@voithos-labs/aragonite 0.10.2 → 0.10.3

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.
Files changed (255) hide show
  1. package/README.md +6 -21
  2. package/THIRD-PARTY-NOTICES.md +25 -0
  3. package/dist/a11y-strings.d.ts +18 -0
  4. package/dist/a11y-strings.js +18 -0
  5. package/dist/action-contracts.d.ts +7 -1
  6. package/dist/ambient/ambient-dom.js +5 -1
  7. package/dist/block-component.d.ts +14 -0
  8. package/dist/components/BlockDragHandle.svelte +35 -24
  9. package/dist/components/BlockHost.svelte +16 -9
  10. package/dist/components/Editor.svelte +381 -198
  11. package/dist/components/Editor.svelte.d.ts +1 -1
  12. package/dist/components/SelectionOverlay.svelte +17 -3
  13. package/dist/components/TailInsert.svelte +107 -0
  14. package/dist/components/TailInsert.svelte.d.ts +17 -0
  15. package/dist/components/block-content-selector.d.ts +6 -2
  16. package/dist/components/block-content-selector.js +6 -2
  17. package/dist/components/blocks/ThematicBreakBlock.svelte +19 -6
  18. package/dist/components/blocks/ThematicBreakBlock.svelte.d.ts +1 -0
  19. package/dist/components/blocks/code/CodeBlock.svelte +211 -30
  20. package/dist/components/blocks/code/CodeBlockRail.svelte +686 -0
  21. package/dist/components/blocks/code/CodeBlockRail.svelte.d.ts +26 -0
  22. package/dist/components/blocks/code/code-bootstrap.js +4 -0
  23. package/dist/components/blocks/code/code-context-actions.d.ts +1 -0
  24. package/dist/components/blocks/code/code-context-actions.js +24 -0
  25. package/dist/components/blocks/code/code-fence-exit.d.ts +15 -0
  26. package/dist/components/blocks/code/code-fence-exit.js +26 -0
  27. package/dist/components/blocks/code/code-languages.d.ts +6 -0
  28. package/dist/components/blocks/code/code-languages.js +11 -0
  29. package/dist/components/blocks/code/code-renderer.js +11 -0
  30. package/dist/components/blocks/directive/DirectiveContainerBlock.svelte +1 -1
  31. package/dist/components/blocks/editable-leaf.d.ts +37 -6
  32. package/dist/components/blocks/editable-leaf.js +243 -29
  33. package/dist/components/blocks/editable-surface.d.ts +9 -0
  34. package/dist/components/blocks/editable-surface.js +22 -3
  35. package/dist/components/blocks/list/ListItemBlock.svelte +3 -3
  36. package/dist/components/blocks/list/task-checkbox.d.ts +2 -0
  37. package/dist/components/blocks/list/task-checkbox.js +11 -2
  38. package/dist/components/blocks/surface-wiring.svelte.d.ts +4 -0
  39. package/dist/components/blocks/surface-wiring.svelte.js +8 -1
  40. package/dist/components/blocks/table/TableActionMenu.svelte +210 -86
  41. package/dist/components/blocks/table/TableActionMenu.svelte.d.ts +5 -0
  42. package/dist/components/blocks/table/TableBlock.svelte +205 -186
  43. package/dist/components/blocks/table/TableBlock.svelte.d.ts +1 -0
  44. package/dist/components/blocks/table/TableCellBlock.svelte +87 -22
  45. package/dist/components/blocks/table/TableRowBlock.svelte +4 -18
  46. package/dist/components/blocks/table/TableRowBlock.svelte.d.ts +0 -5
  47. package/dist/components/blocks/table/cell-clipboard.d.ts +10 -0
  48. package/dist/components/blocks/table/cell-clipboard.js +34 -1
  49. package/dist/components/blocks/table/cell-keydown-plan.d.ts +1 -1
  50. package/dist/components/blocks/table/cell-keydown-plan.js +3 -1
  51. package/dist/components/blocks/table/table-cell-paste.js +2 -1
  52. package/dist/components/blocks/table/table-menu-model.d.ts +35 -8
  53. package/dist/components/blocks/table/table-menu-model.js +36 -17
  54. package/dist/components/blocks/text/TextEditableBlock.svelte +58 -11
  55. package/dist/components/blocks/text/delimiter-autopair.d.ts +71 -0
  56. package/dist/components/blocks/text/delimiter-autopair.js +216 -0
  57. package/dist/components/blocks/text/edge-policy-dispatch.d.ts +4 -0
  58. package/dist/components/blocks/text/edge-policy-dispatch.js +56 -2
  59. package/dist/components/blocks/text/live-selection-edit.js +27 -0
  60. package/dist/components/blocks/text/text-keydown.d.ts +7 -1
  61. package/dist/components/blocks/text/text-keydown.js +11 -1
  62. package/dist/components/blocks/text/text-render.d.ts +1 -1
  63. package/dist/components/blocks/text/text-render.js +3 -1
  64. package/dist/components/blocks/text/widget-interaction.d.ts +3 -0
  65. package/dist/components/blocks/text/widget-interaction.js +144 -22
  66. package/dist/components/drag-handle.d.ts +35 -0
  67. package/dist/components/drag-handle.js +126 -0
  68. package/dist/components/editor-root-focus.d.ts +19 -0
  69. package/dist/components/editor-root-focus.js +67 -0
  70. package/dist/components/editor-root-geometry.d.ts +39 -0
  71. package/dist/components/editor-root-geometry.js +91 -0
  72. package/dist/components/editor-root-keydown.d.ts +1 -1
  73. package/dist/components/editor-root-keydown.js +12 -3
  74. package/dist/components/editor-root-listeners.d.ts +10 -6
  75. package/dist/components/editor-root-listeners.js +23 -22
  76. package/dist/components/editor-root-mode-flip.d.ts +36 -0
  77. package/dist/components/editor-root-mode-flip.js +92 -0
  78. package/dist/components/image/ImageOverlayHost.svelte +14 -6
  79. package/dist/components/image/ImageProperties.svelte +512 -70
  80. package/dist/components/image/ImageProperties.svelte.d.ts +6 -1
  81. package/dist/components/image/ImageResizeHandles.svelte +59 -34
  82. package/dist/components/image/image-crop.d.ts +39 -0
  83. package/dist/components/image/image-crop.js +74 -0
  84. package/dist/components/image/image-edit-commit.d.ts +1 -0
  85. package/dist/components/image/image-edit-commit.js +21 -5
  86. package/dist/components/image/image-source-bytes.js +10 -3
  87. package/dist/components/image/image-widget-editing.js +1 -0
  88. package/dist/components/image/widget-dom.js +5 -1
  89. package/dist/components/link-card/link-card-commit.js +1 -1
  90. package/dist/components/lrd-map-gate.js +1 -1
  91. package/dist/components/menu/BlockMenu.svelte +315 -0
  92. package/dist/components/menu/BlockMenu.svelte.d.ts +34 -0
  93. package/dist/components/menu/MenuIcon.svelte +153 -0
  94. package/dist/components/menu/MenuIcon.svelte.d.ts +51 -0
  95. package/dist/components/menu/clipboard-actions.d.ts +12 -0
  96. package/dist/components/menu/clipboard-actions.js +42 -0
  97. package/dist/components/menu/default-context-actions.d.ts +15 -0
  98. package/dist/components/menu/default-context-actions.js +76 -0
  99. package/dist/components/menu/flyout-placement.d.ts +6 -0
  100. package/dist/components/menu/flyout-placement.js +25 -0
  101. package/dist/core/inline/format-toggle.d.ts +12 -4
  102. package/dist/core/inline/format-toggle.js +94 -40
  103. package/dist/core/inline/image-dimensions.d.ts +3 -0
  104. package/dist/core/inline/image-dimensions.js +46 -10
  105. package/dist/core/inline/inline-widgets.d.ts +11 -0
  106. package/dist/core/inline/scan/brackets.js +1 -0
  107. package/dist/core/inline/scan/plugin-syntax.d.ts +8 -0
  108. package/dist/core/inline/scan/plugin-syntax.js +15 -1
  109. package/dist/core/inline-render.d.ts +6 -0
  110. package/dist/core/inline-render.js +32 -0
  111. package/dist/core/nodes.d.ts +14 -0
  112. package/dist/cursor/edge-affinity.js +2 -1
  113. package/dist/cursor/overlay-remeasure.js +8 -0
  114. package/dist/cursor/reveal-source.js +7 -2
  115. package/dist/cursor/widget-offset.d.ts +6 -0
  116. package/dist/cursor/widget-offset.js +61 -4
  117. package/dist/debug/interaction-trace.d.ts +4 -0
  118. package/dist/debug/interaction-trace.js +15 -0
  119. package/dist/decorations/decoration-state.svelte.js +1 -1
  120. package/dist/editor-actions/ancestry-folds.d.ts +2 -2
  121. package/dist/editor-actions/ancestry-folds.js +1 -1
  122. package/dist/editor-actions/block-edit-scope.js +1 -1
  123. package/dist/editor-actions/commit/text-batch.d.ts +3 -2
  124. package/dist/editor-actions/commit/text-batch.js +1 -1
  125. package/dist/editor-actions/commit/undo-controller.js +4 -2
  126. package/dist/editor-actions/container-edit.js +2 -1
  127. package/dist/editor-actions/enter-completion.d.ts +2 -0
  128. package/dist/editor-actions/enter-completion.js +23 -2
  129. package/dist/editor-actions/inline-range-commit.js +1 -1
  130. package/dist/editor-actions/reorder-action.js +19 -10
  131. package/dist/editor-actions/reorder-drag.js +29 -1
  132. package/dist/editor-actions/replacement-focus.d.ts +1 -1
  133. package/dist/editor-actions/replacement-focus.js +1 -1
  134. package/dist/editor-actions/search-replace.js +1 -1
  135. package/dist/editor-actions/table-context.d.ts +4 -1
  136. package/dist/editor-actions/table-context.js +57 -1
  137. package/dist/editor-events.d.ts +3 -0
  138. package/dist/editor-keys.d.ts +33 -0
  139. package/dist/editor-props.d.ts +21 -12
  140. package/dist/index.d.ts +1 -1
  141. package/dist/plugin.d.ts +6 -0
  142. package/dist/plugin.js +11 -0
  143. package/dist/plugins/latex/BlockMath.svelte +264 -29
  144. package/dist/plugins/latex/BlockMath.svelte.d.ts +2 -0
  145. package/dist/plugins/latex/index.d.ts +2 -1
  146. package/dist/plugins/latex/latex-kind.js +36 -3
  147. package/dist/plugins/latex/math-completion.js +4 -1
  148. package/dist/plugins/latex/math-layout.d.ts +15 -0
  149. package/dist/plugins/latex/math-layout.js +13 -0
  150. package/dist/plugins/latex/math-source.d.ts +19 -0
  151. package/dist/plugins/latex/math-source.js +97 -0
  152. package/dist/plugins/latex/register.d.ts +11 -2
  153. package/dist/plugins/latex/register.js +3 -1
  154. package/dist/plugins/latex/renderer.d.ts +3 -3
  155. package/dist/plugins/latex/renderer.js +13 -6
  156. package/dist/reactivity/list-windowing.svelte.d.ts +8 -8
  157. package/dist/reactivity/list-windowing.svelte.js +49 -24
  158. package/dist/schema/block-completions.d.ts +8 -0
  159. package/dist/schema/block-completions.js +11 -0
  160. package/dist/schema/context-actions.d.ts +31 -0
  161. package/dist/schema/context-actions.js +23 -0
  162. package/dist/schema/fenced-code-raw.js +31 -1
  163. package/dist/schema/operations.d.ts +8 -1
  164. package/dist/schema/reserved-chords.js +25 -4
  165. package/dist/schema/table-cell-raw.d.ts +1 -1
  166. package/dist/schema/table-cell-raw.js +1 -1
  167. package/dist/selection/block-hit-test.js +3 -2
  168. package/dist/selection/char-endpoint-snap.js +1 -1
  169. package/dist/selection/clipboard-text.js +6 -1
  170. package/dist/selection/covered-block.d.ts +10 -0
  171. package/dist/selection/covered-block.js +24 -0
  172. package/dist/selection/cross-block/dispatch.d.ts +3 -0
  173. package/dist/selection/cross-block/dispatch.js +13 -1
  174. package/dist/selection/cross-block/format-range.d.ts +1 -1
  175. package/dist/selection/cross-block/format-range.js +4 -15
  176. package/dist/selection/cross-block/format-toggle.js +1 -1
  177. package/dist/selection/cross-block/keydown.js +1 -1
  178. package/dist/selection/cross-block/ops.js +1 -1
  179. package/dist/selection/cross-block/paste.js +32 -30
  180. package/dist/selection/cross-block/type-replace.d.ts +3 -2
  181. package/dist/selection/cross-block/type-replace.js +62 -13
  182. package/dist/selection/dead-space-caret.d.ts +10 -0
  183. package/dist/selection/dead-space-caret.js +47 -1
  184. package/dist/selection/double-click-trim.d.ts +17 -0
  185. package/dist/selection/double-click-trim.js +57 -0
  186. package/dist/selection/drag-pointer.d.ts +7 -2
  187. package/dist/selection/drag-pointer.js +61 -2
  188. package/dist/selection/gap-caret.js +1 -1
  189. package/dist/selection/keyboard-extend.js +1 -1
  190. package/dist/selection/path-lookup.js +1 -1
  191. package/dist/selection/range-delete-ceremony.js +4 -2
  192. package/dist/selection/range-delete-chrome.js +2 -1
  193. package/dist/selection/range-delete-table-coverage.js +2 -1
  194. package/dist/selection/range-delete-table.js +3 -2
  195. package/dist/selection/range-delete.js +30 -2
  196. package/dist/selection/selection-restore.js +1 -1
  197. package/dist/selection/selection-state.svelte.d.ts +6 -0
  198. package/dist/selection/selection-state.svelte.js +36 -1
  199. package/dist/selection/table-endpoint-snap.js +1 -1
  200. package/dist/selection/table-rect-extend.js +1 -1
  201. package/dist/styles/editor-theme.css +57 -28
  202. package/dist/styles/editor.css +217 -18
  203. package/dist/testing/container-conformance.js +2 -2
  204. package/dist/testing/inline-conformance.js +2 -1
  205. package/dist/tree-operations/blockquote.js +1 -1
  206. package/dist/tree-operations/chain-rebuild.d.ts +63 -0
  207. package/dist/tree-operations/chain-rebuild.js +142 -0
  208. package/dist/tree-operations/children.d.ts +1 -1
  209. package/dist/tree-operations/children.js +1 -1
  210. package/dist/tree-operations/cleanup.js +1 -1
  211. package/dist/tree-operations/content-write.d.ts +50 -0
  212. package/dist/tree-operations/content-write.js +263 -0
  213. package/dist/tree-operations/index.d.ts +8 -3
  214. package/dist/tree-operations/index.js +6 -2
  215. package/dist/tree-operations/list/exit-replacement.js +1 -1
  216. package/dist/tree-operations/list/unwrap-merge.js +3 -3
  217. package/dist/tree-operations/node-ops.d.ts +17 -234
  218. package/dist/tree-operations/node-ops.js +47 -1113
  219. package/dist/tree-operations/node-primitives.d.ts +75 -0
  220. package/dist/tree-operations/node-primitives.js +117 -0
  221. package/dist/tree-operations/paste/apply.js +1 -1
  222. package/dist/tree-operations/paste/body-write.d.ts +1 -1
  223. package/dist/tree-operations/paste/body-write.js +2 -2
  224. package/dist/tree-operations/paste/container-match.js +4 -2
  225. package/dist/tree-operations/paste/dispatch.js +2 -1
  226. package/dist/tree-operations/paste/find-enclosing-list.js +1 -1
  227. package/dist/tree-operations/paste/focus-target.d.ts +1 -1
  228. package/dist/tree-operations/paste/list-absorb.js +1 -1
  229. package/dist/tree-operations/paste/list-break-out.js +1 -1
  230. package/dist/tree-operations/paste/parent-scope.js +1 -1
  231. package/dist/tree-operations/paste/paste-replacement.js +1 -1
  232. package/dist/tree-operations/paste/replace-block-at-parent.js +1 -1
  233. package/dist/tree-operations/path-mutate.d.ts +1 -1
  234. package/dist/tree-operations/path-mutate.js +2 -1
  235. package/dist/tree-operations/reorder-unit.js +1 -1
  236. package/dist/tree-operations/reorder.d.ts +5 -2
  237. package/dist/tree-operations/reorder.js +55 -2
  238. package/dist/tree-operations/settle.d.ts +105 -0
  239. package/dist/tree-operations/settle.js +660 -0
  240. package/dist/tree-operations/table-grid-clipboard.d.ts +21 -0
  241. package/dist/tree-operations/table-grid-clipboard.js +90 -0
  242. package/dist/tree-operations/unshare.d.ts +15 -77
  243. package/dist/tree-operations/unshare.js +15 -166
  244. package/docs/guide/consumer-guide.md +126 -96
  245. package/docs/guide/plugin-api.md +31 -3
  246. package/docs/guide/plugin-guide.md +26 -3
  247. package/package.json +4 -2
  248. package/dist/components/blocks/code/CodeLanguageChip.svelte +0 -127
  249. package/dist/components/blocks/code/CodeLanguageChip.svelte.d.ts +0 -14
  250. package/dist/components/blocks/table/TableGrip.svelte +0 -91
  251. package/dist/components/blocks/table/TableGrip.svelte.d.ts +0 -8
  252. package/dist/components/blocks/table/table-drop-target.d.ts +0 -1
  253. package/dist/components/blocks/table/table-drop-target.js +0 -16
  254. package/dist/components/blocks/table/table-reorder-drag.d.ts +0 -78
  255. package/dist/components/blocks/table/table-reorder-drag.js +0 -97
@@ -0,0 +1,76 @@
1
+ import { EVERY_KIND, registerBlockContextActions } from '../../schema/context-actions';
2
+ import { applyPasteTransforms } from '../../tree-operations/paste/paste-transforms';
3
+ const KIND_NAMES = {
4
+ paragraph: 'paragraph',
5
+ heading: 'heading',
6
+ setextHeading: 'heading',
7
+ fencedCode: 'code block',
8
+ indentedCode: 'code block',
9
+ mathBlock: 'math block',
10
+ mathFence: 'math block',
11
+ table: 'table',
12
+ blockquote: 'quote',
13
+ list: 'list',
14
+ thematicBreak: 'divider',
15
+ html: 'HTML block',
16
+ linkReferenceDefinition: 'link definition'
17
+ };
18
+ const IMAGE_ONLY = /^\s*!\[[^\]]*\]\([^)]*\)\s*$/;
19
+ const PROSE_KINDS = new Set(['paragraph', 'heading', 'setextHeading']);
20
+ /** The noun the menu uses for a block: a paragraph holding nothing but an image is "image". */
21
+ export function blockNoun(node) {
22
+ if (node.kind === 'paragraph' && IMAGE_ONLY.test(node.raw))
23
+ return 'image';
24
+ return KIND_NAMES[node.kind] ?? 'block';
25
+ }
26
+ /** Prose is the page's background: it takes no block menu of its own. An image alone in a
27
+ * paragraph is a block, not prose. */
28
+ export function isProseBackground(node) {
29
+ return PROSE_KINDS.has(node.kind) && blockNoun(node) !== 'image';
30
+ }
31
+ let registered = false;
32
+ /** Test-only. */
33
+ export function __resetDefaultContextActionsForTests() {
34
+ registered = false;
35
+ }
36
+ export function registerDefaultContextActions() {
37
+ if (registered)
38
+ return;
39
+ registered = true;
40
+ registerBlockContextActions(EVERY_KIND, (node) => {
41
+ const noun = blockNoun(node);
42
+ return [
43
+ {
44
+ id: 'block.copy',
45
+ label: `Copy ${noun}`,
46
+ icon: 'copy',
47
+ run: () => navigator.clipboard.writeText(node.raw)
48
+ },
49
+ {
50
+ id: 'block.replace',
51
+ label: 'Replace with clipboard',
52
+ icon: 'clipboard',
53
+ run: async (ctx) => {
54
+ let text;
55
+ try {
56
+ text = await navigator.clipboard.readText();
57
+ }
58
+ catch {
59
+ return;
60
+ }
61
+ if (text.trim() === '')
62
+ return;
63
+ // The paste transforms first, as any paste gets, so a plugin's rewrite applies.
64
+ const md = applyPasteTransforms(text);
65
+ await ctx.replaceRaw(md.endsWith('\n') ? md : md + '\n');
66
+ }
67
+ },
68
+ {
69
+ id: 'block.remove',
70
+ label: `Remove ${noun}`,
71
+ icon: 'trash',
72
+ run: (ctx) => ctx.deleteBlock()
73
+ }
74
+ ];
75
+ });
76
+ }
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Svelte attachment for a menu flyout: measures the surface once it is in the DOM and moves it
3
+ * to stay inside the viewport (`flyoutPlacement`). The element's own CSS hangs it off the row's
4
+ * right edge; this writes the correction inline, so the rule lives in one place for every menu.
5
+ */
6
+ export declare function keepFlyoutOnScreen(node: HTMLElement): void;
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Svelte attachment for a menu flyout: measures the surface once it is in the DOM and moves it
3
+ * to stay inside the viewport (`flyoutPlacement`). The element's own CSS hangs it off the row's
4
+ * right edge; this writes the correction inline, so the rule lives in one place for every menu.
5
+ */
6
+ import { flyoutPlacement } from '../blocks/table/table-menu-model';
7
+ const GAP = 4;
8
+ export function keepFlyoutOnScreen(node) {
9
+ const parentMenu = node.parentElement?.closest('.md-menu');
10
+ if (!parentMenu)
11
+ return;
12
+ const rect = node.getBoundingClientRect();
13
+ const { dy, flip } = flyoutPlacement(rect, parentMenu.getBoundingClientRect(), {
14
+ width: window.innerWidth,
15
+ height: window.innerHeight
16
+ });
17
+ if (dy !== 0)
18
+ node.style.top = `${node.offsetTop + dy}px`;
19
+ if (flip) {
20
+ node.style.left = 'auto';
21
+ node.style.right = '100%';
22
+ node.style.marginLeft = '0';
23
+ node.style.marginRight = `${GAP}px`;
24
+ }
25
+ }
@@ -1,10 +1,11 @@
1
1
  /**
2
2
  * Toggle an inline format inside a prose block. Over a SELECTION the direction is parse coverage,
3
3
  * not edge adjacency: a covered range unapplies (aligned strip, else split, over every covering run
4
- * in turn), overlapping or abutting runs apply over their union, a bare range wraps. Where the mode
5
- * PAINTS delimiters the strip and the wrap write literally; every other candidate declines unless it
6
- * verifies. At a COLLAPSED CARET: unwrap the span, else drop the empty pair, else insert one
7
- * (live-mode.md § 4.3). Every write clamps to the CONTENT range: a marker in `# ` changes the kind.
4
+ * in turn), overlapping or abutting runs apply over their union, a bare range wraps the core its
5
+ * boundary whitespace leaves. Where the mode PAINTS delimiters the strip and the wrap write
6
+ * unverified; every other candidate declines unless it verifies. At a COLLAPSED CARET: unwrap the
7
+ * span, else drop the empty pair, else insert one (live-mode.md § 4.3). Every write clamps to the
8
+ * CONTENT range: a marker in `# ` changes the kind.
8
9
  */
9
10
  import { type PresentationMode } from '../../presentation-mode';
10
11
  import { type InlineMarkKind } from '../../schema/inline-construct-policy';
@@ -48,3 +49,10 @@ export declare function inlineFormatsCovering(edit: InlineFormatEdit, candidates
48
49
  * a toolbar's buttons arrive as N separate calls, and only the caller knows they are one repaint.
49
50
  * One slot, since the previous state is dead the moment a keystroke or the caret moves. */
50
51
  export declare function createInlineFormatActiveMemo(): (edit: InlineFormatEdit, format: InlineMarkKind) => boolean;
52
+ /** A range minus its boundary whitespace, null once nothing is left. One home for the trim, so the
53
+ * wrap here and the cross-block decomposition that trims before it ever asks
54
+ * (`selection/cross-block/format-range.ts`) cannot disagree about where a run may close. */
55
+ export declare function withoutBoundaryWhitespace(display: string, from: number, to: number): {
56
+ start: number;
57
+ end: number;
58
+ } | null;
@@ -1,10 +1,11 @@
1
1
  /**
2
2
  * Toggle an inline format inside a prose block. Over a SELECTION the direction is parse coverage,
3
3
  * not edge adjacency: a covered range unapplies (aligned strip, else split, over every covering run
4
- * in turn), overlapping or abutting runs apply over their union, a bare range wraps. Where the mode
5
- * PAINTS delimiters the strip and the wrap write literally; every other candidate declines unless it
6
- * verifies. At a COLLAPSED CARET: unwrap the span, else drop the empty pair, else insert one
7
- * (live-mode.md § 4.3). Every write clamps to the CONTENT range: a marker in `# ` changes the kind.
4
+ * in turn), overlapping or abutting runs apply over their union, a bare range wraps the core its
5
+ * boundary whitespace leaves. Where the mode PAINTS delimiters the strip and the wrap write
6
+ * unverified; every other candidate declines unless it verifies. At a COLLAPSED CARET: unwrap the
7
+ * span, else drop the empty pair, else insert one (live-mode.md § 4.3). Every write clamps to the
8
+ * CONTENT range: a marker in `# ` changes the kind.
8
9
  */
9
10
  import { recordFormatCoverageRead } from '../../perf/instruments';
10
11
  import { paintsFocusedMarkers } from '../../presentation-mode';
@@ -29,29 +30,31 @@ export function toggleInlineFormat(edit, format, mode) {
29
30
  // Painted delimiters are the mode's own answer, so those modes write their candidate unverified.
30
31
  // The preview rungs paint: this seam only writes into the block those rungs reveal.
31
32
  const paints = paintsFocusedMarkers(mode ?? 'source');
32
- const covering = coveringSpansOf(inlines, start, end, format);
33
+ const { from, to, covering } = coveredReading(display, inlines, start, end, format);
33
34
  // A strip sheds ONE run, so where a second of the same kind covers the selection too, the answer
34
35
  // is the covering arm's split: stripping there leaves the press's own read still active.
35
- const sole = covering.length > 1 ? null : soleStripCandidate(display, inlines, start, end, format);
36
+ const sole = covering.length > 1 ? null : soleStripCandidate(display, inlines, from, to, format);
36
37
  if (sole)
37
38
  return paints || preservesScreen(sole, edit, screenOf(display, content)) ? sole : null;
38
39
  // The flank strip rewrites bytes OUTSIDE the selection, and byte equality can mistake a nested
39
40
  // run's delimiters for the enclosing one's, so its candidate verifies like the split's.
40
- const enclosing = enclosingSpanOf(inlines, start, end, format);
41
- if (enclosing && flanksAreItsMarkers(display, start, end, enclosing)) {
42
- const flank = firstFlipVerified([flankStrip(display, start, end, enclosing)], edit, format, 'unapply');
41
+ const enclosing = enclosingSpanOf(inlines, from, to, format);
42
+ if (enclosing && flanksAreItsMarkers(display, from, to, enclosing)) {
43
+ const flank = firstFlipVerified([flankStrip(display, from, to, enclosing)], edit, format, 'unapply');
43
44
  if (flank)
44
45
  return flank;
45
46
  }
46
47
  if (covering.length > 0)
47
- return firstFlipVerified(covering.flatMap((span) => splitCandidates(display, inlines, span, start, end, format)), edit, format, 'unapply');
48
+ return firstFlipVerified(covering.flatMap((span) => splitCandidates(display, inlines, span, from, to, format)), edit, format, 'unapply');
48
49
  const union = formatUnionOf(inlines, start, end, format);
49
50
  if (union)
50
- return firstFlipVerified(absorbCandidates(display, inlines, union, format, mark), edit, format, 'apply');
51
+ return firstFlipVerified(absorbCandidates(display, inlines, union, { start, end }, format, mark), edit, format, 'apply');
51
52
  // A wrap whose markers do not paint owes split and absorb's coverage check: bytes that re-pair
52
53
  // against a neighbouring run leave the range unformatted with nothing on screen to say so.
53
- const wraps = wrapCandidates(display, inlines, start, end, mark);
54
- return paints ? (wraps[0] ?? null) : firstFlipVerified(wraps, edit, format, 'apply');
54
+ const wrap = wrapCandidate(display, inlines, start, end, mark);
55
+ if (!wrap)
56
+ return null;
57
+ return paints ? wrap : firstFlipVerified([wrap], edit, format, 'apply');
55
58
  }
56
59
  /** Whether a toggle right now would UNAPPLY — the pressed-state a toolbar paints. The same arms
57
60
  * the toggle routes by, asked without emitting: a caret inside the construct, a selection
@@ -137,7 +140,24 @@ function coverageCarries({ display, start, end, inlines, sliceNodes }, format) {
137
140
  const enclosing = enclosingSpanOf(inlines, start, end, format);
138
141
  if (enclosing && flanksAreItsMarkers(display, start, end, enclosing))
139
142
  return true;
140
- return coveringSpansOf(inlines, start, end, format).length > 0;
143
+ return coveredReading(display, inlines, start, end, format).covering.length > 0;
144
+ }
145
+ /**
146
+ * The range an unapply reads, with the runs covering it. A run closes against a word and never
147
+ * whitespace, so the wrap leaves a boundary space OUTSIDE the delimiters: a selection reaching
148
+ * past a run by whitespace alone is still that run's own, or it could not take its own mark off.
149
+ */
150
+ function coveredReading(display, inlines, start, end, format) {
151
+ const covering = coveringSpansOf(inlines, start, end, format);
152
+ if (covering.length > 0)
153
+ return { from: start, to: end, covering };
154
+ const trimmed = withoutBoundaryWhitespace(display, start, end);
155
+ if (!trimmed || (trimmed.start === start && trimmed.end === end))
156
+ return { from: start, to: end, covering };
157
+ const inner = coveringSpansOf(inlines, trimmed.start, trimmed.end, format);
158
+ return inner.length > 0
159
+ ? { from: trimmed.start, to: trimmed.end, covering: inner }
160
+ : { from: start, to: end, covering };
141
161
  }
142
162
  // ── Aligned unapply ──────────────────────────────────────────────────────────
143
163
  /** The selection carries its own flanking markers (the user selected `**word**`): exactly one
@@ -247,18 +267,37 @@ function formatUnionOf(inlines, start, end, format) {
247
267
  }
248
268
  return touched ? { start: from, end: to } : null;
249
269
  }
250
- function absorbCandidates(display, inlines, union, format, mark) {
270
+ /** The run grows to the union, the SELECTION does not: each endpoint is carried through the
271
+ * strip and re-wrap, so a press over half a run leaves that half selected. An endpoint on the
272
+ * union's edge keeps the new marker inside the range, as a bare wrap's selection does. */
273
+ function absorbCandidates(display, inlines, union, selected, format, mark) {
251
274
  if (!cutsLandCleanly(inlines, union, union))
252
275
  return [];
253
- const stripped = stripKindMarkers(display, inlines, format, union.start, union.end);
276
+ const cuts = kindMarkerCuts(inlines, format, union.start, union.end);
277
+ const stripped = spliceOutCuts(display, cuts, union.start, union.end);
254
278
  const prefix = display.slice(0, union.start);
255
279
  const suffix = display.slice(union.end);
256
280
  const wrapAt = (lead, core, trail) => {
257
281
  const wrapped = wrapSlice(core, mark);
282
+ // The kind may pad its fence, so the marker widths are read off the wrap, not the row.
283
+ const found = core ? wrapped.indexOf(core) : -1;
284
+ const open = found >= 0 ? found : (wrapped.length - core.length) / 2;
285
+ const close = wrapped.length - core.length - open;
286
+ const coreStart = lead.length;
287
+ const coreEnd = lead.length + core.length;
288
+ const startAt = strippedOffset(cuts, union.start, selected.start);
289
+ const endAt = strippedOffset(cuts, union.start, selected.end);
290
+ const shift = (at, edgeInclusive) => {
291
+ if (edgeInclusive === 'start' ? at <= coreStart : at < coreStart)
292
+ return at;
293
+ if (edgeInclusive === 'start' ? at <= coreEnd : at < coreEnd)
294
+ return at + open;
295
+ return at + open + close;
296
+ };
258
297
  return {
259
298
  newDisplay: prefix + lead + wrapped + trail + suffix,
260
- newSelStart: prefix.length + lead.length,
261
- newSelEnd: prefix.length + lead.length + wrapped.length
299
+ newSelStart: prefix.length + shift(startAt, 'start'),
300
+ newSelEnd: prefix.length + shift(endAt, 'end')
262
301
  };
263
302
  };
264
303
  const out = [wrapAt('', stripped, '')];
@@ -288,6 +327,10 @@ function cutsLandCleanly(inlines, cuts, within) {
288
327
  /** The bytes of `[from, to)` with the delimiter runs of every same-format construct lying wholly
289
328
  * inside removed; one straddling the range keeps its markers, for the verifier to judge. */
290
329
  function stripKindMarkers(display, inlines, format, from, to) {
330
+ return spliceOutCuts(display, kindMarkerCuts(inlines, format, from, to), from, to);
331
+ }
332
+ /** The marker byte ranges of every `format` run lying wholly inside [from, to), in order. */
333
+ function kindMarkerCuts(inlines, format, from, to) {
291
334
  const cuts = [];
292
335
  for (const node of inlineDescendants(inlines)) {
293
336
  if (node.kind !== format)
@@ -297,7 +340,9 @@ function stripKindMarkers(display, inlines, format, from, to) {
297
340
  continue;
298
341
  cuts.push([span.start, span.contentStart], [span.contentEnd, span.end]);
299
342
  }
300
- cuts.sort((a, b) => a[0] - b[0]);
343
+ return cuts.sort((a, b) => a[0] - b[0]);
344
+ }
345
+ function spliceOutCuts(display, cuts, from, to) {
301
346
  let out = '';
302
347
  let at = from;
303
348
  for (const [cutFrom, cutTo] of cuts) {
@@ -306,30 +351,27 @@ function stripKindMarkers(display, inlines, format, from, to) {
306
351
  }
307
352
  return out + display.slice(at, to);
308
353
  }
354
+ /** Where a display offset lands in the stripped slice: the cut bytes before it fall away, and an
355
+ * offset inside a marker rides to that marker's start. */
356
+ function strippedOffset(cuts, from, offset) {
357
+ let removed = 0;
358
+ for (const [cutFrom, cutTo] of cuts)
359
+ if (cutFrom < offset)
360
+ removed += Math.min(cutTo, offset) - cutFrom;
361
+ return Math.max(0, offset - from - removed);
362
+ }
309
363
  // ── Wrap ─────────────────────────────────────────────────────────────────────
310
364
  /**
311
- * What a bare wrap could mean, what it literally says first. Only the wrap has a second reading:
312
- * markdown opens and closes a run against a word, never whitespace, so a boundary space goes to
313
- * the plain text beside the run, as `live-split-rebalance` reads the same problem. Either reading
314
- * needs both its endpoints to be legal cuts: markers spliced into a construct's bytes re-pair.
365
+ * The bare wrap, over the selection's trimmed core: markdown opens and closes a run against a word,
366
+ * never whitespace, so a boundary space goes to the plain text beside the run, as
367
+ * `live-split-rebalance` reads the same problem. Null where nothing survives the trim, or where an
368
+ * endpoint is no legal cut: markers spliced into a construct's bytes re-pair.
315
369
  */
316
- function wrapCandidates(display, inlines, start, end, mark) {
317
- const core = trimmedRange(display, start, end);
318
- const readings = core === null ? [{ start, end }] : [{ start, end }, core];
319
- return readings
320
- .filter((range) => cutsLandCleanly(inlines, range, range))
321
- .map((range) => wrapRange(display, range.start, range.end, mark));
322
- }
323
- /** The selection minus its boundary whitespace, or null when there is none to trim or nothing
324
- * left once it goes. */
325
- function trimmedRange(display, start, end) {
326
- let from = start;
327
- let to = end;
328
- while (from < to && /\s/.test(display[from]))
329
- from++;
330
- while (to > from && /\s/.test(display[to - 1]))
331
- to--;
332
- return (from === start && to === end) || from === to ? null : { start: from, end: to };
370
+ function wrapCandidate(display, inlines, start, end, mark) {
371
+ const core = withoutBoundaryWhitespace(display, start, end);
372
+ if (!core || !cutsLandCleanly(inlines, core, core))
373
+ return null;
374
+ return wrapRange(display, core.start, core.end, mark);
333
375
  }
334
376
  function wrapRange(display, start, end, mark) {
335
377
  const wrapped = wrapSlice(display.slice(start, end), mark);
@@ -527,6 +569,18 @@ function leadingWs(text) {
527
569
  function trailingWs(text) {
528
570
  return /\s*$/.exec(text)[0];
529
571
  }
572
+ /** A range minus its boundary whitespace, null once nothing is left. One home for the trim, so the
573
+ * wrap here and the cross-block decomposition that trims before it ever asks
574
+ * (`selection/cross-block/format-range.ts`) cannot disagree about where a run may close. */
575
+ export function withoutBoundaryWhitespace(display, from, to) {
576
+ let start = from;
577
+ let end = to;
578
+ while (start < end && /\s/.test(display[start]))
579
+ start++;
580
+ while (end > start && /\s/.test(display[end - 1]))
581
+ end--;
582
+ return start === end ? null : { start, end };
583
+ }
530
584
  // ── Content clamp ────────────────────────────────────────────────────────────
531
585
  function clampToContent(offset, content) {
532
586
  return Math.min(Math.max(offset, content.start), content.end);
@@ -1,6 +1,9 @@
1
+ import type { ImageCrop } from '../nodes';
2
+ export declare const MAX_CROP_ZOOM = 4;
1
3
  export interface ParsedImageAlt {
2
4
  displayAlt: string;
3
5
  width: number | undefined;
4
6
  height: number | undefined;
7
+ crop: ImageCrop | undefined;
5
8
  }
6
9
  export declare function parseImageDimensions(alt: string): ParsedImageAlt;
@@ -1,14 +1,17 @@
1
- // `|N` / `|NxM` is an Obsidian extension, not part of GFM.
1
+ // `|N` / `|NxM` is an Obsidian extension, not part of GFM. The `@X,Y[,Z]` tail on the `NxM`
2
+ // form is this editor's own: the pan and zoom of the image inside that frame.
2
3
  const MAX_DIMENSION = 10000;
3
- // The longest zero-free decodable suffix, `|10000x10000`. Bounding the pipe search to that tail
4
- // keeps nested-label floods linear, at the deliberate cost of not decoding zero-padded runs.
5
- const MAX_SUFFIX_SEARCH = 12;
4
+ export const MAX_CROP_ZOOM = 4;
5
+ // The longest decodable suffix, `|10000x10000@100,100,4.00`. Bounding the pipe search to that
6
+ // tail keeps nested-label floods linear.
7
+ const MAX_SUFFIX_SEARCH = 26;
8
+ const NO_HINT = { width: undefined, height: undefined, crop: undefined };
6
9
  export function parseImageDimensions(alt) {
7
10
  const lastPipe = boundedLastPipe(alt);
8
11
  const dims = lastPipe === -1 ? null : parseDimensionSuffix(alt.slice(lastPipe + 1));
9
12
  if (dims === null)
10
- return { displayAlt: alt, width: undefined, height: undefined };
11
- return { displayAlt: alt.slice(0, lastPipe), width: dims.width, height: dims.height };
13
+ return { displayAlt: alt, ...NO_HINT };
14
+ return { displayAlt: alt.slice(0, lastPipe), ...dims };
12
15
  }
13
16
  function boundedLastPipe(alt) {
14
17
  const floor = alt.length > MAX_SUFFIX_SEARCH ? alt.length - MAX_SUFFIX_SEARCH : 0;
@@ -24,16 +27,49 @@ function parseDimensionSuffix(s) {
24
27
  const w = parseStrictInt(s);
25
28
  if (w === null)
26
29
  return null;
27
- return { width: w, height: undefined };
30
+ return { width: w, height: undefined, crop: undefined };
28
31
  }
32
+ const atIdx = s.indexOf('@');
33
+ const heightEnd = atIdx === -1 ? s.length : atIdx;
29
34
  const w = parseStrictInt(s.slice(0, xIdx));
30
- const h = parseStrictInt(s.slice(xIdx + 1));
35
+ const h = parseStrictInt(s.slice(xIdx + 1, heightEnd));
31
36
  if (w === null || h === null)
32
37
  return null;
33
- return { width: w, height: h };
38
+ if (atIdx === -1)
39
+ return { width: w, height: h, crop: undefined };
40
+ const crop = parseCropTail(s.slice(atIdx + 1));
41
+ // A malformed tail is not a hint at all: the whole suffix stays alt text.
42
+ if (crop === null)
43
+ return null;
44
+ return { width: w, height: h, crop };
45
+ }
46
+ /** `X,Y` or `X,Y,Z`: whole percents 0–100 and a zoom of 1–4 with up to two decimals. */
47
+ function parseCropTail(s) {
48
+ const parts = s.split(',');
49
+ if (parts.length < 2 || parts.length > 3)
50
+ return null;
51
+ const x = parsePercent(parts[0]);
52
+ const y = parsePercent(parts[1]);
53
+ if (x === null || y === null)
54
+ return null;
55
+ if (parts.length === 2)
56
+ return { x, y, z: 1 };
57
+ if (!/^[1-4](?:\.\d{1,2})?$/.test(parts[2]))
58
+ return null;
59
+ const z = Number(parts[2]);
60
+ if (z > MAX_CROP_ZOOM)
61
+ return null;
62
+ return { x, y, z };
63
+ }
64
+ function parsePercent(s) {
65
+ if (!/^\d{1,3}$/.test(s))
66
+ return null;
67
+ const n = Number(s);
68
+ return n > 100 ? null : n;
34
69
  }
70
+ /** No leading zeros: a padded run is alt text, not a hint. */
35
71
  function parseStrictInt(s) {
36
- if (s.length === 0 || !/^\d+$/.test(s))
72
+ if (!/^[1-9]\d*$/.test(s))
37
73
  return null;
38
74
  const n = Number(s);
39
75
  if (n <= 0 || n > MAX_DIMENSION)
@@ -67,6 +67,17 @@ export declare const ON_EDGE_POLICIES: readonly ["select", "step-over"];
67
67
  */
68
68
  export interface InlineWidgetEditingPolicy {
69
69
  revealSource?: boolean;
70
+ /**
71
+ * Where this kind's editable CONTENT sits inside its source span, as offsets relative to
72
+ * that span — `$x$` answers `{ start: 1, end: 2 }`. A click that reveals the source seats
73
+ * the caret at `end`, so typing continues the construct instead of escaping past its
74
+ * closing delimiter. Only the kind knows its own delimiters; absent, the caret keeps the
75
+ * leading edge.
76
+ */
77
+ revealContentSpan?: (source: string) => {
78
+ start: number;
79
+ end: number;
80
+ } | null;
70
81
  deleteGranularity?: (typeof DELETE_GRANULARITIES)[number];
71
82
  onEdge?: (typeof ON_EDGE_POLICIES)[number];
72
83
  onSelectedKey?: (e: KeyboardEvent, ctx: InlineWidgetEditingContext) => boolean;
@@ -112,6 +112,7 @@ function buildImage(raw, opener, labelEnd, target, children) {
112
112
  ...(target.title !== undefined ? { title: target.title } : {}),
113
113
  ...(dims.width !== undefined ? { width: dims.width } : {}),
114
114
  ...(dims.height !== undefined ? { height: dims.height } : {}),
115
+ ...(dims.crop !== undefined ? { crop: dims.crop } : {}),
115
116
  ...(target.label !== undefined ? { label: target.label } : {})
116
117
  };
117
118
  }
@@ -38,6 +38,12 @@ export interface InlineSyntaxOptions {
38
38
  * commit's equality guard. See the plugin guide's inline section.
39
39
  */
40
40
  rewriteImage?: ImageSyntaxRewriter;
41
+ /**
42
+ * The trigger is a symmetric one-byte delimiter (`$…$`) a keystroke should close at once:
43
+ * typing it lands its twin after the caret, so the new opener pairs with that twin rather
44
+ * than with a later formula's delimiter (see `delimiter-autopair.ts`). Bare triggers only.
45
+ */
46
+ autoPair?: boolean;
41
47
  }
42
48
  export interface InlineRung extends InlineSyntaxClaim {
43
49
  recognizer: InlineSyntaxRecognizer;
@@ -57,6 +63,8 @@ export declare function hasInlineSyntax(): boolean;
57
63
  /** Read once per scan, so an empty set leaves `needsScan` at its pre-plugin cost. */
58
64
  export declare function hasScanProbeRungs(): boolean;
59
65
  export declare function isScanProbeTrigger(char: string): boolean;
66
+ /** Whether a plugin asked for `char` to close itself as it is typed. */
67
+ export declare function isAutoPairTrigger(char: string): boolean;
60
68
  /** False costs the scan loop nothing. */
61
69
  export declare function hasPrefixRungs(): boolean;
62
70
  export declare function __resetInlineSyntaxForTests(): void;
@@ -36,6 +36,9 @@ const NO_RUNGS = [];
36
36
  // (`default` arm) so each dispatch path reads one map and its empty gate is one `size` check.
37
37
  const reservedRegistry = new Map();
38
38
  const unreservedRegistry = new Map();
39
+ // Triggers whose keystroke closes itself (`autoPair`); the built-in backtick is not registered
40
+ // here, the typing seam knows it on its own.
41
+ const autoPairTriggers = new Set();
39
42
  // Triggers the fast bail (`needsScan`, scan/index.ts) must visit while a rung lives on them.
40
43
  // Maintained at registration, so a rung on a trigger `SPECIAL_CHARS` already visits costs nothing.
41
44
  const scanProbeTriggers = new Set();
@@ -44,7 +47,7 @@ export function registerInlineSyntax(trigger, recognizer, options) {
44
47
  if (trigger.length !== 1) {
45
48
  throw new Error('registerInlineSyntax: trigger must be a single character');
46
49
  }
47
- const { prefix, priority = INLINE_PRIORITIES.plugin, rewriteImage } = options ?? {};
50
+ const { prefix, priority = INLINE_PRIORITIES.plugin, rewriteImage, autoPair } = options ?? {};
48
51
  if (prefix !== undefined && (prefix.length < 2 || !prefix.startsWith(trigger))) {
49
52
  throw new Error(`registerInlineSyntax: prefix ${JSON.stringify(prefix)} must begin with the trigger ` +
50
53
  `${JSON.stringify(trigger)} and be at least two characters`);
@@ -69,6 +72,10 @@ export function registerInlineSyntax(trigger, recognizer, options) {
69
72
  `the built-in scanner; got ${priority}`);
70
73
  }
71
74
  }
75
+ if (autoPair && prefix !== undefined) {
76
+ throw new Error(`registerInlineSyntax: autoPair pairs the bare trigger ${JSON.stringify(trigger)} with ` +
77
+ `itself, so it cannot be combined with a prefix`);
78
+ }
72
79
  const effectivePrefix = prefix ?? trigger;
73
80
  const registry = reserved ? reservedRegistry : unreservedRegistry;
74
81
  const existing = registry.get(trigger);
@@ -84,6 +91,8 @@ export function registerInlineSyntax(trigger, recognizer, options) {
84
91
  // or the recognizer is the silent no-op this seam refuses to accept.
85
92
  if (!reserved || SCAN_PROBED_RESERVED.has(trigger))
86
93
  scanProbeTriggers.add(trigger);
94
+ if (autoPair)
95
+ autoPairTriggers.add(trigger);
87
96
  }, `registerInlineSyntax: ${JSON.stringify(trigger)} already registered at prefix ` +
88
97
  `${JSON.stringify(effectivePrefix)}, priority ${priority}`);
89
98
  }
@@ -139,6 +148,10 @@ export function hasScanProbeRungs() {
139
148
  export function isScanProbeTrigger(char) {
140
149
  return scanProbeTriggers.has(char);
141
150
  }
151
+ /** Whether a plugin asked for `char` to close itself as it is typed. */
152
+ export function isAutoPairTrigger(char) {
153
+ return autoPairTriggers.has(char);
154
+ }
142
155
  /** False costs the scan loop nothing. */
143
156
  export function hasPrefixRungs() {
144
157
  return reservedRegistry.size > 0;
@@ -147,4 +160,5 @@ export function __resetInlineSyntaxForTests() {
147
160
  reservedRegistry.clear();
148
161
  unreservedRegistry.clear();
149
162
  scanProbeTriggers.clear();
163
+ autoPairTriggers.clear();
150
164
  }
@@ -25,6 +25,12 @@ export interface RenderInlineOptions {
25
25
  * as `buildImageWidget`; absent or null falls the widget back to its raw source.
26
26
  */
27
27
  buildPortalWidget?: (node: InlineNode, raw: string) => HTMLElement | null;
28
+ /**
29
+ * Paint a lone backslash ending the display as the hard break it is about to become: the
30
+ * byte as a (hidden) marker plus two `br` anchors so the caret has a second line to sit on.
31
+ * Only for a mode that hides markers; the DOM stays byte-identical elsewhere.
32
+ */
33
+ pendingBreakSeat?: boolean;
28
34
  /**
29
35
  * Stamp marker spans with the construct's raw range, so preview-inline's reveal trigger can
30
36
  * address them. Attributes only, leaving textContent and the offset walk untouched. Off by
@@ -288,8 +288,40 @@ export function renderInlineNodes(nodes, raw, opts = {}) {
288
288
  if (child !== null)
289
289
  stack.push(child);
290
290
  }
291
+ if (opts.pendingBreakSeat)
292
+ paintPendingBreak(nodes, raw, root.content);
291
293
  return root.content;
292
294
  }
295
+ /**
296
+ * `insertHardBreak` at the end of a block writes `\` whose line ending IS the block's trailing
297
+ * one, so until the next key supplies the following line CommonMark (and the scanner) read the
298
+ * byte as literal text. Left as text it shows as a stray backslash and the caret stays on the
299
+ * first line; painted like this the user sees the new line they asked for. A `br` adds no
300
+ * textContent, so G1.28 holds; the walk seats offset `length` after the first anchor.
301
+ */
302
+ function paintPendingBreak(nodes, raw, frag) {
303
+ const last = nodes[nodes.length - 1];
304
+ if (!last || last.kind !== 'text' || raw[last.end - 1] !== '\\')
305
+ return;
306
+ const rest = raw.slice(last.end);
307
+ if (rest !== '' && rest !== '\n' && rest !== '\r\n')
308
+ return;
309
+ const tail = frag.lastChild;
310
+ if (!tail || tail.nodeType !== Node.TEXT_NODE || !tail.textContent?.endsWith('\\'))
311
+ return;
312
+ if (tail.textContent.length === 1)
313
+ tail.remove();
314
+ else
315
+ tail.textContent = tail.textContent.slice(0, -1);
316
+ const marker = markerSpan('\\');
317
+ marker.classList.add('md-break-pending');
318
+ frag.appendChild(marker);
319
+ for (let i = 0; i < 2; i++) {
320
+ const anchor = document.createElement('br');
321
+ anchor.dataset.caretAnchor = 'break';
322
+ frag.appendChild(anchor);
323
+ }
324
+ }
293
325
  /**
294
326
  * The leaf containing `offset`, preferring the right node at a boundary; `offset === end` only
295
327
  * matches the last node. Model-layer, touching no DOM: the DOM counterpart is
@@ -259,6 +259,8 @@ export interface InlineNode {
259
259
  decoded?: string;
260
260
  width?: number;
261
261
  height?: number;
262
+ /** Pan/zoom inside the `|WxH` frame, read from a `@X,Y[,Z]` tail on the hint. */
263
+ crop?: ImageCrop;
262
264
  /** Discriminator for `unresolvedReference` nodes: which form they would have been. */
263
265
  refKind?: 'link' | 'image';
264
266
  /**
@@ -271,12 +273,24 @@ export interface InlineNode {
271
273
  * Optional keys are omitted rather than set to `undefined`, so a serializer can tell "no title"
272
274
  * from "empty title" and reproduce a node that never carried one.
273
275
  */
276
+ /**
277
+ * How an image sits inside its `|WxH` frame: the image point (`x`%, `y`%) pinned to the same
278
+ * point of the frame, at `z` times the smallest scale that fills it. `{50, 50, 1}` is a plain
279
+ * centred cover fit; absent, the frame stretches the image the way `<img width height>` does.
280
+ */
281
+ export interface ImageCrop {
282
+ x: number;
283
+ y: number;
284
+ z: number;
285
+ }
274
286
  export interface ImageFields {
275
287
  alt: string;
276
288
  url: string;
277
289
  title?: string;
278
290
  width?: number;
279
291
  height?: number;
292
+ /** Needs both dimensions: a crop is a window into a fixed frame. */
293
+ crop?: ImageCrop;
280
294
  /**
281
295
  * Reference-style images only (`![alt][label]`). When set, the serializer emits the reference
282
296
  * form and writes no url/title: those live in the LRD, and inlining them would orphan it.
@@ -39,7 +39,8 @@ export function classifyArrivalKey(key, metaKey = false) {
39
39
  if (metaKey && (key === 'ArrowLeft' || key === 'ArrowRight'))
40
40
  return 'outside';
41
41
  // A step stops on the side of the run it approached from, so one press never changes which
42
- // construct the caret is in: forward keys reach the near side, backward keys the far one.
42
+ // construct the caret is in (live-mode.md § 4.2); leaving a construct is a typed closer's job
43
+ // (delimiter-autopair.ts) or a toggle's, not the arrow's.
43
44
  if (key === 'ArrowRight' || key === 'ArrowDown' || key === 'PageDown')
44
45
  return 'near';
45
46
  if (key === 'ArrowLeft' || key === 'ArrowUp' || key === 'PageUp')