fix(knowledge): restore classification colors and scroll-synced outline

This commit is contained in:
冰朔 2026-09-03 16:40:56 +08:00
commit 71cece74b0
10 changed files with 404 additions and 38 deletions

View file

@ -1,9 +1,14 @@
import { useEffect, useMemo, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { check } from "@tauri-apps/plugin-updater";
import { relaunch } from "@tauri-apps/plugin-process";
import { Icon } from "./icons";
import * as api from "./runtime";
import type { KnowledgeDocument, SourceKind, SystemSnapshot } from "./types";
import type {
KnowledgeDocument,
KnowledgeSummary,
SourceKind,
SystemSnapshot,
} from "./types";
import {
MarkdownDocument,
documentOutline,
@ -304,6 +309,120 @@ function ReceiptStream({ data }: { data: SystemSnapshot }) {
);
}
const tagTints = ["tag-sky", "tag-violet", "tag-amber", "tag-mint", "tag-rose"];
function tagTint(tag: string) {
let hash = 0;
for (const char of tag) hash = (hash * 31 + (char.codePointAt(0) || 0)) % 997;
return tagTints[hash % tagTints.length];
}
interface KnowledgeTreeNode {
name: string;
key: string;
folders: Map<string, KnowledgeTreeNode>;
documents: KnowledgeSummary[];
}
function buildKnowledgeTree(documents: KnowledgeSummary[]) {
const root: KnowledgeTreeNode = {
name: "知识",
key: "",
folders: new Map(),
documents: [],
};
for (const document of documents) {
const segments = document.path.split("/").filter(Boolean);
segments.pop();
let cursor = root;
for (const segment of segments) {
const key = cursor.key ? `${cursor.key}/${segment}` : segment;
if (!cursor.folders.has(segment))
cursor.folders.set(segment, {
name: segment,
key,
folders: new Map(),
documents: [],
});
cursor = cursor.folders.get(segment)!;
}
cursor.documents.push(document);
}
return root;
}
function treeCount(node: KnowledgeTreeNode): number {
return (
node.documents.length +
[...node.folders.values()].reduce((sum, child) => sum + treeCount(child), 0)
);
}
function KnowledgeTree({
node,
depth,
expanded,
active,
onToggle,
onOpen,
}: {
node: KnowledgeTreeNode;
depth: number;
expanded: Set<string>;
active: string | null;
onToggle: (key: string) => void;
onOpen: (path: string) => void;
}) {
const folders = [...node.folders.values()].sort((a, b) =>
a.name.localeCompare(b.name, "zh-CN"),
);
const documents = [...node.documents].sort((a, b) =>
a.title.localeCompare(b.title, "zh-CN"),
);
return (
<>
{folders.map((folder) => {
const open = depth === 0 || expanded.has(folder.key);
return (
<div className="tree-branch" key={folder.key}>
<button
className="tree-folder"
style={{ paddingLeft: 10 + depth * 13 }}
onClick={() => onToggle(folder.key)}
>
<span className={open ? "tree-chevron open" : "tree-chevron"}>
</span>
<Icon name="folder" />
<span>{folder.name}</span>
<em>{treeCount(folder)}</em>
</button>
{open && (
<KnowledgeTree
node={folder}
depth={depth + 1}
expanded={expanded}
active={active}
onToggle={onToggle}
onOpen={onOpen}
/>
)}
</div>
);
})}
{documents.map((document) => (
<button
className={
active === document.path ? "tree-document active" : "tree-document"
}
style={{ paddingLeft: 28 + depth * 13 }}
key={document.path}
onClick={() => onOpen(document.path)}
>
<Icon name="file" />
<span>{document.title}</span>
</button>
))}
</>
);
}
function Knowledge({
data,
refresh,
@ -319,13 +438,17 @@ function Knowledge({
[query, setQuery] = useState(""),
[newTitle, setNewTitle] = useState<string | null>(null),
[editing, setEditing] = useState(false),
[inspectorOpen, setInspectorOpen] = useState(true);
[inspectorOpen, setInspectorOpen] = useState(true),
[expanded, setExpanded] = useState<Set<string>>(new Set(["导入"])),
[activeHeadingId, setActiveHeadingId] = useState<string | null>(null);
const headingScrollTimer = useRef<number | null>(null);
useEffect(() => {
if (selected)
api.readDocument(selected).then((d) => {
setDoc(d);
setBody(d.body);
setEditing(false);
setActiveHeadingId(null);
});
}, [selected]);
const list = useMemo(
@ -335,6 +458,7 @@ function Knowledge({
),
[data.documents, query],
);
const tree = useMemo(() => buildKnowledgeTree(list), [list]);
const create = async () => {
const title = newTitle?.trim();
if (title) {
@ -364,8 +488,33 @@ function Knowledge({
const outline = useMemo(() => documentOutline(doc?.body ?? ""), [doc?.body]);
const stats = useMemo(() => {
const chars = parsed.content.replace(/\s/g, "").length;
return { chars, minutes: Math.max(1, Math.ceil(chars / 450)) };
return { chars, minutes: Math.max(1, Math.ceil(chars / 400)) };
}, [parsed.content]);
useEffect(() => {
if (!activeHeadingId) return;
document
.querySelector(".outline-list button.active")
?.scrollIntoView({ block: "nearest" });
}, [activeHeadingId]);
const handleReaderScroll = (event: React.UIEvent<HTMLDivElement>) => {
const container = event.currentTarget;
if (headingScrollTimer.current !== null) return;
headingScrollTimer.current = window.setTimeout(() => {
headingScrollTimer.current = null;
const containerTop = container.getBoundingClientRect().top;
let current: string | null = null;
for (const heading of Array.from(
container.querySelectorAll<HTMLElement>(
"h1[id], h2[id], h3[id], h4[id]",
),
)) {
if (heading.getBoundingClientRect().top - containerTop <= 28)
current = heading.id;
else break;
}
setActiveHeadingId(current);
}, 100);
};
const openWiki = (target: string) => {
const normalized = target.replace(/[\s·•・\-_|*`]/g, "").toLowerCase();
const hit = data.documents.find(
@ -410,18 +559,23 @@ function Knowledge({
{list.length === 0 ? (
<p className="empty"></p>
) : (
list.map((d) => (
<button
className={selected === d.path ? "selected" : ""}
key={d.path}
onClick={() => setSelected(d.path)}
>
<strong>{d.title}</strong>
<small>
{new Date(d.updatedAt).toLocaleDateString("zh-CN")}
</small>
</button>
))
<div className="knowledge-tree">
<KnowledgeTree
node={tree}
depth={0}
expanded={expanded}
active={selected}
onToggle={(key) =>
setExpanded((current) => {
const next = new Set(current);
if (next.has(key)) next.delete(key);
else next.add(key);
return next;
})
}
onOpen={setSelected}
/>
</div>
)}
</aside>
<section className="document-workspace">
@ -465,7 +619,7 @@ function Knowledge({
onChange={(e) => setBody(e.target.value)}
/>
) : (
<div className="document-scroll">
<div className="document-scroll" onScroll={handleReaderScroll}>
<header className="reader-heading">
<h1>{doc.title}</h1>
<p>
@ -475,10 +629,23 @@ function Knowledge({
{parsed.tags.length > 0 && (
<div className="meta-tags">
{parsed.tags.map((tag) => (
<span key={tag}>{tag}</span>
<span key={tag} className={tagTint(tag)}>
{tag}
</span>
))}
</div>
)}
<div className="meta-properties">
{Object.entries(parsed.metadata)
.filter(([key]) => key !== "tags")
.slice(0, 6)
.map(([key, value]) => (
<span key={key}>
<b>{key}</b>
{value}
</span>
))}
</div>
</header>
<MarkdownDocument
body={doc.body}
@ -509,9 +676,13 @@ function Knowledge({
{outline.length ? (
outline.map((item) => (
<button
className={activeHeadingId === item.id ? "active" : ""}
key={item.id}
style={{ paddingLeft: 12 + (item.level - 1) * 10 }}
onClick={() => jumpToHeading(item.id)}
onClick={() => {
jumpToHeading(item.id);
setActiveHeadingId(item.id);
}}
>
{item.title}
</button>