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:
marcandClaude Opus 5 committed 2026-08-12 01:16:41 +02:00
1 parent 6eecf57247
commit 608da16ac2
12 files changed
+743 -147

No files matched your search

+52 -24
View File
@@ -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>
)}
</>