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]
|
||||
[arthur.events.ui :as ui]
|
||||
[arthur.subs.ui :as sub]
|
||||
[arthur.ui.layout :as layout]
|
||||
[re-frame.core :as rf]))
|
||||
|
||||
(def ^:const slots 16)
|
||||
|
|
@ -61,4 +62,8 @@
|
|||
[:button {:disabled (< (count draft) 6)
|
||||
:on-click #(rf/dispatch [::ui/finish-polygon])} "finish"]
|
||||
[: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.
|
||||
|
||||
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,
|
||||
which is why scrubbing at speed does not touch React at all."
|
||||
(:require [arthur.clock :as clock]
|
||||
|
|
@ -10,6 +11,7 @@
|
|||
[arthur.events.playback :as pb]
|
||||
[arthur.subs.playback :as playback]
|
||||
[arthur.subs.render :as render]
|
||||
[arthur.ui.layout :as layout]
|
||||
[arthur.ui.location :as location]
|
||||
[arthur.ui.palette :as palette]
|
||||
[arthur.ui.params :as params]
|
||||
|
|
@ -45,18 +47,28 @@
|
|||
(finally (r/dispose! remix))))
|
||||
|
||||
(defn view []
|
||||
[:div.app
|
||||
[topbar/view]
|
||||
[pool/view]
|
||||
[:section.view
|
||||
[tabs/view]
|
||||
[palette/bar]
|
||||
[stage/view]]
|
||||
[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]
|
||||
[timeline/view]
|
||||
[convert/view]
|
||||
[audio]])
|
||||
;; The only component that re-renders when the grid changes, which is what the
|
||||
;; namespace docstring says never happens — it happens now, on a pane being
|
||||
;; dragged or shut, and that is the whole of it: every pane still owns its own
|
||||
;; subscriptions and a drag of the inspector's edge re-renders this div and no
|
||||
;; pane's contents.
|
||||
(let [{:keys [pool params shut] :as state} @(rf/subscribe [::layout/state])
|
||||
gone? #(contains? shut %)]
|
||||
[:div.app {:style (layout/vars state)}
|
||||
[topbar/view]
|
||||
(when-not (gone? :pool) [pool/view])
|
||||
(when-not (gone? :pool) [layout/grip :pool pool :col 1])
|
||||
[:section.view
|
||||
[tabs/view]
|
||||
[palette/bar]
|
||||
[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.ui :as sub]
|
||||
[arthur.ui.drag :as drag]
|
||||
[arthur.ui.layout :as layout]
|
||||
[arthur.ui.player :as player]
|
||||
[arthur.ui.underlay :as underlay]
|
||||
[re-frame.core :as rf]))
|
||||
|
||||
(def ^:const zoom
|
||||
"Integer, and the browser suite reads the canvas's own pixels rather than a
|
||||
screenshot because of it."
|
||||
2)
|
||||
;; THE ZOOM IS NOT A CONSTANT ANY MORE — it is `ui/layout`'s, an integer in
|
||||
;; [1 8], 2 to begin with. It scales the canvas with CSS and never its backing
|
||||
;; store, so the browser suite still reads 320x200 of real pixels off
|
||||
;; `canvas.stage` whatever the view is zoomed to; see `ui/canvas`.
|
||||
|
||||
(defn stage-point
|
||||
"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}]
|
||||
[:text.aim-tag {:x 1 :y 3.8} label]])]))))
|
||||
|
||||
(defn- overlay [w h]
|
||||
(defn- overlay [w h zoom]
|
||||
(let [tool @(rf/subscribe [::sub/tool])
|
||||
draft @(rf/subscribe [::sub/draft])
|
||||
drawing? (= :polygon tool)
|
||||
|
|
@ -351,7 +352,8 @@
|
|||
;; reallocates the backing store — so this re-rendering costs nothing per frame.
|
||||
(let [w @(rf/subscribe [::playback/width])
|
||||
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-wrap
|
||||
;; A drop on the stage lands at the PLAYHEAD, where the pointer is in space;
|
||||
|
|
@ -376,4 +378,4 @@
|
|||
:height (str (* zoom h) "px")}}]
|
||||
[:canvas.underlay {:ref #(underlay/set-canvas! %)
|
||||
:width (* zoom w) :height (* zoom h)}]
|
||||
[overlay w h]]]))
|
||||
[overlay w h zoom]]]))
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@
|
|||
[arthur.subs.render :as render]
|
||||
[arthur.subs.ui :as sub]
|
||||
[arthur.ui.drag :as drag]
|
||||
[arthur.ui.layout :as layout]
|
||||
[arthur.ui.player :as player]
|
||||
[re-frame.core :as rf]
|
||||
[reagent.core :as r]))
|
||||
|
|
@ -484,6 +485,11 @@
|
|||
:title "move the selected clip's end to the playhead"
|
||||
:on-click (act [::ui/trim :out])} "out"]]
|
||||
[: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
|
||||
;; comes and goes must not shove the fixed controls sideways when they do.
|
||||
(when @(rf/subscribe [::sub/retry])
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@
|
|||
[arthur.events.export :as export]
|
||||
[arthur.events.project :as project]
|
||||
[arthur.subs.playback :as playback]
|
||||
[arthur.ui.layout :as layout]
|
||||
[arthur.ui.openmenu :as openmenu]
|
||||
[arthur.ui.share :as share]
|
||||
[arthur.ui.snapshots :as snapshots]
|
||||
|
|
@ -58,6 +59,10 @@
|
|||
[:header.top
|
||||
[:a.brand {:href "/" :title "your projects"
|
||||
: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"]
|
||||
[openmenu/view]
|
||||
[undo/view]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue