@aicayzer/inkkit 0.0.14 → 0.0.16

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/NOTICE CHANGED
@@ -1,7 +1,3 @@
1
- InkKit includes editor behavior and source from PadPad and Memos.
2
- Copyright (c) 2026 August Cayzer. Licensed under the MIT License.
3
- Sources: https://github.com/aicayzer/padpad and https://github.com/aicayzer/memos
4
-
5
1
  The scoped table styles in src/style.css are adapted from prosemirror-tables.
6
2
  Source: https://github.com/ProseMirror/prosemirror-tables
7
3
 
package/README.md CHANGED
@@ -1,33 +1,31 @@
1
1
  # InkKit
2
2
 
3
- InkKit is a reusable Markdown editor for simple writing tools and richer document apps. It provides formatted editing, source preservation, portable clipboard content and optional host-managed images, while your app controls its appearance, files and interface.
3
+ A Markdown editor for the web and for apps that host a web view. It edits formatted text and keeps your Markdown as you wrote it: a document you open and leave is saved unchanged, and an edit rewrites only the blocks it touches.
4
4
 
5
- Write in formatted or source mode with shared undo/redo. Edit tables, task lists, footnotes, reference links, highlights and callouts. Preview bounded Mermaid diagrams offline. Copy readable text and semantic HTML, explicitly copy Markdown source, or capture a complete printable document. Native hosts can use scoped readable-text ranges and demand-driven geometry with their own search controls and viewport insets. Unsupported syntax remains editable literal Markdown.
5
+ - Formatted and source editing, with one undo history
6
+ - Tables, task lists, footnotes, callouts, highlights and reference links
7
+ - Mermaid diagram previews, offline
8
+ - Copy as readable text or HTML, or copy the Markdown itself
9
+ - Images and files through your own storage adapter
10
+ - Your app owns the toolbar, menus, files and appearance
6
11
 
7
- Host controls, read-only and text-input policies, command availability, scoped appearance, labels and shortcuts are available from 0.0.8. Check the [releases](https://github.com/aicayzer/inkkit/releases) for published versions and verification evidence.
12
+ ## Install
8
13
 
9
14
  ```sh
10
15
  npm install @aicayzer/inkkit
11
16
  ```
12
17
 
18
+ ## Use
19
+
13
20
  ```ts
14
21
  import { InkKitEditor } from '@aicayzer/inkkit'
15
22
  import '@aicayzer/inkkit/style.css'
16
23
 
17
- const root = document.getElementById('editor')!
18
- const editor = await InkKitEditor.mount(root, {
19
- changed(text, generation) {
20
- // Notify the host; capture a fresh snapshot when saving.
21
- },
22
- stateChanged(state) {
23
- // Update the host's formatting controls.
24
- },
25
- openLink(href) {
26
- // Open through the host.
27
- },
28
- copy(text) {
29
- // Write code-block text to the clipboard.
30
- },
24
+ const editor = await InkKitEditor.mount(document.getElementById('editor')!, {
25
+ changed(markdown, generation) {},
26
+ stateChanged(state) {},
27
+ openLink(href) {},
28
+ copy(text) {},
31
29
  })
32
30
 
33
31
  editor.loadDocument({
@@ -36,17 +34,22 @@ editor.loadDocument({
36
34
  format: 'md',
37
35
  text: '# Hello\n\nStart writing.\n',
38
36
  })
39
- const snapshot = editor.snapshot(1)
37
+
38
+ const { text } = editor.snapshot(1)
40
39
  ```
41
40
 
42
- InkKit runs on the web and in offline web views. Hosts use its public TypeScript facade and own toolbars, menus, persistence, clipboard bridges, Print/PDF actions and image storage. Images require an adapter; private references stay opaque. TXT stays literal. RTF document editing is outside current support.
41
+ ## Built with InkKit
43
42
 
44
- Start with the [feature guide](docs/supported-syntax.md) and [simple and rich integration examples](docs/integration.md). The [documentation index](docs/README.md) links to precise API, preservation, playground, verification and publication guidance.
43
+ - [Memos](https://github.com/aicayzer/memos): a small Mac app for memos, in one floating window
44
+ - [PadPad](https://github.com/aicayzer/padpad): a small Mac app for quick text and files
45
45
 
46
- Scope and release status live in the [InkKit Project](https://github.com/users/aicayzer/projects/3) and [version milestones](https://github.com/aicayzer/inkkit/milestones). Only documented, released features form the supported package contract.
46
+ ## Learn more
47
47
 
48
- MIT licensed. InkKit builds on the public PadPad and Memos editors; see [NOTICE](NOTICE).
48
+ - [Supported syntax](docs/supported-syntax.md)
49
+ - [Integration guide](docs/integration.md)
50
+ - [API reference](docs/api.md)
51
+ - [All documentation](docs/README.md)
49
52
 
50
- Optional wiki links and a host-owned asynchronous file adapter support named/path images, audio, video, PDFs and file cards. Authored source and sizing are preserved; portable copy/print uses validated images or documented descriptive fallbacks. See [linked syntax](docs/supported-syntax.md#linked-syntax) and [adapter contracts](docs/api.md#optional-wiki-links-and-files).
53
+ ## License
51
54
 
52
- From 0.0.11, minimal hosts can mount with `rendering: { codeHighlighting: false, diagramPreview: false }` to skip optional presentation work. Source, shared history and explicit portable exports retain their contracts. Mermaid loads on first actual render. Single-file offline bundles still contain the rendering code; see [performance measurements](docs/performance.md).
55
+ [MIT](LICENSE). Third-party notices are in [NOTICE](NOTICE).
package/dist/comments.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { $node, $prose, $remark } from '@milkdown/kit/utils';
2
2
  import { Fragment } from '@milkdown/kit/prose/model';
3
3
  import { Plugin, PluginKey } from '@milkdown/kit/prose/state';
4
+ import { isImageBlock } from './image-blocks.js';
4
5
  import { fromMarkdown } from 'mdast-util-from-markdown';
5
6
  const lineEnding = (code) => code === -3 || code === -4 || code === -5;
6
7
  const whitespace = (code) => code === 32 || code === -1 || code === -2;
@@ -418,6 +419,12 @@ export function shareableFragment(content) {
418
419
  content.forEach((node) => {
419
420
  if (isComment(node))
420
421
  return;
422
+ // Shared output treats a standalone image as the paragraph its Markdown holds.
423
+ if (isImageBlock(node)) {
424
+ const { paragraph, image } = node.type.schema.nodes;
425
+ filtered.push(paragraph.create(null, image.create(node.attrs)));
426
+ return;
427
+ }
421
428
  if (node.type.name === 'reference_definition' &&
422
429
  labelHasComments(node.attrs.label))
423
430
  return;
package/dist/dialect.js CHANGED
@@ -8,6 +8,7 @@ import { linkedSyntax, wikiLinkSchema, namedFileMarkdown, fileReference, } from
8
8
  import { normalizeTableAlignment, normalizeTableColumnAlignment, } from './table-alignment.js';
9
9
  import { preservingCodeBlocks, remarkCodeFences, fencedCodeHandler, } from './code-fences.js';
10
10
  import { literalBlock, createLiteralPreservation } from './literals.js';
11
+ import { imageAttrs, imageBlocks, markdownImageAttrs, readParagraph, } from './image-blocks.js';
11
12
  import { references, remarkReferencesPlugin } from './references.js';
12
13
  import { callouts, remarkCalloutsPlugin } from './callouts.js';
13
14
  import { inlineHighlight } from './inline-highlight.js';
@@ -110,6 +111,12 @@ const preserveSpacerParagraphs = paragraphSchema.extendSchema((base) => (ctx) =>
110
111
  const schema = base(ctx);
111
112
  return {
112
113
  ...schema,
114
+ parseMarkdown: {
115
+ ...schema.parseMarkdown,
116
+ runner(state, node, type) {
117
+ readParagraph(state, node, type);
118
+ },
119
+ },
113
120
  toMarkdown: {
114
121
  ...schema.toMarkdown,
115
122
  runner(state, node) {
@@ -174,19 +181,11 @@ const visibleSoftbreaks = hardbreakSchema.extendSchema((base) => (ctx) => {
174
181
  ],
175
182
  };
176
183
  });
177
- // Remark represents a missing title as null; the image schema accepts strings.
178
184
  const normalizedImages = imageSchema.extendSchema((base) => (ctx) => {
179
185
  const schema = base(ctx);
180
186
  return {
181
187
  ...schema,
182
- attrs: {
183
- ...schema.attrs,
184
- inkkitFileRaw: { default: null },
185
- inkkitFileTarget: { default: null },
186
- inkkitFileFragment: { default: null },
187
- inkkitFileWidth: { default: null },
188
- inkkitFileInTable: { default: false },
189
- },
188
+ attrs: imageAttrs,
190
189
  parseDOM: [
191
190
  {
192
191
  tag: 'img[src]',
@@ -202,16 +201,7 @@ const normalizedImages = imageSchema.extendSchema((base) => (ctx) => {
202
201
  parseMarkdown: {
203
202
  ...schema.parseMarkdown,
204
203
  runner(state, node, type) {
205
- state.addNode(type, {
206
- src: String(node.url ?? ''),
207
- alt: String(node.alt ?? ''),
208
- title: String(node.title ?? ''),
209
- inkkitFileRaw: node.inkkitFileRaw ?? null,
210
- inkkitFileTarget: node.inkkitFileTarget ?? null,
211
- inkkitFileFragment: node.inkkitFileFragment ?? null,
212
- inkkitFileWidth: node.inkkitFileWidth ?? null,
213
- inkkitFileInTable: node.inkkitFileInTable ?? false,
214
- });
204
+ state.addNode(type, markdownImageAttrs(node));
215
205
  },
216
206
  },
217
207
  toMarkdown: {
@@ -324,6 +314,7 @@ export function createDialect(images, wikiLinks = false, files = false) {
324
314
  preserveSpacerParagraphs,
325
315
  visibleSoftbreaks,
326
316
  normalizedImages,
317
+ imageBlocks,
327
318
  autolinkInputRule,
328
319
  extendListItemSchemaForTask,
329
320
  strikethroughAttr,
package/dist/editor.d.ts CHANGED
@@ -179,6 +179,9 @@ export declare class InkKitEditor {
179
179
  focus?: boolean;
180
180
  reveal?: boolean;
181
181
  }): void;
182
+ /** Throws when a document reached by an edit, after every appended normalisation, cannot be
183
+ * saved as preserved Markdown. Dispatch and the replacement commands share this check. */
184
+ private assertPreserved;
182
185
  replaceTextRange(range: TextRange, text: string): boolean;
183
186
  textRangeRects(range: TextRange): readonly TextRect[];
184
187
  textRangesRects(ranges: readonly TextRange[]): readonly (readonly TextRect[])[];
package/dist/editor.js CHANGED
@@ -21,6 +21,8 @@ import { sourceAttribute, cleanSourceDoc, editedPlainSource, sourceSelection, ra
21
21
  import { normaliseLabel, referenceDefinitions, footnoteDefinitions, } from './references.js';
22
22
  import { selectionMarkdown, selectionContent, referenceMetadata, withReferenceMetadata, } from './reference-clipboard.js';
23
23
  import { imageView } from './images.js';
24
+ import { isImage, isImageBlock } from './image-blocks.js';
25
+ import { GapCursor } from '@milkdown/kit/prose/gapcursor';
24
26
  import { wikiLinkView } from './wiki-view.js';
25
27
  import { withFileSignal } from './files.js';
26
28
  import { tablePlugins, tableCommand, tableCommands, tableAvailability, tableContext, configureTableMovement, } from './tables.js';
@@ -991,7 +993,7 @@ export class InkKitEditor {
991
993
  const parsed = this.editor.ctx.get(parserCtx)(selected.shareable);
992
994
  let portable = false;
993
995
  parsed.descendants((node) => {
994
- if (node.type.name === 'image' || isMermaid(node))
996
+ if (isImage(node) || isMermaid(node))
995
997
  portable = true;
996
998
  });
997
999
  const cut = event.type === 'cut' && this.writable;
@@ -1091,13 +1093,9 @@ export class InkKitEditor {
1091
1093
  ...options,
1092
1094
  dispatchTransaction: function (tr) {
1093
1095
  const next = this.state.apply(tr);
1094
- if (!instance.loading &&
1095
- instance.preservation &&
1096
- !instance.literalSurface &&
1097
- !next.doc.eq(this.state.doc)) {
1096
+ if (!instance.loading) {
1098
1097
  try {
1099
- // Validate the final state, including dialect normalisation transactions.
1100
- instance.currentText(next.doc);
1098
+ instance.assertPreserved(this.state.doc, next.doc);
1101
1099
  }
1102
1100
  catch (error) {
1103
1101
  this.updateState(this.state);
@@ -1319,7 +1317,7 @@ export class InkKitEditor {
1319
1317
  const expanded = selectionContent(view.state.doc, fragment);
1320
1318
  let hasImages = false;
1321
1319
  expanded.descendants((node) => {
1322
- if (node.type.name === 'image' || isMermaid(node))
1320
+ if (isImage(node) || isMermaid(node))
1323
1321
  hasImages = true;
1324
1322
  });
1325
1323
  if (!hasImages) {
@@ -1606,6 +1604,12 @@ export class InkKitEditor {
1606
1604
  this.revealTextRange(range);
1607
1605
  this.publishCommandState();
1608
1606
  }
1607
+ /** Throws when a document reached by an edit, after every appended normalisation, cannot be
1608
+ * saved as preserved Markdown. Dispatch and the replacement commands share this check. */
1609
+ assertPreserved(before, after) {
1610
+ if (this.preservation && !this.literalSurface && !after.eq(before))
1611
+ this.currentText(after);
1612
+ }
1609
1613
  replaceTextRange(range, text) {
1610
1614
  const projection = this.validateTextRange(range);
1611
1615
  this.assertMutation();
@@ -1631,9 +1635,8 @@ export class InkKitEditor {
1631
1635
  throw new InkKitError('invalid-range', 'Replacement cannot remove embedded content or structural separators');
1632
1636
  const from = projectionPosition(projection, range.from, 1);
1633
1637
  const to = projectionPosition(projection, range.to, range.from === range.to ? 1 : -1);
1634
- return replaceFormattedRange(this.editor.ctx.get(editorViewCtx), from, to, text, (doc) => {
1635
- this.currentText(doc);
1636
- });
1638
+ const view = this.editor.ctx.get(editorViewCtx);
1639
+ return replaceFormattedRange(view, from, to, text, (tr) => this.assertPreserved(view.state.doc, view.state.apply(tr).doc));
1637
1640
  }
1638
1641
  textRangeRects(range) {
1639
1642
  return this.textRangesRects([range])[0];
@@ -1804,10 +1807,12 @@ export class InkKitEditor {
1804
1807
  const view = this.editor.ctx.get(editorViewCtx);
1805
1808
  // A caret at the end, where writing carries on; at the start it would sit in a first-line heading and
1806
1809
  // show its marks. The last place text can go, so a document ending in a rule takes a caret above it
1807
- // rather than selecting the rule itself.
1810
+ // rather than selecting the rule itself. After a final image, typing opens a paragraph below it.
1808
1811
  const { doc } = view.state;
1809
- const end = Selection.findFrom(doc.resolve(doc.content.size), -1, true) ??
1810
- Selection.atEnd(doc);
1812
+ const $end = doc.resolve(doc.content.size);
1813
+ const end = isImageBlock(doc.lastChild)
1814
+ ? new GapCursor($end)
1815
+ : (Selection.findFrom($end, -1, true) ?? Selection.atEnd(doc));
1811
1816
  view.dispatch(setSearchState(view.state.tr.setSelection(end), new SearchQuery({ search: '' })));
1812
1817
  }
1813
1818
  find(text, expectedGeneration) {
@@ -1875,9 +1880,8 @@ export class InkKitEditor {
1875
1880
  this.plainSearch = search;
1876
1881
  return result.count;
1877
1882
  }
1878
- return replaceFormatted(this.editor.ctx.get(editorViewCtx), search, replacement, (doc) => {
1879
- this.currentText(doc);
1880
- }, all);
1883
+ const view = this.editor.ctx.get(editorViewCtx);
1884
+ return replaceFormatted(view, search, replacement, (tr) => this.assertPreserved(view.state.doc, view.state.apply(tr).doc), all);
1881
1885
  }
1882
1886
  outlineContext() {
1883
1887
  return {
@@ -2098,22 +2102,34 @@ export class InkKitEditor {
2098
2102
  throw new InkKitError('image-unavailable', 'This editor has no image adapter');
2099
2103
  const view = this.editor.ctx.get(editorViewCtx);
2100
2104
  const { schema } = view.state;
2101
- const nodes = images.map((image) => schema.nodes.image.create({
2105
+ // One paragraph per image: each is read back as a standalone image block where one fits.
2106
+ const blocks = images.map((image) => schema.nodes.paragraph.create(null, schema.nodes.image.create({
2102
2107
  src: image.path,
2103
2108
  alt: image.alt,
2104
2109
  title: image.title ?? '',
2105
- }));
2106
- if (x != null && y != null)
2107
- this.insertBlocks(nodes.map((node) => schema.nodes.paragraph.create(null, node)), x, y);
2108
- else
2109
- view.dispatch(view.state.tr
2110
- .replaceSelectionWith(schema.nodes.paragraph.create(null, nodes))
2111
- .scrollIntoView());
2110
+ })));
2111
+ if (x != null && y != null) {
2112
+ this.insertBlocks(blocks, x, y);
2113
+ return;
2114
+ }
2115
+ const tr = view.state.tr.replaceSelection(new Slice(Fragment.fromArray(blocks), 0, 0));
2116
+ // After the last image, so typing continues below it rather than in the block that follows.
2117
+ const last = blocks.at(-1).firstChild;
2118
+ let after;
2119
+ tr.doc.descendants((node, pos) => {
2120
+ if (node === last)
2121
+ after = pos + node.nodeSize;
2122
+ return after == null;
2123
+ });
2124
+ if (after != null)
2125
+ tr.setSelection(TextSelection.create(tr.doc, after));
2126
+ view.dispatch(tr.scrollIntoView());
2112
2127
  }
2113
2128
  insertBlocks(blocks, x, y) {
2114
2129
  const view = this.editor.ctx.get(editorViewCtx);
2115
2130
  const { state } = view;
2116
- const $pos = state.doc.resolve(view.posAtCoords({ left: x, top: y })?.pos ?? state.selection.from);
2131
+ const hit = view.posAtCoords({ left: x, top: y });
2132
+ const $pos = state.doc.resolve(hit?.pos ?? state.selection.from);
2117
2133
  const tr = state.tr;
2118
2134
  let at;
2119
2135
  if ($pos.depth === 1 &&
@@ -2123,7 +2139,16 @@ export class InkKitEditor {
2123
2139
  tr.replaceWith(at, $pos.after(1), blocks);
2124
2140
  }
2125
2141
  else {
2126
- at = $pos.depth > 0 ? $pos.after(1) : $pos.pos;
2142
+ // A point over a top-level leaf, such as an image, resolves beside it rather than inside.
2143
+ const leaf = $pos.depth === 0 && hit && hit.inside >= 0
2144
+ ? state.doc.resolve(hit.inside)
2145
+ : undefined;
2146
+ at =
2147
+ $pos.depth > 0
2148
+ ? $pos.after(1)
2149
+ : leaf?.depth === 0
2150
+ ? hit.inside + state.doc.nodeAt(hit.inside).nodeSize
2151
+ : $pos.pos;
2127
2152
  tr.insert(at, blocks);
2128
2153
  }
2129
2154
  const end = at + blocks.reduce((size, node) => size + node.nodeSize, 0) - 1;
package/dist/highlight.js CHANGED
@@ -5,41 +5,128 @@ import { $prose } from '@milkdown/kit/utils';
5
5
  const lowlight = createLowlight(common);
6
6
  // Colors are decorations over the plain text, so the markdown is untouched;
7
7
  // a block without a known language stays plain.
8
+ function blockDecorations(node, pos) {
9
+ const language = String(node.attrs.language ?? '').toLowerCase();
10
+ const found = [];
11
+ if (!language || !lowlight.registered(language))
12
+ return found;
13
+ let offset = pos + 1;
14
+ const walk = (children, classes) => {
15
+ for (const child of children) {
16
+ if (child.type === 'text') {
17
+ const end = offset + child.value.length;
18
+ if (classes.length > 0)
19
+ found.push(Decoration.inline(offset, end, { class: classes.join(' ') }));
20
+ offset = end;
21
+ }
22
+ else if (child.type === 'element') {
23
+ const own = child.properties?.className ?? [];
24
+ walk(child.children, [...classes, ...own]);
25
+ }
26
+ }
27
+ };
28
+ walk(lowlight.highlight(language, node.textContent).children, []);
29
+ return found;
30
+ }
8
31
  function decorations(doc) {
9
32
  const found = [];
10
33
  doc.descendants((node, pos) => {
11
- if (node.type.name !== 'code_block')
34
+ if (!node.isTextblock)
12
35
  return true;
13
- const language = String(node.attrs.language ?? '').toLowerCase();
14
- if (!language || !lowlight.registered(language))
15
- return false;
16
- let offset = pos + 1;
17
- const walk = (children, classes) => {
18
- for (const child of children) {
19
- if (child.type === 'text') {
20
- const end = offset + child.value.length;
21
- if (classes.length > 0)
22
- found.push(Decoration.inline(offset, end, { class: classes.join(' ') }));
23
- offset = end;
24
- }
25
- else if (child.type === 'element') {
26
- const own = child.properties?.className ?? [];
27
- walk(child.children, [...classes, ...own]);
28
- }
29
- }
30
- };
31
- walk(lowlight.highlight(language, node.textContent).children, []);
36
+ if (node.type.name === 'code_block')
37
+ for (const decoration of blockDecorations(node, pos))
38
+ found.push(decoration);
32
39
  return false;
33
40
  });
34
41
  return DecorationSet.create(doc, found);
35
42
  }
43
+ // Every range of the new document an update may have touched: each step's own
44
+ // range, plus the whole span where the two documents differ, which also covers
45
+ // steps that change a node without changing its size.
46
+ function touched(tr) {
47
+ const ranges = [];
48
+ tr.mapping.maps.forEach((map, index) => {
49
+ const rest = tr.mapping.slice(index + 1);
50
+ map.forEach((_, __, from, to) => ranges.push([rest.map(from, -1), rest.map(to, 1)]));
51
+ });
52
+ const start = tr.before.content.findDiffStart(tr.doc.content);
53
+ if (start != null) {
54
+ const end = tr.before.content.findDiffEnd(tr.doc.content);
55
+ // The scans overlap when inserted or deleted text repeats its neighbours.
56
+ if (end)
57
+ ranges.push([start, end.b + Math.max(0, start - Math.min(end.a, end.b))]);
58
+ }
59
+ return ranges;
60
+ }
61
+ // Mapping carries colors of unchanged blocks to their new positions without
62
+ // reading them, but leaves stale colors inside any block that was edited. Those
63
+ // blocks are highlighted again; no other block is. Stale colors are dropped by
64
+ // rebuilding the set: DecorationSet.remove compares every removed decoration
65
+ // with every other in its block, which is quadratic in a large block.
66
+ function update(mapped, tr) {
67
+ const stale = [];
68
+ const fresh = [];
69
+ const seen = new Set();
70
+ for (const [from, to] of touched(tr)) {
71
+ let start = from;
72
+ let end = to;
73
+ const blocks = [];
74
+ tr.doc.nodesBetween(from, to, (node, pos) => {
75
+ if (!node.isTextblock)
76
+ return true;
77
+ start = Math.min(start, pos);
78
+ end = Math.max(end, pos + node.nodeSize);
79
+ if (node.type.name === 'code_block' && !seen.has(pos)) {
80
+ seen.add(pos);
81
+ blocks.push([node, pos]);
82
+ }
83
+ return false;
84
+ });
85
+ stale.push([start, end]);
86
+ for (const [node, pos] of blocks)
87
+ for (const decoration of blockDecorations(node, pos))
88
+ fresh.push(decoration);
89
+ }
90
+ const found = [];
91
+ for (const [start, end] of stale)
92
+ for (const decoration of mapped.find(start, end))
93
+ found.push(decoration);
94
+ if (found.length === 0)
95
+ return mapped.add(tr.doc, fresh);
96
+ // remove is quadratic in the decorations it removes, so only a large block takes the rebuild below.
97
+ if (found.length <= 256)
98
+ return mapped.remove(found).add(tr.doc, fresh);
99
+ stale.sort((a, b) => a[0] - b[0]);
100
+ const merged = [];
101
+ for (const range of stale) {
102
+ const last = merged.at(-1);
103
+ if (last && range[0] <= last[1])
104
+ last[1] = Math.max(last[1], range[1]);
105
+ else
106
+ merged.push([range[0], range[1]]);
107
+ }
108
+ const kept = mapped.find().filter((decoration) => {
109
+ let low = 0;
110
+ let high = merged.length;
111
+ while (low < high) {
112
+ const middle = (low + high) >> 1;
113
+ if (merged[middle][0] < decoration.to)
114
+ low = middle + 1;
115
+ else
116
+ high = middle;
117
+ }
118
+ const range = merged[low - 1];
119
+ return !range || range[1] <= decoration.from;
120
+ });
121
+ return DecorationSet.create(tr.doc, kept.concat(fresh));
122
+ }
36
123
  export const highlightPlugin = $prose(() => new Plugin({
37
124
  key: new PluginKey('highlight'),
38
125
  state: {
39
126
  init: (_, state) => decorations(state.doc),
40
127
  apply: (tr, previous) => tr.docChanged
41
- ? decorations(tr.doc)
42
- : previous.map(tr.mapping, tr.doc),
128
+ ? update(previous.map(tr.mapping, tr.doc), tr)
129
+ : previous,
43
130
  },
44
131
  props: {
45
132
  decorations(state) {
@@ -0,0 +1,71 @@
1
+ import type { MilkdownPlugin } from '@milkdown/kit/ctx';
2
+ import { type Node as ProseNode, type NodeType } from '@milkdown/kit/prose/model';
3
+ import type { ParserState } from '@milkdown/kit/transformer';
4
+ export declare const imageBlockName = "image_block";
5
+ /** Attributes shared by an inline image and a standalone image block. */
6
+ export declare const imageAttrs: {
7
+ src: {
8
+ default: string;
9
+ validate: string;
10
+ };
11
+ alt: {
12
+ default: string;
13
+ validate: string;
14
+ };
15
+ title: {
16
+ default: string;
17
+ validate: string;
18
+ };
19
+ inkkitFileRaw: {
20
+ default: null;
21
+ };
22
+ inkkitFileTarget: {
23
+ default: null;
24
+ };
25
+ inkkitFileFragment: {
26
+ default: null;
27
+ };
28
+ inkkitFileWidth: {
29
+ default: null;
30
+ };
31
+ inkkitFileInTable: {
32
+ default: boolean;
33
+ };
34
+ };
35
+ interface MarkdownParagraph {
36
+ children?: unknown[];
37
+ value?: unknown;
38
+ }
39
+ interface MarkdownImage {
40
+ type: string;
41
+ url?: unknown;
42
+ alt?: unknown;
43
+ title?: unknown;
44
+ inkkitFileRaw?: unknown;
45
+ inkkitFileTarget?: unknown;
46
+ inkkitFileFragment?: unknown;
47
+ inkkitFileWidth?: unknown;
48
+ inkkitFileInTable?: unknown;
49
+ }
50
+ export declare function markdownImageAttrs(node: MarkdownImage): {
51
+ src: string;
52
+ alt: string;
53
+ title: string;
54
+ inkkitFileRaw: {} | null;
55
+ inkkitFileTarget: {} | null;
56
+ inkkitFileFragment: {} | null;
57
+ inkkitFileWidth: {} | null;
58
+ inkkitFileInTable: {};
59
+ };
60
+ /** Whether a node displays an authored image, inline or as its own block. */
61
+ export declare function isImage(node: ProseNode): boolean;
62
+ export declare function isImageBlock(node: ProseNode | null | undefined): boolean;
63
+ /** The image a paragraph holds as its only content, without marks, which makes the paragraph a
64
+ * standalone image block. A linked or emphasised image stays inline so its marks are kept. */
65
+ export declare function standaloneImage(children: readonly ProseNode[]): ProseNode | undefined;
66
+ /** Reads a Markdown paragraph, as an image block when it holds a standalone image and the enclosing
67
+ * block accepts one. */
68
+ export declare function readParagraph(state: ParserState, node: MarkdownParagraph, paragraph: NodeType): void;
69
+ export declare const imageBlockSchema: import("@milkdown/kit/utils").$NodeSchema<"image_block">;
70
+ export declare const imageBlocks: MilkdownPlugin[];
71
+ export {};