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
@@ -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/);
});