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

Mobile: Resolves #13104: Accessibility: Allow changing the ALT text of images from the mobile Rich Text Editor (#13169)

Co-authored-by: Laurent Cozic <laurent22@users.noreply.github.com>
This commit is contained in:
Henry Heino
2025-09-30 09:34:03 -07:00
committed by GitHub
parent f832eb38ff
commit 24ff4612fb
28 changed files with 876 additions and 544 deletions

View File

@@ -1098,6 +1098,8 @@ packages/editor/ProseMirror/createEditor.js
packages/editor/ProseMirror/index.js packages/editor/ProseMirror/index.js
packages/editor/ProseMirror/plugins/detailsPlugin.test.js packages/editor/ProseMirror/plugins/detailsPlugin.test.js
packages/editor/ProseMirror/plugins/detailsPlugin.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/inputRulesPlugin.js
packages/editor/ProseMirror/plugins/joplinEditablePlugin/createEditorDialog.js packages/editor/ProseMirror/plugins/joplinEditablePlugin/createEditorDialog.js
packages/editor/ProseMirror/plugins/joplinEditablePlugin/joplinEditablePlugin.test.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/linkTooltipPlugin.js
packages/editor/ProseMirror/plugins/listPlugin.js packages/editor/ProseMirror/plugins/listPlugin.js
packages/editor/ProseMirror/plugins/originalMarkupPlugin.js packages/editor/ProseMirror/plugins/originalMarkupPlugin.js
packages/editor/ProseMirror/plugins/resourcePlaceholderPlugin.js
packages/editor/ProseMirror/plugins/searchPlugin.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/schema.js
packages/editor/ProseMirror/styles.js packages/editor/ProseMirror/styles.js
packages/editor/ProseMirror/testing/createTestEditor.js packages/editor/ProseMirror/testing/createTestEditor.js
packages/editor/ProseMirror/testing/createTestEditorWithSerializer.js
packages/editor/ProseMirror/types.js packages/editor/ProseMirror/types.js
packages/editor/ProseMirror/utils/SelectableNodeView.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
@@ -1122,6 +1127,7 @@ packages/editor/ProseMirror/utils/dom/createButton.js
packages/editor/ProseMirror/utils/dom/createTextArea.js packages/editor/ProseMirror/utils/dom/createTextArea.js
packages/editor/ProseMirror/utils/dom/createTextNode.js packages/editor/ProseMirror/utils/dom/createTextNode.js
packages/editor/ProseMirror/utils/dom/createUniqueId.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.test.js
packages/editor/ProseMirror/utils/extractSelectedLinesTo.js packages/editor/ProseMirror/utils/extractSelectedLinesTo.js
packages/editor/ProseMirror/utils/forEachHeading.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.test.js
packages/editor/ProseMirror/utils/preprocessEditorInput.js packages/editor/ProseMirror/utils/preprocessEditorInput.js
packages/editor/ProseMirror/utils/sanitizeHtml.js packages/editor/ProseMirror/utils/sanitizeHtml.js
packages/editor/ProseMirror/utils/selectFirstInstanceOfNode.js
packages/editor/ProseMirror/utils/trimEmptyParagraphs.js packages/editor/ProseMirror/utils/trimEmptyParagraphs.js
packages/editor/ProseMirror/vendor/changedDescendants.js packages/editor/ProseMirror/vendor/changedDescendants.js
packages/editor/ProseMirror/vendor/splitBlockAs.js packages/editor/ProseMirror/vendor/splitBlockAs.js

9
.gitignore vendored
View File

@@ -1071,6 +1071,8 @@ packages/editor/ProseMirror/createEditor.js
packages/editor/ProseMirror/index.js packages/editor/ProseMirror/index.js
packages/editor/ProseMirror/plugins/detailsPlugin.test.js packages/editor/ProseMirror/plugins/detailsPlugin.test.js
packages/editor/ProseMirror/plugins/detailsPlugin.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/inputRulesPlugin.js
packages/editor/ProseMirror/plugins/joplinEditablePlugin/createEditorDialog.js packages/editor/ProseMirror/plugins/joplinEditablePlugin/createEditorDialog.js
packages/editor/ProseMirror/plugins/joplinEditablePlugin/joplinEditablePlugin.test.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/linkTooltipPlugin.js
packages/editor/ProseMirror/plugins/listPlugin.js packages/editor/ProseMirror/plugins/listPlugin.js
packages/editor/ProseMirror/plugins/originalMarkupPlugin.js packages/editor/ProseMirror/plugins/originalMarkupPlugin.js
packages/editor/ProseMirror/plugins/resourcePlaceholderPlugin.js
packages/editor/ProseMirror/plugins/searchPlugin.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/schema.js
packages/editor/ProseMirror/styles.js packages/editor/ProseMirror/styles.js
packages/editor/ProseMirror/testing/createTestEditor.js packages/editor/ProseMirror/testing/createTestEditor.js
packages/editor/ProseMirror/testing/createTestEditorWithSerializer.js
packages/editor/ProseMirror/types.js packages/editor/ProseMirror/types.js
packages/editor/ProseMirror/utils/SelectableNodeView.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
@@ -1095,6 +1100,7 @@ packages/editor/ProseMirror/utils/dom/createButton.js
packages/editor/ProseMirror/utils/dom/createTextArea.js packages/editor/ProseMirror/utils/dom/createTextArea.js
packages/editor/ProseMirror/utils/dom/createTextNode.js packages/editor/ProseMirror/utils/dom/createTextNode.js
packages/editor/ProseMirror/utils/dom/createUniqueId.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.test.js
packages/editor/ProseMirror/utils/extractSelectedLinesTo.js packages/editor/ProseMirror/utils/extractSelectedLinesTo.js
packages/editor/ProseMirror/utils/forEachHeading.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.test.js
packages/editor/ProseMirror/utils/preprocessEditorInput.js packages/editor/ProseMirror/utils/preprocessEditorInput.js
packages/editor/ProseMirror/utils/sanitizeHtml.js packages/editor/ProseMirror/utils/sanitizeHtml.js
packages/editor/ProseMirror/utils/selectFirstInstanceOfNode.js
packages/editor/ProseMirror/utils/trimEmptyParagraphs.js packages/editor/ProseMirror/utils/trimEmptyParagraphs.js
packages/editor/ProseMirror/vendor/changedDescendants.js packages/editor/ProseMirror/vendor/changedDescendants.js
packages/editor/ProseMirror/vendor/splitBlockAs.js packages/editor/ProseMirror/vendor/splitBlockAs.js

View File

@@ -23,6 +23,7 @@ import { MarkupLanguage } from '@joplin/renderer';
import { NoteEntity } from '@joplin/lib/services/database/types'; import { NoteEntity } from '@joplin/lib/services/database/types';
import { EditorSettings } from './types'; import { EditorSettings } from './types';
import { pregQuote } from '@joplin/lib/string-utils'; import { pregQuote } from '@joplin/lib/string-utils';
import { join } from 'path';
interface WrapperProps { interface WrapperProps {
@@ -103,8 +104,8 @@ const mockTyping = (window: EditorWindow, text: string) => {
} }
}; };
const mockSelectionMovement = (window: EditorWindow, position: number) => { const mockSelectionMovement = (window: EditorWindow, from: number, to?: number) => {
getEditorControl(window).select(position, position); getEditorControl(window).select(from, to ?? from);
}; };
const findElement = async function<ElementType extends Element = Element>(selector: string) { const findElement = async function<ElementType extends Element = Element>(selector: string) {
@@ -333,7 +334,7 @@ describe('RichTextEditor', () => {
const editorContent = body.trim(); const editorContent = body.trim();
if (markupLanguage === MarkupLanguage.Html) { if (markupLanguage === MarkupLanguage.Html) {
expect(editorContent).toMatch( expect(editorContent).toMatch(
new RegExp(`^<p><img src=":/${pregQuote(resource.id)}" alt="${pregQuote(renderedImage.alt)}"[^>]*> test</p>$`), new RegExp(`^<p><img[^>]* src=":/${pregQuote(resource.id)}" alt="${pregQuote(renderedImage.alt)}"[^>]*> test</p>$`),
); );
} else { } else {
expect(editorContent).toBe(`![${renderedImage.alt}](:/${resource.id}) test`); 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(<WrappedEditor
noteBody={note.body}
note={note}
onBodyChange={newBody => { 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([ it.each([
{ useValidSyntax: false }, { useValidSyntax: false },
{ useValidSyntax: true }, { 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$$'; let body = 'Test:\n\n$$3^2 + 4^2 = 5^2$$';
render(<WrappedEditor render(<WrappedEditor
noteBody={body} noteBody={body}
onBodyChange={newBody => { body = newBody; }} onBodyChange={newBody => { body = newBody; }}
/>); />);
const editButton = await findElement<HTMLButtonElement>('button.edit'); const window = await getEditorWindow();
// Select the math block to show the "edit" button.
mockSelectionMovement(window, '<Test:>'.length, '<Test:>$'.length);
const editButton = await findElement<HTMLButtonElement>('button.edit-button');
editButton.click(); editButton.click();
const editor = await findElement('dialog .cm-editor'); const editor = await findElement('dialog .cm-editor');
@@ -453,7 +481,7 @@ describe('RichTextEditor', () => {
'<sup>Super</sup>script', '<sup>Super</sup>script',
'<sub>Sub</sub>script', '<sub>Sub</sub>script',
'![image](data:image/svg+xml;utf8,test)', '![image](data:image/svg+xml;utf8,test)',
'<img src="data:image/svg+xml;utf8,test" width="120">', '<img width="120" src="data:image/svg+xml;utf8,test">',
])('should preserve inline markup on edit (case %#)', async (initialBody) => { ])('should preserve inline markup on edit (case %#)', async (initialBody) => {
initialBody += 'test'; // Ensure that typing will add new content outside the formatting initialBody += 'test'; // Ensure that typing will add new content outside the formatting
let body = initialBody; let body = initialBody;

View File

@@ -44,6 +44,13 @@ function useCss(themeId: number, editorCss: string): string {
font-size: 13pt; font-size: 13pt;
font-family: ${JSON.stringify(theme.fontFamily)}, sans-serif; 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]); }, [themeId, editorCss]);
} }

View File

@@ -23,7 +23,7 @@ import searchExtension from './plugins/searchPlugin';
import joplinEditorApiPlugin, { setEditorApi } from './plugins/joplinEditorApiPlugin'; import joplinEditorApiPlugin, { setEditorApi } from './plugins/joplinEditorApiPlugin';
import linkTooltipPlugin from './plugins/linkTooltipPlugin'; import linkTooltipPlugin from './plugins/linkTooltipPlugin';
import { OnCreateCodeEditor as OnCreateCodeEditor, RendererControl } from './types'; 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 getFileFromPasteEvent from '../utils/getFileFromPasteEvent';
import { RenderResult } from '../../renderer/types'; import { RenderResult } from '../../renderer/types';
import postprocessEditorOutput from './utils/postprocessEditorOutput'; import postprocessEditorOutput from './utils/postprocessEditorOutput';
@@ -92,7 +92,7 @@ const createEditor = async (
linkTooltipPlugin, linkTooltipPlugin,
tableEditing({ allowTableNodeSelection: true }), tableEditing({ allowTableNodeSelection: true }),
joplinEditorApiPlugin, joplinEditorApiPlugin,
resourcePlaceholderPlugin, imagePlugin,
].flat(), ].flat(),
}); });

View File

@@ -1,33 +1,23 @@
import createTestEditor from '../testing/createTestEditor'; import createTestEditor from '../testing/createTestEditor';
import createTestEditorWithSerializer from '../testing/createTestEditorWithSerializer';
import detailsPlugin from './detailsPlugin'; import detailsPlugin from './detailsPlugin';
import originalMarkupPlugin from './originalMarkupPlugin';
describe('detailsPlugin', () => { describe('detailsPlugin', () => {
it('should add jop-noMdConv attributes to <details> and <summary>', () => { it('should add jop-noMdConv attributes to <details> and <summary>', () => {
const serializer = new XMLSerializer(); const { toHtml, normalizeHtml } = createTestEditorWithSerializer({
const markupToHtml = originalMarkupPlugin(node => serializer.serializeToString(node));
const view = createTestEditor({
html: ` html: `
<details><summary>Test</summary> <details><summary>Test</summary>
<p>Test...</p> <p>Test...</p>
</details> </details>
`, `,
plugins: [detailsPlugin, markupToHtml.plugin], plugins: [detailsPlugin],
}); });
// Serialize, then parse to normalize the HTML (for comparison expect(toHtml()).toBe(normalizeHtml([
// with the HTML serialized by markupToHtml). '<details class="jop-noMdConv"><summary class="jop-noMdConv">Test</summary>',
const expectedState = serializer.serializeToString( '<p>Test...</p>',
new DOMParser().parseFromString([ '</details>',
'<details class="jop-noMdConv"><summary class="jop-noMdConv">Test</summary>', ].join('')));
'<p>Test...</p>',
'</details>',
].join(''), 'text/html').querySelector('details'),
);
expect(
markupToHtml.stateToMarkup(view.state).trim(),
).toBe(expectedState);
}); });
it.each([ it.each([

View File

@@ -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: `
<img src="${testImageUrl}" alt="Test"/>
`,
plugins: [],
});
expect(toHtml()).toBe(normalizeHtml(`<p><img src="${testImageUrl}" alt="Test"/></p>`));
});
});

View File

@@ -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<keyof NodeAttrs, AttributeSpec>;
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;

View File

@@ -1,7 +1,6 @@
import { focus } from '@joplin/lib/utils/focusHandler';
import createTextNode from '../../utils/dom/createTextNode';
import { EditorApi } from '../joplinEditorApiPlugin'; import { EditorApi } from '../joplinEditorApiPlugin';
import { EditorLanguageType } from '../../../types'; import { EditorLanguageType } from '../../../types';
import showModal from '../../utils/dom/showModal';
interface SourceBlockData { interface SourceBlockData {
start: string; start: string;
@@ -19,18 +18,12 @@ interface Options {
} }
const createEditorDialog = ({ editorApi, doneLabel, block, onSave, onDismiss }: Options) => { const createEditorDialog = ({ editorApi, doneLabel, block, onSave, onDismiss }: Options) => {
const dialog = document.createElement('dialog'); const content = document.createElement('div');
dialog.classList.add('editor-dialog', '-visible'); content.classList.add('editor-dialog-content');
document.body.appendChild(dialog); document.body.appendChild(content);
dialog.onclose = () => {
onDismiss();
dialog.remove();
editor.remove();
};
const editor = editorApi.createCodeEditor( const editor = editorApi.createCodeEditor(
dialog, content,
EditorLanguageType.Markdown, EditorLanguageType.Markdown,
(newContent) => { (newContent) => {
block = { block = {
@@ -48,35 +41,14 @@ const createEditorDialog = ({ editorApi, doneLabel, block, onSave, onDismiss }:
block.end, block.end,
].join('')); ].join(''));
const onClose = () => { return showModal({
if (dialog.close) { content,
dialog.close(); doneLabel,
} else { onDismiss: () => {
// Handle the case where the dialog element is not supported by the onDismiss();
// browser/testing environment. editor.remove();
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,
};
}; };
export default createEditorDialog; export default createEditorDialog;

View File

@@ -4,6 +4,8 @@ import createTestEditor from '../../testing/createTestEditor';
import joplinEditorApiPlugin, { getEditorApi, setEditorApi } from '../joplinEditorApiPlugin'; import joplinEditorApiPlugin, { getEditorApi, setEditorApi } from '../joplinEditorApiPlugin';
import joplinEditablePlugin, { editSourceBlockAt, hideSourceBlockEditor } from './joplinEditablePlugin'; import joplinEditablePlugin, { editSourceBlockAt, hideSourceBlockEditor } from './joplinEditablePlugin';
import { Second } from '@joplin/utils/time'; import { Second } from '@joplin/utils/time';
import { EditorView } from 'prosemirror-view';
import selectFirstInstanceOfNode from '../../utils/selectFirstInstanceOfNode';
const createEditor = (html: string) => { const createEditor = (html: string) => {
return createTestEditor({ return createTestEditor({
@@ -12,12 +14,12 @@ const createEditor = (html: string) => {
}); });
}; };
const findEditButton = (ancestor: Element): HTMLButtonElement => { const findEditButton = (editor: EditorView): HTMLButtonElement => {
return ancestor.querySelector('.joplin-editable > button.edit'); return editor.dom.parentElement.querySelector('.floating-button-bar:not(.-hidden) > .edit-button');
}; };
const findEditorDialog = () => { const findEditorDialog = () => {
const dialog = document.querySelector('dialog.editor-dialog'); const dialog = document.querySelector('dialog.joplin-dialog');
if (!dialog) { if (!dialog) {
return null; return null;
} }
@@ -47,13 +49,19 @@ describe('joplinEditablePlugin', () => {
'<p>Test: <mark><span class="joplin-editable"><pre class="joplin-source">test</pre>rendered</span></mark></p>', '<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) => { ])('should show an edit button on source blocks (case %#)', (htmlSource) => {
const editor = createEditor(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'); expect(editButton.textContent).toBe('Edit');
}); });
test('clicking the edit button should show an editor dialog', () => { 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 editor = createEditor('<span class="joplin-editable"><pre class="joplin-source">test source</pre>rendered</span>');
const editButton = findEditButton(editor.dom); selectFirstInstanceOfNode(editor, 'joplinEditableInline');
const editButton = findEditButton(editor);
editButton.click(); editButton.click();
// Should show the dialog // Should show the dialog
@@ -76,7 +84,7 @@ describe('joplinEditablePlugin', () => {
}, },
})); }));
const editButton = findEditButton(editor.dom); const editButton = findEditButton(editor);
editButton.click(); editButton.click();
const dialog = findEditorDialog(); const dialog = findEditorDialog();

View File

@@ -1,64 +1,22 @@
import { Command, EditorState, Plugin } from 'prosemirror-state'; import { Plugin } from 'prosemirror-state';
import { Node, NodeSpec, TagParseRule } from 'prosemirror-model'; import { Node, NodeSpec, TagParseRule } from 'prosemirror-model';
import { EditorView, NodeView } from 'prosemirror-view'; import { EditorView } from 'prosemirror-view';
import sanitizeHtml from '../../utils/sanitizeHtml'; import sanitizeHtml from '../../utils/sanitizeHtml';
import createEditorDialog from './createEditorDialog'; import createEditorDialog from './createEditorDialog';
import { getEditorApi } from '../joplinEditorApiPlugin'; import { getEditorApi } from '../joplinEditorApiPlugin';
import { msleep } from '@joplin/utils/time'; import { msleep } from '@joplin/utils/time';
import postProcessRenderedHtml from './postProcessRenderedHtml'; import postProcessRenderedHtml from './postProcessRenderedHtml';
import createButton from '../../utils/dom/createButton';
import makeLinksClickableInElement from '../../utils/makeLinksClickableInElement'; 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 // See the fold example for more information about
// writing similar ProseMirror plugins: // writing similar ProseMirror plugins:
// https://prosemirror.net/examples/fold/ // https://prosemirror.net/examples/fold/
type EditRequest = {
nodeStart: number;
showEditor: true;
} | {
nodeStart?: undefined;
showEditor: false;
};
export const editSourceBlockAt = (nodeStart: number): Command => (state, dispatch) => { const createEditorDialogForNode = (nodePosition: number, view: EditorView, onHide: OnHide) => {
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; let saveCounter = 0;
const getNode = () => ( const getNode = () => (
@@ -108,7 +66,7 @@ const createDialogForNode = (nodePosition: number, view: EditorView) => {
); );
}, },
onDismiss: () => { onDismiss: () => {
hideSourceBlockEditor(view.state, view.dispatch, view); onHide();
}, },
}); });
@@ -120,8 +78,6 @@ const createDialogForNode = (nodePosition: number, view: EditorView) => {
}; };
}; };
type DialogHandle = ReturnType<typeof createDialogForNode>;
interface JoplinEditableAttributes { interface JoplinEditableAttributes {
contentHtml: string; contentHtml: string;
@@ -224,16 +180,14 @@ export const nodeSpecs = {
]), ]),
}; };
type GetPosition = ()=> number; class EditableSourceBlockView extends SelectableNodeView {
public constructor(private node: Node, inline: boolean, view: EditorView) {
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) { if ((node.attrs.contentHtml ?? undefined) === undefined) {
throw new Error(`Unable to create a SourceBlockView for a node lacking contentHtml. Node: ${node}.`); 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'); this.dom.classList.add('joplin-editable');
// The link tooltip used for other in-editor links won't be shown for links within a // 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_(); this.updateContent_();
} }
private showEditDialog_() {
editSourceBlockAt(this.getPosition())(this.view.state, this.view.dispatch, this.view);
}
private updateContent_() { private updateContent_() {
const setDomContentSafe = (html: string) => { const setDomContentSafe = (html: string) => {
this.dom.innerHTML = sanitizeHtml(html); this.dom.innerHTML = sanitizeHtml(html);
}; };
const attrs = this.node.attrs as JoplinEditableAttributes; 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); setDomContentSafe(attrs.contentHtml);
postProcessRenderedHtml(this.dom, this.node.isInline); 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) { public update(node: Node) {
@@ -294,64 +219,32 @@ class EditableSourceBlockView implements NodeView {
} }
} }
interface PluginState { const { plugin: externalEditorPlugin, hideEditor, editAt } = createExternalEditorPlugin({
editingNodeAt: number|null; canEdit: (node: Node) => {
} return (node.type.name === 'joplinEditableInline' || node.type.name === 'joplinEditableBlock') && !node.attrs.readOnly;
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();
}
},
};
}, },
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)
)),
];

View File

@@ -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<keyof NodeAttrs, AttributeSpec>;
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<string, string>;
}
const resourcePlaceholderPlugin: Plugin<PluginState> = 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;

View File

@@ -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<PluginState>({
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;

View File

@@ -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;

View File

@@ -2,7 +2,7 @@ import { AttributeSpec, DOMOutputSpec, MarkSpec, NodeSpec, Schema } from 'prosem
import { nodeSpecs as joplinEditableNodes } from './plugins/joplinEditablePlugin/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 imageNodes } from './plugins/imagePlugin';
import { hasProtocol } from '@joplin/utils/url'; import { hasProtocol } from '@joplin/utils/url';
import { isResourceUrl } from '@joplin/lib/models/utils/resourceUtils'; import { isResourceUrl } from '@joplin/lib/models/utils/resourceUtils';
import { nodeSpecs as detailsNodes } from './plugins/detailsPlugin'; import { nodeSpecs as detailsNodes } from './plugins/detailsPlugin';
@@ -136,12 +136,12 @@ const nodes = addDefaultToplevelAttributes({
}, },
}, },
...detailsNodes, ...detailsNodes,
...resourcePlaceholderNodes, ...imageNodes,
...listNodes, ...listNodes,
...joplinEditableNodes, ...joplinEditableNodes,
...tableNodes({ ...tableNodes({
tableGroup: 'block', tableGroup: 'block',
cellContent: 'inline*', cellContent: 'block+',
cellAttributes: {}, cellAttributes: {},
}), }),
// Override the default `table` node to include the default toplevel attributes // Override the default `table` node to include the default toplevel attributes
@@ -157,56 +157,6 @@ const nodes = addDefaultToplevelAttributes({
text: { text: {
group: 'inline', 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<string, unknown> = { 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: { hard_break: {
inline: true, inline: true,
group: 'inlineBreak', group: 'inlineBreak',

View File

@@ -2,9 +2,13 @@
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/joplin-selectable.css';
import './styles/joplin-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';
import './styles/joplin-image-view.css';
import './styles/alt-text-editor.css';
import './styles/floating-button-bar.css';

View File

@@ -0,0 +1,5 @@
.alt-text-editor {
display: flex;
flex-direction: column;
}

View File

@@ -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;
}

View File

@@ -1,5 +1,5 @@
.editor-dialog { .joplin-dialog {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
background-color: var(--joplin-background-color); background-color: var(--joplin-background-color);
@@ -12,19 +12,15 @@
padding: 16px; padding: 16px;
} }
.editor-dialog > textarea { .joplin-dialog > button.done {
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); color: var(--joplin-color);
background-color: var(--joplin-background-color3); background-color: var(--joplin-background-color3);
border: none; border: 1px solid var(--joplin-border-color4);
border-radius: 8px; border-radius: 8px;
min-height: 36px;
height: 38px; height: 38px;
bottom: 0;
position: sticky;
} }

View File

@@ -4,25 +4,3 @@
/* Override the "white-space" setting for the editor */ /* Override the "white-space" setting for the editor */
white-space: normal; 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;
}

View File

@@ -0,0 +1,4 @@
.joplin-image-view {
display: inline-block;
}

View File

@@ -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;
}

View File

@@ -3,7 +3,7 @@ import { EditorView } from 'prosemirror-view';
import schema from '../schema'; import schema from '../schema';
import { EditorState, Plugin } from 'prosemirror-state'; import { EditorState, Plugin } from 'prosemirror-state';
type PluginList = Plugin[]|(Plugin|Plugin[])[]; export type PluginList = Plugin[]|(Plugin|Plugin[])[];
interface Options { interface Options {
parent?: HTMLElement; parent?: HTMLElement;
@@ -12,6 +12,11 @@ interface Options {
} }
const createTestEditor = ({ html, parent = null, plugins = [] }: 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 htmlDocument = new DOMParser().parseFromString(html, 'text/html');
const proseMirrorDocument = ProseMirrorDomParser.fromSchema(schema).parse(htmlDocument); const proseMirrorDocument = ProseMirrorDomParser.fromSchema(schema).parse(htmlDocument);
return new EditorView(parent, { return new EditorView(parent, {

View File

@@ -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([
'<!DOCTYPE html>',
'<html>',
'<body>',
html,
'</body>',
'</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;

View File

@@ -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');
}
}

View File

@@ -7,32 +7,6 @@ const createButton = (label: LocalizationResult, onClick: OnClick) => {
const button = document.createElement('button'); const button = document.createElement('button');
button.appendChild(createTextNode(label)); button.appendChild(createTextNode(label));
// Works around an issue on iOS in which certain <button> elements within the selected
// region of a contenteditable container do not emit a "click" event when tapped with a touchscreen.
const applyIOSClickWorkaround = () => {
// touchend events can be received even when a touch is no longer contained within
// the initial element.
const buttonContainsTouch = (touch: Touch) => {
return document.elementFromPoint(touch.clientX, touch.clientY) === button;
};
let containedTouchStart = false;
button.addEventListener('touchcancel', () => {
containedTouchStart = false;
});
button.addEventListener('touchstart', () => {
containedTouchStart = true;
});
button.addEventListener('touchend', (event) => {
if (containedTouchStart && event.touches.length === 0 && buttonContainsTouch(event.changedTouches[0])) {
onClick();
event.preventDefault();
}
containedTouchStart = false;
});
};
applyIOSClickWorkaround();
button.onclick = onClick; button.onclick = onClick;
return button; return button;

View File

@@ -0,0 +1,48 @@
import { LocalizationResult } from '../../../types';
import createButton from './createButton';
interface Options {
content: HTMLElement;
doneLabel: LocalizationResult;
onDismiss: ()=> void;
}
const showModal = ({ content, doneLabel, onDismiss }: Options) => {
const dialog = document.createElement('dialog');
dialog.classList.add('joplin-dialog', '-visible');
document.body.appendChild(dialog);
dialog.onclose = () => {
onDismiss();
dialog.remove();
};
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'));
}
};
dialog.appendChild(content);
const submitButton = createButton(doneLabel, onClose);
submitButton.classList.add('done');
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');
}
return {
dismiss: onClose,
};
};
export default showModal;

View File

@@ -0,0 +1,20 @@
import { NodeSelection } from 'prosemirror-state';
import { EditorView } from 'prosemirror-view';
const selectFirstInstanceOfNode = (view: EditorView, nodeName: string) => {
const state = view.state;
let index = -1;
state.doc.descendants((node, pos) => {
if (node.type.name === nodeName) {
index = pos;
}
});
if (index >= 0) {
view.dispatch(
state.tr.setSelection(NodeSelection.create(view.state.doc, index)),
);
}
};
export default selectFirstInstanceOfNode;