From 9d58fe8391a9c7343afc759239d4795dbfecd291 Mon Sep 17 00:00:00 2001 From: scheinriese Date: Sat, 31 Jan 2026 19:53:52 +0100 Subject: [PATCH] Add image icon picker with async asset loading - Add image-icon-cp component that renders image icons from assets - Implement asset-picker with grid view, search, and upload support - Add --- src/main/frontend/components/block.cljs | 6 + src/main/frontend/components/icon.cljs | 474 +++++++++++++++++- src/main/frontend/components/icon.css | 151 +++++- .../frontend/components/property/config.cljs | 3 + 4 files changed, 624 insertions(+), 10 deletions(-) diff --git a/src/main/frontend/components/block.cljs b/src/main/frontend/components/block.cljs index df831f13a6..baf5fee124 100644 --- a/src/main/frontend/components/block.cljs +++ b/src/main/frontend/components/block.cljs @@ -2555,6 +2555,9 @@ (= :avatar (:type new-icon)) {:type :avatar :data (:data new-icon)} + (= :image (:type new-icon)) + {:type :image :id (:id new-icon) :data (:data new-icon)} + :else (select-keys new-icon [:id :type :color]))] (db-property-handler/set-block-property! @@ -3047,6 +3050,9 @@ (= :avatar (:type icon)) {:type :avatar :data (:data icon)} + (= :image (:type icon)) + {:type :image :id (:id icon) :data (:data icon)} + :else (select-keys icon [:id :type :color]))] ;; Set the icon on the page diff --git a/src/main/frontend/components/icon.cljs b/src/main/frontend/components/icon.cljs index ee70412f34..03d4950771 100644 --- a/src/main/frontend/components/icon.cljs +++ b/src/main/frontend/components/icon.cljs @@ -1,14 +1,22 @@ (ns frontend.components.icon (:require ["@emoji-mart/data" :as emoji-data] ["emoji-mart" :refer [SearchIndex]] + ["path" :as node-path] [camel-snake-kebab.core :as csk] [cljs-bean.core :as bean] [clojure.string :as string] [frontend.colors :as colors] [frontend.config :as config] + [frontend.date :as date] [frontend.db :as db] [frontend.db-mixins :as db-mixins] + [frontend.db.async :as db-async] [frontend.db.model :as model] + [frontend.db.utils :as db-utils] + [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] @@ -16,7 +24,10 @@ [frontend.util :as util] [goog.functions :refer [debounce]] [goog.object :as gobj] + [logseq.common.config :as common-config] + [logseq.common.path :as path] [logseq.db :as ldb] + [logseq.db.frontend.asset :as db-asset] [logseq.shui.hooks :as hooks] [logseq.shui.ui :as shui] [medley.core :as medley] @@ -52,6 +63,121 @@ ;; Default: use as-is (might be a color name or other format) :else backgroundColor)) +(defn- get-asset-type-from-db + "Get asset type from DB using a direct Datalog query. + This works even when db/entity returns nil due to lazy loading." + [asset-uuid] + (when (and asset-uuid (string? asset-uuid)) + (try + (let [parsed-uuid (uuid asset-uuid) + result (db-utils/q '[:find ?type . + :in $ ?uuid + :where + [?e :block/uuid ?uuid] + [?e :logseq.property.asset/type ?type]] + parsed-uuid)] + result) + (catch :default _e + nil)))) + +(def ^:private common-image-extensions + "Common image extensions to try when asset-type is unknown" + ["png" "jpg" "jpeg" "gif" "webp" "svg" "bmp" "ico"]) + +(defn- try-load-image-with-extensions! + "Try loading image with common extensions until one works" + [asset-uuid extensions *url *error *loaded-for] + (if (empty? extensions) + ;; No more extensions to try, mark as error + (reset! *error true) + ;; Try current extension + (let [ext (first extensions) + file (str asset-uuid "." ext) + asset-path (path/path-join (str "../" common-config/local-assets-dir) file)] + (-> (assets-handler/ (assets-handler/> results + (filter (fn [[_uuid type _title _updated]] + (contains? image-extensions (some-> type string/lower-case)))) + (sort-by (fn [[_uuid _type _title updated]] updated) >) + (map (fn [[uuid type title _updated]] + {:block/uuid uuid + :block/title (if (string/blank? title) (str uuid) title) + :logseq.property.asset/type type}))))) + +(defn > assets + (filter (fn [asset] + (contains? image-extensions + (some-> (:logseq.property.asset/type asset) string/lower-case)))) + (sort-by :block/updated-at >)))))) + +(defn- write-asset-file! + "Write an asset file to disk" + [repo dir file file-rpath] + (p/let [buffer (.arrayBuffer file)] + (if (util/electron?) + (ipc/ipc "writeFile" repo (path/path-join dir file-rpath) buffer) + ;; web + (p/let [buffer (.arrayBuffer file) + content (js/Uint8Array. buffer)] + (fs/write-plain-text-file! repo dir file-rpath content nil))))) + +(defn save-image-asset! + "Save an image file as an asset using api-insert-new-block! approach. + Creates the asset as a child of the Asset class page (like tag tables do), + avoiding journal entries." + [repo ^js file] + (p/let [[repo-dir asset-dir-rpath] (assets-handler/ensure-assets-dir! repo) + file-name (node-path/basename (.-name file)) + file-name-without-ext* (db-asset/asset-name->title file-name) + file-name-without-ext (if (= file-name-without-ext* "image") + (date/get-date-time-string-2) + file-name-without-ext*) + checksum (assets-handler/get-file-checksum file) + size (.-size file) + ext (db-asset/asset-path->type file-name) + asset-class (db/entity :logseq.class/Asset) + block-id (ldb/new-block-id)] + (when (and ext asset-class) + ;; Write file to disk + (p/let [_ (let [file-path (str block-id "." ext) + file-rpath (str asset-dir-rpath "/" file-path)] + (write-asset-file! repo repo-dir file file-rpath)) + ;; Create block using api-insert-new-block! (same approach as tag tables) + block (editor-handler/api-insert-new-block! + file-name-without-ext + {:page (:block/uuid asset-class) + :custom-uuid block-id + :properties {:block/tags (:db/id asset-class) + :logseq.property.asset/type ext + :logseq.property.asset/checksum checksum + :logseq.property.asset/size size} + :edit-block? false})] + (db/entity [:block/uuid (:block/uuid block)]))))) + (defn- search-emojis [q] (p/let [result (.search SearchIndex q)] @@ -666,6 +893,227 @@ [:div.text-sm.text-gray-07.dark:opacity-80 "Enter initials or use page initials"]]))) +(rum/defc custom-tab-cp + "Combined tab showing Text, Avatar, and Image options side by side" + [*q page-title *color *view icon-value opts] + (let [query @*q + ;; Text item + text-value (if (string/blank? query) + (let [title (or page-title + (some-> (state/get-current-page) + (db/get-page) + (:block/title)))] + (derive-initials title)) + (subs query 0 (min 8 (count query)))) + selected-color (when-not (string/blank? @*color) @*color) + text-item (when text-value + {:type :text + :id (str "text-" text-value) + :label text-value + :data (cond-> {:value text-value} + selected-color (assoc :color selected-color))}) + ;; Avatar item + avatar-value (if (string/blank? query) + (let [title (or page-title + (some-> (state/get-current-page) + (db/get-page) + (:block/title)))] + (derive-avatar-initials title)) + (subs query 0 (min 3 (count query)))) + backgroundColor (or selected-color (colors/variable :gray :09)) + color (or selected-color (colors/variable :gray :09)) + avatar-item (when avatar-value + {:type :avatar + :id (str "avatar-" avatar-value) + :label avatar-value + :data {:value avatar-value + :backgroundColor backgroundColor + :color color}}) + ;; Image item - check if current icon is an image + current-image-icon (when (= :image (:type (normalize-icon icon-value))) + (normalize-icon icon-value)) + on-chosen (:on-chosen opts)] + [:div.custom-tab-content + ;; Text option + (when text-item + [:button.custom-tab-item + {:on-click #(on-chosen % text-item)} + [:div.custom-tab-item-preview + (icon text-item {:size 24})] + [:span.custom-tab-item-label "Text"]]) + + ;; Avatar option + (when avatar-item + [:button.custom-tab-item + {:on-click #(on-chosen % avatar-item)} + [:div.custom-tab-item-preview + (icon avatar-item {:size 24})] + [:span.custom-tab-item-label "Avatar"]]) + + ;; Image option - clicking navigates to asset picker + [:button.custom-tab-item + {:on-click #(reset! *view :asset-picker)} + [:div.custom-tab-item-preview + (if current-image-icon + (icon current-image-icon {:size 32}) + (shui/tabler-icon "photo" {:size 24 :class "text-gray-08"}))] + [:span.custom-tab-item-label "Image"]]])) + +(rum/defcs image-asset-item < rum/reactive + (rum/local nil ::url) + {:did-mount (fn [state] + (let [[asset _opts] (:rum/args state) + *url (::url state) + asset-type (:logseq.property.asset/type asset) + asset-uuid (:block/uuid asset)] + (when (and asset-uuid asset-type) + (let [file (str asset-uuid "." asset-type) + asset-path (path/path-join (str "../" common-config/local-assets-dir) file)] + (-> (assets-handler/ ( file-type + (string/split "/") + second + keyword)] + (contains? config/image-formats ext))) + files)] + (when (seq image-files) + ;; Save each image file using the tag-table approach + (p/let [entities (p/all (map #(save-image-asset! repo %) image-files))] + ;; Refresh assets list + (p/let [updated-assets (> (get-used-items) @@ -853,6 +1301,7 @@ (rum/local false ::select-mode?) (rum/local :all ::tab) (rum/local false ::input-focused?) + (rum/local :icon-picker ::view) ;; :icon-picker or :asset-picker {:init (fn [s] (assoc s ::color (atom (storage/get :ls-icon-color-preset))))} [state {:keys [on-chosen del-btn? icon-value page-title] :as opts}] @@ -861,6 +1310,7 @@ *tab (::tab state) *color (::color state) *input-focused? (::input-focused? state) + *view (::view state) *input-ref (rum/create-ref) *result-ref (rum/create-ref) result @*result @@ -896,11 +1346,18 @@ (when (not= js/document.activeElement input) (.focus input)) (util/scroll-to (rum/deref *result-ref) 0 false))))] - [:div.cp__emoji-icon-picker - {:data-keep-selection true} - ;; search section - [:div.search-section - (tab-observer @*tab {:reset-q! reset-q!}) + (if (= @*view :asset-picker) + ;; Level 2: Asset Picker view + (asset-picker {:on-chosen (fn [e icon-data] + ((:on-chosen opts) e icon-data) + (reset! *view :icon-picker)) + :on-back #(reset! *view :icon-picker)}) + ;; Level 1: Icon Picker view + [:div.cp__emoji-icon-picker + {:data-keep-selection true} + ;; search section + [:div.search-section + (tab-observer @*tab {:reset-q! reset-q!}) (keyboard-shortcut-observer @*tab *input-focused?) (when @*select-mode? (select-observer *input-ref)) @@ -962,7 +1419,7 @@ ;; tabs section [:div.tabs-section - (let [tabs [[:all "All"] [:emoji "Emojis"] [:icon "Icons"] [:text "Text"] [:avatar "Avatar"]]] + (let [tabs [[:all "All"] [:emoji "Emojis"] [:icon "Icons"] [:custom "Custom"]]] (for [[id label] tabs :let [active? (= @*tab id)]] [:button.tab-item @@ -1008,9 +1465,8 @@ (case @*tab :emoji (emojis-cp emojis opts) :icon (icons-cp (get-tabler-icons) opts) - :text (text-tab-cp *q page-title *color opts) - :avatar (avatar-tab-cp *q page-title *color opts) - (all-cp opts))])]]])) + :custom (custom-tab-cp *q page-title *color *view icon-value 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 8b7cbdb0f5..d07a65aedf 100644 --- a/src/main/frontend/components/icon.css +++ b/src/main/frontend/components/icon.css @@ -203,9 +203,158 @@ } .ls-icon-picker { - @apply w-auto overflow-hidden; + @apply overflow-hidden; + /* Ensure consistent width for both icon picker and asset picker views */ + min-width: 380px; } .ls-icon-color-wrap em-emoji { @apply !w-auto !h-auto; } + +/* Custom tab - Text, Avatar, Image options side by side */ +.custom-tab-content { + @apply flex flex-row gap-6 p-4 justify-center items-start; +} + +.custom-tab-item { + @apply cursor-pointer; + @apply transition-opacity duration-150; + @apply border-none bg-transparent p-0; + display: flex !important; + flex-direction: column !important; + align-items: center !important; + gap: 8px !important; + + &:hover { + @apply opacity-80; + } + + &:active { + @apply opacity-60; + } +} + +.custom-tab-item-preview { + @apply w-12 h-12 rounded-lg; + @apply flex items-center justify-center; + @apply transition-all duration-150; + border: 1px solid var(--rx-gray-06); + background-color: var(--rx-gray-03); + + &:hover { + border-color: var(--rx-accent-09); + } +} + +.custom-tab-item-label { + @apply text-xs; + color: var(--rx-gray-11); +} + +/* Asset Picker (Level 2 view) */ +.asset-picker { + @apply flex flex-col overflow-hidden; + width: 380px; + min-width: 380px; + min-height: 320px; + max-height: 440px; + background-color: var(--lx-gray-02); +} + +.asset-picker-header { + @apply flex items-center gap-2 px-3 py-2; + @apply flex-shrink-0; + background-color: var(--lx-gray-03); + border-bottom: 1px solid var(--rx-gray-06); + + .back-button { + @apply p-1 rounded cursor-pointer; + @apply transition-colors duration-150; + @apply border-none bg-transparent; + color: var(--rx-gray-11); + + &:hover { + background-color: var(--rx-gray-04); + } + } +} + +.asset-picker-search { + @apply px-3 py-2; + @apply flex-shrink-0; + background-color: var(--lx-gray-03); + + .ui__input { + @apply h-8 text-sm; + @apply bg-gray-03 border-none; + box-shadow: none !important; + + &:focus { + @apply bg-gray-03; + } + } +} + +.asset-picker-section-header { + @apply px-3 py-2 text-xs font-medium; + @apply flex-shrink-0; + color: var(--rx-gray-10); + background-color: var(--lx-gray-02); + border-bottom: 1px solid var(--rx-gray-05); +} + +.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; +} + +.image-asset-item { + @apply overflow-hidden rounded; + @apply 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; + border: 2px solid transparent; + background-color: var(--rx-gray-04); + + &:hover { + border-color: var(--rx-accent-09); + } + + &:focus { + @apply outline-none; + border-color: var(--rx-accent-09); + } + + img, + > div { + @apply absolute inset-0 w-full h-full object-cover; + } +} + +.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); + + .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); + + &:hover { + background-color: var(--rx-gray-05); + } + } +} diff --git a/src/main/frontend/components/property/config.cljs b/src/main/frontend/components/property/config.cljs index 74d3ab08e3..68e49fc0d3 100644 --- a/src/main/frontend/components/property/config.cljs +++ b/src/main/frontend/components/property/config.cljs @@ -325,6 +325,9 @@ (= :avatar (:type icon)) {:type :avatar :data (:data icon)} + (= :image (:type icon)) + {:type :image :id (:id icon) :data (:data icon)} + :else (select-keys icon [:id :type :color]))] (property-handler/set-block-property!