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:
@@ -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}>
|
||||
|
||||
@@ -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 } : {})}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user