1
0
mirror of https://github.com/laurent22/joplin.git synced 2025-11-29 22:48:10 +02:00
Files
joplin/packages/editor/ProseMirror/schema.ts

276 lines
7.0 KiB
TypeScript

import { AttributeSpec, DOMOutputSpec, MarkSpec, NodeSpec, Schema } from 'prosemirror-model';
import { nodeSpecs as joplinEditableNodes } from './plugins/joplinEditablePlugin/joplinEditablePlugin';
import { tableNodes } from 'prosemirror-tables';
import { nodeSpecs as listNodes } from './plugins/listPlugin';
import { nodeSpecs as imageNodes } from './plugins/imagePlugin';
import { hasProtocol } from '@joplin/utils/url';
import { isResourceUrl } from '@joplin/lib/models/utils/resourceUtils';
import { nodeSpecs as detailsNodes } from './plugins/detailsPlugin';
// For reference, see:
// - https://prosemirror.net/docs/guide/#schema
// - https://github.com/ProseMirror/prosemirror-schema-basic/blob/master/src/schema-basic.ts
const domOutputSpecs = {
paragraph: ['p', 0],
strong: ['strong', 0],
code: ['code', { class: 'inline-code', spellcheck: false }, 0],
emphasis: ['em', 0],
pre: ['pre', { class: 'code-block' }, ['code', 0]],
br: ['br'],
orderedList: ['ol', 0],
unorderedList: ['ul', 0],
listItem: ['li', 0],
blockQuote: ['blockquote', 0],
hr: ['hr'],
sub: ['sub', 0],
sup: ['sup', 0],
mark: ['mark', 0],
} satisfies Record<string, DOMOutputSpec>;
type AttributeSpecs = Record<string, AttributeSpec>;
// Default attributes that should be valid for all toplevel blocks:
const defaultToplevelAttrs: AttributeSpecs = {
// Stores the original markdown used to generate a node.
originalMarkup: { default: '', validate: 'string' },
};
const getDefaultToplevelAttrs = (node: Element) => ({
originalMarkup: node.getAttribute('data-original-markup'),
});
const addDefaultToplevelAttributes = <Nodes extends Record<string, NodeSpec>> (nodes: Nodes) => {
const result: Partial<Nodes> = {};
for (const key in nodes) {
if (nodes[key].group === 'block') {
result[key] = {
...nodes[key],
attrs: {
...defaultToplevelAttrs,
...nodes[key].attrs,
},
parseDOM: nodes[key].parseDOM?.map(rule => {
if (!rule.tag) return rule;
return {
...rule,
getAttrs: (node) => {
const attrs = rule.getAttrs?.(node);
if (attrs === false) return attrs;
return {
...getDefaultToplevelAttrs(node),
...(attrs ?? rule.attrs ?? {}),
};
},
};
}),
};
} else {
result[key] = nodes[key];
}
}
return result as Nodes;
};
const nodes = addDefaultToplevelAttributes({
doc: { content: 'block+' },
paragraph: {
group: 'block',
content: '(inline|hard_break)*',
parseDOM: [{ tag: 'p' }],
toDOM: () => domOutputSpecs.paragraph,
},
heading: { // See prosemirror-schema-basic's `heading`
attrs: {
level: { default: 2, validate: 'number' },
},
group: 'block',
content: 'inline*',
parseDOM: [1, 2, 3, 4, 5, 6].map(level => ({
tag: `h${level}`, getAttrs: node => ({ ...getDefaultToplevelAttrs(node), level }),
})),
toDOM: (node) => [`h${node.attrs.level}`, 0],
},
pre_block: {
content: 'text*',
group: 'block',
marks: '',
code: true,
defining: true, // Preserve the node during replacement operations
parseDOM: [{ tag: 'pre' }],
toDOM: () => domOutputSpecs.pre,
},
blockquote: {
content: 'block+',
group: 'block',
parseDOM: [{ tag: 'blockquote' }],
toDOM: () => domOutputSpecs.blockQuote,
},
horizontal_rule: {
group: 'block',
parseDOM: [{ tag: 'hr' }],
toDOM: () => domOutputSpecs.hr,
},
style_placeholder: {
group: 'block',
parseDOM: [
{
tag: 'style',
getAttrs: node => ({ content: node.textContent }),
},
{
tag: 'div.joplin-style-placeholder',
getAttrs: node => ({ content: node.getAttribute('data-style-content') }),
},
],
attrs: {
content: { validate: 'string' },
},
toDOM: (node) => {
const result = document.createElement('div');
result.classList.add('joplin-style-placeholder');
result.setAttribute('data-style-content', node.attrs.content);
result.textContent = 'CSS';
return result;
},
},
...detailsNodes,
...imageNodes,
...listNodes,
...joplinEditableNodes,
...tableNodes({
tableGroup: 'block',
cellContent: 'block+',
cellAttributes: {},
}),
// Override the default `table` node to include the default toplevel attributes
table: {
content: 'table_row+',
tableRole: 'table',
isolating: true,
group: 'block',
parseDOM: [{ tag: 'table' }],
toDOM: () => ['table', ['tbody', 0]],
},
text: {
group: 'inline',
},
hard_break: {
inline: true,
group: 'inlineBreak',
selectable: false,
parseDOM: [{ tag: 'br' }],
toDOM: () => domOutputSpecs.br,
},
});
const marks = {
strong: {
parseDOM: [{ tag: 'strong' }, { tag: 'b' }],
toDOM: () => domOutputSpecs.strong,
},
emphasis: {
parseDOM: [{ tag: 'em' }],
toDOM: () => domOutputSpecs.emphasis,
},
code: {
parseDOM: [{ tag: 'code' }],
code: true,
toDOM: () => domOutputSpecs.code,
excludes: '_',
},
sub: {
parseDOM: [{ tag: 'sub' }],
toDOM: () => domOutputSpecs.sub,
},
sup: {
parseDOM: [{ tag: 'sup' }],
toDOM: () => domOutputSpecs.sup,
},
mark: {
parseDOM: [{ tag: 'mark' }],
toDOM: () => domOutputSpecs.mark,
},
color: {
inclusive: false,
parseDOM: [{
style: 'color',
getAttrs: (styleValue: string) => {
return { color: styleValue };
},
}],
attrs: {
color: { validate: 'string' },
},
toDOM: node => {
const result = document.createElement('span');
result.style.color = node.attrs.color;
return result;
},
},
link: {
attrs: {
href: { validate: 'string' },
title: { default: '', validate: 'string' },
dataResourceId: { default: undefined as string|undefined, validate: 'string|undefined' },
},
inclusive: false,
parseDOM: [{
tag: 'a[href]',
getAttrs: node => {
const resourceId = node.getAttribute('data-resource-id');
let href = node.getAttribute('href');
const isResourceLink = resourceId && href === '#';
if (isResourceLink) {
href = `:/${resourceId}`;
}
if (href === '#' && node.hasAttribute('data-original-href')) {
href = node.getAttribute('data-original-href');
}
return {
href: isResourceLink ? `:/${resourceId}` : href,
title: node.getAttribute('title'),
dataResourceId: node.getAttribute('data-resource-id'),
};
},
}],
toDOM: node => {
const isSafeForRendering = (href: string) => {
return hasProtocol(href, ['http', 'https', 'joplin']) || isResourceUrl(href);
};
// Avoid rendering URLs with unknown protocols (avoid rendering or pasting unsafe HREFs).
// Note that URL click handling is handled elsewhere and does not use the HTML "href" attribute.
// However "href" may be used by the right-click menu on web:
const safeHref = isSafeForRendering(node.attrs.href) ? node.attrs.href : '#';
return [
'a',
{
href: safeHref,
...(safeHref !== node.attrs.href ? {
'data-original-href': node.attrs.href,
} : {}),
title: node.attrs.title,
'data-resource-id': node.attrs.dataResourceId,
},
];
},
},
} satisfies Record<string, MarkSpec>;
type NodeKeys = keyof typeof nodes;
type MarkKeys = keyof typeof marks;
const schema = new Schema<NodeKeys, MarkKeys>({
marks,
nodes,
});
export default schema;