mirror of
https://github.com/laurent22/joplin.git
synced 2026-06-18 20:16:34 +02:00
Chore: Rich Text Editor: Refactor editor dialog to simplify toggling the dialog from external commands (#13082)
This commit is contained in:
@@ -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;
|
||||
|
||||
+13
-2
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user