fix: improve flow board mind tools
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled

This commit is contained in:
Your Name 2026-04-12 01:37:27 +08:00
parent 9a562db15a
commit 1b239eb2c7
2 changed files with 170 additions and 18 deletions

View File

@ -17,8 +17,10 @@ import {
} from "@plait/core";
import { DrawTransforms, MemorizeKey } from "@plait/draw";
import {
canSetAbstract,
getSelectedMindElements,
type MindElement,
MindTransforms,
} from "@plait/mind";
import type { BoardChangeData } from "@plait-board/react-board";
import {
@ -40,6 +42,7 @@ import {
FileText,
FolderOpen,
Flag,
GitMerge,
Loader2,
type LucideIcon,
Plus,
@ -424,23 +427,38 @@ function getFlowBoardElementEmojiNames(element: MindElement) {
.filter(Boolean);
}
function getExistingFlowBoardMindElements(
board: PlaitBoard,
elements: MindElement[]
) {
return elements.filter((element) => {
try {
PlaitBoard.findPath(board, element);
return true;
} catch (_error) {
return false;
}
});
}
function getFlowBoardMarkerSelectionState(
board: PlaitBoard | null
board: PlaitBoard | null,
selectedElements?: MindElement[]
): FlowBoardMarkerSelectionState {
if (!board) {
return { count: 0, names: [] };
}
const selectedElements = getSelectedMindElements(board);
const targetElements = selectedElements || getSelectedMindElements(board);
const names = new Set<string>();
selectedElements.forEach((element) => {
targetElements.forEach((element) => {
getFlowBoardElementEmojiNames(element).forEach((name) => {
names.add(name);
});
});
return {
count: selectedElements.length,
count: targetElements.length,
names: Array.from(names),
};
}
@ -496,6 +514,29 @@ function getFlowBoardPastePoint(board: PlaitBoard): Point {
);
}
function isFlowBoardEditablePasteTarget(target: HTMLElement | null) {
if (!target) {
return false;
}
return Boolean(
target.isContentEditable ||
target.closest(
[
"input",
"textarea",
"select",
"[contenteditable='true']",
"[role='textbox']",
"[data-slate-editor='true']",
".slate-editable-container",
".plait-text-container",
".ttd-dialog-input",
].join(", ")
)
);
}
function getFlowBoardImageDimensions(file: File) {
return new Promise<{ width: number; height: number }>((resolve) => {
const url = URL.createObjectURL(file);
@ -557,6 +598,7 @@ export function DrawnixWorkspace({
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const boardRef = useRef<PlaitBoard | null>(null);
const mindMapFileInputRef = useRef<HTMLInputElement | null>(null);
const selectedMindElementsRef = useRef<MindElement[]>([]);
const [isMarkerPanelOpen, setIsMarkerPanelOpen] = useState(false);
const [isImportingMindMap, setIsImportingMindMap] = useState(false);
const [markerSelectionState, setMarkerSelectionState] =
@ -572,14 +614,70 @@ export function DrawnixWorkspace({
return siteLogo || siteLogoDark;
}, [resolvedTheme, siteLogo, siteLogoDark]);
const refreshMarkerSelectionState = useCallback(() => {
setMarkerSelectionState(getFlowBoardMarkerSelectionState(boardRef.current));
const getActiveMarkerElements = useCallback(() => {
const board = boardRef.current;
if (!board) {
selectedMindElementsRef.current = [];
return [] as MindElement[];
}
const selectedElements = getSelectedMindElements(board);
if (selectedElements.length > 0) {
selectedMindElementsRef.current = selectedElements;
return selectedElements;
}
const fallbackElements = getExistingFlowBoardMindElements(
board,
selectedMindElementsRef.current
);
selectedMindElementsRef.current = fallbackElements;
return fallbackElements;
}, []);
const refreshMarkerSelectionState = useCallback(
(options: { preserveFallback?: boolean } = {}) => {
const board = boardRef.current;
if (!board) {
selectedMindElementsRef.current = [];
setMarkerSelectionState({ count: 0, names: [] });
return;
}
const selectedElements = getSelectedMindElements(board);
if (selectedElements.length > 0) {
selectedMindElementsRef.current = selectedElements;
setMarkerSelectionState(
getFlowBoardMarkerSelectionState(board, selectedElements)
);
return;
}
if (!options.preserveFallback) {
selectedMindElementsRef.current = [];
setMarkerSelectionState({ count: 0, names: [] });
return;
}
const fallbackElements = getExistingFlowBoardMindElements(
board,
selectedMindElementsRef.current
);
selectedMindElementsRef.current = fallbackElements;
setMarkerSelectionState(
getFlowBoardMarkerSelectionState(board, fallbackElements)
);
},
[]
);
const handleDrawnixInit = useCallback((board: PlaitBoard) => {
boardRef.current = board;
seedFlowBoardDefaultTextColor();
setMarkerSelectionState(getFlowBoardMarkerSelectionState(board));
selectedMindElementsRef.current = getSelectedMindElements(board);
setMarkerSelectionState(
getFlowBoardMarkerSelectionState(board, selectedMindElementsRef.current)
);
}, []);
useEffect(() => {
@ -644,6 +742,7 @@ export function DrawnixWorkspace({
: null
);
setMarkerSelectionState({ count: 0, names: [] });
selectedMindElementsRef.current = [];
updateBrowserFileParam(file.id);
},
[updateBrowserFileParam]
@ -736,6 +835,7 @@ export function DrawnixWorkspace({
setCurrentTitle("流程白板");
setLastSavedAt(null);
setMarkerSelectionState({ count: 0, names: [] });
selectedMindElementsRef.current = [];
updateBrowserFileParam(null);
}
return;
@ -849,7 +949,7 @@ export function DrawnixWorkspace({
return;
}
const selectedElements = getSelectedMindElements(board);
const selectedElements = getActiveMarkerElements();
if (selectedElements.length === 0) {
toast.info("请先选中一个思维导图节点");
return;
@ -858,9 +958,9 @@ export function DrawnixWorkspace({
selectedElements.forEach((element) => {
updateFlowBoardElementMarker(board, element, group, marker);
});
refreshMarkerSelectionState();
refreshMarkerSelectionState({ preserveFallback: true });
},
[refreshMarkerSelectionState]
[getActiveMarkerElements, refreshMarkerSelectionState]
);
const handleClearFlowBoardMarkers = useCallback(() => {
@ -869,7 +969,7 @@ export function DrawnixWorkspace({
return;
}
const selectedElements = getSelectedMindElements(board);
const selectedElements = getActiveMarkerElements();
if (selectedElements.length === 0) {
toast.info("请先选中一个思维导图节点");
return;
@ -904,9 +1004,31 @@ export function DrawnixWorkspace({
PlaitBoard.findPath(board, element)
);
});
refreshMarkerSelectionState();
refreshMarkerSelectionState({ preserveFallback: true });
toast.success("已清空所选节点标记");
}, [refreshMarkerSelectionState]);
}, [getActiveMarkerElements, refreshMarkerSelectionState]);
const handleInsertFlowBoardAbstract = useCallback(() => {
const board = boardRef.current;
if (!board) {
return;
}
const selectedElements = getActiveMarkerElements().filter(canSetAbstract);
if (selectedElements.length === 0) {
toast.info("请先选中要汇总的思维导图节点");
return;
}
try {
MindTransforms.insertAbstract(board, selectedElements);
refreshMarkerSelectionState({ preserveFallback: true });
toast.success("已添加合并汇总");
} catch (error) {
console.error("[FlowBoard] Failed to insert abstract:", error);
toast.error("合并汇总失败,请选择同一组思维导图分支节点");
}
}, [getActiveMarkerElements, refreshMarkerSelectionState]);
const handleFlowBoardPastedImages = useCallback(async (
board: PlaitBoard,
@ -967,7 +1089,10 @@ export function DrawnixWorkspace({
}
const target = event.target instanceof HTMLElement ? event.target : null;
if (target?.closest("[data-flow-board-shell-control='true']")) {
if (
target?.closest("[data-flow-board-shell-control='true']") ||
isFlowBoardEditablePasteTarget(target)
) {
return;
}
@ -1305,6 +1430,7 @@ export function DrawnixWorkspace({
setCurrentTitle("流程白板");
setLastSavedAt(null);
setMarkerSelectionState({ count: 0, names: [] });
selectedMindElementsRef.current = [];
updateBrowserFileParam(null);
} catch (error) {
console.error("[FlowBoard] Failed to delete cloud file:", error);
@ -1376,10 +1502,10 @@ export function DrawnixWorkspace({
</FloatingSidebarShell>
<FloatingSidebarShell
className="absolute left-6 top-[92px] z-20"
className="absolute top-9 right-[180px] z-20"
flowBoardControl
>
<FloatingSidebarRail className="max-h-[calc(100vh-172px)]">
<FloatingSidebarRail orientation="horizontal">
<FloatingSidebarAction
disabled={isSavingNow}
icon={Save}
@ -1406,9 +1532,14 @@ export function DrawnixWorkspace({
onClick={() => {
setIsFilePanelOpen(false);
setIsMarkerPanelOpen((open) => !open);
refreshMarkerSelectionState();
refreshMarkerSelectionState({ preserveFallback: true });
}}
/>
<FloatingSidebarAction
icon={GitMerge}
label="合并汇总"
onClick={handleInsertFlowBoardAbstract}
/>
<FloatingSidebarAction
icon={Plus}
label="新建"
@ -1519,6 +1650,9 @@ export function DrawnixWorkspace({
<aside
className="absolute top-20 right-4 bottom-5 z-30 flex w-[330px] max-w-[calc(100vw-32px)] flex-col overflow-hidden rounded-[28px] border border-black/10 bg-white shadow-[0_24px_80px_rgba(17,24,39,0.16)]"
data-flow-board-shell-control="true"
onMouseDownCapture={(event) => {
event.stopPropagation();
}}
>
<div className="flex items-center justify-between border-black/5 border-b px-5 py-4">
<div>
@ -1585,6 +1719,10 @@ export function DrawnixWorkspace({
: "border-black/10"
}`}
key={`${group.id}-${marker.name}`}
onMouseDown={(event) => {
event.preventDefault();
event.stopPropagation();
}}
onClick={() =>
handleApplyFlowBoardMarker(group, marker)
}
@ -1605,6 +1743,10 @@ export function DrawnixWorkspace({
<button
className="flex h-10 w-full items-center justify-center rounded-2xl border border-black/10 bg-white font-semibold text-[#4f566b] text-sm transition-colors hover:bg-[#f7f8fb] disabled:cursor-not-allowed disabled:opacity-50"
disabled={markerSelectionState.count === 0}
onMouseDown={(event) => {
event.preventDefault();
event.stopPropagation();
}}
onClick={handleClearFlowBoardMarkers}
type="button"
>

View File

@ -101,16 +101,22 @@ export function FloatingLogoPill({
type FloatingSidebarRailProps = {
children: ReactNode;
className?: string;
orientation?: "vertical" | "horizontal";
};
export function FloatingSidebarRail({
children,
className,
orientation = "vertical",
}: FloatingSidebarRailProps) {
const isHorizontal = orientation === "horizontal";
return (
<div
className={cn(
"pointer-events-auto flex w-[56px] flex-col items-center gap-1 overflow-y-auto rounded-[28px] border border-black/10 bg-white px-1.5 py-2.5 shadow",
"pointer-events-auto flex items-center gap-1 border border-black/10 bg-white shadow",
isHorizontal
? "h-[52px] w-auto flex-row overflow-hidden rounded-full px-2.5 py-1.5"
: "w-[56px] flex-col overflow-y-auto rounded-[28px] px-1.5 py-2.5",
className
)}
>
@ -146,6 +152,10 @@ export function FloatingSidebarAction({
"bg-[#111827] text-white shadow-sm hover:bg-[#1f2937] hover:text-white"
)}
disabled={disabled || loading}
onMouseDown={(event) => {
event.preventDefault();
event.stopPropagation();
}}
onClick={onClick}
type="button"
>