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
+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>
|
||||
|
||||
Reference in new issue
Block a user