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
+14
View File
@@ -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();
+1
View File
@@ -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'));
+95 -6
View File
@@ -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;
+30 -10
View File
@@ -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, 'Блок эффектов должен быть выше сюжетной линии');
});
+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;
}
+13
View File
@@ -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>
);
}
+20
View File
@@ -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-тултипа');
});
+33 -16
View File
@@ -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}
</>
);
+2 -1
View File
@@ -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 {
+17
View File
@@ -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 };
+652 -3
View File
@@ -11,6 +11,9 @@
"license": "ISC",
"dependencies": {
"@fontsource/inter": "^5.2.8",
"@tiptap/extension-placeholder": "^3.27.4",
"@tiptap/react": "^3.27.4",
"@tiptap/starter-kit": "^3.27.4",
"electron-updater": "^6.6.2",
"ffmpeg-static": "^5.3.0",
"pixi.js": "^8.18.1",
@@ -1457,6 +1460,34 @@
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
}
},
"node_modules/@floating-ui/core": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.8.0.tgz",
"integrity": "sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==",
"license": "MIT",
"optional": true,
"dependencies": {
"@floating-ui/utils": "^0.2.12"
}
},
"node_modules/@floating-ui/dom": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.8.0.tgz",
"integrity": "sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==",
"license": "MIT",
"optional": true,
"dependencies": {
"@floating-ui/core": "^1.8.0",
"@floating-ui/utils": "^0.2.12"
}
},
"node_modules/@floating-ui/utils": {
"version": "0.2.12",
"resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.12.tgz",
"integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==",
"license": "MIT",
"optional": true
},
"node_modules/@fontsource/inter": {
"version": "5.2.8",
"resolved": "https://registry.npmjs.org/@fontsource/inter/-/inter-5.2.8.tgz",
@@ -3136,6 +3167,449 @@
"node": ">=10"
}
},
"node_modules/@tiptap/core": {
"version": "3.27.4",
"resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.27.4.tgz",
"integrity": "sha512-8W/GwlEn0JwNdpyVfTWcXwHYUpj9BWwO++YxtizmgjJzlwigSh7/xLVJMwVykuQHQ2fCq5rkUvmBRtpHOMLUQA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/pm": "3.27.4"
}
},
"node_modules/@tiptap/extension-blockquote": {
"version": "3.27.4",
"resolved": "https://registry.npmjs.org/@tiptap/extension-blockquote/-/extension-blockquote-3.27.4.tgz",
"integrity": "sha512-d1tOHgP3R5cOE+Ot8qL/dkLXRByajgn+j6cCXHqDtmJO2wsK9knmbKQ0SEjbKrU6OgHrTnY/EotNxBEBW9HGoA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.27.4",
"@tiptap/pm": "3.27.4"
}
},
"node_modules/@tiptap/extension-bold": {
"version": "3.27.4",
"resolved": "https://registry.npmjs.org/@tiptap/extension-bold/-/extension-bold-3.27.4.tgz",
"integrity": "sha512-wTtJUUAxCAZ01ICH2DNlOBzzHKRQ1ZST8aRYtIhBPzqEUhnJaKGcjnDB4X49fqPi48iXaPxzhsInDl+rVUujWg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.27.4"
}
},
"node_modules/@tiptap/extension-bubble-menu": {
"version": "3.27.4",
"resolved": "https://registry.npmjs.org/@tiptap/extension-bubble-menu/-/extension-bubble-menu-3.27.4.tgz",
"integrity": "sha512-Poy7xwcD3POG5ew/TW7mYXv7m++vCchvHxPUqIfnTxBxvvvqDZkPYFWZS1lvPrSBtm1DcfUTQAgVutM5NDZ99Q==",
"license": "MIT",
"optional": true,
"dependencies": {
"@floating-ui/dom": "^1.0.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.27.4",
"@tiptap/pm": "3.27.4"
}
},
"node_modules/@tiptap/extension-bullet-list": {
"version": "3.27.4",
"resolved": "https://registry.npmjs.org/@tiptap/extension-bullet-list/-/extension-bullet-list-3.27.4.tgz",
"integrity": "sha512-rvja0N1RnwGJAVwDdbUfDIJ4NoT+KjPFaZudKiPuEMfMHfbqe4xcbbC2hsfs61JNcl2xmx+ohV6lzD9YxxJl1w==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extension-list": "3.27.4"
}
},
"node_modules/@tiptap/extension-code": {
"version": "3.27.4",
"resolved": "https://registry.npmjs.org/@tiptap/extension-code/-/extension-code-3.27.4.tgz",
"integrity": "sha512-aPc7opCR1ylK4m4c2lsjLsGpEBD1fLQQKWd5PbZiJvrTF8gkdGZlYLt9A6VukpxeJyHhb22Jaj4fxgKmGMeTtw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.27.4"
}
},
"node_modules/@tiptap/extension-code-block": {
"version": "3.27.4",
"resolved": "https://registry.npmjs.org/@tiptap/extension-code-block/-/extension-code-block-3.27.4.tgz",
"integrity": "sha512-a5caWfWN6Z6usy48vzJDDOhWoA6+rFFCHGpQM7jXn/7rRzYPcvBzTZUGptjEbltj4YqtrQ2tVwTJcCtbb+mknA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.27.4",
"@tiptap/pm": "3.27.4"
}
},
"node_modules/@tiptap/extension-document": {
"version": "3.27.4",
"resolved": "https://registry.npmjs.org/@tiptap/extension-document/-/extension-document-3.27.4.tgz",
"integrity": "sha512-7nAqgfkgb9HADBeCTnOHuTiyZuxfxvMPT3nH4OZeY+cmtkI1On3QffqlmtcUPvNbkhT3o9ehA1hVfCnQ1Ye4LQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.27.4"
}
},
"node_modules/@tiptap/extension-dropcursor": {
"version": "3.27.4",
"resolved": "https://registry.npmjs.org/@tiptap/extension-dropcursor/-/extension-dropcursor-3.27.4.tgz",
"integrity": "sha512-RiZasQJuUTUO3aME16Bn8eJH7cYnvhT5JCFDFq0ya/1iFI9wUQA2NJC5tb5TrZ74+sQwkYU9VzexnchM481Y9w==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extensions": "3.27.4"
}
},
"node_modules/@tiptap/extension-floating-menu": {
"version": "3.27.4",
"resolved": "https://registry.npmjs.org/@tiptap/extension-floating-menu/-/extension-floating-menu-3.27.4.tgz",
"integrity": "sha512-tnZywwoNDuEcUZmYYIztXl3PpIKUq+gKeaYPuZhpYEVTThU44tzK3ZuFOmd+qf2aAa1MQwxKWqUuLpNK77bwNw==",
"license": "MIT",
"optional": true,
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@floating-ui/dom": "^1.0.0",
"@tiptap/core": "3.27.4",
"@tiptap/pm": "3.27.4"
}
},
"node_modules/@tiptap/extension-gapcursor": {
"version": "3.27.4",
"resolved": "https://registry.npmjs.org/@tiptap/extension-gapcursor/-/extension-gapcursor-3.27.4.tgz",
"integrity": "sha512-svLwSKcFhzpcJeXvxxKkRFuQpykmXrQefVhEsaXq0L95yJIIAGKMRmQC3mxKdzL2j0P9cY7V41bNVSyOAyvclw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extensions": "3.27.4"
}
},
"node_modules/@tiptap/extension-hard-break": {
"version": "3.27.4",
"resolved": "https://registry.npmjs.org/@tiptap/extension-hard-break/-/extension-hard-break-3.27.4.tgz",
"integrity": "sha512-W+Z9pmDgqjbdu3NeZOQrzA15iM4w60Yd8l2CYzxcdApPVIfYzb2S3a7+u1RqW9wnTYb6xyZjASmFNfxXS4P4cg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.27.4"
}
},
"node_modules/@tiptap/extension-heading": {
"version": "3.27.4",
"resolved": "https://registry.npmjs.org/@tiptap/extension-heading/-/extension-heading-3.27.4.tgz",
"integrity": "sha512-RgvpxzuYk6QEK+az+eiXpWvGlUso42zNcGnjyUrvskoZjS47MbhSg8ylRYQSRtXE0ETlXhAx4J7iGlGr72kyIw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.27.4"
}
},
"node_modules/@tiptap/extension-horizontal-rule": {
"version": "3.27.4",
"resolved": "https://registry.npmjs.org/@tiptap/extension-horizontal-rule/-/extension-horizontal-rule-3.27.4.tgz",
"integrity": "sha512-2eQU/55nE5mhMJHALtLMuBL3dcVJUDVVT7n+uZYMaYE63BtCvC4VS08YLFSR7JZSVJIlgVAmdt5nAw0B+rEPNA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.27.4",
"@tiptap/pm": "3.27.4"
}
},
"node_modules/@tiptap/extension-italic": {
"version": "3.27.4",
"resolved": "https://registry.npmjs.org/@tiptap/extension-italic/-/extension-italic-3.27.4.tgz",
"integrity": "sha512-PeZT4XbyxAp7Lqo/hfA1k5LI27g1RlgS+YgXp2CeHXIrUfSpO5HlZXh02Bvb0pOdl3RFw2tEKtlHzjt8Y1+Nwg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.27.4"
}
},
"node_modules/@tiptap/extension-link": {
"version": "3.27.4",
"resolved": "https://registry.npmjs.org/@tiptap/extension-link/-/extension-link-3.27.4.tgz",
"integrity": "sha512-6K/FkNwMLWWQbNWKlycrUPTN7YcyVFdFwZncoBXe5WyarRjLTGw7ywafnCI9PDIWSq7ttzVL4NgjN2IN8kBXww==",
"license": "MIT",
"dependencies": {
"linkifyjs": "^4.3.3"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.27.4",
"@tiptap/pm": "3.27.4"
}
},
"node_modules/@tiptap/extension-list": {
"version": "3.27.4",
"resolved": "https://registry.npmjs.org/@tiptap/extension-list/-/extension-list-3.27.4.tgz",
"integrity": "sha512-A0BgmRO1RE0yLCx9w7GQITtKfS9wLE5cdngSYDiSpwulcXJhJjKm5mZ4OUZmks2VN4HO5jMl2BWCGt2NSDhA+w==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.27.4",
"@tiptap/pm": "3.27.4"
}
},
"node_modules/@tiptap/extension-list-item": {
"version": "3.27.4",
"resolved": "https://registry.npmjs.org/@tiptap/extension-list-item/-/extension-list-item-3.27.4.tgz",
"integrity": "sha512-z5TVuPw2mkK0B/x+gFg3uUV7tBdaElDFg0zVgnXZCqlSVTLfIyInOOnG5LTWoAd9BdzBjGrzE3PohDcLVDDGBQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extension-list": "3.27.4"
}
},
"node_modules/@tiptap/extension-list-keymap": {
"version": "3.27.4",
"resolved": "https://registry.npmjs.org/@tiptap/extension-list-keymap/-/extension-list-keymap-3.27.4.tgz",
"integrity": "sha512-on7JNDi7Eqz7UdZeZdiO83bQHo0flVDHzjmtR+v/nrCGW9H15D3CHs5+4ozLDiCvTK8tbkBuut/l9AWNxcCE/Q==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extension-list": "3.27.4"
}
},
"node_modules/@tiptap/extension-ordered-list": {
"version": "3.27.4",
"resolved": "https://registry.npmjs.org/@tiptap/extension-ordered-list/-/extension-ordered-list-3.27.4.tgz",
"integrity": "sha512-bHwLiof0FqJfWzB0act7oEKMTZatEKQ4IYCvmyF5EktjMs4kxEatkPp4Yx/1LSYSjLy1MMT7oLELyaz2FFYyXA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extension-list": "3.27.4"
}
},
"node_modules/@tiptap/extension-paragraph": {
"version": "3.27.4",
"resolved": "https://registry.npmjs.org/@tiptap/extension-paragraph/-/extension-paragraph-3.27.4.tgz",
"integrity": "sha512-8Dnr1J5s/s4XYYuEF3b784NnCxLjXOlQpmGyXRxTAzW7JaOP08tIUJWVNvSMekfXc2vXa33HUbqjxyyWZEQ6LQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.27.4"
}
},
"node_modules/@tiptap/extension-placeholder": {
"version": "3.27.4",
"resolved": "https://registry.npmjs.org/@tiptap/extension-placeholder/-/extension-placeholder-3.27.4.tgz",
"integrity": "sha512-7hBoFLeddCv1WzkqB0x3coZ1Hp9WZ9wLoRXIUtUhRKMpzFq2IlTtW1iw88g9pTJnL98bCCElN4DZ4mYtaQvmgA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/extensions": "3.27.4"
}
},
"node_modules/@tiptap/extension-strike": {
"version": "3.27.4",
"resolved": "https://registry.npmjs.org/@tiptap/extension-strike/-/extension-strike-3.27.4.tgz",
"integrity": "sha512-8OXwcPKuV3ToBBgyvDxH1jQdObK5FIKCGiyIim6qNWiOpi9BhM3XYD+aO1khjv8qIjtoI/DYbizF4ewj09fX2g==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.27.4"
}
},
"node_modules/@tiptap/extension-text": {
"version": "3.27.4",
"resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-3.27.4.tgz",
"integrity": "sha512-lKQH/hP4FBXsziHypd6Ywj8JFvMLM5GVkK1xsH6yApNuXbHq95rd42ZOYWpYILIBib7tlaz93z61d74UrJiuiw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.27.4"
}
},
"node_modules/@tiptap/extension-underline": {
"version": "3.27.4",
"resolved": "https://registry.npmjs.org/@tiptap/extension-underline/-/extension-underline-3.27.4.tgz",
"integrity": "sha512-nRJGvRyEXDtINlHTW+C2oWcL3vmX1URVxAPpkD3Zwn5Rb/vEeOU/pk/w97I0iid816MR4iVbvl1XhbUVegK9gQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.27.4"
}
},
"node_modules/@tiptap/extensions": {
"version": "3.27.4",
"resolved": "https://registry.npmjs.org/@tiptap/extensions/-/extensions-3.27.4.tgz",
"integrity": "sha512-d8opkg2iGtVwJmNGIqv0blfRxnvWOJp1brz+Z8CsP4ojSS2ZtaE46d6JSQ5OeJ7nMpjhT+9wh4UQcA7OSEO59w==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "3.27.4",
"@tiptap/pm": "3.27.4"
}
},
"node_modules/@tiptap/pm": {
"version": "3.27.4",
"resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-3.27.4.tgz",
"integrity": "sha512-UB8lcyomfWk7YGI2PZKNqcYXfyRA+PFj+QntlsUXyrsiA5JJIaE8SHKYjxKlGG/xtW3EtPm1b0p38T9Mk4xiFw==",
"license": "MIT",
"dependencies": {
"prosemirror-changeset": "^2.4.1",
"prosemirror-commands": "^1.7.1",
"prosemirror-dropcursor": "^1.8.2",
"prosemirror-gapcursor": "^1.4.1",
"prosemirror-history": "^1.5.0",
"prosemirror-inputrules": "^1.5.1",
"prosemirror-keymap": "^1.2.3",
"prosemirror-model": "^1.25.9",
"prosemirror-schema-list": "^1.5.1",
"prosemirror-state": "^1.4.4",
"prosemirror-tables": "^1.8.5",
"prosemirror-transform": "^1.12.0",
"prosemirror-view": "^1.41.9"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
}
},
"node_modules/@tiptap/react": {
"version": "3.27.4",
"resolved": "https://registry.npmjs.org/@tiptap/react/-/react-3.27.4.tgz",
"integrity": "sha512-rTY1V9Y1jzwmo5ItRi3v2Og/mbcYsr9AjUvGoqpXzR9Z31WhXYphw0y05aYzryh0MHXYzkiE+gbGvrbg+cjwEg==",
"license": "MIT",
"dependencies": {
"@types/use-sync-external-store": "^0.0.6",
"fast-equals": "^5.3.3",
"use-sync-external-store": "^1.4.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"optionalDependencies": {
"@tiptap/extension-bubble-menu": "^3.27.4",
"@tiptap/extension-floating-menu": "^3.27.4"
},
"peerDependencies": {
"@tiptap/core": "3.27.4",
"@tiptap/pm": "3.27.4",
"@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0",
"@types/react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0",
"react": "^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/@tiptap/starter-kit": {
"version": "3.27.4",
"resolved": "https://registry.npmjs.org/@tiptap/starter-kit/-/starter-kit-3.27.4.tgz",
"integrity": "sha512-/sb6rFxNt5BO4hWpUwvHh+Yh1kNyCQuuz3oDpGef5HUUjSdu9p9rfNiHWIUKBadK8VXuw5es7N+UlZ4hma+gvA==",
"license": "MIT",
"dependencies": {
"@tiptap/core": "^3.27.4",
"@tiptap/extension-blockquote": "^3.27.4",
"@tiptap/extension-bold": "^3.27.4",
"@tiptap/extension-bullet-list": "^3.27.4",
"@tiptap/extension-code": "^3.27.4",
"@tiptap/extension-code-block": "^3.27.4",
"@tiptap/extension-document": "^3.27.4",
"@tiptap/extension-dropcursor": "^3.27.4",
"@tiptap/extension-gapcursor": "^3.27.4",
"@tiptap/extension-hard-break": "^3.27.4",
"@tiptap/extension-heading": "^3.27.4",
"@tiptap/extension-horizontal-rule": "^3.27.4",
"@tiptap/extension-italic": "^3.27.4",
"@tiptap/extension-link": "^3.27.4",
"@tiptap/extension-list": "^3.27.4",
"@tiptap/extension-list-item": "^3.27.4",
"@tiptap/extension-list-keymap": "^3.27.4",
"@tiptap/extension-ordered-list": "^3.27.4",
"@tiptap/extension-paragraph": "^3.27.4",
"@tiptap/extension-strike": "^3.27.4",
"@tiptap/extension-text": "^3.27.4",
"@tiptap/extension-underline": "^3.27.4",
"@tiptap/extensions": "^3.27.4",
"@tiptap/pm": "^3.27.4"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
}
},
"node_modules/@tybys/wasm-util": {
"version": "0.10.1",
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
@@ -3523,7 +3997,6 @@
"version": "19.2.14",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"csstype": "^3.2.2"
@@ -3533,7 +4006,6 @@
"version": "19.2.3",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
"dev": true,
"license": "MIT",
"peerDependencies": {
"@types/react": "^19.2.0"
@@ -3549,6 +4021,12 @@
"@types/node": "*"
}
},
"node_modules/@types/use-sync-external-store": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
"license": "MIT"
},
"node_modules/@types/validator": {
"version": "13.15.10",
"resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.15.10.tgz",
@@ -5849,7 +6327,6 @@
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"devOptional": true,
"license": "MIT"
},
"node_modules/d3-color": {
@@ -7748,6 +8225,15 @@
"dev": true,
"license": "Apache-2.0"
},
"node_modules/fast-equals": {
"version": "5.4.1",
"resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.1.tgz",
"integrity": "sha512-DjlFSM5Pk9cGcL0q5QXl66eGzx0N6szNgaswwc5ZphlBohjTVJSnGgI+rJVOgOi65qUoQnDZN4nDqi33udtydQ==",
"license": "MIT",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/fast-json-stable-stringify": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
@@ -10263,6 +10749,12 @@
"url": "https://opencollective.com/parcel"
}
},
"node_modules/linkifyjs": {
"version": "4.3.3",
"resolved": "https://registry.npmjs.org/linkifyjs/-/linkifyjs-4.3.3.tgz",
"integrity": "sha512-P8aEP5U/D1/IlTY2OeYsErdwh9bGuLE30NcXtKEjgdHcahveQoQwM2yZNsioQHsWFz0P7KKudisbrzCgR0sDHg==",
"license": "MIT"
},
"node_modules/load-bmfont": {
"version": "1.4.2",
"resolved": "https://registry.npmjs.org/load-bmfont/-/load-bmfont-1.4.2.tgz",
@@ -11248,6 +11740,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/orderedmap": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/orderedmap/-/orderedmap-2.1.1.tgz",
"integrity": "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==",
"license": "MIT"
},
"node_modules/own-keys": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz",
@@ -11890,6 +12388,145 @@
"signal-exit": "^3.0.2"
}
},
"node_modules/prosemirror-changeset": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/prosemirror-changeset/-/prosemirror-changeset-2.4.1.tgz",
"integrity": "sha512-96WBLhOaYhJ+kPhLg3uW359Tz6I/MfcrQfL4EGv4SrcqKEMC1gmoGrXHecPE8eOwTVCJ4IwgfzM8fFad25wNfw==",
"license": "MIT",
"dependencies": {
"prosemirror-transform": "^1.0.0"
}
},
"node_modules/prosemirror-commands": {
"version": "1.7.1",
"resolved": "https://registry.npmjs.org/prosemirror-commands/-/prosemirror-commands-1.7.1.tgz",
"integrity": "sha512-rT7qZnQtx5c0/y/KlYaGvtG411S97UaL6gdp6RIZ23DLHanMYLyfGBV5DtSnZdthQql7W+lEVbpSfwtO8T+L2w==",
"license": "MIT",
"dependencies": {
"prosemirror-model": "^1.0.0",
"prosemirror-state": "^1.0.0",
"prosemirror-transform": "^1.10.2"
}
},
"node_modules/prosemirror-dropcursor": {
"version": "1.8.3",
"resolved": "https://registry.npmjs.org/prosemirror-dropcursor/-/prosemirror-dropcursor-1.8.3.tgz",
"integrity": "sha512-FoYbsJR8gK+DGlqhNoE29Loa38eIZPzQRIb1VMaDNBoo4OLP6vVof/jR8qFY/6XvUd6Dhug8MDCHl2a/h8RTfQ==",
"license": "MIT",
"dependencies": {
"prosemirror-state": "^1.0.0",
"prosemirror-transform": "^1.1.0",
"prosemirror-view": "^1.1.0"
}
},
"node_modules/prosemirror-gapcursor": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/prosemirror-gapcursor/-/prosemirror-gapcursor-1.4.1.tgz",
"integrity": "sha512-pMdYaEnjNMSwl11yjEGtgTmLkR08m/Vl+Jj443167p9eB3HVQKhYCc4gmHVDsLPODfZfjr/MmirsdyZziXbQKw==",
"license": "MIT",
"dependencies": {
"prosemirror-keymap": "^1.0.0",
"prosemirror-model": "^1.0.0",
"prosemirror-state": "^1.0.0",
"prosemirror-view": "^1.0.0"
}
},
"node_modules/prosemirror-history": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/prosemirror-history/-/prosemirror-history-1.5.0.tgz",
"integrity": "sha512-zlzTiH01eKA55UAf1MEjtssJeHnGxO0j4K4Dpx+gnmX9n+SHNlDqI2oO1Kv1iPN5B1dm5fsljCfqKF9nFL6HRg==",
"license": "MIT",
"dependencies": {
"prosemirror-state": "^1.2.2",
"prosemirror-transform": "^1.0.0",
"prosemirror-view": "^1.31.0",
"rope-sequence": "^1.3.0"
}
},
"node_modules/prosemirror-inputrules": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/prosemirror-inputrules/-/prosemirror-inputrules-1.5.1.tgz",
"integrity": "sha512-7wj4uMjKaXWAQ1CDgxNzNtR9AlsuwzHfdFH1ygEHA2KHF2DOEaXl1CJfNPAKCg9qNEh4rum975QLaCiQPyY6Fw==",
"license": "MIT",
"dependencies": {
"prosemirror-state": "^1.0.0",
"prosemirror-transform": "^1.0.0"
}
},
"node_modules/prosemirror-keymap": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/prosemirror-keymap/-/prosemirror-keymap-1.2.3.tgz",
"integrity": "sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw==",
"license": "MIT",
"dependencies": {
"prosemirror-state": "^1.0.0",
"w3c-keyname": "^2.2.0"
}
},
"node_modules/prosemirror-model": {
"version": "1.25.11",
"resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.11.tgz",
"integrity": "sha512-QWg9RhnpLlogAmp3p96uEFrE5txQpFynd4vhBAELkwgOCWQs/X0yCzB3/hrHqiPwf91RG5KyWq6553zs9JqIOQ==",
"license": "MIT",
"dependencies": {
"orderedmap": "^2.0.0"
}
},
"node_modules/prosemirror-schema-list": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/prosemirror-schema-list/-/prosemirror-schema-list-1.5.1.tgz",
"integrity": "sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q==",
"license": "MIT",
"dependencies": {
"prosemirror-model": "^1.0.0",
"prosemirror-state": "^1.0.0",
"prosemirror-transform": "^1.7.3"
}
},
"node_modules/prosemirror-state": {
"version": "1.4.4",
"resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz",
"integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==",
"license": "MIT",
"dependencies": {
"prosemirror-model": "^1.0.0",
"prosemirror-transform": "^1.0.0",
"prosemirror-view": "^1.27.0"
}
},
"node_modules/prosemirror-tables": {
"version": "1.8.5",
"resolved": "https://registry.npmjs.org/prosemirror-tables/-/prosemirror-tables-1.8.5.tgz",
"integrity": "sha512-V/0cDCsHKHe/tfWkeCmthNUcEp1IVO3p6vwN8XtwE9PZQLAZJigbw3QoraAdfJPir4NKJtNvOB8oYGKRl+t0Dw==",
"license": "MIT",
"dependencies": {
"prosemirror-keymap": "^1.2.3",
"prosemirror-model": "^1.25.4",
"prosemirror-state": "^1.4.4",
"prosemirror-transform": "^1.10.5",
"prosemirror-view": "^1.41.4"
}
},
"node_modules/prosemirror-transform": {
"version": "1.12.0",
"resolved": "https://registry.npmjs.org/prosemirror-transform/-/prosemirror-transform-1.12.0.tgz",
"integrity": "sha512-GxboyN4AMIsoHNtz5uf2r2Ru551i5hWeCMD6E2Ib4Eogqoub0NflniaBPVQ4MrGE5yZ8JV9tUHg9qcZTTrcN4w==",
"license": "MIT",
"dependencies": {
"prosemirror-model": "^1.21.0"
}
},
"node_modules/prosemirror-view": {
"version": "1.42.1",
"resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.42.1.tgz",
"integrity": "sha512-rRqzZnRgkyh69XoOMrfFJHwauHscLBmHbq772kwbic1ymQAM8gXjzEbJse5j1ep2UO2HRIAQL0bY3kZ/RoqjVw==",
"license": "MIT",
"dependencies": {
"prosemirror-model": "^1.25.8",
"prosemirror-state": "^1.0.0",
"prosemirror-transform": "^1.1.0"
}
},
"node_modules/psl": {
"version": "1.15.0",
"resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz",
@@ -12385,6 +13022,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/rope-sequence": {
"version": "1.3.4",
"resolved": "https://registry.npmjs.org/rope-sequence/-/rope-sequence-1.3.4.tgz",
"integrity": "sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==",
"license": "MIT"
},
"node_modules/safe-array-concat": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.3.tgz",
@@ -14566,6 +15209,12 @@
}
}
},
"node_modules/w3c-keyname": {
"version": "2.2.8",
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
"license": "MIT"
},
"node_modules/wcwidth": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/wcwidth/-/wcwidth-1.0.1.tgz",
+4 -1
View File
@@ -10,7 +10,7 @@
"build:obfuscate": "node scripts/build.mjs --production --obfuscate",
"lint": "eslint . --max-warnings 0",
"typecheck": "tsc -p tsconfig.eslint.json --noEmit",
"test": "tsx --test app/renderer/shared/ui/controls.tooltip.test.ts app/renderer/editor/state/projectState.race.test.ts app/renderer/editor/fileDrop.test.ts app/shared/graph/sceneListOrder.test.ts app/renderer/editor/graph/sceneCardById.test.ts app/renderer/editor/i18n/editorMessages.locale.test.ts app/shared/graph/storylineExportImport.test.ts app/shared/ipc/contracts.mediaRemoval.test.ts app/shared/effectEraserHitTest.test.ts app/shared/fieldEffectEraser.test.ts app/renderer/control/controlApp.effectsPanel.test.ts app/renderer/shared/effects/PxiEffectsOverlay.pointer.test.ts app/main/windows/createWindows.editorClose.test.ts app/main/windows/bootWindow.test.ts app/main/effects/effectsStore.test.ts app/main/effects/sceneDarknessStore.test.ts app/main/project/assetPrune.test.ts app/main/project/optimizeImageImport.test.ts app/main/project/scenePreviewThumbnail.test.ts app/main/project/fsRetry.test.ts app/main/project/zipRead.test.ts app/main/project/replaceFileAtomic.test.ts app/main/project/zipStore.legacyContract.test.ts app/shared/package.build.test.ts app/shared/license/canonicalJson.test.ts app/shared/license/productKey.test.ts app/shared/license/licenseService.networkRegression.test.ts app/shared/video/videoPlaybackPerf.networkRegression.test.ts app/shared/video/videoPlaybackLoop.networkRegression.test.ts app/main/license/verifyLicenseToken.test.ts && node --test scripts/build-env.test.mjs scripts/obfuscate-main.test.mjs scripts/release-mac-prep.test.mjs",
"test": "tsx --test app/renderer/shared/ui/controls.tooltip.test.ts app/renderer/editor/state/projectState.race.test.ts app/renderer/editor/fileDrop.test.ts app/shared/graph/sceneListOrder.test.ts app/renderer/editor/graph/sceneCardById.test.ts app/renderer/editor/i18n/editorMessages.locale.test.ts app/renderer/editor/sceneDescriptionHtml.test.ts app/shared/graph/storylineExportImport.test.ts app/shared/ipc/contracts.mediaRemoval.test.ts app/shared/effectEraserHitTest.test.ts app/shared/fieldEffectEraser.test.ts app/renderer/control/controlApp.effectsPanel.test.ts app/renderer/shared/effects/PxiEffectsOverlay.pointer.test.ts app/main/windows/createWindows.editorClose.test.ts app/main/windows/bootWindow.test.ts app/main/effects/effectsStore.test.ts app/main/effects/sceneDarknessStore.test.ts app/main/project/assetPrune.test.ts app/main/project/optimizeImageImport.test.ts app/main/project/scenePreviewThumbnail.test.ts app/main/project/fsRetry.test.ts app/main/project/zipRead.test.ts app/main/project/replaceFileAtomic.test.ts app/main/project/zipStore.legacyContract.test.ts app/shared/package.build.test.ts app/shared/license/canonicalJson.test.ts app/shared/license/productKey.test.ts app/shared/license/licenseService.networkRegression.test.ts app/shared/video/videoPlaybackPerf.networkRegression.test.ts app/shared/video/videoPlaybackLoop.networkRegression.test.ts app/main/license/verifyLicenseToken.test.ts && node --test scripts/build-env.test.mjs scripts/obfuscate-main.test.mjs scripts/release-mac-prep.test.mjs",
"format": "prettier . --check",
"format:write": "prettier . --write",
"postinstall": "patch-package",
@@ -31,6 +31,9 @@
"type": "module",
"dependencies": {
"@fontsource/inter": "^5.2.8",
"@tiptap/extension-placeholder": "^3.27.4",
"@tiptap/react": "^3.27.4",
"@tiptap/starter-kit": "^3.27.4",
"electron-updater": "^6.6.2",
"ffmpeg-static": "^5.3.0",
"pixi.js": "^8.18.1",
+1
View File
@@ -53,6 +53,7 @@ export default defineConfig(({ mode }) => {
editor: path.resolve(__dirname, 'app/renderer/editor.html'),
presentation: path.resolve(__dirname, 'app/renderer/presentation.html'),
control: path.resolve(__dirname, 'app/renderer/control.html'),
sceneDescription: path.resolve(__dirname, 'app/renderer/sceneDescription.html'),
},
},
},