diff --git a/app/main/foundry/foundryImport.ts b/app/main/foundry/foundryImport.ts index b452fe8..8c83a2c 100644 --- a/app/main/foundry/foundryImport.ts +++ b/app/main/foundry/foundryImport.ts @@ -451,6 +451,7 @@ export async function buildProjectFromFoundryDocuments( disposition: 'neutral', imageOffset: { x: 0, y: 0 }, imageScale: 1, + externalUrl: '', }); npcIndex += 1; } diff --git a/app/main/index.ts b/app/main/index.ts index b5ebb6e..f596687 100644 --- a/app/main/index.ts +++ b/app/main/index.ts @@ -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 } : {}), diff --git a/app/main/npcs/npcsOverlayStore.test.ts b/app/main/npcs/npcsOverlayStore.test.ts new file mode 100644 index 0000000..466493e --- /dev/null +++ b/app/main/npcs/npcsOverlayStore.test.ts @@ -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, []); +}); diff --git a/app/main/npcs/npcsOverlayStore.ts b/app/main/npcs/npcsOverlayStore.ts index 0efa3bf..a7d7c1a 100644 --- a/app/main/npcs/npcsOverlayStore.ts +++ b/app/main/npcs/npcsOverlayStore.ts @@ -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): 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 = {}; 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; } diff --git a/app/main/project/zipStore.legacyContract.test.ts b/app/main/project/zipStore.legacyContract.test.ts index 6326340..610c4f5 100644 --- a/app/main/project/zipStore.legacyContract.test.ts +++ b/app/main/project/zipStore.legacyContract.test.ts @@ -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( diff --git a/app/main/project/zipStore.ts b/app/main/project/zipStore.ts index de858b6..0eadf37 100644 --- a/app/main/project/zipStore.ts +++ b/app/main/project/zipStore.ts @@ -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), diff --git a/app/main/sessionIpc.phase6.networkRegression.test.ts b/app/main/sessionIpc.phase6.networkRegression.test.ts index 444252f..0075572 100644 --- a/app/main/sessionIpc.phase6.networkRegression.test.ts +++ b/app/main/sessionIpc.phase6.networkRegression.test.ts @@ -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'/); +}); diff --git a/app/main/windows/createWindows.editorClose.test.ts b/app/main/windows/createWindows.editorClose.test.ts index d0ce921..0f2b7ea 100644 --- a/app/main/windows/createWindows.editorClose.test.ts +++ b/app/main/windows/createWindows.editorClose.test.ts @@ -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: закрытие пульта закрывает сессионные окна и презентацию', () => { diff --git a/app/main/windows/createWindows.ts b/app/main/windows/createWindows.ts index 7020aa7..a1d4de0 100644 --- a/app/main/windows/createWindows.ts +++ b/app/main/windows/createWindows.ts @@ -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(); diff --git a/app/renderer/control/ControlScenePreview.tsx b/app/renderer/control/ControlScenePreview.tsx index a5deb53..a75e6fd 100644 --- a/app/renderer/control/ControlScenePreview.tsx +++ b/app/renderer/control/ControlScenePreview.tsx @@ -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(null); + const scrubFillRef = useRef(null); + const curTimeRef = useRef(null); + const durTimeRef = useRef(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 (
@@ -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} > @@ -128,30 +145,27 @@ export function ControlScenePreview({ session, videoRef, viewCamera = null, onCo {isVideo ? (
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')} > -
+
@@ -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
- {fmt(cur)} / {dur ? fmt(dur) : '—:—'} + 0:00 + {' / '} + —:—
diff --git a/app/renderer/editor/EditorApp.tsx b/app/renderer/editor/EditorApp.tsx index 941dac8..a2d273e 100644 --- a/app/renderer/editor/EditorApp.tsx +++ b/app/renderer/editor/EditorApp.tsx @@ -2990,47 +2990,6 @@ function SceneListCard({ decoding="async" />
- ) : previewUrl && scene.previewAssetType === 'video' ? ( -
- {scene.previewRotationDeg === 0 ? ( -
) : (
)} diff --git a/app/renderer/editor/graph/SceneGraph.tsx b/app/renderer/editor/graph/SceneGraph.tsx index cdc1b11..54d639b 100644 --- a/app/renderer/editor/graph/SceneGraph.tsx +++ b/app/renderer/editor/graph/SceneGraph.tsx @@ -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) { /> )}
- ) : previewUrl && data.previewAssetType === 'video' ? ( -
- {data.previewRotationDeg === 0 ? ( -
) : (
)} diff --git a/app/renderer/editor/i18n/editorMessages.ts b/app/renderer/editor/i18n/editorMessages.ts index 6218458..dfe153a 100644 --- a/app/renderer/editor/i18n/editorMessages.ts +++ b/app/renderer/editor/i18n/editorMessages.ts @@ -211,7 +211,7 @@ export const EDITOR_MESSAGES: Record> = { '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> = { '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> = { '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> = { '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> = { '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> = { '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> = { '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> = { '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', diff --git a/app/renderer/npcs/NpcsApp.module.css b/app/renderer/npcs/NpcsApp.module.css index adc94d5..8446ef5 100644 --- a/app/renderer/npcs/NpcsApp.module.css +++ b/app/renderer/npcs/NpcsApp.module.css @@ -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; diff --git a/app/renderer/npcs/NpcsApp.tsx b/app/renderer/npcs/NpcsApp.tsx index 97de155..a0730b7 100644 --- a/app/renderer/npcs/NpcsApp.tsx +++ b/app/renderer/npcs/NpcsApp.tsx @@ -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({ > ⋮ +
{npc.name}
+ +
{menu ? createPortal(
boolean; onToggleExpanded: (id: NpcGroupId) => void; selectedIds: ReadonlySet; + shownIds: ReadonlySet; 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(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
; +} + +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 ( +
+ + {expanded ? ( + <> + {hasLink ? ( +
+ + +
+ ) : null} + {showLink && externalUrl ? ( + + ) : ( + <> + {safeHtml ? ( +
+ {!hasLink ?
{t('npcs.description')}
: null} +
+
+ ) : ( +
{t('npcs.descriptionEmpty')}
+ )} + {relations.length > 0 ? ( +
+
{t('npcs.relations')}
+
+ {relations.map((r) => ( +
+ {r.text} +
+ ))} +
+
+ ) : null} + + )} + + ) : null} +
+ ); +} + 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>(() => new Set()); + const [collapsedInspect, setCollapsedInspect] = useState>(() => 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([]); 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() {
{detailNpcs.length > 0 ? ( - detailNpcs.map((npc) => { - const safeHtml = sanitizeSceneDescriptionHtml(npc.description); - const npcRelations = relationsByNpcId.get(npc.id) ?? []; - return ( -
-
{npc.name}
- {safeHtml ? ( -
-
{t('npcs.description')}
-
-
- ) : ( -
{t('npcs.descriptionEmpty')}
- )} - {npcRelations.length > 0 ? ( -
-
{t('npcs.relations')}
-
- {npcRelations.map((r) => ( -
- {r.text} -
- ))} -
-
- ) : null} -
- ); - }) + detailNpcs.map((npc) => ( + { + setCollapsedInspect((prev) => { + const next = new Set(prev); + if (next.has(npc.id)) next.delete(npc.id); + else next.add(npc.id); + return next; + }); + }} + /> + )) ) : (
@@ -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} diff --git a/app/renderer/npcs/NpcsEditorApp.module.css b/app/renderer/npcs/NpcsEditorApp.module.css index 2769841..007baad 100644 --- a/app/renderer/npcs/NpcsEditorApp.module.css +++ b/app/renderer/npcs/NpcsEditorApp.module.css @@ -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%; diff --git a/app/renderer/npcs/NpcsEditorApp.tsx b/app/renderer/npcs/NpcsEditorApp.tsx index faa2c50..e330bca 100644 --- a/app/renderer/npcs/NpcsEditorApp.tsx +++ b/app/renderer/npcs/NpcsEditorApp.tsx @@ -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(null); const [graphFilter, setGraphFilter] = useState('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() { <>
{t('npcs.avatar')}
-
+
{USERS_BRANCH_FEATURES_ENABLED ? ( {t('npcs.avatarPickHint')}
+ ) + } + circleOverlay={ + <> + {avatarDrop.dragOver ? ( +
{t('npcs.dropHint')}
+ ) : null} + {selectedUrl && !avatarDrop.dragOver ? ( +
+ +
+ ) : null} + + } onImageOffsetChange={(imageOffset) => { void api.invoke(ipcChannels.project.updateNpcFields, { npcId: selected.id, @@ -761,33 +871,48 @@ export function NpcsEditorApp() { }} /> ) : ( -
+
{selectedUrl ? ( + ) : avatarDrop.dragOver ? null : ( +
{t('npcs.avatarPickHint')}
+ )} + {avatarDrop.dragOver ? ( +
{t('npcs.dropHint')}
+ ) : null} + {selectedUrl && !avatarDrop.dragOver ? ( +
+ +
) : null}
)} -
@@ -868,6 +993,45 @@ export function NpcsEditorApp() { />
+
+
{t('npcs.externalLink')}
+ { + 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 ?
{t('npcs.externalLinkInvalid')}
: null} +
+ {relationsForSelected.length > 0 ? (
{t('npcs.relations')}
diff --git a/app/renderer/shared/effects/PxiEffectsOverlay.pointer.test.ts b/app/renderer/shared/effects/PxiEffectsOverlay.pointer.test.ts index 89e08b5..eebe766 100644 --- a/app/renderer/shared/effects/PxiEffectsOverlay.pointer.test.ts +++ b/app/renderer/shared/effects/PxiEffectsOverlay.pointer.test.ts @@ -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', () => { diff --git a/app/renderer/shared/effects/PxiEffectsOverlay.tsx b/app/renderer/shared/effects/PxiEffectsOverlay.tsx index 43d6c22..d24766d 100644 --- a/app/renderer/shared/effects/PxiEffectsOverlay.tsx +++ b/app/renderer/shared/effects/PxiEffectsOverlay.tsx @@ -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(fu const viewportProvidedRef = useRef(false); /** null = ещё не синхронизировали с Pixi (ticker по умолчанию бежит). */ const tickerWantedRef = useRef(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(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(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(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(fu host.replaceChildren(); }; // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + }, [gpuBoot]); useEffect(() => { const host = hostRef.current; diff --git a/app/renderer/shared/grid/SceneGridOverlay.tsx b/app/renderer/shared/grid/SceneGridOverlay.tsx index e04d2bb..c55e983 100644 --- a/app/renderer/shared/grid/SceneGridOverlay.tsx +++ b/app/renderer/shared/grid/SceneGridOverlay.tsx @@ -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); diff --git a/app/renderer/shared/npcs/useNpcsOverlayState.ts b/app/renderer/shared/npcs/useNpcsOverlayState.ts index ac6b4b2..f82f40a 100644 --- a/app/renderer/shared/npcs/useNpcsOverlayState.ts +++ b/app/renderer/shared/npcs/useNpcsOverlayState.ts @@ -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]); diff --git a/app/renderer/shared/playerToken/PlayerTokenView.module.css b/app/renderer/shared/playerToken/PlayerTokenView.module.css index e503442..f63c888 100644 --- a/app/renderer/shared/playerToken/PlayerTokenView.module.css +++ b/app/renderer/shared/playerToken/PlayerTokenView.module.css @@ -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; diff --git a/app/renderer/shared/playerToken/PlayerTokenView.tsx b/app/renderer/shared/playerToken/PlayerTokenView.tsx index 5de2776..c09b19f 100644 --- a/app/renderer/shared/playerToken/PlayerTokenView.tsx +++ b/app/renderer/shared/playerToken/PlayerTokenView.tsx @@ -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(null); - const scaleRef = useRef(scale); - scaleRef.current = scale; + const imgRef = useRef(null); + const visualOffsetRef = useRef(offset); + const visualScaleRef = useRef(scale); + const pendingOffsetRef = useRef(null); + const pendingScaleRef = useRef(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 (
{ + onCircleClick?.(); + } + : undefined + } > {imageUrl ? ( ) : ( -
+
+ {emptyContent} +
)} + {circleOverlay}
{!hideName ? (
diff --git a/app/renderer/shared/ui/secondaryWindows.stability.test.ts b/app/renderer/shared/ui/secondaryWindows.stability.test.ts index 7f1cf52..41a2d28 100644 --- a/app/renderer/shared/ui/secondaryWindows.stability.test.ts +++ b/app/renderer/shared/ui/secondaryWindows.stability.test.ts @@ -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')); }); diff --git a/app/shared/graph/storylineExportImport.test.ts b/app/shared/graph/storylineExportImport.test.ts index ca7674f..a54f0cb 100644 --- a/app/shared/graph/storylineExportImport.test.ts +++ b/app/shared/graph/storylineExportImport.test.ts @@ -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: '', }, ], }); diff --git a/app/shared/graph/storylineExportImport.ts b/app/shared/graph/storylineExportImport.ts index f1d7a75..2cd794d 100644 --- a/app/shared/graph/storylineExportImport.ts +++ b/app/shared/graph/storylineExportImport.ts @@ -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; diff --git a/app/shared/ipc/contracts.players.test.ts b/app/shared/ipc/contracts.players.test.ts index 0e4d121..e7c5835 100644 --- a/app/shared/ipc/contracts.players.test.ts +++ b/app/shared/ipc/contracts.players.test.ts @@ -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/); }); diff --git a/app/shared/ipc/contracts.ts b/app/shared/ipc/contracts.ts index 6106f2a..097b499 100644 --- a/app/shared/ipc/contracts.ts +++ b/app/shared/ipc/contracts.ts @@ -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; diff --git a/app/shared/types/domain.ts b/app/shared/types/domain.ts index 2512b78..aeb662d 100644 --- a/app/shared/types/domain.ts +++ b/app/shared/types/domain.ts @@ -43,6 +43,8 @@ export type ProjectNpc = { avatarAssetId: AssetId; /** HTML-описание (TipTap), как у сцены. */ description: string; + /** Опциональная внешняя страница (http/https). Пустая строка — нет ссылки. */ + externalUrl: string; /** Позиция карточки на графе взаимосвязей. */ x: number; y: number; diff --git a/app/shared/types/index.ts b/app/shared/types/index.ts index 7447d7d..3152031 100644 --- a/app/shared/types/index.ts +++ b/app/shared/types/index.ts @@ -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'; diff --git a/app/shared/types/npcExternalUrl.test.ts b/app/shared/types/npcExternalUrl.test.ts new file mode 100644 index 0000000..e850164 --- /dev/null +++ b/app/shared/types/npcExternalUrl.test.ts @@ -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); +}); diff --git a/app/shared/types/npcExternalUrl.ts b/app/shared/types/npcExternalUrl.ts new file mode 100644 index 0000000..a6b39cb --- /dev/null +++ b/app/shared/types/npcExternalUrl.ts @@ -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) !== ''; +} diff --git a/app/shared/types/npcs.ts b/app/shared/types/npcs.ts index d43d4f9..25b080e 100644 --- a/app/shared/types/npcs.ts +++ b/app/shared/types/npcs.ts @@ -14,12 +14,16 @@ export type NpcsZoomTool = 'zoomIn' | 'zoomOut' | null; /** Session-only: какие НПС сейчас показаны поверх сцены (только аватары). */ export type NpcsOverlayState = { revision: number; - /** Порядок выделения / показа описаний. */ + /** Аватары на презентации / пульте (чекбокс «Показать»). */ activeNpcIds: NpcId[]; layouts: Record; /** На кого действует 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 }; diff --git a/app/shared/video/videoPlaybackPerf.networkRegression.test.ts b/app/shared/video/videoPlaybackPerf.networkRegression.test.ts index 850e7de..97c0282 100644 --- a/app/shared/video/videoPlaybackPerf.networkRegression.test.ts +++ b/app/shared/video/videoPlaybackPerf.networkRegression.test.ts @@ -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, /