Keep overview keyframes passive and edit expanded automation lanes

This commit is contained in:
Olive Vaughn 2026-10-04 02:05:22 -04:00
parent 606055382b
commit 4d441ae606
12 changed files with 856 additions and 69 deletions

View file

@ -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))))))))))

View file

@ -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)))))))

View file

@ -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`.

View file

@ -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))))))))

View file

@ -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

View file

@ -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."

View file

@ -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)))
(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])))
@(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)]
(let [many (drag/row-selections)]
(drag/done!)
(reset! over nil)
(when (< 1 (count from))
(rf/dispatch [::ui/move-node 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 []

View file

@ -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])))))

View file

@ -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)))))))

View file

@ -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])))))

View file

@ -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;
}
}

View file

@ -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); }