mirror of
https://github.com/logseq/logseq.git
synced 2026-05-16 17:02:34 +00:00
feat: avatar shape + customize band in asset-picker
Adds a Shape dimension to avatar icons (circle | rounded-rect) with inheritance through `:logseq.property.class/default-icon`. Surfaced via a tap-the-preview customize band in the asset-picker (avatar mode) with a Shape dropdown and a keymap-style Reset/Done rail. Animates open/close. Highlights: - normalize-icon defaults `:shape :circle` for legacy data on both the slow path and the already-shaped fast path. - get-node-icon extends class-default `select-keys` to propagate :shape. - avatar.tsx accepts `data-shape`; CSS in icon.css drives the radius via `[data-shape="rounded-rect"]` selectors (avoiding Tailwind JIT issues with conditional arbitrary-value classes). - Customize band: preview tile + Shape dropdown + Reset/Done rail. All blocks always rendered so CSS transitions can interpolate height, gradient, and the cue badge crossfade. Layout matches Paper artboard 99K-1 / 97A-1 (344px inner content inside 380px band, inset rail separator, gradient flush against topbar). - Fixes `keep-popup?` plumbing at three forwarding wrappers (asset- picker, icon-search, icon-picker) and the topmost on-chosen handler in property/value.cljs. Single click now produces a single write instead of the prior triple-write race. - icon-row (property/value.cljs) and icon-search (icon.cljs) both made reactive via model/sub-block — so in-popup commits update the picker preview/chip live, not just the page-header avatar. - Lazy `*text-measure-ctx` so the namespace loads in the Node test runner (was previously blocking all icon tests). - New `.lx-toolbar-action` / `.lx-toolbar-reset-link` utility CSS mirrors Settings → Keymap shortcut popover footer styling. - 10 new test assertions for shape default, preservation, fast-path handling, and field independence. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -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<typeof AvatarPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
|
||||
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root> & ShapeProps
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AvatarPrimitive.Root
|
||||
ref={ref}
|
||||
@@ -21,7 +28,7 @@ Avatar.displayName = AvatarPrimitive.Root.displayName
|
||||
|
||||
const AvatarImage = React.forwardRef<
|
||||
React.ElementRef<typeof AvatarPrimitive.Image>,
|
||||
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
|
||||
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image> & ShapeProps
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AvatarPrimitive.Image
|
||||
ref={ref}
|
||||
@@ -33,7 +40,7 @@ AvatarImage.displayName = AvatarPrimitive.Image.displayName
|
||||
|
||||
const AvatarFallback = React.forwardRef<
|
||||
React.ElementRef<typeof AvatarPrimitive.Fallback>,
|
||||
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
|
||||
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback> & ShapeProps
|
||||
>(({ className, ...props }, ref) => (
|
||||
<AvatarPrimitive.Fallback
|
||||
ref={ref}
|
||||
|
||||
@@ -49,10 +49,13 @@
|
||||
(defonce *upload-status (atom ""))
|
||||
(defonce *uploading-files (atom {}))
|
||||
|
||||
;; Offscreen canvas for measuring text width (never attached to DOM)
|
||||
;; Offscreen canvas for measuring text width (never attached to DOM).
|
||||
;; Lazily constructed so the namespace can load in environments without a DOM
|
||||
;; (e.g. the :node-test build).
|
||||
(defonce *text-measure-ctx
|
||||
(let [canvas (js/document.createElement "canvas")]
|
||||
(.getContext canvas "2d")))
|
||||
(delay
|
||||
(let [canvas (js/document.createElement "canvas")]
|
||||
(.getContext canvas "2d"))))
|
||||
|
||||
(declare normalize-icon derive-initials derive-avatar-initials
|
||||
<search-wikipedia-image <save-url-asset! open-image-asset-picker!)
|
||||
@@ -329,6 +332,7 @@
|
||||
avatar-value (get avatar-data :value "")
|
||||
explicit-bg (get avatar-data :backgroundColor)
|
||||
explicit-color (get avatar-data :color)
|
||||
shape (or (get avatar-data :shape) :circle)
|
||||
display-text (subs avatar-value 0 (min 3 (count avatar-value)))
|
||||
;; Scale font-size with avatar size
|
||||
font-size (cond
|
||||
@@ -337,24 +341,28 @@
|
||||
(<= size 32) "12px"
|
||||
:else "14px")]
|
||||
(shui/avatar
|
||||
{:style {:width size :height size}}
|
||||
{:style {:width size :height size}
|
||||
:data-shape (name shape)}
|
||||
;; Image (shows when loaded, circular with cover fit)
|
||||
(when url
|
||||
(shui/avatar-image {:src url
|
||||
:style {:object-fit "cover"}}))
|
||||
:style {:object-fit "cover"}
|
||||
:data-shape (name shape)}))
|
||||
;; Fallback (shows while loading or on error)
|
||||
(shui/avatar-fallback
|
||||
{:style (avatar-fallback-style {:font-size font-size
|
||||
:bg explicit-bg
|
||||
:color explicit-color})}
|
||||
:color explicit-color})
|
||||
:data-shape (name shape)}
|
||||
display-text))))
|
||||
|
||||
(defn measure-text-width
|
||||
"Measure pixel width of text at given font-size using offscreen canvas."
|
||||
[text font-size-px]
|
||||
(set! (.-font *text-measure-ctx)
|
||||
(str "500 " font-size-px "px Inter, sans-serif"))
|
||||
(.-width (.measureText *text-measure-ctx text)))
|
||||
(let [ctx @*text-measure-ctx]
|
||||
(set! (.-font ctx)
|
||||
(str "500 " font-size-px "px Inter, sans-serif"))
|
||||
(.-width (.measureText ctx text))))
|
||||
|
||||
(defn svg-text-font-size
|
||||
"Compute font-size in viewBox coords (0-100) that makes text fill ~85% width.
|
||||
@@ -521,6 +529,7 @@
|
||||
avatar-value (get avatar-data :value)
|
||||
explicit-bg (get avatar-data :backgroundColor)
|
||||
explicit-color (get avatar-data :color)
|
||||
shape (or (get avatar-data :shape) :circle)
|
||||
display-text (subs avatar-value 0 (min 3 (count avatar-value)))
|
||||
;; Scale font-size with avatar size
|
||||
font-size (cond
|
||||
@@ -529,11 +538,13 @@
|
||||
(<= size 32) "12px"
|
||||
:else "14px")]
|
||||
(shui/avatar
|
||||
{:style {:width size :height size}}
|
||||
{:style {:width size :height size}
|
||||
:data-shape (name shape)}
|
||||
(shui/avatar-fallback
|
||||
{:style (avatar-fallback-style {:font-size font-size
|
||||
:bg explicit-bg
|
||||
:color explicit-color})}
|
||||
:color explicit-color})
|
||||
:data-shape (name shape)}
|
||||
display-text)))))
|
||||
|
||||
;; Image with asset — let image-icon-cp resolve via the filesystem
|
||||
@@ -601,11 +612,13 @@
|
||||
default-icon
|
||||
(case (:type default-icon)
|
||||
:avatar (when (:block/title node-entity)
|
||||
(let [colors (select-keys (:data default-icon) [:backgroundColor :color])]
|
||||
;; Inherit color + shape from the class default. New :shape
|
||||
;; field defaults via normalize-icon when missing.
|
||||
(let [inherited (select-keys (:data default-icon) [:backgroundColor :color :shape])]
|
||||
(cond-> {: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"})
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]))))))
|
||||
|
||||
Reference in New Issue
Block a user