@1agh/maude 1.3.0 → 1.4.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/annotation-edit-base.ts +36 -0
- package/apps/studio/annotations-layer.tsx +13 -2
- package/apps/studio/api.ts +51 -2
- package/apps/studio/canvas-build.ts +49 -8
- package/apps/studio/canvas-edit.ts +77 -0
- package/apps/studio/canvas-lib.tsx +25 -10
- package/apps/studio/canvas-list-watch.ts +33 -2
- package/apps/studio/client/app.jsx +99 -4
- package/apps/studio/client/canvas-url.js +39 -1
- package/apps/studio/client/panels/TimelinePanel.jsx +75 -62
- package/apps/studio/client/photo-knobs.jsx +3 -3
- package/apps/studio/client/styles/3-shell-maude.css +1 -0
- package/apps/studio/collab/awareness-bridge.ts +65 -14
- package/apps/studio/comments-overlay.tsx +86 -2
- package/apps/studio/context.ts +93 -1
- package/apps/studio/dist/client.bundle.js +619 -619
- package/apps/studio/dist/comment-mount.js +2 -2
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/git/accepted-guard.ts +36 -0
- package/apps/studio/hmr-broadcast.ts +34 -1
- package/apps/studio/http.ts +15 -1
- package/apps/studio/managed-projects.ts +22 -0
- package/apps/studio/server.ts +3 -0
- package/apps/studio/sync/accepted-link.ts +3 -0
- package/apps/studio/sync/file-plane.ts +15 -0
- package/apps/studio/sync/index.ts +138 -12
- package/apps/studio/sync/presentation.ts +30 -0
- package/apps/studio/sync/status.ts +5 -1
- package/apps/studio/sync/transaction-client.ts +29 -0
- package/apps/studio/use-tool-mode.tsx +44 -0
- package/apps/studio/whats-new.json +72 -0
- package/apps/studio/ws.ts +9 -2
- package/package.json +8 -8
- package/plugins/design/templates/_shell.html +48 -7
- package/plugins/flow/.claude-plugin/config.schema.json +15 -2
- package/plugins/flow/README.md +1 -1
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// Which SVG an annotation edit names as its base (DDR-241 accepted revisions).
|
|
2
|
+
//
|
|
3
|
+
// The project merges an annotation proposal element by element against the
|
|
4
|
+
// text it was derived from. The canvas used to send the CANONICAL
|
|
5
|
+
// re-serialization of the strokes it held — fine while the stored text is
|
|
6
|
+
// canonical too, and silently fatal when it is not: an element written by an
|
|
7
|
+
// older serializer, a hand edit or any other writer then looks changed on BOTH
|
|
8
|
+
// sides, the merge refuses it as a conflict, and the person's edit is reverted
|
|
9
|
+
// without a word (plan T31, L09 arrowheads on a non-canonical arrow).
|
|
10
|
+
//
|
|
11
|
+
// An edit made on exactly the state this canvas last RECEIVED names the text
|
|
12
|
+
// it received — the project then sees "nothing changed underneath", accepts
|
|
13
|
+
// the edit whole, and the stored text becomes canonical as a side effect. Any
|
|
14
|
+
// other state (edits stacked before the echo returns) is this canvas's own
|
|
15
|
+
// canonical output, which is what the project holds after accepting it.
|
|
16
|
+
|
|
17
|
+
import { type Stroke, strokesToSvg } from './annotations-model.ts';
|
|
18
|
+
|
|
19
|
+
export interface ReceivedAnnotations {
|
|
20
|
+
/** The SVG text exactly as the project delivered it. */
|
|
21
|
+
raw: string;
|
|
22
|
+
/** The same state as this client serializes it. */
|
|
23
|
+
canon: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function receivedAnnotations(raw: string, strokes: readonly Stroke[]): ReceivedAnnotations {
|
|
27
|
+
return { raw, canon: strokesToSvg(strokes) };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function annotationEditBase(
|
|
31
|
+
before: readonly Stroke[],
|
|
32
|
+
received: ReceivedAnnotations | null
|
|
33
|
+
): string {
|
|
34
|
+
const canon = strokesToSvg(before);
|
|
35
|
+
return received && received.canon === canon ? received.raw : canon;
|
|
36
|
+
}
|
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
import {
|
|
2
|
+
annotationEditBase,
|
|
3
|
+
type ReceivedAnnotations,
|
|
4
|
+
receivedAnnotations,
|
|
5
|
+
} from './annotation-edit-base.ts';
|
|
1
6
|
import { createAnnotationEchoGuard, observeAnnotationSnapshots } from './annotations-sync.ts';
|
|
2
7
|
/**
|
|
3
8
|
* @file annotations-layer.tsx — FigJam-style annotation overlay
|
|
@@ -1157,6 +1162,9 @@ export function AnnotationsLayer() {
|
|
|
1157
1162
|
// peer undo/delete can legitimately return to any previously rendered SVG.
|
|
1158
1163
|
const annotationEchoRef = useRef(createAnnotationEchoGuard());
|
|
1159
1164
|
const annotationsChangedRef = useRef(false);
|
|
1165
|
+
// The annotations as the project last delivered them — the base an edit on
|
|
1166
|
+
// exactly that state names (annotation-edit-base.ts).
|
|
1167
|
+
const receivedRef = useRef<ReceivedAnnotations | null>(null);
|
|
1160
1168
|
useEffect(() => {
|
|
1161
1169
|
const file = deriveFile();
|
|
1162
1170
|
fileRef.current = file;
|
|
@@ -1169,6 +1177,7 @@ export function AnnotationsLayer() {
|
|
|
1169
1177
|
.then((text) => {
|
|
1170
1178
|
if (cancelled || annotationsChangedRef.current) return;
|
|
1171
1179
|
const loaded = svgToStrokes(text);
|
|
1180
|
+
receivedRef.current = receivedAnnotations(text, loaded);
|
|
1172
1181
|
if (loaded.length) {
|
|
1173
1182
|
setStrokesState(loaded);
|
|
1174
1183
|
}
|
|
@@ -1186,8 +1195,9 @@ export function AnnotationsLayer() {
|
|
|
1186
1195
|
if (!collab) return;
|
|
1187
1196
|
return observeAnnotationSnapshots(collab.doc, (svg, writeId) => {
|
|
1188
1197
|
annotationsChangedRef.current = true;
|
|
1189
|
-
if (annotationEchoRef.current.isOwn(svg, writeId)) return;
|
|
1190
1198
|
const incoming = svgToStrokes(svg);
|
|
1199
|
+
receivedRef.current = receivedAnnotations(svg, incoming);
|
|
1200
|
+
if (annotationEchoRef.current.isOwn(svg, writeId)) return;
|
|
1191
1201
|
setStrokesState((prev) => reconcileForeignEcho(prev, incoming));
|
|
1192
1202
|
});
|
|
1193
1203
|
}, [collab]);
|
|
@@ -1243,7 +1253,7 @@ export function AnnotationsLayer() {
|
|
|
1243
1253
|
const persistableBefore = before.some(isEphemeralHref)
|
|
1244
1254
|
? before.filter((s) => !isEphemeralHref(s))
|
|
1245
1255
|
: before;
|
|
1246
|
-
const base =
|
|
1256
|
+
const base = annotationEditBase(persistableBefore, receivedRef.current);
|
|
1247
1257
|
// Phase 8 Task 5 — record the SVG we just authored locally so the
|
|
1248
1258
|
// server-broadcast echo (PUT → onAnnotationsChanged → syncRoom* →
|
|
1249
1259
|
// Y.Map.observe) doesn't trigger a redundant setStrokesState.
|
|
@@ -4984,6 +4994,7 @@ function AnnotationContextMenu({
|
|
|
4984
4994
|
<button
|
|
4985
4995
|
type="button"
|
|
4986
4996
|
role="menuitem"
|
|
4997
|
+
data-action={id}
|
|
4987
4998
|
disabled={opts?.disabled}
|
|
4988
4999
|
className={`dc-menu-item${opts?.destructive ? ' is-destructive' : ''}`}
|
|
4989
5000
|
onClick={() => {
|
package/apps/studio/api.ts
CHANGED
|
@@ -141,7 +141,7 @@ const HIDDEN_OK = new Set(['.ai', '.claude', '.design']);
|
|
|
141
141
|
// an explicit enumerated list rather than a broad pattern so it can never
|
|
142
142
|
// accidentally widen to swallow runtime JSON — findFiles already excludes
|
|
143
143
|
// `_`-prefixed entries before this list is even consulted.
|
|
144
|
-
const PREVIEW_ASSET_EXTS = [
|
|
144
|
+
export const PREVIEW_ASSET_EXTS = [
|
|
145
145
|
'.svg',
|
|
146
146
|
'.png',
|
|
147
147
|
'.jpg',
|
|
@@ -171,6 +171,30 @@ const PREVIEW_ASSET_EXTS = [
|
|
|
171
171
|
* (`system/<ds>/preview/*.html`) intentionally stay `.html` and travel via
|
|
172
172
|
* the DS-aware `findFiles()` path below.
|
|
173
173
|
*/
|
|
174
|
+
/**
|
|
175
|
+
* True when `abs` is a directory tree holding nothing but folder placeholders
|
|
176
|
+
* (`.gitkeep`, `.DS_Store`) — what a folder projection leaves before any
|
|
177
|
+
* content arrives. False when it is absent, is not a directory, or holds
|
|
178
|
+
* anything real (a symlink counts as real: never followed, never removed).
|
|
179
|
+
*/
|
|
180
|
+
async function onlyFolderPlaceholders(abs: string, depth = 0): Promise<boolean> {
|
|
181
|
+
if (depth > 16) return false;
|
|
182
|
+
let entries: Dirent[];
|
|
183
|
+
try {
|
|
184
|
+
entries = await readdir(abs, { withFileTypes: true });
|
|
185
|
+
} catch {
|
|
186
|
+
return false;
|
|
187
|
+
}
|
|
188
|
+
for (const e of entries) {
|
|
189
|
+
if (e.isDirectory()) {
|
|
190
|
+
if (!(await onlyFolderPlaceholders(path.join(abs, e.name), depth + 1))) return false;
|
|
191
|
+
} else if (!(e.isFile() && (e.name === '.gitkeep' || e.name === '.DS_Store'))) {
|
|
192
|
+
return false;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
return true;
|
|
196
|
+
}
|
|
197
|
+
|
|
174
198
|
export async function findHtmlFiles(absRoot: string, prefixUnderRepo: string): Promise<string[]> {
|
|
175
199
|
const out: string[] = [];
|
|
176
200
|
let entries: Dirent[];
|
|
@@ -203,7 +227,7 @@ export async function findHtmlFiles(absRoot: string, prefixUnderRepo: string): P
|
|
|
203
227
|
* traversal instead of adding a second full walk of `system/` (the largest
|
|
204
228
|
* group) just to enumerate directories.
|
|
205
229
|
*/
|
|
206
|
-
async function findFiles(
|
|
230
|
+
export async function findFiles(
|
|
207
231
|
absRoot: string,
|
|
208
232
|
prefix: string,
|
|
209
233
|
exts: string[],
|
|
@@ -3214,6 +3238,11 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
3214
3238
|
};
|
|
3215
3239
|
}
|
|
3216
3240
|
await rename(g.abs, toAbs);
|
|
3241
|
+
// A hub's own studio has no watcher to see this rename: announce both
|
|
3242
|
+
// ends, or the journal learns of it only from the walk-import belt and a
|
|
3243
|
+
// teammate waits minutes for a rename that took a second here.
|
|
3244
|
+
announceWritten(rel);
|
|
3245
|
+
announceWritten(toRel);
|
|
3217
3246
|
ctx.bus.emit('canvas-list-update', {
|
|
3218
3247
|
action: 'moved',
|
|
3219
3248
|
rel: toRel,
|
|
@@ -3248,6 +3277,7 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
3248
3277
|
await mkdir(trashDir, { recursive: true });
|
|
3249
3278
|
const trashed = path.join(trashDir, path.posix.basename(rel));
|
|
3250
3279
|
await rename(g.abs, trashed);
|
|
3280
|
+
announceWritten(rel);
|
|
3251
3281
|
ctx.bus.emit('canvas-list-update', { action: 'removed', rel, slug });
|
|
3252
3282
|
return {
|
|
3253
3283
|
ok: true,
|
|
@@ -3903,6 +3933,17 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
3903
3933
|
}
|
|
3904
3934
|
|
|
3905
3935
|
await mkdir(toDirAbs, { recursive: true });
|
|
3936
|
+
// OUR OWN ACTION MAY ALREADY BE ON THIS DISK. The destination did not exist
|
|
3937
|
+
// when this move was checked; the project accepted the move since, and its
|
|
3938
|
+
// announcement can reach this machine's sync before the answer reaches this
|
|
3939
|
+
// request — which materializes the destination folders (each with its
|
|
3940
|
+
// `.gitkeep`). Renaming onto that non-empty placeholder failed (ENOTEMPTY),
|
|
3941
|
+
// leaving the old folder in this person's tree while every teammate had
|
|
3942
|
+
// the move (plan T31/L02). A destination holding nothing but placeholders
|
|
3943
|
+
// is that projection; it is replaced, never merged into.
|
|
3944
|
+
if (await onlyFolderPlaceholders(toDirFinalAbs)) {
|
|
3945
|
+
await rm(toDirFinalAbs, { recursive: true, force: true });
|
|
3946
|
+
}
|
|
3906
3947
|
try {
|
|
3907
3948
|
await rename(dirAbs, toDirFinalAbs);
|
|
3908
3949
|
} catch (err) {
|
|
@@ -3925,6 +3966,14 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
3925
3966
|
);
|
|
3926
3967
|
for (const artifact of canvasArtifacts({ rel: fromRel, paths })) {
|
|
3927
3968
|
if (artifact.kind !== 'slug-keyed') continue; // primary/siblings already moved with the dir
|
|
3969
|
+
if (artifact.carryOnMove === false) {
|
|
3970
|
+
// The old slug's CRDT cache, stamped "moved away" — the same drop as
|
|
3971
|
+
// moveCanvas; carried, the new document opens retired.
|
|
3972
|
+
await rm(artifact.abs, { force: true }).catch((err) => {
|
|
3973
|
+
console.warn(`[move] could not drop the stale doc cache: ${(err as Error).message}`);
|
|
3974
|
+
});
|
|
3975
|
+
continue;
|
|
3976
|
+
}
|
|
3928
3977
|
const dest = relocatedName(artifact, fromRel, toRel, paths);
|
|
3929
3978
|
if (path.resolve(dest) === path.resolve(artifact.abs)) continue;
|
|
3930
3979
|
try {
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
// serves the resulting JS at /<designRel>/ui/<slug>.tsx with Content-Type
|
|
23
23
|
// `application/javascript`.
|
|
24
24
|
|
|
25
|
-
import { existsSync } from 'node:fs';
|
|
25
|
+
import { existsSync, realpathSync } from 'node:fs';
|
|
26
26
|
import path from 'node:path';
|
|
27
27
|
|
|
28
28
|
import { canvasLibPath, canvasLibResolver } from './canvas-lib-resolver.ts';
|
|
@@ -129,6 +129,10 @@ export async function buildCanvasModule(
|
|
|
129
129
|
// needed; RUNTIME_PACKAGES already lists every specifier the importmap covers.
|
|
130
130
|
const externalSpecifiers = new Set<string>(RUNTIME_PACKAGES);
|
|
131
131
|
const denials: Array<{ specifier: string; reason: string }> = [];
|
|
132
|
+
// Every stylesheet the canvas pulls in by relative import — they are inlined
|
|
133
|
+
// (below), so the open canvas has no <link> to swap when one changes; the
|
|
134
|
+
// injector records them for the iframe's HMR client (see buildCssInjector).
|
|
135
|
+
const cssSources = new Set<string>();
|
|
132
136
|
|
|
133
137
|
const built = await Bun.build({
|
|
134
138
|
entrypoints: [canvasAbsPath],
|
|
@@ -151,6 +155,17 @@ export async function buildCanvasModule(
|
|
|
151
155
|
'process.env.NODE_ENV': '"production"',
|
|
152
156
|
},
|
|
153
157
|
plugins: [
|
|
158
|
+
{
|
|
159
|
+
name: 'css-sources',
|
|
160
|
+
setup(builder) {
|
|
161
|
+
builder.onResolve({ filter: /\.css$/ }, (args: { path: string; importer: string }) => {
|
|
162
|
+
if (args.path.startsWith('.') && args.importer) {
|
|
163
|
+
cssSources.add(path.resolve(path.dirname(args.importer), args.path));
|
|
164
|
+
}
|
|
165
|
+
return null; // record only — resolution continues as before
|
|
166
|
+
});
|
|
167
|
+
},
|
|
168
|
+
},
|
|
154
169
|
// Resolve `@maude/canvas-lib` BEFORE exact-externals — we want the bare
|
|
155
170
|
// specifier to map to the dev-server-bundled lib, not get marked external.
|
|
156
171
|
canvasLibResolver(),
|
|
@@ -217,7 +232,26 @@ export async function buildCanvasModule(
|
|
|
217
232
|
for (const a of cssAssets) css += await a.text();
|
|
218
233
|
if (css.trim().length > 0) {
|
|
219
234
|
const slug = canvasAbsPath.split('/').pop() ?? 'canvas';
|
|
220
|
-
|
|
235
|
+
const root = options.designRoot ?? options.restrictImportsTo;
|
|
236
|
+
// Real paths on both sides: the bundler reports importers by their REAL
|
|
237
|
+
// path, and a design root reached through a symlink (macOS `/var` →
|
|
238
|
+
// `/private/var`, where every temp dir lives) made each source look like
|
|
239
|
+
// `../../private/…` — filtered out, so nothing was ever named and the
|
|
240
|
+
// open canvas never restyled.
|
|
241
|
+
const real = (p: string) => {
|
|
242
|
+
try {
|
|
243
|
+
return realpathSync(p);
|
|
244
|
+
} catch {
|
|
245
|
+
return p;
|
|
246
|
+
}
|
|
247
|
+
};
|
|
248
|
+
const realRoot = root ? real(root) : null;
|
|
249
|
+
const sources = realRoot
|
|
250
|
+
? [...cssSources]
|
|
251
|
+
.map((abs) => path.relative(realRoot, real(abs)).split(path.sep).join('/'))
|
|
252
|
+
.filter((rel) => rel && !rel.startsWith('..'))
|
|
253
|
+
: [];
|
|
254
|
+
js = buildCssInjector(slug, css, sources) + js;
|
|
221
255
|
}
|
|
222
256
|
}
|
|
223
257
|
|
|
@@ -227,16 +261,23 @@ export async function buildCanvasModule(
|
|
|
227
261
|
|
|
228
262
|
/**
|
|
229
263
|
* Synthesize a module-init prologue that creates a `<style data-canvas-css>`
|
|
230
|
-
* tag with the bundled CSS text.
|
|
231
|
-
*
|
|
232
|
-
*
|
|
264
|
+
* tag with the bundled CSS text. One tag per slug — a duplicate mount reuses
|
|
265
|
+
* it, and a RE-IMPORT (the iframe's soft reload after a stylesheet changed)
|
|
266
|
+
* replaces its text, so a changed import actually reaches the page. Run at
|
|
267
|
+
* top-level so it executes before the React component does its first render.
|
|
268
|
+
*
|
|
269
|
+
* `sources` — the design-root-relative stylesheets inlined here — lands on the
|
|
270
|
+
* tag as `data-canvas-css-sources` (`|`-separated): the iframe's HMR client
|
|
271
|
+
* reloads when one of them changes, since there is no <link> to swap (plan
|
|
272
|
+
* T31/L16: a canvas importing `../system/<ds>/tokens.css` never restyled).
|
|
233
273
|
*/
|
|
234
|
-
function buildCssInjector(slug: string, css: string): string {
|
|
274
|
+
function buildCssInjector(slug: string, css: string, sources: readonly string[] = []): string {
|
|
235
275
|
// JSON-encode the CSS text so we don't need to worry about backticks,
|
|
236
276
|
// backslashes, or embedded `</style>` (which would break a raw template).
|
|
237
277
|
const enc = JSON.stringify(css);
|
|
238
|
-
const id = `canvas-css-${slug.replace(/[^a-zA-Z0-9-]/g, '_')}
|
|
239
|
-
|
|
278
|
+
const id = JSON.stringify(`canvas-css-${slug.replace(/[^a-zA-Z0-9-]/g, '_')}`);
|
|
279
|
+
const src = JSON.stringify(sources.join('|'));
|
|
280
|
+
return `// canvas-build: inject bundled sibling CSS so the canvas is self-contained.\n(function(){if(typeof document==="undefined")return;var s=document.getElementById(${id});if(!s){s=document.createElement("style");s.id=${id};s.dataset.canvasCss="bundled";document.head.appendChild(s);}s.dataset.canvasCssSources=${src};if(s.textContent!==${enc})s.textContent=${enc};})();\n`;
|
|
240
281
|
}
|
|
241
282
|
|
|
242
283
|
/**
|
|
@@ -696,6 +696,7 @@ export function applyTextEdit(
|
|
|
696
696
|
const expr = (only as AnyNode).expression;
|
|
697
697
|
// A single `{'string literal'}` — rewrite the literal in place (DDR-150 P1).
|
|
698
698
|
if (isStringLit(expr)) {
|
|
699
|
+
if (expr.value === text) return { source, delta: 0 };
|
|
699
700
|
const s = new MagicString(source);
|
|
700
701
|
s.overwrite(expr.start as number, expr.end as number, JSON.stringify(text));
|
|
701
702
|
const out = s.toString();
|
|
@@ -727,6 +728,10 @@ export function applyTextEdit(
|
|
|
727
728
|
const start = only.start as number;
|
|
728
729
|
const end = only.end as number;
|
|
729
730
|
const raw = source.slice(start, end);
|
|
731
|
+
// The text it already shows is not an edit (plan T23): a multi-line JSX text
|
|
732
|
+
// renders as its lines joined by one space, and rewriting it with that same
|
|
733
|
+
// text used to fold the author's line breaks into one line.
|
|
734
|
+
if (jsxRenderedText(raw) === escapeJsxText(text)) return { source, delta: 0 };
|
|
730
735
|
// Preserve the original indentation/newline framing; swap only the visible text.
|
|
731
736
|
const lead = /^\s*/.exec(raw)?.[0] ?? '';
|
|
732
737
|
const trail = /\s*$/.exec(raw)?.[0] ?? '';
|
|
@@ -746,6 +751,26 @@ export function applyTextEdit(
|
|
|
746
751
|
// the index drifts (`.filter().map()`, reorders). Anything we can't target
|
|
747
752
|
// unambiguously returns null → the caller throws → routes to /design:edit.
|
|
748
753
|
|
|
754
|
+
/**
|
|
755
|
+
* JSX's own whitespace rule for a text child, on the raw source: each line is
|
|
756
|
+
* trimmed (inner lines on both sides), empty lines drop, and the rest join with
|
|
757
|
+
* one space. Entities stay encoded — the caller compares against escaped text.
|
|
758
|
+
*/
|
|
759
|
+
function jsxRenderedText(raw: string): string {
|
|
760
|
+
const lines = raw.split(/\r\n|\n|\r/);
|
|
761
|
+
if (lines.length === 1) return raw.trim();
|
|
762
|
+
return lines
|
|
763
|
+
.map((line, i) => {
|
|
764
|
+
let l = line;
|
|
765
|
+
if (i !== 0) l = l.replace(/^[ \t]+/, '');
|
|
766
|
+
if (i !== lines.length - 1) l = l.replace(/[ \t]+$/, '');
|
|
767
|
+
return l;
|
|
768
|
+
})
|
|
769
|
+
.filter((l) => l.length > 0)
|
|
770
|
+
.join(' ')
|
|
771
|
+
.trim();
|
|
772
|
+
}
|
|
773
|
+
|
|
749
774
|
function isStringLit(n: AnyNode): boolean {
|
|
750
775
|
return !!(
|
|
751
776
|
n &&
|
|
@@ -4871,6 +4896,28 @@ export function assembleCompSource(
|
|
|
4871
4896
|
// ---------------------------------------------------------------------------
|
|
4872
4897
|
// Edit shapes.
|
|
4873
4898
|
|
|
4899
|
+
/**
|
|
4900
|
+
* The literal an attribute expression IS — a string, number or boolean (a
|
|
4901
|
+
* negative number arrives as a unary minus) — or null for anything computed.
|
|
4902
|
+
*/
|
|
4903
|
+
function literalOfExpression(expr: AnyNode): string | number | boolean | null {
|
|
4904
|
+
if (!expr) return null;
|
|
4905
|
+
if (expr.type === 'Literal' || expr.type === 'StringLiteral') {
|
|
4906
|
+
const v = expr.value;
|
|
4907
|
+
return typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean' ? v : null;
|
|
4908
|
+
}
|
|
4909
|
+
if (expr.type === 'NumericLiteral') return typeof expr.value === 'number' ? expr.value : null;
|
|
4910
|
+
if (expr.type === 'BooleanLiteral') return typeof expr.value === 'boolean' ? expr.value : null;
|
|
4911
|
+
if (
|
|
4912
|
+
expr.type === 'UnaryExpression' &&
|
|
4913
|
+
expr.operator === '-' &&
|
|
4914
|
+
(expr.argument?.type === 'Literal' || expr.argument?.type === 'NumericLiteral') &&
|
|
4915
|
+
typeof expr.argument.value === 'number'
|
|
4916
|
+
)
|
|
4917
|
+
return -expr.argument.value;
|
|
4918
|
+
return null;
|
|
4919
|
+
}
|
|
4920
|
+
|
|
4874
4921
|
function editStringAttr(
|
|
4875
4922
|
s: MagicString,
|
|
4876
4923
|
opening: AnyNode,
|
|
@@ -4897,6 +4944,9 @@ function editStringAttr(
|
|
|
4897
4944
|
return;
|
|
4898
4945
|
}
|
|
4899
4946
|
if (v.type === 'Literal' || v.type === 'StringLiteral') {
|
|
4947
|
+
// Setting the value it already has is not an edit (plan T23): keep the
|
|
4948
|
+
// author's bytes, quote style included.
|
|
4949
|
+
if (typeof v.value === 'string' && v.value === value) return;
|
|
4900
4950
|
// Replace the whole `"value"` (quotes included) so we control the escaping.
|
|
4901
4951
|
// The value lands in a JSX *attribute*, where `"` must become `"` and
|
|
4902
4952
|
// `<`/`>` their entities — NOT JS backslash escaping. `JSON.stringify` would
|
|
@@ -4921,6 +4971,25 @@ function editStringAttr(
|
|
|
4921
4971
|
{ canvas: canvasAbsPath, id }
|
|
4922
4972
|
);
|
|
4923
4973
|
}
|
|
4974
|
+
// A NUMBER OR BOOLEAN STAYS ONE (plan T23). `height={400}`,
|
|
4975
|
+
// `durationInFrames={30}`, `aria-pressed={false}` used to come back as
|
|
4976
|
+
// `"400"` / `"30"` / `"false"` — a different type, which a component that
|
|
4977
|
+
// does arithmetic on its prop (every Remotion timing prop) silently
|
|
4978
|
+
// mis-reads — and even an edit to the value it already had rewrote the
|
|
4979
|
+
// bytes. The same value is now no edit; a new number or boolean is
|
|
4980
|
+
// written into the braces it came in.
|
|
4981
|
+
const lit = literalOfExpression(inner);
|
|
4982
|
+
if (lit !== null) {
|
|
4983
|
+
if (String(lit) === value) return;
|
|
4984
|
+
if (typeof lit === 'number' && /^-?(?:\d+\.?\d*|\.\d+)(?:e[+-]?\d+)?$/i.test(value)) {
|
|
4985
|
+
s.overwrite(inner.start, inner.end, value);
|
|
4986
|
+
return;
|
|
4987
|
+
}
|
|
4988
|
+
if (typeof lit === 'boolean' && (value === 'true' || value === 'false')) {
|
|
4989
|
+
s.overwrite(inner.start, inner.end, value);
|
|
4990
|
+
return;
|
|
4991
|
+
}
|
|
4992
|
+
}
|
|
4924
4993
|
// Replace the whole `{...}` with a plain quoted literal — keeps the
|
|
4925
4994
|
// resulting JSX readable. Same JSX-attribute escaping as above (NOT
|
|
4926
4995
|
// `JSON.stringify`, which would JS-escape a `"` and corrupt the source).
|
|
@@ -5726,6 +5795,14 @@ export function relocateElement(
|
|
|
5726
5795
|
return matches.length === 1 ? (matches[0] as { id: string }).id : null;
|
|
5727
5796
|
}
|
|
5728
5797
|
|
|
5798
|
+
/** T23 — every JSX element of `source` with its id and print (corpus checks). */
|
|
5799
|
+
export function listElements(
|
|
5800
|
+
canvasAbsPath: string,
|
|
5801
|
+
source: string
|
|
5802
|
+
): Array<{ id: string; print: ElementPrint }> {
|
|
5803
|
+
return printAll(canvasAbsPath, source, null);
|
|
5804
|
+
}
|
|
5805
|
+
|
|
5729
5806
|
/** T23 — how many elements of `source` carry a print no other element shares. */
|
|
5730
5807
|
export function printUniqueness(
|
|
5731
5808
|
canvasAbsPath: string,
|
|
@@ -3138,32 +3138,47 @@ const ASSET_REF_RE = /assets\/[0-9a-f]{8}\.[a-z0-9]+/i;
|
|
|
3138
3138
|
* baked, the element's `src`/`href` is a `data:` URL that no longer contains
|
|
3139
3139
|
* the original asset path, so the substring match alone would lose track of
|
|
3140
3140
|
* it on the very next edit. */
|
|
3141
|
-
function
|
|
3141
|
+
function photoRefOf(el: Element): string {
|
|
3142
|
+
return el.getAttribute('src') || el.getAttribute('href') || el.getAttribute('xlink:href') || '';
|
|
3143
|
+
}
|
|
3144
|
+
|
|
3145
|
+
/**
|
|
3146
|
+
* Is the element's `data-photo-asset` tag still true? The tag is this bridge's
|
|
3147
|
+
* memory of a node it baked — while baked, the node shows a `data:` URL; left
|
|
3148
|
+
* alone, it shows the asset. Once the canvas points the node at something
|
|
3149
|
+
* else (Replace… on the image), the memory is stale: the node is no longer
|
|
3150
|
+
* this photo, and acting on the tag would put the old picture back over the
|
|
3151
|
+
* new one on the machine that made the replace (plan T31/L12).
|
|
3152
|
+
*/
|
|
3153
|
+
function photoTagCurrent(el: Element, tagged: string): boolean {
|
|
3154
|
+
const ref = photoRefOf(el);
|
|
3155
|
+
return ref.startsWith('data:') || ref.includes(tagged);
|
|
3156
|
+
}
|
|
3157
|
+
|
|
3158
|
+
export function findPhotoEl(asset: string): Element | null {
|
|
3142
3159
|
if (typeof document === 'undefined') return null;
|
|
3143
3160
|
// Scoped to img/image (not a bare `[data-photo-asset]` attribute selector) —
|
|
3144
3161
|
// the canvas iframe is untrusted content (DDR-054); an authored canvas could
|
|
3145
3162
|
// otherwise stamp the tag on an arbitrary element to redirect a bake.
|
|
3146
3163
|
for (const n of document.querySelectorAll('img[data-photo-asset], image[data-photo-asset]')) {
|
|
3147
|
-
if (n.getAttribute('data-photo-asset')
|
|
3164
|
+
if (n.getAttribute('data-photo-asset') !== asset) continue;
|
|
3165
|
+
if (photoTagCurrent(n, asset)) return n;
|
|
3166
|
+
n.removeAttribute('data-photo-asset');
|
|
3148
3167
|
}
|
|
3149
3168
|
for (const n of document.querySelectorAll('img, image')) {
|
|
3150
|
-
|
|
3151
|
-
n.getAttribute('src') || n.getAttribute('href') || n.getAttribute('xlink:href') || '';
|
|
3152
|
-
if (src.includes(asset)) return n;
|
|
3169
|
+
if (photoRefOf(n).includes(asset)) return n;
|
|
3153
3170
|
}
|
|
3154
3171
|
return null;
|
|
3155
3172
|
}
|
|
3156
3173
|
|
|
3157
|
-
function extractAssetRef(el: Element): string | null {
|
|
3174
|
+
export function extractAssetRef(el: Element): string | null {
|
|
3158
3175
|
// Only trust `data-photo-asset` when it actually has the `assets/<sha8>.<ext>`
|
|
3159
3176
|
// shape — the tag is attacker-controllable (untrusted canvas content,
|
|
3160
3177
|
// DDR-054), and an unshaped value would otherwise ride unbounded into
|
|
3161
3178
|
// `_active.json`/the WS broadcast via inspect.ts's `enrich()`.
|
|
3162
3179
|
const tagged = el.getAttribute('data-photo-asset');
|
|
3163
|
-
if (tagged && ASSET_REF_RE.test(tagged)) return tagged;
|
|
3164
|
-
|
|
3165
|
-
el.getAttribute('src') || el.getAttribute('href') || el.getAttribute('xlink:href') || '';
|
|
3166
|
-
return ref.match(ASSET_REF_RE)?.[0] ?? null;
|
|
3180
|
+
if (tagged && ASSET_REF_RE.test(tagged) && photoTagCurrent(el, tagged)) return tagged;
|
|
3181
|
+
return photoRefOf(el).match(ASSET_REF_RE)?.[0] ?? null;
|
|
3167
3182
|
}
|
|
3168
3183
|
|
|
3169
3184
|
function setPhotoElSrc(el: Element, url: string): void {
|
|
@@ -25,9 +25,28 @@
|
|
|
25
25
|
|
|
26
26
|
import path from 'node:path';
|
|
27
27
|
|
|
28
|
-
import {
|
|
28
|
+
import {
|
|
29
|
+
canvasSlugFromRel,
|
|
30
|
+
findFiles,
|
|
31
|
+
findHtmlFiles,
|
|
32
|
+
PREVIEW_ASSET_EXTS,
|
|
33
|
+
SKIP_DIRS,
|
|
34
|
+
} from './api.ts';
|
|
29
35
|
import type { Context } from './context.ts';
|
|
30
36
|
|
|
37
|
+
/**
|
|
38
|
+
* Supporting files the tree lists beside the canvases (images, fonts, media —
|
|
39
|
+
* the same `PREVIEW_ASSET_EXTS` `/_index-data` walks). A new one written from
|
|
40
|
+
* outside the app — an agent's export, a teammate's image arriving through
|
|
41
|
+
* sync — is a tree change too; without it the row stayed missing until
|
|
42
|
+
* something else reloaded the tree (plan T17/L03). Sidecars and stylesheets are
|
|
43
|
+
* deliberately NOT here: they are rewritten on every edit, and the tree nests
|
|
44
|
+
* them under their canvas anyway.
|
|
45
|
+
*/
|
|
46
|
+
function isListedFile(low: string): boolean {
|
|
47
|
+
return PREVIEW_ASSET_EXTS.some((x) => low.endsWith(x));
|
|
48
|
+
}
|
|
49
|
+
|
|
31
50
|
/** Burst-collapse window. A new canvas writes both `.tsx` and `.meta.json`; a
|
|
32
51
|
* git checkout touches many files at once — one snapshot per quiet window. */
|
|
33
52
|
export const CANVAS_LIST_DEBOUNCE_MS = 150;
|
|
@@ -59,7 +78,8 @@ export function isCanvasCandidate(rel: string, groupPaths: string[]): boolean {
|
|
|
59
78
|
// canvases costs one recompute and emits nothing.
|
|
60
79
|
const last = p.slice(p.lastIndexOf('/') + 1);
|
|
61
80
|
const looksLikeDir = !last.includes('.');
|
|
62
|
-
if (!looksLikeDir && !low.endsWith('.tsx') && !low.endsWith('.html')
|
|
81
|
+
if (!looksLikeDir && !low.endsWith('.tsx') && !low.endsWith('.html') && !isListedFile(low))
|
|
82
|
+
return false;
|
|
63
83
|
const segs = p.split('/');
|
|
64
84
|
for (const s of segs) {
|
|
65
85
|
if (!s) return false;
|
|
@@ -123,6 +143,11 @@ export function createCanvasListWatch(
|
|
|
123
143
|
continue; // group dir missing / unreadable — treat as empty
|
|
124
144
|
}
|
|
125
145
|
for (const f of files) out.add(f);
|
|
146
|
+
try {
|
|
147
|
+
for (const f of await findFiles(groupAbs, groupRel, PREVIEW_ASSET_EXTS)) out.add(f);
|
|
148
|
+
} catch {
|
|
149
|
+
/* unreadable — the canvases above still diff */
|
|
150
|
+
}
|
|
126
151
|
}
|
|
127
152
|
return out;
|
|
128
153
|
}
|
|
@@ -137,6 +162,12 @@ export function createCanvasListWatch(
|
|
|
137
162
|
// render/open/build sink without re-validating against /_index-data.
|
|
138
163
|
const prefix = `${ctx.paths.designRel.replace(/^\/+|\/+$/g, '')}/`;
|
|
139
164
|
const relOut = rel.startsWith(prefix) ? rel.slice(prefix.length) : rel;
|
|
165
|
+
if (isListedFile(rel.toLowerCase())) {
|
|
166
|
+
// Not a canvas: its own action and no slug, so no consumer mistakes an
|
|
167
|
+
// image for a canvas to close, retarget or adopt.
|
|
168
|
+
ctx.bus.emit('canvas-list-update', { action: `file-${action}`, rel: relOut });
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
140
171
|
ctx.bus.emit('canvas-list-update', {
|
|
141
172
|
action,
|
|
142
173
|
rel: relOut,
|