Unify cel editing in the timeline
This commit is contained in:
parent
4ddd6a8d1d
commit
26ada03591
18 changed files with 534 additions and 922 deletions
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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).
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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).
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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"}
|
||||||
|
|
|
||||||
|
|
@ -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]
|
|
||||||
(fn [n]
|
|
||||||
(if (= :map (get-in n [:time :mode]))
|
(if (= :map (get-in n [:time :mode]))
|
||||||
(update-in n [:time :at] (fnil + 0) d)
|
(update-in n [:time :at] (fnil + 0) d)
|
||||||
(assoc n :time {:mode :map :at d :rate 1}))))}))))
|
(assoc n :time {:mode :map :at d :rate 1})))
|
||||||
|
moved (assoc nodes id (shift (get nodes id)))
|
||||||
|
;; 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
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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."
|
||||||
|
|
|
||||||
|
|
@ -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 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
|
(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))
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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])))
|
||||||
|
|
|
||||||
|
|
@ -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]))
|
|
||||||
|
|
|
||||||
|
|
@ -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)]
|
||||||
|
|
@ -124,6 +94,45 @@
|
||||||
(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)
|
||||||
at (sample doc [3 4 7 8])]
|
at (sample doc [3 4 7 8])]
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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.equal(pasted.history.done.length, beforePaste.history.done.length + 1,
|
|
||||||
'multi-lane paste is one undo step');
|
|
||||||
assert.deepEqual(Object.keys(pasted.clip.symbols), Object.keys(beforePaste.clip.symbols),
|
|
||||||
'copy reuses the drawings');
|
|
||||||
await key('z', {modifiers: 2});
|
|
||||||
assert.deepEqual((await shot()).clip, beforePaste.clip, 'undo restores the entire rectangle');
|
|
||||||
await key('ArrowDown', {modifiers: 8});
|
|
||||||
assert.equal(await evaluate('document.querySelectorAll(".cs-cell.selected").length'), 2);
|
|
||||||
const first = await cellAt(0, 0);
|
|
||||||
await mouse('mousePressed', first);
|
|
||||||
await mouse('mouseReleased', first);
|
|
||||||
await sleep(150);
|
|
||||||
const handle = await evaluate(`(() => {
|
|
||||||
const r = document.querySelector('.cs-fill-handle').getBoundingClientRect();
|
|
||||||
return {x: r.x + r.width / 2, y: r.y + r.height / 2};
|
|
||||||
})()`);
|
})()`);
|
||||||
const beforeHold = await shot();
|
assert(points, `drag handle exists: ${selector}`);
|
||||||
await mouse('mousePressed', handle);
|
const modifiers = shift ? 8 : 0;
|
||||||
await mouse('mouseMoved', await cellAt(0, 4));
|
await send('Input.dispatchMouseEvent', {
|
||||||
await mouse('mouseReleased', await cellAt(0, 4));
|
type: 'mousePressed', x: points.x, y: points.y,
|
||||||
await sleep(150);
|
button: 'left', buttons: 1, clickCount: 1, modifiers,
|
||||||
const afterHold = await shot();
|
});
|
||||||
assert.equal(afterHold.history.done.length, beforeHold.history.done.length + 1,
|
await sleep(100);
|
||||||
'a hold drag commits once');
|
await send('Input.dispatchMouseEvent', {
|
||||||
assert.equal(instances(afterHold).filter(n => {
|
type: 'mouseMoved', x: points.end, y: points.y,
|
||||||
const old = instances(beforeHold).find(o => o.id === n.id);
|
button: 'left', buttons: 1, modifiers,
|
||||||
return old && n.span[1] !== old.span[1] && n.time.at + n.span[1] === 5;
|
});
|
||||||
}).length, 1, 'the dragged hold ends at the previewed boundary');
|
await sleep(100);
|
||||||
await key('z', {modifiers: 2});
|
await send('Input.dispatchMouseEvent', {
|
||||||
assert.deepEqual((await shot()).clip, beforeHold.clip);
|
type: 'mouseReleased', x: points.end, y: points.y,
|
||||||
await key('Delete');
|
button: 'left', buttons: 0, clickCount: 1, modifiers,
|
||||||
const cleared = await shot();
|
});
|
||||||
assert.equal(cleared.history.done.length, beforeHold.history.done.length + 1,
|
await sleep(250);
|
||||||
'Delete clears selected frames in one step, without the global node-delete handler');
|
};
|
||||||
assert.equal(cleared.clip.symbols.main.frames, beforeHold.clip.symbols.main.frames);
|
|
||||||
await key('z', {modifiers: 2});
|
await click('lane');
|
||||||
assert.deepEqual((await shot()).clip, beforeHold.clip);
|
assert.equal(await evaluate('[...document.querySelectorAll(".timing-controls > button")].every(b => b.disabled)'), true,
|
||||||
await key('x', {modifiers: 2});
|
'timing buttons are disabled when the selected row is a lane, not a symbol clip');
|
||||||
assert.equal((await shot()).history.done.length, beforeHold.history.done.length + 1);
|
await click('inside');
|
||||||
await key('z', {modifiers: 2});
|
let s = await shot();
|
||||||
assert.deepEqual((await shot()).clip, beforeHold.clip, 'cut is independently undoable');
|
assert.deepEqual(placed(s), [[0, 1]], 'new inside an aimed lane is a one-frame symbol');
|
||||||
|
assert.equal(await evaluate(`document.querySelectorAll('.cel-sheet, [aria-label="time view"]').length`), 0,
|
||||||
|
'there is one temporal interface');
|
||||||
|
assert.equal(await evaluate('document.querySelectorAll(".timing-controls > button").length'), 3,
|
||||||
|
'timing operations are direct buttons');
|
||||||
|
|
||||||
|
await drag('.tl-cel .tl-edge.out', 3);
|
||||||
|
s = await shot();
|
||||||
|
assert.deepEqual(placed(s), [[0, 4]], 'a right edge directly changes the endpoint');
|
||||||
|
|
||||||
|
for (let i = 0; i < 4; i++) await click('+1');
|
||||||
|
await click('inside');
|
||||||
|
await drag('.tl-cel:nth-of-type(2) .tl-edge.out', 2);
|
||||||
|
for (let i = 0; i < 3; i++) await click('+1');
|
||||||
|
await click('inside');
|
||||||
|
s = await shot();
|
||||||
|
assert.deepEqual(placed(s), [[0, 4], [4, 7], [7, 8]]);
|
||||||
|
|
||||||
|
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' }));
|
||||||
|
|
|
||||||
|
|
@ -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 */
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue