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 <cursoragent@cursor.com>
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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() {
|
||||
<Surface className={styles.remote}>
|
||||
<div className={styles.remoteTitle}>{t('control.remoteTitle')}</div>
|
||||
<div className={styles.spacer12} />
|
||||
<div className={styles.sectionLabel}>{t('control.instruments')}</div>
|
||||
<div className={styles.spacer8} />
|
||||
<div className={styles.iconRow}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
iconOnly
|
||||
disabled={!hasSceneDescription}
|
||||
title={hasSceneDescription ? t('control.descriptionTool') : t('control.descriptionMissing')}
|
||||
ariaLabel={hasSceneDescription ? t('control.descriptionTool') : t('control.descriptionMissing')}
|
||||
onClick={() => {
|
||||
void api.invoke(ipcChannels.windows.openSceneDescription, { html: sceneDescription }).catch((err) => {
|
||||
console.error('[control] openSceneDescription failed', err);
|
||||
});
|
||||
}}
|
||||
>
|
||||
<span className={styles.iconGlyph} aria-hidden>
|
||||
📖
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
<div className={styles.spacer12} />
|
||||
{!isVideoPreviewScene ? (
|
||||
<>
|
||||
<div className={styles.sectionLabel}>{t('control.effects')}</div>
|
||||
|
||||
@@ -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, 'Блок эффектов должен быть выше сюжетной линии');
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user