@bendyline/squisq-editor-react 1.5.3 → 1.6.1

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 (186) hide show
  1. package/README.md +57 -10
  2. package/dist/index.d.ts +1114 -52
  3. package/dist/index.js +18446 -8050
  4. package/dist/index.js.map +1 -1
  5. package/dist/styles/fa-brands-400-AHOAZHCU.woff2 +0 -0
  6. package/dist/styles/fa-regular-400-VRZYIBIZ.woff2 +0 -0
  7. package/dist/styles/fa-solid-900-MDEYK55F.woff2 +0 -0
  8. package/dist/styles/fa-v4compatibility-ETEVP6IB.woff2 +0 -0
  9. package/dist/styles/index.css +13867 -0
  10. package/package.json +15 -7
  11. package/src/BlockCardView.tsx +121 -0
  12. package/src/BlockPreviewPanel.tsx +69 -0
  13. package/src/BlockPropertiesPopover.tsx +191 -0
  14. package/src/EditorContext.tsx +165 -16
  15. package/src/EditorShell.tsx +265 -144
  16. package/src/FolderView.tsx +131 -0
  17. package/src/Icon.tsx +26 -0
  18. package/src/ImageEditor.tsx +69 -22
  19. package/src/OutlinePanel.tsx +64 -7
  20. package/src/PlainHtmlPreview.tsx +30 -3
  21. package/src/PreviewControls.tsx +491 -122
  22. package/src/PreviewPanel.tsx +15 -9
  23. package/src/RawEditor.tsx +179 -17
  24. package/src/RecorderEntry.tsx +9 -16
  25. package/src/TemplateAnnotation.ts +44 -0
  26. package/src/TemplatePicker.tsx +329 -54
  27. package/src/ThemeCustomizerPanel.tsx +30 -336
  28. package/src/ThemePicker.tsx +112 -3
  29. package/src/TimelineBlockPreview.tsx +37 -0
  30. package/src/TimelineTrack.tsx +671 -0
  31. package/src/Toolbar.tsx +544 -180
  32. package/src/Tooltip.tsx +22 -4
  33. package/src/TransitionPicker.tsx +351 -0
  34. package/src/VersionHistoryPanel.tsx +4 -16
  35. package/src/ViewMenuPanel.tsx +17 -14
  36. package/src/WysiwygEditor.tsx +161 -65
  37. package/src/__tests__/blockProperties.test.ts +92 -0
  38. package/src/__tests__/blockRange.test.ts +105 -0
  39. package/src/__tests__/buildPreviewDocTransition.test.ts +73 -0
  40. package/src/__tests__/codeContextSectionView.test.tsx +95 -0
  41. package/src/__tests__/codeContextZoneManager.test.ts +127 -0
  42. package/src/__tests__/createShapeLayer.test.ts +46 -0
  43. package/src/__tests__/diffContextSections.test.ts +39 -0
  44. package/src/__tests__/drawingShapeRoundTrip.test.ts +49 -0
  45. package/src/__tests__/editorShellCodeContext.test.tsx +86 -0
  46. package/src/__tests__/editorShellProps.test.tsx +96 -0
  47. package/src/__tests__/embeddedMedia.test.ts +48 -0
  48. package/src/__tests__/headingTransition.test.ts +138 -0
  49. package/src/__tests__/layoutChildRoundTrip.test.ts +71 -0
  50. package/src/__tests__/plainHtmlPreview.test.tsx +10 -8
  51. package/src/__tests__/previewControls.test.tsx +70 -0
  52. package/src/__tests__/recorderMediaInsert.test.ts +86 -0
  53. package/src/__tests__/templateAnnotationRoundTrip.test.ts +18 -0
  54. package/src/__tests__/templatePickerMetadata.test.ts +32 -0
  55. package/src/__tests__/timelineSource.test.ts +134 -0
  56. package/src/__tests__/tiptapBridge.test.ts +92 -0
  57. package/src/__tests__/tiptapBridgeConformance.test.ts +47 -0
  58. package/src/__tests__/tooltip.test.tsx +72 -0
  59. package/src/__tests__/transitionCatalog.test.ts +64 -0
  60. package/src/__tests__/useBlockNavigator.test.tsx +67 -0
  61. package/src/__tests__/useJsonEditorTokens.test.ts +59 -0
  62. package/src/__tests__/useMediaRecorder.test.ts +41 -0
  63. package/src/__tests__/useTimelineClock.test.ts +21 -0
  64. package/src/blockProperties.ts +88 -0
  65. package/src/blockRange.ts +132 -0
  66. package/src/buildPreviewDoc.ts +98 -9
  67. package/src/codeContext/CodeContextSectionView.tsx +124 -0
  68. package/src/codeContext/CodeContextZoneManager.ts +149 -0
  69. package/src/codeContext/CodeContextZones.tsx +121 -0
  70. package/src/codeContext/diffContextSections.ts +38 -0
  71. package/src/codeContext/types.ts +75 -0
  72. package/src/customTemplates/AddBin.tsx +126 -0
  73. package/src/customTemplates/CustomLayoutManager.tsx +233 -0
  74. package/src/customTemplates/CustomTemplateContext.tsx +182 -0
  75. package/src/customTemplates/LayerToolbar.tsx +580 -0
  76. package/src/customTemplates/ShapeGlyph.tsx +47 -0
  77. package/src/customTemplates/TemplateDesigner.tsx +430 -0
  78. package/src/customTemplates/__tests__/library.test.ts +88 -0
  79. package/src/customTemplates/__tests__/normalizePositions.test.ts +109 -0
  80. package/src/customTemplates/__tests__/shapeDefs.test.ts +49 -0
  81. package/src/customTemplates/__tests__/useMemoryLayerAdapter.test.ts +95 -0
  82. package/src/customTemplates/designer.css +673 -0
  83. package/src/customTemplates/index.ts +31 -0
  84. package/src/customTemplates/library.ts +97 -0
  85. package/src/customTemplates/normalizePositions.ts +75 -0
  86. package/src/customTemplates/shapeDefs.ts +131 -0
  87. package/src/customTemplates/thumbnail.tsx +63 -0
  88. package/src/customTemplates/tokenDefs.ts +60 -0
  89. package/src/customTemplates/useDocCustomTemplates.ts +52 -0
  90. package/src/customTemplates/useMemoryLayerAdapter.ts +123 -0
  91. package/src/customThemes/CustomThemeContext.tsx +179 -0
  92. package/src/customThemes/CustomThemeDialog.tsx +286 -0
  93. package/src/customThemes/__tests__/CustomThemeContext.test.tsx +64 -0
  94. package/src/customThemes/__tests__/CustomThemeDialog.test.tsx +47 -0
  95. package/src/customThemes/__tests__/customThemeLibrary.test.ts +51 -0
  96. package/src/customThemes/customThemeLibrary.ts +97 -0
  97. package/src/customThemes/index.ts +31 -0
  98. package/src/customThemes/themeControls.tsx +229 -0
  99. package/src/customThemes/themeDraft.ts +272 -0
  100. package/src/customThemes/useDocCustomThemes.ts +49 -0
  101. package/src/diagram/DiagramCanvas.tsx +240 -0
  102. package/src/diagram/DiagramExtension.ts +209 -0
  103. package/src/diagram/DiagramMaximizedOverlay.tsx +46 -0
  104. package/src/diagram/DiagramWidget.tsx +270 -0
  105. package/src/diagram/diagramCommands.ts +604 -0
  106. package/src/diagram/diagramConstants.ts +17 -0
  107. package/src/diagram/useDiagramData.ts +126 -0
  108. package/src/embeddedMedia.ts +78 -0
  109. package/src/frontmatter.ts +29 -0
  110. package/src/headingTransition.ts +231 -0
  111. package/src/imageEditor/CanvasSurface.tsx +383 -88
  112. package/src/imageEditor/PropertiesPanel.tsx +47 -1
  113. package/src/imageEditor/Toolbar.tsx +229 -16
  114. package/src/imageEditor/createShapeLayer.ts +280 -0
  115. package/src/imageEditor/icons.tsx +34 -114
  116. package/src/imageEditor/image-editor.css +54 -5
  117. package/src/imageEditor/state.ts +23 -3
  118. package/src/index.ts +109 -1
  119. package/src/jsonEditor/useJsonEditorTokens.ts +13 -43
  120. package/src/recorder/RecorderModal.tsx +120 -53
  121. package/src/recorder/RecorderPanel.tsx +2 -26
  122. package/src/recorder/hooks/useMediaRecorder.ts +17 -11
  123. package/src/recorder/insertMediaBlock.ts +30 -0
  124. package/src/resolveBlockVisual.ts +33 -0
  125. package/src/scene/Scene.tsx +540 -0
  126. package/src/scene/SceneBlockExtension.ts +198 -0
  127. package/src/scene/SceneBlockToolbar.tsx +201 -0
  128. package/src/scene/SceneBlockWidget.tsx +434 -0
  129. package/src/scene/ScenePropsBar.tsx +85 -0
  130. package/src/scene/SceneSelection.tsx +107 -0
  131. package/src/scene/SceneViewport.tsx +102 -0
  132. package/src/scene/ShapePalette.tsx +181 -0
  133. package/src/scene/__tests__/DiagramAdapter.test.ts +56 -0
  134. package/src/scene/__tests__/bezierEdit.test.ts +85 -0
  135. package/src/scene/__tests__/blockLayers.test.ts +57 -0
  136. package/src/scene/__tests__/shapeLayers.test.ts +106 -0
  137. package/src/scene/__tests__/useSceneHitTest.test.ts +90 -0
  138. package/src/scene/__tests__/useScenePanZoom.test.ts +103 -0
  139. package/src/scene/adapters/DiagramAdapter.ts +168 -0
  140. package/src/scene/adapters/DrawingAdapter.ts +415 -0
  141. package/src/scene/adapters/LayoutAdapter.ts +310 -0
  142. package/src/scene/adapters/blockLayers.ts +159 -0
  143. package/src/scene/commands/SceneCommand.ts +70 -0
  144. package/src/scene/commands/drawingCommands.ts +318 -0
  145. package/src/scene/commands/layoutCommands.ts +301 -0
  146. package/src/scene/hooks/useSceneHitTest.ts +105 -0
  147. package/src/scene/hooks/useScenePanZoom.ts +147 -0
  148. package/src/scene/hooks/useSceneSelection.ts +62 -0
  149. package/src/scene/index.ts +95 -0
  150. package/src/scene/layers/DiagramEdges.tsx +127 -0
  151. package/src/scene/layers/edgeGeometry.ts +77 -0
  152. package/src/scene/layers/nodeCard.tsx +145 -0
  153. package/src/scene/layers/renderLayer.tsx +70 -0
  154. package/src/scene/layers/shapeLayers.ts +201 -0
  155. package/src/scene/paths/bezierEdit.ts +208 -0
  156. package/src/scene/scene.css +649 -0
  157. package/src/scene/text/SceneTextOverlay.tsx +161 -0
  158. package/src/scene/text/sceneTextChannel.ts +40 -0
  159. package/src/scene/text/sceneTextConfig.ts +27 -0
  160. package/src/scene/text/sceneTiptap.ts +36 -0
  161. package/src/scene/text/useSceneTextEditing.ts +39 -0
  162. package/src/scene/tools/ConnectTool.ts +111 -0
  163. package/src/scene/tools/DrawingConnectTool.ts +161 -0
  164. package/src/scene/tools/PathTool.ts +158 -0
  165. package/src/scene/tools/PlaceTool.ts +47 -0
  166. package/src/scene/tools/SceneTool.ts +75 -0
  167. package/src/scene/tools/SelectTool.ts +284 -0
  168. package/src/scene/tools/ShapeTool.ts +144 -0
  169. package/src/scene/tools/TextTool.ts +72 -0
  170. package/src/scene/tools/TokenTool.ts +95 -0
  171. package/src/scene/tools/createDrawShapeTool.ts +82 -0
  172. package/src/styles/code-context.css +155 -0
  173. package/src/styles/diagram.css +183 -0
  174. package/src/styles/editor.css +1708 -150
  175. package/src/styles/folder-view.css +210 -0
  176. package/src/styles/image-edit-affordance.css +2 -2
  177. package/src/styles/index.css +5 -0
  178. package/src/timelineSource.ts +244 -0
  179. package/src/tiptapBridge.ts +115 -34
  180. package/src/tooltipPlacement.ts +13 -0
  181. package/src/transitionCatalog.ts +159 -0
  182. package/src/types/monaco-shims.d.ts +10 -0
  183. package/src/useBlockNavigator.ts +153 -0
  184. package/src/useMonacoLoader.ts +23 -1
  185. package/src/useTimelineClock.ts +76 -0
  186. package/src/utils/dropUtils.ts +1 -1
@@ -0,0 +1,131 @@
1
+ import type { ReactNode } from 'react';
2
+
3
+ /**
4
+ * One entry in a {@link FolderView} — a file or subfolder. Host-defined
5
+ * `path` is opaque to this component: it's used as the React key and
6
+ * handed back verbatim to the open callbacks so the host can resolve it.
7
+ */
8
+ export interface FolderEntry {
9
+ /** Display name (basename). */
10
+ name: string;
11
+ /** Full host path. Opaque here; passed back on open. */
12
+ path: string;
13
+ isDirectory: boolean;
14
+ }
15
+
16
+ export interface FolderViewProps {
17
+ /** Folder display name (basename) shown in the header. */
18
+ name: string;
19
+ /** Immediate children of the folder — subfolders and files. */
20
+ entries: FolderEntry[];
21
+ /** Open a file entry. The host decides what "open" means. */
22
+ onOpenFile: (entry: FolderEntry) => void;
23
+ /** Drill into a subfolder. */
24
+ onOpenFolder: (entry: FolderEntry) => void;
25
+ /** Primary action — create a new document in this folder. */
26
+ onNewDocument: () => void;
27
+ /** Secondary action — create a new subfolder. Omit to hide the button. */
28
+ onNewFolder?: () => void;
29
+ /** Color theme (default `'light'`). */
30
+ theme?: 'light' | 'dark';
31
+ /** CSS height for the container (default `'100%'`). */
32
+ height?: string;
33
+ /** Override the per-entry icon. Defaults to FontAwesome file/folder glyphs. */
34
+ iconFor?: (entry: FolderEntry) => ReactNode;
35
+ }
36
+
37
+ /**
38
+ * FolderView — a standalone folder browser surface.
39
+ *
40
+ * Lists a folder's files and subfolders plus a prominent "New document"
41
+ * action (and an optional "New folder"). It's the companion to
42
+ * {@link EditorShell}: where the shell edits a single document, this
43
+ * presents the directory around it.
44
+ *
45
+ * Like the shell, it's host-agnostic — the consumer supplies the
46
+ * `entries` and the open / new callbacks, and FolderView owns only the
47
+ * presentation and theming. It holds no state and reads nothing from
48
+ * disk, so it composes into any storage backend.
49
+ */
50
+ export function FolderView({
51
+ name,
52
+ entries,
53
+ onOpenFile,
54
+ onOpenFolder,
55
+ onNewDocument,
56
+ onNewFolder,
57
+ theme = 'light',
58
+ height = '100%',
59
+ iconFor = defaultIconFor,
60
+ }: FolderViewProps) {
61
+ // Folders first, then files; each group alphabetical (case-insensitive).
62
+ const sorted = [...entries].sort((a, b) => {
63
+ if (a.isDirectory !== b.isDirectory) return a.isDirectory ? -1 : 1;
64
+ return a.name.localeCompare(b.name, undefined, { sensitivity: 'base' });
65
+ });
66
+
67
+ return (
68
+ <div className="squisq-folder-view" data-theme={theme} style={{ height }}>
69
+ <div className="squisq-folder-view-header">
70
+ <i
71
+ className="fa-regular fa-folder-open squisq-folder-view-header-icon"
72
+ aria-hidden="true"
73
+ />
74
+ <span className="squisq-folder-view-title">{name}</span>
75
+ <span className="squisq-folder-view-count">
76
+ {entries.length} {entries.length === 1 ? 'item' : 'items'}
77
+ </span>
78
+ </div>
79
+
80
+ <div className="squisq-folder-view-body">
81
+ {sorted.length === 0 ? (
82
+ <p className="squisq-folder-view-empty">This folder is empty.</p>
83
+ ) : (
84
+ <ul className="squisq-folder-view-list">
85
+ {sorted.map((entry) => (
86
+ <li key={entry.path}>
87
+ <button
88
+ type="button"
89
+ className="squisq-folder-view-item"
90
+ onClick={() => (entry.isDirectory ? onOpenFolder(entry) : onOpenFile(entry))}
91
+ >
92
+ <span className="squisq-folder-view-item-icon">{iconFor(entry)}</span>
93
+ <span className="squisq-folder-view-item-name">{entry.name}</span>
94
+ {entry.isDirectory && (
95
+ <i
96
+ className="fa-solid fa-chevron-right squisq-folder-view-item-chevron"
97
+ aria-hidden="true"
98
+ />
99
+ )}
100
+ </button>
101
+ </li>
102
+ ))}
103
+ </ul>
104
+ )}
105
+ </div>
106
+
107
+ <div className="squisq-folder-view-actions">
108
+ <button type="button" className="squisq-folder-view-new" onClick={onNewDocument}>
109
+ <i className="fa-solid fa-plus" aria-hidden="true" /> New document
110
+ </button>
111
+ {onNewFolder && (
112
+ <button type="button" className="squisq-folder-view-new-folder" onClick={onNewFolder}>
113
+ <i className="fa-regular fa-folder" aria-hidden="true" /> New folder
114
+ </button>
115
+ )}
116
+ </div>
117
+ </div>
118
+ );
119
+ }
120
+
121
+ const IMAGE_RE = /\.(png|jpe?g|gif|svg|webp|bmp|avif)$/i;
122
+
123
+ function defaultIconFor(entry: FolderEntry): ReactNode {
124
+ if (entry.isDirectory) {
125
+ return <i className="fa-regular fa-folder fa-fw" aria-hidden="true" />;
126
+ }
127
+ if (IMAGE_RE.test(entry.name)) {
128
+ return <i className="fa-regular fa-file-image fa-fw" aria-hidden="true" />;
129
+ }
130
+ return <i className="fa-regular fa-file-lines fa-fw" aria-hidden="true" />;
131
+ }
package/src/Icon.tsx ADDED
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Icon — renders a Font Awesome Free glyph via the webfont CSS that
3
+ * `styles/index.css` imports (`@fortawesome/fontawesome-free`).
4
+ *
5
+ * Icons are decorative: the surrounding control owns the accessible
6
+ * label (`aria-label` / `data-tooltip`), so the glyph is marked
7
+ * `aria-hidden`. Sizing and color flow from the parent's `font-size`
8
+ * and `color`, matching how the inline `{[icon]}` content nodes render
9
+ * (see `InlineIcon.ts`).
10
+ */
11
+
12
+ import type { CSSProperties } from 'react';
13
+
14
+ export interface IconProps {
15
+ /** Full Font Awesome class string, e.g. `"fa-solid fa-bold"`. */
16
+ icon: string;
17
+ /** Extra class names appended after the Font Awesome classes. */
18
+ className?: string;
19
+ style?: CSSProperties;
20
+ }
21
+
22
+ export function Icon({ icon, className, style }: IconProps) {
23
+ return (
24
+ <i className={className ? `${icon} ${className}` : icon} style={style} aria-hidden="true" />
25
+ );
26
+ }
@@ -13,7 +13,7 @@
13
13
  * `exportImageEditDoc` from `@bendyline/squisq/imageEdit`.
14
14
  */
15
15
 
16
- import { useCallback, useState } from 'react';
16
+ import { useCallback, useRef, useState } from 'react';
17
17
  import type { ContentContainer } from '@bendyline/squisq/storage';
18
18
  import { exportImageEditDoc, type ImageEditExportFormat } from '@bendyline/squisq/imageEdit';
19
19
  import type { SurfaceScheme, Theme } from '@bendyline/squisq/schemas';
@@ -24,6 +24,13 @@ import { PropertiesPanel } from './imageEditor/PropertiesPanel.js';
24
24
  import { Toolbar } from './imageEditor/Toolbar.js';
25
25
  import { useImageEditor } from './imageEditor/useImageEditor.js';
26
26
  import { useImageEditorTokens } from './imageEditor/useImageEditorTokens.js';
27
+ import {
28
+ createShapeLayer,
29
+ createLinearShapeLayer,
30
+ isLinearShapeKind,
31
+ } from './imageEditor/createShapeLayer.js';
32
+
33
+ const ZOOM_STEPS = [0.0625, 0.083, 0.125, 0.167, 0.25, 0.333, 0.5, 0.667, 1, 1.5, 2, 3, 4, 8, 16];
27
34
 
28
35
  export interface ImageEditorProps {
29
36
  /**
@@ -107,6 +114,33 @@ export function ImageEditor(props: ImageEditorProps) {
107
114
  // re-lists without polling.
108
115
  const [historyRefreshKey, setHistoryRefreshKey] = useState(0);
109
116
 
117
+ const surfaceRef = useRef<HTMLDivElement>(null);
118
+ const [zoom, setZoom] = useState(1.0);
119
+
120
+ const handleZoomIn = useCallback(() => {
121
+ setZoom((z) => ZOOM_STEPS.find((s) => s > z + 0.001) ?? 16);
122
+ }, []);
123
+ const handleZoomOut = useCallback(() => {
124
+ setZoom((z) => [...ZOOM_STEPS].reverse().find((s) => s < z - 0.001) ?? 0.0625);
125
+ }, []);
126
+ const handleZoomSet = useCallback((z: number) => {
127
+ setZoom(Math.max(0.0625, Math.min(16, z)));
128
+ }, []);
129
+ const handleZoom1to1 = useCallback(() => setZoom(1), []);
130
+ const handleZoomFit = useCallback(() => {
131
+ if (!state || !surfaceRef.current) return;
132
+ const { clientWidth, clientHeight } = surfaceRef.current;
133
+ const PADDING = 32;
134
+ const fitZ = Math.min(
135
+ (clientWidth - PADDING) / state.doc.canvas.width,
136
+ (clientHeight - PADDING) / state.doc.canvas.height,
137
+ );
138
+ setZoom(Math.max(0.0625, Math.min(16, fitZ)));
139
+ }, [state]);
140
+
141
+ // ID of a newly-created text layer that should immediately enter inline editing.
142
+ const [requestEditLayerId, setRequestEditLayerId] = useState<string | null>(null);
143
+
110
144
  const handleExport = useCallback(
111
145
  async (format: ImageEditExportFormat) => {
112
146
  if (!state) return;
@@ -198,9 +232,11 @@ export function ImageEditor(props: ImageEditorProps) {
198
232
 
199
233
  const handleCreateTextAt = useCallback(
200
234
  (x: number, y: number) => {
235
+ const id = `layer-${Math.random().toString(36).slice(2, 10)}`;
201
236
  dispatch({
202
237
  type: 'add-layer',
203
238
  layer: {
239
+ id,
204
240
  type: 'text',
205
241
  name: 'Text',
206
242
  position: { x: Math.round(x), y: Math.round(y), width: 240, height: 48 },
@@ -211,35 +247,33 @@ export function ImageEditor(props: ImageEditorProps) {
211
247
  },
212
248
  });
213
249
  dispatch({ type: 'set-tool', tool: 'select' });
250
+ setRequestEditLayerId(id);
214
251
  },
215
252
  [dispatch],
216
253
  );
217
254
 
255
+ const shapeKind = state?.shapeKind ?? 'rectangle';
256
+ const shapeDragDraw = isLinearShapeKind(shapeKind);
257
+
218
258
  const handleCreateShapeAt = useCallback(
219
259
  (x: number, y: number) => {
220
- dispatch({
221
- type: 'add-layer',
222
- layer: {
223
- type: 'shape',
224
- name: 'Rectangle',
225
- position: {
226
- x: Math.round(x - 60),
227
- y: Math.round(y - 40),
228
- width: 120,
229
- height: 80,
230
- },
231
- content: {
232
- shape: 'rect',
233
- fill: '#3399ff',
234
- stroke: '#1a4d80',
235
- strokeWidth: 2,
236
- borderRadius: 8,
237
- },
238
- },
239
- });
260
+ const layer = createShapeLayer(shapeKind, x, y);
261
+ const id = `layer-${Math.random().toString(36).slice(2, 10)}`;
262
+ dispatch({ type: 'add-layer', layer: { ...layer, id } });
263
+ dispatch({ type: 'set-tool', tool: 'select' });
264
+ if (layer.type === 'text') {
265
+ setRequestEditLayerId(id);
266
+ }
267
+ },
268
+ [dispatch, shapeKind],
269
+ );
270
+
271
+ const handleCreateShapeFromPoints = useCallback(
272
+ (x1: number, y1: number, x2: number, y2: number) => {
273
+ dispatch({ type: 'add-layer', layer: createLinearShapeLayer(shapeKind, x1, y1, x2, y2) });
240
274
  dispatch({ type: 'set-tool', tool: 'select' });
241
275
  },
242
- [dispatch],
276
+ [dispatch, shapeKind],
243
277
  );
244
278
 
245
279
  if (error) {
@@ -275,6 +309,7 @@ export function ImageEditor(props: ImageEditorProps) {
275
309
  <Toolbar
276
310
  doc={state.doc}
277
311
  tool={state.tool}
312
+ shapeKind={state.shapeKind}
278
313
  dispatch={dispatch}
279
314
  uploadAsset={uploadAsset}
280
315
  onExport={handleExport}
@@ -296,6 +331,12 @@ export function ImageEditor(props: ImageEditorProps) {
296
331
  />
297
332
  ) : null
298
333
  }
334
+ zoom={zoom}
335
+ onZoomIn={handleZoomIn}
336
+ onZoomOut={handleZoomOut}
337
+ onZoomSet={handleZoomSet}
338
+ onZoomFit={handleZoomFit}
339
+ onZoom1to1={handleZoom1to1}
299
340
  />
300
341
  <div className="squisq-image-editor-body">
301
342
  <div className="squisq-image-editor-center">
@@ -307,6 +348,12 @@ export function ImageEditor(props: ImageEditorProps) {
307
348
  dispatch={dispatch}
308
349
  onCreateTextAt={handleCreateTextAt}
309
350
  onCreateShapeAt={handleCreateShapeAt}
351
+ onCreateShapeFromPoints={handleCreateShapeFromPoints}
352
+ shapeDragDraw={shapeDragDraw}
353
+ zoom={zoom}
354
+ onSetZoom={handleZoomSet}
355
+ surfaceRef={surfaceRef}
356
+ requestEditLayerId={requestEditLayerId}
310
357
  />
311
358
  </div>
312
359
  <div className="squisq-image-editor-side">
@@ -17,18 +17,69 @@ import { templateLabel } from './TemplatePicker';
17
17
  import { useHeadingLayout } from './useHeadingLayout';
18
18
  import { usePreviewSettingsOptional } from './PreviewControls';
19
19
 
20
+ /**
21
+ * Responsive default width for the outline pane, used when no fixed `width`
22
+ * (or `--squisq-outline-width`) is supplied: never narrower than 260px, grows
23
+ * with the viewport so it stretches out to fill horizontal space when there's
24
+ * room, and caps at 460px so it never dominates the editor. A viewport unit
25
+ * (rather than
26
+ * a container unit) keeps the pane and the toolbar's view-tabs — which read the
27
+ * same value — resolving to an identical width, so their right edges stay
28
+ * aligned.
29
+ */
30
+ export const OUTLINE_RESPONSIVE_WIDTH = 'clamp(260px, 30vw, 460px)';
31
+
20
32
  export interface OutlinePanelProps {
21
- /** Width of the pane in pixels (default: 240). */
33
+ /**
34
+ * Fixed width of the pane in pixels. When omitted, the pane sizes
35
+ * responsively from `--squisq-outline-width` (falling back to
36
+ * {@link OUTLINE_RESPONSIVE_WIDTH}) so it stretches on wider screens.
37
+ */
22
38
  width?: number;
23
39
  /** Optional CSS class for the outer container. */
24
40
  className?: string;
25
41
  }
26
42
 
27
- export function OutlinePanel({ width = 240, className }: OutlinePanelProps) {
28
- const { doc, markdownSource, setMarkdownSource } = useEditorContext();
43
+ export function OutlinePanel({ width, className }: OutlinePanelProps) {
44
+ const {
45
+ doc,
46
+ markdownSource,
47
+ setMarkdownSource,
48
+ layoutMode,
49
+ goToBlockByLine,
50
+ activeBlockStartLine,
51
+ } = useEditorContext();
29
52
  const paneRef = useRef<HTMLElement | null>(null);
30
53
  const { scrollToBlock } = useHeadingLayout(paneRef);
31
- const activeBlockId = useActiveOutlineBlockId();
54
+ const cursorActiveId = useActiveOutlineBlockId();
55
+
56
+ // In block-at-a-time mode there's no live cursor across the whole document
57
+ // (only the active block is mounted), so the highlight follows the card:
58
+ // match the block whose heading begins on the active slice's source line.
59
+ const blockModeActiveId = useMemo(() => {
60
+ if (layoutMode !== 'block' || activeBlockStartLine == null || !doc) return null;
61
+ const match = flattenBlocks(doc.blocks).find(
62
+ (b) => b.sourceHeading?.position?.start.line === activeBlockStartLine,
63
+ );
64
+ return match?.id ?? null;
65
+ }, [layoutMode, activeBlockStartLine, doc]);
66
+ const activeBlockId = layoutMode === 'block' ? blockModeActiveId : cursorActiveId;
67
+
68
+ // Clicking a row jumps the card to that block in block mode, or scrolls the
69
+ // editor to it in document mode.
70
+ const handleSelect = useCallback(
71
+ (block: Block) => {
72
+ if (layoutMode === 'block') {
73
+ const line = block.sourceHeading?.position?.start.line;
74
+ if (typeof line === 'number') {
75
+ goToBlockByLine(line);
76
+ return;
77
+ }
78
+ }
79
+ scrollToBlock(block);
80
+ },
81
+ [layoutMode, goToBlockByLine, scrollToBlock],
82
+ );
32
83
 
33
84
  // Promote / demote the row's heading by rewriting just the `#` prefix
34
85
  // on the heading line. Falls through when the new depth would leave the
@@ -53,9 +104,15 @@ export function OutlinePanel({ width = 240, className }: OutlinePanelProps) {
53
104
  const accentColor = previewSettings?.activeTheme?.colors?.primary;
54
105
 
55
106
  const isEmpty = !doc || doc.blocks.length === 0 || !hasAnyHeading(doc.blocks);
107
+ // Fixed px when a width is supplied; otherwise size from the shared
108
+ // `--squisq-outline-width` variable (the shell sets it, and the toolbar's
109
+ // view-tabs read the same value so their right edges stay aligned). The
110
+ // clamp fallback lets a standalone OutlinePanel stretch on wide screens too.
111
+ const basis =
112
+ width != null ? `${width}px` : `var(--squisq-outline-width, ${OUTLINE_RESPONSIVE_WIDTH})`;
56
113
  const paneStyle: CSSProperties = {
57
- width: `${width}px`,
58
- flex: `0 0 ${width}px`,
114
+ width: basis,
115
+ flex: `0 0 ${basis}`,
59
116
  overflow: 'auto',
60
117
  ...(accentColor
61
118
  ? ({ ['--squisq-outline-accent' as string]: accentColor } as CSSProperties)
@@ -81,7 +138,7 @@ export function OutlinePanel({ width = 240, className }: OutlinePanelProps) {
81
138
  key={b.id}
82
139
  block={b}
83
140
  activeBlockId={activeBlockId}
84
- onSelect={scrollToBlock}
141
+ onSelect={handleSelect}
85
142
  onChangeLevel={changeHeadingLevel}
86
143
  />
87
144
  ))}
@@ -21,10 +21,25 @@ import type { CSSProperties } from 'react';
21
21
  import { parseMarkdown } from '@bendyline/squisq/markdown';
22
22
  import type { MarkdownDocument, HtmlNode } from '@bendyline/squisq/markdown';
23
23
  import type { MediaProvider, Theme } from '@bendyline/squisq/schemas';
24
- import { markdownDocToPlainHtml } from '@bendyline/squisq-formats/html';
25
24
  import { normalizeMalformedAssetUrl } from './utils/normalizeMalformedAssetUrl';
26
25
  import { collectInlineFontAwesomeCss } from './utils/collectInlineFontAwesomeCss';
27
26
 
27
+ // formats/html is ~50 KB and only needed for the plain-HTML preview pane.
28
+ // Lazy-load on first render so it doesn't sit in the editor's main chunk.
29
+ type RenderFn = typeof import('@bendyline/squisq-formats/html').markdownDocToPlainHtml;
30
+ let cachedRender: RenderFn | null = null;
31
+ let cachedRenderPromise: Promise<RenderFn> | null = null;
32
+ function loadRenderFn(): Promise<RenderFn> {
33
+ if (cachedRender) return Promise.resolve(cachedRender);
34
+ if (!cachedRenderPromise) {
35
+ cachedRenderPromise = import('@bendyline/squisq-formats/html').then((m) => {
36
+ cachedRender = m.markdownDocToPlainHtml;
37
+ return cachedRender;
38
+ });
39
+ }
40
+ return cachedRenderPromise;
41
+ }
42
+
28
43
  export interface PlainHtmlPreviewProps {
29
44
  /** Raw markdown source. */
30
45
  markdown: string;
@@ -129,9 +144,21 @@ export function PlainHtmlPreview({
129
144
  // fetch under `allow-same-origin`.
130
145
  const iconsCss = useMemo(() => collectInlineFontAwesomeCss(), []);
131
146
 
147
+ const [renderFn, setRenderFn] = useState<RenderFn | null>(() => cachedRender);
148
+ useEffect(() => {
149
+ if (renderFn) return;
150
+ let cancelled = false;
151
+ loadRenderFn().then((fn) => {
152
+ if (!cancelled) setRenderFn(() => fn);
153
+ });
154
+ return () => {
155
+ cancelled = true;
156
+ };
157
+ }, [renderFn]);
158
+
132
159
  const html = useMemo(
133
- () => markdownDocToPlainHtml(mdDoc, { title, images: mergedImages, theme, iconsCss }),
134
- [mdDoc, title, mergedImages, theme, iconsCss],
160
+ () => (renderFn ? renderFn(mdDoc, { title, images: mergedImages, theme, iconsCss }) : ''),
161
+ [renderFn, mdDoc, title, mergedImages, theme, iconsCss],
135
162
  );
136
163
 
137
164
  return (