feat: multi-material overlays, presentation guides, and release prebuilds

Align control/presentation with presentation screen rect and darkness z-order; sync window titles and session window cleanup. Pack Win/Mac/Linux with npmRebuild disabled and release-native-prep for classic-level and sharp.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Ivan Fontosh
2026-08-03 11:28:18 +08:00
parent 61446dacfc
commit 2979d06f1c
41 changed files with 1060 additions and 252 deletions
+10
View File
@@ -47,6 +47,7 @@ import {
createEditorWindowDeferred, createEditorWindowDeferred,
createWindows, createWindows,
focusEditorWindow, focusEditorWindow,
getPresentationContentSize,
getSceneDescriptionContent, getSceneDescriptionContent,
isMultiWindowOpen, isMultiWindowOpen,
markAppQuitting, markAppQuitting,
@@ -61,6 +62,7 @@ import {
closeSceneEditorWindow, closeSceneEditorWindow,
closeNpcsWindow, closeNpcsWindow,
sendToAppWindows, sendToAppWindows,
syncAllWindowChromeTitles,
togglePresentationFullscreen, togglePresentationFullscreen,
waitForEditorWindowReady, waitForEditorWindowReady,
warmNpcsEditorWindow, warmNpcsEditorWindow,
@@ -414,6 +416,10 @@ async function main() {
closeMultiWindow(); closeMultiWindow();
return { ok: true }; return { ok: true };
}); });
registerHandler(ipcChannels.windows.syncChromeTitles, ({ localeTag }) => {
syncAllWindowChromeTitles(localeTag);
return { ok: true };
});
registerHandler(ipcChannels.windows.togglePresentationFullscreen, () => { registerHandler(ipcChannels.windows.togglePresentationFullscreen, () => {
const isFullScreen = togglePresentationFullscreen(); const isFullScreen = togglePresentationFullscreen();
return { ok: true, isFullScreen }; return { ok: true, isFullScreen };
@@ -421,6 +427,10 @@ async function main() {
registerHandler(ipcChannels.windows.getMultiWindowState, () => { registerHandler(ipcChannels.windows.getMultiWindowState, () => {
return { open: isMultiWindowOpen() }; return { open: isMultiWindowOpen() };
}); });
registerHandler(ipcChannels.windows.getPresentationContentSize, () => {
const size = getPresentationContentSize();
return size ?? { width: null, height: null };
});
registerHandler(ipcChannels.windows.openSceneDescription, ({ html }) => { registerHandler(ipcChannels.windows.openSceneDescription, ({ html }) => {
openSceneDescriptionWindow(html); openSceneDescriptionWindow(html);
return { ok: true }; return { ok: true };
+120 -38
View File
@@ -1,6 +1,7 @@
import { import {
clampMaterialsLayout, clampMaterialsLayout,
DEFAULT_MATERIALS_OVERLAY_LAYOUT, DEFAULT_MATERIALS_OVERLAY_LAYOUT,
defaultLegendLayoutForMaterial,
type MaterialId, type MaterialId,
type MaterialsOverlayEvent, type MaterialsOverlayEvent,
type MaterialsOverlayLayout, type MaterialsOverlayLayout,
@@ -12,9 +13,10 @@ import {
function emptyState(): MaterialsOverlayState { function emptyState(): MaterialsOverlayState {
return { return {
revision: 1, revision: 1,
activeMaterialId: null, activeMaterialIds: [],
layout: { ...DEFAULT_MATERIALS_OVERLAY_LAYOUT }, layouts: {},
legendLayout: { ...DEFAULT_MATERIALS_OVERLAY_LAYOUT, cx: 0.82, cy: 0.5, scale: 0.85 }, legendLayouts: {},
focusMaterialId: null,
zoomTool: null, zoomTool: null,
}; };
} }
@@ -26,14 +28,12 @@ function initialLayout(rotationDeg?: number): MaterialsOverlayLayout {
}); });
} }
function initialLegendLayout(): MaterialsOverlayLayout { function layoutFor(state: MaterialsOverlayState, materialId: MaterialId): MaterialsOverlayLayout {
return clampMaterialsLayout({ return state.layouts[materialId] ?? { ...DEFAULT_MATERIALS_OVERLAY_LAYOUT };
...DEFAULT_MATERIALS_OVERLAY_LAYOUT, }
cx: 0.82,
cy: 0.5, function legendLayoutFor(state: MaterialsOverlayState, materialId: MaterialId): MaterialsOverlayLayout {
scale: 0.85, return state.legendLayouts[materialId] ?? defaultLegendLayoutForMaterial();
rotationDeg: 0,
});
} }
export class MaterialsOverlayStore { export class MaterialsOverlayStore {
@@ -44,14 +44,15 @@ export class MaterialsOverlayStore {
} }
clear(): MaterialsOverlayState { clear(): MaterialsOverlayState {
if (this.state.activeMaterialId === null && this.state.zoomTool === null) { if (this.state.activeMaterialIds.length === 0 && this.state.zoomTool === null) {
return this.state; return this.state;
} }
this.state = { this.state = {
revision: this.state.revision + 1, revision: this.state.revision + 1,
activeMaterialId: null, activeMaterialIds: [],
layout: { ...DEFAULT_MATERIALS_OVERLAY_LAYOUT }, layouts: {},
legendLayout: initialLegendLayout(), legendLayouts: {},
focusMaterialId: null,
zoomTool: null, zoomTool: null,
}; };
return this.state; return this.state;
@@ -61,50 +62,98 @@ export class MaterialsOverlayStore {
switch (event.kind) { switch (event.kind) {
case 'hide': case 'hide':
return this.clear(); return this.clear();
case 'show': case 'show': {
if (this.state.activeMaterialIds.includes(event.materialId)) {
this.state = { this.state = {
...this.state,
revision: this.state.revision + 1, revision: this.state.revision + 1,
activeMaterialId: event.materialId, focusMaterialId: event.materialId,
layout: initialLayout(event.rotationDeg),
legendLayout: initialLegendLayout(),
zoomTool: this.state.zoomTool,
}; };
return this.state; return this.state;
case 'toggle': {
if (this.state.activeMaterialId === event.materialId) {
return this.clear();
} }
this.state = { this.state = {
revision: this.state.revision + 1, revision: this.state.revision + 1,
activeMaterialId: event.materialId, activeMaterialIds: [...this.state.activeMaterialIds, event.materialId],
layout: initialLayout(event.rotationDeg), layouts: {
legendLayout: initialLegendLayout(), ...this.state.layouts,
[event.materialId]: initialLayout(event.rotationDeg),
},
legendLayouts: {
...this.state.legendLayouts,
[event.materialId]: defaultLegendLayoutForMaterial(),
},
focusMaterialId: event.materialId,
zoomTool: this.state.zoomTool,
};
return this.state;
}
case 'toggle': {
if (this.state.activeMaterialIds.includes(event.materialId)) {
const activeMaterialIds = this.state.activeMaterialIds.filter((id) => id !== event.materialId);
const layouts = { ...this.state.layouts };
const legendLayouts = { ...this.state.legendLayouts };
delete layouts[event.materialId];
delete legendLayouts[event.materialId];
const focusMaterialId =
this.state.focusMaterialId === event.materialId
? (activeMaterialIds[activeMaterialIds.length - 1] ?? null)
: this.state.focusMaterialId;
this.state = {
revision: this.state.revision + 1,
activeMaterialIds,
layouts,
legendLayouts,
focusMaterialId,
zoomTool: activeMaterialIds.length === 0 ? null : this.state.zoomTool,
};
return this.state;
}
this.state = {
revision: this.state.revision + 1,
activeMaterialIds: [...this.state.activeMaterialIds, event.materialId],
layouts: {
...this.state.layouts,
[event.materialId]: initialLayout(event.rotationDeg),
},
legendLayouts: {
...this.state.legendLayouts,
[event.materialId]: defaultLegendLayoutForMaterial(),
},
focusMaterialId: event.materialId,
zoomTool: this.state.zoomTool, zoomTool: this.state.zoomTool,
}; };
return this.state; return this.state;
} }
case 'layout.set': { case 'layout.set': {
if (this.state.activeMaterialId === null) return this.state; if (!this.state.activeMaterialIds.includes(event.materialId)) return this.state;
this.state = { this.state = {
...this.state, ...this.state,
revision: this.state.revision + 1, revision: this.state.revision + 1,
layout: clampMaterialsLayout({ focusMaterialId: event.materialId,
...this.state.layout, layouts: {
...this.state.layouts,
[event.materialId]: clampMaterialsLayout({
...layoutFor(this.state, event.materialId),
...event.layout, ...event.layout,
}), }),
},
}; };
return this.state; return this.state;
} }
case 'legendLayout.set': { case 'legendLayout.set': {
if (this.state.activeMaterialId === null) return this.state; if (!this.state.activeMaterialIds.includes(event.materialId)) return this.state;
this.state = { this.state = {
...this.state, ...this.state,
revision: this.state.revision + 1, revision: this.state.revision + 1,
legendLayout: clampMaterialsLayout({ focusMaterialId: event.materialId,
...this.state.legendLayout, legendLayouts: {
...this.state.legendLayouts,
[event.materialId]: clampMaterialsLayout({
...legendLayoutFor(this.state, event.materialId),
...event.layout, ...event.layout,
rotationDeg: 0, rotationDeg: 0,
}), }),
},
}; };
return this.state; return this.state;
} }
@@ -118,10 +167,18 @@ export class MaterialsOverlayStore {
return this.state; return this.state;
} }
case 'zoomAt': { case 'zoomAt': {
if (this.state.activeMaterialId === null || !this.state.zoomTool) return this.state; if (this.state.activeMaterialIds.length === 0 || !this.state.zoomTool) return this.state;
const targetId =
(event.materialId && this.state.activeMaterialIds.includes(event.materialId)
? event.materialId
: null) ??
this.state.focusMaterialId ??
this.state.activeMaterialIds[this.state.activeMaterialIds.length - 1] ??
null;
if (!targetId) return this.state;
const factor = this.state.zoomTool === 'zoomIn' ? 1.25 : 1 / 1.25; const factor = this.state.zoomTool === 'zoomIn' ? 1.25 : 1 / 1.25;
const layout: MaterialsOverlayLayout = zoomMaterialsLayoutAt( const layout: MaterialsOverlayLayout = zoomMaterialsLayoutAt(
this.state.layout, layoutFor(this.state, targetId),
event.nx, event.nx,
event.ny, event.ny,
factor, factor,
@@ -129,7 +186,11 @@ export class MaterialsOverlayStore {
this.state = { this.state = {
...this.state, ...this.state,
revision: this.state.revision + 1, revision: this.state.revision + 1,
layout, focusMaterialId: targetId,
layouts: {
...this.state.layouts,
[targetId]: layout,
},
}; };
return this.state; return this.state;
} }
@@ -139,8 +200,29 @@ export class MaterialsOverlayStore {
} }
ensureMaterialStillExists(materialIds: ReadonlySet<MaterialId>): MaterialsOverlayState { ensureMaterialStillExists(materialIds: ReadonlySet<MaterialId>): MaterialsOverlayState {
const active = this.state.activeMaterialId; const activeMaterialIds = this.state.activeMaterialIds.filter((id) => materialIds.has(id));
if (active === null || materialIds.has(active)) return this.state; if (activeMaterialIds.length === this.state.activeMaterialIds.length) return this.state;
return this.clear(); if (activeMaterialIds.length === 0) return this.clear();
const layouts: Record<string, MaterialsOverlayLayout> = {};
const legendLayouts: Record<string, MaterialsOverlayLayout> = {};
for (const id of activeMaterialIds) {
const layout = this.state.layouts[id];
if (layout) layouts[id] = layout;
const legend = this.state.legendLayouts[id];
if (legend) legendLayouts[id] = legend;
}
const focusMaterialId =
this.state.focusMaterialId && activeMaterialIds.includes(this.state.focusMaterialId)
? this.state.focusMaterialId
: (activeMaterialIds[activeMaterialIds.length - 1] ?? null);
this.state = {
revision: this.state.revision + 1,
activeMaterialIds,
layouts,
legendLayouts,
focusMaterialId,
zoomTool: this.state.zoomTool,
};
return this.state;
} }
} }
+1 -1
View File
@@ -1044,7 +1044,7 @@ export class ZipProjectStore {
for (const asset of staged) { for (const asset of staged) {
assets[asset.id] = asset; assets[asset.id] = asset;
if (asset.type !== 'audio') continue; if (asset.type !== 'audio') continue;
campaignAudios.push({ assetId: asset.id, autoplay: true, loop: true }); campaignAudios.push({ assetId: asset.id, autoplay: false, loop: false });
} }
return { ...p, assets, campaignAudios }; return { ...p, assets, campaignAudios };
}); });
@@ -41,7 +41,7 @@ void test('createWindows: окно описания сцены закрывае
assert.ok(src.includes('closeSceneDescriptionWindow')); assert.ok(src.includes('closeSceneDescriptionWindow'));
assert.ok(src.includes("createWindow('sceneDescription'")); assert.ok(src.includes("createWindow('sceneDescription'"));
assert.match(src, /export function closeMultiWindow[\s\S]*closeSceneDescriptionWindow/); assert.match(src, /export function closeMultiWindow[\s\S]*closeSceneDescriptionWindow/);
assert.match(src, /kind !== 'presentation' && kind !== 'control'[\s\S]*closeSceneDescriptionWindow/); assert.match(src, /kind !== 'presentation'[\s\S]*closeSceneDescriptionWindow/);
}); });
void test('createWindows: окно материалов закрывается с multi-window', () => { void test('createWindows: окно материалов закрывается с multi-window', () => {
@@ -63,6 +63,12 @@ void test('createWindows: окно НПС закрывается с multi-window
assert.match(src, /export function closeMultiWindow[\s\S]*closeNpcsWindow/); assert.match(src, /export function closeMultiWindow[\s\S]*closeNpcsWindow/);
}); });
void test('createWindows: закрытие пульта закрывает сессионные окна и презентацию', () => {
const src = readCreateWindows();
assert.match(src, /kind === 'control'/);
assert.match(src, /closePlaySessionAuxiliaryWindows[\s\S]*closePresentationWindow/);
});
void test('createWindows: production — loadFile для HTML (не только file://)', () => { void test('createWindows: production — loadFile для HTML (не только file://)', () => {
const src = readCreateWindows(); const src = readCreateWindows();
assert.ok(src.includes('loadFile')); assert.ok(src.includes('loadFile'));
+112 -10
View File
@@ -2,7 +2,7 @@ import path from 'node:path';
import { app, BrowserWindow, screen } from 'electron'; import { app, BrowserWindow, screen } from 'electron';
import { windowChromeTitle } from '../../shared/appBranding'; import { windowChromeTitle, type AppWindowKind } from '../../shared/appBranding';
import { ipcChannels } from '../../shared/ipc/contracts'; import { ipcChannels } from '../../shared/ipc/contracts';
import { safeConsoleError } from '../safeConsole'; import { safeConsoleError } from '../safeConsole';
@@ -32,7 +32,16 @@ export const SESSION_STATE_WINDOW_KINDS: readonly WindowKind[] = [
const windows = new Map<WindowKind, BrowserWindow>(); const windows = new Map<WindowKind, BrowserWindow>();
/** Язык заголовков окон (из редактора); иначе `app.getLocale()`. */
let chromeLocaleTagOverride: string | null = null;
function resolveChromeLocaleTag(): string {
return chromeLocaleTagOverride ?? app.getLocale();
}
let appQuitting = false; let appQuitting = false;
/** Защита от каскада close(control) ↔ close(presentation). */
let closingPlaySession = false;
let pendingSceneDescriptionHtml = ''; let pendingSceneDescriptionHtml = '';
/** Окно материалов — только колонка списка. */ /** Окно материалов — только колонка списка. */
@@ -60,6 +69,35 @@ function broadcastMultiWindowStateChanged(open: boolean): void {
} }
} }
export function getPresentationContentSize(): { width: number; height: number } | null {
const pres = windows.get('presentation');
if (!pres || pres.isDestroyed()) return null;
const [width, height] = pres.getContentSize();
if (width <= 0 || height <= 0) return null;
return { width, height };
}
function broadcastPresentationContentSize(): void {
const size = getPresentationContentSize();
if (!size) return;
for (const w of BrowserWindow.getAllWindows()) {
if (w.isDestroyed() || w.webContents.isDestroyed()) continue;
try {
w.webContents.send(ipcChannels.windows.presentationContentSizeChanged, size);
} catch {
/* ignore */
}
}
}
function bindPresentationContentSizeTracking(win: BrowserWindow): void {
const emit = () => broadcastPresentationContentSize();
win.on('resize', emit);
win.on('enter-full-screen', emit);
win.on('leave-full-screen', emit);
win.webContents.once('did-finish-load', emit);
}
function sendSceneDescriptionContent(win: BrowserWindow, html: string): void { function sendSceneDescriptionContent(win: BrowserWindow, html: string): void {
if (win.isDestroyed() || win.webContents.isDestroyed()) return; if (win.isDestroyed() || win.webContents.isDestroyed()) return;
try { try {
@@ -91,6 +129,51 @@ export function sendToAppWindows(
} }
} }
function applyWindowChromeTitle(win: BrowserWindow, kind: WindowKind): void {
if (win.isDestroyed()) return;
win.setTitle(windowChromeTitle(kind as AppWindowKind, resolveChromeLocaleTag()));
}
export function syncAllWindowChromeTitles(localeTag: string): void {
chromeLocaleTagOverride = localeTag.trim() || null;
for (const [kind, win] of windows.entries()) {
applyWindowChromeTitle(win, kind);
}
}
function bindWindowChromeTitle(win: BrowserWindow, kind: WindowKind): void {
const apply = () => applyWindowChromeTitle(win, kind);
apply();
win.webContents.on('page-title-updated', (event) => {
event.preventDefault();
apply();
});
win.webContents.on('did-finish-load', () => {
apply();
});
}
/** Закрыть окна сессии, кроме редактора и его дочерних окон. */
function closePlaySessionAuxiliaryWindows(): void {
closeSceneDescriptionWindow();
closeMaterialsWindow();
closeNpcsWindow();
}
function closePresentationWindow(): void {
const pres = windows.get('presentation');
if (pres && !pres.isDestroyed()) {
pres.close();
}
}
function closeControlWindow(): void {
const ctrl = windows.get('control');
if (ctrl && !ctrl.isDestroyed()) {
ctrl.close();
}
}
function quitAppFromEditorClose(): void { function quitAppFromEditorClose(): void {
markAppQuitting(); markAppQuitting();
app.quit(); app.quit();
@@ -272,7 +355,7 @@ function createWindow(kind: WindowKind, opts?: CreateWindowOpts): BrowserWindow
} }
} }
win.setTitle(windowChromeTitle(kind, app.getLocale())); bindWindowChromeTitle(win, kind);
if ( if (
kind === 'sceneDescription' || kind === 'sceneDescription' ||
kind === 'materials' || kind === 'materials' ||
@@ -307,14 +390,30 @@ function createWindow(kind: WindowKind, opts?: CreateWindowOpts): BrowserWindow
quitAppFromEditorClose(); quitAppFromEditorClose();
}); });
} }
if (kind === 'control') {
win.on('close', () => {
if (appQuitting || closingPlaySession) return;
closingPlaySession = true;
closePlaySessionAuxiliaryWindows();
closePresentationWindow();
});
}
if (kind === 'presentation') {
bindPresentationContentSizeTracking(win);
win.on('close', () => {
if (appQuitting || closingPlaySession) return;
closingPlaySession = true;
closePlaySessionAuxiliaryWindows();
closeControlWindow();
});
}
win.on('closed', () => windows.delete(kind)); win.on('closed', () => windows.delete(kind));
win.on('closed', () => { win.on('closed', () => {
if (kind !== 'presentation' && kind !== 'control') return; if (kind !== 'presentation' && kind !== 'control') return;
const open = windows.has('presentation') || windows.has('control'); const open = windows.has('presentation') || windows.has('control');
if (!open) { if (!open) {
closeSceneDescriptionWindow(); closingPlaySession = false;
closeMaterialsWindow(); closePlaySessionAuxiliaryWindows();
closeNpcsWindow();
} }
broadcastMultiWindowStateChanged(open); broadcastMultiWindowStateChanged(open);
}); });
@@ -395,16 +494,19 @@ export function openMultiWindow() {
createWindow('control', process.platform === 'darwin' ? undefined : { parent: presentation }); createWindow('control', process.platform === 'darwin' ? undefined : { parent: presentation });
} }
broadcastMultiWindowStateChanged(true); broadcastMultiWindowStateChanged(true);
broadcastPresentationContentSize();
} }
export function closeMultiWindow(): void { export function closeMultiWindow(): void {
closeSceneDescriptionWindow(); closingPlaySession = true;
closeMaterialsWindow(); closePlaySessionAuxiliaryWindows();
closeNpcsWindow();
const pres = windows.get('presentation'); const pres = windows.get('presentation');
const ctrl = windows.get('control'); const ctrl = windows.get('control');
if (pres) pres.close(); if (pres && !pres.isDestroyed()) pres.close();
if (ctrl) ctrl.close(); if (ctrl && !ctrl.isDestroyed()) ctrl.close();
if (!windows.has('presentation') && !windows.has('control')) {
closingPlaySession = false;
}
} }
export function isMultiWindowOpen(): boolean { export function isMultiWindowOpen(): boolean {
+5 -1
View File
@@ -271,6 +271,7 @@
.historyTitle { .historyTitle {
font-weight: 800; font-weight: 800;
min-width: 0;
} }
.emptyStory { .emptyStory {
@@ -356,7 +357,7 @@
.branchGrid { .branchGrid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px; gap: 12px;
} }
@@ -367,6 +368,8 @@
padding: 12px; padding: 12px;
display: grid; display: grid;
gap: 10px; gap: 10px;
min-width: 0;
max-width: 100%;
} }
.branchCardHeader { .branchCardHeader {
@@ -383,6 +386,7 @@
.branchName { .branchName {
font-weight: 900; font-weight: 900;
min-width: 0;
} }
.branchCardReturn { .branchCardReturn {
+128 -37
View File
@@ -1,6 +1,7 @@
import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import React, { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { pickEraseTargetId } from '../../shared/effectEraserHitTest'; import { pickEraseTargetId } from '../../shared/effectEraserHitTest';
import { fitAspectRect } from '../../shared/geometry/fitAspectRect';
import { ipcChannels } from '../../shared/ipc/contracts'; import { ipcChannels } from '../../shared/ipc/contracts';
import type { SessionState } from '../../shared/ipc/contracts'; import type { SessionState } from '../../shared/ipc/contracts';
import { import {
@@ -8,7 +9,7 @@ import {
isNodeInSideStoryline, isNodeInSideStoryline,
listSideStoryStarts, listSideStoryStarts,
} from '../../shared/graph/sceneGraphLineage'; } from '../../shared/graph/sceneGraphLineage';
import type { GraphNodeId, Scene, SceneId, SceneViewCamera } from '../../shared/types'; import type { GraphNodeId, MaterialId, Scene, SceneId, SceneViewCamera } from '../../shared/types';
import { import {
DEFAULT_SCENE_VIEW_CAMERA, DEFAULT_SCENE_VIEW_CAMERA,
sceneViewPanBy, sceneViewPanBy,
@@ -42,6 +43,8 @@ import { useSceneTokensSession } from '../shared/tokens/useSceneTokensSession';
import { SceneTrapsOverlay } from '../shared/traps/SceneTrapsOverlay'; import { SceneTrapsOverlay } from '../shared/traps/SceneTrapsOverlay';
import { useSceneTrapsState } from '../shared/traps/useSceneTrapsState'; import { useSceneTrapsState } from '../shared/traps/useSceneTrapsState';
import { Button } from '../shared/ui/controls'; import { Button } from '../shared/ui/controls';
import { EllipsisText } from '../shared/ui/EllipsisText';
import ellipsisStyles from '../shared/ui/ellipsisText.module.css';
import { Surface } from '../shared/ui/Surface'; import { Surface } from '../shared/ui/Surface';
import { useAssetUrl } from '../shared/useAssetImageUrl'; import { useAssetUrl } from '../shared/useAssetImageUrl';
@@ -191,10 +194,25 @@ export function ControlApp() {
w: number; w: number;
h: number; h: number;
} | null>(null); } | null>(null);
const [presentationContentSize, setPresentationContentSize] = useState<{
width: number;
height: number;
} | null>(null);
const previewContentRectRef = useRef(previewContentRect); const previewContentRectRef = useRef(previewContentRect);
previewContentRectRef.current = previewContentRect; previewContentRectRef.current = previewContentRect;
const previewSizeRef = useRef(previewSize); const previewSizeRef = useRef(previewSize);
previewSizeRef.current = previewSize; previewSizeRef.current = previewSize;
const presentationScreenRect = useMemo(() => {
if (!presentationContentSize) return null;
if (previewSize.w <= 1 || previewSize.h <= 1) return null;
return fitAspectRect(
previewSize.w,
previewSize.h,
presentationContentSize.width,
presentationContentSize.height,
);
}, [presentationContentSize, previewSize.h, previewSize.w]);
const brushCursorElRef = useRef<HTMLDivElement | null>(null); const brushCursorElRef = useRef<HTMLDivElement | null>(null);
const cursorPosRef = useRef<{ x: number; y: number } | null>(null); const cursorPosRef = useRef<{ x: number; y: number } | null>(null);
const draftPaintRafRef = useRef(0); const draftPaintRafRef = useRef(0);
@@ -229,12 +247,32 @@ export function ControlApp() {
}, [api]); }, [api]);
useEffect(() => { useEffect(() => {
return api.on(ipcChannels.windows.multiWindowStateChanged, ({ open }) => { const refreshPresentationSize = () => {
void api.invoke(ipcChannels.windows.getPresentationContentSize, {}).then((size) => {
if (size.width == null || size.height == null) {
setPresentationContentSize(null);
return;
}
setPresentationContentSize({ width: size.width, height: size.height });
});
};
refreshPresentationSize();
const offSize = api.on(ipcChannels.windows.presentationContentSizeChanged, (size) => {
setPresentationContentSize({ width: size.width, height: size.height });
});
const offMw = api.on(ipcChannels.windows.multiWindowStateChanged, ({ open }) => {
if (!open) { if (!open) {
mainStoryReturnRef.current = null; mainStoryReturnRef.current = null;
setMainStoryReturnGraphNodeId(null); setMainStoryReturnGraphNodeId(null);
setPresentationContentSize(null);
return;
} }
refreshPresentationSize();
}); });
return () => {
offSize();
offMw();
};
}, [api]); }, [api]);
useEffect(() => { useEffect(() => {
@@ -1695,7 +1733,10 @@ export function ControlApp() {
) : ( ) : (
<div className={styles.historyMuted}>{t('control.passed')}</div> <div className={styles.historyMuted}>{t('control.passed')}</div>
)} )}
<div className={styles.historyTitle}>{s?.title ?? (gn ? String(gn.sceneId) : gnId)}</div> <EllipsisText
text={s?.title ?? (gn ? String(gn.sceneId) : gnId)}
className={[styles.historyTitle, ellipsisStyles.root].join(' ')}
/>
</button> </button>
); );
})} })}
@@ -1759,9 +1800,6 @@ export function ControlApp() {
draft={explosionDraft} draft={explosionDraft}
viewport={previewContentRect} viewport={previewContentRect}
/> />
{previewContentRect ? (
<SceneDarknessOverlay state={sdState} overlayAlpha={0.5} viewport={previewContentRect} />
) : null}
<div <div
ref={brushCursorElRef} ref={brushCursorElRef}
className={styles.brushCursor} className={styles.brushCursor}
@@ -1987,11 +2025,28 @@ export function ControlApp() {
</> </>
) : null} ) : null}
{(() => { {(() => {
const activeMaterial =
session?.project && materialsOverlay?.activeMaterialId
? (session.project.materials ?? []).find((m) => m.id === materialsOverlay.activeMaterialId)
: undefined;
const project = session?.project; const project = session?.project;
const materialIds = materialsOverlay?.activeMaterialIds ?? [];
const materialItems =
project && materialIds.length > 0
? materialIds
.map((id) => {
const material = (project.materials ?? []).find((m) => m.id === id);
if (!material) return null;
return {
material,
layout: materialsOverlay?.layouts[id] ?? DEFAULT_MATERIALS_OVERLAY_LAYOUT,
legendLayout:
materialsOverlay?.legendLayouts[id] ?? {
...DEFAULT_MATERIALS_OVERLAY_LAYOUT,
cx: 0.82,
cy: 0.5,
scale: 0.85,
},
};
})
.filter((x): x is NonNullable<typeof x> => x !== null)
: [];
const activeIds = npcsOverlay?.activeNpcIds ?? []; const activeIds = npcsOverlay?.activeNpcIds ?? [];
const npcItems = const npcItems =
project && activeIds.length > 0 project && activeIds.length > 0
@@ -2007,9 +2062,11 @@ export function ControlApp() {
}) })
.filter((x): x is NonNullable<typeof x> => x !== null) .filter((x): x is NonNullable<typeof x> => x !== null)
: []; : [];
const showMaterial = Boolean(activeMaterial); const showMaterial = materialItems.length > 0;
const showNpcs = npcItems.length > 0; const showNpcs = npcItems.length > 0;
if (!showMaterial && !showNpcs) return null; const screenRect = presentationScreenRect;
const showGuide = Boolean(screenRect) && !isVideoPreviewScene;
if (!showMaterial && !showNpcs && !showGuide) return null;
const closes = [ const closes = [
...(showMaterial ...(showMaterial
? [ ? [
@@ -2035,52 +2092,79 @@ export function ControlApp() {
: []), : []),
]; ];
const materialsZoom = materialsOverlay?.zoomTool ?? null; const materialsZoom = materialsOverlay?.zoomTool ?? null;
const materialIdFromTarget = (target: EventTarget | null) => {
if (!(target instanceof Element)) return undefined;
const frame = target.closest('[data-material-id]');
const raw = frame?.getAttribute('data-material-id');
return raw ? (raw as MaterialId) : undefined;
};
return ( return (
<>
{previewContentRect && currentScene?.darkenScene ? (
<SceneDarknessOverlay
state={sdState}
overlayAlpha={0.5}
viewport={previewContentRect}
style={{ zIndex: 30 }}
/>
) : null}
<SceneOverlayHost <SceneOverlayHost
active active={showMaterial || showNpcs}
viewport={screenRect}
showViewportGuide={showGuide}
zoomTool={materialsZoom} zoomTool={materialsZoom}
{...(materialsZoom {...(materialsZoom
? { ? {
onZoomAt: (nx: number, ny: number) => { onZoomAt: (nx: number, ny: number, evTarget?: EventTarget | null) => {
void materialsApi.dispatch({ kind: 'zoomAt', nx, ny }); const mid = materialIdFromTarget(evTarget ?? null);
void materialsApi.dispatch({
kind: 'zoomAt',
nx,
ny,
...(mid ? { materialId: mid } : {}),
});
}, },
} }
: {})} : {})}
closes={closes} closes={closes}
> >
{showMaterial && activeMaterial ? ( {materialItems.map(({ material, layout, legendLayout }) => (
<React.Fragment key={material.id}>
<MaterialOverlay <MaterialOverlay
embedded embedded
assetId={activeMaterial.assetId} assetId={material.assetId}
layout={materialsOverlay?.layout ?? DEFAULT_MATERIALS_OVERLAY_LAYOUT} materialId={material.id}
layout={layout}
editable editable
zoomTool={materialsZoom} zoomTool={materialsZoom}
rotateLabel={t('materials.rotateOverlay')} rotateLabel={t('materials.rotateOverlay')}
onLayoutChange={(layout) => { onLayoutChange={(nextLayout) => {
void materialsApi.dispatch({ kind: 'layout.set', layout }); void materialsApi.dispatch({
kind: 'layout.set',
materialId: material.id,
layout: nextLayout,
});
}} }}
{...(activeMaterial.legend?.enabled {...(material.legend?.enabled
? { legendMarkers: activeMaterial.legend.markers ?? [] } ? { legendMarkers: material.legend.markers ?? [] }
: {})} : {})}
/> />
) : null} {material.legend?.enabled ? (
{showMaterial && activeMaterial?.legend?.enabled ? (
<MaterialLegendPanel <MaterialLegendPanel
legend={activeMaterial.legend} legend={material.legend}
layout={ layout={legendLayout}
materialsOverlay?.legendLayout ?? {
...DEFAULT_MATERIALS_OVERLAY_LAYOUT,
cx: 0.82,
cy: 0.5,
scale: 0.85,
}
}
editable editable
onLayoutChange={(layout) => { onLayoutChange={(nextLayout) => {
void materialsApi.dispatch({ kind: 'legendLayout.set', layout }); void materialsApi.dispatch({
kind: 'legendLayout.set',
materialId: material.id,
layout: nextLayout,
});
}} }}
/> />
) : null} ) : null}
</React.Fragment>
))}
{showNpcs ? ( {showNpcs ? (
<NpcsSceneOverlay <NpcsSceneOverlay
embedded embedded
@@ -2093,6 +2177,7 @@ export function ControlApp() {
/> />
) : null} ) : null}
</SceneOverlayHost> </SceneOverlayHost>
</>
); );
})()} })()}
</div> </div>
@@ -2106,7 +2191,10 @@ export function ControlApp() {
<div className={styles.branchCardHeader}> <div className={styles.branchCardHeader}>
<div className={styles.branchOption}>{t('control.option', { n: '1' })}</div> <div className={styles.branchOption}>{t('control.option', { n: '1' })}</div>
</div> </div>
<div className={styles.branchName}>{returnSceneTitle}</div> <EllipsisText
text={returnSceneTitle}
className={[styles.branchName, ellipsisStyles.root].join(' ')}
/>
<Button variant="primary" onClick={returnToMainStoryline}> <Button variant="primary" onClick={returnToMainStoryline}>
{t('control.returnToMainStory')} {t('control.returnToMainStory')}
</Button> </Button>
@@ -2119,7 +2207,10 @@ export function ControlApp() {
{t('control.option', { n: String(i + 1 + branchOptionOffset) })} {t('control.option', { n: String(i + 1 + branchOptionOffset) })}
</div> </div>
</div> </div>
<div className={styles.branchName}>{o.scene.title || t('control.unnamed')}</div> <EllipsisText
text={o.scene.title || t('control.unnamed')}
className={[styles.branchName, ellipsisStyles.root].join(' ')}
/>
<Button <Button
variant="primary" variant="primary"
onClick={() => onClick={() =>
+10 -4
View File
@@ -1,4 +1,4 @@
import React, { useEffect, useMemo, useState } from 'react'; import React, { useEffect, useMemo, useRef, useState } from 'react';
import { computeTimeSec } from '../../main/video/videoPlaybackStore'; import { computeTimeSec } from '../../main/video/videoPlaybackStore';
import type { SessionState } from '../../shared/ipc/contracts'; import type { SessionState } from '../../shared/ipc/contracts';
@@ -35,6 +35,7 @@ export function ControlScenePreview({ session, videoRef, viewCamera = null, onCo
const isVideo = scene?.previewAssetType === 'video'; const isVideo = scene?.previewAssetType === 'video';
const assetId = scene?.previewAssetType === 'video' ? scene.previewAssetId : null; const assetId = scene?.previewAssetType === 'video' ? scene.previewAssetId : null;
const autostart = scene?.previewVideoAutostart ?? false; const autostart = scene?.previewVideoAutostart ?? false;
const lastTargetRef = useRef<{ sceneKey: string; assetId: string; autostart: boolean } | null>(null);
const [tick, setTick] = useState(0); const [tick, setTick] = useState(0);
const dur = useMemo( const dur = useMemo(
@@ -61,14 +62,18 @@ export function ControlScenePreview({ session, videoRef, viewCamera = null, onCo
useEffect(() => { useEffect(() => {
if (!isVideo) return; if (!isVideo) return;
if (!assetId) return; if (!assetId) return;
// `target.set` bumps revision and resets anchors; avoid firing on every render. const sceneKey = session?.project?.currentGraphNodeId ?? session?.currentSceneId ?? '';
if (vp?.targetAssetId === assetId) return; const prev = lastTargetRef.current;
if (prev && prev.sceneKey === sceneKey && prev.assetId === assetId && prev.autostart === autostart) {
return;
}
lastTargetRef.current = { sceneKey, assetId, autostart };
void video.dispatch({ void video.dispatch({
kind: 'target.set', kind: 'target.set',
assetId, assetId,
autostart, autostart,
}); });
}, [assetId, isVideo, autostart, vp?.targetAssetId, video]); }, [assetId, isVideo, autostart, session?.currentSceneId, session?.project?.currentGraphNodeId, video]);
useEffect(() => { useEffect(() => {
const v = videoRef.current; const v = videoRef.current;
@@ -108,6 +113,7 @@ export function ControlScenePreview({ session, videoRef, viewCamera = null, onCo
className={styles.video} className={styles.video}
src={url} src={url}
playsInline playsInline
loop={Boolean(scene?.settings?.loopVideo)}
preload="auto" preload="auto"
onTimeUpdate={() => setTick((x) => x + 1)} onTimeUpdate={() => setTick((x) => x + 1)}
onLoadedMetadata={() => setTick((x) => x + 1)} onLoadedMetadata={() => setTick((x) => x + 1)}
+27
View File
@@ -946,6 +946,33 @@
flex-wrap: wrap; flex-wrap: wrap;
} }
.actionsRowHalf {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
width: 100%;
}
.actionsRowHalf > span {
display: flex;
min-width: 0;
width: 100%;
flex: 1 1 0;
}
.actionsRowHalf > span > button {
flex: 1 1 auto;
width: 100%;
}
.actionsRowVideoChecks {
display: flex;
flex-wrap: wrap;
gap: 12px;
width: 100%;
align-items: center;
}
.checkboxLabel { .checkboxLabel {
display: flex; display: flex;
gap: 8px; gap: 8px;
+24 -8
View File
@@ -1096,6 +1096,7 @@ export function EditorApp() {
previewAssetId={sc?.previewAssetId ?? null} previewAssetId={sc?.previewAssetId ?? null}
previewAssetType={sc?.previewAssetType ?? null} previewAssetType={sc?.previewAssetType ?? null}
previewVideoAutostart={sc?.previewVideoAutostart ?? false} previewVideoAutostart={sc?.previewVideoAutostart ?? false}
previewVideoLoop={sc?.settings.loopVideo ?? false}
previewRotationDeg={sc?.previewRotationDeg ?? 0} previewRotationDeg={sc?.previewRotationDeg ?? 0}
darkenScene={sc?.darkenScene ?? false} darkenScene={sc?.darkenScene ?? false}
previewBusy={previewBusy} previewBusy={previewBusy}
@@ -1108,6 +1109,9 @@ export function EditorApp() {
onPreviewVideoAutostartChange={(next) => onPreviewVideoAutostartChange={(next) =>
void actions.updateScene(sid, { previewVideoAutostart: next }) void actions.updateScene(sid, { previewVideoAutostart: next })
} }
onPreviewVideoLoopChange={(next) =>
void actions.updateScene(sid, { settings: { loopVideo: next } })
}
onDarkenSceneChange={(next) => void actions.updateScene(sid, { darkenScene: next })} onDarkenSceneChange={(next) => void actions.updateScene(sid, { darkenScene: next })}
onTitleChange={(title) => void actions.updateScene(sid, { title })} onTitleChange={(title) => void actions.updateScene(sid, { title })}
onDescriptionChange={(description) => onDescriptionChange={(description) =>
@@ -2322,6 +2326,7 @@ type SceneInspectorProps = {
previewAssetId: AssetId | null; previewAssetId: AssetId | null;
previewAssetType: 'image' | 'video' | null; previewAssetType: 'image' | 'video' | null;
previewVideoAutostart: boolean; previewVideoAutostart: boolean;
previewVideoLoop: boolean;
previewRotationDeg: 0 | 90 | 180 | 270; previewRotationDeg: 0 | 90 | 180 | 270;
darkenScene: boolean; darkenScene: boolean;
previewBusy: boolean; previewBusy: boolean;
@@ -2330,6 +2335,7 @@ type SceneInspectorProps = {
audioRefs: SceneAudioRef[]; audioRefs: SceneAudioRef[];
onAudioRefsChange: (next: SceneAudioRef[]) => void; onAudioRefsChange: (next: SceneAudioRef[]) => void;
onPreviewVideoAutostartChange: (next: boolean) => void; onPreviewVideoAutostartChange: (next: boolean) => void;
onPreviewVideoLoopChange: (next: boolean) => void;
onDarkenSceneChange: (next: boolean) => void; onDarkenSceneChange: (next: boolean) => void;
onTitleChange: (v: string) => void; onTitleChange: (v: string) => void;
onDescriptionChange: (v: string) => void; onDescriptionChange: (v: string) => void;
@@ -2456,6 +2462,7 @@ function SceneInspector({
previewAssetId, previewAssetId,
previewAssetType, previewAssetType,
previewVideoAutostart, previewVideoAutostart,
previewVideoLoop,
previewRotationDeg, previewRotationDeg,
darkenScene, darkenScene,
previewBusy, previewBusy,
@@ -2464,6 +2471,7 @@ function SceneInspector({
audioRefs, audioRefs,
onAudioRefsChange, onAudioRefsChange,
onPreviewVideoAutostartChange, onPreviewVideoAutostartChange,
onPreviewVideoLoopChange,
onDarkenSceneChange, onDarkenSceneChange,
onTitleChange, onTitleChange,
onDescriptionChange, onDescriptionChange,
@@ -2578,7 +2586,7 @@ function SceneInspector({
muted muted
playsInline playsInline
autoPlay={previewVideoAutostart} autoPlay={previewVideoAutostart}
loop loop={previewVideoLoop}
preload="metadata" preload="metadata"
className={styles.videoCover} className={styles.videoCover}
/> />
@@ -2595,12 +2603,14 @@ function SceneInspector({
</div> </div>
) : null} ) : null}
</div> </div>
<div className={styles.actionsRow}> <div className={styles.actionsRowHalf}>
<Button variant="primary" disabled={previewBusy} onClick={onImportPreview}> <Button variant="primary" disabled={previewBusy} onClick={onImportPreview}>
{previewAssetId ? t('scene.change') : t('campaign.upload')} {previewAssetId ? t('scene.change') : t('campaign.upload')}
</Button> </Button>
{previewAssetId ? <Button onClick={onClearPreview}>{t('scene.clear')}</Button> : null} {previewAssetId ? <Button onClick={onClearPreview}>{t('scene.clear')}</Button> : <span aria-hidden />}
</div>
{previewAssetId && previewAssetType === 'video' ? ( {previewAssetId && previewAssetType === 'video' ? (
<div className={styles.actionsRowVideoChecks}>
<label className={styles.checkboxLabel}> <label className={styles.checkboxLabel}>
<input <input
type="checkbox" type="checkbox"
@@ -2609,8 +2619,19 @@ function SceneInspector({
/> />
<span className={styles.spanSm}>{t('scene.autostart')}</span> <span className={styles.spanSm}>{t('scene.autostart')}</span>
</label> </label>
<label className={styles.checkboxLabel}>
<input
type="checkbox"
checked={previewVideoLoop}
onChange={(e) => onPreviewVideoLoopChange(e.target.checked)}
/>
<span className={styles.spanSm}>{t('campaign.loop')}</span>
</label>
</div>
) : null} ) : null}
{previewAssetId && previewAssetType === 'image' ? ( {previewAssetId && previewAssetType === 'image' ? (
<>
<div className={styles.spacer6} />
<Button <Button
onClick={() => { onClick={() => {
const next = ((previewRotationDeg + 90) % 360) as 0 | 90 | 180 | 270; const next = ((previewRotationDeg + 90) % 360) as 0 | 90 | 180 | 270;
@@ -2619,10 +2640,6 @@ function SceneInspector({
> >
{t('scene.rotate')} {t('scene.rotate')}
</Button> </Button>
) : null}
</div>
{previewAssetId && previewAssetType === 'image' ? (
<>
<div className={styles.spacer6} /> <div className={styles.spacer6} />
<label className={styles.checkboxLabel}> <label className={styles.checkboxLabel}>
<input <input
@@ -2879,7 +2896,6 @@ function SceneListCard({
</div> </div>
<div className={styles.sceneCardBody}> <div className={styles.sceneCardBody}>
<div className={styles.sceneCardHeader}> <div className={styles.sceneCardHeader}>
{scene.active ? <div className={styles.badgeCurrent}>{t('sceneCard.current')}</div> : null}
<div <div
ref={titleRef} ref={titleRef}
className={styles.sceneCardTitle} className={styles.sceneCardTitle}
@@ -15,6 +15,17 @@
flex-wrap: wrap; flex-wrap: wrap;
} }
.legendHeadRow {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px 14px;
}
.legendHeadRow .row {
margin: 0;
}
.hint { .hint {
font-size: 12px; font-size: 12px;
opacity: 0.7; opacity: 0.7;
@@ -27,6 +27,8 @@ type Props = {
/** Крупная карта (окно «Материалы»). */ /** Крупная карта (окно «Материалы»). */
largeMap?: boolean; largeMap?: boolean;
onChange: (legend: MaterialLegend) => void; onChange: (legend: MaterialLegend) => void;
onRotate?: () => void;
rotateLabel?: string;
}; };
type DragMode = type DragMode =
@@ -55,6 +57,8 @@ export function MaterialLegendEditor({
rotationDeg = 0, rotationDeg = 0,
largeMap = false, largeMap = false,
onChange, onChange,
onRotate,
rotateLabel = 'Повернуть',
}: Props) { }: Props) {
const [draft, setDraft] = useState<MaterialLegend>(() => cloneLegend(legend)); const [draft, setDraft] = useState<MaterialLegend>(() => cloneLegend(legend));
const [activeItemId, setActiveItemId] = useState<string | null>(null); const [activeItemId, setActiveItemId] = useState<string | null>(null);
@@ -404,10 +408,15 @@ export function MaterialLegendEditor({
<div className={[styles.legendBlock, largeMap ? styles.legendBlockLarge : ''].filter(Boolean).join(' ')}> <div className={[styles.legendBlock, largeMap ? styles.legendBlockLarge : ''].filter(Boolean).join(' ')}>
{largeMap ? mapBlock : null} {largeMap ? mapBlock : null}
<div className={styles.legendHeadRow}>
{onRotate ? (
<Button onClick={onRotate}>{rotateLabel}</Button>
) : null}
<label className={styles.row}> <label className={styles.row}>
<input type="checkbox" checked={draft.enabled} onChange={(e) => setEnabled(e.target.checked)} /> <input type="checkbox" checked={draft.enabled} onChange={(e) => setEnabled(e.target.checked)} />
<span>Легенда</span> <span>Легенда</span>
</label> </label>
</div>
{draft.enabled ? ( {draft.enabled ? (
<> <>
+15 -4
View File
@@ -19,7 +19,7 @@ export type MaterialsBrowserProps = {
mode: 'editor' | 'runtime'; mode: 'editor' | 'runtime';
selectedId: MaterialId | null; selectedId: MaterialId | null;
onSelect: (id: MaterialId | null) => void; onSelect: (id: MaterialId | null) => void;
activeMaterialId?: MaterialId | null; activeMaterialIds?: readonly MaterialId[];
onAdd?: () => void; onAdd?: () => void;
onEdit?: (material: ProjectMaterial) => void; onEdit?: (material: ProjectMaterial) => void;
onDelete?: (materialId: MaterialId) => Promise<void>; onDelete?: (materialId: MaterialId) => Promise<void>;
@@ -40,7 +40,7 @@ export function MaterialsBrowser({
mode, mode,
selectedId, selectedId,
onSelect, onSelect,
activeMaterialId = null, activeMaterialIds = [],
onAdd, onAdd,
onEdit, onEdit,
onDelete, onDelete,
@@ -74,6 +74,7 @@ export function MaterialsBrowser({
return () => window.removeEventListener('mousedown', onDown); return () => window.removeEventListener('mousedown', onDown);
}, [menuFor]); }, [menuFor]);
const activeSet = useMemo(() => new Set(activeMaterialIds), [activeMaterialIds]);
const filtered = useMemo(() => { const filtered = useMemo(() => {
const q = query.trim().toLowerCase(); const q = query.trim().toLowerCase();
if (!q) return materials; if (!q) return materials;
@@ -126,7 +127,7 @@ export function MaterialsBrowser({
key={m.id} key={m.id}
material={m} material={m}
selected={m.id === selectedId} selected={m.id === selectedId}
active={m.id === activeMaterialId} active={activeSet.has(m.id)}
showMenu={mode === 'editor'} showMenu={mode === 'editor'}
dragging={dragId === m.id} dragging={dragId === m.id}
dropPlace={dropPlace?.id === m.id ? dropPlace.place : null} dropPlace={dropPlace?.id === m.id ? dropPlace.place : null}
@@ -200,6 +201,16 @@ export function MaterialsBrowser({
legend={selected.legend} legend={selected.legend}
previewUrl={selectedUrl} previewUrl={selectedUrl}
rotationDeg={selected.rotationDeg ?? 0} rotationDeg={selected.rotationDeg ?? 0}
rotateLabel={t('scene.rotate')}
onRotate={
onRotate
? () => {
const cur = selected.rotationDeg ?? 0;
const next = ((cur + 90) % 360) as 0 | 90 | 180 | 270;
onRotate(selected.id, next);
}
: undefined
}
onChange={(next) => { onChange={(next) => {
void onLegendChange(selected.id, next); void onLegendChange(selected.id, next);
}} }}
@@ -220,7 +231,7 @@ export function MaterialsBrowser({
)} )}
</div> </div>
)} )}
{selected && onRotate ? ( {selected && onRotate && !onLegendChange ? (
<div className={matStyles.previewActions}> <div className={matStyles.previewActions}>
<Button <Button
onClick={() => { onClick={() => {
@@ -33,6 +33,28 @@
.browserToolbarRow > * { .browserToolbarRow > * {
width: 100%; width: 100%;
min-width: 0; min-width: 0;
display: flex;
flex: 1 1 0;
}
.browserToolbarRow > * > button {
flex: 1 1 auto;
width: 100%;
min-width: 0;
}
.browserToolbarZoomRow {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.browserToolbarFullBtn {
width: 100%;
}
.browserToolbarFullBtn > button {
width: 100%;
} }
.browserToolbarHint { .browserToolbarHint {
@@ -147,6 +147,7 @@
font-size: 20px; font-size: 20px;
line-height: 1.2; line-height: 1.2;
letter-spacing: -0.02em; letter-spacing: -0.02em;
min-width: 0;
} }
.musicParams { .musicParams {
+3 -1
View File
@@ -27,6 +27,8 @@ import {
} from '../../../shared/graph/sceneGraphLineage'; } from '../../../shared/graph/sceneGraphLineage';
import type { AssetId, GraphNodeId, SceneGraphEdge, SceneGraphNode, SceneId } from '../../../shared/types'; import type { AssetId, GraphNodeId, SceneGraphEdge, SceneGraphNode, SceneId } from '../../../shared/types';
import { RotatedImage } from '../../shared/RotatedImage'; import { RotatedImage } from '../../shared/RotatedImage';
import { EllipsisText } from '../../shared/ui/EllipsisText';
import ellipsisStyles from '../../shared/ui/ellipsisText.module.css';
import { useAssetUrl } from '../../shared/useAssetImageUrl'; import { useAssetUrl } from '../../shared/useAssetImageUrl';
import styles from './SceneGraph.module.css'; import styles from './SceneGraph.module.css';
@@ -293,7 +295,7 @@ function SceneCardNode({ data }: NodeProps<SceneCardData>) {
) : null} ) : null}
</div> </div>
<div className={styles.nodeBody}> <div className={styles.nodeBody}>
<div className={styles.title}>{data.title || ui.untitled}</div> <EllipsisText text={data.title || ui.untitled} className={[styles.title, ellipsisStyles.root].join(' ')} />
{data.hasAnyAudioLoop || data.hasAnyAudioAutoplay ? ( {data.hasAnyAudioLoop || data.hasAnyAudioAutoplay ? (
<div className={styles.musicParams}> <div className={styles.musicParams}>
{data.hasAnyAudioLoop ? ( {data.hasAnyAudioLoop ? (
@@ -7,6 +7,8 @@ import {
translateEditorMessage, translateEditorMessage,
type EditorLocale, type EditorLocale,
} from './editorMessages'; } from './editorMessages';
import { getDndApi } from '../../shared/dndApi';
import { ipcChannels } from '../../../shared/ipc/contracts';
type EditorI18nContextValue = { type EditorI18nContextValue = {
locale: EditorLocale; locale: EditorLocale;
@@ -36,6 +38,15 @@ export function EditorI18nProvider({ children }: { children: React.ReactNode })
} }
}, []); }, []);
useEffect(() => {
const tag = locale === 'ru' ? 'ru-RU' : 'en-US';
try {
void getDndApi().invoke(ipcChannels.windows.syncChromeTitles, { localeTag: tag });
} catch {
// preload ещё не готов (редко при первом кадре)
}
}, [locale]);
// Другие окна Electron (пульт, материалы) подхватывают смену языка из редактора. // Другие окна Electron (пульт, материалы) подхватывают смену языка из редактора.
useEffect(() => { useEffect(() => {
const onStorage = (e: StorageEvent) => { const onStorage = (e: StorageEvent) => {
+2 -2
View File
@@ -406,7 +406,7 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'materials.dropHint': 'Перетащите изображение (PNG, JPG, WebP)', 'materials.dropHint': 'Перетащите изображение (PNG, JPG, WebP)',
'materials.tileMenu': 'Меню материала', 'materials.tileMenu': 'Меню материала',
'materials.windowEmpty': 'Добавьте материалы в редакторе.', 'materials.windowEmpty': 'Добавьте материалы в редакторе.',
'materials.closeOverlay': 'Закрыть материал', 'materials.closeOverlay': 'Закрыть материалы',
'materials.rotateOverlay': 'Повернуть', 'materials.rotateOverlay': 'Повернуть',
'materials.deleteTitle': 'Удаление материала', 'materials.deleteTitle': 'Удаление материала',
'materials.deleteConfirm': 'Вы уверены, что хотите удалить материал «{name}»?', 'materials.deleteConfirm': 'Вы уверены, что хотите удалить материал «{name}»?',
@@ -977,7 +977,7 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'materials.dropHint': 'Drop an image (PNG, JPG, WebP)', 'materials.dropHint': 'Drop an image (PNG, JPG, WebP)',
'materials.tileMenu': 'Material menu', 'materials.tileMenu': 'Material menu',
'materials.windowEmpty': 'Add materials in the editor.', 'materials.windowEmpty': 'Add materials in the editor.',
'materials.closeOverlay': 'Close material', 'materials.closeOverlay': 'Close materials',
'materials.rotateOverlay': 'Rotate', 'materials.rotateOverlay': 'Rotate',
'materials.deleteTitle': 'Delete material', 'materials.deleteTitle': 'Delete material',
'materials.deleteConfirm': 'Are you sure you want to delete material “{name}”?', 'materials.deleteConfirm': 'Are you sure you want to delete material “{name}”?',
+8 -6
View File
@@ -59,7 +59,7 @@ export function MaterialsApp() {
useEffect(() => { useEffect(() => {
const onKey = (e: KeyboardEvent) => { const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') { if (e.key === 'Escape') {
if (overlay?.activeMaterialId) { if ((overlay?.activeMaterialIds?.length ?? 0) > 0) {
void overlayApi.dispatch({ kind: 'hide' }); void overlayApi.dispatch({ kind: 'hide' });
return; return;
} }
@@ -72,10 +72,10 @@ export function MaterialsApp() {
}; };
window.addEventListener('keydown', onKey); window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey);
}, [overlay?.activeMaterialId, overlay?.zoomTool, overlayApi]); }, [overlay?.activeMaterialIds, overlay?.zoomTool, overlayApi]);
const materials = session?.project?.materials ?? []; const materials = session?.project?.materials ?? [];
const activeId = overlay?.activeMaterialId ?? null; const activeIds = overlay?.activeMaterialIds ?? [];
const zoomTool = overlay?.zoomTool ?? null; const zoomTool = overlay?.zoomTool ?? null;
return ( return (
@@ -88,7 +88,7 @@ export function MaterialsApp() {
materials={materials} materials={materials}
selectedId={selectedId} selectedId={selectedId}
onSelect={onSelect} onSelect={onSelect}
activeMaterialId={activeId} activeMaterialIds={activeIds}
onTileActivate={(id) => { onTileActivate={(id) => {
const mat = materials.find((m) => m.id === id); const mat = materials.find((m) => m.id === id);
void overlayApi.dispatch({ void overlayApi.dispatch({
@@ -99,7 +99,7 @@ export function MaterialsApp() {
}} }}
toolbar={ toolbar={
<> <>
<div className={matStyles.browserToolbarRow}> <div className={matStyles.browserToolbarZoomRow}>
<Button <Button
variant={zoomTool === 'zoomIn' ? 'primary' : 'ghost'} variant={zoomTool === 'zoomIn' ? 'primary' : 'ghost'}
iconOnly iconOnly
@@ -131,7 +131,8 @@ export function MaterialsApp() {
<ZoomOutIcon /> <ZoomOutIcon />
</Button> </Button>
</div> </div>
{activeId ? ( {activeIds.length > 0 ? (
<div className={matStyles.browserToolbarFullBtn}>
<Button <Button
title={t('materials.closeOverlay')} title={t('materials.closeOverlay')}
ariaLabel={t('materials.closeOverlay')} ariaLabel={t('materials.closeOverlay')}
@@ -142,6 +143,7 @@ export function MaterialsApp() {
> >
{t('materials.closeOverlay')} {t('materials.closeOverlay')}
</Button> </Button>
</div>
) : null} ) : null}
<div className={matStyles.browserToolbarHint}> <div className={matStyles.browserToolbarHint}>
{zoomTool === 'zoomIn' {zoomTool === 'zoomIn'
+73 -1
View File
@@ -3,6 +3,7 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { ipcChannels, type SessionState } from '../../shared/ipc/contracts'; import { ipcChannels, type SessionState } from '../../shared/ipc/contracts';
import { buildNpcGroupForest, type NpcGroupTreeNode } from '../../shared/npcs/npcGroups'; import { buildNpcGroupForest, type NpcGroupTreeNode } from '../../shared/npcs/npcGroups';
import type { NpcGroupId, NpcId, ProjectNpc } from '../../shared/types'; import type { NpcGroupId, NpcId, ProjectNpc } from '../../shared/types';
import matStyles from '../editor/MaterialsModals.module.css';
import { useEditorI18n } from '../editor/i18n/EditorI18nContext'; import { useEditorI18n } from '../editor/i18n/EditorI18nContext';
import { sanitizeSceneDescriptionHtml } from '../editor/sceneDescriptionHtml'; import { sanitizeSceneDescriptionHtml } from '../editor/sceneDescriptionHtml';
import { getDndApi } from '../shared/dndApi'; import { getDndApi } from '../shared/dndApi';
@@ -12,6 +13,32 @@ import { useAssetUrl } from '../shared/useAssetImageUrl';
import styles from './NpcsApp.module.css'; import styles from './NpcsApp.module.css';
function ZoomInIcon() {
return (
<svg viewBox="0 0 24 24" width="20" height="20" aria-hidden>
<circle cx="10.5" cy="10.5" r="6.25" fill="none" stroke="currentColor" strokeWidth="1.8" />
<path d="M15.2 15.2 20 20" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
<path
d="M10.5 7.8v5.4M7.8 10.5h5.4"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
/>
</svg>
);
}
function ZoomOutIcon() {
return (
<svg viewBox="0 0 24 24" width="20" height="20" aria-hidden>
<circle cx="10.5" cy="10.5" r="6.25" fill="none" stroke="currentColor" strokeWidth="1.8" />
<path d="M15.2 15.2 20 20" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
<path d="M7.8 10.5h5.4" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" />
</svg>
);
}
/** Убрать пустые ветки групп (удобно при поиске). */ /** Убрать пустые ветки групп (удобно при поиске). */
function pruneEmptyGroupNodes(nodes: NpcGroupTreeNode[]): NpcGroupTreeNode[] { function pruneEmptyGroupNodes(nodes: NpcGroupTreeNode[]): NpcGroupTreeNode[] {
const out: NpcGroupTreeNode[] = []; const out: NpcGroupTreeNode[] = [];
@@ -137,12 +164,18 @@ export function NpcsApp() {
void overlayApi.dispatch({ kind: 'hide' }); void overlayApi.dispatch({ kind: 'hide' });
return; return;
} }
if (overlay?.zoomTool) {
void overlayApi.dispatch({ kind: 'zoomTool.set', tool: null });
return;
}
window.close(); window.close();
} }
}; };
window.addEventListener('keydown', onKey); window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey);
}, [hasActive, overlayApi]); }, [hasActive, overlay?.zoomTool, overlayApi]);
const zoomTool = overlay?.zoomTool ?? null;
const npcs = useMemo(() => session?.project?.npcs ?? [], [session?.project?.npcs]); const npcs = useMemo(() => session?.project?.npcs ?? [], [session?.project?.npcs]);
const npcGroups = useMemo(() => session?.project?.npcGroups ?? [], [session?.project?.npcGroups]); const npcGroups = useMemo(() => session?.project?.npcGroups ?? [], [session?.project?.npcGroups]);
@@ -215,6 +248,45 @@ export function NpcsApp() {
return ( return (
<div className={styles.page}> <div className={styles.page}>
<div className={styles.toolbar}> <div className={styles.toolbar}>
<div className={matStyles.browserToolbarRow}>
<Button
variant={zoomTool === 'zoomIn' ? 'primary' : 'ghost'}
iconOnly
title={t('npcs.zoomIn')}
ariaLabel={t('npcs.zoomIn')}
tooltipPlacement="bottom"
onClick={() => {
void overlayApi.dispatch({
kind: 'zoomTool.set',
tool: zoomTool === 'zoomIn' ? null : 'zoomIn',
});
}}
>
<ZoomInIcon />
</Button>
<Button
variant={zoomTool === 'zoomOut' ? 'primary' : 'ghost'}
iconOnly
title={t('npcs.zoomOut')}
ariaLabel={t('npcs.zoomOut')}
tooltipPlacement="bottom"
onClick={() => {
void overlayApi.dispatch({
kind: 'zoomTool.set',
tool: zoomTool === 'zoomOut' ? null : 'zoomOut',
});
}}
>
<ZoomOutIcon />
</Button>
</div>
<div className={matStyles.browserToolbarHint}>
{zoomTool === 'zoomIn'
? t('npcs.zoomInHint')
: zoomTool === 'zoomOut'
? t('npcs.zoomOutHint')
: t('npcs.zoomIdleHint')}
</div>
<div className={styles.toolbarRow}> <div className={styles.toolbarRow}>
<Button <Button
title={t('npcs.closeOverlay')} title={t('npcs.closeOverlay')}
@@ -11,10 +11,11 @@
.sidebar { .sidebar {
border-right: 1px solid var(--stroke, #2a2f3a); border-right: 1px solid var(--stroke, #2a2f3a);
padding: 12px; padding: 12px;
overflow: auto; overflow: hidden;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 10px; gap: 10px;
min-height: 0;
} }
.sideTitle { .sideTitle {
@@ -23,6 +24,25 @@
letter-spacing: 0.04em; letter-spacing: 0.04em;
text-transform: uppercase; text-transform: uppercase;
opacity: 0.85; opacity: 0.85;
flex-shrink: 0;
min-width: 0;
}
.hint {
font-size: 12px;
opacity: 0.65;
line-height: 1.35;
flex-shrink: 0;
}
.accordionScroll {
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 10px;
padding-bottom: 12px;
} }
.accordion { .accordion {
@@ -30,6 +50,7 @@
border-radius: 10px; border-radius: 10px;
overflow: hidden; overflow: hidden;
background: rgba(255, 255, 255, 0.02); background: rgba(255, 255, 255, 0.02);
flex-shrink: 0;
} }
.accordionHead { .accordionHead {
@@ -147,12 +168,6 @@
font-weight: 600; font-weight: 600;
} }
.hint {
font-size: 12px;
opacity: 0.65;
line-height: 1.35;
}
.stage { .stage {
position: relative; position: relative;
min-width: 0; min-width: 0;
+8 -1
View File
@@ -25,6 +25,8 @@ import {
import { sceneViewPanBy, sceneViewZoomAt } from '../../shared/types/sceneView'; import { sceneViewPanBy, sceneViewZoomAt } from '../../shared/types/sceneView';
import editorStyles from '../editor/EditorApp.module.css'; import editorStyles from '../editor/EditorApp.module.css';
import { getDndApi } from '../shared/dndApi'; import { getDndApi } from '../shared/dndApi';
import { EllipsisText } from '../shared/ui/EllipsisText';
import ellipsisStyles from '../shared/ui/ellipsisText.module.css';
import { SceneGridOverlay } from '../shared/grid/SceneGridOverlay'; import { SceneGridOverlay } from '../shared/grid/SceneGridOverlay';
import { RotatedImage } from '../shared/RotatedImage'; import { RotatedImage } from '../shared/RotatedImage';
import { useAppTokens } from '../shared/tokens/useAppTokens'; import { useAppTokens } from '../shared/tokens/useAppTokens';
@@ -318,11 +320,15 @@ export function SceneEditorApp() {
return ( return (
<div className={styles.page}> <div className={styles.page}>
<aside className={styles.sidebar}> <aside className={styles.sidebar}>
<div className={styles.sideTitle}>{scene?.title ?? 'Сцена'}</div> <EllipsisText
text={scene?.title ?? 'Сцена'}
className={[styles.sideTitle, ellipsisStyles.root].join(' ')}
/>
<div className={styles.hint}> <div className={styles.hint}>
Колесо зум. СКМ / Space+ЛКМ пан. Delete удалить выбранное. Колесо зум. СКМ / Space+ЛКМ пан. Delete удалить выбранное.
</div> </div>
<div className={styles.accordionScroll}>
<div className={styles.accordion}> <div className={styles.accordion}>
<button type="button" className={styles.accordionHead} onClick={() => setGridOpen((v) => !v)}> <button type="button" className={styles.accordionHead} onClick={() => setGridOpen((v) => !v)}>
Сетка {gridOpen ? '▾' : '▸'} Сетка {gridOpen ? '▾' : '▸'}
@@ -461,6 +467,7 @@ export function SceneEditorApp() {
Очистить сцену Очистить сцену
</Button> </Button>
</div> </div>
</div>
</aside> </aside>
<div className={styles.stage}> <div className={styles.stage}>
+27 -19
View File
@@ -56,11 +56,21 @@ export function PresentationView({
); );
const scene = const scene =
session?.project && session.currentSceneId ? session.project.scenes[session.currentSceneId] : undefined; session?.project && session.currentSceneId ? session.project.scenes[session.currentSceneId] : undefined;
const activeMaterial =
session?.project && materialsOverlay?.activeMaterialId
? (session.project.materials ?? []).find((m) => m.id === materialsOverlay.activeMaterialId)
: undefined;
const project = session?.project; const project = session?.project;
const activeMaterialItems =
project && (materialsOverlay?.activeMaterialIds?.length ?? 0) > 0
? (materialsOverlay?.activeMaterialIds ?? [])
.map((id) => {
const material = (project.materials ?? []).find((m) => m.id === id);
if (!material) return null;
return {
material,
layout: materialsOverlay?.layouts[id] ?? DEFAULT_MATERIALS_OVERLAY_LAYOUT,
legendLayout: materialsOverlay?.legendLayouts[id] ?? DEFAULT_MATERIALS_OVERLAY_LAYOUT,
};
})
.filter((x): x is NonNullable<typeof x> => x !== null)
: [];
const activeNpcItems = const activeNpcItems =
project && (npcsOverlay?.activeNpcIds?.length ?? 0) > 0 project && (npcsOverlay?.activeNpcIds?.length ?? 0) > 0
? (npcsOverlay?.activeNpcIds ?? []) ? (npcsOverlay?.activeNpcIds ?? [])
@@ -156,7 +166,7 @@ export function PresentationView({
src={originalUrl} src={originalUrl}
muted muted
playsInline playsInline
loop={false} loop={Boolean(scene?.settings?.loopVideo)}
preload="auto" preload="auto"
onError={() => { onError={() => {
// noop: status surfaced in control app; keep presentation clean // noop: status surfaced in control app; keep presentation clean
@@ -200,25 +210,23 @@ export function PresentationView({
<ExplosionVideoOverlay state={fxState} viewport={contentRect} /> <ExplosionVideoOverlay state={fxState} viewport={contentRect} />
) : null} ) : null}
{showEffects && scene?.previewAssetType === 'image' && scene.darkenScene && contentRect ? ( {showEffects && scene?.previewAssetType === 'image' && scene.darkenScene && contentRect ? (
<SceneDarknessOverlay state={sdState} overlayAlpha={1} viewport={contentRect} /> <SceneDarknessOverlay state={sdState} overlayAlpha={1} viewport={contentRect} style={{ zIndex: 30 }} />
) : null} ) : null}
<SceneOverlayHost active={Boolean(activeMaterial) || activeNpcItems.length > 0}> <SceneOverlayHost active={activeMaterialItems.length > 0 || activeNpcItems.length > 0}>
{activeMaterial ? ( {activeMaterialItems.map(({ material, layout, legendLayout }) => (
<React.Fragment key={material.id}>
<MaterialOverlay <MaterialOverlay
embedded embedded
assetId={activeMaterial.assetId} assetId={material.assetId}
layout={materialsOverlay?.layout ?? DEFAULT_MATERIALS_OVERLAY_LAYOUT} layout={layout}
{...(activeMaterial.legend?.enabled materialId={material.id}
? { legendMarkers: activeMaterial.legend.markers ?? [] } {...(material.legend?.enabled ? { legendMarkers: material.legend.markers ?? [] } : {})}
: {})}
/>
) : null}
{activeMaterial?.legend?.enabled ? (
<MaterialLegendPanel
legend={activeMaterial.legend}
layout={materialsOverlay?.legendLayout ?? DEFAULT_MATERIALS_OVERLAY_LAYOUT}
/> />
{material.legend?.enabled ? (
<MaterialLegendPanel legend={material.legend} layout={legendLayout} />
) : null} ) : null}
</React.Fragment>
))}
{activeNpcItems.length > 0 ? <NpcsSceneOverlay embedded items={activeNpcItems} /> : null} {activeNpcItems.length > 0 ? <NpcsSceneOverlay embedded items={activeNpcItems} /> : null}
</SceneOverlayHost> </SceneOverlayHost>
{showTitle ? ( {showTitle ? (
@@ -17,6 +17,15 @@
pointer-events: none; pointer-events: none;
} }
.viewportGuide {
position: absolute;
inset: 0;
border: 2px solid rgba(245, 197, 66, 0.55);
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.35);
pointer-events: none;
z-index: 0;
}
.captureZoom { .captureZoom {
pointer-events: auto; pointer-events: auto;
} }
@@ -1,6 +1,6 @@
import React, { useEffect, useRef, useState } from 'react'; import React, { useEffect, useRef, useState } from 'react';
import type { AssetId, MaterialsOverlayLayout, MaterialsZoomTool, NpcsZoomTool } from '../../../shared/types'; import type { AssetId, MaterialId, MaterialsOverlayLayout, MaterialsZoomTool, NpcsZoomTool } from '../../../shared/types';
import { DEFAULT_MATERIALS_OVERLAY_LAYOUT } from '../../../shared/types'; import { DEFAULT_MATERIALS_OVERLAY_LAYOUT } from '../../../shared/types';
import { useSceneOverlayView } from '../sceneOverlay/SceneOverlayViewContext'; import { useSceneOverlayView } from '../sceneOverlay/SceneOverlayViewContext';
import { useAssetUrl } from '../useAssetImageUrl'; import { useAssetUrl } from '../useAssetImageUrl';
@@ -12,6 +12,7 @@ type Corner = 'nw' | 'ne' | 'sw' | 'se';
type MaterialOverlayProps = { type MaterialOverlayProps = {
assetId: AssetId | null; assetId: AssetId | null;
layout: MaterialsOverlayLayout; layout: MaterialsOverlayLayout;
materialId?: MaterialId;
editable?: boolean; editable?: boolean;
zoomTool?: MaterialsZoomTool | NpcsZoomTool; zoomTool?: MaterialsZoomTool | NpcsZoomTool;
showClose?: boolean; showClose?: boolean;
@@ -92,6 +93,7 @@ function localToScreenOffset(localX: number, localY: number, rotationDeg: number
export function MaterialOverlay({ export function MaterialOverlay({
assetId, assetId,
layout, layout,
materialId,
editable = false, editable = false,
zoomTool = null, zoomTool = null,
showClose = false, showClose = false,
@@ -366,6 +368,7 @@ export function MaterialOverlay({
<div <div
className={[styles.frame, editable && !zoomTool ? styles.frameEditable : ''].filter(Boolean).join(' ')} className={[styles.frame, editable && !zoomTool ? styles.frameEditable : ''].filter(Boolean).join(' ')}
data-overlay-kind="material" data-overlay-kind="material"
{...(materialId ? { 'data-material-id': materialId } : {})}
style={{ style={{
left, left,
top, top,
@@ -3,6 +3,7 @@ import React, { useEffect, useMemo, useRef, useState } from 'react';
import type { MaterialsZoomTool, NpcsZoomTool } from '../../../shared/types'; import type { MaterialsZoomTool, NpcsZoomTool } from '../../../shared/types';
import styles from '../materials/MaterialOverlay.module.css'; import styles from '../materials/MaterialOverlay.module.css';
import { overlayRootStyle, type SceneOverlayViewport } from './overlayViewport';
import { SceneOverlayViewContext } from './SceneOverlayViewContext'; import { SceneOverlayViewContext } from './SceneOverlayViewContext';
export type SceneOverlayCloseAction = { export type SceneOverlayCloseAction = {
@@ -14,6 +15,10 @@ export type SceneOverlayCloseAction = {
type SceneOverlayHostProps = { type SceneOverlayHostProps = {
/** Есть ли что показывать (материал и/или NPC). */ /** Есть ли что показывать (материал и/или NPC). */
active: boolean; active: boolean;
/** Область картинки сцены (contain); координаты относительно родителя. */
viewport?: SceneOverlayViewport | null;
/** Рамка видимой области (предпросмотр пульта). */
showViewportGuide?: boolean;
zoomTool?: MaterialsZoomTool | NpcsZoomTool; zoomTool?: MaterialsZoomTool | NpcsZoomTool;
onZoomAt?: (nx: number, ny: number, target: EventTarget | null) => void; onZoomAt?: (nx: number, ny: number, target: EventTarget | null) => void;
closes?: readonly SceneOverlayCloseAction[]; closes?: readonly SceneOverlayCloseAction[];
@@ -26,6 +31,8 @@ type SceneOverlayHostProps = {
*/ */
export function SceneOverlayHost({ export function SceneOverlayHost({
active, active,
viewport = null,
showViewportGuide = false,
zoomTool = null, zoomTool = null,
onZoomAt, onZoomAt,
closes = [], closes = [],
@@ -58,7 +65,7 @@ export function SceneOverlayHost({
const ctx = useMemo(() => ({ rootRef, view }), [view]); const ctx = useMemo(() => ({ rootRef, view }), [view]);
if (!active) return null; if (!active && !showViewportGuide) return null;
const captureZoom = Boolean(zoomTool && onZoomAt); const captureZoom = Boolean(zoomTool && onZoomAt);
const zoomCursor = const zoomCursor =
@@ -81,6 +88,7 @@ export function SceneOverlayHost({
className={[styles.root, styles.hostHitThrough, captureZoom ? styles.captureZoom : '', zoomCursor] className={[styles.root, styles.hostHitThrough, captureZoom ? styles.captureZoom : '', zoomCursor]
.filter(Boolean) .filter(Boolean)
.join(' ')} .join(' ')}
style={overlayRootStyle(viewport)}
role="presentation" role="presentation"
onClick={(e) => { onClick={(e) => {
if (!captureZoom || !onZoomAt) return; if (!captureZoom || !onZoomAt) return;
@@ -89,6 +97,7 @@ export function SceneOverlayHost({
onZoomAt(nx, ny, e.target); onZoomAt(nx, ny, e.target);
}} }}
> >
{showViewportGuide ? <div className={styles.viewportGuide} aria-hidden /> : null}
<div className={styles.dim} /> <div className={styles.dim} />
{children} {children}
{closes.length > 0 ? ( {closes.length > 0 ? (
@@ -0,0 +1,21 @@
import type { CSSProperties } from 'react';
export type SceneOverlayViewport = {
x: number;
y: number;
w: number;
h: number;
};
export function overlayRootStyle(viewport: SceneOverlayViewport | null | undefined): CSSProperties | undefined {
if (!viewport || viewport.w <= 0 || viewport.h <= 0) return undefined;
return {
left: viewport.x,
top: viewport.y,
width: viewport.w,
height: viewport.h,
right: 'auto',
bottom: 'auto',
overflow: 'hidden',
};
}
+33
View File
@@ -0,0 +1,33 @@
import React, { useCallback, useRef, useState } from 'react';
type Props = {
text: string;
className?: string;
};
/** Однострочный текст с ellipsis; полный title при обрезке. */
export function EllipsisText({ text, className }: Props) {
const ref = useRef<HTMLDivElement | null>(null);
const [title, setTitle] = useState<string | undefined>(undefined);
const syncTitle = useCallback(() => {
const el = ref.current;
if (!el) {
setTitle(undefined);
return;
}
setTitle(el.scrollWidth > el.clientWidth + 1 ? text : undefined);
}, [text]);
return (
<div
ref={ref}
className={className}
title={title}
onMouseEnter={syncTitle}
onMouseLeave={() => setTitle(undefined)}
>
{text}
</div>
);
}
@@ -0,0 +1,6 @@
.root {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
+7 -5
View File
@@ -12,7 +12,7 @@ export function appDisplayNameForLocale(localeTag: string): string {
return APP_DISPLAY_NAME_EN; return APP_DISPLAY_NAME_EN;
} }
/** Префикс заголовка окон: `TTRPG - Редактор`. */ /** Префикс заголовка окон (EN): `TTRPG - Editor`. RU — полное имя «НРИ Плеер». */
export const APP_WINDOW_BRAND = 'TTRPG'; export const APP_WINDOW_BRAND = 'TTRPG';
export type AppWindowKind = export type AppWindowKind =
@@ -27,9 +27,9 @@ export type AppWindowKind =
| 'npcs'; | 'npcs';
const WINDOW_SUFFIX: Record<AppWindowKind, { ru: string; en: string }> = { const WINDOW_SUFFIX: Record<AppWindowKind, { ru: string; en: string }> = {
editor: { ru: 'Редактор', en: 'Editor' }, editor: { ru: 'Редактор компании', en: 'Campaign editor' },
presentation: { ru: 'Презентация', en: 'Presentation' }, presentation: { ru: 'Презентация', en: 'Presentation' },
control: { ru: 'Пульт', en: 'Control' }, control: { ru: 'Пульт управления', en: 'Control deck' },
boot: { ru: 'Загрузка', en: 'Loading' }, boot: { ru: 'Загрузка', en: 'Loading' },
sceneDescription: { ru: 'Описание сцены', en: 'Scene description' }, sceneDescription: { ru: 'Описание сцены', en: 'Scene description' },
materials: { ru: 'Материалы', en: 'Materials' }, materials: { ru: 'Материалы', en: 'Materials' },
@@ -40,8 +40,10 @@ const WINDOW_SUFFIX: Record<AppWindowKind, { ru: string; en: string }> = {
export function windowChromeTitle(kind: AppWindowKind, localeTag: string): string { export function windowChromeTitle(kind: AppWindowKind, localeTag: string): string {
const tag = localeTag.trim().toLowerCase(); const tag = localeTag.trim().toLowerCase();
const suffix = tag.startsWith('ru') ? WINDOW_SUFFIX[kind].ru : WINDOW_SUFFIX[kind].en; const ru = tag.startsWith('ru');
return `${APP_WINDOW_BRAND} - ${suffix}`; const prefix = ru ? APP_DISPLAY_NAME_RU : APP_WINDOW_BRAND;
const suffix = ru ? WINDOW_SUFFIX[kind].ru : WINDOW_SUFFIX[kind].en;
return `${prefix} - ${suffix}`;
} }
/** Подписи фильтров в системных диалогах выбора файла (main process). */ /** Подписи фильтров в системных диалогах выбора файла (main process). */
+22
View File
@@ -0,0 +1,22 @@
/** Вписать прямоугольник целевого аспекта в бокс (как object-fit: contain). */
export function fitAspectRect(
boxW: number,
boxH: number,
aspectW: number,
aspectH: number,
): { x: number; y: number; w: number; h: number } {
const bw = Math.max(1, boxW);
const bh = Math.max(1, boxH);
const aw = Math.max(1, aspectW);
const ah = Math.max(1, aspectH);
const boxAspect = bw / bh;
const targetAspect = aw / ah;
if (boxAspect > targetAspect) {
const h = bh;
const w = h * targetAspect;
return { x: (bw - w) / 2, y: 0, w, h };
}
const w = bw;
const h = w / targetAspect;
return { x: 0, y: (bh - h) / 2, w, h };
}
+12
View File
@@ -135,6 +135,9 @@ export const ipcChannels = {
closeNpcs: 'windows.closeNpcs', closeNpcs: 'windows.closeNpcs',
openSceneEditor: 'windows.openSceneEditor', openSceneEditor: 'windows.openSceneEditor',
closeSceneEditor: 'windows.closeSceneEditor', closeSceneEditor: 'windows.closeSceneEditor',
syncChromeTitles: 'windows.syncChromeTitles',
getPresentationContentSize: 'windows.getPresentationContentSize',
presentationContentSizeChanged: 'windows.presentationContentSizeChanged',
}, },
session: { session: {
stateChanged: 'session.stateChanged', stateChanged: 'session.stateChanged',
@@ -257,6 +260,7 @@ export type IpcEventMap = {
[ipcChannels.video.stateChanged]: { state: VideoPlaybackState }; [ipcChannels.video.stateChanged]: { state: VideoPlaybackState };
[ipcChannels.license.statusChanged]: { snapshot: LicenseSnapshot }; [ipcChannels.license.statusChanged]: { snapshot: LicenseSnapshot };
[ipcChannels.windows.multiWindowStateChanged]: { open: boolean }; [ipcChannels.windows.multiWindowStateChanged]: { open: boolean };
[ipcChannels.windows.presentationContentSizeChanged]: { width: number; height: number };
[ipcChannels.windows.sceneDescriptionContent]: { html: string }; [ipcChannels.windows.sceneDescriptionContent]: { html: string };
[ipcChannels.project.importZipProgress]: ZipProgressEvent; [ipcChannels.project.importZipProgress]: ZipProgressEvent;
[ipcChannels.project.exportZipProgress]: ZipProgressEvent; [ipcChannels.project.exportZipProgress]: ZipProgressEvent;
@@ -588,6 +592,10 @@ export type IpcInvokeMap = {
req: Record<string, never>; req: Record<string, never>;
res: { open: boolean }; res: { open: boolean };
}; };
[ipcChannels.windows.getPresentationContentSize]: {
req: Record<string, never>;
res: { width: number; height: number } | { width: null; height: null };
};
[ipcChannels.windows.openSceneDescription]: { [ipcChannels.windows.openSceneDescription]: {
req: { html: string }; req: { html: string };
res: { ok: true }; res: { ok: true };
@@ -632,6 +640,10 @@ export type IpcInvokeMap = {
req: Record<string, never>; req: Record<string, never>;
res: { ok: true }; res: { ok: true };
}; };
[ipcChannels.windows.syncChromeTitles]: {
req: { localeTag: string };
res: { ok: true };
};
[ipcChannels.materialsOverlay.getState]: { [ipcChannels.materialsOverlay.getState]: {
req: Record<string, never>; req: Record<string, never>;
res: { state: MaterialsOverlayState }; res: { state: MaterialsOverlayState };
+10
View File
@@ -11,6 +11,7 @@ void test('package.json: конфиг electron-builder (mac/win/linux)', () => {
build: { build: {
appId: string; appId: string;
asar: boolean; asar: boolean;
npmRebuild: boolean;
asarUnpack: string[]; asarUnpack: string[];
extraResources: { from: string; to: string }[]; extraResources: { from: string; to: string }[];
mac: { target: unknown; artifactName?: string }; mac: { target: unknown; artifactName?: string };
@@ -25,6 +26,11 @@ void test('package.json: конфиг electron-builder (mac/win/linux)', () => {
assert.ok(pkg.build); assert.ok(pkg.build);
assert.equal(pkg.build.appId, 'com.ttrpgplayer.app'); assert.equal(pkg.build.appId, 'com.ttrpgplayer.app');
assert.equal(pkg.build.asar, true, 'релизный артефакт: app.asar без «голого» дерева dist в .app/.exe'); assert.equal(pkg.build.asar, true, 'релизный артефакт: app.asar без «голого» дерева dist в .app/.exe');
assert.equal(
pkg.build.npmRebuild,
false,
'N-API prebuilds (classic-level, sharp @img) — без node-gyp / Visual Studio на pack',
);
assert.ok(Array.isArray(pkg.build.asarUnpack)); assert.ok(Array.isArray(pkg.build.asarUnpack));
assert.ok(pkg.build.asarUnpack.some((p) => p.includes('preload'))); assert.ok(pkg.build.asarUnpack.some((p) => p.includes('preload')));
assert.ok(Array.isArray(pkg.build.extraResources)); assert.ok(Array.isArray(pkg.build.extraResources));
@@ -56,6 +62,10 @@ void test('package.json: конфиг electron-builder (mac/win/linux)', () => {
pkg.build.asarUnpack.some((p) => p.includes('@img')), pkg.build.asarUnpack.some((p) => p.includes('@img')),
'sharp native binaries live under node_modules/@img', 'sharp native binaries live under node_modules/@img',
); );
assert.ok(
pkg.build.asarUnpack.some((p) => p.includes('classic-level')),
'Foundry DB: classic-level stays unpacked with its prebuilds',
);
}); });
void test('package.json: pack:mac runs release-mac-prep before electron-builder', () => { void test('package.json: pack:mac runs release-mac-prep before electron-builder', () => {
+23 -8
View File
@@ -14,13 +14,14 @@ export type MaterialsOverlayLayout = {
export type MaterialsZoomTool = 'zoomIn' | 'zoomOut' | null; export type MaterialsZoomTool = 'zoomIn' | 'zoomOut' | null;
/** Session-only: какой материал сейчас показан поверх сцены. */ /** Session-only: какие материалы сейчас показаны поверх сцены. */
export type MaterialsOverlayState = { export type MaterialsOverlayState = {
revision: number; revision: number;
activeMaterialId: MaterialId | null; activeMaterialIds: MaterialId[];
layout: MaterialsOverlayLayout; layouts: Record<string, MaterialsOverlayLayout>;
/** Раскладка блока описаний легенды (если материал с легендой). */ /** Раскладка блока описаний легенды по materialId. */
legendLayout: MaterialsOverlayLayout; legendLayouts: Record<string, MaterialsOverlayLayout>;
focusMaterialId: MaterialId | null;
zoomTool: MaterialsZoomTool; zoomTool: MaterialsZoomTool;
}; };
@@ -35,10 +36,10 @@ export type MaterialsOverlayEvent =
| { kind: 'show'; materialId: MaterialId; rotationDeg?: number } | { kind: 'show'; materialId: MaterialId; rotationDeg?: number }
| { kind: 'hide' } | { kind: 'hide' }
| { kind: 'toggle'; materialId: MaterialId; rotationDeg?: number } | { kind: 'toggle'; materialId: MaterialId; rotationDeg?: number }
| { kind: 'layout.set'; layout: MaterialsOverlayLayout } | { kind: 'layout.set'; materialId: MaterialId; layout: MaterialsOverlayLayout }
| { kind: 'legendLayout.set'; layout: MaterialsOverlayLayout } | { kind: 'legendLayout.set'; materialId: MaterialId; layout: MaterialsOverlayLayout }
| { kind: 'zoomTool.set'; tool: MaterialsZoomTool } | { kind: 'zoomTool.set'; tool: MaterialsZoomTool }
| { kind: 'zoomAt'; nx: number; ny: number }; | { kind: 'zoomAt'; nx: number; ny: number; materialId?: MaterialId };
/** Нормализация угла в диапазон [0, 360). */ /** Нормализация угла в диапазон [0, 360). */
export function normalizeMaterialsRotation(deg: number): number { export function normalizeMaterialsRotation(deg: number): number {
@@ -73,3 +74,17 @@ export function zoomMaterialsLayoutAt(
scale: clamped.scale, scale: clamped.scale,
}); });
} }
function initialLegendLayout(): MaterialsOverlayLayout {
return clampMaterialsLayout({
...DEFAULT_MATERIALS_OVERLAY_LAYOUT,
cx: 0.82,
cy: 0.5,
scale: 0.85,
rotationDeg: 0,
});
}
export function defaultLegendLayoutForMaterial(): MaterialsOverlayLayout {
return initialLegendLayout();
}
+2 -1
View File
@@ -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/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", "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 scripts/release-native-prep.test.mjs",
"format": "prettier . --check", "format": "prettier . --check",
"format:write": "prettier . --write", "format:write": "prettier . --write",
"postinstall": "patch-package", "postinstall": "patch-package",
@@ -96,6 +96,7 @@
"package.json" "package.json"
], ],
"asar": true, "asar": true,
"npmRebuild": false,
"asarUnpack": [ "asarUnpack": [
"dist/preload/**", "dist/preload/**",
"dist/renderer/app-pack-icon.png", "dist/renderer/app-pack-icon.png",
+6
View File
@@ -9,6 +9,11 @@ import { spawnSync } from 'node:child_process';
import fs from 'node:fs'; import fs from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import process from 'node:process'; import process from 'node:process';
import { fileURLToPath } from 'node:url';
import { ensureReleaseNativeDeps } from './release-native-prep.mjs';
const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
function run(command, args) { function run(command, args) {
const result = spawnSync(command, args, { const result = spawnSync(command, args, {
@@ -63,5 +68,6 @@ if (process.platform === 'win32') {
} }
run('npm', ['run', 'build']); run('npm', ['run', 'build']);
ensureReleaseNativeDeps(projectRoot, 'linux');
run('electron-builder', ['--linux']); run('electron-builder', ['--linux']);
normalizeLinuxReleaseNames(); normalizeLinuxReleaseNames();
+8 -47
View File
@@ -2,43 +2,18 @@
* Перед `electron-builder --mac`: npm ставит sharp только под CPU хоста. * Перед `electron-builder --mac`: npm ставит sharp только под CPU хоста.
* В release идут x64 и arm64 в .app должны быть оба набора @img/sharp-darwin-*. * В release идут x64 и arm64 в .app должны быть оба набора @img/sharp-darwin-*.
*/ */
import { execFileSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
const MAC_SHARP_IMG_PACKAGES = [ import {
'@img/sharp-darwin-arm64', ensureReleaseNativeDeps,
'@img/sharp-libvips-darwin-arm64', MAC_SHARP_IMG_PACKAGES,
'@img/sharp-darwin-x64', sharpImgPackagesToInstall,
'@img/sharp-libvips-darwin-x64', } from './release-native-prep.mjs';
];
/** /** @deprecated use sharpImgPackagesToInstall(root, MAC_SHARP_IMG_PACKAGES) */
* @param {string} root
* @returns {string[]} entries like `@img/sharp-darwin-x64@0.34.5` missing under node_modules
*/
export function macSharpImgPackagesToInstall(root) { export function macSharpImgPackagesToInstall(root) {
const sharpPkgPath = path.join(root, 'node_modules', 'sharp', 'package.json'); return sharpImgPackagesToInstall(root, MAC_SHARP_IMG_PACKAGES);
if (!fs.existsSync(sharpPkgPath)) {
throw new Error('[release-mac-prep] sharp is not installed — run npm ci first');
}
const sharpPkg = JSON.parse(fs.readFileSync(sharpPkgPath, 'utf8'));
const versions = sharpPkg.optionalDependencies ?? {};
const missing = [];
for (const name of MAC_SHARP_IMG_PACKAGES) {
const version = versions[name];
if (!version) {
throw new Error(`[release-mac-prep] sharp optionalDependency missing: ${name}`);
}
if (!fs.existsSync(path.join(root, 'node_modules', name))) {
missing.push(`${name}@${version}`);
}
}
return missing;
} }
/** /**
@@ -46,21 +21,7 @@ export function macSharpImgPackagesToInstall(root) {
* @param {{ runInstall?: boolean }} [opts] * @param {{ runInstall?: boolean }} [opts]
*/ */
export function ensureMacSharpBinaries(root, opts = {}) { export function ensureMacSharpBinaries(root, opts = {}) {
const { runInstall = true } = opts; ensureReleaseNativeDeps(root, 'mac', opts);
const toInstall = macSharpImgPackagesToInstall(root);
if (toInstall.length === 0) {
console.log('[release-mac-prep] all macOS sharp binaries present');
return;
}
console.log('[release-mac-prep] installing', toInstall.join(', '));
if (!runInstall) return;
execFileSync('npm', ['install', '--no-save', '--force', ...toInstall], {
cwd: root,
stdio: 'inherit',
});
} }
const isMain = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url); const isMain = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
+22 -4
View File
@@ -3,15 +3,33 @@ import fs from 'node:fs';
import os from 'node:os'; import os from 'node:os';
import path from 'node:path'; import path from 'node:path';
import test from 'node:test'; import test from 'node:test';
import { fileURLToPath } from 'node:url';
import { macSharpImgPackagesToInstall } from './release-mac-prep.mjs'; import { macSharpImgPackagesToInstall } from './release-mac-prep.mjs';
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
void test('macSharpImgPackagesToInstall: empty when all four @img darwin packages exist', () => { void test('macSharpImgPackagesToInstall: empty when all four @img darwin packages exist', () => {
const missing = macSharpImgPackagesToInstall(root); const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'mac-sharp-all-'));
try {
const sharpOpt = {
'@img/sharp-darwin-arm64': '0.34.5',
'@img/sharp-libvips-darwin-arm64': '1.2.4',
'@img/sharp-darwin-x64': '0.34.5',
'@img/sharp-libvips-darwin-x64': '1.2.4',
};
fs.mkdirSync(path.join(tmp, 'node_modules', 'sharp'), { recursive: true });
fs.writeFileSync(
path.join(tmp, 'node_modules', 'sharp', 'package.json'),
JSON.stringify({ optionalDependencies: sharpOpt }),
);
for (const name of Object.keys(sharpOpt)) {
const dir = path.join(tmp, 'node_modules', name);
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, 'package.json'), '{}');
}
const missing = macSharpImgPackagesToInstall(tmp);
assert.deepEqual(missing, []); assert.deepEqual(missing, []);
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
}); });
void test('macSharpImgPackagesToInstall: lists missing darwin-x64 on arm64-only tree', () => { void test('macSharpImgPackagesToInstall: lists missing darwin-x64 on arm64-only tree', () => {
+113
View File
@@ -0,0 +1,113 @@
/**
* Релиз без @electron/rebuild: classic-level (N-API prebuilds) и sharp (@img бинарники).
* См. package.json build.npmRebuild = false.
*/
import { execFileSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
/** @typedef {'win' | 'mac' | 'linux'} ReleaseNativeProfile */
/** Папки prebuilds/classic-level из npm-пакета classic-level. */
export const CLASSIC_LEVEL_PREBUILD_KEYS = {
win: ['win32-x64'],
mac: ['darwin-x64+arm64'],
linux: ['linux-x64', 'linux-arm64'],
};
export const MAC_SHARP_IMG_PACKAGES = [
'@img/sharp-darwin-arm64',
'@img/sharp-libvips-darwin-arm64',
'@img/sharp-darwin-x64',
'@img/sharp-libvips-darwin-x64',
];
/** AppImage x64/arm64 — glibc, не musl. */
export const LINUX_SHARP_IMG_PACKAGES = [
'@img/sharp-linux-x64',
'@img/sharp-libvips-linux-x64',
'@img/sharp-linux-arm64',
'@img/sharp-libvips-linux-arm64',
];
export const WIN_SHARP_IMG_PACKAGES = ['@img/sharp-win32-x64'];
/**
* @param {string} root
* @param {string[]} packageNames
* @returns {string[]} entries like `@img/sharp-darwin-x64@0.34.5`
*/
export function sharpImgPackagesToInstall(root, packageNames) {
const sharpPkgPath = path.join(root, 'node_modules', 'sharp', 'package.json');
if (!fs.existsSync(sharpPkgPath)) {
throw new Error('[release-native-prep] sharp is not installed — run npm ci first');
}
const sharpPkg = JSON.parse(fs.readFileSync(sharpPkgPath, 'utf8'));
const versions = sharpPkg.optionalDependencies ?? {};
const missing = [];
for (const name of packageNames) {
const version = versions[name];
if (!version) {
throw new Error(`[release-native-prep] sharp optionalDependency missing: ${name}`);
}
if (!fs.existsSync(path.join(root, 'node_modules', name))) {
missing.push(`${name}@${version}`);
}
}
return missing;
}
/**
* @param {string} root
* @param {string} prebuildKey
*/
export function assertClassicLevelPrebuild(root, prebuildKey) {
const dir = path.join(root, 'node_modules', 'classic-level', 'prebuilds', prebuildKey);
if (!fs.existsSync(dir)) {
throw new Error(
`[release-native-prep] classic-level prebuild missing: ${prebuildKey} (run npm ci)`,
);
}
const hasNode = fs.readdirSync(dir).some((f) => f.endsWith('.node'));
if (!hasNode) {
throw new Error(
`[release-native-prep] classic-level prebuild dir empty: ${prebuildKey}`,
);
}
}
/**
* @param {string} root
* @param {ReleaseNativeProfile} profile
* @param {{ runInstall?: boolean }} [opts]
*/
export function ensureReleaseNativeDeps(root, profile, opts = {}) {
const { runInstall = true } = opts;
const classicKeys = CLASSIC_LEVEL_PREBUILD_KEYS[profile];
for (const key of classicKeys) {
assertClassicLevelPrebuild(root, key);
}
let sharpPackages;
if (profile === 'mac') sharpPackages = MAC_SHARP_IMG_PACKAGES;
else if (profile === 'linux') sharpPackages = LINUX_SHARP_IMG_PACKAGES;
else sharpPackages = WIN_SHARP_IMG_PACKAGES;
const toInstall = sharpImgPackagesToInstall(root, sharpPackages);
if (toInstall.length === 0) {
console.log(`[release-native-prep] ${profile}: native prebuilds OK`);
return;
}
console.log(`[release-native-prep] ${profile}: installing`, toInstall.join(', '));
if (!runInstall) return;
execFileSync('npm', ['install', '--no-save', '--force', ...toInstall], {
cwd: root,
stdio: 'inherit',
shell: process.platform === 'win32',
});
}
+49
View File
@@ -0,0 +1,49 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import test from 'node:test';
import { fileURLToPath } from 'node:url';
import {
assertClassicLevelPrebuild,
CLASSIC_LEVEL_PREBUILD_KEYS,
ensureReleaseNativeDeps,
sharpImgPackagesToInstall,
WIN_SHARP_IMG_PACKAGES,
} from './release-native-prep.mjs';
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
void test('CLASSIC_LEVEL_PREBUILD_KEYS covers win/mac/linux', () => {
assert.deepEqual(CLASSIC_LEVEL_PREBUILD_KEYS.win, ['win32-x64']);
assert.ok(CLASSIC_LEVEL_PREBUILD_KEYS.mac.includes('darwin-x64+arm64'));
assert.ok(CLASSIC_LEVEL_PREBUILD_KEYS.linux.includes('linux-x64'));
assert.ok(CLASSIC_LEVEL_PREBUILD_KEYS.linux.includes('linux-arm64'));
});
void test('assertClassicLevelPrebuild: win32-x64 present after npm ci', () => {
assertClassicLevelPrebuild(root, 'win32-x64');
});
void test('ensureReleaseNativeDeps(win): does not throw when prebuilds present', () => {
ensureReleaseNativeDeps(root, 'win', { runInstall: false });
});
void test('sharpImgPackagesToInstall: lists missing win32-x64 when absent', () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'native-prep-'));
try {
const sharpOpt = {
'@img/sharp-win32-x64': '0.34.5',
};
fs.mkdirSync(path.join(tmp, 'node_modules', 'sharp'), { recursive: true });
fs.writeFileSync(
path.join(tmp, 'node_modules', 'sharp', 'package.json'),
JSON.stringify({ optionalDependencies: sharpOpt }),
);
const missing = sharpImgPackagesToInstall(tmp, WIN_SHARP_IMG_PACKAGES);
assert.deepEqual(missing, ['@img/sharp-win32-x64@0.34.5']);
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
});
+3
View File
@@ -7,6 +7,8 @@ import fs from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import { ensureReleaseNativeDeps } from './release-native-prep.mjs';
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const winUnpacked = path.join(root, 'release', 'win-unpacked'); const winUnpacked = path.join(root, 'release', 'win-unpacked');
@@ -43,3 +45,4 @@ async function tryRmWinUnpacked() {
tryKillDndPlayer(); tryKillDndPlayer();
await tryRmWinUnpacked(); await tryRmWinUnpacked();
ensureReleaseNativeDeps(root, 'win');