You've already forked joplin
mirror of
https://github.com/laurent22/joplin.git
synced 2025-08-24 20:19:10 +02:00
Mobile: Rich Text Editor: Support rendering table of contents blocks (#12949)
This commit is contained in:
@@ -1107,7 +1107,9 @@ packages/editor/ProseMirror/utils/dom/createTextNode.js
|
||||
packages/editor/ProseMirror/utils/dom/createUniqueId.js
|
||||
packages/editor/ProseMirror/utils/extractSelectedLinesTo.test.js
|
||||
packages/editor/ProseMirror/utils/extractSelectedLinesTo.js
|
||||
packages/editor/ProseMirror/utils/forEachHeading.js
|
||||
packages/editor/ProseMirror/utils/jumpToHash.js
|
||||
packages/editor/ProseMirror/utils/makeLinksClickableInElement.js
|
||||
packages/editor/ProseMirror/utils/preprocessEditorInput.test.js
|
||||
packages/editor/ProseMirror/utils/preprocessEditorInput.js
|
||||
packages/editor/ProseMirror/utils/sanitizeHtml.js
|
||||
|
2
.gitignore
vendored
2
.gitignore
vendored
@@ -1080,7 +1080,9 @@ packages/editor/ProseMirror/utils/dom/createTextNode.js
|
||||
packages/editor/ProseMirror/utils/dom/createUniqueId.js
|
||||
packages/editor/ProseMirror/utils/extractSelectedLinesTo.test.js
|
||||
packages/editor/ProseMirror/utils/extractSelectedLinesTo.js
|
||||
packages/editor/ProseMirror/utils/forEachHeading.js
|
||||
packages/editor/ProseMirror/utils/jumpToHash.js
|
||||
packages/editor/ProseMirror/utils/makeLinksClickableInElement.js
|
||||
packages/editor/ProseMirror/utils/preprocessEditorInput.test.js
|
||||
packages/editor/ProseMirror/utils/preprocessEditorInput.js
|
||||
packages/editor/ProseMirror/utils/sanitizeHtml.js
|
||||
|
@@ -369,4 +369,26 @@ describe('RichTextEditor', () => {
|
||||
expect(body.trim()).toBe('Test:\n\n$$\n3^2 + 4^2 = \\sqrt{625}\n$$\n\nTest. testing');
|
||||
});
|
||||
});
|
||||
|
||||
it('should preserve table of contents blocks on edit', async () => {
|
||||
let body = '# Heading\n\n# Heading 2\n\n[toc]\n\nTest.';
|
||||
|
||||
render(<WrappedEditor
|
||||
noteBody={body}
|
||||
onBodyChange={newBody => { body = newBody; }}
|
||||
/>);
|
||||
|
||||
// Should render the [toc] as a joplin-editable
|
||||
const renderedTableOfContents = await findElement<HTMLElement>('div.joplin-editable');
|
||||
expect(renderedTableOfContents).toBeTruthy();
|
||||
// Should have a link for each heading
|
||||
expect(renderedTableOfContents.querySelectorAll('a[href]')).toHaveLength(2);
|
||||
|
||||
const window = await getEditorWindow();
|
||||
mockTyping(window, ' testing');
|
||||
|
||||
await waitFor(async () => {
|
||||
expect(body.trim()).toBe('# Heading\n\n# Heading 2\n\n[toc]\n\nTest. testing');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@@ -202,7 +202,7 @@ const commands: Record<EditorCommandType, ExtendedCommand|null> = {
|
||||
[EditorCommandType.ReplaceSelection]: null,
|
||||
[EditorCommandType.SetText]: null,
|
||||
[EditorCommandType.JumpToHash]: (state, dispatch, view, [targetHash]) => {
|
||||
return jumpToHash(targetHash, schema.nodes.heading)(state, dispatch, view);
|
||||
return jumpToHash(targetHash)(state, dispatch, view);
|
||||
},
|
||||
};
|
||||
|
||||
|
@@ -99,4 +99,22 @@ describe('joplinEditablePlugin', () => {
|
||||
// Should render the updated content
|
||||
expect(renderedEditable.querySelector('.test-content').innerHTML).toBe('Mocked!');
|
||||
});
|
||||
|
||||
test('should make #hash links clickable', () => {
|
||||
const editor = createEditor(`
|
||||
<div class="joplin-editable">
|
||||
<a href="#test-heading-1">Test</a>
|
||||
<a href="#test-heading-2">Test</a>
|
||||
</div>
|
||||
<h1>Test heading 1</h1>
|
||||
<h1>Test heading 2</h1>
|
||||
`);
|
||||
const hashLinks = editor.dom.querySelectorAll<HTMLAnchorElement>('a[href^="#test"]');
|
||||
|
||||
hashLinks[0].click();
|
||||
expect(editor.state.selection.$from.parent.textContent).toBe('Test heading 1');
|
||||
|
||||
hashLinks[1].click();
|
||||
expect(editor.state.selection.$from.parent.textContent).toBe('Test heading 2');
|
||||
});
|
||||
});
|
||||
|
@@ -1,5 +1,5 @@
|
||||
import { Plugin } from 'prosemirror-state';
|
||||
import { Node, NodeSpec } from 'prosemirror-model';
|
||||
import { Node, NodeSpec, TagParseRule } from 'prosemirror-model';
|
||||
import { EditorView, NodeView } from 'prosemirror-view';
|
||||
import sanitizeHtml from '../../utils/sanitizeHtml';
|
||||
import createEditorDialog from './createEditorDialog';
|
||||
@@ -7,27 +7,43 @@ import { getEditorApi } from '../joplinEditorApiPlugin';
|
||||
import { msleep } from '@joplin/utils/time';
|
||||
import createTextNode from '../../utils/dom/createTextNode';
|
||||
import postProcessRenderedHtml from './postProcessRenderedHtml';
|
||||
import makeLinksClickableInElement from '../../utils/makeLinksClickableInElement';
|
||||
|
||||
// See the fold example for more information about
|
||||
// writing similar ProseMirror plugins:
|
||||
// https://prosemirror.net/examples/fold/
|
||||
|
||||
interface JoplinEditableAttributes {
|
||||
contentHtml: string;
|
||||
source: string;
|
||||
language: string;
|
||||
openCharacters: string;
|
||||
closeCharacters: string;
|
||||
readOnly: boolean;
|
||||
}
|
||||
|
||||
const makeJoplinEditableSpec = (inline: boolean): NodeSpec => ({
|
||||
const joplinEditableAttributes = {
|
||||
contentHtml: { default: '', validate: 'string' },
|
||||
source: { default: '', validate: 'string' },
|
||||
language: { default: '', validate: 'string' },
|
||||
openCharacters: { default: '', validate: 'string' },
|
||||
closeCharacters: { default: '', validate: 'string' },
|
||||
readOnly: { default: false, validate: 'boolean' },
|
||||
} satisfies Record<keyof JoplinEditableAttributes, unknown>;
|
||||
|
||||
const makeJoplinEditableSpec = (
|
||||
inline: boolean,
|
||||
// Additional tags that should be interpreted as joplinEditable-like blocks.
|
||||
additionalParseRules: TagParseRule[],
|
||||
): NodeSpec => ({
|
||||
group: inline ? 'inline' : 'block',
|
||||
inline: inline,
|
||||
draggable: true,
|
||||
attrs: {
|
||||
contentHtml: { default: '', validate: 'string' },
|
||||
source: { default: '', validate: 'string' },
|
||||
language: { default: '', validate: 'string' },
|
||||
openCharacters: { default: '', validate: 'string' },
|
||||
closeCharacters: { default: '', validate: 'string' },
|
||||
},
|
||||
attrs: joplinEditableAttributes,
|
||||
parseDOM: [
|
||||
{
|
||||
tag: `${inline ? 'span' : 'div'}.joplin-editable`,
|
||||
getAttrs: node => {
|
||||
getAttrs: (node): Partial<JoplinEditableAttributes> => {
|
||||
const sourceNode = node.querySelector('.joplin-source');
|
||||
return {
|
||||
contentHtml: node.innerHTML,
|
||||
@@ -35,20 +51,38 @@ const makeJoplinEditableSpec = (inline: boolean): NodeSpec => ({
|
||||
openCharacters: sourceNode?.getAttribute('data-joplin-source-open'),
|
||||
closeCharacters: sourceNode?.getAttribute('data-joplin-source-close'),
|
||||
language: sourceNode?.getAttribute('data-joplin-language'),
|
||||
readOnly: !!node.hasAttribute('data-joplin-readonly'),
|
||||
};
|
||||
},
|
||||
},
|
||||
...additionalParseRules,
|
||||
],
|
||||
toDOM: node => {
|
||||
const attrs = node.attrs as JoplinEditableAttributes;
|
||||
const content = document.createElement(inline ? 'span' : 'div');
|
||||
content.classList.add('joplin-editable');
|
||||
content.innerHTML = sanitizeHtml(node.attrs.contentHtml);
|
||||
content.innerHTML = sanitizeHtml(attrs.contentHtml);
|
||||
|
||||
const sourceNode = content.querySelector('.joplin-source');
|
||||
const getSourceNode = () => {
|
||||
let sourceNode = content.querySelector('.joplin-source');
|
||||
// If the node has a "source" attribute, its content still needs to be saved
|
||||
if (!sourceNode && attrs.source) {
|
||||
sourceNode = document.createElement(inline ? 'span' : 'div');
|
||||
sourceNode.classList.add('joplin-source');
|
||||
content.appendChild(sourceNode);
|
||||
}
|
||||
return sourceNode;
|
||||
};
|
||||
|
||||
const sourceNode = getSourceNode();
|
||||
if (sourceNode) {
|
||||
sourceNode.textContent = node.attrs.source;
|
||||
sourceNode.setAttribute('data-joplin-source-open', node.attrs.openCharacters);
|
||||
sourceNode.setAttribute('data-joplin-source-close', node.attrs.closeCharacters);
|
||||
sourceNode.textContent = attrs.source;
|
||||
sourceNode.setAttribute('data-joplin-source-open', attrs.openCharacters);
|
||||
sourceNode.setAttribute('data-joplin-source-close', attrs.closeCharacters);
|
||||
}
|
||||
|
||||
if (attrs.readOnly) {
|
||||
content.setAttribute('data-joplin-readonly', 'true');
|
||||
}
|
||||
|
||||
return content;
|
||||
@@ -56,8 +90,28 @@ const makeJoplinEditableSpec = (inline: boolean): NodeSpec => ({
|
||||
});
|
||||
|
||||
export const nodeSpecs = {
|
||||
joplinEditableInline: makeJoplinEditableSpec(true),
|
||||
joplinEditableBlock: makeJoplinEditableSpec(false),
|
||||
joplinEditableInline: makeJoplinEditableSpec(true, []),
|
||||
joplinEditableBlock: makeJoplinEditableSpec(false, [
|
||||
// Table of contents regions are also handled as block editable regions
|
||||
{
|
||||
tag: 'nav.table-of-contents',
|
||||
getAttrs: (node): false|Partial<JoplinEditableAttributes> => {
|
||||
// Additional validation to check that this is indeed a [toc].
|
||||
if (node.children.length !== 1 || node.children[0]?.tagName !== 'UL') {
|
||||
return false; // The rule doesn't match
|
||||
}
|
||||
|
||||
return {
|
||||
contentHtml: node.innerHTML,
|
||||
source: '[toc]',
|
||||
// Disable the [toc]'s default rerendering behavior -- table of contents rendering
|
||||
// requires the document's full content and won't work if "[toc]" is rendered on its
|
||||
// own.
|
||||
readOnly: true,
|
||||
};
|
||||
},
|
||||
},
|
||||
]),
|
||||
};
|
||||
|
||||
type GetPosition = ()=> number;
|
||||
@@ -71,6 +125,11 @@ class EditableSourceBlockView implements NodeView {
|
||||
|
||||
this.dom = document.createElement(inline ? 'span' : 'div');
|
||||
this.dom.classList.add('joplin-editable');
|
||||
|
||||
// The link tooltip used for other in-editor links won't be shown for links within a
|
||||
// rendered source block -- these links need custom logic to be clickable:
|
||||
makeLinksClickableInElement(this.dom, view);
|
||||
|
||||
this.updateContent_();
|
||||
}
|
||||
|
||||
@@ -126,6 +185,7 @@ class EditableSourceBlockView implements NodeView {
|
||||
this.dom.innerHTML = sanitizeHtml(html);
|
||||
};
|
||||
|
||||
const attrs = this.node.attrs as JoplinEditableAttributes;
|
||||
const addEditButton = () => {
|
||||
const editButton = document.createElement('button');
|
||||
editButton.classList.add('edit');
|
||||
@@ -137,10 +197,13 @@ class EditableSourceBlockView implements NodeView {
|
||||
this.showEditDialog_();
|
||||
event.preventDefault();
|
||||
};
|
||||
this.dom.appendChild(editButton);
|
||||
|
||||
if (!attrs.readOnly) {
|
||||
this.dom.appendChild(editButton);
|
||||
}
|
||||
};
|
||||
|
||||
setDomContentSafe(this.node.attrs.contentHtml);
|
||||
setDomContentSafe(attrs.contentHtml);
|
||||
postProcessRenderedHtml(this.dom, this.node.isInline);
|
||||
addEditButton();
|
||||
}
|
||||
|
@@ -62,7 +62,7 @@ class LinkTooltip {
|
||||
this.tooltipContent_.onclick = () => {
|
||||
const href = linkMark.attrs.href;
|
||||
if (href.startsWith('#')) {
|
||||
const command = jumpToHash(href.substring(1), schema.nodes.heading);
|
||||
const command = jumpToHash(href.substring(1));
|
||||
command(view.state, view.dispatch, view);
|
||||
} else {
|
||||
this.onEditorEvent_({
|
||||
|
27
packages/editor/ProseMirror/utils/forEachHeading.ts
Normal file
27
packages/editor/ProseMirror/utils/forEachHeading.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import uslug from '@joplin/fork-uslug/lib/uslug';
|
||||
import { Node } from 'prosemirror-model';
|
||||
|
||||
type OnHeading = (node: Node, hash: string, pos: number)=> boolean|void;
|
||||
|
||||
const forEachHeading = (doc: Node, callback: OnHeading) => {
|
||||
let done = false;
|
||||
const seenHashes = new Set<string>();
|
||||
doc.descendants((node, pos) => {
|
||||
if (node.type.name === 'heading') {
|
||||
const originalHash = uslug(node.textContent);
|
||||
|
||||
let hash = originalHash;
|
||||
let counter = 1;
|
||||
while (seenHashes.has(hash)) {
|
||||
counter++;
|
||||
hash = `${originalHash}-${counter}`;
|
||||
}
|
||||
seenHashes.add(hash);
|
||||
|
||||
done = !!callback(node, hash, pos);
|
||||
}
|
||||
return !done;
|
||||
});
|
||||
};
|
||||
|
||||
export default forEachHeading;
|
@@ -1,18 +1,18 @@
|
||||
import { Command, TextSelection } from 'prosemirror-state';
|
||||
import uslug from '@joplin/fork-uslug/lib/uslug';
|
||||
import { NodeType } from 'prosemirror-model';
|
||||
import { focus } from '@joplin/lib/utils/focusHandler';
|
||||
import forEachHeading from './forEachHeading';
|
||||
|
||||
const jumpToHash = (targetHash: string): Command => (state, dispatch, view) => {
|
||||
if (targetHash.startsWith('#')) {
|
||||
targetHash = targetHash.substring(1);
|
||||
}
|
||||
|
||||
const jumpToHash = (targetHash: string, headingType: NodeType): Command => (state, dispatch, view) => {
|
||||
let targetHeaderPos: number|null = null;
|
||||
state.doc.descendants((node, pos) => {
|
||||
if (node.type === headingType) {
|
||||
const hash = uslug(node.textContent);
|
||||
if (hash === targetHash) {
|
||||
// Subtract one to move the selection to the end of
|
||||
// the node:
|
||||
targetHeaderPos = pos + node.nodeSize - 1;
|
||||
}
|
||||
forEachHeading(view.state.doc, (node, hash, pos) => {
|
||||
if (hash === targetHash) {
|
||||
// Subtract one to move the selection to the end of
|
||||
// the node:
|
||||
targetHeaderPos = pos + node.nodeSize - 1;
|
||||
}
|
||||
|
||||
return targetHeaderPos !== null;
|
||||
|
@@ -0,0 +1,33 @@
|
||||
import { EditorView } from 'prosemirror-view';
|
||||
import jumpToHash from './jumpToHash';
|
||||
import { getEditorApi } from '../plugins/joplinEditorApiPlugin';
|
||||
import { EditorEventType } from '../../events';
|
||||
|
||||
const makeLinksClickableInElement = (element: HTMLElement, view: EditorView) => {
|
||||
const followLink = (target: HTMLAnchorElement) => {
|
||||
const href = target.getAttribute('href');
|
||||
if (href) {
|
||||
if (href.startsWith('#')) {
|
||||
return jumpToHash(href)(view.state, view.dispatch, view);
|
||||
} else {
|
||||
getEditorApi(view.state).onEvent({
|
||||
kind: EditorEventType.FollowLink,
|
||||
link: href,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
element.addEventListener('click', event => {
|
||||
if (event.target instanceof Element && !event.defaultPrevented) {
|
||||
const closestLink = event.target.closest<HTMLAnchorElement>('a[href]');
|
||||
if (closestLink && followLink(closestLink)) {
|
||||
event.preventDefault();
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export default makeLinksClickableInElement;
|
Reference in New Issue
Block a user