diff --git a/esbuild.config.mjs b/esbuild.config.mjs index 0888335..cc1e474 100644 --- a/esbuild.config.mjs +++ b/esbuild.config.mjs @@ -4,7 +4,7 @@ import builtins from "builtin-modules"; import fs from "fs"; const prod = process.argv[2] === "production"; -const pluginDir = "C:/Users/19411_4bs7lzt/OneDrive/obsidian/.obsidian/plugins/dragger"; +const pluginDir = "/Users/jinyujia/Downloads/test/.obsidian/plugins/dragger"; // 复制 styles.css 到插件目录 function copyStyles() { diff --git a/package-lock.json b/package-lock.json index bf0cc18..c7c52cc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "dragger", - "version": "1.2.1", + "version": "1.2.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "dragger", - "version": "1.2.1", + "version": "1.2.2", "license": "MIT", "devDependencies": { "@codemirror/state": "^6.0.0", diff --git a/src/editor/core/constants.ts b/src/editor/core/constants.ts index 64eef82..ecb424d 100644 --- a/src/editor/core/constants.ts +++ b/src/editor/core/constants.ts @@ -29,7 +29,7 @@ export const HANDLE_INTERACTION_ZONE_PX = 64; * Centralised mutable config – set once per plugin load via `applySettings()`. */ const handleConfig = { - sizePx: 16, + sizePx: 24, horizontalOffsetPx: 0, alignToLineNumber: true, }; @@ -39,7 +39,7 @@ export function getHandleSizePx(): number { } export function setHandleSizePx(size: number): void { - handleConfig.sizePx = Math.max(12, Math.min(28, size)); + handleConfig.sizePx = Math.max(12, Math.min(36, size)); } export function getHandleHorizontalOffsetPx(): number { @@ -63,3 +63,4 @@ export function setAlignToLineNumber(align: boolean): void { */ export const HOVER_HIDDEN_LINE_NUMBER_CLASS = 'dnd-line-number-hover-hidden'; export const GRAB_HIDDEN_LINE_NUMBER_CLASS = 'dnd-line-number-grab-hidden'; +export const BLOCK_SELECTION_ACTIVE_CLASS = 'dnd-block-selection-active'; diff --git a/src/editor/core/line-map.spec.ts b/src/editor/core/line-map.spec.ts index 159d3fa..be6c70a 100644 --- a/src/editor/core/line-map.spec.ts +++ b/src/editor/core/line-map.spec.ts @@ -208,8 +208,6 @@ describe('line-map', () => { get: summarizeDurations(getDurations), }; - console.debug('[Dragger][PerfTest] typing_line_map', JSON.stringify(report, null, 2)); - expect(report.total.count).toBe(iterations); expect(report.prime.p95).toBeGreaterThanOrEqual(0); expect(report.get.p95).toBeGreaterThanOrEqual(0); diff --git a/src/editor/core/perf-session.ts b/src/editor/core/perf-session.ts index 518d863..fc7c82c 100644 --- a/src/editor/core/perf-session.ts +++ b/src/editor/core/perf-session.ts @@ -93,10 +93,6 @@ function summarize(values: number[]): { count: number; p50: number; p95: number; }; } -function serializeSnapshot(snapshot: DragPerfSnapshot): string { - return JSON.stringify(snapshot, null, 2); -} - export function createDragPerfSession(input: DragPerfSessionInput): DragPerfSession { const startedAtMs = nowMs(); const durations = createDurationStore(); @@ -145,8 +141,6 @@ export function createDragPerfSession(input: DragPerfSessionInput): DragPerfSess }; } -export function logDragPerfSession(session: DragPerfSession | null, reason: string): void { - if (!session) return; - const snapshot = session.snapshot(); - console.debug('[Dragger][Perf]', reason, serializeSnapshot(snapshot)); +export function logDragPerfSession(_session: DragPerfSession | null, _reason: string): void { + // no-op in normal runtime } diff --git a/src/editor/core/selectors.ts b/src/editor/core/selectors.ts index 3e591c7..6af2120 100644 --- a/src/editor/core/selectors.ts +++ b/src/editor/core/selectors.ts @@ -15,4 +15,5 @@ export const DRAG_SOURCE_LINE_NUMBER_CLASS = 'dnd-drag-source-line-number'; export const RANGE_SELECTED_LINE_CLASS = 'dnd-range-selected-line'; export const RANGE_SELECTED_HANDLE_CLASS = 'dnd-range-selected-handle'; export const RANGE_SELECTION_LINK_CLASS = 'dnd-range-selection-link'; +export const SELECTION_HIGHLIGHT_LINE_CLASS = 'dnd-selection-highlight-line'; export const MOBILE_GESTURE_LOCK_CLASS = 'dnd-mobile-gesture-lock'; diff --git a/src/editor/core/services/EditorSelectionBridge.ts b/src/editor/core/services/EditorSelectionBridge.ts new file mode 100644 index 0000000..1573345 --- /dev/null +++ b/src/editor/core/services/EditorSelectionBridge.ts @@ -0,0 +1,132 @@ +import type { EditorView } from '@codemirror/view'; +import type { LineRange } from '../../../types'; +import { + mergeLineRanges, + resolveBlockAlignedLineRange, + resolveBlockBoundaryAtLine, +} from '../../interaction/RangeSelectionLogic'; + +export type EditorTextSelection = { + from: number; + to: number; + fromLine: number; + toLine: number; +}; + +/** + * Bridge between CodeMirror editor selection and block-based selection. + * Reads the editor's native text selection and converts it to line ranges + * that can be used for block-aligned multi-line selection. + */ +export class EditorSelectionBridge { + constructor(private readonly view: EditorView) {} + + /** + * Get the current text selection in the editor. + * @returns Selection info if there's a valid non-empty selection, null otherwise + */ + getTextSelection(): EditorTextSelection | null { + return this.getTextSelections()[0] ?? null; + } + + /** + * Get all non-empty text selections in the editor. + * Supports multi-range selections (e.g. multiple carets/ranges). + */ + getTextSelections(): EditorTextSelection[] { + const doc = this.view.state.doc; + const ranges = this.view.state.selection.ranges; + const selections: EditorTextSelection[] = []; + + for (const range of ranges) { + if (range.empty) continue; + const fromLine = doc.lineAt(range.from).number; + const toLine = doc.lineAt(range.to).number; + selections.push({ + from: range.from, + to: range.to, + fromLine, + toLine, + }); + } + return selections; + } + + /** + * Check if a line number is within the current editor selection. + */ + isLineInSelection(lineNumber: number): boolean { + const selections = this.getTextSelections(); + if (selections.length === 0) return false; + return selections.some((selection) => ( + lineNumber >= selection.fromLine && lineNumber <= selection.toLine + )); + } + + /** + * Convert the current editor selection to block-aligned line ranges. + * This ensures that partial selections are expanded to include complete blocks. + * @returns Block-aligned line ranges, or null if no valid selection + */ + resolveBlockAlignedSelection(): LineRange[] | null { + const selections = this.getTextSelections(); + if (selections.length === 0) return null; + + const state = this.view.state; + const docLines = state.doc.lines; + const ranges: LineRange[] = []; + + for (const selection of selections) { + // Get the block boundaries at the selection start and end + const startBoundary = resolveBlockBoundaryAtLine(state, selection.fromLine); + const endBoundary = resolveBlockBoundaryAtLine(state, selection.toLine); + + // Resolve to block-aligned range + const aligned = resolveBlockAlignedLineRange( + state, + startBoundary.startLineNumber, + startBoundary.endLineNumber, + endBoundary.startLineNumber, + endBoundary.endLineNumber + ); + ranges.push({ + startLineNumber: aligned.startLineNumber, + endLineNumber: aligned.endLineNumber, + }); + } + return mergeLineRanges(docLines, ranges); + } + + /** + * Check if a line number intersects with the editor selection. + * If it does, return the block-aligned selection ranges. + * @param lineNumber The line number to check (1-indexed) + * @returns Block-aligned ranges if the line intersects, null otherwise + */ + getBlockAlignedRangeIfIntersecting(lineNumber: number): LineRange[] | null { + return this.getBlockAlignedRangeIfRangeIntersecting(lineNumber, lineNumber); + } + + /** + * Check if a line range intersects with the editor selection. + * If it does, return the block-aligned selection ranges. + * @param startLineNumber Inclusive start line number (1-indexed) + * @param endLineNumber Inclusive end line number (1-indexed) + */ + getBlockAlignedRangeIfRangeIntersecting(startLineNumber: number, endLineNumber: number): LineRange[] | null { + const selections = this.getTextSelections(); + if (selections.length === 0) return null; + + const safeStart = Math.min(startLineNumber, endLineNumber); + const safeEnd = Math.max(startLineNumber, endLineNumber); + + // Trigger only when clicked range intersects at least one text selection range. + const intersects = selections.some((selection) => ( + safeEnd >= selection.fromLine && safeStart <= selection.toLine + )); + if (!intersects) return null; + + // Return the block-aligned selection + return this.resolveBlockAlignedSelection(); + } +} diff --git a/src/editor/drag-handle.ts b/src/editor/drag-handle.ts index 69434ee..8518575 100644 --- a/src/editor/drag-handle.ts +++ b/src/editor/drag-handle.ts @@ -8,6 +8,7 @@ import DragNDropPlugin from '../main'; import { ROOT_EDITOR_CLASS, MAIN_EDITOR_CONTENT_CLASS, + DRAG_HANDLE_CLASS, MOBILE_GESTURE_LOCK_CLASS, DRAGGING_BODY_CLASS, } from './core/selectors'; @@ -63,6 +64,7 @@ function createDragHandleViewPlugin(_plugin: DragNDropPlugin) { private readonly semanticRefreshScheduler: SemanticRefreshScheduler; private lastPointerPos: { x: number; y: number } | null = null; private readonly onDocumentPointerMove = (e: PointerEvent) => this.handleDocumentPointerMove(e); + private readonly onDocumentPointerDown = (e: PointerEvent) => this.handleDocumentPointerDown(e); private readonly onSettingsUpdated = () => this.handleSettingsUpdated(); constructor(view: EditorView) { @@ -177,6 +179,10 @@ function createDragHandleViewPlugin(_plugin: DragNDropPlugin) { performDropAtPoint: (sourceBlock, clientX, clientY, pointerType) => this.orchestrator.performDropAtPoint(sourceBlock, clientX, clientY, pointerType ?? null), onDragLifecycleEvent: (event) => this.orchestrator.emitDragLifecycle(event), + setHiddenRangesForSelection: (ranges, anchorHandle) => + this.handleVisibility.setHiddenRangesForSelection(ranges, anchorHandle), + clearHiddenRangesForSelection: () => + this.handleVisibility.clearHiddenRangesForSelection(), }); this.semanticRefreshScheduler = new SemanticRefreshScheduler(this.view, { @@ -189,6 +195,7 @@ function createDragHandleViewPlugin(_plugin: DragNDropPlugin) { this.dragEventHandler.attach(); this.semanticRefreshScheduler.bindViewportScrollFallback(); document.addEventListener('pointermove', this.onDocumentPointerMove, { passive: true }); + document.addEventListener('pointerdown', this.onDocumentPointerDown, { passive: true }); window.addEventListener('dnd:settings-updated', this.onSettingsUpdated); // Pre-warm fence scan during idle to ensure code/math block boundaries are ready @@ -219,6 +226,8 @@ function createDragHandleViewPlugin(_plugin: DragNDropPlugin) { this.handleVisibility.setActiveVisibleHandle(null); this.reResolveActiveHandle(); } + this.handleVisibility.reapplySelectionHighlight(); + this.handleVisibility.reapplySelectionHandleVisibility(); return; } @@ -230,7 +239,12 @@ function createDragHandleViewPlugin(_plugin: DragNDropPlugin) { this.refreshDecorationsAndEmbeds(); } - if (update.docChanged || update.geometryChanged) { + if ( + update.docChanged + || update.geometryChanged + || update.selectionSet + || this.dragEventHandler.hasCommittedSelection() + ) { this.dragEventHandler.refreshSelectionVisual(); } const activeHandle2 = this.handleVisibility.getActiveHandle(); @@ -238,12 +252,15 @@ function createDragHandleViewPlugin(_plugin: DragNDropPlugin) { this.handleVisibility.setActiveVisibleHandle(null); this.reResolveActiveHandle(); } + this.handleVisibility.reapplySelectionHighlight(); + this.handleVisibility.reapplySelectionHandleVisibility(); } destroy(): void { this.lineMapPrewarmer.clear(); this.semanticRefreshScheduler.destroy(); document.removeEventListener('pointermove', this.onDocumentPointerMove); + document.removeEventListener('pointerdown', this.onDocumentPointerDown); window.removeEventListener('dnd:settings-updated', this.onSettingsUpdated); this.handleVisibility.clearGrabbedLineNumbers(); this.handleVisibility.setActiveVisibleHandle(null); @@ -264,6 +281,13 @@ function createDragHandleViewPlugin(_plugin: DragNDropPlugin) { }); } + private handleDocumentPointerDown(e: PointerEvent): void { + // Clear selection highlight when clicking anywhere that's not a drag handle + if (!(e.target instanceof HTMLElement)) return; + if (e.target.closest(`.${DRAG_HANDLE_CLASS}`)) return; + this.handleVisibility.clearSelectionHighlight(); + } + private handleDocumentPointerMove(e: PointerEvent): void { this.lastPointerPos = { x: e.clientX, y: e.clientY }; if (document.body.classList.contains(MOBILE_GESTURE_LOCK_CLASS)) { diff --git a/src/editor/interaction/DragEventHandler.spec.ts b/src/editor/interaction/DragEventHandler.spec.ts index 0c2a0a4..b3958f1 100644 --- a/src/editor/interaction/DragEventHandler.spec.ts +++ b/src/editor/interaction/DragEventHandler.spec.ts @@ -1,6 +1,6 @@ // @vitest-environment jsdom -import { EditorState } from '@codemirror/state'; +import { EditorSelection, EditorState } from '@codemirror/state'; import type { EditorView } from '@codemirror/view'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { BlockInfo, BlockType } from '../../types'; @@ -129,6 +129,58 @@ function dispatchPointer( return event; } +function dispatchDrop( + target: EventTarget, + init: { + clientX: number; + clientY: number; + dataTransfer: { types: string[]; getData: (type: string) => string; dropEffect?: string }; + } +): DragEvent { + const event = new Event('drop', { bubbles: true, cancelable: true }) as DragEvent; + Object.defineProperty(event, 'clientX', { value: init.clientX }); + Object.defineProperty(event, 'clientY', { value: init.clientY }); + Object.defineProperty(event, 'dataTransfer', { value: init.dataTransfer }); + target.dispatchEvent(event); + return event; +} + +function applyTextSelection(view: EditorView, fromLine: number, toLine: number): void { + const doc = view.state.doc; + const safeFromLine = Math.max(1, Math.min(doc.lines, fromLine)); + const safeToLine = Math.max(1, Math.min(doc.lines, toLine)); + const anchor = doc.line(safeFromLine).from; + const head = doc.line(safeToLine).to; + (view as unknown as { state: EditorState }).state = EditorState.create({ + doc: doc.toString(), + selection: { anchor, head }, + }); +} + +function applyMultiTextSelections(view: EditorView, ranges: Array<{ fromLine: number; toLine: number }>): void { + const doc = view.state.doc; + const selectionRanges = ranges.map((range) => { + const safeFromLine = Math.max(1, Math.min(doc.lines, range.fromLine)); + const safeToLine = Math.max(1, Math.min(doc.lines, range.toLine)); + const anchor = doc.line(safeFromLine).from; + const head = doc.line(safeToLine).to; + return EditorSelection.range(anchor, head); + }); + (view as unknown as { state: EditorState }).state = EditorState.create({ + doc: doc.toString(), + extensions: [EditorState.allowMultipleSelections.of(true)], + selection: EditorSelection.create(selectionRanges), + }); +} + +function getCommittedSelectionTarget(view: EditorView): HTMLElement { + const selected = view.contentDOM.querySelectorAll('.dnd-range-selected-line'); + if (selected.length > 0) { + return selected[Math.floor(selected.length / 2)]!; + } + return view.contentDOM; +} + beforeEach(() => { if (!originalElementFromPoint && typeof document.elementFromPoint === 'function') { const native = document.elementFromPoint.bind(document); @@ -213,9 +265,8 @@ describe('DragEventHandler', () => { }); expect(beginPointerDragSession).not.toHaveBeenCalled(); - const link = view.dom.querySelector('.dnd-range-selection-link'); - expect(link).not.toBeNull(); - expect(link?.classList.contains('is-active')).toBe(true); + const lines = view.contentDOM.querySelectorAll('.dnd-range-selected-line'); + expect(lines.length).toBe(1); handler.destroy(); }); @@ -317,12 +368,11 @@ describe('DragEventHandler', () => { }); expect(beginPointerDragSession).not.toHaveBeenCalled(); - const link = view.dom.querySelector('.dnd-range-selection-link'); - expect(link).not.toBeNull(); - dispatchPointer(link!, 'pointerdown', { + const committedTarget = getCommittedSelectionTarget(view); + dispatchPointer(committedTarget, 'pointerdown', { pointerId: 8, pointerType: 'mouse', - clientX: 12, + clientX: 0, clientY: 80, }); vi.advanceTimersByTime(280); @@ -397,13 +447,11 @@ describe('DragEventHandler', () => { clientY: 105, }); - const link = view.dom.querySelector('.dnd-range-selection-link'); - expect(link).not.toBeNull(); - - dispatchPointer(link!, 'pointerdown', { + const committedTarget = getCommittedSelectionTarget(view); + dispatchPointer(committedTarget, 'pointerdown', { pointerId: 71, pointerType: 'mouse', - clientX: 12, + clientX: 0, clientY: 80, }); dispatchPointer(window, 'pointermove', { @@ -459,9 +507,69 @@ describe('DragEventHandler', () => { clientY: 90, }); - const link = view.dom.querySelector('.dnd-range-selection-link'); - expect(link).not.toBeNull(); - expect(link?.classList.contains('is-active')).toBe(true); + const lines = view.contentDOM.querySelectorAll('.dnd-range-selected-line'); + expect(lines.length).toBeGreaterThan(0); + handler.destroy(); + }); + + it('selects a single block on handle click and drags it on the next handle gesture', () => { + const view = createViewStub(8); + const handle = document.createElement('div'); + handle.className = 'dnd-drag-handle'; + handle.setAttribute('draggable', 'true'); + handle.setAttribute('data-block-start', '1'); + view.dom.appendChild(handle); + + const sourceBlock = createBlock('- item', 1, 1); + const beginPointerDragSession = vi.fn(); + const handler = new DragEventHandler(view, { + getDragSourceBlock: () => null, + getBlockInfoForHandle: () => sourceBlock, + getBlockInfoAtPoint: () => null, + isBlockInsideRenderedTableCell: () => false, + beginPointerDragSession, + finishDragSession: vi.fn(), + scheduleDropIndicatorUpdate: vi.fn(), + hideDropIndicator: vi.fn(), + performDropAtPoint: vi.fn(() => null), + }); + + handler.attach(); + + dispatchPointer(handle, 'pointerdown', { + pointerId: 201, + pointerType: 'mouse', + clientX: 12, + clientY: 30, + }); + dispatchPointer(window, 'pointerup', { + pointerId: 201, + pointerType: 'mouse', + clientX: 12, + clientY: 30, + }); + + let lines = view.contentDOM.querySelectorAll('.cm-line'); + expect(lines[1]?.classList.contains('dnd-range-selected-line')).toBe(true); + expect(lines[0]?.classList.contains('dnd-range-selected-line')).toBe(false); + + dispatchPointer(handle, 'pointerdown', { + pointerId: 202, + pointerType: 'mouse', + clientX: 12, + clientY: 30, + }); + dispatchPointer(window, 'pointermove', { + pointerId: 202, + pointerType: 'mouse', + clientX: 36, + clientY: 30, + }); + + expect(beginPointerDragSession).toHaveBeenCalledTimes(1); + const dragged = beginPointerDragSession.mock.calls[0][0] as BlockInfo; + expect(dragged.startLine).toBe(1); + expect(dragged.endLine).toBe(1); handler.destroy(); }); @@ -540,12 +648,11 @@ describe('DragEventHandler', () => { }); expect(beginPointerDragSession).not.toHaveBeenCalled(); - const link = view.dom.querySelector('.dnd-range-selection-link'); - expect(link).not.toBeNull(); - dispatchPointer(link!, 'pointerdown', { + const committedTarget = getCommittedSelectionTarget(view); + dispatchPointer(committedTarget, 'pointerdown', { pointerId: 19, pointerType: 'touch', - clientX: 12, + clientX: 32, clientY: 80, }); vi.advanceTimersByTime(120); @@ -619,20 +726,18 @@ describe('DragEventHandler', () => { clientY: 105, }); - let link = view.dom.querySelector('.dnd-range-selection-link'); - expect(link).not.toBeNull(); - expect(link?.classList.contains('is-active')).toBe(true); + expect(view.dom.querySelectorAll('.dnd-range-selected-line').length).toBeGreaterThan(0); + expect(view.dom.querySelector('.dnd-range-selection-link')).toBeNull(); - dispatchPointer(view.contentDOM, 'pointerdown', { + const lines = view.contentDOM.querySelectorAll('.cm-line'); + dispatchPointer(lines[7] ?? view.contentDOM, 'pointerdown', { pointerId: 42, pointerType: 'mouse', clientX: 220, - clientY: 40, + clientY: 170, }); - link = view.dom.querySelector('.dnd-range-selection-link'); - expect(link).not.toBeNull(); - expect(link?.classList.contains('is-active')).toBe(false); + expect(view.dom.querySelector('.dnd-range-selection-link')).toBeNull(); expect(view.dom.querySelector('.dnd-range-selected-line')).toBeNull(); handler.destroy(); }); @@ -678,8 +783,8 @@ describe('DragEventHandler', () => { clientY: 105, }); - let link = view.dom.querySelector('.dnd-range-selection-link'); - expect(link?.classList.contains('is-active')).toBe(true); + let lines = view.contentDOM.querySelectorAll('.dnd-range-selected-line'); + expect(lines.length).toBeGreaterThan(0); dispatchPointer(view.contentDOM, 'pointerdown', { pointerId: 62, @@ -688,15 +793,15 @@ describe('DragEventHandler', () => { clientY: 40, }); - link = view.dom.querySelector('.dnd-range-selection-link'); - expect(link?.classList.contains('is-active')).toBe(true); + lines = view.contentDOM.querySelectorAll('.dnd-range-selected-line'); + expect(lines.length).toBeGreaterThan(0); const input = document.createElement('textarea'); view.dom.appendChild(input); input.dispatchEvent(new FocusEvent('focusin', { bubbles: true, cancelable: true })); - link = view.dom.querySelector('.dnd-range-selection-link'); - expect(link?.classList.contains('is-active')).toBe(false); + lines = view.contentDOM.querySelectorAll('.dnd-range-selected-line'); + expect(lines.length).toBe(0); handler.destroy(); }); @@ -749,16 +854,16 @@ describe('DragEventHandler', () => { clientY: 105, }); - const link = view.dom.querySelector('.dnd-range-selection-link'); - expect(link).not.toBeNull(); - const topBefore = Number(link?.style.top.replace('px', '') || '0'); + let selectedLines = view.contentDOM.querySelectorAll('.dnd-range-selected-line'); + const countBefore = selectedLines.length; + expect(countBefore).toBeGreaterThan(0); scrollOffset = 40; view.dom.dispatchEvent(new Event('scroll')); vi.advanceTimersByTime(20); - const topAfter = Number(link?.style.top.replace('px', '') || '0'); - expect(topAfter).toBeLessThan(topBefore); + selectedLines = view.contentDOM.querySelectorAll('.dnd-range-selected-line'); + expect(selectedLines.length).toBe(countBefore); handler.destroy(); }); @@ -827,12 +932,11 @@ describe('DragEventHandler', () => { }); expect(beginPointerDragSession).not.toHaveBeenCalled(); - const link = view.dom.querySelector('.dnd-range-selection-link'); - expect(link).not.toBeNull(); - dispatchPointer(link!, 'pointerdown', { + const committedTarget = getCommittedSelectionTarget(view); + dispatchPointer(committedTarget, 'pointerdown', { pointerId: 10, pointerType: 'mouse', - clientX: 12, + clientX: 0, clientY: 25, }); vi.advanceTimersByTime(280); @@ -945,12 +1049,11 @@ describe('DragEventHandler', () => { }); expect(beginPointerDragSession).not.toHaveBeenCalled(); - const link = view.dom.querySelector('.dnd-range-selection-link'); - expect(link).not.toBeNull(); - dispatchPointer(link!, 'pointerdown', { + const committedTarget = getCommittedSelectionTarget(view); + dispatchPointer(committedTarget, 'pointerdown', { pointerId: 12, pointerType: 'mouse', - clientX: 12, + clientX: 0, clientY: 92, }); vi.advanceTimersByTime(280); @@ -1047,14 +1150,12 @@ describe('DragEventHandler', () => { clientY: 150, }); - const link = view.dom.querySelector('.dnd-range-selection-link'); - expect(link).not.toBeNull(); - - dispatchPointer(link!, 'pointerdown', { + const committedTarget = getCommittedSelectionTarget(view); + dispatchPointer(committedTarget, 'pointerdown', { pointerId: 32, pointerType: 'mouse', - clientX: 12, - clientY: 80, + clientX: 0, + clientY: 30, }); vi.advanceTimersByTime(280); dispatchPointer(window, 'pointermove', { @@ -1202,9 +1303,8 @@ describe('DragEventHandler', () => { }); expect(beginPointerDragSession).not.toHaveBeenCalled(); - const link = view.dom.querySelector('.dnd-range-selection-link'); - expect(link).not.toBeNull(); - dispatchPointer(link!, 'pointerdown', { + const committedTarget = getCommittedSelectionTarget(view); + dispatchPointer(committedTarget, 'pointerdown', { pointerId: 3, pointerType: 'touch', clientX: 32, @@ -1347,7 +1447,7 @@ describe('DragEventHandler', () => { const sourceBlock = createBlock('- item', 1, 1); const beginPointerDragSession = vi.fn(); const scheduleDropIndicatorUpdate = vi.fn(); - const performDropAtPoint = vi.fn(); + const performDropAtPoint = vi.fn(() => 4); const finishDragSession = vi.fn(); const handler = new DragEventHandler(view, { @@ -1383,15 +1483,808 @@ describe('DragEventHandler', () => { clientX: 90, clientY: 80, }); + vi.advanceTimersByTime(1); expect(beginPointerDragSession).toHaveBeenCalledTimes(1); expect(scheduleDropIndicatorUpdate).toHaveBeenCalledWith(90, 80, expect.objectContaining({ startLine: 1, endLine: 1, }), 'touch'); + const lines = view.contentDOM.querySelectorAll('.cm-line'); + expect(lines[2]?.classList.contains('dnd-range-selected-line')).toBe(true); + expect(lines[1]?.classList.contains('dnd-range-selected-line')).toBe(false); expect(view.dom.querySelector('.dnd-range-selection-link')).toBeNull(); expect(performDropAtPoint).toHaveBeenCalledTimes(1); expect(finishDragSession).toHaveBeenCalledTimes(1); handler.destroy(); }); + + it('keeps single-block selection after mouse drop event commits the move', () => { + const view = createViewStub(8); + const sourceBlock = createBlock('- item', 1, 1); + const performDropAtPoint = vi.fn(() => 5); + const finishDragSession = vi.fn(); + const handler = new DragEventHandler(view, { + getDragSourceBlock: (e) => { + const raw = e.dataTransfer?.getData('application/dnd-block') ?? ''; + if (!raw) return null; + return JSON.parse(raw) as BlockInfo; + }, + getBlockInfoForHandle: () => sourceBlock, + getBlockInfoAtPoint: () => null, + isBlockInsideRenderedTableCell: () => false, + beginPointerDragSession: vi.fn(), + finishDragSession, + scheduleDropIndicatorUpdate: vi.fn(), + hideDropIndicator: vi.fn(), + performDropAtPoint, + }); + + handler.attach(); + dispatchDrop(view.dom, { + clientX: 120, + clientY: 90, + dataTransfer: { + types: ['application/dnd-block'], + getData: (type: string) => { + if (type === 'application/dnd-block') { + return JSON.stringify(sourceBlock); + } + return ''; + }, + dropEffect: 'move', + }, + }); + vi.advanceTimersByTime(1); + + expect(performDropAtPoint).toHaveBeenCalledTimes(1); + expect(finishDragSession).toHaveBeenCalledTimes(1); + const lines = view.contentDOM.querySelectorAll('.cm-line'); + expect(lines[3]?.classList.contains('dnd-range-selected-line')).toBe(true); + expect(lines[1]?.classList.contains('dnd-range-selected-line')).toBe(false); + handler.destroy(); + }); + + it('maps cross-block text selection to smart drag source and keeps moved blocks selected', () => { + const view = createViewStub(10); + const handle = document.createElement('div'); + handle.className = 'dnd-drag-handle'; + handle.setAttribute('draggable', 'true'); + view.dom.appendChild(handle); + applyTextSelection(view, 2, 4); + + const baseBlock = createBlock('- item', 1, 1); + const performDropAtPoint = vi.fn(() => 6); + const finishDragSession = vi.fn(); + const handler = new DragEventHandler(view, { + getDragSourceBlock: (e) => { + const raw = e.dataTransfer?.getData('application/dnd-block') ?? ''; + if (!raw) return null; + return JSON.parse(raw) as BlockInfo; + }, + getBlockInfoForHandle: () => baseBlock, + getBlockInfoAtPoint: () => null, + isBlockInsideRenderedTableCell: () => false, + beginPointerDragSession: vi.fn(), + finishDragSession, + scheduleDropIndicatorUpdate: vi.fn(), + hideDropIndicator: vi.fn(), + performDropAtPoint, + }); + + handler.attach(); + const downEvent = dispatchPointer(handle, 'pointerdown', { + pointerId: 93, + pointerType: 'mouse', + clientX: 12, + clientY: 30, + }); + expect(downEvent.defaultPrevented).toBe(false); + + const smartSource = handler.resolveNativeDragSourceForHandleDrag(baseBlock); + expect(smartSource?.startLine).toBe(1); + expect(smartSource?.endLine).toBe(3); + handler.finalizeNativeHandleDragStart(); + + dispatchDrop(view.dom, { + clientX: 120, + clientY: 100, + dataTransfer: { + types: ['application/dnd-block'], + getData: (type: string) => { + if (type === 'application/dnd-block') { + return JSON.stringify(smartSource); + } + return ''; + }, + dropEffect: 'move', + }, + }); + vi.advanceTimersByTime(1); + + expect(performDropAtPoint).toHaveBeenCalledTimes(1); + expect(finishDragSession).toHaveBeenCalledTimes(1); + const lines = view.contentDOM.querySelectorAll('.cm-line'); + expect(lines[2]?.classList.contains('dnd-range-selected-line')).toBe(true); + expect(lines[3]?.classList.contains('dnd-range-selected-line')).toBe(true); + expect(lines[4]?.classList.contains('dnd-range-selected-line')).toBe(true); + expect(lines[1]?.classList.contains('dnd-range-selected-line')).toBe(false); + handler.destroy(); + }); + + it('supports both click-to-select and direct-drag for multi-block text selections', () => { + const view = createViewStub(10); + const handle = document.createElement('div'); + handle.className = 'dnd-drag-handle'; + handle.setAttribute('draggable', 'true'); + view.dom.appendChild(handle); + const baseBlock = createBlock('- item', 1, 1); + const performDropAtPoint = vi.fn(() => 6); + const finishDragSession = vi.fn(); + const handler = new DragEventHandler(view, { + getDragSourceBlock: (e) => { + const raw = e.dataTransfer?.getData('application/dnd-block') ?? ''; + if (!raw) return null; + return JSON.parse(raw) as BlockInfo; + }, + getBlockInfoForHandle: () => baseBlock, + getBlockInfoAtPoint: () => null, + isBlockInsideRenderedTableCell: () => false, + beginPointerDragSession: vi.fn(), + finishDragSession, + scheduleDropIndicatorUpdate: vi.fn(), + hideDropIndicator: vi.fn(), + performDropAtPoint, + }); + handler.attach(); + + // Flow 1: click handle after text selection -> commit multi-block selection + applyTextSelection(view, 2, 4); + dispatchPointer(handle, 'pointerdown', { + pointerId: 501, + pointerType: 'mouse', + clientX: 12, + clientY: 30, + }); + dispatchPointer(window, 'pointerup', { + pointerId: 501, + pointerType: 'mouse', + clientX: 12, + clientY: 30, + }); + let lines = view.contentDOM.querySelectorAll('.cm-line'); + expect(lines[1]?.classList.contains('dnd-range-selected-line')).toBe(true); + expect(lines[2]?.classList.contains('dnd-range-selected-line')).toBe(true); + expect(lines[3]?.classList.contains('dnd-range-selected-line')).toBe(true); + + // Clear committed selection using a new pointer event. + dispatchPointer(lines[2]!, 'pointerdown', { + pointerId: 502, + pointerType: 'mouse', + clientX: 220, + clientY: 50, + }); + + // Flow 2: reselect text and directly drag handle -> keep direct multi-block move + applyTextSelection(view, 2, 4); + dispatchPointer(handle, 'pointerdown', { + pointerId: 503, + pointerType: 'mouse', + clientX: 12, + clientY: 30, + }); + const smartSource = handler.resolveNativeDragSourceForHandleDrag(baseBlock); + expect(smartSource?.startLine).toBe(1); + expect(smartSource?.endLine).toBe(3); + handler.finalizeNativeHandleDragStart(); + + dispatchDrop(view.dom, { + clientX: 120, + clientY: 100, + dataTransfer: { + types: ['application/dnd-block'], + getData: (type: string) => { + if (type === 'application/dnd-block') { + return JSON.stringify(smartSource); + } + return ''; + }, + dropEffect: 'move', + }, + }); + vi.advanceTimersByTime(1); + + expect(performDropAtPoint).toHaveBeenCalledTimes(1); + expect(finishDragSession).toHaveBeenCalledTimes(1); + lines = view.contentDOM.querySelectorAll('.cm-line'); + expect(lines[2]?.classList.contains('dnd-range-selected-line')).toBe(true); + expect(lines[3]?.classList.contains('dnd-range-selected-line')).toBe(true); + expect(lines[4]?.classList.contains('dnd-range-selected-line')).toBe(true); + handler.destroy(); + }); + + it('selects the clicked block when text selection exists but handle is outside that text range', () => { + const view = createViewStub(10); + const handleOutsideSelection = document.createElement('div'); + handleOutsideSelection.className = 'dnd-drag-handle'; + handleOutsideSelection.setAttribute('draggable', 'true'); + handleOutsideSelection.setAttribute('data-block-start', '5'); + view.dom.appendChild(handleOutsideSelection); + applyTextSelection(view, 2, 4); + + const insideSelectionBlock = createBlock('- item', 1, 1); + const outsideSelectionBlock = createBlock('- item', 5, 5); + const handler = new DragEventHandler(view, { + getDragSourceBlock: () => null, + getBlockInfoForHandle: (handle) => { + if (handle === handleOutsideSelection) return outsideSelectionBlock; + return insideSelectionBlock; + }, + getBlockInfoAtPoint: () => null, + isBlockInsideRenderedTableCell: () => false, + beginPointerDragSession: vi.fn(), + finishDragSession: vi.fn(), + scheduleDropIndicatorUpdate: vi.fn(), + hideDropIndicator: vi.fn(), + performDropAtPoint: vi.fn(), + }); + + handler.attach(); + dispatchPointer(handleOutsideSelection, 'pointerdown', { + pointerId: 301, + pointerType: 'mouse', + clientX: 12, + clientY: 110, + }); + dispatchPointer(window, 'pointerup', { + pointerId: 301, + pointerType: 'mouse', + clientX: 12, + clientY: 110, + }); + + const lines = view.contentDOM.querySelectorAll('.cm-line'); + expect(lines[5]?.classList.contains('dnd-range-selected-line')).toBe(true); + expect(lines[1]?.classList.contains('dnd-range-selected-line')).toBe(false); + expect(lines[2]?.classList.contains('dnd-range-selected-line')).toBe(false); + expect(lines[3]?.classList.contains('dnd-range-selected-line')).toBe(false); + handler.destroy(); + }); + + it('keeps smart multi-block selection when clicking handle with cross-block text selected', () => { + const view = createViewStub(10); + (view as unknown as { dispatch: (tr: { selection?: unknown }) => void }).dispatch = (tr) => { + if (tr.selection === undefined) return; + (view as unknown as { state: EditorState }).state = EditorState.create({ + doc: view.state.doc.toString(), + selection: tr.selection as { anchor: number; head: number }, + }); + }; + const handle = document.createElement('div'); + handle.className = 'dnd-drag-handle'; + handle.setAttribute('draggable', 'true'); + view.dom.appendChild(handle); + applyTextSelection(view, 2, 4); + + const baseBlock = createBlock('- item', 1, 1); + const handler = new DragEventHandler(view, { + getDragSourceBlock: () => null, + getBlockInfoForHandle: () => baseBlock, + getBlockInfoAtPoint: () => null, + isBlockInsideRenderedTableCell: () => false, + beginPointerDragSession: vi.fn(), + finishDragSession: vi.fn(), + scheduleDropIndicatorUpdate: vi.fn(), + hideDropIndicator: vi.fn(), + performDropAtPoint: vi.fn(), + }); + + handler.attach(); + dispatchPointer(handle, 'pointerdown', { + pointerId: 94, + pointerType: 'mouse', + clientX: 12, + clientY: 30, + }); + vi.advanceTimersByTime(1); + dispatchPointer(window, 'pointerup', { + pointerId: 94, + pointerType: 'mouse', + clientX: 12, + clientY: 30, + }); + + const lines = view.contentDOM.querySelectorAll('.cm-line'); + expect(lines[1]?.classList.contains('dnd-range-selected-line')).toBe(true); + expect(lines[2]?.classList.contains('dnd-range-selected-line')).toBe(true); + expect(lines[3]?.classList.contains('dnd-range-selected-line')).toBe(true); + expect(lines[0]?.classList.contains('dnd-range-selected-line')).toBe(false); + handler.destroy(); + }); + + it('keeps smart multi-block highlight after dispatch replaces line DOM nodes', () => { + const view = createViewStub(10); + const getCurrentLines = () => Array.from(view.contentDOM.querySelectorAll('.cm-line')); + let staleLines: HTMLElement[] = []; + let useStaleDomAtPos = false; + + (view as unknown as { domAtPos: (pos: number) => { node: Node; offset: number } }).domAtPos = (pos) => { + const line = view.state.doc.lineAt(pos); + const source = useStaleDomAtPos ? staleLines : getCurrentLines(); + const node = source[Math.max(0, line.number - 1)] ?? view.contentDOM; + return { node, offset: 0 }; + }; + + Object.defineProperty(document, 'elementFromPoint', { + configurable: true, + writable: true, + value: (_x: number, y: number) => { + const lineIndex = Math.max(0, Math.floor(y / 20)); + return getCurrentLines()[lineIndex] ?? view.contentDOM; + }, + }); + + (view as unknown as { dispatch: (tr: { selection?: unknown }) => void }).dispatch = (tr) => { + if (tr.selection === undefined) return; + staleLines = getCurrentLines(); + (view as unknown as { state: EditorState }).state = EditorState.create({ + doc: view.state.doc.toString(), + selection: tr.selection as { anchor: number; head: number }, + }); + for (const [index, oldLine] of staleLines.entries()) { + const nextLine = document.createElement('div'); + nextLine.className = 'cm-line'; + nextLine.textContent = `line ${index + 1}`; + oldLine.replaceWith(nextLine); + } + useStaleDomAtPos = true; + }; + + const handle = document.createElement('div'); + handle.className = 'dnd-drag-handle'; + handle.setAttribute('draggable', 'true'); + handle.setAttribute('data-block-start', '1'); + view.dom.appendChild(handle); + applyTextSelection(view, 2, 4); + + const baseBlock = createBlock('- item', 1, 1); + const handler = new DragEventHandler(view, { + getDragSourceBlock: () => null, + getBlockInfoForHandle: () => baseBlock, + getBlockInfoAtPoint: () => null, + isBlockInsideRenderedTableCell: () => false, + beginPointerDragSession: vi.fn(), + finishDragSession: vi.fn(), + scheduleDropIndicatorUpdate: vi.fn(), + hideDropIndicator: vi.fn(), + performDropAtPoint: vi.fn(), + }); + + handler.attach(); + dispatchPointer(handle, 'pointerdown', { + pointerId: 302, + pointerType: 'mouse', + clientX: 12, + clientY: 30, + }); + vi.advanceTimersByTime(1); + + const selectedLines = view.contentDOM.querySelectorAll('.dnd-range-selected-line'); + expect(selectedLines.length).toBe(3); + handler.destroy(); + }); + + it('does not clear smart mouse selection on focusin in mobile-like environments', () => { + const view = createViewStub(10); + (view as unknown as { dispatch: (tr: { selection?: unknown }) => void }).dispatch = (tr) => { + if (tr.selection === undefined) return; + (view as unknown as { state: EditorState }).state = EditorState.create({ + doc: view.state.doc.toString(), + selection: tr.selection as { anchor: number; head: number }, + }); + }; + const handle = document.createElement('div'); + handle.className = 'dnd-drag-handle'; + handle.setAttribute('draggable', 'true'); + view.dom.appendChild(handle); + applyTextSelection(view, 2, 4); + + const baseBlock = createBlock('- item', 1, 1); + const handler = new DragEventHandler(view, { + getDragSourceBlock: () => null, + getBlockInfoForHandle: () => baseBlock, + getBlockInfoAtPoint: () => null, + isBlockInsideRenderedTableCell: () => false, + beginPointerDragSession: vi.fn(), + finishDragSession: vi.fn(), + scheduleDropIndicatorUpdate: vi.fn(), + hideDropIndicator: vi.fn(), + performDropAtPoint: vi.fn(), + }); + + handler.attach(); + dispatchPointer(handle, 'pointerdown', { + pointerId: 96, + pointerType: 'mouse', + clientX: 12, + clientY: 30, + }); + vi.advanceTimersByTime(1); + dispatchPointer(window, 'pointerup', { + pointerId: 96, + pointerType: 'mouse', + clientX: 12, + clientY: 30, + }); + + const content = view.contentDOM.querySelector('.cm-line'); + expect(content).not.toBeNull(); + content?.dispatchEvent(new FocusEvent('focusin', { bubbles: true, cancelable: true })); + + const lines = view.contentDOM.querySelectorAll('.cm-line'); + expect(lines[1]?.classList.contains('dnd-range-selected-line')).toBe(true); + expect(lines[2]?.classList.contains('dnd-range-selected-line')).toBe(true); + expect(lines[3]?.classList.contains('dnd-range-selected-line')).toBe(true); + handler.destroy(); + }); + + it('guards smart selection from immediate refresh-based clear when feature flag flips transiently', () => { + const view = createViewStub(10); + (view as unknown as { dispatch: (tr: { selection?: unknown }) => void }).dispatch = (tr) => { + if (tr.selection === undefined) return; + (view as unknown as { state: EditorState }).state = EditorState.create({ + doc: view.state.doc.toString(), + selection: tr.selection as { anchor: number; head: number }, + }); + }; + const handle = document.createElement('div'); + handle.className = 'dnd-drag-handle'; + handle.setAttribute('draggable', 'true'); + view.dom.appendChild(handle); + applyTextSelection(view, 2, 4); + + let multiLineEnabled = true; + const baseBlock = createBlock('- item', 1, 1); + const handler = new DragEventHandler(view, { + getDragSourceBlock: () => null, + getBlockInfoForHandle: () => baseBlock, + getBlockInfoAtPoint: () => null, + isBlockInsideRenderedTableCell: () => false, + isMultiLineSelectionEnabled: () => multiLineEnabled, + beginPointerDragSession: vi.fn(), + finishDragSession: vi.fn(), + scheduleDropIndicatorUpdate: vi.fn(), + hideDropIndicator: vi.fn(), + performDropAtPoint: vi.fn(), + }); + + handler.attach(); + dispatchPointer(handle, 'pointerdown', { + pointerId: 97, + pointerType: 'mouse', + clientX: 12, + clientY: 30, + }); + vi.advanceTimersByTime(1); + dispatchPointer(window, 'pointerup', { + pointerId: 97, + pointerType: 'mouse', + clientX: 12, + clientY: 30, + }); + + multiLineEnabled = false; + handler.refreshSelectionVisual(); + let lines = view.contentDOM.querySelectorAll('.cm-line'); + expect(lines[1]?.classList.contains('dnd-range-selected-line')).toBe(true); + expect(lines[2]?.classList.contains('dnd-range-selected-line')).toBe(true); + expect(lines[3]?.classList.contains('dnd-range-selected-line')).toBe(true); + + vi.advanceTimersByTime(600); + handler.refreshSelectionVisual(); + lines = view.contentDOM.querySelectorAll('.cm-line'); + expect(lines[1]?.classList.contains('dnd-range-selected-line')).toBe(false); + expect(lines[2]?.classList.contains('dnd-range-selected-line')).toBe(false); + expect(lines[3]?.classList.contains('dnd-range-selected-line')).toBe(false); + handler.destroy(); + }); + + it('guards same-pointer immediate clear but allows other pointer clicks to clear committed selection', () => { + const view = createViewStub(10); + (view as unknown as { dispatch: (tr: { selection?: unknown }) => void }).dispatch = (tr) => { + if (tr.selection === undefined) return; + (view as unknown as { state: EditorState }).state = EditorState.create({ + doc: view.state.doc.toString(), + selection: tr.selection as { anchor: number; head: number }, + }); + }; + const handle = document.createElement('div'); + handle.className = 'dnd-drag-handle'; + handle.setAttribute('draggable', 'true'); + view.dom.appendChild(handle); + applyTextSelection(view, 2, 4); + + const baseBlock = createBlock('- item', 1, 1); + const handler = new DragEventHandler(view, { + getDragSourceBlock: () => null, + getBlockInfoForHandle: () => baseBlock, + getBlockInfoAtPoint: () => null, + isBlockInsideRenderedTableCell: () => false, + beginPointerDragSession: vi.fn(), + finishDragSession: vi.fn(), + scheduleDropIndicatorUpdate: vi.fn(), + hideDropIndicator: vi.fn(), + performDropAtPoint: vi.fn(), + }); + + handler.attach(); + dispatchPointer(handle, 'pointerdown', { + pointerId: 111, + pointerType: 'mouse', + clientX: 12, + clientY: 30, + }); + vi.advanceTimersByTime(1); + dispatchPointer(window, 'pointerup', { + pointerId: 111, + pointerType: 'mouse', + clientX: 12, + clientY: 30, + }); + + let lines = view.contentDOM.querySelectorAll('.cm-line'); + expect(lines[1]?.classList.contains('dnd-range-selected-line')).toBe(true); + expect(lines[2]?.classList.contains('dnd-range-selected-line')).toBe(true); + expect(lines[3]?.classList.contains('dnd-range-selected-line')).toBe(true); + + dispatchPointer(lines[2]!, 'pointerdown', { + pointerId: 111, + pointerType: 'mouse', + clientX: 220, + clientY: 50, + }); + lines = view.contentDOM.querySelectorAll('.cm-line'); + expect(lines[1]?.classList.contains('dnd-range-selected-line')).toBe(true); + expect(lines[2]?.classList.contains('dnd-range-selected-line')).toBe(true); + expect(lines[3]?.classList.contains('dnd-range-selected-line')).toBe(true); + + dispatchPointer(lines[2]!, 'pointerdown', { + pointerId: 112, + pointerType: 'mouse', + clientX: 220, + clientY: 50, + }); + lines = view.contentDOM.querySelectorAll('.cm-line'); + expect(lines[1]?.classList.contains('dnd-range-selected-line')).toBe(false); + expect(lines[2]?.classList.contains('dnd-range-selected-line')).toBe(false); + expect(lines[3]?.classList.contains('dnd-range-selected-line')).toBe(false); + handler.destroy(); + }); + + it('clears committed selection when clicking inside selected shaded lines with a new pointer', () => { + const view = createViewStub(8); + (view as unknown as { dispatch: (tr: { selection?: unknown }) => void }).dispatch = (tr) => { + if (tr.selection === undefined) return; + (view as unknown as { state: EditorState }).state = EditorState.create({ + doc: view.state.doc.toString(), + selection: tr.selection as { anchor: number; head: number }, + }); + }; + const handle = document.createElement('div'); + handle.className = 'dnd-drag-handle'; + handle.setAttribute('draggable', 'true'); + view.dom.appendChild(handle); + applyTextSelection(view, 2, 4); + + const baseBlock = createBlock('- item', 1, 1); + const handler = new DragEventHandler(view, { + getDragSourceBlock: () => null, + getBlockInfoForHandle: () => baseBlock, + getBlockInfoAtPoint: () => null, + isBlockInsideRenderedTableCell: () => false, + beginPointerDragSession: vi.fn(), + finishDragSession: vi.fn(), + scheduleDropIndicatorUpdate: vi.fn(), + hideDropIndicator: vi.fn(), + performDropAtPoint: vi.fn(), + }); + + handler.attach(); + dispatchPointer(handle, 'pointerdown', { + pointerId: 301, + pointerType: 'mouse', + clientX: 12, + clientY: 30, + }); + dispatchPointer(window, 'pointerup', { + pointerId: 301, + pointerType: 'mouse', + clientX: 12, + clientY: 30, + }); + + let lines = view.contentDOM.querySelectorAll('.cm-line'); + expect(lines[1]?.classList.contains('dnd-range-selected-line')).toBe(true); + expect(lines[2]?.classList.contains('dnd-range-selected-line')).toBe(true); + expect(lines[3]?.classList.contains('dnd-range-selected-line')).toBe(true); + + dispatchPointer(lines[2]!, 'pointerdown', { + pointerId: 302, + pointerType: 'mouse', + clientX: 220, + clientY: 50, + }); + + lines = view.contentDOM.querySelectorAll('.cm-line'); + expect(lines[1]?.classList.contains('dnd-range-selected-line')).toBe(false); + expect(lines[2]?.classList.contains('dnd-range-selected-line')).toBe(false); + expect(lines[3]?.classList.contains('dnd-range-selected-line')).toBe(false); + handler.destroy(); + }); + + it('keeps all selected blocks when text selection has multiple disjoint ranges', () => { + const view = createViewStub(12); + (view as unknown as { dispatch: (tr: { selection?: unknown }) => void }).dispatch = (tr) => { + if (tr.selection === undefined) return; + (view as unknown as { state: EditorState }).state = EditorState.create({ + doc: view.state.doc.toString(), + selection: tr.selection as { anchor: number; head: number }, + }); + }; + const handle = document.createElement('div'); + handle.className = 'dnd-drag-handle'; + handle.setAttribute('draggable', 'true'); + view.dom.appendChild(handle); + applyMultiTextSelections(view, [ + { fromLine: 2, toLine: 2 }, + { fromLine: 6, toLine: 6 }, + ]); + + const baseBlock = createBlock('- item', 1, 1); + const handler = new DragEventHandler(view, { + getDragSourceBlock: () => null, + getBlockInfoForHandle: () => baseBlock, + getBlockInfoAtPoint: () => null, + isBlockInsideRenderedTableCell: () => false, + beginPointerDragSession: vi.fn(), + finishDragSession: vi.fn(), + scheduleDropIndicatorUpdate: vi.fn(), + hideDropIndicator: vi.fn(), + performDropAtPoint: vi.fn(), + }); + + handler.attach(); + dispatchPointer(handle, 'pointerdown', { + pointerId: 95, + pointerType: 'mouse', + clientX: 12, + clientY: 30, + }); + vi.advanceTimersByTime(1); + dispatchPointer(window, 'pointerup', { + pointerId: 95, + pointerType: 'mouse', + clientX: 12, + clientY: 30, + }); + + const lines = view.contentDOM.querySelectorAll('.cm-line'); + expect(lines[1]?.classList.contains('dnd-range-selected-line')).toBe(true); + expect(lines[5]?.classList.contains('dnd-range-selected-line')).toBe(true); + expect(lines[2]?.classList.contains('dnd-range-selected-line')).toBe(false); + expect(lines[4]?.classList.contains('dnd-range-selected-line')).toBe(false); + handler.destroy(); + }); + + it('does not reuse stale text-selection snapshot for a later handle click', () => { + const view = createViewStub(10); + const handleA = document.createElement('div'); + handleA.className = 'dnd-drag-handle'; + handleA.setAttribute('draggable', 'true'); + const handleB = document.createElement('div'); + handleB.className = 'dnd-drag-handle'; + handleB.setAttribute('draggable', 'true'); + view.dom.appendChild(handleA); + view.dom.appendChild(handleB); + + const blockA = createBlock('- item A', 1, 1); + const blockB = createBlock('- item B', 5, 5); + applyTextSelection(view, 2, 4); + + const handler = new DragEventHandler(view, { + getDragSourceBlock: () => null, + getBlockInfoForHandle: (handle) => { + if (handle === handleA) return blockA; + if (handle === handleB) return blockB; + return null; + }, + getBlockInfoAtPoint: () => null, + isBlockInsideRenderedTableCell: () => false, + beginPointerDragSession: vi.fn(), + finishDragSession: vi.fn(), + scheduleDropIndicatorUpdate: vi.fn(), + hideDropIndicator: vi.fn(), + performDropAtPoint: vi.fn(), + }); + + handler.attach(); + + // First pointerdown captures a text-selection snapshot, but does not click any handle. + const linesBefore = view.contentDOM.querySelectorAll('.cm-line'); + dispatchPointer(linesBefore[0]!, 'pointerdown', { + pointerId: 401, + pointerType: 'mouse', + clientX: 220, + clientY: 10, + }); + + // Clear live editor selection so later smart-selection must not use stale snapshot. + (view as unknown as { state: EditorState }).state = EditorState.create({ + doc: view.state.doc.toString(), + }); + + dispatchPointer(handleB, 'pointerdown', { + pointerId: 402, + pointerType: 'mouse', + clientX: 12, + clientY: 110, + }); + dispatchPointer(window, 'pointerup', { + pointerId: 402, + pointerType: 'mouse', + clientX: 12, + clientY: 110, + }); + + const lines = view.contentDOM.querySelectorAll('.cm-line'); + expect(lines[5]?.classList.contains('dnd-range-selected-line')).toBe(true); + expect(lines[1]?.classList.contains('dnd-range-selected-line')).toBe(false); + expect(lines[2]?.classList.contains('dnd-range-selected-line')).toBe(false); + expect(lines[3]?.classList.contains('dnd-range-selected-line')).toBe(false); + handler.destroy(); + }); + + it('preserves source block type after drop-restore selection', () => { + const view = createViewStub(8); + const sourceBlock = createBlock('- item', 1, 1); + const movedBlockAtTarget = createBlock('- item moved', 3, 3); + const performDropAtPoint = vi.fn(() => 5); + const handler = new DragEventHandler(view, { + getDragSourceBlock: (e) => { + const raw = e.dataTransfer?.getData('application/dnd-block') ?? ''; + if (!raw) return null; + return JSON.parse(raw) as BlockInfo; + }, + getBlockInfoForHandle: () => movedBlockAtTarget, + getBlockInfoAtPoint: () => null, + isBlockInsideRenderedTableCell: () => false, + beginPointerDragSession: vi.fn(), + finishDragSession: vi.fn(), + scheduleDropIndicatorUpdate: vi.fn(), + hideDropIndicator: vi.fn(), + performDropAtPoint, + }); + + handler.attach(); + dispatchDrop(view.dom, { + clientX: 120, + clientY: 90, + dataTransfer: { + types: ['application/dnd-block'], + getData: (type: string) => { + if (type === 'application/dnd-block') { + return JSON.stringify(sourceBlock); + } + return ''; + }, + dropEffect: 'move', + }, + }); + vi.advanceTimersByTime(1); + + const resolved = handler.resolveNativeDragSourceForHandleDrag(movedBlockAtTarget); + expect(resolved?.type).toBe(BlockType.ListItem); + expect(performDropAtPoint).toHaveBeenCalledTimes(1); + handler.destroy(); + }); }); diff --git a/src/editor/interaction/DragEventHandler.ts b/src/editor/interaction/DragEventHandler.ts index ba3debc..b3eb58f 100644 --- a/src/editor/interaction/DragEventHandler.ts +++ b/src/editor/interaction/DragEventHandler.ts @@ -1,4 +1,5 @@ import { EditorView } from '@codemirror/view'; +import { EditorSelection } from '@codemirror/state'; import { BlockInfo, DragLifecycleEvent } from '../../types'; import { getHandleColumnCenterX, @@ -17,6 +18,7 @@ import { type RangeSelectConfig, type CommittedRangeSelection, type MouseRangeSelectState, + type LineRange, normalizeLineRange, mergeLineRanges, cloneLineRanges, @@ -26,6 +28,7 @@ import { resolveTargetBoundaryForRangeSelection, resolveBlockBoundaryAtLine, } from './RangeSelectionLogic'; +import { SmartBlockSelector, SmartSelectionResult, EditorTextSelection } from './SmartBlockSelector'; const MOBILE_DRAG_LONG_PRESS_MS = 100; const MOBILE_DRAG_START_MOVE_THRESHOLD_PX = 8; @@ -35,6 +38,9 @@ const MOUSE_RANGE_SELECT_LONG_PRESS_MS = 260; const MOUSE_RANGE_SELECT_CANCEL_MOVE_THRESHOLD_PX = 12; const RANGE_SELECTION_GRIP_HIT_PADDING_PX = 20; const RANGE_SELECTION_GRIP_HIT_X_PADDING_PX = 28; +const SMART_SELECTION_REFRESH_CLEAR_GUARD_MS = 500; +const SMART_SELECTION_POINTERDOWN_CLEAR_GUARD_MS = 220; +const SMART_SELECTION_SAME_EVENT_TOLERANCE_MS = 1; type PointerDragData = { sourceBlock: BlockInfo; @@ -71,24 +77,61 @@ export interface DragEventHandlerDeps { finishDragSession: () => void; scheduleDropIndicatorUpdate: (clientX: number, clientY: number, dragSource: BlockInfo | null, pointerType: string | null) => void; hideDropIndicator: () => void; - performDropAtPoint: (sourceBlock: BlockInfo, clientX: number, clientY: number, pointerType: string | null) => void; + performDropAtPoint: (sourceBlock: BlockInfo, clientX: number, clientY: number, pointerType: string | null) => number | null; onDragLifecycleEvent?: (event: DragLifecycleEvent) => void; + setHiddenRangesForSelection?: (ranges: Array<{ startLineNumber: number; endLineNumber: number }>, anchorHandle: HTMLElement | null) => void; + clearHiddenRangesForSelection?: () => void; } export class DragEventHandler { private gesture: GestureState = { phase: 'idle' }; private committedRangeSelection: CommittedRangeSelection | null = null; + private lastCommittedSelectionSource: 'smart_mouse' | 'other' = 'other'; + private lastCommittedSelectionAtMs = 0; + private lastCommittedSelectionPointerId: number | null = null; + private lastCommittedSelectionEventTimeStamp: number | null = null; + private lastPointerType: string | null = null; + private pendingSelectionSnapshot: EditorTextSelection[] | null = null; + private pendingSmartCommitEventTimeStamp: number | null = null; + // Store selection info to restore after drag completes + private pendingSelectionRestore: { + ranges: LineRange[]; + rangeLineSpans: number[]; + sourceBlockTemplate: BlockInfo; + anchorHandle: HTMLElement | null; + sourceStartLine: number; + sourceLineCount: number; + } | null = null; readonly rangeVisual: RangeSelectionVisualManager; readonly mobile: MobileGestureController; readonly pointer: PointerSessionController; + private readonly smartSelector: SmartBlockSelector; + // Selection snapshot captured at pointerdown start, before browser might clear it + private selectionSnapshotAtPointerDown: SmartSelectionResult | null = null; private readonly onEditorPointerDown = (e: PointerEvent) => { + // CRITICAL: Capture editor selection snapshot IMMEDIATELY at pointerdown start. + // The browser/editor may clear the text selection during event processing, + // so we must capture it before any other logic runs. + this.selectionSnapshotAtPointerDown = null; + this.pendingSelectionSnapshot = null; + this.pendingSmartCommitEventTimeStamp = null; + const multiLineSelectionEnabled = this.isMultiLineSelectionEnabled(); + if (multiLineSelectionEnabled && e.button === 0) { + const snapshot = this.smartSelector.captureSelectionSnapshot(); + if (snapshot.length > 0) { + // Store snapshot for later use in startPointerDragFromHandle + // We'll evaluate it when we know which block was clicked + this.pendingSelectionSnapshot = snapshot; + } + } + const target = e.target instanceof HTMLElement ? e.target : null; if (!target) return; const pointerType = e.pointerType || null; - const multiLineSelectionEnabled = this.isMultiLineSelectionEnabled(); + this.lastPointerType = pointerType; if (!multiLineSelectionEnabled) { - this.clearCommittedRangeSelection(); + this.clearCommittedRangeSelection({ reason: 'feature_disabled' }); } const canHandleCommittedSelection = ( multiLineSelectionEnabled @@ -97,7 +140,13 @@ export class DragEventHandler { && !!this.committedRangeSelection ); - if (canHandleCommittedSelection && this.isCommittedSelectionGripHit(target, e.clientX, e.clientY, pointerType)) { + if ( + canHandleCommittedSelection + && ( + this.isCommittedSelectionGripHit(target, e.clientX, e.clientY, pointerType) + || this.isCommittedSelectionHandleHit(target) + ) + ) { const committedBlock = this.getCommittedSelectionBlock(); if (committedBlock) { this.startPointerPressDrag(committedBlock, e, { @@ -107,11 +156,18 @@ export class DragEventHandler { } } if (canHandleCommittedSelection && this.shouldClearCommittedSelectionOnPointerDown(target, e.clientX, pointerType)) { - this.clearCommittedRangeSelection(); + this.clearCommittedRangeSelection({ + reason: 'pointerdown_outside_selection', + pointerId: e.pointerId, + eventTimeStamp: e.timeStamp, + }); } const handle = target.closest(`.${DRAG_HANDLE_CLASS}`); if (handle && !handle.classList.contains(EMBED_HANDLE_CLASS)) { + // Stop propagation immediately when clicking handle to prevent + // other handlers from clearing the editor selection + e.stopPropagation(); this.startPointerDragFromHandle(handle, e); return; } @@ -173,9 +229,17 @@ export class DragEventHandler { if (!e.dataTransfer) return; const sourceBlock = this.deps.getDragSourceBlock(e); if (!sourceBlock) return; - this.deps.performDropAtPoint(sourceBlock, e.clientX, e.clientY, 'mouse'); + this.queueSelectionRestoreForSourceBlock(sourceBlock); + const targetLineNumber = this.deps.performDropAtPoint(sourceBlock, e.clientX, e.clientY, 'mouse'); this.deps.hideDropIndicator(); this.deps.finishDragSession(); + if (typeof targetLineNumber === 'number' && this.pendingSelectionRestore) { + setTimeout(() => { + this.restoreSelectionAfterDrop(targetLineNumber); + }, 0); + return; + } + this.pendingSelectionRestore = null; }; private readonly onLostPointerCapture = (e: PointerEvent) => this.handleLostPointerCapture(e); @@ -194,6 +258,7 @@ export class DragEventHandler { onDocumentVisibilityChange: () => this.handleDocumentVisibilityChange(), onTouchMove: (e) => this.handleTouchMove(e), }); + this.smartSelector = new SmartBlockSelector(view); } attach(): void { @@ -209,6 +274,8 @@ export class DragEventHandler { startPointerDragFromHandle(handle: HTMLElement, e: PointerEvent, getBlockInfo?: () => BlockInfo | null): void { if (this.gesture.phase !== 'idle') return; + const selectionSnapshot = this.pendingSelectionSnapshot ?? undefined; + this.pendingSelectionSnapshot = null; const blockInfo = (getBlockInfo ? getBlockInfo() : null) ?? this.deps.getBlockInfoForHandle(handle) @@ -217,6 +284,26 @@ export class DragEventHandler { if (this.deps.isBlockInsideRenderedTableCell(blockInfo)) return; const multiLineSelectionEnabled = this.isMultiLineSelectionEnabled(); + + // Smart block selection: if there's an editor text selection and the clicked block + // intersects with it, use the block-aligned selection directly + if (e.pointerType === 'mouse' && multiLineSelectionEnabled && e.button === 0) { + const smartResult = this.smartSelector.evaluate( + blockInfo, + selectionSnapshot + ); + + if (smartResult.shouldUseSmartSelection && smartResult.blockInfo) { + this.startRangeSelectWithPrecomputedRanges( + smartResult.blockInfo, + smartResult.ranges, + e, + handle + ); + return; + } + } + if (e.pointerType === 'mouse') { if (e.button !== 0) return; if (!multiLineSelectionEnabled) { @@ -243,7 +330,7 @@ export class DragEventHandler { destroy(): void { this.abortPointerSession({ shouldFinishDragSession: true, shouldHideDropIndicator: true }); - this.clearCommittedRangeSelection(); + this.clearCommittedRangeSelection({ reason: 'destroy' }); this.rangeVisual.destroy(); const editorDom = this.view.dom; @@ -260,9 +347,14 @@ export class DragEventHandler { return this.hasActivePointerSession(); } + hasCommittedSelection(): boolean { + return this.committedRangeSelection !== null; + } + refreshSelectionVisual(): void { - if (!this.isMultiLineSelectionEnabled()) { - this.clearCommittedRangeSelection(); + const multiLineSelectionEnabled = this.isMultiLineSelectionEnabled(); + if (!multiLineSelectionEnabled) { + this.clearCommittedRangeSelection({ reason: 'refresh_feature_disabled' }); return; } this.rangeVisual.scheduleRefresh(); @@ -366,6 +458,9 @@ export class DragEventHandler { currentLineNumber: anchorEndLineNumber, committedRangesSnapshot, selectionRanges: initialRanges, + showLinks: false, + highlightHandles: false, + shouldClearEditorSelectionOnCommit: false, } }; this.pointer.attachPointerListeners(); this.emitLifecycle({ @@ -378,6 +473,143 @@ export class DragEventHandler { }); } + /** + * Start range selection with precomputed line ranges. + * Used for smart block selection when editor text selection is converted to block selection. + */ + private startRangeSelectWithPrecomputedRanges( + anchorBlock: BlockInfo, + precomputedRanges: LineRange[], + e: PointerEvent, + handle: HTMLElement | null + ): void { + if (precomputedRanges.length === 0) return; + + const pointerType = e.pointerType || null; + const isMouse = pointerType === 'mouse'; + const sourceHandleDraggableAttr = handle?.getAttribute('draggable') ?? null; + + // Always stop propagation to prevent other handlers from clearing selection + // But for mouse, don't prevent default - allow native HTML5 drag to work + e.stopPropagation(); + if (!isMouse) { + e.preventDefault(); + this.pointer.tryCapturePointer(e); + } + if (!isMouse && handle) { + handle.setAttribute('draggable', 'false'); + } + + const anchorStartLineNumber = precomputedRanges[0].startLineNumber; + const anchorEndLineNumber = precomputedRanges[precomputedRanges.length - 1].endLineNumber; + + // For mouse: immediately commit selection and let native drag work + // For touch: use timeout to allow long-press gesture + if (isMouse) { + this.pendingSmartCommitEventTimeStamp = e.timeStamp; + // Immediately commit the selection for mouse click + const selectionState: MouseRangeSelectState = { + sourceBlock: anchorBlock, + dragSourceBlock: cloneBlockInfo(anchorBlock), + selectedBlock: anchorBlock, + pointerId: e.pointerId, + startX: e.clientX, + startY: e.clientY, + latestX: e.clientX, + latestY: e.clientY, + pointerType, + dragReady: true, + longPressReady: true, + isIntercepting: false, + timeoutId: null, + dragTimeoutId: null, + sourceHandle: handle, + sourceHandleDraggableAttr, + anchorStartLineNumber, + anchorEndLineNumber, + currentLineNumber: anchorEndLineNumber, + committedRangesSnapshot: [], + selectionRanges: precomputedRanges, + showLinks: false, + highlightHandles: false, + shouldClearEditorSelectionOnCommit: true, + }; + + this.gesture = { phase: 'range_selecting', rangeSelect: selectionState }; + + // Render visual immediately + this.rangeVisual.render(precomputedRanges, { showLinks: false, highlightHandles: false }); + if (this.deps.setHiddenRangesForSelection) { + this.deps.setHiddenRangesForSelection(precomputedRanges, handle); + } + + // Commit immediately for mouse + this.commitRangeSelection(selectionState); + this.gesture = { phase: 'idle' }; + return; + } + this.pendingSmartCommitEventTimeStamp = null; + + // For touch: use timeout to allow long-press gesture + const timeoutId = window.setTimeout(() => { + if (this.gesture.phase !== 'range_selecting') return; + const state = this.gesture.rangeSelect; + if (state.pointerId !== e.pointerId) return; + state.longPressReady = true; + this.commitRangeSelection(state); + this.finishRangeSelectionSession(); + }, 0); + + this.gesture = { + phase: 'range_selecting', + rangeSelect: { + sourceBlock: anchorBlock, + dragSourceBlock: cloneBlockInfo(anchorBlock), + selectedBlock: anchorBlock, + pointerId: e.pointerId, + startX: e.clientX, + startY: e.clientY, + latestX: e.clientX, + latestY: e.clientY, + pointerType, + dragReady: true, + longPressReady: false, + isIntercepting: true, + timeoutId, + dragTimeoutId: null, + sourceHandle: handle, + sourceHandleDraggableAttr, + anchorStartLineNumber, + anchorEndLineNumber, + currentLineNumber: anchorEndLineNumber, + committedRangesSnapshot: [], + selectionRanges: precomputedRanges, + showLinks: false, + highlightHandles: false, + shouldClearEditorSelectionOnCommit: true, + }, + }; + + // Immediately render the selection visual (without link line or handle highlights) + this.rangeVisual.render(precomputedRanges, { showLinks: false, highlightHandles: false }); + + // Hide handles for selected blocks except the anchor handle + if (this.deps.setHiddenRangesForSelection) { + this.deps.setHiddenRangesForSelection(precomputedRanges, handle); + } + + this.pointer.attachPointerListeners(); + + this.emitLifecycle({ + state: 'press_pending', + sourceBlock: anchorBlock, + targetLine: null, + listIntent: null, + rejectReason: null, + pointerType, + }); + } + private activateMouseRangeSelectInterception(state: MouseRangeSelectState): void { this.pointer.tryCapturePointerById(state.pointerId); if (state.isIntercepting) return; @@ -494,6 +726,7 @@ export class DragEventHandler { this.pointer.tryCapturePointerById(pointerId); this.pointer.attachPointerListeners(); this.gesture = { phase: 'dragging', drag: { sourceBlock, pointerId } }; + this.queueSelectionRestoreForSourceBlock(sourceBlock); this.deps.beginPointerDragSession(sourceBlock); this.deps.scheduleDropIndicatorUpdate(clientX, clientY, sourceBlock, pointerType); this.emitLifecycle({ @@ -555,7 +788,9 @@ export class DragEventHandler { e.stopPropagation(); const sourceBlock = pressState.sourceBlock; const pointerId = pressState.pointerId; - this.clearCommittedRangeSelection(); + // Preserve selection for restoration after drag + const anchorHandle = this.findHandleAtLine(sourceBlock.startLine + 1); + this.clearCommittedRangeSelection({ preserveForDrag: true, anchorHandle, reason: 'preserve_for_drag' }); this.clearPointerPressState(); this.beginPointerDrag(sourceBlock, pointerId, e.clientX, e.clientY, e.pointerType || null); } @@ -593,7 +828,12 @@ export class DragEventHandler { e.stopPropagation(); const sourceBlock = state.dragSourceBlock; const pointerId = state.pointerId; - this.clearCommittedRangeSelection(); + // Preserve selection for restoration after drag + this.clearCommittedRangeSelection({ + preserveForDrag: true, + anchorHandle: state.sourceHandle, + reason: 'preserve_for_drag', + }); this.clearMouseRangeSelectState(); this.beginPointerDrag(sourceBlock, pointerId, e.clientX, e.clientY, pointerType); } @@ -666,24 +906,352 @@ export class DragEventHandler { state.sourceBlock ); - this.rangeVisual.render(state.selectionRanges); + this.rangeVisual.render(state.selectionRanges, { showLinks: state.showLinks, highlightHandles: state.highlightHandles }); } private commitRangeSelection(state: MouseRangeSelectState): void { const docLines = this.view.state.doc.lines; const committedRanges = mergeLineRanges(docLines, state.selectionRanges); const committedBlock = buildDragSourceFromLineRanges(this.view.state.doc, committedRanges, state.sourceBlock); + + // IMPORTANT: Set committedRangeSelection BEFORE clearing editor selection + // because dispatch() triggers update event which calls refreshSelectionVisual() this.committedRangeSelection = { selectedBlock: committedBlock, ranges: committedRanges, }; - this.rangeVisual.render(committedRanges); + this.lastCommittedSelectionSource = ( + state.shouldClearEditorSelectionOnCommit + && state.pointerType === 'mouse' + ) ? 'smart_mouse' : 'other'; + this.lastCommittedSelectionAtMs = Date.now(); + this.lastCommittedSelectionPointerId = state.pointerId; + this.lastCommittedSelectionEventTimeStamp = this.lastCommittedSelectionSource === 'smart_mouse' + ? this.pendingSmartCommitEventTimeStamp + : null; + this.pendingSmartCommitEventTimeStamp = null; + + // Now safe to clear editor selection - committedRangeSelection is already set + if (state.shouldClearEditorSelectionOnCommit) { + const currentSelection = this.view.state.selection.main; + if (typeof this.view.dispatch === 'function') { + this.view.dispatch({ + selection: EditorSelection.cursor(currentSelection.head), + }); + } + } + + // Render selection visual without link bar (user doesn't want the black vertical line) + this.rangeVisual.render(committedRanges, { showLinks: false, highlightHandles: false }); + // Hide handles for non-anchor blocks during selection + if (this.deps.setHiddenRangesForSelection) { + this.deps.setHiddenRangesForSelection(committedRanges, state.sourceHandle); + } } - private clearCommittedRangeSelection(): void { + private clearCommittedRangeSelection(options?: { + preserveForDrag?: boolean; + anchorHandle?: HTMLElement | null; + reason?: string; + pointerId?: number | null; + eventTimeStamp?: number | null; + }): void { if (!this.committedRangeSelection) return; + + const reason = options?.reason ?? 'unspecified'; + if ( + reason === 'refresh_feature_disabled' + && this.lastCommittedSelectionSource === 'smart_mouse' + && Date.now() - this.lastCommittedSelectionAtMs <= SMART_SELECTION_REFRESH_CLEAR_GUARD_MS + ) { + return; + } + if ( + reason === 'pointerdown_outside_selection' + && this.lastCommittedSelectionSource === 'smart_mouse' + && typeof options?.pointerId === 'number' + && options.pointerId === this.lastCommittedSelectionPointerId + && typeof options?.eventTimeStamp === 'number' + && typeof this.lastCommittedSelectionEventTimeStamp === 'number' + && Math.abs(options.eventTimeStamp - this.lastCommittedSelectionEventTimeStamp) <= SMART_SELECTION_SAME_EVENT_TOLERANCE_MS + && Date.now() - this.lastCommittedSelectionAtMs <= SMART_SELECTION_POINTERDOWN_CLEAR_GUARD_MS + ) { + return; + } + + // If preserveForDrag is true, save the selection info for restoration after drag + if (options?.preserveForDrag) { + const ranges = cloneLineRanges(this.committedRangeSelection.ranges); + const firstRange = ranges[0]; + if (firstRange) { + const sourceLineCount = this.countLinesInRanges(ranges); + this.pendingSelectionRestore = { + ranges, + rangeLineSpans: this.getRangeLineSpans(ranges), + sourceBlockTemplate: cloneBlockInfo(this.committedRangeSelection.selectedBlock), + anchorHandle: options.anchorHandle ?? null, + sourceStartLine: firstRange.startLineNumber, + sourceLineCount, + }; + } + } + this.committedRangeSelection = null; + this.lastCommittedSelectionSource = 'other'; + this.lastCommittedSelectionAtMs = 0; + this.lastCommittedSelectionPointerId = null; + this.lastCommittedSelectionEventTimeStamp = null; this.rangeVisual.clear(); + // Restore normal handle visibility + if (this.deps.clearHiddenRangesForSelection) { + this.deps.clearHiddenRangesForSelection(); + } + } + + private restoreSelectionAfterDrop(targetStartLine: number): void { + if (!this.pendingSelectionRestore) return; + if (!Number.isFinite(targetStartLine)) { + this.pendingSelectionRestore = null; + return; + } + + const ranges = cloneLineRanges(this.pendingSelectionRestore.ranges); + const rangeLineSpans = this.pendingSelectionRestore.rangeLineSpans.length > 0 + ? this.pendingSelectionRestore.rangeLineSpans.filter((span) => span > 0) + : this.getRangeLineSpans(ranges); + const sourceLineCount = rangeLineSpans.reduce((count, span) => count + span, 0); + if (sourceLineCount <= 0) { + this.pendingSelectionRestore = null; + return; + } + + // `targetStartLine` is resolved on the pre-drop document. If any moved ranges were + // above that line, the subsequent delete shifts the inserted block upward. + const movedLinesBeforeTarget = this.countLinesBeforeTargetLine(ranges, targetStartLine); + const newStartLine = targetStartLine - movedLinesBeforeTarget; + + // Clamp to document bounds + const docLines = this.view.state.doc.lines; + const clampedStartLine = Math.max(1, Math.min(docLines, newStartLine)); + const restoredRanges = this.buildRestoredRangesFromSpans(clampedStartLine, rangeLineSpans, docLines); + const firstRestoredRange = restoredRanges[0]; + const lastRestoredRange = restoredRanges[restoredRanges.length - 1]; + if (!firstRestoredRange || !lastRestoredRange) { + this.pendingSelectionRestore = null; + return; + } + + // Clear any existing selection first + this.rangeVisual.clear(); + if (this.deps.clearHiddenRangesForSelection) { + this.deps.clearHiddenRangesForSelection(); + } + + const newBlock = this.buildRestoredSelectionBlock( + restoredRanges, + this.pendingSelectionRestore.sourceBlockTemplate + ); + + this.committedRangeSelection = { + selectedBlock: newBlock, + ranges: restoredRanges, + }; + this.lastCommittedSelectionSource = 'other'; + this.lastCommittedSelectionAtMs = Date.now(); + this.lastCommittedSelectionPointerId = null; + this.lastCommittedSelectionEventTimeStamp = null; + + // Re-render visual (without link bar) and hide other handles + // Don't show link bar after drop - user doesn't want the "big black line" + this.rangeVisual.render(restoredRanges, { showLinks: false, highlightHandles: false }); + if (this.deps.setHiddenRangesForSelection) { + const newAnchorHandle = this.findHandleAtLine(firstRestoredRange.startLineNumber); + this.deps.setHiddenRangesForSelection(restoredRanges, newAnchorHandle); + } + + this.pendingSelectionRestore = null; + } + + private countLinesInRanges(ranges: LineRange[]): number { + return ranges.reduce((count, range) => { + return count + Math.max(0, range.endLineNumber - range.startLineNumber + 1); + }, 0); + } + + private countLinesBeforeTargetLine(ranges: LineRange[], targetLineNumber: number): number { + let count = 0; + for (const range of ranges) { + if (range.endLineNumber < targetLineNumber) { + count += Math.max(0, range.endLineNumber - range.startLineNumber + 1); + } + } + return count; + } + + private getRangeLineSpans(ranges: LineRange[]): number[] { + return ranges + .map((range) => Math.max(0, range.endLineNumber - range.startLineNumber + 1)) + .filter((span) => span > 0); + } + + private buildRestoredRangesFromSpans( + startLineNumber: number, + rangeLineSpans: number[], + docLines: number + ): LineRange[] { + if (docLines < 1) return []; + const spans = rangeLineSpans.filter((span) => span > 0); + if (spans.length === 0) return []; + + const ranges: LineRange[] = []; + let cursor = Math.max(1, Math.min(docLines, startLineNumber)); + for (const span of spans) { + if (cursor > docLines) break; + const start = Math.max(1, Math.min(docLines, cursor)); + const end = Math.max(start, Math.min(docLines, start + span - 1)); + ranges.push({ + startLineNumber: start, + endLineNumber: end, + }); + cursor = end + 1; + } + return ranges; + } + + private buildRestoredSelectionBlock(restoredRanges: LineRange[], template: BlockInfo): BlockInfo { + const doc = this.view.state.doc; + const normalizedRanges = restoredRanges + .map((range) => normalizeLineRange(doc.lines, range.startLineNumber, range.endLineNumber)) + .sort((a, b) => a.startLineNumber - b.startLineNumber); + const firstRange = normalizedRanges[0]; + const lastRange = normalizedRanges[normalizedRanges.length - 1]; + if (!firstRange || !lastRange) { + return cloneBlockInfo(template); + } + + if (normalizedRanges.length === 1) { + const startLine = doc.line(firstRange.startLineNumber); + const endLine = doc.line(firstRange.endLineNumber); + return { + type: template.type, + startLine: firstRange.startLineNumber - 1, + endLine: firstRange.endLineNumber - 1, + from: startLine.from, + to: endLine.to, + indentLevel: template.indentLevel, + content: doc.sliceString(startLine.from, endLine.to), + }; + } + + const firstLine = doc.line(firstRange.startLineNumber); + const lastLine = doc.line(lastRange.endLineNumber); + const content = normalizedRanges.map((range) => { + const startLine = doc.line(range.startLineNumber); + const endLine = doc.line(range.endLineNumber); + const from = startLine.from; + const to = Math.min(endLine.to + 1, doc.length); + return doc.sliceString(from, to); + }).join(''); + + return { + type: template.type, + startLine: firstRange.startLineNumber - 1, + endLine: lastRange.endLineNumber - 1, + from: firstLine.from, + to: lastLine.to, + indentLevel: template.indentLevel, + content, + compositeSelection: { + ranges: normalizedRanges.map((range) => ({ + startLine: range.startLineNumber - 1, + endLine: range.endLineNumber - 1, + })), + }, + }; + } + + resolveNativeDragSourceForHandleDrag(baseBlockInfo: BlockInfo | null): BlockInfo | null { + if (!baseBlockInfo) return null; + if (!this.isMultiLineSelectionEnabled()) return baseBlockInfo; + + if (this.committedRangeSelection && this.isBlockInsideCommittedSelection(baseBlockInfo)) { + return cloneBlockInfo(this.committedRangeSelection.selectedBlock); + } + + if (this.gesture.phase === 'range_selecting') { + const state = this.gesture.rangeSelect; + if (state.pointerType === 'mouse') { + return cloneBlockInfo(state.selectedBlock); + } + } + + const smartResult = this.smartSelector.evaluate(baseBlockInfo); + if (smartResult.shouldUseSmartSelection && smartResult.blockInfo) { + return smartResult.blockInfo; + } + return baseBlockInfo; + } + + finalizeNativeHandleDragStart(): void { + if (this.gesture.phase !== 'range_selecting') return; + const state = this.gesture.rangeSelect; + if (state.pointerType !== 'mouse') return; + + // Native mouse drag can start before the deferred smart-commit timer fires. + // Commit now so text selection is collapsed to block selection immediately. + if (state.shouldClearEditorSelectionOnCommit) { + this.commitRangeSelection(state); + } + + this.clearMouseRangeSelectState({ preserveVisual: true }); + this.pointer.detachPointerListeners(); + this.pointer.releasePointerCapture(); + } + + private isBlockInsideCommittedSelection(blockInfo: BlockInfo): boolean { + if (!this.committedRangeSelection) return false; + const blockStart = blockInfo.startLine + 1; + const blockEnd = blockInfo.endLine + 1; + for (const range of this.committedRangeSelection.ranges) { + if (blockEnd < range.startLineNumber || blockStart > range.endLineNumber) { + continue; + } + return true; + } + return false; + } + + private queueSelectionRestoreForSourceBlock(sourceBlock: BlockInfo): void { + if (this.pendingSelectionRestore) return; + const compositeRanges = sourceBlock.compositeSelection?.ranges ?? []; + if (compositeRanges.length > 1) return; + + const docLines = this.view.state.doc.lines; + const lineRanges: LineRange[] = compositeRanges.length === 1 + ? compositeRanges.map((range) => normalizeLineRange(docLines, range.startLine + 1, range.endLine + 1)) + : [normalizeLineRange(docLines, sourceBlock.startLine + 1, sourceBlock.endLine + 1)]; + const mergedRanges = mergeLineRanges(docLines, lineRanges); + const firstRange = mergedRanges[0]; + if (!firstRange) return; + + const sourceLineCount = mergedRanges.reduce((count, range) => { + return count + Math.max(0, range.endLineNumber - range.startLineNumber + 1); + }, 0); + this.pendingSelectionRestore = { + ranges: mergedRanges, + rangeLineSpans: this.getRangeLineSpans(mergedRanges), + sourceBlockTemplate: cloneBlockInfo(sourceBlock), + anchorHandle: this.findHandleAtLine(firstRange.startLineNumber), + sourceStartLine: firstRange.startLineNumber, + sourceLineCount, + }; + } + + private findHandleAtLine(lineNumber: number): HTMLElement | null { + const blockStart = lineNumber - 1; + const selector = `.${DRAG_HANDLE_CLASS}[data-block-start="${blockStart}"]`; + const handles = this.view.dom.querySelectorAll(selector); + return handles[0] ?? null; } private getCommittedSelectionBlock(): BlockInfo | null { @@ -693,11 +1261,12 @@ export class DragEventHandler { private refreshRangeSelectionVisual(): void { if (this.gesture.phase === 'range_selecting') { - this.rangeVisual.render(this.gesture.rangeSelect.selectionRanges); + const state = this.gesture.rangeSelect; + this.rangeVisual.render(state.selectionRanges, { showLinks: false, highlightHandles: state.highlightHandles }); return; } if (this.committedRangeSelection) { - this.rangeVisual.render(this.committedRangeSelection.ranges); + this.rangeVisual.render(this.committedRangeSelection.ranges, { showLinks: false, highlightHandles: false }); } } @@ -723,9 +1292,12 @@ export class DragEventHandler { pointerType: string | null ): boolean { if (!this.committedRangeSelection) return false; - if (target.closest(`.${RANGE_SELECTION_LINK_CLASS}`)) return false; - if (target.closest(`.${RANGE_SELECTED_HANDLE_CLASS}`)) return false; - if (target.closest(`.${DRAG_HANDLE_CLASS}`)) return false; + const isLink = target.closest(`.${RANGE_SELECTION_LINK_CLASS}`); + const isSelectedHandle = target.closest(`.${RANGE_SELECTED_HANDLE_CLASS}`); + const isHandle = target.closest(`.${DRAG_HANDLE_CLASS}`); + if (isLink) return false; + if (isSelectedHandle) return false; + if (isHandle) return false; if (pointerType && pointerType !== 'mouse') { if (!this.mobile.isWithinContentTolerance(clientX)) { @@ -736,7 +1308,8 @@ export class DragEventHandler { return !inContent && !inGutter; } const centerX = getHandleColumnCenterX(this.view); - return clientX > centerX + RANGE_SELECTION_GRIP_HIT_X_PADDING_PX; + const shouldClear = clientX > centerX + RANGE_SELECTION_GRIP_HIT_X_PADDING_PX; + return shouldClear; } private isCommittedSelectionGripHit( @@ -778,14 +1351,25 @@ export class DragEventHandler { return false; } + private isCommittedSelectionHandleHit(target: HTMLElement): boolean { + if (!this.committedRangeSelection) return false; + const handle = target.closest(`.${DRAG_HANDLE_CLASS}`); + if (!handle) return false; + if (handle.classList.contains(EMBED_HANDLE_CLASS)) return false; + const blockInfo = this.deps.getBlockInfoForHandle(handle); + if (!blockInfo) return false; + return this.isBlockInsideCommittedSelection(blockInfo); + } + private finishPointerDrag(e: PointerEvent, shouldDrop: boolean): void { if (this.gesture.phase !== 'dragging') return; const state = this.gesture.drag; if (e.pointerId !== state.pointerId) return; e.preventDefault(); e.stopPropagation(); + let targetLineNumber: number | null = null; if (shouldDrop) { - this.deps.performDropAtPoint(state.sourceBlock, e.clientX, e.clientY, e.pointerType || null); + targetLineNumber = this.deps.performDropAtPoint(state.sourceBlock, e.clientX, e.clientY, e.pointerType || null); } this.abortPointerSession({ shouldFinishDragSession: true, @@ -793,6 +1377,15 @@ export class DragEventHandler { cancelReason: shouldDrop ? null : 'pointer_cancelled', pointerType: e.pointerType || null, }); + // Restore selection after drop if we had a pending restore and drop succeeded + if (shouldDrop && typeof targetLineNumber === 'number' && this.pendingSelectionRestore) { + // Use setTimeout to ensure the document update has been processed + setTimeout(() => { + this.restoreSelectionAfterDrop(targetLineNumber!); + }, 0); + return; + } + this.pendingSelectionRestore = null; } private handlePointerUp(e: PointerEvent): void { @@ -804,7 +1397,10 @@ export class DragEventHandler { if (this.gesture.phase === 'range_selecting') { const rangeState = this.gesture.rangeSelect; if (e.pointerId === rangeState.pointerId) { - if (!rangeState.longPressReady) { + // For mouse, commit selection even on quick click (longPressReady=false) + // For touch, require longPressReady to be true + const isMouse = rangeState.pointerType === 'mouse'; + if (!rangeState.longPressReady && !isMouse) { this.abortPointerSession({ shouldFinishDragSession: false, shouldHideDropIndicator: false, @@ -897,10 +1493,11 @@ export class DragEventHandler { if ( this.committedRangeSelection && this.isMobileEnvironment() + && this.lastPointerType !== 'mouse' && e.target instanceof HTMLElement && this.mobile.shouldSuppressFocusTarget(e.target) ) { - this.clearCommittedRangeSelection(); + this.clearCommittedRangeSelection({ reason: 'mobile_focus_suppressed' }); } if (!this.hasActivePointerSession()) return; this.mobile.suppressMobileKeyboard(e.target); @@ -957,6 +1554,9 @@ export class DragEventHandler { if (hadDrag && shouldFinishDragSession) { this.deps.finishDragSession(); } + if (cancelReason) { + this.pendingSelectionRestore = null; + } if (cancelReason && sourceBlock) { this.emitLifecycle({ state: 'cancelled', diff --git a/src/editor/interaction/RangeSelectionLogic.ts b/src/editor/interaction/RangeSelectionLogic.ts index d76ab6d..077d522 100644 --- a/src/editor/interaction/RangeSelectionLogic.ts +++ b/src/editor/interaction/RangeSelectionLogic.ts @@ -43,6 +43,9 @@ export type MouseRangeSelectState = { currentLineNumber: number; committedRangesSnapshot: LineRange[]; selectionRanges: LineRange[]; + showLinks: boolean; + highlightHandles: boolean; + shouldClearEditorSelectionOnCommit: boolean; }; export function normalizeLineRange(docLines: number, startLineNumber: number, endLineNumber: number): LineRange { diff --git a/src/editor/interaction/SmartBlockSelector.ts b/src/editor/interaction/SmartBlockSelector.ts new file mode 100644 index 0000000..debd0f4 --- /dev/null +++ b/src/editor/interaction/SmartBlockSelector.ts @@ -0,0 +1,145 @@ +import type { EditorView } from '@codemirror/view'; +import type { BlockInfo, LineRange } from '../../types'; +import { EditorSelectionBridge, EditorTextSelection } from '../core/services/EditorSelectionBridge'; +import { + buildDragSourceFromLineRanges, + mergeLineRanges, + resolveBlockBoundaryAtLine, + resolveBlockAlignedLineRange, +} from './RangeSelectionLogic'; + +export type SmartSelectionResult = { + shouldUseSmartSelection: boolean; + ranges: LineRange[]; + blockInfo: BlockInfo | null; +}; + +export type { EditorTextSelection }; + +/** + * SmartBlockSelector enables text-selection-to-block-selection conversion. + * When a user has text selected in the editor and clicks a handle within + * that selection, this module evaluates and returns the block-aligned + * selection that can be used for multi-block drag operations. + */ +export class SmartBlockSelector { + private readonly editorSelection: EditorSelectionBridge; + + constructor(private readonly view: EditorView) { + this.editorSelection = new EditorSelectionBridge(view); + } + + /** + * Capture current editor selection as a snapshot. + * This should be called at the very start of pointerdown event, + * before the selection might be cleared by browser/editor. + */ + captureSelectionSnapshot(): EditorTextSelection[] { + return this.editorSelection.getTextSelections(); + } + + /** + * Evaluate whether smart block selection should be triggered based on + * the clicked block and editor text selection. + * + * @param clickedBlock The block info of the handle that was clicked + * @param selectionSnapshot Optional pre-captured selection snapshot. + * If provided, uses this instead of reading live selection. + * @returns Smart selection result with ranges and block info if applicable + */ + evaluate(clickedBlock: BlockInfo, selectionSnapshot?: EditorTextSelection[]): SmartSelectionResult { + const clickedStartLine = clickedBlock.startLine + 1; + const clickedEndLine = clickedBlock.endLine + 1; + + // Use snapshot if provided, otherwise read live selection + const alignedRanges = selectionSnapshot + ? this.getBlockAlignedRangeFromSnapshot(selectionSnapshot, clickedStartLine, clickedEndLine) + : this.editorSelection.getBlockAlignedRangeIfRangeIntersecting( + clickedStartLine, + clickedEndLine + ); + + if (!alignedRanges) { + return { + shouldUseSmartSelection: false, + ranges: [], + blockInfo: null, + }; + } + + // Merge ranges if needed + const docLines = this.view.state.doc.lines; + const mergedRanges = mergeLineRanges(docLines, alignedRanges); + + // Build drag source from line ranges + const blockInfo = buildDragSourceFromLineRanges( + this.view.state.doc, + mergedRanges, + clickedBlock + ); + return { + shouldUseSmartSelection: true, + ranges: mergedRanges, + blockInfo, + }; + } + + /** + * Get block-aligned ranges from a pre-captured selection snapshot. + */ + private getBlockAlignedRangeFromSnapshot( + snapshot: EditorTextSelection[], + clickedStartLine: number, + clickedEndLine: number + ): LineRange[] | null { + if (snapshot.length === 0) return null; + + const safeStart = Math.min(clickedStartLine, clickedEndLine); + const safeEnd = Math.max(clickedStartLine, clickedEndLine); + + // Check if clicked range intersects with any selection in snapshot + const intersects = snapshot.some((selection) => ( + safeEnd >= selection.fromLine && safeStart <= selection.toLine + )); + + if (!intersects) return null; + + // Resolve block-aligned selection from snapshot + return this.resolveBlockAlignedSelectionFromSnapshot(snapshot); + } + + /** + * Resolve block-aligned selection from a pre-captured snapshot. + */ + private resolveBlockAlignedSelectionFromSnapshot(snapshot: EditorTextSelection[]): LineRange[] | null { + const state = this.view.state; + const docLines = state.doc.lines; + const ranges: LineRange[] = []; + + for (const selection of snapshot) { + const startBoundary = resolveBlockBoundaryAtLine(state, selection.fromLine); + const endBoundary = resolveBlockBoundaryAtLine(state, selection.toLine); + + const aligned = resolveBlockAlignedLineRange( + state, + startBoundary.startLineNumber, + startBoundary.endLineNumber, + endBoundary.startLineNumber, + endBoundary.endLineNumber + ); + ranges.push({ + startLineNumber: aligned.startLineNumber, + endLineNumber: aligned.endLineNumber, + }); + } + + return mergeLineRanges(docLines, ranges); + } + + /** + * Get the underlying EditorSelectionBridge instance. + */ + getEditorSelection(): EditorSelectionBridge { + return this.editorSelection; + } +} diff --git a/src/editor/orchestration/HandleInteractionOrchestrator.ts b/src/editor/orchestration/HandleInteractionOrchestrator.ts index e55aabf..71511a2 100644 --- a/src/editor/orchestration/HandleInteractionOrchestrator.ts +++ b/src/editor/orchestration/HandleInteractionOrchestrator.ts @@ -65,7 +65,9 @@ export class HandleInteractionOrchestrator { clientY: e.clientY, fallback: getBlockInfo, }); - const sourceBlock = resolveCurrentBlock(); + const sourceBlock = this.getDragEventHandler().resolveNativeDragSourceForHandleDrag( + resolveCurrentBlock() + ); if (sourceBlock) { this.handleVisibility.enterGrabVisualState( sourceBlock.startLine + 1, @@ -75,7 +77,8 @@ export class HandleInteractionOrchestrator { } else { this.handleVisibility.setActiveVisibleHandle(el); } - const started = startDragFromHandle(e, this.view, () => resolveCurrentBlock(), el); + this.getDragEventHandler().finalizeNativeHandleDragStart(); + const started = startDragFromHandle(e, this.view, () => sourceBlock, el); if (!started) { this.handleVisibility.setActiveVisibleHandle(null); finishDragSession(this.view); @@ -134,7 +137,7 @@ export class HandleInteractionOrchestrator { }); const shouldPrimePointerVisual = !( e.pointerType === 'mouse' - && !this.isMultiLineSelectionEnabled() + && this.isMultiLineSelectionEnabled() ); if (shouldPrimePointerVisual) { const blockInfo = resolveCurrentBlock(); @@ -153,7 +156,7 @@ export class HandleInteractionOrchestrator { return handle; } - performDropAtPoint(sourceBlock: BlockInfo, clientX: number, clientY: number, pointerType: string | null): void { + performDropAtPoint(sourceBlock: BlockInfo, clientX: number, clientY: number, pointerType: string | null): number | null { this.ensureDragPerfSession(); const view = this.view; const validation = this.dropTargetCalculator.resolveValidatedDropTarget({ @@ -175,7 +178,7 @@ export class HandleInteractionOrchestrator { rejectReason: validation.reason ?? 'no_target', pointerType, }); - return; + return null; } const targetLineNumber = validation.targetLineNumber; @@ -203,6 +206,7 @@ export class HandleInteractionOrchestrator { rejectReason: null, pointerType, }); + return targetLineNumber; } resolveInteractionBlockInfo(params: { diff --git a/src/editor/visual/HandleVisibilityController.spec.ts b/src/editor/visual/HandleVisibilityController.spec.ts new file mode 100644 index 0000000..26d9161 --- /dev/null +++ b/src/editor/visual/HandleVisibilityController.spec.ts @@ -0,0 +1,106 @@ +// @vitest-environment jsdom + +import { EditorState } from '@codemirror/state'; +import type { EditorView } from '@codemirror/view'; +import { describe, expect, it } from 'vitest'; +import { HandleVisibilityController } from './HandleVisibilityController'; +import { BLOCK_SELECTION_ACTIVE_CLASS } from '../core/constants'; + +function createViewStub(lineCount: number): EditorView { + const root = document.createElement('div'); + const content = document.createElement('div'); + root.appendChild(content); + document.body.appendChild(root); + + const state = EditorState.create({ + doc: Array.from({ length: lineCount }, (_, i) => `line ${i + 1}`).join('\n'), + }); + + const lineElements: HTMLElement[] = []; + for (let i = 0; i < lineCount; i++) { + const lineEl = document.createElement('div'); + lineEl.className = 'cm-line'; + lineEl.textContent = `line ${i + 1}`; + content.appendChild(lineEl); + lineElements.push(lineEl); + } + + return { + dom: root, + contentDOM: content, + state, + domAtPos: (pos: number) => { + const line = state.doc.lineAt(pos); + const node = lineElements[Math.max(0, line.number - 1)] ?? content; + return { node, offset: 0 }; + }, + } as unknown as EditorView; +} + +describe('HandleVisibilityController', () => { + it('clears selection highlight when grabbed line numbers are cleared', () => { + const view = createViewStub(5); + const controller = new HandleVisibilityController(view, { + getBlockInfoForHandle: () => null, + getDraggableBlockAtPoint: () => null, + }); + + controller.enterGrabVisualState(2, 4, null); + + expect(view.dom.querySelectorAll('.dnd-selection-highlight-line').length).toBe(3); + + controller.clearGrabbedLineNumbers(); + + expect(view.dom.querySelectorAll('.dnd-selection-highlight-line').length).toBe(0); + }); + + it('keeps only anchor handle visible while block selection is active', () => { + const view = createViewStub(6); + const controller = new HandleVisibilityController(view, { + getBlockInfoForHandle: () => null, + getDraggableBlockAtPoint: () => null, + }); + + const anchorHandle = document.createElement('div'); + anchorHandle.className = 'dnd-drag-handle'; + anchorHandle.setAttribute('data-block-start', '2'); + const otherHandle = document.createElement('div'); + otherHandle.className = 'dnd-drag-handle'; + otherHandle.setAttribute('data-block-start', '4'); + view.dom.appendChild(anchorHandle); + view.dom.appendChild(otherHandle); + + controller.setHiddenRangesForSelection([{ startLineNumber: 3, endLineNumber: 3 }], anchorHandle); + + expect(document.body.classList.contains(BLOCK_SELECTION_ACTIVE_CLASS)).toBe(true); + expect(anchorHandle.classList.contains('dnd-selection-anchor-handle')).toBe(true); + expect(anchorHandle.classList.contains('dnd-selection-handle-hidden')).toBe(false); + expect(otherHandle.classList.contains('dnd-selection-handle-hidden')).toBe(true); + }); + + it('restores handle visibility classes after block selection is cleared', () => { + const view = createViewStub(6); + const controller = new HandleVisibilityController(view, { + getBlockInfoForHandle: () => null, + getDraggableBlockAtPoint: () => null, + }); + + const anchorHandle = document.createElement('div'); + anchorHandle.className = 'dnd-drag-handle'; + anchorHandle.setAttribute('data-block-start', '1'); + const otherHandle = document.createElement('div'); + otherHandle.className = 'dnd-drag-handle'; + otherHandle.setAttribute('data-block-start', '3'); + view.dom.appendChild(anchorHandle); + view.dom.appendChild(otherHandle); + + controller.setHiddenRangesForSelection([{ startLineNumber: 2, endLineNumber: 2 }], anchorHandle); + controller.clearHiddenRangesForSelection(); + + expect(document.body.classList.contains(BLOCK_SELECTION_ACTIVE_CLASS)).toBe(false); + expect(anchorHandle.classList.contains('dnd-selection-anchor-handle')).toBe(false); + expect(anchorHandle.classList.contains('dnd-selection-handle-hidden')).toBe(false); + expect(otherHandle.classList.contains('dnd-selection-anchor-handle')).toBe(false); + expect(otherHandle.classList.contains('dnd-selection-handle-hidden')).toBe(false); + }); +}); diff --git a/src/editor/visual/HandleVisibilityController.ts b/src/editor/visual/HandleVisibilityController.ts index e995d6a..130ce98 100644 --- a/src/editor/visual/HandleVisibilityController.ts +++ b/src/editor/visual/HandleVisibilityController.ts @@ -5,12 +5,17 @@ import { hasVisibleLineNumberGutter, } from '../core/handle-position'; import { DRAG_HANDLE_CLASS } from '../core/selectors'; +import { SelectionHighlightManager } from './HoverHighlightManager'; import { HANDLE_INTERACTION_ZONE_PX, HOVER_HIDDEN_LINE_NUMBER_CLASS, GRAB_HIDDEN_LINE_NUMBER_CLASS, + BLOCK_SELECTION_ACTIVE_CLASS, } from '../core/constants'; +const SELECTION_ANCHOR_HANDLE_CLASS = 'dnd-selection-anchor-handle'; +const SELECTION_HIDDEN_HANDLE_CLASS = 'dnd-selection-handle-hidden'; + export interface HandleVisibilityDeps { getBlockInfoForHandle: (handle: HTMLElement) => BlockInfo | null; getDraggableBlockAtPoint: (clientX: number, clientY: number) => BlockInfo | null; @@ -21,6 +26,10 @@ export class HandleVisibilityController { private currentHoveredLineNumber: number | null = null; private readonly hiddenGrabbedLineNumberEls = new Set(); private activeHandle: HTMLElement | null = null; + private readonly selectionHighlight = new SelectionHighlightManager(); + // For smart block selection: hide handles in these ranges except the anchor + private hiddenRangesForSelection: Array<{ startLineNumber: number; endLineNumber: number }> = []; + private anchorHandleForSelection: HTMLElement | null = null; constructor( private readonly view: EditorView, @@ -44,6 +53,8 @@ export class HandleVisibilityController { lineNumberEl.classList.remove(GRAB_HIDDEN_LINE_NUMBER_CLASS); } this.hiddenGrabbedLineNumberEls.clear(); + // Grab highlight is tied to an active drag gesture and must not persist after cleanup. + this.selectionHighlight.clear(); } setGrabbedLineNumberRange(startLineNumber: number, endLineNumber: number): void { @@ -61,11 +72,48 @@ export class HandleVisibilityController { } } + /** + * Set ranges where handles should be hidden during block selection, + * except for the anchor handle which remains visible. + */ + setHiddenRangesForSelection( + ranges: Array<{ startLineNumber: number; endLineNumber: number }>, + anchorHandle: HTMLElement | null + ): void { + this.hiddenRangesForSelection = ranges; + this.anchorHandleForSelection = anchorHandle; + // Add body class to disable handle hover effects via CSS + document.body.classList.add(BLOCK_SELECTION_ACTIVE_CLASS); + this.reapplySelectionHandleVisibility(); + // Immediately show the anchor handle + const anchor = this.getConnectedAnchorHandle(); + if (anchor) { + this.setActiveVisibleHandle(anchor); + } + } + + /** + * Clear the hidden ranges for selection and restore normal handle behavior. + */ + clearHiddenRangesForSelection(): void { + this.hiddenRangesForSelection = []; + this.anchorHandleForSelection = null; + // Remove body class to re-enable handle hover effects + document.body.classList.remove(BLOCK_SELECTION_ACTIVE_CLASS); + this.reapplySelectionHandleVisibility(); + } + setActiveVisibleHandle( handle: HTMLElement | null, options?: { preserveHoveredLineNumber?: boolean } ): void { const preserveHoveredLineNumber = options?.preserveHoveredLineNumber === true; + + // If trying to set null but we have an anchor handle for selection, keep the anchor visible + if (!handle && this.anchorHandleForSelection) { + handle = this.anchorHandleForSelection; + } + if (this.activeHandle === handle) { if (!handle && !preserveHoveredLineNumber) { this.clearHoveredLineNumber(); @@ -106,6 +154,49 @@ export class HandleVisibilityController { ); this.clearHoveredLineNumber(); this.setGrabbedLineNumberRange(startLineNumber, endLineNumber); + this.selectionHighlight.highlight(this.view, startLineNumber, endLineNumber); + } + + clearSelectionHighlight(): void { + this.selectionHighlight.clear(); + } + + reapplySelectionHighlight(): void { + this.selectionHighlight.reapply(this.view); + } + + reapplySelectionHandleVisibility(): void { + const handles = Array.from( + this.view.dom.querySelectorAll(`.${DRAG_HANDLE_CLASS}`) + ); + const hasSelectionLock = this.hiddenRangesForSelection.length > 0; + const anchorHandle = this.getConnectedAnchorHandle(); + for (const handle of handles) { + if (!this.view.dom.contains(handle)) continue; + if (!hasSelectionLock) { + handle.classList.remove(SELECTION_ANCHOR_HANDLE_CLASS, SELECTION_HIDDEN_HANDLE_CLASS); + continue; + } + if (anchorHandle && handle === anchorHandle) { + handle.classList.add(SELECTION_ANCHOR_HANDLE_CLASS); + handle.classList.remove(SELECTION_HIDDEN_HANDLE_CLASS); + continue; + } + handle.classList.remove(SELECTION_ANCHOR_HANDLE_CLASS); + handle.classList.add(SELECTION_HIDDEN_HANDLE_CLASS); + if (this.activeHandle === handle) { + handle.classList.remove('is-visible'); + this.activeHandle = null; + } + } + if (!hasSelectionLock) return; + if (anchorHandle) { + anchorHandle.classList.add(SELECTION_ANCHOR_HANDLE_CLASS); + anchorHandle.classList.remove(SELECTION_HIDDEN_HANDLE_CLASS); + this.anchorHandleForSelection = anchorHandle; + return; + } + this.anchorHandleForSelection = null; } isPointerInHandleInteractionZone(clientX: number, clientY: number): boolean { @@ -119,6 +210,12 @@ export class HandleVisibilityController { resolveVisibleHandleFromTarget(target: EventTarget | null): HTMLElement | null { if (!(target instanceof HTMLElement)) return null; + // When there's a selection, only anchor handle should be visible + // All other handles should be hidden + if (this.hiddenRangesForSelection.length > 0) { + return null; + } + const directHandle = target.closest(`.${DRAG_HANDLE_CLASS}`); if (!directHandle) return null; if (this.view.dom.contains(directHandle)) { @@ -128,6 +225,12 @@ export class HandleVisibilityController { } resolveVisibleHandleFromPointerWhenLineNumbersHidden(clientX: number, clientY: number): HTMLElement | null { + // When there's a selection, only anchor handle should be visible + // All other handles should be hidden + if (this.hiddenRangesForSelection.length > 0) { + return null; + } + const contentRect = this.view.contentDOM.getBoundingClientRect(); if ( clientX < contentRect.left @@ -169,6 +272,19 @@ export class HandleVisibilityController { return candidates[0] ?? null; } + private getConnectedAnchorHandle(): HTMLElement | null { + if (this.anchorHandleForSelection && this.anchorHandleForSelection.isConnected) { + return this.anchorHandleForSelection; + } + if (this.hiddenRangesForSelection.length === 0) return null; + const firstRange = this.hiddenRangesForSelection[0]; + if (!firstRange) return null; + const blockStart = firstRange.startLineNumber - 1; + const selector = `.${DRAG_HANDLE_CLASS}[data-block-start="${blockStart}"]`; + const resolved = this.view.dom.querySelector(selector); + return resolved ?? null; + } + private setHoveredLineNumber(lineNumber: number): void { if (this.currentHoveredLineNumber === lineNumber && this.hiddenHoveredLineNumberEl) { return; diff --git a/src/editor/visual/HoverHighlightManager.ts b/src/editor/visual/HoverHighlightManager.ts new file mode 100644 index 0000000..89ba598 --- /dev/null +++ b/src/editor/visual/HoverHighlightManager.ts @@ -0,0 +1,72 @@ +import { EditorView } from '@codemirror/view'; +import { SELECTION_HIGHLIGHT_LINE_CLASS } from '../core/selectors'; + +/** + * Manages the selection highlight on `.cm-line` elements + * for the currently grabbed / single-block-selected range. + * + * Tracks the highlighted line range so the class can be re-applied + * after CM6 DOM updates (which may reset element classes). + */ +export class SelectionHighlightManager { + private readonly highlightedEls = new Set(); + private activeRange: { start: number; end: number } | null = null; + + highlight(view: EditorView, startLineNumber: number, endLineNumber: number): void { + this.removeClassFromElements(); + this.activeRange = { start: startLineNumber, end: endLineNumber }; + this.applyToDOM(view); + } + + clear(): void { + this.removeClassFromElements(); + this.activeRange = null; + } + + /** Re-apply after CM6 view update that may have replaced DOM elements. */ + reapply(view: EditorView): void { + if (!this.activeRange) return; + this.removeClassFromElements(); + this.applyToDOM(view); + } + + destroy(): void { + this.clear(); + } + + private applyToDOM(view: EditorView): void { + if (!this.activeRange) return; + const doc = view.state.doc; + const from = Math.max(1, Math.min(doc.lines, this.activeRange.start)); + const to = Math.max(1, Math.min(doc.lines, this.activeRange.end)); + for (let lineNumber = from; lineNumber <= to; lineNumber++) { + const lineEl = this.getLineElementForLine(view, lineNumber); + if (!lineEl) continue; + lineEl.classList.add(SELECTION_HIGHLIGHT_LINE_CLASS); + this.highlightedEls.add(lineEl); + } + } + + private removeClassFromElements(): void { + for (const el of this.highlightedEls) { + el.classList.remove(SELECTION_HIGHLIGHT_LINE_CLASS); + } + this.highlightedEls.clear(); + } + + private getLineElementForLine(view: EditorView, lineNumber: number): HTMLElement | null { + if (lineNumber < 1 || lineNumber > view.state.doc.lines) return null; + if (typeof view.domAtPos !== 'function') return null; + try { + const line = view.state.doc.line(lineNumber); + const domAtPos = view.domAtPos(line.from); + const base = domAtPos.node.nodeType === Node.TEXT_NODE + ? domAtPos.node.parentElement + : domAtPos.node; + if (!(base instanceof Element)) return null; + return base.closest('.cm-line') ?? null; + } catch { + return null; + } + } +} diff --git a/src/editor/visual/RangeSelectionVisualManager.ts b/src/editor/visual/RangeSelectionVisualManager.ts index 816b1a8..0363917 100644 --- a/src/editor/visual/RangeSelectionVisualManager.ts +++ b/src/editor/visual/RangeSelectionVisualManager.ts @@ -16,6 +16,8 @@ import { import { GRAB_HIDDEN_LINE_NUMBER_CLASS } from '../core/constants'; const RANGE_SELECTED_LINE_NUMBER_HIDDEN_CLASS = GRAB_HIDDEN_LINE_NUMBER_CLASS; +const LINE_RESOLUTION_RETRY_DELAY_MS = 32; +const MAX_LINE_RESOLUTION_RETRIES = 3; export class RangeSelectionVisualManager { private readonly lineElements = new Set(); @@ -25,6 +27,8 @@ export class RangeSelectionVisualManager { private refreshRafHandle: number | null = null; private scrollContainer: HTMLElement | null = null; private readonly onScroll: () => void; + private lineResolutionRetryHandle: number | null = null; + private lineResolutionRetryCount = 0; constructor( private readonly view: EditorView, @@ -34,19 +38,24 @@ export class RangeSelectionVisualManager { this.bindScrollListener(); } - render(ranges: LineRange[]): void { + render(ranges: LineRange[], options?: { showLinks?: boolean; highlightHandles?: boolean }): void { + const showLinks = options?.showLinks ?? true; + const highlightHandles = options?.highlightHandles ?? true; const normalizedRanges = this.mergeLineRanges(ranges); const nextLineElements = new Set(); const nextLineNumberElements = new Set(); const nextHandleElements = new Set(); const doc = this.view.state.doc; const visibleRanges = this.view.visibleRanges ?? [{ from: 0, to: doc.length }]; + + let matchedLines: number[] = []; for (const range of visibleRanges) { let pos = range.from; while (pos <= range.to) { const line = doc.lineAt(pos); const lineNumber = line.number; if (this.isLineNumberInRanges(lineNumber, normalizedRanges)) { + matchedLines.push(lineNumber); const lineEl = this.getLineElementForLine(lineNumber); if (lineEl) { nextLineElements.add(lineEl); @@ -55,14 +64,21 @@ export class RangeSelectionVisualManager { if (lineNumberEl) { nextLineNumberElements.add(lineNumberEl); } - const handleEl = this.getInlineHandleForLine(lineNumber); - if (handleEl) { - nextHandleElements.add(handleEl); + if (highlightHandles) { + const handleEl = this.getInlineHandleForLine(lineNumber); + if (handleEl) { + nextHandleElements.add(handleEl); + } } } pos = line.to + 1; } } + if (matchedLines.length > 0 && nextLineElements.size === 0) { + this.scheduleLineResolutionRetry(); + } else { + this.clearLineResolutionRetry(); + } this.syncSelectionElements( this.lineElements, nextLineElements, @@ -78,10 +94,15 @@ export class RangeSelectionVisualManager { nextHandleElements, RANGE_SELECTED_HANDLE_CLASS ); - this.updateLinks(normalizedRanges); + if (showLinks) { + this.updateLinks(normalizedRanges); + } else { + this.hideLinks(); + } } clear(): void { + // Clear tracked elements for (const lineEl of this.lineElements) { lineEl.classList.remove(RANGE_SELECTED_LINE_CLASS); } @@ -97,6 +118,19 @@ export class RangeSelectionVisualManager { } this.handleElements.clear(); + // Also clear any remaining elements in the DOM that might have been left behind + // (this can happen if the document changed and elements were replaced) + const remainingLineElements = this.view.dom.querySelectorAll(`.${RANGE_SELECTED_LINE_CLASS}`); + remainingLineElements.forEach(el => el.classList.remove(RANGE_SELECTED_LINE_CLASS)); + + const remainingHandleElements = this.view.dom.querySelectorAll(`.${RANGE_SELECTED_HANDLE_CLASS}`); + remainingHandleElements.forEach(el => el.classList.remove(RANGE_SELECTED_HANDLE_CLASS)); + + this.hideLinks(); + this.clearLineResolutionRetry(); + } + + private hideLinks(): void { for (const link of this.linkEls) { link.classList.remove('is-active'); } @@ -167,12 +201,20 @@ export class RangeSelectionVisualManager { if (typeof this.view.domAtPos !== 'function') return null; try { const line = this.view.state.doc.line(lineNumber); - const domAtPos = this.view.domAtPos(line.from); - const base = domAtPos.node.nodeType === Node.TEXT_NODE - ? domAtPos.node.parentElement - : domAtPos.node; - if (!(base instanceof Element)) return null; - return base.closest('.cm-line') ?? null; + const fromMatch = this.resolveConnectedLineFromPos(line.from); + if (fromMatch) return fromMatch; + const toMatch = this.resolveConnectedLineFromPos(line.to); + if (toMatch) return toMatch; + const coords = this.view.coordsAtPos(line.from); + if (!coords || typeof document.elementFromPoint !== 'function') return null; + const x = Math.round((coords.left + coords.right) / 2); + const y = Math.round((coords.top + coords.bottom) / 2); + const hit = document.elementFromPoint(x, y); + if (!(hit instanceof Element)) return null; + const lineEl = hit.closest('.cm-line'); + if (!lineEl || !lineEl.isConnected) return null; + if (!this.view.contentDOM.contains(lineEl)) return null; + return lineEl; } catch { return null; } @@ -185,6 +227,7 @@ export class RangeSelectionVisualManager { } this.linkEls.length = 0; this.cancelScheduledRefresh(); + this.clearLineResolutionRetry(); this.unbindScrollListener(); } @@ -209,20 +252,58 @@ export class RangeSelectionVisualManager { next: Set, className: string ): void { + // Remove class from elements that are no longer selected + // Use a direct DOM check instead of relying on Set identity for (const el of current) { - if (next.has(el)) continue; - el.classList.remove(className); + if (!next.has(el) && el.isConnected) { + el.classList.remove(className); + } } + + // Add class to all elements in next set (they should all have the class) for (const el of next) { - if (current.has(el)) continue; - el.classList.add(className); + if (!el.isConnected) continue; + if (!el.classList.contains(className)) { + el.classList.add(className); + } } + current.clear(); for (const el of next) { + if (!el.isConnected) continue; current.add(el); } } + private resolveConnectedLineFromPos(pos: number): HTMLElement | null { + const domAtPos = this.view.domAtPos(pos); + const base = domAtPos.node.nodeType === Node.TEXT_NODE + ? domAtPos.node.parentElement + : domAtPos.node; + if (!(base instanceof Element)) return null; + const lineEl = base.closest('.cm-line'); + if (!lineEl || !lineEl.isConnected) return null; + if (!this.view.contentDOM.contains(lineEl)) return null; + return lineEl; + } + + private scheduleLineResolutionRetry(): void { + if (this.lineResolutionRetryCount >= MAX_LINE_RESOLUTION_RETRIES) return; + if (this.lineResolutionRetryHandle !== null) return; + this.lineResolutionRetryCount += 1; + this.lineResolutionRetryHandle = window.setTimeout(() => { + this.lineResolutionRetryHandle = null; + this.scheduleRefresh(); + }, LINE_RESOLUTION_RETRY_DELAY_MS); + } + + private clearLineResolutionRetry(): void { + this.lineResolutionRetryCount = 0; + if (this.lineResolutionRetryHandle === null) return; + window.clearTimeout(this.lineResolutionRetryHandle); + this.lineResolutionRetryHandle = null; + } + private isLineNumberInRanges(lineNumber: number, ranges: LineRange[]): boolean { for (const range of ranges) { if (lineNumber >= range.startLineNumber && lineNumber <= range.endLineNumber) { diff --git a/src/i18n.ts b/src/i18n.ts index a595ce5..d4cc97c 100644 --- a/src/i18n.ts +++ b/src/i18n.ts @@ -34,6 +34,10 @@ const zh = { handleOffset: '手柄横向位置', handleOffsetDesc: '向左为负值,向右为正值', + // Selection highlight color + selectionHighlightColor: '选中高亮颜色', + selectionHighlightColorDesc: '选中块时行背景光晕的颜色', + // Indicator color indicatorColor: '指示器颜色', indicatorColorDesc: '跟随主题强调色或自定义颜色', @@ -75,6 +79,9 @@ const en: typeof zh = { handleOffset: 'Handle horizontal offset', handleOffsetDesc: 'Negative = left, positive = right', + selectionHighlightColor: 'Selection highlight color', + selectionHighlightColorDesc: 'Background glow color on selected blocks', + indicatorColor: 'Indicator color', indicatorColorDesc: 'Follow theme accent or pick a custom color', diff --git a/src/main.ts b/src/main.ts index 46754fe..19f99f8 100644 --- a/src/main.ts +++ b/src/main.ts @@ -59,7 +59,7 @@ export default class DragNDropPlugin extends Plugin { let colorValue = ''; if (this.settings.handleColorMode === 'theme') { - colorValue = 'var(--interactive-accent)'; + colorValue = 'var(--text-normal)'; } else if (this.settings.handleColor) { colorValue = this.settings.handleColor; } @@ -72,6 +72,19 @@ export default class DragNDropPlugin extends Plugin { body.style.removeProperty('--dnd-handle-color-hover'); } + let selectionHighlightColorValue = ''; + if (this.settings.selectionHighlightColorMode === 'theme') { + selectionHighlightColorValue = 'var(--interactive-accent)'; + } else if (this.settings.selectionHighlightColor) { + selectionHighlightColorValue = this.settings.selectionHighlightColor; + } + + if (selectionHighlightColorValue) { + body.style.setProperty('--dnd-selection-highlight-color', selectionHighlightColorValue); + } else { + body.style.removeProperty('--dnd-selection-highlight-color'); + } + let indicatorColorValue = ''; if (this.settings.indicatorColorMode === 'theme') { indicatorColorValue = 'var(--interactive-accent)'; @@ -85,10 +98,10 @@ export default class DragNDropPlugin extends Plugin { body.style.removeProperty('--dnd-drop-indicator-color'); } - const handleSize = Math.max(12, Math.min(28, this.settings.handleSize ?? 16)); + const handleSize = Math.max(12, Math.min(36, this.settings.handleSize ?? 24)); setHandleSizePx(handleSize); body.style.setProperty('--dnd-handle-size', `${handleSize}px`); - body.style.setProperty('--dnd-handle-core-size', `${Math.round(handleSize * 0.5)}px`); + body.style.setProperty('--dnd-handle-core-size', `${Math.round(handleSize * 0.65)}px`); body.setAttribute('data-dnd-handle-icon', this.settings.handleIcon ?? 'dot'); window.dispatchEvent(new Event('dnd:settings-updated')); diff --git a/src/settings.ts b/src/settings.ts index 95cd722..4184f4e 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -24,6 +24,10 @@ export interface DragNDropSettings { enableCrossFileDrag: boolean; // 是否启用多行选取拖拽 enableMultiLineSelection: boolean; + // 选中高亮颜色模式 + selectionHighlightColorMode: 'theme' | 'custom'; + // 选中高亮颜色(自定义时生效) + selectionHighlightColor: string; // 手柄横向偏移量(像素) handleHorizontalOffsetPx: number; // 手柄是否与行号对齐 @@ -35,9 +39,11 @@ export const DEFAULT_SETTINGS: DragNDropSettings = { handleColor: '#8a8a8a', handleVisibility: 'hover', handleIcon: 'dot', - handleSize: 16, + handleSize: 24, indicatorColorMode: 'theme', indicatorColor: '#7a7a7a', + selectionHighlightColorMode: 'theme', + selectionHighlightColor: '#4f9eff', enableCrossFileDrag: false, enableMultiLineSelection: true, handleHorizontalOffsetPx: 0, @@ -110,7 +116,7 @@ export class DragNDropSettingTab extends PluginSettingTab { .setName(i.handleSize) .setDesc(i.handleSizeDesc) .addSlider((slider) => slider - .setLimits(12, 28, 2) + .setLimits(12, 36, 2) .setDynamicTooltip() .setValue(this.plugin.settings.handleSize) .onChange(async (value) => { @@ -140,6 +146,26 @@ export class DragNDropSettingTab extends PluginSettingTab { await this.plugin.saveSettings(); })); + const selectionHighlightSetting = new Setting(containerEl) + .setName(i.selectionHighlightColor) + .setDesc(i.selectionHighlightColorDesc); + + selectionHighlightSetting.addDropdown(dropdown => dropdown + .addOption('theme', i.optionTheme) + .addOption('custom', i.optionCustom) + .setValue(this.plugin.settings.selectionHighlightColorMode) + .onChange(async (value: 'theme' | 'custom') => { + this.plugin.settings.selectionHighlightColorMode = value; + await this.plugin.saveSettings(); + })); + + selectionHighlightSetting.addColorPicker(picker => picker + .setValue(this.plugin.settings.selectionHighlightColor) + .onChange(async (value) => { + this.plugin.settings.selectionHighlightColor = value; + await this.plugin.saveSettings(); + })); + const indicatorSetting = new Setting(containerEl) .setName(i.indicatorColor) .setDesc(i.indicatorColorDesc); diff --git a/styles.css b/styles.css index 3261305..59bf91e 100644 --- a/styles.css +++ b/styles.css @@ -7,11 +7,11 @@ /* 拖拽手柄 - 完全悬浮定位,不影响文档布局 */ .dnd-drag-handle { position: absolute; - left: -16px; + left: calc(-1 * var(--dnd-handle-size, 24px)); top: 0; transform: none; - width: var(--dnd-handle-size, 16px); - height: var(--dnd-handle-size, 16px); + width: var(--dnd-handle-size, 24px); + height: var(--dnd-handle-size, 24px); display: flex; align-items: center; justify-content: center; @@ -23,7 +23,8 @@ z-index: 1; } -.dnd-drag-handle:hover { +/* Disable hover state when block selection is active - only anchor handle should be visible */ +body:not(.dnd-block-selection-active) .dnd-drag-handle:hover { opacity: 1 !important; color: var(--dnd-handle-color-hover, var(--dnd-handle-color, var(--text-accent))); filter: brightness(1.08); @@ -34,8 +35,8 @@ } .dnd-handle-core { - width: var(--dnd-handle-core-size, 8px); - height: var(--dnd-handle-core-size, 8px); + width: var(--dnd-handle-core-size, 16px); + height: var(--dnd-handle-core-size, 16px); border-radius: 999px; background: currentColor; box-shadow: @@ -50,7 +51,7 @@ border-radius: 0; background: none; box-shadow: none; - font-size: var(--dnd-handle-core-size, 8px); + font-size: var(--dnd-handle-core-size, 16px); line-height: 1; } [data-dnd-handle-icon="grip-dots"] .dnd-handle-core::before { @@ -64,7 +65,7 @@ border-radius: 0; background: none; box-shadow: none; - font-size: var(--dnd-handle-core-size, 8px); + font-size: var(--dnd-handle-core-size, 16px); line-height: 1; } [data-dnd-handle-icon="grip-lines"] .dnd-handle-core::before { @@ -76,6 +77,12 @@ border-radius: 2px; } +/* 选中高亮:单块拖拽时对应行的背景光晕 */ +.cm-line.dnd-selection-highlight-line { + background: color-mix(in srgb, var(--dnd-selection-highlight-color, var(--interactive-accent)) 12%, transparent); + border-radius: 4px; +} + /* 所有块共用同一显示态:由统一事件链添加 is-visible */ .dnd-drag-handle.is-visible { opacity: 0.5; @@ -113,18 +120,30 @@ 0 0 10px color-mix(in srgb, var(--dnd-handle-color, var(--interactive-accent)) 45%, transparent); } -.markdown-source-view.mod-cm6 .cm-editor.dnd-root-editor .cm-content.dnd-main-content > .cm-line.dnd-range-selected-line { - background: color-mix(in srgb, var(--interactive-accent) 14%, transparent); +body.dnd-block-selection-active .dnd-drag-handle.dnd-selection-handle-hidden { + opacity: 0 !important; + pointer-events: none !important; +} + +body.dnd-block-selection-active .dnd-drag-handle.dnd-selection-anchor-handle { + opacity: 1 !important; + pointer-events: auto !important; +} + +.cm-line.dnd-range-selected-line { + background: color-mix(in srgb, var(--dnd-selection-highlight-color, var(--interactive-accent)) 14%, transparent); border-radius: 4px; } .dnd-range-selection-link { position: absolute; - width: 4px; + width: 3px; transform: translateX(-50%); border-radius: 999px; - background-color: var(--dnd-handle-color, var(--interactive-accent)); - box-shadow: 0 0 6px color-mix(in srgb, var(--dnd-handle-color, var(--interactive-accent)) 36%, transparent); + /* Use a more reliable color fallback chain to avoid "black sidebar" in light themes. + Fallback order: custom var -> text-accent -> interactive-accent -> explicit purple */ + background-color: var(--dnd-selection-highlight-color, var(--text-accent, var(--interactive-accent, #7c3aed))); + box-shadow: 0 0 4px color-mix(in srgb, var(--dnd-selection-highlight-color, var(--text-accent, var(--interactive-accent, #7c3aed))) 28%, transparent); will-change: left, top, height, opacity; transition: left 45ms linear, @@ -230,7 +249,6 @@ body.dnd-mobile-gesture-lock { pointer-events: none; z-index: 9999; border-radius: 6px; - background: color-mix(in srgb, var(--dnd-drop-highlight-color, var(--interactive-accent)) 14%, transparent); - box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--dnd-drop-highlight-color, var(--interactive-accent)) 36%, transparent); + background: color-mix(in srgb, var(--dnd-drop-indicator-color, var(--interactive-accent)) 14%, transparent); + box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--dnd-drop-indicator-color, var(--interactive-accent)) 36%, transparent); } -