From a69dbf1b77636efe3faa964d1c7f7d2400e6578a Mon Sep 17 00:00:00 2001 From: lushuilu <2981999092@qq.com> Date: Thu, 12 Feb 2026 23:22:27 +0800 Subject: [PATCH 01/25] feat: enlarge drag handle default size for better visibility Increase default handle size from 16px to 24px and core-to-handle ratio from 50% to 65% to match Heptabase-style grip dots. Raise max size slider limit from 28px to 36px. Make CSS left offset dynamic via calc(). Co-Authored-By: Claude Opus 4.6 --- esbuild.config.mjs | 2 +- package-lock.json | 4 ++-- src/editor/core/constants.ts | 4 ++-- src/main.ts | 4 ++-- src/settings.ts | 4 ++-- styles.css | 14 +++++++------- 6 files changed, 16 insertions(+), 16 deletions(-) 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..6a2620e 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 { diff --git a/src/main.ts b/src/main.ts index 46754fe..e1dddb6 100644 --- a/src/main.ts +++ b/src/main.ts @@ -85,10 +85,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..d24a1c1 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -35,7 +35,7 @@ export const DEFAULT_SETTINGS: DragNDropSettings = { handleColor: '#8a8a8a', handleVisibility: 'hover', handleIcon: 'dot', - handleSize: 16, + handleSize: 24, indicatorColorMode: 'theme', indicatorColor: '#7a7a7a', enableCrossFileDrag: false, @@ -110,7 +110,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) => { diff --git a/styles.css b/styles.css index 3261305..ca84e2f 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; @@ -34,8 +34,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 +50,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 +64,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 { From 6fab21c5b542009f647b6646f800fef284e2ccf1 Mon Sep 17 00:00:00 2001 From: lushuilu <2981999092@qq.com> Date: Fri, 13 Feb 2026 00:13:37 +0800 Subject: [PATCH 02/25] =?UTF-8?q?feat:=20=E5=A2=9E=E5=8A=A0=E5=9D=97?= =?UTF-8?q?=E9=80=89=E4=B8=AD=E7=9A=84=E8=83=8C=E6=99=AF=E5=85=89=E6=99=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 单块拖拽和多块选取时,被选中的行显示可配置颜色的背景光晕。 新增 SelectionHighlightManager 管理高亮状态,通过 CM6 update 周期自动重新应用 class 以应对 DOM 重渲染。点击非手柄区域自动 清除光晕。设置面板新增「选中高亮颜色」配置项。 Co-Authored-By: Claude Opus 4.6 --- src/editor/core/selectors.ts | 1 + src/editor/drag-handle.ts | 13 ++++ .../visual/HandleVisibilityController.ts | 11 +++ src/editor/visual/HoverHighlightManager.ts | 72 +++++++++++++++++++ src/i18n.ts | 7 ++ src/main.ts | 13 ++++ src/settings.ts | 26 +++++++ styles.css | 10 ++- 8 files changed, 151 insertions(+), 2 deletions(-) create mode 100644 src/editor/visual/HoverHighlightManager.ts 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/drag-handle.ts b/src/editor/drag-handle.ts index 69434ee..0baf87d 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) { @@ -189,6 +191,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 +222,7 @@ function createDragHandleViewPlugin(_plugin: DragNDropPlugin) { this.handleVisibility.setActiveVisibleHandle(null); this.reResolveActiveHandle(); } + this.handleVisibility.reapplySelectionHighlight(); return; } @@ -238,12 +242,14 @@ function createDragHandleViewPlugin(_plugin: DragNDropPlugin) { this.handleVisibility.setActiveVisibleHandle(null); this.reResolveActiveHandle(); } + this.handleVisibility.reapplySelectionHighlight(); } 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 +270,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/visual/HandleVisibilityController.ts b/src/editor/visual/HandleVisibilityController.ts index e995d6a..25cc481 100644 --- a/src/editor/visual/HandleVisibilityController.ts +++ b/src/editor/visual/HandleVisibilityController.ts @@ -5,6 +5,7 @@ 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, @@ -21,6 +22,7 @@ export class HandleVisibilityController { private currentHoveredLineNumber: number | null = null; private readonly hiddenGrabbedLineNumberEls = new Set(); private activeHandle: HTMLElement | null = null; + private readonly selectionHighlight = new SelectionHighlightManager(); constructor( private readonly view: EditorView, @@ -106,6 +108,15 @@ 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); } isPointerInHandleInteractionZone(clientX: number, clientY: number): boolean { 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/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 e1dddb6..ff16be5 100644 --- a/src/main.ts +++ b/src/main.ts @@ -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)'; diff --git a/src/settings.ts b/src/settings.ts index d24a1c1..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; // 手柄是否与行号对齐 @@ -38,6 +42,8 @@ export const DEFAULT_SETTINGS: DragNDropSettings = { handleSize: 24, indicatorColorMode: 'theme', indicatorColor: '#7a7a7a', + selectionHighlightColorMode: 'theme', + selectionHighlightColor: '#4f9eff', enableCrossFileDrag: false, enableMultiLineSelection: true, handleHorizontalOffsetPx: 0, @@ -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 ca84e2f..6d59008 100644 --- a/styles.css +++ b/styles.css @@ -76,6 +76,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,8 +119,8 @@ 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); +.cm-line.dnd-range-selected-line { + background: color-mix(in srgb, var(--dnd-selection-highlight-color, var(--interactive-accent)) 14%, transparent); border-radius: 4px; } From 354d61d2ed1526686d3a50640fa26976b9325f60 Mon Sep 17 00:00:00 2001 From: lushuilu <2981999092@qq.com> Date: Fri, 13 Feb 2026 00:30:34 +0800 Subject: [PATCH 03/25] fix: use correct CSS variable for drop highlight color The .dnd-drop-highlight class was using --dnd-drop-highlight-color which was never set in main.ts. Changed to use --dnd-drop-indicator-color so the indicator color setting actually applies to drop highlights. Co-Authored-By: Claude Opus 4.6 --- styles.css | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/styles.css b/styles.css index 6d59008..d165d57 100644 --- a/styles.css +++ b/styles.css @@ -236,7 +236,7 @@ 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); } From 0fb9a346ee55f8cc45be83718b8ff325980b5814 Mon Sep 17 00:00:00 2001 From: lushuilu <2981999092@qq.com> Date: Fri, 13 Feb 2026 00:31:05 +0800 Subject: [PATCH 04/25] feat: use text color for handle by default Changed default handle color from --interactive-accent to --text-normal so the handle matches the normal text color (black/white based on light/dark theme). Co-Authored-By: Claude Opus 4.6 --- src/main.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main.ts b/src/main.ts index ff16be5..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; } From 34b0a57d5af4e8e6bb1b93ac00a8c7477475cb39 Mon Sep 17 00:00:00 2001 From: lushuilu <2981999092@qq.com> Date: Fri, 13 Feb 2026 00:49:40 +0800 Subject: [PATCH 05/25] feat: add text selection to block selection conversion When user selects text in editor (possibly across multiple blocks) and clicks a handle within that selection, all blocks covered by the text selection are automatically selected as complete blocks. New modules: - EditorSelectionBridge: reads CodeMirror text selection and converts to block-aligned line ranges - SmartBlockSelector: evaluates whether smart block selection should be triggered based on clicked block and editor selection Modified: - DragEventHandler: integrated SmartBlockSelector and added startRangeSelectWithPrecomputedRanges method Co-Authored-By: Claude Opus 4.6 --- .../core/services/EditorSelectionBridge.ts | 100 ++++++++++++++++++ src/editor/interaction/DragEventHandler.ts | 97 +++++++++++++++++ src/editor/interaction/SmartBlockSelector.ts | 71 +++++++++++++ 3 files changed, 268 insertions(+) create mode 100644 src/editor/core/services/EditorSelectionBridge.ts create mode 100644 src/editor/interaction/SmartBlockSelector.ts diff --git a/src/editor/core/services/EditorSelectionBridge.ts b/src/editor/core/services/EditorSelectionBridge.ts new file mode 100644 index 0000000..71489f9 --- /dev/null +++ b/src/editor/core/services/EditorSelectionBridge.ts @@ -0,0 +1,100 @@ +import type { EditorView } from '@codemirror/view'; +import type { LineRange } from '../../../types'; +import { resolveBlockBoundaryAtLine, resolveBlockAlignedLineRange } 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 { + const selection = this.view.state.selection; + const main = selection.main; + + // Empty selection (just cursor position) is not a valid selection + if (main.empty) { + return null; + } + + const doc = this.view.state.doc; + const fromLine = doc.lineAt(main.from).number; + const toLine = doc.lineAt(main.to).number; + + return { + from: main.from, + to: main.to, + fromLine, + toLine, + }; + } + + /** + * Check if a line number is within the current editor selection. + */ + isLineInSelection(lineNumber: number): boolean { + const selection = this.getTextSelection(); + if (!selection) return false; + return 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 selection = this.getTextSelection(); + if (!selection) return null; + + const state = this.view.state; + + // 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 + ); + + return [{ + startLineNumber: aligned.startLineNumber, + endLineNumber: aligned.endLineNumber, + }]; + } + + /** + * 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 { + const selection = this.getTextSelection(); + if (!selection) return null; + + // Check if the line is within the selection range + if (!this.isLineInSelection(lineNumber)) return null; + + // Return the block-aligned selection + return this.resolveBlockAlignedSelection(); + } +} diff --git a/src/editor/interaction/DragEventHandler.ts b/src/editor/interaction/DragEventHandler.ts index ba3debc..5f99c38 100644 --- a/src/editor/interaction/DragEventHandler.ts +++ b/src/editor/interaction/DragEventHandler.ts @@ -26,6 +26,8 @@ import { resolveTargetBoundaryForRangeSelection, resolveBlockBoundaryAtLine, } from './RangeSelectionLogic'; +import { SmartBlockSelector } from './SmartBlockSelector'; +import type { LineRange } from '../../types'; const MOBILE_DRAG_LONG_PRESS_MS = 100; const MOBILE_DRAG_START_MOVE_THRESHOLD_PX = 8; @@ -81,6 +83,7 @@ export class DragEventHandler { readonly rangeVisual: RangeSelectionVisualManager; readonly mobile: MobileGestureController; readonly pointer: PointerSessionController; + private readonly smartSelector: SmartBlockSelector; private readonly onEditorPointerDown = (e: PointerEvent) => { const target = e.target instanceof HTMLElement ? e.target : null; @@ -194,6 +197,7 @@ export class DragEventHandler { onDocumentVisibilityChange: () => this.handleDocumentVisibilityChange(), onTouchMove: (e) => this.handleTouchMove(e), }); + this.smartSelector = new SmartBlockSelector(view); } attach(): void { @@ -217,6 +221,22 @@ 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); + 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) { @@ -378,6 +398,83 @@ 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 sourceHandleDraggableAttr = handle?.getAttribute('draggable') ?? null; + + e.preventDefault(); + e.stopPropagation(); + this.pointer.tryCapturePointer(e); + if (handle) { + handle.setAttribute('draggable', 'false'); + } + + const anchorStartLineNumber = precomputedRanges[0].startLineNumber; + const anchorEndLineNumber = precomputedRanges[precomputedRanges.length - 1].endLineNumber; + + // For smart selection, we skip the long press wait and immediately commit + 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; + // Immediately commit the selection + 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, + }, + }; + + // Immediately render the selection visual + this.rangeVisual.render(precomputedRanges); + 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; diff --git a/src/editor/interaction/SmartBlockSelector.ts b/src/editor/interaction/SmartBlockSelector.ts new file mode 100644 index 0000000..bcfa740 --- /dev/null +++ b/src/editor/interaction/SmartBlockSelector.ts @@ -0,0 +1,71 @@ +import type { EditorView } from '@codemirror/view'; +import type { BlockInfo, LineRange } from '../../types'; +import { EditorSelectionBridge } from '../core/services/EditorSelectionBridge'; +import { buildDragSourceFromLineRanges, mergeLineRanges } from './RangeSelectionLogic'; + +export type SmartSelectionResult = { + shouldUseSmartSelection: boolean; + ranges: LineRange[]; + blockInfo: BlockInfo | null; +}; + +/** + * 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); + } + + /** + * Evaluate whether smart block selection should be triggered based on + * the clicked block and current editor text selection. + * + * @param clickedBlock The block info of the handle that was clicked + * @returns Smart selection result with ranges and block info if applicable + */ + evaluate(clickedBlock: BlockInfo): SmartSelectionResult { + // Convert 0-indexed to 1-indexed line number + const clickedStartLine = clickedBlock.startLine + 1; + + // Check if the clicked block intersects with editor selection + const alignedRanges = this.editorSelection.getBlockAlignedRangeIfIntersecting(clickedStartLine); + + 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 the underlying EditorSelectionBridge instance. + */ + getEditorSelection(): EditorSelectionBridge { + return this.editorSelection; + } +} From 9f106b903e0324ac4b1107d719dd06ed053485f0 Mon Sep 17 00:00:00 2001 From: lushuilu <2981999092@qq.com> Date: Fri, 13 Feb 2026 00:55:38 +0800 Subject: [PATCH 06/25] fix: clear editor text selection when converting to block selection When text selection is converted to block selection via handle click, the editor's text selection is now cleared to avoid visual confusion from having both selection states visible simultaneously. Co-Authored-By: Claude Opus 4.6 --- src/editor/interaction/DragEventHandler.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/editor/interaction/DragEventHandler.ts b/src/editor/interaction/DragEventHandler.ts index 5f99c38..9eb9407 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, @@ -420,6 +421,12 @@ export class DragEventHandler { handle.setAttribute('draggable', 'false'); } + // Clear the editor's text selection to avoid visual confusion + const currentSelection = this.view.state.selection.main; + this.view.dispatch({ + selection: EditorSelection.cursor(currentSelection.head), + }); + const anchorStartLineNumber = precomputedRanges[0].startLineNumber; const anchorEndLineNumber = precomputedRanges[precomputedRanges.length - 1].endLineNumber; From 8103e783d762f4d3877e20bee446e27b954ee342 Mon Sep 17 00:00:00 2001 From: lushuilu <2981999092@qq.com> Date: Fri, 13 Feb 2026 01:00:18 +0800 Subject: [PATCH 07/25] fix: hide vertical link line for text-selection-based block selection When blocks are selected via text selection + handle click, the vertical link line between handles is now hidden. Only the block highlight is shown, keeping the visual cleaner. - Added showLinks parameter to RangeSelectionVisualManager.render() - Added hideLinks() helper method - Updated startRangeSelectWithPrecomputedRanges to pass showLinks=false Co-Authored-By: Claude Opus 4.6 --- src/editor/interaction/DragEventHandler.ts | 4 ++-- src/editor/visual/RangeSelectionVisualManager.ts | 12 ++++++++++-- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/src/editor/interaction/DragEventHandler.ts b/src/editor/interaction/DragEventHandler.ts index 9eb9407..334d838 100644 --- a/src/editor/interaction/DragEventHandler.ts +++ b/src/editor/interaction/DragEventHandler.ts @@ -468,8 +468,8 @@ export class DragEventHandler { }, }; - // Immediately render the selection visual - this.rangeVisual.render(precomputedRanges); + // Immediately render the selection visual (without the vertical link line) + this.rangeVisual.render(precomputedRanges, false); this.pointer.attachPointerListeners(); this.emitLifecycle({ diff --git a/src/editor/visual/RangeSelectionVisualManager.ts b/src/editor/visual/RangeSelectionVisualManager.ts index 816b1a8..401a506 100644 --- a/src/editor/visual/RangeSelectionVisualManager.ts +++ b/src/editor/visual/RangeSelectionVisualManager.ts @@ -34,7 +34,7 @@ export class RangeSelectionVisualManager { this.bindScrollListener(); } - render(ranges: LineRange[]): void { + render(ranges: LineRange[], showLinks: boolean = true): void { const normalizedRanges = this.mergeLineRanges(ranges); const nextLineElements = new Set(); const nextLineNumberElements = new Set(); @@ -78,7 +78,11 @@ export class RangeSelectionVisualManager { nextHandleElements, RANGE_SELECTED_HANDLE_CLASS ); - this.updateLinks(normalizedRanges); + if (showLinks) { + this.updateLinks(normalizedRanges); + } else { + this.hideLinks(); + } } clear(): void { @@ -97,6 +101,10 @@ export class RangeSelectionVisualManager { } this.handleElements.clear(); + this.hideLinks(); + } + + private hideLinks(): void { for (const link of this.linkEls) { link.classList.remove('is-active'); } From cce3c280bf37e7b7c56803a79696eba3de1bdfa7 Mon Sep 17 00:00:00 2001 From: lushuilu <2981999092@qq.com> Date: Fri, 13 Feb 2026 01:03:45 +0800 Subject: [PATCH 08/25] fix: properly hide link line when committing smart block selection The previous fix only hid the link line during initial render, but commitRangeSelection was still showing it. Added showLinks field to MouseRangeSelectState to track whether links should be displayed, and use it consistently throughout the selection lifecycle. Co-Authored-By: Claude Opus 4.6 --- src/editor/interaction/DragEventHandler.ts | 4 +++- src/editor/interaction/RangeSelectionLogic.ts | 1 + 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/src/editor/interaction/DragEventHandler.ts b/src/editor/interaction/DragEventHandler.ts index 334d838..2a7f3fc 100644 --- a/src/editor/interaction/DragEventHandler.ts +++ b/src/editor/interaction/DragEventHandler.ts @@ -387,6 +387,7 @@ export class DragEventHandler { currentLineNumber: anchorEndLineNumber, committedRangesSnapshot, selectionRanges: initialRanges, + showLinks: true, } }; this.pointer.attachPointerListeners(); this.emitLifecycle({ @@ -465,6 +466,7 @@ export class DragEventHandler { currentLineNumber: anchorEndLineNumber, committedRangesSnapshot: [], selectionRanges: precomputedRanges, + showLinks: false, }, }; @@ -781,7 +783,7 @@ export class DragEventHandler { selectedBlock: committedBlock, ranges: committedRanges, }; - this.rangeVisual.render(committedRanges); + this.rangeVisual.render(committedRanges, state.showLinks); } private clearCommittedRangeSelection(): void { diff --git a/src/editor/interaction/RangeSelectionLogic.ts b/src/editor/interaction/RangeSelectionLogic.ts index d76ab6d..2f93bb3 100644 --- a/src/editor/interaction/RangeSelectionLogic.ts +++ b/src/editor/interaction/RangeSelectionLogic.ts @@ -43,6 +43,7 @@ export type MouseRangeSelectState = { currentLineNumber: number; committedRangesSnapshot: LineRange[]; selectionRanges: LineRange[]; + showLinks: boolean; }; export function normalizeLineRange(docLines: number, startLineNumber: number, endLineNumber: number): LineRange { From a78ee7917c55b2aaf05115ee889e5e1b2a082918 Mon Sep 17 00:00:00 2001 From: lushuilu <2981999092@qq.com> Date: Fri, 13 Feb 2026 01:12:20 +0800 Subject: [PATCH 09/25] fix: keep handle in normal state for smart block selection When using text-selection-based block selection, the handles now stay in their normal appearance instead of becoming highlighted. Only the clicked block's handle remains visible in its regular style, while other selected blocks only show background highlight. - Added highlightHandles option to RangeSelectionVisualManager.render() - Added highlightHandles field to MouseRangeSelectState - Smart block selection sets highlightHandles=false Co-Authored-By: Claude Opus 4.6 --- src/editor/interaction/DragEventHandler.ts | 15 ++++++++++----- src/editor/interaction/RangeSelectionLogic.ts | 1 + src/editor/visual/RangeSelectionVisualManager.ts | 12 ++++++++---- 3 files changed, 19 insertions(+), 9 deletions(-) diff --git a/src/editor/interaction/DragEventHandler.ts b/src/editor/interaction/DragEventHandler.ts index 2a7f3fc..1d5013b 100644 --- a/src/editor/interaction/DragEventHandler.ts +++ b/src/editor/interaction/DragEventHandler.ts @@ -388,6 +388,7 @@ export class DragEventHandler { committedRangesSnapshot, selectionRanges: initialRanges, showLinks: true, + highlightHandles: true, } }; this.pointer.attachPointerListeners(); this.emitLifecycle({ @@ -467,11 +468,12 @@ export class DragEventHandler { committedRangesSnapshot: [], selectionRanges: precomputedRanges, showLinks: false, + highlightHandles: false, }, }; - // Immediately render the selection visual (without the vertical link line) - this.rangeVisual.render(precomputedRanges, false); + // Immediately render the selection visual (without link line or handle highlights) + this.rangeVisual.render(precomputedRanges, { showLinks: false, highlightHandles: false }); this.pointer.attachPointerListeners(); this.emitLifecycle({ @@ -772,7 +774,7 @@ 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 { @@ -783,7 +785,7 @@ export class DragEventHandler { selectedBlock: committedBlock, ranges: committedRanges, }; - this.rangeVisual.render(committedRanges, state.showLinks); + this.rangeVisual.render(committedRanges, { showLinks: state.showLinks, highlightHandles: state.highlightHandles }); } private clearCommittedRangeSelection(): void { @@ -799,10 +801,13 @@ 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: state.showLinks, highlightHandles: state.highlightHandles }); return; } if (this.committedRangeSelection) { + // For committed selections, use default (show links and handles) + // since we don't store these flags in committedRangeSelection this.rangeVisual.render(this.committedRangeSelection.ranges); } } diff --git a/src/editor/interaction/RangeSelectionLogic.ts b/src/editor/interaction/RangeSelectionLogic.ts index 2f93bb3..19233a6 100644 --- a/src/editor/interaction/RangeSelectionLogic.ts +++ b/src/editor/interaction/RangeSelectionLogic.ts @@ -44,6 +44,7 @@ export type MouseRangeSelectState = { committedRangesSnapshot: LineRange[]; selectionRanges: LineRange[]; showLinks: boolean; + highlightHandles: boolean; }; export function normalizeLineRange(docLines: number, startLineNumber: number, endLineNumber: number): LineRange { diff --git a/src/editor/visual/RangeSelectionVisualManager.ts b/src/editor/visual/RangeSelectionVisualManager.ts index 401a506..da9beef 100644 --- a/src/editor/visual/RangeSelectionVisualManager.ts +++ b/src/editor/visual/RangeSelectionVisualManager.ts @@ -34,7 +34,9 @@ export class RangeSelectionVisualManager { this.bindScrollListener(); } - render(ranges: LineRange[], showLinks: boolean = true): 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(); @@ -55,9 +57,11 @@ 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; From 556a82426c64566f3be8ee274a2fc38a82a5e84f Mon Sep 17 00:00:00 2001 From: lushuilu <2981999092@qq.com> Date: Fri, 13 Feb 2026 01:22:46 +0800 Subject: [PATCH 10/25] feat: hide handles for non-anchor blocks in smart selection When using text-selection-based block selection, only the clicked block's handle remains visible. Other selected blocks' handles are hidden to avoid cluttering the UI while their background highlight remains visible. - Added setHiddenRangesForSelection/clearHiddenRangesForSelection to HandleVisibilityController - Modified resolveVisibleHandleFromTarget and resolveVisibleHandleFromPointerWhenLineNumbersHidden to skip handles in hidden ranges - Connected DragEventHandler to HandleVisibilityController via new deps callbacks Co-Authored-By: Claude Opus 4.6 --- src/editor/drag-handle.ts | 4 ++ src/editor/interaction/DragEventHandler.ts | 12 +++++ .../visual/HandleVisibilityController.ts | 53 ++++++++++++++++++- 3 files changed, 68 insertions(+), 1 deletion(-) diff --git a/src/editor/drag-handle.ts b/src/editor/drag-handle.ts index 0baf87d..11b7145 100644 --- a/src/editor/drag-handle.ts +++ b/src/editor/drag-handle.ts @@ -179,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, { diff --git a/src/editor/interaction/DragEventHandler.ts b/src/editor/interaction/DragEventHandler.ts index 1d5013b..9605454 100644 --- a/src/editor/interaction/DragEventHandler.ts +++ b/src/editor/interaction/DragEventHandler.ts @@ -76,6 +76,8 @@ export interface DragEventHandlerDeps { hideDropIndicator: () => void; performDropAtPoint: (sourceBlock: BlockInfo, clientX: number, clientY: number, pointerType: string | null) => void; onDragLifecycleEvent?: (event: DragLifecycleEvent) => void; + setHiddenRangesForSelection?: (ranges: Array<{ startLineNumber: number; endLineNumber: number }>, anchorHandle: HTMLElement | null) => void; + clearHiddenRangesForSelection?: () => void; } export class DragEventHandler { @@ -474,6 +476,12 @@ export class DragEventHandler { // 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({ @@ -792,6 +800,10 @@ export class DragEventHandler { if (!this.committedRangeSelection) return; this.committedRangeSelection = null; this.rangeVisual.clear(); + // Restore normal handle visibility + if (this.deps.clearHiddenRangesForSelection) { + this.deps.clearHiddenRangesForSelection(); + } } private getCommittedSelectionBlock(): BlockInfo | null { diff --git a/src/editor/visual/HandleVisibilityController.ts b/src/editor/visual/HandleVisibilityController.ts index 25cc481..4a70cac 100644 --- a/src/editor/visual/HandleVisibilityController.ts +++ b/src/editor/visual/HandleVisibilityController.ts @@ -23,6 +23,9 @@ export class HandleVisibilityController { 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, @@ -63,6 +66,45 @@ export class HandleVisibilityController { } } + /** + * Set ranges where handles should be hidden during smart 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; + } + + /** + * Clear the hidden ranges for selection. + */ + clearHiddenRangesForSelection(): void { + this.hiddenRangesForSelection = []; + this.anchorHandleForSelection = null; + } + + /** + * Check if a handle should be hidden based on selection ranges. + * Returns true if the handle is within a hidden range and is not the anchor. + */ + private isHandleHiddenBySelection(handle: HTMLElement): boolean { + if (this.hiddenRangesForSelection.length === 0) return false; + if (handle === this.anchorHandleForSelection) return false; + + const lineNumber = this.resolveHandleLineNumber(handle); + if (lineNumber === null) return false; + + for (const range of this.hiddenRangesForSelection) { + if (lineNumber >= range.startLineNumber && lineNumber <= range.endLineNumber) { + return true; + } + } + return false; + } + setActiveVisibleHandle( handle: HTMLElement | null, options?: { preserveHoveredLineNumber?: boolean } @@ -133,6 +175,10 @@ export class HandleVisibilityController { const directHandle = target.closest(`.${DRAG_HANDLE_CLASS}`); if (!directHandle) return null; if (this.view.dom.contains(directHandle)) { + // Don't show handles that are hidden by selection + if (this.isHandleHiddenBySelection(directHandle)) { + return null; + } return directHandle; } return null; @@ -151,7 +197,12 @@ export class HandleVisibilityController { const blockInfo = this.deps.getDraggableBlockAtPoint(clientX, clientY); if (!blockInfo) return null; - return this.resolveVisibleHandleForBlock(blockInfo); + const handle = this.resolveVisibleHandleForBlock(blockInfo); + // Don't show handles that are hidden by selection + if (handle && this.isHandleHiddenBySelection(handle)) { + return null; + } + return handle; } resolveHandleLineNumber(handle: HTMLElement): number | null { From 56896037e6572c5b0b3386176f72b985701c145d Mon Sep 17 00:00:00 2001 From: lushuilu <2981999092@qq.com> Date: Fri, 13 Feb 2026 02:00:19 +0800 Subject: [PATCH 11/25] fix: prevent handle hover effects during block selection Disable CSS :hover rule when block selection is active by adding dnd-block-selection-active body class. This prevents handles from appearing on hover when the user has selected blocks via smart selection or single-handle click. Co-Authored-By: Claude Opus 4.6 --- src/editor/core/constants.ts | 1 + .../visual/HandleVisibilityController.ts | 61 +++++++++---------- styles.css | 3 +- 3 files changed, 33 insertions(+), 32 deletions(-) diff --git a/src/editor/core/constants.ts b/src/editor/core/constants.ts index 6a2620e..ecb424d 100644 --- a/src/editor/core/constants.ts +++ b/src/editor/core/constants.ts @@ -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/visual/HandleVisibilityController.ts b/src/editor/visual/HandleVisibilityController.ts index 4a70cac..173d4ea 100644 --- a/src/editor/visual/HandleVisibilityController.ts +++ b/src/editor/visual/HandleVisibilityController.ts @@ -10,6 +10,7 @@ import { HANDLE_INTERACTION_ZONE_PX, HOVER_HIDDEN_LINE_NUMBER_CLASS, GRAB_HIDDEN_LINE_NUMBER_CLASS, + BLOCK_SELECTION_ACTIVE_CLASS, } from '../core/constants'; export interface HandleVisibilityDeps { @@ -67,7 +68,7 @@ export class HandleVisibilityController { } /** - * Set ranges where handles should be hidden during smart block selection, + * Set ranges where handles should be hidden during block selection, * except for the anchor handle which remains visible. */ setHiddenRangesForSelection( @@ -76,33 +77,22 @@ export class HandleVisibilityController { ): 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); + // Immediately show the anchor handle + if (anchorHandle) { + this.setActiveVisibleHandle(anchorHandle); + } } /** - * Clear the hidden ranges for selection. + * Clear the hidden ranges for selection and restore normal handle behavior. */ clearHiddenRangesForSelection(): void { this.hiddenRangesForSelection = []; this.anchorHandleForSelection = null; - } - - /** - * Check if a handle should be hidden based on selection ranges. - * Returns true if the handle is within a hidden range and is not the anchor. - */ - private isHandleHiddenBySelection(handle: HTMLElement): boolean { - if (this.hiddenRangesForSelection.length === 0) return false; - if (handle === this.anchorHandleForSelection) return false; - - const lineNumber = this.resolveHandleLineNumber(handle); - if (lineNumber === null) return false; - - for (const range of this.hiddenRangesForSelection) { - if (lineNumber >= range.startLineNumber && lineNumber <= range.endLineNumber) { - return true; - } - } - return false; + // Remove body class to re-enable handle hover effects + document.body.classList.remove(BLOCK_SELECTION_ACTIVE_CLASS); } setActiveVisibleHandle( @@ -110,6 +100,12 @@ export class HandleVisibilityController { 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(); @@ -172,19 +168,27 @@ 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)) { - // Don't show handles that are hidden by selection - if (this.isHandleHiddenBySelection(directHandle)) { - return null; - } return directHandle; } return null; } 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 @@ -197,12 +201,7 @@ export class HandleVisibilityController { const blockInfo = this.deps.getDraggableBlockAtPoint(clientX, clientY); if (!blockInfo) return null; - const handle = this.resolveVisibleHandleForBlock(blockInfo); - // Don't show handles that are hidden by selection - if (handle && this.isHandleHiddenBySelection(handle)) { - return null; - } - return handle; + return this.resolveVisibleHandleForBlock(blockInfo); } resolveHandleLineNumber(handle: HTMLElement): number | null { diff --git a/styles.css b/styles.css index d165d57..d69d281 100644 --- a/styles.css +++ b/styles.css @@ -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); From 96533ceb5f8ff05d8adb3ce0bf20dbda07156fb2 Mon Sep 17 00:00:00 2001 From: lushuilu <2981999092@qq.com> Date: Fri, 13 Feb 2026 02:04:54 +0800 Subject: [PATCH 12/25] fix: hide other handles when committing single-click range selection Apply the same handle hiding logic to regular single-click range selection that was already working for smart text-selection based block selection. Co-Authored-By: Claude Opus 4.6 --- src/editor/interaction/DragEventHandler.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/editor/interaction/DragEventHandler.ts b/src/editor/interaction/DragEventHandler.ts index 9605454..1f10094 100644 --- a/src/editor/interaction/DragEventHandler.ts +++ b/src/editor/interaction/DragEventHandler.ts @@ -794,6 +794,10 @@ export class DragEventHandler { ranges: committedRanges, }; this.rangeVisual.render(committedRanges, { showLinks: state.showLinks, highlightHandles: state.highlightHandles }); + // Hide handles for non-anchor blocks during selection + if (this.deps.setHiddenRangesForSelection) { + this.deps.setHiddenRangesForSelection(committedRanges, state.sourceHandle); + } } private clearCommittedRangeSelection(): void { From 11329398a9fb6b85a78763da105a863d6ffba427 Mon Sep 17 00:00:00 2001 From: lushuilu <2981999092@qq.com> Date: Fri, 13 Feb 2026 02:15:49 +0800 Subject: [PATCH 13/25] fix: commit selection on quick mouse click and keep handle normal - For mouse clicks, commit block selection immediately without requiring long press (touch still requires long press) - Keep handle appearance normal (not highlighted) when selection is committed, matching smart selection behavior Co-Authored-By: Claude Opus 4.6 --- src/editor/interaction/DragEventHandler.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/editor/interaction/DragEventHandler.ts b/src/editor/interaction/DragEventHandler.ts index 1f10094..e1e790d 100644 --- a/src/editor/interaction/DragEventHandler.ts +++ b/src/editor/interaction/DragEventHandler.ts @@ -389,8 +389,8 @@ export class DragEventHandler { currentLineNumber: anchorEndLineNumber, committedRangesSnapshot, selectionRanges: initialRanges, - showLinks: true, - highlightHandles: true, + showLinks: false, + highlightHandles: false, } }; this.pointer.attachPointerListeners(); this.emitLifecycle({ @@ -931,7 +931,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, From a91a223c06aff0d354ed24207f5aa8af5b48ca60 Mon Sep 17 00:00:00 2001 From: lushuilu <2981999092@qq.com> Date: Fri, 13 Feb 2026 03:02:33 +0800 Subject: [PATCH 14/25] fix: clear stale drag highlight after multi-node move --- .../visual/HandleVisibilityController.spec.ts | 55 +++++++++++++++++++ .../visual/HandleVisibilityController.ts | 2 + 2 files changed, 57 insertions(+) create mode 100644 src/editor/visual/HandleVisibilityController.spec.ts diff --git a/src/editor/visual/HandleVisibilityController.spec.ts b/src/editor/visual/HandleVisibilityController.spec.ts new file mode 100644 index 0000000..16b38d2 --- /dev/null +++ b/src/editor/visual/HandleVisibilityController.spec.ts @@ -0,0 +1,55 @@ +// @vitest-environment jsdom + +import { EditorState } from '@codemirror/state'; +import type { EditorView } from '@codemirror/view'; +import { describe, expect, it } from 'vitest'; +import { HandleVisibilityController } from './HandleVisibilityController'; + +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); + }); +}); diff --git a/src/editor/visual/HandleVisibilityController.ts b/src/editor/visual/HandleVisibilityController.ts index 173d4ea..78ba92c 100644 --- a/src/editor/visual/HandleVisibilityController.ts +++ b/src/editor/visual/HandleVisibilityController.ts @@ -50,6 +50,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 { From b8c34b3c0548ee5972f6340ea57d100f5ae70fb1 Mon Sep 17 00:00:00 2001 From: lushuilu <2981999092@qq.com> Date: Fri, 13 Feb 2026 03:04:00 +0800 Subject: [PATCH 15/25] feat: improve range-selection drag flow and diagnostics --- src/editor/interaction/DragEventHandler.ts | 137 +++++++++++++++++- .../HandleInteractionOrchestrator.ts | 5 +- .../visual/RangeSelectionVisualManager.ts | 22 +++ 3 files changed, 156 insertions(+), 8 deletions(-) diff --git a/src/editor/interaction/DragEventHandler.ts b/src/editor/interaction/DragEventHandler.ts index e1e790d..d913e52 100644 --- a/src/editor/interaction/DragEventHandler.ts +++ b/src/editor/interaction/DragEventHandler.ts @@ -18,6 +18,7 @@ import { type RangeSelectConfig, type CommittedRangeSelection, type MouseRangeSelectState, + type LineRange, normalizeLineRange, mergeLineRanges, cloneLineRanges, @@ -28,7 +29,6 @@ import { resolveBlockBoundaryAtLine, } from './RangeSelectionLogic'; import { SmartBlockSelector } from './SmartBlockSelector'; -import type { LineRange } from '../../types'; const MOBILE_DRAG_LONG_PRESS_MS = 100; const MOBILE_DRAG_START_MOVE_THRESHOLD_PX = 8; @@ -74,7 +74,7 @@ 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; @@ -83,6 +83,13 @@ export interface DragEventHandlerDeps { export class DragEventHandler { private gesture: GestureState = { phase: 'idle' }; private committedRangeSelection: CommittedRangeSelection | null = null; + // Store selection info to restore after drag completes + private pendingSelectionRestore: { + ranges: LineRange[]; + anchorHandle: HTMLElement | null; + sourceStartLine: number; + sourceLineCount: number; + } | null = null; readonly rangeVisual: RangeSelectionVisualManager; readonly mobile: MobileGestureController; readonly pointer: PointerSessionController; @@ -671,7 +678,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 }); this.clearPointerPressState(); this.beginPointerDrag(sourceBlock, pointerId, e.clientX, e.clientY, e.pointerType || null); } @@ -709,7 +718,8 @@ 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 }); this.clearMouseRangeSelectState(); this.beginPointerDrag(sourceBlock, pointerId, e.clientX, e.clientY, pointerType); } @@ -800,8 +810,34 @@ export class DragEventHandler { } } - private clearCommittedRangeSelection(): void { + private clearCommittedRangeSelection(options?: { preserveForDrag?: boolean; anchorHandle?: HTMLElement | null }): void { if (!this.committedRangeSelection) return; + + console.log('[Dragger Debug] clearCommittedRangeSelection', { + preserveForDrag: options?.preserveForDrag, + currentRanges: JSON.stringify(this.committedRangeSelection.ranges), + anchorHandle: options?.anchorHandle, + }); + + // If preserveForDrag is true, save the selection info for restoration after drag + if (options?.preserveForDrag) { + const firstRange = this.committedRangeSelection.ranges[0]; + const lastRange = this.committedRangeSelection.ranges[this.committedRangeSelection.ranges.length - 1]; + if (firstRange && lastRange) { + const sourceLineCount = lastRange.endLineNumber - firstRange.startLineNumber + 1; + this.pendingSelectionRestore = { + ranges: cloneLineRanges(this.committedRangeSelection.ranges), + anchorHandle: options.anchorHandle ?? null, + sourceStartLine: firstRange.startLineNumber, + sourceLineCount, + }; + console.log('[Dragger Debug] Saved pendingSelectionRestore:', { + ...this.pendingSelectionRestore, + ranges: JSON.stringify(this.pendingSelectionRestore.ranges), + }); + } + } + this.committedRangeSelection = null; this.rangeVisual.clear(); // Restore normal handle visibility @@ -810,12 +846,91 @@ export class DragEventHandler { } } + private restoreSelectionAfterDrop(targetStartLine: number): void { + if (!this.pendingSelectionRestore) return; + + const { anchorHandle, sourceLineCount } = this.pendingSelectionRestore; + + console.log('[Dragger Debug] restoreSelectionAfterDrop', { + targetStartLine, + sourceLineCount, + }); + + // The selection should be exactly at the target position with the same line count + const newStartLine = targetStartLine; + const newEndLine = targetStartLine + sourceLineCount - 1; + + // Clamp to document bounds + const docLines = this.view.state.doc.lines; + const clampedStartLine = Math.max(1, Math.min(docLines, newStartLine)); + const clampedEndLine = Math.max(clampedStartLine, Math.min(docLines, newEndLine)); + + console.log('[Dragger Debug] calculated new selection', { + newStartLine: clampedStartLine, + newEndLine: clampedEndLine, + docLines, + }); + + // Clear any existing selection first + this.rangeVisual.clear(); + if (this.deps.clearHiddenRangesForSelection) { + this.deps.clearHiddenRangesForSelection(); + } + + const mergedRanges: LineRange[] = [{ + startLineNumber: clampedStartLine, + endLineNumber: clampedEndLine, + }]; + + const startLine = this.view.state.doc.line(clampedStartLine); + const endLine = this.view.state.doc.line(clampedEndLine); + const newBlock: BlockInfo = { + type: 'paragraph', + startLine: clampedStartLine - 1, + endLine: clampedEndLine - 1, + from: startLine.from, + to: endLine.to, + indentLevel: 0, + content: '', + }; + + this.committedRangeSelection = { + selectedBlock: newBlock, + ranges: mergedRanges, + }; + + console.log('[Dragger Debug] About to render selection, mergedRanges:', JSON.stringify(mergedRanges)); + + // Re-render visual and hide other handles + this.rangeVisual.render(mergedRanges, { showLinks: false, highlightHandles: false }); + if (this.deps.setHiddenRangesForSelection && anchorHandle) { + // Try to find the new anchor handle at the target position + const newAnchorHandle = this.findHandleAtLine(targetStartLine) ?? anchorHandle; + console.log('[Dragger Debug] Setting hidden ranges, newAnchorHandle:', newAnchorHandle); + this.deps.setHiddenRangesForSelection(mergedRanges, newAnchorHandle); + } + + this.pendingSelectionRestore = null; + } + + 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 { if (!this.committedRangeSelection) return null; return cloneBlockInfo(this.committedRangeSelection.selectedBlock); } private refreshRangeSelectionVisual(): void { + console.log('[Dragger Debug] refreshRangeSelectionVisual called', { + phase: this.gesture.phase, + hasCommittedSelection: !!this.committedRangeSelection, + committedRanges: this.committedRangeSelection ? JSON.stringify(this.committedRangeSelection.ranges) : null, + }); if (this.gesture.phase === 'range_selecting') { const state = this.gesture.rangeSelect; this.rangeVisual.render(state.selectionRanges, { showLinks: state.showLinks, highlightHandles: state.highlightHandles }); @@ -911,8 +1026,10 @@ export class DragEventHandler { 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); + console.log('[Dragger Debug] finishPointerDrag - targetLineNumber:', targetLineNumber, 'sourceBlock:', state.sourceBlock); } this.abortPointerSession({ shouldFinishDragSession: true, @@ -920,6 +1037,14 @@ 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 && targetLineNumber !== null && this.pendingSelectionRestore) { + console.log('[Dragger Debug] Will restore selection, pendingSelectionRestore:', this.pendingSelectionRestore); + // Use setTimeout to ensure the document update has been processed + setTimeout(() => { + this.restoreSelectionAfterDrop(targetLineNumber!); + }, 0); + } } private handlePointerUp(e: PointerEvent): void { diff --git a/src/editor/orchestration/HandleInteractionOrchestrator.ts b/src/editor/orchestration/HandleInteractionOrchestrator.ts index e55aabf..fcfbade 100644 --- a/src/editor/orchestration/HandleInteractionOrchestrator.ts +++ b/src/editor/orchestration/HandleInteractionOrchestrator.ts @@ -153,7 +153,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 +175,7 @@ export class HandleInteractionOrchestrator { rejectReason: validation.reason ?? 'no_target', pointerType, }); - return; + return null; } const targetLineNumber = validation.targetLineNumber; @@ -203,6 +203,7 @@ export class HandleInteractionOrchestrator { rejectReason: null, pointerType, }); + return targetLineNumber; } resolveInteractionBlockInfo(params: { diff --git a/src/editor/visual/RangeSelectionVisualManager.ts b/src/editor/visual/RangeSelectionVisualManager.ts index da9beef..509b0fa 100644 --- a/src/editor/visual/RangeSelectionVisualManager.ts +++ b/src/editor/visual/RangeSelectionVisualManager.ts @@ -37,18 +37,26 @@ export class RangeSelectionVisualManager { render(ranges: LineRange[], options?: { showLinks?: boolean; highlightHandles?: boolean }): void { const showLinks = options?.showLinks ?? true; const highlightHandles = options?.highlightHandles ?? true; + console.log('[Dragger Debug] RangeSelectionVisualManager.render', { + ranges: JSON.stringify(ranges), + showLinks, + highlightHandles, + }); 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); @@ -67,6 +75,7 @@ export class RangeSelectionVisualManager { pos = line.to + 1; } } + console.log('[Dragger Debug] Matched lines:', matchedLines, 'lineElements count:', nextLineElements.size); this.syncSelectionElements( this.lineElements, nextLineElements, @@ -90,6 +99,9 @@ export class RangeSelectionVisualManager { } clear(): void { + console.log('[Dragger Debug] RangeSelectionVisualManager.clear called, lineElements count:', this.lineElements.size); + + // Clear tracked elements for (const lineEl of this.lineElements) { lineEl.classList.remove(RANGE_SELECTED_LINE_CLASS); } @@ -105,6 +117,16 @@ 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)); + + console.log('[Dragger Debug] Cleared remaining elements - lines:', remainingLineElements.length, 'handles:', remainingHandleElements.length); + this.hideLinks(); } From 68732ce472de803908fa01c2d3ce749bdce6451d Mon Sep 17 00:00:00 2001 From: lushuilu <2981999092@qq.com> Date: Fri, 13 Feb 2026 03:15:05 +0800 Subject: [PATCH 16/25] feat: keep single-block selected after drag drop --- .../interaction/DragEventHandler.spec.ts | 68 ++++++++++++++++++- src/editor/interaction/DragEventHandler.ts | 56 ++++++++++++--- 2 files changed, 115 insertions(+), 9 deletions(-) diff --git a/src/editor/interaction/DragEventHandler.spec.ts b/src/editor/interaction/DragEventHandler.spec.ts index 0c2a0a4..59d3b97 100644 --- a/src/editor/interaction/DragEventHandler.spec.ts +++ b/src/editor/interaction/DragEventHandler.spec.ts @@ -129,6 +129,22 @@ 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; +} + beforeEach(() => { if (!originalElementFromPoint && typeof document.elementFromPoint === 'function') { const native = document.elementFromPoint.bind(document); @@ -1347,7 +1363,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 +1399,65 @@ 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[3]?.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[4]?.classList.contains('dnd-range-selected-line')).toBe(true); + expect(lines[1]?.classList.contains('dnd-range-selected-line')).toBe(false); + handler.destroy(); + }); }); diff --git a/src/editor/interaction/DragEventHandler.ts b/src/editor/interaction/DragEventHandler.ts index d913e52..fb2a9a2 100644 --- a/src/editor/interaction/DragEventHandler.ts +++ b/src/editor/interaction/DragEventHandler.ts @@ -1,6 +1,6 @@ import { EditorView } from '@codemirror/view'; import { EditorSelection } from '@codemirror/state'; -import { BlockInfo, DragLifecycleEvent } from '../../types'; +import { BlockInfo, BlockType, DragLifecycleEvent } from '../../types'; import { getHandleColumnCenterX, } from '../core/handle-position'; @@ -186,9 +186,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 (targetLineNumber !== null && this.pendingSelectionRestore) { + setTimeout(() => { + this.restoreSelectionAfterDrop(targetLineNumber); + }, 0); + return; + } + this.pendingSelectionRestore = null; }; private readonly onLostPointerCapture = (e: PointerEvent) => this.handleLostPointerCapture(e); @@ -617,6 +625,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({ @@ -821,12 +830,14 @@ export class DragEventHandler { // If preserveForDrag is true, save the selection info for restoration after drag if (options?.preserveForDrag) { - const firstRange = this.committedRangeSelection.ranges[0]; - const lastRange = this.committedRangeSelection.ranges[this.committedRangeSelection.ranges.length - 1]; - if (firstRange && lastRange) { - const sourceLineCount = lastRange.endLineNumber - firstRange.startLineNumber + 1; + const ranges = cloneLineRanges(this.committedRangeSelection.ranges); + const firstRange = ranges[0]; + if (firstRange) { + const sourceLineCount = ranges.reduce((count, range) => { + return count + Math.max(0, range.endLineNumber - range.startLineNumber + 1); + }, 0); this.pendingSelectionRestore = { - ranges: cloneLineRanges(this.committedRangeSelection.ranges), + ranges, anchorHandle: options.anchorHandle ?? null, sourceStartLine: firstRange.startLineNumber, sourceLineCount, @@ -885,7 +896,7 @@ export class DragEventHandler { const startLine = this.view.state.doc.line(clampedStartLine); const endLine = this.view.state.doc.line(clampedEndLine); const newBlock: BlockInfo = { - type: 'paragraph', + type: BlockType.Paragraph, startLine: clampedStartLine - 1, endLine: clampedEndLine - 1, from: startLine.from, @@ -913,6 +924,30 @@ export class DragEventHandler { this.pendingSelectionRestore = null; } + 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, + 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}"]`; @@ -1044,7 +1079,9 @@ export class DragEventHandler { setTimeout(() => { this.restoreSelectionAfterDrop(targetLineNumber!); }, 0); + return; } + this.pendingSelectionRestore = null; } private handlePointerUp(e: PointerEvent): void { @@ -1212,6 +1249,9 @@ export class DragEventHandler { if (hadDrag && shouldFinishDragSession) { this.deps.finishDragSession(); } + if (cancelReason) { + this.pendingSelectionRestore = null; + } if (cancelReason && sourceBlock) { this.emitLifecycle({ state: 'cancelled', From 69d67d72c1b602d26d1985a1ac4ebd0a10c9bb2d Mon Sep 17 00:00:00 2001 From: lushuilu <2981999092@qq.com> Date: Fri, 13 Feb 2026 03:28:16 +0800 Subject: [PATCH 17/25] feat: allow smart text-selection drag with persistent block selection --- .../interaction/DragEventHandler.spec.ts | 79 +++++++++++++++++++ src/editor/interaction/DragEventHandler.ts | 79 +++++++++++++------ src/editor/interaction/RangeSelectionLogic.ts | 1 + .../HandleInteractionOrchestrator.ts | 7 +- 4 files changed, 142 insertions(+), 24 deletions(-) diff --git a/src/editor/interaction/DragEventHandler.spec.ts b/src/editor/interaction/DragEventHandler.spec.ts index 59d3b97..ebe18c8 100644 --- a/src/editor/interaction/DragEventHandler.spec.ts +++ b/src/editor/interaction/DragEventHandler.spec.ts @@ -145,6 +145,18 @@ function dispatchDrop( 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 }, + }); +} + beforeEach(() => { if (!originalElementFromPoint && typeof document.elementFromPoint === 'function') { const native = document.elementFromPoint.bind(document); @@ -1460,4 +1472,71 @@ describe('DragEventHandler', () => { 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[5]?.classList.contains('dnd-range-selected-line')).toBe(true); + expect(lines[6]?.classList.contains('dnd-range-selected-line')).toBe(true); + expect(lines[7]?.classList.contains('dnd-range-selected-line')).toBe(true); + expect(lines[1]?.classList.contains('dnd-range-selected-line')).toBe(false); + handler.destroy(); + }); }); diff --git a/src/editor/interaction/DragEventHandler.ts b/src/editor/interaction/DragEventHandler.ts index fb2a9a2..fe35430 100644 --- a/src/editor/interaction/DragEventHandler.ts +++ b/src/editor/interaction/DragEventHandler.ts @@ -406,6 +406,7 @@ export class DragEventHandler { selectionRanges: initialRanges, showLinks: false, highlightHandles: false, + shouldClearEditorSelectionOnCommit: false, } }; this.pointer.attachPointerListeners(); this.emitLifecycle({ @@ -431,34 +432,33 @@ export class DragEventHandler { if (precomputedRanges.length === 0) return; const pointerType = e.pointerType || null; + const isMouse = pointerType === 'mouse'; const sourceHandleDraggableAttr = handle?.getAttribute('draggable') ?? null; - e.preventDefault(); - e.stopPropagation(); - this.pointer.tryCapturePointer(e); - if (handle) { - handle.setAttribute('draggable', 'false'); + if (!isMouse) { + e.preventDefault(); + e.stopPropagation(); + this.pointer.tryCapturePointer(e); + if (handle) { + handle.setAttribute('draggable', 'false'); + } } - // Clear the editor's text selection to avoid visual confusion - const currentSelection = this.view.state.selection.main; - this.view.dispatch({ - selection: EditorSelection.cursor(currentSelection.head), - }); - const anchorStartLineNumber = precomputedRanges[0].startLineNumber; const anchorEndLineNumber = precomputedRanges[precomputedRanges.length - 1].endLineNumber; - // For smart selection, we skip the long press wait and immediately commit - 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; - // Immediately commit the selection - this.commitRangeSelection(state); - this.finishRangeSelectionSession(); - }, 0); + // For touch/pen smart selection, commit immediately. + // For mouse we keep the existing click-to-commit flow and also allow native drag to start. + const timeoutId = isMouse + ? null + : 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', @@ -474,7 +474,7 @@ export class DragEventHandler { pointerType, dragReady: true, longPressReady: false, - isIntercepting: true, + isIntercepting: !isMouse, timeoutId, dragTimeoutId: null, sourceHandle: handle, @@ -486,6 +486,7 @@ export class DragEventHandler { selectionRanges: precomputedRanges, showLinks: false, highlightHandles: false, + shouldClearEditorSelectionOnCommit: true, }, }; @@ -805,6 +806,12 @@ export class DragEventHandler { } private commitRangeSelection(state: MouseRangeSelectState): void { + if (state.shouldClearEditorSelectionOnCommit) { + const currentSelection = this.view.state.selection.main; + this.view.dispatch({ + selection: EditorSelection.cursor(currentSelection.head), + }); + } const docLines = this.view.state.doc.lines; const committedRanges = mergeLineRanges(docLines, state.selectionRanges); const committedBlock = buildDragSourceFromLineRanges(this.view.state.doc, committedRanges, state.sourceBlock); @@ -924,6 +931,34 @@ export class DragEventHandler { this.pendingSelectionRestore = null; } + resolveNativeDragSourceForHandleDrag(baseBlockInfo: BlockInfo | null): BlockInfo | null { + if (!baseBlockInfo) return null; + if (!this.isMultiLineSelectionEnabled()) return baseBlockInfo; + + 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; + + this.clearMouseRangeSelectState({ preserveVisual: true }); + this.pointer.detachPointerListeners(); + this.pointer.releasePointerCapture(); + } + private queueSelectionRestoreForSourceBlock(sourceBlock: BlockInfo): void { if (this.pendingSelectionRestore) return; const compositeRanges = sourceBlock.compositeSelection?.ranges ?? []; diff --git a/src/editor/interaction/RangeSelectionLogic.ts b/src/editor/interaction/RangeSelectionLogic.ts index 19233a6..077d522 100644 --- a/src/editor/interaction/RangeSelectionLogic.ts +++ b/src/editor/interaction/RangeSelectionLogic.ts @@ -45,6 +45,7 @@ export type MouseRangeSelectState = { selectionRanges: LineRange[]; showLinks: boolean; highlightHandles: boolean; + shouldClearEditorSelectionOnCommit: boolean; }; export function normalizeLineRange(docLines: number, startLineNumber: number, endLineNumber: number): LineRange { diff --git a/src/editor/orchestration/HandleInteractionOrchestrator.ts b/src/editor/orchestration/HandleInteractionOrchestrator.ts index fcfbade..8d595d2 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); From e4e9187883a5d38afe14b3ff89a06ff21cca28f0 Mon Sep 17 00:00:00 2001 From: lushuilu <2981999092@qq.com> Date: Fri, 13 Feb 2026 09:40:53 +0800 Subject: [PATCH 18/25] wip: refine smart text-selection drag and selection visuals --- .../core/services/EditorSelectionBridge.ts | 128 +++++++--- .../interaction/DragEventHandler.spec.ts | 238 +++++++++++++++++- src/editor/interaction/DragEventHandler.ts | 137 +++++++--- src/editor/interaction/SmartBlockSelector.ts | 24 +- .../HandleInteractionOrchestrator.ts | 2 +- styles.css | 9 +- 6 files changed, 454 insertions(+), 84 deletions(-) diff --git a/src/editor/core/services/EditorSelectionBridge.ts b/src/editor/core/services/EditorSelectionBridge.ts index 71489f9..3bebb01 100644 --- a/src/editor/core/services/EditorSelectionBridge.ts +++ b/src/editor/core/services/EditorSelectionBridge.ts @@ -1,6 +1,10 @@ import type { EditorView } from '@codemirror/view'; import type { LineRange } from '../../../types'; -import { resolveBlockBoundaryAtLine, resolveBlockAlignedLineRange } from '../../interaction/RangeSelectionLogic'; +import { + mergeLineRanges, + resolveBlockAlignedLineRange, + resolveBlockBoundaryAtLine, +} from '../../interaction/RangeSelectionLogic'; export type EditorTextSelection = { from: number; @@ -22,33 +26,45 @@ export class EditorSelectionBridge { * @returns Selection info if there's a valid non-empty selection, null otherwise */ getTextSelection(): EditorTextSelection | null { - const selection = this.view.state.selection; - const main = selection.main; - - // Empty selection (just cursor position) is not a valid selection - if (main.empty) { - return 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 fromLine = doc.lineAt(main.from).number; - const toLine = doc.lineAt(main.to).number; + const ranges = this.view.state.selection.ranges; + const selections: EditorTextSelection[] = []; - return { - from: main.from, - to: main.to, - fromLine, - toLine, - }; + 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, + }); + } + console.log('[Dragger Debug] EditorSelectionBridge.getTextSelections', { + count: selections.length, + selections, + }); + return selections; } /** * Check if a line number is within the current editor selection. */ isLineInSelection(lineNumber: number): boolean { - const selection = this.getTextSelection(); - if (!selection) return false; - return lineNumber >= selection.fromLine && lineNumber <= selection.toLine; + const selections = this.getTextSelections(); + if (selections.length === 0) return false; + return selections.some((selection) => ( + lineNumber >= selection.fromLine && lineNumber <= selection.toLine + )); } /** @@ -57,28 +73,37 @@ export class EditorSelectionBridge { * @returns Block-aligned line ranges, or null if no valid selection */ resolveBlockAlignedSelection(): LineRange[] | null { - const selection = this.getTextSelection(); - if (!selection) return null; + const selections = this.getTextSelections(); + if (selections.length === 0) return null; const state = this.view.state; + const docLines = state.doc.lines; + const ranges: LineRange[] = []; - // 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 - ); + 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); - return [{ - startLineNumber: aligned.startLineNumber, - endLineNumber: aligned.endLineNumber, - }]; + // 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, + }); + } + const merged = mergeLineRanges(docLines, ranges); + console.log('[Dragger Debug] EditorSelectionBridge.resolveBlockAlignedSelection', { + inputSelections: selections, + blockRanges: merged, + }); + return merged; } /** @@ -88,11 +113,32 @@ export class EditorSelectionBridge { * @returns Block-aligned ranges if the line intersects, null otherwise */ getBlockAlignedRangeIfIntersecting(lineNumber: number): LineRange[] | null { - const selection = this.getTextSelection(); - if (!selection) return 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); - // Check if the line is within the selection range - if (!this.isLineInSelection(lineNumber)) return null; + // Trigger only when clicked range intersects at least one text selection range. + const intersects = selections.some((selection) => ( + safeEnd >= selection.fromLine && safeStart <= selection.toLine + )); + console.log('[Dragger Debug] EditorSelectionBridge.getBlockAlignedRangeIfRangeIntersecting', { + blockRange: { startLineNumber: safeStart, endLineNumber: safeEnd }, + selections, + intersects, + }); + if (!intersects) return null; // Return the block-aligned selection return this.resolveBlockAlignedSelection(); diff --git a/src/editor/interaction/DragEventHandler.spec.ts b/src/editor/interaction/DragEventHandler.spec.ts index ebe18c8..57120d2 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'; @@ -157,6 +157,22 @@ function applyTextSelection(view: EditorView, fromLine: number, toLine: number): }); } +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), + }); +} + beforeEach(() => { if (!originalElementFromPoint && typeof document.elementFromPoint === 'function') { const native = document.elementFromPoint.bind(document); @@ -1539,4 +1555,224 @@ describe('DragEventHandler', () => { expect(lines[1]?.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('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('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(); + }); }); diff --git a/src/editor/interaction/DragEventHandler.ts b/src/editor/interaction/DragEventHandler.ts index fe35430..17fd923 100644 --- a/src/editor/interaction/DragEventHandler.ts +++ b/src/editor/interaction/DragEventHandler.ts @@ -38,6 +38,7 @@ 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; type PointerDragData = { sourceBlock: BlockInfo; @@ -83,6 +84,9 @@ export interface DragEventHandlerDeps { export class DragEventHandler { private gesture: GestureState = { phase: 'idle' }; private committedRangeSelection: CommittedRangeSelection | null = null; + private lastCommittedSelectionSource: 'smart_mouse' | 'other' = 'other'; + private lastCommittedSelectionAtMs = 0; + private lastPointerType: string | null = null; // Store selection info to restore after drag completes private pendingSelectionRestore: { ranges: LineRange[]; @@ -99,9 +103,10 @@ export class DragEventHandler { const target = e.target instanceof HTMLElement ? e.target : null; if (!target) return; const pointerType = e.pointerType || null; + this.lastPointerType = pointerType; const multiLineSelectionEnabled = this.isMultiLineSelectionEnabled(); if (!multiLineSelectionEnabled) { - this.clearCommittedRangeSelection(); + this.clearCommittedRangeSelection({ reason: 'feature_disabled' }); } const canHandleCommittedSelection = ( multiLineSelectionEnabled @@ -120,7 +125,7 @@ export class DragEventHandler { } } if (canHandleCommittedSelection && this.shouldClearCommittedSelectionOnPointerDown(target, e.clientX, pointerType)) { - this.clearCommittedRangeSelection(); + this.clearCommittedRangeSelection({ reason: 'pointerdown_outside_selection' }); } const handle = target.closest(`.${DRAG_HANDLE_CLASS}`); @@ -281,7 +286,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; @@ -299,8 +304,9 @@ export class DragEventHandler { } refreshSelectionVisual(): void { - if (!this.isMultiLineSelectionEnabled()) { - this.clearCommittedRangeSelection(); + const multiLineSelectionEnabled = this.isMultiLineSelectionEnabled(); + if (!multiLineSelectionEnabled) { + this.clearCommittedRangeSelection({ reason: 'refresh_feature_disabled' }); return; } this.rangeVisual.scheduleRefresh(); @@ -447,18 +453,15 @@ export class DragEventHandler { const anchorStartLineNumber = precomputedRanges[0].startLineNumber; const anchorEndLineNumber = precomputedRanges[precomputedRanges.length - 1].endLineNumber; - // For touch/pen smart selection, commit immediately. - // For mouse we keep the existing click-to-commit flow and also allow native drag to start. - const timeoutId = isMouse - ? null - : 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); + // Smart selection should still commit immediately for both click and drag paths. + 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', @@ -690,7 +693,7 @@ export class DragEventHandler { const pointerId = pressState.pointerId; // Preserve selection for restoration after drag const anchorHandle = this.findHandleAtLine(sourceBlock.startLine + 1); - this.clearCommittedRangeSelection({ preserveForDrag: true, anchorHandle }); + this.clearCommittedRangeSelection({ preserveForDrag: true, anchorHandle, reason: 'preserve_for_drag' }); this.clearPointerPressState(); this.beginPointerDrag(sourceBlock, pointerId, e.clientX, e.clientY, e.pointerType || null); } @@ -729,7 +732,11 @@ export class DragEventHandler { const sourceBlock = state.dragSourceBlock; const pointerId = state.pointerId; // Preserve selection for restoration after drag - this.clearCommittedRangeSelection({ preserveForDrag: true, anchorHandle: state.sourceHandle }); + this.clearCommittedRangeSelection({ + preserveForDrag: true, + anchorHandle: state.sourceHandle, + reason: 'preserve_for_drag', + }); this.clearMouseRangeSelectState(); this.beginPointerDrag(sourceBlock, pointerId, e.clientX, e.clientY, pointerType); } @@ -808,9 +815,11 @@ export class DragEventHandler { private commitRangeSelection(state: MouseRangeSelectState): void { if (state.shouldClearEditorSelectionOnCommit) { const currentSelection = this.view.state.selection.main; - this.view.dispatch({ - selection: EditorSelection.cursor(currentSelection.head), - }); + if (typeof this.view.dispatch === 'function') { + this.view.dispatch({ + selection: EditorSelection.cursor(currentSelection.head), + }); + } } const docLines = this.view.state.doc.lines; const committedRanges = mergeLineRanges(docLines, state.selectionRanges); @@ -819,20 +828,48 @@ export class DragEventHandler { selectedBlock: committedBlock, ranges: committedRanges, }; - this.rangeVisual.render(committedRanges, { showLinks: state.showLinks, highlightHandles: state.highlightHandles }); + this.lastCommittedSelectionSource = ( + state.shouldClearEditorSelectionOnCommit + && state.pointerType === 'mouse' + ) ? 'smart_mouse' : 'other'; + this.lastCommittedSelectionAtMs = Date.now(); + // Committed selection should be visibly interactive (link bar), + // but keep handles normal to avoid "weird handle" appearance. + this.rangeVisual.render(committedRanges, { showLinks: true, highlightHandles: false }); // Hide handles for non-anchor blocks during selection if (this.deps.setHiddenRangesForSelection) { this.deps.setHiddenRangesForSelection(committedRanges, state.sourceHandle); } } - private clearCommittedRangeSelection(options?: { preserveForDrag?: boolean; anchorHandle?: HTMLElement | null }): void { + private clearCommittedRangeSelection(options?: { + preserveForDrag?: boolean; + anchorHandle?: HTMLElement | null; + reason?: string; + }): 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 + ) { + console.log('[Dragger Debug] clearCommittedRangeSelection skipped by guard', { + reason, + ageMs: Date.now() - this.lastCommittedSelectionAtMs, + }); + return; + } + + console.log( + `[Dragger Debug] clearCommittedRangeSelection reason=${reason} preserveForDrag=${String(!!options?.preserveForDrag)}` + ); console.log('[Dragger Debug] clearCommittedRangeSelection', { preserveForDrag: options?.preserveForDrag, currentRanges: JSON.stringify(this.committedRangeSelection.ranges), anchorHandle: options?.anchorHandle, + reason, }); // If preserveForDrag is true, save the selection info for restoration after drag @@ -857,6 +894,8 @@ export class DragEventHandler { } this.committedRangeSelection = null; + this.lastCommittedSelectionSource = 'other'; + this.lastCommittedSelectionAtMs = 0; this.rangeVisual.clear(); // Restore normal handle visibility if (this.deps.clearHiddenRangesForSelection) { @@ -867,7 +906,7 @@ export class DragEventHandler { private restoreSelectionAfterDrop(targetStartLine: number): void { if (!this.pendingSelectionRestore) return; - const { anchorHandle, sourceLineCount } = this.pendingSelectionRestore; + const { sourceLineCount } = this.pendingSelectionRestore; console.log('[Dragger Debug] restoreSelectionAfterDrop', { targetStartLine, @@ -916,15 +955,19 @@ export class DragEventHandler { selectedBlock: newBlock, ranges: mergedRanges, }; + this.lastCommittedSelectionSource = 'other'; + this.lastCommittedSelectionAtMs = Date.now(); console.log('[Dragger Debug] About to render selection, mergedRanges:', JSON.stringify(mergedRanges)); // Re-render visual and hide other handles - this.rangeVisual.render(mergedRanges, { showLinks: false, highlightHandles: false }); - if (this.deps.setHiddenRangesForSelection && anchorHandle) { - // Try to find the new anchor handle at the target position - const newAnchorHandle = this.findHandleAtLine(targetStartLine) ?? anchorHandle; - console.log('[Dragger Debug] Setting hidden ranges, newAnchorHandle:', newAnchorHandle); + this.rangeVisual.render(mergedRanges, { showLinks: true, highlightHandles: false }); + if (this.deps.setHiddenRangesForSelection) { + const newAnchorHandle = this.findHandleAtLine(clampedStartLine); + console.log('[Dragger Debug] Setting hidden ranges after drop restore', { + anchorLine: clampedStartLine, + hasAnchorHandle: !!newAnchorHandle, + }); this.deps.setHiddenRangesForSelection(mergedRanges, newAnchorHandle); } @@ -935,17 +978,25 @@ export class DragEventHandler { if (!baseBlockInfo) return null; if (!this.isMultiLineSelectionEnabled()) return baseBlockInfo; + if (this.committedRangeSelection && this.isBlockInsideCommittedSelection(baseBlockInfo)) { + console.log('[Dragger Debug] resolveNativeDragSourceForHandleDrag: using committedRangeSelection'); + return cloneBlockInfo(this.committedRangeSelection.selectedBlock); + } + if (this.gesture.phase === 'range_selecting') { const state = this.gesture.rangeSelect; if (state.pointerType === 'mouse') { + console.log('[Dragger Debug] resolveNativeDragSourceForHandleDrag: using in-flight range_selecting source'); return cloneBlockInfo(state.selectedBlock); } } const smartResult = this.smartSelector.evaluate(baseBlockInfo); if (smartResult.shouldUseSmartSelection && smartResult.blockInfo) { + console.log('[Dragger Debug] resolveNativeDragSourceForHandleDrag: using smart selection source'); return smartResult.blockInfo; } + console.log('[Dragger Debug] resolveNativeDragSourceForHandleDrag: fallback to base block'); return baseBlockInfo; } @@ -954,11 +1005,30 @@ export class DragEventHandler { 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 ?? []; @@ -1007,9 +1077,7 @@ export class DragEventHandler { return; } if (this.committedRangeSelection) { - // For committed selections, use default (show links and handles) - // since we don't store these flags in committedRangeSelection - this.rangeVisual.render(this.committedRangeSelection.ranges); + this.rangeVisual.render(this.committedRangeSelection.ranges, { showLinks: true, highlightHandles: false }); } } @@ -1224,10 +1292,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); diff --git a/src/editor/interaction/SmartBlockSelector.ts b/src/editor/interaction/SmartBlockSelector.ts index bcfa740..c5afc27 100644 --- a/src/editor/interaction/SmartBlockSelector.ts +++ b/src/editor/interaction/SmartBlockSelector.ts @@ -30,11 +30,21 @@ export class SmartBlockSelector { * @returns Smart selection result with ranges and block info if applicable */ evaluate(clickedBlock: BlockInfo): SmartSelectionResult { - // Convert 0-indexed to 1-indexed line number const clickedStartLine = clickedBlock.startLine + 1; + const clickedEndLine = clickedBlock.endLine + 1; - // Check if the clicked block intersects with editor selection - const alignedRanges = this.editorSelection.getBlockAlignedRangeIfIntersecting(clickedStartLine); + // Check if the clicked block range intersects with editor selection. + const alignedRanges = this.editorSelection.getBlockAlignedRangeIfRangeIntersecting( + clickedStartLine, + clickedEndLine + ); + console.log('[Dragger Debug] SmartBlockSelector.evaluate', { + clickedBlock: { + startLine: clickedBlock.startLine, + endLine: clickedBlock.endLine, + }, + alignedRanges, + }); if (!alignedRanges) { return { @@ -54,6 +64,14 @@ export class SmartBlockSelector { mergedRanges, clickedBlock ); + console.log('[Dragger Debug] SmartBlockSelector.result', { + mergedRanges, + sourceBlock: { + startLine: blockInfo.startLine, + endLine: blockInfo.endLine, + hasComposite: !!blockInfo.compositeSelection, + }, + }); return { shouldUseSmartSelection: true, diff --git a/src/editor/orchestration/HandleInteractionOrchestrator.ts b/src/editor/orchestration/HandleInteractionOrchestrator.ts index 8d595d2..71511a2 100644 --- a/src/editor/orchestration/HandleInteractionOrchestrator.ts +++ b/src/editor/orchestration/HandleInteractionOrchestrator.ts @@ -137,7 +137,7 @@ export class HandleInteractionOrchestrator { }); const shouldPrimePointerVisual = !( e.pointerType === 'mouse' - && !this.isMultiLineSelectionEnabled() + && this.isMultiLineSelectionEnabled() ); if (shouldPrimePointerVisual) { const blockInfo = resolveCurrentBlock(); diff --git a/styles.css b/styles.css index d69d281..4b422c8 100644 --- a/styles.css +++ b/styles.css @@ -127,11 +127,13 @@ body:not(.dnd-block-selection-active) .dnd-drag-handle:hover { .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 selection color, not handle color, to avoid "black sidebar" in light themes + where handle color defaults to --text-normal. */ + background-color: var(--dnd-selection-highlight-color, var(--interactive-accent)); + box-shadow: 0 0 4px color-mix(in srgb, var(--dnd-selection-highlight-color, var(--interactive-accent)) 28%, transparent); will-change: left, top, height, opacity; transition: left 45ms linear, @@ -240,4 +242,3 @@ body.dnd-mobile-gesture-lock { 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); } - From b42c167f5b5537fb09520ae9878dd838ac19c4e4 Mon Sep 17 00:00:00 2001 From: lushuilu <2981999092@qq.com> Date: Fri, 13 Feb 2026 10:35:38 +0800 Subject: [PATCH 19/25] fix: smart text-selection drag now correctly highlights all selected blocks - Capture editor selection snapshot at pointerdown start before browser clears it - Pass snapshot to SmartBlockSelector.evaluate() for reliable multi-block detection - Fix syncSelectionElements to check DOM class existence instead of Set identity - Prevent default/stop propagation in smart selection to avoid event conflicts - Remove link bar (vertical line) from selection visuals per user preference - Improve CSS color fallback chain for selection highlight Co-Authored-By: Claude Opus 4.6 --- src/editor/interaction/DragEventHandler.ts | 61 +++++++++--- src/editor/interaction/SmartBlockSelector.ts | 96 +++++++++++++++++-- .../visual/RangeSelectionVisualManager.ts | 31 +++++- styles.css | 8 +- 4 files changed, 164 insertions(+), 32 deletions(-) diff --git a/src/editor/interaction/DragEventHandler.ts b/src/editor/interaction/DragEventHandler.ts index 17fd923..6d2227f 100644 --- a/src/editor/interaction/DragEventHandler.ts +++ b/src/editor/interaction/DragEventHandler.ts @@ -28,7 +28,7 @@ import { resolveTargetBoundaryForRangeSelection, resolveBlockBoundaryAtLine, } from './RangeSelectionLogic'; -import { SmartBlockSelector } from './SmartBlockSelector'; +import { SmartBlockSelector, SmartSelectionResult } from './SmartBlockSelector'; const MOBILE_DRAG_LONG_PRESS_MS = 100; const MOBILE_DRAG_START_MOVE_THRESHOLD_PX = 8; @@ -98,13 +98,31 @@ export class DragEventHandler { 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; + const multiLineSelectionEnabled = this.isMultiLineSelectionEnabled(); + if (multiLineSelectionEnabled && e.button === 0) { + const snapshot = this.smartSelector.captureSelectionSnapshot(); + if (snapshot.length > 0) { + console.log('[Dragger Debug] Captured selection snapshot at pointerdown:', { + selections: snapshot.map(s => ({ fromLine: s.fromLine, toLine: s.toLine })), + }); + // Store snapshot for later use in startPointerDragFromHandle + // We'll evaluate it when we know which block was clicked + (this as { _pendingSelectionSnapshot?: typeof snapshot })._pendingSelectionSnapshot = snapshot; + } + } + const target = e.target instanceof HTMLElement ? e.target : null; if (!target) return; const pointerType = e.pointerType || null; this.lastPointerType = pointerType; - const multiLineSelectionEnabled = this.isMultiLineSelectionEnabled(); if (!multiLineSelectionEnabled) { this.clearCommittedRangeSelection({ reason: 'feature_disabled' }); } @@ -248,7 +266,15 @@ export class DragEventHandler { // 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); + // Use the pre-captured selection snapshot if available + const snapshot = (this as { _pendingSelectionSnapshot?: unknown })._pendingSelectionSnapshot; + const smartResult = this.smartSelector.evaluate( + blockInfo, + snapshot as import('./SmartBlockSelector').EditorTextSelection[] | undefined + ); + // Clear the snapshot after use + delete (this as { _pendingSelectionSnapshot?: unknown })._pendingSelectionSnapshot; + if (smartResult.shouldUseSmartSelection && smartResult.blockInfo) { this.startRangeSelectWithPrecomputedRanges( smartResult.blockInfo, @@ -260,6 +286,9 @@ export class DragEventHandler { } } + // Clear the snapshot if we didn't use it + delete (this as { _pendingSelectionSnapshot?: unknown })._pendingSelectionSnapshot; + if (e.pointerType === 'mouse') { if (e.button !== 0) return; if (!multiLineSelectionEnabled) { @@ -441,13 +470,15 @@ export class DragEventHandler { const isMouse = pointerType === 'mouse'; const sourceHandleDraggableAttr = handle?.getAttribute('draggable') ?? null; + // Always prevent default and stop propagation for smart selection + // to avoid the selection being cleared by other handlers + e.preventDefault(); + e.stopPropagation(); if (!isMouse) { - e.preventDefault(); - e.stopPropagation(); this.pointer.tryCapturePointer(e); - if (handle) { - handle.setAttribute('draggable', 'false'); - } + } + if (handle) { + handle.setAttribute('draggable', 'false'); } const anchorStartLineNumber = precomputedRanges[0].startLineNumber; @@ -833,9 +864,8 @@ export class DragEventHandler { && state.pointerType === 'mouse' ) ? 'smart_mouse' : 'other'; this.lastCommittedSelectionAtMs = Date.now(); - // Committed selection should be visibly interactive (link bar), - // but keep handles normal to avoid "weird handle" appearance. - this.rangeVisual.render(committedRanges, { showLinks: true, highlightHandles: false }); + // 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); @@ -960,8 +990,9 @@ export class DragEventHandler { console.log('[Dragger Debug] About to render selection, mergedRanges:', JSON.stringify(mergedRanges)); - // Re-render visual and hide other handles - this.rangeVisual.render(mergedRanges, { showLinks: true, highlightHandles: false }); + // 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(mergedRanges, { showLinks: false, highlightHandles: false }); if (this.deps.setHiddenRangesForSelection) { const newAnchorHandle = this.findHandleAtLine(clampedStartLine); console.log('[Dragger Debug] Setting hidden ranges after drop restore', { @@ -1073,11 +1104,11 @@ export class DragEventHandler { }); if (this.gesture.phase === 'range_selecting') { const state = this.gesture.rangeSelect; - this.rangeVisual.render(state.selectionRanges, { showLinks: state.showLinks, highlightHandles: state.highlightHandles }); + this.rangeVisual.render(state.selectionRanges, { showLinks: false, highlightHandles: state.highlightHandles }); return; } if (this.committedRangeSelection) { - this.rangeVisual.render(this.committedRangeSelection.ranges, { showLinks: true, highlightHandles: false }); + this.rangeVisual.render(this.committedRangeSelection.ranges, { showLinks: false, highlightHandles: false }); } } diff --git a/src/editor/interaction/SmartBlockSelector.ts b/src/editor/interaction/SmartBlockSelector.ts index c5afc27..0a0854a 100644 --- a/src/editor/interaction/SmartBlockSelector.ts +++ b/src/editor/interaction/SmartBlockSelector.ts @@ -1,7 +1,12 @@ import type { EditorView } from '@codemirror/view'; import type { BlockInfo, LineRange } from '../../types'; -import { EditorSelectionBridge } from '../core/services/EditorSelectionBridge'; -import { buildDragSourceFromLineRanges, mergeLineRanges } from './RangeSelectionLogic'; +import { EditorSelectionBridge, EditorTextSelection } from '../core/services/EditorSelectionBridge'; +import { + buildDragSourceFromLineRanges, + mergeLineRanges, + resolveBlockBoundaryAtLine, + resolveBlockAlignedLineRange, +} from './RangeSelectionLogic'; export type SmartSelectionResult = { shouldUseSmartSelection: boolean; @@ -22,28 +27,43 @@ export class SmartBlockSelector { 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 current editor text selection. + * 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): SmartSelectionResult { + evaluate(clickedBlock: BlockInfo, selectionSnapshot?: EditorTextSelection[]): SmartSelectionResult { const clickedStartLine = clickedBlock.startLine + 1; const clickedEndLine = clickedBlock.endLine + 1; - // Check if the clicked block range intersects with editor selection. - const alignedRanges = this.editorSelection.getBlockAlignedRangeIfRangeIntersecting( - clickedStartLine, - clickedEndLine - ); + // Use snapshot if provided, otherwise read live selection + const alignedRanges = selectionSnapshot + ? this.getBlockAlignedRangeFromSnapshot(selectionSnapshot, clickedStartLine, clickedEndLine) + : this.editorSelection.getBlockAlignedRangeIfRangeIntersecting( + clickedStartLine, + clickedEndLine + ); + console.log('[Dragger Debug] SmartBlockSelector.evaluate', { clickedBlock: { startLine: clickedBlock.startLine, endLine: clickedBlock.endLine, }, alignedRanges, + usedSnapshot: !!selectionSnapshot, }); if (!alignedRanges) { @@ -80,6 +100,64 @@ export class SmartBlockSelector { }; } + /** + * 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 + )); + + console.log('[Dragger Debug] SmartBlockSelector.getFromSnapshot', { + snapshot: snapshot.map(s => ({ fromLine: s.fromLine, toLine: s.toLine })), + clickedRange: { start: safeStart, end: safeEnd }, + intersects, + }); + + 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. */ diff --git a/src/editor/visual/RangeSelectionVisualManager.ts b/src/editor/visual/RangeSelectionVisualManager.ts index 509b0fa..2557d70 100644 --- a/src/editor/visual/RangeSelectionVisualManager.ts +++ b/src/editor/visual/RangeSelectionVisualManager.ts @@ -60,6 +60,9 @@ export class RangeSelectionVisualManager { const lineEl = this.getLineElementForLine(lineNumber); if (lineEl) { nextLineElements.add(lineEl); + console.log('[Dragger Debug] Found line element for line', lineNumber, ':', lineEl.className); + } else { + console.log('[Dragger Debug] No line element found for line', lineNumber); } const lineNumberEl = getLineNumberElementForLine(this.view, lineNumber); if (lineNumberEl) { @@ -81,6 +84,10 @@ export class RangeSelectionVisualManager { nextLineElements, RANGE_SELECTED_LINE_CLASS ); + // Verify after sync + console.log('[Dragger Debug] After sync, checking DOM for class:', RANGE_SELECTED_LINE_CLASS); + const checkEls = this.view.dom.querySelectorAll('.' + RANGE_SELECTED_LINE_CLASS); + console.log('[Dragger Debug] DOM elements with class:', checkEls.length); this.syncSelectionElements( this.lineNumberElements, nextLineNumberElements, @@ -243,14 +250,30 @@ export class RangeSelectionVisualManager { next: Set, className: string ): void { + console.log('[Dragger Debug] syncSelectionElements', { + className, + currentSize: current.size, + nextSize: next.size, + }); + + // 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) + let addedCount = 0; for (const el of next) { - if (current.has(el)) continue; - el.classList.add(className); + if (!el.classList.contains(className)) { + el.classList.add(className); + addedCount++; + } } + console.log('[Dragger Debug] syncSelectionElements added', addedCount, 'classes'); + current.clear(); for (const el of next) { current.add(el); diff --git a/styles.css b/styles.css index 4b422c8..dc55eb5 100644 --- a/styles.css +++ b/styles.css @@ -130,10 +130,10 @@ body:not(.dnd-block-selection-active) .dnd-drag-handle:hover { width: 3px; transform: translateX(-50%); border-radius: 999px; - /* Use selection color, not handle color, to avoid "black sidebar" in light themes - where handle color defaults to --text-normal. */ - background-color: var(--dnd-selection-highlight-color, var(--interactive-accent)); - box-shadow: 0 0 4px color-mix(in srgb, var(--dnd-selection-highlight-color, var(--interactive-accent)) 28%, 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, From 646d52e3681afdda626e886519ad96cfa1e369d6 Mon Sep 17 00:00:00 2001 From: lushuilu <2981999092@qq.com> Date: Fri, 13 Feb 2026 10:39:39 +0800 Subject: [PATCH 20/25] fix: allow native HTML5 drag after smart text selection - Don't preventDefault/stopPropagation for mouse events in smart selection - Keep draggable attribute enabled for mouse to allow native drag - Touch events still use pointer capture approach Co-Authored-By: Claude Opus 4.6 --- src/editor/interaction/DragEventHandler.ts | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/src/editor/interaction/DragEventHandler.ts b/src/editor/interaction/DragEventHandler.ts index 6d2227f..ed919c4 100644 --- a/src/editor/interaction/DragEventHandler.ts +++ b/src/editor/interaction/DragEventHandler.ts @@ -470,16 +470,18 @@ export class DragEventHandler { const isMouse = pointerType === 'mouse'; const sourceHandleDraggableAttr = handle?.getAttribute('draggable') ?? null; - // Always prevent default and stop propagation for smart selection - // to avoid the selection being cleared by other handlers - e.preventDefault(); - e.stopPropagation(); + // For non-mouse (touch), prevent default and capture pointer + // For mouse, allow native HTML5 drag to work if (!isMouse) { + e.preventDefault(); + e.stopPropagation(); this.pointer.tryCapturePointer(e); + if (handle) { + handle.setAttribute('draggable', 'false'); + } } - if (handle) { - handle.setAttribute('draggable', 'false'); - } + // For mouse: don't prevent default - let native drag work + // The timeout will commit selection, and native drag will use resolveNativeDragSourceForHandleDrag const anchorStartLineNumber = precomputedRanges[0].startLineNumber; const anchorEndLineNumber = precomputedRanges[precomputedRanges.length - 1].endLineNumber; From 4d1673a5e1523ba3ae42751b820b035f93281fa2 Mon Sep 17 00:00:00 2001 From: lushuilu <2981999092@qq.com> Date: Fri, 13 Feb 2026 21:02:07 +0800 Subject: [PATCH 21/25] fix: stabilize smart range selection and preserve drag workflows --- src/editor/drag-handle.ts | 2 + .../interaction/DragEventHandler.spec.ts | 350 +++++++++++++++++- src/editor/interaction/DragEventHandler.ts | 206 +++++++++-- src/editor/interaction/SmartBlockSelector.ts | 2 + .../visual/HandleVisibilityController.spec.ts | 51 +++ .../visual/HandleVisibilityController.ts | 57 ++- .../visual/RangeSelectionVisualManager.ts | 64 +++- styles.css | 10 + 8 files changed, 697 insertions(+), 45 deletions(-) diff --git a/src/editor/drag-handle.ts b/src/editor/drag-handle.ts index 11b7145..321f44e 100644 --- a/src/editor/drag-handle.ts +++ b/src/editor/drag-handle.ts @@ -227,6 +227,7 @@ function createDragHandleViewPlugin(_plugin: DragNDropPlugin) { this.reResolveActiveHandle(); } this.handleVisibility.reapplySelectionHighlight(); + this.handleVisibility.reapplySelectionHandleVisibility(); return; } @@ -247,6 +248,7 @@ function createDragHandleViewPlugin(_plugin: DragNDropPlugin) { this.reResolveActiveHandle(); } this.handleVisibility.reapplySelectionHighlight(); + this.handleVisibility.reapplySelectionHandleVisibility(); } destroy(): void { diff --git a/src/editor/interaction/DragEventHandler.spec.ts b/src/editor/interaction/DragEventHandler.spec.ts index 57120d2..330d440 100644 --- a/src/editor/interaction/DragEventHandler.spec.ts +++ b/src/editor/interaction/DragEventHandler.spec.ts @@ -509,6 +509,67 @@ describe('DragEventHandler', () => { 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(); + }); + it('supports touch thresholds: shorter long-press drags single block, longer long-press enters range selection', () => { const view = createViewStub(8); const handle = document.createElement('div'); @@ -1484,7 +1545,7 @@ describe('DragEventHandler', () => { expect(performDropAtPoint).toHaveBeenCalledTimes(1); expect(finishDragSession).toHaveBeenCalledTimes(1); const lines = view.contentDOM.querySelectorAll('.cm-line'); - expect(lines[4]?.classList.contains('dnd-range-selected-line')).toBe(true); + expect(lines[3]?.classList.contains('dnd-range-selected-line')).toBe(true); expect(lines[1]?.classList.contains('dnd-range-selected-line')).toBe(false); handler.destroy(); }); @@ -1548,11 +1609,150 @@ describe('DragEventHandler', () => { 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[6]?.classList.contains('dnd-range-selected-line')).toBe(true); - expect(lines[7]?.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(); }); @@ -1607,6 +1807,78 @@ describe('DragEventHandler', () => { 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) => { @@ -1722,6 +1994,78 @@ describe('DragEventHandler', () => { 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('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) => { diff --git a/src/editor/interaction/DragEventHandler.ts b/src/editor/interaction/DragEventHandler.ts index ed919c4..2a56248 100644 --- a/src/editor/interaction/DragEventHandler.ts +++ b/src/editor/interaction/DragEventHandler.ts @@ -39,6 +39,7 @@ 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; type PointerDragData = { sourceBlock: BlockInfo; @@ -86,6 +87,7 @@ export class DragEventHandler { private committedRangeSelection: CommittedRangeSelection | null = null; private lastCommittedSelectionSource: 'smart_mouse' | 'other' = 'other'; private lastCommittedSelectionAtMs = 0; + private lastCommittedSelectionPointerId: number | null = null; private lastPointerType: string | null = null; // Store selection info to restore after drag completes private pendingSelectionRestore: { @@ -133,7 +135,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, { @@ -143,11 +151,17 @@ export class DragEventHandler { } } if (canHandleCommittedSelection && this.shouldClearCommittedSelectionOnPointerDown(target, e.clientX, pointerType)) { - this.clearCommittedRangeSelection({ reason: 'pointerdown_outside_selection' }); + this.clearCommittedRangeSelection({ + reason: 'pointerdown_outside_selection', + pointerId: e.pointerId, + }); } 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; } @@ -213,7 +227,7 @@ export class DragEventHandler { const targetLineNumber = this.deps.performDropAtPoint(sourceBlock, e.clientX, e.clientY, 'mouse'); this.deps.hideDropIndicator(); this.deps.finishDragSession(); - if (targetLineNumber !== null && this.pendingSelectionRestore) { + if (typeof targetLineNumber === 'number' && this.pendingSelectionRestore) { setTimeout(() => { this.restoreSelectionAfterDrop(targetLineNumber); }, 0); @@ -333,6 +347,7 @@ export class DragEventHandler { } refreshSelectionVisual(): void { + console.log('[Dragger Debug] refreshSelectionVisual called, committedRangeSelection:', !!this.committedRangeSelection); const multiLineSelectionEnabled = this.isMultiLineSelectionEnabled(); if (!multiLineSelectionEnabled) { this.clearCommittedRangeSelection({ reason: 'refresh_feature_disabled' }); @@ -470,23 +485,69 @@ export class DragEventHandler { const isMouse = pointerType === 'mouse'; const sourceHandleDraggableAttr = handle?.getAttribute('draggable') ?? null; - // For non-mouse (touch), prevent default and capture pointer - // For mouse, allow native HTML5 drag to work + // 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(); - e.stopPropagation(); this.pointer.tryCapturePointer(e); - if (handle) { - handle.setAttribute('draggable', 'false'); - } } - // For mouse: don't prevent default - let native drag work - // The timeout will commit selection, and native drag will use resolveNativeDragSourceForHandleDrag + if (!isMouse && handle) { + handle.setAttribute('draggable', 'false'); + } const anchorStartLineNumber = precomputedRanges[0].startLineNumber; const anchorEndLineNumber = precomputedRanges[precomputedRanges.length - 1].endLineNumber; - // Smart selection should still commit immediately for both click and drag paths. + // For mouse: immediately commit selection and let native drag work + // For touch: use timeout to allow long-press gesture + if (isMouse) { + // 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 + console.log('[Dragger Debug] About to commit selection for mouse'); + this.commitRangeSelection(selectionState); + console.log('[Dragger Debug] After commit, committedRangeSelection:', !!this.committedRangeSelection); + this.gesture = { phase: 'idle' }; + console.log('[Dragger Debug] After setting gesture to idle, committedRangeSelection:', !!this.committedRangeSelection); + return; + } + + // 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; @@ -510,7 +571,7 @@ export class DragEventHandler { pointerType, dragReady: true, longPressReady: false, - isIntercepting: !isMouse, + isIntercepting: true, timeoutId, dragTimeoutId: null, sourceHandle: handle, @@ -846,17 +907,12 @@ export class DragEventHandler { } private commitRangeSelection(state: MouseRangeSelectState): void { - if (state.shouldClearEditorSelectionOnCommit) { - const currentSelection = this.view.state.selection.main; - if (typeof this.view.dispatch === 'function') { - this.view.dispatch({ - selection: EditorSelection.cursor(currentSelection.head), - }); - } - } 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, @@ -866,6 +922,18 @@ export class DragEventHandler { && state.pointerType === 'mouse' ) ? 'smart_mouse' : 'other'; this.lastCommittedSelectionAtMs = Date.now(); + this.lastCommittedSelectionPointerId = state.pointerId; + + // 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 @@ -878,7 +946,9 @@ export class DragEventHandler { preserveForDrag?: boolean; anchorHandle?: HTMLElement | null; reason?: string; + pointerId?: number | null; }): void { + console.log('[Dragger Debug] clearCommittedRangeSelection called, reason:', options?.reason, 'hasSelection:', !!this.committedRangeSelection); if (!this.committedRangeSelection) return; const reason = options?.reason ?? 'unspecified'; @@ -893,6 +963,21 @@ export class DragEventHandler { }); return; } + if ( + reason === 'pointerdown_outside_selection' + && this.lastCommittedSelectionSource === 'smart_mouse' + && typeof options?.pointerId === 'number' + && options.pointerId === this.lastCommittedSelectionPointerId + && Date.now() - this.lastCommittedSelectionAtMs <= SMART_SELECTION_POINTERDOWN_CLEAR_GUARD_MS + ) { + console.log('[Dragger Debug] clearCommittedRangeSelection skipped by pointerdown guard', { + reason, + pointerId: options.pointerId, + committedPointerId: this.lastCommittedSelectionPointerId, + ageMs: Date.now() - this.lastCommittedSelectionAtMs, + }); + return; + } console.log( `[Dragger Debug] clearCommittedRangeSelection reason=${reason} preserveForDrag=${String(!!options?.preserveForDrag)}` @@ -909,9 +994,7 @@ export class DragEventHandler { const ranges = cloneLineRanges(this.committedRangeSelection.ranges); const firstRange = ranges[0]; if (firstRange) { - const sourceLineCount = ranges.reduce((count, range) => { - return count + Math.max(0, range.endLineNumber - range.startLineNumber + 1); - }, 0); + const sourceLineCount = this.countLinesInRanges(ranges); this.pendingSelectionRestore = { ranges, anchorHandle: options.anchorHandle ?? null, @@ -928,6 +1011,7 @@ export class DragEventHandler { this.committedRangeSelection = null; this.lastCommittedSelectionSource = 'other'; this.lastCommittedSelectionAtMs = 0; + this.lastCommittedSelectionPointerId = null; this.rangeVisual.clear(); // Restore normal handle visibility if (this.deps.clearHiddenRangesForSelection) { @@ -937,24 +1021,38 @@ export class DragEventHandler { private restoreSelectionAfterDrop(targetStartLine: number): void { if (!this.pendingSelectionRestore) return; + if (!Number.isFinite(targetStartLine)) { + this.pendingSelectionRestore = null; + return; + } - const { sourceLineCount } = this.pendingSelectionRestore; + const ranges = cloneLineRanges(this.pendingSelectionRestore.ranges); + const sourceLineCount = this.countLinesInRanges(ranges); + if (sourceLineCount <= 0) { + this.pendingSelectionRestore = null; + return; + } console.log('[Dragger Debug] restoreSelectionAfterDrop', { targetStartLine, sourceLineCount, }); - // The selection should be exactly at the target position with the same line count - const newStartLine = targetStartLine; - const newEndLine = targetStartLine + sourceLineCount - 1; + // `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 clampedEndLine = Math.max(clampedStartLine, Math.min(docLines, newEndLine)); + const clampedEndLine = Math.max( + clampedStartLine, + Math.min(docLines, clampedStartLine + sourceLineCount - 1) + ); console.log('[Dragger Debug] calculated new selection', { + movedLinesBeforeTarget, newStartLine: clampedStartLine, newEndLine: clampedEndLine, docLines, @@ -989,6 +1087,7 @@ export class DragEventHandler { }; this.lastCommittedSelectionSource = 'other'; this.lastCommittedSelectionAtMs = Date.now(); + this.lastCommittedSelectionPointerId = null; console.log('[Dragger Debug] About to render selection, mergedRanges:', JSON.stringify(mergedRanges)); @@ -1007,6 +1106,22 @@ export class DragEventHandler { 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; + } + resolveNativeDragSourceForHandleDrag(baseBlockInfo: BlockInfo | null): BlockInfo | null { if (!baseBlockInfo) return null; if (!this.isMultiLineSelectionEnabled()) return baseBlockInfo; @@ -1136,9 +1251,19 @@ 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}`); + console.log('[Dragger Debug] shouldClearCommittedSelectionOnPointerDown', { + targetClass: target.className, + isLink: !!isLink, + isSelectedHandle: !!isSelectedHandle, + isHandle: !!isHandle, + clientX, + }); + if (isLink) return false; + if (isSelectedHandle) return false; + if (isHandle) return false; if (pointerType && pointerType !== 'mouse') { if (!this.mobile.isWithinContentTolerance(clientX)) { @@ -1149,7 +1274,9 @@ 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; + console.log('[Dragger Debug] shouldClearCommittedSelectionOnPointerDown result:', shouldClear, 'centerX:', centerX, 'threshold:', centerX + RANGE_SELECTION_GRIP_HIT_X_PADDING_PX); + return shouldClear; } private isCommittedSelectionGripHit( @@ -1191,6 +1318,16 @@ 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; @@ -1209,7 +1346,7 @@ export class DragEventHandler { pointerType: e.pointerType || null, }); // Restore selection after drop if we had a pending restore and drop succeeded - if (shouldDrop && targetLineNumber !== null && this.pendingSelectionRestore) { + if (shouldDrop && typeof targetLineNumber === 'number' && this.pendingSelectionRestore) { console.log('[Dragger Debug] Will restore selection, pendingSelectionRestore:', this.pendingSelectionRestore); // Use setTimeout to ensure the document update has been processed setTimeout(() => { @@ -1221,6 +1358,7 @@ export class DragEventHandler { } private handlePointerUp(e: PointerEvent): void { + console.log('[Dragger Debug] handlePointerUp called, gesture.phase:', this.gesture.phase, 'committedRangeSelection:', !!this.committedRangeSelection); if (this.gesture.phase === 'dragging') { this.finishPointerDrag(e, true); return; diff --git a/src/editor/interaction/SmartBlockSelector.ts b/src/editor/interaction/SmartBlockSelector.ts index 0a0854a..8e0f479 100644 --- a/src/editor/interaction/SmartBlockSelector.ts +++ b/src/editor/interaction/SmartBlockSelector.ts @@ -14,6 +14,8 @@ export type SmartSelectionResult = { 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 diff --git a/src/editor/visual/HandleVisibilityController.spec.ts b/src/editor/visual/HandleVisibilityController.spec.ts index 16b38d2..26d9161 100644 --- a/src/editor/visual/HandleVisibilityController.spec.ts +++ b/src/editor/visual/HandleVisibilityController.spec.ts @@ -4,6 +4,7 @@ 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'); @@ -52,4 +53,54 @@ describe('HandleVisibilityController', () => { 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 78ba92c..130ce98 100644 --- a/src/editor/visual/HandleVisibilityController.ts +++ b/src/editor/visual/HandleVisibilityController.ts @@ -13,6 +13,9 @@ import { 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; @@ -81,9 +84,11 @@ export class HandleVisibilityController { 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 - if (anchorHandle) { - this.setActiveVisibleHandle(anchorHandle); + const anchor = this.getConnectedAnchorHandle(); + if (anchor) { + this.setActiveVisibleHandle(anchor); } } @@ -95,6 +100,7 @@ export class HandleVisibilityController { this.anchorHandleForSelection = null; // Remove body class to re-enable handle hover effects document.body.classList.remove(BLOCK_SELECTION_ACTIVE_CLASS); + this.reapplySelectionHandleVisibility(); } setActiveVisibleHandle( @@ -159,6 +165,40 @@ export class HandleVisibilityController { 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 { const contentRect = this.view.contentDOM.getBoundingClientRect(); if (clientY < contentRect.top || clientY > contentRect.bottom) return false; @@ -232,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/RangeSelectionVisualManager.ts b/src/editor/visual/RangeSelectionVisualManager.ts index 2557d70..9fce444 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, @@ -79,6 +83,11 @@ export class RangeSelectionVisualManager { } } console.log('[Dragger Debug] Matched lines:', matchedLines, 'lineElements count:', nextLineElements.size); + if (matchedLines.length > 0 && nextLineElements.size === 0) { + this.scheduleLineResolutionRetry(); + } else { + this.clearLineResolutionRetry(); + } this.syncSelectionElements( this.lineElements, nextLineElements, @@ -106,7 +115,9 @@ export class RangeSelectionVisualManager { } clear(): void { + // Add stack trace to find who is calling clear console.log('[Dragger Debug] RangeSelectionVisualManager.clear called, lineElements count:', this.lineElements.size); + console.log('[Dragger Debug] clear() stack trace:', new Error().stack); // Clear tracked elements for (const lineEl of this.lineElements) { @@ -135,6 +146,7 @@ export class RangeSelectionVisualManager { console.log('[Dragger Debug] Cleared remaining elements - lines:', remainingLineElements.length, 'handles:', remainingHandleElements.length); this.hideLinks(); + this.clearLineResolutionRetry(); } private hideLinks(): void { @@ -208,12 +220,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; } @@ -226,6 +246,7 @@ export class RangeSelectionVisualManager { } this.linkEls.length = 0; this.cancelScheduledRefresh(); + this.clearLineResolutionRetry(); this.unbindScrollListener(); } @@ -267,6 +288,7 @@ export class RangeSelectionVisualManager { // Add class to all elements in next set (they should all have the class) let addedCount = 0; for (const el of next) { + if (!el.isConnected) continue; if (!el.classList.contains(className)) { el.classList.add(className); addedCount++; @@ -276,10 +298,40 @@ export class RangeSelectionVisualManager { 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/styles.css b/styles.css index dc55eb5..59bf91e 100644 --- a/styles.css +++ b/styles.css @@ -120,6 +120,16 @@ body:not(.dnd-block-selection-active) .dnd-drag-handle:hover { 0 0 10px color-mix(in srgb, var(--dnd-handle-color, var(--interactive-accent)) 45%, 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; From 9dddaf53d91ddb543b610ff98b9a5f2f80665822 Mon Sep 17 00:00:00 2001 From: lushuilu <2981999092@qq.com> Date: Fri, 13 Feb 2026 21:19:09 +0800 Subject: [PATCH 22/25] fix: keep committed block selection stable after view updates --- src/editor/drag-handle.ts | 7 ++++++- .../interaction/DragEventHandler.spec.ts | 18 ++++++++---------- src/editor/interaction/DragEventHandler.ts | 8 ++++++++ 3 files changed, 22 insertions(+), 11 deletions(-) diff --git a/src/editor/drag-handle.ts b/src/editor/drag-handle.ts index 321f44e..8518575 100644 --- a/src/editor/drag-handle.ts +++ b/src/editor/drag-handle.ts @@ -239,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(); diff --git a/src/editor/interaction/DragEventHandler.spec.ts b/src/editor/interaction/DragEventHandler.spec.ts index 330d440..91c10e7 100644 --- a/src/editor/interaction/DragEventHandler.spec.ts +++ b/src/editor/interaction/DragEventHandler.spec.ts @@ -724,20 +724,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(); }); @@ -2053,11 +2051,11 @@ describe('DragEventHandler', () => { 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', { + dispatchPointer(lines[5]!, 'pointerdown', { pointerId: 112, pointerType: 'mouse', clientX: 220, - clientY: 50, + clientY: 120, }); lines = view.contentDOM.querySelectorAll('.cm-line'); expect(lines[1]?.classList.contains('dnd-range-selected-line')).toBe(false); diff --git a/src/editor/interaction/DragEventHandler.ts b/src/editor/interaction/DragEventHandler.ts index 2a56248..b204924 100644 --- a/src/editor/interaction/DragEventHandler.ts +++ b/src/editor/interaction/DragEventHandler.ts @@ -7,6 +7,7 @@ import { import { DRAG_HANDLE_CLASS, EMBED_HANDLE_CLASS, + RANGE_SELECTED_LINE_CLASS, RANGE_SELECTED_HANDLE_CLASS, RANGE_SELECTION_LINK_CLASS, } from '../core/selectors'; @@ -346,6 +347,10 @@ export class DragEventHandler { return this.hasActivePointerSession(); } + hasCommittedSelection(): boolean { + return this.committedRangeSelection !== null; + } + refreshSelectionVisual(): void { console.log('[Dragger Debug] refreshSelectionVisual called, committedRangeSelection:', !!this.committedRangeSelection); const multiLineSelectionEnabled = this.isMultiLineSelectionEnabled(); @@ -1254,16 +1259,19 @@ export class DragEventHandler { const isLink = target.closest(`.${RANGE_SELECTION_LINK_CLASS}`); const isSelectedHandle = target.closest(`.${RANGE_SELECTED_HANDLE_CLASS}`); const isHandle = target.closest(`.${DRAG_HANDLE_CLASS}`); + const isSelectedLine = target.closest(`.${RANGE_SELECTED_LINE_CLASS}`); console.log('[Dragger Debug] shouldClearCommittedSelectionOnPointerDown', { targetClass: target.className, isLink: !!isLink, isSelectedHandle: !!isSelectedHandle, isHandle: !!isHandle, + isSelectedLine: !!isSelectedLine, clientX, }); if (isLink) return false; if (isSelectedHandle) return false; if (isHandle) return false; + if (isSelectedLine) return false; if (pointerType && pointerType !== 'mouse') { if (!this.mobile.isWithinContentTolerance(clientX)) { From e4c90ec6a07b2e06a85deb40ca9e274d2f8c1256 Mon Sep 17 00:00:00 2001 From: lushuilu <2981999092@qq.com> Date: Fri, 13 Feb 2026 21:43:26 +0800 Subject: [PATCH 23/25] fix: allow clearing block selection by clicking selected area --- .../interaction/DragEventHandler.spec.ts | 65 ++++++++++++++++++- src/editor/interaction/DragEventHandler.ts | 4 -- 2 files changed, 63 insertions(+), 6 deletions(-) diff --git a/src/editor/interaction/DragEventHandler.spec.ts b/src/editor/interaction/DragEventHandler.spec.ts index 91c10e7..399cb8b 100644 --- a/src/editor/interaction/DragEventHandler.spec.ts +++ b/src/editor/interaction/DragEventHandler.spec.ts @@ -2051,12 +2051,73 @@ describe('DragEventHandler', () => { expect(lines[2]?.classList.contains('dnd-range-selected-line')).toBe(true); expect(lines[3]?.classList.contains('dnd-range-selected-line')).toBe(true); - dispatchPointer(lines[5]!, 'pointerdown', { + dispatchPointer(lines[2]!, 'pointerdown', { pointerId: 112, pointerType: 'mouse', clientX: 220, - clientY: 120, + 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); diff --git a/src/editor/interaction/DragEventHandler.ts b/src/editor/interaction/DragEventHandler.ts index b204924..2b0b8d4 100644 --- a/src/editor/interaction/DragEventHandler.ts +++ b/src/editor/interaction/DragEventHandler.ts @@ -7,7 +7,6 @@ import { import { DRAG_HANDLE_CLASS, EMBED_HANDLE_CLASS, - RANGE_SELECTED_LINE_CLASS, RANGE_SELECTED_HANDLE_CLASS, RANGE_SELECTION_LINK_CLASS, } from '../core/selectors'; @@ -1259,19 +1258,16 @@ export class DragEventHandler { const isLink = target.closest(`.${RANGE_SELECTION_LINK_CLASS}`); const isSelectedHandle = target.closest(`.${RANGE_SELECTED_HANDLE_CLASS}`); const isHandle = target.closest(`.${DRAG_HANDLE_CLASS}`); - const isSelectedLine = target.closest(`.${RANGE_SELECTED_LINE_CLASS}`); console.log('[Dragger Debug] shouldClearCommittedSelectionOnPointerDown', { targetClass: target.className, isLink: !!isLink, isSelectedHandle: !!isSelectedHandle, isHandle: !!isHandle, - isSelectedLine: !!isSelectedLine, clientX, }); if (isLink) return false; if (isSelectedHandle) return false; if (isHandle) return false; - if (isSelectedLine) return false; if (pointerType && pointerType !== 'mouse') { if (!this.mobile.isWithinContentTolerance(clientX)) { From 56f306760bdb002a259b449125acbadcc8c97795 Mon Sep 17 00:00:00 2001 From: lushuilu <2981999092@qq.com> Date: Fri, 13 Feb 2026 22:10:52 +0800 Subject: [PATCH 24/25] fix: harden range selection restore and snapshot lifecycle --- .../interaction/DragEventHandler.spec.ts | 205 ++++++++++++++---- src/editor/interaction/DragEventHandler.ts | 179 +++++++++++---- 2 files changed, 296 insertions(+), 88 deletions(-) diff --git a/src/editor/interaction/DragEventHandler.spec.ts b/src/editor/interaction/DragEventHandler.spec.ts index 399cb8b..b3958f1 100644 --- a/src/editor/interaction/DragEventHandler.spec.ts +++ b/src/editor/interaction/DragEventHandler.spec.ts @@ -173,6 +173,14 @@ function applyMultiTextSelections(view: EditorView, ranges: Array<{ fromLine: nu }); } +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); @@ -257,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(); }); @@ -361,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); @@ -441,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', { @@ -503,9 +507,8 @@ 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(); }); @@ -645,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); @@ -781,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, @@ -791,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(); }); @@ -852,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(); }); @@ -930,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); @@ -1048,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); @@ -1150,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', { @@ -1305,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, @@ -1494,7 +1491,7 @@ describe('DragEventHandler', () => { endLine: 1, }), 'touch'); const lines = view.contentDOM.querySelectorAll('.cm-line'); - expect(lines[3]?.classList.contains('dnd-range-selected-line')).toBe(true); + 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); @@ -2178,4 +2175,116 @@ describe('DragEventHandler', () => { 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 2b0b8d4..4c997a3 100644 --- a/src/editor/interaction/DragEventHandler.ts +++ b/src/editor/interaction/DragEventHandler.ts @@ -1,6 +1,6 @@ import { EditorView } from '@codemirror/view'; import { EditorSelection } from '@codemirror/state'; -import { BlockInfo, BlockType, DragLifecycleEvent } from '../../types'; +import { BlockInfo, DragLifecycleEvent } from '../../types'; import { getHandleColumnCenterX, } from '../core/handle-position'; @@ -28,7 +28,7 @@ import { resolveTargetBoundaryForRangeSelection, resolveBlockBoundaryAtLine, } from './RangeSelectionLogic'; -import { SmartBlockSelector, SmartSelectionResult } from './SmartBlockSelector'; +import { SmartBlockSelector, SmartSelectionResult, EditorTextSelection } from './SmartBlockSelector'; const MOBILE_DRAG_LONG_PRESS_MS = 100; const MOBILE_DRAG_START_MOVE_THRESHOLD_PX = 8; @@ -40,6 +40,7 @@ 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; @@ -88,10 +89,15 @@ export class DragEventHandler { 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; @@ -108,6 +114,8 @@ export class DragEventHandler { // 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(); @@ -117,7 +125,7 @@ export class DragEventHandler { }); // Store snapshot for later use in startPointerDragFromHandle // We'll evaluate it when we know which block was clicked - (this as { _pendingSelectionSnapshot?: typeof snapshot })._pendingSelectionSnapshot = snapshot; + this.pendingSelectionSnapshot = snapshot; } } @@ -154,6 +162,7 @@ export class DragEventHandler { this.clearCommittedRangeSelection({ reason: 'pointerdown_outside_selection', pointerId: e.pointerId, + eventTimeStamp: e.timeStamp, }); } @@ -268,6 +277,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) @@ -280,14 +291,10 @@ export class DragEventHandler { // 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) { - // Use the pre-captured selection snapshot if available - const snapshot = (this as { _pendingSelectionSnapshot?: unknown })._pendingSelectionSnapshot; const smartResult = this.smartSelector.evaluate( blockInfo, - snapshot as import('./SmartBlockSelector').EditorTextSelection[] | undefined + selectionSnapshot ); - // Clear the snapshot after use - delete (this as { _pendingSelectionSnapshot?: unknown })._pendingSelectionSnapshot; if (smartResult.shouldUseSmartSelection && smartResult.blockInfo) { this.startRangeSelectWithPrecomputedRanges( @@ -300,9 +307,6 @@ export class DragEventHandler { } } - // Clear the snapshot if we didn't use it - delete (this as { _pendingSelectionSnapshot?: unknown })._pendingSelectionSnapshot; - if (e.pointerType === 'mouse') { if (e.button !== 0) return; if (!multiLineSelectionEnabled) { @@ -506,6 +510,7 @@ export class DragEventHandler { // 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, @@ -550,6 +555,7 @@ export class DragEventHandler { console.log('[Dragger Debug] After setting gesture to idle, committedRangeSelection:', !!this.committedRangeSelection); return; } + this.pendingSmartCommitEventTimeStamp = null; // For touch: use timeout to allow long-press gesture const timeoutId = window.setTimeout(() => { @@ -927,6 +933,10 @@ export class DragEventHandler { ) ? '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) { @@ -951,6 +961,7 @@ export class DragEventHandler { anchorHandle?: HTMLElement | null; reason?: string; pointerId?: number | null; + eventTimeStamp?: number | null; }): void { console.log('[Dragger Debug] clearCommittedRangeSelection called, reason:', options?.reason, 'hasSelection:', !!this.committedRangeSelection); if (!this.committedRangeSelection) return; @@ -972,12 +983,17 @@ export class DragEventHandler { && 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 ) { console.log('[Dragger Debug] clearCommittedRangeSelection skipped by pointerdown guard', { reason, pointerId: options.pointerId, committedPointerId: this.lastCommittedSelectionPointerId, + eventTimeStamp: options.eventTimeStamp, + committedEventTimeStamp: this.lastCommittedSelectionEventTimeStamp, ageMs: Date.now() - this.lastCommittedSelectionAtMs, }); return; @@ -1001,6 +1017,8 @@ export class DragEventHandler { 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, @@ -1016,6 +1034,7 @@ export class DragEventHandler { this.lastCommittedSelectionSource = 'other'; this.lastCommittedSelectionAtMs = 0; this.lastCommittedSelectionPointerId = null; + this.lastCommittedSelectionEventTimeStamp = null; this.rangeVisual.clear(); // Restore normal handle visibility if (this.deps.clearHiddenRangesForSelection) { @@ -1031,7 +1050,10 @@ export class DragEventHandler { } const ranges = cloneLineRanges(this.pendingSelectionRestore.ranges); - const sourceLineCount = this.countLinesInRanges(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; @@ -1050,15 +1072,19 @@ export class DragEventHandler { // Clamp to document bounds const docLines = this.view.state.doc.lines; const clampedStartLine = Math.max(1, Math.min(docLines, newStartLine)); - const clampedEndLine = Math.max( - clampedStartLine, - Math.min(docLines, clampedStartLine + sourceLineCount - 1) - ); + const restoredRanges = this.buildRestoredRangesFromSpans(clampedStartLine, rangeLineSpans, docLines); + const firstRestoredRange = restoredRanges[0]; + const lastRestoredRange = restoredRanges[restoredRanges.length - 1]; + if (!firstRestoredRange || !lastRestoredRange) { + this.pendingSelectionRestore = null; + return; + } console.log('[Dragger Debug] calculated new selection', { movedLinesBeforeTarget, - newStartLine: clampedStartLine, - newEndLine: clampedEndLine, + newStartLine: firstRestoredRange.startLineNumber, + newEndLine: lastRestoredRange.endLineNumber, + restoredRangeCount: restoredRanges.length, docLines, }); @@ -1068,43 +1094,32 @@ export class DragEventHandler { this.deps.clearHiddenRangesForSelection(); } - const mergedRanges: LineRange[] = [{ - startLineNumber: clampedStartLine, - endLineNumber: clampedEndLine, - }]; - - const startLine = this.view.state.doc.line(clampedStartLine); - const endLine = this.view.state.doc.line(clampedEndLine); - const newBlock: BlockInfo = { - type: BlockType.Paragraph, - startLine: clampedStartLine - 1, - endLine: clampedEndLine - 1, - from: startLine.from, - to: endLine.to, - indentLevel: 0, - content: '', - }; + const newBlock = this.buildRestoredSelectionBlock( + restoredRanges, + this.pendingSelectionRestore.sourceBlockTemplate + ); this.committedRangeSelection = { selectedBlock: newBlock, - ranges: mergedRanges, + ranges: restoredRanges, }; this.lastCommittedSelectionSource = 'other'; this.lastCommittedSelectionAtMs = Date.now(); this.lastCommittedSelectionPointerId = null; + this.lastCommittedSelectionEventTimeStamp = null; - console.log('[Dragger Debug] About to render selection, mergedRanges:', JSON.stringify(mergedRanges)); + console.log('[Dragger Debug] About to render selection, restoredRanges:', JSON.stringify(restoredRanges)); // 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(mergedRanges, { showLinks: false, highlightHandles: false }); + this.rangeVisual.render(restoredRanges, { showLinks: false, highlightHandles: false }); if (this.deps.setHiddenRangesForSelection) { - const newAnchorHandle = this.findHandleAtLine(clampedStartLine); + const newAnchorHandle = this.findHandleAtLine(firstRestoredRange.startLineNumber); console.log('[Dragger Debug] Setting hidden ranges after drop restore', { - anchorLine: clampedStartLine, + anchorLine: firstRestoredRange.startLineNumber, hasAnchorHandle: !!newAnchorHandle, }); - this.deps.setHiddenRangesForSelection(mergedRanges, newAnchorHandle); + this.deps.setHiddenRangesForSelection(restoredRanges, newAnchorHandle); } this.pendingSelectionRestore = null; @@ -1126,6 +1141,88 @@ export class DragEventHandler { 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; @@ -1199,6 +1296,8 @@ export class DragEventHandler { }, 0); this.pendingSelectionRestore = { ranges: mergedRanges, + rangeLineSpans: this.getRangeLineSpans(mergedRanges), + sourceBlockTemplate: cloneBlockInfo(sourceBlock), anchorHandle: this.findHandleAtLine(firstRange.startLineNumber), sourceStartLine: firstRange.startLineNumber, sourceLineCount, From d5246a0cac78e4548cdc517d97702aed9e133c96 Mon Sep 17 00:00:00 2001 From: lushuilu <2981999092@qq.com> Date: Fri, 13 Feb 2026 22:23:52 +0800 Subject: [PATCH 25/25] chore: remove debug logs from drag interaction flow --- src/editor/core/line-map.spec.ts | 2 - src/editor/core/perf-session.ts | 10 +-- .../core/services/EditorSelectionBridge.ts | 16 +--- src/editor/interaction/DragEventHandler.ts | 73 ------------------- src/editor/interaction/SmartBlockSelector.ts | 24 ------ .../visual/RangeSelectionVisualManager.ts | 28 ------- 6 files changed, 3 insertions(+), 150 deletions(-) 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/services/EditorSelectionBridge.ts b/src/editor/core/services/EditorSelectionBridge.ts index 3bebb01..1573345 100644 --- a/src/editor/core/services/EditorSelectionBridge.ts +++ b/src/editor/core/services/EditorSelectionBridge.ts @@ -49,10 +49,6 @@ export class EditorSelectionBridge { toLine, }); } - console.log('[Dragger Debug] EditorSelectionBridge.getTextSelections', { - count: selections.length, - selections, - }); return selections; } @@ -98,12 +94,7 @@ export class EditorSelectionBridge { endLineNumber: aligned.endLineNumber, }); } - const merged = mergeLineRanges(docLines, ranges); - console.log('[Dragger Debug] EditorSelectionBridge.resolveBlockAlignedSelection', { - inputSelections: selections, - blockRanges: merged, - }); - return merged; + return mergeLineRanges(docLines, ranges); } /** @@ -133,11 +124,6 @@ export class EditorSelectionBridge { const intersects = selections.some((selection) => ( safeEnd >= selection.fromLine && safeStart <= selection.toLine )); - console.log('[Dragger Debug] EditorSelectionBridge.getBlockAlignedRangeIfRangeIntersecting', { - blockRange: { startLineNumber: safeStart, endLineNumber: safeEnd }, - selections, - intersects, - }); if (!intersects) return null; // Return the block-aligned selection diff --git a/src/editor/interaction/DragEventHandler.ts b/src/editor/interaction/DragEventHandler.ts index 4c997a3..b3eb58f 100644 --- a/src/editor/interaction/DragEventHandler.ts +++ b/src/editor/interaction/DragEventHandler.ts @@ -120,9 +120,6 @@ export class DragEventHandler { if (multiLineSelectionEnabled && e.button === 0) { const snapshot = this.smartSelector.captureSelectionSnapshot(); if (snapshot.length > 0) { - console.log('[Dragger Debug] Captured selection snapshot at pointerdown:', { - selections: snapshot.map(s => ({ fromLine: s.fromLine, toLine: s.toLine })), - }); // Store snapshot for later use in startPointerDragFromHandle // We'll evaluate it when we know which block was clicked this.pendingSelectionSnapshot = snapshot; @@ -355,7 +352,6 @@ export class DragEventHandler { } refreshSelectionVisual(): void { - console.log('[Dragger Debug] refreshSelectionVisual called, committedRangeSelection:', !!this.committedRangeSelection); const multiLineSelectionEnabled = this.isMultiLineSelectionEnabled(); if (!multiLineSelectionEnabled) { this.clearCommittedRangeSelection({ reason: 'refresh_feature_disabled' }); @@ -548,11 +544,8 @@ export class DragEventHandler { } // Commit immediately for mouse - console.log('[Dragger Debug] About to commit selection for mouse'); this.commitRangeSelection(selectionState); - console.log('[Dragger Debug] After commit, committedRangeSelection:', !!this.committedRangeSelection); this.gesture = { phase: 'idle' }; - console.log('[Dragger Debug] After setting gesture to idle, committedRangeSelection:', !!this.committedRangeSelection); return; } this.pendingSmartCommitEventTimeStamp = null; @@ -963,7 +956,6 @@ export class DragEventHandler { pointerId?: number | null; eventTimeStamp?: number | null; }): void { - console.log('[Dragger Debug] clearCommittedRangeSelection called, reason:', options?.reason, 'hasSelection:', !!this.committedRangeSelection); if (!this.committedRangeSelection) return; const reason = options?.reason ?? 'unspecified'; @@ -972,10 +964,6 @@ export class DragEventHandler { && this.lastCommittedSelectionSource === 'smart_mouse' && Date.now() - this.lastCommittedSelectionAtMs <= SMART_SELECTION_REFRESH_CLEAR_GUARD_MS ) { - console.log('[Dragger Debug] clearCommittedRangeSelection skipped by guard', { - reason, - ageMs: Date.now() - this.lastCommittedSelectionAtMs, - }); return; } if ( @@ -988,27 +976,9 @@ export class DragEventHandler { && Math.abs(options.eventTimeStamp - this.lastCommittedSelectionEventTimeStamp) <= SMART_SELECTION_SAME_EVENT_TOLERANCE_MS && Date.now() - this.lastCommittedSelectionAtMs <= SMART_SELECTION_POINTERDOWN_CLEAR_GUARD_MS ) { - console.log('[Dragger Debug] clearCommittedRangeSelection skipped by pointerdown guard', { - reason, - pointerId: options.pointerId, - committedPointerId: this.lastCommittedSelectionPointerId, - eventTimeStamp: options.eventTimeStamp, - committedEventTimeStamp: this.lastCommittedSelectionEventTimeStamp, - ageMs: Date.now() - this.lastCommittedSelectionAtMs, - }); return; } - console.log( - `[Dragger Debug] clearCommittedRangeSelection reason=${reason} preserveForDrag=${String(!!options?.preserveForDrag)}` - ); - console.log('[Dragger Debug] clearCommittedRangeSelection', { - preserveForDrag: options?.preserveForDrag, - currentRanges: JSON.stringify(this.committedRangeSelection.ranges), - anchorHandle: options?.anchorHandle, - reason, - }); - // If preserveForDrag is true, save the selection info for restoration after drag if (options?.preserveForDrag) { const ranges = cloneLineRanges(this.committedRangeSelection.ranges); @@ -1023,10 +993,6 @@ export class DragEventHandler { sourceStartLine: firstRange.startLineNumber, sourceLineCount, }; - console.log('[Dragger Debug] Saved pendingSelectionRestore:', { - ...this.pendingSelectionRestore, - ranges: JSON.stringify(this.pendingSelectionRestore.ranges), - }); } } @@ -1059,11 +1025,6 @@ export class DragEventHandler { return; } - console.log('[Dragger Debug] restoreSelectionAfterDrop', { - targetStartLine, - sourceLineCount, - }); - // `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); @@ -1080,14 +1041,6 @@ export class DragEventHandler { return; } - console.log('[Dragger Debug] calculated new selection', { - movedLinesBeforeTarget, - newStartLine: firstRestoredRange.startLineNumber, - newEndLine: lastRestoredRange.endLineNumber, - restoredRangeCount: restoredRanges.length, - docLines, - }); - // Clear any existing selection first this.rangeVisual.clear(); if (this.deps.clearHiddenRangesForSelection) { @@ -1108,17 +1061,11 @@ export class DragEventHandler { this.lastCommittedSelectionPointerId = null; this.lastCommittedSelectionEventTimeStamp = null; - console.log('[Dragger Debug] About to render selection, restoredRanges:', JSON.stringify(restoredRanges)); - // 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); - console.log('[Dragger Debug] Setting hidden ranges after drop restore', { - anchorLine: firstRestoredRange.startLineNumber, - hasAnchorHandle: !!newAnchorHandle, - }); this.deps.setHiddenRangesForSelection(restoredRanges, newAnchorHandle); } @@ -1228,24 +1175,20 @@ export class DragEventHandler { if (!this.isMultiLineSelectionEnabled()) return baseBlockInfo; if (this.committedRangeSelection && this.isBlockInsideCommittedSelection(baseBlockInfo)) { - console.log('[Dragger Debug] resolveNativeDragSourceForHandleDrag: using committedRangeSelection'); return cloneBlockInfo(this.committedRangeSelection.selectedBlock); } if (this.gesture.phase === 'range_selecting') { const state = this.gesture.rangeSelect; if (state.pointerType === 'mouse') { - console.log('[Dragger Debug] resolveNativeDragSourceForHandleDrag: using in-flight range_selecting source'); return cloneBlockInfo(state.selectedBlock); } } const smartResult = this.smartSelector.evaluate(baseBlockInfo); if (smartResult.shouldUseSmartSelection && smartResult.blockInfo) { - console.log('[Dragger Debug] resolveNativeDragSourceForHandleDrag: using smart selection source'); return smartResult.blockInfo; } - console.log('[Dragger Debug] resolveNativeDragSourceForHandleDrag: fallback to base block'); return baseBlockInfo; } @@ -1317,11 +1260,6 @@ export class DragEventHandler { } private refreshRangeSelectionVisual(): void { - console.log('[Dragger Debug] refreshRangeSelectionVisual called', { - phase: this.gesture.phase, - hasCommittedSelection: !!this.committedRangeSelection, - committedRanges: this.committedRangeSelection ? JSON.stringify(this.committedRangeSelection.ranges) : null, - }); if (this.gesture.phase === 'range_selecting') { const state = this.gesture.rangeSelect; this.rangeVisual.render(state.selectionRanges, { showLinks: false, highlightHandles: state.highlightHandles }); @@ -1357,13 +1295,6 @@ export class DragEventHandler { const isLink = target.closest(`.${RANGE_SELECTION_LINK_CLASS}`); const isSelectedHandle = target.closest(`.${RANGE_SELECTED_HANDLE_CLASS}`); const isHandle = target.closest(`.${DRAG_HANDLE_CLASS}`); - console.log('[Dragger Debug] shouldClearCommittedSelectionOnPointerDown', { - targetClass: target.className, - isLink: !!isLink, - isSelectedHandle: !!isSelectedHandle, - isHandle: !!isHandle, - clientX, - }); if (isLink) return false; if (isSelectedHandle) return false; if (isHandle) return false; @@ -1378,7 +1309,6 @@ export class DragEventHandler { } const centerX = getHandleColumnCenterX(this.view); const shouldClear = clientX > centerX + RANGE_SELECTION_GRIP_HIT_X_PADDING_PX; - console.log('[Dragger Debug] shouldClearCommittedSelectionOnPointerDown result:', shouldClear, 'centerX:', centerX, 'threshold:', centerX + RANGE_SELECTION_GRIP_HIT_X_PADDING_PX); return shouldClear; } @@ -1440,7 +1370,6 @@ export class DragEventHandler { let targetLineNumber: number | null = null; if (shouldDrop) { targetLineNumber = this.deps.performDropAtPoint(state.sourceBlock, e.clientX, e.clientY, e.pointerType || null); - console.log('[Dragger Debug] finishPointerDrag - targetLineNumber:', targetLineNumber, 'sourceBlock:', state.sourceBlock); } this.abortPointerSession({ shouldFinishDragSession: true, @@ -1450,7 +1379,6 @@ export class DragEventHandler { }); // Restore selection after drop if we had a pending restore and drop succeeded if (shouldDrop && typeof targetLineNumber === 'number' && this.pendingSelectionRestore) { - console.log('[Dragger Debug] Will restore selection, pendingSelectionRestore:', this.pendingSelectionRestore); // Use setTimeout to ensure the document update has been processed setTimeout(() => { this.restoreSelectionAfterDrop(targetLineNumber!); @@ -1461,7 +1389,6 @@ export class DragEventHandler { } private handlePointerUp(e: PointerEvent): void { - console.log('[Dragger Debug] handlePointerUp called, gesture.phase:', this.gesture.phase, 'committedRangeSelection:', !!this.committedRangeSelection); if (this.gesture.phase === 'dragging') { this.finishPointerDrag(e, true); return; diff --git a/src/editor/interaction/SmartBlockSelector.ts b/src/editor/interaction/SmartBlockSelector.ts index 8e0f479..debd0f4 100644 --- a/src/editor/interaction/SmartBlockSelector.ts +++ b/src/editor/interaction/SmartBlockSelector.ts @@ -59,15 +59,6 @@ export class SmartBlockSelector { clickedEndLine ); - console.log('[Dragger Debug] SmartBlockSelector.evaluate', { - clickedBlock: { - startLine: clickedBlock.startLine, - endLine: clickedBlock.endLine, - }, - alignedRanges, - usedSnapshot: !!selectionSnapshot, - }); - if (!alignedRanges) { return { shouldUseSmartSelection: false, @@ -86,15 +77,6 @@ export class SmartBlockSelector { mergedRanges, clickedBlock ); - console.log('[Dragger Debug] SmartBlockSelector.result', { - mergedRanges, - sourceBlock: { - startLine: blockInfo.startLine, - endLine: blockInfo.endLine, - hasComposite: !!blockInfo.compositeSelection, - }, - }); - return { shouldUseSmartSelection: true, ranges: mergedRanges, @@ -120,12 +102,6 @@ export class SmartBlockSelector { safeEnd >= selection.fromLine && safeStart <= selection.toLine )); - console.log('[Dragger Debug] SmartBlockSelector.getFromSnapshot', { - snapshot: snapshot.map(s => ({ fromLine: s.fromLine, toLine: s.toLine })), - clickedRange: { start: safeStart, end: safeEnd }, - intersects, - }); - if (!intersects) return null; // Resolve block-aligned selection from snapshot diff --git a/src/editor/visual/RangeSelectionVisualManager.ts b/src/editor/visual/RangeSelectionVisualManager.ts index 9fce444..0363917 100644 --- a/src/editor/visual/RangeSelectionVisualManager.ts +++ b/src/editor/visual/RangeSelectionVisualManager.ts @@ -41,11 +41,6 @@ export class RangeSelectionVisualManager { render(ranges: LineRange[], options?: { showLinks?: boolean; highlightHandles?: boolean }): void { const showLinks = options?.showLinks ?? true; const highlightHandles = options?.highlightHandles ?? true; - console.log('[Dragger Debug] RangeSelectionVisualManager.render', { - ranges: JSON.stringify(ranges), - showLinks, - highlightHandles, - }); const normalizedRanges = this.mergeLineRanges(ranges); const nextLineElements = new Set(); const nextLineNumberElements = new Set(); @@ -64,9 +59,6 @@ export class RangeSelectionVisualManager { const lineEl = this.getLineElementForLine(lineNumber); if (lineEl) { nextLineElements.add(lineEl); - console.log('[Dragger Debug] Found line element for line', lineNumber, ':', lineEl.className); - } else { - console.log('[Dragger Debug] No line element found for line', lineNumber); } const lineNumberEl = getLineNumberElementForLine(this.view, lineNumber); if (lineNumberEl) { @@ -82,7 +74,6 @@ export class RangeSelectionVisualManager { pos = line.to + 1; } } - console.log('[Dragger Debug] Matched lines:', matchedLines, 'lineElements count:', nextLineElements.size); if (matchedLines.length > 0 && nextLineElements.size === 0) { this.scheduleLineResolutionRetry(); } else { @@ -93,10 +84,6 @@ export class RangeSelectionVisualManager { nextLineElements, RANGE_SELECTED_LINE_CLASS ); - // Verify after sync - console.log('[Dragger Debug] After sync, checking DOM for class:', RANGE_SELECTED_LINE_CLASS); - const checkEls = this.view.dom.querySelectorAll('.' + RANGE_SELECTED_LINE_CLASS); - console.log('[Dragger Debug] DOM elements with class:', checkEls.length); this.syncSelectionElements( this.lineNumberElements, nextLineNumberElements, @@ -115,10 +102,6 @@ export class RangeSelectionVisualManager { } clear(): void { - // Add stack trace to find who is calling clear - console.log('[Dragger Debug] RangeSelectionVisualManager.clear called, lineElements count:', this.lineElements.size); - console.log('[Dragger Debug] clear() stack trace:', new Error().stack); - // Clear tracked elements for (const lineEl of this.lineElements) { lineEl.classList.remove(RANGE_SELECTED_LINE_CLASS); @@ -143,8 +126,6 @@ export class RangeSelectionVisualManager { const remainingHandleElements = this.view.dom.querySelectorAll(`.${RANGE_SELECTED_HANDLE_CLASS}`); remainingHandleElements.forEach(el => el.classList.remove(RANGE_SELECTED_HANDLE_CLASS)); - console.log('[Dragger Debug] Cleared remaining elements - lines:', remainingLineElements.length, 'handles:', remainingHandleElements.length); - this.hideLinks(); this.clearLineResolutionRetry(); } @@ -271,12 +252,6 @@ export class RangeSelectionVisualManager { next: Set, className: string ): void { - console.log('[Dragger Debug] syncSelectionElements', { - className, - currentSize: current.size, - nextSize: next.size, - }); - // 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) { @@ -286,15 +261,12 @@ export class RangeSelectionVisualManager { } // Add class to all elements in next set (they should all have the class) - let addedCount = 0; for (const el of next) { if (!el.isConnected) continue; if (!el.classList.contains(className)) { el.classList.add(className); - addedCount++; } } - console.log('[Dragger Debug] syncSelectionElements added', addedCount, 'classes'); current.clear(); for (const el of next) {