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

Mobile: Resolves #12841: Allow editing code blocks from the Rich Text Editor (#12906)

This commit is contained in:
Henry Heino
2025-08-07 02:18:09 -07:00
committed by GitHub
parent 0312f2213d
commit 6704ab0d13
35 changed files with 607 additions and 111 deletions

View File

@@ -1059,7 +1059,10 @@ packages/editor/ProseMirror/commands.js
packages/editor/ProseMirror/createEditor.js packages/editor/ProseMirror/createEditor.js
packages/editor/ProseMirror/index.js packages/editor/ProseMirror/index.js
packages/editor/ProseMirror/plugins/inputRulesPlugin.js packages/editor/ProseMirror/plugins/inputRulesPlugin.js
packages/editor/ProseMirror/plugins/joplinEditablePlugin.js packages/editor/ProseMirror/plugins/joplinEditablePlugin/createEditorDialog.js
packages/editor/ProseMirror/plugins/joplinEditablePlugin/joplinEditablePlugin.test.js
packages/editor/ProseMirror/plugins/joplinEditablePlugin/joplinEditablePlugin.js
packages/editor/ProseMirror/plugins/joplinEditablePlugin/postProcessRenderedHtml.js
packages/editor/ProseMirror/plugins/joplinEditorApiPlugin.js packages/editor/ProseMirror/plugins/joplinEditorApiPlugin.js
packages/editor/ProseMirror/plugins/keymapPlugin.js packages/editor/ProseMirror/plugins/keymapPlugin.js
packages/editor/ProseMirror/plugins/linkTooltipPlugin.test.js packages/editor/ProseMirror/plugins/linkTooltipPlugin.test.js
@@ -1075,6 +1078,9 @@ packages/editor/ProseMirror/types.js
packages/editor/ProseMirror/utils/UndoStackSynchronizer.js packages/editor/ProseMirror/utils/UndoStackSynchronizer.js
packages/editor/ProseMirror/utils/canReplaceSelectionWith.js packages/editor/ProseMirror/utils/canReplaceSelectionWith.js
packages/editor/ProseMirror/utils/computeSelectionFormatting.js packages/editor/ProseMirror/utils/computeSelectionFormatting.js
packages/editor/ProseMirror/utils/dom/createTextArea.js
packages/editor/ProseMirror/utils/dom/createTextNode.js
packages/editor/ProseMirror/utils/dom/createUniqueId.js
packages/editor/ProseMirror/utils/extractSelectedLinesTo.test.js packages/editor/ProseMirror/utils/extractSelectedLinesTo.test.js
packages/editor/ProseMirror/utils/extractSelectedLinesTo.js packages/editor/ProseMirror/utils/extractSelectedLinesTo.js
packages/editor/ProseMirror/utils/jumpToHash.js packages/editor/ProseMirror/utils/jumpToHash.js

8
.gitignore vendored
View File

@@ -1032,7 +1032,10 @@ packages/editor/ProseMirror/commands.js
packages/editor/ProseMirror/createEditor.js packages/editor/ProseMirror/createEditor.js
packages/editor/ProseMirror/index.js packages/editor/ProseMirror/index.js
packages/editor/ProseMirror/plugins/inputRulesPlugin.js packages/editor/ProseMirror/plugins/inputRulesPlugin.js
packages/editor/ProseMirror/plugins/joplinEditablePlugin.js packages/editor/ProseMirror/plugins/joplinEditablePlugin/createEditorDialog.js
packages/editor/ProseMirror/plugins/joplinEditablePlugin/joplinEditablePlugin.test.js
packages/editor/ProseMirror/plugins/joplinEditablePlugin/joplinEditablePlugin.js
packages/editor/ProseMirror/plugins/joplinEditablePlugin/postProcessRenderedHtml.js
packages/editor/ProseMirror/plugins/joplinEditorApiPlugin.js packages/editor/ProseMirror/plugins/joplinEditorApiPlugin.js
packages/editor/ProseMirror/plugins/keymapPlugin.js packages/editor/ProseMirror/plugins/keymapPlugin.js
packages/editor/ProseMirror/plugins/linkTooltipPlugin.test.js packages/editor/ProseMirror/plugins/linkTooltipPlugin.test.js
@@ -1048,6 +1051,9 @@ packages/editor/ProseMirror/types.js
packages/editor/ProseMirror/utils/UndoStackSynchronizer.js packages/editor/ProseMirror/utils/UndoStackSynchronizer.js
packages/editor/ProseMirror/utils/canReplaceSelectionWith.js packages/editor/ProseMirror/utils/canReplaceSelectionWith.js
packages/editor/ProseMirror/utils/computeSelectionFormatting.js packages/editor/ProseMirror/utils/computeSelectionFormatting.js
packages/editor/ProseMirror/utils/dom/createTextArea.js
packages/editor/ProseMirror/utils/dom/createTextNode.js
packages/editor/ProseMirror/utils/dom/createUniqueId.js
packages/editor/ProseMirror/utils/extractSelectedLinesTo.test.js packages/editor/ProseMirror/utils/extractSelectedLinesTo.test.js
packages/editor/ProseMirror/utils/extractSelectedLinesTo.js packages/editor/ProseMirror/utils/extractSelectedLinesTo.js
packages/editor/ProseMirror/utils/jumpToHash.js packages/editor/ProseMirror/utils/jumpToHash.js

View File

@@ -410,6 +410,7 @@ const CodeMirror = (props: NoteBodyEditorProps, ref: ForwardedRef<NoteBodyEditor
onSelectPastBeginning={onSelectPastBeginning} onSelectPastBeginning={onSelectPastBeginning}
externalSearch={props.searchMarkers} externalSearch={props.searchMarkers}
useLocalSearch={props.useLocalSearch} useLocalSearch={props.useLocalSearch}
onLocalize={_}
/> />
</div> </div>
); );

View File

@@ -132,6 +132,7 @@ const useRerenderHandler = (props: Props) => {
highlightedKeywords: props.highlightedKeywords, highlightedKeywords: props.highlightedKeywords,
resources: props.noteResources, resources: props.noteResources,
pluginAssetContainerSelector: '#joplin-container-pluginAssetsContainer', pluginAssetContainerSelector: '#joplin-container-pluginAssetsContainer',
removeUnusedPluginAssets: true,
// If the hash changed, we don't set initial scroll -- we want to scroll to the hash // If the hash changed, we don't set initial scroll -- we want to scroll to the hash
// instead. // instead.

View File

@@ -121,6 +121,7 @@ const MarkdownEditor: React.FC<EditorProps> = props => {
initialText: props.initialText, initialText: props.initialText,
initialNoteId: props.noteId, initialNoteId: props.noteId,
settings: props.editorSettings, settings: props.editorSettings,
onLocalize: _,
}, },
webviewRef, webviewRef,
}); });

View File

@@ -11,6 +11,7 @@ export const initializeEditor = ({
initialText, initialText,
initialNoteId, initialNoteId,
settings, settings,
onLocalize,
}: EditorProps) => { }: EditorProps) => {
const messenger = new WebViewToRNMessenger<EditorProcessApi, MainProcessApi>('markdownEditor', null); const messenger = new WebViewToRNMessenger<EditorProcessApi, MainProcessApi>('markdownEditor', null);
@@ -23,6 +24,7 @@ export const initializeEditor = ({
initialText, initialText,
initialNoteId, initialNoteId,
settings, settings,
onLocalize,
onPasteFile: async (data) => { onPasteFile: async (data) => {
const base64 = await readFileToBase64(data); const base64 = await readFileToBase64(data);

View File

@@ -1,5 +1,5 @@
import { EditorEvent } from '@joplin/editor/events'; import { EditorEvent } from '@joplin/editor/events';
import { EditorControl, EditorSettings } from '@joplin/editor/types'; import { EditorControl, EditorSettings, OnLocalize } from '@joplin/editor/types';
export interface EditorProcessApi { export interface EditorProcessApi {
editor: EditorControl; editor: EditorControl;
@@ -14,6 +14,7 @@ export interface EditorProps {
parentElementClassName: string; parentElementClassName: string;
initialText: string; initialText: string;
initialNoteId: string; initialNoteId: string;
onLocalize: OnLocalize;
settings: EditorSettings; settings: EditorSettings;
} }

View File

@@ -12,6 +12,7 @@ const defaultRendererSettings: RenderSettings = {
noteHash: '', noteHash: '',
initialScroll: 0, initialScroll: 0,
readAssetBlob: async (_path: string) => new Blob(), readAssetBlob: async (_path: string) => new Blob(),
removeUnusedPluginAssets: true,
createEditPopupSyntax: '', createEditPopupSyntax: '',
destroyEditPopupSyntax: '', destroyEditPopupSyntax: '',

View File

@@ -23,6 +23,7 @@ export interface RenderSettings {
initialScroll: number; initialScroll: number;
// If [null], plugin assets are not added to the document // If [null], plugin assets are not added to the document
pluginAssetContainerSelector: string|null; pluginAssetContainerSelector: string|null;
removeUnusedPluginAssets: boolean;
splitted?: boolean; // Move CSS into a separate output splitted?: boolean; // Move CSS into a separate output
mapsToLine?: boolean; // Sourcemaps mapsToLine?: boolean; // Sourcemaps
@@ -156,6 +157,7 @@ export default class Renderer {
inlineAssets: this.setupOptions_.useTransferredFiles, inlineAssets: this.setupOptions_.useTransferredFiles,
readAssetBlob: settings.readAssetBlob, readAssetBlob: settings.readAssetBlob,
container: document.querySelector(settings.pluginAssetContainerSelector), container: document.querySelector(settings.pluginAssetContainerSelector),
removeUnusedPluginAssets: settings.removeUnusedPluginAssets,
}); });
// Some plugins require this event to be dispatched just after being added. // Some plugins require this event to be dispatched just after being added.

View File

@@ -39,6 +39,7 @@ const rewriteInternalAssetLinks = async (asset: RenderResultPluginAsset, content
interface Options { interface Options {
inlineAssets: boolean; inlineAssets: boolean;
removeUnusedPluginAssets: boolean;
container: HTMLElement; container: HTMLElement;
readAssetBlob?(path: string): Promise<Blob>; readAssetBlob?(path: string): Promise<Blob>;
} }
@@ -137,16 +138,22 @@ const addPluginAssets = async (assets: RenderResultPluginAsset[], options: Optio
// light to dark theme, and then back to light theme - in that case // light to dark theme, and then back to light theme - in that case
// the viewer would remain dark because it would use the dark // the viewer would remain dark because it would use the dark
// stylesheet that would still be in the DOM. // stylesheet that would still be in the DOM.
for (const [assetId, asset] of Object.entries(pluginAssetsAdded_)) { //
if (!processedAssetIds.includes(assetId)) { // In some cases, however, we only want to rerender part of the document.
try { // In this case, old plugin assets may have been from the last full-page
asset.element.remove(); // render and should not be removed.
} catch (error) { if (options.removeUnusedPluginAssets) {
// We don't throw an exception but we log it since for (const [assetId, asset] of Object.entries(pluginAssetsAdded_)) {
// it shouldn't happen if (!processedAssetIds.includes(assetId)) {
console.warn('Tried to remove an asset but got an error', error); try {
asset.element.remove();
} catch (error) {
// We don't throw an exception but we log it since
// it shouldn't happen
console.warn('Tried to remove an asset but got an error', error);
}
pluginAssetsAdded_[assetId] = null;
} }
pluginAssetsAdded_[assetId] = null;
} }
} }
}; };

View File

@@ -54,8 +54,13 @@ export interface RenderOptions {
highlightedKeywords: string[]; highlightedKeywords: string[];
resources: ResourceInfos; resources: ResourceInfos;
themeOverrides: Record<string, string|number>; themeOverrides: Record<string, string|number>;
// If null, plugin assets will not be added to the document. // If null, plugin assets will not be added to the document.
pluginAssetContainerSelector: string|null; pluginAssetContainerSelector: string|null;
// When true, plugin assets are removed from the container when not used by the render result.
// This should be true for full-page renders.
removeUnusedPluginAssets: boolean;
noteHash: string; noteHash: string;
initialScroll: number; initialScroll: number;

View File

@@ -219,6 +219,7 @@ const useWebViewSetup = (props: Props): SetUpResult<RendererControl> => {
} }
return shim.fsDriver().fileAtPath(resolvedPath); return shim.fsDriver().fileAtPath(resolvedPath);
}, },
removeUnusedPluginAssets: options.removeUnusedPluginAssets,
}; };
await transferResources(options.resources); await transferResources(options.resources);

View File

@@ -16,22 +16,6 @@ const postprocessHtml = (html: HTMLElement) => {
resource.src = `:/${resourceId}`; resource.src = `:/${resourceId}`;
} }
// Re-add newlines to data-joplin-source-* that were removed
// by ProseMirror.
// TODO: Try to find a better solution
const sourceBlocks = html.querySelectorAll<HTMLPreElement>(
'pre[data-joplin-source-open][data-joplin-source-close].joplin-source',
);
for (const sourceBlock of sourceBlocks) {
const isBlock = sourceBlock.parentElement.tagName !== 'SPAN';
if (isBlock) {
const originalOpen = sourceBlock.getAttribute('data-joplin-source-open');
const originalClose = sourceBlock.getAttribute('data-joplin-source-close');
sourceBlock.setAttribute('data-joplin-source-open', `${originalOpen}\n`);
sourceBlock.setAttribute('data-joplin-source-close', `\n${originalClose}`);
}
}
return html; return html;
}; };
@@ -73,6 +57,7 @@ export const initialize = async ({
settings, settings,
initialText, initialText,
initialNoteId, initialNoteId,
onLocalize: messenger.remoteApi.onLocalize,
onPasteFile: async (data) => { onPasteFile: async (data) => {
const base64 = await readFileToBase64(data); const base64 = await readFileToBase64(data);
@@ -85,14 +70,20 @@ export const initialize = async ({
void messenger.remoteApi.onEditorEvent(event); void messenger.remoteApi.onEditorEvent(event);
}, },
}, { }, {
renderMarkupToHtml: async (markup) => { renderMarkupToHtml: async (markup, options) => {
let language = MarkupLanguage.Markdown;
if (settings.language === EditorLanguageType.Html && !options.forceMarkdown) {
language = MarkupLanguage.Html;
}
return await messenger.remoteApi.onRender({ return await messenger.remoteApi.onRender({
markup, markup,
language: settings.language === EditorLanguageType.Html ? MarkupLanguage.Html : MarkupLanguage.Markdown, language,
}, { }, {
pluginAssetContainerSelector: `#${assetContainer.id}`, pluginAssetContainerSelector: `#${assetContainer.id}`,
splitted: true, splitted: true,
mapsToLine: true, mapsToLine: true,
removeUnusedPluginAssets: options.isFullPageRender,
}); });
}, },
renderHtmlToMarkup: (node) => { renderHtmlToMarkup: (node) => {

View File

@@ -1,5 +1,5 @@
import { EditorEvent } from '@joplin/editor/events'; import { EditorEvent } from '@joplin/editor/events';
import { EditorControl, EditorSettings, SearchState } from '@joplin/editor/types'; import { EditorControl, EditorSettings, OnLocalize, SearchState } from '@joplin/editor/types';
import { MarkupRecord, RendererControl } from '../rendererBundle/types'; import { MarkupRecord, RendererControl } from '../rendererBundle/types';
import { RenderResult } from '@joplin/renderer/types'; import { RenderResult } from '@joplin/renderer/types';
@@ -19,6 +19,7 @@ type RenderOptionsSlice = {
pluginAssetContainerSelector: string; pluginAssetContainerSelector: string;
splitted: boolean; splitted: boolean;
mapsToLine: true; mapsToLine: true;
removeUnusedPluginAssets: boolean;
}; };
export interface MainProcessApi { export interface MainProcessApi {
@@ -26,6 +27,7 @@ export interface MainProcessApi {
logMessage(message: string): Promise<void>; logMessage(message: string): Promise<void>;
onRender(markup: MarkupRecord, options: RenderOptionsSlice): Promise<RenderResult>; onRender(markup: MarkupRecord, options: RenderOptionsSlice): Promise<RenderResult>;
onPasteFile(type: string, base64: string): Promise<void>; onPasteFile(type: string, base64: string): Promise<void>;
onLocalize: OnLocalize;
} }
export interface RichTextEditorControl { export interface RichTextEditorControl {

View File

@@ -11,6 +11,7 @@ import shim from '@joplin/lib/shim';
import { PluginStates } from '@joplin/lib/services/plugins/reducer'; import { PluginStates } from '@joplin/lib/services/plugins/reducer';
import { RendererControl, RenderOptions } from '../rendererBundle/types'; import { RendererControl, RenderOptions } from '../rendererBundle/types';
import { ResourceInfos } from '@joplin/renderer/types'; import { ResourceInfos } from '@joplin/renderer/types';
import { _ } from '@joplin/lib/locale';
import { defaultSearchState } from '../../components/NoteEditor/SearchPanel'; import { defaultSearchState } from '../../components/NoteEditor/SearchPanel';
const logger = Logger.create('useWebViewSetup'); const logger = Logger.create('useWebViewSetup');
@@ -50,6 +51,7 @@ const useMessenger = (props: UseMessengerProps) => {
noteHash: '', noteHash: '',
initialScroll: 0, initialScroll: 0,
pluginAssetContainerSelector: null, pluginAssetContainerSelector: null,
removeUnusedPluginAssets: true,
}; };
return useMemo(() => { return useMemo(() => {
@@ -70,6 +72,7 @@ const useMessenger = (props: UseMessengerProps) => {
splitted: options.splitted, splitted: options.splitted,
pluginAssetContainerSelector: options.pluginAssetContainerSelector, pluginAssetContainerSelector: options.pluginAssetContainerSelector,
mapsToLine: options.mapsToLine, mapsToLine: options.mapsToLine,
removeUnusedPluginAssets: options.removeUnusedPluginAssets,
}, },
); );
return renderResult; return renderResult;
@@ -77,6 +80,7 @@ const useMessenger = (props: UseMessengerProps) => {
onPasteFile: async (type: string, base64: string) => { onPasteFile: async (type: string, base64: string) => {
onAttachRef.current(type, base64); onAttachRef.current(type, base64);
}, },
onLocalize: _,
}; };
const messenger = new RNToWebViewMessenger<MainProcessApi, EditorProcessApi>( const messenger = new RNToWebViewMessenger<MainProcessApi, EditorProcessApi>(

View File

@@ -40,6 +40,7 @@ describe('createEditor', () => {
settings: editorSettings, settings: editorSettings,
onEvent: _event => {}, onEvent: _event => {},
onLogMessage: _message => {}, onLogMessage: _message => {},
onLocalize: input => input,
onPasteFile: null, onPasteFile: null,
}); });
@@ -69,6 +70,7 @@ describe('createEditor', () => {
settings: editorSettings, settings: editorSettings,
onEvent: _event => {}, onEvent: _event => {},
onLogMessage: _message => {}, onLogMessage: _message => {},
onLocalize: input => input,
onPasteFile: null, onPasteFile: null,
}); });
@@ -138,6 +140,7 @@ describe('createEditor', () => {
settings: editorSettings, settings: editorSettings,
onEvent: _event => {}, onEvent: _event => {},
onLogMessage: _message => {}, onLogMessage: _message => {},
onLocalize: input => input,
onPasteFile: null, onPasteFile: null,
}); });
@@ -188,6 +191,7 @@ describe('createEditor', () => {
settings: editorSettings, settings: editorSettings,
onEvent: () => {}, onEvent: () => {},
onLogMessage: () => {}, onLogMessage: () => {},
onLocalize: input => input,
onPasteFile: null, onPasteFile: null,
}); });
const editorState = editor.editor.state; const editorState = editor.editor.state;

View File

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

View File

@@ -99,7 +99,10 @@ const commands: Record<EditorCommandType, ExtendedCommand|null> = {
if (canReplaceSelectionWith(state.selection, nodeType)) { if (canReplaceSelectionWith(state.selection, nodeType)) {
void (async () => { void (async () => {
const separator = block ? '$$' : '$'; const separator = block ? '$$' : '$';
const rendered = await renderer.renderMarkupToHtml(`${separator}${selectedText}${separator}`); const rendered = await renderer.renderMarkupToHtml(`${separator}${selectedText}${separator}`, {
forceMarkdown: true,
isFullPageRender: false,
});
if (view) { if (view) {
view.pasteHTML(rendered.html); view.pasteHTML(rendered.html);

View File

@@ -11,7 +11,7 @@ import { EditorEventType } from '../events';
import UndoStackSynchronizer from './utils/UndoStackSynchronizer'; import UndoStackSynchronizer from './utils/UndoStackSynchronizer';
import computeSelectionFormatting from './utils/computeSelectionFormatting'; import computeSelectionFormatting from './utils/computeSelectionFormatting';
import { defaultSelectionFormatting, selectionFormattingEqual } from '../SelectionFormatting'; import { defaultSelectionFormatting, selectionFormattingEqual } from '../SelectionFormatting';
import joplinEditablePlugin from './plugins/joplinEditablePlugin'; import joplinEditablePlugin from './plugins/joplinEditablePlugin/joplinEditablePlugin';
import keymapExtension from './plugins/keymapPlugin'; import keymapExtension from './plugins/keymapPlugin';
import inputRulesExtension from './plugins/inputRulesPlugin'; import inputRulesExtension from './plugins/inputRulesPlugin';
import originalMarkupPlugin from './plugins/originalMarkupPlugin'; import originalMarkupPlugin from './plugins/originalMarkupPlugin';
@@ -44,7 +44,10 @@ const createEditor = async (
const { plugin: searchPlugin, updateState: updateSearchState } = searchExtension(props.onEvent); const { plugin: searchPlugin, updateState: updateSearchState } = searchExtension(props.onEvent);
const renderAndPostprocessHtml = async (markup: string) => { const renderAndPostprocessHtml = async (markup: string) => {
const renderResult = await renderer.renderMarkupToHtml(markup); const renderResult = await renderer.renderMarkupToHtml(markup, {
forceMarkdown: false,
isFullPageRender: true,
});
const dom = new DOMParser().parseFromString(renderResult.html, 'text/html'); const dom = new DOMParser().parseFromString(renderResult.html, 'text/html');
preprocessEditorInput(dom, markup); preprocessEditorInput(dom, markup);
@@ -81,10 +84,20 @@ const createEditor = async (
].flat(), ].flat(),
}); });
const cachedLocalizations = new Map<string, string|Promise<string>>();
state = state.apply( state = state.apply(
setEditorApi(state.tr, { setEditorApi(state.tr, {
onEvent: props.onEvent, onEvent: props.onEvent,
renderer, renderer,
localize: async (input: string) => {
if (cachedLocalizations.has(input)) {
return cachedLocalizations.get(input);
}
const result = props.onLocalize(input);
cachedLocalizations.set(input, result);
return result;
},
}), }),
); );

View File

@@ -1,69 +0,0 @@
import { Plugin } from 'prosemirror-state';
import { Node, NodeSpec } from 'prosemirror-model';
import { NodeView } from 'prosemirror-view';
import sanitizeHtml from '../utils/sanitizeHtml';
// See the fold example for more information about
// writing similar ProseMirror plugins:
// https://prosemirror.net/examples/fold/
const makeJoplinEditableSpec = (inline: boolean): NodeSpec => ({
group: inline ? 'inline' : 'block',
inline: inline,
draggable: true,
attrs: {
contentHtml: { default: '', validate: 'string' },
},
parseDOM: [
{
tag: `${inline ? 'span' : 'div'}.joplin-editable`,
getAttrs: node => ({
contentHtml: node.innerHTML,
}),
},
],
toDOM: node => {
const content = document.createElement(inline ? 'span' : 'div');
content.classList.add('joplin-editable');
content.innerHTML = sanitizeHtml(node.attrs.contentHtml);
return content;
},
});
export const nodeSpecs = {
joplinEditableInline: makeJoplinEditableSpec(true),
joplinEditableBlock: makeJoplinEditableSpec(false),
};
class EditableSourceBlockView implements NodeView {
public readonly dom: HTMLElement;
public constructor(node: Node, inline: boolean) {
if ((node.attrs.contentHtml ?? undefined) === undefined) {
throw new Error(`Unable to create a SourceBlockView for a node lacking contentHtml. Node: ${node}.`);
}
this.dom = document.createElement(inline ? 'span' : 'div');
this.dom.classList.add('joplin-editable');
this.dom.innerHTML = sanitizeHtml(node.attrs.contentHtml);
}
public selectNode() {
this.dom.classList.add('-selected');
}
public deselectNode() {
this.dom.classList.remove('-selected');
}
}
const joplinEditablePlugin = new Plugin({
props: {
nodeViews: {
joplinEditableInline: node => new EditableSourceBlockView(node, true),
joplinEditableBlock: node => new EditableSourceBlockView(node, false),
},
},
});
export default joplinEditablePlugin;

View File

@@ -0,0 +1,70 @@
import { focus } from '@joplin/lib/utils/focusHandler';
import createTextNode from '../../utils/dom/createTextNode';
import createTextArea from '../../utils/dom/createTextArea';
interface SourceBlockData {
start: string;
content: string;
end: string;
}
interface Options {
editorLabel: string|Promise<string>;
doneLabel: string|Promise<string>;
block: SourceBlockData;
onSave: (newContent: SourceBlockData)=> void;
}
const createEditorDialog = ({ editorLabel, doneLabel, block, onSave }: Options) => {
const dialog = document.createElement('dialog');
dialog.classList.add('editor-dialog', '-visible');
document.body.appendChild(dialog);
dialog.onclose = () => {
dialog.remove();
};
const { textArea, label: textAreaLabel } = createTextArea({
label: editorLabel,
initialContent: block.content,
onChange: (newContent) => {
block = {
...block,
content: newContent,
};
onSave(block);
},
spellCheck: false,
});
const submitButton = document.createElement('button');
submitButton.appendChild(createTextNode(doneLabel));
submitButton.classList.add('submit');
submitButton.onclick = () => {
if (dialog.close) {
dialog.close();
} else {
// .remove the dialog in browsers with limited support for
// HTMLDialogElement (and in JSDOM).
dialog.remove();
}
};
dialog.appendChild(textAreaLabel);
dialog.appendChild(textArea);
dialog.appendChild(submitButton);
// .showModal is not defined in JSDOM and some older (pre-2022) browsers
if (dialog.showModal) {
dialog.showModal();
} else {
dialog.classList.add('-fake-modal');
focus('createEditorDialog/legacy', textArea);
}
return {};
};
export default createEditorDialog;

View File

@@ -0,0 +1,102 @@
import { htmlentities } from '@joplin/utils/html';
import { RenderResult } from '../../../../renderer/types';
import createTestEditor from '../../testing/createTestEditor';
import joplinEditorApiPlugin, { getEditorApi, setEditorApi } from '../joplinEditorApiPlugin';
import joplinEditablePlugin from './joplinEditablePlugin';
import { Second } from '@joplin/utils/time';
const createEditor = (html: string) => {
return createTestEditor({
plugins: [joplinEditablePlugin, joplinEditorApiPlugin],
html,
});
};
const findEditButton = (ancestor: Element): HTMLButtonElement => {
return ancestor.querySelector('.joplin-editable > button.edit');
};
const findEditorDialog = () => {
const dialog = document.querySelector('dialog.editor-dialog');
if (!dialog) {
throw new Error('Could not find an open editor dialog.');
}
const editor = dialog.querySelector('textarea');
const submitButton = dialog.querySelector('button');
return {
dialog,
editor,
submitButton,
};
};
describe('joplinEditablePlugin', () => {
beforeEach(() => {
jest.useFakeTimers();
document.body.replaceChildren();
});
test.each([
// Inline
'<span class="joplin-editable"><pre class="joplin-source">test</pre>rendered</span>',
// Block
'<div class="joplin-editable"><pre class="joplin-source">test</pre>rendered</div>',
// Nested inline
'<p>Test: <mark><span class="joplin-editable"><pre class="joplin-source">test</pre>rendered</span></mark></p>',
])('should show an edit button on source blocks (case %#)', (htmlSource) => {
const editor = createEditor(htmlSource);
const editButton = findEditButton(editor.dom);
expect(editButton.textContent).toBe('Edit');
});
test('clicking the edit button should show an editor dialog', () => {
const editor = createEditor('<span class="joplin-editable"><pre class="joplin-source">test source</pre>rendered</span>');
const editButton = findEditButton(editor.dom);
editButton.click();
// Should show the dialog
const dialog = findEditorDialog();
expect(dialog.editor).toBeTruthy();
expect(dialog.submitButton).toBeTruthy();
});
test('editing the content of an editor dialog should update the source block', async () => {
const editor = createEditor('<div class="joplin-editable"><pre class="joplin-source">test source</pre>rendered</div>');
// Mock render functions:
editor.dispatch(setEditorApi(editor.state.tr, {
...getEditorApi(editor.state),
renderer: {
renderMarkupToHtml: jest.fn(async source => ({
html: `<pre class="joplin-source">${htmlentities(source)}</pre><p class="test-content">Mocked!</p></div>`,
} as RenderResult)),
renderHtmlToMarkup: jest.fn(),
},
}));
const editButton = findEditButton(editor.dom);
editButton.click();
const dialog = findEditorDialog();
dialog.editor.value = 'Updated!';
dialog.editor.dispatchEvent(new Event('input'));
// Should update the editor state with the new source immediately.
expect(editor.state.doc.toJSON()).toMatchObject({
content: [{
type: 'joplinEditableBlock',
attrs: {
source: 'Updated!',
},
}],
});
// Should render and update the display within a short amount of time
await jest.advanceTimersByTimeAsync(Second);
const renderedEditable = editor.dom.querySelector('.joplin-editable');
// Should render the updated content
expect(renderedEditable.querySelector('.test-content').innerHTML).toBe('Mocked!');
});
});

View File

@@ -0,0 +1,182 @@
import { Plugin } from 'prosemirror-state';
import { Node, NodeSpec } from 'prosemirror-model';
import { EditorView, NodeView } from 'prosemirror-view';
import sanitizeHtml from '../../utils/sanitizeHtml';
import createEditorDialog from './createEditorDialog';
import { getEditorApi } from '../joplinEditorApiPlugin';
import { msleep } from '@joplin/utils/time';
import createTextNode from '../../utils/dom/createTextNode';
import postProcessRenderedHtml from './postProcessRenderedHtml';
// See the fold example for more information about
// writing similar ProseMirror plugins:
// https://prosemirror.net/examples/fold/
const makeJoplinEditableSpec = (inline: boolean): NodeSpec => ({
group: inline ? 'inline' : 'block',
inline: inline,
draggable: true,
attrs: {
contentHtml: { default: '', validate: 'string' },
source: { default: '', validate: 'string' },
language: { default: '', validate: 'string' },
openCharacters: { default: '', validate: 'string' },
closeCharacters: { default: '', validate: 'string' },
},
parseDOM: [
{
tag: `${inline ? 'span' : 'div'}.joplin-editable`,
getAttrs: node => {
const sourceNode = node.querySelector('.joplin-source');
return {
contentHtml: node.innerHTML,
source: sourceNode?.textContent,
openCharacters: sourceNode?.getAttribute('data-joplin-source-open'),
closeCharacters: sourceNode?.getAttribute('data-joplin-source-close'),
language: sourceNode?.getAttribute('data-joplin-language'),
};
},
},
],
toDOM: node => {
const content = document.createElement(inline ? 'span' : 'div');
content.classList.add('joplin-editable');
content.innerHTML = sanitizeHtml(node.attrs.contentHtml);
const sourceNode = content.querySelector('.joplin-source');
if (sourceNode) {
sourceNode.textContent = node.attrs.source;
sourceNode.setAttribute('data-joplin-source-open', node.attrs.openCharacters);
sourceNode.setAttribute('data-joplin-source-close', node.attrs.closeCharacters);
}
return content;
},
});
export const nodeSpecs = {
joplinEditableInline: makeJoplinEditableSpec(true),
joplinEditableBlock: makeJoplinEditableSpec(false),
};
type GetPosition = ()=> number;
class EditableSourceBlockView implements NodeView {
public readonly dom: HTMLElement;
public constructor(private node: Node, inline: boolean, private view: EditorView, private getPosition: GetPosition) {
if ((node.attrs.contentHtml ?? undefined) === undefined) {
throw new Error(`Unable to create a SourceBlockView for a node lacking contentHtml. Node: ${node}.`);
}
this.dom = document.createElement(inline ? 'span' : 'div');
this.dom.classList.add('joplin-editable');
this.updateContent_();
}
private showEditDialog_() {
const { localize: _ } = getEditorApi(this.view.state);
let saveCounter = 0;
createEditorDialog({
doneLabel: _('Done'),
editorLabel: _('Code:'),
block: {
content: this.node.attrs.source,
start: this.node.attrs.openCharacters,
end: this.node.attrs.closeCharacters,
},
onSave: async (block) => {
this.view.dispatch(
this.view.state.tr.setNodeAttribute(
this.getPosition(), 'source', block.content,
).setNodeAttribute(
this.getPosition(), 'openCharacters', block.start,
).setNodeAttribute(
this.getPosition(), 'closeCharacters', block.end,
),
);
saveCounter ++;
const initialSaveCounter = saveCounter;
const cancelled = () => saveCounter !== initialSaveCounter;
// Debounce rendering
await msleep(400);
if (cancelled()) return;
const rendered = await getEditorApi(this.view.state).renderer.renderMarkupToHtml(
`${block.start}${block.content}${block.end}`,
{ forceMarkdown: true, isFullPageRender: false },
);
if (cancelled()) return;
const html = postProcessRenderedHtml(rendered.html, this.node.isInline);
this.view.dispatch(
this.view.state.tr.setNodeAttribute(
this.getPosition(), 'contentHtml', html,
),
);
},
});
}
private updateContent_() {
const setDomContentSafe = (html: string) => {
this.dom.innerHTML = sanitizeHtml(html);
};
const addEditButton = () => {
const editButton = document.createElement('button');
editButton.classList.add('edit');
const { localize: _ } = getEditorApi(this.view.state);
editButton.appendChild(createTextNode(_('Edit')));
editButton.onclick = (event) => {
this.showEditDialog_();
event.preventDefault();
};
this.dom.appendChild(editButton);
};
setDomContentSafe(this.node.attrs.contentHtml);
postProcessRenderedHtml(this.dom, this.node.isInline);
addEditButton();
}
public selectNode() {
this.dom.classList.add('-selected');
}
public deselectNode() {
this.dom.classList.remove('-selected');
}
public stopEvent(event: Event) {
// Allow using the keyboard to activate the "edit" button:
return event.target === this.dom.querySelector('button.edit');
}
public update(node: Node) {
if (node.type.spec !== this.node.type.spec) {
return false;
}
this.node = node;
this.updateContent_();
return true;
}
}
const joplinEditablePlugin = new Plugin({
props: {
nodeViews: {
joplinEditableInline: (node, view, getPos) => new EditableSourceBlockView(node, true, view, getPos),
joplinEditableBlock: (node, view, getPos) => new EditableSourceBlockView(node, false, view, getPos),
},
},
});
export default joplinEditablePlugin;

View File

@@ -0,0 +1,42 @@
// If renderedHtml is an HTMLElement, the content is modified in-place.
const postProcessRenderedHtml = <InputType extends string|HTMLElement> (renderedHtml: InputType, isInline: boolean): InputType => {
let rootElement;
if (typeof renderedHtml === 'string') {
const parser = new DOMParser();
const parsed = parser.parseFromString(
`<!DOCTYPE html><html><body>${renderedHtml}</body></html>`,
'text/html',
);
rootElement = parsed.body;
} else {
rootElement = renderedHtml;
}
const replaceChildMatching = (selector: string) => {
const toReplace = [...rootElement.children].find(
child => child.matches(selector),
);
toReplace?.replaceWith(...toReplace.childNodes);
};
// If the original HTML is from .renderToMarkup, it may have a <div> wrapper:
replaceChildMatching('#rendered-md');
if (rootElement.children.length === 1 && isInline) {
replaceChildMatching('p, div');
}
// Remove the 'joplin-editable' container if it's the only thing in the content
// (since this.dom is itself a joplin-editable)
if (rootElement.children.length === 1) {
replaceChildMatching('.joplin-editable');
}
// Match the input type
if (typeof renderedHtml === 'string') {
return rootElement.innerHTML as InputType;
} else {
return rootElement as InputType;
}
};
export default postProcessRenderedHtml;

View File

@@ -1,10 +1,11 @@
import { EditorState, Plugin, Transaction } from 'prosemirror-state'; import { EditorState, Plugin, Transaction } from 'prosemirror-state';
import { OnEventCallback } from '../../types'; import { OnEventCallback, OnLocalize } from '../../types';
import { RendererControl } from '../types'; import { RendererControl } from '../types';
export interface EditorApi { export interface EditorApi {
renderer: RendererControl; renderer: RendererControl;
onEvent: OnEventCallback; onEvent: OnEventCallback;
localize: OnLocalize;
} }
@@ -29,6 +30,8 @@ const joplinEditorApiPlugin = new Plugin<EditorApi>({
throw new Error('Not initialized'); throw new Error('Not initialized');
}, },
}, },
settings: null,
localize: input => input,
}), }),
apply: (tr, value) => { apply: (tr, value) => {
const proposedValue = tr.getMeta(joplinEditorApiPlugin); const proposedValue = tr.getMeta(joplinEditorApiPlugin);

View File

@@ -1,5 +1,5 @@
import { AttributeSpec, DOMOutputSpec, MarkSpec, NodeSpec, Schema } from 'prosemirror-model'; import { AttributeSpec, DOMOutputSpec, MarkSpec, NodeSpec, Schema } from 'prosemirror-model';
import { nodeSpecs as joplinEditableNodes } from './plugins/joplinEditablePlugin'; import { nodeSpecs as joplinEditableNodes } from './plugins/joplinEditablePlugin/joplinEditablePlugin';
import { tableNodes } from 'prosemirror-tables'; import { tableNodes } from 'prosemirror-tables';
import { nodeSpecs as listNodes } from './plugins/listPlugin'; import { nodeSpecs as listNodes } from './plugins/listPlugin';
import { nodeSpecs as resourcePlaceholderNodes } from './plugins/resourcePlaceholderPlugin'; import { nodeSpecs as resourcePlaceholderNodes } from './plugins/resourcePlaceholderPlugin';

View File

@@ -2,7 +2,9 @@
import 'prosemirror-view/style/prosemirror.css'; import 'prosemirror-view/style/prosemirror.css';
import 'prosemirror-search/style/search.css'; import 'prosemirror-search/style/search.css';
import './styles/joplin-editable.css'; import './styles/joplin-editable.css';
import './styles/editor-dialog.css';
import './styles/prosemirror-editor.css'; import './styles/prosemirror-editor.css';
import './styles/table.css'; import './styles/table.css';
import './styles/checklist-item.css'; import './styles/checklist-item.css';
import './styles/link-tooltip.css'; import './styles/link-tooltip.css';

View File

@@ -0,0 +1,30 @@
.editor-dialog {
display: flex;
flex-direction: column;
background-color: var(--joplin-background-color);
border: none;
border-radius: 16px;
box-shadow: 0px 0px 2px var(--joplin-color);
color: var(--joplin-color);
width: min(80vw, 600px);
padding: 16px;
}
.editor-dialog > textarea {
flex-grow: 1;
min-height: min(70vh, 400px);
resize: none;
color: var(--joplin-color);
background-color: var(--joplin-background-color);
}
.editor-dialog > button {
color: var(--joplin-color);
background-color: var(--joplin-background-color3);
border: none;
border-radius: 8px;
height: 38px;
}

View File

@@ -1,4 +1,28 @@
.joplin-editable {
position: relative;
/* Override the "white-space" setting for the editor */
white-space: normal;
}
.joplin-editable > .edit {
position: absolute;
top: 0;
right: 0;
opacity: 0;
display: none;
transition-behavior: allow-discrete;
transition-duration: 0.2s;
transition-property: opacity, display;
}
.joplin-editable.-selected { .joplin-editable.-selected {
outline: 4px solid var(--joplin-text-selection-color); outline: 4px solid var(--joplin-text-selection-color);
} }
.joplin-editable.-selected > .edit {
display: block;
opacity: 0.9;
}

View File

@@ -1,6 +1,11 @@
import { RenderResult } from '../../renderer/types'; import { RenderResult } from '../../renderer/types';
export type MarkupToHtml = (markup: string)=> Promise<RenderResult>; interface MarkupToHtmlOptions {
isFullPageRender: boolean;
forceMarkdown: boolean;
}
export type MarkupToHtml = (markup: string, options: MarkupToHtmlOptions)=> Promise<RenderResult>;
export type HtmlToMarkup = (html: Node|DocumentFragment)=> string; export type HtmlToMarkup = (html: Node|DocumentFragment)=> string;
export interface RendererControl { export interface RendererControl {

View File

@@ -0,0 +1,31 @@
import { LocalizationResult } from '../../../types';
import createTextNode from './createTextNode';
import createUniqueId from './createUniqueId';
interface Options {
label: LocalizationResult;
spellCheck: boolean;
initialContent: string;
onChange: (newContent: string)=> void;
}
const createTextArea = ({ label, initialContent, spellCheck, onChange }: Options) => {
const textArea = document.createElement('textarea');
textArea.spellcheck = spellCheck;
textArea.oninput = () => {
onChange(textArea.value);
};
textArea.value = initialContent;
textArea.id = createUniqueId();
const labelElement = document.createElement('label');
labelElement.htmlFor = textArea.id;
labelElement.appendChild(createTextNode(label));
return {
label: labelElement,
textArea,
};
};
export default createTextArea;

View File

@@ -0,0 +1,11 @@
import { LocalizationResult } from '../../../types';
const createTextNode = (content: LocalizationResult) => {
const result = document.createTextNode(typeof content === 'string' ? content : '...');
void (async () => {
result.textContent = await content;
})();
return result;
};
export default createTextNode;

View File

@@ -0,0 +1,8 @@
let idCounter = 0;
const createUniqueId = () => {
return `__joplin-id-${idCounter++}`;
};
export default createUniqueId;

View File

@@ -190,6 +190,8 @@ export type LogMessageCallback = (message: string)=> void;
export type OnEventCallback = (event: EditorEvent)=> void; export type OnEventCallback = (event: EditorEvent)=> void;
export type PasteFileCallback = (data: File)=> Promise<void>; export type PasteFileCallback = (data: File)=> Promise<void>;
type OnScrollPastBeginningCallback = ()=> void; type OnScrollPastBeginningCallback = ()=> void;
export type LocalizationResult = Promise<string>|string;
export type OnLocalize = (input: string)=> LocalizationResult;
interface Localisations { interface Localisations {
[editorString: string]: string; [editorString: string]: string;
@@ -199,6 +201,7 @@ export interface EditorProps {
settings: EditorSettings; settings: EditorSettings;
initialText: string; initialText: string;
initialNoteId: string; initialNoteId: string;
onLocalize: OnLocalize;
// Used mostly for internal editor library strings // Used mostly for internal editor library strings
localisations?: Localisations; localisations?: Localisations;

View File

@@ -1,9 +1,9 @@
import * as React from 'react'; import type * as React from 'react';
import { NoteEntity, ResourceEntity } from './services/database/types'; import type { NoteEntity, ResourceEntity } from './services/database/types';
import type FsDriverBase from './fs-driver-base'; import type FsDriverBase from './fs-driver-base';
import type FileApiDriverLocal from './file-api-driver-local'; import type FileApiDriverLocal from './file-api-driver-local';
import { Crypto } from './services/e2ee/types'; import type { Crypto } from './services/e2ee/types';
import { MarkupLanguage } from '@joplin/renderer'; import type { MarkupLanguage } from '@joplin/renderer';
export interface CreateResourceFromPathOptions { export interface CreateResourceFromPathOptions {
resizeLargeImages?: 'always' | 'never' | 'ask'; resizeLargeImages?: 'always' | 'never' | 'ask';