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:
@@ -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;
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user