Select, move, turn and scale on the stage, at any depth
A click selects the thing in the open symbol, a double-click goes one level in, ⌘-click goes to the shape itself and Esc comes back out — Figma's rule — and a click inside the selection keeps it, so a shape several symbols down can be dragged. The selection is the one a timeline row makes, so the inspector shows it and its row opens and scrolls into view. A drag writes what the inspector writes: a key on the node's own frame where the channel has keys, its one value where it has none. It is previewed like a bar being slid and let go as one edit, so one undo step. Measured transforms refuse. A shape's points are edited by double-clicking it, and new shapes turn about their middle. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
2a0426707a
commit
550cfe91e5
12 changed files with 584 additions and 23 deletions
123
frontend/test/arthur/domain/gesture_test.cljs
Normal file
123
frontend/test/arthur/domain/gesture_test.cljs
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
(ns arthur.domain.gesture-test
|
||||
(:require [cljs.test :refer [deftest is testing]]
|
||||
[arthur.domain.channel :as ch]
|
||||
[arthur.domain.clip :as clip]
|
||||
[arthur.domain.gesture :as gesture]
|
||||
[arthur.domain.nest :as nest]
|
||||
[arthur.domain.node :as node]
|
||||
[arthur.domain.paint :as paint]
|
||||
[arthur.domain.palette :as pal]
|
||||
[arthur.domain.pick :as pick]))
|
||||
|
||||
(def ^:private u #uuid "00000000-0000-4000-8000-0000000000e1")
|
||||
(def ^:private v #uuid "00000000-0000-4000-8000-0000000000e2")
|
||||
|
||||
(defn- two-down
|
||||
"A shape inside :box, placed turned and scaled unevenly in :mid, placed turned
|
||||
and doubled in :main — so nothing lines up by accident."
|
||||
[]
|
||||
(let [turn (fn [c host id pos rot k]
|
||||
(update-in c [:symbols host :nodes id :channels] merge
|
||||
{[:xform :pos] (ch/framed pos)
|
||||
[:xform :rot] (ch/framed rot)
|
||||
[:xform :scale] (ch/framed k)}))]
|
||||
(-> (clip/blank)
|
||||
(assoc-in [:symbols :mid] {:id :mid :frames 40 :nodes {}})
|
||||
(assoc-in [:symbols :box] {:id :box :frames 30 :nodes {}})
|
||||
(clip/place-symbol nil :main :mid 10 u nil)
|
||||
(clip/place-symbol nil :mid :box 2 v nil)
|
||||
(turn :main u [40 20] (/ js/Math.PI 2) [2 2])
|
||||
(turn :mid v [5 -3] 0.3 [1.5 0.5])
|
||||
(paint/new-shape :box :shape 4 [0 0 10 0 5 10] :brow)
|
||||
(assoc-in [:symbols :box :nodes :shape :channels [:xform :anchor]] (ch/framed [5 3])))))
|
||||
|
||||
(defn- drawn [c path]
|
||||
(partition 2 (take 6 (array-seq (:pts (first (filter #(= path (:node %))
|
||||
((clip/resolver c nil pal/index-of :main) 16))))))))
|
||||
|
||||
(defn- near? [a b] (every? #(< (js/Math.abs %) 1e-9) (map - (flatten a) (flatten b))))
|
||||
|
||||
(defn- dragged [c path f vs-fn]
|
||||
(let [{:keys [sid id frame] :as pl} (nest/placement c nil :main path 16)
|
||||
v0 (gesture/values (get-in c [:symbols sid :nodes id]) frame)]
|
||||
(gesture/apply-values c sid id frame (vs-fn pl v0))))
|
||||
|
||||
(deftest a-shape-two-symbols-down-moves-under-the-pointer
|
||||
(doseq [path [[u] [u v] [u v :shape]]]
|
||||
(let [c (two-down)
|
||||
moved (dragged c path 16 #(gesture/move %1 %2 [30 30] [37 26]))]
|
||||
(is (near? (map (fn [[x y]] [(+ x 7) (- y 4)]) (drawn c [u v :shape]))
|
||||
(drawn moved [u v :shape]))
|
||||
(str "moving " path " by (7, -4) on the stage moves the shape by (7, -4)")))))
|
||||
|
||||
(deftest turning-keeps-the-pivot-where-it-is
|
||||
(let [c (two-down)
|
||||
path [u v :shape]
|
||||
{:keys [world]} (nest/placement c nil :main path 16)
|
||||
pivot #(let [out (js/Float64Array. 2)]
|
||||
(vec (array-seq (node/apply-pt! out 0 (:world (nest/placement % nil :main path 16)) 5 3))))
|
||||
turned (dragged c path 16 #(gesture/turn %2 0.7))]
|
||||
(is (some? world))
|
||||
(is (near? (pivot c) (pivot turned)) "the anchor stays put")
|
||||
(is (not (near? (drawn c path) (drawn turned path))) "and the rest goes round it")))
|
||||
|
||||
(deftest scaling-takes-the-grabbed-point-to-the-pointer
|
||||
(let [c (two-down)
|
||||
path [u v :shape]
|
||||
{:keys [world]} (nest/placement c nil :main path 16)
|
||||
out (js/Float64Array. 2)
|
||||
at #(vec (array-seq (node/apply-pt! out 0 %1 %2 %3)))
|
||||
p0 (at world 10 0)
|
||||
p1 [(+ (first p0) 3) (- (second p0) 5)]
|
||||
grown (dragged c path 16 #(gesture/scale %1 %2 p0 p1 false))
|
||||
w2 (:world (nest/placement grown nil :main path 16))]
|
||||
(is (near? p1 (at w2 10 0))
|
||||
"the corner grabbed is under the pointer, through a turned, unevenly scaled parent")
|
||||
(is (near? (at world 5 3) (at w2 5 3)) "about the pivot")))
|
||||
|
||||
(deftest a-drag-keys-a-keyed-channel-and-sets-a-framed-one
|
||||
(let [c (update-in (two-down) [:symbols :mid :nodes v :channels [:xform :pos]]
|
||||
(constantly (ch/keyed {0 [5 -3] 20 [9 -3]} :linear)))
|
||||
moved (dragged c [u v] 16 #(gesture/move %1 %2 [0 0] [4 0]))
|
||||
pos (get-in moved [:symbols :mid :nodes v :channels [:xform :pos]])
|
||||
rot (dragged c [u v] 16 #(gesture/turn %2 0.1))]
|
||||
(is (= #{0 4 20} (set (keys (:keys pos))))
|
||||
"a key on the instance's own frame — 16 of main is 6 of mid, 4 of its own — and the others kept")
|
||||
(is (= {:animated? false :value 0.4}
|
||||
(update (get-in rot [:symbols :mid :nodes v :channels [:xform :rot]]) :value
|
||||
#(/ (js/Math.round (* 10 %)) 10)))
|
||||
"a channel with no keys has its one value changed")))
|
||||
|
||||
(deftest a-measured-transform-is-not-set-by-hand
|
||||
(is (string? (gesture/refusal {:channels {[:xform :pos] {:animated? true :dense {:stride 2}}}})))
|
||||
(is (string? (gesture/refusal {:anchors {0 12}})))
|
||||
(is (nil? (gesture/refusal {:channels {[:xform :pos] (ch/keyed {0 [1 1]})}}))))
|
||||
|
||||
(deftest a-click-selects-the-level-figma-would
|
||||
(let [hit [:a :b :c :shape]]
|
||||
(testing "choose"
|
||||
(is (= [:a] (pick/choose nil hit false)) "the thing in the open symbol")
|
||||
(is (= hit (pick/choose nil hit true)) "⌘ goes to the shape")
|
||||
(is (= [:a :b] (pick/choose [:a :b] hit false)) "inside what is selected keeps it")
|
||||
(is (= [:a :x] (pick/choose [:a :b] [:a :x :y] false)) "beside it, at the same depth")
|
||||
(is (= [:z] (pick/choose [:a :b] [:z :y] false)) "elsewhere, from the top")
|
||||
(is (nil? (pick/choose [:a] nil false)) "nothing, on nothing"))
|
||||
(testing "deeper"
|
||||
(is (= [:a :b] (pick/deeper [:a] hit)))
|
||||
(is (= hit (pick/deeper hit hit)) "not past the shape")
|
||||
(is (= [:z] (pick/deeper [:z] hit)) "and not into something else"))))
|
||||
|
||||
(deftest the-topmost-op-under-the-point-is-hit
|
||||
(let [sq (fn [node x0] {:kind :poly :node node :n 4
|
||||
:pts (js/Float64Array. #js [x0 0 (+ x0 10) 0 (+ x0 10) 10 x0 10])})
|
||||
ops [(sq :under 0) (sq [u :over] 5) {:kind :disc :node :dot :cx 50 :cy 50 :r 1}]]
|
||||
(is (= [u :over] (pick/hit ops [7 5])) "the later op is drawn on top")
|
||||
(is (= [:under] (pick/hit ops [2 5])))
|
||||
(is (= [:dot] (pick/hit ops [52.5 50])) "a few-pixel shape can be missed by a little")
|
||||
(is (nil? (pick/hit ops [30 30])))))
|
||||
|
||||
(deftest an-instances-box-is-what-its-symbol-draws
|
||||
(let [c (two-down)
|
||||
{:keys [frame]} (nest/placement c nil :main [u v] 16)]
|
||||
(is (= [0 0 10 10] (pick/local-bounds c nil (get-in c [:symbols :mid :nodes v]) frame)))
|
||||
(is (= [0 0 10 10] (pick/local-bounds c nil (get-in c [:symbols :box :nodes :shape]) 4)))))
|
||||
Loading…
Add table
Add a link
Reference in a new issue