@bendyline/squisq-editor-react 1.6.1 → 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/dist/index.d.ts +87 -41
- package/dist/index.js +8263 -6705
- package/dist/index.js.map +1 -1
- package/dist/styles/index.css +841 -91
- package/package.json +4 -4
- package/src/BlockPropertiesPopover.tsx +23 -7
- package/src/EditorShell.tsx +65 -20
- package/src/MediaBin.tsx +171 -28
- package/src/PreviewControls.tsx +177 -44
- package/src/PreviewPanel.tsx +2 -0
- 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 +527 -207
- package/src/TransitionPicker.tsx +8 -1
- package/src/ViewSwitcher.tsx +4 -4
- package/src/WysiwygEditor.tsx +45 -3
- package/src/__tests__/buildPreviewDocTransition.test.ts +1 -2
- package/src/__tests__/editorShellProps.test.tsx +268 -1
- 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 +94 -1
- 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/diagram/DiagramWidget.tsx +6 -4
- package/src/headingTransition.ts +96 -21
- package/src/index.ts +2 -1
- package/src/mediaReferences.ts +299 -0
- 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/editor.css +862 -101
- package/src/templateContentPreviewResolver.ts +353 -0
- package/src/tiptapBridge.ts +60 -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
|
+
}
|
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
|
})}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SceneSideToolbar — placement wrapper for a `SceneBlockToolbar` beside an
|
|
3
|
+
* *inline* diagram / drawing / layout canvas.
|
|
4
|
+
*
|
|
5
|
+
* The toolbar normally floats as an always-open vertical column in the page's
|
|
6
|
+
* right gutter (`.squisq-scene-side-toolbar`, positioned at `left: 100%`). That
|
|
7
|
+
* only works when the gutter is wide enough to hold the 180px column; on a
|
|
8
|
+
* narrow editor — or in a host whose page fills most of the width — the column
|
|
9
|
+
* overhangs the editor's clipping edge and gets cut off.
|
|
10
|
+
*
|
|
11
|
+
* So we measure the real gutter (the gap between the canvas's right edge and
|
|
12
|
+
* the nearest horizontally-clipping ancestor) rather than guessing from a
|
|
13
|
+
* viewport breakpoint, since page width / centering / host chrome all move it:
|
|
14
|
+
* • Fits — render the column as-is (the common, wider case).
|
|
15
|
+
* • Clips — render the toolbar as a compact horizontal bar in normal flow
|
|
16
|
+
* above the canvas (`.squisq-scene-inline-toolbar`). It stays visible and
|
|
17
|
+
* is never clipped; it just costs a small strip of vertical space.
|
|
18
|
+
*
|
|
19
|
+
* A normal-flow bar is used rather than an on-demand overlay popover because
|
|
20
|
+
* absolutely-positioned content overlapping the React Flow canvas fails to
|
|
21
|
+
* paint in some hosts (the diagram's own maximize overlay hits the same wall),
|
|
22
|
+
* whereas in-flow content always renders.
|
|
23
|
+
*
|
|
24
|
+
* Only the inline placement adapts. Maximized mode has a full screen to reserve
|
|
25
|
+
* a static right column, so the widgets render the bare
|
|
26
|
+
* `.squisq-scene-side-toolbar` there and never mount this wrapper.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { useLayoutEffect, useRef, useState, type ReactNode } from 'react';
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Horizontal room the gutter must have to host the open column, kept in sync
|
|
33
|
+
* with `.squisq-scene-side-toolbar` in scene.css (12px `margin-left` + 180px
|
|
34
|
+
* `width`) plus a few px of breathing room so we fall back to the bar just
|
|
35
|
+
* before the column would touch the clip edge.
|
|
36
|
+
*/
|
|
37
|
+
const SIDE_TOOLBAR_FIT_PX = 12 + 180 + 4;
|
|
38
|
+
|
|
39
|
+
interface SceneSideToolbarProps {
|
|
40
|
+
/** The `SceneBlockToolbar` element built by the host widget. */
|
|
41
|
+
children: ReactNode;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function SceneSideToolbar({ children }: SceneSideToolbarProps) {
|
|
45
|
+
const wrapRef = useRef<HTMLDivElement>(null);
|
|
46
|
+
const [collapsed, setCollapsed] = useState(false);
|
|
47
|
+
|
|
48
|
+
// Measure whether the right gutter can host the column. The column is
|
|
49
|
+
// absolutely positioned at the shell's right edge, so its clipping depends on
|
|
50
|
+
// `shellRight` vs. the nearest ancestor that clips horizontally — recomputed
|
|
51
|
+
// whenever the editor body resizes (window resize, outline toggle, etc.).
|
|
52
|
+
// Switching to the in-flow bar changes shell *height*, not `shellRight`, so
|
|
53
|
+
// there's no measure↔collapse loop.
|
|
54
|
+
useLayoutEffect(() => {
|
|
55
|
+
const wrap = wrapRef.current;
|
|
56
|
+
if (!wrap) return;
|
|
57
|
+
const shell = wrap.closest('.squisq-scene-shell') as HTMLElement | null;
|
|
58
|
+
const clip = wrap.closest('.squisq-editor-content') as HTMLElement | null;
|
|
59
|
+
const measure = () => {
|
|
60
|
+
if (!shell) return;
|
|
61
|
+
const shellRight = shell.getBoundingClientRect().right;
|
|
62
|
+
const clipRight = clip
|
|
63
|
+
? clip.getBoundingClientRect().right
|
|
64
|
+
: document.documentElement.clientWidth;
|
|
65
|
+
setCollapsed(clipRight - shellRight < SIDE_TOOLBAR_FIT_PX);
|
|
66
|
+
};
|
|
67
|
+
measure();
|
|
68
|
+
const ro = new ResizeObserver(measure);
|
|
69
|
+
const target = clip ?? shell;
|
|
70
|
+
if (target) ro.observe(target);
|
|
71
|
+
if (shell && shell !== target) ro.observe(shell);
|
|
72
|
+
window.addEventListener('resize', measure);
|
|
73
|
+
return () => {
|
|
74
|
+
ro.disconnect();
|
|
75
|
+
window.removeEventListener('resize', measure);
|
|
76
|
+
};
|
|
77
|
+
}, []);
|
|
78
|
+
|
|
79
|
+
return (
|
|
80
|
+
<div
|
|
81
|
+
ref={wrapRef}
|
|
82
|
+
className={collapsed ? 'squisq-scene-inline-toolbar' : 'squisq-scene-side-toolbar'}
|
|
83
|
+
>
|
|
84
|
+
{children}
|
|
85
|
+
</div>
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export default SceneSideToolbar;
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
import type { MarkerStyle } from '@bendyline/squisq/schemas';
|
|
17
17
|
import { connectorPath, markerPath } from '@bendyline/squisq/doc';
|
|
18
18
|
import type { SceneEdge } from '../commands/SceneCommand';
|
|
19
|
-
import { boxesOf,
|
|
19
|
+
import { boxesOf, edgeEndpoints } from './edgeGeometry';
|
|
20
20
|
import type { EdgeNodeBox } from './edgeGeometry';
|
|
21
21
|
|
|
22
22
|
interface DiagramEdgesProps {
|
|
@@ -81,8 +81,9 @@ export function DiagramEdges({
|
|
|
81
81
|
const a = boxById.get(edge.source);
|
|
82
82
|
const b = boxById.get(edge.target);
|
|
83
83
|
if (!a || !b) return null;
|
|
84
|
-
const
|
|
85
|
-
|
|
84
|
+
const snapped = edgeEndpoints(nodes, edge.source, edge.target);
|
|
85
|
+
if (!snapped) return null;
|
|
86
|
+
const { start, end } = snapped;
|
|
86
87
|
const d = connectorPath(edge.routing ?? defaultRouting, start, end);
|
|
87
88
|
const startMarker = edge.startMarker ?? 'none';
|
|
88
89
|
const endMarker = edge.endMarker ?? 'arrow';
|
|
@@ -8,6 +8,11 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import { NODE_WIDTH, NODE_HEIGHT } from './nodeCard';
|
|
11
|
+
import {
|
|
12
|
+
nearestSnapPoint as nearestCoreSnapPoint,
|
|
13
|
+
snapEndpoints as coreSnapEndpoints,
|
|
14
|
+
type ConnectorSnapPoint,
|
|
15
|
+
} from '@bendyline/squisq/doc';
|
|
11
16
|
|
|
12
17
|
/** Minimal box an edge needs from a node/shape (id + bounds). */
|
|
13
18
|
export interface EdgeNodeBox {
|
|
@@ -49,21 +54,35 @@ export function edgePoint(from: NodeBox, to: NodeBox): { x: number; y: number }
|
|
|
49
54
|
return { x: from.cx + dx * s, y: from.cy + dy * s };
|
|
50
55
|
}
|
|
51
56
|
|
|
57
|
+
function boxFor(nodes: readonly EdgeNodeBox[], id: string): NodeBox | null {
|
|
58
|
+
return boxesOf(nodes).get(id) ?? null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Nearest stable connector port on the named node/shape to `point`. */
|
|
62
|
+
export function snapPointToward(
|
|
63
|
+
nodes: readonly EdgeNodeBox[],
|
|
64
|
+
id: string,
|
|
65
|
+
point: { x: number; y: number },
|
|
66
|
+
): ConnectorSnapPoint | null {
|
|
67
|
+
const box = boxFor(nodes, id);
|
|
68
|
+
return box ? nearestCoreSnapPoint(box, point) : null;
|
|
69
|
+
}
|
|
70
|
+
|
|
52
71
|
/**
|
|
53
|
-
*
|
|
54
|
-
* endpoint shape is missing. Used by the renderer and by
|
|
72
|
+
* Snapped start/end points of the edge `source`→`target`, or null when an
|
|
73
|
+
* endpoint shape is missing. Used by the renderer and by tool hit-testing
|
|
55
74
|
* endpoint handles against the same geometry the renderer draws.
|
|
56
75
|
*/
|
|
57
76
|
export function edgeEndpoints(
|
|
58
77
|
nodes: readonly EdgeNodeBox[],
|
|
59
78
|
source: string,
|
|
60
79
|
target: string,
|
|
61
|
-
): { start:
|
|
80
|
+
): { start: ConnectorSnapPoint; end: ConnectorSnapPoint } | null {
|
|
62
81
|
const boxes = boxesOf(nodes);
|
|
63
82
|
const a = boxes.get(source);
|
|
64
83
|
const b = boxes.get(target);
|
|
65
84
|
if (!a || !b) return null;
|
|
66
|
-
return
|
|
85
|
+
return coreSnapEndpoints(a, b);
|
|
67
86
|
}
|
|
68
87
|
|
|
69
88
|
export function straightPath(a: { x: number; y: number }, b: { x: number; y: number }): string {
|