mirror of
https://github.com/logseq/logseq.git
synced 2026-05-17 01:12:28 +00:00
feat: add text-picker gallery with Initials/Abbreviated/Custom modes and live persistence
- Add derive-abbreviated, normalize-word-boundaries, and abbreviated-stop-words for smart title abbreviation (e.g. "Software Engineer" → "Soft Eng") - Replace single preview with gallery row of 3 selectable style tiles - Add ::mode and ::deleted? state atoms; persist mode in icon data - Live-persist on gallery click, alignment change, and text input (300ms debounce) - Guard will-unmount with ::deleted? to prevent re-persisting after delete - Add gallery CSS (.text-picker-gallery, -item, -preview, -label) and color-picker nested styles in .text-picker-actions Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1409,6 +1409,71 @@
|
||||
(string/upper-case (subs word 0 char-count))))]
|
||||
(subs initials 0 (min 3 (count initials))))))
|
||||
|
||||
(def ^:private abbreviated-stop-words
|
||||
#{"the" "a" "an" "of" "to" "in" "for" "and" "or" "on" "at" "by"
|
||||
"from" "with" "about" "into" "how" "what" "my" "your" "this" "that"})
|
||||
|
||||
(defn- normalize-word-boundaries
|
||||
"Pre-process title to split camelCase, snake_case, and kebab-case into spaces."
|
||||
[title]
|
||||
(-> title
|
||||
(string/replace #"\s*\([^)]*\)\s*" " ")
|
||||
(string/replace #"([a-z])([A-Z])" "$1 $2")
|
||||
(string/replace "_" " ")
|
||||
(string/replace #"(\w)-(\w)" "$1 $2")
|
||||
string/trim
|
||||
(string/replace #"\s+" " ")))
|
||||
|
||||
(defn derive-abbreviated
|
||||
"Derive abbreviated form from page title (max 8 chars).
|
||||
Returns nil if result equals derive-initials output (to avoid duplicates).
|
||||
Examples: 'Software Engineer' -> 'Soft Eng', 'Math 203' -> 'Math 203'"
|
||||
[title]
|
||||
(when title
|
||||
(let [normalized (normalize-word-boundaries (string/trim title))
|
||||
max-len 8]
|
||||
(when-not (string/blank? normalized)
|
||||
(if (<= (count normalized) max-len)
|
||||
(let [initials (derive-initials title)]
|
||||
(when (not= normalized initials)
|
||||
normalized))
|
||||
(let [words (string/split normalized #"\s+")]
|
||||
(if (<= (count words) 1)
|
||||
(let [result (subs (first words) 0 (min max-len (count (first words))))
|
||||
initials (derive-initials title)]
|
||||
(when (not= result initials)
|
||||
result))
|
||||
(let [significant (if (>= (count words) 3)
|
||||
(let [filtered (remove #(contains? abbreviated-stop-words
|
||||
(string/lower-case %))
|
||||
words)]
|
||||
(if (>= (count filtered) 2)
|
||||
(vec filtered)
|
||||
(vec words)))
|
||||
(vec words))
|
||||
w1 (first significant)
|
||||
w2 (second significant)
|
||||
joined (str w1 " " w2)
|
||||
text-budget (dec max-len)
|
||||
result (if (<= (count joined) max-len)
|
||||
joined
|
||||
(cond
|
||||
(<= (count w1) 3)
|
||||
(str w1 " " (subs w2 0 (min (count w2)
|
||||
(- text-budget (count w1)))))
|
||||
(<= (count w2) 3)
|
||||
(str (subs w1 0 (min (count w1)
|
||||
(- text-budget (count w2))))
|
||||
" " w2)
|
||||
:else
|
||||
(let [half (js/Math.ceil (/ text-budget 2))
|
||||
len1 (min (count w1) half)
|
||||
len2 (min (count w2) (- text-budget len1))]
|
||||
(str (subs w1 0 len1) " " (subs w2 0 len2)))))
|
||||
initials (derive-initials title)]
|
||||
(when (not= result initials)
|
||||
result)))))))))
|
||||
|
||||
(rum/defc text-tab-cp
|
||||
[*q page-title *color opts]
|
||||
(let [query @*q
|
||||
@@ -2969,6 +3034,9 @@
|
||||
(rum/local nil ::text-value)
|
||||
(rum/local nil ::alignment)
|
||||
(rum/local nil ::color)
|
||||
(rum/local nil ::mode)
|
||||
(rum/local false ::deleted?)
|
||||
(rum/local nil ::persist-timer)
|
||||
{:will-mount (fn [s]
|
||||
(let [opts (first (:rum/args s))
|
||||
current-icon (:current-icon opts)
|
||||
@@ -2978,43 +3046,51 @@
|
||||
(:block/title)))
|
||||
existing-value (get-in current-icon [:data :value])
|
||||
existing-alignment (get-in current-icon [:data :alignment])
|
||||
existing-color (get-in current-icon [:data :color])]
|
||||
existing-color (get-in current-icon [:data :color])
|
||||
existing-mode (get-in current-icon [:data :mode])]
|
||||
(reset! (::text-value s) (or existing-value (derive-initials title)))
|
||||
(reset! (::alignment s) (or existing-alignment "center"))
|
||||
(reset! (::color s) existing-color)
|
||||
(reset! (::mode s) (or existing-mode "initials"))
|
||||
s))
|
||||
:will-unmount (fn [s]
|
||||
;; Persist on unmount
|
||||
(let [opts (first (:rum/args s))
|
||||
on-chosen (:on-chosen opts)
|
||||
*tv (::text-value s)
|
||||
*al (::alignment s)
|
||||
*co (::color s)
|
||||
title (or (:page-title opts)
|
||||
(some-> (state/get-current-page)
|
||||
(db/get-page)
|
||||
(:block/title)))
|
||||
derived (or (derive-initials title) "?")
|
||||
text (if (string/blank? @*tv) derived @*tv)
|
||||
icon-item {:type :text
|
||||
:id (str "text-" text)
|
||||
:label text
|
||||
:data (cond-> {:value text}
|
||||
@*co (assoc :color @*co)
|
||||
@*al (assoc :alignment @*al))}]
|
||||
(on-chosen nil icon-item true)
|
||||
(add-used-item! icon-item))
|
||||
(when-let [t @(::persist-timer s)]
|
||||
(js/clearTimeout t))
|
||||
(when-not @(::deleted? s)
|
||||
(let [opts (first (:rum/args s))
|
||||
on-chosen (:on-chosen opts)
|
||||
*tv (::text-value s)
|
||||
*al (::alignment s)
|
||||
*co (::color s)
|
||||
*md (::mode s)
|
||||
title (or (:page-title opts)
|
||||
(some-> (state/get-current-page)
|
||||
(db/get-page)
|
||||
(:block/title)))
|
||||
derived (or (derive-initials title) "?")
|
||||
text (if (string/blank? @*tv) derived @*tv)
|
||||
icon-item {:type :text
|
||||
:id (str "text-" text)
|
||||
:label text
|
||||
:data (cond-> {:value text}
|
||||
@*co (assoc :color @*co)
|
||||
@*al (assoc :alignment @*al)
|
||||
@*md (assoc :mode @*md))}]
|
||||
(on-chosen nil icon-item true)
|
||||
(add-used-item! icon-item)))
|
||||
s)}
|
||||
[state {:keys [on-chosen on-back on-delete del-btn? page-title]}]
|
||||
(let [*text-value (::text-value state)
|
||||
*alignment (::alignment state)
|
||||
*color (::color state)
|
||||
*mode (::mode state)
|
||||
*deleted? (::deleted? state)
|
||||
title (or page-title
|
||||
(some-> (state/get-current-page)
|
||||
(db/get-page)
|
||||
(:block/title)))
|
||||
derived-initials (or (derive-initials title) "?")
|
||||
;; Build icon item from current local state
|
||||
derived-abbreviated (derive-abbreviated title)
|
||||
build-icon (fn [text-override]
|
||||
(let [text (or text-override
|
||||
(if (string/blank? @*text-value) derived-initials @*text-value))]
|
||||
@@ -3023,11 +3099,27 @@
|
||||
:label text
|
||||
:data (cond-> {:value text}
|
||||
@*color (assoc :color @*color)
|
||||
@*alignment (assoc :alignment @*alignment))}))
|
||||
@*alignment (assoc :alignment @*alignment)
|
||||
@*mode (assoc :mode @*mode))}))
|
||||
*persist-timer (::persist-timer state)
|
||||
persist! (fn []
|
||||
(when-let [t @*persist-timer]
|
||||
(js/clearTimeout t)
|
||||
(reset! *persist-timer nil))
|
||||
(let [icon-item (build-icon nil)]
|
||||
(on-chosen nil icon-item true)
|
||||
(add-used-item! icon-item)))]
|
||||
(add-used-item! icon-item)))
|
||||
persist-debounced! (fn []
|
||||
(when-let [t @*persist-timer]
|
||||
(js/clearTimeout t))
|
||||
(reset! *persist-timer
|
||||
(js/setTimeout #(persist!) 300)))
|
||||
gallery-options (cond-> [{:mode "initials" :text derived-initials :label "Initials"}]
|
||||
derived-abbreviated
|
||||
(conj {:mode "abbreviated" :text derived-abbreviated :label "Abbreviated"})
|
||||
true
|
||||
(conj {:mode "custom" :text nil :label "Custom"}))
|
||||
current-mode @*mode]
|
||||
[:div.text-picker
|
||||
;; Topbar
|
||||
[:div.text-picker-topbar
|
||||
@@ -3047,19 +3139,42 @@
|
||||
:label text
|
||||
:data (cond-> {:value text}
|
||||
c (assoc :color c)
|
||||
@*alignment (assoc :alignment @*alignment))}]
|
||||
@*alignment (assoc :alignment @*alignment)
|
||||
@*mode (assoc :mode @*mode))}]
|
||||
(on-chosen nil icon-item true))))
|
||||
(when del-btn?
|
||||
(shui/button {:variant :outline :size :sm :data-action "del"
|
||||
:on-click on-delete}
|
||||
:on-click (fn []
|
||||
(reset! *deleted? true)
|
||||
(on-delete))}
|
||||
(shui/tabler-icon "trash" {:size 17})))]]
|
||||
(shui/separator {:class "my-0 opacity-50"})]
|
||||
|
||||
;; Body
|
||||
[:div.text-picker-body
|
||||
;; Preview
|
||||
[:div.text-picker-preview
|
||||
(icon (build-icon nil) {:size 48})]
|
||||
;; Gallery row — Initials / Abbreviated / Custom
|
||||
[:div.text-picker-gallery
|
||||
(for [{:keys [mode text label]} gallery-options]
|
||||
(let [selected? (= current-mode mode)
|
||||
display-text (if (= mode "custom")
|
||||
(when-not (string/blank? @*text-value)
|
||||
@*text-value)
|
||||
text)]
|
||||
[:button.text-picker-gallery-item
|
||||
{:key mode
|
||||
:class (when selected? "selected")
|
||||
:on-click (fn []
|
||||
(reset! *mode mode)
|
||||
(case mode
|
||||
"initials" (reset! *text-value derived-initials)
|
||||
"abbreviated" (reset! *text-value derived-abbreviated)
|
||||
"custom" nil)
|
||||
(persist!))}
|
||||
[:div.text-picker-gallery-preview
|
||||
(if display-text
|
||||
(icon (build-icon display-text) {:size 36})
|
||||
(shui/tabler-icon "pencil" {:size 20}))]
|
||||
[:span.text-picker-gallery-label label]]))]
|
||||
|
||||
(shui/separator {:class "my-0 opacity-50 -mx-3"})
|
||||
|
||||
@@ -3076,10 +3191,15 @@
|
||||
:value @*text-value
|
||||
:on-change (fn [e]
|
||||
(let [v (util/evalue e)]
|
||||
(reset! *text-value v)))
|
||||
(reset! *text-value v)
|
||||
(when (not= @*mode "custom")
|
||||
(reset! *mode "custom"))
|
||||
(persist-debounced!)))
|
||||
:on-blur (fn [_e]
|
||||
(when (string/blank? @*text-value)
|
||||
(reset! *text-value derived-initials)))})]
|
||||
(reset! *text-value derived-initials)
|
||||
(reset! *mode "initials"))
|
||||
(persist!))})]
|
||||
|
||||
;; Alignment
|
||||
[:div.text-picker-section
|
||||
@@ -3093,7 +3213,8 @@
|
||||
:variant (if active? :secondary :outline)
|
||||
:size :sm
|
||||
:on-click (fn []
|
||||
(reset! *alignment align))}
|
||||
(reset! *alignment align)
|
||||
(persist!))}
|
||||
(shui/tabler-icon (str "align-" align) {:size 16}))))]]]]]))
|
||||
|
||||
(rum/defcs ^:large-vars/cleanup-todo icon-search < rum/reactive
|
||||
|
||||
@@ -708,6 +708,14 @@
|
||||
Text Picker (Level 2 view) - matches asset-picker structure
|
||||
============================================================================ */
|
||||
|
||||
.ui__dropdown-menu-content:has(> .text-picker) {
|
||||
@apply flex;
|
||||
}
|
||||
|
||||
.ui__dropdown-menu-content .text-picker {
|
||||
@apply -m-1;
|
||||
}
|
||||
|
||||
.text-picker {
|
||||
@apply flex flex-col overflow-hidden;
|
||||
width: 380px;
|
||||
@@ -745,6 +753,19 @@
|
||||
|
||||
.text-picker-actions {
|
||||
@apply flex items-center gap-1;
|
||||
|
||||
.color-picker {
|
||||
@apply rounded-md w-[24px] h-[24px] overflow-hidden flex relative p-0;
|
||||
|
||||
> strong {
|
||||
@apply w-[18px] h-[18px] rounded-full absolute opacity-90
|
||||
hover:opacity-100 font-normal text-sm;
|
||||
|
||||
.ui__icon {
|
||||
@apply opacity-90 scale-90;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.text-picker-body {
|
||||
@@ -765,23 +786,52 @@
|
||||
}
|
||||
}
|
||||
|
||||
.text-picker-input-row {
|
||||
@apply flex items-center gap-1.5;
|
||||
|
||||
.ui__input {
|
||||
@apply flex-1;
|
||||
}
|
||||
.text-picker-gallery {
|
||||
@apply flex justify-center gap-4;
|
||||
}
|
||||
|
||||
.text-picker-preview {
|
||||
@apply flex items-center justify-center flex-shrink-0;
|
||||
@apply w-9 h-8 rounded;
|
||||
border: 1px solid var(--rx-gray-06);
|
||||
.text-picker-gallery-item {
|
||||
@apply cursor-pointer border-none bg-transparent p-0;
|
||||
@apply flex flex-col items-center gap-1.5;
|
||||
@apply transition-all duration-150;
|
||||
|
||||
&:hover { @apply opacity-80; }
|
||||
&:active { @apply opacity-60; }
|
||||
}
|
||||
|
||||
.text-picker-gallery-preview {
|
||||
@apply w-14 h-14 rounded-lg;
|
||||
@apply flex items-center justify-center;
|
||||
@apply transition-all duration-150;
|
||||
border: 2px solid var(--rx-gray-06);
|
||||
background-color: var(--rx-gray-03);
|
||||
|
||||
&:hover { border-color: var(--rx-accent-09); }
|
||||
}
|
||||
|
||||
.text-picker-gallery-item.selected .text-picker-gallery-preview {
|
||||
border-color: var(--rx-blue-09);
|
||||
box-shadow: 0 0 0 1px var(--rx-blue-09);
|
||||
}
|
||||
|
||||
.text-picker-gallery-label {
|
||||
@apply text-xs;
|
||||
color: var(--rx-gray-11);
|
||||
}
|
||||
|
||||
.text-picker-gallery-item.selected .text-picker-gallery-label {
|
||||
color: var(--rx-blue-09);
|
||||
@apply font-medium;
|
||||
}
|
||||
|
||||
.text-picker-controls-row {
|
||||
@apply flex items-end gap-3;
|
||||
|
||||
.ui__input { @apply !h-8; }
|
||||
}
|
||||
|
||||
.text-picker-alignment {
|
||||
@apply flex gap-1;
|
||||
@apply flex;
|
||||
|
||||
.ui__button {
|
||||
@apply !w-8 !h-8;
|
||||
|
||||
Reference in New Issue
Block a user