Add native task metadata, working-day calendar settings, dependency scheduling and rich note editing. Support complete-sheet PNG export with resolution and transparency controls, preserve XMind extensions and update browser regressions.
319 lines
9.1 KiB
TypeScript
319 lines
9.1 KiB
TypeScript
"use client";
|
|
import {
|
|
Bold,
|
|
Italic,
|
|
Underline,
|
|
Strikethrough,
|
|
List,
|
|
ListOrdered,
|
|
Link2,
|
|
X,
|
|
} from "lucide-react";
|
|
import { useEffect, useRef, useState } from "react";
|
|
import {
|
|
createEditor,
|
|
Editor,
|
|
Element,
|
|
Transforms,
|
|
type Descendant,
|
|
} from "slate";
|
|
import { withHistory } from "slate-history";
|
|
import {
|
|
Editable,
|
|
Slate,
|
|
withReact,
|
|
ReactEditor,
|
|
type RenderElementProps,
|
|
type RenderLeafProps,
|
|
} from "slate-react";
|
|
import {
|
|
type NoteDocument,
|
|
type NoteParagraph,
|
|
safeNoteLink,
|
|
serializeNoteDocument,
|
|
} from "@/lib/flow-board/topic-notes";
|
|
|
|
export function XmindNotesEditor({
|
|
value,
|
|
onChange,
|
|
}: {
|
|
value: NoteDocument;
|
|
onChange: (value: NoteDocument) => void;
|
|
}) {
|
|
const [editor] = useState(() => withHistory(withReact(createEditor())));
|
|
const sourceRef = useRef(value);
|
|
sourceRef.current = value;
|
|
const onChangeRef = useRef(onChange);
|
|
onChangeRef.current = onChange;
|
|
const [revision, setRevision] = useState(0);
|
|
const [linkOpen, setLinkOpen] = useState(false),
|
|
[link, setLink] = useState("");
|
|
const [linkError, setLinkError] = useState("");
|
|
const savedSelection = useRef(editor.selection);
|
|
useEffect(() => {
|
|
if (
|
|
JSON.stringify(serializeNoteDocument(editor.children as NoteDocument)) ===
|
|
JSON.stringify(serializeNoteDocument(value))
|
|
)
|
|
return;
|
|
editor.children = value as Descendant[];
|
|
editor.selection = null;
|
|
editor.history = { undos: [], redos: [] };
|
|
editor.onChange();
|
|
}, [editor, value]);
|
|
const marks = Editor.marks(editor) as Record<string, unknown> | null;
|
|
// Toolbar presses can arrive before Slate's throttled selection listener.
|
|
const syncDOMSelection = () => {
|
|
const selection = window.getSelection();
|
|
if (
|
|
!selection?.anchorNode ||
|
|
!ReactEditor.hasDOMNode(editor, selection.anchorNode)
|
|
)
|
|
return;
|
|
const range = ReactEditor.toSlateRange(editor, selection, {
|
|
exactMatch: false,
|
|
suppressThrow: true,
|
|
});
|
|
if (range) Transforms.select(editor, range);
|
|
};
|
|
const toggleMark = (
|
|
mark: "bold" | "italic" | "underline" | "strikethrough"
|
|
) => {
|
|
syncDOMSelection();
|
|
const currentMarks = Editor.marks(editor) as Record<string, unknown> | null;
|
|
if (currentMarks?.[mark]) Editor.removeMark(editor, mark);
|
|
else Editor.addMark(editor, mark, true);
|
|
setRevision((v) => v + 1);
|
|
};
|
|
const toggleList = (list: "bullet" | "number") => {
|
|
syncDOMSelection();
|
|
const match = Array.from(
|
|
Editor.nodes(editor, { match: (n) => Element.isElement(n) })
|
|
)[0];
|
|
Transforms.setNodes(
|
|
editor,
|
|
{
|
|
list: (match?.[0] as NoteParagraph)?.list === list ? undefined : list,
|
|
} as Partial<Descendant>,
|
|
{ match: (n) => Element.isElement(n) }
|
|
);
|
|
};
|
|
const applyLink = () => {
|
|
const href = link.trim() ? safeNoteLink(link.trim()) : "";
|
|
if (href === null) {
|
|
setLinkError("请输入完整的网页或邮件链接。");
|
|
return;
|
|
}
|
|
if (savedSelection.current)
|
|
Transforms.select(editor, savedSelection.current);
|
|
if (href) Editor.addMark(editor, "href", href);
|
|
else Editor.removeMark(editor, "href");
|
|
setLinkOpen(false);
|
|
editor.onChange();
|
|
};
|
|
const leaf = ({ attributes, children, leaf }: RenderLeafProps) => {
|
|
const t = leaf as {
|
|
bold?: boolean;
|
|
italic?: boolean;
|
|
underline?: boolean;
|
|
strikethrough?: boolean;
|
|
href?: string;
|
|
};
|
|
const href = safeNoteLink(t.href || "");
|
|
return (
|
|
<span
|
|
{...attributes}
|
|
style={{
|
|
fontWeight: t.bold ? 700 : undefined,
|
|
fontStyle: t.italic ? "italic" : undefined,
|
|
textDecoration:
|
|
[
|
|
t.underline ? "underline" : "",
|
|
t.strikethrough ? "line-through" : "",
|
|
]
|
|
.filter(Boolean)
|
|
.join(" ") || undefined,
|
|
}}
|
|
>
|
|
{href ? (
|
|
<a
|
|
href={href}
|
|
title={href}
|
|
rel="noopener noreferrer"
|
|
onClick={(e) => {
|
|
if (!e.metaKey && !e.ctrlKey) e.preventDefault();
|
|
}}
|
|
target="_blank"
|
|
>
|
|
{children}
|
|
</a>
|
|
) : (
|
|
children
|
|
)}
|
|
</span>
|
|
);
|
|
};
|
|
const element = ({ attributes, children, element }: RenderElementProps) => {
|
|
const p = element as NoteParagraph;
|
|
return (
|
|
<p
|
|
{...attributes}
|
|
data-note-list={p.list}
|
|
style={{ marginLeft: Math.max(0, Math.min(120, p.indent || 0)) }}
|
|
>
|
|
{children}
|
|
</p>
|
|
);
|
|
};
|
|
return (
|
|
<div className="xmind-notes-editor" data-editor-revision={revision}>
|
|
<Slate
|
|
editor={editor}
|
|
initialValue={value as Descendant[]}
|
|
onChange={(next) => {
|
|
setRevision((v) => v + 1);
|
|
if (editor.operations.every((o) => o.type === "set_selection"))
|
|
return;
|
|
const doc = next as NoteDocument;
|
|
if (
|
|
JSON.stringify(serializeNoteDocument(doc)) !==
|
|
JSON.stringify(serializeNoteDocument(sourceRef.current))
|
|
)
|
|
onChangeRef.current(doc);
|
|
}}
|
|
>
|
|
<div
|
|
className="xmind-notes-toolbar"
|
|
role="toolbar"
|
|
aria-label="笔记格式"
|
|
>
|
|
{(
|
|
[
|
|
["bold", "加粗", Bold],
|
|
["italic", "斜体", Italic],
|
|
["underline", "下划线", Underline],
|
|
["strikethrough", "删除线", Strikethrough],
|
|
] as const
|
|
).map(([mark, label, Icon]) => (
|
|
<button
|
|
key={mark}
|
|
type="button"
|
|
aria-label={`笔记${label}`}
|
|
aria-pressed={Boolean(marks?.[mark])}
|
|
onMouseDown={(e) => {
|
|
e.preventDefault();
|
|
toggleMark(mark);
|
|
}}
|
|
>
|
|
<Icon size={17} />
|
|
</button>
|
|
))}
|
|
<button
|
|
type="button"
|
|
aria-label="笔记无序列表"
|
|
onMouseDown={(e) => {
|
|
e.preventDefault();
|
|
toggleList("bullet");
|
|
}}
|
|
>
|
|
<List size={17} />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
aria-label="笔记有序列表"
|
|
onMouseDown={(e) => {
|
|
e.preventDefault();
|
|
toggleList("number");
|
|
}}
|
|
>
|
|
<ListOrdered size={17} />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
aria-label="笔记链接"
|
|
onMouseDown={(e) => {
|
|
e.preventDefault();
|
|
syncDOMSelection();
|
|
savedSelection.current = editor.selection;
|
|
setLink(String(marks?.href || ""));
|
|
setLinkError("");
|
|
setLinkOpen(!linkOpen);
|
|
}}
|
|
>
|
|
<Link2 size={17} />
|
|
</button>
|
|
</div>
|
|
{linkOpen ? (
|
|
<div className="xmind-note-link-form">
|
|
<input
|
|
aria-label="笔记链接地址"
|
|
placeholder="https://…"
|
|
value={link}
|
|
onChange={(e) => setLink(e.target.value)}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter") {
|
|
e.preventDefault();
|
|
applyLink();
|
|
}
|
|
}}
|
|
/>
|
|
<button type="button" onClick={applyLink}>
|
|
确定
|
|
</button>
|
|
<button
|
|
type="button"
|
|
aria-label="取消笔记链接"
|
|
onClick={() => setLinkOpen(false)}
|
|
>
|
|
<X size={14} />
|
|
</button>
|
|
{linkError ? <p role="alert">{linkError}</p> : null}
|
|
</div>
|
|
) : null}
|
|
<Editable
|
|
aria-label="主题备注"
|
|
className="xmind-note-content"
|
|
placeholder="为这个主题补充说明…"
|
|
renderLeaf={leaf}
|
|
renderElement={element}
|
|
onKeyDown={(e) => {
|
|
if (
|
|
(e.metaKey || e.ctrlKey) &&
|
|
["b", "i", "u"].includes(e.key.toLowerCase())
|
|
) {
|
|
e.preventDefault();
|
|
toggleMark(
|
|
(
|
|
{ b: "bold", i: "italic", u: "underline" } as Record<
|
|
string,
|
|
"bold" | "italic" | "underline"
|
|
>
|
|
)[e.key.toLowerCase()]
|
|
);
|
|
}
|
|
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "z")
|
|
e.stopPropagation();
|
|
if (e.key === "Tab") {
|
|
e.preventDefault();
|
|
const match = Array.from(
|
|
Editor.nodes(editor, { match: (n) => Element.isElement(n) })
|
|
)[0];
|
|
const indent = (match?.[0] as NoteParagraph)?.indent || 0;
|
|
Transforms.setNodes(
|
|
editor,
|
|
{
|
|
indent: Math.max(
|
|
0,
|
|
Math.min(120, indent + (e.shiftKey ? -20 : 20))
|
|
),
|
|
} as Partial<Descendant>,
|
|
{ match: (n) => Element.isElement(n) }
|
|
);
|
|
}
|
|
}}
|
|
/>
|
|
</Slate>
|
|
</div>
|
|
);
|
|
}
|