From f98101d734d8bd0b7306e20f4e6959425698d4ff Mon Sep 17 00:00:00 2001 From: tianma-if Date: Tue, 8 Sep 2026 09:59:29 +0800 Subject: [PATCH] fix(web): keep architecture library drops on the canvas Keep the shape library open while dragging, accept drops on the scroller surface, and use a text/plain fallback so dragged components stay at the drop point instead of snapping back. --- .../src/components/DiagramEditorPane.test.mjs | 4 ++ apps/web/src/components/DiagramEditorPane.tsx | 58 ++++++++++++++----- 2 files changed, 49 insertions(+), 13 deletions(-) diff --git a/apps/web/src/components/DiagramEditorPane.test.mjs b/apps/web/src/components/DiagramEditorPane.test.mjs index 4f07e561..a36d7076 100644 --- a/apps/web/src/components/DiagramEditorPane.test.mjs +++ b/apps/web/src/components/DiagramEditorPane.test.mjs @@ -246,6 +246,9 @@ describe("diagram editor canvas surface", () => { expect(source).toContain(' {"); expect(source).toContain("onDragOver={handleArchitectureDragOver}"); expect(source).toContain("onDrop={handleArchitectureDrop}"); expect(source).toContain("graph.clientToLocal({ x: event.clientX, y: event.clientY })"); @@ -253,6 +256,7 @@ describe("diagram editor canvas surface", () => { expect(source).toContain("x: options.position.x - authoredSize.width / 2"); expect(source).toContain("onPick={setPendingArchitectureItem}"); expect(source).toContain("onPointerDownCapture={handlePendingArchitecturePlacement}"); + expect(source).toContain("ref={canvasSurfaceRef}"); expect(source).toContain('t("diagram.placeShapeHint"'); }); diff --git a/apps/web/src/components/DiagramEditorPane.tsx b/apps/web/src/components/DiagramEditorPane.tsx index 7ad0daf3..428132f3 100644 --- a/apps/web/src/components/DiagramEditorPane.tsx +++ b/apps/web/src/components/DiagramEditorPane.tsx @@ -312,6 +312,16 @@ const inferArchitectureResourceIcon = ( return item ? architectureResourceIcon(item) : undefined; }; +const isArchitectureLibraryDrag = (event: ReactDragEvent) => { + const types = Array.from(event.dataTransfer.types).map((type) => type.toLowerCase()); + return types.includes(ARCHITECTURE_LIBRARY_DRAG_TYPE) || types.includes("text/plain"); +}; + +const architectureLibraryDragIcon = (event: ReactDragEvent) => ( + event.dataTransfer.getData(ARCHITECTURE_LIBRARY_DRAG_TYPE) + || event.dataTransfer.getData("text/plain") +) as ArchitectureResourceIcon; + const ArchitectureComponentLibrary = ({ onPick, t, @@ -321,6 +331,7 @@ const ArchitectureComponentLibrary = ({ }) => { const [open, setOpen] = useState(false); const [query, setQuery] = useState(""); + const draggingRef = useRef(false); const normalizedQuery = query.trim().toLocaleLowerCase(); const categories = ARCHITECTURE_LIBRARY_CATEGORIES.map((category) => ({ ...category, @@ -329,11 +340,21 @@ const ArchitectureComponentLibrary = ({ return ( { + if (!nextOpen && draggingRef.current) return; setOpen(nextOpen); if (!nextOpen) setQuery(""); }}> setOpen(true)} /> - + { + if (draggingRef.current) event.preventDefault(); + }} + onFocusOutside={(event) => { + if (draggingRef.current) event.preventDefault(); + }} + >
@@ -369,11 +390,19 @@ const ArchitectureComponentLibrary = ({ aria-label={label} className={cn("flex h-10 w-10 cursor-grab justify-center rounded-lg p-0 hover:bg-current/10 focus:bg-current/10 active:cursor-grabbing", category.tone)} draggable + onPointerDown={(event) => event.stopPropagation()} onDragStart={(event) => { + draggingRef.current = true; + event.stopPropagation(); + const icon = architectureResourceIcon(item); event.dataTransfer.effectAllowed = "copy"; - event.dataTransfer.setData(ARCHITECTURE_LIBRARY_DRAG_TYPE, architectureResourceIcon(item)); + event.dataTransfer.setData("text/plain", icon); + event.dataTransfer.setData(ARCHITECTURE_LIBRARY_DRAG_TYPE, icon); + }} + onDragEnd={() => { + draggingRef.current = false; + setOpen(false); }} - onDragEnd={() => setOpen(false)} onSelect={() => { onPick(item); setOpen(false); @@ -1897,26 +1926,26 @@ export const DiagramEditorPane = ({ }, [addNode, t]); const handleArchitectureDragOver = (event: ReactDragEvent) => { - if (document?.kind !== "architecture" || readOnly || !Array.from(event.dataTransfer.types).includes(ARCHITECTURE_LIBRARY_DRAG_TYPE)) return; + if (document?.kind !== "architecture" || readOnly || !isArchitectureLibraryDrag(event)) return; event.preventDefault(); event.dataTransfer.dropEffect = "copy"; }; const handleArchitectureDrop = (event: ReactDragEvent) => { - if (document?.kind !== "architecture" || readOnly) return; - const resourceIcon = event.dataTransfer.getData(ARCHITECTURE_LIBRARY_DRAG_TYPE) as ArchitectureResourceIcon; + if (document?.kind !== "architecture" || readOnly || !isArchitectureLibraryDrag(event)) return; + event.preventDefault(); + event.stopPropagation(); + const resourceIcon = architectureLibraryDragIcon(event); const item = ARCHITECTURE_LIBRARY_ITEMS.find((candidate) => architectureResourceIcon(candidate) === resourceIcon); const graph = graphRef.current; if (!item || !graph) return; - event.preventDefault(); - event.stopPropagation(); placeArchitectureItem(item, graph.clientToLocal({ x: event.clientX, y: event.clientY })); }; const handlePendingArchitecturePlacement = (event: ReactPointerEvent) => { if (!pendingArchitectureItem || document?.kind !== "architecture" || readOnly || event.button !== 0) return; const target = event.target instanceof Element ? event.target : null; - if (target?.closest(".x6-node, .x6-edge")) return; + if (target?.closest(".x6-node, .x6-edge, input, textarea, [role='dialog']")) return; const graph = graphRef.current; if (!graph) return; event.preventDefault(); @@ -2544,7 +2573,13 @@ export const DiagramEditorPane = ({ )} theme={theme} /> -
+
{!readOnly && (