Unify cel editing in the timeline

This commit is contained in:
Your Name 2026-10-01 14:37:59 -04:00
parent 4ddd6a8d1d
commit 26ada03591
18 changed files with 534 additions and 922 deletions

View file

@ -3,6 +3,8 @@
Written against `2f1c9b9` (2026-09-30), following the lane handoff in Written against `2f1c9b9` (2026-09-30), following the lane handoff in
`7a54bfc`. Implemented on `codex/correction-authoring`; this now records the `7a54bfc`. Implemented on `codex/correction-authoring`; this now records the
scope and acceptance criteria of that implementation. scope and acceptance criteria of that implementation.
The cel-sheet targeting work described below was removed with the cel-sheet UI
on 2026-10-01; it remains here only as history of that implementation.
Read [lane-handoff.md](lane-handoff.md) and the correction section of Read [lane-handoff.md](lane-handoff.md) and the correction section of
[lane-model.md](lane-model.md) first. Their ownership and document rules remain [lane-model.md](lane-model.md) first. Their ownership and document rules remain
the foundation. The choices below settle the first implementation's scope. the foundation. The choices below settle the first implementation's scope.

View file

@ -1,11 +1,12 @@
# Lane and cel handoff # Lane and cel handoff
Status (2026-09-30): the lane model is implemented through its commands, its Status (2026-10-01): the timeline is the one timing interface. Cels are ordinary
first two views, and correction authoring. Cels are ordinary nodes with their nodes with their own playback clock and appear as blocks on one row per drawing
own playback clock; the timeline draws them as one row and the cel sheet draws lane. Blocks move by mouse; edge drags trim without overlap; Shift-right-edge
frames down and lanes across. Both views issue the same commands. Rotation and drags ripple every later cel; and the center of a shared cut composes the two
position corrections can be authored as Constant, Ramp, or Return motion on a edge edits into a rolling edit. Linked audio follows picture moves while its
lane or cel, survive regeneration, and expose conflicts for removal or retry. edges remain independently trimmable. Rotation and position corrections survive
regeneration and expose conflicts for removal or retry.
The commits beginning at `3d3c1bb` are the argument for the model and are worth The commits beginning at `3d3c1bb` are the argument for the model and are worth
reading before touching what they did — they are the design record, more than reading before touching what they did — they are the design record, more than
@ -53,8 +54,6 @@ on purpose: the layout names the RULE — children follow one another and may no
overlap — and a group carrying it is called a lane. `node/lane?` is where they overlap — and a group carrying it is called a lane. `node/lane?` is where they
meet. meet.
The second view is the CEL SHEET, not the exposure sheet.
## Decisions already made — do not re-litigate ## Decisions already made — do not re-litigate
These were each argued out and are load-bearing. Changing one is a design These were each argued out and are load-bearing. Changing one is a design
@ -86,23 +85,23 @@ decision, not a cleanup.
- **A cel is not a row.** Rows, expansion and selection are editor state. The - **A cel is not a row.** Rows, expansion and selection are editor state. The
document has never known about rows and must not learn. document has never known about rows and must not learn.
## Current timeline interaction
- Creating a symbol inside an aimed drawing lane creates a one-frame cel at the
playhead. Drawing a polygon uses the existing cel there or creates the same
one-frame cel when the frame is empty.
- Dragging a cel body moves it. A linked audio node follows a picture move;
moving or trimming the audio itself remains independent.
- Dragging a right edge changes its endpoint. Growth consumes adjacent spans
instead of overlapping them. Shift-drag inserts or removes lane time by moving
every later cel by the same delta.
- At a shared boundary, the left and right hit zones trim one side. The center
is a rolling edit: right-edge resize followed by left-edge resize at one frame.
- Split, trim-in, and trim-out are direct buttons and are disabled without an
editable selected span. Movement is a mouse gesture, not a toolbar command.
## Next steps, in order ## Next steps, in order
The cel sheet now supports rectangular selection by pointer drag, Shift-click,
and Shift-arrow, plus an editor-local clipboard (Cmd/Ctrl C/X/V). Paste
overwrites the destination rectangle, including copied gaps, and reuses drawing
symbols. Partial cels retain their local clocks, playback and corrections.
Delete clears frames without closing time. Each cut, paste or clear is one
history transaction. The clipboard belongs to the mounted sheet and current
document; it is not a system clipboard interchange format.
A selected held cel has a bottom-right resize handle. Dragging previews its new
extent and commits one ripple edit on release; Escape cancels. Overflow uses
the existing explicit shot-extension retry. Rectangle edits currently require
lanes on the sheet's clock and paste must fit within the shot and available
columns. Insert-paste, moving rectangles, and repeating multi-cel patterns with
the handle remain future work.
The implemented correction slice and its remaining UI limits are recorded in The implemented correction slice and its remaining UI limits are recorded in
[Correction authoring](correction-authoring-plan.md). [Correction authoring](correction-authoring-plan.md).

View file

@ -1,9 +1,11 @@
# The Lane Model # The Lane Model
Revised 2026-09-30. Target design. Cel ownership, source playback, the Revised 2026-10-01. Cel ownership, source playback, one-row drawing lanes,
content and cel commands, placement anywhere in a lane, overwrite, a one-row cel direct clip movement and edge editing, correction evaluation, and correction
strip, a frame-down cel sheet, correction evaluation, and correction authoring authoring for rotation and position are implemented. The former cel-sheet
for rotation and position are implemented. Retiming commands are not. projection was removed: the timeline is the single timing interface. Sections
below that describe a cel sheet are retained as design history and are superseded
by this revision. Retiming commands are not.
See the status note under See the status note under
[Proof obligations](#proof-obligations-and-implementation-order). [Proof obligations](#proof-obligations-and-implementation-order).

View file

@ -159,7 +159,6 @@
:ui {:open nil :ui {:open nil
:tabs [] :tabs []
:selection nil :selection nil
:time-view :timeline
:tone :skin-base :tone :skin-base
:tool nil :tool nil
:auto-key? false :auto-key? false

View file

@ -63,6 +63,106 @@
nodes later)] nodes later)]
(span/finish clip sid nodes id extent))))) (span/finish clip sid nodes id extent)))))
(defn resize-out
"Put cel `id`'s right edge at lane frame `to`.
Without `ripple?`, growing the cel consumes the starts of the cels it reaches:
wholly covered cels disappear and the last partially covered one is trimmed.
Shrinking leaves a gap. With `ripple?`, every cel beginning at or after the
old edge moves by the same delta, in either direction, so their contents are
preserved. A lane never contains an overlap in either mode."
[clip sid id to {:keys [extent ripple?] :or {extent :keep ripple? false}}]
(let [nodes (get-in clip [:symbols sid :nodes])
n (get nodes id)
lane (get nodes (:parent n))
[lo old-out] (when n (node/placed-span n))
members (when (node/lane? lane) (symbol/lane-cels nodes (:id lane)))
later (when members
(remove #(= id (:id %))
(filter #(>= (first (node/placed-span %)) old-out) members)))
broken (first (symbol/lane-problems nodes))]
(cond
(not (node/lane? lane)) {:refused "select a cel in a lane"}
broken {:refused broken}
(not (integer? to)) {:refused "a cel edge goes to a whole lane frame"}
(not (< lo to)) {:refused "a drawing must keep at least one frame"}
(= to old-out) {:clip clip :selection id}
:else
(let [delta (- to old-out)
resized (assoc nodes id (span/edged n :out to))
changed
(if ripple?
(reduce (fn [ns sibling]
(update-in ns [(:id sibling) :time :at] (fnil + 0) delta))
resized later)
(if (pos? delta)
(reduce
(fn [ns sibling]
(let [[s e] (node/placed-span sibling)]
(cond
(>= s to) ns
(<= e to) (dissoc ns (:id sibling))
:else (assoc ns (:id sibling) (span/edged sibling :in to)))))
resized later)
resized))]
(span/finish clip sid changed id extent)))))
(defn resize-in
"Put cel `id`'s left edge at lane frame `to`. Shrinking leaves a gap;
growing left consumes earlier cels symmetrically with `resize-out`."
[clip sid id to]
(let [nodes (get-in clip [:symbols sid :nodes])
n (get nodes id)
lane (get nodes (:parent n))
[old-in hi] (when n (node/placed-span n))
earlier (when (node/lane? lane)
(remove #(= id (:id %))
(filter #(<= (second (node/placed-span %)) old-in)
(symbol/lane-cels nodes (:id lane)))))
broken (first (symbol/lane-problems nodes))]
(cond
(not (node/lane? lane)) {:refused "select a cel in a lane"}
broken {:refused broken}
(not (integer? to)) {:refused "a cel edge goes to a whole lane frame"}
(not (< to hi)) {:refused "a drawing must keep at least one frame"}
(neg? to) {:refused "a cel cannot begin before the lane"}
(= to old-in) {:clip clip :selection id}
:else
(let [resized (assoc nodes id (span/edged n :in to))
changed (if (< to old-in)
(reduce
(fn [ns sibling]
(let [[s e] (node/placed-span sibling)]
(cond
(<= e to) ns
(>= s to) (dissoc ns (:id sibling))
:else (assoc ns (:id sibling) (span/edged sibling :out to)))))
resized earlier)
resized)]
(span/finish clip sid changed id :keep)))))
(defn roll
"Move the shared boundary between adjacent cels `left-id` and `right-id`.
This is deliberately only the composition of the two ordinary edge edits."
[clip sid left-id right-id to]
(let [nodes (get-in clip [:symbols sid :nodes])
left (get nodes left-id)
right (get nodes right-id)
lane (get nodes (:parent left))
[llo lhi] (when left (node/placed-span left))
[rlo rhi] (when right (node/placed-span right))]
(cond
(or (not (node/lane? lane)) (not= (:parent left) (:parent right)))
{:refused "a rolling edit needs two cels in one lane"}
(not= lhi rlo) {:refused "a rolling edit needs one shared boundary"}
(not (integer? to)) {:refused "a cel edge goes to a whole lane frame"}
(not (< llo to rhi)) {:refused "both drawings must keep at least one frame"}
:else
(let [left-result (resize-out clip sid left-id to {})]
(if (:refused left-result)
left-result
(resize-in (:clip left-result) sid right-id to))))))
(defn blank (defn blank
"Clear lane frames `[a b)` of lane `lane-id`, leaving a GAP. "Clear lane frames `[a b)` of lane `lane-id`, leaving a GAP.
@ -104,58 +204,6 @@
nodes members)] nodes members)]
(span/finish clip sid nodes (or (when spanning id) lane-id) :keep))))) (span/finish clip sid nodes (or (when spanning id) lane-id) :keep)))))
(defn sheet-range
"Snapshot a rectangle in open-symbol frames. Cel-local clocks and channels
survive clipping; gaps are represented by the rectangle's duration."
[clip sid lanes [a b]]
(let [nodes (get-in clip [:symbols sid :nodes])]
(if-not (and (seq lanes) (integer? a) (integer? b) (<= 0 a) (< a b)
(every? #(and (node/lane? (get nodes %))
(= {:at 0 :rate 1} (symbol/frame-map nodes %))) lanes))
{:refused "range editing requires lanes with the same clock as the sheet"}
{:duration (- b a)
:columns
(mapv (fn [id]
(vec (for [n (symbol/lane-cels nodes id)
:let [[lo hi] (node/placed-span n)]
:when (and (< lo b) (> hi a))]
(-> n
(span/edged :in (max a lo))
(span/edged :out (min b hi))
(update-in [:time :at] (fnil - 0) a))))) lanes)})))
(defn paste-range
"Overwrite a rectangle atomically, including its gaps. IDs are supplied by
the caller. Copies share drawings and retain cel-local animation."
[clip sid lanes at payload new-id]
(let [duration (:duration payload)
check (when duration (sheet-range clip sid lanes [at (+ at duration)]))]
(cond
(:refused payload) payload
(not= (count lanes) (count (:columns payload)))
{:refused "the copied range does not fit the destination lanes"}
(or (nil? duration) (:refused check))
(or check {:refused "copy a cel-sheet range first"})
(> (+ at duration) (get-in clip [:symbols sid :frames]))
{:refused "the copied range extends beyond the shot"}
:else
(reduce
(fn [result [id cels]]
(if (:refused result) (reduced result)
(let [cleared (blank (:clip result) sid id [at (+ at duration)] {:id (new-id)})]
(if (:refused cleared) (reduced cleared)
(let [nodes (reduce (fn [nodes n]
(let [cid (new-id)]
(assoc nodes cid (-> n
(assoc :id cid :parent id :z (str "a-" cid))
(update-in [:time :at] + at)))))
(get-in (:clip cleared) [:symbols sid :nodes]) cels)
result (span/finish (:clip cleared) sid nodes id :keep)
problems (when (:clip result) (clip/problems (:clip result)))]
(if (seq problems) {:refused (first problems)} result))))))
{:clip clip :selection (first lanes)}
(map vector lanes (:columns payload))))))
(defn add-lane [clip sid id] (defn add-lane [clip sid id]
(if (or (nil? (clip/symbol clip sid)) (get-in clip [:symbols sid :nodes id])) (if (or (nil? (clip/symbol clip sid)) (get-in clip [:symbols sid :nodes id]))
{:refused "the symbol is missing or the lane ID is already used"} {:refused "the symbol is missing or the lane ID is already used"}

View file

@ -19,8 +19,10 @@
matrix becomes a `:pinv`, Blender's parent-inverse, and the time a new `:at` matrix becomes a `:pinv`, Blender's parent-inverse, and the time a new `:at`
and `:rate`. Its channels, keys and span are untouched." and `:rate`. Its channels, keys and span are untouched."
(:require [arthur.domain.clip :as clip] (:require [arthur.domain.clip :as clip]
[arthur.domain.lane :as lane]
[arthur.domain.node :as node] [arthur.domain.node :as node]
[arthur.domain.palette :as pal] [arthur.domain.palette :as pal]
[arthur.domain.span :as span]
[arthur.domain.symbol :as symbol])) [arthur.domain.symbol :as symbol]))
(defn- resolved (defn- resolved
@ -342,13 +344,84 @@
(nil? (:time here)) {:refused "a looping instance is in the way"} (nil? (:time here)) {:refused "a looping instance is in the way"}
:else :else
(let [chain (map #(get nodes %) (reverse (rest (symbol/lineage nodes id)))) (let [chain (map #(get nodes %) (reverse (rest (symbol/lineage nodes id))))
d (* df (:rate (reduce node/then-time (:time here) (map node/time-of chain))))] d (* df (:rate (reduce node/then-time (:time here) (map node/time-of chain))))
{:clip (clip/update-symbol shift (fn [n]
clip (:sid here) update-in [:nodes id] (if (= :map (get-in n [:time :mode]))
(fn [n] (update-in n [:time :at] (fnil + 0) d)
(if (= :map (get-in n [:time :mode])) (assoc n :time {:mode :map :at d :rate 1})))
(update-in n [:time :at] (fnil + 0) d) moved (assoc nodes id (shift (get nodes id)))
(assoc n :time {:mode :map :at d :rate 1}))))})))) ;; An editorial audio link follows a moved picture. Moving or
;; trimming the audio itself remains independent.
moved (if (= :audio (:kind (get nodes id)))
moved
(reduce (fn [ns [audio-id n]]
(if (and (= :audio (:kind n)) (= id (:linked-to n)))
(assoc ns audio-id (shift n))
ns))
moved nodes))
ps (symbol/problems (assoc (clip/symbol clip (:sid here)) :nodes moved))]
(if (seq ps)
{:refused (first ps)}
{:clip (assoc-in clip [:symbols (:sid here) :nodes] moved)})))))
(defn resize-out
"Move the right edge of the node at `path` by `df` frames of `open`.
Lane cels use the lane's collision/ripple rules; ordinary clips, including
audio, resize independently."
[clip open path df ripple?]
(let [here (down clip open (pop path))
sid (:sid here)
id (peek path)
nodes (:nodes (clip/symbol clip sid))
n (get nodes id)]
(cond
(nil? n) {:refused "nothing to resize"}
(nil? (:time here)) {:refused "a looping instance is in the way"}
:else
(let [chain (map #(get nodes %) (reverse (rest (symbol/lineage nodes id))))
d (* df (:rate (reduce node/then-time (:time here) (map node/time-of chain))))
to (+ (second (node/placed-span n)) d)]
(if (node/lane? (get nodes (:parent n)))
(lane/resize-out clip sid id to {:ripple? ripple? :extent :grow-symbol})
(span/resize-out clip sid id to))))))
(defn resize-in
"Move a lane cel's left edge by `df` frames of `open`."
[clip open path df]
(let [here (down clip open (pop path))
sid (:sid here)
id (peek path)
nodes (:nodes (clip/symbol clip sid))
n (get nodes id)]
(cond
(nil? n) {:refused "nothing to resize"}
(nil? (:time here)) {:refused "a looping instance is in the way"}
(not (node/lane? (get nodes (:parent n))))
{:refused "only a lane cel has a collision-free left edge"}
:else
(let [chain (map #(get nodes %) (reverse (rest (symbol/lineage nodes id))))
d (* df (:rate (reduce node/then-time (:time here) (map node/time-of chain))))
to (+ (first (node/placed-span n)) d)]
(lane/resize-in clip sid id to)))))
(defn roll
"Move the shared boundary at `right-path` and the adjacent `left-path`."
[clip open left-path right-path df]
(let [here (down clip open (pop right-path))
sid (:sid here)
left-id (peek left-path)
right-id (peek right-path)
nodes (:nodes (clip/symbol clip sid))
right (get nodes right-id)]
(cond
(or (not= (pop left-path) (pop right-path)) (nil? right))
{:refused "a rolling edit needs adjacent clips in one lane"}
(nil? (:time here)) {:refused "a looping instance is in the way"}
:else
(let [chain (map #(get nodes %) (reverse (rest (symbol/lineage nodes right-id))))
d (* df (:rate (reduce node/then-time (:time here) (map node/time-of chain))))
to (+ (first (node/placed-span right)) d)]
(lane/roll clip sid left-id right-id to)))))
(defn restack (defn restack
"Put the node at row path `from` just in front of the one at `to` when "Put the node at row path `from` just in front of the one at `to` when

View file

@ -168,6 +168,21 @@
{:refused (str "frame " to " is not inside this; trim narrows it")} {:refused (str "frame " to " is not inside this; trim narrows it")}
:else (finish clip sid (assoc nodes id (edged node edge to)) id :keep)))) :else (finish clip sid (assoc nodes id (edged node edge to)) id :keep))))
(defn resize-out
"Put one node's right edge at parent frame `to`, allowing it to grow.
This is for an ordinary timeline clip, including audio. Lane cels use
`lane/resize-out`, because only a lane has neighbours to trim or ripple."
[clip sid id to]
(let [nodes (get-in clip [:symbols sid :nodes])
{:keys [node refused]} (subject nodes id)
[lo _] (when node (node/placed-span node))]
(cond
refused {:refused refused}
(not (integer? to)) {:refused "an edge goes to a whole frame"}
(not (< lo to)) {:refused "a clip must keep at least one frame"}
:else (finish clip sid (assoc nodes id (edged node :out to)) id :keep))))
(defn move (defn move
"Put node `id` at parent frame `to`, leaving its own length, source and "Put node `id` at parent frame `to`, leaving its own length, source and
corrections alone — and, in a lane, every other cel. corrections alone — and, in a lane, every other cel.

View file

@ -129,9 +129,8 @@
"The symbol's lanes, front-most first. "The symbol's lanes, front-most first.
SAME ORDER THE TIMELINE DRAWS ITS ROWS IN — `:z` descending, the id breaking SAME ORDER THE TIMELINE DRAWS ITS ROWS IN — `:z` descending, the id breaking
ties so it is stable across runs — because the cel sheet's columns are those ties so it is stable across runs, so \"the first lane\" means the same thing
rows stood on end, and \"the first lane\" has to mean the same thing to the view to the view that shows it and to commands that inspect the lane order.
that shows them and to the command that defaults to one.
Only this symbol's own: a lane inside a nested instance belongs to that Only this symbol's own: a lane inside a nested instance belongs to that
symbol, and aiming a drawing at it is entering it first." symbol, and aiming a drawing at it is entering it first."

View file

@ -9,8 +9,8 @@
`lane-model.md` forbids in so many words: \"Selection does not secretly change `lane-model.md` forbids in so many words: \"Selection does not secretly change
where a new symbol goes.\" where a new symbol goes.\"
THE TARGET MOVES ONLY WHEN SOMEBODY AIMS IT. A click on a timeline row label, THE TARGET MOVES ONLY WHEN SOMEBODY AIMS IT. A click on a timeline row label
a cel-sheet column, or a breadcrumb is aiming — each names a place in the or a breadcrumb is aiming — each names a place in the
document and nothing else. A click on the stage, or on a timeline bar, is not: document and nothing else. A click on the stage, or on a timeline bar, is not:
it says look at this. So `::select` never writes the target, and `::aim` it says look at this. So `::select` never writes the target, and `::aim`
always writes both. always writes both.
@ -72,13 +72,13 @@
(rf/reg-event-db (rf/reg-event-db
::aim ::aim
;; The gestures that name a PLACE in the document rather than a thing on ;; The gestures that name a PLACE in the document rather than a thing on
;; screen: a timeline row label, a cel-sheet column, a breadcrumb. ;; screen: a timeline row label or a breadcrumb.
(fn [db [_ selection]] (aimed db selection))) (fn [db [_ selection]] (aimed db selection)))
(rf/reg-event-db (rf/reg-event-db
::aim-at ::aim-at
;; Aiming without selecting, for the cel sheet: the lane becomes where drawings ;; Aiming without selecting: the lane becomes where drawings go while the
;; go, and what is SELECTED stays whatever cel the sheet picked in it. ;; inspector selection remains independent.
(fn [db [_ selection]] (assoc-in db [:ui :target] (target-of selection)))) (fn [db [_ selection]] (assoc-in db [:ui :target] (target-of selection))))
(rf/reg-event-db (rf/reg-event-db
@ -95,35 +95,6 @@
(= sid (get-in db [:ui :open]))) (= sid (get-in db [:ui :open])))
n))) n)))
(defn aim-a-lane
"`db` with a lane of the open symbol aimed, if one is not aimed already.
THE CEL SHEET IS A DRAWING-LANE MODE, so being in it with nothing aimed is not
a state it has anything to say in: every cell belongs to a lane and every new
drawing goes in one. A symbol with no lane at all is the one honest exception,
and the view asks for one to be made rather than this inventing it."
[db]
(let [clip (:clip (store/entry (:clip/current db)))
open (get-in db [:ui :open])]
(if (aimed-lane clip db)
db
(if-let [lane (first (symbol/lanes (get-in clip [:symbols open :nodes])))]
(assoc-in db [:ui :target] {:sid open :id (:id lane) :path [(:id lane)]})
(update db :ui dissoc :target)))))
(rf/reg-event-db
::set-time-view
(fn [db [_ view]]
(if (#{:timeline :cel-sheet} view)
(cond-> (assoc-in db [:ui :time-view] view)
(= :cel-sheet view) aim-a-lane)
db)))
(rf/reg-event-db
::aim-a-lane
;; The cel sheet asks for this when what it had aimed has gone — the lane
;; deleted under it, or the open symbol changed beneath the view.
(fn [db _] (aim-a-lane db)))
(defn apply-lane-command (defn apply-lane-command
"Commit a successful domain command as one history step. A refused command "Commit a successful domain command as one history step. A refused command
@ -364,26 +335,6 @@
{:db (update db :ui dissoc :lane-retry) :dispatch event} {:db (update db :ui dissoc :lane-retry) :dispatch event}
{}))) {})))
(rf/reg-event-db
::sheet-paste
(fn [db [_ sid lanes at payload]]
(let [clip (:clip (store/entry (:clip/current db)))]
(apply-lane-command db sid
(lane/paste-range clip sid lanes at payload random-uuid) nil))))
(rf/reg-event-db
::sheet-hold
(fn [db [_ sid id end extent]]
(let [clip (:clip (store/entry (:clip/current db)))
n (get-in clip [:symbols sid :nodes id])
at (lane/lane-frame clip sid (:parent n) end)
delta (when at (- at (second (node/placed-span n))))]
(if (= 0 delta) db
(apply-lane-command db sid
(if delta
(lane/extend-hold clip sid id delta {:extent (or extent :keep)})
{:refused "this lane's frames are not the open symbol's"})
[::sheet-hold sid id end :grow-symbol])))))
(rf/reg-event-db (rf/reg-event-db
::toggle-row ::toggle-row
@ -520,22 +471,17 @@
(defn polygon-landing (defn polygon-landing
"Choose the document and row path a finished polygon is drawn into. "Choose the document and row path a finished polygon is drawn into.
In the timeline this follows the explicit target. In the cel sheet the aimed An aimed drawing lane wins regardless of what is selected on the stage: an
lane wins, regardless of what is selected on the stage: an occupied frame occupied frame lands in its cel and a gap first becomes a one-frame drawing.
lands in its cel and a gap first becomes a drawing. Keeping this decision out With no lane aimed, ordinary target-based drawing is unchanged."
of the event handler makes the selection/target boundary a rule we can assert
without driving re-frame."
[clip st db] [clip st db]
(if (= :cel-sheet (get-in db [:ui :time-view] :timeline)) (if-let [lane (aimed-lane clip db)]
(if-let [lane (aimed-lane clip db)] (assoc (into-the-lane clip st db lane) :lane? true)
(assoc (into-the-lane clip st db lane) :lane? true)
{:refused "make a drawing lane to draw in — the cel sheet has none"
:lane? true})
{:clip clip :path (where-new-goes clip db) :lane? false})) {:clip clip :path (where-new-goes clip db) :lane? false}))
(defn beginning-polygon (defn beginning-polygon
"Enter polygon mode, first materializing the cel-sheet lane and drawing when "Enter polygon mode, first materializing a drawing at the playhead when a
either is missing. In the timeline this is editor state only. drawing lane is aimed and that frame is empty.
Creating here, rather than when the polygon is finished, means the drawing is Creating here, rather than when the polygon is finished, means the drawing is
already the current cel while points are being placed. Cancelling the polygon already the current cel while points are being placed. Cancelling the polygon
@ -543,20 +489,7 @@
[db] [db]
(let [{clip :clip st :store} (store/entry (:clip/current db)) (let [{clip :clip st :store} (store/entry (:clip/current db))
open (get-in db [:ui :open]) open (get-in db [:ui :open])
sheet? (= :cel-sheet (get-in db [:ui :time-view] :timeline)) landing (polygon-landing clip st db)
no-lanes? (and sheet?
(empty? (symbol/lanes (get-in clip [:symbols open :nodes]))))
lane-id (when no-lanes? (random-uuid))
lane-result (if no-lanes?
(lane/add-lane clip open lane-id)
{:clip clip})
aimed-db (if (and no-lanes? (not (:refused lane-result)))
(assoc-in db [:ui :target]
{:sid open :id lane-id :path [lane-id]})
db)
landing (if-let [why (:refused lane-result)]
{:refused why :lane? true}
(polygon-landing (:clip lane-result) st aimed-db))
source (:clip landing) source (:clip landing)
made? (and source (not (identical? clip source))) made? (and source (not (identical? clip source)))
db' (cond db' (cond
@ -564,13 +497,13 @@
(update db :project merge {:status (:refused landing)}) (update db :project merge {:status (:refused landing)})
made? made?
(-> aimed-db (-> db
(edit/transaction (constantly source)) (edit/transaction (constantly source))
(assoc-in [:ui :selection] (assoc-in [:ui :selection]
[:node open [:node open
(peek (:path landing)) (:path landing)])) (peek (:path landing)) (:path landing)]))
:else aimed-db)] :else db)]
(if (:refused landing) (if (:refused landing)
db' db'
(update db' :ui merge {:tool :polygon :draft []})))) (update db' :ui merge {:tool :polygon :draft []}))))
@ -634,23 +567,38 @@
(rf/reg-event-db (rf/reg-event-db
::new-symbol ::new-symbol
;; `where` is `:inside` — in whatever the target names — or `:top`, in the open ;; `where` is `:inside` — in whatever the target names — or `:top`, in the open
;; symbol regardless of it. Two items on one menu rather than a rule nobody can ;; symbol regardless of it. A drawing lane is the temporal form of this same
;; see: `lane-model.md` asks that "creation controls next to the breadcrumb act ;; operation: inside it, a new symbol is a one-frame cel at the playhead.
;; in that explicit location", and the honest way to offer the other location is
;; to offer it.
(fn [db [_ where]] (fn [db [_ where]]
(let [{clip :clip st :store} (store/entry (:clip/current db)) (let [{clip :clip st :store} (store/entry (:clip/current db))
drawing-lane (when (= :inside where) (aimed-lane clip db))
open (get-in db [:ui :open])
owner (:frame (nest/inside clip st open [] (get-in db [:playback :frame])))
at (when (and drawing-lane (number? owner))
(lane/lane-frame clip open (:id drawing-lane) owner))
drawing-id (clip/fresh-id clip)
cel-id (random-uuid)
down (if (= :top where) [] (where-new-goes clip db)) down (if (= :top where) [] (where-new-goes clip db))
{host :sid frame :frame} (nest/inside clip st (get-in db [:ui :open]) down {host :sid frame :frame} (nest/inside clip st open down
(get-in db [:playback :frame])) (get-in db [:playback :frame]))
sid (clip/fresh-id clip)
uuid (random-uuid)] uuid (random-uuid)]
(if-not host (if drawing-lane
(let [result (if (integer? at)
(lane/overwrite-drawing clip open (:id drawing-lane) cel-id drawing-id at
{:extent :grow-symbol
:remainder-id (random-uuid)})
{:refused "the playhead is not on one frame of this lane"})]
(if-let [why (:refused result)]
(update db :project merge {:status why})
(-> db
(edit/transaction (constantly (:clip result)))
(assoc-in [:ui :selection] [:node open cel-id [cel-id]]))))
(if-not host
(update db :project merge (update db :project merge
{:status "what you are adding to is not on screen at this frame"}) {:status "what you are adding to is not on screen at this frame"})
(let [made [:node host uuid (conj down uuid)]] (let [made [:node host uuid (conj down uuid)]]
(-> db (-> db
(edit/transaction #(clip/new-symbol % host sid frame uuid)) (edit/transaction #(clip/new-symbol % host drawing-id frame uuid))
;; AIMED AT WHAT IT MADE. A symbol is made to put things in, so the ;; AIMED AT WHAT IT MADE. A symbol is made to put things in, so the
;; next thing made goes in it; the outline says so before anybody ;; next thing made goes in it; the outline says so before anybody
;; has to find out by drawing. ;; has to find out by drawing.
@ -658,7 +606,7 @@
;; Open every row down to it, or the new row is inside a closed one ;; Open every row down to it, or the new row is inside a closed one
;; and the button looks like it did nothing. ;; and the button looks like it did nothing.
(update-in [:ui :expanded] (fnil into #{}) (update-in [:ui :expanded] (fnil into #{})
(rest (reductions conj [] down))))))))) (rest (reductions conj [] down))))))))))
;; --------------------------------------------------------------------------- ;; ---------------------------------------------------------------------------
;; a drop in flight ;; a drop in flight
@ -729,16 +677,23 @@
::sliding ::sliding
;; A bar in the middle of a slide, drawn by `::render/clip`; nil path when the ;; A bar in the middle of a slide, drawn by `::render/clip`; nil path when the
;; drag is abandoned. ;; drag is abandoned.
(fn [db [_ path df]] (fn [db [_ path df kind ripple? other]]
(if path (if path
(assoc-in db [:ui :sliding] {:path path :df df}) (assoc-in db [:ui :sliding] {:path path :df df :kind (or kind :slide)
:ripple? (boolean ripple?) :other other})
(update db :ui dissoc :sliding)))) (update db :ui dissoc :sliding))))
(rf/reg-event-db (rf/reg-event-db
::slide ::slide
(fn [db [_ path df]] (fn [db [_ path df kind ripple? other]]
(let [db (update db :ui dissoc :sliding) (let [db (update db :ui dissoc :sliding)
r (nest/slide (:clip (store/entry (:clip/current db))) (get-in db [:ui :open]) path df)] clip (:clip (store/entry (:clip/current db)))
open (get-in db [:ui :open])
r (case kind
:out (nest/resize-out clip open path df ripple?)
:in (nest/resize-in clip open path df)
:roll (nest/roll clip open other path df)
(nest/slide clip open path df))]
(cond (cond
(zero? df) db (zero? df) db
(:refused r) (refused db (:refused r)) (:refused r) (refused db (:refused r))

View file

@ -44,8 +44,12 @@
;; lets go, so the stage and the rows follow the pointer. Nothing is written ;; lets go, so the stage and the rows follow the pointer. Nothing is written
;; until then: one drag is one undo step and one write to collaborators. ;; until then: one drag is one undo step and one write to collaborators.
(let [c (:clip (footage/entry id))] (let [c (:clip (footage/entry id))]
(or (when-let [{:keys [path df]} sliding] (or (when-let [{:keys [path df kind ripple? other]} sliding]
(:clip (nest/slide c open path df))) (:clip (case kind
:out (nest/resize-out c open path df ripple?)
:in (nest/resize-in c open path df)
:roll (nest/roll c open other path df)
(nest/slide c open path df))))
(when-let [{:keys [sid id frame values]} gesture] (when-let [{:keys [sid id frame values]} gesture]
;; A held stage control owns the touched parameters completely. Make ;; A held stage control owns the touched parameters completely. Make
;; them temporary static channels for the preview, so their existing ;; them temporary static channels for the preview, so their existing

View file

@ -13,7 +13,6 @@
(rf/reg-sub ::selection (fn [db _] (get-in db [:ui :selection]))) (rf/reg-sub ::selection (fn [db _] (get-in db [:ui :selection])))
(rf/reg-sub ::target (fn [db _] (get-in db [:ui :target]))) (rf/reg-sub ::target (fn [db _] (get-in db [:ui :target])))
(rf/reg-sub ::time-view (fn [db _] (get-in db [:ui :time-view] :timeline)))
(rf/reg-sub ::lane-retry (fn [db _] (get-in db [:ui :lane-retry]))) (rf/reg-sub ::lane-retry (fn [db _] (get-in db [:ui :lane-retry])))
(rf/reg-sub ::tone (fn [db _] (get-in db [:ui :tone]))) (rf/reg-sub ::tone (fn [db _] (get-in db [:ui :tone])))
(rf/reg-sub ::tool (fn [db _] (get-in db [:ui :tool]))) (rf/reg-sub ::tool (fn [db _] (get-in db [:ui :tool])))

View file

@ -24,7 +24,6 @@
[arthur.domain.node :as node] [arthur.domain.node :as node]
[arthur.domain.clip :as clip] [arthur.domain.clip :as clip]
[arthur.domain.nest :as nest] [arthur.domain.nest :as nest]
[arthur.domain.lane :as lane]
[arthur.domain.span :as span] [arthur.domain.span :as span]
[arthur.domain.symbol :as symbol] [arthur.domain.symbol :as symbol]
[arthur.domain.trace :as trace] [arthur.domain.trace :as trace]
@ -32,7 +31,6 @@
[arthur.events.ui :as ui] [arthur.events.ui :as ui]
[arthur.footage.store :as store] [arthur.footage.store :as store]
[arthur.ui.icon :as icon] [arthur.ui.icon :as icon]
[arthur.ui.menu :as menu]
[arthur.subs.playback :as playback] [arthur.subs.playback :as playback]
[arthur.subs.render :as render] [arthur.subs.render :as render]
[arthur.subs.ui :as sub] [arthur.subs.ui :as sub]
@ -231,7 +229,6 @@
rate @(rf/subscribe [::playback/rate]) rate @(rf/subscribe [::playback/rate])
loop? @(rf/subscribe [::playback/loop?]) loop? @(rf/subscribe [::playback/loop?])
muted? @(rf/subscribe [::playback/muted?]) muted? @(rf/subscribe [::playback/muted?])
view @(rf/subscribe [::sub/time-view])
frame @(rf/subscribe [::playback/frame]) frame @(rf/subscribe [::playback/frame])
frames @(rf/subscribe [::render/frames]) frames @(rf/subscribe [::render/frames])
{:keys [fps drop]} @player/meter {:keys [fps drop]} @player/meter
@ -241,12 +238,7 @@
[_ sid id] selection [_ sid id] selection
open @(rf/subscribe [::render/open]) open @(rf/subscribe [::render/open])
st (:store (store/entry clip-id)) st (:store (store/entry clip-id))
sheet? (= :cel-sheet view)
n (get-in clip [:symbols sid :nodes id]) n (get-in clip [:symbols sid :nodes id])
lane (if (node/lane? n) n (get-in clip [:symbols sid :nodes (:parent n)]))
lane? (node/lane? lane)
cel? (and lane? (= :instance (:kind n)) (some? (node/source n)))
held? (and cel? (zero? (:speed (node/playback-of n))))
;; A SPAN IS A SPAN WHEREVER IT SITS. What `span/split`, `trim` and ;; A SPAN IS A SPAN WHEREVER IT SITS. What `span/split`, `trim` and
;; `move` need is one node with a place in time, which a symbol dropped ;; `move` need is one node with a place in time, which a symbol dropped
;; straight into a shot has as surely as a cel does — so these are ;; straight into a shot has as surely as a cel does — so these are
@ -265,10 +257,6 @@
host (when (number? owner-frame) (span/host-frame clip sid id owner-frame)) host (when (number? owner-frame) (span/host-frame clip sid id owner-frame))
cuttable? (and span? (integer? host) cuttable? (and span? (integer? host)
(let [[lo hi] (node/placed-span n)] (< lo host hi))) (let [[lo hi] (node/placed-span n)] (< lo host hi)))
movable? (and span? (integer? host))
at (when (and lane? (number? owner-frame))
(lane/lane-frame clip sid (:id lane) owner-frame))
insertable? (and lane? (integer? at))
act (fn [event] #(rf/dispatch event))] act (fn [event] #(rf/dispatch event))]
[:div.pane-head [:div.pane-head
;; ----------------------------------------------------------------- time ;; ----------------------------------------------------------------- time
@ -308,97 +296,18 @@
(doall (for [[r label] [[0.25 "¼×"] [0.5 "½×"] [1.0 "1×"] [2.0 "2×"] [4.0 "4×"]]] (doall (for [[r label] [[0.25 "¼×"] [0.5 "½×"] [1.0 "1×"] [2.0 "2×"] [4.0 "4×"]]]
^{:key r} [:option {:value r} label]))]] ^{:key r} [:option {:value r} label]))]]
[:span.sep] [:span.sep]
;; ----------------------------------------------------------------- view ;; The same direct timing operations apply to any selected span, whether it
;; TWO VIEWS OF ONE THING, and you are always in exactly one. Two separate ;; is at the root or is a cel inside a drawing lane.
;; toggles said neither half of that; joined, with the one in force filled, [:div.group.timing-controls {:aria-label "timing"}
;; the control is the statement. [:button {:disabled (not cuttable?) :aria-label "split"
[:div.seg {:role "radiogroup" :aria-label "time view"} :title "split the selected clip at the playhead"
(doall :on-click (act [::ui/split])} "split"]
(for [[k label title] [[:timeline "timeline" "lanes across, frames left to right"] [:button {:disabled (not cuttable?) :aria-label "trim in"
[:cel-sheet "cel sheet" "frames down, one column per lane"]]] :title "move the selected clip's start to the playhead"
^{:key k} :on-click (act [::ui/trim :in])} "in"]
[:button {:class (when (= k view) "on") :role "radio" [:button {:disabled (not cuttable?) :aria-label "trim out"
:aria-checked (= k view) :title title :title "move the selected clip's end to the playhead"
:on-click (act [::ui/set-time-view k])} :on-click (act [::ui/trim :out])} "out"]]
label]))]
[:span.sep]
;; ------------------------------------------------------------- commands
;; GROUPED BY WHAT THEY NEED, not by which view they were built in.
;;
;; `timing` is always here. Split, trim and move are one write to one
;; node's span or position, and that is a fact every placement has: a
;; symbol laid out in a shot is cut and trimmed exactly as a cel is, and
;; the lane gate they used to carry was a leftover from a lane being the
;; only thing anybody had timed. Judging a cut against the other rows is
;; also the timeline's whole shape, so hiding them here would have cost
;; the view the one thing it is best at.
;;
;; `drawing` and `hold` are the CEL SHEET's and appear only there. Each one
;; needs a SEQUENCE — a ripple needs later siblings, a gap needs a row to
;; be a hole in — and a sequence is what a lane is. Choosing how many
;; frames a drawing is exposed for is plate-side work; the timeline is the
;; performance side.
;;
;; `new drawing` is NOT here, in either view. Drawing a polygon on an empty
;; frame of the aimed lane makes the drawing that was missing, so the
;; button said what the gesture already says. `N` still does it from the
;; sheet, for the draw-N-draw-N rhythm that wants a key and not a menu.
;;
;; `make unique` is NOT here either. It is offered on the location bar,
;; beside the count of how many places share the drawing — which is the
;; fact somebody needs before deciding to decouple one, and is why that row
;; exists at all. `ui/location`.
;;
;; `new` is on the location bar for the same kind of reason: creating a
;; symbol acts at the location the breadcrumb names, not on a cel.
[menu/view
{:label "timing" :title "where this sits and how long it lasts"
:note "select a cel, or anything else placed in time"
:items [{:label "split" :disabled? (not cuttable?)
:sub "cut this in two at the playhead; the picture does not change"
:on-click (act [::ui/split])}
{:label "trim in" :disabled? (not cuttable?)
:sub "start this at the playhead; nothing else moves"
:on-click (act [::ui/trim :in])}
{:label "trim out" :disabled? (not cuttable?)
:sub "end this at the playhead; nothing else moves"
:on-click (act [::ui/trim :out])}
{:label "move here" :disabled? (not movable?)
:sub "put this at the playhead; in a lane, refused if something is there"
:on-click (act [::ui/move])}]}]
(when sheet?
[:<>
[menu/view
{:label "drawing" :title "what the aimed lane exposes"
:note "select a cel in the sheet"
:items [{:label "insert" :disabled? (not insertable?)
:sub "a new drawing at the playhead; later drawings ripple later"
:on-click (act [::ui/insert-drawing])}
{:label "overwrite" :disabled? (not insertable?)
:sub "replace the drawing at the playhead; later drawings stay put"
:on-click (act [::ui/overwrite-drawing])}
{:label "reuse" :disabled? (not cel?)
:sub "expose this same drawing again — one drawing, two cels"
:on-click (act [::ui/reuse-drawing])}
{:label "duplicate" :disabled? (not cel?)
:sub "append a copy of this drawing, to draw the next one over it"
:on-click (act [::ui/duplicate-drawing])}
{:label "blank" :disabled? (not cel?)
:sub "clear this cel's frames, leaving a gap; later drawings stay put"
:on-click (act [::ui/blank-cel])}]}]
;; THE ONE PAIR THAT STAYS A BUTTON. Deciding how many frames a drawing
;; is held for is shooting on ones or twos — the most repeated edit in
;; the list, done by eye, a frame at a time. Two clicks into a menu per
;; frame would be the one place this consolidation made the tool worse.
[:span.stepper
[:span.stepper-label "hold"]
[:span.group
[:button {:disabled (not held?) :aria-label "hold −"
:title "shorten this cel; ripple later drawings, keeping lane keys fixed"
:on-click (act [::ui/extend-hold -1])} "−"]
[:button {:disabled (not held?) :aria-label "hold +"
:title "extend this cel; ripple later drawings, keeping lane keys fixed"
:on-click (act [::ui/extend-hold 1])} "+"]]]])
[:span.spacer] [:span.spacer]
;; 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.
@ -542,18 +451,33 @@
:df}`. What it looks like mid-drag is `[:ui :sliding]`, which the clip every :df}`. What it looks like mid-drag is `[:ui :sliding]`, which the clip every
row and the stage are drawn from already has in it." row and the stage are drawn from already has in it."
[{:keys [path span keys dense? kind node-kind select slides cels]} frames sliding] [{:keys [path span keys dense? kind node-kind select slides cels]} frames sliding]
(let [{from :path x0 :x width :width} @sliding (let [active-row (:row @sliding)
slide (fn [^js e] slide (fn [^js e]
(let [{from :row x0 :x width :width} @sliding]
(when (= path from) (when (= path from)
(let [df (js/Math.round (/ (* frames (- (.-clientX e) x0)) (max 1 width)))] (let [df (js/Math.round (/ (* frames (- (.-clientX e) x0)) (max 1 width)))]
(when (not= df (:df @sliding)) (when (not= df (:df @sliding))
(swap! sliding assoc :df df) (swap! sliding assoc :df df)
(rf/dispatch [::ui/sliding (or slides path) df]))))) (rf/dispatch [::ui/sliding (:path @sliding) df
(:kind @sliding) (:ripple? @sliding)
(:other @sliding)]))))))
done (fn [commit?] done (fn [commit?]
(when (= path from) (when (= path (:row @sliding))
(let [df (:df @sliding)] (let [{:keys [path df kind ripple? other]} @sliding]
(reset! sliding nil) (reset! sliding nil)
(rf/dispatch (if commit? [::ui/slide (or slides path) df] [::ui/sliding nil])))))] (rf/dispatch (if commit? [::ui/slide path df kind ripple? other]
[::ui/sliding nil])))))
begin! (fn [^js e actual-path gesture-kind actual-select other]
(let [track (.closest (.-currentTarget e) ".tl-track")]
(.stopPropagation e)
(when actual-select (rf/dispatch [::ui/select actual-select]))
(reset! sliding {:row path :path actual-path :kind gesture-kind
:other other
:ripple? (and (= :out gesture-kind) (.-shiftKey e))
:x (.-clientX e) :df 0
:width (.-width (.getBoundingClientRect track))})
(try (.setPointerCapture track (.-pointerId e))
(catch :default _ nil))))]
[:div.tl-track [:div.tl-track
;; The track, not the bar, holds the pointer while a bar slides, so the drag ;; The track, not the bar, holds the pointer while a bar slides, so the drag
;; goes on when the bar has slid off the ruler and is no longer drawn. ;; goes on when the bar has slid off the ruler and is no longer drawn.
@ -566,37 +490,51 @@
(when (< in out) (when (< in out)
[:div {:class (str "tl-span" (when dense? " dense") (when (= :ghost kind) " ghost") [:div {:class (str "tl-span" (when dense? " dense") (when (= :ghost kind) " ghost")
(when (= :audio node-kind) " sound") (when (= :audio node-kind) " sound")
(when select " movable") (when (= path from) " sliding")) (when select " movable") (when (= path active-row) " sliding"))
:style {:left (edge% in frames) :style {:left (edge% in frames)
:width (str (* 100 (/ (- out in) (max 1 frames))) "%")} :width (str (* 100 (/ (- out in) (max 1 frames))) "%")}
:on-pointer-down :on-pointer-down
(when select (when select
(fn [^js e] #(begin! % (or slides path) :slide select nil))}
(let [track (.. e -currentTarget -parentElement)] (when select
(.stopPropagation e) [:span.tl-edge.out {:title "Drag endpoint · Shift-drag ripples later clips"
(rf/dispatch [::ui/select select]) :on-pointer-down #(begin! % path :out select nil)}])]))
(reset! sliding {:path path :x (.-clientX e) :df 0
:width (.-width (.getBoundingClientRect track))})
;; As on the ruler: an enhancement that throws on a pointer
;; the browser has no record of.
(try (.setPointerCapture track (.-pointerId e))
(catch :default _ nil)))))}]))
(doall (doall
(for [{:keys [id label source span select]} cels (for [[i {:keys [id label source span select]}] (map-indexed vector cels)
:let [in (max 0 (first span)) out (min frames (second span))] :let [prev (when (pos? i) (nth cels (dec i)))
joined? (and prev (= (second (:span prev)) (first span)))
in (max 0 (first span)) out (min frames (second span))]
:when (< in out)] :when (< in out)]
^{:key (str id)} ^{:key (str id)}
[:button.tl-cel [:button.tl-cel
{:title (str label " · select cel; double-click to edit shared drawing") {:title (str label " · select cel; double-click to edit shared drawing")
:style {:position "absolute" :left (edge% in frames) :style {:position "absolute" :left (edge% in frames)
:width (str (* 100 (/ (- out in) (max 1 frames))) "%") :width (str (* 100 (/ (- out in) (max 1 frames))) "%")
:top "2px" :bottom "2px" :overflow "hidden" :padding "0 3px"} :top "2px" :bottom "2px" :overflow "visible" :padding "0 3px"}
:on-pointer-down #(begin! % (nth select 3) :slide select nil)
:on-click (fn [e] (.stopPropagation e) :on-click (fn [e] (.stopPropagation e)
(rf/dispatch [::ui/select select]) (rf/dispatch [::ui/select select])
(rf/dispatch [::pb/seek (js/Math.floor in)])) (rf/dispatch [::pb/seek (js/Math.floor in)]))
:on-double-click (fn [e] (.stopPropagation e) :on-double-click (fn [e] (.stopPropagation e)
(when source (rf/dispatch [::pb/open-symbol source])))} (when source (rf/dispatch [::pb/open-symbol source])))}
label])) [:span.tl-cel-label label]
(if joined?
[:span.tl-junction
{:title "Left: trim left · center: roll cut · right: trim right"
:on-pointer-down
(fn [^js e]
(let [box (.getBoundingClientRect (.-currentTarget e))
x (/ (- (.-clientX e) (.-left box)) (max 1 (.-width box)))
left-path (nth (:select prev) 3)
right-path (nth select 3)]
(cond
(< x 0.34) (begin! e left-path :out (:select prev) nil)
(> x 0.66) (begin! e right-path :in select nil)
:else (begin! e right-path :roll select left-path))))}]
[:span.tl-edge.in {:title "Drag start"
:on-pointer-down #(begin! % (nth select 3) :in select nil)}])
[:span.tl-edge.out {:title "Drag endpoint · Shift-drag ripples later clips"
:on-pointer-down #(begin! % (nth select 3) :out select nil)}]]))
;; A dense channel has a value on every frame, so ticking each one is a solid ;; A dense channel has a value on every frame, so ticking each one is a solid
;; block that says less than the bar behind it already does. ;; block that says less than the bar behind it already does.
(when-not dense? (when-not dense?
@ -717,210 +655,5 @@
[:div.tl-empty "nothing in this symbol"]) [:div.tl-empty "nothing in this symbol"])
[:div.tl-playhead {:style {:left (at% frame frames)}}]]]]))) [:div.tl-playhead {:style {:left (at% frame frames)}}]]]])))
(defn cel-sheet
"The cel-sheet projection of one symbol: frames down, one column per lane.
It reuses `rows`, so its spans and selection addresses are exactly the ones
the timeline presents rather than a second interpretation of the document."
[clip sid frames]
(mapv (fn [{:keys [path label cels select]}]
{:id (peek path)
:path path
:select select
:label label
:cells (mapv (fn [f]
(let [cel (some (fn [{[in out] :span :as cel}]
(when (and (<= in f) (< f out)) cel))
cels)]
{:frame f :lane (peek path) :lane-select select :cel cel}))
(range frames))})
(filter :cels (rows clip sid #{}))))
(defn- cel-sheet-view []
(r/with-let [range-state (r/atom nil)
clipboard (atom nil)
gesture (r/atom nil)]
(let [clip @(rf/subscribe [::render/clip])
clip-id @(rf/subscribe [::render/clip-id])
sid @(rf/subscribe [::render/open])
frames (max 1 (or @(rf/subscribe [::render/frames]) 1))
frame @(rf/subscribe [::playback/frame])
selection @(rf/subscribe [::sub/selection])
target @(rf/subscribe [::sub/target])
columns (cel-sheet clip sid frames)
;; THE AIMED LANE, and there is always one while this view is up. The
;; sheet is a drawing-lane mode: every cell belongs to a lane and every
;; new drawing goes in one, so "nothing aimed" is a state it has nothing
;; to say in. Enforced HERE rather than in the event that switches view,
;; because the lane can also go stale under it — deleted, or the open
;; symbol changed — and this is the only place that notices.
aimed (when (some #(= (:id %) (:id target)) columns) (:id target))
_ (when (and (nil? aimed) (seq columns)) (rf/dispatch [::ui/aim-a-lane]))
active (when (and (= sid (:sid @range-state))
(= clip-id (:clip-id @range-state))) @range-state)
[ac af] (:anchor active)
[bc bf] (:focus active)
left (when active (min ac bc))
right (when active (max ac bc))
top (when active (min af bf))
bottom (when active (max af bf))
lanes (when (and active (< right (count columns)))
(mapv :id (subvec columns left (inc right))))
;; TWO DISPATCHES, BECAUSE THEY ARE TWO FACTS. Clicking a cell AIMS at
;; the column's lane — that is where the next drawing goes — and SELECTS
;; whatever is in the cell, which is what the inspector then shows. A
;; gap selects the lane itself, there being nothing in it to inspect.
choose! (fn [c f extend?]
(reset! range-state {:sid sid :clip-id clip-id
:anchor (if (and extend? active) (:anchor active) [c f])
:focus [c f]})
(rf/dispatch [::pb/seek f])
(rf/dispatch [::ui/aim-at (:select (nth columns c))])
(rf/dispatch [::ui/select (or (get-in columns [c :cells f :cel :select])
(:select (nth columns c)))]))
clear! #(when lanes
(rf/dispatch [::ui/sheet-paste sid lanes top
{:duration (inc (- bottom top))
:columns (mapv (constantly []) lanes)}]))
style {:grid-template-columns
(str "52px repeat(" (max 1 (count columns)) ", 140px)")}]
[:div.cel-sheet
{:style style :tab-index 0
:aria-label "Cel sheet. Drag to select; Shift extends selection. Copy and paste reuse drawings."
:on-pointer-move
(fn [e]
(when @gesture
(when-let [cell (some-> (.elementFromPoint js/document (.-clientX e) (.-clientY e))
(.closest "[data-cs-col]"))]
(let [c (js/Number (.getAttribute cell "data-cs-col"))
f (js/Number (.getAttribute cell "data-cs-frame"))]
(if (= :hold (:kind @gesture))
(swap! gesture assoc :end (inc f))
(swap! range-state assoc :focus [c f]))))))
:on-pointer-up
(fn [_]
(when (= :hold (:kind @gesture))
(rf/dispatch [::ui/sheet-hold sid (:id @gesture) (:end @gesture)]))
(reset! gesture nil))
:on-pointer-cancel #(reset! gesture nil)
:on-lost-pointer-capture #(reset! gesture nil)
:on-key-down
(fn [e]
(let [k (.toLowerCase (.-key e))
cmd? (or (.-metaKey e) (.-ctrlKey e))
arrow (get {"arrowup" [0 -1] "arrowdown" [0 1]
"arrowleft" [-1 0] "arrowright" [1 0]} k)]
(when (and (= "n" k) (not cmd?) aimed)
;; THE ONE THING THE DELETED BUTTON DID THAT DRAWING CANNOT SAY.
;; Drawing on an empty frame makes the drawing there, which covers
;; every case but one: wanting the NEXT drawing when the playhead is
;; not yet on an empty frame. `append-drawing` puts it past the end
;; of the lane and seeks to it, so draw-N-draw-N keeps its rhythm
;; without a trip to a menu. `lane-model.md` calls this one `N` too.
(.preventDefault e)
(.stopPropagation e)
(rf/dispatch [::ui/append-drawing]))
(when (and lanes (or arrow (#{"delete" "backspace" "escape"} k)
(and cmd? (#{"c" "x" "v"} k))))
(.preventDefault e)
(.stopPropagation e)
(cond
(= k "escape") (do (reset! gesture nil) (reset! range-state nil))
arrow (let [[dc df] arrow]
(choose! (max 0 (min (dec (count columns)) (+ bc dc)))
(max 0 (min (dec frames) (+ bf df))) (.-shiftKey e)))
(#{"delete" "backspace"} k) (clear!)
(#{"c" "x"} k)
(let [payload (lane/sheet-range clip sid lanes [top (inc bottom)])]
(if (:refused payload)
(rf/dispatch [::ui/sheet-paste sid lanes top payload])
(do (reset! clipboard {:clip-id clip-id :payload payload})
(when (= k "x") (clear!)))))
(= k "v")
(let [payload (when (= clip-id (:clip-id @clipboard)) (:payload @clipboard))
targets (mapv :id (take (count (:columns payload)) (drop left columns)))]
(rf/dispatch [::ui/sheet-paste sid targets top payload]))))))}
[:div.cs-help {:style {:grid-column "1 / -1"}}
(if (= :hold (:kind @gesture))
(str "Extend hold to frame " (dec (:end @gesture)) " · later cels move with it")
"Drag to select · Shift extends · ⌘/Ctrl C/X/V · Delete clears · drag a hold’s corner to resize")]
[:div.cs-head.cs-frame "frame"]
(doall (for [{:keys [path label select id]} columns]
^{:key (str "head-" path)}
;; A HEAD AIMS AND DOES NOT SELECT. Clicking a column's name says
;; "drawings go here"; it is not a claim about anything to inspect,
;; so the inspector keeps whatever cel it was showing.
[:button.cs-head {:class (str (when (= select selection) " selected")
(when (= id aimed) " aimed"))
:title (if (= id aimed)
(str label " · new drawings go here")
(str label " · aim new drawings here"))
:on-click #(rf/dispatch [::ui/aim-at select])}
;; No name tag here, unlike the stage's outline: the thing the
;; outline is around IS the name. A tag would repeat it.
label]))
(doall
(for [f (range frames)
item (cons {:frame-label? true}
(map-indexed #(assoc (get-in %2 [:cells f]) :column %1) columns))]
(if (:frame-label? item)
^{:key (str "frame-" f)}
[:button.cs-frame {:class (when (= f frame) "on")
:on-click #(rf/dispatch [::pb/seek f])} f]
(let [{:keys [id label select span]} (:cel item)
c (:column item)
selected? (and lanes (<= left c right) (<= top f bottom))
held? (and id (zero? (get-in clip [:symbols sid :nodes id :playback :speed] 1)))
handle? (and held? (= select selection) (= (inc f) (second span)))]
^{:key (str f "-" (:lane item))}
[:button.cs-cell
{:class (str (when (= f frame) " current")
(when selected? " selected")
(when (and (= c (:column @gesture)) (= :hold (:kind @gesture))
(<= (:start @gesture) f) (< f (:end @gesture))) " cs-preview"))
:data-cs-col c :data-cs-frame f
:title (if id (str label " · frame " f) (str "gap · frame " f))
:on-pointer-down
(fn [e]
(when (= 0 (.-button e))
(.preventDefault e)
(.focus (.-currentTarget e))
(.setPointerCapture (.-currentTarget e) (.-pointerId e))
(choose! c f (.-shiftKey e))
(reset! gesture {:kind :select})))
:on-click #(when (zero? (.-detail %)) (choose! c f (.-shiftKey %)))}
(or label "—")
(when handle?
[:span.cs-fill-handle
{:title "Drag to resize hold (ripples later cels)"
:on-pointer-down
(fn [e]
(when (= 0 (.-button e))
(.preventDefault e)
(.stopPropagation e)
(.setPointerCapture (.-currentTarget e) (.-pointerId e))
(reset! gesture {:kind :hold :id id :column c
:start (first span) :end (second span)})))}])]))))])))
(defn- no-lanes
"What the cel sheet has to say about a symbol with nothing to show frames of.
AN EMPTY GRID IS NOT AN ANSWER. The sheet is one column per lane, so a symbol
with no lane renders as a frame ruler and nothing beside it — which looks like
the view is broken rather than like the document is empty. The one thing
anybody can do from here is the one thing offered."
[]
[:div.cs-empty
[:p "This symbol has no drawing lanes."]
[:p.dim "The cel sheet shows one column per lane: frames down, drawings across."]
[:button {:on-click #(rf/dispatch [::ui/new-lane])} "make a drawing lane"]])
(defn view [] (defn view []
(if (= :cel-sheet @(rf/subscribe [::sub/time-view])) [timeline-view])
(let [clip @(rf/subscribe [::render/clip])
open @(rf/subscribe [::render/open])]
[:section.pane.time
[transport]
(if (seq (symbol/lanes (get-in clip [:symbols open :nodes])))
[cel-sheet-view]
[no-lanes])])
[timeline-view]))

View file

@ -43,36 +43,6 @@
:wave (assoc-in (drawing :wave 0 10) [:nodes :mark :channels [:xform :pos]] :wave (assoc-in (drawing :wave 0 10) [:nodes :mark :channels [:xform :pos]]
(ch/keyed {0 [0 0] 9 [900 0]} :linear))}})) (ch/keyed {0 [0 0] 9 [900 0]} :linear))}}))
(deftest sheet-copy-clips-without-resetting-source-or-local-keys
(let [doc (document)
payload (lane/sheet-range doc :main [:girl] [9 11])
copied (first (first (:columns payload)))
result (lane/paste-range doc :main [:girl] 1 payload random-uuid)
nodes (get-in (:clip result) [:symbols :main :nodes])
pasted (first (filter #(and (= :wave (node/source %))
(= [1 3] (node/placed-span %))) (vals nodes)))]
(is (= 2 (:duration payload)))
(is (= [1 3] (:span copied)))
(is (= (:playback copied) (:playback pasted)))
(is (= [1 3] (:span pasted)))
(is (= :wave (node/source pasted)))
(is (empty? (clip/problems (:clip result))))
(is (= [0 1] (node/placed-span (:a nodes))))
(is (some #(= [3 4] (node/placed-span %)) (vals nodes)))))
(deftest sheet-paste-preserves-gaps-and-refuses-overflow-and-incompatible-clocks
(let [doc (document)
gap (:clip (lane/blank doc :main :girl [1 3] {:id :remainder}))
payload (lane/sheet-range gap :main [:girl] [0 4])
result (lane/paste-range gap :main [:girl] 4 payload random-uuid)
cels (symbol/lane-cels (get-in (:clip result) [:symbols :main :nodes]) :girl)]
(is (empty? (clip/problems (:clip result))))
(is (not-any? #(let [[a b] (node/placed-span %)] (and (< a 7) (> b 5))) cels))
(is (:refused (lane/paste-range doc :main [:girl] 10 payload random-uuid)))
(is (:refused (lane/paste-range doc :main [] 0 payload random-uuid)))
(is (:refused (lane/sheet-range
(assoc-in doc [:symbols :main :nodes :girl :time] {:rate 2})
:main [:girl] [0 4])))))
(defn sample [doc fs] (defn sample [doc fs]
(let [r (clip/resolver doc :main nil pal/index-of nil)] (let [r (clip/resolver doc :main nil pal/index-of nil)]
@ -122,7 +92,46 @@
(doseq [delta [0 -4 0.5 js/NaN]] (doseq [delta [0 -4 0.5 js/NaN]]
(is (:refused (lane/extend-hold doc :main :a delta {})))) (is (:refused (lane/extend-hold doc :main :a delta {}))))
(is (:refused (lane/extend-hold doc :main :insert 1 {}))) (is (:refused (lane/extend-hold doc :main :insert 1 {})))
(is (:refused (lane/extend-hold doc :main :missing 1 {}))))) (is (:refused (lane/extend-hold doc :main :missing 1 {})))))
(deftest dragging-a-cel-edge-trims-neighbours-or-ripples-them
(let [doc (document)
plain (:clip (lane/resize-out doc :main :a 6 {}))
across (:clip (lane/resize-out doc :main :a 9 {}))
ripple (:clip (lane/resize-out doc :main :a 6 {:ripple? true
:extent :grow-symbol}))
shrink (:clip (lane/resize-out doc :main :a 2 {:ripple? true}))]
(is (= [[0 6] [6 8] [8 12]]
(mapv node/placed-span (symbol/lane-cels (get-in plain [:symbols :main :nodes]) :girl)))
"a normal grow eats the beginning of the adjacent cel")
(is (= [[0 9] [9 12]]
(mapv node/placed-span (symbol/lane-cels (get-in across [:symbols :main :nodes]) :girl)))
"a long grow removes wholly consumed cels and trims the survivor")
(is (= [[0 6] [6 10] [10 14]]
(mapv node/placed-span (symbol/lane-cels (get-in ripple [:symbols :main :nodes]) :girl)))
"shift-grow moves every later cel")
(is (= [[0 2] [2 6] [6 10]]
(mapv node/placed-span (symbol/lane-cels (get-in shrink [:symbols :main :nodes]) :girl)))
"shift-shrink pulls every later cel left")
(is (:refused (lane/resize-out doc :main :a 0 {})))
(is (:refused (lane/resize-out doc :main :a 2.5 {})))))
(deftest the-middle-of-a-cut-rolls-both-edges
(let [doc (document)
rolled (:clip (lane/roll doc :main :a :b 6))
right-only (:clip (lane/resize-in doc :main :b 6))
grown-left (:clip (lane/resize-in doc :main :b 2))]
(is (= [[0 6] [6 8] [8 12]]
(mapv node/placed-span (symbol/lane-cels (get-in rolled [:symbols :main :nodes]) :girl)))
"the shared cut moves without moving either clip")
(is (= [[0 4] [6 8] [8 12]]
(mapv node/placed-span (symbol/lane-cels (get-in right-only [:symbols :main :nodes]) :girl)))
"the right side of the junction trims only the right clip")
(is (= [[0 2] [2 8] [8 12]]
(mapv node/placed-span (symbol/lane-cels (get-in grown-left [:symbols :main :nodes]) :girl)))
"growing the right clip left trims the neighbour instead of overlapping")
(is (:refused (lane/roll doc :main :a :b 0)))
(is (:refused (lane/roll doc :main :a :insert 6)))))
(deftest a-gap-is-an-uncovered-interval (deftest a-gap-is-an-uncovered-interval
(let [doc (update-in (document) [:symbols :main :nodes] dissoc :b) (let [doc (update-in (document) [:symbols :main :nodes] dissoc :b)

View file

@ -222,6 +222,25 @@
:main [a-uuid :inner] 3)) :main [a-uuid :inner] 3))
"but through a loop one frame outside is many inside"))) "but through a loop one frame outside is many inside")))
(deftest linked-audio-follows-picture-moves-but-keeps-independent-edges
(let [c (-> (clip/blank)
(assoc-in [:symbols :drawing] {:id :drawing :frames 8 :nodes {}})
(clip/place-symbol nil :main :drawing 2 :picture nil)
(clip/place-sound :main {:sound "voice"} "voice" 8 1 2 :voice)
(assoc-in [:symbols :main :nodes :voice :linked-to] :picture))
moved (:clip (nest/slide c :main [:picture] 3))
audio-only (:clip (nest/slide c :main [:voice] 1))
trimmed (:clip (nest/resize-out c :main [:voice] -2 false))]
(is (= [5 13] (node/placed-span (get-in moved [:symbols :main :nodes :picture]))))
(is (= [5 13] (node/placed-span (get-in moved [:symbols :main :nodes :voice])))
"moving picture carries its linked audio")
(is (= [2 10] (node/placed-span (get-in audio-only [:symbols :main :nodes :picture]))))
(is (= [3 11] (node/placed-span (get-in audio-only [:symbols :main :nodes :voice])))
"moving audio itself does not carry picture")
(is (= [2 8] (node/placed-span (get-in trimmed [:symbols :main :nodes :voice])))
"an audio endpoint trims independently")
(is (= [2 10] (node/placed-span (get-in trimmed [:symbols :main :nodes :picture]))))))
(deftest restacking-is-one-write-to-z (deftest restacking-is-one-write-to-z
(let [shape (fn [z] {:kind :poly :z z :channels {}}) (let [shape (fn [z] {:kind :poly :z z :channels {}})
c (-> (clip/blank) c (-> (clip/blank)

View file

@ -161,6 +161,11 @@
(is (:refused (span/trim doc :main :b :middle 6))) (is (:refused (span/trim doc :main :b :middle 6)))
(is (re-find #"group" (:refused (span/trim doc :main :girl :out 6)))))) (is (re-find #"group" (:refused (span/trim doc :main :girl :out 6))))))
(deftest timeline-edge-resize-allows-an-ordinary-clip-to-grow
(let [after (:clip (span/resize-out (document) :main :badge 11))]
(is (= [2 11] (node/placed-span (get-in after [:symbols :main :nodes :badge]))))
(is (:refused (span/resize-out (document) :main :badge 2)))))
;; --------------------------------------------------------------------------- ;; ---------------------------------------------------------------------------
;; move ;; move

View file

@ -22,21 +22,6 @@
(is (= [0 6 12] (:keys lane))) (is (= [0 6 12] (:keys lane)))
(is (= 1 (count (filter :cels (timeline/rows doc :main #{[:girl]}))))))) (is (= 1 (count (filter :cels (timeline/rows doc :main #{[:girl]})))))))
(deftest the-cel-sheet-is-the-same-cels-with-the-axes-turned
(let [doc (fixture/document)
column (first (timeline/cel-sheet doc :main 12))
cells (:cells column)]
(is (= :girl (:id column)))
(is (= [:node :main :girl [:girl]] (:select column)))
(is (every? #(= (:select column) (:lane-select %)) cells))
(is (= [:a :b :insert] (mapv #(get-in cells [% :cel :id]) [0 4 8])))
(is (= [[:node :main :a [:a]]
[:node :main :b [:b]]
[:node :main :insert [:insert]]]
(mapv #(get-in cells [% :cel :select]) [0 4 8])))
(is (= (mapv :select (:cels (first (filter :cels (timeline/rows doc :main #{})))))
(mapv #(get-in cells [% :cel :select]) [0 4 8])))))
(deftest a-nested-selection-converts-the-open-playhead-to-its-owning-symbol (deftest a-nested-selection-converts-the-open-playhead-to-its-owning-symbol
(let [doc (assoc-in (fixture/document) [:symbols :outer] (let [doc (assoc-in (fixture/document) [:symbols :outer]
{:id :outer :frames 30 {:id :outer :frames 30
@ -52,7 +37,6 @@
(deftest polygon-landing-follows-the-target-not-the-selection (deftest polygon-landing-follows-the-target-not-the-selection
(let [doc (fixture/document) (let [doc (fixture/document)
db {:ui {:open :main db {:ui {:open :main
:time-view :timeline
:selection [:node :main :plate [:plate]] :selection [:node :main :plate [:plate]]
:target {:sid :main :id :insert :path [:insert]}} :target {:sid :main :id :insert :path [:insert]}}
:playback {:frame 5}} :playback {:frame 5}}
@ -62,10 +46,9 @@
"looking at another shape does not silently move the creation target") "looking at another shape does not silently move the creation target")
(is (false? (:lane? landing))))) (is (false? (:lane? landing)))))
(deftest cel-sheet-polygon-landing-is-decided-by-the-aimed-lane (deftest timeline-polygon-landing-is-decided-by-the-aimed-lane
(let [doc (fixture/document) (let [doc (fixture/document)
base {:ui {:open :main base {:ui {:open :main
:time-view :cel-sheet
:selection [:node :main :plate [:plate]] :selection [:node :main :plate [:plate]]
:target {:sid :main :id :girl :path [:girl]}} :target {:sid :main :id :girl :path [:girl]}}
:playback {:frame 5}} :playback {:frame 5}}
@ -84,22 +67,22 @@
(first (:path gap)))) (first (:path gap))))
(is (empty? (clip/problems (:clip gap)))))) (is (empty? (clip/problems (:clip gap))))))
(deftest cel-sheet-polygon-landing-refuses-without-an-aimed-lane (deftest polygon-landing-without-an-aimed-lane-uses-the-ordinary-target
(let [doc (fixture/document) (let [doc (fixture/document)
db {:ui {:open :main :time-view :cel-sheet db {:ui {:open :main
:target {:sid :main :id :plate :path [:plate]}} :target {:sid :main :id :plate :path [:plate]}}
:playback {:frame 5}} :playback {:frame 5}}
landing (ui/polygon-landing doc {} db)] landing (ui/polygon-landing doc {} db)]
(is (re-find #"drawing lane" (:refused landing))) (is (= doc (:clip landing)))
(is (true? (:lane? landing))) (is (= [] (:path landing)))
(is (nil? (:clip landing))))) (is (false? (:lane? landing)))))
(deftest beginning-a-polygon-materializes-a-missing-cel-sheet-drawing (deftest beginning-a-polygon-materializes-a-missing-timeline-drawing
(let [doc (fixture/document) (let [doc (fixture/document)
with-gap (:clip (lane/blank doc :main :girl [5 7] {:id :rest})) with-gap (:clip (lane/blank doc :main :girl [5 7] {:id :rest}))
id (store/install! {:clip with-gap :store {}} "polygon-start-test") id (store/install! {:clip with-gap :store {}} "polygon-start-test")
db {:clip/current id :paint/revision 0 db {:clip/current id :paint/revision 0
:ui {:open :main :time-view :cel-sheet :ui {:open :main
:target {:sid :main :id :girl :path [:girl]}} :target {:sid :main :id :girl :path [:girl]}}
:playback {:frame 6}} :playback {:frame 6}}
after (ui/beginning-polygon db) after (ui/beginning-polygon db)
@ -114,28 +97,19 @@
(is (= 6 (get-in saved [:symbols :main :nodes cel-id :time :at]))) (is (= 6 (get-in saved [:symbols :main :nodes cel-id :time :at])))
(is (empty? (clip/problems saved))))) (is (empty? (clip/problems saved)))))
(deftest beginning-a-polygon-materializes-a-lane-before-its-drawing (deftest beginning-a-polygon-does-not-invent-an-unselected-lane
(let [doc (clip/blank) (let [doc (clip/blank)
id (store/install! {:clip doc :store {}} "polygon-lane-start-test") id (store/install! {:clip doc :store {}} "polygon-lane-start-test")
db {:clip/current id :paint/revision 0 db {:clip/current id :paint/revision 0
:ui {:open :main :time-view :cel-sheet} :ui {:open :main}
:playback {:frame 6}} :playback {:frame 6}}
after (ui/beginning-polygon db) after (ui/beginning-polygon db)
saved (:clip (store/entry (:clip/current after))) saved (:clip (store/entry (:clip/current after)))
lanes (symbol/lanes (get-in saved [:symbols :main :nodes])) lanes (symbol/lanes (get-in saved [:symbols :main :nodes]))]
lane-id (:id (first lanes))
[_ sid cel-id path] (get-in after [:ui :selection])]
(is (= :polygon (get-in after [:ui :tool]))) (is (= :polygon (get-in after [:ui :tool])))
(is (= 1 (count lanes))) (is (empty? lanes))
(is (= {:sid :main :id lane-id :path [lane-id]} (is (nil? (get-in after [:ui :target])))
(get-in after [:ui :target]))) (is (nil? (get-in (store/entry (:clip/current after)) [:history :done])))
(is (= :main sid))
(is (= [cel-id] path))
(is (= lane-id (get-in saved [:symbols :main :nodes cel-id :parent])))
(is (= 6 (get-in saved [:symbols :main :nodes cel-id :time :at])))
(is (= 1 (count (get-in (store/entry (:clip/current after))
[:history :done])))
"the lane and drawing are one start-polygon undo step")
(is (empty? (clip/problems saved))))) (is (empty? (clip/problems saved)))))
(deftest sequence-commands-use-isolated-history-transactions (deftest sequence-commands-use-isolated-history-transactions

View file

@ -117,265 +117,98 @@ try {
// not that implementation detail, when asserting exact undo restoration. // not that implementation detail, when asserting exact undo restoration.
const shot = async () => JSON.parse(JSON.stringify(await evaluate('laneSnapshot()'), const shot = async () => JSON.parse(JSON.stringify(await evaluate('laneSnapshot()'),
(_key, value) => value?.uuid ?? value)); (_key, value) => value?.uuid ?? value));
const instances = s => Object.values(s.clip.symbols.main.nodes).filter(n => n.kind === 'instance') const instances = s => Object.values(s.clip.symbols.main.nodes)
.filter(n => n.kind === 'instance')
.sort((a, b) => a.time.at - b.time.at); .sort((a, b) => a.time.at - b.time.at);
await click('lane'); const placed = s => instances(s).map(n => [
await click('new drawing'); n.time.at + n.span[0] / (n.time.rate ?? 1),
await click('hold +'); n.time.at + n.span[1] / (n.time.rate ?? 1),
await click('hold +'); ]);
await click('hold +');
await click('new drawing');
let s = await shot();
assert.deepEqual(instances(s).map(n => [n.time.at, n.span[1]]), [[0, 4], [4, 1]]);
assert.equal(await evaluate('document.querySelectorAll(".tl-cel").length'), 2);
assert.equal(await evaluate('document.querySelectorAll(".tl-label:not(.tl-corner)").length'), 1);
assert.equal(await evaluate(`cljs.core.get_in(cljs.core.deref(re_frame.db.app_db),
cljs.core.vector(cljs.core.keyword('playback'), cljs.core.keyword('frame')))`), 4,
'new drawing seeks to its cel');
// Shorten this test shot to the occupied extent, purely in memory.
await evaluate(`(() => {
const k = cljs.core.keyword, db = cljs.core.deref(re_frame.db.app_db);
arthur.footage.store.edit_clip_BANG_(cljs.core.get(db, k('clip/current')),
clip => cljs.core.assoc_in(clip, cljs.core.vector(k('symbols'), k('main'), k('frames')), 5));
document.querySelector('.tl-cel').click();
})()`);
await sleep(200);
const before = await shot();
await click('hold +');
s = await shot();
assert.deepEqual(s.clip, before.clip, 'refused overflow makes no document change');
assert.equal(s.history.done.length, before.history.done.length);
await click('extend shot and apply');
s = await shot();
assert.equal(s.clip.symbols.main.frames, 6);
assert.deepEqual(instances(s).map(n => [n.time.at, n.span[1]]), [[0, 5], [5, 1]]);
assert.equal(s.history.done.length, before.history.done.length + 1);
await evaluate(`document.dispatchEvent(new KeyboardEvent('keydown', {key:'z', ctrlKey:true, bubbles:true}))`);
await sleep(250);
assert.deepEqual((await shot()).clip, before.clip, 'one undo restores cel, ripple, and shot length');
// Sharing: one drawing exposed twice, then one cel decoupled. Room is
// made first so these assertions are about content and not about overflow.
await evaluate(`(() => {
const k = cljs.core.keyword, db = cljs.core.deref(re_frame.db.app_db);
arthur.footage.store.edit_clip_BANG_(cljs.core.get(db, k('clip/current')),
clip => cljs.core.assoc_in(clip, cljs.core.vector(k('symbols'), k('main'), k('frames')), 20));
document.querySelector('.tl-cel').click();
})()`);
await sleep(200);
const enabled = async label => {
const where = await reveal(label);
const yes = await evaluate(`(() => {
const b = [...document.querySelectorAll('${where}')].find(${named(label)});
return !!b && !b.disabled;
})()`);
await shut();
return yes;
};
assert.equal(await enabled('make unique'), false, 'nothing to decouple from yet');
await click('reuse');
s = await shot();
let cels = instances(s);
assert.equal(cels.length, 3);
assert.equal(cels[2].source.symbol, cels[0].source.symbol, 'reuse exposes the same drawing');
assert.equal(await evaluate('document.querySelectorAll(".tl-cel").length'), 3);
assert.equal(await evaluate('document.querySelectorAll(".tl-label:not(.tl-corner)").length'), 1,
'three cels, still one row');
assert.equal(await enabled('make unique'), true);
await click('make unique');
s = await shot();
cels = instances(s);
assert.notEqual(cels[2].source.symbol, cels[0].source.symbol, 'that cel has its own drawing');
assert.equal(await enabled('make unique'), false, 'and is not shared any more');
await click('duplicate');
s = await shot();
cels = instances(s);
assert.equal(cels.length, 4);
assert.equal(new Set(cels.map(n => n.source.symbol)).size, 4,
'four cels of four drawings: nothing is shared once every copy is made');
assert.equal(s.history.done.length, before.history.done.length + 3, 'three more commands, three more steps');
// A drawing into the middle of a hold: split, then insert. Both act at the
// playhead, and neither guesses what the other one is for.
const placed = s => instances(s)
.map(n => [n.time.at + n.span[0] / (n.time.rate ?? 1), n.time.at + n.span[1] / (n.time.rate ?? 1)])
.sort((a, b) => a[0] - b[0]);
assert.deepEqual(placed(s), [[0, 4], [4, 5], [5, 6], [6, 7]]);
await evaluate(`document.querySelector('.tl-cel').click()`);
await sleep(200);
assert.equal(await enabled('split'), false, 'the start of a cel is not inside it');
await click('+1');
await click('+1');
assert.equal(await enabled('split'), true);
await click('split');
s = await shot();
assert.deepEqual(placed(s), [[0, 2], [2, 4], [4, 5], [5, 6], [6, 7]],
'one cel became two, over the frames it had');
await click('insert');
s = await shot();
assert.deepEqual(placed(s), [[0, 2], [2, 3], [3, 5], [5, 6], [6, 7], [7, 8]],
'the new drawing took frame 2 and everything from there rippled later');
assert.equal(await evaluate('document.querySelectorAll(".tl-cel").length'), 6);
assert.equal(await evaluate('document.querySelectorAll(".tl-label:not(.tl-corner)").length'), 1,
'six cels, still one row');
assert.equal(s.history.done.length, before.history.done.length + 5);
// Trim, move and blank: three gestures that move nothing but their own
// cel, and a shot whose length does not follow what is in it.
await evaluate(`[...document.querySelectorAll('.tl-cel')][2].click()`);
await sleep(200);
assert.deepEqual(placed(await shot()).slice(2, 4), [[3, 5], [5, 6]]);
await click('+1');
assert.equal(await enabled('trim out'), true, 'the playhead is inside it');
await click('trim out');
s = await shot();
assert.deepEqual(placed(s), [[0, 2], [2, 3], [3, 4], [5, 6], [6, 7], [7, 8]],
'it ends at the playhead and every other cel stayed');
assert.equal(await enabled('move here'), true);
await click('move here');
s = await shot();
assert.deepEqual(placed(s), [[0, 2], [2, 3], [4, 5], [5, 6], [6, 7], [7, 8]],
'and moves to the playhead, into the gap it just made');
await click('blank');
s = await shot();
assert.deepEqual(placed(s), [[0, 2], [2, 3], [5, 6], [6, 7], [7, 8]],
'blanked: a gap where it was, and nothing closed it');
assert.equal(s.clip.symbols.main.frames, 20, 'the shot is as long as it was authored');
assert.equal(s.history.done.length, before.history.done.length + 8);
// The same cels with the axes turned. Selecting a sheet cell feeds the same
// action strip and therefore the same domain command and undo transaction.
await click('cel sheet');
assert.equal(await evaluate('document.querySelectorAll(".cs-head:not(.cs-frame)").length'), 1,
'one lane is one cel-sheet column');
assert.equal(await evaluate('document.querySelectorAll(".cs-cell").length'), 20,
'one cell per authored frame');
await evaluate(`document.querySelector('.cs-cell').click()`);
await sleep(180);
await click('hold +');
s = await shot();
assert.deepEqual(placed(s), [[0, 3], [3, 4], [6, 7], [7, 8], [8, 9]],
'a command selected in the sheet has the timeline command semantics');
assert.equal(s.history.done.length, before.history.done.length + 9);
// Correction authoring is reachable from the same selection. The range is
// in this cel's own frames and Apply is one isolated history transaction.
assert(await evaluate(`(() => {
const section = [...document.querySelectorAll('.section')]
.find(s => s.querySelector('h2')?.textContent.trim() === 'corrections');
const label = [...section.querySelectorAll('label')]
.find(l => l.textContent.trim().startsWith('offset (degrees)'));
const input = label?.querySelector('input');
if (!input) return false;
const set = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value').set;
set.call(input, '15');
input.dispatchEvent(new Event('input', {bubbles: true}));
input.dispatchEvent(new Event('change', {bubbles: true}));
return true;
})()`), 'rotation correction value is editable');
await sleep(100);
await click('apply correction');
s = await shot();
const selectedCel = instances(s).find(n => n.time.at === 0);
const rot = Object.values(selectedCel.channels).find(c => c.over?.length);
assert.equal(rot.over.length, 1);
assert(Math.abs(rot.over[0].values.value - Math.PI / 12) < 1e-9,
'the inspector converts the authored degree offset to radians');
assert.equal(s.history.done.length, before.history.done.length + 10);
// A gap targets its column's lane. This catches the stale-selection bug that
// only appears once a sheet has more than one lane.
await click('timeline');
assert.equal(await evaluate('document.querySelectorAll(".tl-cel").length'), 5);
await click('lane');
await click('cel sheet');
assert.equal(await evaluate('document.querySelectorAll(".cs-head:not(.cs-frame)").length'), 2);
await evaluate(`([...document.querySelectorAll('.cs-cell')].slice(0, 2)
.find(c => c.textContent.trim() !== '—')).click()`);
await sleep(100);
await evaluate(`([...document.querySelectorAll('.cs-cell')].slice(0, 2)
.find(c => c.textContent.trim() === '—')).click()`);
await sleep(100);
await click('overwrite');
s = await shot();
assert(Object.values(s.clip.symbols.main.nodes)
.some(n => n.kind === 'instance' && n.parent !== 'girl' && n.time.at === 0),
'clicking a gap selects that column before overwrite');
assert.equal(s.history.done.length, before.history.done.length + 12,
'correction, lane creation, and overwrite are separate undo steps');
// Real pointer capture and keyboard events exercise the spreadsheet surface.
const cellAt = async (c, f) => evaluate(`(() => {
const el = document.querySelector('[data-cs-col="${c}"][data-cs-frame="${f}"]');
el.scrollIntoView({block: 'center'});
const r = el.getBoundingClientRect();
return {x: r.x + r.width / 2, y: r.y + r.height / 2};
})()`);
const mouse = (type, p) => send('Input.dispatchMouseEvent', {
type, ...p, button: 'left', buttons: type === 'mouseReleased' ? 0 : 1, clickCount: 1,
});
const key = async (key, extra = {}) => { const key = async (key, extra = {}) => {
await send('Input.dispatchKeyEvent', {type: 'keyDown', key, ...extra}); await send('Input.dispatchKeyEvent', {type: 'keyDown', key, ...extra});
await send('Input.dispatchKeyEvent', {type: 'keyUp', key, ...extra}); await send('Input.dispatchKeyEvent', {type: 'keyUp', key, ...extra});
await sleep(120); await sleep(180);
}; };
const start = await cellAt(0, 0); const undo = () => key('z', {modifiers: 2});
await mouse('mousePressed', start); const drag = async (selector, df, {zone = 0.5, shift = false} = {}) => {
await mouse('mouseMoved', await cellAt(1, 2)); const points = await evaluate(`(() => {
await mouse('mouseReleased', await cellAt(1, 2)); const handle = document.querySelector(${JSON.stringify(selector)});
await sleep(150); if (!handle) return null;
assert.equal(await evaluate('document.querySelectorAll(".cs-cell.selected").length'), 6); const track = handle.closest('.tl-track');
await key('c', {modifiers: 2}); const h = handle.getBoundingClientRect();
const dest = await cellAt(0, 10); const t = track.getBoundingClientRect();
await mouse('mousePressed', dest); const frames = Number(document.querySelector('.at-frame').textContent.split('/')[1]);
await mouse('mouseReleased', dest); const x = h.left + h.width * ${zone};
await sleep(120); const y = h.top + h.height / 2;
const beforePaste = await shot(); return {x, y, end: x + t.width * ${df} / frames};
await key('v', {modifiers: 2}); })()`);
const pasted = await shot(); assert(points, `drag handle exists: ${selector}`);
assert.equal(pasted.history.done.length, beforePaste.history.done.length + 1, const modifiers = shift ? 8 : 0;
'multi-lane paste is one undo step'); await send('Input.dispatchMouseEvent', {
assert.deepEqual(Object.keys(pasted.clip.symbols), Object.keys(beforePaste.clip.symbols), type: 'mousePressed', x: points.x, y: points.y,
'copy reuses the drawings'); button: 'left', buttons: 1, clickCount: 1, modifiers,
await key('z', {modifiers: 2}); });
assert.deepEqual((await shot()).clip, beforePaste.clip, 'undo restores the entire rectangle'); await sleep(100);
await key('ArrowDown', {modifiers: 8}); await send('Input.dispatchMouseEvent', {
assert.equal(await evaluate('document.querySelectorAll(".cs-cell.selected").length'), 2); type: 'mouseMoved', x: points.end, y: points.y,
const first = await cellAt(0, 0); button: 'left', buttons: 1, modifiers,
await mouse('mousePressed', first); });
await mouse('mouseReleased', first); await sleep(100);
await sleep(150); await send('Input.dispatchMouseEvent', {
const handle = await evaluate(`(() => { type: 'mouseReleased', x: points.end, y: points.y,
const r = document.querySelector('.cs-fill-handle').getBoundingClientRect(); button: 'left', buttons: 0, clickCount: 1, modifiers,
return {x: r.x + r.width / 2, y: r.y + r.height / 2}; });
})()`); await sleep(250);
const beforeHold = await shot(); };
await mouse('mousePressed', handle);
await mouse('mouseMoved', await cellAt(0, 4)); await click('lane');
await mouse('mouseReleased', await cellAt(0, 4)); assert.equal(await evaluate('[...document.querySelectorAll(".timing-controls > button")].every(b => b.disabled)'), true,
await sleep(150); 'timing buttons are disabled when the selected row is a lane, not a symbol clip');
const afterHold = await shot(); await click('inside');
assert.equal(afterHold.history.done.length, beforeHold.history.done.length + 1, let s = await shot();
'a hold drag commits once'); assert.deepEqual(placed(s), [[0, 1]], 'new inside an aimed lane is a one-frame symbol');
assert.equal(instances(afterHold).filter(n => { assert.equal(await evaluate(`document.querySelectorAll('.cel-sheet, [aria-label="time view"]').length`), 0,
const old = instances(beforeHold).find(o => o.id === n.id); 'there is one temporal interface');
return old && n.span[1] !== old.span[1] && n.time.at + n.span[1] === 5; assert.equal(await evaluate('document.querySelectorAll(".timing-controls > button").length'), 3,
}).length, 1, 'the dragged hold ends at the previewed boundary'); 'timing operations are direct buttons');
await key('z', {modifiers: 2});
assert.deepEqual((await shot()).clip, beforeHold.clip); await drag('.tl-cel .tl-edge.out', 3);
await key('Delete'); s = await shot();
const cleared = await shot(); assert.deepEqual(placed(s), [[0, 4]], 'a right edge directly changes the endpoint');
assert.equal(cleared.history.done.length, beforeHold.history.done.length + 1,
'Delete clears selected frames in one step, without the global node-delete handler'); for (let i = 0; i < 4; i++) await click('+1');
assert.equal(cleared.clip.symbols.main.frames, beforeHold.clip.symbols.main.frames); await click('inside');
await key('z', {modifiers: 2}); await drag('.tl-cel:nth-of-type(2) .tl-edge.out', 2);
assert.deepEqual((await shot()).clip, beforeHold.clip); for (let i = 0; i < 3; i++) await click('+1');
await key('x', {modifiers: 2}); await click('inside');
assert.equal((await shot()).history.done.length, beforeHold.history.done.length + 1); s = await shot();
await key('z', {modifiers: 2}); assert.deepEqual(placed(s), [[0, 4], [4, 7], [7, 8]]);
assert.deepEqual((await shot()).clip, beforeHold.clip, 'cut is independently undoable');
await drag('.tl-cel:nth-of-type(2) .tl-junction', 1, {zone: 0.5});
assert.deepEqual(placed(await shot()), [[0, 5], [5, 7], [7, 8]],
'the middle of a junction rolls both edges');
await undo();
await drag('.tl-cel:nth-of-type(2) .tl-junction', 1, {zone: 0.9});
assert.deepEqual(placed(await shot()), [[0, 4], [5, 7], [7, 8]],
'the right side trims only the right clip');
await undo();
await drag('.tl-cel:nth-of-type(2) .tl-junction', -1, {zone: 0.1});
assert.deepEqual(placed(await shot()), [[0, 3], [4, 7], [7, 8]],
'the left side trims only the left clip');
await undo();
await drag('.tl-cel:nth-of-type(2) .tl-edge.out', 2, {shift: true});
assert.deepEqual(placed(await shot()), [[0, 4], [4, 9], [9, 10]],
'Shift-edge ripples every later clip on the lane');
await undo();
await drag('.tl-cel:first-of-type .tl-edge.out', 2);
assert.deepEqual(placed(await shot()), [[0, 6], [6, 7], [7, 8]],
'ordinary growth trims adjacent spans and never overlaps');
assert.equal(errors.length, 0, JSON.stringify(errors)); assert.equal(errors.length, 0, JSON.stringify(errors));
console.log('PASS: lane commands and corrections agree from timeline and cel sheet; no server writes'); console.log('PASS: one timeline creates, trims, rolls, and ripples drawing-lane symbols');
} finally { } finally {
if (ws?.readyState === WebSocket.OPEN) { if (ws?.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ id: 999999, method: 'Browser.close' })); ws.send(JSON.stringify({ id: 999999, method: 'Browser.close' }));

View file

@ -228,8 +228,7 @@ input[type="range"] { width: 100%; accent-color: var(--sel); }
/* Buttons that are one control: a transport, a stepper, a mode picker. They /* Buttons that are one control: a transport, a stepper, a mode picker. They
share their borders, so the group reads as a single object with parts rather share their borders, so the group reads as a single object with parts rather
than as several things that happen to be adjacent — which is the whole claim than as several things that happen to be adjacent — which is the whole claim
a segmented control makes, and the reason `timeline` and `cel sheet` are one a segmented control makes. `.seg` is `.group` with that meaning; both are drawn
of these. `.seg` is `.group` with that meaning; they are drawn the same
because the difference is what the buttons do, not how they look. because the difference is what the buttons do, not how they look.
The negative margin collapses the doubled border between two buttons into The negative margin collapses the doubled border between two buttons into
@ -252,12 +251,6 @@ button.ico > svg { display: block; width: 11px; height: 11px; }
/* Play is the one control in the strip you aim at without looking. */ /* Play is the one control in the strip you aim at without looking. */
button.ico-play { padding-left: 8px; padding-right: 8px; } button.ico-play { padding-left: 8px; padding-right: 8px; }
/* `hold −` / `hold +`: one label over two steppers, because the word is shared
and repeating it in both buttons was most of their width. */
.stepper { display: flex; align-items: center; gap: 4px; }
.stepper-label { color: var(--dim); }
.stepper button { padding: 1px 6px; }
/* A number that changes every frame. Tabular figures stop it twitching, and /* A number that changes every frame. Tabular figures stop it twitching, and
stop the controls after it being nudged about as the count passes 9 and 99. */ stop the controls after it being nudged about as the count passes 9 and 99. */
.readout { font-variant-numeric: tabular-nums; } .readout { font-variant-numeric: tabular-nums; }
@ -1003,6 +996,7 @@ button.share-button:hover, button.share-button.on { filter: brightness(1.1); }
} }
.tl-label.on { background: var(--sel-bg); } .tl-label.on { background: var(--sel-bg); }
.tl-label.aimed { box-shadow: inset 0 0 0 2px var(--aim); }
.tl-label:hover:not(.on) { background: #fff; } .tl-label:hover:not(.on) { background: #fff; }
.tl-label .name { min-width: 0; overflow: hidden; text-overflow: ellipsis; } .tl-label .name { min-width: 0; overflow: hidden; text-overflow: ellipsis; }
.tl-label .kind { color: var(--dim); } .tl-label .kind { color: var(--dim); }
@ -1099,6 +1093,29 @@ button.share-button:hover, button.share-button.on { filter: brightness(1.1); }
/* A node's bar slides it along its symbol's time. */ /* A node's bar slides it along its symbol's time. */
.tl-span.movable { cursor: ew-resize; touch-action: none; } .tl-span.movable { cursor: ew-resize; touch-action: none; }
.tl-span.movable.sliding { border-color: var(--sel); } .tl-span.movable.sliding { border-color: var(--sel); }
.tl-edge {
position: absolute;
top: -1px;
bottom: -1px;
width: 7px;
cursor: e-resize;
touch-action: none;
z-index: 2;
}
.tl-edge.out { right: -3px; }
.tl-edge.in { left: -3px; }
.tl-junction {
position: absolute;
top: -1px;
left: 0;
bottom: -1px;
width: 12px;
transform: translateX(-50%);
cursor: col-resize;
touch-action: none;
z-index: 3;
}
.tl-cel-label { display: block; overflow: hidden; text-overflow: ellipsis; pointer-events: none; }
.tl-span.ghost { .tl-span.ghost {
background: transparent; background: transparent;
border: 1px dashed var(--sel); border: 1px dashed var(--sel);
@ -1143,79 +1160,6 @@ button.share-button:hover, button.share-button.on { filter: brightness(1.1); }
.tl-empty { padding: 9px; color: var(--dim); } .tl-empty { padding: 9px; color: var(--dim); }
/* The second temporal view. Its cells carry the same selection addresses as
the cel blocks above; only the axes change. */
.cel-sheet {
flex: 1;
min-height: 0;
overflow: auto;
display: grid;
align-content: start;
background: var(--line);
gap: 1px;
}
.cs-head,
.cs-frame,
.cs-cell {
min-width: 0;
height: 24px;
border: 0;
border-radius: 0;
padding: 0 6px;
background: #fff;
color: var(--fg);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.cs-head {
position: sticky;
top: 0;
z-index: 2;
display: flex;
align-items: center;
background: var(--chrome);
font-weight: 600;
cursor: pointer;
}
.cs-head.cs-frame { z-index: 3; }
.cs-frame { position: sticky; left: 0; z-index: 1; color: var(--dim); text-align: right; }
.cs-frame.on, .cs-cell.current { box-shadow: inset 3px 0 0 var(--playhead); }
.cs-cell { text-align: left; cursor: pointer; }
.cs-cell:hover { background: var(--sel-bg); }
.cs-cell.selected { background: var(--sel-bg); color: var(--sel); font-weight: 600; }
.cs-head.selected { background: var(--sel-bg); color: var(--sel); }
/* AIMED COMPOSES WITH SELECTED rather than replacing it: the ring is the aim,
the tint is the selection, and a row that is both wears both. No name tag in
the chrome — the thing the ring is around is already the name. */
.tl-label.aimed,
.cs-head.aimed { box-shadow: inset 0 0 0 2px var(--aim); }
.cs-head.aimed { color: var(--aim); }
.cs-empty {
display: grid;
justify-items: center;
align-content: center;
gap: 6px;
padding: 28px 16px;
background: var(--pane);
text-align: center;
}
.cs-empty p { margin: 0; }
.cs-empty .dim { color: var(--dim); font-size: 11px; }
.cs-empty button { margin-top: 4px; }
.cel-sheet { user-select: none; touch-action: none; }
.cs-cell { position: relative; }
.cs-cell:focus-visible { outline: 2px solid var(--sel); outline-offset: -2px; }
.cs-help { padding: 6px 10px; color: var(--dim); font-size: 11px;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.cs-fill-handle { position: absolute; right: 0; bottom: 0; width: 9px; height: 9px;
background: var(--sel); border: 1px solid white; cursor: ns-resize; z-index: 2; }
.cs-preview { outline: 1px dashed var(--sel); outline-offset: -1px; }
/* -------------------------------------------------------------------------- /* --------------------------------------------------------------------------
the video -> symbol dialog */ the video -> symbol dialog */