@bendyline/squisq-editor-react 1.6.2 → 2.0.0

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 (200) hide show
  1. package/README.md +12 -3
  2. package/dist/index.d.ts +1229 -179
  3. package/dist/index.js +18017 -11231
  4. package/dist/index.js.map +1 -1
  5. package/dist/monaco.d.ts +1 -0
  6. package/dist/monaco.js +4 -0
  7. package/dist/monaco.js.map +1 -0
  8. package/dist/styles/index.css +1471 -245
  9. package/package.json +9 -5
  10. package/src/BlockPropertiesPopover.tsx +30 -15
  11. package/src/DocumentSettingsDialog.tsx +13 -21
  12. package/src/EditorContext.tsx +199 -42
  13. package/src/EditorShell.tsx +348 -260
  14. package/src/ImageEditor.tsx +22 -1
  15. package/src/InlinePreviewGutter.tsx +16 -8
  16. package/src/MediaBin.tsx +107 -14
  17. package/src/OutlinePanel.tsx +202 -7
  18. package/src/PlainHtmlPreview.tsx +40 -1
  19. package/src/PreviewControls.tsx +309 -69
  20. package/src/PreviewPanel.tsx +312 -78
  21. package/src/RawEditor.tsx +13 -2
  22. package/src/StatusBar.tsx +5 -2
  23. package/src/TemplateAnnotation.ts +12 -3
  24. package/src/TemplatePicker.tsx +75 -21
  25. package/src/ThemeCustomizerPanel.tsx +8 -0
  26. package/src/ThemePicker.tsx +10 -5
  27. package/src/TimelineBlockPreview.tsx +1 -1
  28. package/src/Toolbar.tsx +202 -41
  29. package/src/TransitionPicker.tsx +36 -4
  30. package/src/ViewMenuPanel.tsx +12 -14
  31. package/src/WysiwygEditor.tsx +124 -91
  32. package/src/__tests__/blockPropertiesPopoverTheme.test.tsx +33 -0
  33. package/src/__tests__/blockTagActivity.test.ts +183 -0
  34. package/src/__tests__/buildDocumentPreviewMarkdown.test.ts +75 -0
  35. package/src/__tests__/buildPreviewDocContent.test.ts +48 -0
  36. package/src/__tests__/buildPreviewDocTransition.test.ts +21 -1
  37. package/src/__tests__/canvasSurfaceTextEditing.test.tsx +60 -0
  38. package/src/__tests__/customLayoutManagerTheme.test.tsx +34 -0
  39. package/src/__tests__/documentSettingsDialog.test.tsx +29 -1
  40. package/src/__tests__/editorScrollHandoff.test.tsx +102 -0
  41. package/src/__tests__/editorScrollSync.test.ts +65 -0
  42. package/src/__tests__/editorShellProps.test.tsx +123 -1
  43. package/src/__tests__/emojiPicker.test.tsx +2 -42
  44. package/src/__tests__/headingTransition.test.ts +0 -12
  45. package/src/__tests__/imageEditAffordance.test.tsx +5 -1
  46. package/src/__tests__/imageEditModalContract.test.tsx +119 -0
  47. package/src/__tests__/imageEditorShell.test.tsx +15 -1
  48. package/src/__tests__/jsonEditor.test.tsx +56 -1
  49. package/src/__tests__/layersPanel.test.tsx +75 -5
  50. package/src/__tests__/majorApiTypes.test.ts +26 -0
  51. package/src/__tests__/mediaBinDrop.test.tsx +90 -0
  52. package/src/__tests__/narrationSave.test.ts +148 -0
  53. package/src/__tests__/outlinePanel.test.tsx +93 -3
  54. package/src/__tests__/outlineSource.test.ts +217 -0
  55. package/src/__tests__/plainHtmlPreview.test.tsx +17 -1
  56. package/src/__tests__/presentationMode.test.tsx +290 -0
  57. package/src/__tests__/previewControls.test.tsx +218 -5
  58. package/src/__tests__/previewPanelAudioFailure.test.tsx +46 -0
  59. package/src/__tests__/previewPanelPresentation.test.tsx +126 -0
  60. package/src/__tests__/previewPanelTransforms.test.tsx +117 -0
  61. package/src/__tests__/rawEditorModelIsolation.test.ts +19 -0
  62. package/src/__tests__/resolveBlockVisual.test.ts +43 -0
  63. package/src/__tests__/statusBar.test.tsx +27 -0
  64. package/src/__tests__/teleprompter.test.tsx +190 -0
  65. package/src/__tests__/templateAnnotationRoundTrip.test.ts +26 -0
  66. package/src/__tests__/templatePickerPortal.test.tsx +62 -0
  67. package/src/__tests__/tiptapBridge.test.ts +23 -0
  68. package/src/__tests__/tiptapCodeBlockRoundTrip.test.ts +109 -0
  69. package/src/__tests__/transformStyleId.test.ts +13 -0
  70. package/src/__tests__/useImageEditor.test.tsx +67 -0
  71. package/src/__tests__/useMediaRecorder.test.ts +75 -0
  72. package/src/__tests__/viewMenuPanel.test.tsx +130 -0
  73. package/src/__tests__/wysiwygImageUpload.test.ts +69 -0
  74. package/src/__tests__/wysiwygTemplateBadgeFocus.test.tsx +56 -0
  75. package/src/asciiDiagram/AsciiDiagramExtension.ts +343 -0
  76. package/src/asciiDiagram/AsciiDiagramWidget.tsx +224 -0
  77. package/src/asciiDiagram/RepairableDiagramExtension.ts +200 -0
  78. package/src/asciiDiagram/__tests__/AsciiDiagramExtension.test.ts +145 -0
  79. package/src/asciiDiagram/__tests__/RepairableDiagramExtension.test.ts +159 -0
  80. package/src/asciiDiagram/__tests__/asciiDiagramCommands.test.ts +227 -0
  81. package/src/asciiDiagram/__tests__/asciiDiagramOps.test.ts +114 -0
  82. package/src/asciiDiagram/__tests__/asciiPaste.test.ts +43 -0
  83. package/src/asciiDiagram/asciiDiagramCommands.ts +148 -0
  84. package/src/asciiDiagram/asciiDiagramData.ts +116 -0
  85. package/src/asciiDiagram/asciiDiagramOps.ts +219 -0
  86. package/src/asciiDiagram/asciiPaste.ts +19 -0
  87. package/src/blockTagActivity.ts +233 -0
  88. package/src/buildDocumentPreviewMarkdown.ts +168 -0
  89. package/src/buildPreviewDoc.ts +19 -12
  90. package/src/customTemplates/CustomLayoutManager.tsx +3 -2
  91. package/src/customTemplates/CustomTemplateContext.tsx +6 -0
  92. package/src/customTemplates/TemplateDesigner.tsx +7 -4
  93. package/src/customTemplates/__tests__/library.test.ts +8 -0
  94. package/src/customTemplates/designer.css +158 -99
  95. package/src/customTemplates/library.ts +16 -3
  96. package/src/customThemes/CustomThemeContext.tsx +6 -0
  97. package/src/customThemes/CustomThemeDialog.tsx +35 -5
  98. package/src/customThemes/ImportThemeSection.tsx +178 -0
  99. package/src/customThemes/__tests__/customThemeLibrary.test.ts +9 -0
  100. package/src/customThemes/__tests__/importThemeSection.test.tsx +192 -0
  101. package/src/customThemes/customThemeLibrary.ts +7 -4
  102. package/src/customThemes/index.ts +7 -1
  103. package/src/customThemes/themeDraft.ts +23 -7
  104. package/src/diagram/DiagramCanvas.tsx +9 -4
  105. package/src/diagram/types.ts +35 -0
  106. package/src/editorScrollSync.ts +73 -0
  107. package/src/emojiData.ts +3 -23
  108. package/src/headingTransition.ts +0 -15
  109. package/src/imageEditor/CanvasSurface.tsx +29 -23
  110. package/src/imageEditor/LayersPanel.tsx +78 -3
  111. package/src/imageEditor/Toolbar.tsx +5 -1
  112. package/src/imageEditor/icons.tsx +4 -0
  113. package/src/imageEditor/image-editor.css +75 -0
  114. package/src/imageEditor/layers/SelectionHandles.tsx +1 -1
  115. package/src/imageEditor/useImageEditor.ts +71 -14
  116. package/src/index.ts +191 -19
  117. package/src/jsonEditor/JsonEditorContext.tsx +12 -6
  118. package/src/jsonEditor/RenderNode.tsx +24 -3
  119. package/src/jsonEditor/editors.tsx +86 -19
  120. package/src/mediaEntries.ts +12 -0
  121. package/src/monaco.ts +35 -0
  122. package/src/monacoWorkers.ts +89 -0
  123. package/src/outlineSource.ts +171 -0
  124. package/src/presentation/PresentationMode.tsx +596 -0
  125. package/src/rawEditorIsolation.ts +18 -0
  126. package/src/recorder/hooks/useMediaRecorder.ts +97 -53
  127. package/src/resolveBlockVisual.ts +10 -4
  128. package/src/scene/Scene.tsx +48 -18
  129. package/src/scene/SceneBlockExtension.ts +17 -10
  130. package/src/scene/SceneBlockWidget.tsx +5 -1
  131. package/src/scene/SceneViewport.tsx +7 -3
  132. package/src/scene/ShapePalette.tsx +17 -2
  133. package/src/scene/__tests__/DiagramAdapter.test.ts +86 -0
  134. package/src/scene/__tests__/SceneBlockExtension.test.ts +33 -0
  135. package/src/scene/__tests__/sceneIsolation.test.tsx +93 -0
  136. package/src/scene/adapters/DiagramAdapter.ts +12 -101
  137. package/src/scene/commands/SceneCommand.ts +4 -1
  138. package/src/scene/index.ts +1 -6
  139. package/src/scene/layers/DiagramEdges.tsx +21 -7
  140. package/src/scene/layers/edgeGeometry.ts +9 -1
  141. package/src/scene/layers/nodeCard.tsx +27 -8
  142. package/src/scene/scene.css +5 -2
  143. package/src/scene/text/SceneTextOverlay.tsx +5 -5
  144. package/src/scene/text/sceneTextChannel.ts +26 -20
  145. package/src/scene/text/sceneTextConfig.ts +4 -0
  146. package/src/scene/tools/ConnectTool.ts +13 -4
  147. package/src/scene/tools/SceneTool.ts +8 -0
  148. package/src/scene/tools/SelectTool.ts +34 -18
  149. package/src/scene/tools/ShapeTool.ts +2 -3
  150. package/src/styles/ascii-diagram.css +79 -0
  151. package/src/styles/ascii-timeline.css +499 -0
  152. package/src/styles/editor.css +706 -120
  153. package/src/styles/index.css +3 -0
  154. package/src/styles/tree-view.css +139 -0
  155. package/src/teleprompter/TeleprompterControls.tsx +218 -0
  156. package/src/teleprompter/TeleprompterSelfView.tsx +22 -0
  157. package/src/teleprompter/TeleprompterSurface.tsx +267 -0
  158. package/src/teleprompter/TeleprompterView.tsx +338 -0
  159. package/src/teleprompter/canvasRenderer.ts +161 -0
  160. package/src/teleprompter/floatingWindow.ts +352 -0
  161. package/src/teleprompter/index.ts +61 -0
  162. package/src/teleprompter/pcmWorklet.ts +62 -0
  163. package/src/teleprompter/recording/insertPreamble.ts +80 -0
  164. package/src/teleprompter/recording/narrationSave.ts +134 -0
  165. package/src/teleprompter/recording/useNarrationRecorder.ts +367 -0
  166. package/src/teleprompter/scrollModel.ts +85 -0
  167. package/src/teleprompter/teleprompterTheme.ts +303 -0
  168. package/src/teleprompter/types.ts +38 -0
  169. package/src/teleprompter/useFloatingWindow.ts +74 -0
  170. package/src/teleprompter/useMicAnalysis.ts +211 -0
  171. package/src/teleprompter/useTeleprompter.ts +421 -0
  172. package/src/templateContentPreviewResolver.ts +12 -5
  173. package/src/timeline/TimelineEditorWidget.tsx +702 -0
  174. package/src/timeline/TimelineViewExtension.ts +252 -0
  175. package/src/timeline/__tests__/TimelineEditorWidget.test.tsx +355 -0
  176. package/src/timeline/__tests__/TimelineViewExtension.test.ts +163 -0
  177. package/src/timeline/__tests__/timelineCommands.test.ts +327 -0
  178. package/src/timeline/__tests__/timelineOps.test.ts +222 -0
  179. package/src/timeline/__tests__/timelinePaste.test.ts +34 -0
  180. package/src/timeline/timelineCommands.ts +367 -0
  181. package/src/timeline/timelineData.ts +54 -0
  182. package/src/timeline/timelineOps.ts +277 -0
  183. package/src/timeline/timelinePaste.ts +8 -0
  184. package/src/tiptapBridge.ts +24 -16
  185. package/src/transformStyleId.ts +17 -0
  186. package/src/treeview/TreeOutlineWidget.tsx +240 -0
  187. package/src/treeview/TreeViewExtension.ts +250 -0
  188. package/src/treeview/__tests__/TreeViewExtension.test.ts +122 -0
  189. package/src/treeview/__tests__/treeOps.test.ts +125 -0
  190. package/src/treeview/__tests__/treePaste.test.ts +40 -0
  191. package/src/treeview/__tests__/treeViewCommands.test.ts +163 -0
  192. package/src/treeview/treeOps.ts +172 -0
  193. package/src/treeview/treePaste.ts +13 -0
  194. package/src/treeview/treeViewCommands.ts +94 -0
  195. package/src/treeview/treeViewData.ts +41 -0
  196. package/src/useMonacoLoader.ts +11 -34
  197. package/src/wysiwygImageUpload.ts +113 -0
  198. package/src/diagram/DiagramExtension.ts +0 -209
  199. package/src/diagram/DiagramWidget.tsx +0 -272
  200. package/src/diagram/useDiagramData.ts +0 -126
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Scroll-position handoff between the WYSIWYG and source editors.
3
+ *
4
+ * The two surfaces render the same document at very different heights, so a
5
+ * normalized position is a better shared currency than raw pixels. Keeping
6
+ * these adapters separate from EditorContext also makes the DOM/Monaco math
7
+ * independently testable.
8
+ */
9
+
10
+ interface MonacoScrollEditor {
11
+ getScrollTop(): number;
12
+ getScrollHeight(): number;
13
+ getLayoutInfo(): { height: number };
14
+ setScrollTop(scrollTop: number): void;
15
+ }
16
+
17
+ interface WysiwygScrollEditor {
18
+ view: { dom: Element };
19
+ }
20
+
21
+ function clampRatio(value: number): number {
22
+ if (!Number.isFinite(value)) return 0;
23
+ return Math.min(1, Math.max(0, value));
24
+ }
25
+
26
+ export function scrollRatio(
27
+ scrollTop: number,
28
+ scrollHeight: number,
29
+ viewportHeight: number,
30
+ ): number {
31
+ const availableScroll = scrollHeight - viewportHeight;
32
+ if (availableScroll <= 0) return 0;
33
+ return clampRatio(scrollTop / availableScroll);
34
+ }
35
+
36
+ export function scrollTopForRatio(
37
+ ratio: number,
38
+ scrollHeight: number,
39
+ viewportHeight: number,
40
+ ): number {
41
+ return clampRatio(ratio) * Math.max(0, scrollHeight - viewportHeight);
42
+ }
43
+
44
+ export function readMonacoScrollRatio(editor: MonacoScrollEditor): number {
45
+ return scrollRatio(
46
+ editor.getScrollTop(),
47
+ editor.getScrollHeight(),
48
+ editor.getLayoutInfo().height,
49
+ );
50
+ }
51
+
52
+ export function restoreMonacoScrollRatio(editor: MonacoScrollEditor, ratio: number): void {
53
+ editor.setScrollTop(
54
+ scrollTopForRatio(ratio, editor.getScrollHeight(), editor.getLayoutInfo().height),
55
+ );
56
+ }
57
+
58
+ function findWysiwygScrollElement(editor: WysiwygScrollEditor): HTMLElement | null {
59
+ return editor.view.dom.closest<HTMLElement>('.squisq-wysiwyg-container');
60
+ }
61
+
62
+ export function readWysiwygScrollRatio(editor: WysiwygScrollEditor): number | null {
63
+ const element = findWysiwygScrollElement(editor);
64
+ if (!element) return null;
65
+ return scrollRatio(element.scrollTop, element.scrollHeight, element.clientHeight);
66
+ }
67
+
68
+ export function restoreWysiwygScrollRatio(editor: WysiwygScrollEditor, ratio: number): boolean {
69
+ const element = findWysiwygScrollElement(editor);
70
+ if (!element) return false;
71
+ element.scrollTop = scrollTopForRatio(ratio, element.scrollHeight, element.clientHeight);
72
+ return true;
73
+ }
package/src/emojiData.ts CHANGED
@@ -13,7 +13,7 @@
13
13
  * lookup; the user can paste anything we don't have.
14
14
  */
15
15
 
16
- export interface EmojiEntry {
16
+ interface EmojiEntry {
17
17
  /** The actual emoji glyph (may be multi-codepoint, e.g. ZWJ sequences). */
18
18
  char: string;
19
19
  /** Display name shown in the tooltip. */
@@ -22,7 +22,7 @@ export interface EmojiEntry {
22
22
  keywords: string;
23
23
  }
24
24
 
25
- export interface EmojiCategory {
25
+ interface EmojiCategory {
26
26
  id: string;
27
27
  label: string;
28
28
  /** Single-glyph icon shown on the tab. */
@@ -34,7 +34,7 @@ function e(char: string, name: string, ...kw: string[]): EmojiEntry {
34
34
  return { char, name, keywords: [name, ...kw].join(' ').toLowerCase() };
35
35
  }
36
36
 
37
- export const EMOJI_CATEGORIES: EmojiCategory[] = [
37
+ const EMOJI_CATEGORIES: EmojiCategory[] = [
38
38
  {
39
39
  id: 'smileys',
40
40
  label: 'Smileys',
@@ -1216,26 +1216,6 @@ export const EMOJI_CATEGORIES: EmojiCategory[] = [
1216
1216
  },
1217
1217
  ];
1218
1218
 
1219
- /**
1220
- * Flat list of every emoji across all categories — used as the search
1221
- * corpus and to expose a `getEmojiByChar` lookup.
1222
- */
1223
- export const ALL_EMOJIS: EmojiEntry[] = EMOJI_CATEGORIES.flatMap((c) => c.emojis);
1224
-
1225
- /** Search for emoji whose name or keywords contain the query (case-insensitive). */
1226
- export function searchEmojis(query: string, limit = 80): EmojiEntry[] {
1227
- const q = query.trim().toLowerCase();
1228
- if (!q) return [];
1229
- const out: EmojiEntry[] = [];
1230
- for (const entry of ALL_EMOJIS) {
1231
- if (entry.keywords.includes(q)) {
1232
- out.push(entry);
1233
- if (out.length >= limit) break;
1234
- }
1235
- }
1236
- return out;
1237
- }
1238
-
1239
1219
  // ── Unified picker entries (emoji + FontAwesome icons) ─────────────
1240
1220
 
1241
1221
  import { ICONS } from '@bendyline/squisq/icons';
@@ -276,21 +276,6 @@ export function setHeadingAttrsTransition(
276
276
  };
277
277
  }
278
278
 
279
- /**
280
- * Legacy single-channel writer for callers that only own `dataBlockAttrs`.
281
- * Internal editor surfaces should use {@link setHeadingAttrsTransition} so new
282
- * transition edits land in the squisq-native `{[…]}` params.
283
- */
284
- export function setBlockAttrsTransition(
285
- blockAttrsInner: string | null | undefined,
286
- next: TransitionFields,
287
- ): string | null {
288
- const attrs: HeadingAttributes = blockAttrsInner ? parsePandocAttrTokens(blockAttrsInner) : {};
289
- attrs.params = applyFieldsToParams(attrs.params ?? {}, next);
290
- const pandoc = pandocBlockOrNull(attrs);
291
- return pandoc ? pandoc.slice(1, -1) : null;
292
- }
293
-
294
279
  // ============================================
295
280
  // shared
296
281
  // ============================================
@@ -15,7 +15,7 @@
15
15
  * the canvas is the viewport and `blockTime` is 0 (no animation).
16
16
  */
17
17
 
18
- import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
18
+ import { useCallback, useEffect, useId, useLayoutEffect, useRef, useState } from 'react';
19
19
  import { PathLayer } from '@bendyline/squisq-react';
20
20
  import type { ImageEditDoc, ImageEditLayer } from '@bendyline/squisq/schemas';
21
21
  import type { CanvasRect, ImageEditorAction, ImageEditorTool } from './state.js';
@@ -97,6 +97,7 @@ export function CanvasSurface({
97
97
  surfaceRef,
98
98
  requestEditLayerId,
99
99
  }: CanvasSurfaceProps) {
100
+ const checkerId = `squisq-image-editor-checker-${useId().replace(/:/g, '')}`;
100
101
  const svgRef = useRef<SVGSVGElement | null>(null);
101
102
  const dragRef = useRef<DragState | null>(null);
102
103
  const [, forceRender] = useState(0);
@@ -402,16 +403,11 @@ export function CanvasSurface({
402
403
  fill={
403
404
  doc.canvas.background && doc.canvas.background !== 'transparent'
404
405
  ? doc.canvas.background
405
- : 'url(#squisq-image-editor-checker)'
406
+ : `url(#${checkerId})`
406
407
  }
407
408
  />
408
409
  <defs>
409
- <pattern
410
- id="squisq-image-editor-checker"
411
- width="16"
412
- height="16"
413
- patternUnits="userSpaceOnUse"
414
- >
410
+ <pattern id={checkerId} width="16" height="16" patternUnits="userSpaceOnUse">
415
411
  <rect width="16" height="16" fill="#f0f0f0" />
416
412
  <rect width="8" height="8" fill="#d0d0d0" />
417
413
  <rect x="8" y="8" width="8" height="8" fill="#d0d0d0" />
@@ -461,9 +457,16 @@ export function CanvasSurface({
461
457
  })}
462
458
 
463
459
  {/* Selection handles */}
464
- {selectedLayer && paddedSelectionBox && tool === 'select' && !selectedLayer.locked && (
465
- <SelectionHandles box={paddedSelectionBox} onHandlePointerDown={onPointerDownHandle} />
466
- )}
460
+ {selectedLayer &&
461
+ paddedSelectionBox &&
462
+ tool === 'select' &&
463
+ !selectedLayer.locked &&
464
+ editingLayerId !== selectedLayer.id && (
465
+ <SelectionHandles
466
+ box={paddedSelectionBox}
467
+ onHandlePointerDown={onPointerDownHandle}
468
+ />
469
+ )}
467
470
 
468
471
  {/* Inline text editor — foreignObject overlays the hidden text layer */}
469
472
  {editingLayerId &&
@@ -471,16 +474,16 @@ export function CanvasSurface({
471
474
  const editLayer = doc.layers.find((l) => l.id === editingLayerId);
472
475
  if (!editLayer || editLayer.type !== 'text') return null;
473
476
  const pos = layerBox(editLayer, doc);
474
- const s = (editLayer as ImageEditLayer & { type: 'text' }).content;
475
- // Give generous height so text can grow without being clipped.
476
- const foHeight = Math.max(pos.height * 4, 200);
477
+ const textLayer = editLayer as ImageEditLayer & { type: 'text' };
478
+ const s = textLayer.content;
479
+ const editBox = measureTextLayerBox(textLayer, pos);
477
480
  return (
478
481
  <foreignObject
479
482
  key={`inline-edit-${editingLayerId}`}
480
- x={pos.x}
481
- y={pos.y}
482
- width={pos.width}
483
- height={foHeight}
483
+ x={editBox.x}
484
+ y={editBox.y}
485
+ width={editBox.width}
486
+ height={editBox.height}
484
487
  style={{ overflow: 'visible' }}
485
488
  >
486
489
  <textarea
@@ -503,9 +506,11 @@ export function CanvasSurface({
503
506
  }}
504
507
  onPointerDown={(e) => e.stopPropagation()}
505
508
  onClick={(e) => e.stopPropagation()}
509
+ wrap="off"
506
510
  style={{
507
511
  display: 'block',
508
512
  width: '100%',
513
+ height: '100%',
509
514
  padding: 0,
510
515
  margin: 0,
511
516
  border: 'none',
@@ -514,9 +519,9 @@ export function CanvasSurface({
514
519
  resize: 'none',
515
520
  background: 'transparent',
516
521
  fontFamily: s.style.fontFamily ?? 'sans-serif',
517
- // font-size in CSS pixels must be scaled by zoom because foreignObject
518
- // content renders in screen pixels, not SVG user-space pixels.
519
- fontSize: `${s.style.fontSize * zoom}px`,
522
+ // The foreignObject participates in the SVG viewBox transform, so its
523
+ // contents use canvas units and inherit zoom from the SVG exactly once.
524
+ fontSize: `${s.style.fontSize}px`,
520
525
  fontWeight: s.style.fontWeight ?? 'normal',
521
526
  color: s.style.color,
522
527
  textAlign: (s.style.textAlign ?? 'left') as 'left' | 'center' | 'right',
@@ -677,8 +682,9 @@ function measureTextLayerBox(
677
682
  }
678
683
  const lineHeight = style.lineHeight ?? 1.4;
679
684
  const lineHeightPx = fontSize * lineHeight;
680
- // First line spans fontSize tall; subsequent lines add lineHeightPx each.
681
- const totalHeight = fontSize + Math.max(0, lines.length - 1) * lineHeightPx;
685
+ // Use full line boxes so the SVG selection and textarea editor share
686
+ // identical bounds without clipping the caret or the final line.
687
+ const totalHeight = Math.max(1, lines.length) * lineHeightPx;
682
688
  // Mirror the textAnchor logic in EditorTextLayer.
683
689
  const anchor =
684
690
  style.textAlign === 'center' ? 'middle' : style.textAlign === 'right' ? 'end' : 'start';
@@ -4,6 +4,7 @@
4
4
  * panel shows the array reversed so "top" appears at the top of the list.
5
5
  */
6
6
 
7
+ import { useEffect, useRef, useState } from 'react';
7
8
  import type { ImageEditDoc } from '@bendyline/squisq/schemas';
8
9
  import type { ImageEditorAction } from './state.js';
9
10
  import {
@@ -12,23 +13,95 @@ import {
12
13
  CloseIcon,
13
14
  EyeIcon,
14
15
  EyeOffIcon,
16
+ ImageIcon,
15
17
  LockIcon,
18
+ PlusIcon,
19
+ ShapeIcon,
20
+ TextIcon,
16
21
  UnlockIcon,
17
22
  } from './icons.js';
18
23
 
24
+ export type AddableLayerKind = 'text' | 'shape' | 'image';
25
+
19
26
  export interface LayersPanelProps {
20
27
  doc: ImageEditDoc;
21
28
  selectedLayerId: string | null;
22
29
  dispatch: (action: ImageEditorAction) => void;
30
+ onAddLayer: (kind: AddableLayerKind) => void;
23
31
  }
24
32
 
25
- export function LayersPanel({ doc, selectedLayerId, dispatch }: LayersPanelProps) {
33
+ const ADD_LAYER_OPTIONS: ReadonlyArray<{
34
+ kind: AddableLayerKind;
35
+ label: string;
36
+ icon: React.ReactNode;
37
+ }> = [
38
+ { kind: 'text', label: 'Text layer', icon: <TextIcon /> },
39
+ { kind: 'shape', label: 'Shape layer', icon: <ShapeIcon /> },
40
+ { kind: 'image', label: 'Image layer…', icon: <ImageIcon /> },
41
+ ];
42
+
43
+ export function LayersPanel({ doc, selectedLayerId, dispatch, onAddLayer }: LayersPanelProps) {
44
+ const [addMenuOpen, setAddMenuOpen] = useState(false);
45
+ const addMenuRef = useRef<HTMLSpanElement | null>(null);
46
+
47
+ useEffect(() => {
48
+ if (!addMenuOpen) return;
49
+
50
+ const closeOnOutsideClick = (event: MouseEvent) => {
51
+ if (!addMenuRef.current?.contains(event.target as Node)) setAddMenuOpen(false);
52
+ };
53
+ const closeOnEscape = (event: KeyboardEvent) => {
54
+ if (event.key === 'Escape') setAddMenuOpen(false);
55
+ };
56
+
57
+ document.addEventListener('mousedown', closeOnOutsideClick);
58
+ document.addEventListener('keydown', closeOnEscape);
59
+ return () => {
60
+ document.removeEventListener('mousedown', closeOnOutsideClick);
61
+ document.removeEventListener('keydown', closeOnEscape);
62
+ };
63
+ }, [addMenuOpen]);
64
+
26
65
  // Visual order: top of stack first.
27
66
  const ordered = doc.layers.slice().reverse();
28
67
 
29
68
  return (
30
69
  <div className="squisq-image-editor-layers" data-testid="image-editor-layers">
31
- <div className="squisq-image-editor-panel-header">Layers</div>
70
+ <div className="squisq-image-editor-panel-header squisq-image-editor-layers-header">
71
+ <span>Layers</span>
72
+ <span ref={addMenuRef} className="squisq-image-editor-layer-add">
73
+ <button
74
+ type="button"
75
+ className="squisq-image-editor-layer-add-button"
76
+ onClick={() => setAddMenuOpen((open) => !open)}
77
+ aria-label="Add layer"
78
+ title="Add layer"
79
+ aria-haspopup="menu"
80
+ aria-expanded={addMenuOpen}
81
+ >
82
+ <PlusIcon />
83
+ </button>
84
+ {addMenuOpen && (
85
+ <div className="squisq-image-editor-layer-add-menu" role="menu">
86
+ {ADD_LAYER_OPTIONS.map(({ kind, label, icon }) => (
87
+ <button
88
+ key={kind}
89
+ type="button"
90
+ className="squisq-image-editor-layer-add-menu-item"
91
+ role="menuitem"
92
+ onClick={() => {
93
+ setAddMenuOpen(false);
94
+ onAddLayer(kind);
95
+ }}
96
+ >
97
+ {icon}
98
+ <span>{label}</span>
99
+ </button>
100
+ ))}
101
+ </div>
102
+ )}
103
+ </span>
104
+ </div>
32
105
  <ul className="squisq-image-editor-layer-list">
33
106
  {ordered.length === 0 && <li className="squisq-image-editor-layer-empty">No layers yet</li>}
34
107
  {ordered.map((layer) => {
@@ -38,6 +111,7 @@ export function LayersPanel({ doc, selectedLayerId, dispatch }: LayersPanelProps
38
111
  const canMoveUp = stackIndex < doc.layers.length - 1;
39
112
  const canMoveDown = stackIndex > 0;
40
113
  const isSelected = selectedLayerId === layer.id;
114
+ const layerName = layer.name ?? defaultLayerName(layer);
41
115
 
42
116
  return (
43
117
  <li
@@ -84,8 +158,9 @@ export function LayersPanel({ doc, selectedLayerId, dispatch }: LayersPanelProps
84
158
  type="button"
85
159
  className="squisq-image-editor-layer-name"
86
160
  onClick={() => dispatch({ type: 'select', layerId: layer.id })}
161
+ title={layerName}
87
162
  >
88
- {layer.name ?? defaultLayerName(layer)}
163
+ <span className="squisq-image-editor-layer-name-text">{layerName}</span>
89
164
  <span className="squisq-image-editor-layer-kind">{layer.type}</span>
90
165
  </button>
91
166
  <button
@@ -18,6 +18,8 @@ export interface ToolbarProps {
18
18
  dispatch: (a: ImageEditorAction) => void;
19
19
  /** Upload an image asset and return its sidecar-relative path. */
20
20
  uploadAsset: (file: Blob, suggestedName?: string) => Promise<string>;
21
+ /** Shared image picker ref used by the toolbar and Layers panel. */
22
+ imageInputRef?: React.RefObject<HTMLInputElement>;
21
23
  /** Trigger an export (PNG/JPEG/WebP) of the flattened canvas. */
22
24
  onExport: (format: 'png' | 'jpeg' | 'webp') => void;
23
25
  /** Force-flush the state.json (host's "save" button). */
@@ -142,6 +144,7 @@ export function Toolbar({
142
144
  shapeKind,
143
145
  dispatch,
144
146
  uploadAsset,
147
+ imageInputRef,
145
148
  onExport,
146
149
  onSave,
147
150
  saveLabel = 'Save',
@@ -154,7 +157,8 @@ export function Toolbar({
154
157
  onZoomFit,
155
158
  onZoom1to1,
156
159
  }: ToolbarProps) {
157
- const fileInputRef = useRef<HTMLInputElement | null>(null);
160
+ const internalImageInputRef = useRef<HTMLInputElement>(null);
161
+ const fileInputRef = imageInputRef ?? internalImageInputRef;
158
162
  const [shapePaletteOpen, setShapePaletteOpen] = useState(false);
159
163
 
160
164
  const onFilePicked = async (file: File) => {
@@ -58,6 +58,10 @@ export function PlusIcon(props: Props) {
58
58
  return <Icon icon="fa-solid fa-plus" {...props} />;
59
59
  }
60
60
 
61
+ export function ImageIcon(props: Props) {
62
+ return <Icon icon="fa-solid fa-image" {...props} />;
63
+ }
64
+
61
65
  export function NoneIcon(props: Props) {
62
66
  // "No fill / transparent" affordance.
63
67
  return <Icon icon="fa-solid fa-ban" {...props} />;
@@ -193,6 +193,73 @@
193
193
  .squisq-image-editor-layers {
194
194
  border-bottom: 1px solid var(--squisq-image-editor-panel-border);
195
195
  }
196
+ .squisq-image-editor-layers-header {
197
+ display: flex;
198
+ align-items: center;
199
+ justify-content: space-between;
200
+ min-height: 32px;
201
+ }
202
+ .squisq-image-editor-layer-add {
203
+ position: relative;
204
+ display: inline-flex;
205
+ }
206
+ .squisq-image-editor-layer-add-button {
207
+ display: inline-flex;
208
+ align-items: center;
209
+ justify-content: center;
210
+ width: 22px;
211
+ height: 22px;
212
+ padding: 0;
213
+ color: inherit;
214
+ background: transparent;
215
+ border: 1px solid transparent;
216
+ border-radius: 3px;
217
+ cursor: pointer;
218
+ font: inherit;
219
+ }
220
+ .squisq-image-editor-layer-add-button:hover,
221
+ .squisq-image-editor-layer-add-button[aria-expanded='true'] {
222
+ background: var(--squisq-image-editor-control-bg);
223
+ border-color: var(--squisq-image-editor-control-border);
224
+ }
225
+ .squisq-image-editor-layer-add-menu {
226
+ position: absolute;
227
+ top: calc(100% + 4px);
228
+ right: 0;
229
+ z-index: 20;
230
+ min-width: 150px;
231
+ padding: 4px;
232
+ background: var(--squisq-image-editor-panel-bg);
233
+ border: 1px solid var(--squisq-image-editor-panel-border);
234
+ border-radius: 5px;
235
+ box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35);
236
+ text-transform: none;
237
+ letter-spacing: normal;
238
+ }
239
+ .squisq-image-editor-layer-add-menu-item {
240
+ display: flex;
241
+ align-items: center;
242
+ gap: 8px;
243
+ width: 100%;
244
+ padding: 6px 8px;
245
+ color: inherit;
246
+ background: transparent;
247
+ border: 0;
248
+ border-radius: 3px;
249
+ cursor: pointer;
250
+ font: inherit;
251
+ font-size: 12px;
252
+ text-align: left;
253
+ }
254
+ .squisq-image-editor-layer-add-menu-item:hover,
255
+ .squisq-image-editor-layer-add-menu-item:focus-visible {
256
+ background: var(--squisq-image-editor-control-bg);
257
+ outline: none;
258
+ }
259
+ .squisq-image-editor-layer-add-menu-item svg {
260
+ flex: 0 0 auto;
261
+ width: 14px;
262
+ }
196
263
  .squisq-image-editor-layer-list {
197
264
  list-style: none;
198
265
  margin: 0;
@@ -239,6 +306,7 @@
239
306
  cursor: not-allowed;
240
307
  }
241
308
  .squisq-image-editor-layer-name {
309
+ min-width: 0;
242
310
  flex: 1 1 auto;
243
311
  text-align: left;
244
312
  background: transparent;
@@ -254,7 +322,14 @@
254
322
  text-overflow: ellipsis;
255
323
  white-space: nowrap;
256
324
  }
325
+ .squisq-image-editor-layer-name-text {
326
+ min-width: 0;
327
+ overflow: hidden;
328
+ text-overflow: ellipsis;
329
+ white-space: nowrap;
330
+ }
257
331
  .squisq-image-editor-layer-kind {
332
+ flex: 0 0 auto;
258
333
  margin-left: auto;
259
334
  font-size: 10px;
260
335
  color: var(--squisq-image-editor-text-muted);
@@ -35,7 +35,7 @@ export function SelectionHandles({ box, onHandlePointerDown }: Props) {
35
35
  ];
36
36
 
37
37
  return (
38
- <g pointerEvents="none">
38
+ <g className="squisq-image-editor-selection-handles" pointerEvents="none">
39
39
  {/*
40
40
  * Outline: rendered as two stacked rectangles so the selection
41
41
  * stays visible over both light and dark imagery. The white
@@ -110,6 +110,7 @@ export function useImageEditor(options: UseImageEditorOptions): UseImageEditorRe
110
110
  const seeded = await seedFromSource(container, initialSrc);
111
111
  if (cancelled) return;
112
112
  await writeImageEditDoc(container, seeded, stateFilename);
113
+ if (cancelled) return;
113
114
  dispatch({ type: 'load', doc: seeded });
114
115
  setReady(true);
115
116
  // Capture an initial snapshot of the freshly-seeded state so the
@@ -131,27 +132,53 @@ export function useImageEditor(options: UseImageEditorOptions): UseImageEditorRe
131
132
  // ── Debounced persistence of state.json ────────────────────────────────
132
133
  const persistTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
133
134
  const docRef = useRef<ImageEditDoc | null>(null);
134
- docRef.current = state?.doc ?? null;
135
+ const dirtyRef = useRef(false);
136
+ const revisionRef = useRef(0);
137
+ const previousDocRef = useRef<ImageEditDoc | null>(null);
138
+ const nextDoc = state?.doc ?? null;
139
+ if (nextDoc !== previousDocRef.current) {
140
+ previousDocRef.current = nextDoc;
141
+ revisionRef.current += 1;
142
+ }
143
+ docRef.current = nextDoc;
144
+ dirtyRef.current = state?.dirty ?? false;
145
+ const writeQueueRef = useRef<Promise<void>>(Promise.resolve());
146
+ const persistTargetRef = useRef({ container, stateFilename });
147
+ persistTargetRef.current = { container, stateFilename };
148
+
149
+ const enqueueWrite = useCallback(
150
+ (doc: ImageEditDoc, revision: number, markClean: boolean): Promise<void> => {
151
+ const write = writeQueueRef.current
152
+ .catch(() => undefined)
153
+ .then(() => writeImageEditDoc(container, doc, stateFilename));
154
+ writeQueueRef.current = write.catch(() => undefined);
155
+ return write.then(() => {
156
+ if (markClean && revision === revisionRef.current && docRef.current === doc) {
157
+ dispatch({ type: 'mark-clean' });
158
+ }
159
+ });
160
+ },
161
+ [container, stateFilename],
162
+ );
135
163
 
136
164
  useEffect(() => {
137
165
  if (!state?.dirty) return;
138
166
  if (persistTimerRef.current) clearTimeout(persistTimerRef.current);
139
167
  persistTimerRef.current = setTimeout(() => {
140
- const doc = docRef.current;
168
+ const doc = state.doc;
141
169
  if (!doc) return;
142
- writeImageEditDoc(container, doc, stateFilename)
143
- .then(() => dispatch({ type: 'mark-clean' }))
144
- .catch((err: unknown) => {
145
- console.warn(
146
- '[squisq-editor] image-edit state persist failed:',
147
- err instanceof Error ? err.message : err,
148
- );
149
- });
170
+ const revision = revisionRef.current;
171
+ enqueueWrite(doc, revision, true).catch((err: unknown) => {
172
+ console.warn(
173
+ '[squisq-editor] image-edit state persist failed:',
174
+ err instanceof Error ? err.message : err,
175
+ );
176
+ });
150
177
  }, persistDebounceMs);
151
178
  return () => {
152
179
  if (persistTimerRef.current) clearTimeout(persistTimerRef.current);
153
180
  };
154
- }, [state?.dirty, state?.doc, container, stateFilename, persistDebounceMs]);
181
+ }, [state?.dirty, state?.doc, persistDebounceMs, enqueueWrite]);
155
182
 
156
183
  const flush = useCallback(async () => {
157
184
  const doc = docRef.current;
@@ -160,9 +187,29 @@ export function useImageEditor(options: UseImageEditorOptions): UseImageEditorRe
160
187
  clearTimeout(persistTimerRef.current);
161
188
  persistTimerRef.current = null;
162
189
  }
163
- await writeImageEditDoc(container, doc, stateFilename);
164
- dispatch({ type: 'mark-clean' });
165
- }, [container, stateFilename]);
190
+ await enqueueWrite(doc, revisionRef.current, true);
191
+ }, [enqueueWrite]);
192
+
193
+ // A quick modal close must not discard the final edit that is still inside
194
+ // the debounce window. Queue it behind any in-flight write on unmount.
195
+ useEffect(
196
+ () => () => {
197
+ if (persistTimerRef.current) clearTimeout(persistTimerRef.current);
198
+ const doc = docRef.current;
199
+ if (!dirtyRef.current || !doc) return;
200
+ const target = persistTargetRef.current;
201
+ const write = writeQueueRef.current
202
+ .catch(() => undefined)
203
+ .then(() => writeImageEditDoc(target.container, doc, target.stateFilename));
204
+ writeQueueRef.current = write.catch((err: unknown) => {
205
+ console.warn(
206
+ '[squisq-editor] image-edit final persist failed:',
207
+ err instanceof Error ? err.message : err,
208
+ );
209
+ });
210
+ },
211
+ [],
212
+ );
166
213
 
167
214
  // ── Versioning ─────────────────────────────────────────────────────────
168
215
  const versioning = useMemo(
@@ -204,17 +251,26 @@ export function useImageEditor(options: UseImageEditorOptions): UseImageEditorRe
204
251
 
205
252
  // ── Asset URL cache ────────────────────────────────────────────────────
206
253
  const urlCacheRef = useRef<Map<string, string>>(new Map());
254
+ const urlCacheGenerationRef = useRef(0);
207
255
 
208
256
  const resolveAssetUrl = useCallback(
209
257
  async (path: string): Promise<string> => {
210
258
  const cache = urlCacheRef.current;
259
+ const generation = urlCacheGenerationRef.current;
211
260
  const cached = cache.get(path);
212
261
  if (cached) return cached;
213
262
  const data = await container.readFile(path);
263
+ if (generation !== urlCacheGenerationRef.current) {
264
+ throw new Error('useImageEditor: asset resolution cancelled');
265
+ }
214
266
  if (!data) throw new Error(`useImageEditor: missing asset "${path}"`);
215
267
  const list = await container.listFiles(path);
216
268
  const mime = list.find((e) => e.path === path)?.mimeType ?? 'application/octet-stream';
217
269
  const url = URL.createObjectURL(new Blob([data], { type: mime }));
270
+ if (generation !== urlCacheGenerationRef.current) {
271
+ URL.revokeObjectURL(url);
272
+ throw new Error('useImageEditor: asset resolution cancelled');
273
+ }
218
274
  cache.set(path, url);
219
275
  return url;
220
276
  },
@@ -225,6 +281,7 @@ export function useImageEditor(options: UseImageEditorOptions): UseImageEditorRe
225
281
  useEffect(() => {
226
282
  const cache = urlCacheRef.current;
227
283
  return () => {
284
+ urlCacheGenerationRef.current += 1;
228
285
  for (const url of cache.values()) URL.revokeObjectURL(url);
229
286
  cache.clear();
230
287
  };