From 4d441ae606c365683a88852bc67cd08ce8423e78 Mon Sep 17 00:00:00 2001 From: Olive Vaughn Date: Sun, 4 Oct 2026 02:05:22 -0400 Subject: [PATCH] Keep overview keyframes passive and edit expanded automation lanes --- frontend/src/arthur/domain/clipboard.cljs | 34 ++ frontend/src/arthur/domain/keyframes.cljs | 37 ++ frontend/src/arthur/domain/nest.cljs | 36 ++ frontend/src/arthur/events/ui.cljs | 56 ++- frontend/src/arthur/subs/render.cljs | 4 +- frontend/src/arthur/ui/drag.cljs | 9 +- frontend/src/arthur/ui/timeline.cljs | 375 +++++++++++++++--- .../test/arthur/domain/clipboard_test.cljs | 42 ++ .../test/arthur/domain/keyframes_test.cljs | 30 ++ .../test/arthur/domain/sequence_test.cljs | 22 + frontend/test/browser/automation.mjs | 264 ++++++++++++ static/arthur/app.css | 16 +- 12 files changed, 856 insertions(+), 69 deletions(-) create mode 100644 frontend/src/arthur/domain/keyframes.cljs create mode 100644 frontend/test/arthur/domain/keyframes_test.cljs create mode 100644 frontend/test/browser/automation.mjs diff --git a/frontend/src/arthur/domain/clipboard.cljs b/frontend/src/arthur/domain/clipboard.cljs index 6fd0863..93d6e1a 100644 --- a/frontend/src/arthur/domain/clipboard.cljs +++ b/frontend/src/arthur/domain/clipboard.cljs @@ -285,3 +285,37 @@ {:sid source-sid :id root :path (conj (vec (butlast path)) root)}) items)})))) + +(defn move-many + "Reparent the selected roots atomically, preserving their world transforms and + clocks. Optional delta places the forest later on the open ruler." + [document store open selections to frame delta] + (let [roots (canonical document selections) + under? (fn [path] (prefix? path (vec to)))] + (cond + (empty? roots) {:refused "select something to move"} + (some #(under? (:path %)) roots) {:refused "a selection cannot go inside itself"} + :else + (let [moved + (reduce (fn [result {:keys [path]}] + (if (:refused result) (reduced result) + (let [r (nest/move-node (:clip result) store open path to frame)] + (if (:refused r) (reduced r) + {:clip (:clip r) + :selections (conj (:selections result) + [:node (:sid r) (:id r) (conj (vec to) (:id r))])})))) + {:clip document :selections []} roots)] + (if (:refused moved) moved + (let [shifted (nest/slide-many (:clip moved) open + (mapv #(nth % 3) (:selections moved)) (or delta 0)) + checked (if (:refused shifted) shifted + (reduce (fn [result sid] + (if (:refused result) (reduced result) + (span/finish (:clip result) sid + (get-in (:clip result) [:symbols sid :nodes]) + nil :grow-symbol))) + shifted (distinct (map :sid roots))))] + (if (:refused checked) checked + (if-let [why (first (clip/problems (:clip checked)))] + {:refused why} + (assoc checked :selections (:selections moved)))))))))) diff --git a/frontend/src/arthur/domain/keyframes.cljs b/frontend/src/arthur/domain/keyframes.cljs new file mode 100644 index 0000000..1d5e5be --- /dev/null +++ b/frontend/src/arthur/domain/keyframes.cljs @@ -0,0 +1,37 @@ +(ns arthur.domain.keyframes + (:require [arthur.domain.channel :as ch])) + +(defn identity-of [k] (select-keys k [:sid :id :channel :frame])) + +(defn shifted [items delta] + (let [delta (max delta (reduce max js/Number.NEGATIVE_INFINITY (map #(max (- (:at %)) (- (* (:frame %) (:scale %)))) items)))] + (mapv (fn [k] + (let [f (max 0 (js/Math.round (+ (:frame k) (/ delta (:scale k)))))] + (assoc k :frame f :at (+ (:at k) (* (:scale k) (- f (:frame k))))))) items))) + +(defn edit-keys [document items delta] + (let [targets-by-id (when (some? delta) + (into {} (map vector (map identity-of items) (shifted items delta))))] + (reduce + (fn [doc [[sid id channel] selected]] + (let [path [:symbols sid :nodes id :channels channel] + c (get-in doc path) + selected (filter #(contains? (:keys c) (:frame %)) selected) + targets (when (some? delta) (mapv #(get targets-by-id (identity-of %)) selected)) + remaining (apply dissoc (:keys c) (map :frame selected)) + ks (if targets + (reduce (fn [ks [old new]] (assoc ks (:frame new) (get (:keys c) (:frame old)))) + remaining (map vector selected targets)) + remaining) + segments (apply dissoc (:segments c) (concat (map :frame selected) (map :frame targets))) + segments (if targets + (reduce (fn [s [old new]] + (if (contains? (:segments c) (:frame old)) + (assoc s (:frame new) (get (:segments c) (:frame old))) s)) + segments (map vector selected targets)) segments)] + (if (empty? selected) doc + (assoc-in doc path + (if (seq ks) + (cond-> (assoc c :keys ks) (:segments c) (assoc :segments segments)) + (ch/framed (get (:keys c) (:frame (last selected))))))))) + document (group-by (juxt :sid :id :channel) (vals (into {} (map (juxt identity-of identity) items))))))) diff --git a/frontend/src/arthur/domain/nest.cljs b/frontend/src/arthur/domain/nest.cljs index 978c931..1b99b3c 100644 --- a/frontend/src/arthur/domain/nest.cljs +++ b/frontend/src/arthur/domain/nest.cljs @@ -477,6 +477,42 @@ ;; too, so this is less code and one fewer invariant to remember. (span/claim clip (:sid here) moved id :grow-symbol (random-uuid)))))) +(defn slide-many + "Move a selection simultaneously. Lane collisions refuse the entire edit." + [document open paths df] + (let [paths (vec (distinct (filter seq paths))) + entries (mapv (fn [path] + (let [here (down document open (pop path)) + id (peek path) + nodes (get-in document [:symbols (:sid here) :nodes])] + {:path path :here here :sid (:sid here) :id id + :nodes nodes :node (get nodes id)})) paths) + roots (remove + (fn [{:keys [path sid id nodes]}] + (some (fn [other] + (or (and (< (count (:path other)) (count path)) + (= (:path other) (subvec path 0 (count (:path other))))) + (and (= sid (:sid other)) (not= id (:id other)) + (some #{(:id other)} (rest (symbol/lineage nodes id)))))) + entries)) entries)] + (if (some #(or (nil? (:node %)) (nil? (get-in % [:here :time]))) roots) + {:refused "selection includes a node without an editable clock"} + (let [changes + (reduce (fn [changes {:keys [here sid id nodes node]}] + (let [from (or (first (node/placed-span node)) (get-in node [:time :at] 0)) + d (- (dragged here nodes id from df) from) + shift (fn [n] (update-in n [:time :at] (fnil + 0) d)) + ids (cons id (when (not= :audio (:kind node)) + (for [[aid n] nodes :when (= id (:linked-to n))] aid)))] + (reduce (fn [out nid] (assoc-in out [sid nid] (shift (get nodes nid)))) changes ids))) + {} roots)] + (reduce (fn [result [sid changed]] + (if (:refused result) (reduced result) + (span/finish (:clip result) sid + (merge (get-in (:clip result) [:symbols sid :nodes]) changed) + nil :grow-symbol))) + {:clip document} changes))))) + (defn resize-out "Move the right edge of the node at `path` by `df` frames of `open`. diff --git a/frontend/src/arthur/events/ui.cljs b/frontend/src/arthur/events/ui.cljs index c7470ff..7e148f1 100644 --- a/frontend/src/arthur/events/ui.cljs +++ b/frontend/src/arthur/events/ui.cljs @@ -17,6 +17,7 @@ app to change and the most expensive to have two copies of." (:require [clojure.string :as str] [arthur.domain.clip :as clip] + [arthur.domain.keyframes :as keyframes] [arthur.domain.channel :as ch] [arthur.domain.clipboard :as clipboard] [arthur.domain.correction :as correction] @@ -1159,15 +1160,15 @@ ::sliding ;; A bar in the middle of a slide, drawn by `::render/clip`; nil path when the ;; drag is abandoned. - (fn [db [_ path df kind ripple? other owner]] + (fn [db [_ path df kind ripple? other owner paths]] (if path (assoc-in db [:ui :sliding] {:path path :df df :kind (or kind :slide) - :ripple? (boolean ripple?) :other other :owner owner}) + :ripple? (boolean ripple?) :other other :owner owner :paths paths}) (update db :ui dissoc :sliding)))) (rf/reg-event-db ::slide - (fn [db [_ path df kind ripple? other owner]] + (fn [db [_ path df kind ripple? other owner paths]] (let [db (update db :ui dissoc :sliding) clip (:clip (store/entry (:clip/current db))) open (or owner (get-in db [:ui :open])) @@ -1175,11 +1176,11 @@ :out (nest/resize-out clip open path df ripple?) :in (nest/resize-in clip open path df) :roll (nest/roll clip open other path df) - (nest/slide clip open path df))] + (if (seq paths) (nest/slide-many clip open paths df) (nest/slide clip open path df)))] (cond (zero? df) db (:refused r) (refused db (:refused r)) - :else (edit/edit db (constantly (:clip r))))))) + :else (edit/transaction db (constantly (:clip r))))))) (rf/reg-event-db ::refuse @@ -1316,3 +1317,48 @@ (assoc-in [:ui :selection] [:node (:sid (nest/inside clip st open host f)) uuid (conj host uuid)]) (update-in [:ui :expanded] conj (conj host uuid))))))) + +(rf/reg-event-db + ::edit-keyframes + (fn [db [_ items delta]] + (edit/transaction db #(keyframes/edit-keys % items delta)))) + +(rf/reg-event-db + ::move-nodes + (fn [db [_ selections to frame delta]] + (let [{document :clip st :store} (store/entry (:clip/current db)) + result (clipboard/move-many document st (get-in db [:ui :open]) selections to + (or frame (editing-frame db document)) delta)] + (if-let [why (:refused result)] + (refused db why) + (-> db + (update :ui dissoc :sliding :drop) + (edit/transaction (constantly (:clip result))) + (selected (peek (:selections result))) + (assoc-in [:ui :selections] (:selections result)) + (update-in [:ui :expanded] (fnil into #{}) (rest (reductions conj [] to)))))))) + +(rf/reg-event-db + ::restack-nodes + (fn [db [_ selections to front?]] + (let [{document :clip st :store} (store/entry (:clip/current db)) + open (get-in db [:ui :open]) + host (pop to) + roots (clipboard/canonical document selections) + foreign (mapv :address (remove #(= host (pop (:path %))) roots)) + moved (if (seq foreign) + (clipboard/move-many document st open foreign host (editing-frame db document) 0) + {:clip document :selections []}) + addresses (vec (concat (map :address (filter #(= host (pop (:path %))) roots)) + (:selections moved))) + result (reduce (fn [r address] + (if (:refused r) (reduced r) + (nest/restack (:clip r) open (nth address 3) to front?))) + moved (if front? (reverse addresses) addresses))] + (if-let [why (:refused result)] + (refused db why) + (-> db + (edit/transaction (constantly (:clip result))) + (selected (peek addresses)) + (assoc-in [:ui :selections] addresses) + (update-in [:ui :expanded] (fnil into #{}) (rest (reductions conj [] host)))))))) diff --git a/frontend/src/arthur/subs/render.cljs b/frontend/src/arthur/subs/render.cljs index 3ead794..c535fe4 100644 --- a/frontend/src/arthur/subs/render.cljs +++ b/frontend/src/arthur/subs/render.cljs @@ -43,13 +43,13 @@ ;; lets go, so the stage and the rows follow the pointer. Nothing is written ;; until then: one drag is one undo step and one write to collaborators. (let [{c :clip st :store} (footage/entry id)] - (or (when-let [{:keys [path df kind ripple? other owner]} sliding] + (or (when-let [{:keys [path df kind ripple? other owner paths]} sliding] (let [root (or owner open)] (:clip (case kind :out (nest/resize-out c root path df ripple?) :in (nest/resize-in c root path df) :roll (nest/roll c root other path df) - (nest/slide c root path df))))) + (if (seq paths) (nest/slide-many c root paths df) (nest/slide c root path df)))))) (when-let [{:keys [sid id frame values edits]} gesture] ;; A held stage control owns the touched parameters completely. Make ;; them temporary static channels for the preview, so their existing diff --git a/frontend/src/arthur/ui/drag.cljs b/frontend/src/arthur/ui/drag.cljs index 2f12131..32f8608 100644 --- a/frontend/src/arthur/ui/drag.cljs +++ b/frontend/src/arthur/ui/drag.cljs @@ -57,9 +57,9 @@ (defn row! "Start carrying the timeline row at `path` — a node of kind `node-kind`, to be moved into another symbol or grouped with another node." - [path node-kind selection] + [path node-kind selection & [selections in]] (reset! carrying {:kind :row :path path :node-kind node-kind - :selection selection})) + :selection selection :selections selections :in in})) (defn row "The path of the row being carried, or nil when it is not a row." @@ -74,6 +74,11 @@ (defn row-selection [] (when (= :row (:kind @carrying)) (:selection @carrying))) +(defn row-selections [] + (when (= :row (:kind @carrying)) (:selections @carrying))) + +(defn row-in [] (:in @carrying)) + (defn other! "Start carrying something that is not yet in the document: `:kind` says what, and the rest is what a preview can show of it before it is fetched." diff --git a/frontend/src/arthur/ui/timeline.cljs b/frontend/src/arthur/ui/timeline.cljs index 95285ea..091ad2e 100644 --- a/frontend/src/arthur/ui/timeline.cljs +++ b/frontend/src/arthur/ui/timeline.cljs @@ -22,6 +22,8 @@ happen and not where they happen again." (:require [clojure.string :as str] [arthur.domain.node :as node] + [arthur.domain.keyframes :as keyframes] + [arthur.domain.clipboard :as clipboard] [arthur.domain.clip :as clip] [arthur.domain.nest :as nest] [arthur.domain.span :as span] @@ -82,7 +84,12 @@ [clip id n] (clip/node-label clip id n)) -(defn- channel-rows [n path depth ->open span] +(defn- key-items [n sid ->open] + (vec (for [[channel ch] (node/channels n) f (keyed-frames ch)] + {:sid sid :id (:id n) :channel channel :frame f + :at (->open f) :scale (- (->open 1) (->open 0))}))) + +(defn- channel-rows [n sid path depth ->open span] (let [keyed (filter (comp seq :keys val) (sort-by (comp str key) (node/channels n)))] (if (seq keyed) @@ -94,6 +101,7 @@ :select nil :span span :keys (mapv ->open (keyed-frames ch)) + :key-items (filterv #(= cpath (:channel %)) (key-items n sid ->open)) :dense? (boolean (:dense ch))}) [{:path (conj path ::automation-hint) :depth depth @@ -142,7 +150,8 @@ (walk source path depth (comp self (time->parent source-time))) (mapv (fn [row] (-> row - (assoc :keys [] :unmapped? true) + (assoc :keys [] :key-items [] :unmapped? true) + (update :cels #(when % (mapv (fn [cel] (assoc cel :keys [] :key-items [])) %))) (assoc :span (when (= :node (:kind row)) span)))) (walk source path depth (constantly (first span))))))) (portal [sid path depth self child] @@ -167,6 +176,7 @@ :expandable? true :expanded? open? :span cspan + :key-items (key-items child sid cself) :keys (into [] (comp (mapcat keyed-frames) (map cself) (distinct)) @@ -175,7 +185,7 @@ (if-not open? [row] (-> [row] - (into (channel-rows child cpath (inc depth) cself cspan)) + (into (channel-rows child sid cpath (inc depth) cself cspan)) (into (inside-rows sid child cpath (inc depth) cself cspan)))))) (walk [sid path depth ->open] (let [sym (get-in clip [:symbols sid]) @@ -214,6 +224,7 @@ (node-label clip (:id child) child)) :source (node/source child) :span (mapv ->open (node/placed-span child)) + :key-items (key-items child sid (comp ->open (local->parent child))) :keys (into [] (comp (mapcat keyed-frames) (map (comp ->open (local->parent child))) @@ -277,6 +288,7 @@ :expandable? true :expanded? open? :span span + :key-items (key-items n sid self) :keys (into [] (comp (mapcat keyed-frames) (map self) (distinct)) @@ -301,6 +313,7 @@ ;; The clip's own keys, on the ;; block, so a collapsed lane ;; still says where it changes. + :key-items (key-items child (node/source n) (comp source-self (local->parent child))) :keys (into [] (comp (mapcat keyed-frames) (map (comp source-self (local->parent child))) @@ -312,7 +325,7 @@ (cond->> (if-not open? [row] (-> [row] - (into (channel-rows n rpath (inc depth) self span)) + (into (channel-rows n sid rpath (inc depth) self span)) ;; The one clip an expanded lane opens. (into (when lane? (if-let [child (first (filter #(under? (conj rpath (:id %))) clips))] @@ -380,12 +393,15 @@ (mapcat (fn [[path tracks]] (let [n (first tracks) + authored (get-in clip [:symbols (:owner n) :nodes (:id n)]) + authored->open (comp own (local->parent n)) span (own-span (node/placed-span n)) select [:node (:owner n) (:id n) path] open? (contains? expanded path) via (when (< 1 (count path)) (str (first path))) row {:path path :depth 0 :label (node-label clip (:id n) n) :kind :node :node-kind :audio :via via + :key-items (key-items authored (:owner n) authored->open) :slides (if via (subvec path 0 1) path) :select select :expandable? true :expanded? open? :span span :keys (mapv own (distinct (mapcat keyed-frames (vals (:channels n)))))}] @@ -396,7 +412,7 @@ :span (own-span (node/placed-span track)) :select select}) (range) tracks))) - (when open? (channel-rows n path 1 own span))))) + (when open? (channel-rows authored (:owner n) path 1 authored->open span))))) (sort-by (comp str key) (group-by :path (remove #(and lane? (= 1 (count (:path %)))) @@ -588,7 +604,10 @@ placement or beside another sound, and only a sound goes beside a sound." [target target-kind] (when-let [from (drag/row)] - (and (not= from (subvec target 0 (min (count from) (count target)))) + (and (every? (fn [source] + (not= source (subvec target 0 (min (count source) (count target))))) + (if-let [many (seq (drag/row-selections))] + (map #(nth % 3) many) [from])) (if (= :audio (drag/row-kind)) (#{:audio :instance} target-kind) (not= :audio target-kind))))) @@ -611,8 +630,8 @@ on every render after, which would fight a person scrolling away." (memoize (fn [_selection] (fn [el] (some-> el (.scrollIntoView #js {:block "nearest"})))))) -(defn- label-cell [{:keys [path depth label kind node-kind lane? select expandable? expanded? of via]} - selection selections target-path over solo tracing renaming draft] +(defn- label-cell [{:keys [path depth label kind node-kind lane? span select expandable? expanded? of via]} + selection selections target-path over solo tracing renaming draft choose-row!] (let [node? (= :node kind) selected? (contains? selections select) editing? (and lane? (= select @renaming)) @@ -640,10 +659,7 @@ ;; The primary row plus playhead resolves creation. :on-click (fn [^js e] (when select - (rf/dispatch [(if (.-shiftKey e) - ::ui/toggle-selection - ::ui/select) - select]))) + (choose-row! e select))) ;; An instance's row opens the symbol it places, as a tab. :on-double-click (fn [^js e] (when of @@ -658,7 +674,8 @@ (.stopPropagation e) (.setData (.-dataTransfer e) "text/plain" "row") (set! (.. e -dataTransfer -effectAllowed) "move") - (drag/row! path node-kind select)) + (drag/row! path node-kind select + (when selected? @(rf/subscribe [::sub/selections])) (first span))) :on-drag-end (fn [_] (reset! over nil) (drag/done!)) :on-drag-enter (fn [^js e] (when (takes? path node-kind) (.preventDefault e))) :on-drag-over (fn [^js e] @@ -672,15 +689,20 @@ (.preventDefault e) (.stopPropagation e) (let [from (when (takes? path node-kind) (drag/row)) - where (zone e node-kind)] + where (zone e node-kind) + many (drag/row-selections)] (reset! over nil) (drag/done!) (when from (rf/dispatch (cond - (not= :into where) [::ui/restack from path (= :front where)] - (= :instance node-kind) [::ui/move-node from path] - :else [::ui/group [from path]])))))})) + (not= :into where) (if (< 1 (count many)) + [::ui/restack-nodes many path (= :front where)] + [::ui/restack from path (= :front where)]) + (= :instance node-kind) (if (< 1 (count many)) + [::ui/move-nodes many path] + [::ui/move-node from path]) + :else [::ui/group (vec (distinct (concat (if (< 1 (count many)) (map #(nth % 3) many) [from]) [path])))])))))})) [:button.tl-twist {:disabled (not expandable?) ;; This button lives inside a draggable row label. Do not let a tiny @@ -749,13 +771,119 @@ (rf/dispatch [::ui/delete-selected]))} "×"])])) +(defn- paint-key-selection! + "Selection and preview are transient DOM state. Update them in one pass, + without scheduling a React render for every marker on every pointer move." + [elements {:keys [ids positions]}] + (let [widths (js/Map.)] + (.forEach elements + (fn [el] + (let [items (aget el "arthurKeys") + selected? (boolean (some ids (aget el "arthurKeyIds"))) + item (first items) + moved (when selected? (get positions (aget el "arthurKeyId")))] + (.toggle (.-classList el) "selected" selected?) + (if moved + (let [row (.-parentElement el) + width (if (.has widths row) (.get widths row) + (let [w (.-width (.getBoundingClientRect row))] + (.set widths row w) w)) + dx (* width (/ (- (:at moved) (:at item)) (aget el "arthurFrames")))] + (set! (.. el -style -transform) (str "translateX(" dx "px)"))) + (set! (.. el -style -transform) ""))))))) + +(defn- key-dot [items frames chosen preview anchor key-state key-elements] + (r/with-let [gesture (atom nil) element (atom nil)] + (let [{:keys [ids]} @key-state + selected? (some ids (map keyframes/identity-of items))] + [:button.tl-key + {:type "button" :class (when selected? "selected") + :title "Drag to move · Shift-click for a range · Command/Ctrl-click to toggle · Command/Ctrl-A selects all visible keys · arrows to shift · Delete to remove" + :style {:left (at% (:at (first items)) frames)} + :ref (fn [el] + (when @element (.delete key-elements @element)) + (reset! element el) + (when el + (aset el "arthurKeys" items) (aset el "arthurFrames" frames) + (aset el "arthurKeyIds" (mapv keyframes/identity-of items)) + (aset el "arthurKeyId" (keyframes/identity-of (first items))) + (.add key-elements el) + (paint-key-selection! (js/Set. #js [el]) @key-state))) + :on-click #(.stopPropagation %) + :on-double-click #(.stopPropagation %) + :on-key-down + (fn [e] + (let [k (.-key e)] + (when (#{"Delete" "Backspace" "ArrowLeft" "ArrowRight" "Escape"} k) + (.preventDefault e) (.stopPropagation e) + (cond + (= k "Escape") (reset! chosen []) + (#{"Delete" "Backspace"} k) + (do (.focus (.closest (.-currentTarget e) "section.time")) + (rf/dispatch [::ui/edit-keyframes @chosen nil]) (reset! chosen [])) + :else (let [df (* (if (= k "ArrowLeft") -1 1) (if (.-shiftKey e) 10 1)) + xs @chosen] + (rf/dispatch [::ui/edit-keyframes xs df]) + (reset! chosen (keyframes/shifted xs df))))))) + :on-pointer-down + (fn [e] + (.stopPropagation e) + (.focus (.-currentTarget e)) + (let [ids (set (map keyframes/identity-of items)) + selected? (some (:ids @key-state) (map keyframes/identity-of items))] + (cond + (or (.-metaKey e) (.-ctrlKey e)) + (reset! chosen (if selected? + (vec (remove #(contains? ids (keyframes/identity-of %)) @chosen)) + (into @chosen items))) + (and (.-shiftKey e) @anchor) + (let [buttons (vec (array-seq (.querySelectorAll (.closest (.-currentTarget e) ".tl-tracks") "button.tl-key"))) + origin (some #(when (some #{(:id @anchor)} (map keyframes/identity-of (aget % "arthurKeys"))) %) buttons) + boxes (js/Map.) + box (.getBoundingClientRect (.-currentTarget e)) + start (when origin (.getBoundingClientRect origin))] + (when start + (reset! chosen + (vec (distinct (mapcat #(aget % "arthurKeys") + (filter (fn [el] + (let [row (.-parentElement el) + b (if (.has boxes row) (.get boxes row) + (let [b (.getBoundingClientRect row)] (.set boxes row b) b)) + x (- (+ (.-left b) (* (.-width b) (/ (+ (:at (first (aget el "arthurKeys"))) 0.5) frames))) 5.5) + y (- (+ (.-top b) (/ (.-clientHeight row) 2)) 5.5)] + (and (<= (- (min (.-left start) (.-left box)) 0.1) x (+ (max (.-left start) (.-left box)) 0.1)) + (<= (- (min (.-top start) (.-top box)) 0.1) y (+ (max (.-top start) (.-top box)) 0.1))))) buttons))))))) + (not selected?) (reset! chosen items)) + (when-not (.-shiftKey e) + (reset! anchor {:id (keyframes/identity-of (first items))}))) + (reset! gesture {:x (.-clientX e) :items @chosen + :width (.-width (.getBoundingClientRect (.-parentElement (.-currentTarget e))))}) + (try (.setPointerCapture (.-currentTarget e) (.-pointerId e)) (catch :default _ nil))) + :on-pointer-move + (fn [e] + (.stopPropagation e) + (when @gesture + (let [df (js/Math.round (* frames (/ (- (.-clientX e) (:x @gesture)) (max 1 (:width @gesture)))))] + (when (not= df (:delta @gesture)) + (swap! gesture assoc :delta df) + (reset! preview {:delta df}))))) + :on-pointer-up + (fn [e] + (.stopPropagation e) + (when-let [g @gesture] + (when (and (seq (:items g)) (not (zero? (or (:delta g) 0)))) + (rf/dispatch-sync [::ui/edit-keyframes (:items g) (:delta g)]) + (reset! chosen (keyframes/shifted (:items g) (:delta g))))) + (reset! gesture nil) (when @preview (reset! preview nil))) + :on-pointer-cancel (fn [e] (.stopPropagation e) (reset! gesture nil) (reset! preview nil))}]))) + (defn- track-cell "`sliding` is the gesture in flight: `{:row :path :kind :from :df}`, where `:from` is the frame the press was on and `:df` how many frames the pointer has moved since. What it looks like mid-drag is `[:ui :sliding]`, which the clip every row and the stage are drawn from already has in it." - [{:keys [path span keys dense? kind node-kind select slides cels lane? of unmapped? owner]} - frames sliding hint {:keys [clip store open selection selections target-path]}] + [{:keys [path span keys key-items dense? kind node-kind select slides cels lane? of unmapped? owner]} + frames sliding hint {:keys [clip store open selection selections target-path key-selection key-preview key-anchor key-state key-elements choose-row!]}] (let [selected-set (set selections) active-row (:row @sliding) slide (fn [^js e] @@ -793,10 +921,14 @@ ;; on it, and on this rare a path an uncached sub ;; costs nothing. why (when (and under (not= (:select under) (:selection drag))) - (nest/move-refusal clip store open - (nth (:selection drag) 3) - (nth (:select under) 3) - @(rf/subscribe [::render/open-frame]))) + (if (seq (:paths @sliding)) + (:refused (clipboard/move-many clip store open selections + (nth (:select under) 3) + @(rf/subscribe [::render/open-frame]) 0)) + (nest/move-refusal clip store open + (nth (:selection drag) 3) + (nth (:select under) 3) + @(rf/subscribe [::render/open-frame])))) nest (when (and under (nil? why) (not= (:select under) (:selection drag))) under) @@ -853,11 +985,11 @@ (swap! sliding assoc :df df) (rf/dispatch [::ui/sliding (:path @sliding) df (:kind @sliding) (:ripple? @sliding) - (:other @sliding) owner])))))))) + (:other @sliding) owner (:paths @sliding)])))))))) done (fn [commit?] (when @sliding (let [{:keys [path df kind ripple? other target-lane target-frame - drag nest on-click more?]} @sliding] + drag nest on-click more? range? paths]} @sliding] (reset! sliding nil) (when hint (reset! hint nil)) (cond @@ -865,25 +997,30 @@ (do (rf/dispatch [::ui/sliding nil]) ;; `nest/move-node`, which is what keeps the world ;; transform and the root timing across the move. - (rf/dispatch [::ui/move-node (nth (:selection drag) 3) - (nth (:select nest) 3)])) + (rf/dispatch (if (seq paths) + [::ui/move-nodes selections (nth (:select nest) 3)] + [::ui/move-node (nth (:selection drag) 3) + (nth (:select nest) 3)]))) (and commit? target-lane drag) (do (rf/dispatch [::ui/sliding nil]) - (rf/dispatch [::ui/drop-clip (:selection drag) - target-lane target-frame])) + (rf/dispatch (if (seq paths) + [::ui/move-nodes selections (nth target-lane 3) + target-frame (- target-frame (:in drag))] + [::ui/drop-clip (:selection drag) + target-lane target-frame]))) commit? ;; A press that moved nothing is a click, and a drag ;; that only moved in time leaves what it moved ;; selected. The two structural cases above select what ;; they landed, so neither needs this. - (do (when on-click - (rf/dispatch [(if more? ::ui/toggle-selection ::ui/select) - on-click])) - (rf/dispatch [::ui/slide path df kind ripple? other owner])) + (do (when (and on-click (or (zero? df) (not (seq paths)))) + (choose-row! #js {:metaKey more? :shiftKey range?} on-click)) + (rf/dispatch [::ui/slide path df kind ripple? other owner paths])) :else (rf/dispatch [::ui/sliding nil]))))) begin! (fn [^js e actual-path gesture-kind actual-select other drag] (let [track (.closest (.-currentTarget e) ".tl-track")] (.stopPropagation e) + (.focus track) ;; SELECTING WAITS FOR THE RELEASE. Selecting on the press ;; changed what the timeline was showing before the gesture ;; had said anything: an expanded lane follows the @@ -893,8 +1030,13 @@ ;; gesture; what it meant is known on release. (reset! sliding {:row path :path actual-path :kind gesture-kind :other other + :paths (when (and (= :slide gesture-kind) (not owner) + (contains? selected-set actual-select) + (< 1 (count selections))) + (mapv #(nth % 3) (filter #(= :node (first %)) selections))) :on-click actual-select - :more? (.-shiftKey e) + :more? (or (.-metaKey e) (.-ctrlKey e)) + :range? (.-shiftKey e) :drag (when drag (assoc drag :grab (- (frame-under e frames track) @@ -912,7 +1054,18 @@ ;; The track, not the bar, holds the pointer while a bar slides, so the drag ;; goes on when the bar has slid off the ruler and is no longer drawn. {:class (str (when lane? "lane") (when (= :palette kind) " palette") + (when (= :channel kind) " automation") (when (= path target-path) " target")) + :tab-index 0 + :on-key-down (fn [e] + (when (and (empty? @key-selection) (= (.-target e) (.-currentTarget e)) + (#{"ArrowLeft" "ArrowRight"} (.-key e))) + (.preventDefault e) (.stopPropagation e) + (let [paths (mapv #(nth % 3) (filter #(= :node (first %)) selections)) + df (* (if (= "ArrowLeft" (.-key e)) -1 1) + (if (.-shiftKey e) 10 1))] + (when (seq paths) + (rf/dispatch [::ui/slide (first paths) df :slide false nil nil paths]))))) :on-pointer-move slide :on-pointer-up (fn [e] (slide e) (done true)) :on-pointer-cancel (fn [_] (done false)) @@ -955,9 +1108,13 @@ (do (.preventDefault e) (.stopPropagation e) (if-let [from (drag/row-selection)] - (do (drag/done!) - (rf/dispatch [::ui/drop-clip from select - (frame-at e frames)])) + (let [many (drag/row-selections) + in (drag/row-in) + at (frame-at e frames)] + (drag/done!) + (rf/dispatch (if (and (< 1 (count many)) (number? in)) + [::ui/move-nodes many (nth select 3) at (- at in)] + [::ui/drop-clip from select at]))) (drag/land! (frame-at e frames) nil select)))))} ;; Clipped to the ruler: an instance longer than the room left in its ;; symbol still plays its own frames from 0, it is just cut off at the end. @@ -981,10 +1138,7 @@ :width (str (* 100 (/ (- out in) (max 1 frames))) "%")} :on-click (when (and select unmapped?) (fn [^js e] (.stopPropagation e) - (rf/dispatch [(if (.-shiftKey e) - ::ui/toggle-selection - ::ui/select) - select]))) + (choose-row! e select))) :on-pointer-down (when (and select (not unmapped?)) #(begin! % (or slides path) :slide select nil nil))} @@ -1082,11 +1236,15 @@ ;; The lane's own keys, and those of the clips on it drawn after the ;; blocks so they land ON the block they belong to: a collapsed lane still ;; says where the thing in it changes, without opening anything. - (when-not dense? + (when (or (not dense?) (seq key-items) (some (comp seq :key-items) cels)) (doall - (for [f (distinct (concat keys (mapcat :keys cels))) + (let [by-frame (group-by :at (concat key-items (mapcat :key-items cels)))] + (for [[index f] (map-indexed vector (sort (distinct (concat keys (mapcat :keys cels))))) :when (and (<= 0 f) (< f frames))] - ^{:key f} [:div.tl-key {:style {:left (at% f frames)}}])))])) + ^{:key index} + (if-let [items (and (= :channel kind) (seq (get by-frame f)))] + [key-dot (vec items) frames key-selection key-preview key-anchor key-state key-elements] + [:div.tl-key {:aria-hidden true :style {:left (at% f frames)}}])))))])) (defn- cursor-hint "What the drag in flight would do, beside the pointer. @@ -1113,6 +1271,14 @@ [tag frames] [tag {:style {:left (at% @(rf/subscribe [::render/open-frame]) frames)}}]) +(defn- key-marquee [marquee] + (when-let [{:keys [x y x2 y2]} @marquee] + (when (and x2 y2) + [:div.tl-key-marquee {:style {:position "fixed" :pointer-events "none" :z-index 20 + :left (str (min x x2) "px") :top (str (min y y2) "px") + :width (str (abs (- x2 x)) "px") :height (str (abs (- y2 y)) "px") + :border "1px solid var(--pick)" :background "color-mix(in srgb, var(--pick) 15%, transparent)"}}]))) + (defn- timeline-view [] (r/with-let [scrubbing (r/atom false) ;; The row a carried row is over and which part of it, for the @@ -1121,8 +1287,31 @@ sliding (r/atom nil) hint (r/atom nil) renaming (r/atom nil) - draft (r/atom "")] - (let [clip @(rf/subscribe [::render/clip]) + draft (r/atom "") + key-selection (atom []) + key-preview (atom nil) + key-elements (js/Set.) + key-state (atom {:ids #{} :positions nil}) + selection-watch + (add-watch key-selection ::selection + (fn [_ _ _ items] + (reset! key-state {:ids (into #{} (map keyframes/identity-of) items) :positions nil}) + (paint-key-selection! key-elements @key-state))) + preview-watch + (add-watch key-preview ::preview + (fn [_ _ _ preview] + (let [items @key-selection delta (or (:delta preview) 0)] + (swap! key-state assoc :positions + (when-not (zero? delta) + (into {} (map vector (map keyframes/identity-of items) + (keyframes/shifted items delta))))) + (paint-key-selection! key-elements @key-state)))) + key-context (atom nil) + key-anchor (atom nil) + row-anchor (atom nil) + marquee (r/atom nil)] + (let [clip-id @(rf/subscribe [::render/clip-id]) + clip @(rf/subscribe [::render/clip]) ;; THE RULER IS THE OPEN SYMBOL'S OWN FRAME SPACE. Every span, key and ;; cut drawn here is stored in that space, so drawing them against the ;; output grid put almost none of them on a mark and left every gesture @@ -1205,8 +1394,30 @@ ;; first shows it. Several marks can share one output frame when the ;; symbol was authored on a finer grid; that is what playing it at this ;; project's rate means, and the playhead lands where it will really be. - seek-to (fn [^js e n] (clip/first-output-frame clip open (frame-at e n)))] + seek-to (fn [^js e n] (clip/first-output-frame clip open (frame-at e n))) + choose-row! (fn [e target] + (let [xs (vec (keep :select visible)) + anchor (or @row-anchor selection target) + a (.indexOf xs anchor) b (.indexOf xs target)] + (cond + (or (.-metaKey e) (.-ctrlKey e)) (rf/dispatch [::ui/toggle-selection target]) + (and (.-shiftKey e) (<= 0 a) (<= 0 b)) + (rf/dispatch [::ui/select-many (subvec xs (min a b) (inc (max a b)))]) + :else (rf/dispatch [::ui/select target])) + (when-not (.-shiftKey e) (reset! row-anchor target))))] + (when (not= [clip-id open] @key-context) + (reset! key-context [clip-id open]) (reset! key-selection []) (reset! key-preview nil) (reset! key-anchor nil) (reset! row-anchor nil)) [:section.pane.time + {:tab-index -1 + :on-key-down (fn [e] + (when (and (= (.-target e) (.-currentTarget e)) + (#{"Delete" "Backspace"} (.-key e))) + (.preventDefault e) (.stopPropagation e))) + :on-pointer-down-capture (fn [e] + (when-not (or (.closest (.-target e) "button.tl-key") + (.closest (.-target e) ".tl-track") + (.-metaKey e) (.-ctrlKey e) (.-shiftKey e)) + (reset! key-selection []) (reset! key-preview nil)))} [transport] [:div.tl-body ;; Blank timeline space means the open symbol. Child controls stop their @@ -1226,19 +1437,71 @@ :on-drop (fn [^js e] (.preventDefault e) (when-let [from (drag/row)] - (drag/done!) - (reset! over nil) - (when (< 1 (count from)) - (rf/dispatch [::ui/move-node from []]))))} + (let [many (drag/row-selections)] + (drag/done!) + (reset! over nil) + (when (< 1 (count from)) + (rf/dispatch (if (< 1 (count many)) [::ui/move-nodes many []] [::ui/move-node from []]))))))} [:div.tl-corner] (doall (for [row visible] (with-meta (if (= :section (:kind row)) [:div.tl-label.tl-section (:label row)] [label-cell row selection selection-set target-path - over solo tracing renaming draft]) + over solo tracing renaming draft choose-row!]) {:key (str (:path row))})))] [:div.tl-tracks - {:on-click (fn [^js e] + {:on-pointer-down-capture + (fn [e] + (when (and (= 0 (.-button e)) + (.closest (.-target e) ".tl-track.automation") + (not (.closest (.-target e) "button, .movable, .unmapped, .tl-edge"))) + (.stopPropagation e) (.preventDefault e) + (.focus (.-currentTarget e)) + (reset! marquee {:x (.-clientX e) :y (.-clientY e) + :hits (let [container (.-currentTarget e)] + (vec (mapcat + (fn [row] + (let [b (.getBoundingClientRect row)] + (map (fn [el] + (let [items (aget el "arthurKeys")] + {:x (+ (.-left b) (* (.-width b) (/ (+ (:at (first items)) 0.5) frames))) + :y (+ (.-top b) (/ (.-height b) 2)) :items items})) + (array-seq (.querySelectorAll row "button.tl-key"))))) + (array-seq (.querySelectorAll container ".tl-track"))))) + :base (if (or (.-metaKey e) (.-ctrlKey e) (.-shiftKey e)) @key-selection [])}) + (reset! key-selection (:base @marquee)) + (try (.setPointerCapture (.-currentTarget e) (.-pointerId e)) (catch :default _ nil)))) + :on-pointer-move + (fn [e] + (when-let [{:keys [x y base hits]} @marquee] + (.stopPropagation e) + (let [x2 (.-clientX e) y2 (.-clientY e)] + (swap! marquee assoc :x2 x2 :y2 y2) + (reset! key-selection + (vec (distinct (concat base + (mapcat (fn [{cx :x cy :y :keys [items]}] + (when (and (<= (min x x2) cx (max x x2)) + (<= (min y y2) cy (max y y2))) items)) hits)))))))) + :on-pointer-up (fn [e] (when @marquee (.stopPropagation e) (reset! marquee nil))) + :on-pointer-cancel (fn [_] (when @marquee (reset! key-selection (:base @marquee)) (reset! marquee nil))) + :tab-index 0 + :on-key-down + (fn [e] + (cond + (and (or (.-metaKey e) (.-ctrlKey e)) (= "a" (.toLowerCase (.-key e)))) + (do (.preventDefault e) (.stopPropagation e) + (reset! key-selection (vec (distinct (mapcat #(aget % "arthurKeys") + (array-seq (.querySelectorAll (.-currentTarget e) "button.tl-key"))))))) + (and (seq @key-selection) (#{"ArrowLeft" "ArrowRight" "Delete" "Backspace" "Escape"} (.-key e))) + (do (.preventDefault e) (.stopPropagation e) + (let [k (.-key e) xs @key-selection] + (cond + (= k "Escape") (reset! key-selection []) + (#{"Delete" "Backspace"} k) (do (rf/dispatch [::ui/edit-keyframes xs nil]) (reset! key-selection [])) + :else (let [df (* (if (= k "ArrowLeft") -1 1) (if (.-shiftKey e) 10 1))] + (rf/dispatch [::ui/edit-keyframes xs df]) + (reset! key-selection (keyframes/shifted xs df)))))))) + :on-click (fn [^js e] (when (= (.-target e) (.-currentTarget e)) (rf/dispatch [::ui/select nil]))) :on-drag-enter (fn [^js e] (when (drag/accepts?) (.preventDefault e))) @@ -1299,10 +1562,12 @@ [track-cell row frames sliding hint {:clip clip :store store :open open :selection selection :selections selections - :target-path target-path}]) + :target-path target-path :key-selection key-selection :key-preview key-preview + :key-anchor key-anchor :key-state key-state :key-elements key-elements :choose-row! choose-row!}]) {:key (str (:path row))}))) [:div.tl-empty "nothing in this symbol"]) - [playhead :div.tl-playhead frames]]] + [playhead :div.tl-playhead frames] + [key-marquee marquee]]] [cursor-hint hint]]))) (defn view [] diff --git a/frontend/test/arthur/domain/clipboard_test.cljs b/frontend/test/arthur/domain/clipboard_test.cljs index 8d24483..8e8f94b 100644 --- a/frontend/test/arthur/domain/clipboard_test.cljs +++ b/frontend/test/arthur/domain/clipboard_test.cljs @@ -161,3 +161,45 @@ (is (nil? (get-in made [:symbols :main :nodes :plate]))) (is (nil? (get-in made [:symbols :main :nodes :child]))) (is (empty? (clip/problems made))))) + +(defn- reparent-document [] + (-> (fixture/document) + (update-in [:symbols :main] dissoc :display) + (assoc-in [:symbols :box] {:id :box :frames 40 :nodes {}}) + (assoc-in [:symbols :main :nodes :target] + (fixture/cel :target :box 0 40 1)))) + +(deftest reparenting-a-forest-preserves-relative-times + (let [doc (reparent-document) + selections [(address :main :a [:a]) (address :main :b [:b])] + r (clipboard/move-many doc {} :main selections [:target] 6 0)] + (is (nil? (:refused r))) + (is (= [0 4] (mapv #(get-in r [:clip :symbols :box :nodes % :time :at]) [:a :b]))) + (is (nil? (get-in r [:clip :symbols :main :nodes :a]))) + (is (nil? (get-in r [:clip :symbols :main :nodes :b]))) + (is (= [[:node :box :a [:target :a]] [:node :box :b [:target :b]]] (:selections r))))) + +(deftest reparenting-refuses-the-entire-forest-on-a-cycle + (let [doc (reparent-document) + r (clipboard/move-many doc {} :main + [(address :main :a [:a]) (address :main :target [:target])] + [:target] 6 0)] + (is (:refused r)) + (is (nil? (:clip r))))) + +(deftest reparenting-into-a-lane-refuses-collision-atomically + (let [doc (-> (reparent-document) + (assoc-in [:symbols :box :display] :lane) + (assoc-in [:symbols :box :nodes :occupied] (fixture/cel :occupied :drawing-a 4 4 0))) + r (clipboard/move-many doc {} :main + [(address :main :a [:a]) (address :main :b [:b])] + [:target] 6 0)] + (is (:refused r)) + (is (nil? (:clip r))))) + +(deftest dropping-the-forest-shifts-it-as-a-unit + (let [r (clipboard/move-many (reparent-document) {} :main + [(address :main :a [:a]) (address :main :b [:b])] + [:target] 6 3)] + (is (nil? (:refused r))) + (is (= [3 7] (mapv #(get-in r [:clip :symbols :box :nodes % :time :at]) [:a :b]))))) diff --git a/frontend/test/arthur/domain/keyframes_test.cljs b/frontend/test/arthur/domain/keyframes_test.cljs new file mode 100644 index 0000000..e445ac7 --- /dev/null +++ b/frontend/test/arthur/domain/keyframes_test.cljs @@ -0,0 +1,30 @@ +(ns arthur.domain.keyframes-test + (:require [cljs.test :refer [deftest is]] + [arthur.domain.keyframes :as k])) + +(def items [{:sid :main :id :a :channel [:xform :pos] :frame 2 :at 2 :scale 1} + {:sid :main :id :a :channel [:xform :pos] :frame 4 :at 4 :scale 1}]) +(def document {:symbols {:main {:nodes {:a {:channels {[:xform :pos] + {:animated? true :interp :linear :keys {2 [2 0] 4 [4 0] 8 [8 0]} + :segments {2 :hold}}}}}}}}) +(defn channel [d] (get-in d [:symbols :main :nodes :a :channels [:xform :pos]])) + +(deftest simultaneous-moves-preserve-values-and-segments + (let [c (channel (k/edit-keys document items 2))] + (is (= {4 [2 0] 6 [4 0] 8 [8 0]} (:keys c))) + (is (= {4 :hold} (:segments c))))) + +(deftest deleting-last-key-keeps-its-value + (let [d (k/edit-keys document items nil) + last-key (assoc (first items) :frame 8 :at 8)] + (is (= {8 [8 0]} (:keys (channel d)))) + (is (= {:animated? false :value [8 0]} (channel (k/edit-keys d [last-key] nil)))))) + +(deftest moving-left-clamps-the-whole-selection + (is (= [0 2] (mapv :frame (k/shifted items -10)))) + (is (= {0 [2 0] 2 [4 0] 8 [8 0]} (:keys (channel (k/edit-keys document items -10)))))) + +(deftest retimed-keys-snap-in-their-own-clock + (let [key (assoc (first items) :scale 2 :at 14)] + (is (= 3 (:frame (first (k/shifted [key] 2))))) + (is (= 16 (:at (first (k/shifted [key] 2))))))) diff --git a/frontend/test/arthur/domain/sequence_test.cljs b/frontend/test/arthur/domain/sequence_test.cljs index 8a9160e..f2cabfc 100644 --- a/frontend/test/arthur/domain/sequence_test.cljs +++ b/frontend/test/arthur/domain/sequence_test.cljs @@ -857,3 +857,25 @@ {:extent :grow-symbol :remainder-id :rest}))] (is (empty? (symbol/overlaps (get-in mixed [:symbols :main])))) (is (empty? (clip/problems mixed)))))) + +(deftest moving-a-selection-in-a-lane-is-simultaneous + (let [doc (document) + moved (:clip (nest/slide-many doc :main [[:a] [:b]] 1))] + (is (nil? moved)) + (is (:refused (nest/slide-many doc :main [[:a] [:b]] 1)) + "the whole group refuses collision with the unselected insert"))) + +(deftest moving-all-lane-clips-preserves-their-spans + (let [doc (document) + moved (:clip (nest/slide-many doc :main [[:a] [:b] [:insert]] 2))] + (is (some? moved)) + (is (= [2 6 10] (mapv #(get-in moved [:symbols :main :nodes % :time :at]) [:a :b :insert]))) + (is (= [0 4] (get-in moved [:symbols :main :nodes :a :span]))))) + +(deftest a-selected-instance-carries-its-selected-contents-once + (let [doc (document) + result (nest/slide-many doc :main [[:insert] [:insert :mark]] 2)] + (is (nil? (:refused result))) + (is (= 10 (get-in result [:clip :symbols :main :nodes :insert :time :at]))) + (is (= (get-in doc [:symbols :wave :nodes]) + (get-in result [:clip :symbols :wave :nodes]))))) diff --git a/frontend/test/browser/automation.mjs b/frontend/test/browser/automation.mjs new file mode 100644 index 0000000..05767ea --- /dev/null +++ b/frontend/test/browser/automation.mjs @@ -0,0 +1,264 @@ +// Browser smoke test for timeline keyframes and group editing. It uses the in-memory +// document and disables project routing, so it performs no server-side write. +import { spawn } from 'node:child_process'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import assert from 'node:assert/strict'; + +const url = process.env.ARTHUR_URL ?? 'http://localhost:8778/'; +const profile = mkdtempSync(join(tmpdir(), 'arthur-timeline-')); +const port = 9336; +const chrome = spawn(process.env.CHROME ?? '/usr/bin/chromium', [ + '--headless=new', '--no-sandbox', '--disable-gpu', '--no-first-run', + '--no-default-browser-check', '--mute-audio', '--window-size=1440,1000', + `--user-data-dir=${profile}`, `--remote-debugging-port=${port}`, url, +], { stdio: 'ignore' }); +const sleep = ms => new Promise(resolve => setTimeout(resolve, ms)); +let ws; + +try { + let target; + for (let i = 0; i < 100 && !target; i++) { + await sleep(100); + try { + target = (await fetch(`http://127.0.0.1:${port}/json/list`).then(r => r.json())) + .find(t => t.type === 'page' && t.url.startsWith(url)); + } catch { /* Chromium is still starting. */ } + } + assert(target, 'browser exposes the editor page'); + ws = new WebSocket(target.webSocketDebuggerUrl); + await new Promise((resolve, reject) => { ws.onopen = resolve; ws.onerror = reject; }); + + let serial = 0; + const pending = new Map(); + const errors = []; + ws.onmessage = ({ data }) => { + const msg = JSON.parse(data); + if (msg.method === 'Runtime.exceptionThrown') errors.push(msg.params.exceptionDetails); + if (msg.id && pending.has(msg.id)) { + const waiting = pending.get(msg.id); + pending.delete(msg.id); + if (msg.error) waiting.reject(new Error(JSON.stringify(msg.error))); + else waiting.resolve(msg.result); + } + }; + const send = (method, params = {}) => new Promise((resolve, reject) => { + const id = ++serial; + pending.set(id, { resolve, reject }); + ws.send(JSON.stringify({ id, method, params })); + }); + const evaluate = async expression => { + const result = await send('Runtime.evaluate', { + expression, returnByValue: true, awaitPromise: true, + }); + if (result.exceptionDetails) throw new Error(JSON.stringify(result.exceptionDetails)); + return result.result.value; + }; + + await send('Runtime.enable'); + for (let i = 0; i < 100; i++) { + try { + if (await evaluate('typeof arthur !== "undefined" && !!document.querySelector("canvas.stage")')) break; + } catch (error) { + if (!error.message.includes("Cannot find default execution context")) throw error; + } + await sleep(100); + } + await evaluate(`(() => { + const k = cljs.core.keyword; + cljs.core.swap_BANG_(re_frame.db.app_db, db => cljs.core.assoc(db, k('route'), k('local-test'))); + window.laneSnapshot = () => { + const db = cljs.core.deref(re_frame.db.app_db); + return cljs.core.clj__GT_js(arthur.footage.store.entry(cljs.core.get(db, k('clip/current')))); + }; + return true; + })()`); + await sleep(250); + + await evaluate(`(() => { + const k = cljs.core.keyword, v = cljs.core.vector, m = cljs.core.hash_map; + let c = arthur.domain.clip.blank(); + const rect = (id, at, keys) => m(k('id'), k(id), k('kind'), k('rect'), k('z'), id, + k('time'), m(k('at'), at, k('rate'), 1), k('span'), v(0, 20), + k('channels'), keys ? m(v(k('xform'), k('pos')), arthur.domain.channel.keyed(m(2, v(2,0), 4, v(4,0)), k('linear'))) : m()); + const target = m(k('id'), k('target'), k('kind'), k('instance'), k('z'), 'z', + k('source'), m(k('symbol'), k('box')), k('span'), v(0, 60), + k('time'), m(k('at'), 0, k('rate'), 1), + k('playback'), m(k('in'), 0, k('speed'), 1, k('end'), k('stop'))); + c = cljs.core.assoc_in(c, v(k('symbols'), k('box')), m(k('id'), k('box'), k('frames'), 60, k('nodes'), m())); + c = cljs.core.assoc_in(c, v(k('symbols'), k('main'), k('nodes')), m(k('a'), rect('a',0,true), k('b'), rect('b',4,false), k('target'), target)); + const id = arthur.footage.store.install_BANG_(m(k('clip'), c, k('store'), m()), 'timeline-browser'); + const a = v(k('node'),k('main'),k('a'),v(k('a'))), b = v(k('node'),k('main'),k('b'),v(k('b'))); + cljs.core.swap_BANG_(re_frame.db.app_db, db => cljs.core.assoc(db, + k('clip/current'), id, k('paint/revision'), 100, + k('ui'), m(k('open'),k('main'),k('selection'), b,k('selections'),v(a,b),k('expanded'),cljs.core.hash_set(v(k('a')))), + k('playback'),m(k('frame'),0,k('playing?'),false))); + return true; + })()`); + await sleep(250); + const shot = async () => JSON.parse(JSON.stringify((await evaluate('laneSnapshot()')).clip, + (key, value) => key === 'channels' && Object.keys(value).length === 0 ? undefined : value)); + assert.equal(await evaluate('document.querySelectorAll(".tl-track:not(.automation) button.tl-key").length'), 0, + 'symbol overview keyframes are passive'); + assert.equal(await evaluate('document.querySelectorAll(".tl-track:not(.automation) .tl-key").length'), 2, + 'symbol overview still shows keyframe ticks'); + const initial = await shot(); + await evaluate(`(() => { + const track = document.querySelector('.tl-span.on').closest('.tl-track'); + track.focus(); track.dispatchEvent(new KeyboardEvent('keydown', {key:'ArrowRight',bubbles:true})); return true; + })()`); + await sleep(100); + assert.equal((await shot()).symbols.main.nodes.a.time.at, 1); + assert.equal((await shot()).symbols.main.nodes.b.time.at, 5); + await evaluate(`re_frame.core.dispatch_sync(cljs.core.vector(cljs.core.keyword('arthur.events.history/undo')))`); + await sleep(100); + assert.deepEqual(await shot(), initial, 'group shift undoes atomically'); + await evaluate(`(() => { + const span=document.querySelector('.tl-span.on'), track=span.closest('.tl-track'); + const box=track.getBoundingClientRect(), x=box.left+box.width*10/120, dx=box.width*2/120; + span.dispatchEvent(new PointerEvent('pointerdown',{bubbles:true,pointerId:20,clientX:x})); + track.dispatchEvent(new PointerEvent('pointermove',{bubbles:true,pointerId:20,clientX:x+dx})); + track.dispatchEvent(new PointerEvent('pointerup',{bubbles:true,pointerId:20,clientX:x+dx})); + return true; + })()`); + await sleep(150); + assert.equal((await shot()).symbols.main.nodes.a.time.at, 2, 'drag shifts the first selected node'); + assert.equal((await shot()).symbols.main.nodes.b.time.at, 6, 'drag shifts the other selected node'); + assert.equal(await evaluate('document.querySelectorAll(".tl-label.on").length'), 2, + 'group dragging preserves selection'); + await evaluate(`re_frame.core.dispatch_sync(cljs.core.vector(cljs.core.keyword('arthur.events.history/undo')))`); + await sleep(100); + assert.deepEqual(await shot(), initial, 'group dragging undoes atomically'); + assert.equal(await evaluate('document.querySelectorAll("button.tl-key").length'), 2); + await evaluate(`(() => { + const keys = [...document.querySelectorAll('button.tl-key')]; + for (let i=0;i<2;i++) { + keys[i].dispatchEvent(new PointerEvent('pointerdown',{bubbles:true,pointerId:i+1,clientX:200+i*20,shiftKey:!!i})); + keys[i].dispatchEvent(new PointerEvent('pointerup',{bubbles:true,pointerId:i+1,clientX:200+i*20,shiftKey:!!i})); + } return true; + })()`); + await sleep(100); + assert.equal(await evaluate('document.querySelectorAll("button.tl-key.selected").length'), 2); + await evaluate(`(() => { + const key=document.querySelector('button.tl-key.selected'); + const box=key.parentElement.getBoundingClientRect(), x=box.left+box.width*2/120; + key.dispatchEvent(new PointerEvent('pointerdown',{bubbles:true,pointerId:21,clientX:x})); + key.dispatchEvent(new PointerEvent('pointermove',{bubbles:true,pointerId:21,clientX:x+box.width*2/120})); + key.dispatchEvent(new PointerEvent('pointerup',{bubbles:true,pointerId:21,clientX:x+box.width*2/120})); + return true; + })()`); + await sleep(100); + assert.deepEqual(await evaluate(`(() => { + const k=cljs.core.keyword,v=cljs.core.vector; + const c=arthur.footage.store.entry(cljs.core.get(cljs.core.deref(re_frame.db.app_db),k('clip/current'))); + return cljs.core.clj__GT_js(cljs.core.sort(cljs.core.keys(cljs.core.get_in(c, + v(k('clip'),k('symbols'),k('main'),k('nodes'),k('a'),k('channels'),v(k('xform'),k('pos')),k('keys')))))); + })()`), [4,6], 'drag moves both selected keys without losing colliding source frames'); + await evaluate(`document.activeElement.dispatchEvent(new KeyboardEvent('keydown',{key:'ArrowRight',bubbles:true}))`); + await sleep(100); + assert.equal(await evaluate('document.querySelectorAll("button.tl-key.selected").length'), 2); + await evaluate(`document.activeElement.dispatchEvent(new KeyboardEvent('keydown',{key:'Delete',bubbles:true}))`); + await sleep(100); + assert.equal(await evaluate('document.querySelectorAll("button.tl-key").length'), 0); + assert((await shot()).symbols.main.nodes.a, 'deleting keys preserves their node'); + await evaluate(`re_frame.core.dispatch_sync(cljs.core.vector(cljs.core.keyword('arthur.events.history/undo')))`); + await evaluate(`re_frame.core.dispatch_sync(cljs.core.vector(cljs.core.keyword('arthur.events.history/undo')))`); + await sleep(100); + await evaluate(`re_frame.core.dispatch_sync(cljs.core.vector(cljs.core.keyword('arthur.events.history/undo')))`); + await sleep(100); + assert.deepEqual(await shot(), initial, 'key dragging, nudge and delete undo independently'); + await evaluate(`(() => { + const k=cljs.core.keyword,v=cljs.core.vector; + const a=v(k('node'),k('main'),k('a'),v(k('a'))),b=v(k('node'),k('main'),k('b'),v(k('b'))); + re_frame.core.dispatch_sync(v(k('arthur.events.ui/select-many'),v(a,b))); + arthur.ui.drag.row_BANG_(v(k('a')),k('rect'),a,v(a,b)); + const row=[...document.querySelectorAll('.tl-label')].find(x=>x.textContent.includes('box')); + const r=row.getBoundingClientRect(); + row.dispatchEvent(new DragEvent('drop',{bubbles:true,clientY:r.top+r.height/2,dataTransfer:new DataTransfer()})); + return true; + })()`); + await sleep(150); + const moved=await shot(); + assert(moved.symbols.box.nodes.a && moved.symbols.box.nodes.b, 'row drop reparents the entire group'); + assert.equal(moved.symbols.box.nodes.b.time.at, 4); + assert(!moved.symbols.main.nodes.a && !moved.symbols.main.nodes.b); + await evaluate(`re_frame.core.dispatch_sync(cljs.core.vector(cljs.core.keyword('arthur.events.history/undo')))`); + await sleep(100); + assert.deepEqual(await shot(), initial, 'group reparenting is one undo step'); + await evaluate(`(() => { + const rows=[...document.querySelectorAll('.tl-label')].filter(x=>x.querySelector('.tl-delete') || /box|rect/.test(x.textContent)); + rows[0].dispatchEvent(new MouseEvent('click',{bubbles:true})); + rows.at(-1).dispatchEvent(new MouseEvent('click',{bubbles:true,shiftKey:true})); + })()`); + await sleep(100); + assert.equal(await evaluate('document.querySelectorAll(".tl-label.on").length'), 3, 'Shift selects the intervening row headers'); + await evaluate(`document.querySelectorAll('.tl-label.on')[1].dispatchEvent(new MouseEvent('click',{bubbles:true,metaKey:true}))`); + await sleep(100); + assert.equal(await evaluate('document.querySelectorAll(".tl-label.on").length'), 2, 'Command toggles just one row'); + await evaluate(`(() => { + const k=cljs.core.keyword,v=cljs.core.vector,m=cljs.core.hash_map; + let c=cljs.core.get(arthur.footage.store.entry(cljs.core.get(cljs.core.deref(re_frame.db.app_db),k('clip/current'))),k('clip')); + c=cljs.core.assoc_in(c,v(k('symbols'),k('main'),k('nodes'),k('a'),k('channels'),v(k('xform'),k('scale'))), + arthur.domain.channel.keyed(m(2,v(1,1),4,v(2,2)),k('linear'))); + const id=arthur.footage.store.install_BANG_(m(k('clip'),c,k('store'),m()),'selection-browser'); + cljs.core.swap_BANG_(re_frame.db.app_db,db=>cljs.core.assoc(db,k('clip/current'),id,k('ui'), + m(k('open'),k('main'),k('expanded'),cljs.core.hash_set(v(k('a')))))); + })()`); + await sleep(200); + await evaluate(`(() => { + const tracks=document.querySelector('.tl-tracks'); + const rows=[...tracks.querySelectorAll('.tl-track')].filter(x=>x.querySelectorAll('button.tl-key').length===2); + const first=rows[0], last=rows[1], b=first.getBoundingClientRect(), end=last.getBoundingClientRect(); + first.dispatchEvent(new PointerEvent('pointerdown',{bubbles:true,pointerId:50,clientX:b.left+1,clientY:b.top+1})); + tracks.dispatchEvent(new PointerEvent('pointermove',{bubbles:true,pointerId:50,clientX:b.left+b.width*6/120,clientY:end.bottom-1})); + tracks.dispatchEvent(new PointerEvent('pointerup',{bubbles:true,pointerId:50,clientX:b.left+b.width*6/120,clientY:end.bottom-1})); + })()`); + await sleep(100); + assert.equal(await evaluate('document.querySelectorAll("button.tl-key.selected").length'), 4, 'marquee selects keys across both automation rows'); + await evaluate(`document.querySelector('.tl-tracks').dispatchEvent(new KeyboardEvent('keydown',{key:'ArrowRight',bubbles:true}))`); + await sleep(100); + assert.deepEqual(await evaluate(`(() => { + const k=cljs.core.keyword,v=cljs.core.vector; + const c=arthur.footage.store.entry(cljs.core.get(cljs.core.deref(re_frame.db.app_db),k('clip/current'))); + return ['pos','scale'].map(channel=>cljs.core.clj__GT_js(cljs.core.sort(cljs.core.keys(cljs.core.get_in(c, + v(k('clip'),k('symbols'),k('main'),k('nodes'),k('a'),k('channels'),v(k('xform'),k(channel)),k('keys'))))))); + })()`), [[3,5],[3,5]], 'marquee selection nudges keys in both automations together'); + await evaluate(`document.querySelector('.tl-tracks').dispatchEvent(new KeyboardEvent('keydown',{key:'Escape',bubbles:true}))`); + await sleep(100); + assert.equal(await evaluate('document.querySelectorAll("button.tl-key.selected").length'), 0); + await evaluate(`(() => { + const rows=[...document.querySelectorAll('.tl-track')].filter(x=>x.querySelectorAll('button.tl-key').length===2); + const a=rows[0].querySelector('button.tl-key'), b=rows[1].querySelectorAll('button.tl-key')[1]; + for (const [key,shift] of [[a,false],[b,true]]) { + const box=key.getBoundingClientRect(); + for (const type of ['pointerdown','pointerup']) key.dispatchEvent(new PointerEvent(type,{bubbles:true,pointerId:70,clientX:box.left,clientY:box.top,shiftKey:shift})); + } + })()`); + await sleep(100); + assert.equal(await evaluate('document.querySelectorAll("button.tl-key.selected").length'), 4, 'Shift-click selects a range across automation rows'); + await evaluate(`(() => { + const key=[...document.querySelectorAll('.tl-track')].filter(x=>x.querySelectorAll('button.tl-key').length===2)[1].querySelectorAll('button.tl-key')[1]; + const box=key.getBoundingClientRect(); + for (const type of ['pointerdown','pointerup']) key.dispatchEvent(new PointerEvent(type,{bubbles:true,pointerId:71,clientX:box.left,metaKey:true})); + })()`); + await sleep(100); + assert.equal(await evaluate('document.querySelectorAll("button.tl-key.selected").length'), 3, 'Command-click toggles one channel key'); + await evaluate(`document.querySelector('.tl-tracks').dispatchEvent(new KeyboardEvent('keydown',{key:'a',metaKey:true,bubbles:true}))`); + await sleep(100); + assert.equal(await evaluate('document.querySelectorAll("button.tl-key.selected").length'), 4, 'Command-A selects all visible keyframes'); + assert.equal(errors.length, 0, JSON.stringify(errors)); + console.log('PASS: passive overview ticks; automation keyframe selection, dragging, marquee, keyboard editing, and undo'); +} finally { + if (ws?.readyState === WebSocket.OPEN) ws.close(); + chrome.kill('SIGTERM'); + await Promise.race([ + new Promise(resolve => chrome.once('exit', resolve)), + sleep(2000).then(() => { if (chrome.exitCode === null) chrome.kill('SIGKILL'); }), + ]); + try { + rmSync(profile, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); + } catch (error) { + if (error.code !== 'ENOTEMPTY') throw error; + } +} diff --git a/static/arthur/app.css b/static/arthur/app.css index 9915246..8498c04 100644 --- a/static/arthur/app.css +++ b/static/arthur/app.css @@ -1285,15 +1285,16 @@ button.share-button:hover, button.share-button.on { filter: brightness(1.1); } } .tl-label.ghost { color: var(--sel); font-style: italic; } -/* Flash's keyframe: a filled dot. */ +/* Passive animation ticks leave the clip underneath available to grab. */ .tl-key { position: absolute; top: 50%; - width: 7px; - height: 7px; - margin: -3.5px 0 0 -3.5px; + width: 2px; + height: 6px; + margin: -3px 0 0 -1px; background: var(--key); - border-radius: 50%; + opacity: 0.5; + border-radius: 1px; /* So grabbing a dot grabs the bar under it. */ pointer-events: none; } @@ -1738,6 +1739,7 @@ button.share-button:hover, button.share-button.on { filter: brightness(1.1); } .passepartout-controls summary { cursor: pointer; } .stage-wrap { flex: 0 0 auto; } + .onion-skin { position: absolute; inset: 0; pointer-events: none; image-rendering: pixelated; } .onion-controls { display: inline-flex; align-items: center; } .onion-controls > button { border-radius: 2px 0 0 2px; } @@ -1750,3 +1752,7 @@ button.share-button:hover, button.share-button.on { filter: brightness(1.1); } .onion-controls summary::-webkit-details-marker { display: none; } .onion-controls .view-popout { line-height: 1.4; } .onion-controls select { width: 100%; } + +/* Expanded automation lanes expose editable keyframe dots. */ +button.tl-key { pointer-events: auto; padding: 0; border: 0; width: 11px; height: 11px; margin: -5.5px; cursor: ew-resize; z-index: 4; opacity: 1; border-radius: 50%; } +button.tl-key.selected { background: var(--pick); box-shadow: 0 0 0 2px var(--pick); }