From c3df8b2d4539799c21c83aef6574c88b630ecb1a Mon Sep 17 00:00:00 2001 From: digital-garbage <133010426+digital-garbage@users.noreply.github.com> Date: Fri, 28 Aug 2026 19:29:57 +0300 Subject: [PATCH] composer: pin the edge panel properly Three bugs in wheel.half, all found by looking at it: The hub was centred on the edge, so the edge sliced it in half -- half a cancel target is not a target. It is pushed inward by its own radius now, and the wedges start beyond it. The ring is an inset shadow rather than a border, because a border pushes every absolutely positioned child inward by its own width, which put the hub a pixel past the screen edge. Highlighting fired anywhere the pointer went, including well outside the wheel, promising a pick that release would not deliver. The hit test is bounded by the same rIn/rOut the wedges are drawn from, so the lit shape is the region. An edge panel did not follow its edge: it kept the pixel position it opened at and drifted inward when the window grew. It re-derives its anchor on resize, and on visualViewport resize for pinch-zoom and mobile chrome. One GEOMETRY object per variant now drives the wedges, the labels, the hub and the hit test together, so they cannot disagree. --- app/composer/elements/wheel.css | 16 +-- app/composer/elements/wheel.js | 208 +++++++++++++++++++--------- app/composer/tests/overlays.test.js | 73 +++++++++- 3 files changed, 222 insertions(+), 75 deletions(-) diff --git a/app/composer/elements/wheel.css b/app/composer/elements/wheel.css index 57566d4f..f1f0849b 100644 --- a/app/composer/elements/wheel.css +++ b/app/composer/elements/wheel.css @@ -1,8 +1,10 @@ +/* The ring is an INSET shadow, not a border. A border pushes every absolutely + * positioned child inward by its own width, which put the hub a pixel past the + * screen edge on an edge panel -- the one place a pixel shows. */ .cx-wheel { position: fixed; pointer-events: auto; border-radius: var(--r-pill); background: color-mix(in srgb, var(--elev-3) 92%, transparent); - border: var(--bw) solid var(--line); - box-shadow: var(--shadow-3); + box-shadow: var(--shadow-3), inset 0 0 0 var(--bw) var(--line); backdrop-filter: blur(6px); animation: cx-wheel-in var(--dur-1) var(--ease-standard); } @@ -36,9 +38,11 @@ /* Sized to the dead zone exactly (radius 42), so the cancel target you can see * is the cancel target you can hit. */ +/* left/top/size come from the geometry so the drawn hub and the cancel region + * are the same circle. */ .cx-wheel-hub { - position: absolute; left: 50%; top: 50%; transform: translate(-50%, -50%); - width: 84px; height: 84px; border-radius: var(--r-pill); + position: absolute; transform: translate(-50%, -50%); + border-radius: var(--r-pill); border: var(--bw) dashed var(--line); display: flex; align-items: center; justify-content: center; color: var(--faint); @@ -56,7 +60,3 @@ * viewport, which is what gives the edge-panel silhouette. The flat side gets a * straight edge so it reads as pinned rather than cropped by accident. */ .cx-wheel-half { backdrop-filter: blur(8px); } -.cx-wheel-right { border-radius: var(--r-pill) 0 0 var(--r-pill); border-right: 0; } -.cx-wheel-left { border-radius: 0 var(--r-pill) var(--r-pill) 0; border-left: 0; } -.cx-wheel-bottom { border-radius: var(--r-pill) var(--r-pill) 0 0; border-bottom: 0; } -.cx-wheel-top { border-radius: 0 0 var(--r-pill) var(--r-pill); border-top: 0; } diff --git a/app/composer/elements/wheel.js b/app/composer/elements/wheel.js index d4d1fe72..a21d7de4 100644 --- a/app/composer/elements/wheel.js +++ b/app/composer/elements/wheel.js @@ -1,9 +1,13 @@ -// The radial picker. +// Radial pickers: the full wheel, and the half wheel pinned to a screen edge. // -// Nothing in v4 resembles this. It is for the handful of choices made over and -// over during editing, where a menu costs a read-and-aim every time: press, +// 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"; @@ -11,16 +15,83 @@ import { mount, unmount } from "../internals/portal.js"; import { claim } from "../internals/zlayer.js"; import { push } from "../internals/dismiss.js"; -const SIZE = 240; // diameter in px -const DEAD_ZONE = 42; // centre radius that means "cancel"; matches the hub exactly const MIN = 2; const MAX = 12; +/** The full circle, opened wherever the pointer is. */ +export const FULL = { + size: 240, + rIn: 42, rOut: 117, rLabel: 84, + 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. * @@ -28,10 +99,13 @@ const point = (cx, cy, radius, degrees) => { * 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, { - cx = SIZE / 2, cy = SIZE / 2, rIn = DEAD_ZONE, rOut = SIZE / 2 - 3, - startDeg = -(360 / count) / 2, spanDeg = 360, -} = {}) { +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; @@ -47,68 +121,42 @@ export function wedgePath(index, count, { `L ${f(x3)} ${f(y3)} A ${rIn} ${rIn} 0 ${largeArc} 0 ${f(x4)} ${f(y4)} Z`; } -/** - * Which sector of an arc a point falls in; -1 for the dead zone or outside it. - * - * 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 in - * the kit goes through here, so a full wheel and an edge panel cannot disagree - * about which item a direction means. - */ -export function sectorInArc(dx, dy, count, { startDeg = 0, spanDeg = 360, deadZone = DEAD_ZONE } = {}) { - if (Math.hypot(dx, dy) < deadZone) 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 = DEAD_ZONE) { - return sectorInArc(dx, dy, count, { startDeg: -(360 / count) / 2, spanDeg: 360, deadZone }); -} - -// Which half of the circle faces inward from each edge, and where a panel -// pinned there sits. An edge panel opens AWAY from its edge, which is the whole -// reason it works with a thumb: the arc lands where the thumb already is. -export const EDGES = { - right: { startDeg: 180, spanDeg: 180, anchor: (w, h, at) => [w, at ?? h / 2] }, - left: { startDeg: 0, spanDeg: 180, anchor: (w, h, at) => [0, at ?? h / 2] }, - bottom: { startDeg: 270, spanDeg: 180, anchor: (w, h, at) => [at ?? w / 2, h] }, - top: { startDeg: 90, spanDeg: 180, anchor: (w, h, at) => [at ?? w / 2, 0] }, -}; - -function buildWheel({ - items, onPick, onClose, centre, arc, cancelOnCentre, cls, hubText, -}) { +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 layer = claim("popover"); + const centre = recentre(); + const node = el("div", { cls, attrs: { role: "menu", "aria-label": "Quick pick" } }); node.style.zIndex = String(layer.z); - node.style.left = `${centre.x - SIZE / 2}px`; - node.style.top = `${centre.y - SIZE / 2}px`; - node.style.width = `${SIZE}px`; - node.style.height = `${SIZE}px`; + 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 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, arc) }); + const path = svg("path", { class: "cx-wheel-wedge", d: wedgePath(i, items.length, geo) }); canvas.append(path); return path; }); node.append(canvas); - const slice = arc.spanDeg / items.length; + const slice = geo.spanDeg / items.length; const wedges = items.map((item, i) => { - const angle = arc.startDeg + slice * (i + 0.5); + 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 }, @@ -119,10 +167,9 @@ function buildWheel({ }); // 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 radius = SIZE / 2 - 34; - const rad = (angle - 90) * (Math.PI / 180); - wedge.style.left = `${SIZE / 2 + Math.cos(rad) * radius}px`; - wedge.style.top = `${SIZE / 2 + Math.sin(rad) * radius}px`; + 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; @@ -135,6 +182,10 @@ function buildWheel({ 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`; node.append(hub); function highlight(index) { @@ -161,7 +212,7 @@ function buildWheel({ const at = (event) => sectorInArc( event.clientX - centre.x, event.clientY - centre.y, items.length, - { ...arc, deadZone: cancelOnCentre ? DEAD_ZONE : 0 }); + { ...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) => { @@ -171,6 +222,19 @@ function buildWheel({ if (index >= 0) commit(index); }; + // 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)) { @@ -197,6 +261,7 @@ function buildWheel({ node, isOverlay: true, get active() { return active; }, + get centre() { return { ...centre }; }, highlight, close(reason = "manual") { if (closed) return; @@ -204,6 +269,8 @@ function buildWheel({ 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); @@ -219,11 +286,11 @@ define("wheel", "picker", ({ items = [], onPick, onClose, x, y, cancelOnCentre = items, onPick, onClose, cancelOnCentre, cls: "cx-wheel", hubText: "Release here to cancel", - centre: { + 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), - }, - arc: { startDeg: -(360 / Math.max(items.length, 1)) / 2, spanDeg: 360 }, + }), })); /** @@ -235,17 +302,30 @@ define("wheel", "picker", ({ items = [], onPick, onClose, x, y, cancelOnCentre = * 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. + * 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 [cx, cy] = spec.anchor(window.innerWidth, window.innerHeight, at); + + 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", - centre: { x: cx, y: cy }, - arc: { startDeg: spec.startDeg, spanDeg: spec.spanDeg }, + geo, + recentre: () => { + const [cx, cy] = spec.anchor(window.innerWidth, window.innerHeight, at); + return { x: cx, y: cy }; + }, }); }); diff --git a/app/composer/tests/overlays.test.js b/app/composer/tests/overlays.test.js index 039f2b97..1f574be1 100644 --- a/app/composer/tests/overlays.test.js +++ b/app/composer/tests/overlays.test.js @@ -275,16 +275,31 @@ test("the click that opens the wheel does not also pick", () => { onPick: (item) => { picked = item.label; }, x: 400, y: 300, }); - window.dispatchEvent(new window.PointerEvent("pointerup", { clientX: 400, clientY: 100, bubbles: true })); + // 80px straight up from the centre: inside the ring, pointing at item 0. + window.dispatchEvent(new window.PointerEvent("pointerup", { clientX: 400, clientY: 220, bubbles: true })); assert.equal(picked, null, "a release with no movement is not a pick"); assert.ok(portalRoot().childElementCount > 0, "the wheel is still open"); - window.dispatchEvent(new window.PointerEvent("pointermove", { clientX: 400, clientY: 100, bubbles: true })); - window.dispatchEvent(new window.PointerEvent("pointerup", { clientX: 400, clientY: 100, bubbles: true })); + window.dispatchEvent(new window.PointerEvent("pointermove", { clientX: 400, clientY: 220, bubbles: true })); + window.dispatchEvent(new window.PointerEvent("pointerup", { clientX: 400, clientY: 220, bubbles: true })); assert.equal(picked, "Split", "after moving, a release picks"); w.destroy(); }); +test("a pointer beyond the rim points at nothing", () => { + // The lit wedge IS the region that picks the item. Highlighting one from + // outside the ring promises a pick that release would not deliver. + const w = composer.wheel.picker({ + items: [{ label: "Split" }, { label: "Duplicate" }, { label: "Rate" }], + x: 400, y: 300, + }); + window.dispatchEvent(new window.PointerEvent("pointermove", { clientX: 400, clientY: 60, bubbles: true })); + assert.equal(w.active, -1, "240px out is off the wheel"); + window.dispatchEvent(new window.PointerEvent("pointermove", { clientX: 400, clientY: 220, bubbles: true })); + assert.equal(w.active, 0, "80px out is on it"); + w.destroy(); +}); + test("releasing in the dead zone cancels without picking", () => { let picked = null; const w = composer.wheel.picker({ @@ -341,6 +356,58 @@ test("a half wheel opens on its edge and picks by key", () => { w.destroy(); }); +test("an edge panel's hub sits fully on screen, not sliced by the edge", () => { + // A hub centred on the edge is cut in half by it, and half a cancel target is + // not a target. It is pushed inward by its own radius. + const w = composer.wheel.half({ edge: "right", at: 300, items: [{ label: "a" }, { label: "b" }] }); + const hub = w.node.querySelector(".cx-wheel-hub"); + const size = Number(w.node.style.width.replace("px", "")); + assert.ok(parseFloat(hub.style.left) < size / 2, "the hub leans into the screen"); + const radius = parseFloat(hub.style.width) / 2; + assert.ok(parseFloat(hub.style.left) + radius <= size / 2 + 0.01, + "and its far edge does not cross the screen edge"); + w.destroy(); +}); + +test("cancel is where the hub actually is, not where the centre is", () => { + const spec = EDGES.right; + const hubR = 40; + const arc = { ...spec, rIn: 84, rOut: 147, hubX: -hubR, hubY: 0, hubR }; + // A point over the drawn hub cancels... + assert.equal(sectorInArc(-hubR, 0, 4, arc), -1); + // ...while the geometric centre, which is now outside the hub, is simply off + // the ring rather than a pick. + assert.equal(sectorInArc(0, 0, 4, arc), -1); +}); + +test("an edge panel follows its edge when the window resizes", () => { + // It is pinned to an edge; the edge moving is the one thing it must follow. + const w = composer.wheel.half({ edge: "right", items: [{ label: "a" }, { label: "b" }] }); + const before = w.centre.x; + + window.innerWidth = window.innerWidth + 400; + window.dispatchEvent(new window.Event("resize")); + + assert.equal(w.centre.x, before + 400, "the centre moved with the edge"); + const size = Number(w.node.style.width.replace("px", "")); + assert.equal(parseFloat(w.node.style.left), w.centre.x - size / 2); + window.innerWidth = window.innerWidth - 400; + w.destroy(); +}); + +test("edge panels stay pinned at any viewport shape", () => { + for (const [w, h] of [[320, 640], [1920, 1080], [2560, 1080], [800, 2000]]) { + window.innerWidth = w; + window.innerHeight = h; + for (const edge of Object.keys(EDGES)) { + const p = composer.wheel.half({ edge, items: [{ label: "a" }, { label: "b" }, { label: "c" }] }); + const [ex, ey] = EDGES[edge].anchor(w, h); + assert.deepEqual(p.centre, { x: ex, y: ey }, `${edge} at ${w}x${h}`); + p.destroy(); + } + } +}); + test("an unknown edge is refused rather than guessed", () => { assert.throws(() => composer.wheel.half({ edge: "diagonal", items: [{ label: "a" }, { label: "b" }] }), RangeError); });