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:
Ivan Fontosh
2026-08-29 15:30:28 +08:00
parent 028c1be059
commit 0a9168b320
10 changed files with 189 additions and 95 deletions
+4 -5
View File
@@ -408,11 +408,10 @@ export function PlayersManagerModal({ open, onClose }: { open: boolean; onClose:
<input
type="color"
value={teamForm.color}
onChange={(e) =>
setTeamForm((prev) =>
prev ? { ...prev, color: e.currentTarget.value } : prev,
)
}
onChange={(e) => {
const next = e.currentTarget.value;
setTeamForm((prev) => (prev ? { ...prev, color: next } : prev));
}}
/>
</label>
<div className={playerStyles.teamFormActions}>
+18 -3
View File
@@ -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: [] },
});
}}
>
Очистить сцену
@@ -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;
}
@@ -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 <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 = {
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);
}}
/>
) : (
<div className={[styles.avatarEmpty, emptyContent ? styles.avatarEmptySolid : ''].filter(Boolean).join(' ')}>
@@ -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%;
@@ -86,12 +86,22 @@ function TokenPreview({
}
if (kind === 'npcToken' && npcToken) {
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
name={npcName ?? ''}
imageUrl={npcAvatarUrl ?? null}
ringColor={ringColor ?? '#888'}
sizePx={sizePx}
hideName
{...(imageOffset ? { imageOffset } : {})}
{...(typeof imageScale === 'number' ? { imageScale } : {})}
/>