diff --git a/docs/lane-handoff.md b/docs/lane-handoff.md index 66bec20..912df41 100644 --- a/docs/lane-handoff.md +++ b/docs/lane-handoff.md @@ -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). diff --git a/frontend/src/arthur/domain/lane.cljs b/frontend/src/arthur/domain/lane.cljs index d2ad7b4..8ee8eb2 100644 --- a/frontend/src/arthur/domain/lane.cljs +++ b/frontend/src/arthur/domain/lane.cljs @@ -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"} diff --git a/frontend/src/arthur/events/ui.cljs b/frontend/src/arthur/events/ui.cljs index 9c20019..2bf2689 100644 --- a/frontend/src/arthur/events/ui.cljs +++ b/frontend/src/arthur/events/ui.cljs @@ -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]] diff --git a/frontend/src/arthur/flow/freeze.cljs b/frontend/src/arthur/flow/freeze.cljs index ab989a4..9933e2d 100644 --- a/frontend/src/arthur/flow/freeze.cljs +++ b/frontend/src/arthur/flow/freeze.cljs @@ -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)) diff --git a/frontend/src/arthur/ui/pool.cljs b/frontend/src/arthur/ui/pool.cljs index 852bed2..d746fee 100644 --- a/frontend/src/arthur/ui/pool.cljs +++ b/frontend/src/arthur/ui/pool.cljs @@ -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)) diff --git a/frontend/src/arthur/ui/timeline.cljs b/frontend/src/arthur/ui/timeline.cljs index d6a1124..9816218 100644 --- a/frontend/src/arthur/ui/timeline.cljs +++ b/frontend/src/arthur/ui/timeline.cljs @@ -696,15 +696,89 @@ (filter :cels (rows clip sid #{})))) (defn- cel-sheet-view [] - (let [clip @(rf/subscribe [::render/clip]) + (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])) diff --git a/frontend/test/arthur/domain/lane_test.cljs b/frontend/test/arthur/domain/lane_test.cljs index eb8d2d1..3c193f8 100644 --- a/frontend/test/arthur/domain/lane_test.cljs +++ b/frontend/test/arthur/domain/lane_test.cljs @@ -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))) diff --git a/frontend/test/browser/lane.mjs b/frontend/test/browser/lane.mjs index ec069c4..17be6b5 100644 --- a/frontend/test/browser/lane.mjs +++ b/frontend/test/browser/lane.mjs @@ -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 { diff --git a/static/arthur/app.css b/static/arthur/app.css index 2dcc35f..573477c 100644 --- a/static/arthur/app.css +++ b/static/arthur/app.css @@ -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 */