import { IconProp } from '@fortawesome/fontawesome-svg-core'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { Tooltip } from '@mui/material'; import { isEqual } from 'lodash'; import { action, computed, IReactionDisposer, observable, ObservableSet, reaction, runInAction } 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, TextSelection, Transaction } from 'prosemirror-state'; import { EditorView } from 'prosemirror-view'; import { BsMarkdownFill } from 'react-icons/bs'; import { DateField } from '../../../../fields/DateField'; import { Doc, DocListCast, Field, Opt } 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 { RichTextUtils } from '../../../../fields/RichTextUtils'; import { ComputedField } from '../../../../fields/ScriptField'; import { BoolCast, Cast, DocCast, FieldValue, NumCast, ScriptCast, StrCast } from '../../../../fields/Types'; import { GetEffectiveAcl, TraceMobx } from '../../../../fields/util'; import { addStyleSheet, addStyleSheetRule, clearStyleSheetRules, emptyFunction, numberRange, returnFalse, returnZero, setupMoveUpEvents, smoothScroll, unimplementedFunction, Utils } from '../../../../Utils'; import { GoogleApiClientUtils, Pulls, Pushes } from '../../../apis/google_docs/GoogleApiClientUtils'; import { gptAPICall, GPTCallType } from '../../../apis/gpt/GPT'; import { DocServer } from '../../../DocServer'; import { Docs, DocUtils } from '../../../documents/Documents'; import { CollectionViewType } from '../../../documents/DocumentTypes'; import { DictationManager } from '../../../util/DictationManager'; import { DocumentManager } from '../../../util/DocumentManager'; import { DragManager } from '../../../util/DragManager'; import { MakeTemplate } from '../../../util/DropConverter'; import { LinkManager } from '../../../util/LinkManager'; import { RTFMarkup } from '../../../util/RTFMarkup'; import { SelectionManager } from '../../../util/SelectionManager'; import { SnappingManager } from '../../../util/SnappingManager'; import { undoable, undoBatch, UndoManager } from '../../../util/UndoManager'; import { CollectionFreeFormView } from '../../collections/collectionFreeForm/CollectionFreeFormView'; import { CollectionStackingView } from '../../collections/CollectionStackingView'; import { CollectionTreeView } from '../../collections/CollectionTreeView'; import { ContextMenu } from '../../ContextMenu'; import { ContextMenuProps } from '../../ContextMenuItem'; import { ViewBoxAnnotatableComponent } from '../../DocComponent'; import { DocumentButtonBar } from '../../DocumentButtonBar'; import { Colors } from '../../global/globalEnums'; import { LightboxView } from '../../LightboxView'; import { AnchorMenu } from '../../pdf/AnchorMenu'; import { GPTPopup } from '../../pdf/GPTPopup/GPTPopup'; import { SidebarAnnos } from '../../SidebarAnnos'; import { StyleProp } from '../../StyleProvider'; import { media_state } from '../AudioBox'; import { DocFocusOptions, DocumentView, DocumentViewInternal, OpenWhere } from '../DocumentView'; import { FieldView, FieldViewProps } from '../FieldView'; import { LinkDocPreview } from '../LinkDocPreview'; import { PinProps, PresBox } from '../trails'; import { DashDocCommentView } from './DashDocCommentView'; import { DashDocView } from './DashDocView'; import { DashFieldView } from './DashFieldView'; import { EquationView } from './EquationView'; import { FootnoteView } from './FootnoteView'; 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 { SummaryView } from './SummaryView'; import * as applyDevTools from 'prosemirror-dev-tools'; import * as React from 'react'; export const GoogleRef = 'googleDocId'; type PullHandler = (exportState: Opt, dataDoc: Doc) => void; export interface FormattedTextBoxProps {} @observer export class FormattedTextBox extends ViewBoxAnnotatableComponent() { public static LayoutString(fieldStr: string) { return FieldView.LayoutString(FormattedTextBox, fieldStr); } public static blankState = () => EditorState.create(FormattedTextBox.Instance.config); public static Instance: FormattedTextBox; public static LiveTextUndo: UndoManager.Batch | undefined; static _globalHighlightsCache: string = ''; static _globalHighlights = new ObservableSet(['Audio Tags', 'Text from Others', 'Todo Items', 'Important Items', 'Disagree Items', 'Ignore Items']); static _highlightStyleSheet: any = addStyleSheet(); static _bulletStyleSheet: any = addStyleSheet(); static _userStyleSheet: any = addStyleSheet(); static _hadSelection: boolean = false; private _selectionHTML: string | undefined; private _sidebarRef = React.createRef(); private _sidebarTagRef = React.createRef(); private _ref: React.RefObject = React.createRef(); private _scrollRef: React.RefObject = React.createRef(); private _editorView: Opt; public _applyingChange: string = ''; private _finishingLink = false; private _searchIndex = 0; private _lastTimedMark: Mark | undefined = undefined; private _cachedLinks: Doc[] = []; private _undoTyping?: UndoManager.Batch; private _disposers: { [name: string]: IReactionDisposer } = {}; private _dropDisposer?: DragManager.DragDropDisposer; private _recordingStart: number = 0; private _ignoreScroll = false; private _lastText = ''; private _hadDownFocus = false; private _focusSpeed: Opt; private _keymap: any = undefined; 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 _forceDownNode: Node | undefined; private _downX = 0; private _downY = 0; private _downTime = 0; private _break = true; public ProseRef?: HTMLDivElement; public get EditorView() { return this._editorView; } public get SidebarKey() { return this.fieldKey + '_sidebar'; } @computed get allSidebarDocs() { return DocListCast(this.dataDoc[this.SidebarKey]); } @computed get noSidebar() { return this.props.docViewPath().lastElement()?.props.hideDecorationTitle || this.props.noSidebar || this.Document._layout_noSidebar; } @computed get layout_sidebarWidthPercent() { return this._showSidebar ? '20%' : StrCast(this.layoutDoc._layout_sidebarWidthPercent, '0%'); } @computed get sidebarColor() { return StrCast(this.layoutDoc.sidebar_color, StrCast(this.layoutDoc[this.fieldKey + '_backgroundColor'], '#e4e4e4')); } @computed get layout_autoHeight() { return (this.props.forceAutoHeight || this.layoutDoc._layout_autoHeight) && !this.props.ignoreAutoHeight; } @computed get textHeight() { return NumCast(this.dataDoc[this.fieldKey + '_height']); } @computed get scrollHeight() { return NumCast(this.dataDoc[this.fieldKey + '_scrollHeight']); } @computed get sidebarHeight() { return !this.sidebarWidth() ? 0 : NumCast(this.dataDoc[this.SidebarKey + '_height']); } @computed get titleHeight() { return this.props.styleProvider?.(this.layoutDoc, this.props, StyleProp.HeaderMargin) || 0; } @computed get layout_autoHeightMargins() { return this.titleHeight + NumCast(this.layoutDoc._layout_autoHeightMargins); } @computed get _recordingDictation() { return this.dataDoc?.mediaState === media_state.Recording; } set _recordingDictation(value) { !this.dataDoc[`${this.fieldKey}_recordingSource`] && (this.dataDoc.mediaState = value ? media_state.Recording : undefined); } @computed get config() { this._keymap = buildKeymap(schema, this.props); this._rules = new RichTextRules(this.Document, this); return { schema, plugins: [ inputRules(this._rules.inpRules), this.richTextMenuPlugin(), history(), keymap(this._keymap), keymap(baseKeymap), new Plugin({ props: { attributes: { class: 'ProseMirror-example-setup-style' } } }), new Plugin({ view(editorView) { return new FormattedTextBoxComment(editorView); }, }), ], }; } // State for GPT @observable private gptRes: string = ''; public static PasteOnLoad: ClipboardEvent | undefined; private static SelectOnLoad: Doc | undefined; public static SetSelectOnLoad(doc: Doc) { FormattedTextBox.SelectOnLoad = doc; } public static DontSelectInitialText = false; // whether initial text should be selected or not public static SelectOnLoadChar = ''; public static IsFragment(html: string) { return html.indexOf('data-pm-slice') !== -1; } public static GetHref(html: string): string { const parser = new DOMParser(); const parsedHtml = parser.parseFromString(html, 'text/html'); if (parsedHtml.body.childNodes.length === 1 && parsedHtml.body.childNodes[0].childNodes.length === 1 && (parsedHtml.body.childNodes[0].childNodes[0] as any).href) { return (parsedHtml.body.childNodes[0].childNodes[0] as any).href; } return ''; } public static GetDocFromUrl(url: string) { return url.startsWith(document.location.origin) ? new URL(url).pathname.split('doc/').lastElement() : ''; // docId } constructor(props: any) { super(props); FormattedTextBox.Instance = this; this._recordingStart = Date.now(); } // 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 = linkDoc?.link_anchor_1 as Doc; const a2 = linkDoc?.link_anchor_2 as Doc; if (state && a1 && a2 && this._editorView) { this.removeDocument(a1); this.removeDocument(a2); var allFoundLinkAnchors: any[] = []; state.doc.nodesBetween(0, state.doc.nodeSize - 2, (node: any, pos: number, parent: any) => { const foundLinkAnchors = findLinkMark(node.marks)?.attrs.allAnchors.filter((a: any) => 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) => { if (!pinProps && this._editorView?.state.selection.empty) return this.Document; const anchor = Docs.Create.ConfigDocument({ title: StrCast(this.Document.title), annotationOn: this.Document }); this.addDocument(anchor); this._finishingLink = true; this.makeLinkAnchor(anchor, OpenWhere.addRight, undefined, 'Anchored Selection', false, addAsAnnotation); this._finishingLink = false; PresBox.pinDocView(anchor, { pinDocLayout: pinProps?.pinDocLayout, pinData: { ...(pinProps?.pinData ?? {}), scrollable: true } }, this.Document); return anchor; }; @action setupAnchorMenu = () => { AnchorMenu.Instance.Status = 'marquee'; AnchorMenu.Instance.OnClick = (e: PointerEvent) => { !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 = (e: PointerEvent) => { !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: any; Doc.GetProto(target).mediaState = media_state.Recording; Doc.GetProto(target).audioAnnoState = 'recording'; DocumentViewInternal.recordAudioAnnotation(Doc.GetProto(target), Doc.LayoutFieldKey(target), stop => (stopFunc = stop)); let reactionDisposer = reaction( () => target.mediaState, action(dictation => { if (!dictation) { Doc.GetProto(target).audioAnnoState = 'stopped'; stopFunc(); reactionDisposer(); } }) ); target.title = ComputedField.MakeFunction(`self["text_audioAnnotations_text"].lastElement()`); } }); }; AnchorMenu.Instance.Highlight = undoable( action((color: string, isLinkButton: boolean) => { this._editorView?.state && RichTextMenu.Instance.setHighlight(color); return undefined; }), '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, undefined, annotationOn); FormattedTextBox.SetSelectOnLoad(target); return target; }; DragManager.StartAnchorAnnoDrag([ele], new DragManager.AnchorAnnoDragData(this.props.docViewPath().lastElement(), () => this.getAnchor(true), targetCreator), e.pageX, e.pageY); }); const coordsB = this._editorView!.coordsAtPos(this._editorView!.state.selection.to); this.props.rootSelected?.() && AnchorMenu.Instance.jumpTo(coordsB.left, coordsB.bottom); let ele: Opt = undefined; try { const contents = window.getSelection()?.getRangeAt(0).cloneContents(); if (contents) { ele = document.createElement('div'); ele.append(contents); } this._selectionHTML = ele?.innerHTML; } catch (e) {} }; dispatchTransaction = (tx: Transaction) => { if (this._editorView && (this._editorView as any).docView) { const state = this._editorView.state.apply(tx); this._editorView.updateState(state); const dataDoc = Doc.IsDelegateField(DocCast(this.layoutDoc.proto), this.fieldKey) ? DocCast(this.layoutDoc.proto) : this.dataDoc; const newText = state.doc.textBetween(0, state.doc.content.size, ' \n'); const newJson = JSON.stringify(state.toJSON()); const prevData = Cast(this.layoutDoc[this.fieldKey], RichTextField, null); // the actual text in the text box const templateData = this.Document !== this.layoutDoc ? prevData : undefined; // the default text stored in a layout template const protoData = Cast(Cast(dataDoc.proto, Doc, null)?.[this.fieldKey], RichTextField, null); // 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: any, pos: number, parent: any) => { if (node.type === schema.nodes.dashField && node.attrs.fieldKey.startsWith('#')) { accumTags.push(node.attrs.fieldKey); } }); dataDoc.tags = accumTags.length ? new List(Array.from(new Set(accumTags))) : undefined; let unchanged = true; if (this._applyingChange !== this.fieldKey && removeSelection(newJson) !== removeSelection(prevData?.Data)) { this._applyingChange = this.fieldKey; const textChange = newText !== prevData?.Text; textChange && (dataDoc[this.fieldKey + '_modificationDate'] = new DateField(new Date(Date.now()))); if ((!prevData && !protoData) || newText || (!newText && !templateData)) { // 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 ((this._finishingLink || this.props.isContentActive()) && removeSelection(newJson) !== removeSelection(prevData?.Data)) { const numstring = NumCast(dataDoc[this.fieldKey], null); dataDoc[this.fieldKey] = numstring !== undefined ? Number(newText) : new RichTextField(newJson, newText); dataDoc[this.fieldKey + '_noTemplate'] = true; // mark the data field as being split from the template if it has been edited textChange && ScriptCast(this.layoutDoc.onTextChanged, null)?.script.run({ this: this.Document, text: newText }); unchanged = false; } } else { // 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((protoData || prevData).Data))); dataDoc[this.fieldKey + '_noTemplate'] = undefined; // mark the data field as not being split from any template it might have 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; let link; LinkManager.Links(this.dataDoc).forEach((l, i) => { const anchor = (l.link_anchor_1 as Doc).annotationOn ? (l.link_anchor_1 as Doc) : (l.link_anchor_2 as Doc).annotationOn ? (l.link_anchor_2 as Doc) : undefined; if (anchor && (anchor.annotationOn as Doc).mediaState === media_state.Recording) { linkTime = NumCast(anchor._timecodeToShow /* audioStart */); linkAnchor = anchor; link = l; } }); if (this._editorView && linkTime) { const state = this._editorView.state; const now = Date.now(); let mark = schema.marks.user_mark.create({ userid: Doc.CurrentUserEmail, modified: Math.floor(now / 1000) }); if (!this._break && state.selection.to !== state.selection.from) { for (let i = state.selection.from; i <= state.selection.to; i++) { const pos = state.doc.resolve(i); const um = Array.from(pos.marks()).find(m => m.type === schema.marks.user_mark); if (um) { mark = um; break; } } } const path = (this._editorView.state.selection.$from as any).path; if (linkAnchor && path[path.length - 3].type !== this._editorView.state.schema.nodes.code_block) { const time = linkTime + Date.now() / 1000 - this._recordingStart / 1000; this._break = false; const from = state.selection.from; const value = this._editorView.state.schema.nodes.audiotag.create({ timeCode: time, audioId: linkAnchor[Id] }); const replaced = this._editorView.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 = LinkManager.Links(this.props.Document).filter(link => link.link_relationship === LinkManager.AutoKeywords); if (this._editorView?.state.doc.textContent) { const isNodeSel = this._editorView.state.selection instanceof NodeSelection; const f = this._editorView.state.selection.from; const t = this._editorView.state.selection.to; var tr = this._editorView.state.tr as any; const autoAnch = this._editorView.state.schema.marks.autoLinkAnchor; tr = tr.removeMark(0, tr.doc.content.size, autoAnch); Doc.MyPublishedDocs.forEach(term => (tr = this.hyperlinkTerm(tr, term, newAutoLinks))); tr = tr.setSelection(isNodeSel && false ? new NodeSelection(tr.doc.resolve(f)) : new TextSelection(tr.doc.resolve(f), tr.doc.resolve(t))); this._editorView?.dispatch(tr); } oldAutoLinks.filter(oldLink => !newAutoLinks.has(oldLink) && oldLink.link_anchor_2 !== this.Document).forEach(LinkManager.Instance.deleteLink); }; updateTitle = () => { const title = StrCast(this.dataDoc.title, Cast(this.dataDoc.title, RichTextField, null)?.Text); if ( !this.props.dontRegisterView && // (this.props.Document.isTemplateForField === "text" || !this.props.Document.isTemplateForField) && // only update the title if the data document's data field is changing (title.startsWith('-') || 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 = str.startsWith('@') ? '' : '-'; 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(); if (str.startsWith('@') && str.length > 1) { Doc.AddToMyPublished(this.Document); } } } }; // creates links between terms in a document and published documents (myPublishedDocs) that have titles starting with an '@' hyperlinkTerm = (tr: any, target: Doc, newAutoLinks: Set) => { const editorView = this._editorView; if (editorView && (editorView as any).docView && !Doc.AreProtosEqual(target, this.Document)) { const autoLinkTerm = StrCast(target.title).replace(/^@/, ''); var alink: Doc | undefined; this.findInNode(editorView, editorView.state.doc, autoLinkTerm).forEach(sel => { const splitter = editorView.state.schema.marks.splitter.create({ id: Utils.GenerateGuid() }); if (!sel.$anchor.pos || editorView.state.doc.textBetween(sel.$anchor.pos - 1, sel.$to.pos).trim() === autoLinkTerm) { tr = tr.addMark(sel.from, sel.to, splitter); tr.doc.nodesBetween(sel.from, sel.to, (node: any, 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 ?? (LinkManager.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.props.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) => { if (this._editorView && (this._editorView as any).docView && terms.some(t => t)) { const mark = this._editorView.state.schema.mark(this._editorView.state.schema.marks.search_highlight); const activeMark = this._editorView.state.schema.mark(this._editorView.state.schema.marks.search_highlight, { selected: true }); const res = terms.filter(t => t).map(term => this.findInNode(this._editorView!, this._editorView!.state.doc, term)); const length = res[0].length; let tr = this._editorView.state.tr; 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] && this._editorView.dispatch(tr.setSelection(new TextSelection(tr.doc.resolve(flattened[lastSel].from), tr.doc.resolve(flattened[lastSel].to))).scrollIntoView()); } }; unhighlightSearchTerms = () => { if (window.screen.width < 600) null; else if (this._editorView && (this._editorView as any).docView) { const mark = this._editorView.state.schema.mark(this._editorView.state.schema.marks.search_highlight); const activeMark = this._editorView.state.schema.mark(this._editorView.state.schema.marks.search_highlight, { selected: true }); const end = this._editorView.state.doc.nodeSize - 2; this._editorView.dispatch(this._editorView.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: Doc.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 @action 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.draggedDocuments.lastElement(); let added: Opt; if ([AclEdit, AclAdmin, AclSelfEdit].includes(effectiveAcl)) { // replace text contents when dragging with Alt if (de.altKey) { const fieldKey = Doc.LayoutFieldKey(draggedDoc); if (draggedDoc[fieldKey] instanceof RichTextField && !Doc.AreProtosEqual(draggedDoc, this.props.Document)) { Doc.GetProto(this.dataDoc)[this.fieldKey] = Field.Copy(draggedDoc[fieldKey]); } // embed document when drag marked as embed } else if (de.embedKey) { const node = schema.nodes.dashDoc.create({ width: NumCast(draggedDoc._width), height: NumCast(draggedDoc._height), title: 'dashDoc', docId: draggedDoc[Id], float: 'unset', }); if (!['embed', 'copy'].includes((dragData.dropAction ?? '') as any)) { added = dragData.removeDocument?.(draggedDoc) ? true : false; } if (added) { draggedDoc._freeform_fitContentsToBox = true; Doc.SetContainer(draggedDoc, this.Document); const view = this._editorView!; try { const pos = view.posAtCoords({ left: de.x, top: de.y })?.pos; pos && view.dispatch(view.state.tr.insert(pos, node)); added = pos ? true : false; // pos will be null if you don't drop onto an actual text location } catch (e) { console.log('Drop failed', e); added = 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 as any).content.length; i++) { const result = this.getNodeEndpoints((context.content as any).content[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 as any).content[i].nodeSize; } return null; } else { 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, foundAt; const ep = this.getNodeEndpoints(pm.state.doc, node); const regexp = new RegExp(find.replace('*', ''), 'i'); if (regexp) { while (ep && (foundAt = node.textContent.slice(index).search(regexp)) > -1) { const sel = new TextSelection(pm.state.doc.resolve(ep.from + index + foundAt + 1), pm.state.doc.resolve(ep.from + index + foundAt + find.length + 1)); ret.push(sel); index = index + foundAt + find.length; } } } else { node.content.forEach((child, i) => (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-' + Doc.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-' + Doc.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-' + Doc.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 intereted in layout changes triggered by css changes (eg., CollectionLinkView) }; @observable _showSidebar = false; @computed get SidebarShown() { return this._showSidebar || this.layoutDoc._layout_showSidebar ? true : false; } @action toggleSidebar = (preview: boolean = false) => { const defaultSidebar = 250; const prevWidth = 1 - this.sidebarWidth() / Number(getComputedStyle(this._ref.current!).width.replace('px', '')); 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, (e, movement, isClick) => !isClick && batch.end(), () => { this.toggleSidebar(); batch.end(); }, true ); }; sidebarMove = (e: PointerEvent, down: number[], delta: number[]) => { const localDelta = this.props .ScreenToLocalTransform() .scale(this.props.NativeDimScaling?.() || 1) .transformDirection(delta[0], delta[1]); 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'); LinkManager.Instance.deleteLink(LinkManager.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 => { const cm = ContextMenu.Instance; const editor = this._editorView!; const pcords = editor.posAtCoords({ left: e.clientX, top: e.clientY }); let target = e.target as any; // hrefs are stored on the database of the node that wraps the hyerlink while (target && !target.dataset?.targethrefs) target = target.parentElement; if (target && !(e.nativeEvent as any).dash) { 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 = undoBatch(() => { const sel = editor.state.selection; editor.dispatch(editor.state.tr.removeMark(sel.from, sel.to, editor.state.schema.marks.linkAnchor)); }); e.persist(); anchorDoc && DocServer.GetRefField(anchorDoc).then( action(anchor => { anchor && SelectionManager.SelectSchemaViewDoc(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: undoBatch(() => { Doc.setNativeView(this.Document); this.layoutDoc.layout_autoHeightMargins = undefined; }), icon: 'eye', }); changeItems.push({ description: 'metadata', event: undoBatch(() => { this.dataDoc.layout_meta = Cast(Doc.UserDoc().emptyHeader, Doc, null)?.layout; this.Document.layout_fieldKey = 'layout_meta'; setTimeout(() => (this.layoutDoc._headerHeight = this.layoutDoc._layout_autoHeightMargins = 50), 50); }), 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: undoBatch(() => { this.layoutDoc.layout_autoHeightMargins = undefined; Doc.setNativeView(this.Document); DocUtils.makeCustomViewClicked(this.Document, Docs.Create.TreeDocument, StrCast(note.title), note); }), 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: ContextMenuProps[] = appearance && 'subitems' in appearance ? appearance.subitems : []; 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', }); !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) { const title = StrCast(this.Document.title); this.Document.title = 'text'; MakeTemplate(this.Document, true, title); } 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' in options ? options.subitems : []; optionItems.push({ description: `Generate Dall-E Image`, event: () => this.generateImage(), icon: 'star' }); 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: `show markdown options`, event: RTFMarkup.Instance.open, icon: }); !options && cm.addItem({ description: 'Options...', subitems: optionItems, icon: 'eye' }); this._downX = this._downY = Number.NaN; }; animateRes = (resIndex: number, newText: string) => { if (resIndex < newText.length) { const marks = this._editorView?.state.storedMarks ?? []; this._editorView?.dispatch(this._editorView.state.tr.setStoredMarks(marks).insertText(newText[resIndex]).setStoredMarks(marks)); setTimeout(() => { this.animateRes(resIndex + 1, newText); }, 20); } }; askGPT = action(async () => { try { let res = await gptAPICall((this.dataDoc.text as RichTextField)?.Text, GPTCallType.COMPLETION); if (!res) { console.error('GPT call failed'); this.animateRes(0, 'Something went wrong.'); } else { this.animateRes(0, res); } } catch (err) { console.error('GPT call failed'); 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.state; const to = state.selection.to; const updated = TextSelection.create(state.doc, to, to); this._editorView.dispatch(state.tr.setSelection(updated).insertText('\n', to)); 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 = DocUtils.MakeLinkToActiveAudio(textanchorFunc, false).lastElement(); if (link) { Doc.GetProto(link).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], }); Doc.GetProto(textanchor).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.from; 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 state = this._editorView?.state; if (state) { let selectedText = ''; const sel = state.selection; const splitter = state.schema.marks.splitter.create({ id: Utils.GenerateGuid() }); let tr = state.tr.addMark(sel.from, sel.to, splitter); if (sel.from !== sel.to) { const anchor = anchorDoc ?? Docs.Create.ConfigDocument({ // title: 'text(' + this._editorView?.state.doc.textBetween(sel.from, sel.to) + ')', annotationOn: this.dataDoc, }); const href = targetHref ?? Doc.localServerPath(anchor); if (anchor !== anchorDoc && addAsAnnotation) this.addDocument(anchor); tr.doc.nodesBetween(sel.from, sel.to, (node: any, 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(sel.from, sel.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 = async (doc: Doc) => { if (DocListCast(this.dataDoc[this.SidebarKey]).find(anno => Doc.AreProtosEqual(doc.layout_unrendered ? DocCast(doc.annotationOn) : doc, anno))) { !this.SidebarShown && this.toggleSidebar(false); setTimeout(() => this._sidebarRef?.current?.makeDocUnfiltered(doc)); } return new Promise>(res => DocumentManager.Instance.AddViewRenderedCb(doc, dv => res(dv))); }; focus = (textAnchor: Doc, options: DocFocusOptions) => { const focusSpeed = options.zoomTime ?? 500; const textAnchorId = textAnchor[Id]; 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; }; let start = 0; 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 editor = this._editorView; const ret = findAnchorFrag(editor.state.doc.content, editor); const content = (ret.frag as any)?.content; if ((ret.frag.size || (content?.length && content[0].type === this._editorView.state.schema.nodes.dashDoc) || (content?.length && content[0].type === this._editorView.state.schema.nodes.audiotag)) && ret.start >= 0) { !options.instant && (this._focusSpeed = focusSpeed); let selection = TextSelection.near(editor.state.doc.resolve(ret.start)); // default to near the start if (ret.frag.firstChild) { selection = TextSelection.between(editor.state.doc.resolve(ret.start), editor.state.doc.resolve(ret.start + ret.frag.firstChild.nodeSize)); // bcz: looks better to not have the target selected } editor.dispatch(editor.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; } else { return this.props.focus(this.Document, options); } } }; // 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 contentScaling() { return Doc.NativeAspect(this.Document, this.dataDoc, false) ? this.props.NativeDimScaling?.() || 1 : 1; } componentDidMount() { !this.props.dontSelectOnLoad && this.props.setContentView?.(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 = LinkManager.Links(this.Document); this._disposers.breakupDictation = reaction(() => Doc.RecordingEvent, this.breakupDictation); this._disposers.layout_autoHeight = reaction( () => this.layout_autoHeight, layout_autoHeight => layout_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(), width => this.tryUpdateScrollHeight() ); this._disposers.scrollHeight = reaction( () => ({ scrollHeight: this.scrollHeight, layout_autoHeight: this.layout_autoHeight, width: NumCast(this.layoutDoc._width) }), ({ width, scrollHeight, layout_autoHeight }) => width && layout_autoHeight && 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, layout_autoHeight: this.layout_autoHeight, marginsHeight: this.layout_autoHeightMargins }), ({ sidebarHeight, textHeight, layout_autoHeight, marginsHeight }) => { const newHeight = this.contentScaling * (marginsHeight + Math.max(sidebarHeight, textHeight)); if ( (!Array.from(FormattedTextBox._globalHighlights).includes('Bold Text') || this.props.isSelected()) && // layout_autoHeight && newHeight && newHeight !== this.layoutDoc.height && !this.props.dontRegisterView ) { this.props.setHeight?.(newHeight); } }, { fireImmediately: !Array.from(FormattedTextBox._globalHighlights).includes('Bold Text') } ); this._disposers.links = reaction( () => LinkManager.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.buttonBar = reaction( () => DocumentButtonBar.Instance, instance => { if (instance) { this.pullFromGoogleDoc(this.checkState); this.dataDoc[GoogleRef] && this.dataDoc.googleDocUnchanged && runInAction(() => (instance.isAnimatingFetch = true)); } } ); this._disposers.editorState = reaction( () => { const dataDoc = Doc.IsDelegateField(DocCast(this.layoutDoc?.proto), this.fieldKey) ? DocCast(this.layoutDoc?.proto) : this?.dataDoc; const whichDoc = !this.dataDoc || !this.layoutDoc ? undefined : dataDoc?.[this.fieldKey + '_noTemplate'] || !this.layoutDoc[this.fieldKey] ? dataDoc : this.layoutDoc; return !whichDoc ? undefined : { data: Cast(whichDoc[this.fieldKey], RichTextField, null), str: Field.toString(DocCast(whichDoc[this.fieldKey])) }; }, 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 (incomingValue?.str) { selectAll(this._editorView.state, tx => this._editorView?.dispatch(tx.insertText(incomingValue.str))); } } } ); this._disposers.pullDoc = reaction( () => this.props.Document[Pulls], () => { if (!DocumentButtonBar.hasPulledHack) { DocumentButtonBar.hasPulledHack = true; this.pullFromGoogleDoc(this.dataDoc.googleDocUnchanged ? this.checkState : this.updateState); } } ); this._disposers.pushDoc = reaction( () => this.props.Document[Pushes], () => { if (!DocumentButtonBar.hasPushedHack) { DocumentButtonBar.hasPushedHack = true; this.pushToGoogleDoc(); } } ); this._disposers.search = reaction( () => Doc.IsSearchMatch(this.Document), search => (search ? this.highlightSearchTerms([Doc.SearchQuery()], search.searchMatch < 0) : this.unhighlightSearchTerms()), { fireImmediately: Doc.IsSearchMatchUnmemoized(this.Document) ? true : false } ); this._disposers.selected = reaction( () => this.props.rootSelected?.(), action(selected => { //selected && setTimeout(() => 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 && !selected) { RichTextMenu.Instance?.updateMenu(undefined, undefined, undefined, undefined); } if (this._editorView && selected) { RichTextMenu.Instance?.updateMenu(this._editorView, undefined, this.props, this.layoutDoc); 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); } var quickScroll: string | undefined = ''; this._disposers.scroll = reaction( () => NumCast(this.layoutDoc._layout_scrollTop), pos => { if (!this._ignoreScroll && this._scrollRef.current && !this.props.dontSelectOnLoad) { const viewTrans = quickScroll ?? StrCast(this.Document._viewTransition); const durationMiliStr = viewTrans.match(/([0-9]*)ms/); const durationSecStr = viewTrans.match(/([0-9.]*)s/); const duration = durationMiliStr ? Number(durationMiliStr[1]) : durationSecStr ? Number(durationSecStr[1]) * 1000 : 0; if (duration) { this._scrollStopper = smoothScroll(duration, this._scrollRef.current, Math.abs(pos || 0), 'ease', this._scrollStopper); } else { this._scrollRef.current.scrollTo({ top: pos }); } } }, { fireImmediately: true } ); quickScroll = undefined; this.tryUpdateScrollHeight(); setTimeout(this.tryUpdateScrollHeight, 250); } pushToGoogleDoc = async () => { this.pullFromGoogleDoc(async (exportState: Opt, dataDoc: Doc) => { const modes = GoogleApiClientUtils.Docs.WriteMode; let mode = modes.Replace; let reference: Opt = Cast(this.dataDoc[GoogleRef], 'string'); if (!reference) { mode = modes.Insert; reference = { title: StrCast(this.dataDoc.title) }; } const redo = async () => { if (this._editorView && reference) { const content = await RichTextUtils.GoogleDocs.Export(this._editorView.state); const response = await GoogleApiClientUtils.Docs.write({ reference, content, mode }); response?.documentId && (this.dataDoc[GoogleRef] = response.documentId); const pushSuccess = response !== undefined && !('errors' in response); dataDoc.googleDocUnchanged = pushSuccess; DocumentButtonBar.Instance.startPushOutcome(pushSuccess); } }; const undo = () => { if (exportState && reference) { const content: GoogleApiClientUtils.Docs.Content = { text: exportState.text, requests: [], }; GoogleApiClientUtils.Docs.write({ reference, content, mode }); } }; UndoManager.AddEvent({ undo, redo, prop: '' }); redo(); }); }; pullFromGoogleDoc = async (handler: PullHandler) => { const dataDoc = this.dataDoc; const documentId = StrCast(dataDoc[GoogleRef]); let exportState: Opt; if (documentId) { exportState = await RichTextUtils.GoogleDocs.Import(documentId, dataDoc); } exportState && UndoManager.RunInBatch(() => handler(exportState, dataDoc), Pulls); }; updateState = (exportState: Opt, dataDoc: Doc) => { let pullSuccess = false; if (exportState !== undefined) { pullSuccess = true; dataDoc[this.fieldKey] = new RichTextField(JSON.stringify(exportState.state.toJSON())); setTimeout(() => { if (this._editorView) { const state = this._editorView.state; const end = state.doc.content.size - 1; this._editorView.dispatch(state.tr.setSelection(TextSelection.create(state.doc, end, end))); } }, 0); dataDoc.title = exportState.title; this.dataDoc.title_custom = true; dataDoc.googleDocUnchanged = true; } else { delete dataDoc[GoogleRef]; } DocumentButtonBar.Instance.startPullOutcome(pullSuccess); }; checkState = (exportState: Opt, dataDoc: Doc) => { if (exportState && this._editorView) { const equalContent = isEqual(this._editorView.state.doc, exportState.state.doc); const equalTitles = dataDoc.title === exportState.title; const unchanged = equalContent && equalTitles; dataDoc.googleDocUnchanged = unchanged; DocumentButtonBar.Instance.setPullState(unchanged); } }; clipboardTextSerializer = (slice: Slice): string => { let text = '', separated = true; const from = 0, 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) ? true : false; }; 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, editable: false }, undefined, [ view.state.schema.marks.linkAnchor.create({ allAnchors: [{ href: `/doc/${this.Document[Id]}`, title: this.Document.title, anchorId: `${this.Document[Id]}` }], title: `from: ${DocCast(pdfAnchor.embedContainer).title}`, noPreview: true, docref: false, }), view.state.schema.marks.pFontSize.create({ fontSize: '8px' }), view.state.schema.marks.em.create({}), ]), ]); 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(el: Element | null | undefined) { while (el && el !== document.body) { if (getComputedStyle(el).display === 'none') return false; el = el.parentNode as any; } return true; } richTextMenuPlugin() { const self = this; return new Plugin({ view(newView) { runInAction(() => self.props.rootSelected?.() && RichTextMenu.Instance && (RichTextMenu.Instance.view = newView)); return new RichTextMenuPlugin({ editorProps: this.props }); }, }); } _didScroll = false; _scrollStopper: undefined | (() => void); 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) { const self = this; 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 = self._ref.current!.getBoundingClientRect(); const scrollRef = self._scrollRef.current; 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 * self.props.ScreenToLocalTransform().Scale; if (this._focusSpeed !== undefined) { scrollPos && (this._scrollStopper = smoothScroll(this._focusSpeed, scrollRef, scrollPos, 'ease', this._scrollStopper)); } else { scrollRef.scrollTo({ top: scrollPos }); } this._didScroll = true; } return true; }, dispatchTransaction: this.dispatchTransaction, nodeViews: { dashComment(node: any, view: any, getPos: any) { return new DashDocCommentView(node, view, getPos); }, dashDoc(node: any, view: any, getPos: any) { return new DashDocView(node, view, getPos, self); }, dashField(node: any, view: any, getPos: any) { return new DashFieldView(node, view, getPos, self); }, equation(node: any, view: any, getPos: any) { return new EquationView(node, view, getPos, self); }, summary(node: any, view: any, getPos: any) { return new SummaryView(node, view, getPos); }, //ordered_list(node: any, view: any, getPos: any) { return new OrderedListView(); }, footnote(node: any, view: any, getPos: any) { return new FootnoteView(node, view, getPos); }, }, clipboardTextSerializer: this.clipboardTextSerializer, handlePaste: this.handlePaste, }); const { state, dispatch } = 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 Field); if (startupText) { dispatch(state.tr.insertText(startupText)); } const textAlign = StrCast(this.dataDoc.text_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 }))); }); } } (this._editorView as any).TextView = this; } const selectOnLoad = Doc.AreProtosEqual(this.props.TemplateDataDocument ?? this.Document, FormattedTextBox.SelectOnLoad) && (!LightboxView.LightboxDoc || LightboxView.IsLightboxDocView(this.props.docViewPath())); if (this._editorView && selectOnLoad && !this.props.dontRegisterView && !this.props.dontSelectOnLoad && this.isActiveTab(this.ProseRef)) { const selLoadChar = FormattedTextBox.SelectOnLoadChar; FormattedTextBox.SelectOnLoad = undefined; 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: Doc.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.tr .setStoredMarks(storedMarks) .insertText(FormattedTextBox.SelectOnLoadChar, this._editorView.state.doc.content.size - 1, this._editorView.state.doc.content.size) .setStoredMarks(storedMarks); this._editorView.dispatch(tr.setSelection(new TextSelection(tr.doc.resolve(tr.doc.content.size)))); } else if (curText && !FormattedTextBox.DontSelectInitialText) { selectAll(this._editorView.state, this._editorView?.dispatch); } } selectOnLoad && this._editorView!.focus(); if (this.props.isContentActive()) this.prepareForTyping(); if (this._editorView) { const tr = this._editorView.state.tr; const { from, to } = tr.selection; // for some reason, the selection is sometimes lost in the sidebar view when prosemirror syncs the seledtion with the dom, so reset the selection after the document has ben fully instantiated. if (FormattedTextBox.DontSelectInitialText) setTimeout(() => this._editorView?.dispatch(tr.setSelection(new TextSelection(tr.doc.resolve(from), tr.doc.resolve(to)))), 250); if (FormattedTextBox.PasteOnLoad) { const pdfAnchorId = FormattedTextBox.PasteOnLoad.clipboardData?.getData('dash/pdfAnchor'); FormattedTextBox.PasteOnLoad = undefined; pdfAnchorId && this.addPdfReference(pdfAnchorId); } } FormattedTextBox.DontSelectInitialText = false; } // 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) return; const docDefaultMarks = [ ...(Doc.UserDoc().fontColor !== 'transparent' && Doc.UserDoc().fontColor ? [schema.mark(schema.marks.pFontColor, { color: StrCast(Doc.UserDoc().fontColor) })] : []), ...(Doc.UserDoc().fontStyle === 'italics' ? [schema.mark(schema.marks.em)] : []), ...(Doc.UserDoc().textDecoration === 'underline' ? [schema.mark(schema.marks.underline)] : []), ...(Doc.UserDoc().fontFamily ? [schema.mark(schema.marks.pFontFamily, { family: this.props.styleProvider?.(this.layoutDoc, this.props, StyleProp.FontFamily) })] : []), ...(Doc.UserDoc().fontSize ? [schema.mark(schema.marks.pFontSize, { fontSize: this.props.styleProvider?.(this.layoutDoc, this.props, StyleProp.FontSize) })] : []), ...(Doc.UserDoc().fontWeight === 'bold' ? [schema.mark(schema.marks.strong)] : []), ...[schema.marks.user_mark.create({ userid: Doc.CurrentUserEmail, modified: Math.floor(Date.now() / 1000) })], ]; this._editorView?.dispatch(this._editorView?.state.tr.setStoredMarks(docDefaultMarks)); }; @action 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 ((e.nativeEvent as any).handledByInnerReactInstance) { return; //e.stopPropagation(); } else (e.nativeEvent as any).handledByInnerReactInstance = true; 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. if ((e.target as any).tagName === 'AUDIOTAG') { e.preventDefault(); e.stopPropagation(); const timecode = Number((e.target as any)?.dataset?.timecode); DocServer.GetRefField((e.target as any)?.dataset?.audioid || 0).then(anchor => { if (anchor instanceof Doc) { // const timecode = NumCast(anchor.timecodeToShow, 0); const audiodoc = anchor.annotationOn as Doc; const func = () => { const docView = DocumentManager.Instance.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(); } this._downX = e.clientX; this._downY = e.clientY; this._downTime = Date.now(); this._hadDownFocus = this.ProseRef?.children[0].className.includes('focused') ?? false; 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); } } if (e.button === 2 || (e.button === 0 && e.ctrlKey)) { e.preventDefault(); } }; onSelectEnd = (e: PointerEvent) => { document.removeEventListener('pointerup', this.onSelectEnd); }; onPointerUp = (e: React.PointerEvent): void => { const editor = this._editorView!; const state = editor?.state; if (!Utils.isClick(e.clientX, e.clientY, this._downX, this._downY, this._downTime) && !this._hadDownFocus) { (this.ProseRef?.children[0] as HTMLElement)?.blur?.(); } if (!state || !editor || !this.ProseRef?.children[0].className.includes('-focused')) return; if (!state.selection.empty && !(state.selection instanceof NodeSelection)) this.setupAnchorMenu(); else if (this.props.isContentActive(true)) { const pcords = editor.posAtCoords({ left: e.clientX, top: e.clientY }); let xpos = pcords?.pos || 0; while (xpos > 0 && !state.doc.resolve(xpos).node()?.isTextblock) { xpos = xpos - 1; } editor.dispatch(state.tr.setSelection(new TextSelection(state.doc.resolve(xpos)))); let target = e.target as any; // hrefs are stored on the dataset of the node that wraps the hyerlink while (target && !target.dataset?.targethrefs) target = target.parentElement; FormattedTextBoxComment.update(this, editor, undefined, target?.dataset?.targethrefs, target?.dataset.linkdoc, target?.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 = () => { const pos = this._editorView?.state.selection.$from.pos || 1; (this.ProseRef?.children?.[0] as any).focus(); setTimeout(() => this._editorView?.dispatch(this._editorView?.state.tr.setSelection(TextSelection.create(this._editorView.state.doc, pos)))); }; @action onFocused = (e: React.FocusEvent): void => { console.log('FOCUSED = ' + this.layoutDoc.title + ' ' + this.props.rootSelected?.()); //applyDevTools.applyDevTools(this._editorView); this.ProseRef?.children[0] === e.nativeEvent.target && this._editorView && RichTextMenu.Instance?.updateMenu(this._editorView, undefined, this.props, this.layoutDoc); e.stopPropagation(); }; onClick = (e: React.MouseEvent): void => { if (!this.props.isContentActive()) return; if ((e.nativeEvent as any).handledByInnerReactInstance) { e.stopPropagation(); return; } if (Math.abs(e.clientX - this._downX) > 4 || Math.abs(e.clientY - this._downY) > 4) { this._forceDownNode = undefined; return; } if (!this._forceUncollapse || (this._editorView!.root as any).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 = this._editorView!.posAtCoords({ left: e.clientX, top: e.clientY }); const node = pcords && this._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 === this._editorView!.state.schema.nodes.dashComment) { this._editorView!.dispatch(this._editorView!.state.tr.setSelection(TextSelection.create(this._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 this._editorView?.focus(); this._editorView!.dispatch(this._editorView!.state.tr.setSelection(TextSelection.create(this._editorView!.state.doc, this._editorView!.state.doc.content.size))); } } else if (node && [this._editorView!.state.schema.nodes.ordered_list, this._editorView!.state.schema.nodes.listItem].includes(node.type) && node !== (this._editorView!.state.selection as NodeSelection)?.node && pcords) { this._editorView!.dispatch(this._editorView!.state.tr.setSelection(NodeSelection.create(this._editorView!.state.doc, pcords.pos))); } } if (this.props.rootSelected?.()) { // if text box is selected, then it consumes all click events (e.nativeEvent as any).handledByInnerReactInstance = true; this.hitBulletTargets(e.clientX, e.clientY, !this._editorView?.state.selection.empty || this._forceUncollapse, false, this._forceDownNode, e.shiftKey); } this._forceUncollapse = !(this._editorView!.root as any).getSelection().isCollapsed; this._forceDownNode = (this._editorView!.state.selection as NodeSelection)?.node; }; // 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, downNode: Node | undefined = undefined, selectOrderedList: boolean = false) { this._forceUncollapse = false; clearStyleSheetRules(FormattedTextBox._bulletStyleSheet); const clickPos = this._editorView!.posAtCoords({ left: x, top: y }); let olistPos = clickPos?.pos; if (clickPos && olistPos && this.props.rootSelected?.()) { const clickNode = this._editorView?.state.doc.nodeAt(olistPos); const nodeBef = this._editorView?.state.doc.nodeAt(Math.max(0, olistPos - 1)); 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 as any).path.length > 3) { olistNode = $olistPos.parent; $olistPos = this._editorView?.state.doc.resolve(($olistPos as any).path[($olistPos as any).path.length - 4]); } } const listPos = this._editorView?.state.doc.resolve(clickPos.pos); const listNode = this._editorView?.state.doc.nodeAt(clickPos.pos); 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 tr = this._editorView.state.tr.setNodeMarkup(clickPos.pos, listNode.type, { ...listNode.attrs, visibility: !listNode.attrs.visibility }); this._editorView.dispatch(tr.setSelection(TextSelection.create(tr.doc, clickPos.pos))); } } addStyleSheetRule(FormattedTextBox._bulletStyleSheet, olistNode.attrs.mapStyle + olistNode.attrs.bulletStyle + ':hover:before', { background: 'lightgray' }); } } } startUndoTypingBatch() { !this._undoTyping && (this._undoTyping = UndoManager.StartBatch('text edits on ' + this.Document.title)); } public endUndoTypingBatch() { this._undoTyping?.end(); this._undoTyping = undefined; } @action onBlur = (e: any) => { 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(); this.autoLink(); if (this._editorView?.state.tr) { const tr = stordMarks?.reduce((tr, m) => { tr.addStoredMark(m); return tr; }, this._editorView.state.tr); tr && this._editorView.dispatch(tr); } } if (RichTextMenu.Instance?.view === this._editorView && !this.props.rootSelected?.()) { RichTextMenu.Instance?.updateMenu(undefined, undefined, undefined, undefined); } FormattedTextBox._hadSelection = window.getSelection()?.toString() !== ''; this.endUndoTypingBatch(); FormattedTextBox.LiveTextUndo?.end(); FormattedTextBox.LiveTextUndo = undefined; const state = this._editorView!.state; const curText = state.doc.textBetween(0, state.doc.content.size, ' \n'); if (StrCast(this.Document.title).startsWith('@') && !this.dataDoc.title_custom) { UndoManager.RunInBatch(() => { this.dataDoc.title_custom = true; this.dataDoc.layout_showTitle = 'title'; const tr = this._editorView!.state.tr; this._editorView?.dispatch(tr.setSelection(new TextSelection(tr.doc.resolve(0), tr.doc.resolve(StrCast(this.Document.title).length + 2))).deleteSelection()); }, 'titler'); } }; onKeyDown = (e: React.KeyboardEvent) => { if ((e.altKey || e.ctrlKey) && e.key === 't') { e.preventDefault(); e.stopPropagation(); this.props.setTitleFocus?.(); return; } const state = this._editorView!.state; if (!state.selection.empty && e.key === '%') { this._rules!.EnteringStyle = true; e.preventDefault(); e.stopPropagation(); return; } if (state.selection.empty || !this._rules!.EnteringStyle) { this._rules!.EnteringStyle = false; } let stopPropagation = true; for (var 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 !== Doc.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 any).blur?.(); SelectionManager.DeselectAll(); RichTextMenu.Instance.updateMenu(undefined, undefined, undefined, undefined); return; case 'Enter': this.insertTime(); case 'Tab': e.preventDefault(); break; case 'c': this._editorView?.state.selection.empty && (stopPropagation = false); break; default: if (this._lastTimedMark?.attrs.userid === Doc.CurrentUserEmail) break; case ' ': if (e.code !== 'Space') { [AclEdit, AclAugment, AclAdmin].includes(GetEffectiveAcl(this.Document)) && this._editorView!.dispatch(this._editorView!.state.tr.removeStoredMark(schema.marks.user_mark).addStoredMark(schema.marks.user_mark.create({ userid: Doc.CurrentUserEmail, modified: Math.floor(Date.now() / 1000) }))); } break; } if (stopPropagation) 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 (!LinkDocPreview.LinkInfo && this._scrollRef.current) { if (!this.props.dontSelectOnLoad) { this._ignoreScroll = true; this.layoutDoc._layout_scrollTop = this._scrollRef.current.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 (children && !SnappingManager.GetIsDragging()) { const toNum = (val: string) => Number(val.replace('px', '').replace('auto', '0')); const toHgt = (node: Element) => { const { height, marginTop, marginBottom } = getComputedStyle(node); return toNum(height) + Math.max(0, toNum(marginTop)) + Math.max(0, toNum(marginBottom)); }; const proseHeight = !this.ProseRef ? 0 : children.reduce((p, child) => p + toHgt(child), margins); const scrollHeight = this.ProseRef && Math.min(NumCast(this.layoutDoc.layout_maxAutoHeight, proseHeight), proseHeight); if (this.props.setHeight && 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.props.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(e => (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' : '')); return !annotated && (!this.props.isContentActive() || SnappingManager.GetIsDragging() || Doc.ActiveTool !== InkTool.None) ? null : (
); } @computed get sidebarCollection() { const renderComponent = (tag: string) => { const ComponentTag: any = tag === CollectionViewType.Freeform ? CollectionFreeFormView : tag === CollectionViewType.Tree ? CollectionTreeView : tag === 'translation' ? FormattedTextBox : CollectionStackingView; return ComponentTag === CollectionStackingView ? ( ) : (
setupMoveUpEvents(this, e, returnFalse, emptyFunction, () => SelectionManager.SelectView(this.props.DocumentView?.()!, false), true)}>
); }; return (
{renderComponent(StrCast(this.layoutDoc[this.SidebarKey + '_type_collection']))}
); } cycleAlternateText = () => { if (this.layoutDoc._layout_enableAltContentUI) { const usePath = this.layoutDoc[`_${this.props.fieldKey}_usePath`]; this.layoutDoc[`_${this.props.fieldKey}_usePath`] = usePath === undefined ? 'alternate' : usePath === 'alternate' ? 'alternate:hover' : undefined; } }; @computed get overlayAlternateIcon() { const usePath = this.layoutDoc[`_${this.props.fieldKey}_usePath`]; return ( toggle (%/) between primary, alternate, and show alternate on hover }>
setupMoveUpEvents(e.target, e, returnFalse, emptyFunction, e => this.cycleAlternateText())} style={{ display: this.props.isContentActive() && !SnappingManager.GetIsDragging() ? 'flex' : 'none', background: usePath === undefined ? 'white' : usePath === 'alternate' ? 'black' : 'gray', color: usePath === undefined ? 'black' : 'white', }}>
); } @computed get fieldKey() { const usePath = StrCast(this.layoutDoc[`${this.props.fieldKey}_usePath`]); return this.props.fieldKey + (usePath && (!usePath.includes(':hover') || this._isHovering || this.props.isContentActive()) ? `_${usePath.replace(':hover', '')}` : ''); } @observable _isHovering = false; onPassiveWheel = (e: WheelEvent) => { if (e.clientX > this.ProseRef!.getBoundingClientRect().right) { if (this.dataDoc[this.SidebarKey + '_type_collection'] === CollectionViewType.Freeform) { // if the scrolled freeform is a child of the sidebar component, we need to let the event go through // so react can let the freeform view handle it. We prevent default to stop any containing views from scrolling e.preventDefault(); } 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 styleFromLayoutString = Doc.styleFromLayoutString(this.Document, this.props, scale); // this converts any expressions in the format string to style props. e.g., const height = Number(styleFromLayoutString.height?.replace('px', '')); // prevent default if selected || child is active but this doc isn't scrollable if ( (this._scrollRef.current?.scrollHeight ?? 0) <= Math.ceil((height ? height : this.props.PanelHeight()) / scale) && // (this.props.rootSelected?.() || this.isAnyChildContentActive()) ) { e.preventDefault(); } e.stopPropagation(); } }; _oldWheel: any; @computed get fontColor() { return this.props.styleProvider?.(this.layoutDoc, this.props, StyleProp.Color); } @computed get fontSize() { return this.props.styleProvider?.(this.layoutDoc, this.props, StyleProp.FontSize); } @computed get fontFamily() { return this.props.styleProvider?.(this.layoutDoc, this.props, StyleProp.FontFamily); } @computed get fontWeight() { return this.props.styleProvider?.(this.layoutDoc, this.props, StyleProp.FontWeight); } render() { TraceMobx(); const scale = (this.props.NativeDimScaling?.() || 1) * NumCast(this.layoutDoc._freeform_scale, 1); const rounded = StrCast(this.layoutDoc.layout_borderRounding) === '100%' ? '-rounded' : ''; setTimeout(() => !this.props.isContentActive() && FormattedTextBoxComment.textBox === this && FormattedTextBoxComment.Hide); const paddingX = NumCast(this.layoutDoc._xMargin, this.props.xPadding || 0); const paddingY = NumCast(this.layoutDoc._yMargin, this.props.yPadding || 0); const styleFromLayoutString = Doc.styleFromLayoutString(this.Document, this.props, scale); // this converts any expressions in the format string to style props. e.g., return styleFromLayoutString?.height === '0px' ? null : (
(this._isHovering = true))} onPointerLeave={action(() => (this._isHovering = false))} ref={r => { 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', // overflowY: this.layoutDoc._layout_autoHeight ? "hidden" : undefined, color: this.fontColor, fontSize: this.fontSize, fontFamily: this.fontFamily, fontWeight: this.fontWeight, ...styleFromLayoutString, }}>
this.hitBulletTargets(e.clientX, e.clientY, e.shiftKey, true)} onBlur={this.onBlur} onPointerUp={this.onPointerUp} onPointerDown={this.onPointerDown} onDoubleClick={this.onDoubleClick}>
{this.noSidebar || this.props.dontSelectOnLoad || !this.SidebarShown || this.layout_sidebarWidthPercent === '0%' ? null : this.sidebarCollection} {this.noSidebar || this.Document._layout_noSidebar || this.props.dontSelectOnLoad || this.Document._createDocOnCR || this.layoutDoc._chromeHidden ? null : this.sidebarHandle} {this.audioHandle} {this.layoutDoc._layout_enableAltContentUI && !this.layoutDoc._chromeHidden ? this.overlayAlternateIcon : null}
); } }