Place a peg's pivot with ⌃/⌘ rather than ⌥

⌥-drag does not survive the trip. Most Linux window managers grab Alt-drag to
move the window, so the page never sees the pointer and the gesture is simply
absent — on the machine it is absent from, with no error and nothing to find.
Reported from one.

⌃ (⌘ on a Mac) now places the pivot, which is the modifier the rest of this
stage already reaches for. ⌥ keeps working for anyone whose desktop leaves it
alone; ⇧ is deliberately not it, since it means CONSTRAIN everywhere else here —
uniform scale, 15° turn steps — and is what a snap to the child's corners will
want when this drag grows one.

The menu row says so, because a modifier nothing mentions is a modifier nobody
finds: "add peg · ⌃/⌘-drag its cross to place the pivot".

`peg.mjs` now asserts each modifier through real pointer events instead of
reading the handler, which is the only way this class of bug shows up — all
three place the pivot with a child drift of 0, and an unmodified drag still
translates.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HkinzDz1VtahZVsujAGRBD
This commit is contained in:
Your Name 2026-10-05 21:48:59 -04:00
parent 357ff1f3b9
commit 39c436584b
3 changed files with 93 additions and 39 deletions

View file

@ -325,9 +325,20 @@
;; to 8.8 from the middle, so the disc has to stop short of 5.2. ;; to 8.8 from the middle, so the disc has to stop short of 5.2.
[:circle.peg-grab [:circle.peg-grab
{:cx px :cy py :r 5 {:cx px :cy py :r 5
;; ⌥ moves the PIVOT instead of the peg, which is the pan-behind ;; ⌃/⌘ moves the PIVOT instead of the peg, which is the pan-behind
;; split: a plain drag writes `pos` and carries the children with ;; split: a plain drag writes `pos` and carries the children with
;; it, and that is a translate, not a new pivot. ;; it, and that is a translate, not a new pivot.
;;
;; NOT ⌥, which this was and which does not survive the trip. Most
;; Linux window managers grab Alt-drag to move the window, so the
;; page never sees the pointer at all and the gesture is simply
;; missing — on the machine it is missing from, with no error. ⌥ is
;; still accepted for anyone whose desktop leaves it alone, and the
;; command key is the one the rest of this stage already reaches for.
;;
;; ⇧ is deliberately NOT it: it means CONSTRAIN everywhere else
;; here — uniform scale, 15° turn steps — and it is what a snap to
;; the child's corners would want when this drag grows one.
:on-pointer-down :on-pointer-down
(fn [^js event] (fn [^js event]
(.stopPropagation event) (.stopPropagation event)
@ -335,7 +346,7 @@
(let [svg (.-ownerSVGElement (.-currentTarget event)) (let [svg (.-ownerSVGElement (.-currentTarget event))
p (xy svg event (:w ctx) (:h ctx))] p (xy svg event (:w ctx) (:h ctx))]
(.setPointerCapture svg (.-pointerId event)) (.setPointerCapture svg (.-pointerId event))
(if (.-altKey event) (if (or (.-ctrlKey event) (.-metaKey event) (.-altKey event))
(reset! repivot {:path path :p p}) (reset! repivot {:path path :p p})
(begin! ctx :move path p))))}] (begin! ctx :move path p))))}]
[:line.knob-arm {:x1 px :y1 (- py r) :x2 px :y2 (- py r 8)}] [:line.knob-arm {:x1 px :y1 (- py r) :x2 px :y2 (- py r 8)}]

View file

@ -95,7 +95,7 @@
;; measured part by hand — which is what `gesture/refusal` tells ;; measured part by hand — which is what `gesture/refusal` tells
;; you to do, so it has to be reachable from somewhere. ;; you to do, so it has to be reachable from somewhere.
{:label "add peg" {:label "add peg"
:sub "A transform node over the selection, to pivot, turn and key from" :sub "A transform node over the selection · ⌃/⌘-drag its cross to place the pivot"
:disabled? (nil? one-node) :disabled? (nil? one-node)
:on-click #(rf/dispatch [::ui/peg one-node])}]}] :on-click #(rf/dispatch [::ui/peg one-node])}]}]
[snapshots/view] [snapshots/view]

View file

@ -201,45 +201,88 @@ try {
String(dom2.cornerHit)); String(dom2.cornerHit));
if (dom2.grab) { if (dom2.grab) {
const before2 = await evalJS(` // Every modifier that has to place the pivot. CDP modifiers: 1=alt, 2=ctrl,
const c = cljs.core, k = c.keyword, v = c.vector; // 4=meta. ⌥ alone was the whole gesture once, and on a desktop whose window
const db = c.deref(re_frame.db.app_db); // manager grabs Alt-drag the page never sees it — so each is asserted
const e = arthur.footage.store.entry(c.get(db, k('clip/current'))); // through real pointer events rather than by reading the handler.
const doc = c.get(e, k('clip'));
const res = arthur.domain.clip.resolver(doc, k('main'), c.get(e,k('store')),
arthur.domain.palette.index_of, null);
return Array.from(c.get(c.first(res.call(null, 0)), k('pts')));
`);
const { x, y } = dom2.grab; const { x, y } = dom2.grab;
const steps = [['mousePressed', 0, 0], ['mouseMoved', -40, 25], ['mouseMoved', -80, 50], let after2;
['mouseReleased', -80, 50]]; for (const [name, modifiers, sign] of [['alt', 1, -1], ['ctrl', 2, 1], ['meta', 4, -1]]) {
for (const [type, dx, dy] of steps) { const pre = await evalJS(`
await send('Input.dispatchMouseEvent', { const c = cljs.core, k = c.keyword, v = c.vector;
type, x: x + dx, y: y + dy, button: 'left', buttons: 1, clickCount: 1, const db = c.deref(re_frame.db.app_db);
pointerType: 'mouse', modifiers: 1, // alt const e = arthur.footage.store.entry(c.get(db, k('clip/current')));
}); const doc = c.get(e, k('clip'));
await sleep(90); const nodes = c.get_in(doc, v(k('symbols'), k('main'), k('nodes')));
const pegId = c.get(c.get(nodes, k('shape')), k('parent'));
const res = arthur.domain.clip.resolver(doc, k('main'), c.get(e,k('store')),
arthur.domain.palette.index_of, null);
return {pts: Array.from(c.get(c.first(res.call(null, 0)), k('pts'))),
pos: c.clj__GT_js(c.get_in(nodes, v(pegId, k('channels'), v(k('xform'),k('pos')), k('value'))))};
`);
// Where the cross is NOW, since the previous modifier already moved it.
const g = await evalJS(`
const q = document.querySelector('.paint-overlay .handles .peg-grab');
const r = q && q.getBoundingClientRect();
return r ? {x: r.x + r.width/2, y: r.y + r.height/2} : null;
`);
const dx = 30 * sign, dy = 20 * sign;
for (const [type, fx, fy] of [['mousePressed', 0, 0], ['mouseMoved', dx/2, dy/2],
['mouseMoved', dx, dy], ['mouseReleased', dx, dy]]) {
await send('Input.dispatchMouseEvent', {
type, x: g.x + fx, y: g.y + fy, button: 'left', buttons: 1, clickCount: 1,
pointerType: 'mouse', modifiers,
});
await sleep(90);
}
await sleep(450);
const post = await evalJS(`
const c = cljs.core, k = c.keyword, v = c.vector;
const db = c.deref(re_frame.db.app_db);
const e = arthur.footage.store.entry(c.get(db, k('clip/current')));
const doc = c.get(e, k('clip'));
const nodes = c.get_in(doc, v(k('symbols'), k('main'), k('nodes')));
const pegId = c.get(c.get(nodes, k('shape')), k('parent'));
const res = arthur.domain.clip.resolver(doc, k('main'), c.get(e,k('store')),
arthur.domain.palette.index_of, null);
return {pts: Array.from(c.get(c.first(res.call(null, 0)), k('pts'))),
pos: c.clj__GT_js(c.get_in(nodes, v(pegId, k('channels'), v(k('xform'),k('pos')), k('value'))))};
`);
const drift = Math.max(...pre.pts.map((b, i) => Math.abs(b - post.pts[i])));
const moved = Math.hypot(post.pos[0] - pre.pos[0], post.pos[1] - pre.pos[1]);
ok(drift < 1e-6, `${name}-drag places the pivot and moves NOTHING under the peg`,
`drift ${drift}`);
ok(moved > 2, `${name}-drag actually moved the pivot`, `by ${moved.toFixed(2)}`);
after2 = post;
}
// And a plain drag still translates, which is the other half of the split.
{
const g = await evalJS(`
const q = document.querySelector('.paint-overlay .handles .peg-grab');
const r = q && q.getBoundingClientRect();
return r ? {x: r.x + r.width/2, y: r.y + r.height/2} : null;
`);
const pre = after2;
for (const [type, fx] of [['mousePressed', 0], ['mouseMoved', 12], ['mouseReleased', 12]]) {
await send('Input.dispatchMouseEvent', {
type, x: g.x + fx, y: g.y, button: 'left', buttons: 1, clickCount: 1, pointerType: 'mouse',
});
await sleep(90);
}
await sleep(450);
const post = await evalJS(`
const c = cljs.core, k = c.keyword, v = c.vector;
const db = c.deref(re_frame.db.app_db);
const e = arthur.footage.store.entry(c.get(db, k('clip/current')));
const res = arthur.domain.clip.resolver(c.get(e, k('clip')), k('main'), c.get(e,k('store')),
arthur.domain.palette.index_of, null);
return Array.from(c.get(c.first(res.call(null, 0)), k('pts')));
`);
ok(Math.abs(post[0] - pre.pts[0]) > 2, 'and an UNMODIFIED drag still translates the child',
`dx=${(post[0] - pre.pts[0]).toFixed(2)}`);
} }
await sleep(500);
const after2 = await evalJS(`
const c = cljs.core, k = c.keyword, v = c.vector;
const db = c.deref(re_frame.db.app_db);
const e = arthur.footage.store.entry(c.get(db, k('clip/current')));
const doc = c.get(e, k('clip'));
const nodes = c.get_in(doc, v(k('symbols'), k('main'), k('nodes')));
const pegId = c.get(c.get(nodes, k('shape')), k('parent'));
const res = arthur.domain.clip.resolver(doc, k('main'), c.get(e,k('store')),
arthur.domain.palette.index_of, null);
return {pos: c.clj__GT_js(c.get_in(nodes, v(pegId, k('channels'), v(k('xform'),k('pos')), k('value')))),
pts: Array.from(c.get(c.first(res.call(null, 0)), k('pts'))),
refusal: c.clj__GT_js(c.get_in(db, v(k('ui'), k('refusal'))))};
`);
const drift = Math.max(...before2.map((b, i) => Math.abs(b - after2.pts[i])));
console.log('after alt-drag: peg pos', JSON.stringify(after2.pos), 'child drift', drift);
ok(drift < 1e-6, 'alt-dragging the cross moved NOTHING under the peg', `drift ${drift}`);
ok(Math.abs(after2.pos[0] - made.pegPos[0]) > 2, 'but the pivot did move',
`${JSON.stringify(made.pegPos)} -> ${JSON.stringify(after2.pos)}`);
} }
console.log(failures ? `\n${failures} FAILED` : '\nPASS: a peg can be made, moved, and have its pivot placed'); console.log(failures ? `\n${failures} FAILED` : '\nPASS: a peg can be made, moved, and have its pivot placed');
} finally { } finally {
if (ws) ws.close(); if (ws) ws.close();