@1agh/maude 1.4.4 → 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 +19 -0
- package/apps/studio/canvas-comment-mount.tsx +48 -3
- package/apps/studio/canvas-lib.tsx +5 -0
- package/apps/studio/canvas-shell.tsx +110 -14
- package/apps/studio/canvas-source-memo.ts +39 -0
- package/apps/studio/canvas-text-patch.ts +117 -0
- package/apps/studio/client/app.jsx +108 -60
- package/apps/studio/client/comments-overlay.css +10 -0
- package/apps/studio/client/file-tree.jsx +157 -0
- package/apps/studio/client/index.html +1 -1
- package/apps/studio/client/panels/DiffView.jsx +37 -8
- package/apps/studio/client/panels/GitPanel.jsx +2 -1
- package/apps/studio/client/panels/SourceConflictPanel.jsx +19 -17
- package/apps/studio/client/styles/3-shell-maude.css +21 -9
- package/apps/studio/client/styles/4-components.css +4 -2
- 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 +1086 -1086
- package/apps/studio/dist/comment-mount.js +2 -2
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/hmr-broadcast.ts +59 -3
- package/apps/studio/http.ts +3 -0
- package/apps/studio/sync/accepted-cold-start.ts +95 -7
- package/apps/studio/sync/accepted-link.ts +165 -29
- package/apps/studio/sync/agent.ts +28 -2
- package/apps/studio/sync/comment-ledger.ts +229 -0
- package/apps/studio/sync/file-plane.ts +101 -2
- package/apps/studio/sync/index.ts +124 -17
- package/apps/studio/sync/journal-client.ts +9 -3
- package/apps/studio/sync/migrate-seed.ts +9 -2
- package/apps/studio/sync/projection.ts +114 -11
- package/apps/studio/sync/remote-docs.ts +3 -2
- package/apps/studio/sync/source-recovery.ts +67 -0
- package/apps/studio/sync/status.ts +11 -3
- package/apps/studio/sync/transaction-client.ts +49 -4
- package/apps/studio/use-collab.tsx +28 -1
- package/apps/studio/use-selection-set.tsx +13 -3
- package/apps/studio/use-undo-stack.tsx +22 -2
- package/apps/studio/whats-new.json +45 -0
- package/cli/lib/workspace-plan.mjs +10 -0
- package/package.json +8 -8
- package/plugins/design/templates/_shell.html +21 -0
|
@@ -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;
|
|
@@ -4234,6 +4249,10 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
4234
4249
|
}
|
|
4235
4250
|
const after = await Bun.file(abs).text();
|
|
4236
4251
|
const seq = after !== before ? logUndo(abs, before, after) : undefined;
|
|
4252
|
+
// This API write is complete: accepted sync need not wait for an
|
|
4253
|
+
// external editor's quiet window. It is still only a proposal, not an
|
|
4254
|
+
// acknowledgement; the normal hub validation/acceptance path owns that.
|
|
4255
|
+
if (after !== before) ctx.bus.emit('source-written', { rel, content: after });
|
|
4237
4256
|
return { ok: true, delta: res.delta, seq, ...previous };
|
|
4238
4257
|
} catch (err) {
|
|
4239
4258
|
ctx.bus.emit('activity:unsuppress', rel);
|
|
@@ -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;
|
|
@@ -636,7 +669,19 @@ function CanvasHmrRuntime({
|
|
|
636
669
|
lastGood.current = canvasRef.current;
|
|
637
670
|
// The new canvas rendered clean → drop any holding state.
|
|
638
671
|
setHoldingState((h) => (h.on ? { on: false } : h));
|
|
639
|
-
|
|
672
|
+
// A soft replacement keeps the iframe document, so inspect.ts does not
|
|
673
|
+
// repeat its initial loaded handshake. Re-announce only AFTER the new
|
|
674
|
+
// subtree commits: the shell then reselects the current element and reads
|
|
675
|
+
// fresh attributes/styles instead of retaining the pre-restore inspector.
|
|
676
|
+
// Failed renders keep the last good tree and must not claim a new load.
|
|
677
|
+
if (attempt > 0 && file) {
|
|
678
|
+
try {
|
|
679
|
+
window.parent.postMessage({ dgn: 'loaded', file }, '*');
|
|
680
|
+
} catch {
|
|
681
|
+
// The parent can detach while an imported canvas is committing.
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
}, [attempt, file]);
|
|
640
685
|
|
|
641
686
|
const handleError = useCallback(() => {
|
|
642
687
|
setHoldingState({ on: true, message: 'render error' });
|
|
@@ -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;
|
|
@@ -2422,6 +2469,38 @@ function isLockedElement(el: Element | null, cdId: string | null): boolean {
|
|
|
2422
2469
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
2423
2470
|
// Router wire-up
|
|
2424
2471
|
|
|
2472
|
+
// The shell writes an inspector edit, THEN posts `record-edit` for it. The
|
|
2473
|
+
// write reaches disk before the shell has its response, so a Cmd+Z pressed in
|
|
2474
|
+
// that window found the stack one entry short and inverted the edit BELOW the
|
|
2475
|
+
// one on screen — refused as "changed by someone else", or worse, applied.
|
|
2476
|
+
// Before undo/redo, ask the shell to answer once its recordable writes have
|
|
2477
|
+
// settled. Messages between two windows arrive in order, so every
|
|
2478
|
+
// `record-edit` those writes produce is delivered before the answer. Bounded:
|
|
2479
|
+
// a shell that never answers costs a short wait, not the undo.
|
|
2480
|
+
export function afterShellRecords(timeoutMs = 3000): Promise<void> {
|
|
2481
|
+
if (typeof window === 'undefined' || window.parent === window) return Promise.resolve();
|
|
2482
|
+
const requestId = `ub-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
|
2483
|
+
return new Promise<void>((resolve) => {
|
|
2484
|
+
const done = () => {
|
|
2485
|
+
clearTimeout(timer);
|
|
2486
|
+
window.removeEventListener('message', onReply);
|
|
2487
|
+
resolve();
|
|
2488
|
+
};
|
|
2489
|
+
const onReply = (e: MessageEvent) => {
|
|
2490
|
+
const d = e.data as { dgn?: string; requestId?: string } | null;
|
|
2491
|
+
if (e.source === window.parent && d?.dgn === 'undo-barrier-ok' && d.requestId === requestId)
|
|
2492
|
+
done();
|
|
2493
|
+
};
|
|
2494
|
+
const timer = setTimeout(done, timeoutMs);
|
|
2495
|
+
window.addEventListener('message', onReply);
|
|
2496
|
+
try {
|
|
2497
|
+
window.parent.postMessage({ dgn: 'undo-barrier', requestId }, '*');
|
|
2498
|
+
} catch {
|
|
2499
|
+
done();
|
|
2500
|
+
}
|
|
2501
|
+
});
|
|
2502
|
+
}
|
|
2503
|
+
|
|
2425
2504
|
function CanvasRouter({
|
|
2426
2505
|
hostRef,
|
|
2427
2506
|
children,
|
|
@@ -2443,6 +2522,11 @@ function CanvasRouter({
|
|
|
2443
2522
|
// without making them a hook dependency — avoids re-binding on every undo op.
|
|
2444
2523
|
const undoStackRef = useRef(undoStack);
|
|
2445
2524
|
undoStackRef.current = undoStack;
|
|
2525
|
+
// Selection readback can refresh while the user is typing (e.g. the shell's
|
|
2526
|
+
// post-HMR halo restore). Keep the editor's lifetime tied to its host, not
|
|
2527
|
+
// the changing selection snapshot, or effect cleanup silently ends typing.
|
|
2528
|
+
const textSelectionRef = useRef(selSet);
|
|
2529
|
+
textSelectionRef.current = selSet;
|
|
2446
2530
|
|
|
2447
2531
|
// Phase: inline-edit undo (DDR-103/104 follow-up). Wire the `editSourceApplyFn`
|
|
2448
2532
|
// sink so an `edit-source` command's do()/undo() reaches the main-origin-only
|
|
@@ -2454,10 +2538,17 @@ function CanvasRouter({
|
|
|
2454
2538
|
// or any failed write) rejects, the stack keeps the entry instead of moving
|
|
2455
2539
|
// its cursor, and the user is told why.
|
|
2456
2540
|
useEffect(() => {
|
|
2541
|
+
// HMR replaces this React subtree, not the iframe or the parent write.
|
|
2542
|
+
// Drain already-sent requests after cleanup instead of losing their ACK
|
|
2543
|
+
// (and, on conflict, their user-facing refusal). Timers remain bounded.
|
|
2544
|
+
let disposed = false;
|
|
2457
2545
|
const pending = new Map<
|
|
2458
2546
|
string,
|
|
2459
2547
|
{ resolve: () => void; reject: (err: Error) => void; timer: ReturnType<typeof setTimeout> }
|
|
2460
2548
|
>();
|
|
2549
|
+
const detachIfIdle = () => {
|
|
2550
|
+
if (disposed && pending.size === 0) window.removeEventListener('message', onResult);
|
|
2551
|
+
};
|
|
2461
2552
|
const onResult = (event: MessageEvent) => {
|
|
2462
2553
|
if (event.source !== window.parent) return;
|
|
2463
2554
|
const data = event.data as
|
|
@@ -2468,6 +2559,7 @@ function CanvasRouter({
|
|
|
2468
2559
|
if (!entry) return;
|
|
2469
2560
|
pending.delete(data.requestId);
|
|
2470
2561
|
clearTimeout(entry.timer);
|
|
2562
|
+
detachIfIdle();
|
|
2471
2563
|
if (data.ok === true) {
|
|
2472
2564
|
entry.resolve();
|
|
2473
2565
|
return;
|
|
@@ -2482,11 +2574,16 @@ function CanvasRouter({
|
|
|
2482
2574
|
let sequence = 0;
|
|
2483
2575
|
const applyFn: EditSourceApplyFn = (apply) =>
|
|
2484
2576
|
new Promise<void>((resolve, reject) => {
|
|
2577
|
+
if (disposed) {
|
|
2578
|
+
reject(new Error('the canvas closed'));
|
|
2579
|
+
return;
|
|
2580
|
+
}
|
|
2485
2581
|
sequence += 1;
|
|
2486
2582
|
const requestId = `${Date.now().toString(36)}-${sequence}-${Math.random().toString(36).slice(2, 8)}`;
|
|
2487
2583
|
// Generous: the shell serializes these writes behind any in-flight one.
|
|
2488
2584
|
const timer = setTimeout(() => {
|
|
2489
2585
|
pending.delete(requestId);
|
|
2586
|
+
detachIfIdle();
|
|
2490
2587
|
reject(new Error('the edit was not confirmed'));
|
|
2491
2588
|
}, 30_000);
|
|
2492
2589
|
pending.set(requestId, { resolve, reject, timer });
|
|
@@ -2496,17 +2593,14 @@ function CanvasRouter({
|
|
|
2496
2593
|
/* detached / cross-origin teardown — nothing to apply */
|
|
2497
2594
|
pending.delete(requestId);
|
|
2498
2595
|
clearTimeout(timer);
|
|
2596
|
+
detachIfIdle();
|
|
2499
2597
|
reject(new Error('the canvas is detached'));
|
|
2500
2598
|
}
|
|
2501
2599
|
});
|
|
2502
2600
|
undoSinks.setSink('editSourceApplyFn', applyFn);
|
|
2503
2601
|
return () => {
|
|
2504
|
-
|
|
2505
|
-
|
|
2506
|
-
clearTimeout(entry.timer);
|
|
2507
|
-
entry.reject(new Error('the canvas closed'));
|
|
2508
|
-
}
|
|
2509
|
-
pending.clear();
|
|
2602
|
+
disposed = true;
|
|
2603
|
+
detachIfIdle();
|
|
2510
2604
|
undoSinks.setSink('editSourceApplyFn', undefined);
|
|
2511
2605
|
};
|
|
2512
2606
|
}, [undoSinks]);
|
|
@@ -2734,11 +2828,11 @@ function CanvasRouter({
|
|
|
2734
2828
|
}
|
|
2735
2829
|
// Plan C — Edit menu (shell) bridges to the in-canvas undo stack.
|
|
2736
2830
|
if (m.dgn === 'undo') {
|
|
2737
|
-
void
|
|
2831
|
+
void afterShellRecords().then(() => undoStackRef.current.undo());
|
|
2738
2832
|
return;
|
|
2739
2833
|
}
|
|
2740
2834
|
if (m.dgn === 'redo') {
|
|
2741
|
-
void
|
|
2835
|
+
void afterShellRecords().then(() => undoStackRef.current.redo());
|
|
2742
2836
|
return;
|
|
2743
2837
|
}
|
|
2744
2838
|
// Phase 12 Task 4 (DDR-103) — Layers-tree round-trip. select-by-id resolves
|
|
@@ -3144,7 +3238,7 @@ function CanvasRouter({
|
|
|
3144
3238
|
// selected (the drill has reached it). Previously a dblclick on leaf
|
|
3145
3239
|
// text jumped straight to the editor from any depth, so the drill ladder
|
|
3146
3240
|
// never ran on text-bearing targets.
|
|
3147
|
-
const sel0 =
|
|
3241
|
+
const sel0 = textSelectionRef.current.selected;
|
|
3148
3242
|
const currentId = sel0.length === 1 ? sel0[0]?.id : undefined;
|
|
3149
3243
|
const leafReached = isLeafText && currentId === stamped.getAttribute('data-cd-id');
|
|
3150
3244
|
if (!leafReached) {
|
|
@@ -3167,7 +3261,9 @@ function CanvasRouter({
|
|
|
3167
3261
|
(next.closest('[data-dc-screen]') as HTMLElement | null)?.getAttribute(
|
|
3168
3262
|
'data-dc-screen'
|
|
3169
3263
|
) ?? null;
|
|
3170
|
-
|
|
3264
|
+
textSelectionRef.current.replace(
|
|
3265
|
+
hoverTargetToSelection({ el: next, cdId, artboardId } as HoverTarget)
|
|
3266
|
+
);
|
|
3171
3267
|
}
|
|
3172
3268
|
}
|
|
3173
3269
|
return;
|
|
@@ -3228,7 +3324,7 @@ function CanvasRouter({
|
|
|
3228
3324
|
document.removeEventListener('maude:enter-text-edit', onEnterTextEdit);
|
|
3229
3325
|
if (editing) teardown(editing);
|
|
3230
3326
|
};
|
|
3231
|
-
}, [hostRef
|
|
3327
|
+
}, [hostRef]);
|
|
3232
3328
|
|
|
3233
3329
|
// Cleanup any pending rAF on unmount.
|
|
3234
3330
|
useEffect(
|
|
@@ -3314,10 +3410,10 @@ function CanvasRouter({
|
|
|
3314
3410
|
},
|
|
3315
3411
|
onTool: ({ tool: t }) => setTool(t),
|
|
3316
3412
|
onUndo: () => {
|
|
3317
|
-
void
|
|
3413
|
+
void afterShellRecords().then(() => undoStackRef.current.undo());
|
|
3318
3414
|
},
|
|
3319
3415
|
onRedo: () => {
|
|
3320
|
-
void
|
|
3416
|
+
void afterShellRecords().then(() => undoStackRef.current.redo());
|
|
3321
3417
|
},
|
|
3322
3418
|
onEscape: () => {
|
|
3323
3419
|
// T21 — abort any mid-stroke draw FIRST. The annotations layer
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// The last canvas source this server BUILT, with the locator that build
|
|
2
|
+
// produced, per file (F4).
|
|
3
|
+
//
|
|
4
|
+
// The HMR broadcaster diffs a changed canvas against it to find a text-only
|
|
5
|
+
// edit it can hand straight to the open iframe (`HmrMessage.patches`). Seeded
|
|
6
|
+
// by the module route after each build — so the memo is exactly the source the
|
|
7
|
+
// iframe renders, and its locator names that render's `data-cd-id`s — and
|
|
8
|
+
// advanced by every broadcast that carried patches. Bounded: a memo miss only
|
|
9
|
+
// means the remount renders the change without the head start.
|
|
10
|
+
|
|
11
|
+
import type { LocatorMap } from './locator.ts';
|
|
12
|
+
|
|
13
|
+
const MAX_FILES = 256;
|
|
14
|
+
const MAX_BYTES = 512 * 1024;
|
|
15
|
+
|
|
16
|
+
export interface BuiltCanvas {
|
|
17
|
+
source: string;
|
|
18
|
+
locator: LocatorMap;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const memo = new Map<string, BuiltCanvas>();
|
|
22
|
+
|
|
23
|
+
export function rememberCanvasBuild(absPath: string, built: BuiltCanvas): void {
|
|
24
|
+
memo.delete(absPath);
|
|
25
|
+
if (built.source.length > MAX_BYTES) return;
|
|
26
|
+
memo.set(absPath, built);
|
|
27
|
+
if (memo.size > MAX_FILES) {
|
|
28
|
+
const oldest = memo.keys().next().value;
|
|
29
|
+
if (oldest !== undefined) memo.delete(oldest);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function recallCanvasBuild(absPath: string): BuiltCanvas | undefined {
|
|
34
|
+
return memo.get(absPath);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function forgetCanvasBuilds(): void {
|
|
38
|
+
memo.clear();
|
|
39
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// Text-only change detection for the F4 peer-render fast path.
|
|
2
|
+
//
|
|
3
|
+
// A teammate's text edit reaches this disk ~100 ms after they commit it, but
|
|
4
|
+
// re-importing and remounting the canvas module takes several hundred more.
|
|
5
|
+
// When the ONLY difference between the source the iframe renders and the new
|
|
6
|
+
// source is the text between an element's opening and closing tag, the iframe
|
|
7
|
+
// can show the new string at once and let the remount confirm it.
|
|
8
|
+
//
|
|
9
|
+
// DELIBERATELY PARSER-FREE. In a cell this runs in the process that holds the
|
|
10
|
+
// tenant's credentials, where canvas source is never parsed or built (DDR-209:
|
|
11
|
+
// that happens in the sandbox). So this is bounded string scanning over one
|
|
12
|
+
// changed region, and the element's identity comes from the LOCATOR the
|
|
13
|
+
// sandboxed build already produced for the rendered source — never from
|
|
14
|
+
// parsing tenant bytes here. Anything it cannot prove is a plain text run —
|
|
15
|
+
// markup, attributes, expressions, entities, several regions — returns null,
|
|
16
|
+
// and the remount alone renders the change, exactly as before.
|
|
17
|
+
|
|
18
|
+
import type { LocatorMap } from './locator.ts';
|
|
19
|
+
|
|
20
|
+
export interface TextPatch {
|
|
21
|
+
/** The element's `data-cd-id` in the rendered (before) build. */
|
|
22
|
+
id: string;
|
|
23
|
+
/** The string React renders for the element's text child. */
|
|
24
|
+
text: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const STRUCTURAL = /[<>{}&]/;
|
|
28
|
+
|
|
29
|
+
/** Babel's JSX text whitespace rule (cleanJSXElementLiteralChild). */
|
|
30
|
+
export function renderedJsxText(raw: string): string {
|
|
31
|
+
const lines = raw.split(/\r\n|\n|\r/);
|
|
32
|
+
let lastNonEmpty = 0;
|
|
33
|
+
for (let i = 0; i < lines.length; i++) if (/[^ \t]/.test(lines[i] as string)) lastNonEmpty = i;
|
|
34
|
+
let out = '';
|
|
35
|
+
for (let i = 0; i < lines.length; i++) {
|
|
36
|
+
let line = (lines[i] as string).replace(/\t/g, ' ');
|
|
37
|
+
if (i !== 0) line = line.replace(/^[ ]+/, '');
|
|
38
|
+
if (i !== lines.length - 1) line = line.replace(/[ ]+$/, '');
|
|
39
|
+
if (line) {
|
|
40
|
+
if (i !== lastNonEmpty) line += ' ';
|
|
41
|
+
out += line;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return out;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** 1-based line / 0-based column, counted the way the build's locator counts. */
|
|
48
|
+
function lineCol(source: string, at: number): { line: number; col: number } {
|
|
49
|
+
let line = 1;
|
|
50
|
+
let col = 0;
|
|
51
|
+
for (let i = 0; i < at; i++) {
|
|
52
|
+
if (source.charCodeAt(i) === 10) {
|
|
53
|
+
line += 1;
|
|
54
|
+
col = 0;
|
|
55
|
+
} else col += 1;
|
|
56
|
+
}
|
|
57
|
+
return { line, col };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const TAG_NAME = /^[A-Za-z][\w.:-]*/;
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The single text patch that turns `before`'s render into `after`'s, [] when
|
|
64
|
+
* they are identical, or null when the change is anything but the text run of
|
|
65
|
+
* one element that `locator` (the build of `before`) names.
|
|
66
|
+
*/
|
|
67
|
+
export function textOnlyPatches(
|
|
68
|
+
before: string,
|
|
69
|
+
after: string,
|
|
70
|
+
locator: LocatorMap
|
|
71
|
+
): TextPatch[] | null {
|
|
72
|
+
if (before === after) return [];
|
|
73
|
+
const limit = Math.min(before.length, after.length);
|
|
74
|
+
let p = 0;
|
|
75
|
+
while (p < limit && before.charCodeAt(p) === after.charCodeAt(p)) p++;
|
|
76
|
+
let s = 0;
|
|
77
|
+
while (
|
|
78
|
+
s < limit - p &&
|
|
79
|
+
before.charCodeAt(before.length - 1 - s) === after.charCodeAt(after.length - 1 - s)
|
|
80
|
+
)
|
|
81
|
+
s++;
|
|
82
|
+
if (STRUCTURAL.test(before.slice(p, before.length - s))) return null;
|
|
83
|
+
if (STRUCTURAL.test(after.slice(p, after.length - s))) return null;
|
|
84
|
+
|
|
85
|
+
// The text run around the change: from the `>` ending an opening tag to the
|
|
86
|
+
// `</` of a closing tag, with nothing structural in between (both sides).
|
|
87
|
+
let open = p - 1;
|
|
88
|
+
while (open >= 0 && !STRUCTURAL.test(before[open] as string)) open--;
|
|
89
|
+
if (open < 0 || before[open] !== '>' || before[open - 1] === '/') return null;
|
|
90
|
+
let close = before.length - s;
|
|
91
|
+
while (close < before.length && !STRUCTURAL.test(before[close] as string)) close++;
|
|
92
|
+
if (before[close] !== '<' || before[close + 1] !== '/') return null;
|
|
93
|
+
const closeAfter = close + (after.length - before.length);
|
|
94
|
+
|
|
95
|
+
// The opening tag this `>` ends: its `<`, a tag name, and a closing tag that
|
|
96
|
+
// names the same element.
|
|
97
|
+
const start = before.lastIndexOf('<', open);
|
|
98
|
+
if (start < 0) return null;
|
|
99
|
+
const name = TAG_NAME.exec(before.slice(start + 1))?.[0];
|
|
100
|
+
if (!name || before.slice(close + 2, close + 2 + name.length + 1) !== `${name}>`) return null;
|
|
101
|
+
|
|
102
|
+
const at = lineCol(before, start);
|
|
103
|
+
// The locator is the sandboxed build's output: trust its shape, not its size.
|
|
104
|
+
const ids = Object.entries(locator ?? {}).filter(
|
|
105
|
+
([id, e]) =>
|
|
106
|
+
/^[0-9a-f]{8}$/.test(id) &&
|
|
107
|
+
e?.line === at.line &&
|
|
108
|
+
e?.col === at.col &&
|
|
109
|
+
Array.isArray(e?.jsxPath) &&
|
|
110
|
+
e.jsxPath.at(-1) === name
|
|
111
|
+
);
|
|
112
|
+
if (ids.length !== 1) return null;
|
|
113
|
+
|
|
114
|
+
const text = renderedJsxText(after.slice(open + 1, closeAfter));
|
|
115
|
+
if (!text) return null;
|
|
116
|
+
return [{ id: (ids[0] as [string, unknown])[0], text }];
|
|
117
|
+
}
|