1
0
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:
Henry Heino
2025-08-18 01:35:48 -07:00
committed by GitHub
parent 97b0ffc263
commit 88ab916008
10 changed files with 199 additions and 32 deletions

View File

@@ -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
View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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_({

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

View File

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

View File

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