@1agh/maude 1.4.5 → 1.4.6
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-model.ts +33 -1
- package/apps/studio/api.ts +15 -0
- package/apps/studio/canvas-comment-mount.tsx +35 -2
- package/apps/studio/canvas-lib.tsx +5 -0
- package/apps/studio/canvas-shell.tsx +48 -1
- package/apps/studio/client/app.jsx +3 -0
- package/apps/studio/client/comments-overlay.css +10 -0
- package/apps/studio/collab/index.ts +9 -0
- package/apps/studio/collab/persistence.ts +49 -1
- 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 +181 -107
- package/apps/studio/dist/client.bundle.js +7 -7
- package/apps/studio/dist/comment-mount.js +2 -2
- package/apps/studio/sync/accepted-cold-start.ts +60 -4
- package/apps/studio/sync/agent.ts +28 -2
- package/apps/studio/sync/comment-ledger.ts +229 -0
- package/apps/studio/sync/file-plane.ts +77 -1
- package/apps/studio/sync/index.ts +37 -0
- package/apps/studio/sync/migrate-seed.ts +9 -2
- package/apps/studio/use-collab.tsx +28 -1
- package/apps/studio/use-selection-set.tsx +4 -0
- package/apps/studio/whats-new.json +36 -0
- package/package.json +8 -8
|
@@ -654,6 +654,37 @@ export function rid(): string {
|
|
|
654
654
|
return `s_${Math.random().toString(36).slice(2, 10)}`;
|
|
655
655
|
}
|
|
656
656
|
|
|
657
|
+
/**
|
|
658
|
+
* The id of an annotation element that was written WITHOUT a `data-id` (a
|
|
659
|
+
* hand-edited or externally generated SVG). It used to be `rid()` — a new
|
|
660
|
+
* random id on every parse — so the same element had a different id in every
|
|
661
|
+
* tab, on every peer and after every reload, and nothing could point at it: a
|
|
662
|
+
* comment anchored to it (#134/#136), a peer's selection halo, an agent's
|
|
663
|
+
* `annotate update`. Now it is derived from the element's own markup, plus its
|
|
664
|
+
* occurrence among identical elements, so every parse of the same SVG gives
|
|
665
|
+
* every element the same id everywhere. The first edit writes it back as a real
|
|
666
|
+
* `data-id` (strokesToSvg always emits one), after which it never changes.
|
|
667
|
+
*
|
|
668
|
+
* Contract for the annotations-v2 element model
|
|
669
|
+
* (.ai/plans/feature-annotations-v2-element-model.md): an annotation's id is
|
|
670
|
+
* stable for the life of the element — across edits, moves, undo/redo, sync
|
|
671
|
+
* and reload — and is what external references (comments' `annotationId`)
|
|
672
|
+
* hold. A migration must carry ids over unchanged.
|
|
673
|
+
*/
|
|
674
|
+
export function stableAnnotationId(el: Element, seen: Map<string, number>): string {
|
|
675
|
+
const content = el.outerHTML;
|
|
676
|
+
const n = seen.get(content) ?? 0;
|
|
677
|
+
seen.set(content, n + 1);
|
|
678
|
+
// FNV-1a, 32-bit — deterministic, dependency-free, plenty for a per-board id.
|
|
679
|
+
let h = 0x811c9dc5;
|
|
680
|
+
const key = `${content}#${n}`;
|
|
681
|
+
for (let i = 0; i < key.length; i++) {
|
|
682
|
+
h ^= key.charCodeAt(i);
|
|
683
|
+
h = Math.imul(h, 0x01000193) >>> 0;
|
|
684
|
+
}
|
|
685
|
+
return `s_h${h.toString(36)}`;
|
|
686
|
+
}
|
|
687
|
+
|
|
657
688
|
/** FigJam v3 — group ids mirror the stroke id scheme (`g_` prefix). */
|
|
658
689
|
export function gid(): string {
|
|
659
690
|
return `g_${Math.random().toString(36).slice(2, 10)}`;
|
|
@@ -1424,9 +1455,10 @@ export function svgToStrokes(svgText: string): Stroke[] {
|
|
|
1424
1455
|
const doc = new DOMParser().parseFromString(text, 'image/svg+xml');
|
|
1425
1456
|
if (doc.querySelector('parsererror')) return [];
|
|
1426
1457
|
const out: Stroke[] = [];
|
|
1458
|
+
const seenContent = new Map<string, number>();
|
|
1427
1459
|
for (const el of Array.from(doc.querySelectorAll('[data-tool]'))) {
|
|
1428
1460
|
const tool = el.getAttribute('data-tool');
|
|
1429
|
-
const id = el.getAttribute('data-id') ||
|
|
1461
|
+
const id = el.getAttribute('data-id') || stableAnnotationId(el, seenContent);
|
|
1430
1462
|
const color = el.getAttribute('stroke') || el.getAttribute('fill') || DEFAULT_COLOR;
|
|
1431
1463
|
const width = Number.parseFloat(el.getAttribute('stroke-width') || '2') || 2;
|
|
1432
1464
|
// FigJam v3 — every branch funnels through push() so the shared attrs
|
package/apps/studio/api.ts
CHANGED
|
@@ -19,6 +19,7 @@ import { canvasArtifacts, locatorKeyFor, relocatedName } from './canvas-artifact
|
|
|
19
19
|
import { renderBriefBoard, validateCanvasName, validateFolderName } from './canvas-create.ts';
|
|
20
20
|
import { rewriteRelativeImports } from './canvas-imports.ts';
|
|
21
21
|
import { canvasSlugFromRel } from './canvas-slug.ts';
|
|
22
|
+
import { isAnnotationId, isWorldPoint } from './comment-anchor.ts';
|
|
22
23
|
import { atomicWrite } from './sync/atomic-write.ts';
|
|
23
24
|
import { dedupeCommentsById } from './sync/comment-identity.ts';
|
|
24
25
|
import { isRuntimeStateRel } from './sync/file-membership.ts';
|
|
@@ -323,6 +324,12 @@ export interface Comment {
|
|
|
323
324
|
* text is untrusted user/peer text (DDR-054) — rendered as text, never
|
|
324
325
|
* into TSX. */
|
|
325
326
|
timeline?: { clipStableId?: string; frameOffset?: number; frame?: number; lane?: string };
|
|
327
|
+
/** #134/#136 — anchor on an annotation (`data-id` in `*.annotations.svg`).
|
|
328
|
+
* Absent on element and floating comments. See comment-anchor.ts. */
|
|
329
|
+
annotationId?: string;
|
|
330
|
+
/** World point of the comment: the anchor of a floating comment, the last
|
|
331
|
+
* known place of an anchored one. Absent on legacy comments. */
|
|
332
|
+
world?: { x: number; y: number };
|
|
326
333
|
}
|
|
327
334
|
|
|
328
335
|
export interface GitCommitter {
|
|
@@ -1247,6 +1254,11 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
1247
1254
|
thread: Array.isArray(c.thread) ? c.thread : [],
|
|
1248
1255
|
mentions: Array.isArray(c.mentions) ? c.mentions : [],
|
|
1249
1256
|
...(timeline ? { timeline } : { timeline: undefined }),
|
|
1257
|
+
// Same trust boundary for the #134/#136 anchors: a peer-synced comment
|
|
1258
|
+
// never passed commentsAdd, so an anchor that fails its shape is dropped
|
|
1259
|
+
// (the comment then renders from `bounds`, detached — never deleted).
|
|
1260
|
+
annotationId: isAnnotationId(c.annotationId) ? c.annotationId : undefined,
|
|
1261
|
+
world: isWorldPoint(c.world) ? { x: c.world.x, y: c.world.y } : undefined,
|
|
1250
1262
|
};
|
|
1251
1263
|
}
|
|
1252
1264
|
|
|
@@ -1462,6 +1474,9 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
1462
1474
|
}
|
|
1463
1475
|
if (anchor.clipStableId != null || anchor.frame != null) c.timeline = anchor;
|
|
1464
1476
|
}
|
|
1477
|
+
// #134/#136 — annotation / world anchors (peer-supplied: shape-checked).
|
|
1478
|
+
if (isAnnotationId(payload.annotationId)) c.annotationId = payload.annotationId;
|
|
1479
|
+
if (isWorldPoint(payload.world)) c.world = { x: payload.world.x, y: payload.world.y };
|
|
1465
1480
|
list.push(c);
|
|
1466
1481
|
await publishComments(payload.file, list, base);
|
|
1467
1482
|
return c;
|
|
@@ -39,6 +39,7 @@ import {
|
|
|
39
39
|
} from 'react';
|
|
40
40
|
import { createRoot } from 'react-dom/client';
|
|
41
41
|
|
|
42
|
+
import { annotationElement, annotationIdAt, clientToWorld } from './comment-anchor.ts';
|
|
42
43
|
import { CommentsOverlay } from './comments-overlay.tsx';
|
|
43
44
|
import { deriveFile, hoverTargetToSelection } from './dom-selection.ts';
|
|
44
45
|
import {
|
|
@@ -394,6 +395,36 @@ function dropComment(
|
|
|
394
395
|
file: string | undefined
|
|
395
396
|
): void {
|
|
396
397
|
if (typeof document === 'undefined') return;
|
|
398
|
+
const world = clientToWorld(clientX, clientY) ?? undefined;
|
|
399
|
+
|
|
400
|
+
// A click on an annotation (sticky, shape, stroke, image, link, media card)
|
|
401
|
+
// anchors to it (#134/#136). Checked first: annotations draw above the
|
|
402
|
+
// artboards, so what the user clicked is the annotation, not the element
|
|
403
|
+
// underneath it.
|
|
404
|
+
const annotationId = annotationIdAt(clientX, clientY);
|
|
405
|
+
if (annotationId) {
|
|
406
|
+
const el = annotationElement(annotationId);
|
|
407
|
+
const r = el?.getBoundingClientRect();
|
|
408
|
+
const annotationSel: Selection = {
|
|
409
|
+
file,
|
|
410
|
+
id: undefined,
|
|
411
|
+
selector: '',
|
|
412
|
+
artboardId: null,
|
|
413
|
+
tag: el?.getAttribute('data-tool') ?? 'annotation',
|
|
414
|
+
classes: '',
|
|
415
|
+
text: '',
|
|
416
|
+
dom_path: [],
|
|
417
|
+
bounds: r
|
|
418
|
+
? { x: r.left, y: r.top, w: r.width, h: r.height }
|
|
419
|
+
: { x: clientX - 12, y: clientY - 12, w: 24, h: 24 },
|
|
420
|
+
html: '',
|
|
421
|
+
annotationId,
|
|
422
|
+
...(world ? { world } : {}),
|
|
423
|
+
};
|
|
424
|
+
openComposer(annotationSel, clientX, clientY);
|
|
425
|
+
return;
|
|
426
|
+
}
|
|
427
|
+
|
|
397
428
|
let target = resolveHoverTarget(document, clientX, clientY, { deep: true });
|
|
398
429
|
if (!target) target = resolveHoverTarget(document, clientX, clientY, { deep: false });
|
|
399
430
|
// UI-canvas recovery — when both passes bail on a `pointer-events: none`
|
|
@@ -428,8 +459,9 @@ function dropComment(
|
|
|
428
459
|
|
|
429
460
|
if (!target) {
|
|
430
461
|
// Floating comment — no element anchor, just the click point (e.g. a click
|
|
431
|
-
// on empty canvas/specimen dead space). The overlay renders
|
|
432
|
-
//
|
|
462
|
+
// on empty canvas/specimen dead space). The overlay renders the pin at the
|
|
463
|
+
// WORLD point, so it stays put through pan and zoom; `bounds` is the
|
|
464
|
+
// screen-space fallback for surfaces without a world plane (specimens).
|
|
433
465
|
const floatingSel: Selection = {
|
|
434
466
|
file,
|
|
435
467
|
id: undefined,
|
|
@@ -441,6 +473,7 @@ function dropComment(
|
|
|
441
473
|
dom_path: [],
|
|
442
474
|
bounds: { x: clientX - 12, y: clientY - 12, w: 24, h: 24 },
|
|
443
475
|
html: '',
|
|
476
|
+
...(world ? { world } : {}),
|
|
444
477
|
};
|
|
445
478
|
openComposer(floatingSel, clientX, clientY);
|
|
446
479
|
return;
|
|
@@ -2869,11 +2869,16 @@ function buildCanvasRectsManifest(): CanvasRectsManifest {
|
|
|
2869
2869
|
declare global {
|
|
2870
2870
|
interface Window {
|
|
2871
2871
|
__maudeCanvasRects?: () => CanvasRectsManifest;
|
|
2872
|
+
/** The live camera, for layers built in another bundle (the comment
|
|
2873
|
+
* overlay mounts from canvas-comment-mount, which gets its own copy of
|
|
2874
|
+
* this module's state) — see comment-anchor.ts. */
|
|
2875
|
+
__maudeViewport?: () => ViewportState | null;
|
|
2872
2876
|
}
|
|
2873
2877
|
}
|
|
2874
2878
|
|
|
2875
2879
|
if (typeof window !== 'undefined') {
|
|
2876
2880
|
window.__maudeCanvasRects = buildCanvasRectsManifest;
|
|
2881
|
+
window.__maudeViewport = getLiveViewport;
|
|
2877
2882
|
}
|
|
2878
2883
|
|
|
2879
2884
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -1130,9 +1130,52 @@ interface DsThemeSupport {
|
|
|
1130
1130
|
}
|
|
1131
1131
|
|
|
1132
1132
|
let _dsThemeSupport: DsThemeSupport | null = null;
|
|
1133
|
+
// A NEGATIVE answer is cached too (#131). The probe below appends DOM to
|
|
1134
|
+
// <body> and reads getComputedStyle for every candidate, which forces a style
|
|
1135
|
+
// recalc of the whole document. Only a positive answer was cached, so on a
|
|
1136
|
+
// canvas whose DS has a single theme it re-ran on EVERY call — and the element
|
|
1137
|
+
// toolbar's menu asks on every render, i.e. every frame of a pan with something
|
|
1138
|
+
// selected. On a 160-board canvas in Safari that alone held pan at ~1.3 fps
|
|
1139
|
+
// (hundreds of ms per probe, two per frame). The reason a negative answer was
|
|
1140
|
+
// not cached — a DS stylesheet that parses after first paint — is honoured
|
|
1141
|
+
// precisely instead: the negative answer holds until a stylesheet is added to
|
|
1142
|
+
// or finishes loading in the document, then the next call probes again.
|
|
1143
|
+
let _dsThemeUnsupported = false;
|
|
1144
|
+
let _dsThemeWatch = false;
|
|
1145
|
+
const _unsupported: DsThemeSupport = { supported: false, wrapperClass: '' };
|
|
1146
|
+
|
|
1147
|
+
function watchStylesheetsOnce(): void {
|
|
1148
|
+
if (_dsThemeWatch || typeof document === 'undefined') return;
|
|
1149
|
+
_dsThemeWatch = true;
|
|
1150
|
+
const invalidate = (): void => {
|
|
1151
|
+
_dsThemeUnsupported = false;
|
|
1152
|
+
};
|
|
1153
|
+
try {
|
|
1154
|
+
new MutationObserver((records) => {
|
|
1155
|
+
for (const r of records) {
|
|
1156
|
+
for (const n of Array.from(r.addedNodes)) {
|
|
1157
|
+
const tag = (n as Element).tagName;
|
|
1158
|
+
if (tag === 'STYLE' || tag === 'LINK') return invalidate();
|
|
1159
|
+
}
|
|
1160
|
+
}
|
|
1161
|
+
}).observe(document.head ?? document.documentElement, { childList: true, subtree: true });
|
|
1162
|
+
// A <link> that was already in the DOM finishing its load (capture: load
|
|
1163
|
+
// does not bubble).
|
|
1164
|
+
document.addEventListener(
|
|
1165
|
+
'load',
|
|
1166
|
+
(e) => {
|
|
1167
|
+
if ((e.target as Element | null)?.tagName === 'LINK') invalidate();
|
|
1168
|
+
},
|
|
1169
|
+
true
|
|
1170
|
+
);
|
|
1171
|
+
} catch {
|
|
1172
|
+
_dsThemeWatch = false;
|
|
1173
|
+
}
|
|
1174
|
+
}
|
|
1133
1175
|
|
|
1134
|
-
function detectDsThemeSupport(): DsThemeSupport {
|
|
1176
|
+
export function detectDsThemeSupport(): DsThemeSupport {
|
|
1135
1177
|
if (_dsThemeSupport) return _dsThemeSupport;
|
|
1178
|
+
if (_dsThemeUnsupported) return _unsupported;
|
|
1136
1179
|
const fallback: DsThemeSupport = { supported: false, wrapperClass: '' };
|
|
1137
1180
|
if (typeof document === 'undefined' || !document.body) return fallback;
|
|
1138
1181
|
try {
|
|
@@ -1181,6 +1224,10 @@ function detectDsThemeSupport(): DsThemeSupport {
|
|
|
1181
1224
|
// unsupported. Caching that would permanently disable theming; instead
|
|
1182
1225
|
// re-probe until support is confirmed (or the DS genuinely has one theme).
|
|
1183
1226
|
if (found.supported) _dsThemeSupport = found;
|
|
1227
|
+
else {
|
|
1228
|
+
_dsThemeUnsupported = true;
|
|
1229
|
+
watchStylesheetsOnce();
|
|
1230
|
+
}
|
|
1184
1231
|
return found;
|
|
1185
1232
|
} catch {
|
|
1186
1233
|
return fallback;
|
|
@@ -13711,6 +13711,9 @@ function App() {
|
|
|
13711
13711
|
classes: p.classes,
|
|
13712
13712
|
bounds: p.bounds,
|
|
13713
13713
|
html_excerpt: p.html_excerpt,
|
|
13714
|
+
// #134/#136 anchors — shape-checked server-side (api.ts commentsAdd).
|
|
13715
|
+
annotationId: p.annotationId,
|
|
13716
|
+
world: p.world,
|
|
13714
13717
|
text: txt,
|
|
13715
13718
|
},
|
|
13716
13719
|
});
|
|
@@ -65,6 +65,16 @@
|
|
|
65
65
|
opacity: 0.6;
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
+
/* Detached — the comment's element or annotation is gone from this canvas. The
|
|
69
|
+
comment is kept at its last known place (never deleted by software, #134/#136).
|
|
70
|
+
A hollow, dashed pin: the state reads from the shape, not from colour alone,
|
|
71
|
+
and the number keeps full contrast. The aria-label says "detached" too. */
|
|
72
|
+
.cm-pin[data-detached='true'] {
|
|
73
|
+
background: var(--maude-chrome-bg-1, #faf6ef);
|
|
74
|
+
color: var(--maude-chrome-fg-0, #2a2520);
|
|
75
|
+
border: 1.5px dashed var(--maude-hud-accent, #6f63ef);
|
|
76
|
+
}
|
|
77
|
+
|
|
68
78
|
.cm-pin[data-focused='true'] {
|
|
69
79
|
/* Subtle ring — DS hard-edges discipline: no glow, just a hairline outline. */
|
|
70
80
|
outline: 1px solid var(--maude-chrome-fg-0, #2a2520);
|
|
@@ -64,6 +64,15 @@ export function createCollab(ctx: Context, api: Api): Collab {
|
|
|
64
64
|
api,
|
|
65
65
|
fileForSlug,
|
|
66
66
|
shouldSeed: (slug) => !(ctx.sharedDoc && registryRef?.isPinned(slug)),
|
|
67
|
+
// Issue #133 — record comment ids as synced only once the hub holds them,
|
|
68
|
+
// and only for the room that IS the hub's doc (pinned). No hub linked:
|
|
69
|
+
// nothing is "synced" — recording then would label local comments with
|
|
70
|
+
// the last hub's identity and a relink would drop them (security review
|
|
71
|
+
// F1a). Linked but the runtime is not up yet: not confirmed.
|
|
72
|
+
commentsConfirmed: (slug) => {
|
|
73
|
+
if (!ctx.cfg?.linkedHub || !registryRef?.isPinned(slug)) return false;
|
|
74
|
+
return ctx.syncControl?.current?.()?.commentsConfirmedOnHub?.(slug) === true;
|
|
75
|
+
},
|
|
67
76
|
// A room restored from its own `.ydoc.bin` must not outrank a sidecar the
|
|
68
77
|
// hub (or an editor) wrote after that cache — see `reconcileAfterCache`.
|
|
69
78
|
// Only for rooms no hub provider owns: a pinned doc is the hub's replica
|
|
@@ -11,6 +11,7 @@ import type { Context } from '../context.ts';
|
|
|
11
11
|
// From the LEAF, never from `sync/codec.ts` — codec imports `Y_TYPES` from this
|
|
12
12
|
// file, so reaching for it here would close a cycle (see sync/limits.ts).
|
|
13
13
|
import { commentKey } from '../sync/comment-identity.ts';
|
|
14
|
+
import { type CommentLedger, commentLedgerFor } from '../sync/comment-ledger.ts';
|
|
14
15
|
import { MAX_ANNOTATIONS_BYTES, MAX_COMMENTS_BYTES, withinByteCap } from '../sync/limits.ts';
|
|
15
16
|
import { ensureStateDir, type RoomCallbacks } from './room.ts';
|
|
16
17
|
|
|
@@ -60,6 +61,19 @@ export interface PersistenceDeps {
|
|
|
60
61
|
* seed). Absent → cache-only restore, the previous behavior.
|
|
61
62
|
*/
|
|
62
63
|
reconcileAfterCache?: (slug: string, doc: Y.Doc, cachedAtMs: number) => Promise<void>;
|
|
64
|
+
/**
|
|
65
|
+
* Issue #133 — which comment ids this machine synced before (see
|
|
66
|
+
* sync/comment-ledger.ts). Defaults to the process-wide ledger for the design
|
|
67
|
+
* root; tests inject a fresh one per simulated launch.
|
|
68
|
+
*/
|
|
69
|
+
commentLedger?: CommentLedger;
|
|
70
|
+
/**
|
|
71
|
+
* May the ledger record `slug`'s projected comments as synced? Only when the
|
|
72
|
+
* hub is known to hold them (see `commentsConfirmedOnHub`, sync/index.ts). A
|
|
73
|
+
* projection of a comment added offline would otherwise read as "synced" and
|
|
74
|
+
* the next cold start would drop it. Absent → always (tests, local projects).
|
|
75
|
+
*/
|
|
76
|
+
commentsConfirmed?: (slug: string) => boolean;
|
|
63
77
|
}
|
|
64
78
|
|
|
65
79
|
/**
|
|
@@ -93,6 +107,7 @@ function withinCap(slug: string, lane: string, value: string, max: number): bool
|
|
|
93
107
|
export function createPersistence(deps: PersistenceDeps): RoomCallbacks {
|
|
94
108
|
const { ctx, api, fileForSlug } = deps;
|
|
95
109
|
const stateDir = ensureStateDir(ctx.paths.designRoot);
|
|
110
|
+
const ledger = deps.commentLedger ?? commentLedgerFor(ctx.paths.designRoot);
|
|
96
111
|
|
|
97
112
|
// Per-slug: every comment identity this doc has EVER carried (issue #111).
|
|
98
113
|
//
|
|
@@ -180,6 +195,27 @@ export function createPersistence(deps: PersistenceDeps): RoomCallbacks {
|
|
|
180
195
|
return ids;
|
|
181
196
|
}
|
|
182
197
|
|
|
198
|
+
// Issue #133 (plan Task 8) — a comment that stays on this disk and out of the
|
|
199
|
+
// shared document is invisible to every other peer, and nothing said so: the
|
|
200
|
+
// report behind #133 had to be reconstructed from code. Say it once per
|
|
201
|
+
// change of the count, in the server log (which the in-app bug report
|
|
202
|
+
// attaches), and say when it clears.
|
|
203
|
+
const localOnlyBySlug = new Map<string, number>();
|
|
204
|
+
function reportLocalOnly(slug: string, n: number): void {
|
|
205
|
+
const prev = localOnlyBySlug.get(slug) ?? 0;
|
|
206
|
+
if (n === prev) return;
|
|
207
|
+
localOnlyBySlug.set(slug, n);
|
|
208
|
+
if (n > 0) {
|
|
209
|
+
console.warn(
|
|
210
|
+
`[collab/${slug}] comments: ${n} on this disk ${n === 1 ? 'is' : 'are'} not in the shared document yet — kept, not overwritten; other peers do not see ${n === 1 ? 'it' : 'them'} until ${n === 1 ? 'it arrives' : 'they arrive'}.`
|
|
211
|
+
);
|
|
212
|
+
} else {
|
|
213
|
+
console.log(
|
|
214
|
+
`[collab/${slug}] comments: every comment on this disk is in the shared document again.`
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
183
219
|
function ydocBinPath(slug: string): string {
|
|
184
220
|
return path.join(stateDir, `${slug}.ydoc.bin`);
|
|
185
221
|
}
|
|
@@ -284,10 +320,22 @@ export function createPersistence(deps: PersistenceDeps): RoomCallbacks {
|
|
|
284
320
|
// brings that id into the doc is itself a doc update, which re-arms the
|
|
285
321
|
// flush, and the next pass writes the merged state. A delete still
|
|
286
322
|
// materializes — its id IS in `everSeen`, so the write proceeds.
|
|
323
|
+
//
|
|
324
|
+
// Issue #133 — `everSeen` starts empty on every launch, so an id deleted
|
|
325
|
+
// by a peer while this machine was closed also reads as "never carried"
|
|
326
|
+
// and froze the file for good. The ledger knows it was synced from here
|
|
327
|
+
// before: an id in the ledger and absent from the doc is a delete.
|
|
287
328
|
const onDisk = await api.loadCommentsForFile(file);
|
|
288
|
-
const
|
|
329
|
+
const synced = ledger.get(slug);
|
|
330
|
+
const localOnly = onDisk.filter((c) => {
|
|
331
|
+
const k = commentKey(c);
|
|
332
|
+
return !everSeen.has(k) && !synced.has(k);
|
|
333
|
+
}).length;
|
|
334
|
+
const behind = localOnly > 0;
|
|
335
|
+
reportLocalOnly(slug, localOnly);
|
|
289
336
|
if (!behind && withinCap(slug, 'comments', JSON.stringify(list), MAX_COMMENTS_BYTES)) {
|
|
290
337
|
await api.saveCommentsForFile(file, list);
|
|
338
|
+
if (deps.commentsConfirmed?.(slug) ?? true) ledger.record(slug, list.map(commentKey));
|
|
291
339
|
}
|
|
292
340
|
}
|
|
293
341
|
|
|
@@ -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
|
+
}
|