@bendyline/squisq-editor-react 2.0.0 → 2.1.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 (110) hide show
  1. package/README.md +10 -2
  2. package/dist/index.d.ts +579 -47
  3. package/dist/index.js +7477 -2943
  4. package/dist/index.js.map +1 -1
  5. package/dist/styles/index.css +1008 -38
  6. package/package.json +6 -5
  7. package/src/DocumentSettingsDialog.tsx +32 -18
  8. package/src/EditorContext.tsx +27 -0
  9. package/src/EditorShell.tsx +25 -1
  10. package/src/PreviewControls.tsx +77 -29
  11. package/src/PreviewPanel.tsx +5 -1
  12. package/src/RawEditor.tsx +12 -0
  13. package/src/RecorderEntry.tsx +5 -0
  14. package/src/Toolbar.tsx +479 -36
  15. package/src/WysiwygEditor.tsx +25 -11
  16. package/src/__tests__/buildPreviewDocContent.test.ts +17 -0
  17. package/src/__tests__/codeContextSectionView.test.tsx +8 -6
  18. package/src/__tests__/documentSettingsDialog.test.tsx +22 -0
  19. package/src/__tests__/editorShellProps.test.tsx +65 -0
  20. package/src/__tests__/findMode.test.tsx +104 -0
  21. package/src/__tests__/findModel.test.ts +55 -0
  22. package/src/__tests__/markdownCodeFence.test.ts +45 -0
  23. package/src/__tests__/mediaAttachmentFlow.test.ts +2 -2
  24. package/src/__tests__/mediaReferences.test.ts +15 -0
  25. package/src/__tests__/previewControls.test.tsx +195 -0
  26. package/src/__tests__/recorderTheme.test.tsx +42 -0
  27. package/src/__tests__/selectionConversions.test.ts +80 -0
  28. package/src/__tests__/tiptapBridge.test.ts +57 -7
  29. package/src/__tests__/tiptapImageRoundTrip.test.ts +1 -1
  30. package/src/__tests__/toolbarSelectionConversion.test.tsx +190 -0
  31. package/src/__tests__/writeCanvasSettings.test.ts +15 -0
  32. package/src/asciiDiagram/AsciiDiagramWidget.tsx +34 -4
  33. package/src/asciiDiagram/__tests__/AsciiDiagramExtension.test.ts +20 -1
  34. package/src/asciiDiagram/__tests__/asciiDiagramCommands.test.ts +58 -1
  35. package/src/asciiDiagram/asciiDiagramCommands.ts +36 -10
  36. package/src/asciiDiagram/asciiDiagramData.ts +33 -0
  37. package/src/asciiDiagram/asciiDiagramOps.ts +19 -0
  38. package/src/buildPreviewDoc.ts +9 -7
  39. package/src/codeContext/types.ts +1 -1
  40. package/src/codeSnippet/CodeSnippetExtension.ts +205 -0
  41. package/src/codeSnippet/CodeSnippetWidget.tsx +109 -0
  42. package/src/codeSnippet/__tests__/CodeSnippetExtension.test.ts +95 -0
  43. package/src/codeSnippet/__tests__/codeSnippetLanguages.test.ts +50 -0
  44. package/src/codeSnippet/codeSnippetCommands.ts +21 -0
  45. package/src/codeSnippet/codeSnippetData.ts +43 -0
  46. package/src/codeSnippet/codeSnippetLanguages.ts +216 -0
  47. package/src/customTemplates/__tests__/useMemoryLayerAdapter.test.ts +13 -0
  48. package/src/customTemplates/useMemoryLayerAdapter.ts +7 -1
  49. package/src/diagram/DiagramCanvas.tsx +17 -2
  50. package/src/find/FindHighlightExtension.ts +81 -0
  51. package/src/find/FindToolbar.tsx +314 -0
  52. package/src/find/findModel.ts +77 -0
  53. package/src/frontmatterSettings.ts +23 -0
  54. package/src/index.ts +96 -1
  55. package/src/markdownCodeFence.ts +72 -0
  56. package/src/mediaReferences.ts +5 -3
  57. package/src/mermaid/MermaidDiagramCanvas.tsx +693 -0
  58. package/src/mermaid/MermaidDiagramExtension.ts +243 -0
  59. package/src/mermaid/MermaidDiagramTypeThumbnail.tsx +184 -0
  60. package/src/mermaid/MermaidDiagramWidget.tsx +653 -0
  61. package/src/mermaid/MermaidShapePalette.tsx +245 -0
  62. package/src/mermaid/__tests__/MermaidDiagramExtension.test.ts +361 -0
  63. package/src/mermaid/__tests__/mermaidDiagramTypes.test.ts +35 -0
  64. package/src/mermaid/__tests__/mermaidRenderer.test.ts +49 -0
  65. package/src/mermaid/__tests__/mermaidSourceOps.test.ts +213 -0
  66. package/src/mermaid/__tests__/mermaidSyntax.test.ts +71 -0
  67. package/src/mermaid/mermaidCommands.ts +34 -0
  68. package/src/mermaid/mermaidData.ts +31 -0
  69. package/src/mermaid/mermaidDiagramTypes.ts +325 -0
  70. package/src/mermaid/mermaidModel.ts +31 -0
  71. package/src/mermaid/mermaidRenderer.ts +181 -0
  72. package/src/mermaid/mermaidShapes.ts +113 -0
  73. package/src/mermaid/mermaidSourceOps.ts +454 -0
  74. package/src/recorder/RecorderButton.tsx +9 -1
  75. package/src/recorder/RecorderModal.tsx +84 -41
  76. package/src/recorder/RecorderPanel.tsx +9 -1
  77. package/src/scene/Scene.tsx +46 -15
  78. package/src/scene/SceneBlockToolbar.tsx +29 -14
  79. package/src/scene/SceneViewControls.tsx +43 -0
  80. package/src/scene/__tests__/fitScale.test.ts +19 -0
  81. package/src/scene/__tests__/sceneIsolation.test.tsx +27 -1
  82. package/src/scene/__tests__/useScenePanZoom.test.ts +28 -1
  83. package/src/scene/adapters/DrawingAdapter.ts +6 -1
  84. package/src/scene/adapters/LayoutAdapter.ts +3 -0
  85. package/src/scene/commands/SceneCommand.ts +13 -2
  86. package/src/scene/fitScale.ts +17 -0
  87. package/src/scene/hooks/useScenePanZoom.ts +11 -4
  88. package/src/scene/scene.css +85 -3
  89. package/src/scene/tools/SelectTool.ts +5 -5
  90. package/src/selectionConversions.ts +155 -0
  91. package/src/styles/ascii-timeline.css +101 -4
  92. package/src/styles/code-snippet.css +76 -0
  93. package/src/styles/editor.css +352 -2
  94. package/src/styles/index.css +2 -0
  95. package/src/styles/mermaid-diagram.css +487 -0
  96. package/src/styles/tree-view.css +51 -1
  97. package/src/timeline/TimelineEditorWidget.tsx +200 -41
  98. package/src/timeline/__tests__/TimelineEditorWidget.test.tsx +61 -2
  99. package/src/timeline/__tests__/timelineCommands.test.ts +32 -0
  100. package/src/timeline/__tests__/timelineOps.test.ts +55 -0
  101. package/src/timeline/timelineCommands.ts +54 -0
  102. package/src/timeline/timelineOps.ts +107 -3
  103. package/src/tiptapBridge.ts +23 -5
  104. package/src/treeview/TreeOutlineWidget.tsx +153 -3
  105. package/src/treeview/__tests__/TreeOutlineWidget.test.tsx +156 -0
  106. package/src/treeview/__tests__/treeOps.test.ts +52 -0
  107. package/src/treeview/__tests__/treeViewCommands.test.ts +16 -0
  108. package/src/treeview/treeOps.ts +59 -0
  109. package/src/treeview/treeViewCommands.ts +5 -0
  110. package/src/writeCanvasSettings.ts +30 -0
@@ -12,7 +12,8 @@
12
12
  * doc parse.
13
13
  *
14
14
  * Visual conventions match `VideoExportModal` from `@bendyline/squisq-
15
- * video-react` (cream / gold palette, inline styles, no external CSS).
15
+ * video-react`, with inline theme tokens so the body-level portal follows
16
+ * the editor's light/dark chrome scheme.
16
17
  */
17
18
 
18
19
  import { useCallback, useEffect, useRef, useState, type CSSProperties } from 'react';
@@ -25,6 +26,8 @@ import { buildTimingJson, encodeTimingJson, timingPathFor } from './timingJson.j
25
26
 
26
27
  // ── Types ──────────────────────────────────────────────────────────
27
28
 
29
+ export type RecorderColorScheme = 'light' | 'dark';
30
+
28
31
  export interface RecorderModalProps {
29
32
  /** Required — recordings are written here. */
30
33
  mediaProvider: MediaProvider;
@@ -37,6 +40,8 @@ export interface RecorderModalProps {
37
40
  container?: ContentContainer | null;
38
41
  /** Initial capture source. Defaults to `'mic'` (narration). */
39
42
  initialMode?: RecorderSource;
43
+ /** Light/dark chrome scheme. Defaults to `'light'`. */
44
+ colorScheme?: RecorderColorScheme;
40
45
  /** Called after the modal is dismissed (save or cancel). */
41
46
  onClose: () => void;
42
47
  /**
@@ -77,9 +82,34 @@ const overlayStyle: CSSProperties = {
77
82
  zIndex: 10000,
78
83
  };
79
84
 
85
+ /**
86
+ * The recorder is normally portaled to `document.body`, outside the editor
87
+ * shell's CSS-variable scope. Copy the scheme onto a fresh scope and provide
88
+ * inline fallbacks so the standalone RecorderModal/RecorderButton exports are
89
+ * themed even when a host has not loaded the editor stylesheet.
90
+ */
91
+ function recorderThemeStyle(colorScheme: RecorderColorScheme): CSSProperties {
92
+ const dark = colorScheme === 'dark';
93
+ return {
94
+ colorScheme,
95
+ '--squisq-recorder-surface': `var(--squisq-bg, ${dark ? '#1f2937' : '#fffdf7'})`,
96
+ '--squisq-recorder-input': `var(--squisq-input-bg, ${dark ? '#374151' : '#fff'})`,
97
+ '--squisq-recorder-border': `var(--squisq-border, ${dark ? '#4b5563' : '#c9b98a'})`,
98
+ '--squisq-recorder-text': `var(--squisq-text, ${dark ? '#e5e7eb' : '#4a3c1f'})`,
99
+ '--squisq-recorder-muted': `var(--squisq-text-muted, ${dark ? '#9ca3af' : '#5a4a2a'})`,
100
+ '--squisq-recorder-accent': 'var(--squisq-accent, #8b6914)',
101
+ '--squisq-recorder-accent-text': '#fff',
102
+ '--squisq-recorder-danger': dark ? '#dc4c4c' : '#b33a3a',
103
+ '--squisq-recorder-danger-border': dark ? '#ef6a6a' : '#902929',
104
+ '--squisq-recorder-error-bg': dark ? '#3f151b' : '#fceeee',
105
+ '--squisq-recorder-error-border': dark ? '#7f1d1d' : '#d88a8a',
106
+ '--squisq-recorder-error-text': dark ? '#fecdd3' : '#8c2a2a',
107
+ } as CSSProperties;
108
+ }
109
+
80
110
  const modalStyle: CSSProperties = {
81
- background: '#FFFDF7',
82
- border: '1px solid #c9b98a',
111
+ background: 'var(--squisq-recorder-surface)',
112
+ border: '1px solid var(--squisq-recorder-border)',
83
113
  borderRadius: 0,
84
114
  padding: '24px 28px',
85
115
  width: 'min(560px, calc(100vw - 48px))',
@@ -87,14 +117,14 @@ const modalStyle: CSSProperties = {
87
117
  overflowY: 'auto',
88
118
  boxShadow: '0 8px 32px rgba(0,0,0,0.18)',
89
119
  fontFamily: 'system-ui, -apple-system, sans-serif',
90
- color: '#4a3c1f',
120
+ color: 'var(--squisq-recorder-text)',
91
121
  };
92
122
 
93
123
  const titleStyle: CSSProperties = {
94
124
  margin: '0 0 16px 0',
95
125
  fontSize: 18,
96
126
  fontWeight: 600,
97
- color: '#2d2310',
127
+ color: 'var(--squisq-recorder-text)',
98
128
  };
99
129
 
100
130
  const labelStyle: CSSProperties = {
@@ -102,7 +132,7 @@ const labelStyle: CSSProperties = {
102
132
  fontSize: 13,
103
133
  fontWeight: 500,
104
134
  marginBottom: 4,
105
- color: '#5a4a2a',
135
+ color: 'var(--squisq-recorder-text)',
106
136
  };
107
137
 
108
138
  const inputStyle: CSSProperties = {
@@ -110,10 +140,10 @@ const inputStyle: CSSProperties = {
110
140
  padding: '6px 8px',
111
141
  fontSize: 13,
112
142
  fontFamily: 'inherit',
113
- border: '1px solid #c9b98a',
143
+ border: '1px solid var(--squisq-recorder-border)',
114
144
  borderRadius: 0,
115
- background: '#fff',
116
- color: '#4a3c1f',
145
+ background: 'var(--squisq-recorder-input)',
146
+ color: 'var(--squisq-recorder-text)',
117
147
  marginBottom: 12,
118
148
  boxSizing: 'border-box',
119
149
  };
@@ -130,9 +160,9 @@ const btnPrimary: CSSProperties = {
130
160
  fontFamily: 'inherit',
131
161
  fontWeight: 500,
132
162
  cursor: 'pointer',
133
- background: '#8B6914',
134
- color: '#fff',
135
- border: '1px solid #7a5c10',
163
+ background: 'var(--squisq-recorder-accent)',
164
+ color: 'var(--squisq-recorder-accent-text)',
165
+ border: '1px solid var(--squisq-recorder-accent)',
136
166
  borderRadius: 0,
137
167
  };
138
168
 
@@ -142,16 +172,16 @@ const btnSecondary: CSSProperties = {
142
172
  fontFamily: 'inherit',
143
173
  fontWeight: 500,
144
174
  cursor: 'pointer',
145
- background: '#E8DFC6',
146
- color: '#4a3c1f',
147
- border: '1px solid #c9b98a',
175
+ background: 'var(--squisq-recorder-input)',
176
+ color: 'var(--squisq-recorder-text)',
177
+ border: '1px solid var(--squisq-recorder-border)',
148
178
  borderRadius: 0,
149
179
  };
150
180
 
151
181
  const btnDanger: CSSProperties = {
152
182
  ...btnPrimary,
153
- background: '#B33A3A',
154
- borderColor: '#902929',
183
+ background: 'var(--squisq-recorder-danger)',
184
+ borderColor: 'var(--squisq-recorder-danger-border)',
155
185
  };
156
186
 
157
187
  const toggleRowStyle: CSSProperties = {
@@ -166,17 +196,17 @@ const toggleBase: CSSProperties = {
166
196
  fontFamily: 'inherit',
167
197
  cursor: 'pointer',
168
198
  background: 'transparent',
169
- color: '#5a4a2a',
170
- border: '1px solid #c9b98a',
199
+ color: 'var(--squisq-recorder-text)',
200
+ border: '1px solid var(--squisq-recorder-border)',
171
201
  borderRadius: 999,
172
202
  };
173
203
 
174
204
  const toggleActive: CSSProperties = {
175
205
  ...toggleBase,
176
- color: '#fffdf5',
206
+ color: 'var(--squisq-recorder-accent-text)',
177
207
  fontWeight: 600,
178
- background: '#8B6914',
179
- borderColor: '#8B6914',
208
+ background: 'var(--squisq-recorder-accent)',
209
+ borderColor: 'var(--squisq-recorder-accent)',
180
210
  };
181
211
 
182
212
  const previewBoxStyle: CSSProperties = {
@@ -196,21 +226,21 @@ const previewBoxStyle: CSSProperties = {
196
226
  const audioMeterStyle: CSSProperties = {
197
227
  width: '100%',
198
228
  height: 56,
199
- background: '#F2EBD9',
200
- border: '1px solid #c9b98a',
229
+ background: 'var(--squisq-recorder-input)',
230
+ border: '1px solid var(--squisq-recorder-border)',
201
231
  marginBottom: 12,
202
232
  display: 'flex',
203
233
  alignItems: 'center',
204
234
  justifyContent: 'center',
205
- color: '#5a4a2a',
235
+ color: 'var(--squisq-recorder-muted)',
206
236
  fontSize: 13,
207
237
  fontVariantNumeric: 'tabular-nums',
208
238
  };
209
239
 
210
240
  const errorStyle: CSSProperties = {
211
- background: '#FCEEEE',
212
- border: '1px solid #D88A8A',
213
- color: '#8C2A2A',
241
+ background: 'var(--squisq-recorder-error-bg)',
242
+ border: '1px solid var(--squisq-recorder-error-border)',
243
+ color: 'var(--squisq-recorder-error-text)',
214
244
  padding: '8px 10px',
215
245
  fontSize: 13,
216
246
  marginBottom: 12,
@@ -223,6 +253,20 @@ const buttonRowStyle: CSSProperties = {
223
253
  marginTop: 8,
224
254
  };
225
255
 
256
+ const summaryStyle: CSSProperties = {
257
+ margin: '0 0 12px 0',
258
+ fontSize: 12,
259
+ color: 'var(--squisq-recorder-muted)',
260
+ };
261
+
262
+ const recordingStatusStyle: CSSProperties = {
263
+ fontSize: 13,
264
+ fontVariantNumeric: 'tabular-nums',
265
+ marginBottom: 12,
266
+ color: 'var(--squisq-recorder-accent)',
267
+ fontWeight: 600,
268
+ };
269
+
226
270
  // ── Helpers ────────────────────────────────────────────────────────
227
271
 
228
272
  function formatDurationMs(ms: number): string {
@@ -293,6 +337,7 @@ export function RecorderModal({
293
337
  mediaProvider,
294
338
  container = null,
295
339
  initialMode = 'mic',
340
+ colorScheme = 'light',
296
341
  onClose,
297
342
  onSave,
298
343
  }: RecorderModalProps) {
@@ -471,7 +516,14 @@ export function RecorderModal({
471
516
  };
472
517
 
473
518
  return (
474
- <div style={overlayStyle} role="dialog" aria-modal="true" aria-label="Record media">
519
+ <div
520
+ className="squisq-editor-shell squisq-recorder-overlay"
521
+ data-theme={colorScheme}
522
+ style={{ ...overlayStyle, ...recorderThemeStyle(colorScheme) }}
523
+ role="dialog"
524
+ aria-modal="true"
525
+ aria-label="Record media"
526
+ >
475
527
  <div style={modalStyle} onClick={(e) => e.stopPropagation()}>
476
528
  <h2 style={titleStyle}>Record media</h2>
477
529
 
@@ -493,9 +545,7 @@ export function RecorderModal({
493
545
  })}
494
546
  </div>
495
547
 
496
- <p style={{ margin: '0 0 12px 0', fontSize: 12, color: '#5a4a2a' }}>
497
- {captureSummary(micOn, video)}
498
- </p>
548
+ <p style={summaryStyle}>{captureSummary(micOn, video)}</p>
499
549
 
500
550
  {recorder.error && <div style={errorStyle}>{recorder.error.message}</div>}
501
551
  {saveError && <div style={errorStyle}>{saveError}</div>}
@@ -579,6 +629,7 @@ export function RecorderModal({
579
629
  >
580
630
  <input
581
631
  type="checkbox"
632
+ style={{ accentColor: 'var(--squisq-recorder-accent)' }}
582
633
  checked={includeSystemAudio}
583
634
  onChange={(e) => setIncludeSystemAudio(e.target.checked)}
584
635
  disabled={recorder.state === 'recording' || recorder.state === 'requesting'}
@@ -602,15 +653,7 @@ export function RecorderModal({
602
653
 
603
654
  {/* Live duration during recording */}
604
655
  {recorder.state === 'recording' && !isAudioOnly && (
605
- <div
606
- style={{
607
- fontSize: 13,
608
- fontVariantNumeric: 'tabular-nums',
609
- marginBottom: 12,
610
- color: '#8B6914',
611
- fontWeight: 600,
612
- }}
613
- >
656
+ <div style={recordingStatusStyle}>
614
657
  ● Recording {formatDurationMs(recorder.durationMs)}
615
658
  </div>
616
659
  )}
@@ -12,7 +12,11 @@ import { useCallback, useState } from 'react';
12
12
  import { createPortal } from 'react-dom';
13
13
  import type { MediaProvider } from '@bendyline/squisq/schemas';
14
14
  import type { ContentContainer } from '@bendyline/squisq/storage';
15
- import { RecorderModal, type RecorderSaveResult } from './RecorderModal.js';
15
+ import {
16
+ RecorderModal,
17
+ type RecorderColorScheme,
18
+ type RecorderSaveResult,
19
+ } from './RecorderModal.js';
16
20
  import { Icon } from '../Icon';
17
21
  import type { RecorderSource } from './hooks/useMediaRecorder.js';
18
22
 
@@ -20,6 +24,8 @@ export interface RecorderPanelProps {
20
24
  mediaProvider: MediaProvider;
21
25
  container?: ContentContainer | null;
22
26
  initialMode?: RecorderSource;
27
+ /** Light/dark chrome scheme copied onto the portaled modal. */
28
+ colorScheme?: RecorderColorScheme;
23
29
  onSave?: (result: RecorderSaveResult) => void;
24
30
  /** ARIA / tooltip label. Defaults to `'Record media'`. */
25
31
  tooltip?: string;
@@ -31,6 +37,7 @@ export function RecorderPanel({
31
37
  mediaProvider,
32
38
  container = null,
33
39
  initialMode = 'mic',
40
+ colorScheme = 'light',
34
41
  onSave,
35
42
  tooltip = 'Record media',
36
43
  className,
@@ -57,6 +64,7 @@ export function RecorderPanel({
57
64
  mediaProvider={mediaProvider}
58
65
  container={container}
59
66
  initialMode={initialMode}
67
+ colorScheme={colorScheme}
60
68
  onClose={handleClose}
61
69
  onSave={(result) => {
62
70
  onSave?.(result);
@@ -34,6 +34,7 @@ import { RenderLayer } from './layers/renderLayer';
34
34
  import { useSceneTextEditing } from './text/useSceneTextEditing';
35
35
  import { SceneTextOverlay } from './text/SceneTextOverlay';
36
36
  import type { SceneTextEditConfig } from './text/sceneTextConfig';
37
+ import { SceneViewControls } from './SceneViewControls';
37
38
 
38
39
  export interface SceneProps {
39
40
  /** Viewport size in viewport units. Layers render in this coordinate space. */
@@ -78,6 +79,8 @@ export interface SceneProps {
78
79
  showMaximize?: boolean;
79
80
  maximized?: boolean;
80
81
  onToggleMaximize?: () => void;
82
+ /** Show the shared diagram zoom and fit controls. */
83
+ showViewControls?: boolean;
81
84
  /** Render the built-in toolbar (Select / Connect / etc.). Default true. */
82
85
  showToolbar?: boolean;
83
86
  /**
@@ -112,6 +115,7 @@ export function Scene(props: SceneProps) {
112
115
  showMaximize,
113
116
  maximized,
114
117
  onToggleMaximize,
118
+ showViewControls = false,
115
119
  showToolbar = true,
116
120
  textEditing,
117
121
  onDrop,
@@ -139,6 +143,8 @@ export function Scene(props: SceneProps) {
139
143
 
140
144
  // ── Pan/zoom + selection ────────────────────────────────────
141
145
  const panZoom = useScenePanZoom();
146
+ const { fitBox, zoomAt } = panZoom;
147
+ const [viewMode, setViewMode] = useState<'fit' | 'manual'>('fit');
142
148
  const selection = useSceneSelection();
143
149
  const setSceneSelection = selection.setSelection;
144
150
  const { hit } = useSceneHitTest();
@@ -226,6 +232,7 @@ export function Scene(props: SceneProps) {
226
232
  const sy = e.clientY - rect.top;
227
233
  // Negative deltaY = wheel up = zoom in.
228
234
  const factor = Math.exp(-e.deltaY * 0.0015);
235
+ setViewMode('manual');
229
236
  panZoom.zoomAt(factor, sx, sy);
230
237
  },
231
238
  [panZoom],
@@ -246,6 +253,7 @@ export function Scene(props: SceneProps) {
246
253
  (e.currentTarget as SVGSVGElement).focus({ preventScroll: true });
247
254
  // Middle-button or space-modified drag → pan, regardless of tool.
248
255
  if (e.button === 1 || (e.button === 0 && e.altKey)) {
256
+ setViewMode('manual');
249
257
  isPanning.current = true;
250
258
  panLast.current = { x: e.clientX, y: e.clientY };
251
259
  (e.currentTarget as Element).setPointerCapture?.(e.pointerId);
@@ -372,8 +380,7 @@ export function Scene(props: SceneProps) {
372
380
  return () => root.removeEventListener('keydown', onKey);
373
381
  }, [tools, activeTool, setActiveTool, textEdit.activeRef]);
374
382
 
375
- // ── Initial fit ─────────────────────────────────────────────
376
- const didFitRef = useRef(false);
383
+ // ── Responsive fit ──────────────────────────────────────────
377
384
  const [containerSize, setContainerSize] = useState<{ width: number; height: number } | null>(
378
385
  null,
379
386
  );
@@ -391,12 +398,8 @@ export function Scene(props: SceneProps) {
391
398
  return () => ro.disconnect();
392
399
  }, []);
393
400
 
394
- useEffect(() => {
395
- if (didFitRef.current) return;
396
- if (!containerSize) return;
397
- if (hitItems.length === 0) return;
398
- didFitRef.current = true;
399
- // Fit to the bounding box of all hit items (the visible content).
401
+ const contentBox = useMemo(() => {
402
+ if (hitItems.length === 0) return null;
400
403
  let minX = Infinity;
401
404
  let minY = Infinity;
402
405
  let maxX = -Infinity;
@@ -407,13 +410,32 @@ export function Scene(props: SceneProps) {
407
410
  if (it.bounds.x + it.bounds.width > maxX) maxX = it.bounds.x + it.bounds.width;
408
411
  if (it.bounds.y + it.bounds.height > maxY) maxY = it.bounds.y + it.bounds.height;
409
412
  }
410
- if (!Number.isFinite(minX)) return;
411
- panZoom.fitBox(
412
- { x: minX, y: minY, width: maxX - minX, height: maxY - minY },
413
- containerSize,
414
- 40,
415
- );
416
- }, [containerSize, hitItems, panZoom]);
413
+ if (!Number.isFinite(minX)) return null;
414
+ return { x: minX, y: minY, width: maxX - minX, height: maxY - minY };
415
+ }, [hitItems]);
416
+
417
+ const fitContent = useCallback(() => {
418
+ if (!containerSize || !contentBox) return;
419
+ fitBox(contentBox, containerSize, 40, 1);
420
+ }, [containerSize, contentBox, fitBox]);
421
+
422
+ useEffect(() => {
423
+ if (viewMode === 'fit') fitContent();
424
+ }, [fitContent, viewMode]);
425
+
426
+ const adjustZoom = useCallback(
427
+ (factor: number) => {
428
+ setViewMode('manual');
429
+ if (!containerSize) return;
430
+ zoomAt(factor, containerSize.width / 2, containerSize.height / 2);
431
+ },
432
+ [containerSize, zoomAt],
433
+ );
434
+
435
+ const activateFit = useCallback(() => {
436
+ setViewMode('fit');
437
+ fitContent();
438
+ }, [fitContent]);
417
439
 
418
440
  // ── Render ──────────────────────────────────────────────────
419
441
  const liveOffset = activeId === 'select' ? getActiveMoveOffset(interaction) : null;
@@ -548,6 +570,15 @@ export function Scene(props: SceneProps) {
548
570
  />
549
571
  {activeTool?.renderOverlay?.(ctx)}
550
572
  </SceneViewport>
573
+ {showViewControls && (
574
+ <SceneViewControls
575
+ scale={panZoom.transform.scale}
576
+ fit={viewMode === 'fit'}
577
+ onZoomOut={() => adjustZoom(1 / 1.2)}
578
+ onZoomIn={() => adjustZoom(1.2)}
579
+ onFit={activateFit}
580
+ />
581
+ )}
551
582
  {showMaximize && onToggleMaximize && (
552
583
  <button
553
584
  type="button"
@@ -40,8 +40,12 @@ export interface SceneBlockAction {
40
40
  title?: string;
41
41
  onClick: () => void;
42
42
  disabled?: boolean;
43
+ /** Apply the selected-mode treatment (for actions such as Connect). */
44
+ active?: boolean;
43
45
  /** Render with the destructive (red) treatment — used for Delete. */
44
46
  danger?: boolean;
47
+ /** Optional dropdown anchored to this action button. */
48
+ popover?: ReactNode;
45
49
  }
46
50
 
47
51
  export interface SceneBlockToolbarProps {
@@ -178,20 +182,31 @@ export function SceneBlockToolbar({
178
182
  {properties}
179
183
  {actions.length > 0 && (
180
184
  <div className="squisq-scene-block-actions">
181
- {actions.map((a) => (
182
- <button
183
- key={a.id}
184
- type="button"
185
- className={`squisq-scene-action${a.danger ? ' squisq-scene-action--danger' : ''}`}
186
- onClick={a.onClick}
187
- disabled={a.disabled}
188
- title={a.title ?? a.label}
189
- aria-label={a.title ?? a.label}
190
- >
191
- {a.icon && <span className="squisq-scene-action-icon">{a.icon}</span>}
192
- <span className="squisq-scene-action-label">{a.label}</span>
193
- </button>
194
- ))}
185
+ {actions.map((a) => {
186
+ const button = (
187
+ <button
188
+ key={a.id}
189
+ type="button"
190
+ className={`squisq-scene-action${a.active ? ' squisq-scene-action--active' : ''}${a.danger ? ' squisq-scene-action--danger' : ''}`}
191
+ onClick={a.onClick}
192
+ disabled={a.disabled}
193
+ title={a.title ?? a.label}
194
+ aria-label={a.title ?? a.label}
195
+ aria-pressed={a.active || undefined}
196
+ >
197
+ {a.icon && <span className="squisq-scene-action-icon">{a.icon}</span>}
198
+ <span className="squisq-scene-action-label">{a.label}</span>
199
+ </button>
200
+ );
201
+ return a.popover ? (
202
+ <span key={a.id} className="squisq-scene-action-popover-anchor">
203
+ {button}
204
+ {a.popover}
205
+ </span>
206
+ ) : (
207
+ button
208
+ );
209
+ })}
195
210
  </div>
196
211
  )}
197
212
  </div>
@@ -0,0 +1,43 @@
1
+ import { Icon } from '../Icon';
2
+
3
+ interface SceneViewControlsProps {
4
+ scale: number;
5
+ fit: boolean;
6
+ onZoomOut: () => void;
7
+ onZoomIn: () => void;
8
+ onFit: () => void;
9
+ }
10
+
11
+ /** Shared zoom/fit chrome for source-backed simple and complex diagrams. */
12
+ export function SceneViewControls({
13
+ scale,
14
+ fit,
15
+ onZoomOut,
16
+ onZoomIn,
17
+ onFit,
18
+ }: SceneViewControlsProps) {
19
+ return (
20
+ <div className="squisq-scene-view-controls" role="toolbar" aria-label="Diagram view">
21
+ <button type="button" onClick={onZoomOut} title="Zoom out" aria-label="Zoom out">
22
+ <Icon icon="fa-solid fa-minus" />
23
+ </button>
24
+ <output className="squisq-scene-view-scale" aria-label="Diagram zoom">
25
+ {Math.round(scale * 100)}%
26
+ </output>
27
+ <button type="button" onClick={onZoomIn} title="Zoom in" aria-label="Zoom in">
28
+ <Icon icon="fa-solid fa-plus" />
29
+ </button>
30
+ <button
31
+ type="button"
32
+ className="squisq-scene-view-fit"
33
+ data-active={fit || undefined}
34
+ aria-pressed={fit}
35
+ onClick={onFit}
36
+ title="Fit all shapes in the canvas"
37
+ aria-label="Fit diagram"
38
+ >
39
+ Fit
40
+ </button>
41
+ </div>
42
+ );
43
+ }
@@ -0,0 +1,19 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { calculateFitScale } from '../fitScale';
3
+
4
+ describe('calculateFitScale', () => {
5
+ it('does not enlarge content that already fits', () => {
6
+ expect(calculateFitScale({ width: 200, height: 100 }, { width: 800, height: 600 })).toBe(1);
7
+ });
8
+
9
+ it('uses the smaller axis scale so every shape remains visible', () => {
10
+ expect(calculateFitScale({ width: 1_000, height: 200 }, { width: 500, height: 300 })).toBe(0.5);
11
+ expect(calculateFitScale({ width: 200, height: 1_000 }, { width: 500, height: 250 })).toBe(
12
+ 0.25,
13
+ );
14
+ });
15
+
16
+ it('falls back to 100% until dimensions are measurable', () => {
17
+ expect(calculateFitScale({ width: 0, height: 100 }, { width: 500, height: 300 })).toBe(1);
18
+ });
19
+ });
@@ -2,7 +2,7 @@
2
2
  import { render } from '@testing-library/react';
3
3
  import { describe, expect, it, vi } from 'vitest';
4
4
  import type { SceneToolContext } from '../tools/SceneTool';
5
- import { SelectTool, getActiveMoveOffset } from '../tools/SelectTool';
5
+ import { SelectTool, beginHandleDrag, getActiveMoveOffset } from '../tools/SelectTool';
6
6
  import { ConnectTool } from '../tools/ConnectTool';
7
7
  import { createSceneTextChannel } from '../text/sceneTextChannel';
8
8
  import { SceneViewport } from '../SceneViewport';
@@ -61,6 +61,32 @@ describe('Scene instance isolation', () => {
61
61
  expect(second.interaction.connect).toBeUndefined();
62
62
  });
63
63
 
64
+ it('commits a resize as one command containing origin and size', () => {
65
+ const ctx = context('node-card-a');
66
+ beginHandleDrag(
67
+ {
68
+ layerId: 'node-card-a',
69
+ corner: 'nw',
70
+ startV: { x: 10, y: 20 },
71
+ startBounds: { x: 10, y: 20, width: 100, height: 50 },
72
+ },
73
+ ctx.interaction,
74
+ );
75
+
76
+ SelectTool.onPointerMove!(pointer(0, 0), ctx);
77
+ SelectTool.onPointerUp!(pointer(0, 0), ctx);
78
+
79
+ expect(ctx.dispatch).toHaveBeenCalledTimes(1);
80
+ expect(ctx.dispatch).toHaveBeenCalledWith({
81
+ kind: 'resizeLayer',
82
+ id: 'node-card-a',
83
+ x: 0,
84
+ y: 0,
85
+ width: 110,
86
+ height: 70,
87
+ });
88
+ });
89
+
64
90
  it('creates independent text-toolbar channels', () => {
65
91
  const first = createSceneTextChannel();
66
92
  const second = createSceneTextChannel();
@@ -44,7 +44,7 @@ describe('useScenePanZoom', () => {
44
44
  act(() => result.current.zoomAt(100, 0, 0));
45
45
  expect(result.current.transform.scale).toBeLessThanOrEqual(8);
46
46
  act(() => result.current.zoomAt(0.0001, 0, 0));
47
- expect(result.current.transform.scale).toBeGreaterThanOrEqual(0.1);
47
+ expect(result.current.transform.scale).toBeGreaterThanOrEqual(0.02);
48
48
  });
49
49
 
50
50
  it('reset returns to identity', () => {
@@ -88,6 +88,33 @@ describe('useScenePanZoom', () => {
88
88
  expect(result.current.transform.scale).toBeCloseTo(1.6, 5);
89
89
  });
90
90
 
91
+ it('fitBox can cap fit at 100% without enlarging small content', () => {
92
+ const { result } = renderHook(() => useScenePanZoom());
93
+ act(() =>
94
+ result.current.fitBox(
95
+ { x: 25, y: 50, width: 100, height: 80 },
96
+ { width: 800, height: 600 },
97
+ 20,
98
+ 1,
99
+ ),
100
+ );
101
+ expect(result.current.transform.scale).toBe(1);
102
+ expect(result.current.viewportToScreen(75, 90)).toEqual({ x: 400, y: 300 });
103
+ });
104
+
105
+ it('fitBox may shrink below the manual zoom floor to keep all content visible', () => {
106
+ const { result } = renderHook(() => useScenePanZoom());
107
+ act(() =>
108
+ result.current.fitBox(
109
+ { x: 0, y: 0, width: 10_000, height: 10_000 },
110
+ { width: 400, height: 300 },
111
+ 20,
112
+ 1,
113
+ ),
114
+ );
115
+ expect(result.current.transform.scale).toBeCloseTo(0.026, 5);
116
+ });
117
+
91
118
  it('screenToViewport and viewportToScreen are inverses', () => {
92
119
  const { result } = renderHook(() => useScenePanZoom());
93
120
  act(() => {
@@ -238,7 +238,12 @@ export function useDrawingAdapter(
238
238
  case 'resizeLayer': {
239
239
  if (!isPrimaryShapeLayer(cmd.id)) return;
240
240
  const id = shapeIdFromLayerId(cmd.id);
241
- if (id) resizeShape(editor, headingPos, id, cmd.width, cmd.height);
241
+ if (id) {
242
+ if (cmd.x !== undefined && cmd.y !== undefined) {
243
+ moveShape(editor, headingPos, id, cmd.x, cmd.y);
244
+ }
245
+ resizeShape(editor, headingPos, id, cmd.width, cmd.height);
246
+ }
242
247
  return;
243
248
  }
244
249
  case 'addLayer': {
@@ -130,6 +130,9 @@ export function useLayoutAdapter(
130
130
  moveLayoutLayer(editor, headingPos, cmd.id, cmd.x, cmd.y);
131
131
  return;
132
132
  case 'resizeLayer':
133
+ if (cmd.x !== undefined && cmd.y !== undefined) {
134
+ moveLayoutLayer(editor, headingPos, cmd.id, cmd.x, cmd.y);
135
+ }
133
136
  resizeLayoutLayer(editor, headingPos, cmd.id, cmd.width, cmd.height);
134
137
  return;
135
138
  case 'addLayer': {