Chore: Rich Text Editor: Refactor editor dialog to simplify toggling the dialog from external commands (#13082)

This commit is contained in:
Henry Heino
2025-08-29 23:28:11 +02:00
committed by GitHub
parent 650594ecea
commit 56ed471a2f
3 changed files with 186 additions and 62 deletions
@@ -48,10 +48,7 @@ const createEditorDialog = ({ editorApi, doneLabel, block, onSave, onDismiss }:
block.end,
].join(''));
const submitButton = document.createElement('button');
submitButton.appendChild(createTextNode(doneLabel));
submitButton.classList.add('submit');
submitButton.onclick = () => {
const onClose = () => {
if (dialog.close) {
dialog.close();
} else {
@@ -61,6 +58,11 @@ const createEditorDialog = ({ editorApi, doneLabel, block, onSave, onDismiss }:
}
};
const submitButton = document.createElement('button');
submitButton.appendChild(createTextNode(doneLabel));
submitButton.classList.add('submit');
submitButton.onclick = onClose;
dialog.appendChild(submitButton);
@@ -72,7 +74,9 @@ const createEditorDialog = ({ editorApi, doneLabel, block, onSave, onDismiss }:
focus('createEditorDialog/legacy', editor);
}
return {};
return {
dismiss: onClose,
};
};
export default createEditorDialog;
@@ -2,7 +2,7 @@ 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 joplinEditablePlugin, { editSourceBlockAt, hideSourceBlockEditor } from './joplinEditablePlugin';
import { Second } from '@joplin/utils/time';
const createEditor = (html: string) => {
@@ -19,7 +19,7 @@ const findEditButton = (ancestor: Element): HTMLButtonElement => {
const findEditorDialog = () => {
const dialog = document.querySelector('dialog.editor-dialog');
if (!dialog) {
throw new Error('Could not find an open editor dialog.');
return null;
}
const editor = dialog.querySelector('textarea');
@@ -117,4 +117,15 @@ describe('joplinEditablePlugin', () => {
hashLinks[1].click();
expect(editor.state.selection.$from.parent.textContent).toBe('Test heading 2');
});
test('should support toggling the editor dialog externally', () => {
const editor = createEditor('<div class="joplin-editable"><pre class="joplin-source">test source</pre>rendered</div>');
editSourceBlockAt(0)(editor.state, editor.dispatch, editor);
const dialog = findEditorDialog();
expect(dialog.editor).toBeTruthy();
hideSourceBlockEditor(editor.state, editor.dispatch, editor);
expect(findEditorDialog()).toBeNull();
});
});
@@ -1,4 +1,4 @@
import { Plugin } from 'prosemirror-state';
import { Command, EditorState, Plugin } from 'prosemirror-state';
import { Node, NodeSpec, TagParseRule } from 'prosemirror-model';
import { EditorView, NodeView } from 'prosemirror-view';
import sanitizeHtml from '../../utils/sanitizeHtml';
@@ -13,6 +13,116 @@ import makeLinksClickableInElement from '../../utils/makeLinksClickableInElement
// writing similar ProseMirror plugins:
// https://prosemirror.net/examples/fold/
type EditRequest = {
nodeStart: number;
showEditor: true;
} | {
nodeStart?: undefined;
showEditor: false;
};
export const editSourceBlockAt = (nodeStart: number): Command => (state, dispatch) => {
const node = state.doc.nodeAt(nodeStart);
if (node.type.name !== 'joplinEditableInline' && node.type.name !== 'joplinEditableBlock') {
return false;
}
if (dispatch) {
const editRequest: EditRequest = {
nodeStart,
showEditor: true,
};
dispatch(state.tr.setMeta(joplinEditablePlugin, editRequest));
}
return true;
};
const isSourceBlockEditorVisible = (state: EditorState) => {
return joplinEditablePlugin.getState(state).editingNodeAt !== null;
};
export const hideSourceBlockEditor: Command = (state, dispatch) => {
const isEditing = isSourceBlockEditorVisible(state);
if (!isEditing) {
return false;
}
if (dispatch) {
const editRequest: EditRequest = {
showEditor: false,
};
dispatch(state.tr.setMeta(joplinEditablePlugin, editRequest));
}
return true;
};
const createDialogForNode = (nodePosition: number, view: EditorView) => {
let saveCounter = 0;
const getNode = () => (
view.state.doc.nodeAt(nodePosition)
);
const { localize: _ } = getEditorApi(view.state);
const { dismiss } = createEditorDialog({
doneLabel: _('Done'),
editorLabel: _('Code:'),
editorApi: getEditorApi(view.state),
block: {
content: getNode().attrs.source,
start: getNode().attrs.openCharacters,
end: getNode().attrs.closeCharacters,
},
onSave: async (block) => {
view.dispatch(
view.state.tr.setNodeAttribute(
nodePosition, 'source', block.content,
).setNodeAttribute(
nodePosition, 'openCharacters', block.start,
).setNodeAttribute(
nodePosition, 'closeCharacters', block.end,
),
);
saveCounter ++;
const initialSaveCounter = saveCounter;
const cancelled = () => saveCounter !== initialSaveCounter;
// Debounce rendering
await msleep(400);
if (cancelled()) return;
const rendered = await getEditorApi(view.state).renderer.renderMarkupToHtml(
`${block.start}${block.content}${block.end}`,
{ forceMarkdown: true, isFullPageRender: false },
);
if (cancelled()) return;
const html = postProcessRenderedHtml(rendered.html, getNode().isInline);
view.dispatch(
view.state.tr.setNodeAttribute(
nodePosition, 'contentHtml', html,
),
);
},
onDismiss: () => {
hideSourceBlockEditor(view.state, view.dispatch, view);
},
});
return {
onPositionChange: (newPosition: number) => {
nodePosition = newPosition;
},
dismiss,
};
};
type DialogHandle = ReturnType<typeof createDialogForNode>;
interface JoplinEditableAttributes {
contentHtml: string;
source: string;
@@ -117,7 +227,6 @@ export const nodeSpecs = {
type GetPosition = ()=> number;
class EditableSourceBlockView implements NodeView {
private editDialogVisible_ = false;
public readonly dom: HTMLElement;
public constructor(private node: Node, inline: boolean, private view: EditorView, private getPosition: GetPosition) {
if ((node.attrs.contentHtml ?? undefined) === undefined) {
@@ -135,58 +244,7 @@ class EditableSourceBlockView implements NodeView {
}
private showEditDialog_() {
if (this.editDialogVisible_) {
return;
}
const { localize: _ } = getEditorApi(this.view.state);
let saveCounter = 0;
createEditorDialog({
doneLabel: _('Done'),
editorLabel: _('Code:'),
editorApi: getEditorApi(this.view.state),
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,
),
);
},
onDismiss: () => {
this.editDialogVisible_ = false;
},
});
editSourceBlockAt(this.getPosition())(this.view.state, this.view.dispatch, this.view);
}
private updateContent_() {
@@ -236,13 +294,64 @@ class EditableSourceBlockView implements NodeView {
}
}
const joplinEditablePlugin = new Plugin({
interface PluginState {
editingNodeAt: number|null;
}
const joplinEditablePlugin = new Plugin<PluginState>({
state: {
init: () => ({
editingNodeAt: null,
}),
apply: (tr, oldValue) => {
let editingAt = oldValue.editingNodeAt;
const editRequest: EditRequest|null = tr.getMeta(joplinEditablePlugin);
if (editRequest) {
if (editRequest.showEditor) {
editingAt = editRequest.nodeStart;
} else {
editingAt = null;
}
}
if (editingAt) {
editingAt = tr.mapping.map(editingAt, 1);
}
return { editingNodeAt: editingAt };
},
},
props: {
nodeViews: {
joplinEditableInline: (node, view, getPos) => new EditableSourceBlockView(node, true, view, getPos),
joplinEditableBlock: (node, view, getPos) => new EditableSourceBlockView(node, false, view, getPos),
},
},
view: () => {
let dialog: DialogHandle|null = null;
return {
update(view, prevState) {
const oldState = joplinEditablePlugin.getState(prevState);
const newState = joplinEditablePlugin.getState(view.state);
if (newState.editingNodeAt !== null) {
if (oldState.editingNodeAt === null) {
dialog = createDialogForNode(newState.editingNodeAt, view);
}
dialog?.onPositionChange(newState.editingNodeAt);
} else if (dialog) {
const lastDialog = dialog;
// Set dialog to null before dismissing to prevent infinite recursion.
// Dismissing the dialog can cause the editor state to update, which can
// result in this callback being re-run.
dialog = null;
lastDialog.dismiss();
}
},
};
},
});
export default joplinEditablePlugin;