From 56c885e3abd77af1eeafd617b8b0099ec6066447 Mon Sep 17 00:00:00 2001 From: Aavash Date: Sun, 11 Oct 2026 07:16:24 +0200 Subject: [PATCH] chore: scroll document on block drags --- e2e/drag-scroll.spec.ts | 81 +++++++++++++++++++++ src/lib/editor/drag-scroll.test.ts | 38 ++++++++++ src/lib/editor/drag-scroll.ts | 111 +++++++++++++++++++++++++++++ src/lib/editor/extensions.ts | 2 + 4 files changed, 232 insertions(+) create mode 100644 e2e/drag-scroll.spec.ts create mode 100644 src/lib/editor/drag-scroll.test.ts create mode 100644 src/lib/editor/drag-scroll.ts diff --git a/e2e/drag-scroll.spec.ts b/e2e/drag-scroll.spec.ts new file mode 100644 index 0000000..87ab274 --- /dev/null +++ b/e2e/drag-scroll.spec.ts @@ -0,0 +1,81 @@ +import { test, expect, type Page } from "@playwright/test"; +import { booted } from "./app"; +import { livePages, putPages } from "./db"; + +/** A drag held at the top or bottom of the page scrolls it, so the drop can go out of view. */ + +const PAGE = "d-scroll"; +const LINES = Array.from({ length: 80 }, (_, i) => `line ${i + 1}`); + +const scrollTop = (page: Page) => page.locator(".content").evaluate((el) => el.scrollTop); + +async function body(page: Page): Promise { + const file = (await livePages(page)).find((p) => p.id === PAGE)?.file ?? ""; + return file + .replace(/^---\n[\s\S]*?\n---\n*/, "") + .split("\n") + .filter((line) => line.trim()); +} + +/** Pick up the block reading `text` by its handle, and leave it held. */ +async function pickUp(page: Page, text: string): Promise { + const block = page.locator(".ProseMirror > p", { hasText: new RegExp(`^${text}$`) }); + const box = (await block.boundingBox())!; + await page.mouse.move(box.x + 4, box.y + box.height / 2); + const handle = page.locator(".drag-handle"); + await expect(handle).toBeVisible(); + await page.waitForTimeout(120); + const grip = (await handle.boundingBox())!; + await page.mouse.move(grip.x + grip.width / 2, grip.y + grip.height / 2); + await page.mouse.down(); +} + +test.beforeEach(async ({ page }) => { + await page.goto("/"); + await booted(page); + await putPages(page, [{ id: PAGE, title: "Scroll", body: LINES.join("\n\n") }]); + await page.goto("/"); + await expect(page.locator(".ProseMirror > p").first()).toBeVisible(); +}); + +test("a block held at the bottom edge scrolls the page down, and drops there", async ({ + page, +}) => { + const area = (await page.locator(".content").boundingBox())!; + const x = area.x + area.width / 2; + expect(await scrollTop(page)).toBe(0); + + await pickUp(page, "line 2"); + await page.mouse.move(x, area.y + area.height - 8, { steps: 12 }); + await expect.poll(() => scrollTop(page)).toBeGreaterThan(600); + + // Back into the middle: it stops, and the drop lands on what scrolled into view. + await page.mouse.move(x, area.y + area.height / 2, { steps: 4 }); + await page.waitForTimeout(100); + const settled = await scrollTop(page); + await page.waitForTimeout(200); + expect(await scrollTop(page)).toBe(settled); + + await page.mouse.up(); + await expect.poll(async () => (await body(page)).indexOf("line 2")).toBeGreaterThan(15); + expect(await body(page)).toHaveLength(LINES.length); +}); + +test("a block held at the top edge scrolls the page back up", async ({ page }) => { + const content = page.locator(".content"); + await content.evaluate((el) => (el.scrollTop = el.scrollHeight)); + const start = await scrollTop(page); + expect(start).toBeGreaterThan(600); + + const area = (await content.boundingBox())!; + await pickUp(page, "line 78"); + await page.mouse.move(area.x + area.width / 2, area.y + 8, { steps: 12 }); + await expect.poll(() => scrollTop(page)).toBeLessThan(start - 600); + + await page.mouse.up(); + // It stops with the drop. + await page.waitForTimeout(100); + const settled = await scrollTop(page); + await page.waitForTimeout(200); + expect(await scrollTop(page)).toBe(settled); +}); diff --git a/src/lib/editor/drag-scroll.test.ts b/src/lib/editor/drag-scroll.test.ts new file mode 100644 index 0000000..75a12ba --- /dev/null +++ b/src/lib/editor/drag-scroll.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from "vitest"; +import { scrollSpeed } from "./drag-scroll"; + +/** An area 800 tall: the band is the top and bottom 72 of it. */ +const TOP = 100; +const BOTTOM = 900; + +describe("scrollSpeed", () => { + it("is still in the middle of the area", () => { + expect(scrollSpeed(500, TOP, BOTTOM)).toBe(0); + expect(scrollSpeed(TOP + 72, TOP, BOTTOM)).toBe(0); + expect(scrollSpeed(BOTTOM - 72, TOP, BOTTOM)).toBe(0); + }); + + it("goes up near the top and down near the bottom", () => { + expect(scrollSpeed(TOP + 20, TOP, BOTTOM)).toBeLessThan(0); + expect(scrollSpeed(BOTTOM - 20, TOP, BOTTOM)).toBeGreaterThan(0); + }); + + it("goes faster the further into the band the pointer is", () => { + const shallow = scrollSpeed(BOTTOM - 60, TOP, BOTTOM); + const deep = scrollSpeed(BOTTOM - 10, TOP, BOTTOM); + expect(deep).toBeGreaterThan(shallow); + expect(scrollSpeed(TOP + 10, TOP, BOTTOM)).toBe(-deep); + }); + + it("tops out at the edge and stays there past it", () => { + const edge = scrollSpeed(BOTTOM, TOP, BOTTOM); + expect(scrollSpeed(BOTTOM + 300, TOP, BOTTOM)).toBe(edge); + expect(scrollSpeed(TOP - 300, TOP, BOTTOM)).toBe(-edge); + }); + + it("leaves a still middle in an area too short for two full bands", () => { + expect(scrollSpeed(60, 0, 120)).toBe(0); + expect(scrollSpeed(5, 0, 120)).toBeLessThan(0); + expect(scrollSpeed(115, 0, 120)).toBeGreaterThan(0); + }); +}); diff --git a/src/lib/editor/drag-scroll.ts b/src/lib/editor/drag-scroll.ts new file mode 100644 index 0000000..fd9f0ff --- /dev/null +++ b/src/lib/editor/drag-scroll.ts @@ -0,0 +1,111 @@ +import { Extension } from "@tiptap/core"; +import { Plugin, PluginKey } from "@tiptap/pm/state"; + +/** How close to the top or bottom of the scrolling area a drag starts to scroll it. */ +const EDGE = 72; + +/** Pixels a second: at the start of the edge band, and at its end or past it. */ +const SPEED = { min: 120, max: 1400 }; + +/** The longest step a single frame may take, so a stalled tab does not jump on waking. */ +const MAX_FRAME_MS = 50; + +/** + * How fast to scroll for a pointer at `y` in an area running from `top` to `bottom`: negative is + * up, zero is not at all. Quadratic, so the first few pixels of the band are easy to hold. + */ +export function scrollSpeed(y: number, top: number, bottom: number): number { + const edge = Math.min(EDGE, (bottom - top) / 3); + if (edge <= 0) return 0; + + const up = top + edge - y; + const down = y - (bottom - edge); + const depth = Math.max(up, down); + if (depth <= 0) return 0; + + const ratio = Math.min(1, depth / edge); + const speed = SPEED.min + (SPEED.max - SPEED.min) * ratio * ratio; + return up > down ? -speed : speed; +} + +function scrollParentOf(el: HTMLElement): HTMLElement | null { + for (let node = el.parentElement; node; node = node.parentElement) { + const overflow = getComputedStyle(node).overflowY; + if (overflow === "auto" || overflow === "scroll") return node; + } + return null; +} + +/** + * Scrolls the page while a drag is held near its top or bottom edge, so a block can be dropped + * somewhere that was out of view when it was picked up. The webview does not do this on its own. + */ +export const DragScroll = Extension.create({ + name: "dragScroll", + addProseMirrorPlugins() { + return [ + new Plugin({ + key: new PluginKey("dragScroll"), + view(view) { + /** Where the drag last was. It only reports while it moves, so this is held between. */ + let at: { x: number; y: number } | null = null; + let frame = 0; + let last = 0; + /** The fraction of a pixel a slow frame leaves over; `scrollTop` takes whole ones. */ + let carry = 0; + + const stop = (): void => { + at = null; + carry = 0; + if (frame) cancelAnimationFrame(frame); + frame = 0; + }; + + const tick = (now: number): void => { + frame = 0; + const parent = scrollParentOf(view.dom); + if (!at || !parent) return; + + const rect = parent.getBoundingClientRect(); + // Only for a drag over the page's own column, not one passing down the sidebar. + const over = at.x >= rect.left && at.x <= rect.right; + const speed = over ? scrollSpeed(at.y, rect.top, rect.bottom) : 0; + if (speed === 0) { + carry = 0; + return; + } + + const elapsed = Math.min(now - last, MAX_FRAME_MS); + last = now; + const move = (speed * elapsed) / 1000 + carry; + const whole = Math.trunc(move); + carry = move - whole; + parent.scrollTop += whole; + + frame = requestAnimationFrame(tick); + }; + + const onDragOver = (event: DragEvent): void => { + at = { x: event.clientX, y: event.clientY }; + if (frame) return; + last = performance.now(); + frame = requestAnimationFrame(tick); + }; + + document.addEventListener("dragover", onDragOver, true); + document.addEventListener("dragend", stop, true); + document.addEventListener("drop", stop, true); + + return { + destroy() { + document.removeEventListener("dragover", onDragOver, true); + document.removeEventListener("dragend", stop, true); + document.removeEventListener("drop", stop, true); + stop(); + }, + }; + }, + }), + ]; + }, +}); diff --git a/src/lib/editor/extensions.ts b/src/lib/editor/extensions.ts index 490dac1..66caa03 100644 --- a/src/lib/editor/extensions.ts +++ b/src/lib/editor/extensions.ts @@ -29,6 +29,7 @@ import { ListMixing } from "./list-mixing"; import { CodeBlockHighlight, CodeBlockMarkdown } from "./code-block"; import { EditorTab, LeadingBackspace } from "./keymaps"; import { DragHitArea } from "./drag-hit-area"; +import { DragScroll } from "./drag-scroll"; import { TodoRollup } from "./todo-rollup"; import { lowlight } from "./lowlight"; import { createDragHandle, NESTED, type DragHandleController } from "./drag-handle"; @@ -158,6 +159,7 @@ export function createExtensions(options: EditorSchemaOptions = {}): Extensions DateMentionCommand, DragHandle.configure({ ...dragHandle, nested: NESTED }), DragHitArea, + DragScroll, TodoRollup, ); }