101f595bac
Add userData players/teams, scene npcTokens with hex-inscribed sizing, session scale synced to presentation, and Playwright e2e coverage. Co-authored-by: Cursor <cursoragent@cursor.com>
117 lines
3.8 KiB
TypeScript
117 lines
3.8 KiB
TypeScript
import { HELP_SECTION_IDS, type HelpSectionId } from './helpSections';
|
|
|
|
/**
|
|
* Короткие имена разделов в перекрёстных ссылках
|
|
* (когда в тексте не полное title, напр. «Эффекты» вместо «Эффекты поля и действий»).
|
|
*/
|
|
export const HELP_SECTION_LINK_ALIASES: Partial<Record<HelpSectionId, readonly string[]>> = {
|
|
effects: ['Эффекты', 'Effects'],
|
|
presentation: ['Презентация', 'Presentation'],
|
|
grid: ['Сетка', 'Grid'],
|
|
controlPanel: ['Пульт'],
|
|
players: ['Игроки', 'Players'],
|
|
};
|
|
|
|
export type HelpLinkCatalogEntry = {
|
|
id: HelpSectionId;
|
|
label: string;
|
|
};
|
|
|
|
export type HelpLinkRange = {
|
|
start: number;
|
|
end: number;
|
|
id: HelpSectionId;
|
|
text: string;
|
|
};
|
|
|
|
function escapeRegExp(value: string): string {
|
|
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
}
|
|
|
|
/** Каталог подписей → id, длинные первыми. */
|
|
export function buildHelpLinkCatalog(getTitle: (id: HelpSectionId) => string): HelpLinkCatalogEntry[] {
|
|
const byLabel = new Map<string, HelpSectionId>();
|
|
for (const id of HELP_SECTION_IDS) {
|
|
const title = getTitle(id).trim();
|
|
if (title) byLabel.set(title, id);
|
|
for (const alias of HELP_SECTION_LINK_ALIASES[id] ?? []) {
|
|
const a = alias.trim();
|
|
if (a) byLabel.set(a, id);
|
|
}
|
|
}
|
|
return [...byLabel.entries()]
|
|
.map(([label, id]) => ({ id, label }))
|
|
.sort((a, b) => b.label.length - a.label.length || a.label.localeCompare(b.label));
|
|
}
|
|
|
|
function isBoundaryChar(ch: string | undefined): boolean {
|
|
if (ch === undefined) return true;
|
|
if (/^[\p{L}\p{N}]$/u.test(ch)) return false;
|
|
return true;
|
|
}
|
|
|
|
function collectMatches(text: string, label: string, id: HelpSectionId): HelpLinkRange[] {
|
|
const out: HelpLinkRange[] = [];
|
|
if (!label) return out;
|
|
|
|
const quoted = new RegExp(`«${escapeRegExp(label)}»`, 'g');
|
|
for (const m of text.matchAll(quoted)) {
|
|
if (m.index === undefined) continue;
|
|
out.push({ start: m.index, end: m.index + m[0].length, id, text: m[0] });
|
|
}
|
|
|
|
const bare = new RegExp(escapeRegExp(label), 'g');
|
|
for (const m of text.matchAll(bare)) {
|
|
if (m.index === undefined) continue;
|
|
const start = m.index;
|
|
const end = start + m[0].length;
|
|
const before = text[start - 1];
|
|
const after = text[end];
|
|
if (!isBoundaryChar(before) || !isBoundaryChar(after)) continue;
|
|
if (before === '«' && after === '»') continue;
|
|
out.push({ start, end, id, text: m[0] });
|
|
}
|
|
return out;
|
|
}
|
|
|
|
export function findHelpLinkRanges(text: string, catalog: readonly HelpLinkCatalogEntry[]): HelpLinkRange[] {
|
|
const candidates: HelpLinkRange[] = [];
|
|
for (const entry of catalog) {
|
|
candidates.push(...collectMatches(text, entry.label, entry.id));
|
|
}
|
|
candidates.sort(
|
|
(a, b) => a.start - b.start || b.end - b.start - (a.end - a.start),
|
|
);
|
|
|
|
const selected: HelpLinkRange[] = [];
|
|
let cursor = 0;
|
|
for (const range of candidates) {
|
|
if (range.start < cursor) continue;
|
|
selected.push(range);
|
|
cursor = range.end;
|
|
}
|
|
return selected;
|
|
}
|
|
|
|
export function splitHelpTextWithLinks(
|
|
text: string,
|
|
catalog: readonly HelpLinkCatalogEntry[],
|
|
): Array<{ type: 'text'; value: string } | { type: 'link'; id: HelpSectionId; value: string }> {
|
|
const ranges = findHelpLinkRanges(text, catalog);
|
|
if (ranges.length === 0) return [{ type: 'text', value: text }];
|
|
const parts: Array<{ type: 'text'; value: string } | { type: 'link'; id: HelpSectionId; value: string }> =
|
|
[];
|
|
let cursor = 0;
|
|
for (const range of ranges) {
|
|
if (range.start > cursor) {
|
|
parts.push({ type: 'text', value: text.slice(cursor, range.start) });
|
|
}
|
|
parts.push({ type: 'link', id: range.id, value: range.text });
|
|
cursor = range.end;
|
|
}
|
|
if (cursor < text.length) {
|
|
parts.push({ type: 'text', value: text.slice(cursor) });
|
|
}
|
|
return parts;
|
|
}
|