@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.
- package/README.md +10 -2
- package/dist/index.d.ts +579 -47
- package/dist/index.js +7477 -2943
- package/dist/index.js.map +1 -1
- package/dist/styles/index.css +1008 -38
- package/package.json +6 -5
- package/src/DocumentSettingsDialog.tsx +32 -18
- package/src/EditorContext.tsx +27 -0
- package/src/EditorShell.tsx +25 -1
- package/src/PreviewControls.tsx +77 -29
- package/src/PreviewPanel.tsx +5 -1
- package/src/RawEditor.tsx +12 -0
- package/src/RecorderEntry.tsx +5 -0
- package/src/Toolbar.tsx +479 -36
- package/src/WysiwygEditor.tsx +25 -11
- package/src/__tests__/buildPreviewDocContent.test.ts +17 -0
- package/src/__tests__/codeContextSectionView.test.tsx +8 -6
- package/src/__tests__/documentSettingsDialog.test.tsx +22 -0
- package/src/__tests__/editorShellProps.test.tsx +65 -0
- package/src/__tests__/findMode.test.tsx +104 -0
- package/src/__tests__/findModel.test.ts +55 -0
- package/src/__tests__/markdownCodeFence.test.ts +45 -0
- package/src/__tests__/mediaAttachmentFlow.test.ts +2 -2
- package/src/__tests__/mediaReferences.test.ts +15 -0
- package/src/__tests__/previewControls.test.tsx +195 -0
- package/src/__tests__/recorderTheme.test.tsx +42 -0
- package/src/__tests__/selectionConversions.test.ts +80 -0
- package/src/__tests__/tiptapBridge.test.ts +57 -7
- package/src/__tests__/tiptapImageRoundTrip.test.ts +1 -1
- package/src/__tests__/toolbarSelectionConversion.test.tsx +190 -0
- package/src/__tests__/writeCanvasSettings.test.ts +15 -0
- package/src/asciiDiagram/AsciiDiagramWidget.tsx +34 -4
- package/src/asciiDiagram/__tests__/AsciiDiagramExtension.test.ts +20 -1
- package/src/asciiDiagram/__tests__/asciiDiagramCommands.test.ts +58 -1
- package/src/asciiDiagram/asciiDiagramCommands.ts +36 -10
- package/src/asciiDiagram/asciiDiagramData.ts +33 -0
- package/src/asciiDiagram/asciiDiagramOps.ts +19 -0
- package/src/buildPreviewDoc.ts +9 -7
- package/src/codeContext/types.ts +1 -1
- package/src/codeSnippet/CodeSnippetExtension.ts +205 -0
- package/src/codeSnippet/CodeSnippetWidget.tsx +109 -0
- package/src/codeSnippet/__tests__/CodeSnippetExtension.test.ts +95 -0
- package/src/codeSnippet/__tests__/codeSnippetLanguages.test.ts +50 -0
- package/src/codeSnippet/codeSnippetCommands.ts +21 -0
- package/src/codeSnippet/codeSnippetData.ts +43 -0
- package/src/codeSnippet/codeSnippetLanguages.ts +216 -0
- package/src/customTemplates/__tests__/useMemoryLayerAdapter.test.ts +13 -0
- package/src/customTemplates/useMemoryLayerAdapter.ts +7 -1
- package/src/diagram/DiagramCanvas.tsx +17 -2
- package/src/find/FindHighlightExtension.ts +81 -0
- package/src/find/FindToolbar.tsx +314 -0
- package/src/find/findModel.ts +77 -0
- package/src/frontmatterSettings.ts +23 -0
- package/src/index.ts +96 -1
- package/src/markdownCodeFence.ts +72 -0
- package/src/mediaReferences.ts +5 -3
- package/src/mermaid/MermaidDiagramCanvas.tsx +693 -0
- package/src/mermaid/MermaidDiagramExtension.ts +243 -0
- package/src/mermaid/MermaidDiagramTypeThumbnail.tsx +184 -0
- package/src/mermaid/MermaidDiagramWidget.tsx +653 -0
- package/src/mermaid/MermaidShapePalette.tsx +245 -0
- package/src/mermaid/__tests__/MermaidDiagramExtension.test.ts +361 -0
- package/src/mermaid/__tests__/mermaidDiagramTypes.test.ts +35 -0
- package/src/mermaid/__tests__/mermaidRenderer.test.ts +49 -0
- package/src/mermaid/__tests__/mermaidSourceOps.test.ts +213 -0
- package/src/mermaid/__tests__/mermaidSyntax.test.ts +71 -0
- package/src/mermaid/mermaidCommands.ts +34 -0
- package/src/mermaid/mermaidData.ts +31 -0
- package/src/mermaid/mermaidDiagramTypes.ts +325 -0
- package/src/mermaid/mermaidModel.ts +31 -0
- package/src/mermaid/mermaidRenderer.ts +181 -0
- package/src/mermaid/mermaidShapes.ts +113 -0
- package/src/mermaid/mermaidSourceOps.ts +454 -0
- package/src/recorder/RecorderButton.tsx +9 -1
- package/src/recorder/RecorderModal.tsx +84 -41
- package/src/recorder/RecorderPanel.tsx +9 -1
- package/src/scene/Scene.tsx +46 -15
- package/src/scene/SceneBlockToolbar.tsx +29 -14
- package/src/scene/SceneViewControls.tsx +43 -0
- package/src/scene/__tests__/fitScale.test.ts +19 -0
- package/src/scene/__tests__/sceneIsolation.test.tsx +27 -1
- package/src/scene/__tests__/useScenePanZoom.test.ts +28 -1
- package/src/scene/adapters/DrawingAdapter.ts +6 -1
- package/src/scene/adapters/LayoutAdapter.ts +3 -0
- package/src/scene/commands/SceneCommand.ts +13 -2
- package/src/scene/fitScale.ts +17 -0
- package/src/scene/hooks/useScenePanZoom.ts +11 -4
- package/src/scene/scene.css +85 -3
- package/src/scene/tools/SelectTool.ts +5 -5
- package/src/selectionConversions.ts +155 -0
- package/src/styles/ascii-timeline.css +101 -4
- package/src/styles/code-snippet.css +76 -0
- package/src/styles/editor.css +352 -2
- package/src/styles/index.css +2 -0
- package/src/styles/mermaid-diagram.css +487 -0
- package/src/styles/tree-view.css +51 -1
- package/src/timeline/TimelineEditorWidget.tsx +200 -41
- package/src/timeline/__tests__/TimelineEditorWidget.test.tsx +61 -2
- package/src/timeline/__tests__/timelineCommands.test.ts +32 -0
- package/src/timeline/__tests__/timelineOps.test.ts +55 -0
- package/src/timeline/timelineCommands.ts +54 -0
- package/src/timeline/timelineOps.ts +107 -3
- package/src/tiptapBridge.ts +23 -5
- package/src/treeview/TreeOutlineWidget.tsx +153 -3
- package/src/treeview/__tests__/TreeOutlineWidget.test.tsx +156 -0
- package/src/treeview/__tests__/treeOps.test.ts +52 -0
- package/src/treeview/__tests__/treeViewCommands.test.ts +16 -0
- package/src/treeview/treeOps.ts +59 -0
- package/src/treeview/treeViewCommands.ts +5 -0
- 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
|
|
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: '
|
|
82
|
-
border: '1px solid
|
|
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: '
|
|
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: '
|
|
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: '
|
|
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
|
|
143
|
+
border: '1px solid var(--squisq-recorder-border)',
|
|
114
144
|
borderRadius: 0,
|
|
115
|
-
background: '
|
|
116
|
-
color: '
|
|
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: '
|
|
134
|
-
color: '
|
|
135
|
-
border: '1px solid
|
|
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: '
|
|
146
|
-
color: '
|
|
147
|
-
border: '1px solid
|
|
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: '
|
|
154
|
-
borderColor: '
|
|
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: '
|
|
170
|
-
border: '1px solid
|
|
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: '
|
|
206
|
+
color: 'var(--squisq-recorder-accent-text)',
|
|
177
207
|
fontWeight: 600,
|
|
178
|
-
background: '
|
|
179
|
-
borderColor: '
|
|
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: '
|
|
200
|
-
border: '1px solid
|
|
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: '
|
|
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: '
|
|
212
|
-
border: '1px solid
|
|
213
|
-
color: '
|
|
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
|
|
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={{
|
|
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 {
|
|
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);
|
package/src/scene/Scene.tsx
CHANGED
|
@@ -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
|
-
// ──
|
|
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
|
-
|
|
395
|
-
if (
|
|
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
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
);
|
|
416
|
-
|
|
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
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
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.
|
|
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)
|
|
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': {
|