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:
@@ -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 };
|
||||||
|
|||||||
@@ -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': {
|
||||||
this.state = {
|
if (this.state.activeMaterialIds.includes(event.materialId)) {
|
||||||
revision: this.state.revision + 1,
|
this.state = {
|
||||||
activeMaterialId: event.materialId,
|
...this.state,
|
||||||
layout: initialLayout(event.rotationDeg),
|
revision: this.state.revision + 1,
|
||||||
legendLayout: initialLegendLayout(),
|
focusMaterialId: event.materialId,
|
||||||
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: {
|
||||||
...event.layout,
|
...this.state.layouts,
|
||||||
}),
|
[event.materialId]: clampMaterialsLayout({
|
||||||
|
...layoutFor(this.state, event.materialId),
|
||||||
|
...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: {
|
||||||
...event.layout,
|
...this.state.legendLayouts,
|
||||||
rotationDeg: 0,
|
[event.materialId]: clampMaterialsLayout({
|
||||||
}),
|
...legendLayoutFor(this.state, event.materialId),
|
||||||
|
...event.layout,
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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'));
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 }) => (
|
||||||
<MaterialOverlay
|
<React.Fragment key={material.id}>
|
||||||
embedded
|
<MaterialOverlay
|
||||||
assetId={activeMaterial.assetId}
|
embedded
|
||||||
layout={materialsOverlay?.layout ?? DEFAULT_MATERIALS_OVERLAY_LAYOUT}
|
assetId={material.assetId}
|
||||||
editable
|
materialId={material.id}
|
||||||
zoomTool={materialsZoom}
|
layout={layout}
|
||||||
rotateLabel={t('materials.rotateOverlay')}
|
editable
|
||||||
onLayoutChange={(layout) => {
|
zoomTool={materialsZoom}
|
||||||
void materialsApi.dispatch({ kind: 'layout.set', layout });
|
rotateLabel={t('materials.rotateOverlay')}
|
||||||
}}
|
onLayoutChange={(nextLayout) => {
|
||||||
{...(activeMaterial.legend?.enabled
|
void materialsApi.dispatch({
|
||||||
? { legendMarkers: activeMaterial.legend.markers ?? [] }
|
kind: 'layout.set',
|
||||||
: {})}
|
materialId: material.id,
|
||||||
/>
|
layout: nextLayout,
|
||||||
) : null}
|
});
|
||||||
{showMaterial && activeMaterial?.legend?.enabled ? (
|
}}
|
||||||
<MaterialLegendPanel
|
{...(material.legend?.enabled
|
||||||
legend={activeMaterial.legend}
|
? { legendMarkers: material.legend.markers ?? [] }
|
||||||
layout={
|
: {})}
|
||||||
materialsOverlay?.legendLayout ?? {
|
/>
|
||||||
...DEFAULT_MATERIALS_OVERLAY_LAYOUT,
|
{material.legend?.enabled ? (
|
||||||
cx: 0.82,
|
<MaterialLegendPanel
|
||||||
cy: 0.5,
|
legend={material.legend}
|
||||||
scale: 0.85,
|
layout={legendLayout}
|
||||||
}
|
editable
|
||||||
}
|
onLayoutChange={(nextLayout) => {
|
||||||
editable
|
void materialsApi.dispatch({
|
||||||
onLayoutChange={(layout) => {
|
kind: 'legendLayout.set',
|
||||||
void materialsApi.dispatch({ kind: 'legendLayout.set', layout });
|
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={() =>
|
||||||
|
|||||||
@@ -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)}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 />}
|
||||||
{previewAssetId && previewAssetType === 'video' ? (
|
</div>
|
||||||
|
{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>
|
||||||
) : null}
|
<label className={styles.checkboxLabel}>
|
||||||
{previewAssetId && previewAssetType === 'image' ? (
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={previewVideoLoop}
|
||||||
|
onChange={(e) => onPreviewVideoLoopChange(e.target.checked)}
|
||||||
|
/>
|
||||||
|
<span className={styles.spanSm}>{t('campaign.loop')}</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{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}
|
||||||
|
|
||||||
<label className={styles.row}>
|
<div className={styles.legendHeadRow}>
|
||||||
<input type="checkbox" checked={draft.enabled} onChange={(e) => setEnabled(e.target.checked)} />
|
{onRotate ? (
|
||||||
<span>Легенда</span>
|
<Button onClick={onRotate}>{rotateLabel}</Button>
|
||||||
</label>
|
) : null}
|
||||||
|
<label className={styles.row}>
|
||||||
|
<input type="checkbox" checked={draft.enabled} onChange={(e) => setEnabled(e.target.checked)} />
|
||||||
|
<span>Легенда</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
{draft.enabled ? (
|
{draft.enabled ? (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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}”?',
|
||||||
|
|||||||
@@ -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,17 +131,19 @@ export function MaterialsApp() {
|
|||||||
<ZoomOutIcon />
|
<ZoomOutIcon />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
{activeId ? (
|
{activeIds.length > 0 ? (
|
||||||
<Button
|
<div className={matStyles.browserToolbarFullBtn}>
|
||||||
title={t('materials.closeOverlay')}
|
<Button
|
||||||
ariaLabel={t('materials.closeOverlay')}
|
title={t('materials.closeOverlay')}
|
||||||
tooltipPlacement="bottom"
|
ariaLabel={t('materials.closeOverlay')}
|
||||||
onClick={() => {
|
tooltipPlacement="bottom"
|
||||||
void overlayApi.dispatch({ kind: 'hide' });
|
onClick={() => {
|
||||||
}}
|
void overlayApi.dispatch({ kind: 'hide' });
|
||||||
>
|
}}
|
||||||
{t('materials.closeOverlay')}
|
>
|
||||||
</Button>
|
{t('materials.closeOverlay')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
<div className={matStyles.browserToolbarHint}>
|
<div className={matStyles.browserToolbarHint}>
|
||||||
{zoomTool === 'zoomIn'
|
{zoomTool === 'zoomIn'
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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}>
|
||||||
|
|||||||
@@ -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 }) => (
|
||||||
<MaterialOverlay
|
<React.Fragment key={material.id}>
|
||||||
embedded
|
<MaterialOverlay
|
||||||
assetId={activeMaterial.assetId}
|
embedded
|
||||||
layout={materialsOverlay?.layout ?? DEFAULT_MATERIALS_OVERLAY_LAYOUT}
|
assetId={material.assetId}
|
||||||
{...(activeMaterial.legend?.enabled
|
layout={layout}
|
||||||
? { legendMarkers: activeMaterial.legend.markers ?? [] }
|
materialId={material.id}
|
||||||
: {})}
|
{...(material.legend?.enabled ? { legendMarkers: material.legend.markers ?? [] } : {})}
|
||||||
/>
|
/>
|
||||||
) : null}
|
{material.legend?.enabled ? (
|
||||||
{activeMaterial?.legend?.enabled ? (
|
<MaterialLegendPanel legend={material.legend} layout={legendLayout} />
|
||||||
<MaterialLegendPanel
|
) : null}
|
||||||
legend={activeMaterial.legend}
|
</React.Fragment>
|
||||||
layout={materialsOverlay?.legendLayout ?? DEFAULT_MATERIALS_OVERLAY_LAYOUT}
|
))}
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
{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',
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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). */
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
|||||||
@@ -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', () => {
|
||||||
|
|||||||
@@ -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
@@ -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",
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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-'));
|
||||||
assert.deepEqual(missing, []);
|
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, []);
|
||||||
|
} 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', () => {
|
||||||
|
|||||||
@@ -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',
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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');
|
||||||
|
|||||||
Reference in New Issue
Block a user