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
+619
View File
@@ -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%;
}
+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>
);
}
+16
View File
@@ -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>,
);
+322
View File
@@ -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;
}