Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions src/Drawd.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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,
Expand All @@ -367,6 +367,7 @@ export default function Drawd({ initialRoomCode }) {
setActiveTool,
onTemplates,
isReadOnly,
duplicateSelection,
});

// ── Derived values ──────────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -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}
Expand Down
31 changes: 31 additions & 0 deletions src/components/CanvasArea.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ export function CanvasArea({
editingConditionGroup, updateConnection, setEditingConditionGroup,
// Group context menu
groupContextMenu, setGroupContextMenu,
duplicateSelection, setCanvasSelection,
// ToolBar
setActiveTool, handleImageUpload, addScreenAtCenter,
// Drop zone overlay
Expand Down Expand Up @@ -322,6 +323,36 @@ export function CanvasArea({
}}
onMouseLeave={() => setGroupContextMenu(null)}
>
{!isReadOnly && (
<>
<button
onClick={() => {
const selScreenIds = canvasSelection.filter((i) => i.type === "screen").map((i) => i.id);
const ids = selScreenIds.length > 0 ? selScreenIds : [groupContextMenu.screenId];
const newIds = duplicateSelection(ids);
setCanvasSelection(newIds.map((id) => ({ type: "screen", id })));
setGroupContextMenu(null);
}}
style={{
display: "block",
width: "100%",
padding: "6px 14px",
background: "none",
border: "none",
color: COLORS.text,
fontFamily: FONTS.mono,
fontSize: 12,
textAlign: "left",
cursor: "pointer",
}}
>
{canvasSelection.filter((i) => i.type === "screen").length > 1
? "Duplicate Selection"
: "Duplicate Screen"}
</button>
<div style={{ height: 1, background: COLORS.border, margin: "4px 0" }} />
</>
)}
<div style={{
fontSize: 9,
color: COLORS.textDim,
Expand Down
1 change: 1 addition & 0 deletions src/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ export const GRID_ROW_HEIGHT = 420;
export const GRID_MARGIN = 60;
export const PASTE_STAGGER = 30;
export const STATE_VARIANT_OFFSET = 250;
export const DUPLICATE_OFFSET = 300;
export const MERGE_GAP = 300;
export const DROP_OVERLAP_MARGIN = 40;
export const CENTER_HEIGHT_ESTIMATE = 160;
Expand Down
19 changes: 18 additions & 1 deletion src/hooks/useKeyboardShortcuts.js
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,9 @@ export function useKeyboardShortcuts({
onTemplates,
// collaboration
isReadOnly,
// duplication
duplicateSelection,
setCanvasSelection,
}) {
useEffect(() => {
const onKeyDown = (e) => {
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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,
]);
}
100 changes: 100 additions & 0 deletions src/hooks/useScreenManager.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
GRID_MARGIN,
PASTE_STAGGER,
STATE_VARIANT_OFFSET,
DUPLICATE_OFFSET,
HOTSPOT_PASTE_OFFSET,
VIEWPORT_FALLBACK_WIDTH,
VIEWPORT_FALLBACK_HEIGHT,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -956,6 +1055,7 @@ export function useScreenManager(pan, zoom, canvasRef) {
deleteDocument,
replaceAll,
mergeAll,
duplicateSelection,
canUndo,
canRedo,
undo,
Expand Down
Loading