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
+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 };