import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { Tooltip } from '@mui/material'; import { action, computed, IReactionDisposer, makeObservable, observable, reaction } from 'mobx'; import { observer } from 'mobx-react'; import { lift, wrapIn } from 'prosemirror-commands'; import { Mark, MarkType, Node as ProsNode, ResolvedPos } from 'prosemirror-model'; import { wrapInList } from 'prosemirror-schema-list'; import { EditorState, NodeSelection, TextSelection } from 'prosemirror-state'; import { EditorView } from 'prosemirror-view'; import * as React from 'react'; import { Doc } from '../../../../fields/Doc'; import { BoolCast, Cast, StrCast } from '../../../../fields/Types'; import { numberRange } from '../../../../Utils'; import { DocServer } from '../../../DocServer'; import { LinkManager } from '../../../util/LinkManager'; import { SelectionManager } from '../../../util/SelectionManager'; import { undoBatch, UndoManager } from '../../../util/UndoManager'; import { AntimodeMenu, AntimodeMenuProps } from '../../AntimodeMenu'; import { ObservableReactComponent } from '../../ObservableReactComponent'; import { EquationBox } from '../EquationBox'; import { FieldViewProps } from '../FieldView'; import { FormattedTextBox } from './FormattedTextBox'; import { updateBullets } from './ProsemirrorExampleTransfer'; import './RichTextMenu.scss'; import { schema } from './schema_rts'; const { toggleMark } = require('prosemirror-commands'); @observer export class RichTextMenu extends AntimodeMenu { static _instance: { menu: RichTextMenu | undefined } = observable({ menu: undefined }); static get Instance() { return RichTextMenu._instance?.menu; } public overMenu: boolean = false; // kind of hacky way to prevent selects not being selectable private _linkToRef = React.createRef(); layoutDoc: Doc | undefined; @observable public view?: EditorView = undefined; public editorProps: FieldViewProps | undefined; public _brushMap: Map> = new Map(); @observable private collapsed: boolean = false; @observable private _noLinkActive: boolean = false; @observable private _boldActive: boolean = false; @observable private _italicsActive: boolean = false; @observable private _underlineActive: boolean = false; @observable private _strikethroughActive: boolean = false; @observable private _subscriptActive: boolean = false; @observable private _superscriptActive: boolean = false; @observable private _activeFontSize: string = '13px'; @observable private _activeFontFamily: string = ''; @observable private _activeListType: string = ''; @observable private _activeAlignment: string = 'left'; @observable private brushMarks: Set = new Set(); @observable private showBrushDropdown: boolean = false; @observable private _activeFontColor: string = 'black'; @observable private showColorDropdown: boolean = false; @observable private _activeHighlightColor: string = 'transparent'; @observable private showHighlightDropdown: boolean = false; @observable private currentLink: string | undefined = ''; @observable private showLinkDropdown: boolean = false; _reaction: IReactionDisposer | undefined; constructor(props: AntimodeMenuProps) { super(props); makeObservable(this); RichTextMenu._instance.menu = this; this.updateMenu(undefined, undefined, props, this.layoutDoc); this._canFade = false; this.Pinned = true; } @computed get noAutoLink() { return this._noLinkActive; } @computed get bold() { return this._boldActive; } @computed get underline() { return this._underlineActive; } @computed get italics() { return this._italicsActive; } @computed get strikeThrough() { return this._strikethroughActive; } @computed get fontColor() { return this._activeFontColor; } @computed get fontHighlight() { return this._activeHighlightColor; } @computed get fontFamily() { return this._activeFontFamily; } @computed get fontSize() { return this._activeFontSize; } @computed get listStyle() { return this._activeListType; } @computed get textAlign() { return this._activeAlignment; } @computed get textVcenter() { return BoolCast(this.layoutDoc?._layout_centered); } _disposer: IReactionDisposer | undefined; componentDidMount() { this._disposer = reaction( () => SelectionManager.Views.slice(), views => this.updateMenu(undefined, undefined, undefined, undefined) ); } componentWillUnmount() { this._disposer?.(); } @action public updateMenu(view: EditorView | undefined, lastState: EditorState | undefined, props: any, layoutDoc: Doc | undefined) { if (this._linkToRef.current?.getBoundingClientRect().width) { return; } this.view = view; this.layoutDoc = layoutDoc; props && (this.editorProps = props); // Don't do anything if the document/selection didn't change if (view && view.hasFocus()) { if (lastState?.doc.eq(view.state.doc) && lastState.selection.eq(view.state.selection)) return; } this.setActiveMarkButtons(this.getActiveMarksOnSelection()); const active = this.getActiveFontStylesOnSelection(); const activeFamilies = active.activeFamilies; const activeSizes = active.activeSizes; const activeColors = active.activeColors; const activeHighlights = active.activeHighlights; const refDoc = SelectionManager.Views.lastElement()?.layoutDoc ?? Doc.UserDoc(); const refField = (pfx => (pfx ? pfx + '_' : ''))(SelectionManager.Views.lastElement()?.LayoutFieldKey); this._activeListType = this.getActiveListStyle(); this._activeAlignment = this.getActiveAlignment(); this._activeFontFamily = !activeFamilies.length ? StrCast(this.TextView?.Document._text_fontFamily, StrCast(refDoc[refField + 'fontFamily'], 'Arial')) : activeFamilies.length === 1 ? String(activeFamilies[0]) : 'various'; this._activeFontSize = !activeSizes.length ? StrCast(this.TextView?.Document.fontSize, StrCast(refDoc[refField + 'fontSize'], '10px')) : activeSizes[0]; this._activeFontColor = !activeColors.length ? StrCast(this.TextView?.Document.fontColor, StrCast(refDoc[refField + 'fontColor'], 'black')) : activeColors.length > 0 ? String(activeColors[0]) : '...'; this._activeHighlightColor = !activeHighlights.length ? '' : activeHighlights.length > 0 ? String(activeHighlights[0]) : '...'; // update link in current selection this.getTextLinkTargetTitle().then(targetTitle => this.setCurrentLink(targetTitle)); } setMark = (mark: Mark, state: EditorState, dispatch: any, dontToggle: boolean = false) => { if (mark) { const liFirst = numberRange(state.selection.$from.depth + 1).find(i => state.selection.$from.node(i)?.type === state.schema.nodes.list_item); const liTo = numberRange(state.selection.$to.depth + 1).find(i => state.selection.$to.node(i)?.type === state.schema.nodes.list_item); const olFirst = numberRange(state.selection.$from.depth + 1).find(i => state.selection.$from.node(i)?.type === state.schema.nodes.ordered_list); const nodeOl = (liFirst && liTo && state.selection.$from.node(liFirst) !== state.selection.$to.node(liTo) && olFirst) || (!liFirst && !liTo && olFirst); const fromRange = numberRange(state.selection.from).reverse(); const newPos = nodeOl ? fromRange.find(i => state.doc.nodeAt(i)?.type === state.schema.nodes.ordered_list) ?? state.selection.from : state.selection.from; const node = (state.selection as NodeSelection).node ?? (newPos >= 0 ? state.doc.nodeAt(newPos) : undefined); if (node?.type === schema.nodes.ordered_list || node?.type === schema.nodes.list_item) { const hasMark = node.marks.some(m => m.type === mark.type); const otherMarks = node.marks.filter(m => m.type !== mark.type); const addAnyway = node.marks.filter(m => m.type === mark.type && Object.keys(m.attrs).some(akey => m.attrs[akey] !== mark.attrs[akey])); const markup = state.tr.setNodeMarkup(newPos, node.type, node.attrs, hasMark && !addAnyway ? otherMarks : [...otherMarks, mark]); dispatch(updateBullets(markup, state.schema)); } else { const state = this.view?.state; const tr = this.view?.state.tr; if (tr && state) { if (dontToggle) { tr.addMark(state.selection.from, state.selection.to, mark); dispatch(tr.setSelection(new TextSelection(tr.doc.resolve(state.selection.from), tr.doc.resolve(state.selection.to)))); // bcz: need to redo the selection because ctrl-a selections disappear otherwise } else { toggleMark(mark.type, mark.attrs)(state, dispatch); } } } } }; // finds font sizes and families in selection getActiveAlignment() { if (this.view && this.TextView?._props.rootSelected?.()) { const path = (this.view.state.selection.$from as any).path; for (let i = path.length - 3; i < path.length && i >= 0; i -= 3) { if (path[i]?.type === this.view.state.schema.nodes.paragraph || path[i]?.type === this.view.state.schema.nodes.heading) { return path[i].attrs.align || 'left'; } } } return 'left'; } // finds font sizes and families in selection getActiveListStyle() { const state = this.view?.state; if (state) { const pos = state.selection.$anchor; for (let i = 0; i < pos.depth; i++) { const node = pos.node(i); if (node.type === schema.nodes.ordered_list) { return node.attrs.mapStyle; } } } return ''; } // finds font sizes and families in selection getActiveFontStylesOnSelection() { const activeFamilies = new Set(); const activeSizes = new Set(); const activeColors = new Set(); const activeHighlights = new Set(); if (this.view && this.TextView?._props.rootSelected?.()) { const state = this.view.state; const pos = this.view.state.selection.$from; var marks: Mark[] = [...(state.storedMarks ?? [])]; if (state.storedMarks !== null) { } else if (state.selection.empty) { for (let i = 0; i <= pos.depth; i++) { marks = [...Array.from(pos.node(i).marks), ...this.view.state.selection.$anchor.marks(), ...marks]; } } else { state.doc.nodesBetween(state.selection.from, state.selection.to, (node, pos, parent, index) => { node.marks?.filter(mark => !mark.isInSet(marks)).map(mark => marks.push(mark)); }); } marks.forEach(m => { m.type === state.schema.marks.pFontFamily && activeFamilies.add(m.attrs.family); m.type === state.schema.marks.pFontColor && activeColors.add(m.attrs.color); m.type === state.schema.marks.pFontSize && activeSizes.add(m.attrs.fontSize); m.type === state.schema.marks.marker && activeHighlights.add(String(m.attrs.highlight)); }); } else if (SelectionManager.Views.some(dv => dv.ComponentView instanceof EquationBox)) { SelectionManager.Views.forEach(dv => StrCast(dv.Document._text_fontSize) && activeSizes.add(StrCast(dv.Document._text_fontSize))); } return { activeFamilies: Array.from(activeFamilies), activeSizes: Array.from(activeSizes), activeColors: Array.from(activeColors), activeHighlights: Array.from(activeHighlights) }; } getMarksInSelection(state: EditorState) { const found = new Set(); const { from, to } = state.selection as TextSelection; state.doc.nodesBetween(from, to, node => node.marks.forEach(m => found.add(m))); return found; } //finds all active marks on selection in given group getActiveMarksOnSelection() { if (!this.view || !this.TextView?._props.rootSelected?.()) return [] as MarkType[]; const state = this.view.state; var marks: Mark[] = [...(state.storedMarks ?? [])]; const pos = this.view.state.selection.$from; if (state.storedMarks !== null) { } else if (state.selection.empty) { for (let i = 0; i <= pos.depth; i++) { marks = [...Array.from(pos.node(i).marks), ...this.view.state.selection.$anchor.marks(), ...marks]; } } else { state.doc.nodesBetween(state.selection.from, state.selection.to, (node, pos, parent, index) => { node.marks?.filter(mark => !mark.isInSet(marks)).map(mark => marks.push(mark)); }); } const markGroup = [schema.marks.noAutoLinkAnchor, schema.marks.strong, schema.marks.em, schema.marks.underline, schema.marks.strikethrough, schema.marks.superscript, schema.marks.subscript]; return markGroup.filter(mark_type => { const mark = state.schema.mark(mark_type); return mark.isInSet(marks); }); } @action setActiveMarkButtons(activeMarks: MarkType[] | undefined) { if (!activeMarks) return; this._noLinkActive = false; this._boldActive = false; this._italicsActive = false; this._underlineActive = false; this._strikethroughActive = false; this._subscriptActive = false; this._superscriptActive = false; activeMarks.forEach(mark => { // prettier-ignore switch (mark.name) { case 'noAutoLinkAnchor': this._noLinkActive = true; break; case 'strong': this._boldActive = true; break; case 'em': this._italicsActive = true; break; case 'underline': this._underlineActive = true; break; case 'strikethrough': this._strikethroughActive = true; break; case 'subscript': this._subscriptActive = true; break; case 'superscript': this._superscriptActive = true; break; } }); } elideSelection = (txstate: EditorState | undefined = undefined, visibility = false) => { const state = txstate ?? this.view?.state; if (!state || state.selection.empty) return false; const mark = state.schema.marks.summarize.create(); const tr = state.tr.addMark(state.tr.selection.from, state.selection.to, mark); const text = tr.selection.content(); const elideNode = state.schema.nodes.summary.create({ visibility, text, textslice: text.toJSON() }); const summary = tr.replaceSelectionWith(elideNode).removeMark(tr.selection.from - 1, tr.selection.from, mark); const expanded = () => { const endOfElidableText = summary.selection.to + text.content.size; const res = summary.insert(summary.selection.to, text.content).insert(endOfElidableText, state.schema.nodes.paragraph.create({})); return res.setSelection(new TextSelection(res.doc.resolve(endOfElidableText + 1))); }; this.view?.dispatch?.(visibility ? expanded() : summary); return true; }; toggleNoAutoLinkAnchor = () => { if (this.view) { const mark = this.view.state.schema.mark(this.view.state.schema.marks.noAutoLinkAnchor); this.setMark(mark, this.view.state, this.view.dispatch, false); this.TextView.autoLink(); this.view.focus(); } }; toggleBold = () => { if (this.view) { const mark = this.view.state.schema.mark(this.view.state.schema.marks.strong); this.setMark(mark, this.view.state, this.view.dispatch, false); this.view.focus(); } }; toggleUnderline = () => { if (this.view) { const mark = this.view.state.schema.mark(this.view.state.schema.marks.underline); this.setMark(mark, this.view.state, this.view.dispatch, false); this.view.focus(); } }; toggleItalics = () => { if (this.view) { const mark = this.view.state.schema.mark(this.view.state.schema.marks.em); this.setMark(mark, this.view.state, this.view.dispatch, false); this.view.focus(); } }; setFontSize = (fontSize: string) => { if (this.view) { if (this.view.state.selection.from === 1 && this.view.state.selection.empty && (!this.view.state.doc.nodeAt(1) || !this.view.state.doc.nodeAt(1)?.marks.some(m => m.type.name === fontSize))) { this.TextView.dataDoc[this.TextView.fieldKey + '_fontSize'] = fontSize; this.view.focus(); } else { const fmark = this.view.state.schema.marks.pFontSize.create({ fontSize }); this.setMark(fmark, this.view.state, (tx: any) => this.view!.dispatch(tx.addStoredMark(fmark)), true); this.view.focus(); } } else if (SelectionManager.Views.length) { SelectionManager.Views.forEach(dv => (dv.layoutDoc[dv.LayoutFieldKey + '_fontSize'] = fontSize)); } else Doc.UserDoc().fontSize = fontSize; this.updateMenu(this.view, undefined, this.props, this.layoutDoc); }; setFontFamily = (family: string) => { if (this.view) { const fmark = this.view.state.schema.marks.pFontFamily.create({ family }); this.setMark(fmark, this.view.state, (tx: any) => this.view!.dispatch(tx.addStoredMark(fmark)), true); this.view.focus(); } else if (SelectionManager.Views.length) { SelectionManager.Views.forEach(dv => (dv.layoutDoc[dv.LayoutFieldKey + '_fontFamily'] = family)); } else Doc.UserDoc().fontFamily = family; this.updateMenu(this.view, undefined, this.props, this.layoutDoc); }; setHighlight(color: string) { if (this.view) { const highlightMark = this.view.state.schema.mark(this.view.state.schema.marks.marker, { highlight: color }); this.setMark(highlightMark, this.view.state, (tx: any) => this.view!.dispatch(tx.addStoredMark(highlightMark)), true); this.view.focus(); } else Doc.UserDoc()._fontHighlight = color; this.updateMenu(this.view, undefined, this.props, this.layoutDoc); } setColor(color: string) { if (this.view) { const colorMark = this.view.state.schema.mark(this.view.state.schema.marks.pFontColor, { color }); this.setMark(colorMark, this.view.state, (tx: any) => this.view!.dispatch(tx.addStoredMark(colorMark)), true); this.view.focus(); } else if (SelectionManager.Views.length) { SelectionManager.Views.forEach(dv => (dv.layoutDoc[dv.LayoutFieldKey + '_fontColor'] = color)); } else Doc.UserDoc().fontColor = color; this.updateMenu(this.view, undefined, this.props, this.layoutDoc); } // TODO: remove doesn't work // remove all node type and apply the passed-in one to the selected text changeListType = (mapStyle: string) => { const active = this.view?.state && RichTextMenu.Instance?.getActiveListStyle(); const newMapStyle = active === mapStyle ? '' : mapStyle; if (!this.view || newMapStyle === '') return; let inList = this.view.state.selection.$anchor.node(1).type === schema.nodes.ordered_list; const marks = this.view.state.storedMarks || (this.view.state.selection.$to.parentOffset && this.view.state.selection.$from.marks()); if (inList) { const tx2 = updateBullets(this.view.state.tr, schema, newMapStyle, this.view.state.doc.resolve(this.view.state.selection.$anchor.before(1) + 1).pos, this.view.state.doc.resolve(this.view.state.selection.$anchor.after(1)).pos); marks && tx2.ensureMarks([...marks]); marks && tx2.setStoredMarks([...marks]); this.view.dispatch(tx2); } else !wrapInList(schema.nodes.ordered_list)(this.view.state, (tx2: any) => { const tx3 = updateBullets(tx2, schema, newMapStyle, this.view!.state.selection.from - 1, this.view!.state.selection.to + 1); marks && tx3.ensureMarks([...marks]); marks && tx3.setStoredMarks([...marks]); this.view!.dispatch(tx3); }); this.view.focus(); this.updateMenu(this.view, undefined, this.props, this.layoutDoc); }; insertSummarizer(state: EditorState, dispatch: any) { if (state.selection.empty) return false; const mark = state.schema.marks.summarize.create(); const tr = state.tr; tr.addMark(state.selection.from, state.selection.to, mark); const content = tr.selection.content(); const newNode = state.schema.nodes.summary.create({ visibility: false, text: content, textslice: content.toJSON() }); dispatch?.(tr.replaceSelectionWith(newNode).removeMark(tr.selection.from - 1, tr.selection.from, mark)); return true; } vcenterToggle = (view: EditorView, dispatch: any) => { this.layoutDoc && (this.layoutDoc._layout_centered = !this.layoutDoc._layout_centered); }; align = (view: EditorView, dispatch: any, alignment: 'left' | 'right' | 'center') => { if (this.TextView?._props.rootSelected?.()) { var tr = view.state.tr; view.state.doc.nodesBetween(view.state.selection.from, view.state.selection.to, (node, pos, parent, index) => { if ([schema.nodes.paragraph, schema.nodes.heading].includes(node.type)) { tr = tr.setNodeMarkup(pos, node.type, { ...node.attrs, align: alignment }, node.marks); return false; } view.focus(); return true; }); view.focus(); dispatch?.(tr); } }; insetParagraph(state: EditorState, dispatch: any) { var tr = state.tr; state.doc.nodesBetween(state.selection.from, state.selection.to, (node, pos, parent, index) => { if (node.type === schema.nodes.paragraph || node.type === schema.nodes.heading) { const inset = (node.attrs.inset ? Number(node.attrs.inset) : 0) + 10; tr = tr.setNodeMarkup(pos, node.type, { ...node.attrs, inset }, node.marks); return false; } return true; }); dispatch?.(tr); return true; } outsetParagraph(state: EditorState, dispatch: any) { var tr = state.tr; state.doc.nodesBetween(state.selection.from, state.selection.to, (node, pos, parent, index) => { if (node.type === schema.nodes.paragraph || node.type === schema.nodes.heading) { const inset = Math.max(0, (node.attrs.inset ? Number(node.attrs.inset) : 0) - 10); tr = tr.setNodeMarkup(pos, node.type, { ...node.attrs, inset }, node.marks); return false; } return true; }); dispatch?.(tr); return true; } indentParagraph(state: EditorState, dispatch: any) { var tr = state.tr; const heading = false; state.doc.nodesBetween(state.selection.from, state.selection.to, (node, pos, parent, index) => { if (node.type === schema.nodes.paragraph || node.type === schema.nodes.heading) { const nodeval = node.attrs.indent ? Number(node.attrs.indent) : undefined; const indent = !nodeval ? 25 : nodeval < 0 ? 0 : nodeval + 25; tr = tr.setNodeMarkup(pos, node.type, { ...node.attrs, indent }, node.marks); return false; } return true; }); !heading && dispatch?.(tr); return true; } hangingIndentParagraph(state: EditorState, dispatch: any) { var tr = state.tr; state.doc.nodesBetween(state.selection.from, state.selection.to, (node, pos, parent, index) => { if (node.type === schema.nodes.paragraph || node.type === schema.nodes.heading) { const nodeval = node.attrs.indent ? Number(node.attrs.indent) : undefined; const indent = !nodeval ? -25 : nodeval > 0 ? 0 : nodeval - 10; tr = tr.setNodeMarkup(pos, node.type, { ...node.attrs, indent }, node.marks); return false; } return true; }); dispatch?.(tr); return true; } insertBlockquote(state: EditorState, dispatch: any) { const path = (state.selection.$from as any).path; if (path.length > 6 && path[path.length - 6].type === schema.nodes.blockquote) { lift(state, dispatch); } else { wrapIn(schema.nodes.blockquote)(state, dispatch); } return true; } insertHorizontalRule(state: EditorState, dispatch: any) { dispatch(state.tr.replaceSelectionWith(state.schema.nodes.horizontal_rule.create()).scrollIntoView()); return true; } @action toggleBrushDropdown() { this.showBrushDropdown = !this.showBrushDropdown; } // todo: add brushes to brushMap to save with a style name onBrushNameKeyPress = (e: React.KeyboardEvent) => { if (e.key === 'Enter') { RichTextMenu.Instance?.brushMarks && RichTextMenu.Instance?._brushMap.set(this._brushNameRef.current!.value, RichTextMenu.Instance.brushMarks); this._brushNameRef.current!.style.background = 'lightGray'; } }; _brushNameRef = React.createRef(); @action clearBrush() { RichTextMenu.Instance && (RichTextMenu.Instance.brushMarks = new Set()); } @action fillBrush(state: EditorState, dispatch: any) { if (!this.view) return; if (!Array.from(this.brushMarks.keys()).length) { const selected_marks = this.getMarksInSelection(this.view.state); if (selected_marks.size >= 0) { this.brushMarks = selected_marks; } } else { const { from, to, $from } = this.view.state.selection; if (!this.view.state.selection.empty && $from && $from.nodeAfter) { if (to - from > 0) { this.view.dispatch(this.view.state.tr.removeMark(from, to)); Array.from(this.brushMarks) .filter(m => m.type !== schema.marks.user_mark) .forEach((mark: Mark) => { this.setMark(mark, this.view!.state, this.view!.dispatch); }); } } } } get TextView() { return (this.view as any)?.TextView as FormattedTextBox; } get TextViewFieldKey() { return this.TextView?._props.fieldKey; } @action setActiveHighlight(color: string) { this._activeHighlightColor = color; } @action setCurrentLink(link: string) { this.currentLink = link; } createLinkButton() { const self = this; function onLinkChange(e: React.ChangeEvent) { self.TextView?.endUndoTypingBatch(); UndoManager.RunInBatch(() => self.setCurrentLink(e.target.value), 'link change'); } const link = this.currentLink ? this.currentLink : ''; const button = ( set hyperlink} placement="bottom"> ); const dropdownContent = (

Linked to:

); return ; } async getTextLinkTargetTitle() { if (!this.view) return; const node = this.view.state.selection.$from.nodeAfter; const link = node && node.marks.find(m => m.type.name === 'link'); if (link) { const href = link.attrs.allAnchors.length > 0 ? link.attrs.allAnchors[0].href : undefined; if (href) { if (href.indexOf(Doc.localServerPath()) === 0) { const linkclicked = href.replace(Doc.localServerPath(), '').split('?')[0]; if (linkclicked) { const linkDoc = await DocServer.GetRefField(linkclicked); if (linkDoc instanceof Doc) { const link_anchor_1 = await Cast(linkDoc.link_anchor_1, Doc); const link_anchor_2 = await Cast(linkDoc.link_anchor_2, Doc); const currentDoc = SelectionManager.Docs.lastElement(); if (currentDoc && link_anchor_1 && link_anchor_2) { if (Doc.AreProtosEqual(currentDoc, link_anchor_1)) { return StrCast(link_anchor_2.title); } if (Doc.AreProtosEqual(currentDoc, link_anchor_2)) { return StrCast(link_anchor_1.title); } } } } } else { return href; } } else { return link.attrs.title; } } } // TODO: should check for valid URL @undoBatch makeLinkToURL = (target: string, lcoation: string) => { ((this.view as any)?.TextView as FormattedTextBox).makeLinkAnchor(undefined, 'onRadd:rightight', target, target); }; @undoBatch deleteLink = () => { if (this.view) { const linkAnchor = this.view.state.selection.$from.nodeAfter?.marks.find(m => m.type === this.view!.state.schema.marks.linkAnchor); if (linkAnchor) { const allAnchors = linkAnchor.attrs.allAnchors.slice(); this.TextView.RemoveAnchorFromSelection(allAnchors); // bcz: Argh ... this will remove the link from the document even it's anchored somewhere else in the text which happens if only part of the anchor text was selected. allAnchors .filter((aref: any) => aref?.href.indexOf(Doc.localServerPath()) === 0) .forEach((aref: any) => { const anchorId = aref.href.replace(Doc.localServerPath(), '').split('?')[0]; anchorId && DocServer.GetRefField(anchorId).then(linkDoc => LinkManager.Instance.deleteLink(linkDoc as Doc)); }); } } }; render() { return null; } } interface ButtonDropdownProps { view?: EditorView; button: JSX.Element; dropdownContent: JSX.Element; openDropdownOnButton?: boolean; link?: boolean; pdf?: boolean; } @observer export class ButtonDropdown extends ObservableReactComponent { @observable private showDropdown: boolean = false; private ref: HTMLDivElement | null = null; constructor(props: any) { super(props); makeObservable(this); } componentDidMount() { document.addEventListener('pointerdown', this.onBlur); } componentWillUnmount() { document.removeEventListener('pointerdown', this.onBlur); } @action setShowDropdown(show: boolean) { this.showDropdown = show; } @action toggleDropdown() { this.showDropdown = !this.showDropdown; } onDropdownClick = (e: React.PointerEvent) => { e.preventDefault(); e.stopPropagation(); this.toggleDropdown(); }; onBlur = (e: PointerEvent) => { setTimeout(() => { if (this.ref !== null && !this.ref.contains(e.target as Node)) { this.setShowDropdown(false); } }, 0); }; render() { return (
(this.ref = node)}> {!this._props.pdf ? (
{this._props.button}
) : ( <> {this._props.button} )} {this.showDropdown ? this._props.dropdownContent : null}
); } } interface RichTextMenuPluginProps { editorProps: any; } export class RichTextMenuPlugin extends React.Component { render() { return null; } update(view: EditorView, lastState: EditorState | undefined) { RichTextMenu.Instance?.updateMenu(view, lastState, this.props.editorProps, (view as any).TextView?.layoutDoc); } }