1
0
mirror of https://github.com/laurent22/joplin.git synced 2025-08-10 22:11:50 +02:00

Desktop, Mobile: Move several features from Extra Markdown Editor Settings into the main app (#12747)

This commit is contained in:
Henry Heino
2025-08-10 01:17:12 -07:00
committed by GitHub
parent 46ab00bfe4
commit ea1d2e4878
39 changed files with 1446 additions and 6 deletions

View File

@@ -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/clipboardUtils.js
packages/app-desktop/gui/NoteEditor/utils/contextMenu.js packages/app-desktop/gui/NoteEditor/utils/contextMenu.js
packages/app-desktop/gui/NoteEditor/utils/contextMenuUtils.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/getWindowCommandPriority.js
packages/app-desktop/gui/NoteEditor/utils/index.js packages/app-desktop/gui/NoteEditor/utils/index.js
packages/app-desktop/gui/NoteEditor/utils/markupRenderOptions.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/editorCommands/supportsCommand.js
packages/editor/CodeMirror/extensions/biDirectionalTextExtension.js packages/editor/CodeMirror/extensions/biDirectionalTextExtension.js
packages/editor/CodeMirror/extensions/keyUpHandlerExtension.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.test.js
packages/editor/CodeMirror/extensions/markdownDecorationExtension.js packages/editor/CodeMirror/extensions/markdownDecorationExtension.js
packages/editor/CodeMirror/extensions/markdownHighlightExtension.test.js packages/editor/CodeMirror/extensions/markdownHighlightExtension.test.js
packages/editor/CodeMirror/extensions/markdownHighlightExtension.js packages/editor/CodeMirror/extensions/markdownHighlightExtension.js
packages/editor/CodeMirror/extensions/markdownMathExtension.test.js packages/editor/CodeMirror/extensions/markdownMathExtension.test.js
packages/editor/CodeMirror/extensions/markdownMathExtension.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.test.js
packages/editor/CodeMirror/extensions/overwriteModeExtension.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/searchExtension.js
packages/editor/CodeMirror/extensions/selectedNoteIdExtension.js packages/editor/CodeMirror/extensions/selectedNoteIdExtension.js
packages/editor/CodeMirror/getScrollFraction.js packages/editor/CodeMirror/getScrollFraction.js

22
.gitignore vendored
View File

@@ -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/clipboardUtils.js
packages/app-desktop/gui/NoteEditor/utils/contextMenu.js packages/app-desktop/gui/NoteEditor/utils/contextMenu.js
packages/app-desktop/gui/NoteEditor/utils/contextMenuUtils.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/getWindowCommandPriority.js
packages/app-desktop/gui/NoteEditor/utils/index.js packages/app-desktop/gui/NoteEditor/utils/index.js
packages/app-desktop/gui/NoteEditor/utils/markupRenderOptions.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/editorCommands/supportsCommand.js
packages/editor/CodeMirror/extensions/biDirectionalTextExtension.js packages/editor/CodeMirror/extensions/biDirectionalTextExtension.js
packages/editor/CodeMirror/extensions/keyUpHandlerExtension.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.test.js
packages/editor/CodeMirror/extensions/markdownDecorationExtension.js packages/editor/CodeMirror/extensions/markdownDecorationExtension.js
packages/editor/CodeMirror/extensions/markdownHighlightExtension.test.js packages/editor/CodeMirror/extensions/markdownHighlightExtension.test.js
packages/editor/CodeMirror/extensions/markdownHighlightExtension.js packages/editor/CodeMirror/extensions/markdownHighlightExtension.js
packages/editor/CodeMirror/extensions/markdownMathExtension.test.js packages/editor/CodeMirror/extensions/markdownMathExtension.test.js
packages/editor/CodeMirror/extensions/markdownMathExtension.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.test.js
packages/editor/CodeMirror/extensions/overwriteModeExtension.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/searchExtension.js
packages/editor/CodeMirror/extensions/selectedNoteIdExtension.js packages/editor/CodeMirror/extensions/selectedNoteIdExtension.js
packages/editor/CodeMirror/getScrollFraction.js packages/editor/CodeMirror/getScrollFraction.js

View File

@@ -340,6 +340,8 @@ const CodeMirror = (props: NoteBodyEditorProps, ref: ForwardedRef<NoteBodyEditor
props.setShowLocalSearch(event.searchState.dialogVisible); props.setShowLocalSearch(event.searchState.dialogVisible);
} }
lastSearchState.current = event.searchState; lastSearchState.current = event.searchState;
} else if (event.kind === EditorEventType.FollowLink) {
void CommandService.instance().execute('openItem', event.link);
} }
}, [editor_scroll, codeMirror_change, props.setLocalSearch, props.setShowLocalSearch]); }, [editor_scroll, codeMirror_change, props.setLocalSearch, props.setShowLocalSearch]);
@@ -362,6 +364,8 @@ const CodeMirror = (props: NoteBodyEditorProps, ref: ForwardedRef<NoteBodyEditor
readOnly: props.disabled, readOnly: props.disabled,
markdownMarkEnabled: Setting.value('markdown.plugin.mark'), markdownMarkEnabled: Setting.value('markdown.plugin.mark'),
katexEnabled: Setting.value('markdown.plugin.katex'), katexEnabled: Setting.value('markdown.plugin.katex'),
inlineRenderingEnabled: Setting.value('editor.inlineRendering'),
imageRenderingEnabled: Setting.value('editor.imageRendering'),
themeData: { themeData: {
...styles.globalTheme, ...styles.globalTheme,
marginLeft: 0, marginLeft: 0,

View File

@@ -15,6 +15,10 @@ import useEditorSearch from '../utils/useEditorSearchExtension';
import CommandService from '@joplin/lib/services/CommandService'; import CommandService from '@joplin/lib/services/CommandService';
import { SearchMarkers } from '../../../utils/useSearchMarkers'; import { SearchMarkers } from '../../../utils/useSearchMarkers';
import localisation from './utils/localisation'; import localisation from './utils/localisation';
import Resource from '@joplin/lib/models/Resource';
import { parseResourceUrl } from '@joplin/lib/urlUtils';
import { resourceFilename } from '@joplin/lib/models/utils/resourceUtils';
import getResourceBaseUrl from '../../../utils/getResourceBaseUrl';
interface Props extends EditorProps { interface Props extends EditorProps {
style: React.CSSProperties; style: React.CSSProperties;
@@ -104,7 +108,15 @@ const Editor = (props: Props, ref: ForwardedRef<CodeMirrorControl>) => {
onLogMessage: message => onLogMessageRef.current(message), 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({ editor.addStyles({
'.cm-scroller': { overflow: 'auto' }, '.cm-scroller': { overflow: 'auto' },
'&.CodeMirror': { '&.CodeMirror': {

View File

@@ -56,6 +56,7 @@ import useResourceUnwatcher from './utils/useResourceUnwatcher';
import StatusBar from './StatusBar'; import StatusBar from './StatusBar';
import useVisiblePluginEditorViewIds from '@joplin/lib/hooks/plugins/useVisiblePluginEditorViewIds'; import useVisiblePluginEditorViewIds from '@joplin/lib/hooks/plugins/useVisiblePluginEditorViewIds';
import useConnectToEditorPlugin from './utils/useConnectToEditorPlugin'; import useConnectToEditorPlugin from './utils/useConnectToEditorPlugin';
import getResourceBaseUrl from './utils/getResourceBaseUrl';
const debounce = require('debounce'); const debounce = require('debounce');
@@ -169,7 +170,7 @@ function NoteEditorContent(props: NoteEditorProps) {
const theme = themeStyle(options.themeId ? options.themeId : props.themeId); const theme = themeStyle(options.themeId ? options.themeId : props.themeId);
const markupToHtml = markupLanguageUtils.newMarkupToHtml(props.plugins, { const markupToHtml = markupLanguageUtils.newMarkupToHtml(props.plugins, {
resourceBaseUrl: `joplin-content://note-viewer/${Setting.value('resourceDir')}/`, resourceBaseUrl: getResourceBaseUrl(),
customCss: props.customCss, customCss: props.customCss,
}); });

View File

@@ -0,0 +1,4 @@
import Setting from '@joplin/lib/models/Setting';
const getResourceBaseUrl = () => `joplin-content://note-viewer/${Setting.value('resourceDir')}/`;
export default getResourceBaseUrl;

View File

@@ -246,6 +246,8 @@ function NoteEditor(props: Props) {
markdownMarkEnabled: Setting.value('markdown.plugin.mark'), markdownMarkEnabled: Setting.value('markdown.plugin.mark'),
katexEnabled: Setting.value('markdown.plugin.katex'), katexEnabled: Setting.value('markdown.plugin.katex'),
spellcheckEnabled: Setting.value('editor.mobile.spellcheckEnabled'), 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, language: props.markupLanguage === MarkupLanguage.Html ? EditorLanguageType.Html : EditorLanguageType.Markdown,
useExternalSearch: true, useExternalSearch: true,
readOnly: props.readOnly, readOnly: props.readOnly,

View File

@@ -37,6 +37,9 @@ export const initializeEditor = ({
onEvent: (event): void => { onEvent: (event): void => {
void messenger.remoteApi.onEditorEvent(event); void messenger.remoteApi.onEditorEvent(event);
}, },
resolveImageSrc: (src) => {
return messenger.remoteApi.onResolveImageSrc(src);
},
}); });
// Works around https://github.com/laurent22/joplin/issues/10047 by handling // Works around https://github.com/laurent22/joplin/issues/10047 by handling

View File

@@ -22,4 +22,5 @@ export interface MainProcessApi {
onEditorEvent(event: EditorEvent): Promise<void>; onEditorEvent(event: EditorEvent): Promise<void>;
logMessage(message: string): Promise<void>; logMessage(message: string): Promise<void>;
onPasteFile(type: string, dataBase64: string): Promise<void>; onPasteFile(type: string, dataBase64: string): Promise<void>;
onResolveImageSrc(src: string): Promise<string|null>;
} }

View File

@@ -7,6 +7,9 @@ import { OnMessageEvent, WebViewControl } from '../../components/ExtendedWebView
import { EditorEvent } from '@joplin/editor/events'; import { EditorEvent } from '@joplin/editor/events';
import Logger from '@joplin/utils/Logger'; import Logger from '@joplin/utils/Logger';
import RNToWebViewMessenger from '../../utils/ipc/RNToWebViewMessenger'; 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'); const logger = Logger.create('markdownEditor');
@@ -109,6 +112,23 @@ const useWebViewSetup = ({
async onPasteFile(type, data) { async onPasteFile(type, data) {
onAttachRef.current(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<MainProcessApi, EditorProcessApi>( const messenger = new RNToWebViewMessenger<MainProcessApi, EditorProcessApi>(
'markdownEditor', webviewRef, localApi, 'markdownEditor', webviewRef, localApi,

View File

@@ -14,8 +14,10 @@ import { vim } from '@replit/codemirror-vim';
import { indentUnit } from '@codemirror/language'; import { indentUnit } from '@codemirror/language';
import { Prec } from '@codemirror/state'; import { Prec } from '@codemirror/state';
import insertNewlineContinueMarkup from './editorCommands/insertNewlineContinueMarkup'; 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 languageExtension = (() => {
const openingBrackets = '`([{\'"‘“(《「『【〔〖〘〚'.split(''); const openingBrackets = '`([{\'"‘“(《「『【〔〖〘〚'.split('');
@@ -84,6 +86,12 @@ const configFromSettings = (settings: EditorSettings) => {
extensions.push(Prec.low(keymap.of(defaultKeymap))); extensions.push(Prec.low(keymap.of(defaultKeymap)));
} }
if (settings.inlineRenderingEnabled) {
extensions.push(renderingExtension(context, {
renderImages: settings.imageRenderingEnabled,
}));
}
return extensions; return extensions;
}; };

View File

@@ -42,6 +42,7 @@ describe('createEditor', () => {
onLogMessage: _message => {}, onLogMessage: _message => {},
onLocalize: input => input, onLocalize: input => input,
onPasteFile: null, onPasteFile: null,
resolveImageSrc: src => Promise.resolve(src),
}); });
// Force the generation of the syntax tree now. // Force the generation of the syntax tree now.
@@ -72,6 +73,7 @@ describe('createEditor', () => {
onLogMessage: _message => {}, onLogMessage: _message => {},
onLocalize: input => input, onLocalize: input => input,
onPasteFile: null, onPasteFile: null,
resolveImageSrc: src=>Promise.resolve(src),
}); });
const getContentScriptJs = jest.fn(async () => { const getContentScriptJs = jest.fn(async () => {
@@ -142,6 +144,7 @@ describe('createEditor', () => {
onLogMessage: _message => {}, onLogMessage: _message => {},
onLocalize: input => input, onLocalize: input => input,
onPasteFile: null, onPasteFile: null,
resolveImageSrc: src=>Promise.resolve(src),
}); });
const getContentScriptJs = jest.fn(async () => { const getContentScriptJs = jest.fn(async () => {
@@ -193,6 +196,7 @@ describe('createEditor', () => {
onLogMessage: () => {}, onLogMessage: () => {},
onLocalize: input => input, onLocalize: input => input,
onPasteFile: null, onPasteFile: null,
resolveImageSrc: src=>Promise.resolve(src),
}); });
const editorState = editor.editor.state; const editorState = editor.editor.state;
const idFacet = editor.joplinExtensions.noteIdFacet; const idFacet = editor.joplinExtensions.noteIdFacet;

View File

@@ -36,6 +36,9 @@ import isCursorAtBeginning from './utils/isCursorAtBeginning';
import overwriteModeExtension from './extensions/overwriteModeExtension'; import overwriteModeExtension from './extensions/overwriteModeExtension';
import handleLinkEditRequests, { showLinkEditor } from './utils/handleLinkEditRequests'; import handleLinkEditRequests, { showLinkEditor } from './utils/handleLinkEditRequests';
import selectedNoteIdExtension, { setNoteIdEffect } from './extensions/selectedNoteIdExtension'; 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. // Newer versions of CodeMirror by default use Chrome's EditContext API.
// While this might be stable enough for desktop use, it causes significant // 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 }; type ExtendedEditorView = typeof EditorView & { EDIT_CONTEXT: boolean };
(EditorView as ExtendedEditorView).EDIT_CONTEXT = false; (EditorView as ExtendedEditorView).EDIT_CONTEXT = false;
export type ResolveImageCallback = (imageSrc: string)=> Promise<string>;
interface CodeMirrorProps {
resolveImageSrc: ResolveImageCallback;
}
const createEditor = ( const createEditor = (
parentElement: HTMLElement, props: EditorProps, parentElement: HTMLElement, props: EditorProps&CodeMirrorProps,
): CodeMirrorControl => { ): CodeMirrorControl => {
const initialText = props.initialText; const initialText = props.initialText;
let settings = props.settings; let settings = props.settings;
props.onLogMessage('Initializing CodeMirror...'); props.onLogMessage('Initializing CodeMirror...');
const context: RenderedContentContext = {
resolveImageSrc: (src) => {
return props.resolveImageSrc(src);
},
};
// Handles firing an event when the undo/redo stack changes // Handles firing an event when the undo/redo stack changes
let schedulePostUndoRedoDepthChangeId_: ReturnType<typeof setTimeout>|null = null; let schedulePostUndoRedoDepthChangeId_: ReturnType<typeof setTimeout>|null = null;
@@ -228,7 +243,7 @@ const createEditor = (
extensions: [ extensions: [
keymapConfig, keymapConfig,
dynamicConfig.of(configFromSettings(props.settings)), dynamicConfig.of(configFromSettings(props.settings, context)),
historyCompartment.of(history()), historyCompartment.of(history()),
searchExtension(props.onEvent, props.settings), searchExtension(props.onEvent, props.settings),
@@ -237,6 +252,9 @@ const createEditor = (
EditorState.allowMultipleSelections.of(true), EditorState.allowMultipleSelections.of(true),
rectangularSelection(), rectangularSelection(),
drawSelection(), drawSelection(),
ctrlClickLinksExtension(link => {
props.onEvent({ kind: EditorEventType.FollowLink, link });
}),
highlightSpecialChars(), highlightSpecialChars(),
indentOnInput(), indentOnInput(),
@@ -274,6 +292,7 @@ const createEditor = (
biDirectionalTextExtension, biDirectionalTextExtension,
overwriteModeExtension, overwriteModeExtension,
ctrlKeyStateClassExtension,
selectedNoteIdExtension, selectedNoteIdExtension,
@@ -320,7 +339,7 @@ const createEditor = (
settings = newSettings; settings = newSettings;
editor.dispatch({ editor.dispatch({
effects: dynamicConfig.reconfigure( effects: dynamicConfig.reconfigure(
configFromSettings(newSettings), configFromSettings(newSettings, context),
), ),
}); });
}, },

View File

@@ -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;

View File

@@ -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/');
});
});

View File

@@ -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<readonly Tooltip[]>({
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;

View File

@@ -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<ReferenceLinkValue>, fromIdx: number, toIdx: number, doc: Text) => {
const newRanges: Range<ReferenceLinkValue>[] = [];
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<RangeSet<ReferenceLinkValue>>({
create(state): RangeSet<ReferenceLinkValue> {
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;

View File

@@ -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);
});
});

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -0,0 +1,45 @@
import { StateEffect, StateField, Transaction } from '@codemirror/state';
import { EditorView } from '@codemirror/view';
const ctrlOrMetaChangedEffect = StateEffect.define<boolean>();
const ctrlOrMetaPressedField = StateField.define<boolean>({
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,
];

View File

@@ -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;

View File

@@ -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();
});
});

View File

@@ -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;

View File

@@ -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)] : []),
];
};

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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<Map<string, number>>): 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<string>;
}

View File

@@ -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<string, number>();
const widgets: Range<Decoration>[] = [];
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<DecorationSet>({
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;

View File

@@ -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<string, number>();
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<Decoration>[] = [];
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;

View File

@@ -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;

View File

@@ -12,6 +12,7 @@ const createEditorControl = (initialText: string) => {
onEvent: _event => {}, onEvent: _event => {},
onLogMessage: _message => {}, onLogMessage: _message => {},
onPasteFile: null, onPasteFile: null,
resolveImageSrc: (src)=>Promise.resolve(src),
onLocalize: input=>input, onLocalize: input=>input,
}); });
}; };

View File

@@ -13,6 +13,7 @@ const createEditorSettings = (themeId: number) => {
ignoreModifiers: false, ignoreModifiers: false,
autocompleteMarkup: true, autocompleteMarkup: true,
tabMovesFocus: false, tabMovesFocus: false,
inlineRenderingEnabled: true,
keymap: EditorKeymap.Default, keymap: EditorKeymap.Default,
language: EditorLanguageType.Markdown, language: EditorLanguageType.Markdown,
@@ -20,6 +21,7 @@ const createEditorSettings = (themeId: number) => {
indentWithTabs: true, indentWithTabs: true,
editorLabel: 'Markdown editor', editorLabel: 'Markdown editor',
imageRenderingEnabled: false,
}; };
return editorSettings; return editorSettings;

View File

@@ -179,6 +179,8 @@ export interface EditorSettings {
markdownMarkEnabled: boolean; markdownMarkEnabled: boolean;
katexEnabled: boolean; katexEnabled: boolean;
spellcheckEnabled: boolean; spellcheckEnabled: boolean;
inlineRenderingEnabled: boolean;
imageRenderingEnabled: boolean;
readOnly: boolean; readOnly: boolean;
indentWithTabs: boolean; indentWithTabs: boolean;

View File

@@ -1455,6 +1455,27 @@ const builtInMetadata = (Setting: typeof SettingType) => {
isGlobal: true, 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': { 'imageeditor.jsdrawToolbar': {
value: '', value: '',
type: SettingItemType.String, type: SettingItemType.String,

View File

@@ -187,6 +187,7 @@ fuzzer
Freespinny Freespinny
BestEtf BestEtf
Etf Etf
currentcolor
prosemirror prosemirror
gapcursor gapcursor
dropcursor dropcursor