fix(tokens): импорт неигровых токенов из zip и плавные пути на macOS
Полный импорт/открытие проекта подхватывает app-tokens. Движение по путям пишется в DOM без ре-рендера кадра, у пульта и презентации выключен background throttling. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -8,6 +8,8 @@
|
||||
|
||||
.token {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.25);
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import React, { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||
|
||||
import type { AppToken, SceneToken, SceneTokensSessionState } from '../../../shared/types';
|
||||
import { clampSceneTokenSizeN } from '../../../shared/types/appTokens';
|
||||
import { tokenPathKey } from '../../../shared/types/tokenPathSession';
|
||||
import { useLiveResizeBroadcast } from '../playerToken/useLiveDragBroadcast';
|
||||
|
||||
import { registerPathTokenNode, writeTokenPathTransform } from './tokenPathLiveDom';
|
||||
import { useTokenImageUrl } from './useTokenImageUrl';
|
||||
import styles from './SceneTokensOverlay.module.css';
|
||||
|
||||
@@ -106,18 +108,29 @@ function TokenSprite({
|
||||
};
|
||||
}, []);
|
||||
|
||||
const elRef = useRef<HTMLDivElement>(null);
|
||||
const posNx = localPos?.nx ?? nx;
|
||||
const posNy = localPos?.ny ?? ny;
|
||||
const shownSizeN = localSize ?? sizeN;
|
||||
const pathKey = tokenPathKey('token', String(placement.id));
|
||||
|
||||
const minDim = Math.min(viewport.w, viewport.h);
|
||||
const sizePx = Math.max(16, shownSizeN * minDim);
|
||||
const left = viewport.x + posNx * viewport.w;
|
||||
const top = viewport.y + posNy * viewport.h;
|
||||
const canDrag = editable && dragEnabled && Boolean(onMove);
|
||||
const canResize = editable && dragEnabled && Boolean(onResize);
|
||||
const interactive = canDrag || canResize || Boolean(onContextMenu);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
registerPathTokenNode(pathKey, elRef.current, viewport, true);
|
||||
return () => registerPathTokenNode(pathKey, null, viewport, true);
|
||||
}, [pathKey, viewport]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const el = elRef.current;
|
||||
if (!el || el.dataset.pathLive === '1') return;
|
||||
writeTokenPathTransform(el, posNx, posNy, rotationDeg, viewport, true);
|
||||
}, [posNx, posNy, rotationDeg, viewport]);
|
||||
|
||||
const hostToNorm = (clientX: number, clientY: number, host: HTMLElement) => {
|
||||
const r = host.getBoundingClientRect();
|
||||
const w = Math.max(1e-6, viewport.w);
|
||||
@@ -160,6 +173,7 @@ function TokenSprite({
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={elRef}
|
||||
className={[
|
||||
styles.token,
|
||||
canDrag ? styles.tokenEditable : '',
|
||||
@@ -168,11 +182,8 @@ function TokenSprite({
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
style={{
|
||||
left,
|
||||
top,
|
||||
width: sizePx,
|
||||
height: sizePx,
|
||||
transform: `translate(-50%, -50%) rotate(${String(rotationDeg)}deg)`,
|
||||
}}
|
||||
onContextMenu={
|
||||
onContextMenu
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { writeTokenPathTransform } from './tokenPathLiveDom';
|
||||
|
||||
void test('writeTokenPathTransform: GPU-friendly translate3d, not left/top', () => {
|
||||
const el = { style: { transform: '' } } as unknown as HTMLElement;
|
||||
writeTokenPathTransform(el, 0.25, 0.5, 45, { x: 10, y: 20, w: 200, h: 100 }, true);
|
||||
assert.equal(el.style.transform, 'translate3d(60px, 70px, 0) translate(-50%, -50%) rotate(45deg)');
|
||||
});
|
||||
|
||||
void test('writeTokenPathTransform: NPC without rotate keeps centering only', () => {
|
||||
const el = { style: { transform: '' } } as unknown as HTMLElement;
|
||||
writeTokenPathTransform(el, 0, 0, 90, { x: 0, y: 0, w: 100, h: 100 }, false);
|
||||
assert.equal(el.style.transform, 'translate3d(0px, 0px, 0) translate(-50%, -50%)');
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
/** Direct DOM transforms for path playback — avoids React re-renders on every RAF tick. */
|
||||
|
||||
export type TokenPathViewport = { x: number; y: number; w: number; h: number };
|
||||
|
||||
type NodeReg = {
|
||||
el: HTMLElement;
|
||||
viewport: TokenPathViewport;
|
||||
rotate: boolean;
|
||||
};
|
||||
|
||||
const registry = new Map<string, NodeReg>();
|
||||
|
||||
export function writeTokenPathTransform(
|
||||
el: HTMLElement,
|
||||
nx: number,
|
||||
ny: number,
|
||||
rotationDeg: number,
|
||||
viewport: TokenPathViewport,
|
||||
rotate: boolean,
|
||||
): void {
|
||||
const x = viewport.x + nx * viewport.w;
|
||||
const y = viewport.y + ny * viewport.h;
|
||||
const rot = rotate ? ` rotate(${String(rotationDeg)}deg)` : '';
|
||||
el.style.transform = `translate3d(${String(x)}px, ${String(y)}px, 0) translate(-50%, -50%)${rot}`;
|
||||
}
|
||||
|
||||
export function registerPathTokenNode(
|
||||
key: string,
|
||||
el: HTMLElement | null,
|
||||
viewport: TokenPathViewport,
|
||||
rotate: boolean,
|
||||
): void {
|
||||
if (!el) {
|
||||
registry.delete(key);
|
||||
return;
|
||||
}
|
||||
registry.set(key, { el, viewport, rotate });
|
||||
}
|
||||
|
||||
export function applyLivePathPose(
|
||||
key: string,
|
||||
nx: number,
|
||||
ny: number,
|
||||
rotationDeg: number,
|
||||
animating: boolean,
|
||||
): void {
|
||||
const n = registry.get(key);
|
||||
if (!n) return;
|
||||
n.el.dataset.pathLive = '1';
|
||||
n.el.style.willChange = animating ? 'transform' : '';
|
||||
writeTokenPathTransform(n.el, nx, ny, rotationDeg, n.viewport, n.rotate);
|
||||
}
|
||||
|
||||
export function endLivePathPose(key: string): void {
|
||||
const n = registry.get(key);
|
||||
if (!n) return;
|
||||
delete n.el.dataset.pathLive;
|
||||
n.el.style.willChange = '';
|
||||
}
|
||||
|
||||
export function endAllLivePathPoses(): void {
|
||||
for (const key of [...registry.keys()]) {
|
||||
endLivePathPose(key);
|
||||
}
|
||||
}
|
||||
@@ -1,58 +1,17 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
import { computePathPlaybackSample } from '../../../shared/types/tokenPathPlayback';
|
||||
import {
|
||||
tokenPathKey,
|
||||
type TokenPathPlaybackPhase,
|
||||
type TokenPathSessionState,
|
||||
} from '../../../shared/types/tokenPathSession';
|
||||
import type { SceneNpcToken, SceneToken } from '../../../shared/types';
|
||||
|
||||
export type TokenPathLivePose = {
|
||||
nx: number;
|
||||
ny: number;
|
||||
rotationDeg: number;
|
||||
phase: TokenPathPlaybackPhase;
|
||||
dist: number;
|
||||
};
|
||||
|
||||
type PoseMaps = {
|
||||
tokenPoses: Record<string, TokenPathLivePose>;
|
||||
npcPoses: Record<string, TokenPathLivePose>;
|
||||
};
|
||||
|
||||
const EMPTY: PoseMaps = { tokenPoses: {}, npcPoses: {} };
|
||||
|
||||
function posesEqual(a: PoseMaps, b: PoseMaps): boolean {
|
||||
const aT = a.tokenPoses;
|
||||
const bT = b.tokenPoses;
|
||||
const aN = a.npcPoses;
|
||||
const bN = b.npcPoses;
|
||||
const aTk = Object.keys(aT);
|
||||
const bTk = Object.keys(bT);
|
||||
const aNk = Object.keys(aN);
|
||||
const bNk = Object.keys(bN);
|
||||
if (aTk.length !== bTk.length || aNk.length !== bNk.length) return false;
|
||||
for (const k of aTk) {
|
||||
const x = aT[k];
|
||||
const y = bT[k];
|
||||
if (!y || x!.nx !== y.nx || x!.ny !== y.ny || x!.rotationDeg !== y.rotationDeg || x!.phase !== y.phase) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
for (const k of aNk) {
|
||||
const x = aN[k];
|
||||
const y = bN[k];
|
||||
if (!y || x!.nx !== y.nx || x!.ny !== y.ny || x!.rotationDeg !== y.rotationDeg || x!.phase !== y.phase) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
import { applyLivePathPose, endAllLivePathPoses, endLivePathPose } from './tokenPathLiveDom';
|
||||
|
||||
/**
|
||||
* RAF poses for control/presentation while path playback is active.
|
||||
* When phase === 'stopped', pose is omitted so session/placement (drag) wins.
|
||||
* Writes transforms on registered token DOM nodes — no React setState per frame.
|
||||
*
|
||||
* Uses local Date.now() — segmentStartedAtMs is also wall-clock from main (same machine).
|
||||
* Do NOT clamp to stale serverNowMs: that froze motion after ~250ms until the next IPC bump.
|
||||
@@ -63,10 +22,10 @@ export function useTokenPathLivePoses(args: {
|
||||
pathSession: TokenPathSessionState | null;
|
||||
enabled?: boolean;
|
||||
onMarkDone?: (kind: 'token' | 'npcToken', placementId: string, atDist: number) => void;
|
||||
}): PoseMaps {
|
||||
}): void {
|
||||
const { tokens, npcTokens, pathSession, enabled = true, onMarkDone } = args;
|
||||
const [poses, setPoses] = useState<PoseMaps>(EMPTY);
|
||||
const markedDoneRef = useRef<Set<string>>(new Set());
|
||||
const liveKeysRef = useRef<Set<string>>(new Set());
|
||||
const onMarkDoneRef = useRef(onMarkDone);
|
||||
onMarkDoneRef.current = onMarkDone;
|
||||
|
||||
@@ -83,7 +42,8 @@ export function useTokenPathLivePoses(args: {
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) {
|
||||
setPoses(EMPTY);
|
||||
endAllLivePathPoses();
|
||||
liveKeysRef.current.clear();
|
||||
return;
|
||||
}
|
||||
let raf = 0;
|
||||
@@ -92,55 +52,51 @@ export function useTokenPathLivePoses(args: {
|
||||
const tick = () => {
|
||||
if (!alive) return;
|
||||
const session = pathSessionRef.current;
|
||||
if (!session) {
|
||||
setPoses((prev) => (prev === EMPTY || Object.keys(prev.tokenPoses).length + Object.keys(prev.npcPoses).length === 0 ? prev : EMPTY));
|
||||
raf = requestAnimationFrame(tick);
|
||||
return;
|
||||
}
|
||||
const nextKeys = new Set<string>();
|
||||
if (session) {
|
||||
const sampleNow = Date.now();
|
||||
|
||||
// Wall clock: matches main's Date.now() for segmentStartedAtMs (Electron, one host).
|
||||
const sampleNow = Date.now();
|
||||
const tokenPoses: Record<string, TokenPathLivePose> = {};
|
||||
const npcPoses: Record<string, TokenPathLivePose> = {};
|
||||
|
||||
const sampleOne = (
|
||||
kind: 'token' | 'npcToken',
|
||||
placementId: string,
|
||||
path: NonNullable<SceneToken['path']>,
|
||||
out: Record<string, TokenPathLivePose>,
|
||||
) => {
|
||||
const key = tokenPathKey(kind, placementId);
|
||||
const entry = session.playback[key];
|
||||
if (!entry) return;
|
||||
if (entry.phase === 'stopped') return;
|
||||
const result = computePathPlaybackSample({ path, entry, nowMs: sampleNow });
|
||||
if (!result) return;
|
||||
out[placementId] = {
|
||||
nx: result.sample.nx,
|
||||
ny: result.sample.ny,
|
||||
rotationDeg: result.sample.rotationDeg,
|
||||
phase: result.phase,
|
||||
dist: result.sample.dist,
|
||||
const sampleOne = (
|
||||
kind: 'token' | 'npcToken',
|
||||
placementId: string,
|
||||
path: NonNullable<SceneToken['path']>,
|
||||
) => {
|
||||
const key = tokenPathKey(kind, placementId);
|
||||
const entry = session.playback[key];
|
||||
if (!entry) return;
|
||||
if (entry.phase === 'stopped') return;
|
||||
const result = computePathPlaybackSample({ path, entry, nowMs: sampleNow });
|
||||
if (!result) return;
|
||||
nextKeys.add(key);
|
||||
applyLivePathPose(
|
||||
key,
|
||||
result.sample.nx,
|
||||
result.sample.ny,
|
||||
result.sample.rotationDeg,
|
||||
entry.phase === 'moving' || entry.phase === 'delay',
|
||||
);
|
||||
if (result.markDone && !markedDoneRef.current.has(key)) {
|
||||
markedDoneRef.current.add(key);
|
||||
onMarkDoneRef.current?.(kind, placementId, result.sample.dist);
|
||||
}
|
||||
};
|
||||
if (result.markDone && !markedDoneRef.current.has(key)) {
|
||||
markedDoneRef.current.add(key);
|
||||
onMarkDoneRef.current?.(kind, placementId, result.sample.dist);
|
||||
}
|
||||
};
|
||||
|
||||
for (const t of tokensRef.current) {
|
||||
if (t.path && t.path.points.length >= 2) {
|
||||
sampleOne('token', String(t.id), t.path, tokenPoses);
|
||||
for (const t of tokensRef.current) {
|
||||
if (t.path && t.path.points.length >= 2) {
|
||||
sampleOne('token', String(t.id), t.path);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const t of npcTokensRef.current) {
|
||||
if (t.path && t.path.points.length >= 2) {
|
||||
sampleOne('npcToken', String(t.id), t.path, npcPoses);
|
||||
for (const t of npcTokensRef.current) {
|
||||
if (t.path && t.path.points.length >= 2) {
|
||||
sampleOne('npcToken', String(t.id), t.path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const next: PoseMaps = { tokenPoses, npcPoses };
|
||||
setPoses((prev) => (posesEqual(prev, next) ? prev : next));
|
||||
for (const key of liveKeysRef.current) {
|
||||
if (!nextKeys.has(key)) endLivePathPose(key);
|
||||
}
|
||||
liveKeysRef.current = nextKeys;
|
||||
raf = requestAnimationFrame(tick);
|
||||
};
|
||||
|
||||
@@ -148,8 +104,10 @@ export function useTokenPathLivePoses(args: {
|
||||
return () => {
|
||||
alive = false;
|
||||
cancelAnimationFrame(raf);
|
||||
for (const key of liveKeysRef.current) {
|
||||
endLivePathPose(key);
|
||||
}
|
||||
liveKeysRef.current.clear();
|
||||
};
|
||||
}, [enabled]);
|
||||
|
||||
return poses;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user