fix: scene clear race, path editor anchors, avatar cover, team color crash
Clear scene now patches traps/tokens/npcTokens atomically and updateScene merges against a fresh scene snapshot. Align NPC path-editor previews on token center, default new paths to fixed facing, size avatars by natural aspect for cover, and read team color synchronously from the picker. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -85,6 +85,18 @@ void test('zipStore: NPC parse defaults externalUrl', () => {
|
|||||||
assert.match(src, /externalUrl: normalizeNpcExternalUrl/);
|
assert.match(src, /externalUrl: normalizeNpcExternalUrl/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
void test('zipStore: updateScene merges patch inside updateProject mutator (no stale base race)', () => {
|
||||||
|
const src = fs.readFileSync(path.join(here, 'zipStore.ts'), 'utf8');
|
||||||
|
const i = src.indexOf('async updateScene(sceneId: SceneId, patch: ScenePatch)');
|
||||||
|
assert.ok(i >= 0);
|
||||||
|
const j = src.indexOf('async updateConnections(', i);
|
||||||
|
assert.ok(j > i);
|
||||||
|
const block = src.slice(i, j);
|
||||||
|
assert.match(block, /await this\.updateProject\(\(p\) => \{/);
|
||||||
|
assert.match(block, /const existing = p\.scenes\[sceneId\]/);
|
||||||
|
assert.doesNotMatch(block, /const existing = open\.project\.scenes\[sceneId\]/);
|
||||||
|
});
|
||||||
|
|
||||||
void test('zipStore: listProjects skips unreadable archives', () => {
|
void test('zipStore: listProjects skips unreadable archives', () => {
|
||||||
const src = fs.readFileSync(path.join(here, 'zipStore.ts'), 'utf8');
|
const src = fs.readFileSync(path.join(here, 'zipStore.ts'), 'utf8');
|
||||||
assert.match(
|
assert.match(
|
||||||
|
|||||||
@@ -728,7 +728,12 @@ export class ZipProjectStore {
|
|||||||
async updateScene(sceneId: SceneId, patch: ScenePatch): Promise<Scene> {
|
async updateScene(sceneId: SceneId, patch: ScenePatch): Promise<Scene> {
|
||||||
const open = this.openProject;
|
const open = this.openProject;
|
||||||
if (!open) throw new Error('No open project');
|
if (!open) throw new Error('No open project');
|
||||||
const existing = open.project.scenes[sceneId];
|
|
||||||
|
// Apply patch inside the mutator so concurrent updateScene calls (e.g. clear scene
|
||||||
|
// firing traps/tokens/npcTokens separately) each read a fresh scene snapshot.
|
||||||
|
let result!: Scene;
|
||||||
|
await this.updateProject((p) => {
|
||||||
|
const existing = p.scenes[sceneId];
|
||||||
const base: Scene =
|
const base: Scene =
|
||||||
existing ??
|
existing ??
|
||||||
({
|
({
|
||||||
@@ -767,7 +772,9 @@ export class ZipProjectStore {
|
|||||||
...(patch.previewVideoAutostart !== undefined
|
...(patch.previewVideoAutostart !== undefined
|
||||||
? { previewVideoAutostart: patch.previewVideoAutostart }
|
? { previewVideoAutostart: patch.previewVideoAutostart }
|
||||||
: null),
|
: null),
|
||||||
...(patch.previewRotationDeg !== undefined ? { previewRotationDeg: patch.previewRotationDeg } : null),
|
...(patch.previewRotationDeg !== undefined
|
||||||
|
? { previewRotationDeg: patch.previewRotationDeg }
|
||||||
|
: null),
|
||||||
...(patch.darkenScene !== undefined ? { darkenScene: patch.darkenScene } : null),
|
...(patch.darkenScene !== undefined ? { darkenScene: patch.darkenScene } : null),
|
||||||
...(patch.traps !== undefined
|
...(patch.traps !== undefined
|
||||||
? {
|
? {
|
||||||
@@ -812,7 +819,7 @@ export class ZipProjectStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.updateProject((p) => {
|
result = next;
|
||||||
const scenes = { ...p.scenes, [sceneId]: next };
|
const scenes = { ...p.scenes, [sceneId]: next };
|
||||||
const sceneListOrder =
|
const sceneListOrder =
|
||||||
existing != null
|
existing != null
|
||||||
@@ -820,7 +827,7 @@ export class ZipProjectStore {
|
|||||||
: prependSceneListOrder(reconcileSceneListOrder(p.scenes, p.sceneListOrder), sceneId);
|
: prependSceneListOrder(reconcileSceneListOrder(p.scenes, p.sceneListOrder), sceneId);
|
||||||
return { ...p, scenes, sceneListOrder };
|
return { ...p, scenes, sceneListOrder };
|
||||||
});
|
});
|
||||||
return next;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateConnections(sceneId: SceneId, connections: SceneId[]): Promise<Scene> {
|
async updateConnections(sceneId: SceneId, connections: SceneId[]): Promise<Scene> {
|
||||||
|
|||||||
@@ -408,11 +408,10 @@ export function PlayersManagerModal({ open, onClose }: { open: boolean; onClose:
|
|||||||
<input
|
<input
|
||||||
type="color"
|
type="color"
|
||||||
value={teamForm.color}
|
value={teamForm.color}
|
||||||
onChange={(e) =>
|
onChange={(e) => {
|
||||||
setTeamForm((prev) =>
|
const next = e.currentTarget.value;
|
||||||
prev ? { ...prev, color: e.currentTarget.value } : prev,
|
setTeamForm((prev) => (prev ? { ...prev, color: next } : prev));
|
||||||
)
|
}}
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
<div className={playerStyles.teamFormActions}>
|
<div className={playerStyles.teamFormActions}>
|
||||||
|
|||||||
@@ -700,10 +700,25 @@ export function SceneEditorApp() {
|
|||||||
!sceneId || (localTraps.length === 0 && localTokens.length === 0 && localNpcTokens.length === 0)
|
!sceneId || (localTraps.length === 0 && localTokens.length === 0 && localNpcTokens.length === 0)
|
||||||
}
|
}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
persistTraps([]);
|
if (!sceneId) return;
|
||||||
persistTokens([]);
|
if (saveTrapsTimerRef.current) window.clearTimeout(saveTrapsTimerRef.current);
|
||||||
persistNpcTokens([]);
|
if (saveTokensTimerRef.current) window.clearTimeout(saveTokensTimerRef.current);
|
||||||
|
if (saveNpcTokensTimerRef.current) window.clearTimeout(saveNpcTokensTimerRef.current);
|
||||||
|
saveTrapsTimerRef.current = 0;
|
||||||
|
saveTokensTimerRef.current = 0;
|
||||||
|
saveNpcTokensTimerRef.current = 0;
|
||||||
|
setLocalTraps([]);
|
||||||
|
setLocalTokens([]);
|
||||||
|
setLocalNpcTokens([]);
|
||||||
|
trapsRef.current = [];
|
||||||
|
tokensRef.current = [];
|
||||||
|
npcTokensRef.current = [];
|
||||||
setSelected(null);
|
setSelected(null);
|
||||||
|
// Single atomic patch — separate persist* calls raced and restored tokens/traps.
|
||||||
|
void api.invoke(ipcChannels.project.updateScene, {
|
||||||
|
sceneId,
|
||||||
|
patch: { traps: [], tokens: [], npcTokens: [] },
|
||||||
|
});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Очистить сцену
|
Очистить сцену
|
||||||
|
|||||||
@@ -34,13 +34,15 @@
|
|||||||
box-shadow: 0 0 0 3px var(--accent-fill-soft, rgba(167, 139, 250, 0.28));
|
box-shadow: 0 0 0 3px var(--accent-fill-soft, rgba(167, 139, 250, 0.28));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Width/height set in JS (syncAvatarCoverSize) for aspect-preserving cover.
|
||||||
|
Do not force a square img box — that cropped tall/wide photos before pan/zoom. */
|
||||||
.avatar {
|
.avatar {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 50%;
|
left: 50%;
|
||||||
top: 50%;
|
top: 50%;
|
||||||
width: 140%;
|
max-width: none;
|
||||||
height: 140%;
|
max-height: none;
|
||||||
object-fit: cover;
|
object-fit: fill;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,17 @@ function writeAvatarTransform(
|
|||||||
img.style.transform = `translate(-50%, -50%) translate(${String(offset.x * 100)}%, ${String(offset.y * 100)}%) scale(${String(scale)})`;
|
img.style.transform = `translate(-50%, -50%) translate(${String(offset.x * 100)}%, ${String(offset.y * 100)}%) scale(${String(scale)})`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Size the <img> so it covers the circle while keeping natural aspect (no square pre-crop). */
|
||||||
|
function syncAvatarCoverSize(img: HTMLImageElement | null, circlePx: number) {
|
||||||
|
if (!img || circlePx <= 0) return;
|
||||||
|
const nw = img.naturalWidth;
|
||||||
|
const nh = img.naturalHeight;
|
||||||
|
if (nw <= 0 || nh <= 0) return;
|
||||||
|
const cover = Math.max(circlePx / nw, circlePx / nh);
|
||||||
|
img.style.width = `${String(nw * cover)}px`;
|
||||||
|
img.style.height = `${String(nh * cover)}px`;
|
||||||
|
}
|
||||||
|
|
||||||
export type PlayerTokenViewProps = {
|
export type PlayerTokenViewProps = {
|
||||||
name: string;
|
name: string;
|
||||||
imageUrl: string | null;
|
imageUrl: string | null;
|
||||||
@@ -134,6 +145,7 @@ export function PlayerTokenView({
|
|||||||
}, [scale]);
|
}, [scale]);
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
|
syncAvatarCoverSize(imgRef.current, sizePx);
|
||||||
writeAvatarTransform(imgRef.current, visualOffsetRef.current, visualScaleRef.current);
|
writeAvatarTransform(imgRef.current, visualOffsetRef.current, visualScaleRef.current);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -259,6 +271,10 @@ export function PlayerTokenView({
|
|||||||
alt=""
|
alt=""
|
||||||
draggable={false}
|
draggable={false}
|
||||||
style={inactive ? { filter: 'grayscale(1)', opacity: 0.85 } : undefined}
|
style={inactive ? { filter: 'grayscale(1)', opacity: 0.85 } : undefined}
|
||||||
|
onLoad={() => {
|
||||||
|
syncAvatarCoverSize(imgRef.current, sizePx);
|
||||||
|
writeAvatarTransform(imgRef.current, visualOffsetRef.current, visualScaleRef.current);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className={[styles.avatarEmpty, emptyContent ? styles.avatarEmptySolid : ''].filter(Boolean).join(' ')}>
|
<div className={[styles.avatarEmpty, emptyContent ? styles.avatarEmptySolid : ''].filter(Boolean).join(' ')}>
|
||||||
|
|||||||
@@ -133,6 +133,14 @@
|
|||||||
background: rgba(0, 0, 0, 0.25);
|
background: rgba(0, 0, 0, 0.25);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* NPC: center-anchor like SceneEditor `.sceneNpcToken`; no box chrome (ring is on PlayerTokenView). */
|
||||||
|
.tokenPreviewNpc {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 3;
|
||||||
|
pointer-events: none;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
.tokenPreview img {
|
.tokenPreview img {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
|||||||
@@ -86,12 +86,22 @@ function TokenPreview({
|
|||||||
}
|
}
|
||||||
if (kind === 'npcToken' && npcToken) {
|
if (kind === 'npcToken' && npcToken) {
|
||||||
return (
|
return (
|
||||||
<div className={styles.tokenPreview} style={{ left, top, width: sizePx, height: sizePx }}>
|
<div
|
||||||
|
className={styles.tokenPreviewNpc}
|
||||||
|
style={{
|
||||||
|
left,
|
||||||
|
top,
|
||||||
|
width: sizePx,
|
||||||
|
height: sizePx,
|
||||||
|
transform: `translate(-50%, -50%) rotate(${String(rotationDeg)}deg)`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
<PlayerTokenView
|
<PlayerTokenView
|
||||||
name={npcName ?? ''}
|
name={npcName ?? ''}
|
||||||
imageUrl={npcAvatarUrl ?? null}
|
imageUrl={npcAvatarUrl ?? null}
|
||||||
ringColor={ringColor ?? '#888'}
|
ringColor={ringColor ?? '#888'}
|
||||||
sizePx={sizePx}
|
sizePx={sizePx}
|
||||||
|
hideName
|
||||||
{...(imageOffset ? { imageOffset } : {})}
|
{...(imageOffset ? { imageOffset } : {})}
|
||||||
{...(typeof imageScale === 'number' ? { imageScale } : {})}
|
{...(typeof imageScale === 'number' ? { imageScale } : {})}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -2,8 +2,10 @@ import assert from 'node:assert/strict';
|
|||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
createEmptyTokenPath,
|
||||||
findNearestAheadDistance,
|
findNearestAheadDistance,
|
||||||
normalizeTokenPath,
|
normalizeTokenPath,
|
||||||
|
normalizeTokenPathFacingMode,
|
||||||
reverseTokenPathPoints,
|
reverseTokenPathPoints,
|
||||||
rotateTokenPathByCwSteps,
|
rotateTokenPathByCwSteps,
|
||||||
sampleTokenPathAtProgress,
|
sampleTokenPathAtProgress,
|
||||||
@@ -12,6 +14,29 @@ import {
|
|||||||
TOKEN_PATH_MIN_POINT_DIST,
|
TOKEN_PATH_MIN_POINT_DIST,
|
||||||
} from './tokenPath';
|
} from './tokenPath';
|
||||||
|
|
||||||
|
void test('createEmptyTokenPath defaults to fixed facing (no auto-rotate)', () => {
|
||||||
|
const empty = createEmptyTokenPath();
|
||||||
|
assert.equal(empty.facingMode, 'fixed');
|
||||||
|
assert.equal(empty.fixedRotationDeg, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('normalizeTokenPathFacingMode: missing → fixed; explicit tangent kept', () => {
|
||||||
|
assert.equal(normalizeTokenPathFacingMode(undefined), 'fixed');
|
||||||
|
assert.equal(normalizeTokenPathFacingMode('fixed'), 'fixed');
|
||||||
|
assert.equal(normalizeTokenPathFacingMode('tangentSmooth'), 'tangentSmooth');
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('normalizeTokenPath defaults facingMode to fixed when omitted', () => {
|
||||||
|
const p = normalizeTokenPath({
|
||||||
|
points: [
|
||||||
|
{ nx: 0.1, ny: 0.1 },
|
||||||
|
{ nx: 0.5, ny: 0.1 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
assert.ok(p);
|
||||||
|
assert.equal(p!.facingMode, 'fixed');
|
||||||
|
});
|
||||||
|
|
||||||
void test('tryAppendPathPoint rejects double-click distance', () => {
|
void test('tryAppendPathPoint rejects double-click distance', () => {
|
||||||
const a = tryAppendPathPoint([], { nx: 0.1, ny: 0.1 });
|
const a = tryAppendPathPoint([], { nx: 0.1, ny: 0.1 });
|
||||||
assert.ok(a);
|
assert.ok(a);
|
||||||
|
|||||||
@@ -56,13 +56,13 @@ export function createEmptyTokenPath(): TokenPath {
|
|||||||
durationSec: DEFAULT_TOKEN_PATH_DURATION_SEC,
|
durationSec: DEFAULT_TOKEN_PATH_DURATION_SEC,
|
||||||
startMode: 'onEnter',
|
startMode: 'onEnter',
|
||||||
delaySec: DEFAULT_TOKEN_PATH_DELAY_SEC,
|
delaySec: DEFAULT_TOKEN_PATH_DELAY_SEC,
|
||||||
facingMode: 'tangentSmooth',
|
facingMode: 'fixed',
|
||||||
fixedRotationDeg: 0,
|
fixedRotationDeg: 0,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function normalizeTokenPathFacingMode(raw: unknown): TokenPathFacingMode {
|
export function normalizeTokenPathFacingMode(raw: unknown): TokenPathFacingMode {
|
||||||
return raw === 'fixed' ? 'fixed' : 'tangentSmooth';
|
return raw === 'tangentSmooth' ? 'tangentSmooth' : 'fixed';
|
||||||
}
|
}
|
||||||
|
|
||||||
export function normalizeTokenPathLoopMode(raw: unknown, closed: boolean): TokenPathLoopMode {
|
export function normalizeTokenPathLoopMode(raw: unknown, closed: boolean): TokenPathLoopMode {
|
||||||
|
|||||||
Reference in New Issue
Block a user