Keep overview keyframes passive and edit expanded automation lanes
This commit is contained in:
parent
606055382b
commit
4d441ae606
12 changed files with 856 additions and 69 deletions
|
|
@ -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))))))))))
|
||||
|
|
|
|||
37
frontend/src/arthur/domain/keyframes.cljs
Normal file
37
frontend/src/arthur/domain/keyframes.cljs
Normal 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)))))))
|
||||
|
|
@ -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`.
|
||||
|
||||
|
|
|
|||
|
|
@ -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))))))))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
|
|
|
|||
|
|
@ -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 []
|
||||
|
|
|
|||
|
|
@ -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])))))
|
||||
|
|
|
|||
30
frontend/test/arthur/domain/keyframes_test.cljs
Normal file
30
frontend/test/arthur/domain/keyframes_test.cljs
Normal 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)))))))
|
||||
|
|
@ -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])))))
|
||||
|
|
|
|||
264
frontend/test/browser/automation.mjs
Normal file
264
frontend/test/browser/automation.mjs
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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); }
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue