DNDGamePlayer: Electron редактор сцен, презентация, упаковка electron-builder
Made-with: Cursor
This commit is contained in:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user