DNDGamePlayer: Electron редактор сцен, презентация, упаковка electron-builder
Made-with: Cursor
This commit is contained in:
Vendored
+1
@@ -0,0 +1 @@
|
||||
declare module 'reactflow/dist/style.css';
|
||||
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="icon" href="/app-window-icon.png" type="image/png" />
|
||||
<title>DnD Player — Control</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/control/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,359 @@
|
||||
.page {
|
||||
height: 100vh;
|
||||
padding: 16px;
|
||||
display: grid;
|
||||
grid-template-columns: 280px 1fr;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.remote {
|
||||
padding: 12px;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.remoteTitle {
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 900;
|
||||
letter-spacing: 0.8px;
|
||||
color: var(--text1);
|
||||
}
|
||||
|
||||
.spacer12 {
|
||||
height: 12px;
|
||||
}
|
||||
|
||||
.spacer8 {
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
.spacer10 {
|
||||
height: 10px;
|
||||
}
|
||||
|
||||
.sectionLabel {
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 800;
|
||||
color: var(--text2);
|
||||
}
|
||||
|
||||
.effectsStack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.iconRow {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.iconGlyph {
|
||||
font-size: 18px;
|
||||
line-height: 1;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.clearIcon {
|
||||
display: flex;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.radiusRow {
|
||||
display: grid;
|
||||
grid-template-columns: 100px 1fr 44px;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.radiusLabel {
|
||||
color: var(--text2);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.range {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.radiusValue {
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--text2);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
|
||||
.storyWrap {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.storyScroll {
|
||||
flex: 0 0 70%;
|
||||
min-height: 0;
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid var(--stroke);
|
||||
background: var(--color-overlay-dark-2);
|
||||
overflow: auto;
|
||||
padding: 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
justify-content: flex-start;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.historyBtn {
|
||||
text-align: left;
|
||||
padding: 10px;
|
||||
border-radius: var(--scene-tile-radius);
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.historyBtn:not(:disabled):not(.historyBtnCurrent):hover {
|
||||
background: var(--scene-list-hover-bg);
|
||||
}
|
||||
|
||||
.historyBtn:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.historyBtnCurrent {
|
||||
border: 1px solid var(--scene-list-selected-border);
|
||||
background: var(--scene-list-selected-bg);
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.historyBadge {
|
||||
color: var(--accent2);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.historyMuted {
|
||||
color: var(--text2);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
|
||||
.historyTitle {
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.emptyStory {
|
||||
color: var(--text2);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
|
||||
.rightStack {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
grid-template-rows: auto auto auto;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.surfacePad {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.previewHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.previewTitle {
|
||||
font-size: var(--text-md);
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.previewActions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.videoHint {
|
||||
color: var(--text2);
|
||||
font-size: var(--text-xs);
|
||||
line-height: 1.45;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.previewFrame {
|
||||
border-radius: 18px;
|
||||
border: 1px solid var(--stroke);
|
||||
height: 360px;
|
||||
overflow: hidden;
|
||||
background: var(--color-overlay-dark-2);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.previewHost {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
.brushCursor {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
transform: translate(-50%, -50%);
|
||||
border-radius: 50%;
|
||||
border: 1px solid rgba(255, 255, 255, 0.55);
|
||||
box-shadow:
|
||||
0 0 0 1px rgba(0, 0, 0, 0.3),
|
||||
0 0 18px rgba(255, 140, 40, 0.12),
|
||||
inset 0 0 18px rgba(255, 140, 40, 0.1);
|
||||
background: rgba(255, 160, 60, 0.03);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.brushLayer {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 3;
|
||||
cursor: crosshair;
|
||||
}
|
||||
|
||||
.branchTitle {
|
||||
font-size: var(--text-md);
|
||||
font-weight: 900;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.branchGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.branchCard {
|
||||
border-radius: var(--scene-tile-radius);
|
||||
border: 1px solid var(--stroke);
|
||||
background: var(--color-overlay-dark-2);
|
||||
padding: 12px;
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.branchCardHeader {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
color: var(--text2);
|
||||
}
|
||||
|
||||
.branchOption {
|
||||
font-size: 11px;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.branchName {
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.musicHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.musicEmpty {
|
||||
color: var(--text2);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
|
||||
.audioMeta {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.audioBadges {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
color: var(--text2);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
|
||||
.audioName {
|
||||
font-weight: 800;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.audioTransport {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.scrubFill {
|
||||
height: 100%;
|
||||
background: var(--accent-fill-solid);
|
||||
}
|
||||
|
||||
.timeRow {
|
||||
margin-top: 6px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
color: var(--text2);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.branchEmpty {
|
||||
grid-column: 1 / -1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
color: var(--text2);
|
||||
font-size: var(--text-xs);
|
||||
padding: 6px 2px;
|
||||
}
|
||||
|
||||
.audioList {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
max-height: 210px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.audioCard {
|
||||
padding: 10px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid var(--stroke);
|
||||
background: var(--color-overlay-dark-2);
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.audioScrub {
|
||||
margin-top: 10px;
|
||||
height: 10px;
|
||||
border-radius: var(--radius-pill);
|
||||
border: 1px solid var(--stroke);
|
||||
background: rgba(0, 0, 0, 0.22);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.audioScrubPointer {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.audioScrubDefault {
|
||||
cursor: default;
|
||||
}
|
||||
@@ -0,0 +1,887 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import { ipcChannels } from '../../shared/ipc/contracts';
|
||||
import type { SessionState } from '../../shared/ipc/contracts';
|
||||
import type { GraphNodeId, Scene, SceneId } from '../../shared/types';
|
||||
import { getDndApi } from '../shared/dndApi';
|
||||
import { PixiEffectsOverlay } from '../shared/effects/PxiEffectsOverlay';
|
||||
import { useEffectsState } from '../shared/effects/useEffectsState';
|
||||
import { Button } from '../shared/ui/controls';
|
||||
import { Surface } from '../shared/ui/Surface';
|
||||
|
||||
import styles from './ControlApp.module.css';
|
||||
import { ControlScenePreview } from './ControlScenePreview';
|
||||
|
||||
function formatTime(sec: number): string {
|
||||
if (!Number.isFinite(sec) || sec < 0) return '0:00';
|
||||
const s = Math.floor(sec);
|
||||
const m = Math.floor(s / 60);
|
||||
const r = s % 60;
|
||||
return `${String(m)}:${String(r).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
export function ControlApp() {
|
||||
const api = getDndApi();
|
||||
const [fxState, fx] = useEffectsState();
|
||||
const [session, setSession] = useState<SessionState | null>(null);
|
||||
const historyRef = useRef<GraphNodeId[]>([]);
|
||||
const suppressNextHistoryPushRef = useRef(false);
|
||||
const [history, setHistory] = useState<GraphNodeId[]>([]);
|
||||
const audioElsRef = useRef<Map<string, HTMLAudioElement>>(new Map());
|
||||
const audioMetaRef = useRef<Map<string, { lastPlayError: string | null }>>(new Map());
|
||||
const [audioStateTick, setAudioStateTick] = useState(0);
|
||||
const audioLoadRunRef = useRef(0);
|
||||
const previewHostRef = useRef<HTMLDivElement | null>(null);
|
||||
const previewVideoRef = useRef<HTMLVideoElement | null>(null);
|
||||
const brushRef = useRef<{
|
||||
tool: 'fog' | 'fire' | 'rain' | 'lightning' | 'freeze' | 'eraser';
|
||||
startN?: { x: number; y: number };
|
||||
points?: { x: number; y: number; tMs: number }[];
|
||||
} | null>(null);
|
||||
const [draftFxTick, setDraftFxTick] = useState(0);
|
||||
const [cursorN, setCursorN] = useState<{ x: number; y: number } | null>(null);
|
||||
const [previewSize, setPreviewSize] = useState<{ w: number; h: number }>({ w: 1, h: 1 });
|
||||
const [previewContentRect, setPreviewContentRect] = useState<{
|
||||
x: number;
|
||||
y: number;
|
||||
w: number;
|
||||
h: number;
|
||||
} | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
void api.invoke(ipcChannels.project.get, {}).then((res) => {
|
||||
const next: SessionState = {
|
||||
project: res.project,
|
||||
currentSceneId: res.project?.currentSceneId ?? null,
|
||||
};
|
||||
setSession(next);
|
||||
historyRef.current = next.project?.currentGraphNodeId ? [next.project.currentGraphNodeId] : [];
|
||||
setHistory(historyRef.current);
|
||||
});
|
||||
return api.on(ipcChannels.session.stateChanged, ({ state }) => {
|
||||
setSession(state);
|
||||
const cur = state.project?.currentGraphNodeId ?? null;
|
||||
if (!cur) return;
|
||||
const arr = historyRef.current;
|
||||
if (suppressNextHistoryPushRef.current) {
|
||||
suppressNextHistoryPushRef.current = false;
|
||||
setHistory(arr);
|
||||
return;
|
||||
}
|
||||
// Если мы перемотались на уже существующий шаг, не дублируем его в истории.
|
||||
if (arr.includes(cur)) {
|
||||
setHistory(arr);
|
||||
return;
|
||||
}
|
||||
if (arr[arr.length - 1] !== cur) {
|
||||
historyRef.current = [...arr, cur];
|
||||
setHistory(historyRef.current);
|
||||
}
|
||||
});
|
||||
}, [api]);
|
||||
|
||||
const project = session?.project ?? null;
|
||||
const currentGraphNodeId = project?.currentGraphNodeId ?? null;
|
||||
const currentScene =
|
||||
project && session?.currentSceneId ? project.scenes[session.currentSceneId] : undefined;
|
||||
const isVideoPreviewScene = currentScene?.previewAssetType === 'video';
|
||||
const sceneAudioRefs = useMemo(() => currentScene?.media.audios ?? [], [currentScene]);
|
||||
|
||||
const sceneAudios = useMemo(() => {
|
||||
if (!project) return [];
|
||||
return sceneAudioRefs
|
||||
.map((r) => {
|
||||
const a = project.assets[r.assetId];
|
||||
return a?.type === 'audio' ? { ref: r, asset: a } : null;
|
||||
})
|
||||
.filter((x): x is { ref: (typeof sceneAudioRefs)[number]; asset: NonNullable<typeof x>['asset'] } =>
|
||||
Boolean(x),
|
||||
);
|
||||
}, [project, sceneAudioRefs]);
|
||||
|
||||
useEffect(() => {
|
||||
audioLoadRunRef.current += 1;
|
||||
const runId = audioLoadRunRef.current;
|
||||
// Cleanup old audios on scene change.
|
||||
const els = audioElsRef.current;
|
||||
for (const el of els.values()) {
|
||||
try {
|
||||
el.pause();
|
||||
el.currentTime = 0;
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
els.clear();
|
||||
audioMetaRef.current.clear();
|
||||
setAudioStateTick((x) => x + 1);
|
||||
|
||||
if (!project || !currentScene) return;
|
||||
void (async () => {
|
||||
const loaded: { ref: (typeof sceneAudioRefs)[number]; el: HTMLAudioElement }[] = [];
|
||||
for (const item of sceneAudioRefs) {
|
||||
const r = await api.invoke(ipcChannels.project.assetFileUrl, { assetId: item.assetId });
|
||||
if (audioLoadRunRef.current !== runId) return;
|
||||
if (!r.url) continue;
|
||||
const el = new Audio(r.url);
|
||||
el.loop = item.loop;
|
||||
el.preload = 'auto';
|
||||
audioMetaRef.current.set(item.assetId, { lastPlayError: null });
|
||||
el.addEventListener('play', () => setAudioStateTick((x) => x + 1));
|
||||
el.addEventListener('pause', () => setAudioStateTick((x) => x + 1));
|
||||
el.addEventListener('ended', () => setAudioStateTick((x) => x + 1));
|
||||
el.addEventListener('canplay', () => setAudioStateTick((x) => x + 1));
|
||||
el.addEventListener('error', () => setAudioStateTick((x) => x + 1));
|
||||
loaded.push({ ref: item, el });
|
||||
audioElsRef.current.set(item.assetId, el);
|
||||
}
|
||||
setAudioStateTick((x) => x + 1);
|
||||
for (const { ref, el } of loaded) {
|
||||
if (audioLoadRunRef.current !== runId) {
|
||||
try {
|
||||
el.pause();
|
||||
el.currentTime = 0;
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (!ref.autoplay) continue;
|
||||
try {
|
||||
await el.play();
|
||||
} catch {
|
||||
const m = audioMetaRef.current.get(ref.assetId) ?? { lastPlayError: null };
|
||||
audioMetaRef.current.set(ref.assetId, {
|
||||
...m,
|
||||
lastPlayError:
|
||||
'Автозапуск заблокирован (нужно действие пользователя) или ошибка воспроизведения.',
|
||||
});
|
||||
setAudioStateTick((x) => x + 1);
|
||||
}
|
||||
}
|
||||
})();
|
||||
}, [api, currentScene, project, sceneAudioRefs]);
|
||||
|
||||
const anyPlaying = useMemo(() => {
|
||||
for (const el of audioElsRef.current.values()) {
|
||||
if (!el.paused) return true;
|
||||
}
|
||||
return false;
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [audioStateTick]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!anyPlaying) return;
|
||||
let raf = 0;
|
||||
const tick = () => {
|
||||
setAudioStateTick((x) => x + 1);
|
||||
raf = window.requestAnimationFrame(tick);
|
||||
};
|
||||
raf = window.requestAnimationFrame(tick);
|
||||
return () => window.cancelAnimationFrame(raf);
|
||||
}, [anyPlaying]);
|
||||
|
||||
useEffect(() => {
|
||||
const host = previewHostRef.current;
|
||||
if (!host) return;
|
||||
const update = () => {
|
||||
const r = host.getBoundingClientRect();
|
||||
setPreviewSize({ w: Math.max(1, r.width), h: Math.max(1, r.height) });
|
||||
};
|
||||
update();
|
||||
const ro = new ResizeObserver(update);
|
||||
ro.observe(host);
|
||||
return () => ro.disconnect();
|
||||
}, []);
|
||||
|
||||
function audioStatus(assetId: string): { label: string; detail?: string } {
|
||||
const el = audioElsRef.current.get(assetId) ?? null;
|
||||
if (!el) return { label: 'URL не получен', detail: 'Не удалось получить dnd://asset URL для аудио.' };
|
||||
const meta = audioMetaRef.current.get(assetId) ?? { lastPlayError: null };
|
||||
if (meta.lastPlayError) return { label: 'Ошибка/блок', detail: meta.lastPlayError };
|
||||
if (el.error)
|
||||
return {
|
||||
label: 'Ошибка',
|
||||
detail: `MediaError code=${String(el.error.code)} (1=ABORTED, 2=NETWORK, 3=DECODE, 4=SRC_NOT_SUPPORTED)`,
|
||||
};
|
||||
if (el.readyState < 2) return { label: 'Загрузка…' };
|
||||
if (!el.paused) return { label: 'Играет' };
|
||||
if (el.currentTime > 0) return { label: 'Пауза' };
|
||||
return { label: 'Остановлено' };
|
||||
}
|
||||
const nextScenes = useMemo(() => {
|
||||
if (!project) return [];
|
||||
if (!currentGraphNodeId) return [];
|
||||
const outgoing = project.sceneGraphEdges
|
||||
.filter((e) => e.sourceGraphNodeId === currentGraphNodeId)
|
||||
.map((e) => {
|
||||
const n = project.sceneGraphNodes.find((x) => x.id === e.targetGraphNodeId);
|
||||
return n ? { graphNodeId: e.targetGraphNodeId, sceneId: n.sceneId } : null;
|
||||
})
|
||||
.filter((x): x is { graphNodeId: GraphNodeId; sceneId: SceneId } => Boolean(x));
|
||||
return outgoing
|
||||
.map((o) => ({ graphNodeId: o.graphNodeId, scene: project.scenes[o.sceneId] }))
|
||||
.filter((x): x is { graphNodeId: GraphNodeId; scene: Scene } => x.scene !== undefined);
|
||||
}, [currentGraphNodeId, project]);
|
||||
|
||||
const tool = fxState?.tool ?? { tool: 'fog', radiusN: 0.08, intensity: 0.6 };
|
||||
|
||||
function toNPoint(e: React.PointerEvent): { x: number; y: number } | null {
|
||||
const host = previewHostRef.current;
|
||||
if (!host) return null;
|
||||
const r = host.getBoundingClientRect();
|
||||
const cr = previewContentRect;
|
||||
const ox = cr ? cr.x : 0;
|
||||
const oy = cr ? cr.y : 0;
|
||||
const cw = cr ? cr.w : r.width;
|
||||
const ch = cr ? cr.h : r.height;
|
||||
const x = (e.clientX - (r.left + ox)) / Math.max(1, cw);
|
||||
const y = (e.clientY - (r.top + oy)) / Math.max(1, ch);
|
||||
return { x: Math.max(0, Math.min(1, x)), y: Math.max(0, Math.min(1, y)) };
|
||||
}
|
||||
|
||||
async function commitStroke(): Promise<void> {
|
||||
if (isVideoPreviewScene) {
|
||||
brushRef.current = null;
|
||||
setDraftFxTick((x) => x + 1);
|
||||
return;
|
||||
}
|
||||
if (!fxState) return;
|
||||
const b = brushRef.current;
|
||||
if (!b) return;
|
||||
const createdAtMs = Date.now();
|
||||
const seed = Math.floor(Math.random() * 1_000_000_000);
|
||||
|
||||
if (b.tool === 'fog' && b.points && b.points.length > 0) {
|
||||
await fx.dispatch({
|
||||
kind: 'instance.add',
|
||||
instance: {
|
||||
id: `fog_${String(createdAtMs)}_${String(seed)}`,
|
||||
type: 'fog',
|
||||
seed,
|
||||
createdAtMs,
|
||||
points: b.points,
|
||||
radiusN: tool.radiusN,
|
||||
opacity: Math.max(0.05, Math.min(0.9, tool.intensity)),
|
||||
lifetimeMs: null,
|
||||
},
|
||||
});
|
||||
}
|
||||
if (b.tool === 'fire' && b.points && b.points.length > 0) {
|
||||
await fx.dispatch({
|
||||
kind: 'instance.add',
|
||||
instance: {
|
||||
id: `fire_${String(createdAtMs)}_${String(seed)}`,
|
||||
type: 'fire',
|
||||
seed,
|
||||
createdAtMs,
|
||||
points: b.points,
|
||||
radiusN: tool.radiusN,
|
||||
// Огонь визуально ярче, но всё равно ограничиваемся безопасными пределами.
|
||||
opacity: Math.max(0.12, Math.min(0.95, tool.intensity)),
|
||||
lifetimeMs: null,
|
||||
},
|
||||
});
|
||||
}
|
||||
if (b.tool === 'rain' && b.points && b.points.length > 0) {
|
||||
await fx.dispatch({
|
||||
kind: 'instance.add',
|
||||
instance: {
|
||||
id: `rain_${String(createdAtMs)}_${String(seed)}`,
|
||||
type: 'rain',
|
||||
seed,
|
||||
createdAtMs,
|
||||
points: b.points,
|
||||
radiusN: tool.radiusN,
|
||||
opacity: Math.max(0.08, Math.min(0.9, tool.intensity)),
|
||||
lifetimeMs: null,
|
||||
},
|
||||
});
|
||||
}
|
||||
if (b.tool === 'lightning' && b.startN && b.points && b.points.length > 0) {
|
||||
const last = b.points[b.points.length - 1];
|
||||
if (last === undefined) return;
|
||||
const end = { x: last.x, y: last.y };
|
||||
const start = { x: end.x, y: 0 };
|
||||
await fx.dispatch({
|
||||
kind: 'instance.add',
|
||||
instance: {
|
||||
id: `lt_${String(createdAtMs)}_${String(seed)}`,
|
||||
type: 'lightning',
|
||||
seed,
|
||||
createdAtMs,
|
||||
start,
|
||||
end,
|
||||
widthN: Math.max(0.01, tool.radiusN * 0.9),
|
||||
intensity: Math.max(0.9, Math.min(1.2, tool.intensity * 1.35)),
|
||||
lifetimeMs: 180,
|
||||
},
|
||||
});
|
||||
await fx.dispatch({
|
||||
kind: 'instance.add',
|
||||
instance: {
|
||||
id: `sc_${String(createdAtMs)}_${String(seed)}`,
|
||||
type: 'scorch',
|
||||
seed: seed ^ 0x5a5a5a,
|
||||
createdAtMs,
|
||||
at: end,
|
||||
radiusN: Math.max(0.03, tool.radiusN * 0.625),
|
||||
opacity: 0.92,
|
||||
lifetimeMs: 60_000,
|
||||
},
|
||||
});
|
||||
}
|
||||
if (b.tool === 'freeze' && b.points && b.points.length > 0) {
|
||||
const last = b.points[b.points.length - 1];
|
||||
if (last === undefined) return;
|
||||
const at = { x: last.x, y: last.y };
|
||||
await fx.dispatch({
|
||||
kind: 'instance.add',
|
||||
instance: {
|
||||
id: `fr_${String(createdAtMs)}_${String(seed)}`,
|
||||
type: 'freeze',
|
||||
seed,
|
||||
createdAtMs,
|
||||
at,
|
||||
intensity: Math.max(0.8, Math.min(1.25, tool.intensity * 1.15)),
|
||||
// Быстро появиться → чуть задержаться → плавно исчезнуть.
|
||||
lifetimeMs: 820,
|
||||
},
|
||||
});
|
||||
await fx.dispatch({
|
||||
kind: 'instance.add',
|
||||
instance: {
|
||||
id: `ice_${String(createdAtMs)}_${String(seed)}`,
|
||||
type: 'ice',
|
||||
seed: seed ^ 0x33cc99,
|
||||
createdAtMs,
|
||||
at,
|
||||
radiusN: Math.max(0.03, tool.radiusN * 0.9),
|
||||
opacity: 0.85,
|
||||
lifetimeMs: 60_000,
|
||||
},
|
||||
});
|
||||
}
|
||||
brushRef.current = null;
|
||||
setDraftFxTick((x) => x + 1);
|
||||
}
|
||||
const draftInstance = useMemo(() => {
|
||||
const b = brushRef.current;
|
||||
if (!b) return null;
|
||||
const seed = 12345;
|
||||
const createdAtMs = Date.now();
|
||||
if (b.tool === 'fog' && b.points && b.points.length > 0) {
|
||||
return {
|
||||
id: '__draft__',
|
||||
type: 'fog' as const,
|
||||
seed,
|
||||
createdAtMs,
|
||||
points: b.points,
|
||||
radiusN: tool.radiusN,
|
||||
opacity: Math.max(0.05, Math.min(0.6, tool.intensity * 0.7)),
|
||||
lifetimeMs: null,
|
||||
};
|
||||
}
|
||||
if (b.tool === 'fire' && b.points && b.points.length > 0) {
|
||||
return {
|
||||
id: '__draft__',
|
||||
type: 'fire' as const,
|
||||
seed,
|
||||
createdAtMs,
|
||||
points: b.points,
|
||||
radiusN: tool.radiusN,
|
||||
opacity: Math.max(0.12, Math.min(0.75, tool.intensity * 0.85)),
|
||||
lifetimeMs: null,
|
||||
};
|
||||
}
|
||||
if (b.tool === 'rain' && b.points && b.points.length > 0) {
|
||||
return {
|
||||
id: '__draft__',
|
||||
type: 'rain' as const,
|
||||
seed,
|
||||
createdAtMs,
|
||||
points: b.points,
|
||||
radiusN: tool.radiusN,
|
||||
opacity: Math.max(0.08, Math.min(0.65, tool.intensity * 0.85)),
|
||||
lifetimeMs: null,
|
||||
};
|
||||
}
|
||||
if (b.tool === 'lightning' && b.startN && b.points && b.points.length > 0) {
|
||||
const last = b.points[b.points.length - 1];
|
||||
if (last === undefined) return null;
|
||||
return {
|
||||
id: '__draft__',
|
||||
type: 'lightning' as const,
|
||||
seed,
|
||||
createdAtMs,
|
||||
start: { x: last.x, y: 0 },
|
||||
end: { x: last.x, y: last.y },
|
||||
widthN: Math.max(0.01, tool.radiusN * 0.9),
|
||||
intensity: Math.max(0.9, Math.min(1.2, tool.intensity * 1.35)),
|
||||
lifetimeMs: 180,
|
||||
};
|
||||
}
|
||||
if (b.tool === 'freeze' && b.points && b.points.length > 0) {
|
||||
const last = b.points[b.points.length - 1];
|
||||
if (last === undefined) return null;
|
||||
return {
|
||||
id: '__draft__',
|
||||
type: 'freeze' as const,
|
||||
seed,
|
||||
createdAtMs,
|
||||
at: { x: last.x, y: last.y },
|
||||
intensity: Math.max(0.8, Math.min(1.25, tool.intensity * 1.15)),
|
||||
lifetimeMs: 240,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [draftFxTick, tool.intensity, tool.radiusN, tool.tool]);
|
||||
|
||||
const fxMergedState = useMemo(() => {
|
||||
if (!fxState) return null;
|
||||
if (!draftInstance) return fxState;
|
||||
return { ...fxState, instances: [...fxState.instances, draftInstance] };
|
||||
}, [draftInstance, fxState]);
|
||||
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
<Surface className={styles.remote}>
|
||||
<div className={styles.remoteTitle}>ПУЛЬТ УПРАВЛЕНИЯ</div>
|
||||
<div className={styles.spacer12} />
|
||||
{!isVideoPreviewScene ? (
|
||||
<>
|
||||
<div className={styles.sectionLabel}>ЭФФЕКТЫ</div>
|
||||
<div className={styles.spacer8} />
|
||||
<div className={styles.effectsStack}>
|
||||
<div className={styles.iconRow}>
|
||||
<Button
|
||||
variant={tool.tool === 'fog' ? 'primary' : 'ghost'}
|
||||
iconOnly
|
||||
title="Туман"
|
||||
ariaLabel="Туман"
|
||||
onClick={() => void fx.dispatch({ kind: 'tool.set', tool: { ...tool, tool: 'fog' } })}
|
||||
>
|
||||
<span className={styles.iconGlyph}>🌫️</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant={tool.tool === 'fire' ? 'primary' : 'ghost'}
|
||||
iconOnly
|
||||
title="Огонь"
|
||||
ariaLabel="Огонь"
|
||||
onClick={() => void fx.dispatch({ kind: 'tool.set', tool: { ...tool, tool: 'fire' } })}
|
||||
>
|
||||
<span className={styles.iconGlyph}>🔥</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant={tool.tool === 'rain' ? 'primary' : 'ghost'}
|
||||
iconOnly
|
||||
title="Дождь"
|
||||
ariaLabel="Дождь"
|
||||
onClick={() => void fx.dispatch({ kind: 'tool.set', tool: { ...tool, tool: 'rain' } })}
|
||||
>
|
||||
<span className={styles.iconGlyph}>🌧️</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant={tool.tool === 'lightning' ? 'primary' : 'ghost'}
|
||||
iconOnly
|
||||
title="Молния"
|
||||
ariaLabel="Молния"
|
||||
onClick={() => void fx.dispatch({ kind: 'tool.set', tool: { ...tool, tool: 'lightning' } })}
|
||||
>
|
||||
<span className={styles.iconGlyph}>⚡</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant={tool.tool === 'freeze' ? 'primary' : 'ghost'}
|
||||
iconOnly
|
||||
title="Заморозка"
|
||||
ariaLabel="Заморозка"
|
||||
onClick={() => void fx.dispatch({ kind: 'tool.set', tool: { ...tool, tool: 'freeze' } })}
|
||||
>
|
||||
<span className={styles.iconGlyph}>❄️</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant={tool.tool === 'eraser' ? 'primary' : 'ghost'}
|
||||
iconOnly
|
||||
title="Ластик"
|
||||
ariaLabel="Ластик"
|
||||
onClick={() => void fx.dispatch({ kind: 'tool.set', tool: { ...tool, tool: 'eraser' } })}
|
||||
>
|
||||
<span className={styles.iconGlyph}>🧹</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
iconOnly
|
||||
title="Очистить эффекты"
|
||||
ariaLabel="Очистить эффекты"
|
||||
onClick={() => void fx.dispatch({ kind: 'instances.clear' })}
|
||||
>
|
||||
<span className={styles.clearIcon}>
|
||||
<svg viewBox="0 0 24 24" width="20" height="20" aria-hidden>
|
||||
<circle cx="12" cy="12" r="8" fill="none" stroke="#e5484d" strokeWidth="2" />
|
||||
<line
|
||||
x1="7"
|
||||
y1="17"
|
||||
x2="17"
|
||||
y2="7"
|
||||
stroke="#e5484d"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
<div className={styles.radiusRow}>
|
||||
<div className={styles.radiusLabel}>Радиус кисти</div>
|
||||
<input
|
||||
type="range"
|
||||
min={0.015}
|
||||
max={0.18}
|
||||
step={0.001}
|
||||
value={tool.radiusN}
|
||||
onChange={(e) => {
|
||||
const v = Number((e.currentTarget as HTMLInputElement).value);
|
||||
const next = Math.max(0.01, Math.min(0.25, Number.isFinite(v) ? v : tool.radiusN));
|
||||
void fx.dispatch({ kind: 'tool.set', tool: { ...tool, radiusN: next } });
|
||||
}}
|
||||
className={styles.range}
|
||||
aria-label="Радиус кисти"
|
||||
/>
|
||||
<div className={styles.radiusValue}>{Math.round(tool.radiusN * 100)}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.spacer12} />
|
||||
</>
|
||||
) : null}
|
||||
<div className={styles.storyWrap}>
|
||||
<div className={styles.sectionLabel}>СЮЖЕТНАЯ ЛИНИЯ</div>
|
||||
<div className={styles.spacer10} />
|
||||
<div className={styles.storyScroll}>
|
||||
{history.map((gnId, idx) => {
|
||||
const gn = project?.sceneGraphNodes.find((n) => n.id === gnId);
|
||||
const s = gn ? project?.scenes[gn.sceneId] : undefined;
|
||||
const isCurrent = gnId === project?.currentGraphNodeId;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={`${gnId}_${String(idx)}`}
|
||||
disabled={!project || isCurrent}
|
||||
className={[styles.historyBtn, isCurrent ? styles.historyBtnCurrent : '']
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
title={project && !isCurrent ? 'Перейти к этой сцене' : undefined}
|
||||
onClick={() => {
|
||||
if (!project) return;
|
||||
if (isCurrent) return;
|
||||
// Перемотка: переходим на выбранный шаг без добавления нового пункта в историю.
|
||||
suppressNextHistoryPushRef.current = true;
|
||||
void api.invoke(ipcChannels.project.setCurrentGraphNode, { graphNodeId: gnId });
|
||||
}}
|
||||
>
|
||||
{isCurrent ? (
|
||||
<div className={styles.historyBadge}>ТЕКУЩАЯ СЦЕНА</div>
|
||||
) : (
|
||||
<div className={styles.historyMuted}>Пройдено</div>
|
||||
)}
|
||||
<div className={styles.historyTitle}>{s?.title ?? (gn ? String(gn.sceneId) : gnId)}</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{history.length === 0 ? <div className={styles.emptyStory}>Нет активной сцены.</div> : null}
|
||||
</div>
|
||||
</div>
|
||||
</Surface>
|
||||
|
||||
<div className={styles.rightStack}>
|
||||
<Surface className={styles.surfacePad}>
|
||||
<div className={styles.previewHeader}>
|
||||
<div className={styles.previewTitle}>Предпросмотр экрана</div>
|
||||
<div className={styles.previewActions}>
|
||||
<Button onClick={() => void api.invoke(ipcChannels.windows.closeMultiWindow, {})}>
|
||||
Выключить демонстрацию
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.spacer10} />
|
||||
{isVideoPreviewScene ? (
|
||||
<div className={styles.videoHint}>
|
||||
Видео-превью: кисть эффектов отключена (как на экране демонстрации — оверлей только для
|
||||
изображения).
|
||||
</div>
|
||||
) : null}
|
||||
<div className={styles.spacer10} />
|
||||
<div className={styles.previewFrame}>
|
||||
<div ref={previewHostRef} className={styles.previewHost}>
|
||||
<ControlScenePreview
|
||||
session={session}
|
||||
videoRef={previewVideoRef}
|
||||
onContentRectChange={setPreviewContentRect}
|
||||
/>
|
||||
</div>
|
||||
{!isVideoPreviewScene ? (
|
||||
<>
|
||||
<PixiEffectsOverlay
|
||||
state={fxMergedState}
|
||||
style={{ zIndex: 1 }}
|
||||
viewport={
|
||||
previewContentRect
|
||||
? {
|
||||
x: previewContentRect.x,
|
||||
y: previewContentRect.y,
|
||||
w: previewContentRect.w,
|
||||
h: previewContentRect.h,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
{cursorN ? (
|
||||
<div
|
||||
className={styles.brushCursor}
|
||||
style={{
|
||||
left:
|
||||
(previewContentRect ? previewContentRect.x : 0) +
|
||||
cursorN.x * (previewContentRect ? previewContentRect.w : previewSize.w),
|
||||
top:
|
||||
(previewContentRect ? previewContentRect.y : 0) +
|
||||
cursorN.y * (previewContentRect ? previewContentRect.h : previewSize.h),
|
||||
width:
|
||||
tool.radiusN *
|
||||
Math.min(
|
||||
previewContentRect ? previewContentRect.w : previewSize.w,
|
||||
previewContentRect ? previewContentRect.h : previewSize.h,
|
||||
) *
|
||||
2,
|
||||
height:
|
||||
tool.radiusN *
|
||||
Math.min(
|
||||
previewContentRect ? previewContentRect.w : previewSize.w,
|
||||
previewContentRect ? previewContentRect.h : previewSize.h,
|
||||
) *
|
||||
2,
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
<div
|
||||
className={styles.brushLayer}
|
||||
onPointerEnter={(e) => {
|
||||
const p = toNPoint(e);
|
||||
if (!p) return;
|
||||
setCursorN(p);
|
||||
}}
|
||||
onPointerLeave={() => setCursorN(null)}
|
||||
onPointerDown={(e) => {
|
||||
const p = toNPoint(e);
|
||||
if (!p) return;
|
||||
setCursorN(p);
|
||||
(e.currentTarget as HTMLDivElement).setPointerCapture(e.pointerId);
|
||||
if (tool.tool === 'eraser') {
|
||||
const rN = tool.radiusN;
|
||||
const nearest = (fxState?.instances ?? [])
|
||||
.map((inst) => {
|
||||
if (inst.type === 'fog') {
|
||||
const d = inst.points.reduce((best, q) => {
|
||||
const dx = q.x - p.x;
|
||||
const dy = q.y - p.y;
|
||||
const dd = dx * dx + dy * dy;
|
||||
return Math.min(best, dd);
|
||||
}, Number.POSITIVE_INFINITY);
|
||||
return { id: inst.id, dd: d };
|
||||
}
|
||||
if (inst.type === 'lightning') {
|
||||
const dx = inst.end.x - p.x;
|
||||
const dy = inst.end.y - p.y;
|
||||
return { id: inst.id, dd: dx * dx + dy * dy };
|
||||
}
|
||||
if (inst.type === 'freeze') {
|
||||
const dx = inst.at.x - p.x;
|
||||
const dy = inst.at.y - p.y;
|
||||
return { id: inst.id, dd: dx * dx + dy * dy };
|
||||
}
|
||||
return { id: inst.id, dd: Number.POSITIVE_INFINITY };
|
||||
})
|
||||
.sort((a, b) => a.dd - b.dd)[0];
|
||||
if (nearest && nearest.dd <= rN * rN) {
|
||||
void fx.dispatch({ kind: 'instance.remove', id: nearest.id });
|
||||
}
|
||||
return;
|
||||
}
|
||||
brushRef.current = {
|
||||
tool: tool.tool,
|
||||
startN: p,
|
||||
points: [{ x: p.x, y: p.y, tMs: Date.now() }],
|
||||
};
|
||||
setDraftFxTick((x) => x + 1);
|
||||
}}
|
||||
onPointerMove={(e) => {
|
||||
const b = brushRef.current;
|
||||
const p = toNPoint(e);
|
||||
if (!p) return;
|
||||
setCursorN(p);
|
||||
if (!b?.points) return;
|
||||
const last = b.points[b.points.length - 1];
|
||||
if (!last) return;
|
||||
const dx = p.x - last.x;
|
||||
const dy = p.y - last.y;
|
||||
const minStep = Math.max(0.004, tool.radiusN * 0.25);
|
||||
if (dx * dx + dy * dy < minStep * minStep) return;
|
||||
b.points.push({ x: p.x, y: p.y, tMs: Date.now() });
|
||||
setDraftFxTick((x) => x + 1);
|
||||
}}
|
||||
onPointerUp={() => {
|
||||
void commitStroke();
|
||||
}}
|
||||
onPointerCancel={() => {
|
||||
brushRef.current = null;
|
||||
setDraftFxTick((x) => x + 1);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</Surface>
|
||||
|
||||
<Surface className={styles.surfacePad}>
|
||||
<div className={styles.branchTitle}>Варианты ветвления</div>
|
||||
<div className={styles.branchGrid}>
|
||||
{nextScenes.map((o, i) => (
|
||||
<div key={o.graphNodeId} className={styles.branchCard}>
|
||||
<div className={styles.branchCardHeader}>
|
||||
<div className={styles.branchOption}>ОПЦИЯ {String(i + 1)}</div>
|
||||
</div>
|
||||
<div className={styles.branchName}>{o.scene.title || 'Без названия'}</div>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() =>
|
||||
void api.invoke(ipcChannels.project.setCurrentGraphNode, { graphNodeId: o.graphNodeId })
|
||||
}
|
||||
>
|
||||
Переключить
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
{nextScenes.length === 0 ? (
|
||||
<div className={styles.branchEmpty}>
|
||||
<div>Нет вариантов перехода.</div>
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={!session?.project?.currentGraphNodeId}
|
||||
onClick={() => void api.invoke(ipcChannels.windows.closeMultiWindow, {})}
|
||||
>
|
||||
Завершить показ
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</Surface>
|
||||
|
||||
<Surface className={styles.surfacePad}>
|
||||
<div className={styles.musicHeader}>
|
||||
<div className={styles.previewTitle}>Музыка</div>
|
||||
</div>
|
||||
<div className={styles.spacer10} />
|
||||
{sceneAudios.length === 0 ? (
|
||||
<div className={styles.musicEmpty}>В текущей сцене нет аудио.</div>
|
||||
) : (
|
||||
<div className={styles.audioList}>
|
||||
{sceneAudios.map(({ ref, asset }) => {
|
||||
const el = audioElsRef.current.get(ref.assetId) ?? null;
|
||||
const st = audioStatus(ref.assetId);
|
||||
const dur = el?.duration && Number.isFinite(el.duration) ? el.duration : 0;
|
||||
const cur = el?.currentTime && Number.isFinite(el.currentTime) ? el.currentTime : 0;
|
||||
const pct = dur > 0 ? Math.max(0, Math.min(1, cur / dur)) : 0;
|
||||
return (
|
||||
<div key={ref.assetId} className={styles.audioCard}>
|
||||
<div className={styles.audioMeta}>
|
||||
<div className={styles.audioName}>{asset.originalName}</div>
|
||||
<div className={styles.audioBadges}>
|
||||
<div>{ref.autoplay ? 'Авто' : 'Ручн.'}</div>
|
||||
<div>{ref.loop ? 'Цикл' : 'Один раз'}</div>
|
||||
<div title={st.detail}>{st.label}</div>
|
||||
</div>
|
||||
<div className={styles.spacer10} />
|
||||
<div
|
||||
role="slider"
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={dur > 0 ? Math.round(dur) : 0}
|
||||
aria-valuenow={Math.round(cur)}
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (!el) return;
|
||||
if (!dur) return;
|
||||
if (e.key === 'ArrowLeft') el.currentTime = Math.max(0, el.currentTime - 5);
|
||||
if (e.key === 'ArrowRight') el.currentTime = Math.min(dur, el.currentTime + 5);
|
||||
setAudioStateTick((x) => x + 1);
|
||||
}}
|
||||
onClick={(e) => {
|
||||
if (!el) return;
|
||||
if (!dur) return;
|
||||
const rect = (e.currentTarget as HTMLDivElement).getBoundingClientRect();
|
||||
const next = (e.clientX - rect.left) / rect.width;
|
||||
el.currentTime = Math.max(0, Math.min(dur, next * dur));
|
||||
setAudioStateTick((x) => x + 1);
|
||||
}}
|
||||
className={[
|
||||
styles.audioScrub,
|
||||
dur > 0 ? styles.audioScrubPointer : styles.audioScrubDefault,
|
||||
].join(' ')}
|
||||
title={dur > 0 ? 'Клик — перемотка' : 'Длительность неизвестна'}
|
||||
>
|
||||
<div
|
||||
className={styles.scrubFill}
|
||||
style={{ width: `${String(Math.round(pct * 100))}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.timeRow}>
|
||||
<div>{formatTime(cur)}</div>
|
||||
<div>{dur ? formatTime(dur) : '—:—'}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.audioTransport}>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => {
|
||||
if (!el) return;
|
||||
const m = audioMetaRef.current.get(ref.assetId) ?? { lastPlayError: null };
|
||||
audioMetaRef.current.set(ref.assetId, { ...m, lastPlayError: null });
|
||||
void el.play().catch(() => {
|
||||
const mm = audioMetaRef.current.get(ref.assetId) ?? { lastPlayError: null };
|
||||
audioMetaRef.current.set(ref.assetId, {
|
||||
...mm,
|
||||
lastPlayError: 'Не удалось запустить.',
|
||||
});
|
||||
setAudioStateTick((x) => x + 1);
|
||||
});
|
||||
}}
|
||||
>
|
||||
▶
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (!el) return;
|
||||
el.pause();
|
||||
}}
|
||||
>
|
||||
❚❚
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (!el) return;
|
||||
el.pause();
|
||||
el.currentTime = 0;
|
||||
setAudioStateTick((x) => x + 1);
|
||||
}}
|
||||
>
|
||||
■
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</Surface>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
.root {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
.video {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.placeholder {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: var(--color-overlay-dark-6);
|
||||
}
|
||||
|
||||
.controls {
|
||||
position: absolute;
|
||||
left: 12px;
|
||||
right: 12px;
|
||||
bottom: 12px;
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.scrub {
|
||||
height: 10px;
|
||||
border-radius: var(--radius-pill);
|
||||
border: 1px solid var(--stroke);
|
||||
background: var(--color-overlay-dark-3);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.scrubPointer {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.scrubDefault {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.scrubFill {
|
||||
height: 100%;
|
||||
background: var(--accent-fill-solid);
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
color: var(--text2);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
|
||||
.transport {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.transportBtn {
|
||||
width: 34px;
|
||||
height: 30px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--stroke-light);
|
||||
background: var(--color-overlay-dark-3);
|
||||
color: var(--text-muted-on-dark-2);
|
||||
cursor: pointer;
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { computeTimeSec } from '../../main/video/videoPlaybackStore';
|
||||
import type { SessionState } from '../../shared/ipc/contracts';
|
||||
import { RotatedImage } from '../shared/RotatedImage';
|
||||
import { useAssetUrl } from '../shared/useAssetImageUrl';
|
||||
import { useVideoPlaybackState } from '../shared/video/useVideoPlaybackState';
|
||||
|
||||
import styles from './ControlScenePreview.module.css';
|
||||
|
||||
type Props = {
|
||||
session: SessionState | null;
|
||||
videoRef: React.RefObject<HTMLVideoElement | null>;
|
||||
onContentRectChange?: (rect: { x: number; y: number; w: number; h: number }) => void;
|
||||
};
|
||||
|
||||
function fmt(sec: number): string {
|
||||
if (!Number.isFinite(sec) || sec < 0) return '0:00';
|
||||
const s = Math.floor(sec);
|
||||
const m = Math.floor(s / 60);
|
||||
const r = s % 60;
|
||||
return `${String(m)}:${String(r).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
export function ControlScenePreview({ session, videoRef, onContentRectChange }: Props) {
|
||||
const [vp, video] = useVideoPlaybackState();
|
||||
const scene =
|
||||
session?.project && session.currentSceneId ? session.project.scenes[session.currentSceneId] : undefined;
|
||||
const url = useAssetUrl(scene?.previewAssetId ?? null);
|
||||
const rot = scene?.previewRotationDeg ?? 0;
|
||||
const isVideo = scene?.previewAssetType === 'video';
|
||||
const assetId = scene?.previewAssetType === 'video' ? scene.previewAssetId : null;
|
||||
|
||||
const [tick, setTick] = useState(0);
|
||||
const dur = useMemo(
|
||||
() => {
|
||||
const v = videoRef.current;
|
||||
if (!v) return 0;
|
||||
return Number.isFinite(v.duration) ? v.duration : 0;
|
||||
},
|
||||
// tick: перечитываем duration из video ref на каждом кадре RAF
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- намеренно
|
||||
[tick, videoRef],
|
||||
);
|
||||
const cur = useMemo(
|
||||
() => {
|
||||
const v = videoRef.current;
|
||||
if (!v) return 0;
|
||||
return Number.isFinite(v.currentTime) ? v.currentTime : 0;
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- намеренно
|
||||
[tick, videoRef],
|
||||
);
|
||||
const pct = dur > 0 ? Math.max(0, Math.min(1, cur / dur)) : 0;
|
||||
|
||||
useEffect(() => {
|
||||
if (!isVideo) return;
|
||||
let raf = 0;
|
||||
const loop = () => {
|
||||
setTick((x) => x + 1);
|
||||
raf = window.requestAnimationFrame(loop);
|
||||
};
|
||||
raf = window.requestAnimationFrame(loop);
|
||||
return () => window.cancelAnimationFrame(raf);
|
||||
}, [isVideo]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isVideo) return;
|
||||
void video.dispatch({
|
||||
kind: 'target.set',
|
||||
assetId,
|
||||
autostart: scene.previewVideoAutostart,
|
||||
});
|
||||
}, [assetId, isVideo, scene, video]);
|
||||
|
||||
useEffect(() => {
|
||||
const v = videoRef.current;
|
||||
if (!v) return;
|
||||
if (!vp) return;
|
||||
if (vp.targetAssetId !== assetId) return;
|
||||
v.playbackRate = vp.playbackRate;
|
||||
const desired = computeTimeSec(vp, vp.serverNowMs);
|
||||
if (Number.isFinite(desired) && Math.abs(v.currentTime - desired) > 0.25) {
|
||||
v.currentTime = Math.max(0, desired);
|
||||
}
|
||||
if (vp.playing) {
|
||||
void v.play().catch(() => undefined);
|
||||
} else {
|
||||
v.pause();
|
||||
}
|
||||
}, [assetId, vp, videoRef]);
|
||||
|
||||
const scrubClass = [styles.scrub, dur ? styles.scrubPointer : styles.scrubDefault].join(' ');
|
||||
|
||||
return (
|
||||
<div className={styles.root}>
|
||||
{url && scene?.previewAssetType === 'image' ? (
|
||||
<RotatedImage url={url} rotationDeg={rot} mode="contain" onContentRectChange={onContentRectChange} />
|
||||
) : url && isVideo ? (
|
||||
<video
|
||||
ref={(el) => {
|
||||
(videoRef as unknown as { current: HTMLVideoElement | null }).current = el;
|
||||
}}
|
||||
className={styles.video}
|
||||
src={url}
|
||||
playsInline
|
||||
preload="auto"
|
||||
>
|
||||
<track kind="captions" srcLang="ru" label="Превью без субтитров" />
|
||||
</video>
|
||||
) : (
|
||||
<div className={styles.placeholder} />
|
||||
)}
|
||||
|
||||
{isVideo ? (
|
||||
<div className={styles.controls}>
|
||||
<div
|
||||
role="slider"
|
||||
tabIndex={0}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={dur > 0 ? Math.round(dur) : 0}
|
||||
aria-valuenow={Math.round(cur)}
|
||||
className={scrubClass}
|
||||
onClick={(e) => {
|
||||
const v = videoRef.current;
|
||||
if (!v || !dur) return;
|
||||
const rect = (e.currentTarget as HTMLDivElement).getBoundingClientRect();
|
||||
const next = (e.clientX - rect.left) / rect.width;
|
||||
void video.dispatch({ kind: 'seek', timeSec: Math.max(0, Math.min(dur, next * dur)) });
|
||||
setTick((x) => x + 1);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (!dur) return;
|
||||
if (e.key === 'ArrowLeft') void video.dispatch({ kind: 'seek', timeSec: Math.max(0, cur - 5) });
|
||||
if (e.key === 'ArrowRight')
|
||||
void video.dispatch({ kind: 'seek', timeSec: Math.min(dur, cur + 5) });
|
||||
if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') setTick((x) => x + 1);
|
||||
}}
|
||||
title="Клик — перемотка"
|
||||
>
|
||||
<div className={styles.scrubFill} style={{ width: `${String(Math.round(pct * 100))}%` }} />
|
||||
</div>
|
||||
<div className={styles.row}>
|
||||
<div className={styles.transport}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.transportBtn}
|
||||
onClick={() => void video.dispatch({ kind: 'play' })}
|
||||
title="Play"
|
||||
>
|
||||
▶
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.transportBtn}
|
||||
onClick={() => void video.dispatch({ kind: 'pause' })}
|
||||
title="Pause"
|
||||
>
|
||||
❚❚
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.transportBtn}
|
||||
onClick={() => {
|
||||
void video.dispatch({ kind: 'stop' });
|
||||
setTick((x) => x + 1);
|
||||
}}
|
||||
title="Stop"
|
||||
>
|
||||
■
|
||||
</button>
|
||||
</div>
|
||||
<div>
|
||||
{fmt(cur)} / {dur ? fmt(dur) : '—:—'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
function readControlApp(): string {
|
||||
return fs.readFileSync(path.join(here, 'ControlApp.tsx'), 'utf8');
|
||||
}
|
||||
|
||||
function readControlAppCss(): string {
|
||||
return fs.readFileSync(path.join(here, 'ControlApp.module.css'), 'utf8');
|
||||
}
|
||||
|
||||
void test('ControlApp: эффекты в пульте, иконки с тултипами и подписью для a11y', () => {
|
||||
const src = readControlApp();
|
||||
assert.ok(src.includes('ЭФФЕКТЫ'));
|
||||
assert.ok(src.includes('title="Туман"'));
|
||||
assert.ok(src.includes('ariaLabel="Туман"'));
|
||||
assert.ok(src.includes('iconOnly'));
|
||||
assert.ok(src.includes('title="Очистить эффекты"'));
|
||||
assert.ok(src.includes('ariaLabel="Очистить эффекты"'));
|
||||
assert.ok(src.includes('#e5484d'));
|
||||
const fx = src.indexOf('ЭФФЕКТЫ');
|
||||
const story = src.indexOf('СЮЖЕТНАЯ ЛИНИЯ');
|
||||
assert.ok(fx !== -1 && story !== -1 && fx < story, 'Блок эффектов должен быть выше сюжетной линии');
|
||||
});
|
||||
|
||||
void test('ControlApp: сюжетная линия — колонка сверху вниз и фон как у карточек ветвления', () => {
|
||||
const src = readControlApp();
|
||||
const css = readControlAppCss();
|
||||
const story = src.indexOf('СЮЖЕТНАЯ ЛИНИЯ');
|
||||
assert.ok(story !== -1);
|
||||
assert.ok(src.includes('className={styles.storyScroll}'));
|
||||
assert.match(css, /\.storyScroll[\s\S]*?justify-content:\s*flex-start/);
|
||||
assert.match(css, /\.storyScroll[\s\S]*?background:\s*var\(--color-overlay-dark-2\)/);
|
||||
assert.match(css, /\.branchCard[\s\S]*?background:\s*var\(--color-overlay-dark-2\)/);
|
||||
});
|
||||
|
||||
void test('ControlApp: слой кисти не использует курсор not-allowed (ластик тоже crosshair)', () => {
|
||||
const src = readControlApp();
|
||||
const css = readControlAppCss();
|
||||
assert.ok(!src.includes("tool.tool === 'eraser' ? 'not-allowed'"));
|
||||
assert.ok(src.includes('className={styles.brushLayer}'));
|
||||
assert.match(css, /\.brushLayer[\s\S]*?cursor:\s*crosshair/);
|
||||
});
|
||||
|
||||
void test('ControlApp: радиус кисти не в блоке предпросмотра', () => {
|
||||
const src = readControlApp();
|
||||
const previewLabel = src.indexOf('Предпросмотр экрана');
|
||||
const radius = src.indexOf('Радиус кисти');
|
||||
assert.ok(previewLabel !== -1 && radius !== -1);
|
||||
assert.ok(
|
||||
radius < previewLabel,
|
||||
'Слайдер радиуса должен быть в пульте (файл: выше заголовка предпросмотра)',
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
import React from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
|
||||
import '../shared/ui/globals.css';
|
||||
import { ControlApp } from './ControlApp';
|
||||
|
||||
const rootEl = document.getElementById('root');
|
||||
if (!rootEl) {
|
||||
throw new Error('Missing #root element');
|
||||
}
|
||||
|
||||
createRoot(rootEl).render(
|
||||
<React.StrictMode>
|
||||
<ControlApp />
|
||||
</React.StrictMode>,
|
||||
);
|
||||
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="icon" href="/app-window-icon.png" type="image/png" />
|
||||
<title>DnD Player — Editor</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/editor/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,619 @@
|
||||
.topBarRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.brandButton {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.brandLogo {
|
||||
flex-shrink: 0;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.brandTitle {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.fileToolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
color: var(--text2);
|
||||
}
|
||||
|
||||
.fileMenuTrigger {
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text2);
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.flex1 {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.appVersion {
|
||||
flex-shrink: 0;
|
||||
font-size: var(--text-xs);
|
||||
color: var(--text2);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.headerActions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.editorSidebar {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr;
|
||||
padding: 12px;
|
||||
border-right: 1px solid var(--stroke);
|
||||
background: var(--editor-column-bg);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.editorGraphHost {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--bg0);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.editorInspector {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
padding: 14px;
|
||||
border-left: 1px solid var(--stroke);
|
||||
background: var(--editor-column-bg);
|
||||
}
|
||||
|
||||
.gridTools {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.spacer14 {
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.sidebarScroll {
|
||||
overflow: auto;
|
||||
padding-right: 2px;
|
||||
min-height: 0;
|
||||
align-self: stretch;
|
||||
}
|
||||
|
||||
.sceneListGrid {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
align-content: start;
|
||||
justify-items: stretch;
|
||||
}
|
||||
|
||||
.centerEmpty {
|
||||
height: 100%;
|
||||
flex: 1;
|
||||
background: var(--bg0);
|
||||
}
|
||||
|
||||
.inspectorTitle {
|
||||
font-weight: 800;
|
||||
margin-bottom: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.inspectorScroll {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: var(--text2);
|
||||
}
|
||||
|
||||
.fileMenu {
|
||||
position: fixed;
|
||||
min-width: 220px;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--stroke);
|
||||
background: var(--color-surface-elevated-2);
|
||||
box-shadow: var(--shadow-lg);
|
||||
padding: 6px;
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
z-index: var(--z-file-menu);
|
||||
}
|
||||
|
||||
.fileMenuItem {
|
||||
text-align: left;
|
||||
padding: 10px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text0);
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.modalBackdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: var(--z-modal-backdrop);
|
||||
border: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
background: var(--color-scrim);
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.modalDialog {
|
||||
position: fixed;
|
||||
z-index: var(--z-modal);
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 520px;
|
||||
max-width: calc(100vw - 32px);
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid var(--stroke);
|
||||
background: var(--color-surface-elevated);
|
||||
box-shadow: var(--shadow-xl);
|
||||
padding: 16px;
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.modalHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.modalTitle {
|
||||
font-weight: 900;
|
||||
font-size: var(--text-lg);
|
||||
}
|
||||
|
||||
.modalClose {
|
||||
border: none;
|
||||
background: var(--panel2);
|
||||
color: var(--text2);
|
||||
border-radius: var(--radius-sm);
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
cursor: pointer;
|
||||
font-size: 18px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.fieldGrid {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.fieldLabel {
|
||||
color: var(--text2);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.fieldError {
|
||||
color: var(--color-danger);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
|
||||
.selectInput {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 8px 10px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--stroke);
|
||||
background: var(--bg0);
|
||||
color: var(--text0);
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.rowFlex {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.modalFooter {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.fileSuffix {
|
||||
color: var(--text2);
|
||||
font-size: var(--text-xs);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.projectPicker {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.projectPickerTitle {
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.projectPickerForm {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.spacer6 {
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
.sectionLabel {
|
||||
color: var(--text2);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 900;
|
||||
letter-spacing: 0.6px;
|
||||
}
|
||||
|
||||
.projectListScroll {
|
||||
overflow: auto;
|
||||
padding-right: 2px;
|
||||
}
|
||||
|
||||
.projectList {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.projectCard {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
padding: 12px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid var(--stroke);
|
||||
background: var(--color-overlay-dark-2);
|
||||
}
|
||||
|
||||
.projectCardBody {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
cursor: pointer;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.projectCardMenuBtn {
|
||||
flex-shrink: 0;
|
||||
margin: -4px -4px 0 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text2);
|
||||
cursor: pointer;
|
||||
padding: 4px 8px;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 18px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.projectCardMenuBtn:hover {
|
||||
background: var(--panel2);
|
||||
color: var(--text0);
|
||||
}
|
||||
|
||||
.projectCardName {
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.projectCardMeta {
|
||||
color: var(--text2);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
|
||||
.sceneInspector {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.labelSm {
|
||||
color: var(--text2);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.spacer8 {
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
.textarea {
|
||||
min-height: 92px;
|
||||
padding: 12px;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--stroke);
|
||||
background: var(--color-overlay-dark-3);
|
||||
resize: none;
|
||||
color: var(--text1);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.hint {
|
||||
color: var(--text2);
|
||||
font-size: var(--text-xs);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.previewBox {
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--stroke);
|
||||
overflow: hidden;
|
||||
background: var(--color-overlay-dark-3);
|
||||
aspect-ratio: 16 / 9;
|
||||
max-height: 140px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.videoCover {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.previewEmpty {
|
||||
color: var(--text2);
|
||||
font-size: var(--text-xs);
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.actionsRow {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.checkboxLabel {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
color: var(--text2);
|
||||
}
|
||||
|
||||
.checkboxLabelSm {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.spanSm {
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.spanXs {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.audioDrop {
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px dashed var(--stroke2);
|
||||
padding: 10px;
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.audioList {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
max-height: 160px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.audioRow {
|
||||
font-size: var(--text-xs);
|
||||
padding: 6px 8px;
|
||||
border-radius: var(--radius-xs);
|
||||
background: var(--color-overlay-dark-3);
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.audioName {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.audioControls {
|
||||
color: var(--text2);
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.audioRemove {
|
||||
flex-shrink: 0;
|
||||
min-width: 28px;
|
||||
min-height: 28px;
|
||||
padding: 4px;
|
||||
margin: -4px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-muted-on-dark);
|
||||
cursor: pointer;
|
||||
line-height: 1;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.audioRemove:hover {
|
||||
color: rgba(255, 255, 255, 0.72);
|
||||
}
|
||||
|
||||
.audioRemoveIcon {
|
||||
display: block;
|
||||
opacity: 0.92;
|
||||
}
|
||||
|
||||
.hintBlock {
|
||||
color: var(--text2);
|
||||
}
|
||||
|
||||
.sceneCard {
|
||||
border-radius: var(--scene-tile-radius);
|
||||
overflow: hidden;
|
||||
cursor: grab;
|
||||
border: 1px solid transparent;
|
||||
box-sizing: border-box;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.sceneCard:not(.sceneCardActive):hover {
|
||||
background: var(--scene-list-hover-bg);
|
||||
}
|
||||
|
||||
.sceneCardActive {
|
||||
border-color: var(--scene-list-selected-border);
|
||||
background: var(--scene-list-selected-bg);
|
||||
}
|
||||
|
||||
.sceneThumb {
|
||||
height: 92px;
|
||||
position: relative;
|
||||
box-sizing: border-box;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.sceneThumbInner {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sceneThumbVideo {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.sceneThumbEmpty {
|
||||
height: 92px;
|
||||
position: relative;
|
||||
box-sizing: border-box;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.sceneThumbEmptyInner {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 10px;
|
||||
background: var(--bg0);
|
||||
}
|
||||
|
||||
.sceneCardBody {
|
||||
padding: 10px;
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.sceneCardHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.badgeCurrent {
|
||||
font-size: var(--text-xs);
|
||||
color: var(--accent2);
|
||||
}
|
||||
|
||||
.sceneMenuBtn {
|
||||
margin-left: auto;
|
||||
border: none;
|
||||
background: var(--panel2);
|
||||
border-radius: var(--radius-xs);
|
||||
padding: 4px 10px;
|
||||
cursor: pointer;
|
||||
color: var(--text2);
|
||||
line-height: 1;
|
||||
font-size: 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sceneCardTitle {
|
||||
font-weight: 750;
|
||||
}
|
||||
|
||||
.menuBackdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: var(--z-menu-backdrop);
|
||||
border: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
background: transparent;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.sceneCtxMenu {
|
||||
position: fixed;
|
||||
z-index: var(--z-file-menu);
|
||||
min-width: 160px;
|
||||
padding: 6px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--stroke);
|
||||
background: var(--color-surface-menu);
|
||||
box-shadow: var(--shadow-menu);
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.sceneCtxDanger {
|
||||
text-align: left;
|
||||
padding: 8px 10px;
|
||||
border-radius: var(--radius-xs);
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--color-danger);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
width: 100%;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,276 @@
|
||||
.nodeWrap {
|
||||
width: 220px;
|
||||
}
|
||||
|
||||
.handle {
|
||||
background: var(--stroke-handle);
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
.card {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
border-radius: var(--scene-tile-radius);
|
||||
overflow: hidden;
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
padding: 8px;
|
||||
background: #18181b;
|
||||
border: 2px solid rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
.cardActive {
|
||||
border-color: var(--graph-node-active-border);
|
||||
box-shadow: 0 25px 50px -12px rgba(139, 92, 246, 0.1);
|
||||
}
|
||||
|
||||
.previewShell {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 135px;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
background: #0c0c0e;
|
||||
}
|
||||
|
||||
.previewFill {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.previewPlaceholder {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: #0c0c0e;
|
||||
}
|
||||
|
||||
.badgeStart {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
left: 8px;
|
||||
z-index: 2;
|
||||
font-size: 8.5px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.4px;
|
||||
padding: 4px 8px;
|
||||
border-radius: 8px;
|
||||
background: var(--accent-fill-solid);
|
||||
color: var(--text-on-accent);
|
||||
box-shadow: var(--shadow-start-badge);
|
||||
}
|
||||
|
||||
.cornerBadges {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.mediaBadge {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 999px;
|
||||
background: rgba(0, 0, 0, 0.72);
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.badgeGlyph {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.imageCover,
|
||||
.videoCover {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.imageCover {
|
||||
user-select: none;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.nodeBody {
|
||||
padding-top: 8px;
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-weight: 900;
|
||||
font-size: 20px;
|
||||
line-height: 1.2;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.musicParams {
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.musicParam {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: calc(14px * 0.7);
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.02em;
|
||||
color: rgba(255, 255, 255, 0.95);
|
||||
text-transform: none;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.musicParamIcon {
|
||||
flex-shrink: 0;
|
||||
color: #a78bfa;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.canvasWrap {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.canvasWrap :global(.react-flow) {
|
||||
background-color: var(--bg0);
|
||||
}
|
||||
|
||||
.canvasWrap :global(.react-flow__renderer) {
|
||||
background-color: var(--bg0);
|
||||
}
|
||||
|
||||
.canvasWrap :global(.react-flow__attribution) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.zoomPanel {
|
||||
margin: 0 0 14px;
|
||||
}
|
||||
|
||||
.zoomBar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
padding: 4px 8px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
background: rgba(24, 24, 27, 0.96);
|
||||
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.35);
|
||||
color: rgba(255, 255, 255, 0.55);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.zoomBtn {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
min-width: 30px;
|
||||
height: 30px;
|
||||
padding: 0 6px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
font-size: 17px;
|
||||
font-weight: 500;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.zoomBtn:hover {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: rgba(255, 255, 255, 0.82);
|
||||
}
|
||||
|
||||
.zoomPct {
|
||||
min-width: 44px;
|
||||
text-align: center;
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.zoomDivider {
|
||||
width: 1px;
|
||||
height: 18px;
|
||||
margin: 0 4px;
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.zoomFitIcon {
|
||||
display: block;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.menuBackdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: var(--z-menu-backdrop);
|
||||
border: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
background: transparent;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.ctxMenu {
|
||||
position: fixed;
|
||||
z-index: var(--z-file-menu);
|
||||
min-width: 200px;
|
||||
padding: 6px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--stroke);
|
||||
background: var(--color-surface-menu);
|
||||
box-shadow: var(--shadow-menu);
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.ctxItem {
|
||||
text-align: left;
|
||||
padding: 8px 10px;
|
||||
border-radius: var(--radius-xs);
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text1);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.ctxItemDanger {
|
||||
text-align: left;
|
||||
padding: 8px 10px;
|
||||
border-radius: var(--radius-xs);
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--color-danger);
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
width: 100%;
|
||||
}
|
||||
@@ -0,0 +1,496 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import ReactFlow, {
|
||||
Background,
|
||||
Handle,
|
||||
MarkerType,
|
||||
Panel,
|
||||
Position,
|
||||
ReactFlowProvider,
|
||||
useEdgesState,
|
||||
useNodesState,
|
||||
useReactFlow,
|
||||
useStore,
|
||||
type Connection,
|
||||
type Edge,
|
||||
type Node,
|
||||
type NodeProps,
|
||||
} from 'reactflow';
|
||||
import 'reactflow/dist/style.css';
|
||||
|
||||
import { isSceneGraphEdgeRejected } from '../../../shared/graph/sceneGraphEdgeRules';
|
||||
import type {
|
||||
AssetId,
|
||||
GraphNodeId,
|
||||
Scene,
|
||||
SceneGraphEdge,
|
||||
SceneGraphNode,
|
||||
SceneId,
|
||||
} from '../../../shared/types';
|
||||
import { RotatedImage } from '../../shared/RotatedImage';
|
||||
import { useAssetUrl } from '../../shared/useAssetImageUrl';
|
||||
|
||||
import styles from './SceneGraph.module.css';
|
||||
|
||||
/** MIME для перетаскивания сцены из списка на граф (см. EditorApp). */
|
||||
export const DND_SCENE_ID_MIME = 'application/x-dnd-scene-id';
|
||||
|
||||
/** Примерные размеры карточки узла — чтобы точка сброса совпадала с центром карточки. */
|
||||
const SCENE_CARD_W = 220;
|
||||
const SCENE_CARD_H = 248;
|
||||
|
||||
export type SceneGraphProps = {
|
||||
sceneGraphNodes: SceneGraphNode[];
|
||||
sceneGraphEdges: SceneGraphEdge[];
|
||||
sceneById: Record<SceneId, Scene>;
|
||||
currentSceneId: SceneId | null;
|
||||
onCurrentSceneChange: (id: SceneId) => void;
|
||||
onConnect: (sourceGraphNodeId: GraphNodeId, targetGraphNodeId: GraphNodeId) => void;
|
||||
onDisconnect: (edgeId: string) => void;
|
||||
onNodePositionCommit: (nodeId: GraphNodeId, x: number, y: number) => void;
|
||||
onRemoveGraphNodes: (nodeIds: GraphNodeId[]) => void;
|
||||
onRemoveGraphNode: (graphNodeId: GraphNodeId) => void;
|
||||
onSetGraphNodeStart: (graphNodeId: GraphNodeId | null) => void;
|
||||
onDropSceneFromList: (sceneId: SceneId, x: number, y: number) => void;
|
||||
};
|
||||
|
||||
type SceneCardData = {
|
||||
sceneId: SceneId;
|
||||
title: string;
|
||||
active: boolean;
|
||||
previewAssetId: AssetId | null;
|
||||
previewAssetType: 'image' | 'video' | null;
|
||||
previewVideoAutostart: boolean;
|
||||
previewRotationDeg: 0 | 90 | 180 | 270;
|
||||
isStartScene: boolean;
|
||||
hasSceneAudio: boolean;
|
||||
previewIsVideo: boolean;
|
||||
hasAnyAudioLoop: boolean;
|
||||
hasAnyAudioAutoplay: boolean;
|
||||
showPreviewVideoAutostart: boolean;
|
||||
showPreviewVideoLoop: boolean;
|
||||
};
|
||||
|
||||
function IconAudioBadge() {
|
||||
return (
|
||||
<svg className={styles.badgeGlyph} viewBox="0 0 24 24" width={14} height={14} aria-hidden>
|
||||
<path fill="currentColor" d="M12 3v10.55A4 4 0 1 0 14 17V7h4V3h-6zM6 15a2 2 0 1 0 4 0 2 2 0 0 0-4 0z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function IconVideoBadge() {
|
||||
return (
|
||||
<svg className={styles.badgeGlyph} viewBox="0 0 24 24" width={14} height={14} aria-hidden>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M4 6.5A2.5 2.5 0 0 1 6.5 4h7A2.5 2.5 0 0 1 16 6.5v11a2.5 2.5 0 0 1-2.5 2.5h-7A2.5 2.5 0 0 1 4 17.5v-11zM19 8.2l-3 2.2v3.2l3 2.2V8.2z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function IconLoopParam() {
|
||||
return (
|
||||
<svg className={styles.musicParamIcon} viewBox="0 0 24 24" width={11} height={11} aria-hidden>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M12 4V1L8 5l4 4V6c3.31 0 6 2.69 6 6 0 1.01-.25 1.97-.7 2.8l1.46 1.46A7.93 7.93 0 0 0 20 12c0-4.42-3.58-8-8-8zm0 14c-3.31 0-6-2.69-6-6 0-1.01.25-1.97.7-2.8L5.24 7.74A7.93 7.93 0 0 0 4 12c0 4.42 3.58 8 8 8v3l4-4-4-4v3z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function IconAutoplayParam() {
|
||||
return (
|
||||
<svg className={styles.musicParamIcon} viewBox="0 0 24 24" width={11} height={11} aria-hidden>
|
||||
<path fill="currentColor" d="M13 2 3 14h7v8l11-14h-8l2-8z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/** Иконка для «Авто превью» (видео-превью). */
|
||||
function IconVideoPreviewAutostart() {
|
||||
return (
|
||||
<svg className={styles.musicParamIcon} viewBox="0 0 24 24" width={11} height={11} aria-hidden>
|
||||
<path fill="currentColor" d="M8 5v14l11-7-11-7z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function SceneCardNode({ data }: NodeProps<SceneCardData>) {
|
||||
const url = useAssetUrl(data.previewAssetId);
|
||||
const cardClass = [styles.card, data.active ? styles.cardActive : ''].filter(Boolean).join(' ');
|
||||
const showCornerVideo = data.previewIsVideo;
|
||||
const showCornerAudio = data.hasSceneAudio;
|
||||
return (
|
||||
<div className={styles.nodeWrap}>
|
||||
<Handle type="target" position={Position.Top} className={styles.handle} />
|
||||
<div className={cardClass}>
|
||||
<div className={styles.previewShell}>
|
||||
{data.isStartScene ? <div className={styles.badgeStart}>НАЧАЛО</div> : null}
|
||||
{url && data.previewAssetType === 'image' ? (
|
||||
<div className={styles.previewFill}>
|
||||
{data.previewRotationDeg === 0 ? (
|
||||
<img src={url} alt="" className={styles.imageCover} draggable={false} />
|
||||
) : (
|
||||
<RotatedImage
|
||||
url={url}
|
||||
rotationDeg={data.previewRotationDeg}
|
||||
mode="cover"
|
||||
style={{ width: '100%', height: '100%' }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : url && data.previewAssetType === 'video' ? (
|
||||
<video
|
||||
src={url}
|
||||
muted
|
||||
playsInline
|
||||
preload="metadata"
|
||||
className={styles.videoCover}
|
||||
onLoadedData={(e) => {
|
||||
const v = e.currentTarget;
|
||||
try {
|
||||
v.currentTime = 0;
|
||||
v.pause();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className={styles.previewPlaceholder} aria-hidden />
|
||||
)}
|
||||
{showCornerVideo || showCornerAudio ? (
|
||||
<div className={styles.cornerBadges}>
|
||||
{showCornerVideo ? (
|
||||
<span className={styles.mediaBadge} title="Видео">
|
||||
<IconVideoBadge />
|
||||
</span>
|
||||
) : null}
|
||||
{showCornerAudio ? (
|
||||
<span className={styles.mediaBadge} title="Аудио">
|
||||
<IconAudioBadge />
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className={styles.nodeBody}>
|
||||
<div className={styles.title}>{data.title || 'Без названия'}</div>
|
||||
{data.hasAnyAudioLoop || data.hasAnyAudioAutoplay ? (
|
||||
<div className={styles.musicParams}>
|
||||
{data.hasAnyAudioLoop ? (
|
||||
<div className={styles.musicParam}>
|
||||
<IconLoopParam />
|
||||
<span>Цикл</span>
|
||||
</div>
|
||||
) : null}
|
||||
{data.hasAnyAudioAutoplay ? (
|
||||
<div className={styles.musicParam}>
|
||||
<IconAutoplayParam />
|
||||
<span>Автостарт</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
{data.showPreviewVideoAutostart || data.showPreviewVideoLoop ? (
|
||||
<div className={styles.musicParams}>
|
||||
{data.showPreviewVideoAutostart ? (
|
||||
<div className={styles.musicParam}>
|
||||
<IconVideoPreviewAutostart />
|
||||
<span>Авто превью</span>
|
||||
</div>
|
||||
) : null}
|
||||
{data.showPreviewVideoLoop ? (
|
||||
<div className={styles.musicParam}>
|
||||
<IconLoopParam />
|
||||
<span>Цикл видео</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<Handle type="source" position={Position.Bottom} className={styles.handle} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const nodeTypes = { sceneCard: SceneCardNode };
|
||||
|
||||
function GraphZoomToolbar() {
|
||||
const { zoomIn, zoomOut, fitView } = useReactFlow();
|
||||
const zoom = useStore((s) => s.transform[2]);
|
||||
const pct = Math.max(1, Math.round(zoom * 100));
|
||||
|
||||
return (
|
||||
<Panel position="bottom-center" className={styles.zoomPanel}>
|
||||
<div className={styles.zoomBar} role="toolbar" aria-label="Масштаб графа">
|
||||
<button type="button" className={styles.zoomBtn} onClick={() => zoomIn()} aria-label="Увеличить">
|
||||
+
|
||||
</button>
|
||||
<span className={styles.zoomPct}>{pct}%</span>
|
||||
<button type="button" className={styles.zoomBtn} onClick={() => zoomOut()} aria-label="Уменьшить">
|
||||
−
|
||||
</button>
|
||||
<span className={styles.zoomDivider} aria-hidden />
|
||||
<button
|
||||
type="button"
|
||||
className={styles.zoomBtn}
|
||||
onClick={() => fitView({ padding: 0.25 })}
|
||||
aria-label="Показать всё"
|
||||
title="Показать всё"
|
||||
>
|
||||
<svg className={styles.zoomFitIcon} viewBox="0 0 24 24" width={18} height={18} aria-hidden>
|
||||
<path
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
d="M9 4H4v5M15 4h5v5M9 20H4v-5M15 20h5v-5"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
||||
function SceneGraphCanvas({
|
||||
sceneGraphNodes,
|
||||
sceneGraphEdges,
|
||||
sceneById,
|
||||
currentSceneId,
|
||||
onCurrentSceneChange,
|
||||
onConnect,
|
||||
onDisconnect,
|
||||
onNodePositionCommit,
|
||||
onRemoveGraphNodes,
|
||||
onRemoveGraphNode,
|
||||
onSetGraphNodeStart,
|
||||
onDropSceneFromList,
|
||||
}: SceneGraphProps) {
|
||||
const { screenToFlowPosition } = useReactFlow();
|
||||
const [menu, setMenu] = useState<{ x: number; y: number; graphNodeId: GraphNodeId } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!menu) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') setMenu(null);
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, [menu]);
|
||||
|
||||
const menuNodeIsStart = useMemo(() => {
|
||||
if (!menu) return false;
|
||||
return sceneGraphNodes.some((n) => n.id === menu.graphNodeId && n.isStartScene);
|
||||
}, [menu, sceneGraphNodes]);
|
||||
|
||||
const desiredNodes = useMemo<Node<SceneCardData>[]>(() => {
|
||||
return sceneGraphNodes.map((gn) => {
|
||||
const s = sceneById[gn.sceneId];
|
||||
const active = gn.sceneId === currentSceneId;
|
||||
const audios = s?.media.audios ?? [];
|
||||
return {
|
||||
id: gn.id,
|
||||
type: 'sceneCard',
|
||||
position: { x: gn.x, y: gn.y },
|
||||
data: {
|
||||
sceneId: gn.sceneId,
|
||||
title: s?.title ?? '',
|
||||
active,
|
||||
previewAssetId: s?.previewAssetId ?? null,
|
||||
previewAssetType: s?.previewAssetType ?? null,
|
||||
previewVideoAutostart: s?.previewVideoAutostart ?? false,
|
||||
previewRotationDeg: s?.previewRotationDeg ?? 0,
|
||||
isStartScene: gn.isStartScene,
|
||||
hasSceneAudio: audios.length >= 1,
|
||||
previewIsVideo: s?.previewAssetType === 'video',
|
||||
hasAnyAudioLoop: audios.some((a) => a.loop),
|
||||
hasAnyAudioAutoplay: audios.some((a) => a.autoplay),
|
||||
showPreviewVideoAutostart: s?.previewAssetType === 'video' ? s.previewVideoAutostart : false,
|
||||
showPreviewVideoLoop: s?.previewAssetType === 'video' ? s.settings.loopVideo : false,
|
||||
},
|
||||
style: { padding: 0, background: 'transparent', border: 'none' },
|
||||
};
|
||||
});
|
||||
}, [currentSceneId, sceneById, sceneGraphNodes]);
|
||||
|
||||
const desiredEdges = useMemo<Edge[]>(() => {
|
||||
return sceneGraphEdges.map((e) => ({
|
||||
id: e.id,
|
||||
source: e.sourceGraphNodeId,
|
||||
target: e.targetGraphNodeId,
|
||||
type: 'smoothstep',
|
||||
animated: false,
|
||||
style: { stroke: 'rgba(167,139,250,0.55)', strokeWidth: 2 },
|
||||
markerEnd: { type: MarkerType.ArrowClosed, color: 'rgba(167,139,250,0.85)', strokeWidth: 2 },
|
||||
}));
|
||||
}, [sceneGraphEdges]);
|
||||
|
||||
const [nodes, setNodes, onNodesChange] = useNodesState<Node<SceneCardData>>([]);
|
||||
const [edges, setEdges, onEdgesChange] = useEdgesState<Edge>([]);
|
||||
|
||||
useEffect(() => {
|
||||
setNodes(desiredNodes as unknown as Parameters<typeof setNodes>[0]);
|
||||
setEdges(desiredEdges);
|
||||
}, [desiredEdges, desiredNodes, setEdges, setNodes]);
|
||||
|
||||
const isValidConnection = useCallback(
|
||||
(conn: Connection) => {
|
||||
const source = conn.source as GraphNodeId | null;
|
||||
const target = conn.target as GraphNodeId | null;
|
||||
if (!source || !target) return false;
|
||||
return !isSceneGraphEdgeRejected(sceneGraphNodes, sceneGraphEdges, source, target);
|
||||
},
|
||||
[sceneGraphEdges, sceneGraphNodes],
|
||||
);
|
||||
|
||||
const onConnectInternal = (conn: Connection) => {
|
||||
const source = conn.source as GraphNodeId | null;
|
||||
const target = conn.target as GraphNodeId | null;
|
||||
if (!source || !target) return;
|
||||
if (!isValidConnection(conn)) return;
|
||||
onConnect(source, target);
|
||||
};
|
||||
|
||||
const onDragOver = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = 'copy';
|
||||
};
|
||||
|
||||
const onDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
const id = e.dataTransfer.getData(DND_SCENE_ID_MIME);
|
||||
if (!id) return;
|
||||
const p = screenToFlowPosition({ x: e.clientX, y: e.clientY });
|
||||
onDropSceneFromList(id as SceneId, p.x - SCENE_CARD_W / 2, p.y - SCENE_CARD_H / 2);
|
||||
};
|
||||
|
||||
const menuPosition = useMemo(() => {
|
||||
if (!menu) return null;
|
||||
const pad = 8;
|
||||
const mw = 220;
|
||||
const mh = 120;
|
||||
const x = Math.max(pad, Math.min(menu.x, window.innerWidth - mw - pad));
|
||||
const y = Math.max(pad, Math.min(menu.y, window.innerHeight - mh - pad));
|
||||
return { x, y };
|
||||
}, [menu]);
|
||||
|
||||
return (
|
||||
<div className={styles.canvasWrap}>
|
||||
<ReactFlow
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
nodeTypes={nodeTypes}
|
||||
onNodesChange={onNodesChange}
|
||||
onNodeDragStop={(_, node) => {
|
||||
onNodePositionCommit(node.id as GraphNodeId, node.position.x, node.position.y);
|
||||
}}
|
||||
onEdgesChange={onEdgesChange}
|
||||
isValidConnection={isValidConnection}
|
||||
onConnect={onConnectInternal}
|
||||
onEdgesDelete={(eds) => {
|
||||
for (const ed of eds) {
|
||||
onDisconnect(ed.id);
|
||||
}
|
||||
}}
|
||||
onEdgeClick={(_, edge) => {
|
||||
onDisconnect(edge.id);
|
||||
}}
|
||||
onNodesDelete={(nds) => {
|
||||
onRemoveGraphNodes(nds.map((n) => n.id as GraphNodeId));
|
||||
}}
|
||||
onNodeClick={(_, node) => {
|
||||
setMenu(null);
|
||||
const d = node.data as SceneCardData;
|
||||
onCurrentSceneChange(d.sceneId);
|
||||
}}
|
||||
onNodeContextMenu={(e, node) => {
|
||||
e.preventDefault();
|
||||
setMenu({ x: e.clientX, y: e.clientY, graphNodeId: node.id as GraphNodeId });
|
||||
}}
|
||||
onPaneClick={() => {
|
||||
setMenu(null);
|
||||
}}
|
||||
onPaneContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
setMenu(null);
|
||||
}}
|
||||
onInit={(instance) => {
|
||||
instance.fitView({ padding: 0.25 });
|
||||
}}
|
||||
onDragOver={onDragOver}
|
||||
onDrop={onDrop}
|
||||
panOnScroll
|
||||
selectionOnDrag={false}
|
||||
deleteKeyCode={['Backspace', 'Delete']}
|
||||
proOptions={{ hideAttribution: true }}
|
||||
>
|
||||
<Background gap={18} size={1} color="rgba(255,255,255,0.06)" />
|
||||
<GraphZoomToolbar />
|
||||
</ReactFlow>
|
||||
{menu && menuPosition
|
||||
? createPortal(
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Закрыть меню"
|
||||
className={styles.menuBackdrop}
|
||||
onClick={() => setMenu(null)}
|
||||
/>
|
||||
<div
|
||||
role="menu"
|
||||
tabIndex={-1}
|
||||
className={styles.ctxMenu}
|
||||
style={{ left: menuPosition.x, top: menuPosition.y }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Escape') setMenu(null);
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className={styles.ctxItem}
|
||||
onClick={() => {
|
||||
if (menuNodeIsStart) {
|
||||
onSetGraphNodeStart(null);
|
||||
} else {
|
||||
onSetGraphNodeStart(menu.graphNodeId);
|
||||
}
|
||||
setMenu(null);
|
||||
}}
|
||||
>
|
||||
{menuNodeIsStart ? 'Снять метку «Начальная сцена»' : 'Начальная сцена'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className={styles.ctxItemDanger}
|
||||
onClick={() => {
|
||||
onRemoveGraphNode(menu.graphNodeId);
|
||||
setMenu(null);
|
||||
}}
|
||||
>
|
||||
Удалить
|
||||
</button>
|
||||
</div>
|
||||
</>,
|
||||
document.body,
|
||||
)
|
||||
: null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SceneGraph(props: SceneGraphProps) {
|
||||
return (
|
||||
<ReactFlowProvider>
|
||||
<SceneGraphCanvas {...props} />
|
||||
</ReactFlowProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import React from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
|
||||
import '../shared/ui/globals.css';
|
||||
import { EditorApp } from './EditorApp';
|
||||
|
||||
const rootEl = document.getElementById('root');
|
||||
if (!rootEl) {
|
||||
throw new Error('Missing #root element');
|
||||
}
|
||||
|
||||
createRoot(rootEl).render(
|
||||
<React.StrictMode>
|
||||
<EditorApp />
|
||||
</React.StrictMode>,
|
||||
);
|
||||
@@ -0,0 +1,322 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { ipcChannels } from '../../../shared/ipc/contracts';
|
||||
import type { AssetId, GraphNodeId, Project, ProjectId, Scene, SceneId } from '../../../shared/types';
|
||||
import { getDndApi } from '../../shared/dndApi';
|
||||
|
||||
type ProjectSummary = { id: ProjectId; name: string; updatedAt: string; fileName: string };
|
||||
|
||||
type State = {
|
||||
projects: ProjectSummary[];
|
||||
project: Project | null;
|
||||
selectedSceneId: SceneId | null;
|
||||
};
|
||||
|
||||
type Actions = {
|
||||
refreshProjects: () => Promise<void>;
|
||||
createProject: (name: string) => Promise<void>;
|
||||
openProject: (id: ProjectId) => Promise<void>;
|
||||
closeProject: () => Promise<void>;
|
||||
createScene: () => Promise<void>;
|
||||
selectScene: (id: SceneId) => Promise<void>;
|
||||
updateScene: (
|
||||
sceneId: SceneId,
|
||||
patch: {
|
||||
title?: string;
|
||||
description?: string;
|
||||
previewAssetId?: AssetId | null;
|
||||
previewAssetType?: 'image' | 'video' | null;
|
||||
previewVideoAutostart?: boolean;
|
||||
previewRotationDeg?: 0 | 90 | 180 | 270;
|
||||
settings?: Partial<Scene['settings']>;
|
||||
media?: Partial<Scene['media']>;
|
||||
layout?: { x: number; y: number };
|
||||
},
|
||||
) => Promise<void>;
|
||||
updateConnections: (sceneId: SceneId, connections: SceneId[]) => Promise<void>;
|
||||
importMediaToScene: (sceneId: SceneId) => Promise<void>;
|
||||
importScenePreview: (sceneId: SceneId) => Promise<void>;
|
||||
clearScenePreview: (sceneId: SceneId) => Promise<void>;
|
||||
updateSceneGraphNodePosition: (nodeId: GraphNodeId, x: number, y: number) => Promise<void>;
|
||||
addSceneGraphNode: (sceneId: SceneId, x: number, y: number) => Promise<void>;
|
||||
removeSceneGraphNode: (nodeId: GraphNodeId) => Promise<void>;
|
||||
addSceneGraphEdge: (sourceGraphNodeId: GraphNodeId, targetGraphNodeId: GraphNodeId) => Promise<void>;
|
||||
removeSceneGraphEdge: (edgeId: string) => Promise<void>;
|
||||
setSceneGraphNodeStart: (graphNodeId: GraphNodeId | null) => Promise<void>;
|
||||
deleteScene: (sceneId: SceneId) => Promise<void>;
|
||||
renameProject: (name: string, fileBaseName: string) => Promise<void>;
|
||||
importProject: () => Promise<void>;
|
||||
exportProject: (projectId: ProjectId) => Promise<void>;
|
||||
deleteProject: (projectId: ProjectId) => Promise<void>;
|
||||
};
|
||||
|
||||
function randomId(prefix: string): string {
|
||||
return `${prefix}_${Math.random().toString(16).slice(2)}_${Date.now().toString(16)}`;
|
||||
}
|
||||
|
||||
export function useProjectState(): readonly [State, Actions] {
|
||||
const api = getDndApi();
|
||||
const [state, setState] = useState<State>({ projects: [], project: null, selectedSceneId: null });
|
||||
|
||||
const actions = useMemo<Actions>(() => {
|
||||
const refreshProjects = async () => {
|
||||
const res = await api.invoke(ipcChannels.project.list, {});
|
||||
setState((s) => ({ ...s, projects: res.projects }));
|
||||
};
|
||||
|
||||
const createProject = async (name: string) => {
|
||||
const res = await api.invoke(ipcChannels.project.create, { name });
|
||||
setState((s) => ({ ...s, project: res.project, selectedSceneId: res.project.currentSceneId }));
|
||||
await refreshProjects();
|
||||
};
|
||||
|
||||
const openProject = async (id: ProjectId) => {
|
||||
const res = await api.invoke(ipcChannels.project.open, { projectId: id });
|
||||
setState((s) => ({ ...s, project: res.project, selectedSceneId: res.project.currentSceneId }));
|
||||
};
|
||||
|
||||
const closeProject = async () => {
|
||||
setState((s) => ({ ...s, project: null, selectedSceneId: null }));
|
||||
await refreshProjects();
|
||||
};
|
||||
|
||||
const createScene = async () => {
|
||||
const p = state.project;
|
||||
if (!p) return;
|
||||
const sceneId = randomId('scene') as SceneId;
|
||||
const scene: Scene = {
|
||||
id: sceneId,
|
||||
title: `Новая сцена`,
|
||||
description: '',
|
||||
previewAssetId: null,
|
||||
previewAssetType: null,
|
||||
previewVideoAutostart: false,
|
||||
previewRotationDeg: 0,
|
||||
media: { videos: [], audios: [] },
|
||||
settings: { autoplayVideo: false, autoplayAudio: true, loopVideo: true, loopAudio: true },
|
||||
connections: [],
|
||||
layout: { x: 0, y: 0 },
|
||||
};
|
||||
|
||||
await api.invoke(ipcChannels.project.updateScene, {
|
||||
sceneId,
|
||||
patch: {
|
||||
title: scene.title,
|
||||
description: scene.description,
|
||||
media: scene.media,
|
||||
settings: scene.settings,
|
||||
layout: scene.layout,
|
||||
previewAssetId: scene.previewAssetId,
|
||||
previewAssetType: scene.previewAssetType,
|
||||
previewVideoAutostart: scene.previewVideoAutostart,
|
||||
},
|
||||
});
|
||||
await api.invoke(ipcChannels.project.setCurrentScene, { sceneId });
|
||||
const res = await api.invoke(ipcChannels.project.get, {});
|
||||
setState((s) => ({ ...s, project: res.project, selectedSceneId: sceneId }));
|
||||
};
|
||||
|
||||
const selectScene = async (id: SceneId) => {
|
||||
setState((s) => ({ ...s, selectedSceneId: id }));
|
||||
await api.invoke(ipcChannels.project.setCurrentScene, { sceneId: id });
|
||||
};
|
||||
|
||||
const updateScene = async (
|
||||
sceneId: SceneId,
|
||||
patch: {
|
||||
title?: string;
|
||||
description?: string;
|
||||
previewAssetId?: AssetId | null;
|
||||
previewAssetType?: 'image' | 'video' | null;
|
||||
previewVideoAutostart?: boolean;
|
||||
previewRotationDeg?: 0 | 90 | 180 | 270;
|
||||
settings?: Partial<Scene['settings']>;
|
||||
media?: Partial<Scene['media']>;
|
||||
layout?: { x: number; y: number };
|
||||
},
|
||||
) => {
|
||||
setState((s) => {
|
||||
const p = s.project;
|
||||
if (!p) return s;
|
||||
const scene = p.scenes[sceneId];
|
||||
if (!scene) return s;
|
||||
const next: Scene = {
|
||||
...scene,
|
||||
...(patch.title !== undefined ? { title: patch.title } : null),
|
||||
...(patch.description !== undefined ? { description: patch.description } : null),
|
||||
...(patch.previewAssetId !== undefined ? { previewAssetId: patch.previewAssetId } : null),
|
||||
...(patch.previewAssetType !== undefined ? { previewAssetType: patch.previewAssetType } : null),
|
||||
...(patch.previewVideoAutostart !== undefined
|
||||
? { previewVideoAutostart: patch.previewVideoAutostart }
|
||||
: null),
|
||||
...(patch.previewRotationDeg !== undefined
|
||||
? { previewRotationDeg: patch.previewRotationDeg }
|
||||
: null),
|
||||
...(patch.settings ? { settings: { ...scene.settings, ...patch.settings } } : null),
|
||||
...(patch.media ? { media: { ...scene.media, ...patch.media } } : null),
|
||||
layout: patch.layout ? { ...scene.layout, ...patch.layout } : scene.layout,
|
||||
};
|
||||
const scenes = { ...p.scenes, [sceneId]: next };
|
||||
const project: Project = { ...p, scenes };
|
||||
return { ...s, project };
|
||||
});
|
||||
await api.invoke(ipcChannels.project.updateScene, { sceneId, patch });
|
||||
};
|
||||
|
||||
const updateConnections = async (sceneId: SceneId, connections: SceneId[]) => {
|
||||
setState((s) => {
|
||||
const p = s.project;
|
||||
if (!p) return s;
|
||||
const scene = p.scenes[sceneId];
|
||||
if (!scene) return s;
|
||||
const next: Scene = { ...scene, connections };
|
||||
const scenes = { ...p.scenes, [sceneId]: next };
|
||||
const project: Project = { ...p, scenes };
|
||||
return { ...s, project };
|
||||
});
|
||||
await api.invoke(ipcChannels.project.updateConnections, { sceneId, connections });
|
||||
};
|
||||
|
||||
const importMediaToScene = async (sceneId: SceneId) => {
|
||||
const res = await api.invoke(ipcChannels.project.importMedia, { sceneId });
|
||||
setState((s) => ({ ...s, project: res.project }));
|
||||
await refreshProjects();
|
||||
};
|
||||
|
||||
const importScenePreview = async (sceneId: SceneId) => {
|
||||
const res = await api.invoke(ipcChannels.project.importScenePreview, { sceneId });
|
||||
setState((s) => ({ ...s, project: res.project }));
|
||||
await refreshProjects();
|
||||
};
|
||||
|
||||
const clearScenePreview = async (sceneId: SceneId) => {
|
||||
const res = await api.invoke(ipcChannels.project.clearScenePreview, { sceneId });
|
||||
setState((s) => ({ ...s, project: res.project }));
|
||||
await refreshProjects();
|
||||
};
|
||||
|
||||
const updateSceneGraphNodePosition = async (nodeId: GraphNodeId, x: number, y: number) => {
|
||||
setState((s) => {
|
||||
const p = s.project;
|
||||
if (!p) return s;
|
||||
return {
|
||||
...s,
|
||||
project: {
|
||||
...p,
|
||||
sceneGraphNodes: p.sceneGraphNodes.map((n) => (n.id === nodeId ? { ...n, x, y } : n)),
|
||||
},
|
||||
};
|
||||
});
|
||||
const res = await api.invoke(ipcChannels.project.updateSceneGraphNodePosition, { nodeId, x, y });
|
||||
setState((s) => ({ ...s, project: res.project }));
|
||||
};
|
||||
|
||||
const addSceneGraphNode = async (sceneId: SceneId, x: number, y: number) => {
|
||||
const res = await api.invoke(ipcChannels.project.addSceneGraphNode, { sceneId, x, y });
|
||||
setState((s) => ({ ...s, project: res.project }));
|
||||
};
|
||||
|
||||
const removeSceneGraphNode = async (nodeId: GraphNodeId) => {
|
||||
const res = await api.invoke(ipcChannels.project.removeSceneGraphNode, { nodeId });
|
||||
setState((s) => ({ ...s, project: res.project }));
|
||||
};
|
||||
|
||||
const addSceneGraphEdge = async (sourceGraphNodeId: GraphNodeId, targetGraphNodeId: GraphNodeId) => {
|
||||
const res = await api.invoke(ipcChannels.project.addSceneGraphEdge, {
|
||||
sourceGraphNodeId,
|
||||
targetGraphNodeId,
|
||||
});
|
||||
setState((s) => ({ ...s, project: res.project }));
|
||||
};
|
||||
|
||||
const removeSceneGraphEdge = async (edgeId: string) => {
|
||||
const res = await api.invoke(ipcChannels.project.removeSceneGraphEdge, { edgeId });
|
||||
setState((s) => ({ ...s, project: res.project }));
|
||||
};
|
||||
|
||||
const setSceneGraphNodeStart = async (graphNodeId: GraphNodeId | null) => {
|
||||
const res = await api.invoke(ipcChannels.project.setSceneGraphNodeStart, { graphNodeId });
|
||||
setState((s) => ({ ...s, project: res.project }));
|
||||
};
|
||||
|
||||
const deleteScene = async (sceneId: SceneId) => {
|
||||
const res = await api.invoke(ipcChannels.project.deleteScene, { sceneId });
|
||||
setState((s) => ({
|
||||
...s,
|
||||
project: res.project,
|
||||
selectedSceneId: res.project.currentSceneId ?? null,
|
||||
}));
|
||||
await refreshProjects();
|
||||
};
|
||||
|
||||
const renameProject = async (name: string, fileBaseName: string) => {
|
||||
const res = await api.invoke(ipcChannels.project.rename, { name, fileBaseName });
|
||||
setState((s) => ({ ...s, project: res.project }));
|
||||
await refreshProjects();
|
||||
};
|
||||
|
||||
const importProject = async () => {
|
||||
const res = await api.invoke(ipcChannels.project.importZip, {});
|
||||
if (res.canceled) return;
|
||||
setState((s) => ({
|
||||
...s,
|
||||
project: res.project,
|
||||
selectedSceneId: res.project.currentSceneId,
|
||||
}));
|
||||
await refreshProjects();
|
||||
};
|
||||
|
||||
const exportProject = async (projectId: ProjectId) => {
|
||||
const res = await api.invoke(ipcChannels.project.exportZip, { projectId });
|
||||
if (res.canceled) return;
|
||||
};
|
||||
|
||||
const deleteProject = async (projectId: ProjectId) => {
|
||||
await api.invoke(ipcChannels.project.deleteProject, { projectId });
|
||||
const listRes = await api.invoke(ipcChannels.project.list, {});
|
||||
const res = await api.invoke(ipcChannels.project.get, {});
|
||||
setState((s) => ({
|
||||
...s,
|
||||
projects: listRes.projects,
|
||||
project: res.project,
|
||||
selectedSceneId: res.project?.currentSceneId ?? null,
|
||||
}));
|
||||
};
|
||||
|
||||
return {
|
||||
refreshProjects,
|
||||
createProject,
|
||||
openProject,
|
||||
closeProject,
|
||||
createScene,
|
||||
selectScene,
|
||||
updateScene,
|
||||
updateConnections,
|
||||
importMediaToScene,
|
||||
importScenePreview,
|
||||
clearScenePreview,
|
||||
updateSceneGraphNodePosition,
|
||||
addSceneGraphNode,
|
||||
removeSceneGraphNode,
|
||||
addSceneGraphEdge,
|
||||
removeSceneGraphEdge,
|
||||
setSceneGraphNodeStart,
|
||||
deleteScene,
|
||||
renameProject,
|
||||
importProject,
|
||||
exportProject,
|
||||
deleteProject,
|
||||
};
|
||||
}, [api, state.project]);
|
||||
|
||||
useEffect(() => {
|
||||
void (async () => {
|
||||
await actions.refreshProjects();
|
||||
const res = await api.invoke(ipcChannels.project.get, {});
|
||||
setState((s) => ({ ...s, project: res.project, selectedSceneId: res.project?.currentSceneId ?? null }));
|
||||
})();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
return [state, actions] as const;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="icon" href="/app-window-icon.png" type="image/png" />
|
||||
<title>DnD Player — Presentation</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/presentation/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,4 @@
|
||||
.root {
|
||||
height: 100vh;
|
||||
width: 100vw;
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
|
||||
import { ipcChannels, type SessionState } from '../../shared/ipc/contracts';
|
||||
import { getDndApi } from '../shared/dndApi';
|
||||
import { PresentationView } from '../shared/PresentationView';
|
||||
|
||||
import styles from './PresentationApp.module.css';
|
||||
|
||||
export function PresentationApp() {
|
||||
const [session, setSession] = useState<SessionState | null>(null);
|
||||
const api = getDndApi();
|
||||
|
||||
useEffect(() => {
|
||||
void api.invoke(ipcChannels.project.get, {}).then((res) => {
|
||||
setSession({
|
||||
project: res.project,
|
||||
currentSceneId: res.project?.currentSceneId ?? null,
|
||||
});
|
||||
});
|
||||
return api.on(ipcChannels.session.stateChanged, ({ state }) => setSession(state));
|
||||
}, [api]);
|
||||
|
||||
useEffect(() => {
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
void api.invoke(ipcChannels.windows.closeMultiWindow, {});
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', onKeyDown);
|
||||
return () => window.removeEventListener('keydown', onKeyDown);
|
||||
}, [api]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={styles.root}
|
||||
onDoubleClick={() => void api.invoke(ipcChannels.windows.togglePresentationFullscreen, {})}
|
||||
>
|
||||
<PresentationView session={session} showTitle={false} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import React from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
|
||||
import '../shared/ui/globals.css';
|
||||
import { PresentationApp } from './PresentationApp';
|
||||
|
||||
const rootEl = document.getElementById('root');
|
||||
if (!rootEl) {
|
||||
throw new Error('Missing #root element');
|
||||
}
|
||||
|
||||
createRoot(rootEl).render(
|
||||
<React.StrictMode>
|
||||
<PresentationApp />
|
||||
</React.StrictMode>,
|
||||
);
|
||||
@@ -0,0 +1,10 @@
|
||||
<svg width="32" height="32" viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="32" height="32" rx="12" fill="#8B5CF6" />
|
||||
<path
|
||||
d="M9.33333 17.6667C9.01146 17.6678 8.71773 17.4834 8.57879 17.193C8.43985 16.9027 8.48055 16.5583 8.68333 16.3083L16.9333 7.80833C17.0608 7.66121 17.2731 7.62194 17.4448 7.71375C17.6164 7.80556 17.7016 8.00398 17.65 8.19167L16.05 13.2083C15.9542 13.4646 15.9904 13.7516 16.1467 13.9762C16.3031 14.2007 16.5597 14.3342 16.8333 14.3333H22.6667C22.9885 14.3322 23.2823 14.5166 23.4212 14.807C23.5601 15.0973 23.5195 15.4417 23.3167 15.6917L15.0667 24.1917C14.9392 24.3388 14.7269 24.3781 14.5552 24.2862C14.3836 24.1944 14.2984 23.996 14.35 23.8083L15.95 18.7917C16.0458 18.5354 16.0096 18.2484 15.8533 18.0238C15.6969 17.7993 15.4403 17.6658 15.1667 17.6667H9.33333"
|
||||
stroke="white"
|
||||
stroke-width="1.66667"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 944 B |
Binary file not shown.
|
After Width: | Height: | Size: 5.6 KiB |
@@ -0,0 +1,63 @@
|
||||
.root {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background: var(--bg0);
|
||||
}
|
||||
|
||||
.fill {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
.placeholderBg {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: var(--color-overlay-dark-6);
|
||||
}
|
||||
|
||||
.video {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.vignette {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.titleWrap {
|
||||
position: absolute;
|
||||
left: 18px;
|
||||
bottom: 16px;
|
||||
right: 18px;
|
||||
color: var(--text0);
|
||||
}
|
||||
|
||||
.titleCompact {
|
||||
font-size: var(--text-xl);
|
||||
font-weight: 900;
|
||||
letter-spacing: -0.5px;
|
||||
text-shadow: var(--shadow-title);
|
||||
line-height: 1.1;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.titleFull {
|
||||
font-size: var(--text-title-lg);
|
||||
font-weight: 900;
|
||||
letter-spacing: -0.5px;
|
||||
text-shadow: var(--shadow-title);
|
||||
line-height: 1.1;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
|
||||
import { computeTimeSec } from '../../main/video/videoPlaybackStore';
|
||||
import type { SessionState } from '../../shared/ipc/contracts';
|
||||
|
||||
import { PixiEffectsOverlay } from './effects/PxiEffectsOverlay';
|
||||
import { useEffectsState } from './effects/useEffectsState';
|
||||
import styles from './PresentationView.module.css';
|
||||
import { RotatedImage } from './RotatedImage';
|
||||
import { useAssetUrl } from './useAssetImageUrl';
|
||||
import { useVideoPlaybackState } from './video/useVideoPlaybackState';
|
||||
|
||||
export type PresentationViewProps = {
|
||||
session: SessionState | null;
|
||||
/** Если true — показываем укороченный заголовок/оверлей для предпросмотра. */
|
||||
compact?: boolean;
|
||||
showTitle?: boolean;
|
||||
showEffects?: boolean;
|
||||
};
|
||||
|
||||
export function PresentationView({
|
||||
session,
|
||||
compact = false,
|
||||
showTitle = true,
|
||||
showEffects = true,
|
||||
}: PresentationViewProps) {
|
||||
const [fxState] = useEffectsState();
|
||||
const [vp] = useVideoPlaybackState();
|
||||
const videoElRef = useRef<HTMLVideoElement | null>(null);
|
||||
const [contentRect, setContentRect] = React.useState<{ x: number; y: number; w: number; h: number } | null>(
|
||||
null,
|
||||
);
|
||||
const scene =
|
||||
session?.project && session.currentSceneId ? session.project.scenes[session.currentSceneId] : undefined;
|
||||
const previewUrl = useAssetUrl(scene?.previewAssetId ?? null);
|
||||
const rot = scene?.previewRotationDeg ?? 0;
|
||||
|
||||
useEffect(() => {
|
||||
const el = videoElRef.current;
|
||||
if (!el) return;
|
||||
if (!vp) return;
|
||||
if (!scene?.previewAssetId) return;
|
||||
if (scene.previewAssetType !== 'video') return;
|
||||
if (vp.targetAssetId !== scene.previewAssetId) return;
|
||||
|
||||
el.playbackRate = vp.playbackRate;
|
||||
const desired = computeTimeSec(vp, vp.serverNowMs);
|
||||
if (Number.isFinite(desired) && Math.abs(el.currentTime - desired) > 0.35) {
|
||||
el.currentTime = Math.max(0, desired);
|
||||
}
|
||||
if (vp.playing) {
|
||||
void el.play().catch(() => undefined);
|
||||
} else {
|
||||
el.pause();
|
||||
}
|
||||
}, [scene?.previewAssetId, scene?.previewAssetType, vp]);
|
||||
|
||||
return (
|
||||
<div className={styles.root}>
|
||||
{previewUrl && scene?.previewAssetType === 'image' ? (
|
||||
<div className={styles.fill}>
|
||||
<RotatedImage
|
||||
url={previewUrl}
|
||||
rotationDeg={rot}
|
||||
mode="contain"
|
||||
onContentRectChange={setContentRect}
|
||||
/>
|
||||
</div>
|
||||
) : previewUrl && scene?.previewAssetType === 'video' ? (
|
||||
<video
|
||||
ref={videoElRef}
|
||||
className={styles.video}
|
||||
src={previewUrl}
|
||||
muted
|
||||
playsInline
|
||||
loop={false}
|
||||
preload="auto"
|
||||
onError={() => {
|
||||
// noop: status surfaced in control app; keep presentation clean
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className={styles.placeholderBg} />
|
||||
)}
|
||||
<div className={styles.vignette} />
|
||||
{showEffects && scene?.previewAssetType !== 'video' ? (
|
||||
<PixiEffectsOverlay
|
||||
state={fxState}
|
||||
viewport={
|
||||
contentRect
|
||||
? { x: contentRect.x, y: contentRect.y, w: contentRect.w, h: contentRect.h }
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
{showTitle ? (
|
||||
<div className={styles.titleWrap}>
|
||||
<div className={compact ? styles.titleCompact : styles.titleFull}>
|
||||
{scene?.title ?? 'Выберите сцену в редакторе'}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
.root {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.img {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
transform-origin: center;
|
||||
display: block;
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import styles from './RotatedImage.module.css';
|
||||
|
||||
type Mode = 'cover' | 'contain';
|
||||
|
||||
type RotatedImageProps = {
|
||||
url: string;
|
||||
rotationDeg: 0 | 90 | 180 | 270;
|
||||
mode: Mode;
|
||||
alt?: string;
|
||||
/** Высота/ширина полностью контролируются родителем. */
|
||||
style?: React.CSSProperties;
|
||||
/** Прямоугольник видимого контента (contain/cover) внутри контейнера. */
|
||||
onContentRectChange?: ((rect: { x: number; y: number; w: number; h: number }) => void) | undefined;
|
||||
};
|
||||
|
||||
function useElementSize<T extends HTMLElement>() {
|
||||
const ref = useRef<T | null>(null);
|
||||
const [size, setSize] = useState<{ w: number; h: number }>({ w: 0, h: 0 });
|
||||
|
||||
useEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
const ro = new ResizeObserver(() => {
|
||||
const r = el.getBoundingClientRect();
|
||||
setSize({ w: r.width, h: r.height });
|
||||
});
|
||||
ro.observe(el);
|
||||
const r = el.getBoundingClientRect();
|
||||
setSize({ w: r.width, h: r.height });
|
||||
return () => ro.disconnect();
|
||||
}, []);
|
||||
|
||||
return [ref, size] as const;
|
||||
}
|
||||
|
||||
export function RotatedImage({
|
||||
url,
|
||||
rotationDeg,
|
||||
mode,
|
||||
alt = '',
|
||||
style,
|
||||
onContentRectChange,
|
||||
}: RotatedImageProps) {
|
||||
const [ref, size] = useElementSize<HTMLDivElement>();
|
||||
const [imgSize, setImgSize] = useState<{ w: number; h: number } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
if (cancelled) return;
|
||||
setImgSize({ w: img.naturalWidth || 1, h: img.naturalHeight || 1 });
|
||||
};
|
||||
img.src = url;
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [url]);
|
||||
|
||||
const scale = useMemo(() => {
|
||||
if (!imgSize) return 1;
|
||||
if (size.w <= 1 || size.h <= 1) return 1;
|
||||
const rotated = rotationDeg === 90 || rotationDeg === 270;
|
||||
const iw = rotated ? imgSize.h : imgSize.w;
|
||||
const ih = rotated ? imgSize.w : imgSize.h;
|
||||
const sx = size.w / iw;
|
||||
const sy = size.h / ih;
|
||||
return mode === 'cover' ? Math.max(sx, sy) : Math.min(sx, sy);
|
||||
}, [imgSize, mode, rotationDeg, size.h, size.w]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!onContentRectChange) return;
|
||||
if (!imgSize) return;
|
||||
if (size.w <= 1 || size.h <= 1) return;
|
||||
const rotated = rotationDeg === 90 || rotationDeg === 270;
|
||||
// Bounding-box размеров после rotate(): при 90/270 меняются местами.
|
||||
const bw = (rotated ? imgSize.h : imgSize.w) * scale;
|
||||
const bh = (rotated ? imgSize.w : imgSize.h) * scale;
|
||||
const x = (size.w - bw) / 2;
|
||||
const y = (size.h - bh) / 2;
|
||||
onContentRectChange({ x, y, w: bw, h: bh });
|
||||
}, [imgSize, mode, onContentRectChange, rotationDeg, scale, size.h, size.w]);
|
||||
|
||||
const w = imgSize ? imgSize.w * scale : undefined;
|
||||
const h = imgSize ? imgSize.h * scale : undefined;
|
||||
|
||||
return (
|
||||
<div ref={ref} className={styles.root} style={style}>
|
||||
<img
|
||||
alt={alt}
|
||||
src={url}
|
||||
className={styles.img}
|
||||
style={{
|
||||
width: w ?? '100%',
|
||||
height: h ?? '100%',
|
||||
objectFit: imgSize ? undefined : mode,
|
||||
transform: `translate(-50%, -50%) rotate(${String(rotationDeg)}deg)`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import React from 'react';
|
||||
|
||||
type Props = {
|
||||
className?: string | undefined;
|
||||
size?: number;
|
||||
title?: string;
|
||||
};
|
||||
|
||||
/** Логотип приложения (SVG из брендинга). */
|
||||
export function AppLogo({ className, size = 26, title }: Props) {
|
||||
return (
|
||||
<svg
|
||||
className={className}
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 32 32"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden={title ? undefined : true}
|
||||
role={title ? 'img' : undefined}
|
||||
aria-label={title}
|
||||
>
|
||||
<rect width="32" height="32" rx="12" fill="#8B5CF6" />
|
||||
<path
|
||||
d="M9.33333 17.6667C9.01146 17.6678 8.71773 17.4834 8.57879 17.193C8.43985 16.9027 8.48055 16.5583 8.68333 16.3083L16.9333 7.80833C17.0608 7.66121 17.2731 7.62194 17.4448 7.71375C17.6164 7.80556 17.7016 8.00398 17.65 8.19167L16.05 13.2083C15.9542 13.4646 15.9904 13.7516 16.1467 13.9762C16.3031 14.2007 16.5597 14.3342 16.8333 14.3333H22.6667C22.9885 14.3322 23.2823 14.5166 23.4212 14.807C23.5601 15.0973 23.5195 15.4417 23.3167 15.6917L15.0667 24.1917C14.9392 24.3388 14.7269 24.3781 14.5552 24.2862C14.3836 24.1944 14.2984 23.996 14.35 23.8083L15.95 18.7917C16.0458 18.5354 16.0096 18.2484 15.8533 18.0238C15.6969 17.7993 15.4403 17.6658 15.1667 17.6667H9.33333"
|
||||
stroke="white"
|
||||
strokeWidth="1.66667"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import type { DndApi } from '../../preload/index';
|
||||
|
||||
export function getDndApi(): DndApi {
|
||||
return window.dnd;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
.host {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
.hostInteractive {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.hostPassthrough {
|
||||
pointer-events: none;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
void test('PxiEffectsOverlay: canvas не перехватывает указатель в режиме без interactive', () => {
|
||||
const src = fs.readFileSync(path.join(here, 'PxiEffectsOverlay.tsx'), 'utf8');
|
||||
assert.ok(src.includes("app.canvas.style.pointerEvents = interactive ? 'auto' : 'none'"));
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,31 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { ipcChannels } from '../../../shared/ipc/contracts';
|
||||
import type { EffectsEvent, EffectsState } from '../../../shared/types';
|
||||
import { getDndApi } from '../dndApi';
|
||||
|
||||
export function useEffectsState(): readonly [
|
||||
EffectsState | null,
|
||||
{ dispatch: (event: EffectsEvent) => Promise<void> },
|
||||
] {
|
||||
const api = getDndApi();
|
||||
const [state, setState] = useState<EffectsState | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
void api.invoke(ipcChannels.effects.getState, {}).then((r) => {
|
||||
setState(r.state);
|
||||
});
|
||||
return api.on(ipcChannels.effects.stateChanged, ({ state: next }) => {
|
||||
setState(next);
|
||||
});
|
||||
}, [api]);
|
||||
|
||||
return [
|
||||
state,
|
||||
{
|
||||
dispatch: async (event) => {
|
||||
await api.invoke(ipcChannels.effects.dispatch, { event });
|
||||
},
|
||||
},
|
||||
] as const;
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
/* ==========================================================================
|
||||
Дизайн-токены (общие для всех окон). Импортируйте через globals.css.
|
||||
========================================================================== */
|
||||
|
||||
:root {
|
||||
/* --- Цвета: фон (legacy алиасы для body/globals) --- */
|
||||
--bg0: #09090b;
|
||||
--bg1: #09090b;
|
||||
--color-bg-0: #09090b;
|
||||
--color-bg-1: #09090b;
|
||||
--color-bg-black: #000;
|
||||
--color-overlay-dark: rgba(0, 0, 0, 0.15);
|
||||
--color-overlay-dark-2: rgba(0, 0, 0, 0.18);
|
||||
--color-overlay-dark-3: rgba(0, 0, 0, 0.25);
|
||||
--color-overlay-dark-4: rgba(0, 0, 0, 0.28);
|
||||
--color-overlay-dark-5: rgba(0, 0, 0, 0.45);
|
||||
--color-overlay-dark-6: rgba(0, 0, 0, 0.55);
|
||||
--color-overlay-dark-7: rgba(0, 0, 0, 0.65);
|
||||
--color-scrim: rgba(0, 0, 0, 0.45);
|
||||
--color-panel: rgba(255, 255, 255, 0.04);
|
||||
--color-panel-2: rgba(255, 255, 255, 0.06);
|
||||
--color-panel-3: rgba(255, 255, 255, 0.05);
|
||||
--color-surface-elevated: rgba(10, 10, 14, 0.96);
|
||||
--color-surface-elevated-2: rgba(10, 10, 14, 0.98);
|
||||
--color-surface-menu: rgba(15, 16, 22, 0.98);
|
||||
--color-tooltip-bg: rgba(20, 22, 28, 0.96);
|
||||
|
||||
/* --- Сцены: плитки (список, граф, пульт) --- */
|
||||
--scene-tile-radius: 12px;
|
||||
--scene-list-selected-bg: rgba(139, 92, 146, 0.1);
|
||||
--scene-list-selected-border: rgba(139, 92, 146, 0.3);
|
||||
--scene-list-hover-bg: rgba(139, 92, 146, 0.08);
|
||||
--graph-node-active-border: rgba(139, 92, 146, 1);
|
||||
|
||||
/* --- Редактор: колонки --- */
|
||||
--editor-column-bg: #18181b;
|
||||
|
||||
/* --- Цвета: обводка --- */
|
||||
--stroke: rgba(255, 255, 255, 0.08);
|
||||
--stroke2: rgba(255, 255, 255, 0.12);
|
||||
--stroke-2: rgba(255, 255, 255, 0.12);
|
||||
--stroke-light: rgba(255, 255, 255, 0.12);
|
||||
--stroke-handle: rgba(167, 139, 250, 0.9);
|
||||
|
||||
/* --- Цвета: текст --- */
|
||||
--text0: rgba(255, 255, 255, 0.92);
|
||||
--text1: rgba(255, 255, 255, 0.72);
|
||||
--text2: rgba(255, 255, 255, 0.52);
|
||||
--panel: rgba(255, 255, 255, 0.04);
|
||||
--panel2: rgba(255, 255, 255, 0.06);
|
||||
--text-on-accent: rgba(255, 255, 255, 0.98);
|
||||
--text-muted-on-dark: rgba(255, 255, 255, 0.85);
|
||||
--text-muted-on-dark-2: rgba(255, 255, 255, 0.9);
|
||||
|
||||
/* --- Цвета: акцент / бренд --- */
|
||||
--accent: #7c3aed;
|
||||
--accent2: #a78bfa;
|
||||
--accent-border: rgba(124, 58, 237, 0.55);
|
||||
--accent-border-strong: rgba(124, 58, 237, 0.85);
|
||||
--accent-fill-soft: rgba(124, 58, 237, 0.1);
|
||||
--accent-fill-soft-2: rgba(124, 58, 237, 0.12);
|
||||
--accent-fill-solid: rgba(124, 58, 237, 0.92);
|
||||
--accent-glow: rgba(124, 58, 237, 0.35);
|
||||
--selection-bg: rgba(124, 58, 237, 0.35);
|
||||
|
||||
/* --- Цвета: опасность / ошибка --- */
|
||||
--color-danger: rgba(248, 113, 113, 0.95);
|
||||
--color-danger-icon: #e5484d;
|
||||
|
||||
/* --- Тени --- */
|
||||
--shadow: 0 24px 60px rgba(0, 0, 0, 0.55);
|
||||
--shadow-lg: 0 18px 60px rgba(0, 0, 0, 0.55);
|
||||
--shadow-xl: 0 24px 80px rgba(0, 0, 0, 0.6);
|
||||
--shadow-menu: 0 12px 40px rgba(0, 0, 0, 0.55);
|
||||
--shadow-tooltip: 0 8px 24px rgba(0, 0, 0, 0.45);
|
||||
--shadow-title: 0 4px 24px rgba(0, 0, 0, 0.65);
|
||||
--shadow-start-badge: 0 4px 12px rgba(0, 0, 0, 0.35);
|
||||
|
||||
/* --- Скругления --- */
|
||||
--radius-lg: 16px;
|
||||
--radius-md: 12px;
|
||||
--radius-sm: 10px;
|
||||
--radius-xs: 8px;
|
||||
--radius-pill: 999px;
|
||||
|
||||
/* --- Типографика --- */
|
||||
--font:
|
||||
'Nimbus Sans', 'Nimbus Sans L', 'Nimbus Sans OT', 'Nimbus Sans PS', ui-sans-serif, system-ui,
|
||||
-apple-system, 'Segoe UI', Roboto, Helvetica, Arial, 'Apple Color Emoji', 'Segoe UI Emoji';
|
||||
--text-xs: 12px;
|
||||
--text-sm: 13px;
|
||||
--text-md: 14px;
|
||||
--text-lg: 16px;
|
||||
--text-xl: 18px;
|
||||
--text-title-lg: 42px;
|
||||
|
||||
/* --- Вёрстка: сетка редактора --- */
|
||||
--topbar-h: 56px;
|
||||
--sidebar-w: 280px;
|
||||
--inspector-w: 380px;
|
||||
--gap: 16px;
|
||||
--pad: 16px;
|
||||
|
||||
/* --- Z-index --- */
|
||||
--z-menu-backdrop: 9999;
|
||||
--z-modal-backdrop: 20000;
|
||||
--z-modal: 20001;
|
||||
--z-file-menu: 40000;
|
||||
--z-tooltip: 200000;
|
||||
|
||||
/* --- Прочее --- */
|
||||
--backdrop-blur-shell: blur(14px);
|
||||
--backdrop-blur-surface: blur(18px);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
.button {
|
||||
height: 34px;
|
||||
padding: 0 14px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--stroke);
|
||||
background: var(--panel);
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.buttonPrimary {
|
||||
border: 1px solid var(--accent-border);
|
||||
background: var(--accent-fill-solid);
|
||||
}
|
||||
|
||||
.iconOnly {
|
||||
min-width: 38px;
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
.tooltip {
|
||||
position: fixed;
|
||||
transform: translate(-50%, calc(-100% - 8px));
|
||||
padding: 6px 10px;
|
||||
border-radius: var(--radius-xs);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: 600;
|
||||
color: rgba(255, 255, 255, 0.95);
|
||||
background: var(--color-tooltip-bg);
|
||||
border: 1px solid var(--stroke-2);
|
||||
box-shadow: var(--shadow-tooltip);
|
||||
pointer-events: none;
|
||||
z-index: var(--z-tooltip);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.input {
|
||||
height: 34px;
|
||||
width: 100%;
|
||||
padding: 0 12px;
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--stroke);
|
||||
background: var(--color-overlay-dark-3);
|
||||
outline: none;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
.root {
|
||||
height: 100vh;
|
||||
display: grid;
|
||||
grid-template-rows: var(--topbar-h) 1fr;
|
||||
}
|
||||
|
||||
.topBar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 16px;
|
||||
border-bottom: 1px solid var(--stroke);
|
||||
background: #18181b;
|
||||
backdrop-filter: var(--backdrop-blur-shell);
|
||||
}
|
||||
|
||||
.body {
|
||||
display: grid;
|
||||
grid-template-columns: var(--sidebar-w) 1fr var(--inspector-w);
|
||||
gap: 0;
|
||||
padding: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.col {
|
||||
min-height: 0;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import React from 'react';
|
||||
|
||||
import styles from './LayoutShell.module.css';
|
||||
|
||||
type Props = {
|
||||
topBar: React.ReactNode;
|
||||
left: React.ReactNode;
|
||||
center: React.ReactNode;
|
||||
right: React.ReactNode;
|
||||
};
|
||||
|
||||
export function LayoutShell({ topBar, left, center, right }: Props) {
|
||||
return (
|
||||
<div className={styles.root}>
|
||||
<div className={styles.topBar}>{topBar}</div>
|
||||
<div className={styles.body}>
|
||||
<div className={styles.col}>{left}</div>
|
||||
<div className={styles.col}>{center}</div>
|
||||
<div className={styles.col}>{right}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
.root {
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--color-panel-2);
|
||||
border: 1px solid var(--stroke);
|
||||
box-shadow: var(--shadow);
|
||||
backdrop-filter: var(--backdrop-blur-surface);
|
||||
overflow: hidden;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import React from 'react';
|
||||
|
||||
import styles from './Surface.module.css';
|
||||
|
||||
type Props = {
|
||||
children: React.ReactNode;
|
||||
/** Разрешено `undefined` из CSS-модулей при `exactOptionalPropertyTypes`. */
|
||||
className?: string | undefined;
|
||||
style?: React.CSSProperties | undefined;
|
||||
};
|
||||
|
||||
export function Surface({ children, className, style }: Props) {
|
||||
return (
|
||||
<div className={[styles.root, className].filter(Boolean).join(' ')} style={style}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
void test('Button: тултип через портал (title), не только нативный атрибут', () => {
|
||||
const src = fs.readFileSync(path.join(here, 'controls.tsx'), 'utf8');
|
||||
assert.ok(src.includes('createPortal'));
|
||||
assert.ok(src.includes('role="tooltip"'));
|
||||
assert.ok(src.includes('onMouseEnter={showTip}'));
|
||||
assert.ok(src.includes('document.body'));
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
import React, { useCallback, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
import styles from './Controls.module.css';
|
||||
|
||||
type ButtonProps = {
|
||||
children: React.ReactNode;
|
||||
onClick?: () => void;
|
||||
variant?: 'primary' | 'ghost';
|
||||
disabled?: boolean;
|
||||
title?: string | undefined;
|
||||
/** Подпись для скринридеров (иконки без текста). */
|
||||
ariaLabel?: string | undefined;
|
||||
/** Компактная кнопка под одну иконку. */
|
||||
iconOnly?: boolean;
|
||||
};
|
||||
|
||||
export function Button({
|
||||
children,
|
||||
onClick,
|
||||
variant = 'ghost',
|
||||
disabled = false,
|
||||
title,
|
||||
ariaLabel,
|
||||
iconOnly = false,
|
||||
}: ButtonProps) {
|
||||
const btnRef = useRef<HTMLButtonElement | null>(null);
|
||||
const [tipPos, setTipPos] = useState<{ x: number; y: number } | null>(null);
|
||||
|
||||
const showTip = useCallback(() => {
|
||||
if (disabled || !title) return;
|
||||
const el = btnRef.current;
|
||||
if (!el) return;
|
||||
const r = el.getBoundingClientRect();
|
||||
setTipPos({ x: r.left + r.width / 2, y: r.top });
|
||||
}, [disabled, title]);
|
||||
|
||||
const hideTip = useCallback(() => {
|
||||
setTipPos(null);
|
||||
}, []);
|
||||
|
||||
const btnClass = [
|
||||
styles.button,
|
||||
variant === 'primary' ? styles.buttonPrimary : '',
|
||||
iconOnly ? styles.iconOnly : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
|
||||
const tip =
|
||||
title && tipPos && typeof document !== 'undefined'
|
||||
? createPortal(
|
||||
<div role="tooltip" className={styles.tooltip} style={{ left: tipPos.x, top: tipPos.y }}>
|
||||
{title}
|
||||
</div>,
|
||||
document.body,
|
||||
)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
ref={btnRef}
|
||||
type="button"
|
||||
className={btnClass}
|
||||
disabled={disabled}
|
||||
aria-label={ariaLabel}
|
||||
onClick={disabled ? undefined : onClick}
|
||||
onMouseEnter={showTip}
|
||||
onMouseLeave={hideTip}
|
||||
onFocus={showTip}
|
||||
onBlur={hideTip}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
{tip}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
type InputProps = {
|
||||
value: string;
|
||||
placeholder?: string;
|
||||
onChange: (v: string) => void;
|
||||
};
|
||||
|
||||
export function Input({ value, placeholder, onChange }: InputProps) {
|
||||
return (
|
||||
<input
|
||||
className={styles.input}
|
||||
value={value}
|
||||
placeholder={placeholder}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
@import '../styles/variables.css';
|
||||
@import url('https://fonts.cdnfonts.com/css/nimbus-sans');
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
html {
|
||||
background: var(--bg0);
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: var(--font);
|
||||
color: var(--text0);
|
||||
background: var(--bg0);
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border: 2px solid rgba(0, 0, 0, 0);
|
||||
background-clip: padding-box;
|
||||
border-radius: 999px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
border: 2px solid rgba(0, 0, 0, 0);
|
||||
background-clip: padding-box;
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
textarea {
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: var(--selection-bg);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { ipcChannels } from '../../shared/ipc/contracts';
|
||||
import type { AssetId } from '../../shared/types';
|
||||
|
||||
import { getDndApi } from './dndApi';
|
||||
|
||||
/**
|
||||
* Возвращает `file://` URL для превью изображения. Пока загрузка или сменился id — `null`.
|
||||
*/
|
||||
export function useAssetUrl(assetId: AssetId | null | undefined): string | null {
|
||||
const id = assetId ?? null;
|
||||
const [entry, setEntry] = useState<{ assetId: AssetId; url: string | null } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (id === null) {
|
||||
return undefined;
|
||||
}
|
||||
let cancelled = false;
|
||||
void getDndApi()
|
||||
.invoke(ipcChannels.project.assetFileUrl, { assetId: id })
|
||||
.then((r) => {
|
||||
if (!cancelled) setEntry({ assetId: id, url: r.url });
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [id]);
|
||||
|
||||
if (id === null) {
|
||||
return null;
|
||||
}
|
||||
if (entry?.assetId !== id) {
|
||||
return null;
|
||||
}
|
||||
return entry.url;
|
||||
}
|
||||
|
||||
/** @deprecated use `useAssetUrl` */
|
||||
export function useAssetImageUrl(assetId: AssetId | null | undefined): string | null {
|
||||
return useAssetUrl(assetId);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { ipcChannels } from '../../../shared/ipc/contracts';
|
||||
import type { VideoPlaybackEvent, VideoPlaybackState } from '../../../shared/types';
|
||||
import { getDndApi } from '../dndApi';
|
||||
|
||||
export function useVideoPlaybackState(): readonly [
|
||||
VideoPlaybackState | null,
|
||||
{ dispatch: (event: VideoPlaybackEvent) => Promise<void> },
|
||||
] {
|
||||
const api = getDndApi();
|
||||
const [state, setState] = useState<VideoPlaybackState | null>(null);
|
||||
const [timeOffsetMs, setTimeOffsetMs] = useState(0);
|
||||
const [clientNowMs, setClientNowMs] = useState(() => Date.now());
|
||||
|
||||
useEffect(() => {
|
||||
if (!state) return;
|
||||
const id = window.setInterval(() => {
|
||||
setClientNowMs(Date.now());
|
||||
}, 250);
|
||||
return () => window.clearInterval(id);
|
||||
}, [state]);
|
||||
|
||||
useEffect(() => {
|
||||
void api.invoke(ipcChannels.video.getState, {}).then((r) => {
|
||||
setState(r.state);
|
||||
setTimeOffsetMs(r.state.serverNowMs - Date.now());
|
||||
});
|
||||
return api.on(ipcChannels.video.stateChanged, ({ state: next }) => {
|
||||
setState(next);
|
||||
setTimeOffsetMs(next.serverNowMs - Date.now());
|
||||
});
|
||||
}, [api]);
|
||||
|
||||
return [
|
||||
state ? { ...state, serverNowMs: clientNowMs + timeOffsetMs } : null,
|
||||
{
|
||||
dispatch: async (event) => {
|
||||
await api.invoke(ipcChannels.video.dispatch, { event });
|
||||
},
|
||||
},
|
||||
] as const;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"types": ["vite/client"],
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["./**/*.ts", "./**/*.tsx", "./**/*.css"],
|
||||
"exclude": ["../../dist", "../../node_modules"]
|
||||
}
|
||||
Reference in New Issue
Block a user