import { focus } from '@joplin/lib/utils/focusHandler'; import { ContentScriptData, EditorCommandType, EditorControl, EditorProps, EditorSettings, SearchState, UpdateBodyOptions, UserEventSource } from '../types'; import { EditorState, TextSelection, Transaction } from 'prosemirror-state'; import { EditorView } from 'prosemirror-view'; import { DOMParser as ProseMirrorDomParser } from 'prosemirror-model'; import { history } from 'prosemirror-history'; import commands from './commands/commands'; import schema from './schema'; import { gapCursor } from 'prosemirror-gapcursor'; import { dropCursor } from 'prosemirror-dropcursor'; import { EditorEventType } from '../events'; import UndoStackSynchronizer from './utils/UndoStackSynchronizer'; import computeSelectionFormatting from './utils/computeSelectionFormatting'; import { defaultSelectionFormatting, selectionFormattingEqual } from '../SelectionFormatting'; import joplinEditablePlugin from './plugins/joplinEditablePlugin/joplinEditablePlugin'; import keymapExtension from './plugins/keymapPlugin'; import inputRulesExtension from './plugins/inputRulesPlugin'; import originalMarkupPlugin from './plugins/originalMarkupPlugin'; import preprocessEditorInput from './utils/preprocessEditorInput'; import listPlugin from './plugins/listPlugin'; import searchExtension from './plugins/searchPlugin'; import joplinEditorApiPlugin, { setEditorApi } from './plugins/joplinEditorApiPlugin'; import linkTooltipPlugin from './plugins/linkTooltipPlugin'; import { OnCreateCodeEditor as OnCreateCodeEditor, RendererControl } from './types'; import imagePlugin, { onResourceDownloaded } from './plugins/imagePlugin'; import getFileFromPasteEvent from '../utils/getFileFromPasteEvent'; import { RenderResult } from '../../renderer/types'; import postprocessEditorOutput from './utils/postprocessEditorOutput'; import detailsPlugin from './plugins/detailsPlugin'; import tablePlugin from './plugins/tablePlugin'; import clampPointToDocument from './utils/clampPointToDocument'; interface ProseMirrorControl extends EditorControl { getSettings(): EditorSettings; } const createEditor = async ( parentElement: HTMLElement, props: EditorProps, renderer: RendererControl, createCodeEditor: OnCreateCodeEditor, ): Promise => { const renderNodeToMarkup = (node: Node|DocumentFragment) => { return renderer.renderHtmlToMarkup( postprocessEditorOutput(node), ); }; const proseMirrorParser = ProseMirrorDomParser.fromSchema(schema); const cssContainer = document.createElement('style'); parentElement.appendChild(cssContainer); const { plugin: markupTracker, stateToMarkup } = originalMarkupPlugin(renderNodeToMarkup); const { plugin: searchPlugin, updateState: updateSearchState } = searchExtension(props.onEvent); const renderAndPostprocessHtml = async (markup: string) => { const renderResult = await renderer.renderMarkupToHtml(markup, { forceMarkdown: false, isFullPageRender: true, }); const dom = new DOMParser().parseFromString(renderResult.html, 'text/html'); preprocessEditorInput(dom, markup); return { renderResult, dom }; }; const updateGlobalCss = (renderResult: RenderResult) => { cssContainer.replaceChildren( document.createTextNode(renderResult.cssStrings.join('\n')), ); }; let settings = props.settings; const createInitialState = async (markup: string) => { const { renderResult, dom } = await renderAndPostprocessHtml(markup); updateGlobalCss(renderResult); let state = EditorState.create({ doc: proseMirrorParser.parse(dom), plugins: [ inputRulesExtension, keymapExtension, gapCursor(), dropCursor(), history(), detailsPlugin, searchPlugin, joplinEditablePlugin, markupTracker, listPlugin, linkTooltipPlugin, tablePlugin, joplinEditorApiPlugin, imagePlugin, ].flat(), }); const cachedLocalizations = new Map>(); state = state.apply( setEditorApi(state.tr, { onEvent: props.onEvent, renderer, createCodeEditor: createCodeEditor, localize: async (input: string) => { if (cachedLocalizations.has(input)) { return cachedLocalizations.get(input); } const result = props.onLocalize(input); cachedLocalizations.set(input, result); return result; }, }), ); return state; }; const undoStackSynchronizer = new UndoStackSynchronizer(props.onEvent); const onDocumentUpdate = (newState: EditorState) => { props.onEvent({ kind: EditorEventType.Change, value: stateToMarkup(newState), }); }; let lastSelectionFormatting = defaultSelectionFormatting; const onUpdateSelection = (newState: EditorState) => { const selectionFormatting = computeSelectionFormatting(newState, settings); if (!selectionFormattingEqual(lastSelectionFormatting, selectionFormatting)) { lastSelectionFormatting = selectionFormatting; props.onEvent({ kind: EditorEventType.SelectionFormattingChange, formatting: selectionFormatting, }); } props.onEvent({ kind: EditorEventType.SelectionRangeChange, anchor: newState.selection.anchor, head: newState.selection.head, from: newState.selection.from, to: newState.selection.to, }); }; const view = new EditorView(parentElement, { state: await createInitialState(props.initialText), editable: () => !settings.readOnly, dispatchTransaction: transaction => { const newState = view.state.apply(transaction); if (transaction.docChanged) { onDocumentUpdate(newState); } if (transaction.selectionSet || transaction.docChanged || transaction.storedMarksSet) { onUpdateSelection(newState); } undoStackSynchronizer.schedulePostUndoRedoDepthChange(view); view.updateState(newState); }, attributes: { 'aria-label': settings.editorLabel, class: 'prosemirror-editor', }, handleDOMEvents: { paste: (_view, event) => { const fileToPaste = getFileFromPasteEvent(event); if (fileToPaste) { event.preventDefault(); void props.onPasteFile(fileToPaste); return true; } return false; }, }, }); const editorControl: ProseMirrorControl = { supportsCommand: (name: EditorCommandType | string) => { return name in commands && !!commands[name as keyof typeof commands]; }, execCommand: (name: EditorCommandType | string, ...args) => { if (!editorControl.supportsCommand(name)) { throw new Error(`Unsupported command: ${name}`); } commands[name as keyof typeof commands](view.state, view.dispatch, view, args); }, undo: () => { void editorControl.execCommand(EditorCommandType.Undo); }, redo: () => { void editorControl.execCommand(EditorCommandType.Redo); }, select: (anchor: number, head: number) => { const transaction = view.state.tr; transaction.setSelection( TextSelection.create( transaction.doc, clampPointToDocument(view.state, anchor), clampPointToDocument(view.state, head), ), ); view.dispatch(transaction); }, setScrollPercent: (fraction: number) => { // TODO: Handle this in a better way? document.scrollingElement.scrollTop = fraction * document.scrollingElement.scrollHeight; }, insertText: async (text: string, _source?: UserEventSource) => { const { dom } = await renderAndPostprocessHtml(text); view.pasteHTML(new XMLSerializer().serializeToString(dom)); }, updateBody: async (newBody: string, _updateBodyOptions?: UpdateBodyOptions) => { view.updateState(await createInitialState(newBody)); }, getSettings: () => { return settings; }, updateSettings: async (newSettings: EditorSettings) => { const oldSettings = settings; settings = newSettings; if (oldSettings.readOnly !== newSettings.readOnly) { view.setProps({ editable: () => !settings.readOnly }); } if (oldSettings.themeData.themeId !== newSettings.themeData.themeId) { // Refresh global CSS when the theme changes -- render the full document // to avoid required CSS being omitted due to missing markup. const { renderResult } = await renderAndPostprocessHtml(stateToMarkup(view.state)); updateGlobalCss(renderResult); } }, updateLink: (label: string, url: string) => { const doc = view.state.doc; const selection = view.state.selection; let transaction: Transaction = view.state.tr; let linkFrom = selection.from; let linkTo = selection.to; doc.nodesBetween(selection.from, selection.to, (node, position) => { const linkMark = node.marks.find(mark => mark.type === schema.marks.link); if (linkMark) { linkFrom = position; linkTo = position + node.nodeSize; transaction = transaction.removeMark( position, position + node.nodeSize, schema.marks.link, ); } }); // Helper functions that return a point at the current stage of // the transaction: const map = (position: number, associativity: number) => transaction.mapping.map(position, associativity); // Update the link text -- if an existing link, replace just the text // in that link. if (label !== transaction.doc.textBetween(linkFrom, linkTo)) { transaction = transaction.insertText( label, linkFrom, linkTo, ); linkFrom = map(linkFrom, -1); // Ensure that linkFrom is to the left of the text linkTo = map(linkTo, 1); // Ensure that linkTo is to the right of the text } // Add the URL if (url) { transaction = transaction.addMark( // Use the entire selection, linkFrom, linkTo, schema.mark(schema.marks.link, { href: url }), ); } view.dispatch(transaction); }, setSearchState: (newState: SearchState, changeSource = 'setSearchState') => { view.dispatch(updateSearchState(view.state, newState, changeSource)); }, setContentScripts: (_plugins: ContentScriptData[]) => { throw new Error('setContentScripts not implemented.'); }, onResourceChanged: async (resourceId: string) => { const rendered = await renderAndPostprocessHtml(``); const renderedImage = rendered.dom.querySelector('img'); // The resource might not be an image. If so, skip. if (!renderedImage) { return; } const stillNotLoaded = renderedImage.classList.contains('not-loaded-resource'); if (stillNotLoaded) { return; } const resourceSrc = renderedImage?.src; // TODO: Handle the more general case where the resource changed externally onResourceDownloaded(view, resourceId, resourceSrc); }, remove: () => { view.dom.remove(); props.onEvent({ kind: EditorEventType.Remove }); }, focus: () => focus('createEditor', view), }; return editorControl; }; export default createEditor;