testrendered', + // Block + '
testrendered
Test: test
rendered
test sourcerendered'); + 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('
test sourcerendered
${htmlentities(source)}
Mocked!
`, + } 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!'); + }); +}); diff --git a/packages/editor/ProseMirror/plugins/joplinEditablePlugin/joplinEditablePlugin.ts b/packages/editor/ProseMirror/plugins/joplinEditablePlugin/joplinEditablePlugin.ts new file mode 100644 index 0000000000..fd969b065f --- /dev/null +++ b/packages/editor/ProseMirror/plugins/joplinEditablePlugin/joplinEditablePlugin.ts @@ -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; diff --git a/packages/editor/ProseMirror/plugins/joplinEditablePlugin/postProcessRenderedHtml.ts b/packages/editor/ProseMirror/plugins/joplinEditablePlugin/postProcessRenderedHtml.ts new file mode 100644 index 0000000000..5dcf449f22 --- /dev/null +++ b/packages/editor/ProseMirror/plugins/joplinEditablePlugin/postProcessRenderedHtml.ts @@ -0,0 +1,42 @@ +// If renderedHtml is an HTMLElement, the content is modified in-place. +const postProcessRenderedHtml =