@aicayzer/inkkit 0.0.15 → 0.0.16
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/NOTICE +0 -4
- package/README.md +27 -24
- package/dist/highlight.js +109 -22
- package/dist/mermaid.js +108 -6
- package/dist/paste-html.d.ts +23 -0
- package/dist/paste-html.js +441 -0
- package/dist/paste.js +31 -49
- package/dist/preserve.d.ts +1 -0
- package/dist/preserve.js +87 -26
- package/package.json +1 -1
package/NOTICE
CHANGED
|
@@ -1,7 +1,3 @@
|
|
|
1
|
-
InkKit includes editor behavior and source from PadPad and Memos.
|
|
2
|
-
Copyright (c) 2026 August Cayzer. Licensed under the MIT License.
|
|
3
|
-
Sources: https://github.com/aicayzer/padpad and https://github.com/aicayzer/memos
|
|
4
|
-
|
|
5
1
|
The scoped table styles in src/style.css are adapted from prosemirror-tables.
|
|
6
2
|
Source: https://github.com/ProseMirror/prosemirror-tables
|
|
7
3
|
|
package/README.md
CHANGED
|
@@ -1,33 +1,31 @@
|
|
|
1
1
|
# InkKit
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
A Markdown editor for the web and for apps that host a web view. It edits formatted text and keeps your Markdown as you wrote it: a document you open and leave is saved unchanged, and an edit rewrites only the blocks it touches.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
- Formatted and source editing, with one undo history
|
|
6
|
+
- Tables, task lists, footnotes, callouts, highlights and reference links
|
|
7
|
+
- Mermaid diagram previews, offline
|
|
8
|
+
- Copy as readable text or HTML, or copy the Markdown itself
|
|
9
|
+
- Images and files through your own storage adapter
|
|
10
|
+
- Your app owns the toolbar, menus, files and appearance
|
|
6
11
|
|
|
7
|
-
|
|
12
|
+
## Install
|
|
8
13
|
|
|
9
14
|
```sh
|
|
10
15
|
npm install @aicayzer/inkkit
|
|
11
16
|
```
|
|
12
17
|
|
|
18
|
+
## Use
|
|
19
|
+
|
|
13
20
|
```ts
|
|
14
21
|
import { InkKitEditor } from '@aicayzer/inkkit'
|
|
15
22
|
import '@aicayzer/inkkit/style.css'
|
|
16
23
|
|
|
17
|
-
const
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
},
|
|
22
|
-
stateChanged(state) {
|
|
23
|
-
// Update the host's formatting controls.
|
|
24
|
-
},
|
|
25
|
-
openLink(href) {
|
|
26
|
-
// Open through the host.
|
|
27
|
-
},
|
|
28
|
-
copy(text) {
|
|
29
|
-
// Write code-block text to the clipboard.
|
|
30
|
-
},
|
|
24
|
+
const editor = await InkKitEditor.mount(document.getElementById('editor')!, {
|
|
25
|
+
changed(markdown, generation) {},
|
|
26
|
+
stateChanged(state) {},
|
|
27
|
+
openLink(href) {},
|
|
28
|
+
copy(text) {},
|
|
31
29
|
})
|
|
32
30
|
|
|
33
31
|
editor.loadDocument({
|
|
@@ -36,17 +34,22 @@ editor.loadDocument({
|
|
|
36
34
|
format: 'md',
|
|
37
35
|
text: '# Hello\n\nStart writing.\n',
|
|
38
36
|
})
|
|
39
|
-
|
|
37
|
+
|
|
38
|
+
const { text } = editor.snapshot(1)
|
|
40
39
|
```
|
|
41
40
|
|
|
42
|
-
|
|
41
|
+
## Built with InkKit
|
|
43
42
|
|
|
44
|
-
|
|
43
|
+
- [Memos](https://github.com/aicayzer/memos): a small Mac app for memos, in one floating window
|
|
44
|
+
- [PadPad](https://github.com/aicayzer/padpad): a small Mac app for quick text and files
|
|
45
45
|
|
|
46
|
-
|
|
46
|
+
## Learn more
|
|
47
47
|
|
|
48
|
-
|
|
48
|
+
- [Supported syntax](docs/supported-syntax.md)
|
|
49
|
+
- [Integration guide](docs/integration.md)
|
|
50
|
+
- [API reference](docs/api.md)
|
|
51
|
+
- [All documentation](docs/README.md)
|
|
49
52
|
|
|
50
|
-
|
|
53
|
+
## License
|
|
51
54
|
|
|
52
|
-
|
|
55
|
+
[MIT](LICENSE). Third-party notices are in [NOTICE](NOTICE).
|
package/dist/highlight.js
CHANGED
|
@@ -5,41 +5,128 @@ import { $prose } from '@milkdown/kit/utils';
|
|
|
5
5
|
const lowlight = createLowlight(common);
|
|
6
6
|
// Colors are decorations over the plain text, so the markdown is untouched;
|
|
7
7
|
// a block without a known language stays plain.
|
|
8
|
+
function blockDecorations(node, pos) {
|
|
9
|
+
const language = String(node.attrs.language ?? '').toLowerCase();
|
|
10
|
+
const found = [];
|
|
11
|
+
if (!language || !lowlight.registered(language))
|
|
12
|
+
return found;
|
|
13
|
+
let offset = pos + 1;
|
|
14
|
+
const walk = (children, classes) => {
|
|
15
|
+
for (const child of children) {
|
|
16
|
+
if (child.type === 'text') {
|
|
17
|
+
const end = offset + child.value.length;
|
|
18
|
+
if (classes.length > 0)
|
|
19
|
+
found.push(Decoration.inline(offset, end, { class: classes.join(' ') }));
|
|
20
|
+
offset = end;
|
|
21
|
+
}
|
|
22
|
+
else if (child.type === 'element') {
|
|
23
|
+
const own = child.properties?.className ?? [];
|
|
24
|
+
walk(child.children, [...classes, ...own]);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
walk(lowlight.highlight(language, node.textContent).children, []);
|
|
29
|
+
return found;
|
|
30
|
+
}
|
|
8
31
|
function decorations(doc) {
|
|
9
32
|
const found = [];
|
|
10
33
|
doc.descendants((node, pos) => {
|
|
11
|
-
if (node.
|
|
34
|
+
if (!node.isTextblock)
|
|
12
35
|
return true;
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
let offset = pos + 1;
|
|
17
|
-
const walk = (children, classes) => {
|
|
18
|
-
for (const child of children) {
|
|
19
|
-
if (child.type === 'text') {
|
|
20
|
-
const end = offset + child.value.length;
|
|
21
|
-
if (classes.length > 0)
|
|
22
|
-
found.push(Decoration.inline(offset, end, { class: classes.join(' ') }));
|
|
23
|
-
offset = end;
|
|
24
|
-
}
|
|
25
|
-
else if (child.type === 'element') {
|
|
26
|
-
const own = child.properties?.className ?? [];
|
|
27
|
-
walk(child.children, [...classes, ...own]);
|
|
28
|
-
}
|
|
29
|
-
}
|
|
30
|
-
};
|
|
31
|
-
walk(lowlight.highlight(language, node.textContent).children, []);
|
|
36
|
+
if (node.type.name === 'code_block')
|
|
37
|
+
for (const decoration of blockDecorations(node, pos))
|
|
38
|
+
found.push(decoration);
|
|
32
39
|
return false;
|
|
33
40
|
});
|
|
34
41
|
return DecorationSet.create(doc, found);
|
|
35
42
|
}
|
|
43
|
+
// Every range of the new document an update may have touched: each step's own
|
|
44
|
+
// range, plus the whole span where the two documents differ, which also covers
|
|
45
|
+
// steps that change a node without changing its size.
|
|
46
|
+
function touched(tr) {
|
|
47
|
+
const ranges = [];
|
|
48
|
+
tr.mapping.maps.forEach((map, index) => {
|
|
49
|
+
const rest = tr.mapping.slice(index + 1);
|
|
50
|
+
map.forEach((_, __, from, to) => ranges.push([rest.map(from, -1), rest.map(to, 1)]));
|
|
51
|
+
});
|
|
52
|
+
const start = tr.before.content.findDiffStart(tr.doc.content);
|
|
53
|
+
if (start != null) {
|
|
54
|
+
const end = tr.before.content.findDiffEnd(tr.doc.content);
|
|
55
|
+
// The scans overlap when inserted or deleted text repeats its neighbours.
|
|
56
|
+
if (end)
|
|
57
|
+
ranges.push([start, end.b + Math.max(0, start - Math.min(end.a, end.b))]);
|
|
58
|
+
}
|
|
59
|
+
return ranges;
|
|
60
|
+
}
|
|
61
|
+
// Mapping carries colors of unchanged blocks to their new positions without
|
|
62
|
+
// reading them, but leaves stale colors inside any block that was edited. Those
|
|
63
|
+
// blocks are highlighted again; no other block is. Stale colors are dropped by
|
|
64
|
+
// rebuilding the set: DecorationSet.remove compares every removed decoration
|
|
65
|
+
// with every other in its block, which is quadratic in a large block.
|
|
66
|
+
function update(mapped, tr) {
|
|
67
|
+
const stale = [];
|
|
68
|
+
const fresh = [];
|
|
69
|
+
const seen = new Set();
|
|
70
|
+
for (const [from, to] of touched(tr)) {
|
|
71
|
+
let start = from;
|
|
72
|
+
let end = to;
|
|
73
|
+
const blocks = [];
|
|
74
|
+
tr.doc.nodesBetween(from, to, (node, pos) => {
|
|
75
|
+
if (!node.isTextblock)
|
|
76
|
+
return true;
|
|
77
|
+
start = Math.min(start, pos);
|
|
78
|
+
end = Math.max(end, pos + node.nodeSize);
|
|
79
|
+
if (node.type.name === 'code_block' && !seen.has(pos)) {
|
|
80
|
+
seen.add(pos);
|
|
81
|
+
blocks.push([node, pos]);
|
|
82
|
+
}
|
|
83
|
+
return false;
|
|
84
|
+
});
|
|
85
|
+
stale.push([start, end]);
|
|
86
|
+
for (const [node, pos] of blocks)
|
|
87
|
+
for (const decoration of blockDecorations(node, pos))
|
|
88
|
+
fresh.push(decoration);
|
|
89
|
+
}
|
|
90
|
+
const found = [];
|
|
91
|
+
for (const [start, end] of stale)
|
|
92
|
+
for (const decoration of mapped.find(start, end))
|
|
93
|
+
found.push(decoration);
|
|
94
|
+
if (found.length === 0)
|
|
95
|
+
return mapped.add(tr.doc, fresh);
|
|
96
|
+
// remove is quadratic in the decorations it removes, so only a large block takes the rebuild below.
|
|
97
|
+
if (found.length <= 256)
|
|
98
|
+
return mapped.remove(found).add(tr.doc, fresh);
|
|
99
|
+
stale.sort((a, b) => a[0] - b[0]);
|
|
100
|
+
const merged = [];
|
|
101
|
+
for (const range of stale) {
|
|
102
|
+
const last = merged.at(-1);
|
|
103
|
+
if (last && range[0] <= last[1])
|
|
104
|
+
last[1] = Math.max(last[1], range[1]);
|
|
105
|
+
else
|
|
106
|
+
merged.push([range[0], range[1]]);
|
|
107
|
+
}
|
|
108
|
+
const kept = mapped.find().filter((decoration) => {
|
|
109
|
+
let low = 0;
|
|
110
|
+
let high = merged.length;
|
|
111
|
+
while (low < high) {
|
|
112
|
+
const middle = (low + high) >> 1;
|
|
113
|
+
if (merged[middle][0] < decoration.to)
|
|
114
|
+
low = middle + 1;
|
|
115
|
+
else
|
|
116
|
+
high = middle;
|
|
117
|
+
}
|
|
118
|
+
const range = merged[low - 1];
|
|
119
|
+
return !range || range[1] <= decoration.from;
|
|
120
|
+
});
|
|
121
|
+
return DecorationSet.create(tr.doc, kept.concat(fresh));
|
|
122
|
+
}
|
|
36
123
|
export const highlightPlugin = $prose(() => new Plugin({
|
|
37
124
|
key: new PluginKey('highlight'),
|
|
38
125
|
state: {
|
|
39
126
|
init: (_, state) => decorations(state.doc),
|
|
40
127
|
apply: (tr, previous) => tr.docChanged
|
|
41
|
-
?
|
|
42
|
-
: previous
|
|
128
|
+
? update(previous.map(tr.mapping, tr.doc), tr)
|
|
129
|
+
: previous,
|
|
43
130
|
},
|
|
44
131
|
props: {
|
|
45
132
|
decorations(state) {
|
package/dist/mermaid.js
CHANGED
|
@@ -7,15 +7,107 @@ import { InkKitError } from './types.js';
|
|
|
7
7
|
export function isMermaid(node) {
|
|
8
8
|
return node.type.name === 'code_block' && node.attrs.language === 'mermaid';
|
|
9
9
|
}
|
|
10
|
+
const refusal = 'HTML, styles, actions, resources and diagram configuration are disabled. The source is retained.';
|
|
11
|
+
// CSS functions that can load an image or display another element. Mermaid's
|
|
12
|
+
// own styles use none of them apart from url() for in-document markers.
|
|
13
|
+
const cssResource = /url\s*\(\s*[^#\s]|(?:image-set|cross-fade|image|element|src)\s*\(/i;
|
|
10
14
|
export function validateDiagram(source) {
|
|
11
15
|
if (source.length > 30000)
|
|
12
16
|
throw new Error('The diagram exceeds the 30,000 character rendering limit.');
|
|
13
17
|
if (!/^\s*(?:%%[^\n]*\n\s*)*(?:flowchart|graph|sequenceDiagram|classDiagram|stateDiagram(?:-v2)?|erDiagram|pie)\b/.test(source))
|
|
14
18
|
throw new Error('This diagram type is not supported. The source is retained.');
|
|
15
|
-
// Mermaid can load image bytes before sanitising its result
|
|
16
|
-
//
|
|
17
|
-
|
|
18
|
-
|
|
19
|
+
// Mermaid can load image bytes before sanitising its result, so resource and
|
|
20
|
+
// configuration syntax is refused anywhere in the source, labels included.
|
|
21
|
+
// Statements are checked separately, in Mermaid's parse result.
|
|
22
|
+
if (/%%\{|^\s*---|<\s*[a-z!/?]|&(?:#\w+|[a-z]+);|@\{|\$\$|(?:https?|data|file|javascript|vbscript|blob):|\/\/|url\s*\(|image-set\s*\(|cross-fade\s*\(|@import|\\/im.test(source))
|
|
23
|
+
throw new Error(refusal);
|
|
24
|
+
}
|
|
25
|
+
const entries = (value) => value instanceof Map ? [...value.values()] : Array.isArray(value) ? value : [];
|
|
26
|
+
const filled = (value) => value instanceof Map
|
|
27
|
+
? value.size > 0
|
|
28
|
+
: Array.isArray(value)
|
|
29
|
+
? value.length > 0
|
|
30
|
+
: typeof value === 'object' && value !== null
|
|
31
|
+
? Object.keys(value).length > 0
|
|
32
|
+
: Boolean(value);
|
|
33
|
+
// In strict mode a flowchart records a click handler only as this class.
|
|
34
|
+
const clickable = (classes) => (Array.isArray(classes) ? classes : String(classes ?? '').split(/\s+/))
|
|
35
|
+
.map(String)
|
|
36
|
+
.includes('clickable');
|
|
37
|
+
function statementIn(node, kinds, seen = new Set()) {
|
|
38
|
+
if (typeof node !== 'object' || node === null || seen.has(node))
|
|
39
|
+
return false;
|
|
40
|
+
seen.add(node);
|
|
41
|
+
if (kinds.includes(String(node.stmt)))
|
|
42
|
+
return true;
|
|
43
|
+
return Object.values(node).some((value) => statementIn(value, kinds, seen));
|
|
44
|
+
}
|
|
45
|
+
// Refuses styles, class definitions, link styles, links, click handlers,
|
|
46
|
+
// tooltips, icons and images wherever the grammar accepted them. The names are
|
|
47
|
+
// those of the pinned Mermaid's diagram databases; if one is missing, the
|
|
48
|
+
// diagram is refused rather than passed unchecked.
|
|
49
|
+
function refuseDirectives({ type, db }) {
|
|
50
|
+
const store = db;
|
|
51
|
+
const field = (name) => {
|
|
52
|
+
if (!(name in store))
|
|
53
|
+
throw new Error('The diagram could not be checked. The source is retained.');
|
|
54
|
+
return store[name];
|
|
55
|
+
};
|
|
56
|
+
const read = (name) => {
|
|
57
|
+
const accessor = field(name);
|
|
58
|
+
if (typeof accessor !== 'function')
|
|
59
|
+
throw new Error('The diagram could not be checked. The source is retained.');
|
|
60
|
+
return accessor.call(db);
|
|
61
|
+
};
|
|
62
|
+
let found;
|
|
63
|
+
switch (type) {
|
|
64
|
+
case 'flowchart-v2': {
|
|
65
|
+
const edges = read('getEdges');
|
|
66
|
+
found =
|
|
67
|
+
filled(read('getClasses')) ||
|
|
68
|
+
filled(field('tooltips')) ||
|
|
69
|
+
filled(edges.defaultStyle) ||
|
|
70
|
+
edges.defaultInterpolate !== undefined ||
|
|
71
|
+
entries(read('getVertices')).some((vertex) => filled(vertex.styles) ||
|
|
72
|
+
Boolean(vertex.link || vertex.haveCallback || vertex.icon || vertex.img) ||
|
|
73
|
+
clickable(vertex.classes)) ||
|
|
74
|
+
entries(edges).some((edge) => filled(edge.style) ||
|
|
75
|
+
edge.interpolate !== undefined ||
|
|
76
|
+
clickable(edge.classes)) ||
|
|
77
|
+
entries(read('getSubGraphs')).some((graph) => clickable(graph.classes));
|
|
78
|
+
break;
|
|
79
|
+
}
|
|
80
|
+
case 'stateDiagram':
|
|
81
|
+
found =
|
|
82
|
+
statementIn(field('rootDoc'), ['style', 'classDef', 'click']) ||
|
|
83
|
+
filled(read('getClasses')) ||
|
|
84
|
+
filled(read('getLinks')) ||
|
|
85
|
+
entries(read('getStates')).some((state) => filled(state.styles) || filled(state.textStyles));
|
|
86
|
+
break;
|
|
87
|
+
case 'classDiagram':
|
|
88
|
+
found =
|
|
89
|
+
filled(field('styleClasses')) ||
|
|
90
|
+
entries(read('getClasses')).some((node) => filled(node.styles) ||
|
|
91
|
+
Boolean(node.link || node.haveCallback || node.tooltip) ||
|
|
92
|
+
clickable(node.cssClasses));
|
|
93
|
+
break;
|
|
94
|
+
case 'er':
|
|
95
|
+
found =
|
|
96
|
+
filled(read('getClasses')) ||
|
|
97
|
+
entries(read('getEntities')).some((entity) => filled(entity.cssStyles)) ||
|
|
98
|
+
entries(read('getSubGraphs')).some((graph) => filled(graph.cssStyles));
|
|
99
|
+
break;
|
|
100
|
+
case 'sequence':
|
|
101
|
+
found = entries(read('getActors')).some((actor) => filled(actor.links) || filled(actor.properties));
|
|
102
|
+
break;
|
|
103
|
+
case 'pie':
|
|
104
|
+
found = false;
|
|
105
|
+
break;
|
|
106
|
+
default:
|
|
107
|
+
throw new Error('This diagram type is not supported. The source is retained.');
|
|
108
|
+
}
|
|
109
|
+
if (found)
|
|
110
|
+
throw new Error(refusal);
|
|
19
111
|
}
|
|
20
112
|
let sequence = 0;
|
|
21
113
|
let queue = Promise.resolve();
|
|
@@ -34,11 +126,13 @@ export function safeDiagramSVG(svg) {
|
|
|
34
126
|
for (const element of [root, ...root.querySelectorAll('*')]) {
|
|
35
127
|
for (const attribute of [...element.attributes]) {
|
|
36
128
|
if (/^on/i.test(attribute.name) ||
|
|
37
|
-
/(?:https?|data|file|javascript|blob):|@import
|
|
129
|
+
/(?:https?|data|file|javascript|blob):|@import|\\/i.test(attribute.value) ||
|
|
130
|
+
cssResource.test(attribute.value))
|
|
38
131
|
element.removeAttribute(attribute.name);
|
|
39
132
|
}
|
|
40
133
|
if (element.tagName.toLowerCase() === 'style' &&
|
|
41
|
-
/@import|(?:https?|data|file)
|
|
134
|
+
(/@import|(?:https?|data|file):|\\/i.test(element.textContent ?? '') ||
|
|
135
|
+
cssResource.test(element.textContent ?? '')))
|
|
42
136
|
element.remove();
|
|
43
137
|
}
|
|
44
138
|
root.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
|
|
@@ -98,6 +192,14 @@ export function renderDiagram(source) {
|
|
|
98
192
|
'suppressErrorRendering',
|
|
99
193
|
],
|
|
100
194
|
});
|
|
195
|
+
// getDiagramFromText is the pinned Mermaid's own parser, which render runs
|
|
196
|
+
// again on the same text; the public parse does not return the diagram. It
|
|
197
|
+
// skips render's configuration step, so the two parse alike only because
|
|
198
|
+
// the source check refuses %%{ directives and front matter.
|
|
199
|
+
refuseDirectives(await mermaid.mermaidAPI.getDiagramFromText(source));
|
|
200
|
+
// Mermaid measures text while laying out, so the holder must be attached
|
|
201
|
+
// and rendered. Such a holder can load CSS resources before sanitising,
|
|
202
|
+
// which is why the source and the parse result are checked first.
|
|
101
203
|
const holder = document.createElement('div');
|
|
102
204
|
holder.style.cssText =
|
|
103
205
|
'position:fixed;left:-100000px;top:0;width:1200px;pointer-events:none';
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export declare function isRichElement(element: Element): boolean;
|
|
2
|
+
/** Whether normalisation enters the element, so its content may be rewritten. */
|
|
3
|
+
export declare function isRewrittenElement(element: Element): boolean;
|
|
4
|
+
/**
|
|
5
|
+
* Whether the element's own inline style makes a browser keep the whitespace
|
|
6
|
+
* of its content, as copied editor code does with `white-space: pre`. The
|
|
7
|
+
* declarations are parsed, so case, spacing, comments, quoted text and
|
|
8
|
+
* `!important` do not matter. Style sheets are not consulted.
|
|
9
|
+
*/
|
|
10
|
+
export declare function keepsWhitespace(element: Element): boolean;
|
|
11
|
+
/**
|
|
12
|
+
* Rewrites common web-page elements to ones the importer already understands.
|
|
13
|
+
* Elements that will stay literal source are not entered, so their content is
|
|
14
|
+
* preserved exactly.
|
|
15
|
+
*/
|
|
16
|
+
export declare function normalizeClipboardHTML(root: Node): void;
|
|
17
|
+
/**
|
|
18
|
+
* Collapses source whitespace the way a browser renders it: runs become one
|
|
19
|
+
* space, and none remains at the edges of a block or beside a line break.
|
|
20
|
+
* `pre`, `code`, elements that stay literal source and elements whose style
|
|
21
|
+
* keeps whitespace are not entered.
|
|
22
|
+
*/
|
|
23
|
+
export declare function collapseClipboardWhitespace(root: Node): void;
|
|
@@ -0,0 +1,441 @@
|
|
|
1
|
+
// Elements the HTML paste path hands to ProseMirror's parser. Anything else
|
|
2
|
+
// is kept as literal source, so a tag must be listed here or rewritten by
|
|
3
|
+
// `normalizeClipboardHTML` to avoid becoming a raw-HTML block.
|
|
4
|
+
const rich = new Set([
|
|
5
|
+
'P',
|
|
6
|
+
'H1',
|
|
7
|
+
'H2',
|
|
8
|
+
'H3',
|
|
9
|
+
'H4',
|
|
10
|
+
'H5',
|
|
11
|
+
'H6',
|
|
12
|
+
'STRONG',
|
|
13
|
+
'MARK',
|
|
14
|
+
'B',
|
|
15
|
+
'EM',
|
|
16
|
+
'I',
|
|
17
|
+
'S',
|
|
18
|
+
'DEL',
|
|
19
|
+
'STRIKE',
|
|
20
|
+
'CODE',
|
|
21
|
+
'PRE',
|
|
22
|
+
'BLOCKQUOTE',
|
|
23
|
+
'A',
|
|
24
|
+
'BR',
|
|
25
|
+
'HR',
|
|
26
|
+
'UL',
|
|
27
|
+
'OL',
|
|
28
|
+
'LI',
|
|
29
|
+
'TABLE',
|
|
30
|
+
'THEAD',
|
|
31
|
+
'TBODY',
|
|
32
|
+
'TFOOT',
|
|
33
|
+
'TR',
|
|
34
|
+
'TH',
|
|
35
|
+
'TD',
|
|
36
|
+
'IMG',
|
|
37
|
+
'SPAN',
|
|
38
|
+
'SUP',
|
|
39
|
+
'DIV',
|
|
40
|
+
'FONT',
|
|
41
|
+
'SECTION',
|
|
42
|
+
'ARTICLE',
|
|
43
|
+
'MAIN',
|
|
44
|
+
'HEADER',
|
|
45
|
+
'FOOTER',
|
|
46
|
+
]);
|
|
47
|
+
// Document furniture and form inputs hold no content of their own. A
|
|
48
|
+
// template never keeps `head`, so it is not listed.
|
|
49
|
+
const dropped = new Set(['STYLE', 'META', 'LINK', 'TITLE', 'SOURCE', 'INPUT']);
|
|
50
|
+
// The dialect has no underline, subscript or similar marks, so these keep
|
|
51
|
+
// their children and lose only the wrapper.
|
|
52
|
+
const unwrapped = new Set([
|
|
53
|
+
'U',
|
|
54
|
+
'SUB',
|
|
55
|
+
'SMALL',
|
|
56
|
+
'TIME',
|
|
57
|
+
'ABBR',
|
|
58
|
+
'CITE',
|
|
59
|
+
'DFN',
|
|
60
|
+
'VAR',
|
|
61
|
+
'INS',
|
|
62
|
+
'BDI',
|
|
63
|
+
'BDO',
|
|
64
|
+
'LABEL',
|
|
65
|
+
]);
|
|
66
|
+
const blockWrappers = new Set(['FIGURE', 'FIGCAPTION', 'DL', 'DD']);
|
|
67
|
+
const handled = new Set([
|
|
68
|
+
...dropped,
|
|
69
|
+
...unwrapped,
|
|
70
|
+
...blockWrappers,
|
|
71
|
+
'DT',
|
|
72
|
+
'PICTURE',
|
|
73
|
+
]);
|
|
74
|
+
export function isRichElement(element) {
|
|
75
|
+
return rich.has(element.tagName);
|
|
76
|
+
}
|
|
77
|
+
/** Whether normalisation enters the element, so its content may be rewritten. */
|
|
78
|
+
export function isRewrittenElement(element) {
|
|
79
|
+
return rich.has(element.tagName) || handled.has(element.tagName);
|
|
80
|
+
}
|
|
81
|
+
// `white-space` and its longhand `white-space-collapse` keep source spaces with
|
|
82
|
+
// these keywords. `pre-line` and `preserve-breaks` collapse spaces, so they are
|
|
83
|
+
// collapsed here too.
|
|
84
|
+
const keepingWhitespace = new Set([
|
|
85
|
+
'pre',
|
|
86
|
+
'pre-wrap',
|
|
87
|
+
'break-spaces',
|
|
88
|
+
'preserve',
|
|
89
|
+
'preserve-spaces',
|
|
90
|
+
]);
|
|
91
|
+
/**
|
|
92
|
+
* Whether the element's own inline style makes a browser keep the whitespace
|
|
93
|
+
* of its content, as copied editor code does with `white-space: pre`. The
|
|
94
|
+
* declarations are parsed, so case, spacing, comments, quoted text and
|
|
95
|
+
* `!important` do not matter. Style sheets are not consulted.
|
|
96
|
+
*/
|
|
97
|
+
export function keepsWhitespace(element) {
|
|
98
|
+
const style = element.getAttribute('style');
|
|
99
|
+
if (!style)
|
|
100
|
+
return false;
|
|
101
|
+
const winning = new Map();
|
|
102
|
+
for (const declaration of declarations(style)) {
|
|
103
|
+
const colon = declaration.indexOf(':');
|
|
104
|
+
if (colon < 0)
|
|
105
|
+
continue;
|
|
106
|
+
const name = declaration.slice(0, colon).trim().toLowerCase();
|
|
107
|
+
if (name !== 'white-space' && name !== 'white-space-collapse')
|
|
108
|
+
continue;
|
|
109
|
+
let value = declaration.slice(colon + 1).trim();
|
|
110
|
+
const important = /!\s*important$/i.test(value);
|
|
111
|
+
if (important)
|
|
112
|
+
value = value.replace(/!\s*important$/i, '');
|
|
113
|
+
const earlier = winning.get(name);
|
|
114
|
+
if (!earlier?.important || important)
|
|
115
|
+
winning.set(name, { value: value.trim().toLowerCase(), important });
|
|
116
|
+
}
|
|
117
|
+
return [...winning.values()].some(({ value }) => value.split(/\s+/).some((keyword) => keepingWhitespace.has(keyword)));
|
|
118
|
+
}
|
|
119
|
+
// Splits a style attribute at the semicolons that end declarations, ignoring
|
|
120
|
+
// those inside comments, quoted strings and parentheses such as `url(...)`.
|
|
121
|
+
function declarations(style) {
|
|
122
|
+
const result = [];
|
|
123
|
+
let current = '';
|
|
124
|
+
let quote = '';
|
|
125
|
+
let depth = 0;
|
|
126
|
+
for (let index = 0; index < style.length; index++) {
|
|
127
|
+
const character = style[index];
|
|
128
|
+
if (quote) {
|
|
129
|
+
current += character;
|
|
130
|
+
if (character === '\\' && index + 1 < style.length)
|
|
131
|
+
current += style[++index];
|
|
132
|
+
else if (character === quote)
|
|
133
|
+
quote = '';
|
|
134
|
+
}
|
|
135
|
+
else if (character === '/' && style[index + 1] === '*') {
|
|
136
|
+
const end = style.indexOf('*/', index + 2);
|
|
137
|
+
index = end < 0 ? style.length : end + 1;
|
|
138
|
+
}
|
|
139
|
+
else if (character === '"' || character === "'") {
|
|
140
|
+
quote = character;
|
|
141
|
+
current += character;
|
|
142
|
+
}
|
|
143
|
+
else if (character === '(') {
|
|
144
|
+
depth++;
|
|
145
|
+
current += character;
|
|
146
|
+
}
|
|
147
|
+
else if (character === ')') {
|
|
148
|
+
depth = Math.max(0, depth - 1);
|
|
149
|
+
current += character;
|
|
150
|
+
}
|
|
151
|
+
else if (character === ';' && depth === 0) {
|
|
152
|
+
result.push(current);
|
|
153
|
+
current = '';
|
|
154
|
+
}
|
|
155
|
+
else
|
|
156
|
+
current += character;
|
|
157
|
+
}
|
|
158
|
+
result.push(current);
|
|
159
|
+
return result;
|
|
160
|
+
}
|
|
161
|
+
/**
|
|
162
|
+
* Rewrites common web-page elements to ones the importer already understands.
|
|
163
|
+
* Elements that will stay literal source are not entered, so their content is
|
|
164
|
+
* preserved exactly.
|
|
165
|
+
*/
|
|
166
|
+
export function normalizeClipboardHTML(root) {
|
|
167
|
+
for (const node of [...root.childNodes]) {
|
|
168
|
+
if (node.nodeType === Node.COMMENT_NODE) {
|
|
169
|
+
// Windows and some native apps leave the CF_HTML fragment markers in.
|
|
170
|
+
if (/^\s*(?:Start|End)Fragment\s*$/.test(node.data))
|
|
171
|
+
node.parentNode?.removeChild(node);
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
if (node.nodeType !== Node.ELEMENT_NODE)
|
|
175
|
+
continue;
|
|
176
|
+
const element = node;
|
|
177
|
+
const tag = element.tagName;
|
|
178
|
+
if (!rich.has(tag) && !handled.has(tag))
|
|
179
|
+
continue;
|
|
180
|
+
if (dropped.has(tag)) {
|
|
181
|
+
const parent = element.parentElement;
|
|
182
|
+
element.remove();
|
|
183
|
+
if (parent)
|
|
184
|
+
removeEmptyParagraph(parent);
|
|
185
|
+
}
|
|
186
|
+
else if (tag === 'PICTURE') {
|
|
187
|
+
const image = element.querySelector('img');
|
|
188
|
+
if (image)
|
|
189
|
+
element.replaceWith(image);
|
|
190
|
+
else
|
|
191
|
+
element.remove();
|
|
192
|
+
}
|
|
193
|
+
else if (unwrapped.has(tag)) {
|
|
194
|
+
normalizeClipboardHTML(element);
|
|
195
|
+
element.replaceWith(...element.childNodes);
|
|
196
|
+
}
|
|
197
|
+
else if (tag === 'DT') {
|
|
198
|
+
normalizeClipboardHTML(element);
|
|
199
|
+
// Markdown has no definition lists. A bold term over its definition
|
|
200
|
+
// keeps which text names the other.
|
|
201
|
+
const term = element.ownerDocument.createElement('div');
|
|
202
|
+
boldInto(term, [...element.childNodes]);
|
|
203
|
+
element.replaceWith(term);
|
|
204
|
+
}
|
|
205
|
+
else if (blockWrappers.has(tag)) {
|
|
206
|
+
normalizeClipboardHTML(element);
|
|
207
|
+
const block = element.ownerDocument.createElement('div');
|
|
208
|
+
block.append(...element.childNodes);
|
|
209
|
+
element.replaceWith(block);
|
|
210
|
+
}
|
|
211
|
+
else {
|
|
212
|
+
if (tag === 'LI')
|
|
213
|
+
markTask(element);
|
|
214
|
+
normalizeClipboardHTML(element);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
// A paragraph whose only content was a dropped element would otherwise import
|
|
219
|
+
// as a blank paragraph.
|
|
220
|
+
function removeEmptyParagraph(from) {
|
|
221
|
+
let node = from;
|
|
222
|
+
while (node &&
|
|
223
|
+
['SPAN', 'FONT', 'STRONG', 'B', 'EM', 'I'].includes(node.tagName))
|
|
224
|
+
node = node.parentElement;
|
|
225
|
+
if (node?.tagName === 'P' &&
|
|
226
|
+
!node.textContent?.trim() &&
|
|
227
|
+
!node.querySelector('img,br,hr'))
|
|
228
|
+
node.remove();
|
|
229
|
+
}
|
|
230
|
+
const termBlocks = new Set(['P', 'DIV', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6']);
|
|
231
|
+
const blocks = new Set([
|
|
232
|
+
...termBlocks,
|
|
233
|
+
'BLOCKQUOTE',
|
|
234
|
+
'PRE',
|
|
235
|
+
'UL',
|
|
236
|
+
'OL',
|
|
237
|
+
'LI',
|
|
238
|
+
'TABLE',
|
|
239
|
+
'THEAD',
|
|
240
|
+
'TBODY',
|
|
241
|
+
'TFOOT',
|
|
242
|
+
'TR',
|
|
243
|
+
'TH',
|
|
244
|
+
'TD',
|
|
245
|
+
'SECTION',
|
|
246
|
+
'ARTICLE',
|
|
247
|
+
'MAIN',
|
|
248
|
+
'HEADER',
|
|
249
|
+
'FOOTER',
|
|
250
|
+
'HR',
|
|
251
|
+
]);
|
|
252
|
+
// Bolds the inline content of a term. Paragraphs, headings and divisions
|
|
253
|
+
// inside it each become one bold paragraph; other blocks are kept as they are.
|
|
254
|
+
function boldInto(target, nodes) {
|
|
255
|
+
const document = target.ownerDocument;
|
|
256
|
+
let run;
|
|
257
|
+
const paragraph = (content) => {
|
|
258
|
+
if (!content.some((node) => node.textContent?.trim() || isImage(node)))
|
|
259
|
+
return undefined;
|
|
260
|
+
const block = document.createElement('p');
|
|
261
|
+
const strong = document.createElement('strong');
|
|
262
|
+
strong.append(...content);
|
|
263
|
+
block.append(strong);
|
|
264
|
+
target.append(block);
|
|
265
|
+
return strong;
|
|
266
|
+
};
|
|
267
|
+
for (const node of nodes) {
|
|
268
|
+
const element = node.nodeType === Node.ELEMENT_NODE ? node : null;
|
|
269
|
+
if (element && blocks.has(element.tagName)) {
|
|
270
|
+
run = undefined;
|
|
271
|
+
if (element.tagName === 'P' || /^H[1-6]$/.test(element.tagName))
|
|
272
|
+
paragraph([...element.childNodes]);
|
|
273
|
+
else if (element.tagName === 'DIV')
|
|
274
|
+
boldInto(target, [...element.childNodes]);
|
|
275
|
+
else
|
|
276
|
+
target.append(element);
|
|
277
|
+
}
|
|
278
|
+
else if (run)
|
|
279
|
+
run.append(node);
|
|
280
|
+
else if (node.textContent?.trim() || isImage(node)) {
|
|
281
|
+
run = paragraph([node]);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
function isImage(node) {
|
|
286
|
+
return (node.nodeType === Node.ELEMENT_NODE &&
|
|
287
|
+
(node.tagName === 'IMG' ||
|
|
288
|
+
!!node.querySelector('img')));
|
|
289
|
+
}
|
|
290
|
+
// GitHub, Notion and similar pages render a task as a checkbox leading its
|
|
291
|
+
// list item, optionally inside a paragraph or label.
|
|
292
|
+
function leadingCheckbox(item) {
|
|
293
|
+
let node = item;
|
|
294
|
+
for (;;) {
|
|
295
|
+
const first = [...node.childNodes].find((child) => child.nodeType !== Node.COMMENT_NODE &&
|
|
296
|
+
!(child.nodeType === Node.TEXT_NODE && !child.textContent?.trim()));
|
|
297
|
+
if (first?.nodeType !== Node.ELEMENT_NODE)
|
|
298
|
+
return undefined;
|
|
299
|
+
const element = first;
|
|
300
|
+
if (element.tagName === 'INPUT')
|
|
301
|
+
return element.getAttribute('type')?.toLowerCase() === 'checkbox'
|
|
302
|
+
? element
|
|
303
|
+
: undefined;
|
|
304
|
+
if (!['P', 'LABEL', 'DIV', 'SPAN'].includes(element.tagName))
|
|
305
|
+
return undefined;
|
|
306
|
+
node = element;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
function markTask(item) {
|
|
310
|
+
const checkbox = leadingCheckbox(item);
|
|
311
|
+
if (!checkbox)
|
|
312
|
+
return;
|
|
313
|
+
item.setAttribute('data-item-type', 'task');
|
|
314
|
+
item.setAttribute('data-checked', String(checkbox.hasAttribute('checked')));
|
|
315
|
+
checkbox.remove();
|
|
316
|
+
}
|
|
317
|
+
const inline = new Set([
|
|
318
|
+
'STRONG',
|
|
319
|
+
'MARK',
|
|
320
|
+
'B',
|
|
321
|
+
'EM',
|
|
322
|
+
'I',
|
|
323
|
+
'S',
|
|
324
|
+
'DEL',
|
|
325
|
+
'STRIKE',
|
|
326
|
+
'A',
|
|
327
|
+
'SPAN',
|
|
328
|
+
'SUP',
|
|
329
|
+
'FONT',
|
|
330
|
+
]);
|
|
331
|
+
const space = /[ \t\r\n\f]+/g;
|
|
332
|
+
/**
|
|
333
|
+
* Collapses source whitespace the way a browser renders it: runs become one
|
|
334
|
+
* space, and none remains at the edges of a block or beside a line break.
|
|
335
|
+
* `pre`, `code`, elements that stay literal source and elements whose style
|
|
336
|
+
* keeps whitespace are not entered.
|
|
337
|
+
*/
|
|
338
|
+
export function collapseClipboardWhitespace(root) {
|
|
339
|
+
hoistEdgeSpace(root);
|
|
340
|
+
let trailing;
|
|
341
|
+
let start = true;
|
|
342
|
+
const trim = () => {
|
|
343
|
+
if (!trailing)
|
|
344
|
+
return;
|
|
345
|
+
trailing.data = trailing.data.slice(0, -1);
|
|
346
|
+
if (!trailing.data)
|
|
347
|
+
trailing.remove();
|
|
348
|
+
trailing = undefined;
|
|
349
|
+
};
|
|
350
|
+
const visit = (node) => {
|
|
351
|
+
if (node.nodeType === Node.TEXT_NODE) {
|
|
352
|
+
const text = node;
|
|
353
|
+
let data = text.data.replace(space, ' ');
|
|
354
|
+
if ((start || trailing) && data.startsWith(' '))
|
|
355
|
+
data = data.slice(1);
|
|
356
|
+
if (!data)
|
|
357
|
+
return text.remove();
|
|
358
|
+
text.data = data;
|
|
359
|
+
start = false;
|
|
360
|
+
trailing = data.endsWith(' ') ? text : undefined;
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
if (node.nodeType !== Node.ELEMENT_NODE)
|
|
364
|
+
return;
|
|
365
|
+
const element = node;
|
|
366
|
+
const tag = element.tagName;
|
|
367
|
+
if (keepsWhitespace(element)) {
|
|
368
|
+
// Treated like `code` inside a line and like `pre` as a block.
|
|
369
|
+
if (inline.has(tag) || tag === 'CODE' || tag === 'IMG') {
|
|
370
|
+
trailing = undefined;
|
|
371
|
+
start = false;
|
|
372
|
+
}
|
|
373
|
+
else {
|
|
374
|
+
trim();
|
|
375
|
+
start = true;
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
else if (inline.has(tag)) {
|
|
379
|
+
for (const child of [...element.childNodes])
|
|
380
|
+
visit(child);
|
|
381
|
+
}
|
|
382
|
+
else if (tag === 'BR') {
|
|
383
|
+
trim();
|
|
384
|
+
start = true;
|
|
385
|
+
}
|
|
386
|
+
else if (tag === 'CODE' || tag === 'IMG') {
|
|
387
|
+
trailing = undefined;
|
|
388
|
+
start = false;
|
|
389
|
+
}
|
|
390
|
+
else {
|
|
391
|
+
trim();
|
|
392
|
+
start = true;
|
|
393
|
+
if (blocks.has(tag) && tag !== 'PRE' && tag !== 'HR')
|
|
394
|
+
for (const child of [...element.childNodes])
|
|
395
|
+
visit(child);
|
|
396
|
+
trim();
|
|
397
|
+
start = true;
|
|
398
|
+
}
|
|
399
|
+
};
|
|
400
|
+
for (const child of [...root.childNodes])
|
|
401
|
+
visit(child);
|
|
402
|
+
trim();
|
|
403
|
+
}
|
|
404
|
+
// Whitespace inside emphasis, links and similar wrappers cannot be written in
|
|
405
|
+
// Markdown, so it moves outside them.
|
|
406
|
+
function hoistEdgeSpace(node) {
|
|
407
|
+
for (const child of [...node.childNodes]) {
|
|
408
|
+
if (child.nodeType !== Node.ELEMENT_NODE)
|
|
409
|
+
continue;
|
|
410
|
+
const element = child;
|
|
411
|
+
if (keepsWhitespace(element))
|
|
412
|
+
continue;
|
|
413
|
+
if (inline.has(element.tagName)) {
|
|
414
|
+
hoistEdgeSpace(element);
|
|
415
|
+
const lead = edgeText(element, 'start');
|
|
416
|
+
const leading = lead?.data.match(/^[ \t\r\n\f]+/)?.[0];
|
|
417
|
+
if (lead && leading) {
|
|
418
|
+
lead.data = lead.data.slice(leading.length);
|
|
419
|
+
element.before(leading);
|
|
420
|
+
}
|
|
421
|
+
const tail = edgeText(element, 'end');
|
|
422
|
+
const trailing = tail?.data.match(/[ \t\r\n\f]+$/)?.[0];
|
|
423
|
+
if (tail && trailing) {
|
|
424
|
+
tail.data = tail.data.slice(0, -trailing.length);
|
|
425
|
+
element.after(trailing);
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
else if (blocks.has(element.tagName) && element.tagName !== 'PRE')
|
|
429
|
+
hoistEdgeSpace(element);
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
function edgeText(element, side) {
|
|
433
|
+
let node = side === 'start' ? element.firstChild : element.lastChild;
|
|
434
|
+
while (node && node.nodeType === Node.ELEMENT_NODE) {
|
|
435
|
+
if (!inline.has(node.tagName) ||
|
|
436
|
+
keepsWhitespace(node))
|
|
437
|
+
return undefined;
|
|
438
|
+
node = side === 'start' ? node.firstChild : node.lastChild;
|
|
439
|
+
}
|
|
440
|
+
return node?.nodeType === Node.TEXT_NODE ? node : undefined;
|
|
441
|
+
}
|
package/dist/paste.js
CHANGED
|
@@ -4,6 +4,7 @@ import { Plugin, PluginKey } from '@milkdown/kit/prose/state';
|
|
|
4
4
|
import { $prose } from '@milkdown/kit/utils';
|
|
5
5
|
import { pasteSpreadsheet } from './spreadsheet.js';
|
|
6
6
|
import { isImage } from './image-blocks.js';
|
|
7
|
+
import { collapseClipboardWhitespace, isRewrittenElement, isRichElement, normalizeClipboardHTML, } from './paste-html.js';
|
|
7
8
|
import { avoidReferenceCollisions, markdownFromHTML, } from './reference-clipboard.js';
|
|
8
9
|
import { InkKitError, } from './types.js';
|
|
9
10
|
export class PasteController {
|
|
@@ -156,8 +157,25 @@ export class PasteController {
|
|
|
156
157
|
for (const table of [...container.querySelectorAll('table')]) {
|
|
157
158
|
if (table.parentElement?.closest('table'))
|
|
158
159
|
continue;
|
|
159
|
-
|
|
160
|
+
// Inside an element that stays literal source, the whole element is
|
|
161
|
+
// kept as written, so its tables are not rewritten or judged here.
|
|
162
|
+
if (!insideRewrittenContent(table))
|
|
160
163
|
continue;
|
|
164
|
+
if (schema.nodes.table) {
|
|
165
|
+
// Judge the table as it will import, so wrappers such as `u` or
|
|
166
|
+
// `label` in a cell do not make it literal. A table that is still
|
|
167
|
+
// unsupported keeps its original source. An input in a cell would
|
|
168
|
+
// be dropped with its state, so that table stays source too.
|
|
169
|
+
const trial = table.cloneNode(true);
|
|
170
|
+
const holder = table.ownerDocument.createDocumentFragment();
|
|
171
|
+
holder.append(trial);
|
|
172
|
+
normalizeClipboardHTML(holder);
|
|
173
|
+
if (!table.querySelector('td input,th input') &&
|
|
174
|
+
!unsupportedClipboardTable(trial)) {
|
|
175
|
+
table.replaceWith(trial);
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
161
179
|
const preserved = ctx.get(parserCtx)(table.outerHTML);
|
|
162
180
|
table.replaceWith(DOMSerializer.fromSchema(schema).serializeFragment(preserved.content));
|
|
163
181
|
}
|
|
@@ -193,10 +211,10 @@ export class PasteController {
|
|
|
193
211
|
element.removeAttribute(name);
|
|
194
212
|
}
|
|
195
213
|
}
|
|
196
|
-
// Native HTML clipboards add encoding metadata
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
214
|
+
// Native HTML clipboards add encoding metadata and fragment markers.
|
|
215
|
+
normalizeClipboardHTML(container);
|
|
216
|
+
// Obsidian's reading view also copies its title and collapse controls
|
|
217
|
+
// with document HTML.
|
|
200
218
|
for (const control of container.querySelectorAll('div.mod-header.mod-ui,div.mod-footer.mod-ui')) {
|
|
201
219
|
if (control.querySelector('.inline-title[contenteditable],.embedded-backlinks'))
|
|
202
220
|
control.remove();
|
|
@@ -212,51 +230,8 @@ export class PasteController {
|
|
|
212
230
|
if (control.querySelector('svg.svg-icon'))
|
|
213
231
|
control.remove();
|
|
214
232
|
}
|
|
215
|
-
const supported = new Set([
|
|
216
|
-
'P',
|
|
217
|
-
'H1',
|
|
218
|
-
'H2',
|
|
219
|
-
'H3',
|
|
220
|
-
'H4',
|
|
221
|
-
'H5',
|
|
222
|
-
'H6',
|
|
223
|
-
'STRONG',
|
|
224
|
-
'MARK',
|
|
225
|
-
'B',
|
|
226
|
-
'EM',
|
|
227
|
-
'I',
|
|
228
|
-
'S',
|
|
229
|
-
'DEL',
|
|
230
|
-
'STRIKE',
|
|
231
|
-
'CODE',
|
|
232
|
-
'PRE',
|
|
233
|
-
'BLOCKQUOTE',
|
|
234
|
-
'A',
|
|
235
|
-
'BR',
|
|
236
|
-
'HR',
|
|
237
|
-
'UL',
|
|
238
|
-
'OL',
|
|
239
|
-
'LI',
|
|
240
|
-
'TABLE',
|
|
241
|
-
'THEAD',
|
|
242
|
-
'TBODY',
|
|
243
|
-
'TFOOT',
|
|
244
|
-
'TR',
|
|
245
|
-
'TH',
|
|
246
|
-
'TD',
|
|
247
|
-
'IMG',
|
|
248
|
-
'SPAN',
|
|
249
|
-
'SUP',
|
|
250
|
-
'DIV',
|
|
251
|
-
'FONT',
|
|
252
|
-
'SECTION',
|
|
253
|
-
'ARTICLE',
|
|
254
|
-
'MAIN',
|
|
255
|
-
'HEADER',
|
|
256
|
-
'FOOTER',
|
|
257
|
-
]);
|
|
258
233
|
for (const element of [...container.querySelectorAll('*')]) {
|
|
259
|
-
if (!container.contains(element) ||
|
|
234
|
+
if (!container.contains(element) || isRichElement(element))
|
|
260
235
|
continue;
|
|
261
236
|
const literal = document.createElement('pre');
|
|
262
237
|
literal.className = 'literal-markdown';
|
|
@@ -294,6 +269,7 @@ export class PasteController {
|
|
|
294
269
|
table.replaceWith(literal);
|
|
295
270
|
}
|
|
296
271
|
}
|
|
272
|
+
collapseClipboardWhitespace(container);
|
|
297
273
|
const imported = new Map();
|
|
298
274
|
const elements = [...container.querySelectorAll('img')];
|
|
299
275
|
const uniqueSources = new Set(elements.map((element) => element.getAttribute('src') ?? ''));
|
|
@@ -449,6 +425,12 @@ export class PasteController {
|
|
|
449
425
|
}
|
|
450
426
|
}
|
|
451
427
|
}
|
|
428
|
+
function insideRewrittenContent(element) {
|
|
429
|
+
for (let parent = element.parentElement; parent; parent = parent.parentElement)
|
|
430
|
+
if (!isRewrittenElement(parent))
|
|
431
|
+
return false;
|
|
432
|
+
return true;
|
|
433
|
+
}
|
|
452
434
|
function unsupportedClipboardTable(table) {
|
|
453
435
|
const rows = [...table.querySelectorAll('tr')];
|
|
454
436
|
const widths = rows.map((row) => row.querySelectorAll(':scope > th,:scope > td').length);
|
package/dist/preserve.d.ts
CHANGED
|
@@ -3,6 +3,7 @@ import type { Node as ProseNode } from '@milkdown/kit/prose/model';
|
|
|
3
3
|
export declare class PreservationError extends Error {
|
|
4
4
|
constructor(message: string, options?: ErrorOptions);
|
|
5
5
|
}
|
|
6
|
+
export declare function correspondence(before: string[], after: string[]): Array<number | undefined>;
|
|
6
7
|
/** Retains the loaded source of blocks the edit leaves alone. */
|
|
7
8
|
export declare class Preservation {
|
|
8
9
|
private readonly ctx;
|
package/dist/preserve.js
CHANGED
|
@@ -323,27 +323,89 @@ function withoutHeadingId(node) {
|
|
|
323
323
|
? node.type.create({ ...node.attrs, id: '' }, node.content, node.marks)
|
|
324
324
|
: node;
|
|
325
325
|
}
|
|
326
|
-
|
|
327
|
-
//
|
|
328
|
-
|
|
326
|
+
const MATCHING_BUDGET = 1_000_000;
|
|
327
|
+
// Unchanged blocks are anchored first, so a long document with many scattered
|
|
328
|
+
// edits is matched as many short gaps rather than one wide span.
|
|
329
|
+
export function correspondence(before, after) {
|
|
329
330
|
const result = new Array(after.length);
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
331
|
+
const work = { budget: MATCHING_BUDGET };
|
|
332
|
+
const spans = [{ b: 0, a: 0, bEnd: before.length, aEnd: after.length }];
|
|
333
|
+
for (let span = spans.pop(); span; span = spans.pop()) {
|
|
334
|
+
let { b, a, bEnd, aEnd } = span;
|
|
335
|
+
while (b < bEnd && a < aEnd && before[b] === after[a])
|
|
336
|
+
result[a++] = b++;
|
|
337
|
+
while (b < bEnd && a < aEnd && before[bEnd - 1] === after[aEnd - 1])
|
|
338
|
+
result[--aEnd] = --bEnd;
|
|
339
|
+
if (b === bEnd || a === aEnd)
|
|
340
|
+
continue;
|
|
341
|
+
work.budget -= bEnd - b + (aEnd - a);
|
|
342
|
+
if (work.budget < 0)
|
|
343
|
+
throw tooManyBlocks();
|
|
344
|
+
const anchors = uniqueAnchors(before, after, b, bEnd, a, aEnd);
|
|
345
|
+
if (!anchors.length) {
|
|
346
|
+
align(before, after, b, bEnd, a, aEnd, result, work);
|
|
347
|
+
continue;
|
|
348
|
+
}
|
|
349
|
+
for (const anchor of anchors) {
|
|
350
|
+
result[anchor.a] = anchor.b;
|
|
351
|
+
spans.push({ b, a, bEnd: anchor.b, aEnd: anchor.a });
|
|
352
|
+
b = anchor.b + 1;
|
|
353
|
+
a = anchor.a + 1;
|
|
354
|
+
}
|
|
355
|
+
spans.push({ b, a, bEnd, aEnd });
|
|
356
|
+
}
|
|
357
|
+
return result;
|
|
358
|
+
}
|
|
359
|
+
function tooManyBlocks() {
|
|
360
|
+
return new PreservationError('The edit changes too many blocks to preserve safely');
|
|
361
|
+
}
|
|
362
|
+
// Blocks that occur exactly once on each side, in the longest subsequence whose
|
|
363
|
+
// order the two sides share. A repeated block is never an anchor: which copy it
|
|
364
|
+
// belongs to is not known until its neighbours are placed.
|
|
365
|
+
function uniqueAnchors(before, after, b, bEnd, a, aEnd) {
|
|
366
|
+
const once = (list, from, to) => {
|
|
367
|
+
const at = new Map();
|
|
368
|
+
for (let i = from; i < to; i++)
|
|
369
|
+
at.set(list[i], at.has(list[i]) ? -1 : i);
|
|
370
|
+
return at;
|
|
371
|
+
};
|
|
372
|
+
const beforeAt = once(before, b, bEnd);
|
|
373
|
+
const afterAt = once(after, a, aEnd);
|
|
374
|
+
const pairs = [];
|
|
375
|
+
for (let i = a; i < aEnd; i++) {
|
|
376
|
+
const match = beforeAt.get(after[i]);
|
|
377
|
+
if (match != null && match >= 0 && afterAt.get(after[i]) === i)
|
|
378
|
+
pairs.push({ b: match, a: i });
|
|
379
|
+
}
|
|
380
|
+
// Patience sorting: tails[k] ends the best increasing subsequence of length k + 1.
|
|
381
|
+
const tails = [];
|
|
382
|
+
const previous = new Array(pairs.length);
|
|
383
|
+
for (const [index, pair] of pairs.entries()) {
|
|
384
|
+
let low = 0;
|
|
385
|
+
let high = tails.length;
|
|
386
|
+
while (low < high) {
|
|
387
|
+
const middle = (low + high) >> 1;
|
|
388
|
+
if (pairs[tails[middle]].b < pair.b)
|
|
389
|
+
low = middle + 1;
|
|
390
|
+
else
|
|
391
|
+
high = middle;
|
|
392
|
+
}
|
|
393
|
+
previous[index] = low ? tails[low - 1] : -1;
|
|
394
|
+
tails[low] = index;
|
|
336
395
|
}
|
|
337
|
-
|
|
338
|
-
let
|
|
339
|
-
while (
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
result[--endAfter] = --endBefore;
|
|
396
|
+
const anchors = [];
|
|
397
|
+
let at = tails.length ? tails[tails.length - 1] : -1;
|
|
398
|
+
while (at >= 0) {
|
|
399
|
+
anchors.push(pairs[at]);
|
|
400
|
+
at = previous[at];
|
|
343
401
|
}
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
402
|
+
return anchors.reverse();
|
|
403
|
+
}
|
|
404
|
+
// Edit distance distinguishes replacements from insertions, so replacing a block
|
|
405
|
+
// retains its surrounding whitespace without assigning it a neighbour's source.
|
|
406
|
+
function align(before, after, b, bEnd, a, aEnd, result, work) {
|
|
407
|
+
const rows = bEnd - b + 1;
|
|
408
|
+
const columns = aEnd - a + 1;
|
|
347
409
|
let width = Math.max(1, Math.abs(rows - columns));
|
|
348
410
|
for (;;) {
|
|
349
411
|
const costs = [];
|
|
@@ -353,13 +415,13 @@ function correspondence(before, after) {
|
|
|
353
415
|
for (let i = 0; i < rows; i++) {
|
|
354
416
|
const from = Math.max(0, i - width);
|
|
355
417
|
const to = Math.min(columns - 1, i + width);
|
|
356
|
-
budget -= to - from + 1;
|
|
357
|
-
if (budget < 0)
|
|
358
|
-
throw
|
|
418
|
+
work.budget -= to - from + 1;
|
|
419
|
+
if (work.budget < 0)
|
|
420
|
+
throw tooManyBlocks();
|
|
359
421
|
const row = new Uint32Array(to - from + 1);
|
|
360
422
|
costs.push(row);
|
|
361
423
|
for (let j = from; j <= to; j++) {
|
|
362
|
-
const change = before[
|
|
424
|
+
const change = before[b + i - 1] === after[a + j - 1] ? 0 : 1;
|
|
363
425
|
row[j - from] =
|
|
364
426
|
i === 0
|
|
365
427
|
? j
|
|
@@ -377,9 +439,9 @@ function correspondence(before, after) {
|
|
|
377
439
|
let i = rows - 1;
|
|
378
440
|
let j = columns - 1;
|
|
379
441
|
while (i || j) {
|
|
380
|
-
const change = i && j && before[
|
|
442
|
+
const change = i && j && before[b + i - 1] === after[a + j - 1] ? 0 : 1;
|
|
381
443
|
if (i && j && get(i, j) === get(i - 1, j - 1) + change) {
|
|
382
|
-
result[
|
|
444
|
+
result[a + j - 1] = b + i - 1;
|
|
383
445
|
i--;
|
|
384
446
|
j--;
|
|
385
447
|
}
|
|
@@ -388,9 +450,8 @@ function correspondence(before, after) {
|
|
|
388
450
|
else
|
|
389
451
|
j--;
|
|
390
452
|
}
|
|
391
|
-
|
|
453
|
+
return;
|
|
392
454
|
}
|
|
393
|
-
return result;
|
|
394
455
|
}
|
|
395
456
|
/** Retains the loaded source of blocks the edit leaves alone. */
|
|
396
457
|
export class Preservation {
|