feat(tokens): animated paths for scene and NPC tokens
Add path editor window, session playback on control/presentation, and RMB controls. Fix live pose clock so motion no longer freezes after ~250ms. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,183 @@
|
||||
.page {
|
||||
display: grid;
|
||||
grid-template-columns: 280px 1fr;
|
||||
height: 100vh;
|
||||
width: 100vw;
|
||||
overflow: hidden;
|
||||
background: var(--bg, #12141a);
|
||||
color: var(--text, #e8eaef);
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
border-right: 1px solid var(--stroke, #2a2f3a);
|
||||
padding: 12px;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-weight: 800;
|
||||
font-size: 14px;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.hint {
|
||||
font-size: 12px;
|
||||
opacity: 0.7;
|
||||
line-height: 1.4;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
margin-top: 4px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.actions > * {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
display: flex;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.actions button {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.meta {
|
||||
font-size: 11px;
|
||||
opacity: 0.65;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.main {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
position: relative;
|
||||
background: #0b0d12;
|
||||
}
|
||||
|
||||
.host {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
overflow: hidden;
|
||||
cursor: crosshair;
|
||||
touch-action: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.empty {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
height: 100%;
|
||||
padding: 24px;
|
||||
text-align: center;
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.pathSvg {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.pathLine {
|
||||
stroke: rgba(90, 210, 255, 0.95);
|
||||
stroke-width: 2.5;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
stroke-dasharray: 8 5;
|
||||
}
|
||||
|
||||
.point {
|
||||
position: absolute;
|
||||
z-index: 4;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.55);
|
||||
background: rgba(20, 90, 140, 0.92);
|
||||
color: #fff;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
transform: translate(-50%, -50%);
|
||||
cursor: grab;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.point:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.tokenPreview {
|
||||
position: absolute;
|
||||
z-index: 3;
|
||||
pointer-events: none;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(255, 255, 255, 0.28);
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
.tokenPreview img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.ctxMenuBackdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 40;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.ctxMenu {
|
||||
position: fixed;
|
||||
z-index: 41;
|
||||
min-width: 160px;
|
||||
padding: 4px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--stroke, #2a2f3a);
|
||||
background: #1a1e27;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.45);
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.ctxItem,
|
||||
.ctxItemDanger {
|
||||
text-align: left;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
padding: 8px 10px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.ctxItemDanger {
|
||||
color: #ff8f8f;
|
||||
}
|
||||
|
||||
.ctxItem:hover,
|
||||
.ctxItemDanger:hover {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
@@ -0,0 +1,679 @@
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
import { ipcChannels, type SessionState } from '../../shared/ipc/contracts';
|
||||
import type { SceneNpcToken, SceneToken, TokenPath, TokenPathPoint } from '../../shared/types';
|
||||
import {
|
||||
createEmptyTokenPath,
|
||||
normalizeTokenPath,
|
||||
reverseTokenPathPoints,
|
||||
sampleTokenPathAtProgress,
|
||||
tokenPathPolyline,
|
||||
tryAppendPathPoint,
|
||||
} from '../../shared/types/tokenPath';
|
||||
import type { TokenPathTargetKind } from '../../shared/types/tokenPathSession';
|
||||
import { USERS_BRANCH_FEATURES_ENABLED } from '../../shared/features/usersBranchFeatures';
|
||||
import { getDndApi } from '../shared/dndApi';
|
||||
import { ContainedVideo } from '../shared/ContainedVideo';
|
||||
import { RotatedImage } from '../shared/RotatedImage';
|
||||
import { useAppTokens } from '../shared/tokens/useAppTokens';
|
||||
import { useTokenImageUrl } from '../shared/tokens/useTokenImageUrl';
|
||||
import { PlayerTokenView } from '../shared/playerToken/PlayerTokenView';
|
||||
import { normalizeNpcDisposition, npcDispositionRingColor } from '../../shared/types/npcDisposition';
|
||||
import { sceneGridTokenFitFactor } from '../../shared/types/sceneGrid';
|
||||
import { useAssetUrl } from '../shared/useAssetImageUrl';
|
||||
import { Button, Input, Select } from '../shared/ui/controls';
|
||||
|
||||
import styles from './TokenPathEditorApp.module.css';
|
||||
|
||||
type Target = { kind: TokenPathTargetKind; placementId: string };
|
||||
|
||||
type Draft = TokenPath;
|
||||
|
||||
type PointMenu = { x: number; y: number; index: number };
|
||||
|
||||
function cloneDraft(path: TokenPath | null | undefined): Draft {
|
||||
if (!path) return createEmptyTokenPath();
|
||||
return {
|
||||
...path,
|
||||
points: path.points.map((p) => ({ ...p })),
|
||||
};
|
||||
}
|
||||
|
||||
function TokenPreview({
|
||||
kind,
|
||||
token,
|
||||
npcToken,
|
||||
npcName,
|
||||
npcAvatarUrl,
|
||||
ringColor,
|
||||
imageOffset,
|
||||
imageScale,
|
||||
left,
|
||||
top,
|
||||
sizePx,
|
||||
rotationDeg,
|
||||
}: {
|
||||
kind: TokenPathTargetKind;
|
||||
token?: SceneToken;
|
||||
npcToken?: SceneNpcToken;
|
||||
npcName?: string;
|
||||
npcAvatarUrl?: string | null;
|
||||
ringColor?: string;
|
||||
imageOffset?: { x: number; y: number };
|
||||
imageScale?: number;
|
||||
left: number;
|
||||
top: number;
|
||||
sizePx: number;
|
||||
rotationDeg: number;
|
||||
}) {
|
||||
const url = useTokenImageUrl(token?.tokenId ?? null);
|
||||
if (kind === 'token' && token) {
|
||||
return (
|
||||
<div
|
||||
className={styles.tokenPreview}
|
||||
style={{
|
||||
left,
|
||||
top,
|
||||
width: sizePx,
|
||||
height: sizePx,
|
||||
transform: `translate(-50%, -50%) rotate(${String(rotationDeg)}deg)`,
|
||||
}}
|
||||
>
|
||||
{url ? <img src={url} alt="" draggable={false} /> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (kind === 'npcToken' && npcToken) {
|
||||
return (
|
||||
<div className={styles.tokenPreview} style={{ left, top, width: sizePx, height: sizePx }}>
|
||||
<PlayerTokenView
|
||||
name={npcName ?? ''}
|
||||
imageUrl={npcAvatarUrl ?? null}
|
||||
ringColor={ringColor ?? '#888'}
|
||||
sizePx={sizePx}
|
||||
{...(imageOffset ? { imageOffset } : {})}
|
||||
{...(typeof imageScale === 'number' ? { imageScale } : {})}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function TokenPathEditorApp() {
|
||||
const api = getDndApi();
|
||||
const appTokens = useAppTokens();
|
||||
const [session, setSession] = useState<SessionState | null>(null);
|
||||
const [target, setTarget] = useState<Target | null>(null);
|
||||
const [draft, setDraft] = useState<Draft>(createEmptyTokenPath());
|
||||
const [contentRect, setContentRect] = useState<{ x: number; y: number; w: number; h: number } | null>(
|
||||
null,
|
||||
);
|
||||
const [pointMenu, setPointMenu] = useState<PointMenu | null>(null);
|
||||
const [previewPlaying, setPreviewPlaying] = useState(false);
|
||||
const [previewU, setPreviewU] = useState(0);
|
||||
const [dirty, setDirty] = useState(false);
|
||||
const [status, setStatus] = useState<string | null>(null);
|
||||
const hostRef = useRef<HTMLDivElement | null>(null);
|
||||
const dragPointRef = useRef<{ index: number; pointerId: number } | null>(null);
|
||||
const saveTimerRef = useRef(0);
|
||||
const draftRef = useRef(draft);
|
||||
draftRef.current = draft;
|
||||
|
||||
const project = session?.project ?? null;
|
||||
const sceneId = project?.currentSceneId ?? null;
|
||||
const scene = sceneId && project ? project.scenes[sceneId] : undefined;
|
||||
const url = useAssetUrl(scene?.previewAssetId ?? null);
|
||||
const rot = scene?.previewRotationDeg ?? 0;
|
||||
const isImage = scene?.previewAssetType === 'image';
|
||||
const isVideo = scene?.previewAssetType === 'video';
|
||||
|
||||
const placement = useMemo(() => {
|
||||
if (!target || !scene) return null;
|
||||
if (target.kind === 'token') {
|
||||
return (scene.tokens ?? []).find((t) => String(t.id) === target.placementId) ?? null;
|
||||
}
|
||||
return (scene.npcTokens ?? []).find((t) => String(t.id) === target.placementId) ?? null;
|
||||
}, [scene, target]);
|
||||
|
||||
const npcMeta = useMemo(() => {
|
||||
if (!target || target.kind !== 'npcToken' || !placement || !('npcId' in placement)) return null;
|
||||
const npc = project?.npcs.find((n) => n.id === placement.npcId);
|
||||
return npc ?? null;
|
||||
}, [placement, project?.npcs, target]);
|
||||
|
||||
const npcAvatarUrl = useAssetUrl(npcMeta?.avatarAssetId ?? null);
|
||||
|
||||
useEffect(() => {
|
||||
void api.invoke(ipcChannels.project.get, {}).then(({ project: p }) => {
|
||||
setSession({ project: p, currentSceneId: p?.currentSceneId ?? null });
|
||||
});
|
||||
return api.on(ipcChannels.session.stateChanged, ({ state }) => setSession(state));
|
||||
}, [api]);
|
||||
|
||||
useEffect(() => {
|
||||
void api.invoke(ipcChannels.windows.getTokenPathEditorTarget, {}).then((t) => {
|
||||
setTarget(t);
|
||||
});
|
||||
return api.on(ipcChannels.windows.tokenPathEditorTargetChanged, (t) => {
|
||||
setTarget(t);
|
||||
});
|
||||
}, [api]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!placement) {
|
||||
setDraft(createEmptyTokenPath());
|
||||
setDirty(false);
|
||||
return;
|
||||
}
|
||||
setDraft(cloneDraft(placement.path ?? null));
|
||||
setDirty(false);
|
||||
setPreviewPlaying(false);
|
||||
setPreviewU(0);
|
||||
setStatus(null);
|
||||
}, [placement?.id, target?.kind, target?.placementId]);
|
||||
|
||||
const persistDraft = useCallback(
|
||||
(next: Draft, immediate = false) => {
|
||||
if (!sceneId || !target) return;
|
||||
const normalized = normalizeTokenPath(next);
|
||||
const run = () => {
|
||||
if (target.kind === 'token') {
|
||||
const tokens = (scene?.tokens ?? []).map((t) =>
|
||||
String(t.id) === target.placementId ? { ...t, path: normalized } : t,
|
||||
);
|
||||
void api.invoke(ipcChannels.project.updateScene, { sceneId, patch: { tokens } });
|
||||
} else {
|
||||
const npcTokens = (scene?.npcTokens ?? []).map((t) =>
|
||||
String(t.id) === target.placementId ? { ...t, path: normalized } : t,
|
||||
);
|
||||
void api.invoke(ipcChannels.project.updateScene, { sceneId, patch: { npcTokens } });
|
||||
}
|
||||
setDirty(false);
|
||||
setStatus(normalized ? 'Сохранено' : 'Путь очищен (нужно ≥2 точки)');
|
||||
};
|
||||
if (saveTimerRef.current) window.clearTimeout(saveTimerRef.current);
|
||||
if (immediate) {
|
||||
run();
|
||||
return;
|
||||
}
|
||||
saveTimerRef.current = window.setTimeout(run, 180);
|
||||
},
|
||||
[api, scene?.npcTokens, scene?.tokens, sceneId, target],
|
||||
);
|
||||
|
||||
const updateDraft = useCallback(
|
||||
(updater: (prev: Draft) => Draft, opts?: { save?: boolean; immediate?: boolean }) => {
|
||||
setDraft((prev) => {
|
||||
const next = updater(prev);
|
||||
draftRef.current = next;
|
||||
if (opts?.save !== false) {
|
||||
setDirty(true);
|
||||
persistDraft(next, opts?.immediate);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[persistDraft],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!previewPlaying) return;
|
||||
const started = performance.now();
|
||||
const durationMs = Math.max(0.5, draft.durationSec) * 1000;
|
||||
const loopMode = draft.loopMode;
|
||||
const closed = draft.closed;
|
||||
let raf = 0;
|
||||
let stopped = false;
|
||||
const tick = (now: number) => {
|
||||
if (stopped) return;
|
||||
const elapsed = Math.max(0, now - started);
|
||||
let u = 0;
|
||||
if (loopMode === 'pingpong') {
|
||||
const period = Math.max(durationMs * 2, 1e-9);
|
||||
let t = elapsed % period;
|
||||
if (t > durationMs) t = period - t;
|
||||
u = t / durationMs;
|
||||
} else if (loopMode === 'loop' && closed) {
|
||||
u = (elapsed % durationMs) / durationMs;
|
||||
} else {
|
||||
// once (и loop без замыкания)
|
||||
u = Math.min(1, elapsed / durationMs);
|
||||
setPreviewU(u);
|
||||
if (u >= 1) {
|
||||
stopped = true;
|
||||
setPreviewPlaying(false);
|
||||
return;
|
||||
}
|
||||
raf = requestAnimationFrame(tick);
|
||||
return;
|
||||
}
|
||||
setPreviewU(u);
|
||||
raf = requestAnimationFrame(tick);
|
||||
};
|
||||
raf = requestAnimationFrame(tick);
|
||||
return () => {
|
||||
stopped = true;
|
||||
cancelAnimationFrame(raf);
|
||||
};
|
||||
}, [previewPlaying, draft.durationSec, draft.points, draft.closed, draft.loopMode]);
|
||||
|
||||
const hostToNorm = useCallback(
|
||||
(clientX: number, clientY: number) => {
|
||||
const host = hostRef.current;
|
||||
if (!host || !contentRect) return null;
|
||||
const r = host.getBoundingClientRect();
|
||||
return {
|
||||
nx: Math.max(0, Math.min(1, (clientX - (r.left + contentRect.x)) / Math.max(1e-6, contentRect.w))),
|
||||
ny: Math.max(0, Math.min(1, (clientY - (r.top + contentRect.y)) / Math.max(1e-6, contentRect.h))),
|
||||
};
|
||||
},
|
||||
[contentRect],
|
||||
);
|
||||
|
||||
const previewSample = useMemo(() => {
|
||||
if (!previewPlaying || draft.points.length < 2) return null;
|
||||
const normalized = normalizeTokenPath(draft);
|
||||
if (!normalized) return null;
|
||||
return sampleTokenPathAtProgress(normalized, previewU);
|
||||
}, [draft, previewPlaying, previewU]);
|
||||
|
||||
const tokenLeftTop = useMemo(() => {
|
||||
if (!contentRect || !placement) return null;
|
||||
const nx = previewSample?.nx ?? placement.nx;
|
||||
const ny = previewSample?.ny ?? placement.ny;
|
||||
const minDim = Math.min(contentRect.w, contentRect.h);
|
||||
const sizeN =
|
||||
target?.kind === 'npcToken' && 'sizeN' in placement
|
||||
? placement.sizeN * sceneGridTokenFitFactor(scene?.grid ?? null)
|
||||
: placement.sizeN;
|
||||
const sizePx = Math.max(16, sizeN * minDim);
|
||||
return {
|
||||
left: contentRect.x + nx * contentRect.w,
|
||||
top: contentRect.y + ny * contentRect.h,
|
||||
sizePx,
|
||||
rotationDeg:
|
||||
previewSample?.rotationDeg ??
|
||||
(target?.kind === 'token' && 'rotationDeg' in placement ? placement.rotationDeg : 0),
|
||||
};
|
||||
}, [contentRect, placement, previewSample, scene?.grid, target?.kind]);
|
||||
|
||||
const title = useMemo(() => {
|
||||
if (!target) return 'Движение токена';
|
||||
if (target.kind === 'token') {
|
||||
const tok = placement && 'tokenId' in placement ? appTokens.find((a) => a.id === placement.tokenId) : null;
|
||||
return tok ? `Движение: ${tok.name}` : 'Движение токена';
|
||||
}
|
||||
return npcMeta ? `Движение: ${npcMeta.name}` : 'Движение НПС';
|
||||
}, [appTokens, npcMeta, placement, target]);
|
||||
|
||||
if (!USERS_BRANCH_FEATURES_ENABLED && target?.kind === 'npcToken') {
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
<div className={styles.empty}>НПС недоступны в этой сборке.</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
<aside className={styles.sidebar}>
|
||||
<div className={styles.title}>{title}</div>
|
||||
<p className={styles.hint}>
|
||||
ЛКМ по карте — добавить точку. Перетаскивайте точки для правки. ПКМ по точке — замкнуть или удалить.
|
||||
</p>
|
||||
|
||||
<label className={styles.field}>
|
||||
<span>Длительность, сек</span>
|
||||
<Input
|
||||
value={String(draft.durationSec)}
|
||||
onChange={(v) => {
|
||||
const n = Number(v);
|
||||
updateDraft((d) => ({
|
||||
...d,
|
||||
durationSec: Number.isFinite(n) ? n : d.durationSec,
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className={styles.field}>
|
||||
<span>Режим цикла</span>
|
||||
<Select
|
||||
value={draft.loopMode}
|
||||
options={[
|
||||
{ value: 'once', label: 'Один раз' },
|
||||
{ value: 'pingpong', label: 'Туда-обратно' },
|
||||
{
|
||||
value: 'loop',
|
||||
label: draft.closed ? 'Зациклить' : 'Зациклить (нужно замкнуть)',
|
||||
disabled: !draft.closed,
|
||||
},
|
||||
]}
|
||||
onChange={(v) => {
|
||||
updateDraft((d) => ({
|
||||
...d,
|
||||
loopMode: v === 'loop' && !d.closed ? 'once' : (v as Draft['loopMode']),
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className={styles.field}>
|
||||
<span>Старт</span>
|
||||
<Select
|
||||
value={draft.startMode}
|
||||
options={[
|
||||
{ value: 'onEnter', label: 'При входе в сцену' },
|
||||
{ value: 'delayed', label: 'С задержкой' },
|
||||
]}
|
||||
onChange={(v) => {
|
||||
updateDraft((d) => ({
|
||||
...d,
|
||||
startMode: v === 'delayed' ? 'delayed' : 'onEnter',
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
|
||||
{draft.startMode === 'delayed' ? (
|
||||
<label className={styles.field}>
|
||||
<span>Задержка, сек</span>
|
||||
<Input
|
||||
value={String(draft.delaySec)}
|
||||
onChange={(v) => {
|
||||
const n = Number(v);
|
||||
updateDraft((d) => ({
|
||||
...d,
|
||||
delaySec: Number.isFinite(n) ? n : d.delaySec,
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
) : null}
|
||||
|
||||
<label className={styles.field}>
|
||||
<span>Ориентация</span>
|
||||
<Select
|
||||
value={draft.facingMode}
|
||||
options={[
|
||||
{ value: 'tangentSmooth', label: 'По касательной' },
|
||||
{ value: 'fixed', label: 'Фиксированный угол' },
|
||||
]}
|
||||
onChange={(v) => {
|
||||
updateDraft((d) => ({
|
||||
...d,
|
||||
facingMode: v === 'fixed' ? 'fixed' : 'tangentSmooth',
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
|
||||
{draft.facingMode === 'fixed' ? (
|
||||
<label className={styles.field}>
|
||||
<span>Угол, °</span>
|
||||
<Input
|
||||
value={String(draft.fixedRotationDeg)}
|
||||
onChange={(v) => {
|
||||
const n = Number(v);
|
||||
updateDraft((d) => ({
|
||||
...d,
|
||||
fixedRotationDeg: Number.isFinite(n) ? n : d.fixedRotationDeg,
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
) : null}
|
||||
|
||||
<div className={styles.actions}>
|
||||
<Button
|
||||
onClick={() => {
|
||||
updateDraft((d) => ({
|
||||
...d,
|
||||
points: reverseTokenPathPoints(d.points),
|
||||
}));
|
||||
}}
|
||||
disabled={draft.points.length < 2}
|
||||
>
|
||||
Обратить путь
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setPreviewPlaying((p) => !p);
|
||||
setPreviewU(0);
|
||||
}}
|
||||
disabled={draft.points.length < 2}
|
||||
>
|
||||
{previewPlaying ? 'Стоп превью' : 'Превью'}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
updateDraft(() => createEmptyTokenPath(), { immediate: true });
|
||||
setPreviewPlaying(false);
|
||||
}}
|
||||
>
|
||||
Очистить путь
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
persistDraft(draftRef.current, true);
|
||||
}}
|
||||
>
|
||||
Сохранить
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className={styles.meta}>
|
||||
Точек: {draft.points.length}
|
||||
{draft.closed ? ' · замкнут' : ''}
|
||||
{dirty ? ' · есть изменения' : ''}
|
||||
{status ? ` · ${status}` : ''}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main className={styles.main}>
|
||||
{!scene || (!isImage && !isVideo) || !url ? (
|
||||
<div className={styles.empty}>Нет карты сцены для редактирования пути.</div>
|
||||
) : !target || !placement ? (
|
||||
<div className={styles.empty}>Выберите токен в редакторе сцены: ПКМ → «Указать движение».</div>
|
||||
) : (
|
||||
<div
|
||||
ref={hostRef}
|
||||
className={styles.host}
|
||||
onContextMenu={(e) => e.preventDefault()}
|
||||
onPointerDown={(e) => {
|
||||
if (e.button !== 0) return;
|
||||
if ((e.target as HTMLElement).closest('[data-path-point]')) return;
|
||||
const p = hostToNorm(e.clientX, e.clientY);
|
||||
if (!p) return;
|
||||
updateDraft((d) => {
|
||||
const next = tryAppendPathPoint(d.points, p);
|
||||
if (!next) return d;
|
||||
return { ...d, points: next, closed: false, loopMode: d.loopMode === 'loop' ? 'once' : d.loopMode };
|
||||
});
|
||||
}}
|
||||
onPointerMove={(e) => {
|
||||
const drag = dragPointRef.current;
|
||||
if (!drag || drag.pointerId !== e.pointerId) return;
|
||||
const p = hostToNorm(e.clientX, e.clientY);
|
||||
if (!p) return;
|
||||
updateDraft((d) => {
|
||||
const points = d.points.map((pt, i) => (i === drag.index ? p : pt));
|
||||
return { ...d, points };
|
||||
});
|
||||
}}
|
||||
onPointerUp={(e) => {
|
||||
if (dragPointRef.current?.pointerId === e.pointerId) {
|
||||
dragPointRef.current = null;
|
||||
persistDraft(draftRef.current, true);
|
||||
}
|
||||
}}
|
||||
onPointerCancel={(e) => {
|
||||
if (dragPointRef.current?.pointerId === e.pointerId) {
|
||||
dragPointRef.current = null;
|
||||
}
|
||||
}}
|
||||
>
|
||||
{isImage ? (
|
||||
<RotatedImage url={url} rotationDeg={rot} mode="contain" onContentRectChange={setContentRect} />
|
||||
) : (
|
||||
<ContainedVideo
|
||||
url={url}
|
||||
rotationDeg={rot}
|
||||
muted
|
||||
playsInline
|
||||
loop
|
||||
preload="metadata"
|
||||
onContentRectChange={setContentRect}
|
||||
/>
|
||||
)}
|
||||
{contentRect ? (
|
||||
<svg className={styles.pathSvg} width="100%" height="100%" aria-hidden>
|
||||
{draft.points.length >= 2 ? (
|
||||
<polyline
|
||||
className={styles.pathLine}
|
||||
fill="none"
|
||||
points={tokenPathPolyline(draft)
|
||||
.map((p) => {
|
||||
const x = contentRect.x + p.nx * contentRect.w;
|
||||
const y = contentRect.y + p.ny * contentRect.h;
|
||||
return `${x},${y}`;
|
||||
})
|
||||
.join(' ')}
|
||||
/>
|
||||
) : null}
|
||||
</svg>
|
||||
) : null}
|
||||
{contentRect
|
||||
? draft.points.map((p: TokenPathPoint, index: number) => {
|
||||
const left = contentRect.x + p.nx * contentRect.w;
|
||||
const top = contentRect.y + p.ny * contentRect.h;
|
||||
return (
|
||||
<button
|
||||
key={`${index}_${p.nx}_${p.ny}`}
|
||||
type="button"
|
||||
data-path-point
|
||||
className={styles.point}
|
||||
style={{ left, top }}
|
||||
onPointerDown={(e) => {
|
||||
if (e.button !== 0) return;
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
(e.currentTarget as HTMLButtonElement).setPointerCapture(e.pointerId);
|
||||
dragPointRef.current = { index, pointerId: e.pointerId };
|
||||
}}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setPointMenu({ x: e.clientX, y: e.clientY, index });
|
||||
}}
|
||||
>
|
||||
{index + 1}
|
||||
</button>
|
||||
);
|
||||
})
|
||||
: null}
|
||||
{contentRect && tokenLeftTop && target ? (
|
||||
<TokenPreview
|
||||
kind={target.kind}
|
||||
left={tokenLeftTop.left}
|
||||
top={tokenLeftTop.top}
|
||||
sizePx={tokenLeftTop.sizePx}
|
||||
rotationDeg={tokenLeftTop.rotationDeg}
|
||||
{...(target.kind === 'token' && placement && 'tokenId' in placement
|
||||
? { token: placement as SceneToken }
|
||||
: {})}
|
||||
{...(target.kind === 'npcToken' && placement && 'npcId' in placement
|
||||
? { npcToken: placement as SceneNpcToken }
|
||||
: {})}
|
||||
{...(npcMeta?.name ? { npcName: npcMeta.name } : {})}
|
||||
{...(npcAvatarUrl ? { npcAvatarUrl } : {})}
|
||||
{...(npcMeta
|
||||
? {
|
||||
ringColor: npcDispositionRingColor(
|
||||
normalizeNpcDisposition(
|
||||
(placement && 'disposition' in placement ? placement.disposition : undefined) ??
|
||||
npcMeta.disposition,
|
||||
),
|
||||
),
|
||||
}
|
||||
: {})}
|
||||
{...(npcMeta?.imageOffset ? { imageOffset: npcMeta.imageOffset } : {})}
|
||||
{...(typeof npcMeta?.imageScale === 'number'
|
||||
? { imageScale: npcMeta.imageScale }
|
||||
: {})}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
|
||||
{pointMenu
|
||||
? createPortal(
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.ctxMenuBackdrop}
|
||||
aria-label="Закрыть"
|
||||
onClick={() => setPointMenu(null)}
|
||||
/>
|
||||
<div className={styles.ctxMenu} style={{ left: pointMenu.x, top: pointMenu.y }} role="menu">
|
||||
<button
|
||||
type="button"
|
||||
className={styles.ctxItem}
|
||||
role="menuitem"
|
||||
onClick={() => {
|
||||
updateDraft((d) => ({
|
||||
...d,
|
||||
closed: true,
|
||||
loopMode: d.loopMode === 'once' ? d.loopMode : d.loopMode,
|
||||
}));
|
||||
setPointMenu(null);
|
||||
}}
|
||||
>
|
||||
Замкнуть путь
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.ctxItem}
|
||||
role="menuitem"
|
||||
onClick={() => {
|
||||
updateDraft((d) => ({
|
||||
...d,
|
||||
closed: false,
|
||||
loopMode: d.loopMode === 'loop' ? 'once' : d.loopMode,
|
||||
}));
|
||||
setPointMenu(null);
|
||||
}}
|
||||
>
|
||||
Разомкнуть
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.ctxItemDanger}
|
||||
role="menuitem"
|
||||
onClick={() => {
|
||||
const idx = pointMenu.index;
|
||||
updateDraft((d) => {
|
||||
const points = d.points.filter((_, i) => i !== idx);
|
||||
return {
|
||||
...d,
|
||||
points,
|
||||
closed: points.length >= 2 ? d.closed : false,
|
||||
loopMode: points.length >= 2 && d.closed ? d.loopMode : d.loopMode === 'loop' ? 'once' : d.loopMode,
|
||||
};
|
||||
});
|
||||
setPointMenu(null);
|
||||
}}
|
||||
>
|
||||
Удалить точку
|
||||
</button>
|
||||
</div>
|
||||
</>,
|
||||
document.body,
|
||||
)
|
||||
: null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import React from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
|
||||
import '../shared/ui/globals.css';
|
||||
import { EditorI18nProvider } from '../editor/i18n/EditorI18nContext';
|
||||
import { WindowErrorBoundary } from '../shared/ui/WindowErrorBoundary';
|
||||
|
||||
import { TokenPathEditorApp } from './TokenPathEditorApp';
|
||||
|
||||
const rootEl = document.getElementById('root');
|
||||
if (!rootEl) {
|
||||
throw new Error('Missing #root element');
|
||||
}
|
||||
|
||||
createRoot(rootEl).render(
|
||||
<React.StrictMode>
|
||||
<WindowErrorBoundary title="Движение токена">
|
||||
<EditorI18nProvider>
|
||||
<TokenPathEditorApp />
|
||||
</EditorI18nProvider>
|
||||
</WindowErrorBoundary>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
Reference in New Issue
Block a user