@bendyline/squisq-editor-react 1.6.0 → 1.6.2

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 (68) hide show
  1. package/README.md +57 -10
  2. package/dist/index.d.ts +476 -105
  3. package/dist/index.js +8703 -6741
  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 +14617 -0
  10. package/package.json +15 -7
  11. package/src/BlockPropertiesPopover.tsx +23 -7
  12. package/src/EditorContext.tsx +22 -16
  13. package/src/EditorShell.tsx +121 -35
  14. package/src/MediaBin.tsx +171 -28
  15. package/src/OutlinePanel.tsx +26 -4
  16. package/src/PreviewControls.tsx +475 -145
  17. package/src/PreviewPanel.tsx +17 -9
  18. package/src/RawEditor.tsx +10 -4
  19. package/src/TemplateAnnotation.ts +22 -7
  20. package/src/TemplateContentPreview.tsx +56 -0
  21. package/src/TemplatePicker.tsx +295 -128
  22. package/src/ThemeCustomizerPanel.tsx +22 -15
  23. package/src/Toolbar.tsx +547 -217
  24. package/src/TransitionPicker.tsx +8 -1
  25. package/src/VersionHistoryPanel.tsx +2 -2
  26. package/src/ViewSwitcher.tsx +4 -4
  27. package/src/WysiwygEditor.tsx +45 -3
  28. package/src/__tests__/buildPreviewDocTransition.test.ts +1 -2
  29. package/src/__tests__/codeContextSectionView.test.tsx +95 -0
  30. package/src/__tests__/codeContextZoneManager.test.ts +127 -0
  31. package/src/__tests__/diffContextSections.test.ts +39 -0
  32. package/src/__tests__/editorShellCodeContext.test.tsx +86 -0
  33. package/src/__tests__/editorShellProps.test.tsx +363 -0
  34. package/src/__tests__/headingTransition.test.ts +59 -9
  35. package/src/__tests__/imageEditorShell.test.tsx +23 -0
  36. package/src/__tests__/mediaReferences.test.ts +82 -0
  37. package/src/__tests__/previewControls.test.tsx +163 -0
  38. package/src/__tests__/templateAnnotationRoundTrip.test.ts +23 -2
  39. package/src/__tests__/templateContentPreview.test.ts +101 -0
  40. package/src/__tests__/tiptapBridge.test.ts +47 -0
  41. package/src/__tests__/useJsonEditorTokens.test.ts +59 -0
  42. package/src/__tests__/useMediaRecorder.test.ts +17 -0
  43. package/src/codeContext/CodeContextSectionView.tsx +124 -0
  44. package/src/codeContext/CodeContextZoneManager.ts +149 -0
  45. package/src/codeContext/CodeContextZones.tsx +121 -0
  46. package/src/codeContext/diffContextSections.ts +38 -0
  47. package/src/codeContext/types.ts +75 -0
  48. package/src/diagram/DiagramWidget.tsx +6 -4
  49. package/src/headingTransition.ts +96 -21
  50. package/src/index.ts +34 -2
  51. package/src/jsonEditor/useJsonEditorTokens.ts +13 -43
  52. package/src/mediaReferences.ts +299 -0
  53. package/src/recorder/hooks/useMediaRecorder.ts +9 -10
  54. package/src/scene/Scene.tsx +53 -7
  55. package/src/scene/SceneBlockWidget.tsx +6 -3
  56. package/src/scene/SceneSelection.tsx +19 -15
  57. package/src/scene/SceneSideToolbar.tsx +89 -0
  58. package/src/scene/layers/DiagramEdges.tsx +4 -3
  59. package/src/scene/layers/edgeGeometry.ts +23 -4
  60. package/src/scene/scene.css +142 -2
  61. package/src/scene/tools/ConnectTool.ts +113 -24
  62. package/src/scene/tools/DrawingConnectTool.ts +86 -16
  63. package/src/scene/tools/SceneTool.ts +2 -0
  64. package/src/styles/code-context.css +155 -0
  65. package/src/styles/editor.css +991 -84
  66. package/src/styles/index.css +1 -0
  67. package/src/templateContentPreviewResolver.ts +353 -0
  68. package/src/tiptapBridge.ts +60 -33
@@ -6,15 +6,9 @@
6
6
 
7
7
  import { useMemo } from 'react';
8
8
  import type { CSSProperties } from 'react';
9
- import {
10
- applySurface,
11
- resolveFontFamily,
12
- type SurfaceScheme,
13
- type Theme,
14
- DEFAULT_THEME,
15
- DARK_SURFACE,
16
- LIGHT_SURFACE,
17
- } from '@bendyline/squisq/schemas';
9
+ import { type SurfaceScheme, type Theme } from '@bendyline/squisq/schemas';
10
+ import { buildJsonFormTokens, resolveJsonFormTheme } from '@bendyline/squisq/jsonForm';
11
+ import { useAutoSurface } from '@bendyline/squisq-react';
18
12
 
19
13
  export interface JsonEditorTokens {
20
14
  style: CSSProperties;
@@ -25,39 +19,15 @@ export function useJsonEditorTokens(
25
19
  theme: Theme | undefined,
26
20
  surface: SurfaceScheme | 'auto' | undefined,
27
21
  ): JsonEditorTokens {
28
- return useMemo(() => {
29
- const baseTheme = theme ?? DEFAULT_THEME;
30
- const resolvedSurface =
31
- surface === 'auto'
32
- ? typeof window !== 'undefined' &&
33
- window.matchMedia?.('(prefers-color-scheme: dark)').matches
34
- ? DARK_SURFACE
35
- : LIGHT_SURFACE
36
- : (surface ?? undefined);
37
- const finalTheme = resolvedSurface ? applySurface(baseTheme, resolvedSurface) : baseTheme;
38
-
39
- const titleFont = resolveFontFamily(finalTheme.typography.titleFont, 'system-ui, sans-serif');
40
- const bodyFont = resolveFontFamily(finalTheme.typography.bodyFont, 'system-ui, sans-serif');
41
- const monoFont = resolveFontFamily(
42
- finalTheme.typography.monoFont,
43
- 'ui-monospace, Consolas, monospace',
44
- );
22
+ // Reactive `prefers-color-scheme` tracking (matches `<JsonView>`), so the
23
+ // editor re-themes live when the OS switches light/dark under `'auto'`.
24
+ const auto = useAutoSurface(surface === 'auto');
25
+ const effectiveSurface = surface === 'auto' ? auto : (surface ?? undefined);
45
26
 
46
- const style: CSSProperties = {
47
- ['--squisq-jsonform-bg' as string]: finalTheme.colors.background,
48
- ['--squisq-jsonform-text' as string]: finalTheme.colors.text,
49
- ['--squisq-jsonform-muted' as string]: finalTheme.colors.textMuted,
50
- ['--squisq-jsonform-primary' as string]: finalTheme.colors.primary,
51
- ['--squisq-jsonform-accent' as string]: finalTheme.colors.secondary,
52
- ['--squisq-jsonform-warning' as string]: finalTheme.colors.warning,
53
- ['--squisq-jsonform-border' as string]: `color-mix(in srgb, ${finalTheme.colors.textMuted} 35%, transparent)`,
54
- ['--squisq-jsonform-input-bg' as string]: finalTheme.colors.backgroundLight,
55
- ['--squisq-jsonform-title-font' as string]: titleFont,
56
- ['--squisq-jsonform-body-font' as string]: bodyFont,
57
- ['--squisq-jsonform-mono-font' as string]: monoFont,
58
- ['--squisq-jsonform-radius' as string]: `${finalTheme.style.borderRadius ?? 8}px`,
59
- };
60
-
61
- return { style, theme: finalTheme };
62
- }, [theme, surface]);
27
+ return useMemo(() => {
28
+ const style = buildJsonFormTokens(theme, effectiveSurface, {
29
+ prefix: '--squisq-jsonform',
30
+ }) as unknown as CSSProperties;
31
+ return { style, theme: resolveJsonFormTheme(theme, effectiveSurface) };
32
+ }, [theme, effectiveSurface]);
63
33
  }
@@ -0,0 +1,299 @@
1
+ import { splitKeyValueToken, tokenizeAttrTokens } from '@bendyline/squisq/markdown';
2
+
3
+ interface MediaReferenceRange {
4
+ start: number;
5
+ end: number;
6
+ }
7
+
8
+ interface MarkdownReferenceMatch {
9
+ destination: string;
10
+ range: MediaReferenceRange;
11
+ }
12
+
13
+ const MEDIA_REFERENCE_PARAM_KEYS = new Set([
14
+ 'audio',
15
+ 'file',
16
+ 'href',
17
+ 'image',
18
+ 'imagesrc',
19
+ 'path',
20
+ 'poster',
21
+ 'src',
22
+ 'url',
23
+ 'video',
24
+ ]);
25
+
26
+ const HTML_MEDIA_ATTRS = ['src', 'href', 'poster'] as const;
27
+
28
+ /**
29
+ * Collect media references from the document source. Used by the Files panel
30
+ * to distinguish stored files that are actually referenced by the document
31
+ * from files that only exist in the bin.
32
+ */
33
+ export function collectMediaReferencesFromMarkdown(source: string): ReadonlySet<string> {
34
+ const refs = new Set<string>();
35
+ collectMarkdownInlineReferences(source, refs);
36
+ collectHtmlAttributeReferences(source, refs);
37
+ collectAnnotationReferences(source, refs);
38
+ return refs;
39
+ }
40
+
41
+ /**
42
+ * Remove markdown/html references to a media asset without reparsing and
43
+ * reserializing the whole document. This targets the ref shapes the editor
44
+ * emits for uploaded files while preserving unrelated author formatting.
45
+ */
46
+ export function removeMediaReferencesFromMarkdown(source: string, mediaPath: string): string {
47
+ if (!mediaPath) return source;
48
+ const withoutMarkdownRefs = removeMarkdownInlineReferences(source, mediaPath);
49
+ const withoutImageTags = removeHtmlTagsByAttribute(withoutMarkdownRefs, 'img', 'src', mediaPath);
50
+ return removeHtmlTagsByAttribute(withoutImageTags, 'a', 'href', mediaPath);
51
+ }
52
+
53
+ function removeMarkdownInlineReferences(source: string, mediaPath: string): string {
54
+ let result = '';
55
+ let cursor = 0;
56
+
57
+ while (cursor < source.length) {
58
+ const range = findNextMarkdownReference(source, cursor, mediaPath);
59
+ if (!range) break;
60
+ result += source.slice(cursor, range.start);
61
+ cursor = range.end;
62
+ }
63
+
64
+ return result + source.slice(cursor);
65
+ }
66
+
67
+ function findNextMarkdownReference(
68
+ source: string,
69
+ startIndex: number,
70
+ mediaPath: string,
71
+ ): MediaReferenceRange | null {
72
+ const match = findNextMarkdownReferenceMatch(source, startIndex, mediaPath);
73
+ return match?.range ?? null;
74
+ }
75
+
76
+ function collectMarkdownInlineReferences(source: string, refs: Set<string>): void {
77
+ let cursor = 0;
78
+ while (cursor < source.length) {
79
+ const match = findNextMarkdownReferenceMatch(source, cursor);
80
+ if (!match) return;
81
+ refs.add(match.destination);
82
+ cursor = Math.max(match.range.end, cursor + 1);
83
+ }
84
+ }
85
+
86
+ function findNextMarkdownReferenceMatch(
87
+ source: string,
88
+ startIndex: number,
89
+ mediaPath?: string,
90
+ ): MarkdownReferenceMatch | null {
91
+ let bracketIndex = source.indexOf('[', startIndex);
92
+
93
+ while (bracketIndex !== -1) {
94
+ const previous = source[bracketIndex - 1];
95
+ const isImage = previous === '!';
96
+ if (previous === '@') {
97
+ bracketIndex = source.indexOf('[', bracketIndex + 1);
98
+ continue;
99
+ }
100
+
101
+ const closeBracket = findClosingBracket(source, bracketIndex);
102
+ if (closeBracket === -1 || source[closeBracket + 1] !== '(') {
103
+ bracketIndex = source.indexOf('[', bracketIndex + 1);
104
+ continue;
105
+ }
106
+
107
+ const openParen = closeBracket + 1;
108
+ const closeParen = findClosingParen(source, openParen);
109
+ if (closeParen === -1) {
110
+ bracketIndex = source.indexOf('[', bracketIndex + 1);
111
+ continue;
112
+ }
113
+
114
+ const destination = readLinkDestination(source.slice(openParen + 1, closeParen));
115
+ if (destination && (mediaPath === undefined || destination === mediaPath)) {
116
+ const tokenStart = isImage ? bracketIndex - 1 : bracketIndex;
117
+ return {
118
+ destination,
119
+ range: expandStandaloneLine(source, tokenStart, closeParen + 1),
120
+ };
121
+ }
122
+
123
+ bracketIndex = source.indexOf('[', bracketIndex + 1);
124
+ }
125
+
126
+ return null;
127
+ }
128
+
129
+ function collectHtmlAttributeReferences(source: string, refs: Set<string>): void {
130
+ const tagPattern = /<[a-z][^>]*>/gi;
131
+ let match: RegExpExecArray | null;
132
+
133
+ while ((match = tagPattern.exec(source))) {
134
+ const tag = match[0];
135
+ for (const attrName of HTML_MEDIA_ATTRS) {
136
+ const value = readHtmlAttribute(tag, attrName);
137
+ if (value) refs.add(value);
138
+ }
139
+ }
140
+ }
141
+
142
+ function collectAnnotationReferences(source: string, refs: Set<string>): void {
143
+ const annotationPattern = /\{\[([^\]]*)\]\}/g;
144
+ let match: RegExpExecArray | null;
145
+
146
+ while ((match = annotationPattern.exec(source))) {
147
+ const inner = match[1];
148
+ if (!inner) continue;
149
+ const tokens = tokenizeAttrTokens(inner);
150
+ for (const token of tokens) {
151
+ const pair = splitKeyValueToken(token);
152
+ if (!pair) continue;
153
+ if (MEDIA_REFERENCE_PARAM_KEYS.has(pair.key.toLowerCase())) {
154
+ refs.add(pair.value);
155
+ }
156
+ }
157
+ }
158
+ }
159
+
160
+ function findClosingBracket(source: string, openIndex: number): number {
161
+ let depth = 0;
162
+ for (let i = openIndex; i < source.length; i++) {
163
+ if (source[i] === '\\') {
164
+ i += 1;
165
+ continue;
166
+ }
167
+ if (source[i] === '[') depth += 1;
168
+ if (source[i] === ']') {
169
+ depth -= 1;
170
+ if (depth === 0) return i;
171
+ }
172
+ }
173
+ return -1;
174
+ }
175
+
176
+ function findClosingParen(source: string, openIndex: number): number {
177
+ let depth = 0;
178
+ let inAngleDestination = false;
179
+
180
+ for (let i = openIndex; i < source.length; i++) {
181
+ const ch = source[i];
182
+ if (ch === '\\') {
183
+ i += 1;
184
+ continue;
185
+ }
186
+ if (inAngleDestination) {
187
+ if (ch === '>') inAngleDestination = false;
188
+ continue;
189
+ }
190
+ if (ch === '<') {
191
+ inAngleDestination = true;
192
+ continue;
193
+ }
194
+ if (ch === '(') {
195
+ depth += 1;
196
+ continue;
197
+ }
198
+ if (ch === ')') {
199
+ depth -= 1;
200
+ if (depth === 0) return i;
201
+ }
202
+ }
203
+
204
+ return -1;
205
+ }
206
+
207
+ function readLinkDestination(raw: string): string {
208
+ const content = raw.trimStart();
209
+ if (!content) return '';
210
+
211
+ if (content.startsWith('<')) {
212
+ const close = findUnescaped(content, '>', 1);
213
+ if (close === -1) return '';
214
+ return unescapeMarkdownUrl(content.slice(1, close));
215
+ }
216
+
217
+ let depth = 0;
218
+ for (let i = 0; i < content.length; i++) {
219
+ const ch = content[i];
220
+ if (ch === '\\') {
221
+ i += 1;
222
+ continue;
223
+ }
224
+ if (ch === '(') {
225
+ depth += 1;
226
+ continue;
227
+ }
228
+ if (ch === ')' && depth > 0) {
229
+ depth -= 1;
230
+ continue;
231
+ }
232
+ if (depth === 0 && /\s/.test(ch)) {
233
+ return unescapeMarkdownUrl(content.slice(0, i));
234
+ }
235
+ }
236
+
237
+ return unescapeMarkdownUrl(content);
238
+ }
239
+
240
+ function findUnescaped(source: string, needle: string, startIndex: number): number {
241
+ for (let i = startIndex; i < source.length; i++) {
242
+ if (source[i] === '\\') {
243
+ i += 1;
244
+ continue;
245
+ }
246
+ if (source[i] === needle) return i;
247
+ }
248
+ return -1;
249
+ }
250
+
251
+ function unescapeMarkdownUrl(value: string): string {
252
+ return value.replace(/\\([\\`*{}[\]()#+\-.!_>])/g, '$1');
253
+ }
254
+
255
+ function expandStandaloneLine(source: string, start: number, end: number): MediaReferenceRange {
256
+ const lineStart = source.lastIndexOf('\n', start - 1) + 1;
257
+ const nextNewline = source.indexOf('\n', end);
258
+ const lineEnd = nextNewline === -1 ? source.length : nextNewline;
259
+ const before = source.slice(lineStart, start);
260
+ const after = source.slice(end, lineEnd);
261
+
262
+ if (before.trim() === '' && after.trim() === '') {
263
+ return {
264
+ start: lineStart,
265
+ end: nextNewline === -1 ? lineEnd : lineEnd + 1,
266
+ };
267
+ }
268
+
269
+ return { start, end };
270
+ }
271
+
272
+ function removeHtmlTagsByAttribute(
273
+ source: string,
274
+ tagName: 'a' | 'img',
275
+ attrName: 'href' | 'src',
276
+ mediaPath: string,
277
+ ): string {
278
+ const tagPattern = tagName === 'img' ? /<img\b[^>]*>/gi : /<a\b[^>]*>[\s\S]*?<\/a>/gi;
279
+ return source.replace(tagPattern, (tag) => {
280
+ const value = readHtmlAttribute(tag, attrName);
281
+ return value === mediaPath ? '' : tag;
282
+ });
283
+ }
284
+
285
+ function readHtmlAttribute(tag: string, attrName: string): string | null {
286
+ const attrPattern = new RegExp(`\\b${attrName}\\s*=\\s*("([^"]*)"|'([^']*)'|([^\\s>]+))`, 'i');
287
+ const match = attrPattern.exec(tag);
288
+ if (!match) return null;
289
+ return unescapeHtmlAttribute(match[2] ?? match[3] ?? match[4] ?? '');
290
+ }
291
+
292
+ function unescapeHtmlAttribute(value: string): string {
293
+ return value
294
+ .replace(/&lt;/g, '<')
295
+ .replace(/&gt;/g, '>')
296
+ .replace(/&quot;/g, '"')
297
+ .replace(/&#39;|&apos;/g, "'")
298
+ .replace(/&amp;/g, '&');
299
+ }
@@ -35,8 +35,8 @@ export type RecorderState =
35
35
  | 'error';
36
36
 
37
37
  export interface UseMediaRecorderOptions {
38
- /** Which capture pipeline to use. */
39
- source: RecorderSource;
38
+ /** Which capture pipeline to use (default: `'mic'`). */
39
+ source?: RecorderSource;
40
40
  /**
41
41
  * Preferred MIME type override. When the browser supports it, this
42
42
  * wins over the default candidate list. When unset (or unsupported),
@@ -115,9 +115,10 @@ export interface UseMediaRecorderResult {
115
115
  * resources (e.g. the screen+mic AudioContext mixer).
116
116
  */
117
117
  async function acquireStream(
118
+ source: RecorderSource,
118
119
  opts: UseMediaRecorderOptions,
119
120
  ): Promise<{ stream: MediaStream; dispose: () => void }> {
120
- switch (opts.source) {
121
+ switch (source) {
121
122
  case 'mic': {
122
123
  const audio = typeof opts.audioConstraints === 'object' ? opts.audioConstraints : undefined;
123
124
  const stream = await requestMicStream(audio);
@@ -135,7 +136,7 @@ async function acquireStream(
135
136
  const handle: ScreenStreamHandle = await requestScreenStream({
136
137
  video: opts.videoConstraints ?? true,
137
138
  systemAudio: opts.systemAudio ?? false,
138
- includeMicrophone: opts.source === 'screen+mic',
139
+ includeMicrophone: source === 'screen+mic',
139
140
  microphoneConstraints:
140
141
  typeof opts.audioConstraints === 'object' ? opts.audioConstraints : undefined,
141
142
  });
@@ -158,7 +159,7 @@ export function getCaptureKind(source: RecorderSource): CaptureKind {
158
159
  return captureKindFor(source);
159
160
  }
160
161
 
161
- export function useMediaRecorder(options: UseMediaRecorderOptions): UseMediaRecorderResult {
162
+ export function useMediaRecorder(options: UseMediaRecorderOptions = {}): UseMediaRecorderResult {
162
163
  const [state, setState] = useState<RecorderState>('idle');
163
164
  const [stream, setStream] = useState<MediaStream | null>(null);
164
165
  const [blob, setBlob] = useState<Blob | null>(null);
@@ -256,11 +257,9 @@ export function useMediaRecorder(options: UseMediaRecorderOptions): UseMediaReco
256
257
  setError(null);
257
258
  setState('requesting');
258
259
  try {
259
- const { stream: nextStream, dispose } = await acquireStream(optionsRef.current);
260
- const resolved = resolveFormat(
261
- captureKindFor(optionsRef.current.source),
262
- optionsRef.current.mimeType,
263
- );
260
+ const source = optionsRef.current.source ?? 'mic';
261
+ const { stream: nextStream, dispose } = await acquireStream(source, optionsRef.current);
262
+ const resolved = resolveFormat(captureKindFor(source), optionsRef.current.mimeType);
264
263
  const recorderOptions: MediaRecorderOptions = {};
265
264
  if (resolved.mimeType) recorderOptions.mimeType = resolved.mimeType;
266
265
  if (optionsRef.current.bitsPerSecond) {
@@ -389,6 +389,7 @@ export function Scene(props: SceneProps) {
389
389
  // ── Render ──────────────────────────────────────────────────
390
390
  const liveOffset = activeId === 'select' ? getActiveMoveOffset() : null;
391
391
  const liveResize = activeId === 'select' ? getActiveResize() : null;
392
+ const showSelectionHandles = activeTool?.hideSelectionHandles !== true;
392
393
 
393
394
  // Per-layer transform applied during an in-flight drag or resize so
394
395
  // the user sees the layer move/stretch in real time, not just on commit.
@@ -415,6 +416,32 @@ export function Scene(props: SceneProps) {
415
416
  return {};
416
417
  };
417
418
 
419
+ // A text box's font size is independent of its box size — a wide box exists
420
+ // precisely so a small caption can left/center/right-align within it. So
421
+ // when a text box is directly resized we reshape the box and re-align its
422
+ // text at the *unchanged* font, rather than scaling the glyphs with the box
423
+ // (the generic matrix path in `wrapperFor`, which would stretch the text and
424
+ // then snap it back to its real size on commit). Returns a clone positioned
425
+ // at the live-resize box, or null when `layer` isn't a text box being
426
+ // directly resized. Follower labels (drawing/diagram, whose font is derived
427
+ // from box height) are resized via their shape, not directly, so they never
428
+ // match here and keep the scale-with-box preview.
429
+ const liveResizedTextBox = (layer: Layer): Layer | null => {
430
+ if (!liveResize || liveResize.layerId !== layer.id || layer.type !== 'text') return null;
431
+ const b = liveResize.bounds;
432
+ return {
433
+ ...layer,
434
+ position: {
435
+ ...layer.position,
436
+ x: b.x,
437
+ y: b.y,
438
+ width: b.width,
439
+ height: b.height,
440
+ anchor: 'top-left',
441
+ },
442
+ };
443
+ };
444
+
418
445
  const handleHandlePointerDown = (e: React.PointerEvent<SVGElement>, corner: ResizeCorner) => {
419
446
  const id = selection.selection.values().next().value as string | undefined;
420
447
  if (!id) return;
@@ -454,21 +481,40 @@ export function Scene(props: SceneProps) {
454
481
  >
455
482
  {/* Extras (e.g. diagram edges) render behind the layers so the
456
483
  cards visually clip edge endpoints. */}
457
- {renderExtras?.(ctx)}
484
+ {renderExtras && <g className="squisq-scene-extras">{renderExtras(ctx)}</g>}
458
485
  {/* Layers — the Scene wraps each in a transformable <g> so a
459
486
  selected layer can show a live drag/resize preview without
460
487
  requiring the host to know about drag state. */}
461
- {layers.map((layer) => (
462
- <g key={layer.id} data-layer-id={layer.id} {...wrapperFor(layer)}>
463
- {layerRenderer(layer, viewport)}
464
- </g>
465
- ))}
488
+ {layers.map((layer) => {
489
+ const layerClassName = `squisq-scene-layer squisq-scene-layer--${layer.type}`;
490
+ // Text boxes reshape (font-fixed) rather than scale during resize;
491
+ // everything else stretches via the group transform.
492
+ const resizedText = liveResizedTextBox(layer);
493
+ if (resizedText) {
494
+ return (
495
+ <g key={layer.id} className={layerClassName} data-layer-id={layer.id}>
496
+ {layerRenderer(resizedText, viewport)}
497
+ </g>
498
+ );
499
+ }
500
+ return (
501
+ <g
502
+ key={layer.id}
503
+ className={layerClassName}
504
+ data-layer-id={layer.id}
505
+ {...wrapperFor(layer)}
506
+ >
507
+ {layerRenderer(layer, viewport)}
508
+ </g>
509
+ );
510
+ })}
466
511
  <SceneSelection
467
512
  selection={selection.selection}
468
513
  hitItems={hitItems}
469
514
  liveOffset={liveOffset}
470
515
  liveResize={liveResize}
471
- onHandlePointerDown={handleHandlePointerDown}
516
+ onHandlePointerDown={showSelectionHandles ? handleHandlePointerDown : undefined}
517
+ showHandles={showSelectionHandles}
472
518
  />
473
519
  {activeTool?.renderOverlay?.(ctx)}
474
520
  </SceneViewport>
@@ -25,6 +25,7 @@ import { shapeIdFromLayerId } from './layers/shapeLayers';
25
25
  import { ShapePalette } from './ShapePalette';
26
26
  import { ScenePropsBar } from './ScenePropsBar';
27
27
  import { SceneBlockToolbar, type SceneBlockAction, type SceneAlignment } from './SceneBlockToolbar';
28
+ import { SceneSideToolbar } from './SceneSideToolbar';
28
29
  import type { SceneTextEditConfig } from './text/sceneTextConfig';
29
30
  import { markdownToTiptap } from '../tiptapBridge';
30
31
  import { Icon } from '../Icon';
@@ -301,15 +302,15 @@ export function SceneBlockWidget({
301
302
  );
302
303
 
303
304
  const body = <div className="squisq-scene-stage">{canvas}</div>;
304
- const sideToolbar = <div className="squisq-scene-side-toolbar">{toolbar}</div>;
305
305
 
306
306
  if (maximized) {
307
307
  return (
308
308
  <div className="squisq-scene-inline-placeholder">
309
309
  <DiagramMaximizedOverlay host={host ?? null} onClose={() => setMaximized(false)}>
310
+ {/* Maximized has a full screen for a static right column — no collapse. */}
310
311
  <div className="squisq-scene-block-max">
311
312
  {body}
312
- {sideToolbar}
313
+ <div className="squisq-scene-side-toolbar">{toolbar}</div>
313
314
  </div>
314
315
  </DiagramMaximizedOverlay>
315
316
  </div>
@@ -318,8 +319,10 @@ export function SceneBlockWidget({
318
319
 
319
320
  return (
320
321
  <div className="squisq-scene-shell">
322
+ {/* Before the canvas so the narrow-width fallback bar sits above it; the
323
+ wide-width gutter column is absolute and unaffected by DOM order. */}
324
+ <SceneSideToolbar>{toolbar}</SceneSideToolbar>
321
325
  <div className="squisq-scene-inline">{body}</div>
322
- {sideToolbar}
323
326
  </div>
324
327
  );
325
328
  }
@@ -28,6 +28,8 @@ interface SceneSelectionProps {
28
28
  } | null;
29
29
  /** Fired when the user presses a corner/edge handle. */
30
30
  onHandlePointerDown?: (e: React.PointerEvent<SVGElement>, corner: ResizeCorner) => void;
31
+ /** Whether to render resize handles in addition to the selection outline. */
32
+ showHandles?: boolean;
31
33
  }
32
34
 
33
35
  const HANDLE_SIZE = 8;
@@ -38,6 +40,7 @@ export function SceneSelection({
38
40
  liveOffset,
39
41
  liveResize,
40
42
  onHandlePointerDown,
43
+ showHandles = true,
41
44
  }: SceneSelectionProps) {
42
45
  if (selection.size === 0) return null;
43
46
  const selected = hitItems.filter((it) => selection.has(it.id));
@@ -84,21 +87,22 @@ export function SceneSelection({
84
87
  className="squisq-scene-selection-outline"
85
88
  pointerEvents="none"
86
89
  />
87
- {corners.map(({ corner, cx, cy, cursor }) => (
88
- <rect
89
- key={corner}
90
- x={cx - HANDLE_SIZE / 2}
91
- y={cy - HANDLE_SIZE / 2}
92
- width={HANDLE_SIZE}
93
- height={HANDLE_SIZE}
94
- className="squisq-scene-selection-handle"
95
- style={{ cursor }}
96
- onPointerDown={
97
- onHandlePointerDown ? (e) => onHandlePointerDown(e, corner) : undefined
98
- }
99
- data-corner={corner}
100
- />
101
- ))}
90
+ {showHandles &&
91
+ corners.map(({ corner, cx, cy, cursor }) => (
92
+ <rect
93
+ key={corner}
94
+ x={cx - HANDLE_SIZE / 2}
95
+ y={cy - HANDLE_SIZE / 2}
96
+ width={HANDLE_SIZE}
97
+ height={HANDLE_SIZE}
98
+ className="squisq-scene-selection-handle"
99
+ style={{ cursor }}
100
+ onPointerDown={
101
+ onHandlePointerDown ? (e) => onHandlePointerDown(e, corner) : undefined
102
+ }
103
+ data-corner={corner}
104
+ />
105
+ ))}
102
106
  </g>
103
107
  );
104
108
  })}