feat(npcs): inspect cards, Show overlay, and optional external link
Clicking an NPC tile opens description cards (multi-select accordion); Show puts avatars on screen. Optional URL opens a webview tab. Avatar pan in the editor commits on pointer up. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -451,6 +451,7 @@ export async function buildProjectFromFoundryDocuments(
|
|||||||
disposition: 'neutral',
|
disposition: 'neutral',
|
||||||
imageOffset: { x: 0, y: 0 },
|
imageOffset: { x: 0, y: 0 },
|
||||||
imageScale: 1,
|
imageScale: 1,
|
||||||
|
externalUrl: '',
|
||||||
});
|
});
|
||||||
npcIndex += 1;
|
npcIndex += 1;
|
||||||
}
|
}
|
||||||
|
|||||||
+9
-5
@@ -124,6 +124,11 @@ if (process.platform === 'win32' && app.isPackaged && process.env.DND_DISABLE_GP
|
|||||||
app.disableHardwareAcceleration();
|
app.disableHardwareAcceleration();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (process.platform === 'darwin') {
|
||||||
|
app.commandLine.appendSwitch('enable-gpu-rasterization');
|
||||||
|
app.commandLine.appendSwitch('enable-zero-copy');
|
||||||
|
}
|
||||||
|
|
||||||
if (process.platform === 'win32') {
|
if (process.platform === 'win32') {
|
||||||
app.setAppUserModelId('com.ttrpgplayer.app');
|
app.setAppUserModelId('com.ttrpgplayer.app');
|
||||||
}
|
}
|
||||||
@@ -368,9 +373,7 @@ function syncSceneTrapsForProject(project: Project): void {
|
|||||||
|
|
||||||
function emitVideoState(): void {
|
function emitVideoState(): void {
|
||||||
const state = videoStore.getState();
|
const state = videoStore.getState();
|
||||||
for (const win of BrowserWindow.getAllWindows()) {
|
sendToAppWindows(ipcChannels.video.stateChanged, { state }, ['control', 'presentation']);
|
||||||
win.webContents.send(ipcChannels.video.stateChanged, { state });
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Периодически чистим истёкшие эффекты (в основном молнии).
|
// Периодически чистим истёкшие эффекты (в основном молнии).
|
||||||
@@ -620,7 +623,7 @@ async function main() {
|
|||||||
openNpcsWindow();
|
openNpcsWindow();
|
||||||
const npcId = req?.npcId ? asNpcId(String(req.npcId)) : null;
|
const npcId = req?.npcId ? asNpcId(String(req.npcId)) : null;
|
||||||
if (npcId) {
|
if (npcId) {
|
||||||
npcsOverlayStore.dispatch({ kind: 'show', npcId });
|
npcsOverlayStore.dispatch({ kind: 'inspect.show', npcId });
|
||||||
emitNpcsOverlayState();
|
emitNpcsOverlayState();
|
||||||
}
|
}
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
@@ -981,10 +984,11 @@ async function main() {
|
|||||||
);
|
);
|
||||||
registerHandler(
|
registerHandler(
|
||||||
ipcChannels.project.updateNpcFields,
|
ipcChannels.project.updateNpcFields,
|
||||||
async ({ npcId, name, description, groupId, ringColor, disposition, imageOffset, imageScale }) => {
|
async ({ npcId, name, description, externalUrl, groupId, ringColor, disposition, imageOffset, imageScale }) => {
|
||||||
const project = await projectStore.updateNpcFields(npcId, {
|
const project = await projectStore.updateNpcFields(npcId, {
|
||||||
...(typeof name === 'string' ? { name } : {}),
|
...(typeof name === 'string' ? { name } : {}),
|
||||||
...(typeof description === 'string' ? { description } : {}),
|
...(typeof description === 'string' ? { description } : {}),
|
||||||
|
...(typeof externalUrl === 'string' ? { externalUrl } : {}),
|
||||||
...(groupId !== undefined ? { groupId } : {}),
|
...(groupId !== undefined ? { groupId } : {}),
|
||||||
...(ringColor !== undefined ? { ringColor } : {}),
|
...(ringColor !== undefined ? { ringColor } : {}),
|
||||||
...(disposition !== undefined ? { disposition } : {}),
|
...(disposition !== undefined ? { disposition } : {}),
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
import { asNpcId } from '../../shared/types/ids';
|
||||||
|
|
||||||
|
import { NpcsOverlayStore } from './npcsOverlayStore';
|
||||||
|
|
||||||
|
const a = asNpcId('npc_a');
|
||||||
|
const b = asNpcId('npc_b');
|
||||||
|
|
||||||
|
void test('NpcsOverlayStore: inspect is independent from overlay show', () => {
|
||||||
|
const store = new NpcsOverlayStore();
|
||||||
|
store.dispatch({ kind: 'inspect.toggle', npcId: a });
|
||||||
|
store.dispatch({ kind: 'inspect.show', npcId: b });
|
||||||
|
assert.deepEqual(store.getState().inspectNpcIds, [a, b]);
|
||||||
|
assert.equal(store.getState().inspectFocusNpcId, b);
|
||||||
|
assert.deepEqual(store.getState().activeNpcIds, []);
|
||||||
|
|
||||||
|
store.dispatch({ kind: 'show', npcId: a });
|
||||||
|
assert.deepEqual(store.getState().activeNpcIds, [a]);
|
||||||
|
assert.deepEqual(store.getState().inspectNpcIds, [a, b]);
|
||||||
|
|
||||||
|
store.dispatch({ kind: 'hide' });
|
||||||
|
assert.deepEqual(store.getState().activeNpcIds, []);
|
||||||
|
assert.deepEqual(store.getState().inspectNpcIds, [a, b]);
|
||||||
|
|
||||||
|
store.dispatch({ kind: 'inspect.toggle', npcId: a });
|
||||||
|
assert.deepEqual(store.getState().inspectNpcIds, [b]);
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('NpcsOverlayStore: clear wipes inspect; ensureNpcStillExists prunes', () => {
|
||||||
|
const store = new NpcsOverlayStore();
|
||||||
|
store.dispatch({ kind: 'inspect.show', npcId: a });
|
||||||
|
store.dispatch({ kind: 'show', npcId: b });
|
||||||
|
store.ensureNpcStillExists(new Set([a]));
|
||||||
|
assert.deepEqual(store.getState().inspectNpcIds, [a]);
|
||||||
|
assert.deepEqual(store.getState().activeNpcIds, []);
|
||||||
|
store.clear();
|
||||||
|
assert.deepEqual(store.getState().inspectNpcIds, []);
|
||||||
|
});
|
||||||
@@ -16,6 +16,8 @@ function emptyState(): NpcsOverlayState {
|
|||||||
layouts: {},
|
layouts: {},
|
||||||
focusNpcId: null,
|
focusNpcId: null,
|
||||||
zoomTool: null,
|
zoomTool: null,
|
||||||
|
inspectNpcIds: [],
|
||||||
|
inspectFocusNpcId: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -23,6 +25,15 @@ function layoutFor(state: NpcsOverlayState, npcId: NpcId): NpcsOverlayLayout {
|
|||||||
return state.layouts[npcId] ?? { ...DEFAULT_NPCS_OVERLAY_LAYOUT };
|
return state.layouts[npcId] ?? { ...DEFAULT_NPCS_OVERLAY_LAYOUT };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function addInspect(ids: NpcId[], npcId: NpcId): NpcId[] {
|
||||||
|
if (ids.includes(npcId)) return ids;
|
||||||
|
return [...ids, npcId];
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeInspect(ids: NpcId[], npcId: NpcId): NpcId[] {
|
||||||
|
return ids.filter((id) => id !== npcId);
|
||||||
|
}
|
||||||
|
|
||||||
export class NpcsOverlayStore {
|
export class NpcsOverlayStore {
|
||||||
private state: NpcsOverlayState = emptyState();
|
private state: NpcsOverlayState = emptyState();
|
||||||
|
|
||||||
@@ -30,11 +41,29 @@ export class NpcsOverlayStore {
|
|||||||
return this.state;
|
return this.state;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Полный сброс (закрытие проекта / конец сессии). */
|
||||||
clear(): NpcsOverlayState {
|
clear(): NpcsOverlayState {
|
||||||
|
const s = this.state;
|
||||||
|
if (
|
||||||
|
s.activeNpcIds.length === 0 &&
|
||||||
|
s.zoomTool === null &&
|
||||||
|
s.inspectNpcIds.length === 0 &&
|
||||||
|
s.inspectFocusNpcId === null
|
||||||
|
) {
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
this.state = emptyState();
|
||||||
|
this.state.revision = s.revision + 1;
|
||||||
|
return this.state;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Снять аватары с экрана, карточки описания оставить. */
|
||||||
|
hideAvatars(): NpcsOverlayState {
|
||||||
if (this.state.activeNpcIds.length === 0 && this.state.zoomTool === null) {
|
if (this.state.activeNpcIds.length === 0 && this.state.zoomTool === null) {
|
||||||
return this.state;
|
return this.state;
|
||||||
}
|
}
|
||||||
this.state = {
|
this.state = {
|
||||||
|
...this.state,
|
||||||
revision: this.state.revision + 1,
|
revision: this.state.revision + 1,
|
||||||
activeNpcIds: [],
|
activeNpcIds: [],
|
||||||
layouts: {},
|
layouts: {},
|
||||||
@@ -47,7 +76,7 @@ export class NpcsOverlayStore {
|
|||||||
dispatch(event: NpcsOverlayEvent): NpcsOverlayState {
|
dispatch(event: NpcsOverlayEvent): NpcsOverlayState {
|
||||||
switch (event.kind) {
|
switch (event.kind) {
|
||||||
case 'hide':
|
case 'hide':
|
||||||
return this.clear();
|
return this.hideAvatars();
|
||||||
case 'show': {
|
case 'show': {
|
||||||
if (this.state.activeNpcIds.includes(event.npcId)) {
|
if (this.state.activeNpcIds.includes(event.npcId)) {
|
||||||
this.state = {
|
this.state = {
|
||||||
@@ -58,6 +87,7 @@ export class NpcsOverlayStore {
|
|||||||
return this.state;
|
return this.state;
|
||||||
}
|
}
|
||||||
this.state = {
|
this.state = {
|
||||||
|
...this.state,
|
||||||
revision: this.state.revision + 1,
|
revision: this.state.revision + 1,
|
||||||
activeNpcIds: [...this.state.activeNpcIds, event.npcId],
|
activeNpcIds: [...this.state.activeNpcIds, event.npcId],
|
||||||
layouts: {
|
layouts: {
|
||||||
@@ -65,7 +95,6 @@ export class NpcsOverlayStore {
|
|||||||
[event.npcId]: { ...DEFAULT_NPCS_OVERLAY_LAYOUT },
|
[event.npcId]: { ...DEFAULT_NPCS_OVERLAY_LAYOUT },
|
||||||
},
|
},
|
||||||
focusNpcId: event.npcId,
|
focusNpcId: event.npcId,
|
||||||
zoomTool: this.state.zoomTool,
|
|
||||||
};
|
};
|
||||||
return this.state;
|
return this.state;
|
||||||
}
|
}
|
||||||
@@ -79,6 +108,7 @@ export class NpcsOverlayStore {
|
|||||||
? (activeNpcIds[activeNpcIds.length - 1] ?? null)
|
? (activeNpcIds[activeNpcIds.length - 1] ?? null)
|
||||||
: this.state.focusNpcId;
|
: this.state.focusNpcId;
|
||||||
this.state = {
|
this.state = {
|
||||||
|
...this.state,
|
||||||
revision: this.state.revision + 1,
|
revision: this.state.revision + 1,
|
||||||
activeNpcIds,
|
activeNpcIds,
|
||||||
layouts,
|
layouts,
|
||||||
@@ -88,6 +118,7 @@ export class NpcsOverlayStore {
|
|||||||
return this.state;
|
return this.state;
|
||||||
}
|
}
|
||||||
this.state = {
|
this.state = {
|
||||||
|
...this.state,
|
||||||
revision: this.state.revision + 1,
|
revision: this.state.revision + 1,
|
||||||
activeNpcIds: [...this.state.activeNpcIds, event.npcId],
|
activeNpcIds: [...this.state.activeNpcIds, event.npcId],
|
||||||
layouts: {
|
layouts: {
|
||||||
@@ -95,7 +126,38 @@ export class NpcsOverlayStore {
|
|||||||
[event.npcId]: { ...DEFAULT_NPCS_OVERLAY_LAYOUT },
|
[event.npcId]: { ...DEFAULT_NPCS_OVERLAY_LAYOUT },
|
||||||
},
|
},
|
||||||
focusNpcId: event.npcId,
|
focusNpcId: event.npcId,
|
||||||
zoomTool: this.state.zoomTool,
|
};
|
||||||
|
return this.state;
|
||||||
|
}
|
||||||
|
case 'inspect.toggle': {
|
||||||
|
if (this.state.inspectNpcIds.includes(event.npcId)) {
|
||||||
|
const inspectNpcIds = removeInspect(this.state.inspectNpcIds, event.npcId);
|
||||||
|
const inspectFocusNpcId =
|
||||||
|
this.state.inspectFocusNpcId === event.npcId
|
||||||
|
? (inspectNpcIds[inspectNpcIds.length - 1] ?? null)
|
||||||
|
: this.state.inspectFocusNpcId;
|
||||||
|
this.state = {
|
||||||
|
...this.state,
|
||||||
|
revision: this.state.revision + 1,
|
||||||
|
inspectNpcIds,
|
||||||
|
inspectFocusNpcId,
|
||||||
|
};
|
||||||
|
return this.state;
|
||||||
|
}
|
||||||
|
this.state = {
|
||||||
|
...this.state,
|
||||||
|
revision: this.state.revision + 1,
|
||||||
|
inspectNpcIds: addInspect(this.state.inspectNpcIds, event.npcId),
|
||||||
|
inspectFocusNpcId: event.npcId,
|
||||||
|
};
|
||||||
|
return this.state;
|
||||||
|
}
|
||||||
|
case 'inspect.show': {
|
||||||
|
this.state = {
|
||||||
|
...this.state,
|
||||||
|
revision: this.state.revision + 1,
|
||||||
|
inspectNpcIds: addInspect(this.state.inspectNpcIds, event.npcId),
|
||||||
|
inspectFocusNpcId: event.npcId,
|
||||||
};
|
};
|
||||||
return this.state;
|
return this.state;
|
||||||
}
|
}
|
||||||
@@ -157,8 +219,19 @@ export class NpcsOverlayStore {
|
|||||||
|
|
||||||
ensureNpcStillExists(npcIds: ReadonlySet<NpcId>): NpcsOverlayState {
|
ensureNpcStillExists(npcIds: ReadonlySet<NpcId>): NpcsOverlayState {
|
||||||
const activeNpcIds = this.state.activeNpcIds.filter((id) => npcIds.has(id));
|
const activeNpcIds = this.state.activeNpcIds.filter((id) => npcIds.has(id));
|
||||||
if (activeNpcIds.length === this.state.activeNpcIds.length) return this.state;
|
const inspectNpcIds = this.state.inspectNpcIds.filter((id) => npcIds.has(id));
|
||||||
if (activeNpcIds.length === 0) return this.clear();
|
const sameActive = activeNpcIds.length === this.state.activeNpcIds.length;
|
||||||
|
const sameInspect = inspectNpcIds.length === this.state.inspectNpcIds.length;
|
||||||
|
if (sameActive && sameInspect) return this.state;
|
||||||
|
if (activeNpcIds.length === 0 && inspectNpcIds.length === 0) {
|
||||||
|
const zoomTool = this.state.zoomTool;
|
||||||
|
this.state = {
|
||||||
|
...emptyState(),
|
||||||
|
revision: this.state.revision + 1,
|
||||||
|
zoomTool,
|
||||||
|
};
|
||||||
|
return this.state;
|
||||||
|
}
|
||||||
const layouts: Record<string, NpcsOverlayLayout> = {};
|
const layouts: Record<string, NpcsOverlayLayout> = {};
|
||||||
for (const id of activeNpcIds) {
|
for (const id of activeNpcIds) {
|
||||||
const layout = this.state.layouts[id];
|
const layout = this.state.layouts[id];
|
||||||
@@ -168,12 +241,18 @@ export class NpcsOverlayStore {
|
|||||||
this.state.focusNpcId && activeNpcIds.includes(this.state.focusNpcId)
|
this.state.focusNpcId && activeNpcIds.includes(this.state.focusNpcId)
|
||||||
? this.state.focusNpcId
|
? this.state.focusNpcId
|
||||||
: (activeNpcIds[activeNpcIds.length - 1] ?? null);
|
: (activeNpcIds[activeNpcIds.length - 1] ?? null);
|
||||||
|
const inspectFocusNpcId =
|
||||||
|
this.state.inspectFocusNpcId && inspectNpcIds.includes(this.state.inspectFocusNpcId)
|
||||||
|
? this.state.inspectFocusNpcId
|
||||||
|
: (inspectNpcIds[inspectNpcIds.length - 1] ?? null);
|
||||||
this.state = {
|
this.state = {
|
||||||
revision: this.state.revision + 1,
|
revision: this.state.revision + 1,
|
||||||
activeNpcIds,
|
activeNpcIds,
|
||||||
layouts,
|
layouts,
|
||||||
focusNpcId,
|
focusNpcId,
|
||||||
zoomTool: this.state.zoomTool,
|
zoomTool: activeNpcIds.length === 0 ? null : this.state.zoomTool,
|
||||||
|
inspectNpcIds,
|
||||||
|
inspectFocusNpcId,
|
||||||
};
|
};
|
||||||
return this.state;
|
return this.state;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -80,6 +80,11 @@ void test('zipStore: normalizeScene defaults previewThumbAssetId for older proje
|
|||||||
assert.match(src, /function normalizeScene\(/);
|
assert.match(src, /function normalizeScene\(/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
void test('zipStore: NPC parse defaults externalUrl', () => {
|
||||||
|
const src = fs.readFileSync(path.join(here, 'zipStore.ts'), 'utf8');
|
||||||
|
assert.match(src, /externalUrl: normalizeNpcExternalUrl/);
|
||||||
|
});
|
||||||
|
|
||||||
void test('zipStore: listProjects skips unreadable archives', () => {
|
void test('zipStore: listProjects skips unreadable archives', () => {
|
||||||
const src = fs.readFileSync(path.join(here, 'zipStore.ts'), 'utf8');
|
const src = fs.readFileSync(path.join(here, 'zipStore.ts'), 'utf8');
|
||||||
assert.match(
|
assert.match(
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ import {
|
|||||||
npcDispositionRingColor,
|
npcDispositionRingColor,
|
||||||
type NpcDisposition,
|
type NpcDisposition,
|
||||||
} from '../../shared/types/npcDisposition';
|
} from '../../shared/types/npcDisposition';
|
||||||
|
import { normalizeNpcExternalUrl } from '../../shared/types/npcExternalUrl';
|
||||||
import { DEFAULT_SCENE_GRID, normalizeSceneGrid } from '../../shared/types/sceneGrid';
|
import { DEFAULT_SCENE_GRID, normalizeSceneGrid } from '../../shared/types/sceneGrid';
|
||||||
import {
|
import {
|
||||||
asPreviewRotationDeg,
|
asPreviewRotationDeg,
|
||||||
@@ -1396,6 +1397,7 @@ export class ZipProjectStore {
|
|||||||
npcId?: NpcId;
|
npcId?: NpcId;
|
||||||
name: string;
|
name: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
|
externalUrl?: string;
|
||||||
filePath?: string;
|
filePath?: string;
|
||||||
groupId?: NpcGroupId | null;
|
groupId?: NpcGroupId | null;
|
||||||
ringColor?: string;
|
ringColor?: string;
|
||||||
@@ -1477,6 +1479,9 @@ export class ZipProjectStore {
|
|||||||
name,
|
name,
|
||||||
avatarAssetId: nextAssetId ?? prev.avatarAssetId,
|
avatarAssetId: nextAssetId ?? prev.avatarAssetId,
|
||||||
description: typeof input.description === 'string' ? input.description : prev.description,
|
description: typeof input.description === 'string' ? input.description : prev.description,
|
||||||
|
...(input.externalUrl !== undefined
|
||||||
|
? { externalUrl: normalizeNpcExternalUrl(input.externalUrl) }
|
||||||
|
: {}),
|
||||||
groupId: resolveGroup(input.groupId, prev.groupId),
|
groupId: resolveGroup(input.groupId, prev.groupId),
|
||||||
...(input.ringColor !== undefined
|
...(input.ringColor !== undefined
|
||||||
? { ringColor: normalizeHexColor(input.ringColor, DEFAULT_PLAYER_RING_COLOR) }
|
? { ringColor: normalizeHexColor(input.ringColor, DEFAULT_PLAYER_RING_COLOR) }
|
||||||
@@ -1503,6 +1508,7 @@ export class ZipProjectStore {
|
|||||||
name,
|
name,
|
||||||
avatarAssetId: nextAssetId,
|
avatarAssetId: nextAssetId,
|
||||||
description: typeof input.description === 'string' ? input.description : '',
|
description: typeof input.description === 'string' ? input.description : '',
|
||||||
|
externalUrl: normalizeNpcExternalUrl(input.externalUrl),
|
||||||
x: 80 + (count % 4) * 220,
|
x: 80 + (count % 4) * 220,
|
||||||
y: 80 + Math.floor(count / 4) * 200,
|
y: 80 + Math.floor(count / 4) * 200,
|
||||||
groupId: resolveGroup(input.groupId, null),
|
groupId: resolveGroup(input.groupId, null),
|
||||||
@@ -1526,6 +1532,7 @@ export class ZipProjectStore {
|
|||||||
patch: {
|
patch: {
|
||||||
name?: string;
|
name?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
|
externalUrl?: string;
|
||||||
groupId?: NpcGroupId | null;
|
groupId?: NpcGroupId | null;
|
||||||
ringColor?: string;
|
ringColor?: string;
|
||||||
disposition?: NpcDisposition;
|
disposition?: NpcDisposition;
|
||||||
@@ -1559,6 +1566,9 @@ export class ZipProjectStore {
|
|||||||
...n,
|
...n,
|
||||||
...(name !== undefined ? { name } : {}),
|
...(name !== undefined ? { name } : {}),
|
||||||
...(typeof patch.description === 'string' ? { description: patch.description } : {}),
|
...(typeof patch.description === 'string' ? { description: patch.description } : {}),
|
||||||
|
...(patch.externalUrl !== undefined
|
||||||
|
? { externalUrl: normalizeNpcExternalUrl(patch.externalUrl) }
|
||||||
|
: {}),
|
||||||
groupId,
|
groupId,
|
||||||
disposition,
|
disposition,
|
||||||
ringColor:
|
ringColor:
|
||||||
@@ -2650,6 +2660,7 @@ function normalizeProject(p: Project): Project {
|
|||||||
name,
|
name,
|
||||||
avatarAssetId: obj.avatarAssetId,
|
avatarAssetId: obj.avatarAssetId,
|
||||||
description: typeof obj.description === 'string' ? obj.description : '',
|
description: typeof obj.description === 'string' ? obj.description : '',
|
||||||
|
externalUrl: normalizeNpcExternalUrl((obj as { externalUrl?: unknown }).externalUrl),
|
||||||
x,
|
x,
|
||||||
y,
|
y,
|
||||||
groupId: resolveNpcGroupId(obj.groupId, groupIdSet),
|
groupId: resolveNpcGroupId(obj.groupId, groupIdSet),
|
||||||
|
|||||||
@@ -52,3 +52,14 @@ void test('session IPC: нет emitSessionState на graph/NPC position hot-path
|
|||||||
);
|
);
|
||||||
assert.match(npcsEditor, /await api\.invoke\(ipcChannels\.project\.updateNpcPosition/);
|
assert.match(npcsEditor, /await api\.invoke\(ipcChannels\.project\.updateNpcPosition/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
void test('video IPC heartbeat does not fan out to every BrowserWindow', () => {
|
||||||
|
const index = fs.readFileSync(path.join(here, 'index.ts'), 'utf8');
|
||||||
|
const emit =
|
||||||
|
/function emitVideoState\(\): void \{([\s\S]*?)\n\}/.exec(index);
|
||||||
|
assert.ok(emit, 'emitVideoState');
|
||||||
|
assert.doesNotMatch(emit[1] ?? '', /getAllWindows/);
|
||||||
|
assert.match(emit[1] ?? '', /sendToAppWindows\(ipcChannels\.video\.stateChanged/);
|
||||||
|
assert.match(emit[1] ?? '', /'control'/);
|
||||||
|
assert.match(emit[1] ?? '', /'presentation'/);
|
||||||
|
});
|
||||||
|
|||||||
@@ -61,6 +61,8 @@ void test('createWindows: окно НПС закрывается с multi-window
|
|||||||
assert.ok(src.includes("createWindow('npcs'"));
|
assert.ok(src.includes("createWindow('npcs'"));
|
||||||
assert.ok(src.includes("createWindow('npcsEditor'"));
|
assert.ok(src.includes("createWindow('npcsEditor'"));
|
||||||
assert.match(src, /export function closeMultiWindow[\s\S]*closeNpcsWindow/);
|
assert.match(src, /export function closeMultiWindow[\s\S]*closeNpcsWindow/);
|
||||||
|
assert.ok(src.includes('webviewTag: true'));
|
||||||
|
assert.ok(src.includes('will-attach-webview'));
|
||||||
});
|
});
|
||||||
|
|
||||||
void test('createWindows: закрытие пульта закрывает сессионные окна и презентацию', () => {
|
void test('createWindows: закрытие пульта закрывает сессионные окна и презентацию', () => {
|
||||||
|
|||||||
@@ -54,9 +54,9 @@ const MATERIALS_WINDOW_HEIGHT = 720;
|
|||||||
const NPCS_EDITOR_WINDOW_WIDTH = 1400;
|
const NPCS_EDITOR_WINDOW_WIDTH = 1400;
|
||||||
const NPCS_EDITOR_WINDOW_HEIGHT = 860;
|
const NPCS_EDITOR_WINDOW_HEIGHT = 860;
|
||||||
|
|
||||||
/** Пульт НПС: деталь слева + список справа. */
|
/** Пульт НПС: деталь слева (webview) + список справа. */
|
||||||
const NPCS_WINDOW_WIDTH = 720;
|
const NPCS_WINDOW_WIDTH = 960;
|
||||||
const NPCS_WINDOW_HEIGHT = 720;
|
const NPCS_WINDOW_HEIGHT = 800;
|
||||||
|
|
||||||
/** Учитываем окна, которые уже уничтожены при каскадном закрытии (родитель → дочернее). */
|
/** Учитываем окна, которые уже уничтожены при каскадном закрытии (родитель → дочернее). */
|
||||||
function broadcastMultiWindowStateChanged(open: boolean): void {
|
function broadcastMultiWindowStateChanged(open: boolean): void {
|
||||||
@@ -340,9 +340,9 @@ function createWindow(kind: WindowKind, opts?: CreateWindowOpts): BrowserWindow
|
|||||||
? {
|
? {
|
||||||
width: NPCS_WINDOW_WIDTH,
|
width: NPCS_WINDOW_WIDTH,
|
||||||
height: NPCS_WINDOW_HEIGHT,
|
height: NPCS_WINDOW_HEIGHT,
|
||||||
minWidth: 560,
|
minWidth: 720,
|
||||||
maxWidth: 900,
|
maxWidth: 1280,
|
||||||
minHeight: 480,
|
minHeight: 560,
|
||||||
}
|
}
|
||||||
: {}),
|
: {}),
|
||||||
show: false,
|
show: false,
|
||||||
@@ -358,6 +358,13 @@ function createWindow(kind: WindowKind, opts?: CreateWindowOpts): BrowserWindow
|
|||||||
autoplayPolicy: 'no-user-gesture-required',
|
autoplayPolicy: 'no-user-gesture-required',
|
||||||
// file:// + бандл Vite: без этого на Windows часто не грузятся чанки; http:// (dev server) оставляем строгим.
|
// file:// + бандл Vite: без этого на Windows часто не грузятся чанки; http:// (dev server) оставляем строгим.
|
||||||
webSecurity: Boolean(process.env.VITE_DEV_SERVER_URL),
|
webSecurity: Boolean(process.env.VITE_DEV_SERVER_URL),
|
||||||
|
...(kind === 'npcs'
|
||||||
|
? {
|
||||||
|
webviewTag: true,
|
||||||
|
// webviewTag в Electron не сочетается с sandbox хоста.
|
||||||
|
sandbox: false,
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (icon) {
|
if (icon) {
|
||||||
@@ -369,6 +376,19 @@ function createWindow(kind: WindowKind, opts?: CreateWindowOpts): BrowserWindow
|
|||||||
}
|
}
|
||||||
|
|
||||||
bindWindowChromeTitle(win, kind);
|
bindWindowChromeTitle(win, kind);
|
||||||
|
if (kind === 'npcs') {
|
||||||
|
win.webContents.on('will-attach-webview', (event, guestPrefs, params) => {
|
||||||
|
delete guestPrefs.preload;
|
||||||
|
guestPrefs.nodeIntegration = false;
|
||||||
|
guestPrefs.contextIsolation = true;
|
||||||
|
guestPrefs.sandbox = true;
|
||||||
|
guestPrefs.webSecurity = true;
|
||||||
|
const src = String(params.src ?? '');
|
||||||
|
if (!src.startsWith('https:') && !src.startsWith('http:')) {
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
if (
|
if (
|
||||||
kind === 'sceneDescription' ||
|
kind === 'sceneDescription' ||
|
||||||
kind === 'materials' ||
|
kind === 'materials' ||
|
||||||
@@ -799,7 +819,7 @@ export function openNpcsWindow(): void {
|
|||||||
existing.setBounds({
|
existing.setBounds({
|
||||||
x: b.x,
|
x: b.x,
|
||||||
y: b.y,
|
y: b.y,
|
||||||
width: Math.min(NPCS_WINDOW_WIDTH, Math.max(560, b.width)),
|
width: Math.min(1280, Math.max(720, b.width)),
|
||||||
height: Math.max(NPCS_WINDOW_HEIGHT, b.height),
|
height: Math.max(NPCS_WINDOW_HEIGHT, b.height),
|
||||||
});
|
});
|
||||||
existing.show();
|
existing.show();
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
import React, { useEffect, useRef } 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';
|
||||||
@@ -26,6 +26,13 @@ function fmt(sec: number): string {
|
|||||||
return `${String(m)}:${String(r).padStart(2, '0')}`;
|
return `${String(m)}:${String(r).padStart(2, '0')}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function readVideoTimes(v: HTMLVideoElement | null): { cur: number; dur: number } {
|
||||||
|
if (!v) return { cur: 0, dur: 0 };
|
||||||
|
const dur = Number.isFinite(v.duration) ? v.duration : 0;
|
||||||
|
const cur = Number.isFinite(v.currentTime) ? v.currentTime : 0;
|
||||||
|
return { cur, dur };
|
||||||
|
}
|
||||||
|
|
||||||
export function ControlScenePreview({ session, videoRef, viewCamera = null, onContentRectChange }: Props) {
|
export function ControlScenePreview({ session, videoRef, viewCamera = null, onContentRectChange }: Props) {
|
||||||
const { t } = useEditorI18n();
|
const { t } = useEditorI18n();
|
||||||
const [vp, video] = useVideoPlaybackState();
|
const [vp, video] = useVideoPlaybackState();
|
||||||
@@ -37,28 +44,27 @@ export function ControlScenePreview({ session, videoRef, viewCamera = null, onCo
|
|||||||
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 lastTargetRef = useRef<{ sceneKey: string; assetId: string; autostart: boolean } | null>(null);
|
||||||
|
const scrubRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const scrubFillRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const curTimeRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const durTimeRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
const [tick, setTick] = useState(0);
|
const paintScrub = (): void => {
|
||||||
const dur = useMemo(
|
const { cur, dur } = readVideoTimes(videoRef.current);
|
||||||
() => {
|
|
||||||
const v = videoRef.current;
|
|
||||||
if (!v) return 0;
|
|
||||||
return Number.isFinite(v.duration) ? v.duration : 0;
|
|
||||||
},
|
|
||||||
// tick: timeupdate / loadedmetadata перечитывают duration и currentTime
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- намеренно
|
|
||||||
[tick, videoRef],
|
|
||||||
);
|
|
||||||
const cur = useMemo(
|
|
||||||
() => {
|
|
||||||
const v = videoRef.current;
|
|
||||||
if (!v) return 0;
|
|
||||||
return Number.isFinite(v.currentTime) ? v.currentTime : 0;
|
|
||||||
},
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- намеренно
|
|
||||||
[tick, videoRef],
|
|
||||||
);
|
|
||||||
const pct = dur > 0 ? Math.max(0, Math.min(1, cur / dur)) : 0;
|
const pct = dur > 0 ? Math.max(0, Math.min(1, cur / dur)) : 0;
|
||||||
|
if (scrubFillRef.current) {
|
||||||
|
scrubFillRef.current.style.width = `${String(Math.round(pct * 100))}%`;
|
||||||
|
}
|
||||||
|
if (curTimeRef.current) curTimeRef.current.textContent = fmt(cur);
|
||||||
|
if (durTimeRef.current) durTimeRef.current.textContent = dur ? fmt(dur) : '—:—';
|
||||||
|
if (scrubRef.current) {
|
||||||
|
scrubRef.current.setAttribute('aria-valuemin', '0');
|
||||||
|
scrubRef.current.setAttribute('aria-valuemax', String(dur > 0 ? Math.round(dur) : 0));
|
||||||
|
scrubRef.current.setAttribute('aria-valuenow', String(Math.round(cur)));
|
||||||
|
scrubRef.current.classList.toggle(styles.scrubPointer, dur > 0);
|
||||||
|
scrubRef.current.classList.toggle(styles.scrubDefault, dur <= 0);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isVideo) return;
|
if (!isVideo) return;
|
||||||
@@ -91,10 +97,21 @@ export function ControlScenePreview({ session, videoRef, viewCamera = null, onCo
|
|||||||
} else {
|
} else {
|
||||||
v.pause();
|
v.pause();
|
||||||
}
|
}
|
||||||
|
paintScrub();
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- avoid reruns on 500ms heartbeats (serverNowMs-only updates)
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- avoid reruns on 500ms heartbeats (serverNowMs-only updates)
|
||||||
}, [assetId, url, vp?.revision, vp?.targetAssetId, vp?.playing, vp?.playbackRate, videoRef]);
|
}, [assetId, url, vp?.revision, vp?.targetAssetId, vp?.playing, vp?.playbackRate, videoRef]);
|
||||||
|
|
||||||
const scrubClass = [styles.scrub, dur ? styles.scrubPointer : styles.scrubDefault].join(' ');
|
const seekByClientX = (clientX: number): void => {
|
||||||
|
const v = videoRef.current;
|
||||||
|
const { dur } = readVideoTimes(v);
|
||||||
|
if (!v || !dur || !scrubRef.current) return;
|
||||||
|
const rect = scrubRef.current.getBoundingClientRect();
|
||||||
|
const next = (clientX - rect.left) / Math.max(1, rect.width);
|
||||||
|
const timeSec = Math.max(0, Math.min(dur, next * dur));
|
||||||
|
void video.dispatch({ kind: 'seek', timeSec });
|
||||||
|
v.currentTime = timeSec;
|
||||||
|
paintScrub();
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.root}>
|
<div className={styles.root}>
|
||||||
@@ -116,8 +133,8 @@ export function ControlScenePreview({ session, videoRef, viewCamera = null, onCo
|
|||||||
preload="auto"
|
preload="auto"
|
||||||
viewCamera={viewCamera}
|
viewCamera={viewCamera}
|
||||||
onContentRectChange={onContentRectChange}
|
onContentRectChange={onContentRectChange}
|
||||||
onTimeUpdate={() => setTick((x) => x + 1)}
|
onTimeUpdate={paintScrub}
|
||||||
onLoadedMetadata={() => setTick((x) => x + 1)}
|
onLoadedMetadata={paintScrub}
|
||||||
>
|
>
|
||||||
<track kind="captions" srcLang="ru" label={t('control.previewTrackLabel')} />
|
<track kind="captions" srcLang="ru" label={t('control.previewTrackLabel')} />
|
||||||
</ContainedVideo>
|
</ContainedVideo>
|
||||||
@@ -128,30 +145,27 @@ export function ControlScenePreview({ session, videoRef, viewCamera = null, onCo
|
|||||||
{isVideo ? (
|
{isVideo ? (
|
||||||
<div className={styles.controls}>
|
<div className={styles.controls}>
|
||||||
<div
|
<div
|
||||||
|
ref={scrubRef}
|
||||||
role="slider"
|
role="slider"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
aria-valuemin={0}
|
aria-valuemin={0}
|
||||||
aria-valuemax={dur > 0 ? Math.round(dur) : 0}
|
aria-valuemax={0}
|
||||||
aria-valuenow={Math.round(cur)}
|
aria-valuenow={0}
|
||||||
className={scrubClass}
|
className={[styles.scrub, styles.scrubDefault].join(' ')}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
const v = videoRef.current;
|
seekByClientX(e.clientX);
|
||||||
if (!v || !dur) return;
|
|
||||||
const rect = (e.currentTarget as HTMLDivElement).getBoundingClientRect();
|
|
||||||
const next = (e.clientX - rect.left) / rect.width;
|
|
||||||
void video.dispatch({ kind: 'seek', timeSec: Math.max(0, Math.min(dur, next * dur)) });
|
|
||||||
setTick((x) => x + 1);
|
|
||||||
}}
|
}}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
|
const { cur, dur } = readVideoTimes(videoRef.current);
|
||||||
if (!dur) return;
|
if (!dur) return;
|
||||||
if (e.key === 'ArrowLeft') void video.dispatch({ kind: 'seek', timeSec: Math.max(0, cur - 5) });
|
if (e.key === 'ArrowLeft') void video.dispatch({ kind: 'seek', timeSec: Math.max(0, cur - 5) });
|
||||||
if (e.key === 'ArrowRight')
|
if (e.key === 'ArrowRight')
|
||||||
void video.dispatch({ kind: 'seek', timeSec: Math.min(dur, cur + 5) });
|
void video.dispatch({ kind: 'seek', timeSec: Math.min(dur, cur + 5) });
|
||||||
if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') setTick((x) => x + 1);
|
if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') paintScrub();
|
||||||
}}
|
}}
|
||||||
title={t('control.scrubSeek')}
|
title={t('control.scrubSeek')}
|
||||||
>
|
>
|
||||||
<div className={styles.scrubFill} style={{ width: `${String(Math.round(pct * 100))}%` }} />
|
<div ref={scrubFillRef} className={styles.scrubFill} style={{ width: '0%' }} />
|
||||||
</div>
|
</div>
|
||||||
<div className={styles.row}>
|
<div className={styles.row}>
|
||||||
<div className={styles.transport}>
|
<div className={styles.transport}>
|
||||||
@@ -176,7 +190,7 @@ export function ControlScenePreview({ session, videoRef, viewCamera = null, onCo
|
|||||||
className={styles.transportBtn}
|
className={styles.transportBtn}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
void video.dispatch({ kind: 'stop' });
|
void video.dispatch({ kind: 'stop' });
|
||||||
setTick((x) => x + 1);
|
paintScrub();
|
||||||
}}
|
}}
|
||||||
title={t('control.transportStop')}
|
title={t('control.transportStop')}
|
||||||
>
|
>
|
||||||
@@ -184,7 +198,9 @@ export function ControlScenePreview({ session, videoRef, viewCamera = null, onCo
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
{fmt(cur)} / {dur ? fmt(dur) : '—:—'}
|
<span ref={curTimeRef}>0:00</span>
|
||||||
|
{' / '}
|
||||||
|
<span ref={durTimeRef}>—:—</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2990,47 +2990,6 @@ function SceneListCard({
|
|||||||
decoding="async"
|
decoding="async"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : previewUrl && scene.previewAssetType === 'video' ? (
|
|
||||||
<div className={styles.sceneThumbInner}>
|
|
||||||
{scene.previewRotationDeg === 0 ? (
|
|
||||||
<video
|
|
||||||
src={previewUrl}
|
|
||||||
muted
|
|
||||||
playsInline
|
|
||||||
preload="metadata"
|
|
||||||
draggable={false}
|
|
||||||
className={styles.sceneThumbVideo}
|
|
||||||
onLoadedData={(e) => {
|
|
||||||
const v = e.currentTarget;
|
|
||||||
try {
|
|
||||||
v.currentTime = 0;
|
|
||||||
v.pause();
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<ContainedVideo
|
|
||||||
url={previewUrl}
|
|
||||||
rotationDeg={scene.previewRotationDeg}
|
|
||||||
mode="cover"
|
|
||||||
muted
|
|
||||||
playsInline
|
|
||||||
preload="metadata"
|
|
||||||
style={{ width: '100%', height: '100%' }}
|
|
||||||
onLoadedData={(e) => {
|
|
||||||
const v = e.currentTarget;
|
|
||||||
try {
|
|
||||||
v.currentTime = 0;
|
|
||||||
v.pause();
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
<div className={styles.sceneThumbEmptyInner} aria-hidden />
|
<div className={styles.sceneThumbEmptyInner} aria-hidden />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -26,7 +26,6 @@ import {
|
|||||||
isSideStoryEdge,
|
isSideStoryEdge,
|
||||||
} 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 { ContainedVideo } from '../../shared/ContainedVideo';
|
|
||||||
import { RotatedImage } from '../../shared/RotatedImage';
|
import { RotatedImage } from '../../shared/RotatedImage';
|
||||||
import { EllipsisText } from '../../shared/ui/EllipsisText';
|
import { EllipsisText } from '../../shared/ui/EllipsisText';
|
||||||
import ellipsisStyles from '../../shared/ui/ellipsisText.module.css';
|
import ellipsisStyles from '../../shared/ui/ellipsisText.module.css';
|
||||||
@@ -260,46 +259,6 @@ function SceneCardNode({ data }: NodeProps<SceneCardData>) {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : previewUrl && data.previewAssetType === 'video' ? (
|
|
||||||
<div className={styles.previewFill}>
|
|
||||||
{data.previewRotationDeg === 0 ? (
|
|
||||||
<video
|
|
||||||
src={previewUrl}
|
|
||||||
muted
|
|
||||||
playsInline
|
|
||||||
preload="metadata"
|
|
||||||
className={styles.videoCover}
|
|
||||||
onLoadedData={(e) => {
|
|
||||||
const v = e.currentTarget;
|
|
||||||
try {
|
|
||||||
v.currentTime = 0;
|
|
||||||
v.pause();
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<ContainedVideo
|
|
||||||
url={previewUrl}
|
|
||||||
rotationDeg={data.previewRotationDeg}
|
|
||||||
mode="cover"
|
|
||||||
muted
|
|
||||||
playsInline
|
|
||||||
preload="metadata"
|
|
||||||
style={{ width: '100%', height: '100%' }}
|
|
||||||
onLoadedData={(e) => {
|
|
||||||
const v = e.currentTarget;
|
|
||||||
try {
|
|
||||||
v.currentTime = 0;
|
|
||||||
v.pause();
|
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
<div className={styles.previewPlaceholder} aria-hidden />
|
<div className={styles.previewPlaceholder} aria-hidden />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -211,7 +211,7 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
|||||||
|
|
||||||
'help.section.npcs.title': 'НПС',
|
'help.section.npcs.title': 'НПС',
|
||||||
'help.section.npcs.body':
|
'help.section.npcs.body':
|
||||||
'НПС — персонажи кампании с аватаром, описанием и однонаправленными связями между собой. Они общие для проекта.\n\nВ редакторе:\n\n1) В блоке «Свойства игры» нажмите «НПС» — откроется отдельное окно редактора персонажей.\n\n2) «Добавить» — укажите уникальное имя и обязательный аватар (PNG, JPG или WebP): кнопка выбора или перетаскивание файла. При необходимости сразу заполните описание.\n\n3) Слева — список персонажей: поиск, порядок перетаскиванием; меню «⋮» — только удаление (с подтверждением; все связи с этим персонажем тоже удаляются). Персонажей можно объединять в группы (и подгруппы): создайте группу и перетащите НПС в неё или оставьте «Без группы». На графе связей доступен фильтр по группе.\n\n4) В центре — граф связей: протяните стрелку от одного персонажа к другому и укажите обязательное название связи.\n\n5) Справа — карточка выбранного персонажа: настройте круглый токен, цвет рамки, положение аватара (перетаскивание и колесо мыши для масштаба), имя, описание, группу и отношения. Поле «Тип» задаёт отношение: враждебный, нейтральный или дружественный — от этого зависит цвет кольца токена на карте.\n\n6) В «Редакторе сцены» раскройте «НПС» и перетащите персонажа на карту. Круглый токен можно двигать и менять его размер.\n\nВо время сессии на пульте токен НПС можно двигать; правый клик по маркеру:\n• если токен неактивен — «Сделать активным»;\n• если активен — «Открыть информацию» (карточка НПС), смена типа (враждебный / нейтральный / дружественный) и «Сделать неактивным».\nНа презентации токены только отображаются. Подробнее о пульте — в разделе «Пульт управления».',
|
'НПС — персонажи кампании с аватаром, описанием и однонаправленными связями между собой. Они общие для проекта.\n\nВ редакторе:\n\n1) В блоке «Свойства игры» нажмите «НПС» — откроется отдельное окно редактора персонажей.\n\n2) «Добавить» — укажите уникальное имя и обязательный аватар (PNG, JPG или WebP): кнопка выбора или перетаскивание файла. При необходимости сразу заполните описание.\n\n3) Слева — список персонажей: поиск, порядок перетаскиванием; меню «⋮» — только удаление (с подтверждением; все связи с этим персонажем тоже удаляются). Персонажей можно объединять в группы (и подгруппы): создайте группу и перетащите НПС в неё или оставьте «Без группы». На графе связей доступен фильтр по группе.\n\n4) В центре — граф связей: протяните стрелку от одного персонажа к другому и укажите обязательное название связи.\n\n5) Справа — карточка выбранного персонажа: настройте круглый токен, цвет рамки, положение аватара (перетаскивание и колесо мыши для масштаба), имя, описание, необязательную внешнюю ссылку (страница в браузере внутри окна НПС на сессии), группу и отношения. Поле «Тип» задаёт отношение: враждебный, нейтральный или дружественный — от этого зависит цвет кольца токена на карте.\n\n6) В «Редакторе сцены» раскройте «НПС» и перетащите персонажа на карту. Круглый токен можно двигать и менять его размер.\n\nВо время сессии на пульте токен НПС можно двигать; правый клик по маркеру:\n• если токен неактивен — «Сделать активным»;\n• если активен — «Открыть информацию» (карточка в окне НПС), смена типа (враждебный / нейтральный / дружественный) и «Сделать неактивным».\nВ окне НПС клик по плитке открывает карточку слева (можно несколько сразу; у каждой аккордеон). Чекбокс «Показать» выводит аватар на экран игроков. Если задана внешняя ссылка, у карточки вкладки «Описание» и «Внешняя ссылка».\nНа презентации токены только отображаются. Подробнее о пульте — в разделе «Пульт управления».',
|
||||||
|
|
||||||
'help.section.session.title': 'Запуск сессии',
|
'help.section.session.title': 'Запуск сессии',
|
||||||
'help.section.session.body':
|
'help.section.session.body':
|
||||||
@@ -473,6 +473,8 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
|||||||
'npcs.avatarEmpty': 'Аватар не выбран',
|
'npcs.avatarEmpty': 'Аватар не выбран',
|
||||||
'npcs.avatarRequired': 'Выберите аватар.',
|
'npcs.avatarRequired': 'Выберите аватар.',
|
||||||
'npcs.chooseAvatar': 'Выбрать аватар',
|
'npcs.chooseAvatar': 'Выбрать аватар',
|
||||||
|
'npcs.avatarPickHint': 'Нажмите или перетащите изображение (PNG, JPG, WebP)',
|
||||||
|
'npcs.changeAvatar': 'Сменить аватар',
|
||||||
'npcs.dropHint': 'Перетащите изображение (PNG, JPG, WebP)',
|
'npcs.dropHint': 'Перетащите изображение (PNG, JPG, WebP)',
|
||||||
'npcs.disposition': 'ТИП',
|
'npcs.disposition': 'ТИП',
|
||||||
'npcs.disposition.hostile': 'Враждебный',
|
'npcs.disposition.hostile': 'Враждебный',
|
||||||
@@ -488,6 +490,12 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
|||||||
'npcs.description': 'ОПИСАНИЕ',
|
'npcs.description': 'ОПИСАНИЕ',
|
||||||
'npcs.descriptionPlaceholder': 'Описание персонажа…',
|
'npcs.descriptionPlaceholder': 'Описание персонажа…',
|
||||||
'npcs.descriptionEmpty': 'Описание отсутствует',
|
'npcs.descriptionEmpty': 'Описание отсутствует',
|
||||||
|
'npcs.externalLink': 'ВНЕШНЯЯ ССЫЛКА',
|
||||||
|
'npcs.externalLinkPlaceholder': 'https://…',
|
||||||
|
'npcs.externalLinkInvalid': 'Нужна ссылка http или https.',
|
||||||
|
'npcs.tabDescription': 'Описание',
|
||||||
|
'npcs.tabExternalLink': 'Внешняя ссылка',
|
||||||
|
'npcs.showOnScreen': 'Показать',
|
||||||
'npcs.relations': 'Отношения',
|
'npcs.relations': 'Отношения',
|
||||||
'npcs.untitled': 'Без имени',
|
'npcs.untitled': 'Без имени',
|
||||||
'npcs.deleteTitle': 'Удаление НПС',
|
'npcs.deleteTitle': 'Удаление НПС',
|
||||||
@@ -506,7 +514,7 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
|||||||
'npcs.graphZoomOut': 'Уменьшить',
|
'npcs.graphZoomOut': 'Уменьшить',
|
||||||
'npcs.graphFitAll': 'Показать всё',
|
'npcs.graphFitAll': 'Показать всё',
|
||||||
'npcs.windowEmpty': 'Добавьте НПС в редакторе.',
|
'npcs.windowEmpty': 'Добавьте НПС в редакторе.',
|
||||||
'npcs.selectToShow': 'Выберите персонажа в списке — он появится на экране.',
|
'npcs.selectToShow': 'Выберите персонажей в списке — карточки откроются слева. Чекбокс «Показать» выводит аватар на экран.',
|
||||||
'npcs.closeOverlay': 'Закрыть всех',
|
'npcs.closeOverlay': 'Закрыть всех',
|
||||||
'npcs.rotateOverlay': 'Повернуть',
|
'npcs.rotateOverlay': 'Повернуть',
|
||||||
'npcs.zoomIn': 'Увеличить',
|
'npcs.zoomIn': 'Увеличить',
|
||||||
@@ -838,7 +846,7 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
|||||||
|
|
||||||
'help.section.npcs.title': 'NPCs',
|
'help.section.npcs.title': 'NPCs',
|
||||||
'help.section.npcs.body':
|
'help.section.npcs.body':
|
||||||
'NPCs are campaign characters with an avatar, description, and one-way relations between them. They belong to the project.\n\nIn the editor:\n\n1) Under Game properties, click NPCs to open the character editor window.\n\n2) Add — enter a unique name and a required avatar (PNG, JPG, or WebP) via Choose image or by dropping a file. Fill in the description if needed.\n\n3) On the left — character list: search, reorder by drag-and-drop; the ⋮ menu only deletes (with confirmation; relations to that character are removed too). Characters can be organized into groups (and subgroups): create a group and drag NPCs into it, or leave them Ungrouped. The relation graph has a group filter.\n\n4) In the center — relation graph: drag an arrow from one character to another and enter a required relation title.\n\n5) On the right — selected character card: circular token, ring color, avatar position (drag and mouse wheel to zoom), name, description, group, and relations. Type sets disposition: hostile, neutral, or friendly — it controls the ring color on the map.\n\n6) In Scene editor, expand NPCs and drag a character onto the map. The circular marker can be moved and resized.\n\nDuring a session on the control panel you can move NPC tokens; right-click a marker:\n• if inactive — Make active;\n• if active — Open information (NPC card), change type (hostile / neutral / friendly), and Make inactive.\nOn presentation tokens are display-only. See Control panel for more.',
|
'NPCs are campaign characters with an avatar, description, and one-way relations between them. They belong to the project.\n\nIn the editor:\n\n1) Under Game properties, click NPCs to open the character editor window.\n\n2) Add — enter a unique name and a required avatar (PNG, JPG, or WebP) via Choose image or by dropping a file. Fill in the description if needed.\n\n3) On the left — character list: search, reorder by drag-and-drop; the ⋮ menu only deletes (with confirmation; relations to that character are removed too). Characters can be organized into groups (and subgroups): create a group and drag NPCs into it, or leave them Ungrouped. The relation graph has a group filter.\n\n4) In the center — relation graph: drag an arrow from one character to another and enter a required relation title.\n\n5) On the right — selected character card: circular token, ring color, avatar position (drag and mouse wheel to zoom), name, description, optional external link (opens as a page in the session NPCs window), group, and relations. Type sets disposition: hostile, neutral, or friendly — it controls the ring color on the map.\n\n6) In Scene editor, expand NPCs and drag a character onto the map. The circular marker can be moved and resized.\n\nDuring a session on the control panel you can move NPC tokens; right-click a marker:\n• if inactive — Make active;\n• if active — Open information (card in the NPCs window), change type (hostile / neutral / friendly), and Make inactive.\nIn the NPCs window, click a tile to open the card on the left (several at once; each is an accordion). The Show checkbox puts the avatar on the player screen. If an external link is set, the card has Description and External link tabs.\nOn presentation tokens are display-only. See Control panel for more.',
|
||||||
|
|
||||||
'help.section.session.title': 'Starting a session',
|
'help.section.session.title': 'Starting a session',
|
||||||
'help.section.session.body':
|
'help.section.session.body':
|
||||||
@@ -1102,6 +1110,8 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
|||||||
'npcs.avatarEmpty': 'No avatar selected',
|
'npcs.avatarEmpty': 'No avatar selected',
|
||||||
'npcs.avatarRequired': 'Choose an avatar.',
|
'npcs.avatarRequired': 'Choose an avatar.',
|
||||||
'npcs.chooseAvatar': 'Choose avatar',
|
'npcs.chooseAvatar': 'Choose avatar',
|
||||||
|
'npcs.avatarPickHint': 'Click or drop an image (PNG, JPG, WebP)',
|
||||||
|
'npcs.changeAvatar': 'Change avatar',
|
||||||
'npcs.dropHint': 'Drop an image (PNG, JPG, WebP)',
|
'npcs.dropHint': 'Drop an image (PNG, JPG, WebP)',
|
||||||
'npcs.disposition': 'TYPE',
|
'npcs.disposition': 'TYPE',
|
||||||
'npcs.disposition.hostile': 'Hostile',
|
'npcs.disposition.hostile': 'Hostile',
|
||||||
@@ -1117,6 +1127,12 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
|||||||
'npcs.description': 'DESCRIPTION',
|
'npcs.description': 'DESCRIPTION',
|
||||||
'npcs.descriptionPlaceholder': 'Character description…',
|
'npcs.descriptionPlaceholder': 'Character description…',
|
||||||
'npcs.descriptionEmpty': 'No description',
|
'npcs.descriptionEmpty': 'No description',
|
||||||
|
'npcs.externalLink': 'EXTERNAL LINK',
|
||||||
|
'npcs.externalLinkPlaceholder': 'https://…',
|
||||||
|
'npcs.externalLinkInvalid': 'Enter an http or https URL.',
|
||||||
|
'npcs.tabDescription': 'Description',
|
||||||
|
'npcs.tabExternalLink': 'External link',
|
||||||
|
'npcs.showOnScreen': 'Show',
|
||||||
'npcs.relations': 'Relations',
|
'npcs.relations': 'Relations',
|
||||||
'npcs.untitled': 'Untitled',
|
'npcs.untitled': 'Untitled',
|
||||||
'npcs.deleteTitle': 'Delete NPC',
|
'npcs.deleteTitle': 'Delete NPC',
|
||||||
@@ -1136,7 +1152,7 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
|
|||||||
'npcs.graphZoomOut': 'Zoom out',
|
'npcs.graphZoomOut': 'Zoom out',
|
||||||
'npcs.graphFitAll': 'Fit view',
|
'npcs.graphFitAll': 'Fit view',
|
||||||
'npcs.windowEmpty': 'Add NPCs in the editor.',
|
'npcs.windowEmpty': 'Add NPCs in the editor.',
|
||||||
'npcs.selectToShow': 'Select a character in the list — they will appear on screen.',
|
'npcs.selectToShow': 'Select characters in the list — their cards open on the left. The Show checkbox puts the avatar on screen.',
|
||||||
'npcs.closeOverlay': 'Close all',
|
'npcs.closeOverlay': 'Close all',
|
||||||
'npcs.rotateOverlay': 'Rotate',
|
'npcs.rotateOverlay': 'Rotate',
|
||||||
'npcs.zoomIn': 'Zoom in',
|
'npcs.zoomIn': 'Zoom in',
|
||||||
|
|||||||
@@ -39,8 +39,9 @@
|
|||||||
padding: 14px 16px;
|
padding: 14px 16px;
|
||||||
border-right: 1px solid var(--stroke);
|
border-right: 1px solid var(--stroke);
|
||||||
background: #0f0f12;
|
background: #0f0f12;
|
||||||
display: grid;
|
display: flex;
|
||||||
gap: 14px;
|
flex-direction: column;
|
||||||
|
gap: 0;
|
||||||
align-content: start;
|
align-content: start;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,8 +54,13 @@
|
|||||||
.detailCard {
|
.detailCard {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
padding-bottom: 14px;
|
padding: 14px 0;
|
||||||
border-bottom: 1px solid var(--stroke);
|
border-bottom: 1px solid var(--stroke);
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detailCard:first-child {
|
||||||
|
padding-top: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.detailCard:last-child {
|
.detailCard:last-child {
|
||||||
@@ -62,11 +68,67 @@
|
|||||||
padding-bottom: 0;
|
padding-bottom: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.detailCardWebview {
|
||||||
|
min-height: 480px;
|
||||||
|
grid-template-rows: auto auto minmax(380px, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detailAccordion {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto 1fr;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: inherit;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detailAccordionMark {
|
||||||
|
color: var(--text2);
|
||||||
|
font-size: 12px;
|
||||||
|
width: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
.detailName {
|
.detailName {
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
font-weight: 900;
|
font-weight: 900;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.detailTabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detailTab {
|
||||||
|
border: 1px solid var(--stroke);
|
||||||
|
background: #18181b;
|
||||||
|
color: var(--text2);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 800;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detailTabActive {
|
||||||
|
border-color: #60a5fa;
|
||||||
|
color: var(--text1);
|
||||||
|
background: #1e293b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.webviewHost {
|
||||||
|
min-height: 380px;
|
||||||
|
height: 52vh;
|
||||||
|
border-radius: 10px;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid var(--stroke);
|
||||||
|
background: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
.detailSectionTitle {
|
.detailSectionTitle {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 900;
|
font-weight: 900;
|
||||||
@@ -123,8 +185,8 @@
|
|||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: auto 1fr;
|
grid-template-columns: auto 1fr;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
align-items: center;
|
align-items: start;
|
||||||
padding: 4px 8px 4px 4px;
|
padding: 6px 8px 8px 4px;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
border: 1px solid var(--stroke);
|
border: 1px solid var(--stroke);
|
||||||
background: #18181b;
|
background: #18181b;
|
||||||
@@ -133,6 +195,12 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tileMain {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.tileBody {
|
.tileBody {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 44px 1fr;
|
grid-template-columns: 44px 1fr;
|
||||||
@@ -150,6 +218,7 @@
|
|||||||
.tileMenuBtn {
|
.tileMenuBtn {
|
||||||
width: 28px;
|
width: 28px;
|
||||||
height: 28px;
|
height: 28px;
|
||||||
|
margin-top: 10px;
|
||||||
border: 0;
|
border: 0;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
@@ -229,6 +298,22 @@
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tileShow {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text2);
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tileShow input {
|
||||||
|
margin: 0;
|
||||||
|
accent-color: #60a5fa;
|
||||||
|
}
|
||||||
|
|
||||||
.muted {
|
.muted {
|
||||||
color: var(--text2);
|
color: var(--text2);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
|
|||||||
+233
-52
@@ -4,6 +4,12 @@ import { createPortal } from 'react-dom';
|
|||||||
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 {
|
||||||
|
listVisibleSceneNpcTokens,
|
||||||
|
nextSessionNpcSpawnPoint,
|
||||||
|
} from '../../shared/types/appPlayers';
|
||||||
|
import { normalizeNpcDisposition } from '../../shared/types/npcDisposition';
|
||||||
|
import { normalizeNpcExternalUrl } from '../../shared/types/npcExternalUrl';
|
||||||
import matStyles from '../editor/MaterialsModals.module.css';
|
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';
|
||||||
@@ -13,11 +19,6 @@ import { useNpcMapSpawnDrag } from '../shared/playerToken/useNpcMapSpawnDrag';
|
|||||||
import { useSceneNpcTokensSession } from '../shared/playerToken/useSceneNpcTokensSession';
|
import { useSceneNpcTokensSession } from '../shared/playerToken/useSceneNpcTokensSession';
|
||||||
import { Button, Input } from '../shared/ui/controls';
|
import { Button, Input } from '../shared/ui/controls';
|
||||||
import { useAssetUrl } from '../shared/useAssetImageUrl';
|
import { useAssetUrl } from '../shared/useAssetImageUrl';
|
||||||
import {
|
|
||||||
listVisibleSceneNpcTokens,
|
|
||||||
nextSessionNpcSpawnPoint,
|
|
||||||
} from '../../shared/types/appPlayers';
|
|
||||||
import { normalizeNpcDisposition } from '../../shared/types/npcDisposition';
|
|
||||||
|
|
||||||
import styles from './NpcsApp.module.css';
|
import styles from './NpcsApp.module.css';
|
||||||
|
|
||||||
@@ -61,18 +62,22 @@ function pruneEmptyGroupNodes(nodes: NpcGroupTreeNode[]): NpcGroupTreeNode[] {
|
|||||||
function RuntimeNpcTile({
|
function RuntimeNpcTile({
|
||||||
npc,
|
npc,
|
||||||
selected,
|
selected,
|
||||||
|
shown,
|
||||||
accentColor,
|
accentColor,
|
||||||
mapDragEnabled,
|
mapDragEnabled,
|
||||||
onActivate,
|
onActivate,
|
||||||
|
onToggleShow,
|
||||||
onAddToMap,
|
onAddToMap,
|
||||||
onMapDragBegin,
|
onMapDragBegin,
|
||||||
onMapDragEnd,
|
onMapDragEnd,
|
||||||
}: {
|
}: {
|
||||||
npc: ProjectNpc;
|
npc: ProjectNpc;
|
||||||
selected: boolean;
|
selected: boolean;
|
||||||
|
shown: boolean;
|
||||||
accentColor?: string | null;
|
accentColor?: string | null;
|
||||||
mapDragEnabled: boolean;
|
mapDragEnabled: boolean;
|
||||||
onActivate: () => void;
|
onActivate: () => void;
|
||||||
|
onToggleShow: () => void;
|
||||||
onAddToMap: () => void;
|
onAddToMap: () => void;
|
||||||
onMapDragBegin: (npcId: NpcId) => void;
|
onMapDragBegin: (npcId: NpcId) => void;
|
||||||
onMapDragEnd: () => void;
|
onMapDragEnd: () => void;
|
||||||
@@ -138,30 +143,38 @@ function RuntimeNpcTile({
|
|||||||
>
|
>
|
||||||
⋮
|
⋮
|
||||||
</button>
|
</button>
|
||||||
|
<div className={styles.tileMain}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={styles.tileBody}
|
className={styles.tileBody}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
if (!suppressClickRef.current) return;
|
if (suppressClickRef.current) {
|
||||||
suppressClickRef.current = false;
|
suppressClickRef.current = false;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onActivate();
|
||||||
}}
|
}}
|
||||||
onPointerDown={(e) => {
|
onPointerDown={(e) => {
|
||||||
if (e.button !== 0 || !mapDragEnabled) return;
|
if (e.button !== 0 || !mapDragEnabled) return;
|
||||||
dragRef.current = { pointerId: e.pointerId, x: e.clientX, y: e.clientY, started: false };
|
dragRef.current = { pointerId: e.pointerId, x: e.clientX, y: e.clientY, started: false };
|
||||||
e.currentTarget.setPointerCapture(e.pointerId);
|
|
||||||
}}
|
}}
|
||||||
onPointerMove={(e) => {
|
onPointerMove={(e) => {
|
||||||
const d = dragRef.current;
|
const d = dragRef.current;
|
||||||
if (!d || d.pointerId !== e.pointerId || d.started) return;
|
if (!d || d.pointerId !== e.pointerId || d.started) return;
|
||||||
if (Math.hypot(e.clientX - d.x, e.clientY - d.y) < 7) return;
|
if (Math.hypot(e.clientX - d.x, e.clientY - d.y) < 7) return;
|
||||||
d.started = true;
|
d.started = true;
|
||||||
|
try {
|
||||||
|
e.currentTarget.setPointerCapture(e.pointerId);
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
onMapDragBegin(npc.id);
|
onMapDragBegin(npc.id);
|
||||||
}}
|
}}
|
||||||
onPointerUp={(e) => {
|
onPointerUp={(e) => {
|
||||||
const d = dragRef.current;
|
const d = dragRef.current;
|
||||||
if (!d || d.pointerId !== e.pointerId) return;
|
if (d && d.pointerId !== e.pointerId) return;
|
||||||
dragRef.current = null;
|
dragRef.current = null;
|
||||||
try {
|
try {
|
||||||
if (e.currentTarget.hasPointerCapture(e.pointerId)) {
|
if (e.currentTarget.hasPointerCapture(e.pointerId)) {
|
||||||
@@ -170,13 +183,11 @@ function RuntimeNpcTile({
|
|||||||
} catch {
|
} catch {
|
||||||
/* ignore */
|
/* ignore */
|
||||||
}
|
}
|
||||||
if (d.started) {
|
if (d?.started) {
|
||||||
suppressClickRef.current = true;
|
suppressClickRef.current = true;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
onMapDragEnd();
|
onMapDragEnd();
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
onActivate();
|
|
||||||
}}
|
}}
|
||||||
onPointerCancel={() => {
|
onPointerCancel={() => {
|
||||||
dragRef.current = null;
|
dragRef.current = null;
|
||||||
@@ -187,6 +198,21 @@ function RuntimeNpcTile({
|
|||||||
</div>
|
</div>
|
||||||
<div className={styles.tileName}>{npc.name}</div>
|
<div className={styles.tileName}>{npc.name}</div>
|
||||||
</button>
|
</button>
|
||||||
|
<label
|
||||||
|
className={styles.tileShow}
|
||||||
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={shown}
|
||||||
|
onChange={() => {
|
||||||
|
onToggleShow();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span>{t('npcs.showOnScreen')}</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
{menu
|
{menu
|
||||||
? createPortal(
|
? createPortal(
|
||||||
<div
|
<div
|
||||||
@@ -224,8 +250,10 @@ function RuntimeGroupSection({
|
|||||||
isExpanded,
|
isExpanded,
|
||||||
onToggleExpanded,
|
onToggleExpanded,
|
||||||
selectedIds,
|
selectedIds,
|
||||||
|
shownIds,
|
||||||
mapDragEnabled,
|
mapDragEnabled,
|
||||||
onActivate,
|
onActivate,
|
||||||
|
onToggleShow,
|
||||||
onAddToMap,
|
onAddToMap,
|
||||||
onMapDragBegin,
|
onMapDragBegin,
|
||||||
onMapDragEnd,
|
onMapDragEnd,
|
||||||
@@ -235,8 +263,10 @@ function RuntimeGroupSection({
|
|||||||
isExpanded: (id: NpcGroupId) => boolean;
|
isExpanded: (id: NpcGroupId) => boolean;
|
||||||
onToggleExpanded: (id: NpcGroupId) => void;
|
onToggleExpanded: (id: NpcGroupId) => void;
|
||||||
selectedIds: ReadonlySet<NpcId>;
|
selectedIds: ReadonlySet<NpcId>;
|
||||||
|
shownIds: ReadonlySet<NpcId>;
|
||||||
mapDragEnabled: boolean;
|
mapDragEnabled: boolean;
|
||||||
onActivate: (id: NpcId) => void;
|
onActivate: (id: NpcId) => void;
|
||||||
|
onToggleShow: (id: NpcId) => void;
|
||||||
onAddToMap: (id: NpcId) => void;
|
onAddToMap: (id: NpcId) => void;
|
||||||
onMapDragBegin: (npcId: NpcId) => void;
|
onMapDragBegin: (npcId: NpcId) => void;
|
||||||
onMapDragEnd: () => void;
|
onMapDragEnd: () => void;
|
||||||
@@ -265,9 +295,11 @@ function RuntimeGroupSection({
|
|||||||
key={n.id}
|
key={n.id}
|
||||||
npc={n}
|
npc={n}
|
||||||
selected={selectedIds.has(n.id)}
|
selected={selectedIds.has(n.id)}
|
||||||
|
shown={shownIds.has(n.id)}
|
||||||
accentColor={g.color}
|
accentColor={g.color}
|
||||||
mapDragEnabled={mapDragEnabled}
|
mapDragEnabled={mapDragEnabled}
|
||||||
onActivate={() => onActivate(n.id)}
|
onActivate={() => onActivate(n.id)}
|
||||||
|
onToggleShow={() => onToggleShow(n.id)}
|
||||||
onAddToMap={() => onAddToMap(n.id)}
|
onAddToMap={() => onAddToMap(n.id)}
|
||||||
onMapDragBegin={onMapDragBegin}
|
onMapDragBegin={onMapDragBegin}
|
||||||
onMapDragEnd={onMapDragEnd}
|
onMapDragEnd={onMapDragEnd}
|
||||||
@@ -281,8 +313,10 @@ function RuntimeGroupSection({
|
|||||||
isExpanded={isExpanded}
|
isExpanded={isExpanded}
|
||||||
onToggleExpanded={onToggleExpanded}
|
onToggleExpanded={onToggleExpanded}
|
||||||
selectedIds={selectedIds}
|
selectedIds={selectedIds}
|
||||||
|
shownIds={shownIds}
|
||||||
mapDragEnabled={mapDragEnabled}
|
mapDragEnabled={mapDragEnabled}
|
||||||
onActivate={onActivate}
|
onActivate={onActivate}
|
||||||
|
onToggleShow={onToggleShow}
|
||||||
onAddToMap={onAddToMap}
|
onAddToMap={onAddToMap}
|
||||||
onMapDragBegin={onMapDragBegin}
|
onMapDragBegin={onMapDragBegin}
|
||||||
onMapDragEnd={onMapDragEnd}
|
onMapDragEnd={onMapDragEnd}
|
||||||
@@ -294,6 +328,128 @@ function RuntimeGroupSection({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function NpcExternalWebview({ src }: { src: string }) {
|
||||||
|
const hostRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const host = hostRef.current;
|
||||||
|
if (!host) return;
|
||||||
|
const el = document.createElement('webview');
|
||||||
|
el.setAttribute('src', src);
|
||||||
|
el.setAttribute('partition', 'persist:npc-external-link');
|
||||||
|
el.style.width = '100%';
|
||||||
|
el.style.height = '100%';
|
||||||
|
el.style.border = '0';
|
||||||
|
el.style.display = 'flex';
|
||||||
|
host.replaceChildren(el);
|
||||||
|
return () => {
|
||||||
|
host.replaceChildren();
|
||||||
|
};
|
||||||
|
}, [src]);
|
||||||
|
|
||||||
|
return <div ref={hostRef} className={styles.webviewHost} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function NpcInspectCard({
|
||||||
|
npc,
|
||||||
|
relations,
|
||||||
|
expanded,
|
||||||
|
onToggleExpanded,
|
||||||
|
}: {
|
||||||
|
npc: ProjectNpc;
|
||||||
|
relations: { id: string; text: string }[];
|
||||||
|
expanded: boolean;
|
||||||
|
onToggleExpanded: () => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useEditorI18n();
|
||||||
|
const externalUrl = normalizeNpcExternalUrl(npc.externalUrl);
|
||||||
|
const [tab, setTab] = useState<'description' | 'link'>('description');
|
||||||
|
const safeHtml = sanitizeSceneDescriptionHtml(npc.description);
|
||||||
|
const hasLink = Boolean(externalUrl);
|
||||||
|
const showLink = hasLink && tab === 'link';
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!hasLink && tab === 'link') setTab('description');
|
||||||
|
}, [hasLink, tab]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
className={[
|
||||||
|
styles.detailCard,
|
||||||
|
expanded && showLink ? styles.detailCardWebview : '',
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' ')}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.detailAccordion}
|
||||||
|
aria-expanded={expanded}
|
||||||
|
onClick={onToggleExpanded}
|
||||||
|
>
|
||||||
|
<span className={styles.detailAccordionMark}>{expanded ? '▾' : '▸'}</span>
|
||||||
|
<span className={styles.detailName}>{npc.name}</span>
|
||||||
|
</button>
|
||||||
|
{expanded ? (
|
||||||
|
<>
|
||||||
|
{hasLink ? (
|
||||||
|
<div className={styles.detailTabs} role="tablist">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={tab === 'description'}
|
||||||
|
className={[styles.detailTab, tab === 'description' ? styles.detailTabActive : '']
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' ')}
|
||||||
|
onClick={() => setTab('description')}
|
||||||
|
>
|
||||||
|
{t('npcs.tabDescription')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={tab === 'link'}
|
||||||
|
className={[styles.detailTab, tab === 'link' ? styles.detailTabActive : '']
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' ')}
|
||||||
|
onClick={() => setTab('link')}
|
||||||
|
>
|
||||||
|
{t('npcs.tabExternalLink')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{showLink && externalUrl ? (
|
||||||
|
<NpcExternalWebview src={externalUrl} />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{safeHtml ? (
|
||||||
|
<div>
|
||||||
|
{!hasLink ? <div className={styles.detailSectionTitle}>{t('npcs.description')}</div> : null}
|
||||||
|
<div className={styles.detailDesc} dangerouslySetInnerHTML={{ __html: safeHtml }} />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className={styles.muted}>{t('npcs.descriptionEmpty')}</div>
|
||||||
|
)}
|
||||||
|
{relations.length > 0 ? (
|
||||||
|
<div>
|
||||||
|
<div className={styles.detailSectionTitle}>{t('npcs.relations')}</div>
|
||||||
|
<div className={styles.relationsList}>
|
||||||
|
{relations.map((r) => (
|
||||||
|
<div key={r.id} className={styles.relationItem}>
|
||||||
|
{r.text}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function NpcsApp() {
|
export function NpcsApp() {
|
||||||
const { t } = useEditorI18n();
|
const { t } = useEditorI18n();
|
||||||
const api = getDndApi();
|
const api = getDndApi();
|
||||||
@@ -303,6 +459,7 @@ export function NpcsApp() {
|
|||||||
const [mapDrag, mapDragApi] = useNpcMapSpawnDrag();
|
const [mapDrag, mapDragApi] = useNpcMapSpawnDrag();
|
||||||
const [query, setQuery] = useState('');
|
const [query, setQuery] = useState('');
|
||||||
const [collapsedGroups, setCollapsedGroups] = useState<Set<NpcGroupId>>(() => new Set());
|
const [collapsedGroups, setCollapsedGroups] = useState<Set<NpcGroupId>>(() => new Set());
|
||||||
|
const [collapsedInspect, setCollapsedInspect] = useState<Set<NpcId>>(() => new Set());
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void api.invoke(ipcChannels.project.get, {}).then(({ project }) => {
|
void api.invoke(ipcChannels.project.get, {}).then(({ project }) => {
|
||||||
@@ -314,7 +471,14 @@ export function NpcsApp() {
|
|||||||
}, [api]);
|
}, [api]);
|
||||||
|
|
||||||
const activeIds = overlay?.activeNpcIds ?? [];
|
const activeIds = overlay?.activeNpcIds ?? [];
|
||||||
|
const [inspectIds, setInspectIds] = useState<NpcId[]>([]);
|
||||||
const hasActive = activeIds.length > 0;
|
const hasActive = activeIds.length > 0;
|
||||||
|
const shownIds = useMemo(() => new Set(activeIds), [activeIds]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!overlay) return;
|
||||||
|
setInspectIds(overlay.inspectNpcIds ?? []);
|
||||||
|
}, [overlay?.inspectNpcIds]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onKey = (e: KeyboardEvent) => {
|
const onKey = (e: KeyboardEvent) => {
|
||||||
@@ -343,20 +507,36 @@ export function NpcsApp() {
|
|||||||
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]);
|
||||||
const relations = useMemo(() => session?.project?.npcRelations ?? [], [session?.project?.npcRelations]);
|
const relations = useMemo(() => session?.project?.npcRelations ?? [], [session?.project?.npcRelations]);
|
||||||
const selectedIds = useMemo(() => new Set(activeIds), [activeIds]);
|
const selectedIds = useMemo(() => new Set(inspectIds), [inspectIds]);
|
||||||
const selectedNpcs = useMemo(
|
const detailNpcs = useMemo(
|
||||||
() => activeIds.map((id) => npcs.find((n) => n.id === id)).filter((n): n is ProjectNpc => Boolean(n)),
|
() => inspectIds.map((id) => npcs.find((n) => n.id === id)).filter((n): n is ProjectNpc => Boolean(n)),
|
||||||
[activeIds, npcs],
|
[inspectIds, npcs],
|
||||||
);
|
);
|
||||||
/** Описание: сфокусированный НПС (открытие информации / последний выбор), иначе все активные. */
|
|
||||||
const detailNpcs = useMemo(() => {
|
useEffect(() => {
|
||||||
const focusId = overlay?.focusNpcId ?? null;
|
const focusId = overlay?.inspectFocusNpcId ?? null;
|
||||||
if (focusId) {
|
if (!focusId) return;
|
||||||
const focused = npcs.find((n) => n.id === focusId);
|
setCollapsedInspect((prev) => {
|
||||||
if (focused) return [focused];
|
if (!prev.has(focusId)) return prev;
|
||||||
|
const next = new Set(prev);
|
||||||
|
next.delete(focusId);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, [overlay?.inspectFocusNpcId, overlay?.revision]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setCollapsedInspect((prev) => {
|
||||||
|
let changed = false;
|
||||||
|
const next = new Set(prev);
|
||||||
|
for (const id of prev) {
|
||||||
|
if (!selectedIds.has(id)) {
|
||||||
|
next.delete(id);
|
||||||
|
changed = true;
|
||||||
}
|
}
|
||||||
return selectedNpcs;
|
}
|
||||||
}, [npcs, overlay?.focusNpcId, selectedNpcs]);
|
return changed ? next : prev;
|
||||||
|
});
|
||||||
|
}, [selectedIds]);
|
||||||
|
|
||||||
const filteredNpcs = useMemo(() => {
|
const filteredNpcs = useMemo(() => {
|
||||||
const q = query.trim().toLowerCase();
|
const q = query.trim().toLowerCase();
|
||||||
@@ -411,6 +591,14 @@ export function NpcsApp() {
|
|||||||
}, [detailNpcs, npcs, relations]);
|
}, [detailNpcs, npcs, relations]);
|
||||||
|
|
||||||
const onSelectTile = useCallback(
|
const onSelectTile = useCallback(
|
||||||
|
(id: NpcId) => {
|
||||||
|
setInspectIds((prev) => (prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]));
|
||||||
|
void overlayApi.dispatch({ kind: 'inspect.toggle', npcId: id });
|
||||||
|
},
|
||||||
|
[overlayApi],
|
||||||
|
);
|
||||||
|
|
||||||
|
const onToggleShow = useCallback(
|
||||||
(id: NpcId) => {
|
(id: NpcId) => {
|
||||||
void overlayApi.dispatch({ kind: 'toggle', npcId: id });
|
void overlayApi.dispatch({ kind: 'toggle', npcId: id });
|
||||||
},
|
},
|
||||||
@@ -521,35 +709,22 @@ export function NpcsApp() {
|
|||||||
<div className={styles.body}>
|
<div className={styles.body}>
|
||||||
<div className={styles.detail}>
|
<div className={styles.detail}>
|
||||||
{detailNpcs.length > 0 ? (
|
{detailNpcs.length > 0 ? (
|
||||||
detailNpcs.map((npc) => {
|
detailNpcs.map((npc) => (
|
||||||
const safeHtml = sanitizeSceneDescriptionHtml(npc.description);
|
<NpcInspectCard
|
||||||
const npcRelations = relationsByNpcId.get(npc.id) ?? [];
|
key={npc.id}
|
||||||
return (
|
npc={npc}
|
||||||
<div key={npc.id} className={styles.detailCard}>
|
relations={relationsByNpcId.get(npc.id) ?? []}
|
||||||
<div className={styles.detailName}>{npc.name}</div>
|
expanded={!collapsedInspect.has(npc.id)}
|
||||||
{safeHtml ? (
|
onToggleExpanded={() => {
|
||||||
<div>
|
setCollapsedInspect((prev) => {
|
||||||
<div className={styles.detailSectionTitle}>{t('npcs.description')}</div>
|
const next = new Set(prev);
|
||||||
<div className={styles.detailDesc} dangerouslySetInnerHTML={{ __html: safeHtml }} />
|
if (next.has(npc.id)) next.delete(npc.id);
|
||||||
</div>
|
else next.add(npc.id);
|
||||||
) : (
|
return next;
|
||||||
<div className={styles.muted}>{t('npcs.descriptionEmpty')}</div>
|
});
|
||||||
)}
|
}}
|
||||||
{npcRelations.length > 0 ? (
|
/>
|
||||||
<div>
|
))
|
||||||
<div className={styles.detailSectionTitle}>{t('npcs.relations')}</div>
|
|
||||||
<div className={styles.relationsList}>
|
|
||||||
{npcRelations.map((r) => (
|
|
||||||
<div key={r.id} className={styles.relationItem}>
|
|
||||||
{r.text}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})
|
|
||||||
) : (
|
) : (
|
||||||
<div className={styles.detailCard}>
|
<div className={styles.detailCard}>
|
||||||
<div className={styles.detailEmpty}>
|
<div className={styles.detailEmpty}>
|
||||||
@@ -568,8 +743,10 @@ export function NpcsApp() {
|
|||||||
key={n.id}
|
key={n.id}
|
||||||
npc={n}
|
npc={n}
|
||||||
selected={selectedIds.has(n.id)}
|
selected={selectedIds.has(n.id)}
|
||||||
|
shown={shownIds.has(n.id)}
|
||||||
mapDragEnabled={mapDragEnabled}
|
mapDragEnabled={mapDragEnabled}
|
||||||
onActivate={() => onSelectTile(n.id)}
|
onActivate={() => onSelectTile(n.id)}
|
||||||
|
onToggleShow={() => onToggleShow(n.id)}
|
||||||
onAddToMap={() => spawnNpcOnMap(n.id)}
|
onAddToMap={() => spawnNpcOnMap(n.id)}
|
||||||
onMapDragBegin={onMapDragBegin}
|
onMapDragBegin={onMapDragBegin}
|
||||||
onMapDragEnd={onMapDragEnd}
|
onMapDragEnd={onMapDragEnd}
|
||||||
@@ -585,8 +762,10 @@ export function NpcsApp() {
|
|||||||
isExpanded={isExpanded}
|
isExpanded={isExpanded}
|
||||||
onToggleExpanded={toggleExpanded}
|
onToggleExpanded={toggleExpanded}
|
||||||
selectedIds={selectedIds}
|
selectedIds={selectedIds}
|
||||||
|
shownIds={shownIds}
|
||||||
mapDragEnabled={mapDragEnabled}
|
mapDragEnabled={mapDragEnabled}
|
||||||
onActivate={onSelectTile}
|
onActivate={onSelectTile}
|
||||||
|
onToggleShow={onToggleShow}
|
||||||
onAddToMap={spawnNpcOnMap}
|
onAddToMap={spawnNpcOnMap}
|
||||||
onMapDragBegin={onMapDragBegin}
|
onMapDragBegin={onMapDragBegin}
|
||||||
onMapDragEnd={onMapDragEnd}
|
onMapDragEnd={onMapDragEnd}
|
||||||
@@ -601,8 +780,10 @@ export function NpcsApp() {
|
|||||||
key={n.id}
|
key={n.id}
|
||||||
npc={n}
|
npc={n}
|
||||||
selected={selectedIds.has(n.id)}
|
selected={selectedIds.has(n.id)}
|
||||||
|
shown={shownIds.has(n.id)}
|
||||||
mapDragEnabled={mapDragEnabled}
|
mapDragEnabled={mapDragEnabled}
|
||||||
onActivate={() => onSelectTile(n.id)}
|
onActivate={() => onSelectTile(n.id)}
|
||||||
|
onToggleShow={() => onToggleShow(n.id)}
|
||||||
onAddToMap={() => spawnNpcOnMap(n.id)}
|
onAddToMap={() => spawnNpcOnMap(n.id)}
|
||||||
onMapDragBegin={onMapDragBegin}
|
onMapDragBegin={onMapDragBegin}
|
||||||
onMapDragEnd={onMapDragEnd}
|
onMapDragEnd={onMapDragEnd}
|
||||||
|
|||||||
@@ -201,10 +201,21 @@
|
|||||||
.avatarPick {
|
.avatarPick {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
justify-items: start;
|
justify-items: center;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatarPickBusy {
|
||||||
|
opacity: 0.65;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatarPickDrop {
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.avatarPreview {
|
.avatarPreview {
|
||||||
|
position: relative;
|
||||||
width: 120px;
|
width: 120px;
|
||||||
height: 120px;
|
height: 120px;
|
||||||
border-radius: 14px;
|
border-radius: 14px;
|
||||||
@@ -213,6 +224,75 @@
|
|||||||
background: #09090b;
|
background: #09090b;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.avatarPreviewPick {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatarPreviewDrop {
|
||||||
|
border-color: var(--accent, #a78bfa);
|
||||||
|
border-style: dashed;
|
||||||
|
background: var(--accent-fill-soft, rgba(167, 139, 250, 0.16));
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatarEmptyHint {
|
||||||
|
position: absolute;
|
||||||
|
inset: 10px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.35;
|
||||||
|
color: var(--text2);
|
||||||
|
pointer-events: none;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatarEdit {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.15s ease;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatarPick [data-token-circle]:hover .avatarEdit,
|
||||||
|
.avatarPreview:hover .avatarEdit {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatarPick [data-token-circle]:active .avatarEdit {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatarEditBtn {
|
||||||
|
pointer-events: auto;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: rgba(24, 24, 27, 0.88);
|
||||||
|
color: #fafafa;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatarEditBtn:hover {
|
||||||
|
background: rgba(39, 39, 42, 0.96);
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatarEditBtn:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
.avatarPreviewImg {
|
.avatarPreviewImg {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
|||||||
@@ -11,11 +11,14 @@ import type {
|
|||||||
ProjectNpcGroup,
|
ProjectNpcGroup,
|
||||||
ProjectNpcRelation,
|
ProjectNpcRelation,
|
||||||
} from '../../shared/types';
|
} from '../../shared/types';
|
||||||
|
import { normalizeNpcExternalUrl, npcExternalUrlIsValid } from '../../shared/types/npcExternalUrl';
|
||||||
import editorStyles from '../editor/EditorApp.module.css';
|
import editorStyles from '../editor/EditorApp.module.css';
|
||||||
|
import { pickFirstMaterialImagePath, useFileDropZone } from '../editor/fileDrop';
|
||||||
import { useEditorI18n } from '../editor/i18n/EditorI18nContext';
|
import { useEditorI18n } from '../editor/i18n/EditorI18nContext';
|
||||||
import { getDndApi } from '../shared/dndApi';
|
import { getDndApi } from '../shared/dndApi';
|
||||||
import { USERS_BRANCH_FEATURES_ENABLED } from '../../shared/features/usersBranchFeatures';
|
import { USERS_BRANCH_FEATURES_ENABLED } from '../../shared/features/usersBranchFeatures';
|
||||||
import { PlayerTokenView } from '../shared/playerToken/PlayerTokenView';
|
import { PlayerTokenView } from '../shared/playerToken/PlayerTokenView';
|
||||||
|
import tokenStyles from '../shared/playerToken/PlayerTokenView.module.css';
|
||||||
import { Button, Input, Select } from '../shared/ui/controls';
|
import { Button, Input, Select } from '../shared/ui/controls';
|
||||||
import { useAssetUrl } from '../shared/useAssetImageUrl';
|
import { useAssetUrl } from '../shared/useAssetImageUrl';
|
||||||
import { npcDispositionRingColor } from '../../shared/types/npcDisposition';
|
import { npcDispositionRingColor } from '../../shared/types/npcDisposition';
|
||||||
@@ -333,6 +336,8 @@ export function NpcsEditorApp() {
|
|||||||
const [pendingDeleteGroup, setPendingDeleteGroup] = useState<ProjectNpcGroup | null>(null);
|
const [pendingDeleteGroup, setPendingDeleteGroup] = useState<ProjectNpcGroup | null>(null);
|
||||||
const [graphFilter, setGraphFilter] = useState<GraphGroupFilter>('all');
|
const [graphFilter, setGraphFilter] = useState<GraphGroupFilter>('all');
|
||||||
const [nameDraft, setNameDraft] = useState('');
|
const [nameDraft, setNameDraft] = useState('');
|
||||||
|
const [linkDraft, setLinkDraft] = useState('');
|
||||||
|
const [linkError, setLinkError] = useState(false);
|
||||||
const [relationModal, setRelationModal] = useState<
|
const [relationModal, setRelationModal] = useState<
|
||||||
| { mode: 'create'; sourceNpcId: NpcId; targetNpcId: NpcId }
|
| { mode: 'create'; sourceNpcId: NpcId; targetNpcId: NpcId }
|
||||||
| { mode: 'edit'; relationId: NpcRelationId; label: string }
|
| { mode: 'edit'; relationId: NpcRelationId; label: string }
|
||||||
@@ -364,6 +369,11 @@ export function NpcsEditorApp() {
|
|||||||
setNameDraft(selected?.name ?? '');
|
setNameDraft(selected?.name ?? '');
|
||||||
}, [selected?.id, selected?.name]);
|
}, [selected?.id, selected?.name]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLinkDraft(selected?.externalUrl ?? '');
|
||||||
|
setLinkError(false);
|
||||||
|
}, [selected?.id, selected?.externalUrl]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selectedId && npcs.some((n) => n.id === selectedId)) return;
|
if (selectedId && npcs.some((n) => n.id === selectedId)) return;
|
||||||
setSelectedId(npcs[0]?.id ?? null);
|
setSelectedId(npcs[0]?.id ?? null);
|
||||||
@@ -415,6 +425,53 @@ export function NpcsEditorApp() {
|
|||||||
return { filePath: res.filePath, previewDataUrl: res.previewDataUrl };
|
return { filePath: res.filePath, previewDataUrl: res.previewDataUrl };
|
||||||
}, [api]);
|
}, [api]);
|
||||||
|
|
||||||
|
const applyAvatarPath = useCallback(
|
||||||
|
async (filePath: string) => {
|
||||||
|
if (!selected || avatarBusy) return;
|
||||||
|
setAvatarBusy(true);
|
||||||
|
try {
|
||||||
|
await api.invoke(ipcChannels.project.upsertNpc, {
|
||||||
|
npcId: selected.id,
|
||||||
|
name: selected.name,
|
||||||
|
filePath,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setAvatarBusy(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[api, avatarBusy, selected],
|
||||||
|
);
|
||||||
|
|
||||||
|
const chooseInspectorAvatar = useCallback(() => {
|
||||||
|
if (avatarBusy || !selected) return;
|
||||||
|
void (async () => {
|
||||||
|
setAvatarBusy(true);
|
||||||
|
try {
|
||||||
|
const picked = await pickAvatar();
|
||||||
|
if (!picked || !selected) return;
|
||||||
|
await api.invoke(ipcChannels.project.upsertNpc, {
|
||||||
|
npcId: selected.id,
|
||||||
|
name: selected.name,
|
||||||
|
filePath: picked.filePath,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setAvatarBusy(false);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}, [api, avatarBusy, pickAvatar, selected]);
|
||||||
|
|
||||||
|
const avatarDrop = useFileDropZone({
|
||||||
|
disabled: !selected || avatarBusy,
|
||||||
|
filterPaths: (paths) => {
|
||||||
|
const picked = pickFirstMaterialImagePath(paths);
|
||||||
|
return picked ? [picked] : [];
|
||||||
|
},
|
||||||
|
onDropPaths: (paths) => {
|
||||||
|
const picked = pickFirstMaterialImagePath(paths);
|
||||||
|
if (picked) void applyAvatarPath(picked);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const commitNpcGroupChange = useCallback(
|
const commitNpcGroupChange = useCallback(
|
||||||
async (npcId: NpcId, groupId: NpcGroupId | null, orderIds?: NpcId[]) => {
|
async (npcId: NpcId, groupId: NpcGroupId | null, orderIds?: NpcId[]) => {
|
||||||
const npc = npcs.find((n) => n.id === npcId);
|
const npc = npcs.find((n) => n.id === npcId);
|
||||||
@@ -737,7 +794,19 @@ export function NpcsEditorApp() {
|
|||||||
<>
|
<>
|
||||||
<div>
|
<div>
|
||||||
<div className={styles.fieldLabel}>{t('npcs.avatar')}</div>
|
<div className={styles.fieldLabel}>{t('npcs.avatar')}</div>
|
||||||
<div className={styles.avatarPick}>
|
<div
|
||||||
|
className={[
|
||||||
|
styles.avatarPick,
|
||||||
|
avatarDrop.dragOver ? styles.avatarPickDrop : '',
|
||||||
|
avatarBusy ? styles.avatarPickBusy : '',
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' ')}
|
||||||
|
onDragEnter={avatarDrop.onDragEnter}
|
||||||
|
onDragLeave={avatarDrop.onDragLeave}
|
||||||
|
onDragOver={avatarDrop.onDragOver}
|
||||||
|
onDrop={avatarDrop.onDrop}
|
||||||
|
>
|
||||||
{USERS_BRANCH_FEATURES_ENABLED ? (
|
{USERS_BRANCH_FEATURES_ENABLED ? (
|
||||||
<PlayerTokenView
|
<PlayerTokenView
|
||||||
name={selected.name}
|
name={selected.name}
|
||||||
@@ -746,7 +815,48 @@ export function NpcsEditorApp() {
|
|||||||
imageOffset={selected.imageOffset}
|
imageOffset={selected.imageOffset}
|
||||||
imageScale={selected.imageScale}
|
imageScale={selected.imageScale}
|
||||||
sizePx={140}
|
sizePx={140}
|
||||||
panEnabled
|
syncKey={selected.id}
|
||||||
|
panEnabled={Boolean(selectedUrl)}
|
||||||
|
dropActive={avatarDrop.dragOver}
|
||||||
|
onCircleClick={selectedUrl ? undefined : chooseInspectorAvatar}
|
||||||
|
emptyContent={
|
||||||
|
selectedUrl || avatarDrop.dragOver ? null : (
|
||||||
|
<div className={tokenStyles.avatarEmptyHint}>{t('npcs.avatarPickHint')}</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
circleOverlay={
|
||||||
|
<>
|
||||||
|
{avatarDrop.dragOver ? (
|
||||||
|
<div className={editorStyles.dropHintOverlay}>{t('npcs.dropHint')}</div>
|
||||||
|
) : null}
|
||||||
|
{selectedUrl && !avatarDrop.dragOver ? (
|
||||||
|
<div className={styles.avatarEdit}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.avatarEditBtn}
|
||||||
|
title={t('npcs.changeAvatar')}
|
||||||
|
aria-label={t('npcs.changeAvatar')}
|
||||||
|
disabled={avatarBusy}
|
||||||
|
onPointerDown={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
}}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
chooseInspectorAvatar();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" width={16} height={16} aria-hidden>
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04a1 1 0 0 0 0-1.41l-2.34-2.34a1 1 0 0 0-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
}
|
||||||
onImageOffsetChange={(imageOffset) => {
|
onImageOffsetChange={(imageOffset) => {
|
||||||
void api.invoke(ipcChannels.project.updateNpcFields, {
|
void api.invoke(ipcChannels.project.updateNpcFields, {
|
||||||
npcId: selected.id,
|
npcId: selected.id,
|
||||||
@@ -761,33 +871,48 @@ export function NpcsEditorApp() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className={styles.avatarPreview}>
|
<div
|
||||||
|
className={[
|
||||||
|
styles.avatarPreview,
|
||||||
|
avatarDrop.dragOver ? styles.avatarPreviewDrop : '',
|
||||||
|
!selectedUrl ? styles.avatarPreviewPick : '',
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' ')}
|
||||||
|
onClick={selectedUrl ? undefined : chooseInspectorAvatar}
|
||||||
|
>
|
||||||
{selectedUrl ? (
|
{selectedUrl ? (
|
||||||
<img className={styles.avatarPreviewImg} src={selectedUrl} alt="" />
|
<img className={styles.avatarPreviewImg} src={selectedUrl} alt="" />
|
||||||
|
) : avatarDrop.dragOver ? null : (
|
||||||
|
<div className={styles.avatarEmptyHint}>{t('npcs.avatarPickHint')}</div>
|
||||||
|
)}
|
||||||
|
{avatarDrop.dragOver ? (
|
||||||
|
<div className={editorStyles.dropHintOverlay}>{t('npcs.dropHint')}</div>
|
||||||
|
) : null}
|
||||||
|
{selectedUrl && !avatarDrop.dragOver ? (
|
||||||
|
<div className={styles.avatarEdit}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.avatarEditBtn}
|
||||||
|
title={t('npcs.changeAvatar')}
|
||||||
|
aria-label={t('npcs.changeAvatar')}
|
||||||
|
disabled={avatarBusy}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
chooseInspectorAvatar();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" width={16} height={16} aria-hidden>
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04a1 1 0 0 0 0-1.41l-2.34-2.34a1 1 0 0 0-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<Button
|
|
||||||
disabled={avatarBusy}
|
|
||||||
onClick={() => {
|
|
||||||
void (async () => {
|
|
||||||
setAvatarBusy(true);
|
|
||||||
try {
|
|
||||||
const picked = await pickAvatar();
|
|
||||||
if (!picked) return;
|
|
||||||
await api.invoke(ipcChannels.project.upsertNpc, {
|
|
||||||
npcId: selected.id,
|
|
||||||
name: selected.name,
|
|
||||||
filePath: picked.filePath,
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
setAvatarBusy(false);
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t('npcs.chooseAvatar')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -868,6 +993,45 @@ export function NpcsEditorApp() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className={styles.fieldLabel}>{t('npcs.externalLink')}</div>
|
||||||
|
<Input
|
||||||
|
value={linkDraft}
|
||||||
|
onChange={(next) => {
|
||||||
|
setLinkDraft(next);
|
||||||
|
if (linkError) setLinkError(!npcExternalUrlIsValid(next));
|
||||||
|
}}
|
||||||
|
onBlur={() => {
|
||||||
|
const trimmed = linkDraft.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
setLinkError(false);
|
||||||
|
setLinkDraft('');
|
||||||
|
if (selected.externalUrl) {
|
||||||
|
void api.invoke(ipcChannels.project.updateNpcFields, {
|
||||||
|
npcId: selected.id,
|
||||||
|
externalUrl: '',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const normalized = normalizeNpcExternalUrl(trimmed);
|
||||||
|
if (!normalized) {
|
||||||
|
setLinkError(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLinkError(false);
|
||||||
|
setLinkDraft(normalized);
|
||||||
|
if (normalized === selected.externalUrl) return;
|
||||||
|
void api.invoke(ipcChannels.project.updateNpcFields, {
|
||||||
|
npcId: selected.id,
|
||||||
|
externalUrl: normalized,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
placeholder={t('npcs.externalLinkPlaceholder')}
|
||||||
|
/>
|
||||||
|
{linkError ? <div className={editorStyles.fieldError}>{t('npcs.externalLinkInvalid')}</div> : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
{relationsForSelected.length > 0 ? (
|
{relationsForSelected.length > 0 ? (
|
||||||
<div>
|
<div>
|
||||||
<div className={styles.relationsTitle}>{t('npcs.relations')}</div>
|
<div className={styles.relationsTitle}>{t('npcs.relations')}</div>
|
||||||
|
|||||||
@@ -11,9 +11,12 @@ void test('PxiEffectsOverlay: canvas не перехватывает указа
|
|||||||
assert.ok(src.includes("app.canvas.style.pointerEvents = interactive ? 'auto' : 'none'"));
|
assert.ok(src.includes("app.canvas.style.pointerEvents = interactive ? 'auto' : 'none'"));
|
||||||
});
|
});
|
||||||
|
|
||||||
void test('PxiEffectsOverlay: ограничение FPS тикера для нагрузки', () => {
|
void test('PxiEffectsOverlay: lazy WebGL until effects need it', () => {
|
||||||
const src = fs.readFileSync(path.join(here, 'PxiEffectsOverlay.tsx'), 'utf8');
|
const src = fs.readFileSync(path.join(here, 'PxiEffectsOverlay.tsx'), 'utf8');
|
||||||
assert.ok(src.includes('app.ticker.maxFPS'));
|
assert.ok(src.includes('effectsNeedWebGl'));
|
||||||
|
assert.ok(src.includes('gpuBoot'));
|
||||||
|
assert.match(src, /canvas\.style\.display = 'none'/);
|
||||||
|
assert.match(src, /const dpr = useMemo\(\(\) => 1/);
|
||||||
});
|
});
|
||||||
|
|
||||||
void test('PxiEffectsOverlay: imperative setDraft для кисти без React state', () => {
|
void test('PxiEffectsOverlay: imperative setDraft для кисти без React state', () => {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { forwardRef, useEffect, useImperativeHandle, useMemo, useRef } from 'react';
|
import React, { forwardRef, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react';
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
EffectInstance,
|
EffectInstance,
|
||||||
@@ -140,6 +140,18 @@ function effectsHaveWork(state: EffectsState | null): boolean {
|
|||||||
return Boolean(state && state.instances.length > 0);
|
return Boolean(state && state.instances.length > 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** WebGL не поднимаем, пока нет полевых/action-эффектов — иначе видео на Retina/M1 композитится через лишний слой. */
|
||||||
|
function effectsNeedWebGl(state: EffectsState | null, draft: EffectInstance | null): boolean {
|
||||||
|
const merged = mergeDraftState(state, draft);
|
||||||
|
if (effectsHaveWork(merged)) return true;
|
||||||
|
const tool = merged?.tool.tool;
|
||||||
|
if (!tool || tool === 'none' || tool === 'darkness' || tool === 'exploreBrush' || tool === 'closeBrush') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (tool === 'eraser') return effectsHaveWork(state);
|
||||||
|
return vfxPacksForTool(tool).length > 0 || tool === 'freeze';
|
||||||
|
}
|
||||||
|
|
||||||
const LIGHTNING_VFX_FRAME_COUNT = 19;
|
const LIGHTNING_VFX_FRAME_COUNT = 19;
|
||||||
const LIGHTNING_VFX_FRAME_ASPECT = 420 / 473;
|
const LIGHTNING_VFX_FRAME_ASPECT = 420 / 473;
|
||||||
const LIGHTNING_VFX_STRIKE_MS = 320;
|
const LIGHTNING_VFX_STRIKE_MS = 320;
|
||||||
@@ -208,6 +220,9 @@ export const PixiEffectsOverlay = forwardRef<PixiEffectsOverlayHandle, Props>(fu
|
|||||||
const viewportProvidedRef = useRef(false);
|
const viewportProvidedRef = useRef(false);
|
||||||
/** null = ещё не синхронизировали с Pixi (ticker по умолчанию бежит). */
|
/** null = ещё не синхронизировали с Pixi (ticker по умолчанию бежит). */
|
||||||
const tickerWantedRef = useRef<boolean | null>(null);
|
const tickerWantedRef = useRef<boolean | null>(null);
|
||||||
|
const [gpuBoot, setGpuBoot] = useState(false);
|
||||||
|
const setGpuBootRef = useRef(setGpuBoot);
|
||||||
|
setGpuBootRef.current = setGpuBoot;
|
||||||
|
|
||||||
const syncTickerForState = (merged: EffectsState | null): void => {
|
const syncTickerForState = (merged: EffectsState | null): void => {
|
||||||
const app = appRef.current;
|
const app = appRef.current;
|
||||||
@@ -217,6 +232,10 @@ export const PixiEffectsOverlay = forwardRef<PixiEffectsOverlayHandle, Props>(fu
|
|||||||
tickerWantedRef.current = want;
|
tickerWantedRef.current = want;
|
||||||
if (want) {
|
if (want) {
|
||||||
try {
|
try {
|
||||||
|
if (app.canvas) {
|
||||||
|
app.canvas.style.display = 'block';
|
||||||
|
app.canvas.style.visibility = 'visible';
|
||||||
|
}
|
||||||
app.ticker.start();
|
app.ticker.start();
|
||||||
} catch {
|
} catch {
|
||||||
/* ignore */
|
/* ignore */
|
||||||
@@ -240,6 +259,14 @@ export const PixiEffectsOverlay = forwardRef<PixiEffectsOverlayHandle, Props>(fu
|
|||||||
} catch {
|
} catch {
|
||||||
/* ignore */
|
/* ignore */
|
||||||
}
|
}
|
||||||
|
try {
|
||||||
|
if (app.canvas) {
|
||||||
|
app.canvas.style.visibility = 'hidden';
|
||||||
|
app.canvas.style.display = 'none';
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const applyMergedState = (committed: EffectsState | null, draft: EffectInstance | null): void => {
|
const applyMergedState = (committed: EffectsState | null, draft: EffectInstance | null): void => {
|
||||||
@@ -265,16 +292,22 @@ export const PixiEffectsOverlay = forwardRef<PixiEffectsOverlayHandle, Props>(fu
|
|||||||
ref,
|
ref,
|
||||||
() => ({
|
() => ({
|
||||||
setDraft: (instance) => {
|
setDraft: (instance) => {
|
||||||
|
if (instance) setGpuBootRef.current(true);
|
||||||
applyMergedState(committedStateRef.current, instance);
|
applyMergedState(committedStateRef.current, instance);
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
/** Снижаем resolution на HiDPI — меньше пикселей в WebGL, визуально ок для оверлея эффектов. */
|
/** 1 CSS-пиксель: на Retina/M1 лишний dpr сильно бьёт по fill-rate поверх видео. */
|
||||||
const dpr = useMemo(() => Math.min(1.5, window.devicePixelRatio || 1), []);
|
const dpr = useMemo(() => 1, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (effectsNeedWebGl(state, draftRef.current)) setGpuBoot(true);
|
||||||
|
}, [state]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!gpuBoot) return;
|
||||||
const host = hostRef.current;
|
const host = hostRef.current;
|
||||||
if (!host) return;
|
if (!host) return;
|
||||||
|
|
||||||
@@ -371,7 +404,7 @@ export const PixiEffectsOverlay = forwardRef<PixiEffectsOverlayHandle, Props>(fu
|
|||||||
host.replaceChildren();
|
host.replaceChildren();
|
||||||
};
|
};
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, []);
|
}, [gpuBoot]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const host = hostRef.current;
|
const host = hostRef.current;
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ export function SceneGridOverlay({ grid, viewport }: Props) {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const canvas = canvasRef.current;
|
const canvas = canvasRef.current;
|
||||||
if (!canvas || !viewport || !grid?.enabled) return;
|
if (!canvas || !viewport || !grid?.enabled) return;
|
||||||
const dpr = Math.max(1, Math.min(2, window.devicePixelRatio || 1));
|
const dpr = 1;
|
||||||
const w = Math.max(1, Math.round(viewport.w));
|
const w = Math.max(1, Math.round(viewport.w));
|
||||||
const h = Math.max(1, Math.round(viewport.h));
|
const h = Math.max(1, Math.round(viewport.h));
|
||||||
canvas.width = Math.round(w * dpr);
|
canvas.width = Math.round(w * dpr);
|
||||||
|
|||||||
@@ -13,10 +13,19 @@ export function useNpcsOverlayState(): readonly [
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void api.invoke(ipcChannels.npcsOverlay.getState, {}).then((r) => {
|
void api.invoke(ipcChannels.npcsOverlay.getState, {}).then((r) => {
|
||||||
setState(r.state);
|
const next = r.state;
|
||||||
|
setState({
|
||||||
|
...next,
|
||||||
|
inspectNpcIds: next.inspectNpcIds ?? [],
|
||||||
|
inspectFocusNpcId: next.inspectFocusNpcId ?? null,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
return api.on(ipcChannels.npcsOverlay.stateChanged, ({ state: next }) => {
|
return api.on(ipcChannels.npcsOverlay.stateChanged, ({ state: next }) => {
|
||||||
setState(next);
|
setState({
|
||||||
|
...next,
|
||||||
|
inspectNpcIds: next.inspectNpcIds ?? [],
|
||||||
|
inspectFocusNpcId: next.inspectFocusNpcId ?? null,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}, [api]);
|
}, [api]);
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,15 @@
|
|||||||
cursor: grabbing;
|
cursor: grabbing;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.circlePick {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.circleDrop {
|
||||||
|
border-color: var(--accent, #a78bfa);
|
||||||
|
box-shadow: 0 0 0 3px var(--accent-fill-soft, rgba(167, 139, 250, 0.28));
|
||||||
|
}
|
||||||
|
|
||||||
.avatar {
|
.avatar {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 50%;
|
left: 50%;
|
||||||
@@ -47,6 +56,25 @@
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.avatarEmptySolid {
|
||||||
|
background: #18181b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatarEmptyHint {
|
||||||
|
position: absolute;
|
||||||
|
inset: 12px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.35;
|
||||||
|
color: var(--text2, #a1a1aa);
|
||||||
|
pointer-events: none;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
.namePlate {
|
.namePlate {
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
padding: 4px 10px;
|
padding: 4px 10px;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useCallback, useEffect, useRef } from 'react';
|
import React, { useCallback, useEffect, useLayoutEffect, useRef, type ReactNode } from 'react';
|
||||||
|
|
||||||
import type { PlayerImageOffset } from '../../../shared/types/appPlayers';
|
import type { PlayerImageOffset } from '../../../shared/types/appPlayers';
|
||||||
import {
|
import {
|
||||||
@@ -11,6 +11,17 @@ import {
|
|||||||
|
|
||||||
import styles from './PlayerTokenView.module.css';
|
import styles from './PlayerTokenView.module.css';
|
||||||
|
|
||||||
|
const SCALE_PERSIST_MS = 200;
|
||||||
|
|
||||||
|
function writeAvatarTransform(
|
||||||
|
img: HTMLImageElement | null,
|
||||||
|
offset: PlayerImageOffset,
|
||||||
|
scale: number,
|
||||||
|
) {
|
||||||
|
if (!img) return;
|
||||||
|
img.style.transform = `translate(-50%, -50%) translate(${String(offset.x * 100)}%, ${String(offset.y * 100)}%) scale(${String(scale)})`;
|
||||||
|
}
|
||||||
|
|
||||||
export type PlayerTokenViewProps = {
|
export type PlayerTokenViewProps = {
|
||||||
name: string;
|
name: string;
|
||||||
imageUrl: string | null;
|
imageUrl: string | null;
|
||||||
@@ -28,6 +39,16 @@ export type PlayerTokenViewProps = {
|
|||||||
hideName?: boolean;
|
hideName?: boolean;
|
||||||
/** Session «Неактивен»: серый фильтр аватара. */
|
/** Session «Неактивен»: серый фильтр аватара. */
|
||||||
inactive?: boolean;
|
inactive?: boolean;
|
||||||
|
/** Текст/контент внутри пустого круга (только редактор, не маркеры на карте). */
|
||||||
|
emptyContent?: ReactNode;
|
||||||
|
/** Оверлей поверх круга (карандаш, drop-hint). */
|
||||||
|
circleOverlay?: ReactNode;
|
||||||
|
/** Подсветка круга при файловом DnD. */
|
||||||
|
dropActive?: boolean;
|
||||||
|
/** Клик по пустому кругу (выбор файла). */
|
||||||
|
onCircleClick?: () => void;
|
||||||
|
/** Сброс локального pan/zoom при смене сущности (npcId / playerId). */
|
||||||
|
syncKey?: string;
|
||||||
'data-testid'?: string;
|
'data-testid'?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -44,13 +65,24 @@ export function PlayerTokenView({
|
|||||||
className,
|
className,
|
||||||
hideName = false,
|
hideName = false,
|
||||||
inactive = false,
|
inactive = false,
|
||||||
|
emptyContent,
|
||||||
|
circleOverlay,
|
||||||
|
dropActive = false,
|
||||||
|
onCircleClick,
|
||||||
|
syncKey,
|
||||||
'data-testid': testId,
|
'data-testid': testId,
|
||||||
}: PlayerTokenViewProps) {
|
}: PlayerTokenViewProps) {
|
||||||
const offset = clampPlayerImageOffset(imageOffset);
|
const offset = clampPlayerImageOffset(imageOffset);
|
||||||
const scale = clampPlayerImageScale(imageScale ?? DEFAULT_PLAYER_IMAGE_SCALE);
|
const scale = clampPlayerImageScale(imageScale ?? DEFAULT_PLAYER_IMAGE_SCALE);
|
||||||
const circleRef = useRef<HTMLDivElement | null>(null);
|
const circleRef = useRef<HTMLDivElement | null>(null);
|
||||||
const scaleRef = useRef(scale);
|
const imgRef = useRef<HTMLImageElement | null>(null);
|
||||||
scaleRef.current = scale;
|
const visualOffsetRef = useRef(offset);
|
||||||
|
const visualScaleRef = useRef(scale);
|
||||||
|
const pendingOffsetRef = useRef<PlayerImageOffset | null>(null);
|
||||||
|
const pendingScaleRef = useRef<number | null>(null);
|
||||||
|
const scaleTimerRef = useRef(0);
|
||||||
|
const onImageOffsetChangeRef = useRef(onImageOffsetChange);
|
||||||
|
onImageOffsetChangeRef.current = onImageOffsetChange;
|
||||||
const onImageScaleChangeRef = useRef(onImageScaleChange);
|
const onImageScaleChangeRef = useRef(onImageScaleChange);
|
||||||
onImageScaleChangeRef.current = onImageScaleChange;
|
onImageScaleChangeRef.current = onImageScaleChange;
|
||||||
const dragRef = useRef<{
|
const dragRef = useRef<{
|
||||||
@@ -61,9 +93,63 @@ export function PlayerTokenView({
|
|||||||
origY: number;
|
origY: number;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
pendingOffsetRef.current = null;
|
||||||
|
pendingScaleRef.current = null;
|
||||||
|
dragRef.current = null;
|
||||||
|
visualOffsetRef.current = clampPlayerImageOffset(imageOffset);
|
||||||
|
visualScaleRef.current = clampPlayerImageScale(imageScale ?? DEFAULT_PLAYER_IMAGE_SCALE);
|
||||||
|
writeAvatarTransform(imgRef.current, visualOffsetRef.current, visualScaleRef.current);
|
||||||
|
// identity only — offset/scale sync is handled below
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [syncKey]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (dragRef.current) return;
|
||||||
|
if (pendingOffsetRef.current) {
|
||||||
|
if (
|
||||||
|
Math.abs(offset.x - pendingOffsetRef.current.x) < 1e-6 &&
|
||||||
|
Math.abs(offset.y - pendingOffsetRef.current.y) < 1e-6
|
||||||
|
) {
|
||||||
|
pendingOffsetRef.current = null;
|
||||||
|
} else {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
visualOffsetRef.current = offset;
|
||||||
|
writeAvatarTransform(imgRef.current, offset, visualScaleRef.current);
|
||||||
|
}, [offset.x, offset.y]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (scaleTimerRef.current) return;
|
||||||
|
if (pendingScaleRef.current != null) {
|
||||||
|
if (Math.abs(scale - pendingScaleRef.current) < 1e-6) {
|
||||||
|
pendingScaleRef.current = null;
|
||||||
|
} else {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
visualScaleRef.current = scale;
|
||||||
|
writeAvatarTransform(imgRef.current, visualOffsetRef.current, scale);
|
||||||
|
}, [scale]);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
writeAvatarTransform(imgRef.current, visualOffsetRef.current, visualScaleRef.current);
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (scaleTimerRef.current) {
|
||||||
|
window.clearTimeout(scaleTimerRef.current);
|
||||||
|
scaleTimerRef.current = 0;
|
||||||
|
onImageScaleChangeRef.current?.(visualScaleRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
const onPointerDown = useCallback(
|
const onPointerDown = useCallback(
|
||||||
(e: React.PointerEvent) => {
|
(e: React.PointerEvent) => {
|
||||||
if (!panEnabled || !onImageOffsetChange) return;
|
if (!panEnabled || !onImageOffsetChangeRef.current || !imageUrl) return;
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
|
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
|
||||||
@@ -71,45 +157,56 @@ export function PlayerTokenView({
|
|||||||
pointerId: e.pointerId,
|
pointerId: e.pointerId,
|
||||||
startX: e.clientX,
|
startX: e.clientX,
|
||||||
startY: e.clientY,
|
startY: e.clientY,
|
||||||
origX: offset.x,
|
origX: visualOffsetRef.current.x,
|
||||||
origY: offset.y,
|
origY: visualOffsetRef.current.y,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
[offset.x, offset.y, onImageOffsetChange, panEnabled],
|
[imageUrl, panEnabled],
|
||||||
);
|
);
|
||||||
|
|
||||||
const onPointerMove = useCallback(
|
const onPointerMove = useCallback(
|
||||||
(e: React.PointerEvent) => {
|
(e: React.PointerEvent) => {
|
||||||
const d = dragRef.current;
|
const d = dragRef.current;
|
||||||
if (!d || d.pointerId !== e.pointerId || !onImageOffsetChange) return;
|
if (!d || d.pointerId !== e.pointerId) return;
|
||||||
const dx = (e.clientX - d.startX) / Math.max(1, sizePx);
|
const dx = (e.clientX - d.startX) / Math.max(1, sizePx);
|
||||||
const dy = (e.clientY - d.startY) / Math.max(1, sizePx);
|
const dy = (e.clientY - d.startY) / Math.max(1, sizePx);
|
||||||
onImageOffsetChange(clampPlayerImageOffset({ x: d.origX + dx, y: d.origY + dy }));
|
const next = clampPlayerImageOffset({ x: d.origX + dx, y: d.origY + dy });
|
||||||
|
visualOffsetRef.current = next;
|
||||||
|
writeAvatarTransform(imgRef.current, next, visualScaleRef.current);
|
||||||
},
|
},
|
||||||
[onImageOffsetChange, sizePx],
|
[sizePx],
|
||||||
);
|
);
|
||||||
|
|
||||||
const onPointerUp = useCallback((e: React.PointerEvent) => {
|
const onPointerUp = useCallback((e: React.PointerEvent) => {
|
||||||
const d = dragRef.current;
|
const d = dragRef.current;
|
||||||
if (!d || d.pointerId !== e.pointerId) return;
|
if (!d || d.pointerId !== e.pointerId) return;
|
||||||
dragRef.current = null;
|
dragRef.current = null;
|
||||||
|
pendingOffsetRef.current = visualOffsetRef.current;
|
||||||
try {
|
try {
|
||||||
(e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId);
|
(e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId);
|
||||||
} catch {
|
} catch {
|
||||||
/* ignore */
|
/* ignore */
|
||||||
}
|
}
|
||||||
|
onImageOffsetChangeRef.current?.(visualOffsetRef.current);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const el = circleRef.current;
|
const el = circleRef.current;
|
||||||
if (!el || !panEnabled) return;
|
if (!el || !panEnabled) return;
|
||||||
const onWheel = (e: WheelEvent) => {
|
const onWheel = (e: WheelEvent) => {
|
||||||
const cb = onImageScaleChangeRef.current;
|
if (!onImageScaleChangeRef.current) return;
|
||||||
if (!cb) return;
|
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
const direction = e.deltaY < 0 ? 1 : -1;
|
const direction = e.deltaY < 0 ? 1 : -1;
|
||||||
cb(clampPlayerImageScale(scaleRef.current + direction * PLAYER_IMAGE_SCALE_STEP));
|
const next = clampPlayerImageScale(visualScaleRef.current + direction * PLAYER_IMAGE_SCALE_STEP);
|
||||||
|
visualScaleRef.current = next;
|
||||||
|
writeAvatarTransform(imgRef.current, visualOffsetRef.current, next);
|
||||||
|
if (scaleTimerRef.current) window.clearTimeout(scaleTimerRef.current);
|
||||||
|
scaleTimerRef.current = window.setTimeout(() => {
|
||||||
|
scaleTimerRef.current = 0;
|
||||||
|
pendingScaleRef.current = visualScaleRef.current;
|
||||||
|
onImageScaleChangeRef.current?.(visualScaleRef.current);
|
||||||
|
}, SCALE_PERSIST_MS);
|
||||||
};
|
};
|
||||||
el.addEventListener('wheel', onWheel, { passive: false });
|
el.addEventListener('wheel', onWheel, { passive: false });
|
||||||
return () => el.removeEventListener('wheel', onWheel);
|
return () => el.removeEventListener('wheel', onWheel);
|
||||||
@@ -118,6 +215,7 @@ export function PlayerTokenView({
|
|||||||
const rootClass = [styles.root, className ?? ''].filter(Boolean).join(' ');
|
const rootClass = [styles.root, className ?? ''].filter(Boolean).join(' ');
|
||||||
const nameFontPx = Math.max(9, Math.min(13, Math.round(sizePx * 0.16)));
|
const nameFontPx = Math.max(9, Math.min(13, Math.round(sizePx * 0.16)));
|
||||||
const nameGapPx = Math.max(2, Math.min(8, Math.round(sizePx * 0.06)));
|
const nameGapPx = Math.max(2, Math.min(8, Math.round(sizePx * 0.06)));
|
||||||
|
const canPickEmpty = Boolean(onCircleClick) && !imageUrl;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -127,31 +225,47 @@ export function PlayerTokenView({
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
ref={circleRef}
|
ref={circleRef}
|
||||||
className={[styles.circle, panEnabled ? styles.circlePan : ''].filter(Boolean).join(' ')}
|
data-token-circle="1"
|
||||||
|
className={[
|
||||||
|
styles.circle,
|
||||||
|
panEnabled && imageUrl ? styles.circlePan : '',
|
||||||
|
canPickEmpty ? styles.circlePick : '',
|
||||||
|
dropActive ? styles.circleDrop : '',
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' ')}
|
||||||
style={{
|
style={{
|
||||||
width: sizePx,
|
width: sizePx,
|
||||||
height: sizePx,
|
height: sizePx,
|
||||||
borderColor: inactive ? '#9ca3af' : ringColor,
|
borderColor: dropActive ? undefined : inactive ? '#9ca3af' : ringColor,
|
||||||
}}
|
}}
|
||||||
onPointerDown={onPointerDown}
|
onPointerDown={onPointerDown}
|
||||||
onPointerMove={onPointerMove}
|
onPointerMove={onPointerMove}
|
||||||
onPointerUp={onPointerUp}
|
onPointerUp={onPointerUp}
|
||||||
onPointerCancel={onPointerUp}
|
onPointerCancel={onPointerUp}
|
||||||
|
onClick={
|
||||||
|
canPickEmpty
|
||||||
|
? () => {
|
||||||
|
onCircleClick?.();
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{imageUrl ? (
|
{imageUrl ? (
|
||||||
<img
|
<img
|
||||||
|
ref={imgRef}
|
||||||
className={styles.avatar}
|
className={styles.avatar}
|
||||||
src={imageUrl}
|
src={imageUrl}
|
||||||
alt=""
|
alt=""
|
||||||
draggable={false}
|
draggable={false}
|
||||||
style={{
|
style={inactive ? { filter: 'grayscale(1)', opacity: 0.85 } : undefined}
|
||||||
transform: `translate(-50%, -50%) translate(${String(offset.x * 100)}%, ${String(offset.y * 100)}%) scale(${String(scale)})`,
|
|
||||||
...(inactive ? { filter: 'grayscale(1)', opacity: 0.85 } : {}),
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className={styles.avatarEmpty} />
|
<div className={[styles.avatarEmpty, emptyContent ? styles.avatarEmptySolid : ''].filter(Boolean).join(' ')}>
|
||||||
|
{emptyContent}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{circleOverlay}
|
||||||
</div>
|
</div>
|
||||||
{!hideName ? (
|
{!hideName ? (
|
||||||
<div className={styles.namePlate} title={name} style={{ fontSize: nameFontPx }}>
|
<div className={styles.namePlate} title={name} style={{ fontSize: nameFontPx }}>
|
||||||
|
|||||||
@@ -129,4 +129,18 @@ void test('NpcsEditorApp: token appearance via PlayerTokenView', () => {
|
|||||||
const src = fs.readFileSync(path.join(rendererRoot, 'npcs/NpcsEditorApp.tsx'), 'utf8');
|
const src = fs.readFileSync(path.join(rendererRoot, 'npcs/NpcsEditorApp.tsx'), 'utf8');
|
||||||
assert.ok(src.includes('PlayerTokenView'));
|
assert.ok(src.includes('PlayerTokenView'));
|
||||||
assert.ok(src.includes('disposition') || src.includes('updateNpcFields'));
|
assert.ok(src.includes('disposition') || src.includes('updateNpcFields'));
|
||||||
|
assert.ok(src.includes('avatarPickHint'));
|
||||||
|
assert.ok(src.includes('useFileDropZone'));
|
||||||
|
assert.ok(!src.includes("t('npcs.chooseAvatar')"));
|
||||||
|
assert.ok(src.includes('externalUrl'));
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('NpcsApp: inspect cards vs Show checkbox, webview for external link', () => {
|
||||||
|
const src = fs.readFileSync(path.join(rendererRoot, 'npcs/NpcsApp.tsx'), 'utf8');
|
||||||
|
assert.ok(src.includes("kind: 'inspect.toggle'"));
|
||||||
|
assert.ok(src.includes('onActivate()'));
|
||||||
|
assert.ok(src.includes("kind: 'toggle'"));
|
||||||
|
assert.ok(src.includes('npcs.showOnScreen'));
|
||||||
|
assert.ok(src.includes('NpcExternalWebview') || src.includes('webview'));
|
||||||
|
assert.ok(src.includes('npcs.tabExternalLink'));
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -248,6 +248,7 @@ void test('selectNpcsByIds / buildPartialExportProject: explicit NPCs, relations
|
|||||||
disposition: 'neutral',
|
disposition: 'neutral',
|
||||||
imageOffset: { x: 0, y: 0 },
|
imageOffset: { x: 0, y: 0 },
|
||||||
imageScale: 1,
|
imageScale: 1,
|
||||||
|
externalUrl: '',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: n2,
|
id: n2,
|
||||||
@@ -261,6 +262,7 @@ void test('selectNpcsByIds / buildPartialExportProject: explicit NPCs, relations
|
|||||||
disposition: 'neutral',
|
disposition: 'neutral',
|
||||||
imageOffset: { x: 0, y: 0 },
|
imageOffset: { x: 0, y: 0 },
|
||||||
imageScale: 1,
|
imageScale: 1,
|
||||||
|
externalUrl: '',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: n3,
|
id: n3,
|
||||||
@@ -274,6 +276,7 @@ void test('selectNpcsByIds / buildPartialExportProject: explicit NPCs, relations
|
|||||||
disposition: 'neutral',
|
disposition: 'neutral',
|
||||||
imageOffset: { x: 0, y: 0 },
|
imageOffset: { x: 0, y: 0 },
|
||||||
imageScale: 1,
|
imageScale: 1,
|
||||||
|
externalUrl: '',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
const relations: ProjectNpcRelation[] = [
|
const relations: ProjectNpcRelation[] = [
|
||||||
@@ -364,6 +367,7 @@ void test('buildPartialExportProject: keeps npcTokens only for exported NPCs', (
|
|||||||
disposition: 'neutral',
|
disposition: 'neutral',
|
||||||
imageOffset: { x: 0, y: 0 },
|
imageOffset: { x: 0, y: 0 },
|
||||||
imageScale: 1,
|
imageScale: 1,
|
||||||
|
externalUrl: '',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: n2,
|
id: n2,
|
||||||
@@ -377,6 +381,7 @@ void test('buildPartialExportProject: keeps npcTokens only for exported NPCs', (
|
|||||||
disposition: 'neutral',
|
disposition: 'neutral',
|
||||||
imageOffset: { x: 0, y: 0 },
|
imageOffset: { x: 0, y: 0 },
|
||||||
imageScale: 1,
|
imageScale: 1,
|
||||||
|
externalUrl: '',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
@@ -430,6 +435,7 @@ void test('mergeStorylinesIntoProject: remaps npcTokens to created NPC ids', ()
|
|||||||
disposition: 'neutral',
|
disposition: 'neutral',
|
||||||
imageOffset: { x: 0.1, y: -0.1 },
|
imageOffset: { x: 0.1, y: -0.1 },
|
||||||
imageScale: 1,
|
imageScale: 1,
|
||||||
|
externalUrl: '',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
@@ -489,6 +495,7 @@ void test('mergeStorylinesIntoProject: imports all NPCs from export bundle', ()
|
|||||||
disposition: 'neutral',
|
disposition: 'neutral',
|
||||||
imageOffset: { x: 0, y: 0 },
|
imageOffset: { x: 0, y: 0 },
|
||||||
imageScale: 1,
|
imageScale: 1,
|
||||||
|
externalUrl: '',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
asSceneNpcTokenId,
|
asSceneNpcTokenId,
|
||||||
asTokenId,
|
asTokenId,
|
||||||
} from '../types/ids';
|
} from '../types/ids';
|
||||||
|
import { normalizeNpcExternalUrl } from '../types/npcExternalUrl';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
getConnectedComponent,
|
getConnectedComponent,
|
||||||
@@ -700,6 +701,7 @@ export function mergeStorylinesIntoProject(
|
|||||||
disposition: n.disposition === 'hostile' || n.disposition === 'friendly' ? n.disposition : 'neutral',
|
disposition: n.disposition === 'hostile' || n.disposition === 'friendly' ? n.disposition : 'neutral',
|
||||||
imageOffset: n.imageOffset ?? { x: 0, y: 0 },
|
imageOffset: n.imageOffset ?? { x: 0, y: 0 },
|
||||||
imageScale: typeof n.imageScale === 'number' && Number.isFinite(n.imageScale) ? n.imageScale : 1,
|
imageScale: typeof n.imageScale === 'number' && Number.isFinite(n.imageScale) ? n.imageScale : 1,
|
||||||
|
externalUrl: normalizeNpcExternalUrl(n.externalUrl),
|
||||||
});
|
});
|
||||||
npcNameKeys.add(name.toLowerCase());
|
npcNameKeys.add(name.toLowerCase());
|
||||||
npcsCreated += 1;
|
npcsCreated += 1;
|
||||||
|
|||||||
@@ -18,4 +18,5 @@ void test('contracts: players and sceneNpcTokensSession channels exist', () => {
|
|||||||
assert.match(src, /tokenGridSnap:\s*\{/);
|
assert.match(src, /tokenGridSnap:\s*\{/);
|
||||||
assert.match(src, /tokenGridSnap\.setEnabled/);
|
assert.match(src, /tokenGridSnap\.setEnabled/);
|
||||||
assert.match(src, /npcTokens\?:\s*SceneNpcToken\[\]/);
|
assert.match(src, /npcTokens\?:\s*SceneNpcToken\[\]/);
|
||||||
|
assert.match(src, /externalUrl\?:\s*string/);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -443,6 +443,7 @@ export type IpcInvokeMap = {
|
|||||||
npcId?: NpcId;
|
npcId?: NpcId;
|
||||||
name: string;
|
name: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
|
externalUrl?: string;
|
||||||
filePath?: string;
|
filePath?: string;
|
||||||
groupId?: NpcGroupId | null;
|
groupId?: NpcGroupId | null;
|
||||||
ringColor?: string;
|
ringColor?: string;
|
||||||
@@ -457,6 +458,7 @@ export type IpcInvokeMap = {
|
|||||||
npcId: NpcId;
|
npcId: NpcId;
|
||||||
name?: string;
|
name?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
|
externalUrl?: string;
|
||||||
groupId?: NpcGroupId | null;
|
groupId?: NpcGroupId | null;
|
||||||
ringColor?: string;
|
ringColor?: string;
|
||||||
disposition?: NpcDisposition;
|
disposition?: NpcDisposition;
|
||||||
|
|||||||
@@ -43,6 +43,8 @@ export type ProjectNpc = {
|
|||||||
avatarAssetId: AssetId;
|
avatarAssetId: AssetId;
|
||||||
/** HTML-описание (TipTap), как у сцены. */
|
/** HTML-описание (TipTap), как у сцены. */
|
||||||
description: string;
|
description: string;
|
||||||
|
/** Опциональная внешняя страница (http/https). Пустая строка — нет ссылки. */
|
||||||
|
externalUrl: string;
|
||||||
/** Позиция карточки на графе взаимосвязей. */
|
/** Позиция карточки на графе взаимосвязей. */
|
||||||
x: number;
|
x: number;
|
||||||
y: number;
|
y: number;
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ export * from './ids';
|
|||||||
export * from './materialLegend';
|
export * from './materialLegend';
|
||||||
export * from './materials';
|
export * from './materials';
|
||||||
export * from './npcDisposition';
|
export * from './npcDisposition';
|
||||||
|
export * from './npcExternalUrl';
|
||||||
export * from './npcs';
|
export * from './npcs';
|
||||||
export * from './sceneDarkness';
|
export * from './sceneDarkness';
|
||||||
export * from './sceneGrid';
|
export * from './sceneGrid';
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
import { normalizeNpcExternalUrl, npcExternalUrlIsValid } from './npcExternalUrl';
|
||||||
|
|
||||||
|
void test('normalizeNpcExternalUrl: empty and junk', () => {
|
||||||
|
assert.equal(normalizeNpcExternalUrl(''), '');
|
||||||
|
assert.equal(normalizeNpcExternalUrl(' '), '');
|
||||||
|
assert.equal(normalizeNpcExternalUrl(null), '');
|
||||||
|
assert.equal(normalizeNpcExternalUrl('javascript:alert(1)'), '');
|
||||||
|
assert.equal(normalizeNpcExternalUrl('file:///etc/passwd'), '');
|
||||||
|
assert.equal(normalizeNpcExternalUrl('data:text/html,hi'), '');
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('normalizeNpcExternalUrl: http(s) and bare host', () => {
|
||||||
|
assert.equal(normalizeNpcExternalUrl('https://example.com/npc'), 'https://example.com/npc');
|
||||||
|
assert.equal(normalizeNpcExternalUrl('http://example.com'), 'http://example.com/');
|
||||||
|
assert.equal(normalizeNpcExternalUrl('example.com/wiki'), 'https://example.com/wiki');
|
||||||
|
assert.equal(normalizeNpcExternalUrl(' https://wiki.example/foo '), 'https://wiki.example/foo');
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('npcExternalUrlIsValid: blank ok, garbage not', () => {
|
||||||
|
assert.equal(npcExternalUrlIsValid(''), true);
|
||||||
|
assert.equal(npcExternalUrlIsValid(' '), true);
|
||||||
|
assert.equal(npcExternalUrlIsValid('https://ok.example'), true);
|
||||||
|
assert.equal(npcExternalUrlIsValid('not a url'), false);
|
||||||
|
});
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
/** Опциональная внешняя ссылка НПС (wiki, заметки). Только http(s). */
|
||||||
|
|
||||||
|
const MAX_NPC_EXTERNAL_URL_LENGTH = 2048;
|
||||||
|
|
||||||
|
export function normalizeNpcExternalUrl(raw: unknown): string {
|
||||||
|
if (typeof raw !== 'string') return '';
|
||||||
|
const trimmed = raw.trim();
|
||||||
|
if (!trimmed) return '';
|
||||||
|
if (trimmed.length > MAX_NPC_EXTERNAL_URL_LENGTH) return '';
|
||||||
|
let candidate = trimmed;
|
||||||
|
if (!/^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(candidate)) {
|
||||||
|
candidate = `https://${candidate}`;
|
||||||
|
}
|
||||||
|
let parsed: URL;
|
||||||
|
try {
|
||||||
|
parsed = new URL(candidate);
|
||||||
|
} catch {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') return '';
|
||||||
|
if (parsed.username || parsed.password) return '';
|
||||||
|
return parsed.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function npcExternalUrlIsValid(raw: unknown): boolean {
|
||||||
|
if (typeof raw !== 'string') return false;
|
||||||
|
const trimmed = raw.trim();
|
||||||
|
if (!trimmed) return true;
|
||||||
|
return normalizeNpcExternalUrl(trimmed) !== '';
|
||||||
|
}
|
||||||
@@ -14,12 +14,16 @@ export type NpcsZoomTool = 'zoomIn' | 'zoomOut' | null;
|
|||||||
/** Session-only: какие НПС сейчас показаны поверх сцены (только аватары). */
|
/** Session-only: какие НПС сейчас показаны поверх сцены (только аватары). */
|
||||||
export type NpcsOverlayState = {
|
export type NpcsOverlayState = {
|
||||||
revision: number;
|
revision: number;
|
||||||
/** Порядок выделения / показа описаний. */
|
/** Аватары на презентации / пульте (чекбокс «Показать»). */
|
||||||
activeNpcIds: NpcId[];
|
activeNpcIds: NpcId[];
|
||||||
layouts: Record<string, NpcsOverlayLayout>;
|
layouts: Record<string, NpcsOverlayLayout>;
|
||||||
/** На кого действует zoom, если клик не попал в конкретный аватар. */
|
/** На кого действует zoom, если клик не попал в конкретный аватар. */
|
||||||
focusNpcId: NpcId | null;
|
focusNpcId: NpcId | null;
|
||||||
zoomTool: NpcsZoomTool;
|
zoomTool: NpcsZoomTool;
|
||||||
|
/** Карточки описания в окне НПС (клик по плитке, можно несколько). */
|
||||||
|
inspectNpcIds: NpcId[];
|
||||||
|
/** Кого развернуть / подсветить в списке карточек (открытие информации). */
|
||||||
|
inspectFocusNpcId: NpcId | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DEFAULT_NPCS_OVERLAY_LAYOUT: NpcsOverlayLayout = {
|
export const DEFAULT_NPCS_OVERLAY_LAYOUT: NpcsOverlayLayout = {
|
||||||
@@ -33,6 +37,8 @@ export type NpcsOverlayEvent =
|
|||||||
| { kind: 'show'; npcId: NpcId }
|
| { kind: 'show'; npcId: NpcId }
|
||||||
| { kind: 'hide' }
|
| { kind: 'hide' }
|
||||||
| { kind: 'toggle'; npcId: NpcId }
|
| { kind: 'toggle'; npcId: NpcId }
|
||||||
|
| { kind: 'inspect.toggle'; npcId: NpcId }
|
||||||
|
| { kind: 'inspect.show'; npcId: NpcId }
|
||||||
| { kind: 'layout.set'; npcId: NpcId; layout: NpcsOverlayLayout }
|
| { kind: 'layout.set'; npcId: NpcId; layout: NpcsOverlayLayout }
|
||||||
| { kind: 'zoomTool.set'; tool: NpcsZoomTool }
|
| { kind: 'zoomTool.set'; tool: NpcsZoomTool }
|
||||||
| { kind: 'zoomAt'; nx: number; ny: number; npcId?: NpcId };
|
| { kind: 'zoomAt'; nx: number; ny: number; npcId?: NpcId };
|
||||||
|
|||||||
@@ -21,4 +21,22 @@ void test('video playback: renderer hooks/components do not tick React each fram
|
|||||||
// Регресс: раньше был RAF loop с setState на каждом кадре.
|
// Регресс: раньше был RAF loop с setState на каждом кадре.
|
||||||
assert.doesNotMatch(previewSrc, /\brequestAnimationFrame\s*\(\s*loop\s*\)/);
|
assert.doesNotMatch(previewSrc, /\brequestAnimationFrame\s*\(\s*loop\s*\)/);
|
||||||
assert.doesNotMatch(previewSrc, /\brequestAnimationFrame\s*\(\s*loop\b/);
|
assert.doesNotMatch(previewSrc, /\brequestAnimationFrame\s*\(\s*loop\b/);
|
||||||
|
// timeupdate пишет scrub в DOM, без setState корня превью (дорого поверх декода видео).
|
||||||
|
assert.doesNotMatch(previewSrc, /\bsetTick\b/);
|
||||||
|
assert.ok(previewSrc.includes('paintScrub'));
|
||||||
|
assert.ok(previewSrc.includes('scrubFillRef'));
|
||||||
|
});
|
||||||
|
|
||||||
|
void test('editor scene cards do not decode full video files', () => {
|
||||||
|
const graph = fs.readFileSync(
|
||||||
|
path.join(root, 'app', 'renderer', 'editor', 'graph', 'SceneGraph.tsx'),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
const editor = fs.readFileSync(
|
||||||
|
path.join(root, 'app', 'renderer', 'editor', 'EditorApp.tsx'),
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
|
assert.doesNotMatch(graph, /<video\b/);
|
||||||
|
assert.doesNotMatch(graph, /ContainedVideo/);
|
||||||
|
assert.doesNotMatch(editor, /sceneThumbVideo/);
|
||||||
});
|
});
|
||||||
|
|||||||
+1
-1
@@ -10,7 +10,7 @@
|
|||||||
"build:obfuscate": "node scripts/build.mjs --production --obfuscate",
|
"build:obfuscate": "node scripts/build.mjs --production --obfuscate",
|
||||||
"lint": "eslint . --max-warnings 0",
|
"lint": "eslint . --max-warnings 0",
|
||||||
"typecheck": "tsc -p tsconfig.eslint.json --noEmit",
|
"typecheck": "tsc -p tsconfig.eslint.json --noEmit",
|
||||||
"test": "tsx --test app/renderer/shared/ui/controls.tooltip.test.ts app/renderer/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/renderer/shared/videoSceneMapParity.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 app/shared/types/appPlayers.test.ts app/shared/types/npcDisposition.test.ts app/shared/types/sceneGrid.test.ts app/shared/types/sceneGridSnap.test.ts app/main/tokens/tokenGridSnapSessionStore.test.ts app/shared/players/playerTeams.test.ts app/shared/players/launchPlayersSelection.test.ts app/main/players/playersStore.test.ts app/main/players/sceneNpcTokensSessionStore.test.ts app/main/players/scenePlayerTokensSessionStore.test.ts app/main/tokens/sceneTokensSessionStore.test.ts app/shared/ipc/contracts.players.test.ts app/shared/types/containMediaRect.test.ts app/shared/types/scenePreviewRotation.test.ts app/shared/types/tokenPath.test.ts app/shared/types/tokenPathPlayback.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 scripts/verify-packaged-sharp.test.mjs app/main/project/sharpRuntime.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/renderer/shared/videoSceneMapParity.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 app/shared/types/appPlayers.test.ts app/shared/types/npcDisposition.test.ts app/shared/types/npcExternalUrl.test.ts app/main/npcs/npcsOverlayStore.test.ts app/shared/types/sceneGrid.test.ts app/shared/types/sceneGridSnap.test.ts app/main/tokens/tokenGridSnapSessionStore.test.ts app/shared/players/playerTeams.test.ts app/shared/players/launchPlayersSelection.test.ts app/main/players/playersStore.test.ts app/main/players/sceneNpcTokensSessionStore.test.ts app/main/players/scenePlayerTokensSessionStore.test.ts app/main/tokens/sceneTokensSessionStore.test.ts app/shared/ipc/contracts.players.test.ts app/shared/types/containMediaRect.test.ts app/shared/types/scenePreviewRotation.test.ts app/shared/types/tokenPath.test.ts app/shared/types/tokenPathPlayback.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 scripts/verify-packaged-sharp.test.mjs app/main/project/sharpRuntime.test.mjs",
|
||||||
"format": "prettier . --check",
|
"format": "prettier . --check",
|
||||||
"format:write": "prettier . --write",
|
"format:write": "prettier . --write",
|
||||||
"postinstall": "patch-package",
|
"postinstall": "patch-package",
|
||||||
|
|||||||
Reference in New Issue
Block a user