Repository navigation
Closed as not planned
Description
Multi-Select: Ctrl+Click Individual Block Toggle
Implement Ctrl+Click (Cmd+Click on Mac) functionality to toggle individual blocks in and out of selection, enabling non-contiguous multi-block selection patterns.
Dependencies
- Foundation: Single-Block Selection with Visual Highlighting #35 (Foundation: Single-Block Selection) - Must be completed for base selection functionality
- Range Selection: Shift+Click Multi-Block Selection #36 (Range Selection: Shift+Click Multi-Block Selection) - Should be completed for full selection context
Related Issues
- Part of multi-node selection epic (multi-select component)
Requirements
Multi-Select Toggle Mechanics
- Ctrl+Click Behavior: Ctrl+Click (Cmd+Click on Mac) toggles individual block selection state
- Additive Selection: New selections add to existing selection rather than replacing
- Toggle Functionality: Clicking selected block with Ctrl removes it from selection
- Non-Contiguous Selection: Support selecting blocks that are not adjacent in hierarchy
- Platform Awareness: Detect Ctrl (Windows/Linux) vs Cmd (Mac) modifier keys
- File Location: Extend
src/lib/stores/selectionStore.tswith multi-select logic
Selection State Management
- Multi-Select Mode: Track when selection is in multi-select vs range vs single mode
- Selection Persistence: Maintain multi-select state across interactions
- Mode Transitions: Smooth transitions between single, range, and multi-select modes
- State Validation: Ensure selection state remains consistent and valid
- File Location: Enhance selection store with multi-select state management
Combined Interaction Patterns
- Modifier Key Combinations: Support Shift+Ctrl+Click for range extension in multi-select
- Mode Switching: Clear transitions between different selection modes
- Visual Feedback: Consistent highlighting across all selection types
- Interaction Priority: Proper handling when multiple modifier keys are pressed
- File Location: Extend BaseNode click handling for complex modifier interactions
Technical Implementation Notes
Enhanced Selection State Interface
// src/lib/stores/selectionStore.ts - Multi-select support
interface SelectionState {
selectedBlocks: string[];
mode: 'single' | 'range' | 'multi';
rangeStart: string | null;
rangeEnd: string | null;
multiSelectAnchor: string | null; // First block in multi-select session
direction: 'up' | 'down' | null;
lastActionType: 'single' | 'range' | 'multi-add' | 'multi-remove' | 'clear';
}
interface SelectionStore extends Readable<SelectionState> {
selectBlock: (blockId: string) => void;
selectRange: (startId: string, endId: string) => void;
extendRange: (targetId: string) => void;
toggleBlock: (blockId: string) => void; // New: Multi-select toggle
addToSelection: (blockId: string) => void; // New: Add block to multi-select
removeFromSelection: (blockId: string) => void; // New: Remove block from selection
clearSelection: () => void;
isSelected: (blockId: string) => boolean;
getSelectedCount: () => number;
getSelectionMode: () => 'single' | 'range' | 'multi';
}Platform-Aware Modifier Detection
// src/lib/utils/modifierKeys.ts
export class ModifierKeyDetector {
/**
* Detect if the primary multi-select modifier is pressed
* Ctrl on Windows/Linux, Cmd on Mac
*/
static isMultiSelectModifier(event: MouseEvent | KeyboardEvent): boolean {
const isMac = navigator.platform.toUpperCase().indexOf('MAC') >= 0;
return isMac ? event.metaKey : event.ctrlKey;
}
/**
* Detect if range selection modifier is pressed
*/
static isRangeSelectModifier(event: MouseEvent | KeyboardEvent): boolean {
return event.shiftKey;
}
/**
* Get human-readable modifier key name for current platform
*/
static getMultiSelectKeyName(): string {
const isMac = navigator.platform.toUpperCase().indexOf('MAC') >= 0;
return isMac ? 'Cmd' : 'Ctrl';
}
/**
* Determine selection action based on modifier keys
*/
static getSelectionAction(event: MouseEvent): SelectionAction {
const hasMultiSelect = this.isMultiSelectModifier(event);
const hasRangeSelect = this.isRangeSelectModifier(event);
if (hasRangeSelect && hasMultiSelect) {
return 'multi-range-extend'; // Future: Range extend in multi-select mode
} else if (hasRangeSelect) {
return 'range-extend';
} else if (hasMultiSelect) {
return 'multi-toggle';
} else {
return 'single-select';
}
}
}
type SelectionAction = 'single-select' | 'range-extend' | 'multi-toggle' | 'multi-range-extend';Enhanced Selection Store Implementation
// src/lib/stores/selectionStore.ts - Multi-select implementation
import { writable, derived } from 'svelte/store';
import type { Readable } from 'svelte/store';
class SelectionStoreImpl implements SelectionStore {
private _store = writable<SelectionState>({
selectedBlocks: [],
mode: 'single',
rangeStart: null,
rangeEnd: null,
multiSelectAnchor: null,
direction: null,
lastActionType: 'clear'
});
subscribe = this._store.subscribe;
selectBlock(blockId: string): void {
this._store.update(state => ({
...state,
selectedBlocks: [blockId],
mode: 'single',
rangeStart: blockId,
rangeEnd: null,
multiSelectAnchor: null,
direction: null,
lastActionType: 'single'
}));
}
toggleBlock(blockId: string): void {
this._store.update(state => {
const isSelected = state.selectedBlocks.includes(blockId);
let newSelectedBlocks: string[];
let newMode: SelectionState['mode'];
let lastActionType: SelectionState['lastActionType'];
if (isSelected) {
// Remove block from selection
newSelectedBlocks = state.selectedBlocks.filter(id => id \!== blockId);
lastActionType = 'multi-remove';
} else {
// Add block to selection
newSelectedBlocks = [...state.selectedBlocks, blockId];
lastActionType = 'multi-add';
}
// Determine new mode based on selection count
if (newSelectedBlocks.length === 0) {
newMode = 'single';
} else if (newSelectedBlocks.length === 1) {
newMode = 'single';
} else {
newMode = 'multi';
}
return {
...state,
selectedBlocks: newSelectedBlocks,
mode: newMode,
multiSelectAnchor: state.multiSelectAnchor || blockId,
rangeStart: null, // Clear range when in multi-select mode
rangeEnd: null,
direction: null,
lastActionType
};
});
}
addToSelection(blockId: string): void {
this._store.update(state => {
if (state.selectedBlocks.includes(blockId)) {
return state; // Already selected
}
const newSelectedBlocks = [...state.selectedBlocks, blockId];
return {
...state,
selectedBlocks: newSelectedBlocks,
mode: newSelectedBlocks.length > 1 ? 'multi' : 'single',
multiSelectAnchor: state.multiSelectAnchor || blockId,
lastActionType: 'multi-add'
};
});
}
removeFromSelection(blockId: string): void {
this._store.update(state => {
const newSelectedBlocks = state.selectedBlocks.filter(id => id \!== blockId);
return {
...state,
selectedBlocks: newSelectedBlocks,
mode: newSelectedBlocks.length > 1 ? 'multi' : 'single',
lastActionType: 'multi-remove'
};
});
}
clearSelection(): void {
this._store.update(state => ({
...state,
selectedBlocks: [],
mode: 'single',
rangeStart: null,
rangeEnd: null,
multiSelectAnchor: null,
direction: null,
lastActionType: 'clear'
}));
}
isSelected(blockId: string): boolean {
let isSelected = false;
this._store.subscribe(state => {
isSelected = state.selectedBlocks.includes(blockId);
})();
return isSelected;
}
getSelectedCount(): number {
let count = 0;
this._store.subscribe(state => {
count = state.selectedBlocks.length;
})();
return count;
}
getSelectionMode(): SelectionState['mode'] {
let mode: SelectionState['mode'] = 'single';
this._store.subscribe(state => {
mode = state.mode;
})();
return mode;
}
}
export const selectionStore = new SelectionStoreImpl();Enhanced BaseNode Click Handling
<\!-- src/lib/design/components/BaseNode.svelte - Multi-select integration -->
<script lang="ts">
import { selectionStore } from '$lib/stores/selectionStore';
import { ModifierKeyDetector } from '$lib/utils/modifierKeys';
export let nodeId: string;
$: isSelected = $selectionStore.selectedBlocks.includes(nodeId);
$: selectionMode = $selectionStore.mode;
function handleClick(event: MouseEvent) {
const action = ModifierKeyDetector.getSelectionAction(event);
switch (action) {
case 'single-select':
selectionStore.selectBlock(nodeId);
break;
case 'range-extend':
selectionStore.extendRange(nodeId);
break;
case 'multi-toggle':
selectionStore.toggleBlock(nodeId);
break;
case 'multi-range-extend':
// Future implementation: Range selection within multi-select
console.log('Multi-range extend (future feature)');
break;
}
// Prevent text selection during multi-select operations
if (action \!== 'single-select') {
event.preventDefault();
}
}
function handleKeydown(event: KeyboardEvent) {
if (event.key === ' ' || event.key === 'Enter') {
const syntheticMouseEvent = {
shiftKey: event.shiftKey,
ctrlKey: event.ctrlKey,
metaKey: event.metaKey,
preventDefault: () => event.preventDefault()
} as MouseEvent;
handleClick(syntheticMouseEvent);
}
}
</script>
<div
class="base-node"
class:selected={isSelected}
class:multi-select-mode={selectionMode === 'multi'}
data-block-id={nodeId}
on:click={handleClick}
on:keydown={handleKeydown}
role="treeitem"
aria-selected={isSelected}
aria-describedby={selectionMode === 'multi' ? 'multi-select-help' : undefined}
tabindex="0"
>
<\!-- existing node content -->
{#if selectionMode === 'multi' && isSelected}
<div class="multi-select-indicator" aria-hidden="true">
✓
</div>
{/if}
</div>
<\!-- Hidden accessibility helper -->
<div id="multi-select-help" class="sr-only">
Use {ModifierKeyDetector.getMultiSelectKeyName()}+click to toggle selection.
Current selection: {$selectionStore.selectedBlocks.length} blocks.
</div>
<style>
.base-node {
transition: var(--selection-transition);
cursor: pointer;
position: relative;
}
.base-node.selected {
background-color: var(--selection-background);
}
.base-node.selected:hover {
background-color: var(--selection-background-hover);
}
.base-node.multi-select-mode {
/* Subtle visual cue for multi-select mode */
border-left: 2px solid transparent;
}
.base-node.multi-select-mode.selected {
border-left-color: var(--selection-border);
}
.multi-select-indicator {
position: absolute;
top: 4px;
right: 4px;
font-size: 12px;
color: var(--selection-border);
font-weight: bold;
pointer-events: none;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
</style>Acceptance Criteria
Multi-Select Toggle Behavior
- Ctrl+Click (Cmd+Click on Mac) toggles individual block selection state
- Clicking unselected block with Ctrl adds it to current selection
- Clicking selected block with Ctrl removes it from current selection
- Multi-select mode maintains selection of non-contiguous blocks
- Platform detection works correctly (Ctrl on Windows/Linux, Cmd on Mac)
Selection State Management
- Multi-select mode is tracked accurately in selection store
- Selection count reflects actual number of selected blocks
- Selection state persists across component re-renders and interactions
- Mode transitions (single → multi → single) work smoothly
- Selection state remains consistent during complex interaction sequences
Combined Modifier Interactions
- Single click (no modifiers) starts new single selection, clearing multi-select
- Shift+click works correctly when starting from multi-select state
- Ctrl+Shift+click combinations are handled gracefully (future extension point)
- Multiple rapid Ctrl+clicks maintain consistent state
- Modifier key detection is accurate across different browsers and platforms
Visual Feedback Requirements
- Selected blocks in multi-select mode show consistent highlighting
- Optional: Visual indicator (checkmark) appears on selected blocks in multi-select mode
- Selection highlighting remains consistent across single, range, and multi modes
- Transitions between selection modes have smooth visual feedback
- Multi-select mode provides subtle visual cue (border, indicator) when active
Performance Requirements
- Multi-select toggle operations respond within 16ms for smooth interaction
- Selection state updates efficiently with large numbers of selected blocks (100+)
- Memory usage remains stable during extended multi-select sessions
- DOM updates are batched to avoid layout thrashing
- Event handling scales efficiently with complex modifier key combinations
Accessibility Requirements
- Multi-selected blocks maintain proper ARIA selection attributes
- Screen readers announce multi-select state changes appropriately
- Keyboard users can toggle multi-select using Space/Enter with Ctrl
- Selection count is announced to screen readers when selection changes
- Platform-specific modifier key names are used in accessibility descriptions
Integration Requirements
- Builds on existing single-block and range selection functionality
- Compatible with all established BaseNode and NodeTree patterns
- Multi-select store can be extended for advanced features (copy/paste, bulk operations)
- No breaking changes to existing selection APIs or behavior
- Mock service integration supports multi-select patterns
Resources & References
Implementation Patterns
- Platform Detection: Cross-platform modifier key handling patterns
- Selection State: Complex state management for multiple selection modes
- Event Handling: Advanced click event processing with modifier combinations
- Visual Design: Multi-select indicators and state feedback systems
UX Research References
- Logseq Multi-Select: Analysis of Ctrl+click toggle implementation
- Desktop Applications: File manager and IDE multi-select patterns
- Web Standards: Accessibility guidelines for multi-select tree widgets
- Platform Conventions: OS-specific modifier key expectations
Architecture Documentation
- State Management: Complex state transitions and validation
- Event System: Advanced modifier key processing and event delegation
- Performance: Optimization strategies for complex selection operations
- Accessibility: WCAG compliance for multi-select interfaces
Definition of Done
Functionality
- Multi-select toggle works reliably across all scenarios and platforms
- All modifier key combinations handled correctly with proper state management
- Selection state remains consistent during complex interaction sequences
- Performance requirements met through profiling and optimization
- Integration with existing selection features is seamless and non-breaking
Code Quality
- TypeScript compilation passes with strict mode and comprehensive type coverage
- Platform detection logic is well-tested across different environments
- ESLint passes with no errors for complex event handling code
- Selection state management follows established patterns and best practices
- No memory leaks or performance regressions in selection operations
Testing
- Unit tests cover multi-select toggle logic thoroughly
- Integration tests verify behavior across different platform/browser combinations
- Complex modifier key scenarios tested (rapid clicks, edge cases)
- Accessibility testing confirms screen reader compatibility
- Performance testing validates responsiveness with large selections
Documentation
- Multi-select behavior documented with clear examples
- Platform-specific modifier key handling explained
- Complex state transitions documented for maintainability
- Extension points identified for future bulk operations
- Accessibility features documented for compliance verification
This completes the core multi-select functionality, enabling advanced features like bulk operations, copy/paste, and context menus in future issues.
Metadata
Metadata
Assignees
Labels
No labels
Activity
Update architecture documentation with advanced UI/UX capabilities
Update architecture documentation with advanced UI/UX capabilities
Update architecture documentation with advanced UI/UX capabilities
malibio commentedon Aug 3, 2026
Closing as superseded by #278 (Copy text across node boundaries).
This issue is part of the 2025 four-issue chain (#35 → #36 → #37 → #38) specifying a block-level, id-set selection model — a selection store of node ids, driven by click / shift+click / ctrl+click, intended to enable bulk operations on a set of nodes.
Two reasons for closing:
1. The specs are not implementable as written. They target a file layout that no longer exists:
src/lib/design/components/BaseNode.svelteandsrc/lib/components/NodeTree.svelte(the codebase is kebab-case —base-node.svelte,node-tree.svelte),src/lib/design/tokens/as a directory (it is a single file,lib/design/tokens.ts), Svelte 4 stores with$:reactivity (the codebase is on Svelte 5 runes), an in-repodocs/directory, and LanceDB.2. The actual need is text-level, not block-level. The requirement driving this work is copying a passage of text that spans several nodes — a range that can start and end partway through a node. Block selection copies whole nodes and cannot express that. #278 now covers it, scoped to text-level selection and copy.
Bulk operations on a set of nodes (delete, indent, move) remain unbuilt and untracked. There is currently no UI surface for node operations at all — no context menu (
contextmenuhas zero hits insrc/lib), no toolbar, and deletion exists only as a side effect of Backspace in the editor (base-node-viewer.svelte:809-905). If that becomes wanted, it should be filed fresh against the current architecture rather than revived from these.Worth noting what already exists and is not lost by closing these: arrow-key node-to-node navigation (
lib/commands/keyboard/navigate-up.command.ts:26,navigate-down.command.ts:26), a keyboard command registry (lib/services/keyboard-command-registry.ts:207-210), and single-node focus management (lib/services/focus-manager.svelte.ts).