Compare commits

..

No commits in common. "78fb120edd711c7f13e965573b916544324c25ad" and "eca1a96b82caa2171faa26e40ef70290bf101ffc" have entirely different histories.

17 changed files with 193 additions and 459 deletions

@ -1 +0,0 @@
Subproject commit f4dd04764204506fc275180364d0366d693036f4

View file

@ -57,9 +57,8 @@
THE EDITOR'S, NOT THE DOCUMENT'S. Showing a reference is a way of looking at
the stage, like solo and zoom: not an undo step, not sent to collaborators,
and it cannot reach an export. A layer is shown unless it is in `:hidden`, so
footage brought in shows without being found and switched on first — once
tracing itself is on, which it is not until asked for."
{:on? false :opacity 0.5 :hidden #{}})
footage brought in shows without being found and switched on first."
{:on? true :opacity 0.5 :hidden #{}})
(def default
{;; --- the document ---

View file

@ -25,14 +25,13 @@
another clip is `arthur.domain.bring`. This namespace is the document and the
operations that only need the document.
NO SYMBOL IS SPECIAL. There is no reserved root id: which symbol is on screen
is the editor's state, not the document's, and every function here that needs
a symbol is told which. A new document has one symbol called `:main` because
it has to be called something, and that is all the name means — it can be
renamed or placed inside another symbol like any of them. What the document
does say is `:root`, which symbol it opens on: a pointer, not a kind of
symbol, the way a Flash file names its scene. See `opens-on` for why that
cannot be worked out instead.
NO SYMBOL IS SPECIAL. There is no reserved root and no pointer to one: which
symbol is on screen is the editor's state, not the document's, and every
function here that needs a symbol is told which. A new document has one symbol
called `:main` because it has to be called something, and that is all the name
means — it can be renamed, placed inside another symbol or deleted like any of
them. `unplaced` answers the question a reserved root used to: which symbols
nothing else places, and so which ones a person opening the document wants.
:fps is the output grid. A symbol's optional :fps names the native grid its
frames were authored or measured on; absent means the document's grid."
@ -53,7 +52,7 @@
layer must not be able to have. Add the field here and to `leaf/leaves` and
`leaf/clip` in the same commit."
#{:name :fps :analyses :subjects :features :groups :width :height :symbols
:palettes :default-palette :root})
:palettes :default-palette})
(defn symbol
"One of the clip's symbols, by id."
@ -202,39 +201,18 @@
(let [placed (into #{} (mapcat #(places clip %)) (keys (:symbols clip)))]
(vec (sort-by str (remove placed (keys (:symbols clip)))))))
(defn- longest-unplaced
"The longest symbol nothing else places, ties broken by id, and never a
tracing symbol: that is footage nobody has placed yet, as long as its take."
(defn opens-on
"The symbol a document opens on: the longest one nothing else places, ties
broken by id. The symbol that contains everything else is the longest of the
unplaced ones in every document made so far, and a reserved name is what this
replaces.
Never a tracing symbol: it is footage nobody has placed yet, and as long as
the take it came from, so it would otherwise win."
[clip]
(first (sort-by (fn [sid] [(- (or (frames clip sid) 0)) (str sid)])
(remove #(trace? (symbol clip %)) (unplaced clip)))))
(defn opens-on
"The symbol a document opens on: its `:root`.
IT IS STORED, NOT WORKED OUT. It used to be the longest unplaced symbol, on
the theory that the one containing everything else is always that. A take
disproves it: imported footage is a symbol a thousand frames long, and the
moment its instance is deleted, or the drop lands somewhere other than the
root, nothing places it and it outranks a 120-frame `:main`. The document then
opened on the take, and `set-root-fps` rewrote the take's rate to the
project's on the way in.
A document with no `:root` — a demo, a fixture — still gets the old answer."
[clip]
(let [root (:root clip)]
(if (contains? (:symbols clip) root) root (longest-unplaced clip))))
(defn pin-root
"Give a document saved before `:root` existed the root it was made with.
Every such document started as `blank`, whose root is `:main`, and ids never
change, so `:main` is the answer wherever it survives; the old rule is only
for documents that never had one."
[clip]
(cond-> clip
(not (:root clip))
(assoc :root (if (contains? (:symbols clip) :main) :main (longest-unplaced clip)))))
(defn set-root-fps
"Set the document/output rate and the root symbol's editing rate together.
@ -272,7 +250,6 @@
:width 320 :height 200
:palettes {pal/default-id pal/default-palette}
:default-palette pal/default-id
:root :main
;; No native fps yet: an untouched canvas follows the project grid. Imported
;; and generated symbols carry their own rate explicitly.
:symbols {:main {:id :main :frames blank-frames :nodes {}}}})
@ -374,58 +351,6 @@
(active-palette [this]
"The palette selected by this resolver's most recently resolved frame."))
(defn- channel-value [store selection frame]
(cond
(nil? selection) nil
(and (map? selection) (contains? selection :animated?))
(ch/value-at selection frame store)
:else selection))
(defn palette-at
"The palette symbol `owner` draws in at `frame`, as the stage's root does: a
palette id, or `{:from :to :t}` while its palette lane blends from one to the
next. `inherited` is what an unset palette falls back to, then `default`."
[clip store default owner frame inherited]
;; A palette track has meaningful uncovered time. Ordinary held
;; channels clamp to their first key before it, but doing that
;; here would erase the gap before the first palette segment.
(let [channel-value (partial channel-value store)
fallback (or (channel-value (:palette owner) frame)
inherited default)
materialize (fn [choice]
(cond
(= pal/inherit choice) fallback
(map? choice) (-> choice
(update :from #(if (= pal/inherit %) fallback %))
(update :to #(if (= pal/inherit %) fallback %)))
:else choice))
track (:palette-channel owner)
track-value (if-let [ks (:keys track)]
(some->> (keys ks)
(filter #(<= % frame))
sort last
(get ks))
(channel-value track frame))]
(or (when-let [track-sid (and (keyword? (:palette-track owner))
(:palette-track owner))]
(let [track-symbol (symbol clip track-sid)
palette-clip (first
(filter (fn [n]
(let [[a b] (node/placed-span n)]
(and a (<= a frame) (< frame b))))
(symbol/children (:nodes track-symbol))))
palette-symbol (some-> palette-clip node/source
(#(symbol clip %)))
fallback (when (= :palette (:type palette-symbol))
(:palette-ref palette-symbol))
choice (get-in palette-clip [:channels [:palette]])
start (some-> palette-clip node/placed-span first)]
(or (when (and choice start)
(materialize (channel-value choice (- frame start))))
fallback)))
track-value
fallback)))
(defn resolver
"Resolve an output frame, selecting native content at each symbol boundary.
Every instance owns its cursors and buffers. The IResolver queries return
@ -433,8 +358,51 @@
[clip sid store palette opts]
(let [context? (and (map? palette) (:palettes palette) (:offsets palette))
active-palette-state (atom (:default palette))]
(letfn [(root-selection-at [owner frame inherited]
(palette-at clip store (:default palette) owner frame inherited))
(letfn [(channel-value [selection frame]
(cond
(nil? selection) nil
(and (map? selection) (contains? selection :animated?))
(ch/value-at selection frame store)
:else selection))
(root-selection-at [owner frame inherited]
;; A palette track has meaningful uncovered time. Ordinary held
;; channels clamp to their first key before it, but doing that
;; here would erase the gap before the first palette segment.
(let [fallback (or (channel-value (:palette owner) frame)
inherited (:default palette))
materialize (fn [choice]
(cond
(= pal/inherit choice) fallback
(map? choice) (-> choice
(update :from #(if (= pal/inherit %) fallback %))
(update :to #(if (= pal/inherit %) fallback %)))
:else choice))
track (:palette-channel owner)
track-value (if-let [ks (:keys track)]
(some->> (keys ks)
(filter #(<= % frame))
sort last
(get ks))
(channel-value track frame))]
(or (when-let [track-sid (and (keyword? (:palette-track owner))
(:palette-track owner))]
(let [track-symbol (symbol clip track-sid)
palette-clip (first
(filter (fn [n]
(let [[a b] (node/placed-span n)]
(and a (<= a frame) (< frame b))))
(symbol/children (:nodes track-symbol))))
palette-symbol (some-> palette-clip node/source
(#(symbol clip %)))
fallback (when (= :palette (:type palette-symbol))
(:palette-ref palette-symbol))
choice (get-in palette-clip [:channels [:palette]])
start (some-> palette-clip node/placed-span first)]
(or (when (and choice start)
(materialize (channel-value choice (- frame start))))
fallback)))
track-value
fallback)))
(selection-at [owner frame inherited]
(let [selection (:palette owner)
chosen (cond
@ -746,9 +714,6 @@
(when (and (contains? clip :default-palette) (map? (:palettes clip))
(not (contains? (:palettes clip) (:default-palette clip))))
[":default-palette must name a project palette"])
(when (and (contains? clip :root) (map? (:symbols clip))
(not (contains? (:symbols clip) (:root clip))))
[(str ":root names missing symbol " (pr-str (:root clip)))])
(for [[id p] (:palettes clip)
:when (or (not= id (:id p)) (not (pal/valid-palette? p)))]
(str "palette " (pr-str id) " is invalid or has a different :id"))

View file

@ -12,7 +12,6 @@
clip/<cid>/stage width, height
clip/<cid>/palette/<pid> a named indexed palette asset
clip/<cid>/palette-default the project fallback palette id
clip/<cid>/root the symbol the document opens on
clip/<cid>/source the analysis record this came out of
clip/<cid>/subject/<subj> a tracked subject and its params
clip/<cid>/feature/<fid> one feature: area, nodes, params
@ -139,7 +138,6 @@
(some-leaf (at "timing") (select-keys clip [:fps]))
(some-leaf (at "stage") (select-keys clip [:width :height]))
(some-leaf (at "palette-default") (select-keys clip [:default-palette]))
(some-leaf (at "root") (select-keys clip [:root]))
(some-leaf (at "analyses") (:analyses clip))
(concat
(for [[id v] (:subjects clip)] {(at "subject" (segment id)) v})
@ -200,7 +198,6 @@
"stage" (merge acc v)
"analyses" (assoc acc :analyses v)
"palette-default" (merge acc v)
"root" (merge acc v)
"palette" (assoc-in acc [:palettes (unsegment a)] v)
"subject" (assoc-in acc [:subjects (unsegment a)] v)
"feature" (assoc-in acc [:features (unsegment a)] v)
@ -242,7 +239,7 @@
false)
(case (count p)
;; The clip's own facts carry no id.
3 (#{"name" "timing" "stage" "analyses" "palette-default" "root"} (nth p 2))
3 (#{"name" "timing" "stage" "analyses" "palette-default"} (nth p 2))
4 (#{"subject" "feature" "group" "palette"} (nth p 2))
false))))]
(vec

View file

@ -113,9 +113,7 @@
;; also normalizes documents saved by the earlier model,
;; where changing project FPS left the root on its old
;; editing grid.
built (-> (:clip loaded)
clip/pin-root
(clip/set-root-fps (:fps (:clip loaded))))]
built (clip/set-root-fps (:clip loaded) (:fps (:clip loaded)))]
(let [entry (merge (select-keys built [:fps :width :height])
{:label (str (or (.-name clip-json) cid) " (saved)")
:cid cid

View file

@ -841,7 +841,6 @@
:width (first stage) :height (second stage)
:palettes {pal/default-id pal/default-palette}
:default-palette pal/default-id
:root :main
:subjects (into {} (map (fn [[id _]]
[id {:id id :params {}
:analysis (:id analysis)

View file

@ -136,25 +136,6 @@
:<- [::palette]
(fn [compiled _] (:ramp compiled)))
(rf/reg-sub
::palettes-shown
:<- [::clip]
:<- [::open]
:<- [::store]
:<- [::open-frame]
:<- [::palette]
;; The palettes the stage is drawing in at the playhead: the open symbol's own,
;; BOTH ends while its palette lane blends from one to the next, and any an
;; instance in it is set to. The palette strip asks it whether the palette it
;; is editing is one of these.
(fn [[document sid store frame compiled] _]
(when-let [sym (and document (clip/symbol document sid))]
(let [at #(clip/palette-at document store (:default compiled) % (or frame 0) nil)
ends #(if (map? %) [(:from %) (:to %)] [%])]
(into (set (ends (at sym)))
(comp (filter :palette) (mapcat (comp ends at)))
(vals (:nodes sym)))))))
(rf/reg-sub
::store
:<- [::clip-id]

View file

@ -21,7 +21,6 @@
half of this (see `@media` in app.css), and a drawer that is shut should not be
holding video thumbnails live."
(:require [arthur.domain.onion :as onion]
[arthur.ui.menu :as menu]
[re-frame.core :as rf]))
(def ^:private narrow?
@ -147,9 +146,8 @@
"− <readout> + for one zoomable surface. THE READOUT IS THE BUTTON BACK: it
says what the zoom is, pressing it returns to normal, and it is accented while
it has something to return from — so the quick way back is in the one place
you already look to find out where you are. `more` joins the group: the
stage's fit."
[k label & more]
you already look to find out where you are."
[k label]
(let [z @(rf/subscribe [::zoom k])
[lo hi] (limits k)
home (k normal)]
@ -164,11 +162,10 @@
(str (js/Math.round (* 100 z)) "%")]
[:button {:disabled (>= z hi) :aria-label (str label " zoom in")
:title (str "zoom " label " in")
:on-click #(rf/dispatch [::set k (stepped k z false)])} "+"]
(into [:<>] more)]))
:on-click #(rf/dispatch [::set k (stepped k z false)])} "+"]]))
(rf/reg-sub ::mat (fn [db _] (merge {:on? true :opacity 0.55} (get-in db [:ui :passepartout]))))
(rf/reg-sub ::mat (fn [db _] (merge {:opacity 0.55} (get-in db [:ui :passepartout]))))
(rf/reg-event-db ::mat (fn [db [_ k v]] (assoc-in db [:ui :passepartout k] v)))
(rf/reg-sub ::onion
@ -178,42 +175,30 @@
(fn [db [_ k v]]
(assoc-in db [:ui :onion (:clip/current db) (get-in db [:ui :open]) k] v)))
(defn overlay
"Something drawn over the stage that is not the picture: a switch, and its
settings in a popover beside it. Tracing, onion and passepartout are all this
one shape, so they read as three of a kind."
[class label on? tip toggle! settings]
[:div.group.overlay {:class class}
[:button {:class (when on? "on") :aria-pressed (boolean on?) :title tip
:on-click toggle!}
label]
[menu/popover {:label "▾" :title (str label " settings")} settings]])
(defn- slider [label lo hi v on-change]
[:label label
[:input {:type "range" :min lo :max hi :step 0.05 :value v
:on-change #(on-change (js/parseFloat (.. % -target -value)))}]])
(defn onion-controls []
(let [{:keys [on? before after opacity scope]} @(rf/subscribe [::onion])]
[overlay "onion-controls" "onion" on?
"Onion skin — neighboring cels of the selected lane"
#(rf/dispatch [::onion :on? (not on?)])
(fn [_]
[:div.view-popout
[:label "Scope"
[:select {:value (name scope)
:on-change #(rf/dispatch [::onion :scope (keyword (.. % -target -value))])}
[:option {:value "selected"} "Selected lane"]
[:option {:value "symbol"} "Whole symbol"]]]
(for [[k label v] [[:before "Previous cels" before] [:after "Next cels" after]]]
^{:key k} [:label label
[:input {:type "number" :min 0 :max 5 :value v
:on-change #(let [n (js/parseInt (.. % -target -value) 10)]
(when-not (js/isNaN n)
(rf/dispatch [::onion k (max 0 (min 5 n))])))}]])
[slider "Opacity" 0.05 0.6 opacity #(rf/dispatch [::onion :opacity %])]
[:small "Previous: red · Next: blue. Select a lane or one of its cels. Hidden during playback."]])]))
[:div.group.onion-controls
[:button {:class (when on? "on") :aria-pressed (boolean on?)
:title "Onion skin — neighboring cels of the selected lane"
:on-click #(rf/dispatch [::onion :on? (not on?)])} "onion"]
[:details
[:summary {:aria-label "Onion skin settings" :title "Onion skin settings"} "▾"]
[:div.view-popout
[:label "Scope"
[:select {:value (name scope)
:on-change #(rf/dispatch [::onion :scope (keyword (.. % -target -value))])}
[:option {:value "selected"} "Selected lane"]
[:option {:value "symbol"} "Whole symbol"]]]
(for [[k label v] [[:before "Previous cels" before] [:after "Next cels" after]]]
^{:key k} [:label label
[:input {:type "number" :min 0 :max 5 :value v
:on-change #(let [n (js/parseInt (.. % -target -value) 10)]
(when-not (js/isNaN n)
(rf/dispatch [::onion k (max 0 (min 5 n))])))}]])
[:label "Opacity"
[:input {:type "range" :min 0.05 :max 0.6 :step 0.05 :value opacity
:on-change #(rf/dispatch [::onion :opacity (js/parseFloat (.. % -target -value))])}]]
[:small "Previous: red · Next: blue. Select a lane or one of its cels. Hidden during playback."]]]]))
(defn fit-stage! []
(when-let [area (.querySelector js/document ".stage-area")]
@ -224,19 +209,14 @@
(/ (- (.-clientHeight area) 32) h))])
(js/requestAnimationFrame #(do (set! (.-scrollLeft area) 0) (set! (.-scrollTop area) 0)))))))
(defn stage-controls
"The stage's own view, after the tracing switch: what else is drawn over it,
then how big it is. Fit is in the zoom group because it IS a zoom."
[]
(let [{:keys [on? opacity]} @(rf/subscribe [::mat])]
(defn stage-controls []
(let [{:keys [opacity]} @(rf/subscribe [::mat])]
[:<>
[onion-controls]
[overlay "passepartout-controls" "passepartout" on?
"Darken the desk around the stage"
#(rf/dispatch [::mat :on? (not on?)])
(fn [_]
[:div.view-popout
[slider "Opacity" 0 1 opacity #(rf/dispatch [::mat :opacity %])]])]
[:span.sep]
[zoomer :stage "the stage"
[:button {:on-click fit-stage! :title "Fit stage in view"} "fit"]]]))
[:button {:on-click fit-stage! :title "Fit stage in view"} "fit"]
[:details.passepartout-controls
[:summary "passepartout"]
[:div.view-popout
[:label "Passepartout"
[:input {:type "range" :min 0 :max 1 :step 0.05 :value opacity
:on-change #(rf/dispatch [::mat :opacity (js/parseFloat (.. % -target -value))])}]]]]]))

View file

@ -79,29 +79,3 @@
:on-click (fn [] (close!) (on-click))}
label
(when sub [:span.sub sub])]))]])]))))
(defn popover
"A button that opens a panel of settings rather than a list of commands: the
onion's counts, a slider, the palette's name. Placed and dismissed exactly as
`view` is — measured against its button, shut by a click anywhere else — so
every popover in the window closes the same way. `body` is a function of the
close function, for a panel with a control that should shut it."
[_ _]
(let [at (r/atom nil)]
(fn [{:keys [label title class]} body]
(let [close! #(reset! at nil)]
[:div.menu-wrap {:class class}
[:button {:class (str "pop" (when @at " on")) :title title :aria-label title
:aria-haspopup "dialog" :aria-expanded (boolean @at)
:on-click (fn [^js e]
(if @at (close!) (reset! at (placement (.-currentTarget e)))))}
label]
(when-let [{:keys [left top bottom max-height]} @at]
[:<>
[:div.menu-scrim {:on-click close!}]
[:div.menu.menu-drop.popout
{:role "dialog" :aria-label title
:style (cond-> {:left (str left "px") :max-height (str max-height "px")}
top (assoc :top (str top "px"))
bottom (assoc :bottom (str bottom "px")))}
(body close!)]])]))))

View file

@ -15,7 +15,6 @@
The footage switch is NOT here. It is a viewing aid rather than something you
set before you draw, so it is a section of the inspector. See `ui/params`."
(:require [arthur.domain.channel :as channel]
[arthur.domain.clip :as clip]
[arthur.domain.node :as node]
[arthur.domain.palette :as pal]
[arthur.domain.symbol :as symbol]
@ -23,8 +22,6 @@
[arthur.events.ui :as ui]
[arthur.subs.render :as render]
[arthur.subs.ui :as sub]
[arthur.ui.menu :as menu]
[clojure.string :as str]
[re-frame.core :as rf]
[reagent.core :as r]))
@ -177,107 +174,30 @@
:on-click #(pick! [:remap {}] placements)}
"⇄"]]]]))
(defn- strip
"Every slot of a palette, side by side: what it looks like at a glance."
[{:keys [slots]}]
[:span.strip (for [[i {:keys [hex]}] (map-indexed vector slots)]
^{:key i} [:i {:style {:background hex}}])])
(defn- rename
"The open palette's name, made editable. Committed on Enter or blur, so a
rename is one undo step and not one per letter; Esc leaves it as it was."
[pid palette done!]
[:input.palette-name
{:type "text" :default-value (:name palette) :auto-focus true
:aria-label "palette name"
:on-focus #(.select (.-target %))
:on-blur (fn [^js e]
(rf/dispatch [::project/palette-name pid (.. e -target -value)])
(done!))
:on-key-down (fn [^js e]
(case (.-key e)
"Enter" (.blur (.-target e))
"Escape" (do (set! (.. e -target -value) (:name palette))
(.blur (.-target e)))
nil))}])
(defn- chooser
"Every palette in the project as a row — its strip and its name — so what you
would switch to is visible before you switch. The open one carries the edit
button that renames it; the ones the stage is drawing in at the playhead carry
a dot."
[clip pid]
(r/with-let [renaming? (r/atom false)]
(let [on-stage @(rf/subscribe [::render/palettes-shown])]
[:div.palette-list {:role "listbox" :aria-label "palettes"}
(doall
(for [[id p] (sort-by (comp str :name val) (pal/palettes clip))
:let [open? (= id pid)]]
^{:key (str id)}
[:div.palette-row {:class (when open? "on")}
(if (and open? @renaming?)
[:div.choose [strip p] [rename id p #(reset! renaming? false)]]
[:button.choose {:role "option" :aria-selected open?
:title (if open? (str "editing " (:name p)) (str "edit " (:name p)))
:on-click (fn []
(when-not open?
(reset! renaming? false)
(rf/dispatch [::ui/set-tone 0])
(rf/dispatch [::select id])))}
[strip p]
[:span.name (:name p)]
(when (contains? on-stage id)
[:span.on-stage {:title "on the stage at the playhead"}])])
(when open?
[:button.rename {:title (str "rename " (:name p)) :aria-label "rename palette"
:on-click #(swap! renaming? not)}
"✎"])]))])))
(defn- elsewhere
"An info badge on the palette button when the palette being edited is not one
the stage draws in at the playhead — not an error: editing another palette is
how one is made ready for a palette lane. Hovering says where to change it.
The card is placed in the viewport, as `ui/menu` places its panels, because
the tool strip scrolls and clips its overflow."
[pid palette]
(r/with-let [at (r/atom nil)]
(let [on-stage @(rf/subscribe [::render/palettes-shown])
clip @(rf/subscribe [::render/clip])
sid @(rf/subscribe [::render/open])]
(when (and (seq on-stage) (not (contains? on-stage pid)))
[:span.palette-info
{:aria-label "palette not on the stage here"
:on-mouse-enter (fn [^js e]
(let [r (.getBoundingClientRect (.-currentTarget e))]
(reset! at [(+ (.-right r) 8) (- (.-top r) 6)])))
:on-mouse-leave #(reset! at nil)}
"i"
(when-let [[x y] @at]
[:span.info-card {:role "tooltip" :style {:left (str x "px") :top (str y "px")}}
[:strong (str (:name palette) " isn't on the stage here")]
(str (or (clip/symbol-name clip sid) "This symbol")
" draws in " (str/join " and " (map #(:name (get (pal/palettes clip) %)) on-stage))
" at this frame. To draw in " (:name palette)
", set the authoring palette in the symbol inspector, or blend to it with a palette lane.")])]))))
(defn assets
"The palettes behind a button at the foot of the strip: switch, rename, add.
The name is NOT printed in the strip: 44px holds no palette's name, and a name
broken over three lines is worse than one shown when asked for."
"Which palette asset the grid shows, and a new or duplicated one, for the
tool strip, with a popout for the full asset controls."
[]
(let [[clip pid palette] (shown)]
[:div.palette-assets-wrap
[menu/popover {:class "palette-assets" :title (str "palette: " (:name palette))
:label [:svg {:view-box "0 0 18 18" :width 18 :height 18}
[:path {:d "M9 2C4.6 2 1.5 5.2 1.5 9c0 4 3.4 7 7.3 7 1.4 0 1.9-.9 1.4-1.9-.5-1 0-2.1 1.4-2.1h1.9c1.9 0 3-1.2 3-3.2C16.5 5 13.2 2 9 2Z"}]
[:circle {:cx 5.5 :cy 8 :r 1.3}]
[:circle {:cx 8.5 :cy 5 :r 1.3}]
[:circle {:cx 12.5 :cy 6.5 :r 1.3}]]}
(fn [_]
[:div.palette-popout
[chooser clip pid]
[:div.row
[:button {:title "a new 16-slot palette" :on-click #(rf/dispatch [::project/new-palette])} "new"]
[:button {:title (str "a copy of " (:name palette) " you can retone")
:on-click #(rf/dispatch [::project/duplicate-palette pid])} "duplicate"]]])]
[elsewhere pid palette]]))
[:details.palette-assets
{:on-toggle (fn [e]
(let [el (.-currentTarget e)
box (.getBoundingClientRect el)
pop (.querySelector el ".palette-popout")]
(set! (.. pop -style -left) (str (+ 4 (.-right box)) "px"))
(set! (.. pop -style -top) (str (max 8 (min (.-top box) (- (.-innerHeight js/window) 160))) "px"))))}
[:summary {:title (:name palette)} [:span (:name palette)]]
[:div.palette-popout
[:strong (:name palette)]
[:select {:value (str pid)
:title "palette asset"
:on-change (fn [e]
(let [v (.. e -target -value)
id (first (filter #(= v (str %)) (keys (pal/palettes clip))))]
(rf/dispatch [::ui/set-tone 0])
(rf/dispatch [::select id])))}
(for [[id p] (sort-by (comp str :name val) (pal/palettes clip))]
^{:key (str id)} [:option {:value (str id)} (:name p)])]
[:button {:title "new 16-slot palette" :on-click #(rf/dispatch [::project/new-palette])} "+"]
[:button {:title (str "duplicate " (:name palette) " — a copy you can retone")
:on-click #(rf/dispatch [::project/duplicate-palette pid])} "clone"]]]))

View file

@ -755,8 +755,7 @@
destination-name (when-let [sid (:sid destination)]
(clip/symbol-name clip sid))
zoom @(rf/subscribe [::layout/zoom :stage])
{:keys [on? opacity]} @(rf/subscribe [::layout/mat])
opacity (if on? opacity 0)]
{:keys [opacity]} @(rf/subscribe [::layout/mat])]
[:div.stage-area
(navigation)
[:div.stage-wrap

View file

@ -1241,9 +1241,10 @@
(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 index}
(if-let [items (and (= :channel kind) (seq (get by-frame f)))]
^{:key index} [key-dot (vec items) frames key-selection key-preview key-anchor key-state key-elements]
^{:key index} [:div.tl-key {:aria-hidden true :style {:left (at% f frames)}}])))))]))
[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.

View file

@ -43,8 +43,8 @@
:title (str name " (" (.toUpperCase key) ") — " tip)
:on-click #(rf/dispatch [::ui/set-tool t])}
[:svg {:view-box "0 0 18 18" :width 18 :height 18} icon]]))]
[palette/grid]
[palette/assets]]))
[palette/assets]
[palette/grid]]))
(defn- size-control []
(let [size @(rf/subscribe [::sub/brush])]
@ -87,16 +87,17 @@
[:span.dim.hint tip]))
[:span.spacer]
(let [{:keys [on? opacity]} @(rf/subscribe [::render/tracing])]
[layout/overlay "tracing-controls" "tracing" on?
"show tracing layers over the picture · never exported"
#(rf/dispatch [::ui/tracing-on (not on?)])
(fn [_]
[:div.view-popout
[:label "Opacity"
[:input {:type "range" :min 0 :max 1 :step 0.05 :value opacity
:on-change #(rf/dispatch [::ui/tracing-opacity (js/parseFloat (.. % -target -value))])}]]])])
;; The stage's zoom and overlays: properties of the view and not of the
;; document, so they sit in the view's own chrome rather than in the inspector.
[:<>
[:button {:class (when on? "on")
:title "show tracing layers over the picture · never exported"
:on-click #(rf/dispatch [::ui/tracing-on (not on?)])} "tracing"]
[:input.trace-opacity
{:type "range" :min 0 :max 1 :step 0.05 :value opacity :disabled (not on?)
:title "how strongly tracing layers draw" :style {:flex "0 0 64px"}
:on-change #(rf/dispatch [::ui/tracing-opacity (js/parseFloat (.. % -target -value))])}]])
;; The stage's zoom: a property of the view and not of the document, so it
;; sits in the view's own chrome rather than in the inspector.
[layout/zoomer :stage "the stage"]
[layout/stage-controls]]))
(defn adjust-last

View file

@ -216,19 +216,10 @@
(deftest no-symbol-is-special
(let [c (nested)]
(testing "a document opens on its root, however long its unplaced symbols are"
(testing "a document opens on the longest symbol nothing places"
(is (= [:loose :main :outer] (clip/unplaced c)))
(is (= :main (clip/opens-on c)))
(is (= :main (clip/opens-on (clip/blank))))
(is (= :loose (clip/opens-on (assoc c :root :loose))))
(is (= c (leaf/clip "u" (leaf/leaves "u" c))))
(is (seq (clip/problems (assoc c :root :gone)))))
(testing "without a root, the longest symbol nothing places"
(is (= :outer (clip/opens-on (dissoc c :root)))))
(testing "a document saved before roots opens on :main, not a longer take"
(is (= :main (:root (clip/pin-root (dissoc c :root)))))
(is (= :outer (:root (clip/pin-root (-> c (dissoc :root) (update :symbols dissoc :main))))))
(is (= :loose (:root (clip/pin-root (assoc c :root :loose))))))
(is (= :outer (clip/opens-on c)))
(is (= :main (clip/opens-on (clip/blank)))))
(testing "an instance can go into any symbol, and spans that symbol's frames"
(let [[n] (vals (get-in c [:symbols :outer :nodes]))]
(is (= #{:inner} (node/sources n)))
@ -450,9 +441,6 @@
extended (nest/resize-out document :palettes [:change] 1 false)]
(resolve 2)
(is (= {:from :day :to :night :t 0.5} (clip/active-palette resolve)))
(is (= (clip/active-palette resolve)
(clip/palette-at document nil :day (clip/symbol document :main) 2 nil))
"the palette strip asks the same question the resolver answers: both ends of a blend")
(is (= [128 128 128]
(nth (pal/effective-ramp context (clip/active-palette resolve))
(pal/render-index context :day 1))))

View file

@ -72,16 +72,14 @@ try {
})()`);
await sleep(250);
assert.equal(await evaluate(`document.querySelector('.onion-controls .menu-wrap > button').textContent`), '▾');
assert.equal(await evaluate(`getComputedStyle(document.querySelector('.onion-controls summary')).listStyleType`), 'none');
assert.equal(await evaluate(`document.querySelector('.onion-controls summary').textContent`), '▾');
await evaluate(`document.querySelector('.onion-controls > button').click()`);
await sleep(150);
assert.equal(await evaluate(`document.querySelector('.onion-controls > button').getAttribute('aria-pressed')`), 'true');
await evaluate(`document.querySelector('.onion-controls .menu-wrap > button').click()`);
await evaluate(`document.querySelector('.onion-controls summary').click()`);
await sleep(100);
assert.equal(await evaluate(`!!document.querySelector('.menu.popout')`), true);
await evaluate(`document.querySelector('.menu-scrim').click()`);
await sleep(100);
assert.equal(await evaluate(`!!document.querySelector('.menu.popout')`), false, 'a click outside shuts the onion settings');
assert.equal(await evaluate(`document.querySelector('.onion-controls details').open`), true);
await evaluate(`(() => {
const c = cljs.core, k = c.keyword, m = (...xs) => c.hash_map(...xs), v = (...xs) => c.vector(...xs);
const db = c.deref(re_frame.db.app_db);

View file

@ -523,32 +523,32 @@ async function main() {
// same synchronous block arrives while the tool is still unset and is dropped.
// A person cannot click twice inside one microtask; a test should not either.
const armed = await page.eval(`(() => {
const button = document.querySelector('.toolbox button.tool[title^="Pen"]');
const button = [...document.querySelectorAll('.palette-bar button')]
.find(b => b.textContent === 'pen');
if (!button) return false;
button.click();
return true;
})()`);
await sleep(100);
// The overlay reaches past the stage on every side, so a stage pixel is
// found through the SVG's own transform, as the stage finds it coming back.
// Each point is its own eval: a click is a re-frame event, and the next one
// reads the draft it added to.
const PEN_AT = (x, y) => `(() => {
const svg = document.querySelector('.paint-overlay.tool-pen');
if (!svg) return false;
const p = new DOMPoint(${x}, ${y}).matrixTransform(svg.getScreenCTM());
svg.dispatchEvent(new PointerEvent('pointerdown', {
bubbles: true, clientX: p.x, clientY: p.y,
}));
const painted = armed && await page.eval(`(() => {
const svg = document.querySelector('.paint-overlay');
const box = svg.getBoundingClientRect();
for (const [x, y] of [[10, 10], [40, 10], [25, 40]]) {
svg.dispatchEvent(new PointerEvent('pointerdown', {
bubbles: true, clientX: box.left + x * box.width / 320,
clientY: box.top + y * box.height / 200,
}));
}
return true;
})()`;
let painted = armed;
for (const [x, y] of [[10, 10], [40, 10], [25, 40]]) {
painted = painted && await page.eval(PEN_AT(x, y));
await sleep(60);
}
// Closed as a person closes it: on the first point.
const finished = painted && await page.eval(PEN_AT(10, 10));
})()`);
await sleep(100);
const finished = await page.eval(`(() => {
const button = [...document.querySelectorAll('.palette-bar button')]
.find(b => b.textContent === 'finish');
if (!button || button.disabled) return false;
button.click();
return true;
})()`);
await sleep(250);
const afterPaint = await page.eval(`(() => {
const c = document.querySelector('canvas.stage');
@ -556,8 +556,7 @@ async function main() {
return [...d];
})()`);
check(painted && finished && beforePaint.join(',') !== afterPaint.join(','),
'a polygon drawn with the paint controls appears on the canvas',
`${beforePaint} -> ${afterPaint}`);
'a polygon drawn with the paint controls appears on the canvas');
for (const f of [8, 16]) {
await page.eval(SEEK(f));
await sleep(100);
@ -598,18 +597,7 @@ async function main() {
return [...c.getContext('2d').getImageData(25, 20, 1, 1).data];
})()`);
check(afterPaint.join(',') === reopenedPaint.join(','),
'the painted polygon survives the project round trip', `${afterPaint} vs ${reopenedPaint}`);
// A reopened document has nothing selected, so the shape's keys are not in
// the inspector until it is selected again, as anybody would.
await page.eval(`(() => {
const c = cljs.core, k = c.keyword;
const db = c.deref(re_frame.db.app_db);
const nodes = c.get_in(c.get(arthur.footage.store.entry(c.get(db, k('clip/current'))), k('clip')),
c.vector(k('symbols'), k('main'), k('nodes')));
const id = c.first(c.filter((id) => c.name(id).startsWith('paint-'), c.keys(nodes)));
re_frame.core.dispatch_sync(c.vector(k('arthur.events.ui/select'),
c.vector(k('node'), k('main'), id, c.vector(id))));
})()`);
'the painted polygon survives the project round trip');
await page.eval(SEEK(8));
await sleep(100);
check(await page.eval(`(() => {

View file

@ -1650,6 +1650,7 @@ button.share-button:hover, button.share-button.on { filter: brightness(1.1); }
background-size: 8px 8px;
background-position: 0 0, 4px 4px;
}
.palette .cell.clear { grid-column: span 2; width: 36px; }
/* --------------------------------------------------------------------------
on the stage: the tools' marks, in stage pixels (the SVG's viewBox), and their
@ -1696,6 +1697,7 @@ button.share-button:hover, button.share-button.on { filter: brightness(1.1); }
background: linear-gradient(135deg, #3a3f52 0 50%, #e8d9a8 50% 100%);
color: #fff; font-size: 11px; line-height: 1; text-shadow: 0 1px 1px rgba(0, 0, 0, .6);
}
.palette .cell.map { grid-column: span 2; width: 36px; }
.palette .cell { position: relative; }
.palette .cell .badge {
position: absolute; right: -2px; bottom: -2px;
@ -1724,87 +1726,32 @@ button.share-button:hover, button.share-button.on { filter: brightness(1.1); }
border-radius: 2px;
}
/* The palette's own settings, at the foot of the strip: drawn as a tool button,
because it is one more thing in the strip you press. */
.palette-assets > button {
width: 34px; height: 30px; padding: 0;
display: grid; place-items: center;
border: 1px solid transparent; border-radius: 4px; background: none;
}
.palette-assets > button:hover:not(:disabled) { background: var(--sunk); border-color: var(--hair); }
.palette-assets > button.on { background: var(--sel-bg); border-color: var(--sel); }
.palette-assets svg path { fill: var(--fg); }
.palette-assets svg circle { fill: var(--chrome); }
.palette-assets > button.on svg circle { fill: var(--sel-bg); }
/* A popover's panel: settings, not commands, so narrower than a menu and laid
out as a form. Placed and dismissed by `ui/menu`. */
.menu.popout { width: 240px; min-width: 0; padding: 10px; line-height: 1.4; }
.menu.popout:has(.palette-popout) { width: 280px; }
.palette-popout { display: grid; gap: 8px; }
.palette-popout .row { margin: 0; }
/* Every palette as its strip and its name: what you would switch to, seen
before you switch. The open one is the selected row. */
.palette-list { display: grid; gap: 1px; max-height: 50vh; overflow: auto; }
.palette-row { display: flex; align-items: center; gap: 2px; border-radius: 3px; }
.palette-row.on { background: var(--sel-bg); }
.palette-row .choose {
flex: 1; min-width: 0;
display: grid; grid-template-columns: 1fr auto; align-items: center; gap: 3px 8px;
padding: 5px 6px; border: 1px solid transparent; border-radius: 3px; background: none; text-align: left;
}
.palette-row button.choose:hover:not(:disabled) { background: var(--sunk); border-color: var(--hair); }
.palette-row.on button.choose { border-color: var(--sel); }
.palette-row.on button.choose:hover { background: var(--sel-bg); }
.palette-row .strip { grid-column: 1 / -1; }
.palette-row .name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.palette-row.on .name { font-weight: 600; color: #14395f; }
.palette-row .palette-name { grid-column: 1 / -1; width: 100%; min-width: 0; }
.palette-row .rename { align-self: stretch; padding: 0 7px; border-color: transparent; background: none; }
.palette-row .rename:hover:not(:disabled) { border-color: var(--sel); background: #fff; }
.strip { display: flex; height: 14px; border: 1px solid rgba(0, 0, 0, .35); border-radius: 2px; overflow: hidden; }
.strip i { flex: 1; }
/* On the stage at the playhead. */
.on-stage { width: 7px; height: 7px; border-radius: 50%; background: var(--live); }
/* A palette being edited that the stage is not drawing in here. Information,
not alarm: the accent of a selection, not of a warning. */
.palette-assets-wrap { position: relative; }
.palette-info {
position: absolute; top: -3px; right: -5px;
width: 13px; height: 13px; border-radius: 50%;
display: grid; place-items: center;
background: var(--sel); color: #fff;
font: italic 600 9px/1 Georgia, serif;
cursor: help;
box-shadow: 0 0 0 1.5px var(--chrome);
}
.info-card {
position: fixed; z-index: 60;
width: 240px; padding: 8px 10px;
display: grid; gap: 3px;
background: var(--pane); color: var(--fg);
font: 11px/1.45 "Lucida Grande", "Segoe UI", system-ui, sans-serif;
border: 1px solid var(--sel); border-left-width: 3px; border-radius: 3px;
box-shadow: 0 3px 10px rgba(0, 0, 0, .25);
cursor: default;
}
.info-card strong { color: #14395f; }
/* Palette assets stay in the existing narrow tool strip. */
.toolbox .palette-assets { display: block; width: 44px; flex: 0 0 auto; }
.palette-assets summary { cursor: pointer; font-size: 10px; line-height: 1.25; overflow-wrap: anywhere; padding: 3px; }
.palette-popout, .view-popout { position: fixed; z-index: 100; background: var(--pane); color: var(--fg); border: 1px solid var(--line); padding: 10px; box-shadow: 0 4px 16px #0008; line-height: 1.4; }
.palette-popout { left: 52px; width: 230px; display: flex; flex-wrap: wrap; gap: 7px; }
.palette-popout strong { width: 100%; overflow-wrap: anywhere; }
.palette-popout select { width: 100%; }
.view-popout { right: 16px; width: 240px; }
.view-popout label { display: block; margin-bottom: 8px; }
.view-popout label:last-child { margin-bottom: 0; }
.view-popout input, .view-popout select { width: 100%; }
/* An overlay switch and its settings: one object, two parts. */
.overlay > .menu-wrap > button { margin-left: -1px; padding: 1px 5px; border-radius: 0 2px 2px 0; }
.overlay > button { border-radius: 2px 0 0 2px; }
.view-popout input { width: 100%; }
.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; }
.onion-controls summary {
display: block; cursor: pointer; list-style: none;
color: var(--fg); background: var(--pane); border: 1px solid var(--line);
border-radius: 0 2px 2px 0; margin-left: -1px; padding: 1px 5px;
}
.onion-controls summary:hover, .onion-controls details[open] > summary { background: #fff; }
.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%; }