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/contextMenu.js
|
||||
packages/app-desktop/gui/NoteEditor/utils/contextMenuUtils.js
|
||||
packages/app-desktop/gui/NoteEditor/utils/getResourceBaseUrl.js
|
||||
packages/app-desktop/gui/NoteEditor/utils/getWindowCommandPriority.js
|
||||
packages/app-desktop/gui/NoteEditor/utils/index.js
|
||||
packages/app-desktop/gui/NoteEditor/utils/markupRenderOptions.js
|
||||
@@ -1004,14 +1005,35 @@ packages/editor/CodeMirror/editorCommands/sortSelectedLines.js
|
||||
packages/editor/CodeMirror/editorCommands/supportsCommand.js
|
||||
packages/editor/CodeMirror/extensions/biDirectionalTextExtension.js
|
||||
packages/editor/CodeMirror/extensions/keyUpHandlerExtension.js
|
||||
packages/editor/CodeMirror/extensions/links/ctrlClickLinksExtension.js
|
||||
packages/editor/CodeMirror/extensions/links/followLinkTooltipExtension.test.js
|
||||
packages/editor/CodeMirror/extensions/links/followLinkTooltipExtension.js
|
||||
packages/editor/CodeMirror/extensions/links/referenceLinksStateField.js
|
||||
packages/editor/CodeMirror/extensions/links/utils/findLineMatchingLink.test.js
|
||||
packages/editor/CodeMirror/extensions/links/utils/findLineMatchingLink.js
|
||||
packages/editor/CodeMirror/extensions/links/utils/getUrlAtPosition.js
|
||||
packages/editor/CodeMirror/extensions/links/utils/openLink.js
|
||||
packages/editor/CodeMirror/extensions/markdownDecorationExtension.test.js
|
||||
packages/editor/CodeMirror/extensions/markdownDecorationExtension.js
|
||||
packages/editor/CodeMirror/extensions/markdownHighlightExtension.test.js
|
||||
packages/editor/CodeMirror/extensions/markdownHighlightExtension.js
|
||||
packages/editor/CodeMirror/extensions/markdownMathExtension.test.js
|
||||
packages/editor/CodeMirror/extensions/markdownMathExtension.js
|
||||
packages/editor/CodeMirror/extensions/modifierKeyCssExtension.js
|
||||
packages/editor/CodeMirror/extensions/overwriteModeExtension.test.js
|
||||
packages/editor/CodeMirror/extensions/overwriteModeExtension.js
|
||||
packages/editor/CodeMirror/extensions/rendering/addFormattingClasses.js
|
||||
packages/editor/CodeMirror/extensions/rendering/renderBlockImages.test.js
|
||||
packages/editor/CodeMirror/extensions/rendering/renderBlockImages.js
|
||||
packages/editor/CodeMirror/extensions/rendering/renderingExtension.js
|
||||
packages/editor/CodeMirror/extensions/rendering/replaceBulletLists.js
|
||||
packages/editor/CodeMirror/extensions/rendering/replaceCheckboxes.js
|
||||
packages/editor/CodeMirror/extensions/rendering/replaceDividers.js
|
||||
packages/editor/CodeMirror/extensions/rendering/replaceFormatCharacters.js
|
||||
packages/editor/CodeMirror/extensions/rendering/types.js
|
||||
packages/editor/CodeMirror/extensions/rendering/utils/makeBlockReplaceExtension.js
|
||||
packages/editor/CodeMirror/extensions/rendering/utils/makeInlineReplaceExtension.js
|
||||
packages/editor/CodeMirror/extensions/rendering/utils/nodeIntersectsSelection.js
|
||||
packages/editor/CodeMirror/extensions/searchExtension.js
|
||||
packages/editor/CodeMirror/extensions/selectedNoteIdExtension.js
|
||||
packages/editor/CodeMirror/getScrollFraction.js
|
||||
|
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/contextMenu.js
|
||||
packages/app-desktop/gui/NoteEditor/utils/contextMenuUtils.js
|
||||
packages/app-desktop/gui/NoteEditor/utils/getResourceBaseUrl.js
|
||||
packages/app-desktop/gui/NoteEditor/utils/getWindowCommandPriority.js
|
||||
packages/app-desktop/gui/NoteEditor/utils/index.js
|
||||
packages/app-desktop/gui/NoteEditor/utils/markupRenderOptions.js
|
||||
@@ -977,14 +978,35 @@ packages/editor/CodeMirror/editorCommands/sortSelectedLines.js
|
||||
packages/editor/CodeMirror/editorCommands/supportsCommand.js
|
||||
packages/editor/CodeMirror/extensions/biDirectionalTextExtension.js
|
||||
packages/editor/CodeMirror/extensions/keyUpHandlerExtension.js
|
||||
packages/editor/CodeMirror/extensions/links/ctrlClickLinksExtension.js
|
||||
packages/editor/CodeMirror/extensions/links/followLinkTooltipExtension.test.js
|
||||
packages/editor/CodeMirror/extensions/links/followLinkTooltipExtension.js
|
||||
packages/editor/CodeMirror/extensions/links/referenceLinksStateField.js
|
||||
packages/editor/CodeMirror/extensions/links/utils/findLineMatchingLink.test.js
|
||||
packages/editor/CodeMirror/extensions/links/utils/findLineMatchingLink.js
|
||||
packages/editor/CodeMirror/extensions/links/utils/getUrlAtPosition.js
|
||||
packages/editor/CodeMirror/extensions/links/utils/openLink.js
|
||||
packages/editor/CodeMirror/extensions/markdownDecorationExtension.test.js
|
||||
packages/editor/CodeMirror/extensions/markdownDecorationExtension.js
|
||||
packages/editor/CodeMirror/extensions/markdownHighlightExtension.test.js
|
||||
packages/editor/CodeMirror/extensions/markdownHighlightExtension.js
|
||||
packages/editor/CodeMirror/extensions/markdownMathExtension.test.js
|
||||
packages/editor/CodeMirror/extensions/markdownMathExtension.js
|
||||
packages/editor/CodeMirror/extensions/modifierKeyCssExtension.js
|
||||
packages/editor/CodeMirror/extensions/overwriteModeExtension.test.js
|
||||
packages/editor/CodeMirror/extensions/overwriteModeExtension.js
|
||||
packages/editor/CodeMirror/extensions/rendering/addFormattingClasses.js
|
||||
packages/editor/CodeMirror/extensions/rendering/renderBlockImages.test.js
|
||||
packages/editor/CodeMirror/extensions/rendering/renderBlockImages.js
|
||||
packages/editor/CodeMirror/extensions/rendering/renderingExtension.js
|
||||
packages/editor/CodeMirror/extensions/rendering/replaceBulletLists.js
|
||||
packages/editor/CodeMirror/extensions/rendering/replaceCheckboxes.js
|
||||
packages/editor/CodeMirror/extensions/rendering/replaceDividers.js
|
||||
packages/editor/CodeMirror/extensions/rendering/replaceFormatCharacters.js
|
||||
packages/editor/CodeMirror/extensions/rendering/types.js
|
||||
packages/editor/CodeMirror/extensions/rendering/utils/makeBlockReplaceExtension.js
|
||||
packages/editor/CodeMirror/extensions/rendering/utils/makeInlineReplaceExtension.js
|
||||
packages/editor/CodeMirror/extensions/rendering/utils/nodeIntersectsSelection.js
|
||||
packages/editor/CodeMirror/extensions/searchExtension.js
|
||||
packages/editor/CodeMirror/extensions/selectedNoteIdExtension.js
|
||||
packages/editor/CodeMirror/getScrollFraction.js
|
||||
|
@@ -340,6 +340,8 @@ const CodeMirror = (props: NoteBodyEditorProps, ref: ForwardedRef<NoteBodyEditor
|
||||
props.setShowLocalSearch(event.searchState.dialogVisible);
|
||||
}
|
||||
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]);
|
||||
|
||||
@@ -362,6 +364,8 @@ const CodeMirror = (props: NoteBodyEditorProps, ref: ForwardedRef<NoteBodyEditor
|
||||
readOnly: props.disabled,
|
||||
markdownMarkEnabled: Setting.value('markdown.plugin.mark'),
|
||||
katexEnabled: Setting.value('markdown.plugin.katex'),
|
||||
inlineRenderingEnabled: Setting.value('editor.inlineRendering'),
|
||||
imageRenderingEnabled: Setting.value('editor.imageRendering'),
|
||||
themeData: {
|
||||
...styles.globalTheme,
|
||||
marginLeft: 0,
|
||||
|
@@ -15,6 +15,10 @@ import useEditorSearch from '../utils/useEditorSearchExtension';
|
||||
import CommandService from '@joplin/lib/services/CommandService';
|
||||
import { SearchMarkers } from '../../../utils/useSearchMarkers';
|
||||
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 {
|
||||
style: React.CSSProperties;
|
||||
@@ -104,7 +108,15 @@ const Editor = (props: Props, ref: ForwardedRef<CodeMirrorControl>) => {
|
||||
onLogMessage: message => onLogMessageRef.current(message),
|
||||
};
|
||||
|
||||
const editor = createEditor(editorContainerRef.current, editorProps);
|
||||
const editor = createEditor(editorContainerRef.current, {
|
||||
...editorProps,
|
||||
resolveImageSrc: async src => {
|
||||
const url = parseResourceUrl(src);
|
||||
if (!url.itemId) return null;
|
||||
const item = await Resource.load(url.itemId);
|
||||
return `${getResourceBaseUrl()}/${resourceFilename(item)}`;
|
||||
},
|
||||
});
|
||||
editor.addStyles({
|
||||
'.cm-scroller': { overflow: 'auto' },
|
||||
'&.CodeMirror': {
|
||||
|
@@ -56,6 +56,7 @@ import useResourceUnwatcher from './utils/useResourceUnwatcher';
|
||||
import StatusBar from './StatusBar';
|
||||
import useVisiblePluginEditorViewIds from '@joplin/lib/hooks/plugins/useVisiblePluginEditorViewIds';
|
||||
import useConnectToEditorPlugin from './utils/useConnectToEditorPlugin';
|
||||
import getResourceBaseUrl from './utils/getResourceBaseUrl';
|
||||
|
||||
const debounce = require('debounce');
|
||||
|
||||
@@ -169,7 +170,7 @@ function NoteEditorContent(props: NoteEditorProps) {
|
||||
const theme = themeStyle(options.themeId ? options.themeId : props.themeId);
|
||||
|
||||
const markupToHtml = markupLanguageUtils.newMarkupToHtml(props.plugins, {
|
||||
resourceBaseUrl: `joplin-content://note-viewer/${Setting.value('resourceDir')}/`,
|
||||
resourceBaseUrl: getResourceBaseUrl(),
|
||||
customCss: props.customCss,
|
||||
});
|
||||
|
||||
|
@@ -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'),
|
||||
katexEnabled: Setting.value('markdown.plugin.katex'),
|
||||
spellcheckEnabled: Setting.value('editor.mobile.spellcheckEnabled'),
|
||||
inlineRenderingEnabled: Setting.value('editor.inlineRendering'),
|
||||
imageRenderingEnabled: Setting.value('editor.imageRendering'),
|
||||
language: props.markupLanguage === MarkupLanguage.Html ? EditorLanguageType.Html : EditorLanguageType.Markdown,
|
||||
useExternalSearch: true,
|
||||
readOnly: props.readOnly,
|
||||
|
@@ -37,6 +37,9 @@ export const initializeEditor = ({
|
||||
onEvent: (event): void => {
|
||||
void messenger.remoteApi.onEditorEvent(event);
|
||||
},
|
||||
resolveImageSrc: (src) => {
|
||||
return messenger.remoteApi.onResolveImageSrc(src);
|
||||
},
|
||||
});
|
||||
|
||||
// Works around https://github.com/laurent22/joplin/issues/10047 by handling
|
||||
|
@@ -22,4 +22,5 @@ export interface MainProcessApi {
|
||||
onEditorEvent(event: EditorEvent): Promise<void>;
|
||||
logMessage(message: 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 Logger from '@joplin/utils/Logger';
|
||||
import RNToWebViewMessenger from '../../utils/ipc/RNToWebViewMessenger';
|
||||
import Resource from '@joplin/lib/models/Resource';
|
||||
import { parseResourceUrl } from '@joplin/lib/urlUtils';
|
||||
const { isImageMimeType } = require('@joplin/lib/resourceUtils');
|
||||
|
||||
const logger = Logger.create('markdownEditor');
|
||||
|
||||
@@ -109,6 +112,23 @@ const useWebViewSetup = ({
|
||||
async onPasteFile(type, data) {
|
||||
onAttachRef.current(type, data);
|
||||
},
|
||||
async onResolveImageSrc(src) {
|
||||
const url = parseResourceUrl(src);
|
||||
if (!url.itemId) return null;
|
||||
const item = await Resource.load(url.itemId);
|
||||
|
||||
if (shim.mobilePlatform() === 'web') {
|
||||
// Maximum 6 MiB on web
|
||||
const maximumSize = 6 * 1024 * 1024;
|
||||
if (isImageMimeType(item.mime) && item.size < maximumSize) {
|
||||
const data = await shim.fsDriver().readFile(Resource.fullPath(item), 'base64');
|
||||
return `data:${item.mime};base64,${data}`;
|
||||
}
|
||||
return null;
|
||||
} else {
|
||||
return Resource.fullPath(item);
|
||||
}
|
||||
},
|
||||
};
|
||||
const messenger = new RNToWebViewMessenger<MainProcessApi, EditorProcessApi>(
|
||||
'markdownEditor', webviewRef, localApi,
|
||||
|
@@ -14,8 +14,10 @@ import { vim } from '@replit/codemirror-vim';
|
||||
import { indentUnit } from '@codemirror/language';
|
||||
import { Prec } from '@codemirror/state';
|
||||
import insertNewlineContinueMarkup from './editorCommands/insertNewlineContinueMarkup';
|
||||
import renderingExtension from './extensions/rendering/renderingExtension';
|
||||
import { RenderedContentContext } from './extensions/rendering/types';
|
||||
|
||||
const configFromSettings = (settings: EditorSettings) => {
|
||||
const configFromSettings = (settings: EditorSettings, context: RenderedContentContext) => {
|
||||
const languageExtension = (() => {
|
||||
const openingBrackets = '`([{\'"‘“(《「『【〔〖〘〚'.split('');
|
||||
|
||||
@@ -84,6 +86,12 @@ const configFromSettings = (settings: EditorSettings) => {
|
||||
extensions.push(Prec.low(keymap.of(defaultKeymap)));
|
||||
}
|
||||
|
||||
if (settings.inlineRenderingEnabled) {
|
||||
extensions.push(renderingExtension(context, {
|
||||
renderImages: settings.imageRenderingEnabled,
|
||||
}));
|
||||
}
|
||||
|
||||
return extensions;
|
||||
};
|
||||
|
||||
|
@@ -42,6 +42,7 @@ describe('createEditor', () => {
|
||||
onLogMessage: _message => {},
|
||||
onLocalize: input => input,
|
||||
onPasteFile: null,
|
||||
resolveImageSrc: src => Promise.resolve(src),
|
||||
});
|
||||
|
||||
// Force the generation of the syntax tree now.
|
||||
@@ -72,6 +73,7 @@ describe('createEditor', () => {
|
||||
onLogMessage: _message => {},
|
||||
onLocalize: input => input,
|
||||
onPasteFile: null,
|
||||
resolveImageSrc: src=>Promise.resolve(src),
|
||||
});
|
||||
|
||||
const getContentScriptJs = jest.fn(async () => {
|
||||
@@ -142,6 +144,7 @@ describe('createEditor', () => {
|
||||
onLogMessage: _message => {},
|
||||
onLocalize: input => input,
|
||||
onPasteFile: null,
|
||||
resolveImageSrc: src=>Promise.resolve(src),
|
||||
});
|
||||
|
||||
const getContentScriptJs = jest.fn(async () => {
|
||||
@@ -193,6 +196,7 @@ describe('createEditor', () => {
|
||||
onLogMessage: () => {},
|
||||
onLocalize: input => input,
|
||||
onPasteFile: null,
|
||||
resolveImageSrc: src=>Promise.resolve(src),
|
||||
});
|
||||
const editorState = editor.editor.state;
|
||||
const idFacet = editor.joplinExtensions.noteIdFacet;
|
||||
|
@@ -36,6 +36,9 @@ import isCursorAtBeginning from './utils/isCursorAtBeginning';
|
||||
import overwriteModeExtension from './extensions/overwriteModeExtension';
|
||||
import handleLinkEditRequests, { showLinkEditor } from './utils/handleLinkEditRequests';
|
||||
import selectedNoteIdExtension, { setNoteIdEffect } from './extensions/selectedNoteIdExtension';
|
||||
import ctrlKeyStateClassExtension from './extensions/modifierKeyCssExtension';
|
||||
import ctrlClickLinksExtension from './extensions/links/ctrlClickLinksExtension';
|
||||
import { RenderedContentContext } from './extensions/rendering/types';
|
||||
|
||||
// Newer versions of CodeMirror by default use Chrome's EditContext API.
|
||||
// While this might be stable enough for desktop use, it causes significant
|
||||
@@ -47,14 +50,26 @@ import selectedNoteIdExtension, { setNoteIdEffect } from './extensions/selectedN
|
||||
type ExtendedEditorView = typeof EditorView & { EDIT_CONTEXT: boolean };
|
||||
(EditorView as ExtendedEditorView).EDIT_CONTEXT = false;
|
||||
|
||||
export type ResolveImageCallback = (imageSrc: string)=> Promise<string>;
|
||||
|
||||
interface CodeMirrorProps {
|
||||
resolveImageSrc: ResolveImageCallback;
|
||||
}
|
||||
|
||||
const createEditor = (
|
||||
parentElement: HTMLElement, props: EditorProps,
|
||||
parentElement: HTMLElement, props: EditorProps&CodeMirrorProps,
|
||||
): CodeMirrorControl => {
|
||||
const initialText = props.initialText;
|
||||
let settings = props.settings;
|
||||
|
||||
props.onLogMessage('Initializing CodeMirror...');
|
||||
|
||||
const context: RenderedContentContext = {
|
||||
resolveImageSrc: (src) => {
|
||||
return props.resolveImageSrc(src);
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
// Handles firing an event when the undo/redo stack changes
|
||||
let schedulePostUndoRedoDepthChangeId_: ReturnType<typeof setTimeout>|null = null;
|
||||
@@ -228,7 +243,7 @@ const createEditor = (
|
||||
extensions: [
|
||||
keymapConfig,
|
||||
|
||||
dynamicConfig.of(configFromSettings(props.settings)),
|
||||
dynamicConfig.of(configFromSettings(props.settings, context)),
|
||||
historyCompartment.of(history()),
|
||||
searchExtension(props.onEvent, props.settings),
|
||||
|
||||
@@ -237,6 +252,9 @@ const createEditor = (
|
||||
EditorState.allowMultipleSelections.of(true),
|
||||
rectangularSelection(),
|
||||
drawSelection(),
|
||||
ctrlClickLinksExtension(link => {
|
||||
props.onEvent({ kind: EditorEventType.FollowLink, link });
|
||||
}),
|
||||
|
||||
highlightSpecialChars(),
|
||||
indentOnInput(),
|
||||
@@ -274,6 +292,7 @@ const createEditor = (
|
||||
|
||||
biDirectionalTextExtension,
|
||||
overwriteModeExtension,
|
||||
ctrlKeyStateClassExtension,
|
||||
|
||||
selectedNoteIdExtension,
|
||||
|
||||
@@ -320,7 +339,7 @@ const createEditor = (
|
||||
settings = newSettings;
|
||||
editor.dispatch({
|
||||
effects: dynamicConfig.reconfigure(
|
||||
configFromSettings(newSettings),
|
||||
configFromSettings(newSettings, context),
|
||||
),
|
||||
});
|
||||
},
|
||||
|
@@ -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 => {},
|
||||
onLogMessage: _message => {},
|
||||
onPasteFile: null,
|
||||
resolveImageSrc: (src)=>Promise.resolve(src),
|
||||
onLocalize: input=>input,
|
||||
});
|
||||
};
|
||||
|
@@ -13,6 +13,7 @@ const createEditorSettings = (themeId: number) => {
|
||||
ignoreModifiers: false,
|
||||
autocompleteMarkup: true,
|
||||
tabMovesFocus: false,
|
||||
inlineRenderingEnabled: true,
|
||||
|
||||
keymap: EditorKeymap.Default,
|
||||
language: EditorLanguageType.Markdown,
|
||||
@@ -20,6 +21,7 @@ const createEditorSettings = (themeId: number) => {
|
||||
|
||||
indentWithTabs: true,
|
||||
editorLabel: 'Markdown editor',
|
||||
imageRenderingEnabled: false,
|
||||
};
|
||||
|
||||
return editorSettings;
|
||||
|
@@ -179,6 +179,8 @@ export interface EditorSettings {
|
||||
markdownMarkEnabled: boolean;
|
||||
katexEnabled: boolean;
|
||||
spellcheckEnabled: boolean;
|
||||
inlineRenderingEnabled: boolean;
|
||||
imageRenderingEnabled: boolean;
|
||||
readOnly: boolean;
|
||||
|
||||
indentWithTabs: boolean;
|
||||
|
@@ -1455,6 +1455,27 @@ const builtInMetadata = (Setting: typeof SettingType) => {
|
||||
isGlobal: true,
|
||||
},
|
||||
|
||||
'editor.inlineRendering': {
|
||||
value: true,
|
||||
type: SettingItemType.Bool,
|
||||
public: true,
|
||||
appTypes: [AppType.Desktop, AppType.Mobile],
|
||||
label: () => _('Markdown editor: Render markup in editor'),
|
||||
description: () => _('Renders markup on all lines that don\'t include the cursor.'),
|
||||
section: 'note',
|
||||
storage: SettingStorage.File,
|
||||
},
|
||||
'editor.imageRendering': {
|
||||
value: true,
|
||||
type: SettingItemType.Bool,
|
||||
public: true,
|
||||
appTypes: [AppType.Desktop, AppType.Mobile],
|
||||
label: () => _('Markdown editor: Render images'),
|
||||
description: () => _('If an image attachment is on its own line and followed by a blank line, it will be rendered just below its Markdown source.'),
|
||||
section: 'note',
|
||||
storage: SettingStorage.File,
|
||||
},
|
||||
|
||||
'imageeditor.jsdrawToolbar': {
|
||||
value: '',
|
||||
type: SettingItemType.String,
|
||||
|
@@ -187,6 +187,7 @@ fuzzer
|
||||
Freespinny
|
||||
BestEtf
|
||||
Etf
|
||||
currentcolor
|
||||
prosemirror
|
||||
gapcursor
|
||||
dropcursor
|
||||
|
Reference in New Issue
Block a user