fix(knowledge): restore classification colors and scroll-synced outline
This commit is contained in:
parent
3466b507c6
commit
71cece74b0
10 changed files with 404 additions and 38 deletions
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in a new issue