/* eslint-disable no-use-before-define */ import { IconProp } from '@fortawesome/fontawesome-svg-core'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { Tooltip } from '@mui/material'; import { action, computed, IReactionDisposer, makeObservable, observable, ObservableSet, reaction } from 'mobx'; import { observer } from 'mobx-react'; import { baseKeymap, selectAll } from 'prosemirror-commands'; import { history } from 'prosemirror-history'; import { inputRules } from 'prosemirror-inputrules'; import { keymap } from 'prosemirror-keymap'; import { Fragment, Mark, Node, Slice } from 'prosemirror-model'; import { EditorState, NodeSelection, Plugin, Selection, TextSelection, Transaction } from 'prosemirror-state'; import { EditorView, NodeViewConstructor } from 'prosemirror-view'; import * as React from 'react'; import { BsMarkdownFill } from 'react-icons/bs'; import { addStyleSheet, addStyleSheetRule, clearStyleSheetRules, ClientUtils, DivWidth, imageUrlToBase64, returnFalse, returnZero, setupMoveUpEvents, simMouseEvent, smoothScroll, StopEvent } from '../../../../ClientUtils'; import { DateField } from '../../../../fields/DateField'; import { CreateLinkToActiveAudio, Doc, DocListCast, Field, FieldType, Opt, StrListCast } from '../../../../fields/Doc'; import { AclAdmin, AclAugment, AclEdit, AclSelfEdit, DocCss, DocData, ForceServerWrite, UpdatingFromServer } from '../../../../fields/DocSymbols'; import { Id } from '../../../../fields/FieldSymbols'; import { InkTool } from '../../../../fields/InkField'; import { List } from '../../../../fields/List'; import { PrefetchProxy } from '../../../../fields/Proxy'; import { RichTextField } from '../../../../fields/RichTextField'; import { ComputedField } from '../../../../fields/ScriptField'; import { BoolCast, Cast, DateCast, DocCast, FieldValue, NumCast, RTFCast, ScriptCast, StrCast } from '../../../../fields/Types'; import { GetEffectiveAcl, TraceMobx } from '../../../../fields/util'; import { emptyFunction, numberRange, unimplementedFunction, Utils } from '../../../../Utils'; import { gptAPICall, GPTCallType, gptImageLabel } from '../../../apis/gpt/GPT'; import { DocServer } from '../../../DocServer'; import { Docs } from '../../../documents/Documents'; import { CollectionViewType, DocumentType } from '../../../documents/DocumentTypes'; import { DocUtils } from '../../../documents/DocUtils'; import { DictationManager } from '../../../util/DictationManager'; import { DragManager } from '../../../util/DragManager'; import { dropActionType } from '../../../util/DropActionTypes'; import { MakeTemplate } from '../../../util/DropConverter'; import { LinkManager } from '../../../util/LinkManager'; import { RTFMarkup } from '../../../util/RTFMarkup'; import { SnappingManager } from '../../../util/SnappingManager'; import { undoable, undoBatch, UndoManager } from '../../../util/UndoManager'; import { CollectionStackingView } from '../../collections/CollectionStackingView'; import { CollectionTreeView } from '../../collections/CollectionTreeView'; import { ContextMenu } from '../../ContextMenu'; import { ContextMenuProps } from '../../ContextMenuItem'; import { ViewBoxAnnotatableComponent } from '../../DocComponent'; import { Colors } from '../../global/globalEnums'; import { AnchorMenu } from '../../pdf/AnchorMenu'; import { GPTPopup } from '../../pdf/GPTPopup/GPTPopup'; import { PinDocView, PinProps } from '../../PinFuncs'; import { SidebarAnnos } from '../../SidebarAnnos'; import { StyleProp } from '../../StyleProp'; import { styleFromLayoutString } from '../../StyleProvider'; import { mediaState } from '../AudioBox'; import { DocumentView } from '../DocumentView'; import { FieldView, FieldViewProps } from '../FieldView'; import { FocusViewOptions } from '../FocusViewOptions'; import { LinkInfo } from '../LinkDocPreview'; import { OpenWhere } from '../OpenWhere'; import './FormattedTextBox.scss'; import { findLinkMark, FormattedTextBoxComment } from './FormattedTextBoxComment'; import { buildKeymap, updateBullets } from './ProsemirrorExampleTransfer'; import { removeMarkWithAttrs } from './prosemirrorPatches'; import { RichTextMenu, RichTextMenuPlugin } from './RichTextMenu'; import { RichTextRules } from './RichTextRules'; import { schema } from './schema_rts'; import { Property } from 'csstype'; import { LabelBox } from '../LabelBox'; import { StickerPalette } from '../../smartdraw/StickerPalette'; // import * as applyDevTools from 'prosemirror-dev-tools'; export interface FormattedTextBoxProps extends FieldViewProps { onBlur?: () => void; // callback when text loses focus autoFocus?: boolean; // whether text should get input focus when created } @observer export class FormattedTextBox extends ViewBoxAnnotatableComponent() { public static LayoutString(fieldStr: string) { return FieldView.LayoutString(FormattedTextBox, fieldStr); } public static MakeConfig(rules?: RichTextRules, props?: FormattedTextBoxProps) { return { schema, plugins: [ inputRules(rules?.inpRules ?? { rules: [] }), ...(props ? [FormattedTextBox.richTextMenuPlugin(props)] : []), history(), keymap(buildKeymap(schema, props ?? {})), keymap(baseKeymap), new Plugin({ props: { attributes: { class: 'ProseMirror-example-setup-style' } } }), new Plugin({ view: () => new FormattedTextBoxComment() }), ], }; } /** * Initialize the class with all the plugin node view components * @param nodeViews prosemirror plugins that render a custom UI for specific node types */ public static Init(nodeViews: (self: FormattedTextBox) => { [key: string]: NodeViewConstructor }) { FormattedTextBox._nodeViews = nodeViews; } // prettier-ignore public static PasteOnLoad: ClipboardEvent | undefined; public static DontSelectInitialText = false; // whether initial text should be selected or not public static SelectOnLoadChar = ''; public static LiveTextUndo: UndoManager.Batch | undefined; // undo batch when typing a new text note into a collection private static _nodeViews: (self: FormattedTextBox) => { [key: string]: NodeViewConstructor }; private static _globalHighlightsCache: string = ''; private static _globalHighlights = new ObservableSet(['Audio Tags', 'Text from Others', 'Todo Items', 'Important Items', 'Disagree Items', 'Ignore Items']); private static _highlightStyleSheet = addStyleSheet(); private static _bulletStyleSheet = addStyleSheet(); private static _userStyleSheet = addStyleSheet(); private _oldWheel: HTMLDivElement | null = null; private _selectionHTML: string | undefined; private _sidebarRef = React.createRef(); private _sidebarTagRef = React.createRef(); private _ref: React.RefObject = React.createRef(); private _scrollRef: HTMLDivElement | null = null; private _editorView: Opt; private _inDrop = false; private _finishingLink = false; private _searchIndex = 0; private _cachedLinks: Doc[] = []; private _undoTyping?: UndoManager.Batch; private _disposers: { [name: string]: IReactionDisposer } = {}; private _dropDisposer?: DragManager.DragDropDisposer; private _recordingStart: number = 0; private _ignoreScroll = false; private _focusSpeed: Opt; private _rules: RichTextRules | undefined; private _forceUncollapse = true; // if the cursor doesn't move between clicks, then the selection will disappear for some reason. This flags the 2nd click as happening on a selection which allows bullet points to toggle private _break = true; public ProseRef?: HTMLDivElement; /** * ApplyingChange - Marks whether an interactive text edit is currently in the process of being written to the database. * This is needed to distinguish changes to text fields caused by editing vs those caused by changes to * the prototype or other external edits */ public ApplyingChange: string = ''; @observable _showSidebar = false; @computed get fontColor() { return this._props.styleProvider?.(this.layoutDoc, this._props, StyleProp.FontColor) as string; } // prettier-ignore @computed get fontSize() { return this._props.styleProvider?.(this.layoutDoc, this._props, StyleProp.FontSize) as string; } // prettier-ignore @computed get fontFamily() { return this._props.styleProvider?.(this.layoutDoc, this._props, StyleProp.FontFamily) as string; } // prettier-ignore @computed get fontWeight() { return this._props.styleProvider?.(this.layoutDoc, this._props, StyleProp.FontWeight) as string; } // prettier-ignore @computed get fontStyle() { return this._props.styleProvider?.(this.layoutDoc, this._props, StyleProp.FontStyle) as string; } // prettier-ignore @computed get fontDecoration() { return this._props.styleProvider?.(this.layoutDoc, this._props, StyleProp.FontDecoration) as string; } // prettier-ignore set _recordingDictation(value) { !this.dataDoc[`${this.fieldKey}_recordingSource`] && (this.dataDoc.mediaState = value ? mediaState.Recording : undefined); } // eslint-disable-next-line no-return-assign @computed get config() { return FormattedTextBox.MakeConfig(this._rules = new RichTextRules(this.Document, this), this._props); } // prettier-ignore @computed get _recordingDictation() { return this.dataDoc?.mediaState === mediaState.Recording; } // prettier-ignore @computed get SidebarShown() { return !!(this._showSidebar || this.layoutDoc._layout_showSidebar); } // prettier-ignore @computed get allSidebarDocs() { return DocListCast(this.dataDoc[this.sidebarKey]); } // prettier-ignore @computed get noSidebar() { return this.DocumentView?.()._props.hideDecorationTitle || this._props.noSidebar || this.Document._layout_noSidebar; } // prettier-ignore @computed get layout_sidebarWidthPercent() { return this._showSidebar ? '20%' : StrCast(this.layoutDoc._layout_sidebarWidthPercent, '0%'); } // prettier-ignore @computed get sidebarColor() { return StrCast(this.layoutDoc.sidebar_color, StrCast(this.layoutDoc[this.fieldKey + '_backgroundColor'], '#e4e4e4')); } // prettier-ignore @computed get layout_autoHeight() { return (this._props.forceAutoHeight || this.layoutDoc._layout_autoHeight) && !this._props.ignoreAutoHeight; } // prettier-ignore @computed get textHeight() { return NumCast(this.dataDoc[this.fieldKey + '_height']); } // prettier-ignore @computed get scrollHeight() { return NumCast(this.dataDoc[this.fieldKey + '_scrollHeight']); } // prettier-ignore @computed get sidebarHeight() { return !this.sidebarWidth() ? 0 : NumCast(this.dataDoc[this.sidebarKey + '_height']); } // prettier-ignore @computed get titleHeight() { return this._props.styleProvider?.(this.layoutDoc, this._props, StyleProp.HeaderMargin) as number || 0; } // prettier-ignore @computed get layout_autoHeightMargins() { return this.titleHeight + NumCast(this.layoutDoc._layout_autoHeightMargins); } // prettier-ignore @computed get sidebarKey() { return this.fieldKey + '_sidebar'; } // prettier-ignore @computed get isLabel() { return this.dataDoc[this.fieldKey+"_fitBox"]; } // prettier-ignore constructor(props: FormattedTextBoxProps) { super(props); makeObservable(this); this._recordingStart = Date.now(); } public get EditorView() { return this.isLabel ? undefined : this._editorView; } // prettier-ignore // public makeAIFlashcards: () => void = unimplementedFunction; public addToCollection: ((doc: Doc | Doc[], annotationKey?: string | undefined) => boolean) | undefined; // removes all hyperlink anchors for the removed linkDoc // TODO: bcz: Argh... if a section of text has multiple anchors, this should just remove the intended one. // but since removing one anchor from the list of attr anchors isn't implemented, this will end up removing nothing. public RemoveLinkFromDoc(linkDoc?: Doc) { this.unhighlightSearchTerms(); const state = this.EditorView?.state; const a1 = DocCast(linkDoc?.link_anchor_1); const a2 = DocCast(linkDoc?.link_anchor_2); if (state && a1 && a2 && this.EditorView) { this.removeDocument(a1); this.removeDocument(a2); let allFoundLinkAnchors: { href: string; title: string; anchorId: string }[] = []; state.doc.nodesBetween(0, state.doc.nodeSize - 2, (node: Node /* , pos: number, parent: any */) => { const foundLinkAnchors = findLinkMark(node.marks)?.attrs.allAnchors.filter((a: { href: string; title: string; anchorId: string }) => a.anchorId === a1[Id] || a.anchorId === a2[Id]) || []; allFoundLinkAnchors = foundLinkAnchors.length ? foundLinkAnchors : allFoundLinkAnchors; return true; }); if (allFoundLinkAnchors.length) { this.EditorView.dispatch(removeMarkWithAttrs(state.tr, 0, state.doc.nodeSize - 2, state.schema.marks.linkAnchor, { allAnchors: allFoundLinkAnchors })); this.setupEditor(this.config, this.fieldKey); } } } // removes all the specified link references from the selection. // NOTE: as above, this won't work correctly if there are marks with overlapping but not exact sets of link references. public RemoveAnchorFromSelection(allAnchors: { href: string; title: string; linkId: string; targetId: string }[]) { const state = this.EditorView?.state; if (state && this.EditorView) { this.EditorView.dispatch(removeMarkWithAttrs(state.tr, state.selection.from, state.selection.to, state.schema.marks.link, { allAnchors })); this.setupEditor(this.config, this.fieldKey); } } getAnchor = (addAsAnnotation: boolean, pinProps?: PinProps) => { const rootDoc: Doc = Doc.isTemplateDoc(this._props.docViewPath().lastElement()?.Document) ? this.Document : DocCast(this.Document.rootDocument, this.Document); if (!pinProps && this.EditorView?.state.selection.empty) return rootDoc; const anchor = Docs.Create.ConfigDocument({ title: StrCast(rootDoc.title), annotationOn: rootDoc }); this.addDocument(anchor); this._finishingLink = true; this.makeLinkAnchor(anchor, OpenWhere.addRight, undefined, 'Anchored Selection', false, addAsAnnotation); this._finishingLink = false; PinDocView(anchor, { pinDocLayout: pinProps?.pinDocLayout, pinData: { ...(pinProps?.pinData ?? {}), scrollable: true } }, this.Document); return anchor; }; gptPDFFlashcards = async () => { const queryText = window.getSelection()?.toString() ?? ''; try { if (queryText) { const res = await gptAPICall(queryText, GPTCallType.FLASHCARD); AnchorMenu.Instance.transferToFlashcard(res || 'Something went wrong', NumCast(this.layoutDoc.x), NumCast(this.layoutDoc.y)); } } catch (err) { console.error(err); } }; @action setupAnchorMenu = () => { AnchorMenu.Instance.Status = 'marquee'; AnchorMenu.Instance.gptFlashcards = this.gptPDFFlashcards; AnchorMenu.Instance.addToCollection = this._props.DocumentView?.()._props.addDocument; AnchorMenu.Instance.OnClick = () => { !this.layoutDoc.layout_showSidebar && this.toggleSidebar(); setTimeout(() => this._sidebarRef.current?.anchorMenuClick(this.makeLinkAnchor(undefined, OpenWhere.addRight, undefined, 'Anchored Selection', true))); // give time for sidebarRef to be created }; AnchorMenu.Instance.OnAudio = () => { !this.layoutDoc.layout_showSidebar && this.toggleSidebar(); const anchor = this.makeLinkAnchor(undefined, OpenWhere.addRight, undefined, 'Anchored Selection', true, true); setTimeout(() => { const target = this._sidebarRef.current?.anchorMenuClick(anchor); if (target) { anchor.followLinkAudio = true; let stopFunc: () => void = emptyFunction; const targetData = target[DocData]; targetData.mediaState = mediaState.Recording; DictationManager.recordAudioAnnotation(targetData, Doc.LayoutFieldKey(target), stop => { stopFunc = stop }); // prettier-ignore const reactionDisposer = reaction( () => target.mediaState, dictation => { if (!dictation) { stopFunc(); reactionDisposer(); } } ); target.title = ComputedField.MakeFunction(`this.text_audioAnnotations_text.lastElement()`); } }); }; AnchorMenu.Instance.Highlight = undoable((color: string) => this.EditorView?.state && RichTextMenu.Instance?.setFontField(color, 'fontHighlight'), 'highlght text'); AnchorMenu.Instance.onMakeAnchor = () => this.getAnchor(true); AnchorMenu.Instance.StartCropDrag = unimplementedFunction; /** * This function is used by the PDFmenu to create an anchor highlight and a new linked text annotation. * It also initiates a Drag/Drop interaction to place the text annotation. */ AnchorMenu.Instance.StartDrag = action(async (e: PointerEvent, ele: HTMLElement) => { e.preventDefault(); e.stopPropagation(); const targetCreator = (annotationOn?: Doc) => { const target = DocUtils.GetNewTextDoc('Note linked to ' + this.Document.title, 0, 0, 100, 100, annotationOn); Doc.SetSelectOnLoad(target); return target; }; const docView = this.DocumentView?.(); docView && DragManager.StartAnchorAnnoDrag([ele], new DragManager.AnchorAnnoDragData(docView, () => this.getAnchor(true), targetCreator), e.pageX, e.pageY); }); AnchorMenu.Instance.AddDrawingAnnotation = (drawing: Doc) => { const container = DocCast(this.Document.embedContainer); const docView = DocumentView.getDocumentView?.(container); docView?.ComponentView?._props.addDocument?.(drawing); drawing.x = NumCast(this.Document.x) + NumCast(this.Document.width); drawing.y = NumCast(this.Document.y); }; AnchorMenu.Instance.setSelectedText(window.getSelection()?.toString() ?? ''); const coordsB = this.EditorView!.coordsAtPos(this.EditorView!.state.selection.to); this._props.rootSelected?.() && AnchorMenu.Instance.jumpTo(coordsB.left, coordsB.bottom); let ele: Opt; try { const contents = window.getSelection()?.getRangeAt(0).cloneContents(); if (contents) { ele = document.createElement('div'); ele.append(contents); } this._selectionHTML = ele?.innerHTML; // eslint-disable-next-line @typescript-eslint/no-unused-vars } catch (e) { /* empty */ } }; leafText = (node: Node) => { if (node.type === this.EditorView?.state.schema.nodes.dashField) { const refDoc = !node.attrs.docId ? DocCast(this.Document.rootDocument, this.Document) : (DocServer.GetCachedRefField(node.attrs.docId as string) as Doc); const fieldKey = StrCast(node.attrs.fieldKey); return ( (node.attrs.hideKey ? '' : fieldKey + ':') + // (node.attrs.hideValue ? '' : Field.toJavascriptString(refDoc[fieldKey] as FieldType)) ); } return ''; }; dispatchTransaction = (tx: Transaction) => { if (this.EditorView && !this.EditorView.isDestroyed) { const state = this.EditorView.state.apply(tx); this.EditorView.updateState(state); this.tryUpdateDoc(false); } }; tryUpdateDoc = (force: boolean) => { if (this.EditorView) { const { state } = this.EditorView; const { dataDoc } = this; const newText = state.doc.textBetween(0, state.doc.content.size, ' \n', this.leafText); const newJson = JSON.stringify(state.toJSON()); const prevData = Cast(this.layoutDoc[this.fieldKey], RichTextField, null); // the actual text in the text box const protoData = Cast(Cast(dataDoc.proto, Doc, null)?.[this.fieldKey], RichTextField, null); // the default text inherited from a prototype const layoutData = this.layoutDoc.isTemplateDoc ? Cast(this.layoutDoc[this.fieldKey], RichTextField, null) : undefined; // the default text inherited from a prototype const effectiveAcl = GetEffectiveAcl(dataDoc); const removeSelection = (json: string | undefined) => json?.replace(/"selection":.*/, ''); if ([AclEdit, AclAdmin, AclSelfEdit, AclAugment].includes(effectiveAcl)) { const accumTags = [] as string[]; state.tr.doc.nodesBetween(0, state.doc.content.size, (node: Node /* , pos: number, parent: any */) => { if (node.type === schema.nodes.dashField && node.attrs.fieldKey.startsWith('#')) { accumTags.push(node.attrs.fieldKey); } }); if (accumTags.some(atag => !StrListCast(dataDoc.tags).includes(atag))) { dataDoc.tags = new List(Array.from(new Set(accumTags))); } let unchanged = true; const textChange = newText !== prevData?.Text; // the Text string can change even if the RichText doesn't because dashFieldViews may return new strings as the data they reference changes const rtField = (layoutData !== prevData ? layoutData : undefined) ?? protoData; if (this.ApplyingChange !== this.fieldKey && (force || textChange || removeSelection(newJson) !== removeSelection(prevData?.Data))) { this.ApplyingChange = this.fieldKey; if ((!prevData && !protoData && !layoutData) || newText || (!newText && !protoData && !layoutData)) { // if no template, or there's text that didn't come from the layout template, write it to the document. (if this is driven by a template, then this overwrites the template text which is intended) if (force || ((this._finishingLink || this._props.isContentActive() || this._inDrop) && (textChange || removeSelection(newJson) !== removeSelection(prevData?.Data)))) { textChange && (dataDoc[this.fieldKey + '_modificationDate'] = new DateField(new Date(Date.now()))); textChange && (dataDoc[this.fieldKey + '_placeholder'] = undefined); const numstring = NumCast(dataDoc[this.fieldKey], null); dataDoc[this.fieldKey] = numstring !== undefined ? Number(newText) : newText || (DocCast(dataDoc.proto)?.[this.fieldKey] === undefined && this.layoutDoc[this.fieldKey] === undefined) ? new RichTextField(newJson, newText) : undefined; textChange && ScriptCast(this.layoutDoc.onTextChanged, null)?.script.run({ this: this.Document, text: newText }); this.ApplyingChange = ''; // turning this off here allows a Doc to retrieve data from template if noTemplate below is changed to false unchanged = false; } } else if (rtField) { textChange && (dataDoc[this.fieldKey + '_modificationDate'] = new DateField(new Date(Date.now()))); // if we've deleted all the text in a note driven by a template, then restore the template data dataDoc[this.fieldKey] = undefined; this.EditorView.updateState(EditorState.fromJSON(this.config, JSON.parse(rtField.Data))); ScriptCast(this.layoutDoc.onTextChanged, null)?.script.run({ this: this.layoutDoc, text: newText }); unchanged = false; } this.ApplyingChange = ''; if (!unchanged) { this.updateTitle(); this.tryUpdateScrollHeight(); } } } else { const jsonstring = Cast(dataDoc[this.fieldKey], RichTextField)?.Data; if (jsonstring) { const json = JSON.parse(jsonstring); json.selection = state.toJSON().selection; this.EditorView.updateState(EditorState.fromJSON(this.config, json)); } } if (window.getSelection()?.isCollapsed && this._props.rootSelected?.()) { AnchorMenu.Instance.fadeOut(true); } } }; // for inserting timestamps insertTime = () => { let linkTime; let linkAnchor; Doc.Links(this.dataDoc).forEach(l => { const anchor = DocCast(l.link_anchor_1)?.annotationOn ? DocCast(l.link_anchor_1) : DocCast(l.link_anchor_2)?.annotationOn ? DocCast(l.link_anchor_2) : undefined; if (anchor && (anchor.annotationOn as Doc).mediaState === mediaState.Recording) { linkTime = NumCast(anchor._timecodeToShow /* audioStart */); linkAnchor = anchor; } }); if (this.EditorView && linkTime) { const { state } = this.EditorView; const node = state.selection.$from.node(); if (linkAnchor && node.type !== state.schema.nodes.code_block) { const time = linkTime + Date.now() / 1000 - this._recordingStart / 1000; this._break = false; const { from } = state.selection; const value = state.schema.nodes.audiotag.create({ timeCode: time, audioId: linkAnchor[Id] }); const replaced = state.tr.insert(from - 1, value); this.EditorView.dispatch(replaced.setSelection(new TextSelection(replaced.doc.resolve(from + 1)))); } } }; autoLink = () => { const newAutoLinks = new Set(); const oldAutoLinks = Doc.Links(this.Document).filter( link => ((!Doc.isTemplateForField(this.Document) && (!Doc.isTemplateForField(DocCast(link.link_anchor_1)) || !Doc.AreProtosEqual(DocCast(link.link_anchor_1), this.Document)) && (!Doc.isTemplateForField(DocCast(link.link_anchor_2)) || !Doc.AreProtosEqual(DocCast(link.link_anchor_2), this.Document))) || (Doc.isTemplateForField(this.Document) && (link.link_anchor_1 === this.Document || link.link_anchor_2 === this.Document))) && link.link_relationship === LinkManager.AutoKeywords ); // prettier-ignore if (this.EditorView?.state.doc.textContent) { let { tr } = this.EditorView.state; const { from, to } = this.EditorView.state.selection; const { autoLinkAnchor } = this.EditorView.state.schema.marks; tr = tr.removeMark(0, tr.doc.content.size, autoLinkAnchor); Doc.MyPublishedDocs.filter(term => term.title).forEach(term => { tr = this.hyperlinkTerm(tr, term, newAutoLinks); }); tr = tr.setSelection(new TextSelection(tr.doc.resolve(from), tr.doc.resolve(to))); this.EditorView?.dispatch(tr); } oldAutoLinks.filter(oldLink => !newAutoLinks.has(oldLink) && oldLink.link_anchor_2 !== this.Document).forEach(doc => Doc.DeleteLink?.(doc)); }; updateTitle = () => { const title = StrCast(this.dataDoc.title, Cast(this.dataDoc.title, RichTextField, null)?.Text); if ( !this._props.dontRegisterView && // (this.Document.isTemplateForField === "text" || !this.Document.isTemplateForField) && // only update the title if the data document's data field is changing title.startsWith('-') && this.EditorView && !this.dataDoc.title_custom && (Doc.LayoutFieldKey(this.Document) === this.fieldKey || this.fieldKey === 'text') ) { let node = this.EditorView.state.doc; while (node.firstChild && node.firstChild.type.name !== 'text') node = node.firstChild; const str = node.textContent; const prefix = '-'; const cfield = ComputedField.WithoutComputed(() => FieldValue(this.dataDoc.title)); if (!(cfield instanceof ComputedField)) { this.dataDoc.title = (prefix + str.substring(0, Math.min(40, str.length)) + (str.length > 40 ? '...' : '')).trim(); } } }; // creates links between terms in a document and published documents (myPublishedDocs) that have titles starting with an '@' /** * Searches the text for occurences of any strings that match the names of 'published' documents. These document * names will begin with an '@' prefix. However, valid matches within the text can have any of the following formats: * name, @, or ^@ * The last of these is interpreted as an include directive when converting the text into evaluated code in the paint * function of a freeform view that is driven by the text box's text. The include directive will copy the code of the published * document into the code being evaluated. */ hyperlinkTerm = (trIn: Transaction, target: Doc, newAutoLinks: Set) => { let tr = trIn; const editorView = this.EditorView; if (editorView && !Doc.AreProtosEqual(target, this.Document)) { const autoLinkTerm = Field.toString(target.title as FieldType).replace(/^@/, ''); let alink: Doc | undefined; this.findInNode(editorView, editorView.state.doc, autoLinkTerm).forEach(sel => { if ( !sel.$anchor.pos || autoLinkTerm === editorView.state.doc .textBetween(sel.$anchor.pos - 1, sel.$to.pos) .trim() .replace(/[\^@]+/, '') ) { const splitter = editorView.state.schema.marks.splitter.create({ id: Utils.GenerateGuid() }); tr = tr.addMark(sel.from, sel.to, splitter); tr.doc.nodesBetween(sel.from, sel.to, (node: Node, pos: number /* , parent: any */) => { if (node.firstChild === null && !node.marks.find((m: Mark) => m.type.name === schema.marks.noAutoLinkAnchor.name) && node.marks.find((m: Mark) => m.type.name === schema.marks.splitter.name)) { alink = alink ?? (Doc.Links(this.Document).find( link => Doc.AreProtosEqual(Cast(link.link_anchor_1, Doc, null), this.Document) && // Doc.AreProtosEqual(Cast(link.link_anchor_2, Doc, null), target) ) || DocUtils.MakeLink(this.Document, target, { link_relationship: LinkManager.AutoKeywords })!); newAutoLinks.add(alink); // DocCast(alink.link_anchor_1).followLinkLocation = 'add:right'; const allAnchors = [{ href: Doc.localServerPath(target), title: 'a link', anchorId: this.Document[Id] }]; allAnchors.push(...(node.marks.find((m: Mark) => m.type.name === schema.marks.autoLinkAnchor.name)?.attrs.allAnchors ?? [])); const link = editorView.state.schema.marks.autoLinkAnchor.create({ allAnchors, title: 'auto term' }); tr = tr.addMark(pos, pos + node.nodeSize, link); } }); tr = tr.removeMark(sel.from, sel.to, splitter); } }); } return tr; }; @action search = (searchString: string, bwd?: boolean, clear: boolean = false) => { if (clear) this.unhighlightSearchTerms(); else this.highlightSearchTerms([searchString], bwd!); return true; }; highlightSearchTerms = (terms: string[], backward: boolean) => { const { _editorView } = this; if (_editorView && terms.some(t => t)) { const { state } = _editorView; let { tr } = state; const mark = state.schema.mark(state.schema.marks.search_highlight); const activeMark = state.schema.mark(state.schema.marks.search_highlight, { selected: true }); const res = terms.filter(t => t).map(term => this.findInNode(_editorView, state.doc, term)); const { length } = res[0]; const flattened: TextSelection[] = []; res.map(r => r.map(h => flattened.push(h))); this._searchIndex = ++this._searchIndex > flattened.length - 1 ? 0 : this._searchIndex; if (backward === true) { if (this._searchIndex > 1) { this._searchIndex += -2; } else if (this._searchIndex === 1) { this._searchIndex = length - 1; } else if (this._searchIndex === 0 && length !== 1) { this._searchIndex = length - 2; } } const lastSel = Math.min(flattened.length - 1, this._searchIndex); flattened.forEach((h: TextSelection, ind: number) => { tr = tr.addMark(h.from, h.to, ind === lastSel ? activeMark : mark); }); flattened[lastSel] && _editorView.dispatch(tr.setSelection(new TextSelection(tr.doc.resolve(flattened[lastSel].from), tr.doc.resolve(flattened[lastSel].to))).scrollIntoView()); } }; unhighlightSearchTerms = () => { if (this.EditorView) { const { state } = this.EditorView; if (state) { const mark = state.schema.mark(state.schema.marks.search_highlight); const activeMark = state.schema.mark(state.schema.marks.search_highlight, { selected: true }); const end = state.doc.nodeSize - 2; this.EditorView.dispatch(state.tr.removeMark(0, end, mark).removeMark(0, end, activeMark)); } } }; adoptAnnotation = (start: number, end: number, mark: Mark) => { const view = this.EditorView!; const nmark = view.state.schema.marks.user_mark.create({ ...mark.attrs, userid: ClientUtils.CurrentUserEmail() }); view.dispatch(view.state.tr.removeMark(start, end, nmark).addMark(start, end, nmark)); }; protected createDropTarget = (ele: HTMLDivElement) => { this._dropDisposer?.(); this.ProseRef = ele; if (ele) { this.setupEditor(this.config, this.fieldKey); this._dropDisposer = DragManager.MakeDropTarget(ele, this.drop.bind(this), this.layoutDoc); } // if (this.layout_autoHeight) this.tryUpdateScrollHeight(); }; @undoBatch drop = (e: Event, de: DragManager.DropEvent) => { if (de.complete.annoDragData) { de.complete.annoDragData.dropDocCreator = () => this.getAnchor(true); return true; } const dragData = de.complete.docDragData; if (dragData) { const dataDoc = Doc.IsDelegateField(DocCast(this.layoutDoc.proto), this.fieldKey) ? DocCast(this.layoutDoc.proto) : this.dataDoc; const effectiveAcl = GetEffectiveAcl(dataDoc); const draggedDoc = dragData.droppedDocuments.lastElement(); let added: Opt; const dropAction = dragData.dropAction || dragData.userDropAction; if ([AclEdit, AclAdmin, AclSelfEdit].includes(effectiveAcl) && !dragData.draggedDocuments.includes(this.Document)) { // replace text contents when dragging with Alt if (de.altKey) { const fieldKey = Doc.LayoutFieldKey(draggedDoc); if (draggedDoc[fieldKey] instanceof RichTextField && !Doc.AreProtosEqual(draggedDoc, this.Document)) { Doc.GetProto(this.dataDoc)[this.fieldKey] = Field.Copy(draggedDoc[fieldKey]); } // embed document when drag marked as embed } else if (de.embedKey || dropAction) { const node = schema.nodes.dashDoc.create({ width: NumCast(draggedDoc._width), height: NumCast(draggedDoc._height), title: 'dashDoc', docId: draggedDoc[Id], float: 'unset', }); if (!de.embedKey && ![dropActionType.embed, dropActionType.copy].includes(dropAction ?? dropActionType.move)) { added = !!dragData.removeDocument?.(draggedDoc); } else { added = true; } if (added) { draggedDoc._freeform_fitContentsToBox = true; Doc.SetContainer(draggedDoc, this.Document); const view = this.EditorView!; try { this._inDrop = true; const pos = view.posAtCoords({ left: de.x, top: de.y })?.pos; pos && view.dispatch(view.state.tr.insert(pos, node)); added = !!pos; // pos will be null if you don't drop onto an actual text location } catch (err) { console.log('Drop failed', err); added = false; } finally { this._inDrop = false; } } } } // otherwise, fall through to outer collection to handle drop added === false && e.preventDefault(); added === true && e.stopPropagation(); return added; } return false; }; getNodeEndpoints(context: Node, node: Node): { from: number; to: number } | null { let offset = 0; if (context === node) return { from: offset, to: offset + node.nodeSize }; if (node.isBlock) { // tslint:disable-next-line: prefer-for-of for (let i = 0; i < context.content.childCount; i++) { const result = this.getNodeEndpoints(context.content.child(i), node); if (result) { return { from: result.from + offset + (context.type.name === 'doc' ? 0 : 1), to: result.to + offset + (context.type.name === 'doc' ? 0 : 1), }; } offset += context.content.child(i).nodeSize; } } return null; } // Recursively finds matches within a given node findInNode(pm: EditorView, node: Node, find: string) { let ret: TextSelection[] = []; if (node.isTextblock) { let index = 0; let foundAt; const ep = this.getNodeEndpoints(pm.state.doc, node); const regexp = new RegExp(find, 'i'); if (regexp) { let blockOffset = 0; for (let i = 0; i < node.childCount; i++) { let textContent = ''; while (i < node.childCount && node.child(i).type === pm.state.schema.nodes.text) { textContent += node.child(i).textContent; i++; } while (ep && (foundAt = textContent.slice(index).search(regexp)) > -1) { const sel = new TextSelection(pm.state.doc.resolve(ep.from + index + blockOffset + foundAt + 1), pm.state.doc.resolve(ep.from + index + blockOffset + foundAt + find.length + 1)); ret.push(sel); index = index + foundAt + find.length; } blockOffset += textContent.length; if (i < node.childCount) blockOffset += node.child(i).nodeSize; } } } else { node.content.forEach(child => { ret = ret.concat(this.findInNode(pm, child, find)); }); } return ret; } updateHighlights = (highlights: string[]) => { if (Array.from(highlights).join('') === FormattedTextBox._globalHighlightsCache) return; setTimeout(() => { FormattedTextBox._globalHighlightsCache = Array.from(highlights).join(''); }); clearStyleSheetRules(FormattedTextBox._userStyleSheet); if (!highlights.includes('Audio Tags')) { addStyleSheetRule(FormattedTextBox._userStyleSheet, 'audiotag', { display: 'none' }, ''); } if (highlights.includes('Text from Others')) { addStyleSheetRule(FormattedTextBox._userStyleSheet, 'UM-remote', { background: 'yellow' }); } if (highlights.includes('My Text')) { addStyleSheetRule(FormattedTextBox._userStyleSheet, 'UM-' + ClientUtils.CurrentUserEmail().replace(/\./g, '').replace(/@/g, ''), { background: 'moccasin' }); } if (highlights.includes('Todo Items')) { addStyleSheetRule(FormattedTextBox._userStyleSheet, 'UT-todo', { outline: 'black solid 1px' }); } if (highlights.includes('Important Items')) { addStyleSheetRule(FormattedTextBox._userStyleSheet, 'UT-important', { 'font-size': 'larger' }); } if (highlights.includes('Bold Text')) { addStyleSheetRule(FormattedTextBox._userStyleSheet, '.formattedTextBox-inner .ProseMirror strong > span', { 'font-size': 'large' }, ''); addStyleSheetRule(FormattedTextBox._userStyleSheet, '.formattedTextBox-inner .ProseMirror :not(strong > span)', { 'font-size': '0px' }, ''); } if (highlights.includes('Disagree Items')) { addStyleSheetRule(FormattedTextBox._userStyleSheet, 'UT-disagree', { 'text-decoration': 'line-through' }); } if (highlights.includes('Ignore Items')) { addStyleSheetRule(FormattedTextBox._userStyleSheet, 'UT-ignore', { 'font-size': '1' }); } if (highlights.includes('By Recent Minute')) { addStyleSheetRule(FormattedTextBox._userStyleSheet, 'UM-' + ClientUtils.CurrentUserEmail().replace('.', '').replace('@', ''), { opacity: '0.1' }); const min = Math.round(Date.now() / 1000 / 60); numberRange(10).map(i => addStyleSheetRule(FormattedTextBox._userStyleSheet, 'UM-min-' + (min - i), { opacity: ((10 - i - 1) / 10).toString() })); } if (highlights.includes('By Recent Hour')) { addStyleSheetRule(FormattedTextBox._userStyleSheet, 'UM-' + ClientUtils.CurrentUserEmail().replace('.', '').replace('@', ''), { opacity: '0.1' }); const hr = Math.round(Date.now() / 1000 / 60 / 60); numberRange(10).map(i => addStyleSheetRule(FormattedTextBox._userStyleSheet, 'UM-hr-' + (hr - i), { opacity: ((10 - i - 1) / 10).toString() })); } this.layoutDoc[DocCss] = this.layoutDoc[DocCss] + 1; // css changes happen outside of react/mobx. so we need to set a flag that will notify anyone interested in layout changes triggered by css changes (eg., CollectionLinkView) }; @action toggleSidebar = (preview: boolean = false) => { const defaultSidebar = 250; const prevWidth = 1 - this.sidebarWidth() / DivWidth(this._ref.current!); if (preview) this._showSidebar = true; else { this.layoutDoc[this.sidebarKey + '_freeform_scale_max'] = 1; this.layoutDoc._layout_showSidebar = (this.layoutDoc._layout_sidebarWidthPercent = StrCast(this.layoutDoc._layout_sidebarWidthPercent, '0%') === '0%' ? `${(defaultSidebar / (NumCast(this.layoutDoc._width) + defaultSidebar)) * 100}%` : '0%') !== '0%'; } this.layoutDoc._width = !preview && this.SidebarShown ? NumCast(this.layoutDoc._width) + defaultSidebar : Math.max(20, NumCast(this.layoutDoc._width) * prevWidth); }; sidebarDown = (e: React.PointerEvent) => { const batch = UndoManager.StartBatch('toggle sidebar'); setupMoveUpEvents( this, e, this.sidebarMove, (moveEv, movement, isClick) => !isClick && batch.end(), () => { this.toggleSidebar(); batch.end(); }, true ); }; sidebarMove = (e: PointerEvent, down: number[], delta: number[]) => { const localDelta = this.DocumentView?.().screenToViewTransform().transformDirection(delta[0], delta[1]) ?? delta; const sidebarWidth = (NumCast(this.layoutDoc._width) * Number(this.layout_sidebarWidthPercent.replace('%', ''))) / 100; const width = NumCast(this.layoutDoc._width) + localDelta[0]; this.layoutDoc._layout_sidebarWidthPercent = Math.max(0, (sidebarWidth + localDelta[0]) / width) * 100 + '%'; this.layoutDoc.width = width; this.layoutDoc._layout_showSidebar = this.layoutDoc._layout_sidebarWidthPercent !== '0%'; e.preventDefault(); return false; }; deleteAnnotation = (anchor: Doc) => { const batch = UndoManager.StartBatch('delete link'); Doc.DeleteLink?.(Doc.Links(anchor)[0]); // const docAnnotations = DocListCast(this._props.dataDoc[this.fieldKey]); // this._props.dataDoc[this.fieldKey] = new List(docAnnotations.filter(a => a !== this.annoTextRegion)); // AnchorMenu.Instance.fadeOut(true); this._props.select(false); setTimeout(batch.end); // wait for reaction to remove link from document }; @undoBatch pinToPres = (anchor: Doc) => this._props.pinToPres(anchor, {}); @undoBatch makeTargetToggle = (anchor: Doc) => { anchor.followLinkToggle = !anchor.followLinkToggle; }; @undoBatch showTargetTrail = (anchor: Doc) => { const trail = DocCast(anchor.presentationTrail); if (trail) { Doc.ActivePresentation = trail; this._props.addDocTab(trail, OpenWhere.replaceRight); } }; isTargetToggler = (anchor: Doc) => BoolCast(anchor.followLinkToggle); specificContextMenu = (e: React.MouseEvent): void => { if (this._props.dontSelect?.()) return; const cm = ContextMenu.Instance; let target: Element | HTMLElement | null = e.target as HTMLElement; // hrefs are stored on the database of the node that wraps the hyerlink while (target && (!(target instanceof HTMLElement) || !target.dataset?.targethrefs)) target = target.parentElement; const editor = this.EditorView; if (editor && target && !(e.nativeEvent instanceof simMouseEvent ? e.nativeEvent.dash : false)) { const hrefs = (target.dataset?.targethrefs as string) ?.trim() .split(' ') .filter(h => h); const anchorDoc = Array.from(hrefs ?? []) .lastElement() .replace(Doc.localServerPath(), '') .split('?')[0]; const deleteMarkups = undoable(() => { const { selection } = editor.state; editor.dispatch(editor.state.tr.removeMark(selection.from, selection.to, editor.state.schema.marks.linkAnchor)); }, 'delete markups'); e.persist(); anchorDoc && DocServer.GetRefField(anchorDoc).then( action(anchor => { anchor && DocumentView.SelectSchemaDoc(anchor as Doc); AnchorMenu.Instance.Status = 'annotation'; AnchorMenu.Instance.Delete = !anchor && editor.state.selection.empty ? returnFalse : !anchor ? deleteMarkups : () => this.deleteAnnotation(anchor as Doc); AnchorMenu.Instance.Pinned = false; AnchorMenu.Instance.PinToPres = !anchor ? returnFalse : () => this.pinToPres(anchor as Doc); AnchorMenu.Instance.MakeTargetToggle = !anchor ? returnFalse : () => this.makeTargetToggle(anchor as Doc); AnchorMenu.Instance.ShowTargetTrail = !anchor ? returnFalse : () => this.showTargetTrail(anchor as Doc); AnchorMenu.Instance.IsTargetToggler = !anchor ? returnFalse : () => this.isTargetToggler(anchor as Doc); AnchorMenu.Instance.jumpTo(e.clientX, e.clientY, true); }) ); e.preventDefault(); e.stopPropagation(); return; } const changeItems: ContextMenuProps[] = []; changeItems.push({ description: 'plain', event: undoable(() => { Doc.setNativeView(this.Document); this.layoutDoc.layout_autoHeightMargins = undefined; }, 'set plain view'), icon: 'eye', }); changeItems.push({ description: 'metadata', event: undoable(() => { this.dataDoc.layout_meta = Cast(Doc.UserDoc().emptyHeader, Doc, null)?.layout; this.Document.layout_fieldKey = 'layout_meta'; setTimeout(() => { this.layoutDoc._header_height = this.layoutDoc._layout_autoHeightMargins = 50; }, 50); }, 'set metadata view'), icon: 'eye', }); const noteTypesDoc = Cast(Doc.UserDoc().template_notes, Doc, null); DocListCast(noteTypesDoc?.data).forEach(note => { const icon: IconProp = StrCast(note.icon) as IconProp; changeItems.push({ description: StrCast(note.title), event: undoable( () => { this.layoutDoc.layout_autoHeightMargins = undefined; Doc.setNativeView(this.Document); DocUtils.makeCustomViewClicked(this.Document, Docs.Create.TreeDocument, StrCast(note.title), note); }, `set ${StrCast(note.title)} view}` ), icon: icon, }); }); const highlighting: ContextMenuProps[] = []; const noviceHighlighting = ['Audio Tags', 'My Text', 'Text from Others', 'Bold Text']; const expertHighlighting = [...noviceHighlighting, 'Important Items', 'Ignore Items', 'Disagree Items', 'By Recent Minute', 'By Recent Hour']; (Doc.noviceMode ? noviceHighlighting : expertHighlighting).forEach(option => highlighting.push({ description: (!FormattedTextBox._globalHighlights.has(option) ? 'Highlight ' : 'Unhighlight ') + option, event: action(() => { e.stopPropagation(); if (!FormattedTextBox._globalHighlights.has(option)) { FormattedTextBox._globalHighlights.add(option); } else { FormattedTextBox._globalHighlights.delete(option); } }), icon: !FormattedTextBox._globalHighlights.has(option) ? 'highlighter' : 'remove-format', }) ); const appearance = cm.findByDescription('Appearance...'); const appearanceItems = appearance?.subitems ?? []; // appearanceItems.push({ // description: 'Find image tags', // event: this.findImageTags, // icon: !this.Document._layout_noSidebar ? 'eye-slash' : 'eye', // }); appearanceItems.push({ description: !this.Document._layout_noSidebar ? 'Hide Sidebar Handle' : 'Show Sidebar Handle', event: () => { this.layoutDoc._layout_noSidebar = !this.layoutDoc._layout_noSidebar; }, icon: !this.Document._layout_noSidebar ? 'eye-slash' : 'eye', }); appearanceItems.push({ description: (this.Document._layout_enableAltContentUI ? 'Hide' : 'Show') + ' Alt Content UI', event: () => { this.layoutDoc._layout_enableAltContentUI = !this.layoutDoc._layout_enableAltContentUI; }, icon: !this.Document._layout_enableAltContentUI ? 'eye-slash' : 'eye', }); if (this.Document._layout_enableAltContentUI) { const usepath = this.layoutDoc[`_${this._props.fieldKey}_usePath`]; appearanceItems.push({ description: (this.layoutDoc[`_${this._props.fieldKey}_usePath`] === 'alternate:hover' ? 'no hover' : 'hover') + ' to show alt content', event: () => { this.layoutDoc[`_${this._props.fieldKey}_usePath`] = usepath === 'alternate' || usepath === undefined ? 'alternate:hover' : undefined; }, icon: !this.Document._layout_enableAltContentUI ? 'eye-slash' : 'eye', }); } !Doc.noviceMode && appearanceItems.push({ description: 'Show Highlights...', noexpand: true, subitems: highlighting, icon: 'hand-point-right' }); !Doc.noviceMode && appearanceItems.push({ description: 'Broadcast Message', event: () => DocServer.GetRefField('rtfProto').then(proto => { proto instanceof Doc && (proto.BROADCAST_MESSAGE = Cast(this.dataDoc[this.fieldKey], RichTextField)?.Text); }), icon: 'expand-arrows-alt', }); appearanceItems.push({ description: 'Change Style...', noexpand: true, subitems: changeItems, icon: 'external-link-alt' }); !Doc.noviceMode && appearanceItems.push({ description: 'Make Default Layout', event: () => { if (!this.layoutDoc.isTemplateDoc) { MakeTemplate(this.Document); } Doc.UserDoc().defaultTextLayout = new PrefetchProxy(this.Document); Doc.AddDocToList(Cast(Doc.UserDoc().template_notes, Doc, null), 'data', this.Document); }, icon: 'eye', }); !appearance && appearanceItems.length && cm.addItem({ description: 'Appearance...', subitems: appearanceItems, icon: 'eye' }); const options = cm.findByDescription('Options...'); const optionItems = options?.subitems ?? []; optionItems.push({ description: `Toggle auto update from template`, event: () => { this.dataDoc[this.fieldKey + '_autoUpdate'] = !this.dataDoc[this.fieldKey + '_autoUpdate']; }, icon: 'star', }); optionItems.push({ description: `Generate Dall-E Image`, event: () => this.generateImage(), icon: 'star' }); // optionItems.push({ description: `Make AI Flashcards`, event: () => this.makeAIFlashcards(), icon: 'lightbulb' }); optionItems.push({ description: `Ask GPT-3`, event: this.askGPT, icon: 'lightbulb' }); this._props.renderDepth && optionItems.push({ description: !this.Document._createDocOnCR ? 'Create New Doc on Carriage Return' : 'Allow Carriage Returns', event: () => { this.layoutDoc._createDocOnCR = !this.layoutDoc._createDocOnCR; }, icon: !this.Document._createDocOnCR ? 'grip-lines' : 'bars', }); !Doc.noviceMode && optionItems.push({ description: `${this.Document._layout_autoHeight ? 'Lock' : 'Auto'} Height`, event: () => { this.layoutDoc._layout_autoHeight = !this.layoutDoc._layout_autoHeight; }, icon: this.Document._layout_autoHeight ? 'lock' : 'unlock', }); optionItems.push({ description: this.Document.savedAsSticker ? 'Sticker Saved!' : 'Save to Stickers', event: action(undoable(async () => await StickerPalette.addToPalette(this.Document), 'save to palette')), icon: this.Document.savedAsSticker ? 'clipboard-check' : 'file-arrow-down', }); !options && cm.addItem({ description: 'Options...', subitems: optionItems, icon: 'eye' }); const help = cm.findByDescription('Help...'); const helpItems = help?.subitems ?? []; helpItems.push({ description: `show markdown options`, event: () => RTFMarkup.Instance.setOpen(true), icon: }); !help && cm.addItem({ description: 'Help...', subitems: helpItems, icon: 'eye' }); }; findImageTags = async () => { const c = this.ProseRef?.getElementsByTagName('img'); if (c) { for (const i of c) { // console.log(canvas.toDataURL()); // canvas.style.zIndex = '2000000'; // document.body.appendChild(canvas); if (i.className !== 'ProseMirror-separator') this.getImageDesc(i.src); } } }; getImageDesc = async (u: string) => { try { const hrefBase64 = await imageUrlToBase64(u); const response = await gptImageLabel( hrefBase64, 'Make flashcards out of this text and image with each question and answer labeled as question and answer. Do not label each flashcard and do not include asterisks: ' + (this.dataDoc.text as RichTextField)?.Text ); AnchorMenu.Instance.transferToFlashcard(response || 'Something went wrong', NumCast(this.dataDoc['x']), NumCast(this.dataDoc['y'])); } catch (error) { console.log('Error', error); } }; animateRes = (resIndex: number, newText: string) => { if (resIndex < newText.length) { const marks = this.EditorView?.state.storedMarks ?? []; this.EditorView?.dispatch(this.EditorView?.state.tr.insertText(newText[resIndex]).setStoredMarks(marks)); setTimeout(() => this.animateRes(resIndex + 1, newText), 20); } }; askGPT = action(async () => { try { GPTPopup.Instance.setSidebarId(this.sidebarKey); GPTPopup.Instance.addDoc = this.sidebarAddDocument; const res = await gptAPICall((this.dataDoc.text as RichTextField)?.Text, GPTCallType.COMPLETION); if (!res) { this.animateRes(0, 'Something went wrong.'); } else if (this.EditorView) { const { dispatch, state } = this.EditorView; // for no animation, use: dispatch(state.tr.insertText(res)); // for animted response starting at end of text, use: dispatch(state.tr.setSelection(Selection.atEnd(state.doc))); this.animateRes(0, '\n\n' + res); } } catch (err) { console.error(err); this.animateRes(0, 'Something went wrong.'); } }); generateImage = async () => { GPTPopup.Instance?.setTextAnchor(this.getAnchor(false)); GPTPopup.Instance?.setImgTargetDoc(this.Document); GPTPopup.Instance.addToCollection = this._props.addDocument; GPTPopup.Instance.setImgDesc((this.dataDoc.text as RichTextField)?.Text); GPTPopup.Instance.generateImage(); }; breakupDictation = () => { if (this.EditorView && this._recordingDictation) { this.stopDictation(/* true */); this._break = true; const { state } = this.EditorView; const { to } = state.selection; const updated = TextSelection.create(state.doc, to, to); this.EditorView.dispatch(state.tr.setSelection(updated).insert(to, state.schema.nodes.paragraph.create({}))); if (this._recordingDictation) { this.recordDictation(); } } }; recordDictation = () => { DictationManager.Controls.listen({ interimHandler: this.setDictationContent, continuous: { indefinite: false }, }).then(results => { if (results && [DictationManager.Controls.Infringed].includes(results)) { DictationManager.Controls.stop(); } }); }; stopDictation = (/* abort: boolean */) => DictationManager.Controls.stop(/* !abort */); setDictationContent = (value: string) => { if (this.EditorView && this._recordingStart) { if (this._break) { const textanchorFunc = () => { const tanch = Docs.Create.ConfigDocument({ title: 'dictation anchor' }); return this.addDocument(tanch) ? tanch : undefined; }; const link = CreateLinkToActiveAudio(textanchorFunc, false).lastElement(); if (link) { link[DocData].isDictation = true; const audioanchor = Cast(link.link_anchor_2, Doc, null); const textanchor = Cast(link.link_anchor_1, Doc, null); if (audioanchor) { audioanchor.backgroundColor = 'tan'; const audiotag = this.EditorView.state.schema.nodes.audiotag.create({ timeCode: NumCast(audioanchor._timecodeToShow), audioId: audioanchor[Id], textId: textanchor[Id], }); textanchor[DocData].title = 'dictation:' + audiotag.attrs.timeCode; const tr = this.EditorView.state.tr.insert(this.EditorView.state.doc.content.size, audiotag); const tr2 = tr.setSelection(TextSelection.create(tr.doc, tr.doc.content.size)); this.EditorView.dispatch(tr.setSelection(TextSelection.create(tr2.doc, tr2.doc.content.size))); } } } const { from } = this.EditorView.state.selection; this._break = false; const tr = this.EditorView.state.tr.insertText(value); this.EditorView.dispatch(tr.setSelection(TextSelection.create(tr.doc, from, tr.doc.content.size)).scrollIntoView()); } }; // TODO: nda -- Look at how link anchors are added makeLinkAnchor(anchorDoc?: Doc, location?: string, targetHref?: string, title?: string, noPreview?: boolean, addAsAnnotation?: boolean) { const { _editorView } = this; if (_editorView) { const { state } = _editorView; let selectedText = ''; const { selection } = state; const splitter = state.schema.marks.splitter.create({ id: Utils.GenerateGuid() }); let tr = state.tr.addMark(selection.from, selection.to, splitter); if (selection.from !== selection.to) { const anchor = anchorDoc ?? Docs.Create.ConfigDocument({ // title: 'text(' + state.doc.textBetween(selection.from, selection.to) + ')', annotationOn: this.dataDoc, }); const href = targetHref ?? Doc.localServerPath(anchor); if (anchor !== anchorDoc && addAsAnnotation) this.addDocument(anchor); tr.doc.nodesBetween(selection.from, selection.to, (node: Node, pos: number /* , parent: any */) => { if (node.firstChild === null && node.marks.find((m: Mark) => m.type.name === schema.marks.splitter.name)) { const allAnchors = [{ href, title, anchorId: anchor[Id] }]; allAnchors.push(...(node.marks.find((m: Mark) => m.type.name === schema.marks.linkAnchor.name)?.attrs.allAnchors ?? [])); const link = state.schema.marks.linkAnchor.create({ allAnchors, title, location, noPreview }); tr = tr.addMark(pos, pos + node.nodeSize, link); selectedText += (node as Node).textContent; } }); this.dataDoc[ForceServerWrite] = this.dataDoc[UpdatingFromServer] = true; // need to allow permissions for adding links to readonly/augment only documents this.EditorView!.dispatch(tr.removeMark(selection.from, selection.to, splitter)); this.dataDoc[UpdatingFromServer] = this.dataDoc[ForceServerWrite] = false; anchor.text = selectedText; anchor.text_html = this._selectionHTML ?? selectedText; anchor.title = selectedText.substring(0, 30); anchor.presentation_zoomText = true; return anchor; } return anchorDoc ?? this.Document; } return anchorDoc ?? this.Document; } getView = (doc: Doc, options: FocusViewOptions) => { if (DocListCast(this.dataDoc[this.sidebarKey]).find(anno => Doc.AreProtosEqual(doc.layout_unrendered ? DocCast(doc.annotationOn) : doc, anno))) { if (!this.SidebarShown) { this.toggleSidebar(false); options.didMove = true; } setTimeout(() => this._sidebarRef?.current?.makeDocUnfiltered(doc)); } return new Promise>(res => { DocumentView.addViewRenderedCb(doc, dv => res(dv)); }); }; focus = (textAnchor: Doc, options: FocusViewOptions) => { const focusSpeed = options.zoomTime ?? 500; const textAnchorId = textAnchor[Id]; let start = 0; const findAnchorFrag = (frag: Fragment, editor: EditorView) => { const nodes: Node[] = []; let hadStart = start !== 0; frag.forEach((node, index) => { const examinedNode = findAnchorNode(node, editor); if (examinedNode?.node && (examinedNode.node.textContent || examinedNode.node.type === this.EditorView?.state.schema.nodes.dashDoc || examinedNode.node.type === this.EditorView?.state.schema.nodes.audiotag)) { nodes.push(examinedNode.node); !hadStart && (start = index + examinedNode.start); hadStart = true; } }); return { frag: Fragment.fromArray(nodes), start }; }; const findAnchorNode = (node: Node, editor: EditorView) => { if (node.type === this.EditorView?.state.schema.nodes.audiotag) { if (node.attrs.textId === textAnchorId) { return { node, start: 0 }; } return undefined; } if (node.type === this.EditorView?.state.schema.nodes.dashDoc) { if (node.attrs.docId === textAnchorId) { return { node, start: 0 }; } return undefined; } if (!node.isText) { const content = findAnchorFrag(node.content, editor); if (content.frag.childCount) return { node: content.frag.childCount ? content.frag.child(0) : node, start: content.start }; } const marks = [...node.marks]; const linkIndex = marks.findIndex(mark => mark.type === editor.state.schema.marks.linkAnchor); return linkIndex !== -1 && marks[linkIndex].attrs.allAnchors.find((item: { href: string }) => textAnchorId === item.href.replace(/.*\/doc\//, '')) ? { node, start: 0 } : undefined; }; this._didScroll = false; // assume we don't need to scroll. if we do, this will get set to true in handleScrollToSelextion when we dispatch the setSelection below if (this.EditorView && textAnchorId) { const { state } = this.EditorView; const ret = findAnchorFrag(state.doc.content, this.EditorView); const firstChild = ret.frag.childCount ? ret.frag.child(0) : undefined; if (ret.start >= 0 && (ret.frag.size || (firstChild && [state.schema.nodes.dashDoc, state.schema.nodes.audioTag].includes(firstChild.type)))) { !options.instant && (this._focusSpeed = focusSpeed); let selection = TextSelection.near(state.doc.resolve(ret.start)); // default to near the start if (ret.frag.firstChild) { selection = TextSelection.between(state.doc.resolve(ret.start), state.doc.resolve(ret.start + ret.frag.firstChild.nodeSize)); // bcz: looks better to not have the target selected } this.EditorView.dispatch(state.tr.setSelection(new TextSelection(selection.$from, selection.$from)).scrollIntoView()); const escAnchorId = textAnchorId[0] >= '0' && textAnchorId[0] <= '9' ? `\\3${textAnchorId[0]} ${textAnchorId.substr(1)}` : textAnchorId; addStyleSheetRule(FormattedTextBox._highlightStyleSheet, `${escAnchorId}`, { background: 'yellow', transform: 'scale(3)', 'transform-origin': 'left bottom' }); setTimeout(() => { this._focusSpeed = undefined; }, this._focusSpeed); setTimeout(() => clearStyleSheetRules(FormattedTextBox._highlightStyleSheet), Math.max(this._focusSpeed || 0, 3000)); return focusSpeed; } return this._props.focus(this.Document, options); } return undefined; }; // if the scroll height has changed and we're in layout_autoHeight mode, then we need to update the textHeight component of the doc. // Since we also monitor all component height changes, this will update the document's height. resetNativeHeight = (scrollHeight: number) => { const nh = this.layoutDoc.isTemplateForField ? 0 : NumCast(this.layoutDoc._nativeHeight); this.dataDoc[this.fieldKey + '_height'] = scrollHeight; if (nh) this.layoutDoc._nativeHeight = scrollHeight; }; @computed get tagsHeight() { return this.DocumentView?.().showTags ? Math.max(0, 20 - Math.max(this._props.yPadding ?? 0, NumCast(this.layoutDoc._yMargin))) * this.ScreenToLocalBoxXf().Scale : 0; } @computed get contentScaling() { return Doc.NativeAspect(this.Document, this.dataDoc, false) ? this._props.NativeDimScaling?.() || 1 : 1; } componentDidMount() { !this._props.dontSelectOnLoad && this._props.setContentViewBox?.(this); // this tells the DocumentView that this AudioBox is the "content" of the document. this allows the DocumentView to indirectly call getAnchor() on the AudioBox when making a link. this._cachedLinks = Doc.Links(this.Document); this._disposers.breakupDictation = reaction(() => Doc.RecordingEvent, this.breakupDictation); this._disposers.layout_autoHeight = reaction( () => ({ autoHeight: this.layout_autoHeight, fontSize: this.fontSize, css: this.Document[DocCss], xMargin: this.Document.xMargin, yMargin: this.Document.yMargin }), autoHeight => setTimeout(() => autoHeight && this.tryUpdateScrollHeight()) ); this._disposers.highlights = reaction( () => Array.from(FormattedTextBox._globalHighlights).slice(), highlights => this.updateHighlights(highlights), { fireImmediately: true } ); this._disposers.width = reaction( () => this._props.PanelWidth(), () => this.tryUpdateScrollHeight() ); this._disposers.scrollHeight = reaction( () => ({ scrollHeight: this.scrollHeight, layoutAutoHeight: this.layout_autoHeight, width: NumCast(this.layoutDoc._width) }), ({ width, scrollHeight, layoutAutoHeight }) => width && layoutAutoHeight && this.resetNativeHeight(scrollHeight), { fireImmediately: true } ); this._disposers.componentHeights = reaction( // set the document height when one of the component heights changes and layout_autoHeight is on () => ({ sidebarHeight: this.sidebarHeight, textHeight: this.textHeight, layoutAutoHeight: this.layout_autoHeight, marginsHeight: this.layout_autoHeightMargins }), ({ sidebarHeight, textHeight, layoutAutoHeight, marginsHeight }) => { const newHeight = this.contentScaling * (marginsHeight + Math.max(sidebarHeight, textHeight)); if ( (!Array.from(FormattedTextBox._globalHighlights).includes('Bold Text') || this._props.isSelected()) && // layoutAutoHeight && newHeight && newHeight !== this.layoutDoc.height && !this._props.dontRegisterView ) { this._props.setHeight?.(newHeight); } }, { fireImmediately: !Array.from(FormattedTextBox._globalHighlights).includes('Bold Text') } ); this._disposers.links = reaction( () => Doc.Links(this.dataDoc), // if a link is deleted, then remove all hyperlinks that reference it from the text's marks newLinks => { this._cachedLinks.forEach(l => !newLinks.includes(l) && this.RemoveLinkFromDoc(l)); this._cachedLinks = newLinks; } ); this._disposers.editorState = reaction( () => { const protoData = DocCast(this.dataDoc.proto)?.[this.fieldKey]; const dataData = this.dataDoc[this.fieldKey]; const layoutData = Doc.AreProtosEqual(this.layoutDoc, this.dataDoc) ? undefined : this.layoutDoc[this.fieldKey]; const dataTime = dataData ? (DateCast(this.dataDoc[this.fieldKey + '_modificationDate'])?.date.getTime() ?? 0) : 0; const layoutTime = layoutData && this.dataDoc[this.fieldKey + '_autoUpdate'] ? (DateCast(DocCast(this.layoutDoc)[this.fieldKey + '_modificationDate'])?.date.getTime() ?? 0) : 0; const protoTime = protoData && this.dataDoc[this.fieldKey + '_autoUpdate'] ? (DateCast(DocCast(this.dataDoc.proto)[this.fieldKey + '_modificationDate'])?.date.getTime() ?? 0) : 0; const recentData = dataTime >= layoutTime ? (protoTime >= dataTime ? protoData : dataData) : layoutTime >= protoTime ? layoutData : protoData; const whichData = recentData ?? (this.layoutDoc.isTemplateDoc ? layoutData : protoData) ?? protoData; return !whichData ? undefined : { data: RTFCast(whichData), str: Field.toString(DocCast(whichData) ?? StrCast(whichData)) }; }, incomingValue => { if (this.EditorView && this.ApplyingChange !== this.fieldKey) { if (incomingValue?.data) { const updatedState = JSON.parse(incomingValue.data.Data); if (JSON.stringify(this.EditorView.state.toJSON()) !== JSON.stringify(updatedState)) { this.EditorView.updateState(EditorState.fromJSON(this.config, updatedState)); this.tryUpdateScrollHeight(); } } else if (this.EditorView.state.doc.textContent !== (incomingValue?.str ?? '')) { selectAll(this.EditorView.state, tx => this.EditorView?.dispatch(tx.insertText(incomingValue?.str ?? ''))); } } }, { fireImmediately: true } ); this._disposers.search = reaction( () => Doc.IsSearchMatch(this.Document), search => (search ? this.highlightSearchTerms([Doc.SearchQuery()], search.searchMatch < 0) : this.unhighlightSearchTerms()), { fireImmediately: !!Doc.IsSearchMatchUnmemoized(this.Document) } ); this._disposers.selected = reaction( () => this._props.rootSelected?.() || this._props.isContentActive(), action(selected => { if (selected && this.dataDoc[this.fieldKey + '_placeholder']) { setTimeout(() => { selectAll(this.EditorView!.state, (tx: Transaction) => { this.EditorView?.dispatch(tx); this.EditorView!.focus(); }); }); } this.prepareForTyping(); if (FormattedTextBox._globalHighlights.has('Bold Text')) { this.layoutDoc[DocCss] = this.layoutDoc[DocCss] + 1; // css change happens outside of mobx/react, so this will notify anyone interested in the layout that it has changed } if (((RichTextMenu.Instance?.view === this.EditorView && this.EditorView) || this.isLabel) && !selected) { RichTextMenu.Instance?.updateMenu(undefined, undefined, undefined, undefined); } if (selected) { RichTextMenu.Instance?.updateMenu(this.EditorView, undefined, this._props, this.dataDoc); this.EditorView && setTimeout(this.autoLink, 20); } }), { fireImmediately: true } ); if (!this._props.dontRegisterView) { this._disposers.record = reaction( () => this._recordingDictation, () => { this.stopDictation(/* true */); this._recordingDictation && this.recordDictation(); }, { fireImmediately: true } ); if (this._recordingDictation) setTimeout(this.recordDictation); } this._disposers.scroll = reaction( () => NumCast(this.layoutDoc._layout_scrollTop), pos => { if (!this._ignoreScroll && this._scrollRef) { const durationStr = StrCast(this.Document._viewTransition).match(/([0-9]+)(m?)s/); const duration = Number(durationStr?.[1]) * (durationStr?.[2] ? 1 : 1000); this._scrollStopper = smoothScroll(duration || 0, this._scrollRef, Math.abs(pos || 0), 'ease', this._scrollStopper); } }, { fireImmediately: true } ); this.tryUpdateScrollHeight(); if (this.Document.image) { // const node = schema.nodes.dashDoc.create({ // width: 200, // height: 200, // title: 'dashDoc', // docId: DocCast(this.Document.image)[Id], // float: 'unset', // }); // DocCast(this.Document.image)._freeform_fitContentsToBox = true; // Doc.SetContainer(DocCast(this.Document.image), this.Document); // const view = this.EditorView!; // try { // this._inDrop = true; // const pos = view.posAtCoords({ left: 0, top: 0 })?.pos; // pos && view.dispatch(view.state.tr.insert(pos, node)); // } catch (err) { // console.log('Drop failed', err); // } this.addDocument?.(DocCast(this.Document.image)); } //if (this.Document.image) this.addDocument?.(DocCast(this.Document.image)); setTimeout(this.tryUpdateScrollHeight, 250); AnchorMenu.Instance.addToCollection = this._props.DocumentView?.()._props.addDocument; } clipboardTextSerializer = (slice: Slice): string => { let text = ''; let separated = true; const from = 0; const to = slice.content.size; slice.content.nodesBetween( from, to, (node, pos) => { if (node.isText) { text += node.text!.slice(Math.max(from, pos) - pos, to - pos); separated = false; } else if (!separated && node.isBlock) { text += '\n'; separated = true; } else if (node.type.name === 'hard_break') { text += '\n'; } }, 0 ); return text; }; handlePaste = (view: EditorView, event: Event /* , slice: Slice */): boolean => { const pdfAnchorId = (event as ClipboardEvent).clipboardData?.getData('dash/pdfAnchor'); return !!(pdfAnchorId && this.addPdfReference(pdfAnchorId)); }; addPdfReference = (pdfAnchorId: string) => { const view = this.EditorView!; if (pdfAnchorId) { DocServer.GetRefField(pdfAnchorId).then(pdfAnchor => { if (pdfAnchor instanceof Doc) { const dashField = view.state.schema.nodes.paragraph.create({}, [ view.state.schema.nodes.dashField.create({ fieldKey: 'text', docId: pdfAnchor[Id], hideKey: true, hideValue: false, editable: false }, undefined, [ view.state.schema.marks.linkAnchor.create({ allAnchors: [{ href: `/doc/${this.Document[Id]}`, title: this.Document.title, anchorId: `${this.Document[Id]}` }], title: StrCast(pdfAnchor.title), noPreview: true, docref: true, fontSize: '8px', }), ]), ]); const link = DocUtils.MakeLink(pdfAnchor, this.Document, { link_relationship: 'PDF pasted' }); if (link) { view.dispatch(view.state.tr.replaceSelectionWith(dashField, false).scrollIntoView().setMeta('paste', true).setMeta('uiEvent', 'paste')); } } }); return true; } return false; }; isActiveTab(elIn: Element | null | undefined) { let el = elIn; while (el && el !== document.body) { if (getComputedStyle(el).display === 'none') return false; el = el.parentElement; } return true; } static richTextMenuPlugin(props: FormattedTextBoxProps) { return new Plugin({ view: action((newView: EditorView) => { props?.rootSelected?.() && RichTextMenu.Instance && (RichTextMenu.Instance.view = newView); return new RichTextMenuPlugin({ editorProps: props }); }), }); } _didScroll = false; _scrollStopper: undefined | (() => void); // eslint-disable-next-line @typescript-eslint/no-explicit-any setupEditor(config: any, fieldKey: string) { const curText = Cast(this.dataDoc[this.fieldKey], RichTextField, null) || StrCast(this.dataDoc[this.fieldKey]); const rtfField = Cast((!curText && this.layoutDoc[this.fieldKey]) || this.dataDoc[fieldKey], RichTextField); if (this.ProseRef) { this.EditorView?.destroy(); this._editorView = new EditorView(this.ProseRef, { state: rtfField?.Data ? EditorState.fromJSON(config, JSON.parse(rtfField.Data)) : EditorState.create(config), handleScrollToSelection: editorView => { const docPos = editorView.coordsAtPos(editorView.state.selection.to); const viewRect = this._ref.current!.getBoundingClientRect(); const scrollRef = this._scrollRef; const topOff = docPos.top < viewRect.top ? docPos.top - viewRect.top : undefined; const botOff = docPos.bottom > viewRect.bottom ? docPos.bottom - viewRect.bottom : undefined; if (((topOff && Math.abs(Math.trunc(topOff)) > 0) || (botOff && Math.abs(Math.trunc(botOff)) > 0)) && scrollRef) { const shift = Math.min(topOff ?? Number.MAX_VALUE, botOff ?? Number.MAX_VALUE); const scrollPos = scrollRef.scrollTop + shift * this.ScreenToLocalBoxXf().Scale; if (this._focusSpeed !== undefined) { setTimeout(() => { scrollPos && (this._scrollStopper = smoothScroll(this._focusSpeed || 0, scrollRef, scrollPos, 'ease', this._scrollStopper)); }); } else { scrollRef.scrollTo({ top: scrollPos }); } this._didScroll = true; } return true; }, dispatchTransaction: this.dispatchTransaction, nodeViews: FormattedTextBox._nodeViews(this), clipboardTextSerializer: this.clipboardTextSerializer, handlePaste: this.handlePaste, }); const { state } = this._editorView; if (!rtfField) { const dataDoc = Doc.IsDelegateField(DocCast(this.layoutDoc.proto), this.fieldKey) ? DocCast(this.layoutDoc.proto) : this.dataDoc; const startupText = Field.toString(dataDoc[fieldKey] as FieldType); const textAlign = StrCast(this.dataDoc[this.fieldKey + '_align'], StrCast(Doc.UserDoc().textAlign)) || 'left'; if (textAlign !== 'left') { selectAll(this._editorView.state, tr => { this.EditorView?.dispatch(tr.replaceSelectionWith(state.schema.nodes.paragraph.create({ align: textAlign }))); }); } if (startupText) { this.EditorView?.dispatch(this.EditorView.state.tr.insertText(startupText)); } this.tryUpdateDoc(true); } this._editorView.TextView = this; } const selectOnLoad = Doc.AreProtosEqual(this._props.TemplateDataDocument ?? this.Document, Doc.SelectOnLoad) && (!DocumentView.LightboxDoc() || DocumentView.LightboxContains(this.DocumentView?.())); const selLoadChar = FormattedTextBox.SelectOnLoadChar; if (selectOnLoad) { Doc.SetSelectOnLoad(undefined); FormattedTextBox.SelectOnLoadChar = ''; } if (this.EditorView && selectOnLoad && !this._props.dontRegisterView && !this._props.dontSelectOnLoad && this.isActiveTab(this.ProseRef)) { this._props.select(false); if (selLoadChar) { const $from = this.EditorView.state.selection.anchor ? this.EditorView.state.doc.resolve(this.EditorView.state.selection.anchor - 1) : undefined; const mark = schema.marks.user_mark.create({ userid: ClientUtils.CurrentUserEmail(), modified: Math.floor(Date.now() / 1000) }); const curMarks = this.EditorView.state.storedMarks ?? $from?.marksAcross(this.EditorView.state.selection.$head) ?? []; const storedMarks = [...curMarks.filter(m => m.type !== mark.type), mark]; const tr1 = this.EditorView.state.tr.setStoredMarks(storedMarks); const tr2 = selLoadChar === 'Enter' ? tr1.insert(this.EditorView.state.doc.content.size - 1, schema.nodes.paragraph.create()) : tr1.insertText(selLoadChar, this.EditorView.state.doc.content.size - 1); const tr = tr2.setStoredMarks(storedMarks); this.EditorView.dispatch(tr.setSelection(new TextSelection(tr.doc.resolve(tr.doc.content.size)))); this.tryUpdateDoc(true); // calling select() above will make isContentActive() true only after a render .. which means the selectAll() above won't write to the Document and the incomingValue will overwrite the selection with the non-updated data } else if (!FormattedTextBox.DontSelectInitialText) { const mark = schema.marks.user_mark.create({ userid: ClientUtils.CurrentUserEmail(), modified: Math.floor(Date.now() / 1000) }); selectAll(this.EditorView.state, (tx: Transaction) => { this.EditorView?.dispatch(tx.addStoredMark(mark)); }); this.EditorView?.dispatch(this.EditorView.state.tr.setSelection(new TextSelection(this.EditorView.state.doc.resolve(1)))); this.tryUpdateDoc(true); // calling select() above will make isContentActive() true only after a render .. which means the selectAll() above won't write to the Document and the incomingValue will overwrite the selection with the non-updated data } else { const $from = this.EditorView.state.selection.anchor ? this.EditorView.state.doc.resolve(this.EditorView.state.selection.anchor - 1) : undefined; const mark = schema.marks.user_mark.create({ userid: ClientUtils.CurrentUserEmail(), modified: Math.floor(Date.now() / 1000) }); const curMarks = this.EditorView.state.storedMarks ?? $from?.marksAcross(this.EditorView.state.selection.$head) ?? []; const storedMarks = [...curMarks.filter(m => m.type !== mark.type), mark]; const { tr } = this.EditorView.state; this.EditorView.dispatch(tr.setSelection(new TextSelection(tr.doc.resolve(tr.doc.content.size))).setStoredMarks(storedMarks)); this.tryUpdateDoc(true); // calling select() above will make isContentActive() true only after a render .. which means the selectAll() above won't write to the Document and the incomingValue will overwrite the selection with the non-updated data } } if (selectOnLoad) { FormattedTextBox.DontSelectInitialText = false; this.EditorView!.focus(); } if (this._props.isContentActive()) this.prepareForTyping(); if (this.EditorView && FormattedTextBox.PasteOnLoad) { const pdfAnchorId = FormattedTextBox.PasteOnLoad.clipboardData?.getData('dash/pdfAnchor'); FormattedTextBox.PasteOnLoad = undefined; pdfAnchorId && this.addPdfReference(pdfAnchorId); } if (this._props.autoFocus) setTimeout(() => this.EditorView!.focus()); // not sure why setTimeout is needed but editing dashFieldView's doesn't work without it. } // add user mark for any first character that was typed since the user mark that gets set in KeyPress won't have been called yet. prepareForTyping = () => { if (this.EditorView) { const { text, paragraph } = schema.nodes; const selNode = this.EditorView.state.selection.$anchor.node(); if (this.EditorView.state.selection.from === 1 && this.EditorView.state.selection.empty && [undefined, text, paragraph].includes(selNode?.type)) { const docDefaultMarks = [schema.marks.user_mark.create({ userid: ClientUtils.CurrentUserEmail(), modified: Math.floor(Date.now() / 1000) })]; this.EditorView.state.selection.empty && this.EditorView.state.selection.from === 1 && this.EditorView?.dispatch(this.EditorView?.state.tr.setStoredMarks(docDefaultMarks).removeStoredMark(schema.marks.pFontColor)); } } }; componentWillUnmount() { if (this._recordingDictation) { this._recordingDictation = !this._recordingDictation; } Object.values(this._disposers).forEach(disposer => disposer?.()); this.endUndoTypingBatch(); FormattedTextBox.LiveTextUndo?.end(); FormattedTextBox.LiveTextUndo = undefined; this.unhighlightSearchTerms(); this.EditorView?.destroy(); RichTextMenu.Instance?.TextView === this && RichTextMenu.Instance.updateMenu(undefined, undefined, undefined, undefined); FormattedTextBoxComment.tooltip && (FormattedTextBoxComment.tooltip.style.display = 'none'); } onPointerDown = (e: React.PointerEvent): void => { if (this.Document.forceActive) e.stopPropagation(); this.tryUpdateScrollHeight(); // if a doc a fitWidth doc is being viewed in different embedContainer (eg freeform & lightbox), then it will have conflicting heights. so when the doc is clicked on, we want to make sure it has the appropriate height for the selected view. const target = e.target as HTMLElement; if (target.tagName === 'AUDIOTAG') { e.preventDefault(); e.stopPropagation(); const timecode = Number(target.dataset?.timecode); DocServer.GetRefField(target.dataset?.audioid || '').then(anchor => { if (anchor instanceof Doc) { // const timecode = NumCast(anchor.timecodeToShow, 0); const audiodoc = anchor.annotationOn as Doc; const func = () => { const docView = DocumentView.getDocumentView(audiodoc); if (!docView) { this._props.addDocTab(audiodoc, OpenWhere.addBottom); setTimeout(func); } else docView.ComponentView?.playFrom?.(timecode, Cast(anchor.timecodeToHide, 'number', null)); // bcz: would be nice to find the next audio tag in the doc and play until that }; func(); } }); } if (this._recordingDictation && !e.ctrlKey && e.button === 0) { this.breakupDictation(); } FormattedTextBoxComment.textBox = this; if (e.button === 0 && this._props.rootSelected?.() && !e.altKey && !e.ctrlKey && !e.metaKey) { if (e.clientX < this.ProseRef!.getBoundingClientRect().right) { // stop propagation if not in sidebar, otherwise nested boxes will lose focus to outer boxes. e.stopPropagation(); // if the text box's content is active, then it consumes all down events document.addEventListener('pointerup', this.onSelectEnd); (this.ProseRef?.children?.[0] as HTMLElement).focus(); } } if (e.button === 2 || (e.button === 0 && e.ctrlKey)) { e.preventDefault(); } }; onSelectEnd = () => { GPTPopup.Instance.setSidebarId(this.sidebarKey); GPTPopup.Instance.addDoc = this.sidebarAddDocument; document.removeEventListener('pointerup', this.onSelectEnd); }; onPointerUp = (e: React.PointerEvent): void => { const state = this.EditorView?.state; if (state && this.ProseRef?.children[0].className.includes('-focused') && this._props.isContentActive() && !e.button) { if (!state.selection.empty && !(state.selection instanceof NodeSelection)) this.setupAnchorMenu(); let clickTarget: HTMLElement | Element | null = e.target as HTMLElement; // hrefs are stored on the dataset of the node that wraps the hyerlink for (let target: HTMLElement | Element | null = clickTarget as HTMLElement; target instanceof HTMLElement && !target.dataset?.targethrefs; target = target.parentElement); while (clickTarget instanceof HTMLElement && !clickTarget.dataset?.targethrefs) clickTarget = clickTarget.parentElement; const dataset = clickTarget instanceof HTMLElement ? clickTarget?.dataset : undefined; FormattedTextBoxComment.update(this, this.EditorView!, undefined, dataset?.targethrefs, dataset?.linkdoc, dataset?.nopreview === 'true'); } }; @action onDoubleClick = (e: React.MouseEvent): void => { FormattedTextBoxComment.textBox = this; if (e.button === 0 && this._props.rootSelected?.() && !e.altKey && !e.ctrlKey && !e.metaKey) { if (e.clientX < this.ProseRef!.getBoundingClientRect().right) { // stop propagation if not in sidebar e.stopPropagation(); // if the text box is selected, then it consumes all click events } } if (e.button === 2 || (e.button === 0 && e.ctrlKey)) { e.preventDefault(); } FormattedTextBoxComment.Hide(); if (e.buttons === 1 && this._props.rootSelected?.() && !e.altKey) { e.stopPropagation(); } }; setFocus = (ipos?: number) => { const pos = ipos ?? (this.EditorView?.state.selection.$from.pos || 1); setTimeout(() => this.EditorView?.dispatch(this.EditorView.state.tr.setSelection(TextSelection.near(this.EditorView.state.doc.resolve(pos)))), 100); setTimeout(() => (this.ProseRef?.children?.[0] as HTMLElement).focus(), 200); }; @action onFocused = (e: React.FocusEvent): void => { // applyDevTools.applyDevTools(this.EditorView); e.stopPropagation(); }; onClick = (e: React.MouseEvent): void => { if (!this._props.isContentActive()) return; const editorView = this.EditorView; const editorRoot = editorView?.root instanceof Document ? editorView.root : undefined; if (editorView && (!this._forceUncollapse || editorRoot?.getSelection()?.isCollapsed)) { // this is a hack to allow the cursor to be placed at the end of a document when the document ends in an inline dash comment. Apparently Chrome on Windows has a bug/feature which breaks this when clicking after the end of the text. const pcords = editorView.posAtCoords({ left: e.clientX, top: e.clientY }); const node = pcords && editorView.state.doc.nodeAt(pcords.pos); // get what prosemirror thinks the clicked node is (if it's null, then we didn't click on any text) if (pcords && node?.type === editorView.state.schema.nodes.dashComment) { this.EditorView!.dispatch(editorView.state.tr.setSelection(TextSelection.create(editorView.state.doc, pcords.pos + 2))); e.preventDefault(); } if (!node && this.ProseRef) { const lastNode = this.ProseRef.children[this.ProseRef.children.length - 1].children[this.ProseRef.children[this.ProseRef.children.length - 1].children.length - 1]; // get the last prosemirror div const boundsRect = lastNode?.getBoundingClientRect(); if (e.clientX > boundsRect.left && e.clientX < boundsRect.right && e.clientY > boundsRect.bottom) { // if we clicked below the last prosemirror div, then set the selection to be the end of the document editorView.focus(); editorView.dispatch(editorView.state.tr.setSelection(TextSelection.create(editorView.state.doc, editorView.state.doc.content.size))); } } else if (node && [editorView.state.schema.nodes.ordered_list, editorView.state.schema.nodes.listItem].includes(node.type) && node !== (editorView.state.selection as NodeSelection)?.node && pcords) { editorView.dispatch(editorView.state.tr.setSelection(NodeSelection.create(editorView.state.doc, pcords.pos))); } } if (editorView && this._props.rootSelected?.()) { // if text box is selected, then it consumes all click events e.stopPropagation(); this.hitBulletTargets(e.clientX, e.clientY, !editorView.state.selection.empty || this._forceUncollapse, false, e.shiftKey); } this._forceUncollapse = !editorRoot?.getSelection()?.isCollapsed; }; // this hackiness handles clicking on the list item bullets to do expand/collapse. the bullets are ::before pseudo elements so there's no real way to hit test against them. hitBulletTargets(x: number, y: number, collapse: boolean, highlightOnly: boolean, selectOrderedList: boolean = false) { this._forceUncollapse = false; clearStyleSheetRules(FormattedTextBox._bulletStyleSheet); const clickPos = this.EditorView!.posAtCoords({ left: x, top: y }); const clickPosVal = clickPos?.pos || 1; let olistPos = clickPosVal; if (clickPos && olistPos && this._props.rootSelected?.()) { const clickNode = this.EditorView?.state.doc.resolve(olistPos).node(); const nodeBef = this.EditorView?.state.doc.resolve(Math.max(0, olistPos - 1)).node(); olistPos = nodeBef?.type === this.EditorView?.state.schema.nodes.ordered_list ? olistPos - 1 : olistPos; let $olistPos = this.EditorView?.state.doc.resolve(olistPos); let olistNode = (nodeBef !== null || clickNode?.type === this.EditorView?.state.schema.nodes.list_item) && olistPos === clickPos?.pos ? clickNode : nodeBef; if (olistNode?.type === this.EditorView?.state.schema.nodes.list_item) { if ($olistPos && $olistPos.depth) { olistNode = $olistPos.parent; $olistPos = this.EditorView?.state.doc.resolve($olistPos.start($olistPos.depth - 1)); } } const maxSize = this.EditorView?.state.doc.content.size ?? 0; const listPos = this.EditorView?.state.doc.resolve(Math.min(maxSize, clickPosVal === olistPos ? clickPosVal + 1 : clickPosVal)); const listNode = listPos?.node(); if (olistNode && olistNode.type === this.EditorView?.state.schema.nodes.ordered_list && listNode) { if (!highlightOnly) { if (selectOrderedList) { this.EditorView.dispatch(this.EditorView.state.tr.setSelection(new NodeSelection(selectOrderedList ? $olistPos! : listPos!))); } else { const nodePos = clickPosVal - (olistPos === clickPosVal ? 0 : 1); if (this.EditorView.state.doc.nodeAt(nodePos)) { const tr = this.EditorView.state.tr.setNodeMarkup(nodePos, listNode.type, { ...listNode.attrs, visibility: !listNode.attrs.visibility }); this.EditorView.dispatch(tr.setSelection(TextSelection.create(tr.doc, nodePos))); } } } addStyleSheetRule(FormattedTextBox._bulletStyleSheet, olistNode.attrs.mapStyle + olistNode.attrs.bulletStyle + ':hover:before', { background: 'gray' }); } } } startUndoTypingBatch() { !this._undoTyping && (this._undoTyping = UndoManager.StartBatch('text edits on ' + this.Document.title)); } public endUndoTypingBatch() { this._undoTyping?.end(); this._undoTyping = undefined; } /** * When a text box loses focus, it might be because a text button was clicked (eg, bold, italics) or color picker. * In these cases, force focus back onto the text box. * @param target */ tryKeepingFocus = (target: Element | null) => { for (let newFocusEle = target instanceof HTMLElement ? target : null; newFocusEle; newFocusEle = newFocusEle?.parentElement) { // test if parent of new focused element is a UI button (should be more specific than testing className) if (newFocusEle?.className === 'fonticonbox' || newFocusEle?.className === 'popup-container') { return this.EditorView?.focus(); // keep focus on text box } } }; @action onBlur = (e: React.FocusEvent) => { this.tryKeepingFocus(e.relatedTarget); if (this.ProseRef?.children[0] !== e.nativeEvent.target) return; if (!(this.EditorView?.state.selection instanceof NodeSelection) || this.EditorView.state.selection.node.type !== this.EditorView.state.schema.nodes.footnote) { const stordMarks = this.EditorView?.state.storedMarks?.slice(); if (!(this.EditorView?.state.selection instanceof NodeSelection)) { this.autoLink(); if (this.EditorView?.state.tr) { const tr = stordMarks?.reduce((tr2, m) => { tr2.addStoredMark(m); return tr2; }, this.EditorView.state.tr); tr && this.EditorView.dispatch(tr); } } } if (RichTextMenu.Instance?.view === this.EditorView && !(this._props.isContentActive() || this._props.rootSelected?.())) { RichTextMenu.Instance?.updateMenu(undefined, undefined, undefined, undefined); } // this is the markdown for @ document publishing to Doc.myPublishedDocs const match = RTFCast(this.Document[this.fieldKey])?.Text.match(/^(@[a-zA-Z][a-zA-Z_0-9 -]*[a-zA-Z_0-9-]+)/); if (match) { this.dataDoc.title_custom = true; this.dataDoc.title = match[1]; // this triggers the collectionDockingView to publish this Doc this.EditorView?.dispatch(this.EditorView?.state.tr.deleteRange(0, match[1].length + 1)); } this.endUndoTypingBatch(); FormattedTextBox.LiveTextUndo?.end(); FormattedTextBox.LiveTextUndo = undefined; // if the text box blurs and none of its contents are focused(), then pass the blur along setTimeout(() => !this.ProseRef?.contains(document.activeElement) && this._props.onBlur?.()); }; onKeyDown = (e: React.KeyboardEvent) => { const { _editorView } = this; if (!_editorView) return; if ((e.altKey || e.ctrlKey) && e.key === 't') { this._props.setTitleFocus?.(); StopEvent(e); return; } const { state } = _editorView; if (!state.selection.empty && e.key === '%') { this._rules!.EnteringStyle = true; StopEvent(e); return; } if (state.selection.empty || !this._rules!.EnteringStyle) { this._rules!.EnteringStyle = false; } for (let i = state.selection.from; i <= state.selection.to; i++) { const node = state.doc.resolve(i); if (state.doc.content.size - 1 > i && node?.marks?.().some(mark => mark.type === schema.marks.user_mark && mark.attrs.userid !== ClientUtils.CurrentUserEmail()) && [AclAugment, AclSelfEdit].includes(GetEffectiveAcl(this.Document))) { e.preventDefault(); } } switch (e.key) { case 'Escape': this.EditorView!.dispatch(state.tr.setSelection(TextSelection.create(state.doc, state.selection.from, state.selection.from))); (document.activeElement as HTMLElement).blur?.(); DocumentView.DeselectAll(); RichTextMenu.Instance?.updateMenu(undefined, undefined, undefined, undefined); return; case 'Enter': this.insertTime(); // eslint-disable-next-line no-fallthrough case 'Tab': e.preventDefault(); break; case 'Space': case 'Backspace': break; default: if ([AclEdit, AclAugment, AclAdmin].includes(GetEffectiveAcl(this.Document))) { const modified = Math.floor(Date.now() / 1000); const mark = state.selection.$to.marks().find(m => m.type === schema.marks.user_mark && m.attrs.modified === modified); _editorView.dispatch(state.tr.removeStoredMark(schema.marks.user_mark).addStoredMark(mark ?? schema.marks.user_mark.create({ userid: ClientUtils.CurrentUserEmail(), modified }))); } break; } e.stopPropagation(); this.startUndoTypingBatch(); }; ondrop = (e: React.DragEvent) => { this.EditorView?.dispatch(updateBullets(this.EditorView.state.tr, this.EditorView.state.schema)); e.stopPropagation(); // drag n drop of text within text note will generate a new note if not caughst, as will dragging in from outside of Dash. }; onScroll = (e: React.UIEvent) => { if (!LinkInfo.Instance?.LinkInfo && this._scrollRef) { this._ignoreScroll = true; this.layoutDoc._layout_scrollTop = this._scrollRef.scrollTop; this._ignoreScroll = false; e.stopPropagation(); e.preventDefault(); } }; tryUpdateScrollHeight = () => { const margins = 2 * NumCast(this.layoutDoc._yMargin, this._props.yPadding || 0); const children = this.ProseRef?.children.length ? Array.from(this.ProseRef.children[0].children) : undefined; if (this.EditorView && children && !SnappingManager.IsDragging) { const getChildrenHeights = (kids: Element[] | undefined) => kids?.reduce((p, child) => p + toHgt(child), margins) ?? 0; const toNum = (val: string) => Number(val.replace('px', '')); const toHgt = (node: Element): number => { const { height, marginTop, marginBottom } = getComputedStyle(node); const childHeight = height === 'auto' ? getChildrenHeights(Array.from(node.children)) : toNum(height); return childHeight + Math.max(0, toNum(marginTop)) + Math.max(0, toNum(marginBottom)); }; const proseHeight = !this.ProseRef ? 0 : getChildrenHeights(children); const scrollHeight = this.ProseRef && proseHeight; if (this._props.setHeight && !this._props.suppressSetHeight && scrollHeight && !this._props.dontRegisterView) { // if top === 0, then the text box is growing upward (as the overlay caption) which doesn't contribute to the height computation const setScrollHeight = () => { this.dataDoc[this.fieldKey + '_scrollHeight'] = scrollHeight; }; if (this.Document === this.layoutDoc || this.layoutDoc.resolvedDataDoc) { setScrollHeight(); } else { setTimeout(setScrollHeight, 10); // if we have a template that hasn't been resolved yet, we can't set the height or we'd be setting it on the unresolved template. So set a timeout and hope its arrived... } } } }; fitContentsToBox = () => BoolCast(this.Document._freeform_fitContentsToBox); sidebarContentScaling = () => (this._props.NativeDimScaling?.() || 1) * NumCast(this.layoutDoc._freeform_scale, 1); sidebarAddDocument = (doc: Doc | Doc[], sidebarKey: string = this.sidebarKey) => { if (!this.layoutDoc._layout_showSidebar) this.toggleSidebar(); return this.addDocument(doc, sidebarKey); }; sidebarMoveDocument = (doc: Doc | Doc[], targetCollection: Doc | undefined, addDocument: (doc: Doc | Doc[]) => boolean) => this.moveDocument(doc, targetCollection, addDocument, this.sidebarKey); sidebarRemDocument = (doc: Doc | Doc[]) => this.removeDocument(doc, this.sidebarKey); setSidebarHeight = (height: number) => { this.dataDoc[this.sidebarKey + '_height'] = height; }; sidebarWidth = () => (Number(this.layout_sidebarWidthPercent.substring(0, this.layout_sidebarWidthPercent.length - 1)) / 100) * this._props.PanelWidth(); sidebarScreenToLocal = () => this._props .ScreenToLocalTransform() .translate(-(this._props.PanelWidth() - this.sidebarWidth()) / (this._props.NativeDimScaling?.() || 1), 0) .scale(1 / NumCast(this.layoutDoc._freeform_scale, 1) / (this._props.NativeDimScaling?.() || 1)); @computed get audioHandle() { return !this._recordingDictation ? null : (
setupMoveUpEvents( this, e, returnFalse, emptyFunction, action(() => { this._recordingDictation = !this._recordingDictation; }) ) }>
); } @computed get sidebarHandle() { TraceMobx(); const annotated = DocListCast(this.dataDoc[this.sidebarKey]).filter(d => d?.author).length; const color = !annotated ? Colors.WHITE : Colors.BLACK; const backgroundColor = !annotated ? (this.sidebarWidth() ? Colors.MEDIUM_BLUE : Colors.BLACK) : (this._props.styleProvider?.(this.layoutDoc, this._props, StyleProp.WidgetColor + (annotated ? ':annotated' : '')) as string); return !annotated && (!this._props.isContentActive() || SnappingManager.IsDragging || Doc.ActiveTool !== InkTool.None) ? null : (
); } @computed get sidebarCollection() { const renderComponent = (tag: string) => { // eslint-disable-next-line @typescript-eslint/no-explicit-any const ComponentTag: any = tag === CollectionViewType.Tree ? CollectionTreeView : tag === 'translation' ? FormattedTextBox : CollectionStackingView; return ComponentTag === CollectionStackingView ? ( ) : (
setupMoveUpEvents(this, e, returnFalse, emptyFunction, () => DocumentView.SelectView(this.DocumentView?.(), false), true)}>
); }; return (
{renderComponent(StrCast(this.layoutDoc[this.sidebarKey + '_type_collection']))}
); } cycleAlternateText = (skipHover?: boolean) => { this.layoutDoc._layout_enableAltContentUI = true; const usePath = this.layoutDoc[`_${this._props.fieldKey}_usePath`]; this.layoutDoc[`_${this._props.fieldKey}_usePath`] = usePath === undefined ? 'alternate' : usePath === 'alternate' && !skipHover ? 'alternate:hover' : undefined; }; @computed get overlayAlternateIcon() { const usePath = this.layoutDoc[`_${this._props.fieldKey}_usePath`]; return ( toggle (%/) between primary and alternate and show alternate on hover }>
setupMoveUpEvents(e.target, e, returnFalse, emptyFunction, () => this.cycleAlternateText())} style={{ display: this._props.isContentActive() && !SnappingManager.IsDragging ? 'flex' : 'none', background: usePath === undefined ? 'white' : usePath === 'alternate' ? 'black' : 'gray', color: usePath === undefined ? 'black' : 'white', }}>
); } get fieldKey() { return this._fieldKey; } @computed get _fieldKey() { const usePath = this._props.ignoreUsePath ? '' : StrCast(this.layoutDoc[`${this._props.fieldKey}_usePath`]); return this._props.fieldKey + (usePath && (!usePath.includes(':hover') || this._props.isHovering?.() || this._props.isContentActive()) ? `_${usePath.replace(':hover', '')}` : ''); } onPassiveWheel = (e: WheelEvent) => { if (e.clientX > this.ProseRef!.getBoundingClientRect().right) { return; } // if scrollTop is 0, then don't let wheel trigger scroll on any container (which it would since onScroll won't be triggered on this) if (this._props.isContentActive()) { const scale = this._props.NativeDimScaling?.() || 1; const styleFromLayout = styleFromLayoutString(this.Document, this._props, scale); // this converts any expressions in the format string to style props. e.g., const height = Number(styleFromLayout.height?.replace('px', '')); // prevent default if selected || child is active but this doc isn't scrollable if ( !isNaN(height) && (this._scrollRef?.scrollHeight ?? 0) <= Math.ceil((height || this._props.PanelHeight()) / scale) && // (this._props.rootSelected?.() || this.isAnyChildContentActive()) ) { e.preventDefault(); } e.stopPropagation(); } }; render() { TraceMobx(); const scale = this._props.NativeDimScaling?.() || 1; const rounded = StrCast(this.layoutDoc.layout_borderRounding) === '100%' ? '-rounded' : ''; setTimeout(() => !this._props.isContentActive() && FormattedTextBoxComment.textBox === this && FormattedTextBoxComment.Hide); const scrSize = (which: number, view = this._props.docViewPath().slice(-which)[0]) => [view._props.PanelWidth() / view.screenToLocalScale(), view._props.PanelHeight() / view.screenToLocalScale()]; // prettier-ignore const scrMargin = [Math.max(0, (scrSize(2)[0] - scrSize(1)[0]) / 2), Math.max(0, (scrSize(2)[1] - scrSize(1)[1]) / 2)]; const paddingX = Math.max(NumCast(this.layoutDoc._xMargin), this._props.xPadding ?? 0, 0, ((this._props.screenXPadding?.() ?? 0) - scrMargin[0]) * this.ScreenToLocalBoxXf().Scale); const paddingY = Math.max(NumCast(this.layoutDoc._yMargin), 0, ((this._props.yPadding ?? 0) - scrMargin[1]) * this.ScreenToLocalBoxXf().Scale); const styleFromLayout = styleFromLayoutString(this.Document, this._props, scale); // this converts any expressions in the format string to style props. e.g., return this.isLabel ? ( ) : styleFromLayout?.height === '0px' ? null : (
{ this._oldWheel?.removeEventListener('wheel', this.onPassiveWheel); this._oldWheel = r; r?.addEventListener('wheel', this.onPassiveWheel, { passive: false }); }} style={{ ...(this._props.dontScale ? {} : { transform: `scale(${scale})`, width: `${100 / scale}%`, height: `${100 / scale}%`, }), transition: 'inherit', paddingBottom: this.tagsHeight, // overflowY: this.layoutDoc._layout_autoHeight ? "hidden" : undefined, color: this.fontColor, fontSize: this.fontSize, fontFamily: this.fontFamily, fontWeight: this.fontWeight, fontStyle: this.fontStyle, textDecoration: this.fontDecoration, ...styleFromLayout, }}>
this.hitBulletTargets(e.clientX, e.clientY, e.shiftKey, true)} onBlur={this.onBlur} onPointerUp={this.onPointerUp} onPointerDown={this.onPointerDown} onDoubleClick={this.onDoubleClick}>
{ this._scrollRef = r; }} style={{ width: this.noSidebar ? '100%' : `calc(100% - ${this.layout_sidebarWidthPercent})`, overflow: this.layoutDoc._createDocOnCR || this.layoutDoc._layout_hideScroll ? 'hidden' : this.layout_autoHeight ? 'visible' : undefined, }} onScroll={this.onScroll} onDrop={this.ondrop}>
{this.noSidebar || !this.SidebarShown || this.layout_sidebarWidthPercent === '0%' ? null : this.sidebarCollection} {this.noSidebar || this.Document._layout_noSidebar || this.Document._createDocOnCR || this.layoutDoc._chromeHidden || this.Document.quiz ? null : this.sidebarHandle} {this.audioHandle} {this.layoutDoc._layout_enableAltContentUI && !this.layoutDoc._chromeHidden ? this.overlayAlternateIcon : null}
); } } Docs.Prototypes.TemplateMap.set(DocumentType.RTF, { layout: { view: FormattedTextBox, dataField: 'text' }, options: { acl: '', _height: 35, _xMargin: 10, _yMargin: 10, _layout_nativeDimEditable: true, _layout_reflowVertical: true, _layout_reflowHorizontal: true, defaultDoubleClick: 'ignore', systemIcon: 'BsFileEarmarkTextFill', }, });