From 35a6e979eb514dda6f3b5b082b32c0053f51fc52 Mon Sep 17 00:00:00 2001 From: Ivan Fontosh Date: Sun, 12 Jul 2026 10:32:50 +0800 Subject: [PATCH] =?UTF-8?q?feat(editor):=20DnD=20=D1=81=D1=86=D0=B5=D0=BD?= =?UTF-8?q?=20=D0=B2=20=D1=81=D0=BF=D0=B8=D1=81=D0=BE=D0=BA,=20=D0=BF?= =?UTF-8?q?=D0=BE=D1=80=D1=8F=D0=B4=D0=BE=D0=BA=20=D1=81=D0=BF=D0=B8=D1=81?= =?UTF-8?q?=D0=BA=D0=B0=20=D0=B8=20=D0=BF=D1=80=D0=B0=D0=B2=D0=BA=D0=B8=20?= =?UTF-8?q?=D0=B8=D0=BC=D0=BF=D0=BE=D1=80=D1=82=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Можно перетащить изображения/видео в колонку сцен: создаются новые сцены с превью и названием из файла, с полноэкранным прогрессом и отчётом о пропущенных файлах. Добавлен ручной порядок сцен в списке (sceneListOrder) с сохранением в проект; перетаскивание карточек отделено от импорта файлов. Во время сортировки снова работает скролл колесом. Исправлен залипающий disabled у «+ Новая сцена» после создания сцены. Co-authored-by: Cursor --- app/main/index.ts | 5 + app/main/project/zipStore.ts | 35 ++- app/renderer/editor/EditorApp.module.css | 53 ++++ app/renderer/editor/EditorApp.tsx | 256 ++++++++++++++++-- app/renderer/editor/fileDrop.test.ts | 39 +++ app/renderer/editor/fileDrop.ts | 96 ++++++- .../editor/graph/sceneCardById.test.ts | 1 + app/renderer/editor/i18n/editorMessages.ts | 14 + .../editor/state/projectState.race.test.ts | 7 + app/renderer/editor/state/projectState.ts | 191 ++++++++++--- app/renderer/shared/RotatedImage.tsx | 1 + app/shared/graph/sceneListOrder.test.ts | 77 ++++++ app/shared/graph/sceneListOrder.ts | 66 +++++ .../graph/storylineExportImport.test.ts | 1 + app/shared/graph/storylineExportImport.ts | 8 +- app/shared/ipc/contracts.ts | 5 + app/shared/types/domain.ts | 5 + package.json | 2 +- 18 files changed, 789 insertions(+), 73 deletions(-) create mode 100644 app/renderer/editor/fileDrop.test.ts create mode 100644 app/shared/graph/sceneListOrder.test.ts create mode 100644 app/shared/graph/sceneListOrder.ts diff --git a/app/main/index.ts b/app/main/index.ts index 29be3da..8c025ca 100644 --- a/app/main/index.ts +++ b/app/main/index.ts @@ -544,6 +544,11 @@ async function main() { emitSessionState(); return { project }; }); + registerHandler(ipcChannels.project.setSceneListOrder, async ({ sceneListOrder }) => { + const project = await projectStore.setSceneListOrder(sceneListOrder); + emitSessionState(); + return { project }; + }); registerHandler(ipcChannels.project.rename, async ({ name, fileBaseName }) => { const project = await projectStore.renameOpenProject(name, fileBaseName); emitSessionState(); diff --git a/app/main/project/zipStore.ts b/app/main/project/zipStore.ts index a0bf6cf..be01c85 100644 --- a/app/main/project/zipStore.ts +++ b/app/main/project/zipStore.ts @@ -8,6 +8,11 @@ import { ZipFile } from 'yazl'; import { isSceneGraphEdgeRejected } from '../../shared/graph/sceneGraphEdgeRules'; import { canSetSideStoryStart, getSideStoryComponentNodeIds } from '../../shared/graph/sceneGraphLineage'; +import { + prependSceneListOrder, + reconcileSceneListOrder, + removeFromSceneListOrder, +} from '../../shared/graph/sceneListOrder'; import { buildPartialExportProject, computeGraphImportOffsetX, @@ -217,6 +222,7 @@ export class ZipProjectStore { schemaVersion: PROJECT_SCHEMA_VERSION, }, scenes: {}, + sceneListOrder: [], assets: {}, campaignAudios: [], currentSceneId: null, @@ -591,7 +597,14 @@ export class ZipProjectStore { ...(patch.layout ? { layout: { ...base.layout, ...patch.layout } } : null), }; - await this.updateProject((p) => ({ ...p, scenes: { ...p.scenes, [sceneId]: next } })); + await this.updateProject((p) => { + const scenes = { ...p.scenes, [sceneId]: next }; + const sceneListOrder = + existing != null + ? reconcileSceneListOrder(scenes, p.sceneListOrder) + : prependSceneListOrder(reconcileSceneListOrder(p.scenes, p.sceneListOrder), sceneId); + return { ...p, scenes, sceneListOrder }; + }); return next; } @@ -638,6 +651,10 @@ export class ZipProjectStore { return { ...withGraph, scenes: nextScenes, + sceneListOrder: removeFromSceneListOrder( + reconcileSceneListOrder(withGraph.scenes, p.sceneListOrder), + sceneId, + ), currentSceneId, }; }); @@ -646,6 +663,18 @@ export class ZipProjectStore { return latest; } + async setSceneListOrder(sceneListOrder: SceneId[]): Promise { + const open = this.openProject; + if (!open) throw new Error('No open project'); + await this.updateProject((p) => ({ + ...p, + sceneListOrder: reconcileSceneListOrder(p.scenes, sceneListOrder), + })); + const latest = this.getOpenProject(); + if (!latest) throw new Error('No open project'); + return latest; + } + async updateSceneGraphNodePosition(nodeId: GraphNodeId, x: number, y: number): Promise { const open = this.openProject; if (!open) throw new Error('No open project'); @@ -1711,6 +1740,10 @@ function normalizeProject(p: Project): Project { sceneGraphNodes, sceneGraphEdges, currentGraphNodeId, + sceneListOrder: reconcileSceneListOrder( + scenes, + (p as { sceneListOrder?: SceneId[] }).sceneListOrder, + ), }; } diff --git a/app/renderer/editor/EditorApp.module.css b/app/renderer/editor/EditorApp.module.css index 494375d..d6141be 100644 --- a/app/renderer/editor/EditorApp.module.css +++ b/app/renderer/editor/EditorApp.module.css @@ -68,6 +68,17 @@ border-right: 1px solid var(--stroke); background: var(--editor-column-bg); overflow: hidden; + position: relative; + transition: + border-color 0.15s ease, + background 0.15s ease, + box-shadow 0.15s ease; +} + +.editorSidebarDragOver { + border-right-color: var(--accent); + background: var(--accent-fill-soft); + box-shadow: inset 0 0 0 2px var(--accent); } .editorGraphHost { @@ -219,6 +230,11 @@ color: var(--text2); } +.noticeMessage { + white-space: pre-wrap; + line-height: 1.45; +} + .fileMenu { position: fixed; min-width: 220px; @@ -872,6 +888,36 @@ background: var(--scene-list-selected-bg); } +.sceneCardDragging { + opacity: 0.45; +} + +.sceneCardDropBefore, +.sceneCardDropAfter { + position: relative; +} + +.sceneCardDropBefore::before, +.sceneCardDropAfter::after { + content: ''; + position: absolute; + left: 8px; + right: 8px; + height: 3px; + border-radius: 999px; + background: var(--accent); + z-index: 2; + pointer-events: none; +} + +.sceneCardDropBefore::before { + top: 0; +} + +.sceneCardDropAfter::after { + bottom: 0; +} + .sceneThumb { height: 92px; position: relative; @@ -886,6 +932,13 @@ overflow: hidden; } +.sceneThumbInner img, +.sceneThumbInner video, +.sceneThumbVideo { + -webkit-user-drag: none; + user-select: none; +} + .sceneThumbVideo { display: block; width: 100%; diff --git a/app/renderer/editor/EditorApp.tsx b/app/renderer/editor/EditorApp.tsx index 67b484d..bae06d8 100644 --- a/app/renderer/editor/EditorApp.tsx +++ b/app/renderer/editor/EditorApp.tsx @@ -27,10 +27,13 @@ import { useAssetUrl } from '../shared/useAssetImageUrl'; import { filterAudioFilePaths, + partitionSceneMediaDrops, pickFirstPreviewFilePath, + sceneTitleFromMediaPath, useFileDropZone, } from './fileDrop'; +import { moveSceneInListOrder, reconcileSceneListOrder } from '../../shared/graph/sceneListOrder'; import type { SceneImportResolution, StorylineImportMergeReport, StorylineSelection } from '../../shared/graph/storylineExportImport'; import { buildSceneResolutionsForImport, @@ -142,6 +145,57 @@ export function EditorApp() { ); const [state, actions] = useProjectState(licenseActive, { onNotice: onProjectNotice }); const renameFromPickerRef = useRef(false); + /** Синхронно на dragStart, чтобы файловый drop колонки не успел сработать. */ + const draggingListSceneIdRef = useRef(null); + + const showSceneDropSkippedNotice = useCallback( + (rejected: { name: string; reason: 'unsupported' | 'no_path' }[]) => { + if (rejected.length === 0) return; + const reasonText = (reason: 'unsupported' | 'no_path') => + reason === 'no_path' ? t('scenes.dropSkippedNoPath') : t('scenes.dropSkippedUnsupported'); + const lines = rejected.map((r) => `• ${r.name} — ${reasonText(r.reason)}`); + setAppNotice({ + title: t('scenes.dropSkippedTitle'), + message: `${t('scenes.dropSkippedIntro')}\n${lines.join('\n')}`, + }); + }, + [t], + ); + + const onScenesColumnDrop = useCallback( + (entries: { path: string; name: string }[]) => { + if (!state.project || state.creatingScene || state.sceneBatchImport) return; + const { accepted, rejected } = partitionSceneMediaDrops(entries); + if (accepted.length === 0) { + showSceneDropSkippedNotice(rejected); + return; + } + void (async () => { + try { + await actions.createScenesFromMediaPaths( + accepted.map((entry) => ({ + filePath: entry.path, + title: sceneTitleFromMediaPath(entry.path), + })), + ); + showSceneDropSkippedNotice(rejected); + } catch (e) { + setAppNotice({ + title: t('common.error'), + message: e instanceof Error ? e.message : String(e), + }); + } + })(); + }, + [actions, showSceneDropSkippedNotice, state.creatingScene, state.project, state.sceneBatchImport, t], + ); + + const scenesColumnDrop = useFileDropZone({ + disabled: !state.project || state.creatingScene || state.sceneBatchImport !== null, + isBlocked: () => draggingListSceneIdRef.current !== null, + onDropEntries: onScenesColumnDrop, + }); + const sceneCardById = useStableSceneCardById(state.project); const graphUi = useMemo( () => ({ @@ -177,6 +231,25 @@ export function EditorApp() { const [settingsMenuPos, setSettingsMenuPos] = useState<{ left: number; top: number } | null>(null); const [aboutMenuPos, setAboutMenuPos] = useState<{ left: number; top: number } | null>(null); const [selectedGraphNodeId, setSelectedGraphNodeId] = useState(null); + const [sceneListDrop, setSceneListDrop] = useState<{ + targetId: SceneId; + place: 'before' | 'after'; + } | null>(null); + const [draggingListSceneId, setDraggingListSceneId] = useState(null); + const sceneListScrollRef = useRef(null); + + useEffect(() => { + if (!draggingListSceneId) return; + const onWheel = (e: WheelEvent) => { + const el = sceneListScrollRef.current; + if (!el) return; + // Во время HTML5-drag браузер глотает обычный scroll — крутим вручную. + e.preventDefault(); + el.scrollTop += e.deltaY; + }; + window.addEventListener('wheel', onWheel, { passive: false, capture: true }); + return () => window.removeEventListener('wheel', onWheel, { capture: true }); + }, [draggingListSceneId]); useEffect(() => { setSelectedGraphNodeId(null); @@ -192,13 +265,10 @@ export function EditorApp() { const scenes = useMemo(() => { const p = state.project; if (!p) return []; - const createdAtSortKey = (sceneId: string): number => { - // sceneId создаётся как `${prefix}_${rand}_${Date.now().toString(16)}` - const last = sceneId.split('_').at(-1) ?? ''; - const n = Number.parseInt(last, 16); - return Number.isFinite(n) ? n : 0; - }; - return Object.values(p.scenes) + const order = reconcileSceneListOrder(p.scenes, p.sceneListOrder); + return order + .map((id) => p.scenes[id]) + .filter((s): s is NonNullable => Boolean(s)) .map((s) => ({ id: s.id, title: s.title, @@ -208,10 +278,11 @@ export function EditorApp() { previewAssetType: s.previewAssetType, previewVideoAutostart: s.previewVideoAutostart, previewRotationDeg: s.previewRotationDeg, - })) - .sort((a, b) => createdAtSortKey(b.id) - createdAtSortKey(a.id)); + })); }, [state.project, state.selectedSceneId]); + const sceneListReorderEnabled = query.trim().length === 0; + const filtered = useMemo( () => scenes.filter((s) => s.title.toLowerCase().includes(query.trim().toLowerCase())), [query, scenes], @@ -532,6 +603,51 @@ export function EditorApp() { document.body, ) : null} + {state.sceneBatchImport + ? createPortal( +
+
+
{t('scenes.batchTitle')}
+
+
+
+
+
+ {t('scenes.batchProgress') + .replace('{current}', String(state.sceneBatchImport.current)) + .replace('{total}', String(state.sceneBatchImport.total))} + {state.sceneBatchImport.fileName + ? `: ${state.sceneBatchImport.fileName}` + : ''} +
+
+ {Math.round( + (state.sceneBatchImport.current / Math.max(1, state.sceneBatchImport.total)) * 100, + )} + % +
+
+
+
, + document.body, + ) + : null} } left={ -
+
+ {state.project && scenesColumnDrop.dragOver ? ( +
{t('scenes.dropHint')}
+ ) : null} {state.project ? ( <>
-
+
{filtered.map((s) => ( { setSelectedGraphNodeId(null); void actions.selectScene(s.id); }} onDeleteScene={(id) => void actions.deleteScene(id)} + onDragListStart={(id) => { + draggingListSceneIdRef.current = id; + setDraggingListSceneId(id); + }} + onDragListEnd={() => { + draggingListSceneIdRef.current = null; + setDraggingListSceneId(null); + setSceneListDrop(null); + }} + onDragListOver={(targetId, place) => { + if (!sceneListReorderEnabled || !draggingListSceneIdRef.current) return; + if (draggingListSceneIdRef.current === targetId) { + setSceneListDrop(null); + return; + } + setSceneListDrop((cur) => + cur?.targetId === targetId && cur.place === place + ? cur + : { targetId, place }, + ); + }} + onDropListReorder={(draggedId, targetId, place) => { + if (!sceneListReorderEnabled || !state.project) { + draggingListSceneIdRef.current = null; + setSceneListDrop(null); + setDraggingListSceneId(null); + return; + } + const order = reconcileSceneListOrder( + state.project.scenes, + state.project.sceneListOrder, + ); + const next = moveSceneInListOrder(order, draggedId, targetId, place); + draggingListSceneIdRef.current = null; + setSceneListDrop(null); + setDraggingListSceneId(null); + void actions.setSceneListOrder(next); + }} /> ))}
@@ -1466,7 +1638,7 @@ function SimpleMessageModal({ open, title, message, onClose }: SimpleMessageModa
-
{message}
+
{message}
@@ -2275,11 +2447,31 @@ function SceneInspector({ type SceneListCardProps = { scene: SceneCard; + reorderEnabled: boolean; + listDragActive: boolean; + dropPlace: 'before' | 'after' | null; + isDragging: boolean; onSelect: () => void; onDeleteScene: (sceneId: SceneId) => void; + onDragListStart: (sceneId: SceneId) => void; + onDragListEnd: () => void; + onDragListOver: (targetId: SceneId, place: 'before' | 'after') => void; + onDropListReorder: (draggedId: SceneId, targetId: SceneId, place: 'before' | 'after') => void; }; -function SceneListCard({ scene, onSelect, onDeleteScene }: SceneListCardProps) { +function SceneListCard({ + scene, + reorderEnabled, + listDragActive, + dropPlace, + isDragging, + onSelect, + onDeleteScene, + onDragListStart, + onDragListEnd, + onDragListOver, + onDropListReorder, +}: SceneListCardProps) { const { t } = useEditorI18n(); const thumbUrl = useAssetUrl(scene.previewThumbAssetId); const previewUrl = useAssetUrl(scene.previewAssetId); @@ -2305,14 +2497,45 @@ function SceneListCard({ scene, onSelect, onDeleteScene }: SceneListCardProps) { }; }, [menu]); - const cardClass = [styles.sceneCard, scene.active ? styles.sceneCardActive : ''].filter(Boolean).join(' '); + const cardClass = [ + styles.sceneCard, + scene.active ? styles.sceneCardActive : '', + isDragging ? styles.sceneCardDragging : '', + dropPlace === 'before' ? styles.sceneCardDropBefore : '', + dropPlace === 'after' ? styles.sceneCardDropAfter : '', + ] + .filter(Boolean) + .join(' '); + return (
{ + // Сразу блокируем файловый drop колонки (до React re-render). + onDragListStart(scene.id); e.dataTransfer.setData(DND_SCENE_ID_MIME, scene.id); - e.dataTransfer.effectAllowed = 'copy'; + e.dataTransfer.effectAllowed = reorderEnabled ? 'copyMove' : 'copy'; + }} + onDragEnd={() => onDragListEnd()} + onDragOver={(e) => { + if (!reorderEnabled || !listDragActive) return; + e.preventDefault(); + e.stopPropagation(); + e.dataTransfer.dropEffect = 'move'; + const rect = e.currentTarget.getBoundingClientRect(); + const place: 'before' | 'after' = e.clientY < rect.top + rect.height / 2 ? 'before' : 'after'; + onDragListOver(scene.id, place); + }} + onDrop={(e) => { + if (!reorderEnabled || !listDragActive) return; + const draggedId = e.dataTransfer.getData(DND_SCENE_ID_MIME); + if (!draggedId) return; + e.preventDefault(); + e.stopPropagation(); + const rect = e.currentTarget.getBoundingClientRect(); + const place: 'before' | 'after' = e.clientY < rect.top + rect.height / 2 ? 'before' : 'after'; + onDropListReorder(draggedId as SceneId, scene.id, place); }} onClick={onSelect} role="button" @@ -2349,6 +2572,7 @@ function SceneListCard({ scene, onSelect, onDeleteScene }: SceneListCardProps) { muted playsInline preload="metadata" + draggable={false} className={styles.sceneThumbVideo} onLoadedData={(e) => { const v = e.currentTarget; diff --git a/app/renderer/editor/fileDrop.test.ts b/app/renderer/editor/fileDrop.test.ts new file mode 100644 index 0000000..01a7ca7 --- /dev/null +++ b/app/renderer/editor/fileDrop.test.ts @@ -0,0 +1,39 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + isPreviewMediaPath, + partitionSceneMediaDrops, + sceneTitleFromMediaPath, +} from './fileDrop'; + +void test('sceneTitleFromMediaPath strips extension and path', () => { + assert.equal(sceneTitleFromMediaPath('C:\\media\\Forest Gate.png'), 'Forest Gate'); + assert.equal(sceneTitleFromMediaPath('/tmp/battle.mp4'), 'battle'); + assert.equal(sceneTitleFromMediaPath('onlyname'), 'onlyname'); +}); + +void test('isPreviewMediaPath accepts images and videos', () => { + assert.equal(isPreviewMediaPath('a.png'), true); + assert.equal(isPreviewMediaPath('a.JPG'), true); + assert.equal(isPreviewMediaPath('a.webm'), true); + assert.equal(isPreviewMediaPath('a.mp3'), false); + assert.equal(isPreviewMediaPath('a.pdf'), false); +}); + +void test('partitionSceneMediaDrops keeps valid and rejects others', () => { + const { accepted, rejected } = partitionSceneMediaDrops([ + { path: 'D:\\a\\one.jpg', name: 'one.jpg' }, + { path: 'D:\\a\\two.mp3', name: 'two.mp3' }, + { path: '', name: 'ghost.png' }, + { path: 'D:\\a\\three.mov', name: 'three.mov' }, + ]); + assert.deepEqual( + accepted.map((x) => x.name), + ['one.jpg', 'three.mov'], + ); + assert.deepEqual(rejected, [ + { name: 'two.mp3', reason: 'unsupported' }, + { name: 'ghost.png', reason: 'no_path' }, + ]); +}); diff --git a/app/renderer/editor/fileDrop.ts b/app/renderer/editor/fileDrop.ts index 17bad04..bce52ff 100644 --- a/app/renderer/editor/fileDrop.ts +++ b/app/renderer/editor/fileDrop.ts @@ -1,4 +1,4 @@ -import { useCallback, useRef, useState, type DragEvent } from 'react'; +import { useCallback, useEffect, useRef, useState, type DragEvent } from 'react'; import { getDndApi } from '../shared/dndApi'; @@ -20,13 +20,26 @@ function fileExtension(filePath: string): string { return dot >= 0 ? filePath.slice(dot).toLowerCase() : ''; } +export type DroppedFileEntry = { path: string; name: string }; + +export type SceneMediaDropRejectReason = 'unsupported' | 'no_path'; + +export type SceneMediaDropRejected = { name: string; reason: SceneMediaDropRejectReason }; + export function getDroppedFilePaths(e: DragEvent): string[] { + return getDroppedFileEntries(e) + .map((entry) => entry.path) + .filter((path) => path.length > 0); +} + +export function getDroppedFileEntries(e: DragEvent): DroppedFileEntry[] { const files = e.dataTransfer?.files; if (!files?.length) return []; const getPathForFile = getDndApi().getPathForFile; - return Array.from(files) - .map((file) => getPathForFile(file)) - .filter((path) => path.length > 0); + return Array.from(files).map((file) => ({ + path: getPathForFile(file), + name: file.name || 'file', + })); } export function filterAudioFilePaths(paths: string[]): string[] { @@ -40,69 +53,122 @@ export function pickFirstPreviewFilePath(paths: string[]): string | null { return null; } +export function isPreviewMediaPath(filePath: string): boolean { + return PREVIEW_EXTENSIONS.has(fileExtension(filePath)); +} + +export function sceneTitleFromMediaPath(filePath: string): string { + const base = filePath.split(/[/\\]/).pop() ?? filePath; + const dot = base.lastIndexOf('.'); + const title = (dot > 0 ? base.slice(0, dot) : base).trim(); + return title.length > 0 ? title : 'Новая сцена'; +} + +export function partitionSceneMediaDrops(entries: DroppedFileEntry[]): { + accepted: DroppedFileEntry[]; + rejected: SceneMediaDropRejected[]; +} { + const accepted: DroppedFileEntry[] = []; + const rejected: SceneMediaDropRejected[] = []; + for (const entry of entries) { + if (!entry.path) { + rejected.push({ name: entry.name, reason: 'no_path' }); + continue; + } + if (!isPreviewMediaPath(entry.path)) { + rejected.push({ name: entry.name, reason: 'unsupported' }); + continue; + } + accepted.push(entry); + } + return { accepted, rejected }; +} + function dragHasFiles(e: DragEvent): boolean { - return Array.from(e.dataTransfer?.types ?? []).includes('Files'); + const types = Array.from(e.dataTransfer?.types ?? []); + // Внутренний drag карточки сцены (в т.ч. с превью-картинкой) не должен считаться файловым. + if (types.some((t) => t === 'application/x-dnd-scene-id')) return false; + return types.includes('Files'); } type UseFileDropZoneOptions = { disabled?: boolean; - onDropPaths: (paths: string[]) => void; + /** Синхронная блокировка (например, идёт reorder списка сцен). */ + isBlocked?: () => boolean; + onDropPaths?: (paths: string[]) => void; + onDropEntries?: (entries: DroppedFileEntry[]) => void; filterPaths?: (paths: string[]) => string[]; }; export function useFileDropZone({ disabled = false, + isBlocked, onDropPaths, + onDropEntries, filterPaths, }: UseFileDropZoneOptions) { const [dragOver, setDragOver] = useState(false); const depthRef = useRef(0); + const blocked = useCallback(() => disabled || Boolean(isBlocked?.()), [disabled, isBlocked]); + + useEffect(() => { + if (!blocked()) return; + depthRef.current = 0; + setDragOver(false); + }, [blocked]); + const onDragEnter = useCallback( (e: DragEvent) => { - if (disabled || !dragHasFiles(e)) return; + if (blocked() || !dragHasFiles(e)) return; e.preventDefault(); e.stopPropagation(); depthRef.current += 1; setDragOver(true); }, - [disabled], + [blocked], ); const onDragLeave = useCallback( (e: DragEvent) => { - if (disabled) return; + if (blocked()) return; e.preventDefault(); e.stopPropagation(); depthRef.current = Math.max(0, depthRef.current - 1); if (depthRef.current === 0) setDragOver(false); }, - [disabled], + [blocked], ); const onDragOver = useCallback( (e: DragEvent) => { - if (disabled || !dragHasFiles(e)) return; + if (blocked() || !dragHasFiles(e)) return; e.preventDefault(); e.stopPropagation(); e.dataTransfer.dropEffect = 'copy'; }, - [disabled], + [blocked], ); const onDrop = useCallback( (e: DragEvent) => { - if (disabled || !dragHasFiles(e)) return; + if (blocked() || !dragHasFiles(e)) return; e.preventDefault(); e.stopPropagation(); depthRef.current = 0; setDragOver(false); + if (onDropEntries) { + const entries = getDroppedFileEntries(e); + if (entries.length === 0) return; + onDropEntries(entries); + return; + } const raw = getDroppedFilePaths(e); const paths = filterPaths ? filterPaths(raw) : raw; if (paths.length === 0) return; - onDropPaths(paths); + onDropPaths?.(paths); }, - [disabled, filterPaths, onDropPaths], + [blocked, filterPaths, onDropEntries, onDropPaths], ); return { dragOver, onDragEnter, onDragLeave, onDragOver, onDrop }; diff --git a/app/renderer/editor/graph/sceneCardById.test.ts b/app/renderer/editor/graph/sceneCardById.test.ts index 2954c09..74a3ede 100644 --- a/app/renderer/editor/graph/sceneCardById.test.ts +++ b/app/renderer/editor/graph/sceneCardById.test.ts @@ -19,6 +19,7 @@ function minimalProject(overrides: Partial): Project { schemaVersion: 1 as unknown as Project['meta']['schemaVersion'], }, scenes: {}, + sceneListOrder: [], assets: {}, campaignAudios: [], currentSceneId: null, diff --git a/app/renderer/editor/i18n/editorMessages.ts b/app/renderer/editor/i18n/editorMessages.ts index a2de4e3..8455961 100644 --- a/app/renderer/editor/i18n/editorMessages.ts +++ b/app/renderer/editor/i18n/editorMessages.ts @@ -233,6 +233,13 @@ export const EDITOR_MESSAGES: Record> = { 'scenes.search': 'Поиск сцен…', 'scenes.new': '+ Новая сцена', + 'scenes.dropHint': 'Перетащите изображения или видео', + 'scenes.batchTitle': 'Создание сцен', + 'scenes.batchProgress': 'Сцена {current} из {total}', + 'scenes.dropSkippedTitle': 'Часть файлов не добавлена', + 'scenes.dropSkippedIntro': 'Эти файлы пропущены:', + 'scenes.dropSkippedUnsupported': 'неподдерживаемый формат', + 'scenes.dropSkippedNoPath': 'не удалось получить путь к файлу', 'scenes.inspectorGame': 'Свойства игры', 'scenes.inspectorScene': 'Свойства сцены', 'scenes.selectHint': 'Выберите сцену слева, чтобы редактировать её свойства.', @@ -610,6 +617,13 @@ export const EDITOR_MESSAGES: Record> = { 'scenes.search': 'Search scenes…', 'scenes.new': '+ New scene', + 'scenes.dropHint': 'Drop images or videos', + 'scenes.batchTitle': 'Creating scenes', + 'scenes.batchProgress': 'Scene {current} of {total}', + 'scenes.dropSkippedTitle': 'Some files were not added', + 'scenes.dropSkippedIntro': 'These files were skipped:', + 'scenes.dropSkippedUnsupported': 'unsupported format', + 'scenes.dropSkippedNoPath': 'could not resolve file path', 'scenes.inspectorGame': 'Game properties', 'scenes.inspectorScene': 'Scene properties', 'scenes.selectHint': 'Select a scene on the left to edit its properties.', diff --git a/app/renderer/editor/state/projectState.race.test.ts b/app/renderer/editor/state/projectState.race.test.ts index cbe1ffc..2408def 100644 --- a/app/renderer/editor/state/projectState.race.test.ts +++ b/app/renderer/editor/state/projectState.race.test.ts @@ -23,6 +23,13 @@ void test('projectState: list/get after delete invalidates in-flight initial loa assert.match(src, /const refreshProjects = async \(\) => \{[\s\S]+?projectDataEpochRef\.current \+= 1/); }); +void test('projectState: createScene clears creatingScene and in-flight ref', () => { + const src = fs.readFileSync(path.join(here, 'projectState.ts'), 'utf8'); + assert.match(src, /createSceneInFlightRef\.current = job;/); + assert.match(src, /creatingScene: false/); + assert.doesNotMatch(src, /createSceneInFlightRef\.current = job\.finally/); +}); + void test('ipc router: project.list does not require license', () => { const routerSrc = fs.readFileSync(path.join(here, '..', '..', '..', 'main', 'ipc', 'router.ts'), 'utf8'); assert.match(routerSrc, /if \(channel === ipcChannels\.project\.list\) return false/); diff --git a/app/renderer/editor/state/projectState.ts b/app/renderer/editor/state/projectState.ts index 95b73fe..b7e0c4d 100644 --- a/app/renderer/editor/state/projectState.ts +++ b/app/renderer/editor/state/projectState.ts @@ -19,6 +19,7 @@ type State = { selectedSceneId: SceneId | null; openingProjectId: ProjectId | null; creatingScene: boolean; + sceneBatchImport: { current: number; total: number; fileName: string } | null; zipProgress: { kind: 'import' | 'export'; percent: number; stage: string; detail?: string } | null; scenePreviewImports: Record< SceneId, @@ -32,6 +33,9 @@ type Actions = { openProject: (id: ProjectId) => Promise; closeProject: () => Promise; createScene: () => Promise; + createScenesFromMediaPaths: ( + items: { filePath: string; title: string }[], + ) => Promise<{ created: number; lastSceneId: SceneId | null }>; selectScene: (id: SceneId) => Promise; importCampaignAudio: () => Promise; importCampaignAudioFromPaths: (filePaths: string[]) => Promise; @@ -70,6 +74,7 @@ type Actions = { setSceneGraphNodeSideStoryStart: (graphNodeId: GraphNodeId) => Promise; updateSideStoryLineTitle: (graphNodeId: GraphNodeId, title: string) => Promise; deleteScene: (sceneId: SceneId) => Promise; + setSceneListOrder: (sceneListOrder: SceneId[]) => Promise; renameProject: (name: string, fileBaseName: string) => Promise; importProject: () => Promise; peekImportZip: (labels: StorylineLabels, targetHasMainStart: boolean) => Promise< @@ -145,6 +150,7 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts) selectedSceneId: null, openingProjectId: null, creatingScene: false, + sceneBatchImport: null, zipProgress: null, scenePreviewImports: {} as Record< SceneId, @@ -268,10 +274,11 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts) } } })(); - openInFlightRef.current = job.finally(() => { + openInFlightRef.current = job; + void job.finally(() => { if (openInFlightRef.current === job) openInFlightRef.current = null; }); - return openInFlightRef.current; + return job; }; const closeProject = async () => { @@ -297,46 +304,140 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts) if (!p) return; const job = (async () => { setState((s) => ({ ...s, creatingScene: true })); - const sceneId = randomId('scene') as SceneId; - const scene: Scene = { - id: sceneId, - title: `Новая сцена`, - description: '', - previewAssetId: null, - previewThumbAssetId: null, - previewAssetType: null, - previewVideoAutostart: false, - previewRotationDeg: 0, - darkenScene: false, - media: { videos: [], audios: [] }, - settings: { autoplayVideo: false, autoplayAudio: true, loopVideo: true, loopAudio: true }, - connections: [], - layout: { x: 0, y: 0 }, - }; - await api.invoke(ipcChannels.project.updateScene, { - sceneId, - patch: { - title: scene.title, - description: scene.description, - media: scene.media, - settings: scene.settings, - layout: scene.layout, - previewAssetId: scene.previewAssetId, - previewAssetType: scene.previewAssetType, - previewVideoAutostart: scene.previewVideoAutostart, - }, - }); - await api.invoke(ipcChannels.project.setCurrentScene, { sceneId }); - const res = await api.invoke(ipcChannels.project.get, {}); - setState((s) => ({ ...s, project: res.project, selectedSceneId: sceneId })); - })(); - createSceneInFlightRef.current = job.finally(() => { - if (createSceneInFlightRef.current === job) { - createSceneInFlightRef.current = null; + try { + const sceneId = randomId('scene') as SceneId; + const scene: Scene = { + id: sceneId, + title: `Новая сцена`, + description: '', + previewAssetId: null, + previewThumbAssetId: null, + previewAssetType: null, + previewVideoAutostart: false, + previewRotationDeg: 0, + darkenScene: false, + media: { videos: [], audios: [] }, + settings: { autoplayVideo: false, autoplayAudio: true, loopVideo: true, loopAudio: true }, + connections: [], + layout: { x: 0, y: 0 }, + }; + await api.invoke(ipcChannels.project.updateScene, { + sceneId, + patch: { + title: scene.title, + description: scene.description, + media: scene.media, + settings: scene.settings, + layout: scene.layout, + previewAssetId: scene.previewAssetId, + previewAssetType: scene.previewAssetType, + previewVideoAutostart: scene.previewVideoAutostart, + }, + }); + await api.invoke(ipcChannels.project.setCurrentScene, { sceneId }); + const res = await api.invoke(ipcChannels.project.get, {}); + setState((s) => ({ ...s, project: res.project, selectedSceneId: sceneId })); + } finally { setState((s) => ({ ...s, creatingScene: false })); } + })(); + createSceneInFlightRef.current = job; + void job.finally(() => { + if (createSceneInFlightRef.current === job) createSceneInFlightRef.current = null; }); - return createSceneInFlightRef.current; + return job; + }; + + const createScenesFromMediaPaths = async ( + items: { filePath: string; title: string }[], + ): Promise<{ created: number; lastSceneId: SceneId | null }> => { + if (items.length === 0) return { created: 0, lastSceneId: null }; + if (createSceneInFlightRef.current) { + await createSceneInFlightRef.current; + } + const p = projectRef.current; + if (!p) return { created: 0, lastSceneId: null }; + + let created = 0; + let lastSceneId: SceneId | null = null; + const job = (async () => { + setState((s) => ({ + ...s, + creatingScene: true, + sceneBatchImport: { current: 0, total: items.length, fileName: items[0]?.title ?? '' }, + })); + try { + for (let i = 0; i < items.length; i += 1) { + const item = items[i]!; + setState((s) => ({ + ...s, + sceneBatchImport: { + current: i + 1, + total: items.length, + fileName: item.title, + }, + })); + const sceneId = randomId('scene') as SceneId; + await api.invoke(ipcChannels.project.updateScene, { + sceneId, + patch: { + title: item.title, + description: '', + media: { videos: [], audios: [] }, + settings: { + autoplayVideo: false, + autoplayAudio: true, + loopVideo: true, + loopAudio: true, + }, + layout: { x: 0, y: 0 }, + previewAssetId: null, + previewAssetType: null, + previewVideoAutostart: false, + }, + }); + const previewRes = await api.invoke(ipcChannels.project.importScenePreview, { + sceneId, + filePath: item.filePath, + }); + setState((s) => { + const nextImports = { ...s.scenePreviewImports }; + if (previewRes.assetId !== null && previewRes.background) { + nextImports[sceneId] = { assetId: previewRes.assetId, phase: 'queued' }; + } + return { + ...s, + project: previewRes.project, + scenePreviewImports: nextImports, + }; + }); + created += 1; + lastSceneId = sceneId; + } + if (lastSceneId) { + await api.invoke(ipcChannels.project.setCurrentScene, { sceneId: lastSceneId }); + const res = await api.invoke(ipcChannels.project.get, {}); + setState((s) => ({ + ...s, + project: res.project, + selectedSceneId: lastSceneId, + })); + } + await refreshProjects(); + } finally { + setState((s) => ({ + ...s, + creatingScene: false, + sceneBatchImport: null, + })); + } + })(); + createSceneInFlightRef.current = job; + void job.finally(() => { + if (createSceneInFlightRef.current === job) createSceneInFlightRef.current = null; + }); + await job; + return { created, lastSceneId }; }; const selectScene = async (id: SceneId) => { @@ -546,6 +647,16 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts) await refreshProjects(); }; + const setSceneListOrder = async (sceneListOrder: SceneId[]) => { + setState((s) => { + const p = s.project; + if (!p) return s; + return { ...s, project: { ...p, sceneListOrder } }; + }); + const res = await api.invoke(ipcChannels.project.setSceneListOrder, { sceneListOrder }); + setState((s) => ({ ...s, project: res.project })); + }; + const renameProject = async (name: string, fileBaseName: string) => { const res = await api.invoke(ipcChannels.project.rename, { name, fileBaseName }); setState((s) => ({ ...s, project: res.project })); @@ -686,6 +797,7 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts) openProject, closeProject, createScene, + createScenesFromMediaPaths, selectScene, importCampaignAudio, importCampaignAudioFromPaths, @@ -706,6 +818,7 @@ export function useProjectState(licenseActive: boolean, opts?: ProjectStateOpts) setSceneGraphNodeSideStoryStart, updateSideStoryLineTitle, deleteScene, + setSceneListOrder, renameProject, importProject, importProjectFromPath, diff --git a/app/renderer/shared/RotatedImage.tsx b/app/renderer/shared/RotatedImage.tsx index b1ed5ea..368e8df 100644 --- a/app/renderer/shared/RotatedImage.tsx +++ b/app/renderer/shared/RotatedImage.tsx @@ -101,6 +101,7 @@ export function RotatedImage({ src={url} loading={loading} decoding={decoding} + draggable={false} className={styles.img} onLoad={(e) => { const el = e.currentTarget; diff --git a/app/shared/graph/sceneListOrder.test.ts b/app/shared/graph/sceneListOrder.test.ts new file mode 100644 index 0000000..d2d4531 --- /dev/null +++ b/app/shared/graph/sceneListOrder.test.ts @@ -0,0 +1,77 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import type { Scene, SceneId } from '../types'; + +import { + defaultSceneListOrder, + moveSceneInListOrder, + prependSceneListOrder, + reconcileSceneListOrder, + removeFromSceneListOrder, +} from './sceneListOrder'; + +function scene(id: string): Scene { + return { + id: id as SceneId, + title: id, + description: '', + previewAssetId: null, + previewThumbAssetId: null, + previewAssetType: null, + previewVideoAutostart: false, + previewRotationDeg: 0, + darkenScene: false, + media: { videos: [], audios: [] }, + settings: { autoplayVideo: false, autoplayAudio: true, loopVideo: true, loopAudio: true }, + connections: [], + layout: { x: 0, y: 0 }, + }; +} + +void test('defaultSceneListOrder sorts by id timestamp newest first', () => { + const scenes = { + scene_a_100: scene('scene_a_100'), + scene_b_200: scene('scene_b_200'), + scene_c_150: scene('scene_c_150'), + } as Record; + assert.deepEqual(defaultSceneListOrder(scenes), ['scene_b_200', 'scene_c_150', 'scene_a_100']); +}); + +void test('reconcileSceneListOrder drops missing and appends new', () => { + const scenes = { + a: scene('a'), + b: scene('b'), + c: scene('c'), + } as Record; + assert.deepEqual(reconcileSceneListOrder(scenes, ['b', 'gone', 'a'] as SceneId[]), ['b', 'a', 'c']); +}); + +void test('moveSceneInListOrder before/after', () => { + const order = ['a', 'b', 'c', 'd'] as SceneId[]; + assert.deepEqual(moveSceneInListOrder(order, 'd' as SceneId, 'b' as SceneId, 'before'), [ + 'a', + 'd', + 'b', + 'c', + ]); + assert.deepEqual(moveSceneInListOrder(order, 'a' as SceneId, 'c' as SceneId, 'after'), [ + 'b', + 'c', + 'a', + 'd', + ]); + assert.deepEqual(moveSceneInListOrder(order, 'b' as SceneId, 'b' as SceneId, 'before'), [ + 'a', + 'b', + 'c', + 'd', + ]); +}); + +void test('prepend and remove helpers', () => { + const order = ['a', 'b'] as SceneId[]; + assert.deepEqual(prependSceneListOrder(order, 'c' as SceneId), ['c', 'a', 'b']); + assert.deepEqual(prependSceneListOrder(order, 'a' as SceneId), ['a', 'b']); + assert.deepEqual(removeFromSceneListOrder(order, 'a' as SceneId), ['b']); +}); diff --git a/app/shared/graph/sceneListOrder.ts b/app/shared/graph/sceneListOrder.ts new file mode 100644 index 0000000..f2418da --- /dev/null +++ b/app/shared/graph/sceneListOrder.ts @@ -0,0 +1,66 @@ +import type { Scene, SceneId } from '../types'; + +function sceneCreatedAtSortKey(sceneId: string): number { + const last = sceneId.split('_').at(-1) ?? ''; + const n = Number.parseInt(last, 16); + return Number.isFinite(n) ? n : 0; +} + +/** Порядок по умолчанию: новее выше (как раньше по timestamp в id). */ +export function defaultSceneListOrder(scenes: Record): SceneId[] { + return (Object.keys(scenes) as SceneId[]).sort( + (a, b) => sceneCreatedAtSortKey(b) - sceneCreatedAtSortKey(a), + ); +} + +/** + * Нормализует порядок: убирает отсутствующие id, добавляет новые в конец + * (новые без записи в order — по умолчанию в «хвост» относительно сохранённого порядка; + * при создании сцены мы сами prepend'им). + */ +export function reconcileSceneListOrder( + scenes: Record, + order: readonly SceneId[] | null | undefined, +): SceneId[] { + const ids = new Set(Object.keys(scenes) as SceneId[]); + if (ids.size === 0) return []; + if (!order || order.length === 0) return defaultSceneListOrder(scenes); + + const seen = new Set(); + const next: SceneId[] = []; + for (const id of order) { + if (!ids.has(id) || seen.has(id)) continue; + next.push(id); + seen.add(id); + } + for (const id of defaultSceneListOrder(scenes)) { + if (seen.has(id)) continue; + next.push(id); + } + return next; +} + +export function prependSceneListOrder(order: readonly SceneId[], sceneId: SceneId): SceneId[] { + return [sceneId, ...order.filter((id) => id !== sceneId)]; +} + +export function removeFromSceneListOrder(order: readonly SceneId[], sceneId: SceneId): SceneId[] { + return order.filter((id) => id !== sceneId); +} + +export function moveSceneInListOrder( + order: readonly SceneId[], + draggedId: SceneId, + targetId: SceneId, + place: 'before' | 'after', +): SceneId[] { + if (draggedId === targetId) return [...order]; + if (!order.includes(draggedId) || !order.includes(targetId)) return [...order]; + const without = order.filter((id) => id !== draggedId); + const targetIndex = without.indexOf(targetId); + if (targetIndex < 0) return [...order]; + const insertAt = place === 'before' ? targetIndex : targetIndex + 1; + const next = [...without]; + next.splice(insertAt, 0, draggedId); + return next; +} diff --git a/app/shared/graph/storylineExportImport.test.ts b/app/shared/graph/storylineExportImport.test.ts index 4de1c50..29b3550 100644 --- a/app/shared/graph/storylineExportImport.test.ts +++ b/app/shared/graph/storylineExportImport.test.ts @@ -64,6 +64,7 @@ function minimalProject(overrides: Partial = {}): Project { schemaVersion: 5, }, scenes: {}, + sceneListOrder: [], assets: {}, campaignAudios: [], currentSceneId: null, diff --git a/app/shared/graph/storylineExportImport.ts b/app/shared/graph/storylineExportImport.ts index a4110d0..be4d022 100644 --- a/app/shared/graph/storylineExportImport.ts +++ b/app/shared/graph/storylineExportImport.ts @@ -3,6 +3,7 @@ import { getSideStoryComponentNodeIds, listSideStoryStarts, } from './sceneGraphLineage'; +import { reconcileSceneListOrder } from './sceneListOrder'; import type { ExportedStorylineRef, GraphNodeId, @@ -248,6 +249,7 @@ export function buildPartialExportProject( updatedAt: new Date().toISOString(), }, scenes, + sceneListOrder: reconcileSceneListOrder(scenes, source.sceneListOrder), sceneGraphNodes, sceneGraphEdges, currentSceneId: null, @@ -491,7 +493,11 @@ export function mergeStorylinesIntoProject( }; const outgoing = recomputeOutgoing(merged.sceneGraphNodes, merged.sceneGraphEdges); - merged = { ...merged, scenes: applyConnectionSets(merged.scenes, outgoing) }; + merged = { + ...merged, + scenes: applyConnectionSets(merged.scenes, outgoing), + sceneListOrder: reconcileSceneListOrder(scenes, target.sceneListOrder), + }; return { project: merged, diff --git a/app/shared/ipc/contracts.ts b/app/shared/ipc/contracts.ts index 692a950..f949cc6 100644 --- a/app/shared/ipc/contracts.ts +++ b/app/shared/ipc/contracts.ts @@ -58,6 +58,7 @@ export const ipcChannels = { setSceneGraphNodeSideStoryStart: 'project.setSceneGraphNodeSideStoryStart', updateSideStoryLineTitle: 'project.updateSideStoryLineTitle', deleteScene: 'project.deleteScene', + setSceneListOrder: 'project.setSceneListOrder', rename: 'project.rename', importZip: 'project.importZip', exportZip: 'project.exportZip', @@ -278,6 +279,10 @@ export type IpcInvokeMap = { req: { sceneId: SceneId }; res: { project: Project }; }; + [ipcChannels.project.setSceneListOrder]: { + req: { sceneListOrder: SceneId[] }; + res: { project: Project }; + }; [ipcChannels.project.rename]: { req: { name: string; fileBaseName: string }; res: { project: Project }; diff --git a/app/shared/types/domain.ts b/app/shared/types/domain.ts index ba2be7c..a5e454b 100644 --- a/app/shared/types/domain.ts +++ b/app/shared/types/domain.ts @@ -127,6 +127,11 @@ export type Project = { id: ProjectId; meta: ProjectMeta; scenes: Record; + /** + * Порядок сцен в левом списке редактора (сверху вниз). + * Если отсутствует или устарел — нормализуется при загрузке. + */ + sceneListOrder: SceneId[]; assets: Record; /** Аудио кампании: играет в пульте на протяжении всей презентации, если в сцене нет своей музыки. */ campaignAudios: SceneAudioRef[]; diff --git a/package.json b/package.json index 4a38624..70bf669 100644 --- a/package.json +++ b/package.json @@ -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/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/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",