@bendyline/squisq-editor-react 1.6.1 → 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 (208) hide show
  1. package/README.md +12 -3
  2. package/dist/index.d.ts +1304 -208
  3. package/dist/index.js +22838 -14494
  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 +2384 -408
  9. package/package.json +9 -5
  10. package/src/BlockPropertiesPopover.tsx +48 -17
  11. package/src/DocumentSettingsDialog.tsx +13 -21
  12. package/src/EditorContext.tsx +199 -42
  13. package/src/EditorShell.tsx +403 -270
  14. package/src/ImageEditor.tsx +22 -1
  15. package/src/InlinePreviewGutter.tsx +16 -8
  16. package/src/MediaBin.tsx +276 -40
  17. package/src/OutlinePanel.tsx +202 -7
  18. package/src/PlainHtmlPreview.tsx +40 -1
  19. package/src/PreviewControls.tsx +466 -93
  20. package/src/PreviewPanel.tsx +313 -77
  21. package/src/RawEditor.tsx +13 -2
  22. package/src/StatusBar.tsx +5 -2
  23. package/src/TemplateAnnotation.ts +34 -10
  24. package/src/TemplateContentPreview.tsx +56 -0
  25. package/src/TemplatePicker.tsx +355 -134
  26. package/src/ThemeCustomizerPanel.tsx +30 -15
  27. package/src/ThemePicker.tsx +10 -5
  28. package/src/TimelineBlockPreview.tsx +1 -1
  29. package/src/Toolbar.tsx +719 -238
  30. package/src/TransitionPicker.tsx +44 -5
  31. package/src/ViewMenuPanel.tsx +12 -14
  32. package/src/ViewSwitcher.tsx +4 -4
  33. package/src/WysiwygEditor.tsx +167 -92
  34. package/src/__tests__/blockPropertiesPopoverTheme.test.tsx +33 -0
  35. package/src/__tests__/blockTagActivity.test.ts +183 -0
  36. package/src/__tests__/buildDocumentPreviewMarkdown.test.ts +75 -0
  37. package/src/__tests__/buildPreviewDocContent.test.ts +48 -0
  38. package/src/__tests__/buildPreviewDocTransition.test.ts +22 -3
  39. package/src/__tests__/canvasSurfaceTextEditing.test.tsx +60 -0
  40. package/src/__tests__/customLayoutManagerTheme.test.tsx +34 -0
  41. package/src/__tests__/documentSettingsDialog.test.tsx +29 -1
  42. package/src/__tests__/editorScrollHandoff.test.tsx +102 -0
  43. package/src/__tests__/editorScrollSync.test.ts +65 -0
  44. package/src/__tests__/editorShellProps.test.tsx +390 -1
  45. package/src/__tests__/emojiPicker.test.tsx +2 -42
  46. package/src/__tests__/headingTransition.test.ts +53 -15
  47. package/src/__tests__/imageEditAffordance.test.tsx +5 -1
  48. package/src/__tests__/imageEditModalContract.test.tsx +119 -0
  49. package/src/__tests__/imageEditorShell.test.tsx +38 -1
  50. package/src/__tests__/jsonEditor.test.tsx +56 -1
  51. package/src/__tests__/layersPanel.test.tsx +75 -5
  52. package/src/__tests__/majorApiTypes.test.ts +26 -0
  53. package/src/__tests__/mediaBinDrop.test.tsx +90 -0
  54. package/src/__tests__/mediaReferences.test.ts +82 -0
  55. package/src/__tests__/narrationSave.test.ts +148 -0
  56. package/src/__tests__/outlinePanel.test.tsx +93 -3
  57. package/src/__tests__/outlineSource.test.ts +217 -0
  58. package/src/__tests__/plainHtmlPreview.test.tsx +17 -1
  59. package/src/__tests__/presentationMode.test.tsx +290 -0
  60. package/src/__tests__/previewControls.test.tsx +312 -6
  61. package/src/__tests__/previewPanelAudioFailure.test.tsx +46 -0
  62. package/src/__tests__/previewPanelPresentation.test.tsx +126 -0
  63. package/src/__tests__/previewPanelTransforms.test.tsx +117 -0
  64. package/src/__tests__/rawEditorModelIsolation.test.ts +19 -0
  65. package/src/__tests__/resolveBlockVisual.test.ts +43 -0
  66. package/src/__tests__/statusBar.test.tsx +27 -0
  67. package/src/__tests__/teleprompter.test.tsx +190 -0
  68. package/src/__tests__/templateAnnotationRoundTrip.test.ts +49 -2
  69. package/src/__tests__/templateContentPreview.test.ts +101 -0
  70. package/src/__tests__/templatePickerPortal.test.tsx +62 -0
  71. package/src/__tests__/tiptapBridge.test.ts +70 -0
  72. package/src/__tests__/tiptapCodeBlockRoundTrip.test.ts +109 -0
  73. package/src/__tests__/transformStyleId.test.ts +13 -0
  74. package/src/__tests__/useImageEditor.test.tsx +67 -0
  75. package/src/__tests__/useMediaRecorder.test.ts +75 -0
  76. package/src/__tests__/viewMenuPanel.test.tsx +130 -0
  77. package/src/__tests__/wysiwygImageUpload.test.ts +69 -0
  78. package/src/__tests__/wysiwygTemplateBadgeFocus.test.tsx +56 -0
  79. package/src/asciiDiagram/AsciiDiagramExtension.ts +343 -0
  80. package/src/asciiDiagram/AsciiDiagramWidget.tsx +224 -0
  81. package/src/asciiDiagram/RepairableDiagramExtension.ts +200 -0
  82. package/src/asciiDiagram/__tests__/AsciiDiagramExtension.test.ts +145 -0
  83. package/src/asciiDiagram/__tests__/RepairableDiagramExtension.test.ts +159 -0
  84. package/src/asciiDiagram/__tests__/asciiDiagramCommands.test.ts +227 -0
  85. package/src/asciiDiagram/__tests__/asciiDiagramOps.test.ts +114 -0
  86. package/src/asciiDiagram/__tests__/asciiPaste.test.ts +43 -0
  87. package/src/asciiDiagram/asciiDiagramCommands.ts +148 -0
  88. package/src/asciiDiagram/asciiDiagramData.ts +116 -0
  89. package/src/asciiDiagram/asciiDiagramOps.ts +219 -0
  90. package/src/asciiDiagram/asciiPaste.ts +19 -0
  91. package/src/blockTagActivity.ts +233 -0
  92. package/src/buildDocumentPreviewMarkdown.ts +168 -0
  93. package/src/buildPreviewDoc.ts +19 -12
  94. package/src/customTemplates/CustomLayoutManager.tsx +3 -2
  95. package/src/customTemplates/CustomTemplateContext.tsx +6 -0
  96. package/src/customTemplates/TemplateDesigner.tsx +7 -4
  97. package/src/customTemplates/__tests__/library.test.ts +8 -0
  98. package/src/customTemplates/designer.css +158 -99
  99. package/src/customTemplates/library.ts +16 -3
  100. package/src/customThemes/CustomThemeContext.tsx +6 -0
  101. package/src/customThemes/CustomThemeDialog.tsx +35 -5
  102. package/src/customThemes/ImportThemeSection.tsx +178 -0
  103. package/src/customThemes/__tests__/customThemeLibrary.test.ts +9 -0
  104. package/src/customThemes/__tests__/importThemeSection.test.tsx +192 -0
  105. package/src/customThemes/customThemeLibrary.ts +7 -4
  106. package/src/customThemes/index.ts +7 -1
  107. package/src/customThemes/themeDraft.ts +23 -7
  108. package/src/diagram/DiagramCanvas.tsx +9 -4
  109. package/src/diagram/types.ts +35 -0
  110. package/src/editorScrollSync.ts +73 -0
  111. package/src/emojiData.ts +3 -23
  112. package/src/headingTransition.ts +85 -25
  113. package/src/imageEditor/CanvasSurface.tsx +29 -23
  114. package/src/imageEditor/LayersPanel.tsx +78 -3
  115. package/src/imageEditor/Toolbar.tsx +5 -1
  116. package/src/imageEditor/icons.tsx +4 -0
  117. package/src/imageEditor/image-editor.css +75 -0
  118. package/src/imageEditor/layers/SelectionHandles.tsx +1 -1
  119. package/src/imageEditor/useImageEditor.ts +71 -14
  120. package/src/index.ts +193 -20
  121. package/src/jsonEditor/JsonEditorContext.tsx +12 -6
  122. package/src/jsonEditor/RenderNode.tsx +24 -3
  123. package/src/jsonEditor/editors.tsx +86 -19
  124. package/src/mediaEntries.ts +12 -0
  125. package/src/mediaReferences.ts +299 -0
  126. package/src/monaco.ts +35 -0
  127. package/src/monacoWorkers.ts +89 -0
  128. package/src/outlineSource.ts +171 -0
  129. package/src/presentation/PresentationMode.tsx +596 -0
  130. package/src/rawEditorIsolation.ts +18 -0
  131. package/src/recorder/hooks/useMediaRecorder.ts +97 -53
  132. package/src/resolveBlockVisual.ts +10 -4
  133. package/src/scene/Scene.tsx +101 -25
  134. package/src/scene/SceneBlockExtension.ts +17 -10
  135. package/src/scene/SceneBlockWidget.tsx +11 -4
  136. package/src/scene/SceneSelection.tsx +19 -15
  137. package/src/scene/SceneSideToolbar.tsx +89 -0
  138. package/src/scene/SceneViewport.tsx +7 -3
  139. package/src/scene/ShapePalette.tsx +17 -2
  140. package/src/scene/__tests__/DiagramAdapter.test.ts +86 -0
  141. package/src/scene/__tests__/SceneBlockExtension.test.ts +33 -0
  142. package/src/scene/__tests__/sceneIsolation.test.tsx +93 -0
  143. package/src/scene/adapters/DiagramAdapter.ts +12 -101
  144. package/src/scene/commands/SceneCommand.ts +4 -1
  145. package/src/scene/index.ts +1 -6
  146. package/src/scene/layers/DiagramEdges.tsx +24 -9
  147. package/src/scene/layers/edgeGeometry.ts +31 -4
  148. package/src/scene/layers/nodeCard.tsx +27 -8
  149. package/src/scene/scene.css +145 -2
  150. package/src/scene/text/SceneTextOverlay.tsx +5 -5
  151. package/src/scene/text/sceneTextChannel.ts +26 -20
  152. package/src/scene/text/sceneTextConfig.ts +4 -0
  153. package/src/scene/tools/ConnectTool.ts +126 -28
  154. package/src/scene/tools/DrawingConnectTool.ts +86 -16
  155. package/src/scene/tools/SceneTool.ts +10 -0
  156. package/src/scene/tools/SelectTool.ts +34 -18
  157. package/src/scene/tools/ShapeTool.ts +2 -3
  158. package/src/styles/ascii-diagram.css +79 -0
  159. package/src/styles/ascii-timeline.css +499 -0
  160. package/src/styles/editor.css +1566 -219
  161. package/src/styles/index.css +3 -0
  162. package/src/styles/tree-view.css +139 -0
  163. package/src/teleprompter/TeleprompterControls.tsx +218 -0
  164. package/src/teleprompter/TeleprompterSelfView.tsx +22 -0
  165. package/src/teleprompter/TeleprompterSurface.tsx +267 -0
  166. package/src/teleprompter/TeleprompterView.tsx +338 -0
  167. package/src/teleprompter/canvasRenderer.ts +161 -0
  168. package/src/teleprompter/floatingWindow.ts +352 -0
  169. package/src/teleprompter/index.ts +61 -0
  170. package/src/teleprompter/pcmWorklet.ts +62 -0
  171. package/src/teleprompter/recording/insertPreamble.ts +80 -0
  172. package/src/teleprompter/recording/narrationSave.ts +134 -0
  173. package/src/teleprompter/recording/useNarrationRecorder.ts +367 -0
  174. package/src/teleprompter/scrollModel.ts +85 -0
  175. package/src/teleprompter/teleprompterTheme.ts +303 -0
  176. package/src/teleprompter/types.ts +38 -0
  177. package/src/teleprompter/useFloatingWindow.ts +74 -0
  178. package/src/teleprompter/useMicAnalysis.ts +211 -0
  179. package/src/teleprompter/useTeleprompter.ts +421 -0
  180. package/src/templateContentPreviewResolver.ts +360 -0
  181. package/src/timeline/TimelineEditorWidget.tsx +702 -0
  182. package/src/timeline/TimelineViewExtension.ts +252 -0
  183. package/src/timeline/__tests__/TimelineEditorWidget.test.tsx +355 -0
  184. package/src/timeline/__tests__/TimelineViewExtension.test.ts +163 -0
  185. package/src/timeline/__tests__/timelineCommands.test.ts +327 -0
  186. package/src/timeline/__tests__/timelineOps.test.ts +222 -0
  187. package/src/timeline/__tests__/timelinePaste.test.ts +34 -0
  188. package/src/timeline/timelineCommands.ts +367 -0
  189. package/src/timeline/timelineData.ts +54 -0
  190. package/src/timeline/timelineOps.ts +277 -0
  191. package/src/timeline/timelinePaste.ts +8 -0
  192. package/src/tiptapBridge.ts +84 -49
  193. package/src/transformStyleId.ts +17 -0
  194. package/src/treeview/TreeOutlineWidget.tsx +240 -0
  195. package/src/treeview/TreeViewExtension.ts +250 -0
  196. package/src/treeview/__tests__/TreeViewExtension.test.ts +122 -0
  197. package/src/treeview/__tests__/treeOps.test.ts +125 -0
  198. package/src/treeview/__tests__/treePaste.test.ts +40 -0
  199. package/src/treeview/__tests__/treeViewCommands.test.ts +163 -0
  200. package/src/treeview/treeOps.ts +172 -0
  201. package/src/treeview/treePaste.ts +13 -0
  202. package/src/treeview/treeViewCommands.ts +94 -0
  203. package/src/treeview/treeViewData.ts +41 -0
  204. package/src/useMonacoLoader.ts +11 -34
  205. package/src/wysiwygImageUpload.ts +113 -0
  206. package/src/diagram/DiagramExtension.ts +0 -209
  207. package/src/diagram/DiagramWidget.tsx +0 -270
  208. 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';
@@ -6,16 +6,13 @@
6
6
  *
7
7
  * - Markdown (Monaco): operate on the raw heading line string.
8
8
  * - WYSIWYG (Tiptap): operate on the heading node's `dataBlockAttrs` string
9
- * (the inner of the Pandoc `{…}` block, no braces matching how
10
- * `tiptapBridge` stores and re-emits it).
9
+ * (Pandoc `{…}` inner) and `dataTemplateParams` string (the params inside
10
+ * the squisq-native `{[…]}` annotation).
11
11
  *
12
- * Transitions are stored in the Pandoc `{#id .class key=value}` attribute
13
- * block, NOT the `{[template …]}` annotation. That mirrors the canonical
14
- * serializer (`core/doc/docToMarkdown.ts` `ensureTransitionAttributes`,
15
- * which always emits the `{…}` form) and `diagram/diagramCommands.ts`, so a
16
- * value set here round-trips through a Doc render without being duplicated
17
- * or moved. Reads still look at the `{[…]}` params too, so a hand-typed
18
- * `{[title transition=fade]}` shows up in the picker.
12
+ * The editor writes transitions to the squisq-native `{[…]}` annotation by
13
+ * default. It still reads legacy Pandoc `{transition=…}` attributes and
14
+ * removes/migrates those keys when rewriting, so the two channels cannot drift
15
+ * after a toolbar edit.
19
16
  *
20
17
  * All the brace-matching / tokenizing / serializing is delegated to the
21
18
  * shared core helpers so this stays in lockstep with the parser by import
@@ -29,6 +26,7 @@ import {
29
26
  serializePandocAttributes,
30
27
  tokenizeAttrTokens,
31
28
  splitKeyValueToken,
29
+ quoteAttrValue,
32
30
  type HeadingAttributes,
33
31
  } from '@bendyline/squisq/markdown';
34
32
 
@@ -85,6 +83,14 @@ function applyFieldsToParams(
85
83
  return out;
86
84
  }
87
85
 
86
+ function removeFieldsFromAttrs(attrs: HeadingAttributes): HeadingAttributes {
87
+ if (!attrs.params) return attrs;
88
+ const params = applyFieldsToParams(attrs.params, EMPTY_TRANSITION);
89
+ if (Object.keys(params).length > 0) attrs.params = params;
90
+ else delete attrs.params;
91
+ return attrs;
92
+ }
93
+
88
94
  /** Parse a bare `key=value …` token string into a params map. */
89
95
  function paramsFromTokenString(input: string, skipFirstToken: boolean): Record<string, string> {
90
96
  const tokens = tokenizeAttrTokens(input);
@@ -96,6 +102,39 @@ function paramsFromTokenString(input: string, skipFirstToken: boolean): Record<s
96
102
  return params;
97
103
  }
98
104
 
105
+ function templatePartsFromInner(inner: string | null | undefined): {
106
+ template: string | undefined;
107
+ params: Record<string, string>;
108
+ } {
109
+ if (!inner) return { template: undefined, params: {} };
110
+ const tokens = tokenizeAttrTokens(inner);
111
+ const firstIsParam = tokens.length > 0 && tokens[0].indexOf('=') > 0;
112
+ const template = firstIsParam || tokens.length === 0 ? undefined : tokens[0];
113
+ const startIdx = template ? 1 : 0;
114
+ const params: Record<string, string> = {};
115
+ for (const token of tokens.slice(startIdx)) {
116
+ const kv = splitKeyValueToken(token);
117
+ if (kv) params[kv.key] = kv.value;
118
+ }
119
+ return { template, params };
120
+ }
121
+
122
+ function paramsToInner(params: Record<string, string>): string | null {
123
+ const parts = Object.entries(params).map(([key, value]) => `${key}=${quoteAttrValue(value)}`);
124
+ return parts.length > 0 ? parts.join(' ') : null;
125
+ }
126
+
127
+ function templateAnnotationOrNull(
128
+ template: string | undefined,
129
+ params: Record<string, string>,
130
+ ): string | null {
131
+ const parts: string[] = [];
132
+ if (template) parts.push(template);
133
+ const paramInner = paramsToInner(params);
134
+ if (paramInner) parts.push(paramInner);
135
+ return parts.length > 0 ? `{[${parts.join(' ')}]}` : null;
136
+ }
137
+
99
138
  // ============================================
100
139
  // Heading line (Markdown / Monaco view)
101
140
  // ============================================
@@ -141,7 +180,7 @@ function splitHeadingLine(line: string): SplitHeadingLine | null {
141
180
 
142
181
  /**
143
182
  * Read the transition fields off a heading line. Looks in both the Pandoc
144
- * `{…}` block (canonical) and the `{[…]}` template params (hand-typed),
183
+ * `{…}` block (legacy) and the `{[…]}` template params (canonical),
145
184
  * with the Pandoc block taking precedence. Returns the empty transition for
146
185
  * non-heading lines.
147
186
  */
@@ -149,7 +188,7 @@ export function readHeadingLineTransition(line: string): TransitionFields {
149
188
  const split = splitHeadingLine(line);
150
189
  if (!split) return { ...EMPTY_TRANSITION };
151
190
  const fromTemplate = split.templateText
152
- ? paramsFromTokenString(stripTemplateBraces(split.templateText), true)
191
+ ? templatePartsFromInner(stripTemplateBraces(split.templateText)).params
153
192
  : {};
154
193
  const fromPandoc = split.pandocInner
155
194
  ? (parsePandocAttrTokens(split.pandocInner).params ?? {})
@@ -159,8 +198,9 @@ export function readHeadingLineTransition(line: string): TransitionFields {
159
198
 
160
199
  /**
161
200
  * Return `line` with its transition rewritten from `next`, writing into the
162
- * Pandoc `{…}` block and leaving the `{[…]}` template annotation untouched.
163
- * Non-heading lines are returned unchanged.
201
+ * squisq-native `{[]}` annotation. Legacy Pandoc transition keys are removed
202
+ * while preserving ids, classes, and other Pandoc params. Non-heading lines are
203
+ * returned unchanged.
164
204
  */
165
205
  export function setHeadingLineTransition(line: string, next: TransitionFields): string {
166
206
  const split = splitHeadingLine(line);
@@ -168,12 +208,18 @@ export function setHeadingLineTransition(line: string, next: TransitionFields):
168
208
  const attrs: HeadingAttributes = split.pandocInner
169
209
  ? parsePandocAttrTokens(split.pandocInner)
170
210
  : {};
171
- attrs.params = applyFieldsToParams(attrs.params ?? {}, next);
211
+ removeFieldsFromAttrs(attrs);
172
212
  const pandoc = pandocBlockOrNull(attrs);
173
213
 
214
+ const templateParts = templatePartsFromInner(
215
+ split.templateText ? stripTemplateBraces(split.templateText) : null,
216
+ );
217
+ const templateParams = applyFieldsToParams(templateParts.params, next);
218
+ const template = templateAnnotationOrNull(templateParts.template, templateParams);
219
+
174
220
  let out = split.prefix + split.text;
175
221
  if (pandoc) out += ` ${pandoc}`;
176
- if (split.templateText) out += ` ${split.templateText}`;
222
+ if (template) out += ` ${template}`;
177
223
  return out;
178
224
  }
179
225
 
@@ -188,8 +234,10 @@ function stripTemplateBraces(templateText: string): string {
188
234
  // ============================================
189
235
 
190
236
  /**
191
- * Read the transition fields from a heading node's `dataBlockAttrs` (Pandoc
192
- * inner) plus `dataTemplateParams` (the `{[…]}` params). Pandoc wins.
237
+ * Read the transition fields from a heading node's `dataBlockAttrs` (legacy
238
+ * Pandoc inner) plus `dataTemplateParams` (canonical `{[…]}` params). Pandoc
239
+ * wins so the picker mirrors the value that `markdownToDoc` will render when
240
+ * both channels are present.
193
241
  */
194
242
  export function readBlockAttrsTransition(
195
243
  blockAttrsInner: string | null | undefined,
@@ -200,20 +248,32 @@ export function readBlockAttrsTransition(
200
248
  return fieldsFromParams({ ...fromTemplate, ...fromPandoc });
201
249
  }
202
250
 
251
+ export interface HeadingTransitionAttrs {
252
+ /** Inner of the Pandoc `{…}` block, without braces. */
253
+ blockAttrsInner: string | null;
254
+ /** Param string inside the `{[…]}` annotation, without the template token. */
255
+ templateParams: string | null;
256
+ }
257
+
203
258
  /**
204
- * Rewrite the transition in a heading node's `dataBlockAttrs` inner string.
205
- * Returns the new inner (no braces), or null when the block carries no
206
- * attributes at all — matching how `tiptapBridge` stores `dataBlockAttrs`
207
- * (absent attribute → null, not `{}`).
259
+ * Rewrite a heading node's transition for Tiptap, writing the transition
260
+ * family into `dataTemplateParams` and removing any legacy transition keys
261
+ * from `dataBlockAttrs`.
208
262
  */
209
- export function setBlockAttrsTransition(
263
+ export function setHeadingAttrsTransition(
210
264
  blockAttrsInner: string | null | undefined,
265
+ templateParams: string | null | undefined,
211
266
  next: TransitionFields,
212
- ): string | null {
267
+ ): HeadingTransitionAttrs {
213
268
  const attrs: HeadingAttributes = blockAttrsInner ? parsePandocAttrTokens(blockAttrsInner) : {};
214
- attrs.params = applyFieldsToParams(attrs.params ?? {}, next);
269
+ removeFieldsFromAttrs(attrs);
215
270
  const pandoc = pandocBlockOrNull(attrs);
216
- return pandoc ? pandoc.slice(1, -1) : null;
271
+
272
+ const params = applyFieldsToParams(paramsFromTokenString(templateParams ?? '', false), next);
273
+ return {
274
+ blockAttrsInner: pandoc ? pandoc.slice(1, -1) : null,
275
+ templateParams: paramsToInner(params),
276
+ };
217
277
  }
218
278
 
219
279
  // ============================================
@@ -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} />;