Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 04c75cd725 | |||
| e687303c57 |
+25
-8
@@ -63,6 +63,7 @@ import {
|
|||||||
sendToAppWindows,
|
sendToAppWindows,
|
||||||
togglePresentationFullscreen,
|
togglePresentationFullscreen,
|
||||||
waitForEditorWindowReady,
|
waitForEditorWindowReady,
|
||||||
|
warmNpcsEditorWindow,
|
||||||
} from './windows/createWindows';
|
} from './windows/createWindows';
|
||||||
|
|
||||||
function emitZipProgress(evt: {
|
function emitZipProgress(evt: {
|
||||||
@@ -95,6 +96,16 @@ function emitMaterialUpsertProgress(evt: {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function emitNpcUpsertProgress(evt: {
|
||||||
|
percent: number;
|
||||||
|
stage: string;
|
||||||
|
detail?: string;
|
||||||
|
}): void {
|
||||||
|
for (const win of BrowserWindow.getAllWindows()) {
|
||||||
|
win.webContents.send(ipcChannels.project.npcUpsertProgress, evt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Отключение GPU ломает скорость вторичных окон (презентация/пульт — WebGL). По умолчанию не трогаем.
|
* Отключение GPU ломает скорость вторичных окон (презентация/пульт — WebGL). По умолчанию не трогаем.
|
||||||
* При чёрном экране в упакованной сборке: `DND_DISABLE_GPU=1`.
|
* При чёрном экране в упакованной сборке: `DND_DISABLE_GPU=1`.
|
||||||
@@ -508,6 +519,7 @@ async function main() {
|
|||||||
registerHandler(ipcChannels.project.create, async ({ name }) => {
|
registerHandler(ipcChannels.project.create, async ({ name }) => {
|
||||||
const project = await projectStore.createProject(name);
|
const project = await projectStore.createProject(name);
|
||||||
emitSessionState();
|
emitSessionState();
|
||||||
|
warmNpcsEditorWindow();
|
||||||
return { project };
|
return { project };
|
||||||
});
|
});
|
||||||
registerHandler(ipcChannels.project.open, async ({ projectId }) => {
|
registerHandler(ipcChannels.project.open, async ({ projectId }) => {
|
||||||
@@ -517,9 +529,11 @@ async function main() {
|
|||||||
emitSceneViewState();
|
emitSceneViewState();
|
||||||
emitSceneTokensSessionState();
|
emitSceneTokensSessionState();
|
||||||
emitSessionState();
|
emitSessionState();
|
||||||
|
warmNpcsEditorWindow();
|
||||||
return { project };
|
return { project };
|
||||||
});
|
});
|
||||||
registerHandler(ipcChannels.project.close, async () => {
|
registerHandler(ipcChannels.project.close, async () => {
|
||||||
|
closeNpcsEditorWindow();
|
||||||
await projectStore.closeOpenProject();
|
await projectStore.closeOpenProject();
|
||||||
effectsStore.clear();
|
effectsStore.clear();
|
||||||
materialsOverlayStore.clear();
|
materialsOverlayStore.clear();
|
||||||
@@ -760,14 +774,17 @@ async function main() {
|
|||||||
}
|
}
|
||||||
filePath = filePaths[0];
|
filePath = filePaths[0];
|
||||||
}
|
}
|
||||||
const project = await projectStore.upsertNpc({
|
const project = await projectStore.upsertNpc(
|
||||||
...(npcId ? { npcId } : {}),
|
{
|
||||||
name,
|
...(npcId ? { npcId } : {}),
|
||||||
...(typeof description === 'string' ? { description } : {}),
|
name,
|
||||||
...(filePath ? { filePath } : {}),
|
...(typeof description === 'string' ? { description } : {}),
|
||||||
...(groupId !== undefined ? { groupId } : {}),
|
...(filePath ? { filePath } : {}),
|
||||||
...(binding !== undefined ? { binding } : {}),
|
...(groupId !== undefined ? { groupId } : {}),
|
||||||
});
|
...(binding !== undefined ? { binding } : {}),
|
||||||
|
},
|
||||||
|
(p) => emitNpcUpsertProgress(p),
|
||||||
|
);
|
||||||
syncNpcsOverlayWithProject(project);
|
syncNpcsOverlayWithProject(project);
|
||||||
emitNpcsOverlayState();
|
emitNpcsOverlayState();
|
||||||
emitSessionState();
|
emitSessionState();
|
||||||
|
|||||||
@@ -1302,14 +1302,20 @@ export class ZipProjectStore {
|
|||||||
* Создаёт или обновляет НПС.
|
* Создаёт или обновляет НПС.
|
||||||
* При создании `filePath` (аватар) обязателен; при обновлении можно сменить только имя/описание/аватар.
|
* При создании `filePath` (аватар) обязателен; при обновлении можно сменить только имя/описание/аватар.
|
||||||
*/
|
*/
|
||||||
async upsertNpc(input: {
|
async upsertNpc(
|
||||||
npcId?: NpcId;
|
input: {
|
||||||
name: string;
|
npcId?: NpcId;
|
||||||
description?: string;
|
name: string;
|
||||||
filePath?: string;
|
description?: string;
|
||||||
groupId?: NpcGroupId | null;
|
filePath?: string;
|
||||||
binding?: NpcBinding;
|
groupId?: NpcGroupId | null;
|
||||||
}): Promise<Project> {
|
binding?: NpcBinding;
|
||||||
|
},
|
||||||
|
onProgress?: (p: { percent: number; stage: string; detail?: string }) => void,
|
||||||
|
): Promise<Project> {
|
||||||
|
const report = (percent: number, stage: string, detail?: string) => {
|
||||||
|
onProgress?.({ percent, stage, ...(detail ? { detail } : {}) });
|
||||||
|
};
|
||||||
const open = this.openProject;
|
const open = this.openProject;
|
||||||
if (!open) throw new Error('No open project');
|
if (!open) throw new Error('No open project');
|
||||||
const name = input.name.trim();
|
const name = input.name.trim();
|
||||||
@@ -1321,6 +1327,8 @@ export class ZipProjectStore {
|
|||||||
throw new Error('NPC name already exists');
|
throw new Error('NPC name already exists');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
report(2, 'start', 'Подождите…');
|
||||||
|
|
||||||
let nextAssetId: AssetId | null = null;
|
let nextAssetId: AssetId | null = null;
|
||||||
let stagedAsset: MediaAsset | null = null;
|
let stagedAsset: MediaAsset | null = null;
|
||||||
if (input.filePath) {
|
if (input.filePath) {
|
||||||
@@ -1330,13 +1338,16 @@ export class ZipProjectStore {
|
|||||||
if (!['.png', '.jpg', '.jpeg', '.webp'].includes(ext)) {
|
if (!['.png', '.jpg', '.jpeg', '.webp'].includes(ext)) {
|
||||||
throw new Error('NPC avatar must be an image (png/jpg/webp)');
|
throw new Error('NPC avatar must be an image (png/jpg/webp)');
|
||||||
}
|
}
|
||||||
|
report(8, 'read', 'Чтение изображения…');
|
||||||
let buf = await fs.readFile(input.filePath);
|
let buf = await fs.readFile(input.filePath);
|
||||||
|
report(18, 'optimize', 'Оптимизация изображения…');
|
||||||
try {
|
try {
|
||||||
const opt = await optimizeImageBufferVisuallyLossless(buf);
|
const opt = await optimizeImageBufferVisuallyLossless(buf);
|
||||||
if (opt.buffer && opt.buffer.length > 0) buf = Buffer.from(opt.buffer);
|
if (opt.buffer && opt.buffer.length > 0) buf = Buffer.from(opt.buffer);
|
||||||
} catch {
|
} catch {
|
||||||
// keep original buffer
|
// keep original buffer
|
||||||
}
|
}
|
||||||
|
report(72, 'write', 'Сохранение файла…');
|
||||||
const sha256 = crypto.createHash('sha256').update(buf).digest('hex');
|
const sha256 = crypto.createHash('sha256').update(buf).digest('hex');
|
||||||
const id = asAssetId(this.randomId());
|
const id = asAssetId(this.randomId());
|
||||||
const orig = path.basename(input.filePath);
|
const orig = path.basename(input.filePath);
|
||||||
@@ -1349,6 +1360,7 @@ export class ZipProjectStore {
|
|||||||
nextAssetId = id;
|
nextAssetId = id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
report(88, 'project', 'Обновление проекта…');
|
||||||
await this.updateProject((p) => {
|
await this.updateProject((p) => {
|
||||||
const npcs = [...(p.npcs ?? [])];
|
const npcs = [...(p.npcs ?? [])];
|
||||||
const assets = { ...p.assets };
|
const assets = { ...p.assets };
|
||||||
@@ -1393,6 +1405,7 @@ export class ZipProjectStore {
|
|||||||
|
|
||||||
const latest = this.getOpenProject();
|
const latest = this.getOpenProject();
|
||||||
if (!latest) throw new Error('No open project');
|
if (!latest) throw new Error('No open project');
|
||||||
|
report(100, 'done', 'Готово');
|
||||||
return latest;
|
return latest;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ void test('createWindows: окно НПС закрывается с multi-window
|
|||||||
assert.ok(src.includes('openNpcsWindow'));
|
assert.ok(src.includes('openNpcsWindow'));
|
||||||
assert.ok(src.includes('closeNpcsWindow'));
|
assert.ok(src.includes('closeNpcsWindow'));
|
||||||
assert.ok(src.includes('openNpcsEditorWindow'));
|
assert.ok(src.includes('openNpcsEditorWindow'));
|
||||||
|
assert.ok(src.includes('warmNpcsEditorWindow'));
|
||||||
assert.ok(src.includes("createWindow('npcs'"));
|
assert.ok(src.includes("createWindow('npcs'"));
|
||||||
assert.ok(src.includes("createWindow('npcsEditor'"));
|
assert.ok(src.includes("createWindow('npcsEditor'"));
|
||||||
assert.match(src, /export function closeMultiWindow[\s\S]*closeNpcsWindow/);
|
assert.match(src, /export function closeMultiWindow[\s\S]*closeNpcsWindow/);
|
||||||
|
|||||||
@@ -163,7 +163,7 @@ export function applyDockIconIfNeeded(): void {
|
|||||||
type CreateWindowOpts = {
|
type CreateWindowOpts = {
|
||||||
/** Дочернее окно (например пульт) держится над родителем (экран просмотра). */
|
/** Дочернее окно (например пульт) держится над родителем (экран просмотра). */
|
||||||
parent?: BrowserWindow;
|
parent?: BrowserWindow;
|
||||||
/** Только редактор: не показывать окно до `show()` (экран загрузки). */
|
/** Не показывать окно до явного `show()` (экран загрузки / прогрев НПС). */
|
||||||
deferVisibility?: boolean;
|
deferVisibility?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -202,7 +202,7 @@ function windowSizeForKind(kind: WindowKind): { width: number; height: number }
|
|||||||
}
|
}
|
||||||
|
|
||||||
function createWindow(kind: WindowKind, opts?: CreateWindowOpts): BrowserWindow {
|
function createWindow(kind: WindowKind, opts?: CreateWindowOpts): BrowserWindow {
|
||||||
const deferEditor = kind === 'editor' && opts?.deferVisibility === true;
|
const deferShow = opts?.deferVisibility === true;
|
||||||
const icon = loadBrandingWindowIcon();
|
const icon = loadBrandingWindowIcon();
|
||||||
const size = windowSizeForKind(kind);
|
const size = windowSizeForKind(kind);
|
||||||
const win = new BrowserWindow({
|
const win = new BrowserWindow({
|
||||||
@@ -300,7 +300,7 @@ function createWindow(kind: WindowKind, opts?: CreateWindowOpts): BrowserWindow
|
|||||||
safeConsoleError('[render-process-gone]', details.reason, details.exitCode);
|
safeConsoleError('[render-process-gone]', details.reason, details.exitCode);
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!deferEditor) {
|
if (!deferShow) {
|
||||||
ensureWindowBecomesVisible(win);
|
ensureWindowBecomesVisible(win);
|
||||||
}
|
}
|
||||||
loadWindowPage(win, kind);
|
loadWindowPage(win, kind);
|
||||||
@@ -527,19 +527,8 @@ export function openMaterialsWindow(): void {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Редактор НПС: отдельное окно с графом и инспектором. */
|
function positionNpcsEditorWindow(win: BrowserWindow): void {
|
||||||
export function openNpcsEditorWindow(): void {
|
|
||||||
const existing = windows.get('npcsEditor');
|
|
||||||
if (existing && !existing.isDestroyed()) {
|
|
||||||
if (existing.isMinimized()) existing.restore();
|
|
||||||
existing.show();
|
|
||||||
existing.focus();
|
|
||||||
existing.moveTop();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const parent = windows.get('editor');
|
const parent = windows.get('editor');
|
||||||
const win = createWindow('npcsEditor', parent ? { parent } : undefined);
|
|
||||||
const { width, height } = win.getBounds();
|
const { width, height } = win.getBounds();
|
||||||
const display = screen.getDisplayMatching(parent?.getBounds() ?? win.getBounds());
|
const display = screen.getDisplayMatching(parent?.getBounds() ?? win.getBounds());
|
||||||
const { x, y, width: dw, height: dh } = display.workArea;
|
const { x, y, width: dw, height: dh } = display.workArea;
|
||||||
@@ -549,9 +538,43 @@ export function openNpcsEditorWindow(): void {
|
|||||||
width,
|
width,
|
||||||
height,
|
height,
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Прогрев окна НПС в фоне после открытия проекта — клик «НПС» не ждёт холодной загрузки. */
|
||||||
|
export function warmNpcsEditorWindow(): void {
|
||||||
|
const existing = windows.get('npcsEditor');
|
||||||
|
if (existing && !existing.isDestroyed()) return;
|
||||||
|
const parent = windows.get('editor');
|
||||||
|
createWindow('npcsEditor', {
|
||||||
|
...(parent ? { parent } : {}),
|
||||||
|
deferVisibility: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Редактор НПС: отдельное окно с графом и инспектором. */
|
||||||
|
export function openNpcsEditorWindow(): void {
|
||||||
|
const existing = windows.get('npcsEditor');
|
||||||
|
if (existing && !existing.isDestroyed()) {
|
||||||
|
if (existing.isMinimized()) existing.restore();
|
||||||
|
positionNpcsEditorWindow(existing);
|
||||||
|
existing.show();
|
||||||
|
existing.focus();
|
||||||
|
existing.moveTop();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parent = windows.get('editor');
|
||||||
|
const win = createWindow('npcsEditor', {
|
||||||
|
...(parent ? { parent } : {}),
|
||||||
|
deferVisibility: true,
|
||||||
|
});
|
||||||
|
positionNpcsEditorWindow(win);
|
||||||
|
// Показываем сразу (тёмный фон), не дожидаясь полной загрузки React/ReactFlow.
|
||||||
|
win.show();
|
||||||
|
win.focus();
|
||||||
|
win.moveTop();
|
||||||
win.webContents.once('did-finish-load', () => {
|
win.webContents.once('did-finish-load', () => {
|
||||||
if (!win.isDestroyed()) {
|
if (!win.isDestroyed()) {
|
||||||
win.show();
|
|
||||||
win.focus();
|
win.focus();
|
||||||
win.moveTop();
|
win.moveTop();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { createRoot } from 'react-dom/client';
|
|||||||
|
|
||||||
import '../shared/ui/globals.css';
|
import '../shared/ui/globals.css';
|
||||||
import { EditorI18nProvider } from '../editor/i18n/EditorI18nContext';
|
import { EditorI18nProvider } from '../editor/i18n/EditorI18nContext';
|
||||||
|
import { WindowErrorBoundary } from '../shared/ui/WindowErrorBoundary';
|
||||||
|
|
||||||
import { ControlApp } from './ControlApp';
|
import { ControlApp } from './ControlApp';
|
||||||
|
|
||||||
@@ -13,8 +14,10 @@ if (!rootEl) {
|
|||||||
|
|
||||||
createRoot(rootEl).render(
|
createRoot(rootEl).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<EditorI18nProvider>
|
<WindowErrorBoundary title="Пульт">
|
||||||
<ControlApp />
|
<EditorI18nProvider>
|
||||||
</EditorI18nProvider>
|
<ControlApp />
|
||||||
|
</EditorI18nProvider>
|
||||||
|
</WindowErrorBoundary>
|
||||||
</React.StrictMode>,
|
</React.StrictMode>,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ export function SceneDescriptionModal({ initialHtml, onClose, onSave }: SceneDes
|
|||||||
const editor = useEditor({
|
const editor = useEditor({
|
||||||
extensions,
|
extensions,
|
||||||
content: initialHtml || '',
|
content: initialHtml || '',
|
||||||
immediatelyRender: true,
|
immediatelyRender: false,
|
||||||
shouldRerenderOnTransaction: true,
|
shouldRerenderOnTransaction: true,
|
||||||
editorProps: {
|
editorProps: {
|
||||||
attributes: {
|
attributes: {
|
||||||
@@ -94,21 +94,56 @@ export function SceneDescriptionModal({ initialHtml, onClose, onSave }: SceneDes
|
|||||||
const toolbarState = useEditorState({
|
const toolbarState = useEditorState({
|
||||||
editor,
|
editor,
|
||||||
selector: ({ editor: ed }) => ({
|
selector: ({ editor: ed }) => ({
|
||||||
bold: ed.isActive('bold'),
|
bold: Boolean(ed && !ed.isDestroyed && ed.isActive('bold')),
|
||||||
italic: ed.isActive('italic'),
|
italic: Boolean(ed && !ed.isDestroyed && ed.isActive('italic')),
|
||||||
underline: ed.isActive('underline'),
|
underline: Boolean(ed && !ed.isDestroyed && ed.isActive('underline')),
|
||||||
bulletList: ed.isActive('bulletList'),
|
bulletList: Boolean(ed && !ed.isDestroyed && ed.isActive('bulletList')),
|
||||||
orderedList: ed.isActive('orderedList'),
|
orderedList: Boolean(ed && !ed.isDestroyed && ed.isActive('orderedList')),
|
||||||
h2: ed.isActive('heading', { level: 2 }),
|
h2: Boolean(ed && !ed.isDestroyed && ed.isActive('heading', { level: 2 })),
|
||||||
h3: ed.isActive('heading', { level: 3 }),
|
h3: Boolean(ed && !ed.isDestroyed && ed.isActive('heading', { level: 3 })),
|
||||||
blockquote: ed.isActive('blockquote'),
|
blockquote: Boolean(ed && !ed.isDestroyed && ed.isActive('blockquote')),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleSave = () => {
|
const handleSave = () => {
|
||||||
onSave(normalizeSceneDescriptionHtml(editor.getHTML()));
|
if (!editor || editor.isDestroyed) return;
|
||||||
|
let raw = '';
|
||||||
|
try {
|
||||||
|
raw = editor.getHTML();
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onSave(normalizeSceneDescriptionHtml(raw));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (!editor || editor.isDestroyed) {
|
||||||
|
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>
|
||||||
|
</>,
|
||||||
|
document.body,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return createPortal(
|
return createPortal(
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -414,6 +414,10 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
|||||||
'npcs.add': 'Добавить',
|
'npcs.add': 'Добавить',
|
||||||
'npcs.addTitle': 'Новый НПС',
|
'npcs.addTitle': 'Новый НПС',
|
||||||
'npcs.editTitle': 'Изменить НПС',
|
'npcs.editTitle': 'Изменить НПС',
|
||||||
|
'npcs.savingTitle': 'Сохранение НПС',
|
||||||
|
'npcs.savingWait': 'Подождите…',
|
||||||
|
'npcs.savingProgress': 'Прогресс сохранения НПС',
|
||||||
|
'npcs.graphLoading': 'Загрузка графа…',
|
||||||
'npcs.edit': 'Изменить',
|
'npcs.edit': 'Изменить',
|
||||||
'npcs.tileMenu': 'Меню НПС',
|
'npcs.tileMenu': 'Меню НПС',
|
||||||
'npcs.search': 'Поиск НПС…',
|
'npcs.search': 'Поиск НПС…',
|
||||||
@@ -979,6 +983,10 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
|||||||
'npcs.add': 'Add',
|
'npcs.add': 'Add',
|
||||||
'npcs.addTitle': 'New NPC',
|
'npcs.addTitle': 'New NPC',
|
||||||
'npcs.editTitle': 'Edit NPC',
|
'npcs.editTitle': 'Edit NPC',
|
||||||
|
'npcs.savingTitle': 'Saving NPC',
|
||||||
|
'npcs.savingWait': 'Please wait…',
|
||||||
|
'npcs.savingProgress': 'NPC save progress',
|
||||||
|
'npcs.graphLoading': 'Loading graph…',
|
||||||
'npcs.edit': 'Edit',
|
'npcs.edit': 'Edit',
|
||||||
'npcs.tileMenu': 'NPC menu',
|
'npcs.tileMenu': 'NPC menu',
|
||||||
'npcs.search': 'Search NPCs…',
|
'npcs.search': 'Search NPCs…',
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { createRoot } from 'react-dom/client';
|
|||||||
|
|
||||||
import '../shared/ui/globals.css';
|
import '../shared/ui/globals.css';
|
||||||
import { EditorI18nProvider } from '../editor/i18n/EditorI18nContext';
|
import { EditorI18nProvider } from '../editor/i18n/EditorI18nContext';
|
||||||
|
import { WindowErrorBoundary } from '../shared/ui/WindowErrorBoundary';
|
||||||
|
|
||||||
import { MaterialsApp } from './MaterialsApp';
|
import { MaterialsApp } from './MaterialsApp';
|
||||||
|
|
||||||
@@ -13,8 +14,10 @@ if (!rootEl) {
|
|||||||
|
|
||||||
createRoot(rootEl).render(
|
createRoot(rootEl).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<EditorI18nProvider>
|
<WindowErrorBoundary title="Материалы">
|
||||||
<MaterialsApp />
|
<EditorI18nProvider>
|
||||||
</EditorI18nProvider>
|
<MaterialsApp />
|
||||||
|
</EditorI18nProvider>
|
||||||
|
</WindowErrorBoundary>
|
||||||
</React.StrictMode>,
|
</React.StrictMode>,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import modalStyles from '../editor/SceneDescriptionModal.module.css';
|
|||||||
import { useEditorI18n } from '../editor/i18n/EditorI18nContext';
|
import { useEditorI18n } from '../editor/i18n/EditorI18nContext';
|
||||||
|
|
||||||
import styles from './NpcsEditorApp.module.css';
|
import styles from './NpcsEditorApp.module.css';
|
||||||
|
import { readTipTapHtmlSafe } from './tiptapEditorSafe';
|
||||||
|
|
||||||
type NpcDescriptionFieldProps = {
|
type NpcDescriptionFieldProps = {
|
||||||
html: string;
|
html: string;
|
||||||
@@ -59,7 +60,8 @@ export function NpcDescriptionField({ html, onCommit }: NpcDescriptionFieldProps
|
|||||||
const editor = useEditor({
|
const editor = useEditor({
|
||||||
extensions,
|
extensions,
|
||||||
content: html || '',
|
content: html || '',
|
||||||
immediatelyRender: true,
|
// StrictMode + true даёт destroy/recreate с null schema → падение getHTML (чёрный экран окна НПС).
|
||||||
|
immediatelyRender: false,
|
||||||
shouldRerenderOnTransaction: true,
|
shouldRerenderOnTransaction: true,
|
||||||
editorProps: {
|
editorProps: {
|
||||||
attributes: {
|
attributes: {
|
||||||
@@ -68,13 +70,17 @@ export function NpcDescriptionField({ html, onCommit }: NpcDescriptionFieldProps
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
onBlur: ({ editor: ed }) => {
|
onBlur: ({ editor: ed }) => {
|
||||||
onCommit(normalizeSceneDescriptionHtml(ed.getHTML()));
|
const raw = readTipTapHtmlSafe(ed);
|
||||||
|
if (raw == null) return;
|
||||||
|
onCommit(normalizeSceneDescriptionHtml(raw));
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!editor) return;
|
if (!editor || editor.isDestroyed) return;
|
||||||
const current = normalizeSceneDescriptionHtml(editor.getHTML());
|
const raw = readTipTapHtmlSafe(editor);
|
||||||
|
if (raw == null) return;
|
||||||
|
const current = normalizeSceneDescriptionHtml(raw);
|
||||||
const next = normalizeSceneDescriptionHtml(html);
|
const next = normalizeSceneDescriptionHtml(html);
|
||||||
if (current !== next) {
|
if (current !== next) {
|
||||||
editor.commands.setContent(html || '', { emitUpdate: false });
|
editor.commands.setContent(html || '', { emitUpdate: false });
|
||||||
@@ -84,30 +90,30 @@ export function NpcDescriptionField({ html, onCommit }: NpcDescriptionFieldProps
|
|||||||
const toolbarState = useEditorState({
|
const toolbarState = useEditorState({
|
||||||
editor,
|
editor,
|
||||||
selector: ({ editor: ed }) => ({
|
selector: ({ editor: ed }) => ({
|
||||||
bold: ed.isActive('bold'),
|
bold: Boolean(ed && !ed.isDestroyed && ed.isActive('bold')),
|
||||||
italic: ed.isActive('italic'),
|
italic: Boolean(ed && !ed.isDestroyed && ed.isActive('italic')),
|
||||||
bulletList: ed.isActive('bulletList'),
|
bulletList: Boolean(ed && !ed.isDestroyed && ed.isActive('bulletList')),
|
||||||
orderedList: ed.isActive('orderedList'),
|
orderedList: Boolean(ed && !ed.isDestroyed && ed.isActive('orderedList')),
|
||||||
h2: ed.isActive('heading', { level: 2 }),
|
h2: Boolean(ed && !ed.isDestroyed && ed.isActive('heading', { level: 2 })),
|
||||||
h3: ed.isActive('heading', { level: 3 }),
|
h3: Boolean(ed && !ed.isDestroyed && ed.isActive('heading', { level: 3 })),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!editor) return null;
|
if (!editor || editor.isDestroyed) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.descShell}>
|
<div className={styles.descShell}>
|
||||||
<div className={modalStyles.toolbar}>
|
<div className={modalStyles.toolbar}>
|
||||||
<div className={modalStyles.toolbarGroup}>
|
<div className={modalStyles.toolbarGroup}>
|
||||||
<ToolButton
|
<ToolButton
|
||||||
active={toolbarState.bold}
|
active={toolbarState?.bold ?? false}
|
||||||
title={t('scene.descriptionBold')}
|
title={t('scene.descriptionBold')}
|
||||||
onClick={() => editor.chain().focus().toggleBold().run()}
|
onClick={() => editor.chain().focus().toggleBold().run()}
|
||||||
>
|
>
|
||||||
B
|
B
|
||||||
</ToolButton>
|
</ToolButton>
|
||||||
<ToolButton
|
<ToolButton
|
||||||
active={toolbarState.italic}
|
active={toolbarState?.italic ?? false}
|
||||||
title={t('scene.descriptionItalic')}
|
title={t('scene.descriptionItalic')}
|
||||||
onClick={() => editor.chain().focus().toggleItalic().run()}
|
onClick={() => editor.chain().focus().toggleItalic().run()}
|
||||||
>
|
>
|
||||||
@@ -117,14 +123,14 @@ export function NpcDescriptionField({ html, onCommit }: NpcDescriptionFieldProps
|
|||||||
<div className={modalStyles.toolbarSep} />
|
<div className={modalStyles.toolbarSep} />
|
||||||
<div className={modalStyles.toolbarGroup}>
|
<div className={modalStyles.toolbarGroup}>
|
||||||
<ToolButton
|
<ToolButton
|
||||||
active={toolbarState.h2}
|
active={toolbarState?.h2 ?? false}
|
||||||
title={t('scene.descriptionHeading2')}
|
title={t('scene.descriptionHeading2')}
|
||||||
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
|
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
|
||||||
>
|
>
|
||||||
H2
|
H2
|
||||||
</ToolButton>
|
</ToolButton>
|
||||||
<ToolButton
|
<ToolButton
|
||||||
active={toolbarState.h3}
|
active={toolbarState?.h3 ?? false}
|
||||||
title={t('scene.descriptionHeading3')}
|
title={t('scene.descriptionHeading3')}
|
||||||
onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}
|
onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}
|
||||||
>
|
>
|
||||||
@@ -134,14 +140,14 @@ export function NpcDescriptionField({ html, onCommit }: NpcDescriptionFieldProps
|
|||||||
<div className={modalStyles.toolbarSep} />
|
<div className={modalStyles.toolbarSep} />
|
||||||
<div className={modalStyles.toolbarGroup}>
|
<div className={modalStyles.toolbarGroup}>
|
||||||
<ToolButton
|
<ToolButton
|
||||||
active={toolbarState.bulletList}
|
active={toolbarState?.bulletList ?? false}
|
||||||
title={t('scene.descriptionBulletList')}
|
title={t('scene.descriptionBulletList')}
|
||||||
onClick={() => editor.chain().focus().toggleBulletList().run()}
|
onClick={() => editor.chain().focus().toggleBulletList().run()}
|
||||||
>
|
>
|
||||||
•
|
•
|
||||||
</ToolButton>
|
</ToolButton>
|
||||||
<ToolButton
|
<ToolButton
|
||||||
active={toolbarState.orderedList}
|
active={toolbarState?.orderedList ?? false}
|
||||||
title={t('scene.descriptionOrderedList')}
|
title={t('scene.descriptionOrderedList')}
|
||||||
onClick={() => editor.chain().focus().toggleOrderedList().run()}
|
onClick={() => editor.chain().focus().toggleOrderedList().run()}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import React, { useEffect, useMemo, useState } from 'react';
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal, flushSync } from 'react-dom';
|
||||||
|
|
||||||
|
import { ipcChannels } from '../../shared/ipc/contracts';
|
||||||
import { noneBinding } from '../../shared/npcs/npcBinding';
|
import { noneBinding } from '../../shared/npcs/npcBinding';
|
||||||
import { buildNpcGroupForest } from '../../shared/npcs/npcGroups';
|
import { buildNpcGroupForest } from '../../shared/npcs/npcGroups';
|
||||||
import type { NpcBinding, NpcGroupId, Project, ProjectNpc, ProjectNpcGroup } from '../../shared/types';
|
import type { NpcBinding, NpcGroupId, Project, ProjectNpc, ProjectNpcGroup } from '../../shared/types';
|
||||||
@@ -13,6 +14,7 @@ import {
|
|||||||
} from '../editor/fileDrop';
|
} from '../editor/fileDrop';
|
||||||
import { useEditorI18n } from '../editor/i18n/EditorI18nContext';
|
import { useEditorI18n } from '../editor/i18n/EditorI18nContext';
|
||||||
import matStyles from '../editor/MaterialsModals.module.css';
|
import matStyles from '../editor/MaterialsModals.module.css';
|
||||||
|
import { getDndApi } from '../shared/dndApi';
|
||||||
import { Button, Input, Select } from '../shared/ui/controls';
|
import { Button, Input, Select } from '../shared/ui/controls';
|
||||||
import { useAssetUrl } from '../shared/useAssetImageUrl';
|
import { useAssetUrl } from '../shared/useAssetImageUrl';
|
||||||
|
|
||||||
@@ -62,12 +64,14 @@ export function NpcEditModal({
|
|||||||
onSave,
|
onSave,
|
||||||
}: NpcEditModalProps) {
|
}: NpcEditModalProps) {
|
||||||
const { t } = useEditorI18n();
|
const { t } = useEditorI18n();
|
||||||
|
const api = getDndApi();
|
||||||
const [name, setName] = useState('');
|
const [name, setName] = useState('');
|
||||||
const [filePath, setFilePath] = useState<string | null>(null);
|
const [filePath, setFilePath] = useState<string | null>(null);
|
||||||
const [localPreviewUrl, setLocalPreviewUrl] = useState<string | null>(null);
|
const [localPreviewUrl, setLocalPreviewUrl] = useState<string | null>(null);
|
||||||
const [groupId, setGroupId] = useState<NpcGroupId | ''>('');
|
const [groupId, setGroupId] = useState<NpcGroupId | ''>('');
|
||||||
const [binding, setBinding] = useState<NpcBinding>(noneBinding());
|
const [binding, setBinding] = useState<NpcBinding>(noneBinding());
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [saveProgress, setSaveProgress] = useState<{ percent: number; detail: string } | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const existingUrl = useAssetUrl(initial?.avatarAssetId ?? null);
|
const existingUrl = useAssetUrl(initial?.avatarAssetId ?? null);
|
||||||
|
|
||||||
@@ -84,6 +88,7 @@ export function NpcEditModal({
|
|||||||
setGroupId(initial?.groupId ?? '');
|
setGroupId(initial?.groupId ?? '');
|
||||||
setBinding(initial?.binding ?? noneBinding());
|
setBinding(initial?.binding ?? noneBinding());
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
|
setSaveProgress(null);
|
||||||
setError(null);
|
setError(null);
|
||||||
}, [initial, open]);
|
}, [initial, open]);
|
||||||
|
|
||||||
@@ -93,14 +98,24 @@ export function NpcEditModal({
|
|||||||
};
|
};
|
||||||
}, [localPreviewUrl]);
|
}, [localPreviewUrl]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
return api.on(ipcChannels.project.npcUpsertProgress, (evt) => {
|
||||||
|
setSaveProgress({
|
||||||
|
percent: Math.max(0, Math.min(100, Math.round(evt.percent))),
|
||||||
|
detail: evt.detail?.trim() || t('npcs.savingWait'),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}, [api, open, t]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
const onKey = (e: KeyboardEvent) => {
|
const onKey = (e: KeyboardEvent) => {
|
||||||
if (e.key === 'Escape') onClose();
|
if (e.key === 'Escape' && !saving) onClose();
|
||||||
};
|
};
|
||||||
window.addEventListener('keydown', onKey);
|
window.addEventListener('keydown', onKey);
|
||||||
return () => window.removeEventListener('keydown', onKey);
|
return () => window.removeEventListener('keydown', onKey);
|
||||||
}, [onClose, open]);
|
}, [onClose, open, saving]);
|
||||||
|
|
||||||
const setPreviewFromPathAndUrl = (path: string, previewUrl: string) => {
|
const setPreviewFromPathAndUrl = (path: string, previewUrl: string) => {
|
||||||
setFilePath(path);
|
setFilePath(path);
|
||||||
@@ -131,6 +146,8 @@ export function NpcEditModal({
|
|||||||
const hasImage = Boolean(filePath) || Boolean(initial?.avatarAssetId);
|
const hasImage = Boolean(filePath) || Boolean(initial?.avatarAssetId);
|
||||||
const canSave = nameOk && !nameDup && hasImage && !saving;
|
const canSave = nameOk && !nameDup && hasImage && !saving;
|
||||||
const previewSrc = localPreviewUrl ?? existingUrl;
|
const previewSrc = localPreviewUrl ?? existingUrl;
|
||||||
|
const progressPercent = saveProgress?.percent ?? (saving ? 0 : 0);
|
||||||
|
const progressDetail = saveProgress?.detail ?? t('npcs.savingWait');
|
||||||
|
|
||||||
if (!open) return null;
|
if (!open) return null;
|
||||||
|
|
||||||
@@ -139,7 +156,9 @@ export function NpcEditModal({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label={t('common.close')}
|
aria-label={t('common.close')}
|
||||||
onClick={onClose}
|
onClick={() => {
|
||||||
|
if (!saving) onClose();
|
||||||
|
}}
|
||||||
className={editorStyles.modalBackdrop}
|
className={editorStyles.modalBackdrop}
|
||||||
/>
|
/>
|
||||||
<div role="dialog" aria-modal="true" className={editorStyles.modalDialog}>
|
<div role="dialog" aria-modal="true" className={editorStyles.modalDialog}>
|
||||||
@@ -148,8 +167,11 @@ export function NpcEditModal({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label={t('common.close')}
|
aria-label={t('common.close')}
|
||||||
onClick={onClose}
|
onClick={() => {
|
||||||
|
if (!saving) onClose();
|
||||||
|
}}
|
||||||
className={editorStyles.modalClose}
|
className={editorStyles.modalClose}
|
||||||
|
disabled={saving}
|
||||||
>
|
>
|
||||||
×
|
×
|
||||||
</button>
|
</button>
|
||||||
@@ -208,6 +230,7 @@ export function NpcEditModal({
|
|||||||
<div className={matStyles.imageDropEmpty}>
|
<div className={matStyles.imageDropEmpty}>
|
||||||
<div className={editorStyles.muted}>{t('npcs.avatarEmpty')}</div>
|
<div className={editorStyles.muted}>{t('npcs.avatarEmpty')}</div>
|
||||||
<Button
|
<Button
|
||||||
|
disabled={saving}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
void (async () => {
|
void (async () => {
|
||||||
const picked = await onPickImage();
|
const picked = await onPickImage();
|
||||||
@@ -222,6 +245,7 @@ export function NpcEditModal({
|
|||||||
)}
|
)}
|
||||||
{previewSrc ? (
|
{previewSrc ? (
|
||||||
<Button
|
<Button
|
||||||
|
disabled={saving}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
void (async () => {
|
void (async () => {
|
||||||
const picked = await onPickImage();
|
const picked = await onPickImage();
|
||||||
@@ -253,8 +277,11 @@ export function NpcEditModal({
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (!canSave) return;
|
if (!canSave) return;
|
||||||
void (async () => {
|
void (async () => {
|
||||||
setSaving(true);
|
flushSync(() => {
|
||||||
setError(null);
|
setSaving(true);
|
||||||
|
setSaveProgress({ percent: 0, detail: t('npcs.savingWait') });
|
||||||
|
setError(null);
|
||||||
|
});
|
||||||
try {
|
try {
|
||||||
await onSave({
|
await onSave({
|
||||||
name: trimmed,
|
name: trimmed,
|
||||||
@@ -266,14 +293,38 @@ export function NpcEditModal({
|
|||||||
setError(e instanceof Error ? e.message : String(e));
|
setError(e instanceof Error ? e.message : String(e));
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
|
setSaveProgress(null);
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{t('common.save')}
|
{saving ? t('common.saving') : t('common.save')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{saving ? (
|
||||||
|
<div
|
||||||
|
className={editorStyles.progressOverlay}
|
||||||
|
role="dialog"
|
||||||
|
aria-label={t('npcs.savingProgress')}
|
||||||
|
aria-busy
|
||||||
|
>
|
||||||
|
<div className={editorStyles.progressModal}>
|
||||||
|
<div className={editorStyles.progressTitle}>{t('npcs.savingTitle')}</div>
|
||||||
|
<div className={editorStyles.previewSpinner} aria-hidden />
|
||||||
|
<div className={editorStyles.progressBar}>
|
||||||
|
<div
|
||||||
|
className={editorStyles.progressFill}
|
||||||
|
style={{ width: `${String(Math.max(0, Math.min(100, progressPercent)))}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className={editorStyles.progressMeta}>
|
||||||
|
<div>{progressDetail}</div>
|
||||||
|
<div>{progressPercent}%</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</>,
|
</>,
|
||||||
document.body,
|
document.body,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -32,6 +32,16 @@
|
|||||||
border-right: 1px solid var(--stroke);
|
border-right: 1px solid var(--stroke);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.graphLoading {
|
||||||
|
height: 100%;
|
||||||
|
min-height: 240px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: var(--text2);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
.col:last-child {
|
.col:last-child {
|
||||||
border-right: 0;
|
border-right: 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
import React, { Suspense, lazy, useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
|
|
||||||
import { ipcChannels, type SessionState } from '../../shared/ipc/contracts';
|
import { ipcChannels, type SessionState } from '../../shared/ipc/contracts';
|
||||||
@@ -21,7 +21,7 @@ import { useAssetUrl } from '../shared/useAssetImageUrl';
|
|||||||
import { NpcBindingFields } from './NpcBindingFields';
|
import { NpcBindingFields } from './NpcBindingFields';
|
||||||
import { NpcDescriptionField } from './NpcDescriptionField';
|
import { NpcDescriptionField } from './NpcDescriptionField';
|
||||||
import { NpcEditModal } from './NpcEditModal';
|
import { NpcEditModal } from './NpcEditModal';
|
||||||
import { NpcGraph, type GraphGroupFilter } from './NpcGraph';
|
import type { GraphGroupFilter } from './NpcGraph';
|
||||||
import { NpcGroupModal } from './NpcGroupModal';
|
import { NpcGroupModal } from './NpcGroupModal';
|
||||||
import {
|
import {
|
||||||
flattenGroupOptions,
|
flattenGroupOptions,
|
||||||
@@ -32,6 +32,11 @@ import {
|
|||||||
import { NpcRelationModal } from './NpcRelationModal';
|
import { NpcRelationModal } from './NpcRelationModal';
|
||||||
import styles from './NpcsEditorApp.module.css';
|
import styles from './NpcsEditorApp.module.css';
|
||||||
|
|
||||||
|
const NpcGraph = lazy(async () => {
|
||||||
|
const mod = await import('./NpcGraph');
|
||||||
|
return { default: mod.NpcGraph };
|
||||||
|
});
|
||||||
|
|
||||||
const DND_NPC_ID_MIME = 'application/x-dnd-npc-id';
|
const DND_NPC_ID_MIME = 'application/x-dnd-npc-id';
|
||||||
const DND_NPC_GROUP_ID_MIME = 'application/x-dnd-npc-group-id';
|
const DND_NPC_GROUP_ID_MIME = 'application/x-dnd-npc-group-id';
|
||||||
|
|
||||||
@@ -671,56 +676,58 @@ export function NpcsEditorApp() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={styles.col}>
|
<div className={styles.col}>
|
||||||
<NpcGraph
|
<Suspense fallback={<div className={styles.graphLoading}>{t('npcs.graphLoading')}</div>}>
|
||||||
npcs={npcs}
|
<NpcGraph
|
||||||
relations={relations}
|
npcs={npcs}
|
||||||
npcGroups={npcGroups}
|
relations={relations}
|
||||||
selectedNpcId={selectedId}
|
npcGroups={npcGroups}
|
||||||
graphFilter={graphFilter}
|
selectedNpcId={selectedId}
|
||||||
onGraphFilterChange={setGraphFilter}
|
graphFilter={graphFilter}
|
||||||
graphUi={graphUi}
|
onGraphFilterChange={setGraphFilter}
|
||||||
onSelect={setSelectedId}
|
graphUi={graphUi}
|
||||||
onConnectRequest={(sourceNpcId, targetNpcId) => {
|
onSelect={setSelectedId}
|
||||||
setRelationModal({ mode: 'create', sourceNpcId, targetNpcId });
|
onConnectRequest={(sourceNpcId, targetNpcId) => {
|
||||||
}}
|
setRelationModal({ mode: 'create', sourceNpcId, targetNpcId });
|
||||||
onNodePositionCommit={(npcId, x, y) => {
|
}}
|
||||||
void (async () => {
|
onNodePositionCommit={(npcId, x, y) => {
|
||||||
setSession((prev) => {
|
void (async () => {
|
||||||
if (!prev?.project) return prev;
|
setSession((prev) => {
|
||||||
return {
|
if (!prev?.project) return prev;
|
||||||
...prev,
|
return {
|
||||||
project: {
|
...prev,
|
||||||
...prev.project,
|
project: {
|
||||||
npcs: prev.project.npcs.map((n) => (n.id === npcId ? { ...n, x, y } : n)),
|
...prev.project,
|
||||||
},
|
npcs: prev.project.npcs.map((n) => (n.id === npcId ? { ...n, x, y } : n)),
|
||||||
};
|
},
|
||||||
});
|
};
|
||||||
try {
|
|
||||||
const res = await api.invoke(ipcChannels.project.updateNpcPosition, {
|
|
||||||
npcId,
|
|
||||||
x,
|
|
||||||
y,
|
|
||||||
});
|
});
|
||||||
setSession({
|
try {
|
||||||
project: res.project,
|
const res = await api.invoke(ipcChannels.project.updateNpcPosition, {
|
||||||
currentSceneId: res.project?.currentSceneId ?? null,
|
npcId,
|
||||||
});
|
x,
|
||||||
} catch {
|
y,
|
||||||
/* позиция уже оптимистично в UI; следующий session sync поправит при CRUD */
|
});
|
||||||
}
|
setSession({
|
||||||
})();
|
project: res.project,
|
||||||
}}
|
currentSceneId: res.project?.currentSceneId ?? null,
|
||||||
onEditRelation={(relationId) => {
|
});
|
||||||
const rel = relations.find((r) => r.id === relationId);
|
} catch {
|
||||||
if (!rel) return;
|
/* позиция уже оптимистично в UI; следующий session sync поправит при CRUD */
|
||||||
setRelationModal({ mode: 'edit', relationId, label: rel.label });
|
}
|
||||||
}}
|
})();
|
||||||
onDeleteRelation={(relationId) => {
|
}}
|
||||||
const rel = relations.find((r) => r.id === relationId);
|
onEditRelation={(relationId) => {
|
||||||
if (!rel) return;
|
const rel = relations.find((r) => r.id === relationId);
|
||||||
setPendingDeleteRelation(rel);
|
if (!rel) return;
|
||||||
}}
|
setRelationModal({ mode: 'edit', relationId, label: rel.label });
|
||||||
/>
|
}}
|
||||||
|
onDeleteRelation={(relationId) => {
|
||||||
|
const rel = relations.find((r) => r.id === relationId);
|
||||||
|
if (!rel) return;
|
||||||
|
setPendingDeleteRelation(rel);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={[styles.col, styles.inspector].join(' ')}>
|
<div className={[styles.col, styles.inspector].join(' ')}>
|
||||||
@@ -761,10 +768,9 @@ export function NpcsEditorApp() {
|
|||||||
|
|
||||||
<div>
|
<div>
|
||||||
<div className={styles.fieldLabel}>{t('npcs.name')}</div>
|
<div className={styles.fieldLabel}>{t('npcs.name')}</div>
|
||||||
<input
|
<Input
|
||||||
className={controlStyles.input}
|
|
||||||
value={nameDraft}
|
value={nameDraft}
|
||||||
onChange={(e) => setNameDraft(e.target.value)}
|
onChange={setNameDraft}
|
||||||
onBlur={() => {
|
onBlur={() => {
|
||||||
const next = nameDraft.trim();
|
const next = nameDraft.trim();
|
||||||
if (!next || next === selected.name) {
|
if (!next || next === selected.name) {
|
||||||
@@ -804,6 +810,7 @@ export function NpcsEditorApp() {
|
|||||||
<div>
|
<div>
|
||||||
<div className={styles.fieldLabel}>{t('npcs.description')}</div>
|
<div className={styles.fieldLabel}>{t('npcs.description')}</div>
|
||||||
<NpcDescriptionField
|
<NpcDescriptionField
|
||||||
|
key={selected.id}
|
||||||
html={selected.description}
|
html={selected.description}
|
||||||
onCommit={(html) => {
|
onCommit={(html) => {
|
||||||
if (html === selected.description) return;
|
if (html === selected.description) return;
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { createRoot } from 'react-dom/client';
|
|||||||
|
|
||||||
import '../shared/ui/globals.css';
|
import '../shared/ui/globals.css';
|
||||||
import { EditorI18nProvider } from '../editor/i18n/EditorI18nContext';
|
import { EditorI18nProvider } from '../editor/i18n/EditorI18nContext';
|
||||||
|
import { WindowErrorBoundary } from '../shared/ui/WindowErrorBoundary';
|
||||||
|
|
||||||
import { NpcsEditorApp } from './NpcsEditorApp';
|
import { NpcsEditorApp } from './NpcsEditorApp';
|
||||||
|
|
||||||
@@ -13,8 +14,10 @@ if (!rootEl) {
|
|||||||
|
|
||||||
createRoot(rootEl).render(
|
createRoot(rootEl).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<EditorI18nProvider>
|
<WindowErrorBoundary title="НПС">
|
||||||
<NpcsEditorApp />
|
<EditorI18nProvider>
|
||||||
</EditorI18nProvider>
|
<NpcsEditorApp />
|
||||||
|
</EditorI18nProvider>
|
||||||
|
</WindowErrorBoundary>
|
||||||
</React.StrictMode>,
|
</React.StrictMode>,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { createRoot } from 'react-dom/client';
|
|||||||
|
|
||||||
import '../shared/ui/globals.css';
|
import '../shared/ui/globals.css';
|
||||||
import { EditorI18nProvider } from '../editor/i18n/EditorI18nContext';
|
import { EditorI18nProvider } from '../editor/i18n/EditorI18nContext';
|
||||||
|
import { WindowErrorBoundary } from '../shared/ui/WindowErrorBoundary';
|
||||||
|
|
||||||
import { NpcsApp } from './NpcsApp';
|
import { NpcsApp } from './NpcsApp';
|
||||||
|
|
||||||
@@ -13,8 +14,10 @@ if (!rootEl) {
|
|||||||
|
|
||||||
createRoot(rootEl).render(
|
createRoot(rootEl).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<EditorI18nProvider>
|
<WindowErrorBoundary title="НПС">
|
||||||
<NpcsApp />
|
<EditorI18nProvider>
|
||||||
</EditorI18nProvider>
|
<NpcsApp />
|
||||||
|
</EditorI18nProvider>
|
||||||
|
</WindowErrorBoundary>
|
||||||
</React.StrictMode>,
|
</React.StrictMode>,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
import { readTipTapHtmlSafe } from './tiptapEditorSafe';
|
||||||
|
|
||||||
|
void test('readTipTapHtmlSafe: null / destroyed → null', () => {
|
||||||
|
assert.equal(readTipTapHtmlSafe(null), null);
|
||||||
|
assert.equal(readTipTapHtmlSafe(undefined), null);
|
||||||
|
assert.equal(
|
||||||
|
readTipTapHtmlSafe({
|
||||||
|
isDestroyed: true,
|
||||||
|
getHTML: () => '<p>x</p>',
|
||||||
|
}),
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('readTipTapHtmlSafe: getHTML throw → null', () => {
|
||||||
|
assert.equal(
|
||||||
|
readTipTapHtmlSafe({
|
||||||
|
isDestroyed: false,
|
||||||
|
getHTML: () => {
|
||||||
|
throw new TypeError("Cannot read properties of null (reading 'cached')");
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('readTipTapHtmlSafe: ok → html', () => {
|
||||||
|
assert.equal(
|
||||||
|
readTipTapHtmlSafe({
|
||||||
|
isDestroyed: false,
|
||||||
|
getHTML: () => '<p>ok</p>',
|
||||||
|
}),
|
||||||
|
'<p>ok</p>',
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
/** Безопасное чтение HTML из TipTap/ProseMirror (StrictMode / destroy mid-flight). */
|
||||||
|
export function readTipTapHtmlSafe(editor: {
|
||||||
|
isDestroyed?: boolean;
|
||||||
|
getHTML: () => string;
|
||||||
|
} | null | undefined): string | null {
|
||||||
|
if (!editor || editor.isDestroyed) return null;
|
||||||
|
try {
|
||||||
|
return editor.getHTML();
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,8 @@ import React from 'react';
|
|||||||
import { createRoot } from 'react-dom/client';
|
import { createRoot } from 'react-dom/client';
|
||||||
|
|
||||||
import '../shared/ui/globals.css';
|
import '../shared/ui/globals.css';
|
||||||
|
import { WindowErrorBoundary } from '../shared/ui/WindowErrorBoundary';
|
||||||
|
|
||||||
import { PresentationApp } from './PresentationApp';
|
import { PresentationApp } from './PresentationApp';
|
||||||
|
|
||||||
const rootEl = document.getElementById('root');
|
const rootEl = document.getElementById('root');
|
||||||
@@ -11,6 +13,8 @@ if (!rootEl) {
|
|||||||
|
|
||||||
createRoot(rootEl).render(
|
createRoot(rootEl).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<PresentationApp />
|
<WindowErrorBoundary title="Презентация">
|
||||||
|
<PresentationApp />
|
||||||
|
</WindowErrorBoundary>
|
||||||
</React.StrictMode>,
|
</React.StrictMode>,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { createRoot } from 'react-dom/client';
|
|||||||
|
|
||||||
import '../shared/ui/globals.css';
|
import '../shared/ui/globals.css';
|
||||||
import { EditorI18nProvider } from '../editor/i18n/EditorI18nContext';
|
import { EditorI18nProvider } from '../editor/i18n/EditorI18nContext';
|
||||||
|
import { WindowErrorBoundary } from '../shared/ui/WindowErrorBoundary';
|
||||||
|
|
||||||
import { SceneDescriptionApp } from './SceneDescriptionApp';
|
import { SceneDescriptionApp } from './SceneDescriptionApp';
|
||||||
|
|
||||||
@@ -13,8 +14,10 @@ if (!rootEl) {
|
|||||||
|
|
||||||
createRoot(rootEl).render(
|
createRoot(rootEl).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<EditorI18nProvider>
|
<WindowErrorBoundary title="Описание сцены">
|
||||||
<SceneDescriptionApp />
|
<EditorI18nProvider>
|
||||||
</EditorI18nProvider>
|
<SceneDescriptionApp />
|
||||||
|
</EditorI18nProvider>
|
||||||
|
</WindowErrorBoundary>
|
||||||
</React.StrictMode>,
|
</React.StrictMode>,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { createRoot } from 'react-dom/client';
|
|||||||
|
|
||||||
import '../shared/ui/globals.css';
|
import '../shared/ui/globals.css';
|
||||||
import { EditorI18nProvider } from '../editor/i18n/EditorI18nContext';
|
import { EditorI18nProvider } from '../editor/i18n/EditorI18nContext';
|
||||||
|
import { WindowErrorBoundary } from '../shared/ui/WindowErrorBoundary';
|
||||||
|
|
||||||
import { SceneEditorApp } from './SceneEditorApp';
|
import { SceneEditorApp } from './SceneEditorApp';
|
||||||
|
|
||||||
@@ -13,8 +14,10 @@ if (!rootEl) {
|
|||||||
|
|
||||||
createRoot(rootEl).render(
|
createRoot(rootEl).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<EditorI18nProvider>
|
<WindowErrorBoundary title="Редактор сцены">
|
||||||
<SceneEditorApp />
|
<EditorI18nProvider>
|
||||||
</EditorI18nProvider>
|
<SceneEditorApp />
|
||||||
|
</EditorI18nProvider>
|
||||||
|
</WindowErrorBoundary>
|
||||||
</React.StrictMode>,
|
</React.StrictMode>,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
type WindowErrorBoundaryProps = {
|
||||||
|
title?: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
type WindowErrorBoundaryState = {
|
||||||
|
error: Error | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ловит падения React в дочерних окнах (НПС, материалы, редактор сцены…),
|
||||||
|
* чтобы вместо чёрного экрана показать сообщение и кнопку перезагрузки.
|
||||||
|
*/
|
||||||
|
export class WindowErrorBoundary extends React.Component<
|
||||||
|
WindowErrorBoundaryProps,
|
||||||
|
WindowErrorBoundaryState
|
||||||
|
> {
|
||||||
|
state: WindowErrorBoundaryState = { error: null };
|
||||||
|
|
||||||
|
static getDerivedStateFromError(error: Error): WindowErrorBoundaryState {
|
||||||
|
return { error };
|
||||||
|
}
|
||||||
|
|
||||||
|
override componentDidCatch(error: Error, info: React.ErrorInfo): void {
|
||||||
|
console.error('[WindowErrorBoundary]', error, info.componentStack);
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleReload = (): void => {
|
||||||
|
this.setState({ error: null });
|
||||||
|
window.location.reload();
|
||||||
|
};
|
||||||
|
|
||||||
|
override render(): React.ReactNode {
|
||||||
|
if (!this.state.error) return this.props.children;
|
||||||
|
|
||||||
|
const title = this.props.title ?? 'Ошибка окна';
|
||||||
|
const message = this.state.error.message || String(this.state.error);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="alert"
|
||||||
|
style={{
|
||||||
|
height: '100vh',
|
||||||
|
boxSizing: 'border-box',
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
gap: 12,
|
||||||
|
padding: 24,
|
||||||
|
background: '#09090b',
|
||||||
|
color: '#e4e4e7',
|
||||||
|
fontFamily: 'system-ui, sans-serif',
|
||||||
|
textAlign: 'center',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ fontWeight: 800, fontSize: 16 }}>{title}</div>
|
||||||
|
<div style={{ opacity: 0.85, fontSize: 13, maxWidth: 480, lineHeight: 1.45 }}>{message}</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={this.handleReload}
|
||||||
|
style={{
|
||||||
|
marginTop: 8,
|
||||||
|
height: 34,
|
||||||
|
padding: '0 14px',
|
||||||
|
borderRadius: 8,
|
||||||
|
border: '1px solid rgba(255,255,255,0.16)',
|
||||||
|
background: '#27272a',
|
||||||
|
color: '#fafafa',
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Перезагрузить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -122,9 +122,10 @@ type InputProps = {
|
|||||||
onChange: (v: string) => void;
|
onChange: (v: string) => void;
|
||||||
autoFocus?: boolean;
|
autoFocus?: boolean;
|
||||||
onKeyDown?: (e: React.KeyboardEvent<HTMLInputElement>) => void;
|
onKeyDown?: (e: React.KeyboardEvent<HTMLInputElement>) => void;
|
||||||
|
onBlur?: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function Input({ value, placeholder, onChange, autoFocus, onKeyDown }: InputProps) {
|
export function Input({ value, placeholder, onChange, autoFocus, onKeyDown, onBlur }: InputProps) {
|
||||||
return (
|
return (
|
||||||
<input
|
<input
|
||||||
className={styles.input}
|
className={styles.input}
|
||||||
@@ -133,6 +134,7 @@ export function Input({ value, placeholder, onChange, autoFocus, onKeyDown }: In
|
|||||||
autoFocus={autoFocus}
|
autoFocus={autoFocus}
|
||||||
onChange={(e) => onChange(e.target.value)}
|
onChange={(e) => onChange(e.target.value)}
|
||||||
onKeyDown={onKeyDown}
|
onKeyDown={onKeyDown}
|
||||||
|
onBlur={onBlur}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import test from 'node:test';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
|
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const rendererRoot = path.resolve(here, '../..');
|
||||||
|
|
||||||
|
const SECONDARY_WINDOW_MAINS = [
|
||||||
|
'npcs/npcsEditorMain.tsx',
|
||||||
|
'npcs/npcsMain.tsx',
|
||||||
|
'materials/main.tsx',
|
||||||
|
'sceneEditor/main.tsx',
|
||||||
|
'sceneDescription/main.tsx',
|
||||||
|
'control/main.tsx',
|
||||||
|
'presentation/main.tsx',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
void test('secondary window mains: WindowErrorBoundary wraps app root', () => {
|
||||||
|
for (const rel of SECONDARY_WINDOW_MAINS) {
|
||||||
|
const src = fs.readFileSync(path.join(rendererRoot, rel), 'utf8');
|
||||||
|
assert.ok(
|
||||||
|
src.includes('WindowErrorBoundary'),
|
||||||
|
`${rel}: must wrap with WindowErrorBoundary to avoid black screen on React crash`,
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
src,
|
||||||
|
/<WindowErrorBoundary[\s\S]*?>[\s\S]*<\/WindowErrorBoundary>/,
|
||||||
|
`${rel}: WindowErrorBoundary must wrap children`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('NpcDescriptionField: TipTap StrictMode-safe (no black screen on NPC open)', () => {
|
||||||
|
const src = fs.readFileSync(path.join(rendererRoot, 'npcs/NpcDescriptionField.tsx'), 'utf8');
|
||||||
|
assert.match(src, /immediatelyRender:\s*false/);
|
||||||
|
assert.ok(src.includes('readTipTapHtmlSafe'));
|
||||||
|
assert.ok(src.includes('isDestroyed'));
|
||||||
|
assert.doesNotMatch(src, /immediatelyRender:\s*true/);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('NpcsEditorApp: no undefined controlStyles (inspector crash)', () => {
|
||||||
|
const src = fs.readFileSync(path.join(rendererRoot, 'npcs/NpcsEditorApp.tsx'), 'utf8');
|
||||||
|
assert.doesNotMatch(src, /controlStyles/);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('WindowErrorBoundary component exists and catches errors', () => {
|
||||||
|
const src = fs.readFileSync(path.join(here, 'WindowErrorBoundary.tsx'), 'utf8');
|
||||||
|
assert.ok(src.includes('getDerivedStateFromError'));
|
||||||
|
assert.ok(src.includes('componentDidCatch'));
|
||||||
|
assert.ok(src.includes('role="alert"'));
|
||||||
|
});
|
||||||
@@ -116,6 +116,7 @@ export const ipcChannels = {
|
|||||||
exportZipProgress: 'project.exportZipProgress',
|
exportZipProgress: 'project.exportZipProgress',
|
||||||
scenePreviewImportProgress: 'project.scenePreviewImportProgress',
|
scenePreviewImportProgress: 'project.scenePreviewImportProgress',
|
||||||
materialUpsertProgress: 'project.materialUpsertProgress',
|
materialUpsertProgress: 'project.materialUpsertProgress',
|
||||||
|
npcUpsertProgress: 'project.npcUpsertProgress',
|
||||||
},
|
},
|
||||||
windows: {
|
windows: {
|
||||||
openMultiWindow: 'windows.openMultiWindow',
|
openMultiWindow: 'windows.openMultiWindow',
|
||||||
@@ -217,6 +218,8 @@ export type MaterialUpsertProgressEvent = {
|
|||||||
detail?: string;
|
detail?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type NpcUpsertProgressEvent = MaterialUpsertProgressEvent;
|
||||||
|
|
||||||
export type UpdaterCheckResponse =
|
export type UpdaterCheckResponse =
|
||||||
| { outcome: 'not_packaged' }
|
| { outcome: 'not_packaged' }
|
||||||
| { outcome: 'no_license' }
|
| { outcome: 'no_license' }
|
||||||
@@ -260,6 +263,7 @@ export type IpcEventMap = {
|
|||||||
[ipcChannels.project.exportZipProgress]: ZipProgressEvent;
|
[ipcChannels.project.exportZipProgress]: ZipProgressEvent;
|
||||||
[ipcChannels.project.scenePreviewImportProgress]: ScenePreviewImportEvent;
|
[ipcChannels.project.scenePreviewImportProgress]: ScenePreviewImportEvent;
|
||||||
[ipcChannels.project.materialUpsertProgress]: MaterialUpsertProgressEvent;
|
[ipcChannels.project.materialUpsertProgress]: MaterialUpsertProgressEvent;
|
||||||
|
[ipcChannels.project.npcUpsertProgress]: NpcUpsertProgressEvent;
|
||||||
[ipcChannels.updater.progress]: UpdaterProgressEvent;
|
[ipcChannels.updater.progress]: UpdaterProgressEvent;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -10,7 +10,7 @@
|
|||||||
"build:obfuscate": "node scripts/build.mjs --production --obfuscate",
|
"build:obfuscate": "node scripts/build.mjs --production --obfuscate",
|
||||||
"lint": "eslint . --max-warnings 0",
|
"lint": "eslint . --max-warnings 0",
|
||||||
"typecheck": "tsc -p tsconfig.eslint.json --noEmit",
|
"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/renderer/editor/help/helpLinkify.test.ts app/renderer/editor/sceneDescriptionHtml.test.ts app/shared/graph/storylineExportImport.test.ts app/shared/foundry/foundryGraph.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/control/controlApp.audioPerf.networkRegression.test.ts app/renderer/control/controlApp.brushPerf.networkRegression.test.ts app/renderer/shared/useAssetImageUrl.cache.test.ts app/main/sessionIpc.phase6.networkRegression.test.ts app/renderer/shared/sceneOverlay/sceneOverlayHost.test.ts app/renderer/shared/effects/PxiEffectsOverlay.pointer.test.ts app/renderer/shared/traps/trapActivation.test.ts app/main/windows/createWindows.editorClose.test.ts app/main/safeConsole.test.ts app/main/windows/bootWindow.test.ts app/main/effects/effectsStore.test.ts app/main/effects/sceneDarknessStore.test.ts app/main/sceneTraps/sceneTrapsStore.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 app/main/license/machineFingerprint.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/shared/ui/secondaryWindows.stability.test.ts app/renderer/npcs/tiptapEditorSafe.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/help/helpLinkify.test.ts app/renderer/editor/sceneDescriptionHtml.test.ts app/shared/graph/storylineExportImport.test.ts app/shared/foundry/foundryGraph.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/control/controlApp.audioPerf.networkRegression.test.ts app/renderer/control/controlApp.brushPerf.networkRegression.test.ts app/renderer/shared/useAssetImageUrl.cache.test.ts app/main/sessionIpc.phase6.networkRegression.test.ts app/renderer/shared/sceneOverlay/sceneOverlayHost.test.ts app/renderer/shared/effects/PxiEffectsOverlay.pointer.test.ts app/renderer/shared/traps/trapActivation.test.ts app/main/windows/createWindows.editorClose.test.ts app/main/safeConsole.test.ts app/main/windows/bootWindow.test.ts app/main/effects/effectsStore.test.ts app/main/effects/sceneDarknessStore.test.ts app/main/sceneTraps/sceneTrapsStore.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 app/main/license/machineFingerprint.test.ts && node --test scripts/build-env.test.mjs scripts/obfuscate-main.test.mjs scripts/release-mac-prep.test.mjs",
|
||||||
"format": "prettier . --check",
|
"format": "prettier . --check",
|
||||||
"format:write": "prettier . --write",
|
"format:write": "prettier . --write",
|
||||||
"postinstall": "patch-package",
|
"postinstall": "patch-package",
|
||||||
|
|||||||
Reference in New Issue
Block a user