ai-chatbot/lib/flow-board/topic-search.ts
134 822c4cbfb9 fix(flow-board): align Xmind editor UI and restore marquee feedback
Add native-style menus, search and presentation controls, typography resources, and Xmind import/export styling support.

Bundle canvas styles for preview and production, show marquee borders and selection fills, and align editor density with the project UI. Include interaction regression coverage and client comparison notes.
2026-10-04 18:57:02 +08:00

59 lines
2.8 KiB
TypeScript

import type { PlaitElement } from "@plait/core";
import { getTopicEntries } from "./topic-details";
export type TopicSearchResult = { id: string; title: string; sheetId: string; sheetTitle: string; occurrences: number };
export type SearchSheet = { id: string; title: string; children: PlaitElement[] };
export function searchTopics(sheets: SearchSheet[], query: string, caseSensitive = false): TopicSearchResult[] {
if (!query) return [];
const needle = caseSensitive ? query : query.toLocaleLowerCase();
return sheets.flatMap(sheet => getTopicEntries(sheet.children).flatMap(({ element, title }) => {
const text = caseSensitive ? title : title.toLocaleLowerCase();
let count = 0;
let offset = 0;
for (let at = text.indexOf(needle, offset); at !== -1; at = text.indexOf(needle, offset)) {
count++;
offset = at + needle.length;
}
return count ? [{ id: element.id, title, sheetId: sheet.id, sheetTitle: sheet.title, occurrences: count }] : [];
}));
}
/** A match may cross styled leaves. Replacement inherits the first matched leaf's marks. */
export function replaceTopicText(element: PlaitElement, query: string, replacement: string, caseSensitive = false) {
const topic = element.data?.topic;
if (!query || !topic) return topic;
const leaves: { node: Record<string, unknown>; start: number; end: number }[] = [];
let source = "";
const collect = (node: Record<string, unknown>) => {
if (typeof node.text === "string") {
const start = source.length;
source += node.text;
leaves.push({ node, start, end: source.length });
} else if (Array.isArray(node.children)) {
node.children.forEach(child => collect(child as Record<string, unknown>));
}
};
collect(topic);
const pattern = new RegExp(query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), caseSensitive ? "g" : "gi");
const matches = Array.from(source.matchAll(pattern), match => ({ start: match.index!, end: match.index! + match[0].length }));
const replacements = new Map<object, string>();
for (const { node, start, end } of leaves) {
let cursor = start;
let text = "";
for (const match of matches) {
if (match.end <= start || match.start >= end) continue;
text += source.slice(cursor, Math.max(cursor, match.start));
if (match.start >= start) text += replacement;
cursor = Math.min(end, match.end);
}
replacements.set(node, text + source.slice(cursor, end));
}
const visit = (node: Record<string, unknown>): Record<string, unknown> => {
if (typeof node.text === "string") return { ...node, text: replacements.get(node) ?? node.text };
if (Array.isArray(node.children)) return { ...node, children: node.children.map(child => visit(child as Record<string, unknown>)) };
return node;
};
return visit(topic);
}