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,