diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1a607e9..118293b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,6 +8,36 @@ not from the output of `docker push`.
---
+## Unreleased
+
+**Fixed**
+- **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 knew — a 404, five
+ retries, then thrown away. The note arrived empty and untitled with no sign
+ anything had been lost. The queue is now repointed along with the database.
+ The same held for a notebook made offline.
+- **The order of notes could not be changed on a phone.** The list used the
+ HTML5 drag API, which mobile browsers never fire from touch. It uses pointer
+ events now, and every row has a grip: pressing the row itself still scrolls,
+ and holding it still opens the menu.
+- **"Move to…" opened off the side of the screen** and could not be opened at
+ all with a finger. It was pinned to the right of the menu and reacted only to
+ hover. It now opens on a tap, unfolds in place on a narrow screen, and flips
+ to the other side on a wide one when there is no room.
+- The menu itself is placed by measuring rather than by assuming it is 220px
+ wide, so it can no longer hang off an edge; rows are tall enough to hit with a
+ thumb, and Escape closes it.
+
+**Changed**
+- **The install entry is always there.** It used to appear only when the browser
+ offered a prompt, so where installing was impossible there was nothing on
+ screen at all — no button, no reason. It now says which of the fixable causes
+ applies: served over plain HTTP, or a browser that wants to be asked through
+ its own menu.
+
+---
+
## 2026-08-12 — Covers, the search box, and room to write in the margin
**Images** — web `sha256:c1222665…`, server `sha256:06f94f43…`
diff --git a/e2e/offline.mjs b/e2e/offline.mjs
index 4045f5c..c8327f0 100644
--- a/e2e/offline.mjs
+++ b/e2e/offline.mjs
@@ -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 {
diff --git a/e2e/organise.mjs b/e2e/organise.mjs
index daf422b..153ad9b 100644
--- a/e2e/organise.mjs
+++ b/e2e/organise.mjs
@@ -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 {
diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
index e4319fd..c4752b6 100644
--- a/frontend/src/App.jsx
+++ b/frontend/src/App.jsx
@@ -88,7 +88,7 @@ export default function App() {
const T = useMemo(() => makeTheme(dark), [dark]);
const { isMobile, sidebarOpen, setSidebarOpen, mobileView, setMobileView } = useResponsive();
- const { canInstall, promptInstall } = usePwaInstall();
+ const { canInstall, reason: installReason, promptInstall } = usePwaInstall();
// Presentation only, and it lives on the account so it follows to the phone.
const noteFont = user?.preferences?.noteFont || "serif";
@@ -97,7 +97,13 @@ export default function App() {
const { notes, notebooks, noteContents, backlinks } = data;
const search = useSearchIndex(user, notes, searchQuery);
const dnd = useNotebookDnd(data.reorderNotebooks);
- const noteDnd = useNotebookDnd((dragId, targetId) => data.rearrange(activeFolder, dragId, targetId));
+ /* Notes reorder through their own list, which is the only place that knows
+ whether a hand-made arrangement is even in effect. Notebooks keep the HTML5
+ hook: they are dragged with a mouse, in the sidebar and on the dashboard. */
+ const rearrangeNotes = useCallback(
+ (dragId, targetId) => data.rearrange(activeFolder, dragId, targetId),
+ [data, activeFolder]
+ );
useEffect(() => sync.onSyncStatus(setSyncStatus), []);
@@ -610,6 +616,7 @@ export default function App() {
onNotebookContextMenu={notebookContextMenu}
dnd={dnd}
canInstall={canInstall}
+ installReason={installReason}
onInstall={promptInstall}
syncStatus={syncStatus}
online={sync.isOnline()}
@@ -656,7 +663,7 @@ export default function App() {
onStartRenameSeparator={setRenamingSeparator}
onRenameSeparator={renameSeparator}
onSeparatorContextMenu={separatorContextMenu}
- dnd={noteDnd}
+ onReorder={rearrangeNotes}
onToggleSidebar={() => setSidebarOpen(!sidebarOpen)}
onSelectNote={openNote}
onNoteContextMenu={noteContextMenu}
diff --git a/frontend/src/features/notes/NoteList.jsx b/frontend/src/features/notes/NoteList.jsx
index 8931f78..5f698e2 100644
--- a/frontend/src/features/notes/NoteList.jsx
+++ b/frontend/src/features/notes/NoteList.jsx
@@ -4,6 +4,7 @@ import { I } from "../../ui/icons.jsx";
import { fmtDate } from "../../ui/format";
import { FONT_SERIF, FONT_UI } from "../../ui/theme";
import { END_ID, SORT_LABELS, mergeEntries } from "./noteSort";
+import { useReorderDrag } from "../../hooks/useReorderDrag";
const iconButton = (T) => ({
display: "flex",
@@ -35,6 +36,37 @@ const DropLine = ({ T }) => (
);
+/**
+ * What you take hold of to move a row.
+ *
+ * A mouse can drag a row from anywhere, and did. A finger cannot: pressing the
+ * row has to keep scrolling the list, and holding it has to keep opening the
+ * menu. So touch gets its own target, wide enough to hit and only there while a
+ * hand-made arrangement is in effect — which is the only time moving a row means
+ * anything.
+ */
+const Grip = ({ T, ...rest }) => (
+
+ {I.grip}
+
+);
+
/** Width the separator's label keeps in both states, so renaming moves nothing. */
const MIDDLE_W = 132;
@@ -49,7 +81,7 @@ const MIDDLE_W = 132;
* could not be clicked — to select it, to drag from it — without falling into an
* edit; and the × for removal sat a few pixels from that same target.
*/
-function SeparatorRow({ T, separator, editing, onStartRename, onRename, onContextMenu }) {
+function SeparatorRow({ T, separator, editing, onStartRename, onRename, onContextMenu, grip }) {
const [draft, setDraft] = useState(separator.label);
useEffect(() => {
@@ -73,6 +105,7 @@ function SeparatorRow({ T, separator, editing, onStartRename, onRename, onContex
cursor: "grab",
}}
>
+ {grip}
{/* The two rules always share what is left over equally, and the middle
keeps one width whether it is showing a label or an input. Sizing it to
its contents made the whole row jump sideways the moment renaming
@@ -335,7 +368,7 @@ export function NoteList({
onStartRenameSeparator,
onRenameSeparator,
onSeparatorContextMenu,
- dnd,
+ onReorder,
onToggleSidebar,
onSelectNote,
onNoteContextMenu,
@@ -344,8 +377,10 @@ export function NoteList({
onImportMarkdown,
onEmptyTrash,
}) {
- // Opened by the button and kept open while there is something typed in it, so
- // a search never disappears because the box collapsed under it.
+ /* Reordering by pointer rather than by the HTML5 drag API, which mobile
+ browsers never fire from touch — on a phone the arrangement could not be
+ changed at all, with nothing on screen to say why. */
+ const dnd = useReorderDrag(!!reorderable, onReorder);
return (
{
- const showDropLine = reorderable && dnd?.overId === entry.id;
+ const showDropLine = reorderable && dnd.overId === entry.id;
/* The whole row, indicator included, is the drop target.
*
* Putting these on the inner element left the indicator itself as
* a dead strip: it pushes the row down as it appears, so the
* pointer ended up over the line rather than over the row, and
* releasing there dropped on nothing. */
- const rowProps = reorderable
- ? {
- draggable: true,
- onDragStart: (e) => dnd.onDragStart(e, entry.id),
- onDragOver: (e) => dnd.onDragOver(e, entry.id),
- onDrop: (e) => dnd.onDrop(e, entry.id),
- onDragEnd: dnd.onDragEnd,
- }
- : {};
+ const rowProps = reorderable ? dnd.rowProps(entry.id) : {};
+ const lifted = dnd.draggingId === entry.id;
if (entry.kind === "separator") {
return (
-
);
})}
{/* Dropping below the last row moves something to the end, which
otherwise needs an awkward drop onto the very last note. */}
{reorderable && (
-
)}
>
diff --git a/frontend/src/hooks/usePwaInstall.ts b/frontend/src/hooks/usePwaInstall.ts
index 6088428..ca1241d 100644
--- a/frontend/src/hooks/usePwaInstall.ts
+++ b/frontend/src/hooks/usePwaInstall.ts
@@ -7,25 +7,46 @@ interface BeforeInstallPromptEvent extends Event {
}
export interface PwaInstall {
- /** Non-null only while the browser is willing to show an install prompt. */
+ /** True while the browser is willing to show its own install prompt. */
canInstall: boolean;
+ /** True when the app is already running as an installed app. */
+ installed: boolean;
+ /**
+ * Why there is no prompt, when there is none. Empty while one is available.
+ *
+ * The button used to appear only when the browser offered a prompt, so when
+ * installing was impossible there was nothing on screen at all — no button, no
+ * reason, nothing to act on. Every one of these is fixable by whoever is
+ * holding the phone, which is why it is worth saying.
+ */
+ reason: "" | "installed" | "insecure" | "manual";
promptInstall: () => Promise;
}
-/**
- * Captures the browser's install offer so the app can present it at a sensible
- * moment instead of letting the browser choose one.
- */
+const isStandalone = () =>
+ window.matchMedia?.("(display-mode: standalone)")?.matches ||
+ // iOS keeps its own flag rather than reporting the display mode.
+ (navigator as unknown as { standalone?: boolean }).standalone === true;
+
export function usePwaInstall(): PwaInstall {
const [event, setEvent] = useState(null);
+ const [installed, setInstalled] = useState(isStandalone);
useEffect(() => {
const onPrompt = (e: Event) => {
e.preventDefault();
setEvent(e as BeforeInstallPromptEvent);
};
+ const onInstalled = () => {
+ setInstalled(true);
+ setEvent(null);
+ };
window.addEventListener("beforeinstallprompt", onPrompt);
- return () => window.removeEventListener("beforeinstallprompt", onPrompt);
+ window.addEventListener("appinstalled", onInstalled);
+ return () => {
+ window.removeEventListener("beforeinstallprompt", onPrompt);
+ window.removeEventListener("appinstalled", onInstalled);
+ };
}, []);
const promptInstall = async () => {
@@ -37,5 +58,17 @@ export function usePwaInstall(): PwaInstall {
if (outcome === "accepted") setEvent(null);
};
- return { canInstall: event !== null, promptInstall };
+ /* An app can only be installed from a secure origin. Served over plain HTTP
+ * the service worker never registers either, so this one condition takes the
+ * install *and* offline with it — which is worth naming rather than leaving
+ * as two separate mysteries. */
+ const reason: PwaInstall["reason"] = installed
+ ? "installed"
+ : event
+ ? ""
+ : window.isSecureContext
+ ? "manual"
+ : "insecure";
+
+ return { canInstall: event !== null, installed, reason, promptInstall };
}
diff --git a/frontend/src/hooks/useReorderDrag.ts b/frontend/src/hooks/useReorderDrag.ts
new file mode 100644
index 0000000..e2f8f0d
--- /dev/null
+++ b/frontend/src/hooks/useReorderDrag.ts
@@ -0,0 +1,128 @@
+import { useCallback, useEffect, useRef, useState } from "react";
+
+/**
+ * Drag-to-reorder that works with a finger.
+ *
+ * The list used to use the HTML5 drag-and-drop API, which mobile browsers never
+ * fire from touch — so on a phone the arrangement simply could not be changed,
+ * with nothing on screen to say why. This uses pointer events instead, which
+ * mouse, pen and finger all produce.
+ *
+ * The two devices want different things, so they get different starts:
+ *
+ * - **With a mouse**, pressing anywhere on a row and moving a few pixels begins
+ * the drag. That is what a mouse has always done here.
+ * - **With a finger**, only the grip begins it. Pressing the row itself has to
+ * go on scrolling the list, and a long press has to go on opening the menu —
+ * both would be lost to a drag that started from anywhere.
+ */
+
+/** Pixels a mouse must travel before a press counts as a drag rather than a click. */
+const MOUSE_THRESHOLD = 5;
+
+export interface ReorderDrag {
+ /** The row the pointer is over, for the drop indicator. */
+ overId: string | null;
+ /** The row being carried, so it can be shown as lifted. */
+ draggingId: string | null;
+ /** Spread onto each row; `id` is what gets reordered. */
+ rowProps: (id: string) => { "data-reorder-id": string; onPointerDown: (e: React.PointerEvent) => void };
+ /** Spread onto the grip inside a row. */
+ gripProps: { "data-reorder-grip": string };
+}
+
+export function useReorderDrag(
+ enabled: boolean,
+ reorder: (dragId: string, targetId: string) => void
+): ReorderDrag {
+ const [overId, setOverId] = useState(null);
+ const [draggingId, setDraggingId] = useState(null);
+
+ // Everything the pointer handlers need, kept out of state: they run far more
+ // often than anything needs to be redrawn.
+ const drag = useRef<{ id: string; x: number; y: number; active: boolean; fromGrip: boolean } | null>(null);
+ const overRef = useRef(null);
+
+ const stop = useCallback(() => {
+ drag.current = null;
+ overRef.current = null;
+ setDraggingId(null);
+ setOverId(null);
+ document.body.style.removeProperty("user-select");
+ }, []);
+
+ const onPointerDown = useCallback(
+ (id: string) => (e: React.PointerEvent) => {
+ if (!enabled || e.button > 0) return;
+ const fromGrip = !!(e.target as HTMLElement)?.closest?.("[data-reorder-grip]");
+ // A finger on the row itself is a scroll or a long press, not a drag.
+ if (e.pointerType !== "mouse" && !fromGrip) return;
+ drag.current = { id, x: e.clientX, y: e.clientY, active: fromGrip, fromGrip };
+ if (fromGrip) {
+ setDraggingId(id);
+ document.body.style.userSelect = "none";
+ // The grip owns the gesture from here, so the list must not also scroll.
+ e.preventDefault();
+ }
+ },
+ [enabled]
+ );
+
+ useEffect(() => {
+ if (!enabled) return;
+
+ const move = (e: PointerEvent) => {
+ const d = drag.current;
+ if (!d) return;
+ if (!d.active) {
+ if (Math.abs(e.clientX - d.x) + Math.abs(e.clientY - d.y) < MOUSE_THRESHOLD) return;
+ d.active = true;
+ setDraggingId(d.id);
+ document.body.style.userSelect = "none";
+ }
+ // Which row is under the finger — asked of the document rather than
+ // tracked with enter/leave handlers, because a finger generates no
+ // pointerover on anything it did not press.
+ const el = document.elementFromPoint(e.clientX, e.clientY) as HTMLElement | null;
+ const row = el?.closest?.("[data-reorder-id]") as HTMLElement | null;
+ const target = row?.dataset.reorderId ?? null;
+ const next = target && target !== d.id ? target : null;
+ if (next !== overRef.current) {
+ overRef.current = next;
+ setOverId(next);
+ }
+ if (e.cancelable) e.preventDefault();
+ };
+
+ const up = () => {
+ const d = drag.current;
+ const target = overRef.current;
+ if (d?.active && target && target !== d.id) reorder(d.id, target);
+ stop();
+ };
+
+ window.addEventListener("pointermove", move, { passive: false });
+ window.addEventListener("pointerup", up);
+ window.addEventListener("pointercancel", stop);
+ return () => {
+ window.removeEventListener("pointermove", move);
+ window.removeEventListener("pointerup", up);
+ window.removeEventListener("pointercancel", stop);
+ };
+ }, [enabled, reorder, stop]);
+
+ // A drag that outlives its list would leave the page unselectable.
+ useEffect(
+ () => () => {
+ document.body.style.removeProperty("user-select");
+ },
+ []
+ );
+
+ return {
+ overId,
+ draggingId,
+ rowProps: (id: string) => ({ "data-reorder-id": id, onPointerDown: onPointerDown(id) }),
+ gripProps: { "data-reorder-grip": "" },
+ };
+}
diff --git a/frontend/src/offlineStore.ts b/frontend/src/offlineStore.ts
index 6232a47..9fd4199 100644
--- a/frontend/src/offlineStore.ts
+++ b/frontend/src/offlineStore.ts
@@ -225,6 +225,34 @@ export async function updateQueueEntry(queueId: number, patch: Partial {
+ if (!oldId || oldId === newId) return;
+ const s = await store("syncQueue", "readwrite");
+ const entries = await reqToPromise(s.getAll());
+ for (const entry of entries) {
+ const before = JSON.stringify(entry.data ?? {});
+ if (!before.includes(oldId)) continue;
+ await reqToPromise(s.put({ ...entry, data: JSON.parse(before.split(oldId).join(newId)) }));
+ }
+}
+
export async function clearQueue(): Promise {
const s = await store("syncQueue", "readwrite");
await reqToPromise(s.clear());
diff --git a/frontend/src/syncEngine.ts b/frontend/src/syncEngine.ts
index 0b43474..225981c 100644
--- a/frontend/src/syncEngine.ts
+++ b/frontend/src/syncEngine.ts
@@ -571,6 +571,9 @@ async function processEntry(entry: QueueEntry): Promise {
for (const n of await db.getAllNotes()) {
if (n.folderId === d.localId) await db.putNote({ ...n, folderId: r.id });
}
+ // ...and everything still queued behind this create, which is addressed
+ // to a name only this device ever knew.
+ await db.repointQueue(d.localId, r.id);
}
break;
}
@@ -605,6 +608,11 @@ async function processEntry(entry: QueueEntry): Promise {
await db.putNote({ ...rest, id: r.id });
}
if (oldC) await db.putNoteContent(r.id, oldC.content, oldC.drawingData, oldC.drawingStrokes ?? null);
+ /* The queue too. Without this the title, the text and the drawing that
+ * followed were all addressed to the local id, met a 404, and were
+ * thrown away after five attempts — so a note written offline arrived
+ * empty and untitled, with no sign that anything had been lost. */
+ await db.repointQueue(d.localId, r.id);
}
break;
}
diff --git a/frontend/src/ui/ContextMenu.jsx b/frontend/src/ui/ContextMenu.jsx
index 752b25e..d09109b 100644
--- a/frontend/src/ui/ContextMenu.jsx
+++ b/frontend/src/ui/ContextMenu.jsx
@@ -1,4 +1,7 @@
-import { useEffect, useMemo, useRef, useState } from "react";
+import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
+
+/** Below this the screen is too narrow for a menu to fly out beside another. */
+const NARROW = 620;
export function ContextMenu({ x, y, items, onClose, dark }) {
const T = useMemo(
@@ -11,21 +14,51 @@ export function ContextMenu({ x, y, items, onClose, dark }) {
}),
[dark]
);
+
+ const ref = useRef(null);
+ /* Placed after measuring, not from a guess.
+ *
+ * The corner used to be pinned with `innerWidth - 220`, a stand-in for a width
+ * nobody had measured — which put the menu in the wrong place whenever it was
+ * not 220 wide, and left a submenu opening off the side of a phone. */
+ const [pos, setPos] = useState({ left: x, top: y, ready: false });
+
+ useLayoutEffect(() => {
+ const el = ref.current;
+ if (!el) return;
+ const { width, height } = el.getBoundingClientRect();
+ const M = 8;
+ setPos({
+ left: Math.max(M, Math.min(x, window.innerWidth - width - M)),
+ top: Math.max(M, Math.min(y, window.innerHeight - height - M)),
+ ready: true,
+ });
+ }, [x, y, items]);
+
useEffect(() => {
const h = () => onClose();
+ const onKey = (e) => e.key === "Escape" && onClose();
window.addEventListener("click", h);
window.addEventListener("contextmenu", h);
+ window.addEventListener("keydown", onKey);
return () => {
window.removeEventListener("click", h);
window.removeEventListener("contextmenu", h);
+ window.removeEventListener("keydown", onKey);
};
}, [onClose]);
+
return (
e.stopPropagation()}
@@ -43,54 +79,127 @@ export function ContextMenu({ x, y, items, onClose, dark }) {
) : item.submenu ? (
) : (
-
+
)
)}
);
}
+function MenuButton({ T, item, onClose, small }) {
+ return (
+
+ );
+}
+
+/**
+ * A menu inside a menu — "Move to", and the notebooks under it.
+ *
+ * Three things it has to get right, all of which it used to get wrong:
+ *
+ * - **It opens on a tap**, not only on hover. There is no hover on a phone, so
+ * the entry could not be opened there at all.
+ * - **On a narrow screen it unfolds in place** rather than flying out beside the
+ * menu. A phone has no room beside anything.
+ * - **On a wide one it flips to the left** when the right would put it off the
+ * edge. It used to be pinned to `left: 100%` whatever was there, which is how
+ * a list of notebooks ended up somewhere nobody could see it.
+ */
function SubMenu({ item, T, onClose }) {
const [open, setOpen] = useState(false);
+ const [side, setSide] = useState("right");
+ const rowRef = useRef(null);
+ const [inline, setInline] = useState(() => window.innerWidth < NARROW);
+
+ useLayoutEffect(() => {
+ if (!open) return;
+ const narrow = window.innerWidth < NARROW;
+ setInline(narrow);
+ if (narrow) return;
+ const row = rowRef.current?.getBoundingClientRect();
+ if (!row) return;
+ // 180 is this submenu's minimum width; going off the right edge means it
+ // belongs on the other side.
+ setSide(row.right + 180 > window.innerWidth - 8 ? "left" : "right");
+ }, [open]);
+
+ const panel = (
+
);
}
diff --git a/frontend/src/ui/Sidebar.jsx b/frontend/src/ui/Sidebar.jsx
index be2047a..0b4cca7 100644
--- a/frontend/src/ui/Sidebar.jsx
+++ b/frontend/src/ui/Sidebar.jsx
@@ -1,3 +1,5 @@
+import { useState } from "react";
+
import { COVERS, DEFAULT_FOLDERS } from "./constants.jsx";
import { I } from "./icons.jsx";
import { FONT_UI } from "./theme";
@@ -60,6 +62,70 @@ const countBadge = (T, color) => ({
* On a phone it becomes a full-screen overlay; `onClose` is what the backdrop
* and every navigation action call so it does not stay in the way.
*/
+/**
+ * Installing the app, and why it cannot be.
+ *
+ * This used to be a button that appeared only when the browser had offered a
+ * prompt. When installing was impossible there was nothing on screen at all —
+ * no button, no reason — and the way to find out was to know how PWAs work.
+ *
+ * The three cases that are left are all things the person holding the phone can
+ * act on, which is the whole reason for saying them out loud.
+ */
+function InstallRow({ T, canInstall, reason, onInstall }) {
+ const [why, setWhy] = useState(false);
+ if (reason === "installed") return null;
+
+ const message =
+ reason === "insecure"
+ ? "This browser will only install a site served over HTTPS. Reach InkNote at an https:// address and the offer appears — the offline cache needs the same thing, so both come back together."
+ : "Your browser has not offered to install it. Open its menu and look for “Install app” or “Add to Home screen”.";
+
+ return (
+
●{SYNC_LABELS[syncStatus] || "Sync error"}
diff --git a/readme.md b/readme.md
index 933e41b..22cf90b 100644
--- a/readme.md
+++ b/readme.md
@@ -314,6 +314,45 @@ bug they cover actually happened:
- `hasPendingWrites` must cover the debounce window, not just requests in flight
- v1 note bodies must migrate without losing task state or smuggling in markup
+### Reordering, and menus, with a finger
+
+Both were built for a mouse and quietly did nothing on a phone.
+
+**Dragging** went through the HTML5 drag-and-drop API, which mobile browsers
+never fire from touch — so the arrangement could not be changed at all, with
+nothing on screen to say why. [`useReorderDrag`](frontend/src/hooks/useReorderDrag.ts)
+uses pointer events instead, which mouse, pen and finger all produce. The two
+devices get different starts, because they want different things:
+
+- **A mouse** begins a drag by pressing anywhere on a row and moving a few pixels.
+- **A finger** begins one only from the grip. Pressing the row has to go on
+ scrolling the list, and holding it has to go on opening the menu; a drag that
+ started from anywhere would take both.
+
+The row under the finger is found with `elementFromPoint`, not with enter/leave
+handlers — a finger generates no `pointerover` on anything it did not press.
+
+**The context menu** was placed with `innerWidth - 220`, a stand-in for a width
+nobody had measured, and its submenu was pinned to `left: 100%` and opened only
+on hover. On a phone the parent already sits against the right edge, so the list
+of notebooks under *Move to…* appeared past the edge of the display — and with no
+hover, there was no way to open it in the first place. It is now placed by
+measuring, opens on a tap, unfolds in place on a narrow screen, and flips to the
+other side on a wide one when the right would not fit.
+
+### Installing it
+
+The install entry used to appear only when the browser had offered a prompt
+(`beforeinstallprompt`). Where installing was impossible there was nothing on
+screen at all — no button, no reason, and no way to find out short of knowing how
+PWAs work.
+
+It is always there now, and when there is no prompt it says which of the fixable
+causes applies. The common one is worth naming: **a browser will only install a
+site served over HTTPS**, and on plain HTTP the service worker never registers
+either — so one cause takes the install *and* offline with it, which otherwise
+looks like two unrelated mysteries.
+
### Notebook covers
A cover is stored as an **index** into `COVERS` in
@@ -350,7 +389,7 @@ docker compose -f docker-compose.dev.yml up -d --build # must be running
| Suite | Checks | What it holds down |
| --- | --- | --- |
| `admin` | 15 | no sign-up anywhere; `/api/admin/*` refused for non-admins even when called directly |
-| `editor` | 31 | tables and their right-click menu, task lists, note links, backlinks — and that each survives a reload |
+| `editor` | 36 | tables and their right-click menu, task lists, note links, backlinks — and that each survives a reload |
| `search-attachments` | 12 | body-only matches, ranking, umlauts; images as blob URLs, not inline bytes |
| `history` | 10 | a large deletion is snapshotted, restoring works, restoring is itself undoable |
| `drawing` | 18 | strokes survive the poller, match across four viewport widths, and stay on the page they were drawn on |
@@ -358,8 +397,8 @@ docker compose -f docker-compose.dev.yml up -d --build # must be running
| `attachments-lifecycle` | 10 | a picture is kept while any version shows it, and freed once none does |
| `conflict` | 10 | a late write wins but the state it buries survives as a version |
| `export` | 21 | the downloaded bytes are real Markdown, ZIP and PNG, unpacked and read back |
-| `organise` | 39 | a hand-made order survives a search; separators hold their place; a note exported and imported comes back whole |
-| `offline` | 12 | a cold page load with the network off still opens notes and searches them |
+| `organise` | 50 | a hand-made order survives a search; separators hold their place; a note exported and imported comes back whole |
+| `offline` | 17 | a cold page load with the network off still opens notes and searches them — and a note made, written and drawn on offline arrives whole once there is a connection |
| `sync` | 7 | a PDF written on one device — document, drawing and pinned notes — reaches a second one, which can find it by its contents without opening it |
Each suite creates a throwaway account through the admin API and deletes it
@@ -1128,6 +1167,30 @@ out as a ZIP rather than as its first page alone.
---
+## Made offline, and what it is called
+
+Something created with no connection carries an id this device invented —
+`local_x9k2…`. When its create finally reaches the server, the server names it
+something else, and the local database is repointed at the new name.
+
+**The queue has to be repointed too**, and for a long time it was not. The create
+arrived; every edit still queued behind it went on addressing the name only this
+device ever knew; the server answered 404; the entry was retried five times and
+thrown away. A note written and drawn on offline reached the server *empty and
+untitled*, with nothing to say that anything had been lost. The same held for a
+notebook made offline and everything filed into it.
+
+`repointQueue(oldId, newId)` in [`offlineStore.ts`](frontend/src/offlineStore.ts)
+now rewrites the waiting entries alongside the database. It replaces the id as a
+plain string across the whole queued payload rather than in a list of named
+fields — ids are long random strings, so a false match is not a practical worry,
+and a list of fields is a list to forget to add to.
+
+The `offline` suite makes a note with the network off, titles it, writes in it,
+draws on it, reconnects, and then asks the *server* for all three.
+
+---
+
## Conflict Resolution
Two devices can edit the same note without knowing about each other — a phone