Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 81 additions & 0 deletions e2e/drag-scroll.spec.ts
Original file line number Diff line number Diff line change
@@ -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<string[]> {
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<void> {
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);
});
38 changes: 38 additions & 0 deletions src/lib/editor/drag-scroll.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
111 changes: 111 additions & 0 deletions src/lib/editor/drag-scroll.ts
Original file line number Diff line number Diff line change
@@ -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();
},
};
},
}),
];
},
});
2 changes: 2 additions & 0 deletions src/lib/editor/extensions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -158,6 +159,7 @@ export function createExtensions(options: EditorSchemaOptions = {}): Extensions
DateMentionCommand,
DragHandle.configure({ ...dragHandle, nested: NESTED }),
DragHitArea,
DragScroll,
TodoRollup,
);
}
Expand Down