1ab6ffd593
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>
680 lines
24 KiB
TypeScript
680 lines
24 KiB
TypeScript
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>
|
||
);
|
||
}
|