You've already forked joplin
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:
@@ -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
22
.gitignore
vendored
@@ -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
|
||||||
|
@@ -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,
|
||||||
|
@@ -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': {
|
||||||
|
@@ -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,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@@ -0,0 +1,4 @@
|
|||||||
|
import Setting from '@joplin/lib/models/Setting';
|
||||||
|
|
||||||
|
const getResourceBaseUrl = () => `joplin-content://note-viewer/${Setting.value('resourceDir')}/`;
|
||||||
|
export default getResourceBaseUrl;
|
@@ -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,
|
||||||
|
@@ -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
|
||||||
|
@@ -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>;
|
||||||
}
|
}
|
||||||
|
@@ -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,
|
||||||
|
@@ -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;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@@ -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;
|
||||||
|
@@ -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),
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
@@ -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;
|
@@ -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/');
|
||||||
|
});
|
||||||
|
});
|
@@ -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;
|
@@ -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;
|
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
@@ -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;
|
@@ -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;
|
@@ -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;
|
@@ -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,
|
||||||
|
];
|
@@ -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;
|
@@ -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}${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('\n\n', true);
|
||||||
|
const image = findImage(editor);
|
||||||
|
expect(image).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
@@ -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;
|
@@ -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)] : []),
|
||||||
|
];
|
||||||
|
};
|
@@ -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;
|
@@ -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;
|
@@ -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;
|
@@ -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;
|
20
packages/editor/CodeMirror/extensions/rendering/types.ts
Normal file
20
packages/editor/CodeMirror/extensions/rendering/types.ts
Normal 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>;
|
||||||
|
}
|
@@ -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;
|
||||||
|
|
@@ -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;
|
@@ -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;
|
@@ -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,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
@@ -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;
|
||||||
|
@@ -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;
|
||||||
|
@@ -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,
|
||||||
|
@@ -187,6 +187,7 @@ fuzzer
|
|||||||
Freespinny
|
Freespinny
|
||||||
BestEtf
|
BestEtf
|
||||||
Etf
|
Etf
|
||||||
|
currentcolor
|
||||||
prosemirror
|
prosemirror
|
||||||
gapcursor
|
gapcursor
|
||||||
dropcursor
|
dropcursor
|
||||||
|
Reference in New Issue
Block a user