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:
Ivan Fontosh
2026-08-19 10:42:57 +08:00
parent 1a9f0f2557
commit 0f4bb76b41
35 changed files with 1189 additions and 259 deletions
+1
View File
@@ -451,6 +451,7 @@ export async function buildProjectFromFoundryDocuments(
disposition: 'neutral',
imageOffset: { x: 0, y: 0 },
imageScale: 1,
externalUrl: '',
});
npcIndex += 1;
}
+9 -5
View File
@@ -124,6 +124,11 @@ if (process.platform === 'win32' && app.isPackaged && process.env.DND_DISABLE_GP
app.disableHardwareAcceleration();
}
if (process.platform === 'darwin') {
app.commandLine.appendSwitch('enable-gpu-rasterization');
app.commandLine.appendSwitch('enable-zero-copy');
}
if (process.platform === 'win32') {
app.setAppUserModelId('com.ttrpgplayer.app');
}
@@ -368,9 +373,7 @@ function syncSceneTrapsForProject(project: Project): void {
function emitVideoState(): void {
const state = videoStore.getState();
for (const win of BrowserWindow.getAllWindows()) {
win.webContents.send(ipcChannels.video.stateChanged, { state });
}
sendToAppWindows(ipcChannels.video.stateChanged, { state }, ['control', 'presentation']);
}
// Периодически чистим истёкшие эффекты (в основном молнии).
@@ -620,7 +623,7 @@ async function main() {
openNpcsWindow();
const npcId = req?.npcId ? asNpcId(String(req.npcId)) : null;
if (npcId) {
npcsOverlayStore.dispatch({ kind: 'show', npcId });
npcsOverlayStore.dispatch({ kind: 'inspect.show', npcId });
emitNpcsOverlayState();
}
return { ok: true };
@@ -981,10 +984,11 @@ async function main() {
);
registerHandler(
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, {
...(typeof name === 'string' ? { name } : {}),
...(typeof description === 'string' ? { description } : {}),
...(typeof externalUrl === 'string' ? { externalUrl } : {}),
...(groupId !== undefined ? { groupId } : {}),
...(ringColor !== undefined ? { ringColor } : {}),
...(disposition !== undefined ? { disposition } : {}),
+40
View File
@@ -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, []);
});
+85 -6
View File
@@ -16,6 +16,8 @@ function emptyState(): NpcsOverlayState {
layouts: {},
focusNpcId: 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 };
}
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 {
private state: NpcsOverlayState = emptyState();
@@ -30,11 +41,29 @@ export class NpcsOverlayStore {
return this.state;
}
/** Полный сброс (закрытие проекта / конец сессии). */
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) {
return this.state;
}
this.state = {
...this.state,
revision: this.state.revision + 1,
activeNpcIds: [],
layouts: {},
@@ -47,7 +76,7 @@ export class NpcsOverlayStore {
dispatch(event: NpcsOverlayEvent): NpcsOverlayState {
switch (event.kind) {
case 'hide':
return this.clear();
return this.hideAvatars();
case 'show': {
if (this.state.activeNpcIds.includes(event.npcId)) {
this.state = {
@@ -58,6 +87,7 @@ export class NpcsOverlayStore {
return this.state;
}
this.state = {
...this.state,
revision: this.state.revision + 1,
activeNpcIds: [...this.state.activeNpcIds, event.npcId],
layouts: {
@@ -65,7 +95,6 @@ export class NpcsOverlayStore {
[event.npcId]: { ...DEFAULT_NPCS_OVERLAY_LAYOUT },
},
focusNpcId: event.npcId,
zoomTool: this.state.zoomTool,
};
return this.state;
}
@@ -79,6 +108,7 @@ export class NpcsOverlayStore {
? (activeNpcIds[activeNpcIds.length - 1] ?? null)
: this.state.focusNpcId;
this.state = {
...this.state,
revision: this.state.revision + 1,
activeNpcIds,
layouts,
@@ -88,6 +118,7 @@ export class NpcsOverlayStore {
return this.state;
}
this.state = {
...this.state,
revision: this.state.revision + 1,
activeNpcIds: [...this.state.activeNpcIds, event.npcId],
layouts: {
@@ -95,7 +126,38 @@ export class NpcsOverlayStore {
[event.npcId]: { ...DEFAULT_NPCS_OVERLAY_LAYOUT },
},
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;
}
@@ -157,8 +219,19 @@ export class NpcsOverlayStore {
ensureNpcStillExists(npcIds: ReadonlySet<NpcId>): NpcsOverlayState {
const activeNpcIds = this.state.activeNpcIds.filter((id) => npcIds.has(id));
if (activeNpcIds.length === this.state.activeNpcIds.length) return this.state;
if (activeNpcIds.length === 0) return this.clear();
const inspectNpcIds = this.state.inspectNpcIds.filter((id) => npcIds.has(id));
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> = {};
for (const id of activeNpcIds) {
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[activeNpcIds.length - 1] ?? null);
const inspectFocusNpcId =
this.state.inspectFocusNpcId && inspectNpcIds.includes(this.state.inspectFocusNpcId)
? this.state.inspectFocusNpcId
: (inspectNpcIds[inspectNpcIds.length - 1] ?? null);
this.state = {
revision: this.state.revision + 1,
activeNpcIds,
layouts,
focusNpcId,
zoomTool: this.state.zoomTool,
zoomTool: activeNpcIds.length === 0 ? null : this.state.zoomTool,
inspectNpcIds,
inspectFocusNpcId,
};
return this.state;
}
@@ -80,6 +80,11 @@ void test('zipStore: normalizeScene defaults previewThumbAssetId for older proje
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', () => {
const src = fs.readFileSync(path.join(here, 'zipStore.ts'), 'utf8');
assert.match(
+11
View File
@@ -66,6 +66,7 @@ import {
npcDispositionRingColor,
type NpcDisposition,
} from '../../shared/types/npcDisposition';
import { normalizeNpcExternalUrl } from '../../shared/types/npcExternalUrl';
import { DEFAULT_SCENE_GRID, normalizeSceneGrid } from '../../shared/types/sceneGrid';
import {
asPreviewRotationDeg,
@@ -1396,6 +1397,7 @@ export class ZipProjectStore {
npcId?: NpcId;
name: string;
description?: string;
externalUrl?: string;
filePath?: string;
groupId?: NpcGroupId | null;
ringColor?: string;
@@ -1477,6 +1479,9 @@ export class ZipProjectStore {
name,
avatarAssetId: nextAssetId ?? prev.avatarAssetId,
description: typeof input.description === 'string' ? input.description : prev.description,
...(input.externalUrl !== undefined
? { externalUrl: normalizeNpcExternalUrl(input.externalUrl) }
: {}),
groupId: resolveGroup(input.groupId, prev.groupId),
...(input.ringColor !== undefined
? { ringColor: normalizeHexColor(input.ringColor, DEFAULT_PLAYER_RING_COLOR) }
@@ -1503,6 +1508,7 @@ export class ZipProjectStore {
name,
avatarAssetId: nextAssetId,
description: typeof input.description === 'string' ? input.description : '',
externalUrl: normalizeNpcExternalUrl(input.externalUrl),
x: 80 + (count % 4) * 220,
y: 80 + Math.floor(count / 4) * 200,
groupId: resolveGroup(input.groupId, null),
@@ -1526,6 +1532,7 @@ export class ZipProjectStore {
patch: {
name?: string;
description?: string;
externalUrl?: string;
groupId?: NpcGroupId | null;
ringColor?: string;
disposition?: NpcDisposition;
@@ -1559,6 +1566,9 @@ export class ZipProjectStore {
...n,
...(name !== undefined ? { name } : {}),
...(typeof patch.description === 'string' ? { description: patch.description } : {}),
...(patch.externalUrl !== undefined
? { externalUrl: normalizeNpcExternalUrl(patch.externalUrl) }
: {}),
groupId,
disposition,
ringColor:
@@ -2650,6 +2660,7 @@ function normalizeProject(p: Project): Project {
name,
avatarAssetId: obj.avatarAssetId,
description: typeof obj.description === 'string' ? obj.description : '',
externalUrl: normalizeNpcExternalUrl((obj as { externalUrl?: unknown }).externalUrl),
x,
y,
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/);
});
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('npcsEditor'"));
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: закрытие пульта закрывает сессионные окна и презентацию', () => {
+27 -7
View File
@@ -54,9 +54,9 @@ const MATERIALS_WINDOW_HEIGHT = 720;
const NPCS_EDITOR_WINDOW_WIDTH = 1400;
const NPCS_EDITOR_WINDOW_HEIGHT = 860;
/** Пульт НПС: деталь слева + список справа. */
const NPCS_WINDOW_WIDTH = 720;
const NPCS_WINDOW_HEIGHT = 720;
/** Пульт НПС: деталь слева (webview) + список справа. */
const NPCS_WINDOW_WIDTH = 960;
const NPCS_WINDOW_HEIGHT = 800;
/** Учитываем окна, которые уже уничтожены при каскадном закрытии (родитель → дочернее). */
function broadcastMultiWindowStateChanged(open: boolean): void {
@@ -340,9 +340,9 @@ function createWindow(kind: WindowKind, opts?: CreateWindowOpts): BrowserWindow
? {
width: NPCS_WINDOW_WIDTH,
height: NPCS_WINDOW_HEIGHT,
minWidth: 560,
maxWidth: 900,
minHeight: 480,
minWidth: 720,
maxWidth: 1280,
minHeight: 560,
}
: {}),
show: false,
@@ -358,6 +358,13 @@ function createWindow(kind: WindowKind, opts?: CreateWindowOpts): BrowserWindow
autoplayPolicy: 'no-user-gesture-required',
// file:// + бандл Vite: без этого на Windows часто не грузятся чанки; http:// (dev server) оставляем строгим.
webSecurity: Boolean(process.env.VITE_DEV_SERVER_URL),
...(kind === 'npcs'
? {
webviewTag: true,
// webviewTag в Electron не сочетается с sandbox хоста.
sandbox: false,
}
: {}),
},
});
if (icon) {
@@ -369,6 +376,19 @@ function createWindow(kind: WindowKind, opts?: CreateWindowOpts): BrowserWindow
}
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 (
kind === 'sceneDescription' ||
kind === 'materials' ||
@@ -799,7 +819,7 @@ export function openNpcsWindow(): void {
existing.setBounds({
x: b.x,
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),
});
existing.show();
+54 -38
View File
@@ -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 type { SessionState } from '../../shared/ipc/contracts';
@@ -26,6 +26,13 @@ function fmt(sec: number): string {
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) {
const { t } = useEditorI18n();
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 autostart = scene?.previewVideoAutostart ?? false;
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 dur = useMemo(
() => {
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 paintScrub = (): void => {
const { cur, dur } = readVideoTimes(videoRef.current);
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(() => {
if (!isVideo) return;
@@ -91,10 +97,21 @@ export function ControlScenePreview({ session, videoRef, viewCamera = null, onCo
} else {
v.pause();
}
paintScrub();
// 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]);
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 (
<div className={styles.root}>
@@ -116,8 +133,8 @@ export function ControlScenePreview({ session, videoRef, viewCamera = null, onCo
preload="auto"
viewCamera={viewCamera}
onContentRectChange={onContentRectChange}
onTimeUpdate={() => setTick((x) => x + 1)}
onLoadedMetadata={() => setTick((x) => x + 1)}
onTimeUpdate={paintScrub}
onLoadedMetadata={paintScrub}
>
<track kind="captions" srcLang="ru" label={t('control.previewTrackLabel')} />
</ContainedVideo>
@@ -128,30 +145,27 @@ export function ControlScenePreview({ session, videoRef, viewCamera = null, onCo
{isVideo ? (
<div className={styles.controls}>
<div
ref={scrubRef}
role="slider"
tabIndex={0}
aria-valuemin={0}
aria-valuemax={dur > 0 ? Math.round(dur) : 0}
aria-valuenow={Math.round(cur)}
className={scrubClass}
aria-valuemax={0}
aria-valuenow={0}
className={[styles.scrub, styles.scrubDefault].join(' ')}
onClick={(e) => {
const v = videoRef.current;
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);
seekByClientX(e.clientX);
}}
onKeyDown={(e) => {
const { cur, dur } = readVideoTimes(videoRef.current);
if (!dur) return;
if (e.key === 'ArrowLeft') void video.dispatch({ kind: 'seek', timeSec: Math.max(0, cur - 5) });
if (e.key === 'ArrowRight')
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')}
>
<div className={styles.scrubFill} style={{ width: `${String(Math.round(pct * 100))}%` }} />
<div ref={scrubFillRef} className={styles.scrubFill} style={{ width: '0%' }} />
</div>
<div className={styles.row}>
<div className={styles.transport}>
@@ -176,7 +190,7 @@ export function ControlScenePreview({ session, videoRef, viewCamera = null, onCo
className={styles.transportBtn}
onClick={() => {
void video.dispatch({ kind: 'stop' });
setTick((x) => x + 1);
paintScrub();
}}
title={t('control.transportStop')}
>
@@ -184,7 +198,9 @@ export function ControlScenePreview({ session, videoRef, viewCamera = null, onCo
</button>
</div>
<div>
{fmt(cur)} / {dur ? fmt(dur) : '—:—'}
<span ref={curTimeRef}>0:00</span>
{' / '}
<span ref={durTimeRef}>—:—</span>
</div>
</div>
</div>
-41
View File
@@ -2990,47 +2990,6 @@ function SceneListCard({
decoding="async"
/>
</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 />
)}
-41
View File
@@ -26,7 +26,6 @@ import {
isSideStoryEdge,
} from '../../../shared/graph/sceneGraphLineage';
import type { AssetId, GraphNodeId, SceneGraphEdge, SceneGraphNode, SceneId } from '../../../shared/types';
import { ContainedVideo } from '../../shared/ContainedVideo';
import { RotatedImage } from '../../shared/RotatedImage';
import { EllipsisText } from '../../shared/ui/EllipsisText';
import ellipsisStyles from '../../shared/ui/ellipsisText.module.css';
@@ -260,46 +259,6 @@ function SceneCardNode({ data }: NodeProps<SceneCardData>) {
/>
)}
</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 />
)}
+20 -4
View File
@@ -211,7 +211,7 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'help.section.npcs.title': 'НПС',
'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.body':
@@ -473,6 +473,8 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'npcs.avatarEmpty': 'Аватар не выбран',
'npcs.avatarRequired': 'Выберите аватар.',
'npcs.chooseAvatar': 'Выбрать аватар',
'npcs.avatarPickHint': 'Нажмите или перетащите изображение (PNG, JPG, WebP)',
'npcs.changeAvatar': 'Сменить аватар',
'npcs.dropHint': 'Перетащите изображение (PNG, JPG, WebP)',
'npcs.disposition': 'ТИП',
'npcs.disposition.hostile': 'Враждебный',
@@ -488,6 +490,12 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'npcs.description': 'ОПИСАНИЕ',
'npcs.descriptionPlaceholder': 'Описание персонажа…',
'npcs.descriptionEmpty': 'Описание отсутствует',
'npcs.externalLink': 'ВНЕШНЯЯ ССЫЛКА',
'npcs.externalLinkPlaceholder': 'https://…',
'npcs.externalLinkInvalid': 'Нужна ссылка http или https.',
'npcs.tabDescription': 'Описание',
'npcs.tabExternalLink': 'Внешняя ссылка',
'npcs.showOnScreen': 'Показать',
'npcs.relations': 'Отношения',
'npcs.untitled': 'Без имени',
'npcs.deleteTitle': 'Удаление НПС',
@@ -506,7 +514,7 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'npcs.graphZoomOut': 'Уменьшить',
'npcs.graphFitAll': 'Показать всё',
'npcs.windowEmpty': 'Добавьте НПС в редакторе.',
'npcs.selectToShow': 'Выберите персонажа в списке — он появится на экране.',
'npcs.selectToShow': 'Выберите персонажей в списке — карточки откроются слева. Чекбокс «Показать» выводит аватар на экран.',
'npcs.closeOverlay': 'Закрыть всех',
'npcs.rotateOverlay': 'Повернуть',
'npcs.zoomIn': 'Увеличить',
@@ -838,7 +846,7 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'help.section.npcs.title': 'NPCs',
'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.body':
@@ -1102,6 +1110,8 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'npcs.avatarEmpty': 'No avatar selected',
'npcs.avatarRequired': 'Choose an 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.disposition': 'TYPE',
'npcs.disposition.hostile': 'Hostile',
@@ -1117,6 +1127,12 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'npcs.description': 'DESCRIPTION',
'npcs.descriptionPlaceholder': 'Character 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.untitled': 'Untitled',
'npcs.deleteTitle': 'Delete NPC',
@@ -1136,7 +1152,7 @@ export const EDITOR_MESSAGES: Record<EditorLocale, Record<string, string>> = {
'npcs.graphZoomOut': 'Zoom out',
'npcs.graphFitAll': 'Fit view',
'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.rotateOverlay': 'Rotate',
'npcs.zoomIn': 'Zoom in',
+90 -5
View File
@@ -39,8 +39,9 @@
padding: 14px 16px;
border-right: 1px solid var(--stroke);
background: #0f0f12;
display: grid;
gap: 14px;
display: flex;
flex-direction: column;
gap: 0;
align-content: start;
}
@@ -53,8 +54,13 @@
.detailCard {
display: grid;
gap: 10px;
padding-bottom: 14px;
padding: 14px 0;
border-bottom: 1px solid var(--stroke);
min-width: 0;
}
.detailCard:first-child {
padding-top: 0;
}
.detailCard:last-child {
@@ -62,11 +68,67 @@
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 {
font-size: 18px;
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 {
font-size: 12px;
font-weight: 900;
@@ -123,8 +185,8 @@
display: grid;
grid-template-columns: auto 1fr;
gap: 4px;
align-items: center;
padding: 4px 8px 4px 4px;
align-items: start;
padding: 6px 8px 8px 4px;
border-radius: 12px;
border: 1px solid var(--stroke);
background: #18181b;
@@ -133,6 +195,12 @@
width: 100%;
}
.tileMain {
display: grid;
gap: 6px;
min-width: 0;
}
.tileBody {
display: grid;
grid-template-columns: 44px 1fr;
@@ -150,6 +218,7 @@
.tileMenuBtn {
width: 28px;
height: 28px;
margin-top: 10px;
border: 0;
border-radius: 8px;
background: transparent;
@@ -229,6 +298,22 @@
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 {
color: var(--text2);
font-size: 13px;
+237 -56
View File
@@ -4,6 +4,12 @@ import { createPortal } from 'react-dom';
import { ipcChannels, type SessionState } from '../../shared/ipc/contracts';
import { buildNpcGroupForest, type NpcGroupTreeNode } from '../../shared/npcs/npcGroups';
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 { useEditorI18n } from '../editor/i18n/EditorI18nContext';
import { sanitizeSceneDescriptionHtml } from '../editor/sceneDescriptionHtml';
@@ -13,11 +19,6 @@ import { useNpcMapSpawnDrag } from '../shared/playerToken/useNpcMapSpawnDrag';
import { useSceneNpcTokensSession } from '../shared/playerToken/useSceneNpcTokensSession';
import { Button, Input } from '../shared/ui/controls';
import { useAssetUrl } from '../shared/useAssetImageUrl';
import {
listVisibleSceneNpcTokens,
nextSessionNpcSpawnPoint,
} from '../../shared/types/appPlayers';
import { normalizeNpcDisposition } from '../../shared/types/npcDisposition';
import styles from './NpcsApp.module.css';
@@ -61,18 +62,22 @@ function pruneEmptyGroupNodes(nodes: NpcGroupTreeNode[]): NpcGroupTreeNode[] {
function RuntimeNpcTile({
npc,
selected,
shown,
accentColor,
mapDragEnabled,
onActivate,
onToggleShow,
onAddToMap,
onMapDragBegin,
onMapDragEnd,
}: {
npc: ProjectNpc;
selected: boolean;
shown: boolean;
accentColor?: string | null;
mapDragEnabled: boolean;
onActivate: () => void;
onToggleShow: () => void;
onAddToMap: () => void;
onMapDragBegin: (npcId: NpcId) => void;
onMapDragEnd: () => void;
@@ -138,30 +143,38 @@ function RuntimeNpcTile({
>
⋮
</button>
<div className={styles.tileMain}>
<button
type="button"
className={styles.tileBody}
onClick={(e) => {
if (!suppressClickRef.current) return;
suppressClickRef.current = false;
e.preventDefault();
e.stopPropagation();
if (suppressClickRef.current) {
suppressClickRef.current = false;
e.preventDefault();
e.stopPropagation();
return;
}
onActivate();
}}
onPointerDown={(e) => {
if (e.button !== 0 || !mapDragEnabled) return;
dragRef.current = { pointerId: e.pointerId, x: e.clientX, y: e.clientY, started: false };
e.currentTarget.setPointerCapture(e.pointerId);
}}
onPointerMove={(e) => {
const d = dragRef.current;
if (!d || d.pointerId !== e.pointerId || d.started) return;
if (Math.hypot(e.clientX - d.x, e.clientY - d.y) < 7) return;
d.started = true;
try {
e.currentTarget.setPointerCapture(e.pointerId);
} catch {
/* ignore */
}
onMapDragBegin(npc.id);
}}
onPointerUp={(e) => {
const d = dragRef.current;
if (!d || d.pointerId !== e.pointerId) return;
if (d && d.pointerId !== e.pointerId) return;
dragRef.current = null;
try {
if (e.currentTarget.hasPointerCapture(e.pointerId)) {
@@ -170,13 +183,11 @@ function RuntimeNpcTile({
} catch {
/* ignore */
}
if (d.started) {
if (d?.started) {
suppressClickRef.current = true;
e.preventDefault();
onMapDragEnd();
return;
}
onActivate();
}}
onPointerCancel={() => {
dragRef.current = null;
@@ -187,6 +198,21 @@ function RuntimeNpcTile({
</div>
<div className={styles.tileName}>{npc.name}</div>
</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
? createPortal(
<div
@@ -224,8 +250,10 @@ function RuntimeGroupSection({
isExpanded,
onToggleExpanded,
selectedIds,
shownIds,
mapDragEnabled,
onActivate,
onToggleShow,
onAddToMap,
onMapDragBegin,
onMapDragEnd,
@@ -235,8 +263,10 @@ function RuntimeGroupSection({
isExpanded: (id: NpcGroupId) => boolean;
onToggleExpanded: (id: NpcGroupId) => void;
selectedIds: ReadonlySet<NpcId>;
shownIds: ReadonlySet<NpcId>;
mapDragEnabled: boolean;
onActivate: (id: NpcId) => void;
onToggleShow: (id: NpcId) => void;
onAddToMap: (id: NpcId) => void;
onMapDragBegin: (npcId: NpcId) => void;
onMapDragEnd: () => void;
@@ -265,9 +295,11 @@ function RuntimeGroupSection({
key={n.id}
npc={n}
selected={selectedIds.has(n.id)}
shown={shownIds.has(n.id)}
accentColor={g.color}
mapDragEnabled={mapDragEnabled}
onActivate={() => onActivate(n.id)}
onToggleShow={() => onToggleShow(n.id)}
onAddToMap={() => onAddToMap(n.id)}
onMapDragBegin={onMapDragBegin}
onMapDragEnd={onMapDragEnd}
@@ -281,8 +313,10 @@ function RuntimeGroupSection({
isExpanded={isExpanded}
onToggleExpanded={onToggleExpanded}
selectedIds={selectedIds}
shownIds={shownIds}
mapDragEnabled={mapDragEnabled}
onActivate={onActivate}
onToggleShow={onToggleShow}
onAddToMap={onAddToMap}
onMapDragBegin={onMapDragBegin}
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() {
const { t } = useEditorI18n();
const api = getDndApi();
@@ -303,6 +459,7 @@ export function NpcsApp() {
const [mapDrag, mapDragApi] = useNpcMapSpawnDrag();
const [query, setQuery] = useState('');
const [collapsedGroups, setCollapsedGroups] = useState<Set<NpcGroupId>>(() => new Set());
const [collapsedInspect, setCollapsedInspect] = useState<Set<NpcId>>(() => new Set());
useEffect(() => {
void api.invoke(ipcChannels.project.get, {}).then(({ project }) => {
@@ -314,7 +471,14 @@ export function NpcsApp() {
}, [api]);
const activeIds = overlay?.activeNpcIds ?? [];
const [inspectIds, setInspectIds] = useState<NpcId[]>([]);
const hasActive = activeIds.length > 0;
const shownIds = useMemo(() => new Set(activeIds), [activeIds]);
useEffect(() => {
if (!overlay) return;
setInspectIds(overlay.inspectNpcIds ?? []);
}, [overlay?.inspectNpcIds]);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
@@ -343,20 +507,36 @@ export function NpcsApp() {
const npcs = useMemo(() => session?.project?.npcs ?? [], [session?.project?.npcs]);
const npcGroups = useMemo(() => session?.project?.npcGroups ?? [], [session?.project?.npcGroups]);
const relations = useMemo(() => session?.project?.npcRelations ?? [], [session?.project?.npcRelations]);
const selectedIds = useMemo(() => new Set(activeIds), [activeIds]);
const selectedNpcs = useMemo(
() => activeIds.map((id) => npcs.find((n) => n.id === id)).filter((n): n is ProjectNpc => Boolean(n)),
[activeIds, npcs],
const selectedIds = useMemo(() => new Set(inspectIds), [inspectIds]);
const detailNpcs = useMemo(
() => inspectIds.map((id) => npcs.find((n) => n.id === id)).filter((n): n is ProjectNpc => Boolean(n)),
[inspectIds, npcs],
);
/** Описание: сфокусированный НПС (открытие информации / последний выбор), иначе все активные. */
const detailNpcs = useMemo(() => {
const focusId = overlay?.focusNpcId ?? null;
if (focusId) {
const focused = npcs.find((n) => n.id === focusId);
if (focused) return [focused];
}
return selectedNpcs;
}, [npcs, overlay?.focusNpcId, selectedNpcs]);
useEffect(() => {
const focusId = overlay?.inspectFocusNpcId ?? null;
if (!focusId) return;
setCollapsedInspect((prev) => {
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 changed ? next : prev;
});
}, [selectedIds]);
const filteredNpcs = useMemo(() => {
const q = query.trim().toLowerCase();
@@ -411,6 +591,14 @@ export function NpcsApp() {
}, [detailNpcs, npcs, relations]);
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) => {
void overlayApi.dispatch({ kind: 'toggle', npcId: id });
},
@@ -521,35 +709,22 @@ export function NpcsApp() {
<div className={styles.body}>
<div className={styles.detail}>
{detailNpcs.length > 0 ? (
detailNpcs.map((npc) => {
const safeHtml = sanitizeSceneDescriptionHtml(npc.description);
const npcRelations = relationsByNpcId.get(npc.id) ?? [];
return (
<div key={npc.id} className={styles.detailCard}>
<div className={styles.detailName}>{npc.name}</div>
{safeHtml ? (
<div>
<div className={styles.detailSectionTitle}>{t('npcs.description')}</div>
<div className={styles.detailDesc} dangerouslySetInnerHTML={{ __html: safeHtml }} />
</div>
) : (
<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>
);
})
detailNpcs.map((npc) => (
<NpcInspectCard
key={npc.id}
npc={npc}
relations={relationsByNpcId.get(npc.id) ?? []}
expanded={!collapsedInspect.has(npc.id)}
onToggleExpanded={() => {
setCollapsedInspect((prev) => {
const next = new Set(prev);
if (next.has(npc.id)) next.delete(npc.id);
else next.add(npc.id);
return next;
});
}}
/>
))
) : (
<div className={styles.detailCard}>
<div className={styles.detailEmpty}>
@@ -568,8 +743,10 @@ export function NpcsApp() {
key={n.id}
npc={n}
selected={selectedIds.has(n.id)}
shown={shownIds.has(n.id)}
mapDragEnabled={mapDragEnabled}
onActivate={() => onSelectTile(n.id)}
onToggleShow={() => onToggleShow(n.id)}
onAddToMap={() => spawnNpcOnMap(n.id)}
onMapDragBegin={onMapDragBegin}
onMapDragEnd={onMapDragEnd}
@@ -585,8 +762,10 @@ export function NpcsApp() {
isExpanded={isExpanded}
onToggleExpanded={toggleExpanded}
selectedIds={selectedIds}
shownIds={shownIds}
mapDragEnabled={mapDragEnabled}
onActivate={onSelectTile}
onToggleShow={onToggleShow}
onAddToMap={spawnNpcOnMap}
onMapDragBegin={onMapDragBegin}
onMapDragEnd={onMapDragEnd}
@@ -601,8 +780,10 @@ export function NpcsApp() {
key={n.id}
npc={n}
selected={selectedIds.has(n.id)}
shown={shownIds.has(n.id)}
mapDragEnabled={mapDragEnabled}
onActivate={() => onSelectTile(n.id)}
onToggleShow={() => onToggleShow(n.id)}
onAddToMap={() => spawnNpcOnMap(n.id)}
onMapDragBegin={onMapDragBegin}
onMapDragEnd={onMapDragEnd}
+81 -1
View File
@@ -201,10 +201,21 @@
.avatarPick {
display: grid;
gap: 10px;
justify-items: start;
justify-items: center;
width: 100%;
}
.avatarPickBusy {
opacity: 0.65;
pointer-events: none;
}
.avatarPickDrop {
position: relative;
}
.avatarPreview {
position: relative;
width: 120px;
height: 120px;
border-radius: 14px;
@@ -213,6 +224,75 @@
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 {
width: 100%;
height: 100%;
+188 -24
View File
@@ -11,11 +11,14 @@ import type {
ProjectNpcGroup,
ProjectNpcRelation,
} from '../../shared/types';
import { normalizeNpcExternalUrl, npcExternalUrlIsValid } from '../../shared/types/npcExternalUrl';
import editorStyles from '../editor/EditorApp.module.css';
import { pickFirstMaterialImagePath, useFileDropZone } from '../editor/fileDrop';
import { useEditorI18n } from '../editor/i18n/EditorI18nContext';
import { getDndApi } from '../shared/dndApi';
import { USERS_BRANCH_FEATURES_ENABLED } from '../../shared/features/usersBranchFeatures';
import { PlayerTokenView } from '../shared/playerToken/PlayerTokenView';
import tokenStyles from '../shared/playerToken/PlayerTokenView.module.css';
import { Button, Input, Select } from '../shared/ui/controls';
import { useAssetUrl } from '../shared/useAssetImageUrl';
import { npcDispositionRingColor } from '../../shared/types/npcDisposition';
@@ -333,6 +336,8 @@ export function NpcsEditorApp() {
const [pendingDeleteGroup, setPendingDeleteGroup] = useState<ProjectNpcGroup | null>(null);
const [graphFilter, setGraphFilter] = useState<GraphGroupFilter>('all');
const [nameDraft, setNameDraft] = useState('');
const [linkDraft, setLinkDraft] = useState('');
const [linkError, setLinkError] = useState(false);
const [relationModal, setRelationModal] = useState<
| { mode: 'create'; sourceNpcId: NpcId; targetNpcId: NpcId }
| { mode: 'edit'; relationId: NpcRelationId; label: string }
@@ -364,6 +369,11 @@ export function NpcsEditorApp() {
setNameDraft(selected?.name ?? '');
}, [selected?.id, selected?.name]);
useEffect(() => {
setLinkDraft(selected?.externalUrl ?? '');
setLinkError(false);
}, [selected?.id, selected?.externalUrl]);
useEffect(() => {
if (selectedId && npcs.some((n) => n.id === selectedId)) return;
setSelectedId(npcs[0]?.id ?? null);
@@ -415,6 +425,53 @@ export function NpcsEditorApp() {
return { filePath: res.filePath, previewDataUrl: res.previewDataUrl };
}, [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(
async (npcId: NpcId, groupId: NpcGroupId | null, orderIds?: NpcId[]) => {
const npc = npcs.find((n) => n.id === npcId);
@@ -737,7 +794,19 @@ export function NpcsEditorApp() {
<>
<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 ? (
<PlayerTokenView
name={selected.name}
@@ -746,7 +815,48 @@ export function NpcsEditorApp() {
imageOffset={selected.imageOffset}
imageScale={selected.imageScale}
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) => {
void api.invoke(ipcChannels.project.updateNpcFields, {
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 ? (
<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}
</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>
@@ -868,6 +993,45 @@ export function NpcsEditorApp() {
/>
</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 ? (
<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'"));
});
void test('PxiEffectsOverlay: ограничение FPS тикера для нагрузки', () => {
void test('PxiEffectsOverlay: lazy WebGL until effects need it', () => {
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', () => {
@@ -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 {
EffectInstance,
@@ -140,6 +140,18 @@ function effectsHaveWork(state: EffectsState | null): boolean {
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_ASPECT = 420 / 473;
const LIGHTNING_VFX_STRIKE_MS = 320;
@@ -208,6 +220,9 @@ export const PixiEffectsOverlay = forwardRef<PixiEffectsOverlayHandle, Props>(fu
const viewportProvidedRef = useRef(false);
/** null = ещё не синхронизировали с Pixi (ticker по умолчанию бежит). */
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 app = appRef.current;
@@ -217,6 +232,10 @@ export const PixiEffectsOverlay = forwardRef<PixiEffectsOverlayHandle, Props>(fu
tickerWantedRef.current = want;
if (want) {
try {
if (app.canvas) {
app.canvas.style.display = 'block';
app.canvas.style.visibility = 'visible';
}
app.ticker.start();
} catch {
/* ignore */
@@ -240,6 +259,14 @@ export const PixiEffectsOverlay = forwardRef<PixiEffectsOverlayHandle, Props>(fu
} catch {
/* 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 => {
@@ -265,16 +292,22 @@ export const PixiEffectsOverlay = forwardRef<PixiEffectsOverlayHandle, Props>(fu
ref,
() => ({
setDraft: (instance) => {
if (instance) setGpuBootRef.current(true);
applyMergedState(committedStateRef.current, instance);
},
}),
[],
);
/** Снижаем resolution на HiDPI — меньше пикселей в WebGL, визуально ок для оверлея эффектов. */
const dpr = useMemo(() => Math.min(1.5, window.devicePixelRatio || 1), []);
/** 1 CSS-пиксель: на Retina/M1 лишний dpr сильно бьёт по fill-rate поверх видео. */
const dpr = useMemo(() => 1, []);
useEffect(() => {
if (effectsNeedWebGl(state, draftRef.current)) setGpuBoot(true);
}, [state]);
useEffect(() => {
if (!gpuBoot) return;
const host = hostRef.current;
if (!host) return;
@@ -371,7 +404,7 @@ export const PixiEffectsOverlay = forwardRef<PixiEffectsOverlayHandle, Props>(fu
host.replaceChildren();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
}, [gpuBoot]);
useEffect(() => {
const host = hostRef.current;
@@ -62,7 +62,7 @@ export function SceneGridOverlay({ grid, viewport }: Props) {
useEffect(() => {
const canvas = canvasRef.current;
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 h = Math.max(1, Math.round(viewport.h));
canvas.width = Math.round(w * dpr);
@@ -13,10 +13,19 @@ export function useNpcsOverlayState(): readonly [
useEffect(() => {
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 }) => {
setState(next);
setState({
...next,
inspectNpcIds: next.inspectNpcIds ?? [],
inspectFocusNpcId: next.inspectFocusNpcId ?? null,
});
});
}, [api]);
@@ -25,6 +25,15 @@
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 {
position: absolute;
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 {
max-width: 100%;
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 {
@@ -11,6 +11,17 @@ import {
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 = {
name: string;
imageUrl: string | null;
@@ -28,6 +39,16 @@ export type PlayerTokenViewProps = {
hideName?: boolean;
/** Session «Неактивен»: серый фильтр аватара. */
inactive?: boolean;
/** Текст/контент внутри пустого круга (только редактор, не маркеры на карте). */
emptyContent?: ReactNode;
/** Оверлей поверх круга (карандаш, drop-hint). */
circleOverlay?: ReactNode;
/** Подсветка круга при файловом DnD. */
dropActive?: boolean;
/** Клик по пустому кругу (выбор файла). */
onCircleClick?: () => void;
/** Сброс локального pan/zoom при смене сущности (npcId / playerId). */
syncKey?: string;
'data-testid'?: string;
};
@@ -44,13 +65,24 @@ export function PlayerTokenView({
className,
hideName = false,
inactive = false,
emptyContent,
circleOverlay,
dropActive = false,
onCircleClick,
syncKey,
'data-testid': testId,
}: PlayerTokenViewProps) {
const offset = clampPlayerImageOffset(imageOffset);
const scale = clampPlayerImageScale(imageScale ?? DEFAULT_PLAYER_IMAGE_SCALE);
const circleRef = useRef<HTMLDivElement | null>(null);
const scaleRef = useRef(scale);
scaleRef.current = scale;
const imgRef = useRef<HTMLImageElement | null>(null);
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);
onImageScaleChangeRef.current = onImageScaleChange;
const dragRef = useRef<{
@@ -61,9 +93,63 @@ export function PlayerTokenView({
origY: number;
} | 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(
(e: React.PointerEvent) => {
if (!panEnabled || !onImageOffsetChange) return;
if (!panEnabled || !onImageOffsetChangeRef.current || !imageUrl) return;
e.preventDefault();
e.stopPropagation();
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
@@ -71,45 +157,56 @@ export function PlayerTokenView({
pointerId: e.pointerId,
startX: e.clientX,
startY: e.clientY,
origX: offset.x,
origY: offset.y,
origX: visualOffsetRef.current.x,
origY: visualOffsetRef.current.y,
};
},
[offset.x, offset.y, onImageOffsetChange, panEnabled],
[imageUrl, panEnabled],
);
const onPointerMove = useCallback(
(e: React.PointerEvent) => {
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 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 d = dragRef.current;
if (!d || d.pointerId !== e.pointerId) return;
dragRef.current = null;
pendingOffsetRef.current = visualOffsetRef.current;
try {
(e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId);
} catch {
/* ignore */
}
onImageOffsetChangeRef.current?.(visualOffsetRef.current);
}, []);
useEffect(() => {
const el = circleRef.current;
if (!el || !panEnabled) return;
const onWheel = (e: WheelEvent) => {
const cb = onImageScaleChangeRef.current;
if (!cb) return;
if (!onImageScaleChangeRef.current) return;
e.preventDefault();
e.stopPropagation();
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 });
return () => el.removeEventListener('wheel', onWheel);
@@ -118,6 +215,7 @@ export function PlayerTokenView({
const rootClass = [styles.root, className ?? ''].filter(Boolean).join(' ');
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 canPickEmpty = Boolean(onCircleClick) && !imageUrl;
return (
<div
@@ -127,31 +225,47 @@ export function PlayerTokenView({
>
<div
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={{
width: sizePx,
height: sizePx,
borderColor: inactive ? '#9ca3af' : ringColor,
borderColor: dropActive ? undefined : inactive ? '#9ca3af' : ringColor,
}}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerCancel={onPointerUp}
onClick={
canPickEmpty
? () => {
onCircleClick?.();
}
: undefined
}
>
{imageUrl ? (
<img
ref={imgRef}
className={styles.avatar}
src={imageUrl}
alt=""
draggable={false}
style={{
transform: `translate(-50%, -50%) translate(${String(offset.x * 100)}%, ${String(offset.y * 100)}%) scale(${String(scale)})`,
...(inactive ? { filter: 'grayscale(1)', opacity: 0.85 } : {}),
}}
style={inactive ? { filter: 'grayscale(1)', opacity: 0.85 } : undefined}
/>
) : (
<div className={styles.avatarEmpty} />
<div className={[styles.avatarEmpty, emptyContent ? styles.avatarEmptySolid : ''].filter(Boolean).join(' ')}>
{emptyContent}
</div>
)}
{circleOverlay}
</div>
{!hideName ? (
<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');
assert.ok(src.includes('PlayerTokenView'));
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',
imageOffset: { x: 0, y: 0 },
imageScale: 1,
externalUrl: '',
},
{
id: n2,
@@ -261,6 +262,7 @@ void test('selectNpcsByIds / buildPartialExportProject: explicit NPCs, relations
disposition: 'neutral',
imageOffset: { x: 0, y: 0 },
imageScale: 1,
externalUrl: '',
},
{
id: n3,
@@ -274,6 +276,7 @@ void test('selectNpcsByIds / buildPartialExportProject: explicit NPCs, relations
disposition: 'neutral',
imageOffset: { x: 0, y: 0 },
imageScale: 1,
externalUrl: '',
},
];
const relations: ProjectNpcRelation[] = [
@@ -364,6 +367,7 @@ void test('buildPartialExportProject: keeps npcTokens only for exported NPCs', (
disposition: 'neutral',
imageOffset: { x: 0, y: 0 },
imageScale: 1,
externalUrl: '',
},
{
id: n2,
@@ -377,6 +381,7 @@ void test('buildPartialExportProject: keeps npcTokens only for exported NPCs', (
disposition: 'neutral',
imageOffset: { x: 0, y: 0 },
imageScale: 1,
externalUrl: '',
},
],
});
@@ -430,6 +435,7 @@ void test('mergeStorylinesIntoProject: remaps npcTokens to created NPC ids', ()
disposition: 'neutral',
imageOffset: { x: 0.1, y: -0.1 },
imageScale: 1,
externalUrl: '',
},
],
});
@@ -489,6 +495,7 @@ void test('mergeStorylinesIntoProject: imports all NPCs from export bundle', ()
disposition: 'neutral',
imageOffset: { x: 0, y: 0 },
imageScale: 1,
externalUrl: '',
},
],
});
@@ -22,6 +22,7 @@ import {
asSceneNpcTokenId,
asTokenId,
} from '../types/ids';
import { normalizeNpcExternalUrl } from '../types/npcExternalUrl';
import {
getConnectedComponent,
@@ -700,6 +701,7 @@ export function mergeStorylinesIntoProject(
disposition: n.disposition === 'hostile' || n.disposition === 'friendly' ? n.disposition : 'neutral',
imageOffset: n.imageOffset ?? { x: 0, y: 0 },
imageScale: typeof n.imageScale === 'number' && Number.isFinite(n.imageScale) ? n.imageScale : 1,
externalUrl: normalizeNpcExternalUrl(n.externalUrl),
});
npcNameKeys.add(name.toLowerCase());
npcsCreated += 1;
+1
View File
@@ -18,4 +18,5 @@ void test('contracts: players and sceneNpcTokensSession channels exist', () => {
assert.match(src, /tokenGridSnap:\s*\{/);
assert.match(src, /tokenGridSnap\.setEnabled/);
assert.match(src, /npcTokens\?:\s*SceneNpcToken\[\]/);
assert.match(src, /externalUrl\?:\s*string/);
});
+2
View File
@@ -443,6 +443,7 @@ export type IpcInvokeMap = {
npcId?: NpcId;
name: string;
description?: string;
externalUrl?: string;
filePath?: string;
groupId?: NpcGroupId | null;
ringColor?: string;
@@ -457,6 +458,7 @@ export type IpcInvokeMap = {
npcId: NpcId;
name?: string;
description?: string;
externalUrl?: string;
groupId?: NpcGroupId | null;
ringColor?: string;
disposition?: NpcDisposition;
+2
View File
@@ -43,6 +43,8 @@ export type ProjectNpc = {
avatarAssetId: AssetId;
/** HTML-описание (TipTap), как у сцены. */
description: string;
/** Опциональная внешняя страница (http/https). Пустая строка — нет ссылки. */
externalUrl: string;
/** Позиция карточки на графе взаимосвязей. */
x: number;
y: number;
+1
View File
@@ -6,6 +6,7 @@ export * from './ids';
export * from './materialLegend';
export * from './materials';
export * from './npcDisposition';
export * from './npcExternalUrl';
export * from './npcs';
export * from './sceneDarkness';
export * from './sceneGrid';
+27
View File
@@ -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);
});
+30
View File
@@ -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) !== '';
}
+7 -1
View File
@@ -14,12 +14,16 @@ export type NpcsZoomTool = 'zoomIn' | 'zoomOut' | null;
/** Session-only: какие НПС сейчас показаны поверх сцены (только аватары). */
export type NpcsOverlayState = {
revision: number;
/** Порядок выделения / показа описаний. */
/** Аватары на презентации / пульте (чекбокс «Показать»). */
activeNpcIds: NpcId[];
layouts: Record<string, NpcsOverlayLayout>;
/** На кого действует zoom, если клик не попал в конкретный аватар. */
focusNpcId: NpcId | null;
zoomTool: NpcsZoomTool;
/** Карточки описания в окне НПС (клик по плитке, можно несколько). */
inspectNpcIds: NpcId[];
/** Кого развернуть / подсветить в списке карточек (открытие информации). */
inspectFocusNpcId: NpcId | null;
};
export const DEFAULT_NPCS_OVERLAY_LAYOUT: NpcsOverlayLayout = {
@@ -33,6 +37,8 @@ export type NpcsOverlayEvent =
| { kind: 'show'; npcId: NpcId }
| { kind: 'hide' }
| { kind: 'toggle'; npcId: NpcId }
| { kind: 'inspect.toggle'; npcId: NpcId }
| { kind: 'inspect.show'; npcId: NpcId }
| { kind: 'layout.set'; npcId: NpcId; layout: NpcsOverlayLayout }
| { kind: 'zoomTool.set'; tool: NpcsZoomTool }
| { 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 на каждом кадре.
assert.doesNotMatch(previewSrc, /\brequestAnimationFrame\s*\(\s*loop\s*\)/);
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/);
});