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
+82
View File
@@ -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);
+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} />
@@ -0,0 +1,182 @@
.dialog {
width: min(720px, calc(100vw - 32px));
max-height: calc(100vh - 48px);
grid-template-rows: auto 1fr auto;
}
.editorShell {
display: grid;
grid-template-rows: auto 1fr;
min-height: 360px;
max-height: min(560px, calc(100vh - 200px));
border-radius: var(--radius-md);
border: 1px solid var(--stroke);
background: var(--color-overlay-dark-3);
overflow: hidden;
}
.toolbar {
display: flex;
flex-wrap: wrap;
gap: 4px;
padding: 8px;
border-bottom: 1px solid var(--stroke);
background: var(--color-panel-2);
}
.toolbarGroup {
display: flex;
gap: 2px;
}
.toolbarSep {
width: 1px;
align-self: stretch;
margin: 2px 4px;
background: var(--stroke-2);
}
.toolBtn {
min-width: 32px;
height: 32px;
padding: 0 8px;
border: 1px solid transparent;
border-radius: var(--radius-xs);
background: transparent;
color: var(--text1);
cursor: pointer;
font: inherit;
font-size: var(--text-sm);
font-weight: 700;
line-height: 1;
display: grid;
place-items: center;
}
.toolBtn:hover:not(:disabled) {
background: var(--panel);
color: var(--text0);
}
.toolBtnActive {
border-color: var(--accent-border);
background: var(--accent-fill-soft);
color: var(--accent2);
}
.toolBtn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.toolIcon {
display: block;
}
.editorContent {
min-height: 0;
overflow: auto;
padding: 14px 16px;
}
.editorContent :global(.tiptap) {
min-height: 280px;
outline: none;
color: var(--text0);
font-size: var(--text-md);
line-height: 1.55;
word-break: break-word;
}
.editorContent :global(.tiptap p.is-editor-empty:first-child::before) {
color: var(--text2);
content: attr(data-placeholder);
float: left;
height: 0;
pointer-events: none;
}
/* Shared rich-text look (editor + inspector preview) */
.prose :global(p) {
margin: 0 0 0.65em;
}
.prose :global(p:last-child) {
margin-bottom: 0;
}
.prose :global(h2),
.prose :global(h3) {
margin: 0.85em 0 0.4em;
font-weight: 800;
color: var(--text0);
line-height: 1.3;
}
.prose :global(h2) {
font-size: 1.2em;
}
.prose :global(h3) {
font-size: 1.08em;
}
.prose :global(ul),
.prose :global(ol) {
margin: 0 0 0.65em;
padding-left: 1.35em;
}
.prose :global(li) {
margin: 0.15em 0;
}
.prose :global(strong) {
font-weight: 800;
color: var(--text0);
}
.prose :global(em) {
font-style: italic;
}
.prose :global(u) {
text-decoration: underline;
text-underline-offset: 2px;
}
.prose :global(a) {
color: var(--accent2);
text-decoration: underline;
text-underline-offset: 2px;
}
.prose :global(blockquote) {
margin: 0 0 0.65em;
padding: 0.35em 0 0.35em 0.85em;
border-left: 3px solid var(--accent-border);
color: var(--text1);
}
.prose :global(code) {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 0.92em;
padding: 0.1em 0.35em;
border-radius: 4px;
background: var(--color-overlay-dark-4);
color: var(--text0);
}
.prose :global(pre) {
margin: 0 0 0.65em;
padding: 10px 12px;
border-radius: var(--radius-xs);
border: 1px solid var(--stroke);
background: var(--color-overlay-dark-5);
overflow-x: auto;
}
.prose :global(pre code) {
padding: 0;
background: transparent;
}
@@ -0,0 +1,242 @@
import Placeholder from '@tiptap/extension-placeholder';
import { EditorContent, useEditor, useEditorState } from '@tiptap/react';
import StarterKit from '@tiptap/starter-kit';
import React, { useEffect, useMemo } from 'react';
import { createPortal } from 'react-dom';
import { Button } from '../shared/ui/controls';
import styles from './EditorApp.module.css';
import { useEditorI18n } from './i18n/EditorI18nContext';
import { normalizeSceneDescriptionHtml } from './sceneDescriptionHtml';
import modalStyles from './SceneDescriptionModal.module.css';
type SceneDescriptionModalProps = {
initialHtml: string;
onClose: () => void;
onSave: (html: string) => void;
};
function ToolbarIcon({ path, size = 14 }: { path: string; size?: number }) {
return (
<svg className={modalStyles.toolIcon} viewBox="0 0 24 24" width={size} height={size} aria-hidden>
<path fill="currentColor" d={path} />
</svg>
);
}
function ToolButton({
active = false,
disabled = false,
title,
onClick,
children,
}: {
active?: boolean;
disabled?: boolean;
title: string;
onClick: () => void;
children: React.ReactNode;
}) {
return (
<button
type="button"
title={title}
aria-label={title}
aria-pressed={active}
disabled={disabled}
className={[modalStyles.toolBtn, active ? modalStyles.toolBtnActive : ''].filter(Boolean).join(' ')}
onClick={onClick}
>
{children}
</button>
);
}
export function SceneDescriptionModal({ initialHtml, onClose, onSave }: SceneDescriptionModalProps) {
const { t } = useEditorI18n();
const extensions = useMemo(
() => [
StarterKit.configure({
heading: { levels: [2, 3] },
codeBlock: false,
link: {
openOnClick: false,
autolink: true,
HTMLAttributes: {
rel: 'noopener noreferrer',
target: '_blank',
},
},
}),
Placeholder.configure({
placeholder: t('scene.descriptionPlaceholder'),
}),
],
[t],
);
const editor = useEditor({
extensions,
content: initialHtml || '',
immediatelyRender: true,
shouldRerenderOnTransaction: true,
editorProps: {
attributes: {
class: [modalStyles.prose, 'tiptap'].join(' '),
'aria-label': t('scene.descriptionModalTitle'),
},
},
});
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [onClose]);
const toolbarState = useEditorState({
editor,
selector: ({ editor: ed }) => ({
bold: ed.isActive('bold'),
italic: ed.isActive('italic'),
underline: ed.isActive('underline'),
bulletList: ed.isActive('bulletList'),
orderedList: ed.isActive('orderedList'),
h2: ed.isActive('heading', { level: 2 }),
h3: ed.isActive('heading', { level: 3 }),
blockquote: ed.isActive('blockquote'),
canLink: ed.isEditable,
}),
});
const handleSave = () => {
onSave(normalizeSceneDescriptionHtml(editor.getHTML()));
};
const setLink = () => {
const prev = editor.getAttributes('link').href as string | undefined;
const next = window.prompt(t('scene.descriptionLinkPrompt'), prev ?? 'https://');
if (next === null) return;
const trimmed = next.trim();
if (trimmed === '') {
editor.chain().focus().extendMarkRange('link').unsetLink().run();
return;
}
editor.chain().focus().extendMarkRange('link').setLink({ href: trimmed }).run();
};
return createPortal(
<>
<button
type="button"
aria-label={t('common.close')}
onClick={onClose}
className={styles.modalBackdrop}
/>
<div role="dialog" aria-modal="true" className={[styles.modalDialog, modalStyles.dialog].join(' ')}>
<div className={styles.modalHeader}>
<div className={styles.modalTitle}>{t('scene.descriptionModalTitle')}</div>
<button
type="button"
aria-label={t('common.close')}
onClick={onClose}
className={styles.modalClose}
>
×
</button>
</div>
<div className={modalStyles.editorShell}>
<div className={modalStyles.toolbar} role="toolbar" aria-label={t('scene.descriptionToolbar')}>
<div className={modalStyles.toolbarGroup}>
<ToolButton
title={t('scene.descriptionBold')}
active={toolbarState.bold}
onClick={() => editor.chain().focus().toggleBold().run()}
>
<strong>B</strong>
</ToolButton>
<ToolButton
title={t('scene.descriptionItalic')}
active={toolbarState.italic}
onClick={() => editor.chain().focus().toggleItalic().run()}
>
<em>I</em>
</ToolButton>
<ToolButton
title={t('scene.descriptionUnderline')}
active={toolbarState.underline}
onClick={() => editor.chain().focus().toggleUnderline().run()}
>
<span style={{ textDecoration: 'underline' }}>U</span>
</ToolButton>
</div>
<div className={modalStyles.toolbarSep} />
<div className={modalStyles.toolbarGroup}>
<ToolButton
title={t('scene.descriptionHeading2')}
active={toolbarState.h2}
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
>
H2
</ToolButton>
<ToolButton
title={t('scene.descriptionHeading3')}
active={toolbarState.h3}
onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}
>
H3
</ToolButton>
<ToolButton
title={t('scene.descriptionQuote')}
active={toolbarState.blockquote}
onClick={() => editor.chain().focus().toggleBlockquote().run()}
>
<ToolbarIcon path="M6 17h3l2-4V7H5v6h3zm8 0h3l2-4V7h-6v6h3z" />
</ToolButton>
</div>
<div className={modalStyles.toolbarSep} />
<div className={modalStyles.toolbarGroup}>
<ToolButton
title={t('scene.descriptionBulletList')}
active={toolbarState.bulletList}
onClick={() => editor.chain().focus().toggleBulletList().run()}
>
<ToolbarIcon path="M4 6h2v2H4V6zm0 5h2v2H4v-2zm0 5h2v2H4v-2zm4-10h12v2H8V6zm0 5h12v2H8v-2zm0 5h12v2H8v-2z" />
</ToolButton>
<ToolButton
title={t('scene.descriptionOrderedList')}
active={toolbarState.orderedList}
onClick={() => editor.chain().focus().toggleOrderedList().run()}
>
<ToolbarIcon path="M2 17h2v.5H3v1h1v.5H2v1h3v-4H2v1zm1-9h1V4H2v1h1v3zm-1 3h1.8L2 13.1V14h3v-1H3.2L5 10.9V10H2v1zm5-6v2h14V5H7zm0 14h14v-2H7v2zm0-6h14v-2H7v2z" />
</ToolButton>
<ToolButton
title={t('scene.descriptionLink')}
disabled={!toolbarState.canLink}
onClick={setLink}
>
<ToolbarIcon path="M3.9 12a5 5 0 0 1 5-5h4v2h-4a3 3 0 1 0 0 6h4v2h-4a5 5 0 0 1-5-5zm7-1h6v2h-6v-2zm5-4h-4v2h4a3 3 0 1 1 0 6h-4v2h4a5 5 0 0 0 0-10z" />
</ToolButton>
</div>
</div>
<div className={modalStyles.editorContent}>
<EditorContent editor={editor} />
</div>
</div>
<div className={styles.modalFooter}>
<Button onClick={onClose}>{t('common.cancel')}</Button>
<Button variant="primary" onClick={handleSave}>
{t('common.save')}
</Button>
</div>
</div>
</>,
document.body,
);
}
@@ -54,6 +54,7 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'common.close': 'Закрыть',
'common.cancel': 'Отмена',
'common.save': 'Сохранить',
'common.edit': 'Редактировать',
'common.understood': 'Понятно',
'common.message': 'Сообщение',
'common.error': 'Ошибка',
@@ -325,6 +326,20 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'scene.title': 'НАЗВАНИЕ СЦЕНЫ',
'scene.description': 'ОПИСАНИЕ',
'scene.descriptionEmpty': 'описание отсутствует',
'scene.descriptionModalTitle': 'Описание сцены',
'scene.descriptionPlaceholder': 'Введите описание сцены…',
'scene.descriptionToolbar': 'Форматирование',
'scene.descriptionBold': 'Жирный',
'scene.descriptionItalic': 'Курсив',
'scene.descriptionUnderline': 'Подчёркнутый',
'scene.descriptionHeading2': 'Заголовок',
'scene.descriptionHeading3': 'Подзаголовок',
'scene.descriptionQuote': 'Цитата',
'scene.descriptionBulletList': 'Маркированный список',
'scene.descriptionOrderedList': 'Нумерованный список',
'scene.descriptionLink': 'Ссылка',
'scene.descriptionLinkPrompt': 'URL ссылки',
'scene.preview': 'ПРЕВЬЮ СЦЕНЫ',
'scene.previewHint': 'Файл изображения (PNG, JPG, WebP, GIF и т.д.).',
'scene.previewEmpty': 'Превью не задано',
@@ -369,6 +384,9 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'scene.sideStoryLineTitle': 'Название побочной линии',
'control.remoteTitle': 'ПУЛЬТ УПРАВЛЕНИЯ',
'control.instruments': 'ИНСТРУМЕНТЫ',
'control.descriptionTool': 'Описание',
'control.descriptionMissing': 'Описание отсутствует',
'control.effects': 'ЭФФЕКТЫ',
'control.tools': 'Инструменты',
'control.fieldEffects': 'Эффекты поля',
@@ -439,6 +457,7 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'common.close': 'Close',
'common.cancel': 'Cancel',
'common.save': 'Save',
'common.edit': 'Edit',
'common.understood': 'OK',
'common.message': 'Message',
'common.error': 'Error',
@@ -710,6 +729,20 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'scene.title': 'SCENE TITLE',
'scene.description': 'DESCRIPTION',
'scene.descriptionEmpty': 'no description',
'scene.descriptionModalTitle': 'Scene description',
'scene.descriptionPlaceholder': 'Enter scene description…',
'scene.descriptionToolbar': 'Formatting',
'scene.descriptionBold': 'Bold',
'scene.descriptionItalic': 'Italic',
'scene.descriptionUnderline': 'Underline',
'scene.descriptionHeading2': 'Heading',
'scene.descriptionHeading3': 'Subheading',
'scene.descriptionQuote': 'Quote',
'scene.descriptionBulletList': 'Bullet list',
'scene.descriptionOrderedList': 'Numbered list',
'scene.descriptionLink': 'Link',
'scene.descriptionLinkPrompt': 'Link URL',
'scene.preview': 'SCENE PREVIEW',
'scene.previewHint': 'Image file (PNG, JPG, WebP, GIF, etc.).',
'scene.previewEmpty': 'No preview',
@@ -753,6 +786,9 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'graph.runFromScene': 'Start from this scene',
'control.remoteTitle': 'CONTROL PANEL',
'control.instruments': 'TOOLS',
'control.descriptionTool': 'Description',
'control.descriptionMissing': 'No description',
'control.effects': 'EFFECTS',
'control.tools': 'Tools',
'control.fieldEffects': 'Field effects',
@@ -0,0 +1,19 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { isSceneDescriptionEmpty, normalizeSceneDescriptionHtml } from './sceneDescriptionHtml';
void test('isSceneDescriptionEmpty treats blank and empty paragraphs as empty', () => {
assert.equal(isSceneDescriptionEmpty(''), true);
assert.equal(isSceneDescriptionEmpty(' '), true);
assert.equal(isSceneDescriptionEmpty('<p></p>'), true);
assert.equal(isSceneDescriptionEmpty('<p><br></p>'), true);
assert.equal(isSceneDescriptionEmpty('<p>&nbsp;</p>'), true);
assert.equal(isSceneDescriptionEmpty('<p>Hi</p>'), false);
assert.equal(isSceneDescriptionEmpty('plain'), false);
});
void test('normalizeSceneDescriptionHtml clears empty markup', () => {
assert.equal(normalizeSceneDescriptionHtml('<p></p>'), '');
assert.equal(normalizeSceneDescriptionHtml('<p>Ok</p>'), '<p>Ok</p>');
});
@@ -0,0 +1,94 @@
/** Detect empty TipTap / legacy plain description values. */
export function isSceneDescriptionEmpty(html: string | null | undefined): boolean {
if (html == null) return true;
const trimmed = html.trim();
if (trimmed === '') return true;
const text = trimmed
.replace(/<br\s*\/?>/gi, ' ')
.replace(/<\/(p|div|h[1-6]|li|blockquote)>/gi, ' ')
.replace(/<[^>]+>/g, '')
.replace(/&nbsp;/gi, ' ')
.replace(/&#160;/g, ' ')
.replace(/\s+/g, ' ')
.trim();
return text.length === 0;
}
/** Persist empty editor as '' instead of empty paragraph markup. */
export function normalizeSceneDescriptionHtml(html: string): string {
return isSceneDescriptionEmpty(html) ? '' : html.trim();
}
const ALLOWED_TAGS = new Set([
'P',
'BR',
'STRONG',
'B',
'EM',
'I',
'U',
'S',
'H2',
'H3',
'UL',
'OL',
'LI',
'BLOCKQUOTE',
'A',
'CODE',
'SPAN',
]);
/** Sanitize TipTap HTML for safe preview rendering. */
export function sanitizeSceneDescriptionHtml(html: string): string {
if (typeof document === 'undefined') return html;
if (isSceneDescriptionEmpty(html)) return '';
if (!/<[a-z][\s\S]*>/i.test(html)) {
const esc = document.createElement('div');
esc.textContent = html;
return esc.innerHTML;
}
const template = document.createElement('template');
template.innerHTML = html.trim();
const container = document.createElement('div');
container.appendChild(template.content.cloneNode(true));
const walk = (node: Node) => {
if (node.nodeType === Node.ELEMENT_NODE) {
const el = node as HTMLElement;
const tag = el.tagName;
if (!ALLOWED_TAGS.has(tag)) {
const parent = el.parentNode;
if (parent) {
while (el.firstChild) parent.insertBefore(el.firstChild, el);
parent.removeChild(el);
}
return;
}
for (const attr of [...el.attributes]) {
const name = attr.name.toLowerCase();
if (tag === 'A' && (name === 'href' || name === 'target' || name === 'rel')) {
if (name === 'href') {
const href = attr.value.trim();
if (!/^(https?:|mailto:)/i.test(href)) {
el.removeAttribute(attr.name);
}
}
continue;
}
el.removeAttribute(attr.name);
}
if (tag === 'A') {
el.setAttribute('rel', 'noopener noreferrer');
el.setAttribute('target', '_blank');
}
}
for (const child of [...node.childNodes]) walk(child);
};
// Walk children only — never treat the container as a removable tag
// (that would unwrap it and leave container.innerHTML empty).
for (const child of [...container.childNodes]) walk(child);
return container.innerHTML;
}