From 0a9168b32044b0a7fe2b69302c2c7f5e52555c1a Mon Sep 17 00:00:00 2001 From: Ivan Fontosh Date: Sat, 29 Aug 2026 15:30:28 +0800 Subject: [PATCH] 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 --- .../project/zipStore.legacyContract.test.ts | 12 ++ app/main/project/zipStore.ts | 169 +++++++++--------- app/renderer/editor/PlayersModals.tsx | 9 +- app/renderer/sceneEditor/SceneEditorApp.tsx | 21 ++- .../playerToken/PlayerTokenView.module.css | 8 +- .../shared/playerToken/PlayerTokenView.tsx | 16 ++ .../TokenPathEditorApp.module.css | 8 + .../tokenPathEditor/TokenPathEditorApp.tsx | 12 +- app/shared/types/tokenPath.test.ts | 25 +++ app/shared/types/tokenPath.ts | 4 +- 10 files changed, 189 insertions(+), 95 deletions(-) diff --git a/app/main/project/zipStore.legacyContract.test.ts b/app/main/project/zipStore.legacyContract.test.ts index 610c4f5..272f858 100644 --- a/app/main/project/zipStore.legacyContract.test.ts +++ b/app/main/project/zipStore.legacyContract.test.ts @@ -85,6 +85,18 @@ void test('zipStore: NPC parse defaults externalUrl', () => { 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', () => { const src = fs.readFileSync(path.join(here, 'zipStore.ts'), 'utf8'); assert.match( diff --git a/app/main/project/zipStore.ts b/app/main/project/zipStore.ts index 0eadf37..c38c5e2 100644 --- a/app/main/project/zipStore.ts +++ b/app/main/project/zipStore.ts @@ -728,91 +728,98 @@ export class ZipProjectStore { async updateScene(sceneId: SceneId, patch: ScenePatch): Promise { const open = this.openProject; if (!open) throw new Error('No open project'); - const existing = open.project.scenes[sceneId]; - const base: Scene = - existing ?? - ({ - id: sceneId, - title: '', - description: '', - media: { videos: [], audios: [] }, - settings: { autoplayVideo: false, autoplayAudio: false, loopVideo: false, loopAudio: false }, - connections: [], - layout: { x: 0, y: 0 }, - previewAssetId: null, - previewAssetType: null, - previewThumbAssetId: null, - previewVideoAutostart: false, - previewRotationDeg: 0, - darkenScene: false, - traps: [], - tokens: [], - npcTokens: [], - grid: { ...DEFAULT_SCENE_GRID }, - } satisfies Scene); - const next: Scene = { - ...base, - traps: base.traps ?? [], - tokens: base.tokens ?? [], - npcTokens: base.npcTokens ?? [], - grid: base.grid ?? { ...DEFAULT_SCENE_GRID }, - ...(patch.title !== undefined ? { title: patch.title } : null), - ...(patch.description !== undefined ? { description: patch.description } : null), - ...(patch.previewAssetId !== undefined ? { previewAssetId: patch.previewAssetId } : null), - ...(patch.previewAssetType !== undefined ? { previewAssetType: patch.previewAssetType } : null), - ...(patch.previewThumbAssetId !== undefined - ? { previewThumbAssetId: patch.previewThumbAssetId } - : null), - ...(patch.previewVideoAutostart !== undefined - ? { previewVideoAutostart: patch.previewVideoAutostart } - : null), - ...(patch.previewRotationDeg !== undefined ? { previewRotationDeg: patch.previewRotationDeg } : null), - ...(patch.darkenScene !== undefined ? { darkenScene: patch.darkenScene } : null), - ...(patch.traps !== undefined - ? { - traps: patch.traps.map((t) => normalizeSceneTrap(t)).filter((t): t is SceneTrap => Boolean(t)), - } - : null), - ...(patch.tokens !== undefined - ? { - tokens: patch.tokens - .map((t) => normalizeSceneToken(t)) - .filter((t): t is SceneToken => Boolean(t)), - } - : null), - ...(patch.npcTokens !== undefined - ? { - npcTokens: patch.npcTokens - .map((t) => normalizeSceneNpcToken(t)) - .filter((t): t is SceneNpcToken => Boolean(t)), - } - : null), - ...(patch.grid !== undefined ? { grid: normalizeSceneGrid(patch.grid) } : null), - ...(patch.settings ? { settings: { ...base.settings, ...patch.settings } } : null), - ...(patch.media ? { media: { ...base.media, ...patch.media } } : null), - ...(patch.layout ? { layout: { ...base.layout, ...patch.layout } } : null), - }; + // 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 = + existing ?? + ({ + id: sceneId, + title: '', + description: '', + media: { videos: [], audios: [] }, + settings: { autoplayVideo: false, autoplayAudio: false, loopVideo: false, loopAudio: false }, + connections: [], + layout: { x: 0, y: 0 }, + previewAssetId: null, + previewAssetType: null, + previewThumbAssetId: null, + previewVideoAutostart: false, + previewRotationDeg: 0, + darkenScene: false, + traps: [], + tokens: [], + npcTokens: [], + grid: { ...DEFAULT_SCENE_GRID }, + } satisfies Scene); - // Keep map markers glued to the art when previewRotationDeg changes (image/video). - if (patch.previewRotationDeg !== undefined) { - const fromRot = asPreviewRotationDeg(base.previewRotationDeg); - const toRot = asPreviewRotationDeg(patch.previewRotationDeg); - const steps = previewRotationStepsCw(fromRot, toRot); - if (steps !== 0) { - if (patch.tokens === undefined) { - next.tokens = rotateSceneTokensByCwSteps(base.tokens ?? [], steps); - } - if (patch.npcTokens === undefined) { - next.npcTokens = rotateMapMarkersByCwSteps(base.npcTokens ?? [], steps); - } - if (patch.traps === undefined) { - next.traps = rotateMapMarkersByCwSteps(base.traps ?? [], steps); + const next: Scene = { + ...base, + traps: base.traps ?? [], + tokens: base.tokens ?? [], + npcTokens: base.npcTokens ?? [], + grid: base.grid ?? { ...DEFAULT_SCENE_GRID }, + ...(patch.title !== undefined ? { title: patch.title } : null), + ...(patch.description !== undefined ? { description: patch.description } : null), + ...(patch.previewAssetId !== undefined ? { previewAssetId: patch.previewAssetId } : null), + ...(patch.previewAssetType !== undefined ? { previewAssetType: patch.previewAssetType } : null), + ...(patch.previewThumbAssetId !== undefined + ? { previewThumbAssetId: patch.previewThumbAssetId } + : null), + ...(patch.previewVideoAutostart !== undefined + ? { previewVideoAutostart: patch.previewVideoAutostart } + : null), + ...(patch.previewRotationDeg !== undefined + ? { previewRotationDeg: patch.previewRotationDeg } + : null), + ...(patch.darkenScene !== undefined ? { darkenScene: patch.darkenScene } : null), + ...(patch.traps !== undefined + ? { + traps: patch.traps.map((t) => normalizeSceneTrap(t)).filter((t): t is SceneTrap => Boolean(t)), + } + : null), + ...(patch.tokens !== undefined + ? { + tokens: patch.tokens + .map((t) => normalizeSceneToken(t)) + .filter((t): t is SceneToken => Boolean(t)), + } + : null), + ...(patch.npcTokens !== undefined + ? { + npcTokens: patch.npcTokens + .map((t) => normalizeSceneNpcToken(t)) + .filter((t): t is SceneNpcToken => Boolean(t)), + } + : null), + ...(patch.grid !== undefined ? { grid: normalizeSceneGrid(patch.grid) } : null), + ...(patch.settings ? { settings: { ...base.settings, ...patch.settings } } : null), + ...(patch.media ? { media: { ...base.media, ...patch.media } } : null), + ...(patch.layout ? { layout: { ...base.layout, ...patch.layout } } : null), + }; + + // Keep map markers glued to the art when previewRotationDeg changes (image/video). + if (patch.previewRotationDeg !== undefined) { + const fromRot = asPreviewRotationDeg(base.previewRotationDeg); + const toRot = asPreviewRotationDeg(patch.previewRotationDeg); + const steps = previewRotationStepsCw(fromRot, toRot); + if (steps !== 0) { + if (patch.tokens === undefined) { + next.tokens = rotateSceneTokensByCwSteps(base.tokens ?? [], steps); + } + if (patch.npcTokens === undefined) { + next.npcTokens = rotateMapMarkersByCwSteps(base.npcTokens ?? [], steps); + } + if (patch.traps === undefined) { + next.traps = rotateMapMarkersByCwSteps(base.traps ?? [], steps); + } } } - } - await this.updateProject((p) => { + result = next; const scenes = { ...p.scenes, [sceneId]: next }; const sceneListOrder = existing != null @@ -820,7 +827,7 @@ export class ZipProjectStore { : prependSceneListOrder(reconcileSceneListOrder(p.scenes, p.sceneListOrder), sceneId); return { ...p, scenes, sceneListOrder }; }); - return next; + return result; } async updateConnections(sceneId: SceneId, connections: SceneId[]): Promise { diff --git a/app/renderer/editor/PlayersModals.tsx b/app/renderer/editor/PlayersModals.tsx index 00008cf..1b6f601 100644 --- a/app/renderer/editor/PlayersModals.tsx +++ b/app/renderer/editor/PlayersModals.tsx @@ -408,11 +408,10 @@ export function PlayersManagerModal({ open, onClose }: { open: boolean; onClose: - setTeamForm((prev) => - prev ? { ...prev, color: e.currentTarget.value } : prev, - ) - } + onChange={(e) => { + const next = e.currentTarget.value; + setTeamForm((prev) => (prev ? { ...prev, color: next } : prev)); + }} />
diff --git a/app/renderer/sceneEditor/SceneEditorApp.tsx b/app/renderer/sceneEditor/SceneEditorApp.tsx index 67d6e64..4208fce 100644 --- a/app/renderer/sceneEditor/SceneEditorApp.tsx +++ b/app/renderer/sceneEditor/SceneEditorApp.tsx @@ -700,10 +700,25 @@ export function SceneEditorApp() { !sceneId || (localTraps.length === 0 && localTokens.length === 0 && localNpcTokens.length === 0) } onClick={() => { - persistTraps([]); - persistTokens([]); - persistNpcTokens([]); + if (!sceneId) return; + if (saveTrapsTimerRef.current) window.clearTimeout(saveTrapsTimerRef.current); + 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); + // Single atomic patch — separate persist* calls raced and restored tokens/traps. + void api.invoke(ipcChannels.project.updateScene, { + sceneId, + patch: { traps: [], tokens: [], npcTokens: [] }, + }); }} > Очистить сцену diff --git a/app/renderer/shared/playerToken/PlayerTokenView.module.css b/app/renderer/shared/playerToken/PlayerTokenView.module.css index f63c888..47c58bf 100644 --- a/app/renderer/shared/playerToken/PlayerTokenView.module.css +++ b/app/renderer/shared/playerToken/PlayerTokenView.module.css @@ -34,13 +34,15 @@ 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 { position: absolute; left: 50%; top: 50%; - width: 140%; - height: 140%; - object-fit: cover; + max-width: none; + max-height: none; + object-fit: fill; pointer-events: none; } diff --git a/app/renderer/shared/playerToken/PlayerTokenView.tsx b/app/renderer/shared/playerToken/PlayerTokenView.tsx index c09b19f..5f81cca 100644 --- a/app/renderer/shared/playerToken/PlayerTokenView.tsx +++ b/app/renderer/shared/playerToken/PlayerTokenView.tsx @@ -22,6 +22,17 @@ function writeAvatarTransform( img.style.transform = `translate(-50%, -50%) translate(${String(offset.x * 100)}%, ${String(offset.y * 100)}%) scale(${String(scale)})`; } +/** Size the 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 = { name: string; imageUrl: string | null; @@ -134,6 +145,7 @@ export function PlayerTokenView({ }, [scale]); useLayoutEffect(() => { + syncAvatarCoverSize(imgRef.current, sizePx); writeAvatarTransform(imgRef.current, visualOffsetRef.current, visualScaleRef.current); }); @@ -259,6 +271,10 @@ export function PlayerTokenView({ alt="" draggable={false} style={inactive ? { filter: 'grayscale(1)', opacity: 0.85 } : undefined} + onLoad={() => { + syncAvatarCoverSize(imgRef.current, sizePx); + writeAvatarTransform(imgRef.current, visualOffsetRef.current, visualScaleRef.current); + }} /> ) : (
diff --git a/app/renderer/tokenPathEditor/TokenPathEditorApp.module.css b/app/renderer/tokenPathEditor/TokenPathEditorApp.module.css index 2d029cb..a0a14df 100644 --- a/app/renderer/tokenPathEditor/TokenPathEditorApp.module.css +++ b/app/renderer/tokenPathEditor/TokenPathEditorApp.module.css @@ -133,6 +133,14 @@ 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 { width: 100%; height: 100%; diff --git a/app/renderer/tokenPathEditor/TokenPathEditorApp.tsx b/app/renderer/tokenPathEditor/TokenPathEditorApp.tsx index 1e0e87e..a6a683d 100644 --- a/app/renderer/tokenPathEditor/TokenPathEditorApp.tsx +++ b/app/renderer/tokenPathEditor/TokenPathEditorApp.tsx @@ -86,12 +86,22 @@ function TokenPreview({ } if (kind === 'npcToken' && npcToken) { return ( -
+
diff --git a/app/shared/types/tokenPath.test.ts b/app/shared/types/tokenPath.test.ts index 1ee5a7c..84343e3 100644 --- a/app/shared/types/tokenPath.test.ts +++ b/app/shared/types/tokenPath.test.ts @@ -2,8 +2,10 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { + createEmptyTokenPath, findNearestAheadDistance, normalizeTokenPath, + normalizeTokenPathFacingMode, reverseTokenPathPoints, rotateTokenPathByCwSteps, sampleTokenPathAtProgress, @@ -12,6 +14,29 @@ import { TOKEN_PATH_MIN_POINT_DIST, } 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', () => { const a = tryAppendPathPoint([], { nx: 0.1, ny: 0.1 }); assert.ok(a); diff --git a/app/shared/types/tokenPath.ts b/app/shared/types/tokenPath.ts index afd0664..dc13264 100644 --- a/app/shared/types/tokenPath.ts +++ b/app/shared/types/tokenPath.ts @@ -56,13 +56,13 @@ export function createEmptyTokenPath(): TokenPath { durationSec: DEFAULT_TOKEN_PATH_DURATION_SEC, startMode: 'onEnter', delaySec: DEFAULT_TOKEN_PATH_DELAY_SEC, - facingMode: 'tangentSmooth', + facingMode: 'fixed', fixedRotationDeg: 0, }; } export function normalizeTokenPathFacingMode(raw: unknown): TokenPathFacingMode { - return raw === 'fixed' ? 'fixed' : 'tangentSmooth'; + return raw === 'tangentSmooth' ? 'tangentSmooth' : 'fixed'; } export function normalizeTokenPathLoopMode(raw: unknown, closed: boolean): TokenPathLoopMode {