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:
Ivan Fontosh
2026-07-15 09:49:40 +08:00
parent 35a6e979eb
commit cfa3959fb3
26 changed files with 1795 additions and 60 deletions
+62 -23
View File
@@ -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({
<div className={styles.labelSm}>{t('scene.title')}</div>
<Input value={title} onChange={onTitleChange} />
<div className={styles.spacer8} />
<div className={styles.labelSm}>{t('scene.description')}</div>
<textarea
className={styles.textarea}
value={description}
onChange={(e) => onDescriptionChange(e.target.value)}
/>
<div className={styles.labelRow}>
<div className={styles.labelSm}>{t('scene.description')}</div>
<Button
iconOnly
title={t('common.edit')}
ariaLabel={t('common.edit')}
onClick={() => setDescriptionModalOpen(true)}
>
<svg viewBox="0 0 24 24" width={14} height={14} aria-hidden>
<path
fill="currentColor"
d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04a1 1 0 0 0 0-1.41l-2.34-2.34a1 1 0 0 0-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z"
/>
</svg>
</Button>
</div>
{descriptionEmpty ? (
<div className={styles.descriptionEmpty}>{t('scene.descriptionEmpty')}</div>
) : (
<div
className={styles.descriptionPreview}
dangerouslySetInnerHTML={{ __html: descriptionPreviewHtml }}
/>
)}
{descriptionModalOpen ? (
<SceneDescriptionModal
initialHtml={description}
onClose={() => setDescriptionModalOpen(false)}
onSave={(html) => {
onDescriptionChange(html);
setDescriptionModalOpen(false);
}}
/>
) : null}
{sideStoryStartNodes.length > 0 ? (
<>
<div className={styles.spacer8} />