@bendyline/squisq-editor-react 2.0.0 → 2.0.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.
- package/dist/index.d.ts +75 -33
- package/dist/index.js +1717 -935
- package/dist/index.js.map +1 -1
- package/dist/styles/index.css +145 -5
- package/package.json +4 -4
- package/src/DocumentSettingsDialog.tsx +32 -18
- package/src/EditorShell.tsx +1 -1
- package/src/PreviewControls.tsx +57 -24
- package/src/RecorderEntry.tsx +2 -0
- package/src/Toolbar.tsx +164 -19
- package/src/__tests__/codeContextSectionView.test.tsx +8 -6
- package/src/__tests__/documentSettingsDialog.test.tsx +22 -0
- package/src/__tests__/mediaAttachmentFlow.test.ts +2 -2
- package/src/__tests__/previewControls.test.tsx +164 -0
- package/src/__tests__/recorderTheme.test.tsx +42 -0
- package/src/__tests__/selectionConversions.test.ts +80 -0
- package/src/__tests__/tiptapBridge.test.ts +48 -7
- package/src/__tests__/tiptapImageRoundTrip.test.ts +1 -1
- package/src/__tests__/toolbarSelectionConversion.test.tsx +164 -0
- package/src/asciiDiagram/AsciiDiagramWidget.tsx +34 -4
- 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/codeContext/types.ts +1 -1
- package/src/customTemplates/__tests__/useMemoryLayerAdapter.test.ts +13 -0
- package/src/customTemplates/useMemoryLayerAdapter.ts +7 -1
- package/src/diagram/DiagramCanvas.tsx +16 -2
- package/src/frontmatterSettings.ts +23 -0
- package/src/index.ts +7 -1
- package/src/recorder/RecorderButton.tsx +9 -1
- package/src/recorder/RecorderModal.tsx +84 -41
- package/src/recorder/RecorderPanel.tsx +9 -1
- package/src/scene/__tests__/sceneIsolation.test.tsx +27 -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/tools/SelectTool.ts +5 -5
- package/src/selectionConversions.ts +155 -0
- package/src/styles/ascii-timeline.css +101 -4
- package/src/styles/editor.css +30 -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
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/** Canonical and legacy keys for frontmatter settings managed by the editor. */
|
|
2
|
+
export const FRONTMATTER_SETTING_KEYS = {
|
|
3
|
+
theme: { canonical: 'squisq-theme', legacy: ['themeId', 'theme'] as const },
|
|
4
|
+
transform: { canonical: 'squisq-transform', legacy: 'transform-style' as const },
|
|
5
|
+
captions: { canonical: 'squisq-captions', legacy: 'caption-style' as const },
|
|
6
|
+
coverSlide: { canonical: 'squisq-cover-slide', legacy: 'cover-slide' as const },
|
|
7
|
+
} as const;
|
|
8
|
+
|
|
9
|
+
/** Runtime defaults whose equivalent frontmatter entries can be omitted. */
|
|
10
|
+
export const FRONTMATTER_SETTING_DEFAULTS = {
|
|
11
|
+
theme: 'standard',
|
|
12
|
+
transform: '',
|
|
13
|
+
captions: 'standard',
|
|
14
|
+
coverSlide: true,
|
|
15
|
+
} as const;
|
|
16
|
+
|
|
17
|
+
/** Return `null` when a setting matches its runtime default so writers remove it. */
|
|
18
|
+
export function omitFrontmatterDefault<T extends string | number | boolean>(
|
|
19
|
+
value: T,
|
|
20
|
+
defaultValue: T,
|
|
21
|
+
): T | null {
|
|
22
|
+
return value === defaultValue ? null : value;
|
|
23
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -263,6 +263,7 @@ export {
|
|
|
263
263
|
applyRepairCommand,
|
|
264
264
|
replaceAsciiFenceText,
|
|
265
265
|
} from './asciiDiagram/asciiDiagramCommands.js';
|
|
266
|
+
export type { ApplyAsciiDiagramCommandOptions } from './asciiDiagram/asciiDiagramCommands.js';
|
|
266
267
|
// RepairableDiagramExtension mounts an inline "Repair as diagram" button on
|
|
267
268
|
// code fences holding BROKEN box art — art too misaligned for clean detection
|
|
268
269
|
// (so it renders as a faithful code block). One click reconstructs it into
|
|
@@ -288,6 +289,7 @@ export {
|
|
|
288
289
|
renameNodeOp,
|
|
289
290
|
resizeNodeOp,
|
|
290
291
|
sanitizeAsciiLabel,
|
|
292
|
+
translateDiagramOp,
|
|
291
293
|
} from './asciiDiagram/asciiDiagramOps.js';
|
|
292
294
|
export { shouldPasteAsAsciiFence } from './asciiDiagram/asciiPaste.js';
|
|
293
295
|
|
|
@@ -367,7 +369,11 @@ export type { JsonEditorProps } from './jsonEditor/index.js';
|
|
|
367
369
|
// `MediaProvider`. Previously published as `@bendyline/squisq-recorder-react`;
|
|
368
370
|
// folded into editor-react so it ships with the editor it's wired into.
|
|
369
371
|
export { RecorderModal } from './recorder/RecorderModal.js';
|
|
370
|
-
export type {
|
|
372
|
+
export type {
|
|
373
|
+
RecorderColorScheme,
|
|
374
|
+
RecorderModalProps,
|
|
375
|
+
RecorderSaveResult,
|
|
376
|
+
} from './recorder/RecorderModal.js';
|
|
371
377
|
export { RecorderButton } from './recorder/RecorderButton.js';
|
|
372
378
|
export type { RecorderButtonProps } from './recorder/RecorderButton.js';
|
|
373
379
|
export { RecorderPanel } from './recorder/RecorderPanel.js';
|
|
@@ -11,7 +11,11 @@ import { useCallback, useState, type CSSProperties } from 'react';
|
|
|
11
11
|
import { createPortal } from 'react-dom';
|
|
12
12
|
import type { MediaProvider } from '@bendyline/squisq/schemas';
|
|
13
13
|
import type { ContentContainer } from '@bendyline/squisq/storage';
|
|
14
|
-
import {
|
|
14
|
+
import {
|
|
15
|
+
RecorderModal,
|
|
16
|
+
type RecorderColorScheme,
|
|
17
|
+
type RecorderSaveResult,
|
|
18
|
+
} from './RecorderModal.js';
|
|
15
19
|
import type { RecorderSource } from './hooks/useMediaRecorder.js';
|
|
16
20
|
|
|
17
21
|
export interface RecorderButtonProps {
|
|
@@ -21,6 +25,8 @@ export interface RecorderButtonProps {
|
|
|
21
25
|
container?: ContentContainer | null;
|
|
22
26
|
/** Initial capture source. Defaults to `'mic'`. */
|
|
23
27
|
initialMode?: RecorderSource;
|
|
28
|
+
/** Light/dark chrome scheme copied onto the portaled modal. */
|
|
29
|
+
colorScheme?: RecorderColorScheme;
|
|
24
30
|
/** Fired after a successful save. */
|
|
25
31
|
onSave?: (result: RecorderSaveResult) => void;
|
|
26
32
|
/** Button label. Defaults to `'Record'`. */
|
|
@@ -35,6 +41,7 @@ export function RecorderButton({
|
|
|
35
41
|
mediaProvider,
|
|
36
42
|
container = null,
|
|
37
43
|
initialMode = 'mic',
|
|
44
|
+
colorScheme = 'light',
|
|
38
45
|
onSave,
|
|
39
46
|
label = 'Record',
|
|
40
47
|
style,
|
|
@@ -62,6 +69,7 @@ export function RecorderButton({
|
|
|
62
69
|
mediaProvider={mediaProvider}
|
|
63
70
|
container={container}
|
|
64
71
|
initialMode={initialMode}
|
|
72
|
+
colorScheme={colorScheme}
|
|
65
73
|
onClose={handleClose}
|
|
66
74
|
onSave={handleSave}
|
|
67
75
|
/>,
|
|
@@ -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);
|
|
@@ -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();
|
|
@@ -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': {
|
|
@@ -16,8 +16,19 @@ import type { DiagramEdgeAnchor, Layer } from '@bendyline/squisq/schemas';
|
|
|
16
16
|
export type SceneCommand =
|
|
17
17
|
/** Commit a layer's new top-left position (in viewport units). */
|
|
18
18
|
| { kind: 'moveLayer'; id: string; x: number; y: number }
|
|
19
|
-
/**
|
|
20
|
-
|
|
19
|
+
/**
|
|
20
|
+
* Commit a layer's new size (in viewport units). `x`/`y` carry the final
|
|
21
|
+
* top-left for west/north handle drags so hosts can persist the whole
|
|
22
|
+
* resize atomically instead of reparsing between a move and a size write.
|
|
23
|
+
*/
|
|
24
|
+
| {
|
|
25
|
+
kind: 'resizeLayer';
|
|
26
|
+
id: string;
|
|
27
|
+
width: number;
|
|
28
|
+
height: number;
|
|
29
|
+
x?: number;
|
|
30
|
+
y?: number;
|
|
31
|
+
}
|
|
21
32
|
/** Append a new layer to the scene. */
|
|
22
33
|
| { kind: 'addLayer'; layer: Layer }
|
|
23
34
|
/** Remove a layer by id. */
|
|
@@ -212,11 +212,11 @@ export const SelectTool: SceneTool = {
|
|
|
212
212
|
}
|
|
213
213
|
} else if (dragState.kind === 'resize') {
|
|
214
214
|
const { x, y, width, height } = dragState.currentBounds;
|
|
215
|
-
//
|
|
216
|
-
//
|
|
217
|
-
//
|
|
218
|
-
|
|
219
|
-
ctx.dispatch({ kind: 'resizeLayer', id: dragState.layerId, width, height });
|
|
215
|
+
// Keep the final origin + size in one command. In particular, the ASCII
|
|
216
|
+
// diagram adapter must render/reparse only once: committing a move and
|
|
217
|
+
// then a resize used to normalize the art between the two halves of one
|
|
218
|
+
// pointer gesture and could snap the node to a different grid position.
|
|
219
|
+
ctx.dispatch({ kind: 'resizeLayer', id: dragState.layerId, x, y, width, height });
|
|
220
220
|
}
|
|
221
221
|
(e.currentTarget as Element).releasePointerCapture?.(e.pointerId);
|
|
222
222
|
setDragState(ctx.interaction, null);
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plain-text selection conversions used by the toolbar's Convert menu.
|
|
3
|
+
* Keeping delimiter detection separate from the editor integrations makes
|
|
4
|
+
* the behavior identical in Monaco and Tiptap.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export type SelectionTableDelimiter = 'pipe' | 'comma' | 'tab' | 'multispace' | null;
|
|
8
|
+
|
|
9
|
+
export interface SelectionTable {
|
|
10
|
+
delimiter: SelectionTableDelimiter;
|
|
11
|
+
rows: string[][];
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface SelectionTaskItem {
|
|
15
|
+
checked: boolean;
|
|
16
|
+
text: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
type CandidateDelimiter = Exclude<SelectionTableDelimiter, null>;
|
|
20
|
+
|
|
21
|
+
/** Non-empty selected lines, trimmed without changing their order. */
|
|
22
|
+
export function selectionLines(text: string): string[] {
|
|
23
|
+
return text
|
|
24
|
+
.replace(/\r\n?/g, '\n')
|
|
25
|
+
.split('\n')
|
|
26
|
+
.map((line) => line.trim())
|
|
27
|
+
.filter((line) => line.length > 0);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function splitPipeLine(line: string): string[] {
|
|
31
|
+
let value = line.trim();
|
|
32
|
+
if (value.startsWith('|')) value = value.slice(1);
|
|
33
|
+
if (value.endsWith('|') && !value.endsWith('\\|')) value = value.slice(0, -1);
|
|
34
|
+
|
|
35
|
+
const cells: string[] = [];
|
|
36
|
+
let cell = '';
|
|
37
|
+
for (let i = 0; i < value.length; i++) {
|
|
38
|
+
const char = value[i];
|
|
39
|
+
if (char === '\\' && value[i + 1] === '|') {
|
|
40
|
+
cell += '|';
|
|
41
|
+
i++;
|
|
42
|
+
} else if (char === '|') {
|
|
43
|
+
cells.push(cell.trim());
|
|
44
|
+
cell = '';
|
|
45
|
+
} else {
|
|
46
|
+
cell += char;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
cells.push(cell.trim());
|
|
50
|
+
return cells;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** A small CSV-row parser so quoted commas remain inside their cell. */
|
|
54
|
+
function splitCommaLine(line: string): string[] {
|
|
55
|
+
const cells: string[] = [];
|
|
56
|
+
let cell = '';
|
|
57
|
+
let quoted = false;
|
|
58
|
+
|
|
59
|
+
for (let i = 0; i < line.length; i++) {
|
|
60
|
+
const char = line[i];
|
|
61
|
+
if (char === '"' && !quoted && cell.trim().length === 0) {
|
|
62
|
+
// A quote only opens CSV quoting at the start of a field. Literal
|
|
63
|
+
// quotes elsewhere (for example 5" screen) remain part of the value.
|
|
64
|
+
quoted = true;
|
|
65
|
+
cell = '';
|
|
66
|
+
} else if (char === '"' && quoted) {
|
|
67
|
+
if (line[i + 1] === '"') {
|
|
68
|
+
cell += '"';
|
|
69
|
+
i++;
|
|
70
|
+
} else {
|
|
71
|
+
quoted = false;
|
|
72
|
+
}
|
|
73
|
+
} else if (char === ',' && !quoted) {
|
|
74
|
+
cells.push(cell.trim());
|
|
75
|
+
cell = '';
|
|
76
|
+
} else {
|
|
77
|
+
cell += char;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
if (quoted) return line.split(',').map((value) => value.trim());
|
|
81
|
+
cells.push(cell.trim());
|
|
82
|
+
return cells;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function splitLine(line: string, delimiter: CandidateDelimiter): string[] {
|
|
86
|
+
switch (delimiter) {
|
|
87
|
+
case 'pipe':
|
|
88
|
+
return splitPipeLine(line);
|
|
89
|
+
case 'comma':
|
|
90
|
+
return splitCommaLine(line);
|
|
91
|
+
case 'tab':
|
|
92
|
+
return line
|
|
93
|
+
.trim()
|
|
94
|
+
.split('\t')
|
|
95
|
+
.map((cell) => cell.trim());
|
|
96
|
+
case 'multispace':
|
|
97
|
+
return line
|
|
98
|
+
.trim()
|
|
99
|
+
.split(/[ \u00a0]{2,}/)
|
|
100
|
+
.map((cell) => cell.trim());
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Detect a delimiter only when at least two lines use it and every line
|
|
106
|
+
* produces the same number of columns. Inconsistent prose therefore falls
|
|
107
|
+
* back to a useful one-column table instead of losing text.
|
|
108
|
+
*/
|
|
109
|
+
export function selectionToTable(text: string): SelectionTable {
|
|
110
|
+
const lines = selectionLines(text);
|
|
111
|
+
const candidates: CandidateDelimiter[] = ['pipe', 'comma', 'tab', 'multispace'];
|
|
112
|
+
|
|
113
|
+
if (lines.length >= 2) {
|
|
114
|
+
for (const delimiter of candidates) {
|
|
115
|
+
const rows = lines.map((line) => splitLine(line, delimiter));
|
|
116
|
+
const columnCount = rows[0]?.length ?? 0;
|
|
117
|
+
if (columnCount >= 2 && rows.every((row) => row.length === columnCount)) {
|
|
118
|
+
return { delimiter, rows };
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return { delimiter: null, rows: lines.map((line) => [line]) };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function escapeTableCell(cell: string): string {
|
|
127
|
+
return cell.replace(/\|/g, '\\|');
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Render a selection as a GFM table, using its first row as the header. */
|
|
131
|
+
export function selectionToTableMarkdown(text: string): string {
|
|
132
|
+
const { rows } = selectionToTable(text);
|
|
133
|
+
if (rows.length === 0) return '';
|
|
134
|
+
|
|
135
|
+
const formatRow = (row: string[]) => `| ${row.map(escapeTableCell).join(' | ')} |`;
|
|
136
|
+
const separator = rows[0].map(() => '---');
|
|
137
|
+
return [formatRow(rows[0]), formatRow(separator), ...rows.slice(1).map(formatRow)].join('\n');
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Convert selected lines to task items, normalizing existing list markers. */
|
|
141
|
+
export function selectionToTaskItems(text: string): SelectionTaskItem[] {
|
|
142
|
+
return selectionLines(text).map((line) => {
|
|
143
|
+
const task = /^[-*+]\s+\[([ xX])\]\s*(.*)$/.exec(line);
|
|
144
|
+
if (task) return { checked: task[1].toLowerCase() === 'x', text: task[2].trim() };
|
|
145
|
+
|
|
146
|
+
const withoutListMarker = line.replace(/^(?:[-*+]\s+|\d+[.)]\s+)/, '');
|
|
147
|
+
return { checked: false, text: withoutListMarker.trim() };
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function selectionToTaskListMarkdown(text: string): string {
|
|
152
|
+
return selectionToTaskItems(text)
|
|
153
|
+
.map((item) => `- [${item.checked ? 'x' : ' '}] ${item.text}`)
|
|
154
|
+
.join('\n');
|
|
155
|
+
}
|