Make the phone work: touch reordering, reachable menus, and nothing lost offline
Four things that were built for a mouse on a desk and quietly did nothing useful on a phone. The last one is the serious one. **A note made offline lost everything written into it.** Its create reached the server, the server named it something else, and every edit still queued behind it went on addressing the name only that device ever knew — 404, five retries, thrown away. The note arrived empty and untitled, with nothing to say that anything had been lost. The database was repointed at the new id all along; the queue was not. It is now, by replacing the id across the whole queued payload rather than in a list of named fields, because a list of fields is a list to forget to add to. The same held for a notebook made offline and everything filed into it. **The order of notes could not be changed on a phone**, because the list went through the HTML5 drag API, which mobile browsers never fire from touch. Pointer events instead, which mouse, pen and finger all produce. A mouse still drags a row from anywhere; a finger drags from a grip, because pressing the row has to go on scrolling and holding it has to go on opening the menu. The row under the finger is found with elementFromPoint — a finger raises no pointerover on anything it did not press. **"Move to..." opened off the side of the screen**, and could not be opened at all without a hover. It is a tap now, unfolding in place where there is no room beside the menu and flipping sides where there is. The menu itself is placed by measuring rather than by assuming it is 220px wide, its rows are tall enough to hit with a thumb, and Escape closes it. **Installing said nothing when it could not be done.** The entry appeared only when the browser had offered a prompt, so where installing was impossible there was no button and no reason. It is always there now and names the cause — most usefully that a browser will only install a site served over HTTPS, which is also why the service worker would not have registered, so one cause was showing up as two mysteries. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
6eecf57247
commit
608da16ac2
12 files changed
+743
-147
No files matched your search
@@ -131,6 +131,38 @@ try {
|
||||
const status = await p.locator("body").innerText();
|
||||
check(/offline|Pending|Wartet|Änderungen/i.test(status), "the app says it is offline rather than pretending");
|
||||
|
||||
/* ── the rest of what a note is, with no connection ──
|
||||
* Reading and editing were covered. Making a note and drawing on one were
|
||||
* not, and those are half of what the app is for on a phone — which is
|
||||
* exactly where the connection is missing. */
|
||||
const beforeNew = (await p.locator("[data-note-row]").count());
|
||||
await createNote(p);
|
||||
await p.waitForTimeout(1200);
|
||||
await setTitle(p, "Offline entstanden");
|
||||
await typeInEditor(p, "Ohne Netz geschrieben.");
|
||||
await p.waitForTimeout(1500);
|
||||
check(
|
||||
(await p.locator("[data-note-row]").count()) > beforeNew,
|
||||
"a note can be made with no connection",
|
||||
`${beforeNew} → ${await p.locator("[data-note-row]").count()}`
|
||||
);
|
||||
|
||||
await p.locator("button").filter({ hasText: "Draw" }).first().click();
|
||||
await p.waitForTimeout(1200);
|
||||
const canvas = await p.locator('canvas[data-draw-page="1"]').boundingBox();
|
||||
await p.mouse.move(canvas.x + canvas.width * 0.3, canvas.y + canvas.height * 0.3);
|
||||
await p.mouse.down();
|
||||
await p.mouse.move(canvas.x + canvas.width * 0.7, canvas.y + canvas.height * 0.45, { steps: 12 });
|
||||
await p.mouse.up();
|
||||
await p.waitForTimeout(2500);
|
||||
const inkOffline = await p.locator('canvas[data-draw-page="1"]').evaluate((c) => {
|
||||
const d = c.getContext("2d").getImageData(0, 0, c.width, c.height).data;
|
||||
let n = 0;
|
||||
for (let i = 3; i < d.length; i += 4) if (d[i] > 40) n++;
|
||||
return n;
|
||||
});
|
||||
check(inkOffline > 100, "and drawn on, with no connection", `${inkOffline} inked pixels`);
|
||||
|
||||
/* ── back on ── */
|
||||
await context.setOffline(false);
|
||||
await p.waitForTimeout(1000);
|
||||
@@ -145,6 +177,22 @@ try {
|
||||
"and the edit made offline reaches the server once there is a connection",
|
||||
String(stored.body.content).slice(0, 80)
|
||||
);
|
||||
|
||||
const made = server.find((n) => n.title === "Offline entstanden");
|
||||
check(!!made, "the note made offline reaches the server too", made ? made.id : "not there");
|
||||
if (made) {
|
||||
const madeBody = await api(p, "GET", `/notes/${made.id}`);
|
||||
check(
|
||||
String(madeBody.body.content).includes("Ohne Netz"),
|
||||
"with what was written in it",
|
||||
String(madeBody.body.content).slice(0, 80)
|
||||
);
|
||||
check(
|
||||
(madeBody.body.drawingStrokes?.strokes?.length ?? 0) > 0,
|
||||
"and with the drawing that was made on it",
|
||||
JSON.stringify(Object.keys(madeBody.body.drawingStrokes || {}))
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
check(false, "suite ran to the end", e.message);
|
||||
} finally {
|
||||
|
||||
+119
-3
@@ -119,16 +119,18 @@ try {
|
||||
"and the button that used to open it is gone"
|
||||
);
|
||||
|
||||
/* The point of the whole feature. */
|
||||
/* The point of the whole feature. Measured against the arrangement as it
|
||||
stands now: the rows have been moved about since it was first made. */
|
||||
const arrangedNow = await titles();
|
||||
await search(p, "Beta");
|
||||
const searched = await titles();
|
||||
check(searched.length < 3, "searching narrows the list", searched.join(", "));
|
||||
|
||||
await search(p, "");
|
||||
check(
|
||||
(await titles()).join(",") === arranged.join(","),
|
||||
(await titles()).join(",") === arrangedNow.join(","),
|
||||
"and clearing the search shows exactly the arrangement again",
|
||||
`${arranged.join(",")} vs ${(await titles()).join(",")}`
|
||||
`${arrangedNow.join(",")} vs ${(await titles()).join(",")}`
|
||||
);
|
||||
|
||||
/* Reordering must not look like editing: a dragged note keeps its place in
|
||||
@@ -401,6 +403,120 @@ try {
|
||||
);
|
||||
await p.getByRole("button", { name: "Cancel" }).click();
|
||||
await p.waitForTimeout(400);
|
||||
/* ── moving a row with a finger ──
|
||||
* Last in the suite, because moving rows about would pull the ground from
|
||||
* under every check above that names an order.
|
||||
*
|
||||
* The list used the HTML5 drag API, which mobile browsers never fire from
|
||||
* touch: on a phone the arrangement could not be changed at all, and nothing
|
||||
* on screen said why. A finger drags from the grip — pressing the row itself
|
||||
* has to go on scrolling, and holding it has to go on opening the menu.
|
||||
*
|
||||
* The pointer events below are synthesised, because Playwright drives a mouse.
|
||||
* They still exercise the branch that was missing: pointerType "touch".
|
||||
*/
|
||||
const touchDrag = async (fromIndex, toIndex) =>
|
||||
p.evaluate(
|
||||
([from, toIdx]) => {
|
||||
const grip = document.querySelectorAll("[data-reorder-grip]")[from];
|
||||
const to = document.querySelectorAll("[data-note-row]")[toIdx];
|
||||
if (!grip || !to) return "missing";
|
||||
const box = to.getBoundingClientRect();
|
||||
const fire = (target, type, x, y) =>
|
||||
target.dispatchEvent(
|
||||
new PointerEvent(type, {
|
||||
pointerType: "touch",
|
||||
pointerId: 1,
|
||||
clientX: x,
|
||||
clientY: y,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
})
|
||||
);
|
||||
const start = grip.getBoundingClientRect();
|
||||
fire(grip, "pointerdown", start.x + start.width / 2, start.y + start.height / 2);
|
||||
fire(window, "pointermove", box.x + box.width / 2, box.y + box.height / 2 - 20);
|
||||
fire(window, "pointermove", box.x + box.width / 2, box.y + box.height / 2);
|
||||
fire(window, "pointerup", box.x + box.width / 2, box.y + box.height / 2);
|
||||
return "done";
|
||||
},
|
||||
[fromIndex, toIndex]
|
||||
);
|
||||
|
||||
await p.locator("button").filter({ hasText: "Ordnung" }).first().click();
|
||||
await p.waitForTimeout(1200);
|
||||
await sortBy("manual");
|
||||
|
||||
const beforeTouch = await titles();
|
||||
// The last row onto the first, so the order cannot come out the same by luck.
|
||||
const rowCount = (await titles()).length;
|
||||
check((await p.locator("[data-reorder-grip]").count()) >= rowCount, "every row has a grip to take hold of");
|
||||
await touchDrag(rowCount - 1, 0);
|
||||
await p.waitForTimeout(2000);
|
||||
check(
|
||||
(await titles()).join(",") !== beforeTouch.join(","),
|
||||
"a row can be moved with a finger, from its grip",
|
||||
`${beforeTouch.join(",")} → ${(await titles()).join(",")}`
|
||||
);
|
||||
|
||||
// And pressing the row itself must still scroll rather than drag.
|
||||
const beforeBody = await titles();
|
||||
await p.evaluate(() => {
|
||||
const row = document.querySelector("[data-note-row]");
|
||||
const b = row.getBoundingClientRect();
|
||||
const fire = (t, type, y) =>
|
||||
t.dispatchEvent(
|
||||
new PointerEvent(type, { pointerType: "touch", pointerId: 2, clientX: b.x + 40, clientY: y, bubbles: true, cancelable: true })
|
||||
);
|
||||
fire(row, "pointerdown", b.y + b.height / 2);
|
||||
fire(window, "pointermove", b.y + 260);
|
||||
fire(window, "pointerup", b.y + 260);
|
||||
});
|
||||
await p.waitForTimeout(1200);
|
||||
check(
|
||||
(await titles()).join(",") === beforeBody.join(","),
|
||||
"and pressing the row itself does not drag it — that gesture still scrolls",
|
||||
`${beforeBody.join(",")} → ${(await titles()).join(",")}`
|
||||
);
|
||||
|
||||
/* ── "Move to..." on a phone ──
|
||||
* The submenu was pinned to `left: 100%` and opened only on hover. On a narrow
|
||||
* screen the parent menu already sits against the right edge, so the list of
|
||||
* notebooks appeared off the side of the display — and with no hover there was
|
||||
* no way to open it in the first place. */
|
||||
await createNotebook(p, "Anderes Buch");
|
||||
await p.locator("button").filter({ hasText: "Ordnung" }).first().click();
|
||||
await p.waitForTimeout(900);
|
||||
await p.setViewportSize({ width: 420, height: 780 });
|
||||
await p.waitForTimeout(900);
|
||||
// Below the breakpoint the sidebar stays open as a full-screen overlay whose
|
||||
// backdrop swallows every click. Dismiss it by tapping beside the panel.
|
||||
await p.mouse.click(408, 420);
|
||||
await p.waitForTimeout(700);
|
||||
await p.locator("[data-note-row]").first().click({ button: "right" });
|
||||
await p.waitForTimeout(600);
|
||||
const menu = p.locator("[data-context-menu]");
|
||||
check((await menu.count()) === 1, "the menu opens on a phone-sized screen");
|
||||
const inside = async (loc) => {
|
||||
const b = await loc.boundingBox();
|
||||
return !!b && b.x >= -1 && b.x + b.width <= 421;
|
||||
};
|
||||
check(await inside(menu), "and stays on the screen", JSON.stringify(await menu.boundingBox()));
|
||||
|
||||
await p.locator("[data-submenu-toggle]").first().click();
|
||||
await p.waitForTimeout(500);
|
||||
const sub = p.locator("[data-submenu]");
|
||||
check((await sub.count()) === 1, "tapping 'Move to...' opens it — there is no hover on a phone");
|
||||
check(
|
||||
await inside(sub),
|
||||
"and the notebooks it offers are on the screen, not past its edge",
|
||||
JSON.stringify(await sub.boundingBox())
|
||||
);
|
||||
await p.keyboard.press("Escape");
|
||||
await p.waitForTimeout(400);
|
||||
await p.setViewportSize({ width: 1400, height: 950 });
|
||||
await p.waitForTimeout(900);
|
||||
|
||||
} catch (e) {
|
||||
check(false, "suite ran to the end", e.message);
|
||||
} finally {
|
||||
|
||||
Reference in new issue
Block a user