diff --git a/.eslintignore b/.eslintignore index 1f936b453e..8c6df956fb 100644 --- a/.eslintignore +++ b/.eslintignore @@ -1098,6 +1098,8 @@ packages/editor/ProseMirror/createEditor.js packages/editor/ProseMirror/index.js packages/editor/ProseMirror/plugins/detailsPlugin.test.js packages/editor/ProseMirror/plugins/detailsPlugin.js +packages/editor/ProseMirror/plugins/imagePlugin.test.js +packages/editor/ProseMirror/plugins/imagePlugin.js packages/editor/ProseMirror/plugins/inputRulesPlugin.js packages/editor/ProseMirror/plugins/joplinEditablePlugin/createEditorDialog.js packages/editor/ProseMirror/plugins/joplinEditablePlugin/joplinEditablePlugin.test.js @@ -1109,12 +1111,15 @@ packages/editor/ProseMirror/plugins/linkTooltipPlugin.test.js packages/editor/ProseMirror/plugins/linkTooltipPlugin.js packages/editor/ProseMirror/plugins/listPlugin.js packages/editor/ProseMirror/plugins/originalMarkupPlugin.js -packages/editor/ProseMirror/plugins/resourcePlaceholderPlugin.js packages/editor/ProseMirror/plugins/searchPlugin.js +packages/editor/ProseMirror/plugins/utils/createExternalEditorPlugin.js +packages/editor/ProseMirror/plugins/utils/createFloatingButtonPlugin.js packages/editor/ProseMirror/schema.js packages/editor/ProseMirror/styles.js packages/editor/ProseMirror/testing/createTestEditor.js +packages/editor/ProseMirror/testing/createTestEditorWithSerializer.js packages/editor/ProseMirror/types.js +packages/editor/ProseMirror/utils/SelectableNodeView.js packages/editor/ProseMirror/utils/UndoStackSynchronizer.js packages/editor/ProseMirror/utils/canReplaceSelectionWith.js packages/editor/ProseMirror/utils/computeSelectionFormatting.js @@ -1122,6 +1127,7 @@ packages/editor/ProseMirror/utils/dom/createButton.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/dom/showModal.js packages/editor/ProseMirror/utils/extractSelectedLinesTo.test.js packages/editor/ProseMirror/utils/extractSelectedLinesTo.js packages/editor/ProseMirror/utils/forEachHeading.js @@ -1132,6 +1138,7 @@ packages/editor/ProseMirror/utils/postprocessEditorOutput.js packages/editor/ProseMirror/utils/preprocessEditorInput.test.js packages/editor/ProseMirror/utils/preprocessEditorInput.js packages/editor/ProseMirror/utils/sanitizeHtml.js +packages/editor/ProseMirror/utils/selectFirstInstanceOfNode.js packages/editor/ProseMirror/utils/trimEmptyParagraphs.js packages/editor/ProseMirror/vendor/changedDescendants.js packages/editor/ProseMirror/vendor/splitBlockAs.js diff --git a/.gitignore b/.gitignore index a11d329095..b5e041cc54 100644 --- a/.gitignore +++ b/.gitignore @@ -1071,6 +1071,8 @@ packages/editor/ProseMirror/createEditor.js packages/editor/ProseMirror/index.js packages/editor/ProseMirror/plugins/detailsPlugin.test.js packages/editor/ProseMirror/plugins/detailsPlugin.js +packages/editor/ProseMirror/plugins/imagePlugin.test.js +packages/editor/ProseMirror/plugins/imagePlugin.js packages/editor/ProseMirror/plugins/inputRulesPlugin.js packages/editor/ProseMirror/plugins/joplinEditablePlugin/createEditorDialog.js packages/editor/ProseMirror/plugins/joplinEditablePlugin/joplinEditablePlugin.test.js @@ -1082,12 +1084,15 @@ packages/editor/ProseMirror/plugins/linkTooltipPlugin.test.js packages/editor/ProseMirror/plugins/linkTooltipPlugin.js packages/editor/ProseMirror/plugins/listPlugin.js packages/editor/ProseMirror/plugins/originalMarkupPlugin.js -packages/editor/ProseMirror/plugins/resourcePlaceholderPlugin.js packages/editor/ProseMirror/plugins/searchPlugin.js +packages/editor/ProseMirror/plugins/utils/createExternalEditorPlugin.js +packages/editor/ProseMirror/plugins/utils/createFloatingButtonPlugin.js packages/editor/ProseMirror/schema.js packages/editor/ProseMirror/styles.js packages/editor/ProseMirror/testing/createTestEditor.js +packages/editor/ProseMirror/testing/createTestEditorWithSerializer.js packages/editor/ProseMirror/types.js +packages/editor/ProseMirror/utils/SelectableNodeView.js packages/editor/ProseMirror/utils/UndoStackSynchronizer.js packages/editor/ProseMirror/utils/canReplaceSelectionWith.js packages/editor/ProseMirror/utils/computeSelectionFormatting.js @@ -1095,6 +1100,7 @@ packages/editor/ProseMirror/utils/dom/createButton.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/dom/showModal.js packages/editor/ProseMirror/utils/extractSelectedLinesTo.test.js packages/editor/ProseMirror/utils/extractSelectedLinesTo.js packages/editor/ProseMirror/utils/forEachHeading.js @@ -1105,6 +1111,7 @@ packages/editor/ProseMirror/utils/postprocessEditorOutput.js packages/editor/ProseMirror/utils/preprocessEditorInput.test.js packages/editor/ProseMirror/utils/preprocessEditorInput.js packages/editor/ProseMirror/utils/sanitizeHtml.js +packages/editor/ProseMirror/utils/selectFirstInstanceOfNode.js packages/editor/ProseMirror/utils/trimEmptyParagraphs.js packages/editor/ProseMirror/vendor/changedDescendants.js packages/editor/ProseMirror/vendor/splitBlockAs.js diff --git a/packages/app-mobile/components/NoteEditor/RichTextEditor.test.tsx b/packages/app-mobile/components/NoteEditor/RichTextEditor.test.tsx index 0a493e80a9..93521c3a8c 100644 --- a/packages/app-mobile/components/NoteEditor/RichTextEditor.test.tsx +++ b/packages/app-mobile/components/NoteEditor/RichTextEditor.test.tsx @@ -23,6 +23,7 @@ import { MarkupLanguage } from '@joplin/renderer'; import { NoteEntity } from '@joplin/lib/services/database/types'; import { EditorSettings } from './types'; import { pregQuote } from '@joplin/lib/string-utils'; +import { join } from 'path'; interface WrapperProps { @@ -103,8 +104,8 @@ const mockTyping = (window: EditorWindow, text: string) => { } }; -const mockSelectionMovement = (window: EditorWindow, position: number) => { - getEditorControl(window).select(position, position); +const mockSelectionMovement = (window: EditorWindow, from: number, to?: number) => { + getEditorControl(window).select(from, to ?? from); }; const findElement = async function(selector: string) { @@ -333,7 +334,7 @@ describe('RichTextEditor', () => { const editorContent = body.trim(); if (markupLanguage === MarkupLanguage.Html) { expect(editorContent).toMatch( - new RegExp(`^

${pregQuote(renderedImage.alt)}]*> test

$`), + new RegExp(`^

]* src=":/${pregQuote(resource.id)}" alt="${pregQuote(renderedImage.alt)}"[^>]*> test

$`), ); } else { expect(editorContent).toBe(`![${renderedImage.alt}](:/${resource.id}) test`); @@ -341,6 +342,29 @@ describe('RichTextEditor', () => { }); }); + it('should preserve non-image attachments on edit', async () => { + const { note, resource } = await createNoteAndResource({ path: join(supportDir, 'sample.txt') }); + let body = note.body; + + const resources = await attachedResources(body); + render( { body = newBody; }} + noteResources={resources} + />); + + const window = await getEditorWindow(); + mockTyping(window, ' test'); + + await waitFor(async () => { + const editorContent = body.trim(); + // TODO: At present, the resource title may be included in the final Markdown + // (e.g. as [sample.txt](:/id-here "sample.txt")). + expect(editorContent).toMatch(new RegExp(`^\\[sample\\.txt\\]\\(:/${pregQuote(resource.id)}.*\\) test$`)); + }); + }); + it.each([ { useValidSyntax: false }, { useValidSyntax: true }, @@ -390,14 +414,18 @@ describe('RichTextEditor', () => { }); }); - it('should be possible show an editor for math blocks', async () => { + it('should be possible to show an editor for math blocks', async () => { let body = 'Test:\n\n$$3^2 + 4^2 = 5^2$$'; render( { body = newBody; }} />); - const editButton = await findElement('button.edit'); + const window = await getEditorWindow(); + // Select the math block to show the "edit" button. + mockSelectionMovement(window, ''.length, '$'.length); + + const editButton = await findElement('button.edit-button'); editButton.click(); const editor = await findElement('dialog .cm-editor'); @@ -453,7 +481,7 @@ describe('RichTextEditor', () => { 'Superscript', 'Subscript', '![image](data:image/svg+xml;utf8,test)', - '', + '', ])('should preserve inline markup on edit (case %#)', async (initialBody) => { initialBody += 'test'; // Ensure that typing will add new content outside the formatting let body = initialBody; diff --git a/packages/app-mobile/components/NoteEditor/RichTextEditor.tsx b/packages/app-mobile/components/NoteEditor/RichTextEditor.tsx index 10671ccb3f..d81597abcd 100644 --- a/packages/app-mobile/components/NoteEditor/RichTextEditor.tsx +++ b/packages/app-mobile/components/NoteEditor/RichTextEditor.tsx @@ -44,6 +44,13 @@ function useCss(themeId: number, editorCss: string): string { font-size: 13pt; font-family: ${JSON.stringify(theme.fontFamily)}, sans-serif; } + + .RichTextEditor { + /* Relatively positioning the editor container causes absolutely-positioned + elements to be positioned relative to Rich Text Editor's container, + rather than the body. This fixes an alignment issue involving button overlays. */ + position: relative; + } `; }, [themeId, editorCss]); } diff --git a/packages/editor/ProseMirror/createEditor.ts b/packages/editor/ProseMirror/createEditor.ts index dd139ca93e..6685ce4292 100644 --- a/packages/editor/ProseMirror/createEditor.ts +++ b/packages/editor/ProseMirror/createEditor.ts @@ -23,7 +23,7 @@ import searchExtension from './plugins/searchPlugin'; import joplinEditorApiPlugin, { setEditorApi } from './plugins/joplinEditorApiPlugin'; import linkTooltipPlugin from './plugins/linkTooltipPlugin'; import { OnCreateCodeEditor as OnCreateCodeEditor, RendererControl } from './types'; -import resourcePlaceholderPlugin, { onResourceDownloaded } from './plugins/resourcePlaceholderPlugin'; +import imagePlugin, { onResourceDownloaded } from './plugins/imagePlugin'; import getFileFromPasteEvent from '../utils/getFileFromPasteEvent'; import { RenderResult } from '../../renderer/types'; import postprocessEditorOutput from './utils/postprocessEditorOutput'; @@ -92,7 +92,7 @@ const createEditor = async ( linkTooltipPlugin, tableEditing({ allowTableNodeSelection: true }), joplinEditorApiPlugin, - resourcePlaceholderPlugin, + imagePlugin, ].flat(), }); diff --git a/packages/editor/ProseMirror/plugins/detailsPlugin.test.ts b/packages/editor/ProseMirror/plugins/detailsPlugin.test.ts index 491abfb594..77cb35df5c 100644 --- a/packages/editor/ProseMirror/plugins/detailsPlugin.test.ts +++ b/packages/editor/ProseMirror/plugins/detailsPlugin.test.ts @@ -1,33 +1,23 @@ import createTestEditor from '../testing/createTestEditor'; +import createTestEditorWithSerializer from '../testing/createTestEditorWithSerializer'; import detailsPlugin from './detailsPlugin'; -import originalMarkupPlugin from './originalMarkupPlugin'; describe('detailsPlugin', () => { it('should add jop-noMdConv attributes to
and ', () => { - const serializer = new XMLSerializer(); - const markupToHtml = originalMarkupPlugin(node => serializer.serializeToString(node)); - const view = createTestEditor({ + const { toHtml, normalizeHtml } = createTestEditorWithSerializer({ html: `
Test

Test...

`, - plugins: [detailsPlugin, markupToHtml.plugin], + plugins: [detailsPlugin], }); - // Serialize, then parse to normalize the HTML (for comparison - // with the HTML serialized by markupToHtml). - const expectedState = serializer.serializeToString( - new DOMParser().parseFromString([ - '
Test', - '

Test...

', - '
', - ].join(''), 'text/html').querySelector('details'), - ); - - expect( - markupToHtml.stateToMarkup(view.state).trim(), - ).toBe(expectedState); + expect(toHtml()).toBe(normalizeHtml([ + '
Test', + '

Test...

', + '
', + ].join(''))); }); it.each([ diff --git a/packages/editor/ProseMirror/plugins/imagePlugin.test.ts b/packages/editor/ProseMirror/plugins/imagePlugin.test.ts new file mode 100644 index 0000000000..5b2f4b6efd --- /dev/null +++ b/packages/editor/ProseMirror/plugins/imagePlugin.test.ts @@ -0,0 +1,16 @@ +import createTestEditorWithSerializer from '../testing/createTestEditorWithSerializer'; + +const testImageUrl = 'data:image/svg+xml;utf8,some-icon-here'; + +describe('imagePlugin', () => { + test('should preserve image ALT text on save', () => { + const { toHtml, normalizeHtml } = createTestEditorWithSerializer({ + html: ` + Test + `, + plugins: [], + }); + + expect(toHtml()).toBe(normalizeHtml(`

Test

`)); + }); +}); diff --git a/packages/editor/ProseMirror/plugins/imagePlugin.ts b/packages/editor/ProseMirror/plugins/imagePlugin.ts new file mode 100644 index 0000000000..055977c2ee --- /dev/null +++ b/packages/editor/ProseMirror/plugins/imagePlugin.ts @@ -0,0 +1,269 @@ +import { Plugin } from 'prosemirror-state'; +import { AttributeSpec, Node, NodeSpec } from 'prosemirror-model'; +import { EditorView } from 'prosemirror-view'; +import SelectableNodeView, { GetPosition } from '../utils/SelectableNodeView'; +import { getEditorApi } from './joplinEditorApiPlugin'; +import showModal from '../utils/dom/showModal'; +import createTextArea from '../utils/dom/createTextArea'; +import createExternalEditorPlugin, { OnHide } from './utils/createExternalEditorPlugin'; +import createFloatingButtonPlugin, { ToolbarPosition } from './utils/createFloatingButtonPlugin'; + +// See the fold example for more information about +// writing similar ProseMirror plugins: +// https://prosemirror.net/examples/fold/ + +type NodeAttrs = Readonly<{ + // Placeholder attributes (e.g. if the src is not + // yet valid). + isPlaceholder: boolean; + placeholderSrc: string; + placeholderAlt: string; + notDownloaded: boolean; + isImage: boolean; + + resourceId: string; + src: string; + fromMd: boolean; + alt: string; + title: string; + width: string; + height: string; +}>; + +const attrsSpec = { + isPlaceholder: { default: '', validate: 'boolean' }, + placeholderSrc: { default: '', validate: 'string' }, + placeholderAlt: { default: '', validate: 'string' }, + notDownloaded: { validate: 'boolean' }, + isImage: { validate: 'boolean' }, + width: { validate: 'string', default: '' }, + height: { validate: 'string', default: '' }, + + resourceId: { default: null as string|null, validate: 'string|null' }, + src: { default: '', validate: 'string' }, + fromMd: { default: false, validate: 'boolean' }, + alt: { default: '', validate: 'string' }, + title: { default: '', validate: 'string' }, +} satisfies Record; + +const imageSpec: NodeSpec = { + group: 'inline', + inline: true, + attrs: attrsSpec, + draggable: true, + parseDOM: [ + { + // Images + tag: 'img[src]', + getAttrs: (node): NodeAttrs => ({ + src: node.getAttribute('src'), + alt: node.getAttribute('alt'), + title: node.getAttribute('title'), + width: node.getAttribute('width') ?? '', + height: node.getAttribute('height') ?? '', + fromMd: node.hasAttribute('data-from-md'), + resourceId: node.getAttribute('data-resource-id') || null, + + isPlaceholder: false, + placeholderSrc: '', + placeholderAlt: '', + notDownloaded: false, + isImage: true, + }), + }, + { + // Placeholders + tag: 'span[data-resource-id].not-loaded-resource', + getAttrs: (node): NodeAttrs => { + return { + isPlaceholder: true, + resourceId: node.getAttribute('data-resource-id'), + placeholderSrc: node.querySelector('img')?.src, + src: '', + width: null, + height: null, + placeholderAlt: node.querySelector('img')?.alt, + fromMd: false, + alt: node.getAttribute('data-original-alt'), + title: node.getAttribute('data-original-title'), + isImage: node.classList.contains('not-loaded-image-resource'), + notDownloaded: node.classList.contains('resource-status-notDownloaded'), + }; + }, + }, + ], + toDOM: (node) => { + const attrs = node.attrs as NodeAttrs; + // Continue to render non-images as placeholders for now, even after downloading: + return (attrs.isPlaceholder || !attrs.isImage) ? [ + 'span', + { + 'data-resource-id': attrs.resourceId, + 'data-original-alt': attrs.alt, + 'data-original-title': attrs.title, + class: [ + 'not-loaded-resource', + attrs.isImage ? 'not-loaded-image-resource' : null, + attrs.notDownloaded ? 'resource-status-notDownloaded' : null, + ].filter(item => !!item).join(' '), + }, + ['img', { src: attrs.placeholderSrc, alt: attrs.placeholderAlt }], + ] : [ + 'img', + { + ...(attrs.fromMd ? { + 'data-from-md': true, + } : {}), + ...(attrs.resourceId ? { + 'data-resource-id': attrs.resourceId, + } : {}), + ...(attrs.width ? { + width: attrs.width, + } : {}), + ...(attrs.height ? { + height: attrs.height, + } : {}), + src: attrs.src, + alt: attrs.alt, + title: attrs.title, + }, + ]; + }, +}; + +export const nodeSpecs = { + image: imageSpec, +}; + + +const createAltTextDialog = (nodePosition: number, view: EditorView, onHide: ()=> void) => { + const node = view.state.doc.nodeAt(nodePosition); + const attrs = node.attrs as NodeAttrs; + + const { localize: _ } = getEditorApi(view.state); + + const content = document.createElement('div'); + content.classList.add('alt-text-editor'); + const input = createTextArea({ + label: _('A brief description of the image:'), + initialContent: attrs.alt, + onChange: (newContent) => { + view.dispatch( + view.state.tr.setNodeAttribute(nodePosition, 'alt', newContent.replace(/[\n]+/g, '\n')), + ); + }, + }); + input.textArea.setAttribute('autofocus', 'true'); + content.appendChild(input.label); + content.appendChild(input.textArea); + + const modal = showModal({ + content, + doneLabel: _('Done'), + onDismiss: ()=>{ + onHide(); + }, + }); + + return { + onPositionChange: (newPosition: number) => { + nodePosition = newPosition; + }, + dismiss: () => modal.dismiss(), + }; +}; + +const { plugin: altTextEditorPlugin, editAt: editAltTextAt } = createExternalEditorPlugin({ + canEdit: (node: Node) => { + return node.type.name === 'image'; + }, + showEditor: (pos: number, view: EditorView, onHide: OnHide) => { + return createAltTextDialog(pos, view, onHide); + }, +}); + +class ImageView extends SelectableNodeView { + public constructor(node: Node, view: EditorView, getPosition: GetPosition) { + super(true); + this.dom.classList.add('joplin-image-view'); + + this.dom.appendChild(this.createDom_(node)); + this.dom.ondblclick = () => { + editAltTextAt(getPosition())(view.state, view.dispatch, view); + }; + } + + private createDom_(node: Node) { + const attrs = node.attrs as NodeAttrs; + + const createDom = (imageSrc: string, imageAlt: string, loaded: boolean) => { + const image = document.createElement('img'); + image.src = imageSrc; + image.alt = imageAlt; + + image.setAttribute('width', attrs.width); + image.setAttribute('height', attrs.height); + + let dom; + if (!loaded) { + dom = document.createElement('span'); + dom.classList.add('not-loaded-resource'); + dom.appendChild(image); + } else { + dom = image; + dom.classList.add('late-loaded-resource'); + } + // For testing + dom.setAttribute('data-resource-id', attrs.resourceId); + return dom; + }; + + let imageSrc = attrs.placeholderSrc; + let imageAlt = attrs.placeholderAlt; + let loaded = false; + + if (!attrs.isPlaceholder) { + loaded = true; + imageSrc = attrs.src; + imageAlt = attrs.alt; + } + + return createDom(imageSrc, imageAlt, loaded); + } +} + +export const onResourceDownloaded = (view: EditorView, resourceId: string, newSrc: string) => { + let tr = view.state.tr; + view.state.doc.descendants((node, pos) => { + if (node.type.name === 'image') { + const attrs = node.attrs as NodeAttrs; + const itemId = attrs.resourceId; + + if (itemId === resourceId && attrs.isPlaceholder) { + tr = tr.setNodeAttribute(pos, 'isPlaceholder', false) + .setNodeAttribute(pos, 'notDownloaded', false) + .setNodeAttribute(pos, 'src', newSrc) + .setMeta('addToHistory', false); + } + } + }); + view.dispatch(tr); +}; + +const imagePlugin = [ + altTextEditorPlugin, + new Plugin({ + props: { + nodeViews: { + image: (node, view, getPosition) => { + return new ImageView(node, view, getPosition); + }, + }, + }, + }), + createFloatingButtonPlugin('image', [ + { label: _ => _('Label'), command: (_node, offset) => editAltTextAt(offset) }, + ], ToolbarPosition.TopRightInside), +]; + +export default imagePlugin; diff --git a/packages/editor/ProseMirror/plugins/joplinEditablePlugin/createEditorDialog.ts b/packages/editor/ProseMirror/plugins/joplinEditablePlugin/createEditorDialog.ts index 74847ba5e5..67c27ab5b6 100644 --- a/packages/editor/ProseMirror/plugins/joplinEditablePlugin/createEditorDialog.ts +++ b/packages/editor/ProseMirror/plugins/joplinEditablePlugin/createEditorDialog.ts @@ -1,7 +1,6 @@ -import { focus } from '@joplin/lib/utils/focusHandler'; -import createTextNode from '../../utils/dom/createTextNode'; import { EditorApi } from '../joplinEditorApiPlugin'; import { EditorLanguageType } from '../../../types'; +import showModal from '../../utils/dom/showModal'; interface SourceBlockData { start: string; @@ -19,18 +18,12 @@ interface Options { } const createEditorDialog = ({ editorApi, doneLabel, block, onSave, onDismiss }: Options) => { - const dialog = document.createElement('dialog'); - dialog.classList.add('editor-dialog', '-visible'); - document.body.appendChild(dialog); - - dialog.onclose = () => { - onDismiss(); - dialog.remove(); - editor.remove(); - }; + const content = document.createElement('div'); + content.classList.add('editor-dialog-content'); + document.body.appendChild(content); const editor = editorApi.createCodeEditor( - dialog, + content, EditorLanguageType.Markdown, (newContent) => { block = { @@ -48,35 +41,14 @@ const createEditorDialog = ({ editorApi, doneLabel, block, onSave, onDismiss }: block.end, ].join('')); - const onClose = () => { - if (dialog.close) { - dialog.close(); - } else { - // Handle the case where the dialog element is not supported by the - // browser/testing environment. - dialog.onclose(new Event('close')); - } - }; - - const submitButton = document.createElement('button'); - submitButton.appendChild(createTextNode(doneLabel)); - submitButton.classList.add('submit'); - submitButton.onclick = onClose; - - 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', editor); - } - - return { - dismiss: onClose, - }; + return showModal({ + content, + doneLabel, + onDismiss: () => { + onDismiss(); + editor.remove(); + }, + }); }; export default createEditorDialog; diff --git a/packages/editor/ProseMirror/plugins/joplinEditablePlugin/joplinEditablePlugin.test.ts b/packages/editor/ProseMirror/plugins/joplinEditablePlugin/joplinEditablePlugin.test.ts index f832b85bc3..b61b82db56 100644 --- a/packages/editor/ProseMirror/plugins/joplinEditablePlugin/joplinEditablePlugin.test.ts +++ b/packages/editor/ProseMirror/plugins/joplinEditablePlugin/joplinEditablePlugin.test.ts @@ -4,6 +4,8 @@ import createTestEditor from '../../testing/createTestEditor'; import joplinEditorApiPlugin, { getEditorApi, setEditorApi } from '../joplinEditorApiPlugin'; import joplinEditablePlugin, { editSourceBlockAt, hideSourceBlockEditor } from './joplinEditablePlugin'; import { Second } from '@joplin/utils/time'; +import { EditorView } from 'prosemirror-view'; +import selectFirstInstanceOfNode from '../../utils/selectFirstInstanceOfNode'; const createEditor = (html: string) => { return createTestEditor({ @@ -12,12 +14,12 @@ const createEditor = (html: string) => { }); }; -const findEditButton = (ancestor: Element): HTMLButtonElement => { - return ancestor.querySelector('.joplin-editable > button.edit'); +const findEditButton = (editor: EditorView): HTMLButtonElement => { + return editor.dom.parentElement.querySelector('.floating-button-bar:not(.-hidden) > .edit-button'); }; const findEditorDialog = () => { - const dialog = document.querySelector('dialog.editor-dialog'); + const dialog = document.querySelector('dialog.joplin-dialog'); if (!dialog) { return null; } @@ -47,13 +49,19 @@ describe('joplinEditablePlugin', () => { '

Test:

test
rendered

', ])('should show an edit button on source blocks (case %#)', (htmlSource) => { const editor = createEditor(htmlSource); - const editButton = findEditButton(editor.dom); + + selectFirstInstanceOfNode(editor, 'joplinEditableInline'); + selectFirstInstanceOfNode(editor, 'joplinEditableBlock'); + + const editButton = findEditButton(editor); expect(editButton.textContent).toBe('Edit'); }); test('clicking the edit button should show an editor dialog', () => { const editor = createEditor('
test source
rendered
'); - const editButton = findEditButton(editor.dom); + selectFirstInstanceOfNode(editor, 'joplinEditableInline'); + + const editButton = findEditButton(editor); editButton.click(); // Should show the dialog @@ -76,7 +84,7 @@ describe('joplinEditablePlugin', () => { }, })); - const editButton = findEditButton(editor.dom); + const editButton = findEditButton(editor); editButton.click(); const dialog = findEditorDialog(); diff --git a/packages/editor/ProseMirror/plugins/joplinEditablePlugin/joplinEditablePlugin.ts b/packages/editor/ProseMirror/plugins/joplinEditablePlugin/joplinEditablePlugin.ts index 2e9c813157..73535e8093 100644 --- a/packages/editor/ProseMirror/plugins/joplinEditablePlugin/joplinEditablePlugin.ts +++ b/packages/editor/ProseMirror/plugins/joplinEditablePlugin/joplinEditablePlugin.ts @@ -1,64 +1,22 @@ -import { Command, EditorState, Plugin } from 'prosemirror-state'; +import { Plugin } from 'prosemirror-state'; import { Node, NodeSpec, TagParseRule } from 'prosemirror-model'; -import { EditorView, NodeView } from 'prosemirror-view'; +import { EditorView } from 'prosemirror-view'; import sanitizeHtml from '../../utils/sanitizeHtml'; import createEditorDialog from './createEditorDialog'; import { getEditorApi } from '../joplinEditorApiPlugin'; import { msleep } from '@joplin/utils/time'; import postProcessRenderedHtml from './postProcessRenderedHtml'; -import createButton from '../../utils/dom/createButton'; import makeLinksClickableInElement from '../../utils/makeLinksClickableInElement'; +import SelectableNodeView from '../../utils/SelectableNodeView'; +import createExternalEditorPlugin, { OnHide } from '../utils/createExternalEditorPlugin'; +import createFloatingButtonPlugin, { ToolbarPosition } from '../utils/createFloatingButtonPlugin'; // See the fold example for more information about // 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) => { +const createEditorDialogForNode = (nodePosition: number, view: EditorView, onHide: OnHide) => { let saveCounter = 0; const getNode = () => ( @@ -108,7 +66,7 @@ const createDialogForNode = (nodePosition: number, view: EditorView) => { ); }, onDismiss: () => { - hideSourceBlockEditor(view.state, view.dispatch, view); + onHide(); }, }); @@ -120,8 +78,6 @@ const createDialogForNode = (nodePosition: number, view: EditorView) => { }; }; -type DialogHandle = ReturnType; - interface JoplinEditableAttributes { contentHtml: string; @@ -224,16 +180,14 @@ export const nodeSpecs = { ]), }; -type GetPosition = ()=> number; - -class EditableSourceBlockView implements NodeView { - public readonly dom: HTMLElement; - public constructor(private node: Node, inline: boolean, private view: EditorView, private getPosition: GetPosition) { +class EditableSourceBlockView extends SelectableNodeView { + public constructor(private node: Node, inline: boolean, view: EditorView) { 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'); + super(inline); + this.dom.classList.add('joplin-editable'); // The link tooltip used for other in-editor links won't be shown for links within a @@ -243,43 +197,14 @@ class EditableSourceBlockView implements NodeView { this.updateContent_(); } - private showEditDialog_() { - editSourceBlockAt(this.getPosition())(this.view.state, this.view.dispatch, this.view); - } - private updateContent_() { const setDomContentSafe = (html: string) => { this.dom.innerHTML = sanitizeHtml(html); }; const attrs = this.node.attrs as JoplinEditableAttributes; - const addEditButton = () => { - const { localize: _ } = getEditorApi(this.view.state); - - const editButton = createButton(_('Edit'), () => this.showEditDialog_()); - editButton.classList.add('edit'); - - if (!attrs.readOnly) { - this.dom.appendChild(editButton); - } - }; - setDomContentSafe(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) { @@ -294,64 +219,32 @@ class EditableSourceBlockView implements NodeView { } } -interface PluginState { - editingNodeAt: number|null; -} - -const joplinEditablePlugin = new Plugin({ - 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(); - } - }, - }; +const { plugin: externalEditorPlugin, hideEditor, editAt } = createExternalEditorPlugin({ + canEdit: (node: Node) => { + return (node.type.name === 'joplinEditableInline' || node.type.name === 'joplinEditableBlock') && !node.attrs.readOnly; }, + showEditor: createEditorDialogForNode, }); -export default joplinEditablePlugin; +export { hideEditor as hideSourceBlockEditor, editAt as editSourceBlockAt }; + +export default [ + externalEditorPlugin, + new Plugin({ + props: { + nodeViews: { + joplinEditableInline: (node, view) => new EditableSourceBlockView(node, true, view), + joplinEditableBlock: (node, view) => new EditableSourceBlockView(node, false, view), + }, + }, + }), + ...['joplinEditableInline', 'joplinEditableBlock'].map(nodeName => ( + createFloatingButtonPlugin(nodeName, [ + { + label: _ => _('Edit'), + className: 'edit-button', + command: (_node, offset) => editAt(offset), + }, + ], ToolbarPosition.TopRightInside) + )), +]; diff --git a/packages/editor/ProseMirror/plugins/resourcePlaceholderPlugin.ts b/packages/editor/ProseMirror/plugins/resourcePlaceholderPlugin.ts deleted file mode 100644 index 4986a1cc05..0000000000 --- a/packages/editor/ProseMirror/plugins/resourcePlaceholderPlugin.ts +++ /dev/null @@ -1,211 +0,0 @@ -import { Plugin } from 'prosemirror-state'; -import { AttributeSpec, Node, NodeSpec } from 'prosemirror-model'; -import { Decoration, DecorationSet, EditorView, NodeView } from 'prosemirror-view'; -import changedDescendants from '../vendor/changedDescendants'; - -// See the fold example for more information about -// writing similar ProseMirror plugins: -// https://prosemirror.net/examples/fold/ - -type NodeAttrs = Readonly<{ - placeholderSrc: string; - placeholderAlt: string; - itemId: string; - alt: string; - title: string; - isImage: boolean; - notDownloaded: boolean; -}>; - -const attrsSpec = { - placeholderSrc: { default: '', validate: 'string' }, - placeholderAlt: { default: '', validate: 'string' }, - - itemId: { validate: 'string' }, - alt: { default: '', validate: 'string' }, - title: { default: '', validate: 'string' }, - isImage: { validate: 'boolean' }, - notDownloaded: { validate: 'boolean' }, -} satisfies Record; - -const placeholderSpec: NodeSpec = { - group: 'inline', - inline: true, - attrs: attrsSpec, - parseDOM: [ - { - tag: 'span[data-resource-id].not-loaded-resource', - getAttrs: (node): NodeAttrs => { - return { - itemId: node.getAttribute('data-resource-id'), - alt: node.getAttribute('data-original-alt'), - title: node.getAttribute('data-original-title'), - isImage: node.classList.contains('not-loaded-image-resource'), - notDownloaded: node.classList.contains('resource-status-notDownloaded'), - placeholderSrc: node.querySelector('img')?.src, - placeholderAlt: node.querySelector('img')?.alt, - }; - }, - }, - ], - toDOM: (node) => [ - 'span', - { - 'data-resource-id': node.attrs.itemId, - 'data-original-alt': node.attrs.alt, - 'data-original-title': node.attrs.title, - class: [ - 'not-loaded-resource', - node.attrs.isImage ? 'not-loaded-image-resource' : null, - node.attrs.notDownloaded ? 'resource-status-notDownloaded' : null, - ].filter(item => !!item).join(' '), - }, - ['img', { src: node.attrs.placeholderSrc, alt: node.attrs.placeholderAlt }], - ], -}; - -export const nodeSpecs = { - resourcePlaceholder: placeholderSpec, -}; - -class ResourcePlaceholderView implements NodeView { - public readonly dom: HTMLElement; - private resourceId_: string; - - public constructor(node: Node, decorations: readonly Decoration[]) { - this.resourceId_ = node.attrs.itemId; - this.dom = this.createDom_(node, decorations); - } - - private createDom_(node: Node, decorations: readonly Decoration[]) { - const createDom = (imageSrc: string, imageAlt: string, loaded: boolean) => { - const image = document.createElement('img'); - image.src = imageSrc; - image.alt = imageAlt; - - let dom; - if (!loaded) { - dom = document.createElement('span'); - dom.classList.add('not-loaded-resource'); - dom.appendChild(image); - } else { - dom = image; - dom.classList.add('late-loaded-resource'); - } - // For testing - dom.setAttribute('data-resource-id', this.resourceId_); - return dom; - }; - - const attrs = node.attrs as NodeAttrs; - let imageSrc = attrs.placeholderSrc; - let imageAlt = attrs.placeholderAlt; - let loaded = false; - - for (const decoration of decorations) { - if (decoration.spec.resourceId === this.resourceId_) { - imageSrc = (decoration.spec as PluginMeta).newSrc; - imageAlt = attrs.alt; - loaded = true; - } - } - - return createDom(imageSrc, imageAlt, loaded); - } - - public update(node: Node, decorations: readonly Decoration[]) { - if (node.type.spec !== placeholderSpec) return false; - - for (const decoration of decorations) { - if (decoration.spec.resourceId === this.resourceId_) { - this.dom.replaceWith(this.createDom_(node, decorations)); - } - } - return true; - } -} - -interface PluginMeta { - resourceId: string; - newSrc: string; -} -export const onResourceDownloaded = (view: EditorView, resourceId: string, newSrc: string) => { - const meta: PluginMeta = { - resourceId, - newSrc, - }; - view.dispatch( - view.state.tr.setMeta(resourcePlaceholderPlugin, meta), - ); -}; - -interface PluginState { - decorations: DecorationSet; - idToSrc: Record; -} - -const resourcePlaceholderPlugin: Plugin = new Plugin({ - state: { - init: (): PluginState => ({ - decorations: DecorationSet.empty, - idToSrc: Object.create(null), - }), - apply: (tr, oldValue, oldState, newState) => { - let decorations = oldValue.decorations.map(tr.mapping, tr.doc); - let idToSrc = oldValue.idToSrc; - - const tryAddDecoration = (node: Node, pos: number) => { - if (node.type.spec === placeholderSpec && decorations.find(pos, pos + node.nodeSize).length === 0) { - const attrs = node.attrs as NodeAttrs; - const itemId = attrs.itemId; - - if (Object.hasOwnProperty.call(idToSrc, itemId)) { - const spec: PluginMeta = { - newSrc: idToSrc[attrs.itemId], - resourceId: attrs.itemId, - }; - - decorations = decorations.add(tr.doc, [ - Decoration.node(pos, pos + node.nodeSize, {}, spec), - ]); - } - } - }; - - const meta: PluginMeta|undefined = tr.getMeta(resourcePlaceholderPlugin); - if (meta) { - const { resourceId, newSrc } = meta; - if (!resourceId || !newSrc) { - throw new Error('Invalid .setMeta for resourcePlaceholderPlugin'); - } - - idToSrc = { ...idToSrc, [resourceId]: newSrc }; - - tr.doc.descendants((node, pos) => { - tryAddDecoration(node, pos); - }); - } - - changedDescendants(oldState.doc, newState.doc, 0, (node, pos) => { - tryAddDecoration(node, pos); - }); - - return { - decorations, - idToSrc, - }; - }, - }, - props: { - nodeViews: { - resourcePlaceholder: (node, _view, _getPos, decorations) => { - return new ResourcePlaceholderView(node, decorations); - }, - }, - decorations(state) { - return this.getState(state).decorations; - }, - }, -}); - -export default resourcePlaceholderPlugin; diff --git a/packages/editor/ProseMirror/plugins/utils/createExternalEditorPlugin.ts b/packages/editor/ProseMirror/plugins/utils/createExternalEditorPlugin.ts new file mode 100644 index 0000000000..b8bd1fee7b --- /dev/null +++ b/packages/editor/ProseMirror/plugins/utils/createExternalEditorPlugin.ts @@ -0,0 +1,126 @@ +import { Command, EditorState, Plugin } from 'prosemirror-state'; +import { EditorView } from 'prosemirror-view'; +import { Node } from 'prosemirror-model'; + +// See the fold example for more information about +// writing similar ProseMirror plugins: +// https://prosemirror.net/examples/fold/ + +type EditRequest = { + nodeStart: number; + showEditor: true; +} | { + nodeStart?: undefined; + showEditor: false; +}; + +interface PluginState { + editingNodeAt: number|null; +} + +interface EditorDialog { + onPositionChange: (position: number)=> void; + dismiss: ()=> void; +} + +export type OnHide = ()=> void; +interface Options { + canEdit: (node: Node, pos: number)=> boolean; + showEditor: (pos: number, view: EditorView, onHide: OnHide)=> EditorDialog; +} + +const createExternalEditorPlugin = (options: Options) => { + const plugin = new Plugin({ + state: { + init: () => ({ + editingNodeAt: null, + }), + apply: (tr, oldValue) => { + let editingAt = oldValue.editingNodeAt; + + const editRequest: EditRequest|null = tr.getMeta(plugin); + if (editRequest) { + if (editRequest.showEditor) { + editingAt = editRequest.nodeStart; + } else { + editingAt = null; + } + } + + if (editingAt) { + editingAt = tr.mapping.map(editingAt, 1); + } + return { editingNodeAt: editingAt }; + }, + }, + view: () => { + let dialog: EditorDialog|null = null; + + return { + update(view, prevState) { + const oldState = plugin.getState(prevState); + const newState = plugin.getState(view.state); + + if (newState.editingNodeAt !== null) { + if (oldState.editingNodeAt === null) { + const onHide = () => { + hideEditor(view.state, view.dispatch, view); + }; + dialog = options.showEditor(newState.editingNodeAt, view, onHide); + } + 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(); + } + }, + }; + }, + }); + + const editAt = (nodeStart: number): Command => (state, dispatch) => { + const node = state.doc.nodeAt(nodeStart); + if (!options.canEdit(node, nodeStart)) { + return false; + } + + if (dispatch) { + const editRequest: EditRequest = { + nodeStart, + showEditor: true, + }; + dispatch(state.tr.setMeta(plugin, editRequest)); + } + + return true; + }; + + const isEditorVisible = (state: EditorState) => { + return plugin.getState(state).editingNodeAt !== null; + }; + + const hideEditor: Command = (state, dispatch) => { + const isEditing = isEditorVisible(state); + if (!isEditing) { + return false; + } + + if (dispatch) { + const editRequest: EditRequest = { + showEditor: false, + }; + dispatch(state.tr.setMeta(plugin, editRequest)); + } + + return true; + }; + + return { plugin, hideEditor, editAt, isEditorVisible }; +}; + +export default createExternalEditorPlugin; diff --git a/packages/editor/ProseMirror/plugins/utils/createFloatingButtonPlugin.ts b/packages/editor/ProseMirror/plugins/utils/createFloatingButtonPlugin.ts new file mode 100644 index 0000000000..c461fe011d --- /dev/null +++ b/packages/editor/ProseMirror/plugins/utils/createFloatingButtonPlugin.ts @@ -0,0 +1,130 @@ +import { Command, EditorState, Plugin } from 'prosemirror-state'; +import { LocalizationResult, OnLocalize } from '../../../types'; +import { EditorView } from 'prosemirror-view'; +import createButton from '../../utils/dom/createButton'; +import { getEditorApi } from '../joplinEditorApiPlugin'; +import { Node } from 'prosemirror-model'; + +type LocalizeFunction = (_: OnLocalize)=> LocalizationResult; + +interface ButtonSpec { + label: LocalizeFunction; + command: (node: Node, offset: number)=> Command; + showForNode?: (node: Node)=> boolean; + className?: string; +} + +export enum ToolbarPosition { + TopLeftOutside, + TopRightInside, +} + +class FloatingButtonBar { + private container_: HTMLElement; + + public constructor( + view: EditorView, private targetNode_: string, private buttons_: ButtonSpec[], private position_: ToolbarPosition, + ) { + this.container_ = document.createElement('div'); + this.container_.classList.add('floating-button-bar'); + + // Prevent other elements (e.g. checkboxes, links) from being between the toolbar button and the + // target element. If the toolbar is instead included **after** the Rich Text Editor's main content, + // then all items included directly within the Rich Text Editor come before the toolbar in the focus + // order. + view.dom.parentElement.prepend(this.container_); + this.update(view, null); + } + + public update(view: EditorView, lastState: EditorState|null) { + const state = view.state; + const sameSelection = lastState && state.selection.eq(lastState.selection); + const sameDoc = lastState && state.doc.eq(lastState.doc); + if (sameSelection && sameDoc) { + return; + } + + const findTargetNode = () => { + type TargetNode = { offset: number; node: Node }; + let target: TargetNode = null; + state.doc.nodesBetween(state.selection.from, state.selection.to, (node, offset) => { + if (node.type.name === this.targetNode_) { + target = { node, offset }; + return false; + } + return true; + }); + + return target; + }; + + const target = findTargetNode(); + if (!target) { + this.container_.classList.add('-hidden'); + } else { + this.container_.classList.remove('-hidden'); + + const hasCreatedButtons = this.container_.children.length === this.buttons_.length; + if (!hasCreatedButtons) { + const { localize } = getEditorApi(view.state); + this.container_.replaceChildren(...this.buttons_.map(buttonSpec => { + const button = createButton( + buttonSpec.label(localize), + () => { }, + ); + + button.classList.add('action'); + if (buttonSpec.className) { + button.classList.add(buttonSpec.className); + } + + return button; + })); + } + + for (let i = 0; i < this.buttons_.length; i++) { + const button = this.container_.children[i] as HTMLButtonElement; + const buttonSpec = this.buttons_[i]; + + const command = buttonSpec.command(target.node, target.offset); + button.onclick = () => { + command(view.state, view.dispatch, view); + }; + + button.disabled = !command(view.state); + } + + const position = view.coordsAtPos(target.offset); + // Fall back to document.body to support testing environments: + const parentBox = (this.container_.offsetParent ?? document.body).getBoundingClientRect(); + const tooltipBox = this.container_.getBoundingClientRect(); + + this.container_.style.left = ''; + this.container_.style.right = ''; + + const nodeElement = view.nodeDOM(target.offset); + const nodeBbox = nodeElement instanceof HTMLElement ? nodeElement.getBoundingClientRect() : { + ...position, + width: 0, + height: 0, + }; + + let top = nodeBbox.top - parentBox.top; + if (this.position_ === ToolbarPosition.TopLeftOutside) { + top -= tooltipBox.height; + this.container_.style.left = `${Math.max(nodeBbox.left - parentBox.left, 0)}px`; + } else if (this.position_ === ToolbarPosition.TopRightInside) { + this.container_.style.right = `${parentBox.width - nodeBbox.width - (nodeBbox.left - parentBox.left)}px`; + } + this.container_.style.top = `${top}px`; + } + } +} + +const createFloatingButtonPlugin = (nodeName: string, actions: ButtonSpec[], position: ToolbarPosition) => { + return new Plugin({ + view: (view) => new FloatingButtonBar(view, nodeName, actions, position), + }); +}; + +export default createFloatingButtonPlugin; diff --git a/packages/editor/ProseMirror/schema.ts b/packages/editor/ProseMirror/schema.ts index e564195b2a..4201e55503 100644 --- a/packages/editor/ProseMirror/schema.ts +++ b/packages/editor/ProseMirror/schema.ts @@ -2,7 +2,7 @@ import { AttributeSpec, DOMOutputSpec, MarkSpec, NodeSpec, Schema } from 'prosem import { nodeSpecs as joplinEditableNodes } from './plugins/joplinEditablePlugin/joplinEditablePlugin'; import { tableNodes } from 'prosemirror-tables'; import { nodeSpecs as listNodes } from './plugins/listPlugin'; -import { nodeSpecs as resourcePlaceholderNodes } from './plugins/resourcePlaceholderPlugin'; +import { nodeSpecs as imageNodes } from './plugins/imagePlugin'; import { hasProtocol } from '@joplin/utils/url'; import { isResourceUrl } from '@joplin/lib/models/utils/resourceUtils'; import { nodeSpecs as detailsNodes } from './plugins/detailsPlugin'; @@ -136,12 +136,12 @@ const nodes = addDefaultToplevelAttributes({ }, }, ...detailsNodes, - ...resourcePlaceholderNodes, + ...imageNodes, ...listNodes, ...joplinEditableNodes, ...tableNodes({ tableGroup: 'block', - cellContent: 'inline*', + cellContent: 'block+', cellAttributes: {}, }), // Override the default `table` node to include the default toplevel attributes @@ -157,56 +157,6 @@ const nodes = addDefaultToplevelAttributes({ text: { group: 'inline', }, - image: { - group: 'inline', - inline: true, - draggable: true, - attrs: { - src: { default: '', validate: 'string' }, - alt: { default: '', validate: 'string' }, - title: { default: '', validate: 'string' }, - fromMd: { default: false, validate: 'boolean' }, - resourceId: { default: null as string|null, validate: 'string|null' }, - width: { default: '', validate: 'string' }, - height: { default: '', validate: 'string' }, - }, - parseDOM: [ - { - tag: 'img[src]', - getAttrs: node => ({ - src: node.getAttribute('src'), - alt: node.getAttribute('alt'), - width: node.getAttribute('width') ?? '', - height: node.getAttribute('height') ?? '', - title: node.getAttribute('title'), - fromMd: node.hasAttribute('data-from-md'), - resourceId: node.getAttribute('data-resource-id') || null, - }), - }, - ], - toDOM: node => { - const { src, alt, title, width, height, fromMd, resourceId } = node.attrs; - const outputAttrs: Record = { src, alt, title }; - - if (fromMd) { - outputAttrs['data-from-md'] = true; - } - if (resourceId) { - outputAttrs['data-resource-id'] = resourceId; - } - if (width) { - outputAttrs.width = width; - } - if (height) { - outputAttrs.height = height; - } - - return [ - 'img', - outputAttrs, - ]; - }, - }, hard_break: { inline: true, group: 'inlineBreak', diff --git a/packages/editor/ProseMirror/styles.ts b/packages/editor/ProseMirror/styles.ts index e3f27f2be9..66a435e1a2 100644 --- a/packages/editor/ProseMirror/styles.ts +++ b/packages/editor/ProseMirror/styles.ts @@ -2,9 +2,13 @@ import 'prosemirror-view/style/prosemirror.css'; import 'prosemirror-search/style/search.css'; import './styles/joplin-editable.css'; -import './styles/editor-dialog.css'; +import './styles/joplin-selectable.css'; +import './styles/joplin-dialog.css'; import './styles/prosemirror-editor.css'; import './styles/table.css'; import './styles/checklist-item.css'; import './styles/link-tooltip.css'; +import './styles/joplin-image-view.css'; +import './styles/alt-text-editor.css'; +import './styles/floating-button-bar.css'; diff --git a/packages/editor/ProseMirror/styles/alt-text-editor.css b/packages/editor/ProseMirror/styles/alt-text-editor.css new file mode 100644 index 0000000000..4d302816b1 --- /dev/null +++ b/packages/editor/ProseMirror/styles/alt-text-editor.css @@ -0,0 +1,5 @@ + +.alt-text-editor { + display: flex; + flex-direction: column; +} diff --git a/packages/editor/ProseMirror/styles/floating-button-bar.css b/packages/editor/ProseMirror/styles/floating-button-bar.css new file mode 100644 index 0000000000..782a727ecf --- /dev/null +++ b/packages/editor/ProseMirror/styles/floating-button-bar.css @@ -0,0 +1,13 @@ + +.floating-button-bar { + position: absolute; + z-index: 1; +} + +.floating-button-bar.-hidden { + display: none; +} + +.floating-button-bar > .action { + opacity: 0.9; +} diff --git a/packages/editor/ProseMirror/styles/editor-dialog.css b/packages/editor/ProseMirror/styles/joplin-dialog.css similarity index 61% rename from packages/editor/ProseMirror/styles/editor-dialog.css rename to packages/editor/ProseMirror/styles/joplin-dialog.css index ecca4ff19f..eb59a7d497 100644 --- a/packages/editor/ProseMirror/styles/editor-dialog.css +++ b/packages/editor/ProseMirror/styles/joplin-dialog.css @@ -1,5 +1,5 @@ -.editor-dialog { +.joplin-dialog { display: flex; flex-direction: column; background-color: var(--joplin-background-color); @@ -12,19 +12,15 @@ 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 { +.joplin-dialog > button.done { color: var(--joplin-color); background-color: var(--joplin-background-color3); - border: none; + border: 1px solid var(--joplin-border-color4); border-radius: 8px; + min-height: 36px; height: 38px; + + bottom: 0; + position: sticky; } diff --git a/packages/editor/ProseMirror/styles/joplin-editable.css b/packages/editor/ProseMirror/styles/joplin-editable.css index 0f75c7b01f..3a770150a8 100644 --- a/packages/editor/ProseMirror/styles/joplin-editable.css +++ b/packages/editor/ProseMirror/styles/joplin-editable.css @@ -4,25 +4,3 @@ /* 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 { - outline: 4px solid var(--joplin-text-selection-color); -} - -.joplin-editable.-selected > .edit { - display: block; - opacity: 0.9; -} diff --git a/packages/editor/ProseMirror/styles/joplin-image-view.css b/packages/editor/ProseMirror/styles/joplin-image-view.css new file mode 100644 index 0000000000..12c41183f0 --- /dev/null +++ b/packages/editor/ProseMirror/styles/joplin-image-view.css @@ -0,0 +1,4 @@ + +.joplin-image-view { + display: inline-block; +} diff --git a/packages/editor/ProseMirror/styles/joplin-selectable.css b/packages/editor/ProseMirror/styles/joplin-selectable.css new file mode 100644 index 0000000000..03d9bce515 --- /dev/null +++ b/packages/editor/ProseMirror/styles/joplin-selectable.css @@ -0,0 +1,26 @@ + +.joplin-selectable { + position: relative; +} + +.joplin-selectable > .actions { + position: absolute; + top: 0; + right: 0; + opacity: 0; + + display: none; + + transition-behavior: allow-discrete; + transition-duration: 0.2s; + transition-property: opacity, display; +} + +.joplin-selectable.-selected { + outline: 4px solid var(--joplin-text-selection-color); +} + +.joplin-selectable.-selected > .actions, .joplin-selectable:focus-within > .actions { + display: block; + opacity: 0.9; +} diff --git a/packages/editor/ProseMirror/testing/createTestEditor.ts b/packages/editor/ProseMirror/testing/createTestEditor.ts index a841fef325..56f64594c2 100644 --- a/packages/editor/ProseMirror/testing/createTestEditor.ts +++ b/packages/editor/ProseMirror/testing/createTestEditor.ts @@ -3,7 +3,7 @@ import { EditorView } from 'prosemirror-view'; import schema from '../schema'; import { EditorState, Plugin } from 'prosemirror-state'; -type PluginList = Plugin[]|(Plugin|Plugin[])[]; +export type PluginList = Plugin[]|(Plugin|Plugin[])[]; interface Options { parent?: HTMLElement; @@ -12,6 +12,11 @@ interface Options { } const createTestEditor = ({ html, parent = null, plugins = [] }: Options) => { + if (parent === null) { + // Create a test parent -- some code adds tooltips, etc to view.dom.parent. + parent = document.createElement('div'); + } + const htmlDocument = new DOMParser().parseFromString(html, 'text/html'); const proseMirrorDocument = ProseMirrorDomParser.fromSchema(schema).parse(htmlDocument); return new EditorView(parent, { diff --git a/packages/editor/ProseMirror/testing/createTestEditorWithSerializer.ts b/packages/editor/ProseMirror/testing/createTestEditorWithSerializer.ts new file mode 100644 index 0000000000..0bf44f5a6d --- /dev/null +++ b/packages/editor/ProseMirror/testing/createTestEditorWithSerializer.ts @@ -0,0 +1,48 @@ +import originalMarkupPlugin from '../plugins/originalMarkupPlugin'; +import createTestEditor, { PluginList } from './createTestEditor'; + +interface Props { + html: string; + plugins: PluginList; +} + +const createTestEditorWithSerializer = (props: Props) => { + const serializer = new XMLSerializer(); + const markupToHtml = originalMarkupPlugin(node => serializer.serializeToString(node)); + const view = createTestEditor({ + html: props.html, + plugins: [markupToHtml.plugin, ...props.plugins], + }); + + const normalizeHtml = (html: string) => { + const parsed = new DOMParser().parseFromString([ + '', + '', + '', + html, + '', + '', + ].join(''), 'text/html'); + + // Remove extra XMLNS declarations. These are sometimes added by serialization + // done by the originalMarkupPlugin. + const namespacedRootElements = parsed.body.querySelectorAll(':scope > [xmlns]'); + for (const element of namespacedRootElements) { + element.removeAttribute('xmlns'); + } + + return serializer.serializeToString( + parsed.querySelector('body'), + ); + }; + + return { + view, + toHtml: () => { + return normalizeHtml(markupToHtml.stateToMarkup(view.state).trim()); + }, + normalizeHtml, + }; +}; + +export default createTestEditorWithSerializer; diff --git a/packages/editor/ProseMirror/utils/SelectableNodeView.ts b/packages/editor/ProseMirror/utils/SelectableNodeView.ts new file mode 100644 index 0000000000..3aa3f9ab8c --- /dev/null +++ b/packages/editor/ProseMirror/utils/SelectableNodeView.ts @@ -0,0 +1,19 @@ +import { NodeView } from 'prosemirror-view'; + +export type GetPosition = ()=> number; + +export default class SelectableNodeView implements NodeView { + public readonly dom: HTMLElement; + public constructor(inline: boolean) { + this.dom = document.createElement(inline ? 'span' : 'div'); + this.dom.classList.add('joplin-selectable'); + } + + public selectNode() { + this.dom.classList.add('-selected'); + } + + public deselectNode() { + this.dom.classList.remove('-selected'); + } +} diff --git a/packages/editor/ProseMirror/utils/dom/createButton.ts b/packages/editor/ProseMirror/utils/dom/createButton.ts index be3f9b6ae1..bafbfc64a7 100644 --- a/packages/editor/ProseMirror/utils/dom/createButton.ts +++ b/packages/editor/ProseMirror/utils/dom/createButton.ts @@ -7,32 +7,6 @@ const createButton = (label: LocalizationResult, onClick: OnClick) => { const button = document.createElement('button'); button.appendChild(createTextNode(label)); - // Works around an issue on iOS in which certain