@1agh/maude 0.43.0 → 0.44.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/acp/bridge.ts +44 -10
- package/apps/studio/acp/env.ts +23 -0
- package/apps/studio/acp/login-state.ts +263 -0
- package/apps/studio/acp/plugin-bootstrap.ts +31 -25
- package/apps/studio/acp/probe.ts +152 -1
- package/apps/studio/annotations-layer.tsx +396 -46
- package/apps/studio/api.ts +395 -2
- package/apps/studio/bin/_audio-search.mjs +140 -0
- package/apps/studio/bin/_import-asset-pdf-worker.mjs +34 -0
- package/apps/studio/bin/_import-asset.mjs +815 -0
- package/apps/studio/bin/_import-brand.mjs +635 -0
- package/apps/studio/bin/_import-tokens-alias-resolver.mjs +95 -0
- package/apps/studio/bin/_import-tokens.mjs +1201 -0
- package/apps/studio/bin/_transcribe.mjs +419 -0
- package/apps/studio/bin/_transcribe.test.mjs +80 -0
- package/apps/studio/bin/audio-search.sh +28 -0
- package/apps/studio/bin/generate.sh +154 -0
- package/apps/studio/bin/import-asset.sh +34 -0
- package/apps/studio/bin/import-brand.sh +29 -0
- package/apps/studio/bin/import-tokens.sh +33 -0
- package/apps/studio/bin/photo-bg-remove.sh +119 -21
- package/apps/studio/bin/transcribe.sh +39 -0
- package/apps/studio/canvas-edit.ts +197 -2
- package/apps/studio/canvas-lib.tsx +115 -6
- package/apps/studio/client/app.jsx +1954 -685
- package/apps/studio/client/generate-dialog.jsx +352 -0
- package/apps/studio/client/github.js +14 -0
- package/apps/studio/client/inspector-controls.jsx +781 -0
- package/apps/studio/client/panels/BrandUploadPanel.jsx +233 -0
- package/apps/studio/client/panels/ChatPanel.jsx +15 -33
- package/apps/studio/client/panels/IntroVideoDialog.jsx +44 -0
- package/apps/studio/client/panels/OnboardingWizard.jsx +12 -0
- package/apps/studio/client/panels/ReadinessList.jsx +227 -7
- package/apps/studio/client/panels/SettingsPanel.jsx +828 -0
- package/apps/studio/client/panels/SetupChecklist.jsx +223 -0
- package/apps/studio/client/photo-knobs.jsx +167 -172
- package/apps/studio/client/styles/3-shell-maude.css +334 -28
- package/apps/studio/client/styles/4-components.css +223 -0
- package/apps/studio/client/styles/6-acp-chat.css +86 -0
- package/apps/studio/client/tour/quick-setup-tour.js +39 -0
- package/apps/studio/commands/annotation-strokes-command.ts +13 -3
- package/apps/studio/config.schema.json +70 -0
- package/apps/studio/context-menu.tsx +11 -2
- package/apps/studio/context.ts +16 -0
- package/apps/studio/design-setup-readiness.ts +115 -0
- package/apps/studio/dist/client.bundle.js +2097 -2088
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/draw/palette.ts +34 -0
- package/apps/studio/footage/schema.test.ts +92 -0
- package/apps/studio/footage/schema.ts +179 -1
- package/apps/studio/generation/adapters/elevenlabs.ts +370 -0
- package/apps/studio/generation/adapters/gemini.ts +542 -0
- package/apps/studio/generation/adapters/groq.test.ts +80 -0
- package/apps/studio/generation/adapters/groq.ts +247 -0
- package/apps/studio/generation/audio-library.test.ts +95 -0
- package/apps/studio/generation/audio-library.ts +156 -0
- package/apps/studio/generation/captions.test.ts +78 -0
- package/apps/studio/generation/captions.ts +132 -0
- package/apps/studio/generation/download.ts +76 -0
- package/apps/studio/generation/elevenlabs.test.ts +223 -0
- package/apps/studio/generation/gemini.test.ts +350 -0
- package/apps/studio/generation/jobs.test.ts +128 -0
- package/apps/studio/generation/jobs.ts +243 -0
- package/apps/studio/generation/keys.test.ts +75 -0
- package/apps/studio/generation/keys.ts +162 -0
- package/apps/studio/generation/prefs.test.ts +95 -0
- package/apps/studio/generation/prefs.ts +82 -0
- package/apps/studio/generation/registry.ts +88 -0
- package/apps/studio/generation/types.test.ts +123 -0
- package/apps/studio/generation/types.ts +266 -0
- package/apps/studio/generation/whisper-models.test.ts +152 -0
- package/apps/studio/generation/whisper-models.ts +257 -0
- package/apps/studio/generation/whisper-spike-results.md +61 -0
- package/apps/studio/git/service.ts +3 -1
- package/apps/studio/http.ts +867 -9
- package/apps/studio/media/intro.mp4 +0 -0
- package/apps/studio/media-commit-chain.ts +119 -0
- package/apps/studio/paths.ts +10 -0
- package/apps/studio/readiness.ts +108 -20
- package/apps/studio/scaffold-design.ts +1145 -0
- package/apps/studio/server.ts +13 -1
- package/apps/studio/test/_helpers.ts +15 -2
- package/apps/studio/test/acp-bridge.test.ts +34 -1
- package/apps/studio/test/acp-env.test.ts +30 -0
- package/apps/studio/test/acp-plugin-bootstrap.test.ts +23 -57
- package/apps/studio/test/acp-session-plugins.test.ts +72 -1
- package/apps/studio/test/annotation-strokes-command.test.ts +17 -0
- package/apps/studio/test/annotations-layer.test.ts +174 -0
- package/apps/studio/test/canvas-origin-gate.test.ts +37 -0
- package/apps/studio/test/csp-canvas-shell.test.ts +11 -0
- package/apps/studio/test/csrf-write-guard.test.ts +23 -1
- package/apps/studio/test/design-setup-readiness.test.ts +155 -0
- package/apps/studio/test/element-structural-edit.test.ts +108 -0
- package/apps/studio/test/fixtures/fake-claude-auth.mjs +13 -0
- package/apps/studio/test/generate-route.test.ts +106 -0
- package/apps/studio/test/import-asset-browser.test.ts +64 -0
- package/apps/studio/test/import-asset-route.test.ts +71 -0
- package/apps/studio/test/import-asset.test.ts +399 -0
- package/apps/studio/test/import-brand-browser.test.ts +88 -0
- package/apps/studio/test/import-brand-route.test.ts +120 -0
- package/apps/studio/test/import-brand.test.ts +206 -0
- package/apps/studio/test/import-tokens.test.ts +722 -0
- package/apps/studio/test/media-commit-chain.test.ts +210 -0
- package/apps/studio/test/photo-bg-remove-validation.test.ts +71 -0
- package/apps/studio/test/readiness.test.ts +13 -11
- package/apps/studio/test/scaffold-design.test.ts +122 -0
- package/apps/studio/test/tour-overlay.test.tsx +16 -0
- package/apps/studio/tool-palette.tsx +11 -6
- package/apps/studio/ui-prefs.ts +130 -0
- package/apps/studio/use-canvas-media-drop.tsx +4 -2
- package/apps/studio/use-chrome-visibility.tsx +8 -2
- package/apps/studio/whats-new.json +96 -0
- package/apps/studio/ws.ts +5 -0
- package/cli/bin/maude.mjs +6 -6
- package/cli/commands/design.mjs +33 -0
- package/cli/lib/gitignore-block.mjs +1 -0
- package/cli/lib/pkg-root.mjs +107 -0
- package/cli/lib/pkg-root.test.mjs +79 -0
- package/cli/lib/reconstruct-toolset.test.mjs +194 -0
- package/cli/lib/update-check.mjs +8 -0
- package/package.json +12 -10
- package/plugins/design/dependencies.json +18 -0
|
@@ -158,6 +158,7 @@ import { useViewportControllerContext, useWorldRefContext } from './canvas-lib.t
|
|
|
158
158
|
import { buildAnnotationStrokesRecord } from './commands/annotation-strokes-command.ts';
|
|
159
159
|
import { ensureMenuStyles as ensureCtxMenuStyles } from './context-menu.tsx';
|
|
160
160
|
import { crossedDragThreshold, type Tool } from './input-router.tsx';
|
|
161
|
+
import { createMediaCommitChain, type MediaCommitResult } from './media-commit-chain.ts';
|
|
161
162
|
import { mountCaret, placeCaretAt } from './text-caret.ts';
|
|
162
163
|
import {
|
|
163
164
|
AnnotationResizeOverlay,
|
|
@@ -170,6 +171,7 @@ import {
|
|
|
170
171
|
import { useAnnotationSelectionOptional } from './use-annotation-selection.tsx';
|
|
171
172
|
import { useAnnotationsVisibility } from './use-annotations-visibility.tsx';
|
|
172
173
|
import {
|
|
174
|
+
BATCH_DROP_CASCADE_PX,
|
|
173
175
|
isHttpUrl,
|
|
174
176
|
linkDomain,
|
|
175
177
|
prettifyUrl,
|
|
@@ -396,6 +398,123 @@ function resolveAssetHref(href: string): string {
|
|
|
396
398
|
return /^assets\//.test(href) ? `/${canvasDesignRel()}/${href}` : href;
|
|
397
399
|
}
|
|
398
400
|
|
|
401
|
+
/**
|
|
402
|
+
* An optimistic ImageStroke's `blob:`/`data:` href must never be PUT to the
|
|
403
|
+
* server. `sanitizeAnnotationSvg` (api.ts) strips an href it doesn't
|
|
404
|
+
* recognize — keeping the `<image>` element, dropping only the attribute —
|
|
405
|
+
* so the server's STORED + broadcast SVG silently diverges from whatever the
|
|
406
|
+
* client just sent. That divergence defeats the collab-echo self-suppression
|
|
407
|
+
* guard (the `recentSelfSvgsRef` history, near `putStrokes` below): the
|
|
408
|
+
* echo's content no longer matches anything we recorded as "already
|
|
409
|
+
* applied", so a real `setStrokesState` fires from the (href-stripped)
|
|
410
|
+
* server copy — which can wipe out a SIBLING stroke's still-in-flight
|
|
411
|
+
* optimistic insert that was never itself part of that PUT. This is the
|
|
412
|
+
* concrete failure a 3-file concurrent drop hit: one
|
|
413
|
+
* image ended up href-stripped (a blank frame) and another was dropped
|
|
414
|
+
* entirely once an unrelated commit's echo round-tripped while both were
|
|
415
|
+
* still uploading. See media-commit-chain.ts for the sibling accumulator fix
|
|
416
|
+
* — this closes the other half, at the persistence layer.
|
|
417
|
+
*/
|
|
418
|
+
function isEphemeralHref(s: Stroke): boolean {
|
|
419
|
+
return s.tool === 'image' && /^(?:blob|data):/i.test(s.href);
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* Fold a local mutation's `next` against the live-rendered `prev`, restoring
|
|
424
|
+
* only strokes that are genuinely concurrent additions from ANOTHER
|
|
425
|
+
* in-flight commit — never reverting THIS mutation's own deletions.
|
|
426
|
+
* Disambiguated via `opBefore`, the baseline this mutation's `next` was
|
|
427
|
+
* itself computed from: an id present in `prev` but absent from BOTH
|
|
428
|
+
* `opBefore` and `next` was added by someone else after `opBefore` was
|
|
429
|
+
* captured (fold it in — this is the sibling-insert race this helper was
|
|
430
|
+
* originally written for). An id present in `opBefore` but absent from
|
|
431
|
+
* `next` was deliberately removed BY THIS MUTATION and must stay removed
|
|
432
|
+
* even though `prev` (React's rendered state) hasn't caught up to that
|
|
433
|
+
* removal yet.
|
|
434
|
+
*
|
|
435
|
+
* The prior version of this helper compared only `prev` against `next` —
|
|
436
|
+
* exactly the shape of every delete (an id in `prev`, absent from `next`) —
|
|
437
|
+
* so it silently folded every erased stroke straight back in locally, while
|
|
438
|
+
* the smaller, correct set still went out over PUT. Backspace looked like a
|
|
439
|
+
* no-op until a reload picked up the server's already-correct copy.
|
|
440
|
+
*/
|
|
441
|
+
export function reconcileCommit(
|
|
442
|
+
prev: readonly Stroke[],
|
|
443
|
+
opBefore: readonly Stroke[],
|
|
444
|
+
next: readonly Stroke[]
|
|
445
|
+
): Stroke[] {
|
|
446
|
+
const beforeIds = new Set(opBefore.map((s) => s.id));
|
|
447
|
+
const nextIds = new Set(next.map((s) => s.id));
|
|
448
|
+
const extra = prev.filter((s) => !beforeIds.has(s.id) && !nextIds.has(s.id));
|
|
449
|
+
return extra.length ? [...next, ...extra] : (next as Stroke[]);
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/**
|
|
453
|
+
* Fold a FOREIGN collab-echo snapshot against local state. A foreign
|
|
454
|
+
* broadcast legitimately omits an id that was deleted (by us or a peer) —
|
|
455
|
+
* reviving it just because local `prev` hasn't caught up would make deletes
|
|
456
|
+
* unsyncable across tabs/peers. The only strokes worth resurrecting here are
|
|
457
|
+
* ones that are still purely local, not-yet-synced optimistic previews (an
|
|
458
|
+
* ephemeral blob:/data: href image mid-upload) that a foreign broadcast could
|
|
459
|
+
* never have known about in the first place.
|
|
460
|
+
*/
|
|
461
|
+
export function reconcileForeignEcho(
|
|
462
|
+
prev: readonly Stroke[],
|
|
463
|
+
incoming: readonly Stroke[]
|
|
464
|
+
): Stroke[] {
|
|
465
|
+
const incomingIds = new Set(incoming.map((s) => s.id));
|
|
466
|
+
const extra = prev.filter((s) => !incomingIds.has(s.id) && isEphemeralHref(s));
|
|
467
|
+
return extra.length ? [...incoming, ...extra] : (incoming as Stroke[]);
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/**
|
|
471
|
+
* Decide what `createImageFromFile`'s post-upload swap should do with a
|
|
472
|
+
* given `before` chain link. Extracted as a pure function (no React) so the
|
|
473
|
+
* delete-during-upload race is unit-testable: `wasDeleted` reflects whether
|
|
474
|
+
* `deleteStrokes` recorded this id (via `deletedStrokeIdsRef`) before the
|
|
475
|
+
* upload resolved — checking that FIRST, rather than inferring intent from
|
|
476
|
+
* `before`'s membership alone, is what stops a Backspace-then-still-
|
|
477
|
+
* uploading image from resurrecting once the upload lands (and syncing the
|
|
478
|
+
* resurrection to every collab peer). When not deleted, an id absent from
|
|
479
|
+
* `before` is assumed to be render lag (this same file's optimistic insert
|
|
480
|
+
* hasn't rendered yet) rather than a delete, and gets folded back in.
|
|
481
|
+
*/
|
|
482
|
+
export function resolveImageUploadSwap(
|
|
483
|
+
before: readonly Stroke[],
|
|
484
|
+
id: string,
|
|
485
|
+
optimistic: Stroke,
|
|
486
|
+
realHref: string,
|
|
487
|
+
wasDeleted: boolean
|
|
488
|
+
): MediaCommitResult<Stroke> | null {
|
|
489
|
+
if (wasDeleted) {
|
|
490
|
+
if (!before.some((s) => s.id === id)) return null;
|
|
491
|
+
return { after: before.filter((s) => s.id !== id), label: 'add image' };
|
|
492
|
+
}
|
|
493
|
+
const base = before.some((s) => s.id === id) ? before : [...before, optimistic];
|
|
494
|
+
const after = base.map((s) => (s.id === id ? ({ ...s, href: realHref } as Stroke) : s));
|
|
495
|
+
// The optimistic blob: entry was never itself committed, so the undo
|
|
496
|
+
// "before" excludes it — undo should remove the image outright, not
|
|
497
|
+
// restore a revoked blob:.
|
|
498
|
+
const commitBefore = base.filter((s) => s.id !== id);
|
|
499
|
+
return { after, commitBefore, label: 'add image' };
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
// feature-bulk-media-insert Task 11 — classify an already-uploaded
|
|
503
|
+
// `assets/<sha8>.<ext>` path client-side, mirroring the same extension sets
|
|
504
|
+
// `listAssets` uses server-side (api.ts). The picker only ever surfaces paths
|
|
505
|
+
// it already listed via /_api/assets, so every path resolves to one of these;
|
|
506
|
+
// `null` (unrecognized) is treated as "skip" by the caller.
|
|
507
|
+
const ANNOTATION_IMAGE_EXT = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp', 'avif', 'svg']);
|
|
508
|
+
const ANNOTATION_VIDEO_EXT = new Set(['mp4', 'webm', 'mov', 'm4v', 'ogg']);
|
|
509
|
+
const ANNOTATION_AUDIO_EXT = new Set(['mp3', 'wav', 'm4a', 'aac', 'flac', 'oga', 'opus']);
|
|
510
|
+
function classifyAssetPathKind(path: string): 'image' | 'video' | 'audio' | null {
|
|
511
|
+
const ext = (path.split('.').pop() || '').toLowerCase();
|
|
512
|
+
if (ANNOTATION_IMAGE_EXT.has(ext)) return 'image';
|
|
513
|
+
if (ANNOTATION_VIDEO_EXT.has(ext)) return 'video';
|
|
514
|
+
if (ANNOTATION_AUDIO_EXT.has(ext)) return 'audio';
|
|
515
|
+
return null;
|
|
516
|
+
}
|
|
517
|
+
|
|
399
518
|
/**
|
|
400
519
|
* DDR-150 dogfood #8 — true when a pointer/click event targets the inline
|
|
401
520
|
* media player inside a mediaref chip. Every document-capture annotation
|
|
@@ -935,6 +1054,43 @@ export function AnnotationsLayer() {
|
|
|
935
1054
|
*/
|
|
936
1055
|
const strokesRef = useRef<Stroke[]>(strokes);
|
|
937
1056
|
strokesRef.current = strokes;
|
|
1057
|
+
// Phase 23 batch-drop fix — see media-commit-chain.ts. Every async media
|
|
1058
|
+
// completion (image upload swap, video/audio upload commit) that can be
|
|
1059
|
+
// triggered concurrently (N Finder files dropped at once) enqueues onto
|
|
1060
|
+
// this instead of calling commitStrokes directly off a `strokesRef.current`
|
|
1061
|
+
// read, so two completions landing before a render commit don't clobber
|
|
1062
|
+
// each other.
|
|
1063
|
+
const mediaCommitChainRef = useRef(
|
|
1064
|
+
createMediaCommitChain<Stroke>(
|
|
1065
|
+
() => strokesRef.current,
|
|
1066
|
+
(s) => s.id
|
|
1067
|
+
)
|
|
1068
|
+
);
|
|
1069
|
+
// Live-security-review finding (feature-bulk-media-insert follow-up) —
|
|
1070
|
+
// `createImageFromFile`'s upload-completion swap folds its optimistic
|
|
1071
|
+
// stroke back in whenever the chain's `before` doesn't have it yet,
|
|
1072
|
+
// reasoning "render lag, not a delete" (see the comment there). That
|
|
1073
|
+
// reasoning has no way to tell a genuine delete apart from render lag by
|
|
1074
|
+
// array membership alone — if the user drops an image then hits
|
|
1075
|
+
// Backspace before the upload resolves, the id is legitimately gone, and
|
|
1076
|
+
// the old code resurrected it anyway once the upload landed (and synced
|
|
1077
|
+
// the resurrection to every collab peer). Tracked explicitly here instead
|
|
1078
|
+
// of inferred: `deleteStrokes` records every id it removes; the swap
|
|
1079
|
+
// checks (and consumes) this before assuming absence means lag. Bounded
|
|
1080
|
+
// like `recentSelfSvgsRef` below — only ever holds ids a delete has
|
|
1081
|
+
// touched, which is small in practice, but capped for safety.
|
|
1082
|
+
const DELETED_STROKE_IDS_CAP = 128;
|
|
1083
|
+
const deletedStrokeIdsRef = useRef<Set<string>>(new Set());
|
|
1084
|
+
const rememberDeletedIds = useCallback((ids: readonly string[]) => {
|
|
1085
|
+
const set = deletedStrokeIdsRef.current;
|
|
1086
|
+
for (const id of ids) {
|
|
1087
|
+
set.add(id);
|
|
1088
|
+
if (set.size > DELETED_STROKE_IDS_CAP) {
|
|
1089
|
+
const oldest = set.values().next().value;
|
|
1090
|
+
if (oldest !== undefined) set.delete(oldest);
|
|
1091
|
+
}
|
|
1092
|
+
}
|
|
1093
|
+
}, []);
|
|
938
1094
|
|
|
939
1095
|
const isDraw =
|
|
940
1096
|
tool === 'pen' ||
|
|
@@ -961,10 +1117,28 @@ export function AnnotationsLayer() {
|
|
|
961
1117
|
}, [ghostCapable, visible]);
|
|
962
1118
|
|
|
963
1119
|
// Load existing annotations on mount.
|
|
964
|
-
// Phase 8 Task 5 —
|
|
965
|
-
//
|
|
966
|
-
//
|
|
967
|
-
|
|
1120
|
+
// Self-echo suppression (Phase 8 Task 5, hardened — feature-bulk-media-
|
|
1121
|
+
// insert follow-up). A single "last applied" string only catches the MOST
|
|
1122
|
+
// RECENT self-write: when the chain fires several rapid commits (a batch
|
|
1123
|
+
// drop), the server's broadcast of an EARLIER commit can arrive after a
|
|
1124
|
+
// LATER local commit has already moved "last applied" on — so the earlier
|
|
1125
|
+
// echo no longer matches, gets misread as a foreign change, and rolls
|
|
1126
|
+
// local state BACK to that stale snapshot. This is the confirmed cause of
|
|
1127
|
+
// images silently vanishing after a multi-file drop (live-tested: 12
|
|
1128
|
+
// mismatched/misapplied echoes correlated exactly with lost strokes across
|
|
1129
|
+
// a 30-batch stress run). Track a bounded HISTORY of our own recent writes
|
|
1130
|
+
// instead of just the latest one, so an out-of-order echo of any recent
|
|
1131
|
+
// self-write is still recognized and suppressed.
|
|
1132
|
+
const RECENT_SELF_SVG_CAP = 64;
|
|
1133
|
+
const recentSelfSvgsRef = useRef<Set<string>>(new Set());
|
|
1134
|
+
const rememberSelfSvg = useCallback((svg: string) => {
|
|
1135
|
+
const set = recentSelfSvgsRef.current;
|
|
1136
|
+
set.add(svg);
|
|
1137
|
+
if (set.size > RECENT_SELF_SVG_CAP) {
|
|
1138
|
+
const oldest = set.values().next().value;
|
|
1139
|
+
if (oldest !== undefined) set.delete(oldest);
|
|
1140
|
+
}
|
|
1141
|
+
}, []);
|
|
968
1142
|
useEffect(() => {
|
|
969
1143
|
const file = deriveFile();
|
|
970
1144
|
fileRef.current = file;
|
|
@@ -979,7 +1153,7 @@ export function AnnotationsLayer() {
|
|
|
979
1153
|
const loaded = svgToStrokes(text);
|
|
980
1154
|
if (loaded.length) {
|
|
981
1155
|
setStrokesState(loaded);
|
|
982
|
-
|
|
1156
|
+
rememberSelfSvg(text);
|
|
983
1157
|
}
|
|
984
1158
|
})
|
|
985
1159
|
.catch(() => {
|
|
@@ -988,14 +1162,15 @@ export function AnnotationsLayer() {
|
|
|
988
1162
|
return () => {
|
|
989
1163
|
cancelled = true;
|
|
990
1164
|
};
|
|
991
|
-
}, []);
|
|
1165
|
+
}, [rememberSelfSvg]);
|
|
992
1166
|
|
|
993
1167
|
// Phase 8 Task 5 — observe the Y.Map.annotations for live updates from
|
|
994
|
-
// other tabs. Bail when the incoming SVG STRING
|
|
995
|
-
//
|
|
996
|
-
// foreign changes). The prior
|
|
997
|
-
// resize / move keeps the same id
|
|
998
|
-
// even though geometry changed —
|
|
1168
|
+
// other tabs. Bail when the incoming SVG STRING matches any RECENT
|
|
1169
|
+
// self-write (covers the local echo round-trip, including an
|
|
1170
|
+
// out-of-order one, without missing real foreign changes). The prior
|
|
1171
|
+
// length+first/last-id check was wrong: a resize / move keeps the same id
|
|
1172
|
+
// list, so all three predicates matched even though geometry changed —
|
|
1173
|
+
// foreign edits silently disappeared.
|
|
999
1174
|
const collab = useCollab();
|
|
1000
1175
|
useEffect(() => {
|
|
1001
1176
|
if (!collab) return;
|
|
@@ -1003,9 +1178,10 @@ export function AnnotationsLayer() {
|
|
|
1003
1178
|
const apply = () => {
|
|
1004
1179
|
const svg = map.get('svg');
|
|
1005
1180
|
if (typeof svg !== 'string' || !svg) return;
|
|
1006
|
-
if (
|
|
1007
|
-
|
|
1008
|
-
|
|
1181
|
+
if (recentSelfSvgsRef.current.has(svg)) return;
|
|
1182
|
+
rememberSelfSvg(svg);
|
|
1183
|
+
const incoming = svgToStrokes(svg);
|
|
1184
|
+
setStrokesState((prev) => reconcileForeignEcho(prev, incoming));
|
|
1009
1185
|
};
|
|
1010
1186
|
apply();
|
|
1011
1187
|
map.observe(apply);
|
|
@@ -1016,13 +1192,29 @@ export function AnnotationsLayer() {
|
|
|
1016
1192
|
/* doc destroyed before unmount */
|
|
1017
1193
|
}
|
|
1018
1194
|
};
|
|
1019
|
-
}, [collab]);
|
|
1195
|
+
}, [collab, rememberSelfSvg]);
|
|
1020
1196
|
|
|
1021
1197
|
const undoStack = useUndoStackOptional();
|
|
1022
1198
|
const undoSinks = useUndoSinks();
|
|
1023
1199
|
const undoStackRef = useRef(undoStack);
|
|
1024
1200
|
undoStackRef.current = undoStack;
|
|
1025
1201
|
|
|
1202
|
+
// feature-bulk-media-insert follow-up — dedicated PUT dispatch queue.
|
|
1203
|
+
// The undo-stack's own `inFlightRef` serializes its PUSH TASKS (each
|
|
1204
|
+
// awaits `cmd.do()` before the next task starts), but live network
|
|
1205
|
+
// capture during a rapid-fire batch drop showed the actual PUT REQUESTS
|
|
1206
|
+
// still overlapping (request N+1 starting before request N's response
|
|
1207
|
+
// arrived) — some other async hop between "task starts" and "fetch
|
|
1208
|
+
// fires" lets them interleave. Since every PUT body here is a strictly
|
|
1209
|
+
// growing superset (the media-commit-chain guarantees each commit is at
|
|
1210
|
+
// least as complete as the last), overlap is dangerous: if an EARLIER
|
|
1211
|
+
// (smaller) request's write lands on disk AFTER a LATER (larger) one's,
|
|
1212
|
+
// the smaller snapshot wins and silently erases the larger one's extra
|
|
1213
|
+
// strokes. A dedicated chain — mirroring `editApplyChainRef` in
|
|
1214
|
+
// client/app.jsx — makes the fetch DISPATCH itself wait for the
|
|
1215
|
+
// previous one's response, independent of whatever the undo-stack does.
|
|
1216
|
+
const putChainRef = useRef<Promise<void>>(Promise.resolve());
|
|
1217
|
+
|
|
1026
1218
|
/**
|
|
1027
1219
|
* Apply a `Stroke[]` snapshot: update local React state AND fire-and-forget
|
|
1028
1220
|
* PUT to the server. Used as the `putFn` injected into the
|
|
@@ -1036,25 +1228,40 @@ export function AnnotationsLayer() {
|
|
|
1036
1228
|
* we push a command, so the server only sees one PUT per edit instead
|
|
1037
1229
|
* of two-step racing.
|
|
1038
1230
|
*/
|
|
1039
|
-
const putStrokes = useCallback(
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1231
|
+
const putStrokes = useCallback(
|
|
1232
|
+
(next: readonly Stroke[], before: readonly Stroke[]) => {
|
|
1233
|
+
// See reconcileCommit — a direct setStrokesState(next) here can
|
|
1234
|
+
// clobber a sibling file's concurrent optimistic insert; folding
|
|
1235
|
+
// blindly against `prev` (no baseline) can just as easily revert this
|
|
1236
|
+
// very mutation's own delete. `before` (this command's own baseline)
|
|
1237
|
+
// disambiguates the two.
|
|
1238
|
+
setStrokesState((prev) => reconcileCommit(prev, before, next));
|
|
1239
|
+
const file = fileRef.current;
|
|
1240
|
+
if (!file) return Promise.resolve();
|
|
1241
|
+
const persistable = next.some(isEphemeralHref)
|
|
1242
|
+
? next.filter((s) => !isEphemeralHref(s))
|
|
1243
|
+
: next;
|
|
1244
|
+
const svg = strokesToSvg(persistable);
|
|
1245
|
+
// Phase 8 Task 5 — record the SVG we just authored locally so the
|
|
1246
|
+
// server-broadcast echo (PUT → onAnnotationsChanged → syncRoom* →
|
|
1247
|
+
// Y.Map.observe) doesn't trigger a redundant setStrokesState.
|
|
1248
|
+
rememberSelfSvg(svg);
|
|
1249
|
+
const dispatch = () =>
|
|
1250
|
+
fetch('/_api/annotations', {
|
|
1251
|
+
method: 'PUT',
|
|
1252
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1253
|
+
body: JSON.stringify({ file, svg }),
|
|
1254
|
+
})
|
|
1255
|
+
.then(() => undefined)
|
|
1256
|
+
.catch(() => {
|
|
1257
|
+
/* swallow — user sees uncommitted state until the next stroke */
|
|
1258
|
+
});
|
|
1259
|
+
const chained = putChainRef.current.then(dispatch, dispatch);
|
|
1260
|
+
putChainRef.current = chained;
|
|
1261
|
+
return chained;
|
|
1262
|
+
},
|
|
1263
|
+
[rememberSelfSvg]
|
|
1264
|
+
);
|
|
1058
1265
|
|
|
1059
1266
|
// Register the strokes put sink with the undo provider so the rebuilt
|
|
1060
1267
|
// AnnotationStrokesCommand (after a canvas switch + return) routes through
|
|
@@ -1113,6 +1320,12 @@ export function AnnotationsLayer() {
|
|
|
1113
1320
|
(s) => !set.has(s.id) && !(s.tool === 'text' && s.anchorId != null && set.has(s.anchorId))
|
|
1114
1321
|
);
|
|
1115
1322
|
if (filtered.length === prev.length) return;
|
|
1323
|
+
// Record every id actually removed (not just the requested `ids` —
|
|
1324
|
+
// bound text anchors get swept too) so an in-flight image upload's
|
|
1325
|
+
// swap (see deletedStrokeIdsRef above) knows this id is a genuine
|
|
1326
|
+
// delete, not render lag, if it resolves after this point.
|
|
1327
|
+
const filteredIds = new Set(filtered.map((s) => s.id));
|
|
1328
|
+
rememberDeletedIds(prev.filter((s) => !filteredIds.has(s.id)).map((s) => s.id));
|
|
1116
1329
|
// FigJam v3 — deleting a bind host strips the bind (endpoint frozen,
|
|
1117
1330
|
// arrow survives); singleton/empty groups dissolve (tldraw lifecycle).
|
|
1118
1331
|
commitStrokes(prev, recomputeBoundArrows(normalizeGroups(filtered)));
|
|
@@ -1220,7 +1433,100 @@ export function AnnotationsLayer() {
|
|
|
1220
1433
|
previewStroke,
|
|
1221
1434
|
commitGesture,
|
|
1222
1435
|
};
|
|
1223
|
-
}, [strokes, setStrokes, commitStrokes]);
|
|
1436
|
+
}, [strokes, setStrokes, commitStrokes, rememberDeletedIds]);
|
|
1437
|
+
|
|
1438
|
+
// feature-bulk-media-insert Task 11 — the picker's "Add as annotation"
|
|
1439
|
+
// confirm posts N already-uploaded asset paths in ONE message; unlike
|
|
1440
|
+
// createImageFromFile/createMediaReference (which each race an independent
|
|
1441
|
+
// upload), every path here is already on disk, so this resolves every
|
|
1442
|
+
// image's natural size up front (Promise.all) and performs exactly ONE
|
|
1443
|
+
// commitStrokes for the whole batch — correct by construction, no chain
|
|
1444
|
+
// needed since there's no per-item async race once all N are resolved.
|
|
1445
|
+
const createMediaFromAssetPaths = useCallback(
|
|
1446
|
+
(paths: readonly string[], world: [number, number]) => {
|
|
1447
|
+
if (typeof window === 'undefined' || !paths.length) return;
|
|
1448
|
+
const classified = paths
|
|
1449
|
+
.map((path) => ({ path, kind: classifyAssetPathKind(path) }))
|
|
1450
|
+
.filter((it): it is { path: string; kind: 'image' | 'video' | 'audio' } => it.kind != null);
|
|
1451
|
+
if (!classified.length) return;
|
|
1452
|
+
|
|
1453
|
+
type Resolved = { path: string; kind: 'image' | 'video' | 'audio'; w: number; h: number };
|
|
1454
|
+
const probeOne = (path: string, kind: 'image' | 'video' | 'audio'): Promise<Resolved> => {
|
|
1455
|
+
if (kind !== 'image') {
|
|
1456
|
+
const h = kind === 'video' ? MEDIAREF_VIDEO_H : MEDIAREF_DEFAULT_H;
|
|
1457
|
+
return Promise.resolve({ path, kind, w: MEDIAREF_DEFAULT_W, h });
|
|
1458
|
+
}
|
|
1459
|
+
return new Promise<Resolved>((resolve) => {
|
|
1460
|
+
const probe = new Image();
|
|
1461
|
+
probe.onload = () => {
|
|
1462
|
+
const natW = probe.naturalWidth || IMAGE_MAX_DROP_SIDE;
|
|
1463
|
+
const natH = probe.naturalHeight || Math.round(IMAGE_MAX_DROP_SIDE * 0.66);
|
|
1464
|
+
const longest = Math.max(natW, natH) || 1;
|
|
1465
|
+
const scale = longest > IMAGE_MAX_DROP_SIDE ? IMAGE_MAX_DROP_SIDE / longest : 1;
|
|
1466
|
+
resolve({
|
|
1467
|
+
path,
|
|
1468
|
+
kind: 'image',
|
|
1469
|
+
w: Math.max(IMAGE_MIN_SIZE, Math.round(natW * scale)),
|
|
1470
|
+
h: Math.max(IMAGE_MIN_SIZE, Math.round(natH * scale)),
|
|
1471
|
+
});
|
|
1472
|
+
};
|
|
1473
|
+
// Couldn't decode — fall back to a small square rather than
|
|
1474
|
+
// dropping this item from the batch (the file is already on disk).
|
|
1475
|
+
probe.onerror = () => resolve({ path, kind: 'image', w: 64, h: 64 });
|
|
1476
|
+
probe.src = resolveAssetHref(path);
|
|
1477
|
+
});
|
|
1478
|
+
};
|
|
1479
|
+
|
|
1480
|
+
void Promise.all(classified.map(({ path, kind }) => probeOne(path, kind))).then(
|
|
1481
|
+
(resolved) => {
|
|
1482
|
+
// Ids generated up front (stable regardless of chain timing) so
|
|
1483
|
+
// they're available for the selection call below without waiting
|
|
1484
|
+
// on the chain to settle.
|
|
1485
|
+
const withIds = resolved.map((item, i) => ({ ...item, id: rid(), i }));
|
|
1486
|
+
const label = `add ${withIds.length} item${withIds.length === 1 ? '' : 's'}`;
|
|
1487
|
+
// Batch-drop fix (see media-commit-chain.ts) — every path here is
|
|
1488
|
+
// already resolved, so THIS call alone is race-free by
|
|
1489
|
+
// construction, but a SECOND overlapping bulk-insert (or a
|
|
1490
|
+
// concurrent drag-drop via createImageFromFile/createMediaReference)
|
|
1491
|
+
// can still land while this one's Promise.all was in flight (large
|
|
1492
|
+
// photos take real, non-trivial decode time) — both read/write the
|
|
1493
|
+
// same `strokes` state, so this must enqueue onto the shared chain
|
|
1494
|
+
// too, not read `strokesRef.current` + commit directly.
|
|
1495
|
+
void mediaCommitChainRef.current.enqueue((before) => {
|
|
1496
|
+
const added: Stroke[] = withIds.map((item) => {
|
|
1497
|
+
const cx = world[0] + item.i * BATCH_DROP_CASCADE_PX;
|
|
1498
|
+
const cy = world[1] + item.i * BATCH_DROP_CASCADE_PX;
|
|
1499
|
+
if (item.kind === 'image') {
|
|
1500
|
+
return {
|
|
1501
|
+
id: item.id,
|
|
1502
|
+
tool: 'image',
|
|
1503
|
+
x: cx - item.w / 2,
|
|
1504
|
+
y: cy - item.h / 2,
|
|
1505
|
+
w: item.w,
|
|
1506
|
+
h: item.h,
|
|
1507
|
+
href: item.path,
|
|
1508
|
+
} as ImageStroke;
|
|
1509
|
+
}
|
|
1510
|
+
return {
|
|
1511
|
+
id: item.id,
|
|
1512
|
+
tool: 'mediaref',
|
|
1513
|
+
x: cx - item.w / 2,
|
|
1514
|
+
y: cy - item.h / 2,
|
|
1515
|
+
w: item.w,
|
|
1516
|
+
h: item.h,
|
|
1517
|
+
src: item.path,
|
|
1518
|
+
mediaKind: item.kind,
|
|
1519
|
+
title: item.path.split('/').pop() || item.path,
|
|
1520
|
+
} as MediaRefStroke;
|
|
1521
|
+
});
|
|
1522
|
+
return { after: [...before, ...added], label };
|
|
1523
|
+
}, commitStrokes);
|
|
1524
|
+
annotSel?.replace(withIds.map((item) => item.id));
|
|
1525
|
+
}
|
|
1526
|
+
);
|
|
1527
|
+
},
|
|
1528
|
+
[commitStrokes, annotSel]
|
|
1529
|
+
);
|
|
1224
1530
|
|
|
1225
1531
|
// Menubar bridge (Phase 5.1 Task 10) — listen for postMessages from the
|
|
1226
1532
|
// dev-server shell. `selection-clear` + `tool-set` live in canvas-shell
|
|
@@ -1297,10 +1603,29 @@ export function AnnotationsLayer() {
|
|
|
1297
1603
|
commitStrokes(before, [...before, stroke], 'add sticker');
|
|
1298
1604
|
annotSel?.replace([id]);
|
|
1299
1605
|
}
|
|
1606
|
+
// feature-bulk-media-insert Task 11 — the shell's AssetPicker (main-
|
|
1607
|
+
// origin) multi-select "Add as annotation" confirm. Every path is
|
|
1608
|
+
// already an uploaded assets/… path (no upload step here); same
|
|
1609
|
+
// viewport-center placement as insert-sticker above (no cross-origin
|
|
1610
|
+
// cursor position to reuse).
|
|
1611
|
+
if (m.dgn === 'insert-annotation-media' && Array.isArray((m as { paths?: unknown }).paths)) {
|
|
1612
|
+
const paths = (m as { paths: unknown[] }).paths.filter(
|
|
1613
|
+
(p): p is string => typeof p === 'string'
|
|
1614
|
+
);
|
|
1615
|
+
if (paths.length) {
|
|
1616
|
+
const v = vpRef.current ?? { x: 0, y: 0, zoom: 1 };
|
|
1617
|
+
const z = v.zoom || 1;
|
|
1618
|
+
const cx = typeof window !== 'undefined' ? window.innerWidth / 2 : 0;
|
|
1619
|
+
const cy = typeof window !== 'undefined' ? window.innerHeight / 2 : 0;
|
|
1620
|
+
const wx = (cx - v.x) / z;
|
|
1621
|
+
const wy = (cy - v.y) / z;
|
|
1622
|
+
createMediaFromAssetPaths(paths, [wx, wy]);
|
|
1623
|
+
}
|
|
1624
|
+
}
|
|
1300
1625
|
};
|
|
1301
1626
|
window.addEventListener('message', onMessage);
|
|
1302
1627
|
return () => window.removeEventListener('message', onMessage);
|
|
1303
|
-
}, [annotSel, strokes, setVisible, commitStrokes]);
|
|
1628
|
+
}, [annotSel, strokes, setVisible, commitStrokes, createMediaFromAssetPaths]);
|
|
1304
1629
|
|
|
1305
1630
|
// Document-level toggle: Shift+P (presentation). Annotation-shortcut help is
|
|
1306
1631
|
// owned by the dev-server menubar (Help button); we no longer ship an
|
|
@@ -1355,17 +1680,35 @@ export function AnnotationsLayer() {
|
|
|
1355
1680
|
};
|
|
1356
1681
|
// Local-only insert — the blob: href must NOT reach the server (it's
|
|
1357
1682
|
// ephemeral + would be stripped by the sanitizer); we commit only the
|
|
1358
|
-
// assets/… form once the upload lands.
|
|
1359
|
-
|
|
1683
|
+
// assets/… form once the upload lands. Functional updater — a batch
|
|
1684
|
+
// drop fires this for every file independently, so two optimistic
|
|
1685
|
+
// inserts landing before a render commit must compose against each
|
|
1686
|
+
// other rather than each overwriting the other's array snapshot.
|
|
1687
|
+
setStrokesState((prev) => [...prev, optimistic]);
|
|
1360
1688
|
void uploadAsset(file).then((res) => {
|
|
1361
|
-
const cur = strokesRef.current;
|
|
1362
1689
|
if ('path' in res) {
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1690
|
+
// Batch-drop fix (see media-commit-chain.ts) — N concurrent
|
|
1691
|
+
// uploads resolve at unpredictable relative speed; enqueue the
|
|
1692
|
+
// swap instead of reading strokesRef.current directly, so this
|
|
1693
|
+
// completion always builds on the latest accumulated state
|
|
1694
|
+
// rather than a stale pre-render snapshot another completion
|
|
1695
|
+
// already moved past.
|
|
1696
|
+
void mediaCommitChainRef.current
|
|
1697
|
+
.enqueue((before) => {
|
|
1698
|
+
const wasDeleted = deletedStrokeIdsRef.current.has(id);
|
|
1699
|
+
if (wasDeleted) deletedStrokeIdsRef.current.delete(id);
|
|
1700
|
+
return resolveImageUploadSwap(before, id, optimistic, res.path, wasDeleted);
|
|
1701
|
+
}, commitStrokes)
|
|
1702
|
+
.then((after) => {
|
|
1703
|
+
if (after.some((s) => s.id === id)) annotSel?.replace([id]);
|
|
1704
|
+
});
|
|
1367
1705
|
} else {
|
|
1368
|
-
|
|
1706
|
+
// Failure cleanup — no undo record (the optimistic stroke was
|
|
1707
|
+
// never committed), so a plain functional updater (not the
|
|
1708
|
+
// chain) is enough: it still composes correctly against any
|
|
1709
|
+
// chain-driven setStrokesState queued in the same batch.
|
|
1710
|
+
setStrokesState((prev) => prev.filter((s) => s.id !== id));
|
|
1711
|
+
deletedStrokeIdsRef.current.delete(id); // never reached the chain — nothing to consume there
|
|
1369
1712
|
showCanvasToast(`Image upload failed — ${res.error}`);
|
|
1370
1713
|
}
|
|
1371
1714
|
URL.revokeObjectURL(blobUrl);
|
|
@@ -1433,8 +1776,15 @@ export function AnnotationsLayer() {
|
|
|
1433
1776
|
mediaKind,
|
|
1434
1777
|
title: (file.name || res.path).slice(0, 300),
|
|
1435
1778
|
};
|
|
1436
|
-
|
|
1437
|
-
|
|
1779
|
+
// Batch-drop fix (see media-commit-chain.ts) — same accumulator
|
|
1780
|
+
// chain as createImageFromFile's swap, so a video/audio upload
|
|
1781
|
+
// resolving interleaved with concurrent image uploads (or other
|
|
1782
|
+
// media uploads) in the same batch never commits against a stale
|
|
1783
|
+
// pre-render snapshot.
|
|
1784
|
+
void mediaCommitChainRef.current.enqueue(
|
|
1785
|
+
(before) => ({ after: [...before, ref], label: `add ${mediaKind} reference` }),
|
|
1786
|
+
commitStrokes
|
|
1787
|
+
);
|
|
1438
1788
|
annotSel?.replace([id]);
|
|
1439
1789
|
const sizeMb = file.size / (1024 * 1024);
|
|
1440
1790
|
showCanvasToast(
|