spreadsheet ui
This commit is contained in:
parent
7e34d0c704
commit
e369ad9462
9 changed files with 355 additions and 60 deletions
|
|
@ -88,6 +88,21 @@ decision, not a cleanup.
|
|||
|
||||
## 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
|
||||
[Correction authoring](correction-authoring-plan.md).
|
||||
|
||||
|
|
|
|||
|
|
@ -246,6 +246,58 @@
|
|||
nodes members)]
|
||||
(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} (lane-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
|
||||
(edged :in (max a lo))
|
||||
(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 (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]
|
||||
(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"}
|
||||
|
|
|
|||
|
|
@ -283,6 +283,27 @@
|
|||
{: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
|
||||
::toggle-row
|
||||
(fn [db [_ path]]
|
||||
|
|
|
|||
|
|
@ -25,7 +25,8 @@
|
|||
oval's bbox and zooms until the face is 80% of the raster height, so every
|
||||
stored vertex carries a cropping decision made once, at analysis time, from one
|
||||
frame's landmarks. Here the geometry stays in the node's own local space and the
|
||||
framing is `[:xform :*]` on an authored `:face` node, which the stage clips.
|
||||
framing is `[:xform :*]` on each face's own authored `:place` node, which the
|
||||
stage clips.
|
||||
Project dimensions are therefore independent of the footage — see
|
||||
`face-placement`, and \"What space geometry is in\" in docs/animation-model.md.
|
||||
|
||||
|
|
@ -396,7 +397,8 @@
|
|||
The synthetic take uses the reference rigid configuration for its default.
|
||||
Real footage can request `:fit-motion?`: its default fits the observed mouth,
|
||||
eyes and brows in the stage across the shot.
|
||||
Both are ordinary editable transforms on :face, never baked into the geometry.
|
||||
Both are ordinary editable transforms on the face's own `:place`, never baked
|
||||
into the geometry.
|
||||
The face oval is not measured, because its only consumers in the prototype were
|
||||
the old baked framing transform and the placeholder plate outline.
|
||||
|
||||
|
|
@ -419,8 +421,18 @@
|
|||
leaves the jaw and the mouth on the stage. Whatever hangs off is clipped, which
|
||||
is not a feature to add: every fill in `domain/raster` clamps already.
|
||||
|
||||
All subjects share one source-to-stage mapping on the :face group. Each
|
||||
instance can then be placed independently with ordinary transform channels."
|
||||
ONE MAPPING, COMPUTED OVER EVERY SUBJECT AND WRITTEN INTO EACH FACE. It is
|
||||
measured across all of them together — which is what keeps two faces filmed
|
||||
side by side in their filmed relation — and then stored on each face's own
|
||||
`:place` rather than on a group above them all. Same transform, same subtree,
|
||||
one level lower: the composite is identical to the pixel.
|
||||
|
||||
THE OWNER IS THE POINT. A face carrying its own source-to-stage mapping is a
|
||||
face that is the right size wherever it is put — dropped into another symbol,
|
||||
or opened in its own tab to be drawn over — and the symbol that places it needs
|
||||
to know nothing. On a group above the instances the scale belonged to the take,
|
||||
so a face taken out of that take had no size at all and drew at a fraction of a
|
||||
pixel."
|
||||
[{:keys [stage fit-motion?]} subjects]
|
||||
(let [[w h] stage
|
||||
inputs (vals subjects)]
|
||||
|
|
@ -646,6 +658,17 @@
|
|||
;; ---------------------------------------------------------------------------
|
||||
;; the clip
|
||||
|
||||
(defn- place-in
|
||||
"Put the source-to-stage mapping on symbol `sym`'s own `:place`, above its head.
|
||||
|
||||
`:place` is the one authored node a freeze leaves on a face, and `face-placement`
|
||||
says why it is a default rather than a measurement."
|
||||
[sym channels]
|
||||
(-> sym
|
||||
(assoc-in [:nodes :place] {:id :place :name "source placement" :kind :group
|
||||
:z "a1" :channels channels})
|
||||
(assoc-in [:nodes :head :parent] :place)))
|
||||
|
||||
(defn- subject-part
|
||||
"A subject's drawing, metadata and blocks. Node names are symbol-local."
|
||||
[params subject {:keys [outer eyes brows teeth] :as inputs}]
|
||||
|
|
@ -727,10 +750,11 @@
|
|||
[{:keys [name fps stage expose] :as params} subjects]
|
||||
(when-not (and (map? subjects) (seq subjects)
|
||||
(every? keyword? (keys subjects))
|
||||
(not-any? #{:main :root :face} (keys subjects)))
|
||||
(throw (ex-info "a freeze needs subjects with ids distinct from :main, :root and :face" {})))
|
||||
(not-any? #{:main :root :place} (keys subjects)))
|
||||
(throw (ex-info "a freeze needs subjects with ids distinct from :main, :root and :place" {})))
|
||||
(let [ordered (sort-by (comp str key) subjects)
|
||||
parts (mapv (fn [[id inputs]] [id (subject-part params id inputs)]) ordered)
|
||||
placement (face-placement params subjects)
|
||||
lengths (distinct (map #(get-in % [1 :symbol :frames]) parts))
|
||||
_ (when-not (and (= 1 (count lengths)) (pos? (first lengths)))
|
||||
(throw (ex-info "subjects need the same positive frame count"
|
||||
|
|
@ -745,17 +769,16 @@
|
|||
(into {:main
|
||||
{:id :main :frames nf
|
||||
:nodes (into {:root {:id :root :name "clip" :kind :group :z "a1"
|
||||
:time {:mode :map :expose expose}}
|
||||
:face {:id :face :name "source placement" :kind :group
|
||||
:parent :root :z "a1"
|
||||
:channels (face-placement params subjects)}}
|
||||
:time {:mode :map :expose expose}}}
|
||||
(map-indexed
|
||||
(fn [i [id _]]
|
||||
[id {:id id :kind :instance :parent :face
|
||||
[id {:id id :kind :instance :parent :root
|
||||
:z (str "a" i)
|
||||
:source {:symbol id}}]))
|
||||
ordered)}}
|
||||
(map (fn [[id part]] [id (:symbol part)])) parts)}]
|
||||
(map (fn [[id part]]
|
||||
[id (place-in (:symbol part) placement)]))
|
||||
parts)}]
|
||||
(doseq [[subject inputs] ordered
|
||||
[id track] (:presence inputs)]
|
||||
(when-not (and (= nf (count track))
|
||||
|
|
|
|||
|
|
@ -45,9 +45,7 @@
|
|||
footage stay separate records on the server, so dropping the same file twice
|
||||
does not decode it twice."
|
||||
(:require [arthur.domain.clip :as clip]
|
||||
[arthur.domain.node :as node]
|
||||
[arthur.domain.raster :as raster]
|
||||
[arthur.export :as export]
|
||||
[arthur.events.footage :as footage]
|
||||
[arthur.events.playback :as pb]
|
||||
[arthur.events.project :as project]
|
||||
|
|
@ -74,35 +72,16 @@
|
|||
;; cache's.
|
||||
(atom {:clip nil :urls {}}))
|
||||
|
||||
(defn- placed-in
|
||||
"Where `sid` is placed, as `[host-symbol node-id]`, or nil for one nothing
|
||||
places. Lowest ids first, so a symbol placed several times gets one picture and
|
||||
the same one every time."
|
||||
[document sid]
|
||||
(first (for [h (sort-by str (keys (:symbols document)))
|
||||
n (sort-by str (keys (get-in document [:symbols h :nodes])))
|
||||
:when (contains? (node/sources (get-in document [:symbols h :nodes n])) sid)]
|
||||
[h n])))
|
||||
|
||||
(defn- draw-symbol
|
||||
"`sid`'s first frame as a PNG data URL, through the resolver and the rasteriser
|
||||
the stage uses — so the picture in the pool is the picture, not a sketch of it.
|
||||
|
||||
THROUGH THE PLACEMENT, WHERE THERE IS ONE, and this is the whole subtlety.
|
||||
Rooting at a symbol renders its DRAWING, in its own local space; a rotoscoped
|
||||
face is stored head-local in units of one image height, so its numbers are
|
||||
around 0..1 and the source-to-stage scale that turns them into stage pixels —
|
||||
several hundred — lives on the `:face` group of the symbol that PLACES it. See
|
||||
`freeze/face-placement` and \"What space geometry is in\" in
|
||||
docs/animation-model.md. Rendered rooted at itself, a face is therefore correct
|
||||
and under a pixel across, which is a true picture of nothing anybody wants to
|
||||
look at.
|
||||
|
||||
So a placed symbol is drawn where it is placed, with everything else in that
|
||||
host isolated away — `export/isolate`, whose docstring draws the same
|
||||
distinction for the same reason. A symbol nothing places has no placement to
|
||||
borrow and is rendered rooted at itself, which for the main timeline is exactly
|
||||
right because the stage is its own.
|
||||
ROOTED AT THE SYMBOL, which is only honest because a symbol carries its own
|
||||
placement. A face's geometry is head-local in units of one image height, and
|
||||
the source-to-stage mapping that turns those into stage pixels lives on the
|
||||
face's own `:place` — see `freeze/face-placement`. So a face drawn by itself is
|
||||
the size it is on the stage, and this needs to know nothing about where it
|
||||
happens to be used.
|
||||
|
||||
nil rather than a throw when the frame will not resolve — a cycle, a missing
|
||||
block, an instance naming a symbol that has gone. A pool is a list of names,
|
||||
|
|
@ -116,12 +95,8 @@
|
|||
[document sid store palette ramp]
|
||||
(try
|
||||
(when (clip/symbol document sid)
|
||||
(let [[host id] (placed-in document sid)
|
||||
document (cond-> document
|
||||
host (update-in [:symbols host] export/isolate id))
|
||||
root (or host sid)
|
||||
resolve (clip/resolver document root store palette {})
|
||||
[w h] (clip/stage document root)]
|
||||
(let [resolve (clip/resolver document sid store palette {})
|
||||
[w h] (clip/stage document sid)]
|
||||
(-> (raster/make w h)
|
||||
(raster/clear! (get palette :bg 0))
|
||||
(raster/draw-ops! (resolve 0))
|
||||
|
|
|
|||
|
|
@ -696,15 +696,89 @@
|
|||
(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])
|
||||
columns (cel-sheet clip sid frames)
|
||||
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))))
|
||||
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/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)) ", minmax(110px, 1fr))")}]
|
||||
[:div.cel-sheet {:style style}
|
||||
(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 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]} columns]
|
||||
^{:key (str "head-" path)}
|
||||
|
|
@ -714,22 +788,45 @@
|
|||
(doall
|
||||
(for [f (range frames)
|
||||
item (cons {:frame-label? true}
|
||||
(map #(get-in % [:cells f]) columns))]
|
||||
(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]} (:cel item)
|
||||
target (or select (:lane-select item))]
|
||||
^{:key (str f "-" (:lane item) "-" (or id "gap"))}
|
||||
(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 (and select (= select selection)) " selected"))
|
||||
(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-click (fn []
|
||||
(rf/dispatch [::pb/seek f])
|
||||
(rf/dispatch [::ui/select target]))}
|
||||
(or label "—")]))))]))
|
||||
: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 view []
|
||||
(if (= :cel-sheet @(rf/subscribe [::sub/time-view]))
|
||||
|
|
|
|||
|
|
@ -42,6 +42,37 @@
|
|||
:wave (assoc-in (drawing :wave 0 10) [:nodes :mark :channels [:xform :pos]]
|
||||
(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]
|
||||
(let [r (clip/resolver doc :main nil pal/index-of nil)]
|
||||
(into {} (map (fn [f] [f (into {} (map (juxt :node :cx)) (r f))])) fs)))
|
||||
|
|
|
|||
|
|
@ -75,7 +75,7 @@ try {
|
|||
// its `aria-label`, which is also what a screen reader is told it is.
|
||||
// Two bars carry commands: the location bar says where an edit lands and holds
|
||||
// what creates things there, the transport strip holds what acts on a cel.
|
||||
const bars = ['.loc', '.pane.time .pane-head'];
|
||||
const bars = ['.loc', '.pane.time .pane-head', '.section'];
|
||||
const within = (suffix) => bars.map((b) => `${b} ${suffix}`).join(', ');
|
||||
const named = label =>
|
||||
`(b => b.textContent.trim() === ${JSON.stringify(label)}` +
|
||||
|
|
@ -113,7 +113,10 @@ try {
|
|||
await sleep(180);
|
||||
await shut();
|
||||
};
|
||||
const shot = async () => (await evaluate('laneSnapshot()'));
|
||||
// UUIDs expose a mutable hash cache through clj->js; compare their identity,
|
||||
// not that implementation detail, when asserting exact undo restoration.
|
||||
const shot = async () => JSON.parse(JSON.stringify(await evaluate('laneSnapshot()'),
|
||||
(_key, value) => value?.uuid ?? value));
|
||||
const instances = s => Object.values(s.clip.symbols.main.nodes).filter(n => n.kind === 'instance')
|
||||
.sort((a, b) => a.time.at - b.time.at);
|
||||
await click('lane');
|
||||
|
|
@ -285,7 +288,7 @@ try {
|
|||
// 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('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)
|
||||
|
|
@ -301,6 +304,76 @@ try {
|
|||
'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 = {}) => {
|
||||
await send('Input.dispatchKeyEvent', {type: 'keyDown', key, ...extra});
|
||||
await send('Input.dispatchKeyEvent', {type: 'keyUp', key, ...extra});
|
||||
await sleep(120);
|
||||
};
|
||||
const start = await cellAt(0, 0);
|
||||
await mouse('mousePressed', start);
|
||||
await mouse('mouseMoved', await cellAt(1, 2));
|
||||
await mouse('mouseReleased', await cellAt(1, 2));
|
||||
await sleep(150);
|
||||
assert.equal(await evaluate('document.querySelectorAll(".cs-cell.selected").length'), 6);
|
||||
await key('c', {modifiers: 2});
|
||||
const dest = await cellAt(0, 10);
|
||||
await mouse('mousePressed', dest);
|
||||
await mouse('mouseReleased', dest);
|
||||
await sleep(120);
|
||||
const beforePaste = await shot();
|
||||
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();
|
||||
await mouse('mousePressed', handle);
|
||||
await mouse('mouseMoved', await cellAt(0, 4));
|
||||
await mouse('mouseReleased', await cellAt(0, 4));
|
||||
await sleep(150);
|
||||
const afterHold = await shot();
|
||||
assert.equal(afterHold.history.done.length, beforeHold.history.done.length + 1,
|
||||
'a hold drag commits once');
|
||||
assert.equal(instances(afterHold).filter(n => {
|
||||
const old = instances(beforeHold).find(o => o.id === n.id);
|
||||
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 key('z', {modifiers: 2});
|
||||
assert.deepEqual((await shot()).clip, beforeHold.clip);
|
||||
await key('Delete');
|
||||
const cleared = await shot();
|
||||
assert.equal(cleared.history.done.length, beforeHold.history.done.length + 1,
|
||||
'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});
|
||||
assert.deepEqual((await shot()).clip, beforeHold.clip);
|
||||
await key('x', {modifiers: 2});
|
||||
assert.equal((await shot()).history.done.length, beforeHold.history.done.length + 1);
|
||||
await key('z', {modifiers: 2});
|
||||
assert.deepEqual((await shot()).clip, beforeHold.clip, 'cut is independently undoable');
|
||||
assert.equal(errors.length, 0, JSON.stringify(errors));
|
||||
console.log('PASS: lane commands and corrections agree from timeline and cel sheet; no server writes');
|
||||
} finally {
|
||||
|
|
|
|||
|
|
@ -1160,6 +1160,14 @@ button.share-button:hover, button.share-button.on { filter: brightness(1.1); }
|
|||
.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); }
|
||||
.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 */
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue