@aicayzer/inkkit 0.0.17 → 0.0.18
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/README.md +2 -0
- package/dist/callouts.d.ts +0 -1
- package/dist/callouts.js +12 -35
- package/dist/code-fences.js +6 -1
- package/dist/comments.d.ts +21 -0
- package/dist/comments.js +55 -4
- package/dist/core.d.ts +7 -0
- package/dist/core.js +2 -0
- package/dist/cursor.d.ts +2 -0
- package/dist/cursor.js +40 -0
- package/dist/dialect.js +100 -12
- package/dist/drop-placement.d.ts +19 -0
- package/dist/drop-placement.js +47 -0
- package/dist/editor.d.ts +32 -1
- package/dist/editor.js +293 -61
- package/dist/html-import.d.ts +13 -0
- package/dist/html-import.js +477 -0
- package/dist/image-blocks.d.ts +15 -0
- package/dist/image-blocks.js +44 -1
- package/dist/index.d.ts +1 -7
- package/dist/index.js +3 -2
- package/dist/link-safety.d.ts +17 -0
- package/dist/link-safety.js +29 -0
- package/dist/literals.d.ts +1 -0
- package/dist/literals.js +14 -9
- package/dist/mermaid.d.ts +21 -1
- package/dist/mermaid.js +92 -9
- package/dist/paste-html.d.ts +8 -0
- package/dist/paste-html.js +21 -0
- package/dist/paste.d.ts +7 -0
- package/dist/paste.js +152 -43
- package/dist/preserve.d.ts +20 -3
- package/dist/preserve.js +793 -154
- package/dist/print.js +2 -5
- package/dist/references.d.ts +2 -0
- package/dist/references.js +63 -13
- package/dist/scroll.d.ts +15 -0
- package/dist/scroll.js +27 -0
- package/dist/spreadsheet.d.ts +6 -1
- package/dist/spreadsheet.js +29 -15
- package/dist/style.css +11 -19
- package/dist/tables.js +24 -2
- package/package.json +12 -4
package/dist/literals.js
CHANGED
|
@@ -4,11 +4,15 @@ const unsupported = new Set(['html', 'image', 'imageReference']);
|
|
|
4
4
|
export function sourceFrontmatter(source) {
|
|
5
5
|
return /^(---|\+\+\+)\r?\n[\s\S]*?\r?\n\1(?:\r?\n|$)/.exec(source)?.[0];
|
|
6
6
|
}
|
|
7
|
+
export function lineBreakHTML(value) {
|
|
8
|
+
return ['<br />', '<br>', '<br >', '<br/>'].includes(value.trim());
|
|
9
|
+
}
|
|
7
10
|
function restoreInlineBreaks(node) {
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
node.children
|
|
11
|
-
|
|
11
|
+
// A cell cannot hold a line ending, so even a lone break there is content.
|
|
12
|
+
if (((node.type === 'paragraph' || node.type === 'heading') &&
|
|
13
|
+
node.children.length > 1) ||
|
|
14
|
+
node.type === 'tableCell') {
|
|
15
|
+
node.children = node.children.map((child) => child.type === 'html' && lineBreakHTML(child.value)
|
|
12
16
|
? {
|
|
13
17
|
type: 'break',
|
|
14
18
|
data: { inkkitHTMLBreak: true },
|
|
@@ -21,16 +25,17 @@ function restoreInlineBreaks(node) {
|
|
|
21
25
|
}
|
|
22
26
|
function containsUnsupported(node, images, inline = false) {
|
|
23
27
|
// Only a standalone break represents a spacer; inline HTML must remain literal.
|
|
24
|
-
if (!inline &&
|
|
25
|
-
node.type === 'html' &&
|
|
26
|
-
['<br />', '<br>', '<br >', '<br/>'].includes(node.value.trim()))
|
|
28
|
+
if (!inline && node.type === 'html' && lineBreakHTML(node.value))
|
|
27
29
|
return false;
|
|
28
30
|
if (node.type === 'paragraph' && node.children.length === 1) {
|
|
29
31
|
const child = node.children[0];
|
|
30
|
-
if (child?.type === 'html' &&
|
|
31
|
-
['<br />', '<br>', '<br >', '<br/>'].includes(child.value.trim()))
|
|
32
|
+
if (child?.type === 'html' && lineBreakHTML(child.value))
|
|
32
33
|
return false;
|
|
33
34
|
}
|
|
35
|
+
// GFM ignores cells past the header's count, so a grid cannot show them.
|
|
36
|
+
if (node.type === 'table' &&
|
|
37
|
+
node.children.some((row) => row.children.length > (node.children[0]?.children.length ?? 0)))
|
|
38
|
+
return true;
|
|
34
39
|
return ((unsupported.has(node.type) && !(images && node.type === 'image')) ||
|
|
35
40
|
('children' in node &&
|
|
36
41
|
node.children.some((child) => containsUnsupported(child, images, inline || node.type === 'paragraph' || node.type === 'heading'))));
|
package/dist/mermaid.d.ts
CHANGED
|
@@ -1,8 +1,28 @@
|
|
|
1
1
|
import type { Node as ProseNode } from '@milkdown/kit/prose/model';
|
|
2
2
|
import { type PortableImage } from './types.js';
|
|
3
3
|
export declare function isMermaid(node: ProseNode): boolean;
|
|
4
|
-
|
|
4
|
+
interface Annotations {
|
|
5
|
+
names: string[];
|
|
6
|
+
rest: string;
|
|
7
|
+
}
|
|
8
|
+
export declare function validateDiagram(source: string): Annotations;
|
|
9
|
+
interface ParsedDiagram {
|
|
10
|
+
type: string;
|
|
11
|
+
db: unknown;
|
|
12
|
+
}
|
|
13
|
+
interface Mermaid {
|
|
14
|
+
initialize(config: object): void;
|
|
15
|
+
render(id: string, text: string, container?: Element): Promise<{
|
|
16
|
+
svg: string;
|
|
17
|
+
}>;
|
|
18
|
+
mermaidAPI: {
|
|
19
|
+
getDiagramFromText(text: string): Promise<ParsedDiagram>;
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
export declare function registerMermaid(load: () => Promise<Mermaid>): void;
|
|
23
|
+
export declare const diagramsAvailable: () => boolean;
|
|
5
24
|
export declare function safeDiagramSVG(svg: string): string;
|
|
6
25
|
export declare function renderDiagram(source: string): Promise<string>;
|
|
7
26
|
export declare function diagramImage(source: string): Promise<PortableImage>;
|
|
8
27
|
export declare function mermaidPreview(onError: (error: Error) => void): import("@milkdown/kit/utils").$Prose;
|
|
28
|
+
export {};
|
package/dist/mermaid.js
CHANGED
|
@@ -11,16 +11,48 @@ const refusal = 'HTML, styles, actions, resources and diagram configuration are
|
|
|
11
11
|
// CSS functions that can load an image or display another element. Mermaid's
|
|
12
12
|
// own styles use none of them apart from url() for in-document markers.
|
|
13
13
|
const cssResource = /url\s*\(\s*[^#\s]|(?:image-set|cross-fade|image|element|src)\s*\(/i;
|
|
14
|
+
// Annotation syntax in the positions the pinned grammars read it: class
|
|
15
|
+
// annotations on their own line, before a class name or after `class Name`, and
|
|
16
|
+
// the state diagram fork, join and choice markers after `state Name`.
|
|
17
|
+
const annotationName = String.raw `\w(?:[\w -]*\w)?`;
|
|
18
|
+
const classAnnotation = new RegExp(String.raw `(?<=^[ \t]*(?:class[ \t]+[^\s<>"]+(?:\["[^"\n<>]*"\])?[ \t]+)?)<<(${annotationName})>>(?=[ \t{]|$)`, 'gm');
|
|
19
|
+
const stateAnnotation = /(?<=^[ \t]*state[ \t]+[^\s<>"]+[ \t]+)<<(fork|join|choice)>>(?=[ \t]*$)/gim;
|
|
20
|
+
const stateBrackets = /^[ \t]*state[ \t].*\[\[(fork|join|choice)\]\]/gim;
|
|
21
|
+
// Separates the annotations that Mermaid may read from the rest of the source,
|
|
22
|
+
// which must pass the HTML check. Whether each one became an annotation is
|
|
23
|
+
// checked in the parse result.
|
|
24
|
+
function annotations(source) {
|
|
25
|
+
const header = /^\s*(?:%%[^\n]*\n\s*)*(classDiagram|stateDiagram)/.exec(source)?.[1];
|
|
26
|
+
const pattern = header === 'classDiagram'
|
|
27
|
+
? classAnnotation
|
|
28
|
+
: header === 'stateDiagram'
|
|
29
|
+
? stateAnnotation
|
|
30
|
+
: undefined;
|
|
31
|
+
if (!pattern)
|
|
32
|
+
return { names: [], rest: source };
|
|
33
|
+
const names = [];
|
|
34
|
+
const rest = source.replace(pattern, (_, name) => {
|
|
35
|
+
names.push(header === 'stateDiagram' ? name.toLowerCase() : name);
|
|
36
|
+
return '';
|
|
37
|
+
});
|
|
38
|
+
// The class lexer skips comments, so a comment after << would hide an
|
|
39
|
+
// annotation name from the HTML check.
|
|
40
|
+
if (header === 'classDiagram' && /<<\s*%%/.test(rest))
|
|
41
|
+
throw new Error(refusal);
|
|
42
|
+
return { names, rest };
|
|
43
|
+
}
|
|
14
44
|
export function validateDiagram(source) {
|
|
15
45
|
if (source.length > 30000)
|
|
16
46
|
throw new Error('The diagram exceeds the 30,000 character rendering limit.');
|
|
17
47
|
if (!/^\s*(?:%%[^\n]*\n\s*)*(?:flowchart|graph|sequenceDiagram|classDiagram|stateDiagram(?:-v2)?|erDiagram|pie)\b/.test(source))
|
|
18
48
|
throw new Error('This diagram type is not supported. The source is retained.');
|
|
49
|
+
const found = annotations(source);
|
|
19
50
|
// Mermaid can load image bytes before sanitising its result, so resource and
|
|
20
51
|
// configuration syntax is refused anywhere in the source, labels included.
|
|
21
52
|
// 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(
|
|
53
|
+
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(found.rest))
|
|
23
54
|
throw new Error(refusal);
|
|
55
|
+
return found;
|
|
24
56
|
}
|
|
25
57
|
const entries = (value) => value instanceof Map ? [...value.values()] : Array.isArray(value) ? value : [];
|
|
26
58
|
const filled = (value) => value instanceof Map
|
|
@@ -34,14 +66,17 @@ const filled = (value) => value instanceof Map
|
|
|
34
66
|
const clickable = (classes) => (Array.isArray(classes) ? classes : String(classes ?? '').split(/\s+/))
|
|
35
67
|
.map(String)
|
|
36
68
|
.includes('clickable');
|
|
37
|
-
|
|
69
|
+
const unchecked = () => new Error('The diagram could not be checked. The source is retained.');
|
|
70
|
+
// Visits each object reachable from node once, until visit returns true.
|
|
71
|
+
function walk(node, visit, seen = new Set()) {
|
|
38
72
|
if (typeof node !== 'object' || node === null || seen.has(node))
|
|
39
73
|
return false;
|
|
40
74
|
seen.add(node);
|
|
41
|
-
if (
|
|
75
|
+
if (visit(node))
|
|
42
76
|
return true;
|
|
43
|
-
return Object.values(node).some((value) =>
|
|
77
|
+
return Object.values(node).some((value) => walk(value, visit, seen));
|
|
44
78
|
}
|
|
79
|
+
const statementIn = (node, kinds) => walk(node, (entry) => kinds.includes(String(entry.stmt)));
|
|
45
80
|
// Refuses styles, class definitions, link styles, links, click handlers,
|
|
46
81
|
// tooltips, icons and images wherever the grammar accepted them. The names are
|
|
47
82
|
// those of the pinned Mermaid's diagram databases; if one is missing, the
|
|
@@ -50,13 +85,13 @@ function refuseDirectives({ type, db }) {
|
|
|
50
85
|
const store = db;
|
|
51
86
|
const field = (name) => {
|
|
52
87
|
if (!(name in store))
|
|
53
|
-
throw
|
|
88
|
+
throw unchecked();
|
|
54
89
|
return store[name];
|
|
55
90
|
};
|
|
56
91
|
const read = (name) => {
|
|
57
92
|
const accessor = field(name);
|
|
58
93
|
if (typeof accessor !== 'function')
|
|
59
|
-
throw
|
|
94
|
+
throw unchecked();
|
|
60
95
|
return accessor.call(db);
|
|
61
96
|
};
|
|
62
97
|
let found;
|
|
@@ -109,6 +144,48 @@ function refuseDirectives({ type, db }) {
|
|
|
109
144
|
if (found)
|
|
110
145
|
throw new Error(refusal);
|
|
111
146
|
}
|
|
147
|
+
// Each annotation taken out of the source check must be one that Mermaid read
|
|
148
|
+
// as an annotation. One read as text, in a note or a multi-line label, would
|
|
149
|
+
// put angle brackets in a label.
|
|
150
|
+
function refuseStrayAnnotations({ type, db }, source, names) {
|
|
151
|
+
if (names.length === 0)
|
|
152
|
+
return;
|
|
153
|
+
const store = db;
|
|
154
|
+
const expected = [...names];
|
|
155
|
+
const parsed = [];
|
|
156
|
+
if (type === 'classDiagram') {
|
|
157
|
+
if (typeof store.getClasses !== 'function')
|
|
158
|
+
throw unchecked();
|
|
159
|
+
for (const node of entries(store.getClasses.call(db))) {
|
|
160
|
+
if (!Array.isArray(node.annotations))
|
|
161
|
+
throw unchecked();
|
|
162
|
+
parsed.push(...node.annotations.map(String));
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
else if (type === 'stateDiagram') {
|
|
166
|
+
if (!('rootDoc' in store))
|
|
167
|
+
throw unchecked();
|
|
168
|
+
walk(store.rootDoc, ({ stmt, type }) => {
|
|
169
|
+
if (stmt === 'state' && /^(?:fork|join|choice)$/.test(String(type)))
|
|
170
|
+
parsed.push(String(type));
|
|
171
|
+
});
|
|
172
|
+
// [[fork]] syntax on a state line makes the same states without angle
|
|
173
|
+
// brackets.
|
|
174
|
+
for (const [, name] of source.matchAll(stateBrackets))
|
|
175
|
+
expected.push(name.toLowerCase());
|
|
176
|
+
}
|
|
177
|
+
else
|
|
178
|
+
throw new Error(refusal);
|
|
179
|
+
if (parsed.sort().join('\n') !== expected.sort().join('\n'))
|
|
180
|
+
throw new Error(refusal);
|
|
181
|
+
}
|
|
182
|
+
let loadMermaid;
|
|
183
|
+
// The package's main entry registers Mermaid. The core entry does not, so a
|
|
184
|
+
// host that imports only the core entry does not bundle it.
|
|
185
|
+
export function registerMermaid(load) {
|
|
186
|
+
loadMermaid = load;
|
|
187
|
+
}
|
|
188
|
+
export const diagramsAvailable = () => loadMermaid !== undefined;
|
|
112
189
|
let sequence = 0;
|
|
113
190
|
let queue = Promise.resolve();
|
|
114
191
|
const cache = new Map();
|
|
@@ -139,8 +216,12 @@ export function safeDiagramSVG(svg) {
|
|
|
139
216
|
return root.outerHTML;
|
|
140
217
|
}
|
|
141
218
|
export function renderDiagram(source) {
|
|
219
|
+
const load = loadMermaid;
|
|
220
|
+
if (!load)
|
|
221
|
+
return Promise.reject(new Error('Diagram rendering is not included in this build. The source is retained.'));
|
|
222
|
+
let names;
|
|
142
223
|
try {
|
|
143
|
-
validateDiagram(source);
|
|
224
|
+
names = validateDiagram(source).names;
|
|
144
225
|
}
|
|
145
226
|
catch (error) {
|
|
146
227
|
return Promise.reject(error);
|
|
@@ -149,7 +230,7 @@ export function renderDiagram(source) {
|
|
|
149
230
|
if (previous)
|
|
150
231
|
return previous;
|
|
151
232
|
const rendered = queue.then(async () => {
|
|
152
|
-
const
|
|
233
|
+
const mermaid = await load();
|
|
153
234
|
mermaid.initialize({
|
|
154
235
|
startOnLoad: false,
|
|
155
236
|
securityLevel: 'strict',
|
|
@@ -196,7 +277,9 @@ export function renderDiagram(source) {
|
|
|
196
277
|
// again on the same text; the public parse does not return the diagram. It
|
|
197
278
|
// skips render's configuration step, so the two parse alike only because
|
|
198
279
|
// the source check refuses %%{ directives and front matter.
|
|
199
|
-
|
|
280
|
+
const parsed = await mermaid.mermaidAPI.getDiagramFromText(source);
|
|
281
|
+
refuseDirectives(parsed);
|
|
282
|
+
refuseStrayAnnotations(parsed, source, names);
|
|
200
283
|
// Mermaid measures text while laying out, so the holder must be attached
|
|
201
284
|
// and rendered. Such a holder can load CSS resources before sanitising,
|
|
202
285
|
// which is why the source and the parse result are checked first.
|
package/dist/paste-html.d.ts
CHANGED
|
@@ -8,12 +8,20 @@ export declare function isRewrittenElement(element: Element): boolean;
|
|
|
8
8
|
* `!important` do not matter. Style sheets are not consulted.
|
|
9
9
|
*/
|
|
10
10
|
export declare function keepsWhitespace(element: Element): boolean;
|
|
11
|
+
/**
|
|
12
|
+
* WebKit writes a space beside an inline element to the clipboard as a
|
|
13
|
+
* non-breaking space in a `span.Apple-converted-space`, to protect it from
|
|
14
|
+
* the destination's whitespace collapse. Only such a span is converted back:
|
|
15
|
+
* a non-breaking space anywhere else was authored.
|
|
16
|
+
*/
|
|
17
|
+
export declare function restoreConvertedSpaces(root: ParentNode): void;
|
|
11
18
|
/**
|
|
12
19
|
* Rewrites common web-page elements to ones the importer already understands.
|
|
13
20
|
* Elements that will stay literal source are not entered, so their content is
|
|
14
21
|
* preserved exactly.
|
|
15
22
|
*/
|
|
16
23
|
export declare function normalizeClipboardHTML(root: Node): void;
|
|
24
|
+
export declare function isBlockElement(element: Element): boolean;
|
|
17
25
|
/**
|
|
18
26
|
* Collapses source whitespace the way a browser renders it: runs become one
|
|
19
27
|
* space, and none remains at the edges of a block or beside a line break.
|
package/dist/paste-html.js
CHANGED
|
@@ -158,6 +158,21 @@ function declarations(style) {
|
|
|
158
158
|
result.push(current);
|
|
159
159
|
return result;
|
|
160
160
|
}
|
|
161
|
+
/**
|
|
162
|
+
* WebKit writes a space beside an inline element to the clipboard as a
|
|
163
|
+
* non-breaking space in a `span.Apple-converted-space`, to protect it from
|
|
164
|
+
* the destination's whitespace collapse. Only such a span is converted back:
|
|
165
|
+
* a non-breaking space anywhere else was authored.
|
|
166
|
+
*/
|
|
167
|
+
export function restoreConvertedSpaces(root) {
|
|
168
|
+
for (const span of root.querySelectorAll('span.Apple-converted-space'))
|
|
169
|
+
restoreConvertedSpace(span);
|
|
170
|
+
}
|
|
171
|
+
function restoreConvertedSpace(span) {
|
|
172
|
+
for (const node of span.childNodes)
|
|
173
|
+
if (node.nodeType === Node.TEXT_NODE)
|
|
174
|
+
node.data = node.data.replace(/\u00a0/g, ' ');
|
|
175
|
+
}
|
|
161
176
|
/**
|
|
162
177
|
* Rewrites common web-page elements to ones the importer already understands.
|
|
163
178
|
* Elements that will stay literal source are not entered, so their content is
|
|
@@ -211,6 +226,9 @@ export function normalizeClipboardHTML(root) {
|
|
|
211
226
|
else {
|
|
212
227
|
if (tag === 'LI')
|
|
213
228
|
markTask(element);
|
|
229
|
+
else if (tag === 'SPAN' &&
|
|
230
|
+
element.classList.contains('Apple-converted-space'))
|
|
231
|
+
restoreConvertedSpace(element);
|
|
214
232
|
normalizeClipboardHTML(element);
|
|
215
233
|
}
|
|
216
234
|
}
|
|
@@ -249,6 +267,9 @@ const blocks = new Set([
|
|
|
249
267
|
'FOOTER',
|
|
250
268
|
'HR',
|
|
251
269
|
]);
|
|
270
|
+
export function isBlockElement(element) {
|
|
271
|
+
return blocks.has(element.tagName);
|
|
272
|
+
}
|
|
252
273
|
// Bolds the inline content of a term. Paragraphs, headings and divisions
|
|
253
274
|
// inside it each become one bold paragraph; other blocks are kept as they are.
|
|
254
275
|
function boldInto(target, nodes) {
|
package/dist/paste.d.ts
CHANGED
|
@@ -14,6 +14,7 @@ export declare class PasteController {
|
|
|
14
14
|
private active?;
|
|
15
15
|
private readonly key;
|
|
16
16
|
private dead;
|
|
17
|
+
private claimed;
|
|
17
18
|
get pending(): boolean;
|
|
18
19
|
readonly plugin: import("@milkdown/kit/utils").$Prose;
|
|
19
20
|
constructor(options: PasteControllerOptions);
|
|
@@ -22,5 +23,11 @@ export declare class PasteController {
|
|
|
22
23
|
cancelPending(): void;
|
|
23
24
|
destroy(): void;
|
|
24
25
|
private assertContext;
|
|
26
|
+
/**
|
|
27
|
+
* Inserts dropped content as whole blocks, so none joins the block beside
|
|
28
|
+
* it, and puts the caret after them.
|
|
29
|
+
*/
|
|
30
|
+
private insertBlocks;
|
|
31
|
+
/** Imports the input at the selection, or in place of `target`. */
|
|
25
32
|
private run;
|
|
26
33
|
}
|
package/dist/paste.js
CHANGED
|
@@ -3,8 +3,10 @@ import { DOMParser as ProseParser, DOMSerializer, Fragment, Slice, } from '@milk
|
|
|
3
3
|
import { Plugin, PluginKey } from '@milkdown/kit/prose/state';
|
|
4
4
|
import { $prose } from '@milkdown/kit/utils';
|
|
5
5
|
import { pasteSpreadsheet } from './spreadsheet.js';
|
|
6
|
+
import { importableSlice, topLevel } from './html-import.js';
|
|
7
|
+
import { blockDropRange, caretAfter } from './drop-placement.js';
|
|
6
8
|
import { isImage } from './image-blocks.js';
|
|
7
|
-
import { collapseClipboardWhitespace, isRewrittenElement, isRichElement, normalizeClipboardHTML, } from './paste-html.js';
|
|
9
|
+
import { collapseClipboardWhitespace, isBlockElement, isRewrittenElement, isRichElement, normalizeClipboardHTML, } from './paste-html.js';
|
|
8
10
|
import { avoidReferenceCollisions, markdownFromHTML, } from './reference-clipboard.js';
|
|
9
11
|
import { InkKitError, } from './types.js';
|
|
10
12
|
export class PasteController {
|
|
@@ -12,6 +14,7 @@ export class PasteController {
|
|
|
12
14
|
active;
|
|
13
15
|
key = new PluginKey('inkkitPaste');
|
|
14
16
|
dead = false;
|
|
17
|
+
claimed = false;
|
|
15
18
|
get pending() {
|
|
16
19
|
return this.active != null;
|
|
17
20
|
}
|
|
@@ -34,30 +37,51 @@ export class PasteController {
|
|
|
34
37
|
},
|
|
35
38
|
},
|
|
36
39
|
props: {
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
+
handleDOMEvents: {
|
|
41
|
+
paste: (view, event) => {
|
|
42
|
+
// ProseMirror parses the clipboard before asking handlePaste,
|
|
43
|
+
// and this controller then imports it again itself.
|
|
44
|
+
this.claimed = claimsPaste(view, event) !== false;
|
|
45
|
+
queueMicrotask(() => {
|
|
46
|
+
this.claimed = false;
|
|
47
|
+
});
|
|
40
48
|
return false;
|
|
41
|
-
|
|
49
|
+
},
|
|
50
|
+
// A drop of HTML from outside the editor imports as a paste of
|
|
51
|
+
// whole blocks, placed as dropped images are. ProseMirror keeps
|
|
52
|
+
// moves within the editor and drops of plain text.
|
|
53
|
+
drop: (view, event) => {
|
|
54
|
+
const data = event.dataTransfer;
|
|
55
|
+
if (!data?.getData('text/html') ||
|
|
56
|
+
!view.editable ||
|
|
57
|
+
view.dragging)
|
|
58
|
+
return false;
|
|
59
|
+
event.preventDefault();
|
|
60
|
+
const target = blockDropRange(view, event.clientX, event.clientY);
|
|
61
|
+
view.focus();
|
|
62
|
+
void this.run({
|
|
63
|
+
text: data.getData('text/plain'),
|
|
64
|
+
html: data.getData('text/html'),
|
|
65
|
+
plainText: this.options.literalText?.() ?? false,
|
|
66
|
+
}, [...data.files].filter((file) => file.type.startsWith('image/')), target).catch((error) => options.onError?.(error));
|
|
67
|
+
return true;
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
// Pastes and drops that ProseMirror inserts itself.
|
|
71
|
+
transformPasted: (slice, view, plain) => this.claimed || plain || view.dragging
|
|
72
|
+
? slice
|
|
73
|
+
: importableSlice(this.options.ctx(), slice),
|
|
74
|
+
handlePaste: (view, event) => {
|
|
75
|
+
this.claimed = false;
|
|
76
|
+
const claim = claimsPaste(view, event);
|
|
77
|
+
if (claim === false)
|
|
42
78
|
return false;
|
|
79
|
+
const data = event.clipboardData;
|
|
43
80
|
const text = data.getData('text/plain');
|
|
44
81
|
const html = data.getData('text/html');
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
const value = JSON.parse(metadata);
|
|
49
|
-
if (!value ||
|
|
50
|
-
typeof value !== 'object' ||
|
|
51
|
-
!('mode' in value) ||
|
|
52
|
-
typeof value.mode !== 'string')
|
|
53
|
-
throw new Error('Invalid VS Code clipboard metadata');
|
|
54
|
-
// Retain the provider's code-language behaviour for valid metadata.
|
|
55
|
-
return false;
|
|
56
|
-
}
|
|
57
|
-
catch {
|
|
58
|
-
void this.paste({ text, plainText: true }).catch((error) => options.onError?.(error));
|
|
59
|
-
return true;
|
|
60
|
-
}
|
|
82
|
+
if (claim === 'plain') {
|
|
83
|
+
void this.paste({ text, plainText: true }).catch((error) => options.onError?.(error));
|
|
84
|
+
return true;
|
|
61
85
|
}
|
|
62
86
|
const files = [...data.files].filter((file) => file.type.startsWith('image/'));
|
|
63
87
|
void this.run({
|
|
@@ -94,7 +118,18 @@ export class PasteController {
|
|
|
94
118
|
current.generation !== context.generation)
|
|
95
119
|
throw new InkKitError('stale-document', 'The document changed while images were importing.');
|
|
96
120
|
}
|
|
97
|
-
|
|
121
|
+
/**
|
|
122
|
+
* Inserts dropped content as whole blocks, so none joins the block beside
|
|
123
|
+
* it, and puts the caret after them.
|
|
124
|
+
*/
|
|
125
|
+
insertBlocks(view, slice, range) {
|
|
126
|
+
const blocks = Fragment.fromArray(topLevel(view.state.schema, slice.content));
|
|
127
|
+
const tr = view.state.tr.replaceWith(range.from, range.to, blocks);
|
|
128
|
+
const end = tr.mapping.map(range.to);
|
|
129
|
+
view.dispatch(tr.setSelection(caretAfter(tr.doc, end)).scrollIntoView());
|
|
130
|
+
}
|
|
131
|
+
/** Imports the input at the selection, or in place of `target`. */
|
|
132
|
+
async run(input, files = [], target) {
|
|
98
133
|
if (this.dead)
|
|
99
134
|
throw new InkKitError('destroyed', 'The editor was destroyed.');
|
|
100
135
|
if (this.pending)
|
|
@@ -106,10 +141,10 @@ export class PasteController {
|
|
|
106
141
|
return;
|
|
107
142
|
if (view.composing)
|
|
108
143
|
throw new InkKitError('composition', 'Finish composing text before pasting.');
|
|
109
|
-
const operation =
|
|
110
|
-
|
|
111
|
-
to: view.state.selection.to
|
|
112
|
-
|
|
144
|
+
const operation = target
|
|
145
|
+
? { ...target }
|
|
146
|
+
: { from: view.state.selection.from, to: view.state.selection.to };
|
|
147
|
+
const inCode = () => view.state.doc.resolve(this.anchor(operation).from).parent.type.spec.code;
|
|
113
148
|
this.active = operation;
|
|
114
149
|
this.options.onPendingChanged?.();
|
|
115
150
|
try {
|
|
@@ -123,19 +158,17 @@ export class PasteController {
|
|
|
123
158
|
this.assertContext(context, operation);
|
|
124
159
|
const schema = view.state.schema;
|
|
125
160
|
let slice;
|
|
126
|
-
const plainText = input.plainText ||
|
|
127
|
-
this.options.literalText?.() ||
|
|
128
|
-
view.state.selection.$from.parent.type.spec.code;
|
|
161
|
+
const plainText = input.plainText || this.options.literalText?.() || inCode();
|
|
129
162
|
if (!plainText &&
|
|
130
163
|
input.markdown == null &&
|
|
131
|
-
pasteSpreadsheet(ctx, { ...input, images }))
|
|
164
|
+
pasteSpreadsheet(ctx, { ...input, images }, target && dropRange(view.state.doc, this.anchor(operation))))
|
|
132
165
|
return;
|
|
133
166
|
const markdown = plainText
|
|
134
167
|
? undefined
|
|
135
168
|
: (input.markdown ??
|
|
136
169
|
(input.html ? markdownFromHTML(input.html) : undefined));
|
|
137
170
|
if (plainText) {
|
|
138
|
-
if (
|
|
171
|
+
if (inCode()) {
|
|
139
172
|
const anchor = this.anchor(operation);
|
|
140
173
|
view.dispatch(view.state.tr.insertText(input.text, anchor.from, anchor.to));
|
|
141
174
|
return;
|
|
@@ -238,16 +271,6 @@ export class PasteController {
|
|
|
238
271
|
literal.textContent = element.outerHTML;
|
|
239
272
|
element.replaceWith(literal);
|
|
240
273
|
}
|
|
241
|
-
const walker = document.createTreeWalker(container, NodeFilter.SHOW_COMMENT);
|
|
242
|
-
const comments = [];
|
|
243
|
-
while (walker.nextNode())
|
|
244
|
-
comments.push(walker.currentNode);
|
|
245
|
-
for (const comment of comments) {
|
|
246
|
-
const literal = document.createElement('pre');
|
|
247
|
-
literal.className = 'literal-markdown';
|
|
248
|
-
literal.textContent = `<!--${comment.data}-->`;
|
|
249
|
-
comment.replaceWith(literal);
|
|
250
|
-
}
|
|
251
274
|
for (const table of [...container.querySelectorAll('table')]) {
|
|
252
275
|
if (table.parentElement?.closest('table'))
|
|
253
276
|
continue;
|
|
@@ -270,6 +293,18 @@ export class PasteController {
|
|
|
270
293
|
}
|
|
271
294
|
}
|
|
272
295
|
collapseClipboardWhitespace(container);
|
|
296
|
+
// An authored comment is read in Markdown as an InkKit comment, so it
|
|
297
|
+
// imports as one, after whitespace collapse has passed it by.
|
|
298
|
+
const walker = document.createTreeWalker(container, NodeFilter.SHOW_COMMENT);
|
|
299
|
+
const comments = [];
|
|
300
|
+
while (walker.nextNode())
|
|
301
|
+
comments.push(walker.currentNode);
|
|
302
|
+
for (const comment of comments) {
|
|
303
|
+
const element = document.createElement(inlineComment(comment) ? 'span' : 'div');
|
|
304
|
+
element.setAttribute('data-inkkit-comment', 'html');
|
|
305
|
+
element.textContent = comment.data;
|
|
306
|
+
comment.replaceWith(element);
|
|
307
|
+
}
|
|
273
308
|
const imported = new Map();
|
|
274
309
|
const elements = [...container.querySelectorAll('img')];
|
|
275
310
|
const uniqueSources = new Set(elements.map((element) => element.getAttribute('src') ?? ''));
|
|
@@ -334,9 +369,9 @@ export class PasteController {
|
|
|
334
369
|
slice = new Slice(hydrate(parsed).content, 0, 0);
|
|
335
370
|
}
|
|
336
371
|
else
|
|
337
|
-
slice = ProseParser.fromSchema(schema).parseSlice(container, {
|
|
372
|
+
slice = importableSlice(ctx, ProseParser.fromSchema(schema).parseSlice(container, {
|
|
338
373
|
preserveWhitespace: true,
|
|
339
|
-
});
|
|
374
|
+
}));
|
|
340
375
|
}
|
|
341
376
|
else if (images.length) {
|
|
342
377
|
const nodes = [];
|
|
@@ -400,7 +435,25 @@ export class PasteController {
|
|
|
400
435
|
? remapped.firstChild.content
|
|
401
436
|
: remapped.content, slice.openStart, slice.openEnd);
|
|
402
437
|
}
|
|
438
|
+
if (target) {
|
|
439
|
+
this.insertBlocks(view, slice, dropRange(view.state.doc, anchor));
|
|
440
|
+
return;
|
|
441
|
+
}
|
|
403
442
|
const insertion = view.state.doc.resolve(anchor.from);
|
|
443
|
+
// Blocks pasted into an empty top-level paragraph take its place whole,
|
|
444
|
+
// as a drop does; joining the first of them into it could lose a
|
|
445
|
+
// wrapper whose only content was empty.
|
|
446
|
+
if (anchor.from === anchor.to &&
|
|
447
|
+
insertion.depth === 1 &&
|
|
448
|
+
insertion.parent.type.name === 'paragraph' &&
|
|
449
|
+
insertion.parent.content.size === 0 &&
|
|
450
|
+
slice.content.firstChild?.isBlock) {
|
|
451
|
+
this.insertBlocks(view, slice, {
|
|
452
|
+
from: insertion.before(),
|
|
453
|
+
to: insertion.after(),
|
|
454
|
+
});
|
|
455
|
+
return;
|
|
456
|
+
}
|
|
404
457
|
// An isolated block such as a table cannot fit inside an empty paragraph.
|
|
405
458
|
// Replace that empty block rather than retaining a spurious spacer.
|
|
406
459
|
if (anchor.from === anchor.to &&
|
|
@@ -425,6 +478,62 @@ export class PasteController {
|
|
|
425
478
|
}
|
|
426
479
|
}
|
|
427
480
|
}
|
|
481
|
+
/**
|
|
482
|
+
* The range a drop replaces, mapped to the current document. It replaces an
|
|
483
|
+
* empty paragraph only while that is still empty, so text typed there while
|
|
484
|
+
* images import is kept, and the drop then follows it.
|
|
485
|
+
*/
|
|
486
|
+
function dropRange(doc, range) {
|
|
487
|
+
const node = doc.nodeAt(range.from);
|
|
488
|
+
return range.from < range.to &&
|
|
489
|
+
node?.type.name === 'paragraph' &&
|
|
490
|
+
node.content.size === 0 &&
|
|
491
|
+
range.from + node.nodeSize === range.to
|
|
492
|
+
? range
|
|
493
|
+
: { from: range.to, to: range.to };
|
|
494
|
+
}
|
|
495
|
+
/**
|
|
496
|
+
* Whether `handlePaste` takes the paste, and as what. Valid VS Code metadata
|
|
497
|
+
* keeps the provider's code-language behaviour; invalid metadata pastes plain
|
|
498
|
+
* text.
|
|
499
|
+
*/
|
|
500
|
+
function claimsPaste(view, event) {
|
|
501
|
+
const data = event.clipboardData;
|
|
502
|
+
if (!data || !view.editable)
|
|
503
|
+
return false;
|
|
504
|
+
if (view.state.selection.$from.parent.type.spec.code)
|
|
505
|
+
return false;
|
|
506
|
+
const metadata = data.getData('vscode-editor-data');
|
|
507
|
+
if (!metadata)
|
|
508
|
+
return 'import';
|
|
509
|
+
try {
|
|
510
|
+
const value = JSON.parse(metadata);
|
|
511
|
+
return value &&
|
|
512
|
+
typeof value === 'object' &&
|
|
513
|
+
'mode' in value &&
|
|
514
|
+
typeof value.mode === 'string'
|
|
515
|
+
? false
|
|
516
|
+
: 'plain';
|
|
517
|
+
}
|
|
518
|
+
catch {
|
|
519
|
+
return 'plain';
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
// Inside text or an element that holds text, a comment is part of a line.
|
|
523
|
+
function inlineComment(comment) {
|
|
524
|
+
const parent = comment.parentElement;
|
|
525
|
+
if (parent && /^(?:P|H[1-6]|TD|TH|LI|DT|DD)$/.test(parent.tagName))
|
|
526
|
+
return true;
|
|
527
|
+
if (parent && isRichElement(parent) && !isBlockElement(parent))
|
|
528
|
+
return true;
|
|
529
|
+
for (const sibling of comment.parentNode?.childNodes ?? [])
|
|
530
|
+
if ((sibling.nodeType === Node.TEXT_NODE && sibling.textContent?.trim()) ||
|
|
531
|
+
(sibling.nodeType === Node.ELEMENT_NODE &&
|
|
532
|
+
isRichElement(sibling) &&
|
|
533
|
+
!isBlockElement(sibling)))
|
|
534
|
+
return true;
|
|
535
|
+
return false;
|
|
536
|
+
}
|
|
428
537
|
function insideRewrittenContent(element) {
|
|
429
538
|
for (let parent = element.parentElement; parent; parent = parent.parentElement)
|
|
430
539
|
if (!isRewrittenElement(parent))
|
package/dist/preserve.d.ts
CHANGED
|
@@ -3,7 +3,8 @@ 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
|
|
6
|
+
export declare function semanticSignature(doc: ProseNode): string;
|
|
7
|
+
export declare function correspondence(before: string[], after: string[]): Array<number | undefined>;
|
|
7
8
|
/** Retains the loaded source of blocks the edit leaves alone. */
|
|
8
9
|
export declare class Preservation {
|
|
9
10
|
private readonly ctx;
|
|
@@ -13,9 +14,25 @@ export declare class Preservation {
|
|
|
13
14
|
private readonly lineEnding;
|
|
14
15
|
private readonly originalForms;
|
|
15
16
|
private readonly forms;
|
|
16
|
-
private readonly
|
|
17
|
+
private readonly blockEdits;
|
|
18
|
+
private readonly itemEdits;
|
|
19
|
+
private readonly definitionNodes;
|
|
20
|
+
private readonly definitionSources?;
|
|
17
21
|
constructor(ctx: Ctx, source: string);
|
|
18
22
|
private retainBlock;
|
|
19
|
-
private
|
|
23
|
+
private localEdits;
|
|
24
|
+
private pairedInPlace;
|
|
25
|
+
private definitions;
|
|
26
|
+
private inContext;
|
|
27
|
+
private parseLocally;
|
|
28
|
+
private canonicalTree;
|
|
29
|
+
private static plainInline;
|
|
30
|
+
private separated;
|
|
31
|
+
private localBlock;
|
|
32
|
+
private formOf;
|
|
33
|
+
private localItems;
|
|
34
|
+
private localItem;
|
|
35
|
+
private separateLists;
|
|
36
|
+
private relabelList;
|
|
20
37
|
serialize(doc: ProseNode): string;
|
|
21
38
|
}
|