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 ( -
+
{showDropLine && } } T={T} separator={entry.separator} editing={renamingSeparator === entry.id} @@ -585,8 +614,10 @@ export function NoteList({ const note = entry.note; const snippet = snippetFor(note.id); return ( -
+
{showDropLine && } +
+ {reorderable && }
+
); })} {/* Dropping below the last row moves something to the end, which otherwise needs an awkward drop onto the very last note. */} {reorderable && ( -
dnd.onDragOver(e, END_ID)} - onDrop={(e) => dnd.onDrop(e, END_ID)} - style={{ minHeight: 40 }} - > - {dnd?.overId === END_ID && } +
+ {dnd.overId === END_ID && }
)} 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 ( ); } +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 = ( +
+ {item.submenu.map((sub, j) => ( + + ))} +
+ ); + return (
setOpen(true)} - onMouseLeave={() => setOpen(false)} + onMouseEnter={() => window.innerWidth >= NARROW && setOpen(true)} + onMouseLeave={() => window.innerWidth >= NARROW && setOpen(false)} >
setOpen((v) => (window.innerWidth < NARROW ? !v : true))} style={{ display: "flex", alignItems: "center", gap: 10, width: "100%", - padding: "8px 12px", + padding: "11px 12px", borderRadius: 8, fontSize: 13, fontWeight: 500, @@ -101,58 +210,11 @@ function SubMenu({ item, T, onClose }) { > {item.icon && {item.icon}} {item.label} - ▸ + + {inline && open ? "▾" : side === "left" ? "◂" : "▸"} +
- {open && ( -
- {item.submenu.map((sub, j) => ( - - ))} -
- )} + {open && 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 ( +
+ + {!canInstall && why && ( +
+ {message} +
+ )} +
+ ); +} + export function Sidebar({ T, dark, @@ -77,6 +143,7 @@ export function Sidebar({ onNotebookContextMenu, dnd, canInstall, + installReason, onInstall, syncStatus, online, @@ -298,29 +365,7 @@ export function Sidebar({
- {canInstall && ( - - )} +
● {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