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:
@@ -43,6 +43,8 @@ export type ProjectNpc = {
|
||||
avatarAssetId: AssetId;
|
||||
/** HTML-описание (TipTap), как у сцены. */
|
||||
description: string;
|
||||
/** Опциональная внешняя страница (http/https). Пустая строка — нет ссылки. */
|
||||
externalUrl: string;
|
||||
/** Позиция карточки на графе взаимосвязей. */
|
||||
x: number;
|
||||
y: number;
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { normalizeNpcExternalUrl, npcExternalUrlIsValid } from './npcExternalUrl';
|
||||
|
||||
void test('normalizeNpcExternalUrl: empty and junk', () => {
|
||||
assert.equal(normalizeNpcExternalUrl(''), '');
|
||||
assert.equal(normalizeNpcExternalUrl(' '), '');
|
||||
assert.equal(normalizeNpcExternalUrl(null), '');
|
||||
assert.equal(normalizeNpcExternalUrl('javascript:alert(1)'), '');
|
||||
assert.equal(normalizeNpcExternalUrl('file:///etc/passwd'), '');
|
||||
assert.equal(normalizeNpcExternalUrl('data:text/html,hi'), '');
|
||||
});
|
||||
|
||||
void test('normalizeNpcExternalUrl: http(s) and bare host', () => {
|
||||
assert.equal(normalizeNpcExternalUrl('https://example.com/npc'), 'https://example.com/npc');
|
||||
assert.equal(normalizeNpcExternalUrl('http://example.com'), 'http://example.com/');
|
||||
assert.equal(normalizeNpcExternalUrl('example.com/wiki'), 'https://example.com/wiki');
|
||||
assert.equal(normalizeNpcExternalUrl(' https://wiki.example/foo '), 'https://wiki.example/foo');
|
||||
});
|
||||
|
||||
void test('npcExternalUrlIsValid: blank ok, garbage not', () => {
|
||||
assert.equal(npcExternalUrlIsValid(''), true);
|
||||
assert.equal(npcExternalUrlIsValid(' '), true);
|
||||
assert.equal(npcExternalUrlIsValid('https://ok.example'), true);
|
||||
assert.equal(npcExternalUrlIsValid('not a url'), false);
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
/** Опциональная внешняя ссылка НПС (wiki, заметки). Только http(s). */
|
||||
|
||||
const MAX_NPC_EXTERNAL_URL_LENGTH = 2048;
|
||||
|
||||
export function normalizeNpcExternalUrl(raw: unknown): string {
|
||||
if (typeof raw !== 'string') return '';
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return '';
|
||||
if (trimmed.length > MAX_NPC_EXTERNAL_URL_LENGTH) return '';
|
||||
let candidate = trimmed;
|
||||
if (!/^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(candidate)) {
|
||||
candidate = `https://${candidate}`;
|
||||
}
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(candidate);
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') return '';
|
||||
if (parsed.username || parsed.password) return '';
|
||||
return parsed.toString();
|
||||
}
|
||||
|
||||
export function npcExternalUrlIsValid(raw: unknown): boolean {
|
||||
if (typeof raw !== 'string') return false;
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return true;
|
||||
return normalizeNpcExternalUrl(trimmed) !== '';
|
||||
}
|
||||
@@ -14,12 +14,16 @@ export type NpcsZoomTool = 'zoomIn' | 'zoomOut' | null;
|
||||
/** Session-only: какие НПС сейчас показаны поверх сцены (только аватары). */
|
||||
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 };
|
||||
|
||||
Reference in New Issue
Block a user