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 (
{url ? : null}
); } if (kind === 'npcToken' && npcToken) { return (
); } return null; } export function TokenPathEditorApp() { const api = getDndApi(); const appTokens = useAppTokens(); const [session, setSession] = useState(null); const [target, setTarget] = useState(null); const [draft, setDraft] = useState(createEmptyTokenPath()); const [contentRect, setContentRect] = useState<{ x: number; y: number; w: number; h: number } | null>( null, ); const [pointMenu, setPointMenu] = useState(null); const [previewPlaying, setPreviewPlaying] = useState(false); const [previewU, setPreviewU] = useState(0); const [dirty, setDirty] = useState(false); const [status, setStatus] = useState(null); const hostRef = useRef(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 (
НПС недоступны в этой сборке.
); } return (
{!scene || (!isImage && !isVideo) || !url ? (
Нет карты сцены для редактирования пути.
) : !target || !placement ? (
Выберите токен в редакторе сцены: ПКМ → «Указать движение».
) : (
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 ? ( ) : ( )} {contentRect ? ( {draft.points.length >= 2 ? ( { const x = contentRect.x + p.nx * contentRect.w; const y = contentRect.y + p.ny * contentRect.h; return `${x},${y}`; }) .join(' ')} /> ) : null} ) : 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 ( ); }) : null} {contentRect && tokenLeftTop && target ? ( ) : null}
)}
{pointMenu ? createPortal( <>
, document.body, ) : null} ); }