Skip to content

Commit b901fb2

Browse files
j4rviscmdCopilot
andauthored
fix: prevent scroll jump after block drag-and-drop (#94)
Disable Tauri's native drag-drop handler via WebviewWindowBuilder::disable_drag_drop_handler() so HTML5 D&D events reach the BlockNote editor unintercepted. Add a DOM-level scroll guard in the cursor-centering ProseMirror plugin that captures scrollTop at drag-start and aggressively restores it after drop, preventing the browser from jumping to the old cursor position when blocks are moved across distant parts of the document. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 3e90e0f commit b901fb2

File tree

3 files changed

+80
-3
lines changed

3 files changed

+80
-3
lines changed

src-tauri/src/window.rs

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,9 +31,13 @@ const RESTORE_ENABLED_KEY: &str = "windowStateRestoreEnabled";
3131
#[derive(Debug, Clone, Serialize, Deserialize)]
3232
#[serde(rename_all = "camelCase")]
3333
struct WindowGeometry {
34+
/// Logical x-coordinate of the window's top-left corner.
3435
x: f64,
36+
/// Logical y-coordinate of the window's top-left corner.
3537
y: f64,
38+
/// Logical width of the window's content area.
3639
width: f64,
40+
/// Logical height of the window's content area.
3741
height: f64,
3842
}
3943

@@ -48,14 +52,17 @@ struct WindowGeometry {
4852
/// The window is created with `resizable(false)` and
4953
/// `maximizable(false)` so the splash screen cannot be resized.
5054
/// The frontend unlocks these constraints once the splash has faded
51-
/// out.
55+
/// out. The built-in Tauri drag-and-drop handler is disabled via
56+
/// `disable_drag_drop_handler()` so the frontend can manage file
57+
/// drop events with its own logic.
5258
pub fn create_main_window(app: &AppHandle) -> Result<(), Box<dyn std::error::Error>> {
5359
let geometry = read_saved_geometry(app);
5460

5561
let mut builder = WebviewWindowBuilder::new(app, "main", WebviewUrl::default())
5662
.title(WINDOW_TITLE)
5763
.resizable(false)
58-
.maximizable(false);
64+
.maximizable(false)
65+
.disable_drag_drop_handler();
5966

6067
match geometry {
6168
Some(geo) => {

src/features/editor/lib/cursorCentering.ts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,12 @@ let docChangedInLastTr = false
9696
* the document actually changed (i.e. the user typed something).
9797
* - The scroll offset is clamped to `[0, maxScroll]` so that short
9898
* documents keep the cursor near the top (no centre-jump).
99+
*
100+
* **Drag-and-drop scroll fix:**
101+
* Additionally, a DOM-level drag/drop listener captures the scroll
102+
* container's `scrollTop` at drag-start and aggressively restores
103+
* it after drop to prevent the browser from jumping to the old
104+
* cursor position.
99105
*/
100106
export const cursorCenteringExtension = createExtension({
101107
key: 'cursorCentering',
@@ -110,6 +116,65 @@ export const cursorCenteringExtension = createExtension({
110116
return {}
111117
},
112118
},
119+
view(editorView) {
120+
const container = findScrollContainer(editorView.dom)
121+
let savedScrollTop = 0
122+
let guardScrollUntil = 0
123+
124+
const onDragStart = () => {
125+
if (container) {
126+
savedScrollTop = container.scrollTop
127+
}
128+
}
129+
130+
const onDrop = () => {
131+
if (!container) return
132+
// Do NOT update savedScrollTop here — use the value from
133+
// dragstart, which captures the scroll position BEFORE
134+
// auto-scroll or selection-triggered scroll during drag.
135+
136+
// Guard against scroll changes for the next 500ms.
137+
guardScrollUntil = Date.now() + 500
138+
139+
const restore = () => {
140+
if (Date.now() < guardScrollUntil) {
141+
container.scrollTop = savedScrollTop
142+
}
143+
}
144+
requestAnimationFrame(restore)
145+
setTimeout(restore, 0)
146+
setTimeout(restore, 16)
147+
setTimeout(restore, 50)
148+
setTimeout(restore, 100)
149+
setTimeout(restore, 200)
150+
setTimeout(restore, 300)
151+
setTimeout(restore, 400)
152+
}
153+
154+
// Intercept scroll events on the container during the guard window
155+
const onScroll = () => {
156+
if (
157+
container &&
158+
Date.now() < guardScrollUntil &&
159+
container.scrollTop !== savedScrollTop
160+
) {
161+
container.scrollTop = savedScrollTop
162+
}
163+
}
164+
165+
// Use capture phase to intercept before any other handlers
166+
document.addEventListener('dragstart', onDragStart, true)
167+
editorView.dom.addEventListener('drop', onDrop, true)
168+
container?.addEventListener('scroll', onScroll, { passive: false })
169+
170+
return {
171+
destroy() {
172+
document.removeEventListener('dragstart', onDragStart, true)
173+
editorView.dom.removeEventListener('drop', onDrop, true)
174+
container?.removeEventListener('scroll', onScroll)
175+
},
176+
}
177+
},
113178
props: {
114179
scrollMargin: {
115180
top: 100,

src/features/editor/lib/cursorCenteringConfig.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
/** Default values used when no persisted setting exists. */
22
export const DEFAULT_CURSOR_CENTERING = {
3+
/** Whether cursor centering is active by default. */
34
enabled: true,
5+
/** Vertical position ratio (0 = top, 1 = bottom) at which the cursor is kept during typing. */
46
targetRatio: 0.6,
57
}
68

@@ -15,7 +17,10 @@ export const DEFAULT_CURSOR_CENTERING = {
1517
* Updated via the {@link useCursorCentering} hook, which also
1618
* persists the values to `configStore`.
1719
*/
18-
export const cursorCenteringConfig = { ...DEFAULT_CURSOR_CENTERING }
20+
export const cursorCenteringConfig: {
21+
enabled: boolean
22+
targetRatio: number
23+
} = { ...DEFAULT_CURSOR_CENTERING }
1924

2025
/** Store key names for persistence via `configStore`. */
2126
export const CURSOR_CENTERING_STORE_KEYS = {

0 commit comments

Comments
 (0)