diff --git a/src/main/frontend/components/block.cljs b/src/main/frontend/components/block.cljs index baf5fee124..1dacbb59b4 100644 --- a/src/main/frontend/components/block.cljs +++ b/src/main/frontend/components/block.cljs @@ -2570,6 +2570,7 @@ :logseq.property/icon {:type :none}))) :del-btn? true ;; Always show delete when icon displayed + :page-title (:block/title block) :icon-props {:size 16}})]))))) (when-not table? @@ -3077,6 +3078,7 @@ (:db/id block) :logseq.property.class/default-icon))))) :del-btn? (boolean icon') + :page-title (:block/title block) :icon-props {:style {:width "1lh" :height "1lh" :font-size (cond diff --git a/src/main/frontend/components/block.css b/src/main/frontend/components/block.css index 5212aaeaf0..5230394a83 100644 --- a/src/main/frontend/components/block.css +++ b/src/main/frontend/components/block.css @@ -213,6 +213,15 @@ height: initial; } +/* Ensure page title has correct font-size in both editing and non-editing mode */ +.ls-page-title-container { + font-size: var(--ls-page-title-size, 32px) !important; +} + +.ls-page-title-container .block-title-wrap { + font-size: inherit; +} + .block-title-wrap { @apply w-full inline; @@ -1097,6 +1106,21 @@ html.is-mac { height: 38px; } +/* Image icons in page title - fill the button like avatars do */ +.ls-page-title .ls-page-icon .image-icon { + width: 2rem !important; /* 32px, matching avatar size */ + height: 2rem !important; + display: flex; + align-items: center; + justify-content: center; +} + +.ls-page-title .ls-page-icon .image-icon img { + width: 100%; + height: 100%; + object-fit: contain; +} + /* Scale up avatars in page title context */ .ls-page-title .ls-page-icon .ui__avatar { width: 2rem; /* 32px */ diff --git a/src/main/frontend/components/container.css b/src/main/frontend/components/container.css index 743b49875b..b5b65d1fe6 100644 --- a/src/main/frontend/components/container.css +++ b/src/main/frontend/components/container.css @@ -244,6 +244,18 @@ > .ui__icon { @apply mr-0; } + + /* Image icons in sidebar - fit without cropping, match container width */ + .image-icon { + width: 20px !important; + height: 20px !important; + + img { + width: 100%; + height: 100%; + object-fit: contain; + } + } } } diff --git a/src/main/frontend/components/icon.cljs b/src/main/frontend/components/icon.cljs index 03d4950771..04a423cc78 100644 --- a/src/main/frontend/components/icon.cljs +++ b/src/main/frontend/components/icon.cljs @@ -5,6 +5,7 @@ [camel-snake-kebab.core :as csk] [cljs-bean.core :as bean] [clojure.string :as string] + [electron.ipc :as ipc] [frontend.colors :as colors] [frontend.config :as config] [frontend.date :as date] @@ -16,7 +17,6 @@ [frontend.fs :as fs] [frontend.handler.assets :as assets-handler] [frontend.handler.editor :as editor-handler] - [electron.ipc :as ipc] [frontend.search :as search] [frontend.state :as state] [frontend.storage :as storage] @@ -166,7 +166,7 @@ url [:span.ui__icon.image-icon.flex.items-center.justify-center - {:style {:max-width size :max-height size}} + {:style {:width size :height size}} [:img {:src url :loading "lazy" @@ -178,6 +178,52 @@ [:span.ui__icon.image-icon.bg-gray-04.animate-pulse {:style {:width size :height size}}]))) +(rum/defcs avatar-image-cp < rum/reactive + (rum/local nil ::url) + (rum/local false ::error) + (rum/local nil ::loaded-for) + {:did-mount (fn [state] + (let [[asset-uuid asset-type _avatar-data _opts] (:rum/args state) + *url (::url state) + *error (::error state) + *loaded-for (::loaded-for state)] + (load-image-url! asset-uuid asset-type *url *error *loaded-for)) + state) + :did-update (fn [state] + (let [[asset-uuid asset-type _avatar-data _opts] (:rum/args state) + *loaded-for (::loaded-for state)] + (when (not= @*loaded-for [asset-uuid asset-type]) + (let [*url (::url state) + *error (::error state)] + (load-image-url! asset-uuid asset-type *url *error *loaded-for)))) + state)} + "Renders an avatar with an image, with initials as fallback. + Uses shui/avatar for circular display with object-fit: cover." + [state _asset-uuid _asset-type avatar-data opts] + (let [url @(::url state) + ;; Fallback data from avatar + avatar-value (get avatar-data :value "") + backgroundColor (or (get avatar-data :backgroundColor) + (colors/variable :gray :09)) + color (or (get avatar-data :color) + (colors/variable :gray :09)) + display-text (subs avatar-value 0 (min 3 (count avatar-value))) + bg-color-rgba (convert-bg-color-to-rgba backgroundColor) + font-size (if (<= (:size opts 20) 16) "8px" "14px")] + (shui/avatar + {:class "w-5 h-5"} + ;; Image (shows when loaded, circular with cover fit) + (when url + (shui/avatar-image {:src url + :style {:object-fit "cover"}})) + ;; Fallback (shows while loading or on error) + (shui/avatar-fallback + {:style {:background-color bg-color-rgba + :font-size font-size + :font-weight "500" + :color color}} + display-text)))) + (defn icon [icon' & [opts]] (let [normalized (or (normalize-icon icon') icon') @@ -210,24 +256,29 @@ display-text]]) (and (map? normalized) (= :avatar (:type normalized)) (get-in normalized [:data :value])) - (let [avatar-value (get-in normalized [:data :value]) - ;; Default to gray Radix color - backgroundColor (or (get-in normalized [:data :backgroundColor]) - (colors/variable :gray :09)) - color (or (get-in normalized [:data :color]) - (colors/variable :gray :09)) - display-text (subs avatar-value 0 (min 3 (count avatar-value))) - bg-color-rgba (convert-bg-color-to-rgba backgroundColor) - ;; Font size: 8px for sidebar contexts, 14px for page titles - font-size (if (<= (:size opts 20) 16) "8px" "14px")] - (shui/avatar - {:class "w-5 h-5"} - (shui/avatar-fallback - {:style {:background-color bg-color-rgba - :font-size font-size - :font-weight "500" - :color color}} - display-text))) + (let [avatar-data (get normalized :data) + asset-uuid (get avatar-data :asset-uuid) + asset-type (get avatar-data :asset-type)] + (if asset-uuid + ;; Avatar with image - use async loading component + (avatar-image-cp asset-uuid asset-type avatar-data opts) + ;; Text-only avatar + (let [avatar-value (get avatar-data :value) + backgroundColor (or (get avatar-data :backgroundColor) + (colors/variable :gray :09)) + color (or (get avatar-data :color) + (colors/variable :gray :09)) + display-text (subs avatar-value 0 (min 3 (count avatar-value))) + bg-color-rgba (convert-bg-color-to-rgba backgroundColor) + font-size (if (<= (:size opts 20) 16) "8px" "14px")] + (shui/avatar + {:class "w-5 h-5"} + (shui/avatar-fallback + {:style {:background-color bg-color-rgba + :font-size font-size + :font-weight "500" + :color color}} + display-text))))) (and (map? normalized) (= :image (:type normalized)) (get-in normalized [:data :asset-uuid])) (let [asset-uuid (get-in normalized [:data :asset-uuid]) @@ -382,13 +433,18 @@ :avatar (let [backgroundColor (or (:backgroundColor v) (colors/variable :gray :09)) color (or (:color v) - (colors/variable :gray :09))] + (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))] {:type :avatar :id (or id (str "avatar-" value)) :label (or label value) - :data {:value value - :backgroundColor backgroundColor - :color color}}) + :data (cond-> {:value value + :backgroundColor backgroundColor + :color color} + 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) raw-uuid (or (get-in v [:data :asset-uuid]) (:asset-uuid v) value) asset-uuid (if (and (string? raw-uuid) (string/starts-with? raw-uuid "image-")) @@ -701,8 +757,8 @@ (shui/shortcut keyboard-hint {:style :compact})])]) (rum/defc pane-section - [label icon-items & {:keys [collapsible? keyboard-hint total-count searching? virtual-list? render-item-fn expanded? input-focused?] - :or {virtual-list? true collapsible? false expanded? true input-focused? false} + [label icon-items & {:keys [collapsible? keyboard-hint total-count searching? virtual-list? render-item-fn expanded? input-focused? show-header?] + :or {virtual-list? true collapsible? false expanded? true input-focused? false show-header? true} :as opts}] (let [*el-ref (rum/use-ref nil) render-fn (or render-item-fn render-item) @@ -714,17 +770,18 @@ [{:has-virtual-list virtual-list? :searching-result searching?}])} ;; Use new collapsible header when collapsible? is true - (if collapsible? - (section-header {:title label - :count (count icon-items) - :total-count total-count - :expanded? expanded? - :keyboard-hint keyboard-hint - :on-toggle toggle-fn - :input-focused? input-focused?}) - ;; Simple header (current style) for non-collapsible - [:div.hd.px-1.pb-1.leading-none - [:strong.text-xs.font-medium.text-gray-07.dark:opacity-80 label]]) + (when show-header? + (if collapsible? + (section-header {:title label + :count (count icon-items) + :total-count total-count + :expanded? expanded? + :keyboard-hint keyboard-hint + :on-toggle toggle-fn + :input-focused? input-focused?}) + ;; Simple header (current style) for non-collapsible + [:div.hd.px-1.pb-1.leading-none + [:strong.text-xs.font-medium.text-gray-07.dark:opacity-80 label]])) ;; Content - only render if expanded or not collapsible (when (or (not collapsible?) expanded?) @@ -762,9 +819,11 @@ :data {:value (:id emoji)}}) emojis*)] (pane-section - (util/format "Emojis (%s)" (count emojis*)) + "Emojis" icon-items - opts))) + :show-header? false + :on-chosen (:on-chosen opts) + :on-hover (:on-hover opts)))) (rum/defc icons-cp < rum/static [icons opts] @@ -775,9 +834,11 @@ :data {:value icon-name}}) icons)] (pane-section - (util/format "Icons (%s)" (count icons)) + "Icons" icon-items - opts))) + :show-header? false + :on-chosen (:on-chosen opts) + :on-hover (:on-hover opts)))) (defn get-used-items [] @@ -961,9 +1022,11 @@ (rum/defcs image-asset-item < rum/reactive (rum/local nil ::url) + (rum/local false ::error) {:did-mount (fn [state] (let [[asset _opts] (:rum/args state) *url (::url state) + *error (::error state) asset-type (:logseq.property.asset/type asset) asset-uuid (:block/uuid asset)] (when (and asset-uuid asset-type) @@ -971,27 +1034,43 @@ asset-path (path/path-join (str "../" common-config/local-assets-dir) file)] (-> (assets-handler/ (rum/deref *input-ref) (.blur)) - (shui/popup-hide!) - (reset-q!))) - 38 (do (util/stop e)) - (9 40) (do - (reset! *select-mode? true) - (util/stop e)) - :dune)) - :on-change (debounce - (fn [e] - (reset! *q (util/evalue e)) - (reset! *select-mode? false) - (if (string/blank? @*q) - (reset! *result {}) - (p/let [result (search @*q @*tab)] - (reset! *result result)))) - 200)})] - (when-not (string/blank? @*q) - [:a.x {:on-click reset-q!} (shui/tabler-icon "x" {:size 14})])] - ;; color picker (always visible) - (color-picker *color (fn [c] - (cond - (or (= :icon (:type normalized-icon-value)) - (= :text (:type normalized-icon-value))) - (on-chosen nil (assoc-in normalized-icon-value [:data :color] c) true) + ;; Topbar: search + separator + tabs + [:div.icon-picker-topbar + [:div.search-section + (tab-observer @*tab {:q @*q :*result *result}) + (keyboard-shortcut-observer @*tab *input-focused?) + (when @*select-mode? + (select-observer *input-ref)) + [:div.search-input + (shui/tabler-icon "search" {:size 16}) + [(shui/input + {:auto-focus true + :ref *input-ref + :placeholder "Search emojis, icons, assets..." + :default-value "" + :on-focus #(do (reset! *select-mode? false) + (reset! *input-focused? true)) + :on-blur #(reset! *input-focused? false) + :on-key-down (fn [^js e] + (case (.-keyCode e) + ;; esc + 27 (do (util/stop e) + (if (string/blank? @*q) + (shui/popup-hide!) + (reset-q!))) + 38 (do (util/stop e)) + (9 40) (do + (reset! *select-mode? true) + (util/stop e)) + :dune)) + :on-change (debounce + (fn [e] + (reset! *q (util/evalue e)) + (reset! *select-mode? false) + (if (string/blank? @*q) + (reset! *result {}) + (p/let [result (search @*q @*tab)] + (reset! *result result)))) + 200)})] + (when-not (string/blank? @*q) + [:a.x {:on-click reset-q!} (shui/tabler-icon "x" {:size 14})])] - (= :avatar (:type normalized-icon-value)) - (on-chosen nil (-> normalized-icon-value - (assoc-in [:data :color] c) - (assoc-in [:data :backgroundColor] c)) true)))) + ;; color picker (always visible) + (color-picker *color (fn [c] + (cond + (or (= :icon (:type normalized-icon-value)) + (= :text (:type normalized-icon-value))) + (on-chosen nil (assoc-in normalized-icon-value [:data :color] c) true) - ;; delete button - (when del-btn? - (shui/button {:variant :outline :size :sm :data-action "del" - :on-click #(on-chosen nil)} - (shui/tabler-icon "trash" {:size 17})))] + (= :avatar (:type normalized-icon-value)) + (on-chosen nil (-> normalized-icon-value + (assoc-in [:data :color] c) + (assoc-in [:data :backgroundColor] c)) true)))) - ;; separator - (shui/separator {:class "my-0 icon-picker-separator"}) + ;; delete button + (when del-btn? + (shui/button {:variant :outline :size :sm :data-action "del" + :on-click #(on-chosen nil)} + (shui/tabler-icon "trash" {:size 17})))] - ;; tabs section - [:div.tabs-section - (let [tabs [[:all "All"] [:emoji "Emojis"] [:icon "Icons"] [:custom "Custom"]]] - (for [[id label] tabs - :let [active? (= @*tab id)]] - [:button.tab-item - {:key (name id) - :data-active (when active? "true") - :on-mouse-down (fn [e] - (util/stop e) - (reset! *tab id))} - label]))] + (shui/separator {:class "my-0 icon-picker-separator"}) - ;; body - [:div.bd.bd-scroll - {:ref *result-ref - :class (or (some-> @*tab (name)) "other")} - [:div.content-pane - (if (seq result) - (let [section-states (rum/react *section-states)] - [:div.flex.flex-1.flex-col.search-result - ;; Emojis section - (when (seq (:emojis result)) - (pane-section - "Emojis" - (:emojis result) - (assoc opts - :collapsible? true - :keyboard-hint "alt mod 2" - :total-count (count (:emojis result)) - :virtual-list? false - :expanded? (get section-states "Emojis" true)))) + [:div.tabs-section + (let [tabs [[:all "All"] [:emoji "Emojis"] [:icon "Icons"] [:custom "Custom"]]] + (for [[id label] tabs + :let [active? (= @*tab id)]] + [:button.tab-item + {:key (name id) + :data-active (when active? "true") + :on-mouse-down (fn [e] + (util/stop e) + (reset! *tab id))} + label]))]] - ;; Icons section - (when (seq (:icons result)) - (pane-section - "Icons" - (:icons result) - (assoc opts - :collapsible? true - :keyboard-hint "alt mod 3" - :total-count (count (:icons result)) - :virtual-list? false - :expanded? (get section-states "Icons" true))))]) - [:div.flex.flex-1.flex-col.gap-1 - (case @*tab - :emoji (emojis-cp emojis opts) - :icon (icons-cp (get-tabler-icons) opts) - :custom (custom-tab-cp *q page-title *color *view icon-value opts) - (all-cp opts))])]]]))) + ;; Body + [:div.bd.bd-scroll + {:ref *result-ref + :class (or (some-> @*tab (name)) "other")} + [:div.content-pane + ;; Custom tab always shows its own content (Text/Avatar/Image buttons) + (if (= @*tab :custom) + (custom-tab-cp *q page-title *color *view icon-value opts) + ;; Other tabs: show search results if present, else show tab content + (if (seq result) + (let [section-states (rum/react *section-states)] + [:div.flex.flex-1.flex-col.search-result + ;; Emojis section + (when (seq (:emojis result)) + (pane-section + "Emojis" + (:emojis result) + (assoc opts + :collapsible? true + :keyboard-hint "alt mod 2" + :total-count (count (:emojis result)) + :virtual-list? false + :expanded? (get section-states "Emojis" true)))) + + ;; Icons section + (when (seq (:icons result)) + (pane-section + "Icons" + (:icons result) + (assoc opts + :collapsible? true + :keyboard-hint "alt mod 3" + :total-count (count (:icons result)) + :virtual-list? false + :expanded? (get section-states "Icons" true))))]) + [:div.flex.flex-1.flex-col.gap-1 + (case @*tab + :emoji (emojis-cp emojis opts) + :icon (icons-cp (get-tabler-icons) opts) + (all-cp opts))]))]]]))) (rum/defc icon-picker [icon-value {:keys [empty-label disabled? initial-open? del-btn? on-chosen icon-props popup-opts button-opts page-title]}] diff --git a/src/main/frontend/components/icon.css b/src/main/frontend/components/icon.css index d07a65aedf..52e42e1620 100644 --- a/src/main/frontend/components/icon.css +++ b/src/main/frontend/components/icon.css @@ -8,7 +8,12 @@ background-color: var(--rx-gray-05) !important; } - > .search-section { + /* Topbar wrapper for easier inspection */ + .icon-picker-topbar { + @apply flex-shrink-0; + } + + .search-section { @apply flex items-center gap-2 py-1 pr-3; @apply flex-shrink-0; background-color: var(--lx-gray-03); @@ -176,6 +181,11 @@ @apply -m-1; } +/* Same negative margin to counteract dropdown p-1 padding */ +.ui__dropdown-menu-content .asset-picker { + @apply -m-1; +} + .ls-icon { &-Backlog { @apply text-gray-05; @@ -252,9 +262,10 @@ color: var(--rx-gray-11); } -/* Asset Picker (Level 2 view) */ +/* Asset Picker (Level 2 view) - Figma aligned */ .asset-picker { - @apply flex flex-col overflow-hidden; + @apply flex flex-col overflow-hidden relative; + /* Match icon-picker width for visual continuity */ width: 380px; min-width: 380px; min-height: 320px; @@ -262,65 +273,97 @@ background-color: var(--lx-gray-02); } -.asset-picker-header { - @apply flex items-center gap-2 px-3 py-2; +/* Topbar wrapper for easier inspection */ +.asset-picker-topbar { @apply flex-shrink-0; +} + +/* Back button as link-style button */ +.asset-picker-back { + @apply flex-shrink-0; + /* py-2 + content height matches icon-picker topbar */ + padding-top: 8px; + padding-bottom: 7px; + /* 14px aligns chevron icon with search icon below */ + padding-left: 14px; + padding-right: 14px; background-color: var(--lx-gray-03); - border-bottom: 1px solid var(--rx-gray-06); + border-bottom: 1px solid var(--rx-gray-05); .back-button { - @apply p-1 rounded cursor-pointer; + @apply flex items-center gap-0.5; + @apply text-sm cursor-pointer; + @apply border-none bg-transparent p-0; @apply transition-colors duration-150; - @apply border-none bg-transparent; - color: var(--rx-gray-11); + color: var(--rx-gray-10); &:hover { - background-color: var(--rx-gray-04); + color: var(--rx-gray-12); } } } +/* Search input matching icon picker */ .asset-picker-search { - @apply px-3 py-2; + @apply py-1; @apply flex-shrink-0; background-color: var(--lx-gray-03); + /* Grey line at bottom of header section, matching icon picker */ + border-bottom: 1px solid var(--rx-gray-06); - .ui__input { - @apply h-8 text-sm; - @apply bg-gray-03 border-none; - box-shadow: none !important; + .search-input { + @apply relative flex-1 px-2; - &:focus { - @apply bg-gray-03; + .ls-icon-search { + @apply absolute left-[14px] top-[8px] opacity-50; + } + + .ui__input { + @apply leading-none pl-8 outline-none border-none bg-gray-03; + @apply focus:bg-gray-03 !h-8; + box-shadow: none !important; } } } +/* Section header matching icon picker */ .asset-picker-section-header { - @apply px-3 py-2 text-xs font-medium; + @apply flex items-center justify-between px-4 py-1; + @apply text-[10px] tracking-tight; @apply flex-shrink-0; - color: var(--rx-gray-10); - background-color: var(--lx-gray-02); - border-bottom: 1px solid var(--rx-gray-05); + color: var(--rx-gray-11); + + .section-title { + @apply flex items-center gap-0.5; + } + + .section-actions { + @apply flex items-center gap-2; + + .shortcut { + @apply text-xs; + } + } } +/* Grid layout - 5 columns for larger, scannable images */ .asset-picker-grid { @apply grid gap-2 p-3; @apply flex-1 overflow-y-auto; grid-template-columns: repeat(5, 1fr); min-height: 120px; align-content: start; + /* Add padding at bottom for floating action buttons */ + padding-bottom: 56px; } +/* Image items - fill grid cell with proper aspect ratio */ .image-asset-item { - @apply overflow-hidden rounded; - @apply cursor-pointer; + @apply overflow-hidden rounded cursor-pointer; @apply transition-all duration-150; - @apply border-none p-0; - /* Square aspect ratio - explicit since aspect-square not available */ - width: 100%; - padding-bottom: 100%; position: relative; + width: 100%; + padding-bottom: 100%; /* Square aspect ratio */ border: 2px solid transparent; background-color: var(--rx-gray-04); @@ -333,28 +376,69 @@ border-color: var(--rx-accent-09); } - img, - > div { - @apply absolute inset-0 w-full h-full object-cover; + img { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + object-fit: cover; + border-radius: 4px; } -} -.asset-picker-actions { - @apply px-3 py-2; - @apply flex-shrink-0; - border-top: 1px solid var(--rx-gray-06); - background-color: var(--lx-gray-03); + > div { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + border-radius: 4px; + } - .upload-button { - @apply flex items-center justify-center gap-2; - @apply w-full py-2 px-3 rounded-md; - @apply text-sm font-medium cursor-pointer; - @apply transition-colors duration-150; - background-color: var(--rx-gray-04); - color: var(--rx-gray-11); + /* Avatar mode - circular previews */ + &.avatar-mode { + @apply rounded-full; - &:hover { - background-color: var(--rx-gray-05); + img { + @apply rounded-full; + } + + > div { + @apply rounded-full; + } + } +} + +/* Action buttons - floating at bottom */ +.asset-picker-actions { + @apply flex items-center justify-center gap-2 px-3 py-2; + @apply absolute bottom-2 left-1/2 -translate-x-1/2; + @apply rounded-lg; + background-color: var(--lx-gray-03); + border: 1px solid var(--rx-gray-06); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); + + .secondary-button { + @apply flex items-center gap-1.5 px-2.5 py-1.5 rounded-md; + @apply text-xs font-medium cursor-pointer; + @apply transition-colors duration-150 whitespace-nowrap; + background-color: var(--rx-gray-06); + color: var(--rx-gray-12); + border: none; + + &:hover { + background-color: var(--rx-gray-07); + } + } + + .primary-button { + @apply flex items-center gap-1.5 px-2.5 py-1.5 rounded-md; + @apply text-xs font-medium cursor-pointer; + @apply transition-colors duration-150 whitespace-nowrap; + background-color: var(--rx-blue-09); + color: white; + border: none; + + &:hover { + background-color: var(--rx-blue-10); } } }