@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
@@ -1,52 +1,66 @@
1
1
  <script lang="ts">
2
2
  import { untrack } from 'svelte';
3
- import type { ImageFields } from '../../core/nodes';
3
+ import type { ImageCrop, ImageFields } from '../../core/nodes';
4
4
  import { IMAGE_CHROME_SELECTOR, type WidgetTarget } from './widget-selection-state.svelte';
5
- import { IMAGE_PROPERTIES_LABEL } from '../../a11y-strings';
5
+ import {
6
+ IMAGE_ALT_FIELD,
7
+ IMAGE_ALT_PLACEHOLDER,
8
+ IMAGE_CROP,
9
+ IMAGE_CROP_APPLY,
10
+ IMAGE_CROP_CANCEL,
11
+ IMAGE_PROPERTIES_LABEL,
12
+ IMAGE_REMOVE
13
+ } from '../../a11y-strings';
14
+ import MenuIcon from '../menu/MenuIcon.svelte';
15
+ import { DEFAULT_CROP, applyCropToWidget, panCrop, zoomCrop, type Size } from './image-crop';
6
16
 
7
17
  let {
8
18
  target,
9
19
  fields,
20
+ getWidgetEl,
10
21
  buildBytes,
11
22
  onCommit,
12
- onDismiss
23
+ onRemove,
24
+ onDismiss,
25
+ maxFrameWidth,
26
+ cropping = $bindable(false)
13
27
  }: {
14
28
  target: WidgetTarget;
15
29
  fields: ImageFields;
30
+ getWidgetEl: () => HTMLElement | null;
16
31
  /** The popover's dirty check is a byte comparison, and the bytes are the write
17
32
  * seam's to decide. */
18
33
  buildBytes: (target: WidgetTarget, fields: ImageFields) => string | null;
19
34
  onCommit: (target: WidgetTarget, newFields: ImageFields) => void;
35
+ onRemove: (target: WidgetTarget) => void;
20
36
  onDismiss: () => void;
37
+ /** The column width: the widest frame a corner drag can open. */
38
+ maxFrameWidth: () => number;
39
+ cropping?: boolean;
21
40
  } = $props();
22
41
 
23
- let url = $state(untrack(() => fields.url));
42
+ // The URL has no field: a user who wants to retarget an image does it in source mode.
24
43
  let alt = $state(untrack(() => fields.alt));
25
- let titleInput = $state(untrack(() => fields.title ?? ''));
26
- let titleTouched = $state(false);
27
- let popoverEl: HTMLDivElement | undefined = $state();
44
+ let fieldOpen = $state(false);
45
+ let rootEl: HTMLDivElement | undefined = $state();
46
+ let fieldInput: HTMLInputElement | undefined = $state();
28
47
 
29
48
  // The seed is the BYTES, not the fields object: a rebuild mints a fresh one per render, so
30
49
  // identity says nothing about whether the image moved.
31
50
  let seedBytes = $state(untrack(() => buildBytes(target, fields)));
32
51
 
33
- // The popover follows the document while it is open: an undo — or any write landing from
52
+ // The surface follows the document while it is open: an undo — or any write landing from
34
53
  // outside this gesture — moves the image past the draft, and the dismiss commit would put the
35
54
  // old bytes back. The in-flight draft is discarded rather than a committed change reverted.
36
55
  $effect(() => {
37
56
  const live = buildBytes(target, fields);
38
57
  if (live === seedBytes) return;
39
58
  seedBytes = live;
40
- url = fields.url;
41
59
  alt = fields.alt;
42
- titleInput = fields.title ?? '';
43
- titleTouched = false;
44
60
  });
45
61
 
46
- // The commit runs in $effect cleanup so dismiss, image-switch (key change) and
47
- // programmatic clear all commit through one seam.
48
62
  $effect(() => {
49
- if (!popoverEl) return;
63
+ if (!rootEl) return;
50
64
  const handler = (e: PointerEvent) => {
51
65
  const target = e.target as Element | null;
52
66
  if (target?.closest(IMAGE_CHROME_SELECTOR)) return;
@@ -56,98 +70,526 @@
56
70
  return () => document.removeEventListener('pointerdown', handler, true);
57
71
  });
58
72
 
73
+ // The commit runs in $effect cleanup so dismiss, image-switch (key change) and
74
+ // programmatic clear all commit through one seam. A crop still open is abandoned, not
75
+ // committed: only the tick writes one.
59
76
  $effect(() => {
60
77
  return () => {
78
+ if (cropping) cancelCrop();
61
79
  commitIfChanged();
62
80
  };
63
81
  });
64
82
 
65
- function onTitleInput() {
66
- titleTouched = true;
67
- }
83
+ $effect(() => {
84
+ if (fieldOpen && fieldInput) fieldInput.focus();
85
+ });
68
86
 
69
- function commitIfChanged() {
70
- const resolvedTitle = titleTouched
71
- ? titleInput.length > 0
72
- ? titleInput
73
- : undefined
74
- : fields.title;
75
- const next: ImageFields = {
87
+ // ── Alt ────────────────────────────────────────────────────────────────────
88
+
89
+ // The title (`![alt](url "title")`) has no field: it is a tooltip nobody reads in an editor,
90
+ // and an existing one rides through every commit untouched, as do the url, frame and crop.
91
+ function draftFields(): ImageFields {
92
+ return {
76
93
  alt,
77
- url,
78
- ...(resolvedTitle !== undefined ? { title: resolvedTitle } : {}),
94
+ url: fields.url,
95
+ ...(fields.title !== undefined ? { title: fields.title } : {}),
79
96
  ...(fields.width !== undefined ? { width: fields.width } : {}),
80
- ...(fields.height !== undefined ? { height: fields.height } : {})
97
+ ...(fields.height !== undefined ? { height: fields.height } : {}),
98
+ ...(fields.crop !== undefined ? { crop: fields.crop } : {})
81
99
  };
100
+ }
101
+
102
+ function commitIfChanged() {
103
+ const next = draftFields();
82
104
  const newBytes = buildBytes(target, next);
83
105
  if (newBytes === seedBytes) return;
106
+ // Seeded now rather than when the write lands, so a blur and the unmount that follows
107
+ // it cannot commit the same draft twice.
108
+ seedBytes = newBytes;
84
109
  onCommit(target, next);
85
110
  }
86
111
 
87
- function handleKeyDown(e: KeyboardEvent) {
88
- if (e.key === 'Escape') {
112
+ function toggleField() {
113
+ if (fieldOpen) closeField(true);
114
+ else fieldOpen = true;
115
+ }
116
+
117
+ function closeField(commit: boolean) {
118
+ if (commit) commitIfChanged();
119
+ else alt = fields.alt;
120
+ fieldOpen = false;
121
+ }
122
+
123
+ function onFieldKeydown(e: KeyboardEvent) {
124
+ if (e.key === 'Enter') {
125
+ e.preventDefault();
126
+ closeField(true);
127
+ } else if (e.key === 'Escape') {
128
+ e.preventDefault();
129
+ e.stopPropagation();
130
+ closeField(false);
131
+ }
132
+ }
133
+
134
+ // ── Crop ───────────────────────────────────────────────────────────────────
135
+ // limestone's cover crop: drag pans, the wheel zooms, corner brackets mark the frame, and
136
+ // the tick writes it. The frame is the box the image has right now, so an unframed image
137
+ // gains `|WxH` on its first crop.
138
+
139
+ let draft = $state<ImageCrop>(DEFAULT_CROP);
140
+ let frame: Size = { width: 0, height: 0 };
141
+ let natural: Size = { width: 0, height: 0 };
142
+ let snapshot: {
143
+ widget: HTMLElement;
144
+ img: HTMLImageElement;
145
+ widgetStyle: string | null;
146
+ imgStyle: string | null;
147
+ cropped: boolean;
148
+ } | null = null;
149
+ let lastPointer: { x: number; y: number } | null = null;
150
+
151
+ function startCrop() {
152
+ const widget = getWidgetEl();
153
+ const img = widget?.querySelector('img') ?? null;
154
+ if (!widget || !img || img.naturalWidth === 0) return;
155
+ if (fieldOpen) closeField(true);
156
+ const box = widget.getBoundingClientRect();
157
+ frame = { width: box.width, height: box.height };
158
+ natural = { width: img.naturalWidth, height: img.naturalHeight };
159
+ snapshot = {
160
+ widget,
161
+ img,
162
+ widgetStyle: widget.getAttribute('style'),
163
+ imgStyle: img.getAttribute('style'),
164
+ cropped: widget.classList.contains('md-image-cropped')
165
+ };
166
+ draft = fields.crop ? { ...fields.crop } : { ...DEFAULT_CROP };
167
+ applyCropToWidget(widget, img, frame, draft);
168
+ cropping = true;
169
+ }
170
+
171
+ function paintDraft() {
172
+ if (snapshot) applyCropToWidget(snapshot.widget, snapshot.img, frame, draft);
173
+ }
174
+
175
+ function restoreSnapshot() {
176
+ if (!snapshot) return;
177
+ const { widget, img, widgetStyle, imgStyle, cropped } = snapshot;
178
+ if (widget.isConnected) {
179
+ setStyle(widget, widgetStyle);
180
+ setStyle(img, imgStyle);
181
+ widget.classList.toggle('md-image-cropped', cropped);
182
+ }
183
+ snapshot = null;
184
+ }
185
+
186
+ function setStyle(el: HTMLElement, style: string | null) {
187
+ if (style === null) el.removeAttribute('style');
188
+ else el.setAttribute('style', style);
189
+ }
190
+
191
+ function cancelCrop() {
192
+ restoreSnapshot();
193
+ cropping = false;
194
+ }
195
+
196
+ function applyCrop() {
197
+ const next: ImageFields = {
198
+ ...draftFields(),
199
+ width: Math.round(frame.width),
200
+ height: Math.round(frame.height),
201
+ crop: draft
202
+ };
203
+ // The commit rebuilds the widget from the bytes; the preview styles go with the old DOM.
204
+ snapshot = null;
205
+ cropping = false;
206
+ seedBytes = buildBytes(target, next);
207
+ onCommit(target, next);
208
+ }
209
+
210
+ function onCropPointerDown(e: PointerEvent) {
211
+ if (e.button !== 0) return;
212
+ e.preventDefault();
213
+ (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
214
+ lastPointer = { x: e.clientX, y: e.clientY };
215
+ }
216
+
217
+ function onCropPointerMove(e: PointerEvent) {
218
+ if (!lastPointer) return;
219
+ draft = panCrop(draft, e.clientX - lastPointer.x, e.clientY - lastPointer.y, frame, natural);
220
+ lastPointer = { x: e.clientX, y: e.clientY };
221
+ paintDraft();
222
+ }
223
+
224
+ function onCropPointerUp() {
225
+ lastPointer = null;
226
+ }
227
+
228
+ function onCropWheel(e: WheelEvent) {
229
+ e.preventDefault();
230
+ draft = zoomCrop(draft, e.deltaY);
231
+ paintDraft();
232
+ }
233
+
234
+ // The corner brackets resize the frame itself, which is what changes its aspect. The frame
235
+ // is anchored where the image sits in the flow, so every corner moves the far edges: a
236
+ // left-hand corner dragged inward shrinks the width the way the right-hand one dragged
237
+ // inward does.
238
+ type Corner = 'tl' | 'tr' | 'bl' | 'br';
239
+ const MIN_FRAME = 32;
240
+ let cornerDrag: { corner: Corner; startX: number; startY: number; start: Size } | null = null;
241
+
242
+ function onCornerPointerDown(e: PointerEvent, corner: Corner) {
243
+ if (e.button !== 0) return;
244
+ e.preventDefault();
245
+ e.stopPropagation();
246
+ (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
247
+ cornerDrag = { corner, startX: e.clientX, startY: e.clientY, start: { ...frame } };
248
+ }
249
+
250
+ function onCornerPointerMove(e: PointerEvent) {
251
+ if (!cornerDrag) return;
252
+ const { corner, startX, startY, start } = cornerDrag;
253
+ const dx = (e.clientX - startX) * (corner === 'tl' || corner === 'bl' ? -1 : 1);
254
+ const dy = (e.clientY - startY) * (corner === 'tl' || corner === 'tr' ? -1 : 1);
255
+ const maxWidth = Math.max(MIN_FRAME, maxFrameWidth());
256
+ frame = {
257
+ width: Math.min(maxWidth, Math.max(MIN_FRAME, start.width + dx)),
258
+ height: Math.max(MIN_FRAME, start.height + dy)
259
+ };
260
+ paintDraft();
261
+ }
262
+
263
+ function onCornerPointerUp(e: PointerEvent) {
264
+ if (!cornerDrag) return;
265
+ e.stopPropagation();
266
+ cornerDrag = null;
267
+ }
268
+
269
+ // A double click on the picture is the crop gesture: the first click selects the image, so
270
+ // by the second the toolbar (and this listener) is already mounted. On the document, since
271
+ // each commit rebuilds the widget and a listener bound to one goes stale with it.
272
+ $effect(() => {
273
+ const onDoubleClick = (e: MouseEvent) => {
274
+ if (cropping) return;
275
+ const widget = getWidgetEl();
276
+ if (!widget || !(e.target instanceof Node) || !widget.contains(e.target)) return;
277
+ e.preventDefault();
278
+ // The double click leaves a native range across the widget; a crop is not a text
279
+ // gesture, so it goes.
280
+ document.getSelection()?.removeAllRanges();
281
+ startCrop();
282
+ };
283
+ document.addEventListener('dblclick', onDoubleClick);
284
+ return () => document.removeEventListener('dblclick', onDoubleClick);
285
+ });
286
+
287
+ $effect(() => {
288
+ if (!cropping) return;
289
+ const onKey = (e: KeyboardEvent) => {
290
+ if (e.key === 'Escape') {
291
+ e.preventDefault();
292
+ cancelCrop();
293
+ } else if (e.key === 'Enter') {
294
+ e.preventDefault();
295
+ applyCrop();
296
+ }
297
+ };
298
+ document.addEventListener('keydown', onKey, true);
299
+ return () => document.removeEventListener('keydown', onKey, true);
300
+ });
301
+
302
+ // ── Placement ──────────────────────────────────────────────────────────────
303
+
304
+ /** Beside the image, level with its top, when the viewport has room there; otherwise a row
305
+ * inside the image's top-right corner, clamped to the viewport when the image itself runs
306
+ * past it. Re-measured whenever the overlay re-anchors. */
307
+ function keepBesideImage(node: HTMLElement): (() => void) | void {
308
+ const place = () => {
309
+ node.classList.remove('inside');
310
+ node.style.left = '';
311
+ const overlay = node.parentElement?.getBoundingClientRect();
312
+ if (!overlay || node.getBoundingClientRect().right <= window.innerWidth - 8) return;
313
+ node.classList.add('inside');
314
+ const rightLimit = Math.min(overlay.right, window.innerWidth) - 8;
315
+ const width = node.getBoundingClientRect().width;
316
+ node.style.left = `${Math.max(0, rightLimit - width - overlay.left)}px`;
317
+ };
318
+ place();
319
+ const overlay = node.parentElement;
320
+ const observer = overlay ? new MutationObserver(place) : null;
321
+ observer?.observe(overlay!, { attributes: true, attributeFilter: ['style'] });
322
+ window.addEventListener('resize', place);
323
+ return () => {
324
+ observer?.disconnect();
325
+ window.removeEventListener('resize', place);
326
+ };
327
+ }
328
+
329
+ /** The field hangs off the toolbar's far side; with no room there it hangs off the near one. */
330
+ function keepFieldOnScreen(node: HTMLElement): void {
331
+ const rect = node.getBoundingClientRect();
332
+ if (rect.right > window.innerWidth - 8) node.classList.add('flipped');
333
+ }
334
+
335
+ function onRootKeydown(e: KeyboardEvent) {
336
+ if (e.key === 'Escape' && !fieldOpen && !cropping) {
89
337
  e.preventDefault();
90
- // Esc is cancel: discard local edits so the unmount commit short-circuits on
91
- // byte-equality rather than persisting in-flight typing.
92
- url = fields.url;
93
- alt = fields.alt;
94
- titleInput = fields.title ?? '';
95
- titleTouched = false;
96
338
  onDismiss();
97
339
  }
98
340
  }
99
341
  </script>
100
342
 
343
+ {#if cropping}
344
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
345
+ <div
346
+ class="md-image-crop-surface"
347
+ onpointerdown={onCropPointerDown}
348
+ onpointermove={onCropPointerMove}
349
+ onpointerup={onCropPointerUp}
350
+ onpointercancel={onCropPointerUp}
351
+ onwheel={onCropWheel}
352
+ >
353
+ {#each ['tl', 'tr', 'bl', 'br'] as const as corner (corner)}
354
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
355
+ <span
356
+ class="md-image-crop-corner {corner}"
357
+ data-crop-corner={corner}
358
+ onpointerdown={(e) => onCornerPointerDown(e, corner)}
359
+ onpointermove={onCornerPointerMove}
360
+ onpointerup={onCornerPointerUp}
361
+ onpointercancel={onCornerPointerUp}
362
+ ></span>
363
+ {/each}
364
+ </div>
365
+ {/if}
366
+
101
367
  <div
102
- bind:this={popoverEl}
368
+ bind:this={rootEl}
103
369
  class="md-image-properties"
104
- role="dialog"
370
+ role="toolbar"
105
371
  aria-label={IMAGE_PROPERTIES_LABEL}
106
372
  tabindex="-1"
107
- onkeydown={handleKeyDown}
373
+ onkeydown={onRootKeydown}
374
+ {@attach keepBesideImage}
108
375
  >
109
- <label>
110
- <span>URL</span>
111
- <input type="text" bind:value={url} />
112
- </label>
113
- <label>
114
- <span>Alt</span>
115
- <input type="text" bind:value={alt} />
116
- </label>
117
- <label>
118
- <span>Title</span>
119
- <input type="text" bind:value={titleInput} oninput={onTitleInput} />
120
- </label>
376
+ {#if cropping}
377
+ <button
378
+ type="button"
379
+ class="md-image-btn"
380
+ aria-label={IMAGE_CROP_APPLY}
381
+ title={IMAGE_CROP_APPLY}
382
+ onclick={applyCrop}
383
+ >
384
+ <MenuIcon name="check" />
385
+ </button>
386
+ <button
387
+ type="button"
388
+ class="md-image-btn"
389
+ aria-label={IMAGE_CROP_CANCEL}
390
+ title={IMAGE_CROP_CANCEL}
391
+ onclick={cancelCrop}
392
+ >
393
+ <MenuIcon name="x" />
394
+ </button>
395
+ {:else}
396
+ <button
397
+ type="button"
398
+ class="md-image-btn"
399
+ class:active={fieldOpen}
400
+ class:set={alt.length > 0}
401
+ aria-label={IMAGE_ALT_FIELD}
402
+ aria-pressed={fieldOpen}
403
+ title={IMAGE_ALT_FIELD}
404
+ onclick={toggleField}
405
+ >
406
+ <MenuIcon name="captions" />
407
+ </button>
408
+ <button
409
+ type="button"
410
+ class="md-image-btn"
411
+ aria-label={IMAGE_CROP}
412
+ title="{IMAGE_CROP} — or double-click it"
413
+ onclick={startCrop}
414
+ >
415
+ <MenuIcon name="crop" />
416
+ </button>
417
+ <button
418
+ type="button"
419
+ class="md-image-btn"
420
+ aria-label={IMAGE_REMOVE}
421
+ title={IMAGE_REMOVE}
422
+ onclick={() => onRemove(target)}
423
+ >
424
+ <MenuIcon name="trash" />
425
+ </button>
426
+ {/if}
427
+ {#if fieldOpen}
428
+ <label class="md-image-field" {@attach keepFieldOnScreen}>
429
+ <span class="md-image-field-label">Alt</span>
430
+ <input
431
+ type="text"
432
+ bind:this={fieldInput}
433
+ bind:value={alt}
434
+ aria-label={IMAGE_ALT_FIELD}
435
+ placeholder={IMAGE_ALT_PLACEHOLDER}
436
+ onkeydown={onFieldKeydown}
437
+ />
438
+ </label>
439
+ {/if}
121
440
  </div>
122
441
 
123
442
  <style>
443
+ /* limestone's cover actions: a stack of small surface buttons beside the image, level with
444
+ its top. `inside` (no room beside) lays them as a row in the image's top-right corner. */
124
445
  .md-image-properties {
125
446
  position: absolute;
126
- top: calc(100% + 4px);
127
- left: 0;
128
- background: var(--color-bg-elevated, #2a2a2a);
129
- border: 1px solid var(--color-ui-muted, #a4a4a4);
130
- border-radius: 4px;
131
- padding: 8px;
132
- display: grid;
133
- gap: 6px;
134
- min-width: 280px;
447
+ top: 0;
448
+ left: calc(100% + 8px);
449
+ display: flex;
450
+ flex-direction: column;
451
+ gap: 4px;
135
452
  z-index: 100;
136
- box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
453
+ outline: none;
454
+ font-family: var(--font-ui, system-ui, sans-serif);
137
455
  }
138
- label {
139
- display: grid;
140
- grid-template-columns: 60px 1fr;
456
+ /* `inside` is the placement attachment's own class, written outside the template. */
457
+ .md-image-properties:global(.inside) {
458
+ top: 8px;
459
+ flex-direction: row;
460
+ }
461
+ .md-image-btn {
462
+ display: inline-flex;
141
463
  align-items: center;
142
- gap: 8px;
464
+ justify-content: center;
465
+ width: 26px;
466
+ height: 26px;
467
+ padding: 0;
468
+ border: none;
469
+ border-radius: 6px;
470
+ background: var(--color-bg, #2c2c2a);
471
+ color: var(--color-text-secondary, #cfcfca);
472
+ cursor: pointer;
473
+ box-shadow: var(--menu-shadow, 0 12px 32px rgba(0, 0, 0, 0.5), 0 2px 8px rgba(0, 0, 0, 0.35));
474
+ }
475
+ .md-image-btn:hover,
476
+ .md-image-btn.active,
477
+ .md-image-btn.set {
478
+ color: var(--color-text-primary, #e8e8e5);
143
479
  }
144
- input {
480
+ .md-image-btn.active {
145
481
  background: var(--color-surface, #2d3033);
146
- color: var(--color-text-secondary, #eee);
147
- border: 1px solid var(--color-ui-muted, #a4a4a4);
148
- padding: 4px 6px;
149
- border-radius: var(--radius-ui, 3px);
482
+ }
483
+
484
+ /* The alt field, hung off the toolbar's far side on the menu surface: a caption over its
485
+ input, the way a properties row reads, rather than a label beside a box. */
486
+ .md-image-field {
487
+ position: absolute;
488
+ top: 0;
489
+ left: calc(100% + 6px);
490
+ width: 240px;
491
+ padding: 8px 10px 10px;
492
+ box-sizing: border-box;
493
+ display: flex;
494
+ flex-direction: column;
495
+ gap: 5px;
496
+ background: var(--color-bg, #2c2c2a);
497
+ border: 1px solid var(--color-border, #3e3e3b);
498
+ border-radius: 8px;
499
+ box-shadow: var(--menu-shadow, 0 12px 32px rgba(0, 0, 0, 0.5), 0 2px 8px rgba(0, 0, 0, 0.35));
500
+ }
501
+ .md-image-field.flipped {
502
+ left: auto;
503
+ right: calc(100% + 6px);
504
+ }
505
+ .md-image-properties:global(.inside) .md-image-field {
506
+ left: auto;
507
+ right: 0;
508
+ top: calc(100% + 6px);
509
+ }
510
+ .md-image-field-label {
511
+ font-size: 10.5px;
512
+ font-weight: 600;
513
+ letter-spacing: 0.06em;
514
+ text-transform: uppercase;
515
+ color: var(--color-text-muted, #aaaaaa);
516
+ }
517
+
518
+ /* Underlined, not boxed: one rule under the text reads as a field to fill in and keeps the
519
+ surface calm, where a second rounded box inside a rounded card reads as chrome on chrome. */
520
+ .md-image-field input {
521
+ width: 100%;
522
+ box-sizing: border-box;
523
+ background: transparent;
524
+ color: var(--color-text-primary, #e8e8e5);
525
+ border: none;
526
+ border-bottom: 1px solid var(--color-border, #3e3e3b);
527
+ border-radius: 0;
528
+ padding: 3px 1px 5px;
150
529
  font-family: inherit;
151
- font-size: 12px;
530
+ font-size: 13px;
531
+ line-height: 1.4;
532
+ outline: none;
533
+ transition: border-color 120ms ease-out;
534
+ }
535
+ .md-image-field input:focus {
536
+ border-bottom-color: var(--color-accent, #567b67);
537
+ }
538
+ .md-image-field input::placeholder {
539
+ color: var(--color-text-muted, #aaaaaa);
540
+ }
541
+
542
+ /* The crop session: the whole image is a pan surface, bracketed at the corners. */
543
+ .md-image-crop-surface {
544
+ position: absolute;
545
+ inset: 0;
546
+ cursor: grab;
547
+ touch-action: none;
548
+ z-index: 20;
549
+ }
550
+ .md-image-crop-surface:active {
551
+ cursor: grabbing;
552
+ }
553
+ /* The brackets are the frame's own handles: dragging one resizes the frame. */
554
+ .md-image-crop-corner {
555
+ position: absolute;
556
+ width: 18px;
557
+ height: 18px;
558
+ border: 2px solid rgba(255, 255, 255, 0.9);
559
+ filter: drop-shadow(0 0 1px rgba(0, 0, 0, 0.6));
560
+ touch-action: none;
561
+ }
562
+ .md-image-crop-corner::before {
563
+ content: '';
564
+ position: absolute;
565
+ inset: -8px;
566
+ }
567
+ .md-image-crop-corner.tl {
568
+ top: 8px;
569
+ left: 8px;
570
+ border-right: none;
571
+ border-bottom: none;
572
+ cursor: nwse-resize;
573
+ }
574
+ .md-image-crop-corner.tr {
575
+ top: 8px;
576
+ right: 8px;
577
+ border-left: none;
578
+ border-bottom: none;
579
+ cursor: nesw-resize;
580
+ }
581
+ .md-image-crop-corner.bl {
582
+ bottom: 8px;
583
+ left: 8px;
584
+ border-right: none;
585
+ border-top: none;
586
+ cursor: nesw-resize;
587
+ }
588
+ .md-image-crop-corner.br {
589
+ bottom: 8px;
590
+ right: 8px;
591
+ border-left: none;
592
+ border-top: none;
593
+ cursor: nwse-resize;
152
594
  }
153
595
  </style>
@@ -3,12 +3,17 @@ import { type WidgetTarget } from './widget-selection-state.svelte';
3
3
  type $$ComponentProps = {
4
4
  target: WidgetTarget;
5
5
  fields: ImageFields;
6
+ getWidgetEl: () => HTMLElement | null;
6
7
  /** The popover's dirty check is a byte comparison, and the bytes are the write
7
8
  * seam's to decide. */
8
9
  buildBytes: (target: WidgetTarget, fields: ImageFields) => string | null;
9
10
  onCommit: (target: WidgetTarget, newFields: ImageFields) => void;
11
+ onRemove: (target: WidgetTarget) => void;
10
12
  onDismiss: () => void;
13
+ /** The column width: the widest frame a corner drag can open. */
14
+ maxFrameWidth: () => number;
15
+ cropping?: boolean;
11
16
  };
12
- declare const ImageProperties: import("svelte").Component<$$ComponentProps, {}, "">;
17
+ declare const ImageProperties: import("svelte").Component<$$ComponentProps, {}, "cropping">;
13
18
  type ImageProperties = ReturnType<typeof ImageProperties>;
14
19
  export default ImageProperties;