@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 +0 -4
- package/README.md +27 -24
- package/dist/comments.js +7 -0
- package/dist/dialect.js +10 -19
- package/dist/editor.d.ts +3 -0
- package/dist/editor.js +52 -27
- package/dist/highlight.js +109 -22
- package/dist/image-blocks.d.ts +71 -0
- package/dist/image-blocks.js +286 -0
- package/dist/images.d.ts +3 -1
- package/dist/images.js +52 -23
- package/dist/mermaid.js +108 -6
- package/dist/paste-html.d.ts +23 -0
- package/dist/paste-html.js +441 -0
- package/dist/paste.js +39 -52
- package/dist/preserve.d.ts +1 -0
- package/dist/preserve.js +87 -26
- package/dist/search.d.ts +3 -3
- package/dist/search.js +2 -2
- package/dist/style.css +98 -4
- package/dist/text-ranges.js +10 -1
- package/package.json +1 -1
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
|
|
37
|
+
|
|
38
|
+
const { text } = editor.snapshot(1)
|
|
40
39
|
```
|
|
41
40
|
|
|
42
|
-
|
|
41
|
+
## Built with InkKit
|
|
43
42
|
|
|
44
|
-
|
|
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
|
-
|
|
46
|
+
## Learn more
|
|
47
47
|
|
|
48
|
-
|
|
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
|
-
|
|
53
|
+
## License
|
|
51
54
|
|
|
52
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
1635
|
-
|
|
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 =
|
|
1810
|
-
|
|
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
|
-
|
|
1879
|
-
|
|
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
|
-
|
|
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(
|
|
2108
|
-
|
|
2109
|
-
|
|
2110
|
-
|
|
2111
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
34
|
+
if (!node.isTextblock)
|
|
12
35
|
return true;
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
?
|
|
42
|
-
: previous
|
|
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 {};
|