Unify default-icon property to store icon type and value together

Replaces the separate default-icon-type (closed values) property with a single
default-icon property that stores a map containing the icon type and data.
This simplifies icon inheritance by storing all icon info in one place.

Changes:
- default-icon now stores {:type :avatar/:text/:tabler-icon/:emoji, :id ..., ...}
- Add :none type to explicitly hide inherited icons on instances
- Add "Set icon" menu item in block context menu for easy access
- Classes sync their icon to default-icon for automatic inheritance
- Include schema migrations 65.23-65.26 for gradual migration

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
scheinriese
2026-01-31 01:57:09 +01:00
parent e73643227e
commit ad0b7b20c1
11 changed files with 354 additions and 116 deletions

View File

@@ -192,19 +192,17 @@
:view-context :class}
:properties
{:logseq.property/description "When enabled, this tag will show reverse nodes that link to the current node via properties."}}
:logseq.property.class/default-icon-type {:title "Default icon type"
:schema {:type :default
:public? true
:view-context :class}
:closed-values
(mapv (fn [[db-ident value]]
{:db-ident db-ident
:value value
:uuid (common-uuid/gen-uuid :db-ident-block-uuid db-ident)})
[[:logseq.property.class/default-icon-type.avatar "avatar"]
[:logseq.property.class/default-icon-type.text "text"]])
:properties
{:logseq.property/description "Set the default icon type for instances of this tag. Icons will be auto-generated from each instance's page title."}}
;; Unified default icon property - stores icon data with type inferred from :type field
;; For icon: {:type :tabler-icon :id "checkbox"}
;; For emoji: {:type :emoji :id "🎯"}
;; For avatar: {:type :avatar} (value derived from instance title)
;; For text: {:type :text} (value derived from instance title)
:logseq.property.class/default-icon {:title "Default Icon"
:schema {:type :map
:public? true
:view-context :class}
:properties
{:logseq.property/description "Set the default icon for instances of this tag. Avatar/text are auto-generated from title."}}
:logseq.property/hide-empty-value {:title "Hide empty value"
:schema {:type :checkbox
:public? true

View File

@@ -30,7 +30,7 @@
(map (juxt :major :minor)
[(parse-schema-version x) (parse-schema-version y)])))
(def version (parse-schema-version "65.22"))
(def version (parse-schema-version "65.26"))
(defn major-version
"Return a number.

View File

@@ -2518,49 +2518,56 @@
;; Inline block icon - displayed BEFORE positioned properties like Status
(when-not (or table? (:page-title? config))
(let [block-icon (:logseq.property/icon block)
tag-icon (some :logseq.property/icon (:block/tags block))
;; Check for default-icon-type on tags (for auto-generated icons)
;; Check for :none override - explicitly hidden icon
icon-hidden? (= :none (:type block-icon))
;; Check for default-icon on tags (unified icon inheritance)
sorted-tags (sort-by :db/id (:block/tags block))
default-icon-type (some (fn [tag]
(when-let [dit (:logseq.property.class/default-icon-type tag)]
(or (:block/title dit)
(:logseq.property/value dit))))
sorted-tags)
has-icon? (or block-icon tag-icon default-icon-type)]
default-icon (some (fn [tag]
(:logseq.property.class/default-icon tag))
sorted-tags)
;; Determine if we have an icon to show
has-icon? (and (not icon-hidden?)
(or (and block-icon (not= :none (:type block-icon)))
default-icon))]
(when has-icon?
(let [icon (or block-icon
(when (and default-icon-type (:block/title block))
(case default-icon-type
"avatar" {:type :avatar
:data {:value (icon-component/derive-avatar-initials (:block/title block))}}
"text" {:type :text
:data {:value (icon-component/derive-initials (:block/title block))}}
nil))
tag-icon)]
[:div.inline-block-icon.flex.items-center.h-6.select-none
(icon-component/icon-picker
icon
{:on-chosen (fn [_e new-icon]
(if new-icon
(let [icon-data (cond
(= :text (:type new-icon))
{:type :text :data (:data new-icon)}
(let [icon (or (when (and block-icon (not= :none (:type block-icon)))
block-icon)
(when default-icon
(case (:type default-icon)
:avatar (when (:block/title block)
{:type :avatar
:data {:value (icon-component/derive-avatar-initials (:block/title block))}})
:text (when (:block/title block)
{:type :text
:data {:value (icon-component/derive-initials (:block/title block))}})
;; For tabler-icon and emoji, use the stored value directly
default-icon)))]
(when icon
[:div.inline-block-icon.flex.items-center.h-6.select-none
(icon-component/icon-picker
icon
{:on-chosen (fn [_e new-icon]
(if new-icon
(let [icon-data (cond
(= :text (:type new-icon))
{:type :text :data (:data new-icon)}
(= :avatar (:type new-icon))
{:type :avatar :data (:data new-icon)}
(= :avatar (:type new-icon))
{:type :avatar :data (:data new-icon)}
:else
(select-keys new-icon [:id :type :color]))]
:else
(select-keys new-icon [:id :type :color]))]
(db-property-handler/set-block-property!
(:db/id block)
:logseq.property/icon
icon-data))
;; Delete = set :none to override inheritance
(db-property-handler/set-block-property!
(:db/id block)
:logseq.property/icon
icon-data))
;; Delete icon
(db-property-handler/remove-block-property!
(:db/id block)
:logseq.property/icon)))
:del-btn? (boolean block-icon)
:icon-props {:size 16}})]))))
{:type :none})))
:del-btn? true ;; Always show delete when icon displayed
:icon-props {:size 16}})])))))
(when-not table?
(block-positioned-properties config block :block-left))
@@ -3005,23 +3012,22 @@
children (ldb/get-children block)
page-icon (when (:page-title? config)
(let [icon' (get block :logseq.property/icon)
;; Check for default-icon-type on tags (for auto-generated icons)
;; Check for default-icon on tags (for auto-generated icons)
sorted-tags (sort-by :db/id (:block/tags block))
default-icon-type (some (fn [tag]
(when-let [dit (:logseq.property.class/default-icon-type tag)]
(or (:block/title dit)
(:logseq.property/value dit))))
sorted-tags)]
default-icon (some (fn [tag]
(:logseq.property.class/default-icon tag))
sorted-tags)]
(when-let [icon (and (ldb/page? block)
(or icon'
;; Generate icon from default-icon-type
(when (and default-icon-type (:block/title block))
(case default-icon-type
"avatar" {:type :avatar
:data {:value (icon-component/derive-avatar-initials (:block/title block))}}
"text" {:type :text
:data {:value (icon-component/derive-initials (:block/title block))}}
nil))
;; Generate icon from default-icon
(when (and default-icon (:block/title block))
(case (:type default-icon)
:avatar {:type :avatar
:data {:value (icon-component/derive-avatar-initials (:block/title block))}}
:text {:type :text
:data {:value (icon-component/derive-initials (:block/title block))}}
;; For tabler-icon and emoji, use the stored value
default-icon))
(some :logseq.property/icon (:block/tags block))
(when (ldb/class? block)
{:type :tabler-icon
@@ -3043,14 +3049,27 @@
:else
(select-keys icon [:id :type :color]))]
;; Set the icon on the page
(db-property-handler/set-block-property!
(:db/id block)
:logseq.property/icon
icon-data))
icon-data)
;; For classes, also set default-icon for inheritance
(when (ldb/class? block)
(db-property-handler/set-block-property!
(:db/id block)
:logseq.property.class/default-icon
icon-data)))
;; del
(db-property-handler/remove-block-property!
(:db/id block)
:logseq.property/icon)))
(do
(db-property-handler/remove-block-property!
(:db/id block)
:logseq.property/icon)
;; For classes, also remove default-icon
(when (ldb/class? block)
(db-property-handler/remove-block-property!
(:db/id block)
:logseq.property.class/default-icon)))))
:del-btn? (boolean icon')
:icon-props {:style {:width "1lh"
:height "1lh"

View File

@@ -5,6 +5,7 @@
[frontend.commands :as commands]
[frontend.components.editor :as editor]
[frontend.components.export :as export]
[frontend.components.icon :as icon-component]
[frontend.components.page-menu :as page-menu]
[frontend.context.i18n :refer [t]]
[frontend.db :as db]
@@ -134,6 +135,41 @@
#(editor-handler/set-heading! block-id true)
#(editor-handler/remove-heading! block-id))
;; Set icon menu item
(let [current-icon (:logseq.property/icon block)]
(shui/dropdown-menu-item
{:key "Set icon"
:on-click (fn [^js e]
(shui/popup-show!
(.-target e)
(fn [{:keys [id]}]
(icon-component/icon-search
{:on-chosen (fn [_e icon-value]
(let [icon-data (when icon-value
(cond
(= :text (:type icon-value))
{:type :text :data (:data icon-value)}
(= :avatar (:type icon-value))
{:type :avatar :data (:data icon-value)}
:else
(select-keys icon-value [:type :id :color])))]
(property-handler/set-block-property!
block-id
:logseq.property/icon
icon-data))
(shui/popup-hide! id))
:icon-value current-icon
:del-btn? (some? current-icon)}))
{:align :start
:id :ls-icon-picker
:content-props {:class "ls-icon-picker"
:onEscapeKeyDown #(.preventDefault %)}}))}
(if current-icon
(t :content/change-icon)
(t :content/set-icon))))
(shui/dropdown-menu-separator)
(shui/dropdown-menu-item

View File

@@ -124,44 +124,47 @@
(defn get-node-icon
[node-entity]
(let [sorted-tags (sort-by :db/id (:block/tags node-entity))
first-tag-icon (some :logseq.property/icon sorted-tags)
;; Check for default-icon-type on tags (for auto-generated icons)
default-icon-type (some (fn [tag]
(when-let [dit (:logseq.property.class/default-icon-type tag)]
;; dit is a reference to the closed value entity
;; closed values store their value in :block/title or :logseq.property/value
(or (:block/title dit)
(:logseq.property/value dit))))
sorted-tags)]
(or
;; 1. Instance's own icon takes precedence
(get node-entity :logseq.property/icon)
;; 2. Check for default-icon-type from tags (generates icon from page title)
(when (and default-icon-type (:block/title node-entity))
(let [title (:block/title node-entity)]
(case default-icon-type
"avatar" {:type :avatar
:data {:value (derive-avatar-initials title)}}
"text" {:type :text
:data {:value (derive-initials title)}}
nil)))
;; 3. Fall back to first tag's explicit icon (existing inheritance)
(when (some? first-tag-icon)
first-tag-icon)
;; 4. Type-based defaults
(let [asset-type (:logseq.property.asset/type node-entity)]
(cond
(ldb/class? node-entity)
"hash"
(ldb/property? node-entity)
"letter-p"
(ldb/page? node-entity)
"file"
(= asset-type "pdf")
"book"
:else
"point-filled")))))
(let [block-icon (get node-entity :logseq.property/icon)
sorted-tags (sort-by :db/id (:block/tags node-entity))
;; Check for default-icon on tags (unified icon inheritance)
default-icon (some (fn [tag]
(:logseq.property.class/default-icon tag))
sorted-tags)]
(cond
;; 1. Explicit "no icon" override - hide icon even if inherited
(= :none (:type block-icon))
nil
;; 2. Instance's own icon takes precedence
block-icon
block-icon
;; 3. Resolve from tag's default-icon (unified inheritance)
default-icon
(case (:type default-icon)
:avatar (when (:block/title node-entity)
{:type :avatar
:data {:value (derive-avatar-initials (:block/title node-entity))}})
:text (when (:block/title node-entity)
{:type :text
:data {:value (derive-initials (:block/title node-entity))}})
;; For tabler-icon and emoji, use the stored icon value directly
default-icon)
;; 4. Type-based defaults (for classes, properties, pages, etc.)
:else
(let [asset-type (:logseq.property.asset/type node-entity)]
(cond
(ldb/class? node-entity)
"hash"
(ldb/property? node-entity)
"letter-p"
(ldb/page? node-entity)
"file"
(= asset-type "pdf")
"book"
:else
"point-filled")))))
(rum/defc get-node-icon-cp < rum/reactive db-mixins/query
[node-entity opts]

View File

@@ -706,8 +706,13 @@
(not (ldb/built-in? block)))
(-> (assoc :logseq.property.class/enable-bidirectional?
(:logseq.property.class/enable-bidirectional? block))
(assoc :logseq.property.class/default-icon-type
(:logseq.property.class/default-icon-type block))))
(assoc :logseq.property.class/default-icon
(:logseq.property.class/default-icon block)))
;; Show icon properties on built-in classes too
(and (ldb/class? block)
(ldb/built-in? block))
(assoc :logseq.property.class/default-icon
(:logseq.property.class/default-icon block)))
remove-built-in-or-other-position-properties
(fn [properties show-in-hidden-properties?]
(remove (fn [property]
@@ -738,7 +743,7 @@
;; This section produces own-properties and full-hidden-properties
;; Class properties that should always show even when empty
always-show-class-properties #{:logseq.property.class/enable-bidirectional?
:logseq.property.class/default-icon-type}
:logseq.property.class/default-icon}
hide-with-property-id (fn [property-id]
(let [property (db/entity property-id)]
(boolean

View File

@@ -684,7 +684,7 @@
(property-type-sub-pane property ops))}))
(when (and (= property-type :node)
(not (contains? #{:logseq.property.class/extends} (:db/ident property))))
(not (contains? #{:logseq.property.class/extends :logseq.property.class/default-icon} (:db/ident property))))
(dropdown-editor-menuitem {:icon :hash
:disabled? disabled?
:title "Specify node tags"
@@ -739,7 +739,7 @@
(when (and (not= :logseq.property/enable-history? (:db/ident property))
(not special-built-in-prop?))
(let [property-type (:logseq.property/type property)
group' (->> [(when (and (not (contains? #{:logseq.property.class/extends :logseq.property.class/properties} (:db/ident property)))
group' (->> [(when (and (not (contains? #{:logseq.property.class/extends :logseq.property.class/properties :logseq.property.class/default-icon} (:db/ident property)))
(contains? #{:default :number :date :checkbox :node} property-type)
(not
(and (= :default property-type)
@@ -751,13 +751,13 @@
:disabled? config/publishing?
:submenu-content (fn [ops] (ui-position-sub-pane property (assoc ops :ui-position position)))})))
(when (not (contains? #{:logseq.property.class/extends :logseq.property.class/properties} (:db/ident property)))
(when (not (contains? #{:logseq.property.class/extends :logseq.property.class/properties :logseq.property.class/default-icon} (:db/ident property)))
(dropdown-editor-menuitem {:icon :eye-off :title "Hide by default" :toggle-checked? (boolean (:logseq.property/hide? property))
:disabled? config/publishing?
:on-toggle-checked-change #(db-property-handler/set-block-property! (:db/id property)
:logseq.property/hide?
%)}))
(when (not (contains? #{:logseq.property.class/extends :logseq.property.class/properties} (:db/ident property)))
(when (not (contains? #{:logseq.property.class/extends :logseq.property.class/properties :logseq.property.class/default-icon} (:db/ident property)))
(dropdown-editor-menuitem
{:icon :eye-off :title "Hide empty value"
:toggle-checked? (boolean (:logseq.property/hide-empty-value property))
@@ -804,7 +804,7 @@
;; Any property should be removable from Tag Properties
(if class-schema?
(contains? (set (map :db/id (:logseq.property.class/properties owner-block))) (:db/id property))
(not (contains? #{:logseq.property.class/extends :logseq.property.class/properties} (:db/ident property)))))
(not (contains? #{:logseq.property.class/extends :logseq.property.class/properties :logseq.property.class/default-icon} (:db/ident property)))))
(dropdown-editor-menuitem
{:id :delete-property :icon :x

View File

@@ -141,6 +141,100 @@
:del-btn? (some? icon-value)
:on-chosen on-chosen!})])))
(def ^:private default-icon-type-options
[{:value :tabler-icon :label "Icon"}
{:value :emoji :label "Emoji"}
{:value :avatar :label "Avatar"}
{:value :text :label "Text"}])
(rum/defc default-icon-row < rum/reactive
"Renders the unified Default Icon property for classes.
Shows a type dropdown and icon picker side by side."
[block _editing?]
;; Subscribe to block changes for reactivity
(let [block (or (model/sub-block (:db/id block)) block)
current-value (:logseq.property.class/default-icon block)
;; Only show a type if we have a value, otherwise show empty state
has-value? (some? current-value)
current-type (when has-value? (:type current-value))
shows-picker? (contains? #{:tabler-icon :emoji} current-type)
set-default-icon! (fn [icon-data]
(property-handler/set-block-property!
(:db/id block)
:logseq.property.class/default-icon
icon-data))
on-type-change (fn [new-type]
;; When changing type, preserve icon data if switching between icon/emoji
;; For avatar/text, just store the type marker
(cond
(contains? #{:avatar :text} new-type)
(set-default-icon! {:type new-type})
;; Switching to icon/emoji - keep existing icon if compatible
(and (contains? #{:tabler-icon :emoji} new-type)
(contains? #{:tabler-icon :emoji} current-type))
(set-default-icon! (assoc current-value :type new-type))
;; Switching to icon/emoji from avatar/text - clear and let user pick
:else
(set-default-icon! {:type new-type})))
on-icon-chosen (fn [_e icon]
(if icon
(let [icon-data (cond
(= :text (:type icon))
{:type :text :data (:data icon)}
(= :avatar (:type icon))
{:type :avatar :data (:data icon)}
:else
(select-keys icon [:type :id :color]))]
(set-default-icon! icon-data))
;; Delete - remove the property
(property-handler/remove-block-property!
(:db/id block)
:logseq.property.class/default-icon)))
type-label (some #(when (= (:value %) current-type) (:label %)) default-icon-type-options)]
[:div.col-span-3.flex.flex-row.items-center.gap-2
;; Type dropdown
(shui/dropdown-menu
(shui/dropdown-menu-trigger
{:asChild true}
(shui/button {:variant :ghost :size :sm :class "h-6 px-2 gap-1"}
[:span.text-sm (or type-label "Empty")]
(shui/tabler-icon "chevron-down" {:size 14})))
(shui/dropdown-menu-content
{:align "start"}
(doall
(for [{:keys [value label]} default-icon-type-options]
(shui/dropdown-menu-item
{:key (name value)
:onSelect (fn [_e] (on-type-change value))}
[:span {:class (when (= value current-type) "font-medium")} label])))))
;; Icon picker (only for icon/emoji types)
(when (and has-value? shows-picker?)
[:span.text-muted-foreground.text-sm ">"])
(cond
;; No value set yet
(not has-value?)
nil
;; Icon/emoji type - show picker
shows-picker?
(icon-component/icon-picker current-value
{:disabled? config/publishing?
:del-btn? (some? (:id current-value))
:on-chosen on-icon-chosen
:icon-props {:size 18}})
;; Avatar/text type - show preview indicator
:else
[:span.text-muted-foreground.text-xs.italic
(case current-type
:avatar "(auto from title)"
:text "(auto from title)"
"")])]))
(defn select-type?
[block property]
(let [type (:logseq.property/type property)]
@@ -1442,6 +1536,9 @@
(= :logseq.property/icon (:db/ident property))
(icon-row block editing?)
(= :logseq.property.class/default-icon (:db/ident property))
(default-icon-row block editing?)
(and (= type :number) (not editing?) (not closed-values?))
(single-number-input block property value (:table-view? opts))

View File

@@ -43,7 +43,7 @@
:logseq.property/page-tags :logseq.property.class/extends
:logseq.property.class/bidirectional-property-title
:logseq.property.class/enable-bidirectional?
:logseq.property.class/default-icon-type
:logseq.property.class/default-icon
:logseq.property/publishing-public? :logseq.property.user/avatar
:logseq.property.user/email :logseq.property.user/name})

View File

@@ -197,6 +197,76 @@
updated-icon (assoc icon-value :data updated-data)]
[:db/add entity-id :logseq.property/icon updated-icon])))))))))
(defn- migrate-class-icons-to-default-icon-type-value
"Migrate class icons to use default-icon-type/default-icon-value.
For classes with tabler-icon or emoji icons, set default-icon-type to 'icon' or 'emoji'
and copy the icon to default-icon-value for inheritance to instances.
NOTE: This is an intermediate migration step, later consolidated into default-icon."
[db]
(let [;; Get the closed value entities for default-icon-type
icon-type-entity (db-property/get-closed-value-entity-by-name
db :logseq.property.class/default-icon-type "icon")
emoji-type-entity (db-property/get-closed-value-entity-by-name
db :logseq.property.class/default-icon-type "emoji")]
(->> (d/datoms db :avet :logseq.property/icon)
(mapcat (fn [datom]
(let [icon-value (:v datom)
entity-id (:e datom)
entity (d/entity db entity-id)]
;; Only process classes that have icons
(when (and (map? icon-value)
(ldb/class? entity)
;; Don't override if already set
(nil? (:logseq.property.class/default-icon-type entity)))
(let [icon-type (:type icon-value)]
(cond
;; Tabler icons -> default-icon-type "icon"
(= :tabler-icon icon-type)
(when icon-type-entity
[[:db/add entity-id :logseq.property.class/default-icon-type (:db/id icon-type-entity)]
[:db/add entity-id :logseq.property.class/default-icon-value icon-value]])
;; Emoji icons -> default-icon-type "emoji"
(= :emoji icon-type)
(when emoji-type-entity
[[:db/add entity-id :logseq.property.class/default-icon-type (:db/id emoji-type-entity)]
[:db/add entity-id :logseq.property.class/default-icon-value icon-value]])
;; Other types (avatar, text) - skip, as they're handled differently
:else nil)))))))))
(defn- migrate-to-unified-default-icon
"Migrate from two-property system (default-icon-type + default-icon-value)
to unified single property (default-icon).
Also migrates classes with :logseq.property/icon to use default-icon."
[db]
(let [tx-data
(->> (d/datoms db :avet :logseq.property/icon)
(mapcat (fn [datom]
(let [icon-value (:v datom)
entity-id (:e datom)
entity (d/entity db entity-id)]
;; Only process classes that have icons
(when (and (map? icon-value)
(ldb/class? entity)
;; Don't override if already set
(nil? (:logseq.property.class/default-icon entity)))
;; Set the unified default-icon property
[[:db/add entity-id :logseq.property.class/default-icon icon-value]])))))]
;; Also migrate any existing default-icon-type avatar/text settings
(concat tx-data
(->> (d/datoms db :avet :logseq.property.class/default-icon-type)
(keep (fn [datom]
(let [entity-id (:e datom)
entity (d/entity db entity-id)
type-entity (:logseq.property.class/default-icon-type entity)
type-value (or (:block/title type-entity) (:logseq.property/value type-entity))]
;; For avatar/text types, create the marker icon
(when (and (contains? #{"avatar" "text"} type-value)
(nil? (:logseq.property.class/default-icon entity)))
[:db/add entity-id :logseq.property.class/default-icon
{:type (keyword type-value)}]))))))))
(def schema-version->updates
"A vec of tuples defining datascript migrations. Each tuple consists of the
schema version integer and a migration map. A migration map can have keys of :properties, :classes
@@ -219,7 +289,15 @@
["65.19" {:properties [:logseq.property/choice-classes :logseq.property/choice-exclusions]}]
["65.20" {:properties [:logseq.property.class/bidirectional-property-title :logseq.property.class/enable-bidirectional?]}]
["65.21" {:properties [:logseq.property.class/default-icon-type]}]
["65.22" {:fix migrate-icon-colors}]])
["65.22" {:fix migrate-icon-colors}]
["65.23" {:properties [:logseq.property.class/default-icon-value]}]
["65.24" {:fix migrate-class-icons-to-default-icon-type-value}]
["65.25" {:properties [:logseq.property.class/default-icon]
:fix migrate-to-unified-default-icon}]
;; 65.26: Update default-icon property type from :default to :map so it can store map values
["65.26" {:fix (fn [db]
(when-let [property (d/entity db :logseq.property.class/default-icon)]
[[:db/add (:db/id property) :logseq.property/type :map]]))}]])
(let [[major minor] (last (sort (map (comp (juxt :major :minor) db-schema/parse-schema-version first)
schema-version->updates)))]

View File

@@ -159,6 +159,8 @@
:content/replace-with-text "Replace with text"
:content/replace-with-embed "Replace with embed"
:content/open-in-sidebar "Open in sidebar"
:content/set-icon "Set icon"
:content/change-icon "Change icon"
:content/click-to-edit "Click to edit"
:context-menu/make-a-flashcard "Make a Flashcard"
:context-menu/toggle-number-list "Toggle number list"