@1agh/maude 1.4.6 → 1.5.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/apps/studio/annotations/ai-read.ts +288 -0
- package/apps/studio/annotations/ai-write.ts +534 -0
- package/apps/studio/annotations/board-io.ts +94 -0
- package/apps/studio/annotations/board-text.ts +45 -0
- package/apps/studio/annotations/constants.ts +58 -0
- package/apps/studio/annotations/elements/_shared.ts +125 -0
- package/apps/studio/annotations/elements/arrow.model.ts +196 -0
- package/apps/studio/annotations/elements/media.model.ts +79 -0
- package/apps/studio/annotations/elements/pen.model.ts +82 -0
- package/apps/studio/annotations/elements/section.model.ts +70 -0
- package/apps/studio/annotations/elements/shape.model.ts +141 -0
- package/apps/studio/annotations/elements/sticky.model.ts +48 -0
- package/apps/studio/annotations/elements/text.model.ts +52 -0
- package/apps/studio/annotations/fields.ts +347 -0
- package/apps/studio/annotations/fractional-index.ts +234 -0
- package/apps/studio/annotations/legacy/mini-dom.ts +207 -0
- package/apps/studio/annotations/migrate-boot.ts +172 -0
- package/apps/studio/annotations/migrate-cli.ts +37 -0
- package/apps/studio/annotations/migrate-v1.ts +383 -0
- package/apps/studio/annotations/ops-merge.ts +15 -0
- package/apps/studio/annotations/ops.ts +499 -0
- package/apps/studio/annotations/registry.ts +159 -0
- package/apps/studio/annotations/replica.ts +264 -0
- package/apps/studio/annotations/scene.ts +235 -0
- package/apps/studio/annotations/schema.ts +188 -0
- package/apps/studio/annotations/types.ts +77 -0
- package/apps/studio/annotations/ui/board.ts +126 -0
- package/apps/studio/annotations/ui/containment.ts +118 -0
- package/apps/studio/annotations/ui/edit-actions.ts +551 -0
- package/apps/studio/annotations/ui/editor-channel.ts +62 -0
- package/apps/studio/annotations/ui/element-node.tsx +993 -0
- package/apps/studio/annotations/ui/pipeline-context.ts +17 -0
- package/apps/studio/annotations/ui/pointer-pipeline.ts +150 -0
- package/apps/studio/annotations/ui/render-model.ts +264 -0
- package/apps/studio/annotations/ui/scene.tsx +63 -0
- package/apps/studio/annotations/ui/text-editor.tsx +420 -0
- package/apps/studio/annotations/ui/text-session.ts +140 -0
- package/apps/studio/annotations/ui/text-style.ts +111 -0
- package/apps/studio/annotations/ui/world.ts +47 -0
- package/apps/studio/annotations/v1-adapter.ts +428 -0
- package/apps/studio/annotations-align.ts +21 -6
- package/apps/studio/annotations-bindings.ts +2 -0
- package/apps/studio/annotations-context-toolbar.tsx +9 -13
- package/apps/studio/annotations-groups.ts +3 -0
- package/apps/studio/annotations-layer.tsx +923 -1925
- package/apps/studio/annotations-model.ts +65 -4
- package/apps/studio/annotations-sync.ts +4 -47
- package/apps/studio/api.ts +249 -69
- package/apps/studio/bin/_import-figma.mjs +22 -12
- package/apps/studio/bin/annotate.mjs +331 -838
- package/apps/studio/bin/annotate.sh +4 -4
- package/apps/studio/bin/perf.sh +21 -7
- package/apps/studio/bin/read-annotations.mjs +184 -666
- package/apps/studio/bin/read-annotations.sh +9 -5
- package/apps/studio/canvas-artifacts.ts +9 -0
- package/apps/studio/canvas-lib.tsx +13 -1
- package/apps/studio/canvas-shell.tsx +6 -0
- package/apps/studio/client/app.jsx +98 -35
- package/apps/studio/client/hmr.mjs +1 -1
- package/apps/studio/client/panels/git-grouping.js +2 -2
- package/apps/studio/client/tree-expansion.js +217 -0
- package/apps/studio/collab/index.ts +49 -5
- package/apps/studio/collab/persistence.ts +31 -10
- package/apps/studio/collab/registry.ts +64 -26
- package/apps/studio/commands/annotation-ops-command.ts +72 -0
- package/apps/studio/cursors-overlay.tsx +158 -4
- package/apps/studio/dist/client.bundle.js +850 -850
- package/apps/studio/dist/comment-mount.js +2 -2
- package/apps/studio/figma/to-strokes.ts +31 -10
- package/apps/studio/git/endpoints.ts +1 -1
- package/apps/studio/git/service.ts +1 -1
- package/apps/studio/git/watch.ts +1 -1
- package/apps/studio/http.ts +90 -15
- package/apps/studio/server.ts +16 -0
- package/apps/studio/sync/accepted-cold-start.ts +12 -4
- package/apps/studio/sync/agent.ts +8 -4
- package/apps/studio/sync/codec.ts +96 -40
- package/apps/studio/sync/file-membership.ts +12 -2
- package/apps/studio/sync/file-plane.ts +1 -1
- package/apps/studio/sync/index.ts +38 -16
- package/apps/studio/sync/journal-client.ts +5 -0
- package/apps/studio/sync/limits.ts +7 -2
- package/apps/studio/sync/migrate-seed.ts +8 -5
- package/apps/studio/sync/projection.ts +7 -2
- package/apps/studio/sync/remote-docs.ts +34 -0
- package/apps/studio/sync/writer-registry.ts +10 -0
- package/apps/studio/text-caret.ts +11 -2
- package/apps/studio/tree-state.ts +45 -0
- package/apps/studio/undo-stack.ts +2 -2
- package/apps/studio/use-annotation-resize.tsx +48 -23
- package/apps/studio/use-annotation-selection.tsx +9 -2
- package/apps/studio/use-collab.tsx +76 -0
- package/apps/studio/whats-new.json +27 -0
- package/cli/lib/design-link.mjs +5 -1
- package/cli/lib/gitignore-block.mjs +1 -1
- package/cli/lib/gitignore-drift.mjs +2 -1
- package/package.json +9 -8
- package/plugins/design/templates/brief-board.tsx.template +1 -1
- package/apps/studio/annotation-edit-base.ts +0 -36
- package/apps/studio/commands/annotation-strokes-command.ts +0 -137
|
@@ -1,9 +1,35 @@
|
|
|
1
|
+
import { MAX_BOARD_BYTES, MAX_ELEMENTS } from './annotations/constants.ts';
|
|
2
|
+
import { v1ToV2 } from './annotations/migrate-v1.ts';
|
|
3
|
+
import { type Op as AnnotationOp, applyOps, diffToOps } from './annotations/ops.ts';
|
|
4
|
+
import { defOf } from './annotations/registry.ts';
|
|
5
|
+
import { observeReplica } from './annotations/replica.ts';
|
|
6
|
+
import { parseBoard, validateElements } from './annotations/schema.ts';
|
|
7
|
+
import type { AnnotationElement } from './annotations/types.ts';
|
|
8
|
+
import { BoardStore } from './annotations/ui/board.ts';
|
|
1
9
|
import {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
10
|
+
Containment,
|
|
11
|
+
expandForOp,
|
|
12
|
+
insertSection,
|
|
13
|
+
type MarqueeItem,
|
|
14
|
+
marqueeHits,
|
|
15
|
+
} from './annotations/ui/containment.ts';
|
|
16
|
+
import type { EditRequest } from './annotations/ui/element-node.tsx';
|
|
17
|
+
import { AnnotationPipelineContext } from './annotations/ui/pipeline-context.ts';
|
|
18
|
+
import { type Claim, PointerPipeline } from './annotations/ui/pointer-pipeline.ts';
|
|
19
|
+
import { type RenderItem, renderItemsFromStrokes } from './annotations/ui/render-model.ts';
|
|
20
|
+
import { AnnotationScene } from './annotations/ui/scene.tsx';
|
|
21
|
+
import {
|
|
22
|
+
aimCommitOps,
|
|
23
|
+
draftOp,
|
|
24
|
+
markSent,
|
|
25
|
+
openSession,
|
|
26
|
+
remotelyEdited,
|
|
27
|
+
restoreOp,
|
|
28
|
+
type TextSession,
|
|
29
|
+
} from './annotations/ui/text-session.ts';
|
|
30
|
+
import { TEXT_LAYER_CSS } from './annotations/ui/text-style.ts';
|
|
31
|
+
import { projectStrokes, type StrokeCache } from './annotations/ui/world.ts';
|
|
32
|
+
import { elementsToStrokes, strokesToElementMap } from './annotations/v1-adapter.ts';
|
|
7
33
|
/**
|
|
8
34
|
* @file annotations-layer.tsx — FigJam-style annotation overlay
|
|
9
35
|
* @scope apps/studio/annotations-layer.tsx
|
|
@@ -40,6 +66,7 @@ import {
|
|
|
40
66
|
useMemo,
|
|
41
67
|
useRef,
|
|
42
68
|
useState,
|
|
69
|
+
useSyncExternalStore,
|
|
43
70
|
} from 'react';
|
|
44
71
|
import { createPortal } from 'react-dom';
|
|
45
72
|
import {
|
|
@@ -146,8 +173,6 @@ import {
|
|
|
146
173
|
strokeHitTest,
|
|
147
174
|
strokeRotation,
|
|
148
175
|
strokesShallowEqual,
|
|
149
|
-
strokesToSvg,
|
|
150
|
-
svgToStrokes,
|
|
151
176
|
TEXT_LINE_HEIGHT,
|
|
152
177
|
type TextAlign,
|
|
153
178
|
type TextStroke,
|
|
@@ -172,11 +197,10 @@ import {
|
|
|
172
197
|
useViewportControllerContext,
|
|
173
198
|
useWorldRefContext,
|
|
174
199
|
} from './canvas-lib.tsx';
|
|
175
|
-
import {
|
|
200
|
+
import { buildAnnotationOpsRecord } from './commands/annotation-ops-command.ts';
|
|
176
201
|
import { ensureMenuStyles as ensureCtxMenuStyles } from './context-menu.tsx';
|
|
177
202
|
import { crossedDragThreshold, type Tool } from './input-router.tsx';
|
|
178
203
|
import { createMediaCommitChain, type MediaCommitResult } from './media-commit-chain.ts';
|
|
179
|
-
import { collapseEntrySelectAll, mountCaret, placeCaretAt } from './text-caret.ts';
|
|
180
204
|
import {
|
|
181
205
|
AnnotationResizeOverlay,
|
|
182
206
|
bboxResize,
|
|
@@ -197,7 +221,7 @@ import {
|
|
|
197
221
|
useCanvasMediaDrop,
|
|
198
222
|
} from './use-canvas-media-drop.tsx';
|
|
199
223
|
import { useChromeVisibility } from './use-chrome-visibility.tsx';
|
|
200
|
-
import { colorForName, useCollab } from './use-collab.tsx';
|
|
224
|
+
import { type AnnotationGesture, colorForName, useCollab } from './use-collab.tsx';
|
|
201
225
|
import { useSelectionSetOptional } from './use-selection-set.tsx';
|
|
202
226
|
import { type ShapeKind, useToolMode } from './use-tool-mode.tsx';
|
|
203
227
|
import { useUndoSinks, useUndoStackOptional } from './use-undo-stack.tsx';
|
|
@@ -208,6 +232,10 @@ import { useUndoSinks, useUndoStackOptional } from './use-undo-stack.tsx';
|
|
|
208
232
|
// re-exports the whole model so every existing
|
|
209
233
|
// `from './annotations-layer.tsx'` import keeps working unchanged.
|
|
210
234
|
export * from './annotations-model.ts';
|
|
235
|
+
// Arrow style enums are owned by canvas-arrowheads.ts (no cycle, DDR-067).
|
|
236
|
+
export type { ArrowHead, ArrowLineType } from './canvas-arrowheads.ts';
|
|
237
|
+
export { arrowHeadPoints } from './canvas-arrowheads.ts';
|
|
238
|
+
export { useAnnotationsVisibility } from './use-annotations-visibility.tsx';
|
|
211
239
|
|
|
212
240
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
213
241
|
// Types
|
|
@@ -215,7 +243,6 @@ export * from './annotations-model.ts';
|
|
|
215
243
|
// Phase 24 — arrow style enums are OWNED by canvas-arrowheads.ts (so that
|
|
216
244
|
// module imports nothing back from here — no cycle, see DDR-067) and re-exported
|
|
217
245
|
// here for back-compat (context-toolbar etc. import them from this module).
|
|
218
|
-
export type { ArrowHead, ArrowLineType } from './canvas-arrowheads.ts';
|
|
219
246
|
|
|
220
247
|
/** Phase 24 — cursor-following ghost placeholder descriptor (pure chrome). */
|
|
221
248
|
type GhostDescriptor =
|
|
@@ -266,97 +293,17 @@ function useCanvasChromeTheme(): 'light' | 'dark' {
|
|
|
266
293
|
return theme;
|
|
267
294
|
}
|
|
268
295
|
|
|
269
|
-
/**
|
|
270
|
-
* Shared inline-formatting state for the three text editors (sticky / anchored /
|
|
271
|
-
* standalone) — the unification surface (item 4d). Cmd/Ctrl + B / I / U toggle
|
|
272
|
-
* bold / italic / underline WHILE editing (preventing the browser's native
|
|
273
|
-
* execCommand, which would inject markup the model can't read), preview live via
|
|
274
|
-
* `style`, and commit on the stroke via `fmtRef`. `strike` rides along unchanged
|
|
275
|
-
* (toolbar-only — no universal shortcut). One hook = identical behaviour across
|
|
276
|
-
* all three editors.
|
|
277
|
-
*/
|
|
278
|
-
function useEditorFormat(initial: EditorFmt): {
|
|
279
|
-
fmtRef: { current: EditorFmt };
|
|
280
|
-
style: CSSProperties;
|
|
281
|
-
onFormatKey: (e: ReactKeyboardEvent) => boolean;
|
|
282
|
-
} {
|
|
283
|
-
const [bold, setBold] = useState(!!initial.bold);
|
|
284
|
-
const [italic, setItalic] = useState(!!initial.italic);
|
|
285
|
-
const [underline, setUnderline] = useState(!!initial.underline);
|
|
286
|
-
const [strike, setStrike] = useState(!!initial.strike);
|
|
287
|
-
// FigJam v3 — edit-mode toolbar extensions: size + alignment preview live in
|
|
288
|
-
// the editor and commit with the text (normFmt carries them through).
|
|
289
|
-
const [fontSize, setFontSize] = useState<number | undefined>(initial.fontSize);
|
|
290
|
-
const [align, setAlign] = useState<TextAlign | undefined>(initial.align);
|
|
291
|
-
const fmtRef = useRef<EditorFmt>({ bold, italic, underline, strike, fontSize, align });
|
|
292
|
-
fmtRef.current = { bold, italic, underline, strike, fontSize, align };
|
|
293
|
-
const style: CSSProperties = {
|
|
294
|
-
fontWeight: bold ? 700 : undefined,
|
|
295
|
-
fontStyle: italic ? 'italic' : undefined,
|
|
296
|
-
textDecoration: textDecoCss(strike, underline),
|
|
297
|
-
...(fontSize != null && fontSize !== initial.fontSize ? { fontSize: `${fontSize}px` } : {}),
|
|
298
|
-
...(align && align !== initial.align ? { textAlign: align } : {}),
|
|
299
|
-
};
|
|
300
|
-
// FigJam v3 — the edit-mode context toolbar drives the editor through this
|
|
301
|
-
// event (mutating the STROKE mid-edit would re-render the contentEditable
|
|
302
|
-
// and clobber typed text). The editor echoes its state back so the toolbar's
|
|
303
|
-
// pressed-states track live.
|
|
304
|
-
useEffect(() => {
|
|
305
|
-
if (typeof document === 'undefined') return;
|
|
306
|
-
const onFmt = (e: Event) => {
|
|
307
|
-
const d = (e as CustomEvent<{ key?: string; value?: unknown }>).detail;
|
|
308
|
-
if (!d?.key) return;
|
|
309
|
-
if (d.key === 'bold') setBold((v) => !v);
|
|
310
|
-
else if (d.key === 'italic') setItalic((v) => !v);
|
|
311
|
-
else if (d.key === 'underline') setUnderline((v) => !v);
|
|
312
|
-
else if (d.key === 'strike') setStrike((v) => !v);
|
|
313
|
-
else if (d.key === 'fontSize' && typeof d.value === 'number') setFontSize(d.value);
|
|
314
|
-
else if (d.key === 'align' && typeof d.value === 'string') setAlign(d.value as TextAlign);
|
|
315
|
-
};
|
|
316
|
-
document.addEventListener('maude:editor-format', onFmt);
|
|
317
|
-
return () => document.removeEventListener('maude:editor-format', onFmt);
|
|
318
|
-
}, []);
|
|
319
|
-
useEffect(() => {
|
|
320
|
-
if (typeof document === 'undefined') return;
|
|
321
|
-
const broadcast = () => {
|
|
322
|
-
document.dispatchEvent(
|
|
323
|
-
new CustomEvent('maude:editor-format-state', {
|
|
324
|
-
detail: { bold, italic, underline, strike, fontSize, align },
|
|
325
|
-
})
|
|
326
|
-
);
|
|
327
|
-
};
|
|
328
|
-
broadcast();
|
|
329
|
-
// The toolbar may mount AFTER the editor's first broadcast — it asks.
|
|
330
|
-
document.addEventListener('maude:editor-format-request', broadcast);
|
|
331
|
-
return () => document.removeEventListener('maude:editor-format-request', broadcast);
|
|
332
|
-
}, [bold, italic, underline, strike, fontSize, align]);
|
|
333
|
-
const onFormatKey = useCallback((e: ReactKeyboardEvent): boolean => {
|
|
334
|
-
if (!(e.metaKey || e.ctrlKey) || e.altKey || e.shiftKey) return false;
|
|
335
|
-
const k = e.key.toLowerCase();
|
|
336
|
-
if (k === 'b') {
|
|
337
|
-
e.preventDefault();
|
|
338
|
-
setBold((v) => !v);
|
|
339
|
-
return true;
|
|
340
|
-
}
|
|
341
|
-
if (k === 'i') {
|
|
342
|
-
e.preventDefault();
|
|
343
|
-
setItalic((v) => !v);
|
|
344
|
-
return true;
|
|
345
|
-
}
|
|
346
|
-
if (k === 'u') {
|
|
347
|
-
e.preventDefault();
|
|
348
|
-
setUnderline((v) => !v);
|
|
349
|
-
return true;
|
|
350
|
-
}
|
|
351
|
-
return false;
|
|
352
|
-
}, []);
|
|
353
|
-
return { fmtRef, style, onFormatKey };
|
|
354
|
-
}
|
|
355
|
-
|
|
356
296
|
// Phase 24 — moved to canvas-arrowheads.ts (single source for shaft + heads).
|
|
357
|
-
// Re-exported so the existing test import (`from '
|
|
297
|
+
// Re-exported so the existing test import (`from './annotations/annotations-layer.tsx'`)
|
|
358
298
|
// and the byte-identical canary keep working.
|
|
359
|
-
|
|
299
|
+
|
|
300
|
+
/** A whiteboard clipboard payload (v2 elements, or a pre-v2 strokes list). */
|
|
301
|
+
function isBoardClipboard(txt: string): boolean {
|
|
302
|
+
// The clipboard is foreign input (any page the user copied from can set it):
|
|
303
|
+
// nothing larger than a whole board is ever parsed (security review W3/A6).
|
|
304
|
+
if (txt.length > MAX_BOARD_BYTES) return false;
|
|
305
|
+
return txt.startsWith('{"maudeElements"') || txt.startsWith('{"maudeStrokes"');
|
|
306
|
+
}
|
|
360
307
|
|
|
361
308
|
function isEditable(t: EventTarget | null): boolean {
|
|
362
309
|
if (!t || !(t as HTMLElement).tagName) return false;
|
|
@@ -416,85 +363,14 @@ function resolveAssetHref(href: string): string {
|
|
|
416
363
|
}
|
|
417
364
|
|
|
418
365
|
/**
|
|
419
|
-
* An optimistic ImageStroke's `blob:`/`data:` href
|
|
420
|
-
*
|
|
421
|
-
*
|
|
422
|
-
* so the server's STORED + broadcast SVG silently diverges from whatever the
|
|
423
|
-
* client just sent. That divergence defeats the collab-echo self-suppression
|
|
424
|
-
* guard (the `annotationEchoRef` operation history, near `putStrokes` below): the
|
|
425
|
-
* echo's content no longer matches anything we recorded as "already
|
|
426
|
-
* applied", so a real `setStrokesState` fires from the (href-stripped)
|
|
427
|
-
* server copy — which can wipe out a SIBLING stroke's still-in-flight
|
|
428
|
-
* optimistic insert that was never itself part of that PUT. This is the
|
|
429
|
-
* concrete failure a 3-file concurrent drop hit: one
|
|
430
|
-
* image ended up href-stripped (a blank frame) and another was dropped
|
|
431
|
-
* entirely once an unrelated commit's echo round-tripped while both were
|
|
432
|
-
* still uploading. See media-commit-chain.ts for the sibling accumulator fix
|
|
433
|
-
* — this closes the other half, at the persistence layer.
|
|
366
|
+
* An optimistic ImageStroke's `blob:`/`data:` href (an upload in flight) is
|
|
367
|
+
* never sent to the board: the image is shown from `uploading` and committed
|
|
368
|
+
* once, with its `assets/…` href, when the upload lands.
|
|
434
369
|
*/
|
|
435
370
|
function isEphemeralHref(s: Stroke): boolean {
|
|
436
371
|
return s.tool === 'image' && /^(?:blob|data):/i.test(s.href);
|
|
437
372
|
}
|
|
438
373
|
|
|
439
|
-
/**
|
|
440
|
-
* Fold a local mutation's `next` against the live-rendered `prev`, restoring
|
|
441
|
-
* only strokes that are genuinely concurrent additions from ANOTHER
|
|
442
|
-
* in-flight commit — never reverting THIS mutation's own deletions.
|
|
443
|
-
* Disambiguated via `opBefore`, the baseline this mutation's `next` was
|
|
444
|
-
* itself computed from: an id present in `prev` but absent from BOTH
|
|
445
|
-
* `opBefore` and `next` was added by someone else after `opBefore` was
|
|
446
|
-
* captured (fold it in — this is the sibling-insert race this helper was
|
|
447
|
-
* originally written for). An id present in `opBefore` but absent from
|
|
448
|
-
* `next` was deliberately removed BY THIS MUTATION and must stay removed
|
|
449
|
-
* even though `prev` (React's rendered state) hasn't caught up to that
|
|
450
|
-
* removal yet.
|
|
451
|
-
*
|
|
452
|
-
* The prior version of this helper compared only `prev` against `next` —
|
|
453
|
-
* exactly the shape of every delete (an id in `prev`, absent from `next`) —
|
|
454
|
-
* so it silently folded every erased stroke straight back in locally, while
|
|
455
|
-
* the smaller, correct set still went out over PUT. Backspace looked like a
|
|
456
|
-
* no-op until a reload picked up the server's already-correct copy.
|
|
457
|
-
*/
|
|
458
|
-
export function reconcileCommit(
|
|
459
|
-
prev: readonly Stroke[],
|
|
460
|
-
opBefore: readonly Stroke[],
|
|
461
|
-
next: readonly Stroke[]
|
|
462
|
-
): Stroke[] {
|
|
463
|
-
const beforeIds = new Set(opBefore.map((s) => s.id));
|
|
464
|
-
const nextIds = new Set(next.map((s) => s.id));
|
|
465
|
-
const extra = prev.filter((s) => !beforeIds.has(s.id) && !nextIds.has(s.id));
|
|
466
|
-
return extra.length ? [...next, ...extra] : (next as Stroke[]);
|
|
467
|
-
}
|
|
468
|
-
|
|
469
|
-
/**
|
|
470
|
-
* Fold a FOREIGN collab-echo snapshot against local state. A foreign
|
|
471
|
-
* broadcast legitimately omits an id that was deleted (by us or a peer) —
|
|
472
|
-
* reviving it just because local `prev` hasn't caught up would make deletes
|
|
473
|
-
* unsyncable across tabs/peers. The only strokes worth resurrecting here are
|
|
474
|
-
* ones that are still purely local, not-yet-synced optimistic previews (an
|
|
475
|
-
* ephemeral blob:/data: href image mid-upload) that a foreign broadcast could
|
|
476
|
-
* never have known about in the first place.
|
|
477
|
-
*
|
|
478
|
-
* issue-106 deliberately does NOT carve out the stroke a local editor has open.
|
|
479
|
-
* That was tried: hold back its `text` so a peer's commit could not overwrite
|
|
480
|
-
* the editor. But this function's output IS the persisted store — it is
|
|
481
|
-
* serialized to `*.annotations.svg` and re-broadcast on the next commit of ANY
|
|
482
|
-
* stroke — so holding a field back doesn't shield a view, it silently reverts
|
|
483
|
-
* the peer's write and republishes the stale text under the local user's name,
|
|
484
|
-
* for as long as the editor stays open. The editor never needed it: the clobber
|
|
485
|
-
* was React rewriting an UNCONTROLLED contentEditable, which `useSessionValue`
|
|
486
|
-
* fixes at the DOM, and the commit reads `innerText` off the node rather than
|
|
487
|
-
* off the stroke. Last-write-wins stays the model here.
|
|
488
|
-
*/
|
|
489
|
-
export function reconcileForeignEcho(
|
|
490
|
-
prev: readonly Stroke[],
|
|
491
|
-
incoming: readonly Stroke[]
|
|
492
|
-
): Stroke[] {
|
|
493
|
-
const incomingIds = new Set(incoming.map((s) => s.id));
|
|
494
|
-
const extra = prev.filter((s) => !incomingIds.has(s.id) && isEphemeralHref(s));
|
|
495
|
-
return extra.length ? [...incoming, ...extra] : (incoming as Stroke[]);
|
|
496
|
-
}
|
|
497
|
-
|
|
498
374
|
/**
|
|
499
375
|
* Decide what `createImageFromFile`'s post-upload swap should do with a
|
|
500
376
|
* given `before` chain link. Extracted as a pure function (no React) so the
|
|
@@ -764,26 +640,6 @@ const ANNOT_CSS = `
|
|
|
764
640
|
stroke-width: 1;
|
|
765
641
|
stroke-dasharray: 4 3;
|
|
766
642
|
}
|
|
767
|
-
/* Phase 24 — sticky-note body. Word-wrapped multi-line text inside the card's
|
|
768
|
-
foreignObject. Text sits TOP-LEFT (FigJam parity); the editor contentEditable
|
|
769
|
-
mirrors the same box metrics so the read-edit swap doesn't shift the text.
|
|
770
|
-
text-align is overridden inline per-sticky when align is not left. */
|
|
771
|
-
.dc-sticky-body {
|
|
772
|
-
width: 100%;
|
|
773
|
-
height: 100%;
|
|
774
|
-
box-sizing: border-box;
|
|
775
|
-
padding: 14px 16px;
|
|
776
|
-
display: flex;
|
|
777
|
-
align-items: flex-start;
|
|
778
|
-
justify-content: flex-start;
|
|
779
|
-
text-align: left;
|
|
780
|
-
color: #2a2a28;
|
|
781
|
-
font-family: var(--u-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
|
|
782
|
-
line-height: 1.35;
|
|
783
|
-
white-space: pre-wrap;
|
|
784
|
-
overflow-wrap: anywhere;
|
|
785
|
-
overflow: hidden;
|
|
786
|
-
}
|
|
787
643
|
/* Phase 24 — while editing an annotation's text (a text label OR a sticky body,
|
|
788
644
|
both carry the dc-annot-editor class), force the I-beam. The important flag
|
|
789
645
|
plus the class selector beat use-tool-mode's blanket star-cursor rule (you
|
|
@@ -792,13 +648,13 @@ const ANNOT_CSS = `
|
|
|
792
648
|
that fight on its own — a non-important inline style loses to !important.
|
|
793
649
|
See DDR-067. (No backticks in this comment: the whole block is a JS template
|
|
794
650
|
literal, so a backtick here would terminate the string.) */
|
|
795
|
-
.dc-annot-editor, .dc-annot-editor
|
|
651
|
+
.dc-annot-editor, .dc-annot-editor *, textarea.dc-annot-text { cursor: text !important; }
|
|
796
652
|
/* Phase 7 (unified-text-editing) — hover affordance parity with artboard leaf
|
|
797
653
|
text: a standalone text stroke invites editing with the I-beam in Move mode
|
|
798
654
|
(double-click / Text-tool click enters its editor in place). Shapes and
|
|
799
655
|
stickies keep the selection arrow — their whole body is a move/select
|
|
800
656
|
target first. */
|
|
801
|
-
.dc-annot-
|
|
657
|
+
.dc-annot-el[data-tool="text"] .dc-annot-text { cursor: text; }
|
|
802
658
|
/* FigJam v3 — connection dots on a selected bindable shape. The important flag
|
|
803
659
|
beats use-tool-mode's blanket star-cursor rule (same fight as the editor +
|
|
804
660
|
resize handles — DDR-067). */
|
|
@@ -810,7 +666,7 @@ function ensureAnnotStyles(): void {
|
|
|
810
666
|
if (document.getElementById('dc-annot-css')) return;
|
|
811
667
|
const s = document.createElement('style');
|
|
812
668
|
s.id = 'dc-annot-css';
|
|
813
|
-
s.textContent = ANNOT_CSS
|
|
669
|
+
s.textContent = `${ANNOT_CSS}\n${TEXT_LAYER_CSS}`;
|
|
814
670
|
document.head.appendChild(s);
|
|
815
671
|
}
|
|
816
672
|
|
|
@@ -818,6 +674,10 @@ function ensureAnnotStyles(): void {
|
|
|
818
674
|
// Strokes store — lifted out of the layer so the contextual toolbar (Phase 5.1
|
|
819
675
|
// Task 8) can mutate strokes without prop-drilling.
|
|
820
676
|
|
|
677
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
678
|
+
// Strokes store — lifted out of the layer so the contextual toolbar (Phase 5.1
|
|
679
|
+
// Task 8) can mutate strokes without prop-drilling.
|
|
680
|
+
|
|
821
681
|
export interface StrokesStoreValue {
|
|
822
682
|
strokes: Stroke[];
|
|
823
683
|
setStrokes: (next: Stroke[]) => void;
|
|
@@ -854,9 +714,15 @@ export interface StrokesStoreValue {
|
|
|
854
714
|
previewStroke: (id: string, patch: Partial<Stroke>) => void;
|
|
855
715
|
/** Wave H — single undo record from a preview gesture's start snapshot. */
|
|
856
716
|
commitGesture: (before: readonly Stroke[], label?: string) => void;
|
|
717
|
+
/** The host a dragged arrow endpoint would bind to (halo), or null. */
|
|
718
|
+
setBindHint?: (hostId: string | null) => void;
|
|
719
|
+
/** Live size label + dimension-match halos while resizing, or null. */
|
|
720
|
+
setResizeInfo?: (
|
|
721
|
+
info: { box: { x: number; y: number; w: number; h: number }; matchIds: string[] } | null
|
|
722
|
+
) => void;
|
|
857
723
|
}
|
|
858
724
|
|
|
859
|
-
const StrokesStoreContext = createContext<StrokesStoreValue | null>(null);
|
|
725
|
+
export const StrokesStoreContext = createContext<StrokesStoreValue | null>(null);
|
|
860
726
|
|
|
861
727
|
export function useStrokesStore(): StrokesStoreValue | null {
|
|
862
728
|
return useContext(StrokesStoreContext);
|
|
@@ -875,6 +741,9 @@ const CHROME_SELECTOR =
|
|
|
875
741
|
|
|
876
742
|
const HINTS_KEY = 'maude-annot-hints-v1';
|
|
877
743
|
|
|
744
|
+
/** Below this zoom a pointer entry into text editing first zooms to the element (AD7). */
|
|
745
|
+
const EDIT_ZOOM_FLOOR = 0.5;
|
|
746
|
+
|
|
878
747
|
function showOnceHint(key: string, msg: string): void {
|
|
879
748
|
if (typeof window === 'undefined') return;
|
|
880
749
|
try {
|
|
@@ -894,7 +763,6 @@ function showOnceHint(key: string, msg: string): void {
|
|
|
894
763
|
// Annotations visibility now lives in use-annotations-visibility.tsx so the
|
|
895
764
|
// ToolPalette (a sibling under CanvasRouter, not a descendant of this layer)
|
|
896
765
|
// can read the same state. Re-exported here for back-compat.
|
|
897
|
-
export { useAnnotationsVisibility } from './use-annotations-visibility.tsx';
|
|
898
766
|
|
|
899
767
|
/**
|
|
900
768
|
* Phase 2 (whiteboard-improvements) — proportional group resize. Maps one
|
|
@@ -971,6 +839,52 @@ function scaleStrokeInGroup(
|
|
|
971
839
|
return null;
|
|
972
840
|
}
|
|
973
841
|
|
|
842
|
+
/** The copies of `ids` in a duplicate result (what to select afterwards). */
|
|
843
|
+
function cloneRoots(ids: readonly string[], res: { idMap: ReadonlyMap<string, string> }): string[] {
|
|
844
|
+
return ids.map((id) => res.idMap.get(id)).filter((x): x is string => !!x);
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
/** The element an editing target edits (a shape label edits on the shape). */
|
|
848
|
+
function editTargetId(t: EditingTarget): string | null {
|
|
849
|
+
if (!t) return null;
|
|
850
|
+
if (t.kind === 'anchored') return t.anchorId;
|
|
851
|
+
if (t.kind === 'sticky') return t.sticky.id;
|
|
852
|
+
if (t.kind === 'standalone') return t.text.id;
|
|
853
|
+
if (t.kind === 'section') return t.section.id;
|
|
854
|
+
return null;
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
function editTargetStroke(t: Exclude<EditingTarget, null | { kind: 'pending' }>): Stroke {
|
|
858
|
+
if (t.kind === 'anchored') return t.host;
|
|
859
|
+
if (t.kind === 'sticky') return t.sticky;
|
|
860
|
+
if (t.kind === 'standalone') return t.text;
|
|
861
|
+
return t.section;
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
/** The text a slot keeps for what was typed (the commit writers' own rules). */
|
|
865
|
+
function storedSlotText(kind: string, text: string): string {
|
|
866
|
+
if (kind === 'sticky') return text;
|
|
867
|
+
if (kind === 'section') return text.trim().replace(/\s*\n+\s*/g, ' ') || 'Section';
|
|
868
|
+
return text.trim();
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
/** `strokes` with the session's slot back at its base text — the undo target of a text commit. */
|
|
872
|
+
function withSlotText(strokes: readonly Stroke[], s: TextSession): Stroke[] {
|
|
873
|
+
if (s.kind === 'label-record') {
|
|
874
|
+
const label = strokes.find((x) => x.tool === 'text' && x.anchorId === s.id);
|
|
875
|
+
if (!s.base) return strokes.filter((x) => x !== label);
|
|
876
|
+
return label
|
|
877
|
+
? strokes.map((x) => (x === label ? { ...label, text: s.base } : x))
|
|
878
|
+
: [...strokes];
|
|
879
|
+
}
|
|
880
|
+
return strokes.map((x) => {
|
|
881
|
+
if (x.id !== s.id) return x;
|
|
882
|
+
if (x.tool === 'section') return { ...x, label: s.base };
|
|
883
|
+
if (x.tool === 'sticky' || x.tool === 'text') return { ...x, text: s.base };
|
|
884
|
+
return x;
|
|
885
|
+
});
|
|
886
|
+
}
|
|
887
|
+
|
|
974
888
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
975
889
|
// Component
|
|
976
890
|
|
|
@@ -981,11 +895,69 @@ export function AnnotationsLayer() {
|
|
|
981
895
|
const theme = useCanvasChromeTheme();
|
|
982
896
|
const controller = useViewportControllerContext();
|
|
983
897
|
const vp = controller?.viewport ?? null;
|
|
898
|
+
const controllerRef = useRef(controller);
|
|
899
|
+
controllerRef.current = controller;
|
|
984
900
|
const worldRef = useWorldRefContext();
|
|
985
901
|
const annotSel = useAnnotationSelectionOptional();
|
|
986
902
|
const elementSel = useSelectionSetOptional();
|
|
903
|
+
// Task 21 — ONE pointer pipeline for every annotation gesture: stages tried
|
|
904
|
+
// in explicit priority order, one owner per gesture (pointer-pipeline.ts).
|
|
905
|
+
const pipeline = useMemo(() => new PointerPipeline(), []);
|
|
906
|
+
const collab = useCollab();
|
|
907
|
+
const collabRef = useRef(collab);
|
|
908
|
+
collabRef.current = collab;
|
|
909
|
+
/**
|
|
910
|
+
* Task 22 — the gesture in flight, for peers: awareness only (throttled,
|
|
911
|
+
* never storage). The op still commits once at gesture end; null clears.
|
|
912
|
+
*/
|
|
913
|
+
const publishGesture = useCallback((g: AnnotationGesture | null) => {
|
|
914
|
+
collabRef.current?.publishAwareness({ annotationGesture: g });
|
|
915
|
+
}, []);
|
|
916
|
+
const [gestureState, setGestureState] = useState('idle');
|
|
917
|
+
useEffect(() => {
|
|
918
|
+
if (typeof document === 'undefined') return;
|
|
919
|
+
pipeline.onState = setGestureState;
|
|
920
|
+
return pipeline.attach(document);
|
|
921
|
+
}, [pipeline]);
|
|
922
|
+
// Chrome owns its own presses (toolbars, handles, editors, menus, the
|
|
923
|
+
// palette, comments, an inline media player): one decision, here, instead of
|
|
924
|
+
// a skip-list copied into every handler (the ce641b18 class of bug).
|
|
925
|
+
useEffect(
|
|
926
|
+
() =>
|
|
927
|
+
pipeline.add({
|
|
928
|
+
priority: 5,
|
|
929
|
+
name: 'chrome',
|
|
930
|
+
down: (e) => {
|
|
931
|
+
const t = e.target as Element | null;
|
|
932
|
+
return isMediaPlayerTarget(e) || t?.closest?.(CHROME_SELECTOR) ? 'pass' : undefined;
|
|
933
|
+
},
|
|
934
|
+
}),
|
|
935
|
+
[pipeline]
|
|
936
|
+
);
|
|
987
937
|
|
|
988
|
-
|
|
938
|
+
// DDR-242 AD8 (Task 26) — the board is the element store: what the project
|
|
939
|
+
// delivered plus this tab's optimistic op batches. The editing UI reads it
|
|
940
|
+
// as world-space strokes, projected per element (unchanged elements keep
|
|
941
|
+
// their identity, so only what changed re-renders).
|
|
942
|
+
const boardRef = useRef<BoardStore | null>(null);
|
|
943
|
+
if (!boardRef.current) boardRef.current = new BoardStore();
|
|
944
|
+
const board = boardRef.current;
|
|
945
|
+
const boardVersion = useSyncExternalStore(board.subscribe, board.getVersion, board.getVersion);
|
|
946
|
+
const strokeCacheRef = useRef<StrokeCache>(new WeakMap());
|
|
947
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: re-projected per board version
|
|
948
|
+
const boardStrokes = useMemo(
|
|
949
|
+
() => projectStrokes(board.scene(), strokeCacheRef.current),
|
|
950
|
+
[board, boardVersion]
|
|
951
|
+
);
|
|
952
|
+
// Images still uploading (a `blob:` href) — shown, never on the board.
|
|
953
|
+
const [uploading, setUploading] = useState<Stroke[]>([]);
|
|
954
|
+
// A gesture in flight (drag, resize): the strokes as the gesture shows them.
|
|
955
|
+
// Never stored; the gesture commits once, as one op batch.
|
|
956
|
+
const [preview, setPreview] = useState<Stroke[] | null>(null);
|
|
957
|
+
const strokes = useMemo(
|
|
958
|
+
() => preview ?? (uploading.length ? [...boardStrokes, ...uploading] : boardStrokes),
|
|
959
|
+
[preview, uploading, boardStrokes]
|
|
960
|
+
);
|
|
989
961
|
const [drawing, setDrawing] = useState<Stroke | null>(null);
|
|
990
962
|
// Theme-aware live default ink (items 3/5/6). Initialized from the current
|
|
991
963
|
// theme; tracked-untouched until the user picks a swatch, after which it
|
|
@@ -1035,6 +1007,12 @@ export function AnnotationsLayer() {
|
|
|
1035
1007
|
// id of the host a dragged arrow endpoint would bind to (accent halo).
|
|
1036
1008
|
const [snapGuides, setSnapGuides] = useState<SnapGuide[] | null>(null);
|
|
1037
1009
|
const [bindHintId, setBindHintId] = useState<string | null>(null);
|
|
1010
|
+
// FigJam v3 — live size label + dimension-match halos while resizing (the
|
|
1011
|
+
// resize overlay sets it through the strokes store; the SVG layer paints).
|
|
1012
|
+
const [resizeInfo, setResizeInfo] = useState<{
|
|
1013
|
+
box: { x: number; y: number; w: number; h: number } | null;
|
|
1014
|
+
matchIds: string[];
|
|
1015
|
+
} | null>(null);
|
|
1038
1016
|
// Cmd/Ctrl held — suppresses binding at arrow draw-end (FigJam: ⌘ keeps the
|
|
1039
1017
|
// endpoint free). Tracked here because endStroke (pointerup) carries no
|
|
1040
1018
|
// modifier state of its own.
|
|
@@ -1079,9 +1057,32 @@ export function AnnotationsLayer() {
|
|
|
1079
1057
|
// text-tool click-through); keyboard entries (Enter, fresh-create,
|
|
1080
1058
|
// ⌘Enter chain) leave it null → select-all, the rename convention.
|
|
1081
1059
|
const [editCaretPoint, setEditCaretPoint] = useState<{ x: number; y: number } | null>(null);
|
|
1060
|
+
/**
|
|
1061
|
+
* Open an element's text editor from a pointer (double-click, Text-tool
|
|
1062
|
+
* click). Below the editing zoom floor the view first zooms to the element
|
|
1063
|
+
* (AD7), so the caret and the text are readable; the entry click then no
|
|
1064
|
+
* longer points at the text, so the editor opens with the text selected.
|
|
1065
|
+
*/
|
|
1066
|
+
const openEditorAt = useCallback((id: string, clientX: number, clientY: number) => {
|
|
1067
|
+
const zoom = getLiveViewport()?.zoom ?? 1;
|
|
1068
|
+
const s = strokesRef.current.find((x) => x.id === id);
|
|
1069
|
+
const bb = s ? strokeBBox(s) : null;
|
|
1070
|
+
if (zoom < EDIT_ZOOM_FLOOR && bb && controllerRef.current) {
|
|
1071
|
+
const pad = 48;
|
|
1072
|
+
controllerRef.current.jumpTo({
|
|
1073
|
+
x: bb.x - pad,
|
|
1074
|
+
y: bb.y - pad,
|
|
1075
|
+
w: bb.w + pad * 2,
|
|
1076
|
+
h: bb.h + pad * 2,
|
|
1077
|
+
} as Parameters<NonNullable<typeof controller>['jumpTo']>[0]);
|
|
1078
|
+
setEditCaretPoint(null);
|
|
1079
|
+
} else {
|
|
1080
|
+
setEditCaretPoint({ x: clientX, y: clientY });
|
|
1081
|
+
}
|
|
1082
|
+
setEditingId(id);
|
|
1083
|
+
}, []);
|
|
1082
1084
|
|
|
1083
1085
|
const fileRef = useRef<string | undefined>(undefined);
|
|
1084
|
-
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
1085
1086
|
const drawingRef = useRef<Stroke | null>(null);
|
|
1086
1087
|
drawingRef.current = drawing;
|
|
1087
1088
|
// Phase 24 — pointer-down anchor + last cursor (world coords) for the active
|
|
@@ -1096,6 +1097,8 @@ export function AnnotationsLayer() {
|
|
|
1096
1097
|
*/
|
|
1097
1098
|
const strokesRef = useRef<Stroke[]>(strokes);
|
|
1098
1099
|
strokesRef.current = strokes;
|
|
1100
|
+
const uploadingRef = useRef(uploading);
|
|
1101
|
+
uploadingRef.current = uploading;
|
|
1099
1102
|
// Phase 23 batch-drop fix — see media-commit-chain.ts. Every async media
|
|
1100
1103
|
// completion (image upload swap, video/audio upload commit) that can be
|
|
1101
1104
|
// triggered concurrently (N Finder files dropped at once) enqueues onto
|
|
@@ -1158,29 +1161,32 @@ export function AnnotationsLayer() {
|
|
|
1158
1161
|
if (!ghostCapable || !visible) setGhost(null);
|
|
1159
1162
|
}, [ghostCapable, visible]);
|
|
1160
1163
|
|
|
1161
|
-
//
|
|
1162
|
-
//
|
|
1163
|
-
|
|
1164
|
+
// Op batches applied here but not yet seen back from the replica, in send
|
|
1165
|
+
// order. A snapshot from the replica is rebased onto them, so a
|
|
1166
|
+
// collaborator's change never hides an edit of ours still in flight.
|
|
1167
|
+
const pendingRef = useRef<Array<{ actionId: string; ops: readonly AnnotationOp[] }>>([]);
|
|
1164
1168
|
const annotationsChangedRef = useRef(false);
|
|
1165
|
-
//
|
|
1166
|
-
//
|
|
1167
|
-
|
|
1169
|
+
// Task 19 — the open text edit session (text-session.ts): its base (undo
|
|
1170
|
+
// target) and the text it last sent (every text op expects it). `commit…`
|
|
1171
|
+
// hands the session to commitStrokes for exactly one commit.
|
|
1172
|
+
const textSessionRef = useRef<TextSession | null>(null);
|
|
1173
|
+
const commitSessionRef = useRef<TextSession | null>(null);
|
|
1174
|
+
const sessionCommittedRef = useRef(false);
|
|
1175
|
+
// The edited element as the board last had it — what "keep my text" restores.
|
|
1176
|
+
const sessionElRef = useRef<AnnotationElement | null>(null);
|
|
1177
|
+
const [remoteEdited, setRemoteEdited] = useState(false);
|
|
1168
1178
|
useEffect(() => {
|
|
1169
1179
|
const file = deriveFile();
|
|
1170
1180
|
fileRef.current = file;
|
|
1171
1181
|
if (!file) return;
|
|
1172
1182
|
let cancelled = false;
|
|
1173
1183
|
void fetch(`/_api/annotations?file=${encodeURIComponent(file)}`, {
|
|
1174
|
-
headers: { Accept: '
|
|
1184
|
+
headers: { Accept: 'application/json' },
|
|
1175
1185
|
})
|
|
1176
1186
|
.then((r) => (r.ok ? r.text() : ''))
|
|
1177
1187
|
.then((text) => {
|
|
1178
1188
|
if (cancelled || annotationsChangedRef.current) return;
|
|
1179
|
-
|
|
1180
|
-
receivedRef.current = receivedAnnotations(text, loaded);
|
|
1181
|
-
if (loaded.length) {
|
|
1182
|
-
setStrokesState(loaded);
|
|
1183
|
-
}
|
|
1189
|
+
board.setAll(parseBoard(text).elements);
|
|
1184
1190
|
})
|
|
1185
1191
|
.catch(() => {
|
|
1186
1192
|
/* network blip — start with an empty annotation set */
|
|
@@ -1188,97 +1194,91 @@ export function AnnotationsLayer() {
|
|
|
1188
1194
|
return () => {
|
|
1189
1195
|
cancelled = true;
|
|
1190
1196
|
};
|
|
1191
|
-
}, []);
|
|
1197
|
+
}, [board]);
|
|
1192
1198
|
|
|
1193
|
-
const collab = useCollab();
|
|
1194
1199
|
useEffect(() => {
|
|
1195
1200
|
if (!collab) return;
|
|
1196
|
-
return
|
|
1201
|
+
return observeReplica(collab.doc, (elements, _changed, actionId) => {
|
|
1197
1202
|
annotationsChangedRef.current = true;
|
|
1198
|
-
const
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1203
|
+
const pending = pendingRef.current;
|
|
1204
|
+
const landed = actionId ? pending.findIndex((p) => p.actionId === actionId) : -1;
|
|
1205
|
+
// Batches reach the board in send order: this one and every earlier one landed.
|
|
1206
|
+
if (landed >= 0) pending.splice(0, landed + 1);
|
|
1207
|
+
const isOwn = landed >= 0;
|
|
1208
|
+
// A collaborator changed the text under an open editor: the editor keeps
|
|
1209
|
+
// the user's text and shows a marker; the commit merges both (Task 19).
|
|
1210
|
+
const session = textSessionRef.current;
|
|
1211
|
+
if (session && !isOwn) {
|
|
1212
|
+
const el = elements.find((e) => e.id === session.id);
|
|
1213
|
+
if (el && remotelyEdited(session, el)) setRemoteEdited(true);
|
|
1214
|
+
}
|
|
1215
|
+
let state = new Map(elements.map((e) => [e.id, e]));
|
|
1216
|
+
for (const p of pending) state = applyOps(state, p.ops).state;
|
|
1217
|
+
board.setAll(state.values());
|
|
1202
1218
|
});
|
|
1203
|
-
}, [collab]);
|
|
1219
|
+
}, [collab, board]);
|
|
1204
1220
|
|
|
1205
1221
|
const undoStack = useUndoStackOptional();
|
|
1206
1222
|
const undoSinks = useUndoSinks();
|
|
1207
1223
|
const undoStackRef = useRef(undoStack);
|
|
1208
1224
|
undoStackRef.current = undoStack;
|
|
1209
1225
|
|
|
1210
|
-
// feature-bulk-media-insert follow-up — dedicated
|
|
1211
|
-
//
|
|
1212
|
-
//
|
|
1213
|
-
//
|
|
1214
|
-
//
|
|
1215
|
-
// arrived) — some other async hop between "task starts" and "fetch
|
|
1216
|
-
// fires" lets them interleave. Since every PUT body here is a strictly
|
|
1217
|
-
// growing superset (the media-commit-chain guarantees each commit is at
|
|
1218
|
-
// least as complete as the last), overlap is dangerous: if an EARLIER
|
|
1219
|
-
// (smaller) request's write lands on disk AFTER a LATER (larger) one's,
|
|
1220
|
-
// the smaller snapshot wins and silently erases the larger one's extra
|
|
1221
|
-
// strokes. A dedicated chain — mirroring `editApplyChainRef` in
|
|
1222
|
-
// client/app.jsx — makes the fetch DISPATCH itself wait for the
|
|
1223
|
-
// previous one's response, independent of whatever the undo-stack does.
|
|
1226
|
+
// feature-bulk-media-insert follow-up — dedicated dispatch queue. Rapid-fire
|
|
1227
|
+
// commits (a batch media drop) must reach the server in order: ops are
|
|
1228
|
+
// applied in arrival order, and an undo batch sent before the edit it undoes
|
|
1229
|
+
// would be rejected as `gone`. A dedicated chain makes each POST wait for
|
|
1230
|
+
// the previous one's response.
|
|
1224
1231
|
const putChainRef = useRef<Promise<void>>(Promise.resolve());
|
|
1225
1232
|
|
|
1226
1233
|
/**
|
|
1227
|
-
*
|
|
1228
|
-
*
|
|
1229
|
-
*
|
|
1230
|
-
* replay route through here, so the iframe's `strokes` state always
|
|
1231
|
-
* tracks the server. (Without the setStrokesState here, Cmd+Z would
|
|
1232
|
-
* silently PUT the prior SVG but the canvas would keep painting the
|
|
1233
|
-
* post-edit strokes until the user reloaded.)
|
|
1234
|
-
*
|
|
1235
|
-
* The 200 ms scheduled-save debounce (legacy path) is cleared the moment
|
|
1236
|
-
* we push a command, so the server only sees one PUT per edit instead
|
|
1237
|
-
* of two-step racing.
|
|
1234
|
+
* Send one element-op batch to the board (DDR-242 §4), in order after every
|
|
1235
|
+
* batch before it. `local` marks a batch already applied to the store: it
|
|
1236
|
+
* stays pending (re-applied over replica snapshots) until its echo arrives.
|
|
1238
1237
|
*/
|
|
1239
|
-
const
|
|
1240
|
-
// See reconcileCommit — a direct setStrokesState(next) here can
|
|
1241
|
-
// clobber a sibling file's concurrent optimistic insert; folding
|
|
1242
|
-
// blindly against `prev` (no baseline) can just as easily revert this
|
|
1243
|
-
// very mutation's own delete. `before` (this command's own baseline)
|
|
1244
|
-
// disambiguates the two.
|
|
1245
|
-
annotationsChangedRef.current = true;
|
|
1246
|
-
setStrokesState((prev) => reconcileCommit(prev, before, next));
|
|
1238
|
+
const sendOps = useCallback((ops: readonly AnnotationOp[], local = false): Promise<void> => {
|
|
1247
1239
|
const file = fileRef.current;
|
|
1248
|
-
if (!file) return
|
|
1249
|
-
const
|
|
1250
|
-
const
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
// Y.Map.observe) doesn't trigger a redundant setStrokesState.
|
|
1260
|
-
const writeId = crypto.randomUUID();
|
|
1261
|
-
annotationEchoRef.current.remember(writeId, svg);
|
|
1240
|
+
if (!file || !ops.length) return putChainRef.current;
|
|
1241
|
+
const actionId = crypto.randomUUID();
|
|
1242
|
+
const pending = pendingRef.current;
|
|
1243
|
+
if (local) {
|
|
1244
|
+
pending.push({ actionId, ops });
|
|
1245
|
+
if (pending.length > 64) pending.splice(0, pending.length - 64);
|
|
1246
|
+
}
|
|
1247
|
+
const settle = () => {
|
|
1248
|
+
const i = pending.findIndex((p) => p.actionId === actionId);
|
|
1249
|
+
if (i >= 0) pending.splice(i, 1);
|
|
1250
|
+
};
|
|
1262
1251
|
const dispatch = () =>
|
|
1263
|
-
fetch('/_api/annotations', {
|
|
1264
|
-
method: '
|
|
1252
|
+
fetch('/_api/annotations/ops', {
|
|
1253
|
+
method: 'POST',
|
|
1265
1254
|
headers: { 'Content-Type': 'application/json' },
|
|
1266
|
-
body: JSON.stringify({ file,
|
|
1255
|
+
body: JSON.stringify({ file, actionId, ops }),
|
|
1267
1256
|
})
|
|
1268
|
-
.then((r) => {
|
|
1257
|
+
.then(async (r) => {
|
|
1269
1258
|
// A refused save (403 read-only, 405 at a proxy door) previously
|
|
1270
|
-
// dissolved
|
|
1271
|
-
//
|
|
1272
|
-
//
|
|
1273
|
-
//
|
|
1259
|
+
// dissolved without a trace — the user kept drawing on state that
|
|
1260
|
+
// never reached disk, a peer, or a reload (the cloud canvas-writes
|
|
1261
|
+
// RCA). Optimistic local state is still the right UX; a persistence
|
|
1262
|
+
// failure being INVISIBLE is not.
|
|
1274
1263
|
if (!r.ok) {
|
|
1275
|
-
|
|
1264
|
+
settle();
|
|
1276
1265
|
console.warn(`[annotations] save refused (${r.status}) — strokes are local-only`);
|
|
1266
|
+
return undefined;
|
|
1277
1267
|
}
|
|
1268
|
+
const res = (await r.json().catch(() => null)) as {
|
|
1269
|
+
rejected?: Array<{ id?: string; reason: string }>;
|
|
1270
|
+
} | null;
|
|
1271
|
+
if (res?.rejected?.length) {
|
|
1272
|
+
console.warn('[annotations] some changes were not applied', res.rejected);
|
|
1273
|
+
}
|
|
1274
|
+
// Without a replica no echo will come; with one, the echo settles
|
|
1275
|
+
// it — unless the board had nothing to change (no echo either).
|
|
1276
|
+
if (!collabRef.current || res?.rejected?.length) settle();
|
|
1277
|
+
else setTimeout(settle, 10_000);
|
|
1278
1278
|
return undefined;
|
|
1279
1279
|
})
|
|
1280
1280
|
.catch(() => {
|
|
1281
|
-
|
|
1281
|
+
settle();
|
|
1282
1282
|
/* Pending persistence UX is handled by the project outbox work. */
|
|
1283
1283
|
});
|
|
1284
1284
|
const chained = putChainRef.current.then(dispatch, dispatch);
|
|
@@ -1286,36 +1286,95 @@ export function AnnotationsLayer() {
|
|
|
1286
1286
|
return chained;
|
|
1287
1287
|
}, []);
|
|
1288
1288
|
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1289
|
+
/** Apply a batch here (optimistic) and send it. Returns what undoes it. */
|
|
1290
|
+
const applyOpsLocal = useCallback(
|
|
1291
|
+
(ops: readonly AnnotationOp[]): AnnotationOp[] => {
|
|
1292
|
+
if (!ops.length) return [];
|
|
1293
|
+
annotationsChangedRef.current = true;
|
|
1294
|
+
const r = board.apply(ops);
|
|
1295
|
+
void sendOps(ops, true);
|
|
1296
|
+
return r.inverse;
|
|
1297
|
+
},
|
|
1298
|
+
[board, sendOps]
|
|
1299
|
+
);
|
|
1300
|
+
|
|
1301
|
+
// Undo / redo replay an op batch through THIS iframe's store (the stack is
|
|
1302
|
+
// rebuilt from records after a canvas switch — DDR-050).
|
|
1292
1303
|
useEffect(() => {
|
|
1293
|
-
undoSinks.setSink('
|
|
1294
|
-
|
|
1295
|
-
|
|
1304
|
+
undoSinks.setSink('annotationOpsFn', (ops: readonly AnnotationOp[], file?: string) => {
|
|
1305
|
+
// A record made on another canvas never applies here (security review A5).
|
|
1306
|
+
if (file !== fileRef.current) return [];
|
|
1307
|
+
setPreview(null);
|
|
1308
|
+
return applyOpsLocal(ops);
|
|
1309
|
+
});
|
|
1310
|
+
return () => undoSinks.setSink('annotationOpsFn', undefined);
|
|
1311
|
+
}, [undoSinks, applyOpsLocal]);
|
|
1296
1312
|
|
|
1297
1313
|
/**
|
|
1298
|
-
* Single entry point for every stroke mutation
|
|
1299
|
-
*
|
|
1300
|
-
*
|
|
1301
|
-
*
|
|
1302
|
-
*
|
|
1303
|
-
*
|
|
1314
|
+
* Single entry point for every stroke mutation: the change from `prev` to
|
|
1315
|
+
* `next` becomes ONE element-op batch (only what the user changed — a
|
|
1316
|
+
* peer's concurrent edit to another element or another field is never
|
|
1317
|
+
* overwritten, and concurrent typing in the same text is merged), applied
|
|
1318
|
+
* here, sent, and recorded as one undo step whose undo is the batch's
|
|
1319
|
+
* inverse (peer-safe: it reverts only fields still holding our values).
|
|
1304
1320
|
*/
|
|
1305
1321
|
const commitStrokes = useCallback(
|
|
1306
1322
|
(prev: readonly Stroke[], next: readonly Stroke[], label?: string) => {
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1323
|
+
setPreview(null);
|
|
1324
|
+
// An upload still in flight is not on the board: it leaves the view
|
|
1325
|
+
// when this commit removes it or when its uploaded form lands.
|
|
1326
|
+
if (uploadingRef.current.length) {
|
|
1327
|
+
const before = new Set(prev.map((s) => s.id));
|
|
1328
|
+
const after = new Map(next.map((s) => [s.id, s]));
|
|
1329
|
+
setUploading((list) =>
|
|
1330
|
+
list.filter((u) => {
|
|
1331
|
+
const n = after.get(u.id);
|
|
1332
|
+
if (n) return isEphemeralHref(n);
|
|
1333
|
+
return !before.has(u.id);
|
|
1334
|
+
})
|
|
1335
|
+
);
|
|
1310
1336
|
}
|
|
1311
|
-
const
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1337
|
+
const persist = (list: readonly Stroke[]) =>
|
|
1338
|
+
list.some(isEphemeralHref) ? list.filter((s) => !isEphemeralHref(s)) : list;
|
|
1339
|
+
const committed = board.committed;
|
|
1340
|
+
const prevMap = strokesToElementMap(persist(prev), committed);
|
|
1341
|
+
const nextMap = strokesToElementMap(persist(next), committed);
|
|
1342
|
+
const ops = diffToOps(prevMap, nextMap);
|
|
1343
|
+
// A text edit session's commit (Task 19): the board merges against what
|
|
1344
|
+
// the session last SENT (its drafts), while undo goes back to the text
|
|
1345
|
+
// before the edit. Drafts never reach undo.
|
|
1346
|
+
const session = commitSessionRef.current;
|
|
1347
|
+
if (session) {
|
|
1348
|
+
commitSessionRef.current = null;
|
|
1349
|
+
sessionCommittedRef.current = true;
|
|
1350
|
+
applyOpsLocal(aimCommitOps(ops, session));
|
|
1351
|
+
const baseMap = strokesToElementMap(persist(withSlotText(prev, session)), committed);
|
|
1352
|
+
const redo = diffToOps(baseMap, nextMap);
|
|
1353
|
+
if (!redo.length) return;
|
|
1354
|
+
// Undo MERGES back (not a strict inverse): a collaborator's typing
|
|
1355
|
+
// that landed in the same text survives the undo of ours.
|
|
1356
|
+
undoStackRef.current.record(
|
|
1357
|
+
buildAnnotationOpsRecord({
|
|
1358
|
+
ops: redo,
|
|
1359
|
+
inverse: diffToOps(nextMap, baseMap),
|
|
1360
|
+
label: label ?? 'edit text',
|
|
1361
|
+
file: fileRef.current,
|
|
1362
|
+
})
|
|
1363
|
+
);
|
|
1364
|
+
return;
|
|
1365
|
+
}
|
|
1366
|
+
if (!ops.length) return;
|
|
1367
|
+
const inverse = applyOpsLocal(ops);
|
|
1368
|
+
undoStackRef.current.record(
|
|
1369
|
+
buildAnnotationOpsRecord({
|
|
1370
|
+
ops,
|
|
1371
|
+
inverse,
|
|
1372
|
+
label: label ?? 'edit annotations',
|
|
1373
|
+
file: fileRef.current,
|
|
1374
|
+
})
|
|
1375
|
+
);
|
|
1317
1376
|
},
|
|
1318
|
-
[]
|
|
1377
|
+
[board, applyOpsLocal]
|
|
1319
1378
|
);
|
|
1320
1379
|
|
|
1321
1380
|
const setStrokes = useCallback(
|
|
@@ -1336,8 +1395,13 @@ export function AnnotationsLayer() {
|
|
|
1336
1395
|
);
|
|
1337
1396
|
commitStrokes(prev, next);
|
|
1338
1397
|
};
|
|
1398
|
+
// Task 20 — an operation on a section acts on its subtree (containment.ts).
|
|
1399
|
+
const containmentNow = () =>
|
|
1400
|
+
new Containment(strokesToElementMap(strokesRef.current, board.committed).values());
|
|
1401
|
+
const withSubtree = (ids: readonly string[]) =>
|
|
1402
|
+
expandForOp(ids, (x) => expandIdsToGroups(x, strokesRef.current), containmentNow());
|
|
1339
1403
|
const deleteStrokes = (ids: string[]): void => {
|
|
1340
|
-
const set = new Set(ids);
|
|
1404
|
+
const set = new Set(withSubtree(ids));
|
|
1341
1405
|
const prev = strokesRef.current;
|
|
1342
1406
|
const filtered = prev.filter(
|
|
1343
1407
|
(s) => !set.has(s.id) && !(s.tool === 'text' && s.anchorId != null && set.has(s.anchorId))
|
|
@@ -1354,7 +1418,7 @@ export function AnnotationsLayer() {
|
|
|
1354
1418
|
commitStrokes(prev, recomputeBoundArrows(normalizeGroups(filtered)));
|
|
1355
1419
|
};
|
|
1356
1420
|
const translateStrokes = (ids: string[], dx: number, dy: number): void => {
|
|
1357
|
-
const set = new Set(ids);
|
|
1421
|
+
const set = new Set(withSubtree(ids));
|
|
1358
1422
|
const prev = strokesRef.current;
|
|
1359
1423
|
const next = recomputeBoundArrows(
|
|
1360
1424
|
prev.map((s) => (set.has(s.id) ? translateOne(s, dx, dy) : s))
|
|
@@ -1396,11 +1460,12 @@ export function AnnotationsLayer() {
|
|
|
1396
1460
|
};
|
|
1397
1461
|
const duplicateSelection = (ids: readonly string[], dx: number, dy: number): string[] => {
|
|
1398
1462
|
const prev = strokesRef.current;
|
|
1399
|
-
const res = duplicateStrokes(prev, ids, dx, dy);
|
|
1463
|
+
const res = duplicateStrokes(prev, withSubtree(ids), dx, dy);
|
|
1400
1464
|
if (res.strokes.length === prev.length) return [];
|
|
1401
1465
|
const added = res.strokes.length - prev.length;
|
|
1402
1466
|
commitStrokes(prev, res.strokes, `duplicate ${added} stroke${added === 1 ? '' : 's'}`);
|
|
1403
|
-
|
|
1467
|
+
// Select the copies of what was selected (their contents ride along).
|
|
1468
|
+
return cloneRoots(ids, res);
|
|
1404
1469
|
};
|
|
1405
1470
|
const reorderSelection = (ids: readonly string[], op: ZOrderOp): void => {
|
|
1406
1471
|
const prev = strokesRef.current;
|
|
@@ -1414,13 +1479,19 @@ export function AnnotationsLayer() {
|
|
|
1414
1479
|
};
|
|
1415
1480
|
const alignSelection = (ids: readonly string[], edge: AlignEdge): void => {
|
|
1416
1481
|
const prev = strokesRef.current;
|
|
1417
|
-
const
|
|
1482
|
+
const c = containmentNow();
|
|
1483
|
+
const next = recomputeBoundArrows(
|
|
1484
|
+
alignStrokes(prev, c.roots(ids), edge, (id) => c.contentsOf(id))
|
|
1485
|
+
);
|
|
1418
1486
|
if (strokesShallowEqual(prev, next)) return;
|
|
1419
1487
|
commitStrokes(prev, next, `align ${edge}`);
|
|
1420
1488
|
};
|
|
1421
1489
|
const distributeSelection = (ids: readonly string[], axis: DistributeAxis): void => {
|
|
1422
1490
|
const prev = strokesRef.current;
|
|
1423
|
-
const
|
|
1491
|
+
const c = containmentNow();
|
|
1492
|
+
const next = recomputeBoundArrows(
|
|
1493
|
+
distributeStrokes(prev, c.roots(ids), axis, (id) => c.contentsOf(id))
|
|
1494
|
+
);
|
|
1424
1495
|
if (strokesShallowEqual(prev, next)) return;
|
|
1425
1496
|
commitStrokes(prev, next, 'distribute');
|
|
1426
1497
|
};
|
|
@@ -1429,19 +1500,27 @@ export function AnnotationsLayer() {
|
|
|
1429
1500
|
// mirroring the move-drag's onMove. `commitGesture` closes it as ONE
|
|
1430
1501
|
// record (no-op when the gesture ended where it started).
|
|
1431
1502
|
const previewStroke = (id: string, patch: Partial<Stroke>): void => {
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
strokesRef.current.map((s) => (s.id === id ? ({ ...s, ...patch } as Stroke) : s))
|
|
1435
|
-
)
|
|
1503
|
+
const next = recomputeBoundArrows(
|
|
1504
|
+
strokesRef.current.map((s) => (s.id === id ? ({ ...s, ...patch } as Stroke) : s))
|
|
1436
1505
|
);
|
|
1506
|
+
setPreview(next);
|
|
1507
|
+
const moved = next.find((s) => s.id === id);
|
|
1508
|
+
const box = moved ? strokeBBox(moved) : null;
|
|
1509
|
+
if (box) publishGesture({ kind: 'resize', ids: [id], box });
|
|
1437
1510
|
};
|
|
1438
1511
|
const commitGesture = (before: readonly Stroke[], label?: string): void => {
|
|
1512
|
+
publishGesture(null);
|
|
1439
1513
|
const cur = strokesRef.current;
|
|
1440
|
-
if (strokesShallowEqual(before, cur))
|
|
1514
|
+
if (strokesShallowEqual(before, cur)) {
|
|
1515
|
+
setPreview(null);
|
|
1516
|
+
return;
|
|
1517
|
+
}
|
|
1441
1518
|
commitStrokes(before, cur, label);
|
|
1442
1519
|
};
|
|
1443
1520
|
return {
|
|
1444
1521
|
strokes,
|
|
1522
|
+
setBindHint: setBindHintId,
|
|
1523
|
+
setResizeInfo,
|
|
1445
1524
|
setStrokes,
|
|
1446
1525
|
updateStroke,
|
|
1447
1526
|
deleteStrokes,
|
|
@@ -1709,7 +1788,7 @@ export function AnnotationsLayer() {
|
|
|
1709
1788
|
// drop fires this for every file independently, so two optimistic
|
|
1710
1789
|
// inserts landing before a render commit must compose against each
|
|
1711
1790
|
// other rather than each overwriting the other's array snapshot.
|
|
1712
|
-
|
|
1791
|
+
setUploading((prev) => [...prev, optimistic]);
|
|
1713
1792
|
void uploadAsset(file).then((res) => {
|
|
1714
1793
|
if ('path' in res) {
|
|
1715
1794
|
// Batch-drop fix (see media-commit-chain.ts) — N concurrent
|
|
@@ -1732,7 +1811,7 @@ export function AnnotationsLayer() {
|
|
|
1732
1811
|
// never committed), so a plain functional updater (not the
|
|
1733
1812
|
// chain) is enough: it still composes correctly against any
|
|
1734
1813
|
// chain-driven setStrokesState queued in the same batch.
|
|
1735
|
-
|
|
1814
|
+
setUploading((prev) => prev.filter((s) => s.id !== id));
|
|
1736
1815
|
deletedStrokeIdsRef.current.delete(id); // never reached the chain — nothing to consume there
|
|
1737
1816
|
showCanvasToast(`Image upload failed — ${res.error}`, 'error');
|
|
1738
1817
|
}
|
|
@@ -1997,8 +2076,7 @@ export function AnnotationsLayer() {
|
|
|
1997
2076
|
// which owns the DOM/contentEditable side, via maude:enter-text-edit).
|
|
1998
2077
|
const strokeId = findTextStrokeAt(wx, wy, strokesRef.current, vpRef.current?.zoom || 1);
|
|
1999
2078
|
if (strokeId) {
|
|
2000
|
-
|
|
2001
|
-
setEditingId(strokeId);
|
|
2079
|
+
openEditorAt(strokeId, e.clientX, e.clientY);
|
|
2002
2080
|
if (annotSel) annotSel.replace(strokeId);
|
|
2003
2081
|
// DDR-223 — back to the MODE's resting tool (move in edit, browse in
|
|
2004
2082
|
// preview): editing a text annotation shouldn't exit preview.
|
|
@@ -2185,8 +2263,22 @@ export function AnnotationsLayer() {
|
|
|
2185
2263
|
const committed = final;
|
|
2186
2264
|
const prev = strokesRef.current;
|
|
2187
2265
|
// FigJam — sections are CONTAINERS: they slot in at the BACK of the
|
|
2188
|
-
// z-order so content placed on them keeps rendering above
|
|
2189
|
-
|
|
2266
|
+
// z-order so content placed on them keeps rendering above — and a
|
|
2267
|
+
// section drawn INSIDE another goes right above that one (Task 20), so
|
|
2268
|
+
// it renders in front of its outer section and adopts what sits on it.
|
|
2269
|
+
let next: Stroke[];
|
|
2270
|
+
if (committed.tool === 'section') {
|
|
2271
|
+
const bb = strokeBBox(committed);
|
|
2272
|
+
const outer = bb
|
|
2273
|
+
? new Containment(strokesToElementMap(prev, board.committed).values()).containerAt(
|
|
2274
|
+
bb.x + bb.w / 2,
|
|
2275
|
+
bb.y + bb.h / 2
|
|
2276
|
+
)
|
|
2277
|
+
: null;
|
|
2278
|
+
next = insertSection(prev, committed, outer);
|
|
2279
|
+
} else {
|
|
2280
|
+
next = [...prev, committed];
|
|
2281
|
+
}
|
|
2190
2282
|
commitStrokes(prev, next, `draw ${committed.tool}`);
|
|
2191
2283
|
// T18 — auto-select the freshly drawn shape so the user can immediately
|
|
2192
2284
|
// see + adjust it. annotSel is optional (some test harnesses mount
|
|
@@ -2274,6 +2366,29 @@ export function AnnotationsLayer() {
|
|
|
2274
2366
|
() => (drawing ? [...strokes, drawing] : strokes),
|
|
2275
2367
|
[strokes, drawing]
|
|
2276
2368
|
);
|
|
2369
|
+
// Task 22 — what is being drawn, live for peers (pen ink tail, or the box).
|
|
2370
|
+
const drawPublishedRef = useRef(false);
|
|
2371
|
+
useEffect(() => {
|
|
2372
|
+
if (!drawing) {
|
|
2373
|
+
if (drawPublishedRef.current) publishGesture(null);
|
|
2374
|
+
drawPublishedRef.current = false;
|
|
2375
|
+
return;
|
|
2376
|
+
}
|
|
2377
|
+
drawPublishedRef.current = true;
|
|
2378
|
+
if (drawing.tool === 'pen') {
|
|
2379
|
+
publishGesture({ kind: 'draw', ids: [], points: drawing.points.slice(-256).flat() });
|
|
2380
|
+
return;
|
|
2381
|
+
}
|
|
2382
|
+
const box = strokeBBox(drawing);
|
|
2383
|
+
if (box) publishGesture({ kind: 'draw', ids: [], box });
|
|
2384
|
+
}, [drawing, publishGesture]);
|
|
2385
|
+
// A newer peer's element types: v1 strokes can't hold them, the board still
|
|
2386
|
+
// does — they are drawn as placeholders (DDR-242: never dropped).
|
|
2387
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: re-read per board version
|
|
2388
|
+
const unknownElements = useMemo(
|
|
2389
|
+
() => [...board.elements.values()].filter((e) => !defOf(e.type)),
|
|
2390
|
+
[board, boardVersion]
|
|
2391
|
+
);
|
|
2277
2392
|
|
|
2278
2393
|
// Phase 24 — the ghost descriptor handed to the SVG layer. Suppressed while a
|
|
2279
2394
|
// draw is in progress (the real preview takes over) so the two never overlap.
|
|
@@ -2292,6 +2407,22 @@ export function AnnotationsLayer() {
|
|
|
2292
2407
|
return map;
|
|
2293
2408
|
}, [strokes]);
|
|
2294
2409
|
|
|
2410
|
+
// Task 19 — an element that vanished (a collaborator deleted it, an undo
|
|
2411
|
+
// removed it) leaves the selection, unless its editor is still open. Only
|
|
2412
|
+
// ids that WERE on the board go: a just-created element selected before its
|
|
2413
|
+
// stroke lands must stay selected.
|
|
2414
|
+
const prevStrokeIdsRef = useRef<Set<string>>(new Set());
|
|
2415
|
+
useEffect(() => {
|
|
2416
|
+
const now = new Set(strokes.map((s) => s.id));
|
|
2417
|
+
const before = prevStrokeIdsRef.current;
|
|
2418
|
+
prevStrokeIdsRef.current = now;
|
|
2419
|
+
if (!annotSel || annotSel.selectedIds.length === 0) return;
|
|
2420
|
+
const gone = annotSel.selectedIds.filter(
|
|
2421
|
+
(id) => before.has(id) && !now.has(id) && id !== editingIdRef.current
|
|
2422
|
+
);
|
|
2423
|
+
if (gone.length) annotSel.remove(gone);
|
|
2424
|
+
}, [strokes, annotSel]);
|
|
2425
|
+
|
|
2295
2426
|
const strokesById = useMemo(() => {
|
|
2296
2427
|
const map = new Map<string, Stroke>();
|
|
2297
2428
|
for (const s of strokes) map.set(s.id, s);
|
|
@@ -2379,12 +2510,10 @@ export function AnnotationsLayer() {
|
|
|
2379
2510
|
return null;
|
|
2380
2511
|
};
|
|
2381
2512
|
|
|
2382
|
-
const onDown = (e: PointerEvent) => {
|
|
2383
|
-
if (isMediaPlayerTarget(e)) return; // mediaref inline player owns this event
|
|
2513
|
+
const onDown = (e: PointerEvent): Claim => {
|
|
2384
2514
|
if (e.button !== 0) return;
|
|
2385
2515
|
if (e.metaKey || e.ctrlKey) return; // escape hatch into element-selection
|
|
2386
2516
|
const target = e.target as Element | null;
|
|
2387
|
-
if (target?.closest?.(CHROME_SELECTOR)) return; // chrome owns its clicks
|
|
2388
2517
|
const strokeId = findStrokeId(target);
|
|
2389
2518
|
const [wx, wy] = screenToWorld(e.clientX, e.clientY);
|
|
2390
2519
|
const startClientX = e.clientX;
|
|
@@ -2414,6 +2543,14 @@ export function AnnotationsLayer() {
|
|
|
2414
2543
|
// handler); an already-selected stroke keeps the current selection, so
|
|
2415
2544
|
// a deep-selected member drags alone without re-expanding.
|
|
2416
2545
|
const members = expandIdsToGroups([strokeId], strokesStoreRef.current.strokes);
|
|
2546
|
+
if (e.shiftKey && members.every((m) => annotSel.contains(m))) {
|
|
2547
|
+
// Shift-click on something already selected takes it OUT of the
|
|
2548
|
+
// selection (FigJam / Figma toggle) and starts no drag.
|
|
2549
|
+
e.preventDefault();
|
|
2550
|
+
e.stopImmediatePropagation();
|
|
2551
|
+
annotSel.remove(members);
|
|
2552
|
+
return;
|
|
2553
|
+
}
|
|
2417
2554
|
if (e.shiftKey) {
|
|
2418
2555
|
annotSel.add(members);
|
|
2419
2556
|
const merged = new Set([...annotSel.selectedIds, ...members]);
|
|
@@ -2446,6 +2583,20 @@ export function AnnotationsLayer() {
|
|
|
2446
2583
|
if (ids?.length) {
|
|
2447
2584
|
e.preventDefault();
|
|
2448
2585
|
e.stopImmediatePropagation();
|
|
2586
|
+
// preventDefault also stops the press from moving focus — so after the
|
|
2587
|
+
// toolbar (its focused button then unmounted: focus on <body>) Esc,
|
|
2588
|
+
// arrows and ⌘-shortcuts never reached the canvas. A press on the
|
|
2589
|
+
// board gives the canvas its keyboard back, as a plain click would.
|
|
2590
|
+
const canvasEl = target?.closest?.('.dc-canvas') as HTMLElement | null;
|
|
2591
|
+
const focused = document.activeElement as HTMLElement | null;
|
|
2592
|
+
if (
|
|
2593
|
+
canvasEl &&
|
|
2594
|
+
(!focused ||
|
|
2595
|
+
!canvasEl.contains(focused) ||
|
|
2596
|
+
focused.closest?.('.dc-annot-ctx, .dc-context-menu, .dc-annot-chrome'))
|
|
2597
|
+
) {
|
|
2598
|
+
canvasEl.focus({ preventScroll: true });
|
|
2599
|
+
}
|
|
2449
2600
|
// Capture a snapshot of all strokes at drag start. Every pointermove
|
|
2450
2601
|
// re-translates FROM the snapshot using the cumulative cursor delta
|
|
2451
2602
|
// (NOT a delta-from-last-frame mutation), so dragging back to origin
|
|
@@ -2460,12 +2611,21 @@ export function AnnotationsLayer() {
|
|
|
2460
2611
|
// Alt+click can't silently mint copies. undoBase stays pre-clone, so
|
|
2461
2612
|
// clone + move commit as one record.
|
|
2462
2613
|
if (e.altKey) {
|
|
2463
|
-
|
|
2614
|
+
// The section's contents are cloned with it and only the CLONES move.
|
|
2615
|
+
const subtree = expandForOp(
|
|
2616
|
+
ids,
|
|
2617
|
+
(x) => expandIdsToGroups(x, undoBase),
|
|
2618
|
+
new Containment(strokesToElementMap(undoBase, board.committed).values())
|
|
2619
|
+
);
|
|
2620
|
+
const res = duplicateStrokes(undoBase, subtree, 0, 0);
|
|
2464
2621
|
if (res.newIds.length) {
|
|
2465
2622
|
altDup = true;
|
|
2466
2623
|
dragSnapshot = res.strokes;
|
|
2467
|
-
|
|
2468
|
-
|
|
2624
|
+
setPreview(res.strokes);
|
|
2625
|
+
// Select the copies of what was selected; drag EVERY clone — the
|
|
2626
|
+
// copied contents move with the copied section by id, not by
|
|
2627
|
+
// geometry (the copy lies exactly on its original).
|
|
2628
|
+
annotSel.replace(cloneRoots(ids, res));
|
|
2469
2629
|
ids = res.newIds;
|
|
2470
2630
|
}
|
|
2471
2631
|
}
|
|
@@ -2479,22 +2639,16 @@ export function AnnotationsLayer() {
|
|
|
2479
2639
|
altDup,
|
|
2480
2640
|
};
|
|
2481
2641
|
const movedSet = new Set(ids);
|
|
2482
|
-
//
|
|
2483
|
-
// (
|
|
2484
|
-
|
|
2485
|
-
|
|
2486
|
-
|
|
2487
|
-
|
|
2488
|
-
|
|
2489
|
-
|
|
2490
|
-
|
|
2491
|
-
|
|
2492
|
-
const bb = strokeBBox(t);
|
|
2493
|
-
if (!bb) continue;
|
|
2494
|
-
const ccx = bb.x + bb.w / 2;
|
|
2495
|
-
const ccy = bb.y + bb.h / 2;
|
|
2496
|
-
if (ccx >= sx && ccx <= sx2 && ccy >= sy && ccy <= sy2) movedSet.add(t.id);
|
|
2497
|
-
}
|
|
2642
|
+
// A section carries its whole subtree — nested sections and their
|
|
2643
|
+
// contents too (explicit containment, Task 20), captured at start.
|
|
2644
|
+
if (dragSnapshot.some((x) => x.tool === 'section' && movedSet.has(x.id))) {
|
|
2645
|
+
const c = new Containment(
|
|
2646
|
+
strokesToElementMap(
|
|
2647
|
+
dragSnapshot.filter((x) => !isEphemeralHref(x)),
|
|
2648
|
+
board.committed
|
|
2649
|
+
).values()
|
|
2650
|
+
);
|
|
2651
|
+
for (const id of c.withContents([...movedSet])) movedSet.add(id);
|
|
2498
2652
|
}
|
|
2499
2653
|
// FigJam v3 — snap setup, computed ONCE per gesture: candidates are
|
|
2500
2654
|
// the bboxes of every non-moved stroke plus the artboard rects (in
|
|
@@ -2564,25 +2718,22 @@ export function AnnotationsLayer() {
|
|
|
2564
2718
|
st.snapshot.map((s) => (movedSet.has(s.id) ? translateOne(s, dx, dy) : s))
|
|
2565
2719
|
);
|
|
2566
2720
|
// Local React state only. No commitStrokes — no PUT, no undo push.
|
|
2567
|
-
|
|
2721
|
+
setPreview(next);
|
|
2722
|
+
publishGesture({ kind: 'move', ids: [...movedSet].slice(0, 256), dx, dy });
|
|
2568
2723
|
};
|
|
2569
2724
|
const onUp = (up: PointerEvent) => {
|
|
2570
2725
|
const st = dragStateRef.current;
|
|
2571
2726
|
if (!st || up.pointerId !== st.pointerId) return;
|
|
2572
2727
|
dragStateRef.current = null;
|
|
2573
2728
|
setSnapGuides(null);
|
|
2574
|
-
|
|
2575
|
-
document.removeEventListener('pointerup', onUp, true);
|
|
2576
|
-
document.removeEventListener('pointercancel', onUp, true);
|
|
2729
|
+
publishGesture(null);
|
|
2577
2730
|
// Commit the gesture as ONE record. Skip on zero-movement
|
|
2578
2731
|
// (click without drag past threshold or drag back to origin).
|
|
2579
2732
|
const final = strokesRef.current;
|
|
2580
2733
|
if (strokesShallowEqual(st.snapshot, final)) {
|
|
2581
|
-
|
|
2582
|
-
|
|
2583
|
-
|
|
2584
|
-
annotSel.replace(preAltIds);
|
|
2585
|
-
}
|
|
2734
|
+
// Nothing moved (an Alt+click also drops its eager clones).
|
|
2735
|
+
setPreview(null);
|
|
2736
|
+
if (st.altDup) annotSel.replace(preAltIds);
|
|
2586
2737
|
return;
|
|
2587
2738
|
}
|
|
2588
2739
|
commitStrokes(
|
|
@@ -2593,10 +2744,12 @@ export function AnnotationsLayer() {
|
|
|
2593
2744
|
}`
|
|
2594
2745
|
);
|
|
2595
2746
|
};
|
|
2596
|
-
|
|
2597
|
-
|
|
2598
|
-
|
|
2599
|
-
|
|
2747
|
+
return {
|
|
2748
|
+
kind: altDup ? 'duplicating' : 'dragging',
|
|
2749
|
+
move: onMove,
|
|
2750
|
+
up: onUp,
|
|
2751
|
+
cancel: (ev) => onUp(ev ?? ({ pointerId: e.pointerId } as PointerEvent)),
|
|
2752
|
+
};
|
|
2600
2753
|
}
|
|
2601
2754
|
|
|
2602
2755
|
// Not a stroke / hull-group drag. When pointerdown lands inside an
|
|
@@ -2621,10 +2774,7 @@ export function AnnotationsLayer() {
|
|
|
2621
2774
|
const [cwx, cwy] = screenToWorld(mv.clientX, mv.clientY);
|
|
2622
2775
|
setMarquee({ ax: wx, ay: wy, bx: cwx, by: cwy });
|
|
2623
2776
|
};
|
|
2624
|
-
const onUp = (
|
|
2625
|
-
document.removeEventListener('pointermove', onMove, true);
|
|
2626
|
-
document.removeEventListener('pointerup', onUp, true);
|
|
2627
|
-
document.removeEventListener('pointercancel', onUp, true);
|
|
2777
|
+
const onUp = () => {
|
|
2628
2778
|
if (!moved) {
|
|
2629
2779
|
// Click without movement on empty world → clear annotation
|
|
2630
2780
|
// selection (post-Wave-3 user feedback). Shift-click preserves
|
|
@@ -2635,22 +2785,15 @@ export function AnnotationsLayer() {
|
|
|
2635
2785
|
const final = marqueeRef.current;
|
|
2636
2786
|
setMarquee(null);
|
|
2637
2787
|
if (!final) return;
|
|
2638
|
-
|
|
2639
|
-
|
|
2640
|
-
const
|
|
2641
|
-
const yMax = Math.max(final.ay, final.by);
|
|
2642
|
-
const hits: string[] = [];
|
|
2788
|
+
// Anchored text rides its host (selected with it); a section is taken
|
|
2789
|
+
// only when the marquee encloses it (containment.ts marqueeHits).
|
|
2790
|
+
const items: MarqueeItem[] = [];
|
|
2643
2791
|
for (const s of strokesStoreRef.current.strokes) {
|
|
2644
|
-
// Anchored text inherits its host's bbox (selected with the host);
|
|
2645
|
-
// standalone text (Phase 21) has its own synthetic bbox and IS
|
|
2646
|
-
// marquee-selectable.
|
|
2647
2792
|
if (s.tool === 'text' && s.anchorId != null && s.anchorId !== '') continue;
|
|
2648
2793
|
const bb = strokeBBox(s);
|
|
2649
|
-
if (
|
|
2650
|
-
if (bb.x + bb.w >= xMin && bb.x <= xMax && bb.y + bb.h >= yMin && bb.y <= yMax) {
|
|
2651
|
-
hits.push(s.id);
|
|
2652
|
-
}
|
|
2794
|
+
if (bb) items.push({ id: s.id, box: bb, container: s.tool === 'section' });
|
|
2653
2795
|
}
|
|
2796
|
+
const hits = marqueeHits(items, { x1: final.ax, y1: final.ay, x2: final.bx, y2: final.by });
|
|
2654
2797
|
// Marquee that captured no strokes — preserve existing selection.
|
|
2655
2798
|
if (hits.length === 0) return;
|
|
2656
2799
|
// FigJam v3 — a marquee touching any group member selects the whole
|
|
@@ -2659,17 +2802,14 @@ export function AnnotationsLayer() {
|
|
|
2659
2802
|
if (addToSelection) annotSel.add(expanded);
|
|
2660
2803
|
else annotSel.replace(expanded);
|
|
2661
2804
|
};
|
|
2662
|
-
|
|
2663
|
-
document.addEventListener('pointerup', onUp, true);
|
|
2664
|
-
document.addEventListener('pointercancel', onUp, true);
|
|
2805
|
+
return { kind: 'marquee', move: onMove, up: onUp, cancel: onUp };
|
|
2665
2806
|
};
|
|
2666
2807
|
|
|
2667
|
-
|
|
2668
|
-
return () => document.removeEventListener('pointerdown', onDown, true);
|
|
2808
|
+
return pipeline.add({ priority: 100, name: 'select', down: onDown });
|
|
2669
2809
|
// commitStrokes is included defensively (it is a stable useCallback([]) ref,
|
|
2670
|
-
// so this never re-
|
|
2810
|
+
// so this never re-registers the stage) to remove the latent stale-closure
|
|
2671
2811
|
// trap flagged in the Phase 24 frontend review.
|
|
2672
|
-
}, [tool, annotSel, elementSel, screenToWorld, strokesStore, commitStrokes]);
|
|
2812
|
+
}, [pipeline, tool, annotSel, elementSel, screenToWorld, strokesStore, commitStrokes]);
|
|
2673
2813
|
|
|
2674
2814
|
// Latest marquee + strokes refs for the doc-level pointerup callback
|
|
2675
2815
|
// (avoids re-binding the listener on every state tick).
|
|
@@ -2694,7 +2834,7 @@ export function AnnotationsLayer() {
|
|
|
2694
2834
|
if (tool !== 'move') return;
|
|
2695
2835
|
if (!annotSel) return;
|
|
2696
2836
|
|
|
2697
|
-
const onDown = (e: PointerEvent) => {
|
|
2837
|
+
const onDown = (e: PointerEvent): Claim => {
|
|
2698
2838
|
const target = e.target as Element | null;
|
|
2699
2839
|
const cornerEl = target?.closest?.('[data-group-resize-corner]') ?? null;
|
|
2700
2840
|
if (!cornerEl) return;
|
|
@@ -2736,6 +2876,12 @@ export function AnnotationsLayer() {
|
|
|
2736
2876
|
} catch {
|
|
2737
2877
|
/* some browsers reject capture on synthetic events */
|
|
2738
2878
|
}
|
|
2879
|
+
return {
|
|
2880
|
+
kind: 'resizing',
|
|
2881
|
+
move: onMove,
|
|
2882
|
+
up: onUp,
|
|
2883
|
+
cancel: (ev) => onUp(ev ?? ({ pointerId: e.pointerId } as PointerEvent)),
|
|
2884
|
+
};
|
|
2739
2885
|
};
|
|
2740
2886
|
const onMove = (e: PointerEvent) => {
|
|
2741
2887
|
const d = groupResizeRef.current;
|
|
@@ -2762,29 +2908,25 @@ export function AnnotationsLayer() {
|
|
|
2762
2908
|
return patch ? ({ ...s, ...patch } as Stroke) : s;
|
|
2763
2909
|
})
|
|
2764
2910
|
);
|
|
2765
|
-
|
|
2911
|
+
setPreview(next);
|
|
2912
|
+
publishGesture({ kind: 'resize', ids: d.ids.slice(0, 256), box: groupB1 });
|
|
2766
2913
|
};
|
|
2767
2914
|
const onUp = (e: PointerEvent) => {
|
|
2768
2915
|
const d = groupResizeRef.current;
|
|
2769
2916
|
if (!d || e.pointerId !== d.pointerId) return;
|
|
2770
2917
|
groupResizeRef.current = null;
|
|
2918
|
+
publishGesture(null);
|
|
2771
2919
|
const final = strokesRef.current;
|
|
2772
2920
|
// No-op drag (grabbed a handle, released without moving past the
|
|
2773
2921
|
// resize's own resolution) skips the undo record.
|
|
2774
|
-
if (strokesShallowEqual(d.undoBase, final))
|
|
2922
|
+
if (strokesShallowEqual(d.undoBase, final)) {
|
|
2923
|
+
setPreview(null);
|
|
2924
|
+
return;
|
|
2925
|
+
}
|
|
2775
2926
|
commitStrokes(d.undoBase, final, `resize ${d.ids.length} strokes`);
|
|
2776
2927
|
};
|
|
2777
|
-
|
|
2778
|
-
|
|
2779
|
-
document.addEventListener('pointerup', onUp, true);
|
|
2780
|
-
document.addEventListener('pointercancel', onUp, true);
|
|
2781
|
-
return () => {
|
|
2782
|
-
document.removeEventListener('pointerdown', onDown, true);
|
|
2783
|
-
document.removeEventListener('pointermove', onMove, true);
|
|
2784
|
-
document.removeEventListener('pointerup', onUp, true);
|
|
2785
|
-
document.removeEventListener('pointercancel', onUp, true);
|
|
2786
|
-
};
|
|
2787
|
-
}, [tool, annotSel, screenToWorld, anchorsById, commitStrokes]);
|
|
2928
|
+
return pipeline.add({ priority: 3, name: 'group-resize', down: onDown });
|
|
2929
|
+
}, [pipeline, tool, annotSel, screenToWorld, anchorsById, commitStrokes]);
|
|
2788
2930
|
|
|
2789
2931
|
// Double-click enters text-edit mode: rect/ellipse (anchored text), sticky
|
|
2790
2932
|
// (its own body), or a standalone text node (re-edit in place). Anchored text
|
|
@@ -2795,8 +2937,22 @@ export function AnnotationsLayer() {
|
|
|
2795
2937
|
const onDbl = (e: MouseEvent) => {
|
|
2796
2938
|
if (isMediaPlayerTarget(e)) return; // mediaref inline player owns this event
|
|
2797
2939
|
const target = e.target as Element | null;
|
|
2798
|
-
|
|
2940
|
+
// Inside an open editor a double-click selects a word (native).
|
|
2941
|
+
if (target?.closest?.('[data-annot-editor]')) return;
|
|
2942
|
+
let node = target?.closest?.('[data-id][data-tool]');
|
|
2799
2943
|
if (!node) return;
|
|
2944
|
+
// A shape's label is its own <text data-anchor-id=host>: a double-click
|
|
2945
|
+
// on the words of a label edits the SHAPE. It used to be skipped here,
|
|
2946
|
+
// so the event fell through to the canvas-shell dblclick→fit() handler
|
|
2947
|
+
// and the view jumped to "fit all" instead of opening the editor.
|
|
2948
|
+
const hostId =
|
|
2949
|
+
node.getAttribute('data-tool') === 'text' ? node.getAttribute('data-anchor-id') : null;
|
|
2950
|
+
if (hostId) {
|
|
2951
|
+
const host = node.ownerDocument?.querySelector?.(
|
|
2952
|
+
`[data-id="${CSS.escape(hostId)}"][data-tool]`
|
|
2953
|
+
);
|
|
2954
|
+
if (host) node = host;
|
|
2955
|
+
}
|
|
2800
2956
|
const id = node.getAttribute('data-id');
|
|
2801
2957
|
const t = node.getAttribute('data-tool');
|
|
2802
2958
|
if (!id) return;
|
|
@@ -2817,15 +2973,13 @@ export function AnnotationsLayer() {
|
|
|
2817
2973
|
// a shape's text jumped the viewport to fit().
|
|
2818
2974
|
e.preventDefault();
|
|
2819
2975
|
e.stopPropagation();
|
|
2820
|
-
|
|
2821
|
-
setEditingId(id);
|
|
2976
|
+
openEditorAt(id, e.clientX, e.clientY);
|
|
2822
2977
|
return;
|
|
2823
2978
|
}
|
|
2824
2979
|
if (t === 'text' && !node.getAttribute('data-anchor-id')) {
|
|
2825
2980
|
e.preventDefault();
|
|
2826
2981
|
e.stopPropagation();
|
|
2827
|
-
|
|
2828
|
-
setEditingId(id);
|
|
2982
|
+
openEditorAt(id, e.clientX, e.clientY);
|
|
2829
2983
|
}
|
|
2830
2984
|
};
|
|
2831
2985
|
document.addEventListener('dblclick', onDbl, true);
|
|
@@ -2953,21 +3107,78 @@ export function AnnotationsLayer() {
|
|
|
2953
3107
|
// commit call to the right writer. `editingId` doubles as the host id
|
|
2954
3108
|
// (anchored) OR the sticky/standalone stroke id; `pendingText` is the
|
|
2955
3109
|
// not-yet-born text caret.
|
|
3110
|
+
// The last resolved target: when a collaborator deletes the element being
|
|
3111
|
+
// edited, the editor stays open on it (Task 19 — never drop typed text).
|
|
3112
|
+
const lastTargetRef = useRef<EditingTarget>(null);
|
|
2956
3113
|
const editingTarget = useMemo<EditingTarget>(() => {
|
|
2957
3114
|
if (pendingText) return { kind: 'pending', x: pendingText.x, y: pendingText.y };
|
|
2958
3115
|
if (!editingId) return null;
|
|
3116
|
+
let t: EditingTarget = null;
|
|
2959
3117
|
const host = anchorsById.get(editingId);
|
|
2960
|
-
if (host) return { kind: 'anchored', anchorId: editingId, host };
|
|
2961
3118
|
const s = strokesById.get(editingId);
|
|
2962
|
-
if (
|
|
2963
|
-
if (s?.tool === '
|
|
2964
|
-
if (s?.tool === '
|
|
2965
|
-
|
|
2966
|
-
|
|
3119
|
+
if (host) t = { kind: 'anchored', anchorId: editingId, host };
|
|
3120
|
+
else if (s?.tool === 'sticky') t = { kind: 'sticky', sticky: s };
|
|
3121
|
+
else if (s?.tool === 'section') t = { kind: 'section', section: s };
|
|
3122
|
+
else if (s?.tool === 'text' && (s.anchorId == null || s.anchorId === ''))
|
|
3123
|
+
t = { kind: 'standalone', text: s };
|
|
3124
|
+
if (t) {
|
|
3125
|
+
lastTargetRef.current = t;
|
|
3126
|
+
return t;
|
|
3127
|
+
}
|
|
3128
|
+
const last = lastTargetRef.current;
|
|
3129
|
+
return last && editTargetId(last) === editingId ? last : null;
|
|
2967
3130
|
}, [pendingText, editingId, anchorsById, strokesById]);
|
|
3131
|
+
const editGone = !!editingId && !strokesById.has(editingId);
|
|
2968
3132
|
|
|
2969
3133
|
const editingTargetRef = useRef(editingTarget);
|
|
2970
3134
|
editingTargetRef.current = editingTarget;
|
|
3135
|
+
const editingIdRef = useRef(editingId);
|
|
3136
|
+
editingIdRef.current = editingId;
|
|
3137
|
+
|
|
3138
|
+
// One edit session per opened element (Task 19). Opened from the board as
|
|
3139
|
+
// last delivered, so its base is what everyone agreed on at that moment.
|
|
3140
|
+
useEffect(() => {
|
|
3141
|
+
textSessionRef.current = null;
|
|
3142
|
+
setRemoteEdited(false);
|
|
3143
|
+
if (!editingId) {
|
|
3144
|
+
sessionElRef.current = null;
|
|
3145
|
+
return;
|
|
3146
|
+
}
|
|
3147
|
+
const el = board.committed.get(editingId);
|
|
3148
|
+
if (el) {
|
|
3149
|
+
textSessionRef.current = openSession(el);
|
|
3150
|
+
sessionElRef.current = el;
|
|
3151
|
+
}
|
|
3152
|
+
}, [editingId]);
|
|
3153
|
+
if (editingId) {
|
|
3154
|
+
const el = board.committed.get(editingId);
|
|
3155
|
+
if (el) sessionElRef.current = el;
|
|
3156
|
+
}
|
|
3157
|
+
|
|
3158
|
+
/** The session for `id`, opened lazily (an element born a moment ago). */
|
|
3159
|
+
const sessionFor = useCallback((id: string): TextSession | null => {
|
|
3160
|
+
const cur = textSessionRef.current;
|
|
3161
|
+
if (cur && cur.id === id) return cur;
|
|
3162
|
+
const el = board.committed.get(id);
|
|
3163
|
+
const next = el ? openSession(el) : null;
|
|
3164
|
+
textSessionRef.current = next;
|
|
3165
|
+
return next;
|
|
3166
|
+
}, []);
|
|
3167
|
+
|
|
3168
|
+
/** Idle-typing draft: the board (and every peer) sees the text so far. Never undo. */
|
|
3169
|
+
const draftEditing = useCallback(
|
|
3170
|
+
(text: string) => {
|
|
3171
|
+
const id = editingIdRef.current;
|
|
3172
|
+
if (!id) return;
|
|
3173
|
+
const session = sessionFor(id);
|
|
3174
|
+
if (!session) return;
|
|
3175
|
+
const op = draftOp(session, board.committed.get(id), text);
|
|
3176
|
+
if (!op) return;
|
|
3177
|
+
void sendOps([op]);
|
|
3178
|
+
textSessionRef.current = markSent(session, text);
|
|
3179
|
+
},
|
|
3180
|
+
[sessionFor, sendOps]
|
|
3181
|
+
);
|
|
2971
3182
|
|
|
2972
3183
|
const commitEditing = useCallback(
|
|
2973
3184
|
(text: string, fmt?: EditorFmt, measuredH?: number) => {
|
|
@@ -2976,19 +3187,62 @@ export function AnnotationsLayer() {
|
|
|
2976
3187
|
setPendingText(null);
|
|
2977
3188
|
setEditCaretPoint(null);
|
|
2978
3189
|
if (!target) return;
|
|
2979
|
-
if (target.kind === '
|
|
2980
|
-
|
|
2981
|
-
|
|
2982
|
-
|
|
2983
|
-
|
|
2984
|
-
|
|
3190
|
+
if (target.kind === 'pending') {
|
|
3191
|
+
createStandaloneText(target.x, target.y, text, fmt);
|
|
3192
|
+
return;
|
|
3193
|
+
}
|
|
3194
|
+
const id = editTargetId(target) as string;
|
|
3195
|
+
const session = sessionFor(id);
|
|
3196
|
+
const stored = storedSlotText(target.kind, text);
|
|
3197
|
+
// A collaborator deleted it while we typed: keep the text by putting the
|
|
3198
|
+
// element back (same id) — unless the text is empty anyway.
|
|
3199
|
+
if (!strokesRef.current.some((s) => s.id === id)) {
|
|
3200
|
+
const lastKnown = sessionElRef.current;
|
|
3201
|
+
if (!session || !lastKnown || !stored.trim()) return;
|
|
3202
|
+
const parentExists = !!lastKnown.parent && board.committed.has(lastKnown.parent);
|
|
3203
|
+
const bb = strokeBBox(editTargetStroke(target));
|
|
3204
|
+
applyOpsLocal([
|
|
3205
|
+
restoreOp(
|
|
3206
|
+
session,
|
|
3207
|
+
lastKnown,
|
|
3208
|
+
stored,
|
|
3209
|
+
parentExists,
|
|
3210
|
+
bb ? { x: bb.x, y: bb.y } : undefined
|
|
3211
|
+
),
|
|
3212
|
+
]);
|
|
3213
|
+
return;
|
|
3214
|
+
}
|
|
3215
|
+
commitSessionRef.current = session;
|
|
3216
|
+
sessionCommittedRef.current = false;
|
|
3217
|
+
try {
|
|
3218
|
+
if (target.kind === 'anchored') commitText(target.anchorId, text, fmt);
|
|
3219
|
+
else if (target.kind === 'sticky') commitStickyText(target.sticky.id, text, fmt, measuredH);
|
|
3220
|
+
else if (target.kind === 'standalone') commitStandaloneText(target.text.id, text, fmt);
|
|
3221
|
+
else if (target.kind === 'section' && stored !== target.section.label) {
|
|
2985
3222
|
strokesStoreRef.current.updateStroke(target.section.id, {
|
|
2986
|
-
label,
|
|
3223
|
+
label: stored,
|
|
2987
3224
|
} as Partial<Stroke>);
|
|
2988
3225
|
}
|
|
2989
|
-
}
|
|
3226
|
+
} finally {
|
|
3227
|
+
commitSessionRef.current = null;
|
|
3228
|
+
}
|
|
3229
|
+
// No change to commit — but drafts may have moved the board: bring it
|
|
3230
|
+
// back to the text the element keeps (no undo step: nothing changed).
|
|
3231
|
+
if (session && !sessionCommittedRef.current) {
|
|
3232
|
+
const op = draftOp(session, board.committed.get(id), stored);
|
|
3233
|
+
if (op) applyOpsLocal([op]);
|
|
3234
|
+
}
|
|
3235
|
+
textSessionRef.current = null;
|
|
2990
3236
|
},
|
|
2991
|
-
[
|
|
3237
|
+
[
|
|
3238
|
+
commitText,
|
|
3239
|
+
commitStickyText,
|
|
3240
|
+
commitStandaloneText,
|
|
3241
|
+
createStandaloneText,
|
|
3242
|
+
sessionFor,
|
|
3243
|
+
applyOpsLocal,
|
|
3244
|
+
board,
|
|
3245
|
+
]
|
|
2992
3246
|
);
|
|
2993
3247
|
|
|
2994
3248
|
const cancelEditing = useCallback(() => {
|
|
@@ -2999,7 +3253,7 @@ export function AnnotationsLayer() {
|
|
|
2999
3253
|
|
|
3000
3254
|
/**
|
|
3001
3255
|
* FigJam v3 — copy/cut the (expanded) selection to the OS clipboard as a
|
|
3002
|
-
* `{"
|
|
3256
|
+
* `{"maudeElements":2}` JSON text payload (v2 elements, world coordinates). Shared by ⌘C/⌘X and the
|
|
3003
3257
|
* right-click menu. Returns true when something was copied.
|
|
3004
3258
|
*/
|
|
3005
3259
|
const copySelection = useCallback(
|
|
@@ -3008,7 +3262,13 @@ export function AnnotationsLayer() {
|
|
|
3008
3262
|
const sel = annotSel.selectedIds;
|
|
3009
3263
|
if (sel.length === 0) return false;
|
|
3010
3264
|
const store = strokesStoreRef.current;
|
|
3011
|
-
const expanded = new Set(
|
|
3265
|
+
const expanded = new Set(
|
|
3266
|
+
expandForOp(
|
|
3267
|
+
sel,
|
|
3268
|
+
(x) => expandIdsToGroups(x, store.strokes),
|
|
3269
|
+
new Containment(strokesToElementMap(store.strokes, board.committed).values())
|
|
3270
|
+
)
|
|
3271
|
+
);
|
|
3012
3272
|
const payload = store.strokes.filter(
|
|
3013
3273
|
(s) =>
|
|
3014
3274
|
expanded.has(s.id) ||
|
|
@@ -3017,7 +3277,12 @@ export function AnnotationsLayer() {
|
|
|
3017
3277
|
if (payload.length === 0) return false;
|
|
3018
3278
|
try {
|
|
3019
3279
|
void navigator.clipboard
|
|
3020
|
-
?.writeText(
|
|
3280
|
+
?.writeText(
|
|
3281
|
+
JSON.stringify({
|
|
3282
|
+
maudeElements: 2,
|
|
3283
|
+
elements: v1ToV2(payload, { flat: true }).elements,
|
|
3284
|
+
})
|
|
3285
|
+
)
|
|
3021
3286
|
.catch(() => {
|
|
3022
3287
|
/* clipboard permission denied — copy is best-effort */
|
|
3023
3288
|
});
|
|
@@ -3034,24 +3299,45 @@ export function AnnotationsLayer() {
|
|
|
3034
3299
|
);
|
|
3035
3300
|
|
|
3036
3301
|
/**
|
|
3037
|
-
* FigJam v3 — paste a
|
|
3038
|
-
*
|
|
3039
|
-
*
|
|
3302
|
+
* FigJam v3 — paste a board clipboard payload (⌘V or the right-click menu).
|
|
3303
|
+
* Every element is validated like a peer's (DDR-242 — foreign JSON is
|
|
3304
|
+
* untrusted); a malformed one is dropped. Clones get fresh ids and a
|
|
3305
|
+
* +16/+16 offset. A pre-v2 `{"maudeStrokes":1}` payload is upconverted.
|
|
3040
3306
|
*/
|
|
3041
3307
|
const pasteStrokesText = useCallback(
|
|
3042
3308
|
(txt: string): boolean => {
|
|
3043
|
-
if (!annotSel) return false;
|
|
3044
|
-
if (!txt.startsWith('{"maudeStrokes"')) return false;
|
|
3045
|
-
let parsed: { maudeStrokes?: number; strokes?: unknown } | null = null;
|
|
3046
|
-
try {
|
|
3047
|
-
parsed = JSON.parse(txt) as { maudeStrokes?: number; strokes?: unknown };
|
|
3048
|
-
} catch {
|
|
3049
|
-
return false;
|
|
3050
|
-
}
|
|
3051
|
-
if (parsed?.maudeStrokes !== 1 || !Array.isArray(parsed.strokes)) return false;
|
|
3309
|
+
if (!annotSel || !isBoardClipboard(txt)) return false;
|
|
3052
3310
|
let safe: Stroke[] = [];
|
|
3053
3311
|
try {
|
|
3054
|
-
|
|
3312
|
+
const parsed = JSON.parse(txt) as {
|
|
3313
|
+
maudeElements?: number;
|
|
3314
|
+
elements?: unknown;
|
|
3315
|
+
maudeStrokes?: number;
|
|
3316
|
+
strokes?: unknown;
|
|
3317
|
+
};
|
|
3318
|
+
const raw =
|
|
3319
|
+
parsed.maudeElements === 2 && Array.isArray(parsed.elements)
|
|
3320
|
+
? parsed.elements.slice(0, MAX_ELEMENTS)
|
|
3321
|
+
: parsed.maudeStrokes === 1 && Array.isArray(parsed.strokes)
|
|
3322
|
+
? v1ToV2((parsed.strokes as Stroke[]).slice(0, MAX_ELEMENTS), { flat: true }).elements
|
|
3323
|
+
: null;
|
|
3324
|
+
if (!raw) return false;
|
|
3325
|
+
// What lands is THIS user's paste: it carries their authorship, never a
|
|
3326
|
+
// name the clipboard claims, and a link card's domain comes from its
|
|
3327
|
+
// url (a pastejacked card must not read "accounts.google.com" while
|
|
3328
|
+
// pointing elsewhere — security review A3).
|
|
3329
|
+
const me = collabRef.current?.myName;
|
|
3330
|
+
const author = me
|
|
3331
|
+
? { kind: 'human', name: me, id: collabRef.current?.myConnId }
|
|
3332
|
+
: undefined;
|
|
3333
|
+
const mine = validateElements(raw).elements.map((el) => {
|
|
3334
|
+
const out: Record<string, unknown> = { ...el };
|
|
3335
|
+
delete out.author;
|
|
3336
|
+
if (author) out.author = author;
|
|
3337
|
+
if (el.type === 'link' && typeof el.url === 'string') out.domain = linkDomain(el.url);
|
|
3338
|
+
return out;
|
|
3339
|
+
});
|
|
3340
|
+
safe = elementsToStrokes(validateElements(mine).elements);
|
|
3055
3341
|
} catch {
|
|
3056
3342
|
return false;
|
|
3057
3343
|
}
|
|
@@ -3153,53 +3439,14 @@ export function AnnotationsLayer() {
|
|
|
3153
3439
|
);
|
|
3154
3440
|
|
|
3155
3441
|
// FigJam v3 — ⌘Enter pressed INSIDE a sticky/anchored editor commits there
|
|
3156
|
-
// and
|
|
3157
|
-
//
|
|
3158
|
-
|
|
3159
|
-
|
|
3160
|
-
|
|
3161
|
-
|
|
3162
|
-
|
|
3163
|
-
|
|
3164
|
-
chainCreate(id);
|
|
3165
|
-
}, 0);
|
|
3166
|
-
};
|
|
3167
|
-
document.addEventListener('maude:chain-create', onChain);
|
|
3168
|
-
return () => document.removeEventListener('maude:chain-create', onChain);
|
|
3169
|
-
}, [chainCreate]);
|
|
3170
|
-
|
|
3171
|
-
// FigJam v3 — the resize overlay (a sibling component that owns the arrow
|
|
3172
|
-
// endpoint handles) broadcasts the bind candidate while an endpoint drags;
|
|
3173
|
-
// the halo renders here because the SVG layer owns the world overlay.
|
|
3174
|
-
useEffect(() => {
|
|
3175
|
-
if (typeof document === 'undefined') return;
|
|
3176
|
-
const onHint = (e: Event) => {
|
|
3177
|
-
setBindHintId((e as CustomEvent<{ hostId?: string | null }>).detail?.hostId ?? null);
|
|
3178
|
-
};
|
|
3179
|
-
document.addEventListener('maude:bind-hint', onHint);
|
|
3180
|
-
return () => document.removeEventListener('maude:bind-hint', onHint);
|
|
3181
|
-
}, []);
|
|
3182
|
-
|
|
3183
|
-
// FigJam v3 — live size label + dimension-match halos while resizing (the
|
|
3184
|
-
// overlay broadcasts; the SVG layer paints).
|
|
3185
|
-
const [resizeInfo, setResizeInfo] = useState<{
|
|
3186
|
-
box: { x: number; y: number; w: number; h: number } | null;
|
|
3187
|
-
matchIds: string[];
|
|
3188
|
-
} | null>(null);
|
|
3189
|
-
useEffect(() => {
|
|
3190
|
-
if (typeof document === 'undefined') return;
|
|
3191
|
-
const onInfo = (e: Event) => {
|
|
3192
|
-
const detail = (
|
|
3193
|
-
e as CustomEvent<{
|
|
3194
|
-
box?: { x: number; y: number; w: number; h: number } | null;
|
|
3195
|
-
matchIds?: string[];
|
|
3196
|
-
}>
|
|
3197
|
-
).detail;
|
|
3198
|
-
setResizeInfo(detail?.box ? { box: detail.box, matchIds: detail.matchIds ?? [] } : null);
|
|
3199
|
-
};
|
|
3200
|
-
document.addEventListener('maude:resize-info', onInfo);
|
|
3201
|
-
return () => document.removeEventListener('maude:resize-info', onInfo);
|
|
3202
|
-
}, []);
|
|
3442
|
+
// and chains the next sibling. Deferred a tick so the editor's commit lands
|
|
3443
|
+
// in strokesRef (React state) before the chain reads it.
|
|
3444
|
+
const chainAfterCommit = useCallback(
|
|
3445
|
+
(id: string) => {
|
|
3446
|
+
window.setTimeout(() => chainCreate(id), 0);
|
|
3447
|
+
},
|
|
3448
|
+
[chainCreate]
|
|
3449
|
+
);
|
|
3203
3450
|
|
|
3204
3451
|
// FigJam v3 — manipulation shortcuts: ⌘G group / ⌘⇧G ungroup, ⌘D duplicate,
|
|
3205
3452
|
// ] [ ⌘] ⌘[ z-order, ⌘C/⌘X copy/cut (selection → OS clipboard as a JSON
|
|
@@ -3286,7 +3533,7 @@ export function AnnotationsLayer() {
|
|
|
3286
3533
|
const onPaste = (e: ClipboardEvent) => {
|
|
3287
3534
|
if (isEditable(e.target)) return;
|
|
3288
3535
|
const txt = e.clipboardData?.getData('text/plain') ?? '';
|
|
3289
|
-
if (!txt
|
|
3536
|
+
if (!isBoardClipboard(txt)) return;
|
|
3290
3537
|
e.preventDefault();
|
|
3291
3538
|
e.stopImmediatePropagation();
|
|
3292
3539
|
pasteStrokesText(txt);
|
|
@@ -3380,7 +3627,7 @@ export function AnnotationsLayer() {
|
|
|
3380
3627
|
useEffect(() => {
|
|
3381
3628
|
if (typeof document === 'undefined') return;
|
|
3382
3629
|
if (tool !== 'move') return;
|
|
3383
|
-
const onDown = (e: PointerEvent) => {
|
|
3630
|
+
const onDown = (e: PointerEvent): Claim => {
|
|
3384
3631
|
if (isMediaPlayerTarget(e)) return; // mediaref inline player owns this event
|
|
3385
3632
|
if (e.button !== 0) return;
|
|
3386
3633
|
const dot = (e.target as Element | null)?.closest?.('.dc-annot-conn-dot');
|
|
@@ -3424,9 +3671,6 @@ export function AnnotationsLayer() {
|
|
|
3424
3671
|
};
|
|
3425
3672
|
const onUp = (up: PointerEvent) => {
|
|
3426
3673
|
if (up.pointerId !== pointerId) return;
|
|
3427
|
-
document.removeEventListener('pointermove', onMove, true);
|
|
3428
|
-
document.removeEventListener('pointerup', onUp, true);
|
|
3429
|
-
document.removeEventListener('pointercancel', onUp, true);
|
|
3430
3674
|
const draft = connDraftRef.current;
|
|
3431
3675
|
setConnDraft(null);
|
|
3432
3676
|
setBindHintId(null);
|
|
@@ -3456,13 +3700,15 @@ export function AnnotationsLayer() {
|
|
|
3456
3700
|
);
|
|
3457
3701
|
}
|
|
3458
3702
|
};
|
|
3459
|
-
|
|
3460
|
-
|
|
3461
|
-
|
|
3703
|
+
return {
|
|
3704
|
+
kind: 'connecting',
|
|
3705
|
+
move: onMove,
|
|
3706
|
+
up: onUp,
|
|
3707
|
+
cancel: (ev) => onUp(ev ?? ({ pointerId } as PointerEvent)),
|
|
3708
|
+
};
|
|
3462
3709
|
};
|
|
3463
|
-
|
|
3464
|
-
|
|
3465
|
-
}, [tool, screenToWorld, commitStrokes, annotSel, theme]);
|
|
3710
|
+
return pipeline.add({ priority: 2, name: 'connector', down: onDown });
|
|
3711
|
+
}, [pipeline, tool, screenToWorld, commitStrokes, annotSel, theme]);
|
|
3466
3712
|
|
|
3467
3713
|
// FigJam v3 — right-click on a stroke SELECTS it (keeping a multi-selection
|
|
3468
3714
|
// the press lands inside) and opens the annotation context menu (z-order,
|
|
@@ -3496,19 +3742,20 @@ export function AnnotationsLayer() {
|
|
|
3496
3742
|
// capture listener fires before the router's host-capture one and stops
|
|
3497
3743
|
// propagation WITHOUT preventDefault, so the native contextmenu event
|
|
3498
3744
|
// (which opens OUR menu above) still follows.
|
|
3499
|
-
const onDown = (e: PointerEvent) => {
|
|
3500
|
-
if (isMediaPlayerTarget(e)) return;
|
|
3745
|
+
const onDown = (e: PointerEvent): Claim => {
|
|
3746
|
+
if (isMediaPlayerTarget(e)) return;
|
|
3501
3747
|
if (e.button !== 2) return;
|
|
3502
3748
|
if (!strokeAt(e.target as Element | null)) return;
|
|
3503
3749
|
e.stopImmediatePropagation();
|
|
3750
|
+
return 'pass';
|
|
3504
3751
|
};
|
|
3505
3752
|
document.addEventListener('contextmenu', onCtx, true);
|
|
3506
|
-
|
|
3753
|
+
const unstage = pipeline.add({ priority: 1, name: 'context-menu', down: onDown });
|
|
3507
3754
|
return () => {
|
|
3508
3755
|
document.removeEventListener('contextmenu', onCtx, true);
|
|
3509
|
-
|
|
3756
|
+
unstage();
|
|
3510
3757
|
};
|
|
3511
|
-
}, [tool, annotSel]);
|
|
3758
|
+
}, [pipeline, tool, annotSel]);
|
|
3512
3759
|
|
|
3513
3760
|
// Keyboard: arrow nudge + Backspace/Delete remove selected strokes.
|
|
3514
3761
|
useEffect(() => {
|
|
@@ -3678,92 +3925,99 @@ export function AnnotationsLayer() {
|
|
|
3678
3925
|
}, [annotSel, strokesById]);
|
|
3679
3926
|
|
|
3680
3927
|
return (
|
|
3681
|
-
<
|
|
3682
|
-
<
|
|
3683
|
-
|
|
3684
|
-
|
|
3685
|
-
|
|
3686
|
-
|
|
3687
|
-
|
|
3688
|
-
|
|
3689
|
-
|
|
3690
|
-
|
|
3691
|
-
{visible ? (
|
|
3692
|
-
<AnnotationsSvg
|
|
3693
|
-
worldRef={worldRef}
|
|
3694
|
-
strokes={renderStrokes}
|
|
3695
|
-
anchorsById={anchorsById}
|
|
3696
|
-
selectMode={tool === 'move'}
|
|
3697
|
-
selectedStrokes={selectedStrokes}
|
|
3698
|
-
marquee={marquee}
|
|
3699
|
-
snapGuides={snapGuides}
|
|
3700
|
-
bindHintId={bindHintId}
|
|
3701
|
-
resizeInfo={resizeInfo}
|
|
3702
|
-
connDraft={connDraft}
|
|
3703
|
-
addTextHintId={editingTarget ? null : addTextHintId}
|
|
3704
|
-
ghost={ghostPreview}
|
|
3705
|
-
editingTarget={editingTarget}
|
|
3706
|
-
editCaretPoint={editCaretPoint}
|
|
3707
|
-
inkColor={color}
|
|
3708
|
-
onCommitEdit={commitEditing}
|
|
3709
|
-
onCancelEdit={cancelEditing}
|
|
3928
|
+
<AnnotationPipelineContext.Provider value={pipeline}>
|
|
3929
|
+
<StrokesStoreContext.Provider value={strokesStore}>
|
|
3930
|
+
<AnnotationsInput
|
|
3931
|
+
isActive={isActive}
|
|
3932
|
+
visible={visible}
|
|
3933
|
+
cursor={tools.find((t) => t.id === tool)?.cursor ?? 'crosshair'}
|
|
3934
|
+
beginStroke={beginStroke}
|
|
3935
|
+
moveStroke={moveStroke}
|
|
3936
|
+
endStroke={endStroke}
|
|
3937
|
+
onLeave={() => setGhost(null)}
|
|
3710
3938
|
/>
|
|
3711
|
-
|
|
3712
|
-
|
|
3939
|
+
{visible ? (
|
|
3940
|
+
<AnnotationsSvg
|
|
3941
|
+
worldRef={worldRef}
|
|
3942
|
+
strokes={renderStrokes}
|
|
3943
|
+
anchorsById={anchorsById}
|
|
3944
|
+
selectMode={tool === 'move'}
|
|
3945
|
+
selectedStrokes={selectedStrokes}
|
|
3946
|
+
marquee={marquee}
|
|
3947
|
+
snapGuides={snapGuides}
|
|
3948
|
+
bindHintId={bindHintId}
|
|
3949
|
+
resizeInfo={resizeInfo}
|
|
3950
|
+
connDraft={connDraft}
|
|
3951
|
+
addTextHintId={editingTarget ? null : addTextHintId}
|
|
3952
|
+
ghost={ghostPreview}
|
|
3953
|
+
editingTarget={editingTarget}
|
|
3954
|
+
editCaretPoint={editCaretPoint}
|
|
3955
|
+
inkColor={color}
|
|
3956
|
+
onCommitEdit={commitEditing}
|
|
3957
|
+
onCancelEdit={cancelEditing}
|
|
3958
|
+
onDraftEdit={draftEditing}
|
|
3959
|
+
onChainEdit={chainAfterCommit}
|
|
3960
|
+
gestureState={gestureState}
|
|
3961
|
+
editNotice={editGone ? 'deleted' : remoteEdited ? 'edited' : null}
|
|
3962
|
+
unknownElements={unknownElements}
|
|
3963
|
+
/>
|
|
3964
|
+
) : null}
|
|
3965
|
+
{/* DDR-150 dogfood #8 — inline players for media-reference chips (HTML
|
|
3713
3966
|
overlay in the world div; see MediaRefPlayers for why not
|
|
3714
3967
|
foreignObject). */}
|
|
3715
|
-
|
|
3716
|
-
|
|
3717
|
-
|
|
3718
|
-
|
|
3719
|
-
|
|
3720
|
-
|
|
3721
|
-
|
|
3722
|
-
|
|
3723
|
-
|
|
3724
|
-
|
|
3725
|
-
}
|
|
3726
|
-
/>
|
|
3727
|
-
{ctxMenu && annotSel ? (
|
|
3728
|
-
<AnnotationContextMenu
|
|
3729
|
-
pos={ctxMenu}
|
|
3730
|
-
selCount={annotSel.selectedIds.length}
|
|
3731
|
-
canUngroup={selectedStrokes.some((s) => (s.groupIds?.length ?? 0) > 0)}
|
|
3732
|
-
canReplace={
|
|
3733
|
-
selectedStrokes.length === 1 &&
|
|
3734
|
-
(selectedStrokes[0]?.tool === 'image' || selectedStrokes[0]?.tool === 'mediaref')
|
|
3968
|
+
<MediaRefPlayers worldRef={worldRef} strokes={renderStrokes} visible={visible} />
|
|
3969
|
+
<AnnotationContextToolbar
|
|
3970
|
+
editingId={
|
|
3971
|
+
editingTarget?.kind === 'anchored'
|
|
3972
|
+
? editingTarget.anchorId
|
|
3973
|
+
: editingTarget?.kind === 'sticky'
|
|
3974
|
+
? editingTarget.sticky.id
|
|
3975
|
+
: editingTarget?.kind === 'standalone'
|
|
3976
|
+
? editingTarget.text.id
|
|
3977
|
+
: null
|
|
3735
3978
|
}
|
|
3736
|
-
canEditPhoto={
|
|
3737
|
-
selectedStrokes.length === 1 &&
|
|
3738
|
-
selectedStrokes[0]?.tool === 'image' &&
|
|
3739
|
-
/assets\/[0-9a-f]{8}\.[a-z0-9]+/i.test((selectedStrokes[0] as ImageStroke).href || '')
|
|
3740
|
-
}
|
|
3741
|
-
onAction={onMenuAction}
|
|
3742
|
-
onClose={() => setCtxMenu(null)}
|
|
3743
|
-
/>
|
|
3744
|
-
) : null}
|
|
3745
|
-
{visible && tool === 'move' ? <AnnotationResizeOverlay store={strokesStore} /> : null}
|
|
3746
|
-
{isActive ? (
|
|
3747
|
-
<AnnotationsChrome
|
|
3748
|
-
tool={tool}
|
|
3749
|
-
theme={theme}
|
|
3750
|
-
color={color}
|
|
3751
|
-
setColor={setColor}
|
|
3752
|
-
stickyColor={stickyColor}
|
|
3753
|
-
setStickyColor={setStickyColor}
|
|
3754
|
-
highlighterColor={highlighterColor}
|
|
3755
|
-
setHighlighterColor={setHighlighterColor}
|
|
3756
|
-
highlighterWidth={highlighterWidth}
|
|
3757
|
-
setHighlighterWidth={setHighlighterWidth}
|
|
3758
|
-
supportsFill={supportsFill}
|
|
3759
|
-
fill={fill}
|
|
3760
|
-
setFill={setFill}
|
|
3761
|
-
supportsThickness={supportsThickness}
|
|
3762
|
-
thickness={thickness}
|
|
3763
|
-
setThickness={setThickness}
|
|
3764
3979
|
/>
|
|
3765
|
-
|
|
3766
|
-
|
|
3980
|
+
{ctxMenu && annotSel ? (
|
|
3981
|
+
<AnnotationContextMenu
|
|
3982
|
+
pos={ctxMenu}
|
|
3983
|
+
selCount={annotSel.selectedIds.length}
|
|
3984
|
+
canUngroup={selectedStrokes.some((s) => (s.groupIds?.length ?? 0) > 0)}
|
|
3985
|
+
canReplace={
|
|
3986
|
+
selectedStrokes.length === 1 &&
|
|
3987
|
+
(selectedStrokes[0]?.tool === 'image' || selectedStrokes[0]?.tool === 'mediaref')
|
|
3988
|
+
}
|
|
3989
|
+
canEditPhoto={
|
|
3990
|
+
selectedStrokes.length === 1 &&
|
|
3991
|
+
selectedStrokes[0]?.tool === 'image' &&
|
|
3992
|
+
/assets\/[0-9a-f]{8}\.[a-z0-9]+/i.test((selectedStrokes[0] as ImageStroke).href || '')
|
|
3993
|
+
}
|
|
3994
|
+
onAction={onMenuAction}
|
|
3995
|
+
onClose={() => setCtxMenu(null)}
|
|
3996
|
+
/>
|
|
3997
|
+
) : null}
|
|
3998
|
+
{visible && tool === 'move' ? <AnnotationResizeOverlay store={strokesStore} /> : null}
|
|
3999
|
+
{isActive ? (
|
|
4000
|
+
<AnnotationsChrome
|
|
4001
|
+
tool={tool}
|
|
4002
|
+
theme={theme}
|
|
4003
|
+
color={color}
|
|
4004
|
+
setColor={setColor}
|
|
4005
|
+
stickyColor={stickyColor}
|
|
4006
|
+
setStickyColor={setStickyColor}
|
|
4007
|
+
highlighterColor={highlighterColor}
|
|
4008
|
+
setHighlighterColor={setHighlighterColor}
|
|
4009
|
+
highlighterWidth={highlighterWidth}
|
|
4010
|
+
setHighlighterWidth={setHighlighterWidth}
|
|
4011
|
+
supportsFill={supportsFill}
|
|
4012
|
+
fill={fill}
|
|
4013
|
+
setFill={setFill}
|
|
4014
|
+
supportsThickness={supportsThickness}
|
|
4015
|
+
thickness={thickness}
|
|
4016
|
+
setThickness={setThickness}
|
|
4017
|
+
/>
|
|
4018
|
+
) : null}
|
|
4019
|
+
</StrokesStoreContext.Provider>
|
|
4020
|
+
</AnnotationPipelineContext.Provider>
|
|
3767
4021
|
);
|
|
3768
4022
|
}
|
|
3769
4023
|
AnnotationsLayer.displayName = 'AnnotationsLayer';
|
|
@@ -3869,9 +4123,24 @@ function AnnotationsSvg({
|
|
|
3869
4123
|
inkColor,
|
|
3870
4124
|
onCommitEdit,
|
|
3871
4125
|
onCancelEdit,
|
|
4126
|
+
onDraftEdit,
|
|
4127
|
+
onChainEdit,
|
|
4128
|
+
editNotice,
|
|
4129
|
+
gestureState,
|
|
4130
|
+
unknownElements,
|
|
3872
4131
|
}: {
|
|
3873
4132
|
worldRef: ReturnType<typeof useWorldRefContext>;
|
|
3874
4133
|
strokes: readonly Stroke[];
|
|
4134
|
+
/** Elements of a type this build does not know — drawn as placeholders, never dropped. */
|
|
4135
|
+
unknownElements: readonly AnnotationElement[];
|
|
4136
|
+
/** Idle-typing draft of the open editor (Task 19). */
|
|
4137
|
+
onDraftEdit: (text: string) => void;
|
|
4138
|
+
/** ⌘Enter in a sticky / shape label: spawn the next sibling. */
|
|
4139
|
+
onChainEdit: (id: string) => void;
|
|
4140
|
+
/** The pointer pipeline's state (`idle`, `dragging`, `marquee`, …) — a tooling hook. */
|
|
4141
|
+
gestureState: string;
|
|
4142
|
+
/** A collaborator edited or deleted the element under the open editor. */
|
|
4143
|
+
editNotice: 'edited' | 'deleted' | null;
|
|
3875
4144
|
anchorsById: Map<string, AnchorHost>;
|
|
3876
4145
|
selectMode: boolean;
|
|
3877
4146
|
selectedStrokes: readonly Stroke[];
|
|
@@ -3905,41 +4174,102 @@ function AnnotationsSvg({
|
|
|
3905
4174
|
onCommitEdit: (text: string, fmt?: EditorFmt, measuredH?: number) => void;
|
|
3906
4175
|
onCancelEdit: () => void;
|
|
3907
4176
|
}) {
|
|
4177
|
+
const annotSel = useAnnotationSelectionOptional();
|
|
3908
4178
|
const [, force] = useState({});
|
|
3909
4179
|
useEffect(() => {
|
|
3910
4180
|
if (worldRef?.current) return;
|
|
3911
4181
|
const id = setTimeout(() => force({}), 0);
|
|
3912
4182
|
return () => clearTimeout(id);
|
|
3913
4183
|
}, [worldRef]);
|
|
4184
|
+
// DDR-242 AD7/AD8 — one node per element, text as HTML (annotations/ui/).
|
|
4185
|
+
const baseItems = useMemo(
|
|
4186
|
+
() => renderItemsFromStrokes(strokes, unknownElements),
|
|
4187
|
+
[strokes, unknownElements]
|
|
4188
|
+
);
|
|
4189
|
+
// The element whose text slot is open: a shape's label edits on the shape,
|
|
4190
|
+
// everything else on itself; `pending` is a caret not yet backed by an element.
|
|
4191
|
+
const editingId =
|
|
4192
|
+
editingTarget?.kind === 'anchored'
|
|
4193
|
+
? editingTarget.anchorId
|
|
4194
|
+
: editingTarget?.kind === 'sticky'
|
|
4195
|
+
? editingTarget.sticky.id
|
|
4196
|
+
: editingTarget?.kind === 'standalone'
|
|
4197
|
+
? editingTarget.text.id
|
|
4198
|
+
: editingTarget?.kind === 'section'
|
|
4199
|
+
? editingTarget.section.id
|
|
4200
|
+
: null;
|
|
4201
|
+
const pending =
|
|
4202
|
+
editingTarget?.kind === 'pending'
|
|
4203
|
+
? { x: editingTarget.x, y: editingTarget.y, color: inkColor, fontSize: DEFAULT_FONT_SIZE }
|
|
4204
|
+
: null;
|
|
4205
|
+
const sessionKey = editingTarget
|
|
4206
|
+
? `${editingTarget.kind}:${editingId ?? `${pending?.x},${pending?.y}`}`
|
|
4207
|
+
: null;
|
|
4208
|
+
// An element deleted by a collaborator mid-edit keeps drawing (its last
|
|
4209
|
+
// known form) while its editor is open, so the typed text is never lost.
|
|
4210
|
+
const lastEditItemRef = useRef<RenderItem | null>(null);
|
|
4211
|
+
let items = baseItems;
|
|
4212
|
+
if (editingId) {
|
|
4213
|
+
const live = baseItems.find((it) => it.el.id === editingId);
|
|
4214
|
+
if (live) lastEditItemRef.current = live;
|
|
4215
|
+
else if (lastEditItemRef.current?.el.id === editingId) {
|
|
4216
|
+
items = [...baseItems, lastEditItemRef.current];
|
|
4217
|
+
}
|
|
4218
|
+
}
|
|
4219
|
+
const draftRef = useRef(onDraftEdit);
|
|
4220
|
+
draftRef.current = onDraftEdit;
|
|
4221
|
+
const chainRef = useRef(onChainEdit);
|
|
4222
|
+
chainRef.current = onChainEdit;
|
|
4223
|
+
const commitRef = useRef(onCommitEdit);
|
|
4224
|
+
commitRef.current = onCommitEdit;
|
|
4225
|
+
const cancelRef = useRef(onCancelEdit);
|
|
4226
|
+
cancelRef.current = onCancelEdit;
|
|
4227
|
+
const chainKindRef = useRef<string | null>(null);
|
|
4228
|
+
chainKindRef.current = editingTarget?.kind ?? null;
|
|
4229
|
+
// One request object per edit session, so only the edited node re-renders.
|
|
4230
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: keyed on the session, callbacks via refs
|
|
4231
|
+
const edit = useMemo<EditRequest | null>(() => {
|
|
4232
|
+
if (!sessionKey) return null;
|
|
4233
|
+
const id = editingId;
|
|
4234
|
+
return {
|
|
4235
|
+
caretPoint: editCaretPoint,
|
|
4236
|
+
notice: editNotice,
|
|
4237
|
+
onDraft: pending ? undefined : (text: string) => draftRef.current(text),
|
|
4238
|
+
onCommit: (info) => {
|
|
4239
|
+
commitRef.current(info.text, info.fmt, info.measured.h);
|
|
4240
|
+
// ⌘Enter in a sticky or a shape label also spawns the next sibling.
|
|
4241
|
+
const kind = chainKindRef.current;
|
|
4242
|
+
if (info.chain && id && (kind === 'sticky' || kind === 'anchored')) {
|
|
4243
|
+
chainRef.current(id);
|
|
4244
|
+
}
|
|
4245
|
+
},
|
|
4246
|
+
onCancel: () => cancelRef.current(),
|
|
4247
|
+
};
|
|
4248
|
+
}, [sessionKey, editNotice]);
|
|
3914
4249
|
const target = worldRef?.current ?? null;
|
|
3915
4250
|
if (!target) return null;
|
|
3916
|
-
// A sticky whose body is being edited hides its read-only text so the
|
|
3917
|
-
// editor textarea (rendered below at the same bbox) isn't double-painted.
|
|
3918
|
-
const editingStickyId = editingTarget?.kind === 'sticky' ? editingTarget.sticky.id : null;
|
|
3919
|
-
// Same double-paint issue for a section being renamed — its label chip must
|
|
3920
|
-
// hide while the StandaloneTextEditor sits at the same spot, else the old
|
|
3921
|
-
// label reads through behind the new text.
|
|
3922
|
-
const editingSectionId = editingTarget?.kind === 'section' ? editingTarget.section.id : null;
|
|
3923
|
-
const anchoredExisting =
|
|
3924
|
-
editingTarget?.kind === 'anchored'
|
|
3925
|
-
? (strokes.find((s) => s.tool === 'text' && s.anchorId === editingTarget.anchorId) as
|
|
3926
|
-
| TextStroke
|
|
3927
|
-
| undefined)
|
|
3928
|
-
: undefined;
|
|
3929
|
-
// Same double-paint issue for a shape's anchored text — hide the read-only
|
|
3930
|
-
// <text> stroke while TextEditor sits at the same bbox (Phase 1 jump-fix
|
|
3931
|
-
// companion). Only applies once the TextStroke exists; a not-yet-created
|
|
3932
|
-
// one has nothing to hide.
|
|
3933
|
-
const editingAnchoredTextId = anchoredExisting?.id ?? null;
|
|
3934
|
-
// A standalone text being re-edited hides its read-only <text> too — the
|
|
3935
|
-
// editor paints at the same x/y, so leaving it visible double-paints (the
|
|
3936
|
-
// pre-Phase-2 "ghost" under the editor).
|
|
3937
|
-
const editingStandaloneTextId =
|
|
3938
|
-
editingTarget?.kind === 'standalone' ? editingTarget.text.id : null;
|
|
3939
4251
|
return (
|
|
3940
4252
|
<>
|
|
3941
4253
|
{createPortal(
|
|
3942
|
-
<
|
|
4254
|
+
<AnnotationScene
|
|
4255
|
+
items={items}
|
|
4256
|
+
interactive={selectMode}
|
|
4257
|
+
editingId={editingId}
|
|
4258
|
+
edit={edit}
|
|
4259
|
+
pending={pending}
|
|
4260
|
+
resolveAsset={resolveAssetHref}
|
|
4261
|
+
/>,
|
|
4262
|
+
target
|
|
4263
|
+
)}
|
|
4264
|
+
{createPortal(
|
|
4265
|
+
<svg
|
|
4266
|
+
className="dc-annot-svg"
|
|
4267
|
+
aria-hidden="true"
|
|
4268
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
4269
|
+
// DOM-driven E2E + tooling hook: the current annotation selection.
|
|
4270
|
+
data-selection={annotSel?.selectedIds.join(' ') ?? ''}
|
|
4271
|
+
data-annot-state={gestureState}
|
|
4272
|
+
>
|
|
3943
4273
|
<defs>
|
|
3944
4274
|
{/* Phase 21 — soft "lifted paper" drop shadow for sticky notes. */}
|
|
3945
4275
|
<filter id="dc-sticky-shadow" x="-25%" y="-25%" width="150%" height="170%">
|
|
@@ -3952,20 +4282,6 @@ function AnnotationsSvg({
|
|
|
3952
4282
|
/>
|
|
3953
4283
|
</filter>
|
|
3954
4284
|
</defs>
|
|
3955
|
-
{strokes.map((s) => (
|
|
3956
|
-
<StrokeNode
|
|
3957
|
-
key={s.id}
|
|
3958
|
-
stroke={s}
|
|
3959
|
-
anchorsById={anchorsById}
|
|
3960
|
-
interactive={selectMode}
|
|
3961
|
-
editing={
|
|
3962
|
-
s.id === editingStickyId ||
|
|
3963
|
-
s.id === editingSectionId ||
|
|
3964
|
-
s.id === editingAnchoredTextId ||
|
|
3965
|
-
s.id === editingStandaloneTextId
|
|
3966
|
-
}
|
|
3967
|
-
/>
|
|
3968
|
-
))}
|
|
3969
4285
|
{selectedStrokes.map((s) => (
|
|
3970
4286
|
<SelectionHalo
|
|
3971
4287
|
key={`halo-${s.id}`}
|
|
@@ -4077,114 +4393,10 @@ function AnnotationsSvg({
|
|
|
4077
4393
|
</svg>,
|
|
4078
4394
|
target
|
|
4079
4395
|
)}
|
|
4080
|
-
<AnnotEditors
|
|
4081
|
-
worldRef={worldRef}
|
|
4082
|
-
editingTarget={editingTarget}
|
|
4083
|
-
anchoredExisting={anchoredExisting}
|
|
4084
|
-
caretPoint={editCaretPoint}
|
|
4085
|
-
inkColor={inkColor}
|
|
4086
|
-
onCommitEdit={onCommitEdit}
|
|
4087
|
-
onCancelEdit={onCancelEdit}
|
|
4088
|
-
/>
|
|
4089
4396
|
</>
|
|
4090
4397
|
);
|
|
4091
4398
|
}
|
|
4092
4399
|
|
|
4093
|
-
/**
|
|
4094
|
-
* The active annotation text editor, rendered as PLAIN HTML absolutely
|
|
4095
|
-
* positioned in the world div — NOT as SVG foreignObject. Same architectural
|
|
4096
|
-
* move as MediaRefPlayers below (read its docblock): foreignObject content
|
|
4097
|
-
* under the transformed `.dc-world` mis-hit-tests clicks at most zoom levels
|
|
4098
|
-
* (WebKit + Chromium), and the `.dc-annot-svg` root's pointer-events:none
|
|
4099
|
-
* additionally swallowed in-editor clicks — so caret-at-click could never
|
|
4100
|
-
* work. HTML children of the transformed div hit-test correctly by
|
|
4101
|
-
* construction. World coords map 1:1 (the div carries the pan/zoom
|
|
4102
|
-
* transform), so each editor's old foreignObject x/y/w/h becomes left/top/
|
|
4103
|
-
* width/height verbatim. The [data-annot-editor] attr keeps document-capture
|
|
4104
|
-
* annotation handlers out (isAnnotEditorTarget guard, mirroring
|
|
4105
|
-
* [data-mediaref-player]).
|
|
4106
|
-
*/
|
|
4107
|
-
function AnnotEditors({
|
|
4108
|
-
worldRef,
|
|
4109
|
-
editingTarget,
|
|
4110
|
-
anchoredExisting,
|
|
4111
|
-
caretPoint,
|
|
4112
|
-
inkColor,
|
|
4113
|
-
onCommitEdit,
|
|
4114
|
-
onCancelEdit,
|
|
4115
|
-
}: {
|
|
4116
|
-
worldRef: ReturnType<typeof useWorldRefContext>;
|
|
4117
|
-
editingTarget: EditingTarget;
|
|
4118
|
-
anchoredExisting: TextStroke | undefined;
|
|
4119
|
-
caretPoint: { x: number; y: number } | null;
|
|
4120
|
-
inkColor: string;
|
|
4121
|
-
onCommitEdit: (text: string, fmt?: EditorFmt, measuredH?: number) => void;
|
|
4122
|
-
onCancelEdit: () => void;
|
|
4123
|
-
}) {
|
|
4124
|
-
const target = worldRef?.current ?? null;
|
|
4125
|
-
if (!target || !editingTarget) return null;
|
|
4126
|
-
return createPortal(
|
|
4127
|
-
<>
|
|
4128
|
-
{editingTarget.kind === 'anchored' ? (
|
|
4129
|
-
<TextEditor
|
|
4130
|
-
anchorId={editingTarget.anchorId}
|
|
4131
|
-
host={editingTarget.host}
|
|
4132
|
-
existing={anchoredExisting}
|
|
4133
|
-
caretPoint={caretPoint}
|
|
4134
|
-
onCommit={(_anchorId, text, fmt) => onCommitEdit(text, fmt)}
|
|
4135
|
-
onCancel={onCancelEdit}
|
|
4136
|
-
/>
|
|
4137
|
-
) : null}
|
|
4138
|
-
{editingTarget.kind === 'sticky' ? (
|
|
4139
|
-
<StickyEditor
|
|
4140
|
-
sticky={editingTarget.sticky}
|
|
4141
|
-
caretPoint={caretPoint}
|
|
4142
|
-
onCommit={onCommitEdit}
|
|
4143
|
-
onCancel={onCancelEdit}
|
|
4144
|
-
/>
|
|
4145
|
-
) : null}
|
|
4146
|
-
{editingTarget.kind === 'standalone' ? (
|
|
4147
|
-
<StandaloneTextEditor
|
|
4148
|
-
x={editingTarget.text.x ?? 0}
|
|
4149
|
-
y={editingTarget.text.y ?? 0}
|
|
4150
|
-
fontSize={editingTarget.text.fontSize}
|
|
4151
|
-
color={editingTarget.text.color}
|
|
4152
|
-
initialText={editingTarget.text.text}
|
|
4153
|
-
bold={editingTarget.text.bold}
|
|
4154
|
-
italic={editingTarget.text.italic}
|
|
4155
|
-
strike={editingTarget.text.strike}
|
|
4156
|
-
underline={editingTarget.text.underline}
|
|
4157
|
-
align={editingTarget.text.align ?? 'left'}
|
|
4158
|
-
listType={editingTarget.text.listType}
|
|
4159
|
-
caretPoint={caretPoint}
|
|
4160
|
-
onCommit={onCommitEdit}
|
|
4161
|
-
onCancel={onCancelEdit}
|
|
4162
|
-
/>
|
|
4163
|
-
) : null}
|
|
4164
|
-
{editingTarget.kind === 'pending' ? (
|
|
4165
|
-
<StandaloneTextEditor
|
|
4166
|
-
x={editingTarget.x}
|
|
4167
|
-
y={editingTarget.y}
|
|
4168
|
-
fontSize={DEFAULT_FONT_SIZE}
|
|
4169
|
-
color={inkColor}
|
|
4170
|
-
initialText=""
|
|
4171
|
-
onCommit={onCommitEdit}
|
|
4172
|
-
onCancel={onCancelEdit}
|
|
4173
|
-
/>
|
|
4174
|
-
) : null}
|
|
4175
|
-
{editingTarget.kind === 'section' ? (
|
|
4176
|
-
<SectionTitleEditor
|
|
4177
|
-
section={editingTarget.section}
|
|
4178
|
-
caretPoint={caretPoint}
|
|
4179
|
-
onCommit={onCommitEdit}
|
|
4180
|
-
onCancel={onCancelEdit}
|
|
4181
|
-
/>
|
|
4182
|
-
) : null}
|
|
4183
|
-
</>,
|
|
4184
|
-
target
|
|
4185
|
-
);
|
|
4186
|
-
}
|
|
4187
|
-
|
|
4188
4400
|
/**
|
|
4189
4401
|
* DDR-150 dogfood #8 — the mediaref chips' inline players, rendered as PLAIN
|
|
4190
4402
|
* HTML absolutely positioned in the world div (which carries the pan/zoom CSS
|
|
@@ -4275,567 +4487,6 @@ function MediaRefPlayers({
|
|
|
4275
4487
|
);
|
|
4276
4488
|
}
|
|
4277
4489
|
|
|
4278
|
-
// An empty flex-centered contentEditable has no line box for `justifyContent:
|
|
4279
|
-
// 'center'` to center — WebKit parks the caret at the box's top, then jumps it
|
|
4280
|
-
// to the true centered position the instant a real character exists. A
|
|
4281
|
-
// zero-width space gives the box real (invisible) content from mount, so the
|
|
4282
|
-
// caret starts centered and never jumps. Stripped back off on commit.
|
|
4283
|
-
const JUMP_SENTINEL = '\u200B';
|
|
4284
|
-
function stripJumpSentinel(text: string): string {
|
|
4285
|
-
return text.startsWith(JUMP_SENTINEL) ? text.slice(JUMP_SENTINEL.length) : text;
|
|
4286
|
-
}
|
|
4287
|
-
|
|
4288
|
-
// Unified caret style across every contentEditable text surface (annotation
|
|
4289
|
-
// editors here + the artboard inline editor's `.dc-text-editing` CSS in
|
|
4290
|
-
// canvas-shell.tsx use the SAME `--maude-hud-accent` so the caret reads the
|
|
4291
|
-
// same everywhere). An explicit caretColor makes the caret visible against any
|
|
4292
|
-
// background. NOTE: deliberately NO `transform: translateZ(0)` / `will-change`
|
|
4293
|
-
// here \u2014 promoting a contentEditable onto its own compositing layer is a known
|
|
4294
|
-
// WebKit caret-BLINK killer (the compositor caches the layer and never repaints
|
|
4295
|
-
// the blink, so the caret shows as a static line). A prior dogfood pass added
|
|
4296
|
-
// translateZ(0) to "restore" the caret and instead froze its blink; removing it
|
|
4297
|
-
// lets WebKit run the native blink. The editor already lives inside the
|
|
4298
|
-
// transformed `.dc-world`, but that ancestor transform alone does not stop the
|
|
4299
|
-
// blink \u2014 only a compositing trigger ON the editable does.
|
|
4300
|
-
const CARET_FIX_STYLE = {
|
|
4301
|
-
caretColor: 'var(--maude-hud-accent, #4a63e7)',
|
|
4302
|
-
} as const;
|
|
4303
|
-
|
|
4304
|
-
/**
|
|
4305
|
-
* Phase 3 (unified-text-editing) — shared caret behavior for every annotation
|
|
4306
|
-
* editor. On mount: focus, place a collapsed caret at the entry click point
|
|
4307
|
-
* (`placeCaretAt`, the SAME chain the artboard's enterEditModeAt uses;
|
|
4308
|
-
* keyboard entry has no point → select-all, the rename convention), and mount
|
|
4309
|
-
* the custom blinking caret (text-caret.ts) for the session. Afterwards every
|
|
4310
|
-
* plain in-editor click re-places the caret from its coordinates on pointerup
|
|
4311
|
-
* so repositioning never depends on native hit-testing (synthetic e2e clicks
|
|
4312
|
-
* take the same path — untrusted events get no UA caret action at all).
|
|
4313
|
-
* Shift-clicks and drag-selections keep native behavior; ⌘A stays native.
|
|
4314
|
-
*/
|
|
4315
|
-
function useEditorCaret(
|
|
4316
|
-
ref: RefObject<HTMLDivElement | null>,
|
|
4317
|
-
caretPoint: { x: number; y: number } | null | undefined
|
|
4318
|
-
) {
|
|
4319
|
-
const entryPointRef = useRef(caretPoint ?? null);
|
|
4320
|
-
useEffect(() => {
|
|
4321
|
-
const el = ref.current;
|
|
4322
|
-
if (!el) return;
|
|
4323
|
-
el.focus();
|
|
4324
|
-
placeCaretAt(el, window, entryPointRef.current ?? undefined);
|
|
4325
|
-
return mountCaret(el, window);
|
|
4326
|
-
}, [ref]);
|
|
4327
|
-
const downRef = useRef<{ x: number; y: number } | null>(null);
|
|
4328
|
-
const onPointerDown = useCallback((e: ReactPointerEvent<HTMLDivElement>) => {
|
|
4329
|
-
downRef.current = e.button === 0 && !e.shiftKey ? { x: e.clientX, y: e.clientY } : null;
|
|
4330
|
-
}, []);
|
|
4331
|
-
const onPointerUp = useCallback(
|
|
4332
|
-
(e: ReactPointerEvent<HTMLDivElement>) => {
|
|
4333
|
-
const d = downRef.current;
|
|
4334
|
-
downRef.current = null;
|
|
4335
|
-
if (!d || e.shiftKey) return;
|
|
4336
|
-
// A real drag is a range-selection gesture — leave it to the engine.
|
|
4337
|
-
if (Math.hypot(e.clientX - d.x, e.clientY - d.y) > 3) return;
|
|
4338
|
-
const el = ref.current;
|
|
4339
|
-
if (!el) return;
|
|
4340
|
-
const sel = window.getSelection();
|
|
4341
|
-
if (sel && !sel.isCollapsed) return; // double-click word-select etc.
|
|
4342
|
-
placeCaretAt(el, window, { x: e.clientX, y: e.clientY }, false);
|
|
4343
|
-
},
|
|
4344
|
-
[ref]
|
|
4345
|
-
);
|
|
4346
|
-
return { onPointerDown, onPointerUp };
|
|
4347
|
-
}
|
|
4348
|
-
|
|
4349
|
-
/**
|
|
4350
|
-
* issue-106 — the body an annotation editor hands React as its contentEditable
|
|
4351
|
-
* children, snapshotted for the life of ONE edit session.
|
|
4352
|
-
*
|
|
4353
|
-
* These editors are uncontrolled: the user types straight into the DOM. If the
|
|
4354
|
-
* rendered children value changes while the session is open, React writes the
|
|
4355
|
-
* new string into the live node and everything typed since — line breaks
|
|
4356
|
-
* included — is gone. That is exactly what a peer's commit arriving over
|
|
4357
|
-
* shared-doc sync (DDR-064) used to do, and what the hazard note above
|
|
4358
|
-
* `useEditorFormat` warns about for local mutations. Snapshotting per target id
|
|
4359
|
-
* makes the children a constant for the session, so no store update can reach
|
|
4360
|
-
* the DOM; the commit path reads `innerText` from the node itself, which is the
|
|
4361
|
-
* user's real content either way. Re-keys when the editor is reused for a
|
|
4362
|
-
* different stroke.
|
|
4363
|
-
*
|
|
4364
|
-
* `listType` is snapshotted through the same hook: the body is prefixed with
|
|
4365
|
-
* list markers at OPEN time and stripped again at commit, so if a peer flipped
|
|
4366
|
-
* the stroke's list style mid-session the strip would run a different rule than
|
|
4367
|
-
* the prefix did — turning a bullet into a literal "• " in the stored text, or
|
|
4368
|
-
* eating a leading "1. " the user actually typed.
|
|
4369
|
-
*/
|
|
4370
|
-
function useSessionValue<T>(id: string, compute: () => T): T {
|
|
4371
|
-
const ref = useRef<{ id: string; value: T } | null>(null);
|
|
4372
|
-
if (ref.current === null || ref.current.id !== id) ref.current = { id, value: compute() };
|
|
4373
|
-
return ref.current.value;
|
|
4374
|
-
}
|
|
4375
|
-
|
|
4376
|
-
function TextEditor({
|
|
4377
|
-
anchorId,
|
|
4378
|
-
host,
|
|
4379
|
-
existing,
|
|
4380
|
-
caretPoint,
|
|
4381
|
-
onCommit,
|
|
4382
|
-
onCancel,
|
|
4383
|
-
}: {
|
|
4384
|
-
anchorId: string;
|
|
4385
|
-
host: AnchorHost | null;
|
|
4386
|
-
existing: TextStroke | undefined;
|
|
4387
|
-
caretPoint?: { x: number; y: number } | null;
|
|
4388
|
-
onCommit: (anchorId: string, text: string, fmt?: EditorFmt) => void;
|
|
4389
|
-
onCancel: () => void;
|
|
4390
|
-
}) {
|
|
4391
|
-
const ref = useRef<HTMLDivElement | null>(null);
|
|
4392
|
-
// Show list markers WHILE editing so the read↔edit swap doesn't flicker
|
|
4393
|
-
// (item 4c) — stripped back to raw text on commit.
|
|
4394
|
-
const sessionListType = useSessionValue(anchorId, () => existing?.listType);
|
|
4395
|
-
const initial = useSessionValue(anchorId, () =>
|
|
4396
|
-
listPrefixedBody(existing?.text ?? '', sessionListType)
|
|
4397
|
-
);
|
|
4398
|
-
// Cmd/Ctrl+B/I/U formatting while editing (item 4d).
|
|
4399
|
-
const {
|
|
4400
|
-
fmtRef,
|
|
4401
|
-
style: fmtStyle,
|
|
4402
|
-
onFormatKey,
|
|
4403
|
-
} = useEditorFormat({
|
|
4404
|
-
bold: existing?.bold,
|
|
4405
|
-
italic: existing?.italic,
|
|
4406
|
-
underline: existing?.underline,
|
|
4407
|
-
strike: existing?.strike,
|
|
4408
|
-
fontSize: existing?.fontSize ?? DEFAULT_FONT_SIZE,
|
|
4409
|
-
align: existing?.align ?? 'center',
|
|
4410
|
-
});
|
|
4411
|
-
// Both commit sites below (outside-click + Cmd/Ctrl+Enter) need the same
|
|
4412
|
-
// sentinel-strip + marker-strip pipeline. Memoized so the outside-click
|
|
4413
|
-
// effect below can depend on it directly instead of its own copy of
|
|
4414
|
-
// existing?.listType (lint/correctness/useExhaustiveDependencies).
|
|
4415
|
-
const toCommittedText = useCallback(
|
|
4416
|
-
(raw: string) => stripEditorMarkers(stripJumpSentinel(raw), sessionListType),
|
|
4417
|
-
[sessionListType]
|
|
4418
|
-
);
|
|
4419
|
-
|
|
4420
|
-
// Caret-at-click on entry + custom blinking caret + click re-placement
|
|
4421
|
-
// (select-all only for keyboard entry — see useEditorCaret).
|
|
4422
|
-
const caretHandlers = useEditorCaret(ref, caretPoint);
|
|
4423
|
-
|
|
4424
|
-
// Commit on outside click; cancel-on-Esc handled in onKeyDown below.
|
|
4425
|
-
useEffect(() => {
|
|
4426
|
-
if (typeof document === 'undefined') return;
|
|
4427
|
-
const onDown = (e: PointerEvent) => {
|
|
4428
|
-
if (isMediaPlayerTarget(e)) return; // mediaref inline player owns this event
|
|
4429
|
-
const el = ref.current;
|
|
4430
|
-
if (!el) return;
|
|
4431
|
-
if (el.contains(e.target as Node)) return;
|
|
4432
|
-
// FigJam v3 — the edit-mode text toolbar drives THIS editor; clicking
|
|
4433
|
-
// it must not commit-and-close the session.
|
|
4434
|
-
if ((e.target as Element | null)?.closest?.('.dc-annot-ctx')) return;
|
|
4435
|
-
onCommit(anchorId, toCommittedText(el.innerText || ''), fmtRef.current);
|
|
4436
|
-
};
|
|
4437
|
-
document.addEventListener('pointerdown', onDown, true);
|
|
4438
|
-
return () => document.removeEventListener('pointerdown', onDown, true);
|
|
4439
|
-
}, [anchorId, onCommit, fmtRef, toCommittedText]);
|
|
4440
|
-
|
|
4441
|
-
if (!host) return null;
|
|
4442
|
-
const bbox = strokeBBox(host);
|
|
4443
|
-
if (!bbox) return null;
|
|
4444
|
-
const fontSize = existing?.fontSize ?? DEFAULT_FONT_SIZE;
|
|
4445
|
-
// Phase 24 — match the committed render's bold / strike / align (anchored
|
|
4446
|
-
// default align = centre).
|
|
4447
|
-
const align = existing?.align ?? 'center';
|
|
4448
|
-
return (
|
|
4449
|
-
// Plain HTML host in the world div (NOT foreignObject — see AnnotEditors'
|
|
4450
|
-
// docblock): world coords map 1:1 to left/top, clicks hit-test correctly.
|
|
4451
|
-
<div
|
|
4452
|
-
data-annot-editor="1"
|
|
4453
|
-
style={{
|
|
4454
|
-
position: 'absolute',
|
|
4455
|
-
left: bbox.x,
|
|
4456
|
-
top: bbox.y,
|
|
4457
|
-
width: Math.max(20, bbox.w),
|
|
4458
|
-
height: Math.max(20, bbox.h),
|
|
4459
|
-
zIndex: 5,
|
|
4460
|
-
}}
|
|
4461
|
-
>
|
|
4462
|
-
<div
|
|
4463
|
-
ref={ref}
|
|
4464
|
-
className="dc-annot-editor"
|
|
4465
|
-
contentEditable
|
|
4466
|
-
suppressContentEditableWarning
|
|
4467
|
-
aria-label="Edit annotation text"
|
|
4468
|
-
style={{
|
|
4469
|
-
width: '100%',
|
|
4470
|
-
height: '100%',
|
|
4471
|
-
// Column flex (NOT row) so contentEditable line breaks stack
|
|
4472
|
-
// vertically; justify-center keeps the block vertically centred in
|
|
4473
|
-
// the host. The pre-Task-5 row-flex laid lines out side-by-side
|
|
4474
|
-
// (item 4a — the mangled multi-line look).
|
|
4475
|
-
display: 'flex',
|
|
4476
|
-
flexDirection: 'column',
|
|
4477
|
-
justifyContent: 'center',
|
|
4478
|
-
alignItems: align === 'left' ? 'flex-start' : align === 'right' ? 'flex-end' : 'center',
|
|
4479
|
-
padding: '0 8px',
|
|
4480
|
-
boxSizing: 'border-box',
|
|
4481
|
-
textAlign: align,
|
|
4482
|
-
whiteSpace: 'pre-wrap',
|
|
4483
|
-
color: existing?.color ?? '#1a1a1a',
|
|
4484
|
-
fontSize: `${fontSize}px`,
|
|
4485
|
-
fontFamily: 'var(--u-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace)',
|
|
4486
|
-
...fmtStyle,
|
|
4487
|
-
lineHeight: 1.25,
|
|
4488
|
-
outline: 'none',
|
|
4489
|
-
background: 'transparent',
|
|
4490
|
-
cursor: 'text',
|
|
4491
|
-
...CARET_FIX_STYLE,
|
|
4492
|
-
}}
|
|
4493
|
-
onPointerDown={caretHandlers.onPointerDown}
|
|
4494
|
-
onPointerUp={caretHandlers.onPointerUp}
|
|
4495
|
-
onKeyDown={(e) => {
|
|
4496
|
-
if (onFormatKey(e)) return; // Cmd/Ctrl+B/I/U
|
|
4497
|
-
if (e.key === 'Escape') {
|
|
4498
|
-
e.preventDefault();
|
|
4499
|
-
onCancel();
|
|
4500
|
-
return;
|
|
4501
|
-
}
|
|
4502
|
-
// issue-106 — Shift+Enter ADDS a line. On keyboard entry the whole
|
|
4503
|
-
// body is still select-all'd (the retype convention), so letting the
|
|
4504
|
-
// break replace the selection wiped the text. Collapse that entry
|
|
4505
|
-
// selection to its end first; a user's own partial selection is left
|
|
4506
|
-
// alone. See collapseEntrySelectAll.
|
|
4507
|
-
if (e.key === 'Enter' && e.shiftKey && ref.current) {
|
|
4508
|
-
collapseEntrySelectAll(ref.current, window);
|
|
4509
|
-
}
|
|
4510
|
-
// Unified across every text surface: plain Enter commits,
|
|
4511
|
-
// Shift+Enter inserts a newline (falls through untouched). ⌘/Ctrl
|
|
4512
|
-
// +Enter also commits AND chains a connected sibling (quick-create).
|
|
4513
|
-
if (e.key === 'Enter' && !e.shiftKey) {
|
|
4514
|
-
e.preventDefault();
|
|
4515
|
-
const el = ref.current;
|
|
4516
|
-
onCommit(anchorId, toCommittedText(el?.innerText || ''), fmtRef.current);
|
|
4517
|
-
if (e.metaKey || e.ctrlKey) {
|
|
4518
|
-
document.dispatchEvent(
|
|
4519
|
-
new CustomEvent('maude:chain-create', { detail: { id: anchorId } })
|
|
4520
|
-
);
|
|
4521
|
-
}
|
|
4522
|
-
}
|
|
4523
|
-
}}
|
|
4524
|
-
>
|
|
4525
|
-
{initial || JUMP_SENTINEL}
|
|
4526
|
-
</div>
|
|
4527
|
-
</div>
|
|
4528
|
-
);
|
|
4529
|
-
}
|
|
4530
|
-
|
|
4531
|
-
// Phase 21 — sticky body editor, hosted as plain HTML at the card's bbox in
|
|
4532
|
-
// the world div (word-wrap + zoom come from the div's own box + the world
|
|
4533
|
-
// transform). Commit on blur, cancel on Esc; Enter commits, Shift+Enter
|
|
4534
|
-
// inserts a newline.
|
|
4535
|
-
function StickyEditor({
|
|
4536
|
-
sticky,
|
|
4537
|
-
caretPoint,
|
|
4538
|
-
onCommit,
|
|
4539
|
-
onCancel,
|
|
4540
|
-
}: {
|
|
4541
|
-
sticky: StickyStroke;
|
|
4542
|
-
caretPoint?: { x: number; y: number } | null;
|
|
4543
|
-
onCommit: (text: string, fmt?: EditorFmt, measuredH?: number) => void;
|
|
4544
|
-
onCancel: () => void;
|
|
4545
|
-
}) {
|
|
4546
|
-
// A flex-centered contentEditable (NOT a textarea) so the edit view matches
|
|
4547
|
-
// the committed `.dc-sticky-body` exactly — text stays centered, no jump on
|
|
4548
|
-
// commit. Multi-line: Shift+Enter inserts a line break, plain Enter commits;
|
|
4549
|
-
// Esc cancels; blur commits; Cmd/Ctrl+B/I/U format (unified with the others).
|
|
4550
|
-
const ref = useRef<HTMLDivElement | null>(null);
|
|
4551
|
-
const doneRef = useRef(false);
|
|
4552
|
-
const sessionListType = useSessionValue(sticky.id, () => sticky.listType);
|
|
4553
|
-
const sessionBody = useSessionValue(sticky.id, () => stickyBodyText(sticky));
|
|
4554
|
-
const {
|
|
4555
|
-
fmtRef,
|
|
4556
|
-
style: fmtStyle,
|
|
4557
|
-
onFormatKey,
|
|
4558
|
-
} = useEditorFormat({
|
|
4559
|
-
bold: sticky.bold,
|
|
4560
|
-
italic: sticky.italic,
|
|
4561
|
-
underline: sticky.underline,
|
|
4562
|
-
strike: sticky.strike,
|
|
4563
|
-
fontSize: sticky.fontSize,
|
|
4564
|
-
align: sticky.align ?? 'left',
|
|
4565
|
-
});
|
|
4566
|
-
const commit = () => {
|
|
4567
|
-
if (doneRef.current) return;
|
|
4568
|
-
doneRef.current = true;
|
|
4569
|
-
const el = ref.current;
|
|
4570
|
-
// issue-106 — the editor IS the measurement: same class, same width, same
|
|
4571
|
-
// font as the committed body, and laid out in world units (the pan/zoom
|
|
4572
|
-
// transform on `.dc-world` doesn't change layout), so its scrollHeight is
|
|
4573
|
-
// the height the card needs. No offscreen measuring rig required.
|
|
4574
|
-
onCommit(
|
|
4575
|
-
stripEditorMarkers(el?.innerText ?? '', sessionListType),
|
|
4576
|
-
fmtRef.current,
|
|
4577
|
-
el?.scrollHeight
|
|
4578
|
-
);
|
|
4579
|
-
};
|
|
4580
|
-
// FigJam v3 — a toolbar click steals focus for a tick; don't treat it as
|
|
4581
|
-
// "done editing" (the button's onMouseDown preventDefault usually stops the
|
|
4582
|
-
// blur, this guards the browsers where it doesn't).
|
|
4583
|
-
const onBlur = (e: { relatedTarget?: EventTarget | null }) => {
|
|
4584
|
-
const to = e.relatedTarget as Element | null;
|
|
4585
|
-
if (to?.closest?.('.dc-annot-ctx')) return;
|
|
4586
|
-
commit();
|
|
4587
|
-
};
|
|
4588
|
-
// Caret-at-click on entry + custom blinking caret + click re-placement.
|
|
4589
|
-
const caretHandlers = useEditorCaret(ref, caretPoint);
|
|
4590
|
-
const x = Math.min(sticky.x, sticky.x + sticky.w);
|
|
4591
|
-
const y = Math.min(sticky.y, sticky.y + sticky.h);
|
|
4592
|
-
const w = Math.abs(sticky.w);
|
|
4593
|
-
const h = Math.abs(sticky.h);
|
|
4594
|
-
const r = sticky.cornerRadius ?? STICKY_CORNER_RADIUS;
|
|
4595
|
-
return (
|
|
4596
|
-
<div
|
|
4597
|
-
data-annot-editor="1"
|
|
4598
|
-
style={{
|
|
4599
|
-
position: 'absolute',
|
|
4600
|
-
left: x,
|
|
4601
|
-
top: y,
|
|
4602
|
-
width: w,
|
|
4603
|
-
// issue-106 — NOT a fixed `height: h`. The card clips at `overflow:
|
|
4604
|
-
// hidden`, so on a sticky that is already full the line Shift+Enter
|
|
4605
|
-
// inserts landed outside the box: the keystroke worked, nothing moved
|
|
4606
|
-
// on screen, and it read as "shift+enter does nothing". While the
|
|
4607
|
-
// editor is open it grows downward instead, painting its own paper so
|
|
4608
|
-
// the overflow still reads as the note (the SVG card behind is still
|
|
4609
|
-
// the old size until the commit below persists the grown height).
|
|
4610
|
-
minHeight: h,
|
|
4611
|
-
zIndex: 5,
|
|
4612
|
-
// `backgroundColor`, never the `background` shorthand: `color` comes
|
|
4613
|
-
// verbatim from a synced stroke's `fill` (peer-controlled, and the
|
|
4614
|
-
// sanitizer does not touch `fill`), and the shorthand would accept a
|
|
4615
|
-
// `url(...)` — an outbound request from the studio origin the moment a
|
|
4616
|
-
// note is opened. `background-color` cannot take one.
|
|
4617
|
-
backgroundColor: sticky.color,
|
|
4618
|
-
// Mirrors stickyCornerPath: TL/TR/BL rounded, BR sharp.
|
|
4619
|
-
borderRadius: `${r}px ${r}px 0 ${r}px`,
|
|
4620
|
-
}}
|
|
4621
|
-
>
|
|
4622
|
-
<div
|
|
4623
|
-
ref={ref}
|
|
4624
|
-
className="dc-annot-editor dc-sticky-body"
|
|
4625
|
-
contentEditable
|
|
4626
|
-
suppressContentEditableWarning
|
|
4627
|
-
aria-label="Edit sticky note text"
|
|
4628
|
-
style={{
|
|
4629
|
-
...stickyBodyStyle(sticky),
|
|
4630
|
-
...fmtStyle,
|
|
4631
|
-
outline: 'none',
|
|
4632
|
-
cursor: 'text',
|
|
4633
|
-
...CARET_FIX_STYLE,
|
|
4634
|
-
// Beats `.dc-sticky-body { height: 100%; overflow: hidden }` for the
|
|
4635
|
-
// duration of the edit — see the wrapper note above.
|
|
4636
|
-
height: 'auto',
|
|
4637
|
-
minHeight: h,
|
|
4638
|
-
overflow: 'visible',
|
|
4639
|
-
}}
|
|
4640
|
-
onBlur={onBlur}
|
|
4641
|
-
onPointerDown={caretHandlers.onPointerDown}
|
|
4642
|
-
onPointerUp={caretHandlers.onPointerUp}
|
|
4643
|
-
onKeyDown={(e) => {
|
|
4644
|
-
if (onFormatKey(e)) return; // Cmd/Ctrl+B/I/U
|
|
4645
|
-
if (e.key === 'Escape') {
|
|
4646
|
-
e.preventDefault();
|
|
4647
|
-
doneRef.current = true; // suppress the unmount blur-commit
|
|
4648
|
-
onCancel();
|
|
4649
|
-
return;
|
|
4650
|
-
}
|
|
4651
|
-
// issue-106 — see the same guard in TextEditor: Shift+Enter must add
|
|
4652
|
-
// a line, never consume the entry select-all and delete the body.
|
|
4653
|
-
if (e.key === 'Enter' && e.shiftKey && ref.current) {
|
|
4654
|
-
collapseEntrySelectAll(ref.current, window);
|
|
4655
|
-
}
|
|
4656
|
-
// Unified: plain Enter commits, Shift+Enter inserts a newline.
|
|
4657
|
-
// ⌘/Ctrl+Enter also commits AND chains the next sticky beside it.
|
|
4658
|
-
if (e.key === 'Enter' && !e.shiftKey) {
|
|
4659
|
-
e.preventDefault();
|
|
4660
|
-
commit();
|
|
4661
|
-
if (e.metaKey || e.ctrlKey) {
|
|
4662
|
-
document.dispatchEvent(
|
|
4663
|
-
new CustomEvent('maude:chain-create', { detail: { id: sticky.id } })
|
|
4664
|
-
);
|
|
4665
|
-
}
|
|
4666
|
-
}
|
|
4667
|
-
}}
|
|
4668
|
-
>
|
|
4669
|
-
{/* Show the list markers while editing (item 4c) so the read↔edit swap
|
|
4670
|
-
doesn't flicker; stripped back to raw text on commit. */}
|
|
4671
|
-
{sessionBody}
|
|
4672
|
-
</div>
|
|
4673
|
-
</div>
|
|
4674
|
-
);
|
|
4675
|
-
}
|
|
4676
|
-
|
|
4677
|
-
// Phase 21 — standalone text editor. A single-line contentEditable box anchored
|
|
4678
|
-
// at the world (x, y). Enter / blur / outside-click commit; Esc cancels.
|
|
4679
|
-
function StandaloneTextEditor({
|
|
4680
|
-
x,
|
|
4681
|
-
y,
|
|
4682
|
-
fontSize,
|
|
4683
|
-
color,
|
|
4684
|
-
initialText,
|
|
4685
|
-
bold,
|
|
4686
|
-
italic,
|
|
4687
|
-
strike,
|
|
4688
|
-
underline,
|
|
4689
|
-
align,
|
|
4690
|
-
listType,
|
|
4691
|
-
singleLine,
|
|
4692
|
-
boxStyle,
|
|
4693
|
-
caretPoint,
|
|
4694
|
-
onCommit,
|
|
4695
|
-
onCancel,
|
|
4696
|
-
}: {
|
|
4697
|
-
x: number;
|
|
4698
|
-
y: number;
|
|
4699
|
-
fontSize: number;
|
|
4700
|
-
color: string;
|
|
4701
|
-
initialText: string;
|
|
4702
|
-
bold?: boolean;
|
|
4703
|
-
italic?: boolean;
|
|
4704
|
-
strike?: boolean;
|
|
4705
|
-
underline?: boolean;
|
|
4706
|
-
align?: TextAlign;
|
|
4707
|
-
listType?: ListType;
|
|
4708
|
-
/** The click that opened the editor — caret lands there (Phase 3). */
|
|
4709
|
-
caretPoint?: { x: number; y: number } | null;
|
|
4710
|
-
/** A one-line field (e.g. a section title rename) — plain Enter commits
|
|
4711
|
-
* instead of inserting a newline, matching a native text-input's Enter. */
|
|
4712
|
-
singleLine?: boolean;
|
|
4713
|
-
/** Extra style merged onto the editable box — e.g. a section rename wants
|
|
4714
|
-
* the same chip background/padding/radius the read-only label chip has. */
|
|
4715
|
-
boxStyle?: CSSProperties;
|
|
4716
|
-
onCommit: (text: string, fmt?: EditorFmt) => void;
|
|
4717
|
-
onCancel: () => void;
|
|
4718
|
-
}) {
|
|
4719
|
-
const ref = useRef<HTMLDivElement | null>(null);
|
|
4720
|
-
// Cmd/Ctrl+B/I/U formatting while editing (item 4d).
|
|
4721
|
-
const {
|
|
4722
|
-
fmtRef,
|
|
4723
|
-
style: fmtStyle,
|
|
4724
|
-
onFormatKey,
|
|
4725
|
-
} = useEditorFormat({
|
|
4726
|
-
bold,
|
|
4727
|
-
italic,
|
|
4728
|
-
underline,
|
|
4729
|
-
strike,
|
|
4730
|
-
fontSize,
|
|
4731
|
-
align: align ?? 'left',
|
|
4732
|
-
});
|
|
4733
|
-
// Single-fire commit guard — outside-click + blur can both fire in one tick;
|
|
4734
|
-
// without this the text would commit twice (two undo records). Markers shown
|
|
4735
|
-
// while editing (item 4c) are stripped back to raw text here on commit.
|
|
4736
|
-
const doneRef = useRef(false);
|
|
4737
|
-
const commitOnce = useCallback(
|
|
4738
|
-
(text: string) => {
|
|
4739
|
-
if (doneRef.current) return;
|
|
4740
|
-
doneRef.current = true;
|
|
4741
|
-
onCommit(stripEditorMarkers(text, listType), fmtRef.current);
|
|
4742
|
-
},
|
|
4743
|
-
[onCommit, listType, fmtRef]
|
|
4744
|
-
);
|
|
4745
|
-
// Caret-at-click on entry + custom blinking caret + click re-placement.
|
|
4746
|
-
const caretHandlers = useEditorCaret(ref, caretPoint);
|
|
4747
|
-
// Commit on outside click.
|
|
4748
|
-
useEffect(() => {
|
|
4749
|
-
if (typeof document === 'undefined') return;
|
|
4750
|
-
const onDown = (e: PointerEvent) => {
|
|
4751
|
-
if (isMediaPlayerTarget(e)) return; // mediaref inline player owns this event
|
|
4752
|
-
const el = ref.current;
|
|
4753
|
-
if (!el) return;
|
|
4754
|
-
if (el.contains(e.target as Node)) return;
|
|
4755
|
-
// FigJam v3 — clicks into the edit-mode text toolbar keep the session.
|
|
4756
|
-
if ((e.target as Element | null)?.closest?.('.dc-annot-ctx')) return;
|
|
4757
|
-
commitOnce(el.innerText || '');
|
|
4758
|
-
};
|
|
4759
|
-
document.addEventListener('pointerdown', onDown, true);
|
|
4760
|
-
return () => document.removeEventListener('pointerdown', onDown, true);
|
|
4761
|
-
}, [commitOnce]);
|
|
4762
|
-
return (
|
|
4763
|
-
// Generous box so multi-line text isn't clipped while typing (item 4a).
|
|
4764
|
-
// The host passes pointer events through (empty area is not the editor —
|
|
4765
|
-
// outside-click must still commit); only the editable itself is
|
|
4766
|
-
// interactive, so clicks in it place the caret.
|
|
4767
|
-
<div
|
|
4768
|
-
data-annot-editor="1"
|
|
4769
|
-
style={{
|
|
4770
|
-
position: 'absolute',
|
|
4771
|
-
left: x,
|
|
4772
|
-
top: y,
|
|
4773
|
-
width: 640,
|
|
4774
|
-
height: 480,
|
|
4775
|
-
zIndex: 5,
|
|
4776
|
-
pointerEvents: 'none',
|
|
4777
|
-
}}
|
|
4778
|
-
>
|
|
4779
|
-
<div
|
|
4780
|
-
ref={ref}
|
|
4781
|
-
className="dc-annot-editor"
|
|
4782
|
-
contentEditable
|
|
4783
|
-
suppressContentEditableWarning
|
|
4784
|
-
aria-label="Edit text"
|
|
4785
|
-
style={{
|
|
4786
|
-
display: 'inline-block',
|
|
4787
|
-
minWidth: '8px',
|
|
4788
|
-
// The pass-through host (above) is inert — re-enable events HERE so
|
|
4789
|
-
// in-editor clicks place the caret instead of falling through.
|
|
4790
|
-
pointerEvents: 'auto',
|
|
4791
|
-
// pre-wrap so Enter inserts a real newline (multi-line text), not a
|
|
4792
|
-
// commit; long lines also wrap within the box.
|
|
4793
|
-
whiteSpace: 'pre-wrap',
|
|
4794
|
-
padding: '0 2px',
|
|
4795
|
-
color,
|
|
4796
|
-
fontSize: `${fontSize}px`,
|
|
4797
|
-
fontFamily: 'var(--u-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace)',
|
|
4798
|
-
...fmtStyle,
|
|
4799
|
-
textAlign: align ?? 'left',
|
|
4800
|
-
lineHeight: TEXT_LINE_HEIGHT,
|
|
4801
|
-
outline: 'none',
|
|
4802
|
-
background: 'transparent',
|
|
4803
|
-
cursor: 'text',
|
|
4804
|
-
...CARET_FIX_STYLE,
|
|
4805
|
-
...boxStyle,
|
|
4806
|
-
}}
|
|
4807
|
-
onBlur={() => commitOnce(ref.current?.innerText || '')}
|
|
4808
|
-
onPointerDown={caretHandlers.onPointerDown}
|
|
4809
|
-
onPointerUp={caretHandlers.onPointerUp}
|
|
4810
|
-
onKeyDown={(e) => {
|
|
4811
|
-
if (onFormatKey(e)) return; // Cmd/Ctrl+B/I/U
|
|
4812
|
-
if (e.key === 'Escape') {
|
|
4813
|
-
e.preventDefault();
|
|
4814
|
-
// Mark done so the unmount blur that follows doesn't commit.
|
|
4815
|
-
doneRef.current = true;
|
|
4816
|
-
onCancel();
|
|
4817
|
-
return;
|
|
4818
|
-
}
|
|
4819
|
-
// issue-106 — same guard as the other two editors, for the multi-line
|
|
4820
|
-
// case only (a singleLine field commits on Shift+Enter anyway).
|
|
4821
|
-
if (e.key === 'Enter' && e.shiftKey && !singleLine && ref.current) {
|
|
4822
|
-
collapseEntrySelectAll(ref.current, window);
|
|
4823
|
-
}
|
|
4824
|
-
// Unified: plain Enter commits, Shift+Enter inserts a newline. A
|
|
4825
|
-
// singleLine field (section rename) is a title — Shift+Enter commits
|
|
4826
|
-
// too rather than adding a newline the one-line chip can't show.
|
|
4827
|
-
if (e.key === 'Enter' && (!e.shiftKey || singleLine)) {
|
|
4828
|
-
e.preventDefault();
|
|
4829
|
-
commitOnce(ref.current?.innerText || '');
|
|
4830
|
-
}
|
|
4831
|
-
}}
|
|
4832
|
-
>
|
|
4833
|
-
{listPrefixedBody(initialText, listType)}
|
|
4834
|
-
</div>
|
|
4835
|
-
</div>
|
|
4836
|
-
);
|
|
4837
|
-
}
|
|
4838
|
-
|
|
4839
4490
|
/**
|
|
4840
4491
|
* FigJam v3 — centered ghost "Add text" label on a hovered EMPTY rect/ellipse
|
|
4841
4492
|
* (FigJam shows the same invitation). Pure chrome; double-click / Enter edits.
|
|
@@ -5232,51 +4883,6 @@ function AnnotGroupBbox({
|
|
|
5232
4883
|
* read↔edit swap doesn't shift). Applies bold / strike / align atop the
|
|
5233
4884
|
* `.dc-sticky-body` defaults (top-left).
|
|
5234
4885
|
*/
|
|
5235
|
-
function stickyBodyStyle(s: StickyStroke): CSSProperties {
|
|
5236
|
-
const align = s.align ?? 'left';
|
|
5237
|
-
return {
|
|
5238
|
-
fontSize: `${s.fontSize}px`,
|
|
5239
|
-
fontWeight: s.bold ? 700 : undefined,
|
|
5240
|
-
fontStyle: s.italic ? 'italic' : undefined,
|
|
5241
|
-
textDecoration: textDecoCss(s.strike, s.underline),
|
|
5242
|
-
textAlign: align,
|
|
5243
|
-
justifyContent: align === 'left' ? 'flex-start' : align === 'right' ? 'flex-end' : 'center',
|
|
5244
|
-
};
|
|
5245
|
-
}
|
|
5246
|
-
|
|
5247
|
-
/** Sticky body content: raw text for a plain card, else per-line with list
|
|
5248
|
-
* markers prepended (item 4c — markers are render-only). */
|
|
5249
|
-
function stickyBodyText(s: StickyStroke): string {
|
|
5250
|
-
if (!s.listType) return s.text;
|
|
5251
|
-
return splitTextLines(s.text)
|
|
5252
|
-
.map((line, i) => listPrefixedLine(line, i, s.listType))
|
|
5253
|
-
.join('\n');
|
|
5254
|
-
}
|
|
5255
|
-
|
|
5256
|
-
/**
|
|
5257
|
-
* Render the inner content of a `<text>` stroke: a single string for single-
|
|
5258
|
-
* line unstyled text (item 4a parity with the legacy form), else one `<tspan>`
|
|
5259
|
-
* per line with list markers prepended (item 4c). `tx` is the per-line origin;
|
|
5260
|
-
* `centered` lifts the block half its height for vertically-centred anchored
|
|
5261
|
-
* text. Mirrors `textInnerSvg` so the live + persisted geometry agree.
|
|
5262
|
-
*/
|
|
5263
|
-
function renderTextLines(
|
|
5264
|
-
text: string,
|
|
5265
|
-
fontSize: number,
|
|
5266
|
-
tx: number,
|
|
5267
|
-
centered: boolean,
|
|
5268
|
-
list?: ListType
|
|
5269
|
-
) {
|
|
5270
|
-
if (!list && !text.includes('\n')) return text;
|
|
5271
|
-
const lines = splitTextLines(text);
|
|
5272
|
-
return lines.map((line, i) => (
|
|
5273
|
-
// biome-ignore lint/suspicious/noArrayIndexKey: lines are positional + immutable per render
|
|
5274
|
-
<tspan key={i} x={tx} dy={textLineDy(i, fontSize, lines.length, centered)}>
|
|
5275
|
-
{listPrefixedLine(line, i, list)}
|
|
5276
|
-
</tspan>
|
|
5277
|
-
));
|
|
5278
|
-
}
|
|
5279
|
-
|
|
5280
4886
|
/**
|
|
5281
4887
|
* Phase 24 — the translucent cursor-following ghost placeholder. Pure chrome:
|
|
5282
4888
|
* `pointer-events:none`, never added to `strokes`, so it can't be selected,
|
|
@@ -5341,614 +4947,6 @@ function GhostPreview({ ghost }: { ghost: GhostDescriptor }) {
|
|
|
5341
4947
|
return <polygon points={polygonPoints(ghost.shapeKind, x, y, sz, sz)} {...common} />;
|
|
5342
4948
|
}
|
|
5343
4949
|
|
|
5344
|
-
/**
|
|
5345
|
-
* FigJam v3 — rotation wrapper. The base node renders axis-aligned geometry;
|
|
5346
|
-
* a rotated stroke wraps it in a `rotate()` group around its bbox center
|
|
5347
|
-
* (anchored text inherits its HOST's rotation so labels turn with the shape).
|
|
5348
|
-
* Pointer events pass through the group, so hit-testing + the ctx-toolbar's
|
|
5349
|
-
* getBoundingClientRect positioning keep working on the rotated form.
|
|
5350
|
-
*/
|
|
5351
|
-
/** Section title chip — deliberately screen-size-constant. The body/border
|
|
5352
|
-
* scale with the world like everything else, but a title that shrinks to
|
|
5353
|
-
* unreadable at zoom-out defeats the point of a label (matches the `r={5/zoom}`
|
|
5354
|
-
* counter-scale convention used for halos/connector-dots elsewhere in this file). */
|
|
5355
|
-
function SectionLabelChip({
|
|
5356
|
-
stroke,
|
|
5357
|
-
x,
|
|
5358
|
-
y,
|
|
5359
|
-
hitMode,
|
|
5360
|
-
}: {
|
|
5361
|
-
stroke: SectionStroke;
|
|
5362
|
-
x: number;
|
|
5363
|
-
y: number;
|
|
5364
|
-
hitMode: 'visiblePainted' | 'none';
|
|
5365
|
-
}) {
|
|
5366
|
-
// Counter-scaled chrome — must hold a constant screen size while the world
|
|
5367
|
-
// scales, so it needs the live zoom, not the settle-cadence published one.
|
|
5368
|
-
const zoom = useLiveViewport().zoom || 1;
|
|
5369
|
-
const fontSize = SECTION_LABEL_FONT / zoom;
|
|
5370
|
-
const chipH = SECTION_LABEL_H / zoom;
|
|
5371
|
-
const gap = 4 / zoom;
|
|
5372
|
-
const padX = 9 / zoom;
|
|
5373
|
-
const chipW = Math.max(56 / zoom, stroke.label.length * fontSize * 0.62 + 18 / zoom);
|
|
5374
|
-
// NOTE: this chip geometry (chipW/chipH/gap vs the region's y) is mirrored
|
|
5375
|
-
// by findTextStrokeAt's section branch — the Text tool's click-through
|
|
5376
|
-
// renames a section only from its label chip. Keep the two in sync.
|
|
5377
|
-
return (
|
|
5378
|
-
<g pointerEvents={hitMode}>
|
|
5379
|
-
<rect
|
|
5380
|
-
x={x}
|
|
5381
|
-
y={y - chipH - gap}
|
|
5382
|
-
width={chipW}
|
|
5383
|
-
height={chipH}
|
|
5384
|
-
rx={5 / zoom}
|
|
5385
|
-
ry={5 / zoom}
|
|
5386
|
-
fill={stroke.color}
|
|
5387
|
-
fillOpacity={0.16}
|
|
5388
|
-
/>
|
|
5389
|
-
<text
|
|
5390
|
-
x={x + padX}
|
|
5391
|
-
y={y - chipH / 2 - gap}
|
|
5392
|
-
dominantBaseline="middle"
|
|
5393
|
-
fontSize={fontSize}
|
|
5394
|
-
fill={stroke.color}
|
|
5395
|
-
style={{
|
|
5396
|
-
fontFamily: 'var(--u-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace)',
|
|
5397
|
-
}}
|
|
5398
|
-
>
|
|
5399
|
-
{stroke.label}
|
|
5400
|
-
</text>
|
|
5401
|
-
</g>
|
|
5402
|
-
);
|
|
5403
|
-
}
|
|
5404
|
-
|
|
5405
|
-
/** Section title RENAME field — same chip visuals + zoom-invariant sizing as
|
|
5406
|
-
* SectionLabelChip (read state), so switching into edit mode doesn't swap
|
|
5407
|
-
* the pill for a bare, ambient-zoomed sliver of text (it used to: the editor
|
|
5408
|
-
* had no background and its font-size wasn't counter-scaled, so at any zoom
|
|
5409
|
-
* below 1× the chip effectively vanished mid-rename). */
|
|
5410
|
-
function SectionTitleEditor({
|
|
5411
|
-
section,
|
|
5412
|
-
caretPoint,
|
|
5413
|
-
onCommit,
|
|
5414
|
-
onCancel,
|
|
5415
|
-
}: {
|
|
5416
|
-
section: SectionStroke;
|
|
5417
|
-
caretPoint?: { x: number; y: number } | null;
|
|
5418
|
-
onCommit: (text: string, fmt?: EditorFmt) => void;
|
|
5419
|
-
onCancel: () => void;
|
|
5420
|
-
}) {
|
|
5421
|
-
// Counter-scaled chrome — must hold a constant screen size while the world
|
|
5422
|
-
// scales, so it needs the live zoom, not the settle-cadence published one.
|
|
5423
|
-
const zoom = useLiveViewport().zoom || 1;
|
|
5424
|
-
const fontSize = SECTION_LABEL_FONT / zoom;
|
|
5425
|
-
const chipH = SECTION_LABEL_H / zoom;
|
|
5426
|
-
const gap = 4 / zoom;
|
|
5427
|
-
const padX = 9 / zoom;
|
|
5428
|
-
const x = Math.min(section.x, section.x + section.w);
|
|
5429
|
-
const y = Math.min(section.y, section.y + section.h);
|
|
5430
|
-
return (
|
|
5431
|
-
<StandaloneTextEditor
|
|
5432
|
-
x={x}
|
|
5433
|
-
y={y - chipH - gap}
|
|
5434
|
-
fontSize={fontSize}
|
|
5435
|
-
color={section.color}
|
|
5436
|
-
initialText={section.label}
|
|
5437
|
-
caretPoint={caretPoint}
|
|
5438
|
-
singleLine
|
|
5439
|
-
boxStyle={{
|
|
5440
|
-
background: `color-mix(in oklab, ${section.color} 16%, transparent)`,
|
|
5441
|
-
borderRadius: `${5 / zoom}px`,
|
|
5442
|
-
padding: `0 ${padX}px`,
|
|
5443
|
-
minHeight: `${chipH}px`,
|
|
5444
|
-
lineHeight: `${chipH}px`,
|
|
5445
|
-
whiteSpace: 'nowrap',
|
|
5446
|
-
}}
|
|
5447
|
-
onCommit={onCommit}
|
|
5448
|
-
onCancel={onCancel}
|
|
5449
|
-
/>
|
|
5450
|
-
);
|
|
5451
|
-
}
|
|
5452
|
-
|
|
5453
|
-
function StrokeNode(props: {
|
|
5454
|
-
stroke: Stroke;
|
|
5455
|
-
anchorsById: Map<string, AnchorHost>;
|
|
5456
|
-
interactive: boolean;
|
|
5457
|
-
editing?: boolean;
|
|
5458
|
-
}) {
|
|
5459
|
-
const { stroke, anchorsById } = props;
|
|
5460
|
-
let rot = strokeRotation(stroke);
|
|
5461
|
-
let pivot = rot !== 0 ? strokeCenter(stroke) : null;
|
|
5462
|
-
if (stroke.tool === 'text' && stroke.anchorId != null && stroke.anchorId !== '') {
|
|
5463
|
-
const host = anchorsById.get(stroke.anchorId);
|
|
5464
|
-
rot = host ? strokeRotation(host) : 0;
|
|
5465
|
-
pivot = rot !== 0 && host ? strokeCenter(host) : null;
|
|
5466
|
-
}
|
|
5467
|
-
const node = <StrokeNodeBase {...props} />;
|
|
5468
|
-
if (rot === 0 || !pivot) return node;
|
|
5469
|
-
return <g transform={`rotate(${rot} ${pivot[0]} ${pivot[1]})`}>{node}</g>;
|
|
5470
|
-
}
|
|
5471
|
-
|
|
5472
|
-
function StrokeNodeBase({
|
|
5473
|
-
stroke,
|
|
5474
|
-
anchorsById,
|
|
5475
|
-
interactive,
|
|
5476
|
-
editing = false,
|
|
5477
|
-
}: {
|
|
5478
|
-
stroke: Stroke;
|
|
5479
|
-
anchorsById: Map<string, AnchorHost>;
|
|
5480
|
-
interactive: boolean;
|
|
5481
|
-
/** Hide the read-only body/text while its editor is up (sticky, section, or
|
|
5482
|
-
* anchored shape text — whichever this stroke is). */
|
|
5483
|
-
editing?: boolean;
|
|
5484
|
-
}) {
|
|
5485
|
-
// In Move mode, individual stroke nodes claim pointer events so we can
|
|
5486
|
-
// hit-test them from the doc-level capture listener. In draw mode the
|
|
5487
|
-
// overlay above handles input, so the strokes themselves stay inert.
|
|
5488
|
-
const hitMode = interactive ? 'visiblePainted' : ('none' as const);
|
|
5489
|
-
const strokeHit = interactive ? 'stroke' : ('none' as const);
|
|
5490
|
-
if (stroke.tool === 'text') {
|
|
5491
|
-
// Anchored text renders centered in its host; standalone (Phase 21) renders
|
|
5492
|
-
// top-left-anchored at its own world (x, y). bold / italic / strike /
|
|
5493
|
-
// underline applied to the rendered <text>; multi-line + list markers via
|
|
5494
|
-
// renderTextLines (one <tspan> per line).
|
|
5495
|
-
const textStyle = {
|
|
5496
|
-
fontFamily: 'var(--u-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace)',
|
|
5497
|
-
fontWeight: stroke.bold ? 700 : undefined,
|
|
5498
|
-
fontStyle: stroke.italic ? 'italic' : undefined,
|
|
5499
|
-
textDecoration: textDecoCss(stroke.strike, stroke.underline),
|
|
5500
|
-
} as const;
|
|
5501
|
-
if (stroke.anchorId != null && stroke.anchorId !== '') {
|
|
5502
|
-
const host = anchorsById.get(stroke.anchorId);
|
|
5503
|
-
const bbox = host ? strokeBBox(host) : null;
|
|
5504
|
-
if (!bbox) return null;
|
|
5505
|
-
// Its editor (TextEditor) paints the same bbox while active — skip the
|
|
5506
|
-
// read-only <text> so the two don't double-paint (Phase 1 jump-fix
|
|
5507
|
-
// companion: the editor was already exempt via editingStickyId's
|
|
5508
|
-
// sibling, this stroke type never was).
|
|
5509
|
-
if (editing) return null;
|
|
5510
|
-
const cy = bbox.y + bbox.h / 2;
|
|
5511
|
-
const align = stroke.align ?? 'center';
|
|
5512
|
-
const pad = 8;
|
|
5513
|
-
const anchor = align === 'left' ? 'start' : align === 'right' ? 'end' : 'middle';
|
|
5514
|
-
const tx =
|
|
5515
|
-
align === 'left'
|
|
5516
|
-
? bbox.x + pad
|
|
5517
|
-
: align === 'right'
|
|
5518
|
-
? bbox.x + bbox.w - pad
|
|
5519
|
-
: bbox.x + bbox.w / 2;
|
|
5520
|
-
return (
|
|
5521
|
-
<text
|
|
5522
|
-
data-id={stroke.id}
|
|
5523
|
-
data-tool="text"
|
|
5524
|
-
data-anchor-id={stroke.anchorId}
|
|
5525
|
-
data-font-size={stroke.fontSize}
|
|
5526
|
-
x={tx}
|
|
5527
|
-
y={cy}
|
|
5528
|
-
fill={stroke.color}
|
|
5529
|
-
fontSize={stroke.fontSize}
|
|
5530
|
-
textAnchor={anchor}
|
|
5531
|
-
dominantBaseline="middle"
|
|
5532
|
-
style={textStyle}
|
|
5533
|
-
>
|
|
5534
|
-
{renderTextLines(stroke.text, stroke.fontSize, tx, true, stroke.listType)}
|
|
5535
|
-
</text>
|
|
5536
|
-
);
|
|
5537
|
-
}
|
|
5538
|
-
// Its editor (StandaloneTextEditor) paints at the same x/y while active —
|
|
5539
|
-
// skip the read-only <text> so the two don't double-paint (the "ghost").
|
|
5540
|
-
if (editing) return null;
|
|
5541
|
-
const align = stroke.align ?? 'left';
|
|
5542
|
-
const anchor = align === 'left' ? 'start' : align === 'right' ? 'end' : 'middle';
|
|
5543
|
-
const tx = stroke.x ?? 0;
|
|
5544
|
-
return (
|
|
5545
|
-
<text
|
|
5546
|
-
data-id={stroke.id}
|
|
5547
|
-
data-tool="text"
|
|
5548
|
-
data-font-size={stroke.fontSize}
|
|
5549
|
-
x={tx}
|
|
5550
|
-
y={stroke.y ?? 0}
|
|
5551
|
-
fill={stroke.color}
|
|
5552
|
-
fontSize={stroke.fontSize}
|
|
5553
|
-
textAnchor={anchor}
|
|
5554
|
-
dominantBaseline="hanging"
|
|
5555
|
-
pointerEvents={interactive ? 'visiblePainted' : 'none'}
|
|
5556
|
-
style={textStyle}
|
|
5557
|
-
>
|
|
5558
|
-
{renderTextLines(stroke.text, stroke.fontSize, tx, false, stroke.listType)}
|
|
5559
|
-
</text>
|
|
5560
|
-
);
|
|
5561
|
-
}
|
|
5562
|
-
if (stroke.tool === 'sticky') {
|
|
5563
|
-
const x = Math.min(stroke.x, stroke.x + stroke.w);
|
|
5564
|
-
const y = Math.min(stroke.y, stroke.y + stroke.h);
|
|
5565
|
-
const w = Math.abs(stroke.w);
|
|
5566
|
-
const h = Math.abs(stroke.h);
|
|
5567
|
-
const r = stroke.cornerRadius ?? STICKY_CORNER_RADIUS;
|
|
5568
|
-
return (
|
|
5569
|
-
<g data-id={stroke.id} data-tool="sticky" pointerEvents={hitMode}>
|
|
5570
|
-
{/* Paper card: soft drop shadow + hairline edge so it reads as a
|
|
5571
|
-
lifted sticky, not a flat colored box (FigJam-style). The body is a
|
|
5572
|
-
path with a SHARP bottom-right corner (item 1) — TL/TR/BL rounded.
|
|
5573
|
-
The persisted form stays a <rect> (DDR), so this is render-only. */}
|
|
5574
|
-
<path
|
|
5575
|
-
d={stickyCornerPath(x, y, w, h, r)}
|
|
5576
|
-
fill={stroke.color}
|
|
5577
|
-
stroke="rgba(0,0,0,0.05)"
|
|
5578
|
-
strokeWidth={1}
|
|
5579
|
-
vectorEffect="non-scaling-stroke"
|
|
5580
|
-
filter="url(#dc-sticky-shadow)"
|
|
5581
|
-
/>
|
|
5582
|
-
{editing ? null : (
|
|
5583
|
-
<foreignObject x={x} y={y} width={w} height={h} pointerEvents="none">
|
|
5584
|
-
<div
|
|
5585
|
-
xmlns="http://www.w3.org/1999/xhtml"
|
|
5586
|
-
className="dc-sticky-body"
|
|
5587
|
-
style={stickyBodyStyle(stroke)}
|
|
5588
|
-
>
|
|
5589
|
-
{stickyBodyText(stroke)}
|
|
5590
|
-
</div>
|
|
5591
|
-
</foreignObject>
|
|
5592
|
-
)}
|
|
5593
|
-
{/* Phase 3 (whiteboard-improvements) — author badge, bottom-right
|
|
5594
|
-
corner. A name label (not an avatar — a full name/nickname reads
|
|
5595
|
-
faster than initials and doesn't need a legend to decode). Color
|
|
5596
|
-
re-derives from the (sanitized) name via colorForName — NEVER a
|
|
5597
|
-
stored/wire color — so it matches the author's live presence hue
|
|
5598
|
-
(cursor/avatar use the same function). foreignObject width is a
|
|
5599
|
-
fixed generous box right-anchored via flex, since a name's pixel
|
|
5600
|
-
width isn't known without measuring the DOM. */}
|
|
5601
|
-
{stroke.authorName && (
|
|
5602
|
-
<foreignObject
|
|
5603
|
-
x={x + w - 160}
|
|
5604
|
-
y={y + h - 20}
|
|
5605
|
-
width={160}
|
|
5606
|
-
height={20}
|
|
5607
|
-
pointerEvents="none"
|
|
5608
|
-
>
|
|
5609
|
-
<div
|
|
5610
|
-
xmlns="http://www.w3.org/1999/xhtml"
|
|
5611
|
-
style={{
|
|
5612
|
-
display: 'flex',
|
|
5613
|
-
justifyContent: 'flex-end',
|
|
5614
|
-
alignItems: 'center',
|
|
5615
|
-
height: '100%',
|
|
5616
|
-
paddingRight: 4,
|
|
5617
|
-
}}
|
|
5618
|
-
>
|
|
5619
|
-
<span
|
|
5620
|
-
title={stroke.authorName}
|
|
5621
|
-
style={{
|
|
5622
|
-
maxWidth: '100%',
|
|
5623
|
-
overflow: 'hidden',
|
|
5624
|
-
textOverflow: 'ellipsis',
|
|
5625
|
-
whiteSpace: 'nowrap',
|
|
5626
|
-
fontSize: 9,
|
|
5627
|
-
fontWeight: 600,
|
|
5628
|
-
fontFamily: 'var(--u-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace)',
|
|
5629
|
-
color: colorForName(stroke.authorName),
|
|
5630
|
-
background: 'rgba(255,255,255,0.78)',
|
|
5631
|
-
padding: '1px 5px',
|
|
5632
|
-
borderRadius: 8,
|
|
5633
|
-
lineHeight: 1.4,
|
|
5634
|
-
}}
|
|
5635
|
-
>
|
|
5636
|
-
{stroke.authorName}
|
|
5637
|
-
</span>
|
|
5638
|
-
</div>
|
|
5639
|
-
</foreignObject>
|
|
5640
|
-
)}
|
|
5641
|
-
</g>
|
|
5642
|
-
);
|
|
5643
|
-
}
|
|
5644
|
-
if (stroke.tool === 'image') {
|
|
5645
|
-
const x = Math.min(stroke.x, stroke.x + stroke.w);
|
|
5646
|
-
const y = Math.min(stroke.y, stroke.y + stroke.h);
|
|
5647
|
-
const w = Math.abs(stroke.w);
|
|
5648
|
-
const h = Math.abs(stroke.h);
|
|
5649
|
-
return (
|
|
5650
|
-
<image
|
|
5651
|
-
data-id={stroke.id}
|
|
5652
|
-
data-tool="image"
|
|
5653
|
-
x={x}
|
|
5654
|
-
y={y}
|
|
5655
|
-
width={w}
|
|
5656
|
-
height={h}
|
|
5657
|
-
href={resolveAssetHref(stroke.href)}
|
|
5658
|
-
preserveAspectRatio="xMidYMid meet"
|
|
5659
|
-
aria-label={stroke.alt || undefined}
|
|
5660
|
-
pointerEvents={hitMode}
|
|
5661
|
-
/>
|
|
5662
|
-
);
|
|
5663
|
-
}
|
|
5664
|
-
if (stroke.tool === 'link') {
|
|
5665
|
-
const x = Math.min(stroke.x, stroke.x + stroke.w);
|
|
5666
|
-
const y = Math.min(stroke.y, stroke.y + stroke.h);
|
|
5667
|
-
const w = Math.abs(stroke.w);
|
|
5668
|
-
const h = Math.abs(stroke.h);
|
|
5669
|
-
const lay = linkCardLayout(x, y, w, h);
|
|
5670
|
-
const shownTitle = clampLinkTitle(stroke.title, lay.textMaxChars);
|
|
5671
|
-
const textFont = {
|
|
5672
|
-
fontFamily: 'var(--u-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace)',
|
|
5673
|
-
} as const;
|
|
5674
|
-
return (
|
|
5675
|
-
<g
|
|
5676
|
-
data-id={stroke.id}
|
|
5677
|
-
data-tool="link"
|
|
5678
|
-
data-url={stroke.url}
|
|
5679
|
-
data-title={stroke.title}
|
|
5680
|
-
data-domain={stroke.domain}
|
|
5681
|
-
pointerEvents={hitMode}
|
|
5682
|
-
>
|
|
5683
|
-
<rect
|
|
5684
|
-
x={x}
|
|
5685
|
-
y={y}
|
|
5686
|
-
width={w}
|
|
5687
|
-
height={h}
|
|
5688
|
-
rx={8}
|
|
5689
|
-
ry={8}
|
|
5690
|
-
fill={LINK_CARD_FILL}
|
|
5691
|
-
stroke={LINK_CARD_STROKE}
|
|
5692
|
-
strokeWidth={1}
|
|
5693
|
-
vectorEffect="non-scaling-stroke"
|
|
5694
|
-
filter="url(#dc-sticky-shadow)"
|
|
5695
|
-
/>
|
|
5696
|
-
<svg
|
|
5697
|
-
x={lay.glyph.x}
|
|
5698
|
-
y={lay.glyph.y}
|
|
5699
|
-
width={lay.glyph.size}
|
|
5700
|
-
height={lay.glyph.size}
|
|
5701
|
-
viewBox="0 0 24 24"
|
|
5702
|
-
fill="none"
|
|
5703
|
-
stroke={LINK_GLYPH_STROKE}
|
|
5704
|
-
strokeWidth={2}
|
|
5705
|
-
strokeLinecap="round"
|
|
5706
|
-
strokeLinejoin="round"
|
|
5707
|
-
aria-hidden="true"
|
|
5708
|
-
>
|
|
5709
|
-
<path d={LINK_GLYPH_D1} />
|
|
5710
|
-
<path d={LINK_GLYPH_D2} />
|
|
5711
|
-
</svg>
|
|
5712
|
-
<text
|
|
5713
|
-
x={lay.textX}
|
|
5714
|
-
y={lay.domain.y}
|
|
5715
|
-
fontSize={lay.domain.fontSize}
|
|
5716
|
-
fill={LINK_DOMAIN_FILL}
|
|
5717
|
-
dominantBaseline="hanging"
|
|
5718
|
-
style={textFont}
|
|
5719
|
-
>
|
|
5720
|
-
{stroke.domain}
|
|
5721
|
-
</text>
|
|
5722
|
-
<text
|
|
5723
|
-
x={lay.textX}
|
|
5724
|
-
y={lay.title.y}
|
|
5725
|
-
fontSize={lay.title.fontSize}
|
|
5726
|
-
fill={LINK_TITLE_FILL}
|
|
5727
|
-
fontWeight={600}
|
|
5728
|
-
dominantBaseline="hanging"
|
|
5729
|
-
style={textFont}
|
|
5730
|
-
>
|
|
5731
|
-
{shownTitle}
|
|
5732
|
-
</text>
|
|
5733
|
-
</g>
|
|
5734
|
-
);
|
|
5735
|
-
}
|
|
5736
|
-
if (stroke.tool === 'mediaref') {
|
|
5737
|
-
// DDR-150 P4 + dogfood #8 — reference chip with a REAL inline player.
|
|
5738
|
-
// LIVE-RENDER ONLY: the <foreignObject> + <video>/<audio> below never
|
|
5739
|
-
// persist — the model serializer still writes the sanitizer-safe data-*
|
|
5740
|
-
// card (foreignObject is stripped by sanitizeAnnotationSvg by design).
|
|
5741
|
-
// The 26px header strip (badge + filename) is the select/drag handle; the
|
|
5742
|
-
// player area is fenced off from the annotation handlers by the
|
|
5743
|
-
// [data-mediaref-player] window-capture guard.
|
|
5744
|
-
const x = Math.min(stroke.x, stroke.x + stroke.w);
|
|
5745
|
-
const y = Math.min(stroke.y, stroke.y + stroke.h);
|
|
5746
|
-
const w = Math.abs(stroke.w);
|
|
5747
|
-
const h = Math.abs(stroke.h);
|
|
5748
|
-
const HEADER = 26;
|
|
5749
|
-
const isAudio = stroke.mediaKind === 'audio';
|
|
5750
|
-
const mediaUrl = stroke.src ? resolveAssetHref(stroke.src) : '';
|
|
5751
|
-
const shownTitle = clampLinkTitle(stroke.title, Math.max(8, Math.floor((w - 40) / 7)));
|
|
5752
|
-
const textFont = {
|
|
5753
|
-
fontFamily: 'var(--u-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace)',
|
|
5754
|
-
} as const;
|
|
5755
|
-
return (
|
|
5756
|
-
<g
|
|
5757
|
-
data-id={stroke.id}
|
|
5758
|
-
data-tool="mediaref"
|
|
5759
|
-
data-src={stroke.src}
|
|
5760
|
-
data-media-kind={stroke.mediaKind}
|
|
5761
|
-
data-title={stroke.title}
|
|
5762
|
-
pointerEvents={hitMode}
|
|
5763
|
-
>
|
|
5764
|
-
<rect
|
|
5765
|
-
x={x}
|
|
5766
|
-
y={y}
|
|
5767
|
-
width={w}
|
|
5768
|
-
height={h}
|
|
5769
|
-
rx={8}
|
|
5770
|
-
ry={8}
|
|
5771
|
-
fill={LINK_CARD_FILL}
|
|
5772
|
-
stroke={LINK_CARD_STROKE}
|
|
5773
|
-
strokeWidth={1}
|
|
5774
|
-
vectorEffect="non-scaling-stroke"
|
|
5775
|
-
filter="url(#dc-sticky-shadow)"
|
|
5776
|
-
/>
|
|
5777
|
-
<svg
|
|
5778
|
-
x={x + 8}
|
|
5779
|
-
y={y + 5}
|
|
5780
|
-
width={16}
|
|
5781
|
-
height={16}
|
|
5782
|
-
viewBox="0 0 24 24"
|
|
5783
|
-
fill={LINK_GLYPH_STROKE}
|
|
5784
|
-
stroke="none"
|
|
5785
|
-
aria-hidden="true"
|
|
5786
|
-
>
|
|
5787
|
-
<path d={isAudio ? MEDIAREF_AUDIO_GLYPH : MEDIAREF_VIDEO_GLYPH} />
|
|
5788
|
-
</svg>
|
|
5789
|
-
<text
|
|
5790
|
-
x={x + 30}
|
|
5791
|
-
y={y + 9}
|
|
5792
|
-
fontSize={11}
|
|
5793
|
-
fill={LINK_TITLE_FILL}
|
|
5794
|
-
fontWeight={600}
|
|
5795
|
-
dominantBaseline="hanging"
|
|
5796
|
-
style={textFont}
|
|
5797
|
-
>
|
|
5798
|
-
{shownTitle}
|
|
5799
|
-
</text>
|
|
5800
|
-
{/* The inline player itself is an HTML overlay portaled beside this SVG
|
|
5801
|
-
(MediaRefPlayers below) — NOT a foreignObject: Chromium hit-tests
|
|
5802
|
-
foreignObject content under a CSS-transformed ancestor in the WRONG
|
|
5803
|
-
coordinate space (the un-panned/un-zoomed one), so real clicks miss
|
|
5804
|
-
the player at most zoom levels while elementFromPoint lies that
|
|
5805
|
-
they'd land. Plain HTML in the transformed world hit-tests right. */}
|
|
5806
|
-
{!mediaUrl ? (
|
|
5807
|
-
<text
|
|
5808
|
-
x={x + 30}
|
|
5809
|
-
y={y + HEADER + 10}
|
|
5810
|
-
fontSize={10}
|
|
5811
|
-
fill={LINK_DOMAIN_FILL}
|
|
5812
|
-
dominantBaseline="hanging"
|
|
5813
|
-
style={textFont}
|
|
5814
|
-
>
|
|
5815
|
-
(missing media reference)
|
|
5816
|
-
</text>
|
|
5817
|
-
) : null}
|
|
5818
|
-
</g>
|
|
5819
|
-
);
|
|
5820
|
-
}
|
|
5821
|
-
if (stroke.tool === 'section') {
|
|
5822
|
-
const x = Math.min(stroke.x, stroke.x + stroke.w);
|
|
5823
|
-
const y = Math.min(stroke.y, stroke.y + stroke.h);
|
|
5824
|
-
const w = Math.abs(stroke.w);
|
|
5825
|
-
const h = Math.abs(stroke.h);
|
|
5826
|
-
return (
|
|
5827
|
-
<g data-id={stroke.id} data-tool="section">
|
|
5828
|
-
{/* Region body — pure backdrop, CLICK-THROUGH (FigJam: content on a
|
|
5829
|
-
section selects normally; the section is grabbed by border/chip). */}
|
|
5830
|
-
<rect
|
|
5831
|
-
x={x}
|
|
5832
|
-
y={y}
|
|
5833
|
-
width={w}
|
|
5834
|
-
height={h}
|
|
5835
|
-
rx={SECTION_CORNER_RADIUS}
|
|
5836
|
-
ry={SECTION_CORNER_RADIUS}
|
|
5837
|
-
fill={stroke.color}
|
|
5838
|
-
fillOpacity={0.07}
|
|
5839
|
-
stroke={stroke.color}
|
|
5840
|
-
strokeOpacity={0.45}
|
|
5841
|
-
strokeWidth={1.5}
|
|
5842
|
-
vectorEffect="non-scaling-stroke"
|
|
5843
|
-
pointerEvents="none"
|
|
5844
|
-
/>
|
|
5845
|
-
{/* Invisible border hit ring — the grabbable edge. */}
|
|
5846
|
-
{interactive ? (
|
|
5847
|
-
<rect
|
|
5848
|
-
x={x}
|
|
5849
|
-
y={y}
|
|
5850
|
-
width={w}
|
|
5851
|
-
height={h}
|
|
5852
|
-
rx={SECTION_CORNER_RADIUS}
|
|
5853
|
-
ry={SECTION_CORNER_RADIUS}
|
|
5854
|
-
fill="none"
|
|
5855
|
-
stroke="transparent"
|
|
5856
|
-
strokeWidth={12}
|
|
5857
|
-
vectorEffect="non-scaling-stroke"
|
|
5858
|
-
pointerEvents="stroke"
|
|
5859
|
-
/>
|
|
5860
|
-
) : null}
|
|
5861
|
-
{/* Label chip above the top-left corner — also a grab handle. Hidden
|
|
5862
|
-
while the rename editor (StandaloneTextEditor) is up at the same
|
|
5863
|
-
spot, else the old label reads through behind the new text. */}
|
|
5864
|
-
{editing ? null : <SectionLabelChip stroke={stroke} x={x} y={y} hitMode={hitMode} />}
|
|
5865
|
-
</g>
|
|
5866
|
-
);
|
|
5867
|
-
}
|
|
5868
|
-
// Deliberately NO `vector-effect="non-scaling-stroke"` here: drawn ink is
|
|
5869
|
-
// world-space content and must thicken/thin with the zoom, the way it does in
|
|
5870
|
-
// Figma/FigJam. The attribute also rendered differently per engine — Blink
|
|
5871
|
-
// honours it under the world's CSS `zoom` (ink stayed a fixed screen width at
|
|
5872
|
-
// every zoom level), WebKit ignores it under `transform: scale`, so the same
|
|
5873
|
-
// board drew differently in Chrome than in the desktop shell. Card hairlines
|
|
5874
|
-
// and selection chrome below still opt in — those ARE fixed-px by intent.
|
|
5875
|
-
const common = {
|
|
5876
|
-
'data-id': stroke.id,
|
|
5877
|
-
'data-tool': stroke.tool,
|
|
5878
|
-
stroke: stroke.color,
|
|
5879
|
-
strokeWidth: stroke.width,
|
|
5880
|
-
strokeLinecap: 'round' as const,
|
|
5881
|
-
strokeLinejoin: 'round' as const,
|
|
5882
|
-
};
|
|
5883
|
-
if (stroke.tool === 'pen') {
|
|
5884
|
-
// Highlighter (item 8) — overlaps darken via multiply; the translucent hue
|
|
5885
|
-
// lives in `stroke.color`, the wide nib in `stroke.width`.
|
|
5886
|
-
return (
|
|
5887
|
-
<path
|
|
5888
|
-
{...common}
|
|
5889
|
-
fill="none"
|
|
5890
|
-
d={penPathD(stroke.points)}
|
|
5891
|
-
style={stroke.highlighter ? { mixBlendMode: 'multiply' } : undefined}
|
|
5892
|
-
pointerEvents={strokeHit}
|
|
5893
|
-
/>
|
|
5894
|
-
);
|
|
5895
|
-
}
|
|
5896
|
-
if (stroke.tool === 'rect') {
|
|
5897
|
-
const x = Math.min(stroke.x, stroke.x + stroke.w);
|
|
5898
|
-
const y = Math.min(stroke.y, stroke.y + stroke.h);
|
|
5899
|
-
const r = stroke.cornerRadius ?? 0;
|
|
5900
|
-
return (
|
|
5901
|
-
<rect
|
|
5902
|
-
{...common}
|
|
5903
|
-
fill={stroke.fill ?? 'none'}
|
|
5904
|
-
x={x}
|
|
5905
|
-
y={y}
|
|
5906
|
-
width={Math.abs(stroke.w)}
|
|
5907
|
-
height={Math.abs(stroke.h)}
|
|
5908
|
-
rx={r}
|
|
5909
|
-
ry={r}
|
|
5910
|
-
strokeDasharray={stroke.dashed ? '6 4' : undefined}
|
|
5911
|
-
pointerEvents={hitMode}
|
|
5912
|
-
/>
|
|
5913
|
-
);
|
|
5914
|
-
}
|
|
5915
|
-
if (stroke.tool === 'ellipse') {
|
|
5916
|
-
return (
|
|
5917
|
-
<ellipse
|
|
5918
|
-
{...common}
|
|
5919
|
-
fill={stroke.fill ?? 'none'}
|
|
5920
|
-
cx={stroke.cx}
|
|
5921
|
-
cy={stroke.cy}
|
|
5922
|
-
rx={Math.max(0, stroke.rx)}
|
|
5923
|
-
ry={Math.max(0, stroke.ry)}
|
|
5924
|
-
strokeDasharray={stroke.dashed ? '6 4' : undefined}
|
|
5925
|
-
pointerEvents={hitMode}
|
|
5926
|
-
/>
|
|
5927
|
-
);
|
|
5928
|
-
}
|
|
5929
|
-
if (stroke.tool === 'polygon') {
|
|
5930
|
-
const nx = Math.min(stroke.x, stroke.x + stroke.w);
|
|
5931
|
-
const ny = Math.min(stroke.y, stroke.y + stroke.h);
|
|
5932
|
-
return (
|
|
5933
|
-
<polygon
|
|
5934
|
-
{...common}
|
|
5935
|
-
data-shape={stroke.shape}
|
|
5936
|
-
fill={stroke.fill ?? 'none'}
|
|
5937
|
-
points={polygonPoints(stroke.shape, nx, ny, Math.abs(stroke.w), Math.abs(stroke.h))}
|
|
5938
|
-
strokeDasharray={stroke.dashed ? '6 4' : undefined}
|
|
5939
|
-
pointerEvents={hitMode}
|
|
5940
|
-
/>
|
|
5941
|
-
);
|
|
5942
|
-
}
|
|
5943
|
-
// arrow — Phase 24 renders the SAME ordered primitives the serializer emits
|
|
5944
|
-
// (canvas-arrowheads), so the on-canvas and persisted forms can never drift.
|
|
5945
|
-
return (
|
|
5946
|
-
<g {...common} fill="none" pointerEvents={hitMode}>
|
|
5947
|
-
{arrowPrimitives(stroke).map((p, i) => renderArrowPrimitive(p, i))}
|
|
5948
|
-
</g>
|
|
5949
|
-
);
|
|
5950
|
-
}
|
|
5951
|
-
|
|
5952
4950
|
/** Map one arrow primitive to JSX (heads inherit stroke from the parent <g>). */
|
|
5953
4951
|
function renderArrowPrimitive(p: SvgPrimitive, key: number): JSX.Element {
|
|
5954
4952
|
switch (p.el) {
|