Skip to content

Multi-Select: Ctrl+Click Individual Block Toggle #37

Closed as not planned
Closed as not planned

Description

@malibio

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

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.ts with 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.

Activity

  1. malibio commented on Aug 3, 2026

    @malibio
    CollaboratorAuthor

    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.svelte and src/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-repo docs/ 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 (contextmenu has zero hits in src/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).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions