The window's shape is editor state, not a constant
The grid was five panes at three fixed widths and one fixed height, written as lengths in the stylesheet. That is a claim about every screen the tool is ever opened on, and it was wrong on two of them: a phone, where 210 + 250 pixels of side pane leave nothing for a 320x200 stage, and a large display, where the inspector is the width somebody once typed rather than the width their work wants. So the tracks are custom properties and `ui/layout` owns the numbers. A drag of an edge is one `assoc-in` and one property on `.app`; no pane's contents re-render, which is the same reason the picture is painted by `ui/player`'s loop rather than by anything reactive. Sizes stay in PIXELS and not fractions — three of these panes hold fixed-width rows, so what you drag an edge to is the width you meant at any window size. State, events and widgets are one namespace, which is not this repo's shape anywhere else. They are the same six integers: `:pool` is a width, a grid track, a drag's clamp and a button's label at once, and splitting six integers across `db`, `events/` and `ui/` is more wiring than state. Three things follow from making it state at all: SHUTTING A PANE. Pool and inspector unmount; the timeline collapses to its transport strip instead, because play, pause and the frame readout live in that strip and a window with nowhere to press play is broken rather than small. The toggles are in the top bar because a shut pane has no head left to carry its own handle. A NARROW WINDOW. Below 760px the grid is one column and the side panes are drawers over the stage rather than columns beside it. The breakpoint is read once, at load, and only decides what the layout OPENS at: throwing away the sizes somebody dragged because they turned a tablet sideways is worse than a layout that is briefly the wrong shape. ZOOM. The stage's was already a constant 2 in `ui/stage`; it is now an integer in [1 8], still CSS over a canvas that is the raster's own size, so the browser suite still reads 320x200 of real pixels off `canvas.stage`. Whole pixels only — a fractional scale under `image-rendering: pixelated` draws some rows of the raster thicker than others, which misrepresents the one thing the preview exists to judge. The timeline's is the stylesheet's entirely: every mark in the tracks is positioned as a percentage of their width, so one multiplier on that width spreads the grid, the spans, the keys, the ruler and the playhead apart together. Both readouts are the button back to normal, accented while there is something to return from, because the quick way back belongs in the one place you already look to find out where you are. One trap, paid for and then found: the top strip has to scroll sideways on a narrow screen, and `overflow-x: auto` makes an element a clipping container on BOTH axes. Every top-bar menu — open, snapshots, share, sign in — was then cut off by a 30px-tall bar and drawn at whatever x the strip happened to be scrolled to, which reads as the button doing nothing at all. They are fixed to the viewport there, as `.menu-drop` already was one pane down for the same reason. The same shape of bug ate the polygon tool and both zoom readouts: a flex row that does not fit shrinks every item and pushes the last ones off the end, so nothing in a strip shrinks now and the strip scrolls as a strip. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
93f5112bb3
commit
4e5e02c856
7 changed files with 374 additions and 32 deletions
167
frontend/src/arthur/ui/layout.cljs
Normal file
167
frontend/src/arthur/ui/layout.cljs
Normal file
|
|
@ -0,0 +1,167 @@
|
||||||
|
(ns arthur.ui.layout
|
||||||
|
"How big the panes are, which of them are shut, and at what zoom the two
|
||||||
|
zoomable surfaces are drawn.
|
||||||
|
|
||||||
|
ONE NAMESPACE FOR STATE, EVENTS AND WIDGETS, which is not this repo's shape
|
||||||
|
anywhere else. The reason is that all three are the same handful of numbers:
|
||||||
|
`:pool` is a pane's width in pixels, a grid track, a drag's clamp and a
|
||||||
|
button's `aria` label, and splitting six integers across `db`, `events/` and
|
||||||
|
`ui/` would be more wiring than state. Nothing below is about the document.
|
||||||
|
|
||||||
|
The panes stay sized in PIXELS rather than fractions. A pane whose width is a
|
||||||
|
share of the window changes size when the window does, and three of these hold
|
||||||
|
lists of fixed-width rows — a label column, a swatch strip, an inspector — so
|
||||||
|
what somebody drags the edge to is the width they wanted, at any window size.
|
||||||
|
|
||||||
|
THE TIMELINE DOES NOT SHUT AWAY ITS HEAD. Shutting it leaves the transport
|
||||||
|
strip on screen, because that strip is play, pause and the frame readout: a
|
||||||
|
window with nowhere to press play is not a smaller window, it is a broken one.
|
||||||
|
The library and the inspector unmount instead — on a narrow screen they are
|
||||||
|
drawers over the stage rather than columns beside it, which is the stylesheet's
|
||||||
|
half of this (see `@media` in app.css), and a drawer that is shut should not be
|
||||||
|
holding video thumbnails live."
|
||||||
|
(:require [re-frame.core :as rf]))
|
||||||
|
|
||||||
|
(def ^:private narrow?
|
||||||
|
"A phone-shaped window, read ONCE at load. It decides what the layout opens
|
||||||
|
at and nothing after that: a resize past the breakpoint re-lays the grid out
|
||||||
|
through the stylesheet, and silently throwing away the sizes somebody dragged
|
||||||
|
because they turned their tablet sideways would be a worse answer than a
|
||||||
|
layout that is briefly the wrong shape. Guarded for the node test build, which
|
||||||
|
has no window."
|
||||||
|
(and (exists? js/window) (< (.-innerWidth js/window) 760)))
|
||||||
|
|
||||||
|
(def normal
|
||||||
|
"The layout nobody chose: what the window opens at, and what the zoom readouts
|
||||||
|
go back to when pressed. Narrow windows open with both side panes shut and the
|
||||||
|
stage at 1:1, because 320x200 at 2x is wider than the screen it would be the
|
||||||
|
only thing on."
|
||||||
|
(merge {:pool 210 :params 250 :time 232 :stage 2 :tl 1 :shut #{}}
|
||||||
|
(when narrow? {:time 150 :stage 1 :shut #{:pool :params}})))
|
||||||
|
|
||||||
|
(def ^:private limits
|
||||||
|
"[lo hi] per key — pixels for a pane, a multiplier for a zoom. The low end of
|
||||||
|
`:time` is the transport strip plus a row, so dragging the timeline shut and
|
||||||
|
shutting it are the same shape of window."
|
||||||
|
{:pool [120 480] :params [150 560] :time [44 660] :stage [1 8] :tl [1 24]})
|
||||||
|
|
||||||
|
(defn- clamp [k v]
|
||||||
|
(let [[lo hi] (limits k)] (max lo (min hi v))))
|
||||||
|
|
||||||
|
(rf/reg-sub ::state (fn [db _] (merge normal (get-in db [:ui :layout]))))
|
||||||
|
(rf/reg-sub ::zoom :<- [::state] (fn [state [_ k]] (k state)))
|
||||||
|
|
||||||
|
(rf/reg-event-db
|
||||||
|
::set
|
||||||
|
;; Pixels for a pane, a multiplier for a zoom, clamped either way.
|
||||||
|
(fn [db [_ k v]] (assoc-in db [:ui :layout k] (clamp k v))))
|
||||||
|
|
||||||
|
(rf/reg-event-db
|
||||||
|
::normal
|
||||||
|
(fn [db [_ k]] (assoc-in db [:ui :layout k] (k normal))))
|
||||||
|
|
||||||
|
(rf/reg-event-db
|
||||||
|
::toggle
|
||||||
|
(fn [db [_ k]]
|
||||||
|
(update-in db [:ui :layout :shut]
|
||||||
|
#(let [shut (or % (:shut normal))]
|
||||||
|
(if (contains? shut k) (disj shut k) (conj shut k))))))
|
||||||
|
|
||||||
|
(defn vars
|
||||||
|
"The grid, as custom properties for `.app` to resolve its tracks against. A
|
||||||
|
shut pane is a zero-width track AND a zero-width grip: there is no edge to
|
||||||
|
drag when there is nothing on the other side of it."
|
||||||
|
[{:keys [pool params time tl shut]}]
|
||||||
|
(let [gone? #(contains? shut %)]
|
||||||
|
{"--pool-w" (if (gone? :pool) "0px" (str pool "px"))
|
||||||
|
"--params-w" (if (gone? :params) "0px" (str params "px"))
|
||||||
|
;; Head-height, not zero: see the namespace docstring.
|
||||||
|
"--time-h" (if (gone? :time) "22px" (str time "px"))
|
||||||
|
"--grip-pool" (if (gone? :pool) "0px" "4px")
|
||||||
|
"--grip-params" (if (gone? :params) "0px" "4px")
|
||||||
|
"--grip-time" (if (gone? :time) "0px" "4px")
|
||||||
|
;; The timeline's zoom is entirely the stylesheet's: every mark in the
|
||||||
|
;; tracks is positioned as a percentage of their width, so widening them
|
||||||
|
;; widens the frame grid, the spans, the ruler and the playhead together.
|
||||||
|
"--tl-zoom" tl}))
|
||||||
|
|
||||||
|
;; Which grip is being dragged, and from where. A PLAIN atom: it lives for the
|
||||||
|
;; length of one drag and nothing renders from it, as in `ui/stage`.
|
||||||
|
(defonce ^:private sizing (atom nil))
|
||||||
|
|
||||||
|
(defn grip
|
||||||
|
"The edge between two panes, as something you can grab. `sign` is which way
|
||||||
|
the pointer grows the pane — the inspector's edge is on its left, so dragging
|
||||||
|
left makes it wider."
|
||||||
|
[k size axis sign]
|
||||||
|
(let [at (fn [^js e] (if (= :col axis) (.-clientX e) (.-clientY e)))]
|
||||||
|
[:div
|
||||||
|
{:class (str "grip grip-" (name k) " " (name axis))
|
||||||
|
:role "separator"
|
||||||
|
:aria-label (str "resize the " (name k) " pane")
|
||||||
|
:on-pointer-down
|
||||||
|
(fn [^js e]
|
||||||
|
(.preventDefault e)
|
||||||
|
;; The size is read HERE and the drag is absolute from it, rather than
|
||||||
|
;; accumulating per-move deltas: a clamped increment loses the pointer
|
||||||
|
;; at the limits, and the pane then lags the finger coming back.
|
||||||
|
(reset! sizing [(at e) size])
|
||||||
|
;; Capture is what keeps a drag alive once the pointer leaves a 4px
|
||||||
|
;; target. As in `ui/timeline`'s ruler it throws on a synthesised
|
||||||
|
;; pointer id, and what the catch loses is the drag and nothing else.
|
||||||
|
(try (.setPointerCapture (.-currentTarget e) (.-pointerId e))
|
||||||
|
(catch :default _ nil)))
|
||||||
|
:on-pointer-move
|
||||||
|
(fn [^js e]
|
||||||
|
(when-let [[from from-size] @sizing]
|
||||||
|
(rf/dispatch [::set k (+ from-size (* sign (- (at e) from)))])))
|
||||||
|
:on-pointer-up (fn [_] (reset! sizing nil))
|
||||||
|
:on-pointer-cancel (fn [_] (reset! sizing nil))}]))
|
||||||
|
|
||||||
|
(defn panes
|
||||||
|
"Which panes are open, as a segmented control in the top bar. In the top bar
|
||||||
|
because a shut pane has no head left to carry its own handle, and because on a
|
||||||
|
narrow screen this is the whole of the navigation."
|
||||||
|
[]
|
||||||
|
(let [{:keys [shut]} @(rf/subscribe [::state])]
|
||||||
|
[:div.seg.panes {:aria-label "panes"}
|
||||||
|
(doall
|
||||||
|
(for [[k label] [[:pool "pool"] [:params "inspector"] [:time "timeline"]]]
|
||||||
|
(let [open? (not (contains? shut k))]
|
||||||
|
^{:key k}
|
||||||
|
[:button {:class (when open? "on")
|
||||||
|
:aria-pressed open?
|
||||||
|
:title (str (if open? "hide the " "show the ") label)
|
||||||
|
:on-click #(rf/dispatch [::toggle k])}
|
||||||
|
label])))]))
|
||||||
|
|
||||||
|
(defn- stepped [k z out?]
|
||||||
|
;; The stage steps by WHOLE pixels. A fractional scale under
|
||||||
|
;; `image-rendering: pixelated` draws some rows of the raster thicker than
|
||||||
|
;; others, which misrepresents the one thing the preview exists to judge. The
|
||||||
|
;; timeline has no pixel grid to honour, so it steps geometrically.
|
||||||
|
(if (= :stage k)
|
||||||
|
(+ z (if out? -1 1))
|
||||||
|
(* z (if out? (/ 1 1.5) 1.5))))
|
||||||
|
|
||||||
|
(defn zoomer
|
||||||
|
"− <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."
|
||||||
|
[k label]
|
||||||
|
(let [z @(rf/subscribe [::zoom k])
|
||||||
|
[lo hi] (limits k)
|
||||||
|
home (k normal)]
|
||||||
|
[:div.group.zoomer
|
||||||
|
[:button {:disabled (<= z lo) :aria-label (str label " zoom out")
|
||||||
|
:title (str "zoom " label " out")
|
||||||
|
:on-click #(rf/dispatch [::set k (stepped k z true)])} "−"]
|
||||||
|
[:button.zoom-at {:class (when (not= z home) "on")
|
||||||
|
:aria-label (str label " zoom")
|
||||||
|
:title (str "back to " (js/Math.round (* 100 home)) "% — the normal zoom")
|
||||||
|
:on-click #(rf/dispatch [::normal k])}
|
||||||
|
(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)])} "+"]]))
|
||||||
|
|
@ -20,6 +20,7 @@
|
||||||
(:require [arthur.domain.palette :as pal]
|
(:require [arthur.domain.palette :as pal]
|
||||||
[arthur.events.ui :as ui]
|
[arthur.events.ui :as ui]
|
||||||
[arthur.subs.ui :as sub]
|
[arthur.subs.ui :as sub]
|
||||||
|
[arthur.ui.layout :as layout]
|
||||||
[re-frame.core :as rf]))
|
[re-frame.core :as rf]))
|
||||||
|
|
||||||
(def ^:const slots 16)
|
(def ^:const slots 16)
|
||||||
|
|
@ -61,4 +62,8 @@
|
||||||
[:button {:disabled (< (count draft) 6)
|
[:button {:disabled (< (count draft) 6)
|
||||||
:on-click #(rf/dispatch [::ui/finish-polygon])} "finish"]
|
:on-click #(rf/dispatch [::ui/finish-polygon])} "finish"]
|
||||||
[:button {:on-click #(rf/dispatch [::ui/cancel-polygon])} "cancel"]]
|
[:button {:on-click #(rf/dispatch [::ui/cancel-polygon])} "cancel"]]
|
||||||
[:button {:on-click #(rf/dispatch [::ui/begin-polygon])} "polygon"])]))
|
[:button {:on-click #(rf/dispatch [::ui/begin-polygon])} "polygon"])
|
||||||
|
;; The stage's zoom, at the right end of the bar above the stage: it is 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"]]))
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,8 @@
|
||||||
"The window: one grid, five panes, a location bar, and the audio element.
|
"The window: one grid, five panes, a location bar, and the audio element.
|
||||||
|
|
||||||
Nothing else. Each pane owns its own subscriptions, so this component re-renders
|
Nothing else. Each pane owns its own subscriptions, so this component re-renders
|
||||||
only when the grid itself would change — which is never. The picture is put on
|
only when the grid itself would change — which is a pane being resized, shut or
|
||||||
|
reopened, and nothing a pane's contents can do. The picture is put on
|
||||||
the canvas by `ui/player`'s loop rather than by anything here re-rendering,
|
the canvas by `ui/player`'s loop rather than by anything here re-rendering,
|
||||||
which is why scrubbing at speed does not touch React at all."
|
which is why scrubbing at speed does not touch React at all."
|
||||||
(:require [arthur.clock :as clock]
|
(:require [arthur.clock :as clock]
|
||||||
|
|
@ -10,6 +11,7 @@
|
||||||
[arthur.events.playback :as pb]
|
[arthur.events.playback :as pb]
|
||||||
[arthur.subs.playback :as playback]
|
[arthur.subs.playback :as playback]
|
||||||
[arthur.subs.render :as render]
|
[arthur.subs.render :as render]
|
||||||
|
[arthur.ui.layout :as layout]
|
||||||
[arthur.ui.location :as location]
|
[arthur.ui.location :as location]
|
||||||
[arthur.ui.palette :as palette]
|
[arthur.ui.palette :as palette]
|
||||||
[arthur.ui.params :as params]
|
[arthur.ui.params :as params]
|
||||||
|
|
@ -45,18 +47,28 @@
|
||||||
(finally (r/dispose! remix))))
|
(finally (r/dispose! remix))))
|
||||||
|
|
||||||
(defn view []
|
(defn view []
|
||||||
[:div.app
|
;; The only component that re-renders when the grid changes, which is what the
|
||||||
[topbar/view]
|
;; namespace docstring says never happens — it happens now, on a pane being
|
||||||
[pool/view]
|
;; dragged or shut, and that is the whole of it: every pane still owns its own
|
||||||
[:section.view
|
;; subscriptions and a drag of the inspector's edge re-renders this div and no
|
||||||
[tabs/view]
|
;; pane's contents.
|
||||||
[palette/bar]
|
(let [{:keys [pool params shut] :as state} @(rf/subscribe [::layout/state])
|
||||||
[stage/view]]
|
gone? #(contains? shut %)]
|
||||||
[params/view]
|
[:div.app {:style (layout/vars state)}
|
||||||
;; Above the timeline rather than inside it: the bar says where an edit would
|
[topbar/view]
|
||||||
;; land, which is a fact about the SELECTION and not about either temporal
|
(when-not (gone? :pool) [pool/view])
|
||||||
;; view, and both views are drawn below it unchanged.
|
(when-not (gone? :pool) [layout/grip :pool pool :col 1])
|
||||||
[location/view]
|
[:section.view
|
||||||
[timeline/view]
|
[tabs/view]
|
||||||
[convert/view]
|
[palette/bar]
|
||||||
[audio]])
|
[stage/view]]
|
||||||
|
(when-not (gone? :params) [layout/grip :params params :col -1])
|
||||||
|
(when-not (gone? :params) [params/view])
|
||||||
|
;; Above the timeline rather than inside it: the bar says where an edit would
|
||||||
|
;; land, which is a fact about the SELECTION and not about either temporal
|
||||||
|
;; view, and both views are drawn below it unchanged.
|
||||||
|
[location/view]
|
||||||
|
(when-not (gone? :time) [layout/grip :time (:time state) :row -1])
|
||||||
|
[timeline/view]
|
||||||
|
[convert/view]
|
||||||
|
[audio]]))
|
||||||
|
|
|
||||||
|
|
@ -22,14 +22,15 @@
|
||||||
[arthur.subs.render :as render]
|
[arthur.subs.render :as render]
|
||||||
[arthur.subs.ui :as sub]
|
[arthur.subs.ui :as sub]
|
||||||
[arthur.ui.drag :as drag]
|
[arthur.ui.drag :as drag]
|
||||||
|
[arthur.ui.layout :as layout]
|
||||||
[arthur.ui.player :as player]
|
[arthur.ui.player :as player]
|
||||||
[arthur.ui.underlay :as underlay]
|
[arthur.ui.underlay :as underlay]
|
||||||
[re-frame.core :as rf]))
|
[re-frame.core :as rf]))
|
||||||
|
|
||||||
(def ^:const zoom
|
;; THE ZOOM IS NOT A CONSTANT ANY MORE — it is `ui/layout`'s, an integer in
|
||||||
"Integer, and the browser suite reads the canvas's own pixels rather than a
|
;; [1 8], 2 to begin with. It scales the canvas with CSS and never its backing
|
||||||
screenshot because of it."
|
;; store, so the browser suite still reads 320x200 of real pixels off
|
||||||
2)
|
;; `canvas.stage` whatever the view is zoomed to; see `ui/canvas`.
|
||||||
|
|
||||||
(defn stage-point
|
(defn stage-point
|
||||||
"Where a pointer event landed, in stage pixels. Shared by the vertex editor and
|
"Where a pointer event landed, in stage pixels. Shared by the vertex editor and
|
||||||
|
|
@ -262,7 +263,7 @@
|
||||||
:width (+ 2 (* 2.1 (count label))) :height 5}]
|
:width (+ 2 (* 2.1 (count label))) :height 5}]
|
||||||
[:text.aim-tag {:x 1 :y 3.8} label]])]))))
|
[:text.aim-tag {:x 1 :y 3.8} label]])]))))
|
||||||
|
|
||||||
(defn- overlay [w h]
|
(defn- overlay [w h zoom]
|
||||||
(let [tool @(rf/subscribe [::sub/tool])
|
(let [tool @(rf/subscribe [::sub/tool])
|
||||||
draft @(rf/subscribe [::sub/draft])
|
draft @(rf/subscribe [::sub/draft])
|
||||||
drawing? (= :polygon tool)
|
drawing? (= :polygon tool)
|
||||||
|
|
@ -351,7 +352,8 @@
|
||||||
;; reallocates the backing store — so this re-rendering costs nothing per frame.
|
;; reallocates the backing store — so this re-rendering costs nothing per frame.
|
||||||
(let [w @(rf/subscribe [::playback/width])
|
(let [w @(rf/subscribe [::playback/width])
|
||||||
h @(rf/subscribe [::playback/height])
|
h @(rf/subscribe [::playback/height])
|
||||||
frame @(rf/subscribe [::playback/frame])]
|
frame @(rf/subscribe [::playback/frame])
|
||||||
|
zoom @(rf/subscribe [::layout/zoom :stage])]
|
||||||
[:div.stage-area
|
[:div.stage-area
|
||||||
[:div.stage-wrap
|
[:div.stage-wrap
|
||||||
;; A drop on the stage lands at the PLAYHEAD, where the pointer is in space;
|
;; A drop on the stage lands at the PLAYHEAD, where the pointer is in space;
|
||||||
|
|
@ -376,4 +378,4 @@
|
||||||
:height (str (* zoom h) "px")}}]
|
:height (str (* zoom h) "px")}}]
|
||||||
[:canvas.underlay {:ref #(underlay/set-canvas! %)
|
[:canvas.underlay {:ref #(underlay/set-canvas! %)
|
||||||
:width (* zoom w) :height (* zoom h)}]
|
:width (* zoom w) :height (* zoom h)}]
|
||||||
[overlay w h]]]))
|
[overlay w h zoom]]]))
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,7 @@
|
||||||
[arthur.subs.render :as render]
|
[arthur.subs.render :as render]
|
||||||
[arthur.subs.ui :as sub]
|
[arthur.subs.ui :as sub]
|
||||||
[arthur.ui.drag :as drag]
|
[arthur.ui.drag :as drag]
|
||||||
|
[arthur.ui.layout :as layout]
|
||||||
[arthur.ui.player :as player]
|
[arthur.ui.player :as player]
|
||||||
[re-frame.core :as rf]
|
[re-frame.core :as rf]
|
||||||
[reagent.core :as r]))
|
[reagent.core :as r]))
|
||||||
|
|
@ -484,6 +485,11 @@
|
||||||
:title "move the selected clip's end to the playhead"
|
:title "move the selected clip's end to the playhead"
|
||||||
:on-click (act [::ui/trim :out])} "out"]]
|
:on-click (act [::ui/trim :out])} "out"]]
|
||||||
[:span.spacer]
|
[:span.spacer]
|
||||||
|
;; How many pixels a frame is. The tracks are positioned in percentages of
|
||||||
|
;; their own width, so this is one custom property on the grid and no
|
||||||
|
;; arithmetic here — see `ui/layout`.
|
||||||
|
[layout/zoomer :tl "the timeline"]
|
||||||
|
[:span.sep]
|
||||||
;; After the spacer, both of them: an offer that appears and a reading that
|
;; After the spacer, both of them: an offer that appears and a reading that
|
||||||
;; comes and goes must not shove the fixed controls sideways when they do.
|
;; comes and goes must not shove the fixed controls sideways when they do.
|
||||||
(when @(rf/subscribe [::sub/retry])
|
(when @(rf/subscribe [::sub/retry])
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@
|
||||||
[arthur.events.export :as export]
|
[arthur.events.export :as export]
|
||||||
[arthur.events.project :as project]
|
[arthur.events.project :as project]
|
||||||
[arthur.subs.playback :as playback]
|
[arthur.subs.playback :as playback]
|
||||||
|
[arthur.ui.layout :as layout]
|
||||||
[arthur.ui.openmenu :as openmenu]
|
[arthur.ui.openmenu :as openmenu]
|
||||||
[arthur.ui.share :as share]
|
[arthur.ui.share :as share]
|
||||||
[arthur.ui.snapshots :as snapshots]
|
[arthur.ui.snapshots :as snapshots]
|
||||||
|
|
@ -58,6 +59,10 @@
|
||||||
[:header.top
|
[:header.top
|
||||||
[:a.brand {:href "/" :title "your projects"
|
[:a.brand {:href "/" :title "your projects"
|
||||||
:on-click (fn [e] (.preventDefault e) (collab/navigate! "/"))} "arthur"]
|
:on-click (fn [e] (.preventDefault e) (collab/navigate! "/"))} "arthur"]
|
||||||
|
;; At the left end, before the document's own commands: on a narrow window
|
||||||
|
;; this strip scrolls, and which panes are open is the one control that
|
||||||
|
;; must not be the thing off the end of it.
|
||||||
|
[layout/panes]
|
||||||
[:button {:disabled busy? :on-click #(rf/dispatch [::collab/create])} "new"]
|
[:button {:disabled busy? :on-click #(rf/dispatch [::collab/create])} "new"]
|
||||||
[openmenu/view]
|
[openmenu/view]
|
||||||
[undo/view]
|
[undo/view]
|
||||||
|
|
|
||||||
|
|
@ -97,19 +97,32 @@ audio { display: none; }
|
||||||
/* --------------------------------------------------------------------------
|
/* --------------------------------------------------------------------------
|
||||||
the frame */
|
the frame */
|
||||||
|
|
||||||
|
/* THE TRACKS ARE CUSTOM PROPERTIES, NOT LENGTHS. `ui/layout` writes all six on
|
||||||
|
this element, which is what makes the three edges between the panes draggable:
|
||||||
|
a drag is one `assoc-in` and one property, and no pane's contents re-render.
|
||||||
|
The defaults here are the same numbers `ui/layout`'s `normal` holds, written
|
||||||
|
twice so that this file still describes a window on its own. */
|
||||||
.app {
|
.app {
|
||||||
display: grid;
|
display: grid;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
grid-template-columns: var(--label) minmax(0, 1fr) 250px;
|
--pool-w: 210px;
|
||||||
|
--params-w: 250px;
|
||||||
|
--time-h: 232px;
|
||||||
|
--grip-pool: 4px;
|
||||||
|
--grip-params: 4px;
|
||||||
|
--grip-time: 4px;
|
||||||
|
grid-template-columns:
|
||||||
|
var(--pool-w) var(--grip-pool) minmax(0, 1fr) var(--grip-params) var(--params-w);
|
||||||
/* The location bar is its own row and takes its height from the stage, not
|
/* The location bar is its own row and takes its height from the stage, not
|
||||||
from the timeline: it exists to explain what the timeline is showing, so
|
from the timeline: it exists to explain what the timeline is showing, so
|
||||||
paying for it in timeline rows would be the wrong trade. */
|
paying for it in timeline rows would be the wrong trade. */
|
||||||
grid-template-rows: 30px minmax(0, 1fr) 21px 232px;
|
grid-template-rows: 30px minmax(0, 1fr) 21px var(--grip-time) var(--time-h);
|
||||||
grid-template-areas:
|
grid-template-areas:
|
||||||
"top top top"
|
"top top top top top"
|
||||||
"pool view params"
|
"pool gpool view gparm params"
|
||||||
"loc loc loc"
|
"loc loc loc loc loc"
|
||||||
"time time time";
|
"gtime gtime gtime gtime gtime"
|
||||||
|
"time time time time time";
|
||||||
gap: 1px;
|
gap: 1px;
|
||||||
background: var(--line);
|
background: var(--line);
|
||||||
}
|
}
|
||||||
|
|
@ -121,6 +134,28 @@ audio { display: none; }
|
||||||
.loc { grid-area: loc; }
|
.loc { grid-area: loc; }
|
||||||
.time { grid-area: time; }
|
.time { grid-area: time; }
|
||||||
|
|
||||||
|
/* The draggable edge between two panes. Four pixels of chrome that lights up
|
||||||
|
under the pointer: a hairline is honest about where the boundary is and
|
||||||
|
impossible to hit, and a wide gutter would be furniture. */
|
||||||
|
.grip { touch-action: none; background: var(--chrome); }
|
||||||
|
.grip:hover, .grip:active { background: var(--sel); }
|
||||||
|
.grip.col { cursor: col-resize; }
|
||||||
|
.grip.row { cursor: row-resize; }
|
||||||
|
.grip-pool { grid-area: gpool; }
|
||||||
|
.grip-params { grid-area: gparm; }
|
||||||
|
.grip-time { grid-area: gtime; }
|
||||||
|
|
||||||
|
/* Which panes are open, in the top bar. Lower-case words rather than icons: the
|
||||||
|
three panes have names and the names are what the rest of the chrome calls
|
||||||
|
them. */
|
||||||
|
.panes > button { color: var(--dim); }
|
||||||
|
.panes > button.on { color: var(--fg); }
|
||||||
|
|
||||||
|
/* A zoom readout that is also the way back to 1:1, so it is a button that looks
|
||||||
|
like the number it shows. Tabular figures, because the + beside it must not
|
||||||
|
move as the percentage passes 100. */
|
||||||
|
.zoom-at { min-width: 44px; text-align: center; font-variant-numeric: tabular-nums; }
|
||||||
|
|
||||||
/* Every pane is its own scroll container. `min-height: 0` is what lets a grid
|
/* Every pane is its own scroll container. `min-height: 0` is what lets a grid
|
||||||
row shrink below its content instead of pushing the layout taller than the
|
row shrink below its content instead of pushing the layout taller than the
|
||||||
viewport — the single line that separates "a page that scrolls" from "an
|
viewport — the single line that separates "a page that scrolls" from "an
|
||||||
|
|
@ -792,7 +827,11 @@ button.share-button:hover, button.share-button.on { filter: brightness(1.1); }
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
/* `safe`, because the stage zooms: a centred grid item larger than its scroll
|
||||||
|
container puts its top-left edge out of reach on the start side, which at
|
||||||
|
8x is most of the picture. Safe alignment falls back to start when it does
|
||||||
|
not fit, and centring is only ever about where a SMALL stage sits. */
|
||||||
|
place-items: safe center;
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
padding: 14px;
|
padding: 14px;
|
||||||
}
|
}
|
||||||
|
|
@ -959,7 +998,12 @@ button.share-button:hover, button.share-button.on { filter: brightness(1.1); }
|
||||||
|
|
||||||
.tl-tracks {
|
.tl-tracks {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: 340px;
|
/* The timeline's zoom, and the whole of it: every mark in here is positioned
|
||||||
|
as a percentage of this element's width, so one multiplier on the width
|
||||||
|
spreads the frame grid, the spans, the keys, the ruler and the playhead
|
||||||
|
apart together, and `.tl-body` was already a scroll container. 1 is the
|
||||||
|
width that exactly fills the body, which is what `flex: 1` alone gave. */
|
||||||
|
min-width: max(340px, calc((100% - var(--label)) * var(--tl-zoom, 1)));
|
||||||
position: relative;
|
position: relative;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
/* The frame grid, five frames to a division, as a background rather than as
|
/* The frame grid, five frames to a division, as a background rather than as
|
||||||
|
|
@ -1296,3 +1340,104 @@ button.share-button:hover, button.share-button.on { filter: brightness(1.1); }
|
||||||
hold, dimmed because its own frames have no place on this ruler. */
|
hold, dimmed because its own frames have no place on this ruler. */
|
||||||
.tl-span.unmapped { opacity: 0.45; cursor: pointer; }
|
.tl-span.unmapped { opacity: 0.45; cursor: pointer; }
|
||||||
.tl-hint.refusing { background: var(--bad, #b00); color: #fff; }
|
.tl-hint.refusing { background: var(--bad, #b00); color: #fff; }
|
||||||
|
|
||||||
|
/* --------------------------------------------------------------------------
|
||||||
|
narrow windows
|
||||||
|
|
||||||
|
A phone is not a small desk: there is room for ONE column, so the stage gets
|
||||||
|
it and the two side panes become drawers over the top of it rather than
|
||||||
|
columns beside it. They are mounted only while open (`ui/shell`), so a shut
|
||||||
|
drawer is not holding thumbnails live, and the top bar's pane buttons are
|
||||||
|
what opens them — which is why they are at the left end of a strip that
|
||||||
|
scrolls.
|
||||||
|
|
||||||
|
The timeline keeps its row. Shut, that row is its transport strip and
|
||||||
|
nothing else, because a window with nowhere to press play is broken rather
|
||||||
|
than small; its height is still draggable, by touch, on the grip above it. */
|
||||||
|
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
/* A uuid-wide label column is half the screen. */
|
||||||
|
:root { --label: 136px; }
|
||||||
|
|
||||||
|
.app {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
grid-template-rows: 30px minmax(0, 1fr) 21px var(--grip-time) var(--time-h);
|
||||||
|
grid-template-areas:
|
||||||
|
"top"
|
||||||
|
"view"
|
||||||
|
"loc"
|
||||||
|
"gtime"
|
||||||
|
"time";
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Everything in the strip stays reachable, by scrolling it. */
|
||||||
|
.top { overflow-x: auto; }
|
||||||
|
.project-title { max-width: 32vw; }
|
||||||
|
/* So that 34px below is the same distance on both pages: the editor's strip
|
||||||
|
takes its 30px from the grid row, and the index page has no grid. */
|
||||||
|
.index > .top { flex: 0 0 30px; }
|
||||||
|
|
||||||
|
/* A SCROLL CONTAINER CLIPS BOTH AXES. `overflow-x: auto` above makes the
|
||||||
|
strip one, so a panel anchored to its own button — open, snapshots, share,
|
||||||
|
sign in — is cut off by a 30px-tall bar and drawn at whatever x the strip
|
||||||
|
happens to be scrolled to, which reads as the button doing nothing at all.
|
||||||
|
Fixed to the viewport instead, as `.menu-drop` already is for the same
|
||||||
|
reason one pane down: these are all top-bar menus, the top bar is at the top
|
||||||
|
of the window, and a near-full-width sheet is what there is room for. Both
|
||||||
|
offsets on the axis, so `.menu-left`'s anchoring is overridden rather than
|
||||||
|
left half-applied. `.menu-drop` is excluded because `ui/menu` places that
|
||||||
|
one itself, inline, and would then be over-constrained. */
|
||||||
|
.top .menu:not(.menu-drop),
|
||||||
|
.top .menu.menu-left:not(.menu-drop) {
|
||||||
|
position: fixed;
|
||||||
|
top: 34px;
|
||||||
|
left: 4px;
|
||||||
|
right: 4px;
|
||||||
|
width: auto;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Out of the grid altogether: fixed to the viewport, so the column the grid
|
||||||
|
has left is the stage's whether a drawer is open or not. */
|
||||||
|
.pool, .params {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 30;
|
||||||
|
top: 31px;
|
||||||
|
bottom: 0;
|
||||||
|
width: min(78vw, 300px);
|
||||||
|
box-shadow: 0 2px 16px rgba(0, 0, 0, .4);
|
||||||
|
}
|
||||||
|
.pool { left: 0; border-right: 1px solid var(--line); }
|
||||||
|
.params { right: 0; border-left: 1px solid var(--line); }
|
||||||
|
|
||||||
|
/* There is no column edge left to drag. */
|
||||||
|
.grip.col { display: none; }
|
||||||
|
|
||||||
|
/* The picture, not the desk around it. */
|
||||||
|
.stage-area { padding: 5px; }
|
||||||
|
|
||||||
|
/* Touch targets, in the strips that are all buttons. */
|
||||||
|
.pane-head, .palette-bar { gap: 7px; }
|
||||||
|
.palette-bar button, .pane-head button, .top button { min-height: 24px; }
|
||||||
|
|
||||||
|
/* A STRIP OF CONTROLS SCROLLS AS A STRIP. Flexbox's instinct when a row does
|
||||||
|
not fit is to shrink every item and then push the last ones off the end —
|
||||||
|
and the last ones here are the polygon tool, the timing commands and both
|
||||||
|
zoom readouts, which would be unreachable rather than merely off-screen.
|
||||||
|
So nothing shrinks, nothing wraps, and each strip scrolls. The `.spacer`
|
||||||
|
and `.status` rules above still win on specificity, which is what keeps the
|
||||||
|
right-hand groups right-hand while there IS room. */
|
||||||
|
.top > *, .pane-head > *, .palette-bar > * { flex-shrink: 0; }
|
||||||
|
.top button, .pane-head button { white-space: nowrap; }
|
||||||
|
.pane-head, .palette-bar { overflow-x: auto; }
|
||||||
|
|
||||||
|
/* The one compressible thing in the palette bar is the swatches, so they
|
||||||
|
scroll inside their own share instead of taking it from the commands. The
|
||||||
|
tone's NAME goes: the ringed swatch already says which one is active, and
|
||||||
|
it is the widest thing in the bar that nothing is lost by dropping. */
|
||||||
|
.palette-bar .swatches { flex: 1 1 130px; min-width: 56px; overflow-x: auto; }
|
||||||
|
/* A swatch is a 15px circle or it is not a swatch: the strip scrolls, the
|
||||||
|
dots do not get thinner. */
|
||||||
|
.palette-bar .swatches > * { flex-shrink: 0; }
|
||||||
|
.palette-bar > .dim { display: none; }
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue