From ddeb6000c8df6d03ff97826fdbcf4152161e6ff0 Mon Sep 17 00:00:00 2001 From: Quang Tran <16215255+trmquang93@users.noreply.github.com> Date: Fri, 3 Apr 2026 09:32:37 +0700 Subject: [PATCH] feat: duplicate selected screens with hotspots and connections (Cmd+D) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds duplicateSelection() to useScreenManager that deep-clones a set of screens — remapping all screen, hotspot, stateGroup, and conditionGroupId references — and clones only connections whose both endpoints are within the selection. Exposed via Cmd+D keyboard shortcut and a context menu option that labels itself "Duplicate Screen" or "Duplicate Selection" based on multi-select state. The entire operation is a single undo step. --- src/Drawd.jsx | 7 ++- src/components/CanvasArea.jsx | 31 +++++++++ src/constants.js | 1 + src/hooks/useKeyboardShortcuts.js | 19 +++++- src/hooks/useScreenManager.js | 100 ++++++++++++++++++++++++++++++ 5 files changed, 155 insertions(+), 3 deletions(-) diff --git a/src/Drawd.jsx b/src/Drawd.jsx index 8e9945a..415b056 100644 --- a/src/Drawd.jsx +++ b/src/Drawd.jsx @@ -52,7 +52,7 @@ export default function Drawd({ initialRoomCode }) { updateConnection, deleteConnection, addConnection, convertToConditionalGroup, addToConditionalGroup, saveConnectionGroup, deleteConnectionGroup, addState, linkAsState, updateStateName, addDocument, updateDocument, deleteDocument, - replaceAll, mergeAll, + replaceAll, mergeAll, duplicateSelection, canUndo, canRedo, undo, redo, captureDragSnapshot, commitDragSnapshot, updateScreenStatus, markAllExisting, } = useScreenManager(pan, zoom, canvasRef); @@ -358,7 +358,7 @@ export default function Drawd({ initialRoomCode }) { hotspotInteraction, cancelHotspotInteraction, selectedConnection, setSelectedConnection, selectedHotspots, setSelectedHotspots, - canvasSelection, clearSelection, removeScreens, deleteStickyNote, addScreenGroup, screens, + canvasSelection, setCanvasSelection, clearSelection, removeScreens, deleteStickyNote, addScreenGroup, screens, connections, deleteHotspot, deleteHotspots, deleteConnection, deleteConnectionGroup, selectedScreen, removeScreen, selectedStickyNote, setSelectedStickyNote, @@ -367,6 +367,7 @@ export default function Drawd({ initialRoomCode }) { setActiveTool, onTemplates, isReadOnly, + duplicateSelection, }); // ── Derived values ────────────────────────────────────────────────────────────────── @@ -538,6 +539,8 @@ export default function Drawd({ initialRoomCode }) { setEditingConditionGroup={setEditingConditionGroup} groupContextMenu={groupContextMenu} setGroupContextMenu={setGroupContextMenu} + duplicateSelection={duplicateSelection} + setCanvasSelection={setCanvasSelection} handleImageUpload={handleImageUpload} addScreenAtCenter={addScreenAtCenter} isDraggingOver={isDraggingOver} diff --git a/src/components/CanvasArea.jsx b/src/components/CanvasArea.jsx index 42f5e11..42cef4c 100644 --- a/src/components/CanvasArea.jsx +++ b/src/components/CanvasArea.jsx @@ -50,6 +50,7 @@ export function CanvasArea({ editingConditionGroup, updateConnection, setEditingConditionGroup, // Group context menu groupContextMenu, setGroupContextMenu, + duplicateSelection, setCanvasSelection, // ToolBar setActiveTool, handleImageUpload, addScreenAtCenter, // Drop zone overlay @@ -322,6 +323,36 @@ export function CanvasArea({ }} onMouseLeave={() => setGroupContextMenu(null)} > + {!isReadOnly && ( + <> + +
+ + )}
{ const onKeyDown = (e) => { @@ -200,6 +203,20 @@ export function useKeyboardShortcuts({ return; } + // Duplicate selection (Cmd+D) + if ((e.metaKey || e.ctrlKey) && e.key === "d") { + const tag = document.activeElement?.tagName; + if (tag === "INPUT" || tag === "TEXTAREA") return; + if (anyModalOpen) return; + if (isReadOnly) return; + e.preventDefault(); + const screenIds = canvasSelection.filter((i) => i.type === "screen").map((i) => i.id); + if (screenIds.length === 0) return; + const newIds = duplicateSelection(screenIds); + setCanvasSelection(newIds.map((id) => ({ type: "screen", id }))); + return; + } + // Save shortcut (Cmd+S) if ((e.metaKey || e.ctrlKey) && e.key === "s") { e.preventDefault(); @@ -251,6 +268,6 @@ export function useKeyboardShortcuts({ deleteHotspot, selectedStickyNote, setSelectedStickyNote, deleteStickyNote, selectedScreenGroup, setSelectedScreenGroup, deleteScreenGroup, setActiveTool, canvasSelection, clearSelection, removeScreens, addScreenGroup, screens, - onTemplates, isReadOnly, + onTemplates, isReadOnly, duplicateSelection, setCanvasSelection, ]); } diff --git a/src/hooks/useScreenManager.js b/src/hooks/useScreenManager.js index a2e16ad..de8ba13 100644 --- a/src/hooks/useScreenManager.js +++ b/src/hooks/useScreenManager.js @@ -11,6 +11,7 @@ import { GRID_MARGIN, PASTE_STAGGER, STATE_VARIANT_OFFSET, + DUPLICATE_OFFSET, HOTSPOT_PASTE_OFFSET, VIEWPORT_FALLBACK_WIDTH, VIEWPORT_FALLBACK_HEIGHT, @@ -903,6 +904,104 @@ export function useScreenManager(pan, zoom, canvasRef) { setDocuments((prev) => [...prev, ...newDocuments]); }, [clearHistory]); + const duplicateSelection = useCallback((selectedScreenIds) => { + if (!selectedScreenIds || selectedScreenIds.length === 0) return []; + + pushHistory(screens, connections, documents); + + const selectionSet = new Set(selectedScreenIds); + + // Build screen ID remap table + const screenIdMap = new Map(); + selectedScreenIds.forEach((id) => screenIdMap.set(id, generateId())); + + // Build hotspot ID remap table + const hotspotIdMap = new Map(); + screens.forEach((s) => { + if (!selectionSet.has(s.id)) return; + s.hotspots.forEach((h) => hotspotIdMap.set(h.id, generateId())); + }); + + // Build stateGroup remap table — only remap groups where ALL members are selected + const stateGroupMembers = new Map(); // stateGroup -> Set of screen IDs + screens.forEach((s) => { + if (!s.stateGroup) return; + if (!stateGroupMembers.has(s.stateGroup)) stateGroupMembers.set(s.stateGroup, new Set()); + stateGroupMembers.get(s.stateGroup).add(s.id); + }); + const stateGroupMap = new Map(); + stateGroupMembers.forEach((members, groupId) => { + const allSelected = [...members].every((id) => selectionSet.has(id)); + if (allSelected) stateGroupMap.set(groupId, generateId()); + }); + + // Build conditionGroupId remap table from connections being duplicated + const conditionGroupMap = new Map(); + connections.forEach((c) => { + if (!c.conditionGroupId) return; + if (!selectionSet.has(c.fromScreenId) || !selectionSet.has(c.toScreenId)) return; + if (!conditionGroupMap.has(c.conditionGroupId)) { + conditionGroupMap.set(c.conditionGroupId, generateId()); + } + }); + + // Helper to remap a target screen ID (only if it's in the selection) + const remapTarget = (id) => (id && screenIdMap.has(id) ? screenIdMap.get(id) : id); + + // Clone screens + const clonedScreens = screens + .filter((s) => selectionSet.has(s.id)) + .map((s) => { + const clonedHotspots = s.hotspots.map((h) => { + const cloned = { ...h, id: hotspotIdMap.get(h.id) ?? generateId() }; + if (cloned.targetScreenId) cloned.targetScreenId = remapTarget(cloned.targetScreenId); + if (cloned.onSuccessTargetId) cloned.onSuccessTargetId = remapTarget(cloned.onSuccessTargetId); + if (cloned.onErrorTargetId) cloned.onErrorTargetId = remapTarget(cloned.onErrorTargetId); + if (Array.isArray(cloned.conditions)) { + cloned.conditions = cloned.conditions.map((cond) => + cond.targetScreenId + ? { ...cond, targetScreenId: remapTarget(cond.targetScreenId) } + : cond + ); + } + return cloned; + }); + + return { + ...s, + id: screenIdMap.get(s.id), + name: s.name ? `${s.name} (copy)` : s.name, + x: s.x + DUPLICATE_OFFSET, + stateGroup: s.stateGroup && stateGroupMap.has(s.stateGroup) + ? stateGroupMap.get(s.stateGroup) + : null, + stateName: s.stateGroup && stateGroupMap.has(s.stateGroup) ? s.stateName : "", + hotspots: clonedHotspots, + }; + }); + + // Clone connections where both endpoints are in the selection + const clonedConnections = connections + .filter((c) => selectionSet.has(c.fromScreenId) && selectionSet.has(c.toScreenId)) + .map((c) => ({ + ...c, + id: generateId(), + fromScreenId: screenIdMap.get(c.fromScreenId), + toScreenId: screenIdMap.get(c.toScreenId), + hotspotId: c.hotspotId && hotspotIdMap.has(c.hotspotId) + ? hotspotIdMap.get(c.hotspotId) + : c.hotspotId, + conditionGroupId: c.conditionGroupId && conditionGroupMap.has(c.conditionGroupId) + ? conditionGroupMap.get(c.conditionGroupId) + : c.conditionGroupId, + })); + + setScreens((prev) => [...prev, ...clonedScreens]); + setConnections((prev) => [...prev, ...clonedConnections]); + + return clonedScreens.map((s) => s.id); + }, [screens, connections, documents, pushHistory]); + return { screens, connections, @@ -956,6 +1055,7 @@ export function useScreenManager(pan, zoom, canvasRef) { deleteDocument, replaceAll, mergeAll, + duplicateSelection, canUndo, canRedo, undo,