Files
DndGamePlayer/app/renderer/editor/help/helpLinkify.ts
T
Ivan Fontosh a2b418b78a feat(help): traps/tokens/grid sections and clickable cross-links
Add instruction pages for traps, non-player tokens, and grid generator, and turn section mentions into in-modal navigation links.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-28 12:13:53 +08:00

116 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: ['Пульт'],
};
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;
}