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:
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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'));
|
||||
|
||||
@@ -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<WindowKind, BrowserWindow>();
|
||||
|
||||
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;
|
||||
|
||||
@@ -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, 'Блок эффектов должен быть выше сюжетной линии');
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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> </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(/ /gi, ' ')
|
||||
.replace(/ /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;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="icon" href="/app-window-icon.png" type="image/png" />
|
||||
<title>TTRPG - Scene description</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/sceneDescription/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,28 @@
|
||||
.page {
|
||||
height: 100vh;
|
||||
padding: 16px;
|
||||
box-sizing: border-box;
|
||||
display: grid;
|
||||
background: var(--color-surface-elevated);
|
||||
}
|
||||
|
||||
.body {
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
padding: 14px 16px;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--stroke);
|
||||
background: var(--color-overlay-dark-3);
|
||||
}
|
||||
|
||||
.proseText {
|
||||
color: var(--text0);
|
||||
font-size: var(--text-md);
|
||||
line-height: 1.55;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.empty {
|
||||
color: var(--text2);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { ipcChannels } from '../../shared/ipc/contracts';
|
||||
import { useEditorI18n } from '../editor/i18n/EditorI18nContext';
|
||||
import { isSceneDescriptionEmpty, sanitizeSceneDescriptionHtml } from '../editor/sceneDescriptionHtml';
|
||||
import proseStyles from '../editor/SceneDescriptionModal.module.css';
|
||||
import { getDndApi } from '../shared/dndApi';
|
||||
|
||||
import styles from './SceneDescriptionApp.module.css';
|
||||
|
||||
export function SceneDescriptionApp() {
|
||||
const { t } = useEditorI18n();
|
||||
const [html, setHtml] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
const api = getDndApi();
|
||||
void api.invoke(ipcChannels.windows.getSceneDescriptionContent, {}).then(({ html: next }) => {
|
||||
setHtml(next);
|
||||
});
|
||||
return api.on(ipcChannels.windows.sceneDescriptionContent, ({ html: next }) => {
|
||||
setHtml(next);
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') window.close();
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
return () => window.removeEventListener('keydown', onKey);
|
||||
}, []);
|
||||
|
||||
const safeHtml = useMemo(() => sanitizeSceneDescriptionHtml(html), [html]);
|
||||
const empty = isSceneDescriptionEmpty(html);
|
||||
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
<div className={styles.body}>
|
||||
{empty ? (
|
||||
<div className={styles.empty}>{t('scene.descriptionEmpty')}</div>
|
||||
) : (
|
||||
<div
|
||||
className={[proseStyles.prose, styles.proseText].join(' ')}
|
||||
dangerouslySetInnerHTML={{ __html: safeHtml }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import React from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
|
||||
import '../shared/ui/globals.css';
|
||||
import { EditorI18nProvider } from '../editor/i18n/EditorI18nContext';
|
||||
|
||||
import { SceneDescriptionApp } from './SceneDescriptionApp';
|
||||
|
||||
const rootEl = document.getElementById('root');
|
||||
if (!rootEl) {
|
||||
throw new Error('Missing #root element');
|
||||
}
|
||||
|
||||
createRoot(rootEl).render(
|
||||
<React.StrictMode>
|
||||
<EditorI18nProvider>
|
||||
<SceneDescriptionApp />
|
||||
</EditorI18nProvider>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
@@ -13,6 +13,12 @@
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.disabledTipHost {
|
||||
display: inline-flex;
|
||||
vertical-align: middle;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.buttonPrimary {
|
||||
border: 1px solid var(--accent-border);
|
||||
background: var(--accent-fill-solid);
|
||||
|
||||
@@ -12,4 +12,5 @@ void test('Button: тултип через портал (title), не тольк
|
||||
assert.ok(src.includes('role="tooltip"'));
|
||||
assert.ok(src.includes('onMouseEnter={showTip}'));
|
||||
assert.ok(src.includes('document.body'));
|
||||
assert.ok(src.includes('disabledTipHost'), 'disabled + title → хост для hover-тултипа');
|
||||
});
|
||||
|
||||
@@ -28,11 +28,12 @@ export function Button({
|
||||
tooltipPlacement = 'top',
|
||||
}: ButtonProps) {
|
||||
const btnRef = useRef<HTMLButtonElement | null>(null);
|
||||
const hostRef = useRef<HTMLSpanElement | null>(null);
|
||||
const [tipPos, setTipPos] = useState<{ x: number; y: number } | null>(null);
|
||||
|
||||
const showTip = useCallback(() => {
|
||||
if (disabled || !title) return;
|
||||
const el = btnRef.current;
|
||||
if (!title) return;
|
||||
const el = disabled ? hostRef.current : btnRef.current;
|
||||
if (!el) return;
|
||||
const r = el.getBoundingClientRect();
|
||||
if (tooltipPlacement === 'bottom-left') {
|
||||
@@ -66,22 +67,38 @@ export function Button({
|
||||
)
|
||||
: null;
|
||||
|
||||
const button = (
|
||||
<button
|
||||
ref={btnRef}
|
||||
type="button"
|
||||
className={btnClass}
|
||||
disabled={disabled}
|
||||
aria-label={ariaLabel}
|
||||
onClick={disabled ? undefined : onClick}
|
||||
onMouseEnter={disabled ? undefined : showTip}
|
||||
onMouseLeave={disabled ? undefined : hideTip}
|
||||
onFocus={showTip}
|
||||
onBlur={hideTip}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
|
||||
// Disabled buttons don't receive mouse events — host span keeps tooltip usable.
|
||||
if (disabled && title) {
|
||||
return (
|
||||
<>
|
||||
<span ref={hostRef} className={styles.disabledTipHost} onMouseEnter={showTip} onMouseLeave={hideTip}>
|
||||
{button}
|
||||
</span>
|
||||
{tip}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
ref={btnRef}
|
||||
type="button"
|
||||
className={btnClass}
|
||||
disabled={disabled}
|
||||
aria-label={ariaLabel}
|
||||
onClick={disabled ? undefined : onClick}
|
||||
onMouseEnter={showTip}
|
||||
onMouseLeave={hideTip}
|
||||
onFocus={showTip}
|
||||
onBlur={hideTip}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
{button}
|
||||
{tip}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -15,13 +15,14 @@ export function appDisplayNameForLocale(localeTag: string): string {
|
||||
/** Префикс заголовка окон: `TTRPG - Редактор`. */
|
||||
export const APP_WINDOW_BRAND = 'TTRPG';
|
||||
|
||||
export type AppWindowKind = 'editor' | 'presentation' | 'control' | 'boot';
|
||||
export type AppWindowKind = 'editor' | 'presentation' | 'control' | 'boot' | 'sceneDescription';
|
||||
|
||||
const WINDOW_SUFFIX: Record<AppWindowKind, { ru: string; en: string }> = {
|
||||
editor: { ru: 'Редактор', en: 'Editor' },
|
||||
presentation: { ru: 'Презентация', en: 'Presentation' },
|
||||
control: { ru: 'Пульт', en: 'Control' },
|
||||
boot: { ru: 'Загрузка', en: 'Loading' },
|
||||
sceneDescription: { ru: 'Описание сцены', en: 'Scene description' },
|
||||
};
|
||||
|
||||
export function windowChromeTitle(kind: AppWindowKind, localeTag: string): string {
|
||||
|
||||
@@ -81,6 +81,10 @@ export const ipcChannels = {
|
||||
togglePresentationFullscreen: 'windows.togglePresentationFullscreen',
|
||||
getMultiWindowState: 'windows.getMultiWindowState',
|
||||
multiWindowStateChanged: 'windows.multiWindowStateChanged',
|
||||
openSceneDescription: 'windows.openSceneDescription',
|
||||
closeSceneDescription: 'windows.closeSceneDescription',
|
||||
getSceneDescriptionContent: 'windows.getSceneDescriptionContent',
|
||||
sceneDescriptionContent: 'windows.sceneDescriptionContent',
|
||||
},
|
||||
session: {
|
||||
stateChanged: 'session.stateChanged',
|
||||
@@ -156,6 +160,7 @@ export type IpcEventMap = {
|
||||
[ipcChannels.video.stateChanged]: { state: VideoPlaybackState };
|
||||
[ipcChannels.license.statusChanged]: { snapshot: LicenseSnapshot };
|
||||
[ipcChannels.windows.multiWindowStateChanged]: { open: boolean };
|
||||
[ipcChannels.windows.sceneDescriptionContent]: { html: string };
|
||||
[ipcChannels.project.importZipProgress]: ZipProgressEvent;
|
||||
[ipcChannels.project.exportZipProgress]: ZipProgressEvent;
|
||||
[ipcChannels.project.scenePreviewImportProgress]: ScenePreviewImportEvent;
|
||||
@@ -373,6 +378,18 @@ export type IpcInvokeMap = {
|
||||
req: Record<string, never>;
|
||||
res: { open: boolean };
|
||||
};
|
||||
[ipcChannels.windows.openSceneDescription]: {
|
||||
req: { html: string };
|
||||
res: { ok: true };
|
||||
};
|
||||
[ipcChannels.windows.closeSceneDescription]: {
|
||||
req: Record<string, never>;
|
||||
res: { ok: true };
|
||||
};
|
||||
[ipcChannels.windows.getSceneDescriptionContent]: {
|
||||
req: Record<string, never>;
|
||||
res: { html: string };
|
||||
};
|
||||
[ipcChannels.effects.getState]: {
|
||||
req: Record<string, never>;
|
||||
res: { state: EffectsState };
|
||||
|
||||
Reference in New Issue
Block a user