@1agh/maude 0.41.0 → 0.43.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/apps/studio/.ai/cache/_stats.json +7 -0
- package/apps/studio/acp/bootstrap-brief.ts +12 -2
- package/apps/studio/annotations-layer.tsx +1055 -278
- package/apps/studio/annotations-model.ts +58 -0
- package/apps/studio/api.ts +173 -63
- package/apps/studio/bin/_html-playwright.mjs +26 -4
- package/apps/studio/bin/_ingest-footage.mjs +386 -0
- package/apps/studio/bin/_pdf-playwright.mjs +13 -2
- package/apps/studio/bin/_png-playwright.mjs +15 -2
- package/apps/studio/bin/_pptx-playwright.mjs +17 -4
- package/apps/studio/bin/_probe-footage-playwright.mjs +269 -0
- package/apps/studio/bin/_screenshot-playwright.mjs +17 -2
- package/apps/studio/bin/_svg-playwright.mjs +26 -4
- package/apps/studio/bin/_video-playwright.mjs +141 -71
- package/apps/studio/bin/ingest-footage.sh +27 -0
- package/apps/studio/bin/photo-adjust.sh +163 -0
- package/apps/studio/bin/photo-bg-remove.sh +160 -0
- package/apps/studio/bin/probe-footage.sh +28 -0
- package/apps/studio/bin/read-annotations.mjs +125 -2
- package/apps/studio/bin/screenshot.sh +53 -4
- package/apps/studio/canvas-edit.ts +459 -10
- package/apps/studio/canvas-lib.tsx +523 -5
- package/apps/studio/canvas-pipeline.ts +65 -7
- package/apps/studio/canvas-shell.tsx +156 -16
- package/apps/studio/client/app.jsx +476 -88
- package/apps/studio/client/export-center.jsx +460 -0
- package/apps/studio/client/github.js +4 -0
- package/apps/studio/client/panels/GitPanel.jsx +8 -0
- package/apps/studio/client/panels/RepoBranchSwitcher.jsx +133 -21
- package/apps/studio/client/panels/StickerPicker.jsx +160 -0
- package/apps/studio/client/panels/TimelinePanel.jsx +15 -2
- package/apps/studio/client/photo-knobs.jsx +437 -0
- package/apps/studio/client/styles/3-shell-maude.css +59 -0
- package/apps/studio/client/styles/4-components.css +150 -0
- package/apps/studio/commands/edit-source-command.ts +31 -4
- package/apps/studio/config.schema.json +2 -2
- package/apps/studio/context-menu.tsx +31 -6
- package/apps/studio/dist/client.bundle.js +5561 -19
- package/apps/studio/dist/comment-mount.js +2 -2
- package/apps/studio/dist/runtime/.min-sizes.json +1 -0
- package/apps/studio/dist/runtime/@imgly_background-removal.js +5543 -0
- package/apps/studio/dist/runtime/pixi-js.js +519 -519
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/dom-selection.ts +25 -0
- package/apps/studio/export-dialog.tsx +18 -25
- package/apps/studio/exporters/_runtime.ts +104 -0
- package/apps/studio/exporters/html.ts +12 -20
- package/apps/studio/exporters/index.ts +14 -2
- package/apps/studio/exporters/jobs.ts +376 -0
- package/apps/studio/exporters/pdf.ts +16 -20
- package/apps/studio/exporters/png.ts +12 -20
- package/apps/studio/exporters/pptx.ts +22 -23
- package/apps/studio/exporters/scope.ts +1 -0
- package/apps/studio/exporters/svg.ts +14 -22
- package/apps/studio/exporters/video.ts +37 -19
- package/apps/studio/footage/schema.test.ts +195 -0
- package/apps/studio/footage/schema.ts +484 -0
- package/apps/studio/footage-store.ts +235 -0
- package/apps/studio/fs-watch.ts +10 -0
- package/apps/studio/git/endpoints.ts +68 -7
- package/apps/studio/git/service.ts +198 -20
- package/apps/studio/github/service.ts +67 -0
- package/apps/studio/handoff.ts +3 -2
- package/apps/studio/http.ts +318 -64
- package/apps/studio/input-router.tsx +10 -0
- package/apps/studio/inspect.ts +53 -1
- package/apps/studio/paths.ts +10 -0
- package/apps/studio/photo/filters.ts +285 -0
- package/apps/studio/photo/pipeline.ts +607 -0
- package/apps/studio/photo/schema.ts +367 -0
- package/apps/studio/photo-store.ts +154 -0
- package/apps/studio/runtime-bundle.ts +29 -6
- package/apps/studio/server.ts +8 -2
- package/apps/studio/stickers/figjam-doodle/manifest.json +104 -0
- package/apps/studio/stickers/figjam-doodle/slice1.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice10.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice11.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice12.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice13.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice14.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice15.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice16.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice17.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice18.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice19.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice2.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice20.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice21.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice22.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice23.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice24.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice3.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice4.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice5.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice6.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice7.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice8.png +0 -0
- package/apps/studio/stickers/figjam-doodle/slice9.png +0 -0
- package/apps/studio/stickers/life-style/best.png +0 -0
- package/apps/studio/stickers/life-style/come-on.png +0 -0
- package/apps/studio/stickers/life-style/cut.png +0 -0
- package/apps/studio/stickers/life-style/detox.png +0 -0
- package/apps/studio/stickers/life-style/just.png +0 -0
- package/apps/studio/stickers/life-style/manifest.json +68 -0
- package/apps/studio/stickers/life-style/mirror.png +0 -0
- package/apps/studio/stickers/life-style/nice.png +0 -0
- package/apps/studio/stickers/life-style/objects.png +0 -0
- package/apps/studio/stickers/life-style/ok.png +0 -0
- package/apps/studio/stickers/life-style/queen.png +0 -0
- package/apps/studio/stickers/life-style/star.png +0 -0
- package/apps/studio/stickers/life-style/sun.png +0 -0
- package/apps/studio/stickers/life-style/water.png +0 -0
- package/apps/studio/stickers/life-style/yeah.png +0 -0
- package/apps/studio/stickers/life-style/you-can.png +0 -0
- package/apps/studio/stickers/opposing-thoughts/check-the-deets.png +0 -0
- package/apps/studio/stickers/opposing-thoughts/cut-it.png +0 -0
- package/apps/studio/stickers/opposing-thoughts/dope.png +0 -0
- package/apps/studio/stickers/opposing-thoughts/fresh.png +0 -0
- package/apps/studio/stickers/opposing-thoughts/hot.png +0 -0
- package/apps/studio/stickers/opposing-thoughts/idea.png +0 -0
- package/apps/studio/stickers/opposing-thoughts/keep-exploring.png +0 -0
- package/apps/studio/stickers/opposing-thoughts/killed-it.png +0 -0
- package/apps/studio/stickers/opposing-thoughts/love-it.png +0 -0
- package/apps/studio/stickers/opposing-thoughts/manifest.json +88 -0
- package/apps/studio/stickers/opposing-thoughts/not-sure.png +0 -0
- package/apps/studio/stickers/opposing-thoughts/ok.png +0 -0
- package/apps/studio/stickers/opposing-thoughts/pure-gold.png +0 -0
- package/apps/studio/stickers/opposing-thoughts/save-for-later.png +0 -0
- package/apps/studio/stickers/opposing-thoughts/steal-this.png +0 -0
- package/apps/studio/stickers/opposing-thoughts/take-a-peek.png +0 -0
- package/apps/studio/stickers/opposing-thoughts/this-or-that.png +0 -0
- package/apps/studio/stickers/opposing-thoughts/thoughts.png +0 -0
- package/apps/studio/stickers/opposing-thoughts/vibes.png +0 -0
- package/apps/studio/stickers/opposing-thoughts/winner.png +0 -0
- package/apps/studio/stickers/opposing-thoughts/wip.png +0 -0
- package/apps/studio/stickers/project-status/group-100.png +0 -0
- package/apps/studio/stickers/project-status/group-101.png +0 -0
- package/apps/studio/stickers/project-status/group-102.png +0 -0
- package/apps/studio/stickers/project-status/group-103.png +0 -0
- package/apps/studio/stickers/project-status/group-104.png +0 -0
- package/apps/studio/stickers/project-status/group-105.png +0 -0
- package/apps/studio/stickers/project-status/group-106.png +0 -0
- package/apps/studio/stickers/project-status/group-107.png +0 -0
- package/apps/studio/stickers/project-status/group-108.png +0 -0
- package/apps/studio/stickers/project-status/group-109.png +0 -0
- package/apps/studio/stickers/project-status/group-110.png +0 -0
- package/apps/studio/stickers/project-status/group-111.png +0 -0
- package/apps/studio/stickers/project-status/group-112.png +0 -0
- package/apps/studio/stickers/project-status/group-113.png +0 -0
- package/apps/studio/stickers/project-status/group-114.png +0 -0
- package/apps/studio/stickers/project-status/group-115.png +0 -0
- package/apps/studio/stickers/project-status/group-117.png +0 -0
- package/apps/studio/stickers/project-status/group-118.png +0 -0
- package/apps/studio/stickers/project-status/group-119.png +0 -0
- package/apps/studio/stickers/project-status/group-120.png +0 -0
- package/apps/studio/stickers/project-status/group-121.png +0 -0
- package/apps/studio/stickers/project-status/group-122.png +0 -0
- package/apps/studio/stickers/project-status/group-41.png +0 -0
- package/apps/studio/stickers/project-status/group-42.png +0 -0
- package/apps/studio/stickers/project-status/group-43.png +0 -0
- package/apps/studio/stickers/project-status/group-44.png +0 -0
- package/apps/studio/stickers/project-status/group-45.png +0 -0
- package/apps/studio/stickers/project-status/group-46.png +0 -0
- package/apps/studio/stickers/project-status/group-47.png +0 -0
- package/apps/studio/stickers/project-status/group-48.png +0 -0
- package/apps/studio/stickers/project-status/group-49.png +0 -0
- package/apps/studio/stickers/project-status/group-50.png +0 -0
- package/apps/studio/stickers/project-status/group-51.png +0 -0
- package/apps/studio/stickers/project-status/group-52.png +0 -0
- package/apps/studio/stickers/project-status/group-53.png +0 -0
- package/apps/studio/stickers/project-status/group-54.png +0 -0
- package/apps/studio/stickers/project-status/group-55.png +0 -0
- package/apps/studio/stickers/project-status/group-56.png +0 -0
- package/apps/studio/stickers/project-status/group-57.png +0 -0
- package/apps/studio/stickers/project-status/group-58.png +0 -0
- package/apps/studio/stickers/project-status/group-59.png +0 -0
- package/apps/studio/stickers/project-status/group-60.png +0 -0
- package/apps/studio/stickers/project-status/group-61.png +0 -0
- package/apps/studio/stickers/project-status/group-62.png +0 -0
- package/apps/studio/stickers/project-status/group-63.png +0 -0
- package/apps/studio/stickers/project-status/group-64.png +0 -0
- package/apps/studio/stickers/project-status/group-65.png +0 -0
- package/apps/studio/stickers/project-status/group-66.png +0 -0
- package/apps/studio/stickers/project-status/group-67.png +0 -0
- package/apps/studio/stickers/project-status/group-68.png +0 -0
- package/apps/studio/stickers/project-status/group-69.png +0 -0
- package/apps/studio/stickers/project-status/group-70.png +0 -0
- package/apps/studio/stickers/project-status/group-71.png +0 -0
- package/apps/studio/stickers/project-status/group-72.png +0 -0
- package/apps/studio/stickers/project-status/group-73.png +0 -0
- package/apps/studio/stickers/project-status/group-74.png +0 -0
- package/apps/studio/stickers/project-status/group-75.png +0 -0
- package/apps/studio/stickers/project-status/group-76.png +0 -0
- package/apps/studio/stickers/project-status/group-77.png +0 -0
- package/apps/studio/stickers/project-status/group-78.png +0 -0
- package/apps/studio/stickers/project-status/group-79.png +0 -0
- package/apps/studio/stickers/project-status/group-80.png +0 -0
- package/apps/studio/stickers/project-status/group-81.png +0 -0
- package/apps/studio/stickers/project-status/group-82.png +0 -0
- package/apps/studio/stickers/project-status/group-83.png +0 -0
- package/apps/studio/stickers/project-status/group-84.png +0 -0
- package/apps/studio/stickers/project-status/group-85.png +0 -0
- package/apps/studio/stickers/project-status/group-86.png +0 -0
- package/apps/studio/stickers/project-status/group-87.png +0 -0
- package/apps/studio/stickers/project-status/group-88.png +0 -0
- package/apps/studio/stickers/project-status/group-89.png +0 -0
- package/apps/studio/stickers/project-status/group-90.png +0 -0
- package/apps/studio/stickers/project-status/group-91.png +0 -0
- package/apps/studio/stickers/project-status/group-92.png +0 -0
- package/apps/studio/stickers/project-status/group-93.png +0 -0
- package/apps/studio/stickers/project-status/group-94.png +0 -0
- package/apps/studio/stickers/project-status/group-96.png +0 -0
- package/apps/studio/stickers/project-status/group-97.png +0 -0
- package/apps/studio/stickers/project-status/group-98.png +0 -0
- package/apps/studio/stickers/project-status/group-99.png +0 -0
- package/apps/studio/stickers/project-status/manifest.json +328 -0
- package/apps/studio/test/acp-bootstrap-brief.test.ts +11 -0
- package/apps/studio/test/acp-commands.test.ts +2 -1
- package/apps/studio/test/asset-api.test.ts +3 -3
- package/apps/studio/test/canvas-edit.test.ts +17 -6
- package/apps/studio/test/canvas-origin-gate.test.ts +19 -0
- package/apps/studio/test/dns-rebinding-guard.test.ts +27 -0
- package/apps/studio/test/dynamic-text-edit.test.ts +162 -0
- package/apps/studio/test/edit-source-command.test.ts +23 -0
- package/apps/studio/test/element-structural-edit.test.ts +46 -0
- package/apps/studio/test/export-center.test.tsx +287 -0
- package/apps/studio/test/export-shim-multi-capture.test.ts +83 -0
- package/apps/studio/test/exporters/history.test.ts +32 -3
- package/apps/studio/test/exporters/jobs.test.ts +263 -0
- package/apps/studio/test/footage-store.test.ts +100 -0
- package/apps/studio/test/git-branches.test.ts +97 -0
- package/apps/studio/test/github-api.test.ts +56 -1
- package/apps/studio/test/input-router.test.ts +33 -0
- package/apps/studio/test/inspect-script-syntax.test.ts +53 -0
- package/apps/studio/test/photo-canvas-bundle.test.ts +61 -0
- package/apps/studio/test/photo-edit-api.test.ts +201 -0
- package/apps/studio/test/photo-filters.test.ts +149 -0
- package/apps/studio/test/photo-pipeline.test.ts +106 -0
- package/apps/studio/test/photo-taxonomy.test.ts +96 -0
- package/apps/studio/test/read-annotations.test.ts +164 -0
- package/apps/studio/test/shell-importmap.test.ts +49 -0
- package/apps/studio/test/text-editability-stamp.test.ts +131 -0
- package/apps/studio/test/timeline-parse.test.ts +57 -0
- package/apps/studio/test/tool-palette-insert-anchor.test.ts +5 -3
- package/apps/studio/test/undo-stack.test.ts +33 -0
- package/apps/studio/test/video-asset.test.ts +5 -5
- package/apps/studio/test/video-render-bridge.test.ts +23 -1
- package/apps/studio/text-caret.ts +239 -0
- package/apps/studio/tool-palette.tsx +43 -20
- package/apps/studio/undo-stack.ts +94 -2
- package/apps/studio/use-annotation-resize.tsx +4 -4
- package/apps/studio/use-canvas-media-drop.tsx +38 -1
- package/apps/studio/use-selection-set.tsx +21 -0
- package/apps/studio/video-comp.tsx +48 -7
- package/apps/studio/whats-new.json +70 -0
- package/apps/studio/ws.ts +6 -0
- package/cli/commands/design.mjs +34 -3
- package/cli/lib/gitignore-block.mjs +2 -0
- package/package.json +10 -10
- package/plugins/design/templates/_shell.html +1 -0
|
@@ -26,6 +26,7 @@ import {
|
|
|
26
26
|
createContext,
|
|
27
27
|
type KeyboardEvent as ReactKeyboardEvent,
|
|
28
28
|
type PointerEvent as ReactPointerEvent,
|
|
29
|
+
type RefObject,
|
|
29
30
|
useCallback,
|
|
30
31
|
useContext,
|
|
31
32
|
useEffect,
|
|
@@ -115,6 +116,7 @@ import {
|
|
|
115
116
|
SECTION_MIN_SIZE,
|
|
116
117
|
type SectionStroke,
|
|
117
118
|
SHAPE_DEFAULT_SIZE,
|
|
119
|
+
STICKER_DROP_SIZE,
|
|
118
120
|
STICKY_CORNER_RADIUS,
|
|
119
121
|
STICKY_DEFAULT_H,
|
|
120
122
|
STICKY_DEFAULT_W,
|
|
@@ -156,7 +158,15 @@ import { useViewportControllerContext, useWorldRefContext } from './canvas-lib.t
|
|
|
156
158
|
import { buildAnnotationStrokesRecord } from './commands/annotation-strokes-command.ts';
|
|
157
159
|
import { ensureMenuStyles as ensureCtxMenuStyles } from './context-menu.tsx';
|
|
158
160
|
import { crossedDragThreshold, type Tool } from './input-router.tsx';
|
|
159
|
-
import {
|
|
161
|
+
import { mountCaret, placeCaretAt } from './text-caret.ts';
|
|
162
|
+
import {
|
|
163
|
+
AnnotationResizeOverlay,
|
|
164
|
+
bboxResize,
|
|
165
|
+
type Corner,
|
|
166
|
+
padDX,
|
|
167
|
+
padDY,
|
|
168
|
+
type ResizeMods,
|
|
169
|
+
} from './use-annotation-resize.tsx';
|
|
160
170
|
import { useAnnotationSelectionOptional } from './use-annotation-selection.tsx';
|
|
161
171
|
import { useAnnotationsVisibility } from './use-annotations-visibility.tsx';
|
|
162
172
|
import {
|
|
@@ -168,7 +178,7 @@ import {
|
|
|
168
178
|
useCanvasMediaDrop,
|
|
169
179
|
} from './use-canvas-media-drop.tsx';
|
|
170
180
|
import { useChromeVisibility } from './use-chrome-visibility.tsx';
|
|
171
|
-
import { useCollab } from './use-collab.tsx';
|
|
181
|
+
import { colorForName, useCollab } from './use-collab.tsx';
|
|
172
182
|
import { useSelectionSetOptional } from './use-selection-set.tsx';
|
|
173
183
|
import { type ShapeKind, useToolMode } from './use-tool-mode.tsx';
|
|
174
184
|
import { useUndoSinks, useUndoStackOptional } from './use-undo-stack.tsx';
|
|
@@ -399,6 +409,70 @@ function isMediaPlayerTarget(e: Event): boolean {
|
|
|
399
409
|
return !!(t && typeof t.closest === 'function' && t.closest('[data-mediaref-player]'));
|
|
400
410
|
}
|
|
401
411
|
|
|
412
|
+
/**
|
|
413
|
+
* Dogfood fix — the Text tool's click-through target for ARTBOARD text. This
|
|
414
|
+
* layer's own input-capture div sits above the artboard content (z-index), so
|
|
415
|
+
* `elementsFromPoint` (the full z-stack at that point, topmost first) is
|
|
416
|
+
* needed to see past it. Mirrors canvas-shell.tsx's own `isLeafText` check
|
|
417
|
+
* exactly (all children are text nodes) so "would this be a leaf-text edit
|
|
418
|
+
* target" agrees between the Text-tool click-through and the native
|
|
419
|
+
* double-click path it delegates to.
|
|
420
|
+
*/
|
|
421
|
+
function findEditableElementAt(clientX: number, clientY: number): HTMLElement | null {
|
|
422
|
+
if (typeof document === 'undefined' || typeof document.elementsFromPoint !== 'function') {
|
|
423
|
+
return null;
|
|
424
|
+
}
|
|
425
|
+
const stack = document.elementsFromPoint(clientX, clientY);
|
|
426
|
+
for (const el of stack) {
|
|
427
|
+
const stamped = el.closest?.('[data-cd-id]') as HTMLElement | null;
|
|
428
|
+
if (!stamped) continue;
|
|
429
|
+
const kids = Array.from(stamped.childNodes);
|
|
430
|
+
const isLeafText = kids.length > 0 && kids.every((n) => n.nodeType === 3);
|
|
431
|
+
return isLeafText ? stamped : null;
|
|
432
|
+
}
|
|
433
|
+
return null;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* Phase 4 (unified-text-editing) — the Text tool's click-through target for
|
|
438
|
+
* ANNOTATION strokes. Deliberately GEOMETRIC (world-coord bbox containment),
|
|
439
|
+
* not DOM elementsFromPoint: while a draw tool is armed, every stroke node
|
|
440
|
+
* renders with pointer-events:none (`hitMode`), which makes it invisible to
|
|
441
|
+
* DOM hit-testing — measured in the WKWebView harness. Walks strokes topmost
|
|
442
|
+
* (last-rendered) first. An anchored text is skipped — its HOST shape's bbox
|
|
443
|
+
* already covers it and resolves to the same editor. A section matches only
|
|
444
|
+
* on its label CHIP (geometry mirrored from SectionLabelChip) so a click
|
|
445
|
+
* inside the region body still drops a NEW text there.
|
|
446
|
+
*/
|
|
447
|
+
const TEXT_EDITABLE_TOOLS = new Set(['text', 'sticky', 'rect', 'ellipse', 'polygon', 'section']);
|
|
448
|
+
|
|
449
|
+
function findTextStrokeAt(
|
|
450
|
+
wx: number,
|
|
451
|
+
wy: number,
|
|
452
|
+
strokes: readonly Stroke[],
|
|
453
|
+
zoom: number
|
|
454
|
+
): string | null {
|
|
455
|
+
for (let i = strokes.length - 1; i >= 0; i--) {
|
|
456
|
+
const s = strokes[i];
|
|
457
|
+
if (!s || !TEXT_EDITABLE_TOOLS.has(s.tool)) continue;
|
|
458
|
+
if (s.tool === 'section') {
|
|
459
|
+
const x = Math.min(s.x, s.x + s.w);
|
|
460
|
+
const y = Math.min(s.y, s.y + s.h);
|
|
461
|
+
const fontSize = SECTION_LABEL_FONT / zoom;
|
|
462
|
+
const chipH = SECTION_LABEL_H / zoom;
|
|
463
|
+
const gap = 4 / zoom;
|
|
464
|
+
const chipW = Math.max(56 / zoom, s.label.length * fontSize * 0.62 + 18 / zoom);
|
|
465
|
+
if (wx >= x && wx <= x + chipW && wy >= y - chipH - gap && wy <= y - gap) return s.id;
|
|
466
|
+
continue;
|
|
467
|
+
}
|
|
468
|
+
if (s.tool === 'text' && s.anchorId != null && s.anchorId !== '') continue;
|
|
469
|
+
const bb = strokeBBox(s);
|
|
470
|
+
if (!bb) continue;
|
|
471
|
+
if (wx >= bb.x && wx <= bb.x + bb.w && wy >= bb.y && wy <= bb.y + bb.h) return s.id;
|
|
472
|
+
}
|
|
473
|
+
return null;
|
|
474
|
+
}
|
|
475
|
+
|
|
402
476
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
403
477
|
// Styles
|
|
404
478
|
|
|
@@ -573,6 +647,12 @@ const ANNOT_CSS = `
|
|
|
573
647
|
See DDR-067. (No backticks in this comment: the whole block is a JS template
|
|
574
648
|
literal, so a backtick here would terminate the string.) */
|
|
575
649
|
.dc-annot-editor, .dc-annot-editor * { cursor: text !important; }
|
|
650
|
+
/* Phase 7 (unified-text-editing) — hover affordance parity with artboard leaf
|
|
651
|
+
text: a standalone text stroke invites editing with the I-beam in Move mode
|
|
652
|
+
(double-click / Text-tool click enters its editor in place). Shapes and
|
|
653
|
+
stickies keep the selection arrow — their whole body is a move/select
|
|
654
|
+
target first. */
|
|
655
|
+
.dc-annot-svg text[data-tool="text"] { cursor: text; }
|
|
576
656
|
/* FigJam v3 — connection dots on a selected bindable shape. The important flag
|
|
577
657
|
beats use-tool-mode's blanket star-cursor rule (same fight as the editor +
|
|
578
658
|
resize handles — DDR-067). */
|
|
@@ -645,7 +725,7 @@ export function useStrokesStore(): StrokesStoreValue | null {
|
|
|
645
725
|
// the main tool palette, the in-canvas draw chrome, the minimap, and the
|
|
646
726
|
// right-click menu. Clicks on these route to their own handlers.
|
|
647
727
|
const CHROME_SELECTOR =
|
|
648
|
-
'.dc-annot-conn-dot, .dc-annot-ctx, .dc-tool-palette, .dc-annot-chrome, .dc-mm, .dc-context-menu, .dc-tp-popover, .dc-multi-artboard-tb, .dc-elem-ctx-tb, .dc-cv-eq-spacing-layer, .cm-composer, .cm-thread, .cm-mention-popup, .cm-pin, .dc-annot-resize-handle, .dc-annot-rotate-zone, .dc-annot-editor';
|
|
728
|
+
'.dc-annot-conn-dot, .dc-annot-ctx, .dc-tool-palette, .dc-annot-chrome, .dc-mm, .dc-context-menu, .dc-tp-popover, .dc-multi-artboard-tb, .dc-elem-ctx-tb, .dc-cv-eq-spacing-layer, .cm-composer, .cm-thread, .cm-mention-popup, .cm-pin, .dc-annot-resize-handle, .dc-annot-rotate-zone, .dc-annot-editor, [data-group-resize-corner]';
|
|
649
729
|
|
|
650
730
|
const HINTS_KEY = 'maude-annot-hints-v1';
|
|
651
731
|
|
|
@@ -670,6 +750,81 @@ function showOnceHint(key: string, msg: string): void {
|
|
|
670
750
|
// can read the same state. Re-exported here for back-compat.
|
|
671
751
|
export { useAnnotationsVisibility } from './use-annotations-visibility.tsx';
|
|
672
752
|
|
|
753
|
+
/**
|
|
754
|
+
* Phase 2 (whiteboard-improvements) — proportional group resize. Maps one
|
|
755
|
+
* stroke's geometry from its position inside the group's START bbox (`b0`)
|
|
756
|
+
* to the equivalent position inside the RESIZED bbox (`b1`) — the same
|
|
757
|
+
* affine transform applied to every selected member, so the whole selection
|
|
758
|
+
* scales as one rigid composition anchored at whichever corner `bboxResize`
|
|
759
|
+
* derived `b1` from (reused verbatim for the group's own outer bbox — see
|
|
760
|
+
* the pointerdown handler below). Returns null for a stroke with nothing to
|
|
761
|
+
* scale: anchored text inherits its host's bbox at render time already, so
|
|
762
|
+
* scaling it too would double-transform it.
|
|
763
|
+
*/
|
|
764
|
+
function scaleStrokeInGroup(
|
|
765
|
+
s: Stroke,
|
|
766
|
+
b0: { x: number; y: number; w: number; h: number },
|
|
767
|
+
b1: { x: number; y: number; w: number; h: number }
|
|
768
|
+
): Partial<Stroke> | null {
|
|
769
|
+
if (s.tool === 'text' && s.anchorId != null && s.anchorId !== '') return null;
|
|
770
|
+
const sx = b0.w === 0 ? 1 : b1.w / b0.w;
|
|
771
|
+
const sy = b0.h === 0 ? 1 : b1.h / b0.h;
|
|
772
|
+
// Non-uniform group stretches (no Shift held) still need ONE scalar for
|
|
773
|
+
// things that don't have an independent width/height, like font size.
|
|
774
|
+
const avgScale = (sx + sy) / 2;
|
|
775
|
+
const tp = (px: number, py: number): [number, number] => [
|
|
776
|
+
b1.x + (px - b0.x) * sx,
|
|
777
|
+
b1.y + (py - b0.y) * sy,
|
|
778
|
+
];
|
|
779
|
+
if (s.tool === 'pen') {
|
|
780
|
+
return { points: s.points.map(([px, py]) => tp(px, py)) } as Partial<PenStroke>;
|
|
781
|
+
}
|
|
782
|
+
if (s.tool === 'arrow') {
|
|
783
|
+
const [x1, y1] = tp(s.x1, s.y1);
|
|
784
|
+
const [x2, y2] = tp(s.x2, s.y2);
|
|
785
|
+
return { x1, y1, x2, y2 } as Partial<ArrowStroke>;
|
|
786
|
+
}
|
|
787
|
+
if (s.tool === 'ellipse') {
|
|
788
|
+
const [cx, cy] = tp(s.cx, s.cy);
|
|
789
|
+
return {
|
|
790
|
+
cx,
|
|
791
|
+
cy,
|
|
792
|
+
rx: Math.max(1, s.rx * sx),
|
|
793
|
+
ry: Math.max(1, s.ry * sy),
|
|
794
|
+
} as Partial<EllipseStroke>;
|
|
795
|
+
}
|
|
796
|
+
if (s.tool === 'text') {
|
|
797
|
+
// Standalone (unanchored) text — its own origin scales with the group;
|
|
798
|
+
// font size scales by the average factor so a non-uniform stretch keeps
|
|
799
|
+
// the text legible instead of only stretching its box.
|
|
800
|
+
const [x, y] = tp(s.x ?? 0, s.y ?? 0);
|
|
801
|
+
return {
|
|
802
|
+
x,
|
|
803
|
+
y,
|
|
804
|
+
fontSize: Math.max(6, Math.round(s.fontSize * avgScale)),
|
|
805
|
+
} as Partial<TextStroke>;
|
|
806
|
+
}
|
|
807
|
+
if (
|
|
808
|
+
s.tool === 'rect' ||
|
|
809
|
+
s.tool === 'polygon' ||
|
|
810
|
+
s.tool === 'link' ||
|
|
811
|
+
s.tool === 'mediaref' ||
|
|
812
|
+
s.tool === 'section' ||
|
|
813
|
+
s.tool === 'image' ||
|
|
814
|
+
s.tool === 'sticky'
|
|
815
|
+
) {
|
|
816
|
+
const bb = strokeBBox(s);
|
|
817
|
+
if (!bb) return null;
|
|
818
|
+
const [x, y] = tp(bb.x, bb.y);
|
|
819
|
+
const patch: Partial<Stroke> = { x, y, w: Math.max(1, bb.w * sx), h: Math.max(1, bb.h * sy) };
|
|
820
|
+
if (s.tool === 'sticky') {
|
|
821
|
+
(patch as Partial<StickyStroke>).fontSize = Math.max(6, Math.round(s.fontSize * avgScale));
|
|
822
|
+
}
|
|
823
|
+
return patch;
|
|
824
|
+
}
|
|
825
|
+
return null;
|
|
826
|
+
}
|
|
827
|
+
|
|
673
828
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
674
829
|
// Component
|
|
675
830
|
|
|
@@ -757,6 +912,12 @@ export function AnnotationsLayer() {
|
|
|
757
912
|
[visibilityCtx]
|
|
758
913
|
);
|
|
759
914
|
const [editingId, setEditingId] = useState<string | null>(null);
|
|
915
|
+
// Phase 3 (unified-text-editing) — the click that OPENED the editor, so the
|
|
916
|
+
// editor can place a collapsed caret at that exact character on mount
|
|
917
|
+
// instead of select-all. Set only by pointer entry paths (double-click /
|
|
918
|
+
// text-tool click-through); keyboard entries (Enter, fresh-create,
|
|
919
|
+
// ⌘Enter chain) leave it null → select-all, the rename convention.
|
|
920
|
+
const [editCaretPoint, setEditCaretPoint] = useState<{ x: number; y: number } | null>(null);
|
|
760
921
|
|
|
761
922
|
const fileRef = useRef<string | undefined>(undefined);
|
|
762
923
|
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
@@ -1068,6 +1229,7 @@ export function AnnotationsLayer() {
|
|
|
1068
1229
|
useEffect(() => {
|
|
1069
1230
|
if (typeof window === 'undefined') return;
|
|
1070
1231
|
const onMessage = (e: MessageEvent) => {
|
|
1232
|
+
if (e.source !== window.parent) return;
|
|
1071
1233
|
const m = e.data as { dgn?: string; visible?: boolean } | null;
|
|
1072
1234
|
if (!m || typeof m !== 'object' || !m.dgn) return;
|
|
1073
1235
|
if (m.dgn === 'view-annotations') {
|
|
@@ -1101,6 +1263,39 @@ export function AnnotationsLayer() {
|
|
|
1101
1263
|
: ({ ...s, src: path } as MediaRefStroke);
|
|
1102
1264
|
});
|
|
1103
1265
|
commitStrokes(before, after, 'replace media');
|
|
1266
|
+
return;
|
|
1267
|
+
}
|
|
1268
|
+
// Phase 4 (whiteboard-improvements) — the shell's StickerPicker (main-
|
|
1269
|
+
// origin) already uploaded the picked bundled sticker to a PROJECT asset
|
|
1270
|
+
// path via /_api/asset (canvas-origin-allowlisted — the round-trip
|
|
1271
|
+
// through the shell exists only because /_stickers/* itself is main-
|
|
1272
|
+
// origin-only, same posture as AssetPicker/replace-annotation-media
|
|
1273
|
+
// above). Drop it at the current viewport center — no cross-origin
|
|
1274
|
+
// cursor position to reuse. Inlines the screenToWorld formula (that
|
|
1275
|
+
// callback isn't declared until below this effect) against vpRef, which
|
|
1276
|
+
// is.
|
|
1277
|
+
if (m.dgn === 'insert-sticker' && typeof (m as { path?: unknown }).path === 'string') {
|
|
1278
|
+
const path = (m as { path: string }).path;
|
|
1279
|
+
const v = vpRef.current ?? { x: 0, y: 0, zoom: 1 };
|
|
1280
|
+
const z = v.zoom || 1;
|
|
1281
|
+
const cx = typeof window !== 'undefined' ? window.innerWidth / 2 : 0;
|
|
1282
|
+
const cy = typeof window !== 'undefined' ? window.innerHeight / 2 : 0;
|
|
1283
|
+
const wx = (cx - v.x) / z;
|
|
1284
|
+
const wy = (cy - v.y) / z;
|
|
1285
|
+
const size = STICKER_DROP_SIZE;
|
|
1286
|
+
const id = rid();
|
|
1287
|
+
const stroke: ImageStroke = {
|
|
1288
|
+
id,
|
|
1289
|
+
tool: 'image',
|
|
1290
|
+
x: wx - size / 2,
|
|
1291
|
+
y: wy - size / 2,
|
|
1292
|
+
w: size,
|
|
1293
|
+
h: size,
|
|
1294
|
+
href: path,
|
|
1295
|
+
};
|
|
1296
|
+
const before = strokesRef.current;
|
|
1297
|
+
commitStrokes(before, [...before, stroke], 'add sticker');
|
|
1298
|
+
annotSel?.replace([id]);
|
|
1104
1299
|
}
|
|
1105
1300
|
};
|
|
1106
1301
|
window.addEventListener('message', onMessage);
|
|
@@ -1387,6 +1582,9 @@ export function AnnotationsLayer() {
|
|
|
1387
1582
|
} else if (tool === 'sticky') {
|
|
1388
1583
|
// Phase 21 — drag-create a paper card. Default size if the user just
|
|
1389
1584
|
// taps (no drag) is applied in endStroke.
|
|
1585
|
+
// Phase 3 (whiteboard-improvements) — stamp who drew it from presence
|
|
1586
|
+
// identity (git user.name via useCollab, else its anonymous-* / no-op
|
|
1587
|
+
// fallback); collab is optional (test harnesses without a provider).
|
|
1390
1588
|
setDrawing({
|
|
1391
1589
|
id,
|
|
1392
1590
|
tool: 'sticky',
|
|
@@ -1398,6 +1596,7 @@ export function AnnotationsLayer() {
|
|
|
1398
1596
|
text: '',
|
|
1399
1597
|
fontSize: DEFAULT_FONT_SIZE,
|
|
1400
1598
|
cornerRadius: STICKY_CORNER_RADIUS,
|
|
1599
|
+
...(collab?.myName ? { authorName: collab.myName, authorId: collab.myConnId } : null),
|
|
1401
1600
|
});
|
|
1402
1601
|
} else if (tool === 'section') {
|
|
1403
1602
|
// FigJam v3 — drag-create a labelled container; a bare tap drops the
|
|
@@ -1413,6 +1612,42 @@ export function AnnotationsLayer() {
|
|
|
1413
1612
|
color: DEFAULT_SECTION_COLOR,
|
|
1414
1613
|
});
|
|
1415
1614
|
} else if (tool === 'text') {
|
|
1615
|
+
// Dogfood fix + Phase 4 (unified-text-editing) — the Text tool
|
|
1616
|
+
// clicking EXISTING editable text edits it in place rather than
|
|
1617
|
+
// dropping a new standalone annotation on top of it. Precedence is
|
|
1618
|
+
// z-order: annotation strokes paint above artboard content, so the
|
|
1619
|
+
// geometric stroke hit-test (findTextStrokeAt — DOM hit-testing can't
|
|
1620
|
+
// see pointer-events:none stroke nodes while a tool is armed) runs
|
|
1621
|
+
// first; then artboard leaf-text (delegated to canvas-shell.tsx,
|
|
1622
|
+
// which owns the DOM/contentEditable side, via maude:enter-text-edit).
|
|
1623
|
+
const strokeId = findTextStrokeAt(wx, wy, strokesRef.current, vpRef.current?.zoom || 1);
|
|
1624
|
+
if (strokeId) {
|
|
1625
|
+
setEditCaretPoint({ x: e.clientX, y: e.clientY });
|
|
1626
|
+
setEditingId(strokeId);
|
|
1627
|
+
if (annotSel) annotSel.replace(strokeId);
|
|
1628
|
+
setTool('move');
|
|
1629
|
+
return true;
|
|
1630
|
+
}
|
|
1631
|
+
const editableTarget = findEditableElementAt(e.clientX, e.clientY);
|
|
1632
|
+
if (editableTarget) {
|
|
1633
|
+
// Phase 6 — leaf-looking text the engine would refuse (mixed /
|
|
1634
|
+
// expression source; no build-time data-cd-editable marker) gets an
|
|
1635
|
+
// honest hint, not a new annotation dropped on top of it.
|
|
1636
|
+
if (!editableTarget.hasAttribute('data-cd-editable')) {
|
|
1637
|
+
showCanvasToast(
|
|
1638
|
+
'This text is filled in from code (a variable) — edit it via chat or /design:edit.'
|
|
1639
|
+
);
|
|
1640
|
+
setTool('move');
|
|
1641
|
+
return true;
|
|
1642
|
+
}
|
|
1643
|
+
document.dispatchEvent(
|
|
1644
|
+
new CustomEvent('maude:enter-text-edit', {
|
|
1645
|
+
detail: { el: editableTarget, clientX: e.clientX, clientY: e.clientY },
|
|
1646
|
+
})
|
|
1647
|
+
);
|
|
1648
|
+
setTool('move');
|
|
1649
|
+
return true;
|
|
1650
|
+
}
|
|
1416
1651
|
// Phase 21 — single click drops an editable caret at the click point.
|
|
1417
1652
|
// No stroke is created until the user commits real text (mirrors the
|
|
1418
1653
|
// anchored double-click flow), so an empty caret leaves nothing behind.
|
|
@@ -1577,10 +1812,18 @@ export function AnnotationsLayer() {
|
|
|
1577
1812
|
// AnnotationsLayer without the provider), so guard the call.
|
|
1578
1813
|
if (annotSel) annotSel.replace(committed.id);
|
|
1579
1814
|
// Phase 21 — a fresh sticky opens in edit mode (FigJam parity: drop a
|
|
1580
|
-
// note, type immediately).
|
|
1581
|
-
|
|
1815
|
+
// note, type immediately). Phase 1 whiteboard-improvements — shapes get
|
|
1816
|
+
// the same treatment (rect/ellipse/polygon are all AnchorHost, so
|
|
1817
|
+
// setEditingId resolves them via editingTarget's 'anchored' branch same
|
|
1818
|
+
// as a double-click would).
|
|
1819
|
+
if (
|
|
1820
|
+
committed.tool === 'sticky' ||
|
|
1821
|
+
committed.tool === 'rect' ||
|
|
1822
|
+
committed.tool === 'ellipse' ||
|
|
1823
|
+
committed.tool === 'polygon'
|
|
1824
|
+
) {
|
|
1582
1825
|
setEditingId(committed.id);
|
|
1583
|
-
showOnceHint('chain', '⌘Enter commits and creates the next
|
|
1826
|
+
showOnceHint('chain', '⌘Enter commits and creates the next one beside it.');
|
|
1584
1827
|
}
|
|
1585
1828
|
}
|
|
1586
1829
|
// T18 / T19 — flip the tool back to Move after every commit UNLESS sticky
|
|
@@ -1697,6 +1940,20 @@ export function AnnotationsLayer() {
|
|
|
1697
1940
|
altDup: boolean;
|
|
1698
1941
|
} | null>(null);
|
|
1699
1942
|
|
|
1943
|
+
// Phase 2 (whiteboard-improvements) — proportional group resize drag state.
|
|
1944
|
+
// See the gesture effect below (registered after strokesStoreRef).
|
|
1945
|
+
const groupResizeRef = useRef<{
|
|
1946
|
+
pointerId: number;
|
|
1947
|
+
corner: Corner;
|
|
1948
|
+
/** The selection's own union bbox at drag start — unpadded (AnnotGroupBbox
|
|
1949
|
+
* renders it with a screen-constant pad; the resize math works in the raw
|
|
1950
|
+
* content bbox, same convention single-resize's padDX/padDY correct for). */
|
|
1951
|
+
groupB0: { x: number; y: number; w: number; h: number };
|
|
1952
|
+
ids: string[];
|
|
1953
|
+
/** Full-strokes undo baseline (the drag-start snapshot). */
|
|
1954
|
+
undoBase: Stroke[];
|
|
1955
|
+
} | null>(null);
|
|
1956
|
+
|
|
1700
1957
|
// Drag-select marquee state. World-coord rectangle (anchor + cursor); the
|
|
1701
1958
|
// cursor end animates with pointermove. `null` = no marquee active.
|
|
1702
1959
|
const [marquee, setMarquee] = useState<{
|
|
@@ -1754,7 +2011,18 @@ export function AnnotationsLayer() {
|
|
|
1754
2011
|
// branch below and DESELECTED them.
|
|
1755
2012
|
let ids: string[] | null = null;
|
|
1756
2013
|
if (strokeId) {
|
|
1757
|
-
|
|
2014
|
+
// feature-photo-editor — an image-stroke hit routes to the Photo-only
|
|
2015
|
+
// Inspector tab (`edit-annotation-photo-request`, the singleSelectedImageId
|
|
2016
|
+
// effect below), which the shell shows via `photoSel` taking priority over
|
|
2017
|
+
// the DOM `selected` channel. `elementSel.clear()` posts a DEBOUNCED
|
|
2018
|
+
// (50ms, use-selection-set.tsx POST_DEBOUNCE_MS) `select-set: null` — that
|
|
2019
|
+
// lands AFTER the (undebounced) photo-request and the shell's `select-set`
|
|
2020
|
+
// handler unconditionally nulls `photoSel` back out, so the Photo tab
|
|
2021
|
+
// flickers open then goes blank. Skip the clear for an image stroke: it
|
|
2022
|
+
// isn't needed (photoSel already takes precedence in tab resolution) and
|
|
2023
|
+
// is the only case that races with a still-in-flight photo-tab request.
|
|
2024
|
+
const strokeTool = target?.closest?.('[data-id][data-tool]')?.getAttribute('data-tool');
|
|
2025
|
+
if (strokeTool !== 'image') elementSel?.clear();
|
|
1758
2026
|
// FigJam v3 — clicking a group member acts on the WHOLE outermost
|
|
1759
2027
|
// group. Double-click deep-selects the member (see the dblclick
|
|
1760
2028
|
// handler); an already-selected stroke keeps the current selection, so
|
|
@@ -2024,6 +2292,114 @@ export function AnnotationsLayer() {
|
|
|
2024
2292
|
const strokesStoreRef = useRef(strokesStore);
|
|
2025
2293
|
strokesStoreRef.current = strokesStore;
|
|
2026
2294
|
|
|
2295
|
+
// Phase 2 (whiteboard-improvements) — proportional group resize. A grabbed
|
|
2296
|
+
// corner (AnnotGroupBbox's `[data-group-resize-corner]` handles) scales the
|
|
2297
|
+
// SELECTION's own outer bbox via bboxResize — the exact single-shape corner-
|
|
2298
|
+
// anchor / Shift-aspect-lock / Alt-center-scale math, applied here to the
|
|
2299
|
+
// group's union bbox instead of one stroke's — then propagates that affine
|
|
2300
|
+
// transform to every selected stroke via scaleStrokeInGroup. One local
|
|
2301
|
+
// preview per pointermove, one commitStrokes on pointerup: mirrors the
|
|
2302
|
+
// hull-drag gesture above. Registering pointermove/up unconditionally
|
|
2303
|
+
// (no-op via the groupResizeRef null-check) rather than adding them inside
|
|
2304
|
+
// onDown matches AnnotationResizeOverlay's own pattern in
|
|
2305
|
+
// use-annotation-resize.tsx.
|
|
2306
|
+
useEffect(() => {
|
|
2307
|
+
if (typeof document === 'undefined') return;
|
|
2308
|
+
if (tool !== 'move') return;
|
|
2309
|
+
if (!annotSel) return;
|
|
2310
|
+
|
|
2311
|
+
const onDown = (e: PointerEvent) => {
|
|
2312
|
+
const target = e.target as Element | null;
|
|
2313
|
+
const cornerEl = target?.closest?.('[data-group-resize-corner]') ?? null;
|
|
2314
|
+
if (!cornerEl) return;
|
|
2315
|
+
const corner = cornerEl.getAttribute('data-group-resize-corner') as Corner | null;
|
|
2316
|
+
if (!corner) return;
|
|
2317
|
+
const ids = annotSel.selectedIds;
|
|
2318
|
+
if (ids.length < 2) return;
|
|
2319
|
+
const idSet = new Set(ids);
|
|
2320
|
+
const strokesNow = strokesStoreRef.current.strokes;
|
|
2321
|
+
let xMin = Number.POSITIVE_INFINITY;
|
|
2322
|
+
let yMin = Number.POSITIVE_INFINITY;
|
|
2323
|
+
let xMax = Number.NEGATIVE_INFINITY;
|
|
2324
|
+
let yMax = Number.NEGATIVE_INFINITY;
|
|
2325
|
+
let any = false;
|
|
2326
|
+
for (const s of strokesNow) {
|
|
2327
|
+
if (!idSet.has(s.id)) continue;
|
|
2328
|
+
const b = strokeBBox(s, anchorsById);
|
|
2329
|
+
if (!b) continue;
|
|
2330
|
+
any = true;
|
|
2331
|
+
if (b.x < xMin) xMin = b.x;
|
|
2332
|
+
if (b.y < yMin) yMin = b.y;
|
|
2333
|
+
if (b.x + b.w > xMax) xMax = b.x + b.w;
|
|
2334
|
+
if (b.y + b.h > yMax) yMax = b.y + b.h;
|
|
2335
|
+
}
|
|
2336
|
+
if (!any) return;
|
|
2337
|
+
e.preventDefault();
|
|
2338
|
+
e.stopPropagation();
|
|
2339
|
+
groupResizeRef.current = {
|
|
2340
|
+
pointerId: e.pointerId,
|
|
2341
|
+
corner,
|
|
2342
|
+
groupB0: { x: xMin, y: yMin, w: xMax - xMin, h: yMax - yMin },
|
|
2343
|
+
ids,
|
|
2344
|
+
undoBase: strokesNow,
|
|
2345
|
+
};
|
|
2346
|
+
try {
|
|
2347
|
+
(cornerEl as Element & { setPointerCapture?: (id: number) => void }).setPointerCapture?.(
|
|
2348
|
+
e.pointerId
|
|
2349
|
+
);
|
|
2350
|
+
} catch {
|
|
2351
|
+
/* some browsers reject capture on synthetic events */
|
|
2352
|
+
}
|
|
2353
|
+
};
|
|
2354
|
+
const onMove = (e: PointerEvent) => {
|
|
2355
|
+
const d = groupResizeRef.current;
|
|
2356
|
+
if (!d || e.pointerId !== d.pointerId) return;
|
|
2357
|
+
const [wxRaw, wyRaw] = screenToWorld(e.clientX, e.clientY);
|
|
2358
|
+
const zoom = vpRef.current?.zoom || 1;
|
|
2359
|
+
// Handles render padded OFF the group bbox (screen-constant, same as
|
|
2360
|
+
// AnnotGroupBbox's `pad`) — shift the cursor back onto the true corner
|
|
2361
|
+
// so the first move doesn't jump-grow by the pad amount (mirrors
|
|
2362
|
+
// single-resize applyResize's padDX/padDY correction).
|
|
2363
|
+
const padWorld = HALO_PAD_PX / zoom;
|
|
2364
|
+
const wx = wxRaw + padDX(d.corner) * padWorld;
|
|
2365
|
+
const wy = wyRaw + padDY(d.corner) * padWorld;
|
|
2366
|
+
const mods: ResizeMods = { shift: e.shiftKey, alt: e.altKey };
|
|
2367
|
+
const groupB1 = bboxResize(d.groupB0, d.corner, wx, wy, mods, false);
|
|
2368
|
+
const idSet = new Set(d.ids);
|
|
2369
|
+
// Local React state only — no commitStrokes, no PUT, matching the
|
|
2370
|
+
// hull-drag / single-resize preview convention (one undo record lands
|
|
2371
|
+
// on pointerup, not one per pixel moved).
|
|
2372
|
+
const next = recomputeBoundArrows(
|
|
2373
|
+
d.undoBase.map((s) => {
|
|
2374
|
+
if (!idSet.has(s.id)) return s;
|
|
2375
|
+
const patch = scaleStrokeInGroup(s, d.groupB0, groupB1);
|
|
2376
|
+
return patch ? ({ ...s, ...patch } as Stroke) : s;
|
|
2377
|
+
})
|
|
2378
|
+
);
|
|
2379
|
+
setStrokesState(next);
|
|
2380
|
+
};
|
|
2381
|
+
const onUp = (e: PointerEvent) => {
|
|
2382
|
+
const d = groupResizeRef.current;
|
|
2383
|
+
if (!d || e.pointerId !== d.pointerId) return;
|
|
2384
|
+
groupResizeRef.current = null;
|
|
2385
|
+
const final = strokesRef.current;
|
|
2386
|
+
// No-op drag (grabbed a handle, released without moving past the
|
|
2387
|
+
// resize's own resolution) skips the undo record.
|
|
2388
|
+
if (strokesShallowEqual(d.undoBase, final)) return;
|
|
2389
|
+
commitStrokes(d.undoBase, final, `resize ${d.ids.length} strokes`);
|
|
2390
|
+
};
|
|
2391
|
+
document.addEventListener('pointerdown', onDown, true);
|
|
2392
|
+
document.addEventListener('pointermove', onMove, true);
|
|
2393
|
+
document.addEventListener('pointerup', onUp, true);
|
|
2394
|
+
document.addEventListener('pointercancel', onUp, true);
|
|
2395
|
+
return () => {
|
|
2396
|
+
document.removeEventListener('pointerdown', onDown, true);
|
|
2397
|
+
document.removeEventListener('pointermove', onMove, true);
|
|
2398
|
+
document.removeEventListener('pointerup', onUp, true);
|
|
2399
|
+
document.removeEventListener('pointercancel', onUp, true);
|
|
2400
|
+
};
|
|
2401
|
+
}, [tool, annotSel, screenToWorld, anchorsById, commitStrokes]);
|
|
2402
|
+
|
|
2027
2403
|
// Double-click enters text-edit mode: rect/ellipse (anchored text), sticky
|
|
2028
2404
|
// (its own body), or a standalone text node (re-edit in place). Anchored text
|
|
2029
2405
|
// nodes are edited via their host, so a data-anchor-id text node is skipped.
|
|
@@ -2045,15 +2421,24 @@ export function AnnotationsLayer() {
|
|
|
2045
2421
|
const stroke = strokesRef.current.find((s) => s.id === id);
|
|
2046
2422
|
if (stroke && outermostGroupOf(stroke) && annotSel) {
|
|
2047
2423
|
e.preventDefault();
|
|
2424
|
+
e.stopPropagation();
|
|
2048
2425
|
annotSel.replace(id);
|
|
2049
2426
|
}
|
|
2050
2427
|
if (t === 'rect' || t === 'ellipse' || t === 'polygon' || t === 'sticky' || t === 'section') {
|
|
2428
|
+
// A double-click we've claimed for editing must not also reach the
|
|
2429
|
+
// canvas-shell dblclick-empty→fit() handler (bubble phase, below us) —
|
|
2430
|
+
// without this it fired right after, so renaming a section or editing
|
|
2431
|
+
// a shape's text jumped the viewport to fit().
|
|
2051
2432
|
e.preventDefault();
|
|
2433
|
+
e.stopPropagation();
|
|
2434
|
+
setEditCaretPoint({ x: e.clientX, y: e.clientY });
|
|
2052
2435
|
setEditingId(id);
|
|
2053
2436
|
return;
|
|
2054
2437
|
}
|
|
2055
2438
|
if (t === 'text' && !node.getAttribute('data-anchor-id')) {
|
|
2056
2439
|
e.preventDefault();
|
|
2440
|
+
e.stopPropagation();
|
|
2441
|
+
setEditCaretPoint({ x: e.clientX, y: e.clientY });
|
|
2057
2442
|
setEditingId(id);
|
|
2058
2443
|
}
|
|
2059
2444
|
};
|
|
@@ -2192,6 +2577,7 @@ export function AnnotationsLayer() {
|
|
|
2192
2577
|
const target = editingTargetRef.current;
|
|
2193
2578
|
setEditingId(null);
|
|
2194
2579
|
setPendingText(null);
|
|
2580
|
+
setEditCaretPoint(null);
|
|
2195
2581
|
if (!target) return;
|
|
2196
2582
|
if (target.kind === 'anchored') commitText(target.anchorId, text, fmt);
|
|
2197
2583
|
else if (target.kind === 'sticky') commitStickyText(target.sticky.id, text, fmt);
|
|
@@ -2211,6 +2597,7 @@ export function AnnotationsLayer() {
|
|
|
2211
2597
|
const cancelEditing = useCallback(() => {
|
|
2212
2598
|
setEditingId(null);
|
|
2213
2599
|
setPendingText(null);
|
|
2600
|
+
setEditCaretPoint(null);
|
|
2214
2601
|
}, []);
|
|
2215
2602
|
|
|
2216
2603
|
/**
|
|
@@ -2780,6 +3167,21 @@ export function AnnotationsLayer() {
|
|
|
2780
3167
|
} catch {
|
|
2781
3168
|
/* detached / cross-origin teardown */
|
|
2782
3169
|
}
|
|
3170
|
+
} else if (action === 'edit-photo') {
|
|
3171
|
+
// feature-photo-editor (Task 17) — "Edit Photo…" on an ImageStroke. Same
|
|
3172
|
+
// untrusted-origin REQUEST shape as Replace (DDR-054); the shell opens the
|
|
3173
|
+
// Photo-only Inspector tab on this stroke's content-addressed asset.
|
|
3174
|
+
if (sel.length !== 1) return;
|
|
3175
|
+
const target = strokesById.get(sel[0] as string);
|
|
3176
|
+
if (target?.tool !== 'image') return;
|
|
3177
|
+
try {
|
|
3178
|
+
window.parent.postMessage(
|
|
3179
|
+
{ dgn: 'edit-annotation-photo-request', id: target.id, asset: target.href },
|
|
3180
|
+
'*'
|
|
3181
|
+
);
|
|
3182
|
+
} catch {
|
|
3183
|
+
/* detached / cross-origin teardown */
|
|
3184
|
+
}
|
|
2783
3185
|
}
|
|
2784
3186
|
},
|
|
2785
3187
|
[annotSel, copySelection, pasteStrokesText, strokesById]
|
|
@@ -2797,6 +3199,34 @@ export function AnnotationsLayer() {
|
|
|
2797
3199
|
}
|
|
2798
3200
|
}, [selCount]);
|
|
2799
3201
|
|
|
3202
|
+
// feature-photo-editor — auto-open the Inspector's Photo tab whenever the
|
|
3203
|
+
// annotation selection becomes exactly one ImageStroke. Previously this
|
|
3204
|
+
// only fired from the "Edit Photo…" context-menu action; a plain click/
|
|
3205
|
+
// select left the Inspector showing its no-selection placeholder, since an
|
|
3206
|
+
// annotation stroke has no data-cd-id / DOM selection to ride the normal
|
|
3207
|
+
// `select`/`select-set` path. An ImageStroke has no OTHER inspectable
|
|
3208
|
+
// surface (Task 13: Photo is the only tab for this content type), so
|
|
3209
|
+
// requiring a right-click detour to reach it was pure friction. Same
|
|
3210
|
+
// `edit-annotation-photo-request` message + confused-deputy gate (DDR-054)
|
|
3211
|
+
// the context-menu action already used.
|
|
3212
|
+
const singleSelectedImageId =
|
|
3213
|
+
selCount === 1 && strokesById.get(annotSel?.selectedIds[0] as string)?.tool === 'image'
|
|
3214
|
+
? (annotSel?.selectedIds[0] as string)
|
|
3215
|
+
: null;
|
|
3216
|
+
useEffect(() => {
|
|
3217
|
+
if (!singleSelectedImageId) return;
|
|
3218
|
+
const s = strokesById.get(singleSelectedImageId);
|
|
3219
|
+
if (s?.tool !== 'image') return;
|
|
3220
|
+
try {
|
|
3221
|
+
window.parent.postMessage(
|
|
3222
|
+
{ dgn: 'edit-annotation-photo-request', id: s.id, asset: s.href },
|
|
3223
|
+
'*'
|
|
3224
|
+
);
|
|
3225
|
+
} catch {
|
|
3226
|
+
/* detached / cross-origin teardown */
|
|
3227
|
+
}
|
|
3228
|
+
}, [singleSelectedImageId, strokesById]);
|
|
3229
|
+
|
|
2800
3230
|
// Selected stroke halos — bboxes in world coords, vector-effect non-scaling-stroke.
|
|
2801
3231
|
const selectedStrokes = useMemo(() => {
|
|
2802
3232
|
if (!annotSel || annotSel.selectedIds.length === 0) return [] as Stroke[];
|
|
@@ -2834,6 +3264,7 @@ export function AnnotationsLayer() {
|
|
|
2834
3264
|
addTextHintId={editingTarget ? null : addTextHintId}
|
|
2835
3265
|
ghost={ghostPreview}
|
|
2836
3266
|
editingTarget={editingTarget}
|
|
3267
|
+
editCaretPoint={editCaretPoint}
|
|
2837
3268
|
inkColor={color}
|
|
2838
3269
|
onCommitEdit={commitEditing}
|
|
2839
3270
|
onCancelEdit={cancelEditing}
|
|
@@ -2863,6 +3294,11 @@ export function AnnotationsLayer() {
|
|
|
2863
3294
|
selectedStrokes.length === 1 &&
|
|
2864
3295
|
(selectedStrokes[0]?.tool === 'image' || selectedStrokes[0]?.tool === 'mediaref')
|
|
2865
3296
|
}
|
|
3297
|
+
canEditPhoto={
|
|
3298
|
+
selectedStrokes.length === 1 &&
|
|
3299
|
+
selectedStrokes[0]?.tool === 'image' &&
|
|
3300
|
+
/assets\/[0-9a-f]{8}\.[a-z0-9]+/i.test((selectedStrokes[0] as ImageStroke).href || '')
|
|
3301
|
+
}
|
|
2866
3302
|
onAction={onMenuAction}
|
|
2867
3303
|
onClose={() => setCtxMenu(null)}
|
|
2868
3304
|
/>
|
|
@@ -2988,6 +3424,7 @@ function AnnotationsSvg({
|
|
|
2988
3424
|
addTextHintId,
|
|
2989
3425
|
ghost,
|
|
2990
3426
|
editingTarget,
|
|
3427
|
+
editCaretPoint,
|
|
2991
3428
|
inkColor,
|
|
2992
3429
|
onCommitEdit,
|
|
2993
3430
|
onCancelEdit,
|
|
@@ -3020,6 +3457,8 @@ function AnnotationsSvg({
|
|
|
3020
3457
|
addTextHintId: string | null;
|
|
3021
3458
|
ghost: GhostDescriptor | null;
|
|
3022
3459
|
editingTarget: EditingTarget;
|
|
3460
|
+
/** The click that opened the editor — caret-at-click on mount (Phase 3). */
|
|
3461
|
+
editCaretPoint: { x: number; y: number } | null;
|
|
3023
3462
|
/** Live default ink (theme-aware) for a not-yet-born pending text caret. */
|
|
3024
3463
|
inkColor: string;
|
|
3025
3464
|
onCommitEdit: (text: string, fmt?: EditorFmt) => void;
|
|
@@ -3036,154 +3475,234 @@ function AnnotationsSvg({
|
|
|
3036
3475
|
// A sticky whose body is being edited hides its read-only text so the
|
|
3037
3476
|
// editor textarea (rendered below at the same bbox) isn't double-painted.
|
|
3038
3477
|
const editingStickyId = editingTarget?.kind === 'sticky' ? editingTarget.sticky.id : null;
|
|
3478
|
+
// Same double-paint issue for a section being renamed — its label chip must
|
|
3479
|
+
// hide while the StandaloneTextEditor sits at the same spot, else the old
|
|
3480
|
+
// label reads through behind the new text.
|
|
3481
|
+
const editingSectionId = editingTarget?.kind === 'section' ? editingTarget.section.id : null;
|
|
3039
3482
|
const anchoredExisting =
|
|
3040
3483
|
editingTarget?.kind === 'anchored'
|
|
3041
3484
|
? (strokes.find((s) => s.tool === 'text' && s.anchorId === editingTarget.anchorId) as
|
|
3042
3485
|
| TextStroke
|
|
3043
3486
|
| undefined)
|
|
3044
3487
|
: undefined;
|
|
3045
|
-
|
|
3046
|
-
|
|
3047
|
-
|
|
3048
|
-
|
|
3049
|
-
|
|
3050
|
-
|
|
3051
|
-
|
|
3052
|
-
|
|
3053
|
-
|
|
3054
|
-
|
|
3055
|
-
|
|
3056
|
-
|
|
3057
|
-
|
|
3058
|
-
|
|
3059
|
-
|
|
3060
|
-
|
|
3061
|
-
|
|
3062
|
-
|
|
3063
|
-
|
|
3064
|
-
|
|
3065
|
-
|
|
3066
|
-
|
|
3067
|
-
|
|
3068
|
-
|
|
3069
|
-
|
|
3070
|
-
|
|
3071
|
-
|
|
3072
|
-
|
|
3073
|
-
|
|
3074
|
-
|
|
3075
|
-
|
|
3076
|
-
|
|
3077
|
-
|
|
3078
|
-
|
|
3079
|
-
|
|
3080
|
-
|
|
3081
|
-
|
|
3488
|
+
// Same double-paint issue for a shape's anchored text — hide the read-only
|
|
3489
|
+
// <text> stroke while TextEditor sits at the same bbox (Phase 1 jump-fix
|
|
3490
|
+
// companion). Only applies once the TextStroke exists; a not-yet-created
|
|
3491
|
+
// one has nothing to hide.
|
|
3492
|
+
const editingAnchoredTextId = anchoredExisting?.id ?? null;
|
|
3493
|
+
// A standalone text being re-edited hides its read-only <text> too — the
|
|
3494
|
+
// editor paints at the same x/y, so leaving it visible double-paints (the
|
|
3495
|
+
// pre-Phase-2 "ghost" under the editor).
|
|
3496
|
+
const editingStandaloneTextId =
|
|
3497
|
+
editingTarget?.kind === 'standalone' ? editingTarget.text.id : null;
|
|
3498
|
+
return (
|
|
3499
|
+
<>
|
|
3500
|
+
{createPortal(
|
|
3501
|
+
<svg className="dc-annot-svg" aria-hidden="true" xmlns="http://www.w3.org/2000/svg">
|
|
3502
|
+
<defs>
|
|
3503
|
+
{/* Phase 21 — soft "lifted paper" drop shadow for sticky notes. */}
|
|
3504
|
+
<filter id="dc-sticky-shadow" x="-25%" y="-25%" width="150%" height="170%">
|
|
3505
|
+
<feDropShadow
|
|
3506
|
+
dx="0"
|
|
3507
|
+
dy="4"
|
|
3508
|
+
stdDeviation="8"
|
|
3509
|
+
floodColor="#000000"
|
|
3510
|
+
floodOpacity="0.28"
|
|
3511
|
+
/>
|
|
3512
|
+
</filter>
|
|
3513
|
+
</defs>
|
|
3514
|
+
{strokes.map((s) => (
|
|
3515
|
+
<StrokeNode
|
|
3516
|
+
key={s.id}
|
|
3517
|
+
stroke={s}
|
|
3518
|
+
anchorsById={anchorsById}
|
|
3519
|
+
interactive={selectMode}
|
|
3520
|
+
editing={
|
|
3521
|
+
s.id === editingStickyId ||
|
|
3522
|
+
s.id === editingSectionId ||
|
|
3523
|
+
s.id === editingAnchoredTextId ||
|
|
3524
|
+
s.id === editingStandaloneTextId
|
|
3525
|
+
}
|
|
3526
|
+
/>
|
|
3527
|
+
))}
|
|
3528
|
+
{selectedStrokes.map((s) => (
|
|
3529
|
+
<SelectionHalo
|
|
3530
|
+
key={`halo-${s.id}`}
|
|
3531
|
+
stroke={s}
|
|
3532
|
+
anchorsById={anchorsById}
|
|
3533
|
+
multi={selectedStrokes.length > 1}
|
|
3534
|
+
/>
|
|
3535
|
+
))}
|
|
3536
|
+
<AnnotGroupBbox selectedStrokes={selectedStrokes} anchorsById={anchorsById} />
|
|
3537
|
+
{marquee ? (
|
|
3538
|
+
<rect
|
|
3539
|
+
className="dc-annot-marquee"
|
|
3540
|
+
x={Math.min(marquee.ax, marquee.bx)}
|
|
3541
|
+
y={Math.min(marquee.ay, marquee.by)}
|
|
3542
|
+
width={Math.abs(marquee.bx - marquee.ax)}
|
|
3543
|
+
height={Math.abs(marquee.by - marquee.ay)}
|
|
3544
|
+
vectorEffect="non-scaling-stroke"
|
|
3545
|
+
/>
|
|
3546
|
+
) : null}
|
|
3547
|
+
{/* FigJam v3 — smart guides: solid 1px accent lines at the snapped edge/
|
|
3082
3548
|
center, painted only while a drag is actively snapping. */}
|
|
3083
|
-
|
|
3084
|
-
|
|
3085
|
-
|
|
3086
|
-
|
|
3087
|
-
|
|
3088
|
-
|
|
3089
|
-
|
|
3090
|
-
|
|
3091
|
-
|
|
3092
|
-
|
|
3093
|
-
|
|
3094
|
-
|
|
3095
|
-
|
|
3096
|
-
|
|
3097
|
-
|
|
3549
|
+
{snapGuides?.map((g, i) => (
|
|
3550
|
+
<line
|
|
3551
|
+
// biome-ignore lint/suspicious/noArrayIndexKey: guides are positional + rebuilt per move tick
|
|
3552
|
+
key={`guide-${i}`}
|
|
3553
|
+
x1={g.axis === 'x' ? g.at : g.from}
|
|
3554
|
+
y1={g.axis === 'x' ? g.from : g.at}
|
|
3555
|
+
x2={g.axis === 'x' ? g.at : g.to}
|
|
3556
|
+
y2={g.axis === 'x' ? g.to : g.at}
|
|
3557
|
+
stroke="var(--maude-hud-accent, #d63b1f)"
|
|
3558
|
+
strokeWidth={1}
|
|
3559
|
+
vectorEffect="non-scaling-stroke"
|
|
3560
|
+
pointerEvents="none"
|
|
3561
|
+
/>
|
|
3562
|
+
))}
|
|
3563
|
+
{/* FigJam v3 — bind hint: halo the host a dragged arrow endpoint would
|
|
3098
3564
|
magnetically attach to. */}
|
|
3099
|
-
|
|
3100
|
-
|
|
3565
|
+
<BindHintHalo strokes={strokes} bindHintId={bindHintId} />
|
|
3566
|
+
{/* FigJam v3 — connection dots on a single selected bindable shape;
|
|
3101
3567
|
dragging one draws a bound connector (rendered below as a draft). */}
|
|
3102
|
-
|
|
3103
|
-
|
|
3104
|
-
|
|
3105
|
-
|
|
3106
|
-
|
|
3107
|
-
|
|
3108
|
-
|
|
3109
|
-
|
|
3110
|
-
|
|
3111
|
-
|
|
3112
|
-
|
|
3113
|
-
|
|
3114
|
-
|
|
3115
|
-
|
|
3116
|
-
|
|
3117
|
-
|
|
3118
|
-
|
|
3119
|
-
|
|
3120
|
-
|
|
3121
|
-
|
|
3122
|
-
|
|
3123
|
-
|
|
3124
|
-
|
|
3125
|
-
|
|
3126
|
-
|
|
3127
|
-
|
|
3128
|
-
|
|
3129
|
-
|
|
3130
|
-
|
|
3568
|
+
{selectMode && !connDraft && selectedStrokes.length === 1 && selectedStrokes[0] ? (
|
|
3569
|
+
<ConnectorDots stroke={selectedStrokes[0]} />
|
|
3570
|
+
) : null}
|
|
3571
|
+
{connDraft ? (
|
|
3572
|
+
<g
|
|
3573
|
+
stroke={inkColor}
|
|
3574
|
+
strokeWidth={2.5}
|
|
3575
|
+
strokeLinecap="round"
|
|
3576
|
+
strokeLinejoin="round"
|
|
3577
|
+
vectorEffect="non-scaling-stroke"
|
|
3578
|
+
fill="none"
|
|
3579
|
+
pointerEvents="none"
|
|
3580
|
+
>
|
|
3581
|
+
{arrowPrimitives({
|
|
3582
|
+
x1: connDraft.x1,
|
|
3583
|
+
y1: connDraft.y1,
|
|
3584
|
+
x2: connDraft.x2,
|
|
3585
|
+
y2: connDraft.y2,
|
|
3586
|
+
width: 2.5,
|
|
3587
|
+
color: inkColor,
|
|
3588
|
+
lineType: 'curved',
|
|
3589
|
+
startBind: connDraft.startBind,
|
|
3590
|
+
...(connDraft.endBind ? { endBind: connDraft.endBind } : {}),
|
|
3591
|
+
}).map((prim, i) => renderArrowPrimitive(prim, i))}
|
|
3592
|
+
</g>
|
|
3593
|
+
) : null}
|
|
3594
|
+
{/* FigJam v3 — hover affordance: an empty shape invites text. */}
|
|
3595
|
+
<AddTextHint strokes={strokes} hintId={addTextHintId} />
|
|
3596
|
+
{/* FigJam v3 — resize chrome: live W × H label at the box corner plus a
|
|
3131
3597
|
dashed halo on any neighbour whose dimension the resize just matched
|
|
3132
3598
|
(the "same size as that one" quota). */}
|
|
3133
|
-
|
|
3134
|
-
|
|
3135
|
-
|
|
3136
|
-
|
|
3137
|
-
|
|
3138
|
-
|
|
3139
|
-
|
|
3140
|
-
|
|
3141
|
-
|
|
3142
|
-
|
|
3143
|
-
|
|
3144
|
-
|
|
3145
|
-
|
|
3146
|
-
|
|
3147
|
-
|
|
3148
|
-
|
|
3149
|
-
|
|
3150
|
-
|
|
3151
|
-
|
|
3152
|
-
|
|
3153
|
-
|
|
3154
|
-
|
|
3155
|
-
|
|
3156
|
-
|
|
3157
|
-
|
|
3158
|
-
|
|
3159
|
-
|
|
3160
|
-
|
|
3161
|
-
|
|
3162
|
-
|
|
3163
|
-
|
|
3164
|
-
|
|
3165
|
-
|
|
3166
|
-
|
|
3167
|
-
|
|
3168
|
-
|
|
3169
|
-
|
|
3170
|
-
|
|
3599
|
+
{resizeInfo?.box ? (
|
|
3600
|
+
<g pointerEvents="none">
|
|
3601
|
+
{resizeInfo.matchIds.map((id) => {
|
|
3602
|
+
const m = strokes.find((s) => s.id === id);
|
|
3603
|
+
const bb = m ? strokeBBox(m) : null;
|
|
3604
|
+
if (!bb) return null;
|
|
3605
|
+
return (
|
|
3606
|
+
<rect
|
|
3607
|
+
key={`dim-${id}`}
|
|
3608
|
+
x={bb.x - 2}
|
|
3609
|
+
y={bb.y - 2}
|
|
3610
|
+
width={bb.w + 4}
|
|
3611
|
+
height={bb.h + 4}
|
|
3612
|
+
fill="none"
|
|
3613
|
+
stroke="var(--maude-hud-accent, #d63b1f)"
|
|
3614
|
+
strokeWidth={1.5}
|
|
3615
|
+
strokeDasharray="5 3"
|
|
3616
|
+
vectorEffect="non-scaling-stroke"
|
|
3617
|
+
rx={2}
|
|
3618
|
+
/>
|
|
3619
|
+
);
|
|
3620
|
+
})}
|
|
3621
|
+
<text
|
|
3622
|
+
x={resizeInfo.box.x + resizeInfo.box.w / 2}
|
|
3623
|
+
y={resizeInfo.box.y + resizeInfo.box.h + 18}
|
|
3624
|
+
textAnchor="middle"
|
|
3625
|
+
fontSize={11}
|
|
3626
|
+
fill="var(--maude-hud-accent, #d63b1f)"
|
|
3627
|
+
style={{
|
|
3628
|
+
fontFamily: 'var(--u-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace)',
|
|
3629
|
+
}}
|
|
3630
|
+
>
|
|
3631
|
+
{`${Math.round(resizeInfo.box.w)} × ${Math.round(resizeInfo.box.h)}`}
|
|
3632
|
+
</text>
|
|
3633
|
+
</g>
|
|
3634
|
+
) : null}
|
|
3635
|
+
{ghost ? <GhostPreview ghost={ghost} /> : null}
|
|
3636
|
+
</svg>,
|
|
3637
|
+
target
|
|
3638
|
+
)}
|
|
3639
|
+
<AnnotEditors
|
|
3640
|
+
worldRef={worldRef}
|
|
3641
|
+
editingTarget={editingTarget}
|
|
3642
|
+
anchoredExisting={anchoredExisting}
|
|
3643
|
+
caretPoint={editCaretPoint}
|
|
3644
|
+
inkColor={inkColor}
|
|
3645
|
+
onCommitEdit={onCommitEdit}
|
|
3646
|
+
onCancelEdit={onCancelEdit}
|
|
3647
|
+
/>
|
|
3648
|
+
</>
|
|
3649
|
+
);
|
|
3650
|
+
}
|
|
3651
|
+
|
|
3652
|
+
/**
|
|
3653
|
+
* The active annotation text editor, rendered as PLAIN HTML absolutely
|
|
3654
|
+
* positioned in the world div — NOT as SVG foreignObject. Same architectural
|
|
3655
|
+
* move as MediaRefPlayers below (read its docblock): foreignObject content
|
|
3656
|
+
* under the transformed `.dc-world` mis-hit-tests clicks at most zoom levels
|
|
3657
|
+
* (WebKit + Chromium), and the `.dc-annot-svg` root's pointer-events:none
|
|
3658
|
+
* additionally swallowed in-editor clicks — so caret-at-click could never
|
|
3659
|
+
* work. HTML children of the transformed div hit-test correctly by
|
|
3660
|
+
* construction. World coords map 1:1 (the div carries the pan/zoom
|
|
3661
|
+
* transform), so each editor's old foreignObject x/y/w/h becomes left/top/
|
|
3662
|
+
* width/height verbatim. The [data-annot-editor] attr keeps document-capture
|
|
3663
|
+
* annotation handlers out (isAnnotEditorTarget guard, mirroring
|
|
3664
|
+
* [data-mediaref-player]).
|
|
3665
|
+
*/
|
|
3666
|
+
function AnnotEditors({
|
|
3667
|
+
worldRef,
|
|
3668
|
+
editingTarget,
|
|
3669
|
+
anchoredExisting,
|
|
3670
|
+
caretPoint,
|
|
3671
|
+
inkColor,
|
|
3672
|
+
onCommitEdit,
|
|
3673
|
+
onCancelEdit,
|
|
3674
|
+
}: {
|
|
3675
|
+
worldRef: ReturnType<typeof useWorldRefContext>;
|
|
3676
|
+
editingTarget: EditingTarget;
|
|
3677
|
+
anchoredExisting: TextStroke | undefined;
|
|
3678
|
+
caretPoint: { x: number; y: number } | null;
|
|
3679
|
+
inkColor: string;
|
|
3680
|
+
onCommitEdit: (text: string, fmt?: EditorFmt) => void;
|
|
3681
|
+
onCancelEdit: () => void;
|
|
3682
|
+
}) {
|
|
3683
|
+
const target = worldRef?.current ?? null;
|
|
3684
|
+
if (!target || !editingTarget) return null;
|
|
3685
|
+
return createPortal(
|
|
3686
|
+
<>
|
|
3687
|
+
{editingTarget.kind === 'anchored' ? (
|
|
3171
3688
|
<TextEditor
|
|
3172
3689
|
anchorId={editingTarget.anchorId}
|
|
3173
3690
|
host={editingTarget.host}
|
|
3174
3691
|
existing={anchoredExisting}
|
|
3692
|
+
caretPoint={caretPoint}
|
|
3175
3693
|
onCommit={(_anchorId, text, fmt) => onCommitEdit(text, fmt)}
|
|
3176
3694
|
onCancel={onCancelEdit}
|
|
3177
3695
|
/>
|
|
3178
3696
|
) : null}
|
|
3179
|
-
{editingTarget
|
|
3697
|
+
{editingTarget.kind === 'sticky' ? (
|
|
3180
3698
|
<StickyEditor
|
|
3181
3699
|
sticky={editingTarget.sticky}
|
|
3700
|
+
caretPoint={caretPoint}
|
|
3182
3701
|
onCommit={onCommitEdit}
|
|
3183
3702
|
onCancel={onCancelEdit}
|
|
3184
3703
|
/>
|
|
3185
3704
|
) : null}
|
|
3186
|
-
{editingTarget
|
|
3705
|
+
{editingTarget.kind === 'standalone' ? (
|
|
3187
3706
|
<StandaloneTextEditor
|
|
3188
3707
|
x={editingTarget.text.x ?? 0}
|
|
3189
3708
|
y={editingTarget.text.y ?? 0}
|
|
@@ -3196,11 +3715,12 @@ function AnnotationsSvg({
|
|
|
3196
3715
|
underline={editingTarget.text.underline}
|
|
3197
3716
|
align={editingTarget.text.align ?? 'left'}
|
|
3198
3717
|
listType={editingTarget.text.listType}
|
|
3718
|
+
caretPoint={caretPoint}
|
|
3199
3719
|
onCommit={onCommitEdit}
|
|
3200
3720
|
onCancel={onCancelEdit}
|
|
3201
3721
|
/>
|
|
3202
3722
|
) : null}
|
|
3203
|
-
{editingTarget
|
|
3723
|
+
{editingTarget.kind === 'pending' ? (
|
|
3204
3724
|
<StandaloneTextEditor
|
|
3205
3725
|
x={editingTarget.x}
|
|
3206
3726
|
y={editingTarget.y}
|
|
@@ -3211,24 +3731,15 @@ function AnnotationsSvg({
|
|
|
3211
3731
|
onCancel={onCancelEdit}
|
|
3212
3732
|
/>
|
|
3213
3733
|
) : null}
|
|
3214
|
-
{editingTarget
|
|
3215
|
-
<
|
|
3216
|
-
|
|
3217
|
-
|
|
3218
|
-
}
|
|
3219
|
-
y={
|
|
3220
|
-
Math.min(editingTarget.section.y, editingTarget.section.y + editingTarget.section.h) -
|
|
3221
|
-
SECTION_LABEL_H -
|
|
3222
|
-
2
|
|
3223
|
-
}
|
|
3224
|
-
fontSize={SECTION_LABEL_FONT}
|
|
3225
|
-
color={editingTarget.section.color}
|
|
3226
|
-
initialText={editingTarget.section.label}
|
|
3734
|
+
{editingTarget.kind === 'section' ? (
|
|
3735
|
+
<SectionTitleEditor
|
|
3736
|
+
section={editingTarget.section}
|
|
3737
|
+
caretPoint={caretPoint}
|
|
3227
3738
|
onCommit={onCommitEdit}
|
|
3228
3739
|
onCancel={onCancelEdit}
|
|
3229
3740
|
/>
|
|
3230
3741
|
) : null}
|
|
3231
|
-
|
|
3742
|
+
</>,
|
|
3232
3743
|
target
|
|
3233
3744
|
);
|
|
3234
3745
|
}
|
|
@@ -3319,16 +3830,89 @@ function MediaRefPlayers({
|
|
|
3319
3830
|
);
|
|
3320
3831
|
}
|
|
3321
3832
|
|
|
3833
|
+
// An empty flex-centered contentEditable has no line box for `justifyContent:
|
|
3834
|
+
// 'center'` to center — WebKit parks the caret at the box's top, then jumps it
|
|
3835
|
+
// to the true centered position the instant a real character exists. A
|
|
3836
|
+
// zero-width space gives the box real (invisible) content from mount, so the
|
|
3837
|
+
// caret starts centered and never jumps. Stripped back off on commit.
|
|
3838
|
+
const JUMP_SENTINEL = '\u200B';
|
|
3839
|
+
function stripJumpSentinel(text: string): string {
|
|
3840
|
+
return text.startsWith(JUMP_SENTINEL) ? text.slice(JUMP_SENTINEL.length) : text;
|
|
3841
|
+
}
|
|
3842
|
+
|
|
3843
|
+
// Unified caret style across every contentEditable text surface (annotation
|
|
3844
|
+
// editors here + the artboard inline editor's `.dc-text-editing` CSS in
|
|
3845
|
+
// canvas-shell.tsx use the SAME `--maude-hud-accent` so the caret reads the
|
|
3846
|
+
// same everywhere). An explicit caretColor makes the caret visible against any
|
|
3847
|
+
// background. NOTE: deliberately NO `transform: translateZ(0)` / `will-change`
|
|
3848
|
+
// here \u2014 promoting a contentEditable onto its own compositing layer is a known
|
|
3849
|
+
// WebKit caret-BLINK killer (the compositor caches the layer and never repaints
|
|
3850
|
+
// the blink, so the caret shows as a static line). A prior dogfood pass added
|
|
3851
|
+
// translateZ(0) to "restore" the caret and instead froze its blink; removing it
|
|
3852
|
+
// lets WebKit run the native blink. The editor already lives inside the
|
|
3853
|
+
// transformed `.dc-world`, but that ancestor transform alone does not stop the
|
|
3854
|
+
// blink \u2014 only a compositing trigger ON the editable does.
|
|
3855
|
+
const CARET_FIX_STYLE = {
|
|
3856
|
+
caretColor: 'var(--maude-hud-accent, #4a63e7)',
|
|
3857
|
+
} as const;
|
|
3858
|
+
|
|
3859
|
+
/**
|
|
3860
|
+
* Phase 3 (unified-text-editing) — shared caret behavior for every annotation
|
|
3861
|
+
* editor. On mount: focus, place a collapsed caret at the entry click point
|
|
3862
|
+
* (`placeCaretAt`, the SAME chain the artboard's enterEditModeAt uses;
|
|
3863
|
+
* keyboard entry has no point → select-all, the rename convention), and mount
|
|
3864
|
+
* the custom blinking caret (text-caret.ts) for the session. Afterwards every
|
|
3865
|
+
* plain in-editor click re-places the caret from its coordinates on pointerup
|
|
3866
|
+
* so repositioning never depends on native hit-testing (synthetic e2e clicks
|
|
3867
|
+
* take the same path — untrusted events get no UA caret action at all).
|
|
3868
|
+
* Shift-clicks and drag-selections keep native behavior; ⌘A stays native.
|
|
3869
|
+
*/
|
|
3870
|
+
function useEditorCaret(
|
|
3871
|
+
ref: RefObject<HTMLDivElement | null>,
|
|
3872
|
+
caretPoint: { x: number; y: number } | null | undefined
|
|
3873
|
+
) {
|
|
3874
|
+
const entryPointRef = useRef(caretPoint ?? null);
|
|
3875
|
+
useEffect(() => {
|
|
3876
|
+
const el = ref.current;
|
|
3877
|
+
if (!el) return;
|
|
3878
|
+
el.focus();
|
|
3879
|
+
placeCaretAt(el, window, entryPointRef.current ?? undefined);
|
|
3880
|
+
return mountCaret(el, window);
|
|
3881
|
+
}, [ref]);
|
|
3882
|
+
const downRef = useRef<{ x: number; y: number } | null>(null);
|
|
3883
|
+
const onPointerDown = useCallback((e: ReactPointerEvent<HTMLDivElement>) => {
|
|
3884
|
+
downRef.current = e.button === 0 && !e.shiftKey ? { x: e.clientX, y: e.clientY } : null;
|
|
3885
|
+
}, []);
|
|
3886
|
+
const onPointerUp = useCallback(
|
|
3887
|
+
(e: ReactPointerEvent<HTMLDivElement>) => {
|
|
3888
|
+
const d = downRef.current;
|
|
3889
|
+
downRef.current = null;
|
|
3890
|
+
if (!d || e.shiftKey) return;
|
|
3891
|
+
// A real drag is a range-selection gesture — leave it to the engine.
|
|
3892
|
+
if (Math.hypot(e.clientX - d.x, e.clientY - d.y) > 3) return;
|
|
3893
|
+
const el = ref.current;
|
|
3894
|
+
if (!el) return;
|
|
3895
|
+
const sel = window.getSelection();
|
|
3896
|
+
if (sel && !sel.isCollapsed) return; // double-click word-select etc.
|
|
3897
|
+
placeCaretAt(el, window, { x: e.clientX, y: e.clientY }, false);
|
|
3898
|
+
},
|
|
3899
|
+
[ref]
|
|
3900
|
+
);
|
|
3901
|
+
return { onPointerDown, onPointerUp };
|
|
3902
|
+
}
|
|
3903
|
+
|
|
3322
3904
|
function TextEditor({
|
|
3323
3905
|
anchorId,
|
|
3324
3906
|
host,
|
|
3325
3907
|
existing,
|
|
3908
|
+
caretPoint,
|
|
3326
3909
|
onCommit,
|
|
3327
3910
|
onCancel,
|
|
3328
3911
|
}: {
|
|
3329
3912
|
anchorId: string;
|
|
3330
3913
|
host: AnchorHost | null;
|
|
3331
3914
|
existing: TextStroke | undefined;
|
|
3915
|
+
caretPoint?: { x: number; y: number } | null;
|
|
3332
3916
|
onCommit: (anchorId: string, text: string, fmt?: EditorFmt) => void;
|
|
3333
3917
|
onCancel: () => void;
|
|
3334
3918
|
}) {
|
|
@@ -3351,24 +3935,18 @@ function TextEditor({
|
|
|
3351
3935
|
fontSize: existing?.fontSize ?? DEFAULT_FONT_SIZE,
|
|
3352
3936
|
align: existing?.align ?? 'center',
|
|
3353
3937
|
});
|
|
3938
|
+
// Both commit sites below (outside-click + Cmd/Ctrl+Enter) need the same
|
|
3939
|
+
// sentinel-strip + marker-strip pipeline. Memoized so the outside-click
|
|
3940
|
+
// effect below can depend on it directly instead of its own copy of
|
|
3941
|
+
// existing?.listType (lint/correctness/useExhaustiveDependencies).
|
|
3942
|
+
const toCommittedText = useCallback(
|
|
3943
|
+
(raw: string) => stripEditorMarkers(stripJumpSentinel(raw), existing?.listType),
|
|
3944
|
+
[existing?.listType]
|
|
3945
|
+
);
|
|
3354
3946
|
|
|
3355
|
-
|
|
3356
|
-
|
|
3357
|
-
|
|
3358
|
-
el.focus();
|
|
3359
|
-
// Select all so a re-edit replaces existing text easily.
|
|
3360
|
-
try {
|
|
3361
|
-
const r = document.createRange();
|
|
3362
|
-
r.selectNodeContents(el);
|
|
3363
|
-
const sel = window.getSelection();
|
|
3364
|
-
if (sel) {
|
|
3365
|
-
sel.removeAllRanges();
|
|
3366
|
-
sel.addRange(r);
|
|
3367
|
-
}
|
|
3368
|
-
} catch {
|
|
3369
|
-
/* selection API blocked */
|
|
3370
|
-
}
|
|
3371
|
-
}, []);
|
|
3947
|
+
// Caret-at-click on entry + custom blinking caret + click re-placement
|
|
3948
|
+
// (select-all only for keyboard entry — see useEditorCaret).
|
|
3949
|
+
const caretHandlers = useEditorCaret(ref, caretPoint);
|
|
3372
3950
|
|
|
3373
3951
|
// Commit on outside click; cancel-on-Esc handled in onKeyDown below.
|
|
3374
3952
|
useEffect(() => {
|
|
@@ -3381,15 +3959,11 @@ function TextEditor({
|
|
|
3381
3959
|
// FigJam v3 — the edit-mode text toolbar drives THIS editor; clicking
|
|
3382
3960
|
// it must not commit-and-close the session.
|
|
3383
3961
|
if ((e.target as Element | null)?.closest?.('.dc-annot-ctx')) return;
|
|
3384
|
-
onCommit(
|
|
3385
|
-
anchorId,
|
|
3386
|
-
stripEditorMarkers(el.innerText || '', existing?.listType),
|
|
3387
|
-
fmtRef.current
|
|
3388
|
-
);
|
|
3962
|
+
onCommit(anchorId, toCommittedText(el.innerText || ''), fmtRef.current);
|
|
3389
3963
|
};
|
|
3390
3964
|
document.addEventListener('pointerdown', onDown, true);
|
|
3391
3965
|
return () => document.removeEventListener('pointerdown', onDown, true);
|
|
3392
|
-
}, [anchorId, onCommit,
|
|
3966
|
+
}, [anchorId, onCommit, fmtRef, toCommittedText]);
|
|
3393
3967
|
|
|
3394
3968
|
if (!host) return null;
|
|
3395
3969
|
const bbox = strokeBBox(host);
|
|
@@ -3399,7 +3973,19 @@ function TextEditor({
|
|
|
3399
3973
|
// default align = centre).
|
|
3400
3974
|
const align = existing?.align ?? 'center';
|
|
3401
3975
|
return (
|
|
3402
|
-
|
|
3976
|
+
// Plain HTML host in the world div (NOT foreignObject — see AnnotEditors'
|
|
3977
|
+
// docblock): world coords map 1:1 to left/top, clicks hit-test correctly.
|
|
3978
|
+
<div
|
|
3979
|
+
data-annot-editor="1"
|
|
3980
|
+
style={{
|
|
3981
|
+
position: 'absolute',
|
|
3982
|
+
left: bbox.x,
|
|
3983
|
+
top: bbox.y,
|
|
3984
|
+
width: Math.max(20, bbox.w),
|
|
3985
|
+
height: Math.max(20, bbox.h),
|
|
3986
|
+
zIndex: 5,
|
|
3987
|
+
}}
|
|
3988
|
+
>
|
|
3403
3989
|
<div
|
|
3404
3990
|
ref={ref}
|
|
3405
3991
|
className="dc-annot-editor"
|
|
@@ -3429,7 +4015,10 @@ function TextEditor({
|
|
|
3429
4015
|
outline: 'none',
|
|
3430
4016
|
background: 'transparent',
|
|
3431
4017
|
cursor: 'text',
|
|
4018
|
+
...CARET_FIX_STYLE,
|
|
3432
4019
|
}}
|
|
4020
|
+
onPointerDown={caretHandlers.onPointerDown}
|
|
4021
|
+
onPointerUp={caretHandlers.onPointerUp}
|
|
3433
4022
|
onKeyDown={(e) => {
|
|
3434
4023
|
if (onFormatKey(e)) return; // Cmd/Ctrl+B/I/U
|
|
3435
4024
|
if (e.key === 'Escape') {
|
|
@@ -3437,44 +4026,46 @@ function TextEditor({
|
|
|
3437
4026
|
onCancel();
|
|
3438
4027
|
return;
|
|
3439
4028
|
}
|
|
3440
|
-
|
|
4029
|
+
// Unified across every text surface: plain Enter commits,
|
|
4030
|
+
// Shift+Enter inserts a newline (falls through untouched). ⌘/Ctrl
|
|
4031
|
+
// +Enter also commits AND chains a connected sibling (quick-create).
|
|
4032
|
+
if (e.key === 'Enter' && !e.shiftKey) {
|
|
3441
4033
|
e.preventDefault();
|
|
3442
4034
|
const el = ref.current;
|
|
3443
|
-
onCommit(
|
|
3444
|
-
|
|
3445
|
-
|
|
3446
|
-
|
|
3447
|
-
|
|
3448
|
-
|
|
3449
|
-
// sibling shape (quick-create) from the host.
|
|
3450
|
-
document.dispatchEvent(
|
|
3451
|
-
new CustomEvent('maude:chain-create', { detail: { id: anchorId } })
|
|
3452
|
-
);
|
|
4035
|
+
onCommit(anchorId, toCommittedText(el?.innerText || ''), fmtRef.current);
|
|
4036
|
+
if (e.metaKey || e.ctrlKey) {
|
|
4037
|
+
document.dispatchEvent(
|
|
4038
|
+
new CustomEvent('maude:chain-create', { detail: { id: anchorId } })
|
|
4039
|
+
);
|
|
4040
|
+
}
|
|
3453
4041
|
}
|
|
3454
4042
|
}}
|
|
3455
4043
|
>
|
|
3456
|
-
{initial}
|
|
4044
|
+
{initial || JUMP_SENTINEL}
|
|
3457
4045
|
</div>
|
|
3458
|
-
</
|
|
4046
|
+
</div>
|
|
3459
4047
|
);
|
|
3460
4048
|
}
|
|
3461
4049
|
|
|
3462
|
-
// Phase 21 — sticky body editor
|
|
3463
|
-
//
|
|
3464
|
-
// cancel on Esc; Enter
|
|
4050
|
+
// Phase 21 — sticky body editor, hosted as plain HTML at the card's bbox in
|
|
4051
|
+
// the world div (word-wrap + zoom come from the div's own box + the world
|
|
4052
|
+
// transform). Commit on blur, cancel on Esc; Enter commits, Shift+Enter
|
|
4053
|
+
// inserts a newline.
|
|
3465
4054
|
function StickyEditor({
|
|
3466
4055
|
sticky,
|
|
4056
|
+
caretPoint,
|
|
3467
4057
|
onCommit,
|
|
3468
4058
|
onCancel,
|
|
3469
4059
|
}: {
|
|
3470
4060
|
sticky: StickyStroke;
|
|
4061
|
+
caretPoint?: { x: number; y: number } | null;
|
|
3471
4062
|
onCommit: (text: string, fmt?: EditorFmt) => void;
|
|
3472
4063
|
onCancel: () => void;
|
|
3473
4064
|
}) {
|
|
3474
4065
|
// A flex-centered contentEditable (NOT a textarea) so the edit view matches
|
|
3475
4066
|
// the committed `.dc-sticky-body` exactly — text stays centered, no jump on
|
|
3476
|
-
// commit. Multi-line: Enter inserts a line break
|
|
3477
|
-
//
|
|
4067
|
+
// commit. Multi-line: Shift+Enter inserts a line break, plain Enter commits;
|
|
4068
|
+
// Esc cancels; blur commits; Cmd/Ctrl+B/I/U format (unified with the others).
|
|
3478
4069
|
const ref = useRef<HTMLDivElement | null>(null);
|
|
3479
4070
|
const doneRef = useRef(false);
|
|
3480
4071
|
const {
|
|
@@ -3502,37 +4093,33 @@ function StickyEditor({
|
|
|
3502
4093
|
if (to?.closest?.('.dc-annot-ctx')) return;
|
|
3503
4094
|
commit();
|
|
3504
4095
|
};
|
|
3505
|
-
|
|
3506
|
-
|
|
3507
|
-
if (!el) return;
|
|
3508
|
-
el.focus();
|
|
3509
|
-
try {
|
|
3510
|
-
const r = document.createRange();
|
|
3511
|
-
r.selectNodeContents(el);
|
|
3512
|
-
const sel = window.getSelection();
|
|
3513
|
-
if (sel) {
|
|
3514
|
-
sel.removeAllRanges();
|
|
3515
|
-
sel.addRange(r);
|
|
3516
|
-
}
|
|
3517
|
-
} catch {
|
|
3518
|
-
/* selection API blocked */
|
|
3519
|
-
}
|
|
3520
|
-
}, []);
|
|
4096
|
+
// Caret-at-click on entry + custom blinking caret + click re-placement.
|
|
4097
|
+
const caretHandlers = useEditorCaret(ref, caretPoint);
|
|
3521
4098
|
const x = Math.min(sticky.x, sticky.x + sticky.w);
|
|
3522
4099
|
const y = Math.min(sticky.y, sticky.y + sticky.h);
|
|
3523
4100
|
const w = Math.abs(sticky.w);
|
|
3524
4101
|
const h = Math.abs(sticky.h);
|
|
3525
4102
|
return (
|
|
3526
|
-
<
|
|
4103
|
+
<div
|
|
4104
|
+
data-annot-editor="1"
|
|
4105
|
+
style={{ position: 'absolute', left: x, top: y, width: w, height: h, zIndex: 5 }}
|
|
4106
|
+
>
|
|
3527
4107
|
<div
|
|
3528
|
-
xmlns="http://www.w3.org/1999/xhtml"
|
|
3529
4108
|
ref={ref}
|
|
3530
4109
|
className="dc-annot-editor dc-sticky-body"
|
|
3531
4110
|
contentEditable
|
|
3532
4111
|
suppressContentEditableWarning
|
|
3533
4112
|
aria-label="Edit sticky note text"
|
|
3534
|
-
style={{
|
|
4113
|
+
style={{
|
|
4114
|
+
...stickyBodyStyle(sticky),
|
|
4115
|
+
...fmtStyle,
|
|
4116
|
+
outline: 'none',
|
|
4117
|
+
cursor: 'text',
|
|
4118
|
+
...CARET_FIX_STYLE,
|
|
4119
|
+
}}
|
|
3535
4120
|
onBlur={onBlur}
|
|
4121
|
+
onPointerDown={caretHandlers.onPointerDown}
|
|
4122
|
+
onPointerUp={caretHandlers.onPointerUp}
|
|
3536
4123
|
onKeyDown={(e) => {
|
|
3537
4124
|
if (onFormatKey(e)) return; // Cmd/Ctrl+B/I/U
|
|
3538
4125
|
if (e.key === 'Escape') {
|
|
@@ -3541,14 +4128,16 @@ function StickyEditor({
|
|
|
3541
4128
|
onCancel();
|
|
3542
4129
|
return;
|
|
3543
4130
|
}
|
|
3544
|
-
|
|
4131
|
+
// Unified: plain Enter commits, Shift+Enter inserts a newline.
|
|
4132
|
+
// ⌘/Ctrl+Enter also commits AND chains the next sticky beside it.
|
|
4133
|
+
if (e.key === 'Enter' && !e.shiftKey) {
|
|
3545
4134
|
e.preventDefault();
|
|
3546
4135
|
commit();
|
|
3547
|
-
|
|
3548
|
-
|
|
3549
|
-
|
|
3550
|
-
|
|
3551
|
-
|
|
4136
|
+
if (e.metaKey || e.ctrlKey) {
|
|
4137
|
+
document.dispatchEvent(
|
|
4138
|
+
new CustomEvent('maude:chain-create', { detail: { id: sticky.id } })
|
|
4139
|
+
);
|
|
4140
|
+
}
|
|
3552
4141
|
}
|
|
3553
4142
|
}}
|
|
3554
4143
|
>
|
|
@@ -3556,7 +4145,7 @@ function StickyEditor({
|
|
|
3556
4145
|
doesn't flicker; stripped back to raw text on commit. */}
|
|
3557
4146
|
{stickyBodyText(sticky)}
|
|
3558
4147
|
</div>
|
|
3559
|
-
</
|
|
4148
|
+
</div>
|
|
3560
4149
|
);
|
|
3561
4150
|
}
|
|
3562
4151
|
|
|
@@ -3574,6 +4163,9 @@ function StandaloneTextEditor({
|
|
|
3574
4163
|
underline,
|
|
3575
4164
|
align,
|
|
3576
4165
|
listType,
|
|
4166
|
+
singleLine,
|
|
4167
|
+
boxStyle,
|
|
4168
|
+
caretPoint,
|
|
3577
4169
|
onCommit,
|
|
3578
4170
|
onCancel,
|
|
3579
4171
|
}: {
|
|
@@ -3588,6 +4180,14 @@ function StandaloneTextEditor({
|
|
|
3588
4180
|
underline?: boolean;
|
|
3589
4181
|
align?: TextAlign;
|
|
3590
4182
|
listType?: ListType;
|
|
4183
|
+
/** The click that opened the editor — caret lands there (Phase 3). */
|
|
4184
|
+
caretPoint?: { x: number; y: number } | null;
|
|
4185
|
+
/** A one-line field (e.g. a section title rename) — plain Enter commits
|
|
4186
|
+
* instead of inserting a newline, matching a native text-input's Enter. */
|
|
4187
|
+
singleLine?: boolean;
|
|
4188
|
+
/** Extra style merged onto the editable box — e.g. a section rename wants
|
|
4189
|
+
* the same chip background/padding/radius the read-only label chip has. */
|
|
4190
|
+
boxStyle?: CSSProperties;
|
|
3591
4191
|
onCommit: (text: string, fmt?: EditorFmt) => void;
|
|
3592
4192
|
onCancel: () => void;
|
|
3593
4193
|
}) {
|
|
@@ -3617,22 +4217,8 @@ function StandaloneTextEditor({
|
|
|
3617
4217
|
},
|
|
3618
4218
|
[onCommit, listType, fmtRef]
|
|
3619
4219
|
);
|
|
3620
|
-
|
|
3621
|
-
|
|
3622
|
-
if (!el) return;
|
|
3623
|
-
el.focus();
|
|
3624
|
-
try {
|
|
3625
|
-
const r = document.createRange();
|
|
3626
|
-
r.selectNodeContents(el);
|
|
3627
|
-
const sel = window.getSelection();
|
|
3628
|
-
if (sel) {
|
|
3629
|
-
sel.removeAllRanges();
|
|
3630
|
-
sel.addRange(r);
|
|
3631
|
-
}
|
|
3632
|
-
} catch {
|
|
3633
|
-
/* selection API blocked */
|
|
3634
|
-
}
|
|
3635
|
-
}, []);
|
|
4220
|
+
// Caret-at-click on entry + custom blinking caret + click re-placement.
|
|
4221
|
+
const caretHandlers = useEditorCaret(ref, caretPoint);
|
|
3636
4222
|
// Commit on outside click.
|
|
3637
4223
|
useEffect(() => {
|
|
3638
4224
|
if (typeof document === 'undefined') return;
|
|
@@ -3649,12 +4235,23 @@ function StandaloneTextEditor({
|
|
|
3649
4235
|
return () => document.removeEventListener('pointerdown', onDown, true);
|
|
3650
4236
|
}, [commitOnce]);
|
|
3651
4237
|
return (
|
|
3652
|
-
// Generous box so multi-line text isn't clipped while typing (item 4a).
|
|
3653
|
-
//
|
|
3654
|
-
//
|
|
3655
|
-
|
|
4238
|
+
// Generous box so multi-line text isn't clipped while typing (item 4a).
|
|
4239
|
+
// The host passes pointer events through (empty area is not the editor —
|
|
4240
|
+
// outside-click must still commit); only the editable itself is
|
|
4241
|
+
// interactive, so clicks in it place the caret.
|
|
4242
|
+
<div
|
|
4243
|
+
data-annot-editor="1"
|
|
4244
|
+
style={{
|
|
4245
|
+
position: 'absolute',
|
|
4246
|
+
left: x,
|
|
4247
|
+
top: y,
|
|
4248
|
+
width: 640,
|
|
4249
|
+
height: 480,
|
|
4250
|
+
zIndex: 5,
|
|
4251
|
+
pointerEvents: 'none',
|
|
4252
|
+
}}
|
|
4253
|
+
>
|
|
3656
4254
|
<div
|
|
3657
|
-
xmlns="http://www.w3.org/1999/xhtml"
|
|
3658
4255
|
ref={ref}
|
|
3659
4256
|
className="dc-annot-editor"
|
|
3660
4257
|
contentEditable
|
|
@@ -3663,6 +4260,9 @@ function StandaloneTextEditor({
|
|
|
3663
4260
|
style={{
|
|
3664
4261
|
display: 'inline-block',
|
|
3665
4262
|
minWidth: '8px',
|
|
4263
|
+
// The pass-through host (above) is inert — re-enable events HERE so
|
|
4264
|
+
// in-editor clicks place the caret instead of falling through.
|
|
4265
|
+
pointerEvents: 'auto',
|
|
3666
4266
|
// pre-wrap so Enter inserts a real newline (multi-line text), not a
|
|
3667
4267
|
// commit; long lines also wrap within the box.
|
|
3668
4268
|
whiteSpace: 'pre-wrap',
|
|
@@ -3676,8 +4276,12 @@ function StandaloneTextEditor({
|
|
|
3676
4276
|
outline: 'none',
|
|
3677
4277
|
background: 'transparent',
|
|
3678
4278
|
cursor: 'text',
|
|
4279
|
+
...CARET_FIX_STYLE,
|
|
4280
|
+
...boxStyle,
|
|
3679
4281
|
}}
|
|
3680
4282
|
onBlur={() => commitOnce(ref.current?.innerText || '')}
|
|
4283
|
+
onPointerDown={caretHandlers.onPointerDown}
|
|
4284
|
+
onPointerUp={caretHandlers.onPointerUp}
|
|
3681
4285
|
onKeyDown={(e) => {
|
|
3682
4286
|
if (onFormatKey(e)) return; // Cmd/Ctrl+B/I/U
|
|
3683
4287
|
if (e.key === 'Escape') {
|
|
@@ -3687,8 +4291,10 @@ function StandaloneTextEditor({
|
|
|
3687
4291
|
onCancel();
|
|
3688
4292
|
return;
|
|
3689
4293
|
}
|
|
3690
|
-
//
|
|
3691
|
-
|
|
4294
|
+
// Unified: plain Enter commits, Shift+Enter inserts a newline. A
|
|
4295
|
+
// singleLine field (section rename) is a title — Shift+Enter commits
|
|
4296
|
+
// too rather than adding a newline the one-line chip can't show.
|
|
4297
|
+
if (e.key === 'Enter' && (!e.shiftKey || singleLine)) {
|
|
3692
4298
|
e.preventDefault();
|
|
3693
4299
|
commitOnce(ref.current?.innerText || '');
|
|
3694
4300
|
}
|
|
@@ -3696,7 +4302,7 @@ function StandaloneTextEditor({
|
|
|
3696
4302
|
>
|
|
3697
4303
|
{listPrefixedBody(initialText, listType)}
|
|
3698
4304
|
</div>
|
|
3699
|
-
</
|
|
4305
|
+
</div>
|
|
3700
4306
|
);
|
|
3701
4307
|
}
|
|
3702
4308
|
|
|
@@ -3798,6 +4404,7 @@ function AnnotationContextMenu({
|
|
|
3798
4404
|
selCount,
|
|
3799
4405
|
canUngroup,
|
|
3800
4406
|
canReplace,
|
|
4407
|
+
canEditPhoto,
|
|
3801
4408
|
onAction,
|
|
3802
4409
|
onClose,
|
|
3803
4410
|
}: {
|
|
@@ -3806,6 +4413,8 @@ function AnnotationContextMenu({
|
|
|
3806
4413
|
canUngroup: boolean;
|
|
3807
4414
|
/** Stage F3 — exactly one ImageStroke/MediaRefStroke is selected. */
|
|
3808
4415
|
canReplace: boolean;
|
|
4416
|
+
/** feature-photo-editor (Task 17) — exactly one content-addressed ImageStroke. */
|
|
4417
|
+
canEditPhoto: boolean;
|
|
3809
4418
|
onAction: (action: string) => void;
|
|
3810
4419
|
onClose: () => void;
|
|
3811
4420
|
}) {
|
|
@@ -3873,6 +4482,7 @@ function AnnotationContextMenu({
|
|
|
3873
4482
|
{item('paste', 'Paste', '⌘V')}
|
|
3874
4483
|
{item('duplicate', 'Duplicate', '⌘D')}
|
|
3875
4484
|
{canReplace ? item('replace', 'Replace…') : null}
|
|
4485
|
+
{canEditPhoto ? item('edit-photo', 'Edit Photo…') : null}
|
|
3876
4486
|
<div className="dc-menu-sep" aria-hidden="true" />
|
|
3877
4487
|
{item('front', 'Bring to front', ']')}
|
|
3878
4488
|
{item('forward', 'Bring forward', '⌘]')}
|
|
@@ -4004,6 +4614,16 @@ function AnnotGroupBbox({
|
|
|
4004
4614
|
const h = yMax - yMin + pad * 2;
|
|
4005
4615
|
const handle = 6;
|
|
4006
4616
|
const inset = 3;
|
|
4617
|
+
// Screen-constant hit target — bigger than the visible 6×6 square (matches
|
|
4618
|
+
// the single-resize DOM handles' generous grab area) so the corner stays
|
|
4619
|
+
// easy to grab at any zoom.
|
|
4620
|
+
const hit = 16 / zoom;
|
|
4621
|
+
const cursorFor: Record<string, string> = {
|
|
4622
|
+
nw: 'nwse-resize',
|
|
4623
|
+
se: 'nwse-resize',
|
|
4624
|
+
ne: 'nesw-resize',
|
|
4625
|
+
sw: 'nesw-resize',
|
|
4626
|
+
};
|
|
4007
4627
|
const handles = [
|
|
4008
4628
|
{ corner: 'nw', x: x - inset, y: y - inset },
|
|
4009
4629
|
{ corner: 'ne', x: x + w - handle + inset, y: y - inset },
|
|
@@ -4024,20 +4644,42 @@ function AnnotGroupBbox({
|
|
|
4024
4644
|
vectorEffect="non-scaling-stroke"
|
|
4025
4645
|
rx={2}
|
|
4026
4646
|
/>
|
|
4027
|
-
{
|
|
4028
|
-
|
|
4029
|
-
|
|
4030
|
-
|
|
4031
|
-
|
|
4032
|
-
|
|
4033
|
-
|
|
4034
|
-
|
|
4035
|
-
|
|
4036
|
-
|
|
4037
|
-
|
|
4038
|
-
|
|
4039
|
-
|
|
4040
|
-
|
|
4647
|
+
{/* Phase 2 (whiteboard-improvements) — proportional group resize: these
|
|
4648
|
+
corners used to be decorative (pointerEvents inherited 'none' from
|
|
4649
|
+
the wrapping <g>). Each now sets its OWN pointerEvents, which SVG
|
|
4650
|
+
lets a descendant override independent of an ancestor's value, and
|
|
4651
|
+
carries `data-group-resize-corner` for the drag gesture registered
|
|
4652
|
+
in AnnotationsLayer to identify which corner was grabbed. */}
|
|
4653
|
+
{handles.map((c) => {
|
|
4654
|
+
const cx = c.x + handle / 2;
|
|
4655
|
+
const cy = c.y + handle / 2;
|
|
4656
|
+
return (
|
|
4657
|
+
<g key={c.corner}>
|
|
4658
|
+
<rect
|
|
4659
|
+
x={cx - hit / 2}
|
|
4660
|
+
y={cy - hit / 2}
|
|
4661
|
+
width={hit}
|
|
4662
|
+
height={hit}
|
|
4663
|
+
fill="transparent"
|
|
4664
|
+
pointerEvents="all"
|
|
4665
|
+
data-group-resize-corner={c.corner}
|
|
4666
|
+
style={{ cursor: cursorFor[c.corner] }}
|
|
4667
|
+
/>
|
|
4668
|
+
<rect
|
|
4669
|
+
x={c.x}
|
|
4670
|
+
y={c.y}
|
|
4671
|
+
width={handle}
|
|
4672
|
+
height={handle}
|
|
4673
|
+
fill="var(--maude-hud-accent, #d63b1f)"
|
|
4674
|
+
stroke="var(--maude-chrome-bg-0, #ffffff)"
|
|
4675
|
+
strokeWidth={1}
|
|
4676
|
+
vectorEffect="non-scaling-stroke"
|
|
4677
|
+
rx={1}
|
|
4678
|
+
pointerEvents="none"
|
|
4679
|
+
/>
|
|
4680
|
+
</g>
|
|
4681
|
+
);
|
|
4682
|
+
})}
|
|
4041
4683
|
</g>
|
|
4042
4684
|
);
|
|
4043
4685
|
}
|
|
@@ -4166,6 +4808,106 @@ function GhostPreview({ ghost }: { ghost: GhostDescriptor }) {
|
|
|
4166
4808
|
* Pointer events pass through the group, so hit-testing + the ctx-toolbar's
|
|
4167
4809
|
* getBoundingClientRect positioning keep working on the rotated form.
|
|
4168
4810
|
*/
|
|
4811
|
+
/** Section title chip — deliberately screen-size-constant. The body/border
|
|
4812
|
+
* scale with the world like everything else, but a title that shrinks to
|
|
4813
|
+
* unreadable at zoom-out defeats the point of a label (matches the `r={5/zoom}`
|
|
4814
|
+
* counter-scale convention used for halos/connector-dots elsewhere in this file). */
|
|
4815
|
+
function SectionLabelChip({
|
|
4816
|
+
stroke,
|
|
4817
|
+
x,
|
|
4818
|
+
y,
|
|
4819
|
+
hitMode,
|
|
4820
|
+
}: {
|
|
4821
|
+
stroke: SectionStroke;
|
|
4822
|
+
x: number;
|
|
4823
|
+
y: number;
|
|
4824
|
+
hitMode: 'visiblePainted' | 'none';
|
|
4825
|
+
}) {
|
|
4826
|
+
const controller = useViewportControllerContext();
|
|
4827
|
+
const zoom = controller?.viewport?.zoom || 1;
|
|
4828
|
+
const fontSize = SECTION_LABEL_FONT / zoom;
|
|
4829
|
+
const chipH = SECTION_LABEL_H / zoom;
|
|
4830
|
+
const gap = 4 / zoom;
|
|
4831
|
+
const padX = 9 / zoom;
|
|
4832
|
+
const chipW = Math.max(56 / zoom, stroke.label.length * fontSize * 0.62 + 18 / zoom);
|
|
4833
|
+
// NOTE: this chip geometry (chipW/chipH/gap vs the region's y) is mirrored
|
|
4834
|
+
// by findTextStrokeAt's section branch — the Text tool's click-through
|
|
4835
|
+
// renames a section only from its label chip. Keep the two in sync.
|
|
4836
|
+
return (
|
|
4837
|
+
<g pointerEvents={hitMode}>
|
|
4838
|
+
<rect
|
|
4839
|
+
x={x}
|
|
4840
|
+
y={y - chipH - gap}
|
|
4841
|
+
width={chipW}
|
|
4842
|
+
height={chipH}
|
|
4843
|
+
rx={5 / zoom}
|
|
4844
|
+
ry={5 / zoom}
|
|
4845
|
+
fill={stroke.color}
|
|
4846
|
+
fillOpacity={0.16}
|
|
4847
|
+
/>
|
|
4848
|
+
<text
|
|
4849
|
+
x={x + padX}
|
|
4850
|
+
y={y - chipH / 2 - gap}
|
|
4851
|
+
dominantBaseline="middle"
|
|
4852
|
+
fontSize={fontSize}
|
|
4853
|
+
fill={stroke.color}
|
|
4854
|
+
style={{
|
|
4855
|
+
fontFamily: 'var(--u-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace)',
|
|
4856
|
+
}}
|
|
4857
|
+
>
|
|
4858
|
+
{stroke.label}
|
|
4859
|
+
</text>
|
|
4860
|
+
</g>
|
|
4861
|
+
);
|
|
4862
|
+
}
|
|
4863
|
+
|
|
4864
|
+
/** Section title RENAME field — same chip visuals + zoom-invariant sizing as
|
|
4865
|
+
* SectionLabelChip (read state), so switching into edit mode doesn't swap
|
|
4866
|
+
* the pill for a bare, ambient-zoomed sliver of text (it used to: the editor
|
|
4867
|
+
* had no background and its font-size wasn't counter-scaled, so at any zoom
|
|
4868
|
+
* below 1× the chip effectively vanished mid-rename). */
|
|
4869
|
+
function SectionTitleEditor({
|
|
4870
|
+
section,
|
|
4871
|
+
caretPoint,
|
|
4872
|
+
onCommit,
|
|
4873
|
+
onCancel,
|
|
4874
|
+
}: {
|
|
4875
|
+
section: SectionStroke;
|
|
4876
|
+
caretPoint?: { x: number; y: number } | null;
|
|
4877
|
+
onCommit: (text: string, fmt?: EditorFmt) => void;
|
|
4878
|
+
onCancel: () => void;
|
|
4879
|
+
}) {
|
|
4880
|
+
const controller = useViewportControllerContext();
|
|
4881
|
+
const zoom = controller?.viewport?.zoom || 1;
|
|
4882
|
+
const fontSize = SECTION_LABEL_FONT / zoom;
|
|
4883
|
+
const chipH = SECTION_LABEL_H / zoom;
|
|
4884
|
+
const gap = 4 / zoom;
|
|
4885
|
+
const padX = 9 / zoom;
|
|
4886
|
+
const x = Math.min(section.x, section.x + section.w);
|
|
4887
|
+
const y = Math.min(section.y, section.y + section.h);
|
|
4888
|
+
return (
|
|
4889
|
+
<StandaloneTextEditor
|
|
4890
|
+
x={x}
|
|
4891
|
+
y={y - chipH - gap}
|
|
4892
|
+
fontSize={fontSize}
|
|
4893
|
+
color={section.color}
|
|
4894
|
+
initialText={section.label}
|
|
4895
|
+
caretPoint={caretPoint}
|
|
4896
|
+
singleLine
|
|
4897
|
+
boxStyle={{
|
|
4898
|
+
background: `color-mix(in oklab, ${section.color} 16%, transparent)`,
|
|
4899
|
+
borderRadius: `${5 / zoom}px`,
|
|
4900
|
+
padding: `0 ${padX}px`,
|
|
4901
|
+
minHeight: `${chipH}px`,
|
|
4902
|
+
lineHeight: `${chipH}px`,
|
|
4903
|
+
whiteSpace: 'nowrap',
|
|
4904
|
+
}}
|
|
4905
|
+
onCommit={onCommit}
|
|
4906
|
+
onCancel={onCancel}
|
|
4907
|
+
/>
|
|
4908
|
+
);
|
|
4909
|
+
}
|
|
4910
|
+
|
|
4169
4911
|
function StrokeNode(props: {
|
|
4170
4912
|
stroke: Stroke;
|
|
4171
4913
|
anchorsById: Map<string, AnchorHost>;
|
|
@@ -4194,7 +4936,8 @@ function StrokeNodeBase({
|
|
|
4194
4936
|
stroke: Stroke;
|
|
4195
4937
|
anchorsById: Map<string, AnchorHost>;
|
|
4196
4938
|
interactive: boolean;
|
|
4197
|
-
/**
|
|
4939
|
+
/** Hide the read-only body/text while its editor is up (sticky, section, or
|
|
4940
|
+
* anchored shape text — whichever this stroke is). */
|
|
4198
4941
|
editing?: boolean;
|
|
4199
4942
|
}) {
|
|
4200
4943
|
// In Move mode, individual stroke nodes claim pointer events so we can
|
|
@@ -4217,6 +4960,11 @@ function StrokeNodeBase({
|
|
|
4217
4960
|
const host = anchorsById.get(stroke.anchorId);
|
|
4218
4961
|
const bbox = host ? strokeBBox(host) : null;
|
|
4219
4962
|
if (!bbox) return null;
|
|
4963
|
+
// Its editor (TextEditor) paints the same bbox while active — skip the
|
|
4964
|
+
// read-only <text> so the two don't double-paint (Phase 1 jump-fix
|
|
4965
|
+
// companion: the editor was already exempt via editingStickyId's
|
|
4966
|
+
// sibling, this stroke type never was).
|
|
4967
|
+
if (editing) return null;
|
|
4220
4968
|
const cy = bbox.y + bbox.h / 2;
|
|
4221
4969
|
const align = stroke.align ?? 'center';
|
|
4222
4970
|
const pad = 8;
|
|
@@ -4245,6 +4993,9 @@ function StrokeNodeBase({
|
|
|
4245
4993
|
</text>
|
|
4246
4994
|
);
|
|
4247
4995
|
}
|
|
4996
|
+
// Its editor (StandaloneTextEditor) paints at the same x/y while active —
|
|
4997
|
+
// skip the read-only <text> so the two don't double-paint (the "ghost").
|
|
4998
|
+
if (editing) return null;
|
|
4248
4999
|
const align = stroke.align ?? 'left';
|
|
4249
5000
|
const anchor = align === 'left' ? 'start' : align === 'right' ? 'end' : 'middle';
|
|
4250
5001
|
const tx = stroke.x ?? 0;
|
|
@@ -4297,6 +5048,54 @@ function StrokeNodeBase({
|
|
|
4297
5048
|
</div>
|
|
4298
5049
|
</foreignObject>
|
|
4299
5050
|
)}
|
|
5051
|
+
{/* Phase 3 (whiteboard-improvements) — author badge, bottom-right
|
|
5052
|
+
corner. A name label (not an avatar — a full name/nickname reads
|
|
5053
|
+
faster than initials and doesn't need a legend to decode). Color
|
|
5054
|
+
re-derives from the (sanitized) name via colorForName — NEVER a
|
|
5055
|
+
stored/wire color — so it matches the author's live presence hue
|
|
5056
|
+
(cursor/avatar use the same function). foreignObject width is a
|
|
5057
|
+
fixed generous box right-anchored via flex, since a name's pixel
|
|
5058
|
+
width isn't known without measuring the DOM. */}
|
|
5059
|
+
{stroke.authorName && (
|
|
5060
|
+
<foreignObject
|
|
5061
|
+
x={x + w - 160}
|
|
5062
|
+
y={y + h - 20}
|
|
5063
|
+
width={160}
|
|
5064
|
+
height={20}
|
|
5065
|
+
pointerEvents="none"
|
|
5066
|
+
>
|
|
5067
|
+
<div
|
|
5068
|
+
xmlns="http://www.w3.org/1999/xhtml"
|
|
5069
|
+
style={{
|
|
5070
|
+
display: 'flex',
|
|
5071
|
+
justifyContent: 'flex-end',
|
|
5072
|
+
alignItems: 'center',
|
|
5073
|
+
height: '100%',
|
|
5074
|
+
paddingRight: 4,
|
|
5075
|
+
}}
|
|
5076
|
+
>
|
|
5077
|
+
<span
|
|
5078
|
+
title={stroke.authorName}
|
|
5079
|
+
style={{
|
|
5080
|
+
maxWidth: '100%',
|
|
5081
|
+
overflow: 'hidden',
|
|
5082
|
+
textOverflow: 'ellipsis',
|
|
5083
|
+
whiteSpace: 'nowrap',
|
|
5084
|
+
fontSize: 9,
|
|
5085
|
+
fontWeight: 600,
|
|
5086
|
+
fontFamily: 'var(--u-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace)',
|
|
5087
|
+
color: colorForName(stroke.authorName),
|
|
5088
|
+
background: 'rgba(255,255,255,0.78)',
|
|
5089
|
+
padding: '1px 5px',
|
|
5090
|
+
borderRadius: 8,
|
|
5091
|
+
lineHeight: 1.4,
|
|
5092
|
+
}}
|
|
5093
|
+
>
|
|
5094
|
+
{stroke.authorName}
|
|
5095
|
+
</span>
|
|
5096
|
+
</div>
|
|
5097
|
+
</foreignObject>
|
|
5098
|
+
)}
|
|
4300
5099
|
</g>
|
|
4301
5100
|
);
|
|
4302
5101
|
}
|
|
@@ -4482,7 +5281,6 @@ function StrokeNodeBase({
|
|
|
4482
5281
|
const y = Math.min(stroke.y, stroke.y + stroke.h);
|
|
4483
5282
|
const w = Math.abs(stroke.w);
|
|
4484
5283
|
const h = Math.abs(stroke.h);
|
|
4485
|
-
const chipW = Math.max(56, stroke.label.length * SECTION_LABEL_FONT * 0.62 + 18);
|
|
4486
5284
|
return (
|
|
4487
5285
|
<g data-id={stroke.id} data-tool="section">
|
|
4488
5286
|
{/* Region body — pure backdrop, CLICK-THROUGH (FigJam: content on a
|
|
@@ -4518,31 +5316,10 @@ function StrokeNodeBase({
|
|
|
4518
5316
|
pointerEvents="stroke"
|
|
4519
5317
|
/>
|
|
4520
5318
|
) : null}
|
|
4521
|
-
{/* Label chip above the top-left corner — also a grab handle.
|
|
4522
|
-
|
|
4523
|
-
|
|
4524
|
-
|
|
4525
|
-
y={y - SECTION_LABEL_H - 4}
|
|
4526
|
-
width={chipW}
|
|
4527
|
-
height={SECTION_LABEL_H}
|
|
4528
|
-
rx={5}
|
|
4529
|
-
ry={5}
|
|
4530
|
-
fill={stroke.color}
|
|
4531
|
-
fillOpacity={0.16}
|
|
4532
|
-
/>
|
|
4533
|
-
<text
|
|
4534
|
-
x={x + 9}
|
|
4535
|
-
y={y - SECTION_LABEL_H / 2 - 4}
|
|
4536
|
-
dominantBaseline="middle"
|
|
4537
|
-
fontSize={SECTION_LABEL_FONT}
|
|
4538
|
-
fill={stroke.color}
|
|
4539
|
-
style={{
|
|
4540
|
-
fontFamily: 'var(--u-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace)',
|
|
4541
|
-
}}
|
|
4542
|
-
>
|
|
4543
|
-
{stroke.label}
|
|
4544
|
-
</text>
|
|
4545
|
-
</g>
|
|
5319
|
+
{/* Label chip above the top-left corner — also a grab handle. Hidden
|
|
5320
|
+
while the rename editor (StandaloneTextEditor) is up at the same
|
|
5321
|
+
spot, else the old label reads through behind the new text. */}
|
|
5322
|
+
{editing ? null : <SectionLabelChip stroke={stroke} x={x} y={y} hitMode={hitMode} />}
|
|
4546
5323
|
</g>
|
|
4547
5324
|
);
|
|
4548
5325
|
}
|