From 6704ab0d13a35b625097cdc5463f58a6f6b1253f Mon Sep 17 00:00:00 2001 From: Henry Heino <46334387+personalizedrefrigerator@users.noreply.github.com> Date: Thu, 7 Aug 2025 02:18:09 -0700 Subject: [PATCH] Mobile: Resolves #12841: Allow editing code blocks from the Rich Text Editor (#12906) --- .eslintignore | 8 +- .gitignore | 8 +- .../NoteBody/CodeMirror/v6/CodeMirror.tsx | 1 + .../hooks/useRerenderHandler.ts | 1 + .../components/NoteEditor/MarkdownEditor.tsx | 1 + .../markdownEditorBundle/contentScript.ts | 2 + .../markdownEditorBundle/types.ts | 3 +- .../contentScript/Renderer.test.ts | 1 + .../rendererBundle/contentScript/Renderer.ts | 2 + .../contentScript/utils/addPluginAssets.ts | 25 ++- .../contentScripts/rendererBundle/types.ts | 5 + .../rendererBundle/useWebViewSetup.ts | 1 + .../contentScript/index.ts | 27 +-- .../richTextEditorBundle/types.ts | 4 +- .../richTextEditorBundle/useWebViewSetup.ts | 4 + .../editor/CodeMirror/createEditor.test.ts | 4 + .../CodeMirror/testing/createEditorControl.ts | 1 + packages/editor/ProseMirror/commands.ts | 5 +- packages/editor/ProseMirror/createEditor.ts | 17 +- .../plugins/joplinEditablePlugin.ts | 69 ------- .../createEditorDialog.ts | 70 +++++++ .../joplinEditablePlugin.test.ts | 102 ++++++++++ .../joplinEditablePlugin.ts | 182 ++++++++++++++++++ .../postProcessRenderedHtml.ts | 42 ++++ .../plugins/joplinEditorApiPlugin.ts | 5 +- packages/editor/ProseMirror/schema.ts | 2 +- packages/editor/ProseMirror/styles.ts | 2 + .../ProseMirror/styles/editor-dialog.css | 30 +++ .../ProseMirror/styles/joplin-editable.css | 26 ++- packages/editor/ProseMirror/types.ts | 7 +- .../ProseMirror/utils/dom/createTextArea.ts | 31 +++ .../ProseMirror/utils/dom/createTextNode.ts | 11 ++ .../ProseMirror/utils/dom/createUniqueId.ts | 8 + packages/editor/types.ts | 3 + packages/lib/shim.ts | 8 +- 35 files changed, 607 insertions(+), 111 deletions(-) delete mode 100644 packages/editor/ProseMirror/plugins/joplinEditablePlugin.ts create mode 100644 packages/editor/ProseMirror/plugins/joplinEditablePlugin/createEditorDialog.ts create mode 100644 packages/editor/ProseMirror/plugins/joplinEditablePlugin/joplinEditablePlugin.test.ts create mode 100644 packages/editor/ProseMirror/plugins/joplinEditablePlugin/joplinEditablePlugin.ts create mode 100644 packages/editor/ProseMirror/plugins/joplinEditablePlugin/postProcessRenderedHtml.ts create mode 100644 packages/editor/ProseMirror/styles/editor-dialog.css create mode 100644 packages/editor/ProseMirror/utils/dom/createTextArea.ts create mode 100644 packages/editor/ProseMirror/utils/dom/createTextNode.ts create mode 100644 packages/editor/ProseMirror/utils/dom/createUniqueId.ts diff --git a/.eslintignore b/.eslintignore index 8710a4374d..034843e1ae 100644 --- a/.eslintignore +++ b/.eslintignore @@ -1059,7 +1059,10 @@ packages/editor/ProseMirror/commands.js packages/editor/ProseMirror/createEditor.js packages/editor/ProseMirror/index.js packages/editor/ProseMirror/plugins/inputRulesPlugin.js -packages/editor/ProseMirror/plugins/joplinEditablePlugin.js +packages/editor/ProseMirror/plugins/joplinEditablePlugin/createEditorDialog.js +packages/editor/ProseMirror/plugins/joplinEditablePlugin/joplinEditablePlugin.test.js +packages/editor/ProseMirror/plugins/joplinEditablePlugin/joplinEditablePlugin.js +packages/editor/ProseMirror/plugins/joplinEditablePlugin/postProcessRenderedHtml.js packages/editor/ProseMirror/plugins/joplinEditorApiPlugin.js packages/editor/ProseMirror/plugins/keymapPlugin.js packages/editor/ProseMirror/plugins/linkTooltipPlugin.test.js @@ -1075,6 +1078,9 @@ packages/editor/ProseMirror/types.js packages/editor/ProseMirror/utils/UndoStackSynchronizer.js packages/editor/ProseMirror/utils/canReplaceSelectionWith.js packages/editor/ProseMirror/utils/computeSelectionFormatting.js +packages/editor/ProseMirror/utils/dom/createTextArea.js +packages/editor/ProseMirror/utils/dom/createTextNode.js +packages/editor/ProseMirror/utils/dom/createUniqueId.js packages/editor/ProseMirror/utils/extractSelectedLinesTo.test.js packages/editor/ProseMirror/utils/extractSelectedLinesTo.js packages/editor/ProseMirror/utils/jumpToHash.js diff --git a/.gitignore b/.gitignore index c9e65105e7..b070294533 100644 --- a/.gitignore +++ b/.gitignore @@ -1032,7 +1032,10 @@ packages/editor/ProseMirror/commands.js packages/editor/ProseMirror/createEditor.js packages/editor/ProseMirror/index.js packages/editor/ProseMirror/plugins/inputRulesPlugin.js -packages/editor/ProseMirror/plugins/joplinEditablePlugin.js +packages/editor/ProseMirror/plugins/joplinEditablePlugin/createEditorDialog.js +packages/editor/ProseMirror/plugins/joplinEditablePlugin/joplinEditablePlugin.test.js +packages/editor/ProseMirror/plugins/joplinEditablePlugin/joplinEditablePlugin.js +packages/editor/ProseMirror/plugins/joplinEditablePlugin/postProcessRenderedHtml.js packages/editor/ProseMirror/plugins/joplinEditorApiPlugin.js packages/editor/ProseMirror/plugins/keymapPlugin.js packages/editor/ProseMirror/plugins/linkTooltipPlugin.test.js @@ -1048,6 +1051,9 @@ packages/editor/ProseMirror/types.js packages/editor/ProseMirror/utils/UndoStackSynchronizer.js packages/editor/ProseMirror/utils/canReplaceSelectionWith.js packages/editor/ProseMirror/utils/computeSelectionFormatting.js +packages/editor/ProseMirror/utils/dom/createTextArea.js +packages/editor/ProseMirror/utils/dom/createTextNode.js +packages/editor/ProseMirror/utils/dom/createUniqueId.js packages/editor/ProseMirror/utils/extractSelectedLinesTo.test.js packages/editor/ProseMirror/utils/extractSelectedLinesTo.js packages/editor/ProseMirror/utils/jumpToHash.js diff --git a/packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/v6/CodeMirror.tsx b/packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/v6/CodeMirror.tsx index 0ac8e45b71..78c2a5d65b 100644 --- a/packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/v6/CodeMirror.tsx +++ b/packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/v6/CodeMirror.tsx @@ -410,6 +410,7 @@ const CodeMirror = (props: NoteBodyEditorProps, ref: ForwardedRef ); diff --git a/packages/app-mobile/components/NoteBodyViewer/hooks/useRerenderHandler.ts b/packages/app-mobile/components/NoteBodyViewer/hooks/useRerenderHandler.ts index 0e7b136910..ff9f3be33a 100644 --- a/packages/app-mobile/components/NoteBodyViewer/hooks/useRerenderHandler.ts +++ b/packages/app-mobile/components/NoteBodyViewer/hooks/useRerenderHandler.ts @@ -132,6 +132,7 @@ const useRerenderHandler = (props: Props) => { highlightedKeywords: props.highlightedKeywords, resources: props.noteResources, pluginAssetContainerSelector: '#joplin-container-pluginAssetsContainer', + removeUnusedPluginAssets: true, // If the hash changed, we don't set initial scroll -- we want to scroll to the hash // instead. diff --git a/packages/app-mobile/components/NoteEditor/MarkdownEditor.tsx b/packages/app-mobile/components/NoteEditor/MarkdownEditor.tsx index ab8690415c..dd0c6fa01a 100644 --- a/packages/app-mobile/components/NoteEditor/MarkdownEditor.tsx +++ b/packages/app-mobile/components/NoteEditor/MarkdownEditor.tsx @@ -121,6 +121,7 @@ const MarkdownEditor: React.FC = props => { initialText: props.initialText, initialNoteId: props.noteId, settings: props.editorSettings, + onLocalize: _, }, webviewRef, }); diff --git a/packages/app-mobile/contentScripts/markdownEditorBundle/contentScript.ts b/packages/app-mobile/contentScripts/markdownEditorBundle/contentScript.ts index 9ebeeca416..13631f99b6 100644 --- a/packages/app-mobile/contentScripts/markdownEditorBundle/contentScript.ts +++ b/packages/app-mobile/contentScripts/markdownEditorBundle/contentScript.ts @@ -11,6 +11,7 @@ export const initializeEditor = ({ initialText, initialNoteId, settings, + onLocalize, }: EditorProps) => { const messenger = new WebViewToRNMessenger('markdownEditor', null); @@ -23,6 +24,7 @@ export const initializeEditor = ({ initialText, initialNoteId, settings, + onLocalize, onPasteFile: async (data) => { const base64 = await readFileToBase64(data); diff --git a/packages/app-mobile/contentScripts/markdownEditorBundle/types.ts b/packages/app-mobile/contentScripts/markdownEditorBundle/types.ts index 7af148c31f..9abc5546d0 100644 --- a/packages/app-mobile/contentScripts/markdownEditorBundle/types.ts +++ b/packages/app-mobile/contentScripts/markdownEditorBundle/types.ts @@ -1,5 +1,5 @@ import { EditorEvent } from '@joplin/editor/events'; -import { EditorControl, EditorSettings } from '@joplin/editor/types'; +import { EditorControl, EditorSettings, OnLocalize } from '@joplin/editor/types'; export interface EditorProcessApi { editor: EditorControl; @@ -14,6 +14,7 @@ export interface EditorProps { parentElementClassName: string; initialText: string; initialNoteId: string; + onLocalize: OnLocalize; settings: EditorSettings; } diff --git a/packages/app-mobile/contentScripts/rendererBundle/contentScript/Renderer.test.ts b/packages/app-mobile/contentScripts/rendererBundle/contentScript/Renderer.test.ts index f3be5e1424..a0047fac0c 100644 --- a/packages/app-mobile/contentScripts/rendererBundle/contentScript/Renderer.test.ts +++ b/packages/app-mobile/contentScripts/rendererBundle/contentScript/Renderer.test.ts @@ -12,6 +12,7 @@ const defaultRendererSettings: RenderSettings = { noteHash: '', initialScroll: 0, readAssetBlob: async (_path: string) => new Blob(), + removeUnusedPluginAssets: true, createEditPopupSyntax: '', destroyEditPopupSyntax: '', diff --git a/packages/app-mobile/contentScripts/rendererBundle/contentScript/Renderer.ts b/packages/app-mobile/contentScripts/rendererBundle/contentScript/Renderer.ts index e8affbc6bf..9a2af928c2 100644 --- a/packages/app-mobile/contentScripts/rendererBundle/contentScript/Renderer.ts +++ b/packages/app-mobile/contentScripts/rendererBundle/contentScript/Renderer.ts @@ -23,6 +23,7 @@ export interface RenderSettings { initialScroll: number; // If [null], plugin assets are not added to the document pluginAssetContainerSelector: string|null; + removeUnusedPluginAssets: boolean; splitted?: boolean; // Move CSS into a separate output mapsToLine?: boolean; // Sourcemaps @@ -156,6 +157,7 @@ export default class Renderer { inlineAssets: this.setupOptions_.useTransferredFiles, readAssetBlob: settings.readAssetBlob, container: document.querySelector(settings.pluginAssetContainerSelector), + removeUnusedPluginAssets: settings.removeUnusedPluginAssets, }); // Some plugins require this event to be dispatched just after being added. diff --git a/packages/app-mobile/contentScripts/rendererBundle/contentScript/utils/addPluginAssets.ts b/packages/app-mobile/contentScripts/rendererBundle/contentScript/utils/addPluginAssets.ts index 9485b0f1dd..55f5b484f0 100644 --- a/packages/app-mobile/contentScripts/rendererBundle/contentScript/utils/addPluginAssets.ts +++ b/packages/app-mobile/contentScripts/rendererBundle/contentScript/utils/addPluginAssets.ts @@ -39,6 +39,7 @@ const rewriteInternalAssetLinks = async (asset: RenderResultPluginAsset, content interface Options { inlineAssets: boolean; + removeUnusedPluginAssets: boolean; container: HTMLElement; readAssetBlob?(path: string): Promise; } @@ -137,16 +138,22 @@ const addPluginAssets = async (assets: RenderResultPluginAsset[], options: Optio // light to dark theme, and then back to light theme - in that case // the viewer would remain dark because it would use the dark // stylesheet that would still be in the DOM. - for (const [assetId, asset] of Object.entries(pluginAssetsAdded_)) { - if (!processedAssetIds.includes(assetId)) { - try { - asset.element.remove(); - } catch (error) { - // We don't throw an exception but we log it since - // it shouldn't happen - console.warn('Tried to remove an asset but got an error', error); + // + // In some cases, however, we only want to rerender part of the document. + // In this case, old plugin assets may have been from the last full-page + // render and should not be removed. + if (options.removeUnusedPluginAssets) { + for (const [assetId, asset] of Object.entries(pluginAssetsAdded_)) { + if (!processedAssetIds.includes(assetId)) { + try { + asset.element.remove(); + } catch (error) { + // We don't throw an exception but we log it since + // it shouldn't happen + console.warn('Tried to remove an asset but got an error', error); + } + pluginAssetsAdded_[assetId] = null; } - pluginAssetsAdded_[assetId] = null; } } }; diff --git a/packages/app-mobile/contentScripts/rendererBundle/types.ts b/packages/app-mobile/contentScripts/rendererBundle/types.ts index b950034f40..086052513c 100644 --- a/packages/app-mobile/contentScripts/rendererBundle/types.ts +++ b/packages/app-mobile/contentScripts/rendererBundle/types.ts @@ -54,8 +54,13 @@ export interface RenderOptions { highlightedKeywords: string[]; resources: ResourceInfos; themeOverrides: Record; + // If null, plugin assets will not be added to the document. pluginAssetContainerSelector: string|null; + // When true, plugin assets are removed from the container when not used by the render result. + // This should be true for full-page renders. + removeUnusedPluginAssets: boolean; + noteHash: string; initialScroll: number; diff --git a/packages/app-mobile/contentScripts/rendererBundle/useWebViewSetup.ts b/packages/app-mobile/contentScripts/rendererBundle/useWebViewSetup.ts index 3f378f1163..1fed0d1948 100644 --- a/packages/app-mobile/contentScripts/rendererBundle/useWebViewSetup.ts +++ b/packages/app-mobile/contentScripts/rendererBundle/useWebViewSetup.ts @@ -219,6 +219,7 @@ const useWebViewSetup = (props: Props): SetUpResult => { } return shim.fsDriver().fileAtPath(resolvedPath); }, + removeUnusedPluginAssets: options.removeUnusedPluginAssets, }; await transferResources(options.resources); diff --git a/packages/app-mobile/contentScripts/richTextEditorBundle/contentScript/index.ts b/packages/app-mobile/contentScripts/richTextEditorBundle/contentScript/index.ts index 9a72c03fd7..86cb3e18ea 100644 --- a/packages/app-mobile/contentScripts/richTextEditorBundle/contentScript/index.ts +++ b/packages/app-mobile/contentScripts/richTextEditorBundle/contentScript/index.ts @@ -16,22 +16,6 @@ const postprocessHtml = (html: HTMLElement) => { resource.src = `:/${resourceId}`; } - // Re-add newlines to data-joplin-source-* that were removed - // by ProseMirror. - // TODO: Try to find a better solution - const sourceBlocks = html.querySelectorAll( - 'pre[data-joplin-source-open][data-joplin-source-close].joplin-source', - ); - for (const sourceBlock of sourceBlocks) { - const isBlock = sourceBlock.parentElement.tagName !== 'SPAN'; - if (isBlock) { - const originalOpen = sourceBlock.getAttribute('data-joplin-source-open'); - const originalClose = sourceBlock.getAttribute('data-joplin-source-close'); - sourceBlock.setAttribute('data-joplin-source-open', `${originalOpen}\n`); - sourceBlock.setAttribute('data-joplin-source-close', `\n${originalClose}`); - } - } - return html; }; @@ -73,6 +57,7 @@ export const initialize = async ({ settings, initialText, initialNoteId, + onLocalize: messenger.remoteApi.onLocalize, onPasteFile: async (data) => { const base64 = await readFileToBase64(data); @@ -85,14 +70,20 @@ export const initialize = async ({ void messenger.remoteApi.onEditorEvent(event); }, }, { - renderMarkupToHtml: async (markup) => { + renderMarkupToHtml: async (markup, options) => { + let language = MarkupLanguage.Markdown; + if (settings.language === EditorLanguageType.Html && !options.forceMarkdown) { + language = MarkupLanguage.Html; + } + return await messenger.remoteApi.onRender({ markup, - language: settings.language === EditorLanguageType.Html ? MarkupLanguage.Html : MarkupLanguage.Markdown, + language, }, { pluginAssetContainerSelector: `#${assetContainer.id}`, splitted: true, mapsToLine: true, + removeUnusedPluginAssets: options.isFullPageRender, }); }, renderHtmlToMarkup: (node) => { diff --git a/packages/app-mobile/contentScripts/richTextEditorBundle/types.ts b/packages/app-mobile/contentScripts/richTextEditorBundle/types.ts index db3d50ff3c..0acbfa1135 100644 --- a/packages/app-mobile/contentScripts/richTextEditorBundle/types.ts +++ b/packages/app-mobile/contentScripts/richTextEditorBundle/types.ts @@ -1,5 +1,5 @@ import { EditorEvent } from '@joplin/editor/events'; -import { EditorControl, EditorSettings, SearchState } from '@joplin/editor/types'; +import { EditorControl, EditorSettings, OnLocalize, SearchState } from '@joplin/editor/types'; import { MarkupRecord, RendererControl } from '../rendererBundle/types'; import { RenderResult } from '@joplin/renderer/types'; @@ -19,6 +19,7 @@ type RenderOptionsSlice = { pluginAssetContainerSelector: string; splitted: boolean; mapsToLine: true; + removeUnusedPluginAssets: boolean; }; export interface MainProcessApi { @@ -26,6 +27,7 @@ export interface MainProcessApi { logMessage(message: string): Promise; onRender(markup: MarkupRecord, options: RenderOptionsSlice): Promise; onPasteFile(type: string, base64: string): Promise; + onLocalize: OnLocalize; } export interface RichTextEditorControl { diff --git a/packages/app-mobile/contentScripts/richTextEditorBundle/useWebViewSetup.ts b/packages/app-mobile/contentScripts/richTextEditorBundle/useWebViewSetup.ts index 98b5a2e717..b7067d6921 100644 --- a/packages/app-mobile/contentScripts/richTextEditorBundle/useWebViewSetup.ts +++ b/packages/app-mobile/contentScripts/richTextEditorBundle/useWebViewSetup.ts @@ -11,6 +11,7 @@ import shim from '@joplin/lib/shim'; import { PluginStates } from '@joplin/lib/services/plugins/reducer'; import { RendererControl, RenderOptions } from '../rendererBundle/types'; import { ResourceInfos } from '@joplin/renderer/types'; +import { _ } from '@joplin/lib/locale'; import { defaultSearchState } from '../../components/NoteEditor/SearchPanel'; const logger = Logger.create('useWebViewSetup'); @@ -50,6 +51,7 @@ const useMessenger = (props: UseMessengerProps) => { noteHash: '', initialScroll: 0, pluginAssetContainerSelector: null, + removeUnusedPluginAssets: true, }; return useMemo(() => { @@ -70,6 +72,7 @@ const useMessenger = (props: UseMessengerProps) => { splitted: options.splitted, pluginAssetContainerSelector: options.pluginAssetContainerSelector, mapsToLine: options.mapsToLine, + removeUnusedPluginAssets: options.removeUnusedPluginAssets, }, ); return renderResult; @@ -77,6 +80,7 @@ const useMessenger = (props: UseMessengerProps) => { onPasteFile: async (type: string, base64: string) => { onAttachRef.current(type, base64); }, + onLocalize: _, }; const messenger = new RNToWebViewMessenger( diff --git a/packages/editor/CodeMirror/createEditor.test.ts b/packages/editor/CodeMirror/createEditor.test.ts index 7e09f22547..c513257a21 100644 --- a/packages/editor/CodeMirror/createEditor.test.ts +++ b/packages/editor/CodeMirror/createEditor.test.ts @@ -40,6 +40,7 @@ describe('createEditor', () => { settings: editorSettings, onEvent: _event => {}, onLogMessage: _message => {}, + onLocalize: input => input, onPasteFile: null, }); @@ -69,6 +70,7 @@ describe('createEditor', () => { settings: editorSettings, onEvent: _event => {}, onLogMessage: _message => {}, + onLocalize: input => input, onPasteFile: null, }); @@ -138,6 +140,7 @@ describe('createEditor', () => { settings: editorSettings, onEvent: _event => {}, onLogMessage: _message => {}, + onLocalize: input => input, onPasteFile: null, }); @@ -188,6 +191,7 @@ describe('createEditor', () => { settings: editorSettings, onEvent: () => {}, onLogMessage: () => {}, + onLocalize: input => input, onPasteFile: null, }); const editorState = editor.editor.state; diff --git a/packages/editor/CodeMirror/testing/createEditorControl.ts b/packages/editor/CodeMirror/testing/createEditorControl.ts index d027b75c95..30ce1afef4 100644 --- a/packages/editor/CodeMirror/testing/createEditorControl.ts +++ b/packages/editor/CodeMirror/testing/createEditorControl.ts @@ -12,6 +12,7 @@ const createEditorControl = (initialText: string) => { onEvent: _event => {}, onLogMessage: _message => {}, onPasteFile: null, + onLocalize: input=>input, }); }; diff --git a/packages/editor/ProseMirror/commands.ts b/packages/editor/ProseMirror/commands.ts index a781e1ae1d..82f2c237f2 100644 --- a/packages/editor/ProseMirror/commands.ts +++ b/packages/editor/ProseMirror/commands.ts @@ -99,7 +99,10 @@ const commands: Record = { if (canReplaceSelectionWith(state.selection, nodeType)) { void (async () => { const separator = block ? '$$' : '$'; - const rendered = await renderer.renderMarkupToHtml(`${separator}${selectedText}${separator}`); + const rendered = await renderer.renderMarkupToHtml(`${separator}${selectedText}${separator}`, { + forceMarkdown: true, + isFullPageRender: false, + }); if (view) { view.pasteHTML(rendered.html); diff --git a/packages/editor/ProseMirror/createEditor.ts b/packages/editor/ProseMirror/createEditor.ts index 99f9d55123..b5bd908fd9 100644 --- a/packages/editor/ProseMirror/createEditor.ts +++ b/packages/editor/ProseMirror/createEditor.ts @@ -11,7 +11,7 @@ import { EditorEventType } from '../events'; import UndoStackSynchronizer from './utils/UndoStackSynchronizer'; import computeSelectionFormatting from './utils/computeSelectionFormatting'; import { defaultSelectionFormatting, selectionFormattingEqual } from '../SelectionFormatting'; -import joplinEditablePlugin from './plugins/joplinEditablePlugin'; +import joplinEditablePlugin from './plugins/joplinEditablePlugin/joplinEditablePlugin'; import keymapExtension from './plugins/keymapPlugin'; import inputRulesExtension from './plugins/inputRulesPlugin'; import originalMarkupPlugin from './plugins/originalMarkupPlugin'; @@ -44,7 +44,10 @@ const createEditor = async ( const { plugin: searchPlugin, updateState: updateSearchState } = searchExtension(props.onEvent); const renderAndPostprocessHtml = async (markup: string) => { - const renderResult = await renderer.renderMarkupToHtml(markup); + const renderResult = await renderer.renderMarkupToHtml(markup, { + forceMarkdown: false, + isFullPageRender: true, + }); const dom = new DOMParser().parseFromString(renderResult.html, 'text/html'); preprocessEditorInput(dom, markup); @@ -81,10 +84,20 @@ const createEditor = async ( ].flat(), }); + const cachedLocalizations = new Map>(); state = state.apply( setEditorApi(state.tr, { onEvent: props.onEvent, renderer, + localize: async (input: string) => { + if (cachedLocalizations.has(input)) { + return cachedLocalizations.get(input); + } + + const result = props.onLocalize(input); + cachedLocalizations.set(input, result); + return result; + }, }), ); diff --git a/packages/editor/ProseMirror/plugins/joplinEditablePlugin.ts b/packages/editor/ProseMirror/plugins/joplinEditablePlugin.ts deleted file mode 100644 index 35c7faf23d..0000000000 --- a/packages/editor/ProseMirror/plugins/joplinEditablePlugin.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { Plugin } from 'prosemirror-state'; -import { Node, NodeSpec } from 'prosemirror-model'; -import { NodeView } from 'prosemirror-view'; -import sanitizeHtml from '../utils/sanitizeHtml'; - -// See the fold example for more information about -// writing similar ProseMirror plugins: -// https://prosemirror.net/examples/fold/ - - -const makeJoplinEditableSpec = (inline: boolean): NodeSpec => ({ - group: inline ? 'inline' : 'block', - inline: inline, - draggable: true, - attrs: { - contentHtml: { default: '', validate: 'string' }, - }, - parseDOM: [ - { - tag: `${inline ? 'span' : 'div'}.joplin-editable`, - getAttrs: node => ({ - contentHtml: node.innerHTML, - }), - }, - ], - toDOM: node => { - const content = document.createElement(inline ? 'span' : 'div'); - content.classList.add('joplin-editable'); - content.innerHTML = sanitizeHtml(node.attrs.contentHtml); - return content; - }, -}); - -export const nodeSpecs = { - joplinEditableInline: makeJoplinEditableSpec(true), - joplinEditableBlock: makeJoplinEditableSpec(false), -}; - -class EditableSourceBlockView implements NodeView { - public readonly dom: HTMLElement; - public constructor(node: Node, inline: boolean) { - if ((node.attrs.contentHtml ?? undefined) === undefined) { - throw new Error(`Unable to create a SourceBlockView for a node lacking contentHtml. Node: ${node}.`); - } - - this.dom = document.createElement(inline ? 'span' : 'div'); - this.dom.classList.add('joplin-editable'); - this.dom.innerHTML = sanitizeHtml(node.attrs.contentHtml); - } - - public selectNode() { - this.dom.classList.add('-selected'); - } - - public deselectNode() { - this.dom.classList.remove('-selected'); - } -} - -const joplinEditablePlugin = new Plugin({ - props: { - nodeViews: { - joplinEditableInline: node => new EditableSourceBlockView(node, true), - joplinEditableBlock: node => new EditableSourceBlockView(node, false), - }, - }, -}); - -export default joplinEditablePlugin; diff --git a/packages/editor/ProseMirror/plugins/joplinEditablePlugin/createEditorDialog.ts b/packages/editor/ProseMirror/plugins/joplinEditablePlugin/createEditorDialog.ts new file mode 100644 index 0000000000..cb810ecfc6 --- /dev/null +++ b/packages/editor/ProseMirror/plugins/joplinEditablePlugin/createEditorDialog.ts @@ -0,0 +1,70 @@ +import { focus } from '@joplin/lib/utils/focusHandler'; +import createTextNode from '../../utils/dom/createTextNode'; +import createTextArea from '../../utils/dom/createTextArea'; + +interface SourceBlockData { + start: string; + content: string; + end: string; +} + +interface Options { + editorLabel: string|Promise; + doneLabel: string|Promise; + block: SourceBlockData; + onSave: (newContent: SourceBlockData)=> void; +} + +const createEditorDialog = ({ editorLabel, doneLabel, block, onSave }: Options) => { + const dialog = document.createElement('dialog'); + dialog.classList.add('editor-dialog', '-visible'); + document.body.appendChild(dialog); + + dialog.onclose = () => { + dialog.remove(); + }; + + const { textArea, label: textAreaLabel } = createTextArea({ + label: editorLabel, + initialContent: block.content, + onChange: (newContent) => { + block = { + ...block, + content: newContent, + }; + onSave(block); + }, + spellCheck: false, + }); + + + const submitButton = document.createElement('button'); + submitButton.appendChild(createTextNode(doneLabel)); + submitButton.classList.add('submit'); + submitButton.onclick = () => { + if (dialog.close) { + dialog.close(); + } else { + // .remove the dialog in browsers with limited support for + // HTMLDialogElement (and in JSDOM). + dialog.remove(); + } + }; + + dialog.appendChild(textAreaLabel); + dialog.appendChild(textArea); + dialog.appendChild(submitButton); + + + // .showModal is not defined in JSDOM and some older (pre-2022) browsers + if (dialog.showModal) { + dialog.showModal(); + } else { + dialog.classList.add('-fake-modal'); + focus('createEditorDialog/legacy', textArea); + } + + return {}; +}; + +export default createEditorDialog; diff --git a/packages/editor/ProseMirror/plugins/joplinEditablePlugin/joplinEditablePlugin.test.ts b/packages/editor/ProseMirror/plugins/joplinEditablePlugin/joplinEditablePlugin.test.ts new file mode 100644 index 0000000000..bbc06aecc1 --- /dev/null +++ b/packages/editor/ProseMirror/plugins/joplinEditablePlugin/joplinEditablePlugin.test.ts @@ -0,0 +1,102 @@ +import { htmlentities } from '@joplin/utils/html'; +import { RenderResult } from '../../../../renderer/types'; +import createTestEditor from '../../testing/createTestEditor'; +import joplinEditorApiPlugin, { getEditorApi, setEditorApi } from '../joplinEditorApiPlugin'; +import joplinEditablePlugin from './joplinEditablePlugin'; +import { Second } from '@joplin/utils/time'; + +const createEditor = (html: string) => { + return createTestEditor({ + plugins: [joplinEditablePlugin, joplinEditorApiPlugin], + html, + }); +}; + +const findEditButton = (ancestor: Element): HTMLButtonElement => { + return ancestor.querySelector('.joplin-editable > button.edit'); +}; + +const findEditorDialog = () => { + const dialog = document.querySelector('dialog.editor-dialog'); + if (!dialog) { + throw new Error('Could not find an open editor dialog.'); + } + + const editor = dialog.querySelector('textarea'); + const submitButton = dialog.querySelector('button'); + + return { + dialog, + editor, + submitButton, + }; +}; + +describe('joplinEditablePlugin', () => { + beforeEach(() => { + jest.useFakeTimers(); + document.body.replaceChildren(); + }); + + test.each([ + // Inline + '
test
rendered
', + // Block + '
test
rendered
', + // Nested inline + '

Test:

test
rendered

', + ])('should show an edit button on source blocks (case %#)', (htmlSource) => { + const editor = createEditor(htmlSource); + const editButton = findEditButton(editor.dom); + expect(editButton.textContent).toBe('Edit'); + }); + + test('clicking the edit button should show an editor dialog', () => { + const editor = createEditor('
test source
rendered
'); + const editButton = findEditButton(editor.dom); + editButton.click(); + + // Should show the dialog + const dialog = findEditorDialog(); + expect(dialog.editor).toBeTruthy(); + expect(dialog.submitButton).toBeTruthy(); + }); + + test('editing the content of an editor dialog should update the source block', async () => { + const editor = createEditor('
test source
rendered
'); + + // Mock render functions: + editor.dispatch(setEditorApi(editor.state.tr, { + ...getEditorApi(editor.state), + renderer: { + renderMarkupToHtml: jest.fn(async source => ({ + html: `
${htmlentities(source)}

Mocked!

`, + } as RenderResult)), + renderHtmlToMarkup: jest.fn(), + }, + })); + + const editButton = findEditButton(editor.dom); + editButton.click(); + + const dialog = findEditorDialog(); + dialog.editor.value = 'Updated!'; + dialog.editor.dispatchEvent(new Event('input')); + + // Should update the editor state with the new source immediately. + expect(editor.state.doc.toJSON()).toMatchObject({ + content: [{ + type: 'joplinEditableBlock', + attrs: { + source: 'Updated!', + }, + }], + }); + + // Should render and update the display within a short amount of time + await jest.advanceTimersByTimeAsync(Second); + const renderedEditable = editor.dom.querySelector('.joplin-editable'); + // Should render the updated content + expect(renderedEditable.querySelector('.test-content').innerHTML).toBe('Mocked!'); + }); +}); diff --git a/packages/editor/ProseMirror/plugins/joplinEditablePlugin/joplinEditablePlugin.ts b/packages/editor/ProseMirror/plugins/joplinEditablePlugin/joplinEditablePlugin.ts new file mode 100644 index 0000000000..fd969b065f --- /dev/null +++ b/packages/editor/ProseMirror/plugins/joplinEditablePlugin/joplinEditablePlugin.ts @@ -0,0 +1,182 @@ +import { Plugin } from 'prosemirror-state'; +import { Node, NodeSpec } from 'prosemirror-model'; +import { EditorView, NodeView } from 'prosemirror-view'; +import sanitizeHtml from '../../utils/sanitizeHtml'; +import createEditorDialog from './createEditorDialog'; +import { getEditorApi } from '../joplinEditorApiPlugin'; +import { msleep } from '@joplin/utils/time'; +import createTextNode from '../../utils/dom/createTextNode'; +import postProcessRenderedHtml from './postProcessRenderedHtml'; + +// See the fold example for more information about +// writing similar ProseMirror plugins: +// https://prosemirror.net/examples/fold/ + + +const makeJoplinEditableSpec = (inline: boolean): NodeSpec => ({ + group: inline ? 'inline' : 'block', + inline: inline, + draggable: true, + attrs: { + contentHtml: { default: '', validate: 'string' }, + source: { default: '', validate: 'string' }, + language: { default: '', validate: 'string' }, + openCharacters: { default: '', validate: 'string' }, + closeCharacters: { default: '', validate: 'string' }, + }, + parseDOM: [ + { + tag: `${inline ? 'span' : 'div'}.joplin-editable`, + getAttrs: node => { + const sourceNode = node.querySelector('.joplin-source'); + return { + contentHtml: node.innerHTML, + source: sourceNode?.textContent, + openCharacters: sourceNode?.getAttribute('data-joplin-source-open'), + closeCharacters: sourceNode?.getAttribute('data-joplin-source-close'), + language: sourceNode?.getAttribute('data-joplin-language'), + }; + }, + }, + ], + toDOM: node => { + const content = document.createElement(inline ? 'span' : 'div'); + content.classList.add('joplin-editable'); + content.innerHTML = sanitizeHtml(node.attrs.contentHtml); + + const sourceNode = content.querySelector('.joplin-source'); + if (sourceNode) { + sourceNode.textContent = node.attrs.source; + sourceNode.setAttribute('data-joplin-source-open', node.attrs.openCharacters); + sourceNode.setAttribute('data-joplin-source-close', node.attrs.closeCharacters); + } + + return content; + }, +}); + +export const nodeSpecs = { + joplinEditableInline: makeJoplinEditableSpec(true), + joplinEditableBlock: makeJoplinEditableSpec(false), +}; + +type GetPosition = ()=> number; + +class EditableSourceBlockView implements NodeView { + public readonly dom: HTMLElement; + public constructor(private node: Node, inline: boolean, private view: EditorView, private getPosition: GetPosition) { + if ((node.attrs.contentHtml ?? undefined) === undefined) { + throw new Error(`Unable to create a SourceBlockView for a node lacking contentHtml. Node: ${node}.`); + } + + this.dom = document.createElement(inline ? 'span' : 'div'); + this.dom.classList.add('joplin-editable'); + this.updateContent_(); + } + + private showEditDialog_() { + const { localize: _ } = getEditorApi(this.view.state); + + let saveCounter = 0; + createEditorDialog({ + doneLabel: _('Done'), + editorLabel: _('Code:'), + block: { + content: this.node.attrs.source, + start: this.node.attrs.openCharacters, + end: this.node.attrs.closeCharacters, + }, + onSave: async (block) => { + this.view.dispatch( + this.view.state.tr.setNodeAttribute( + this.getPosition(), 'source', block.content, + ).setNodeAttribute( + this.getPosition(), 'openCharacters', block.start, + ).setNodeAttribute( + this.getPosition(), 'closeCharacters', block.end, + ), + ); + + saveCounter ++; + const initialSaveCounter = saveCounter; + const cancelled = () => saveCounter !== initialSaveCounter; + + // Debounce rendering + await msleep(400); + if (cancelled()) return; + + const rendered = await getEditorApi(this.view.state).renderer.renderMarkupToHtml( + `${block.start}${block.content}${block.end}`, + { forceMarkdown: true, isFullPageRender: false }, + ); + if (cancelled()) return; + + const html = postProcessRenderedHtml(rendered.html, this.node.isInline); + this.view.dispatch( + this.view.state.tr.setNodeAttribute( + this.getPosition(), 'contentHtml', html, + ), + ); + }, + }); + } + + private updateContent_() { + const setDomContentSafe = (html: string) => { + this.dom.innerHTML = sanitizeHtml(html); + }; + + const addEditButton = () => { + const editButton = document.createElement('button'); + editButton.classList.add('edit'); + + const { localize: _ } = getEditorApi(this.view.state); + + editButton.appendChild(createTextNode(_('Edit'))); + editButton.onclick = (event) => { + this.showEditDialog_(); + event.preventDefault(); + }; + this.dom.appendChild(editButton); + }; + + setDomContentSafe(this.node.attrs.contentHtml); + postProcessRenderedHtml(this.dom, this.node.isInline); + addEditButton(); + } + + public selectNode() { + this.dom.classList.add('-selected'); + } + + public deselectNode() { + this.dom.classList.remove('-selected'); + } + + public stopEvent(event: Event) { + // Allow using the keyboard to activate the "edit" button: + return event.target === this.dom.querySelector('button.edit'); + } + + public update(node: Node) { + if (node.type.spec !== this.node.type.spec) { + return false; + } + + this.node = node; + this.updateContent_(); + + return true; + } +} + +const joplinEditablePlugin = new Plugin({ + props: { + nodeViews: { + joplinEditableInline: (node, view, getPos) => new EditableSourceBlockView(node, true, view, getPos), + joplinEditableBlock: (node, view, getPos) => new EditableSourceBlockView(node, false, view, getPos), + }, + }, +}); + +export default joplinEditablePlugin; diff --git a/packages/editor/ProseMirror/plugins/joplinEditablePlugin/postProcessRenderedHtml.ts b/packages/editor/ProseMirror/plugins/joplinEditablePlugin/postProcessRenderedHtml.ts new file mode 100644 index 0000000000..5dcf449f22 --- /dev/null +++ b/packages/editor/ProseMirror/plugins/joplinEditablePlugin/postProcessRenderedHtml.ts @@ -0,0 +1,42 @@ +// If renderedHtml is an HTMLElement, the content is modified in-place. +const postProcessRenderedHtml = (renderedHtml: InputType, isInline: boolean): InputType => { + let rootElement; + if (typeof renderedHtml === 'string') { + const parser = new DOMParser(); + const parsed = parser.parseFromString( + `${renderedHtml}`, + 'text/html', + ); + rootElement = parsed.body; + } else { + rootElement = renderedHtml; + } + + const replaceChildMatching = (selector: string) => { + const toReplace = [...rootElement.children].find( + child => child.matches(selector), + ); + toReplace?.replaceWith(...toReplace.childNodes); + }; + // If the original HTML is from .renderToMarkup, it may have a
wrapper: + replaceChildMatching('#rendered-md'); + + if (rootElement.children.length === 1 && isInline) { + replaceChildMatching('p, div'); + } + + // Remove the 'joplin-editable' container if it's the only thing in the content + // (since this.dom is itself a joplin-editable) + if (rootElement.children.length === 1) { + replaceChildMatching('.joplin-editable'); + } + + // Match the input type + if (typeof renderedHtml === 'string') { + return rootElement.innerHTML as InputType; + } else { + return rootElement as InputType; + } +}; + +export default postProcessRenderedHtml; diff --git a/packages/editor/ProseMirror/plugins/joplinEditorApiPlugin.ts b/packages/editor/ProseMirror/plugins/joplinEditorApiPlugin.ts index b1c60aee41..6d76bfb88d 100644 --- a/packages/editor/ProseMirror/plugins/joplinEditorApiPlugin.ts +++ b/packages/editor/ProseMirror/plugins/joplinEditorApiPlugin.ts @@ -1,10 +1,11 @@ import { EditorState, Plugin, Transaction } from 'prosemirror-state'; -import { OnEventCallback } from '../../types'; +import { OnEventCallback, OnLocalize } from '../../types'; import { RendererControl } from '../types'; export interface EditorApi { renderer: RendererControl; onEvent: OnEventCallback; + localize: OnLocalize; } @@ -29,6 +30,8 @@ const joplinEditorApiPlugin = new Plugin({ throw new Error('Not initialized'); }, }, + settings: null, + localize: input => input, }), apply: (tr, value) => { const proposedValue = tr.getMeta(joplinEditorApiPlugin); diff --git a/packages/editor/ProseMirror/schema.ts b/packages/editor/ProseMirror/schema.ts index cdbe5b8fa9..9f455e636f 100644 --- a/packages/editor/ProseMirror/schema.ts +++ b/packages/editor/ProseMirror/schema.ts @@ -1,5 +1,5 @@ import { AttributeSpec, DOMOutputSpec, MarkSpec, NodeSpec, Schema } from 'prosemirror-model'; -import { nodeSpecs as joplinEditableNodes } from './plugins/joplinEditablePlugin'; +import { nodeSpecs as joplinEditableNodes } from './plugins/joplinEditablePlugin/joplinEditablePlugin'; import { tableNodes } from 'prosemirror-tables'; import { nodeSpecs as listNodes } from './plugins/listPlugin'; import { nodeSpecs as resourcePlaceholderNodes } from './plugins/resourcePlaceholderPlugin'; diff --git a/packages/editor/ProseMirror/styles.ts b/packages/editor/ProseMirror/styles.ts index 16729c62ad..e3f27f2be9 100644 --- a/packages/editor/ProseMirror/styles.ts +++ b/packages/editor/ProseMirror/styles.ts @@ -2,7 +2,9 @@ import 'prosemirror-view/style/prosemirror.css'; import 'prosemirror-search/style/search.css'; import './styles/joplin-editable.css'; +import './styles/editor-dialog.css'; import './styles/prosemirror-editor.css'; import './styles/table.css'; import './styles/checklist-item.css'; import './styles/link-tooltip.css'; + diff --git a/packages/editor/ProseMirror/styles/editor-dialog.css b/packages/editor/ProseMirror/styles/editor-dialog.css new file mode 100644 index 0000000000..ecca4ff19f --- /dev/null +++ b/packages/editor/ProseMirror/styles/editor-dialog.css @@ -0,0 +1,30 @@ + +.editor-dialog { + display: flex; + flex-direction: column; + background-color: var(--joplin-background-color); + border: none; + border-radius: 16px; + box-shadow: 0px 0px 2px var(--joplin-color); + color: var(--joplin-color); + + width: min(80vw, 600px); + padding: 16px; +} + +.editor-dialog > textarea { + flex-grow: 1; + min-height: min(70vh, 400px); + resize: none; + color: var(--joplin-color); + background-color: var(--joplin-background-color); +} + +.editor-dialog > button { + color: var(--joplin-color); + background-color: var(--joplin-background-color3); + border: none; + + border-radius: 8px; + height: 38px; +} diff --git a/packages/editor/ProseMirror/styles/joplin-editable.css b/packages/editor/ProseMirror/styles/joplin-editable.css index 98505f4333..0f75c7b01f 100644 --- a/packages/editor/ProseMirror/styles/joplin-editable.css +++ b/packages/editor/ProseMirror/styles/joplin-editable.css @@ -1,4 +1,28 @@ +.joplin-editable { + position: relative; + /* Override the "white-space" setting for the editor */ + white-space: normal; +} + +.joplin-editable > .edit { + position: absolute; + top: 0; + right: 0; + opacity: 0; + + display: none; + + transition-behavior: allow-discrete; + transition-duration: 0.2s; + transition-property: opacity, display; +} + .joplin-editable.-selected { outline: 4px solid var(--joplin-text-selection-color); -} \ No newline at end of file +} + +.joplin-editable.-selected > .edit { + display: block; + opacity: 0.9; +} diff --git a/packages/editor/ProseMirror/types.ts b/packages/editor/ProseMirror/types.ts index c9c0730aba..86299a9a11 100644 --- a/packages/editor/ProseMirror/types.ts +++ b/packages/editor/ProseMirror/types.ts @@ -1,6 +1,11 @@ import { RenderResult } from '../../renderer/types'; -export type MarkupToHtml = (markup: string)=> Promise; +interface MarkupToHtmlOptions { + isFullPageRender: boolean; + forceMarkdown: boolean; +} + +export type MarkupToHtml = (markup: string, options: MarkupToHtmlOptions)=> Promise; export type HtmlToMarkup = (html: Node|DocumentFragment)=> string; export interface RendererControl { diff --git a/packages/editor/ProseMirror/utils/dom/createTextArea.ts b/packages/editor/ProseMirror/utils/dom/createTextArea.ts new file mode 100644 index 0000000000..bf244a96f8 --- /dev/null +++ b/packages/editor/ProseMirror/utils/dom/createTextArea.ts @@ -0,0 +1,31 @@ +import { LocalizationResult } from '../../../types'; +import createTextNode from './createTextNode'; +import createUniqueId from './createUniqueId'; + +interface Options { + label: LocalizationResult; + spellCheck: boolean; + initialContent: string; + onChange: (newContent: string)=> void; +} + +const createTextArea = ({ label, initialContent, spellCheck, onChange }: Options) => { + const textArea = document.createElement('textarea'); + textArea.spellcheck = spellCheck; + textArea.oninput = () => { + onChange(textArea.value); + }; + textArea.value = initialContent; + textArea.id = createUniqueId(); + + const labelElement = document.createElement('label'); + labelElement.htmlFor = textArea.id; + labelElement.appendChild(createTextNode(label)); + + return { + label: labelElement, + textArea, + }; +}; + +export default createTextArea; diff --git a/packages/editor/ProseMirror/utils/dom/createTextNode.ts b/packages/editor/ProseMirror/utils/dom/createTextNode.ts new file mode 100644 index 0000000000..823c8b9052 --- /dev/null +++ b/packages/editor/ProseMirror/utils/dom/createTextNode.ts @@ -0,0 +1,11 @@ +import { LocalizationResult } from '../../../types'; + +const createTextNode = (content: LocalizationResult) => { + const result = document.createTextNode(typeof content === 'string' ? content : '...'); + void (async () => { + result.textContent = await content; + })(); + return result; +}; + +export default createTextNode; diff --git a/packages/editor/ProseMirror/utils/dom/createUniqueId.ts b/packages/editor/ProseMirror/utils/dom/createUniqueId.ts new file mode 100644 index 0000000000..3259dd3671 --- /dev/null +++ b/packages/editor/ProseMirror/utils/dom/createUniqueId.ts @@ -0,0 +1,8 @@ + +let idCounter = 0; + +const createUniqueId = () => { + return `__joplin-id-${idCounter++}`; +}; + +export default createUniqueId; diff --git a/packages/editor/types.ts b/packages/editor/types.ts index 6beee91a30..fca41d205b 100644 --- a/packages/editor/types.ts +++ b/packages/editor/types.ts @@ -190,6 +190,8 @@ export type LogMessageCallback = (message: string)=> void; export type OnEventCallback = (event: EditorEvent)=> void; export type PasteFileCallback = (data: File)=> Promise; type OnScrollPastBeginningCallback = ()=> void; +export type LocalizationResult = Promise|string; +export type OnLocalize = (input: string)=> LocalizationResult; interface Localisations { [editorString: string]: string; @@ -199,6 +201,7 @@ export interface EditorProps { settings: EditorSettings; initialText: string; initialNoteId: string; + onLocalize: OnLocalize; // Used mostly for internal editor library strings localisations?: Localisations; diff --git a/packages/lib/shim.ts b/packages/lib/shim.ts index af17ea63c2..7dfa5e2b60 100644 --- a/packages/lib/shim.ts +++ b/packages/lib/shim.ts @@ -1,9 +1,9 @@ -import * as React from 'react'; -import { NoteEntity, ResourceEntity } from './services/database/types'; +import type * as React from 'react'; +import type { NoteEntity, ResourceEntity } from './services/database/types'; import type FsDriverBase from './fs-driver-base'; import type FileApiDriverLocal from './file-api-driver-local'; -import { Crypto } from './services/e2ee/types'; -import { MarkupLanguage } from '@joplin/renderer'; +import type { Crypto } from './services/e2ee/types'; +import type { MarkupLanguage } from '@joplin/renderer'; export interface CreateResourceFromPathOptions { resizeLargeImages?: 'always' | 'never' | 'ask';