perfect target area thing

This commit is contained in:
Your Name 2026-10-03 00:39:07 -04:00
parent 664252e0fc
commit 5bcf22e458
23 changed files with 668 additions and 432 deletions

View file

@ -211,8 +211,8 @@ def probe(path):
raise ValueError(f"the video reports a frame rate of {float(rate):g}, which is " raise ValueError(f"the video reports a frame rate of {float(rate):g}, which is "
"not a rate footage can be measured at") "not a rate footage can be measured at")
duration = float(data.get("format", {}).get("duration") or 0) duration = float(data.get("format", {}).get("duration") or 0)
if duration > 0 and duration * float(rate) > 901: # if duration > 0 and duration * float(rate) > 901:
raise ValueError("video is longer than the 900-frame footage limit") # raise ValueError("video is longer than the 900-frame footage limit")
frames = video.get("nb_frames") frames = video.get("nb_frames")
return {"fps": float(rate), return {"fps": float(rate),
# The exact rate, for ffmpeg. 30000/1001 is not a float, and handing # The exact rate, for ffmpeg. 30000/1001 is not a float, and handing
@ -338,8 +338,8 @@ def run(key):
proxy_facts = probe(proxy_path) proxy_facts = probe(proxy_path)
_refuse_a_shifted_timeline(proxy_path) _refuse_a_shifted_timeline(proxy_path)
frames = count_frames(proxy_path) frames = count_frames(proxy_path)
if not 1 <= frames <= 900: #if not 1 <= frames <= 900:
raise ValueError(f"the proxy holds {frames} frames; the limit is 1–900") # raise ValueError(f"the proxy holds {frames} frames; the limit is 1–900")
# CHECKED AS A DURATION, not as a frame count. The page's clock is # CHECKED AS A DURATION, not as a frame count. The page's clock is
# `frame = floor(audio.currentTime * fps)`, so what must not drift is # `frame = floor(audio.currentTime * fps)`, so what must not drift is
# how long the picture lasts against how long the audio lasts — and # how long the picture lasts against how long the audio lasts — and

View file

@ -30,10 +30,11 @@ stage and timeline. A stage marquee replaces, or with Shift adds to, the same
set. Timeline rows, bars, and cel blocks render membership from that same set; set. Timeline rows, bars, and cel blocks render membership from that same set;
the primary member gets the inspector/focus treatment. the primary member gets the inspector/focus treatment.
Targeting remains separate. Clicking a timeline label both selects it and aims Creation targeting is derived rather than stored. The primary (last-selected)
it. Shift-click changes the selection set and makes the clicked row the one occurrence is the preferred row; the playhead validates it and, when necessary,
target. Clicking a bar/cel or the stage changes selection without silently walks outward to the nearest valid containing occurrence. A target is singular
changing the target. A target is singular even when selection is plural. even when selection is plural. See `docs/creating-in.md` for the resolver
contract.
## Clipboard value ## Clipboard value
@ -50,19 +51,13 @@ cross-project identity collision policy hidden inside it.
## Paste ## Paste
Paste always uses the explicit insertion target and the playhead: Paste resolves one destination from the primary selection and playhead. An
ordinary occurrence is usable only while the playhead maps through every
- no target: paste at the top of the open symbol; enclosing occurrence and lies within its extent. Otherwise resolution walks
- aimed ordinary node: paste beside it in the symbol that owns it; outward, with the open symbol as the total fallback. A selected lane is an
- aimed instance, including an instance whose source is drawn as a lane: paste insertion surface only while all occurrences enclosing its parent symbol are
inside the symbol it places; valid. Multi-selection supplies one ordered payload, not several destinations;
- the open lane's own row: paste directly into that lane. only its primary member anchors this resolution.
The destination is resolved structurally from the target. It does not require
the target instance to be visible at the playhead. Being outside the target
symbol's authored window is legal: content can exist there and be cropped by
the window. Lane paste may grow the lane/window because a sequence cannot store
an overlapping or unreachable tail and pretend the edit succeeded.
The earliest finite start among the copied roots is aligned with the playhead The earliest finite start among the copied roots is aligned with the playhead
in the destination. All other root starts retain their offset from it. Roots in the destination. All other root starts retain their offset from it. Roots
@ -118,13 +113,14 @@ After either duplicate command, the new roots replace the selection.
Cut, paste, duplicate, and duplicate unique each call one domain command and Cut, paste, duplicate, and duplicate unique each call one domain command and
commit through one `edit/transaction`; each is exactly one undo/redo step no commit through one `edit/transaction`; each is exactly one undo/redo step no
matter how many nodes or symbols it touches. Copy, aiming, and selection do not matter how many nodes or symbols it touches. Copy and selection do not
touch history. A refusal changes no document leaves and creates no history step. touch history. A refusal changes no document leaves and creates no history step.
Undo/redo filters the complete selection set against the restored document and Undo/redo filters the complete selection set against the restored document and
repairs the primary selection. Clipboard payloads remain snapshots. Paste repairs the primary selection. Clipboard payloads remain snapshots. Paste
validates the destination and all remapped references at commit time, so a stale validates the resolved destination and all remapped references at commit time,
target or a newly impossible symbol cycle refuses rather than partially editing. so a stale selection or a newly impossible symbol cycle refuses rather than
partially editing.
## Required tests ## Required tests
@ -133,7 +129,7 @@ normal shared references, deep unique graph remaps, multi-root relative timing,
composition overlap, lane overwrite, lane forward duplication and ripple, composition overlap, lane overwrite, lane forward duplication and ripple,
mixed-owner duplication, cycle refusal, stale targets, and all-or-nothing mixed-owner duplication, cycle refusal, stale targets, and all-or-nothing
failure. Event tests cover copy without history, atomic cut/paste/duplicates, failure. Event tests cover copy without history, atomic cut/paste/duplicates,
resulting multi-selection, target resolution, pasting beyond a symbol window, resulting multi-selection, target fallback at the playhead,
and one undo plus redo of each mutation. Browser tests cover mirrored stage and and one undo plus redo of each mutation. Browser tests cover mirrored stage and
timeline selection, Shift-toggle on labels/bars/cels, singular aiming, and the timeline selection, Shift-toggle on labels/bars/cels, singular derived creation
keyboard commands. targeting, and the keyboard commands.

149
docs/creating-in.md Normal file
View file

@ -0,0 +1,149 @@
# Creating in
Revised 2026-10-02.
Arthur's creation model is a layer list plus a playhead. The primary active row
is the preferred place for a new thing. The playhead decides whether that row is
present in the current occurrence and supplies the frame at which the thing is
created.
This is editor behavior, not document structure. A saved clip has symbols,
instances and spans; it does not have timeline rows or a creation target.
## Selection and the active row
The selection set names the objects affected by copy, delete and transform. Its
primary member is also the active row, analogous to the active layer in a paint
program. There is no second targeting gesture for a user to maintain.
- Clicking a row label, its timeline body, or the same occurrence on the stage
makes that occurrence primary.
- Shift/marquee selection may retain several objects, but only the primary row
anchors creation.
- Clicking blank stage or timeline space returns the active row to the symbol in
the current tab.
- Row disclosure is independent. Selecting or creating never implicitly expands
a row.
The selection does not change merely because the playhead moves. An off-frame
object remains available for copying, deletion and inspection.
## Resolving the active row at the playhead
Resolution starts with the structural destination implied by the primary row:
- A lane row means the lane symbol itself.
- An ordinary symbol-instance row means the symbol placed by that occurrence.
- A non-instance row means the symbol containing that node.
- No row means the symbol in the current tab.
For an ordinary symbol occurrence, the playhead must be within that occurrence's
extent in the current nested context. Extents are tested after walking all parent
time maps and use the half-open interval `[in, out)`. If the preferred occurrence
is not present, resolution walks outward to the nearest parent whose occurrence
is present. A containing lane is therefore the natural fallback from an inactive
cel. If no nested occurrence is present, the current tab is the destination.
The preferred row is retained during fallback. Scrubbing back into its extent
makes it the effective destination again.
A lane differs only in what its row means. It is an insertion surface across its
containing timeline and does not require an existing cel under the playhead. A
cel within the lane is still an ordinary symbol occurrence with an extent.
For `A -> B -> lane C -> cel D`:
- over D, with D primary, creation happens inside D;
- past D but while C is available, creation inserts a new cel in C;
- outside C's containing occurrence but inside B, creation happens inside B;
- outside every nested occurrence, creation happens in A, the current tab.
## What creation does
Once resolved, every creation command follows the destination kind.
### Lane destination
- A new or dropped symbol is instantiated directly in the lane at the playhead.
- Its interval claims that time. Existing cels under the interval are removed or
trimmed by the lane's ordinary claim-time command.
- Beginning a drawing creates a new one-frame drawing symbol in the lane and the
finished shape is a child of that drawing.
- Selecting an existing cel changes the destination from the lane to the symbol
placed by that cel; subsequent symbols and shapes become children there.
Thus no separate "new cel" versus "add inside" mode is needed. Selecting the
lane header says new cel; selecting a cel says add inside.
### Ordinary symbol destination
- A new symbol is instantiated as a child at the mapped playhead frame.
- A new shape is authored directly in the symbol at that frame.
- Stage coordinates are transformed through the occurrence into the destination
symbol's local coordinates.
### Explicit timeline drop
A timeline drop uses the row and frame under the pointer, not the stored active
row and playhead. Dropping onto a lane therefore always instantiates in that lane
and claims the pointer's interval. A stage drop uses the resolved active row and
the playhead.
Paste, imported symbols and converted footage obey the same resolver as direct
creation. They must not each reconstruct nesting or extent fallback separately.
## Multi-selection and paste
Multi-selection does not create multiple insertion targets. Copy and cut take
the canonical forest of selected roots as one ordered payload; the primary
(last-selected) occurrence alone supplies the preferred row for a later paste.
At paste time that row and the current playhead resolve one effective
destination, and every root in the payload is inserted there in one transaction.
The earliest finite root start is aligned to the destination frame. Other roots
keep their timing offsets, hierarchy, and clipboard order. In an ordinary symbol
the roots may overlap. In a lane every root must have a finite span and the
payload's root spans must not overlap one another; valid spans claim their times
and trim or remove existing cels as a batch. An invalid member refuses the whole
paste rather than inserting a partial payload.
After paste, all new roots form the selection and the final root is primary, so
it becomes the preferred row for the next creation. Pasting one payload into
several selected destinations is intentionally not implicit: that would be a
separate distribute command. Duplicate is also distinct from paste—it stays
beside each source in its original owner and does not consult the playhead or
creation target.
## Palette lanes
Palette lanes use the same row-and-playhead resolution. Their content filter and
transition command remain palette-specific: only palettes and palette
transitions can be inserted there. This is a type restriction, not a second
targeting model.
## Implementation boundary
One pure resolver returns the effective destination:
```clojure
{:kind :lane | :symbol
:sid destination-symbol
:path effective-occurrence-path
:frame destination-local-frame
:matrix destination-to-current-tab-transform}
```
Callers may add the unchanged document as `:clip` or rename `:frame` to `:at`,
but they must not reinterpret the active row. Polygon creation, symbol creation,
stage drops, paste, import and footage conversion all consume this answer.
Explicit timeline drops use the same structural row rule with the row and frame
under the pointer; unlike playhead resolution, an invalid pointer destination is
refused rather than allowed to fall outward.
The invariants are:
1. The primary row is the preferred structural destination.
2. The playhead validates occurrences and supplies creation time.
3. Inactive targets fall outward; selection does not follow them.
4. A lane row inserts a cel, while a cel row enters its symbol.
5. Stage creation uses the playhead; timeline drops use pointer time.

View file

@ -94,12 +94,9 @@ decision, not a cleanup.
existing root instance all produce the same child instance shape. The only existing root instance all produce the same child instance shape. The only
difference is playback policy: a new empty drawing holds source frame zero; difference is playback policy: a new empty drawing holds source frame zero;
a dropped library symbol plays at speed one. a dropped library symbol plays at speed one.
- **Every symbol is born with a lane,** `clip/lane-node`, id `:lane`. A symbol - **Lanes are explicit.** A symbol may contain ordinary overlapping children
with none had nowhere to drop a thing, which made the first drop into any without a lane. Selecting a lane row opts creation into its claim-time
symbol a special case. An unaimed drop fills an EMPTY lane that is already behavior; selecting a cel enters that cel's source symbol instead.
there and otherwise makes a new one; it never takes an occupied lane nobody
pointed at, because placement claims time and would trim or delete what was
in it.
- **Placement claims time.** Lanes never store overlaps. A new or extended clip - **Placement claims time.** Lanes never store overlaps. A new or extended clip
trims, removes, or splits whatever previously owned the claimed interval. trims, removes, or splits whatever previously owned the claimed interval.
Real compositing overlap uses another lane, where ordering remains explicit. Real compositing overlap uses another lane, where ordering remains explicit.
@ -122,12 +119,12 @@ that is mostly refused today.
## Current timeline interaction ## Current timeline interaction
- Creating a symbol inside an aimed lane creates a one-frame held clip at the - Creation follows the primary active row and the playhead; the complete rule is
playhead. With no aimed lane it first creates a lane. Drawing a polygon uses in `docs/creating-in.md`. A lane row creates a new cel and claims its interval,
the existing clip there or creates the same one-frame clip when the frame is while selecting a cel creates inside the symbol that cel places.
empty. - Drawing with a lane row active creates a new one-frame drawing cel. Dropping a
- Dropping any library symbol into a lane creates a natural-duration playing library symbol there creates a natural-duration playing cel. Explicit timeline
clip. Dropping it on unclaimed timeline or stage space first creates a lane. drops use the row and frame under the pointer.
- Dragging a clip body moves it. A linked audio node follows a picture move; - Dragging a clip body moves it. A linked audio node follows a picture move;
moving or trimming the audio itself remains independent. moving or trimming the audio itself remains independent.
- Dragging a right edge changes its endpoint. Growth consumes adjacent spans - Dragging a right edge changes its endpoint. Growth consumes adjacent spans

View file

@ -65,8 +65,7 @@ code. `npm test` is green at each step either way.
fixtures and risks nothing. fixtures and risks nothing.
7. **Lane creation stays explicit.** A blank document and `new symbol` create 7. **Lane creation stays explicit.** A blank document and `new symbol` create
ordinary symbols. The separate `new → lane` command creates and places a ordinary symbols. The separate `new → lane` command creates and places a
symbol with `:display :lane`, aims it for immediate drawing or dropping, and symbol with `:display :lane` in the effective creation target derived from
always adds it at the top of the open symbol rather than nesting it in the selection and playhead; the new lane then becomes the primary selection.
previously aimed lane.
## Notes ## Notes

View file

@ -384,9 +384,10 @@ content identity, and cel context when applicable. Show local time and
its project context where a useful mapping exists. Holds and loops need an honest its project context where a useful mapping exists. Holds and loops need an honest
description instead of a fictitious unique global frame. description instead of a fictitious unique global frame.
Creation controls next to the breadcrumb act in that explicit location. Selection Creation follows the primary active row, resolved at the playhead as specified in
does not secretly change where a new symbol goes. A shared drawing indicates its [`creating-in.md`](creating-in.md). The wider selection set still names what copy,
reuse and offers Make this cel unique. Names help identify content; delete and transform affect; it is not a second list of creation destinations. A
shared drawing indicates its reuse and offers Make this cel unique. Names help identify content;
linked-use indicators must rely on IDs, because different drawings can share names. linked-use indicators must rely on IDs, because different drawings can share names.
| Surface | Primary scope and controls | | Surface | Primary scope and controls |

View file

@ -0,0 +1,43 @@
(ns arthur.domain.creation
"Resolve where a new thing goes from the primary selection and playhead.
This namespace owns no editor state. A row address is a preference; walking
the occurrence path at one frame answers which preferred or enclosing symbol
is actually available. See `docs/creating-in.md`."
(:require [arthur.domain.clip :as clip]
[arthur.domain.nest :as nest]
[arthur.domain.symbol :as symbol]))
(defn preferred-path
"The container path structurally implied by `selection`.
Selecting an instance means inside it. Selecting any other node means its
containing symbol. An empty or non-node selection means the open symbol."
[document selection]
(let [[kind sid id selected-path] selection
path (when (= :node kind)
(vec (or (seq selected-path) (when id [id]))))]
(cond
(empty? path) []
(= :instance (get-in document [:symbols sid :nodes id :kind])) path
:else (vec (butlast path)))))
(defn target
"The nearest creation context available at `frame` of `open`.
Every non-empty candidate is validated by `nest/inside`, so every enclosing
symbol occurrence must be under the playhead in the current context. Walking
outward stops at the first valid symbol. A lane needs no cel at that frame,
but the occurrence chain that reaches the lane must still be valid. The empty
path always resolves to the open symbol.
Returns `{:kind :lane|:symbol :sid :path :frame :matrix :time}`."
[document store open selection frame]
(let [preferred (preferred-path document selection)]
(some (fn [path]
(when-let [inside (nest/inside document store open path frame)]
(when-let [sid (:sid inside)]
(assoc inside :path path
:kind (if (symbol/lane? (clip/symbol document sid))
:lane :symbol)))))
(take (inc (count preferred)) (iterate pop preferred)))))

View file

@ -509,7 +509,10 @@
st (merge (:store entry) (:store built)) st (merge (:store entry) (:store built))
imported-frames (clip/output-frames clip sid) imported-frames (clip/output-frames clip sid)
source-fps (get-in built [:clip :fps]) source-fps (get-in built [:clip :fps])
where (ui/drop-destination db clip st frame target) where (if point
(ui/creation-destination db clip st frame)
(ui/drop-destination db clip st frame target))
point (ui/destination-point where point)
result (if (:refused where) result (if (:refused where)
where where
(span/place-symbol (:clip where) st (:sid where) (span/place-symbol (:clip where) st (:sid where)
@ -527,7 +530,7 @@
:source-inputs]))))] :source-inputs]))))]
{:db (-> db {:db (-> db
(update :ui dissoc :convert) (update :ui dissoc :convert)
(assoc-in [:ui :selection] (ui/selected
[:node (:sid where) uuid (conj (vec (:path where)) uuid)]) [:node (:sid where) uuid (conj (vec (:path where)) uuid)])
(update :footage merge (update :footage merge
{:loading? false {:loading? false

View file

@ -44,6 +44,10 @@
:trace {:faces (trace/showing-for (:clip entry) sid #{}) :trace {:faces (trace/showing-for (:clip entry) sid #{})
:opacity (or (get-in db [:ui :trace :opacity]) :opacity (or (get-in db [:ui :trace :opacity])
trace/opacity-default)}}) trace/opacity-default)}})
;; Occurrence addresses belong to the document being left. Creation is
;; derived from the primary selection, so carrying one across documents
;; could otherwise make a coincidentally equal id a nested destination.
(update :ui dissoc :selection :selections :points)
(assoc-in [:playback :frame] 0) (assoc-in [:playback :frame] 0)
(assoc-in [:playback :playing?] false)))) (assoc-in [:playback :playing?] false))))
@ -190,18 +194,13 @@
{:db (-> db {:db (-> db
(update-in [:ui :tabs] #(if (some #{sid} %) % (conj (vec %) sid))) (update-in [:ui :tabs] #(if (some #{sid} %) % (conj (vec %) sid)))
(assoc-in [:ui :open] sid) (assoc-in [:ui :open] sid)
;; WHAT WAS SELECTED IS NOT IN HERE. A node selection and an ;; A node address is relative to the tab being left. Opening a
;; aimed target are paths in the symbol being left, and every ;; symbol therefore arrives at its root; a pool `[:symbol _]`
;; bar that reads one — the breadcrumb, the inspector, where a ;; selection is not an occurrence address and may survive.
;; new symbol would land — reads it against the open one. So
;; opening a symbol arrives with nothing selected, which is the
;; state the root crumb already means. A `[:symbol _]`
;; selection names a symbol rather than a place inside one and
;; survives.
(update :ui (fn [ui] (update :ui (fn [ui]
(cond-> (dissoc ui :target) (cond-> ui
(= :node (first (:selection ui))) (= :node (first (:selection ui)))
(dissoc :selection)))) (dissoc :selection :selections))))
(update-in [:ui :trace :faces] #(trace/showing-for clip sid %)) (update-in [:ui :trace :faces] #(trace/showing-for clip sid %))
(assoc-in [:playback :frame] 0) (assoc-in [:playback :frame] 0)
(assoc-in [:playback :playing?] false)) (assoc-in [:playback :playing?] false))

View file

@ -207,7 +207,10 @@
st (merge (:store entry) (:store other)) st (merge (:store entry) (:store other))
;; A symbol from another project arrives as an ordinary clip, the same ;; A symbol from another project arrives as an ordinary clip, the same
;; as one from this project's pool. ;; as one from this project's pool.
where (ui/drop-destination db clip st frame target) where (if point
(ui/creation-destination db clip st frame)
(ui/drop-destination db clip st frame target))
point (ui/destination-point where point)
result (if (:refused where) result (if (:refused where)
where where
(span/place-symbol (:clip where) st (:sid where) (span/place-symbol (:clip where) st (:sid where)
@ -217,7 +220,7 @@
(if-let [why (or (:refused where) (:refused result))] (if-let [why (or (:refused where) (:refused result))]
{:db (update db :project merge {:status why})} {:db (update db :project merge {:status why})}
{:db (-> (edit/edit-entry db #(assoc % :clip (:clip result) :store st)) {:db (-> (edit/edit-entry db #(assoc % :clip (:clip result) :store st))
(assoc-in [:ui :selection] (ui/selected
[:node (:sid where) uuid (conj (vec (:path where)) uuid)]) [:node (:sid where) uuid (conj (vec (:path where)) uuid)])
(update :project merge {:status (str "brought in " label)})) (update :project merge {:status (str "brought in " label)}))
:dispatch [::pb/refresh-clock]})))) :dispatch [::pb/refresh-clock]}))))

View file

@ -1,19 +1,16 @@
(ns arthur.events.ui (ns arthur.events.ui
"Selection, the TARGET, the active tone, the polygon being drawn, and which "Selection, the active tone, the polygon being drawn, and which timeline rows
timeline rows are open. are open.
TWO PIECES OF STATE AND NOT ONE. `[:ui :selection]` is what you are LOOKING THE CREATION TARGET IS A FUNCTION, NOT EDITOR STATE. The primary selection is
at — what the inspector shows, what the stage puts handles on. `[:ui :target]` the preferred row, like the active layer in a paint program. The playhead
is where a new thing would GO. They used to be one, so clicking a shape on the resolves that row to the nearest occurrence creation can happen in: an active
stage silently re-aimed the next polygon at whatever held it, which ordinary symbol, a lane insertion surface, an active ancestor, or the open
`lane-model.md` forbids in so many words: \"Selection does not secretly change symbol. `docs/creating-in.md` is the contract.
where a new symbol goes.\"
THE TARGET MOVES ONLY WHEN SOMEBODY AIMS IT. A click on a timeline row label `[:ui :selections]` remains the set copied, deleted or transformed together;
or a breadcrumb is aiming — each names a place in the `[:ui :selection]` is its primary member and therefore the creation anchor.
document and nothing else. A click on the stage, or on a timeline bar, is not: Moving the playhead never rewrites either one.
it says look at this. So `::select` never writes the target, and `::aim`
always writes both.
All of it is `assoc-in` under `:ui`. There is no effect in this namespace and All of it is `assoc-in` under `:ui`. There is no effect in this namespace and
there should not be one: an editor's own state is the cheapest thing in the there should not be one: an editor's own state is the cheapest thing in the
@ -22,11 +19,11 @@
[arthur.domain.clip :as clip] [arthur.domain.clip :as clip]
[arthur.domain.clipboard :as clipboard] [arthur.domain.clipboard :as clipboard]
[arthur.domain.correction :as correction] [arthur.domain.correction :as correction]
[arthur.domain.creation :as creation]
[arthur.domain.gesture :as gesture] [arthur.domain.gesture :as gesture]
[arthur.domain.nest :as nest] [arthur.domain.nest :as nest]
[arthur.domain.node :as node] [arthur.domain.node :as node]
[arthur.domain.span :as span] [arthur.domain.span :as span]
[arthur.domain.symbol :as symbol]
[arthur.events.edit :as edit] [arthur.events.edit :as edit]
[arthur.domain.paint :as paint] [arthur.domain.paint :as paint]
[arthur.events.playback :as playback] [arthur.events.playback :as playback]
@ -61,37 +58,17 @@
(update-in % [:symbols sid :nodes id] dissoc :name))))))) (update-in % [:symbols sid :nodes id] dissoc :name)))))))
(defn selected (defn selected
"`db` with `selection` selected, and nothing aimed. "`db` with `selection` as its primary active row.
Selection and row disclosure are independent. A click or trim can select a Selection and row disclosure are independent. A click or trim can select a
nested item, but only the row's twist button opens or closes it." nested item, but only the row's twist button opens or closes it."
[db selection] [db selection]
;; A captured trim gesture also selects on pointer-up. It must not acquire the ;; A captured trim gesture also selects on pointer-up. It must not acquire the
;; unrelated side effect of opening every row above the edited clip. ;; unrelated side effect of opening every row above the edited clip.
(cond-> (-> db (-> db
(assoc-in [:ui :selection] selection) (assoc-in [:ui :selection] selection)
(assoc-in [:ui :selections] (if selection [selection] [])) (assoc-in [:ui :selections] (if selection [selection] []))
(update :ui dissoc :points :retry)) (update :ui dissoc :points :retry)))
(nil? selection) (assoc-in [:ui :target] nil)))
(defn target-of
"The target `selection` names: `{:sid :id :path}`, or nil for the open symbol
itself.
All three parts, because a path alone cannot be looked up — one symbol placed
twice is two rows with the same node ids in them, so the row path says WHICH
and the sid says in which symbol's node map to find it. A selection made on
the stage carries no path; it names a node in the open symbol directly, and
the node's own id standing alone is that one-step path."
[selection]
(let [[kind sid id path] selection]
(when (and (= :node kind) id)
{:sid sid :id id :path (vec (or (seq path) [id]))})))
(defn aimed
"`db` with `selection` selected AND aimed: the target follows it."
[db selection]
(assoc-in (selected db selection) [:ui :target] (target-of selection)))
(rf/reg-event-db ::select (fn [db [_ selection]] (selected db selection))) (rf/reg-event-db ::select (fn [db [_ selection]] (selected db selection)))
@ -118,34 +95,6 @@
(-> (selected db primary) (assoc-in [:ui :selections] xs)) (-> (selected db primary) (assoc-in [:ui :selections] xs))
(selected db nil))))) (selected db nil)))))
(rf/reg-event-db
::toggle-and-aim
;; A modified timeline-label click has two independent meanings: membership in
;; the shared selection set, and the one place subsequent creation/paste uses.
(fn [db [_ selection]]
(let [primary (get-in db [:ui :selection])
many (vec (get-in db [:ui :selections]))
old (if (some #{primary} many) many (if primary [primary] []))
xs (if (some #{selection} old)
(vec (remove #{selection} old))
(conj old selection))
db (if-let [primary (peek xs)]
(-> (selected db primary) (assoc-in [:ui :selections] xs))
(selected db nil))]
(assoc-in db [:ui :target] (target-of selection)))))
(rf/reg-event-db
::aim
;; The gestures that name a PLACE in the document rather than a thing on
;; screen: a timeline row label or a breadcrumb.
(fn [db [_ selection]] (aimed db selection)))
(rf/reg-event-db
::aim-at
;; Aiming without selecting: the lane becomes where drawings go while the
;; inspector selection remains independent.
(fn [db [_ selection]] (assoc-in db [:ui :target] (target-of selection))))
(rf/reg-event-db (rf/reg-event-db
::set-tone ::set-tone
(fn [db [_ tone]] (assoc-in db [:ui :tone] tone))) (fn [db [_ tone]] (assoc-in db [:ui :tone] tone)))
@ -450,42 +399,6 @@
(update-in db [:ui :smart] #(let [on? (boolean on)] (update-in db [:ui :smart] #(let [on? (boolean on)]
((if on? conj disj) (set %) face))))) ((if on? conj disj) (set %) face)))))
(defn where-new-goes
"The row path, from the open symbol down, of the symbol a new thing goes into:
INSIDE the aimed instance, or BESIDE an aimed node of any other kind, or at
the top of the open symbol when nothing is aimed.
READ OFF THE TARGET, NEVER THE SELECTION. The two were one thing, and the
result was that clicking a shape on the stage to look at it re-aimed the next
polygon at whatever happened to hold that shape. `[:ui :target]` is a row
path, because one symbol placed twice is two rows and only the path says which
one was aimed at."
[clip db]
(let [{:keys [sid id path]} (get-in db [:ui :target])]
(cond
(empty? path) []
(= :instance (get-in clip [:symbols sid :nodes id :kind])) path
:else (vec (butlast path)))))
(defn aimed-symbol
"The symbol a command acts in and the path of rows down to it: `{:sid :path}`.
INSIDE the aimed instance, beside an aimed node of any other kind, the open
symbol when nothing is aimed — which is `where-new-goes`, resolved. There is
no lane to aim at any more: a lane is how a symbol is DRAWN, so what a gesture
names is a symbol, and whether that symbol is drawn as a lane is a separate
question `symbol/lane?` answers."
[clip st db frame]
(let [open (get-in db [:ui :open])
down (where-new-goes clip db)
inside (nest/inside clip st open down frame)]
;; A stale or presently invisible target is no target. Creation must remain
;; a total operation, so it falls back to the document's main symbol.
(if (:sid inside)
(assoc inside :path down)
{:sid (if (get-in clip [:symbols :main]) :main open)
:path [] :frame frame})))
;; --------------------------------------------------------------------------- ;; ---------------------------------------------------------------------------
;; clipboard and local duplication ;; clipboard and local duplication
@ -537,47 +450,11 @@
(edit/transaction (constantly (:clip r))) (edit/transaction (constantly (:clip r)))
(select-results [])))))))) (select-results []))))))))
(defn- apply-time [{:keys [at rate]} f]
(* rate (- f at)))
(defn paste-destination (defn paste-destination
"The aimed symbol, its row-path prefix, and the playhead in its frames. "The effective creation context at the playhead, in the destination's frames."
Resolve the destination structurally. For an aimed instance, try the ordinary
visible walk first, then continue its authored clock beyond its span/window;
content outside the window is legal and is simply cropped by that window."
[db clip st] [db clip st]
(let [open (get-in db [:ui :open]) (creation/target clip st (get-in db [:ui :open])
frame (editing-frame db clip) (get-in db [:ui :selection]) (editing-frame db clip)))
{:keys [sid id path]} (get-in db [:ui :target])
n (when id (get-in clip [:symbols sid :nodes id]))
selection [:node sid id path]
owner-frame (when id (selection-frame clip st open selection frame))]
(cond
(nil? id)
{:sid open :path [] :frame frame}
(nil? n)
{:sid (if (get-in clip [:symbols :main]) :main open) :path [] :frame frame}
(= :instance (:kind n))
(let [visible (nest/inside clip st open path frame)
local (when (number? owner-frame) (node/local-frame n owner-frame))
shown (when (number? local) (clip/placed-frame clip sid n local))
mapped (when-let [t (and (number? owner-frame)
(clip/source-time clip sid n))]
(apply-time (node/then-time (node/time-of n) t) owner-frame))
held (when (zero? (:speed (node/playback-of n)))
(:in (node/playback-of n)))
at (or (:frame visible) (:frame shown) mapped held)]
(if (number? at)
{:sid (node/source n) :path (vec path) :frame (js/Math.floor at)}
{:sid (if (get-in clip [:symbols :main]) :main open) :path [] :frame frame}))
:else
(if (number? owner-frame)
{:sid sid :path (vec (butlast path)) :frame (js/Math.floor owner-frame)}
{:sid (if (get-in clip [:symbols :main]) :main open) :path [] :frame frame}))))
(rf/reg-event-db (rf/reg-event-db
::paste ::paste
@ -633,59 +510,46 @@
(update-in db [:ui :draft] into [x y]) (update-in db [:ui :draft] into [x y])
db))) db)))
(defn into-the-sequence (defn new-lane-drawing
"Where a polygon drawn into a symbol DRAWN AS A LANE goes: the path of the held "Create the one-frame drawing cel implied by drawing with a lane row active.
clip that symbol exposes at the playhead, and the document containing it.
`{:clip :path}`, or `{:refused why}`.
A GAP IS NOT A REFUSAL, IT IS A NEW DRAWING. The frame under the playhead is A lane is an insertion surface, so this always makes a new cel at the
where the drawing belongs, so drawing on an empty frame makes the one-frame playhead. Existing content at that frame is claimed and trimmed by
held clip that was missing. The gesture already says what the removed drawing `overwrite-drawing`. Selecting an existing cel resolves to that cel's ordinary
controls used to say. source symbol before this function is reached, and therefore draws inside it.
`overwrite-drawing` rather than `append-drawing`, because a gap already has `overwrite-drawing` rather than `append-drawing`, because a gap already has
the room. Appending RIPPLES everything after it later by the new clip's the room. Appending RIPPLES everything after it later by the new clip's
duration — that is what `insert` means, and it is a different intention. duration — that is what `insert` means, and it is a different intention.
Placed into a gap, overwrite clears nothing and moves nobody.
THE CLIP'S PATH IS THE SYMBOL'S PATH PLUS ONE STEP, because a clip of a lane THE CLIP'S PATH IS THE SYMBOL'S PATH PLUS ONE STEP, because a clip of a lane
is an ordinary row of the symbol holding it — which is the addressing `rows` is an ordinary row of the symbol holding it — which is the addressing `rows`
hands the timeline and `trail` reads back." hands the timeline and `trail` reads back."
[clip db {:keys [sid frame path]}] [clip {:keys [sid frame path]}]
(let [exposed (when (integer? frame) (if-not (integer? frame)
(some (fn [c] (let [[lo hi] (node/placed-span c)]
(when (and (<= lo frame) (< frame hi)) c)))
(symbol/children (get-in clip [:symbols sid :nodes]))))]
(cond
(not (integer? frame))
{:refused "the playhead is not on one frame of this symbol"} {:refused "the playhead is not on one frame of this symbol"}
exposed {:clip clip :sid sid :path (conj (vec path) (:id exposed))}
:else
(let [cel-id (random-uuid) (let [cel-id (random-uuid)
made (span/overwrite-drawing clip sid cel-id (clip/fresh-id clip) frame made (span/overwrite-drawing clip sid cel-id (clip/fresh-id clip) frame
{:extent :grow-symbol {:extent :grow-symbol
:remainder-id (random-uuid)})] :remainder-id (random-uuid)})]
(if (:refused made) (if (:refused made)
made made
{:clip (:clip made) :sid sid :path (conj (vec path) cel-id)}))))) {:clip (:clip made) :sid sid :path (conj (vec path) cel-id)}))))
(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.
WHERE IT GOES IS A SYMBOL, and what happens there follows how that symbol is A resolved lane always gets a new drawing cel. A selected cel resolves to its
DRAWN: in one drawn as a lane an occupied frame lands in its clip and a gap source symbol instead, so drawing there adds a child to that existing cel."
first becomes a one-frame drawing, because the sequence is what says which
drawing the playhead is on. In an ordinary composition the polygon goes
straight into the symbol, as it always did."
[clip st db] [clip st db]
(let [into (aimed-symbol clip st db (editing-frame db clip))] (let [into (creation/target clip st (get-in db [:ui :open])
(if (and (:sid into) (symbol/lane? (clip/symbol clip (:sid into)))) (get-in db [:ui :selection]) (editing-frame db clip))]
(assoc (into-the-sequence clip db into) :lane? true) (if (= :lane (:kind into))
(assoc (new-lane-drawing clip into) :lane? true)
{:clip clip :sid (:sid into) :path (:path into) :lane? false}))) {:clip clip :sid (:sid into) :path (:path into) :lane? false})))
(defn beginning-polygon (defn beginning-polygon
"Enter polygon mode, first materializing a drawing at the playhead when the "Enter polygon mode, first materializing a new drawing at the playhead when
symbol it lands in is drawn as a lane and that frame is empty. the effective target is a lane.
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
@ -708,8 +572,7 @@
;; left a selection that looked up to nothing, so the ;; left a selection that looked up to nothing, so the
;; inspector, the breadcrumb and every span command went ;; inspector, the breadcrumb and every span command went
;; blank on a drawing that had just been created. ;; blank on a drawing that had just been created.
(assoc-in [:ui :selection] (selected [:node (:sid landing)
[:node (:sid landing)
(peek (:path landing)) (:path landing)])) (peek (:path landing)) (:path landing)]))
:else db)] :else db)]
@ -761,8 +624,8 @@
(-> db (-> db
(edit/transaction (edit/transaction
(fn [_] (paint/new-shape source sid id frame pts (get-in db [:ui :tone])))) (fn [_] (paint/new-shape source sid id frame pts (get-in db [:ui :tone]))))
(update :ui merge {:tool nil :draft [] (update :ui merge {:tool nil :draft []})
:selection [:node sid id (conj down id)]}))))))) (selected [:node sid id (conj down id)])))))))
(rf/reg-event-db (rf/reg-event-db
::set-knob ::set-knob
@ -783,21 +646,22 @@
"Create and place one empty symbol. Lane creation is explicit: `lane?` marks "Create and place one empty symbol. Lane creation is explicit: `lane?` marks
the new symbol for linear timeline display; ordinary symbol creation never the new symbol for linear timeline display; ordinary symbol creation never
does. Both operations place the new symbol through the same span command, so does. Both operations place the new symbol through the same span command, so
an explicitly aimed lane still applies its claim-time rule." a selected lane still applies its claim-time rule."
[db where lane?] [db where lane?]
(let [{document :clip st :store} (store/entry (:clip/current db)) (let [{document :clip st :store} (store/entry (:clip/current db))
open (get-in db [:ui :open]) open (get-in db [:ui :open])
frame (editing-frame db document) playhead (editing-frame db document)
into (if (= :top where)
(creation/target document st open nil playhead)
(creation/target document st open (get-in db [:ui :selection]) playhead))
;; A lane is the open symbol's timeline, not an insert that happens to ;; A lane is the open symbol's timeline, not an insert that happens to
;; begin where the playhead was when it was made. Giving that wrapper ;; begin where the playhead was when it was made. Giving that wrapper
;; the whole open-symbol window makes the row's promise true: it is a ;; its whole parent window makes the row's promise true: it is a
;; destination at every frame. Ordinary symbols remain clips created at ;; destination at every frame. Resolve the parent at the ACTUAL playhead
;; the playhead. ;; first; using frame zero for resolution would incorrectly fall out of
frame (if lane? 0 frame) ;; every nested symbol that begins later in the open context.
into (if (= :top where) {host :sid resolved-at :frame down :path destination-kind :kind} into
(assoc (nest/inside document st open [] frame) :path []) at (if lane? 0 resolved-at)
(aimed-symbol document st db frame))
{host :sid at :frame down :path} into
sid (clip/fresh-id document) sid (clip/fresh-id document)
instance-id (random-uuid) instance-id (random-uuid)
end (clip/frames document host) end (clip/frames document host)
@ -811,7 +675,13 @@
:else :else
(let [new-symbol (cond-> {:id sid :name (name sid) (let [new-symbol (cond-> {:id sid :name (name sid)
:fps (clip/fps document host) :fps (clip/fps document host)
:frames (- end at) :nodes {}} ;; A fresh cel is one frame for
;; frame-by-frame work. In an
;; ordinary composition the new
;; symbol keeps the available window.
:frames (if (= :lane destination-kind)
1 (- end at))
:nodes {}}
lane? (assoc :display :lane)) lane? (assoc :display :lane))
seeded (assoc-in document [:symbols sid] new-symbol)] seeded (assoc-in document [:symbols sid] new-symbol)]
(span/place-symbol seeded st host instance-id sid at (span/place-symbol seeded st host instance-id sid at
@ -820,12 +690,11 @@
(if-let [why (:refused result)] (if-let [why (:refused result)]
(update db :project merge {:status why}) (update db :project merge {:status why})
(let [selection [:node host instance-id (conj (vec down) instance-id)]] (let [selection [:node host instance-id (conj (vec down) instance-id)]]
(cond-> (-> db (-> db
(edit/transaction (constantly (:clip result))) (edit/transaction (constantly (:clip result)))
(assoc-in [:ui :selection] selection) (selected selection)
(update-in [:ui :expanded] (fnil into #{}) (update-in [:ui :expanded] (fnil into #{})
(rest (reductions conj [] down)))) (rest (reductions conj [] down))))))))
lane? (assoc-in [:ui :target] (target-of selection)))))))
(rf/reg-event-db (rf/reg-event-db
::new-symbol ::new-symbol
@ -835,11 +704,9 @@
(rf/reg-event-db (rf/reg-event-db
::new-lane ::new-lane
(fn [db _] (fn [db _]
;; A lane is an explicit, persistent top-level track of the open symbol. It ;; A lane is an explicit persistent row across the effective target symbol.
;; must not become nested merely because the previously created lane is ;; The playhead validates that nested target; the row itself begins at zero.
;; still aimed, and it spans the open symbol rather than starting at the (create-container db :inside true)))
;; current playhead.
(create-container db :top true)))
;; --------------------------------------------------------------------------- ;; ---------------------------------------------------------------------------
;; a drop in flight ;; a drop in flight
@ -875,31 +742,47 @@
pointer answered with — and `:at` is that frame carried down into the pointer answered with — and `:at` is that frame carried down into the
destination's. Nothing on this path crosses the output grid. destination's. Nothing on this path crosses the output grid.
`:clip` is handed back unchanged and is in the result only so the callers that `:clip` is handed back unchanged so all placement callers consume the same
used to be given a document with a freshly made lane in it go on reading one result shape."
thing."
[document st open frame target] [document st open frame target]
(let [target (if (vector? target) (let [[_ sid id selected-path] target
(let [[_ sid id path] target] path0 (vec (or (seq selected-path) (when id [id])))
{:sid sid :id id :path path})
target)
path (cond path (cond
(nil? target) [] (nil? target) []
(= :instance (get-in document [:symbols (:sid target) (= :instance (get-in document [:symbols sid :nodes id :kind])) path0
:nodes (:id target) :kind])) :else (vec (butlast path0)))
(vec (:path target)) {destination :sid at :frame matrix :matrix}
:else (vec (butlast (:path target)))) (nest/inside document st open path frame)]
{:keys [sid] at :frame} (nest/inside document st open path frame)]
(cond (cond
(nil? sid) {:refused "what you are dropping into is not on screen at this frame"} (nil? destination) {:refused "what you are dropping into is not on screen at this frame"}
(not (integer? at)) {:refused "the drop is not on one frame of that symbol"} (not (integer? at)) {:refused "the drop is not on one frame of that symbol"}
:else {:clip document :sid sid :at at :path path}))) :else {:clip document :sid destination :at at :path path :matrix matrix})))
(defn drop-destination (defn drop-destination
"`drop-destination-at` from the symbol currently open in `db`." "`drop-destination-at` from the symbol currently open in `db`."
[db document st frame target] [db document st frame target]
(drop-destination-at document st (get-in db [:ui :open]) frame target)) (drop-destination-at document st (get-in db [:ui :open]) frame target))
(defn creation-destination
"A stage creation destination from the primary selection and playhead."
[db document st frame]
(let [{:keys [sid path matrix] at :frame}
(creation/target document st (get-in db [:ui :open])
(get-in db [:ui :selection]) frame)]
(cond
(nil? sid) {:refused "nothing under the playhead can contain this"}
(not (integer? at)) {:refused "the playhead is not on one frame of that symbol"}
:else {:clip document :sid sid :at at :path path :matrix matrix})))
(defn destination-point
"A point in the current tab transformed into `where`'s local coordinates."
[{:keys [matrix]} point]
(if-let [inv (and point matrix (node/invert matrix))]
(let [out (js/Float64Array. 2)]
(node/apply-pt! out 0 inv (first point) (second point))
[(aget out 0) (aget out 1)])
point))
(defn landed (defn landed
"`db` after a drop that produced `result`, with `uuid` selected." "`db` after a drop that produced `result`, with `uuid` selected."
[db {:keys [sid path]} uuid result] [db {:keys [sid path]} uuid result]
@ -908,7 +791,7 @@
(-> db (-> db
(update :ui dissoc :drop) (update :ui dissoc :drop)
(edit/transaction (constantly (:clip result))) (edit/transaction (constantly (:clip result)))
(assoc-in [:ui :selection] [:node sid uuid (conj (vec path) uuid)])))) (selected [:node sid uuid (conj (vec path) uuid)]))))
(rf/reg-event-db (rf/reg-event-db
::drop-symbol ::drop-symbol
@ -918,7 +801,10 @@
;; a lane trims its neighbour and a drop into a composition does not. ;; a lane trims its neighbour and a drop into a composition does not.
(fn [db [_ source-id frame point target]] (fn [db [_ source-id frame point target]]
(let [{document :clip st :store} (store/entry (:clip/current db)) (let [{document :clip st :store} (store/entry (:clip/current db))
where (drop-destination db document st frame target) where (if point
(creation-destination db document st frame)
(drop-destination db document st frame target))
point (destination-point where point)
uuid (random-uuid)] uuid (random-uuid)]
(if (:refused where) (if (:refused where)
(-> db (update :ui dissoc :drop) (update :project merge {:status (:refused where)})) (-> db (update :ui dissoc :drop) (update :project merge {:status (:refused where)}))
@ -943,7 +829,7 @@
;; `place-sound` positions in. It used to be multiplied by the grid rate ;; `place-sound` positions in. It used to be multiplied by the grid rate
;; again on the way in — a second conversion of an already-converted ;; again on the way in — a second conversion of an already-converted
;; number, which dropped a sound 2.5 frames late for every frame it was ;; number, which dropped a sound 2.5 frames late for every frame it was
;; aimed at in a 12fps project. ;; selected in a 12fps project.
(let [sid (:sid where) (let [sid (:sid where)
seeded (clip/place-sound (:clip where) sid source label length rate seeded (clip/place-sound (:clip where) sid source label length rate
(:at where) uuid)] (:at where) uuid)]

View file

@ -41,7 +41,6 @@
frames (js/Number (:frames m)) frames (js/Number (:frames m))
urls (:urls m)] urls (:urls m)]
(when-not (and (js/Number.isFinite fps) (pos? fps) (when-not (and (js/Number.isFinite fps) (pos? fps)
(js/Number.isInteger frames) (<= 1 frames 900)
(string? (:audio m)) (seq (:audio m)) (string? (:audio m)) (seq (:audio m))
(sequential? urls) (every? string? urls)) (sequential? urls) (every? string? urls))
(throw (ex-info "a footage manifest needs fps, frames (1–900), audio and a url per frame" (throw (ex-info "a footage manifest needs fps, frames (1–900), audio and a url per frame"

View file

@ -4,7 +4,8 @@
Cheap by construction, like `subs/playback`: each reads a path and returns a Cheap by construction, like `subs/playback`: each reads a path and returns a
value, so clicking a swatch notifies the swatches and nothing else." value, so clicking a swatch notifies the swatches and nothing else."
(:require [arthur.domain.nest :as nest] (:require [arthur.domain.creation :as creation]
[arthur.domain.nest :as nest]
[arthur.domain.pick :as pick] [arthur.domain.pick :as pick]
[arthur.footage.store :as store] [arthur.footage.store :as store]
[arthur.subs.playback :as playback] [arthur.subs.playback :as playback]
@ -23,7 +24,6 @@
(cond (nil? primary) [] (cond (nil? primary) []
(some #{primary} many) many (some #{primary} many) many
:else [primary])))) :else [primary]))))
(rf/reg-sub ::target (fn [db _] (get-in db [:ui :target])))
(rf/reg-sub ::clipboard (fn [db _] (get-in db [:ui :clipboard]))) (rf/reg-sub ::clipboard (fn [db _] (get-in db [:ui :clipboard])))
(rf/reg-sub ::retry (fn [db _] (get-in db [:ui :retry]))) (rf/reg-sub ::retry (fn [db _] (get-in db [:ui :retry])))
(rf/reg-sub ::tone (fn [db _] (get-in db [:ui :tone]))) (rf/reg-sub ::tone (fn [db _] (get-in db [:ui :tone])))
@ -67,33 +67,33 @@
(nest/inside clip st open (or path [id]) f))))) (nest/inside clip st open (or path [id]) f)))))
(rf/reg-sub (rf/reg-sub
::target-node ::creation-target
:<- [::target] :<- [::selection]
:<- [::render/clip]
(fn [[{:keys [sid id]} clip] _]
;; The node a new thing would be parented to, for the outline that says so.
;; `[sid id node]` as `::selected-node` gives it, so the two read alike.
(when (and clip id)
(when-let [n (get-in clip [:symbols sid :nodes id])]
[sid id n]))))
(rf/reg-sub
::target-placement
:<- [::target]
:<- [::target-node]
:<- [::render/clip] :<- [::render/clip]
:<- [::render/clip-id] :<- [::render/clip-id]
:<- [::render/open] :<- [::render/open]
:<- [::render/open-frame] :<- [::render/open-frame]
(fn [[{:keys [path]} [_ id n] clip clip-id open f] _] (fn [[selection clip clip-id open f] _]
;; `nest/placement` of the TARGET, with the bounds of what it draws — the (when clip
;; same work `::selected-placement` does, kept separate because the two are (creation/target clip (:store (store/entry clip-id)) open selection f))))
;; separate states and are drawn differently: the target gets an outline and
;; a tag, the selection gets handles. (rf/reg-sub
(when n ::creation-placement
:<- [::creation-target]
:<- [::render/clip]
:<- [::render/clip-id]
:<- [::render/open]
:<- [::render/open-frame]
(fn [[{:keys [path]} clip clip-id open f] _]
;; The effective occurrence whose contents receive creation. A fallback may
;; therefore outline an ancestor while the off-frame primary selection stays
;; unchanged.
(when (and clip (seq path))
(let [st (:store (store/entry clip-id))] (let [st (:store (store/entry clip-id))]
(when-let [pl (nest/placement clip st open (or path [id]) f)] (when-let [{:keys [sid id frame] :as pl} (nest/placement clip st open path f)]
(assoc pl :node n :bounds ((pick/bounds-of clip st (:sid pl) n) (:frame pl)))))))) (let [n (get-in clip [:symbols sid :nodes id])]
(assoc pl :node n
:bounds ((pick/bounds-of clip st sid n) frame))))))))
(rf/reg-sub (rf/reg-sub
::selected-placement ::selected-placement

View file

@ -119,7 +119,7 @@
(case kind (case kind
:symbol (rf/dispatch [::ui/drop-symbol sid frame point target]) :symbol (rf/dispatch [::ui/drop-symbol sid frame point target])
;; Video is asked about before anything happens: which frames, and what ;; Video is asked about before anything happens: which frames, and what
;; the symbol they become is called. The lane it was aimed at travels ;; the symbol they become is called. The explicit timeline row travels
;; with the question, so the answer lands where the drop pointed. ;; with the question, so the answer lands where the drop pointed.
:footage (rf/dispatch [::footage/ask-convert c frame point target]) :footage (rf/dispatch [::footage/ask-convert c frame point target])
:import (rf/dispatch [::project/import c frame point target]) :import (rf/dispatch [::project/import c frame point target])

View file

@ -141,12 +141,10 @@
last-i (dec (count crumbs)) last-i (dec (count crumbs))
shared (shared-with clip n) shared (shared-with clip n)
says (whereabouts clip n inside) says (whereabouts clip n inside)
;; WHAT IS AIMED, which is not what is selected: the menu has to name ;; The effective parent can be an active ancestor of the primary row
;; the place it would add to, and that place is the target. Named the ;; when the preferred occurrence is off the playhead.
;; way the trail names a crumb, so the menu and the outline's tag and destination (let [{:keys [id node]} @(rf/subscribe [::sub/creation-placement])]
;; the breadcrumb all call one node by one name. (when node (crumb-label clip id node)))]
aimed (let [[tsid tid tn] @(rf/subscribe [::sub/target-node])]
(when tn (crumb-label clip tid tn)))]
[:section.loc [:section.loc
[:nav.crumbs {:aria-label "editing location"} [:nav.crumbs {:aria-label "editing location"}
(doall (doall
@ -161,9 +159,7 @@
:title (if (zero? i) :title (if (zero? i)
"the open symbol — clear the selection" "the open symbol — clear the selection"
(str label " · " (if lane? "lane" (name crumb-kind)))) (str label " · " (if lane? "lane" (name crumb-kind))))
;; A CRUMB AIMS, because this bar is the one that says where an :on-click #(rf/dispatch [::ui/select (when (pos? i) select)])}
;; edit would land: going out to a level is going there to work.
:on-click #(rf/dispatch [::ui/aim (when (pos? i) select)])}
label]]))] label]]))]
(when says (when says
[:span.loc-fact {:title "the frame this selection is showing, in its own time"} [:span.loc-fact {:title "the frame this selection is showing, in its own time"}
@ -184,20 +180,18 @@
[menu/view [menu/view
{:label "new" :title "add to the document" {:label "new" :title "add to the document"
:items [{:label "inside" :items [{:label "inside"
:sub (if aimed :sub (if destination
(str "a symbol in " aimed " — the outlined one") (str "a symbol in " destination " — the outlined one")
(str "a symbol at the top of " (clip/symbol-name clip open) (str "a symbol at the top of " (clip/symbol-name clip open)
", since nothing is aimed")) ", since no nested row is active here"))
:on-click #(rf/dispatch [::ui/new-symbol :inside])} :on-click #(rf/dispatch [::ui/new-symbol :inside])}
;; THE SECOND ITEM IS THE POINT OF THE MENU. With one item it was ;; THE SECOND ITEM IS THE POINT OF THE MENU. With one item it was
;; impossible to add anything at the top of the open symbol ;; impossible to add anything at the top of the open symbol
;; without first clearing the aim, which meant the only way out ;; without first clearing the active row.
;; of a nesting was to leave it — and nobody could see the rule
;; they were working against.
{:label "at top" {:label "at top"
:disabled? (nil? aimed) :disabled? (nil? destination)
:sub (str "a symbol straight into " (clip/symbol-name clip open) :sub (str "a symbol straight into " (clip/symbol-name clip open)
", ignoring what is aimed") ", ignoring the active row")
:on-click #(rf/dispatch [::ui/new-symbol :top])} :on-click #(rf/dispatch [::ui/new-symbol :top])}
{:label "lane" {:label "lane"
:sub (str "a row for symbol clips in " (clip/symbol-name clip open)) :sub (str "a row for symbol clips in " (clip/symbol-name clip open))

View file

@ -140,15 +140,13 @@
(defn- select! (defn- select!
"Select the node at row path `path` of the open symbol — the selection a "Select the node at row path `path` of the open symbol — the selection a
timeline row makes, so the row, the inspector and the stage all show it — or timeline row makes, so the row, the inspector and the stage all show it — or
the open symbol itself when `path` is empty. Blank stage is an explicit place, the open symbol itself when `path` is empty."
not merely an absence of a picked shape, so it clears a stale drawing target
as well as the inspector selection."
[{:keys [open f] :as ctx} path] [{:keys [open f] :as ctx} path]
(let [{document :clip st :store} (loaded ctx)] (let [{document :clip st :store} (loaded ctx)]
(if-let [{:keys [sid id]} (when (seq path) (if-let [{:keys [sid id]} (when (seq path)
(nest/placement document st open path f))] (nest/placement document st open path f))]
(rf/dispatch [::ui/select [:node sid id path]]) (rf/dispatch [::ui/select [:node sid id path]])
(rf/dispatch [::ui/aim nil])))) (rf/dispatch [::ui/select nil]))))
(defn- begin! (defn- begin!
"Start dragging `kind` of the node at `path` from stage point `p`." "Start dragging `kind` of the node at `path` from stage point `p`."
@ -305,13 +303,12 @@
(when-let [{:keys [sid id]} (and (seq path) (nest/placement document st open path f))] (when-let [{:keys [sid id]} (and (seq path) (nest/placement document st open path f))]
[:node sid id path]))) [:node sid id path])))
(defn- aim-box (defn- creation-box
"The TARGET's outline: where a new polygon or symbol would be parented, drawn "The effective creation parent's outline, resolved from selection and playhead.
around the thing that would be its parent.
NOT THE SELECTION'S BOX AND DELIBERATELY NOT LIKE IT. The selection gets NOT THE SELECTION'S BOX AND DELIBERATELY NOT LIKE IT. The selection gets
handles, because handles are how you change it; the target gets an outline handles, because handles are how you change it; the target gets an outline
with nothing to grab, because there is nothing to change about being aimed at. with nothing to grab, because the derived destination is not itself a handle.
Same geometry — the node's bounds through its own transform, so it turns with Same geometry — the node's bounds through its own transform, so it turns with
what it is around — and a different statement. what it is around — and a different statement.
@ -323,8 +320,9 @@
carries a `viewBox` scaled by `zoom`, which is also why the stroke widths carries a `viewBox` scaled by `zoom`, which is also why the stroke widths
nearby are fractions." nearby are fractions."
[] []
(let [{:keys [world bounds]} @(rf/subscribe [::sub/target-placement]) (let [{:keys [world bounds id node]}
[_ id n] @(rf/subscribe [::sub/target-node]) @(rf/subscribe [::sub/creation-placement])
n node
label (or (:name n) (some-> (node/source n) name) label (or (:name n) (some-> (node/source n) name)
(when id (if (keyword? id) (subs (str id) 1) (subs (str id) 0 8))))] (when id (if (keyword? id) (subs (str id) 1) (subs (str id) 0 8))))]
(when (and world bounds) (when (and world bounds)
@ -333,16 +331,16 @@
;; Furthest bottom-right of the four as drawn: the corner a reader ;; Furthest bottom-right of the four as drawn: the corner a reader
;; would call "the bottom right" whatever the node has been turned to. ;; would call "the bottom right" whatever the node has been turned to.
[tx ty] (apply max-key (fn [[x y]] (+ x y)) corners)] [tx ty] (apply max-key (fn [[x y]] (+ x y)) corners)]
[:g.aim {:pointer-events "none"} [:g.creation-target {:pointer-events "none"}
[:polygon.aim-box {:points (points-text (flatten corners))}] [:polygon.creation-target-box {:points (points-text (flatten corners))}]
(when label (when label
[:g {:transform (str "translate(" (+ tx 1.5) " " (+ ty 1.5) ")")} [:g {:transform (str "translate(" (+ tx 1.5) " " (+ ty 1.5) ")")}
;; The plate behind the text, sized off the string rather than ;; The plate behind the text, sized off the string rather than
;; measured: `textLength` would need a layout pass, and a tag that ;; measured: `textLength` would need a layout pass, and a tag that
;; is a little wide is better than one that reflows the frame. ;; is a little wide is better than one that reflows the frame.
[:rect.aim-tag-bg {:x 0 :y 0 :rx 0.8 [:rect.creation-target-tag-bg {:x 0 :y 0 :rx 0.8
:width (+ 2 (* 2.1 (count label))) :height 5}] :width (+ 2 (* 2.1 (count label))) :height 5}]
[:text.aim-tag {:x 1 :y 3.8} label]])])))) [:text.creation-target-tag {:x 1 :y 3.8} label]])]))))
(defn- overlay [w h zoom] (defn- overlay [w h zoom]
(let [tool @(rf/subscribe [::sub/tool]) (let [tool @(rf/subscribe [::sub/tool])
@ -444,7 +442,7 @@
;; DRAWN WHILE DRAWING, unlike the handles. "Where will this polygon land" ;; DRAWN WHILE DRAWING, unlike the handles. "Where will this polygon land"
;; is the question the outline exists to answer, and the moment it is being ;; is the question the outline exists to answer, and the moment it is being
;; asked is mid-draft. ;; asked is mid-draft.
(when-not points? [aim-box]) (when-not points? [creation-box])
(when-not (or drawing? points?) (when-not (or drawing? points?)
(if (> (count placements) 1) [group-handles ctx placements] [handles ctx])) (if (> (count placements) 1) [group-handles ctx placements] [handles ctx]))
(when (and id pts (not drawing?) (not (channel/nothing? pts))) (when (and id pts (not drawing?) (not (channel/nothing? pts)))
@ -475,12 +473,7 @@
;; unit a drop on the timeline's ruler lands in. ;; unit a drop on the timeline's ruler lands in.
frame @(rf/subscribe [::render/open-frame]) frame @(rf/subscribe [::render/open-frame])
clip @(rf/subscribe [::render/clip]) clip @(rf/subscribe [::render/clip])
clip-id @(rf/subscribe [::render/clip-id]) destination @(rf/subscribe [::sub/creation-target])
db-target @(rf/subscribe [::sub/target])
open @(rf/subscribe [::render/open])
destination (when clip
(ui/aimed-symbol clip (:store (store/entry clip-id))
{:ui {:open open :target db-target}} frame))
destination-name (when-let [sid (:sid destination)] destination-name (when-let [sid (:sid destination)]
(clip/symbol-name clip sid)) (clip/symbol-name clip sid))
zoom @(rf/subscribe [::layout/zoom :stage])] zoom @(rf/subscribe [::layout/zoom :stage])]

View file

@ -585,14 +585,9 @@
(memoize (fn [_selection] (fn [el] (some-> el (.scrollIntoView #js {:block "nearest"})))))) (memoize (fn [_selection] (fn [el] (some-> el (.scrollIntoView #js {:block "nearest"}))))))
(defn- label-cell [{:keys [path depth label kind node-kind lane? select expandable? expanded? of via]} (defn- label-cell [{:keys [path depth label kind node-kind lane? select expandable? expanded? of via]}
selection selections target over solo tracing renaming draft] selection selections target-path over solo tracing renaming draft]
(let [node? (= :node kind) (let [node? (= :node kind)
selected? (contains? selections select) selected? (contains? selections select)
;; AIMED IS NOT SELECTED, so it does not wear the selected class. The
;; target is where a new thing would go; the selection is what the
;; inspector is showing. One row is often both and must still say which
;; of the two it is being.
aimed? (and node? (= path (:path target)))
editing? (and lane? (= select @renaming)) editing? (and lane? (= select @renaming))
begin-rename! (fn [] (reset! draft label) (reset! renaming select)) begin-rename! (fn [] (reset! draft label) (reset! renaming select))
commit-rename! (fn [] commit-rename! (fn []
@ -603,7 +598,7 @@
[over-path where] @over] [over-path where] @over]
[:div (cond-> {:class (str "tl-label" (when (and select selected?) " on") [:div (cond-> {:class (str "tl-label" (when (and select selected?) " on")
(when (and select (= select selection)) " primary") (when (and select (= select selection)) " primary")
(when aimed? " aimed") (when (= path target-path) " target")
(when lane? " lane") (when lane? " lane")
(when (= :ghost kind) " ghost") (when (= :ghost kind) " ghost")
(when (= path over-path) (when (= path over-path)
@ -614,16 +609,13 @@
:style {:padding-left (str (+ 4 (* 11 depth)) "px")} :style {:padding-left (str (+ 4 (* 11 depth)) "px")}
:title label :title label
:ref (when (and select (= select selection)) (reveal selection)) :ref (when (and select (= select selection)) (reveal selection))
;; A LABEL AIMS. Clicking a row's name says "I am working ;; Labels, bars and stage objects all select the same row.
;; here", which is a statement about a place in the document; ;; The primary row plus playhead resolves creation.
;; clicking its bar or one of its cels, below, says "show me
;; this", which is a statement about a thing on screen. Only
;; the first moves where new drawings and symbols go.
:on-click (fn [^js e] :on-click (fn [^js e]
(when select (when select
(rf/dispatch [(if (.-shiftKey e) (rf/dispatch [(if (.-shiftKey e)
::ui/toggle-and-aim ::ui/toggle-selection
::ui/aim) ::ui/select)
select]))) select])))
;; An instance's row opens the symbol it places, as a tab. ;; An instance's row opens the symbol it places, as a tab.
:on-double-click (fn [^js e] :on-double-click (fn [^js e]
@ -665,8 +657,10 @@
[:button.tl-twist [:button.tl-twist
{:disabled (not expandable?) {:disabled (not expandable?)
:on-click (fn [^js e] :on-click (fn [^js e]
(println "heyyy")
(.stopPropagation e) (.stopPropagation e)
(rf/dispatch [::ui/toggle-row path]))} (rf/dispatch [::ui/toggle-row path]))
:class (println expandable?)}
(when expandable? (if expanded? "▾" "▸"))] (when expandable? (if expanded? "▾" "▸"))]
;; A LANE IS NOT AN INSTANCE WEARING A DIFFERENT HAT, to read. Its one row ;; A LANE IS NOT AN INSTANCE WEARING A DIFFERENT HAT, to read. Its one row
;; holds blocks that follow one another in time, where every other row ;; holds blocks that follow one another in time, where every other row
@ -726,7 +720,7 @@
moved since. What it looks like mid-drag is `[:ui :sliding]`, which the clip moved since. What it looks like mid-drag is `[:ui :sliding]`, which the clip
every row and the stage are drawn from already has in it." every row and the stage are drawn from already has in it."
[{:keys [path span keys dense? kind node-kind select slides cels lane? of unmapped? owner]} [{:keys [path span keys dense? kind node-kind select slides cels lane? of unmapped? owner]}
frames sliding hint {:keys [clip store open selection selections]}] frames sliding hint {:keys [clip store open selection selections target-path]}]
(let [selected-set (set selections) (let [selected-set (set selections)
active-row (:row @sliding) active-row (:row @sliding)
slide (fn [^js e] slide (fn [^js e]
@ -882,7 +876,8 @@
[: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.
{:class (str (when lane? "lane") (when (= :palette kind) " palette")) {:class (str (when lane? "lane") (when (= :palette kind) " palette")
(when (= path target-path) " target"))
:on-pointer-move slide :on-pointer-move slide
:on-pointer-up (fn [e] (slide e) (done true)) :on-pointer-up (fn [e] (slide e) (done true))
:on-pointer-cancel (fn [_] (done false)) :on-pointer-cancel (fn [_] (done false))
@ -1000,6 +995,7 @@
:class (str (when ghost? "ghost") :class (str (when ghost? "ghost")
(when (and select (contains? selected-set select)) " on") (when (and select (contains? selected-set select)) " on")
(when (and select (= select selection)) " primary") (when (and select (= select selection)) " primary")
(when (= (nth select 3 nil) target-path) " target")
(when (and select (= select (get-in @sliding [:nest :select]))) (when (and select (= select (get-in @sliding [:nest :select])))
" nest-target")) " nest-target"))
:style {:position "absolute" :left (edge% in frames) :style {:position "absolute" :left (edge% in frames)
@ -1107,7 +1103,12 @@
selection @(rf/subscribe [::sub/selection]) selection @(rf/subscribe [::sub/selection])
selections @(rf/subscribe [::sub/selections]) selections @(rf/subscribe [::sub/selections])
selection-set (set selections) selection-set (set selections)
target @(rf/subscribe [::sub/target]) creation @(rf/subscribe [::sub/creation-target])
target-path (let [path (:path creation)]
(if (and (empty? path)
(= :lane (:kind creation)))
[::lane]
path))
expanded @(rf/subscribe [::sub/expanded]) expanded @(rf/subscribe [::sub/expanded])
drop @(rf/subscribe [::sub/drop]) drop @(rf/subscribe [::sub/drop])
solo (set @(rf/subscribe [::render/solo])) solo (set @(rf/subscribe [::render/solo]))
@ -1123,7 +1124,7 @@
;; will resolve on release -- and with no row under the pointer it is a ;; will resolve on release -- and with no row under the pointer it is a
;; row of its own at the top of the section it will appear in. ;; row of its own at the top of the section it will appear in.
;; ;;
;; A SOUND USED TO BE THE EXCEPTION: aimed at a lane it drew no block ;; A SOUND USED TO BE THE EXCEPTION: over a lane it drew no block
;; there and a ghost row down in the audio section instead, so dragging ;; there and a ghost row down in the audio section instead, so dragging
;; a sound onto a lane looked exactly like a lane refusing it -- while ;; a sound onto a lane looked exactly like a lane refusing it -- while
;; the drop itself worked and put the sound in the lane. One rule ;; the drop itself worked and put the sound in the lane. One rule
@ -1175,18 +1176,16 @@
[:section.pane.time [:section.pane.time
[transport] [transport]
[:div.tl-body [:div.tl-body
;; Blank timeline space means the open symbol. Use the same `aim` ;; Blank timeline space means the open symbol. Child controls stop their
;; gesture as its breadcrumb so both the visible selection and the
;; drawing destination return to the root. Child controls stop their
;; own events; the target check keeps ordinary row/bar clicks local. ;; own events; the target check keeps ordinary row/bar clicks local.
{:on-click (fn [^js e] {:on-click (fn [^js e]
(when (= (.-target e) (.-currentTarget e)) (when (= (.-target e) (.-currentTarget e))
(rf/dispatch [::ui/aim nil])))} (rf/dispatch [::ui/select nil])))}
[:div.tl-labels [:div.tl-labels
;; Empty label space takes a row back out to the top of the open symbol. ;; Empty label space takes a row back out to the top of the open symbol.
{:on-click (fn [^js e] {:on-click (fn [^js e]
(when (= (.-target e) (.-currentTarget e)) (when (= (.-target e) (.-currentTarget e))
(rf/dispatch [::ui/aim nil]))) (rf/dispatch [::ui/select nil])))
:on-drag-over (fn [^js e] :on-drag-over (fn [^js e]
(when (drag/row) (when (drag/row)
(.preventDefault e) (.preventDefault e)
@ -1202,12 +1201,13 @@
(doall (for [row visible] (doall (for [row visible]
(with-meta (if (= :section (:kind row)) (with-meta (if (= :section (:kind row))
[:div.tl-label.tl-section (:label row)] [:div.tl-label.tl-section (:label row)]
[label-cell row selection selection-set target over solo tracing renaming draft]) [label-cell row selection selection-set target-path
over solo tracing renaming draft])
{:key (str (:path row))})))] {:key (str (:path row))})))]
[:div.tl-tracks [:div.tl-tracks
{:on-click (fn [^js e] {:on-click (fn [^js e]
(when (= (.-target e) (.-currentTarget e)) (when (= (.-target e) (.-currentTarget e))
(rf/dispatch [::ui/aim nil]))) (rf/dispatch [::ui/select nil])))
:on-drag-enter (fn [^js e] (when (drag/accepts?) (.preventDefault e))) :on-drag-enter (fn [^js e] (when (drag/accepts?) (.preventDefault e)))
:on-drag-over (fn [^js e] :on-drag-over (fn [^js e]
(when (drag/accepts?) (when (drag/accepts?)
@ -1265,7 +1265,8 @@
[:div.tl-track.tl-section] [:div.tl-track.tl-section]
[track-cell row frames sliding hint [track-cell row frames sliding hint
{:clip clip :store store :open open {:clip clip :store store :open open
:selection selection :selections selections}]) :selection selection :selections selections
:target-path target-path}])
{:key (str (:path row))}))) {:key (str (:path row))})))
[:div.tl-empty "nothing in this symbol"]) [:div.tl-empty "nothing in this symbol"])
[playhead :div.tl-playhead frames]]] [playhead :div.tl-playhead frames]]]

View file

@ -78,9 +78,9 @@
:disabled? (not selected?) :on-click #(rf/dispatch [::ui/copy])} :disabled? (not selected?) :on-click #(rf/dispatch [::ui/copy])}
{:label "cut" :sub "Cut selection · ⌘/Ctrl X" {:label "cut" :sub "Cut selection · ⌘/Ctrl X"
:disabled? (not selected?) :on-click #(rf/dispatch [::ui/cut])} :disabled? (not selected?) :on-click #(rf/dispatch [::ui/cut])}
{:label "paste" :sub "Paste at playhead into the aimed place · ⌘/Ctrl V" {:label "paste" :sub "Paste at playhead into the active row · ⌘/Ctrl V"
:disabled? (nil? clipboard) :on-click #(rf/dispatch [::ui/paste])} :disabled? (nil? clipboard) :on-click #(rf/dispatch [::ui/paste])}
{:label "duplicate" :sub "Duplicate here; lanes repeat forward · ⌘/Ctrl D" {:label "duplicate" :sub "Duplicate beside selection; lanes repeat forward · ⌘/Ctrl D"
:disabled? (not selected?) :on-click #(rf/dispatch [::ui/duplicate])} :disabled? (not selected?) :on-click #(rf/dispatch [::ui/duplicate])}
{:label "duplicate unique" {:label "duplicate unique"
:sub "Duplicate with a private nested symbol graph · ⇧⌘/Ctrl D" :sub "Duplicate with a private nested symbol graph · ⇧⌘/Ctrl D"

View file

@ -51,6 +51,52 @@
(is (= [4 8] (node/placed-span (get-in made [:symbols :main :nodes id])))) (is (= [4 8] (node/placed-span (get-in made [:symbols :main :nodes id]))))
(is (empty? (clip/problems made))))) (is (empty? (clip/problems made)))))
(deftest multi-paste-has-one-destination-and-preserves-root-timing
(let [doc (update-in (fixture/document) [:symbols :main] dissoc :display)
payload (:clipboard
(clipboard/snapshot doc [(address :main :a [:a])
(address :main :b [:b])]))
r (clipboard/paste doc payload :main 20 {:fresh-id (ids)})
made (:clip r)
[a2 b2] (:roots r)]
(is (= [20 24] (node/placed-span (get-in made [:symbols :main :nodes a2]))))
(is (= [24 28] (node/placed-span (get-in made [:symbols :main :nodes b2]))))
(is (= 2 (count (:roots r))))
(is (empty? (clip/problems made)))))
(deftest multi-paste-into-a-lane-claims-all-intervals-atomically
(let [doc (fixture/document)
payload (:clipboard
(clipboard/snapshot doc [(address :main :a [:a])
(address :main :insert [:insert])]))
r (clipboard/paste doc payload :main 4 {:fresh-id (ids)})
made (:clip r)
[a2 insert2] (:roots r)]
(is (= [4 8] (node/placed-span (get-in made [:symbols :main :nodes a2]))))
(is (= [12 16] (node/placed-span (get-in made [:symbols :main :nodes insert2]))))
(is (nil? (get-in made [:symbols :main :nodes :b])))
(is (= [8 12] (node/placed-span (get-in made [:symbols :main :nodes :insert]))))
(is (= 16 (get-in made [:symbols :main :frames])))
(is (empty? (clip/problems made)))))
(deftest overlapping-multi-paste-into-a-lane-is-all-or-nothing
(let [base (fixture/document)
doc (-> base
(assoc-in [:symbols :source]
{:id :source :frames 8
:nodes {:a (get-in base [:symbols :main :nodes :a])
:b (assoc-in (get-in base [:symbols :main :nodes :b])
[:time :at] 2)}})
(assoc-in [:symbols :empty-lane]
{:id :empty-lane :frames 8 :display :lane :nodes {}}))
payload (:clipboard
(clipboard/snapshot doc [(address :source :a [:a])
(address :source :b [:b])]))
r (clipboard/paste doc payload :empty-lane 0 {:fresh-id (ids)})]
(is (= "overlapping copied things cannot be pasted into one lane" (:refused r)))
(is (nil? (:clip r)))
(is (= {} (get-in doc [:symbols :empty-lane :nodes])))))
(deftest lane-duplicate-is-the-forward-repeat-and-ripples-later-cels (deftest lane-duplicate-is-the-forward-repeat-and-ripples-later-cels
(let [doc (fixture/document) (let [doc (fixture/document)
payload (:clipboard payload (:clipboard

View file

@ -1,6 +1,7 @@
(ns arthur.events.lane-test (ns arthur.events.lane-test
(:require [cljs.test :refer [deftest is]] (:require [cljs.test :refer [deftest is]]
[arthur.domain.clip :as clip] [arthur.domain.clip :as clip]
[arthur.domain.creation :as creation]
[arthur.domain.history :as history] [arthur.domain.history :as history]
[arthur.domain.leaf :as leaf] [arthur.domain.leaf :as leaf]
[arthur.domain.node :as node] [arthur.domain.node :as node]
@ -42,22 +43,61 @@
(node/placed-span (get-in fitted [:symbols :main :nodes instance-id]))) (node/placed-span (get-in fitted [:symbols :main :nodes instance-id])))
"the lane follows a later change to its parent's extent") "the lane follows a later change to its parent's extent")
(is (= 300 (clip/frames fitted lane-id)))) (is (= 300 (clip/frames fitted lane-id))))
(is (some? (get-in lane-db [:ui :target])) (is (= :lane (:kind (creation/target document {} :main
"the new lane is aimed so drawing and pool drops can go into it")))) (get-in lane-db [:ui :selection]) 6)))
"the new lane is the active creation row"))))
(deftest aiming-the-root-clears-selection-and-drawing-target (deftest a-new-symbol-on-a-lane-is-a-one-frame-cel
(let [doc (fixture/document)
id (store/install! {:clip doc :store {}} "one-frame-new-symbol")]
(reset! rf-db/app-db {:clip/current id :paint/revision 0
:ui {:open :main
:selection [:node :main nil []]}
:playback {:frame 5}})
(rf/dispatch-sync [::ui/new-symbol :inside])
(let [db @rf-db/app-db
saved (:clip (store/entry id))
[_ sid instance-id] (get-in db [:ui :selection])
instance (get-in saved [:symbols sid :nodes instance-id])
source (node/source instance)]
(is (= :main sid))
(is (= [5 6] (node/placed-span instance)))
(is (= 1 (clip/frames saved source)))
(is (every? (fn [other]
(or (= instance-id (:id other))
(let [[a b] (node/placed-span other)]
(or (<= b 5) (<= 6 a)))))
(filter node/placed-span
(vals (get-in saved [:symbols :main :nodes]))))
"claiming the frame leaves no overlapping cel"))))
(deftest a-new-lane-uses-the-symbol-selected-at-the-playhead
(let [doc (clip/blank)
id (store/install! {:clip doc :store {}} "nested-new-lane")]
(reset! rf-db/app-db {:clip/current id :paint/revision 0
:ui {:open :main} :playback {:frame 6}})
(rf/dispatch-sync [::ui/new-symbol :inside])
(let [parent-instance (nth (get-in @rf-db/app-db [:ui :selection]) 2)
parent-sid (node/source (get-in (:clip (store/entry id))
[:symbols :main :nodes parent-instance]))]
(rf/dispatch-sync [::ui/new-lane])
(let [db @rf-db/app-db
saved (:clip (store/entry id))
[_ host lane-instance] (get-in db [:ui :selection])
lane-sid (node/source (get-in saved [:symbols host :nodes lane-instance]))]
(is (= parent-sid host) "the lane is created inside the selected symbol")
(is (= :lane (get-in saved [:symbols lane-sid :display])))
(is (= [0 (clip/frames saved host)]
(node/placed-span (get-in saved [:symbols host :nodes lane-instance]))))
(is (nil? (get-in saved [:symbols :main :nodes lane-instance]))
"it does not bypass the target and land in the tab root")))))
(deftest selecting-the-root-clears-the-active-row
(let [db {:ui {:selection [:node :main :shape [:lane :shape]] (let [db {:ui {:selection [:node :main :shape [:lane :shape]]
:target {:sid :main :id :lane :path [:lane]}}} :selections [[:node :main :shape [:lane :shape]]]}}
after (ui/aimed db nil)]
(is (nil? (get-in after [:ui :selection])))
(is (nil? (get-in after [:ui :target])))))
(deftest clearing-selection-also-clears-the-creation-target
(let [db {:ui {:selection [:symbol :drawing]
:target {:sid :main :id :lane :path [:lane]}}}
after (ui/selected db nil)] after (ui/selected db nil)]
(is (nil? (get-in after [:ui :selection]))) (is (nil? (get-in after [:ui :selection])))
(is (nil? (get-in after [:ui :target]))))) (is (empty? (get-in after [:ui :selections])))))
(deftest an-explicit-lane-is-one-row-of-clips (deftest an-explicit-lane-is-one-row-of-clips
(let [doc (fixture/document) (let [doc (fixture/document)
@ -111,7 +151,34 @@
(is (= 12 (ui/selection-frame doc nil :main (is (= 12 (ui/selection-frame doc nil :main
[:node :main :a [:a]] 12))))) [:node :main :a [:a]] 12)))))
(deftest paste-targeting-continues-an-instance-clock-past-its-window (deftest creation-walks-to-the-nearest-valid-parent-at-the-playhead
(let [doc (-> (fixture/document)
(assoc-in [:symbols :shot]
{:id :shot :frames 12 :fps 24
:nodes {:girl {:id :girl :kind :instance :z "a"
:span [0 12] :time {:mode :map :at 0 :rate 1}
:source {:symbol :main}
:playback {:in 0 :speed 1 :end :stop}}}})
(assoc-in [:symbols :outer]
{:id :outer :frames 30 :fps 24
:nodes {:take {:id :take :kind :instance :z "a"
:span [0 12] :time {:mode :map :at 10 :rate 1}
:source {:symbol :shot}
:playback {:in 0 :speed 1 :end :stop}}}}))
selection [:node :main :a [:take :girl :a]]
at #(select-keys (creation/target doc nil :outer selection %)
[:kind :sid :path :frame])]
(is (= {:kind :symbol :sid :drawing-a :path [:take :girl :a] :frame 0}
(at 12))
"the selected cel is the parent while every enclosing occurrence is active")
(is (= {:kind :lane :sid :main :path [:take :girl] :frame 5}
(at 15))
"past the cel, its containing lane is the nearest valid insertion surface")
(is (= {:kind :symbol :sid :outer :path [] :frame 25}
(at 25))
"a lane behind an inactive parent occurrence is not valid")))
(deftest creation-walks-out-of-an-instance-past-its-window
(let [doc {:fps 24 :width 20 :height 20 (let [doc {:fps 24 :width 20 :height 20
:symbols :symbols
{:main {:id :main :frames 40 {:main {:id :main :frames 40
@ -121,16 +188,40 @@
:playback {:in 0 :speed 1 :end :stop}}}} :playback {:in 0 :speed 1 :end :stop}}}}
:inside {:id :inside :frames 10 :nodes {}}}} :inside {:id :inside :frames 10 :nodes {}}}}
db {:ui {:open :main db {:ui {:open :main
:target {:sid :main :id :drawing :path [:drawing]}} :selection [:node :main :drawing [:drawing]]}
:playback {:frame 20}}] :playback {:frame 20}}]
(is (= {:sid :inside :path [:drawing] :frame 20} (is (= {:sid :main :path [] :frame 20}
(ui/paste-destination db doc nil)) (select-keys (ui/paste-destination db doc nil) [:sid :path :frame]))
"pasting outside a target symbol's window authors cropped content"))) "an inactive selected symbol falls back to the open symbol")))
(deftest multi-paste-uses-one-current-primary-target-and-selects-the-batch
(let [doc (fixture/document)
id (store/install! {:clip doc :store {}} "multi-paste-target")
a [:node :main :a [:a]]
insert [:node :main :insert [:insert]]]
(reset! rf-db/app-db {:clip/current id :paint/revision 0
:ui {:open :main :selection insert :selections [a insert]}
:playback {:frame 4}})
(rf/dispatch-sync [::ui/copy])
;; Changing selection after copy changes the destination, not the payload.
(rf/dispatch-sync [::ui/select [:node :main nil []]])
(rf/dispatch-sync [::ui/paste])
(let [db @rf-db/app-db
saved (:clip (store/entry id))
selections (get-in db [:ui :selections])
[a2 insert2] (map #(nth % 2) selections)]
(is (= 2 (count selections)))
(is (= (peek selections) (get-in db [:ui :selection]))
"the last pasted root is the new primary creation anchor")
(is (= [4 8] (node/placed-span (get-in saved [:symbols :main :nodes a2]))))
(is (= [12 16] (node/placed-span (get-in saved [:symbols :main :nodes insert2]))))
(is (= 1 (count (get-in (store/entry id) [:history :done])))
"the complete batch is one edit"))))
(deftest polygon-landing-obeys-the-destination-symbol-mode (deftest polygon-landing-obeys-the-destination-symbol-mode
(let [lane (fixture/document) (let [lane (fixture/document)
ordinary (update-in lane [:symbols :main] dissoc :display) ordinary (update-in lane [:symbols :main] dissoc :display)
db {:ui {:open :main :target {:sid :main :id :plate :path [:plate]}} db {:ui {:open :main :selection [:node :main :plate [:plate]]}
:playback {:frame 5}}] :playback {:frame 5}}]
(is (true? (:lane? (ui/polygon-landing lane {} db)))) (is (true? (:lane? (ui/polygon-landing lane {} db))))
(is (false? (:lane? (ui/polygon-landing ordinary {} db)))))) (is (false? (:lane? (ui/polygon-landing ordinary {} db))))))
@ -148,6 +239,21 @@
(is (nil? (get-in saved [:symbols :main :display]))) (is (nil? (get-in saved [:symbols :main :display])))
(is (nil? (get-in (store/entry (:clip/current after)) [:history :done]))))) (is (nil? (get-in (store/entry (:clip/current after)) [:history :done])))))
(deftest drawing-with-the-lane-row-active-creates-a-new-cel
(let [doc (fixture/document)
id (store/install! {:clip doc :store {}} "draw-new-lane-cel")
db {:clip/current id :paint/revision 0
:ui {:open :main :selection [:node :main nil []]}
:playback {:frame 1}}
after (ui/beginning-polygon db)
[_ sid cel-id] (get-in after [:ui :selection])
saved (:clip (store/entry id))]
(is (= :main sid))
(is (not= :a cel-id) "the occupied cel is not reused")
(is (= [1 2] (node/placed-span (get-in saved [:symbols sid :nodes cel-id]))))
(is (= 1 (clip/frames saved (node/source (get-in saved [:symbols sid :nodes cel-id])))))
(is (= :polygon (get-in after [:ui :tool])))))
(deftest sequence-commands-use-isolated-history-transactions (deftest sequence-commands-use-isolated-history-transactions
(let [doc (fixture/document) (let [doc (fixture/document)
id (store/install! {:clip doc :store {}} "sequence-command-test") id (store/install! {:clip doc :store {}} "sequence-command-test")
@ -184,24 +290,22 @@
(is (= [:vo] (mapv :id (:cels lane)))) (is (= [:vo] (mapv :id (:cels lane))))
(is (empty? (timeline/sound-rows doc :main #{}))))) (is (empty? (timeline/sound-rows doc :main #{})))))
(deftest aiming-the-open-lanes-own-row-is-a-selection-and-not-a-crash (deftest selecting-the-open-lanes-own-row-is-the-lane-target
;; `rows` names the open symbol's own lane row `[:node sid nil []]`, and ;; `rows` names the open symbol's own lane row `[:node sid nil []]`, and
;; `selected` used to open the rows above a selection with `(pop path)` — ;; `selected` used to open the rows above a selection with `(pop path)` —
;; which THROWS on `[]`, aborting the whole event. Nothing was selected, ;; which THROWS on `[]`, aborting the whole event. Nothing was selected,
;; nothing was aimed, and the next polygon went wherever the stale target ;; nothing was selected, and the next polygon used stale creation state.
;; still pointed, which is most of what made aiming a lane feel random.
(let [doc (fixture/document) (let [doc (fixture/document)
id (store/install! {:clip doc :store {}} "aim-the-open-lane") id (store/install! {:clip doc :store {}} "select-the-open-lane")
db {:clip/current id :paint/revision 0 db {:clip/current id :paint/revision 0
:ui {:open :main :target {:sid :main :id :a :path [:a]}} :ui {:open :main}
:playback {:frame 0}} :playback {:frame 0}}
lane (first (filter :lane? (timeline/rows doc :main #{}))) lane (first (filter :lane? (timeline/rows doc :main #{})))
after (ui/aimed db (:select lane))] after (ui/selected db (:select lane))]
(is (= [:node :main nil []] (:select lane))) (is (= [:node :main nil []] (:select lane)))
(is (= [:node :main nil []] (get-in after [:ui :selection]))) (is (= [:node :main nil []] (get-in after [:ui :selection])))
(is (nil? (get-in after [:ui :target])) (is (= :lane (:kind (creation/target doc {} :main
"the open symbol IS the place, so aiming its own row clears the target") (get-in after [:ui :selection]) 0))))
(is (= [] (ui/where-new-goes doc after)))
(is (empty? (get-in after [:ui :expanded])) (is (empty? (get-in after [:ui :expanded]))
"and there are no rows above the top to open"))) "and there are no rows above the top to open")))
@ -229,7 +333,7 @@
id (store/install! {:clip doc :store {}} "finish-opens-nothing") id (store/install! {:clip doc :store {}} "finish-opens-nothing")
db {:clip/current id :paint/revision 0 db {:clip/current id :paint/revision 0
:ui {:open :main :tool :polygon :ui {:open :main :tool :polygon
:target {:sid :main :id :a :path [:a]} :selection [:node :main :a [:a]]
:draft [10 10 40 10 40 40]} :draft [10 10 40 10 40 40]}
:playback {:frame 1}}] :playback {:frame 1}}]
(reset! rf-db/app-db db) (reset! rf-db/app-db db)
@ -237,7 +341,7 @@
(let [after @rf-db/app-db (let [after @rf-db/app-db
[kind sid shape-id path] (get-in after [:ui :selection])] [kind sid shape-id path] (get-in after [:ui :selection])]
(is (= :node kind)) (is (= :node kind))
(is (= :drawing-a sid) "the shape went into the drawing the aimed clip places") (is (= :drawing-a sid) "the shape went into the drawing the selected clip places")
(is (= [:a shape-id] path)) (is (= [:a shape-id] path))
(is (empty? (get-in after [:ui :expanded]))) (is (empty? (get-in after [:ui :expanded])))
(is (nil? (get-in after [:ui :tool])))))) (is (nil? (get-in after [:ui :tool]))))))
@ -260,7 +364,7 @@
:playback {:in 0 :speed 1 :end :stop}}}})) :playback {:in 0 :speed 1 :end :stop}}}}))
id (store/install! {:clip doc :store {}} "clip-selected-where-it-lives") id (store/install! {:clip doc :store {}} "clip-selected-where-it-lives")
db {:clip/current id :paint/revision 0 db {:clip/current id :paint/revision 0
:ui {:open :shot :target {:sid :shot :id :girl :path [:girl]}} :ui {:open :shot :selection [:node :shot :girl [:girl]]}
:playback {:frame 9}} :playback {:frame 9}}
after (ui/beginning-polygon db) after (ui/beginning-polygon db)
[_ sid clip-id path] (get-in after [:ui :selection]) [_ sid clip-id path] (get-in after [:ui :selection])

View file

@ -120,6 +120,11 @@ try {
assert.equal(await evaluate('document.querySelectorAll(".tl-track:not(.palette)").length'), 1, assert.equal(await evaluate('document.querySelectorAll(".tl-track:not(.palette)").length'), 1,
'an ordinary symbol is an ordinary timeline row'); 'an ordinary symbol is an ordinary timeline row');
// The new symbol is now the active creation parent. Return to the tab root
// to ask for a sibling lane in `main`; leaving it selected would correctly
// create the lane inside that symbol.
await evaluate(`re_frame.core.dispatch_sync(cljs.core.vector(
cljs.core.keyword('arthur.events.ui/select'), null))`);
await clickNew('lane'); await clickNew('lane');
s = await shot(); s = await shot();
placed = mainInstances(s); placed = mainInstances(s);
@ -178,8 +183,11 @@ try {
`the dropped clip remains in the explicit lane: ${JSON.stringify(s)}`); `the dropped clip remains in the explicit lane: ${JSON.stringify(s)}`);
assert.equal(laneSymbols(s).length, 1, 'the drop creates no extra lane'); assert.equal(laneSymbols(s).length, 1, 'the drop creates no extra lane');
// A second explicit lane is a sibling in the open symbol even though the // Return to the root active row, then make a sibling lane there. Lane
// first remains aimed. Move the clip between their linear tracks. // creation follows the selected target; the unit suite separately asserts
// that leaving a symbol selected creates the lane inside that symbol.
await evaluate(`re_frame.core.dispatch_sync(cljs.core.vector(
cljs.core.keyword('arthur.events.ui/select'), null))`);
await clickNew('lane'); await clickNew('lane');
s = await shot(); s = await shot();
assert.equal(laneSymbols(s).length, 2, 'a second explicit command creates a second lane'); assert.equal(laneSymbols(s).length, 2, 'a second explicit command creates a second lane');
@ -239,7 +247,7 @@ try {
await shortcut('z', 'KeyZ'); await shortcut('z', 'KeyZ');
assert.equal(mainInstances(await shot()).length, 2, 'one undo restores the cut'); assert.equal(mainInstances(await shot()).length, 2, 'one undo restores the cut');
await evaluate(`re_frame.core.dispatch_sync(cljs.core.vector( await evaluate(`re_frame.core.dispatch_sync(cljs.core.vector(
cljs.core.keyword('arthur.events.ui/aim'), null))`); cljs.core.keyword('arthur.events.ui/select'), null))`);
await sleep(80); await sleep(80);
await shortcut('v', 'KeyV'); await shortcut('v', 'KeyV');
assert.equal(mainInstances(await shot()).length, 3, 'paste uses the copied snapshot'); assert.equal(mainInstances(await shot()).length, 3, 'paste uses the copied snapshot');

View file

@ -501,6 +501,17 @@ async function main() {
// the authored node, the raster preview and the document round trip. // the authored node, the raster preview and the document round trip.
await page.eval(SEEK(0)); await page.eval(SEEK(0));
await sleep(150); await sleep(150);
// A lane row means "new one-frame cel". This test authors drawing keys
// across the existing take, so make that cel the active row first; creation
// then enters the symbol it places, exactly as a person clicking its block.
await page.eval(`(() => {
const cel = document.querySelector('.tl-cel');
if (!cel?.arthurCel?.select) return false;
re_frame.core.dispatch_sync(cljs.core.vector(
cljs.core.keyword('arthur.events.ui/select'), cel.arthurCel.select));
return true;
})()`);
await sleep(150);
const beforePaint = await page.eval(`(() => { const beforePaint = await page.eval(`(() => {
const c = document.querySelector('canvas.stage'); const c = document.querySelector('canvas.stage');
const d = c.getContext('2d').getImageData(25, 20, 1, 1).data; const d = c.getContext('2d').getImageData(25, 20, 1, 1).data;
@ -513,7 +524,7 @@ async function main() {
// A person cannot click twice inside one microtask; a test should not either. // A person cannot click twice inside one microtask; a test should not either.
const armed = await page.eval(`(() => { const armed = await page.eval(`(() => {
const button = [...document.querySelectorAll('.palette-bar button')] const button = [...document.querySelectorAll('.palette-bar button')]
.find(b => b.textContent === 'polygon'); .find(b => b.textContent === 'pen');
if (!button) return false; if (!button) return false;
button.click(); button.click();
return true; return true;

View file

@ -40,21 +40,16 @@
--sel: #2f6fc0; --sel: #2f6fc0;
--sel-bg: #cfe0f5; --sel-bg: #cfe0f5;
/* A SECOND accent, and the one exception to the rule above — stated here /* A SECOND accent because selection and effective creation target are two
because SELECTED and AIMED are two different facts that are often true of facts that are often true of one node at the same time.
one node at the same time, and one accent cannot say both.
Selected is what you are LOOKING at: the inspector's subject, the thing
with handles on it. Aimed is where a new drawing or symbol would GO. They
used to be the same state, which is how drawing a polygon ended up in
whatever you had last clicked on the stage.
Violet rather than another blue: in the chrome it has to be told apart Violet rather than another blue: in the chrome it has to be told apart
from --sel, and on the stage from the warm gold the handles are drawn in. from --sel, and on the stage from the warm gold the handles are drawn in.
Colour is not the only channel either — the aim outline is SOLID where the Colour is not the only channel either — the target outline is SOLID where the
selection's box is dashed, and it carries no handles at all, because there selection's box is dashed, and it carries no handles at all, because there
is nothing about being aimed at that you could drag. --aim-stage is the is nothing about being a creation parent that you could drag. --target-stage is the
same hue lifted to read against --stage, which is the one dark surface. */ same hue lifted to read against --stage, which is the one dark surface. */
--aim: #8d4bd6; --target: #8d4bd6;
--aim-stage: #c79bf2; --target-stage: #c79bf2;
/* A THIRD, and the last. SELECTED IN THE TIMELINE had been --sel-bg, which is /* A THIRD, and the last. SELECTED IN THE TIMELINE had been --sel-bg, which is
#cfe0f5 against spans drawn in #dfe8f4: two pale blues a hair apart, in the #cfe0f5 against spans drawn in #dfe8f4: two pale blues a hair apart, in the
@ -62,13 +57,13 @@
is showing — an expanded lane opens exactly the selected clip. The fix is is showing — an expanded lane opens exactly the selected clip. The fix is
not a stronger blue, which stays in the family the spans already own, but a not a stronger blue, which stays in the family the spans already own, but a
different hue entirely. Ochre: warm against every cool surface in the pane, different hue entirely. Ochre: warm against every cool surface in the pane,
clear of --aim's violet, --live's green and the playhead's red, and dark clear of --target's violet, --live's green and the playhead's red, and dark
enough at full strength to carry white type on a 21px block. */ enough at full strength to carry white type on a 21px block. */
--pick: #a85c00; --pick: #a85c00;
--pick-bg: #ffd9a0; --pick-bg: #ffd9a0;
--pick-fg: #4a2800; --pick-fg: #4a2800;
/* Auto-key is a recording state, distinct from selection and aiming. */ /* Auto-key is a recording state, distinct from selection and creation target. */
--live: #239447; --live: #239447;
--live-bg: #e2f5e7; --live-bg: #e2f5e7;
@ -1085,10 +1080,18 @@ button.share-button:hover, button.share-button.on { filter: brightness(1.1); }
color: var(--pick-fg); color: var(--pick-fg);
} }
.tl-label.on .kind { color: var(--pick); } .tl-label.on .kind { color: var(--pick); }
.tl-label.aimed { box-shadow: inset 0 0 0 2px var(--aim); } .tl-label.target { box-shadow: inset 0 0 0 2px var(--target); }
/* Both at once, and they must still be two readable facts: the aim's ring sits /* Both at once, and they must still be two readable facts: the target ring sits
inside the pick's edge rather than replacing it. */ inside the pick's edge rather than replacing it. */
.tl-label.on.aimed { box-shadow: inset 3px 0 0 var(--pick), inset 0 0 0 2px var(--aim); } .tl-label.on.target { box-shadow: inset 3px 0 0 var(--pick), inset 0 0 0 2px var(--target); }
.tl-track.target::after {
content: "";
position: absolute;
inset: 0;
border: 2px solid var(--target);
pointer-events: none;
z-index: 4;
}
.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); }
@ -1213,6 +1216,7 @@ button.share-button:hover, button.share-button.on { filter: brightness(1.1); }
} }
.tl-cel.on:hover:not(:disabled) { background: var(--pick); filter: brightness(1.12); } .tl-cel.on:hover:not(:disabled) { background: var(--pick); filter: brightness(1.12); }
.tl-span.on { background: var(--pick-bg); border-color: var(--pick); } .tl-span.on { background: var(--pick-bg); border-color: var(--pick); }
.tl-cel.target, .tl-span.target { outline: 2px solid var(--target); outline-offset: -2px; }
.tl-span.on.dense { .tl-span.on.dense {
background: repeating-linear-gradient( background: repeating-linear-gradient(
-45deg, var(--pick-bg) 0 3px, #f0bf77 3px 6px); -45deg, var(--pick-bg) 0 3px, #f0bf77 3px 6px);
@ -1397,14 +1401,14 @@ button.share-button:hover, button.share-button.on { filter: brightness(1.1); }
.paint-overlay .handles .pivot { stroke: #fff1be; stroke-width: 0.6; pointer-events: none; } .paint-overlay .handles .pivot { stroke: #fff1be; stroke-width: 0.6; pointer-events: none; }
.paint-overlay .marquee { fill: rgba(230, 202, 139, 0.12); stroke: #e6ca8b; stroke-width: 0.6; stroke-dasharray: 2 1; pointer-events: none; } .paint-overlay .marquee { fill: rgba(230, 202, 139, 0.12); stroke: #e6ca8b; stroke-width: 0.6; stroke-dasharray: 2 1; pointer-events: none; }
/* The aim outline and its name tag. Solid, no handles, and a tag pinned just /* The creation-target outline and its name tag. Solid, no handles, and a tag pinned just
outside the bottom-right corner — see `ui/stage/aim-box` for why the box outside the bottom-right corner — see `ui/stage/creation-box` for why the box
rotates with the node and the tag does not. Sizes are in user units because rotates with the node and the tag does not. Sizes are in user units because
this SVG's viewBox is the stage's 320x200 scaled by `zoom`. */ this SVG's viewBox is the stage's 320x200 scaled by `zoom`. */
.paint-overlay .aim { pointer-events: none; } .paint-overlay .creation-target { pointer-events: none; }
.paint-overlay .aim .aim-box { fill: none; stroke: var(--aim-stage); stroke-width: 0.7; } .paint-overlay .creation-target .creation-target-box { fill: none; stroke: var(--target-stage); stroke-width: 0.7; }
.paint-overlay .aim .aim-tag-bg { fill: var(--aim-stage); } .paint-overlay .creation-target .creation-target-tag-bg { fill: var(--target-stage); }
.paint-overlay .aim .aim-tag { .paint-overlay .creation-target .creation-target-tag {
fill: #1b1030; fill: #1b1030;
font: 600 3.4px ui-sans-serif, system-ui, sans-serif; font: 600 3.4px ui-sans-serif, system-ui, sans-serif;
letter-spacing: 0.04px; letter-spacing: 0.04px;