castle-web-cli 0.4.71 → 0.4.73

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.
Files changed (57) hide show
  1. package/dist/agent-prompts.js +22 -3
  2. package/dist/agent.js +731 -313
  3. package/dist/init.js +1 -1
  4. package/dist/shell/assets/index-Dfn29Bkt.js +108 -0
  5. package/dist/shell/assets/{index-CVEnWuGV.css → index-WNbOHPBj.css} +1 -1
  6. package/dist/shell/index.html +2 -2
  7. package/dist/vitePlugins.js +3 -2
  8. package/kits/basic-2d/CLAUDE.md +29 -8
  9. package/kits/basic-2d/behaviors/Collider.jsx +6 -4
  10. package/kits/basic-2d/behaviors/Layout.jsx +2 -2
  11. package/kits/basic-2d/behaviors/Sprite.jsx +210 -0
  12. package/kits/basic-2d/behaviors/tint.js +47 -0
  13. package/kits/basic-2d/docs/pxart-format.md +298 -0
  14. package/kits/basic-2d/drawings/pig.pxart +59 -0
  15. package/kits/basic-2d/editors/App.jsx +125 -76
  16. package/kits/basic-2d/editors/CodeEditor.jsx +9 -45
  17. package/kits/basic-2d/editors/FileBrowser.jsx +234 -47
  18. package/kits/basic-2d/editors/PlayOnly.jsx +9 -7
  19. package/kits/basic-2d/editors/PxArtEditor.jsx +662 -0
  20. package/kits/basic-2d/editors/SceneEditor.jsx +587 -221
  21. package/kits/basic-2d/editors/SelectionOverlay.jsx +808 -0
  22. package/kits/basic-2d/editors/SingleEditor.jsx +38 -20
  23. package/kits/basic-2d/editors/codeTheme.js +135 -0
  24. package/kits/basic-2d/editors/editorHistory.js +44 -17
  25. package/kits/basic-2d/editors/inspectorSheet.js +23 -0
  26. package/kits/basic-2d/editors/pixelCanvas.js +11 -0
  27. package/kits/basic-2d/editors/pixelEditorChrome.jsx +55 -0
  28. package/kits/basic-2d/editors/pixelGeometry.js +45 -0
  29. package/kits/basic-2d/editors/pixelInspector.jsx +416 -0
  30. package/kits/basic-2d/editors/pxArtEditorModel.js +718 -0
  31. package/kits/basic-2d/editors/pxArtPlayback.js +92 -0
  32. package/kits/basic-2d/editors/pxArtTimeline.jsx +752 -0
  33. package/kits/basic-2d/editors/pxArtTimeline.module.css +506 -0
  34. package/kits/basic-2d/editors/pxArtTools.js +124 -0
  35. package/kits/basic-2d/editors/useArtboardFit.js +102 -0
  36. package/kits/basic-2d/engine/ScenePlayer.jsx +10 -4
  37. package/kits/basic-2d/engine/SceneUI.jsx +3 -11
  38. package/kits/basic-2d/engine/assets.js +15 -0
  39. package/kits/basic-2d/engine/files.js +57 -2
  40. package/kits/basic-2d/engine/pxart.js +985 -0
  41. package/kits/basic-2d/engine/scene.js +222 -41
  42. package/kits/basic-2d/engine/ui.jsx +155 -26
  43. package/kits/basic-2d/engine/ui.module.css +1280 -344
  44. package/kits/basic-2d/eslint.config.js +21 -0
  45. package/kits/basic-2d/index.html +13 -0
  46. package/kits/basic-2d/package.json +1 -0
  47. package/kits/basic-2d/pnpm-lock.yaml +5 -5
  48. package/kits/basic-2d/scenes/main.scene +19 -26
  49. package/kits/basic-2d/scripts/draw.mjs +121 -0
  50. package/kits/basic-3d/editors/PlayOnly.jsx +9 -1
  51. package/kits/basic-3d/engine/ScenePlayer.jsx +7 -1
  52. package/package.json +1 -1
  53. package/dist/shell/assets/index-BY21Og40.js +0 -106
  54. package/kits/basic-2d/behaviors/Drawing.jsx +0 -142
  55. package/kits/basic-2d/drawings/block.drawing +0 -70
  56. package/kits/basic-2d/drawings/default.drawing +0 -70
  57. package/kits/basic-2d/editors/DrawingEditor.jsx +0 -224
@@ -2,6 +2,12 @@ import { initialFiles, parseJsonFile } from './files';
2
2
 
3
3
  const CARD_WIDTH = 500;
4
4
  const CARD_HEIGHT = 700;
5
+ const DEFAULT_VIEWPORT = {
6
+ width: CARD_WIDTH,
7
+ height: CARD_HEIGHT,
8
+ originX: 0,
9
+ originY: 0,
10
+ };
5
11
 
6
12
  export const cardSize = { width: CARD_WIDTH, height: CARD_HEIGHT };
7
13
 
@@ -19,9 +25,9 @@ function resolveSceneFileKey(name) {
19
25
  }
20
26
 
21
27
  export class SceneRuntime {
22
- constructor(sceneData, behaviors, drawings) {
28
+ constructor(sceneData, behaviors, sprites) {
23
29
  this.behaviors = new Map(behaviors.map((Behavior) => [Behavior.behaviorName, Behavior]));
24
- this.drawings = drawings;
30
+ this.sprites = sprites ?? {};
25
31
  this.time = 0;
26
32
  this.keys = new Set();
27
33
  this.pointer = { x: 0, y: 0, down: false };
@@ -71,7 +77,7 @@ export class SceneRuntime {
71
77
  }
72
78
 
73
79
  clone() {
74
- return new SceneRuntime(this.serialize(), [...this.behaviors.values()], this.drawings);
80
+ return new SceneRuntime(this.serialize(), [...this.behaviors.values()], this.sprites);
75
81
  }
76
82
 
77
83
  serialize() {
@@ -171,25 +177,40 @@ export class SceneRuntime {
171
177
  }
172
178
 
173
179
  draw(ctx, options = {}) {
174
- ctx.clearRect(0, 0, CARD_WIDTH, CARD_HEIGHT);
175
- ctx.fillStyle = this.data.background ?? '#0f2a1d';
176
- ctx.fillRect(0, 0, CARD_WIDTH, CARD_HEIGHT);
180
+ const viewport = options.viewport ?? DEFAULT_VIEWPORT;
181
+ ctx.clearRect(0, 0, viewport.width, viewport.height);
182
+ const camera = options.useCamera && this.camera ? this.camera : { x: 0, y: 0 };
183
+ if (options.showCropOutline) {
184
+ ctx.fillStyle = this.data.background ?? '#0f2a1d';
185
+ ctx.fillRect(0, 0, viewport.width, viewport.height);
186
+ } else {
187
+ ctx.fillStyle = this.data.background ?? '#0f2a1d';
188
+ ctx.fillRect(0, 0, CARD_WIDTH, CARD_HEIGHT);
189
+ }
177
190
 
178
191
  ctx.save();
179
- if (options.useCamera && this.camera) {
180
- ctx.translate(-Math.round(this.camera.x ?? 0), -Math.round(this.camera.y ?? 0));
192
+ if (options.useCamera && camera) {
193
+ ctx.translate(
194
+ Math.round(viewport.originX - (camera.x ?? 0)),
195
+ Math.round(viewport.originY - (camera.y ?? 0))
196
+ );
181
197
  }
198
+ if (options.showGrid) drawDotGrid(ctx, options.gridSize, viewport, camera);
199
+ const editSelectedIds = options.editPlaceholders
200
+ ? new Set(options.editSelectedActorIds ?? [])
201
+ : null;
182
202
  for (const actor of this.getActors()) {
183
203
  // Per-actor transform: Layout's `rotation` (degrees) rotates every draw
184
204
  // behavior of the actor about its center.
185
205
  ctx.save();
186
206
  applyLayoutRotation(ctx, getLayout(actor));
187
- if (options.editPlaceholders) drawEditPlaceholder(ctx, actor);
207
+ if (options.editPlaceholders && shouldDrawEditPlaceholder(actor, editSelectedIds)) {
208
+ drawEditPlaceholder(ctx, actor);
209
+ }
188
210
  this.forEachBehavior(actor, (instance) => instance.draw?.(actor, this, ctx, options));
189
211
  ctx.restore();
190
212
  }
191
213
 
192
- if (options.showGrid) drawGrid(ctx);
193
214
  if (options.selectedActorIds) {
194
215
  for (const id of options.selectedActorIds) {
195
216
  const actor = this.getActor(id);
@@ -197,6 +218,7 @@ export class SceneRuntime {
197
218
  }
198
219
  }
199
220
  if (options.marquee) drawMarquee(ctx, options.marquee);
221
+ if (options.showCropOutline) drawCropOutline(ctx);
200
222
  ctx.restore();
201
223
  }
202
224
 
@@ -239,8 +261,8 @@ export class SceneRuntime {
239
261
  }
240
262
  }
241
263
 
242
- export function makeScene(sceneData, behaviors, drawings) {
243
- return new SceneRuntime(sceneData, behaviors, drawings);
264
+ export function makeScene(sceneData, behaviors, sprites) {
265
+ return new SceneRuntime(sceneData, behaviors, sprites);
244
266
  }
245
267
 
246
268
  export function setActorComponent(sceneData, actorId, behaviorName, nextProps) {
@@ -292,6 +314,7 @@ export function duplicateActors(sceneData, actorIds) {
292
314
  if (actorIds.length === 0) return { sceneData, newIds: [] };
293
315
  const next = structuredClone(sceneData);
294
316
  const existingIds = new Set(next.actors.map((actor) => actor.id));
317
+ const offset = duplicateOffset(sceneData);
295
318
  const newIds = [];
296
319
  for (const id of actorIds) {
297
320
  const source = next.actors.find((candidate) => candidate.id === id);
@@ -303,8 +326,8 @@ export function duplicateActors(sceneData, actorIds) {
303
326
  if (layout) {
304
327
  copy.components.Layout = {
305
328
  ...layout,
306
- x: Math.round(layout.x + 1),
307
- y: Math.round(layout.y + 1),
329
+ x: Math.round(layout.x + offset),
330
+ y: Math.round(layout.y + offset),
308
331
  };
309
332
  }
310
333
  next.actors.push(copy);
@@ -313,7 +336,108 @@ export function duplicateActors(sceneData, actorIds) {
313
336
  return { sceneData: next, newIds };
314
337
  }
315
338
 
316
- export function addActor(sceneData, _files, drawings) {
339
+ function duplicateOffset(sceneData) {
340
+ const gridSize = sceneData.editor?.gridSize;
341
+ return Number.isFinite(gridSize) && gridSize > 0 ? gridSize : 50;
342
+ }
343
+
344
+ export function arrangeActors(sceneData, actorIds, action) {
345
+ if (actorIds.length === 0) return sceneData;
346
+ const selected = new Set(actorIds);
347
+ const ordered = sceneData.actors
348
+ .map((actor, index) => ({ actor, index, layout: getLayout(actor) }))
349
+ .filter((entry) => entry.layout)
350
+ .sort((a, b) => (a.layout.z ?? 0) - (b.layout.z ?? 0) || a.index - b.index);
351
+ if (!ordered.some((entry) => selected.has(entry.actor.id))) return sceneData;
352
+ const arranged = arrangeOrderedActors(ordered, selected, action);
353
+ if (arranged === ordered) return sceneData;
354
+ return applyActorOrder(sceneData, arranged);
355
+ }
356
+
357
+ function arrangeOrderedActors(ordered, selected, action) {
358
+ if (action === 'front') return moveSelectionToEdge(ordered, selected, 'front');
359
+ if (action === 'back') return moveSelectionToEdge(ordered, selected, 'back');
360
+ if (action === 'forward') return moveSelectionOneStep(ordered, selected, 1);
361
+ if (action === 'backward') return moveSelectionOneStep(ordered, selected, -1);
362
+ return ordered;
363
+ }
364
+
365
+ function moveSelectionToEdge(ordered, selected, edge) {
366
+ const picked = ordered.filter((entry) => selected.has(entry.actor.id));
367
+ const rest = ordered.filter((entry) => !selected.has(entry.actor.id));
368
+ const next = edge === 'front' ? [...rest, ...picked] : [...picked, ...rest];
369
+ return sameActorOrder(ordered, next) ? ordered : next;
370
+ }
371
+
372
+ function moveSelectionOneStep(ordered, selected, direction) {
373
+ const next = [...ordered];
374
+ if (direction > 0) {
375
+ for (let index = next.length - 2; index >= 0; index--) {
376
+ if (!selected.has(next[index].actor.id) || selected.has(next[index + 1].actor.id)) continue;
377
+ [next[index], next[index + 1]] = [next[index + 1], next[index]];
378
+ }
379
+ } else {
380
+ for (let index = 1; index < next.length; index++) {
381
+ if (!selected.has(next[index].actor.id) || selected.has(next[index - 1].actor.id)) continue;
382
+ [next[index], next[index - 1]] = [next[index - 1], next[index]];
383
+ }
384
+ }
385
+ return sameActorOrder(ordered, next) ? ordered : next;
386
+ }
387
+
388
+ function sameActorOrder(a, b) {
389
+ return a.length === b.length && a.every((entry, index) => entry.actor.id === b[index].actor.id);
390
+ }
391
+
392
+ function applyActorOrder(sceneData, ordered) {
393
+ const zById = new Map(ordered.map((entry, index) => [entry.actor.id, index]));
394
+ const next = structuredClone(sceneData);
395
+ let changed = false;
396
+ for (const actor of next.actors) {
397
+ const z = zById.get(actor.id);
398
+ const layout = getLayout(actor);
399
+ if (z === undefined || !layout || layout.z === z) continue;
400
+ actor.components.Layout = { ...layout, z };
401
+ changed = true;
402
+ }
403
+ return changed ? next : sceneData;
404
+ }
405
+
406
+ // Axis-aligned layout box of a single actor in world (card) units, or null.
407
+ // Ignores Layout.rotation -- v1 selection bounds are axis-aligned (the canvas
408
+ // `drawSelection` still draws the rotated outline for per-actor feedback).
409
+ export function getLayoutRect(actor) {
410
+ const layout = getLayout(actor);
411
+ if (!layout) return null;
412
+ return { x: layout.x, y: layout.y, width: layout.width, height: layout.height };
413
+ }
414
+
415
+ // Union axis-aligned bounding box of the given actors in world units, or null
416
+ // when none of them have a Layout. Used to anchor the on-canvas selection
417
+ // toolbar to the whole multi-selection.
418
+ export function getSelectionBounds(sceneData, actorIds) {
419
+ if (!sceneData || !actorIds || actorIds.length === 0) return null;
420
+ const wanted = new Set(actorIds);
421
+ let minX = Infinity;
422
+ let minY = Infinity;
423
+ let maxX = -Infinity;
424
+ let maxY = -Infinity;
425
+ let found = false;
426
+ for (const actor of sceneData.actors) {
427
+ if (!wanted.has(actor.id)) continue;
428
+ const rect = getLayoutRect(actor);
429
+ if (!rect) continue;
430
+ found = true;
431
+ minX = Math.min(minX, rect.x);
432
+ minY = Math.min(minY, rect.y);
433
+ maxX = Math.max(maxX, rect.x + rect.width);
434
+ maxY = Math.max(maxY, rect.y + rect.height);
435
+ }
436
+ if (!found) return null;
437
+ return { x: minX, y: minY, width: maxX - minX, height: maxY - minY };
438
+ }
439
+
440
+ export function addActor(sceneData, _files, sprites) {
317
441
  const next = structuredClone(sceneData);
318
442
  const existingIds = new Set(next.actors.map((actor) => actor.id));
319
443
  const id = mintActorId(existingIds);
@@ -328,9 +452,10 @@ export function addActor(sceneData, _files, drawings) {
328
452
  rotation: 0,
329
453
  };
330
454
  const components = { Layout: layout };
331
- const drawingPath = Object.keys(drawings).sort()[0];
332
- if (drawingPath) {
333
- components.Drawing = { file: drawingPath };
455
+ // Give a fresh actor the first available .pxart sprite so it shows art.
456
+ const spritePath = Object.keys(sprites ?? {}).sort()[0];
457
+ if (spritePath) {
458
+ components.Sprite = { file: spritePath };
334
459
  }
335
460
  next.actors.push({ id, components });
336
461
  return { sceneData: next, newId: id };
@@ -348,13 +473,14 @@ function mintActorId(existing) {
348
473
 
349
474
  export function screenToCard(canvas, clientX, clientY) {
350
475
  const rect = canvas.getBoundingClientRect();
476
+ const viewport = readCanvasViewport(canvas);
351
477
  return {
352
- x: ((clientX - rect.left) / rect.width) * CARD_WIDTH,
353
- y: ((clientY - rect.top) / rect.height) * CARD_HEIGHT,
478
+ x: ((clientX - rect.left) / rect.width) * viewport.width - viewport.originX,
479
+ y: ((clientY - rect.top) / rect.height) * viewport.height - viewport.originY,
354
480
  };
355
481
  }
356
482
 
357
- export function configureSceneCanvas(canvas, ctx = canvas.getContext('2d')) {
483
+ export function configureSceneCanvas(canvas, ctx = canvas.getContext('2d'), viewport = DEFAULT_VIEWPORT) {
358
484
  if (!ctx) return null;
359
485
  const rect = canvas.getBoundingClientRect();
360
486
  const dpr = window.devicePixelRatio || 1;
@@ -364,30 +490,54 @@ export function configureSceneCanvas(canvas, ctx = canvas.getContext('2d')) {
364
490
  const pixelHeight = Math.max(1, Math.round(displayHeight * dpr));
365
491
  if (canvas.width !== pixelWidth) canvas.width = pixelWidth;
366
492
  if (canvas.height !== pixelHeight) canvas.height = pixelHeight;
367
- ctx.setTransform(pixelWidth / CARD_WIDTH, 0, 0, pixelHeight / CARD_HEIGHT, 0, 0);
493
+ canvas.dataset.viewportWidth = String(viewport.width);
494
+ canvas.dataset.viewportHeight = String(viewport.height);
495
+ canvas.dataset.viewportOriginX = String(viewport.originX);
496
+ canvas.dataset.viewportOriginY = String(viewport.originY);
497
+ ctx.setTransform(pixelWidth / viewport.width, 0, 0, pixelHeight / viewport.height, 0, 0);
368
498
  ctx.imageSmoothingEnabled = true;
369
499
  return ctx;
370
500
  }
371
501
 
372
- function drawGrid(ctx) {
502
+ function readCanvasViewport(canvas) {
503
+ return {
504
+ width: Number(canvas.dataset.viewportWidth) || CARD_WIDTH,
505
+ height: Number(canvas.dataset.viewportHeight) || CARD_HEIGHT,
506
+ originX: Number(canvas.dataset.viewportOriginX) || 0,
507
+ originY: Number(canvas.dataset.viewportOriginY) || 0,
508
+ };
509
+ }
510
+
511
+ function drawDotGrid(ctx, gridSize = 25, viewport = DEFAULT_VIEWPORT, camera = { x: 0, y: 0 }) {
512
+ const size = Number.isFinite(gridSize) && gridSize > 0 ? gridSize : 25;
513
+ const minX = (camera.x ?? 0) - viewport.originX;
514
+ const minY = (camera.y ?? 0) - viewport.originY;
515
+ const maxX = minX + viewport.width;
516
+ const maxY = minY + viewport.height;
517
+ const startX = Math.floor(minX / size) * size;
518
+ const startY = Math.floor(minY / size) * size;
373
519
  ctx.save();
374
- ctx.strokeStyle = 'rgba(255, 255, 255, 0.08)';
375
- ctx.lineWidth = 1;
376
- for (let x = 0; x <= CARD_WIDTH; x += 25) {
377
- ctx.beginPath();
378
- ctx.moveTo(x + 0.5, 0);
379
- ctx.lineTo(x + 0.5, CARD_HEIGHT);
380
- ctx.stroke();
381
- }
382
- for (let y = 0; y <= CARD_HEIGHT; y += 25) {
383
- ctx.beginPath();
384
- ctx.moveTo(0, y + 0.5);
385
- ctx.lineTo(CARD_WIDTH, y + 0.5);
386
- ctx.stroke();
520
+ ctx.fillStyle = 'rgba(255, 255, 255, 0.18)';
521
+ for (let x = startX; x <= maxX; x += size) {
522
+ for (let y = startY; y <= maxY; y += size) {
523
+ ctx.beginPath();
524
+ ctx.arc(x, y, 1.35, 0, Math.PI * 2);
525
+ ctx.fill();
526
+ }
387
527
  }
388
528
  ctx.restore();
389
529
  }
390
530
 
531
+ // Per-actor edit bounding box visibility: show it only when the actor lacks a
532
+ // real sprite to look at (no Sprite behavior, or a Sprite with no .pxart file)
533
+ // or when the actor is currently selected. A Sprite-backed, unselected actor
534
+ // shows its art instead of the box.
535
+ function shouldDrawEditPlaceholder(actor, selectedIds) {
536
+ if (selectedIds && selectedIds.has(actor.id)) return true;
537
+ const sprite = actor?.components?.Sprite;
538
+ return !sprite || !sprite.file;
539
+ }
540
+
391
541
  function drawEditPlaceholder(ctx, actor) {
392
542
  const layout = getLayout(actor);
393
543
  if (!layout) return;
@@ -405,13 +555,42 @@ function drawMarquee(ctx, rect) {
405
555
  ctx.save();
406
556
  ctx.fillStyle = 'rgba(255, 255, 255, 0.12)';
407
557
  ctx.fillRect(rect.x, rect.y, rect.width, rect.height);
408
- ctx.strokeStyle = 'rgba(255, 255, 255, 0.85)';
558
+ ctx.strokeStyle = 'rgba(255, 255, 255, 0.4)';
409
559
  ctx.lineWidth = 1;
410
560
  ctx.setLineDash([4, 3]);
411
561
  ctx.strokeRect(rect.x + 0.5, rect.y + 0.5, rect.width - 1, rect.height - 1);
412
562
  ctx.restore();
413
563
  }
414
564
 
565
+ function drawCropOutline(ctx) {
566
+ ctx.save();
567
+ ctx.strokeStyle = 'rgba(255, 255, 255, 0.4)';
568
+ ctx.lineWidth = 2;
569
+ ctx.setLineDash([]);
570
+ const transform = ctx.getTransform();
571
+ const dpr = window.devicePixelRatio || 1;
572
+ const cssRadius = 12;
573
+ const radius = transform.a ? (cssRadius * dpr) / transform.a : cssRadius;
574
+ roundedRect(ctx, 1, 1, CARD_WIDTH - 2, CARD_HEIGHT - 2, radius);
575
+ ctx.stroke();
576
+ ctx.restore();
577
+ }
578
+
579
+ function roundedRect(ctx, x, y, width, height, radius) {
580
+ const r = Math.min(radius, width / 2, height / 2);
581
+ ctx.beginPath();
582
+ ctx.moveTo(x + r, y);
583
+ ctx.lineTo(x + width - r, y);
584
+ ctx.quadraticCurveTo(x + width, y, x + width, y + r);
585
+ ctx.lineTo(x + width, y + height - r);
586
+ ctx.quadraticCurveTo(x + width, y + height, x + width - r, y + height);
587
+ ctx.lineTo(x + r, y + height);
588
+ ctx.quadraticCurveTo(x, y + height, x, y + height - r);
589
+ ctx.lineTo(x, y + r);
590
+ ctx.quadraticCurveTo(x, y, x + r, y);
591
+ ctx.closePath();
592
+ }
593
+
415
594
  function drawSelection(ctx, actor) {
416
595
  const layout = getLayout(actor);
417
596
  if (!layout) return;
@@ -442,11 +621,13 @@ function getColliderRect(actor) {
442
621
  const layout = getLayout(actor);
443
622
  const collider = actor?.components?.Collider;
444
623
  if (!layout || !collider) return null;
624
+ const width = collider.width ?? layout.width;
625
+ const height = collider.height ?? layout.height;
445
626
  return {
446
- x: layout.x + (collider.offsetX ?? 0),
447
- y: layout.y + (collider.offsetY ?? 0),
448
- width: collider.width ?? layout.width,
449
- height: collider.height ?? layout.height,
627
+ x: layout.x + (layout.width - width) / 2 + (collider.offsetX ?? 0),
628
+ y: layout.y + (layout.height - height) / 2 + (collider.offsetY ?? 0),
629
+ width,
630
+ height,
450
631
  };
451
632
  }
452
633
 
@@ -1,16 +1,31 @@
1
- import React, { useRef, useState } from 'react';
1
+ import React, { useEffect, useLayoutEffect, useRef, useState } from 'react';
2
+ import { createPortal } from 'react-dom';
2
3
  import {
4
+ faArrowsAlt,
3
5
  faBars,
4
6
  faChevronDown,
5
7
  faChevronRight,
6
8
  faChevronUp,
7
9
  faClone,
10
+ faCode,
11
+ faEraser,
12
+ faEyeDropper,
13
+ faFile,
14
+ faFilm,
15
+ faFillDrip,
16
+ faGlobe,
17
+ faImage,
18
+ faLayerGroup,
8
19
  faObjectGroup,
9
20
  faPalette,
21
+ faPencilAlt,
10
22
  faPlay,
11
23
  faPlus,
12
24
  faRedo,
25
+ faSquare,
26
+ faStamp,
13
27
  faStop,
28
+ faSyncAlt,
14
29
  faTimes,
15
30
  faTrash,
16
31
  faUndo,
@@ -20,55 +35,93 @@ import styles from './ui.module.css';
20
35
  export { styles };
21
36
  export const theme = {
22
37
  transparentChecker:
23
- 'repeating-linear-gradient(45deg, #050505, #050505 4px, #252525 4px, #252525 8px)',
38
+ 'repeating-linear-gradient(45deg, #121212, #121212 4px, #1a1a1a 4px, #1a1a1a 8px)',
24
39
  };
25
40
  export function cx(...parts) {
26
41
  return parts.filter(Boolean).join(' ');
27
42
  }
43
+ // Track an element's content box via ResizeObserver. Shared by the canvas
44
+ // overlays (SceneUI, SelectionOverlay) that scale a card-unit layer onto the
45
+ // live canvas size.
46
+ export function useElementSize(ref) {
47
+ const [size, setSize] = useState({ width: 0, height: 0 });
48
+ useEffect(() => {
49
+ const node = ref.current;
50
+ if (!node) return undefined;
51
+ const measure = () => setSize({ width: node.clientWidth, height: node.clientHeight });
52
+ const observer = new ResizeObserver(measure);
53
+ observer.observe(node);
54
+ measure();
55
+ return () => observer.disconnect();
56
+ }, [ref]);
57
+ return size;
58
+ }
59
+ // True when the viewport is in the compact (≤600px) layout, where the file
60
+ // list and inspector become bottom sheets. Mirrors the `@media (max-width:
61
+ // 600px)` breakpoint so JS behavior (toggle target) tracks the CSS layout.
62
+ export function useCompactViewport() {
63
+ const query = '(max-width: 600px)';
64
+ const [compact, setCompact] = useState(
65
+ () => typeof window !== 'undefined' && window.matchMedia(query).matches
66
+ );
67
+ useEffect(() => {
68
+ const mql = window.matchMedia(query);
69
+ const onChange = (event) => setCompact(event.matches);
70
+ mql.addEventListener('change', onChange);
71
+ setCompact(mql.matches);
72
+ return () => mql.removeEventListener('change', onChange);
73
+ }, []);
74
+ return compact;
75
+ }
28
76
  export function AppShell({ children }) {
29
77
  return <div className={styles.appShell}>{children}</div>;
30
78
  }
31
79
  export function MainEditor({ children }) {
32
80
  return <main className={styles.mainEditor}>{children}</main>;
33
81
  }
34
- export function EditorHeader({ title, subtitle, right, onToggleFiles, filesOpen }) {
35
- const hasRight = !!right || !!onToggleFiles;
36
- return (
82
+ // The header is a full-width row spanning the file list + workspace. Each
83
+ // editor still owns its header content (title/subtitle/right controls depend
84
+ // on per-editor state), so EditorHeader portals its DOM into the editor-root
85
+ // header slot (`headerHost`) that App renders above the `[files | workspace]`
86
+ // grid. The files toggle lives on the left and works on every viewport.
87
+ export function EditorHeader({ title, subtitle, right, onToggleFiles, filesOpen, headerHost }) {
88
+ const header = (
37
89
  <header className={styles.editorHeader}>
38
90
  <div className={styles.editorHeaderLeft}>
91
+ {onToggleFiles ? (
92
+ <IconButton
93
+ icon="bars"
94
+ label="Files"
95
+ title="Toggle file list"
96
+ active={filesOpen}
97
+ aria-pressed={filesOpen ? 'true' : 'false'}
98
+ onClick={onToggleFiles}
99
+ />
100
+ ) : null}
39
101
  <div>
40
102
  <div className={styles.editorTitle}>{title}</div>
41
103
  {subtitle ? <div className={styles.muted}>{subtitle}</div> : null}
42
104
  </div>
43
105
  </div>
44
- {hasRight ? (
45
- <div className={styles.editorHeaderRight}>
46
- {right}
47
- {onToggleFiles ? (
48
- <button
49
- type="button"
50
- className={cx(styles.headerFilesButton, styles.mobileOnly)}
51
- onClick={onToggleFiles}
52
- aria-label="Files"
53
- aria-pressed={filesOpen ? 'true' : 'false'}>
54
- <Icon name="bars" />
55
- </button>
56
- ) : null}
57
- </div>
58
- ) : null}
106
+ {right ? <div className={styles.editorHeaderRight}>{right}</div> : null}
59
107
  </header>
60
108
  );
109
+ if (!headerHost) return null;
110
+ return createPortal(header, headerHost);
61
111
  }
62
- export function EditorBody({ children }) {
63
- return <div className={styles.editorBody}>{children}</div>;
64
- }
112
+ export const EditorBody = React.forwardRef(function EditorBody({ children }, ref) {
113
+ return (
114
+ <div ref={ref} className={styles.editorBody}>
115
+ {children}
116
+ </div>
117
+ );
118
+ });
65
119
  export function Button({ children, active = false, variant = '', className = '', ...props }) {
66
120
  return (
67
121
  <button
68
122
  className={cx(
69
123
  styles.button,
70
124
  active && styles.buttonActive,
71
- variant === 'primary' && styles.buttonPrimary,
72
125
  variant === 'danger' && styles.buttonDanger,
73
126
  className
74
127
  )}
@@ -97,11 +150,25 @@ const icons = {
97
150
  'chevron-right': faChevronRight,
98
151
  'chevron-up': faChevronUp,
99
152
  clone: faClone,
153
+ code: faCode,
154
+ eraser: faEraser,
155
+ eyedropper: faEyeDropper,
156
+ fill: faFillDrip,
157
+ file: faFile,
158
+ film: faFilm,
159
+ globe: faGlobe,
160
+ image: faImage,
161
+ 'layer-group': faLayerGroup,
162
+ move: faArrowsAlt,
100
163
  'object-group': faObjectGroup,
101
164
  palette: faPalette,
165
+ pencil: faPencilAlt,
102
166
  play: faPlay,
103
167
  plus: faPlus,
104
168
  redo: faRedo,
169
+ rotate: faSyncAlt,
170
+ square: faSquare,
171
+ stamp: faStamp,
105
172
  stop: faStop,
106
173
  times: faTimes,
107
174
  trash: faTrash,
@@ -121,10 +188,15 @@ export function Icon({ name }) {
121
188
  </svg>
122
189
  );
123
190
  }
124
- export function Panel({ title, children }) {
191
+ export function Panel({ title, action, children }) {
125
192
  return (
126
193
  <section className={styles.panel}>
127
- {title ? <div className={styles.panelHeader}>{title}</div> : null}
194
+ {title ? (
195
+ <div className={styles.panelHeader}>
196
+ <span>{title}</span>
197
+ {action}
198
+ </div>
199
+ ) : null}
128
200
  <div className={styles.panelBody}>{children}</div>
129
201
  </section>
130
202
  );
@@ -322,6 +394,63 @@ export function SheetGrabHandle({ label, hint }) {
322
394
  </>
323
395
  );
324
396
  }
397
+ // Cursor-anchored right-click menu reusing the scene editor's "Arrange" menu
398
+ // chrome (the shared `selArrangeMenu`/`selArrangeItem` classes). Opens at the
399
+ // given client point, clamps into the viewport, and closes on outside-click or
400
+ // Escape. Items are `{ key, label, disabled?, onClick }` descriptors. The
401
+ // timeline keeps its own inline copy; this one backs the FileBrowser menus.
402
+ export function ContextMenu({ x, y, items, onClose }) {
403
+ const ref = useRef(null);
404
+ const [pos, setPos] = useState({ left: x, top: y });
405
+ useLayoutEffect(() => {
406
+ const el = ref.current;
407
+ if (!el) return;
408
+ const { width, height } = el.getBoundingClientRect();
409
+ const margin = 8;
410
+ const maxLeft = Math.max(margin, window.innerWidth - width - margin);
411
+ const maxTop = Math.max(margin, window.innerHeight - height - margin);
412
+ setPos({ left: Math.min(x, maxLeft), top: Math.min(y, maxTop) });
413
+ }, [x, y]);
414
+ useEffect(() => {
415
+ const dismiss = (event) => {
416
+ if (event.type === 'keydown' && event.key !== 'Escape') return;
417
+ if (event.type === 'pointerdown' && ref.current?.contains(event.target)) return;
418
+ onClose();
419
+ };
420
+ window.addEventListener('pointerdown', dismiss);
421
+ window.addEventListener('keydown', dismiss);
422
+ return () => {
423
+ window.removeEventListener('pointerdown', dismiss);
424
+ window.removeEventListener('keydown', dismiss);
425
+ };
426
+ }, [onClose]);
427
+ const pick = (item) => {
428
+ if (item.disabled) return;
429
+ item.onClick();
430
+ onClose();
431
+ };
432
+ const menuStyle = { position: 'fixed', left: pos.left, top: pos.top, transform: 'none' };
433
+ return (
434
+ <div
435
+ ref={ref}
436
+ role="menu"
437
+ style={menuStyle}
438
+ onContextMenu={(event) => event.preventDefault()}
439
+ className={styles.selArrangeMenu}>
440
+ {items.map((item) => (
441
+ <button
442
+ type="button"
443
+ key={item.key}
444
+ disabled={item.disabled}
445
+ onClick={() => pick(item)}
446
+ className={cx(styles.selArrangeItem, item.disabled && styles.selArrangeItemDisabled)}
447
+ role="menuitem">
448
+ {item.label}
449
+ </button>
450
+ ))}
451
+ </div>
452
+ );
453
+ }
325
454
  export function SelectField({ label, value, onChange, options }) {
326
455
  return (
327
456
  <FieldRow label={label}>