DNDGamePlayer: Electron редактор сцен, презентация, упаковка electron-builder

Made-with: Cursor
This commit is contained in:
Ivan Fontosh
2026-04-19 14:16:54 +08:00
commit a6cbcc273e
82 changed files with 22195 additions and 0 deletions
@@ -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%;
}
+496
View File
@@ -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>
);
}