From da1f1ee90ccdb8e9b12e3204bc7af721e6f6d5cd Mon Sep 17 00:00:00 2001 From: vanelsas <58037137+avanelsas@users.noreply.github.com> Date: Wed, 29 Apr 2026 15:10:45 +0200 Subject: [PATCH 01/25] Multi-select: shift-click, marquee, batch delete :selection moves from a single {:id ...} map to a vector of node ids. Pure helpers (selected-ids, selected?, single-selected-id) plus effectful select-one!, select-clear!, select-toggle! cover the call sites; single-node consumers (resize handles, nudge, inspector, inline edit) route through single-selected-id and degrade gracefully under multi-select. The selection overlay becomes a pool of N borders, one per selected DOM id; the primary overlay carries the resize handles and only exposes them when exactly one node is selected. Shift-click on the canvas or in the layers panel toggles a node in the selection; pointerdown on empty canvas (or padding) starts a marquee that paints a rectangle and adds every overlapped node id on release, with Shift extending the existing selection. Esc cancels mid-drag. ops/remove-many is the batch counterpart to ops/remove; the :delete shortcut now drops the entire selection in one commit, idempotent against cascaded removals and root. In edit mode the canvas-host gets user-select: none so Shift-click and marquee-drag don't paint a native text-selection band over the rendered preview; .bareforge-inline-edit re-enables user-select: text so the inline text editor works as before. Co-Authored-By: Claude Opus 4.7 (1M context) --- public/index.html | 23 +++ src/bareforge/dnd/drag.cljs | 186 +++++++++++++++++++++-- src/bareforge/doc/ops.cljs | 16 ++ src/bareforge/render/selection.cljs | 193 ++++++++++++++++-------- src/bareforge/state.cljs | 60 +++++++- src/bareforge/storage/project_file.cljs | 2 +- src/bareforge/ui/inspector.cljs | 48 ++++-- src/bareforge/ui/layers.cljs | 21 ++- src/bareforge/ui/palette.cljs | 16 +- src/bareforge/ui/shortcuts.cljs | 29 ++-- test/bareforge/doc/ops_test.cljs | 30 ++++ test/bareforge/state_test.cljs | 61 +++++++- test/bareforge/ui/inspector_test.cljs | 31 +++- test/bareforge/ui/palette_test.cljs | 10 +- 14 files changed, 595 insertions(+), 131 deletions(-) diff --git a/public/index.html b/public/index.html index 76f93bf..52325cc 100644 --- a/public/index.html +++ b/public/index.html @@ -111,6 +111,20 @@ overflow: auto; position: relative; } + /* In edit mode, suppress native text selection inside the canvas: + Shift-click and marquee-drag would otherwise paint a browser + text-selection band over the rendered preview, fighting our own + multi-select. Inline text editing has its own textarea overlay, + which we re-enable below. Preview mode leaves selection untouched + so users can interact with their built page normally. */ + .chrome[data-mode="edit"] .canvas-host { + user-select: none; + -webkit-user-select: none; + } + .bareforge-inline-edit { + user-select: text; + -webkit-user-select: text; + } .bareforge-selection-overlay { position: absolute; pointer-events: none; @@ -157,6 +171,15 @@ .bareforge-selection-handle[data-handle="s"] { bottom: -5px; left: 50%; margin-left: -5px; cursor: s-resize; } .bareforge-selection-handle[data-handle="sw"] { bottom: -5px; left: -5px; cursor: sw-resize; } .bareforge-selection-handle[data-handle="w"] { top: 50%; left: -5px; margin-top: -5px; cursor: w-resize; } + .bareforge-marquee { + position: absolute; + pointer-events: none; + z-index: 99; + border: 1px dashed var(--x-color-primary, #4f46e5); + background: rgba(79, 70, 229, 0.08); + } + .bareforge-marquee[data-hidden] { display: none; } + .chrome[data-mode="preview"] .bareforge-marquee { display: none; } .bareforge-inline-edit { position: absolute; z-index: 101; diff --git a/src/bareforge/dnd/drag.cljs b/src/bareforge/dnd/drag.cljs index 9516ebf..decb626 100644 --- a/src/bareforge/dnd/drag.cljs +++ b/src/bareforge/dnd/drag.cljs @@ -39,7 +39,7 @@ ;; --- mutable drag state --------------------------------------------------- (defonce ^:private drag-state - #js {:phase :idle ;; :idle | :armed | :dragging + #js {:phase :idle ;; :idle | :armed | :dragging | :marquee-armed | :marquee :source-tag nil ;; palette drag: tag name to insert :source-node-id nil ;; canvas drag: existing node id to move :start-x 0 @@ -55,7 +55,10 @@ :canvas-el nil :free-drag? false ;; true when source is a :free node :free-initial-x 0 - :free-initial-y 0}) + :free-initial-y 0 + :marquee-el nil ;; the rectangle div drawn during a marquee + :marquee-additive? false ;; Shift-held at marquee start: extend selection + }) (def ^:private move-threshold 4) @@ -143,6 +146,124 @@ (unchecked-set drag-state "slot-target-name" slot-name) (unchecked-set drag-state "slot-target-el" row-el)))) +;; --- marquee selection --------------------------------------------------- + +;; `start-marquee!` is paired with the existing palette/canvas drag +;; starters and must reset state through `cleanup!`, which is defined +;; below alongside the other public-API helpers. Forward-declare so +;; the marquee block stays self-contained. +(declare cleanup!) + +(defn- marquee-el [] (unchecked-get drag-state "marquee-el")) +(defn- set-marquee-el! [el] (unchecked-set drag-state "marquee-el" el)) +(defn- marquee-additive? [] (unchecked-get drag-state "marquee-additive?")) +(defn- set-marquee-additive! [v] (unchecked-set drag-state "marquee-additive?" (boolean v))) + +(defn- canvas-content-coord + "Convert a (clientX, clientY) point to the canvas host's content + coordinate space — i.e. the same space overlays use, accounting + for scroll. Returns `[x y]`." + [^js host client-x client-y] + (let [^js br (.getBoundingClientRect host)] + [(+ (- client-x (.-left br)) (.-scrollLeft host)) + (+ (- client-y (.-top br)) (.-scrollTop host))])) + +(defn- ensure-marquee-el! + "Lazily create the rectangle div on first move. Lives inside the + canvas host so it scrolls in lockstep with the rendered tree, and + uses the same content-coord space as the selection overlay pool." + [] + (or (marquee-el) + (let [^js host (canvas-el) + ^js m (js/document.createElement "div")] + (.setAttribute m "class" "bareforge-marquee") + (.appendChild host m) + (set-marquee-el! m) + m))) + +(defn- update-marquee-rect! + "Paint the rectangle for the current cursor position." + [^js e] + (let [^js host (canvas-el) + [x0 y0] (canvas-content-coord host (start-x) (start-y)) + [x1 y1] (canvas-content-coord host (.-clientX e) (.-clientY e)) + left (min x0 x1) + top (min y0 y1) + width (js/Math.abs (- x1 x0)) + height (js/Math.abs (- y1 y0)) + ^js m (ensure-marquee-el!)] + (set! (.. m -style -left) (str left "px")) + (set! (.. m -style -top) (str top "px")) + (set! (.. m -style -width) (str width "px")) + (set! (.. m -style -height) (str height "px")))) + +(defn- clear-marquee-el! [] + (when-let [^js m (marquee-el)] + (when-let [^js p (.-parentNode m)] + (.removeChild p m))) + (set-marquee-el! nil)) + +(defn- rects-overlap? + "Pure: AABB intersection test on `{:left :top :right :bottom}` maps." + [a b] + (and (< (:left a) (:right b)) + (> (:right a) (:left b)) + (< (:top a) (:bottom b)) + (> (:bottom a) (:top b)))) + +(defn- marquee-hits + "Walk every `[data-bareforge-id]` element under the canvas host and + return the raw DOM ids of those whose bounding box overlaps the + marquee rectangle. Skips root and skips the canvas-host itself. + Stable in document order." + [^js host marquee-rect] + (let [^js br (.getBoundingClientRect host) + sl (.-scrollLeft host) + st (.-scrollTop host) + ^js nodes (.querySelectorAll host "[data-bareforge-id]") + out (volatile! [])] + (dotimes [i (.-length nodes)] + (let [^js el (.item nodes i) + id (.getAttribute el "data-bareforge-id")] + (when (and id (not= id "root")) + (let [^js eb (.getBoundingClientRect el) + left (+ (- (.-left eb) (.-left br)) sl) + top (+ (- (.-top eb) (.-top br)) st) + rect {:left left + :top top + :right (+ left (.-width eb)) + :bottom (+ top (.-height eb))}] + (when (rects-overlap? marquee-rect rect) + (vswap! out conj id)))))) + @out)) + +(defn- commit-marquee! [^js e] + (let [^js host (canvas-el) + [x0 y0] (canvas-content-coord host (start-x) (start-y)) + [x1 y1] (canvas-content-coord host (.-clientX e) (.-clientY e)) + rect {:left (min x0 x1) :top (min y0 y1) + :right (max x0 x1) :bottom (max y0 y1)} + hits (marquee-hits host rect) + existing (if (marquee-additive?) + (state/selected-ids @state/app-state) + []) + merged (vec (distinct (concat existing hits)))] + (state/set-selection! merged))) + +(defn- start-marquee! + "Arm a marquee selection from a pointerdown that landed on the canvas + root (no node hit). Stashes whether Shift was held so the eventual + commit either replaces or extends the existing selection — same + convention as Figma / Webflow." + [^js e] + (when-not (= :idle (phase)) + (cleanup!)) + (set-source-tag! nil) + (set-source-node-id! nil) + (start-xy! (.-clientX e) (.-clientY e)) + (set-marquee-additive! (.-shiftKey e)) + (set-phase! :marquee-armed)) + ;; --- ghost element -------------------------------------------------------- (defn- make-ghost [tag] @@ -336,11 +457,10 @@ (and tid (#{:before :after} pos)) (or (before-after-target doc tid pos) ;; Root has no parent; fall back to inside. - (palette/insertion-target doc {:id tid})) + (palette/insertion-target doc tid)) :else - (let [sel (when tid {:id tid})] - (palette/insertion-target doc sel))))) + (palette/insertion-target doc tid)))) (defn- update-free-drag-position! "Visual feedback during a :free drag. Uses CSS transform so we @@ -415,7 +535,7 @@ {doc' :doc id :id} (ops/insert-new doc parent-id slot index tag overrides)] (state/commit! doc') - (state/set-selection! {:id id})))) + (state/select-one! id)))) (defn- commit-move! [^js _e] (when (valid?) @@ -428,7 +548,7 @@ (try (let [doc' (ops/move doc src-id parent-id slot index)] (state/commit! doc') - (state/set-selection! {:id src-id})) + (state/select-one! src-id)) (catch :default _ nil))))) ;; --- public API ----------------------------------------------------------- @@ -438,6 +558,8 @@ (clear-target-highlight!) (clear-slot-target!) (slot-strips/hide!) + (clear-marquee-el!) + (set-marquee-additive! false) (set-valid! false) (set-target-position! nil) (set-phase! :idle) @@ -513,6 +635,16 @@ (do (position-ghost! (ghost) e) (resolve-target! e))) + :marquee-armed + (let [dx (- (.-clientX e) (start-x)) + dy (- (.-clientY e) (start-y))] + (when (>= (+ (* dx dx) (* dy dy)) (* move-threshold move-threshold)) + (set-phase! :marquee) + (update-marquee-rect! e))) + + :marquee + (update-marquee-rect! e) + nil)) (defn on-up! [^js e] @@ -527,7 +659,8 @@ :armed (let [tag (source-tag) - node-id (source-node-id)] + node-id (source-node-id) + shift? (.-shiftKey e)] (cleanup!) (cond ;; Palette tap (no movement) → insert at current selection. @@ -537,7 +670,23 @@ ;; `__seed` suffix) so the selection overlay can ;; highlight the specific clicked element; doc-lookup ;; sites (inspector, shortcuts) canonicalise on read. - node-id (state/set-selection! {:id node-id}))) + ;; Shift-tap toggles membership for multi-select; plain + ;; tap collapses the selection back to a single id. + node-id (if shift? + (state/select-toggle! node-id) + (state/select-one! node-id)))) + + :marquee + (do (commit-marquee! e) + (cleanup!)) + + :marquee-armed + ;; Tap on empty canvas with no movement: clear selection unless + ;; Shift was held (additive marquee with no rectangle is a no-op). + (let [additive? (marquee-additive?)] + (cleanup!) + (when-not additive? + (state/select-clear!))) nil)) @@ -550,20 +699,25 @@ bubble phase; this one uses capture (see `install-window-listeners!`) so the drag layer always wins the race for a Escape-during-drag." [^js e] - (when (and (contains? #{:armed :dragging} (phase)) + (when (and (contains? #{:armed :dragging :marquee-armed :marquee} (phase)) (= "Escape" (.-key e))) (.stopImmediatePropagation e) (cancel!))) (defn- on-canvas-pointerdown! [^js e] ;; Delegate: any pointerdown inside the canvas host starts a drag on - ;; the nearest bareforge-rendered ancestor. Root is not draggable. - ;; Preview mode disables drag entirely so native click / pointer - ;; events flow through to the user's own components. + ;; the nearest bareforge-rendered ancestor. A pointerdown that + ;; resolves to root, or that lands on the canvas padding (no + ;; bareforge-rendered ancestor at all), starts a marquee selection + ;; instead — root is not draggable, and the padding is conceptually + ;; empty space. Preview mode disables both drag and marquee entirely + ;; so native click / pointer events flow through to the user's own + ;; components. (when (not= :preview (:mode @state/app-state)) - (when-let [id (canvas/element->node-id (.-target e))] - (when (not= id "root") - (start-from-canvas! e id))))) + (let [id (canvas/element->node-id (.-target e))] + (cond + (or (nil? id) (= id "root")) (start-marquee! e) + :else (start-from-canvas! e id))))) (defn install-window-listeners! "Install the pointermove, pointerup, pointercancel, and keydown diff --git a/src/bareforge/doc/ops.cljs b/src/bareforge/doc/ops.cljs index 3ce4ddb..698f64c 100644 --- a/src/bareforge/doc/ops.cljs +++ b/src/bareforge/doc/ops.cljs @@ -56,6 +56,22 @@ slot-path* (vec (butlast node-path))] (update-in doc slot-path* vec-remove idx))))) +(defn remove-many + "Remove every id in `ids` (and their subtrees). Idempotent against + ids that disappear partway through (e.g. removing a parent + cascades-remove its descendants — a later id in the same set just + becomes a no-op). Skips root and skips already-missing paths so + the op is safe to feed an unfiltered selection vector." + [doc ids] + (reduce (fn [d id] + (let [p (m/path-to d id)] + (cond + (nil? p) d + (= p [:root]) d + :else (remove d id)))) + doc + ids)) + (defn move "Move an existing node to a new parent/slot/idx. Moving a node under its own subtree is an error. Note: when moving within the same slot, the diff --git a/src/bareforge/render/selection.cljs b/src/bareforge/render/selection.cljs index 2d92f4f..ff68d42 100644 --- a/src/bareforge/render/selection.cljs +++ b/src/bareforge/render/selection.cljs @@ -1,25 +1,28 @@ (ns bareforge.render.selection - "Canvas selection overlay. A thin, absolutely-positioned `
` - sibling of the rendered tree inside the canvas host is repositioned - on every selection / document change to draw a 1 px border *around* - the currently-selected element. The overlay never modifies the - selected element's own CSS, so it cannot interact with the user's - design (no box-shadow inflation, no layout shift). + "Canvas selection overlay. A pool of thin, absolutely-positioned + `
` siblings of the rendered tree inside the canvas host is + repositioned on every selection / document change to draw a 1 px + border *around* every currently-selected element. Overlays never + modify the selected elements' own CSS, so they cannot interact + with the user's design (no box-shadow inflation, no layout shift). Coordinates are computed relative to the canvas host's padding box, - which is also the containing block for the overlay (canvas-host has - `position: relative`). Because the overlay is a child of the same + which is also the containing block for the overlays (canvas-host has + `position: relative`). Because overlays are children of the same scrollable container as the rendered content, scrolling moves both in lockstep — no scroll listener is needed. - The overlay also renders 8 resize handles as children. Which - handles are interactive depends on the selected node's placement: - `:free` nodes get the full 8 (updates `:layout :x :y :w :h`); - `:flow` nodes get only E/S/SE (grow the width/height axis, - updating `:layout :width :height` as CSS length strings); - `:background` nodes and the root get none. Live visual feedback - writes directly to the element's inline style during drag, then - a single commit at pointerup keeps the undo history clean." + The first overlay in the pool (the 'primary') also carries 8 + resize handles as children. Which handles are interactive depends + on the selected node's placement: `:free` nodes get the full 8 + (updates `:layout :x :y :w :h`); `:flow` nodes get only E/S/SE + (grow the width/height axis, updating `:layout :width :height` as + CSS length strings); `:background` nodes and the root get none. + Resize is single-select only: when more than one node is selected + the primary overlay's `data-resize-mode` attribute is cleared so + the handles stay hidden. Live visual feedback writes directly to + the element's inline style during drag, then a single commit at + pointerup keeps the undo history clean." (:require [bareforge.doc.model :as m] [bareforge.doc.ops :as ops] [bareforge.render.canvas :as canvas] @@ -107,7 +110,11 @@ ;; --- effectful ----------------------------------------------------------- -(defonce ^:private overlay-state #js {:el nil :host nil}) +;; Pool of overlay elements. The first ('primary') carries the 8 resize +;; handles; secondaries are plain border boxes. Pool grows on demand and +;; never shrinks — extras are simply hidden via `data-hidden` when the +;; selection contracts. +(defonce ^:private overlay-state #js {:host nil :pool #js []}) (defonce ^:private resize-state #js {:active? false @@ -119,8 +126,28 @@ :start-x 0 :start-y 0 :start-w 0 :start-h 0}) -(defn- ^js overlay-el [] (unchecked-get overlay-state "el")) -(defn- ^js host-el [] (unchecked-get overlay-state "host")) +(defn- ^js host-el [] (unchecked-get overlay-state "host")) + +(defn- ^js pool + "Return the overlay pool, lazy-initialising it on first access. The + lazy init survives a shadow-cljs hot-reload that started under an + earlier version of `overlay-state` whose shape lacked the `:pool` + key — without it, refresh! would dereference undefined and the + overlay would silently stop drawing." + [] + (or (unchecked-get overlay-state "pool") + (let [p #js []] + (unchecked-set overlay-state "pool" p) + p))) +(defn- ^js primary-overlay [] + (let [^js p (pool)] + (when (and p (pos? (.-length p))) + (aget p 0)))) + +;; Backwards-compatible alias used by resize-state initialisation +;; below — the resize machinery only ever uses the primary overlay. +(defn- ^js overlay-el [] (primary-overlay)) + (defn- resize-active? [] (unchecked-get resize-state "active?")) (defn- bcr->map [^js r] @@ -141,31 +168,71 @@ (defn- hide! [^js overlay] (.setAttribute overlay "data-hidden" "")) -(defn- selected-node [] - (when-let [sel-id (get-in @state/app-state [:selection :id])] - (m/get-node (:document @state/app-state) sel-id))) +(declare build-handles!) + +(defn- create-overlay! + "Append a fresh overlay element to the host and push it onto the + pool. The first overlay created (`primary?` true) gets the resize + handle children installed." + [primary?] + (let [^js o (js/document.createElement "div")] + (.setAttribute o "class" "bareforge-selection-overlay") + (.setAttribute o "data-hidden" "") + (.appendChild (host-el) o) + (.push (pool) o) + (when primary? (build-handles! o)) + o)) + +(defn- ensure-pool-size! [n] + (let [^js p (pool)] + (while (< (.-length p) n) + (create-overlay! false)))) + +(defn- selection-entries + "Resolve `:selection` to a vector of {:id :el :node} maps for ids + whose DOM element exists. Missing / stale ids are dropped silently + so a render pass mid-edit doesn't flicker the overlay." + [] + (let [doc (:document @state/app-state)] + (into [] + (keep (fn [id] + (when-let [^js el (canvas/dom-for-id id)] + {:id id + :el el + :node (m/get-node doc (canvas/canonical-node-id id))}))) + (state/selected-ids @state/app-state)))) (defn- refresh! - "Read the current selection from app-state, look up its DOM element, - and reposition the overlay (or hide it if nothing valid is selected). - Sets `data-resize-mode` on the overlay to `\"free\"` or `\"flow\"` - so CSS can show the appropriate handle subset; root and background - selections clear the attribute entirely." + "Walk the current selection vector, position one overlay per + resolved DOM element, and hide any pool entries beyond that count. + The primary overlay carries `data-resize-mode` only when exactly + one node is selected and that node's placement supports resize — + otherwise the handles stay hidden via CSS, even though they remain + in the DOM as primary's children." [] - (let [^js overlay (overlay-el) - ^js host (host-el)] - (when (and overlay host) - (let [sel-id (get-in @state/app-state [:selection :id]) - ^js el (canvas/dom-for-id sel-id) - node (selected-node) - mode (when (and node (not= "root" sel-id)) - (resize-mode-for-node node))] - (if el - (do (show! overlay el host) - (if mode - (.setAttribute overlay "data-resize-mode" (name mode)) - (.removeAttribute overlay "data-resize-mode"))) - (hide! overlay)))))) + (let [^js host (host-el)] + (when host + (let [entries (selection-entries) + n (count entries) + single? (= n 1)] + (ensure-pool-size! n) + (let [^js p (pool)] + (dotimes [i n] + (let [{:keys [id el node]} (nth entries i) + ^js o (aget p i) + primary? (zero? i)] + (show! o el host) + (if (and primary? single? (not= "root" id)) + (let [mode (resize-mode-for-node node)] + (if mode + (.setAttribute o "data-resize-mode" (name mode)) + (.removeAttribute o "data-resize-mode"))) + (when primary? (.removeAttribute o "data-resize-mode"))))) + ;; Hide pool entries that are no longer needed. + (loop [i n] + (when (< i (.-length p)) + (hide! (aget p i)) + (recur (inc i))))))))) (declare on-resize-move! on-resize-up!) @@ -245,9 +312,11 @@ (defn- start-resize! [^js e handle-str] - (let [sel-id (get-in @state/app-state [:selection :id]) - node (selected-node) - ^js el (canvas/dom-for-id sel-id) + (let [sel-id (state/single-selected-id @state/app-state) + node (when sel-id + (m/get-node (:document @state/app-state) + (canvas/canonical-node-id sel-id))) + ^js el (when sel-id (canvas/dom-for-id sel-id)) handle (keyword handle-str) mode (when node (resize-mode-for-node node))] (when (and node el mode @@ -316,24 +385,20 @@ (js/requestAnimationFrame refresh!)))) (defn install! - "Mount the selection overlay inside `canvas-host-el`. Creates the - overlay element, appends it to the host, installs a watcher on - `state/app-state` that fires on selection / document changes, and - wires a window resize listener for layout-shift cases. Safe to - call once at app startup." + "Mount the selection overlay pool inside `canvas-host-el`. Seeds the + pool with one primary overlay (handles attached), installs a + watcher on `state/app-state` that fires on selection / document + changes, and wires a window resize listener for layout-shift + cases. Safe to call once at app startup." [^js canvas-host-el] - (let [overlay (js/document.createElement "div")] - (.setAttribute overlay "class" "bareforge-selection-overlay") - (.setAttribute overlay "data-hidden" "") - (.appendChild canvas-host-el overlay) - (unchecked-set overlay-state "el" overlay) - (unchecked-set overlay-state "host" canvas-host-el) - (build-handles! overlay) - (schedule-refresh!) - (add-watch state/app-state ::selection-overlay - (fn [_ _ old-state new-state] - (when (or (not= (:selection old-state) (:selection new-state)) - (not= (:document old-state) (:document new-state))) - (schedule-refresh!)))) - (.addEventListener js/window "resize" - (fn [_] (schedule-refresh!))))) + (unchecked-set overlay-state "host" canvas-host-el) + (unchecked-set overlay-state "pool" #js []) + (create-overlay! true) + (schedule-refresh!) + (add-watch state/app-state ::selection-overlay + (fn [_ _ old-state new-state] + (when (or (not= (:selection old-state) (:selection new-state)) + (not= (:document old-state) (:document new-state))) + (schedule-refresh!)))) + (.addEventListener js/window "resize" + (fn [_] (schedule-refresh!)))) diff --git a/src/bareforge/state.cljs b/src/bareforge/state.cljs index 8bb9722..5fa32ae 100644 --- a/src/bareforge/state.cljs +++ b/src/bareforge/state.cljs @@ -7,7 +7,10 @@ "Pure constructor for a fresh application state." [] {:document (m/empty-document) - :selection nil + ;; Vector of selected node ids. Empty = nothing selected. Multi-select + ;; (Shift-click, marquee) extends this; single-node consumers route + ;; through `single-selected-id` which returns nil under multi-select. + :selection [] :history {:past [] :future []} :mode :edit :theme {:base-preset "ocean" :overrides {}} @@ -115,8 +118,59 @@ [k f & args] (apply swap! app-state update-in [:ui k] f args)) -(defn set-selection! [sel] - (swap! app-state assoc :selection sel)) +;; --- selection helpers (pure) ------------------------------------------- + +(defn selected-ids + "Pure: the current selection as a vector of node ids. Empty vector + when nothing is selected." + [state] + (or (:selection state) [])) + +(defn selected? + "Pure: true iff `id` is in the current selection." + [state id] + (boolean (some #(= id %) (selected-ids state)))) + +(defn single-selected-id + "Pure: the lone selected id, or nil if zero or 2+ are selected. + Single-node consumers (resize handles, nudge, inspector lookup, + inline edit teardown comparison) read selection through this so + they degrade gracefully when the user multi-selects." + [state] + (let [ids (selected-ids state)] + (when (= 1 (count ids)) (first ids)))) + +;; --- selection mutators (effectful) ------------------------------------- + +(defn set-selection! + "Replace the current selection with `ids` (a coll of node ids). nil + or an empty coll clears the selection. Order is preserved — the + most-recently added id ends up last, which is the natural anchor + for future range / shift-extend behaviour." + [ids] + (swap! app-state assoc :selection (vec ids))) + +(defn select-one! + "Replace the selection with a single id, or clear when nil." + [id] + (set-selection! (when id [id]))) + +(defn select-clear! + "Drop the selection entirely. Equivalent to `(set-selection! nil)`." + [] + (set-selection! nil)) + +(defn select-toggle! + "Toggle membership of `id` in the current selection. Adds when + absent, removes when present. Used by Shift-click and the layers + panel's Shift-click row toggle." + [id] + (swap! app-state update :selection + (fn [current] + (let [ids (or current [])] + (if (some #(= id %) ids) + (vec (clojure.core/remove #(= id %) ids)) + (conj (vec ids) id)))))) (defn set-mode! [mode] (swap! app-state assoc :mode mode)) diff --git a/src/bareforge/storage/project_file.cljs b/src/bareforge/storage/project_file.cljs index 43f5e25..e8c4051 100644 --- a/src/bareforge/storage/project_file.cljs +++ b/src/bareforge/storage/project_file.cljs @@ -66,7 +66,7 @@ (-> s (assoc :document (:document parsed)) (assoc :theme (or (:theme parsed) (:theme s))) - (assoc :selection nil) + (assoc :selection []) (assoc-in [:history :past] []) (assoc-in [:history :future] []) (assoc :dirty? false) diff --git a/src/bareforge/ui/inspector.cljs b/src/bareforge/ui/inspector.cljs index ffe5097..a59960a 100644 --- a/src/bareforge/ui/inspector.cljs +++ b/src/bareforge/ui/inspector.cljs @@ -93,17 +93,32 @@ (defn inspector-model "Project app-state into the view model the inspector needs. Returns - nil when no meaningful selection exists. The selection id may be - a template-instance clone's DOM id (`__seed` suffix); canonicalise - before looking it up in the doc so canvas-tap on a clone reveals - the underlying template node." + nil when no meaningful selection exists OR when multiple distinct + nodes are selected (multi-select edits land in M2). The selection + id may be a template-instance clone's DOM id (`__seed` suffix); + canonicalise before looking it up in the doc so canvas-tap on a + clone reveals the underlying template node. + + Returns `{:multi }` when the canonicalised, deduped selection + resolves to more than one doc node — the renderer surfaces a + read-only summary in that case so the panel doesn't simply blank + out and confuse the user." [app-state] - (let [sel-id (get-in app-state [:selection :id]) - doc-id (canvas/canonical-node-id sel-id) - node (when doc-id (m/get-node (:document app-state) doc-id))] - (when node - {:node node - :meta (registry/get-meta (:tag node))}))) + (let [ids (state/selected-ids app-state) + doc-ids (->> ids (map canvas/canonical-node-id) (remove nil?) distinct)] + (cond + (empty? doc-ids) + nil + + (> (count doc-ids) 1) + {:multi (count doc-ids)} + + :else + (let [doc-id (first doc-ids) + node (m/get-node (:document app-state) doc-id)] + (when node + {:node node + :meta (registry/get-meta (:tag node))}))))) ;; --- effectful: editor widgets ------------------------------------------- @@ -895,6 +910,13 @@ (u/el :div {:class "inspector-empty"}) "No component selected. Click a layer or a palette item.")])) +(defn- multi-view [n] + (u/el :div {:class "inspector-empty-state"} + [(u/set-text! + (u/el :div {:class "inspector-empty"}) + (str n " components selected. Multi-select editing lands later — " + "click a single component to edit it."))])) + (defn- widget-value [^js el] (let [v (.-value el)] (if (nil? v) "" v))) @@ -2002,7 +2024,11 @@ (defn- render! [^js host-el model] - (let [sections (if model + (let [sections (cond + (and model (:multi model)) + [(multi-view (:multi model))] + + model (let [doc (:document @state/app-state) all-fields (collect-all-fields doc) t (text-section model) diff --git a/src/bareforge/ui/layers.cljs b/src/bareforge/ui/layers.cljs index f0e385e..5f6b5b7 100644 --- a/src/bareforge/ui/layers.cljs +++ b/src/bareforge/ui/layers.cljs @@ -49,7 +49,11 @@ :style (str "padding-left:" (+ 8 (* depth 14)) "px")} [(u/set-text! (u/el :span {:class "layers-row-label"}) label) (u/set-text! (u/el :span {:class "layers-row-tag"}) tag)])] - (u/on! row :click (fn [_] (state/set-selection! {:id id}))) + (u/on! row :click + (fn [^js e] + (if (.-shiftKey e) + (state/select-toggle! id) + (state/select-one! id)))) ;; Layer rows are also drag sources — pointerdown starts a ;; canvas-existing drag for the row's node so the user can ;; reposition deep in the tree without needing the canvas hit. @@ -61,16 +65,19 @@ (defn- render-tree! [^js host-el doc selection] (let [rows (flatten-tree doc) - ;; Selection stores the raw DOM id so the canvas overlay can + ;; Selection stores raw DOM ids so the canvas overlay can ;; highlight the specific clicked clone; template-instance ;; clones carry a `__seed` suffix. Layer rows key by the - ;; canonical doc id (no suffix), so canonicalise here before - ;; comparing — otherwise clicking a product card or any of - ;; its descendants shows no highlight in the layers panel. - selected-id (canvas/canonical-node-id (:id selection))] + ;; canonical doc id (no suffix), so canonicalise the entire + ;; selection vector and dedupe before deciding which rows to + ;; mark — otherwise clicking a product card (or any of its + ;; descendants) shows no highlight in the layers panel. + selected (into #{} + (comp (map canvas/canonical-node-id) (remove nil?)) + selection)] (.replaceChildren host-el) (doseq [r rows] - (.appendChild host-el (row-el r (= (:id r) selected-id)))))) + (.appendChild host-el (row-el r (contains? selected (:id r))))))) (defn create "Build the layers panel. Installs a single watcher that rebuilds the diff --git a/src/bareforge/ui/palette.cljs b/src/bareforge/ui/palette.cljs index 84ed21e..d74915e 100644 --- a/src/bareforge/ui/palette.cljs +++ b/src/bareforge/ui/palette.cljs @@ -132,10 +132,10 @@ append inside that slot. - Else if a non-container node is selected, insert as a sibling immediately after it (same parent, same slot, index + 1). - - Else append to root's default slot." - [doc selection] - (let [sel-id (:id selection) - sel-node (when sel-id (m/get-node doc sel-id)) + - Else append to root's default slot. Pass nil for `sel-id` when + no single anchor exists (no selection or multi-select)." + [doc sel-id] + (let [sel-node (when sel-id (m/get-node doc sel-id)) container (some-> sel-node :tag container-slot)] (cond (and sel-node container) @@ -155,15 +155,15 @@ "Public tap-to-insert helper shared by the palette's armed-tap path and the dnd layer. Reads the current selection from the state atom, computes an insertion target, inserts, commits, and selects the new - node." + node. Multi-select degrades to root-append (no single anchor)." [tag] (let [doc (:document @state/app-state) - selection (:selection @state/app-state) - {:keys [parent-id slot index]} (insertion-target doc selection) + sel-id (state/single-selected-id @state/app-state) + {:keys [parent-id slot index]} (insertion-target doc sel-id) {doc' :doc new-id :id} (ops/insert-new doc parent-id slot index tag (seed-for-tag tag))] (state/commit! doc') - (state/set-selection! {:id new-id}))) + (state/select-one! new-id))) (defn- palette-item "Palette items have no :click handler any more — taps and drags both diff --git a/src/bareforge/ui/shortcuts.cljs b/src/bareforge/ui/shortcuts.cljs index 64f4d46..39310d0 100644 --- a/src/bareforge/ui/shortcuts.cljs +++ b/src/bareforge/ui/shortcuts.cljs @@ -149,15 +149,19 @@ ;; template-instance previews). Canonicalise before every ;; doc-side lookup so keyboard ops address the template ;; node the user intends, not a synthetic clone id. - sel-id (get-in @state/app-state [:selection :id]) - doc-id (canvas/canonical-node-id sel-id) - node (when doc-id (m/get-node (:document @state/app-state) doc-id))] + ;; Multi-select degrades placement-aware shortcuts (nudge, + ;; selection-id-aware delete) to no-op via single-selected-id + ;; → nil; has-selection? still reflects the broader vector. + single (state/single-selected-id @state/app-state) + doc-id (canvas/canonical-node-id single) + node (when doc-id (m/get-node (:document @state/app-state) doc-id)) + any-sel? (seq (state/selected-ids @state/app-state))] {:key (.-key e) :meta? (or (.-metaKey e) (.-ctrlKey e)) :shift? (.-shiftKey e) :tag-name (some-> t .-tagName) :content-editable? (and t (.-isContentEditable t)) - :has-selection? (some? sel-id) + :has-selection? (boolean any-sel?) :selection-id doc-id :placement (get-in node [:layout :placement]) :text-editing-id (get-in @state/app-state [:ui :text-editing-id])})) @@ -176,7 +180,7 @@ selection change) starts a fresh history entry." [dx dy] (let [sel-id (canvas/canonical-node-id - (get-in @state/app-state [:selection :id])) + (state/single-selected-id @state/app-state)) doc (:document @state/app-state) node (m/get-node doc sel-id) cur-x (or (get-in node [:layout :x]) 0) @@ -219,14 +223,17 @@ "Start a new project? Unsaved changes will be lost.")) (pf/new!))) :delete (do (.preventDefault e) - (let [sel-id (canvas/canonical-node-id - (get-in @state/app-state [:selection :id])) - doc (:document @state/app-state) - doc' (ops/remove doc sel-id)] + (let [doc-ids (->> (state/selected-ids @state/app-state) + (map canvas/canonical-node-id) + distinct + (remove #{"root"}) + vec) + doc (:document @state/app-state) + doc' (ops/remove-many doc doc-ids)] (state/commit! doc') - (state/set-selection! nil))) + (state/select-clear!))) :exit-text-edit (do (.preventDefault e) (inline-edit/teardown!)) - :deselect (do (.preventDefault e) (state/set-selection! nil)) + :deselect (do (.preventDefault e) (state/select-clear!)) nil))) (defn- on-keydown! [^js e] diff --git a/test/bareforge/doc/ops_test.cljs b/test/bareforge/doc/ops_test.cljs index 439c140..b9965e4 100644 --- a/test/bareforge/doc/ops_test.cljs +++ b/test/bareforge/doc/ops_test.cljs @@ -51,6 +51,36 @@ (deftest remove-missing-throws (is (thrown? js/Error (ops/remove (empty-doc) "nope")))) +(deftest remove-many-removes-each-id + (let [d0 (empty-doc) + {d1 :doc id-a :id} (ops/insert-new d0 "root" "default" 0 "x-a") + {d2 :doc id-b :id} (ops/insert-new d1 "root" "default" 1 "x-b") + {d3 :doc id-c :id} (ops/insert-new d2 "root" "default" 2 "x-c") + d4 (ops/remove-many d3 [id-a id-c])] + (is (= [id-b] (mapv :id (get-in d4 [:root :slots "default"]))) + "removed first and last; middle survives"))) + +(deftest remove-many-tolerates-missing-or-already-removed + (let [d0 (empty-doc) + {d1 :doc id :id} (ops/insert-new d0 "root" "default" 0 "x-button") + ;; Removing a parent cascades — descendants in the same set + ;; are no-ops on subsequent passes. + {d2 :doc inner :id}(ops/insert-new d1 id "default" 0 "x-inner") + d3 (ops/remove-many d2 [id inner "ghost"])] + (is (empty? (get-in d3 [:root :slots "default"]))))) + +(deftest remove-many-skips-root + (let [d0 (empty-doc) + d1 (ops/remove-many d0 ["root" "ghost"])] + (is (= d0 d1) + "root is silently skipped — no throw, document unchanged"))) + +(deftest remove-many-empty-coll-is-no-op + (let [d0 (empty-doc) + {d1 :doc} (ops/insert-new d0 "root" "default" 0 "x-button")] + (is (= d1 (ops/remove-many d1 []))) + (is (= d1 (ops/remove-many d1 nil))))) + (deftest move-within-same-slot (let [d0 (empty-doc) {d1 :doc id-a :id} (ops/insert-new d0 "root" "default" 0 "x-a") diff --git a/test/bareforge/state_test.cljs b/test/bareforge/state_test.cljs index abf8bc9..e4c7046 100644 --- a/test/bareforge/state_test.cljs +++ b/test/bareforge/state_test.cljs @@ -140,9 +140,66 @@ (is (= "#4f46e5" (get-in @state/app-state [:theme :overrides "--x-color-primary"]))) (state/assoc-ui! :palette-search "nav") (is (= "nav" (get-in @state/app-state [:ui :palette-search]))) - (state/set-selection! {:id "abc"}) - (is (= {:id "abc"} (:selection @state/app-state))) + (state/select-one! "abc") + (is (= ["abc"] (:selection @state/app-state))) (testing "theme changes do not enter history" (is (= [] (get-in @state/app-state [:history :past]))) (is (= [] (get-in @state/app-state [:history :future])))) (state/reset-state!)) + +;; --- selection helpers --------------------------------------------------- + +(deftest selected-ids-defaults-empty + (is (= [] (state/selected-ids (fresh)))) + (is (= [] (state/selected-ids {})))) + +(deftest selected?-and-single-selected-id + (let [s0 (assoc (fresh) :selection []) + s1 (assoc (fresh) :selection ["a"]) + s2 (assoc (fresh) :selection ["a" "b"])] + (is (false? (state/selected? s0 "a"))) + (is (true? (state/selected? s1 "a"))) + (is (false? (state/selected? s1 "b"))) + (is (true? (state/selected? s2 "b"))) + (is (nil? (state/single-selected-id s0))) + (is (= "a" (state/single-selected-id s1))) + (is (nil? (state/single-selected-id s2)) + "multi-select degrades single-selected-id to nil"))) + +(deftest set-selection!-normalises-to-vector + (state/reset-state!) + (state/set-selection! ["a" "b" "c"]) + (is (= ["a" "b" "c"] (:selection @state/app-state))) + (state/set-selection! '("d" "e")) + (is (= ["d" "e"] (:selection @state/app-state)) + "lists are coerced to vectors") + (state/set-selection! nil) + (is (= [] (:selection @state/app-state)) + "nil clears the selection") + (state/reset-state!)) + +(deftest select-one!-and-clear! + (state/reset-state!) + (state/select-one! "abc") + (is (= ["abc"] (:selection @state/app-state))) + (state/select-clear!) + (is (= [] (:selection @state/app-state))) + (state/select-one! nil) + (is (= [] (:selection @state/app-state)) + "select-one! nil clears the selection") + (state/reset-state!)) + +(deftest select-toggle!-add-and-remove + (state/reset-state!) + (state/select-toggle! "a") + (is (= ["a"] (:selection @state/app-state))) + (state/select-toggle! "b") + (is (= ["a" "b"] (:selection @state/app-state)) + "second toggle conjs onto the existing vector — newest at tail") + (state/select-toggle! "a") + (is (= ["b"] (:selection @state/app-state)) + "toggling a present id removes it") + (state/select-toggle! "b") + (is (= [] (:selection @state/app-state)) + "toggling the last id clears the selection") + (state/reset-state!)) diff --git a/test/bareforge/ui/inspector_test.cljs b/test/bareforge/ui/inspector_test.cljs index f654bed..36fc3bb 100644 --- a/test/bareforge/ui/inspector_test.cljs +++ b/test/bareforge/ui/inspector_test.cljs @@ -65,7 +65,7 @@ (is (nil? (insp/inspector-model (state/initial-state))))) (deftest inspector-model-nil-for-missing-id - (let [s (assoc (state/initial-state) :selection {:id "does-not-exist"})] + (let [s (assoc (state/initial-state) :selection ["does-not-exist"])] (is (nil? (insp/inspector-model s))))) (deftest inspector-model-returns-node-and-meta @@ -73,7 +73,7 @@ {d1 :doc id :id} (ops/insert-new (:document s0) "root" "default" 0 "x-button") s1 (-> s0 (assoc :document d1) - (assoc :selection {:id id})) + (assoc :selection [id])) model (insp/inspector-model s1)] (is (some? model)) (is (= id (get-in model [:node :id]))) @@ -85,8 +85,33 @@ (get-in model [:meta :properties])))))) (deftest inspector-model-works-for-root - (let [s (assoc (state/initial-state) :selection {:id "root"}) + (let [s (assoc (state/initial-state) :selection ["root"]) model (insp/inspector-model s)] (is (some? model)) (is (= "root" (get-in model [:node :id]))) (is (= "x-container" (get-in model [:node :tag]))))) + +(deftest inspector-model-multi-select-returns-multi-marker + (let [s0 (state/initial-state) + {d1 :doc id-a :id} (ops/insert-new (:document s0) "root" "default" 0 "x-button") + {d2 :doc id-b :id} (ops/insert-new d1 "root" "default" 1 "x-card") + s (-> s0 + (assoc :document d2) + (assoc :selection [id-a id-b])) + model (insp/inspector-model s)] + (is (= {:multi 2} model) + "two distinct doc nodes selected → :multi marker, no :node payload"))) + +(deftest inspector-model-collapses-clones-of-one-doc-node + (testing "two raw DOM ids that canonicalise to the same doc node still + count as a single selection — clone-aware overlays don't + confuse the inspector." + (let [s0 (state/initial-state) + {d1 :doc id :id} (ops/insert-new (:document s0) "root" "default" 0 "x-button") + s (-> s0 + (assoc :document d1) + (assoc :selection [id (str id "__seed1")])) + model (insp/inspector-model s)] + (is (some? model)) + (is (nil? (:multi model))) + (is (= id (get-in model [:node :id])))))) diff --git a/test/bareforge/ui/palette_test.cljs b/test/bareforge/ui/palette_test.cljs index c298f22..fbdcc0d 100644 --- a/test/bareforge/ui/palette_test.cljs +++ b/test/bareforge/ui/palette_test.cljs @@ -78,14 +78,14 @@ {d1 :doc id-a :id} (ops/insert-new d0 "root" "default" 0 "x-a") {d2 :doc} (ops/insert-new d1 "root" "default" 1 "x-b")] (is (= {:parent-id "root" :slot "default" :index 1} - (p/insertion-target d2 {:id id-a})) + (p/insertion-target d2 id-a)) "selecting x-a should cause the next insert to land at index 1"))) (deftest insertion-target-with-root-selected-appends-inside-root (let [d0 (m/empty-document) {d1 :doc} (ops/insert-new d0 "root" "default" 0 "x-a")] (is (= {:parent-id "root" :slot "default" :index 1} - (p/insertion-target d1 {:id "root"})) + (p/insertion-target d1 "root")) "root is a container, so clicks with root selected append inside it"))) (deftest insertion-target-container-selected-goes-inside @@ -93,16 +93,16 @@ (let [d0 (m/empty-document) {d1 :doc id-nav :id} (ops/insert-new d0 "root" "default" 0 "x-navbar")] (is (= {:parent-id id-nav :slot "default" :index 0} - (p/insertion-target d1 {:id id-nav}))))) + (p/insertion-target d1 id-nav))))) (testing "selecting an x-card appends inside its default slot" (let [d0 (m/empty-document) {d1 :doc id-card :id} (ops/insert-new d0 "root" "default" 0 "x-card") {d2 :doc} (ops/insert-new d1 id-card "default" 0 "x-typography")] (is (= {:parent-id id-card :slot "default" :index 1} - (p/insertion-target d2 {:id id-card}))))) + (p/insertion-target d2 id-card))))) (testing "selecting a leaf like x-button inserts as a sibling after" (let [d0 (m/empty-document) {d1 :doc} (ops/insert-new d0 "root" "default" 0 "x-card") {d2 :doc id-btn :id} (ops/insert-new d1 "root" "default" 1 "x-button")] (is (= {:parent-id "root" :slot "default" :index 2} - (p/insertion-target d2 {:id id-btn})))))) + (p/insertion-target d2 id-btn)))))) From 93c6b95988af9c078a5c51fe666f93854f1df71f Mon Sep 17 00:00:00 2001 From: vanelsas <58037137+avanelsas@users.noreply.github.com> Date: Wed, 29 Apr 2026 15:16:46 +0200 Subject: [PATCH 02/25] Cmd-D duplicate, Cmd-G wrap-in selection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ops/duplicate deep-clones a node and its subtree with fresh ids, inserting the clone as the next sibling. ops/duplicate-many maps that over a selection in input order, skipping missing/root ids silently so an unfiltered selection vector is safe to feed. ops/wrap-many wraps a set of sibling ids in a fresh container at the position of the lowest-indexed sibling, preserving original document order inside the wrapper's default slot. Refuses to wrap when ids don't share a parent+slot or include root. Cmd-D in shortcuts/dispatch maps to :duplicate; the perform! handler runs duplicate-many over the canonicalised selection and selects the new clones — natural follow-up gesture is to drag the duplicates. Cmd-G maps to [:wrap-in "x-container"]; Cmd-Shift-G maps to :wrap-in-prompt, which uses window.prompt restricted to a small container whitelist (x-container, x-grid, x-card, x-flex). The wrapper becomes the new selection so the user can immediately keep editing it. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/bareforge/doc/ops.cljs | 99 +++++++++++++++++++++ src/bareforge/ui/shortcuts.cljs | 97 ++++++++++++++++++--- test/bareforge/doc/ops_test.cljs | 119 +++++++++++++++++++++++++- test/bareforge/ui/shortcuts_test.cljs | 59 +++++++++++++ 4 files changed, 361 insertions(+), 13 deletions(-) diff --git a/src/bareforge/doc/ops.cljs b/src/bareforge/doc/ops.cljs index 698f64c..a4a4e21 100644 --- a/src/bareforge/doc/ops.cljs +++ b/src/bareforge/doc/ops.cljs @@ -92,6 +92,105 @@ (update-in removed (slot-vec-path pt new-slot) (fnil vec-insert []) new-idx node))) +(defn- assign-fresh-ids + "Pure: walk `node` and its slots subtree, assigning a fresh id to + every node. Returns `[renamed-node next-id-counter]`. Used by + `duplicate` to clone a subtree without violating the document's + id-uniqueness invariant — every id in the cloned subtree comes + from the same `(ids/gen)` stream as a fresh insertion would." + [node next-id] + (let [[new-id next1] (ids/gen next-id) + with-id (assoc node :id new-id)] + (if-let [slots (:slots node)] + (let [[new-slots final-id] + (reduce-kv + (fn [[acc nid] slot-name children] + (let [[chs nid'] (reduce + (fn [[chs cnid] child] + (let [[c' cnid'] (assign-fresh-ids child cnid)] + [(conj chs c') cnid'])) + [[] nid] + children)] + [(assoc acc slot-name chs) nid'])) + [{} next1] + slots)] + [(assoc with-id :slots new-slots) final-id]) + [with-id next1]))) + +(defn duplicate + "Insert a deep clone of `id` (the entire subtree, every descendant + re-ided) as the next sibling. Root cannot be duplicated. Throws + when `id` is missing or refers to root. Returns + `{:doc :id}` with the top-level clone's new id." + [doc id] + (let [info (m/parent-of doc id)] + (when (nil? info) + (throw (ex-info "duplicate: cannot duplicate root or missing node" + {:id id}))) + (let [node (m/get-node doc id) + [renamed next1] (assign-fresh-ids node (:next-id doc 0)) + {:keys [parent-id slot index]} info + parent-path (m/path-to doc parent-id)] + {:doc (-> doc + (assoc :next-id next1) + (update-in (slot-vec-path parent-path slot) + (fnil vec-insert []) (inc index) renamed)) + :id (:id renamed)}))) + +(defn duplicate-many + "Duplicate every id in `ids`, in input order. Returns + `{:doc :ids}` where `:ids` is a vector of the new top-level clone + ids in the same order — the natural new selection after a + multi-duplicate. Ids that no longer exist (or refer to root) are + silently skipped so the op is safe to feed an unfiltered selection + vector." + [doc ids] + (reduce (fn [{:keys [doc ids] :as acc} id] + (let [info (m/parent-of doc id)] + (if (nil? info) + acc + (let [{doc' :doc new-id :id} (duplicate doc id)] + {:doc doc' :ids (conj ids new-id)})))) + {:doc doc :ids []} + ids)) + +(defn wrap-many + "Wrap a set of sibling nodes in a fresh `tag` node, inserted at the + position of the lowest-indexed sibling. The selected ids must + share a parent and slot — otherwise the op is a no-op. Original + document order is preserved inside the wrapper's `default` slot. + Returns `{:doc :id}` where `:id` is the new wrapper's id, or nil + when the op was a no-op." + [doc ids tag] + (let [infos (->> ids + (keep (fn [id] + (when-let [i (m/parent-of doc id)] + (assoc i :child-id id)))) + vec)] + (cond + (empty? infos) + {:doc doc :id nil} + + (not= (count infos) (count ids)) + ;; At least one id was missing or referred to root; refuse to + ;; wrap a partial set rather than silently dropping nodes. + {:doc doc :id nil} + + (not (apply = (map (juxt :parent-id :slot) infos))) + {:doc doc :id nil} + + :else + (let [{:keys [parent-id slot]} (first infos) + ordered (sort-by :index infos) + insert-idx (:index (first ordered)) + {doc' :doc wrap-id :id} + (insert-new doc parent-id slot insert-idx tag) + wrapped (reduce (fn [d [i {child-id :child-id}]] + (move d child-id wrap-id "default" i)) + doc' + (map-indexed vector ordered))] + {:doc wrapped :id wrap-id})))) + (defn- at [doc id] (or (m/path-to doc id) (throw (ex-info "node not found" {:id id})))) diff --git a/src/bareforge/ui/shortcuts.cljs b/src/bareforge/ui/shortcuts.cljs index 39310d0..92a78cd 100644 --- a/src/bareforge/ui/shortcuts.cljs +++ b/src/bareforge/ui/shortcuts.cljs @@ -74,9 +74,10 @@ (defn dispatch "Project a key event into an action. Simple actions return a - keyword (`:undo` `:redo` `:delete` `:deselect` `:exit-text-edit` - `:save` `:open` `:new` `:noop`); parameterized actions return a - vector (`[:nudge dx dy]`). Takes a map shape: + keyword (`:undo` `:redo` `:delete` `:duplicate` `:wrap-in-prompt` + `:deselect` `:exit-text-edit` `:save` `:open` `:new` `:noop`); + parameterized actions return a vector (`[:nudge dx dy]`, + `[:wrap-in tag]`). Takes a map shape: {:key :meta? :shift? :tag-name :content-editable? :has-selection? :selection-id :placement :text-editing-id} @@ -92,7 +93,9 @@ Cmd+S / Cmd+O / Cmd+N map to project-file save / open / new so the File menu in the toolbar is an affordance, not the only - path to these actions." + path to these actions. Cmd+D duplicates the current selection; + Cmd+G wraps it in an x-container, with Cmd+Shift+G prompting + for a wrapper tag from a small whitelist." [{:keys [key meta? shift? has-selection? selection-id placement text-editing-id] :as event}] @@ -113,6 +116,24 @@ (and meta? (= "n" key) (not shift?) (not editable?)) :new + (and meta? (= "d" key) (not shift?) + has-selection? + (not editable?)) + :duplicate + + ;; Cmd-Shift-G first so the more-specific binding wins over Cmd-G. + (and meta? (or (= "G" key) (and shift? (= "g" key))) + has-selection? + (not= "root" selection-id) + (not editable?)) + :wrap-in-prompt + + (and meta? (= "g" key) (not shift?) + has-selection? + (not= "root" selection-id) + (not editable?)) + [:wrap-in "x-container"] + (and (contains? #{"Delete" "Backspace"} key) has-selection? (not= "root" selection-id) @@ -202,14 +223,66 @@ :last-ms now-ms :past-count (count (get-in @state/app-state [:history :past]))}))) +(def ^:private wrap-tag-whitelist + "Tags accepted by Cmd-Shift-G's prompt as a wrapper. Kept tight on + purpose: container components that have a `:default` slot accepting + multiple children. Adding a new tag here requires it to be a real + container in the registry, otherwise wrap-many's reparent step + would fail." + #{"x-container" "x-grid" "x-card" "x-flex"}) + +(defn- selected-doc-ids + "Read the current selection, canonicalise each id, dedupe, and drop + `\"root\"`. Used by every multi-id action (delete / duplicate / + wrap) so they all see the same logical id set." + [] + (->> (state/selected-ids @state/app-state) + (map canvas/canonical-node-id) + distinct + (remove #{"root"}) + (remove nil?) + vec)) + +(defn- duplicate! [] + (let [ids (selected-doc-ids)] + (when (seq ids) + (let [doc (:document @state/app-state) + {doc' :doc new-ids :ids} (ops/duplicate-many doc ids)] + (state/commit! doc') + (state/set-selection! new-ids))))) + +(defn- wrap-in! [tag] + (let [ids (selected-doc-ids)] + (when (and (contains? wrap-tag-whitelist tag) (seq ids)) + (let [doc (:document @state/app-state) + {doc' :doc wrap-id :id} (ops/wrap-many doc ids tag)] + (when wrap-id + (state/commit! doc') + (state/select-one! wrap-id)))))) + +(defn- prompt-wrap-tag + "Prompt the user for a wrapper tag, restricted to `wrap-tag-whitelist`. + Returns the chosen tag string, or nil when the user cancels or + types something off-list." + [] + (let [input (js/window.prompt + "Wrap selection in: x-container, x-grid, x-card, x-flex" + "x-container")] + (when (and (string? input) + (contains? wrap-tag-whitelist input)) + input))) + (defn- perform! [action ^js e] (cond (vector? action) (let [[op & args] action] (case op - :nudge (let [[dx dy] args] - (.preventDefault e) - (nudge! dx dy)))) + :nudge (let [[dx dy] args] + (.preventDefault e) + (nudge! dx dy)) + :wrap-in (let [[tag] args] + (.preventDefault e) + (wrap-in! tag)))) :else (case action @@ -223,15 +296,15 @@ "Start a new project? Unsaved changes will be lost.")) (pf/new!))) :delete (do (.preventDefault e) - (let [doc-ids (->> (state/selected-ids @state/app-state) - (map canvas/canonical-node-id) - distinct - (remove #{"root"}) - vec) + (let [doc-ids (selected-doc-ids) doc (:document @state/app-state) doc' (ops/remove-many doc doc-ids)] (state/commit! doc') (state/select-clear!))) + :duplicate (do (.preventDefault e) (duplicate!)) + :wrap-in-prompt (do (.preventDefault e) + (when-let [tag (prompt-wrap-tag)] + (wrap-in! tag))) :exit-text-edit (do (.preventDefault e) (inline-edit/teardown!)) :deselect (do (.preventDefault e) (state/select-clear!)) nil))) diff --git a/test/bareforge/doc/ops_test.cljs b/test/bareforge/doc/ops_test.cljs index b9965e4..5af2a4a 100644 --- a/test/bareforge/doc/ops_test.cljs +++ b/test/bareforge/doc/ops_test.cljs @@ -1,5 +1,5 @@ (ns bareforge.doc.ops-test - (:require [cljs.test :refer [deftest is]] + (:require [cljs.test :refer [deftest is testing]] [clojure.spec.alpha :as s] [bareforge.doc.model :as m] [bareforge.doc.ops :as ops] @@ -81,6 +81,123 @@ (is (= d1 (ops/remove-many d1 []))) (is (= d1 (ops/remove-many d1 nil))))) +;; --- duplicate / duplicate-many ----------------------------------------- + +(deftest duplicate-inserts-clone-as-next-sibling + (let [d0 (empty-doc) + {d1 :doc id :id} (ops/insert-new d0 "root" "default" 0 "x-button") + {d2 :doc new-id :id} (ops/duplicate d1 id)] + (is (= [id new-id] + (mapv :id (get-in d2 [:root :slots "default"]))) + "clone lands at index+1 as a sibling") + (is (not= id new-id) "clone has a fresh id") + (is (= "x-button" (get-in d2 [:root :slots "default" 1 :tag]))))) + +(deftest duplicate-clones-subtree-with-fresh-ids + (let [d0 (empty-doc) + {d1 :doc id :id} (ops/insert-new d0 "root" "default" 0 "x-card") + {d2 :doc child :id} (ops/insert-new d1 id "default" 0 "x-button") + {d3 :doc clone :id} (ops/duplicate d2 id) + clone-node (m/get-node d3 clone) + clone-child (first (get-in clone-node [:slots "default"]))] + (is (not= clone child) "descendant ids are fresh too") + (is (= "x-button" (:tag clone-child)) "clone preserves child tags") + (is (= 4 (:next-id d3)) + ":next-id advances by the size of the cloned subtree"))) + +(deftest duplicate-root-throws + (is (thrown? js/Error (ops/duplicate (empty-doc) "root")))) + +(deftest duplicate-missing-throws + (is (thrown? js/Error (ops/duplicate (empty-doc) "ghost")))) + +(deftest duplicate-many-clones-each-in-input-order + (let [d0 (empty-doc) + {d1 :doc id-a :id} (ops/insert-new d0 "root" "default" 0 "x-a") + {d2 :doc id-b :id} (ops/insert-new d1 "root" "default" 1 "x-b") + {doc' :doc new-ids :ids} (ops/duplicate-many d2 [id-a id-b])] + (is (= 2 (count new-ids))) + (is (every? string? new-ids)) + (is (= 4 (count (get-in doc' [:root :slots "default"]))) + "each duplicate landed alongside its original") + (testing ":ids matches input order" + (let [tags-by-id (into {} (map (juxt :id :tag)) + (get-in doc' [:root :slots "default"]))] + (is (= "x-a" (tags-by-id (first new-ids)))) + (is (= "x-b" (tags-by-id (second new-ids)))))))) + +(deftest duplicate-many-skips-missing + (let [d0 (empty-doc) + {d1 :doc id :id} (ops/insert-new d0 "root" "default" 0 "x-button") + {ids :ids} (ops/duplicate-many d1 [id "ghost" "root"])] + (is (= 1 (count ids)) + "ghost and root are silently skipped"))) + +(deftest duplicate-many-empty-is-no-op + (let [d0 (empty-doc) + {doc' :doc ids :ids} (ops/duplicate-many d0 [])] + (is (= d0 doc')) + (is (= [] ids)))) + +;; --- wrap-many ---------------------------------------------------------- + +(deftest wrap-many-wraps-siblings-in-new-container + (let [d0 (empty-doc) + {d1 :doc id-a :id} (ops/insert-new d0 "root" "default" 0 "x-a") + {d2 :doc id-b :id} (ops/insert-new d1 "root" "default" 1 "x-b") + {d3 :doc id-c :id} (ops/insert-new d2 "root" "default" 2 "x-c") + {doc' :doc wrap :id} (ops/wrap-many d3 [id-a id-c] "x-container") + root-children (get-in doc' [:root :slots "default"])] + (is (some? wrap)) + (testing "wrapper sits at the lowest-index sibling's old position" + (is (= [wrap id-b] (mapv :id root-children)))) + (testing "wrapped children move into the wrapper's default slot" + (let [wrap-node (m/get-node doc' wrap)] + (is (= [id-a id-c] (mapv :id (get-in wrap-node [:slots "default"])))) + (is (= "x-container" (:tag wrap-node))))))) + +(deftest wrap-many-preserves-document-order-inside-wrapper + (let [d0 (empty-doc) + {d1 :doc id-a :id} (ops/insert-new d0 "root" "default" 0 "x-a") + {d2 :doc id-b :id} (ops/insert-new d1 "root" "default" 1 "x-b") + {d3 :doc id-c :id} (ops/insert-new d2 "root" "default" 2 "x-c") + ;; Selection arrived in reverse order; the op should still + ;; reorder children by original index inside the wrapper. + {doc' :doc wrap :id} (ops/wrap-many d3 [id-c id-a id-b] "x-container")] + (is (= [id-a id-b id-c] + (mapv :id (get-in (m/get-node doc' wrap) [:slots "default"])))))) + +(deftest wrap-many-single-id-still-wraps + (let [d0 (empty-doc) + {d1 :doc id :id} (ops/insert-new d0 "root" "default" 0 "x-button") + {doc' :doc wrap :id} (ops/wrap-many d1 [id] "x-container")] + (is (some? wrap)) + (is (= [wrap] (mapv :id (get-in doc' [:root :slots "default"])))) + (is (= [id] (mapv :id (get-in (m/get-node doc' wrap) [:slots "default"])))))) + +(deftest wrap-many-rejects-non-siblings + (let [d0 (empty-doc) + {d1 :doc id-a :id} (ops/insert-new d0 "root" "default" 0 "x-card") + {d2 :doc id-inner :id} (ops/insert-new d1 id-a "default" 0 "x-button") + ;; id-a is a direct child of root; id-inner is nested under id-a. + ;; Different parents → wrap-many is a no-op. + {doc' :doc wrap :id} (ops/wrap-many d2 [id-a id-inner] "x-container")] + (is (nil? wrap)) + (is (= d2 doc') "document unchanged when ids don't share a parent"))) + +(deftest wrap-many-rejects-when-any-id-is-root + (let [d0 (empty-doc) + {d1 :doc id :id} (ops/insert-new d0 "root" "default" 0 "x-button") + {doc' :doc wrap :id} (ops/wrap-many d1 [id "root"] "x-container")] + (is (nil? wrap)) + (is (= d1 doc')))) + +(deftest wrap-many-empty-is-no-op + (let [d0 (empty-doc) + {doc' :doc wrap :id} (ops/wrap-many d0 [] "x-container")] + (is (nil? wrap)) + (is (= d0 doc')))) + (deftest move-within-same-slot (let [d0 (empty-doc) {d1 :doc id-a :id} (ops/insert-new d0 "root" "default" 0 "x-a") diff --git a/test/bareforge/ui/shortcuts_test.cljs b/test/bareforge/ui/shortcuts_test.cljs index f415d12..ad940a6 100644 --- a/test/bareforge/ui/shortcuts_test.cljs +++ b/test/bareforge/ui/shortcuts_test.cljs @@ -219,6 +219,65 @@ (is (= :noop (k/dispatch (assoc free-selected :key "ArrowLeft" :meta? true)))))) +;; --- duplicate ---------------------------------------------------------- + +(def ^:private with-selection + (assoc base + :has-selection? true + :selection-id "n_3")) + +(deftest dispatch-cmd-d-duplicates + (is (= :duplicate + (k/dispatch (assoc with-selection :key "d" :meta? true))))) + +(deftest dispatch-cmd-d-noop-without-selection + (is (= :noop + (k/dispatch (assoc base :key "d" :meta? true))))) + +(deftest dispatch-cmd-d-ignored-in-editable + (is (= :noop + (k/dispatch (assoc with-selection :key "d" :meta? true + :tag-name "INPUT"))))) + +(deftest dispatch-cmd-shift-d-is-noop + (testing "Cmd+Shift+D is reserved (browser bookmark variants); + we only bind plain Cmd+D" + (is (= :noop + (k/dispatch (assoc with-selection :key "d" :meta? true :shift? true)))))) + +;; --- wrap-in ------------------------------------------------------------ + +(deftest dispatch-cmd-g-wraps-in-x-container + (is (= [:wrap-in "x-container"] + (k/dispatch (assoc with-selection :key "g" :meta? true))))) + +(deftest dispatch-cmd-g-noop-without-selection + (is (= :noop + (k/dispatch (assoc base :key "g" :meta? true))))) + +(deftest dispatch-cmd-g-noop-on-root + (testing "wrapping root makes no sense — handled by the dispatch + guard, mirroring the :delete pattern" + (is (= :noop + (k/dispatch (assoc with-selection :key "g" :meta? true + :selection-id "root")))))) + +(deftest dispatch-cmd-g-ignored-in-editable + (is (= :noop + (k/dispatch (assoc with-selection :key "g" :meta? true + :tag-name "X-SEARCH-FIELD"))))) + +(deftest dispatch-cmd-shift-g-prompts + (is (= :wrap-in-prompt + (k/dispatch (assoc with-selection :key "g" :meta? true :shift? true)))) + (is (= :wrap-in-prompt + (k/dispatch (assoc with-selection :key "G" :meta? true :shift? true))))) + +(deftest dispatch-cmd-shift-g-noop-on-root + (is (= :noop + (k/dispatch (assoc with-selection :key "g" :meta? true :shift? true + :selection-id "root"))))) + ;; --- coalesce? ----------------------------------------------------------- (def ^:private last-rec From dffd7b3a0b303cf912beb6509e74d6fa83a5a107 Mon Sep 17 00:00:00 2001 From: vanelsas <58037137+avanelsas@users.noreply.github.com> Date: Wed, 29 Apr 2026 15:26:16 +0200 Subject: [PATCH 03/25] M2.1: drag-to-scrub on numeric inspector rows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Inspector field labels for :number kind editors and the free-coord :layout fields (x/y/w/h) become horizontal drag handles. Pointerdown captures the start value; pointermove computes new = start + dx, optionally x10 with Shift; pointerup releases. The first move of a drag commits normally so the pre-drag state lands in :past, and every subsequent move uses commit-coalesced! so the entire scrub collapses into a single undo entry — the same pattern arrow-key nudge already uses. The mechanism is opt-in: each scrubbable widget builder calls attach-scrub-meta! to stash a {:read-fn :commit-fn! :step} spec on the widget element; the field-row wrapper detects the spec and wires pointer-scrub! on the row's label, adding the is-scrubbable class so CSS can show ew-resize on hover and suppress text selection. Non-numeric rows are unaffected. CSS-length-shaped attributes (width/height/padding/margin) and percentage / rem / em values stay non-scrubbable for now — preserving the suffix while scrubbing needs its own design and is deferred to a follow-up. Free-coord layout fields are clean numbers in the document, so they scrub directly. Co-Authored-By: Claude Opus 4.7 (1M context) --- public/index.html | 9 ++ src/bareforge/ui/inspector.cljs | 171 +++++++++++++++++++++++++++++--- 2 files changed, 165 insertions(+), 15 deletions(-) diff --git a/public/index.html b/public/index.html index 52325cc..f22752d 100644 --- a/public/index.html +++ b/public/index.html @@ -340,6 +340,15 @@ color: var(--x-color-text-muted, rgba(127, 127, 127, 0.7)); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; } + /* Numeric labels become drag-to-scrub handles. The `is-scrubbable` + class is added by `pointer-scrub!` only when the widget exposes + a scrub spec, so non-numeric labels keep their default cursor. */ + .inspector-field-label.is-scrubbable { + cursor: ew-resize; + user-select: none; + -webkit-user-select: none; + touch-action: none; + } .inspector-field-widget { width: 100%; } /* Compact x-color-picker for the Inspector. Halves the main colour-area height and tightens the strip heights + gap so a diff --git a/src/bareforge/ui/inspector.cljs b/src/bareforge/ui/inspector.cljs index a59960a..af27e62 100644 --- a/src/bareforge/ui/inspector.cljs +++ b/src/bareforge/ui/inspector.cljs @@ -153,6 +153,101 @@ (or (some-> e .-detail (.-value)) (some-> e .-target .-value))) +;; --- numeric drag (M2.1) ----------------------------------------------- + +(defn- ^js attach-scrub-meta! + "Stash a scrub spec on a widget element. `field-row` reads it back + to wire a horizontal-drag scrubber on the row's label. Spec map: + `{:read-fn :commit-fn! :step}`. Returns the element for thread- + friendly use in builder pipelines." + [^js el spec] + (set! (.-bareforgeScrub el) spec) + el) + +(defn- read-scrub-meta [^js el] + (when el (.-bareforgeScrub el))) + +(defonce ^:private scrub-state + ;; One global drag-in-flight tracker is enough — only one inspector + ;; row can be scrubbed at a time, and a label captures the pointer + ;; so other handlers don't compete. + #js {:active? false + :start-x 0 + :start-val 0 + :first? true + :pointer-id nil + :label nil + :input nil + :commit-fn nil + :step 1}) + +(defn- on-scrub-move! [^js e] + (when (.-active? scrub-state) + (.preventDefault e) + (let [dx (- (.-clientX e) (.-start-x scrub-state)) + step-px (.-step scrub-state) + mult (if (.-shiftKey e) 10 1) + start-val (.-start-val scrub-state) + new-val (+ start-val (* dx step-px mult)) + ;; Snap to an integer when the step is integer-valued; for + ;; sub-unit steps fall through with the raw float. + rounded (if (zero? (mod step-px 1)) + (js/Math.round new-val) + new-val) + ^js input (.-input scrub-state) + first? (.-first? scrub-state) + commit-fn (.-commit-fn scrub-state)] + (commit-fn rounded first?) + (when input (u/set-attr! input :value (str rounded))) + (when first? (set! (.-first? scrub-state) false))))) + +(defn- end-scrub! [] + (let [^js label (.-label scrub-state) + pid (.-pointer-id scrub-state)] + (when (and label pid) + (try (.releasePointerCapture label pid) (catch :default _ nil))) + (set! (.-active? scrub-state) false) + (set! (.-label scrub-state) nil) + (set! (.-input scrub-state) nil) + (set! (.-commit-fn scrub-state) nil) + (set! (.-pointer-id scrub-state) nil) + (.removeEventListener js/window "pointermove" on-scrub-move!) + (.removeEventListener js/window "pointerup" end-scrub!) + (.removeEventListener js/window "pointercancel" end-scrub!))) + +(defn- pointer-scrub! + "Wire pointerdown on `label-el` so a horizontal drag scrubs the + numeric value of `input-el`. `read-fn` returns the current numeric + value (nil to suppress the gesture). `commit-fn!` is called as + `(new-val first?)`; the first call pushes a fresh history entry + via `state/commit!`, every later call uses `state/commit-coalesced!` + so the whole drag undoes as a single step. Shift multiplies the + step by 10." + [^js label-el ^js input-el read-fn commit-fn! step] + (.. label-el -classList (add "is-scrubbable")) + (u/on! label-el :pointerdown + (fn [^js e] + (let [v (read-fn)] + (when (number? v) + (.preventDefault e) + (try (.setPointerCapture label-el (.-pointerId e)) + (catch :default _ nil)) + (set! (.-active? scrub-state) true) + (set! (.-start-x scrub-state) (.-clientX e)) + (set! (.-start-val scrub-state) v) + (set! (.-first? scrub-state) true) + (set! (.-pointer-id scrub-state) (.-pointerId e)) + (set! (.-label scrub-state) label-el) + (set! (.-input scrub-state) input-el) + (set! (.-commit-fn scrub-state) commit-fn!) + (set! (.-step scrub-state) (or step 1)) + ;; Window-level move/up means the drag survives the + ;; pointer leaving the label, which is the natural + ;; gesture (drag continues until release). + (.addEventListener js/window "pointermove" on-scrub-move!) + (.addEventListener js/window "pointerup" end-scrub!) + (.addEventListener js/window "pointercancel" end-scrub!)))))) + (defn- read-event-checked ^js [^js e] (let [d (.-detail e)] (cond @@ -206,18 +301,37 @@ (defn- build-search-field [node prop spec] (let [transform (:transform prop) + numeric? (= "number" (:type spec)) el (-> (u/el :x-search-field (cond-> {:class "inspector-field-widget"} - (= "number" (:type spec)) (assoc :type "number"))) + numeric? (assoc :type "number"))) (tag-widget! (:name prop) "text" transform)) - current (current-value node prop)] + current (current-value node prop) + node-id (:id node) + attr-name (:name prop)] (when current (u/set-attr! el :value current)) (u/on! el :x-search-field-input (fn [^js e] (let [v (read-event-value e) v (if transform (transform-for-commit transform v) v)] - (commit-attr! (:id node) (:name prop) v)))) - el)) + (commit-attr! node-id attr-name v)))) + (cond-> el + numeric? + (attach-scrub-meta! + {:read-fn (fn [] + (let [doc (:document @state/app-state) + n (m/get-node doc node-id) + raw (current-value n prop)] + (when (and raw (not= "" raw)) + (let [n (js/parseFloat raw)] + (when-not (js/isNaN n) n))))) + :commit-fn! (fn [new-val first?] + (let [doc (:document @state/app-state) + doc' (ops/set-attr doc node-id attr-name (str new-val))] + (if first? + (state/commit! doc') + (state/commit-coalesced! doc')))) + :step 1})))) (defn- build-widget [node prop] (let [spec (editor-spec prop)] @@ -314,19 +428,39 @@ (defn- build-free-coord-field "Numeric/length editor for one of the :layout :x :y :w :h fields. Most useful when the node's placement is :free, but shown - unconditionally so users can pre-fill coordinates before toggling." + unconditionally so users can pre-fill coordinates before toggling. + Free-coord fields always store numbers (the reconciler turns them + into px lengths), so the row is uniformly scrubbable." [node layout-key placeholder] (let [el (-> (u/el :x-search-field {:class "inspector-field-widget" :placeholder placeholder}) (tag-widget! (str "__layout__/" (name layout-key)) "layout")) - raw (get-in node [:layout layout-key])] + raw (get-in node [:layout layout-key]) + node-id (:id node)] (when raw (u/set-attr! el :value (str raw))) (u/on! el :x-search-field-input (fn [^js e] (let [v (read-event-value e)] - (commit-with! ops/set-layout (:id node) layout-key (parse-length-value v))))) - el)) + (commit-with! ops/set-layout node-id layout-key (parse-length-value v))))) + (attach-scrub-meta! + el + {:read-fn (fn [] + (let [doc (:document @state/app-state) + n (get-in (m/get-node doc node-id) + [:layout layout-key])] + (cond + (number? n) n + (string? n) (let [parsed (js/parseFloat n)] + (when-not (js/isNaN parsed) parsed)) + :else nil))) + :commit-fn! (fn [new-val first?] + (let [doc (:document @state/app-state) + doc' (ops/set-layout doc node-id layout-key new-val)] + (if first? + (state/commit! doc') + (state/commit-coalesced! doc')))) + :step 1}))) (defn- build-layout-textarea "Multi-line editor for the free-form `:extra-style` layout field. @@ -696,10 +830,14 @@ container)) (defn- field-row-with-binding [label widget node prop all-fields] - (u/el :div {:class "inspector-field"} - [(u/set-text! (u/el :div {:class "inspector-field-label"}) label) - widget - (build-bind-toggle node prop all-fields)])) + (let [label-el (u/set-text! (u/el :div {:class "inspector-field-label"}) label)] + (when-let [scrub (read-scrub-meta widget)] + (pointer-scrub! label-el widget + (:read-fn scrub) (:commit-fn! scrub) (:step scrub))) + (u/el :div {:class "inspector-field"} + [label-el + widget + (build-bind-toggle node prop all-fields)]))) ;; --- section builders ---------------------------------------------------- @@ -736,9 +874,12 @@ [label-el body-el]))) (defn- field-row [label widget] - (u/el :div {:class "inspector-field"} - [(u/set-text! (u/el :div {:class "inspector-field-label"}) label) - widget])) + (let [label-el (u/set-text! (u/el :div {:class "inspector-field-label"}) label)] + (when-let [scrub (read-scrub-meta widget)] + (pointer-scrub! label-el widget + (:read-fn scrub) (:commit-fn! scrub) (:step scrub))) + (u/el :div {:class "inspector-field"} + [label-el widget]))) (defn- attributes-section [{:keys [node meta]} all-fields] (let [props (:properties meta) From 169d217b47bc6df1f80fd9f703a8d7486e016028 Mon Sep 17 00:00:00 2001 From: vanelsas <58037137+avanelsas@users.noreply.github.com> Date: Wed, 29 Apr 2026 15:31:44 +0200 Subject: [PATCH 04/25] Scrub: default unset numerics to 0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Original M2.1 read-fns returned nil when a numeric attribute was empty, which short-circuited the pointerdown handler's `(when (number? v) ...)` guard — the gesture engaged the cursor hint but nothing else, so dragging an unset min/max/step or a non-:free x/y/w/h field did nothing visible. Both scrub-eligible builders now fall back to 0 when the underlying value is missing or unparseable, so the drag picks up at zero and populates the field as the user drags. Existing numeric values behave exactly as before. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/bareforge/ui/inspector.cljs | 31 ++++++++++++++++++++----------- 1 file changed, 20 insertions(+), 11 deletions(-) diff --git a/src/bareforge/ui/inspector.cljs b/src/bareforge/ui/inspector.cljs index af27e62..21ae657 100644 --- a/src/bareforge/ui/inspector.cljs +++ b/src/bareforge/ui/inspector.cljs @@ -319,12 +319,17 @@ numeric? (attach-scrub-meta! {:read-fn (fn [] - (let [doc (:document @state/app-state) - n (m/get-node doc node-id) - raw (current-value n prop)] - (when (and raw (not= "" raw)) - (let [n (js/parseFloat raw)] - (when-not (js/isNaN n) n))))) + ;; Empty / non-numeric attr starts the drag at 0 + ;; so unset fields are still scrubbable from + ;; nothing — otherwise the gesture would silently + ;; do nothing on a fresh component. + (let [doc (:document @state/app-state) + n (m/get-node doc node-id) + raw (current-value n prop) + parsed (when (and raw (not= "" raw)) + (let [p (js/parseFloat raw)] + (when-not (js/isNaN p) p)))] + (or parsed 0))) :commit-fn! (fn [new-val first?] (let [doc (:document @state/app-state) doc' (ops/set-attr doc node-id attr-name (str new-val))] @@ -446,14 +451,18 @@ (attach-scrub-meta! el {:read-fn (fn [] + ;; Default to 0 when the field is empty so the + ;; gesture engages immediately — useful when + ;; placement is being changed to :free and the + ;; user wants to scrub the new coord into shape. (let [doc (:document @state/app-state) - n (get-in (m/get-node doc node-id) + v (get-in (m/get-node doc node-id) [:layout layout-key])] (cond - (number? n) n - (string? n) (let [parsed (js/parseFloat n)] - (when-not (js/isNaN parsed) parsed)) - :else nil))) + (number? v) v + (string? v) (let [parsed (js/parseFloat v)] + (if (js/isNaN parsed) 0 parsed)) + :else 0))) :commit-fn! (fn [new-val first?] (let [doc (:document @state/app-state) doc' (ops/set-layout doc node-id layout-key new-val)] From f9bb7e8d158a1c41eaad054a5635c244555c3986 Mon Sep 17 00:00:00 2001 From: vanelsas <58037137+avanelsas@users.noreply.github.com> Date: Wed, 29 Apr 2026 15:39:12 +0200 Subject: [PATCH 05/25] M2.2: Cmd-Opt-C / Cmd-Opt-V copy and paste attributes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cmd-Opt-C captures the single-selected node's :attrs + :props into an in-memory clipboard slot under :ui :clipboard :attrs (no history, no DOM clipboard). Cmd-Opt-V applies that snapshot onto every canonicalised id in the current selection, filtered by each target tag's supported attributes — pasting an x-button's `variant` onto an x-card silently drops it instead of stamping an unknown attr. The gesture deliberately lives on Cmd-Opt-C/V instead of plain Cmd-C/V so the browser's native copy/paste keeps working in inspector text fields and the layers panel; the editable-target guard already covers most surfaces, but the alt requirement keeps the canvas gesture out of muscle-memory range of native copy/paste entirely. ops/set-attrs and ops/set-props are the batch counterparts to set- attr / set-prop — nil values dispatch to the unset path so blanket- pasting a sparse clipboard correctly clears keys absent on the source. They're also the foundation for M2.3 multi-select edit. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/bareforge/doc/ops.cljs | 24 +++++++++ src/bareforge/state.cljs | 18 +++++++ src/bareforge/ui/shortcuts.cljs | 74 ++++++++++++++++++++++++--- test/bareforge/doc/ops_test.cljs | 43 ++++++++++++++++ test/bareforge/state_test.cljs | 17 ++++++ test/bareforge/ui/shortcuts_test.cljs | 39 ++++++++++++++ 6 files changed, 208 insertions(+), 7 deletions(-) diff --git a/src/bareforge/doc/ops.cljs b/src/bareforge/doc/ops.cljs index a4a4e21..3f6775f 100644 --- a/src/bareforge/doc/ops.cljs +++ b/src/bareforge/doc/ops.cljs @@ -208,6 +208,30 @@ (defn unset-attr [doc id k] (update-in doc (conj (at doc id) :attrs) dissoc k)) (defn set-prop [doc id k v] (assoc-in doc (conj (at doc id) :props k) v)) (defn unset-prop [doc id k] (update-in doc (conj (at doc id) :props) dissoc k)) + +(defn set-attrs + "Apply a map of attribute name → value to one node in a single + update. Nil values dispatch to `unset-attr` so blanket-pasting a + sparse clipboard clears any keys that were absent on the source. + Used by attribute paste; safe to call with an empty map (no-op)." + [doc id attr-map] + (reduce-kv (fn [d k v] + (if (nil? v) + (unset-attr d id k) + (set-attr d id k v))) + doc + (or attr-map {}))) + +(defn set-props + "Counterpart to `set-attrs` for boolean / JS-side properties stored + under `:props`. Keys are keywords. Nil values unset." + [doc id prop-map] + (reduce-kv (fn [d k v] + (if (nil? v) + (unset-prop d id k) + (set-prop d id k v))) + doc + (or prop-map {}))) (defn set-text [doc id t] (assoc-in doc (conj (at doc id) :text) t)) (defn set-inner-html diff --git a/src/bareforge/state.cljs b/src/bareforge/state.cljs index 5fa32ae..e60110f 100644 --- a/src/bareforge/state.cljs +++ b/src/bareforge/state.cljs @@ -172,6 +172,24 @@ (vec (clojure.core/remove #(= id %) ids)) (conj (vec ids) id)))))) +;; --- attribute clipboard ------------------------------------------------ + +(defn clipboard-attrs + "Pure: read the in-memory attribute clipboard, or nil when nothing + has been copied. Shape: + `{:source-tag :attrs { } :props { }}`." + [state] + (get-in state [:ui :clipboard :attrs])) + +(defn set-clipboard-attrs! + "Replace the attribute clipboard with `entry`, or clear when nil. + Lives under `:ui` so it bypasses history — copy/paste is a workflow + gesture, not a document edit." + [entry] + (if (nil? entry) + (swap! app-state update-in [:ui :clipboard] dissoc :attrs) + (swap! app-state assoc-in [:ui :clipboard :attrs] entry))) + (defn set-mode! [mode] (swap! app-state assoc :mode mode)) diff --git a/src/bareforge/ui/shortcuts.cljs b/src/bareforge/ui/shortcuts.cljs index 92a78cd..4d84f93 100644 --- a/src/bareforge/ui/shortcuts.cljs +++ b/src/bareforge/ui/shortcuts.cljs @@ -5,6 +5,7 @@ real listener to `state/*` calls." (:require [bareforge.doc.model :as m] [bareforge.doc.ops :as ops] + [bareforge.meta.registry :as registry] [bareforge.render.canvas :as canvas] [bareforge.state :as state] [bareforge.storage.project-file :as pf] @@ -75,11 +76,11 @@ (defn dispatch "Project a key event into an action. Simple actions return a keyword (`:undo` `:redo` `:delete` `:duplicate` `:wrap-in-prompt` - `:deselect` `:exit-text-edit` `:save` `:open` `:new` `:noop`); - parameterized actions return a vector (`[:nudge dx dy]`, - `[:wrap-in tag]`). Takes a map shape: - {:key :meta? :shift? :tag-name :content-editable? :has-selection? - :selection-id :placement :text-editing-id} + `:copy-attrs` `:paste-attrs` `:deselect` `:exit-text-edit` `:save` + `:open` `:new` `:noop`); parameterized actions return a vector + (`[:nudge dx dy]`, `[:wrap-in tag]`). Takes a map shape: + {:key :meta? :alt? :shift? :tag-name :content-editable? + :has-selection? :selection-id :placement :text-editing-id} Arrow keys nudge the selected node's `:layout :x / :y` by 1 px (or 10 px with Shift), but only when the selection has `:free` @@ -96,7 +97,7 @@ path to these actions. Cmd+D duplicates the current selection; Cmd+G wraps it in an x-container, with Cmd+Shift+G prompting for a wrapper tag from a small whitelist." - [{:keys [key meta? shift? has-selection? selection-id placement + [{:keys [key meta? alt? shift? has-selection? selection-id placement text-editing-id] :as event}] (let [editable? (editable-target? event)] @@ -116,7 +117,18 @@ (and meta? (= "n" key) (not shift?) (not editable?)) :new - (and meta? (= "d" key) (not shift?) + (and meta? alt? (= "c" key) (not shift?) + has-selection? + (not= "root" selection-id) + (not editable?)) + :copy-attrs + + (and meta? alt? (= "v" key) (not shift?) + has-selection? + (not editable?)) + :paste-attrs + + (and meta? (= "d" key) (not shift?) (not alt?) has-selection? (not editable?)) :duplicate @@ -179,6 +191,7 @@ any-sel? (seq (state/selected-ids @state/app-state))] {:key (.-key e) :meta? (or (.-metaKey e) (.-ctrlKey e)) + :alt? (.-altKey e) :shift? (.-shiftKey e) :tag-name (some-> t .-tagName) :content-editable? (and t (.-isContentEditable t)) @@ -251,6 +264,51 @@ (state/commit! doc') (state/set-selection! new-ids))))) +(defn- supported-attr-names + "Set of attribute names the registry advertises for `tag`. Used to + filter pasted attrs/props to keys the target tag actually accepts — + pasting an x-button's `variant` onto an x-card should silently + drop, not stamp an unknown attribute." + [tag] + (set (map :name (:properties (registry/get-meta tag))))) + +(defn- copy-attrs! [] + (when-let [id (some-> (state/single-selected-id @state/app-state) + canvas/canonical-node-id)] + (when-let [n (and (not= "root" id) + (m/get-node (:document @state/app-state) id))] + (state/set-clipboard-attrs! + {:source-tag (:tag n) + :attrs (or (:attrs n) {}) + :props (or (:props n) {})})))) + +(defn- paste-attrs! [] + (when-let [{:keys [attrs props]} (state/clipboard-attrs @state/app-state)] + (let [target-ids (selected-doc-ids)] + (when (seq target-ids) + (let [doc (:document @state/app-state) + doc' (reduce + (fn [d id] + (let [tag (some-> (m/get-node d id) :tag) + ok (when tag (supported-attr-names tag))] + (if (and tag (seq ok)) + (let [attrs* (select-keys attrs ok) + ;; Boolean :props are keyed by keyword; + ;; cross-reference against the string + ;; supported-attrs set via `name`. + props* (into {} + (filter (fn [[k _]] + (contains? ok (name k))) + props))] + (-> d + (ops/set-attrs id attrs*) + (ops/set-props id props*))) + d))) + doc + target-ids)] + (when (not= doc doc') + (state/commit! doc'))))))) + (defn- wrap-in! [tag] (let [ids (selected-doc-ids)] (when (and (contains? wrap-tag-whitelist tag) (seq ids)) @@ -305,6 +363,8 @@ :wrap-in-prompt (do (.preventDefault e) (when-let [tag (prompt-wrap-tag)] (wrap-in! tag))) + :copy-attrs (do (.preventDefault e) (copy-attrs!)) + :paste-attrs (do (.preventDefault e) (paste-attrs!)) :exit-text-edit (do (.preventDefault e) (inline-edit/teardown!)) :deselect (do (.preventDefault e) (state/select-clear!)) nil))) diff --git a/test/bareforge/doc/ops_test.cljs b/test/bareforge/doc/ops_test.cljs index 5af2a4a..b56adc0 100644 --- a/test/bareforge/doc/ops_test.cljs +++ b/test/bareforge/doc/ops_test.cljs @@ -198,6 +198,49 @@ (is (nil? wrap)) (is (= d0 doc')))) +;; --- set-attrs / set-props ---------------------------------------------- + +(deftest set-attrs-applies-each-pair + (let [d0 (empty-doc) + {d1 :doc id :id} (ops/insert-new d0 "root" "default" 0 "x-button") + d2 (ops/set-attrs d1 id {"variant" "primary" + "label" "Click"}) + node (m/get-node d2 id)] + (is (= "primary" (get-in node [:attrs "variant"]))) + (is (= "Click" (get-in node [:attrs "label"]))))) + +(deftest set-attrs-nil-value-unsets + (let [d0 (empty-doc) + {d1 :doc id :id} (ops/insert-new d0 "root" "default" 0 "x-button") + d2 (ops/set-attr d1 id "variant" "primary") + d3 (ops/set-attrs d2 id {"variant" nil + "label" "Click"}) + node (m/get-node d3 id)] + (is (nil? (get-in node [:attrs "variant"])) + "nil values dispatch to unset-attr") + (is (= "Click" (get-in node [:attrs "label"]))))) + +(deftest set-attrs-empty-and-nil-noop + (let [d0 (empty-doc) + {d1 :doc id :id} (ops/insert-new d0 "root" "default" 0 "x-button")] + (is (= d1 (ops/set-attrs d1 id {}))) + (is (= d1 (ops/set-attrs d1 id nil))))) + +(deftest set-props-applies-each-pair-keyword-keys + (let [d0 (empty-doc) + {d1 :doc id :id} (ops/insert-new d0 "root" "default" 0 "x-button") + d2 (ops/set-props d1 id {:disabled true :loading false}) + node (m/get-node d2 id)] + (is (true? (get-in node [:props :disabled]))) + (is (false? (get-in node [:props :loading]))))) + +(deftest set-props-nil-value-unsets + (let [d0 (empty-doc) + {d1 :doc id :id} (ops/insert-new d0 "root" "default" 0 "x-button") + d2 (ops/set-prop d1 id :disabled true) + d3 (ops/set-props d2 id {:disabled nil})] + (is (nil? (get-in (m/get-node d3 id) [:props :disabled]))))) + (deftest move-within-same-slot (let [d0 (empty-doc) {d1 :doc id-a :id} (ops/insert-new d0 "root" "default" 0 "x-a") diff --git a/test/bareforge/state_test.cljs b/test/bareforge/state_test.cljs index e4c7046..c959921 100644 --- a/test/bareforge/state_test.cljs +++ b/test/bareforge/state_test.cljs @@ -203,3 +203,20 @@ (is (= [] (:selection @state/app-state)) "toggling the last id clears the selection") (state/reset-state!)) + +;; --- attribute clipboard -------------------------------------------------- + +(deftest clipboard-attrs-defaults-nil + (is (nil? (state/clipboard-attrs (fresh))))) + +(deftest set-clipboard-attrs-roundtrip + (state/reset-state!) + (let [entry {:source-tag "x-button" + :attrs {"variant" "primary"} + :props {:disabled true}}] + (state/set-clipboard-attrs! entry) + (is (= entry (state/clipboard-attrs @state/app-state)))) + (state/set-clipboard-attrs! nil) + (is (nil? (state/clipboard-attrs @state/app-state)) + "nil clears the clipboard") + (state/reset-state!)) diff --git a/test/bareforge/ui/shortcuts_test.cljs b/test/bareforge/ui/shortcuts_test.cljs index ad940a6..3a37010 100644 --- a/test/bareforge/ui/shortcuts_test.cljs +++ b/test/bareforge/ui/shortcuts_test.cljs @@ -278,6 +278,45 @@ (k/dispatch (assoc with-selection :key "g" :meta? true :shift? true :selection-id "root"))))) +;; --- copy / paste attrs ------------------------------------------------- + +(deftest dispatch-cmd-opt-c-copies-attrs + (is (= :copy-attrs + (k/dispatch (assoc with-selection :key "c" :meta? true :alt? true))))) + +(deftest dispatch-cmd-opt-c-noop-on-root + (is (= :noop + (k/dispatch (assoc with-selection :key "c" :meta? true :alt? true + :selection-id "root"))))) + +(deftest dispatch-cmd-opt-c-noop-without-selection + (is (= :noop + (k/dispatch (assoc base :key "c" :meta? true :alt? true))))) + +(deftest dispatch-plain-cmd-c-is-noop + (testing "plain Cmd-C still falls through to the browser's native copy + so users can copy text from inspector inputs / layers panel" + (is (= :noop + (k/dispatch (assoc with-selection :key "c" :meta? true)))))) + +(deftest dispatch-cmd-opt-v-pastes-attrs + (is (= :paste-attrs + (k/dispatch (assoc with-selection :key "v" :meta? true :alt? true))))) + +(deftest dispatch-cmd-opt-v-noop-without-selection + (is (= :noop + (k/dispatch (assoc base :key "v" :meta? true :alt? true))))) + +(deftest dispatch-cmd-opt-c-ignored-in-editable + (is (= :noop + (k/dispatch (assoc with-selection :key "c" :meta? true :alt? true + :tag-name "INPUT"))))) + +(deftest dispatch-cmd-opt-v-ignored-in-editable + (is (= :noop + (k/dispatch (assoc with-selection :key "v" :meta? true :alt? true + :tag-name "X-TEXT-FIELD"))))) + ;; --- coalesce? ----------------------------------------------------------- (def ^:private last-rec From 6d2971b917b61fbcf0fb8feb0ec9006008466aea Mon Sep 17 00:00:00 2001 From: vanelsas <58037137+avanelsas@users.noreply.github.com> Date: Wed, 29 Apr 2026 15:42:33 +0200 Subject: [PATCH 06/25] Cmd-Opt-C / Cmd-Opt-V: match macOS .code fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On macOS US layout Option+C produces 'ç' and Option+V produces '√' — the literal `.key` value never equalled "c" / "v", so dispatch fell through and the gesture was silently swallowed. event-descriptor now also captures `.code`, and dispatch matches either ("c" + "KeyC" / "v" + "KeyV") so the same gesture works across macOS / Linux / Windows. Added a regression test for the Option-modified key path. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/bareforge/ui/shortcuts.cljs | 16 ++++++++++++---- test/bareforge/ui/shortcuts_test.cljs | 14 ++++++++++++++ 2 files changed, 26 insertions(+), 4 deletions(-) diff --git a/src/bareforge/ui/shortcuts.cljs b/src/bareforge/ui/shortcuts.cljs index 4d84f93..26cf281 100644 --- a/src/bareforge/ui/shortcuts.cljs +++ b/src/bareforge/ui/shortcuts.cljs @@ -97,10 +97,17 @@ path to these actions. Cmd+D duplicates the current selection; Cmd+G wraps it in an x-container, with Cmd+Shift+G prompting for a wrapper tag from a small whitelist." - [{:keys [key meta? alt? shift? has-selection? selection-id placement + [{:keys [key code meta? alt? shift? has-selection? selection-id placement text-editing-id] :as event}] - (let [editable? (editable-target? event)] + (let [editable? (editable-target? event) + ;; macOS US layout: Option+C produces "ç", Option+V produces + ;; "√" — the literal `key` no longer matches. `code` is the + ;; physical-key identifier ("KeyC", "KeyV"), unaffected by + ;; modifiers, so we match either to keep the gesture portable + ;; across macOS / Linux / Windows. + c-letter? (or (= "c" key) (= "KeyC" code)) + v-letter? (or (= "v" key) (= "KeyV" code))] (cond (and meta? (= "z" key) (not shift?) (not editable?)) :undo @@ -117,13 +124,13 @@ (and meta? (= "n" key) (not shift?) (not editable?)) :new - (and meta? alt? (= "c" key) (not shift?) + (and meta? alt? c-letter? (not shift?) has-selection? (not= "root" selection-id) (not editable?)) :copy-attrs - (and meta? alt? (= "v" key) (not shift?) + (and meta? alt? v-letter? (not shift?) has-selection? (not editable?)) :paste-attrs @@ -190,6 +197,7 @@ node (when doc-id (m/get-node (:document @state/app-state) doc-id)) any-sel? (seq (state/selected-ids @state/app-state))] {:key (.-key e) + :code (.-code e) :meta? (or (.-metaKey e) (.-ctrlKey e)) :alt? (.-altKey e) :shift? (.-shiftKey e) diff --git a/test/bareforge/ui/shortcuts_test.cljs b/test/bareforge/ui/shortcuts_test.cljs index 3a37010..6e64593 100644 --- a/test/bareforge/ui/shortcuts_test.cljs +++ b/test/bareforge/ui/shortcuts_test.cljs @@ -317,6 +317,20 @@ (k/dispatch (assoc with-selection :key "v" :meta? true :alt? true :tag-name "X-TEXT-FIELD"))))) +(deftest dispatch-cmd-opt-c-matches-macos-modified-key + (testing "On macOS US layout Option+C produces 'ç', so dispatch must + also accept the physical .code 'KeyC' as the letter source. + We supply the modified key — the matching .code value + should still resolve the action." + (is (= :copy-attrs + (k/dispatch (assoc with-selection + :key "ç" :code "KeyC" + :meta? true :alt? true)))) + (is (= :paste-attrs + (k/dispatch (assoc with-selection + :key "√" :code "KeyV" + :meta? true :alt? true)))))) + ;; --- coalesce? ----------------------------------------------------------- (def ^:private last-rec From 051858a808010ed6e9e161ed7ae0a4da6a4c5daf Mon Sep 17 00:00:00 2001 From: vanelsas <58037137+avanelsas@users.noreply.github.com> Date: Wed, 29 Apr 2026 15:49:31 +0200 Subject: [PATCH 07/25] M2.3: multi-select inspector edits shared attributes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit inspector-model under multi-select now surfaces every selected node and its tag set instead of a bare count, and the inspector's multi-mode rebuilds as a "Shared attributes" section: every property that exists on every selected tag (matched by :name and :kind) becomes an editable row. Mixed values render with a "Mixed" placeholder and an .is-mixed class (dashed border, slight opacity) so it's obvious which rows are uniform versus divergent. Editing a row dispatches a single ops/set-attrs-many (or set-props- many for booleans) commit, applying the user's value to every selected node. The doc-changed watcher branch short-circuits in multi-mode so per-keystroke commits don't tear down the input the user is typing into; visual "Mixed" markers may lag until the user re-enters multi-select, an acceptable v1 trade-off. Widget coverage in v1: enum, string-short, string-long, number, boolean. CSS-var, layout, and seed-record editors stay single- select-only — they need their own multi-edit story (different sources of truth) and the UX for partial overlap there is not obviously a win. Co-Authored-By: Claude Opus 4.7 (1M context) --- public/index.html | 8 ++ src/bareforge/doc/ops.cljs | 23 ++++ src/bareforge/ui/inspector.cljs | 190 +++++++++++++++++++++++--- test/bareforge/doc/ops_test.cljs | 27 ++++ test/bareforge/ui/inspector_test.cljs | 41 +++++- 5 files changed, 266 insertions(+), 23 deletions(-) diff --git a/public/index.html b/public/index.html index f22752d..5cfefea 100644 --- a/public/index.html +++ b/public/index.html @@ -350,6 +350,14 @@ touch-action: none; } .inspector-field-widget { width: 100%; } + /* Multi-select shared-attr widgets render the .is-mixed class + when values disagree across the selected nodes. A subtle + border tint plus muted text on the placeholder makes the + state legible without shouting at the user. */ + .inspector-field-widget.is-mixed { + opacity: 0.85; + border-style: dashed; + } /* Compact x-color-picker for the Inspector. Halves the main colour-area height and tightens the strip heights + gap so a picker with alpha still fits where the default (alpha-less) diff --git a/src/bareforge/doc/ops.cljs b/src/bareforge/doc/ops.cljs index 3f6775f..871c04c 100644 --- a/src/bareforge/doc/ops.cljs +++ b/src/bareforge/doc/ops.cljs @@ -232,6 +232,29 @@ (set-prop d id k v))) doc (or prop-map {}))) + +(defn set-attrs-many + "Apply `attr-map` to every id in `ids` in a single document update. + Foundation for multi-select inspector edit — one user input + becomes one commit covering N nodes. Skips ids that no longer + exist so an unfiltered selection vector is safe." + [doc ids attr-map] + (reduce (fn [d id] + (if (m/path-to d id) + (set-attrs d id attr-map) + d)) + doc + (or ids []))) + +(defn set-props-many + "Counterpart of `set-attrs-many` for boolean props." + [doc ids prop-map] + (reduce (fn [d id] + (if (m/path-to d id) + (set-props d id prop-map) + d)) + doc + (or ids []))) (defn set-text [doc id t] (assoc-in doc (conj (at doc id) :text) t)) (defn set-inner-html diff --git a/src/bareforge/ui/inspector.cljs b/src/bareforge/ui/inspector.cljs index 21ae657..1a9237e 100644 --- a/src/bareforge/ui/inspector.cljs +++ b/src/bareforge/ui/inspector.cljs @@ -93,25 +93,29 @@ (defn inspector-model "Project app-state into the view model the inspector needs. Returns - nil when no meaningful selection exists OR when multiple distinct - nodes are selected (multi-select edits land in M2). The selection - id may be a template-instance clone's DOM id (`__seed` suffix); - canonicalise before looking it up in the doc so canvas-tap on a - clone reveals the underlying template node. - - Returns `{:multi }` when the canonicalised, deduped selection - resolves to more than one doc node — the renderer surfaces a - read-only summary in that case so the panel doesn't simply blank - out and confuse the user." + nil when no meaningful selection exists. The selection id may be a + template-instance clone's DOM id (`__seed` suffix); canonicalise + before looking it up in the doc so canvas-tap on a clone reveals + the underlying template node. + + Single-select returns `{:node :meta}`. Multi-select (>1 distinct + doc nodes) returns `{:multi true :nodes [...] :tags #{...}}`; the + renderer uses that to show shared-attribute editors." [app-state] - (let [ids (state/selected-ids app-state) - doc-ids (->> ids (map canvas/canonical-node-id) (remove nil?) distinct)] + (let [ids (state/selected-ids app-state) + doc-ids (->> ids (map canvas/canonical-node-id) (remove nil?) distinct)] (cond (empty? doc-ids) nil (> (count doc-ids) 1) - {:multi (count doc-ids)} + (let [nodes (->> doc-ids + (keep #(m/get-node (:document app-state) %)) + vec)] + (when (seq nodes) + {:multi true + :nodes nodes + :tags (set (map :tag nodes))})) :else (let [doc-id (first doc-ids) @@ -1060,12 +1064,148 @@ (u/el :div {:class "inspector-empty"}) "No component selected. Click a layer or a palette item.")])) -(defn- multi-view [n] - (u/el :div {:class "inspector-empty-state"} - [(u/set-text! - (u/el :div {:class "inspector-empty"}) - (str n " components selected. Multi-select editing lands later — " - "click a single component to edit it."))])) +;; --- multi-select shared editors (M2.3) --------------------------------- + +(defn shared-properties + "Pure: properties that exist on every tag in `tags` with the same + `:name` and `:kind`. Returns the descriptor from the first tag + (the others are equivalent on the dimensions the inspector cares + about). Used by the multi-select attribute section to decide + which rows to render." + [tags] + (let [meta-list (mapv registry/get-meta tags) + first-list (-> meta-list first :properties)] + (if (or (empty? meta-list) (empty? first-list)) + [] + (filterv + (fn [prop] + (every? + (fn [m] + (some #(and (= (:name prop) (:name %)) + (= (:kind prop) (:kind %))) + (:properties m))) + (rest meta-list))) + first-list)))) + +(defn joint-attr-value + "Pure: read attribute `prop` across `nodes` and return + `{:value :mixed?}`. `:value` is the common value when every node + agrees, the first node's value otherwise. `:mixed?` is true iff + the values disagree." + [nodes prop] + (let [vs (mapv #(current-value % prop) nodes)] + {:value (first vs) + :mixed? (not (apply = vs))})) + +(defn- multi-set-attr! [ids attr-name v] + (let [doc (:document @state/app-state) + v (if (or (nil? v) (= "" v)) nil v) + doc' (ops/set-attrs-many doc ids {attr-name v})] + (state/commit! doc'))) + +(defn- multi-set-prop! [ids prop-name v] + (let [doc (:document @state/app-state) + doc' (ops/set-props-many doc ids {(keyword prop-name) v})] + (state/commit! doc'))) + +(defn- build-multi-search-field [nodes prop spec] + (let [{:keys [value mixed?]} (joint-attr-value nodes prop) + transform (:transform prop) + numeric? (= "number" (:type spec)) + el (-> (u/el :x-search-field + (cond-> {:class "inspector-field-widget"} + numeric? (assoc :type "number") + mixed? (assoc :placeholder "Mixed"))) + (tag-widget! (:name prop) "text" transform)) + ids (mapv :id nodes)] + (when (and (not mixed?) value (not= "" value)) + (u/set-attr! el :value value)) + (when mixed? + (.. el -classList (add "is-mixed"))) + (u/on! el :x-search-field-input + (fn [^js e] + (let [v (read-event-value e) + v (if transform (transform-for-commit transform v) v)] + (multi-set-attr! ids (:name prop) v)))) + el)) + +(defn- build-multi-text-area [nodes prop] + (let [{:keys [value mixed?]} (joint-attr-value nodes prop) + el (-> (u/el :x-text-area + (cond-> {:class "inspector-field-widget"} + mixed? (assoc :placeholder "Mixed"))) + (tag-widget! (:name prop) "text")) + ids (mapv :id nodes)] + (when (and (not mixed?) value (not= "" value)) + (u/set-attr! el :value value)) + (when mixed? + (.. el -classList (add "is-mixed"))) + (u/on! el :x-text-area-input + (fn [^js e] + (multi-set-attr! ids (:name prop) (read-event-value e)))) + el)) + +(defn- build-multi-enum [nodes prop] + (let [{:keys [value mixed?]} (joint-attr-value nodes prop) + sel-el (-> (u/el :x-select {:class "inspector-field-widget"}) + (tag-widget! (:name prop) "text")) + ids (mapv :id nodes)] + ;; Prepend a "—" sentinel option only in mixed mode so the user + ;; can see "values differ" without an explicit choice having been + ;; made; selecting any concrete option then commits to all. + (doseq [opt (cond-> (vec (:choices prop)) + mixed? (->> (cons "—") vec))] + (let [^js o (js/document.createElement "option")] + (.setAttribute o "value" opt) + (set! (.-textContent o) opt) + (.appendChild sel-el o))) + (when (and (not mixed?) value) + (u/set-attr! sel-el :value value)) + (when mixed? + (u/set-attr! sel-el :value "—") + (.. sel-el -classList (add "is-mixed"))) + (u/on! sel-el :x-select-input + (fn [^js e] + (let [v (read-event-value e)] + (when (not= v "—") + (multi-set-attr! ids (:name prop) v))))) + sel-el)) + +(defn- build-multi-boolean [nodes prop] + (let [vs (mapv #(boolean (get-in % [:props (keyword (:name prop))])) nodes) + mixed? (not (apply = vs)) + value (first vs) + el (-> (u/el :x-switch (cond-> {:class "inspector-field-widget"} + value (assoc :checked ""))) + (tag-widget! (:name prop) "boolean")) + ids (mapv :id nodes)] + (when mixed? + (.. el -classList (add "is-mixed")) + ;; Indeterminate-ish: render off so the first toggle force- + ;; sets all to true, the next toggle force-sets all to false. + (.removeAttribute el "checked")) + (u/on! el :x-switch-change + (fn [^js e] + (multi-set-prop! ids (:name prop) (boolean (read-event-value e))))) + el)) + +(defn- build-multi-widget [nodes prop] + (let [spec (editor-spec prop)] + (case (:kind spec) + :boolean (build-multi-boolean nodes prop) + :enum (build-multi-enum nodes prop) + :string-long (build-multi-text-area nodes prop) + (build-multi-search-field nodes prop spec)))) + +(defn- multi-attributes-section [{:keys [nodes tags]}] + (let [props (shared-properties tags) + body (if (seq props) + (for [p props] + (field-row (display-label p) (build-multi-widget nodes p))) + [(u/set-text! (u/el :div {:class "inspector-empty"}) + "No shared attributes between the selected components.")])] + (section (str (count nodes) " components selected — Shared attributes") + body))) (defn- widget-value [^js el] (let [v (.-value el)] (if (nil? v) "" v))) @@ -2176,7 +2316,7 @@ [^js host-el model] (let [sections (cond (and model (:multi model)) - [(multi-view (:multi model))] + [(multi-attributes-section model)] model (let [doc (:document @state/app-state) @@ -2238,6 +2378,16 @@ (and doc-changed? (nil? new-model)) (render! body nil) + ;; Multi-select: doc changed (most likely from + ;; the user editing a shared-attr field). Skip + ;; the rebuild — every keystroke would otherwise + ;; throw away the focused input. The committed + ;; values are correct in the doc; the panel's + ;; visual "Mixed" markers may lag until the user + ;; re-enters multi-select, which is acceptable. + (and doc-changed? (:multi new-model)) + nil + doc-changed? (let [old-model (inspector-model old-state) old-node (:node old-model) diff --git a/test/bareforge/doc/ops_test.cljs b/test/bareforge/doc/ops_test.cljs index b56adc0..78ff52c 100644 --- a/test/bareforge/doc/ops_test.cljs +++ b/test/bareforge/doc/ops_test.cljs @@ -241,6 +241,33 @@ d3 (ops/set-props d2 id {:disabled nil})] (is (nil? (get-in (m/get-node d3 id) [:props :disabled]))))) +(deftest set-attrs-many-applies-to-each-id + (let [d0 (empty-doc) + {d1 :doc id-a :id} (ops/insert-new d0 "root" "default" 0 "x-button") + {d2 :doc id-b :id} (ops/insert-new d1 "root" "default" 1 "x-button") + d3 (ops/set-attrs-many d2 [id-a id-b] + {"variant" "primary"})] + (is (= "primary" (get-in (m/get-node d3 id-a) [:attrs "variant"]))) + (is (= "primary" (get-in (m/get-node d3 id-b) [:attrs "variant"]))))) + +(deftest set-attrs-many-skips-missing-ids + (let [d0 (empty-doc) + {d1 :doc id :id} (ops/insert-new d0 "root" "default" 0 "x-button") + d2 (ops/set-attrs-many d1 [id "ghost"] + {"variant" "primary"})] + (is (= "primary" (get-in (m/get-node d2 id) [:attrs "variant"]))) + (is (= d1 (ops/set-attrs-many d1 [] {"variant" "x"}))) + (is (= d1 (ops/set-attrs-many d1 nil {"variant" "x"}))))) + +(deftest set-props-many-applies-to-each-id + (let [d0 (empty-doc) + {d1 :doc id-a :id} (ops/insert-new d0 "root" "default" 0 "x-button") + {d2 :doc id-b :id} (ops/insert-new d1 "root" "default" 1 "x-button") + d3 (ops/set-props-many d2 [id-a id-b] + {:disabled true})] + (is (true? (get-in (m/get-node d3 id-a) [:props :disabled]))) + (is (true? (get-in (m/get-node d3 id-b) [:props :disabled]))))) + (deftest move-within-same-slot (let [d0 (empty-doc) {d1 :doc id-a :id} (ops/insert-new d0 "root" "default" 0 "x-a") diff --git a/test/bareforge/ui/inspector_test.cljs b/test/bareforge/ui/inspector_test.cljs index 36fc3bb..a620e4c 100644 --- a/test/bareforge/ui/inspector_test.cljs +++ b/test/bareforge/ui/inspector_test.cljs @@ -1,6 +1,7 @@ (ns bareforge.ui.inspector-test (:require [cljs.test :refer [deftest is testing]] [bareforge.doc.ops :as ops] + [bareforge.meta.registry :as registry] [bareforge.state :as state] [bareforge.ui.inspector :as insp])) @@ -91,7 +92,7 @@ (is (= "root" (get-in model [:node :id]))) (is (= "x-container" (get-in model [:node :tag]))))) -(deftest inspector-model-multi-select-returns-multi-marker +(deftest inspector-model-multi-select-surfaces-nodes-and-tags (let [s0 (state/initial-state) {d1 :doc id-a :id} (ops/insert-new (:document s0) "root" "default" 0 "x-button") {d2 :doc id-b :id} (ops/insert-new d1 "root" "default" 1 "x-card") @@ -99,8 +100,42 @@ (assoc :document d2) (assoc :selection [id-a id-b])) model (insp/inspector-model s)] - (is (= {:multi 2} model) - "two distinct doc nodes selected → :multi marker, no :node payload"))) + (is (true? (:multi model))) + (is (= 2 (count (:nodes model)))) + (is (= #{"x-button" "x-card"} (:tags model))))) + +;; --- shared-properties + joint-attr-value (M2.3) ---------------------- + +(deftest shared-properties-intersect-by-name-and-kind + (let [props (insp/shared-properties #{"x-button" "x-card"})] + (testing "every returned descriptor exists on both tags" + (doseq [p props] + (is (contains? #{:enum :boolean :string-short :string-long + :number :url :color :date :unknown} + (or (:kind p) :unknown)) + "descriptor has a recognisable kind"))))) + +(deftest shared-properties-empty-for-empty-tag-set + (is (= [] (insp/shared-properties #{})))) + +(deftest shared-properties-single-tag-returns-its-properties + (let [single (insp/shared-properties #{"x-button"}) + all (-> "x-button" registry/get-meta :properties)] + (is (= (count all) (count single)) + "with a single tag, every property is trivially shared"))) + +(deftest joint-attr-value-uniform + (let [n1 {:attrs {"variant" "primary"}} + n2 {:attrs {"variant" "primary"}} + out (insp/joint-attr-value [n1 n2] {:name "variant" :kind :enum})] + (is (= "primary" (:value out))) + (is (false? (:mixed? out))))) + +(deftest joint-attr-value-mixed + (let [n1 {:attrs {"variant" "primary"}} + n2 {:attrs {"variant" "ghost"}} + out (insp/joint-attr-value [n1 n2] {:name "variant" :kind :enum})] + (is (true? (:mixed? out))))) (deftest inspector-model-collapses-clones-of-one-doc-node (testing "two raw DOM ids that canonicalise to the same doc node still From c93378447c9368f5c730036a06f38b5e8575f8e2 Mon Sep 17 00:00:00 2001 From: vanelsas <58037137+avanelsas@users.noreply.github.com> Date: Wed, 29 Apr 2026 15:54:37 +0200 Subject: [PATCH 08/25] M2.3: fix multi-edit enum/boolean event names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit build-multi-enum subscribed to :x-select-input, but x-select fires :select-change (mirroring build-enum's single-select wiring). The selected option also needs the `selected=""` attribute on the matching