diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e6a08b..55a07c6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,103 @@ possible" — I won't promise API stability until `1.0.0` lands. ## [Unreleased] -Nothing yet. +Editor authoring quality-of-life. No document-model, project-file, +or export changes — saved projects load identically, every export +target stays at parity. Test count: 571 (up from 486), zero +release-build warnings under Closure Advanced. + +### Added + +- **Multi-select** on the canvas and Layers panel. + - Shift-click extends; drag from empty canvas starts a marquee + rectangle (Shift+drag extends). + - The selection overlay becomes a pool — one 1px border per + selected DOM id. Resize handles only when exactly one node is + selected. + - Esc clears the selection; Delete / Backspace removes every + selected node in one commit via `ops/remove-many`. +- **Multi-select inspector edit.** With more than one node + selected, the Inspector renders the shared-attribute set across + all of them. Editing a row dispatches a single + `ops/set-attrs-many` (or `set-props-many`) commit; mixed values + show with a `Mixed` placeholder and an `is-mixed` class. +- **Cmd-D duplicate** the selection (deep-clone with fresh ids + throughout the subtree). +- **Cmd-G** wraps the selection in an `x-container`; + **Cmd-Shift-G** prompts for `x-grid` / `x-card` / `x-navbar`. New + `ops/wrap-many` keeps a sibling set's document order intact + inside the new wrapper. +- **Cmd-Opt-C / Cmd-Opt-V** copy and paste attributes between + nodes. Paste is filtered to the target tag's supported attrs so + `x-button` → `x-card` silently drops `variant` instead of + stamping unknown attributes. Macro-OS Option-modified key (`ç`, + `√`) falls back to `.code` (`KeyC`, `KeyV`) for cross-platform + parity. +- **Drag-to-scrub** numeric inspector rows. The label of `:number` + kind editors and free-coord `:layout :x / :y / :w / :h` fields + becomes a horizontal drag handle; Shift × 10 step. The whole + drag is one undo entry via `state/commit-coalesced!`. +- **BareDOM theme-token autocomplete.** Colour and length fields + surface every `--x-color-*` / `--x-space-*` / `--x-radius-*` / + `--x-font-size-*` / `--x-border-width` token via a native + `` injected into the field's shadow root. New + `bareforge.meta.design-tokens` mirrors the 50 `tk-*` tokens from + `baredom.components.x-theme.model`. +- **`?` keyboard cheat sheet.** Lists every shortcut and gesture + grouped by Editing / Selection / Navigation / File / View. Built + on `x-modal` + `x-typography` so it inherits the active theme + preset. Source of truth is the static `shortcut-info` data; + unit tests assert category coverage. +- **Cmd-K command palette.** Built on the BareDOM + `x-command-palette` web component — owns its own focus, fuzzy + filter, scrim, ARIA roles, and theme inheritance. Curated File / + View / Selection commands plus one entry per registered BareDOM + tag (`Insert `) plus the four wrap-in targets. Selection + flows through a synthetic-id → run-fn dispatch map. +- **Layers panel keyboard navigation.** Focus the Layers tree, then + ↑ / ↓ walk siblings within the parent slot, ← / → step to parent + / first child, and **Alt+↑ / Alt+↓** reorder within the slot via + `ops/move`. The keydown handler `stopPropagation`s so an arrow on + a free-placed selection no longer simultaneously navigates and + nudges. +- **Inline component patterns.** New `bareforge.meta.patterns` + carries pre-styled named configurations per tag: `x-button` → + primary / secondary / ghost / danger / loading; `x-typography` → + h1 / h2 / h3 / body / caption / code; `x-alert`, `x-badge`, + `x-card`, `x-chip`, `x-grid`, `x-divider`, `x-switch`, + `x-checkbox` covered. Tags with patterns grow a `▾` caret on + their palette tile that toggles an inline flyout of pattern + chips. A coverage warning prints uncovered tags in test output. +- **Per-tag empty-slot hints.** New `bareforge.meta.hints` provides + hint strings (`Drop nav links / actions`, `Drop tiles into the + grid`, `Drop x-tab here`, etc.) for ~20 container tags. The + canvas reconciler stamps `data-bareforge-hint` on creation; + existing CSS reads it via `attr()` to override the generic + `(empty)` placeholder. Drag-time and preview-mode invisibility + fall out of existing rules. + +### Changed + +- `:selection` in `app-state` is now a vector of node ids. New + pure helpers `state/selected-ids`, `selected?`, + `single-selected-id`; new effectful `select-one!`, `select-clear!`, + `select-toggle!`. Internal-only refactor — single-node consumers + (resize handles, nudge, inspector lookup, inline-edit teardown) + route through `single-selected-id` and degrade gracefully under + multi-select. **Saved project files are unchanged.** +- Action helpers in `ui.shortcuts` (`duplicate!`, `wrap-in!`, + `copy-attrs!`, `paste-attrs!`) become public so the command + palette reuses them instead of re-implementing the + selection → commit → reselect flow. +- Wrap-in whitelist updated to `x-container / x-grid / x-card / + x-navbar`. **`x-flex` is removed** — it isn't a tag in BareDOM + 2.4, so the previous Cmd-Shift-G prompt would have inserted an + unknown element. Cmd-G default behaviour (`x-container`) is + unchanged. +- In edit mode, the canvas host gets `user-select: none` so the + Shift-click and marquee-drag gestures don't paint a native + text-selection band over the rendered preview. Inline-text + editing's textarea overlay re-enables `user-select: text`. ## [0.1.1] — 2026-04-28 diff --git a/README.md b/README.md index 27de34f..2b5c702 100644 --- a/README.md +++ b/README.md @@ -78,6 +78,66 @@ I wondered if I could automate some of that and with that the idea for Bareforge - **Escape to deselect** + full keyboard shortcuts (Cmd-Z / Cmd-Shift-Z / Delete / arrow-key nudge) +## Authoring shortcuts + +A set of power-user gestures that make daily editing faster. Every +keyboard shortcut lives in a press-`?` cheat sheet; every action is +reachable through a Cmd-K command palette. + +### Multi-select & bulk ops + +- **Shift-click** any node (canvas or Layers panel) to extend the + selection. **Drag from empty canvas** for marquee select; hold + **Shift** to extend. +- With multiple nodes selected, the Inspector shows the **shared + attributes** across them. Edit a row once, every selected node + updates in a single undo step. Mixed values render with a `Mixed` + placeholder. +- **Cmd-D** duplicates (deep-clone with fresh ids); **Cmd-G** wraps + the selection in an `x-container` (**Cmd-Shift-G** prompts for + `x-grid` / `x-card` / `x-navbar`); **Delete** removes the whole + set in one commit. +- **Cmd-Opt-C / Cmd-Opt-V** copy attributes from the selection and + paste them onto another node, filtered to the target tag's + supported attrs — a paste from `x-button` onto an `x-card` + silently drops `variant` instead of stamping an unknown attr. + +### Inspector ergonomics + +- **Drag-to-scrub** numeric labels — the `min` / `max` / `step` rows + and the free-coord `:layout :x / :y / :w / :h` rows. Drag the + label horizontally; hold Shift for ×10 steps. The whole drag is + one undo entry. +- **`var(--x-…)` autocomplete.** Type `var(` into a colour or length + field and a native `` surfaces every BareDOM theme + token — `--x-color-primary`, `--x-space-md`, `--x-radius-lg`, + and so on, sourced from `x-theme` and resolved live by the + active preset. + +### Discoverability + +- **`?`** opens a cheat sheet with every keyboard shortcut and + gesture, grouped by category. Built on `x-modal` + `x-typography` + so it inherits the live theme preset. +- **Cmd-K** opens a fuzzy command palette built on + `x-command-palette`. Insert any of the 90 BareDOM tags by typing + a fragment of the name; toggle the theme editor, the templates + panel, preview mode, or the cheat sheet itself — every toolbar + action is one keystroke away. +- **Layers keyboard nav.** Focus the Layers tree, then ↑ / ↓ walk + siblings, ← / → step to parent / first child, **Alt+↑ / Alt+↓** + reorder within the parent slot. +- **Palette pattern flyout.** Components with curated variants — + `x-button` (primary / secondary / ghost / danger / loading), + `x-typography` (h1–h3 / body / caption / code), `x-alert`, + `x-badge`, `x-card`, `x-grid` (2-col / 3-col / 4-col / sidebar), + and more — show a `▾` caret next to their palette tile. Expand, + pick a chip, the component lands pre-styled. +- **Empty-slot hints.** Empty containers in edit mode show a per-tag + prompt — `Drop nav links / actions` inside an empty `x-navbar`, + `Drop tiles into the grid` inside an `x-grid`, `Drop x-tab here` + inside `x-tabs` — so the next move is always obvious. + ## Fields & bindings Give a container a **name** and it becomes a component group with its @@ -452,7 +512,7 @@ structure, then customise content, theme, and layout in the editor. **Early alpha.** Feature-complete: 90 BareDOM components in the palette, four export plugins at full feature parity (HTML, bundle, CLJS, vanilla-JS), nine starter templates, first-run welcome tour, -doc-level XSS sanitiser, CSP + SRI on every export. 486 tests / 0 +doc-level XSS sanitiser, CSP + SRI on every export. 571 tests / 0 release-build warnings under Closure Advanced. Expect rough edges on less-common BareDOM components until their augment entries are hand-tuned. See [`CHANGELOG.md`](./CHANGELOG.md) for what's in diff --git a/public/index.html b/public/index.html index 76f93bf..c955ba2 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,30 @@ .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); + } + /* --- cheat sheet ----------------------------------------------- */ + /* x-modal carries its own backdrop / panel chrome; we only style + the row + group layout inside the modal body. Typography (kbd, + overline, body2, h3) inherits from the active theme. */ + .cheat-group + .cheat-group { margin-top: 18px; } + .cheat-row { + display: grid; + grid-template-columns: 240px 1fr; + gap: 14px; + padding: 4px 0; + align-items: baseline; + } + + /* x-command-palette owns its own modal chrome and shadow-DOM + styling — no host-level CSS needed. */ + .bareforge-marquee[data-hidden] { display: none; } + .chrome[data-mode="preview"] .bareforge-marquee { display: none; } .bareforge-inline-edit { position: absolute; z-index: 101; @@ -226,12 +264,50 @@ .palette-item:active { background: var(--x-color-surface-active, rgba(127, 127, 127, 0.2)); } + .palette-item-header { + display: grid; + grid-template-columns: 1fr auto auto; + column-gap: 8px; + align-items: baseline; + } .palette-item-label { font-size: 13px; font-weight: 500; } .palette-item-tag { font-size: 11px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; color: var(--x-color-text-muted, rgba(127, 127, 127, 0.7)); } + /* Pattern caret + inline flyout (M3.4). The caret rotates when + open so the affordance reads as expand/collapse. */ + .palette-item-caret { + font-size: 11px; + color: var(--x-color-text-muted, rgba(127, 127, 127, 0.7)); + cursor: pointer; + padding: 0 4px; + user-select: none; + transition: transform 120ms ease; + } + .palette-item-caret[data-open] { transform: rotate(180deg); } + .palette-patterns { + display: flex; + flex-wrap: wrap; + gap: 4px; + margin-top: 6px; + } + .palette-patterns[data-hidden] { display: none; } + .palette-pattern { + font-size: 11px; + padding: 3px 8px; + border-radius: 999px; + background: var(--x-color-surface, rgba(127, 127, 127, 0.1)); + color: var(--x-color-text); + border: 1px solid var(--x-color-border, rgba(127, 127, 127, 0.2)); + cursor: pointer; + user-select: none; + } + .palette-pattern:hover { + background: var(--x-color-surface-hover, rgba(127, 127, 127, 0.18)); + border-color: var(--x-color-primary); + } .palette-empty { font-size: 12px; color: var(--x-color-text-muted, rgba(127, 127, 127, 0.7)); @@ -317,7 +393,24 @@ 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%; } + /* 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) @@ -671,6 +764,13 @@ pointer-events: none; user-select: none; } + /* Per-tag drop hint (M3.6) — overrides the generic placeholder + when the canvas reconciler stamped a `data-bareforge-hint` + attribute (see meta/hints.cljs for the data table). The more + specific selector wins over the generic rule above. */ + .canvas-host [data-bareforge-container][data-bareforge-hint]:empty::before { + content: attr(data-bareforge-hint); + } /* Containers that host an absolute :background child get this class from the reconciler so they form a positioned ancestor without touching their own inline style. `isolation: isolate` 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..871c04c 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 @@ -76,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})))) @@ -93,6 +208,53 @@ (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-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/main.cljs b/src/bareforge/main.cljs index 998c927..0b81a95 100644 --- a/src/bareforge/main.cljs +++ b/src/bareforge/main.cljs @@ -4,6 +4,10 @@ [bareforge.state :as state] [bareforge.storage.indexeddb :as idb] [bareforge.ui.app :as app] + [bareforge.ui.cheat-sheet :as cheat-sheet] + [bareforge.ui.command-palette :as command-palette] + [bareforge.ui.inspector :as inspector] + [bareforge.ui.shortcuts :as shortcuts] [bareforge.ui.welcome-tour :as welcome-tour] [bareforge.util.dom :as u])) @@ -25,6 +29,9 @@ (defn ^:export init [] (baredom/register!) + (inspector/install-token-datalists!) + (shortcuts/set-show-shortcuts! cheat-sheet/toggle!) + (shortcuts/set-show-command-palette! command-palette/toggle!) (app/mount! (u/by-id "app")) (-> (idb/restore!) (.then (fn [restored?] diff --git a/src/bareforge/meta/design_tokens.cljs b/src/bareforge/meta/design_tokens.cljs new file mode 100644 index 0000000..32e0e30 --- /dev/null +++ b/src/bareforge/meta/design_tokens.cljs @@ -0,0 +1,93 @@ +(ns bareforge.meta.design-tokens + "Authoritative list of BareDOM theme tokens, mirrored from + `baredom.components.x-theme.model`. Each entry is `{:name :category}` + where `:name` is the CSS custom-property string (e.g. `--x-color-primary`) + and `:category` is one of `:color :length :font :shadow :motion :z + :opacity`. + + Why mirror instead of reading from x-theme directly? The `tk-*` defs + are individual vars — exposing them via `(ns-publics)` would only work + in dev (Closure Advanced strips that machinery). Hardcoded strings are + stable; a unit test (`bareforge.meta.design-tokens-test`) compares + them against a regex on the x-theme source so a BareDOM bump that + adds tokens shows up as a missing-coverage failure rather than + silent autocomplete drift.") + +(def all-tokens + "Vector of every BareDOM theme token, ordered by category for a + readable inspector datalist." + [;; --- colour --- + {:name "--x-color-primary" :category :color} + {:name "--x-color-primary-hover" :category :color} + {:name "--x-color-primary-active" :category :color} + {:name "--x-color-secondary" :category :color} + {:name "--x-color-secondary-hover" :category :color} + {:name "--x-color-secondary-active" :category :color} + {:name "--x-color-tertiary" :category :color} + {:name "--x-color-tertiary-hover" :category :color} + {:name "--x-color-tertiary-active" :category :color} + {:name "--x-color-surface" :category :color} + {:name "--x-color-surface-hover" :category :color} + {:name "--x-color-surface-active" :category :color} + {:name "--x-color-bg" :category :color} + {:name "--x-color-text" :category :color} + {:name "--x-color-text-muted" :category :color} + {:name "--x-color-border" :category :color} + {:name "--x-color-focus-ring" :category :color} + {:name "--x-color-danger" :category :color} + {:name "--x-color-success" :category :color} + {:name "--x-color-warning" :category :color} + + ;; --- length / spacing --- + {:name "--x-space-xs" :category :length} + {:name "--x-space-sm" :category :length} + {:name "--x-space-md" :category :length} + {:name "--x-space-lg" :category :length} + {:name "--x-space-xl" :category :length} + {:name "--x-radius-sm" :category :length} + {:name "--x-radius-md" :category :length} + {:name "--x-radius-lg" :category :length} + {:name "--x-radius-full" :category :length} + {:name "--x-font-size-xs" :category :length} + {:name "--x-font-size-sm" :category :length} + {:name "--x-font-size-base" :category :length} + {:name "--x-font-size-lg" :category :length} + {:name "--x-border-width" :category :length} + + ;; --- font / typography --- + {:name "--x-font-family" :category :font} + {:name "--x-font-family-mono" :category :font} + {:name "--x-font-weight-normal" :category :font} + {:name "--x-font-weight-medium" :category :font} + {:name "--x-font-weight-semibold" :category :font} + {:name "--x-line-height-normal" :category :font} + + ;; --- shadow --- + {:name "--x-shadow-sm" :category :shadow} + {:name "--x-shadow-md" :category :shadow} + {:name "--x-shadow-lg" :category :shadow} + + ;; --- motion --- + {:name "--x-transition-duration" :category :motion} + {:name "--x-transition-easing" :category :motion} + + ;; --- z-index --- + {:name "--x-z-dropdown" :category :z} + {:name "--x-z-modal" :category :z} + {:name "--x-z-toast" :category :z} + + ;; --- opacity --- + {:name "--x-opacity-disabled" :category :opacity} + {:name "--x-opacity-placeholder" :category :opacity}]) + +(defn tokens-for + "Pure: filter `all-tokens` to the named category. Returns the + raw entries (callers project to `:name` as needed)." + [category] + (filterv #(= category (:category %)) all-tokens)) + +(defn var-of + "Pure: wrap `--x-foo` into the canonical CSS reference string + `var(--x-foo)`. Handy when emitting datalist `