@aicayzer/inkkit 0.0.1 → 0.0.3
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 +4 -2
- package/dist/callouts.d.ts +24 -0
- package/dist/callouts.js +296 -0
- package/dist/clipboard.js +31 -1
- package/dist/comments.d.ts +55 -0
- package/dist/comments.js +487 -0
- package/dist/dialect.js +12 -2
- package/dist/editor.d.ts +7 -2
- package/dist/editor.js +199 -10
- package/dist/inline-highlight.d.ts +6 -0
- package/dist/inline-highlight.js +112 -0
- package/dist/literals.d.ts +5 -0
- package/dist/literals.js +62 -20
- package/dist/paste.js +61 -5
- package/dist/preserve.js +254 -8
- package/dist/reference-clipboard.d.ts +8 -0
- package/dist/reference-clipboard.js +201 -0
- package/dist/references.d.ts +14 -0
- package/dist/references.js +367 -0
- package/dist/style.css +117 -0
- package/dist/types.d.ts +1 -0
- package/package.json +11 -4
package/dist/paste.js
CHANGED
|
@@ -2,6 +2,7 @@ import { editorViewCtx, parserCtx } from '@milkdown/kit/core';
|
|
|
2
2
|
import { DOMParser as ProseParser, Fragment, Slice, } from '@milkdown/kit/prose/model';
|
|
3
3
|
import { Plugin, PluginKey } from '@milkdown/kit/prose/state';
|
|
4
4
|
import { $prose } from '@milkdown/kit/utils';
|
|
5
|
+
import { avoidReferenceCollisions, markdownFromHTML, } from './reference-clipboard.js';
|
|
5
6
|
import { InkKitError, } from './types.js';
|
|
6
7
|
export class PasteController {
|
|
7
8
|
options;
|
|
@@ -113,6 +114,10 @@ export class PasteController {
|
|
|
113
114
|
const plainText = input.plainText ||
|
|
114
115
|
this.options.literalText?.() ||
|
|
115
116
|
view.state.selection.$from.parent.type.spec.code;
|
|
117
|
+
const markdown = plainText
|
|
118
|
+
? undefined
|
|
119
|
+
: (input.markdown ??
|
|
120
|
+
(input.html ? markdownFromHTML(input.html) : undefined));
|
|
116
121
|
if (plainText) {
|
|
117
122
|
if (view.state.selection.$from.parent.type.spec.code) {
|
|
118
123
|
const anchor = operation;
|
|
@@ -131,6 +136,21 @@ export class PasteController {
|
|
|
131
136
|
const template = document.createElement('template');
|
|
132
137
|
template.innerHTML = input.html;
|
|
133
138
|
const container = template.content;
|
|
139
|
+
if (markdown == null) {
|
|
140
|
+
// Native destinations can keep node metadata after dropping the
|
|
141
|
+
// Markdown wrapper and its definitions. Import their visible HTML.
|
|
142
|
+
for (const element of container.querySelectorAll('[data-inkkit-reference],[data-inkkit-footnote-reference],[data-inkkit-footnote-definition]')) {
|
|
143
|
+
for (const name of [
|
|
144
|
+
'data-inkkit-reference',
|
|
145
|
+
'data-inkkit-footnote-reference',
|
|
146
|
+
'data-inkkit-footnote-definition',
|
|
147
|
+
'data-inkkit-label',
|
|
148
|
+
'data-inkkit-reference-type',
|
|
149
|
+
'data-inkkit-reference-content',
|
|
150
|
+
])
|
|
151
|
+
element.removeAttribute(name);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
134
154
|
// Native HTML clipboards add encoding metadata. Obsidian's reading
|
|
135
155
|
// view also copies its title and collapse controls with document HTML.
|
|
136
156
|
for (const metadata of container.querySelectorAll('meta[charset]'))
|
|
@@ -159,6 +179,7 @@ export class PasteController {
|
|
|
159
179
|
'H5',
|
|
160
180
|
'H6',
|
|
161
181
|
'STRONG',
|
|
182
|
+
'MARK',
|
|
162
183
|
'B',
|
|
163
184
|
'EM',
|
|
164
185
|
'I',
|
|
@@ -183,6 +204,7 @@ export class PasteController {
|
|
|
183
204
|
'TD',
|
|
184
205
|
'IMG',
|
|
185
206
|
'SPAN',
|
|
207
|
+
'SUP',
|
|
186
208
|
'DIV',
|
|
187
209
|
'FONT',
|
|
188
210
|
'SECTION',
|
|
@@ -271,9 +293,33 @@ export class PasteController {
|
|
|
271
293
|
: 'The pasted image is unavailable.'));
|
|
272
294
|
}
|
|
273
295
|
}
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
296
|
+
if (markdown != null) {
|
|
297
|
+
const parsed = ctx.get(parserCtx)(markdown);
|
|
298
|
+
let imageIndex = 0;
|
|
299
|
+
const hydrate = (node) => {
|
|
300
|
+
if (node.type.name === 'image') {
|
|
301
|
+
const slot = /^inkkit-clipboard-image:(\d+)$/.exec(String(node.attrs.src));
|
|
302
|
+
const position = slot ? Number(slot[1]) : imageIndex++;
|
|
303
|
+
const element = elements.some((element) => element.hasAttribute('data-inkkit-image-slot'))
|
|
304
|
+
? elements.find((element) => Number(element.getAttribute('data-inkkit-image-slot')) ===
|
|
305
|
+
position)
|
|
306
|
+
: elements[position];
|
|
307
|
+
if (!element || !container.contains(element))
|
|
308
|
+
return schema.text(`[${node.attrs.alt || 'Image'}: image unavailable]`);
|
|
309
|
+
return node.type.create({ ...node.attrs, src: element.getAttribute('src') }, null, node.marks);
|
|
310
|
+
}
|
|
311
|
+
if (node.isText)
|
|
312
|
+
return node;
|
|
313
|
+
const children = [];
|
|
314
|
+
node.forEach((child) => children.push(hydrate(child)));
|
|
315
|
+
return node.copy(Fragment.fromArray(children));
|
|
316
|
+
};
|
|
317
|
+
slice = new Slice(hydrate(parsed).content, 0, 0);
|
|
318
|
+
}
|
|
319
|
+
else
|
|
320
|
+
slice = ProseParser.fromSchema(schema).parseSlice(container, {
|
|
321
|
+
preserveWhitespace: true,
|
|
322
|
+
});
|
|
277
323
|
}
|
|
278
324
|
else if (images.length) {
|
|
279
325
|
const nodes = [];
|
|
@@ -310,7 +356,8 @@ export class PasteController {
|
|
|
310
356
|
else {
|
|
311
357
|
const selection = view.state.selection;
|
|
312
358
|
const text = input.text.trim();
|
|
313
|
-
if (
|
|
359
|
+
if (markdown == null &&
|
|
360
|
+
!selection.empty &&
|
|
314
361
|
/^https?:\/\/\S+$/.test(text) &&
|
|
315
362
|
schema.marks.link) {
|
|
316
363
|
const anchor = operation;
|
|
@@ -320,13 +367,22 @@ export class PasteController {
|
|
|
320
367
|
.removeStoredMark(mark));
|
|
321
368
|
return;
|
|
322
369
|
}
|
|
323
|
-
const doc = ctx.get(parserCtx)(input.text);
|
|
370
|
+
const doc = ctx.get(parserCtx)(markdown ?? input.text);
|
|
324
371
|
slice = new Slice(doc.content, 0, 0);
|
|
325
372
|
}
|
|
326
373
|
this.assertContext(context, operation);
|
|
327
374
|
if (!view.editable || this.options.editable?.() === false)
|
|
328
375
|
return;
|
|
329
376
|
const anchor = operation;
|
|
377
|
+
if (!plainText) {
|
|
378
|
+
const valid = slice.content.firstChild?.isInline
|
|
379
|
+
? Fragment.from(schema.nodes.paragraph.create(null, slice.content))
|
|
380
|
+
: slice.content;
|
|
381
|
+
const remapped = avoidReferenceCollisions(schema.topNodeType.create(null, valid), view.state.doc);
|
|
382
|
+
slice = new Slice(slice.content.firstChild?.isInline
|
|
383
|
+
? remapped.firstChild.content
|
|
384
|
+
: remapped.content, slice.openStart, slice.openEnd);
|
|
385
|
+
}
|
|
330
386
|
const insertion = view.state.doc.resolve(anchor.from);
|
|
331
387
|
// An isolated block such as a table cannot fit inside an empty paragraph.
|
|
332
388
|
// Replace that empty block rather than retaining a spurious spacer.
|
package/dist/preserve.js
CHANGED
|
@@ -1,5 +1,191 @@
|
|
|
1
1
|
import { parserCtx, remarkCtx } from '@milkdown/kit/core';
|
|
2
2
|
import { serialize } from './dialect.js';
|
|
3
|
+
function signature(node) {
|
|
4
|
+
return JSON.stringify(node, (key, value) => ['position', 'data', 'url', 'title', 'marker'].includes(key)
|
|
5
|
+
? undefined
|
|
6
|
+
: value);
|
|
7
|
+
}
|
|
8
|
+
function ownShape(node) {
|
|
9
|
+
return JSON.stringify(node, (key, value) => ['position', 'data', 'children', 'value', 'marker'].includes(key)
|
|
10
|
+
? undefined
|
|
11
|
+
: value);
|
|
12
|
+
}
|
|
13
|
+
function retainText(raw, before, after, quoteIndent = false) {
|
|
14
|
+
before = before.replace(/\r\n?/g, '\n');
|
|
15
|
+
after = after.replace(/\r\n?/g, '\n');
|
|
16
|
+
let prefix = 0;
|
|
17
|
+
while (prefix < before.length &&
|
|
18
|
+
prefix < after.length &&
|
|
19
|
+
before[prefix] === after[prefix])
|
|
20
|
+
prefix++;
|
|
21
|
+
let suffix = 0;
|
|
22
|
+
while (suffix < before.length - prefix &&
|
|
23
|
+
suffix < after.length - prefix &&
|
|
24
|
+
before[before.length - 1 - suffix] === after[after.length - 1 - suffix])
|
|
25
|
+
suffix++;
|
|
26
|
+
const offsets = [0];
|
|
27
|
+
let decoded = '';
|
|
28
|
+
for (let index = 0; index < raw.length;) {
|
|
29
|
+
if (index > 0 && raw[index - 1] === '\n') {
|
|
30
|
+
const gutter = (quoteIndent ? /^(?:[ \t]*>[ \t]*)+/ : /^(?:[ \t]*>[ \t]?)+/).exec(raw.slice(index));
|
|
31
|
+
if (gutter) {
|
|
32
|
+
index += gutter[0].length;
|
|
33
|
+
offsets[offsets.length - 1] = index;
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
const escaped = /^\\([!"#$%&'()*+,\-./:;<=>?@[\]\\^_`{|}~])/.exec(raw.slice(index));
|
|
38
|
+
const entity = /^&(?:#[xX][\da-fA-F]+|#\d+|[A-Za-z][A-Za-z\d]+);/.exec(raw.slice(index));
|
|
39
|
+
let value;
|
|
40
|
+
let length;
|
|
41
|
+
if (escaped) {
|
|
42
|
+
value = escaped[1];
|
|
43
|
+
length = escaped[0].length;
|
|
44
|
+
}
|
|
45
|
+
else if (entity) {
|
|
46
|
+
const element = document.createElement('textarea');
|
|
47
|
+
element.innerHTML = entity[0];
|
|
48
|
+
value = element.value;
|
|
49
|
+
length = entity[0].length;
|
|
50
|
+
}
|
|
51
|
+
else if (raw.slice(index, index + 2) === '\r\n') {
|
|
52
|
+
value = '\n';
|
|
53
|
+
length = 2;
|
|
54
|
+
}
|
|
55
|
+
else {
|
|
56
|
+
value = raw[index];
|
|
57
|
+
length = 1;
|
|
58
|
+
}
|
|
59
|
+
decoded += value;
|
|
60
|
+
index += length;
|
|
61
|
+
for (let part = 0; part < value.length; part++)
|
|
62
|
+
offsets.push(index);
|
|
63
|
+
}
|
|
64
|
+
if (decoded !== before) {
|
|
65
|
+
// Continuation indentation is excluded from a quoted paragraph's text.
|
|
66
|
+
return quoteIndent ? after : retainText(raw, before, after, true);
|
|
67
|
+
}
|
|
68
|
+
const inserted = after
|
|
69
|
+
.slice(prefix, after.length - suffix)
|
|
70
|
+
.split('')
|
|
71
|
+
.map((character, index) => {
|
|
72
|
+
const at = prefix + index;
|
|
73
|
+
const delimiter = (character === '%' || character === '=') &&
|
|
74
|
+
(after[at - 1] === character || after[at + 1] === character);
|
|
75
|
+
return delimiter || /[\\`*_[\]<>]/.test(character)
|
|
76
|
+
? `\\${character}`
|
|
77
|
+
: character;
|
|
78
|
+
})
|
|
79
|
+
.join('');
|
|
80
|
+
return (raw.slice(0, offsets[prefix]) +
|
|
81
|
+
inserted +
|
|
82
|
+
raw.slice(offsets[before.length - suffix]));
|
|
83
|
+
}
|
|
84
|
+
function retainBoundaryWhitespace(raw, canonical) {
|
|
85
|
+
// A middle-of-line space becomes indentation or trimmed text at a block edge.
|
|
86
|
+
const token = '(?:[ \\t]|&#(?:[xX]0*(?:20|9)|0*(?:32|9));|	)';
|
|
87
|
+
const decoded = (value) => (value.match(new RegExp(token, 'g')) ?? [])
|
|
88
|
+
.map((value) => value === '\t' || /(?:9;|Tab;)$/.test(value) ? '\t' : ' ')
|
|
89
|
+
.join('');
|
|
90
|
+
const encode = (value) => value.replace(/[ \t]/g, (value) => (value === '\t' ? '	' : ' '));
|
|
91
|
+
const start = /^[ \t]+/.exec(raw)?.[0];
|
|
92
|
+
const rawStart = new RegExp(`^${token}+`).exec(raw)?.[0];
|
|
93
|
+
const encodedStart = new RegExp(`^${token}+`).exec(canonical)?.[0];
|
|
94
|
+
if (start &&
|
|
95
|
+
rawStart &&
|
|
96
|
+
encodedStart?.includes('&') &&
|
|
97
|
+
decoded(rawStart) === decoded(encodedStart))
|
|
98
|
+
raw = encode(start) + raw.slice(start.length);
|
|
99
|
+
const end = /[ \t]+$/.exec(raw)?.[0];
|
|
100
|
+
const rawEnd = new RegExp(`${token}+$`).exec(raw)?.[0];
|
|
101
|
+
const encodedEnd = new RegExp(`${token}+$`).exec(canonical)?.[0];
|
|
102
|
+
if (end &&
|
|
103
|
+
rawEnd &&
|
|
104
|
+
encodedEnd?.includes('&') &&
|
|
105
|
+
decoded(rawEnd) === decoded(encodedEnd))
|
|
106
|
+
raw = raw.slice(0, -end.length) + encode(end);
|
|
107
|
+
return raw;
|
|
108
|
+
}
|
|
109
|
+
// Patch changed leaves so an adjacent edit retains authored delimiters and labels.
|
|
110
|
+
function retainSource(before, after, source, canonical, lineEnding) {
|
|
111
|
+
const start = before.position?.start.offset;
|
|
112
|
+
const end = before.position?.end.offset;
|
|
113
|
+
const newStart = after.position?.start.offset;
|
|
114
|
+
const newEnd = after.position?.end.offset;
|
|
115
|
+
if (start == null || end == null || newStart == null || newEnd == null)
|
|
116
|
+
throw new PreservationError('A source token has no location');
|
|
117
|
+
const raw = source.slice(start, end);
|
|
118
|
+
if (before.type === 'definition' &&
|
|
119
|
+
after.type === 'definition' &&
|
|
120
|
+
before.label === after.label &&
|
|
121
|
+
before.title === after.title) {
|
|
122
|
+
const destination = /^(\[[\s\S]*?\]:[ \t]*)(<[^>\n]*>|[^\s]+)([\s\S]*)$/.exec(raw);
|
|
123
|
+
if (destination)
|
|
124
|
+
return (destination[1] +
|
|
125
|
+
(destination[2].startsWith('<')
|
|
126
|
+
? `<${after.url}>`
|
|
127
|
+
: String(after.url)) +
|
|
128
|
+
destination[3]);
|
|
129
|
+
}
|
|
130
|
+
if (before.type === 'text' && after.type === 'text')
|
|
131
|
+
return retainBoundaryWhitespace(retainText(raw, before.value, after.value), canonical.slice(newStart, newEnd));
|
|
132
|
+
if (signature(before) === signature(after) &&
|
|
133
|
+
ownShape(before) === ownShape(after))
|
|
134
|
+
return raw;
|
|
135
|
+
if (before.type === after.type &&
|
|
136
|
+
ownShape(before) === ownShape(after) &&
|
|
137
|
+
before.children &&
|
|
138
|
+
after.children &&
|
|
139
|
+
before.children.length === after.children.length) {
|
|
140
|
+
let output = '', previous = start;
|
|
141
|
+
before.children.forEach((child, index) => {
|
|
142
|
+
const childStart = child.position?.start.offset, childEnd = child.position?.end.offset;
|
|
143
|
+
if (childStart == null || childEnd == null)
|
|
144
|
+
throw new PreservationError('A source token has no location');
|
|
145
|
+
output +=
|
|
146
|
+
source.slice(previous, childStart) +
|
|
147
|
+
retainSource(child, after.children[index], source, canonical, lineEnding);
|
|
148
|
+
previous = childEnd;
|
|
149
|
+
});
|
|
150
|
+
return output + source.slice(previous, end);
|
|
151
|
+
}
|
|
152
|
+
if (before.type === after.type &&
|
|
153
|
+
ownShape(before) === ownShape(after) &&
|
|
154
|
+
before.children &&
|
|
155
|
+
after.children) {
|
|
156
|
+
let prefix = 0, suffix = 0;
|
|
157
|
+
while (prefix < before.children.length &&
|
|
158
|
+
prefix < after.children.length &&
|
|
159
|
+
signature(before.children[prefix]) === signature(after.children[prefix]))
|
|
160
|
+
prefix++;
|
|
161
|
+
while (suffix < before.children.length - prefix &&
|
|
162
|
+
suffix < after.children.length - prefix &&
|
|
163
|
+
signature(before.children[before.children.length - 1 - suffix]) ===
|
|
164
|
+
signature(after.children[after.children.length - 1 - suffix]))
|
|
165
|
+
suffix++;
|
|
166
|
+
const from = prefix
|
|
167
|
+
? before.children[prefix - 1].position?.end.offset
|
|
168
|
+
: start;
|
|
169
|
+
const to = suffix
|
|
170
|
+
? before.children[before.children.length - suffix].position?.start.offset
|
|
171
|
+
: end;
|
|
172
|
+
const newFrom = prefix
|
|
173
|
+
? after.children[prefix - 1].position?.end.offset
|
|
174
|
+
: newStart;
|
|
175
|
+
const newTo = suffix
|
|
176
|
+
? after.children[after.children.length - suffix].position?.start.offset
|
|
177
|
+
: newEnd;
|
|
178
|
+
if (from != null && to != null && newFrom != null && newTo != null)
|
|
179
|
+
return retainBoundaryWhitespace(source.slice(start, from) +
|
|
180
|
+
canonical.slice(newFrom, newTo).replaceAll('\n', lineEnding) +
|
|
181
|
+
source.slice(to, end), canonical.slice(newStart, newEnd));
|
|
182
|
+
}
|
|
183
|
+
return canonical.slice(newStart, newEnd).replaceAll('\n', lineEnding);
|
|
184
|
+
}
|
|
185
|
+
function positionalNode(tree, transformed) {
|
|
186
|
+
return (tree.children.find((node) => node.position?.start.offset === transformed.position?.start.offset &&
|
|
187
|
+
node.position?.end.offset === transformed.position?.end.offset) ?? transformed);
|
|
188
|
+
}
|
|
3
189
|
export class PreservationError extends Error {
|
|
4
190
|
constructor(message, options) {
|
|
5
191
|
super(message, options);
|
|
@@ -16,6 +202,51 @@ function blockForm(ctx, node) {
|
|
|
16
202
|
return '<br />';
|
|
17
203
|
return form(serialize(ctx, node.type.schema.topNodeType.create(null, [node])));
|
|
18
204
|
}
|
|
205
|
+
function semanticSignature(doc) {
|
|
206
|
+
const clean = (value) => {
|
|
207
|
+
const content = value.content;
|
|
208
|
+
if (!content)
|
|
209
|
+
return value;
|
|
210
|
+
const tokens = [];
|
|
211
|
+
for (const child of content) {
|
|
212
|
+
if (child.type !== 'text') {
|
|
213
|
+
tokens.push(clean(child));
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
216
|
+
const parts = /^(\s*)([\s\S]*?)(\s*)$/.exec(String(child.text));
|
|
217
|
+
for (const [index, text] of parts.slice(1).entries()) {
|
|
218
|
+
if (!text)
|
|
219
|
+
continue;
|
|
220
|
+
const token = {
|
|
221
|
+
...child,
|
|
222
|
+
text,
|
|
223
|
+
marks: index === 1 ? child.marks : undefined,
|
|
224
|
+
};
|
|
225
|
+
const previous = tokens.at(-1);
|
|
226
|
+
if (previous?.type === 'text' &&
|
|
227
|
+
JSON.stringify(previous.marks) === JSON.stringify(token.marks))
|
|
228
|
+
previous.text = String(previous.text) + text;
|
|
229
|
+
else
|
|
230
|
+
tokens.push(token);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
return { ...value, content: tokens };
|
|
234
|
+
};
|
|
235
|
+
const json = JSON.parse(JSON.stringify(doc.toJSON(), (key, value) =>
|
|
236
|
+
// Fresh list items default to loose even when their Markdown reopens tight.
|
|
237
|
+
[
|
|
238
|
+
'referenceContent',
|
|
239
|
+
'referenceType',
|
|
240
|
+
'listPlaceholder',
|
|
241
|
+
'marker',
|
|
242
|
+
'id',
|
|
243
|
+
'label',
|
|
244
|
+
'spread',
|
|
245
|
+
].includes(key)
|
|
246
|
+
? undefined
|
|
247
|
+
: value));
|
|
248
|
+
return JSON.stringify(clean(json));
|
|
249
|
+
}
|
|
19
250
|
// Edit distance distinguishes replacements from insertions, so replacing a block
|
|
20
251
|
// retains its surrounding whitespace without assigning it a neighbour's source.
|
|
21
252
|
function correspondence(before, after) {
|
|
@@ -86,10 +317,11 @@ export class Preservation {
|
|
|
86
317
|
const processor = ctx.get(remarkCtx);
|
|
87
318
|
const offset = source.startsWith('\uFEFF') ? 1 : 0;
|
|
88
319
|
const body = source.slice(offset);
|
|
89
|
-
const
|
|
320
|
+
const rawTree = processor.parse(source);
|
|
321
|
+
const tree = processor.runSync(structuredClone(rawTree), {
|
|
90
322
|
value: body,
|
|
91
323
|
});
|
|
92
|
-
for (const node of tree.children) {
|
|
324
|
+
for (const [index, node] of tree.children.entries()) {
|
|
93
325
|
let start = node.position?.start.offset;
|
|
94
326
|
const end = node.position?.end.offset;
|
|
95
327
|
if (start == null || end == null)
|
|
@@ -100,13 +332,16 @@ export class Preservation {
|
|
|
100
332
|
if (/^[ \t]*$/.test(source.slice(firstColumn, start)))
|
|
101
333
|
start = firstColumn;
|
|
102
334
|
const text = source.slice(start, sourceEnd);
|
|
103
|
-
const parsed =
|
|
335
|
+
const parsed = this.baseline.childCount === tree.children.length
|
|
336
|
+
? this.baseline.type.create(null, [this.baseline.child(index)])
|
|
337
|
+
: parse(text);
|
|
104
338
|
this.blocks.push({
|
|
105
339
|
start,
|
|
106
340
|
end: sourceEnd,
|
|
107
341
|
form: parsed.childCount === 1
|
|
108
342
|
? blockForm(ctx, parsed.firstChild)
|
|
109
343
|
: form(serialize(ctx, parsed)),
|
|
344
|
+
ast: positionalNode(rawTree, node),
|
|
110
345
|
});
|
|
111
346
|
}
|
|
112
347
|
}
|
|
@@ -135,6 +370,12 @@ export class Preservation {
|
|
|
135
370
|
current.push(fingerprint);
|
|
136
371
|
});
|
|
137
372
|
const positions = correspondence(this.blocks.map((block) => block.form), current);
|
|
373
|
+
const processor = this.ctx.get(remarkCtx);
|
|
374
|
+
const currentRawTree = processor.parse(canonical);
|
|
375
|
+
const currentTree = processor.runSync(structuredClone(currentRawTree), {
|
|
376
|
+
value: canonical,
|
|
377
|
+
});
|
|
378
|
+
const body = this.source.replace(/^\uFEFF/, '');
|
|
138
379
|
let output = '';
|
|
139
380
|
let previous;
|
|
140
381
|
current.forEach((text, index) => {
|
|
@@ -151,10 +392,13 @@ export class Preservation {
|
|
|
151
392
|
}
|
|
152
393
|
else
|
|
153
394
|
output += this.lineEnding.repeat(2);
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
395
|
+
if (block && block.form === text)
|
|
396
|
+
output += this.source.slice(block.start, block.end);
|
|
397
|
+
else if (block && currentTree.children.length === current.length) {
|
|
398
|
+
output += retainSource(block.ast, positionalNode(currentRawTree, currentTree.children[index]), body, canonical, this.lineEnding);
|
|
399
|
+
}
|
|
400
|
+
else
|
|
401
|
+
output += text.replaceAll('\n', this.lineEnding);
|
|
158
402
|
previous = position;
|
|
159
403
|
});
|
|
160
404
|
if (previous === this.blocks.length - 1 && this.blocks.length)
|
|
@@ -162,7 +406,9 @@ export class Preservation {
|
|
|
162
406
|
else if (output)
|
|
163
407
|
output += this.lineEnding;
|
|
164
408
|
const parse = this.ctx.get(parserCtx);
|
|
165
|
-
|
|
409
|
+
const reopened = parse(output);
|
|
410
|
+
if (serialize(this.ctx, reopened) === canonical &&
|
|
411
|
+
semanticSignature(reopened) === semanticSignature(doc))
|
|
166
412
|
return output;
|
|
167
413
|
throw new PreservationError('The edited Markdown cannot be reopened without changing its content');
|
|
168
414
|
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { Ctx } from '@milkdown/kit/ctx';
|
|
2
|
+
import { Fragment, type Node } from '@milkdown/kit/prose/model';
|
|
3
|
+
export declare function selectionContent(doc: Node, content: Fragment): Fragment;
|
|
4
|
+
export declare function selectionMarkdown(ctx: Ctx, doc: Node, content: Fragment): string;
|
|
5
|
+
export declare function referenceMetadata(ctx: Ctx, doc: Node, content: Fragment): string | undefined;
|
|
6
|
+
export declare function avoidReferenceCollisions(incoming: Node, destination: Node): Node;
|
|
7
|
+
export declare function markdownFromHTML(html: string): string | undefined;
|
|
8
|
+
export declare function withReferenceMetadata(html: string, markdown: string): string;
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import { Fragment } from '@milkdown/kit/prose/model';
|
|
2
|
+
import { serialize } from './dialect.js';
|
|
3
|
+
import { normaliseLabel } from './references.js';
|
|
4
|
+
import { shareableFragment } from './comments.js';
|
|
5
|
+
function key(node) {
|
|
6
|
+
if (node.type.name === 'reference_definition')
|
|
7
|
+
return `link:${node.attrs.identifier}`;
|
|
8
|
+
if (node.type.name === 'footnote_definition')
|
|
9
|
+
return `footnote:${node.attrs.identifier}`;
|
|
10
|
+
return undefined;
|
|
11
|
+
}
|
|
12
|
+
function used(content) {
|
|
13
|
+
const result = new Set();
|
|
14
|
+
content.descendants((node) => {
|
|
15
|
+
if (node.type.name === 'footnote_reference')
|
|
16
|
+
result.add(`footnote:${node.attrs.identifier}`);
|
|
17
|
+
for (const mark of node.marks)
|
|
18
|
+
if (mark.type.name === 'link' && mark.attrs.identifier)
|
|
19
|
+
result.add(`link:${mark.attrs.identifier}`);
|
|
20
|
+
});
|
|
21
|
+
return result;
|
|
22
|
+
}
|
|
23
|
+
export function selectionContent(doc, content) {
|
|
24
|
+
const definitions = new Map();
|
|
25
|
+
doc.descendants((node) => {
|
|
26
|
+
const id = key(node);
|
|
27
|
+
if (id && !definitions.has(id))
|
|
28
|
+
definitions.set(id, node);
|
|
29
|
+
});
|
|
30
|
+
const required = used(content);
|
|
31
|
+
for (const id of required) {
|
|
32
|
+
const definition = definitions.get(id);
|
|
33
|
+
if (definition)
|
|
34
|
+
for (const dependency of used(definition.content))
|
|
35
|
+
required.add(dependency);
|
|
36
|
+
}
|
|
37
|
+
const complete = (node) => {
|
|
38
|
+
const id = key(node);
|
|
39
|
+
if (id && required.has(id) && definitions.has(id))
|
|
40
|
+
return definitions.get(id);
|
|
41
|
+
if (node.isText)
|
|
42
|
+
return node;
|
|
43
|
+
const children = [];
|
|
44
|
+
node.forEach((child) => children.push(complete(child)));
|
|
45
|
+
return node.copy(Fragment.fromArray(children));
|
|
46
|
+
};
|
|
47
|
+
const selected = [];
|
|
48
|
+
content.forEach((node) => selected.push(complete(node)));
|
|
49
|
+
content = Fragment.fromArray(selected);
|
|
50
|
+
const included = new Set();
|
|
51
|
+
content.descendants((node) => {
|
|
52
|
+
const id = key(node);
|
|
53
|
+
if (id)
|
|
54
|
+
included.add(id);
|
|
55
|
+
});
|
|
56
|
+
const extra = [];
|
|
57
|
+
for (const id of required) {
|
|
58
|
+
if (included.has(id))
|
|
59
|
+
continue;
|
|
60
|
+
const definition = definitions.get(id);
|
|
61
|
+
if (!definition)
|
|
62
|
+
continue;
|
|
63
|
+
included.add(id);
|
|
64
|
+
extra.push(definition);
|
|
65
|
+
for (const dependency of used(definition.content))
|
|
66
|
+
required.add(dependency);
|
|
67
|
+
}
|
|
68
|
+
return content.append(Fragment.fromArray(extra));
|
|
69
|
+
}
|
|
70
|
+
export function selectionMarkdown(ctx, doc, content) {
|
|
71
|
+
return serialize(ctx, doc.type.create(null, selectionContent(doc, content)));
|
|
72
|
+
}
|
|
73
|
+
export function referenceMetadata(ctx, doc, content) {
|
|
74
|
+
doc = doc.copy(shareableFragment(doc.content));
|
|
75
|
+
content = shareableFragment(content);
|
|
76
|
+
let references = false, index = 0;
|
|
77
|
+
const rewrite = (node) => {
|
|
78
|
+
if (key(node) ||
|
|
79
|
+
node.type.name === 'footnote_reference' ||
|
|
80
|
+
node.marks.some((mark) => mark.type.name === 'link' && mark.attrs.identifier))
|
|
81
|
+
references = true;
|
|
82
|
+
if (node.type.name === 'image')
|
|
83
|
+
return node.type.create({ ...node.attrs, src: `inkkit-clipboard-image:${index++}` }, null, node.marks);
|
|
84
|
+
if (node.isText)
|
|
85
|
+
return node;
|
|
86
|
+
const children = [];
|
|
87
|
+
node.forEach((child) => children.push(rewrite(child)));
|
|
88
|
+
return node.copy(Fragment.fromArray(children));
|
|
89
|
+
};
|
|
90
|
+
const valid = content.firstChild?.isInline
|
|
91
|
+
? Fragment.from(doc.type.schema.nodes.paragraph.create(null, content))
|
|
92
|
+
: content;
|
|
93
|
+
const copied = rewrite(doc.type.create(null, selectionContent(doc, valid)));
|
|
94
|
+
return references ? serialize(ctx, copied) : undefined;
|
|
95
|
+
}
|
|
96
|
+
function allLabels(doc) {
|
|
97
|
+
const labels = new Map([
|
|
98
|
+
['link', new Set()],
|
|
99
|
+
['footnote', new Set()],
|
|
100
|
+
]);
|
|
101
|
+
doc.descendants((node) => {
|
|
102
|
+
if (isCode(node))
|
|
103
|
+
return false;
|
|
104
|
+
const id = key(node);
|
|
105
|
+
if (id) {
|
|
106
|
+
const separator = id.indexOf(':');
|
|
107
|
+
labels.get(id.slice(0, separator)).add(id.slice(separator + 1));
|
|
108
|
+
}
|
|
109
|
+
if (node.type.name === 'footnote_reference')
|
|
110
|
+
labels.get('footnote').add(node.attrs.identifier);
|
|
111
|
+
for (const mark of node.marks)
|
|
112
|
+
if (mark.type.name === 'link' && mark.attrs.identifier)
|
|
113
|
+
labels.get('link').add(mark.attrs.identifier);
|
|
114
|
+
if (node.isText) {
|
|
115
|
+
for (const match of node.text.matchAll(rawReferences))
|
|
116
|
+
labels
|
|
117
|
+
.get(match[1].startsWith('^') ? 'footnote' : 'link')
|
|
118
|
+
.add(normaliseLabel(match[1].startsWith('^')
|
|
119
|
+
? match[1].slice(1)
|
|
120
|
+
: match[2] || match[1]));
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
return labels;
|
|
124
|
+
}
|
|
125
|
+
const rawReferences = /(?<![\\!])\[([^\]]+)\](?:\[([^\]]*)\])?(?!\()/g;
|
|
126
|
+
function isCode(node) {
|
|
127
|
+
return (node.type.name.startsWith('comment_') ||
|
|
128
|
+
node.type.name === 'code_block' ||
|
|
129
|
+
node.marks.some((mark) => mark.type.name === 'inlineCode'));
|
|
130
|
+
}
|
|
131
|
+
// Rename only the incoming document. Definitions belong to the insertion's undo transaction.
|
|
132
|
+
export function avoidReferenceCollisions(incoming, destination) {
|
|
133
|
+
const occupied = allLabels(destination);
|
|
134
|
+
const incomingLabels = allLabels(incoming);
|
|
135
|
+
const replacements = new Map();
|
|
136
|
+
for (const [kind, labels] of incomingLabels) {
|
|
137
|
+
const reserved = new Set([...occupied.get(kind), ...labels]);
|
|
138
|
+
for (const identifier of labels) {
|
|
139
|
+
if (!occupied.get(kind).has(identifier))
|
|
140
|
+
continue;
|
|
141
|
+
let suffix = 2;
|
|
142
|
+
while (reserved.has(normaliseLabel(`${identifier}-${suffix}`)))
|
|
143
|
+
suffix++;
|
|
144
|
+
const label = `${identifier}-${suffix}`;
|
|
145
|
+
reserved.add(normaliseLabel(label));
|
|
146
|
+
replacements.set(`${kind}:${identifier}`, label);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
if (!replacements.size)
|
|
150
|
+
return incoming;
|
|
151
|
+
const rewrite = (node) => {
|
|
152
|
+
if (isCode(node))
|
|
153
|
+
return node;
|
|
154
|
+
const kind = node.type.name.startsWith('footnote_') ? 'footnote' : 'link';
|
|
155
|
+
const label = replacements.get(`${kind}:${node.attrs.identifier}`);
|
|
156
|
+
const attrs = label
|
|
157
|
+
? { ...node.attrs, label, identifier: normaliseLabel(label) }
|
|
158
|
+
: node.attrs;
|
|
159
|
+
const marks = node.marks.map((mark) => {
|
|
160
|
+
const replacement = replacements.get(`link:${mark.attrs.identifier}`);
|
|
161
|
+
return replacement && mark.type.name === 'link'
|
|
162
|
+
? mark.type.create({
|
|
163
|
+
...mark.attrs,
|
|
164
|
+
label: replacement,
|
|
165
|
+
identifier: normaliseLabel(replacement),
|
|
166
|
+
referenceType: 'full',
|
|
167
|
+
})
|
|
168
|
+
: mark;
|
|
169
|
+
});
|
|
170
|
+
if (node.isText) {
|
|
171
|
+
const text = node.text.replace(rawReferences, (raw, display, reference) => {
|
|
172
|
+
const footnote = display.startsWith('^')
|
|
173
|
+
? display.slice(1)
|
|
174
|
+
: undefined;
|
|
175
|
+
const replacement = replacements.get(`${footnote ? 'footnote' : 'link'}:${normaliseLabel(footnote ?? (reference || display))}`);
|
|
176
|
+
return replacement
|
|
177
|
+
? footnote
|
|
178
|
+
? `[^${replacement}]`
|
|
179
|
+
: `[${display}][${replacement}]`
|
|
180
|
+
: raw;
|
|
181
|
+
});
|
|
182
|
+
return node.type.schema.text(text, marks);
|
|
183
|
+
}
|
|
184
|
+
const children = [];
|
|
185
|
+
node.forEach((child) => children.push(rewrite(child)));
|
|
186
|
+
return node.type.create(attrs, children, marks);
|
|
187
|
+
};
|
|
188
|
+
return rewrite(incoming);
|
|
189
|
+
}
|
|
190
|
+
export function markdownFromHTML(html) {
|
|
191
|
+
const template = document.createElement('template');
|
|
192
|
+
template.innerHTML = html;
|
|
193
|
+
const element = template.content.querySelector('[data-inkkit-markdown]');
|
|
194
|
+
return element?.getAttribute('data-inkkit-markdown') ?? undefined;
|
|
195
|
+
}
|
|
196
|
+
export function withReferenceMetadata(html, markdown) {
|
|
197
|
+
const container = document.createElement('div');
|
|
198
|
+
container.setAttribute('data-inkkit-markdown', markdown);
|
|
199
|
+
container.innerHTML = html;
|
|
200
|
+
return container.outerHTML;
|
|
201
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { Node as ProseNode } from '@milkdown/kit/prose/model';
|
|
2
|
+
export declare function normaliseLabel(label: string): string;
|
|
3
|
+
export interface LocatedDefinition {
|
|
4
|
+
node: ProseNode;
|
|
5
|
+
pos: number;
|
|
6
|
+
}
|
|
7
|
+
export declare const referenceDefinitions: (doc: ProseNode) => Map<string, LocatedDefinition>;
|
|
8
|
+
export declare const footnoteDefinitions: (doc: ProseNode) => Map<string, LocatedDefinition>;
|
|
9
|
+
export declare const remarkReferencesPlugin: import("@milkdown/kit/utils").$Remark<"inkkitReferences", unknown>;
|
|
10
|
+
export declare const referenceLinks: import("@milkdown/kit/utils").$MarkSchema<"link">;
|
|
11
|
+
export declare const referenceDefinition: import("@milkdown/kit/utils").$Node;
|
|
12
|
+
export declare const footnoteReference: import("@milkdown/kit/utils").$Node;
|
|
13
|
+
export declare const footnoteDefinition: import("@milkdown/kit/utils").$Node;
|
|
14
|
+
export declare const references: (import("@milkdown/kit/utils").$Ctx<import("@milkdown/kit/utils").GetMarkSchema, "link"> | import("@milkdown/kit/utils").$Mark | import("@milkdown/kit/utils").$Node | import("@milkdown/kit/utils").$Prose)[];
|