Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
333 lines
13 KiB
JavaScript
333 lines
13 KiB
JavaScript
// Radial pickers: the full wheel, and the half wheel pinned to a screen edge.
|
|
//
|
|
// Nothing in v4 resembles these. They are for the handful of choices made over
|
|
// and over during editing, where a menu costs a read-and-aim every time: press,
|
|
// flick toward the wedge you already know the position of, release. The muscle
|
|
// memory is the feature -- the same choice always lives at the same angle.
|
|
//
|
|
// One GEOMETRY object per variant drives the drawn wedges, the label placement,
|
|
// the hub and the hit test, so what you see and what you can hit cannot drift
|
|
// apart.
|
|
|
|
import { define } from "../internals/register.js";
|
|
import { el, svg } from "../internals/el.js";
|
|
import { mount, unmount } from "../internals/portal.js";
|
|
import { claimFor } from "../internals/zlayer.js";
|
|
import { push } from "../internals/dismiss.js";
|
|
|
|
const MIN = 2;
|
|
const MAX = 12;
|
|
|
|
/** The full circle, opened wherever the pointer is. */
|
|
export const FULL = {
|
|
size: 240,
|
|
rIn: 42, rOut: 117, rLabel: 80,
|
|
hub: { r: 42, x: 0, y: 0 },
|
|
};
|
|
|
|
/**
|
|
* Half a circle, centred ON an edge.
|
|
*
|
|
* The hub is pushed inward by its own radius so it sits fully on screen -- a hub
|
|
* centred on the edge is sliced in half by it, and half a cancel target is not
|
|
* a target. Everything else is sized around that: the wedges start beyond the
|
|
* hub so the two never overlap.
|
|
*/
|
|
export const HALF = {
|
|
size: 300,
|
|
rIn: 84, rOut: 147, rLabel: 115,
|
|
hub: { r: 40, x: 0, y: 0 }, // x/y filled in per edge, below
|
|
};
|
|
|
|
// Which half faces inward from each edge, where a panel pinned there sits, and
|
|
// which way "inward" points. An edge panel opens AWAY from its edge, which is
|
|
// the whole reason it works with a thumb: the arc lands where the thumb is.
|
|
export const EDGES = {
|
|
right: { startDeg: 180, spanDeg: 180, inward: [-1, 0], anchor: (w, h, at) => [w, at ?? h / 2] },
|
|
left: { startDeg: 0, spanDeg: 180, inward: [1, 0], anchor: (w, h, at) => [0, at ?? h / 2] },
|
|
bottom: { startDeg: 270, spanDeg: 180, inward: [0, -1], anchor: (w, h, at) => [at ?? w / 2, h] },
|
|
top: { startDeg: 90, spanDeg: 180, inward: [0, 1], anchor: (w, h, at) => [at ?? w / 2, 0] },
|
|
};
|
|
|
|
const point = (cx, cy, radius, degrees) => {
|
|
const rad = (degrees - 90) * (Math.PI / 180);
|
|
return [cx + Math.cos(rad) * radius, cy + Math.sin(rad) * radius];
|
|
};
|
|
|
|
/**
|
|
* Which sector of an arc a point falls in; -1 for the hub or anywhere off the
|
|
* ring.
|
|
*
|
|
* Angles run clockwise from straight up, so 0 is 12 o'clock and the arrangement
|
|
* matches how people describe it ("the one at the top"). Everything radial goes
|
|
* through here, so a full wheel and an edge panel cannot disagree about which
|
|
* item a direction means.
|
|
*
|
|
* The bounds matter as much as the angle: the lit wedge IS the region that
|
|
* picks the item, so a pointer beyond the rim -- or over the hub -- is pointing
|
|
* at nothing, and highlighting an item there would promise a pick that release
|
|
* would not deliver.
|
|
*/
|
|
export function sectorInArc(dx, dy, count, {
|
|
startDeg = 0, spanDeg = 360,
|
|
rIn = FULL.rIn, rOut = FULL.rOut,
|
|
hubX = 0, hubY = 0, hubR = rIn,
|
|
bounded = true,
|
|
} = {}) {
|
|
if (Math.hypot(dx - hubX, dy - hubY) < hubR) return -1; // over the hub: cancel
|
|
const distance = Math.hypot(dx, dy);
|
|
if (bounded && (distance < rIn || distance > rOut)) return -1;
|
|
|
|
const angle = ((Math.atan2(dx, -dy) * 180) / Math.PI + 360) % 360;
|
|
const rel = (angle - startDeg + 720) % 360;
|
|
if (rel > spanDeg) return -1; // outside a partial arc
|
|
return Math.min(Math.floor(rel / (spanDeg / count)), count - 1);
|
|
}
|
|
|
|
/** The full circle: item 0 centred at the top. */
|
|
export function sectorAt(dx, dy, count, deadZone = FULL.hub.r) {
|
|
return sectorInArc(dx, dy, count, {
|
|
startDeg: -(360 / count) / 2, spanDeg: 360,
|
|
rIn: deadZone, rOut: FULL.rOut, hubR: deadZone,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* An annular wedge: the actual area that picks this item.
|
|
*
|
|
* Highlighting the wedge rather than a box around the label is what makes the
|
|
* target legible -- the lit shape IS the region your pointer has to be in, so
|
|
* aiming stops being a guess about where one item's zone ends.
|
|
*/
|
|
export function wedgePath(index, count, geo = {}) {
|
|
const {
|
|
size = FULL.size, rIn = FULL.rIn, rOut = FULL.rOut,
|
|
startDeg = -(360 / count) / 2, spanDeg = 360,
|
|
} = geo;
|
|
const cx = size / 2;
|
|
const cy = size / 2;
|
|
const slice = spanDeg / count;
|
|
const start = startDeg + index * slice;
|
|
const end = start + slice;
|
|
const largeArc = slice > 180 ? 1 : 0;
|
|
|
|
const [x1, y1] = point(cx, cy, rOut, start);
|
|
const [x2, y2] = point(cx, cy, rOut, end);
|
|
const [x3, y3] = point(cx, cy, rIn, end);
|
|
const [x4, y4] = point(cx, cy, rIn, start);
|
|
|
|
const f = (n) => Number(n.toFixed(2));
|
|
return `M ${f(x1)} ${f(y1)} A ${rOut} ${rOut} 0 ${largeArc} 1 ${f(x2)} ${f(y2)} ` +
|
|
`L ${f(x3)} ${f(y3)} A ${rIn} ${rIn} 0 ${largeArc} 0 ${f(x4)} ${f(y4)} Z`;
|
|
}
|
|
|
|
function buildWheel({ items, onPick, onClose, geo, recentre, cancelOnCentre, cls, hubText }) {
|
|
if (items.length < MIN || items.length > MAX) {
|
|
throw new RangeError(`A picker takes ${MIN}-${MAX} items; got ${items.length}. More than that and nobody can aim.`);
|
|
}
|
|
|
|
const { size } = geo;
|
|
const centre = recentre();
|
|
|
|
const node = el("div", { cls, attrs: { role: "menu", "aria-label": "Quick pick" } });
|
|
const layer = claimFor("popover", node);
|
|
node.style.width = `${size}px`;
|
|
node.style.height = `${size}px`;
|
|
|
|
const place = () => {
|
|
node.style.left = `${centre.x - size / 2}px`;
|
|
node.style.top = `${centre.y - size / 2}px`;
|
|
};
|
|
place();
|
|
|
|
let active = -1;
|
|
|
|
// The wedges are drawn behind the labels, so the highlight is the pickable
|
|
// region itself rather than a box around some text.
|
|
const canvas = svg("svg", { class: "cx-wheel-canvas", viewBox: `0 0 ${size} ${size}`,
|
|
width: size, height: size, "aria-hidden": "true" });
|
|
const shapes = items.map((_, i) => {
|
|
const path = svg("path", { class: "cx-wheel-wedge", d: wedgePath(i, items.length, geo) });
|
|
canvas.append(path);
|
|
return path;
|
|
});
|
|
node.append(canvas);
|
|
|
|
const slice = geo.spanDeg / items.length;
|
|
const wedges = items.map((item, i) => {
|
|
const angle = geo.startDeg + slice * (i + 0.5);
|
|
const wedge = el("button", {
|
|
cls: ["cx-wheel-item", "cx-focusable"],
|
|
attrs: { type: "button", role: "menuitem", title: item.label },
|
|
children: [
|
|
item.icon ? el("span", { cls: "cx-wheel-icon", text: item.icon, attrs: { "aria-hidden": "true" } }) : null,
|
|
el("span", { cls: "cx-wheel-label", text: item.label }),
|
|
].filter(Boolean),
|
|
});
|
|
// Placed on a circle rather than drawn as a pie slice: a round label that
|
|
// stays upright is far easier to read than text bent around a wedge.
|
|
const [lx, ly] = point(size / 2, size / 2, geo.rLabel, angle);
|
|
wedge.style.left = `${lx}px`;
|
|
wedge.style.top = `${ly}px`;
|
|
wedge.addEventListener("click", () => commit(i));
|
|
node.append(wedge);
|
|
return wedge;
|
|
});
|
|
|
|
// A glyph, not a sentence: the hub is small and round, and wrapped text in a
|
|
// circle reads badly at any size. The label survives as the accessible name.
|
|
const hub = el("div", {
|
|
cls: "cx-wheel-hub",
|
|
attrs: cancelOnCentre ? { role: "img", "aria-label": hubText, title: hubText } : { "aria-hidden": "true" },
|
|
children: cancelOnCentre ? el("span", { cls: "cx-wheel-hub-glyph", text: "✕" }) : null,
|
|
});
|
|
hub.style.width = `${geo.hub.r * 2}px`;
|
|
hub.style.height = `${geo.hub.r * 2}px`;
|
|
hub.style.left = `${size / 2 + geo.hub.x}px`;
|
|
hub.style.top = `${size / 2 + geo.hub.y}px`;
|
|
if (cancelOnCentre) hub.addEventListener("click", () => commit(-1)); // the cross is a button too, not only a release target
|
|
node.append(hub);
|
|
|
|
function highlight(index) {
|
|
active = index;
|
|
wedges.forEach((w, i) => w.classList.toggle("cx-on", i === index));
|
|
shapes.forEach((sh, i) => sh.classList.toggle("cx-on", i === index));
|
|
hub.classList.toggle("cx-on", index === -1);
|
|
}
|
|
|
|
function commit(index) {
|
|
if (index >= 0 && items[index] && onPick) onPick(items[index]);
|
|
handle.close(index >= 0 ? "picked" : "cancelled");
|
|
}
|
|
|
|
// Press-and-flick: the pointer never has to land on the item, only point at
|
|
// it, which is what makes this faster than a menu.
|
|
//
|
|
// `armed` exists because the click that OPENS the wheel also delivers a
|
|
// pointerup to this listener, at wherever the opening control happened to be
|
|
// -- which committed whatever wedge that direction pointed at, instantly.
|
|
// Requiring a pointermove first means a release only counts once the pointer
|
|
// has actually gone somewhere.
|
|
let armed = false;
|
|
|
|
const at = (event) => sectorInArc(
|
|
event.clientX - centre.x, event.clientY - centre.y, items.length,
|
|
{ ...geo, hubX: geo.hub.x, hubY: geo.hub.y, hubR: cancelOnCentre ? geo.hub.r : 0 });
|
|
|
|
const onMove = (event) => { armed = true; highlight(at(event)); };
|
|
const onUp = (event) => {
|
|
if (!armed) return;
|
|
armed = false;
|
|
const index = at(event);
|
|
if (index >= 0) commit(index);
|
|
else if (cancelOnCentre && Math.hypot(event.clientX - centre.x - geo.hub.x, event.clientY - centre.y - geo.hub.y) < geo.hub.r) commit(-1); // let go over the cross
|
|
};
|
|
|
|
// An edge panel is pinned to an edge, so the edge moving is the one thing it
|
|
// must follow. Without this it keeps the pixel position it opened at and
|
|
// drifts into the middle of the window on a resize.
|
|
const onResize = () => {
|
|
Object.assign(centre, recentre());
|
|
place();
|
|
};
|
|
window.addEventListener("resize", onResize);
|
|
// visualViewport covers what window.resize misses: pinch-zoom, and mobile
|
|
// browser chrome sliding in and out, both of which move the edge without
|
|
// changing innerWidth.
|
|
window.visualViewport?.addEventListener("resize", onResize);
|
|
|
|
const onKeyDown = (event) => {
|
|
const n = items.length;
|
|
if (/^[1-9]$/.test(event.key)) {
|
|
const i = Number(event.key) - 1;
|
|
if (i < n) { event.preventDefault(); commit(i); }
|
|
} else if (event.key === "ArrowRight" || event.key === "ArrowDown") {
|
|
event.preventDefault(); highlight(((active < 0 ? -1 : active) + 1 + n) % n);
|
|
} else if (event.key === "ArrowLeft" || event.key === "ArrowUp") {
|
|
event.preventDefault(); highlight(((active < 0 ? 1 : active) - 1 + n) % n);
|
|
} else if (event.key === "Enter" && active >= 0) {
|
|
event.preventDefault(); commit(active);
|
|
}
|
|
};
|
|
|
|
mount(node);
|
|
window.addEventListener("pointermove", onMove);
|
|
window.addEventListener("pointerup", onUp);
|
|
window.addEventListener("keydown", onKeyDown);
|
|
|
|
const dismissal = push({ nodes: node, onDismiss: (reason) => handle.close(reason) });
|
|
|
|
let closed = false;
|
|
const handle = {
|
|
node,
|
|
isOverlay: true,
|
|
get active() { return active; },
|
|
get centre() { return { ...centre }; },
|
|
highlight,
|
|
close(reason = "manual") {
|
|
if (closed) return;
|
|
closed = true;
|
|
window.removeEventListener("pointermove", onMove);
|
|
window.removeEventListener("pointerup", onUp);
|
|
window.removeEventListener("keydown", onKeyDown);
|
|
window.removeEventListener("resize", onResize);
|
|
window.visualViewport?.removeEventListener("resize", onResize);
|
|
dismissal.release();
|
|
layer.release();
|
|
unmount(node);
|
|
if (onClose) onClose(reason);
|
|
},
|
|
destroy() { handle.close("destroy"); },
|
|
};
|
|
return handle;
|
|
}
|
|
|
|
define("wheel", "picker", ({ items = [], onPick, onClose, x, y, cancelOnCentre = true } = {}) =>
|
|
buildWheel({
|
|
items, onPick, onClose, cancelOnCentre,
|
|
cls: "cx-wheel",
|
|
hubText: "Release here to cancel",
|
|
geo: { ...FULL, startDeg: -(360 / Math.max(items.length, 1)) / 2, spanDeg: 360 },
|
|
recentre: () => ({
|
|
x: x ?? Math.round(window.innerWidth / 2),
|
|
y: y ?? Math.round(window.innerHeight / 2),
|
|
}),
|
|
}));
|
|
|
|
/**
|
|
* Half a wheel, pinned to a screen edge -- the Samsung edge-panel shape.
|
|
*
|
|
* A control living at the edge has nowhere to open a full wheel: half of it
|
|
* would be off-screen, and the items that landed there would be unreachable.
|
|
* This puts the centre ON the edge and fans the items into the screen, so the
|
|
* arc lands exactly where the thumb already is and every item is in reach.
|
|
*
|
|
* `at` is the position along that edge (y for left/right, x for top/bottom);
|
|
* it defaults to the middle, and is re-derived whenever the window resizes.
|
|
*/
|
|
define("wheel", "half", ({ items = [], edge = "right", at, onPick, onClose, cancelOnCentre = true } = {}) => {
|
|
const spec = EDGES[edge];
|
|
if (!spec) throw new RangeError(`Unknown edge "${edge}". Known: ${Object.keys(EDGES).join(", ")}.`);
|
|
|
|
const [ix, iy] = spec.inward;
|
|
const geo = {
|
|
...HALF,
|
|
startDeg: spec.startDeg,
|
|
spanDeg: spec.spanDeg,
|
|
// Pushed inward by its own radius, so the whole hub is on screen instead of
|
|
// being sliced down the middle by the edge it sits on.
|
|
hub: { r: HALF.hub.r, x: ix * HALF.hub.r, y: iy * HALF.hub.r },
|
|
};
|
|
|
|
return buildWheel({
|
|
items, onPick, onClose, cancelOnCentre,
|
|
cls: `cx-wheel cx-wheel-half cx-wheel-${edge}`,
|
|
hubText: "Release here to cancel",
|
|
geo,
|
|
recentre: () => {
|
|
const [cx, cy] = spec.anchor(window.innerWidth, window.innerHeight, at);
|
|
return { x: cx, y: cy };
|
|
},
|
|
});
|
|
});
|