From ea1d2e48782b6ddf3e18d4c405031040d0bd6b65 Mon Sep 17 00:00:00 2001 From: Henry Heino <46334387+personalizedrefrigerator@users.noreply.github.com> Date: Sun, 10 Aug 2025 01:17:12 -0700 Subject: [PATCH] Desktop, Mobile: Move several features from Extra Markdown Editor Settings into the main app (#12747) --- .eslintignore | 22 +++ .gitignore | 22 +++ .../NoteBody/CodeMirror/v6/CodeMirror.tsx | 4 + .../NoteBody/CodeMirror/v6/Editor.tsx | 14 +- .../app-desktop/gui/NoteEditor/NoteEditor.tsx | 3 +- .../NoteEditor/utils/getResourceBaseUrl.ts | 4 + .../components/NoteEditor/NoteEditor.tsx | 2 + .../markdownEditorBundle/contentScript.ts | 3 + .../markdownEditorBundle/types.ts | 1 + .../markdownEditorBundle/useWebViewSetup.ts | 20 +++ .../editor/CodeMirror/configFromSettings.ts | 10 +- .../editor/CodeMirror/createEditor.test.ts | 4 + packages/editor/CodeMirror/createEditor.ts | 25 ++- .../links/ctrlClickLinksExtension.ts | 46 ++++++ .../links/followLinkTooltipExtension.test.ts | 26 +++ .../links/followLinkTooltipExtension.ts | 86 ++++++++++ .../links/referenceLinksStateField.ts | 94 +++++++++++ .../links/utils/findLineMatchingLink.test.ts | 36 +++++ .../links/utils/findLineMatchingLink.ts | 36 +++++ .../links/utils/getUrlAtPosition.ts | 53 ++++++ .../extensions/links/utils/openLink.ts | 22 +++ .../extensions/modifierKeyCssExtension.ts | 45 ++++++ .../rendering/addFormattingClasses.ts | 31 ++++ .../rendering/renderBlockImages.test.ts | 42 +++++ .../extensions/rendering/renderBlockImages.ts | 134 +++++++++++++++ .../rendering/renderingExtension.ts | 22 +++ .../rendering/replaceBulletLists.ts | 97 +++++++++++ .../extensions/rendering/replaceCheckboxes.ts | 153 ++++++++++++++++++ .../extensions/rendering/replaceDividers.ts | 70 ++++++++ .../rendering/replaceFormatCharacters.ts | 81 ++++++++++ .../CodeMirror/extensions/rendering/types.ts | 20 +++ .../utils/makeBlockReplaceExtension.ts | 89 ++++++++++ .../utils/makeInlineReplaceExtension.ts | 91 +++++++++++ .../utils/nodeIntersectsSelection.ts | 17 ++ .../CodeMirror/testing/createEditorControl.ts | 1 + .../editor/testing/createEditorSettings.ts | 2 + packages/editor/types.ts | 2 + .../lib/models/settings/builtInMetadata.ts | 21 +++ packages/tools/cspell/dictionary4.txt | 1 + 39 files changed, 1446 insertions(+), 6 deletions(-) create mode 100644 packages/app-desktop/gui/NoteEditor/utils/getResourceBaseUrl.ts create mode 100644 packages/editor/CodeMirror/extensions/links/ctrlClickLinksExtension.ts create mode 100644 packages/editor/CodeMirror/extensions/links/followLinkTooltipExtension.test.ts create mode 100644 packages/editor/CodeMirror/extensions/links/followLinkTooltipExtension.ts create mode 100644 packages/editor/CodeMirror/extensions/links/referenceLinksStateField.ts create mode 100644 packages/editor/CodeMirror/extensions/links/utils/findLineMatchingLink.test.ts create mode 100644 packages/editor/CodeMirror/extensions/links/utils/findLineMatchingLink.ts create mode 100644 packages/editor/CodeMirror/extensions/links/utils/getUrlAtPosition.ts create mode 100644 packages/editor/CodeMirror/extensions/links/utils/openLink.ts create mode 100644 packages/editor/CodeMirror/extensions/modifierKeyCssExtension.ts create mode 100644 packages/editor/CodeMirror/extensions/rendering/addFormattingClasses.ts create mode 100644 packages/editor/CodeMirror/extensions/rendering/renderBlockImages.test.ts create mode 100644 packages/editor/CodeMirror/extensions/rendering/renderBlockImages.ts create mode 100644 packages/editor/CodeMirror/extensions/rendering/renderingExtension.ts create mode 100644 packages/editor/CodeMirror/extensions/rendering/replaceBulletLists.ts create mode 100644 packages/editor/CodeMirror/extensions/rendering/replaceCheckboxes.ts create mode 100644 packages/editor/CodeMirror/extensions/rendering/replaceDividers.ts create mode 100644 packages/editor/CodeMirror/extensions/rendering/replaceFormatCharacters.ts create mode 100644 packages/editor/CodeMirror/extensions/rendering/types.ts create mode 100644 packages/editor/CodeMirror/extensions/rendering/utils/makeBlockReplaceExtension.ts create mode 100644 packages/editor/CodeMirror/extensions/rendering/utils/makeInlineReplaceExtension.ts create mode 100644 packages/editor/CodeMirror/extensions/rendering/utils/nodeIntersectsSelection.ts diff --git a/.eslintignore b/.eslintignore index 1c4fc04901..8d8793531b 100644 --- a/.eslintignore +++ b/.eslintignore @@ -303,6 +303,7 @@ packages/app-desktop/gui/NoteEditor/utils/clipboardUtils.test.js packages/app-desktop/gui/NoteEditor/utils/clipboardUtils.js packages/app-desktop/gui/NoteEditor/utils/contextMenu.js packages/app-desktop/gui/NoteEditor/utils/contextMenuUtils.js +packages/app-desktop/gui/NoteEditor/utils/getResourceBaseUrl.js packages/app-desktop/gui/NoteEditor/utils/getWindowCommandPriority.js packages/app-desktop/gui/NoteEditor/utils/index.js packages/app-desktop/gui/NoteEditor/utils/markupRenderOptions.js @@ -1004,14 +1005,35 @@ packages/editor/CodeMirror/editorCommands/sortSelectedLines.js packages/editor/CodeMirror/editorCommands/supportsCommand.js packages/editor/CodeMirror/extensions/biDirectionalTextExtension.js packages/editor/CodeMirror/extensions/keyUpHandlerExtension.js +packages/editor/CodeMirror/extensions/links/ctrlClickLinksExtension.js +packages/editor/CodeMirror/extensions/links/followLinkTooltipExtension.test.js +packages/editor/CodeMirror/extensions/links/followLinkTooltipExtension.js +packages/editor/CodeMirror/extensions/links/referenceLinksStateField.js +packages/editor/CodeMirror/extensions/links/utils/findLineMatchingLink.test.js +packages/editor/CodeMirror/extensions/links/utils/findLineMatchingLink.js +packages/editor/CodeMirror/extensions/links/utils/getUrlAtPosition.js +packages/editor/CodeMirror/extensions/links/utils/openLink.js packages/editor/CodeMirror/extensions/markdownDecorationExtension.test.js packages/editor/CodeMirror/extensions/markdownDecorationExtension.js packages/editor/CodeMirror/extensions/markdownHighlightExtension.test.js packages/editor/CodeMirror/extensions/markdownHighlightExtension.js packages/editor/CodeMirror/extensions/markdownMathExtension.test.js packages/editor/CodeMirror/extensions/markdownMathExtension.js +packages/editor/CodeMirror/extensions/modifierKeyCssExtension.js packages/editor/CodeMirror/extensions/overwriteModeExtension.test.js packages/editor/CodeMirror/extensions/overwriteModeExtension.js +packages/editor/CodeMirror/extensions/rendering/addFormattingClasses.js +packages/editor/CodeMirror/extensions/rendering/renderBlockImages.test.js +packages/editor/CodeMirror/extensions/rendering/renderBlockImages.js +packages/editor/CodeMirror/extensions/rendering/renderingExtension.js +packages/editor/CodeMirror/extensions/rendering/replaceBulletLists.js +packages/editor/CodeMirror/extensions/rendering/replaceCheckboxes.js +packages/editor/CodeMirror/extensions/rendering/replaceDividers.js +packages/editor/CodeMirror/extensions/rendering/replaceFormatCharacters.js +packages/editor/CodeMirror/extensions/rendering/types.js +packages/editor/CodeMirror/extensions/rendering/utils/makeBlockReplaceExtension.js +packages/editor/CodeMirror/extensions/rendering/utils/makeInlineReplaceExtension.js +packages/editor/CodeMirror/extensions/rendering/utils/nodeIntersectsSelection.js packages/editor/CodeMirror/extensions/searchExtension.js packages/editor/CodeMirror/extensions/selectedNoteIdExtension.js packages/editor/CodeMirror/getScrollFraction.js diff --git a/.gitignore b/.gitignore index cc91d263e8..577cc54615 100644 --- a/.gitignore +++ b/.gitignore @@ -276,6 +276,7 @@ packages/app-desktop/gui/NoteEditor/utils/clipboardUtils.test.js packages/app-desktop/gui/NoteEditor/utils/clipboardUtils.js packages/app-desktop/gui/NoteEditor/utils/contextMenu.js packages/app-desktop/gui/NoteEditor/utils/contextMenuUtils.js +packages/app-desktop/gui/NoteEditor/utils/getResourceBaseUrl.js packages/app-desktop/gui/NoteEditor/utils/getWindowCommandPriority.js packages/app-desktop/gui/NoteEditor/utils/index.js packages/app-desktop/gui/NoteEditor/utils/markupRenderOptions.js @@ -977,14 +978,35 @@ packages/editor/CodeMirror/editorCommands/sortSelectedLines.js packages/editor/CodeMirror/editorCommands/supportsCommand.js packages/editor/CodeMirror/extensions/biDirectionalTextExtension.js packages/editor/CodeMirror/extensions/keyUpHandlerExtension.js +packages/editor/CodeMirror/extensions/links/ctrlClickLinksExtension.js +packages/editor/CodeMirror/extensions/links/followLinkTooltipExtension.test.js +packages/editor/CodeMirror/extensions/links/followLinkTooltipExtension.js +packages/editor/CodeMirror/extensions/links/referenceLinksStateField.js +packages/editor/CodeMirror/extensions/links/utils/findLineMatchingLink.test.js +packages/editor/CodeMirror/extensions/links/utils/findLineMatchingLink.js +packages/editor/CodeMirror/extensions/links/utils/getUrlAtPosition.js +packages/editor/CodeMirror/extensions/links/utils/openLink.js packages/editor/CodeMirror/extensions/markdownDecorationExtension.test.js packages/editor/CodeMirror/extensions/markdownDecorationExtension.js packages/editor/CodeMirror/extensions/markdownHighlightExtension.test.js packages/editor/CodeMirror/extensions/markdownHighlightExtension.js packages/editor/CodeMirror/extensions/markdownMathExtension.test.js packages/editor/CodeMirror/extensions/markdownMathExtension.js +packages/editor/CodeMirror/extensions/modifierKeyCssExtension.js packages/editor/CodeMirror/extensions/overwriteModeExtension.test.js packages/editor/CodeMirror/extensions/overwriteModeExtension.js +packages/editor/CodeMirror/extensions/rendering/addFormattingClasses.js +packages/editor/CodeMirror/extensions/rendering/renderBlockImages.test.js +packages/editor/CodeMirror/extensions/rendering/renderBlockImages.js +packages/editor/CodeMirror/extensions/rendering/renderingExtension.js +packages/editor/CodeMirror/extensions/rendering/replaceBulletLists.js +packages/editor/CodeMirror/extensions/rendering/replaceCheckboxes.js +packages/editor/CodeMirror/extensions/rendering/replaceDividers.js +packages/editor/CodeMirror/extensions/rendering/replaceFormatCharacters.js +packages/editor/CodeMirror/extensions/rendering/types.js +packages/editor/CodeMirror/extensions/rendering/utils/makeBlockReplaceExtension.js +packages/editor/CodeMirror/extensions/rendering/utils/makeInlineReplaceExtension.js +packages/editor/CodeMirror/extensions/rendering/utils/nodeIntersectsSelection.js packages/editor/CodeMirror/extensions/searchExtension.js packages/editor/CodeMirror/extensions/selectedNoteIdExtension.js packages/editor/CodeMirror/getScrollFraction.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 78c2a5d65b..1980c6fdc0 100644 --- a/packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/v6/CodeMirror.tsx +++ b/packages/app-desktop/gui/NoteEditor/NoteBody/CodeMirror/v6/CodeMirror.tsx @@ -340,6 +340,8 @@ const CodeMirror = (props: NoteBodyEditorProps, ref: ForwardedRef) => { onLogMessage: message => onLogMessageRef.current(message), }; - const editor = createEditor(editorContainerRef.current, editorProps); + const editor = createEditor(editorContainerRef.current, { + ...editorProps, + resolveImageSrc: async src => { + const url = parseResourceUrl(src); + if (!url.itemId) return null; + const item = await Resource.load(url.itemId); + return `${getResourceBaseUrl()}/${resourceFilename(item)}`; + }, + }); editor.addStyles({ '.cm-scroller': { overflow: 'auto' }, '&.CodeMirror': { diff --git a/packages/app-desktop/gui/NoteEditor/NoteEditor.tsx b/packages/app-desktop/gui/NoteEditor/NoteEditor.tsx index 7ea04a7b99..0c764b45d4 100644 --- a/packages/app-desktop/gui/NoteEditor/NoteEditor.tsx +++ b/packages/app-desktop/gui/NoteEditor/NoteEditor.tsx @@ -56,6 +56,7 @@ import useResourceUnwatcher from './utils/useResourceUnwatcher'; import StatusBar from './StatusBar'; import useVisiblePluginEditorViewIds from '@joplin/lib/hooks/plugins/useVisiblePluginEditorViewIds'; import useConnectToEditorPlugin from './utils/useConnectToEditorPlugin'; +import getResourceBaseUrl from './utils/getResourceBaseUrl'; const debounce = require('debounce'); @@ -169,7 +170,7 @@ function NoteEditorContent(props: NoteEditorProps) { const theme = themeStyle(options.themeId ? options.themeId : props.themeId); const markupToHtml = markupLanguageUtils.newMarkupToHtml(props.plugins, { - resourceBaseUrl: `joplin-content://note-viewer/${Setting.value('resourceDir')}/`, + resourceBaseUrl: getResourceBaseUrl(), customCss: props.customCss, }); diff --git a/packages/app-desktop/gui/NoteEditor/utils/getResourceBaseUrl.ts b/packages/app-desktop/gui/NoteEditor/utils/getResourceBaseUrl.ts new file mode 100644 index 0000000000..faa6ad8c8f --- /dev/null +++ b/packages/app-desktop/gui/NoteEditor/utils/getResourceBaseUrl.ts @@ -0,0 +1,4 @@ +import Setting from '@joplin/lib/models/Setting'; + +const getResourceBaseUrl = () => `joplin-content://note-viewer/${Setting.value('resourceDir')}/`; +export default getResourceBaseUrl; diff --git a/packages/app-mobile/components/NoteEditor/NoteEditor.tsx b/packages/app-mobile/components/NoteEditor/NoteEditor.tsx index e96f605f28..cfcf4779dc 100644 --- a/packages/app-mobile/components/NoteEditor/NoteEditor.tsx +++ b/packages/app-mobile/components/NoteEditor/NoteEditor.tsx @@ -246,6 +246,8 @@ function NoteEditor(props: Props) { markdownMarkEnabled: Setting.value('markdown.plugin.mark'), katexEnabled: Setting.value('markdown.plugin.katex'), spellcheckEnabled: Setting.value('editor.mobile.spellcheckEnabled'), + inlineRenderingEnabled: Setting.value('editor.inlineRendering'), + imageRenderingEnabled: Setting.value('editor.imageRendering'), language: props.markupLanguage === MarkupLanguage.Html ? EditorLanguageType.Html : EditorLanguageType.Markdown, useExternalSearch: true, readOnly: props.readOnly, diff --git a/packages/app-mobile/contentScripts/markdownEditorBundle/contentScript.ts b/packages/app-mobile/contentScripts/markdownEditorBundle/contentScript.ts index 13631f99b6..194d9a5009 100644 --- a/packages/app-mobile/contentScripts/markdownEditorBundle/contentScript.ts +++ b/packages/app-mobile/contentScripts/markdownEditorBundle/contentScript.ts @@ -37,6 +37,9 @@ export const initializeEditor = ({ onEvent: (event): void => { void messenger.remoteApi.onEditorEvent(event); }, + resolveImageSrc: (src) => { + return messenger.remoteApi.onResolveImageSrc(src); + }, }); // Works around https://github.com/laurent22/joplin/issues/10047 by handling diff --git a/packages/app-mobile/contentScripts/markdownEditorBundle/types.ts b/packages/app-mobile/contentScripts/markdownEditorBundle/types.ts index 9abc5546d0..fe715167f8 100644 --- a/packages/app-mobile/contentScripts/markdownEditorBundle/types.ts +++ b/packages/app-mobile/contentScripts/markdownEditorBundle/types.ts @@ -22,4 +22,5 @@ export interface MainProcessApi { onEditorEvent(event: EditorEvent): Promise; logMessage(message: string): Promise; onPasteFile(type: string, dataBase64: string): Promise; + onResolveImageSrc(src: string): Promise; } diff --git a/packages/app-mobile/contentScripts/markdownEditorBundle/useWebViewSetup.ts b/packages/app-mobile/contentScripts/markdownEditorBundle/useWebViewSetup.ts index 0d53e2701e..d52b68dd32 100644 --- a/packages/app-mobile/contentScripts/markdownEditorBundle/useWebViewSetup.ts +++ b/packages/app-mobile/contentScripts/markdownEditorBundle/useWebViewSetup.ts @@ -7,6 +7,9 @@ import { OnMessageEvent, WebViewControl } from '../../components/ExtendedWebView import { EditorEvent } from '@joplin/editor/events'; import Logger from '@joplin/utils/Logger'; import RNToWebViewMessenger from '../../utils/ipc/RNToWebViewMessenger'; +import Resource from '@joplin/lib/models/Resource'; +import { parseResourceUrl } from '@joplin/lib/urlUtils'; +const { isImageMimeType } = require('@joplin/lib/resourceUtils'); const logger = Logger.create('markdownEditor'); @@ -109,6 +112,23 @@ const useWebViewSetup = ({ async onPasteFile(type, data) { onAttachRef.current(type, data); }, + async onResolveImageSrc(src) { + const url = parseResourceUrl(src); + if (!url.itemId) return null; + const item = await Resource.load(url.itemId); + + if (shim.mobilePlatform() === 'web') { + // Maximum 6 MiB on web + const maximumSize = 6 * 1024 * 1024; + if (isImageMimeType(item.mime) && item.size < maximumSize) { + const data = await shim.fsDriver().readFile(Resource.fullPath(item), 'base64'); + return `data:${item.mime};base64,${data}`; + } + return null; + } else { + return Resource.fullPath(item); + } + }, }; const messenger = new RNToWebViewMessenger( 'markdownEditor', webviewRef, localApi, diff --git a/packages/editor/CodeMirror/configFromSettings.ts b/packages/editor/CodeMirror/configFromSettings.ts index 4b8013be2a..77944eb3f3 100644 --- a/packages/editor/CodeMirror/configFromSettings.ts +++ b/packages/editor/CodeMirror/configFromSettings.ts @@ -14,8 +14,10 @@ import { vim } from '@replit/codemirror-vim'; import { indentUnit } from '@codemirror/language'; import { Prec } from '@codemirror/state'; import insertNewlineContinueMarkup from './editorCommands/insertNewlineContinueMarkup'; +import renderingExtension from './extensions/rendering/renderingExtension'; +import { RenderedContentContext } from './extensions/rendering/types'; -const configFromSettings = (settings: EditorSettings) => { +const configFromSettings = (settings: EditorSettings, context: RenderedContentContext) => { const languageExtension = (() => { const openingBrackets = '`([{\'"‘“(《「『【〔〖〘〚'.split(''); @@ -84,6 +86,12 @@ const configFromSettings = (settings: EditorSettings) => { extensions.push(Prec.low(keymap.of(defaultKeymap))); } + if (settings.inlineRenderingEnabled) { + extensions.push(renderingExtension(context, { + renderImages: settings.imageRenderingEnabled, + })); + } + return extensions; }; diff --git a/packages/editor/CodeMirror/createEditor.test.ts b/packages/editor/CodeMirror/createEditor.test.ts index c513257a21..08566928a8 100644 --- a/packages/editor/CodeMirror/createEditor.test.ts +++ b/packages/editor/CodeMirror/createEditor.test.ts @@ -42,6 +42,7 @@ describe('createEditor', () => { onLogMessage: _message => {}, onLocalize: input => input, onPasteFile: null, + resolveImageSrc: src => Promise.resolve(src), }); // Force the generation of the syntax tree now. @@ -72,6 +73,7 @@ describe('createEditor', () => { onLogMessage: _message => {}, onLocalize: input => input, onPasteFile: null, + resolveImageSrc: src=>Promise.resolve(src), }); const getContentScriptJs = jest.fn(async () => { @@ -142,6 +144,7 @@ describe('createEditor', () => { onLogMessage: _message => {}, onLocalize: input => input, onPasteFile: null, + resolveImageSrc: src=>Promise.resolve(src), }); const getContentScriptJs = jest.fn(async () => { @@ -193,6 +196,7 @@ describe('createEditor', () => { onLogMessage: () => {}, onLocalize: input => input, onPasteFile: null, + resolveImageSrc: src=>Promise.resolve(src), }); const editorState = editor.editor.state; const idFacet = editor.joplinExtensions.noteIdFacet; diff --git a/packages/editor/CodeMirror/createEditor.ts b/packages/editor/CodeMirror/createEditor.ts index a2222b4a7d..285bf841be 100644 --- a/packages/editor/CodeMirror/createEditor.ts +++ b/packages/editor/CodeMirror/createEditor.ts @@ -36,6 +36,9 @@ import isCursorAtBeginning from './utils/isCursorAtBeginning'; import overwriteModeExtension from './extensions/overwriteModeExtension'; import handleLinkEditRequests, { showLinkEditor } from './utils/handleLinkEditRequests'; import selectedNoteIdExtension, { setNoteIdEffect } from './extensions/selectedNoteIdExtension'; +import ctrlKeyStateClassExtension from './extensions/modifierKeyCssExtension'; +import ctrlClickLinksExtension from './extensions/links/ctrlClickLinksExtension'; +import { RenderedContentContext } from './extensions/rendering/types'; // Newer versions of CodeMirror by default use Chrome's EditContext API. // While this might be stable enough for desktop use, it causes significant @@ -47,14 +50,26 @@ import selectedNoteIdExtension, { setNoteIdEffect } from './extensions/selectedN type ExtendedEditorView = typeof EditorView & { EDIT_CONTEXT: boolean }; (EditorView as ExtendedEditorView).EDIT_CONTEXT = false; +export type ResolveImageCallback = (imageSrc: string)=> Promise; + +interface CodeMirrorProps { + resolveImageSrc: ResolveImageCallback; +} + const createEditor = ( - parentElement: HTMLElement, props: EditorProps, + parentElement: HTMLElement, props: EditorProps&CodeMirrorProps, ): CodeMirrorControl => { const initialText = props.initialText; let settings = props.settings; props.onLogMessage('Initializing CodeMirror...'); + const context: RenderedContentContext = { + resolveImageSrc: (src) => { + return props.resolveImageSrc(src); + }, + }; + // Handles firing an event when the undo/redo stack changes let schedulePostUndoRedoDepthChangeId_: ReturnType|null = null; @@ -228,7 +243,7 @@ const createEditor = ( extensions: [ keymapConfig, - dynamicConfig.of(configFromSettings(props.settings)), + dynamicConfig.of(configFromSettings(props.settings, context)), historyCompartment.of(history()), searchExtension(props.onEvent, props.settings), @@ -237,6 +252,9 @@ const createEditor = ( EditorState.allowMultipleSelections.of(true), rectangularSelection(), drawSelection(), + ctrlClickLinksExtension(link => { + props.onEvent({ kind: EditorEventType.FollowLink, link }); + }), highlightSpecialChars(), indentOnInput(), @@ -274,6 +292,7 @@ const createEditor = ( biDirectionalTextExtension, overwriteModeExtension, + ctrlKeyStateClassExtension, selectedNoteIdExtension, @@ -320,7 +339,7 @@ const createEditor = ( settings = newSettings; editor.dispatch({ effects: dynamicConfig.reconfigure( - configFromSettings(newSettings), + configFromSettings(newSettings, context), ), }); }, diff --git a/packages/editor/CodeMirror/extensions/links/ctrlClickLinksExtension.ts b/packages/editor/CodeMirror/extensions/links/ctrlClickLinksExtension.ts new file mode 100644 index 0000000000..f622a6af47 --- /dev/null +++ b/packages/editor/CodeMirror/extensions/links/ctrlClickLinksExtension.ts @@ -0,0 +1,46 @@ +import { EditorView } from '@codemirror/view'; +import referenceLinkStateField from './referenceLinksStateField'; +import modifierKeyCssExtension from '../modifierKeyCssExtension'; +import openLink from './utils/openLink'; +import getUrlAtPosition from './utils/getUrlAtPosition'; +import { syntaxTree } from '@codemirror/language'; +import { Prec } from '@codemirror/state'; + + +type OnOpenLink = (url: string, view: EditorView)=> void; + + +const ctrlClickLinksExtension = (onOpenExternalLink: OnOpenLink) => { + return [ + modifierKeyCssExtension, + referenceLinkStateField, + EditorView.theme({ + '&.-ctrl-or-cmd-pressed .cm-url, &.-ctrl-or-cmd-pressed .tok-link': { + cursor: 'pointer', + }, + }), + Prec.high([ + EditorView.domEventHandlers({ + mousedown: (event: MouseEvent, view: EditorView) => { + if (event.ctrlKey || event.metaKey) { + const target = view.posAtCoords(event); + const url = getUrlAtPosition(target, syntaxTree(view.state), view.state); + const hasMultipleCursors = view.state.selection.ranges.length > 1; + + // The default CodeMirror action for ctrl-click is to add another cursor + // to the document. If the user already has multiple cursors, assume that + // the ctrl-click action is intended to add another. + if (url && !hasMultipleCursors) { + openLink(url.url, view, onOpenExternalLink); + event.preventDefault(); + return true; + } + } + return false; + }, + }), + ]), + ]; +}; + +export default ctrlClickLinksExtension; diff --git a/packages/editor/CodeMirror/extensions/links/followLinkTooltipExtension.test.ts b/packages/editor/CodeMirror/extensions/links/followLinkTooltipExtension.test.ts new file mode 100644 index 0000000000..b2d29f5332 --- /dev/null +++ b/packages/editor/CodeMirror/extensions/links/followLinkTooltipExtension.test.ts @@ -0,0 +1,26 @@ +import { forceParsing } from '@codemirror/language'; +import createTestEditor from '../../testing/createTestEditor'; +import followLinkTooltip from './followLinkTooltipExtension'; +import { EditorSelection } from '@codemirror/state'; + +describe('followLinkTooltip', () => { + it('should show a clickable tooltip for a URL link', async () => { + const doc = '[link](http://example.com/)'; + const onOpenLink = jest.fn(); + + const editor = await createTestEditor(doc, EditorSelection.cursor(0), [], [followLinkTooltip(url => onOpenLink(url))]); + forceParsing(editor, editor.state.doc.length); + + editor.dispatch({ + userEvent: 'select', + selection: { anchor: 4 }, + }); + const tooltip = editor.dom.querySelector('.cm-md-link-tooltip'); + if (!tooltip) throw new Error('No tooltip found.'); + + const link = tooltip.querySelector('button'); + link!.click(); + + expect(onOpenLink).toHaveBeenCalledWith('http://example.com/'); + }); +}); diff --git a/packages/editor/CodeMirror/extensions/links/followLinkTooltipExtension.ts b/packages/editor/CodeMirror/extensions/links/followLinkTooltipExtension.ts new file mode 100644 index 0000000000..637f8ecd2f --- /dev/null +++ b/packages/editor/CodeMirror/extensions/links/followLinkTooltipExtension.ts @@ -0,0 +1,86 @@ +import { syntaxTree } from '@codemirror/language'; +import { EditorState, StateField } from '@codemirror/state'; +import { EditorView, showTooltip, Tooltip } from '@codemirror/view'; +import referenceLinkStateField from './referenceLinksStateField'; +import getUrlAtPosition from './utils/getUrlAtPosition'; +import openLink from './utils/openLink'; +import ctrlClickLinksExtension from './ctrlClickLinksExtension'; + + +type OnOpenLink = (url: string, view: EditorView)=> void; + +// Returns tooltips for the links under the cursor(s). +const getLinkTooltips = (onOpenLink: OnOpenLink, state: EditorState) => { + const tree = syntaxTree(state); + return state.selection.ranges.map((range): Tooltip|null => { + if (!range.empty) return null; + const url = getUrlAtPosition(range.anchor, tree, state); + if (!url) return null; + + return { + pos: range.head, + arrow: true, + create: (view) => { + const dom = document.createElement('div'); + dom.classList.add('cm-md-link-tooltip'); + + const link = document.createElement('button'); + link.role = 'link'; + link.textContent = `🔗 ${url.url}${url.label ? `: ${url.label}` : ''}`; + link.title = state.phrase('Follow link: $1', url.url); + link.onclick = () => { + onOpenLink(url.url, view); + }; + + dom.appendChild(link); + + return { dom }; + }, + }; + }).filter(tooltip => !!tooltip) as Tooltip[]; +}; + +const followLinkTooltip = (onOpenExternalLink: OnOpenLink) => { + const onOpenLink = (link: string, view: EditorView) => { + openLink(link, view, onOpenExternalLink); + }; + + const followLinkTooltipField = StateField.define({ + create: state => getLinkTooltips(onOpenLink, state), + update: (tooltips, transaction) => { + if (!transaction.docChanged && !transaction.selection) { + return tooltips; + } + + return getLinkTooltips(onOpenLink, transaction.state); + }, + provide: field => { + const tooltipsFromState = (state: EditorState) => state.field(field); + return showTooltip.computeN([field], tooltipsFromState); + }, + }); + + return [ + referenceLinkStateField, + EditorView.theme({ + '& .cm-md-link-tooltip > button': { + backgroundColor: 'transparent', + border: 'transparent', + fontSize: 'inherit', + + whiteSpace: 'pre', + maxWidth: '95vw', + textOverflow: 'ellipsis', + overflowX: 'hidden', + + textDecoration: 'underline', + cursor: 'pointer', + color: 'var(--joplin-url-color)', + }, + }), + followLinkTooltipField, + ctrlClickLinksExtension(onOpenExternalLink), + ]; +}; + +export default followLinkTooltip; diff --git a/packages/editor/CodeMirror/extensions/links/referenceLinksStateField.ts b/packages/editor/CodeMirror/extensions/links/referenceLinksStateField.ts new file mode 100644 index 0000000000..5a28dcc2b6 --- /dev/null +++ b/packages/editor/CodeMirror/extensions/links/referenceLinksStateField.ts @@ -0,0 +1,94 @@ +import { EditorState, RangeSet, Range, RangeValue, StateField, Text } from '@codemirror/state'; + +class ReferenceLinkValue extends RangeValue { + public constructor(public readonly key: string, public readonly value: string) { + super(); + } +} + +export const resolveReferenceById = (referenceId: string, state: EditorState) => { + const cursor = state.field(referenceLinkStateField).iter(); + for (; cursor.value; cursor.next()) { + if (cursor.value.key === referenceId) { + return cursor.value.value; + } + } + + return null; +}; + +const referenceLinkExp = /^(\[[^\]]+\])\s*(\[[^\]]+\])?$/; + +export const isReferenceLink = (link: string) => { + return !!link.trim().match(referenceLinkExp); +}; + +export const resolveReferenceFromLink = (link: string, state: EditorState) => { + const referenceMatch = link.trim().match(referenceLinkExp); + if (!referenceMatch) return null; + + const resolved = resolveReferenceById(referenceMatch[2] ?? referenceMatch[1], state); + return resolved?.trim() ?? null; +}; + + +// Returns the key and value for a link reference definition in the form +// [a test]: http://some/def/here/ +const parseReferenceDef = (lineText: string) => { + const linkStart = lineText.match(/^(\[[^[\]]+\]):/); + if (!linkStart) return null; + + const key = linkStart[1]; + return { + key, + value: lineText.substring(linkStart[0].length), + }; +}; + +const addReferencesToSet = (set: RangeSet, fromIdx: number, toIdx: number, doc: Text) => { + const newRanges: Range[] = []; + + const fromLine = doc.lineAt(fromIdx); + const toLine = doc.lineAt(toIdx); + + for (let i = fromLine.number; i <= toLine.number; i++) { + const line = doc.line(i); + const parsedRef = parseReferenceDef(line.text); + if (parsedRef) { + newRanges.push( + new ReferenceLinkValue(parsedRef.key, parsedRef.value).range(line.from), + ); + } + } + + return set.update({ add: newRanges }); +}; + +const referenceLinkStateField = StateField.define>({ + create(state): RangeSet { + return addReferencesToSet(RangeSet.empty, 0, state.doc.length, state.doc); + }, + update(value, transaction) { + if (!transaction.docChanged) return value.map(transaction.changes); + + // Remove deleted/modified definitions + transaction.changes.iterChangedRanges((fromA, toA) => { + value = value.update({ + filterFrom: fromA, + filterTo: toA, + filter: () => false, + }); + }); + + // Switch line numbers to match the new document + value = value.map(transaction.changes); + + transaction.changes.iterChangedRanges((_fromA, _fromB, fromB, toB) => { + value = addReferencesToSet(value, fromB, toB, transaction.newDoc); + }); + + return value; + }, +}); + +export default referenceLinkStateField; diff --git a/packages/editor/CodeMirror/extensions/links/utils/findLineMatchingLink.test.ts b/packages/editor/CodeMirror/extensions/links/utils/findLineMatchingLink.test.ts new file mode 100644 index 0000000000..106febcd3f --- /dev/null +++ b/packages/editor/CodeMirror/extensions/links/utils/findLineMatchingLink.test.ts @@ -0,0 +1,36 @@ +import { EditorSelection } from '@codemirror/state'; +import createTestEditor from '../../../testing/createTestEditor'; +import findLineMatchingLink from './findLineMatchingLink'; + +describe('findLineMatchingLink', () => { + test.each([ + // Should match headings + ['# Heading\n', '#heading', 1], + ['# Heading', '#heading', 1], + ['## Heading', '#heading', 1], + ['### Heading', '#heading', 1], + // Should match headings not on the first line + ['\n### Heading', '#heading', 2], + ['# Test\n\n### Heading', '#heading', 3], + ['# Test\n\n### Heading\n\ntest', '#heading', 3], + // Should return null when there are no matches + ['# Heading', '#missing-heading', null], + + // Should match footnotes + ['[^1]: Footnote!\n', '[^1]', 1], + ['[^1]: Footnote!\n[^2]: Other footnote.', '[^1]', 1], + ['# ^1\n[^1]: Footnote!\n[^2]: Other footnote.', '[^1]', 2], + ['# ^1\n[^1]: Footnote!\n[^2]: Other footnote.', '[^not a footnote]', null], + + // Should not process http:// links + ['# Test', 'http://example.com', null], + + ])('should correctly find lines matching the given link (doc: %j, link: %j) (case %#)', async ( + doc, link, expectedMatchingLine, + ) => { + const editor = await createTestEditor(doc, EditorSelection.cursor(0), []); + expect( + findLineMatchingLink(link, editor.state)?.number ?? null, + ).toBe(expectedMatchingLine); + }); +}); diff --git a/packages/editor/CodeMirror/extensions/links/utils/findLineMatchingLink.ts b/packages/editor/CodeMirror/extensions/links/utils/findLineMatchingLink.ts new file mode 100644 index 0000000000..1aa9cce842 --- /dev/null +++ b/packages/editor/CodeMirror/extensions/links/utils/findLineMatchingLink.ts @@ -0,0 +1,36 @@ +import { EditorState, Line } from '@codemirror/state'; +import uslug from '@joplin/fork-uslug/lib/uslug'; + +// Searches the given `state` for a line that matches the target link. +const findLineMatchingLink = (link: string, state: EditorState): Line|null => { + const isAnchorLink = link.startsWith('#'); + const isFootnote = link.startsWith('[^') && link.endsWith(']'); + + if (!isAnchorLink && !isFootnote) return null; + + const matchesLine = (line: string) => { + if (isAnchorLink) { + line = line.replace(/^#+/, '').trim(); + return uslug(line) === link.substring(1); + } else if (isFootnote) { + return line.trim().startsWith(`${link}:`); + } + return false; + }; + + let iterator = state.doc.iterLines(); + let lineNumber = 0; + while (!iterator.done && lineNumber <= state.doc.lines) { + lineNumber ++; + iterator = iterator.next(); + const line = iterator.value; + + if (matchesLine(line)) { + return state.doc.line(lineNumber); + } + } + + return null; +}; + +export default findLineMatchingLink; diff --git a/packages/editor/CodeMirror/extensions/links/utils/getUrlAtPosition.ts b/packages/editor/CodeMirror/extensions/links/utils/getUrlAtPosition.ts new file mode 100644 index 0000000000..8a4dbafa4e --- /dev/null +++ b/packages/editor/CodeMirror/extensions/links/utils/getUrlAtPosition.ts @@ -0,0 +1,53 @@ +import { EditorState } from '@codemirror/state'; +import { resolveReferenceFromLink } from '../referenceLinksStateField'; +import { SyntaxNodeRef, Tree } from '@lezer/common'; + +enum MatchedUrlType { + Footnote, + Link, +} + +type MatchedUrl = { + type: MatchedUrlType; + url: string; + label?: string; +}; + +const getUrlAtPosition = (pos: number, tree: Tree, state: EditorState): MatchedUrl|null => { + const nodeText = (node: SyntaxNodeRef) => { + return state.doc.sliceString(node.from, node.to); + }; + + let iterator = tree.resolveStack(pos); + + while (true) { + if (iterator.node.name === 'Link') { + const urlNode = iterator.node.getChild('URL'); + if (urlNode) { + return { type: MatchedUrlType.Link, url: nodeText(urlNode) }; + } + const fullLinkText = nodeText(iterator.node); + const referenceLink = resolveReferenceFromLink(fullLinkText, state); + if (referenceLink) { + const isFootnote = fullLinkText.match(/^\[\^\d+\]$/); + if (isFootnote) { + return { type: MatchedUrlType.Footnote, url: fullLinkText, label: referenceLink }; + } else { + return { type: MatchedUrlType.Link, url: referenceLink }; + } + } + } else if (iterator.node.name === 'URL') { + return { type: MatchedUrlType.Link, url: nodeText(iterator.node) }; + } + + if (!iterator.next) { + break; + } else { + iterator = iterator.next; + } + } + + return null; +}; + +export default getUrlAtPosition; diff --git a/packages/editor/CodeMirror/extensions/links/utils/openLink.ts b/packages/editor/CodeMirror/extensions/links/utils/openLink.ts new file mode 100644 index 0000000000..80a90ee8e0 --- /dev/null +++ b/packages/editor/CodeMirror/extensions/links/utils/openLink.ts @@ -0,0 +1,22 @@ +import { EditorView } from '@codemirror/view'; +import findLineMatchingLink from './findLineMatchingLink'; + +export type OnOpenExternalLink = (url: string, view: EditorView)=> void; +const openLink = (link: string, view: EditorView, onOpenExternalLink: OnOpenExternalLink) => { + const targetLine = findLineMatchingLink(link, view.state); + if (targetLine) { + view.dispatch({ + selection: { anchor: targetLine.to }, + scrollIntoView: true, + effects: [ + EditorView.announce.of(`Jumped to line ${targetLine.number}`), + ], + }); + // eslint-disable-next-line no-restricted-properties -- Old code from before rule was applied + view.focus(); + } else { + onOpenExternalLink(link, view); + } +}; + +export default openLink; diff --git a/packages/editor/CodeMirror/extensions/modifierKeyCssExtension.ts b/packages/editor/CodeMirror/extensions/modifierKeyCssExtension.ts new file mode 100644 index 0000000000..e41ac224ac --- /dev/null +++ b/packages/editor/CodeMirror/extensions/modifierKeyCssExtension.ts @@ -0,0 +1,45 @@ +import { StateEffect, StateField, Transaction } from '@codemirror/state'; +import { EditorView } from '@codemirror/view'; + +const ctrlOrMetaChangedEffect = StateEffect.define(); + +const ctrlOrMetaPressedField = StateField.define({ + create: () => false, + update: (value: boolean, transaction: Transaction) => { + const toggleEffect = transaction.effects.find(effect => effect.is(ctrlOrMetaChangedEffect)); + if (toggleEffect) { + return toggleEffect.value; + } + return value; + }, + provide: (field) => [ + EditorView.editorAttributes.from(field, on => ({ + class: on ? '-ctrl-or-cmd-pressed' : '', + })), + ...(() => { + const onEvent = (event: KeyboardEvent|MouseEvent, view: EditorView) => { + const ctrlOrCmdPressed = event.ctrlKey || event.metaKey; + if (ctrlOrCmdPressed !== view.state.field(ctrlOrMetaPressedField)) { + view.dispatch({ + effects: [ + ctrlOrMetaChangedEffect.of(ctrlOrCmdPressed), + ], + }); + } + }; + + return [ + EditorView.domEventObservers({ + keydown: onEvent, + keyup: onEvent, + mouseenter: onEvent, + mousemove: onEvent, + }), + ]; + })(), + ], +}); + +export default [ + ctrlOrMetaPressedField, +]; diff --git a/packages/editor/CodeMirror/extensions/rendering/addFormattingClasses.ts b/packages/editor/CodeMirror/extensions/rendering/addFormattingClasses.ts new file mode 100644 index 0000000000..91b8b78764 --- /dev/null +++ b/packages/editor/CodeMirror/extensions/rendering/addFormattingClasses.ts @@ -0,0 +1,31 @@ +import { Decoration, EditorView } from '@codemirror/view'; +import makeInlineReplaceExtension from './utils/makeInlineReplaceExtension'; + +const linkClassName = 'cm-ext-unfocused-link'; +const urlMarkDecoration = Decoration.mark({ class: linkClassName }); +const strikethroughClassName = 'cm-ext-strikethrough'; +const strikethroughMarkDecoration = Decoration.mark({ class: strikethroughClassName }); + +const addFormattingClasses = [ + EditorView.theme({ + [`& .${linkClassName}, & .${linkClassName} span`]: { + textDecoration: 'underline', + }, + [`& .${strikethroughClassName}, & .${strikethroughClassName} span`]: { + textDecoration: 'line-through', + }, + }), + makeInlineReplaceExtension({ + createDecoration: (node) => { + if (node.name === 'URL' || node.name === 'Link') { + return urlMarkDecoration; + } + if (node.name === 'Strikethrough') { + return strikethroughMarkDecoration; + } + return null; + }, + }), +]; + +export default addFormattingClasses; diff --git a/packages/editor/CodeMirror/extensions/rendering/renderBlockImages.test.ts b/packages/editor/CodeMirror/extensions/rendering/renderBlockImages.test.ts new file mode 100644 index 0000000000..e435203a36 --- /dev/null +++ b/packages/editor/CodeMirror/extensions/rendering/renderBlockImages.test.ts @@ -0,0 +1,42 @@ +import { EditorSelection } from '@codemirror/state'; +import createTestEditor from '../../testing/createTestEditor'; +import renderBlockImages from './renderBlockImages'; +import { EditorView } from '@codemirror/view'; + +const createEditor = (initialMarkdown: string, hasImage: boolean) => { + const resolveImageSrc = jest.fn(src => Promise.resolve(src)); + return createTestEditor( + initialMarkdown, + EditorSelection.cursor(0), + hasImage ? ['Image'] : [], + [renderBlockImages({ resolveImageSrc })], + ); +}; + +const findImage = (editor: EditorView) => { + return editor.dom.querySelector('div.cm-md-image > .image'); +}; + +describe('renderBlockImages', () => { + test.each([ + { spaceBefore: '', spaceAfter: '\n\n', alt: 'test' }, + { spaceBefore: '', spaceAfter: '', alt: 'This is a test!' }, + { spaceBefore: ' ', spaceAfter: ' ', alt: 'test' }, + { spaceBefore: '', spaceAfter: '', alt: '!!!!' }, + ])('should render images below their Markdown source (case %#)', async ({ spaceBefore, spaceAfter, alt }) => { + const editor = await createEditor(`${spaceBefore}![${alt}](:/0123456789abcdef0123456789abcdef)${spaceAfter}`, true); + + const image = findImage(editor); + expect(image).toBeTruthy(); + expect(image.role).toBe('image'); + expect(image.ariaLabel).toBe(alt); + }); + + // For now, only Joplin resources are rendered. This simplifies the implementation and avoids + // potentially-unwanted web requests when opening a note with only the editor open. + test('should not render web images', async () => { + const editor = await createEditor('![test](https://example.com/test.png)\n\n', true); + const image = findImage(editor); + expect(image).toBeNull(); + }); +}); diff --git a/packages/editor/CodeMirror/extensions/rendering/renderBlockImages.ts b/packages/editor/CodeMirror/extensions/rendering/renderBlockImages.ts new file mode 100644 index 0000000000..3b119f507c --- /dev/null +++ b/packages/editor/CodeMirror/extensions/rendering/renderBlockImages.ts @@ -0,0 +1,134 @@ +import { Decoration, EditorView, WidgetType } from '@codemirror/view'; +import { SyntaxNodeRef } from '@lezer/common'; +import { EditorState } from '@codemirror/state'; +import { RenderedContentContext } from './types'; +import makeBlockReplaceExtension from './utils/makeBlockReplaceExtension'; + +const imageClassName = 'cm-md-image'; +// Pre-set the image height for performance (allows CodeMirror to better calculate +// the document height while scrolling). +const imageHeight = 200; + +class ImageWidget extends WidgetType { + private resolvedSrc_: string; + + public constructor( + private readonly context_: RenderedContentContext, + private readonly src_: string, + private readonly alt_: string, + ) { + super(); + } + + public eq(other: ImageWidget) { + return this.src_ === other.src_ && this.alt_ === other.alt_; + } + + public toDOM() { + const container = document.createElement('div'); + container.classList.add(imageClassName); + + const image = document.createElement('div'); + image.role = 'image'; + image.ariaLabel = this.alt_; + image.classList.add('image'); + + const updateImageUrl = () => { + if (this.resolvedSrc_) { + // Use a background-image style property rather than img[src=]. This + // simplifies setting the image to the correct size/position. + image.style.backgroundImage = `url(${JSON.stringify(this.resolvedSrc_)})`; + } + }; + + if (!this.resolvedSrc_) { + void (async () => { + this.resolvedSrc_ = await this.context_.resolveImageSrc(this.src_); + updateImageUrl(); + })(); + } else { + updateImageUrl(); + } + + container.appendChild(image); + return container; + } + + public get estimatedHeight() { + return imageHeight; + } +} + +const getImageSrc = (node: SyntaxNodeRef, state: EditorState) => { + const nodeText = state.sliceDoc(node.from, node.to); + // For now, only render Joplin resource images (avoid auto-fetching images from + // the internet if just the Markdown editor is open). + const match = nodeText.match(/:\/[a-zA-Z0-9]{32}/); + if (match) { + return match[0]; + } else { + return null; + } +}; + +const getImageAlt = (node: SyntaxNodeRef, state: EditorState) => { + const nodeText = state.sliceDoc(node.from, node.to); + + const match = nodeText.match(/!\s*\[(.+)\]/); + if (match) { + return match[1]; + } else { + return null; + } +}; + +const renderBlockImages = (context: RenderedContentContext) => [ + EditorView.theme({ + [`& .${imageClassName} > div`]: { + height: `${imageHeight}px`, + backgroundSize: 'contain', + backgroundRepeat: 'no-repeat', + backgroundPosition: 'center', + display: 'block', + }, + }), + makeBlockReplaceExtension({ + createDecoration: (node, state) => { + if (node.name === 'Image') { + const lineFrom = state.doc.lineAt(node.from); + const lineTo = state.doc.lineAt(node.to); + const textBefore = state.sliceDoc(lineFrom.from, node.from); + const textAfter = state.sliceDoc(node.to, lineTo.to); + if (textBefore.trim() === '' && textAfter.trim() === '') { + const src = getImageSrc(node, state); + const alt = getImageAlt(node, state); + + if (src) { + const isLastLine = lineTo.number === state.doc.lines; + return Decoration.widget({ + widget: new ImageWidget(context, src, alt), + // "side: -1": In general, when the cursor is at the widget's location, it should be at + // the start of the next line (and so "side" should be -1). + // + // "side: 1": However, when the widget is at the end of the document, the widget's + // position is **one index less** than when it isn't (to prevent the widget's + // position from being outside the document, which would break CodeMirror). + // This means that we need "side: 1" to put the cursor before the widget + // when at the end of the document. + side: isLastLine ? 1 : -1, + block: true, + }); + } + } + } + return null; + }, + getDecorationRange: (node, state) => { + const nodeLine = state.doc.lineAt(node.to); + return [Math.min(nodeLine.to + 1, state.doc.length)]; + }, + hideWhenContainsSelection: false, + }), +]; + +export default renderBlockImages; diff --git a/packages/editor/CodeMirror/extensions/rendering/renderingExtension.ts b/packages/editor/CodeMirror/extensions/rendering/renderingExtension.ts new file mode 100644 index 0000000000..1243f62086 --- /dev/null +++ b/packages/editor/CodeMirror/extensions/rendering/renderingExtension.ts @@ -0,0 +1,22 @@ +import addFormattingClasses from './addFormattingClasses'; +import renderBlockImages from './renderBlockImages'; +import replaceBulletLists from './replaceBulletLists'; +import replaceCheckboxes from './replaceCheckboxes'; +import replaceDividers from './replaceDividers'; +import replaceFormatCharacters from './replaceFormatCharacters'; +import { RenderedContentContext } from './types'; + +interface Options { + renderImages: boolean; +} + +export default (context: RenderedContentContext, options: Options) => { + return [ + replaceCheckboxes, + replaceBulletLists, + replaceFormatCharacters, + replaceDividers, + addFormattingClasses, + ...(options.renderImages ? [renderBlockImages(context)] : []), + ]; +}; diff --git a/packages/editor/CodeMirror/extensions/rendering/replaceBulletLists.ts b/packages/editor/CodeMirror/extensions/rendering/replaceBulletLists.ts new file mode 100644 index 0000000000..ef31c75ab7 --- /dev/null +++ b/packages/editor/CodeMirror/extensions/rendering/replaceBulletLists.ts @@ -0,0 +1,97 @@ +import { EditorView, WidgetType } from '@codemirror/view'; +import makeReplaceExtension from './utils/makeInlineReplaceExtension'; + +const listMarkerClassName = 'cm-bullet-list-marker'; + +class BulletListMarker extends WidgetType { + private className: string; + public constructor(depth: number) { + super(); + if (depth % 3 === 0) { + this.className = '-depth-0'; + } else if (depth % 3 === 1) { + this.className = '-depth-1'; + } else { + this.className = '-depth-2'; + } + } + + public eq(other: BulletListMarker) { + return other.className === this.className; + } + + public toDOM() { + const container = document.createElement('span'); + container.classList.add(listMarkerClassName, this.className); + container.setAttribute('aria-label', 'bullet'); + container.role = 'img'; + + const sizingNode = document.createElement('span'); + sizingNode.classList.add('sizing'); + sizingNode.textContent = '-'; + container.appendChild(sizingNode); + + const content = document.createElement('span'); + content.classList.add('content'); + container.appendChild(content); + + return container; + } + + public updateDOM(other: HTMLElement) { + other.classList.remove('-depth-0', '-depth-1', '-depth-2'); + other.classList.add(this.className); + return true; + } +} + +const replaceBulletLists = [ + EditorView.theme({ + [`& .${listMarkerClassName}`]: { + 'pointer-events': 'none', + 'position': 'relative', + + '&.-depth-0 > .content': { + 'border-radius': 0, + }, + '&.-depth-2 > .content': { + 'border': '1px solid currentcolor', + 'background-color': 'transparent', + }, + + '& > .sizing': { + 'color': 'transparent', + }, + + '& > .content': { + 'position': 'absolute', + 'left': '0', + + '--size': '4px', + // Push the content to the center of the container + '--vertical-offset': 'calc(50% - calc(var(--size) / 2))', + 'top': 'var(--vertical-offset)', + 'bottom': 'var(--vertical-offset)', + + 'width': 'var(--size)', + 'height': 'var(--size)', + 'box-sizing': 'border-box', + 'border-radius': 'var(--size)', + 'background-color': 'currentcolor', + }, + }, + }), + makeReplaceExtension({ + createDecoration: (node, _view, parentTagCounts) => { + if (node.name === 'ListMark') { + const parent = node.node.parent; + if (parent?.name === 'ListItem' && parent?.parent?.name === 'BulletList') { + return new BulletListMarker(parentTagCounts.get('BulletList') ?? 1); + } + } + return null; + }, + }), +]; + +export default replaceBulletLists; diff --git a/packages/editor/CodeMirror/extensions/rendering/replaceCheckboxes.ts b/packages/editor/CodeMirror/extensions/rendering/replaceCheckboxes.ts new file mode 100644 index 0000000000..f05f61a861 --- /dev/null +++ b/packages/editor/CodeMirror/extensions/rendering/replaceCheckboxes.ts @@ -0,0 +1,153 @@ +import { Decoration, EditorView, WidgetType } from '@codemirror/view'; +import { SyntaxNodeRef } from '@lezer/common'; +import makeReplaceExtension from './utils/makeInlineReplaceExtension'; + +const checkboxClassName = 'cm-ext-checkbox-toggle'; + +const toggleCheckbox = (view: EditorView, linePos: number) => { + if (linePos >= view.state.doc.length) { + // Position out of range + return false; + } + + const line = view.state.doc.lineAt(linePos); + const checkboxMarkup = line.text.match(/\[(x|\s)\]/); + if (!checkboxMarkup) { + // Couldn't find the checkbox + return false; + } + + const isChecked = checkboxMarkup[0] === '[x]'; + const checkboxPos = checkboxMarkup.index! + line.from; + + view.dispatch({ + changes: [{ from: checkboxPos, to: checkboxPos + 3, insert: isChecked ? '[ ]' : '[x]' }], + }); + return true; +}; + +class CheckboxWidget extends WidgetType { + public constructor(private checked: boolean, private depth: number, private label: string) { + super(); + } + + public eq(other: CheckboxWidget) { + return other.checked === this.checked && other.depth === this.depth && other.label === this.label; + } + + private applyContainerClasses(container: HTMLElement) { + container.classList.add(checkboxClassName); + + for (const className of [...container.classList]) { + if (className.startsWith('-depth-')) { + container.classList.remove(className); + } + } + + container.classList.add(`-depth-${this.depth}`); + } + + public toDOM(view: EditorView) { + const container = document.createElement('span'); + + const checkbox = document.createElement('input'); + checkbox.type = 'checkbox'; + checkbox.checked = this.checked; + checkbox.ariaLabel = this.label; + checkbox.title = this.label; + container.appendChild(checkbox); + + checkbox.oninput = () => { + toggleCheckbox(view, view.posAtDOM(container)); + }; + + this.applyContainerClasses(container); + return container; + } + + public updateDOM(dom: HTMLElement): boolean { + this.applyContainerClasses(dom); + + const input = dom.querySelector('input'); + if (input) { + input.checked = this.checked; + return true; + } + return false; + } + + public ignoreEvent() { + return false; + } +} + +const completedTaskClassName = 'cm-md-completed-item'; +const completedListItemDecoration = Decoration.line({ class: completedTaskClassName, isFullLine: true }); + +const replaceCheckboxes = [ + EditorView.theme({ + [`& .${checkboxClassName}`]: { + '& > input': { + width: '1.1em', + height: '1.1em', + margin: '4px', + verticalAlign: 'middle', + }, + '&:not(.-depth-1) > input': { + marginInlineStart: 0, + }, + }, + [`& .${completedTaskClassName}`]: { + opacity: 0.69, + }, + }), + EditorView.domEventHandlers({ + mousedown: (event) => { + const target = event.target as Element; + if (target.nodeName === 'INPUT' && target.parentElement?.classList?.contains(checkboxClassName)) { + // Let the checkbox handle the event + return true; + } + return false; + }, + }), + makeReplaceExtension({ + createDecoration: (node, state, parentTags) => { + const markerIsChecked = (marker: SyntaxNodeRef) => { + const content = state.doc.sliceString(marker.from, marker.to); + return content.toLowerCase().indexOf('x') !== -1; + }; + + if (node.name === 'TaskMarker') { + const containerLine = state.doc.lineAt(node.from); + const labelText = state.doc.sliceString(node.to, containerLine.to); + + return new CheckboxWidget(markerIsChecked(node), parentTags.get('ListItem') ?? 0, labelText); + } else if (node.name === 'Task') { + const marker = node.node.getChild('TaskMarker'); + if (marker && markerIsChecked(marker)) { + return completedListItemDecoration; + } + } + return null; + }, + getDecorationRange: (node, state) => { + if (node.name === 'TaskMarker') { + const container = node.node.parent?.parent; + const listMarker = container?.getChild('ListMark'); + if (!listMarker) { + return null; + } + + return [listMarker.from, node.to]; + } else if (node.name === 'Task') { + const taskLine = state.doc.lineAt(node.from); + return [taskLine.from]; + } + + return null; + }, + }), +]; + +export default replaceCheckboxes; diff --git a/packages/editor/CodeMirror/extensions/rendering/replaceDividers.ts b/packages/editor/CodeMirror/extensions/rendering/replaceDividers.ts new file mode 100644 index 0000000000..d7751f922e --- /dev/null +++ b/packages/editor/CodeMirror/extensions/rendering/replaceDividers.ts @@ -0,0 +1,70 @@ +import { Decoration, EditorView, WidgetType } from '@codemirror/view'; +import makeInlineReplaceExtension from './utils/makeInlineReplaceExtension'; + +const dividerClassName = 'cm-md-divider'; +const dividerLineClassName = 'cm-md-divider-line'; + +class DividerWidget extends WidgetType { + public constructor() { + super(); + } + + public eq(_other: DividerWidget) { + return true; + } + + public toDOM() { + const container = document.createElement('hr'); + container.classList.add(dividerClassName); + return container; + } + + public ignoreEvent() { + return true; + } +} + +const dividerLineMark = Decoration.line({ class: dividerLineClassName }); + +const replaceDividers = [ + EditorView.theme({ + [`& .cm-line.${dividerLineClassName}`]: { + // Use flex layout to allow the divider to fill the remainder of the line. + // This applies, for example, to the case where the divider is in a blockquote or + // a sub list item. + display: 'flex', + flexWrap: 'wrap', + }, + [`& .${dividerClassName}`]: { + // Fill remaining width + flexGrow: 1, + flexShrink: 1, + + border: 'none', + borderBottom: '2px solid var(--joplin-divider-color)', + position: 'relative', + }, + }), + makeInlineReplaceExtension({ + createDecoration: (node) => { + if (node.name === 'HorizontalRule') { + return new DividerWidget(); + } + return null; + }, + }), + makeInlineReplaceExtension({ + createDecoration: (node) => { + if (node.name === 'HorizontalRule') { + return dividerLineMark; + } + return null; + }, + getDecorationRange: (node, state) => { + const line = state.doc.lineAt(node.from); + return [line.from]; + }, + }), +]; + +export default replaceDividers; diff --git a/packages/editor/CodeMirror/extensions/rendering/replaceFormatCharacters.ts b/packages/editor/CodeMirror/extensions/rendering/replaceFormatCharacters.ts new file mode 100644 index 0000000000..2ca98fdcd3 --- /dev/null +++ b/packages/editor/CodeMirror/extensions/rendering/replaceFormatCharacters.ts @@ -0,0 +1,81 @@ +import makeInlineReplaceExtension from './utils/makeInlineReplaceExtension'; +import { SyntaxNodeRef } from '@lezer/common'; +import { EditorState } from '@codemirror/state'; +import referenceLinkStateField, { isReferenceLink, resolveReferenceFromLink } from '../links/referenceLinksStateField'; +import { Decoration } from '@codemirror/view'; + +const shouldFullReplace = (node: SyntaxNodeRef, state: EditorState) => { + const getParentName = () => node.node.parent?.name; + const getNodeStartLine = () => state.doc.lineAt(node.from); + + if (['HeaderMark', 'CodeMark', 'EmphasisMark', 'StrikethroughMark', 'HighlightMarker'].includes(node.name)) { + return true; + } + + if ((node.name === 'URL' || node.name === 'LinkMark') && getParentName() === 'Link') { + const parent = node.node.parent!; + const parentContent = state.sliceDoc(parent.from, parent.to); + if (node.name === 'LinkMark') { + if (isReferenceLink(parentContent)) { + return !!resolveReferenceFromLink(parentContent, state); + } + } else if (node.name === 'URL') { + // Find all closing link marks + const closingBracketNodes = parent.getChildren('LinkMark').filter(mark => { + const isClosingBracket = state.sliceDoc(mark.from, mark.to) === ']'; + return isClosingBracket; + }); + + // URLs can only be hidden if after the last ]. + const lastClosingBracketIdx = closingBracketNodes.length > 0 ? closingBracketNodes[closingBracketNodes.length - 1].from : null; + if (!lastClosingBracketIdx || node.from < lastClosingBracketIdx) { + return false; + } + } + return true; + } + + if (node.name === 'QuoteMark' && node.from === getNodeStartLine().from) { + return true; + } + + return false; +}; + +const hideDecoration = Decoration.replace({}); + +const replaceFormatCharacters = [ + // Dependency + referenceLinkStateField, + + makeInlineReplaceExtension({ + createDecoration: (node, state) => { + if (shouldFullReplace(node, state)) { + return hideDecoration; + } + return null; + }, + getDecorationRange: (node, state) => { + // Headers in the form "## Header" should have the "##"s and the + // space immediately after hidden + if (node.name === 'HeaderMark') { + const markerLine = state.doc.lineAt(node.from); + + // Certain header styles DON'T have a space after the header mark: + const hasRoomForSpace = node.to + 1 >= markerLine.to; + if (hasRoomForSpace) { + return null; + } + + // Include the space in the hidden region, if it's available + if (state.doc.sliceString(node.to, node.to + 1) === ' ') { + return [node.from, node.to + 1]; + } + } + + return null; + }, + }), +]; + +export default replaceFormatCharacters; diff --git a/packages/editor/CodeMirror/extensions/rendering/types.ts b/packages/editor/CodeMirror/extensions/rendering/types.ts new file mode 100644 index 0000000000..bda044b74f --- /dev/null +++ b/packages/editor/CodeMirror/extensions/rendering/types.ts @@ -0,0 +1,20 @@ +import type { EditorState } from '@codemirror/state'; +import type { Decoration, WidgetType } from '@codemirror/view'; +import type { SyntaxNodeRef } from '@lezer/common'; + +export interface ReplacementExtension { + // Should return the widget that replaces `node`. Returning `null` preserves `node` without replacement. + createDecoration(node: SyntaxNodeRef, state: EditorState, parentTags: Readonly>): Decoration|WidgetType|null; + + // Returns a range ([from, to]) to which the decoration should be applied. Returning `null` + // replaces the entire widget with the decoration. + // Only a single number should be returned to create a point/full line range. + getDecorationRange?(node: SyntaxNodeRef, state: EditorState): [number]|[number, number]|null; + + // Disable the decoration when near the cursor. Defaults to true. + hideWhenContainsSelection?: boolean; +} + +export interface RenderedContentContext { + resolveImageSrc(src: string): Promise; +} diff --git a/packages/editor/CodeMirror/extensions/rendering/utils/makeBlockReplaceExtension.ts b/packages/editor/CodeMirror/extensions/rendering/utils/makeBlockReplaceExtension.ts new file mode 100644 index 0000000000..ba099c1a94 --- /dev/null +++ b/packages/editor/CodeMirror/extensions/rendering/utils/makeBlockReplaceExtension.ts @@ -0,0 +1,89 @@ +import { EditorView, Decoration, DecorationSet, WidgetType } from '@codemirror/view'; +import { syntaxTree } from '@codemirror/language'; +import { EditorState, Range, StateField } from '@codemirror/state'; +import { ReplacementExtension } from '../types'; +import nodeIntersectsSelection from './nodeIntersectsSelection'; + +const updateDecorations = (state: EditorState, extensionSpec: ReplacementExtension) => { + const doc = state.doc; + const cursorLine = doc.lineAt(state.selection.main.anchor); + + const parentTagCounts = new Map(); + const widgets: Range[] = []; + syntaxTree(state).iterate({ + enter: node => { + parentTagCounts.set(node.name, (parentTagCounts.get(node.name) ?? 0) + 1); + + const nodeLineFrom = doc.lineAt(node.from); + const nodeLineTo = doc.lineAt(node.to); + const selectionIsNearNode = Math.abs(nodeLineFrom.number - cursorLine.number) <= 1 || Math.abs(nodeLineTo.number - cursorLine.number) <= 1; + const shouldHide = ( + (extensionSpec.hideWhenContainsSelection ?? true) && ( + nodeIntersectsSelection(state.selection, node) || selectionIsNearNode + ) + ); + + if (!shouldHide) { + const widget = extensionSpec.createDecoration(node, state, parentTagCounts); + if (widget) { + let decoration; + if (widget instanceof WidgetType) { + decoration = Decoration.replace({ + widget, + block: true, + }); + } else { + decoration = widget; + } + + let rangeFrom = nodeLineFrom.from; + let rangeTo = nodeLineTo.to; + let skip = false; + if (extensionSpec.getDecorationRange) { + const range = extensionSpec.getDecorationRange(node, state); + if (range) { + rangeFrom = range[0]; + rangeTo = range.length === 1 ? range[0] : range[1]; + } else { + skip = true; + } + } + + if (!skip) { + widgets.push(decoration.range(rangeFrom, rangeTo)); + } + } + } + }, + leave: node => { + parentTagCounts.set(node.name, (parentTagCounts.get(node.name) ?? 0) - 1); + }, + }); + + return Decoration.set(widgets, true); +}; + +const makeBlockReplaceExtension = (extensionSpec: ReplacementExtension) => { + const blockDecorationField = StateField.define({ + create(state) { + return updateDecorations(state, extensionSpec); + }, + update(decorations, transaction) { + decorations = decorations.map(transaction.changes); + const selectionChanged = !transaction.newSelection.eq(transaction.startState.selection); + + if (transaction.docChanged || selectionChanged) { + decorations = updateDecorations(transaction.state, extensionSpec); + } + + return decorations; + }, + provide: f => EditorView.decorations.from(f), + }); + return [ + blockDecorationField, + ]; +}; + +export default makeBlockReplaceExtension; + diff --git a/packages/editor/CodeMirror/extensions/rendering/utils/makeInlineReplaceExtension.ts b/packages/editor/CodeMirror/extensions/rendering/utils/makeInlineReplaceExtension.ts new file mode 100644 index 0000000000..10033759df --- /dev/null +++ b/packages/editor/CodeMirror/extensions/rendering/utils/makeInlineReplaceExtension.ts @@ -0,0 +1,91 @@ +// Ref: https://codemirror.net/examples/bundle/ +// and https://codemirror.net/examples/decoration/ + +import { EditorView, Decoration, DecorationSet, WidgetType } from '@codemirror/view'; +import { ViewPlugin, ViewUpdate } from '@codemirror/view'; +import { syntaxTree } from '@codemirror/language'; +import { Range } from '@codemirror/state'; +import { SyntaxNodeRef } from '@lezer/common'; +import { ReplacementExtension } from '../types'; +import nodeIntersectsSelection from './nodeIntersectsSelection'; + + +export const makeInlineReplaceExtension = (extensionSpec: ReplacementExtension) => ViewPlugin.fromClass(class { + public decorations: DecorationSet; + + public constructor(view: EditorView) { + this.updateDecorations(view); + } + + private updateDecorations(view: EditorView) { + const doc = view.state.doc; + const cursorLine = doc.lineAt(view.state.selection.main.anchor); + const selection = view.state.selection; + + const parentTagCounts = new Map(); + const decorateNode = (node: SyntaxNodeRef) => { + const widgetOrDecoration = extensionSpec.createDecoration(node, view.state, parentTagCounts); + let decoration; + if (widgetOrDecoration instanceof WidgetType) { + decoration = Decoration.replace({ + widget: widgetOrDecoration, + }); + } else if (widgetOrDecoration instanceof Decoration) { + decoration = widgetOrDecoration; + } + + if (decoration) { + const range = extensionSpec.getDecorationRange?.(node, view.state) ?? [node.from, node.to]; + const rangeLineFrom = doc.lineAt(range[0]); + const rangeLineTo = range.length === 2 ? doc.lineAt(range[1]) : rangeLineFrom; + + // A different start/end line causes errors. + if (rangeLineFrom.number === rangeLineTo.number) { + if (range.length === 1) { + widgets.push(decoration.range(range[0])); + } else { + widgets.push(decoration.range(range[0], range[1])); + } + } + } + }; + + const widgets: Range[] = []; + for (const { from, to } of view.visibleRanges) { + parentTagCounts.clear(); + syntaxTree(view.state).iterate({ + from, to, + enter: node => { + parentTagCounts.set(node.name, (parentTagCounts.get(node.name) ?? 0) + 1); + + const nodeLineFrom = doc.lineAt(node.from); + const nodeLineTo = doc.lineAt(node.from); + const nodeLineContainsSelection = cursorLine.number === nodeLineFrom.number || cursorLine.number === nodeLineTo.number; + const shouldHide = ( + (extensionSpec.hideWhenContainsSelection ?? true) && ( + nodeIntersectsSelection(selection, node) || nodeLineContainsSelection + ) + ); + + if (!shouldHide) { + decorateNode(node); + } + }, + leave: node => { + parentTagCounts.set(node.name, (parentTagCounts.get(node.name) ?? 0) - 1); + }, + }); + } + this.decorations = Decoration.set(widgets, true); + } + + public update(update: ViewUpdate) { + if (update.docChanged || update.viewportChanged || update.selectionSet) { + this.updateDecorations(update.view); + } + } +}, { + decorations: view => view.decorations, +}); + +export default makeInlineReplaceExtension; diff --git a/packages/editor/CodeMirror/extensions/rendering/utils/nodeIntersectsSelection.ts b/packages/editor/CodeMirror/extensions/rendering/utils/nodeIntersectsSelection.ts new file mode 100644 index 0000000000..82bda7a61c --- /dev/null +++ b/packages/editor/CodeMirror/extensions/rendering/utils/nodeIntersectsSelection.ts @@ -0,0 +1,17 @@ +import { EditorSelection } from '@codemirror/state'; +import { SyntaxNodeRef } from '@lezer/common'; + +const nodeIntersectsSelection = (selection: EditorSelection, node: SyntaxNodeRef) => { + const mainSelection = selection.main; + + const nodeContains = (point: number) => { + return point >= node.from && point <= node.to; + }; + const selectionContains = (point: number) => { + return point >= mainSelection.from && point <= mainSelection.to; + }; + return nodeContains(mainSelection.from) || nodeContains(mainSelection.to) + || selectionContains(node.from) || selectionContains(node.to); +}; + +export default nodeIntersectsSelection; diff --git a/packages/editor/CodeMirror/testing/createEditorControl.ts b/packages/editor/CodeMirror/testing/createEditorControl.ts index 30ce1afef4..1087c2aaca 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, + resolveImageSrc: (src)=>Promise.resolve(src), onLocalize: input=>input, }); }; diff --git a/packages/editor/testing/createEditorSettings.ts b/packages/editor/testing/createEditorSettings.ts index 38417dd1cf..f048bcb55b 100644 --- a/packages/editor/testing/createEditorSettings.ts +++ b/packages/editor/testing/createEditorSettings.ts @@ -13,6 +13,7 @@ const createEditorSettings = (themeId: number) => { ignoreModifiers: false, autocompleteMarkup: true, tabMovesFocus: false, + inlineRenderingEnabled: true, keymap: EditorKeymap.Default, language: EditorLanguageType.Markdown, @@ -20,6 +21,7 @@ const createEditorSettings = (themeId: number) => { indentWithTabs: true, editorLabel: 'Markdown editor', + imageRenderingEnabled: false, }; return editorSettings; diff --git a/packages/editor/types.ts b/packages/editor/types.ts index fca41d205b..c4879af434 100644 --- a/packages/editor/types.ts +++ b/packages/editor/types.ts @@ -179,6 +179,8 @@ export interface EditorSettings { markdownMarkEnabled: boolean; katexEnabled: boolean; spellcheckEnabled: boolean; + inlineRenderingEnabled: boolean; + imageRenderingEnabled: boolean; readOnly: boolean; indentWithTabs: boolean; diff --git a/packages/lib/models/settings/builtInMetadata.ts b/packages/lib/models/settings/builtInMetadata.ts index 9c71776179..e9083276af 100644 --- a/packages/lib/models/settings/builtInMetadata.ts +++ b/packages/lib/models/settings/builtInMetadata.ts @@ -1455,6 +1455,27 @@ const builtInMetadata = (Setting: typeof SettingType) => { isGlobal: true, }, + 'editor.inlineRendering': { + value: true, + type: SettingItemType.Bool, + public: true, + appTypes: [AppType.Desktop, AppType.Mobile], + label: () => _('Markdown editor: Render markup in editor'), + description: () => _('Renders markup on all lines that don\'t include the cursor.'), + section: 'note', + storage: SettingStorage.File, + }, + 'editor.imageRendering': { + value: true, + type: SettingItemType.Bool, + public: true, + appTypes: [AppType.Desktop, AppType.Mobile], + label: () => _('Markdown editor: Render images'), + description: () => _('If an image attachment is on its own line and followed by a blank line, it will be rendered just below its Markdown source.'), + section: 'note', + storage: SettingStorage.File, + }, + 'imageeditor.jsdrawToolbar': { value: '', type: SettingItemType.String, diff --git a/packages/tools/cspell/dictionary4.txt b/packages/tools/cspell/dictionary4.txt index a8c6d377e3..55b29e71d2 100644 --- a/packages/tools/cspell/dictionary4.txt +++ b/packages/tools/cspell/dictionary4.txt @@ -187,6 +187,7 @@ fuzzer Freespinny BestEtf Etf +currentcolor prosemirror gapcursor dropcursor