Repository navigation
Closed as not planned
Description
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
- Foundation: Single-Block Selection with Visual Highlighting #35 (Foundation: Single-Block Selection) - Must be completed first for base selection functionality
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.tswith 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.tsfor 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.csswith 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:
getNodesBetweenalgorithm 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.
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).