@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.
- package/README.md +57 -10
- package/dist/index.d.ts +476 -105
- package/dist/index.js +8703 -6741
- package/dist/index.js.map +1 -1
- package/dist/styles/fa-brands-400-AHOAZHCU.woff2 +0 -0
- package/dist/styles/fa-regular-400-VRZYIBIZ.woff2 +0 -0
- package/dist/styles/fa-solid-900-MDEYK55F.woff2 +0 -0
- package/dist/styles/fa-v4compatibility-ETEVP6IB.woff2 +0 -0
- package/dist/styles/index.css +14617 -0
- package/package.json +15 -7
- package/src/BlockPropertiesPopover.tsx +23 -7
- package/src/EditorContext.tsx +22 -16
- package/src/EditorShell.tsx +121 -35
- package/src/MediaBin.tsx +171 -28
- package/src/OutlinePanel.tsx +26 -4
- package/src/PreviewControls.tsx +475 -145
- package/src/PreviewPanel.tsx +17 -9
- package/src/RawEditor.tsx +10 -4
- package/src/TemplateAnnotation.ts +22 -7
- package/src/TemplateContentPreview.tsx +56 -0
- package/src/TemplatePicker.tsx +295 -128
- package/src/ThemeCustomizerPanel.tsx +22 -15
- package/src/Toolbar.tsx +547 -217
- package/src/TransitionPicker.tsx +8 -1
- package/src/VersionHistoryPanel.tsx +2 -2
- package/src/ViewSwitcher.tsx +4 -4
- package/src/WysiwygEditor.tsx +45 -3
- package/src/__tests__/buildPreviewDocTransition.test.ts +1 -2
- package/src/__tests__/codeContextSectionView.test.tsx +95 -0
- package/src/__tests__/codeContextZoneManager.test.ts +127 -0
- package/src/__tests__/diffContextSections.test.ts +39 -0
- package/src/__tests__/editorShellCodeContext.test.tsx +86 -0
- package/src/__tests__/editorShellProps.test.tsx +363 -0
- package/src/__tests__/headingTransition.test.ts +59 -9
- package/src/__tests__/imageEditorShell.test.tsx +23 -0
- package/src/__tests__/mediaReferences.test.ts +82 -0
- package/src/__tests__/previewControls.test.tsx +163 -0
- package/src/__tests__/templateAnnotationRoundTrip.test.ts +23 -2
- package/src/__tests__/templateContentPreview.test.ts +101 -0
- package/src/__tests__/tiptapBridge.test.ts +47 -0
- package/src/__tests__/useJsonEditorTokens.test.ts +59 -0
- package/src/__tests__/useMediaRecorder.test.ts +17 -0
- package/src/codeContext/CodeContextSectionView.tsx +124 -0
- package/src/codeContext/CodeContextZoneManager.ts +149 -0
- package/src/codeContext/CodeContextZones.tsx +121 -0
- package/src/codeContext/diffContextSections.ts +38 -0
- package/src/codeContext/types.ts +75 -0
- package/src/diagram/DiagramWidget.tsx +6 -4
- package/src/headingTransition.ts +96 -21
- package/src/index.ts +34 -2
- package/src/jsonEditor/useJsonEditorTokens.ts +13 -43
- package/src/mediaReferences.ts +299 -0
- package/src/recorder/hooks/useMediaRecorder.ts +9 -10
- package/src/scene/Scene.tsx +53 -7
- package/src/scene/SceneBlockWidget.tsx +6 -3
- package/src/scene/SceneSelection.tsx +19 -15
- package/src/scene/SceneSideToolbar.tsx +89 -0
- package/src/scene/layers/DiagramEdges.tsx +4 -3
- package/src/scene/layers/edgeGeometry.ts +23 -4
- package/src/scene/scene.css +142 -2
- package/src/scene/tools/ConnectTool.ts +113 -24
- package/src/scene/tools/DrawingConnectTool.ts +86 -16
- package/src/scene/tools/SceneTool.ts +2 -0
- package/src/styles/code-context.css +155 -0
- package/src/styles/editor.css +991 -84
- package/src/styles/index.css +1 -0
- package/src/templateContentPreviewResolver.ts +353 -0
- 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
|
-
|
|
11
|
-
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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(/</g, '<')
|
|
295
|
+
.replace(/>/g, '>')
|
|
296
|
+
.replace(/"/g, '"')
|
|
297
|
+
.replace(/'|'/g, "'")
|
|
298
|
+
.replace(/&/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
|
|
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 (
|
|
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:
|
|
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
|
|
260
|
-
const
|
|
261
|
-
|
|
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) {
|
package/src/scene/Scene.tsx
CHANGED
|
@@ -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
|
|
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
|
-
|
|
463
|
-
|
|
464
|
-
|
|
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
|
-
{
|
|
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
|
-
{
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
})}
|