@aicayzer/inkkit 0.0.13 → 0.0.15

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/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,8 +179,12 @@ 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[];
187
+ textRangesRects(ranges: readonly TextRange[]): readonly (readonly TextRect[])[];
184
188
  visibleTextRanges(snapshotId: string): readonly TextRange[];
185
189
  revealTextRange(range: TextRange): void;
186
190
  private revealRects;
package/dist/editor.js CHANGED
@@ -21,11 +21,13 @@ 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';
27
29
  import { InkKitError, } from './types.js';
28
- import { readableProjection, projectionPosition, projectionOffset, validateOffsets, formattedRects, literalRects, literalVisibleRanges, intersectsViewport, textRect, } from './text-ranges.js';
30
+ import { readableProjection, projectionPosition, projectionOffset, validateOffsets, formattedRects, literalRangesRects, literalVisibleRanges, intersectsViewport, textRect, } from './text-ranges.js';
29
31
  import { highlightKeymap, toggleHighlightCommand } from './inline-highlight.js';
30
32
  import { commentSelectionContent, setCommentVisibility } from './comments.js';
31
33
  import { PasteController } from './paste.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;
@@ -1089,6 +1091,20 @@ export class InkKitEditor {
1089
1091
  unbind(orderedListKeymap);
1090
1092
  ctx.update(editorViewOptionsCtx, (options) => ({
1091
1093
  ...options,
1094
+ dispatchTransaction: function (tr) {
1095
+ const next = this.state.apply(tr);
1096
+ if (!instance.loading) {
1097
+ try {
1098
+ instance.assertPreserved(this.state.doc, next.doc);
1099
+ }
1100
+ catch (error) {
1101
+ this.updateState(this.state);
1102
+ instance.events.error?.(new InkKitError('preservation', `The edit was not applied because its Markdown could not be preserved: ${error instanceof Error ? error.message : String(error)}`));
1103
+ return;
1104
+ }
1105
+ }
1106
+ this.updateState(next);
1107
+ },
1092
1108
  handleScrollToSelection: () => {
1093
1109
  if (!instance.loading && instance.ready)
1094
1110
  instance.revealCurrentSelection();
@@ -1301,7 +1317,7 @@ export class InkKitEditor {
1301
1317
  const expanded = selectionContent(view.state.doc, fragment);
1302
1318
  let hasImages = false;
1303
1319
  expanded.descendants((node) => {
1304
- if (node.type.name === 'image' || isMermaid(node))
1320
+ if (isImage(node) || isMermaid(node))
1305
1321
  hasImages = true;
1306
1322
  });
1307
1323
  if (!hasImages) {
@@ -1588,6 +1604,12 @@ export class InkKitEditor {
1588
1604
  this.revealTextRange(range);
1589
1605
  this.publishCommandState();
1590
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
+ }
1591
1613
  replaceTextRange(range, text) {
1592
1614
  const projection = this.validateTextRange(range);
1593
1615
  this.assertMutation();
@@ -1613,16 +1635,21 @@ export class InkKitEditor {
1613
1635
  throw new InkKitError('invalid-range', 'Replacement cannot remove embedded content or structural separators');
1614
1636
  const from = projectionPosition(projection, range.from, 1);
1615
1637
  const to = projectionPosition(projection, range.to, range.from === range.to ? 1 : -1);
1616
- return replaceFormattedRange(this.editor.ctx.get(editorViewCtx), from, to, text, (doc) => {
1617
- this.currentText(doc);
1618
- });
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));
1619
1640
  }
1620
1641
  textRangeRects(range) {
1621
- const projection = this.validateTextRange(range);
1622
- const rects = this.literalSurface
1623
- ? literalRects(this.plain, range.from, range.to)
1624
- : formattedRects(this.editor.ctx.get(editorViewCtx), projection, range.from, range.to);
1625
- return Object.freeze(rects.map((rect) => Object.freeze(rect)));
1642
+ return this.textRangesRects([range])[0];
1643
+ }
1644
+ textRangesRects(ranges) {
1645
+ this.assertCurrent();
1646
+ const projections = ranges.map((range) => this.validateTextRange(range));
1647
+ if (!ranges.length)
1648
+ return Object.freeze([]);
1649
+ const results = this.literalSurface
1650
+ ? literalRangesRects(this.plain, ranges)
1651
+ : ranges.map((range, index) => formattedRects(this.editor.ctx.get(editorViewCtx), projections[index], range.from, range.to));
1652
+ return Object.freeze(results.map((rects) => Object.freeze(rects.map((rect) => Object.freeze(rect)))));
1626
1653
  }
1627
1654
  visibleTextRanges(snapshotId) {
1628
1655
  const snapshot = this.textSnapshot();
@@ -1758,7 +1785,12 @@ export class InkKitEditor {
1758
1785
  });
1759
1786
  }
1760
1787
  revealLiteralSelection() {
1761
- this.revealRects(literalRects(this.plain, this.plain.selectionStart, this.plain.selectionEnd));
1788
+ this.revealRects(literalRangesRects(this.plain, [
1789
+ {
1790
+ from: this.plain.selectionStart,
1791
+ to: this.plain.selectionEnd,
1792
+ },
1793
+ ])[0]);
1762
1794
  }
1763
1795
  load(markdown, generation) {
1764
1796
  this.generation = generation;
@@ -1775,10 +1807,12 @@ export class InkKitEditor {
1775
1807
  const view = this.editor.ctx.get(editorViewCtx);
1776
1808
  // A caret at the end, where writing carries on; at the start it would sit in a first-line heading and
1777
1809
  // show its marks. The last place text can go, so a document ending in a rule takes a caret above it
1778
- // rather than selecting the rule itself.
1810
+ // rather than selecting the rule itself. After a final image, typing opens a paragraph below it.
1779
1811
  const { doc } = view.state;
1780
- const end = Selection.findFrom(doc.resolve(doc.content.size), -1, true) ??
1781
- 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));
1782
1816
  view.dispatch(setSearchState(view.state.tr.setSelection(end), new SearchQuery({ search: '' })));
1783
1817
  }
1784
1818
  find(text, expectedGeneration) {
@@ -1846,9 +1880,8 @@ export class InkKitEditor {
1846
1880
  this.plainSearch = search;
1847
1881
  return result.count;
1848
1882
  }
1849
- return replaceFormatted(this.editor.ctx.get(editorViewCtx), search, replacement, (doc) => {
1850
- this.currentText(doc);
1851
- }, 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);
1852
1885
  }
1853
1886
  outlineContext() {
1854
1887
  return {
@@ -2069,22 +2102,34 @@ export class InkKitEditor {
2069
2102
  throw new InkKitError('image-unavailable', 'This editor has no image adapter');
2070
2103
  const view = this.editor.ctx.get(editorViewCtx);
2071
2104
  const { schema } = view.state;
2072
- 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({
2073
2107
  src: image.path,
2074
2108
  alt: image.alt,
2075
2109
  title: image.title ?? '',
2076
- }));
2077
- if (x != null && y != null)
2078
- this.insertBlocks(nodes.map((node) => schema.nodes.paragraph.create(null, node)), x, y);
2079
- else
2080
- view.dispatch(view.state.tr
2081
- .replaceSelectionWith(schema.nodes.paragraph.create(null, nodes))
2082
- .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());
2083
2127
  }
2084
2128
  insertBlocks(blocks, x, y) {
2085
2129
  const view = this.editor.ctx.get(editorViewCtx);
2086
2130
  const { state } = view;
2087
- 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);
2088
2133
  const tr = state.tr;
2089
2134
  let at;
2090
2135
  if ($pos.depth === 1 &&
@@ -2094,7 +2139,16 @@ export class InkKitEditor {
2094
2139
  tr.replaceWith(at, $pos.after(1), blocks);
2095
2140
  }
2096
2141
  else {
2097
- 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;
2098
2152
  tr.insert(at, blocks);
2099
2153
  }
2100
2154
  const end = at + blocks.reduce((size, node) => size + node.nodeSize, 0) - 1;
@@ -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 {};
@@ -0,0 +1,286 @@
1
+ import { keydownHandler } from '@milkdown/kit/prose/keymap';
2
+ import { GapCursor } from '@milkdown/kit/prose/gapcursor';
3
+ import { Fragment, } from '@milkdown/kit/prose/model';
4
+ import { NodeSelection, Plugin, PluginKey, Selection, TextSelection, } from '@milkdown/kit/prose/state';
5
+ import { $nodeSchema, $prose } from '@milkdown/kit/utils';
6
+ export const imageBlockName = 'image_block';
7
+ /** Attributes shared by an inline image and a standalone image block. */
8
+ export const imageAttrs = {
9
+ src: { default: '', validate: 'string' },
10
+ alt: { default: '', validate: 'string' },
11
+ title: { default: '', validate: 'string' },
12
+ inkkitFileRaw: { default: null },
13
+ inkkitFileTarget: { default: null },
14
+ inkkitFileFragment: { default: null },
15
+ inkkitFileWidth: { default: null },
16
+ inkkitFileInTable: { default: false },
17
+ };
18
+ // Remark represents a missing title as null; the image schema accepts strings.
19
+ export function markdownImageAttrs(node) {
20
+ return {
21
+ src: String(node.url ?? ''),
22
+ alt: String(node.alt ?? ''),
23
+ title: String(node.title ?? ''),
24
+ inkkitFileRaw: node.inkkitFileRaw ?? null,
25
+ inkkitFileTarget: node.inkkitFileTarget ?? null,
26
+ inkkitFileFragment: node.inkkitFileFragment ?? null,
27
+ inkkitFileWidth: node.inkkitFileWidth ?? null,
28
+ inkkitFileInTable: node.inkkitFileInTable ?? false,
29
+ };
30
+ }
31
+ /** Whether a node displays an authored image, inline or as its own block. */
32
+ export function isImage(node) {
33
+ return node.type.name === 'image' || node.type.name === imageBlockName;
34
+ }
35
+ export function isImageBlock(node) {
36
+ return node?.type.name === imageBlockName;
37
+ }
38
+ function accepts(parent, index, type) {
39
+ return parent.canReplaceWith(index, index + 1, type);
40
+ }
41
+ /** The image a paragraph holds as its only content, without marks, which makes the paragraph a
42
+ * standalone image block. A linked or emphasised image stays inline so its marks are kept. */
43
+ export function standaloneImage(children) {
44
+ const [image] = children;
45
+ return children.length === 1 &&
46
+ image.type.name === 'image' &&
47
+ !image.marks.length
48
+ ? image
49
+ : undefined;
50
+ }
51
+ /** Reads a Markdown paragraph, as an image block when it holds a standalone image and the enclosing
52
+ * block accepts one. */
53
+ export function readParagraph(state, node, paragraph) {
54
+ state.openNode(paragraph);
55
+ if (node.children)
56
+ state.next(node.children);
57
+ else
58
+ state.addText(String(node.value ?? ''));
59
+ const type = state.schema.nodes[imageBlockName];
60
+ const element = state.top();
61
+ const image = standaloneImage(element.content);
62
+ state.close();
63
+ const parent = state.top();
64
+ if (image &&
65
+ type &&
66
+ parent?.type.contentMatch
67
+ .matchFragment(Fragment.fromArray(parent.content))
68
+ ?.matchType(type))
69
+ state.addNode(type, image.attrs);
70
+ else {
71
+ state.openNode(paragraph);
72
+ for (const child of element.content)
73
+ state.top().push(child);
74
+ state.closeNode();
75
+ }
76
+ }
77
+ // A standalone image is a block with no inside, so no edit can put text beside it. In Markdown it
78
+ // remains a paragraph holding the image, which is how it is read and written.
79
+ export const imageBlockSchema = $nodeSchema(imageBlockName, () => ({
80
+ group: 'block',
81
+ atom: true,
82
+ selectable: true,
83
+ draggable: true,
84
+ marks: '',
85
+ attrs: imageAttrs,
86
+ toDOM: (node) => [
87
+ 'p',
88
+ { class: 'inkkit-image-block' },
89
+ [
90
+ 'img',
91
+ {
92
+ src: String(node.attrs.src),
93
+ alt: String(node.attrs.alt),
94
+ title: String(node.attrs.title),
95
+ },
96
+ ],
97
+ ],
98
+ parseMarkdown: { match: () => false, runner: () => undefined },
99
+ toMarkdown: {
100
+ match: (node) => node.type.name === imageBlockName,
101
+ runner: (state, node) => {
102
+ state.openNode('paragraph');
103
+ state.next(node.type.schema.nodes.image.create(node.attrs));
104
+ state.closeNode();
105
+ },
106
+ },
107
+ }));
108
+ // An edit can leave a paragraph holding only an image, which reading its Markdown again would give
109
+ // as an image block. The caret keeps its side of the image.
110
+ const normalise = $prose(() => new Plugin({
111
+ key: new PluginKey('inkkitImageBlockNormalise'),
112
+ appendTransaction(transactions, old, state) {
113
+ const type = state.schema.nodes[imageBlockName];
114
+ if (!type || !transactions.some((tr) => tr.docChanged))
115
+ return null;
116
+ // Compare the documents, as mark and attribute steps change no positions.
117
+ const start = old.doc.content.findDiffStart(state.doc.content);
118
+ const end = old.doc.content.findDiffEnd(state.doc.content);
119
+ if (start == null || !end)
120
+ return null;
121
+ const found = new Map();
122
+ state.doc.nodesBetween(Math.min(start, end.b), Math.max(start, end.b), (node, pos, parent, index) => {
123
+ if (!node.isTextblock)
124
+ return true;
125
+ const children = [];
126
+ node.forEach((child) => children.push(child));
127
+ if (node.type.name === 'paragraph' &&
128
+ standaloneImage(children) &&
129
+ parent &&
130
+ accepts(parent, index, type))
131
+ found.set(pos, node);
132
+ return false;
133
+ });
134
+ if (!found.size)
135
+ return null;
136
+ const tr = state.tr;
137
+ const { head } = state.selection;
138
+ let caret;
139
+ for (const [pos, node] of [...found].sort(([a], [b]) => b - a)) {
140
+ const end = pos + node.nodeSize;
141
+ if (head > pos && head < end)
142
+ caret = head > pos + 1 ? { pos: end, side: 1 } : { pos, side: -1 };
143
+ tr.replaceWith(pos, end, type.create(node.firstChild.attrs));
144
+ }
145
+ if (caret)
146
+ tr.setSelection(new GapCursor(tr.doc.resolve(tr.mapping.map(caret.pos, caret.side))));
147
+ return tr;
148
+ },
149
+ }));
150
+ /** The image block a selection rests beside or selects, as the position for a new paragraph. */
151
+ function newParagraphAt(state) {
152
+ const { selection } = state;
153
+ if (selection instanceof NodeSelection && isImageBlock(selection.node))
154
+ return selection.to;
155
+ if (selection instanceof GapCursor &&
156
+ (isImageBlock(selection.$head.nodeBefore) ||
157
+ isImageBlock(selection.$head.nodeAfter)))
158
+ return selection.head;
159
+ return undefined;
160
+ }
161
+ /** Opens an empty paragraph beside a selected image block, or at a gap beside one. */
162
+ function openParagraph(state) {
163
+ const pos = newParagraphAt(state);
164
+ const paragraph = state.schema.nodes.paragraph;
165
+ if (pos == null || !paragraph)
166
+ return undefined;
167
+ const $pos = state.doc.resolve(pos);
168
+ if (!accepts($pos.parent, $pos.index(), paragraph))
169
+ return undefined;
170
+ const tr = state.tr.insert(pos, paragraph.create());
171
+ return tr.setSelection(TextSelection.create(tr.doc, pos + 1));
172
+ }
173
+ const enter = (state, dispatch) => {
174
+ const tr = openParagraph(state);
175
+ if (!tr)
176
+ return false;
177
+ dispatch?.(tr.scrollIntoView());
178
+ return true;
179
+ };
180
+ /** Backspace or Delete toward an image block selects it before anything is deleted; from a gap beside
181
+ * one toward text, the caret moves into the text. An empty paragraph next to one is removed. */
182
+ function towardImage(dir) {
183
+ return (state, dispatch) => {
184
+ const { selection, doc } = state;
185
+ if (!selection.empty)
186
+ return false;
187
+ const $head = selection.$head;
188
+ if (selection instanceof GapCursor) {
189
+ const node = dir < 0 ? $head.nodeBefore : $head.nodeAfter;
190
+ const other = dir < 0 ? $head.nodeAfter : $head.nodeBefore;
191
+ if (!node || !(isImageBlock(other) || isImageBlock(node)))
192
+ return false;
193
+ const next = isImageBlock(node)
194
+ ? NodeSelection.create(doc, dir < 0 ? $head.pos - 1 : $head.pos)
195
+ : Selection.findFrom($head, dir, true);
196
+ if (!next)
197
+ return false;
198
+ dispatch?.(state.tr.setSelection(next).scrollIntoView());
199
+ return true;
200
+ }
201
+ if (!$head.parent.isTextblock || $head.depth === 0)
202
+ return false;
203
+ const atEdge = dir < 0
204
+ ? $head.parentOffset === 0
205
+ : $head.parentOffset === $head.parent.content.size;
206
+ if (!atEdge)
207
+ return false;
208
+ const index = $head.index(-1) + dir;
209
+ const parent = $head.node(-1);
210
+ const neighbour = index >= 0 && index < parent.childCount ? parent.child(index) : undefined;
211
+ if (!isImageBlock(neighbour))
212
+ return false;
213
+ const block = dir < 0 ? $head.before() - 1 : $head.after();
214
+ // An empty paragraph goes, unless its parent needs it, such as a list item's first line.
215
+ if ($head.parent.content.size === 0 &&
216
+ $head.parent.type.name === 'paragraph' &&
217
+ parent.canReplace($head.index(-1), $head.index(-1) + 1)) {
218
+ const tr = state.tr.delete($head.before(), $head.after());
219
+ dispatch?.(tr
220
+ .setSelection(new GapCursor(tr.doc.resolve($head.before())))
221
+ .scrollIntoView());
222
+ return true;
223
+ }
224
+ if (!dispatch)
225
+ return true;
226
+ dispatch(state.tr.setSelection(NodeSelection.create(doc, block)).scrollIntoView());
227
+ return true;
228
+ };
229
+ }
230
+ const keys = keydownHandler({
231
+ Enter: enter,
232
+ 'Shift-Enter': enter,
233
+ Backspace: towardImage(-1),
234
+ Delete: towardImage(1),
235
+ });
236
+ const interaction = $prose(() => new Plugin({
237
+ key: new PluginKey('inkkitImageBlocks'),
238
+ props: {
239
+ handleKeyDown: (view, event) => !view.composing && view.editable && keys(view, event),
240
+ handleTextInput(view, _from, _to, text) {
241
+ const tr = openParagraph(view.state);
242
+ if (!tr)
243
+ return false;
244
+ view.dispatch(tr.insertText(text).scrollIntoView());
245
+ return true;
246
+ },
247
+ // A click beside an image card, not on it, puts the caret after the image.
248
+ handleClickOn(view, _pos, node, nodePos, event, direct) {
249
+ if (!direct ||
250
+ !view.editable ||
251
+ !isImageBlock(node) ||
252
+ !(event.target instanceof Element) ||
253
+ event.target.closest('.image'))
254
+ return false;
255
+ view.dispatch(view.state.tr.setSelection(new GapCursor(view.state.doc.resolve(nodePos + node.nodeSize))));
256
+ return true;
257
+ },
258
+ handleDOMEvents: {
259
+ // ProseMirror replaces a selection when composition starts; a selected image is kept and
260
+ // the composed text goes after it.
261
+ compositionstart(view) {
262
+ const { selection } = view.state;
263
+ if (view.editable &&
264
+ selection instanceof NodeSelection &&
265
+ isImageBlock(selection.node))
266
+ view.dispatch(view.state.tr.setSelection(new GapCursor(view.state.doc.resolve(selection.to))));
267
+ return false;
268
+ },
269
+ // Composed text needs a paragraph to land in before the browser writes it, as the gap
270
+ // cursor plugin does for its own gaps.
271
+ beforeinput(view, event) {
272
+ if (event.inputType !== 'insertCompositionText' || !view.editable)
273
+ return false;
274
+ const tr = openParagraph(view.state);
275
+ if (tr)
276
+ view.dispatch(tr);
277
+ return false;
278
+ },
279
+ },
280
+ },
281
+ }));
282
+ export const imageBlocks = [
283
+ imageBlockSchema,
284
+ normalise,
285
+ interaction,
286
+ ].flat();
package/dist/images.d.ts CHANGED
@@ -17,6 +17,8 @@ declare class ImageView implements NodeView {
17
17
  private readonly runtime?;
18
18
  private readonly canMutate;
19
19
  dom: HTMLElement;
20
+ /** The card or picture, which a standalone image's block row holds. */
21
+ private element;
20
22
  private image?;
21
23
  private media?;
22
24
  private pdf?;
@@ -45,5 +47,5 @@ declare class ImageView implements NodeView {
45
47
  private commit;
46
48
  private canResize;
47
49
  }
48
- export declare function imageView(adapter?: ImageAdapter, runtime?: FileViewRuntime, canMutate?: () => boolean): import("@milkdown/kit/utils").$View<import("@milkdown/kit/utils").$Node, (node: ProseNode, view: EditorView, getPos: () => number | undefined) => ImageView>;
50
+ export declare function imageView(adapter?: ImageAdapter, runtime?: FileViewRuntime, canMutate?: () => boolean): import("@milkdown/kit/utils").$View<import("@milkdown/kit/utils").$Node, (node: ProseNode, view: EditorView, getPos: () => number | undefined) => ImageView>[];
49
51
  export {};