@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/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
- if ((node.type === 'paragraph' || node.type === 'heading') &&
9
- node.children.length > 1) {
10
- node.children = node.children.map((child) => child.type === 'html' &&
11
- ['<br />', '<br>', '<br >', '<br/>'].includes(child.value.trim())
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
- export declare function validateDiagram(source: string): void;
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(source))
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
- function statementIn(node, kinds, seen = new Set()) {
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 (kinds.includes(String(node.stmt)))
75
+ if (visit(node))
42
76
  return true;
43
- return Object.values(node).some((value) => statementIn(value, kinds, seen));
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 new Error('The diagram could not be checked. The source is retained.');
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 new Error('The diagram could not be checked. The source is retained.');
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 { default: mermaid } = await import('mermaid');
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
- refuseDirectives(await mermaid.mermaidAPI.getDiagramFromText(source));
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.
@@ -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.
@@ -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
- handlePaste: (view, event) => {
38
- const data = event.clipboardData;
39
- if (!data || !view.editable)
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
- if (view.state.selection.$from.parent.type.spec.code)
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
- const metadata = data.getData('vscode-editor-data');
46
- if (metadata) {
47
- try {
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
- async run(input, files = []) {
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
- from: view.state.selection.from,
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 (view.state.selection.$from.parent.type.spec.code) {
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))
@@ -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 correspondence(before: string[], after: string[], resemblance?: boolean): Array<number | undefined>;
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 textBlockEdits;
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 independentTextBlocks;
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
  }