@1agh/maude 1.4.5 → 1.5.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/annotations/ai-read.ts +288 -0
- package/apps/studio/annotations/ai-write.ts +533 -0
- package/apps/studio/annotations/board-io.ts +94 -0
- package/apps/studio/annotations/board-text.ts +45 -0
- package/apps/studio/annotations/constants.ts +58 -0
- package/apps/studio/annotations/elements/_shared.ts +125 -0
- package/apps/studio/annotations/elements/arrow.model.ts +196 -0
- package/apps/studio/annotations/elements/media.model.ts +79 -0
- package/apps/studio/annotations/elements/pen.model.ts +82 -0
- package/apps/studio/annotations/elements/section.model.ts +70 -0
- package/apps/studio/annotations/elements/shape.model.ts +141 -0
- package/apps/studio/annotations/elements/sticky.model.ts +48 -0
- package/apps/studio/annotations/elements/text.model.ts +52 -0
- package/apps/studio/annotations/fields.ts +347 -0
- package/apps/studio/annotations/fractional-index.ts +234 -0
- package/apps/studio/annotations/legacy/mini-dom.ts +207 -0
- package/apps/studio/annotations/migrate-boot.ts +172 -0
- package/apps/studio/annotations/migrate-cli.ts +37 -0
- package/apps/studio/annotations/migrate-v1.ts +383 -0
- package/apps/studio/annotations/ops.ts +474 -0
- package/apps/studio/annotations/registry.ts +159 -0
- package/apps/studio/annotations/replica.ts +264 -0
- package/apps/studio/annotations/scene.ts +235 -0
- package/apps/studio/annotations/schema.ts +188 -0
- package/apps/studio/annotations/types.ts +77 -0
- package/apps/studio/annotations/ui/board.ts +126 -0
- package/apps/studio/annotations/ui/containment.ts +118 -0
- package/apps/studio/annotations/ui/edit-actions.ts +551 -0
- package/apps/studio/annotations/ui/editor-channel.ts +62 -0
- package/apps/studio/annotations/ui/element-node.tsx +993 -0
- package/apps/studio/annotations/ui/pipeline-context.ts +17 -0
- package/apps/studio/annotations/ui/pointer-pipeline.ts +150 -0
- package/apps/studio/annotations/ui/render-model.ts +264 -0
- package/apps/studio/annotations/ui/scene.tsx +63 -0
- package/apps/studio/annotations/ui/text-editor.tsx +420 -0
- package/apps/studio/annotations/ui/text-session.ts +140 -0
- package/apps/studio/annotations/ui/text-style.ts +111 -0
- package/apps/studio/annotations/ui/world.ts +47 -0
- package/apps/studio/annotations/v1-adapter.ts +428 -0
- package/apps/studio/annotations-align.ts +21 -6
- package/apps/studio/annotations-bindings.ts +2 -0
- package/apps/studio/annotations-context-toolbar.tsx +9 -13
- package/apps/studio/annotations-groups.ts +3 -0
- package/apps/studio/annotations-layer.tsx +923 -1925
- package/apps/studio/annotations-model.ts +97 -4
- package/apps/studio/annotations-sync.ts +4 -47
- package/apps/studio/api.ts +262 -68
- package/apps/studio/bin/_import-figma.mjs +22 -12
- package/apps/studio/bin/annotate.mjs +331 -838
- package/apps/studio/bin/annotate.sh +4 -4
- package/apps/studio/bin/perf.sh +21 -7
- package/apps/studio/bin/read-annotations.mjs +184 -666
- package/apps/studio/bin/read-annotations.sh +9 -5
- package/apps/studio/canvas-artifacts.ts +9 -0
- package/apps/studio/canvas-comment-mount.tsx +35 -2
- package/apps/studio/canvas-lib.tsx +18 -1
- package/apps/studio/canvas-shell.tsx +54 -1
- package/apps/studio/client/app.jsx +101 -35
- package/apps/studio/client/comments-overlay.css +10 -0
- package/apps/studio/client/hmr.mjs +1 -1
- package/apps/studio/client/panels/git-grouping.js +2 -2
- package/apps/studio/client/tree-expansion.js +217 -0
- package/apps/studio/collab/index.ts +58 -5
- package/apps/studio/collab/persistence.ts +80 -11
- package/apps/studio/collab/registry.ts +63 -26
- package/apps/studio/commands/annotation-ops-command.ts +72 -0
- package/apps/studio/comment-anchor.ts +116 -0
- package/apps/studio/comments-overlay.tsx +78 -35
- package/apps/studio/context.ts +1 -0
- package/apps/studio/cursors-overlay.tsx +337 -109
- package/apps/studio/dist/client.bundle.js +850 -850
- package/apps/studio/dist/comment-mount.js +2 -2
- package/apps/studio/figma/to-strokes.ts +31 -10
- package/apps/studio/git/endpoints.ts +1 -1
- package/apps/studio/git/service.ts +1 -1
- package/apps/studio/git/watch.ts +1 -1
- package/apps/studio/http.ts +90 -15
- package/apps/studio/server.ts +16 -0
- package/apps/studio/sync/accepted-cold-start.ts +72 -8
- package/apps/studio/sync/agent.ts +36 -6
- package/apps/studio/sync/codec.ts +95 -40
- package/apps/studio/sync/comment-ledger.ts +229 -0
- package/apps/studio/sync/file-membership.ts +12 -2
- package/apps/studio/sync/file-plane.ts +78 -2
- package/apps/studio/sync/index.ts +75 -16
- package/apps/studio/sync/journal-client.ts +5 -0
- package/apps/studio/sync/limits.ts +7 -2
- package/apps/studio/sync/migrate-seed.ts +17 -7
- package/apps/studio/sync/projection.ts +7 -2
- package/apps/studio/sync/remote-docs.ts +34 -0
- package/apps/studio/sync/writer-registry.ts +10 -0
- package/apps/studio/text-caret.ts +11 -2
- package/apps/studio/tree-state.ts +45 -0
- package/apps/studio/undo-stack.ts +2 -2
- package/apps/studio/use-annotation-resize.tsx +48 -23
- package/apps/studio/use-annotation-selection.tsx +9 -2
- package/apps/studio/use-collab.tsx +104 -1
- package/apps/studio/use-selection-set.tsx +4 -0
- package/apps/studio/whats-new.json +63 -0
- package/cli/lib/design-link.mjs +5 -1
- package/cli/lib/gitignore-block.mjs +1 -1
- package/cli/lib/gitignore-drift.mjs +2 -1
- package/package.json +9 -8
- package/plugins/design/templates/brief-board.tsx.template +1 -1
- package/apps/studio/annotation-edit-base.ts +0 -36
- package/apps/studio/commands/annotation-strokes-command.ts +0 -137
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file commands/annotation-ops-command.ts — undo entry for an annotation op batch
|
|
3
|
+
* @scope apps/studio/commands/annotation-ops-command.ts
|
|
4
|
+
* @purpose DDR-242 AD4 (Task 26): one user gesture is one op batch and one
|
|
5
|
+
* undo record; undo is the batch's INVERSE (from `applyOps`),
|
|
6
|
+
* which is peer-safe at field granularity — it reverts only the
|
|
7
|
+
* fields this action set and only while they still hold its value.
|
|
8
|
+
* Nothing here replays a snapshot of the whole board.
|
|
9
|
+
*
|
|
10
|
+
* Rebuilt per iframe mount from a serializable record (DDR-050), so
|
|
11
|
+
* the stack survives canvas switches; the side effect (apply
|
|
12
|
+
* locally + send) is the layer's `annotationOpsFn` sink.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { Op } from '../annotations/ops.ts';
|
|
16
|
+
import type { CommandRecord, EditCommand } from '../undo-stack.ts';
|
|
17
|
+
import { registerCommand } from '../undo-stack.ts';
|
|
18
|
+
|
|
19
|
+
export const ANNOTATION_OPS_KIND = 'annotation-ops';
|
|
20
|
+
|
|
21
|
+
export interface AnnotationOpsPayload {
|
|
22
|
+
ops: readonly Op[];
|
|
23
|
+
inverse: readonly Op[];
|
|
24
|
+
/**
|
|
25
|
+
* The canvas the batch belongs to. The stack's records live in origin-wide
|
|
26
|
+
* sessionStorage, so the layer replays only its own canvas's (security
|
|
27
|
+
* review A5 — another canvas must not steer this one's undo).
|
|
28
|
+
*/
|
|
29
|
+
file?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Applies a batch locally and sends it; returns the batch's own inverse. */
|
|
33
|
+
export type AnnotationOpsFn = (ops: readonly Op[], file?: string) => readonly Op[] | void;
|
|
34
|
+
|
|
35
|
+
export function buildAnnotationOpsRecord(opts: {
|
|
36
|
+
ops: readonly Op[];
|
|
37
|
+
inverse: readonly Op[];
|
|
38
|
+
label: string;
|
|
39
|
+
file?: string;
|
|
40
|
+
}): CommandRecord<AnnotationOpsPayload> {
|
|
41
|
+
return {
|
|
42
|
+
kind: ANNOTATION_OPS_KIND,
|
|
43
|
+
label: opts.label,
|
|
44
|
+
payload: {
|
|
45
|
+
ops: structuredClone(opts.ops),
|
|
46
|
+
inverse: structuredClone(opts.inverse),
|
|
47
|
+
...(opts.file ? { file: opts.file } : {}),
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function createAnnotationOpsCommand(
|
|
53
|
+
record: CommandRecord<AnnotationOpsPayload>,
|
|
54
|
+
fn: AnnotationOpsFn
|
|
55
|
+
): EditCommand {
|
|
56
|
+
return {
|
|
57
|
+
kind: ANNOTATION_OPS_KIND,
|
|
58
|
+
label: record.label,
|
|
59
|
+
async do() {
|
|
60
|
+
fn(record.payload.ops, record.payload.file);
|
|
61
|
+
},
|
|
62
|
+
async undo() {
|
|
63
|
+
fn(record.payload.inverse, record.payload.file);
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
registerCommand<AnnotationOpsPayload>(ANNOTATION_OPS_KIND, (record, sinks) => {
|
|
69
|
+
const fn = sinks.annotationOpsFn as AnnotationOpsFn | undefined;
|
|
70
|
+
if (!fn) return null;
|
|
71
|
+
return createAnnotationOpsCommand(record, fn);
|
|
72
|
+
});
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file comment-anchor.ts — where a comment lives when it is not on an element
|
|
3
|
+
* @scope apps/studio/comment-anchor.ts
|
|
4
|
+
* @purpose Two anchors besides the `data-cd-id` element selector (#134/#136):
|
|
5
|
+
*
|
|
6
|
+
* - `annotationId` — a comment placed on a sticky, shape, pen stroke, image,
|
|
7
|
+
* link or media card anchors to that annotation's `data-id`. Annotation ids
|
|
8
|
+
* are persisted in `*.annotations.svg` and stable across edits, undo and
|
|
9
|
+
* sync (see `stableAnnotationId` in annotations-model.ts for the one case
|
|
10
|
+
* they were not). The pin follows the annotation; a deleted annotation
|
|
11
|
+
* DETACHES the comment, it never deletes it.
|
|
12
|
+
* - `world` — a comment placed on empty canvas holds a WORLD point, so it stays
|
|
13
|
+
* put through pan and zoom. The legacy screen `bounds` captured at create
|
|
14
|
+
* time only matched the camera of that moment.
|
|
15
|
+
*
|
|
16
|
+
* Runs inside the canvas iframe, in both the canvas bundle and the separate
|
|
17
|
+
* comment-mount bundle — so the camera is read through `window.__maudeViewport`
|
|
18
|
+
* (canvas-lib.tsx), not through a module import that would be another copy.
|
|
19
|
+
* Annotation ids are peer-supplied (DDR-054): every id is validated before it
|
|
20
|
+
* reaches a selector.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
export interface WorldPoint {
|
|
24
|
+
x: number;
|
|
25
|
+
y: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface Viewport {
|
|
29
|
+
x: number;
|
|
30
|
+
y: number;
|
|
31
|
+
zoom: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Shape rule for an annotation id — the `s_…` scheme plus imported ids. */
|
|
35
|
+
const ANNOTATION_ID_RE = /^[A-Za-z0-9_-]{1,64}$/;
|
|
36
|
+
/** World coordinates beyond this are garbage, not a place on the board. */
|
|
37
|
+
const MAX_WORLD = 10_000_000;
|
|
38
|
+
|
|
39
|
+
export function isAnnotationId(v: unknown): v is string {
|
|
40
|
+
return typeof v === 'string' && ANNOTATION_ID_RE.test(v);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function isWorldPoint(v: unknown): v is WorldPoint {
|
|
44
|
+
if (!v || typeof v !== 'object') return false;
|
|
45
|
+
const { x, y } = v as { x?: unknown; y?: unknown };
|
|
46
|
+
return (
|
|
47
|
+
typeof x === 'number' &&
|
|
48
|
+
typeof y === 'number' &&
|
|
49
|
+
Number.isFinite(x) &&
|
|
50
|
+
Number.isFinite(y) &&
|
|
51
|
+
Math.abs(x) <= MAX_WORLD &&
|
|
52
|
+
Math.abs(y) <= MAX_WORLD
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function liveCamera(): { vp: Viewport; left: number; top: number } | null {
|
|
57
|
+
if (typeof window === 'undefined' || typeof document === 'undefined') return null;
|
|
58
|
+
const vp = window.__maudeViewport?.() ?? null;
|
|
59
|
+
if (!vp || !(vp.zoom > 0)) return null;
|
|
60
|
+
const host = document.querySelector('.dc-canvas');
|
|
61
|
+
if (!host) return null;
|
|
62
|
+
const r = host.getBoundingClientRect();
|
|
63
|
+
return { vp, left: r.left, top: r.top };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Client (iframe viewport) point → world point, or null with no world plane. */
|
|
67
|
+
export function clientToWorld(clientX: number, clientY: number): WorldPoint | null {
|
|
68
|
+
const cam = liveCamera();
|
|
69
|
+
if (!cam) return null;
|
|
70
|
+
const { vp } = cam;
|
|
71
|
+
return {
|
|
72
|
+
x: (clientX - cam.left - vp.x) / vp.zoom,
|
|
73
|
+
y: (clientY - cam.top - vp.y) / vp.zoom,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** World point → client point, or null with no world plane. */
|
|
78
|
+
export function worldToClient(p: WorldPoint): { x: number; y: number } | null {
|
|
79
|
+
const cam = liveCamera();
|
|
80
|
+
if (!cam) return null;
|
|
81
|
+
const { vp } = cam;
|
|
82
|
+
return { x: cam.left + vp.x + p.x * vp.zoom, y: cam.top + vp.y + p.y * vp.zoom };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** The live element of an annotation, or null. */
|
|
86
|
+
export function annotationElement(id: string): Element | null {
|
|
87
|
+
if (typeof document === 'undefined' || !isAnnotationId(id)) return null;
|
|
88
|
+
// The id is already restricted to [A-Za-z0-9_-]; no escaping needed.
|
|
89
|
+
return document.querySelector(`[data-id="${id}"][data-tool]`);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* The annotation under a client point, if any. Geometric rather than
|
|
94
|
+
* `elementFromPoint`: in comment mode the annotation layer is not the hit
|
|
95
|
+
* target (its pointer events belong to the draw tools), which is exactly why a
|
|
96
|
+
* click on a sticky used to fall through to a floating comment.
|
|
97
|
+
*
|
|
98
|
+
* The smallest containing box wins, so a sticky inside a section anchors to
|
|
99
|
+
* the sticky. Sections are skipped: they frame artboards and other elements,
|
|
100
|
+
* and "a comment somewhere inside this section" is a floating comment.
|
|
101
|
+
*/
|
|
102
|
+
export function annotationIdAt(clientX: number, clientY: number): string | null {
|
|
103
|
+
if (typeof document === 'undefined') return null;
|
|
104
|
+
let best: { id: string; area: number } | null = null;
|
|
105
|
+
for (const el of Array.from(document.querySelectorAll('[data-id][data-tool]'))) {
|
|
106
|
+
if (el.getAttribute('data-tool') === 'section') continue;
|
|
107
|
+
const id = el.getAttribute('data-id');
|
|
108
|
+
if (!isAnnotationId(id)) continue;
|
|
109
|
+
const r = el.getBoundingClientRect();
|
|
110
|
+
if (r.width <= 0 && r.height <= 0) continue;
|
|
111
|
+
if (clientX < r.left || clientX > r.right || clientY < r.top || clientY > r.bottom) continue;
|
|
112
|
+
const area = Math.max(1, r.width) * Math.max(1, r.height);
|
|
113
|
+
if (!best || area <= best.area) best = { id, area };
|
|
114
|
+
}
|
|
115
|
+
return best?.id ?? null;
|
|
116
|
+
}
|
|
@@ -33,9 +33,13 @@
|
|
|
33
33
|
* trade-off), which can silently reanchor a comment to the wrong element or to
|
|
34
34
|
* nothing. `resolveCommentTarget` tries the stored selector first, falls back
|
|
35
35
|
* to a structural match via `resolveByDomPath` (dom-selection.ts) when the
|
|
36
|
-
* direct hit is missing or looks like the wrong element (tag mismatch)
|
|
37
|
-
*
|
|
38
|
-
*
|
|
36
|
+
* direct hit is missing or looks like the wrong element (tag mismatch). A
|
|
37
|
+
* comment whose target stays unresolvable past a short grace window is
|
|
38
|
+
* DETACHED — kept, shown at its last known place, and marked — never deleted:
|
|
39
|
+
* software deleting a comment is how #134/#136 lost every comment placed on a
|
|
40
|
+
* sticky or on empty canvas, and how one peer's missing element deleted a
|
|
41
|
+
* comment for everyone. Only a person deletes a comment. Comments on
|
|
42
|
+
* annotations and on empty canvas anchor through `comment-anchor.ts`.
|
|
39
43
|
*
|
|
40
44
|
* Popup placement — `CommentComposer` / `CommentThread` pick a side
|
|
41
45
|
* (left/right, above/below the anchor) that actually fits the viewport via
|
|
@@ -49,6 +53,13 @@
|
|
|
49
53
|
|
|
50
54
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
51
55
|
|
|
56
|
+
import {
|
|
57
|
+
annotationElement,
|
|
58
|
+
isAnnotationId,
|
|
59
|
+
isWorldPoint,
|
|
60
|
+
type WorldPoint,
|
|
61
|
+
worldToClient,
|
|
62
|
+
} from './comment-anchor.ts';
|
|
52
63
|
import { resolveByDomPath } from './dom-selection.ts';
|
|
53
64
|
import { isReadOnlyCanvas } from './read-only-mode.ts';
|
|
54
65
|
import { useCollab } from './use-collab.tsx';
|
|
@@ -84,6 +95,10 @@ interface ComposeSelection {
|
|
|
84
95
|
dom_path: string[];
|
|
85
96
|
bounds: OverlayBounds | null;
|
|
86
97
|
html: string;
|
|
98
|
+
/** Set when the click landed on an annotation (comment-anchor.ts). */
|
|
99
|
+
annotationId?: string;
|
|
100
|
+
/** World point of the click, when the canvas has a world plane. */
|
|
101
|
+
world?: WorldPoint;
|
|
87
102
|
}
|
|
88
103
|
|
|
89
104
|
interface ComposerState {
|
|
@@ -121,6 +136,11 @@ export interface OverlayComment {
|
|
|
121
136
|
tag?: string;
|
|
122
137
|
classes?: string;
|
|
123
138
|
dom_path?: string[];
|
|
139
|
+
/** Anchor on an annotation (sticky, shape, stroke…) — see comment-anchor.ts. */
|
|
140
|
+
annotationId?: string;
|
|
141
|
+
/** World point of the comment — the anchor of a floating comment, and the
|
|
142
|
+
* last known place of an anchored one whose target is gone. */
|
|
143
|
+
world?: WorldPoint;
|
|
124
144
|
// html_excerpt unused at overlay layer; kept off the type to keep the
|
|
125
145
|
// surface tight.
|
|
126
146
|
}
|
|
@@ -176,6 +196,7 @@ export interface TargetRef {
|
|
|
176
196
|
tag?: string;
|
|
177
197
|
classes?: string;
|
|
178
198
|
dom_path?: string[];
|
|
199
|
+
annotationId?: string;
|
|
179
200
|
}
|
|
180
201
|
|
|
181
202
|
/**
|
|
@@ -187,6 +208,9 @@ export interface TargetRef {
|
|
|
187
208
|
* match via `resolveByDomPath`.
|
|
188
209
|
*/
|
|
189
210
|
export function resolveCommentTarget(target: TargetRef): HTMLElement | null {
|
|
211
|
+
if (isAnnotationId(target.annotationId)) {
|
|
212
|
+
return annotationElement(target.annotationId) as HTMLElement | null;
|
|
213
|
+
}
|
|
190
214
|
if (!target.selector) return null;
|
|
191
215
|
let el: HTMLElement | null = null;
|
|
192
216
|
try {
|
|
@@ -426,6 +450,8 @@ export function CommentsOverlay(): React.ReactNode {
|
|
|
426
450
|
classes: sel.classes,
|
|
427
451
|
bounds: sel.bounds,
|
|
428
452
|
html_excerpt: sel.html,
|
|
453
|
+
...(sel.annotationId ? { annotationId: sel.annotationId } : {}),
|
|
454
|
+
...(sel.world ? { world: sel.world } : {}),
|
|
429
455
|
text,
|
|
430
456
|
};
|
|
431
457
|
if (typeof window === 'undefined') return;
|
|
@@ -547,7 +573,6 @@ export function CommentsOverlay(): React.ReactNode {
|
|
|
547
573
|
sequence={n}
|
|
548
574
|
focused={focusedId === c.id}
|
|
549
575
|
onClick={handlePinClick}
|
|
550
|
-
onOrphaned={handleDelete}
|
|
551
576
|
/>
|
|
552
577
|
);
|
|
553
578
|
})}
|
|
@@ -836,28 +861,54 @@ function MentionAwareTextarea({
|
|
|
836
861
|
// reflow, font load). Falls back to the stored `bounds` when the target is
|
|
837
862
|
// gone from the DOM.
|
|
838
863
|
|
|
839
|
-
// How long a pin
|
|
840
|
-
//
|
|
841
|
-
//
|
|
842
|
-
|
|
843
|
-
|
|
864
|
+
// How long a pin may stay unresolvable before it is shown as DETACHED. Long
|
|
865
|
+
// enough to ride out a canvas HMR remount. Detached is a view of this client's
|
|
866
|
+
// DOM, never a fact written back: another peer may still resolve the target.
|
|
867
|
+
const DETACH_GRACE_MS = 3000;
|
|
868
|
+
|
|
869
|
+
/** The pin box for a client point — the pin centres on the point. */
|
|
870
|
+
function pointBox(p: { x: number; y: number }): OverlayBounds {
|
|
871
|
+
return { x: p.x - 12, y: p.y - 12, w: 24, h: 24 };
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
/**
|
|
875
|
+
* Where a comment's pin goes this frame, and whether its target is missing.
|
|
876
|
+
*
|
|
877
|
+
* - anchored (element selector or annotation id) and resolved → on the target;
|
|
878
|
+
* - anchored but unresolved → last known place (world point, else the
|
|
879
|
+
* create-time screen bounds), `missing: true`;
|
|
880
|
+
* - floating (no anchor) → its world point, else legacy screen bounds.
|
|
881
|
+
*/
|
|
882
|
+
export function locateComment(comment: OverlayComment): {
|
|
883
|
+
pos: OverlayBounds | null;
|
|
884
|
+
missing: boolean;
|
|
885
|
+
} {
|
|
886
|
+
const anchored = !!comment.selector || isAnnotationId(comment.annotationId);
|
|
887
|
+
if (anchored) {
|
|
888
|
+
const live = screenRectFor(comment);
|
|
889
|
+
if (live) return { pos: live, missing: false };
|
|
890
|
+
}
|
|
891
|
+
const at = isWorldPoint(comment.world) ? worldToClient(comment.world) : null;
|
|
892
|
+
const pos = at ? pointBox(at) : (comment.bounds ?? null);
|
|
893
|
+
return { pos, missing: anchored };
|
|
894
|
+
}
|
|
844
895
|
|
|
845
896
|
function CommentPin({
|
|
846
897
|
comment,
|
|
847
898
|
sequence,
|
|
848
899
|
focused,
|
|
849
900
|
onClick,
|
|
850
|
-
onOrphaned,
|
|
851
901
|
}: {
|
|
852
902
|
comment: OverlayComment;
|
|
853
903
|
sequence: number;
|
|
854
904
|
focused: boolean;
|
|
855
905
|
onClick: (id: string) => void;
|
|
856
|
-
onOrphaned: (id: string) => void;
|
|
857
906
|
}) {
|
|
858
907
|
const ref = useRef<HTMLButtonElement | null>(null);
|
|
859
908
|
const rafRef = useRef<number | null>(null);
|
|
860
|
-
const
|
|
909
|
+
const missingSinceRef = useRef<number | null>(null);
|
|
910
|
+
const [detached, setDetached] = useState(false);
|
|
911
|
+
const detachedRef = useRef(false);
|
|
861
912
|
|
|
862
913
|
useEffect(() => {
|
|
863
914
|
const tick = () => {
|
|
@@ -865,27 +916,18 @@ function CommentPin({
|
|
|
865
916
|
const pin = ref.current;
|
|
866
917
|
if (!pin) return;
|
|
867
918
|
|
|
868
|
-
// Live screen-coord lookup mirrors SelectionHalos in canvas-shell.tsx
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
unresolvedSinceRef.current = null;
|
|
919
|
+
// Live screen-coord lookup mirrors SelectionHalos in canvas-shell.tsx.
|
|
920
|
+
const { pos, missing } = locateComment(comment);
|
|
921
|
+
let nowDetached = false;
|
|
922
|
+
if (missing) {
|
|
923
|
+
if (missingSinceRef.current == null) missingSinceRef.current = Date.now();
|
|
924
|
+
nowDetached = Date.now() - missingSinceRef.current > DETACH_GRACE_MS;
|
|
875
925
|
} else {
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
if (comment.bounds) {
|
|
882
|
-
pos = {
|
|
883
|
-
x: comment.bounds.x,
|
|
884
|
-
y: comment.bounds.y,
|
|
885
|
-
w: comment.bounds.w,
|
|
886
|
-
h: comment.bounds.h,
|
|
887
|
-
};
|
|
888
|
-
}
|
|
926
|
+
missingSinceRef.current = null;
|
|
927
|
+
}
|
|
928
|
+
if (nowDetached !== detachedRef.current) {
|
|
929
|
+
detachedRef.current = nowDetached;
|
|
930
|
+
setDetached(nowDetached);
|
|
889
931
|
}
|
|
890
932
|
if (!pos) {
|
|
891
933
|
pin.style.display = 'none';
|
|
@@ -905,10 +947,10 @@ function CommentPin({
|
|
|
905
947
|
return () => {
|
|
906
948
|
if (rafRef.current != null) cancelAnimationFrame(rafRef.current);
|
|
907
949
|
};
|
|
908
|
-
}, [comment
|
|
950
|
+
}, [comment]);
|
|
909
951
|
|
|
910
952
|
const author = comment.author?.trim() || 'unknown';
|
|
911
|
-
const label = `Comment ${sequence} by ${author}`;
|
|
953
|
+
const label = `Comment ${sequence} by ${author}${detached ? ' (detached: its target is gone)' : ''}`;
|
|
912
954
|
|
|
913
955
|
return (
|
|
914
956
|
<button
|
|
@@ -917,6 +959,7 @@ function CommentPin({
|
|
|
917
959
|
className="cm-pin"
|
|
918
960
|
data-resolved={comment.status === 'resolved' ? 'true' : 'false'}
|
|
919
961
|
data-focused={focused ? 'true' : 'false'}
|
|
962
|
+
data-detached={detached ? 'true' : 'false'}
|
|
920
963
|
data-comment-pin={comment.id}
|
|
921
964
|
aria-label={label}
|
|
922
965
|
aria-expanded={focused}
|
|
@@ -1002,7 +1045,7 @@ function CommentComposer({
|
|
|
1002
1045
|
// tight inside the 300px card.
|
|
1003
1046
|
const selectorChip = useMemo(() => {
|
|
1004
1047
|
const s = state.selection.selector || '';
|
|
1005
|
-
if (!s) return state.selection.tag || 'canvas';
|
|
1048
|
+
if (!s) return state.selection.tag || (state.selection.annotationId ? 'annotation' : 'canvas');
|
|
1006
1049
|
// [data-cd-id="…"] → cd:<id> · keeps the chip readable when stable ids
|
|
1007
1050
|
// are present.
|
|
1008
1051
|
const cd = s.match(/data-cd-id="([^"]+)"/);
|
package/apps/studio/context.ts
CHANGED
|
@@ -274,6 +274,7 @@ export interface Context {
|
|
|
274
274
|
| { op: 'dir.move'; from: string; to: string }
|
|
275
275
|
): Promise<{ status: 'accepted' | 'rejected'; code?: string; queued?: boolean }> | null;
|
|
276
276
|
acceptedMode?(): boolean;
|
|
277
|
+
commentsConfirmedOnHub?(slug: string): boolean;
|
|
277
278
|
acceptedHistory?(q: {
|
|
278
279
|
limit?: number;
|
|
279
280
|
before?: number | null;
|