@aicayzer/inkkit 0.0.0-stage → 0.0.1

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 ADDED
@@ -0,0 +1,362 @@
1
+ import { editorViewCtx, parserCtx } from '@milkdown/kit/core';
2
+ import { DOMParser as ProseParser, Fragment, Slice, } from '@milkdown/kit/prose/model';
3
+ import { Plugin, PluginKey } from '@milkdown/kit/prose/state';
4
+ import { $prose } from '@milkdown/kit/utils';
5
+ import { InkKitError, } from './types.js';
6
+ export class PasteController {
7
+ options;
8
+ active;
9
+ dead = false;
10
+ get pending() {
11
+ return this.active != null;
12
+ }
13
+ plugin;
14
+ constructor(options) {
15
+ this.options = options;
16
+ this.plugin = $prose(() => new Plugin({
17
+ key: new PluginKey('inkkitPaste'),
18
+ state: {
19
+ init: () => null,
20
+ apply: (transaction) => {
21
+ if (this.active) {
22
+ const empty = this.active.from === this.active.to;
23
+ this.active.from = transaction.mapping.map(this.active.from, 1);
24
+ this.active.to = transaction.mapping.map(this.active.to, empty ? 1 : -1);
25
+ }
26
+ return null;
27
+ },
28
+ },
29
+ props: {
30
+ handlePaste: (view, event) => {
31
+ const data = event.clipboardData;
32
+ if (!data || !view.editable)
33
+ return false;
34
+ if (view.state.selection.$from.parent.type.spec.code)
35
+ return false;
36
+ const text = data.getData('text/plain');
37
+ const html = data.getData('text/html');
38
+ const metadata = data.getData('vscode-editor-data');
39
+ if (metadata) {
40
+ try {
41
+ const value = JSON.parse(metadata);
42
+ if (!value ||
43
+ typeof value !== 'object' ||
44
+ !('mode' in value) ||
45
+ typeof value.mode !== 'string')
46
+ throw new Error('Invalid VS Code clipboard metadata');
47
+ // Retain the provider's code-language behaviour for valid metadata.
48
+ return false;
49
+ }
50
+ catch {
51
+ void this.paste({ text, plainText: true }).catch((error) => options.onError?.(error));
52
+ return true;
53
+ }
54
+ }
55
+ const files = [...data.files].filter((file) => file.type.startsWith('image/'));
56
+ void this.run({
57
+ text,
58
+ html,
59
+ plainText: this.options.literalText?.() ?? false,
60
+ }, files).catch((error) => options.onError?.(error));
61
+ return true;
62
+ },
63
+ },
64
+ }));
65
+ }
66
+ paste(input) {
67
+ return this.run(input);
68
+ }
69
+ cancelPending() {
70
+ this.active = undefined;
71
+ }
72
+ destroy() {
73
+ this.dead = true;
74
+ this.cancelPending();
75
+ }
76
+ assertContext(context, operation) {
77
+ if (this.dead)
78
+ throw new InkKitError('destroyed', 'The editor was destroyed.');
79
+ const current = this.options.context();
80
+ if (this.active !== operation ||
81
+ current.documentId !== context.documentId ||
82
+ current.generation !== context.generation)
83
+ throw new InkKitError('stale-document', 'The document changed while images were importing.');
84
+ }
85
+ async run(input, files = []) {
86
+ if (this.dead)
87
+ throw new InkKitError('destroyed', 'The editor was destroyed.');
88
+ if (this.pending)
89
+ throw new InkKitError('operation-pending', 'An image paste is still pending.');
90
+ const context = { ...this.options.context() };
91
+ const ctx = this.options.ctx();
92
+ const view = ctx.get(editorViewCtx);
93
+ if (!view.editable || this.options.editable?.() === false)
94
+ return;
95
+ if (view.composing)
96
+ throw new InkKitError('composition', 'Finish composing text before pasting.');
97
+ const operation = {
98
+ from: view.state.selection.from,
99
+ to: view.state.selection.to,
100
+ };
101
+ this.active = operation;
102
+ try {
103
+ const images = [...(input.images ?? [])];
104
+ for (const file of files)
105
+ images.push({
106
+ bytes: new Uint8Array(await file.arrayBuffer()),
107
+ mimeType: file.type,
108
+ filename: file.name,
109
+ });
110
+ this.assertContext(context, operation);
111
+ const schema = view.state.schema;
112
+ let slice;
113
+ const plainText = input.plainText ||
114
+ this.options.literalText?.() ||
115
+ view.state.selection.$from.parent.type.spec.code;
116
+ if (plainText) {
117
+ if (view.state.selection.$from.parent.type.spec.code) {
118
+ const anchor = operation;
119
+ view.dispatch(view.state.tr.insertText(input.text, anchor.from, anchor.to));
120
+ return;
121
+ }
122
+ const paragraphs = input.text
123
+ .replace(/\r\n?/g, '\n')
124
+ .split('\n')
125
+ .map((line) => schema.nodes.paragraph.create(null, line ? schema.text(line) : undefined));
126
+ slice = new Slice(Fragment.fromArray(paragraphs), 1, 1);
127
+ }
128
+ else if (input.html) {
129
+ // Template contents remain inert while unsupported source is replaced
130
+ // and managed image references are imported.
131
+ const template = document.createElement('template');
132
+ template.innerHTML = input.html;
133
+ const container = template.content;
134
+ // Native HTML clipboards add encoding metadata. Obsidian's reading
135
+ // view also copies its title and collapse controls with document HTML.
136
+ for (const metadata of container.querySelectorAll('meta[charset]'))
137
+ metadata.remove();
138
+ for (const control of container.querySelectorAll('div.mod-header.mod-ui,div.mod-footer.mod-ui')) {
139
+ if (control.querySelector('.inline-title[contenteditable],.embedded-backlinks'))
140
+ control.remove();
141
+ }
142
+ for (const heading of container.querySelectorAll('h1[data-heading],h2[data-heading],h3[data-heading],h4[data-heading],h5[data-heading],h6[data-heading]')) {
143
+ for (const control of heading.querySelectorAll(':scope > span.heading-collapse-indicator.collapse-indicator.collapse-icon')) {
144
+ if (control.querySelector(':scope > svg.svg-icon.right-triangle') &&
145
+ !control.textContent?.trim())
146
+ control.remove();
147
+ }
148
+ }
149
+ for (const control of container.querySelectorAll('pre > button.copy-code-button')) {
150
+ if (control.querySelector('svg.svg-icon'))
151
+ control.remove();
152
+ }
153
+ const supported = new Set([
154
+ 'P',
155
+ 'H1',
156
+ 'H2',
157
+ 'H3',
158
+ 'H4',
159
+ 'H5',
160
+ 'H6',
161
+ 'STRONG',
162
+ 'B',
163
+ 'EM',
164
+ 'I',
165
+ 'S',
166
+ 'DEL',
167
+ 'STRIKE',
168
+ 'CODE',
169
+ 'PRE',
170
+ 'BLOCKQUOTE',
171
+ 'A',
172
+ 'BR',
173
+ 'HR',
174
+ 'UL',
175
+ 'OL',
176
+ 'LI',
177
+ 'TABLE',
178
+ 'THEAD',
179
+ 'TBODY',
180
+ 'TFOOT',
181
+ 'TR',
182
+ 'TH',
183
+ 'TD',
184
+ 'IMG',
185
+ 'SPAN',
186
+ 'DIV',
187
+ 'FONT',
188
+ 'SECTION',
189
+ 'ARTICLE',
190
+ 'MAIN',
191
+ 'HEADER',
192
+ 'FOOTER',
193
+ ]);
194
+ for (const element of [...container.querySelectorAll('*')]) {
195
+ if (!container.contains(element) || supported.has(element.tagName))
196
+ continue;
197
+ const literal = document.createElement('pre');
198
+ literal.className = 'literal-markdown';
199
+ literal.textContent = element.outerHTML;
200
+ element.replaceWith(literal);
201
+ }
202
+ const walker = document.createTreeWalker(container, NodeFilter.SHOW_COMMENT);
203
+ const comments = [];
204
+ while (walker.nextNode())
205
+ comments.push(walker.currentNode);
206
+ for (const comment of comments) {
207
+ const literal = document.createElement('pre');
208
+ literal.className = 'literal-markdown';
209
+ literal.textContent = `<!--${comment.data}-->`;
210
+ comment.replaceWith(literal);
211
+ }
212
+ for (const table of [...container.querySelectorAll('table')]) {
213
+ if (table.parentElement?.closest('table'))
214
+ continue;
215
+ const rows = [...table.querySelectorAll('tr')];
216
+ const widths = rows.map((row) => row.querySelectorAll(':scope > th,:scope > td').length);
217
+ const unsupported = !schema.nodes.table ||
218
+ widths.length === 0 ||
219
+ widths.some((width) => !width || width !== widths[0]) ||
220
+ table.querySelector('table,[rowspan]:not([rowspan="1"]),[colspan]:not([colspan="1"]),td > p ~ p,th > p ~ p,td > div,th > div,td > ul,td > ol,td > pre,td > blockquote,th > ul,th > ol,th > pre,th > blockquote');
221
+ if (!unsupported && !table.querySelector('th')) {
222
+ for (const cell of rows[0]?.querySelectorAll(':scope > td') ?? []) {
223
+ const heading = document.createElement('th');
224
+ for (const attribute of [...cell.attributes])
225
+ heading.setAttribute(attribute.name, attribute.value);
226
+ heading.append(...cell.childNodes);
227
+ cell.replaceWith(heading);
228
+ }
229
+ }
230
+ if (unsupported) {
231
+ const literal = document.createElement('pre');
232
+ literal.className = 'literal-markdown';
233
+ literal.textContent = table.outerHTML;
234
+ table.replaceWith(literal);
235
+ }
236
+ }
237
+ const imported = new Map();
238
+ const elements = [...container.querySelectorAll('img')];
239
+ const uniqueSources = new Set(elements.map((element) => element.getAttribute('src') ?? ''));
240
+ // A host-supplied array explicitly associates unnamed bytes by unique
241
+ // preview order. A browser FileList has no such association, except the
242
+ // unambiguous single-file/single-source case.
243
+ const positional = (files.length === 0 || (files.length === 1 && uniqueSources.size === 1)
244
+ ? images
245
+ : (input.images ?? [])).filter((image) => !image.source);
246
+ let index = 0;
247
+ for (const element of elements) {
248
+ const source = element.getAttribute('src') ?? '';
249
+ const alt = element.getAttribute('alt') ?? 'Image';
250
+ try {
251
+ let reference = imported.get(source);
252
+ if (!reference) {
253
+ const matched = images.find((image) => image.source ===
254
+ source);
255
+ const captured = matched ?? dataImage(source) ?? positional[index++];
256
+ if (!captured || !this.options.adapter)
257
+ throw new Error('The pasted image is unavailable.');
258
+ reference = (await this.options.adapter.importImage(captured, { ...context })).reference;
259
+ this.assertContext(context, operation);
260
+ imported.set(source, reference);
261
+ }
262
+ element.setAttribute('src', reference);
263
+ element.removeAttribute('srcset');
264
+ }
265
+ catch (error) {
266
+ if (error instanceof InkKitError)
267
+ throw error;
268
+ element.replaceWith(document.createTextNode(`[${alt}: image unavailable]`));
269
+ this.options.onError?.(new InkKitError('image-unavailable', error instanceof Error
270
+ ? error.message
271
+ : 'The pasted image is unavailable.'));
272
+ }
273
+ }
274
+ slice = ProseParser.fromSchema(schema).parseSlice(container, {
275
+ preserveWhitespace: true,
276
+ });
277
+ }
278
+ else if (images.length) {
279
+ const nodes = [];
280
+ if (input.text)
281
+ nodes.push(schema.nodes.paragraph.create(null, schema.text(input.text)));
282
+ for (const image of images) {
283
+ try {
284
+ if (!this.options.adapter)
285
+ throw new InkKitError('image-unavailable', 'No image adapter is configured.');
286
+ const imported = await this.options.adapter.importImage(image, {
287
+ ...context,
288
+ });
289
+ this.assertContext(context, operation);
290
+ const imageNode = schema.nodes.image?.create({
291
+ src: imported.reference,
292
+ alt: image.filename ?? 'Image',
293
+ });
294
+ if (!imageNode)
295
+ throw new InkKitError('image-unavailable', 'Images are disabled for this document.');
296
+ nodes.push(schema.nodes.paragraph.create(null, imageNode));
297
+ }
298
+ catch (error) {
299
+ if (error instanceof InkKitError &&
300
+ error.code !== 'image-unavailable')
301
+ throw error;
302
+ nodes.push(schema.nodes.paragraph.create(null, schema.text(`[${image.filename ?? 'Image'}: image unavailable]`)));
303
+ this.options.onError?.(new InkKitError('image-unavailable', error instanceof Error
304
+ ? error.message
305
+ : 'The pasted image is unavailable.'));
306
+ }
307
+ }
308
+ slice = new Slice(Fragment.fromArray(nodes), 1, 1);
309
+ }
310
+ else {
311
+ const selection = view.state.selection;
312
+ const text = input.text.trim();
313
+ if (!selection.empty &&
314
+ /^https?:\/\/\S+$/.test(text) &&
315
+ schema.marks.link) {
316
+ const anchor = operation;
317
+ const mark = schema.marks.link.create({ href: text, title: '' });
318
+ view.dispatch(view.state.tr
319
+ .addMark(anchor.from, anchor.to, mark)
320
+ .removeStoredMark(mark));
321
+ return;
322
+ }
323
+ const doc = ctx.get(parserCtx)(input.text);
324
+ slice = new Slice(doc.content, 0, 0);
325
+ }
326
+ this.assertContext(context, operation);
327
+ if (!view.editable || this.options.editable?.() === false)
328
+ return;
329
+ const anchor = operation;
330
+ const insertion = view.state.doc.resolve(anchor.from);
331
+ // An isolated block such as a table cannot fit inside an empty paragraph.
332
+ // Replace that empty block rather than retaining a spurious spacer.
333
+ if (anchor.from === anchor.to &&
334
+ insertion.parent.type.name === 'paragraph' &&
335
+ insertion.parent.content.size === 0 &&
336
+ slice.content.firstChild?.isBlock &&
337
+ slice.content.firstChild.type.spec.isolating) {
338
+ view.dispatch(view.state.tr
339
+ .replaceRange(insertion.before(), insertion.after(), new Slice(slice.content, 0, 0))
340
+ .scrollIntoView());
341
+ }
342
+ else
343
+ view.dispatch(view.state.tr
344
+ .replaceRange(anchor.from, anchor.to, slice)
345
+ .scrollIntoView());
346
+ }
347
+ finally {
348
+ if (this.active === operation)
349
+ this.active = undefined;
350
+ }
351
+ }
352
+ }
353
+ function dataImage(source) {
354
+ const match = /^data:(image\/(?:png|jpeg|gif|webp|avif|bmp|tiff));base64,([A-Za-z0-9+/=\s]+)$/.exec(source);
355
+ if (!match)
356
+ return undefined;
357
+ const binary = atob(match[2]);
358
+ return {
359
+ mimeType: match[1],
360
+ bytes: Uint8Array.from(binary, (character) => character.charCodeAt(0)),
361
+ };
362
+ }
@@ -0,0 +1,16 @@
1
+ import type { Ctx } from '@milkdown/kit/ctx';
2
+ import type { Node as ProseNode } from '@milkdown/kit/prose/model';
3
+ export declare class PreservationError extends Error {
4
+ constructor(message: string, options?: ErrorOptions);
5
+ }
6
+ /** Retains the loaded source of blocks the edit leaves alone. */
7
+ export declare class Preservation {
8
+ private readonly ctx;
9
+ private readonly source;
10
+ private readonly baseline;
11
+ private readonly blocks;
12
+ private readonly lineEnding;
13
+ private readonly forms;
14
+ constructor(ctx: Ctx, source: string);
15
+ serialize(doc: ProseNode): string;
16
+ }
@@ -0,0 +1,177 @@
1
+ import { parserCtx, remarkCtx } from '@milkdown/kit/core';
2
+ import { serialize } from './dialect.js';
3
+ export class PreservationError extends Error {
4
+ constructor(message, options) {
5
+ super(message, options);
6
+ this.name = 'PreservationError';
7
+ }
8
+ }
9
+ function form(text) {
10
+ return text.replace(/\n+$/, '');
11
+ }
12
+ function blockForm(ctx, node) {
13
+ // An empty document is empty Markdown, but an empty block inside a document
14
+ // is authored spacing and must remain a real block when read again.
15
+ if (node.type.name === 'paragraph' && node.content.size === 0)
16
+ return '<br />';
17
+ return form(serialize(ctx, node.type.schema.topNodeType.create(null, [node])));
18
+ }
19
+ // Edit distance distinguishes replacements from insertions, so replacing a block
20
+ // retains its surrounding whitespace without assigning it a neighbour's source.
21
+ function correspondence(before, after) {
22
+ const result = new Array(after.length);
23
+ let head = 0;
24
+ while (head < before.length &&
25
+ head < after.length &&
26
+ before[head] === after[head]) {
27
+ result[head] = head;
28
+ head++;
29
+ }
30
+ let endBefore = before.length;
31
+ let endAfter = after.length;
32
+ while (endBefore > head &&
33
+ endAfter > head &&
34
+ before[endBefore - 1] === after[endAfter - 1]) {
35
+ result[--endAfter] = --endBefore;
36
+ }
37
+ const rows = endBefore - head + 1;
38
+ const columns = endAfter - head + 1;
39
+ if (rows * columns > 1_000_000)
40
+ throw new PreservationError('The edit changes too many blocks to preserve safely');
41
+ const costs = Array.from({ length: rows }, () => new Uint32Array(columns));
42
+ for (let i = 0; i < rows; i++)
43
+ costs[i][0] = i;
44
+ for (let j = 0; j < columns; j++)
45
+ costs[0][j] = j;
46
+ for (let i = 1; i < rows; i++) {
47
+ for (let j = 1; j < columns; j++) {
48
+ const change = before[head + i - 1] === after[head + j - 1] ? 0 : 1;
49
+ costs[i][j] = Math.min(costs[i - 1][j] + 1, costs[i][j - 1] + 1, costs[i - 1][j - 1] + change);
50
+ }
51
+ }
52
+ let i = rows - 1;
53
+ let j = columns - 1;
54
+ while (i || j) {
55
+ const change = i && j && before[head + i - 1] === after[head + j - 1] ? 0 : 1;
56
+ if (i && j && costs[i][j] === costs[i - 1][j - 1] + change) {
57
+ result[head + j - 1] = head + i - 1;
58
+ i--;
59
+ j--;
60
+ }
61
+ else if (i && costs[i][j] === costs[i - 1][j] + 1)
62
+ i--;
63
+ else
64
+ j--;
65
+ }
66
+ return result;
67
+ }
68
+ /** Retains the loaded source of blocks the edit leaves alone. */
69
+ export class Preservation {
70
+ ctx;
71
+ source;
72
+ baseline;
73
+ blocks = [];
74
+ lineEnding;
75
+ forms = new WeakMap();
76
+ constructor(ctx, source) {
77
+ this.ctx = ctx;
78
+ this.source = source;
79
+ try {
80
+ const parse = ctx.get(parserCtx);
81
+ this.baseline = parse(source);
82
+ this.lineEnding =
83
+ source.includes('\r\n') && !source.replaceAll('\r\n', '').includes('\n')
84
+ ? '\r\n'
85
+ : '\n';
86
+ const processor = ctx.get(remarkCtx);
87
+ const offset = source.startsWith('\uFEFF') ? 1 : 0;
88
+ const body = source.slice(offset);
89
+ const tree = processor.runSync(processor.parse(source), {
90
+ value: body,
91
+ });
92
+ for (const node of tree.children) {
93
+ let start = node.position?.start.offset;
94
+ const end = node.position?.end.offset;
95
+ if (start == null || end == null)
96
+ throw new PreservationError('A source block has no location');
97
+ start += offset;
98
+ const sourceEnd = end + offset;
99
+ const firstColumn = source.lastIndexOf('\n', start - 1) + 1;
100
+ if (/^[ \t]*$/.test(source.slice(firstColumn, start)))
101
+ start = firstColumn;
102
+ const text = source.slice(start, sourceEnd);
103
+ const parsed = parse(text);
104
+ this.blocks.push({
105
+ start,
106
+ end: sourceEnd,
107
+ form: parsed.childCount === 1
108
+ ? blockForm(ctx, parsed.firstChild)
109
+ : form(serialize(ctx, parsed)),
110
+ });
111
+ }
112
+ }
113
+ catch (cause) {
114
+ if (cause instanceof PreservationError)
115
+ throw cause;
116
+ throw new PreservationError('Cannot read Markdown for preservation', {
117
+ cause,
118
+ });
119
+ }
120
+ }
121
+ serialize(doc) {
122
+ if (doc.eq(this.baseline))
123
+ return this.source;
124
+ try {
125
+ const canonical = serialize(this.ctx, doc);
126
+ if (canonical === '')
127
+ return this.source.startsWith('\uFEFF') ? '\uFEFF' : '';
128
+ const current = [];
129
+ doc.forEach((node) => {
130
+ let fingerprint = this.forms.get(node);
131
+ if (fingerprint == null) {
132
+ fingerprint = blockForm(this.ctx, node);
133
+ this.forms.set(node, fingerprint);
134
+ }
135
+ current.push(fingerprint);
136
+ });
137
+ const positions = correspondence(this.blocks.map((block) => block.form), current);
138
+ let output = '';
139
+ let previous;
140
+ current.forEach((text, index) => {
141
+ const position = positions[index];
142
+ const block = position == null ? undefined : this.blocks[position];
143
+ if (index === 0) {
144
+ if (position === 0 && block)
145
+ output += this.source.slice(0, block.start);
146
+ else if (this.source.startsWith('\uFEFF'))
147
+ output += '\uFEFF';
148
+ }
149
+ else if (previous != null && position === previous + 1 && block) {
150
+ output += this.source.slice(this.blocks[previous].end, block.start);
151
+ }
152
+ else
153
+ 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);
158
+ previous = position;
159
+ });
160
+ if (previous === this.blocks.length - 1 && this.blocks.length)
161
+ output += this.source.slice(this.blocks[previous].end);
162
+ else if (output)
163
+ output += this.lineEnding;
164
+ const parse = this.ctx.get(parserCtx);
165
+ if (serialize(this.ctx, parse(output)) === canonical)
166
+ return output;
167
+ throw new PreservationError('The edited Markdown cannot be reopened without changing its content');
168
+ }
169
+ catch (cause) {
170
+ if (cause instanceof PreservationError)
171
+ throw cause;
172
+ throw new PreservationError('Cannot preserve the edited Markdown', {
173
+ cause,
174
+ });
175
+ }
176
+ }
177
+ }
@@ -0,0 +1 @@
1
+ export declare const selectionPlugin: import("@milkdown/kit/utils").$Prose;
@@ -0,0 +1,47 @@
1
+ import { Plugin, PluginKey } from '@milkdown/kit/prose/state';
2
+ import { $prose } from '@milkdown/kit/utils';
3
+ /** The stylesheet paints the selection under this name. */
4
+ const name = 'selection';
5
+ /** The selected text within each block. */
6
+ function covered(state) {
7
+ const { from, to, empty, visible } = state.selection;
8
+ if (empty || !visible)
9
+ return null;
10
+ const text = [];
11
+ state.doc.nodesBetween(from, to, (node, pos) => {
12
+ if (!node.isTextblock)
13
+ return true;
14
+ const start = Math.max(from, pos + 1);
15
+ const end = Math.min(to, pos + node.nodeSize - 1);
16
+ if (end > start)
17
+ text.push({ from: start, to: end });
18
+ return false;
19
+ });
20
+ return text;
21
+ }
22
+ function domRange(view, span) {
23
+ const start = view.domAtPos(span.from);
24
+ const end = view.domAtPos(span.to);
25
+ const range = document.createRange();
26
+ range.setStart(start.node, start.offset);
27
+ range.setEnd(end.node, end.offset);
28
+ return range;
29
+ }
30
+ function paint(view, highlight) {
31
+ highlight.clear();
32
+ for (const span of covered(view.state) ?? [])
33
+ highlight.add(domRange(view, span));
34
+ }
35
+ // Paint only selected text, without WebKit filling the surrounding margins.
36
+ export const selectionPlugin = $prose(() => new Plugin({
37
+ key: new PluginKey('selection'),
38
+ view(view) {
39
+ const highlight = new Highlight();
40
+ CSS.highlights.set(name, highlight);
41
+ paint(view, highlight);
42
+ return {
43
+ update: (view) => paint(view, highlight),
44
+ destroy: () => CSS.highlights.delete(name),
45
+ };
46
+ },
47
+ }));