Skip to content

Commit 6e806cb

Browse files
committed
Add Cmd+Click to add child, Option+Cmd+Click to delete, global Cmd+Arrow navigation
1 parent 9f9ad2f commit 6e806cb

File tree

8 files changed

+165
-47
lines changed

8 files changed

+165
-47
lines changed

src/App.vue

Lines changed: 49 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1941,9 +1941,15 @@ function getSearchActionLabel(node) {
19411941
// are now provided by useCardDrag composable initialized above
19421942
19431943
function handleCardClick(e, node) {
1944-
if (e.ctrlKey || e.metaKey) {
1945-
// Toggle selection
1946-
handleMultiSelect({ node, add: true })
1944+
const hasCmd = e.ctrlKey || e.metaKey
1945+
const hasAlt = e.altKey
1946+
1947+
if (hasCmd && hasAlt) {
1948+
// Option+Cmd+click: delete the node
1949+
deleteNode(node.id)
1950+
} else if (hasCmd) {
1951+
// Cmd+click: add child node
1952+
addChildToCard(node.id, e)
19471953
} else if (e.shiftKey) {
19481954
// Range selection
19491955
handleMultiSelect({ node, range: true })
@@ -1954,10 +1960,17 @@ function handleCardClick(e, node) {
19541960
}
19551961
19561962
function handleChildCardClick(e, node) {
1957-
// Same as handleCardClick - supports Ctrl/Cmd+click and Shift+click
1958-
if (e.ctrlKey || e.metaKey) {
1959-
handleMultiSelect({ node, add: true })
1963+
const hasCmd = e.ctrlKey || e.metaKey
1964+
const hasAlt = e.altKey
1965+
1966+
if (hasCmd && hasAlt) {
1967+
// Option+Cmd+click: delete the node
1968+
deleteNode(node.id)
1969+
} else if (hasCmd) {
1970+
// Cmd+click: add child node
1971+
addChildToCard(node.id, e)
19601972
} else if (e.shiftKey) {
1973+
// Range selection
19611974
handleMultiSelect({ node, range: true })
19621975
} else {
19631976
// Normal click - select and open detail panel
@@ -2146,8 +2159,16 @@ let resizeObserver = null
21462159
* When not in input fields:
21472160
* - Cmd/Ctrl + Delete/Backspace: Delete selected items
21482161
* - Cmd/Ctrl + A: Select all visible items
2162+
* - Cmd/Ctrl + ArrowUp: Navigate to parent container
2163+
* - Cmd/Ctrl + ArrowDown: Navigate to first child
2164+
* - Cmd/Ctrl + ArrowLeft: Navigate to previous sibling
2165+
* - Cmd/Ctrl + ArrowRight: Navigate to next sibling
21492166
* - Escape: Exit fullscreen or clear selection
21502167
*
2168+
* Click modifiers (all views):
2169+
* - Cmd/Ctrl + Click: Add child to clicked item
2170+
* - Option + Cmd/Ctrl + Click: Delete clicked item
2171+
*
21512172
* Note: Plain Delete/Backspace without Cmd/Ctrl does NOT delete items
21522173
* to prevent accidental deletions.
21532174
*/
@@ -2206,6 +2227,28 @@ function handleKeydown(e) {
22062227
}
22072228
}
22082229
2230+
// Cmd/Ctrl + Arrow keys - navigation (works in all views)
2231+
if ((e.metaKey || e.ctrlKey) && e.key === 'ArrowUp') {
2232+
e.preventDefault()
2233+
goToParent()
2234+
return
2235+
}
2236+
if ((e.metaKey || e.ctrlKey) && e.key === 'ArrowDown') {
2237+
e.preventDefault()
2238+
goToFirstChild()
2239+
return
2240+
}
2241+
if ((e.metaKey || e.ctrlKey) && e.key === 'ArrowLeft') {
2242+
e.preventDefault()
2243+
goToPrevSibling()
2244+
return
2245+
}
2246+
if ((e.metaKey || e.ctrlKey) && e.key === 'ArrowRight') {
2247+
e.preventDefault()
2248+
goToNextSibling()
2249+
return
2250+
}
2251+
22092252
// Escape - exit fullscreen or clear selection (respects pin)
22102253
if (e.key === 'Escape') {
22112254
if (fullscreenDetail.value) {

src/__tests__/node-interactions.test.js

Lines changed: 49 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,8 @@ import {
1414
* - Hover: Light select (updates detail panel if open)
1515
* - Click: Select + open detail panel
1616
* - Double-click: Navigate into node
17-
* - Ctrl/Cmd + Click: Toggle persistent multi-select
17+
* - Cmd/Ctrl + Click: Add child node
18+
* - Option + Cmd/Ctrl + Click: Delete node
1819
* - Shift + Click: Range select
1920
* - Enter: Toggle detail panel
2021
*/
@@ -35,20 +36,20 @@ describe('Node Interactions', () => {
3536
describe('Click (Select + Open Detail)', () => {
3637
it('should call onSelect for plain click', () => {
3738
const callbacks = { onSelect: vi.fn() }
38-
const event = { ctrlKey: false, metaKey: false, shiftKey: false }
39+
const event = { ctrlKey: false, metaKey: false, shiftKey: false, altKey: false }
3940

4041
handleNodeClick(event, mockNode, callbacks)
4142

4243
expect(callbacks.onSelect).toHaveBeenCalledWith(mockNode)
4344
})
4445

45-
it('should NOT call onMultiSelect for plain click', () => {
46-
const callbacks = { onSelect: vi.fn(), onMultiSelect: vi.fn() }
47-
const event = { ctrlKey: false, metaKey: false, shiftKey: false }
46+
it('should NOT call onAddChild for plain click', () => {
47+
const callbacks = { onSelect: vi.fn(), onAddChild: vi.fn() }
48+
const event = { ctrlKey: false, metaKey: false, shiftKey: false, altKey: false }
4849

4950
handleNodeClick(event, mockNode, callbacks)
5051

51-
expect(callbacks.onMultiSelect).not.toHaveBeenCalled()
52+
expect(callbacks.onAddChild).not.toHaveBeenCalled()
5253
})
5354
})
5455

@@ -62,39 +63,68 @@ describe('Node Interactions', () => {
6263
})
6364
})
6465

65-
describe('Ctrl/Cmd + Click (Multi-select)', () => {
66-
it('should call onMultiSelect with add:true for Ctrl+click', () => {
67-
const callbacks = { onMultiSelect: vi.fn() }
68-
const event = { ctrlKey: true, metaKey: false, shiftKey: false }
66+
describe('Cmd/Ctrl + Click (Add Child)', () => {
67+
it('should call onAddChild for Ctrl+click', () => {
68+
const callbacks = { onAddChild: vi.fn() }
69+
const event = { ctrlKey: true, metaKey: false, shiftKey: false, altKey: false }
6970

7071
handleNodeClick(event, mockNode, callbacks)
7172

72-
expect(callbacks.onMultiSelect).toHaveBeenCalledWith(mockNode, { add: true })
73+
expect(callbacks.onAddChild).toHaveBeenCalledWith(mockNode)
7374
})
7475

75-
it('should call onMultiSelect with add:true for Cmd+click (macOS)', () => {
76-
const callbacks = { onMultiSelect: vi.fn() }
77-
const event = { ctrlKey: false, metaKey: true, shiftKey: false }
76+
it('should call onAddChild for Cmd+click (macOS)', () => {
77+
const callbacks = { onAddChild: vi.fn() }
78+
const event = { ctrlKey: false, metaKey: true, shiftKey: false, altKey: false }
7879

7980
handleNodeClick(event, mockNode, callbacks)
8081

81-
expect(callbacks.onMultiSelect).toHaveBeenCalledWith(mockNode, { add: true })
82+
expect(callbacks.onAddChild).toHaveBeenCalledWith(mockNode)
8283
})
8384

8485
it('should NOT call onSelect for Ctrl+click', () => {
85-
const callbacks = { onSelect: vi.fn(), onMultiSelect: vi.fn() }
86-
const event = { ctrlKey: true, metaKey: false, shiftKey: false }
86+
const callbacks = { onSelect: vi.fn(), onAddChild: vi.fn() }
87+
const event = { ctrlKey: true, metaKey: false, shiftKey: false, altKey: false }
8788

8889
handleNodeClick(event, mockNode, callbacks)
8990

9091
expect(callbacks.onSelect).not.toHaveBeenCalled()
9192
})
9293
})
9394

95+
describe('Option + Cmd/Ctrl + Click (Delete)', () => {
96+
it('should call onDelete for Option+Ctrl+click', () => {
97+
const callbacks = { onDelete: vi.fn() }
98+
const event = { ctrlKey: true, metaKey: false, shiftKey: false, altKey: true }
99+
100+
handleNodeClick(event, mockNode, callbacks)
101+
102+
expect(callbacks.onDelete).toHaveBeenCalledWith(mockNode)
103+
})
104+
105+
it('should call onDelete for Option+Cmd+click (macOS)', () => {
106+
const callbacks = { onDelete: vi.fn() }
107+
const event = { ctrlKey: false, metaKey: true, shiftKey: false, altKey: true }
108+
109+
handleNodeClick(event, mockNode, callbacks)
110+
111+
expect(callbacks.onDelete).toHaveBeenCalledWith(mockNode)
112+
})
113+
114+
it('should NOT call onAddChild for Option+Cmd+click', () => {
115+
const callbacks = { onAddChild: vi.fn(), onDelete: vi.fn() }
116+
const event = { ctrlKey: false, metaKey: true, shiftKey: false, altKey: true }
117+
118+
handleNodeClick(event, mockNode, callbacks)
119+
120+
expect(callbacks.onAddChild).not.toHaveBeenCalled()
121+
})
122+
})
123+
94124
describe('Shift + Click (Range select)', () => {
95125
it('should call onMultiSelect with range:true for Shift+click', () => {
96126
const callbacks = { onMultiSelect: vi.fn() }
97-
const event = { ctrlKey: false, metaKey: false, shiftKey: true }
127+
const event = { ctrlKey: false, metaKey: false, shiftKey: true, altKey: false }
98128

99129
handleNodeClick(event, mockNode, callbacks)
100130

@@ -103,7 +133,7 @@ describe('Node Interactions', () => {
103133

104134
it('should NOT call onSelect for Shift+click', () => {
105135
const callbacks = { onSelect: vi.fn(), onMultiSelect: vi.fn() }
106-
const event = { ctrlKey: false, metaKey: false, shiftKey: true }
136+
const event = { ctrlKey: false, metaKey: false, shiftKey: true, altKey: false }
107137

108138
handleNodeClick(event, mockNode, callbacks)
109139

src/components/GraphView.vue

Lines changed: 19 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1070,14 +1070,21 @@ async function initGraph() {
10701070
}
10711071
}])
10721072
1073-
// Click to select, Cmd/Ctrl+click to add to multi-selection, Shift+click for range
1073+
// Click to select, Cmd/Ctrl+click to add child, Option+Cmd/Ctrl+click to delete, Shift+click for range
10741074
cy.on('tap', 'node', (e) => {
10751075
const node = e.target.data('nodeData')
10761076
if (!node) return
10771077
1078-
if (e.originalEvent.metaKey || e.originalEvent.ctrlKey) {
1079-
// Cmd/Ctrl+click: add/toggle in multi-selection
1080-
emit('select-multiple', { node, add: true })
1078+
const hasCmd = e.originalEvent.metaKey || e.originalEvent.ctrlKey
1079+
const hasAlt = e.originalEvent.altKey
1080+
1081+
if (hasCmd && hasAlt) {
1082+
// Option+Cmd/Ctrl+click: delete the node
1083+
emit('delete', node.id)
1084+
} else if (hasCmd) {
1085+
// Cmd/Ctrl+click: add child node
1086+
const pos = e.target.position()
1087+
showAddNodeModal(node.id, { x: pos.x + 50, y: pos.y + 80 })
10811088
} else if (e.originalEvent.shiftKey) {
10821089
// Shift+click: range selection
10831090
emit('select-multiple', { node, range: true })
@@ -1146,26 +1153,27 @@ async function initGraph() {
11461153
saveNodePositions()
11471154
})
11481155
1149-
// Click on edge to insert node between
1156+
// Click on edge: Cmd+click to insert node between, Option+Cmd+click to delete edge
11501157
cy.on('tap', 'edge', async (e) => {
11511158
const edge = e.target
11521159
const sourceId = parseInt(edge.source().id())
11531160
const targetId = parseInt(edge.target().id())
11541161
const sourceNode = edge.source().data('nodeData')
11551162
const targetNode = edge.target().data('nodeData')
11561163
const isLinkEdge = edge.data('isLink')
1157-
const isCmdClick = e.originalEvent?.metaKey || e.originalEvent?.ctrlKey
1164+
const hasCmd = e.originalEvent?.metaKey || e.originalEvent?.ctrlKey
1165+
const hasAlt = e.originalEvent?.altKey
11581166
11591167
if (sourceNode && targetNode) {
1160-
if (isCmdClick) {
1161-
// Cmd+click - delete/remove the edge
1168+
if (hasCmd && hasAlt) {
1169+
// Option+Cmd+click - delete/remove the edge
11621170
if (isLinkEdge) {
11631171
emit('unlink', { sourceId, targetId })
11641172
} else {
11651173
emit('move', { nodeId: targetId, oldParentId: sourceId, newParentId: null })
11661174
}
1167-
} else {
1168-
// Normal click - show add node modal for insert between
1175+
} else if (hasCmd) {
1176+
// Cmd+click - show add node modal for insert between
11691177
const midPos = {
11701178
x: (edge.source().position().x + edge.target().position().x) / 2,
11711179
y: (edge.source().position().y + edge.target().position().y) / 2
@@ -1176,6 +1184,7 @@ async function initGraph() {
11761184
isLink: isLinkEdge
11771185
})
11781186
}
1187+
// Normal click - no action (could select edge in future)
11791188
}
11801189
})
11811190

src/components/NodeCard.vue

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ const props = defineProps({
88
hideSensitive: Boolean
99
})
1010
11-
const emit = defineEmits(['select', 'toggle-complete', 'update-notes'])
11+
const emit = defineEmits(['select', 'toggle-complete', 'update-notes', 'add-child', 'delete'])
1212
1313
const typeLabel = computed(() => getTypeIcon(props.node.type))
1414
const isPerson = computed(() => props.node.type === 'person')
@@ -30,8 +30,17 @@ watch(() => props.node.notes, (newNotes) => {
3030
}
3131
}, { immediate: true })
3232
33-
function selectNode() {
34-
emit('select', props.node)
33+
function selectNode(event) {
34+
const hasCmd = event.metaKey || event.ctrlKey
35+
const hasAlt = event.altKey
36+
37+
if (hasCmd && hasAlt) {
38+
emit('delete', props.node.id)
39+
} else if (hasCmd) {
40+
emit('add-child', { parentId: props.node.id, title: '', prompt: true })
41+
} else {
42+
emit('select', props.node)
43+
}
3544
}
3645
3746
function toggleComplete(event) {
@@ -73,7 +82,7 @@ function handleNotesKeydown(event) {
7382
<div
7483
class="node-card"
7584
:class="[{ selected }, `type-${node.type}`]"
76-
@click="selectNode"
85+
@click="selectNode($event)"
7786
>
7887
<div class="node-card-header">
7988
<span v-if="isPerson" class="node-card-type person" :style="personStyle" v-html="personIconSvg"></span>

src/components/TableView.vue

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,8 @@ const { handleHover, handleLeave, handleClick, handleDoubleClick } = useNodeInte
102102
onSelect: (node) => emit('select', node), // Full select + open detail on click
103103
onNavigate: (node) => emit('enter', node), // Navigate on double-click
104104
onMultiSelect: (node, opts) => emit('select-multiple', { node, ...opts }),
105+
onAddChild: (node) => emit('add-child', { parentId: node.id, title: '', prompt: true }),
106+
onDelete: (node) => emit('delete', node.id),
105107
getShowDetail: () => props.showDetail,
106108
showTooltip,
107109
hideTooltip

src/components/TimelineView.vue

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,28 @@ const props = defineProps({
99
colorMap: { type: Object, default: () => ({}) }
1010
})
1111
12-
const emit = defineEmits(['select', 'enter', 'show-tooltip', 'hide-tooltip', 'context-menu'])
12+
const emit = defineEmits(['select', 'enter', 'show-tooltip', 'hide-tooltip', 'context-menu', 'add-child', 'delete'])
1313
1414
// Context menu handler
1515
function handleContextMenu(e, node) {
1616
e.preventDefault()
1717
emit('context-menu', { event: e, node })
1818
}
1919
20+
// Click handler with modifier support
21+
function handleNodeClick(e, node) {
22+
const hasCmd = e.metaKey || e.ctrlKey
23+
const hasAlt = e.altKey
24+
25+
if (hasCmd && hasAlt) {
26+
emit('delete', node.id)
27+
} else if (hasCmd) {
28+
emit('add-child', { parentId: node.id, title: '', prompt: true })
29+
} else {
30+
emit('select', node)
31+
}
32+
}
33+
2034
// Zoom level: pixels per day (higher = more zoomed in)
2135
const zoomLevel = ref(20)
2236
const minZoom = 5
@@ -409,7 +423,7 @@ watch(() => props.nodes, () => {
409423
:key="'label-' + node.id"
410424
class="row-label"
411425
:class="{ selected: selectedId === node.id }"
412-
@click="emit('select', node)"
426+
@click="handleNodeClick($event, node)"
413427
@mouseenter="emit('show-tooltip', $event, node)"
414428
@mouseleave="emit('hide-tooltip')"
415429
@contextmenu.prevent="handleContextMenu($event, node)"
@@ -516,7 +530,7 @@ watch(() => props.nodes, () => {
516530
class="timeline-bar"
517531
:class="{ selected: selectedId === node.id, completed: node.completed }"
518532
:style="getBarStyle(node)"
519-
@click="emit('select', node)"
533+
@click="handleNodeClick($event, node)"
520534
@dblclick="emit('enter', node)"
521535
@mouseenter="emit('show-tooltip', $event, node)"
522536
@mouseleave="emit('hide-tooltip')"

0 commit comments

Comments
 (0)