castle-web-cli 0.4.73 → 0.4.74
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.
|
@@ -43,6 +43,27 @@ const EDIT_VIEWPORT = {
|
|
|
43
43
|
originX: cardSize.width,
|
|
44
44
|
originY: cardSize.height,
|
|
45
45
|
};
|
|
46
|
+
// Edit-camera zoom bounds. zoom > 1 magnifies (the visible viewport shrinks);
|
|
47
|
+
// zoom < 1 pulls back to show more around the card.
|
|
48
|
+
const MIN_EDIT_ZOOM = 0.4;
|
|
49
|
+
const MAX_EDIT_ZOOM = 6;
|
|
50
|
+
// Wheel-delta -> zoom-factor sensitivity for trackpad pinch (ctrl+wheel).
|
|
51
|
+
const ZOOM_WHEEL_SPEED = 0.01;
|
|
52
|
+
const clampZoom = (zoom) =>
|
|
53
|
+
Math.min(MAX_EDIT_ZOOM, Math.max(MIN_EDIT_ZOOM, Number(zoom) || 1));
|
|
54
|
+
// Derive the effective edit viewport for a zoom level. Both extent and origin
|
|
55
|
+
// scale by 1/zoom, which keeps the canvas draw path, `screenToCard`, and the
|
|
56
|
+
// SelectionOverlay's `sx = box/card` projection mutually consistent (the overlay
|
|
57
|
+
// just multiplies its scale by the same zoom).
|
|
58
|
+
function editViewportForZoom(zoom) {
|
|
59
|
+
const z = clampZoom(zoom);
|
|
60
|
+
return {
|
|
61
|
+
width: EDIT_VIEWPORT.width / z,
|
|
62
|
+
height: EDIT_VIEWPORT.height / z,
|
|
63
|
+
originX: EDIT_VIEWPORT.originX / z,
|
|
64
|
+
originY: EDIT_VIEWPORT.originY / z,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
46
67
|
export function SceneEditor({
|
|
47
68
|
path,
|
|
48
69
|
text,
|
|
@@ -60,7 +81,7 @@ export function SceneEditor({
|
|
|
60
81
|
const canvasRef = useRef(null);
|
|
61
82
|
const runtimeRef = useRef(null);
|
|
62
83
|
const marqueeRef = useRef(null);
|
|
63
|
-
const editCameraRef = useRef({ x: 0, y: 0 });
|
|
84
|
+
const editCameraRef = useRef({ x: 0, y: 0, zoom: 1 });
|
|
64
85
|
const selectedActorIdsRef = useRef(selectedActorIds);
|
|
65
86
|
selectedActorIdsRef.current = selectedActorIds;
|
|
66
87
|
const [isPlaying, setIsPlaying] = useState(false);
|
|
@@ -215,7 +236,6 @@ export function SceneEditor({
|
|
|
215
236
|
? panGesture.onPointerUp(event)
|
|
216
237
|
: gesture.onPointerUp(event)
|
|
217
238
|
}
|
|
218
|
-
onWheel={panGesture.onWheel}
|
|
219
239
|
/>
|
|
220
240
|
{isPlaying && <SceneUI getRuntime={getRuntime} />}
|
|
221
241
|
</div>
|
|
@@ -923,6 +943,10 @@ function usePlayPointerGesture({ canvasRef, runtimeRef }) {
|
|
|
923
943
|
function usePanGesture({ canvasRef, editCameraRef, isPlaying }) {
|
|
924
944
|
const dragRef = useRef(null);
|
|
925
945
|
const spaceDownRef = useRef(false);
|
|
946
|
+
// Native wheel/gesture listeners (below) read play state through a ref so they
|
|
947
|
+
// never need to detach/reattach when play toggles.
|
|
948
|
+
const isPlayingRef = useRef(isPlaying);
|
|
949
|
+
isPlayingRef.current = isPlaying;
|
|
926
950
|
useEffect(() => {
|
|
927
951
|
function onKeyDown(event) {
|
|
928
952
|
if (event.key !== ' ' || isEditableTarget(event.target)) return;
|
|
@@ -950,12 +974,104 @@ function usePanGesture({ canvasRef, editCameraRef, isPlaying }) {
|
|
|
950
974
|
const viewportWidth = Number(canvas.dataset.viewportWidth) || cardSize.width;
|
|
951
975
|
const viewportHeight = Number(canvas.dataset.viewportHeight) || cardSize.height;
|
|
952
976
|
editCameraRef.current = {
|
|
977
|
+
...editCameraRef.current,
|
|
953
978
|
x: editCameraRef.current.x + (dx * viewportWidth) / rect.width,
|
|
954
979
|
y: editCameraRef.current.y + (dy * viewportHeight) / rect.height,
|
|
955
980
|
};
|
|
956
981
|
},
|
|
957
982
|
[canvasRef, editCameraRef]
|
|
958
983
|
);
|
|
984
|
+
// Zoom about the pointer by a multiplicative factor: keep the world point
|
|
985
|
+
// under the cursor pinned while the zoom level changes. Because the effective
|
|
986
|
+
// origin scales as EDIT_VIEWPORT.origin / zoom, the camera shift to re-pin the
|
|
987
|
+
// cursor is (1/z0 - 1/z1) * (frac * EDIT_VIEWPORT.extent - EDIT_VIEWPORT.origin).
|
|
988
|
+
const zoomByFactorAtPointer = useCallback(
|
|
989
|
+
(clientX, clientY, factor) => {
|
|
990
|
+
const canvas = canvasRef.current;
|
|
991
|
+
if (!canvas || !Number.isFinite(factor) || factor <= 0) return;
|
|
992
|
+
const cam = editCameraRef.current;
|
|
993
|
+
const z0 = cam.zoom ?? 1;
|
|
994
|
+
const z1 = clampZoom(z0 * factor);
|
|
995
|
+
if (z1 === z0) return;
|
|
996
|
+
const rect = canvas.getBoundingClientRect();
|
|
997
|
+
const fracX = (clientX - rect.left) / rect.width;
|
|
998
|
+
const fracY = (clientY - rect.top) / rect.height;
|
|
999
|
+
const k = 1 / z0 - 1 / z1;
|
|
1000
|
+
editCameraRef.current = {
|
|
1001
|
+
x: cam.x + k * (fracX * EDIT_VIEWPORT.width - EDIT_VIEWPORT.originX),
|
|
1002
|
+
y: cam.y + k * (fracY * EDIT_VIEWPORT.height - EDIT_VIEWPORT.originY),
|
|
1003
|
+
zoom: z1,
|
|
1004
|
+
};
|
|
1005
|
+
},
|
|
1006
|
+
[canvasRef, editCameraRef]
|
|
1007
|
+
);
|
|
1008
|
+
// Wheel + pinch are bound as NATIVE, non-passive listeners (not React props):
|
|
1009
|
+
// React attaches `wheel` passively at its root, so a React onWheel can't call
|
|
1010
|
+
// preventDefault and the browser's own page-zoom wins. We also need Safari's
|
|
1011
|
+
// proprietary `gesture*` events, which is how it reports trackpad pinch (it
|
|
1012
|
+
// does not emit ctrl+wheel like Chromium). Both paths preventDefault to stop
|
|
1013
|
+
// the browser from zooming the page underneath the editor.
|
|
1014
|
+
useEffect(() => {
|
|
1015
|
+
const canvas = canvasRef.current;
|
|
1016
|
+
if (!canvas) return undefined;
|
|
1017
|
+
let gestureScale = 1;
|
|
1018
|
+
// Set while a Safari pinch gesture is in flight, so we don't double-apply
|
|
1019
|
+
// zoom if Safari also emits ctrl+wheel for the same pinch.
|
|
1020
|
+
let gestureActive = false;
|
|
1021
|
+
const onWheel = (event) => {
|
|
1022
|
+
if (isPlayingRef.current) return;
|
|
1023
|
+
event.preventDefault();
|
|
1024
|
+
// Trackpad pinch (and Ctrl+wheel) surface as wheel events with ctrlKey set
|
|
1025
|
+
// in Chromium; route those to zoom and leave plain scroll as pan.
|
|
1026
|
+
if (event.ctrlKey) {
|
|
1027
|
+
if (gestureActive) return;
|
|
1028
|
+
zoomByFactorAtPointer(
|
|
1029
|
+
event.clientX,
|
|
1030
|
+
event.clientY,
|
|
1031
|
+
Math.exp(-event.deltaY * ZOOM_WHEEL_SPEED)
|
|
1032
|
+
);
|
|
1033
|
+
} else {
|
|
1034
|
+
panByScreenDelta(event.deltaX, event.deltaY);
|
|
1035
|
+
}
|
|
1036
|
+
};
|
|
1037
|
+
const onGestureStart = (event) => {
|
|
1038
|
+
if (isPlayingRef.current) return;
|
|
1039
|
+
event.preventDefault();
|
|
1040
|
+
gestureActive = true;
|
|
1041
|
+
gestureScale = event.scale || 1;
|
|
1042
|
+
};
|
|
1043
|
+
const onGestureChange = (event) => {
|
|
1044
|
+
if (isPlayingRef.current) return;
|
|
1045
|
+
event.preventDefault();
|
|
1046
|
+
// Safari reports `scale` cumulatively from gesturestart (1.0 at start), so
|
|
1047
|
+
// zoom by the ratio against the last reading.
|
|
1048
|
+
const prev = gestureScale || 1;
|
|
1049
|
+
const next = event.scale || prev;
|
|
1050
|
+
gestureScale = next;
|
|
1051
|
+
// GestureEvent carries clientX/clientY (the pinch centroid); fall back to
|
|
1052
|
+
// the canvas center if a browser omits them.
|
|
1053
|
+
const rect = canvas.getBoundingClientRect();
|
|
1054
|
+
const px = Number.isFinite(event.clientX) ? event.clientX : rect.left + rect.width / 2;
|
|
1055
|
+
const py = Number.isFinite(event.clientY) ? event.clientY : rect.top + rect.height / 2;
|
|
1056
|
+
zoomByFactorAtPointer(px, py, next / prev);
|
|
1057
|
+
};
|
|
1058
|
+
const onGestureEnd = (event) => {
|
|
1059
|
+
if (isPlayingRef.current) return;
|
|
1060
|
+
event.preventDefault();
|
|
1061
|
+
gestureActive = false;
|
|
1062
|
+
};
|
|
1063
|
+
const opts = { passive: false };
|
|
1064
|
+
canvas.addEventListener('wheel', onWheel, opts);
|
|
1065
|
+
canvas.addEventListener('gesturestart', onGestureStart, opts);
|
|
1066
|
+
canvas.addEventListener('gesturechange', onGestureChange, opts);
|
|
1067
|
+
canvas.addEventListener('gestureend', onGestureEnd, opts);
|
|
1068
|
+
return () => {
|
|
1069
|
+
canvas.removeEventListener('wheel', onWheel, opts);
|
|
1070
|
+
canvas.removeEventListener('gesturestart', onGestureStart, opts);
|
|
1071
|
+
canvas.removeEventListener('gesturechange', onGestureChange, opts);
|
|
1072
|
+
canvas.removeEventListener('gestureend', onGestureEnd, opts);
|
|
1073
|
+
};
|
|
1074
|
+
}, [canvasRef, panByScreenDelta, zoomByFactorAtPointer]);
|
|
959
1075
|
const onPointerDown = useCallback((event) => {
|
|
960
1076
|
if (isPlaying || !spaceDownRef.current) return;
|
|
961
1077
|
event.preventDefault();
|
|
@@ -984,17 +1100,9 @@ function usePanGesture({ canvasRef, editCameraRef, isPlaying }) {
|
|
|
984
1100
|
}
|
|
985
1101
|
dragRef.current = null;
|
|
986
1102
|
}, []);
|
|
987
|
-
const onWheel = useCallback(
|
|
988
|
-
(event) => {
|
|
989
|
-
if (isPlaying) return;
|
|
990
|
-
event.preventDefault();
|
|
991
|
-
panByScreenDelta(event.deltaX, event.deltaY);
|
|
992
|
-
},
|
|
993
|
-
[isPlaying, panByScreenDelta]
|
|
994
|
-
);
|
|
995
1103
|
const isSpacePanning = useCallback(() => spaceDownRef.current, []);
|
|
996
1104
|
const isActive = useCallback(() => Boolean(dragRef.current), []);
|
|
997
|
-
return { onPointerDown, onPointerMove, onPointerUp,
|
|
1105
|
+
return { onPointerDown, onPointerMove, onPointerUp, isSpacePanning, isActive };
|
|
998
1106
|
}
|
|
999
1107
|
function handleShiftPointerDown(drag, actor, current) {
|
|
1000
1108
|
if (actor) {
|
|
@@ -1149,13 +1257,20 @@ function useScenePlayLoop({
|
|
|
1149
1257
|
const canvas = canvasRef.current;
|
|
1150
1258
|
const ctx = canvas.getContext('2d');
|
|
1151
1259
|
if (!ctx) return undefined;
|
|
1152
|
-
const viewport = isPlaying
|
|
1260
|
+
const viewport = isPlaying
|
|
1261
|
+
? undefined
|
|
1262
|
+
: editViewportForZoom(editCameraRef.current.zoom);
|
|
1153
1263
|
configureSceneCanvas(canvas, ctx, viewport);
|
|
1154
1264
|
let raf = 0;
|
|
1155
1265
|
let last = performance.now();
|
|
1156
1266
|
const frame = (now) => {
|
|
1157
1267
|
const dt = Math.min(0.033, (now - last) / 1000);
|
|
1158
1268
|
last = now;
|
|
1269
|
+
// Recompute each frame so the edit zoom (mutated on the camera ref by the
|
|
1270
|
+
// pinch gesture) takes effect without restarting the loop.
|
|
1271
|
+
const frameViewport = isPlaying
|
|
1272
|
+
? undefined
|
|
1273
|
+
: editViewportForZoom(editCameraRef.current.zoom);
|
|
1159
1274
|
const scene = isPlaying
|
|
1160
1275
|
? runtimeRef.current
|
|
1161
1276
|
: makeScene(sceneData, behaviorClasses, sprites);
|
|
@@ -1163,7 +1278,7 @@ function useScenePlayLoop({
|
|
|
1163
1278
|
const snap = getSnapSettings(sceneData);
|
|
1164
1279
|
if (isPlaying) scene.update(dt);
|
|
1165
1280
|
if (!isPlaying) scene.camera = { ...editCameraRef.current };
|
|
1166
|
-
configureSceneCanvas(canvas, ctx,
|
|
1281
|
+
configureSceneCanvas(canvas, ctx, frameViewport);
|
|
1167
1282
|
// Read selection from the ref each frame so the grid/box gating tracks
|
|
1168
1283
|
// the current selection without resetting this loop.
|
|
1169
1284
|
const editSelectedActorIds = isPlaying ? [] : selectedActorIdsRef.current ?? [];
|
|
@@ -1174,7 +1289,7 @@ function useScenePlayLoop({
|
|
|
1174
1289
|
gridSize: snap.gridSize,
|
|
1175
1290
|
showDebugColliders: false,
|
|
1176
1291
|
showCropOutline: !isPlaying,
|
|
1177
|
-
viewport,
|
|
1292
|
+
viewport: frameViewport,
|
|
1178
1293
|
useCamera: true,
|
|
1179
1294
|
editPlaceholders: !isPlaying,
|
|
1180
1295
|
editSelectedActorIds,
|
|
@@ -63,7 +63,7 @@ export function SelectionOverlay({
|
|
|
63
63
|
const rootRef = useRef(null);
|
|
64
64
|
// Track the canvas-sized overlay box so the card-unit layer scales onto it.
|
|
65
65
|
const box = useElementSize(rootRef);
|
|
66
|
-
const [camera, setCamera] = useState({ x: 0, y: 0 });
|
|
66
|
+
const [camera, setCamera] = useState({ x: 0, y: 0, zoom: 1 });
|
|
67
67
|
const [groupFrameRotation, setGroupFrameRotation] = useState(null);
|
|
68
68
|
const [arrangeOpen, setArrangeOpen] = useState(false);
|
|
69
69
|
const selectionKey = [...selectedActorIds].sort().join('|');
|
|
@@ -73,8 +73,13 @@ export function SelectionOverlay({
|
|
|
73
73
|
useEffect(() => {
|
|
74
74
|
let raf = 0;
|
|
75
75
|
const tick = () => {
|
|
76
|
-
const cam = editCameraRef.current ?? { x: 0, y: 0 };
|
|
77
|
-
|
|
76
|
+
const cam = editCameraRef.current ?? { x: 0, y: 0, zoom: 1 };
|
|
77
|
+
const zoom = cam.zoom ?? 1;
|
|
78
|
+
setCamera((prev) =>
|
|
79
|
+
prev.x === cam.x && prev.y === cam.y && prev.zoom === zoom
|
|
80
|
+
? prev
|
|
81
|
+
: { x: cam.x, y: cam.y, zoom }
|
|
82
|
+
);
|
|
78
83
|
raf = requestAnimationFrame(tick);
|
|
79
84
|
};
|
|
80
85
|
raf = requestAnimationFrame(tick);
|
|
@@ -334,8 +339,13 @@ function getSelectionFrame(sceneData, actorIds, preferredRotation = null) {
|
|
|
334
339
|
}
|
|
335
340
|
|
|
336
341
|
function getOverlayGeometry(frame, box, camera) {
|
|
337
|
-
|
|
338
|
-
|
|
342
|
+
// Fold the edit zoom into the card-unit -> screen-px scale. The canvas draws
|
|
343
|
+
// the card at (box / card) * zoom px-per-unit (the effective viewport extent
|
|
344
|
+
// and origin both scale by 1/zoom), and the chrome layer's `transform-origin:
|
|
345
|
+
// 0 0` projection from world (0,0) matches once sx/sy carry the same zoom.
|
|
346
|
+
const zoom = camera.zoom ?? 1;
|
|
347
|
+
const sx = (box.width / cardSize.width) * zoom;
|
|
348
|
+
const sy = (box.height / cardSize.height) * zoom;
|
|
339
349
|
const camX = Math.round(camera.x);
|
|
340
350
|
const camY = Math.round(camera.y);
|
|
341
351
|
const rotation = frame.rotation;
|
|
@@ -1,7 +1,10 @@
|
|
|
1
|
-
import React, { useCallback, useEffect, useRef } from 'react';
|
|
1
|
+
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
|
2
|
+
import { createPortal } from 'react-dom';
|
|
2
3
|
import { configureSceneCanvas, makeScene } from './scene';
|
|
3
4
|
import { SceneUI } from './SceneUI';
|
|
4
5
|
import { TouchControls } from './TouchControls';
|
|
6
|
+
import { cx, Icon, styles } from './ui';
|
|
7
|
+
import { usePlayLogs } from './playConsole';
|
|
5
8
|
// Engine-level scene player: mount a `SceneRuntime` against a canvas, wire
|
|
6
9
|
// keyboard / pointer input, run the update+draw loop, and render the
|
|
7
10
|
// behavior-driven UI overlay. No game logic lives here -- behaviors and
|
|
@@ -9,8 +12,12 @@ import { TouchControls } from './TouchControls';
|
|
|
9
12
|
export function ScenePlayer({ sceneData, sprites, behaviorClasses, onFirstFrame }) {
|
|
10
13
|
const canvasRef = useRef(null);
|
|
11
14
|
const runtimeRef = useRef(null);
|
|
15
|
+
// Bumping this re-runs the mount effect below, which tears down the running
|
|
16
|
+
// loop/runtime and rebuilds a fresh scene from the same data -- a clean restart.
|
|
17
|
+
const [runToken, setRunToken] = useState(0);
|
|
12
18
|
const getKeys = useCallback(() => runtimeRef.current?.keys ?? null, []);
|
|
13
19
|
const getRuntime = useCallback(() => runtimeRef.current, []);
|
|
20
|
+
const restart = useCallback(() => setRunToken((token) => token + 1), []);
|
|
14
21
|
useEffect(() => {
|
|
15
22
|
const canvas = canvasRef.current;
|
|
16
23
|
if (!canvas) return undefined;
|
|
@@ -56,7 +63,9 @@ export function ScenePlayer({ sceneData, sprites, behaviorClasses, onFirstFrame
|
|
|
56
63
|
canvas.addEventListener('pointerup', onPointerUp);
|
|
57
64
|
canvas.addEventListener('pointercancel', onPointerUp);
|
|
58
65
|
canvas.focus();
|
|
59
|
-
|
|
66
|
+
// Only signal first-frame readiness on the initial run; a restart shouldn't
|
|
67
|
+
// re-fire the host launcher reveal.
|
|
68
|
+
const stopLoop = startPlayerLoop(canvas, ctx, runtime, runToken === 0 ? onFirstFrame : undefined);
|
|
60
69
|
return () => {
|
|
61
70
|
stopLoop();
|
|
62
71
|
window.removeEventListener('keydown', onKeyDown);
|
|
@@ -68,16 +77,157 @@ export function ScenePlayer({ sceneData, sprites, behaviorClasses, onFirstFrame
|
|
|
68
77
|
runtimeRef.current = null;
|
|
69
78
|
};
|
|
70
79
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
80
|
+
}, [runToken]);
|
|
81
|
+
return (
|
|
82
|
+
<>
|
|
83
|
+
<div style={{ position: 'fixed', inset: 0, background: '#000' }}>
|
|
84
|
+
<canvas
|
|
85
|
+
ref={canvasRef}
|
|
86
|
+
tabIndex={0}
|
|
87
|
+
style={{ width: '100%', height: '100%', display: 'block', outline: 'none' }}
|
|
88
|
+
/>
|
|
89
|
+
<SceneUI getRuntime={getRuntime} />
|
|
90
|
+
<TouchControls getKeys={getKeys} />
|
|
91
|
+
</div>
|
|
92
|
+
{/* Portal to <body> so the console drawer escapes the SDK's card-sized,
|
|
93
|
+
transformed `#root > *` wrapper and pins to the panel, outside the
|
|
94
|
+
play preview. */}
|
|
95
|
+
{createPortal(<PlayConsole onRestart={restart} />, document.body)}
|
|
96
|
+
</>
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
function PlayConsole({ onRestart }) {
|
|
100
|
+
const { logs, clearLogs } = usePlayLogs();
|
|
101
|
+
const [open, setOpen] = useState(false);
|
|
102
|
+
const rootRef = useRef(null);
|
|
103
|
+
const bodyRef = useRef(null);
|
|
104
|
+
// Reserve the drawer's footprint by shrinking + lifting the SDK deck card so it
|
|
105
|
+
// sits fully above the drawer (visible + interactive) instead of behind it.
|
|
106
|
+
// Driven by the live drawer height; recomputed on toggle (height change via the
|
|
107
|
+
// ResizeObserver) and on window/card resize.
|
|
108
|
+
useEffect(() => {
|
|
109
|
+
const el = rootRef.current;
|
|
110
|
+
if (!el) return undefined;
|
|
111
|
+
const docEl = document.documentElement;
|
|
112
|
+
docEl.dataset.castleLogs = '';
|
|
113
|
+
const TOP_MARGIN = 16;
|
|
114
|
+
const MIN_CARD_SCALE = 0.2;
|
|
115
|
+
const apply = () => {
|
|
116
|
+
const drawerHeight = el.offsetHeight;
|
|
117
|
+
const cs = getComputedStyle(docEl);
|
|
118
|
+
const fullW = parseFloat(cs.getPropertyValue('--castle-card-w'));
|
|
119
|
+
const fullH = parseFloat(cs.getPropertyValue('--castle-card-h'));
|
|
120
|
+
docEl.style.setProperty('--castle-logs-shift', `${drawerHeight / 2}px`);
|
|
121
|
+
// Only resize the card when we know its natural size; otherwise leave it to
|
|
122
|
+
// the SDK fallback (the rule's var() defaults handle this).
|
|
123
|
+
if (fullW > 0 && fullH > 0) {
|
|
124
|
+
const available = window.innerHeight - drawerHeight - TOP_MARGIN;
|
|
125
|
+
// Floor the scale so a very short panel (drawer taller than the area)
|
|
126
|
+
// can't drive the card to 0/negative and make the deck disappear.
|
|
127
|
+
const scale = Math.min(1, Math.max(MIN_CARD_SCALE, available / fullH));
|
|
128
|
+
docEl.style.setProperty('--castle-logs-card-w', `${fullW * scale}px`);
|
|
129
|
+
docEl.style.setProperty('--castle-logs-card-h', `${fullH * scale}px`);
|
|
130
|
+
} else {
|
|
131
|
+
docEl.style.removeProperty('--castle-logs-card-w');
|
|
132
|
+
docEl.style.removeProperty('--castle-logs-card-h');
|
|
133
|
+
}
|
|
134
|
+
};
|
|
135
|
+
apply();
|
|
136
|
+
const observer = new ResizeObserver(apply);
|
|
137
|
+
observer.observe(el);
|
|
138
|
+
window.addEventListener('resize', apply);
|
|
139
|
+
return () => {
|
|
140
|
+
observer.disconnect();
|
|
141
|
+
window.removeEventListener('resize', apply);
|
|
142
|
+
delete docEl.dataset.castleLogs;
|
|
143
|
+
docEl.style.removeProperty('--castle-logs-card-w');
|
|
144
|
+
docEl.style.removeProperty('--castle-logs-card-h');
|
|
145
|
+
docEl.style.removeProperty('--castle-logs-shift');
|
|
146
|
+
};
|
|
71
147
|
}, []);
|
|
148
|
+
// Keep the newest line in view while expanded.
|
|
149
|
+
useEffect(() => {
|
|
150
|
+
if (open && bodyRef.current) bodyRef.current.scrollTop = bodyRef.current.scrollHeight;
|
|
151
|
+
}, [logs, open]);
|
|
152
|
+
const copy = useCallback(() => {
|
|
153
|
+
const text = logs.map((line) => line.text).join('\n');
|
|
154
|
+
navigator.clipboard?.writeText(text).catch(() => {});
|
|
155
|
+
}, [logs]);
|
|
156
|
+
const hasLogs = logs.length > 0;
|
|
72
157
|
return (
|
|
73
|
-
<div
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
158
|
+
<div ref={rootRef} className={styles.playConsole}>
|
|
159
|
+
{/* The whole header row toggles; the action buttons stopPropagation so
|
|
160
|
+
they don't also collapse/expand. */}
|
|
161
|
+
<div
|
|
162
|
+
className={styles.playConsoleHeader}
|
|
163
|
+
role="button"
|
|
164
|
+
tabIndex={-1}
|
|
165
|
+
aria-expanded={open}
|
|
166
|
+
aria-label={open ? 'Collapse logs' : 'Expand logs'}
|
|
167
|
+
onClick={() => setOpen((value) => !value)}>
|
|
168
|
+
<span className={styles.playConsoleToggle}>
|
|
169
|
+
<span className={styles.playConsoleCaret}>
|
|
170
|
+
<Icon name={open ? 'chevron-down' : 'chevron-right'} />
|
|
171
|
+
</span>
|
|
172
|
+
<span className={styles.playConsoleTitle}>Logs</span>
|
|
173
|
+
</span>
|
|
174
|
+
<div
|
|
175
|
+
className={styles.playConsoleActions}
|
|
176
|
+
onClick={(event) => event.stopPropagation()}>
|
|
177
|
+
{open ? (
|
|
178
|
+
<>
|
|
179
|
+
<button
|
|
180
|
+
type="button"
|
|
181
|
+
tabIndex={-1}
|
|
182
|
+
className={styles.playConsoleBtn}
|
|
183
|
+
aria-label="Copy logs"
|
|
184
|
+
title="Copy logs"
|
|
185
|
+
disabled={!hasLogs}
|
|
186
|
+
onClick={copy}>
|
|
187
|
+
<Icon name="clone" />
|
|
188
|
+
</button>
|
|
189
|
+
<button
|
|
190
|
+
type="button"
|
|
191
|
+
tabIndex={-1}
|
|
192
|
+
className={styles.playConsoleBtn}
|
|
193
|
+
aria-label="Clear logs"
|
|
194
|
+
title="Clear logs"
|
|
195
|
+
disabled={!hasLogs}
|
|
196
|
+
onClick={clearLogs}>
|
|
197
|
+
<Icon name="trash" />
|
|
198
|
+
</button>
|
|
199
|
+
</>
|
|
200
|
+
) : null}
|
|
201
|
+
<button
|
|
202
|
+
type="button"
|
|
203
|
+
tabIndex={-1}
|
|
204
|
+
className={styles.playConsoleBtn}
|
|
205
|
+
aria-label="Restart"
|
|
206
|
+
title="Restart"
|
|
207
|
+
onClick={onRestart}>
|
|
208
|
+
<Icon name="rotate" />
|
|
209
|
+
</button>
|
|
210
|
+
</div>
|
|
211
|
+
</div>
|
|
212
|
+
{open ? (
|
|
213
|
+
<div ref={bodyRef} className={styles.playConsoleBody}>
|
|
214
|
+
{hasLogs ? (
|
|
215
|
+
logs.map((line) => (
|
|
216
|
+
<div
|
|
217
|
+
key={line.id}
|
|
218
|
+
className={cx(
|
|
219
|
+
styles.playConsoleLine,
|
|
220
|
+
line.level === 'warn' && styles.playConsoleLineWarn,
|
|
221
|
+
line.level === 'error' && styles.playConsoleLineError
|
|
222
|
+
)}>
|
|
223
|
+
{line.text}
|
|
224
|
+
</div>
|
|
225
|
+
))
|
|
226
|
+
) : (
|
|
227
|
+
<div className={styles.playConsoleEmpty}>no output</div>
|
|
228
|
+
)}
|
|
229
|
+
</div>
|
|
230
|
+
) : null}
|
|
81
231
|
</div>
|
|
82
232
|
);
|
|
83
233
|
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react';
|
|
2
|
+
// Captures console output from the running deck into an in-memory ring buffer and
|
|
3
|
+
// notifies subscribers, so the on-panel play console can display it.
|
|
4
|
+
//
|
|
5
|
+
// The SDK already wraps console (to forward logs to the dev server over its
|
|
6
|
+
// websocket); this wraps ON TOP and always calls through, so that forwarding and
|
|
7
|
+
// the real devtools output are preserved. Install is idempotent.
|
|
8
|
+
const MAX_LINES = 500;
|
|
9
|
+
let buffer = [];
|
|
10
|
+
let nextId = 1;
|
|
11
|
+
let installed = false;
|
|
12
|
+
const subscribers = new Set();
|
|
13
|
+
function formatArgs(args) {
|
|
14
|
+
return args
|
|
15
|
+
.map((arg) => {
|
|
16
|
+
if (typeof arg === 'string') return arg;
|
|
17
|
+
if (arg instanceof Error) return arg.stack || arg.message;
|
|
18
|
+
try {
|
|
19
|
+
const json = JSON.stringify(arg);
|
|
20
|
+
return json ?? String(arg);
|
|
21
|
+
} catch {
|
|
22
|
+
return String(arg);
|
|
23
|
+
}
|
|
24
|
+
})
|
|
25
|
+
.join(' ');
|
|
26
|
+
}
|
|
27
|
+
function emit(level, args) {
|
|
28
|
+
const entry = { id: nextId++, level, text: formatArgs(args) };
|
|
29
|
+
const next = buffer.concat(entry);
|
|
30
|
+
buffer = next.length > MAX_LINES ? next.slice(next.length - MAX_LINES) : next;
|
|
31
|
+
for (const cb of subscribers) cb(buffer);
|
|
32
|
+
}
|
|
33
|
+
export function installConsoleCapture() {
|
|
34
|
+
if (installed || typeof console === 'undefined') return;
|
|
35
|
+
installed = true;
|
|
36
|
+
for (const level of ['log', 'warn', 'error']) {
|
|
37
|
+
const original = typeof console[level] === 'function' ? console[level].bind(console) : null;
|
|
38
|
+
console[level] = (...args) => {
|
|
39
|
+
if (original) original(...args);
|
|
40
|
+
// Never let capture throw into the caller's logging path.
|
|
41
|
+
try {
|
|
42
|
+
emit(level, args);
|
|
43
|
+
} catch {
|
|
44
|
+
// ignore
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
export function subscribeLogs(callback) {
|
|
50
|
+
subscribers.add(callback);
|
|
51
|
+
callback(buffer);
|
|
52
|
+
return () => subscribers.delete(callback);
|
|
53
|
+
}
|
|
54
|
+
export function clearLogs() {
|
|
55
|
+
buffer = [];
|
|
56
|
+
for (const cb of subscribers) cb(buffer);
|
|
57
|
+
}
|
|
58
|
+
// React hook: install capture (idempotent) and track the live log buffer.
|
|
59
|
+
export function usePlayLogs() {
|
|
60
|
+
const [logs, setLogs] = useState(buffer);
|
|
61
|
+
useEffect(() => {
|
|
62
|
+
installConsoleCapture();
|
|
63
|
+
return subscribeLogs(setLogs);
|
|
64
|
+
}, []);
|
|
65
|
+
return { logs, clearLogs };
|
|
66
|
+
}
|
|
@@ -1901,3 +1901,120 @@
|
|
|
1901
1901
|
display: flex;
|
|
1902
1902
|
}
|
|
1903
1903
|
}
|
|
1904
|
+
|
|
1905
|
+
/* On-panel play console: a collapsible log drawer pinned to the bottom of the
|
|
1906
|
+
play panel (portaled to <body> so it sits outside the SDK's card wrapper).
|
|
1907
|
+
Houses the restart control plus copy/clear log affordances. */
|
|
1908
|
+
.playConsole {
|
|
1909
|
+
position: fixed;
|
|
1910
|
+
left: 0;
|
|
1911
|
+
right: 0;
|
|
1912
|
+
bottom: 0;
|
|
1913
|
+
z-index: 60;
|
|
1914
|
+
display: flex;
|
|
1915
|
+
flex-direction: column;
|
|
1916
|
+
border-top: 1px solid rgba(255, 255, 255, 0.12);
|
|
1917
|
+
background: rgba(13, 13, 13, 0.94);
|
|
1918
|
+
color: #e6e6e6;
|
|
1919
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono',
|
|
1920
|
+
'Courier New', monospace;
|
|
1921
|
+
padding-bottom: env(safe-area-inset-bottom, 0px);
|
|
1922
|
+
overflow: hidden;
|
|
1923
|
+
}
|
|
1924
|
+
.playConsoleHeader {
|
|
1925
|
+
display: flex;
|
|
1926
|
+
align-items: center;
|
|
1927
|
+
justify-content: space-between;
|
|
1928
|
+
gap: 8px;
|
|
1929
|
+
padding: 7px 8px 7px 10px;
|
|
1930
|
+
cursor: pointer;
|
|
1931
|
+
}
|
|
1932
|
+
.playConsoleHeader:hover {
|
|
1933
|
+
background: rgba(255, 255, 255, 0.04);
|
|
1934
|
+
}
|
|
1935
|
+
.playConsoleToggle {
|
|
1936
|
+
display: flex;
|
|
1937
|
+
align-items: center;
|
|
1938
|
+
gap: 8px;
|
|
1939
|
+
}
|
|
1940
|
+
.playConsoleCaret {
|
|
1941
|
+
display: inline-flex;
|
|
1942
|
+
font-size: 11px;
|
|
1943
|
+
color: #8a8a8a;
|
|
1944
|
+
}
|
|
1945
|
+
.playConsoleTitle {
|
|
1946
|
+
font-size: 11px;
|
|
1947
|
+
font-weight: 700;
|
|
1948
|
+
letter-spacing: 1px;
|
|
1949
|
+
text-transform: uppercase;
|
|
1950
|
+
color: #b3b3b3;
|
|
1951
|
+
}
|
|
1952
|
+
.playConsoleActions {
|
|
1953
|
+
display: flex;
|
|
1954
|
+
align-items: center;
|
|
1955
|
+
gap: 6px;
|
|
1956
|
+
}
|
|
1957
|
+
.playConsoleBtn {
|
|
1958
|
+
display: inline-flex;
|
|
1959
|
+
align-items: center;
|
|
1960
|
+
justify-content: center;
|
|
1961
|
+
width: 30px;
|
|
1962
|
+
height: 28px;
|
|
1963
|
+
padding: 0;
|
|
1964
|
+
font-size: 13px;
|
|
1965
|
+
color: #cfcfcf;
|
|
1966
|
+
background: transparent;
|
|
1967
|
+
border: 1px solid rgba(255, 255, 255, 0.14);
|
|
1968
|
+
border-radius: 6px;
|
|
1969
|
+
cursor: pointer;
|
|
1970
|
+
}
|
|
1971
|
+
.playConsoleBtn:hover {
|
|
1972
|
+
background: rgba(255, 255, 255, 0.08);
|
|
1973
|
+
color: #fff;
|
|
1974
|
+
}
|
|
1975
|
+
.playConsoleBtn:disabled {
|
|
1976
|
+
opacity: 0.38;
|
|
1977
|
+
cursor: default;
|
|
1978
|
+
}
|
|
1979
|
+
.playConsoleBtn:disabled:hover {
|
|
1980
|
+
background: transparent;
|
|
1981
|
+
color: #cfcfcf;
|
|
1982
|
+
}
|
|
1983
|
+
.playConsoleBody {
|
|
1984
|
+
border-top: 1px solid rgba(255, 255, 255, 0.08);
|
|
1985
|
+
height: 165px;
|
|
1986
|
+
overflow-y: auto;
|
|
1987
|
+
padding: 8px 10px;
|
|
1988
|
+
font-size: 12px;
|
|
1989
|
+
line-height: 1.5;
|
|
1990
|
+
}
|
|
1991
|
+
.playConsoleEmpty {
|
|
1992
|
+
color: #6a6a6a;
|
|
1993
|
+
}
|
|
1994
|
+
.playConsoleLine {
|
|
1995
|
+
white-space: pre-wrap;
|
|
1996
|
+
word-break: break-word;
|
|
1997
|
+
color: #c8c8c8;
|
|
1998
|
+
}
|
|
1999
|
+
.playConsoleLineWarn {
|
|
2000
|
+
color: #e0af68;
|
|
2001
|
+
}
|
|
2002
|
+
.playConsoleLineError {
|
|
2003
|
+
color: #ff7a93;
|
|
2004
|
+
}
|
|
2005
|
+
|
|
2006
|
+
/* While the logs drawer is mounted, scale the deck card to fit ABOVE the drawer
|
|
2007
|
+
(rather than sitting behind it) so play stays fully visible and interactive.
|
|
2008
|
+
The SDK's initPlayCard() pins `#root > *` as a viewport-centered card with
|
|
2009
|
+
!important; this higher-specificity (attribute + id) rule overrides its size
|
|
2010
|
+
and vertical offset. The three custom properties are driven from JS in
|
|
2011
|
+
PlayConsole using the live drawer height + the SDK's natural card size. */
|
|
2012
|
+
:global(html[data-castle-logs] #root > *) {
|
|
2013
|
+
width: var(--castle-logs-card-w, var(--castle-card-w, 100vw)) !important;
|
|
2014
|
+
height: var(--castle-logs-card-h, var(--castle-card-h, 100vh)) !important;
|
|
2015
|
+
transform: translate(-50%, calc(-50% - var(--castle-logs-shift, 0px))) !important;
|
|
2016
|
+
transition:
|
|
2017
|
+
transform 0.18s ease,
|
|
2018
|
+
width 0.18s ease,
|
|
2019
|
+
height 0.18s ease;
|
|
2020
|
+
}
|