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
@@ -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…`
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+10
-3
@@ -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}
|
||||
|
||||
@@ -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 }) => (
|
||||
<div data-drop-line style={{ height: 2, borderRadius: 2, background: T.accent, margin: "2px 4px" }} />
|
||||
);
|
||||
|
||||
/**
|
||||
* 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 }) => (
|
||||
<div
|
||||
{...rest}
|
||||
aria-hidden
|
||||
title="Drag to reorder"
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
width: 30,
|
||||
flexShrink: 0,
|
||||
color: T.faint,
|
||||
cursor: "grab",
|
||||
// Without this the browser claims the gesture for scrolling and the drag
|
||||
// never begins.
|
||||
touchAction: "none",
|
||||
}}
|
||||
>
|
||||
{I.grip}
|
||||
</div>
|
||||
);
|
||||
|
||||
/** 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 (
|
||||
<div
|
||||
@@ -549,28 +584,22 @@ export function NoteList({
|
||||
line on every edit. They are not shown in the other orders, and
|
||||
are still in place on the way back. */}
|
||||
{(reorderable ? mergeEntries(notes, separators) : notes.map(asNoteEntry)).map((entry) => {
|
||||
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 (
|
||||
<div key={entry.id} {...rowProps}>
|
||||
<div key={entry.id} {...rowProps} style={{ opacity: lifted ? 0.45 : 1 }}>
|
||||
{showDropLine && <DropLine T={T} />}
|
||||
<SeparatorRow
|
||||
grip={<Grip T={T} {...dnd.gripProps} />}
|
||||
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 (
|
||||
<div key={note.id} {...rowProps}>
|
||||
<div key={note.id} {...rowProps} style={{ opacity: lifted ? 0.45 : 1 }}>
|
||||
{showDropLine && <DropLine T={T} />}
|
||||
<div style={{ display: "flex", alignItems: "stretch", gap: 2 }}>
|
||||
{reorderable && <Grip T={T} {...dnd.gripProps} />}
|
||||
<button
|
||||
data-note-row={note.id}
|
||||
onClick={() => onSelectNote(note.id)}
|
||||
@@ -594,7 +625,8 @@ export function NoteList({
|
||||
style={{
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
width: "100%",
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
padding: "9px 12px",
|
||||
borderRadius: 10,
|
||||
border: `1px solid ${activeNote === note.id ? T.accent + "55" : "transparent"}`,
|
||||
@@ -654,19 +686,15 @@ export function NoteList({
|
||||
{fmtDate(note.updatedAt)}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{/* Dropping below the last row moves something to the end, which
|
||||
otherwise needs an awkward drop onto the very last note. */}
|
||||
{reorderable && (
|
||||
<div
|
||||
data-drop-end
|
||||
onDragOver={(e) => dnd.onDragOver(e, END_ID)}
|
||||
onDrop={(e) => dnd.onDrop(e, END_ID)}
|
||||
style={{ minHeight: 40 }}
|
||||
>
|
||||
{dnd?.overId === END_ID && <DropLine T={T} />}
|
||||
<div data-drop-end {...dnd.rowProps(END_ID)} style={{ minHeight: 56 }}>
|
||||
{dnd.overId === END_ID && <DropLine T={T} />}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -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<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<BeforeInstallPromptEvent | null>(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 };
|
||||
}
|
||||
@@ -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<string | null>(null);
|
||||
const [draggingId, setDraggingId] = useState<string | null>(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<string | null>(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": "" },
|
||||
};
|
||||
}
|
||||
@@ -225,6 +225,34 @@ export async function updateQueueEntry(queueId: number, patch: Partial<QueueEntr
|
||||
await reqToPromise(s.put({ ...entry, ...patch }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrites every queued reference to a temporary id.
|
||||
*
|
||||
* Something made offline carries an id this device invented. When its create
|
||||
* finally reaches the server, the server names it something else — and every
|
||||
* entry still waiting in the queue is addressed to the name nobody else knows.
|
||||
*
|
||||
* They used to be left as they were. The database was repointed and the queue
|
||||
* was not, so the create arrived and then every edit behind it asked the server
|
||||
* for `local_xxxx`, got a 404, retried five times and was thrown away: a note
|
||||
* written and drawn on offline reached the server empty and untitled, with the
|
||||
* work silently dropped.
|
||||
*
|
||||
* The replacement is a plain string match over the whole payload rather than a
|
||||
* list of fields to look in. 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.
|
||||
*/
|
||||
export async function repointQueue(oldId: string, newId: string): Promise<void> {
|
||||
if (!oldId || oldId === newId) return;
|
||||
const s = await store("syncQueue", "readwrite");
|
||||
const entries = await reqToPromise<QueueEntry[]>(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<void> {
|
||||
const s = await store("syncQueue", "readwrite");
|
||||
await reqToPromise(s.clear());
|
||||
|
||||
@@ -571,6 +571,9 @@ async function processEntry(entry: QueueEntry): Promise<void> {
|
||||
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<void> {
|
||||
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;
|
||||
}
|
||||
|
||||
+146
-84
@@ -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 (
|
||||
<div
|
||||
ref={ref}
|
||||
data-context-menu
|
||||
style={{
|
||||
position: "fixed",
|
||||
left: Math.min(x, window.innerWidth - 220),
|
||||
top: Math.min(y, window.innerHeight - 200),
|
||||
left: pos.left,
|
||||
top: pos.top,
|
||||
// Hidden for the one frame it takes to measure, so it is never seen in
|
||||
// the wrong place first.
|
||||
visibility: pos.ready ? "visible" : "hidden",
|
||||
zIndex: 3000,
|
||||
background: T.bg,
|
||||
border: `1px solid ${T.border}`,
|
||||
@@ -33,6 +66,9 @@ export function ContextMenu({ x, y, items, onClose, dark }) {
|
||||
boxShadow: "0 8px 24px rgba(0,0,0,0.25)",
|
||||
padding: 4,
|
||||
minWidth: 190,
|
||||
maxWidth: "calc(100vw - 16px)",
|
||||
maxHeight: "calc(100vh - 16px)",
|
||||
overflowY: "auto",
|
||||
fontFamily: "'DM Sans',sans-serif",
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
@@ -43,54 +79,127 @@ export function ContextMenu({ x, y, items, onClose, dark }) {
|
||||
) : item.submenu ? (
|
||||
<SubMenu key={i} item={item} T={T} onClose={onClose} />
|
||||
) : (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => {
|
||||
item.action();
|
||||
onClose();
|
||||
}}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 10,
|
||||
width: "100%",
|
||||
padding: "8px 12px",
|
||||
borderRadius: 8,
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
fontSize: 13,
|
||||
fontWeight: 500,
|
||||
background: "transparent",
|
||||
color: item.danger ? T.danger : T.text,
|
||||
textAlign: "left",
|
||||
}}
|
||||
onMouseEnter={(e) => (e.currentTarget.style.background = T.hover)}
|
||||
onMouseLeave={(e) => (e.currentTarget.style.background = "transparent")}
|
||||
>
|
||||
{item.icon && <span style={{ display: "flex", opacity: 0.7 }}>{item.icon}</span>}
|
||||
{item.label}
|
||||
</button>
|
||||
<MenuButton key={i} T={T} item={item} onClose={onClose} />
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MenuButton({ T, item, onClose, small }) {
|
||||
return (
|
||||
<button
|
||||
onClick={() => {
|
||||
item.action();
|
||||
onClose();
|
||||
}}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: small ? 8 : 10,
|
||||
width: "100%",
|
||||
// A finger needs a target; 8px of padding gives a 30px row, which is
|
||||
// under every published minimum and was being missed on a phone.
|
||||
padding: small ? "10px 12px" : "11px 12px",
|
||||
borderRadius: 8,
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
fontSize: small ? 12.5 : 13,
|
||||
fontWeight: 500,
|
||||
background: "transparent",
|
||||
color: item.danger ? T.danger : T.text,
|
||||
textAlign: "left",
|
||||
}}
|
||||
onMouseEnter={(e) => (e.currentTarget.style.background = T.hover)}
|
||||
onMouseLeave={(e) => (e.currentTarget.style.background = "transparent")}
|
||||
>
|
||||
{item.colorDot && (
|
||||
<div style={{ width: 12, height: 8, borderRadius: 2, background: item.colorDot, flexShrink: 0 }} />
|
||||
)}
|
||||
{item.icon && <span style={{ display: "flex", opacity: 0.7 }}>{item.icon}</span>}
|
||||
<span style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{item.label}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 = (
|
||||
<div
|
||||
data-submenu
|
||||
style={
|
||||
inline
|
||||
? { margin: "2px 0 4px 10px", borderLeft: `1px solid ${T.border}`, paddingLeft: 6 }
|
||||
: {
|
||||
position: "absolute",
|
||||
[side]: "100%",
|
||||
top: 0,
|
||||
background: T.bg,
|
||||
border: `1px solid ${T.border}`,
|
||||
borderRadius: 10,
|
||||
boxShadow: "0 8px 20px rgba(0,0,0,0.2)",
|
||||
padding: 4,
|
||||
minWidth: 180,
|
||||
maxHeight: "60vh",
|
||||
overflowY: "auto",
|
||||
zIndex: 10,
|
||||
}
|
||||
}
|
||||
>
|
||||
{item.submenu.map((sub, j) => (
|
||||
<MenuButton key={j} T={T} item={sub} onClose={onClose} small />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{ position: "relative" }}
|
||||
onMouseEnter={() => setOpen(true)}
|
||||
onMouseLeave={() => setOpen(false)}
|
||||
onMouseEnter={() => window.innerWidth >= NARROW && setOpen(true)}
|
||||
onMouseLeave={() => window.innerWidth >= NARROW && setOpen(false)}
|
||||
>
|
||||
<div
|
||||
ref={rowRef}
|
||||
data-submenu-toggle
|
||||
/* Narrow: a tap opens it and a second one folds it away again. Wide:
|
||||
hover already governs, so a click only ever opens — toggling there
|
||||
would fight the pointer that is sitting on the row. */
|
||||
onClick={() => 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 && <span style={{ display: "flex", opacity: 0.7 }}>{item.icon}</span>}
|
||||
{item.label}
|
||||
<span style={{ marginLeft: "auto", fontSize: 10, opacity: 0.5 }}>▸</span>
|
||||
<span style={{ marginLeft: "auto", fontSize: 10, opacity: 0.5 }}>
|
||||
{inline && open ? "▾" : side === "left" ? "◂" : "▸"}
|
||||
</span>
|
||||
</div>
|
||||
{open && (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: "100%",
|
||||
top: 0,
|
||||
background: T.bg,
|
||||
border: `1px solid ${T.border}`,
|
||||
borderRadius: 10,
|
||||
boxShadow: "0 8px 20px rgba(0,0,0,0.2)",
|
||||
padding: 4,
|
||||
minWidth: 160,
|
||||
zIndex: 10,
|
||||
}}
|
||||
>
|
||||
{item.submenu.map((sub, j) => (
|
||||
<button
|
||||
key={j}
|
||||
onClick={() => {
|
||||
sub.action();
|
||||
onClose();
|
||||
}}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
width: "100%",
|
||||
padding: "7px 12px",
|
||||
borderRadius: 6,
|
||||
border: "none",
|
||||
cursor: "pointer",
|
||||
fontSize: 12,
|
||||
fontWeight: 500,
|
||||
background: "transparent",
|
||||
color: T.text,
|
||||
textAlign: "left",
|
||||
}}
|
||||
onMouseEnter={(e) => (e.currentTarget.style.background = T.hover)}
|
||||
onMouseLeave={(e) => (e.currentTarget.style.background = "transparent")}
|
||||
>
|
||||
{sub.colorDot && (
|
||||
<div
|
||||
style={{ width: 12, height: 8, borderRadius: 2, background: sub.colorDot, flexShrink: 0 }}
|
||||
/>
|
||||
)}
|
||||
{sub.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{open && panel}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+68
-23
@@ -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 (
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<button
|
||||
data-install-app={canInstall ? "ready" : reason}
|
||||
onClick={() => (canInstall ? onInstall() : setWhy((v) => !v))}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
width: "100%",
|
||||
padding: "8px 12px",
|
||||
borderRadius: 10,
|
||||
border: `1px solid ${canInstall ? T.accent + "44" : T.border}`,
|
||||
background: canInstall ? T.accentSoft : "transparent",
|
||||
cursor: "pointer",
|
||||
color: canInstall ? T.accent : T.muted,
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
textAlign: "left",
|
||||
}}
|
||||
>
|
||||
{I.download}
|
||||
Install App
|
||||
</button>
|
||||
{!canInstall && why && (
|
||||
<div
|
||||
data-install-why
|
||||
style={{
|
||||
marginTop: 6,
|
||||
padding: "8px 10px",
|
||||
borderRadius: 8,
|
||||
background: T.hover || "transparent",
|
||||
border: `1px solid ${T.borderLight}`,
|
||||
color: T.muted,
|
||||
fontSize: 11,
|
||||
lineHeight: 1.5,
|
||||
}}
|
||||
>
|
||||
{message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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({
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: "auto", padding: "12px 14px", borderTop: `1px solid ${T.borderLight}` }}>
|
||||
{canInstall && (
|
||||
<button
|
||||
onClick={onInstall}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
width: "100%",
|
||||
padding: "8px 12px",
|
||||
borderRadius: 10,
|
||||
border: `1px solid ${T.accent}44`,
|
||||
background: T.accentSoft,
|
||||
cursor: "pointer",
|
||||
color: T.accent,
|
||||
fontSize: 12,
|
||||
fontWeight: 600,
|
||||
marginBottom: 8,
|
||||
}}
|
||||
>
|
||||
{I.download}
|
||||
Install App
|
||||
</button>
|
||||
)}
|
||||
<InstallRow T={T} canInstall={canInstall} reason={installReason} onInstall={onInstall} />
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 6, color: T.muted, fontSize: 12 }}>
|
||||
<span style={{ color: syncColor(syncStatus, T) }}>●</span>
|
||||
<span>{SYNC_LABELS[syncStatus] || "Sync error"}</span>
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user