@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/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
- slice = ProseParser.fromSchema(schema).parseSlice(container, {
275
- preserveWhitespace: true,
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 (!selection.empty &&
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));|&Tab;)';
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' ? '&#x9;' : '&#x20;'));
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 tree = processor.runSync(processor.parse(source), {
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 = parse(text);
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
- output +=
155
- block && block.form === text
156
- ? this.source.slice(block.start, block.end)
157
- : text.replaceAll('\n', this.lineEnding);
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
- if (serialize(this.ctx, parse(output)) === canonical)
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)[];