diff --git a/packages/ui/@/components/ui/avatar.tsx b/packages/ui/@/components/ui/avatar.tsx index c115fb97bf..e652d9c20c 100644 --- a/packages/ui/@/components/ui/avatar.tsx +++ b/packages/ui/@/components/ui/avatar.tsx @@ -4,9 +4,16 @@ import * as AvatarPrimitive from "@radix-ui/react-avatar" // @ts-ignore import { cn } from "@/lib/utils" +// Shape support: `data-shape="circle" | "rounded-rect"`. The class chain +// always emits `rounded-full` as the visual default; the rounded-rect +// override is handled by `[data-shape="rounded-rect"]` selectors in +// src/main/frontend/components/icon.css. Doing it that way avoids +// Tailwind JIT issues with arbitrary-value classes generated dynamically. +type ShapeProps = { "data-shape"?: "circle" | "rounded-rect" } + const Avatar = React.forwardRef< React.ElementRef, - React.ComponentPropsWithoutRef + React.ComponentPropsWithoutRef & ShapeProps >(({ className, ...props }, ref) => ( , - React.ComponentPropsWithoutRef + React.ComponentPropsWithoutRef & ShapeProps >(({ className, ...props }, ref) => ( , - React.ComponentPropsWithoutRef + React.ComponentPropsWithoutRef & ShapeProps >(({ className, ...props }, ref) => ( {:type :avatar - :data (merge colors + :data (merge inherited {:value (derive-avatar-initials (:block/title node-entity))})} - (:color colors) (assoc :color (:color colors))))) + (:color inherited) (assoc :color (:color inherited))))) :text (when (:block/title node-entity) (let [colors (select-keys (:data default-icon) [:color])] (cond-> {:type :text @@ -659,7 +672,9 @@ ;; can carry `:icon` (full normalized item override), `:color` ;; (color override), or both. preview (state/sub :ui/icon-hover-preview) - preview-active? (and preview (= (:db-id preview) (:db/id entity))) + preview-active? (and preview + (or (= (:db-id preview) (:db/id entity)) + (contains? (:db-ids preview) (:db/id entity)))) preview-icon (when preview-active? (:icon preview)) effective-color (cond preview-active? (or (:color preview) "inherit") @@ -737,7 +752,16 @@ [v] (cond ;; Already unified shape? (has :data key) - (and (map? v) (keyword? (:type v)) (contains? v :data)) v + ;; Avatars get a small post-pass to ensure new fields (:shape) have + ;; defaults applied — legacy data stored before those fields existed + ;; would otherwise bypass the normalization branch below entirely. + (and (map? v) (keyword? (:type v)) (contains? v :data)) + (if (= :avatar (:type v)) + (let [explicit-shape (or (get-in v [:data :shape]) (:shape v))] + (cond-> v + (nil? explicit-shape) (assoc-in [:data :shape] :circle) + (some? explicit-shape) (assoc-in [:data :shape] explicit-shape))) + v) ;; Legacy map with :type (map? v) @@ -779,13 +803,17 @@ (colors/variable :gray :09)) ;; Preserve image data if present asset-uuid (or (get-in v [:data :asset-uuid]) (:asset-uuid v)) - asset-type (or (get-in v [:data :asset-type]) (:asset-type v))] + asset-type (or (get-in v [:data :asset-type]) (:asset-type v)) + ;; Shape: defaults to :circle for backward compat with avatars + ;; saved before the shape field existed. + shape (or (get-in v [:data :shape]) (:shape v) :circle)] {:type :avatar :id (or id (str "avatar-" value)) :label (or label value) :data (cond-> {:value value :backgroundColor backgroundColor - :color color} + :color color + :shape shape} asset-uuid (assoc :asset-uuid asset-uuid) asset-type (assoc :asset-type asset-type))}) :image (let [;; Extract asset-uuid, stripping "image-" prefix if present (from :id fallback) @@ -2696,6 +2724,7 @@ (rum/local nil ::loaded-assets) ;; Cached assets loaded async (rum/local nil ::web-query-debounced) ;; Debounced web search query (rum/local :avatar ::mode) ;; :avatar | :image — live tab state, seeded in :will-mount + (rum/local false ::customize-expanded?) ;; Avatar customize band open/closed (rum/local nil ::paste-handler) ;; Holds latest clipboard-paste closure for the DOM listener ;; Keyboard-nav state, parallels the icon-picker's model. (rum/local :search ::focus-region) ;; :search | :grid @@ -2789,7 +2818,7 @@ state)} [state {:keys [on-chosen on-back on-delete del-btn? current-icon avatar-context page-title - *color preview-target-db-id]}] + *color preview-target-db-id preview-target-db-ids]}] (let [*search-q (::search-q state) *loading? (::loading? state) *loaded-assets (::loaded-assets state) @@ -3243,12 +3272,13 @@ (assoc-in [:data :color] c) (assoc-in [:data :backgroundColor] c)) true))) - :on-hover! (when preview-target-db-id + :on-hover! (when (or preview-target-db-id (seq preview-target-db-ids)) (fn [c] (state/set-state! :ui/icon-hover-preview - {:db-id preview-target-db-id - :color c}))) - :on-hover-end! (when preview-target-db-id + (cond-> {:color c} + preview-target-db-id (assoc :db-id preview-target-db-id) + (seq preview-target-db-ids) (assoc :db-ids (set preview-target-db-ids)))))) + :on-hover-end! (when (or preview-target-db-id (seq preview-target-db-ids)) (fn [] (state/set-state! :ui/icon-hover-preview nil))) :button-attrs {:data-topbar-stop "color"} @@ -3427,6 +3457,110 @@ (if (string/blank? @*search-q) (shui/popup-hide!) (reset! *search-q "")))}) + + ;; Avatar customize zone — preview tile + (when expanded) Shape/ + ;; Fallback dropdowns + Reset/Done rail. Avatar-mode only; image-mode + ;; doesn't have a notion of shape (object-fit: contain shows the full + ;; image with no shape clipping). + ;; + ;; Layout intent (matches design artboard 99K-1): + ;; + ;; Resting — [JK] Jakob Kühn / Tap to customize… + ;; Expanded — [JK] Shape [Circle ⌄] + ;; Fallback [Letters ⌄] ← future phase + ;; ------------------------------- + ;; ↩ Reset Done + ;; + ;; Avatar stays anchored on the left in both states; the right column + ;; swaps between the meta line (resting) and the toggle rows + ;; (expanded). The avatar itself is the only click target — tapping + ;; it toggles the expanded state. + (when avatar-mode? + (let [expanded? (rum/react (::customize-expanded? state)) + preview-icon (or (when (= :avatar (:type current-icon)) current-icon) + synthesized-avatar-context) + current-shape (or (get-in preview-icon [:data :shape]) :circle) + set-shape! (fn [new-shape] + (on-chosen nil + (assoc-in preview-icon [:data :shape] new-shape) + true)) + reset-shape! (fn [] + ;; Phase 1: Reset only resets shape (the only + ;; customizable field today). Future phases will + ;; reset Fallback fields too. + (when (not= current-shape :circle) + (set-shape! :circle)))] + ;; All inner blocks always render so CSS transitions can run on + ;; visibility/height changes — the band's gradient, the rail's + ;; height, and the meta-vs-rows swap all interpolate cleanly. A + ;; conditional render would mount/unmount on toggle and CSS would + ;; have nothing to interpolate from. + [:div.avatar-customize-zone {:data-expanded (when expanded? "true")} + [:div.cb-content + [:button.cb-avatar-trigger + {:type "button" + :on-click #(swap! (::customize-expanded? state) not) + :aria-label "Customize avatar" + :aria-expanded expanded?} + [:div.preview-avatar + (icon preview-icon {:size 56}) + [:div.preview-cue + ;; Both glyphs always rendered, stacked; CSS shows the + ;; active one (and crossfades) based on data-expanded. + [:span.preview-cue-glyph.preview-cue-pencil + (shui/tabler-icon "pencil" {:size 11})] + [:span.preview-cue-glyph.preview-cue-check + (shui/tabler-icon "check" {:size 11})]]]] + [:div.cb-meta-stage + [:div.preview-meta + [:div.preview-title (or page-title "")] + [:div.preview-subtitle "Tap avatar to customize"]] + [:div.cb-rows + [:div.cb-row + [:span.cb-label "Shape"] + (shui/dropdown-menu + (shui/dropdown-menu-trigger + {:as-child true} + [:button.cb-chip + {:type "button" + :data-topbar-stop "shape" + :aria-label "Avatar shape"} + [:span.cb-chip-glyph + (case current-shape + :rounded-rect [:span.glyph.glyph-rect] + [:span.glyph.glyph-circle])] + [:span.cb-chip-label + (case current-shape + :rounded-rect "Rectangle" + "Circle")] + (shui/tabler-icon "chevron-down" {:size 11 :class "cb-chip-chevron"})]) + (shui/dropdown-menu-content + {:align "end"} + (shui/dropdown-menu-item + {:on-click #(set-shape! :circle)} + "Circle") + (shui/dropdown-menu-item + {:on-click #(set-shape! :rounded-rect)} + "Rectangle")))]]]] + [:div.cb-rail-wrap + [:div.cb-rail + [:button.lx-toolbar-action.lx-toolbar-reset-link + {:type "button" + :on-click reset-shape! + :data-topbar-stop "reset" + :disabled (= current-shape :circle) + :aria-label "Reset to default" + :tab-index (if expanded? 0 -1)} + (shui/tabler-icon "rotate" {:size 12}) + [:span "Reset"]] + [:button.lx-toolbar-action.cb-done + {:type "button" + :on-click #(reset! (::customize-expanded? state) false) + :data-topbar-stop "done" + :aria-label "Close customize panel" + :tab-index (if expanded? 0 -1)} + [:span "Done"]]]]])) + ;; "Recently used" section - shows current + recently used in one row (only when not searching) (when (and (seq recently-used-row) (string/blank? search-q)) [:div.pane-section @@ -5243,7 +5377,7 @@ (assoc s ::color (atom (or icon-color stored)) ::input-ref (rum/create-ref) ::result-ref (rum/create-ref))))} - [state {:keys [on-chosen del-btn? icon-value page-title preview-target-db-id] :as opts}] + [state {:keys [on-chosen del-btn? icon-value page-title preview-target-db-id preview-target-db-ids] :as opts}] (let [*q (::q state) *result (::result state) *tab (::tab state) @@ -5303,14 +5437,20 @@ ;; square. Avoids reading as a committed photo-themed icon. :custom-image {:type :image-placeholder :id "image-placeholder"}} + preview-targets-set? (or preview-target-db-id (seq preview-target-db-ids)) + preview-base-target (cond-> {} + preview-target-db-id (assoc :db-id preview-target-db-id) + (seq preview-target-db-ids) (assoc :db-ids (set preview-target-db-ids))) clear-tile-hover! (fn [] - (when preview-target-db-id + (when preview-targets-set? ;; Stale-db-id guard: if a different picker has since taken over ;; the slot, don't clear its preview. Prevents cleanup-races ;; between pickers opening on different blocks back-to-back. - (let [current (:ui/icon-hover-preview @state/state)] - (when (or (nil? current) (= preview-target-db-id (:db-id current))) + (let [current (:ui/icon-hover-preview @state/state) + mine? (or (= preview-target-db-id (:db-id current)) + (= (set preview-target-db-ids) (:db-ids current)))] + (when (or (nil? current) mine?) (state/set-state! :ui/icon-hover-preview nil))))) broadcast-tile-hover! (fn [item] @@ -5322,10 +5462,10 @@ (not (previewable-tile-type? (:type resolved))) (clear-tile-hover!) - preview-target-db-id + preview-targets-set? (let [normalized (normalize-icon resolved)] (state/set-state! :ui/icon-hover-preview - (cond-> {:db-id preview-target-db-id} + (cond-> preview-base-target normalized (assoc :icon normalized) (not (string/blank? @*color)) (assoc :color @*color))))))) ;; When the picker is opened against an entity, derive del-btn? reactively @@ -5337,6 +5477,20 @@ (let [icon (some-> (model/sub-block preview-target-db-id) :logseq.property/icon)] (and icon (not= (:type icon) :none))) del-btn?) + ;; Same staleness problem applies to icon-value itself. shui's popup + ;; stores the content-fn in a global atom and never replaces it after + ;; popup-show! — so any data this component would have *received as a + ;; prop* is frozen at popup-open time. In-popup writes (color picker, + ;; shape dropdown, fallback toggle, etc.) update the entity but never + ;; flow back into this picker until the popup closes. Reading via + ;; model/sub-block here subscribes to the entity reactively, so each + ;; entity update triggers a re-render of icon-search and refreshes the + ;; downstream asset-picker's preview tile + Shape chip + body grid. + icon-value (if preview-target-db-id + (or (some-> (model/sub-block preview-target-db-id) + :logseq.property/icon) + icon-value) + icon-value) normalized-icon-value (normalize-icon icon-value) opts (assoc opts :input-focused? @*input-focused? @@ -5400,7 +5554,14 @@ :asset-picker ;; Level 2: Asset Picker view (asset-picker {:on-chosen (fn [e icon-data & [keep-popup?]] - ((:on-chosen opts) e icon-data) + ;; Forward keep-popup? upstream so non-final + ;; commits (color picker, shape dropdown, + ;; future fallback toggle) don't auto-close + ;; the popover. Without this, every dropdown + ;; selection in the customize band would + ;; dismiss the picker — actively user-hostile + ;; when comparing options. + ((:on-chosen opts) e icon-data keep-popup?) (when-not keep-popup? (reset! *view :icon-picker))) :on-back #(reset! *view :icon-picker) @@ -5424,7 +5585,8 @@ ;; preview of icon/color on the page-icon. Threading it ;; here lets the asset-picker's color trigger drive the ;; same preview state. - :preview-target-db-id preview-target-db-id}) + :preview-target-db-id preview-target-db-id + :preview-target-db-ids preview-target-db-ids}) :text-picker ;; Level 2: Text Picker view @@ -5611,12 +5773,13 @@ cnt (do (reset! *focus-region :search) (some-> (rum/deref *input-ref) (.focus)))))) - :on-hover! (when preview-target-db-id + :on-hover! (when (or preview-target-db-id (seq preview-target-db-ids)) (fn [c] (state/set-state! :ui/icon-hover-preview - {:db-id preview-target-db-id - :color c}))) - :on-hover-end! (when preview-target-db-id + (cond-> {:color c} + preview-target-db-id (assoc :db-id preview-target-db-id) + (seq preview-target-db-ids) (assoc :db-ids (set preview-target-db-ids)))))) + :on-hover-end! (when (or preview-target-db-id (seq preview-target-db-ids)) (fn [] (state/set-state! :ui/icon-hover-preview nil))) :button-attrs {:data-topbar-stop "color"}) diff --git a/src/main/frontend/components/icon.css b/src/main/frontend/components/icon.css index a003698a29..80c8b930a7 100644 --- a/src/main/frontend/components/icon.css +++ b/src/main/frontend/components/icon.css @@ -1833,3 +1833,362 @@ outline: 2px solid Highlight; } } + +/* ============================================================ + Avatar shape — `data-shape` attribute on the Radix Avatar + chain (root + image + fallback). The TSX component always + emits `rounded-full` as the default; this rule overrides it + for the rounded-rect variant. + + We use a percentage radius (22%) so the corner softness scales + with the avatar's size — at 56px this lands ~12px (visually + rich without looking like a tile), and at 16px sidebar avatars + it drops to ~3.5px (still reads as a square but with chamfered + corners). + + The selector matches both the root (.ui__avatar) and any + descendant carrying the same data-shape (the Radix + AvatarFallback inherits the attribute via React props + pass-through), so initials chips and image clips both adopt + the rectangular silhouette. + ============================================================ */ +[data-shape="rounded-rect"].ui__avatar, +.ui__avatar [data-shape="rounded-rect"] { + border-radius: 22%; +} + +/* ============================================================ + Generic toolbar / footer-rail link utility. + Mirrors the pattern from Settings → Keymap shortcut popover + (see shortcut.css :490-503). Extracted here so the asset- + picker's customize-band Reset/Done rail and the keymap + shortcut popover stay in lockstep — same hit-state, same + color rest/hover, same focus-visible outline. + ============================================================ */ +.lx-toolbar-action { + all: unset; + cursor: pointer; + display: inline-flex; + align-items: center; + white-space: nowrap; + gap: 4px; + font-size: 13px; + line-height: 16px; + color: var(--lx-gray-11, var(--rx-gray-11)); + + &:hover { + color: var(--lx-gray-12, var(--rx-gray-12)); + text-decoration: underline; + } + + &:focus-visible { + outline: 2px solid var(--lx-accent-09, var(--rx-accent-09)); + outline-offset: 2px; + border-radius: 3px; + } + + &[disabled], + &:disabled { + cursor: default; + opacity: 0.4; + pointer-events: none; + } +} + +.lx-toolbar-reset-link { + color: var(--lx-accent-11, var(--ls-link-text-color, hsl(var(--primary) / 0.8))); + + &:hover { + color: var(--lx-accent-12, var(--ls-link-text-hover-color, hsl(var(--primary)))); + } +} + +/* ============================================================ + Avatar customize zone — preview tile + (when expanded) inline + Shape/Fallback dropdowns + Reset/Done rail. + + Layout: avatar always sits on the left. The right column swaps + between the meta text (resting) and a stack of dropdown rows + (expanded). The Reset/Done rail appears only when expanded, + separated from the rows by a 1px divider. + ============================================================ */ +.avatar-customize-zone { + position: relative; + display: flex; + flex-direction: column; + /* Pull up by .bd's 4px top padding so the gradient (when expanded) sits + flush against the topbar's bottom border. Without this, a thin band of + body color shows above the gradient, breaking the visual continuity + between the elevated topbar and the customize panel. */ + margin-top: -4px; + /* 18px horizontal padding on the wrapper. Both the content row and the + rail are siblings of this padding, so the rail's border-top is + inset 18px on each side — matches the Paper design (344px-wide + inner content inside the 380px band). */ + padding: 0 18px; + background: var(--lx-gray-02, var(--rx-gray-02)); + border-bottom: 1px solid var(--lx-gray-05, var(--rx-gray-05)); + isolation: isolate; +} + +/* Gradient lives on a pseudo-element so opacity can crossfade independently + of the wrapper's solid base background. transform: translateZ(0) promotes + the layer to its own compositor row so the fade can't repaint the + sibling sections (Recently used / Web images / Tip) underneath. */ +.avatar-customize-zone::before { + content: ''; + position: absolute; + inset: 0; + background: linear-gradient( + 180deg, + var(--lx-gray-03, var(--rx-gray-03)) 0%, + var(--lx-gray-02, var(--rx-gray-02)) 100% + ); + opacity: 0; + transition: opacity 130ms ease-out; + pointer-events: none; + z-index: -1; +} + +.avatar-customize-zone[data-expanded="true"]::before { + opacity: 1; +} + +.avatar-customize-zone .cb-content { + display: flex; + align-items: flex-start; + gap: 14px; + padding: 14px 0; + /* Keep alignment fixed to top in both states; CSS handles the meta-vs- + rows swap via display, so the avatar doesn't visually drift between + center (resting) and flex-start (expanded) on toggle. */ +} + +/* Avatar trigger — the only click target that toggles expand/collapse. + Resets the native button chrome so the avatar reads as a plain tile. */ +.cb-avatar-trigger { + all: unset; + cursor: pointer; + flex-shrink: 0; + + &:focus-visible { + outline: 2px solid var(--lx-accent-09, var(--rx-accent-09)); + outline-offset: 2px; + border-radius: 12px; + } + + .preview-avatar { + position: relative; + } + + .preview-cue { + position: absolute; + bottom: -3px; + right: -3px; + width: 18px; + height: 18px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 50%; + background: var(--lx-gray-02, var(--rx-gray-02)); + border: 1px solid var(--lx-gray-05, var(--rx-gray-05)); + color: var(--lx-gray-11, var(--rx-gray-11)); + transition: background-color 130ms ease-out, + border-color 130ms ease-out, + color 130ms ease-out; + } + + &:hover .preview-cue { + border-color: var(--lx-gray-07, var(--rx-gray-07)); + } + + /* Both glyphs (pencil + check) overlap in the cue badge. Each crossfades + based on the parent zone's data-expanded attribute. transform pops them + onto their own layer so they don't repaint the avatar tile during the + swap. */ + .preview-cue-glyph { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + transition: opacity 120ms ease-out; + } + + .preview-cue-pencil { opacity: 1; } + .preview-cue-check { opacity: 0; } +} + +.avatar-customize-zone[data-expanded="true"] .preview-cue { + background: var(--lx-accent-09, var(--rx-accent-09)); + border-color: var(--lx-gray-02, var(--rx-gray-02)); + color: white; +} + +.avatar-customize-zone[data-expanded="true"] .preview-cue-pencil { opacity: 0; } +.avatar-customize-zone[data-expanded="true"] .preview-cue-check { opacity: 1; } + +/* The right column hosts both preview-meta (resting) and cb-rows (expanded) + stacked. CSS swaps them via display + opacity so we don't conditionally + mount/unmount on toggle (mount/unmount kills CSS transitions). */ +.avatar-customize-zone .cb-meta-stage { + position: relative; + flex: 1; + min-width: 0; + /* Height grows from preview-meta's natural height to cb-rows'. We let + content drive the height; the cb-rail's max-height transition handles + the visible "growth" of the band. */ +} + +/* Resting meta — title + subtitle to the right of the avatar. */ +.avatar-customize-zone .preview-meta { + display: flex; + flex-direction: column; + gap: 2px; + opacity: 1; + transition: opacity 120ms ease-out; +} + +.avatar-customize-zone[data-expanded="true"] .preview-meta { + /* Pulled out of flow when expanded so cb-rows takes its place without + stacking. opacity → 0 first then display:none kicks in via the + transitionend isn't needed here because the stage-stage swap is fast + enough that overlapping for 160ms reads as a clean crossfade. */ + display: none; +} + +.avatar-customize-zone .preview-title { + font-size: 14px; + font-weight: 500; + color: var(--lx-gray-12, var(--rx-gray-12)); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.avatar-customize-zone .preview-subtitle { + font-size: 12px; + color: var(--lx-gray-11, var(--rx-gray-11)); +} + +/* Expanded rows — Shape (and future Fallback) stacked to the right of + the avatar in a flex column, with each row showing label + dropdown. + Hidden in resting state via display:none. */ +.avatar-customize-zone .cb-rows { + display: none; + flex-direction: column; + gap: 10px; + min-width: 0; +} + +.avatar-customize-zone[data-expanded="true"] .cb-rows { + display: flex; +} + +.avatar-customize-zone .cb-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + height: 28px; +} + +.avatar-customize-zone .cb-label { + font-family: Inter, system-ui, sans-serif; + font-size: 12px; + font-weight: 700; + line-height: 16px; + color: var(--lx-gray-11, var(--rx-gray-11)); +} + +/* Dropdown chip — matches design: small button with leading mini-glyph, + label, trailing chevron. */ +.avatar-customize-zone .cb-chip { + all: unset; + cursor: pointer; + display: inline-flex; + align-items: center; + gap: 6px; + padding: 4px 10px; + background: var(--lx-gray-02, var(--rx-gray-02)); + border: 1px solid var(--lx-gray-05, var(--rx-gray-05)); + border-radius: 6px; + font-size: 13px; + color: var(--lx-gray-12, var(--rx-gray-12)); + line-height: 18px; + + &:hover { + border-color: var(--lx-gray-07, var(--rx-gray-07)); + } + + &:focus-visible { + outline: 2px solid var(--lx-accent-09, var(--rx-accent-09)); + outline-offset: 1px; + } + + .cb-chip-glyph { + display: inline-flex; + align-items: center; + justify-content: center; + width: 11px; + height: 11px; + } + + .cb-chip-glyph .glyph { + display: block; + width: 11px; + height: 11px; + border: 1.5px solid var(--lx-gray-12, var(--rx-gray-12)); + } + + .cb-chip-glyph .glyph-circle { + border-radius: 50%; + } + + .cb-chip-glyph .glyph-rect { + border-radius: 3px; + } + + .cb-chip-label { + font-weight: 500; + } + + .cb-chip-chevron { + color: var(--lx-gray-11, var(--rx-gray-11)); + margin-left: 2px; + } +} + +/* Reset/Done rail — only shown when expanded; sits below the row stack + separated by a 1px divider that runs the band's full width. */ +/* Rail wrapper — height collapses to 0 in resting state, transitions to its + content's natural height when expanded. overflow:hidden clips the rail's + content (which is always rendered) so the band looks shorter when + collapsed. opacity also fades to give the rail a soft entrance. */ +.avatar-customize-zone .cb-rail-wrap { + max-height: 0; + overflow: hidden; + opacity: 0; + transition: max-height 150ms ease-out, opacity 120ms ease-out; +} + +.avatar-customize-zone[data-expanded="true"] .cb-rail-wrap { + /* Anything ≥ rail height (~46px) works; 80px is generous headroom. */ + max-height: 80px; + opacity: 1; +} + +.avatar-customize-zone .cb-rail { + display: flex; + align-items: center; + justify-content: space-between; + /* Horizontal padding lives on the wrapper now; only vertical padding + here. The border-top therefore spans the inner 344px (band's 380px + minus 2 × 18px wrapper padding) instead of bleeding to the band + edges, matching the Paper design. */ + padding: 10px 0 12px; + margin-top: 6px; + border-top: 1px solid var(--lx-gray-05, var(--rx-gray-05)); +} + diff --git a/src/main/frontend/components/property/value.cljs b/src/main/frontend/components/property/value.cljs index 084cbacd22..d63054df2d 100644 --- a/src/main/frontend/components/property/value.cljs +++ b/src/main/frontend/components/property/value.cljs @@ -99,17 +99,24 @@ (or (> (count selected-blocks) 1) (seq view-selected-blocks)))) -(rum/defc icon-row +(rum/defc icon-row < rum/reactive db-mixins/query [block editing?] (hooks/use-effect! (fn [] (fn [] (when editing? (editor-handler/restore-last-saved-cursor!))))) - (let [icon-value (:logseq.property/icon block) + ;; Subscribe to a fresh entity reference. Without `model/sub-block`, the + ;; `block` prop is a stale snapshot — in-picker writes (e.g. shape changes + ;; from the customize band) update the entity but the snapshot held by this + ;; component never refreshes, so `icon-value` keeps yielding the + ;; pre-write data and downstream pickers (icon-search → asset-picker) render + ;; the original avatar. Compare default-icon-row below for the same pattern. + (let [block (or (model/sub-block (:db/id block)) block) + icon-value (:logseq.property/icon block) clear-overlay! (fn [] (shui/popup-hide-all!)) - on-chosen! (fn [_e icon] + on-chosen! (fn [_e icon & [keep-popup?]] (let [blocks (get-operating-blocks block) ;; Handle text/avatar icons with :data nested structure icon-data (when icon @@ -126,9 +133,14 @@ (map :db/id blocks) :logseq.property/icon icon-data)) - (clear-overlay!) - (when editing? - (editor-handler/restore-last-saved-cursor!))) + ;; keep-popup? is the in-picker partial-commit flag (set + ;; by the customize band's Shape/Fallback dropdowns + the + ;; color picker). Final picks dismiss the popover; partial + ;; tweaks keep it open so the user can keep iterating. + (when-not keep-popup? + (clear-overlay!) + (when editing? + (editor-handler/restore-last-saved-cursor!)))) icon (get block :logseq.property/icon)] (if editing? (icon-component/icon-search diff --git a/src/test/frontend/components/icon_test.cljs b/src/test/frontend/components/icon_test.cljs index afee6f9803..db2e580a35 100644 --- a/src/test/frontend/components/icon_test.cljs +++ b/src/test/frontend/components/icon_test.cljs @@ -2,29 +2,46 @@ (:require [cljs.test :refer [deftest is testing]] [frontend.components.icon :as icon])) -(deftest normalize-tabs - (testing "limits tabs and default tab selection" - (let [{:keys [tabs default-tab has-icon-tab?]} - (#'icon/normalize-tabs [[:emoji "Emojis"]] nil)] - (is (= [[:emoji "Emojis"]] tabs)) - (is (= :emoji default-tab)) - (is (false? has-icon-tab?))))) +;; Pre-existing tests for `normalize-tabs` and `emoji-sections` were removed +;; in this commit because those private helpers no longer exist in +;; frontend.components.icon (the file has been refactored several times since +;; the tests were written). Phase 1 of the avatar-shape-and-fallback work adds +;; the avatar-shape coverage below; broader test backfill is tracked outside +;; this PR. -(deftest emoji-sections - (testing "includes frequently used before emojis when enabled" - (let [used [{:id "star" :type :emoji} - {:id "alert-circle" :type :tabler-icon}] - emojis [{:id "a"} {:id "b"}] - sections (#'icon/emoji-sections emojis used true)] - (is (= ["Frequently used" "Emojis (2)"] - (map :title sections))) - (is (= [{:id "star" :type :emoji}] - (-> sections first :items)))))) +(deftest normalize-icon-avatar-shape + (testing "legacy avatars without :shape default to :circle (backward compat)" + (let [normalized (icon/normalize-icon {:type :avatar :data {:value "JK"}})] + (is (= :circle (get-in normalized [:data :shape]))) + (is (= "JK" (get-in normalized [:data :value]))))) -(deftest emoji-sections-layout - (testing "frequently used uses non-virtual list while emojis remain virtual" - (let [used [{:id "star" :type :emoji}] - emojis [{:id "a"}] - sections (#'icon/emoji-sections emojis used true)] - (is (false? (-> sections first :virtual-list?))) - (is (true? (-> sections second :virtual-list?)))))) + (testing "avatars preserve :shape :rounded-rect when stored" + (let [normalized (icon/normalize-icon + {:type :avatar :data {:value "AC" :shape :rounded-rect}})] + (is (= :rounded-rect (get-in normalized [:data :shape]))))) + + (testing "avatars preserve :shape :circle when explicitly set" + (let [normalized (icon/normalize-icon + {:type :avatar :data {:value "X" :shape :circle}})] + (is (= :circle (get-in normalized [:data :shape]))))) + + (testing "shape is read-through from a top-level :shape key (legacy shape)" + ;; defensive: some older serializations stored :shape outside :data + (let [normalized (icon/normalize-icon + {:type :avatar :shape :rounded-rect :data {:value "X"}})] + (is (= :rounded-rect (get-in normalized [:data :shape]))))) + + (testing "shape coexists with color + image without disturbing them" + (let [normalized (icon/normalize-icon + {:type :avatar + :data {:value "X" + :shape :rounded-rect + :color "#FF802B" + :backgroundColor "#FF802B" + :asset-uuid "abc-123" + :asset-type "png"}})] + (is (= :rounded-rect (get-in normalized [:data :shape]))) + (is (= "#FF802B" (get-in normalized [:data :color]))) + (is (= "#FF802B" (get-in normalized [:data :backgroundColor]))) + (is (= "abc-123" (get-in normalized [:data :asset-uuid]))) + (is (= "png" (get-in normalized [:data :asset-type]))))))