From 4574b7f03ccc85c4bebdbfd9475788456086704f Mon Sep 17 00:00:00 2001 From: bobzel Date: Thu, 8 Aug 2024 12:27:40 -0400 Subject: many changes to add typing in place of 'any's etc --- .../views/nodes/formattedText/RichTextRules.ts | 56 ++++++++++------------ 1 file changed, 25 insertions(+), 31 deletions(-) (limited to 'src/client/views/nodes/formattedText/RichTextRules.ts') diff --git a/src/client/views/nodes/formattedText/RichTextRules.ts b/src/client/views/nodes/formattedText/RichTextRules.ts index bf11dfe62..39f589b1e 100644 --- a/src/client/views/nodes/formattedText/RichTextRules.ts +++ b/src/client/views/nodes/formattedText/RichTextRules.ts @@ -1,4 +1,5 @@ import { ellipsis, emDash, InputRule, smartQuotes, textblockTypeInputRule } from 'prosemirror-inputrules'; +import { NodeType } from 'prosemirror-model'; import { NodeSelection, TextSelection } from 'prosemirror-state'; import { ClientUtils } from '../../../../ClientUtils'; import { Doc, DocListCast, FieldResult, StrListCast } from '../../../../fields/Doc'; @@ -6,7 +7,7 @@ import { DocData } from '../../../../fields/DocSymbols'; import { Id } from '../../../../fields/FieldSymbols'; import { List } from '../../../../fields/List'; import { NumCast, StrCast } from '../../../../fields/Types'; -import { Utils } from '../../../../Utils'; +import { emptyFunction, Utils } from '../../../../Utils'; import { Docs } from '../../../documents/Documents'; import { CollectionViewType } from '../../../documents/DocumentTypes'; import { DocUtils } from '../../../documents/DocUtils'; @@ -35,13 +36,7 @@ export class RichTextRules { wrappingInputRule(/%>$/, schema.nodes.blockquote), // 1. create numerical ordered list - wrappingInputRule( - /^1\.\s$/, - schema.nodes.ordered_list, - () => ({ mapStyle: 'decimal', bulletStyle: 1 }), - (match: any, node: any) => node.childCount + node.attrs.order === +match[1], - ((type: any) => ({ type: type, attrs: { mapStyle: 'decimal', bulletStyle: 1 } })) as any - ), + wrappingInputRule(/^1\.\s$/, schema.nodes.ordered_list, () => ({ mapStyle: 'decimal', bulletStyle: 1 }), emptyFunction, ((type: unknown) => ({ type, attrs: { mapStyle: 'decimal', bulletStyle: 1 } })) as unknown as null), // A. create alphabetical ordered list wrappingInputRule( @@ -49,9 +44,8 @@ export class RichTextRules { schema.nodes.ordered_list, // match => { () => ({ mapStyle: 'multi', bulletStyle: 1 }), - // return ({ order: +match[1] }) - (match: any, node: any) => node.childCount + node.attrs.order === +match[1], - ((type: any) => ({ type: type, attrs: { mapStyle: 'multi', bulletStyle: 1 } })) as any + emptyFunction, + ((type: NodeType) => ({ type, attrs: { mapStyle: 'multi', bulletStyle: 1 } })) as unknown as null ), // * + - create bullet list @@ -60,8 +54,8 @@ export class RichTextRules { schema.nodes.ordered_list, // match => { () => ({ mapStyle: 'bullet' }), // ({ order: +match[1] }) - (match: any, node: any) => node.childCount + node.attrs.order === +match[1], - ((type: any) => ({ type: type, attrs: { mapStyle: 'bullet' } })) as any + emptyFunction, + ((type: NodeType) => ({ type: type, attrs: { mapStyle: 'bullet' } })) as unknown as null ), // ``` create code block @@ -93,7 +87,7 @@ export class RichTextRules { const textDoc = this.Document[DocData]; const numInlines = NumCast(textDoc.inlineTextCount); textDoc.inlineTextCount = numInlines + 1; - const node = (state.doc.resolve(start) as any).nodeAfter; + const node = state.doc.resolve(start).nodeAfter; const newNode = schema.nodes.dashComment.create({ docId: doc[Id], reflow: false }); const dashDoc = schema.nodes.dashDoc.create({ width: 75, height: 35, title: 'dashDoc', docId: doc[Id], float: 'right' }); const sm = state.storedMarks || undefined; @@ -137,7 +131,7 @@ export class RichTextRules { textDocInline.proto = textDoc; // make the annotation inherit from the outer text doc so that it can resolve any nested field references, e.g., [[field]] textDoc[inlineLayoutKey] = FormattedTextBox.LayoutString(inlineFieldKey); // create a layout string for the layout key that will render the annotation text textDoc[inlineFieldKey] = ''; // set a default value for the annotation - const node = (state.doc.resolve(start) as any).nodeAfter; + const node = state.doc.resolve(start).nodeAfter; const newNode = schema.nodes.dashComment.create({ docId: textDocInline[Id], reflow: true }); const dashDoc = schema.nodes.dashDoc.create({ width: 75, height: 35, title: 'dashDoc', docId: textDocInline[Id], float: 'right' }); const sm = state.storedMarks || undefined; @@ -154,8 +148,8 @@ export class RichTextRules { // set the First-line indent node type for the selection's paragraph (assumes % was used to initiate an EnteringStyle mode) new InputRule(/(%d|d)$/, (state, match, start, end) => { if (!match[0].startsWith('%') && !this.EnteringStyle) return null; - const pos = state.doc.resolve(start) as any; - for (let depth = pos.path.length / 3 - 1; depth >= 0; depth--) { + const pos = state.doc.resolve(start); + for (let depth = pos.depth; depth >= 0; depth--) { const node = pos.node(depth); if (node.type === schema.nodes.paragraph) { const replaced = state.tr.setNodeMarkup(pos.pos - pos.parentOffset - 1, node.type, { ...node.attrs, indent: node.attrs.indent === 25 ? undefined : 25 }); @@ -169,8 +163,8 @@ export class RichTextRules { // set the Hanging indent node type for the current selection's paragraph (assumes % was used to initiate an EnteringStyle mode) new InputRule(/(%h|h)$/, (state, match, start, end) => { if (!match[0].startsWith('%') && !this.EnteringStyle) return null; - const pos = state.doc.resolve(start) as any; - for (let depth = pos.path.length / 3 - 1; depth >= 0; depth--) { + const pos = state.doc.resolve(start); + for (let depth = pos.depth; depth >= 0; depth--) { const node = pos.node(depth); if (node.type === schema.nodes.paragraph) { const replaced = state.tr.setNodeMarkup(pos.pos - pos.parentOffset - 1, node.type, { ...node.attrs, indent: node.attrs.indent === -25 ? undefined : -25 }); @@ -184,12 +178,12 @@ export class RichTextRules { // set the Quoted indent node type for the current selection's paragraph (assumes % was used to initiate an EnteringStyle mode) new InputRule(/(%q|q)$/, (state, match, start, end) => { if (!match[0].startsWith('%') && !this.EnteringStyle) return null; - const pos = state.doc.resolve(start) as any; + const pos = state.doc.resolve(start); if (state.selection instanceof NodeSelection && state.selection.node.type === schema.nodes.ordered_list) { const { node } = state.selection; return state.tr.setNodeMarkup(pos.pos, node.type, { ...node.attrs, indent: node.attrs.indent === 30 ? undefined : 30 }); } - for (let depth = pos.path.length / 3 - 1; depth >= 0; depth--) { + for (let depth = pos.depth; depth >= 0; depth--) { const node = pos.node(depth); if (node.type === schema.nodes.paragraph) { const replaced = state.tr.setNodeMarkup(pos.pos - pos.parentOffset - 1, node.type, { ...node.attrs, inset: node.attrs.inset === 30 ? undefined : 30 }); @@ -202,9 +196,9 @@ export class RichTextRules { // center justify text new InputRule(/%\^/, (state, match, start, end) => { - const resolved = state.doc.resolve(start) as any; + const resolved = state.doc.resolve(start); if (resolved?.parent.type.name === 'paragraph') { - return state.tr.deleteRange(start, end).setNodeMarkup(resolved.path[resolved.path.length - 4], schema.nodes.paragraph, { ...resolved.parent.attrs, align: 'center' }, resolved.parent.marks); + return state.tr.deleteRange(start, end).setNodeMarkup(resolved.start() - 1, schema.nodes.paragraph, { ...resolved.parent.attrs, align: 'center' }, resolved.parent.marks); } const node = resolved.nodeAfter; const sm = state.storedMarks || undefined; @@ -214,9 +208,9 @@ export class RichTextRules { // left justify text new InputRule(/%\[/, (state, match, start, end) => { - const resolved = state.doc.resolve(start) as any; + const resolved = state.doc.resolve(start); if (resolved?.parent.type.name === 'paragraph') { - return state.tr.deleteRange(start, end).setNodeMarkup(resolved.path[resolved.path.length - 4], schema.nodes.paragraph, { ...resolved.parent.attrs, align: 'left' }, resolved.parent.marks); + return state.tr.deleteRange(start, end).setNodeMarkup(resolved.start() - 1, schema.nodes.paragraph, { ...resolved.parent.attrs, align: 'left' }, resolved.parent.marks); } const node = resolved.nodeAfter; const sm = state.storedMarks || undefined; @@ -226,9 +220,9 @@ export class RichTextRules { // right justify text new InputRule(/%\]/, (state, match, start, end) => { - const resolved = state.doc.resolve(start) as any; + const resolved = state.doc.resolve(start); if (resolved?.parent.type.name === 'paragraph') { - return state.tr.deleteRange(start, end).setNodeMarkup(resolved.path[resolved.path.length - 4], schema.nodes.paragraph, { ...resolved.parent.attrs, align: 'right' }, resolved.parent.marks); + return state.tr.deleteRange(start, end).setNodeMarkup(resolved.start() - 1, schema.nodes.paragraph, { ...resolved.parent.attrs, align: 'right' }, resolved.parent.marks); } const node = resolved.nodeAfter; const sm = state.storedMarks || undefined; @@ -426,9 +420,9 @@ export class RichTextRules { if (state.selection.to === state.selection.from || !this.EnteringStyle) return null; const tag = match[0] === 't' ? 'todo' : match[0] === 'i' ? 'ignore' : match[0] === 'x' ? 'disagree' : match[0] === '!' ? 'important' : '??'; - const node = (state.doc.resolve(start) as any).nodeAfter; + const node = state.doc.resolve(start).nodeAfter; - if (node?.marks.findIndex((m: any) => m.type === schema.marks.user_tag) !== -1) return state.tr.removeMark(start, end, schema.marks.user_tag); + if (node?.marks.findIndex(m => m.type === schema.marks.user_tag) !== -1) return state.tr.removeMark(start, end, schema.marks.user_tag); return node ? state.tr .removeMark(start, end, schema.marks.user_mark) @@ -438,7 +432,7 @@ export class RichTextRules { }), new InputRule(/%\(/, (state, match, start, end) => { - const node = (state.doc.resolve(start) as any).nodeAfter; + const node = state.doc.resolve(start).nodeAfter; const sm = state.storedMarks?.slice() || []; const mark = state.schema.marks.summarizeInclusive.create(); @@ -447,7 +441,7 @@ export class RichTextRules { const content = selected.selection.content(); const replaced = node ? selected.replaceRangeWith(start, end, schema.nodes.summary.create({ visibility: true, text: content, textslice: content.toJSON() })) : state.tr; - return replaced.setSelection(new TextSelection(replaced.doc.resolve(end))).setStoredMarks([...node.marks, ...sm]); + return replaced.setSelection(new TextSelection(replaced.doc.resolve(end))).setStoredMarks([...(node?.marks ?? []), ...sm]); }), new InputRule(/%\)/, (state, match, start, end) => state.tr.deleteRange(start, end).removeStoredMark(state.schema.marks.summarizeInclusive.create())), -- cgit v1.2.3-70-g09d2 From dc7bb2ff07139c45efd3bf66ace0042b76c91ccf Mon Sep 17 00:00:00 2001 From: bobzel Date: Fri, 23 Aug 2024 11:50:49 -0400 Subject: modified keywordsBox to use tags instead data_labels. made text #tags trigger keywords box. made keywords box fit contents. --- src/client/views/DocumentButtonBar.tsx | 10 +---- src/client/views/KeywordBox.tsx | 51 ++++++++++------------ src/client/views/StyleProvider.tsx | 5 +-- .../views/nodes/formattedText/RichTextRules.ts | 3 +- 4 files changed, 30 insertions(+), 39 deletions(-) (limited to 'src/client/views/nodes/formattedText/RichTextRules.ts') diff --git a/src/client/views/DocumentButtonBar.tsx b/src/client/views/DocumentButtonBar.tsx index d42a18e4e..eb157b9ab 100644 --- a/src/client/views/DocumentButtonBar.tsx +++ b/src/client/views/DocumentButtonBar.tsx @@ -282,15 +282,9 @@ export class DocumentButtonBar extends ObservableReactComponent<{ views: () => ( @computed get keywordButton() { - const targetDoc = this.view0?.Document; - return !targetDoc ? null : ( + return !DocumentView.Selected().length ? null : ( Open keyword menu}> -
{ - targetDoc[DocData].showLabels = !targetDoc[DocData].showLabels; - }}> +
DocumentView.Selected().map(dv => (dv.dataDoc.showLabels = !dv.dataDoc.showLabels))}>
diff --git a/src/client/views/KeywordBox.tsx b/src/client/views/KeywordBox.tsx index 703299ae6..1460501db 100644 --- a/src/client/views/KeywordBox.tsx +++ b/src/client/views/KeywordBox.tsx @@ -18,7 +18,7 @@ import { ObservableReactComponent } from './ObservableReactComponent'; interface KeywordItemProps { doc: Doc; keyword: string; - keywordDoc: Doc; + keywordDoc?: Doc; setToEditing: () => void; isEditing: boolean; } @@ -100,29 +100,28 @@ export class KeywordItem extends ObservableReactComponent { } } - if (!doc[DocData].data_labels) doc[DocData].data_labels = new List(); - (doc[DocData].data_labels as List).push(keyword); - doc[DocData][keyword] = true; + if (!doc[DocData].tags) doc[DocData].tags = new List(); + const tagList = doc[DocData].tags as List; + if (!tagList.includes(keyword)) tagList.push(keyword); }; - public static RemoveLabel = (doc: Doc, keyword: string, keywordDoc: Doc) => { - if (doc[DocData].data_labels) { + public static RemoveLabel = (doc: Doc, keyword: string, keywordDoc?: Doc) => { + if (doc[DocData].tags) { if (doc.type === DocumentType.COL) { - Doc.RemoveDocFromList(keywordDoc[DocData], 'collections', doc); + keywordDoc && Doc.RemoveDocFromList(keywordDoc[DocData], 'collections', doc); for (const cur_doc of KeywordItem.allDocsWithKeyword(keyword)) { doc[DocData].data = new List(DocListCast(doc[DocData].data).filter(d => !Doc.AreProtosEqual(cur_doc, d))); } } else { - Doc.RemoveDocFromList(keywordDoc[DocData], 'docs', doc); + keywordDoc && Doc.RemoveDocFromList(keywordDoc[DocData], 'docs', doc); - for (const collection of DocListCast(keywordDoc.collections)) { + for (const collection of DocListCast(keywordDoc?.collections)) { collection[DocData].data = new List(DocListCast(collection[DocData].data).filter(d => !Doc.AreProtosEqual(doc, d))); } } } - doc[DocData].data_labels = new List((doc[DocData].data_labels as List).filter(label => label !== keyword)); - doc[DocData][keyword] = undefined; + doc[DocData].tags = new List((doc[DocData].tags as List).filter(label => label !== keyword)); }; private _ref: React.RefObject; @@ -146,9 +145,9 @@ export class KeywordItem extends ObservableReactComponent { const docData = doc[DocData]; docData.data = new List(newEmbeddings); docData.title = this._props.keyword; - docData.data_labels = new List([this._props.keyword]); - docData[`${this._props.keyword}`] = true; + docData.tags = new List([this._props.keyword]); docData.showLabels = true; + docData.freeform_fitContentsToBox = true; doc._freeform_panX = doc._freeform_panY = 0; doc._width = 900; doc._height = 900; @@ -158,7 +157,7 @@ export class KeywordItem extends ObservableReactComponent { newEmbeddings.forEach(embed => Doc.SetContainer(embed, newCollection)); // Add the collection to the keyword document's list of associated smart collections. - Doc.AddDocToList(this._props.keywordDoc, 'collections', newCollection); + this._props.keywordDoc && Doc.AddDocToList(this._props.keywordDoc, 'collections', newCollection); return newCollection; }; @@ -181,14 +180,15 @@ export class KeywordItem extends ObservableReactComponent { }; render() { - const keyword = this._props.keyword.replace(/^@/, ''); - const metadata = this._props.keyword.startsWith('@'); + setTimeout(() => KeywordItem.addLabelToDoc(this._props.doc, this._props.keyword)); // bcz: hack to make sure that Docs are added to their keyword Doc collection since metadata can get set anywhere without a guard triggering an add to the collection + const keyword = this._props.keyword.replace(/^#/, ''); + const metadata = keyword.startsWith('@') ? keyword.replace(/^@/, '') : ''; return (
{metadata ? ( {keyword}  - {this._props.doc[keyword] as string} + {this._props.doc[metadata] as string} ) : ( keyword @@ -232,7 +232,7 @@ export class KeywordBox extends ObservableReactComponent { } @observable _currentInput = ''; - @observable _isEditing = !StrListCast(this._props.Document[DocData].data_labels).length; + @observable _isEditing = !StrListCast(this._props.Document[DocData].tags).length; @computed get currentScale() { return NumCast((this._props.Document.embedContainer as Doc)?._freeform_scale, 1); @@ -267,14 +267,12 @@ export class KeywordBox extends ObservableReactComponent { */ submitLabel = undoable((keyword: string) => { const submittedLabel = keyword.trim(); - if (submittedLabel && !this._props.Document[DocData][submittedLabel]) { - KeywordItem.addLabelToDoc(this._props.Document, submittedLabel); - this._currentInput = ''; // Clear the input box - } + submittedLabel && KeywordItem.addLabelToDoc(this._props.Document, '#' + submittedLabel.replace(/^#/, '')); + this._currentInput = ''; // Clear the input box }, 'added doc label'); render() { - const keywordsList = StrListCast(this._props.Document[DocData].data_labels); + const keywordsList = StrListCast(this._props.Document[DocData].tags); return !this._props.Document.showLabels ? null : (
{ }}>
- {keywordsList.map(keyword => { - const keywordDoc = KeywordItem.findKeywordCollectionDoc(keyword); - return !keywordDoc ? null : ; - })} + {keywordsList.map(keyword => ( + + ))}
{this.isEditing ? (
diff --git a/src/client/views/StyleProvider.tsx b/src/client/views/StyleProvider.tsx index 1e80e7ee5..0fb4f7dd1 100644 --- a/src/client/views/StyleProvider.tsx +++ b/src/client/views/StyleProvider.tsx @@ -9,9 +9,7 @@ import { BsArrowDown, BsArrowDownUp, BsArrowUp } from 'react-icons/bs'; import { FaFilter } from 'react-icons/fa'; import { ClientUtils, DashColor, lightOrDark } from '../../ClientUtils'; import { Doc, Opt, StrListCast } from '../../fields/Doc'; -import { DocData } from '../../fields/DocSymbols'; import { Id } from '../../fields/FieldSymbols'; -import { List } from '../../fields/List'; import { ScriptField } from '../../fields/ScriptField'; import { BoolCast, Cast, DocCast, ImageCast, NumCast, ScriptCast, StrCast } from '../../fields/Types'; import { AudioAnnoState } from '../../server/SharedMediaTypes'; @@ -22,6 +20,7 @@ import { undoable, UndoManager } from '../util/UndoManager'; import { TreeSort } from './collections/TreeSort'; import { Colors } from './global/globalEnums'; import { KeywordBox } from './KeywordBox'; +import { CollectionFreeFormDocumentView } from './nodes/CollectionFreeFormDocumentView'; import { DocumentView, DocumentViewProps } from './nodes/DocumentView'; import { FieldViewProps } from './nodes/FieldView'; import { StyleProp } from './StyleProp'; @@ -364,7 +363,7 @@ export function DefaultStyleProvider(doc: Opt, props: Opt ); }; - const keywords = () => doc ? : null; + const keywords = () => doc && CollectionFreeFormDocumentView.from(props?.DocumentView?.()) ? : null; return ( <> {paint()} diff --git a/src/client/views/nodes/formattedText/RichTextRules.ts b/src/client/views/nodes/formattedText/RichTextRules.ts index 39f589b1e..79c118490 100644 --- a/src/client/views/nodes/formattedText/RichTextRules.ts +++ b/src/client/views/nodes/formattedText/RichTextRules.ts @@ -396,7 +396,7 @@ export class RichTextRules { }), // create an inline view of a tag stored under the '#' field - new InputRule(/#([a-zA-Z_-]+[a-zA-Z_\-0-9]*)\s$/, (state, match, start, end) => { + new InputRule(/#(@?[a-zA-Z_-]+[a-zA-Z_\-0-9]*)\s$/, (state, match, start, end) => { const tag = match[1]; if (!tag) return state.tr; // this.Document[DocData]['#' + tag] = '#' + tag; @@ -404,6 +404,7 @@ export class RichTextRules { if (!tags.includes(tag)) { tags.push(tag); this.Document[DocData].tags = new List(tags); + this.Document[DocData].showLabels = true; } const fieldView = state.schema.nodes.dashField.create({ fieldKey: '#' + tag }); return state.tr -- cgit v1.2.3-70-g09d2 From a52cda0a098716c24b3c6aafe23d2bd9267c72d9 Mon Sep 17 00:00:00 2001 From: bobzel Date: Mon, 26 Aug 2024 10:43:43 -0400 Subject: moved KeywordsBox to TagsView. changed imagellable to use tags --- src/client/documents/Documents.ts | 8 +- src/client/views/DocumentButtonBar.tsx | 2 +- src/client/views/DocumentDecorations.tsx | 6 +- src/client/views/KeywordBox.tsx | 316 ------------------- src/client/views/StyleProvider.scss | 64 ---- src/client/views/StyleProvider.tsx | 6 +- src/client/views/TagsView.scss | 63 ++++ src/client/views/TagsView.tsx | 344 +++++++++++++++++++++ .../collectionFreeForm/ImageLabelBox.tsx | 58 ++-- src/client/views/nodes/DocumentView.tsx | 1 + .../views/nodes/formattedText/RichTextRules.ts | 2 +- src/fields/Doc.ts | 4 +- src/fields/DocSymbols.ts | 1 - 13 files changed, 447 insertions(+), 428 deletions(-) delete mode 100644 src/client/views/KeywordBox.tsx create mode 100644 src/client/views/TagsView.scss create mode 100644 src/client/views/TagsView.tsx (limited to 'src/client/views/nodes/formattedText/RichTextRules.ts') diff --git a/src/client/documents/Documents.ts b/src/client/documents/Documents.ts index 6b5469cca..c0e4e961c 100644 --- a/src/client/documents/Documents.ts +++ b/src/client/documents/Documents.ts @@ -43,7 +43,7 @@ export class FInfo { description: string = ''; readOnly: boolean = false; fieldType?: FInfoFieldType; - values?: FieldType[] | Map; + values?: FieldType[]; filterable?: boolean = true; // can be used as a Filter in FilterPanel // format?: string; // format to display values (e.g, decimal places, $, etc) @@ -144,10 +144,6 @@ class ListInfo extends FInfo { fieldType? = FInfoFieldType.list; values?: List[] = []; } -class MapInfo extends FInfo { - fieldType? = FInfoFieldType.map; - values?: Map = new Map(); -} type BOOLt = BoolInfo | boolean; type NUMt = NumInfo | number; type STRt = StrInfo | string; @@ -160,7 +156,6 @@ type COLLt = CTypeInfo | CollectionViewType; type DROPt = DAInfo | dropActionType; type DATEt = DateInfo | number; type DTYPEt = DTypeInfo | string; -type MAPt = MapInfo | Map; export class DocumentOptions { // coordinate and dimensions depending on view x?: NUMt = new NumInfo('horizontal coordinate in freeform view', false); @@ -489,7 +484,6 @@ export class DocumentOptions { cardSort?: STRt = new StrInfo('way cards are sorted in deck view'); cardSort_customField?: STRt = new StrInfo('field key used for sorting cards'); cardSort_visibleSortGroups?: List; // which sorting values are being filtered (shown) - keywords?: MAPt = new MapInfo('keywords', true); } export const DocOptions = new DocumentOptions(); diff --git a/src/client/views/DocumentButtonBar.tsx b/src/client/views/DocumentButtonBar.tsx index eb157b9ab..58b7f207c 100644 --- a/src/client/views/DocumentButtonBar.tsx +++ b/src/client/views/DocumentButtonBar.tsx @@ -284,7 +284,7 @@ export class DocumentButtonBar extends ObservableReactComponent<{ views: () => ( get keywordButton() { return !DocumentView.Selected().length ? null : ( Open keyword menu
}> -
DocumentView.Selected().map(dv => (dv.dataDoc.showLabels = !dv.dataDoc.showLabels))}> +
DocumentView.Selected().map(dv => (dv.dataDoc.showTags = !dv.dataDoc.showTags))}>
diff --git a/src/client/views/DocumentDecorations.tsx b/src/client/views/DocumentDecorations.tsx index 19c4a097b..da35459bb 100644 --- a/src/client/views/DocumentDecorations.tsx +++ b/src/client/views/DocumentDecorations.tsx @@ -1,3 +1,4 @@ +import { IconProp } from '@fortawesome/fontawesome-svg-core'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { Tooltip } from '@mui/material'; import { IconButton } from 'browndash-components'; @@ -9,7 +10,7 @@ import { lightOrDark, returnFalse, setupMoveUpEvents } from '../../ClientUtils'; import { Utils, emptyFunction, numberValue } from '../../Utils'; import { DateField } from '../../fields/DateField'; import { Doc, DocListCast, Field, FieldType, HierarchyMapping, ReverseHierarchyMap } from '../../fields/Doc'; -import { AclAdmin, AclAugment, AclEdit, DocData, KeywordsHeight } from '../../fields/DocSymbols'; +import { AclAdmin, AclAugment, AclEdit, DocData } from '../../fields/DocSymbols'; import { Id } from '../../fields/FieldSymbols'; import { InkField } from '../../fields/InkField'; import { ScriptField } from '../../fields/ScriptField'; @@ -34,7 +35,6 @@ import { DocumentView } from './nodes/DocumentView'; import { ImageBox } from './nodes/ImageBox'; import { OpenWhere, OpenWhereMod } from './nodes/OpenWhere'; import { FormattedTextBox } from './nodes/formattedText/FormattedTextBox'; -import { IconProp } from '@fortawesome/fontawesome-svg-core'; interface DocumentDecorationsProps { PanelWidth: number; @@ -835,7 +835,7 @@ export class DocumentDecorations extends ObservableReactComponent DocumentView.Selected()} /> diff --git a/src/client/views/KeywordBox.tsx b/src/client/views/KeywordBox.tsx deleted file mode 100644 index 841c5394b..000000000 --- a/src/client/views/KeywordBox.tsx +++ /dev/null @@ -1,316 +0,0 @@ -import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { Button, Colors, IconButton } from 'browndash-components'; -import { action, computed, makeObservable, observable } from 'mobx'; -import { observer } from 'mobx-react'; -import React from 'react'; -import ResizeObserver from 'resize-observer-polyfill'; -import { returnFalse, setupMoveUpEvents } from '../../ClientUtils'; -import { Utils, emptyFunction } from '../../Utils'; -import { Doc, DocListCast, StrListCast } from '../../fields/Doc'; -import { DocData, KeywordsHeight } from '../../fields/DocSymbols'; -import { List } from '../../fields/List'; -import { NumCast, StrCast } from '../../fields/Types'; -import { DocumentType } from '../documents/DocumentTypes'; -import { DragManager } from '../util/DragManager'; -import { SnappingManager } from '../util/SnappingManager'; -import { undoable } from '../util/UndoManager'; -import { ObservableReactComponent } from './ObservableReactComponent'; -import { DocumentView } from './nodes/DocumentView'; -import { FontIconBox } from './nodes/FontIconBox/FontIconBox'; - -interface KeywordItemProps { - doc: Doc; - keyword: string; - keywordDoc?: Doc; - setToEditing: () => void; - isEditing: boolean; -} - -/** - * A component that handles individual keywords. - */ -@observer -export class KeywordItem extends ObservableReactComponent { - /** - * return list of all Docs that collect Docs with specified keywords - */ - public static get AllKeywordCollections() { - return DocListCast(Doc.ActiveDashboard?.myKeywordCollections); - } - /** - * Find Doc that collects all Docs with given keyword - * @param keyword keyword string - * @returns keyword collection Doc or undefined - */ - public static findKeywordCollectionDoc = (keyword: String) => KeywordItem.AllKeywordCollections.find(doc => doc.title === keyword); - - /** - * Creates a Doc that collects Docs with the specified keyword - * @param keyword keyword string - * @returns collection Doc - */ - public static createKeywordCollectionDoc = (keyword: string) => { - const newKeywordCol = new Doc(); - newKeywordCol.title = keyword; - newKeywordCol.collections = new List(); - newKeywordCol[DocData].docs = new List(); - // If the active Dashboard does not have a keyword collection, create it. - if (Doc.ActiveDashboard) { - if (!Doc.ActiveDashboard.myKeywordCollections) Doc.ActiveDashboard.myKeywordCollections = new List(); - Doc.AddDocToList(Doc.ActiveDashboard, 'myKeywordCollections', newKeywordCol); - } - - return newKeywordCol; - }; - /** - * Gets all Docs that have the specified keyword - * @param keyword keyword string - * @returns An array of documents that contain the keyword. - */ - public static allDocsWithKeyword = (keyword: string) => DocListCast(KeywordItem.findKeywordCollectionDoc(keyword)?.[DocData].docs); - - /** - * Adds a keyword to the metadata of this document - * @param keyword keyword string - */ - public static addLabelToDoc = (doc: Doc, keyword: string) => { - // If the keyword collection is not in active Dashboard, add it as a new doc, with the keyword as its title. - const keywordCollection = KeywordItem.findKeywordCollectionDoc(keyword) ?? KeywordItem.createKeywordCollectionDoc(keyword); - - // If the document is of type COLLECTION, make it a smart collection, otherwise, add the keyword to the document. - if (doc.type === DocumentType.COL) { - Doc.AddDocToList(keywordCollection[DocData], 'collections', doc); - - // Iterate through the keyword Doc collections and add a copy of the document to each collection - for (const cdoc of DocListCast(keywordCollection[DocData].docs)) { - if (!DocListCast(doc[DocData].data).find(d => Doc.AreProtosEqual(d, cdoc))) { - const newEmbedding = Doc.MakeEmbedding(cdoc); - Doc.AddDocToList(doc[DocData], 'data', newEmbedding); - Doc.SetContainer(newEmbedding, doc); - } - } - } else { - // Add this document to the keyword's collection of associated documents. - Doc.AddDocToList(keywordCollection[DocData], 'docs', doc); - - // Iterate through the keyword document's collections and add a copy of the document to each collection - for (const collection of DocListCast(keywordCollection.collections)) { - if (!DocListCast(collection[DocData].data).find(d => Doc.AreProtosEqual(d, doc))) { - const newEmbedding = Doc.MakeEmbedding(doc); - Doc.AddDocToList(collection[DocData], 'data', newEmbedding); - Doc.SetContainer(newEmbedding, collection); - } - } - } - - if (!doc[DocData].tags) doc[DocData].tags = new List(); - const tagList = doc[DocData].tags as List; - if (!tagList.includes(keyword)) tagList.push(keyword); - }; - - public static RemoveLabel = (doc: Doc, keyword: string, keywordDoc?: Doc) => { - if (doc[DocData].tags) { - if (doc.type === DocumentType.COL) { - keywordDoc && Doc.RemoveDocFromList(keywordDoc[DocData], 'collections', doc); - - for (const cur_doc of KeywordItem.allDocsWithKeyword(keyword)) { - doc[DocData].data = new List(DocListCast(doc[DocData].data).filter(d => !Doc.AreProtosEqual(cur_doc, d))); - } - } else { - keywordDoc && Doc.RemoveDocFromList(keywordDoc[DocData], 'docs', doc); - - for (const collection of DocListCast(keywordDoc?.collections)) { - collection[DocData].data = new List(DocListCast(collection[DocData].data).filter(d => !Doc.AreProtosEqual(doc, d))); - } - } - } - doc[DocData].tags = new List((doc[DocData].tags as List).filter(label => label !== keyword)); - }; - - private _ref: React.RefObject; - - constructor(props: any) { - super(props); - makeObservable(this); - this._ref = React.createRef(); - } - - /** - * Creates a smart collection. - * @returns - */ - createCollection = () => { - // Get the documents that contain the keyword. - const newEmbeddings = KeywordItem.allDocsWithKeyword(this._props.keyword).map(doc => Doc.MakeEmbedding(doc)); - - // Create a new collection and set up configurations. - const newCollection = ((doc: Doc) => { - const docData = doc[DocData]; - docData.data = new List(newEmbeddings); - docData.title = this._props.keyword; - docData.tags = new List([this._props.keyword]); - docData.showLabels = true; - docData.freeform_fitContentsToBox = true; - doc._freeform_panX = doc._freeform_panY = 0; - doc._width = 900; - doc._height = 900; - doc.layout_fitWidth = true; - return doc; - })(Doc.MakeCopy(Doc.UserDoc().emptyCollection as Doc, true)); - newEmbeddings.forEach(embed => Doc.SetContainer(embed, newCollection)); - - // Add the collection to the keyword document's list of associated smart collections. - this._props.keywordDoc && Doc.AddDocToList(this._props.keywordDoc, 'collections', newCollection); - return newCollection; - }; - - @action - handleDragStart = (e: React.PointerEvent) => { - setupMoveUpEvents( - this, - e, - () => { - const dragData = new DragManager.DocumentDragData([this.createCollection()]); - DragManager.StartDocumentDrag([this._ref.current!], dragData, e.clientX, e.clientY, {}); - return true; - }, - returnFalse, - emptyFunction - ); - e.preventDefault(); - }; - - render() { - setTimeout(() => KeywordItem.addLabelToDoc(this._props.doc, this._props.keyword)); // bcz: hack to make sure that Docs are added to their keyword Doc collection since metadata can get set anywhere without a guard triggering an add to the collection - const keyword = this._props.keyword.replace(/^#/, ''); - const metadata = keyword.startsWith('@') ? keyword.replace(/^@/, '') : ''; - return ( -
- {metadata ? ( - - {keyword}  - {this._props.doc[metadata] as string} - - ) : ( - keyword - )} - {this.props.isEditing && ( - KeywordItem.RemoveLabel(this._props.doc, this._props.keyword, this._props.keywordDoc), `remove label ${this._props.keyword}`)} - icon={} - style={{ width: '8px', height: '8px', marginLeft: '10px' }} - /> - )} -
- ); - } -} - -interface KeywordBoxProps { - View: DocumentView; -} - -/** - * A component that handles the keyword display for documents. - */ -@observer -export class KeywordBox extends ObservableReactComponent { - private _ref: React.RefObject; - - constructor(props: any) { - super(props); - makeObservable(this); - this._ref = React.createRef(); - } - - @observable _currentInput = ''; - @observable _isEditing = !StrListCast(this._props.View.dataDoc.tags).length; - - @computed get currentScale() { - return NumCast((this._props.View.Document.embedContainer as Doc)?._freeform_scale, 1); - } - @computed get isEditing() { - return this._isEditing && DocumentView.SelectedDocs().includes(this._props.View.Document); - } - - @action - setToEditing = (editing = true) => { - this._isEditing = editing; - editing && this._props.View.select(false); - }; - - /** - * Adds the keyword to the document. - * @param keyword - */ - submitLabel = undoable((keyword: string) => { - const submittedLabel = keyword.trim(); - submittedLabel && KeywordItem.addLabelToDoc(this._props.View.Document, '#' + submittedLabel.replace(/^#/, '')); - this._currentInput = ''; // Clear the input box - }, 'added doc label'); - - render() { - const keywordsList = StrListCast(this._props.View.dataDoc.tags); - - return !this._props.View.Document.showLabels ? null : ( -
r && new ResizeObserver(action(() => (this._props.View.Document[KeywordsHeight] = r?.getBoundingClientRect().height ?? 0))).observe(r)} - style={{ - transformOrigin: 'top left', - maxWidth: `${100 * this.currentScale}%`, - width: 'max-content', - transform: `scale(${1 / this.currentScale})`, - backgroundColor: this.isEditing ? Colors.LIGHT_GRAY : Colors.TRANSPARENT, - borderColor: this.isEditing ? Colors.BLACK : Colors.TRANSPARENT, - }}> -
-
- {!keywordsList.length ? null : ( // - this.setToEditing(!this._isEditing)} icon={} /> - )} - {keywordsList.map(keyword => ( - - ))} -
- {this.isEditing ? ( -
-
- (this._currentInput = e.target.value))} - onKeyDown={e => { - e.key === 'Enter' ? this.submitLabel(this._currentInput) : null; - e.stopPropagation(); - }} - type="text" - placeholder="Input keywords for document..." - aria-label="keyword-input" - className="keyword-input" - style={{ width: '100%', borderRadius: '5px' }} - /> -
-
- {KeywordItem.AllKeywordCollections.map(doc => { - const keyword = StrCast(doc.title); - return ( -
-
- ) : null} -
-
- ); - } -} diff --git a/src/client/views/StyleProvider.scss b/src/client/views/StyleProvider.scss index 6f6939d54..ce00f6101 100644 --- a/src/client/views/StyleProvider.scss +++ b/src/client/views/StyleProvider.scss @@ -53,67 +53,3 @@ .styleProvider-treeView-icon { opacity: 0; } - -.keywords-container { - display: flex; - flex-wrap: wrap; - flex-direction: column; - border: 1px solid; - border-radius: 4px; -} - -.keywords-list { - display: flex; - flex-wrap: wrap; - .iconButton-container { - min-height: unset !important; - } -} - -.keyword { - padding: 1px 5px; - background-color: lightblue; - border: 1px solid black; - border-radius: 5px; - white-space: nowrap; - display: flex; - align-items: center; -} - -.keyword-suggestions-box { - display: flex; - flex-wrap: wrap; - margin: auto; - align-self: center; - width: 90%; - border: 1px solid black; - border-radius: 2px; - margin-top: 8px; -} - -.keyword-suggestion { - cursor: pointer; - padding: 1px 1px; - margin: 2px 2px; - background-color: lightblue; - border: 1px solid black; - border-radius: 5px; - white-space: nowrap; - display: flex; - align-items: center; -} - -.keyword-editing-box { - margin-top: 8px; -} - -.keyword-input-box { - margin: auto; - align-self: center; - width: 90%; -} - -.keyword-buttons { - margin-left: auto; - width: 10%; -} diff --git a/src/client/views/StyleProvider.tsx b/src/client/views/StyleProvider.tsx index a841ec63a..e48994586 100644 --- a/src/client/views/StyleProvider.tsx +++ b/src/client/views/StyleProvider.tsx @@ -19,7 +19,7 @@ import { SnappingManager } from '../util/SnappingManager'; import { undoable, UndoManager } from '../util/UndoManager'; import { TreeSort } from './collections/TreeSort'; import { Colors } from './global/globalEnums'; -import { KeywordBox } from './KeywordBox'; +import { TagsView } from './TagsView'; import { CollectionFreeFormDocumentView } from './nodes/CollectionFreeFormDocumentView'; import { DocumentView, DocumentViewProps } from './nodes/DocumentView'; import { FieldViewProps } from './nodes/FieldView'; @@ -363,14 +363,14 @@ export function DefaultStyleProvider(doc: Opt, props: Opt ); }; - const keywords = () => props?.DocumentView?.() && CollectionFreeFormDocumentView.from(props.DocumentView()) ? : null; + const tags = () => props?.DocumentView?.() && CollectionFreeFormDocumentView.from(props.DocumentView()) ? : null; return ( <> {paint()} {lock()} {filter()} {audio()} - {keywords()} + {tags()} ); } diff --git a/src/client/views/TagsView.scss b/src/client/views/TagsView.scss new file mode 100644 index 000000000..f7365a51b --- /dev/null +++ b/src/client/views/TagsView.scss @@ -0,0 +1,63 @@ +.tagsView-container { + display: flex; + flex-wrap: wrap; + flex-direction: column; + border: 1px solid; + border-radius: 4px; +} + +.tagsView-list { + display: flex; + flex-wrap: wrap; + .iconButton-container { + min-height: unset !important; + } +} + +.tagItem { + padding: 1px 5px; + background-color: lightblue; + border: 1px solid black; + border-radius: 5px; + white-space: nowrap; + display: flex; + align-items: center; +} + +.tagsView-suggestions-box { + display: flex; + flex-wrap: wrap; + margin: auto; + align-self: center; + width: 90%; + border: 1px solid black; + border-radius: 2px; + margin-top: 8px; +} + +.tagsView-suggestion { + cursor: pointer; + padding: 1px 1px; + margin: 2px 2px; + background-color: lightblue; + border: 1px solid black; + border-radius: 5px; + white-space: nowrap; + display: flex; + align-items: center; +} + +.tagsView-editing-box { + margin-top: 8px; +} + +.tagsView-input-box { + margin: auto; + align-self: center; + width: 90%; +} + +.tagsView-buttons { + margin-left: auto; + width: 10%; +} diff --git a/src/client/views/TagsView.tsx b/src/client/views/TagsView.tsx new file mode 100644 index 000000000..dffd1e096 --- /dev/null +++ b/src/client/views/TagsView.tsx @@ -0,0 +1,344 @@ +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { Button, Colors, IconButton } from 'browndash-components'; +import { action, computed, makeObservable, observable } from 'mobx'; +import { observer } from 'mobx-react'; +import React from 'react'; +import ResizeObserver from 'resize-observer-polyfill'; +import { returnFalse, setupMoveUpEvents } from '../../ClientUtils'; +import { emptyFunction } from '../../Utils'; +import { Doc, DocListCast, Opt, StrListCast } from '../../fields/Doc'; +import { DocData } from '../../fields/DocSymbols'; +import { List } from '../../fields/List'; +import { NumCast, StrCast } from '../../fields/Types'; +import { DocumentType } from '../documents/DocumentTypes'; +import { DragManager } from '../util/DragManager'; +import { SnappingManager } from '../util/SnappingManager'; +import { undoable } from '../util/UndoManager'; +import { ObservableReactComponent } from './ObservableReactComponent'; +import './TagsView.scss'; +import { DocumentView } from './nodes/DocumentView'; + +/** + * The TagsView is a metadata input/display panel shown at the bottom of a DocumentView in a freeform collection. + * + * This panel allow sthe user to add metadata tags to a Doc, and to display those tags, or any metadata field + * in a panel of 'buttons' (TagItems) just below the DocumentView. TagItems are interactive - + * the user can drag them off in order to display a collection of all documents that share the tag value. + * + * The tags that are added using the panel are the same as the #tags that can entered in a text Doc. + * Note that tags starting with #@ display a metadata key/value pair instead of the tag itself. + * e.g., '#@author' shows the document author + * + */ + +interface TagItemProps { + doc: Doc; + tag: string; + tagDoc: Opt; + showRemoveUI: boolean; + setToEditing: () => void; +} + +/** + * Interactive component that display a single metadata tag or value. + * + * These items can be dragged and dropped to create a collection of Docs that + * share the same metadata tag / value. + */ +@observer +export class TagItem extends ObservableReactComponent { + /** + * return list of all tag Docs (ie, Doc that are collections of Docs sharing a specific tag / value) + */ + public static get AllTagCollectionDocs() { + return DocListCast(Doc.ActiveDashboard?.myTagCollections); + } + /** + * Find tag Doc that collects all Docs with given tag / value + * @param tag tag string + * @returns tag collection Doc or undefined + */ + public static findTagCollectionDoc = (tag: String) => TagItem.AllTagCollectionDocs.find(doc => doc.title === tag); + + /** + * Creates a Doc that collects Docs with the specified tag / value + * @param tag tag string + * @returns tag collection Doc + */ + public static createTagCollectionDoc = (tag: string) => { + const newTagCol = new Doc(); + newTagCol.title = tag; + newTagCol.collections = new List(); + newTagCol[DocData].docs = new List(); + // If the active Dashboard does not have a tag Doc collection, create it. + if (Doc.ActiveDashboard) { + if (!Doc.ActiveDashboard.myTagCollections) Doc.ActiveDashboard.myTagCollections = new List(); + Doc.AddDocToList(Doc.ActiveDashboard, 'myTagCollections', newTagCol); + } + + return newTagCol; + }; + /** + * Gets all Docs that have the specified tag / value + * @param tag tag string + * @returns An array of documents that contain the tag. + */ + public static allDocsWithTag = (tag: string) => DocListCast(TagItem.findTagCollectionDoc(tag)?.[DocData].docs); + + /** + * Adds a tag to the metadata of this document and adds the Doc to the corresponding tag collection Doc (or creates it) + * @param tag tag string + */ + public static addTagToDoc = (doc: Doc, tag: string) => { + // If the tag collection is not in active Dashboard, add it as a new doc, with the tag as its title. + const tagCollection = TagItem.findTagCollectionDoc(tag) ?? TagItem.createTagCollectionDoc(tag); + + // If the document is of type COLLECTION, make it a smart collection, otherwise, add the tag to the document. + if (doc.type === DocumentType.COL) { + Doc.AddDocToList(tagCollection[DocData], 'collections', doc); + + // Iterate through the tag Doc collections and add a copy of the document to each collection + for (const cdoc of DocListCast(tagCollection[DocData].docs)) { + if (!DocListCast(doc[DocData].data).find(d => Doc.AreProtosEqual(d, cdoc))) { + const newEmbedding = Doc.MakeEmbedding(cdoc); + Doc.AddDocToList(doc[DocData], 'data', newEmbedding); + Doc.SetContainer(newEmbedding, doc); + } + } + } else { + // Add this document to the tag's collection of associated documents. + Doc.AddDocToList(tagCollection[DocData], 'docs', doc); + + // Iterate through the tag document's collections and add a copy of the document to each collection + for (const collection of DocListCast(tagCollection.collections)) { + if (!DocListCast(collection[DocData].data).find(d => Doc.AreProtosEqual(d, doc))) { + const newEmbedding = Doc.MakeEmbedding(doc); + Doc.AddDocToList(collection[DocData], 'data', newEmbedding); + Doc.SetContainer(newEmbedding, collection); + } + } + } + + if (!doc[DocData].tags) doc[DocData].tags = new List(); + const tagList = doc[DocData].tags as List; + if (!tagList.includes(tag)) tagList.push(tag); + }; + + /** + * Removes a tag from a Doc and removes the Doc from the corresponding tag collection Doc + * @param doc Doc to add tag + * @param tag tag string + * @param tagDoc doc that collections the Docs with the tag + */ + public static removeTagFromDoc = (doc: Doc, tag: string, tagDoc?: Doc) => { + if (doc[DocData].tags) { + if (doc.type === DocumentType.COL) { + tagDoc && Doc.RemoveDocFromList(tagDoc[DocData], 'collections', doc); + + for (const cur_doc of TagItem.allDocsWithTag(tag)) { + doc[DocData].data = new List(DocListCast(doc[DocData].data).filter(d => !Doc.AreProtosEqual(cur_doc, d))); + } + } else { + tagDoc && Doc.RemoveDocFromList(tagDoc[DocData], 'docs', doc); + + for (const collection of DocListCast(tagDoc?.collections)) { + collection[DocData].data = new List(DocListCast(collection[DocData].data).filter(d => !Doc.AreProtosEqual(doc, d))); + } + } + } + doc[DocData].tags = new List((doc[DocData].tags as List).filter(label => label !== tag)); + }; + + private _ref: React.RefObject; + + constructor(props: any) { + super(props); + makeObservable(this); + this._ref = React.createRef(); + } + + /** + * Creates a smart collection. + * @returns + */ + createTagCollection = () => { + // Get the documents that contain the tag. + const newEmbeddings = TagItem.allDocsWithTag(this._props.tag).map(doc => Doc.MakeEmbedding(doc)); + + // Create a new collection and set up configurations. + const newCollection = ((doc: Doc) => { + const docData = doc[DocData]; + docData.data = new List(newEmbeddings); + docData.title = this._props.tag; + docData.tags = new List([this._props.tag]); + docData.showTags = true; + docData.freeform_fitContentsToBox = true; + doc._freeform_panX = doc._freeform_panY = 0; + doc._width = 900; + doc._height = 900; + doc.layout_fitWidth = true; + return doc; + })(Doc.MakeCopy(Doc.UserDoc().emptyCollection as Doc, true)); + newEmbeddings.forEach(embed => Doc.SetContainer(embed, newCollection)); + + // Add the collection to the tag document's list of associated smart collections. + this._props.tagDoc && Doc.AddDocToList(this._props.tagDoc, 'collections', newCollection); + return newCollection; + }; + + @action + handleDragStart = (e: React.PointerEvent) => { + setupMoveUpEvents( + this, + e, + () => { + const dragData = new DragManager.DocumentDragData([this.createTagCollection()]); + DragManager.StartDocumentDrag([this._ref.current!], dragData, e.clientX, e.clientY, {}); + return true; + }, + returnFalse, + emptyFunction + ); + e.preventDefault(); + }; + + render() { + setTimeout(() => TagItem.addTagToDoc(this._props.doc, this._props.tag)); // bcz: hack to make sure that Docs are added to their tag Doc collection since metadata can get set anywhere without a guard triggering an add to the collection + const tag = this._props.tag.replace(/^#/, ''); + const metadata = tag.startsWith('@') ? tag.replace(/^@/, '') : ''; + return ( +
+ {metadata ? ( + + {tag}  + {this._props.doc[metadata] as string} + + ) : ( + tag + )} + {this.props.showRemoveUI && ( + TagItem.removeTagFromDoc(this._props.doc, this._props.tag, this._props.tagDoc), `remove tag ${this._props.tag}`)} + icon={} + style={{ width: '8px', height: '8px', marginLeft: '10px' }} + /> + )} +
+ ); + } +} + +interface TagViewProps { + View: DocumentView; +} + +/** + * Displays a panel of tags that have been added to a Doc. Also allows for editing the applied tags through a dropdown UI. + */ +@observer +export class TagsView extends ObservableReactComponent { + private _ref: React.RefObject; + + constructor(props: any) { + super(props); + makeObservable(this); + this._ref = React.createRef(); + } + + @observable _currentInput = ''; + @observable _isEditing = !StrListCast(this._props.View.dataDoc.tags).length; + + @computed get currentScale() { + return NumCast((this._props.View.Document.embedContainer as Doc)?._freeform_scale, 1); + } + @computed get isEditing() { + return this._isEditing && DocumentView.SelectedDocs().includes(this._props.View.Document); + } + + @action + setToEditing = (editing = true) => { + this._isEditing = editing; + editing && this._props.View.select(false); + }; + + /** + * Adds the specified tag to the Doc. If the tag is not prefixed with '#', then a '#' prefix is added. + * Whne the tag (after the '#') begins with '@', then a metadata key/value pair is displayed instead of + * just the tag. + * @param tag tag string to add + */ + submitTag = undoable((tag: string) => { + const submittedLabel = tag.trim(); + submittedLabel && TagItem.addTagToDoc(this._props.View.Document, '#' + submittedLabel.replace(/^#/, '')); + this._currentInput = ''; // Clear the input box + }, 'added doc label'); + + /** + * When 'showTags' is set on a Doc, this displays a wrapping panel of tagItemViews corresponding to all the tags set on the Doc). + * When the dropdown is clicked, this will toggle an extended UI that allows additional tags to be added/removed. + */ + render() { + const tagsList = StrListCast(this._props.View.dataDoc.tags); + + return !this._props.View.Document.showTags ? null : ( +
r && new ResizeObserver(action(() => (this._props.View.TagPanelHeight = r?.getBoundingClientRect().height ?? 0))).observe(r)} + style={{ + transformOrigin: 'top left', + maxWidth: `${100 * this.currentScale}%`, + width: 'max-content', + transform: `scale(${1 / this.currentScale})`, + backgroundColor: this.isEditing ? Colors.LIGHT_GRAY : Colors.TRANSPARENT, + borderColor: this.isEditing ? Colors.BLACK : Colors.TRANSPARENT, + }}> +
+
+ {!tagsList.length ? null : ( // + this.setToEditing(!this._isEditing)} icon={} /> + )} + {tagsList.map(tag => ( + + ))} +
+ {this.isEditing ? ( +
+
+ (this._currentInput = e.target.value))} + onKeyDown={e => { + e.key === 'Enter' ? this.submitTag(this._currentInput) : null; + e.stopPropagation(); + }} + type="text" + placeholder="Input tags for document..." + aria-label="tagsView-input" + className="tagsView-input" + style={{ width: '100%', borderRadius: '5px' }} + /> +
+
+ {TagItem.AllTagCollectionDocs.map((doc, i) => { + const tag = StrCast(doc.title); + return ( +
+
+ ) : null} +
+
+ ); + } +} diff --git a/src/client/views/collections/collectionFreeForm/ImageLabelBox.tsx b/src/client/views/collections/collectionFreeForm/ImageLabelBox.tsx index 421b5d0a6..6eb3eb784 100644 --- a/src/client/views/collections/collectionFreeForm/ImageLabelBox.tsx +++ b/src/client/views/collections/collectionFreeForm/ImageLabelBox.tsx @@ -1,30 +1,30 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { Colors, IconButton } from 'browndash-components'; +import similarity from 'compute-cosine-similarity'; +import { ring } from 'ldrs'; +import 'ldrs/ring'; import { action, computed, makeObservable, observable, reaction } from 'mobx'; import { observer } from 'mobx-react'; import React from 'react'; +import { Utils, numberRange } from '../../../../Utils'; import { Doc, NumListCast, Opt } from '../../../../fields/Doc'; -import { Docs } from '../../../documents/Documents'; -import { DocumentType } from '../../../documents/DocumentTypes'; -import { ViewBoxBaseComponent } from '../../DocComponent'; -import { FieldView, FieldViewProps } from '../../nodes/FieldView'; -import { MarqueeOptionsMenu } from './MarqueeOptionsMenu'; -import './ImageLabelBox.scss'; -import { MainView } from '../../MainView'; -import 'ldrs/ring'; -import { ring } from 'ldrs'; -import { SnappingManager } from '../../../util/SnappingManager'; -import { ImageCast } from '../../../../fields/Types'; import { DocData } from '../../../../fields/DocSymbols'; -import { SettingsManager } from '../../../util/SettingsManager'; -import { CollectionCardView } from '../CollectionCardDeckView'; -import { gptGetEmbedding, gptImageLabel } from '../../../apis/gpt/GPT'; -import { numberRange, Utils } from '../../../../Utils'; import { List } from '../../../../fields/List'; +import { ImageCast } from '../../../../fields/Types'; +import { gptGetEmbedding, gptImageLabel } from '../../../apis/gpt/GPT'; +import { DocumentType } from '../../../documents/DocumentTypes'; +import { Docs } from '../../../documents/Documents'; import { DragManager } from '../../../util/DragManager'; -import { OpenWhere } from '../../nodes/OpenWhere'; -import similarity from 'compute-cosine-similarity'; +import { SettingsManager } from '../../../util/SettingsManager'; +import { SnappingManager } from '../../../util/SnappingManager'; +import { ViewBoxBaseComponent } from '../../DocComponent'; +import { MainView } from '../../MainView'; import { DocumentView } from '../../nodes/DocumentView'; +import { FieldView, FieldViewProps } from '../../nodes/FieldView'; +import { OpenWhere } from '../../nodes/OpenWhere'; +import { CollectionCardView } from '../CollectionCardDeckView'; +import './ImageLabelBox.scss'; +import { MarqueeOptionsMenu } from './MarqueeOptionsMenu'; export class ImageInformationItem {} @@ -139,9 +139,9 @@ export class ImageLabelBox extends ViewBoxBaseComponent() { toggleDisplayInformation = () => { this._displayImageInformation = !this._displayImageInformation; if (this._displayImageInformation) { - this._selectedImages.forEach(doc => (doc[DocData].showLabels = true)); + this._selectedImages.forEach(doc => (doc[DocData].showTags = true)); } else { - this._selectedImages.forEach(doc => (doc[DocData].showLabels = false)); + this._selectedImages.forEach(doc => (doc[DocData].showTags = false)); } }; @@ -163,7 +163,7 @@ export class ImageLabelBox extends ViewBoxBaseComponent() { // Converts the images into a Base64 format, afterwhich the information is sent to GPT to label them. const imageInfos = this._selectedImages.map(async doc => { - if (!doc[DocData].data_labels) { + if (!doc[DocData].tags) { const [name, type] = ImageCast(doc[Doc.LayoutFieldKey(doc)]).url.href.split('.'); return CollectionCardView.imageUrlToBase64(`${name}_o.${type}`).then(hrefBase64 => !hrefBase64 ? undefined : @@ -174,14 +174,14 @@ export class ImageLabelBox extends ViewBoxBaseComponent() { (await Promise.all(imageInfos)).forEach(imageInfo => { if (imageInfo) { - imageInfo.doc[DocData].data_labels = new List(); + imageInfo.doc[DocData].tags = (imageInfo.doc[DocData].tags as List) ?? new List(); const labels = imageInfo.labels.split('\n'); labels.forEach(label => { - label = label.replace(/^\d+\.\s*|-|\*/, '').trim(); + label = label.replace(/^\d+\.\s*|-|f\*/, '').trim(); console.log(label); - imageInfo.doc[DocData][`${label}`] = true; - (imageInfo.doc[DocData].data_labels as List).push(label); + imageInfo.doc[DocData][label] = true; + (imageInfo.doc[DocData].tags as List).push(label); }); } }); @@ -196,10 +196,10 @@ export class ImageLabelBox extends ViewBoxBaseComponent() { this.startLoading(); for (const doc of this._selectedImages) { - for (let index = 0; index < (doc[DocData].data_labels as List).length; index++) { - const label = (doc[DocData].data_labels as List)[index]; + for (let index = 0; index < (doc[DocData].tags as List).length; index++) { + const label = (doc[DocData].tags as List)[index]; const embedding = await gptGetEmbedding(label); - doc[DocData][`data_labels_embedding_${index + 1}`] = new List(embedding); + doc[DocData][`tags_embedding_${index + 1}`] = new List(embedding); } } @@ -210,7 +210,7 @@ export class ImageLabelBox extends ViewBoxBaseComponent() { // For each image, loop through the labels, and calculate similarity. Associate it with the // most similar one. this._selectedImages.forEach(doc => { - const embedLists = numberRange((doc[DocData].data_labels as List).length).map(n => Array.from(NumListCast(doc[DocData][`data_labels_embedding_${n + 1}`]))); + const embedLists = numberRange((doc[DocData].tags as List).length).map(n => Array.from(NumListCast(doc[DocData][`tags_embedding_${n + 1}`]))); const bestEmbedScore = (embedding: Opt) => Math.max(...embedLists.map((l, index) => (embedding && similarity(Array.from(embedding), l)!) || 0)); const {label: mostSimilarLabelCollect} = this._labelGroups.map(label => ({ label, similarityScore: bestEmbedScore(labelToEmbedding.get(label)) })) @@ -317,7 +317,7 @@ export class ImageLabelBox extends ViewBoxBaseComponent() { await DocumentView.showDocument(doc, { willZoomCentered: true }); }}>
this._props.addDocTab(doc, OpenWhere.addRightKeyvalue)}> - {(doc[DocData].data_labels as List).map(label => { + {(doc[DocData].tags as List).map(label => { return (
{label} diff --git a/src/client/views/nodes/DocumentView.tsx b/src/client/views/nodes/DocumentView.tsx index 5efdb3df4..4c357cf45 100644 --- a/src/client/views/nodes/DocumentView.tsx +++ b/src/client/views/nodes/DocumentView.tsx @@ -1124,6 +1124,7 @@ export class DocumentView extends DocComponent() { @observable private _isHovering = false; @observable private _selected = false; @observable public static CurrentlyPlaying: DocumentView[] = []; // audio or video media views that are currently playing + @observable public TagPanelHeight = 0; @computed private get shouldNotScale() { return (this.layout_fitWidth && !this.nativeWidth) || this.ComponentView?.isUnstyledView?.(); diff --git a/src/client/views/nodes/formattedText/RichTextRules.ts b/src/client/views/nodes/formattedText/RichTextRules.ts index 79c118490..e0d6c7c05 100644 --- a/src/client/views/nodes/formattedText/RichTextRules.ts +++ b/src/client/views/nodes/formattedText/RichTextRules.ts @@ -404,7 +404,7 @@ export class RichTextRules { if (!tags.includes(tag)) { tags.push(tag); this.Document[DocData].tags = new List(tags); - this.Document[DocData].showLabels = true; + this.Document[DocData].showTags = true; } const fieldView = state.schema.nodes.dashField.create({ fieldKey: '#' + tag }); return state.tr diff --git a/src/fields/Doc.ts b/src/fields/Doc.ts index f246f49e5..ffb5aab79 100644 --- a/src/fields/Doc.ts +++ b/src/fields/Doc.ts @@ -11,7 +11,7 @@ import { ClientUtils, incrementTitleCopy } from '../ClientUtils'; import { AclAdmin, AclAugment, AclEdit, AclPrivate, AclReadonly, Animation, AudioPlay, Brushed, CachedUpdates, DirectLinks, DocAcl, DocCss, DocData, DocLayout, DocViews, FieldKeys, FieldTuples, ForceServerWrite, Height, Highlight, - Initializing, KeywordsHeight, Self, SelfProxy, TransitionTimer, UpdatingFromServer, Width + Initializing, Self, SelfProxy, TransitionTimer, UpdatingFromServer, Width } from './DocSymbols'; // prettier-ignore import { Copy, FieldChanged, HandleUpdate, Id, Parent, ToJavascriptString, ToScriptString, ToString } from './FieldSymbols'; import { InkTool } from './InkField'; @@ -303,7 +303,6 @@ export class Doc extends RefField { Height, Highlight, Initializing, - KeywordsHeight, Self, SelfProxy, UpdatingFromServer, @@ -369,7 +368,6 @@ export class Doc extends RefField { @observable public [Highlight]: boolean = false; @observable public [Brushed]: boolean = false; @observable public [DocViews] = new ObservableSet(); - @observable public [KeywordsHeight]: number = 0; private [Self] = this; private [SelfProxy]: Doc; diff --git a/src/fields/DocSymbols.ts b/src/fields/DocSymbols.ts index 9e091ab29..dc18d8638 100644 --- a/src/fields/DocSymbols.ts +++ b/src/fields/DocSymbols.ts @@ -32,6 +32,5 @@ export const DocViews = Symbol('DocViews'); export const Brushed = Symbol('DocBrushed'); export const DocCss = Symbol('DocCss'); export const TransitionTimer = Symbol('DocTransitionTimer'); -export const KeywordsHeight = Symbol('DocKeywordsHeight'); export const DashVersion = 'v0.8.0'; -- cgit v1.2.3-70-g09d2