From cfa3959fb3b1a3fe442b26ddba2cdfa7f528b6a6 Mon Sep 17 00:00:00 2001 From: Ivan Fontosh Date: Wed, 15 Jul 2026 09:49:40 +0800 Subject: [PATCH] feat(scenes): rich scene descriptions and control viewer window Add TipTap editing in the editor and an Electron window on the control panel to read the current scene description. Co-authored-by: Cursor --- app/main/index.ts | 14 + app/main/ipc/router.ts | 1 + .../windows/createWindows.editorClose.test.ts | 9 + app/main/windows/createWindows.ts | 101 ++- app/renderer/control/ControlApp.module.css | 92 +++ app/renderer/control/ControlApp.tsx | 40 +- .../control/controlApp.effectsPanel.test.ts | 10 + app/renderer/editor/EditorApp.module.css | 82 +++ app/renderer/editor/EditorApp.tsx | 85 ++- .../editor/SceneDescriptionModal.module.css | 182 +++++ app/renderer/editor/SceneDescriptionModal.tsx | 242 +++++++ app/renderer/editor/i18n/editorMessages.ts | 36 + .../editor/sceneDescriptionHtml.test.ts | 19 + app/renderer/editor/sceneDescriptionHtml.ts | 94 +++ app/renderer/sceneDescription.html | 13 + .../SceneDescriptionApp.module.css | 28 + .../sceneDescription/SceneDescriptionApp.tsx | 50 ++ app/renderer/sceneDescription/main.tsx | 20 + app/renderer/shared/ui/Controls.module.css | 6 + .../shared/ui/controls.tooltip.test.ts | 1 + app/renderer/shared/ui/controls.tsx | 49 +- app/shared/appBranding.ts | 3 +- app/shared/ipc/contracts.ts | 17 + package-lock.json | 655 +++++++++++++++++- package.json | 5 +- vite.config.ts | 1 + 26 files changed, 1795 insertions(+), 60 deletions(-) create mode 100644 app/renderer/editor/SceneDescriptionModal.module.css create mode 100644 app/renderer/editor/SceneDescriptionModal.tsx create mode 100644 app/renderer/editor/sceneDescriptionHtml.test.ts create mode 100644 app/renderer/editor/sceneDescriptionHtml.ts create mode 100644 app/renderer/sceneDescription.html create mode 100644 app/renderer/sceneDescription/SceneDescriptionApp.module.css create mode 100644 app/renderer/sceneDescription/SceneDescriptionApp.tsx create mode 100644 app/renderer/sceneDescription/main.tsx diff --git a/app/main/index.ts b/app/main/index.ts index 8c025ca..97d24cc 100644 --- a/app/main/index.ts +++ b/app/main/index.ts @@ -29,12 +29,15 @@ import { import { applyDockIconIfNeeded, closeMultiWindow, + closeSceneDescriptionWindow, createEditorWindowDeferred, createWindows, focusEditorWindow, + getSceneDescriptionContent, isMultiWindowOpen, markAppQuitting, openMultiWindow, + openSceneDescriptionWindow, togglePresentationFullscreen, waitForEditorWindowReady, } from './windows/createWindows'; @@ -308,6 +311,17 @@ async function main() { registerHandler(ipcChannels.windows.getMultiWindowState, () => { return { open: isMultiWindowOpen() }; }); + registerHandler(ipcChannels.windows.openSceneDescription, ({ html }) => { + openSceneDescriptionWindow(html); + return { ok: true }; + }); + registerHandler(ipcChannels.windows.closeSceneDescription, () => { + closeSceneDescriptionWindow(); + return { ok: true }; + }); + registerHandler(ipcChannels.windows.getSceneDescriptionContent, () => { + return { html: getSceneDescriptionContent() }; + }); registerHandler(ipcChannels.project.list, async () => { const projects = await projectStore.listProjects(); diff --git a/app/main/ipc/router.ts b/app/main/ipc/router.ts index 23c0679..aaa4d1c 100644 --- a/app/main/ipc/router.ts +++ b/app/main/ipc/router.ts @@ -18,6 +18,7 @@ function channelRequiresLicense(channel: string): boolean { if (channel.startsWith('license.')) return false; if (channel.startsWith('app.')) return false; if (channel === ipcChannels.windows.closeMultiWindow) return false; + if (channel === ipcChannels.windows.closeSceneDescription) return false; if (channel === ipcChannels.windows.togglePresentationFullscreen) return false; // Список файлов в %userData%/projects — только чтение; без лицензии список не должен «пропадать». if (channel === ipcChannels.project.list) return false; diff --git a/app/main/windows/createWindows.editorClose.test.ts b/app/main/windows/createWindows.editorClose.test.ts index f9fc254..5f8bc8f 100644 --- a/app/main/windows/createWindows.editorClose.test.ts +++ b/app/main/windows/createWindows.editorClose.test.ts @@ -35,6 +35,15 @@ void test('createWindows: пульт поверх экрана просмотр assert.ok(src.includes("createWindow('control'")); }); +void test('createWindows: окно описания сцены закрывается с multi-window и отдельно', () => { + const src = readCreateWindows(); + assert.ok(src.includes('openSceneDescriptionWindow')); + assert.ok(src.includes('closeSceneDescriptionWindow')); + assert.ok(src.includes("createWindow('sceneDescription')")); + assert.match(src, /export function closeMultiWindow[\s\S]*closeSceneDescriptionWindow/); + assert.match(src, /kind !== 'presentation' && kind !== 'control'[\s\S]*closeSceneDescriptionWindow/); +}); + void test('createWindows: production — loadFile для HTML (не только file://)', () => { const src = readCreateWindows(); assert.ok(src.includes('loadFile')); diff --git a/app/main/windows/createWindows.ts b/app/main/windows/createWindows.ts index 3c0b142..904053c 100644 --- a/app/main/windows/createWindows.ts +++ b/app/main/windows/createWindows.ts @@ -8,11 +8,12 @@ import { ipcChannels } from '../../shared/ipc/contracts'; import { getBootSplashWindow } from './bootWindow'; import { loadBrandingWindowIcon } from './brandingIcon'; -type WindowKind = 'editor' | 'presentation' | 'control'; +type WindowKind = 'editor' | 'presentation' | 'control' | 'sceneDescription'; const windows = new Map(); let appQuitting = false; +let pendingSceneDescriptionHtml = ''; /** Учитываем окна, которые уже уничтожены при каскадном закрытии (родитель → дочернее). */ function broadcastMultiWindowStateChanged(open: boolean): void { @@ -27,6 +28,15 @@ function broadcastMultiWindowStateChanged(open: boolean): void { } } +function sendSceneDescriptionContent(win: BrowserWindow, html: string): void { + if (win.isDestroyed() || win.webContents.isDestroyed()) return; + try { + win.webContents.send(ipcChannels.windows.sceneDescriptionContent, { html }); + } catch { + /* ignore */ + } +} + /** Разрешает реальное закрытие окна редактора (выход из приложения). */ export function markAppQuitting(): void { appQuitting = true; @@ -51,6 +61,19 @@ function getRendererHtmlPath(kind: WindowKind): string { return path.join(app.getAppPath(), 'dist', 'renderer', `${kind}.html`); } +function pageNameForKind(kind: WindowKind): string { + switch (kind) { + case 'editor': + return 'editor.html'; + case 'presentation': + return 'presentation.html'; + case 'control': + return 'control.html'; + case 'sceneDescription': + return 'sceneDescription.html'; + } +} + /** * В production `loadURL(file://…)` на Windows с asar иногда даёт чёрный экран; * `loadFile` корректно открывает HTML из asar и на Windows, и на macOS. @@ -58,9 +81,7 @@ function getRendererHtmlPath(kind: WindowKind): string { function loadWindowPage(win: BrowserWindow, kind: WindowKind): void { const dev = process.env.VITE_DEV_SERVER_URL; if (dev) { - const page = - kind === 'editor' ? 'editor.html' : kind === 'presentation' ? 'presentation.html' : 'control.html'; - void win.loadURL(new URL(page, dev).toString()); + void win.loadURL(new URL(pageNameForKind(kind), dev).toString()); return; } void win.loadFile(getRendererHtmlPath(kind)); @@ -112,12 +133,27 @@ function ensureWindowBecomesVisible(win: BrowserWindow): void { }); } +function windowSizeForKind(kind: WindowKind): { width: number; height: number } { + if (kind === 'editor') return { width: 1280, height: 800 }; + if (kind === 'control') return { width: 1200, height: 800 }; + if (kind === 'sceneDescription') return { width: 720, height: 640 }; + return { width: 1280, height: 800 }; +} + function createWindow(kind: WindowKind, opts?: CreateWindowOpts): BrowserWindow { const deferEditor = kind === 'editor' && opts?.deferVisibility === true; const icon = loadBrandingWindowIcon(); + const size = windowSizeForKind(kind); const win = new BrowserWindow({ - width: kind === 'editor' ? 1280 : kind === 'control' ? 1200 : 1280, - height: 800, + width: size.width, + height: size.height, + ...(kind === 'sceneDescription' + ? { + minWidth: 520, + minHeight: 420, + autoHideMenuBar: true, + } + : {}), show: false, backgroundColor: '#09090B', ...(icon ? { icon } : {}), @@ -142,6 +178,9 @@ function createWindow(kind: WindowKind, opts?: CreateWindowOpts): BrowserWindow } win.setTitle(windowChromeTitle(kind, app.getLocale())); + if (kind === 'sceneDescription') { + win.setMenuBarVisibility(false); + } win.webContents.on('preload-error', (_event, preloadPath, error) => { console.error(`[preload-error] ${preloadPath}:`, error); @@ -168,6 +207,9 @@ function createWindow(kind: WindowKind, opts?: CreateWindowOpts): BrowserWindow win.on('closed', () => { if (kind !== 'presentation' && kind !== 'control') return; const open = windows.has('presentation') || windows.has('control'); + if (!open) { + closeSceneDescriptionWindow(); + } broadcastMultiWindowStateChanged(open); }); windows.set(kind, win); @@ -250,6 +292,7 @@ export function openMultiWindow() { } export function closeMultiWindow(): void { + closeSceneDescriptionWindow(); const pres = windows.get('presentation'); const ctrl = windows.get('control'); if (pres) pres.close(); @@ -260,6 +303,52 @@ export function isMultiWindowOpen(): boolean { return windows.has('presentation') || windows.has('control'); } +export function closeSceneDescriptionWindow(): void { + const win = windows.get('sceneDescription'); + if (win && !win.isDestroyed()) { + win.close(); + } +} + +export function getSceneDescriptionContent(): string { + return pendingSceneDescriptionHtml; +} + +/** Одно окно описания: переиспользовать существующее или создать новое. */ +export function openSceneDescriptionWindow(html: string): void { + pendingSceneDescriptionHtml = html; + const existing = windows.get('sceneDescription'); + if (existing && !existing.isDestroyed()) { + if (existing.isMinimized()) existing.restore(); + existing.show(); + existing.focus(); + existing.moveTop(); + sendSceneDescriptionContent(existing, html); + return; + } + + // Держим поверх пульта/презентации, иначе окно уходит под полноэкранный экран просмотра. + const parent = windows.get('control') ?? windows.get('presentation'); + const win = createWindow('sceneDescription', parent ? { parent } : undefined); + const { width, height } = win.getBounds(); + const display = screen.getDisplayMatching(parent?.getBounds() ?? win.getBounds()); + const { x, y, width: dw, height: dh } = display.workArea; + win.setBounds({ + x: Math.round(x + (dw - width) / 2), + y: Math.round(y + (dh - height) / 2), + width, + height, + }); + win.webContents.once('did-finish-load', () => { + sendSceneDescriptionContent(win, pendingSceneDescriptionHtml); + if (!win.isDestroyed()) { + win.show(); + win.focus(); + win.moveTop(); + } + }); +} + export function togglePresentationFullscreen(): boolean { const pres = windows.get('presentation'); if (!pres) return false; diff --git a/app/renderer/control/ControlApp.module.css b/app/renderer/control/ControlApp.module.css index 7ffe88f..b454a94 100644 --- a/app/renderer/control/ControlApp.module.css +++ b/app/renderer/control/ControlApp.module.css @@ -79,6 +79,98 @@ justify-content: center; } +.bookIcon { + display: block; + color: var(--text-muted-on-dark); +} + +.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; +} + +.descriptionViewDialog { + width: min(720px, calc(100vw - 32px)); + max-height: calc(100vh - 48px); + grid-template-rows: auto 1fr auto; +} + +.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; +} + +.modalFooter { + display: flex; + justify-content: flex-end; + gap: 10px; + margin-top: 4px; +} + +.descriptionViewBody { + min-height: 360px; + max-height: min(560px, calc(100vh - 200px)); + overflow: auto; + padding: 14px 16px; + border-radius: var(--radius-md); + border: 1px solid var(--stroke); + background: var(--color-overlay-dark-3); +} + +.descriptionViewProse { + color: var(--text0); + font-size: var(--text-md); + line-height: 1.55; + word-break: break-word; +} + +.descriptionViewEmpty { + color: var(--text2); + font-size: var(--text-sm); +} + .radiusRow { display: grid; grid-template-columns: 100px 1fr 44px; diff --git a/app/renderer/control/ControlApp.tsx b/app/renderer/control/ControlApp.tsx index a446c53..a91f164 100644 --- a/app/renderer/control/ControlApp.tsx +++ b/app/renderer/control/ControlApp.tsx @@ -3,9 +3,14 @@ import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 're import { pickEraseTargetId } from '../../shared/effectEraserHitTest'; import { ipcChannels } from '../../shared/ipc/contracts'; import type { SessionState } from '../../shared/ipc/contracts'; -import { isNodeInMainStoryline, isNodeInSideStoryline, listSideStoryStarts } from '../../shared/graph/sceneGraphLineage'; +import { + isNodeInMainStoryline, + isNodeInSideStoryline, + listSideStoryStarts, +} from '../../shared/graph/sceneGraphLineage'; import type { GraphNodeId, Scene, SceneId } from '../../shared/types'; import { useEditorI18n } from '../editor/i18n/EditorI18nContext'; +import { isSceneDescriptionEmpty } from '../editor/sceneDescriptionHtml'; import { getDndApi } from '../shared/dndApi'; import { RotatedImage } from '../shared/RotatedImage'; import { PixiEffectsOverlay } from '../shared/effects/PxiEffectsOverlay'; @@ -48,15 +53,7 @@ function playLightningEffectSound(): void { } } -function SideStoryTile({ - scene, - title, - onClick, -}: { - scene: Scene; - title: string; - onClick: () => void; -}) { +function SideStoryTile({ scene, title, onClick }: { scene: Scene; title: string; onClick: () => void }) { const thumbUrl = useAssetUrl(scene.previewThumbAssetId ?? scene.previewAssetId); const previewUrl = useAssetUrl(scene.previewAssetId); const imageUrl = thumbUrl ?? (scene.previewAssetType === 'image' ? previewUrl : null); @@ -213,6 +210,8 @@ export function ControlApp() { project && session?.currentSceneId ? project.scenes[session.currentSceneId] : undefined; const isVideoPreviewScene = currentScene?.previewAssetType === 'video'; const isDarkenScene = Boolean(currentScene?.darkenScene) && !isVideoPreviewScene; + const sceneDescription = currentScene?.description ?? ''; + const hasSceneDescription = !isSceneDescriptionEmpty(sceneDescription); const sceneAudioRefs = useMemo(() => currentScene?.media.audios ?? [], [currentScene]); // Keep this memo as narrow as possible: project changes on scene switch, // but campaign audio list/config often does not. @@ -1149,6 +1148,27 @@ export function ControlApp() {
{t('control.remoteTitle')}
+
{t('control.instruments')}
+
+
+ +
+
{!isVideoPreviewScene ? ( <>
{t('control.effects')}
diff --git a/app/renderer/control/controlApp.effectsPanel.test.ts b/app/renderer/control/controlApp.effectsPanel.test.ts index a5eebf0..85b83b4 100644 --- a/app/renderer/control/controlApp.effectsPanel.test.ts +++ b/app/renderer/control/controlApp.effectsPanel.test.ts @@ -48,6 +48,11 @@ void test('ControlApp: звук облака яда (public/oblako-yada.mp3)', ( void test('ControlApp: эффекты в пульте, иконки с тултипами и подписью для a11y', () => { const src = readControlApp(); + assert.ok(src.includes("t('control.instruments')")); + assert.ok(src.includes("t('control.descriptionTool')")); + assert.ok(src.includes("t('control.descriptionMissing')")); + assert.ok(src.includes('openSceneDescription')); + assert.ok(!src.includes('SceneDescriptionViewModal')); assert.ok(src.includes("t('control.effects')")); assert.ok(src.includes("t('control.tools')")); assert.ok(src.includes("t('control.fieldEffects')")); @@ -68,8 +73,13 @@ void test('ControlApp: эффекты в пульте, иконки с тулт assert.ok(src.includes("title={t('control.clearEffects')}")); assert.ok(src.includes("ariaLabel={t('control.clearEffects')}")); assert.ok(src.includes('#e5484d')); + const instruments = src.indexOf("t('control.instruments')"); const fx = src.indexOf("t('control.effects')"); const story = src.indexOf("t('control.storyLine')"); + assert.ok( + instruments !== -1 && fx !== -1 && instruments < fx, + 'Блок инструментов должен быть выше эффектов', + ); assert.ok(fx !== -1 && story !== -1 && fx < story, 'Блок эффектов должен быть выше сюжетной линии'); }); diff --git a/app/renderer/editor/EditorApp.module.css b/app/renderer/editor/EditorApp.module.css index d6141be..d79f982 100644 --- a/app/renderer/editor/EditorApp.module.css +++ b/app/renderer/editor/EditorApp.module.css @@ -645,6 +645,19 @@ font-weight: 700; } +.labelRow { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + min-height: 28px; +} + +.labelRow .labelSm { + flex: 1; + min-width: 0; +} + .spacer8 { height: 8px; } @@ -660,6 +673,75 @@ outline: none; } +.descriptionEmpty { + min-height: 36px; + padding: 8px 10px; + border-radius: var(--radius-md); + border: 1px dashed var(--stroke-2); + background: var(--color-overlay-dark-2); + color: var(--text2); + font-size: var(--text-xs); + line-height: 1.4; + display: flex; + align-items: center; +} + +.descriptionPreview { + max-height: 4.6em; + overflow: hidden; + padding: 8px 10px; + border-radius: var(--radius-md); + border: 1px solid var(--stroke); + background: var(--color-overlay-dark-3); + color: var(--text1); + font-size: var(--text-xs); + line-height: 1.45; + word-break: break-word; + pointer-events: none; +} + +.descriptionPreview :global(p), +.descriptionPreview :global(h2), +.descriptionPreview :global(h3), +.descriptionPreview :global(ul), +.descriptionPreview :global(ol), +.descriptionPreview :global(blockquote), +.descriptionPreview :global(pre) { + margin: 0 0 0.35em; +} + +.descriptionPreview :global(p:last-child), +.descriptionPreview :global(h2:last-child), +.descriptionPreview :global(h3:last-child), +.descriptionPreview :global(ul:last-child), +.descriptionPreview :global(ol:last-child), +.descriptionPreview :global(blockquote:last-child), +.descriptionPreview :global(pre:last-child) { + margin-bottom: 0; +} + +.descriptionPreview :global(h2), +.descriptionPreview :global(h3) { + font-size: 1em; + font-weight: 800; + color: var(--text0); +} + +.descriptionPreview :global(ul), +.descriptionPreview :global(ol) { + padding-left: 1.2em; +} + +.descriptionPreview :global(strong), +.descriptionPreview :global(b) { + font-weight: 800; + color: var(--text0); +} + +.descriptionPreview :global(a) { + color: var(--accent2); +} + .hint { color: var(--text2); font-size: var(--text-xs); diff --git a/app/renderer/editor/EditorApp.tsx b/app/renderer/editor/EditorApp.tsx index bae06d8..ff43266 100644 --- a/app/renderer/editor/EditorApp.tsx +++ b/app/renderer/editor/EditorApp.tsx @@ -1,6 +1,12 @@ import React, { startTransition, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; +import { moveSceneInListOrder, reconcileSceneListOrder } from '../../shared/graph/sceneListOrder'; +import type { + SceneImportResolution, + StorylineImportMergeReport, + StorylineSelection, +} from '../../shared/graph/storylineExportImport'; import { ipcChannels, type UpdaterCheckResponse, @@ -25,6 +31,8 @@ import { Button, Input } from '../shared/ui/controls'; import { LayoutShell } from '../shared/ui/LayoutShell'; import { useAssetUrl } from '../shared/useAssetImageUrl'; +import { AppAboutModal, InstructionsModal } from './about/EditorAboutModals'; +import styles from './EditorApp.module.css'; import { filterAudioFilePaths, partitionSceneMediaDrops, @@ -32,9 +40,20 @@ import { sceneTitleFromMediaPath, useFileDropZone, } from './fileDrop'; - -import { moveSceneInListOrder, reconcileSceneListOrder } from '../../shared/graph/sceneListOrder'; -import type { SceneImportResolution, StorylineImportMergeReport, StorylineSelection } from '../../shared/graph/storylineExportImport'; +import { buildNextSceneCardById } from './graph/sceneCardById'; +import { + DND_SCENE_ID_MIME, + SceneGraph, + type SceneGraphSceneCard, + type SceneGraphUiStrings, +} from './graph/SceneGraph'; +import type { HelpSectionId } from './help/helpSections'; +import { useEditorI18n } from './i18n/EditorI18nContext'; +import { EulaModal, LicenseAboutModal, LicenseTokenModal } from './license/EditorLicenseModals'; +import { isSceneDescriptionEmpty, sanitizeSceneDescriptionHtml } from './sceneDescriptionHtml'; +import { SceneDescriptionModal } from './SceneDescriptionModal'; +import type { ProjectNoticeCode } from './state/projectState'; +import { useProjectState } from './state/projectState'; import { buildSceneResolutionsForImport, computeImportConflicts, @@ -47,20 +66,6 @@ import { type ImportPeekResult, type ImportSourceSelection, } from './StorylineTransferModals'; -import { AppAboutModal, InstructionsModal } from './about/EditorAboutModals'; -import styles from './EditorApp.module.css'; -import { buildNextSceneCardById } from './graph/sceneCardById'; -import { - DND_SCENE_ID_MIME, - SceneGraph, - type SceneGraphSceneCard, - type SceneGraphUiStrings, -} from './graph/SceneGraph'; -import type { HelpSectionId } from './help/helpSections'; -import { useEditorI18n } from './i18n/EditorI18nContext'; -import { EulaModal, LicenseAboutModal, LicenseTokenModal } from './license/EditorLicenseModals'; -import type { ProjectNoticeCode } from './state/projectState'; -import { useProjectState } from './state/projectState'; type SceneCard = { id: SceneId; @@ -2238,8 +2243,14 @@ function SceneInspector({ onSideStoryLineTitleChange, }: SceneInspectorProps) { const { t } = useEditorI18n(); + const [descriptionModalOpen, setDescriptionModalOpen] = useState(false); const previewUrl = useAssetUrl(previewAssetId); const audioById = useMemo(() => new Map(audioRefs.map((a) => [a.assetId, a])), [audioRefs]); + const descriptionEmpty = isSceneDescriptionEmpty(description); + const descriptionPreviewHtml = useMemo( + () => (descriptionEmpty ? '' : sanitizeSceneDescriptionHtml(description)), + [description, descriptionEmpty], + ); const previewDrop = useFileDropZone({ disabled: previewBusy, onDropPaths: (paths) => { @@ -2256,12 +2267,40 @@ function SceneInspector({
{t('scene.title')}
-
{t('scene.description')}
-