You've already forked joplin
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:
@@ -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
9
.gitignore
vendored
@@ -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
|
||||||
|
@@ -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(` test`);
|
expect(editorContent).toBe(` 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',
|
||||||
'',
|
'',
|
||||||
'<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;
|
||||||
|
@@ -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]);
|
||||||
}
|
}
|
||||||
|
@@ -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(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@@ -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([
|
||||||
|
16
packages/editor/ProseMirror/plugins/imagePlugin.test.ts
Normal file
16
packages/editor/ProseMirror/plugins/imagePlugin.test.ts
Normal 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>`));
|
||||||
|
});
|
||||||
|
});
|
269
packages/editor/ProseMirror/plugins/imagePlugin.ts
Normal file
269
packages/editor/ProseMirror/plugins/imagePlugin.ts
Normal 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;
|
@@ -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;
|
||||||
|
@@ -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();
|
||||||
|
@@ -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)
|
||||||
|
)),
|
||||||
|
];
|
||||||
|
@@ -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;
|
|
@@ -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;
|
@@ -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;
|
@@ -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',
|
||||||
|
@@ -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';
|
||||||
|
|
||||||
|
5
packages/editor/ProseMirror/styles/alt-text-editor.css
Normal file
5
packages/editor/ProseMirror/styles/alt-text-editor.css
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
|
||||||
|
.alt-text-editor {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
13
packages/editor/ProseMirror/styles/floating-button-bar.css
Normal file
13
packages/editor/ProseMirror/styles/floating-button-bar.css
Normal 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;
|
||||||
|
}
|
@@ -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;
|
||||||
}
|
}
|
@@ -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;
|
|
||||||
}
|
|
||||||
|
4
packages/editor/ProseMirror/styles/joplin-image-view.css
Normal file
4
packages/editor/ProseMirror/styles/joplin-image-view.css
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
|
||||||
|
.joplin-image-view {
|
||||||
|
display: inline-block;
|
||||||
|
}
|
26
packages/editor/ProseMirror/styles/joplin-selectable.css
Normal file
26
packages/editor/ProseMirror/styles/joplin-selectable.css
Normal 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;
|
||||||
|
}
|
@@ -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, {
|
||||||
|
@@ -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;
|
19
packages/editor/ProseMirror/utils/SelectableNodeView.ts
Normal file
19
packages/editor/ProseMirror/utils/SelectableNodeView.ts
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
@@ -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;
|
||||||
|
48
packages/editor/ProseMirror/utils/dom/showModal.ts
Normal file
48
packages/editor/ProseMirror/utils/dom/showModal.ts
Normal 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;
|
@@ -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;
|
Reference in New Issue
Block a user