Skip to content

Range Selection: Shift+Click Multi-Block Selection #36

Closed as not planned

Description

@malibio

Range Selection: Shift+Click Multi-Block Selection

Implement shift+click range selection functionality to select multiple contiguous blocks in hierarchical structures, building on single-block selection foundation.

Dependencies

Related Issues

  • Part of multi-node selection epic (range selection component)

Requirements

Range Selection Mechanics

  • Shift+Click Behavior: Shift+click extends selection from last selected block to clicked block
  • Direction Awareness: Track selection direction (up/down) for proper range calculation
  • Hierarchical Range: Selection spans across parent-child relationships and sibling groups
  • Range Boundaries: Clear visual indication of selection start and end points
  • File Location: Extend src/lib/stores/selectionStore.ts with range selection logic

DOM Traversal Algorithm

  • Block Range Calculation: Implement algorithm to find all blocks between two points in hierarchy
  • Document Order: Maintain proper document order regardless of selection direction
  • Nested Structure Handling: Handle parent-child relationships correctly in range calculations
  • Efficient Traversal: Optimize for performance with large hierarchical structures
  • File Location: Create src/lib/utils/hierarchyTraversal.ts for DOM range calculations

Visual Range Indicators

  • Contiguous Highlighting: All blocks in range show selection highlighting
  • Start/End Markers: Subtle visual indicators for range boundaries (optional enhancement)
  • Direction Feedback: Visual cue indicating selection direction during range building
  • Consistent Styling: Range selection uses same highlighting as single selection
  • File Location: Extend src/lib/design/tokens/selection.css with range-specific styles

Technical Implementation Notes

Enhanced Selection State

// src/lib/stores/selectionStore.ts - Extended interface
interface SelectionState {
  selectedBlocks: string[];
  mode: 'single' | 'range' | 'multi';
  rangeStart: string | null;      // Start block for range operations
  rangeEnd: string | null;        // End block for range operations
  direction: 'up' | 'down' | null; // Selection direction
  lastActionType: 'single' | 'range' | 'clear';
}

interface SelectionStore extends Readable<SelectionState> {
  selectBlock: (blockId: string) => void;
  selectRange: (startId: string, endId: string) => void;
  extendRange: (targetId: string) => void; // For shift+click
  clearSelection: () => void;
  isSelected: (blockId: string) => boolean;
  isRangeStart: (blockId: string) => boolean;
  isRangeEnd: (blockId: string) => boolean;
}

Hierarchy Traversal Algorithm

// src/lib/utils/hierarchyTraversal.ts
interface BlockPosition {
  blockId: string;
  element: HTMLElement;
  depth: number;
  documentOrder: number;
}

export class HierarchyTraversal {
  /**
   * Get all blocks between start and end positions in document order
   * Based on Logseq's getNodesBetween implementation
   */
  static getBlocksInRange(
    containerElement: HTMLElement, 
    startBlockId: string, 
    endBlockId: string
  ): string[] {
    const startElement = this.findBlockElement(containerElement, startBlockId);
    const endElement = this.findBlockElement(containerElement, endBlockId);
    
    if (\!startElement || \!endElement) return [];
    
    // Determine document order
    const comparison = startElement.compareDocumentPosition(endElement);
    const [firstElement, lastElement] = comparison & Node.DOCUMENT_POSITION_FOLLOWING
      ? [startElement, endElement]
      : [endElement, startElement];
    
    return this.collectBlocksBetween(containerElement, firstElement, lastElement);
  }
  
  /**
   * Collect all block elements between two DOM nodes
   */
  private static collectBlocksBetween(
    container: HTMLElement,
    start: HTMLElement,
    end: HTMLElement
  ): string[] {
    const blockIds: string[] = [];
    const walker = document.createTreeWalker(
      container,
      NodeFilter.SHOW_ELEMENT,
      {
        acceptNode: (node: Element) => {
          // Accept nodes that are blocks and within our range
          if (this.isBlockElement(node) && this.isInRange(node, start, end)) {
            return NodeFilter.FILTER_ACCEPT;
          }
          return NodeFilter.FILTER_SKIP;
        }
      }
    );
    
    let currentNode = walker.nextNode();
    while (currentNode) {
      const blockId = this.getBlockId(currentNode as HTMLElement);
      if (blockId) blockIds.push(blockId);
      currentNode = walker.nextNode();
    }
    
    return blockIds;
  }
  
  private static isBlockElement(element: Element): boolean {
    return element.classList.contains('base-node') || 
           element.hasAttribute('data-block-id');
  }
  
  private static getBlockId(element: HTMLElement): string | null {
    return element.getAttribute('data-block-id') || 
           element.getAttribute('id');
  }
  
  private static isInRange(node: Element, start: HTMLElement, end: HTMLElement): boolean {
    const startComparison = start.compareDocumentPosition(node);
    const endComparison = node.compareDocumentPosition(end);
    
    return (startComparison & Node.DOCUMENT_POSITION_FOLLOWING) &&
           (endComparison & Node.DOCUMENT_POSITION_FOLLOWING);
  }
  
  private static findBlockElement(container: HTMLElement, blockId: string): HTMLElement | null {
    return container.querySelector(`[data-block-id="${blockId}"]`) as HTMLElement;
  }
}

Enhanced BaseNode Integration

<\!-- src/lib/design/components/BaseNode.svelte - Extended click handling -->
<script lang="ts">
  import { selectionStore } from '$lib/stores/selectionStore';
  export let nodeId: string;
  
  $: isSelected = $selectionStore.selectedBlocks.includes(nodeId);
  $: isRangeStart = $selectionStore.rangeStart === nodeId;
  $: isRangeEnd = $selectionStore.rangeEnd === nodeId;
  
  function handleClick(event: MouseEvent) {
    if (event.shiftKey) {
      // Range selection mode
      selectionStore.extendRange(nodeId);
    } else if (event.ctrlKey || event.metaKey) {
      // Future: Multi-select mode (placeholder)
      console.log('Multi-select mode (future implementation)');
    } else {
      // Single selection mode
      selectionStore.selectBlock(nodeId);
    }
    
    // Prevent text selection during block selection
    if (event.shiftKey || event.ctrlKey || event.metaKey) {
      event.preventDefault();
    }
  }
</script>

<div 
  class="base-node"
  class:selected={isSelected}
  class:range-start={isRangeStart}
  class:range-end={isRangeEnd}
  data-block-id={nodeId}
  on:click={handleClick}
  role="treeitem"
  aria-selected={isSelected}
  tabindex="0"
>
  <\!-- existing node content -->
</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);
  }
  
  /* Optional: Range boundary indicators */
  .base-node.range-start::before {
    content: '';
    position: absolute;
    left: -2px;
    top: 0;
    bottom: 0;
    width: 2px;
    background-color: var(--selection-border);
  }
  
  .base-node.range-end::after {
    content: '';
    position: absolute;
    right: -2px;
    top: 0;
    bottom: 0;
    width: 2px;
    background-color: var(--selection-border);
  }
</style>

Mock Service Extension

// src/lib/services/mockSelectionService.ts - Extended for range selection
export class MockSelectionService {
  private selectedNodes = new Set<string>();
  private rangeStart: string | null = null;
  private rangeEnd: string | null = null;
  
  selectRange(startId: string, endId: string, allNodesInRange: string[]): void {
    this.selectedNodes.clear();
    this.rangeStart = startId;
    this.rangeEnd = endId;
    
    allNodesInRange.forEach(nodeId => {
      this.selectedNodes.add(nodeId);
    });
    
    this.notifySelectionChange();
  }
  
  extendRangeToNode(targetId: string, hierarchyTraversal: HierarchyTraversal): void {
    if (this.selectedNodes.size === 0) {
      this.selectNode(targetId);
      return;
    }
    
    const startId = this.rangeStart || Array.from(this.selectedNodes)[0];
    const nodesInRange = hierarchyTraversal.getBlocksInRange(
      document.querySelector('.node-tree')\!,
      startId,
      targetId
    );
    
    this.selectRange(startId, targetId, nodesInRange);
  }
  
  // ... existing methods
}

Acceptance Criteria

Range Selection Behavior

  • Shift+click on block extends selection from last selected block to clicked block
  • Range selection includes all blocks between start and end points in document order
  • Selection works correctly across different hierarchy levels (parent-child relationships)
  • Range selection handles both upward and downward directions properly
  • Previous selection is cleared when starting new range selection without shift key

Visual Feedback Requirements

  • All blocks in selected range show consistent highlight styling
  • Range selection maintains same visual treatment as single selection
  • Selection changes are smooth with 200ms transition animations
  • Optional: Range start/end points have subtle visual indicators
  • No visual glitches during range selection operations

Hierarchical Traversal Requirements

  • Range selection correctly spans parent-child relationships
  • Selection includes intermediate parent blocks when selecting child ranges
  • Range calculation respects document order regardless of click order
  • Deep nesting (5+ levels) handled correctly without performance issues
  • Range selection works with collapsed/expanded parent blocks

Interaction Requirements

  • Shift+click behavior is intuitive and predictable
  • Range selection works from any direction (up-to-down, down-to-up)
  • Click without modifiers starts new single selection (clears range)
  • Range selection prevents accidental text selection in blocks
  • Selection state persists across component re-renders

Performance Requirements

  • Range calculation completes within 50ms for hierarchies up to 500 blocks
  • DOM traversal algorithm scales efficiently with hierarchy depth
  • No memory leaks during repeated range selection operations
  • Selection highlighting updates smoothly without layout thrashing
  • Event handling remains responsive during range operations

Accessibility Requirements

  • Range-selected blocks maintain proper ARIA selection attributes
  • Screen readers announce range selection changes appropriately
  • Keyboard users can initiate range selection with keyboard shortcuts
  • High contrast mode preserves range selection visibility
  • Focus management works correctly during range selection

Integration Requirements

  • Builds seamlessly on existing single-block selection functionality
  • Compatible with current BaseNode and NodeTree architecture
  • Range selection store can be extended for future multi-selection features
  • No breaking changes to existing selection behavior or APIs
  • Mock service integration maintains existing patterns

Resources & References

Implementation Patterns

  • Logseq Range Selection: getNodesBetween algorithm from UX research findings
  • DOM Traversal APIs: TreeWalker and compareDocumentPosition browser APIs
  • Selection Store: Extending existing selection state management patterns
  • BaseNode Integration: Building on established click handling patterns

Architecture Documentation

  • Hierarchy Algorithms: Research on efficient tree traversal patterns
  • Selection Patterns: Modern web application selection standards (VS Code, GitHub)
  • State Management: Reactive store patterns for complex state updates
  • Performance Guidelines: Optimization strategies for DOM manipulation

UX Research References

  • Multi-Node Selection Report: Comprehensive analysis of Logseq's implementation
  • Interaction Patterns: Shift+click conventions across modern applications
  • Visual Design: Consistent highlighting patterns and user feedback systems

Definition of Done

Functionality

  • Range selection works reliably across all hierarchy structures
  • All acceptance criteria validated through comprehensive testing
  • Algorithm handles edge cases (single block ranges, cross-level selections)
  • Performance requirements met through profiling and optimization
  • Integration with existing components is seamless and non-breaking

Code Quality

  • TypeScript compilation passes with strict mode and full type coverage
  • ESLint passes with no errors (development warnings acceptable)
  • DOM traversal algorithm is well-documented and optimized
  • Selection store maintains clean, predictable API surface
  • No circular dependencies or memory leaks in selection management

Testing

  • Unit tests cover hierarchy traversal algorithm thoroughly
  • Integration tests verify range selection across various hierarchy structures
  • Performance tests validate scalability requirements
  • Accessibility testing confirms WCAG compliance for range selection
  • Cross-browser testing ensures consistent behavior

Documentation

  • Hierarchy traversal algorithm documented with examples
  • Range selection behavior documented for future developers
  • Performance characteristics and limitations documented
  • Extension points identified for multi-selection implementation
  • Code comments explain complex DOM traversal logic

This range selection foundation enables advanced selection features like multi-select (Ctrl+click) and keyboard-driven selection 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