castle-web-cli 0.4.72 → 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.
- package/dist/agent-prompts.js +22 -3
- package/dist/agent.js +731 -313
- package/dist/init.js +1 -1
- package/dist/shell/assets/index-Dfn29Bkt.js +108 -0
- package/dist/shell/assets/{index-CVEnWuGV.css → index-WNbOHPBj.css} +1 -1
- package/dist/shell/index.html +2 -2
- package/dist/vitePlugins.js +3 -2
- package/kits/basic-2d/CLAUDE.md +29 -8
- package/kits/basic-2d/behaviors/Collider.jsx +6 -4
- package/kits/basic-2d/behaviors/Layout.jsx +2 -2
- package/kits/basic-2d/behaviors/Sprite.jsx +210 -0
- package/kits/basic-2d/behaviors/tint.js +47 -0
- package/kits/basic-2d/docs/pxart-format.md +298 -0
- package/kits/basic-2d/drawings/pig.pxart +59 -0
- package/kits/basic-2d/editors/App.jsx +125 -76
- package/kits/basic-2d/editors/CodeEditor.jsx +9 -45
- package/kits/basic-2d/editors/FileBrowser.jsx +234 -47
- package/kits/basic-2d/editors/PlayOnly.jsx +9 -7
- package/kits/basic-2d/editors/PxArtEditor.jsx +662 -0
- package/kits/basic-2d/editors/SceneEditor.jsx +587 -221
- package/kits/basic-2d/editors/SelectionOverlay.jsx +808 -0
- package/kits/basic-2d/editors/SingleEditor.jsx +38 -20
- package/kits/basic-2d/editors/codeTheme.js +135 -0
- package/kits/basic-2d/editors/editorHistory.js +44 -17
- package/kits/basic-2d/editors/inspectorSheet.js +23 -0
- package/kits/basic-2d/editors/pixelCanvas.js +11 -0
- package/kits/basic-2d/editors/pixelEditorChrome.jsx +55 -0
- package/kits/basic-2d/editors/pixelGeometry.js +45 -0
- package/kits/basic-2d/editors/pixelInspector.jsx +416 -0
- package/kits/basic-2d/editors/pxArtEditorModel.js +718 -0
- package/kits/basic-2d/editors/pxArtPlayback.js +92 -0
- package/kits/basic-2d/editors/pxArtTimeline.jsx +752 -0
- package/kits/basic-2d/editors/pxArtTimeline.module.css +506 -0
- package/kits/basic-2d/editors/pxArtTools.js +124 -0
- package/kits/basic-2d/editors/useArtboardFit.js +102 -0
- package/kits/basic-2d/engine/ScenePlayer.jsx +10 -4
- package/kits/basic-2d/engine/SceneUI.jsx +3 -11
- package/kits/basic-2d/engine/assets.js +15 -0
- package/kits/basic-2d/engine/files.js +57 -2
- package/kits/basic-2d/engine/pxart.js +985 -0
- package/kits/basic-2d/engine/scene.js +222 -41
- package/kits/basic-2d/engine/ui.jsx +155 -26
- package/kits/basic-2d/engine/ui.module.css +1280 -344
- package/kits/basic-2d/eslint.config.js +21 -0
- package/kits/basic-2d/index.html +13 -0
- package/kits/basic-2d/package.json +1 -0
- package/kits/basic-2d/pnpm-lock.yaml +5 -5
- package/kits/basic-2d/scenes/main.scene +19 -26
- package/kits/basic-2d/scripts/draw.mjs +121 -0
- package/kits/basic-3d/editors/PlayOnly.jsx +9 -1
- package/kits/basic-3d/engine/ScenePlayer.jsx +7 -1
- package/package.json +1 -1
- package/dist/shell/assets/index-vmdKwUE1.js +0 -106
- package/kits/basic-2d/behaviors/Drawing.jsx +0 -142
- package/kits/basic-2d/drawings/block.drawing +0 -70
- package/kits/basic-2d/drawings/default.drawing +0 -70
- 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,
|
|
28
|
+
constructor(sceneData, behaviors, sprites) {
|
|
23
29
|
this.behaviors = new Map(behaviors.map((Behavior) => [Behavior.behaviorName, Behavior]));
|
|
24
|
-
this.
|
|
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.
|
|
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
|
-
|
|
175
|
-
ctx.
|
|
176
|
-
|
|
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 &&
|
|
180
|
-
ctx.translate(
|
|
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
|
|
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,
|
|
243
|
-
return new SceneRuntime(sceneData, behaviors,
|
|
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 +
|
|
307
|
-
y: Math.round(layout.y +
|
|
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
|
-
|
|
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
|
-
|
|
332
|
-
|
|
333
|
-
|
|
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) *
|
|
353
|
-
y: ((clientY - rect.top) / rect.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
|
-
|
|
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
|
|
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.
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
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.
|
|
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
|
|
449
|
-
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, #
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
{
|
|
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
|
|
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 ?
|
|
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}>
|