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.
@ -3,10 +3,5 @@ export default function FlowBoardLayout({
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<link href="/vendor/drawnix.css" rel="stylesheet" />
|
||||
{children}
|
||||
</>
|
||||
);
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
@ -1,5 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import "./xmind-native.css";
|
||||
import { XmindSearchPanel } from "./xmind-search-panel";
|
||||
import { XmindPresentation } from "./xmind-presentation";
|
||||
import { replaceTopicText, type TopicSearchResult } from "@/lib/flow-board/topic-search";
|
||||
|
||||
import { Drawnix, type DrawnixProps } from "@drawnix/drawnix";
|
||||
import {
|
||||
BoardCreationMode,
|
||||
@ -197,7 +202,7 @@ type FlowBoardSelectionStyleState = {
|
||||
|
||||
const FLOW_BOARD_FILES_API = "/api/flow-board/files";
|
||||
const DEFAULT_FLOW_BOARD_CANVAS: FlowBoardCanvasSettings = {
|
||||
backgroundColor: "#f7f8fb",
|
||||
backgroundColor: "#ffffff",
|
||||
gridColor: "#d8dde8",
|
||||
gridEnabled: false,
|
||||
gridSize: 24,
|
||||
@ -928,6 +933,12 @@ export function DrawnixWorkspace({
|
||||
const [isSavingNow, setIsSavingNow] = useState(false);
|
||||
const [isExporting, setIsExporting] = useState(false);
|
||||
const [isOutlineOpen, setIsOutlineOpen] = useState(false);
|
||||
const [isSearchOpen, setIsSearchOpen] = useState(false);
|
||||
const [isZenMode, setIsZenMode] = useState(false);
|
||||
const [isPresenting, setIsPresenting] = useState(false);
|
||||
const [pendingTopicJump, setPendingTopicJump] = useState<TopicSearchResult | null>(null);
|
||||
const searchUndoRef = useRef<{ before: FlowBoardSheetSnapshot[]; after: FlowBoardSheetSnapshot[] }[]>([]);
|
||||
const searchRedoRef = useRef<{ before: FlowBoardSheetSnapshot[]; after: FlowBoardSheetSnapshot[] }[]>([]);
|
||||
const [outlineZoomPercent, setOutlineZoomPercent] = useState(100);
|
||||
const [, setLastSavedAt] = useState<Date | null>(null);
|
||||
const [activeTool, setActiveTool] =
|
||||
@ -966,6 +977,20 @@ export function DrawnixWorkspace({
|
||||
getFlowBoardSelectionStyleState(null, [])
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (event: KeyboardEvent) => {
|
||||
if (isPresenting) return;
|
||||
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "f") {
|
||||
event.preventDefault(); event.stopPropagation(); setIsSearchOpen(true);
|
||||
}
|
||||
if (event.key === "Escape" && (isZenMode || isSearchOpen)) {
|
||||
event.preventDefault(); event.stopPropagation(); setIsZenMode(false); setIsSearchOpen(false);
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown",onKey,true);
|
||||
return () => window.removeEventListener("keydown",onKey,true);
|
||||
}, [isZenMode,isSearchOpen,isPresenting]);
|
||||
|
||||
const displayLogo = useMemo(() => {
|
||||
if (resolvedTheme === "dark" && siteLogoDark) {
|
||||
return siteLogoDark;
|
||||
@ -1063,6 +1088,8 @@ export function DrawnixWorkspace({
|
||||
const handleDrawnixInit = useCallback((board: PlaitBoard) => {
|
||||
boardRef.current = board;
|
||||
withMindAnnotations(board);
|
||||
BoardTransforms.updatePointerType(board, PlaitPointerType.selection);
|
||||
setCreationMode(board, BoardCreationMode.dnd);
|
||||
setRelationshipSourceId(null);
|
||||
setAnnotationId(null);
|
||||
setSelectedTopicId(null);
|
||||
@ -1081,9 +1108,7 @@ export function DrawnixWorkspace({
|
||||
selectedMindElementsRef.current
|
||||
)
|
||||
);
|
||||
setActiveTool(
|
||||
(PlaitBoard.getPointer(board) as FlowBoardToolId) || "selection"
|
||||
);
|
||||
setActiveTool("selection");
|
||||
setZoomPercent(Math.round((board.viewport?.zoom || 1) * 100));
|
||||
}, []);
|
||||
|
||||
@ -1139,6 +1164,8 @@ export function DrawnixWorkspace({
|
||||
const nextSnapshot = normalizeSnapshot(file.snapshot);
|
||||
snapshotRef.current = nextSnapshot;
|
||||
setSnapshot(nextSnapshot);
|
||||
searchUndoRef.current = [];
|
||||
searchRedoRef.current = [];
|
||||
setCurrentFileId(file.id);
|
||||
setCurrentTitle(file.title || "未命名流程白板");
|
||||
setLastSavedAt(
|
||||
@ -1632,12 +1659,25 @@ export function DrawnixWorkspace({
|
||||
text: BasicShapes.text,
|
||||
};
|
||||
BoardTransforms.updatePointerType(board, pointers[tool]);
|
||||
if (tool !== "selection" && tool !== "hand") {
|
||||
setCreationMode(board, BoardCreationMode.drawing);
|
||||
}
|
||||
setCreationMode(board, tool === "selection" || tool === "hand" || tool === "mind"
|
||||
? BoardCreationMode.dnd : BoardCreationMode.drawing);
|
||||
setActiveTool(tool);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const handlePointerShortcut = (event: KeyboardEvent) => {
|
||||
if (isPresenting || isOutlineOpen || event.metaKey || event.ctrlKey || event.altKey ||
|
||||
isFlowBoardEditablePasteTarget(event.target as HTMLElement)) return;
|
||||
const key = event.key.toLowerCase();
|
||||
if (key !== "v" && key !== "h") return;
|
||||
event.preventDefault();
|
||||
event.stopImmediatePropagation();
|
||||
handleToolChange(key === "v" ? "selection" : "hand");
|
||||
};
|
||||
window.addEventListener("keydown", handlePointerShortcut, true);
|
||||
return () => window.removeEventListener("keydown", handlePointerShortcut, true);
|
||||
}, [handleToolChange, isPresenting, isOutlineOpen]);
|
||||
|
||||
const handleInsertChildTopic = useCallback(() => {
|
||||
const board = boardRef.current;
|
||||
const element = getActiveMarkerElements()[0];
|
||||
@ -1879,13 +1919,36 @@ export function DrawnixWorkspace({
|
||||
BoardTransforms.updateThemeColor(board, themeId as ThemeColorMode);
|
||||
}, []);
|
||||
|
||||
const searchFingerprint = (sheets: FlowBoardSheetSnapshot[]) => JSON.stringify(sheets.map(sheet => ({ id: sheet.id, children: sheet.children })));
|
||||
const restoreSearchSheets = useCallback((sheets: FlowBoardSheetSnapshot[]) => {
|
||||
const board = boardRef.current;
|
||||
const current = snapshotRef.current;
|
||||
const active = sheets.find(sheet => sheet.id === current.activeSheetId);
|
||||
if (!board || !active) return;
|
||||
PlaitHistoryBoard.withoutSaving(board, () => {
|
||||
for (const entry of getTopicEntries(board.children)) {
|
||||
const target = getTopicEntries(active.children).find(item => item.element.id === entry.element.id);
|
||||
if (target && JSON.stringify(target.element.data) !== JSON.stringify(entry.element.data)) {
|
||||
Transforms.setNode(board, { data: structuredClone(target.element.data) }, entry.path);
|
||||
}
|
||||
}
|
||||
});
|
||||
updateSnapshot({ sheets: structuredClone(sheets), children: board.children });
|
||||
}, [updateSnapshot]);
|
||||
|
||||
const handleUndo = useCallback(() => {
|
||||
boardRef.current?.undo();
|
||||
}, []);
|
||||
const edit = searchUndoRef.current.at(-1);
|
||||
if (edit && searchFingerprint(snapshotRef.current.sheets || []) === searchFingerprint(edit.after)) {
|
||||
searchUndoRef.current.pop(); searchRedoRef.current.push(edit); restoreSearchSheets(edit.before);
|
||||
} else boardRef.current?.undo();
|
||||
}, [restoreSearchSheets]);
|
||||
|
||||
const handleRedo = useCallback(() => {
|
||||
boardRef.current?.redo();
|
||||
}, []);
|
||||
const edit = searchRedoRef.current.at(-1);
|
||||
if (edit && searchFingerprint(snapshotRef.current.sheets || []) === searchFingerprint(edit.before)) {
|
||||
searchRedoRef.current.pop(); searchUndoRef.current.push(edit); restoreSearchSheets(edit.after);
|
||||
} else boardRef.current?.redo();
|
||||
}, [restoreSearchSheets]);
|
||||
|
||||
const handleZoom = useCallback((delta: number) => {
|
||||
const board = boardRef.current;
|
||||
@ -1954,6 +2017,46 @@ export function DrawnixWorkspace({
|
||||
[updateSnapshot]
|
||||
);
|
||||
|
||||
const handleSearchJump = useCallback((result: TopicSearchResult) => {
|
||||
if (result.sheetId === snapshotRef.current.activeSheetId) handleJumpToTopic(result.id);
|
||||
else {
|
||||
setPendingTopicJump(result);
|
||||
handleSwitchSheet(result.sheetId);
|
||||
}
|
||||
}, [handleJumpToTopic, handleSwitchSheet]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!pendingTopicJump || snapshot.activeSheetId !== pendingTopicJump.sheetId) return;
|
||||
let frame = 0;
|
||||
let attempts = 0;
|
||||
const jump = () => {
|
||||
const entries = getTopicEntries(boardRef.current?.children || []);
|
||||
if (entries.some(entry => entry.element.id === pendingTopicJump.id)) {
|
||||
handleJumpToTopic(pendingTopicJump.id); setPendingTopicJump(null);
|
||||
} else if (++attempts < 30) frame = requestAnimationFrame(jump);
|
||||
};
|
||||
frame = requestAnimationFrame(jump);
|
||||
return () => cancelAnimationFrame(frame);
|
||||
}, [pendingTopicJump, snapshot.activeSheetId, handleJumpToTopic]);
|
||||
|
||||
const handleSearchReplace = useCallback((results: TopicSearchResult[], query: string, replacement: string, caseSensitive: boolean) => {
|
||||
const board = boardRef.current;
|
||||
if (!board || !query || !results.length) return;
|
||||
const current = snapshotRef.current;
|
||||
const before = structuredClone(current.sheets || []);
|
||||
const ids = new Set(results.map(result => `${result.sheetId}:${result.id}`));
|
||||
const after = structuredClone(before);
|
||||
for (const sheet of after) {
|
||||
for (const entry of getTopicEntries(sheet.children)) {
|
||||
if (ids.has(`${sheet.id}:${entry.element.id}`)) entry.element.data = { ...entry.element.data, topic: replaceTopicText(entry.element,query,replacement,caseSensitive) };
|
||||
}
|
||||
}
|
||||
if (searchFingerprint(before) === searchFingerprint(after)) return;
|
||||
searchUndoRef.current.push({ before, after });
|
||||
searchRedoRef.current = [];
|
||||
restoreSearchSheets(after);
|
||||
}, [restoreSearchSheets]);
|
||||
|
||||
const handleAddSheet = useCallback(() => {
|
||||
const current = snapshotRef.current;
|
||||
const sheets = current.sheets || [];
|
||||
@ -2268,6 +2371,9 @@ export function DrawnixWorkspace({
|
||||
await flushPendingSave(false);
|
||||
}
|
||||
|
||||
if (typeof document !== "undefined" && document.fonts) {
|
||||
await Promise.all([document.fonts.load("18px NeverMind"), document.fonts.load("bold 30px NeverMind"), document.fonts.load("500 18px NeverMind"), document.fonts.load("800 30px NeverMind")]);
|
||||
}
|
||||
const title = getMindMapImportTitle(file.name);
|
||||
const imported = await parseMindMapFileToDocument(file, title);
|
||||
const nextSnapshot = normalizeSnapshot({
|
||||
@ -2511,7 +2617,7 @@ export function DrawnixWorkspace({
|
||||
);
|
||||
|
||||
const isFormatPanelVisible =
|
||||
isFormatPanelOpen && !isFilePanelOpen && !isMarkerPanelOpen;
|
||||
!isZenMode && isFormatPanelOpen && !isFilePanelOpen && !isMarkerPanelOpen;
|
||||
const topicEntries = getTopicEntries(snapshot.children);
|
||||
const selectedTopic = topicEntries.find(({ element }) => element.id === selectedTopicId)?.element || null;
|
||||
const selectedAnnotation = snapshot.children.find(
|
||||
@ -2534,9 +2640,25 @@ export function DrawnixWorkspace({
|
||||
|
||||
return (
|
||||
<main
|
||||
className="relative h-svh w-full overflow-hidden bg-[#f7f8fb]"
|
||||
className="xmind-workspace relative h-svh w-full overflow-hidden"
|
||||
style={{ "--xmind-panel-offset": !isZenMode && (isFormatPanelVisible || isFilePanelOpen || isMarkerPanelOpen) ? "calc(var(--xmind-panel-width) + 16px)" : "0px" } as React.CSSProperties}
|
||||
onChangeCapture={handleFlowBoardFileInputChangeCapture}
|
||||
onKeyDownCapture={(event) => {
|
||||
if (isPresenting) return;
|
||||
if (event.key === "Escape") {
|
||||
if (isSearchOpen) { event.preventDefault(); setIsSearchOpen(false); }
|
||||
if (isZenMode) { event.preventDefault(); setIsZenMode(false); }
|
||||
}
|
||||
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "f") {
|
||||
event.preventDefault(); event.stopPropagation(); setIsSearchOpen(true);
|
||||
}
|
||||
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "z" && !isFlowBoardEditablePasteTarget(event.target as HTMLElement)) {
|
||||
event.preventDefault(); event.stopPropagation();
|
||||
if (event.shiftKey) handleRedo(); else handleUndo();
|
||||
}
|
||||
if ((event.metaKey || event.ctrlKey) && event.shiftKey && event.key.toLowerCase() === "n") {
|
||||
event.preventDefault(); setIsTopicPanelOpen(true); setIsFormatPanelOpen(true); setTopicDetailSection("notes");
|
||||
}
|
||||
if (event.key === "Escape" && relationshipSourceId) {
|
||||
event.preventDefault();
|
||||
setRelationshipSourceId(null);
|
||||
@ -2622,7 +2744,11 @@ export function DrawnixWorkspace({
|
||||
type="file"
|
||||
/>
|
||||
|
||||
<XmindEditorTopBar
|
||||
{!isZenMode ? <XmindEditorTopBar
|
||||
onSearch={() => setIsSearchOpen(true)}
|
||||
onToggleZen={() => setIsZenMode(true)}
|
||||
onToggleOutline={() => setIsOutlineOpen(value => !value)}
|
||||
onPresentation={() => setIsPresenting(true)}
|
||||
activeTool={activeTool}
|
||||
canEditMind={markerSelectionState.count > 0}
|
||||
currentTitle={currentTitle}
|
||||
@ -2677,7 +2803,7 @@ export function DrawnixWorkspace({
|
||||
onUndo={handleUndo}
|
||||
siteLogo={displayLogo}
|
||||
siteName={siteName}
|
||||
/>
|
||||
/> : <button type="button" aria-label="退出 ZEN 模式" className="xmind-zen-exit" data-flow-board-shell-control="true" onClick={() => setIsZenMode(false)}><X size={14} />退出 ZEN · Esc</button>}
|
||||
|
||||
{isFormatPanelVisible && isTopicPanelOpen ? (
|
||||
<XmindTopicPanel topic={selectedTopic} topics={topicEntries} section={topicDetailSection}
|
||||
@ -2745,9 +2871,9 @@ export function DrawnixWorkspace({
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{isFilePanelOpen ? (
|
||||
{!isZenMode && isFilePanelOpen ? (
|
||||
<aside
|
||||
className="absolute right-0 top-[94px] bottom-9 z-30 flex w-full max-w-full flex-col overflow-hidden border-[#dfe2e7] border-l bg-white sm:w-[304px]"
|
||||
className="xmind-aux-panel absolute right-0 top-[94px] bottom-9 z-30 flex w-full max-w-full flex-col overflow-hidden border-[#dfe2e7] border-l bg-white sm:w-[304px]"
|
||||
data-flow-board-shell-control="true"
|
||||
>
|
||||
<div className="flex items-center justify-between border-[#eceef1] border-b px-4 py-3">
|
||||
@ -2846,9 +2972,9 @@ export function DrawnixWorkspace({
|
||||
</aside>
|
||||
) : null}
|
||||
|
||||
{isMarkerPanelOpen ? (
|
||||
{!isZenMode && isMarkerPanelOpen ? (
|
||||
<aside
|
||||
className="absolute right-0 top-[94px] bottom-9 z-30 flex w-full max-w-full flex-col overflow-hidden border-[#dfe2e7] border-l bg-white sm:w-[304px]"
|
||||
className="xmind-aux-panel absolute right-0 top-[94px] bottom-9 z-30 flex w-full max-w-full flex-col overflow-hidden border-[#dfe2e7] border-l bg-white sm:w-[304px]"
|
||||
data-flow-board-shell-control="true"
|
||||
onMouseDownCapture={(event) => {
|
||||
event.stopPropagation();
|
||||
@ -2973,9 +3099,7 @@ export function DrawnixWorkspace({
|
||||
<div
|
||||
inert={isOutlineOpen}
|
||||
aria-hidden={isOutlineOpen}
|
||||
className={`flow-board-canvas-shell absolute bottom-9 left-0 top-[94px] transition-[right] ${
|
||||
isFormatPanelVisible ? "right-0 sm:right-[304px]" : "right-0"
|
||||
}`}
|
||||
className="flow-board-canvas-shell absolute left-0"
|
||||
style={{
|
||||
backgroundColor: snapshot.canvas.backgroundColor,
|
||||
backgroundImage: snapshot.canvas.gridEnabled
|
||||
@ -2991,10 +3115,11 @@ export function DrawnixWorkspace({
|
||||
<button className="text-[#717887] hover:text-[#17191d]" onClick={() => setRelationshipSourceId(null)} type="button">取消 · Esc</button>
|
||||
</div>
|
||||
) : null}
|
||||
{!snapshot.children.length && !isOutlineOpen ? <button type="button" aria-label="创建中心主题" className="xmind-empty-canvas" data-flow-board-shell-control="true" onClick={() => { const id = handleOutlineCommand(null,"child"); if (id) requestAnimationFrame(handleFitViewport); }}>中心主题</button> : null}
|
||||
<Drawnix
|
||||
plugins={FLOW_BOARD_PLUGINS}
|
||||
afterInit={handleDrawnixInit}
|
||||
className="h-full w-full"
|
||||
className="xmind-canvas h-full w-full"
|
||||
key={snapshot.activeSheetId || "sheet-1"}
|
||||
onChange={handleDrawnixChange as NonNullable<DrawnixProps["onChange"]>}
|
||||
onSelectionChange={handleDrawnixSelectionChange}
|
||||
@ -3008,12 +3133,15 @@ export function DrawnixWorkspace({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isOutlineOpen ? <div className={`absolute top-[94px] bottom-9 left-0 z-20 ${isFormatPanelVisible ? "right-0 sm:right-[304px]" : "right-0"}`}>
|
||||
{isOutlineOpen ? <div className="xmind-outline-shell">
|
||||
<XmindOutline key={snapshot.activeSheetId} elements={snapshot.children} selectedId={selectedTopicId} zoomPercent={outlineZoomPercent}
|
||||
onSelect={handleJumpToTopic} onRename={handleOutlineRename} onToggle={handleOutlineToggle}
|
||||
onCommand={handleOutlineCommand} onUndo={handleUndo} onRedo={handleRedo} />
|
||||
</div> : null}
|
||||
<XmindSheetBar
|
||||
{!isZenMode ? <XmindSheetBar
|
||||
topicCount={topicEntries.length}
|
||||
selectedCount={markerSelectionState.count}
|
||||
onZoomTo={(percent) => { if (isOutlineOpen) setOutlineZoomPercent(percent); else { const board = boardRef.current; if (board) { BoardTransforms.updateZoom(board,percent / 100); setZoomPercent(percent); } } }}
|
||||
outlineOpen={isOutlineOpen}
|
||||
onToggleOutline={() => setIsOutlineOpen(value => !value)}
|
||||
activeSheetId={snapshot.activeSheetId || "sheet-1"}
|
||||
@ -3028,7 +3156,9 @@ export function DrawnixWorkspace({
|
||||
onZoomOut={() => isOutlineOpen ? setOutlineZoomPercent(value => Math.max(50, value - 10)) : handleZoom(-0.1)}
|
||||
sheets={sheetTabs}
|
||||
zoomPercent={isOutlineOpen ? outlineZoomPercent : zoomPercent}
|
||||
/>
|
||||
/> : null}
|
||||
{isSearchOpen && !isZenMode ? <XmindSearchPanel sheets={snapshot.sheets || []} activeSheetId={snapshot.activeSheetId || "sheet-1"} onClose={() => setIsSearchOpen(false)} onJump={handleSearchJump} onReplace={handleSearchReplace} /> : null}
|
||||
{isPresenting ? <XmindPresentation elements={snapshot.children} title={currentTitle} onClose={() => setIsPresenting(false)} /> : null}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import "@/public/vendor/drawnix.css";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
|
||||
@ -16,6 +16,8 @@ import {
|
||||
specialSubtreeBounds,
|
||||
} from "@/lib/flow-board/mind-special-layout";
|
||||
|
||||
import { measureXmindTopics } from "@/lib/flow-board/xmind-typography";
|
||||
|
||||
const ns = "http://www.w3.org/2000/svg";
|
||||
type TableCell = {
|
||||
x: number;
|
||||
@ -457,6 +459,7 @@ function specialLink(component: MindNodeComponent) {
|
||||
}
|
||||
class SpecialMindRoot extends PlaitMindComponent {
|
||||
override updateMindLayout(element = this.element) {
|
||||
measureXmindTopics(this.board, element);
|
||||
super.updateMindLayout(element);
|
||||
if (isSpecialMindLayout(element.specialLayout))
|
||||
arrangeSpecialMind(this.root, element.specialLayout);
|
||||
|
||||
@ -20,7 +20,7 @@ export function XmindAnnotationPanel({
|
||||
.join("");
|
||||
return (
|
||||
<aside
|
||||
className="absolute right-0 top-[94px] bottom-9 z-30 w-full max-w-[304px] overflow-y-auto border-l border-[#dfe2e7] bg-white text-[#343b48]"
|
||||
className="xmind-aux-panel absolute right-0 top-[94px] bottom-9 z-30 w-full max-w-[304px] overflow-y-auto border-l border-[#dfe2e7] bg-white text-[#343b48]"
|
||||
data-flow-board-shell-control="true"
|
||||
>
|
||||
<div className="flex h-12 items-center justify-between border-b border-[#eceef1] px-4">
|
||||
|
||||
70
components/flow-board/xmind-command-menu.tsx
Normal file
@ -0,0 +1,70 @@
|
||||
"use client";
|
||||
|
||||
import { type ReactNode, useEffect, useRef, useState } from "react";
|
||||
import { ChevronDown, type LucideIcon } from "lucide-react";
|
||||
|
||||
export type XmindCommand = {
|
||||
label: string;
|
||||
icon?: LucideIcon;
|
||||
shortcut?: string;
|
||||
disabled?: boolean;
|
||||
checked?: boolean;
|
||||
separator?: boolean;
|
||||
run: () => void;
|
||||
};
|
||||
|
||||
export function XmindCommandMenu({ label, children, commands, className = "", align = "left" }: {
|
||||
label: string;
|
||||
children: ReactNode;
|
||||
commands: XmindCommand[];
|
||||
className?: string;
|
||||
align?: "left" | "right";
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const host = useRef<HTMLDivElement>(null);
|
||||
const trigger = useRef<HTMLButtonElement>(null);
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const dismiss = (event: PointerEvent) => {
|
||||
if (!host.current?.contains(event.target as Node)) setOpen(false);
|
||||
};
|
||||
document.addEventListener("pointerdown", dismiss);
|
||||
return () => document.removeEventListener("pointerdown", dismiss);
|
||||
}, [open]);
|
||||
return (
|
||||
<div className={`xmind-menu-host ${className}`} ref={host} data-flow-board-shell-control="true">
|
||||
<button type="button" ref={trigger} aria-label={label} aria-haspopup="menu" aria-expanded={open}
|
||||
className="xmind-menu-trigger" onClick={() => setOpen(value => !value)}
|
||||
onKeyDown={event => {
|
||||
if (event.key === "ArrowDown") {
|
||||
event.preventDefault();
|
||||
setOpen(true);
|
||||
requestAnimationFrame(() => host.current?.querySelector<HTMLButtonElement>("[role=menuitem]:not(:disabled)")?.focus());
|
||||
}
|
||||
}}>
|
||||
{children}<ChevronDown size={12} />
|
||||
</button>
|
||||
{open ? <div role="menu" aria-label={label} className={`xmind-command-menu ${align === "right" ? "xmind-menu-right" : ""}`}
|
||||
onKeyDown={event => {
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault(); event.stopPropagation(); setOpen(false); trigger.current?.focus();
|
||||
}
|
||||
const items = Array.from(host.current?.querySelectorAll<HTMLButtonElement>("[role=menuitem]:not(:disabled)") || []);
|
||||
const index = items.indexOf(document.activeElement as HTMLButtonElement);
|
||||
if (["ArrowDown", "ArrowUp", "Home", "End"].includes(event.key)) {
|
||||
event.preventDefault();
|
||||
const next = event.key === "Home" ? 0 : event.key === "End" ? items.length - 1 : (index + (event.key === "ArrowDown" ? 1 : -1) + items.length) % items.length;
|
||||
items[next]?.focus();
|
||||
}
|
||||
}}>
|
||||
{commands.map(({ label: title, icon: Icon, shortcut, disabled, checked, separator, run }) => <div key={title}>
|
||||
{separator ? <hr className="xmind-menu-separator" /> : null}
|
||||
<button type="button" role="menuitem" disabled={disabled} className="xmind-command" onClick={() => { setOpen(false); run(); }}>
|
||||
{Icon ? <Icon size={16} /> : <span className="xmind-command-check">{checked ? "✓" : ""}</span>}
|
||||
<span>{title}</span>{shortcut ? <kbd>{shortcut}</kbd> : null}
|
||||
</button>
|
||||
</div>)}
|
||||
</div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
161
components/flow-board/xmind-native.css
Normal file
@ -0,0 +1,161 @@
|
||||
/* Xmind editing controls use the project's home/canvas typography and density. */
|
||||
.xmind-workspace {
|
||||
--xmind-panel-width: 240px;
|
||||
--xmind-ink: #24282b;
|
||||
--xmind-muted: #888b8e;
|
||||
--xmind-panel: #f3f4f5;
|
||||
--xmind-border: #e3e5e8;
|
||||
color: var(--xmind-ink);
|
||||
font-family: Arial, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
background: white;
|
||||
font-size: 13px;
|
||||
}
|
||||
.xmind-workspace button { cursor: pointer; }
|
||||
.xmind-workspace :is(button,input,textarea,select) { font-family: inherit; }
|
||||
.xmind-workspace button:disabled { cursor: default; opacity: .3; }
|
||||
.xmind-workspace button:focus-visible { outline: 2px solid #35b5ff; outline-offset: 2px; }
|
||||
.xmind-topbar { position:absolute; top:0; left:0; right:var(--xmind-panel-offset,0px); height:56px; z-index:50; display:flex; align-items:flex-start; padding:12px 12px 0; gap:8px; pointer-events:none; }
|
||||
.xmind-topbar > * { pointer-events:auto; }
|
||||
.xmind-document-heading { display:flex; align-items:center; gap:8px; min-width:200px; }
|
||||
.xmind-home-button { display:grid; place-items:center; width:32px; height:32px; border:1px solid var(--xmind-border); border-radius:8px; background:rgba(255,255,255,.95); box-shadow:0 2px 8px #00000006; flex-shrink:0; }
|
||||
.xmind-document-name { height:32px; display:flex; flex-direction:column; justify-content:center; gap:0; min-width:100px; }
|
||||
.xmind-document-name input { background:transparent; border:1px solid transparent; border-radius:5px; font-size:13px; font-weight:600; width:140px; padding:0 3px; outline:none; }
|
||||
.xmind-document-name input:focus { border-color:#35b5ff; }
|
||||
.xmind-document-name span { font-size:11px; padding-left:4px; }
|
||||
.xmind-document-menu { margin-left:-8px; opacity:0; transition:opacity .15s; }
|
||||
.xmind-document-heading:hover .xmind-document-menu,.xmind-document-heading:focus-within .xmind-document-menu { opacity:1; }
|
||||
.xmind-pointer-toolbar { position:absolute; top:64px; left:12px; display:flex; flex-direction:column; gap:2px; padding:3px; border:1px solid var(--xmind-border); border-radius:10px; background:#fffffff5; box-shadow:0 2px 8px #00000008; }
|
||||
.xmind-pointer-toolbar .xmind-icon-button { width:28px; height:28px; }
|
||||
.xmind-primary-tools { display:flex; align-items:center; gap:8px; margin:auto; margin-top:0; }
|
||||
.xmind-toolbar-pill { display:flex; align-items:center; padding:2px; border:1px solid var(--xmind-border); border-radius:8px; background:rgba(255,255,255,.95); box-shadow:0 2px 8px #00000006; backdrop-filter:blur(24px); }
|
||||
.xmind-icon-button { width:30px; height:28px; display:grid; place-items:center; border-radius:6px; color:var(--xmind-ink); background:transparent; flex-shrink:0; }
|
||||
.xmind-icon-button > svg { width:18px; height:18px; }
|
||||
.xmind-icon-button:hover:not(:disabled),.xmind-home-button:hover,.xmind-menu-trigger:hover { background:#00000009; }
|
||||
.xmind-icon-button.is-active { background:#363b3e; color:white; }
|
||||
.xmind-utility-tools { display:flex; align-items:center; gap:8px; }
|
||||
.xmind-menu-host { position:relative; }
|
||||
.xmind-menu-trigger { display:flex; align-items:center; justify-content:center; gap:4px; min-height:28px; padding:2px 5px; border-radius:24px; }
|
||||
.xmind-document-menu .xmind-menu-trigger > svg:last-child,.xmind-sheet-actions .xmind-menu-trigger > svg:last-child { display:none; }
|
||||
.xmind-command-menu { position:absolute; top:calc(100% + 10px); left:0; min-width:220px; max-height:min(70vh,540px); overflow-y:auto; padding:5px; border:1px solid #dedfe2; border-radius:10px; background:#fcfcfcf5; backdrop-filter:blur(24px); box-shadow:0 12px 32px #00000020,0 2px 6px #0000000a; z-index:70; }
|
||||
.xmind-menu-right { left:auto; right:0; }
|
||||
.xmind-command { width:100%; display:flex; align-items:center; gap:10px; border-radius:5px; padding:7px 9px; text-align:left; min-height:32px; color:#34393c; font-size:13px; }
|
||||
.xmind-command:hover:not(:disabled),.xmind-command:focus-visible { background:#35a6fc; color:white; outline:none; }
|
||||
.xmind-command kbd { font:inherit; opacity:.5; margin-left:auto; font-size:11px; }
|
||||
.xmind-command-check { display:block; width:16px; }
|
||||
.xmind-menu-separator { height:1px; background:#e1e2e4; margin:4px 6px; }
|
||||
.xmind-format-panel { position:absolute; top:8px; right:8px; bottom:8px; width:var(--xmind-panel-width); border-radius:12px; background:var(--xmind-panel); border:1px solid #e5e7e9; box-shadow:-5px 0 20px #00000005; display:flex; flex-direction:column; z-index:35; overflow:hidden; }
|
||||
.xmind-panel-heading { display:flex; justify-content:space-between; align-items:center; min-height:36px; padding:0 12px; font-weight:600; }
|
||||
.xmind-panel-heading button { border-radius:6px; padding:4px; color:#7a7e83; }
|
||||
.xmind-panel-heading button:hover { background:#e2e4e6; }
|
||||
.xmind-panel-tabs { display:flex; margin:4px 10px 10px; padding:3px; height:28px; border-radius:8px; background:#e7e8eb; flex-shrink:0; }
|
||||
.xmind-panel-tabs button { flex:1; border-radius:6px; font-size:12px; color:#6d7276; }
|
||||
.xmind-panel-tabs button[aria-selected=true] { background:#ffffffd9; color:#23282b; font-weight:600; }
|
||||
.xmind-panel-content { overflow-y:auto; min-height:0; padding:0 12px 12px; scrollbar-width:thin; scrollbar-color:#b5b5b5 transparent; }
|
||||
.xmind-topic-preview { min-height:48px; border:1px solid var(--xmind-border); border-radius:8px; background:#fff; display:flex; align-items:center; gap:8px; padding:8px; margin-bottom:12px; }
|
||||
.xmind-topic-preview > span { flex:1; text-align:center; font-size:14px; padding:5px; }
|
||||
.xmind-topic-preview svg { color:#5c6267; flex-shrink:0; }
|
||||
.xmind-format-section { padding:12px 0; border-top:1px solid var(--xmind-border); }
|
||||
.xmind-format-section h3 { display:flex; align-items:center; gap:5px; margin:0 0 10px; font-size:12px; font-weight:600; }
|
||||
.xmind-setting-row { display:flex; align-items:center; justify-content:space-between; gap:8px; margin:8px 0; font-size:12px; }
|
||||
.xmind-setting-row h3 { margin:0; }
|
||||
.xmind-setting-row > span { flex-shrink:0; }
|
||||
.xmind-setting-row select { height:25px; background:#fff; border:1px solid var(--xmind-border); border-radius:8px; min-width:74px; max-width:170px; padding:0 8px; font-size:12px; }
|
||||
.xmind-color-control { height:25px; width:74px; padding:0; border:1px solid var(--xmind-border); border-radius:8px; overflow:hidden; background:white; cursor:pointer; }
|
||||
.xmind-color-control::-webkit-color-swatch-wrapper { padding:0; }
|
||||
.xmind-color-control::-webkit-color-swatch { border:0; border-radius:7px; }
|
||||
.xmind-segmented { display:flex; border:1px solid var(--xmind-border); background:white; border-radius:8px; overflow:hidden; height:27px; }
|
||||
.xmind-segmented button { flex:1; display:grid; place-items:center; margin:2px; border-radius:6px; }
|
||||
.xmind-segmented button[aria-pressed=true] { background:#e4e6e8; }
|
||||
.xmind-segmented button:hover:not(:disabled) { background:#f1f2f3; }
|
||||
.xmind-shape-rectangle { width:18px; height:12px; border:1.5px solid currentColor; border-radius:3px; }
|
||||
.xmind-shape-line { width:24px; border-bottom:1.5px solid currentColor; }
|
||||
.xmind-text-marks { height:28px; margin-top:12px; }
|
||||
.xmind-structure-trigger { display:flex; height:26px; align-items:center; padding:0 8px; background:white; border:1px solid var(--xmind-border); border-radius:8px; }
|
||||
.xmind-structure-trigger svg[data-layout-preview] { width:35px; height:24px; }
|
||||
.xmind-current-structure { padding:4px 10px; margin-top:14px; border:1px solid var(--xmind-border); border-radius:8px; background:#fff; font-size:12px; color:#7e8388; }
|
||||
.xmind-structure-grid { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:6px; margin-top:12px; }
|
||||
.xmind-structure-grid button { display:flex; flex-direction:column; justify-content:center; align-items:center; gap:4px; min-height:64px; border:1px solid #e0e3e7; border-radius:7px; font-size:9px; padding:4px 2px; background:white; }
|
||||
.xmind-structure-grid button[aria-pressed=true] { border-color:#36b4ff; background:#edf8ff; color:#179cdc; }
|
||||
.xmind-background-presets { display:grid; grid-template-columns:repeat(6,1fr); gap:6px; }
|
||||
.xmind-background-presets button { height:28px; border:1px solid #d6d8dc; border-radius:6px; }
|
||||
.xmind-background-presets button[aria-pressed=true] { outline:2px solid #35b5ff; }
|
||||
.xmind-theme-grid { display:grid; grid-template-columns:repeat(3,1fr); gap:8px; }
|
||||
.xmind-theme-grid button { background:white; border:1px solid #e1e3e6; border-radius:8px; font-size:11px; padding:8px; }
|
||||
.xmind-theme-grid button[aria-pressed=true] { border-color:#35b5ff; }
|
||||
.xmind-theme-grid button > span { display:flex; margin-bottom:8px; height:12px; overflow:hidden; border-radius:3px; }
|
||||
.xmind-theme-grid i { flex:1; }
|
||||
.xmind-switch { width:30px; height:18px; border-radius:12px; background:#bfc3c8; position:relative; }
|
||||
.xmind-switch i { position:absolute; top:2px; left:2px; height:14px; width:14px; background:white; border-radius:50%; transition:transform .15s; }
|
||||
.xmind-switch[aria-checked=true] { background:#35b5ff; }
|
||||
.xmind-switch[aria-checked=true] i { transform:translateX(12px); }
|
||||
.xmind-canvas-preview { height:96px; border:1px solid var(--xmind-border); border-radius:8px; margin-top:12px; }
|
||||
.xmind-sheetbar { position:absolute; bottom:10px; left:16px; right:calc(var(--xmind-panel-offset,0px) + 16px); height:30px; z-index:40; display:flex; align-items:center; gap:8px; pointer-events:none; }
|
||||
.xmind-sheetbar > * { pointer-events:auto; }
|
||||
.xmind-sheet-tabs { display:flex; gap:4px; max-width:60%; overflow-x:auto; scrollbar-width:none; }
|
||||
.xmind-sheet-tab-host { flex-shrink:0; }
|
||||
.xmind-sheet-tab-host > button { border-radius:16px; padding:4px 18px; font-size:12px; font-weight:500; background:#ffffffd9; max-width:180px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
|
||||
.xmind-sheet-tab-host > button[aria-pressed=true] { background:#e4e5e6; font-weight:600; }
|
||||
.xmind-sheet-tab-host input { width:130px; height:25px; border-radius:6px; background:white; border:1px solid #35b5ff; padding:0 8px; }
|
||||
.xmind-sheet-context { position:absolute; bottom:36px; min-width:140px; padding:5px; border:1px solid #e2e4e7; border-radius:10px; background:white; box-shadow:0 10px 25px #00000015; }
|
||||
.xmind-sheet-context button { display:block; width:100%; text-align:left; padding:7px 10px; border-radius:6px; }
|
||||
.xmind-sheet-context button:hover { background:#f0f3f5; }
|
||||
.xmind-footer-divider { width:1px; height:13px; background:#dfe2e5; flex-shrink:0; }
|
||||
.xmind-footer-icon { display:grid; place-items:center; width:26px; height:26px; border-radius:6px; }
|
||||
.xmind-footer-icon:hover { background:#f2f3f5; }
|
||||
.xmind-sheet-actions { opacity:0; }
|
||||
.xmind-sheetbar:hover .xmind-sheet-actions,.xmind-sheet-actions:focus-within { opacity:1; }
|
||||
.xmind-sheet-status { margin-left:auto; display:flex; gap:12px; align-items:center; background:#ffffffd9; }
|
||||
.xmind-topic-count { font-size:11px; white-space:nowrap; }
|
||||
.xmind-zoom-menu .xmind-command-menu,.xmind-sheet-actions .xmind-command-menu { top:auto; bottom:calc(100% + 8px); }
|
||||
.xmind-zoom-menu .xmind-menu-trigger { font-size:12px; padding:0; min-width:50px; }
|
||||
.xmind-outline-toggle { font-weight:600; font-size:13px; white-space:nowrap; }
|
||||
.xmind-workspace .flow-board-canvas-shell { top:0; bottom:0; right:var(--xmind-panel-offset,0px); }
|
||||
.xmind-workspace .board-host-svg { background:transparent !important; }
|
||||
.xmind-workspace .board-active-svg { z-index:2; pointer-events:none; }
|
||||
.xmind-workspace .active-host-g path[stroke="#6698FF"] { stroke:#387cff; stroke-width:2px; vector-effect:non-scaling-stroke; }
|
||||
.xmind-workspace .active-host-g path[fill="#6698FF25"] { fill:rgba(56,124,255,.12); }
|
||||
.xmind-outline-shell { position:absolute; inset:56px var(--xmind-panel-offset,0px) 48px 0; z-index:20; }
|
||||
.xmind-workspace .xmind-aux-panel { top:8px; bottom:8px; right:8px; width:var(--xmind-panel-width); max-width:var(--xmind-panel-width); border-radius:12px; border:1px solid var(--xmind-border); background:var(--xmind-panel); }
|
||||
.xmind-zen-exit { position:absolute; top:16px; right:16px; z-index:45; display:flex; gap:8px; align-items:center; padding:9px 14px; border-radius:24px; background:#fffffff0; box-shadow:0 4px 20px #0000000c; font-size:12px; }
|
||||
.xmind-empty-canvas { position:absolute; top:50%; left:50%; transform:translate(-50%,-50%); z-index:20; border:1px solid #333; background:white; border-radius:8px; padding:8px 16px; font-size:16px; font-weight:600; }
|
||||
.xmind-search-panel { position:absolute; top:64px; left:8px; bottom:48px; width:var(--xmind-panel-width); border-radius:12px; background:var(--xmind-panel); border:1px solid var(--xmind-border); z-index:45; display:flex; flex-direction:column; box-shadow:5px 0 20px #00000008; }
|
||||
.xmind-workspace:has(.xmind-search-panel) .xmind-pointer-toolbar { visibility:hidden; }
|
||||
.xmind-search-panel .xmind-panel-content { padding:0 14px 14px; }
|
||||
.xmind-search-box { display:flex; gap:8px; border-radius:8px; border:1px solid var(--xmind-border); background:white; padding:8px; align-items:center; margin:8px 0; }
|
||||
.xmind-search-box input { outline:0; width:100%; background:transparent; font-size:13px; }
|
||||
.xmind-search-options { display:flex; align-items:center; gap:12px; margin:10px 0; font-size:11px; }
|
||||
.xmind-search-panel select { width:100%; height:30px; border-radius:8px; background:white; border:1px solid var(--xmind-border); padding:0 8px; font-size:12px; }
|
||||
.xmind-search-navigation { display:flex; align-items:center; gap:8px; margin:12px 0; font-size:11px; color:#81868b; }
|
||||
.xmind-search-navigation span { flex:1; }
|
||||
.xmind-search-navigation button { padding:4px; border-radius:5px; }
|
||||
.xmind-search-navigation button:hover { background:#e5e7e9; }
|
||||
.xmind-search-result { display:block; width:100%; text-align:left; padding:10px; border-radius:8px; margin:4px 0; background:#ffffffa6; }
|
||||
.xmind-search-result.is-active { outline:1px solid #35b5ff; background:#ecf8ff; }
|
||||
.xmind-search-result > span { display:block; font-size:13px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
||||
.xmind-search-result small { font-size:10px; color:#8a8e91; }
|
||||
.xmind-search-replace-buttons { display:flex; gap:8px; margin:10px 0 16px; }
|
||||
.xmind-search-replace-buttons button { flex:1; border:1px solid var(--xmind-border); border-radius:6px; background:white; padding:5px; font-size:11px; }
|
||||
.xmind-presentation { position:absolute; inset:0; z-index:60; background:#fff; display:flex; flex-direction:column; }
|
||||
.xmind-presentation > header { display:flex; align-items:center; justify-content:space-between; padding:20px 28px; color:#8b9095; font-size:12px; }
|
||||
.xmind-presentation > header button { display:flex; align-items:center; gap:6px; }
|
||||
.xmind-presentation-slide { display:flex; flex:1; flex-direction:column; align-items:center; justify-content:center; padding:60px; overflow:auto; }
|
||||
.xmind-presentation-slide h1 { font-size:clamp(28px,4vw,56px); font-weight:650; text-align:center; }
|
||||
.xmind-presentation-slide ul { display:flex; flex-direction:column; gap:20px; margin-top:40px; font-size:clamp(18px,2vw,28px); }
|
||||
.xmind-presentation-slide li { display:flex; align-items:center; gap:15px; }
|
||||
.xmind-presentation-slide li::before { content:""; width:8px; height:8px; border-radius:50%; background:#ff7160; }
|
||||
.xmind-presentation > footer { display:flex; justify-content:center; align-items:center; gap:20px; padding:20px; font-size:12px; color:#72787e; }
|
||||
.xmind-presentation > footer button { padding:8px; border:1px solid #e6e7e9; border-radius:50%; }
|
||||
@media (max-width:1100px) { .xmind-window-dots { display:none; } .xmind-document-heading { min-width:160px; gap:8px; } .xmind-document-name input { width:100px; } .xmind-utility-tools { gap:8px; } .xmind-primary-tools { gap:8px; } .xmind-topbar { gap:8px; padding-left:12px; } }
|
||||
@media (max-width:850px) { .xmind-document-name { display:none; } .xmind-document-heading { min-width:auto; } .xmind-document-menu { opacity:1; } .xmind-utility-tools > .xmind-toolbar-pill:nth-child(2) { display:none; } .xmind-topbar { justify-content:space-between; } }
|
||||
@media (max-width:640px) { .xmind-workspace { --xmind-panel-offset:0px !important; } .xmind-format-panel,.xmind-workspace .xmind-aux-panel { width:min(var(--xmind-panel-width),calc(100vw - 16px)); } .xmind-topbar { gap:4px; padding:12px 8px; } .xmind-home-button { width:32px; } .xmind-primary-tools { gap:4px; margin:0 auto; } .xmind-icon-button { width:28px; } .xmind-document-menu { display:none; } .xmind-topic-count { display:none; } .xmind-sheetbar { left:10px; right:10px; gap:5px; } .xmind-sheet-status { gap:6px; } .xmind-sheet-tabs { max-width:50%; } }
|
||||
@font-face { font-family:NeverMind; src:url('/fonts/xmind/NeverMind-Regular.ttf') format('truetype'); font-weight:400; font-style:normal; font-display:swap; }
|
||||
@font-face { font-family:NeverMind; src:url('/fonts/xmind/NeverMind-Medium.ttf') format('truetype'); font-weight:500; font-style:normal; font-display:swap; }
|
||||
@font-face { font-family:NeverMind; src:url('/fonts/xmind/NeverMind-Bold.ttf') format('truetype'); font-weight:700; font-style:normal; font-display:swap; }
|
||||
@font-face { font-family:NeverMind; src:url('/fonts/xmind/NeverMind-Extrabold.ttf') format('truetype'); font-weight:800; font-style:normal; font-display:swap; }
|
||||
@layer base {
|
||||
.xmind-workspace .drawnix,.xmind-workspace .drawnix * { --ui-font:Arial,"PingFang SC",sans-serif; font-family:Arial,"PingFang SC",sans-serif !important; }
|
||||
.xmind-workspace .drawnix .plait-text-container,.xmind-workspace .drawnix .plait-text-container * { font-family:NeverMind,Arial,"PingFang SC",sans-serif !important; }
|
||||
}
|
||||
|
||||
.xmind-native-icon { display:block; width:18px; height:18px; background:currentColor; mask-size:contain; mask-position:center; mask-repeat:no-repeat; }
|
||||
28
components/flow-board/xmind-presentation.tsx
Normal file
@ -0,0 +1,28 @@
|
||||
"use client";
|
||||
|
||||
import { ChevronLeft, ChevronRight, X } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import type { PlaitElement } from "@plait/core";
|
||||
import { getTopicEntries, getTopicTitle } from "@/lib/flow-board/topic-details";
|
||||
|
||||
export function XmindPresentation({ elements, title, onClose }: { elements: PlaitElement[]; title: string; onClose: () => void }) {
|
||||
const slides = getTopicEntries(elements).filter(({ element, depth }) => depth === 0 || element.children?.length);
|
||||
const [index,setIndex] = useState(0);
|
||||
const entry = slides[Math.min(index,slides.length - 1)];
|
||||
const next = () => setIndex(value => Math.min(slides.length - 1,value + 1));
|
||||
const previous = () => setIndex(value => Math.max(0,value - 1));
|
||||
useEffect(() => {
|
||||
const onKey = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") { event.preventDefault(); onClose(); }
|
||||
if (["ArrowRight","ArrowDown"," ","PageDown"].includes(event.key)) { event.preventDefault(); next(); }
|
||||
if (["ArrowLeft","ArrowUp","PageUp"].includes(event.key)) { event.preventDefault(); previous(); }
|
||||
};
|
||||
window.addEventListener("keydown",onKey);
|
||||
return () => window.removeEventListener("keydown",onKey);
|
||||
}, [onClose,slides.length]);
|
||||
return <section className="xmind-presentation" aria-label="演示视图" data-flow-board-shell-control="true">
|
||||
<header><span>{title}</span><button type="button" aria-label="退出演示" onClick={onClose}><X size={16} />退出演示</button></header>
|
||||
<div className="xmind-presentation-slide"><h1>{entry?.title || title}</h1><ul>{(entry?.element.children || []).map(child => <li key={child.id}>{getTopicTitle(child)}</li>)}</ul></div>
|
||||
<footer><button type="button" aria-label="上一页" disabled={index === 0} onClick={previous}><ChevronLeft size={18} /></button><span>{Math.min(index + 1,slides.length)} / {slides.length}</span><button type="button" aria-label="下一页" disabled={index >= slides.length - 1} onClick={next}><ChevronRight size={18} /></button></footer>
|
||||
</section>;
|
||||
}
|
||||
45
components/flow-board/xmind-search-panel.tsx
Normal file
@ -0,0 +1,45 @@
|
||||
"use client";
|
||||
|
||||
import { ChevronDown, ChevronUp, Search, X } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { searchTopics, type SearchSheet, type TopicSearchResult } from "@/lib/flow-board/topic-search";
|
||||
|
||||
export function XmindSearchPanel({ sheets, activeSheetId, onJump, onReplace, onClose }: {
|
||||
sheets: SearchSheet[];
|
||||
activeSheetId: string;
|
||||
onJump: (result: TopicSearchResult) => void;
|
||||
onReplace: (results: TopicSearchResult[], query: string, replacement: string, caseSensitive: boolean) => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [query, setQuery] = useState("");
|
||||
const [replacement, setReplacement] = useState("");
|
||||
const [caseSensitive, setCaseSensitive] = useState(false);
|
||||
const [scope, setScope] = useState("current");
|
||||
const [activeIndex, setActiveIndex] = useState(0);
|
||||
const input = useRef<HTMLInputElement>(null);
|
||||
const results = searchTopics(scope === "all" ? sheets : sheets.filter(sheet => sheet.id === activeSheetId),query,caseSensitive);
|
||||
useEffect(() => { input.current?.focus(); }, []);
|
||||
useEffect(() => { setActiveIndex(0); }, [query,caseSensitive,scope]);
|
||||
const navigate = (step: number) => {
|
||||
if (!results.length) return;
|
||||
const next = (Math.min(activeIndex,results.length - 1) + step + results.length) % results.length;
|
||||
setActiveIndex(next); onJump(results[next]);
|
||||
};
|
||||
return <aside className="xmind-search-panel" aria-label="查找与替换" data-flow-board-shell-control="true" onKeyDown={event => {
|
||||
if (event.key === "Escape") { event.preventDefault(); event.stopPropagation(); onClose(); }
|
||||
}}>
|
||||
<div className="xmind-panel-heading"><span>查找与替换</span><button type="button" aria-label="关闭查找" onClick={onClose}><X size={16} /></button></div>
|
||||
<div className="xmind-panel-content">
|
||||
<label className="xmind-search-box"><Search size={15} /><input ref={input} aria-label="查找主题" placeholder="查找主题" value={query} onChange={e => setQuery(e.target.value)} onKeyDown={e => { if (e.key === "Enter") { e.preventDefault(); navigate(e.shiftKey ? -1 : 1); } }} /></label>
|
||||
<div className="xmind-search-options"><label><input type="checkbox" checked={caseSensitive} onChange={e => setCaseSensitive(e.target.checked)} /> 区分大小写</label></div>
|
||||
<select aria-label="查找范围" value={scope} onChange={e => setScope(e.target.value)}><option value="current">当前画布</option><option value="all">所有画布</option></select>
|
||||
<label className="xmind-search-box"><input aria-label="替换为" placeholder="替换为" value={replacement} onChange={e => setReplacement(e.target.value)} /></label>
|
||||
<div className="xmind-search-replace-buttons">
|
||||
<button type="button" disabled={!results.length} onClick={() => onReplace([results[Math.min(activeIndex,results.length - 1)]],query,replacement,caseSensitive)}>替换</button>
|
||||
<button type="button" disabled={!results.length} onClick={() => onReplace(results,query,replacement,caseSensitive)}>全部替换</button>
|
||||
</div>
|
||||
<div className="xmind-search-navigation"><span role="status">{query ? `${results.length} 个匹配主题` : "输入内容开始查找"}</span><button type="button" aria-label="上一个结果" disabled={!results.length} onClick={() => navigate(-1)}><ChevronUp size={16} /></button><button type="button" aria-label="下一个结果" disabled={!results.length} onClick={() => navigate(1)}><ChevronDown size={16} /></button></div>
|
||||
<div aria-label="查找结果">{results.map((result,index) => <button type="button" key={`${result.sheetId}:${result.id}`} aria-label={`定位 ${result.title}`} className={`xmind-search-result ${index === activeIndex ? "is-active" : ""}`} onClick={() => { setActiveIndex(index); onJump(result); }}><span>{result.title}</span><small>{result.sheetTitle} · {result.occurrences} 处匹配</small></button>)}</div>
|
||||
</div>
|
||||
</aside>;
|
||||
}
|
||||
@ -96,7 +96,7 @@ export function XmindTopicPanel({
|
||||
};
|
||||
return (
|
||||
<aside
|
||||
className="absolute right-0 top-[94px] bottom-9 z-30 w-full max-w-[304px] overflow-y-auto border-l border-[#dfe2e7] bg-white text-[#343b48]"
|
||||
className="xmind-aux-panel absolute right-0 top-[94px] bottom-9 z-30 w-full max-w-[304px] overflow-y-auto border-l border-[#dfe2e7] bg-white text-[#343b48]"
|
||||
data-flow-board-shell-control="true"
|
||||
>
|
||||
<div className="flex h-12 items-center justify-between border-b border-[#eceef1] px-4">
|
||||
|
||||
30
docs/XMind-26.04-客户端界面对照.md
Normal file
@ -0,0 +1,30 @@
|
||||
# 本机 Xmind 客户端源码与界面对照
|
||||
|
||||
本轮修改位于用户指定的“旧电脑桌面/第四部分/开发项目/ai-chatbot-main”。本机 `/Applications/Xmind.app` 为 26.04.01337,与此前的 26.05 参考包分别记录。
|
||||
|
||||
- 读取 `Contents/Resources/app.asar`,核对 `renderer/editor-frame.js`、`renderer/editor.js`、Vanakit 样式及浮动工具栏/工作表栏样式。完整第三方代码只放在临时目录。
|
||||
- 浮动工具栏按客户端顶部 16px、组间距 12px、圆角 24px 调整。主题/子主题、联系、概要、外框、插入、导出、大纲、ZEN、演示、标记和格式入口接入现有画布。菜单支持方向键和 Escape。
|
||||
- 格式侧栏包含样式/画布分类及可展开结构选择。底部胶囊工作表支持重命名取消及缩放菜单。图标、NeverMind 字体来源记录在各自 public 子目录。
|
||||
- 修正全局等宽字体覆盖、字体测量与实际渲染不一致引发的标题裁切。导入保留无填充、零边框、分支配色,透明填充随 XMind 导出。
|
||||
- 查找支持折叠后代、当前/全部工作表、跨工作表定位。跨富文本叶替换保持文字标记,替换中的 `$&` 按字面写入;批量替换可撤销/重做。
|
||||
- 30 项数据测试、18 项浏览器测试、类型检查及新增组件/工具的 Biome 检查通过。浏览器测试覆盖安装包 9 个基础样例,并检查主主题字体和文字不裁切。
|
||||
|
||||
此轮完成上述基础能力和界面调整,尚未达到全功能及逐像素一致。格式控件、节点间距、左侧分支顺序、完整 Pitch、全部主题、任务/甘特图、云协作等仍有差距,不能用测试数量认定与原生完全对等。完整验收清单见 [功能对照](./XMind-复刻功能对照.md)。
|
||||
|
||||
## 2026-10-04 实机操作与项目 UI 调整
|
||||
|
||||
- 本机客户端实际打开网页导出的 XMind 文件、显示备注和标签、将主主题字号改为 24pt,并保存;网页再次导入后,字号、两行纯文本备注和附件字节保留。该指定样例通过,不代表所有样式和内容无损兼容。
|
||||
- 实际打开原生查找、标记和甘特窗口。任务编辑及完整演示遇到授权界面;云端服务、所有导出类型、全部布局变体仍未完成逐项实机覆盖。
|
||||
- 修改前的 17 个网页功能组操作中 14 个通过、3 个文字格式组异常。进一步读取可见文字片段,确认选中整个主题修改字号、文字颜色或标记时,格式落入空文字片段,已有标题未更新。该问题仍待修复。
|
||||
- 相同窗口 1480×873、DPR=2、100% 下,原生样例主主题色块约 137×41,网页约 124×37;同侧分支间距约 130 与 63,左侧分支顺序不同。测量的是色块区域,不能视为逐像素一致。
|
||||
- 根据用户最新要求,界面改为项目首页使用的 Arial/PingFang 字体与紧凑控件:取消模拟窗口按钮,工具栏圆角改为 8px,侧栏从 264px 改为 240px,减少预览卡片和分组留白。画布主题字体和实际缩放由文档保留。检查显示页面 CSS zoom=1、transform=none,新建画板 100%。
|
||||
- 增加常驻选择/平移按钮、V/H 快捷键与当前模式反馈,初始化和工作表切换默认选择模式;切换选择/平移时重置创建模式。文字输入框的 V/H 不被快捷键拦截。
|
||||
- 框选两分支、批量删除和撤销、平移后再次框选、按钮/快捷键切换和文本输入均通过浏览器验证;查找侧栏遮挡文件菜单的问题已修正。20 项浏览器用例全部通过(首轮 18 项通过,两个既有测试因新增工具占据原点击坐标而失败,调整为空白坐标后复测通过);TypeScript 检查通过。800px 窗口无页面横向溢出。
|
||||
- 开发预览点击保存后刷新回空白,不是正式账号云端保存的验收结果。详细截图和 46 项能力对照保存在本机 `.local/xmind-audit-2026-10-04/index.html`,未修改用户原始 Xmind 文档。
|
||||
|
||||
## 2026-10-04 框选线可见性修复
|
||||
|
||||
- 原有框选数量测试未覆盖实际显示。实测发现预览页没有应用画板基础样式,活动 SVG 的 position=static,区域顶边位于屏幕 y=1309,因此选中数量变化但框线不可见。
|
||||
- 将画板基础 CSS 移到共享客户端组件中打包加载,替代只在正式路由加载的外部样式链接。修复后活动 SVG 为 absolute,覆盖画布可视区域且 pointer-events=none,不拦截编辑操作。
|
||||
- 框选边线为 2px 蓝色,拖动区域有浅蓝填充;松开后去掉拖动填充,保留被选主题和多选范围描边。
|
||||
- 框选回归新增可视坐标、边框颜色/宽度、填充及松开后高亮检查,覆盖平移后再框选。实际截图保存在 `.local/xmind-audit-2026-10-04/web/22-marquee-drag-after.png`。
|
||||
@ -22,16 +22,16 @@
|
||||
| 备注、外链、主题链接、标签 | 基础 | 已验证纯文本备注、标签和外链编辑、撤销、工作表切换、快照序列化;同画布链接可展开折叠分支定位。待补富文本备注、跨画布链接、画布内容标识、真实云端保存重载 |
|
||||
| 图片、附件、音频、公式 | 部分支持 | 新旧 XMind 内嵌 PNG/JPEG/GIF/WebP 导入;附件新增/替换/下载/移除/撤销。待补 SVG、音频、公式、资源导出和真实样本覆盖 |
|
||||
| 任务、待办、进度、日期、负责人、甘特图 | 待实现 | 任务模型、视图和筛选,不能用普通图标替代 |
|
||||
| 查找替换、过滤、聚焦、导航 | 待实现 | 折叠节点和多画布范围、替换撤销 |
|
||||
| 查找替换、过滤、聚焦、导航 | 查找替换基础 | 已验证折叠后代、当前/全部画布范围、跨工作表定位、替换批量撤销/重做和跨富文本叶替换;过滤、分支聚焦待补 |
|
||||
| 大纲视图 | 基础 | 已验证画布双向改名、新增/删除、缩进/提升、拖动重排、展开折叠、缩放和撤销;待补混合富文本、批量选区、分支聚焦、大纲专用导出和原生视觉对照 |
|
||||
| 浮动工具栏、主题添加按钮 | 待新版对照 | 26.05 安装包有新增编辑体验文案,需按原生行为补齐 |
|
||||
| 编辑器偏好设置 | 待实现 | 默认缩放、默认中日韩字体、一周起始日、快捷输入启动偏好 |
|
||||
| ZEN、演示/Pitch、分步展示 | 待实现 | 交互、快捷键、导出与演示行为 |
|
||||
| ZEN、演示/Pitch、分步展示 | ZEN / 演示基础 | 已有隐藏编辑工具、Escape 恢复和主题演示前后页;完整 Pitch 模板、分步动画、演示导出待补 |
|
||||
| 多工作表新建/切换/重命名/复制/删除 | 基础 | 跨工作表移动/链接、合并 XMind 文件 |
|
||||
| 云文件、自动保存 | 基础 | 离线恢复、版本历史、异常恢复与冲突 |
|
||||
| XMind 新版 JSON / 旧版 XML 导入 | 基础 | 资源、全部样式、结构和扩展内容;真实样本库 |
|
||||
| MD、OPML、FreeMind 导入 | 基础 | 更多格式与大文件性能 |
|
||||
| XMind 导出与往返兼容 | 基础 | 已有多工作表 ZIP 导出与项目内往返测试;原生 XMind 打开和保存回传尚未核验,不能仅自读自写认定兼容 |
|
||||
| XMind 导出与往返兼容 | 基础,指定原生往返样例已核验 | 本机 26.04 实际打开网页导出文件、修改字号并保存回传,指定主题的字号/纯文本备注/附件字节保留;全结构、复杂样式与混合富文本的原生无损往返仍待核验 |
|
||||
| PNG/SVG/PDF、Markdown/OPML、Office 导出 | 待逐项核验 | 全图/选区/单页/多页、透明背景、纸张和清晰度 |
|
||||
| 打印、密码、缓存、文件恢复 | 待实现 | 不得以浏览器存储代替完整文件保护 |
|
||||
| 模板、主题库、手绘风格 | 待实现/部分底层能力 | 内置模板、自定义、导入与复用 |
|
||||
|
||||
58
lib/flow-board/topic-search.ts
Normal file
@ -0,0 +1,58 @@
|
||||
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);
|
||||
}
|
||||
@ -160,6 +160,7 @@ export async function exportXmind(
|
||||
properties[key] = String(value);
|
||||
};
|
||||
copy("svg:fill", element.fill);
|
||||
if (element.fill === "transparent") properties["fill-pattern"] = "none";
|
||||
copy("border-line-color", element.strokeColor);
|
||||
copy("border-line-width", element.strokeWidth);
|
||||
copy("line-color", element.branchColor);
|
||||
|
||||
@ -233,11 +233,13 @@ function convertSheets(sheets: Data[], fallbackTitle: string): ImportedMindMap {
|
||||
const floating: PlaitElement[] = [];
|
||||
const annotations: PlaitElement[] = [];
|
||||
const theme = record(sheet.theme);
|
||||
const palette = text(record(record(theme.map).properties)["multi-line-colors"] || record(record(theme.map).properties)["color-list"]).split(/\s+/).map(color).filter((value): value is string => Boolean(value));
|
||||
const build = (
|
||||
topic: Data,
|
||||
depth: number,
|
||||
root = false,
|
||||
floatingRoot = false
|
||||
floatingRoot = false,
|
||||
inheritedBranchColor = ""
|
||||
): MindElement => {
|
||||
if (++nodeCount > 10_000 || depth > 128)
|
||||
throw new MindMapImportError(
|
||||
@ -280,16 +282,20 @@ function convertSheets(sheets: Data[], fallbackTitle: string): ImportedMindMap {
|
||||
...record(record(topic.style).properties),
|
||||
};
|
||||
const fill = color(properties["svg:fill"]);
|
||||
if (fill) element.fill = fill;
|
||||
if (properties["fill-pattern"] === "none") element.fill = "transparent";
|
||||
else if (fill) element.fill = fill;
|
||||
else if (properties["svg:fill"] === "inherited" && inheritedBranchColor) element.fill = inheritedBranchColor;
|
||||
const stroke = color(properties["border-line-color"]);
|
||||
if (stroke) element.strokeColor = stroke;
|
||||
const branch = color(properties["line-color"]);
|
||||
if (branch) element.branchColor = branch;
|
||||
else if (inheritedBranchColor) element.branchColor = inheritedBranchColor;
|
||||
const borderWidth = Number.parseFloat(
|
||||
String(properties["border-line-width"] || "")
|
||||
);
|
||||
if (Number.isFinite(borderWidth))
|
||||
element.strokeWidth = Math.max(0, Math.min(16, borderWidth));
|
||||
if (borderWidth === 0 || properties["border-line-pattern"] === "none") element.strokeColor = "transparent";
|
||||
const branchWidth = Number.parseFloat(
|
||||
String(properties["line-width"] || "")
|
||||
);
|
||||
@ -311,6 +317,7 @@ function convertSheets(sheets: Data[], fallbackTitle: string): ImportedMindMap {
|
||||
);
|
||||
const leaf = {
|
||||
text: text(topic.title, "未命名主题"),
|
||||
...(properties["fo:color"] === "inherited" ? { color: "#000000" } : {}),
|
||||
...(color(properties["fo:color"])
|
||||
? { color: color(properties["fo:color"]) }
|
||||
: {}),
|
||||
@ -354,8 +361,8 @@ function convertSheets(sheets: Data[], fallbackTitle: string): ImportedMindMap {
|
||||
}
|
||||
if (emojis.length)
|
||||
element.data.emojis = emojis.map((name) => ({ name }));
|
||||
element.children = list(groups.attached).map((child) =>
|
||||
build(child, depth + 1)
|
||||
element.children = list(groups.attached).map((child,index) =>
|
||||
build(child, depth + 1, false, false, depth === 0 && palette.length ? palette[index % palette.length] : inheritedBranchColor)
|
||||
);
|
||||
const attachedCount = element.children.length;
|
||||
if (element.layout === MindLayoutType.standard) {
|
||||
|
||||
23
lib/flow-board/xmind-typography.ts
Normal file
@ -0,0 +1,23 @@
|
||||
import { measureElement, updateElementSizeCache } from "@plait/common";
|
||||
import type { PlaitBoard } from "@plait/core";
|
||||
import { getDefaultFontSizeForMindElement, NodeSpace, type MindElement, type PlaitMindBoard } from "@plait/mind";
|
||||
|
||||
const measured = new WeakMap<object, number>();
|
||||
|
||||
/** Plait measures with a system font by default. Seed its cache with the canvas font. */
|
||||
export function measureXmindTopics(board: PlaitBoard, element: MindElement) {
|
||||
const topic = element.data?.topic;
|
||||
const maxWidth = NodeSpace.getTopicMaxDynamicWidth(board as PlaitMindBoard, element);
|
||||
if (topic && measured.get(topic) !== maxWidth) {
|
||||
const size = measureElement(board, topic, {
|
||||
fontSize: getDefaultFontSizeForMindElement(element),
|
||||
fontFamily: 'NeverMind, Arial, "PingFang SC", sans-serif',
|
||||
}, maxWidth);
|
||||
updateElementSizeCache(board, topic, {
|
||||
width: Math.ceil(size.width) + 2,
|
||||
height: Math.ceil(size.height),
|
||||
});
|
||||
measured.set(topic, maxWidth);
|
||||
}
|
||||
for (const child of element.children || []) measureXmindTopics(board, child);
|
||||
}
|
||||
BIN
public/fonts/xmind/NeverMind-Bold.ttf
Normal file
BIN
public/fonts/xmind/NeverMind-Extrabold.ttf
Normal file
BIN
public/fonts/xmind/NeverMind-Medium.ttf
Normal file
BIN
public/fonts/xmind/NeverMind-Regular.ttf
Normal file
5
public/fonts/xmind/README.md
Normal file
@ -0,0 +1,5 @@
|
||||
# Xmind 画布字体
|
||||
|
||||
来自本机 Xmind 26.04.01337 安装包 `/static/snowbird/resource/fonts/files/` 的 NeverMind 字体,按用户要求读取客户端资源用于本地界面对照。包含 Regular(400)、Medium(500)、Bold(700)、Extrabold(800)。
|
||||
|
||||
流程白板在导入前等待字体加载,并以画布字体计算主题文字尺寸,避免系统字体测量与实际渲染不一致导致标题换行裁切。
|
||||
16
public/icons/xmind/README.md
Normal file
@ -0,0 +1,16 @@
|
||||
# Xmind 客户端工具栏资源
|
||||
|
||||
本轮按用户要求读取本机 `/Applications/Xmind.app`(26.04.01337)的 `Contents/Resources/app.asar`。这些 SVG 对应客户端 `/static/assets/images/toolbar/` 中的同名资源,用于流程白板浮动工具栏。
|
||||
|
||||
| 文件 | SHA-256 |
|
||||
|---|---|
|
||||
| boundary.svg | `a8350515015e5aa0ae7494cd0b77e7d605168445074df12e8dc0c5fd17bfb90a` |
|
||||
| export.svg | `586724f13eff9c6c3ed7814ceb3b1bd521fb1450aa0ec23a5c852a0db31fbd6a` |
|
||||
| insert.svg | `8ea160058134d30f81b9c80a0f35a4633f5cdec5e1840171024d8e34806f9a12` |
|
||||
| pitch.svg | `c92f506ad0d9bb9d1e898f89013d9945455204b18e6ddf35e8fcac14f8428d96` |
|
||||
| relationship.svg | `2b4f8f8f3878608a474a307369d273b4506220bcae7573e9d514dfbe60204001` |
|
||||
| sidebar.svg | `4d7d1d69f952543e1e467f38e1d2186c4c4c6b92a93d7bf5ce8c84d4194a509f` |
|
||||
| subtopic.svg | `575fb049501cb178875011e3ad8afc14d859f1c38cc51af5b84e252710456dd1` |
|
||||
| summary.svg | `1b1744acc95b53aca30c45263701a6b614784759d146bd0ad57bd99aff1c1ccc` |
|
||||
| topic.svg | `011c9c671caa4a936210d408aa0d28b992eb69f42599edba1c2ab099e32ee683` |
|
||||
| zen.svg | `03537495a95775356831ffd43ad3c29c63d9113f577eba70213110049a88c83e` |
|
||||
1
public/icons/xmind/boundary.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" width="20" height="20"><g class="uk-icon-alternate-dark" fill="none"><path d="M13.5 5H15a1.5 1.5 0 0 1 1.5 1.5v3h1.498v1H16.5v3A1.5 1.5 0 0 1 15 15h-1.5v1.5H15a3 3 0 0 0 3-3v-7a3 3 0 0 0-3-3h-1.5V5ZM7.5 15h5v1.5h-5V15ZM12.5 5V3.5h-5V5h5ZM5 15h1.5v1.5H5a3 3 0 0 1-3-3v-3h1.5v3A1.5 1.5 0 0 0 5 15ZM3.5 9.5v-3A1.5 1.5 0 0 1 5 5h1.5V3.5H5a3 3 0 0 0-3 3v3h1.5ZM7.5 9.25a.75.75 0 0 0 0 1.5h5a.75.75 0 0 0 0-1.5h-5Z" fill="#27292A"/></g><g class="uk-icon-alternate-default" fill="none"><path d="M13.5 5H15a1.5 1.5 0 0 1 1.5 1.5v3h1.498v1H16.5v3A1.5 1.5 0 0 1 15 15h-1.5v1.5H15a3 3 0 0 0 3-3v-7a3 3 0 0 0-3-3h-1.5V5ZM7.5 15h5v1.5h-5V15ZM12.5 5V3.5h-5V5h5ZM5 15h1.5v1.5H5a3 3 0 0 1-3-3v-3h1.5v3A1.5 1.5 0 0 0 5 15ZM3.5 9.5v-3A1.5 1.5 0 0 1 5 5h1.5V3.5H5a3 3 0 0 0-3 3v3h1.5ZM7.5 9.25a.75.75 0 0 0 0 1.5h5a.75.75 0 0 0 0-1.5h-5Z" fill="#27292A"/></g></svg>
|
||||
|
After Width: | Height: | Size: 906 B |
16
public/icons/xmind/export.svg
Normal file
@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="30px" height="18px" viewBox="0 0 30 18" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<title>Icons/Toolbar/export</title>
|
||||
<g id="Icons/Toolbar/export" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
||||
<g id="编组" transform="translate(8.500000, 2.000000)" stroke="#000000">
|
||||
<g id="编组-2" transform="translate(13.000000, 8.000000) rotate(-270.000000) translate(-13.000000, -8.000000) translate(9.000000, 3.000000)" stroke-linejoin="round" stroke-width="1.8">
|
||||
<line x1="4" y1="1.59164348e-12" x2="4" y2="10" id="Path-6" transform="translate(4.000000, 5.000000) rotate(-180.000000) translate(-4.000000, -5.000000) "></line>
|
||||
<polyline id="Path-25" transform="translate(3.998923, 1.500556) rotate(-180.000000) translate(-3.998923, -1.500556) " points="7.42514605 0.00111233222 3.99784595 2.99999993 0.572699893 0.00299713025"></polyline>
|
||||
</g>
|
||||
<g id="编组-3" transform="translate(0.000000, -0.000000)">
|
||||
<path d="M10,1 L12,1 C12.5522847,1 13,1.44771525 13,2 L13,12 C13,12.5522847 12.5522847,13 12,13 L3.41421356,13 C3.14899707,13 2.89464316,12.8946432 2.70710678,12.7071068 L-0.707106781,9.29289322 C-0.89464316,9.10535684 -1,8.85100293 -1,8.58578644 L-1,2 C-1,1.44771525 -0.55228475,1 0,1 L4,1 L4,1" id="Path" stroke-width="2" transform="translate(6.000000, 7.000000) rotate(-270.000000) translate(-6.000000, -7.000000) "></path>
|
||||
<path d="M0,5 L4,5 C4.55228475,5 5,4.55228475 5,4 L5,0 L5,0" id="路径" stroke-width="1.8"></path>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
1
public/icons/xmind/insert.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><g class="uk-icon-alternate-dark" fill="none"><path d="M6 12h12M12 6v12" stroke="#27292A" stroke-width="1.6" stroke-linecap="round"/></g><g class="uk-icon-alternate-default" fill="none"><path d="M6 12h12M12 6v12" stroke="#27292A" stroke-width="1.6" stroke-linecap="round"/></g></svg>
|
||||
|
After Width: | Height: | Size: 366 B |
1
public/icons/xmind/pitch.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" width="20" height="20"><g class="uk-icon-alternate-dark" fill="none"><path d="M8.636 7.422a.3.3 0 0 0-.436.267v4.622a.3.3 0 0 0 .436.267l4.539-2.31a.3.3 0 0 0 0-.535L8.636 7.422Z" fill="#27292A"/><path fill-rule="evenodd" clip-rule="evenodd" d="M1 7a3 3 0 0 1 3-3h12a3 3 0 0 1 3 3v6a3 3 0 0 1-3 3H4a3 3 0 0 1-3-3V7Zm3-1.5h12A1.5 1.5 0 0 1 17.5 7v6a1.5 1.5 0 0 1-1.5 1.5H4A1.5 1.5 0 0 1 2.5 13V7A1.5 1.5 0 0 1 4 5.5Z" fill="#27292A"/></g><g class="uk-icon-alternate-default" fill="none"><path d="M8.636 7.422a.3.3 0 0 0-.436.267v4.622a.3.3 0 0 0 .436.267l4.539-2.31a.3.3 0 0 0 0-.535L8.636 7.422Z" fill="#27292A"/><path fill-rule="evenodd" clip-rule="evenodd" d="M1 7a3 3 0 0 1 3-3h12a3 3 0 0 1 3 3v6a3 3 0 0 1-3 3H4a3 3 0 0 1-3-3V7Zm3-1.5h12A1.5 1.5 0 0 1 17.5 7v6a1.5 1.5 0 0 1-1.5 1.5H4A1.5 1.5 0 0 1 2.5 13V7A1.5 1.5 0 0 1 4 5.5Z" fill="#27292A"/></g></svg>
|
||||
|
After Width: | Height: | Size: 920 B |
1
public/icons/xmind/relationship.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" width="20" height="20"><g class="uk-icon-alternate-dark" fill="none"><path d="m16.611 10.092 1.328-.457a.726.726 0 0 1 .928.906l-1.18 3.711a.725.725 0 0 1-1.135.355l-2.96-2.288a.725.725 0 0 1 .208-1.26l1.188-.409C13.75 8.38 11.663 6.533 8.78 6.842c-2.234.38-3.124 1.574-4.268 3.153a1.842 1.842 0 1 1-1.507-.817l.131-.183C4.294 7.393 5.55 5.657 8.523 5.162l.023-.004.023-.003c3.994-.444 6.627 2.214 8.042 4.937Z" fill="#27292A"/></g><g class="uk-icon-alternate-default" fill="none"><path d="m16.611 10.092 1.328-.457a.726.726 0 0 1 .928.906l-1.18 3.711a.725.725 0 0 1-1.135.355l-2.96-2.288a.725.725 0 0 1 .208-1.26l1.188-.409C13.75 8.38 11.663 6.533 8.78 6.842c-2.234.38-3.124 1.574-4.268 3.153a1.842 1.842 0 1 1-1.507-.817l.131-.183C4.294 7.393 5.55 5.657 8.523 5.162l.023-.004.023-.003c3.994-.444 6.627 2.214 8.042 4.937Z" fill="#27292A"/></g></svg>
|
||||
|
After Width: | Height: | Size: 910 B |
1
public/icons/xmind/sidebar.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"><g class="uk-icon-alternate-dark" fill="none"><rect x="4" y="6" width="16" height="12" rx="1" stroke="#27292A" stroke-width="1.6"/><path d="M16 6v12" stroke="#27292A" stroke-width="1.6"/></g><g class="uk-icon-alternate-default" fill="none"><rect x="4" y="6" width="16" height="12" rx="1" stroke="#27292A" stroke-width="1.6"/><path d="M16 6v12" stroke="#27292A" stroke-width="1.6"/></g></svg>
|
||||
|
After Width: | Height: | Size: 474 B |
1
public/icons/xmind/subtopic.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" width="20" height="20"><g class="uk-icon-alternate-dark" fill="none"><path fill-rule="evenodd" clip-rule="evenodd" d="M7 8a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v4a2 2 0 0 1-2 2H9a2 2 0 0 1-2-2V8Zm2-.5h9a.5.5 0 0 1 .5.5v4a.5.5 0 0 1-.5.5H9a.5.5 0 0 1-.5-.5V8a.5.5 0 0 1 .5-.5Z" fill="#27292A"/><path d="M5.355 10.75a2 2 0 1 1 0-1.5H7v1.5H5.355Z" fill="#27292A"/></g><g class="uk-icon-alternate-default" fill="none"><path fill-rule="evenodd" clip-rule="evenodd" d="M7 8a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v4a2 2 0 0 1-2 2H9a2 2 0 0 1-2-2V8Zm2-.5h9a.5.5 0 0 1 .5.5v4a.5.5 0 0 1-.5.5H9a.5.5 0 0 1-.5-.5V8a.5.5 0 0 1 .5-.5Z" fill="#27292A"/><path d="M5.355 10.75a2 2 0 1 1 0-1.5H7v1.5H5.355Z" fill="#27292A"/></g></svg>
|
||||
|
After Width: | Height: | Size: 760 B |
1
public/icons/xmind/summary.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" width="20" height="20"><g class="uk-icon-alternate-dark" fill="none"><path d="M1.25 4A.75.75 0 0 1 2 3.25c.635 0 1.186.104 1.628.356.466.265.75.659.891 1.103.134.42.136.862.1 1.258-.037.4-.12.813-.197 1.186l-.028.133c-.07.335-.133.635-.171.917-.044.322-.045.55-.012.707.028.133.07.18.118.212.07.048.253.128.671.128a.75.75 0 0 1 0 1.5c-.418 0-.6.08-.671.128-.048.032-.09.079-.118.213-.033.156-.032.384.012.706.038.282.1.582.17.917l.029.133c.077.373.16.787.197 1.186.036.396.034.839-.1 1.258a1.906 1.906 0 0 1-.891 1.103c-.442.252-.993.356-1.628.356a.75.75 0 0 1 0-1.5c.49 0 .752-.083.886-.16a.41.41 0 0 0 .204-.256c.046-.143.063-.357.035-.664a9.362 9.362 0 0 0-.172-1.017l-.027-.13A14.872 14.872 0 0 1 2.736 12c-.052-.382-.078-.81.006-1.216.058-.274.168-.546.354-.784a1.884 1.884 0 0 1-.354-.785C2.658 8.81 2.684 8.382 2.736 8c.046-.338.121-.695.19-1.022l.027-.131a9.36 9.36 0 0 0 .172-1.017c.028-.307.011-.52-.035-.664a.41.41 0 0 0-.204-.256c-.134-.077-.396-.16-.886-.16A.75.75 0 0 1 1.25 4Z" fill="#27292A"/><path fill-rule="evenodd" clip-rule="evenodd" d="M8.5 7A1.5 1.5 0 0 0 7 8.5v3A1.5 1.5 0 0 0 8.5 13h8a1.5 1.5 0 0 0 1.5-1.5v-3A1.5 1.5 0 0 0 16.5 7h-8Zm8 1.5h-8v3h8v-3Z" fill="#27292A"/></g><g class="uk-icon-alternate-default" fill="none"><path d="M1.25 4A.75.75 0 0 1 2 3.25c.635 0 1.186.104 1.628.356.466.265.75.659.891 1.103.134.42.136.862.1 1.258-.037.4-.12.813-.197 1.186l-.028.133c-.07.335-.133.635-.171.917-.044.322-.045.55-.012.707.028.133.07.18.118.212.07.048.253.128.671.128a.75.75 0 0 1 0 1.5c-.418 0-.6.08-.671.128-.048.032-.09.079-.118.213-.033.156-.032.384.012.706.038.282.1.582.17.917l.029.133c.077.373.16.787.197 1.186.036.396.034.839-.1 1.258a1.906 1.906 0 0 1-.891 1.103c-.442.252-.993.356-1.628.356a.75.75 0 0 1 0-1.5c.49 0 .752-.083.886-.16a.41.41 0 0 0 .204-.256c.046-.143.063-.357.035-.664a9.362 9.362 0 0 0-.172-1.017l-.027-.13A14.872 14.872 0 0 1 2.736 12c-.052-.382-.078-.81.006-1.216.058-.274.168-.546.354-.784a1.884 1.884 0 0 1-.354-.785C2.658 8.81 2.684 8.382 2.736 8c.046-.338.121-.695.19-1.022l.027-.131a9.36 9.36 0 0 0 .172-1.017c.028-.307.011-.52-.035-.664a.41.41 0 0 0-.204-.256c-.134-.077-.396-.16-.886-.16A.75.75 0 0 1 1.25 4Z" fill="#27292A"/><path fill-rule="evenodd" clip-rule="evenodd" d="M8.5 7A1.5 1.5 0 0 0 7 8.5v3A1.5 1.5 0 0 0 8.5 13h8a1.5 1.5 0 0 0 1.5-1.5v-3A1.5 1.5 0 0 0 16.5 7h-8Zm8 1.5h-8v3h8v-3Z" fill="#27292A"/></g></svg>
|
||||
|
After Width: | Height: | Size: 2.4 KiB |
1
public/icons/xmind/topic.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" width="20" height="20"><g class="uk-icon-alternate-dark" fill="none"><path fill-rule="evenodd" clip-rule="evenodd" d="M7.145 6.25H5.5a.25.25 0 0 0-.25.25v5c0 .138.112.25.25.25H7V10.5A1.5 1.5 0 0 1 8.5 9h9a1.5 1.5 0 0 1 1.5 1.5v4a1.5 1.5 0 0 1-1.5 1.5h-9A1.5 1.5 0 0 1 7 14.5v-1.25H5.5a1.75 1.75 0 0 1-1.75-1.75v-5c0-.966.784-1.75 1.75-1.75h1.645a2 2 0 1 1 0 1.5ZM8.5 10.5h9v4h-9v-4Z" fill="#27292A"/></g><g class="uk-icon-alternate-default" fill="none"><path fill-rule="evenodd" clip-rule="evenodd" d="M7.145 6.25H5.5a.25.25 0 0 0-.25.25v5c0 .138.112.25.25.25H7V10.5A1.5 1.5 0 0 1 8.5 9h9a1.5 1.5 0 0 1 1.5 1.5v4a1.5 1.5 0 0 1-1.5 1.5h-9A1.5 1.5 0 0 1 7 14.5v-1.25H5.5a1.75 1.75 0 0 1-1.75-1.75v-5c0-.966.784-1.75 1.75-1.75h1.645a2 2 0 1 1 0 1.5ZM8.5 10.5h9v4h-9v-4Z" fill="#27292A"/></g></svg>
|
||||
|
After Width: | Height: | Size: 854 B |
1
public/icons/xmind/zen.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" width="20" height="20"><g class="uk-icon-alternate-dark" fill="none"><path d="M4 3.75a1.75 1.75 0 0 0-1.224.527c-.299.3-.526.732-.526 1.223v3a.75.75 0 0 0 1.5 0v-3c0-.023.015-.09.087-.162.073-.073.14-.088.163-.088h3.5a.75.75 0 0 0 0-1.5H4ZM3.75 11.5a.75.75 0 0 0-1.5 0v3c0 .494.24.925.532 1.218.293.293.724.532 1.218.532h3a.75.75 0 0 0 0-1.5H4c-.006 0-.075-.01-.157-.093-.082-.082-.093-.15-.093-.157v-3ZM11.75 4.5a.75.75 0 0 1 .75-.75h3.542c.929 0 1.682.752 1.682 1.68V8.5a.75.75 0 0 1-1.5 0V5.43c0-.098-.08-.18-.182-.18H12.5a.75.75 0 0 1-.75-.75ZM17.75 11.5a.75.75 0 0 0-1.5 0v3c0 .024-.015.09-.087.163-.072.072-.14.087-.163.087h-3a.75.75 0 0 0 0 1.5h3c.491 0 .924-.227 1.223-.527.3-.3.527-.732.527-1.223v-3Z" fill="#27292A"/></g><g class="uk-icon-alternate-default" fill="none"><path d="M4 3.75a1.75 1.75 0 0 0-1.224.527c-.299.3-.526.732-.526 1.223v3a.75.75 0 0 0 1.5 0v-3c0-.023.015-.09.087-.162.073-.073.14-.088.163-.088h3.5a.75.75 0 0 0 0-1.5H4ZM3.75 11.5a.75.75 0 0 0-1.5 0v3c0 .494.24.925.532 1.218.293.293.724.532 1.218.532h3a.75.75 0 0 0 0-1.5H4c-.006 0-.075-.01-.157-.093-.082-.082-.093-.15-.093-.157v-3ZM11.75 4.5a.75.75 0 0 1 .75-.75h3.542c.929 0 1.682.752 1.682 1.68V8.5a.75.75 0 0 1-1.5 0V5.43c0-.098-.08-.18-.182-.18H12.5a.75.75 0 0 1-.75-.75ZM17.75 11.5a.75.75 0 0 0-1.5 0v3c0 .024-.015.09-.087.163-.072.072-.14.087-.163.087h-3a.75.75 0 0 0 0 1.5h3c.491 0 .924-.227 1.223-.527.3-.3.527-.732.527-1.223v-3Z" fill="#27292A"/></g></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
@ -1,3 +1,4 @@
|
||||
import { chooseXmindLayout, clickXmindAction, clickXmindSheet } from "./editor-actions";
|
||||
import { expect, type Locator, type Page, test } from "@playwright/test";
|
||||
|
||||
async function importMap(page: Page) {
|
||||
@ -57,9 +58,9 @@ test("annotations follow layout and collapse, persist across sheets, and undo wi
|
||||
const boundary = page.locator("[plait-geometry=true]");
|
||||
const originalBounds = await bounds(boundary);
|
||||
await page.locator(".drawnix").getByText("项目计划", { exact: true }).click();
|
||||
await page.getByRole("button", { name: "向下逻辑", exact: true }).click();
|
||||
await chooseXmindLayout(page, "向下逻辑");
|
||||
await expect.poll(() => bounds(boundary)).not.toEqual(originalBounds);
|
||||
await page.getByRole("button", { name: "向右逻辑", exact: true }).click();
|
||||
await chooseXmindLayout(page, "向右逻辑");
|
||||
await expect.poll(() => bounds(boundary)).toEqual(originalBounds);
|
||||
|
||||
await page.getByText("产品调研", { exact: true }).click();
|
||||
@ -76,15 +77,15 @@ test("annotations follow layout and collapse, persist across sheets, and undo wi
|
||||
|
||||
await page.getByRole("button", { name: "新增画布", exact: true }).click();
|
||||
await expect(relationText).toHaveCount(0);
|
||||
await page.getByRole("button", { name: "画布 1", exact: true }).click();
|
||||
await clickXmindSheet(page, "画布 1");
|
||||
await expect(relationText).toBeVisible();
|
||||
await expect(boundary).toHaveCount(1);
|
||||
await page.getByText("视觉设计", { exact: true }).click();
|
||||
await page.keyboard.press("Backspace");
|
||||
await expect(relationText).toHaveCount(0);
|
||||
await page.getByRole("button", { name: "撤销", exact: true }).click();
|
||||
await clickXmindAction(page, "撤销");
|
||||
await expect(relationText).toBeVisible();
|
||||
await page.getByRole("button", { name: "重做", exact: true }).click();
|
||||
await clickXmindAction(page, "重做");
|
||||
await expect(relationText).toHaveCount(0);
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
|
||||
35
tests/flow-board/editor-actions.ts
Normal file
@ -0,0 +1,35 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
|
||||
export async function clickXmindSheet(page: Page, label: string) {
|
||||
const tab = page.getByRole("tablist", { name: "工作表" }).getByRole("button", { name: label, exact: true });
|
||||
const box = await tab.boundingBox();
|
||||
if (!box) throw new Error(`工作表不可见:${label}`);
|
||||
// The local Next.js indicator can overlap the center of a short first tab.
|
||||
await tab.click({ position: { x: box.width - 6, y: box.height / 2 } });
|
||||
}
|
||||
|
||||
export async function clickXmindAction(page: Page, label: string) {
|
||||
const menus: Record<string,string> = {
|
||||
备注:"插入",链接:"插入",标签:"插入",主题:"插入",文本:"插入",图片:"插入",
|
||||
撤销:"文件与编辑",重做:"文件与编辑",保存:"文件与编辑",导入:"文件与编辑",云文件:"文件与编辑",
|
||||
"查找与替换":"文件与编辑",
|
||||
放大:"缩放",缩小:"缩放",适应画布:"缩放",
|
||||
重命名当前画布:"画布操作",复制当前画布:"画布操作",删除当前画布:"画布操作",
|
||||
};
|
||||
const menu = menus[label];
|
||||
if (menu) {
|
||||
const trigger = page.getByRole("button",{name:menu,exact:true});
|
||||
if (await trigger.getAttribute("aria-expanded") !== "true") await trigger.click();
|
||||
await page.getByRole("menuitem",{name:label,exact:false}).click();
|
||||
} else await page.getByRole("button",{name:label,exact:true}).click();
|
||||
}
|
||||
|
||||
export async function openXmindStructures(page: Page) {
|
||||
const trigger = page.getByRole("button",{name:"选择结构",exact:true});
|
||||
if (await trigger.getAttribute("aria-expanded") !== "true") await trigger.click();
|
||||
}
|
||||
|
||||
export async function chooseXmindLayout(page: Page, label: string) {
|
||||
await openXmindStructures(page);
|
||||
await page.getByRole("button",{name:label,exact:true}).click();
|
||||
}
|
||||
@ -1,3 +1,4 @@
|
||||
import { clickXmindAction, openXmindStructures } from "./editor-actions";
|
||||
import { expect, type Page, test } from "@playwright/test";
|
||||
import JSZip from "jszip";
|
||||
|
||||
@ -51,7 +52,7 @@ test("side-title tree table renders a spanning left header and preserves its nat
|
||||
await expect(
|
||||
table.locator('[data-special-table-cell][data-depth="0"]')
|
||||
).toHaveAttribute("fill", "#000000");
|
||||
await page.getByRole("button", { name: "适应画布", exact: true }).click();
|
||||
await clickXmindAction(page, "适应画布");
|
||||
const box = async (title: string) =>
|
||||
(await canvas.getByText(title, { exact: true }).boundingBox())!;
|
||||
const root = await box("发布协调");
|
||||
@ -66,7 +67,7 @@ test("side-title tree table renders a spanning left header and preserves its nat
|
||||
await page.keyboard.insertText("上线复盘");
|
||||
await page
|
||||
.locator(".flow-board-canvas-shell")
|
||||
.click({ position: { x: 50, y: 50 } });
|
||||
.click({ position: { x: 50, y: 100 } });
|
||||
await expect(canvas.getByText("上线复盘", { exact: true })).toBeVisible();
|
||||
await expect(table.locator("[data-special-table-cell]")).toHaveCount(6);
|
||||
await page.screenshot({
|
||||
@ -75,6 +76,7 @@ test("side-title tree table renders a spanning left header and preserves its nat
|
||||
});
|
||||
|
||||
await canvas.getByText("发布协调", { exact: true }).click();
|
||||
await openXmindStructures(page);
|
||||
await expect(
|
||||
page.getByRole("button", { name: "树形表格(侧标题)", exact: true })
|
||||
).toHaveAttribute("aria-pressed", "true");
|
||||
@ -160,7 +162,7 @@ test("spreadsheet imports XMind columns, stacks labelled entries and round-trips
|
||||
await expect(
|
||||
table.locator(`[data-special-matrix-column-label="${column}"]`)
|
||||
).toHaveText(column);
|
||||
await page.getByRole("button", { name: "适应画布", exact: true }).click();
|
||||
await clickXmindAction(page, "适应画布");
|
||||
const box = async (title: string) =>
|
||||
(await canvas.getByText(title, { exact: true }).boundingBox())!;
|
||||
const happy = await box("Happy");
|
||||
@ -178,12 +180,13 @@ test("spreadsheet imports XMind columns, stacks labelled entries and round-trips
|
||||
await page.keyboard.insertText("Team coffee");
|
||||
await page
|
||||
.locator(".flow-board-canvas-shell")
|
||||
.click({ position: { x: 50, y: 50 } });
|
||||
.click({ position: { x: 50, y: 100 } });
|
||||
await expect(canvas.getByText("Team coffee", { exact: true })).toBeVisible();
|
||||
|
||||
await canvas.getByText("Mood Tracker", { exact: true }).click();
|
||||
await openXmindStructures(page);
|
||||
await expect(
|
||||
page.getByLabel("矩阵", { exact: true })
|
||||
page.getByRole("complementary", { name: "格式面板" }).getByLabel("矩阵", { exact: true })
|
||||
).toHaveAttribute("aria-pressed", "true");
|
||||
const downloadPromise = page.waitForEvent("download");
|
||||
await page.getByRole("button", { name: "导出 XMind", exact: true }).click();
|
||||
@ -215,7 +218,7 @@ test("spreadsheet imports XMind columns, stacks labelled entries and round-trips
|
||||
});
|
||||
await expect(table).toBeVisible();
|
||||
await expect(table.locator("[data-special-matrix-column]")).toHaveCount(3);
|
||||
await page.getByRole("button", { name: "适应画布", exact: true }).click();
|
||||
await clickXmindAction(page, "适应画布");
|
||||
await page.screenshot({
|
||||
path: test.info().outputPath("spreadsheet-preview.png"),
|
||||
animations: "disabled",
|
||||
|
||||
86
tests/flow-board/native-editor.spec.ts
Normal file
@ -0,0 +1,86 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import JSZip from "jszip";
|
||||
import { clickXmindAction } from "./editor-actions";
|
||||
|
||||
async function importWorkbook(page: import("@playwright/test").Page) {
|
||||
await page.goto("/dev/flow-board-preview",{waitUntil:"commit"});
|
||||
const zip = new JSZip();
|
||||
zip.file("content.json",JSON.stringify([
|
||||
{title:"产品计划",rootTopic:{id:"r",title:"产品计划",children:{attached:[
|
||||
{id:"a",title:"设计评审",branch:"folded",children:{attached:[{id:"b",title:"设计细节"}]}},
|
||||
{id:"c",title:"开发验收"},
|
||||
]}}},
|
||||
{title:"归档",rootTopic:{id:"r2",title:"历史设计记录"}},
|
||||
]));
|
||||
await page.locator("input[accept*=xmind]").setInputFiles({name:"计划.xmind",mimeType:"application/octet-stream",buffer:await zip.generateAsync({type:"nodebuffer"})});
|
||||
await expect(page.locator(".drawnix").getByText("产品计划",{exact:true})).toBeVisible();
|
||||
}
|
||||
|
||||
test("native floating toolbar and menus work with keyboard and narrow viewports",async ({page}) => {
|
||||
const errors:string[]=[];page.on("pageerror",e => errors.push(e.message));
|
||||
await importWorkbook(page);
|
||||
await expect(page.locator(".xmind-toolbar-pill").first()).toHaveCSS("border-radius","8px");
|
||||
await expect(page.getByLabel("格式面板",{exact:true})).toHaveCSS("width","240px");
|
||||
await page.getByRole("button",{name:"文件与编辑",exact:true}).focus();
|
||||
await page.keyboard.press("ArrowDown");
|
||||
await expect(page.getByRole("menuitem",{name:"保存",exact:false})).toBeFocused();
|
||||
await page.keyboard.press("ArrowDown");
|
||||
await expect(page.getByRole("menuitem",{name:"云文件",exact:true})).toBeFocused();
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(page.getByRole("menu",{name:"文件与编辑",exact:true})).toHaveCount(0);
|
||||
await page.setViewportSize({width:800,height:700});
|
||||
const toolbar=await page.locator(".xmind-topbar").boundingBox();
|
||||
expect(toolbar!.x+toolbar!.width).toBeLessThanOrEqual(800);
|
||||
await page.getByRole("button",{name:"格式",exact:true}).click();
|
||||
await expect(page.getByLabel("格式面板",{exact:true})).toHaveCount(0);
|
||||
await page.getByRole("button",{name:"格式",exact:true}).click();
|
||||
await expect(page.getByLabel("格式面板",{exact:true})).toBeVisible();
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
|
||||
test("search finds collapsed and cross-sheet topics, replacement undoes as a batch",async ({page}) => {
|
||||
await importWorkbook(page);
|
||||
await page.keyboard.press("ControlOrMeta+f");
|
||||
await page.getByLabel("查找主题",{exact:true}).fill("设计");
|
||||
const search = page.getByLabel("查找与替换",{exact:true});
|
||||
await expect(search.getByRole("status")).toHaveText("2 个匹配主题");
|
||||
await search.getByRole("button",{name:"定位 设计细节",exact:true}).click();
|
||||
await expect(page.locator(".drawnix").getByText("设计细节",{exact:true})).toBeVisible();
|
||||
await page.getByLabel("替换为",{exact:true}).fill("方案");
|
||||
await search.getByRole("button",{name:"全部替换",exact:true}).click();
|
||||
await expect(page.locator(".drawnix").getByText("方案评审",{exact:true})).toBeVisible();
|
||||
await expect(page.locator(".drawnix").getByText("方案细节",{exact:true})).toBeVisible();
|
||||
await clickXmindAction(page,"撤销");
|
||||
await expect(page.locator(".drawnix").getByText("设计评审",{exact:true})).toBeVisible();
|
||||
await clickXmindAction(page,"重做");
|
||||
await expect(page.locator(".drawnix").getByText("方案评审",{exact:true})).toBeVisible();
|
||||
await page.getByLabel("查找范围",{exact:true}).selectOption("all");
|
||||
await expect(search.getByRole("status")).toHaveText("1 个匹配主题");
|
||||
await search.getByRole("button",{name:"定位 历史设计记录",exact:true}).click();
|
||||
await expect(page.locator(".drawnix").getByText("历史设计记录",{exact:true})).toBeVisible();
|
||||
await page.getByRole("button",{name:"关闭查找",exact:true}).click();
|
||||
await page.keyboard.press("ControlOrMeta+f");
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(search).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("ZEN and presentation restore the edited workbook and sheet",async ({page}) => {
|
||||
await importWorkbook(page);
|
||||
await page.getByRole("button",{name:"ZEN 模式",exact:true}).click();
|
||||
await expect(page.locator(".xmind-topbar")).toHaveCount(0);
|
||||
await expect(page.getByLabel("格式面板",{exact:true})).toHaveCount(0);
|
||||
await expect(page.locator(".drawnix").getByText("产品计划",{exact:true})).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(page.locator(".xmind-topbar")).toBeVisible();
|
||||
await page.getByRole("button",{name:"演示",exact:true}).click();
|
||||
const presentation = page.getByLabel("演示视图",{exact:true});
|
||||
await expect(presentation.getByRole("heading",{name:"产品计划",exact:true})).toBeVisible();
|
||||
await page.keyboard.press("ArrowRight");
|
||||
await expect(presentation.getByRole("heading",{name:"设计评审",exact:true})).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(presentation).toHaveCount(0);
|
||||
await expect(page.locator(".drawnix").getByText("产品计划",{exact:true})).toBeVisible();
|
||||
await page.getByRole("button",{name:"产品计划",exact:true}).dblclick();
|
||||
const title=page.getByLabel("重命名画布",{exact:true});await title.fill("错误标题");await title.press("Escape");
|
||||
await expect(page.getByRole("button",{name:"产品计划",exact:true})).toBeVisible();
|
||||
});
|
||||
@ -1,3 +1,4 @@
|
||||
import { clickXmindAction, clickXmindSheet } from "./editor-actions";
|
||||
import { expect, test } from "@playwright/test";
|
||||
import JSZip from "jszip";
|
||||
|
||||
@ -47,11 +48,11 @@ test("outline edits, creates, nests and deletes topics with shared canvas histor
|
||||
).toBeVisible();
|
||||
const research = outline.getByLabel("大纲主题 市场调研", { exact: true });
|
||||
const initialHeight = (await research.boundingBox())!.height;
|
||||
await page.getByRole("button", { name: "放大", exact: true }).click();
|
||||
await clickXmindAction(page, "放大");
|
||||
await expect
|
||||
.poll(async () => (await research.boundingBox())!.height)
|
||||
.toBeGreaterThan(initialHeight);
|
||||
await page.getByRole("button", { name: "适应画布", exact: true }).click();
|
||||
await clickXmindAction(page, "适应画布");
|
||||
await research.fill("用户研究");
|
||||
await research.press("Enter");
|
||||
await expect(
|
||||
@ -64,9 +65,9 @@ test("outline edits, creates, nests and deletes topics with shared canvas histor
|
||||
await expect(competitor.locator("..")).toHaveAttribute("data-depth", "2");
|
||||
await competitor.press("Shift+Tab");
|
||||
await expect(competitor.locator("..")).toHaveAttribute("data-depth", "1");
|
||||
await page.getByRole("button", { name: "撤销", exact: true }).click();
|
||||
await clickXmindAction(page, "撤销");
|
||||
await expect(competitor.locator("..")).toHaveAttribute("data-depth", "2");
|
||||
await page.getByRole("button", { name: "重做", exact: true }).click();
|
||||
await clickXmindAction(page, "重做");
|
||||
await expect(competitor.locator("..")).toHaveAttribute("data-depth", "1");
|
||||
await outline
|
||||
.getByRole("button", { name: "折叠 产品设计", exact: true })
|
||||
@ -103,7 +104,7 @@ test("outline edits, creates, nests and deletes topics with shared canvas histor
|
||||
await expect(
|
||||
outline.getByLabel("大纲主题 竞争分析", { exact: true })
|
||||
).toHaveCount(0);
|
||||
await page.getByRole("button", { name: "撤销", exact: true }).click();
|
||||
await clickXmindAction(page, "撤销");
|
||||
await expect(
|
||||
outline.getByLabel("大纲主题 竞争分析", { exact: true })
|
||||
).toBeVisible();
|
||||
@ -111,7 +112,7 @@ test("outline edits, creates, nests and deletes topics with shared canvas histor
|
||||
await expect(
|
||||
outline.getByLabel("大纲主题 历史记录", { exact: true })
|
||||
).toBeVisible();
|
||||
await page.getByRole("button", { name: "项目", exact: true }).click();
|
||||
await clickXmindSheet(page, "项目");
|
||||
await expect(
|
||||
outline.getByLabel("大纲主题 用户研究", { exact: true })
|
||||
).toBeVisible();
|
||||
@ -122,7 +123,7 @@ test("outline edits, creates, nests and deletes topics with shared canvas histor
|
||||
await renamedResearch.press("Escape");
|
||||
await expect(renamedResearch).toHaveValue("用户研究");
|
||||
await outline.getByLabel("大纲主题 用户研究", { exact: true }).click();
|
||||
await page.getByRole("button", { name: "备注", exact: true }).click();
|
||||
await clickXmindAction(page, "备注");
|
||||
await expect(page.getByLabel("主题备注", { exact: true })).toHaveValue(
|
||||
"调研备注"
|
||||
);
|
||||
|
||||
95
tests/flow-board/selection.spec.ts
Normal file
@ -0,0 +1,95 @@
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
import JSZip from "jszip";
|
||||
import { clickXmindAction } from "./editor-actions";
|
||||
|
||||
async function openMap(page: Page) {
|
||||
await page.goto("/dev/flow-board-preview", { waitUntil: "commit" });
|
||||
const zip = new JSZip();
|
||||
zip.file("content.json", JSON.stringify([{ title: "框选验证", rootTopic: {
|
||||
id: "root", title: "项目", children: { attached: [
|
||||
{ id: "first", title: "产品" }, { id: "second", title: "设计" },
|
||||
{ id: "third", title: "开发" }, { id: "fourth", title: "上线" },
|
||||
] },
|
||||
} }]));
|
||||
await page.locator("input[accept*=xmind]").setInputFiles({
|
||||
name: "selection.xmind", mimeType: "application/octet-stream",
|
||||
buffer: await zip.generateAsync({ type: "nodebuffer" }),
|
||||
});
|
||||
await expect(page.locator(".drawnix").getByText("产品", { exact: true })).toBeVisible();
|
||||
await page.evaluate(() => document.fonts.ready);
|
||||
}
|
||||
|
||||
async function marqueeBranches(page: Page) {
|
||||
const canvas = page.locator(".drawnix");
|
||||
const first = await canvas.getByText("产品", { exact: true }).boundingBox();
|
||||
const second = await canvas.getByText("设计", { exact: true }).boundingBox();
|
||||
if (!first || !second) throw new Error("Missing branch geometry");
|
||||
const start = { x: Math.min(first.x, second.x) - 35, y: Math.min(first.y, second.y) - 25 };
|
||||
const end = { x: Math.max(first.x + first.width, second.x + second.width) + 35, y: Math.max(first.y + first.height, second.y + second.height) + 25 };
|
||||
await page.mouse.move(start.x, start.y);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(end.x, end.y, { steps: 20 });
|
||||
const overlay = canvas.locator(".board-active-svg");
|
||||
await expect(overlay).toHaveCSS("position", "absolute");
|
||||
await expect(overlay).toHaveCSS("pointer-events", "none");
|
||||
const frame = overlay.locator('.active-host-g > g > path[stroke="#6698FF"]');
|
||||
await expect(frame).toHaveCount(1);
|
||||
await expect(frame).toHaveCSS("stroke", "rgb(56, 124, 255)");
|
||||
await expect(frame).toHaveCSS("stroke-width", "2px");
|
||||
await expect.poll(async () => {
|
||||
const box = await frame.boundingBox();
|
||||
if (!box) return Number.POSITIVE_INFINITY;
|
||||
// SVG paint bounds may include the selection outline's padding.
|
||||
return Math.max(Math.abs(box.x - start.x), Math.abs(box.y - start.y),
|
||||
Math.abs(box.x + box.width - end.x), Math.abs(box.y + box.height - end.y));
|
||||
}).toBeLessThanOrEqual(5);
|
||||
const fill = overlay.locator('.active-host-g > g > path[fill="#6698FF25"]');
|
||||
await expect(fill).toHaveCSS("fill", "rgba(56, 124, 255, 0.12)");
|
||||
await page.mouse.up();
|
||||
await expect(page.locator(".xmind-topic-count")).toHaveText("主题: 2 / 5");
|
||||
await expect(fill).toHaveCount(0);
|
||||
await expect(overlay.locator('.selection-rectangle')).not.toHaveCount(0);
|
||||
}
|
||||
|
||||
test("blank-canvas marquee selects two branches and batch deletion can be undone", async ({ page }) => {
|
||||
await openMap(page);
|
||||
await expect(page.getByRole("button", { name: "选择工具", exact: true })).toHaveAttribute("aria-pressed", "true");
|
||||
await marqueeBranches(page);
|
||||
await page.keyboard.press("Backspace");
|
||||
const canvas = page.locator(".drawnix");
|
||||
await expect(canvas.getByText("产品", { exact: true })).toHaveCount(0);
|
||||
await expect(canvas.getByText("设计", { exact: true })).toHaveCount(0);
|
||||
await expect(canvas.getByText("开发", { exact: true })).toBeVisible();
|
||||
await clickXmindAction(page, "撤销");
|
||||
await expect(canvas.getByText("产品", { exact: true })).toBeVisible();
|
||||
await expect(canvas.getByText("设计", { exact: true })).toBeVisible();
|
||||
});
|
||||
|
||||
test("panning and selection shortcuts stay in sync and do not consume text input", async ({ page }) => {
|
||||
await openMap(page);
|
||||
const select = page.getByRole("button", { name: "选择工具", exact: true });
|
||||
const pan = page.getByRole("button", { name: "拖动画布", exact: true });
|
||||
await page.keyboard.press("h");
|
||||
await expect(pan).toHaveAttribute("aria-pressed", "true");
|
||||
const topic = page.locator(".drawnix").getByText("产品", { exact: true });
|
||||
const before = await topic.boundingBox();
|
||||
await page.mouse.move(160, 200);
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(210, 240, { steps: 12 });
|
||||
await page.mouse.up();
|
||||
await expect.poll(async () => (await topic.boundingBox())?.x).not.toBe(before?.x);
|
||||
await page.keyboard.press("v");
|
||||
await expect(select).toHaveAttribute("aria-pressed", "true");
|
||||
await marqueeBranches(page);
|
||||
await pan.click();
|
||||
await expect(pan).toHaveAttribute("aria-pressed", "true");
|
||||
await select.click();
|
||||
await marqueeBranches(page);
|
||||
const title = page.getByLabel("白板标题", { exact: true });
|
||||
await title.fill("");
|
||||
await title.pressSequentially("hv");
|
||||
await expect(title).toHaveValue("hv");
|
||||
await expect(select).toHaveAttribute("aria-pressed", "true");
|
||||
await page.getByRole("button", { name: "新增画布", exact: true }).click();
|
||||
await expect(select).toHaveAttribute("aria-pressed", "true");
|
||||
});
|
||||
@ -1,3 +1,4 @@
|
||||
import { chooseXmindLayout, clickXmindAction, openXmindStructures } from "./editor-actions";
|
||||
import { expect, test } from "@playwright/test";
|
||||
import JSZip from "jszip";
|
||||
|
||||
@ -50,7 +51,7 @@ test("special layouts change geometry and connectors, edit normally and round-tr
|
||||
await expect(
|
||||
canvas.locator('[data-special-connector="fishbone"]')
|
||||
).toHaveCount(4);
|
||||
await page.getByRole("button", { name: "适应画布", exact: true }).click();
|
||||
await clickXmindAction(page, "适应画布");
|
||||
const rect = async (title: string) =>
|
||||
(await canvas.getByText(title, { exact: true }).boundingBox())!;
|
||||
const center = await rect("中心主题");
|
||||
@ -61,6 +62,7 @@ test("special layouts change geometry and connectors, edit normally and round-tr
|
||||
expect(b.y).toBeGreaterThan(center.y + center.height);
|
||||
expect(c.x).toBeGreaterThan(a.x + a.width);
|
||||
await canvas.getByText("中心主题", { exact: true }).click();
|
||||
await openXmindStructures(page);
|
||||
await expect(
|
||||
page.getByRole("button", { name: "鱼骨图", exact: true })
|
||||
).toHaveAttribute("aria-pressed", "true");
|
||||
@ -68,15 +70,15 @@ test("special layouts change geometry and connectors, edit normally and round-tr
|
||||
path: test.info().outputPath("fishbone-preview.png"),
|
||||
animations: "disabled",
|
||||
});
|
||||
await page.getByRole("button", { name: "水平时间轴", exact: true }).click();
|
||||
await chooseXmindLayout(page, "水平时间轴");
|
||||
await expect(canvas.locator('[data-special-axis="timeline"]')).toHaveCount(1);
|
||||
await expect(
|
||||
canvas.locator('[data-special-connector="timeline"] circle')
|
||||
).toHaveCount(4);
|
||||
await expect(canvas.locator('[data-special-axis="fishbone"]')).toHaveCount(0);
|
||||
await page.getByRole("button", { name: "撤销", exact: true }).click();
|
||||
await clickXmindAction(page, "撤销");
|
||||
await expect(canvas.locator('[data-special-axis="fishbone"]')).toHaveCount(1);
|
||||
await page.getByRole("button", { name: "重做", exact: true }).click();
|
||||
await clickXmindAction(page, "重做");
|
||||
await expect(canvas.locator('[data-special-axis="timeline"]')).toHaveCount(1);
|
||||
await canvas.getByText("分支二", { exact: true }).dblclick();
|
||||
await page.keyboard.press("ControlOrMeta+A");
|
||||
@ -97,7 +99,7 @@ test("special layouts change geometry and connectors, edit normally and round-tr
|
||||
.click({ position: { x: 80, y: 80 } });
|
||||
await expect(canvas.getByText("新增时间任务", { exact: true })).toBeVisible();
|
||||
await canvas.getByText("中心主题", { exact: true }).click();
|
||||
await page.getByRole("button", { name: "括号图", exact: true }).click();
|
||||
await chooseXmindLayout(page, "括号图");
|
||||
await expect(
|
||||
canvas.locator('[data-special-structure="brace"] path')
|
||||
).toHaveCount(3);
|
||||
@ -117,7 +119,7 @@ test("special layouts change geometry and connectors, edit normally and round-tr
|
||||
await exported.file("content.json")!.async("string")
|
||||
);
|
||||
expect(content[0].rootTopic.structureClass).toBe("org.xmind.ui.brace.right");
|
||||
await page.getByRole("button", { name: "向右逻辑", exact: true }).click();
|
||||
await chooseXmindLayout(page, "向右逻辑");
|
||||
await expect(canvas.locator("[data-special-structure]")).toHaveCount(0);
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import { clickXmindAction } from "./editor-actions";
|
||||
import { expect, test } from "@playwright/test";
|
||||
import JSZip from "jszip";
|
||||
|
||||
@ -61,7 +62,7 @@ test("topic content imports, edits, undoes, survives sheet changes and follows i
|
||||
});
|
||||
const canvas = page.locator(".drawnix");
|
||||
await canvas.getByText("产品说明", { exact: true }).click();
|
||||
await page.getByRole("button", { name: "备注", exact: true }).click();
|
||||
await clickXmindAction(page, "备注");
|
||||
await expect(page.getByLabel("主题备注")).toHaveValue(
|
||||
"第一行说明\n第二行说明"
|
||||
);
|
||||
@ -83,14 +84,14 @@ test("topic content imports, edits, undoes, survives sheet changes and follows i
|
||||
await expect(
|
||||
page.getByRole("button", { name: "移除标签 完成", exact: true })
|
||||
).toBeVisible();
|
||||
await page.getByRole("button", { name: "撤销", exact: true }).click();
|
||||
await clickXmindAction(page, "撤销");
|
||||
await expect(
|
||||
page.getByRole("button", { name: "移除标签 完成", exact: true })
|
||||
).toHaveCount(0);
|
||||
await expect(page.getByLabel("主题备注")).toHaveValue(
|
||||
"更新后的多行备注\n保留换行"
|
||||
);
|
||||
await page.getByRole("button", { name: "重做", exact: true }).click();
|
||||
await clickXmindAction(page, "重做");
|
||||
await expect(
|
||||
page.getByRole("button", { name: "移除标签 完成", exact: true })
|
||||
).toBeVisible();
|
||||
@ -101,7 +102,7 @@ test("topic content imports, edits, undoes, survives sheet changes and follows i
|
||||
"true"
|
||||
);
|
||||
await page.getByRole("button", { name: "关闭主题内容", exact: true }).click();
|
||||
await page.getByRole("button", { name: "备注", exact: true }).click();
|
||||
await clickXmindAction(page, "备注");
|
||||
await expect(page.getByLabel("主题外部链接")).toHaveValue(
|
||||
"https://example.com/docs"
|
||||
);
|
||||
@ -137,7 +138,7 @@ test("topic content imports, edits, undoes, survives sheet changes and follows i
|
||||
"href",
|
||||
`data:application/octet-stream;base64,${Buffer.from("替换后的附件").toString("base64")}`
|
||||
);
|
||||
await page.getByRole("button", { name: "撤销", exact: true }).click();
|
||||
await clickXmindAction(page, "撤销");
|
||||
await expect(
|
||||
page.getByRole("link", { name: "下载 spec.txt", exact: true })
|
||||
).toBeVisible();
|
||||
@ -145,7 +146,7 @@ test("topic content imports, edits, undoes, survives sheet changes and follows i
|
||||
await expect(
|
||||
page.getByRole("link", { name: "下载 spec.txt", exact: true })
|
||||
).toHaveCount(0);
|
||||
await page.getByRole("button", { name: "撤销", exact: true }).click();
|
||||
await clickXmindAction(page, "撤销");
|
||||
await expect(
|
||||
page.getByRole("link", { name: "下载 spec.txt", exact: true })
|
||||
).toBeVisible();
|
||||
@ -169,9 +170,12 @@ test("legacy XML imports plain notes, separate labels and embedded pictures", as
|
||||
mimeType: "application/octet-stream",
|
||||
buffer: await zip.generateAsync({ type: "nodebuffer" }),
|
||||
});
|
||||
await expect(
|
||||
page.getByText("部分图片缺失、损坏或格式暂不支持", { exact: true })
|
||||
).toBeVisible();
|
||||
const canvas = page.locator(".drawnix");
|
||||
await canvas.getByText("旧版资料", { exact: true }).click();
|
||||
await page.getByRole("button", { name: "备注", exact: true }).click();
|
||||
await clickXmindAction(page, "备注");
|
||||
await expect(page.getByLabel("主题备注")).toHaveValue("旧版备注");
|
||||
await expect(
|
||||
page.getByRole("button", { name: "移除标签 计划", exact: true })
|
||||
@ -188,7 +192,4 @@ test("legacy XML imports plain notes, separate labels and embedded pictures", as
|
||||
await expect(
|
||||
canvas.getByText("缺失图片仍保留主题", { exact: true })
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByText("部分图片缺失、损坏或格式暂不支持", { exact: true })
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import { chooseXmindLayout, clickXmindAction, openXmindStructures } from "./editor-actions";
|
||||
import { expect, test } from "@playwright/test";
|
||||
import JSZip from "jszip";
|
||||
|
||||
@ -47,7 +48,7 @@ test("tree table aligns hierarchy cells, edits topics and preserves its XMind st
|
||||
const table = canvas.locator('[data-special-table="treeTable"]');
|
||||
await expect(table).toBeVisible();
|
||||
await expect(table.locator("[data-special-table-cell]")).toHaveCount(6);
|
||||
await page.getByRole("button", { name: "适应画布", exact: true }).click();
|
||||
await clickXmindAction(page, "适应画布");
|
||||
const box = async (title: string) =>
|
||||
(await canvas.getByText(title, { exact: true }).boundingBox())!;
|
||||
const root = await box("项目清单");
|
||||
@ -66,17 +67,18 @@ test("tree table aligns hierarchy cells, edits topics and preserves its XMind st
|
||||
await page.keyboard.insertText("接口联调");
|
||||
await page
|
||||
.locator(".flow-board-canvas-shell")
|
||||
.click({ position: { x: 40, y: 40 } });
|
||||
.click({ position: { x: 100, y: 100 } });
|
||||
await expect(canvas.getByText("接口联调", { exact: true })).toBeVisible();
|
||||
await expect(table.locator("[data-special-table-cell]")).toHaveCount(7);
|
||||
|
||||
await canvas.getByText("项目清单", { exact: true }).click();
|
||||
await openXmindStructures(page);
|
||||
await expect(
|
||||
page.getByLabel("树形表格", { exact: true })
|
||||
page.getByRole("complementary", { name: "格式面板" }).getByLabel("树形表格", { exact: true })
|
||||
).toHaveAttribute("aria-pressed", "true");
|
||||
await page.getByRole("button", { name: "向右逻辑", exact: true }).click();
|
||||
await chooseXmindLayout(page, "向右逻辑");
|
||||
await expect(table).toHaveCount(0);
|
||||
await page.getByRole("button", { name: "撤销", exact: true }).click();
|
||||
await clickXmindAction(page, "撤销");
|
||||
await expect(table).toBeVisible();
|
||||
|
||||
const downloading = page.waitForEvent("download");
|
||||
@ -99,7 +101,7 @@ test("tree table aligns hierarchy cells, edits topics and preserves its XMind st
|
||||
});
|
||||
await expect(table).toBeVisible();
|
||||
await expect(canvas.getByText("接口联调", { exact: true })).toBeVisible();
|
||||
await page.getByRole("button", { name: "适应画布", exact: true }).click();
|
||||
await clickXmindAction(page, "适应画布");
|
||||
await page.screenshot({
|
||||
path: test.info().outputPath("tree-table-preview.png"),
|
||||
animations: "disabled",
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import { chooseXmindLayout, clickXmindAction } from "./editor-actions";
|
||||
import { expect, test } from "@playwright/test";
|
||||
import JSZip from "jszip";
|
||||
|
||||
@ -42,7 +43,7 @@ test("vertical timeline mirrors branches, edits children and survives sheet swit
|
||||
const canvas = page.locator(".drawnix");
|
||||
const axis = canvas.locator('[data-special-axis="verticalTimeline"]');
|
||||
await expect(axis).toHaveCount(1);
|
||||
await page.getByRole("button", { name: "适应画布", exact: true }).click();
|
||||
await clickXmindAction(page, "适应画布");
|
||||
const box = async (title: string) =>
|
||||
(await canvas.getByText(title, { exact: true }).boundingBox())!;
|
||||
const root = await box("年度计划"),
|
||||
@ -60,13 +61,13 @@ test("vertical timeline mirrors branches, edits children and survives sheet swit
|
||||
await page.keyboard.insertText("新左侧任务");
|
||||
await page
|
||||
.locator(".flow-board-canvas-shell")
|
||||
.click({ position: { x: 40, y: 40 } });
|
||||
.click({ position: { x: 100, y: 100 } });
|
||||
await expect(canvas.getByText("新左侧任务", { exact: true })).toBeVisible();
|
||||
expect((await box("新左侧任务")).x).toBeLessThan((await box("第二阶段")).x);
|
||||
await canvas.getByText("年度计划", { exact: true }).click();
|
||||
await page.getByRole("button", { name: "向右逻辑", exact: true }).click();
|
||||
await chooseXmindLayout(page, "向右逻辑");
|
||||
await expect(axis).toHaveCount(0);
|
||||
await page.getByRole("button", { name: "撤销", exact: true }).click();
|
||||
await clickXmindAction(page, "撤销");
|
||||
await expect(axis).toHaveCount(1);
|
||||
await page.getByRole("button", { name: "另一个画布", exact: true }).click();
|
||||
await expect(axis).toHaveCount(0);
|
||||
@ -99,7 +100,7 @@ test("vertical timeline mirrors branches, edits children and survives sheet swit
|
||||
});
|
||||
await expect(axis).toHaveCount(1);
|
||||
await expect(canvas.getByText("新左侧任务", { exact: true })).toBeVisible();
|
||||
await page.getByRole("button", { name: "适应画布", exact: true }).click();
|
||||
await clickXmindAction(page, "适应画布");
|
||||
await page.screenshot({
|
||||
path: test.info().outputPath("vertical-timeline-preview.png"),
|
||||
animations: "disabled",
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import { clickXmindAction } from "./editor-actions";
|
||||
import { expect, test } from "@playwright/test";
|
||||
import JSZip from "jszip";
|
||||
const pixel =
|
||||
@ -69,7 +70,7 @@ test("export downloads all sheets and preserves editable topic content after rei
|
||||
});
|
||||
const canvas = page.locator(".drawnix");
|
||||
await canvas.getByText("设计方案", { exact: true }).click();
|
||||
await page.getByRole("button", { name: "备注", exact: true }).click();
|
||||
await clickXmindAction(page, "备注");
|
||||
await page.getByLabel("主题备注").fill("编辑后的备注");
|
||||
const downloading = page.waitForEvent("download");
|
||||
await page.getByRole("button", { name: "导出 XMind", exact: true }).click();
|
||||
@ -118,7 +119,7 @@ test("export downloads all sheets and preserves editable topic content after rei
|
||||
canvas.locator('img[src^="data:image/png"], image[href^="data:image/png"]')
|
||||
).toHaveCount(1);
|
||||
await canvas.getByText("设计方案", { exact: true }).click();
|
||||
await page.getByRole("button", { name: "备注", exact: true }).click();
|
||||
await clickXmindAction(page, "备注");
|
||||
await expect(page.getByLabel("主题备注")).toHaveValue("编辑后的备注");
|
||||
await expect(
|
||||
page.getByRole("button", { name: "移除标签 重点", exact: true })
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import { openXmindStructures } from "./editor-actions";
|
||||
import { expect, type Page, test } from "@playwright/test";
|
||||
import JSZip from "jszip";
|
||||
|
||||
@ -29,6 +30,7 @@ test("modern XMind preserves sheets, free topics, summaries, boundaries, relatio
|
||||
await expect(canvas.getByText("市场调研", { exact: true })).toHaveCSS("font-size", "22px");
|
||||
await canvas.getByText("市场调研", { exact: true }).click();
|
||||
await expect(page.getByLabel("节点字号")).toHaveValue("22");
|
||||
await openXmindStructures(page);
|
||||
await expect(page.getByRole("button", { name: "向右逻辑", exact: true })).toHaveAttribute("aria-pressed", "true");
|
||||
await page.getByRole("button", { name: "上线清单", exact: true }).click();
|
||||
await expect(canvas.getByText("质量验收", { exact: true })).toBeVisible();
|
||||
|
||||
@ -75,6 +75,16 @@ test("native XMind basic samples retain every topic through import and export",
|
||||
)
|
||||
.first()
|
||||
).toBeVisible();
|
||||
if (name === "mind_map.xmind") {
|
||||
const topic = page.locator(".drawnix").getByText("Central Topic", { exact: true });
|
||||
await expect(topic).toHaveCSS("font-family", /NeverMind/);
|
||||
const metrics = await topic.evaluate(element => {
|
||||
const container = element.closest(".plait-text-container")!;
|
||||
const foreign = container.parentElement!;
|
||||
return { contentHeight: container.scrollHeight, frameHeight: Number(foreign.getAttribute("height")) };
|
||||
});
|
||||
expect(metrics.contentHeight).toBeLessThanOrEqual(Math.ceil(metrics.frameHeight));
|
||||
}
|
||||
const downloadPromise = page.waitForEvent("download");
|
||||
await page
|
||||
.getByRole("button", { name: "导出 XMind", exact: true })
|
||||
|
||||
37
tests/routes/flow-board-search.test.ts
Normal file
@ -0,0 +1,37 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import type { PlaitElement } from "@plait/core";
|
||||
import { replaceTopicText, searchTopics } from "@/lib/flow-board/topic-search";
|
||||
|
||||
const topic = (id: string, leaves: Record<string, unknown>[], children: PlaitElement[] = []): PlaitElement => ({
|
||||
id, type: "mind_child", data: { topic: { children: leaves } }, children,
|
||||
});
|
||||
|
||||
test("search includes folded descendants and counts literal matches across sheets", () => {
|
||||
const child = topic("c", [{ text: "C++ C++" }]);
|
||||
const parent = { ...topic("p", [{ text: "折叠分支" }], [child]), isCollapsed: true };
|
||||
const sheets = [
|
||||
{ id: "a", title: "当前", children: [parent] },
|
||||
{ id: "b", title: "归档", children: [topic("b", [{ text: "c++" }])] },
|
||||
];
|
||||
expect(searchTopics(sheets, "C++").map(item => [item.sheetId, item.id, item.occurrences])).toEqual([["a", "c", 2], ["b", "b", 1]]);
|
||||
expect(searchTopics(sheets, "C++", true)).toHaveLength(1);
|
||||
expect(searchTopics(sheets, "")).toEqual([]);
|
||||
});
|
||||
|
||||
test("replacement crosses styled leaves while preserving marks and literal replacement text", () => {
|
||||
const original = topic("a", [{ text: "start de", bold: true }, { text: "sign end", color: "#ff0000" }]);
|
||||
const before = JSON.stringify(original);
|
||||
expect(replaceTopicText(original, "design", "$& 新设计")).toEqual({ children: [
|
||||
{ text: "start $& 新设计", bold: true },
|
||||
{ text: " end", color: "#ff0000" },
|
||||
] });
|
||||
expect(JSON.stringify(original)).toBe(before);
|
||||
});
|
||||
|
||||
test("adjacent matches replace once each, including matches spanning three leaves", () => {
|
||||
const original = topic("a", [{ text: "a" }, { text: "ba", italic: true }, { text: "bab" }]);
|
||||
const output = replaceTopicText(original, "ab", "X");
|
||||
expect(output.children.map((leaf: {text: string}) => leaf.text).join("")).toBe("XXX");
|
||||
expect(output.children[1].italic).toBe(true);
|
||||
expect(replaceTopicText(original, "", "X")).toEqual(original.data.topic);
|
||||
});
|
||||