mirror of
https://github.com/logseq/logseq.git
synced 2026-05-29 23:19:38 +00:00
The text-picker gallery (Initials / Abbreviated / Custom) called the
`icon` fn without `:color? true`, so the SVG text rendered with
default theme foreground instead of the user's chosen color — visible
mismatch where the page-icon next to the page title showed the new
color, but the preview tiles in the picker stayed white/themed.
Same fix as the Custom-tab Text tile (commit 63c7236f74). The icon
fn only wraps its output in `.ls-icon-color-wrap` when `:color? true`,
and the wrapper's `style: color: ...` is what makes the SVG's
`fill: currentColor` resolve to the chosen color.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
7753 lines
404 KiB
Clojure
7753 lines
404 KiB
Clojure
(ns frontend.components.icon
|
||
(:require ["@emoji-mart/data" :as emoji-data]
|
||
["emoji-mart" :refer [SearchIndex]]
|
||
["path" :as node-path]
|
||
["react-colorful" :refer [HexColorPicker]]
|
||
[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.context.i18n :as i18n :refer [t]]
|
||
[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.extensions.lightbox :as lightbox]
|
||
[frontend.fs :as fs]
|
||
[frontend.handler.assets :as assets-handler]
|
||
[frontend.handler.editor :as editor-handler]
|
||
[frontend.handler.icon-color :as icon-color]
|
||
[frontend.handler.property :as property-handler]
|
||
[frontend.rum :as r]
|
||
[frontend.search :as search]
|
||
[frontend.state :as state]
|
||
[frontend.storage :as storage]
|
||
[frontend.ui :as ui]
|
||
[frontend.util :as util]
|
||
[goog.functions :refer [debounce]]
|
||
[goog.object :as gobj]
|
||
[lambdaisland.glogi :as log]
|
||
[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]
|
||
[promesa.core :as p]
|
||
[rum.core :as rum]))
|
||
|
||
(defonce hex-color-picker (r/adapt-class HexColorPicker))
|
||
|
||
(defonce emojis (vals (bean/->clj (gobj/get emoji-data "emojis"))))
|
||
|
||
;; Drag/upload and section-collapse state was previously module-global —
|
||
;; two picker instances open simultaneously (e.g. sidebar + main view) would
|
||
;; cross-talk their drag highlights, upload progress, and asset-picker-open
|
||
;; flag, plus the OUTER icon-search root and INNER asset-picker overlapped
|
||
;; on the shared `*drag-active?` even within a single session. State now
|
||
;; lives in `rum/local` on each owning component (asset-picker + icon-search),
|
||
;; so each mount gets its own atoms and cleanup happens automatically when
|
||
;; the component unmounts.
|
||
|
||
;; ============================================================
|
||
;; Icon hover-preview helpers
|
||
;;
|
||
;; The picker broadcasts hover state to `:ui/icon-hover-preview`,
|
||
;; which page-icon readers (sidebar, cmdk, page-title, table rows)
|
||
;; consume to render a live preview of what the user is hovering.
|
||
;;
|
||
;; Two orthogonal preview signals share the same state slot:
|
||
;;
|
||
;; :icon — full icon override from a tile/grid hover
|
||
;; (e.g. hovering the Avatar tile in the Custom tab).
|
||
;; :color — color-only preview from a color-swatch hover.
|
||
;;
|
||
;; They MUST compose: hovering an avatar tile and then moving onto a
|
||
;; color swatch should keep showing the avatar with the previewed
|
||
;; color overlaid. Direct `state/set-state!` writes overwrite
|
||
;; siblings, so use these helpers instead — they merge fields when
|
||
;; the scope (db-id + db-ids) matches the existing preview, and
|
||
;; replace otherwise.
|
||
;;
|
||
;; The envelope carries two independent scopes — primary (`:db-id` +
|
||
;; `:property`, optionally `:db-ids` for batch peers) and inheritor
|
||
;; (`:inheritor-db-ids` + `:inheritor-property`). The inheritor scope
|
||
;; lets a class default-icon edit preview on instance rows
|
||
;; (rendering `:logseq.property/icon` via inheritance) WITHOUT
|
||
;; matching the class entity's own `:logseq.property/icon` page-title
|
||
;; / sidebar icons, which are unrelated to the property under edit.
|
||
;; ============================================================
|
||
|
||
(defn- icon-preview-same-scope?
|
||
"True when `incoming` describes the same picker scope as `current`
|
||
(entity + entity-set). Property is intentionally NOT part of scope
|
||
identity — a class default-icon picker broadcasts under multiple
|
||
properties simultaneously and they all belong to the same picker."
|
||
[current incoming]
|
||
(and current incoming
|
||
(= (:db-id current) (:db-id incoming))
|
||
(= (:db-ids current) (:db-ids incoming))))
|
||
|
||
(defn- merge-into-icon-preview!
|
||
"Merge `incoming` into `:ui/icon-hover-preview`. If the existing
|
||
preview's scope matches, preserve any sibling visual fields
|
||
(`:icon`/`:color`) so tile and color hovers compose. Otherwise
|
||
replace — different scope means the previous picker is gone or
|
||
unrelated."
|
||
[incoming]
|
||
(state/update-state!
|
||
:ui/icon-hover-preview
|
||
(fn [current]
|
||
(if (icon-preview-same-scope? current incoming)
|
||
(merge current incoming)
|
||
incoming))))
|
||
|
||
(defn- dissoc-icon-preview-field!
|
||
"Remove `field` from the preview map iff scope matches. If no
|
||
visual fields remain (`:icon` or `:color`), clear the slot
|
||
entirely so receivers fully revert."
|
||
[scope-target field]
|
||
(state/update-state!
|
||
:ui/icon-hover-preview
|
||
(fn [current]
|
||
(when (and current (icon-preview-same-scope? current scope-target))
|
||
(let [next (dissoc current field)]
|
||
(when (or (:icon next) (:color next))
|
||
next))))))
|
||
|
||
(defn- reset-picker-transient-state!
|
||
"Clear every transient/optimistic atom that should not survive a
|
||
trash-button click. Picker-instance atoms are passed explicitly
|
||
because rum/local atoms aren't reachable from outside the
|
||
component; global state is cleared unconditionally.
|
||
|
||
Fixes the race where an in-flight `<save-image-asset!` resolves
|
||
AFTER a delete click and writes back to `::pending-icon`, leaving
|
||
a phantom placeholder. Also kills any active hover-preview overlay
|
||
so the deleted icon doesn't briefly ghost back via the preview
|
||
pipeline."
|
||
[{:keys [*pending-icon *asset-picker-initial-mode *upload-status]}]
|
||
(state/set-state! :ui/icon-hover-preview nil)
|
||
(some-> *pending-icon (reset! nil))
|
||
(some-> *asset-picker-initial-mode (reset! nil))
|
||
(some-> *upload-status (reset! "")))
|
||
|
||
(defn- icon-preview-matches?
|
||
"True when `preview` applies to entity at `entity-id` rendering
|
||
`viewer-property`. Two independent scopes:
|
||
|
||
1. Primary — entity matches `:db-id` (singleton) or is in
|
||
`:db-ids` (set, e.g. batch-selected peers), AND viewer-property
|
||
matches `:property`.
|
||
|
||
2. Inheritor — entity is in `:inheritor-db-ids`, AND viewer-property
|
||
matches `:inheritor-property`. Used for class default-icon edits
|
||
so instance rows (rendering `:logseq.property/icon` via
|
||
inheritance) preview without dragging in the class entity's own
|
||
`:logseq.property/icon` page-title icon, which is unrelated to
|
||
the default-icon property under edit."
|
||
[preview entity-id viewer-property]
|
||
(let [primary? (and (= viewer-property (:property preview))
|
||
(or (= entity-id (:db-id preview))
|
||
(contains? (:db-ids preview) entity-id)))
|
||
inheritor? (and (:inheritor-property preview)
|
||
(= viewer-property (:inheritor-property preview))
|
||
(contains? (:inheritor-db-ids preview) entity-id))]
|
||
(or primary? inheritor?)))
|
||
|
||
;; 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
|
||
(delay
|
||
(let [canvas (js/document.createElement "canvas")]
|
||
(.getContext canvas "2d"))))
|
||
|
||
(declare normalize-icon derive-initials derive-avatar-initials derive-abbreviated
|
||
<search-wikipedia-image <save-url-asset! open-image-asset-picker!
|
||
;; Used by `asset-picker`'s avatar fallback sub-picker — declared
|
||
;; up front because `icon-search` itself is defined far below
|
||
;; (after asset-picker), so the call site at the avatar fallback
|
||
;; needs a forward declare to satisfy the cljs compiler.
|
||
icon-search)
|
||
|
||
(def ^:private icon-name-acronyms
|
||
"All-caps tokens that should stay uppercase when humanizing tabler icon
|
||
names — without this allowlist `TvOff` would render as `Tv off` instead
|
||
of `TV off`. Keep this small; only well-known global acronyms qualify."
|
||
#{"3D" "2D" "TV" "AI" "URL" "PDF" "USB" "AM" "PM" "GPS" "ID"
|
||
"HTML" "CSS" "JS" "API" "QR" "AC" "DC" "PC" "CPU" "GPU" "RSS"
|
||
"SQL" "XML" "JSON" "SVG" "PNG" "JPG" "GIF" "MP3" "MP4" "WIFI"})
|
||
|
||
(defn humanize-icon-name
|
||
"Turn a tabler component name into user-facing copy.
|
||
'3dCubeSphere' -> '3D cube sphere'
|
||
'BrandSlack' -> 'Slack'
|
||
'TvOff' -> 'TV off'
|
||
'briefcase' -> 'Briefcase'
|
||
`Brand` is dropped because the brand-name suffix is the meaningful
|
||
token (`BrandSlack` -> `Slack`). Returns an empty string for blank
|
||
input so the caller can string/concat without a nil guard."
|
||
[s]
|
||
(if (string/blank? s)
|
||
""
|
||
(let [spaced (-> s
|
||
(string/replace #"([a-z])([A-Z])" "$1 $2")
|
||
(string/replace #"([A-Z])([A-Z][a-z])" "$1 $2")
|
||
(string/replace #"([a-zA-Z])(\d)" "$1 $2")
|
||
(string/replace #"-" " "))
|
||
stripped (string/replace spaced #"(?i)^brand\s+" "")
|
||
tokens (string/split stripped #"\s+")
|
||
normalized (map (fn [t]
|
||
(let [up (string/upper-case t)]
|
||
(cond
|
||
(contains? icon-name-acronyms up) up
|
||
(re-matches #"\d+[a-zA-Z]+" t) (string/upper-case t)
|
||
:else (string/lower-case t))))
|
||
tokens)
|
||
joined (string/join " " normalized)]
|
||
(if (seq joined)
|
||
(str (string/upper-case (subs joined 0 1)) (subs joined 1))
|
||
""))))
|
||
|
||
(defn- avatar-fallback-style
|
||
"Build the inline :style map for an avatar fallback chip (the colored
|
||
circle holding initials).
|
||
|
||
Design intent: a muted, hue-preserving tint behind crisp picked-color
|
||
text — so the picked hue shows through on the initials, with the bg
|
||
acting as atmospheric framing rather than competing for attention.
|
||
|
||
- bg gets `muted-tint`: same hue as picked, low chroma, L bisected to
|
||
~1.5:1 contrast vs the page surface. Always visibly distinct from
|
||
the surface, never as saturated as the picked color itself.
|
||
- text uses the picked color directly when it reads against the muted
|
||
bg; falls back to `adjust-for-contrast … 3.0` to lift L only when
|
||
needed (e.g. dark picks on a dark surface where the picked color
|
||
would still be invisible against its own muted tint).
|
||
|
||
The 3.0 target — rather than 4.5:1 body-text — treats avatar initials
|
||
as decorative identifiers (Slack/Linear/GitHub all do similar). With
|
||
4.5 the lift triggered for hues like tomato/red whose picked color
|
||
sits at ~3.5:1 against their own muted bg, and OKLCh L bisection
|
||
toward white desaturates as it climbs — turning vivid red into dusty
|
||
pink. 3.0 lets those hues pass through as-is while still safely
|
||
lifting truly-dark picks (#1a3d60 etc.) into legibility.
|
||
|
||
`bg` and `color` may be hex literals OR theme tokens (`var(--rx-...)`),
|
||
since the picker offers both a custom hex picker and a Radix-token
|
||
palette. Both are resolved to current-theme hex via `colors/->hex` so
|
||
they hit the same OKLCh pipeline; otherwise preset picks would skip
|
||
muting and render as flat saturated discs (bg = text = same token).
|
||
|
||
Earlier iteration used a 31.4% alpha treatment, which silently rendered
|
||
dark picks at ~1.1:1 vs surface — invisible. The next iteration went
|
||
solid bg + auto-lifted text, which inverted the hierarchy and made
|
||
vivid picks read as a single solid disc. This version restores the
|
||
original intent with deterministic OKLCh math."
|
||
[{:keys [font-size bg color]}]
|
||
(let [bg-hex (colors/->hex bg)
|
||
color-hex (colors/->hex (or color bg))
|
||
page-bg (when bg-hex
|
||
(colors/read-bg-var "--ls-primary-background-color"))
|
||
bg' (if (and bg-hex page-bg)
|
||
(colors/muted-tint bg-hex page-bg 1.5)
|
||
bg)
|
||
color' (if (and color-hex bg-hex page-bg)
|
||
(colors/adjust-for-contrast color-hex bg' 3.0)
|
||
(or color bg))]
|
||
(cond-> {:font-size font-size :font-weight "500"}
|
||
bg' (assoc :background-color bg')
|
||
color' (assoc :color color'))))
|
||
|
||
(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- worker-not-ready-err?
|
||
"True when an error from `<make-asset-url` is the db-worker hasn't-finished-
|
||
booting transient (vs. a real failure like a missing file). Worth a longer
|
||
retry window than other errors since the only thing to do is wait."
|
||
[err]
|
||
(string/includes? (str err) "not been initialized"))
|
||
|
||
;; Retry budgets. Worker-not-ready is a pure timing problem — the call will
|
||
;; succeed once the SharedWorker finishes booting — so spend a longer wallclock
|
||
;; window on it (~7.5s) at a tighter cadence. Real failures (missing file, etc.)
|
||
;; stay on the original 3×1s budget; further retries there don't change the
|
||
;; outcome and just delay surfacing the error icon.
|
||
(def ^:private worker-not-ready-max-retries 15)
|
||
(def ^:private worker-not-ready-delay-ms 500)
|
||
|
||
;; Grid column counts. Used by the virtualized icon grid, section
|
||
;; `:cols` declarations, and the diagonal-wave row/col indices. Single
|
||
;; source of truth so the layout, virtualization, and color wave all
|
||
;; stay in lockstep.
|
||
(def ^:private icon-grid-cols 9)
|
||
(def ^:private custom-tab-cols 3)
|
||
(def ^:private asset-search-grid-cols 5)
|
||
|
||
(defn- <load-asset-url!
|
||
"Resolve an asset blob URL, retrying on transient failures (e.g. db-worker not ready).
|
||
When asset-type is nil and :try-extensions? is true, tries common image extensions.
|
||
Pass :*load-id atom (per-component) to guard against stale retries overwriting a newer load.
|
||
Without :*load-id, no staleness check is performed (safe for single-load components)."
|
||
[*url *error asset-uuid asset-type {:keys [max-retries delay-ms try-extensions? *load-id]
|
||
:or {max-retries 3 delay-ms 1000 try-extensions? false}}]
|
||
(let [load-id (when *load-id (swap! *load-id inc))
|
||
stale? (if *load-id
|
||
#(not= load-id @*load-id)
|
||
(constantly false))]
|
||
(reset! *url nil)
|
||
(reset! *error false)
|
||
(if (and (not asset-type) try-extensions?)
|
||
;; Unknown extension — try common ones sequentially
|
||
(letfn [(try-ext [exts attempt]
|
||
(if (empty? exts)
|
||
(when-not (stale?)
|
||
(reset! *error true))
|
||
(let [ext (first exts)
|
||
file (str asset-uuid "." ext)
|
||
asset-path (path/path-join (str "../" common-config/local-assets-dir) file)]
|
||
(-> (assets-handler/<make-asset-url asset-path)
|
||
(p/then (fn [url]
|
||
(when-not (stale?)
|
||
(reset! *error false)
|
||
(reset! *url url))))
|
||
(p/catch (fn [err]
|
||
(when-not (stale?)
|
||
;; Worker-not-ready: retry the same ext on a tighter cadence
|
||
;; with a wider budget. Other errors fall through to next ext.
|
||
(if (and (worker-not-ready-err? err)
|
||
(< attempt worker-not-ready-max-retries))
|
||
(js/setTimeout #(try-ext exts (inc attempt)) worker-not-ready-delay-ms)
|
||
(try-ext (rest exts) 0)))))))))]
|
||
(try-ext common-image-extensions 0))
|
||
;; Known extension — retry with delay on failure
|
||
(let [file (str asset-uuid "." asset-type)
|
||
asset-path (path/path-join (str "../" common-config/local-assets-dir) file)]
|
||
(letfn [(attempt [n]
|
||
(-> (assets-handler/<make-asset-url asset-path)
|
||
(p/then (fn [url]
|
||
(when-not (stale?)
|
||
(reset! *error false)
|
||
(reset! *url url))))
|
||
(p/catch (fn [err]
|
||
(when-not (stale?)
|
||
(let [worker-not-ready? (worker-not-ready-err? err)
|
||
budget (if worker-not-ready? worker-not-ready-max-retries max-retries)
|
||
next-delay (if worker-not-ready? worker-not-ready-delay-ms delay-ms)]
|
||
(if (< n budget)
|
||
(js/setTimeout #(attempt (inc n)) next-delay)
|
||
(reset! *error true))))))))]
|
||
(attempt 0))))))
|
||
|
||
(rum/defcs image-icon-cp < rum/reactive db-mixins/query
|
||
(rum/local nil ::url)
|
||
(rum/local false ::error)
|
||
(rum/local nil ::loaded-uuid)
|
||
(rum/local 0 ::load-id)
|
||
{:did-mount (fn [state]
|
||
(let [[asset-uuid asset-type-arg _opts] (:rum/args state)
|
||
asset-type (or asset-type-arg
|
||
(get-asset-type-from-db asset-uuid))
|
||
*url (::url state)
|
||
*error (::error state)
|
||
*loaded-uuid (::loaded-uuid state)]
|
||
(when (and asset-uuid (not= @*loaded-uuid asset-uuid))
|
||
(reset! *loaded-uuid asset-uuid)
|
||
(<load-asset-url! *url *error asset-uuid asset-type
|
||
{:try-extensions? (nil? asset-type)
|
||
:*load-id (::load-id state)})))
|
||
state)
|
||
:did-update (fn [state]
|
||
(let [[asset-uuid asset-type-arg _opts] (:rum/args state)
|
||
*url (::url state)
|
||
*error (::error state)
|
||
*loaded-uuid (::loaded-uuid state)]
|
||
(cond
|
||
;; New uuid → fresh load.
|
||
(and asset-uuid (not= @*loaded-uuid asset-uuid))
|
||
(let [asset-type (or asset-type-arg
|
||
(get-asset-type-from-db asset-uuid))]
|
||
(reset! *loaded-uuid asset-uuid)
|
||
(<load-asset-url! *url *error asset-uuid asset-type
|
||
{:try-extensions? (nil? asset-type)
|
||
:*load-id (::load-id state)}))
|
||
;; Retraction: this uuid appears in the latest tx's
|
||
;; :deleted-ids. Clear *url so the image vanishes; a
|
||
;; follow-up load attempt fails (file deleted by the
|
||
;; outliner pipeline) and *error flips → image-error
|
||
;; fallback renders.
|
||
(and asset-uuid (string? asset-uuid) @*url
|
||
(let [uuid-val (try (uuid asset-uuid) (catch :default _ nil))
|
||
deleted (some-> @(get @state/state :db/latest-transacted-entity-uuids)
|
||
:deleted-ids)]
|
||
(and uuid-val deleted (contains? deleted uuid-val))))
|
||
(let [asset-type (or asset-type-arg
|
||
(get-asset-type-from-db asset-uuid))]
|
||
(reset! *url nil)
|
||
(reset! *error false)
|
||
(<load-asset-url! *url *error asset-uuid asset-type
|
||
{:try-extensions? (nil? asset-type)
|
||
:*load-id (::load-id state)}))))
|
||
state)}
|
||
"Renders an image icon by loading the asset URL asynchronously.
|
||
Tries common extensions if asset-type is unknown.
|
||
Accepts optional :on-click-error callback in opts for error state clicks."
|
||
[state asset-uuid _asset-type-arg opts]
|
||
(let [;; Re-render on any main-thread tx so we can react to retractions.
|
||
;; `model/sub-block` can't drive this — the worker's affected-keys
|
||
;; pipeline (worker/react.cljs:63-67) calls `(d/entity db-after id)`,
|
||
;; which returns nil for retracted entities, so no `[::block id]`
|
||
;; is emitted and subscriptions on retracted entities never fire.
|
||
;; `:db/latest-transacted-entity-uuids` (modules/outliner/pipeline.cljs:56-58)
|
||
;; flips on every tx and is the reliable retraction signal.
|
||
_latest-tx (state/sub :db/latest-transacted-entity-uuids)
|
||
url @(::url state)
|
||
load-error? @(::error state)
|
||
;; Render decision is driven purely by load outcome: URL set →
|
||
;; image; load-error → fallback; otherwise → loading placeholder.
|
||
;; Entity presence is *not* part of this gate — on cold reload
|
||
;; the asset block hasn't transacted into the main-thread DB yet
|
||
;; even though the file loads fine from the worker, so gating
|
||
;; the URL on `(db/entity …)` masks a working blob URL with the
|
||
;; image-error fallback until an unrelated query (e.g. visiting
|
||
;; `#Asset`) hydrates the block.
|
||
;;
|
||
;; Retraction is handled in `:did-update` by watching
|
||
;; `:db/latest-transacted-entity-uuids :deleted-ids` and
|
||
;; clearing `*url` so the next load attempt fails into the
|
||
;; fallback (see below).
|
||
error? load-error?
|
||
size (or (:size opts) 20)
|
||
on-click-error (:on-click-error opts)]
|
||
(cond
|
||
error?
|
||
;; Broken/missing asset. Match the visual language of the "pick an image"
|
||
;; placeholder (icon.cljs:382-394) — bordered tile, transparent fill,
|
||
;; muted icon — but with a solid border (vs dashed) and a `photo-off`
|
||
;; glyph to signal "settled but broken" rather than "awaiting input".
|
||
;; Inner icon sized at 0.45 to match placeholder; the heavy filled
|
||
;; `bg-gray-04` look it replaced was visually loud at page-icon scale
|
||
;; (38px) and read as a real piece of chrome rather than a fallback.
|
||
(let [inner (max 8 (int (* size 0.45)))
|
||
inner-px (str inner "px")]
|
||
[:span.ui__icon.image-icon.image-error
|
||
(cond-> {:style {:display "inline-flex"
|
||
:align-items "center"
|
||
:justify-content "center"
|
||
:width (str size "px")
|
||
:height (str size "px")
|
||
:border "1px solid var(--rx-gray-07)"
|
||
:border-radius "5px"
|
||
:background "var(--rx-gray-03-alpha)"
|
||
:color "var(--lx-gray-11, var(--ls-primary-text-color, var(--rx-gray-11)))"}
|
||
:title "Image not found - click to replace"}
|
||
on-click-error (assoc :on-click on-click-error
|
||
:class "cursor-pointer"))
|
||
(shui/tabler-icon "photo-off" {:size inner
|
||
:style {:width inner-px
|
||
:height inner-px}})])
|
||
|
||
url
|
||
[:span.ui__icon.image-icon.flex.items-center.justify-center
|
||
{:style {:width size :height size}}
|
||
[:img
|
||
{:src url
|
||
:loading "lazy"
|
||
:on-error (fn [_e]
|
||
(reset! (::url state) nil)
|
||
(reset! (::error state) true))
|
||
:style {:width "100%"
|
||
:height "100%"
|
||
:object-fit "contain"
|
||
:display "block"}}]]
|
||
|
||
:else
|
||
[:span.ui__icon.image-icon.bg-gray-04.animate-pulse
|
||
{:style {:width size :height size}}])))
|
||
|
||
(rum/defcs avatar-image-cp < rum/reactive db-mixins/query
|
||
(rum/local nil ::url)
|
||
(rum/local false ::error)
|
||
(rum/local nil ::loaded-for)
|
||
(rum/local 0 ::load-id)
|
||
{: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)]
|
||
(when (and asset-uuid (not= @*loaded-for [asset-uuid asset-type]))
|
||
(reset! *loaded-for [asset-uuid asset-type])
|
||
(<load-asset-url! *url *error asset-uuid asset-type
|
||
{:try-extensions? (nil? asset-type)
|
||
:*load-id (::load-id state)})))
|
||
state)
|
||
:did-update (fn [state]
|
||
(let [[asset-uuid asset-type _avatar-data _opts] (:rum/args state)
|
||
*url (::url state)
|
||
*error (::error state)
|
||
*loaded-for (::loaded-for state)]
|
||
(cond
|
||
(and asset-uuid (not= @*loaded-for [asset-uuid asset-type]))
|
||
(do (reset! *loaded-for [asset-uuid asset-type])
|
||
(<load-asset-url! *url *error asset-uuid asset-type
|
||
{:try-extensions? (nil? asset-type)
|
||
:*load-id (::load-id state)}))
|
||
;; Retraction signal — see `image-icon-cp` :did-update.
|
||
(and asset-uuid (string? asset-uuid) @*url
|
||
(let [uuid-val (try (uuid asset-uuid) (catch :default _ nil))
|
||
deleted (some-> @(get @state/state :db/latest-transacted-entity-uuids)
|
||
:deleted-ids)]
|
||
(and uuid-val deleted (contains? deleted uuid-val))))
|
||
(do (reset! *url nil)
|
||
(reset! *error false)
|
||
(<load-asset-url! *url *error asset-uuid asset-type
|
||
{:try-extensions? (nil? asset-type)
|
||
:*load-id (::load-id state)}))))
|
||
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 [;; Re-render on every tx so :did-update can react to retractions
|
||
;; (it watches `:deleted-ids` and clears `*url`).
|
||
_latest-tx (state/sub :db/latest-transacted-entity-uuids)
|
||
url @(::url state)
|
||
;; Render is driven purely by load outcome. Don't gate on
|
||
;; `(db/entity …)` presence: on cold reload the asset block
|
||
;; hasn't transacted into the main-thread DB yet even though
|
||
;; the file loads fine from the worker, and gating here masks a
|
||
;; working blob URL with the initials fallback until an
|
||
;; unrelated query hydrates the block.
|
||
_load-error? @(::error state)
|
||
;; Size from opts, default to 20px
|
||
size (or (:size opts) 20)
|
||
;; Fallback data from avatar
|
||
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)
|
||
fb-type (or (get avatar-data :fallback-type) :letters)
|
||
fb-icon (get avatar-data :fallback-icon)
|
||
display-text (subs avatar-value 0 (min 3 (count avatar-value)))
|
||
;; Scale font-size with avatar size
|
||
font-size (cond
|
||
(<= size 16) "8px"
|
||
(<= size 24) "10px"
|
||
(<= size 32) "12px"
|
||
:else "14px")
|
||
icon-size (max 10 (int (* size 0.55)))
|
||
fallback-style (avatar-fallback-style {:font-size font-size
|
||
:bg explicit-bg
|
||
:color explicit-color})]
|
||
(shui/avatar
|
||
{;; Force-remount when the URL transitions absent <-> present.
|
||
;; Radix's Avatar primitive tracks image-loading status in
|
||
;; context. Once Avatar.Image reports "loaded", that status
|
||
;; sticks even after Avatar.Image unmounts — Avatar.Fallback
|
||
;; reads the status and stays hidden because it thinks the
|
||
;; image is still loaded. Toggling the key on URL presence
|
||
;; forces a fresh mount with a clean status machine, so the
|
||
;; fallback renders the moment the URL clears (e.g. retraction
|
||
;; clears `*url`) and the image renders afresh when a new URL
|
||
;; lands.
|
||
:key (if url "with-image" "no-image")
|
||
:style {:width size :height size}
|
||
:data-shape (name shape)}
|
||
;; Image (shows when loaded, circular with cover fit)
|
||
(when url
|
||
(shui/avatar-image {:src url
|
||
;; Decorative: the avatar is always rendered next
|
||
;; to its label (page/block title), which the
|
||
;; screen reader already announces. Content-
|
||
;; bearing alt would double-announce.
|
||
:alt ""
|
||
:style {:object-fit "cover"}
|
||
:data-shape (name shape)}))
|
||
;; Fallback (shows while loading, on error, OR when there's no image
|
||
;; but the avatar still wants to render — Letters, Icon, or Emoji.
|
||
(shui/avatar-fallback
|
||
{:style fallback-style
|
||
:data-shape (name shape)}
|
||
(cond
|
||
(and (= fb-type :icon) (not (string/blank? fb-icon)))
|
||
(shui/tabler-icon fb-icon
|
||
{:size icon-size
|
||
:style {:color (:color fallback-style)}})
|
||
|
||
(and (= fb-type :emoji) (not (string/blank? fb-icon)))
|
||
[:em-emoji {:id fb-icon
|
||
:size icon-size
|
||
:style {:line-height 1}}]
|
||
|
||
:else
|
||
display-text)))))
|
||
|
||
(defn measure-text-width
|
||
"Measure pixel width of text at given font-size using offscreen canvas."
|
||
[text font-size-px]
|
||
(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.
|
||
Uses canvas measureText for accuracy across proportional fonts."
|
||
[text]
|
||
(if (string/blank? text)
|
||
72
|
||
(let [target-width 85
|
||
initial-size (cond
|
||
(<= (count text) 1) 72
|
||
(<= (count text) 2) 56
|
||
(<= (count text) 3) 44
|
||
(<= (count text) 4) 36
|
||
:else 28)
|
||
measured (measure-text-width text initial-size)
|
||
adjusted (* initial-size (/ target-width measured))]
|
||
(min 80 (max 10 adjusted)))))
|
||
|
||
(defn smart-split-text
|
||
"Split 5+ char text into two lines at natural boundaries.
|
||
Prefers splitting at spaces, then letters+digits, then midpoint.
|
||
Returns vector of 1 or 2 strings."
|
||
[text]
|
||
(if (<= (count text) 4)
|
||
[text]
|
||
(let [mid (js/Math.ceil (/ (count text) 2))
|
||
;; Find all space positions using string/index-of
|
||
find-spaces (fn []
|
||
(loop [idx 0 result []]
|
||
(let [found (string/index-of text " " idx)]
|
||
(if (some? found)
|
||
(recur (inc found) (conj result found))
|
||
result))))]
|
||
(let [spaces (find-spaces)]
|
||
(if (seq spaces)
|
||
;; Split at space nearest to midpoint
|
||
(let [best (apply min-key #(js/Math.abs (- % mid)) spaces)]
|
||
[(string/trim (subs text 0 best))
|
||
(string/trim (subs text (inc best)))])
|
||
;; No spaces: try letters+digits boundary
|
||
(if-let [[_ letters digits] (re-matches #"^([A-Za-z]+)(\d+)$" text)]
|
||
[letters digits]
|
||
;; Fallback: midpoint
|
||
[(subs text 0 mid) (subs text mid)]))))))
|
||
|
||
(defn icon
|
||
[icon' & [opts]]
|
||
(let [normalized (or (normalize-icon icon') icon')
|
||
color? (:color? opts)
|
||
opts (dissoc opts :color?)
|
||
item (cond
|
||
;; Unified shape format
|
||
(and (map? normalized) (= :emoji (:type normalized)) (get-in normalized [:data :value]))
|
||
[:span.ui__icon
|
||
[:em-emoji (merge {:id (get-in normalized [:data :value])
|
||
:style {:line-height 1}}
|
||
opts)]]
|
||
|
||
;; "Pick an image" placeholder shown during icon-picker hover
|
||
;; preview when the user navigates to the Custom-tab Image
|
||
;; button. Mirrors Logseq's universal "no icon yet, click to
|
||
;; add" affordance (plus inside a dashed rounded square),
|
||
;; signalling awaiting-input rather than a committed photo
|
||
;; icon. Sized to match the surrounding tabler icons.
|
||
(and (map? normalized) (= :image-placeholder (:type normalized)))
|
||
;; Pin neutral grays explicitly (override the parent color
|
||
;; cascade). Image asset icons can't be tinted, so the
|
||
;; preview shouldn't promise a colored outcome the actual
|
||
;; commit won't deliver. Matches the Custom-tab Image tile
|
||
;; (custom-tab-cp:1920-1929) for visual continuity.
|
||
;;
|
||
;; Inline width/height on the inner SVG: the page-title's
|
||
;; CSS forces all svgs inside .ls-page-icon to 38x38,
|
||
;; ignoring the :size prop. Inline style outranks that
|
||
;; class selector and lets the plus actually shrink.
|
||
(let [size (or (:size opts) 20)
|
||
inner (max 8 (int (* size 0.45)))
|
||
inner-px (str inner "px")]
|
||
[:span.ui__icon.image-placeholder-icon
|
||
{:style {:display "inline-flex"
|
||
:align-items "center"
|
||
:justify-content "center"
|
||
:width (str size "px")
|
||
:height (str size "px")
|
||
;; Themed dashed border (matches Custom-tab image
|
||
;; tile — see custom-tab-cp image branch).
|
||
:border "1px dashed var(--lx-gray-08, var(--ls-border-color, var(--rx-gray-08)))"
|
||
:border-radius "5px"
|
||
:background "var(--rx-gray-03-alpha)"
|
||
:color "var(--lx-gray-11, var(--ls-primary-text-color, var(--rx-gray-11)))"}}
|
||
(ui/icon "plus" {:size inner
|
||
:style {:width inner-px
|
||
:height inner-px}})])
|
||
|
||
(and (map? normalized) (= :icon (:type normalized)) (get-in normalized [:data :value]))
|
||
(ui/icon (get-in normalized [:data :value]) opts)
|
||
|
||
(and (map? normalized) (= :text (:type normalized)) (get-in normalized [:data :value]))
|
||
(let [text-value (get-in normalized [:data :value])
|
||
text-align (get-in normalized [:data :alignment])
|
||
display-text (if (> (count text-value) 8)
|
||
(subs text-value 0 8)
|
||
text-value)
|
||
size (or (:size opts) 20)
|
||
;; Always split 5+ char text — icon should look identical at all sizes
|
||
lines (smart-split-text display-text)
|
||
multi-line? (> (count lines) 1)
|
||
;; Compute font-size in viewBox coords for widest line
|
||
widest-line (if multi-line?
|
||
(apply max-key count lines)
|
||
display-text)
|
||
font-size (svg-text-font-size widest-line)
|
||
;; For multi-line, clamp so both lines fit vertically
|
||
font-size (if (and multi-line? (> (* font-size 2.4) 95))
|
||
(/ 95 2.4)
|
||
font-size)
|
||
;; Dynamic y-positions based on font-size
|
||
line-spacing (* font-size 1.25)
|
||
y1 (- 50 (/ line-spacing 2))
|
||
y2 (+ 50 (/ line-spacing 2))
|
||
;; SVG text-anchor from alignment
|
||
anchor (case text-align "left" "start" "right" "end" "middle")
|
||
x (case text-align "left" 8 "right" 92 50)]
|
||
;; Fill inherits via `currentColor` so the outer `color?`
|
||
;; wrapper's contrast-adjusted color (icon.cljs:772-789,
|
||
;; same `colors/adjust-for-contrast` path tabler icons
|
||
;; use) actually reaches the text. Previously this branch
|
||
;; baked the raw user-picked hex directly into `:fill`,
|
||
;; which bypassed the wrapper's adjustment — a `#000000`
|
||
;; pick rendered as invisible black on dark themes.
|
||
[:svg {:viewBox "0 0 100 100"
|
||
:width size :height size
|
||
:style {:fill "currentColor"
|
||
:flex-shrink 0}}
|
||
(if multi-line?
|
||
(list
|
||
[:text {:x x :y y1 :font-size font-size :font-weight "500"
|
||
:font-family "Inter, sans-serif"
|
||
:text-anchor anchor :dominant-baseline "central"}
|
||
(first lines)]
|
||
[:text {:x x :y y2 :font-size font-size :font-weight "500"
|
||
:font-family "Inter, sans-serif"
|
||
:text-anchor anchor :dominant-baseline "central"}
|
||
(second lines)])
|
||
[:text {:x x :y 50 :font-size font-size :font-weight "500"
|
||
:font-family "Inter, sans-serif"
|
||
:text-anchor anchor :dominant-baseline "central"}
|
||
display-text])])
|
||
|
||
(and (map? normalized) (= :avatar (:type normalized)) (get-in normalized [:data :value]))
|
||
(let [avatar-data (get normalized :data)
|
||
asset-uuid (get avatar-data :asset-uuid)
|
||
asset-type (get avatar-data :asset-type)]
|
||
(if (and (string? asset-uuid) (not (string/blank? asset-uuid)))
|
||
;; Avatar with image — let avatar-image-cp resolve via the
|
||
;; filesystem loader. Don't gate on a renderer-side
|
||
;; `db/entity` check: assets hydrate lazily, so a direct
|
||
;; navigation can find the entity missing while the file
|
||
;; is on disk. The loader retries on transient failures
|
||
;; and shui/avatar-fallback (initials) shows underneath
|
||
;; until the image lands; if the asset is truly gone the
|
||
;; initials persist as the natural error state.
|
||
(avatar-image-cp asset-uuid asset-type avatar-data opts)
|
||
;; Text-only avatar (no image set). Renders either initials
|
||
;; or a tabler icon depending on :fallback-type.
|
||
(let [size (or (:size opts) 20)
|
||
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)
|
||
fb-type (or (get avatar-data :fallback-type) :letters)
|
||
fb-icon (get avatar-data :fallback-icon)
|
||
display-text (subs avatar-value 0 (min 3 (count avatar-value)))
|
||
;; Scale font-size with avatar size. The earlier
|
||
;; tier capped at 14px past 32px, which left the
|
||
;; 38px page-icon and 56px customize-band preview
|
||
;; reading as too-small text on too-big tiles.
|
||
;; Past 32px we step proportionally (~38–40% of
|
||
;; size) so the text fills the chip the way
|
||
;; Notion / Linear / Slack avatars do.
|
||
font-size (cond
|
||
(<= size 16) "8px"
|
||
(<= size 24) "10px"
|
||
(<= size 32) "12px"
|
||
(<= size 40) "16px"
|
||
(<= size 56) "22px"
|
||
:else (str (int (* size 0.4)) "px"))
|
||
;; Icon glyph scales to ~55% of the avatar's box.
|
||
icon-size (max 10 (int (* size 0.55)))
|
||
fallback-style (avatar-fallback-style {:font-size font-size
|
||
:bg explicit-bg
|
||
:color explicit-color})]
|
||
(shui/avatar
|
||
{:style {:width size :height size}
|
||
:data-shape (name shape)}
|
||
(shui/avatar-fallback
|
||
{:style fallback-style
|
||
:data-shape (name shape)}
|
||
(cond
|
||
(and (= fb-type :icon) (not (string/blank? fb-icon)))
|
||
;; Icon fallback inherits the foreground color from
|
||
;; avatar-fallback-style (which has already been
|
||
;; contrast-adjusted against the muted background).
|
||
(shui/tabler-icon fb-icon
|
||
{:size icon-size
|
||
:style {:color (:color fallback-style)}})
|
||
|
||
(and (= fb-type :emoji) (not (string/blank? fb-icon)))
|
||
;; Emoji fallback: emoji glyphs aren't tintable
|
||
;; (they're full-color SVGs), so we don't pass
|
||
;; the muted color through. `em-emoji` accepts
|
||
;; `:size` directly (same prop emoji-mart's
|
||
;; web component reads); matches the icon tier
|
||
;; so emoji and tabler glyphs occupy the same
|
||
;; footprint inside the avatar.
|
||
[:em-emoji {:id fb-icon
|
||
:size icon-size
|
||
:style {:line-height 1}}]
|
||
|
||
:else
|
||
display-text))))))
|
||
|
||
;; Image with asset — let image-icon-cp resolve via the filesystem
|
||
;; loader. Don't gate on a renderer-side `db/entity` check:
|
||
;; assets hydrate lazily into the renderer DataScript, so a
|
||
;; direct navigation to a page whose icon points at an asset
|
||
;; can find the entity missing while the file is on disk.
|
||
;; image-icon-cp retries the load on transient failures and
|
||
;; shows a `photo-off` icon if the file is truly gone.
|
||
(and (map? normalized) (= :image (:type normalized))
|
||
(let [u (get-in normalized [:data :asset-uuid])]
|
||
(and (string? u) (not (string/blank? u)))))
|
||
(let [asset-uuid (get-in normalized [:data :asset-uuid])
|
||
asset-type (get-in normalized [:data :asset-type])]
|
||
(image-icon-cp asset-uuid asset-type opts))
|
||
|
||
;; Legacy format support (fallback if normalization failed)
|
||
(and (map? icon') (= :emoji (:type icon')) (:id icon'))
|
||
[:span.ui__icon
|
||
[:em-emoji (merge {:id (:id icon')
|
||
:style {:line-height 1}}
|
||
opts)]]
|
||
|
||
(and (map? icon') (= :tabler-icon (:type icon')) (:id icon'))
|
||
(ui/icon (:id icon') (cond-> opts
|
||
(#{"property" "child-node" "page-property" "node"} (:id icon'))
|
||
(assoc :extension? true)))
|
||
|
||
:else nil)]
|
||
(when item
|
||
(if color?
|
||
(let [c (or (get-in normalized [:data :color])
|
||
(some-> icon' :color))
|
||
;; Display-color: when a real hex is stored, lift contrast vs the
|
||
;; current page background to WCAG 3:1 (non-text UI threshold).
|
||
;; CSS-var values (Radix `var(--rx-...)`) are theme-aware
|
||
;; out-of-band and intentionally bypassed.
|
||
page-bg (when (and c (string/starts-with? c "#"))
|
||
(colors/read-bg-var "--ls-primary-background-color"))
|
||
display-c (if (and c (not= c "inherit")
|
||
(string/starts-with? c "#")
|
||
page-bg)
|
||
(colors/adjust-for-contrast c page-bg 3.0)
|
||
c)]
|
||
[:span.inline-flex.items-center.ls-icon-color-wrap
|
||
{:class (when (and c (not= c "inherit")) "icon-colored")
|
||
:style {:color (or display-c "inherit")}} item])
|
||
item))))
|
||
|
||
(defn get-node-icon
|
||
[node-entity]
|
||
(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
|
||
default-icon (some :logseq.property.class/default-icon sorted-tags)]
|
||
(cond
|
||
;; 1. Instance's own icon takes precedence (:none = explicitly deleted, skip inheritance)
|
||
(and block-icon (= :none (:type block-icon)))
|
||
nil
|
||
|
||
block-icon
|
||
block-icon
|
||
|
||
;; 2. Resolve from tag's default-icon (unified inheritance)
|
||
default-icon
|
||
(case (:type default-icon)
|
||
:avatar (when (:block/title node-entity)
|
||
;; Inherit color + shape + fallback from the class default.
|
||
;; normalize-icon downstream applies defaults for any field
|
||
;; the class doesn't override.
|
||
(let [inherited (select-keys (:data default-icon)
|
||
[:backgroundColor :color
|
||
:shape :fallback-type :fallback-icon])]
|
||
(cond-> {:type :avatar
|
||
:data (merge inherited
|
||
{:value (derive-avatar-initials (:block/title node-entity))})}
|
||
(:color inherited) (assoc :color (:color inherited)))))
|
||
:text (when (:block/title node-entity)
|
||
;; Inherit color + alignment + mode from the class
|
||
;; default. `:mode` ("initials" | "abbreviated" |
|
||
;; "custom") drives per-instance derivation:
|
||
;; initials — derive 2-char initials from each
|
||
;; instance's title (e.g. "M2")
|
||
;; abbreviated— derive a short word-prefix per
|
||
;; instance ("Math" for "Math 201")
|
||
;; custom — propagate the class's literal text
|
||
;; verbatim (a constant, not a fn)
|
||
;; Without this, every inheriting row showed
|
||
;; `derive-initials` regardless of the class's chosen
|
||
;; style, making the Abbreviated/Custom options
|
||
;; appear broken at the row level.
|
||
(let [inherited (select-keys (:data default-icon)
|
||
[:color :alignment :mode])
|
||
mode (:mode inherited)
|
||
title (:block/title node-entity)
|
||
derived-value (cond
|
||
(= mode "abbreviated")
|
||
(or (derive-abbreviated title)
|
||
(derive-initials title))
|
||
|
||
(= mode "custom")
|
||
(or (get-in default-icon [:data :value])
|
||
(derive-initials title))
|
||
|
||
:else
|
||
(derive-initials title))]
|
||
(cond-> {:type :text
|
||
:data (merge inherited {:value derived-value})}
|
||
(:color inherited) (assoc :color (:color inherited)))))
|
||
;; Image type: return marker indicating inherited image without asset yet
|
||
:image {:type :image
|
||
:data {:empty? true}}
|
||
;; For tabler-icon and emoji, use the stored icon value directly
|
||
default-icon)
|
||
|
||
;; 3. 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]
|
||
(let [;; Get fresh entity using db/sub-block to make it reactive to property changes
|
||
fresh-entity (when-let [db-id (:db/id node-entity)]
|
||
(or (model/sub-block db-id) node-entity))
|
||
entity (or fresh-entity node-entity)
|
||
node-icon (cond
|
||
(:own-icon? opts)
|
||
(get entity :logseq.property/icon)
|
||
(:link? opts)
|
||
"arrow-narrow-right"
|
||
:else
|
||
(get-node-icon entity))
|
||
;; Photo-based custom icons (avatar/image) default to 20px but respect caller's :size.
|
||
;; Symbolic icons (emoji, tabler, text, defaults) use caller's :size or 14.
|
||
photo-icon? (and (map? node-icon)
|
||
(contains? #{:avatar :image} (:type node-icon)))
|
||
effective-size (if photo-icon?
|
||
(or (:size opts) 20)
|
||
(or (:size opts) 14))
|
||
opts' (assoc opts :size effective-size)
|
||
;; Hover preview from icon-picker — overrides node's icon and/or
|
||
;; color while the user is hovering tiles in the picker. The state
|
||
;; can carry `:icon` (full normalized item override), `:color`
|
||
;; (color override), or both. `:property` scopes the preview so a
|
||
;; Default-Icon-scoped hover doesn't leak into surfaces that
|
||
;; render the page-icon (sidebar, cmdk, breadcrumb, etc.).
|
||
;; Defaults to `:logseq.property/icon` because that's what every
|
||
;; existing caller of this fn renders.
|
||
preview-property (or (:property opts) :logseq.property/icon)
|
||
preview (state/sub :ui/icon-hover-preview)
|
||
preview-active? (and preview
|
||
(icon-preview-matches? preview (:db/id entity) preview-property))
|
||
preview-icon (when preview-active? (:icon preview))
|
||
;; Preview color is only applied when the hover-preview state
|
||
;; carries an explicit `:color`. Without that guard, a shape- or
|
||
;; fallback-only hover (which omits `:color`) would clobber the
|
||
;; avatar's `:backgroundColor` to "inherit" and kill the chip.
|
||
preview-color (when preview-active? (:color preview))
|
||
effective-color (cond
|
||
preview-color preview-color
|
||
:else (or (:color node-icon) "inherit"))
|
||
;; Source icon for the preview overlay: either the previewed icon
|
||
;; (cross-type swap) or the committed node-icon. Then layer the
|
||
;; preview color into [:data :color] so the inner `icon` fn renders
|
||
;; with the preview color — its inline `style: color` would
|
||
;; otherwise win over our outer wrapper's cascade.
|
||
;;
|
||
;; For avatars: also override [:data :backgroundColor], since the
|
||
;; circle's bg-color is inline and doesn't inherit from `color`.
|
||
base-icon (or preview-icon node-icon)
|
||
effective-node-icon (cond-> base-icon
|
||
;; Only mutate when there's an *explicit*
|
||
;; preview color — non-color previews
|
||
;; (shape, fallback) leave the icon's own
|
||
;; colors intact.
|
||
(and preview-color (map? base-icon))
|
||
(-> (normalize-icon)
|
||
(assoc :color preview-color)
|
||
(assoc-in [:data :color] preview-color))
|
||
|
||
(and preview-color
|
||
(map? base-icon)
|
||
(= :avatar (:type (normalize-icon base-icon))))
|
||
(assoc-in [:data :backgroundColor] preview-color))
|
||
;; Lift contrast vs the page background to WCAG 3:1 (non-text UI
|
||
;; threshold) — same logic the inner `icon` fn applies when called
|
||
;; with `:color? true`. This wrapper is used by sidebar / right
|
||
;; panel / cmdk which don't pass `:color?`, so the adjustment must
|
||
;; happen here too. CSS-var values pass through unchanged.
|
||
page-bg (when (and (string? effective-color)
|
||
(string/starts-with? effective-color "#"))
|
||
(colors/read-bg-var "--ls-primary-background-color"))
|
||
display-color (if (and (string? effective-color)
|
||
(not= effective-color "inherit")
|
||
(string/starts-with? effective-color "#")
|
||
page-bg)
|
||
(colors/adjust-for-contrast effective-color page-bg 3.0)
|
||
effective-color)]
|
||
(when-not (and (nil? preview-icon)
|
||
(or (string/blank? node-icon)
|
||
(and (contains? #{"letter-n" "file"} node-icon)
|
||
(:not-text-or-page? opts))))
|
||
[:div.icon-cp-container.flex.items-center.justify-center
|
||
{:style {:color display-color}
|
||
:class (str (when photo-icon? "photo-icon")
|
||
(when (and effective-color (not= effective-color "inherit")) " icon-colored")
|
||
(when-let [c (:class opts)] (str " " c)))}
|
||
(icon effective-node-icon opts')])))
|
||
|
||
(defn- emoji-char?
|
||
"Check if a string is a single emoji character by checking against known emojis"
|
||
[s]
|
||
(and (string? s)
|
||
(not (string/blank? s))
|
||
(<= (count s) 2) ; emojis are typically 1-2 code units
|
||
(some #(= (:id %) s) emojis)))
|
||
|
||
(defn- guess-from-value
|
||
"Attempt to guess icon type from map value when type is unknown"
|
||
[m]
|
||
(let [value (or (:value m) (:id m))]
|
||
(when (string? value)
|
||
(if (emoji-char? value)
|
||
{:type :emoji
|
||
:id (str "emoji-" value)
|
||
:label value
|
||
:data {:value value}}
|
||
{:type :icon
|
||
:id (str "icon-" value)
|
||
:label value
|
||
:data {:value value}}))))
|
||
|
||
(defn normalize-icon
|
||
"Convert various icon formats to unified icon-item shape:
|
||
{:id string, :type :emoji|:icon|:text|:avatar, :label string, :data {:value string, :color string (optional), :backgroundColor string (optional)}}"
|
||
[v]
|
||
(cond
|
||
;; Already unified shape? (has :data key)
|
||
;; Avatars get a small post-pass to ensure new fields (:shape,
|
||
;; :fallback-type, :fallback-icon) 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))
|
||
fb-type (or (get-in v [:data :fallback-type]) (:fallback-type v))
|
||
fb-icon (or (get-in v [:data :fallback-icon]) (:fallback-icon v))
|
||
;; A nil fb-type defaults to :letters. `:icon` and `:emoji`
|
||
;; both store their value in `:fallback-icon` (tabler name or
|
||
;; emoji shortcode) — without a non-blank value the fb is
|
||
;; unrenderable, so degrade back to `:letters` and let the
|
||
;; renderer rely on the invariant that `:icon`/`:emoji`
|
||
;; always carries a non-blank `:fallback-icon`.
|
||
effective-fb-type (cond
|
||
(nil? fb-type) :letters
|
||
(and (#{:icon :emoji} fb-type)
|
||
(string/blank? fb-icon)) :letters
|
||
:else fb-type)]
|
||
(cond-> v
|
||
(nil? explicit-shape) (assoc-in [:data :shape] :circle)
|
||
(some? explicit-shape) (assoc-in [:data :shape] explicit-shape)
|
||
true (assoc-in [:data :fallback-type] effective-fb-type)
|
||
(and (#{:icon :emoji} effective-fb-type)
|
||
(not (string/blank? fb-icon)))
|
||
(assoc-in [:data :fallback-icon] fb-icon)))
|
||
v)
|
||
|
||
;; Legacy map with :type
|
||
(map? v)
|
||
(let [type-kw (cond
|
||
(keyword? (:type v)) (:type v)
|
||
(string? (:type v)) (keyword (:type v))
|
||
:else nil)
|
||
id (or (:id v) (:value v))
|
||
value (or (:value v) (:id v))
|
||
color (:color v)
|
||
label (or (:name v) (:label v) value)]
|
||
(case type-kw
|
||
:emoji {:type :emoji
|
||
:id (or id (str "emoji-" value))
|
||
:label (or label value)
|
||
:data {:value value}}
|
||
:tabler-icon {:type :icon
|
||
:id (or id (str "icon-" value))
|
||
:label (or label value)
|
||
:data (cond-> {:value value}
|
||
color (assoc :color color))}
|
||
:icon {:type :icon
|
||
:id (or id (str "icon-" value))
|
||
:label (or label value)
|
||
:data (cond-> {:value value}
|
||
color (assoc :color color))}
|
||
:text (let [alignment (or (get-in v [:data :alignment]) (:alignment v))
|
||
mode (or (get-in v [:data :mode]) (:mode v))]
|
||
{:type :text
|
||
:id (or id (str "text-" value))
|
||
:label (or label value)
|
||
:data (cond-> {:value value}
|
||
color (assoc :color color)
|
||
alignment (assoc :alignment alignment)
|
||
mode (assoc :mode mode))})
|
||
:avatar (let [backgroundColor (or (:backgroundColor v)
|
||
(colors/variable :gray :09))
|
||
color (or (:color v)
|
||
(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))
|
||
;; 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)
|
||
;; Fallback layer: rendered when no image is set.
|
||
;; :letters → render the auto-derived initials.
|
||
;; :icon → render :fallback-icon (a tabler icon name).
|
||
;; :emoji → render :fallback-icon (an emoji shortcode).
|
||
;; Default :letters; :icon/:emoji without a non-blank
|
||
;; :fallback-icon degrade to :letters so the renderer
|
||
;; never has to render an empty avatar.
|
||
fb-type (or (get-in v [:data :fallback-type]) (:fallback-type v))
|
||
fb-icon (or (get-in v [:data :fallback-icon]) (:fallback-icon v))
|
||
effective-fb-type (cond
|
||
(nil? fb-type) :letters
|
||
(and (#{:icon :emoji} fb-type)
|
||
(string/blank? fb-icon)) :letters
|
||
:else fb-type)]
|
||
{:type :avatar
|
||
:id (or id (str "avatar-" value))
|
||
:label (or label value)
|
||
:data (cond-> {:value value
|
||
:backgroundColor backgroundColor
|
||
:color color
|
||
:shape shape
|
||
:fallback-type effective-fb-type}
|
||
asset-uuid (assoc :asset-uuid asset-uuid)
|
||
asset-type (assoc :asset-type asset-type)
|
||
(and (#{:icon :emoji} effective-fb-type)
|
||
(not (string/blank? fb-icon)))
|
||
(assoc :fallback-icon fb-icon))})
|
||
: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-"))
|
||
(subs raw-uuid 6)
|
||
raw-uuid)
|
||
;; Try to get asset-type from data, or look up from DB using Datalog query
|
||
asset-type (or (get-in v [:data :asset-type])
|
||
(:asset-type v)
|
||
(get-asset-type-from-db asset-uuid))]
|
||
{:type :image
|
||
:id (or id (str "image-" asset-uuid))
|
||
:label (or label asset-uuid)
|
||
:data {:asset-uuid asset-uuid
|
||
:asset-type asset-type}})
|
||
;; Synthetic placeholder type used by the icon-picker hover preview
|
||
;; when the user navigates to the Custom-tab "Image" button. Has no
|
||
;; payload — the renderer in `icon` produces a self-contained plus-
|
||
;; in-dashed-square visual.
|
||
:image-placeholder {:type :image-placeholder
|
||
:id (or id "image-placeholder")
|
||
:label "Pick an image"
|
||
:data {}}
|
||
;; Fallback: try to guess from value
|
||
(or (guess-from-value v)
|
||
{:type :icon
|
||
:id (str "icon-" (or value "unknown"))
|
||
:label (or label value "unknown")
|
||
:data {:value (or value "")}})))
|
||
|
||
;; Plain string: detect emoji vs icon name
|
||
(string? v)
|
||
(if (emoji-char? v)
|
||
{:type :emoji
|
||
:id (str "emoji-" v)
|
||
:label v
|
||
:data {:value v}}
|
||
{:type :icon
|
||
:id (str "icon-" v)
|
||
:label v
|
||
:data {:value v}})
|
||
|
||
:else nil))
|
||
|
||
(defn icon-data-for-storage
|
||
"Strip a picker-emitted icon down to fields persisted on a block. Mirrors
|
||
`normalize-icon` (which targets rendering) but inverts intent — keep only
|
||
what the renderer will need to reconstruct the icon, drop ephemeral
|
||
picker state."
|
||
[icon]
|
||
(cond
|
||
(= :text (:type icon)) {:type :text :data (:data icon)}
|
||
(= :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])))
|
||
|
||
(defn renderable-icon?
|
||
"True when icon-value would produce a visible element via `icon`. For :icon type
|
||
this includes verifying that the underlying Tabler component actually exists,
|
||
which catches stored values whose :id no longer resolves (e.g. data saved from a
|
||
stale picker entry before the tabler-icons filter was added).
|
||
|
||
For :image/:avatar we trust the presence of an asset-uuid string rather than
|
||
probing the renderer-side entity: assets hydrate lazily, so a synchronous
|
||
`db/entity` check races with cold loads (and would also flap the page-title
|
||
'Add icon' button while the entity is still being fetched). The actual
|
||
render path resolves via the filesystem loader and surfaces error states
|
||
on real failure."
|
||
[icon-value]
|
||
(boolean
|
||
(when-let [normalized (normalize-icon icon-value)]
|
||
(case (:type normalized)
|
||
:none false
|
||
:emoji (not (string/blank? (get-in normalized [:data :value])))
|
||
:icon (when-let [v (get-in normalized [:data :value])]
|
||
(and (exists? js/tablerIcons)
|
||
(some? (gobj/get js/tablerIcons (str "Icon" (csk/->PascalCase v))))))
|
||
:text (not (string/blank? (get-in normalized [:data :value])))
|
||
:avatar (let [u (get-in normalized [:data :asset-uuid])]
|
||
(or (and (string? u) (not (string/blank? u)))
|
||
(not (string/blank? (get-in normalized [:data :value])))))
|
||
:image (let [u (get-in normalized [:data :asset-uuid])]
|
||
(and (string? u) (not (string/blank? u))))
|
||
false))))
|
||
|
||
(defn get-image-assets
|
||
"Get image assets from frontend Datascript (fast, but may be empty on cold start)"
|
||
[]
|
||
(let [image-extensions (set (map name config/image-formats))
|
||
results (db-utils/q '[:find ?uuid ?type ?title ?updated ?checksum ?source-url ?source-name ?license
|
||
:where
|
||
[?e :logseq.property.asset/type ?type]
|
||
[?e :logseq.property.asset/checksum ?checksum]
|
||
[?e :block/uuid ?uuid]
|
||
[(get-else $ ?e :block/title "") ?title]
|
||
[(get-else $ ?e :block/updated-at 0) ?updated]
|
||
[(get-else $ ?e :logseq.property.asset/source-url "") ?source-url]
|
||
[(get-else $ ?e :logseq.property.asset/source-name "") ?source-name]
|
||
[(get-else $ ?e :logseq.property.asset/license "") ?license]])]
|
||
(->> results
|
||
(filter (fn [[_uuid type & _]]
|
||
(contains? image-extensions (some-> type string/lower-case))))
|
||
(sort-by (fn [[_uuid _type _title updated & _]] updated) >)
|
||
;; Deduplicate by checksum — keep the most recently updated entry
|
||
(medley/distinct-by (fn [[_uuid _type _title _updated checksum & _]] checksum))
|
||
(map (fn [[uuid type title _updated checksum source-url source-name license]]
|
||
(cond-> {:block/uuid uuid
|
||
:block/title (if (string/blank? title) (str uuid) title)
|
||
:logseq.property.asset/type type
|
||
:logseq.property.asset/checksum checksum}
|
||
(not (string/blank? source-url))
|
||
(assoc :logseq.property.asset/source-url source-url)
|
||
|
||
(not (string/blank? source-name))
|
||
(assoc :logseq.property.asset/source-name source-name)
|
||
|
||
(not (string/blank? license))
|
||
(assoc :logseq.property.asset/license license)))))))
|
||
|
||
(defn <get-image-assets
|
||
"Async fetch image assets from DB worker (works on cold start).
|
||
Returns a promise that resolves to a list of asset maps.
|
||
Uses transact-db? false to avoid re-transacting deleted assets back into frontend."
|
||
[]
|
||
(when-let [graph (state/get-current-repo)]
|
||
(p/let [results (db-async/<q graph
|
||
{:transact-db? false}
|
||
'[:find (pull ?e [:block/uuid :block/title :logseq.property.asset/type :logseq.property.asset/checksum :logseq.property.asset/source-url :logseq.property.asset/source-name :logseq.property.asset/license :block/updated-at])
|
||
:where
|
||
[?e :logseq.property.asset/type ?type]
|
||
[?e :logseq.property.asset/checksum _]])]
|
||
(let [image-extensions (set (map name config/image-formats))
|
||
;; Results from pull queries come as [[{map}] [{map}] ...], extract the maps
|
||
assets (map (fn [r] (if (vector? r) (first r) r)) results)]
|
||
(->> assets
|
||
(filter (fn [asset]
|
||
(contains? image-extensions
|
||
(some-> (:logseq.property.asset/type asset) string/lower-case))))
|
||
;; Deduplicate by checksum — keep the most recently updated entry
|
||
(sort-by :block/updated-at >)
|
||
(medley/distinct-by :logseq.property.asset/checksum))))))
|
||
|
||
(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)))))
|
||
|
||
;; ============================================================================
|
||
;; URL Asset Download Helpers
|
||
;; ============================================================================
|
||
|
||
(def ^:private max-url-asset-size
|
||
"Maximum allowed size for URL assets (10MB)"
|
||
(* 10 1024 1024))
|
||
|
||
(defn- valid-image-content-type?
|
||
"Check if content-type header indicates an image"
|
||
[content-type]
|
||
(and (string? content-type)
|
||
(string/starts-with? content-type "image/")))
|
||
|
||
(defn- content-type->extension
|
||
"Convert content-type to file extension"
|
||
[content-type]
|
||
(when (string? content-type)
|
||
(case (string/lower-case content-type)
|
||
"image/png" "png"
|
||
"image/jpeg" "jpg"
|
||
"image/jpg" "jpg"
|
||
"image/gif" "gif"
|
||
"image/webp" "webp"
|
||
"image/svg+xml" "svg"
|
||
"image/bmp" "bmp"
|
||
"image/heic" "heic"
|
||
"image/x-icon" "ico"
|
||
"image/vnd.microsoft.icon" "ico"
|
||
;; Fallback: extract from content-type (e.g., "image/tiff" -> "tiff")
|
||
(second (re-find #"image/(\w+)" content-type)))))
|
||
|
||
(defn- extract-filename-from-url
|
||
"Extract a filename from URL path, stripping extension"
|
||
[url]
|
||
(try
|
||
(let [url-obj (js/URL. url)
|
||
pathname (.-pathname url-obj)
|
||
basename (node-path/basename pathname)
|
||
;; Use db-asset utility to strip extension
|
||
name-without-ext (db-asset/asset-name->title basename)]
|
||
(if (or (string/blank? name-without-ext)
|
||
(= name-without-ext "image"))
|
||
;; Fallback to timestamp if no meaningful name
|
||
(date/get-date-time-string-2)
|
||
name-without-ext))
|
||
(catch :default _
|
||
(date/get-date-time-string-2))))
|
||
|
||
(defn- sniff-image-content-type
|
||
"Detect image MIME type from the first bytes of an ArrayBuffer.
|
||
Returns {:content-type String :kind :image|:html|:unknown :ext String?}.
|
||
content-type is a string suitable for valid-image-content-type? and
|
||
content-type->extension. :kind :html distinguishes HTML pages from
|
||
unknown binary blobs so callers can surface a dedicated error."
|
||
[^js array-buffer]
|
||
(let [bytes (js/Uint8Array. array-buffer)
|
||
len (.-length bytes)
|
||
b (fn [i] (when (< i len) (aget bytes i)))
|
||
starts? (fn [prefix]
|
||
(and (>= len (count prefix))
|
||
(every? (fn [[i v]] (= v (b i)))
|
||
(map-indexed vector prefix))))
|
||
text-head (when (pos? len)
|
||
(let [n (min len 200)
|
||
ta (js/Uint8Array. array-buffer 0 n)
|
||
decoder (js/TextDecoder. "utf-8" #js {:fatal false})]
|
||
(string/lower-case (.decode decoder ta))))
|
||
trimmed (some-> text-head string/triml)
|
||
html? (fn []
|
||
(and text-head
|
||
(or (re-find #"<!doctype\s+html" text-head)
|
||
(re-find #"<html[\s>]" text-head)
|
||
(re-find #"<head[\s>]" text-head)
|
||
(re-find #"<body[\s>]" text-head)
|
||
(string/starts-with? trimmed "<!--"))))
|
||
svg? (fn []
|
||
(and text-head
|
||
(or (re-find #"<\?xml" text-head)
|
||
(re-find #"<svg[\s>]" text-head))))]
|
||
(cond
|
||
(starts? [0x89 0x50 0x4E 0x47 0x0D 0x0A 0x1A 0x0A])
|
||
{:content-type "image/png" :kind :image :ext "png"}
|
||
|
||
(starts? [0xFF 0xD8 0xFF])
|
||
{:content-type "image/jpeg" :kind :image :ext "jpg"}
|
||
|
||
(or (starts? [0x47 0x49 0x46 0x38 0x37 0x61])
|
||
(starts? [0x47 0x49 0x46 0x38 0x39 0x61]))
|
||
{:content-type "image/gif" :kind :image :ext "gif"}
|
||
|
||
(and (starts? [0x52 0x49 0x46 0x46])
|
||
(= 0x57 (b 8)) (= 0x45 (b 9)) (= 0x42 (b 10)) (= 0x50 (b 11)))
|
||
{:content-type "image/webp" :kind :image :ext "webp"}
|
||
|
||
(starts? [0x42 0x4D])
|
||
{:content-type "image/bmp" :kind :image :ext "bmp"}
|
||
|
||
(and (= 0x66 (b 4)) (= 0x74 (b 5)) (= 0x79 (b 6)) (= 0x70 (b 7))
|
||
(contains? #{"heic" "heix" "hevc" "hevx" "heim" "heis" "mif1" "msf1"}
|
||
(str (char (or (b 8) 0)) (char (or (b 9) 0))
|
||
(char (or (b 10) 0)) (char (or (b 11) 0)))))
|
||
{:content-type "image/heic" :kind :image :ext "heic"}
|
||
|
||
(svg?) {:content-type "image/svg+xml" :kind :image :ext "svg"}
|
||
(html?) {:content-type "text/html" :kind :html}
|
||
:else {:content-type nil :kind :unknown})))
|
||
|
||
(defn- read-from-event
|
||
"Synchronously extract an image file or text URL from a ClipboardEvent.
|
||
Returns a promise of {:kind :image :file File} | {:kind :url :url String} | nil.
|
||
Note: the promise may resolve synchronously for image-file events."
|
||
[^js event]
|
||
(if (nil? event)
|
||
(p/resolved nil)
|
||
(let [cd (some-> event .-clipboardData)]
|
||
(if (nil? cd)
|
||
(p/resolved nil)
|
||
(let [files (.-files cd)
|
||
items (.-items cd)]
|
||
(cond
|
||
(and files (pos? (.-length files)))
|
||
(if-let [f (aget files 0)]
|
||
(p/resolved {:kind :image :file f})
|
||
(p/resolved nil))
|
||
|
||
:else
|
||
(let [n (if items (.-length items) 0)
|
||
indices (range n)
|
||
file-item (some (fn [i]
|
||
(let [it (aget items i)]
|
||
(when (and it (= "file" (.-kind it)))
|
||
(when-let [f (.getAsFile it)]
|
||
{:file f}))))
|
||
indices)]
|
||
(if file-item
|
||
(p/resolved {:kind :image :file (:file file-item)})
|
||
(let [text-item (some (fn [i]
|
||
(let [it (aget items i)]
|
||
(when (and it
|
||
(= "string" (.-kind it))
|
||
(= "text/plain" (.-type it)))
|
||
it)))
|
||
indices)]
|
||
(if text-item
|
||
(-> (p/create
|
||
(fn [resolve _reject]
|
||
(.getAsString ^js text-item
|
||
(fn [s] (resolve s)))))
|
||
(p/then (fn [s]
|
||
(let [trimmed (some-> s string/trim)]
|
||
(if (and trimmed
|
||
(re-matches #"^https?://\S+$" trimmed))
|
||
{:kind :url :url trimmed}
|
||
nil)))))
|
||
(p/resolved nil)))))))))))
|
||
|
||
(defn- <download-url-asset-via-fetch
|
||
"Browser path: uses js/fetch. Subject to CORS."
|
||
[url]
|
||
(p/create
|
||
(fn [resolve reject]
|
||
(-> (js/fetch url #js {:method "GET"
|
||
:mode "cors"
|
||
:credentials "omit"})
|
||
(.then (fn [^js response]
|
||
(if (.-ok response)
|
||
(let [content-type (.get (.-headers response) "content-type")]
|
||
(-> (.arrayBuffer response)
|
||
(.then (fn [buffer]
|
||
(resolve {:data buffer
|
||
:content-type content-type
|
||
:size (.-byteLength buffer)
|
||
:kind :image})))))
|
||
(reject (ex-info "Failed to download"
|
||
{:kind :http-status :status (.-status response) :url url})))))
|
||
(.catch (fn [^js err]
|
||
(log/error :icon/url-asset-fetch-failed {:url url :error err})
|
||
;; In a browser build, TypeError from a CORS-mode fetch almost always
|
||
;; means the target blocked cross-origin access. We can't distinguish
|
||
;; true network failures from CORS rejections here — the browser
|
||
;; deliberately obscures it for security. Treat as :cors in the browser.
|
||
(let [cors? (instance? js/TypeError err)]
|
||
(reject (ex-info (if cors? "Cross-origin blocked" "Network error")
|
||
{:kind (if cors? :cors :network)
|
||
:error (some-> err .-message)
|
||
:url url})))))))))
|
||
|
||
(defn- <download-url-asset-via-ipc
|
||
"Electron path: uses main-process IPC (node-fetch, no CORS).
|
||
Consumes structured {:status :ok :headers :data} response; classifies
|
||
failures by HTTP status and content. Sniffs magic bytes to distinguish
|
||
HTML challenge pages from real images when headers are unreliable."
|
||
[url]
|
||
(-> (ipc/ipc :httpRequest (str (random-uuid))
|
||
#js {:url url
|
||
:method "GET"
|
||
:returnType "arraybuffer"
|
||
:structured true})
|
||
(p/then (fn [^js response]
|
||
(let [status (.-status response)
|
||
headers (.-headers response)
|
||
header-content-type (some-> headers (gobj/get "content-type"))
|
||
data (.-data response)
|
||
byte-length (if (and data (.-byteLength data))
|
||
(.-byteLength data) 0)]
|
||
(cond
|
||
(not (<= 200 status 299))
|
||
(throw (ex-info "HTTP error"
|
||
{:kind :http-status
|
||
:status status
|
||
:content-type header-content-type
|
||
:url url}))
|
||
|
||
(zero? byte-length)
|
||
(throw (ex-info "Empty response"
|
||
{:kind :empty
|
||
:status status
|
||
:url url}))
|
||
|
||
:else
|
||
(let [sniffed (sniff-image-content-type data)
|
||
sniff-kind (:kind sniffed)
|
||
;; Bytes are authoritative; header is fallback.
|
||
final-kind (cond
|
||
(= :image sniff-kind) :image
|
||
(= :html sniff-kind) :html
|
||
(and header-content-type
|
||
(string/starts-with?
|
||
header-content-type "text/html")) :html
|
||
(and header-content-type
|
||
(string/starts-with?
|
||
header-content-type "image/")) :image
|
||
:else :unknown)
|
||
final-content-type (or (:content-type sniffed)
|
||
header-content-type)]
|
||
{:data data
|
||
:content-type final-content-type
|
||
:size byte-length
|
||
:kind final-kind
|
||
:status status})))))
|
||
(p/catch (fn [^js err]
|
||
(log/error :icon/url-asset-ipc-failed {:url url :error err})
|
||
(if (ex-data err)
|
||
(throw err)
|
||
(throw (ex-info "Network error"
|
||
{:kind :network
|
||
:error (some-> err .-message)
|
||
:url url})))))))
|
||
|
||
(defn- <download-url-asset
|
||
"Download image from URL. Returns promise with {:data (ArrayBuffer) :content-type :size :kind}.
|
||
:kind is :image (ok), :html (page, not image), or :unknown (binary, non-image)."
|
||
[url]
|
||
(if (util/electron?)
|
||
(<download-url-asset-via-ipc url)
|
||
(<download-url-asset-via-fetch url)))
|
||
|
||
;; ============================================================================
|
||
;; Asset Saving
|
||
;; ============================================================================
|
||
|
||
(defn- source-meta->properties
|
||
"Build the property map for source-meta keys to write on the asset entity.
|
||
Returns nil when no meta was supplied."
|
||
[{:keys [source-url source-name license attribution]}]
|
||
(let [pairs (cond-> {}
|
||
(and source-url (not (string/blank? source-url)))
|
||
(assoc :logseq.property.asset/source-url source-url)
|
||
(and source-name (not (string/blank? source-name)))
|
||
(assoc :logseq.property.asset/source-name source-name)
|
||
(and license (not (string/blank? license)))
|
||
(assoc :logseq.property.asset/license license)
|
||
(and attribution (not (string/blank? attribution)))
|
||
(assoc :logseq.property.asset/attribution attribution))]
|
||
(when (seq pairs) pairs)))
|
||
|
||
(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.
|
||
Optional source-meta map: {:source-url, :source-name, :license, :attribution}
|
||
is persisted as additional asset properties when provided."
|
||
([repo file] (<save-image-asset! repo file nil))
|
||
([repo ^js file source-meta]
|
||
(p/let [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)
|
||
existing-asset (some->> checksum (db-async/<get-asset-with-checksum repo))]
|
||
(if existing-asset
|
||
;; Reuse existing asset — skip file write and block creation
|
||
existing-asset
|
||
(p/let [[repo-dir asset-dir-rpath] (assets-handler/ensure-assets-dir! repo)
|
||
size (.-size file)
|
||
ext (db-asset/asset-path->type file-name)
|
||
asset-class (db/entity :logseq.class/Asset)
|
||
block-id (ldb/new-block-id)
|
||
extra-props (source-meta->properties source-meta)]
|
||
(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 (merge {:block/tags (:db/id asset-class)
|
||
:logseq.property.asset/type ext
|
||
:logseq.property.asset/checksum checksum
|
||
:logseq.property.asset/size size}
|
||
extra-props)
|
||
:edit-block? false})]
|
||
(db/entity [:block/uuid (:block/uuid block)]))))))))
|
||
|
||
(defn <save-url-asset!
|
||
"Download image from URL and save as asset. Returns promise with asset entity.
|
||
Optional source-meta map propagates attribution metadata to the asset entity."
|
||
([repo url asset-name] (<save-url-asset! repo url asset-name nil))
|
||
([repo url asset-name source-meta]
|
||
(p/let [{:keys [data content-type size kind]} (<download-url-asset url)]
|
||
;; Validate kind / content-type
|
||
(cond
|
||
(= :html kind)
|
||
(throw (ex-info "URL is a webpage" {:kind :html-page :content-type content-type}))
|
||
|
||
(= :unknown kind)
|
||
(throw (ex-info "Unknown content type" {:kind :unknown :content-type content-type}))
|
||
|
||
(not (valid-image-content-type? content-type))
|
||
(throw (ex-info "Not an image" {:kind :not-image :content-type content-type}))
|
||
|
||
(and size (> size max-url-asset-size))
|
||
(throw (ex-info "File too large" {:kind :too-large :size size :max max-url-asset-size})))
|
||
;; Create a File object from the ArrayBuffer
|
||
(let [ext (or (content-type->extension content-type) "png")
|
||
filename (str asset-name "." ext)
|
||
blob (js/Blob. #js [data] #js {:type content-type})
|
||
file (js/File. #js [blob] filename #js {:type content-type})]
|
||
;; Delegate to existing save function
|
||
(<save-image-asset! repo file source-meta)))))
|
||
|
||
;; ============================================================================
|
||
;; Web Image Search (Wikipedia Commons)
|
||
;; ============================================================================
|
||
|
||
;; The legacy "Always add without asking" preference is meaningless now that
|
||
;; clicks commit directly. Drop the lingering localStorage key once per app
|
||
;; load so it doesn't sit around as a footgun for future debugging.
|
||
#_:clj-kondo/ignore
|
||
(defonce ^:private web-image-skip-confirm-cleanup
|
||
(try (storage/remove "ls-web-image-skip-confirm") (catch :default _ nil)))
|
||
|
||
(def ^:private license-name->spdx
|
||
"Static map of Commons LicenseShortName values to SPDX identifiers."
|
||
{"CC0" "CC0-1.0"
|
||
"CC0 1.0" "CC0-1.0"
|
||
"CC BY 1.0" "CC-BY-1.0"
|
||
"CC BY 2.0" "CC-BY-2.0"
|
||
"CC BY 2.5" "CC-BY-2.5"
|
||
"CC BY 3.0" "CC-BY-3.0"
|
||
"CC BY 4.0" "CC-BY-4.0"
|
||
"CC BY-SA 1.0" "CC-BY-SA-1.0"
|
||
"CC BY-SA 2.0" "CC-BY-SA-2.0"
|
||
"CC BY-SA 2.5" "CC-BY-SA-2.5"
|
||
"CC BY-SA 3.0" "CC-BY-SA-3.0"
|
||
"CC BY-SA 4.0" "CC-BY-SA-4.0"
|
||
"CC BY-NC 3.0" "CC-BY-NC-3.0"
|
||
"CC BY-NC 4.0" "CC-BY-NC-4.0"
|
||
"CC BY-NC-SA 3.0" "CC-BY-NC-SA-3.0"
|
||
"CC BY-NC-SA 4.0" "CC-BY-NC-SA-4.0"
|
||
"GFDL" "GFDL-1.3-or-later"
|
||
"Public domain" "Public-Domain"})
|
||
|
||
(defn- license->spdx
|
||
"Normalize a Commons LicenseShortName to an SPDX-style identifier.
|
||
Falls back to space→dash replacement for unknown values."
|
||
[license]
|
||
(when (and license (not (string/blank? license)))
|
||
(or (license-name->spdx license)
|
||
;; Fallback: trim, replace internal spaces with dashes
|
||
(-> license string/trim (string/replace #"\s+" "-")))))
|
||
|
||
(defn license->description
|
||
"Convert license code to human-readable description. Public so both the
|
||
web-image search path and the asset-block hover preview can consume it."
|
||
[license]
|
||
(when license
|
||
(let [license-lower (string/lower-case license)]
|
||
(cond
|
||
(or (string/includes? license-lower "public domain")
|
||
(string/includes? license-lower "cc0")
|
||
(string/includes? license-lower "pd"))
|
||
"Free for any use"
|
||
|
||
(or (string/includes? license-lower "cc by-nc")
|
||
(string/includes? license-lower "cc-by-nc"))
|
||
"Personal use only"
|
||
|
||
(or (string/includes? license-lower "cc by")
|
||
(string/includes? license-lower "cc-by")
|
||
(string/includes? license-lower "gfdl"))
|
||
"Free for commercial use"
|
||
|
||
:else
|
||
"Check license terms"))))
|
||
|
||
(defn- strip-html
|
||
"Strip HTML tags and decode common entities from an extmetadata string.
|
||
Wikipedia Commons returns Artist/Credit fields as wikitext-rendered HTML."
|
||
[s]
|
||
(when (and s (string? s))
|
||
(-> s
|
||
(string/replace #"<[^>]+>" "")
|
||
(string/replace #"&" "&")
|
||
(string/replace #"<" "<")
|
||
(string/replace #">" ">")
|
||
(string/replace #""" "\"")
|
||
(string/replace #"'" "'")
|
||
(string/replace #" " " ")
|
||
string/trim)))
|
||
|
||
(defn- source-name-for
|
||
"Map source keyword to display name."
|
||
[source]
|
||
(case source
|
||
:wikipedia "Wikipedia"
|
||
:wikipedia-commons "Wikimedia Commons"
|
||
"Web"))
|
||
|
||
(defn- build-attribution
|
||
"Pre-render a TASL credit string. Returns nil when there's nothing useful
|
||
to attribute (e.g. Wikipedia PageImages with no metadata)."
|
||
[{:keys [title author source license]}]
|
||
(let [source-display (source-name-for source)
|
||
has-title? (and title (not (string/blank? title)))]
|
||
(cond
|
||
(and has-title? author license)
|
||
(str title " by " author " (" source-display ", " license ")")
|
||
|
||
(and has-title? author)
|
||
(str title " by " author " (" source-display ")")
|
||
|
||
(and has-title? license)
|
||
(str title " (" source-display ", " license ")")
|
||
|
||
has-title?
|
||
(str title " (" source-display ")")
|
||
|
||
:else nil)))
|
||
|
||
(defn- <search-wikipedia-image
|
||
"Fetch the main/best image for a Wikipedia article via PageImages API.
|
||
Returns a promise resolving to one of:
|
||
{:status :ok :image image-or-nil} — request succeeded; image may be nil
|
||
{:status :error} — network/fetch failed"
|
||
[query]
|
||
(p/create
|
||
(fn [resolve _reject]
|
||
(let [url (str "https://en.wikipedia.org/w/api.php?"
|
||
"action=query"
|
||
"&titles=" (js/encodeURIComponent query)
|
||
"&prop=pageimages"
|
||
"&piprop=thumbnail|original"
|
||
"&pithumbsize=200"
|
||
"&format=json"
|
||
"&origin=*")]
|
||
(-> (js/fetch url #js {:method "GET"
|
||
:mode "cors"
|
||
:credentials "omit"})
|
||
(.then (fn [^js response]
|
||
(if (.-ok response)
|
||
(.json response)
|
||
(resolve {:status :error}))))
|
||
(.then (fn [data]
|
||
(when data
|
||
(let [pages (some-> data
|
||
(gobj/getValueByKeys "query" "pages")
|
||
js->clj)
|
||
page (first (vals pages))]
|
||
(if-let [original (get page "original")]
|
||
(resolve {:status :ok
|
||
:image {:url (get original "source")
|
||
:thumb-url (get-in page ["thumbnail" "source"])
|
||
:title query
|
||
:source :wikipedia
|
||
:license nil ; PageImages doesn't return license
|
||
:license-desc nil
|
||
:author nil
|
||
:source-url (str "https://en.wikipedia.org/wiki/"
|
||
(js/encodeURIComponent query))}})
|
||
(resolve {:status :ok :image nil}))))))
|
||
(.catch (fn [_err]
|
||
(resolve {:status :error}))))))))
|
||
|
||
(defn- <search-commons-images
|
||
"Search Wikimedia Commons for images matching query.
|
||
Returns a promise resolving to one of:
|
||
{:status :ok :images [...]} — request succeeded; vector may be empty
|
||
{:status :error} — network/fetch failed"
|
||
[query limit]
|
||
(p/create
|
||
(fn [resolve _reject]
|
||
(let [url (str "https://commons.wikimedia.org/w/api.php?"
|
||
"action=query"
|
||
"&generator=search"
|
||
"&gsrnamespace=6"
|
||
"&gsrsearch=" (js/encodeURIComponent query)
|
||
"&gsrlimit=" limit
|
||
"&prop=imageinfo"
|
||
"&iiprop=url|extmetadata"
|
||
"&iiextmetadatafilter=Artist|Credit|LicenseShortName|LicenseUrl|UsageTerms"
|
||
"&iiurlwidth=200"
|
||
"&format=json"
|
||
"&origin=*")]
|
||
(-> (js/fetch url #js {:method "GET"
|
||
:mode "cors"
|
||
:credentials "omit"})
|
||
(.then (fn [^js response]
|
||
(if (.-ok response)
|
||
(.json response)
|
||
(resolve {:status :error}))))
|
||
(.then (fn [data]
|
||
(when data
|
||
(let [pages (some-> data
|
||
(gobj/getValueByKeys "query" "pages")
|
||
js->clj
|
||
vals)]
|
||
(resolve
|
||
{:status :ok
|
||
:images
|
||
(->> pages
|
||
(map (fn [page]
|
||
(let [imageinfo (first (get page "imageinfo"))
|
||
ext (get imageinfo "extmetadata")
|
||
license-raw (get-in ext ["LicenseShortName" "value"])
|
||
license-spdx (license->spdx license-raw)
|
||
artist-html (or (get-in ext ["Artist" "value"])
|
||
(get-in ext ["Credit" "value"]))
|
||
author (strip-html artist-html)
|
||
title (-> (get page "title" "")
|
||
(string/replace #"^File:" "")
|
||
(string/replace #"\.[^.]+$" ""))]
|
||
{:url (get imageinfo "url")
|
||
:thumb-url (get imageinfo "thumburl")
|
||
:title title
|
||
:source :wikipedia-commons
|
||
:license license-spdx
|
||
:license-desc (license->description license-raw)
|
||
:author (when-not (string/blank? author) author)
|
||
:source-url (get imageinfo "descriptionurl")})))
|
||
(filter :url)
|
||
vec)})))))
|
||
(.catch (fn [_err]
|
||
(resolve {:status :error}))))))))
|
||
|
||
(defn- <search-web-images
|
||
"Combined web image search. Returns up to 5 images from Wikipedia + Commons.
|
||
Wikipedia PageImages result is prioritized (slot 1), Commons fills remaining.
|
||
Returns a promise resolving to {:images [...] :network-error? boolean}.
|
||
network-error? is true only when BOTH inner searches failed; partial
|
||
network failure is silent (we still surface whatever succeeded)."
|
||
[query]
|
||
(when-not (string/blank? query)
|
||
(p/let [;; Fire both requests in parallel
|
||
[wiki-result commons-result] (p/all [(<search-wikipedia-image query)
|
||
(<search-commons-images query 5)])
|
||
wiki-error? (= :error (:status wiki-result))
|
||
commons-error? (= :error (:status commons-result))
|
||
wiki-image (when-not wiki-error? (:image wiki-result))
|
||
commons-images (if commons-error? [] (:images commons-result))]
|
||
;; Combine results: Wikipedia first, then Commons (deduplicated)
|
||
(let [wiki-url (:url wiki-image)
|
||
;; Filter out Commons images that match the Wikipedia image
|
||
filtered-commons (if wiki-url
|
||
(remove #(= (:url %) wiki-url) commons-images)
|
||
commons-images)
|
||
;; Combine and take up to 5
|
||
combined (if wiki-image
|
||
(cons wiki-image filtered-commons)
|
||
filtered-commons)]
|
||
{:images (vec (take 5 combined))
|
||
:network-error? (and wiki-error? commons-error?)}))))
|
||
|
||
(defn- search-emojis
|
||
[q]
|
||
(p/let [result (.search SearchIndex q)]
|
||
(->> (bean/->clj result)
|
||
(map (fn [emoji]
|
||
{:type :emoji
|
||
:id (:id emoji)
|
||
:label (or (:name emoji) (:id emoji))
|
||
:data {:value (:id emoji)}})))))
|
||
|
||
(defonce *tabler-icons (atom nil))
|
||
(defn- get-tabler-icons
|
||
[]
|
||
(if @*tabler-icons
|
||
@*tabler-icons
|
||
(let [result (->> (keys (bean/->clj js/tablerIcons))
|
||
;; @tabler/icons-react exports icon components (IconFoo) alongside
|
||
;; utility functions (e.g. createReactComponent). Drop anything that
|
||
;; isn't an icon component — otherwise they surface as phantom entries
|
||
;; in search, render empty, and corrupt the icon property when picked.
|
||
(filter #(string/starts-with? (name %) "Icon"))
|
||
(map (fn [k]
|
||
(-> (string/replace (csk/->Camel_Snake_Case (name k)) "_" " ")
|
||
(string/replace-first "Icon " ""))))
|
||
;; csk/->Camel_Snake_Case treats "AB" in IconAB / IconAB2 / IconABOff
|
||
;; as an acronym and lowercases the B, producing labels "Ab" / "Ab 2"
|
||
;; / "Ab Off". The renderer's reverse lookup (label → tabler key)
|
||
;; then expects "IconAb" and misses the real "IconAB" export — so
|
||
;; the icon renders empty. Filter them out so they don't surface
|
||
;; as broken entries in search. Other consecutive-cap exports
|
||
;; (IconEPassport, IconSTurnDown) survive because the second cap
|
||
;; is followed by lowercase letters, which preserves the boundary.
|
||
(remove #{"Ab" "Ab 2" "Ab Off"}))]
|
||
(reset! *tabler-icons result)
|
||
result)))
|
||
|
||
(defn- search-tabler-icons
|
||
[q]
|
||
(->> (search/fuzzy-search (get-tabler-icons) q :limit 100)
|
||
(map (fn [icon-name]
|
||
{:type :icon
|
||
:id (str "icon-" icon-name)
|
||
:label icon-name
|
||
:data {:value icon-name}}))))
|
||
|
||
(defn- search-assets
|
||
"Fuzzy-match local assets by :block/title. `assets` is the per-picker
|
||
in-memory cache (loaded once on mount). No DB query happens here."
|
||
[q assets]
|
||
(when (and (string? q) (not (string/blank? q)) (seq assets))
|
||
(->> (search/fuzzy-search assets q :limit 50 :extract-fn :block/title)
|
||
(map (fn [asset]
|
||
(let [uuid (:block/uuid asset)
|
||
atype (:logseq.property.asset/type asset)]
|
||
{:type :image
|
||
:id (str "image-" uuid)
|
||
:label (or (:block/title asset) (str uuid))
|
||
:asset asset
|
||
:data {:asset-uuid (str uuid)
|
||
:asset-type atype}}))))))
|
||
|
||
(defn- search
|
||
[q tab assets opts]
|
||
(p/let [icons (when (not= tab :emoji) (search-tabler-icons q))
|
||
emojis' (when (not= tab :icon) (search-emojis q))
|
||
assets' (when (and (= tab :all)
|
||
(not (:no-assets? opts)))
|
||
(search-assets q assets))]
|
||
{:icons icons
|
||
:emojis emojis'
|
||
:assets assets'}))
|
||
|
||
(rum/defc icons-row
|
||
[items]
|
||
[:div.its.icons-row items])
|
||
|
||
(rum/defc icon-cp < rum/static
|
||
[icon-item {:keys [on-chosen hover on-tile-hover! highlighted-id ghost-highlighted-id wave]}]
|
||
(let [icon-id (get-in icon-item [:data :value])
|
||
icon-name (or (:label icon-item) icon-id)
|
||
color (get-in icon-item [:data :color])
|
||
icon-id' (when icon-id (cond-> icon-id (string? icon-id) (string/replace " " "")))
|
||
my-id (:id icon-item)
|
||
item-shape (cond-> {:type :tabler-icon
|
||
:id icon-id'
|
||
:value icon-id'}
|
||
color (assoc :color color))]
|
||
[:button.w-9.h-9.transition-opacity
|
||
(when icon-id'
|
||
{:key icon-id'
|
||
:tabIndex "-1"
|
||
:data-item-id my-id
|
||
:class (cond
|
||
(= my-id highlighted-id) "is-highlighted"
|
||
(= my-id ghost-highlighted-id) "is-ghost-highlighted")
|
||
:style (when wave {"--r" (:r wave) "--c" (:c wave)})
|
||
:title icon-name
|
||
:on-click (fn [e] (on-chosen e item-shape))
|
||
:on-mouse-over (fn []
|
||
(some-> hover (reset! item-shape))
|
||
(some-> on-tile-hover! (apply [item-shape])))
|
||
:on-mouse-out #()})
|
||
(when icon-id'
|
||
(ui/icon icon-id' {:size 24}))]))
|
||
|
||
(rum/defc emoji-cp < rum/static
|
||
[icon-item {:keys [on-chosen hover on-tile-hover! highlighted-id ghost-highlighted-id wave]}]
|
||
(let [emoji-id (get-in icon-item [:data :value])
|
||
emoji-name (or (:label icon-item) emoji-id)
|
||
my-id (:id icon-item)
|
||
item-shape {:type :emoji :id emoji-id :name emoji-name}]
|
||
[:button.text-2xl.w-9.h-9.transition-opacity
|
||
{:tabIndex "-1"
|
||
:data-item-id my-id
|
||
:class (cond
|
||
(= my-id highlighted-id) "is-highlighted"
|
||
(= my-id ghost-highlighted-id) "is-ghost-highlighted")
|
||
:style (when wave {"--r" (:r wave) "--c" (:c wave)})
|
||
:title emoji-name
|
||
:on-click (fn [e] (on-chosen e item-shape))
|
||
:on-mouse-over (fn []
|
||
(some-> hover (reset! item-shape))
|
||
(some-> on-tile-hover! (apply [item-shape])))
|
||
:on-mouse-out #()}
|
||
[:em-emoji {:id emoji-id
|
||
:style {:line-height 1}}]]))
|
||
|
||
(rum/defc text-cp < rum/static
|
||
[icon-item {:keys [on-chosen hover on-tile-hover! highlighted-id ghost-highlighted-id wave]}]
|
||
(let [text-value (get-in icon-item [:data :value])
|
||
text-color (get-in icon-item [:data :color])
|
||
my-id (:id icon-item)
|
||
display-text (if (> (count text-value) 8)
|
||
(subs text-value 0 8)
|
||
text-value)
|
||
item-shape {:type :text
|
||
:data (cond-> {:value text-value}
|
||
text-color (assoc :color text-color))}]
|
||
[:button.w-9.h-9.transition-opacity.text-sm.font-medium
|
||
{:tabIndex "-1"
|
||
:data-item-id my-id
|
||
:class (cond
|
||
(= my-id highlighted-id) "is-highlighted"
|
||
(= my-id ghost-highlighted-id) "is-ghost-highlighted")
|
||
:style (when wave {"--r" (:r wave) "--c" (:c wave)})
|
||
:title text-value
|
||
:on-click (fn [e] (on-chosen e item-shape))
|
||
:on-mouse-over (fn []
|
||
(some-> hover (reset! item-shape))
|
||
(some-> on-tile-hover! (apply [item-shape])))
|
||
:on-mouse-out #()}
|
||
display-text]))
|
||
|
||
(rum/defc avatar-cp < rum/static
|
||
[icon-item {:keys [on-chosen hover on-tile-hover! highlighted-id ghost-highlighted-id wave]}]
|
||
(let [avatar-value (get-in icon-item [:data :value])
|
||
backgroundColor (or (get-in icon-item [:data :backgroundColor])
|
||
(colors/variable :gray :09))
|
||
color (or (get-in icon-item [:data :color])
|
||
(colors/variable :gray :09))
|
||
my-id (:id icon-item)
|
||
display-text (subs avatar-value 0 (min 3 (count avatar-value)))
|
||
item-shape {:type :avatar
|
||
:data {:value avatar-value
|
||
:backgroundColor backgroundColor
|
||
:color color}}]
|
||
[:button.w-9.h-9.transition-opacity.flex.items-center.justify-center
|
||
{:tabIndex "-1"
|
||
:data-item-id my-id
|
||
:title avatar-value
|
||
:style (when wave {"--r" (:r wave) "--c" (:c wave)})
|
||
:class (str "p-0 border-0 bg-transparent cursor-pointer"
|
||
(cond
|
||
(= my-id highlighted-id) " is-highlighted"
|
||
(= my-id ghost-highlighted-id) " is-ghost-highlighted"))
|
||
:on-click (fn [e] (on-chosen e item-shape))
|
||
:on-mouse-over (fn []
|
||
(some-> hover (reset! item-shape))
|
||
(some-> on-tile-hover! (apply [item-shape])))
|
||
:on-mouse-out #()}
|
||
(shui/avatar
|
||
{:class "w-7 h-7"}
|
||
(shui/avatar-fallback
|
||
{:style (avatar-fallback-style {:font-size "12px"
|
||
:bg backgroundColor
|
||
:color color})}
|
||
display-text))]))
|
||
|
||
(rum/defc image-cp < rum/static
|
||
"Compact image-asset tile for the flex-wrap row layout (recently-used).
|
||
Search-results assets render via a dedicated 5-col grid using
|
||
`image-asset-item` directly — that path bypasses `render-item`."
|
||
[icon-item {:keys [on-chosen hover on-tile-hover! highlighted-id ghost-highlighted-id wave]}]
|
||
(let [asset-uuid (get-in icon-item [:data :asset-uuid])
|
||
asset-type (get-in icon-item [:data :asset-type])
|
||
my-id (:id icon-item)
|
||
item-shape (select-keys icon-item [:type :id :label :data])]
|
||
(when (and (string? asset-uuid) (not (string/blank? asset-uuid)))
|
||
[:button.w-9.h-9.transition-opacity.overflow-hidden.rounded
|
||
{:tabIndex "-1"
|
||
:data-item-id my-id
|
||
:class (cond
|
||
(= my-id highlighted-id) "is-highlighted"
|
||
(= my-id ghost-highlighted-id) "is-ghost-highlighted")
|
||
:style (when wave {"--r" (:r wave) "--c" (:c wave)})
|
||
:title (:label icon-item)
|
||
:on-click (fn [e] (on-chosen e item-shape))
|
||
:on-mouse-over (fn []
|
||
(some-> hover (reset! item-shape))
|
||
(some-> on-tile-hover! (apply [item-shape])))
|
||
:on-mouse-out #()}
|
||
(image-icon-cp asset-uuid asset-type {:size 32})])))
|
||
|
||
(defn render-item
|
||
"Render an icon-item based on its type"
|
||
[icon-item opts]
|
||
(case (:type icon-item)
|
||
:emoji (emoji-cp icon-item opts)
|
||
:icon (icon-cp icon-item opts)
|
||
:text (text-cp icon-item opts)
|
||
:avatar (avatar-cp icon-item opts)
|
||
:image (image-cp icon-item opts)
|
||
nil))
|
||
|
||
(defn item-render
|
||
[item opts]
|
||
(if (map? item)
|
||
(render-item item opts)
|
||
;; Legacy support: handle raw strings/old formats
|
||
(let [normalized (normalize-icon item)]
|
||
(if normalized
|
||
(render-item normalized opts)
|
||
nil))))
|
||
|
||
;; Shared state for section expansion (persists during session)
|
||
(defonce *section-states (atom {}))
|
||
|
||
(rum/defc section-header
|
||
[{:keys [title count total-count expanded? keyboard-hint on-toggle focus-region simple?]}]
|
||
[:div.section-header.text-xs.py-1.5.px-3.flex.justify-between.items-center.gap-2.bg-gray-02.h-8
|
||
{:style {:color "var(--lx-gray-11, var(--ls-primary-text-color, var(--rx-gray-11)))"}}
|
||
;; Left: Title · total-count · Chevron (chevron and count hidden in simple mode)
|
||
[:div.flex.items-center.gap-1.select-none
|
||
(when-not simple? {:class "cursor-pointer"
|
||
:on-click on-toggle})
|
||
[:span.font-bold title]
|
||
(when (or total-count count)
|
||
[:<>
|
||
[:span "·"]
|
||
[:span {:style {:font-size "0.7rem"}}
|
||
(or total-count count)]])
|
||
(when-not simple?
|
||
(ui/icon (if expanded? "chevron-down" "chevron-right") {:size 14}))]
|
||
|
||
[:div.flex-1] ; Spacer
|
||
|
||
;; Right: Hide/Show with keyboard shortcut (visible when navigating grid or tabs, hidden when typing in search)
|
||
(when keyboard-hint
|
||
(let [show-hint? (contains? #{:grid :tabs} focus-region)]
|
||
[:div.flex.gap-1.items-center.text-xs.opacity-50.transition-all.duration-200
|
||
{:class (when-not show-hint? "!opacity-0")
|
||
:style {:pointer-events (if show-hint? "auto" "none")}}
|
||
(if expanded? (t :icon.section-header/hide) (t :icon.section-header/show))
|
||
(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? focus-region show-header? *virtuoso-ref header-cp]
|
||
:or {virtual-list? true collapsible? false expanded? true show-header? true}
|
||
:as opts}]
|
||
(let [*el-ref (rum/use-ref nil)
|
||
render-fn (or render-item-fn render-item)
|
||
toggle-fn (when collapsible?
|
||
#(swap! *section-states update label (fn [v] (if (nil? v) false (not v)))))]
|
||
[:div.pane-section
|
||
{:ref *el-ref
|
||
:class (util/classnames
|
||
[{:has-virtual-list virtual-list?
|
||
:searching-result searching?}])}
|
||
;; Section header: collapsible with chevron + shortcut, or simple label-only
|
||
(when show-header?
|
||
(section-header {:title label
|
||
:count (count icon-items)
|
||
:total-count total-count
|
||
:expanded? expanded?
|
||
:keyboard-hint keyboard-hint
|
||
:on-toggle toggle-fn
|
||
:focus-region focus-region
|
||
:simple? (not collapsible?)}))
|
||
|
||
;; Content - only render if expanded or not collapsible
|
||
(when (or (not collapsible?) expanded?)
|
||
(if virtual-list?
|
||
(let [total (count icon-items)
|
||
step icon-grid-cols
|
||
rows (quot total step)
|
||
mods (mod total step)
|
||
rows (if (zero? mods) rows (inc rows))
|
||
items (vec icon-items)]
|
||
(ui/virtualized-list
|
||
(cond-> {:total-count rows
|
||
:ref (fn [^js el]
|
||
(when *virtuoso-ref
|
||
(reset! *virtuoso-ref el)))
|
||
;; Single-scroller layout: Virtuoso delegates
|
||
;; scrolling to the nearest `.bd-scroll` ancestor
|
||
;; instead of creating its own internal scroller.
|
||
;; This keeps `.bd` as the only scroll surface
|
||
;; across every picker mode (All / Emojis / Icons /
|
||
;; reaction / search), reclaiming the ~6px the
|
||
;; inner Virtuoso scrollbar would otherwise eat so
|
||
;; the 9-column grid stays at 9. On first render
|
||
;; the ref isn't attached yet and this is `nil`;
|
||
;; Virtuoso falls back to internal scrolling for
|
||
;; one frame, then re-renders with the parent.
|
||
:custom-scroll-parent (some-> (rum/deref *el-ref) (.closest ".bd-scroll"))
|
||
:item-content (fn [idx]
|
||
(icons-row
|
||
(let [last? (= (dec rows) idx)
|
||
start (* idx step)
|
||
end (* (inc idx) (if (and last? (not (zero? mods))) mods step))
|
||
icons (try (subvec items start end)
|
||
(catch js/Error e
|
||
(log/error :icon/grid-subvec-failed
|
||
{:start start :end end :count (count items) :error e})
|
||
nil))]
|
||
(vec (map-indexed
|
||
(fn [c-idx item]
|
||
(render-fn item (assoc opts :wave {:r idx :c c-idx})))
|
||
icons)))))}
|
||
|
||
header-cp
|
||
(assoc :components #js {:Header header-cp}))))
|
||
[:div.its
|
||
(map-indexed
|
||
(fn [i item]
|
||
(render-fn item (assoc opts :wave {:r (quot i icon-grid-cols) :c (mod i icon-grid-cols)})))
|
||
icon-items)]))]))
|
||
|
||
(def reaction-picker-opts
|
||
"Standard opts for the minimal emoji-only reaction picker. Callers
|
||
`merge` their own `:on-chosen` (and any additional opts) onto this."
|
||
{:allowed-tabs [:emoji]
|
||
:hide-topbar? true
|
||
:show-used? true
|
||
:icon-value nil})
|
||
|
||
(declare get-used-items)
|
||
|
||
(rum/defc emojis-cp < rum/static
|
||
[emojis* {:keys [show-used?] :as opts}]
|
||
(let [used-emojis (when show-used?
|
||
(->> (get-used-items)
|
||
(filterv #(= :emoji (:type %)))))
|
||
has-recents? (seq used-emojis)
|
||
icon-items (map (fn [emoji]
|
||
{:type :emoji
|
||
:id (:id emoji)
|
||
:label (or (:name emoji) (:id emoji))
|
||
:data {:value (:id emoji)}})
|
||
emojis*)]
|
||
;; Recents render as a sibling pane-section above the full grid.
|
||
;; Single scroll surface (.bd) means a sibling no longer triggers
|
||
;; a second scrollbar — same compositional pattern as `all-pane`.
|
||
;; The Emojis header doubles as the visual divider between the two
|
||
;; sections; suppress it when there are no recents (full picker
|
||
;; Emojis tab) to keep the picker minimal there.
|
||
[:<>
|
||
(when has-recents?
|
||
(pane-section "Recently used" used-emojis
|
||
(assoc opts :virtual-list? false)))
|
||
(pane-section "Emojis" icon-items
|
||
(assoc opts :show-header? has-recents?))]))
|
||
|
||
(rum/defc icons-cp < rum/static
|
||
[icons opts]
|
||
(let [icon-items (map (fn [icon-name]
|
||
{:type :icon
|
||
:id (str "icon-" icon-name)
|
||
:label icon-name
|
||
:data {:value icon-name}})
|
||
icons)]
|
||
(pane-section "Icons" icon-items (assoc opts :show-header? false))))
|
||
|
||
;; ============================================================================
|
||
;; Recently Used Assets
|
||
;; ============================================================================
|
||
|
||
(defn get-used-assets
|
||
"Get list of recently used asset UUIDs from storage"
|
||
[]
|
||
(or (storage/get :ui/ls-assets-used) []))
|
||
|
||
(defn add-used-asset!
|
||
"Add an asset UUID to the recently used list (max 10 items)"
|
||
[asset-uuid]
|
||
(when asset-uuid
|
||
(let [uuid-str (str asset-uuid)
|
||
current (get-used-assets)
|
||
;; Remove if already exists, then add to front
|
||
filtered (remove #(= % uuid-str) current)
|
||
updated (take 10 (cons uuid-str filtered))]
|
||
(storage/set :ui/ls-assets-used updated))))
|
||
|
||
;; ============================================================================
|
||
;; Recently Used Icons
|
||
;; ============================================================================
|
||
|
||
(defn get-used-items
|
||
[]
|
||
(let [v2-items (storage/get :ui/ls-icons-used-v2)
|
||
items (if (seq v2-items)
|
||
v2-items
|
||
;; Migrate from legacy format
|
||
(let [legacy-items (storage/get :ui/ls-icons-used)]
|
||
(if (seq legacy-items)
|
||
(let [normalized (map normalize-icon legacy-items)]
|
||
(storage/set :ui/ls-icons-used-v2 normalized)
|
||
normalized)
|
||
[])))]
|
||
;; Drop entries that no longer resolve (e.g. residue from the phantom
|
||
;; tabler-icons-react utility exports that used to appear in search).
|
||
(filter renderable-icon? items)))
|
||
|
||
(defn add-used-item!
|
||
[m]
|
||
(let [normalized (normalize-icon m)
|
||
new-type (:type normalized)
|
||
;; For text and avatar icons, remove all previous instances of that type
|
||
;; For other icons, only remove exact duplicates
|
||
should-keep? (fn [item]
|
||
(if (#{:text :avatar} new-type)
|
||
;; Remove any existing text/avatar icons
|
||
(not= (:type item) new-type)
|
||
;; Remove exact duplicates for other types
|
||
(not= normalized item)))
|
||
;; Filter dupes across the WHOLE existing list, not just the first 24
|
||
;; — otherwise an existing duplicate beyond position 24 stays in storage
|
||
;; on the next call. Then cons the new pick and cap at 24.
|
||
s (some->> (or (get-used-items) [])
|
||
(filter should-keep?)
|
||
(cons normalized)
|
||
(take 24))]
|
||
(storage/set :ui/ls-icons-used-v2 s)))
|
||
|
||
(defn derive-initials
|
||
"Derive initials from a page title (max 8 chars)"
|
||
[title]
|
||
(when title
|
||
(let [words (string/split (string/trim title) #"\s+")
|
||
initials (if (> (count words) 1)
|
||
;; Take first letter of first two words
|
||
(str (subs (first words) 0 1)
|
||
(subs (second words) 0 1))
|
||
;; Single word: take first 2 chars
|
||
(subs (first words) 0 (min 2 (count (first words)))))]
|
||
(subs initials 0 (min 8 (count initials))))))
|
||
|
||
(defn derive-avatar-initials
|
||
"Derive initials from a page title (max 2-3 chars for avatars, always uppercase)"
|
||
[title]
|
||
(when title
|
||
(let [words (string/split (string/trim title) #"\s+")
|
||
initials (if (> (count words) 1)
|
||
;; Take first letter of first two words
|
||
(str (string/upper-case (subs (first words) 0 1))
|
||
(string/upper-case (subs (second words) 0 1)))
|
||
;; Single word: take first 2 chars and uppercase them
|
||
(let [word (first words)
|
||
char-count (min 2 (count word))]
|
||
(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
|
||
text-value (if (string/blank? query)
|
||
;; Use page-title or fallback to current page
|
||
(let [title (or page-title
|
||
(some-> (state/get-current-page)
|
||
(db/get-page)
|
||
(:block/title)))]
|
||
(derive-initials title))
|
||
;; Use query (max 8 chars)
|
||
(subs query 0 (min 8 (count query))))
|
||
;; Include selected color if available
|
||
selected-color (when-not (string/blank? @*color) @*color)
|
||
icon-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))})]
|
||
(if icon-item
|
||
(pane-section "Text" [icon-item] (assoc opts :virtual-list? false))
|
||
[:div.pane-section.px-2.py-4
|
||
[:div.text-sm.text-gray-07.dark:opacity-80
|
||
(t :icon.text-tab/empty-prompt)]])))
|
||
|
||
(rum/defc avatar-tab-cp
|
||
[*q page-title *color opts]
|
||
(let [query @*q
|
||
avatar-value (if (string/blank? query)
|
||
;; Use page-title or fallback to current page
|
||
(let [title (or page-title
|
||
(some-> (state/get-current-page)
|
||
(db/get-page)
|
||
(:block/title)))]
|
||
(derive-avatar-initials title))
|
||
;; Use query (max 2-3 chars)
|
||
(subs query 0 (min 3 (count query))))
|
||
;; Use selected color if available, otherwise default to gray
|
||
selected-color (when-not (string/blank? @*color) @*color)
|
||
backgroundColor (or selected-color (colors/variable :gray :09))
|
||
color (or selected-color (colors/variable :gray :09))
|
||
icon-item (when avatar-value
|
||
{:type :avatar
|
||
:id (str "avatar-" avatar-value)
|
||
:label avatar-value
|
||
:data {:value avatar-value
|
||
:backgroundColor backgroundColor
|
||
:color color}})]
|
||
(if icon-item
|
||
(pane-section "Avatar" [icon-item] (assoc opts :virtual-list? false))
|
||
[:div.pane-section.px-2.py-4
|
||
[:div.text-sm.text-gray-07.dark:opacity-80
|
||
(t :icon.avatar-tab/empty-prompt)]])))
|
||
|
||
(rum/defc custom-tab-cp < rum/reactive
|
||
"Combined tab showing Text, Avatar, and Image options side by side"
|
||
[*q page-title *color *view *asset-picker-initial-mode 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))))
|
||
;; Live color preview during hover/keyboard nav over color
|
||
;; swatches in the picker's color popover. Without this, the
|
||
;; page-title icon updates live (it reads `:ui/icon-hover-preview`
|
||
;; via `get-node-icon-cp`) while the Text/Avatar tiles in the
|
||
;; Custom tab stay locked to the committed color — a confusing
|
||
;; mismatch the user can hit mid-pick. Same scope-match logic
|
||
;; the asset-picker tile uses (db-id + property) so a Default
|
||
;; Icon picker doesn't bleed previews into a sibling page-title
|
||
;; picker on the same entity.
|
||
preview-target-db-id (:preview-target-db-id opts)
|
||
preview-target-db-ids (:preview-target-db-ids opts)
|
||
scope-property (or (:property opts) :logseq.property/icon)
|
||
hover-preview-state (state/sub :ui/icon-hover-preview)
|
||
hover-color-match? (and hover-preview-state
|
||
(icon-preview-matches? hover-preview-state preview-target-db-id scope-property))
|
||
hover-color (when hover-color-match?
|
||
(let [c (:color hover-preview-state)]
|
||
(when (and c (not (string/blank? c)) (not= c "inherit"))
|
||
c)))
|
||
committed-color (when-not (string/blank? @*color) @*color)
|
||
;; Hover wins over committed so the tiles tint live; on
|
||
;; mouse-leave the hover-preview clears and the committed
|
||
;; color re-emerges.
|
||
selected-color (or hover-color committed-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}})
|
||
on-chosen (:on-chosen opts)
|
||
highlighted-id (:highlighted-id opts)
|
||
on-tile-hover! (:on-tile-hover! opts)
|
||
;; In default-icon mode (used by tag class default-icon row), Text and
|
||
;; Image tiles commit immediately rather than drilling into sub-pickers.
|
||
;; Avatar still opens the asset-picker since avatars need an image.
|
||
default-icon? (:default-icon? opts)
|
||
;; Mouse-hover preview broadcast: pass the synthesized preview item
|
||
;; the page-icon should render for each button. Keyboard hover
|
||
;; broadcasts `:custom-*` markers and relies on icon-search's
|
||
;; translation; here we pass the resolved item directly.
|
||
image-placeholder-item {:type :image-placeholder :id "image-placeholder"}]
|
||
[:div.custom-tab-content
|
||
;; Text option
|
||
(when text-item
|
||
[:button.custom-tab-item
|
||
{:data-item-id "custom-text"
|
||
:tabIndex "-1"
|
||
:class (when (= "custom-text" highlighted-id) "is-highlighted")
|
||
:on-click (if default-icon?
|
||
#(when on-chosen (on-chosen % text-item))
|
||
#(reset! *view :text-picker))
|
||
:on-mouse-over (fn [] (some-> on-tile-hover! (apply [text-item])))}
|
||
[:div.custom-tab-item-preview {:aria-hidden "true"}
|
||
;; `:color? true` wraps the SVG in `.ls-icon-color-wrap` so the
|
||
;; text-tile preview picks up the user's selected color via
|
||
;; `currentColor` (avatar and image previews use other paths,
|
||
;; but text icons rely on this wrapper to colorize their fill).
|
||
(icon text-item {:size 32 :color? true})]
|
||
[:span.custom-tab-item-label (t :icon.mode/text)]])
|
||
|
||
;; Avatar option. In page-icon context: commits the synthesized initials
|
||
;; avatar immediately and lands on the asset-picker's Avatar tab so the
|
||
;; user can pick a face image. In default-icon (class) context: commits
|
||
;; only the type-without-image; each instance auto-derives its own
|
||
;; initials from its own title via get-node-icon, so binding a specific
|
||
;; face would be the wrong shape for the class default.
|
||
(when avatar-item
|
||
[:button.custom-tab-item
|
||
{:data-item-id "custom-avatar"
|
||
:tabIndex "-1"
|
||
:class (when (= "custom-avatar" highlighted-id) "is-highlighted")
|
||
:on-click (if default-icon?
|
||
#(when on-chosen (on-chosen % avatar-item))
|
||
(fn [e]
|
||
(when on-chosen (on-chosen e avatar-item true))
|
||
(reset! *asset-picker-initial-mode :avatar)
|
||
(reset! *view :asset-picker)))
|
||
:on-mouse-over (fn [] (some-> on-tile-hover! (apply [avatar-item])))}
|
||
[:div.custom-tab-item-preview {:aria-hidden "true"}
|
||
(icon avatar-item {:size 32})]
|
||
[:span.custom-tab-item-label (t :icon.mode/avatar)]])
|
||
|
||
;; Image option — commits the placeholder icon immediately so the page
|
||
;; icon stays as the plus+dashed placeholder while the asset-picker
|
||
;; opens. Picking an image inside the asset-picker replaces the
|
||
;; placeholder; backing out keeps it.
|
||
[:button.custom-tab-item
|
||
{:data-item-id "custom-image"
|
||
:tabIndex "-1"
|
||
:class (when (= "custom-image" highlighted-id) "is-highlighted")
|
||
:on-click (if default-icon?
|
||
;; Default-icon context: commit placeholder and close.
|
||
;; Per-instance images are auto-derived elsewhere.
|
||
(fn [e] (when on-chosen (on-chosen e image-placeholder-item)))
|
||
(fn [e]
|
||
(when on-chosen (on-chosen e image-placeholder-item true))
|
||
(reset! *asset-picker-initial-mode :image)
|
||
(reset! *view :asset-picker)))
|
||
:on-mouse-over (fn [] (some-> on-tile-hover! (apply [image-placeholder-item])))}
|
||
[:div.custom-tab-item-preview {:aria-hidden "true"}
|
||
[:span.image-tile-placeholder
|
||
{:style {:width 32
|
||
:height 32
|
||
;; Themed dashed border via `--ls-border-color` middle
|
||
;; step (matches the ghost-highlight outline pattern).
|
||
:border "1px dashed var(--lx-gray-08, var(--ls-border-color, var(--rx-gray-08)))"
|
||
:border-radius "3px"
|
||
:display "flex"
|
||
:align-items "center"
|
||
:justify-content "center"
|
||
:background "var(--rx-gray-03-alpha)"}}
|
||
(shui/tabler-icon "photo" {:size 20 :style {:color "var(--lx-gray-11, var(--ls-primary-text-color, var(--rx-gray-11)))"}})]]
|
||
[:span.custom-tab-item-label (t :icon.mode/image)]]]))
|
||
|
||
;; <load-asset-url! is defined near the top of the file (unified loader with retry + extension guessing)
|
||
|
||
(declare web-image-card-content)
|
||
|
||
(defn- asset->preview-data
|
||
"Normalize an asset block + its resolved blob URL into the shape that
|
||
`web-image-card-content` consumes (the same hover-preview component
|
||
used by the web-image search results). Returns nil when no URL is
|
||
resolved yet — the preview body needs an image to render meaningfully.
|
||
|
||
`:source` is derived from `source-name` so the existing case in the
|
||
preview body still routes Wikipedia/Wikimedia Commons to their named
|
||
labels. For other sources we let `source-name` drive the display
|
||
directly (the preview component prefers explicit `source-name` over
|
||
the keyword)."
|
||
[asset url]
|
||
(when url
|
||
(let [source-name (:logseq.property.asset/source-name asset)
|
||
source-url (:logseq.property.asset/source-url asset)
|
||
license (:logseq.property.asset/license asset)
|
||
source-kw (cond
|
||
(not source-name) nil
|
||
(re-find #"(?i)wikimedia\s+commons" source-name) :wikipedia-commons
|
||
(re-find #"(?i)wikipedia" source-name) :wikipedia
|
||
:else :other)]
|
||
{:url url
|
||
:thumb-url url
|
||
:title (:block/title asset)
|
||
:source source-kw
|
||
:source-name source-name
|
||
:source-url source-url
|
||
:license license
|
||
:license-desc (license->description license)})))
|
||
|
||
(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)
|
||
(<load-asset-url! *url *error asset-uuid asset-type {})))
|
||
state)}
|
||
"Renders a single image asset thumbnail in the asset picker grid.
|
||
When avatar-context is provided, renders circular previews and returns avatar data.
|
||
Returns nil if asset file doesn't exist (ghost asset).
|
||
|
||
The button is wrapped in a `shui/tooltip` that surfaces the same
|
||
hover-preview card the web-image lane uses — bigger thumbnail, plus
|
||
`From:` and license badge when the asset has source / license metadata
|
||
(web-downloaded assets do; locally-uploaded assets gracefully degrade
|
||
to image + title only). Preview only renders once the blob URL has
|
||
resolved; ghost assets (load errors) skip it."
|
||
[state asset {:keys [on-chosen avatar-context selected? item-id highlighted? ghost-highlighted? variant]}]
|
||
(let [url @(::url state)
|
||
error? @(::error state)
|
||
asset-type (:logseq.property.asset/type asset)
|
||
asset-uuid (:block/uuid asset)
|
||
asset-title (or (:block/title asset) (str asset-uuid))
|
||
avatar-mode? (some? avatar-context)
|
||
search-variant? (= variant :search)
|
||
button-hiccup
|
||
[:button.image-asset-item
|
||
{:title asset-title
|
||
:data-item-id item-id
|
||
:class (util/classnames [{:avatar-mode avatar-mode?
|
||
:search-variant search-variant?
|
||
:selected selected?
|
||
;; Ghost icon is hidden in :search to avoid an ugly
|
||
;; refresh-affordance in a short-lived results grid.
|
||
;; The 3 silent retries still run below the surface;
|
||
;; users see broken tiles as empty slots and skip them.
|
||
:ghost-asset (and error? (not search-variant?))
|
||
:is-highlighted highlighted?
|
||
:is-ghost-highlighted ghost-highlighted?}])
|
||
:on-click (fn [e]
|
||
(if (and error? (not search-variant?))
|
||
;; Click-to-retry on ghost assets (only in the asset-picker
|
||
;; surface; search variant doesn't surface the affordance).
|
||
(do (reset! (::error state) false)
|
||
(<load-asset-url! (::url state) (::error state) asset-uuid asset-type {}))
|
||
(do
|
||
;; Track as recently used
|
||
(add-used-asset! asset-uuid)
|
||
(let [image-data {:asset-uuid (str asset-uuid)
|
||
:asset-type asset-type}]
|
||
(on-chosen e
|
||
(if avatar-context
|
||
;; Merge image into existing avatar
|
||
{:type :avatar
|
||
:id (:id avatar-context)
|
||
:label (:label avatar-context)
|
||
:data (merge (:data avatar-context) image-data)}
|
||
;; Standard image selection
|
||
{:type :image
|
||
:id (str "image-" asset-uuid)
|
||
:label asset-title
|
||
:data image-data}))))))
|
||
:disabled false}
|
||
(cond
|
||
(and error? (not search-variant?))
|
||
[:div.ghost-asset-placeholder
|
||
{:title (t :icon.asset/retry-load-tooltip)}
|
||
(ui/icon "refresh" {:size 16})]
|
||
url
|
||
[:img {:src url
|
||
:loading "lazy"
|
||
:on-error (fn [_e]
|
||
;; Blob URL became invalid — mark as error so user can retry
|
||
(reset! (::url state) nil)
|
||
(reset! (::error state) true))}]
|
||
:else
|
||
[:div.bg-gray-04 (when-not search-variant? {:class "animate-pulse"})])]]
|
||
(if-let [preview (and (not error?) (asset->preview-data asset url))]
|
||
;; Tooltip delay: 200ms in search (fast intent), 400ms in picker (browse intent).
|
||
(shui/tooltip-provider
|
||
{:delay-duration (if search-variant? 200 400) :skip-delay-duration 100}
|
||
(shui/tooltip
|
||
(shui/tooltip-trigger
|
||
{:as-child true}
|
||
button-hiccup)
|
||
(shui/tooltip-content
|
||
{:side "top" :align "center" :class "web-image-card-popup"
|
||
:side-offset 8 :collision-padding 8}
|
||
(web-image-card-content preview))))
|
||
button-hiccup)))
|
||
|
||
(defn- should-use-blur-bg?
|
||
"Determine if blurred background should be used based on image format.
|
||
SVGs and potentially transparent formats should not use blur."
|
||
[url]
|
||
(let [url-lower (when url (string/lower-case url))
|
||
;; SVG detection
|
||
is-svg? (and url-lower (string/ends-with? url-lower ".svg"))
|
||
;; Formats that commonly have transparency (conservative approach)
|
||
likely-transparent? (or (and url-lower (string/ends-with? url-lower ".png"))
|
||
(and url-lower (string/ends-with? url-lower ".gif"))
|
||
(and url-lower (string/ends-with? url-lower ".webp")))]
|
||
;; Use blur only for opaque formats (JPEG, BMP)
|
||
;; Skip for SVG (uses PNG thumbnail anyway) and potentially transparent formats
|
||
(not (or is-svg? likely-transparent?))))
|
||
|
||
(rum/defc web-image-card-content
|
||
"Pure-render preview block: blurred-bg + sharp overlay + maximize button +
|
||
title + source · license + license badge. Used as the tooltip content for
|
||
web-image tiles AND for asset-block tiles in the recents / available
|
||
lanes (via `asset->preview-data`). No buttons, no checkbox — clicking
|
||
the tile commits.
|
||
|
||
Adapts to missing metadata: when `source` and `source-name` are nil
|
||
(e.g. a locally-uploaded asset), the source row is omitted entirely
|
||
rather than rendering a misleading 'From: Web' fallback. The license
|
||
badge already gates on `license-desc`. Result: local uploads render
|
||
image + title only, fitting the card without empty bands."
|
||
[{:keys [url thumb-url title license license-desc source source-name]}]
|
||
(let [source-label (cond
|
||
source-name source-name
|
||
(= source :wikipedia) (t :icon.web-images/wikipedia)
|
||
(= source :wikipedia-commons) (t :icon.web-images/wikipedia-commons)
|
||
:else nil)
|
||
source-text (when source-label
|
||
(str "From: " source-label
|
||
(when license (str " · " license))))
|
||
display-url (or thumb-url url)
|
||
use-blur? (should-use-blur-bg? display-url)]
|
||
[:div.web-image-card
|
||
[:div.preview-image
|
||
(when use-blur?
|
||
[:img.blur-bg {:src display-url :alt ""}])
|
||
[:img.preview-img {:src display-url
|
||
:alt (if source-label
|
||
(t :icon.web-images/image-alt-from-source
|
||
(or title (t :icon.web-images/image-fallback-title))
|
||
source-label)
|
||
(or title (t :icon.web-images/image-fallback-title)))}]
|
||
[:button.maximize-btn
|
||
{:aria-label (t :icon.web-images/view-full-size)
|
||
:on-pointer-down (fn [e]
|
||
;; Stop the click bubbling to the underlying tile.
|
||
;; Without this, opening the lightbox would also
|
||
;; commit the image to the page-icon.
|
||
(.stopPropagation e))
|
||
:on-click (fn [e]
|
||
(.stopPropagation e)
|
||
;; The lightbox wrapper (extensions/lightbox.cljs)
|
||
;; installs a window-capture pointerdown/click swallow
|
||
;; for any target outside `.pswp` for its lifetime, so
|
||
;; the asset picker stays visually open behind the
|
||
;; lightbox but Radix's dismiss handler never runs
|
||
;; and stray clicks don't fall through to the page.
|
||
;; PhotoSwipe restores focus to this button on close.
|
||
;;
|
||
;; Pick the URL we feed to PhotoSwipe with care:
|
||
;; - For Wikimedia results, `:url` may be the original
|
||
;; source file (sometimes a PDF or DJVU for scanned
|
||
;; documents). `:thumb-url` is always a server-
|
||
;; rendered JPG. We upscale the thumb URL's
|
||
;; `NNNpx-` segment to a high-res value so the
|
||
;; lightbox loads a large JPG render rather than a
|
||
;; PDF that PhotoSwipe can't display. PDF and DJVU
|
||
;; thumbs use `/pageN-NNNpx-` so we match the size
|
||
;; token without anchoring to the slash.
|
||
;; Wikimedia's PdfHandler caps PDF/DJVU thumbs at
|
||
;; 1280px wide; requesting 1600 returns HTTP 400.
|
||
;; Clamp the target accordingly when we detect the
|
||
;; `/pageN-` prefix.
|
||
;; - For local blob URLs the regex doesn't match and
|
||
;; `(or url thumb-url)` flows through unchanged.
|
||
;; Then PhotoSwipe needs real dimensions (passing 0/0
|
||
;; stretches the image without a backdrop). Always
|
||
;; probe via `new Image()` after upscaling so the
|
||
;; declared dims match the actual image we serve. If
|
||
;; the upscaled URL fails — some Wikimedia files
|
||
;; don't have every size cached — fall back to the
|
||
;; original thumb URL, which we know loaded in the
|
||
;; hover preview. Lose resolution to gain a viewable
|
||
;; image, rather than showing the "cannot be loaded"
|
||
;; placeholder.
|
||
(let [base-thumb thumb-url
|
||
pdf-thumb? (and base-thumb
|
||
(re-find #"/page\d+-\d+px-" base-thumb))
|
||
target-px (if pdf-thumb? "1280px-" "1600px-")
|
||
upscaled (when (and base-thumb
|
||
(re-find #"\d+px-" base-thumb))
|
||
(string/replace base-thumb
|
||
#"\d+px-"
|
||
target-px))
|
||
src (or upscaled url thumb-url)
|
||
open! (fn [s w h]
|
||
(lightbox/preview-images!
|
||
[{:src s :w w :h h}]))]
|
||
(let [^js probe (js/Image.)]
|
||
(set! (.-onload probe)
|
||
(fn [_] (open! src
|
||
(.-naturalWidth probe)
|
||
(.-naturalHeight probe))))
|
||
(set! (.-onerror probe)
|
||
(fn [_]
|
||
(if (and base-thumb (not= base-thumb src))
|
||
(let [^js retry (js/Image.)]
|
||
(set! (.-onload retry)
|
||
(fn [_] (open! base-thumb
|
||
(.-naturalWidth retry)
|
||
(.-naturalHeight retry))))
|
||
(set! (.-onerror retry)
|
||
(fn [_] (open! src 1600 1200)))
|
||
(set! (.-src retry) base-thumb))
|
||
(open! src 1600 1200))))
|
||
(set! (.-src probe) src))))}
|
||
(shui/tabler-icon "arrows-maximize" {:size 16})]]
|
||
[:div.content-wrapper
|
||
[:div.image-info
|
||
[:div.image-title (or title (t :icon.web-images/untitled))]
|
||
(when source-text
|
||
[:div.image-source {:style {:color "var(--lx-gray-11, var(--ls-primary-text-color, var(--rx-gray-11)))"}} source-text])
|
||
(when license-desc
|
||
[:div.license-badge license-desc])]]]))
|
||
|
||
(rum/defc web-image-item
|
||
"Renders a single web image thumbnail with external indicator and rich
|
||
hover card. Hover (or keyboard focus) reveals title + source + license
|
||
+ larger preview + maximize affordance. Click commits."
|
||
[{:keys [url thumb-url title license license-desc source] :as web-image}
|
||
{:keys [on-click avatar-mode? item-id highlighted? ghost-highlighted? saved?]}]
|
||
(let [display-url (or thumb-url url)
|
||
;; Carry full info through the trigger button's aria-label so screen-
|
||
;; reader users get title + license + source without entering the card.
|
||
;; When the asset is already in the user's library, lead the label with
|
||
;; "Saved:" so AT users get the same signal the green badge gives
|
||
;; sighted users.
|
||
aria (str (if saved? "Saved: " "Add image: ")
|
||
(or title "Web image")
|
||
(when license (str ", " license))
|
||
(when license-desc
|
||
(when-not license (str ", " license-desc)))
|
||
", from "
|
||
(case source
|
||
:wikipedia "Wikipedia"
|
||
:wikipedia-commons "Wikimedia Commons"
|
||
"the web"))]
|
||
(shui/tooltip-provider
|
||
;; 400ms first-hover delay matches NN/g rich-tooltip guidance and the
|
||
;; existing color-picker hover timings; 100ms on subsequent hovers gives
|
||
;; instant swap when arrowing/hovering between sibling tiles.
|
||
{:delay-duration 400 :skip-delay-duration 100}
|
||
(shui/tooltip
|
||
(shui/tooltip-trigger
|
||
{:as-child true}
|
||
[:button.web-image-item
|
||
{:data-item-id item-id
|
||
:aria-label aria
|
||
:class (util/classnames [{:avatar-mode avatar-mode?
|
||
:is-highlighted highlighted?
|
||
:is-ghost-highlighted ghost-highlighted?}])
|
||
:on-click (fn [e] (on-click e web-image))}
|
||
(if display-url
|
||
[:img {:src display-url :loading "lazy" :alt ""}]
|
||
[:div.bg-gray-04.animate-pulse])
|
||
;; Corner badge. Default = globe (web provenance). When this image is
|
||
;; already saved as a local asset (matched by source-url), swap to a
|
||
;; green check so the user sees at a glance which results they already
|
||
;; have. Click semantics on the parent route saved tiles to the
|
||
;; existing asset instead of re-downloading.
|
||
(if saved?
|
||
[:div.saved-badge
|
||
(shui/tabler-icon "check" {:size 10})]
|
||
[:div.external-badge
|
||
(shui/tabler-icon "world" {:size 10})])
|
||
;; Touch-only license byline. CSS toggles via @media (hover: none) so
|
||
;; touch users see attribution without a hover affordance.
|
||
(when (or license license-desc)
|
||
[:div.touch-byline
|
||
(str (case source
|
||
:wikipedia-commons (t :icon.web-images/commons-short)
|
||
:wikipedia (t :icon.web-images/wikipedia)
|
||
(t :icon.web-images/web))
|
||
(when license (str " · " license)))])])
|
||
(shui/tooltip-content
|
||
{:side "top" :align "center" :class "web-image-card-popup"
|
||
:side-offset 8 :collision-padding 8}
|
||
(web-image-card-content web-image))))))
|
||
|
||
(rum/defcs web-images-section < rum/reactive
|
||
(rum/local nil ::images)
|
||
(rum/local true ::loading?)
|
||
(rum/local nil ::current-query)
|
||
;; True when the latest fetch reported a network error (both Wikipedia and
|
||
;; Commons calls failed). Cleared when a fresh fetch begins.
|
||
(rum/local false ::search-error?)
|
||
;; Generation counter — responses whose id no longer matches are stale
|
||
;; (e.g. a "do" prefix response arriving after "donald trump" was issued)
|
||
;; and must not overwrite the current images.
|
||
(rum/local 0 ::request-id)
|
||
{:did-mount (fn [state]
|
||
(let [[{:keys [query *result-sink]}] (:rum/args state)
|
||
*images (::images state)
|
||
*loading? (::loading? state)
|
||
*current-query (::current-query state)
|
||
*request-id (::request-id state)
|
||
*search-error? (::search-error? state)
|
||
publish! (fn [results error?]
|
||
(reset! *images results)
|
||
(reset! *search-error? (boolean error?))
|
||
(when *result-sink (reset! *result-sink (vec results))))]
|
||
(when-not (string/blank? query)
|
||
(reset! *current-query query)
|
||
(reset! *loading? true)
|
||
(reset! *search-error? false)
|
||
(let [my-id (swap! *request-id inc)]
|
||
(-> (<search-web-images query)
|
||
(p/then (fn [{:keys [images network-error?]}]
|
||
(when (= my-id @*request-id)
|
||
(publish! images network-error?)
|
||
(reset! *loading? false))))
|
||
(p/catch (fn [_err]
|
||
(when (= my-id @*request-id)
|
||
(publish! [] true)
|
||
(reset! *loading? false))))))))
|
||
state)
|
||
:did-update (fn [state]
|
||
(let [[{:keys [query *result-sink]}] (:rum/args state)
|
||
*images (::images state)
|
||
*loading? (::loading? state)
|
||
*current-query (::current-query state)
|
||
*request-id (::request-id state)
|
||
*search-error? (::search-error? state)
|
||
current-query @*current-query
|
||
publish! (fn [results error?]
|
||
(reset! *images results)
|
||
(reset! *search-error? (boolean error?))
|
||
(when *result-sink (reset! *result-sink (vec results))))]
|
||
;; Only refetch if query changed
|
||
(when (and (not= query current-query)
|
||
(not (string/blank? query)))
|
||
(reset! *current-query query)
|
||
(reset! *loading? true)
|
||
(reset! *search-error? false)
|
||
(let [my-id (swap! *request-id inc)]
|
||
(-> (<search-web-images query)
|
||
(p/then (fn [{:keys [images network-error?]}]
|
||
(when (= my-id @*request-id)
|
||
(publish! images network-error?)
|
||
(reset! *loading? false))))
|
||
(p/catch (fn [_err]
|
||
(when (= my-id @*request-id)
|
||
(publish! [] true)
|
||
(reset! *loading? false))))))))
|
||
state)}
|
||
"Renders the web images section with loading states.
|
||
query: search query (page title or user input)
|
||
on-select: callback when user selects a web image
|
||
avatar-context: if set, picker is in avatar mode
|
||
*result-sink: optional atom to publish current results to (for parent
|
||
keyboard-nav)
|
||
highlighted-id: stable id of currently-highlighted tile (string), or nil
|
||
ghost-highlighted-id: stable id of the ghost-highlighted tile (hint that
|
||
Enter-from-search will pick this one), or nil"
|
||
[state {:keys [query on-select avatar-context user-typing?
|
||
highlighted-id ghost-highlighted-id saved-source-urls]}]
|
||
(let [*images (::images state)
|
||
*loading? (::loading? state)
|
||
*current-query (::current-query state)
|
||
*search-error? (::search-error? state)
|
||
images (rum/react *images)
|
||
;; `saved-source-urls` is a set of source URLs for assets the user has
|
||
;; already downloaded locally. We don't filter those tiles out — hiding
|
||
;; them on a 5-wide row collapses the layout and reads as a broken
|
||
;; search when the user has saved 3 of the top hits. Instead, the per-
|
||
;; tile `saved?` flag swaps the corner globe overlay for a green check
|
||
;; badge, and the click routes through the existing asset (no redownload).
|
||
loading? (rum/react *loading?)
|
||
current-query (rum/react *current-query)
|
||
search-error? (rum/react *search-error?)
|
||
;; `pending?` captures two transition states where skeletons should
|
||
;; show even though `loading?` hasn't flipped yet:
|
||
;; 1. `user-typing?` — user has typed but the 500ms debounce hasn't
|
||
;; caught up yet; no fetch has been issued.
|
||
;; 2. `(not= query current-query)` — parent passed a new query but
|
||
;; `:did-update` hasn't yet set `*loading? true`.
|
||
pending? (or user-typing?
|
||
(and (not (string/blank? query))
|
||
(not= query current-query)))
|
||
show-loading? (or loading? pending?)
|
||
avatar-mode? (some? avatar-context)
|
||
web-expanded? (get (rum/react *section-states) "Web images" true)]
|
||
;; Hide only when a settled fetch returned no results AND there was no
|
||
;; network error. During any transition we keep the section mounted and
|
||
;; show skeletons so the layout below doesn't jump. When the network
|
||
;; failed and we have nothing to show, we surface an inline error.
|
||
(when-not (and (not show-loading?) (not search-error?) (empty? images))
|
||
[:div.pane-section.web-images-section
|
||
;; Section header with info icon
|
||
[:div.section-header-row
|
||
(section-header {:title "Web images"
|
||
:count (when-not show-loading? (count images))
|
||
:expanded? web-expanded?
|
||
:on-toggle #(swap! *section-states update "Web images" (fn [v] (if (nil? v) false (not v))))})
|
||
(shui/tooltip-provider
|
||
{:delay-duration 200}
|
||
(shui/tooltip
|
||
(shui/tooltip-trigger
|
||
{:as-child true}
|
||
[:button.info-icon
|
||
(shui/tabler-icon "info-circle" {:size 14})])
|
||
(shui/tooltip-content
|
||
{:side "top" :show-arrow true}
|
||
[:div
|
||
[:div.text-sm.font-medium (t :icon.web-images/info-title)]
|
||
[:div.text-xs.opacity-70.mt-1 (t :icon.web-images/info-desc)]])))]
|
||
|
||
;; Image grid (or inline network-error message)
|
||
(when web-expanded?
|
||
(cond
|
||
(and search-error? (not show-loading?) (empty? images))
|
||
[:div.web-images-error
|
||
(shui/tabler-icon "wifi-off" {:size 14})
|
||
[:span (t :icon.web-images/network-error)]]
|
||
|
||
:else
|
||
[:div.asset-picker-grid.web-images-row
|
||
{:class (when avatar-mode? "avatar-mode")}
|
||
(if show-loading?
|
||
;; Loading skeletons — inherit avatar-mode so they render as circles
|
||
(for [i (range 5)]
|
||
[:div.web-image-placeholder
|
||
{:key (str "skeleton-" i)
|
||
:class (when avatar-mode? "avatar-mode")}
|
||
(shui/skeleton {:class "w-full h-full rounded"})])
|
||
;; Actual images
|
||
(for [web-image images
|
||
:let [web-id (str "web-" (:url web-image))
|
||
saved? (and (seq saved-source-urls)
|
||
(contains? saved-source-urls (:source-url web-image)))]]
|
||
(rum/with-key
|
||
(web-image-item
|
||
web-image
|
||
{:item-id web-id
|
||
:highlighted? (= highlighted-id web-id)
|
||
:ghost-highlighted? (= ghost-highlighted-id web-id)
|
||
;; True when this tile's source-url matches a locally saved
|
||
;; asset. Drives the green "saved" badge (vs the default
|
||
;; globe) and lets the parent's on-select route the click
|
||
;; to the existing asset instead of re-downloading.
|
||
:saved? saved?
|
||
;; Click commits directly. The hover card already showed the
|
||
;; user the preview + license; a modal confirmation is excise
|
||
;; work given click-to-revert is one click away.
|
||
:on-click (fn [e img] (on-select e img))
|
||
:avatar-mode? avatar-mode?})
|
||
web-id)))]))])))
|
||
|
||
;; ============================================================================
|
||
;; URL Asset Save Error Copy
|
||
;; ============================================================================
|
||
|
||
(defn- url-save-error-copy
|
||
"Map an ex-info thrown by the URL-save path to user-facing copy."
|
||
[^js err]
|
||
(let [data (ex-data err)
|
||
kind (:kind data)]
|
||
(case kind
|
||
:html-page
|
||
"That link is a webpage, not an image. Right-click the image itself and copy image address."
|
||
|
||
:not-image
|
||
(str "That URL isn't an image"
|
||
(when-let [ct (:content-type data)] (str " (" ct ")"))
|
||
". Try a direct image link.")
|
||
|
||
:too-large
|
||
(str "Image exceeds " (/ (:max data) 1024 1024) "MB size limit.")
|
||
|
||
:unknown
|
||
"Couldn't tell what that URL is. Try saving the image and uploading directly."
|
||
|
||
:http-status
|
||
(let [status (:status data)]
|
||
(case status
|
||
401 "That image requires sign-in. Save it locally and upload instead."
|
||
402 "That page is paywalled. Save the image to your computer first."
|
||
403 "That site refused the download. Save the image locally and upload."
|
||
404 "That URL doesn't exist anymore."
|
||
429 "Too many requests to that site. Try again in a moment."
|
||
(str "Server returned " status ". Save the image manually and upload.")))
|
||
|
||
:empty
|
||
"The server closed the connection without sending data. Try again in a moment."
|
||
|
||
:cors
|
||
"Your browser blocked that URL (cross-origin). Try the desktop app for broader URL support, or save the image and upload it directly."
|
||
|
||
:network
|
||
"Couldn't reach that URL. Check your connection, or the site may be offline."
|
||
|
||
;; Default — preserve ex-message for unclassified errors
|
||
(or (ex-message err) "Failed to download image."))))
|
||
|
||
;; ============================================================================
|
||
;; Multi-File Upload Preview
|
||
;; ============================================================================
|
||
|
||
(rum/defc multi-file-preview
|
||
[files on-confirm on-cancel]
|
||
(let [image-files (filter #(contains? config/image-formats
|
||
(keyword (second (string/split (.-type %) "/"))))
|
||
files)
|
||
other-files (remove #(contains? config/image-formats
|
||
(keyword (second (string/split (.-type %) "/"))))
|
||
files)]
|
||
[:div.multi-file-preview.p-4.space-y-4
|
||
[:h3.text-base.font-semibold
|
||
(t :icon.upload/multi-file-confirm-title (count image-files))]
|
||
|
||
;; File list
|
||
[:div.space-y-1.max-h-64.overflow-y-auto
|
||
(for [file image-files]
|
||
[:div.text-sm.py-1
|
||
{:key (.-name file)}
|
||
[:span.truncate (.-name file)]])]
|
||
|
||
;; Warning for skipped files
|
||
(when (seq other-files)
|
||
[:div.text-sm.text-yellow-09.bg-yellow-02.rounded.px-3.py-2
|
||
(t :icon.upload/skip-non-image-warning (count other-files))])
|
||
|
||
;; Action buttons
|
||
[:div.flex.gap-2.justify-end
|
||
(shui/button {:variant :outline :on-click on-cancel} (t :ui/cancel))
|
||
(shui/button {:on-click on-confirm} (t :icon.upload/action))]]))
|
||
|
||
;; ============================================================================
|
||
;; Asset Picker
|
||
;; ============================================================================
|
||
|
||
(defn- <read-from-async-api
|
||
"Async read from the system clipboard via navigator.clipboard.read().
|
||
Returns a promise resolving to:
|
||
{:kind :image :file File}
|
||
{:kind :url :url \"https://...\"}
|
||
{:kind :none}
|
||
{:kind :error :error err}"
|
||
[]
|
||
(if (and js/navigator (.-clipboard js/navigator) (.-read (.-clipboard js/navigator)))
|
||
(-> (p/let [items (.read (.-clipboard js/navigator))
|
||
items-arr (js->clj items)
|
||
first-item (first items-arr)]
|
||
(if (nil? first-item)
|
||
{:kind :none}
|
||
(p/let [types (js->clj (.-types ^js first-item))
|
||
image-type (some #(when (string/starts-with? % "image/") %) types)]
|
||
(cond
|
||
image-type
|
||
(p/let [blob (.getType ^js first-item image-type)
|
||
ext (last (string/split image-type #"/"))
|
||
filename (str "clipboard-" (.now js/Date) "." ext)
|
||
file (js/File. #js [blob] filename #js {:type image-type})]
|
||
{:kind :image :file file})
|
||
|
||
(some #(= % "text/plain") types)
|
||
(p/let [blob (.getType ^js first-item "text/plain")
|
||
text (.text blob)
|
||
trimmed (when text (string/trim text))]
|
||
(if (and trimmed (re-matches #"^https?://\S+$" trimmed))
|
||
{:kind :url :url trimmed}
|
||
{:kind :none}))
|
||
|
||
:else
|
||
{:kind :none}))))
|
||
(p/catch (fn [err] {:kind :error :error err})))
|
||
(p/resolved {:kind :error :error (js/Error. "Clipboard API not available")})))
|
||
|
||
(defn- <read-clipboard-image
|
||
"Read an image or URL from the clipboard.
|
||
If `event` is provided, prefer its synchronous clipboardData (covers Finder
|
||
file pastes, which the Async Clipboard API cannot see). Falls back to
|
||
navigator.clipboard.read() otherwise.
|
||
Returns promise of:
|
||
{:kind :image :file File}
|
||
{:kind :url :url String}
|
||
{:kind :none}
|
||
{:kind :error :error js/Error}"
|
||
([] (<read-clipboard-image nil))
|
||
([^js event]
|
||
(-> (read-from-event event)
|
||
(p/then (fn [result]
|
||
(if (some? result)
|
||
result
|
||
;; Fall back to async API.
|
||
(<read-from-async-api)))))))
|
||
|
||
;; Forward declarations: defined later in the file (near the icon-picker)
|
||
;; but consumed here by the asset-picker. Without these, CLJS emits direct
|
||
;; namespace property references at compile time and the call sites blow up
|
||
;; at runtime with "undefined" — which manifests as the entire enclosing
|
||
;; subtree (e.g. the topbar action group) failing to render.
|
||
(declare keyboard-nav-controller)
|
||
(declare color-picker)
|
||
|
||
(rum/defcs ^:large-vars/cleanup-todo asset-picker < rum/reactive db-mixins/query
|
||
(rum/local "" ::search-q)
|
||
(rum/local true ::loading?) ;; Start with loading state
|
||
(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
|
||
;; Controlled open state for the Fallback dropdown menu (the chip's
|
||
;; menu that contains Letters / Icon… options). Controlled so we can
|
||
;; programmatically close the whole menu chain after a sub-picker
|
||
;; commit (Radix cascade-closes the sub-content when the parent
|
||
;; closes). Without this, the user picks an icon in the sub-picker
|
||
;; and the Fallback dropdown stays open until they manually click
|
||
;; out — confusing and slow.
|
||
(rum/local false ::fallback-menu-open?)
|
||
(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
|
||
(rum/local nil ::highlighted-index) ;; flat index into computed flat-items
|
||
(rum/local nil ::web-images-result) ;; web-images-section publishes its current images here
|
||
;; Monotonic counter for web-image saves. Captured per-click; only the
|
||
;; latest captured id applies its on-chosen — so a quick A→B sequence ends
|
||
;; up showing B regardless of resolution order.
|
||
(rum/local 0 ::web-image-save-id)
|
||
;; Optimistic mirror of the just-committed icon. Set synchronously in the
|
||
;; on-chosen* wrapper so the avatar tile renders the new value during the
|
||
;; ~30-50ms window before the SharedWorker entity write lands and
|
||
;; `current-icon` (a model/sub-block-derived prop) catches up. Without
|
||
;; this, the tile briefly flashes the previous icon/color after Enter or
|
||
;; click — same race the icon-picker solves with its `pending-icon`
|
||
;; rum/use-state. Cleared in :will-remount when `current-icon` changes,
|
||
;; matching icon-picker's `(use-effect! ... [icon-value])` semantics.
|
||
(rum/local nil ::pending-icon)
|
||
;; Drag-and-drop + upload-progress state, per picker instance. See the
|
||
;; comment near the top of this ns for why this isn't module-global.
|
||
(rum/local false ::drag-active?)
|
||
(rum/local 0 ::drag-depth) ;; Track drag enter/leave depth to prevent flicker
|
||
(rum/local false ::asset-picker-open?)
|
||
(rum/local "" ::upload-status)
|
||
;; Create a single stable debounced setter. Must live in state (not the
|
||
;; render `let`) so the debounce timer persists across renders — otherwise
|
||
;; every keystroke gets a fresh timer and no debouncing happens, causing
|
||
;; stale partial-prefix searches to race. Runs as :will-mount (not :init)
|
||
;; because rum/local installs its atoms during :will-mount.
|
||
{:will-mount (fn [state]
|
||
(let [*web-query-debounced (::web-query-debounced state)
|
||
*mode (::mode state)
|
||
{:keys [current-icon avatar-context initial-mode]} (first (:rum/args state))
|
||
initial-mode (cond
|
||
;; Caller-provided initial mode wins
|
||
;; (e.g., Custom-tab Avatar/Image tiles
|
||
;; want to land on a specific tab even
|
||
;; when no current-icon hints at it).
|
||
(#{:avatar :image} initial-mode) initial-mode
|
||
(= :image (:type current-icon)) :image
|
||
(= :avatar (:type current-icon)) :avatar
|
||
(some? avatar-context) :avatar
|
||
:else :avatar)]
|
||
(reset! *mode initial-mode)
|
||
(assoc state ::update-web-query!
|
||
(debounce (fn [q] (reset! *web-query-debounced q)) 500)
|
||
::search-input-ref (rum/create-ref))))
|
||
:did-mount (fn [state]
|
||
;; Track picker open state
|
||
(let [*asset-picker-open? (::asset-picker-open? state)]
|
||
(reset! *asset-picker-open? true)
|
||
|
||
;; Fetch assets - use sync as placeholder, always fire async for completeness
|
||
(let [*loaded-assets (::loaded-assets state)
|
||
*loading? (::loading? state)
|
||
sync-assets (get-image-assets)]
|
||
;; Use sync data as immediate placeholder (avoids spinner if we have partial data)
|
||
(when (seq sync-assets)
|
||
(reset! *loaded-assets sync-assets)
|
||
(reset! *loading? false))
|
||
;; Always fire async query to ensure complete asset list
|
||
(-> (<get-image-assets)
|
||
(p/then (fn [async-assets]
|
||
(when @*asset-picker-open?
|
||
(reset! *loaded-assets (vec async-assets))
|
||
(reset! *loading? false))))
|
||
(p/catch (fn [_err]
|
||
(when @*asset-picker-open?
|
||
(reset! *loading? false)))))))
|
||
|
||
;; Pre-warm the customize-band's expanded layout. The
|
||
;; first expand from compact dropped frames because the
|
||
;; expanded layout (cb-rail-wrap visible at 39px, avatar
|
||
;; at 56px, cb-content at 14px padding + 14px gap) had
|
||
;; never been computed before the user clicked. We
|
||
;; briefly set `data-expanded` (with a `data-prewarming`
|
||
;; sibling that suppresses every transition under the
|
||
;; zone — see icon.css), force a synchronous reflow via
|
||
;; `offsetHeight`, then revert. The browser keeps the
|
||
;; expanded layout's box geometry warm; the user's first
|
||
;; real click hits a hot cache and the morph runs at 60fps.
|
||
(when-let [^js zone (some-> (rum/dom-node state)
|
||
(.querySelector ".avatar-customize-zone"))]
|
||
(.setAttribute zone "data-prewarming" "")
|
||
(.setAttribute zone "data-expanded" "true")
|
||
(.-offsetHeight zone)
|
||
(.removeAttribute zone "data-expanded")
|
||
(.-offsetHeight zone)
|
||
(.removeAttribute zone "data-prewarming"))
|
||
|
||
;; Attach a paste listener to the picker root so ⌘V anywhere in
|
||
;; the modal routes through the render-time clipboard handler.
|
||
(let [node (rum/dom-node state)
|
||
listener (fn [^js e]
|
||
(let [target (.-target e)
|
||
tag (some-> target .-tagName string/lower-case)
|
||
in-input? (contains? #{"input" "textarea"} tag)
|
||
clipboard-data (.-clipboardData e)
|
||
items (some-> clipboard-data .-items)
|
||
has-image? (when items
|
||
(some (fn [i]
|
||
(let [it (aget items i)]
|
||
(and it
|
||
(some-> it .-type
|
||
(string/starts-with? "image/")))))
|
||
(range (.-length items))))]
|
||
(when (or has-image? (not in-input?))
|
||
(.preventDefault e)
|
||
(when-let [h @(::paste-handler state)]
|
||
(h e)))))]
|
||
(.addEventListener node "paste" listener)
|
||
(assoc state ::paste-listener listener)))
|
||
:will-remount (fn [old-state new-state]
|
||
;; Clear the optimistic ::pending-icon mirror once the
|
||
;; entity-derived `current-icon` prop changes — i.e., the
|
||
;; SharedWorker write has landed and model/sub-block
|
||
;; refreshed. Mirrors icon-picker's
|
||
;; `(use-effect! ... [icon-value])` reset.
|
||
(let [old-icon (:current-icon (first (:rum/args old-state)))
|
||
new-icon (:current-icon (first (:rum/args new-state)))]
|
||
(when (not= old-icon new-icon)
|
||
(reset! (::pending-icon new-state) nil)))
|
||
new-state)
|
||
:will-unmount (fn [state]
|
||
;; Remove paste listener first (best-effort — node may be gone)
|
||
(when-let [listener (::paste-listener state)]
|
||
(try
|
||
(when-let [node (rum/dom-node state)]
|
||
(.removeEventListener node "paste" listener))
|
||
(catch :default _ nil)))
|
||
;; Track picker closed state
|
||
(reset! (::asset-picker-open? state) false)
|
||
|
||
state)}
|
||
[state {:keys [on-chosen on-back on-delete del-btn? delete-mode current-icon avatar-context page-title
|
||
*color preview-target-db-id preview-target-db-ids preview-inheritor-db-ids property]
|
||
:or {property :logseq.property/icon}}]
|
||
(let [delete-mode (or delete-mode (if del-btn? :remove :hidden))
|
||
*search-q (::search-q state)
|
||
*loading? (::loading? state)
|
||
*loaded-assets (::loaded-assets state)
|
||
*web-query-debounced (::web-query-debounced state)
|
||
;; Per-instance drag/upload state — atoms previously module-global.
|
||
*drag-active? (::drag-active? state)
|
||
*drag-depth (::drag-depth state)
|
||
*asset-picker-open? (::asset-picker-open? state)
|
||
*upload-status (::upload-status state)
|
||
;; Optimistic local mirror — see ::pending-icon comment in mixins.
|
||
*pending-icon (::pending-icon state)
|
||
pending-icon (rum/react *pending-icon)
|
||
;; Wrap on-chosen so every commit primes the optimistic mirror
|
||
;; *before* the (async, ~30-50ms) entity write fires. The tile reads
|
||
;; `pending-icon` ahead of the stale `current-icon` prop, so there's
|
||
;; no flash of the previous value between commit and entity refresh.
|
||
on-chosen* (fn [e v & rest]
|
||
(reset! *pending-icon v)
|
||
(apply on-chosen e v rest))
|
||
;; Keyboard-nav state
|
||
*focus-region (::focus-region state)
|
||
*highlighted-index (::highlighted-index state)
|
||
*web-images-result (::web-images-result state)
|
||
*web-image-save-id (::web-image-save-id state)
|
||
*search-input-ref (::search-input-ref state)
|
||
web-images (rum/react *web-images-result)
|
||
highlighted-idx (rum/react *highlighted-index)
|
||
loading? (rum/react *loading?)
|
||
;; Use cached assets if available, otherwise try to get them
|
||
assets (or (rum/react *loaded-assets) [])
|
||
search-q @*search-q
|
||
;; Web search query: use search input if typing, otherwise use page title
|
||
web-query (rum/react *web-query-debounced)
|
||
effective-web-query (if (string/blank? search-q)
|
||
(or page-title "")
|
||
(or web-query page-title ""))
|
||
;; Extract current image UUID from the icon (works for both :image and :avatar with image)
|
||
current-asset-uuid (or (get-in current-icon [:data :asset-uuid])
|
||
(when (= :image (:type current-icon))
|
||
(get-in current-icon [:data :asset-uuid])))
|
||
;; Find the current asset from the list
|
||
current-asset (when current-asset-uuid
|
||
(some #(when (= (str (:block/uuid %)) current-asset-uuid) %)
|
||
assets))
|
||
;; Filter assets by search query
|
||
filtered-assets (if (string/blank? search-q)
|
||
assets
|
||
(filter (fn [asset]
|
||
(let [title (or (:block/title asset) "")]
|
||
(string/includes?
|
||
(string/lower-case title)
|
||
(string/lower-case search-q))))
|
||
assets))
|
||
asset-count (count filtered-assets)
|
||
*mode (::mode state)
|
||
mode (rum/react *mode)
|
||
avatar-mode? (= :avatar mode)
|
||
;; Stable avatar "template" used both when synthesizing an avatar from
|
||
;; an :image icon and when the caller didn't supply an avatar-context.
|
||
synthesized-avatar-context
|
||
(or avatar-context
|
||
{:type :avatar
|
||
:id (str "avatar-" (or page-title "page"))
|
||
:label (or page-title "")
|
||
:data {:value (derive-avatar-initials (or page-title ""))
|
||
:backgroundColor (colors/variable :gray :09)
|
||
:color (colors/variable :gray :09)}})
|
||
;; Child components read `(some? avatar-context)` to decide circle vs
|
||
;; square; flip that live from the active tab.
|
||
effective-avatar-context (when avatar-mode? synthesized-avatar-context)
|
||
;; Avatar shape (`:circle` | `:rounded-rect`) for cropping the
|
||
;; asset-grid tiles in lockstep with the avatar tile. Reads in
|
||
;; precedence:
|
||
;; 1. Customize-band Shape-row hover-preview — broadcasts a
|
||
;; synthetic icon with the previewed shape so the grid
|
||
;; tiles re-crop live while the user is hovering Circle /
|
||
;; Rectangle in the Shape sub-menu.
|
||
;; 2. Optimistic `pending-icon` mirror — covers the ~50ms
|
||
;; window after commit before `current-icon` (the entity
|
||
;; prop) refreshes.
|
||
;; 3. Committed `current-icon` — the entity-derived value.
|
||
;; Falls back to `:circle` so the existing avatar-mode rules
|
||
;; keep applying when no avatar context is active.
|
||
;; Shared envelope for every preview broadcast in this picker
|
||
;; (color popover, shape/fallback hover, sub-picker tile hover).
|
||
;; Centralized so each broadcaster passes the SAME scope —
|
||
;; receivers gate on it to ignore previews from other pickers.
|
||
;; Two scopes (see icon.cljs `icon-preview-matches?` doc):
|
||
;; primary — `:db-id` / `:db-ids` rendering `:property`
|
||
;; inheritor — `:inheritor-db-ids` rendering `:inheritor-property`
|
||
preview-base-target (cond-> {:property property}
|
||
preview-target-db-id (assoc :db-id preview-target-db-id)
|
||
(seq preview-target-db-ids) (assoc :db-ids (set preview-target-db-ids))
|
||
(seq preview-inheritor-db-ids)
|
||
(assoc :inheritor-property :logseq.property/icon
|
||
:inheritor-db-ids (set preview-inheritor-db-ids)))
|
||
hover-preview-state (state/sub :ui/icon-hover-preview)
|
||
hover-shape-match? (and hover-preview-state
|
||
(icon-preview-matches? hover-preview-state preview-target-db-id property))
|
||
hover-shape (when hover-shape-match?
|
||
(get-in hover-preview-state [:icon :data :shape]))
|
||
avatar-source-icon (or (when (= :avatar (:type pending-icon)) pending-icon)
|
||
(when (= :avatar (:type current-icon)) current-icon))
|
||
current-shape (or hover-shape
|
||
(get-in avatar-source-icon [:data :shape])
|
||
:circle)
|
||
;; Tab click handler. Persists an in-place :type flip when the current
|
||
;; icon already has an asset; otherwise it's a local preview only.
|
||
on-mode-change
|
||
(fn [new-mode]
|
||
(when (not= new-mode @*mode)
|
||
(reset! *mode new-mode)
|
||
;; The color trigger is rendered Avatar-mode-only, so its
|
||
;; trigger button vanishes when the user toggles to Image. The
|
||
;; popover lives in a portal and won't auto-close on trigger
|
||
;; unmount; dismiss it explicitly by id so it doesn't orphan
|
||
;; over an unrelated topbar.
|
||
(when (= new-mode :image)
|
||
(shui/popup-hide! :asset-picker-color))
|
||
(when-let [asset-uuid (get-in current-icon [:data :asset-uuid])]
|
||
(let [asset-type (get-in current-icon [:data :asset-type])
|
||
image-data {:asset-uuid asset-uuid :asset-type asset-type}
|
||
next-icon (case new-mode
|
||
:image {:type :image
|
||
:id (str "image-" asset-uuid)
|
||
:label (or (:label current-icon) "")
|
||
:data image-data}
|
||
:avatar {:type :avatar
|
||
:id (or (:id synthesized-avatar-context)
|
||
(str "avatar-" asset-uuid))
|
||
:label (or (:label current-icon)
|
||
(:label synthesized-avatar-context))
|
||
:data (merge (:data synthesized-avatar-context)
|
||
image-data)})]
|
||
(on-chosen* nil next-icon true)))))
|
||
;; Stable debounced web-query setter (created once in :init)
|
||
update-web-query! (::update-web-query! state)
|
||
;; SVG detection helper - checks if URL is an SVG file
|
||
svg-url?
|
||
(fn [url]
|
||
(and (string? url)
|
||
(string/ends-with? (string/lower-case url) ".svg")))
|
||
|
||
;; Download + save path. Called from `handle-web-image-select` when no
|
||
;; local asset matches the web image's source-url. Defined first so
|
||
;; the dispatcher below can close over it.
|
||
handle-web-image-download
|
||
(fn [repo url thumb-url title source license author source-url]
|
||
(let [;; Use PNG thumbnail for SVGs (avoids blob rendering issues)
|
||
;; Fall back to original URL for non-SVGs or if no thumbnail
|
||
download-url (if (and (svg-url? url) thumb-url)
|
||
thumb-url ; PNG thumbnail for SVG
|
||
url) ; Original for other formats
|
||
asset-name (or title "web-image")
|
||
source-name (source-name-for source)
|
||
attribution (build-attribution {:title title :author author
|
||
:source source :license license})
|
||
source-meta (cond-> {}
|
||
source-url (assoc :source-url source-url)
|
||
source-name (assoc :source-name source-name)
|
||
license (assoc :license license)
|
||
attribution (assoc :attribution attribution))
|
||
;; Capture save-id for race protection. A later click supersedes
|
||
;; this one's on-chosen so the icon reflects the LAST pick, even
|
||
;; if saves resolve out of order.
|
||
my-save-id (swap! *web-image-save-id inc)]
|
||
(-> (<save-url-asset! repo download-url asset-name source-meta)
|
||
(p/then (fn [asset-entity]
|
||
(when (and asset-entity
|
||
(= my-save-id @*web-image-save-id))
|
||
;; Track as recently used
|
||
(add-used-asset! (:block/uuid asset-entity))
|
||
;; Refresh asset list
|
||
(p/let [updated-assets (<get-image-assets)]
|
||
(reset! *loaded-assets (or (seq updated-assets) [])))
|
||
;; Select the new asset
|
||
(let [image-data {:asset-uuid (str (:block/uuid asset-entity))
|
||
:asset-type (:logseq.property.asset/type asset-entity)}]
|
||
(on-chosen* nil
|
||
(if (= :avatar mode)
|
||
{:type :avatar
|
||
:id (:id synthesized-avatar-context)
|
||
:label (:label synthesized-avatar-context)
|
||
:data (merge (:data synthesized-avatar-context) image-data)}
|
||
{:type :image
|
||
:id (str "image-" (:block/uuid asset-entity))
|
||
:label (or (:block/title asset-entity) "")
|
||
:data image-data}))))))
|
||
(p/catch (fn [err]
|
||
;; Only show error for the latest save attempt;
|
||
;; superseded saves fail silently to avoid double
|
||
;; toasts on rapid successive picks.
|
||
(when (= my-save-id @*web-image-save-id)
|
||
(shui/toast! (url-save-error-copy err) :error)))))))
|
||
|
||
;; Click dispatcher for the web-image grid. Splits between two paths:
|
||
;;
|
||
;; - Already-saved fast path: when the web image's source-url matches
|
||
;; an asset already in `assets`, route the click to that asset
|
||
;; without re-downloading. The green "saved" badge on the tile is
|
||
;; the visible promise of this — re-fetching would create an orphan
|
||
;; duplicate.
|
||
;; - Fresh download: otherwise, fall through to `handle-web-image-download`
|
||
;; which downloads the image, writes the asset, and selects it.
|
||
handle-web-image-select
|
||
(fn [_e web-image]
|
||
(let [repo (state/get-current-repo)
|
||
{:keys [url thumb-url title source license author source-url]} web-image
|
||
existing-asset (when (and source-url (not (string/blank? source-url)))
|
||
(some #(when (= source-url
|
||
(:logseq.property.asset/source-url %))
|
||
%)
|
||
assets))]
|
||
(if existing-asset
|
||
(let [image-data {:asset-uuid (str (:block/uuid existing-asset))
|
||
:asset-type (:logseq.property.asset/type existing-asset)}]
|
||
(add-used-asset! (:block/uuid existing-asset))
|
||
(on-chosen* nil
|
||
(if (= :avatar mode)
|
||
{:type :avatar
|
||
:id (:id synthesized-avatar-context)
|
||
:label (:label synthesized-avatar-context)
|
||
:data (merge (:data synthesized-avatar-context) image-data)}
|
||
{:type :image
|
||
:id (str "image-" (:block/uuid existing-asset))
|
||
:label (or (:block/title existing-asset) "")
|
||
:data image-data})))
|
||
(handle-web-image-download repo url thumb-url title source license author source-url))))
|
||
|
||
;; Process upload (actual upload logic extracted for reuse)
|
||
process-upload (fn [files]
|
||
(let [repo (state/get-current-repo)
|
||
image-files (filter (fn [file]
|
||
(let [file-type (.-type file)
|
||
ext (some-> file-type
|
||
(string/split "/")
|
||
second
|
||
keyword)]
|
||
(contains? config/image-formats ext)))
|
||
files)
|
||
rejected-files (remove (fn [file]
|
||
(let [file-type (.-type file)
|
||
ext (some-> file-type
|
||
(string/split "/")
|
||
second
|
||
keyword)]
|
||
(contains? config/image-formats ext)))
|
||
files)]
|
||
(when (seq image-files)
|
||
;; Check which files already exist (by checksum)
|
||
(p/let [checksums (p/all (map #(assets-handler/get-file-checksum %) image-files))
|
||
existing (p/all (map #(when % (db-async/<get-asset-with-checksum repo %)) checksums))
|
||
new-files (vec (keep-indexed (fn [i f] (when-not (nth existing i) f)) image-files))
|
||
reused-entities (vec (remove nil? existing))]
|
||
|
||
;; Update ARIA status
|
||
(when (seq new-files)
|
||
(reset! *upload-status
|
||
(str "Uploading " (count new-files) " image"
|
||
(when (not= 1 (count new-files)) "s"))))
|
||
|
||
(p/let [new-entities (p/all (map #(<save-image-asset! repo %) new-files))
|
||
entities (into (vec (remove nil? new-entities)) reused-entities)]
|
||
(p/let [updated-assets (<get-image-assets)]
|
||
(reset! *loaded-assets (or (seq updated-assets) [])))
|
||
|
||
;; Show feedback notification
|
||
(let [new-count (count (remove nil? new-entities))
|
||
reused-count (count reused-entities)]
|
||
(cond
|
||
(and (pos? new-count) (zero? reused-count) (empty? rejected-files))
|
||
(shui/toast! (t :icon.upload/uploaded-success new-count)
|
||
:success)
|
||
|
||
(and (pos? new-count) (pos? reused-count))
|
||
(shui/toast! (t :icon.upload/uploaded-mixed-success new-count reused-count)
|
||
:success)
|
||
|
||
(and (zero? new-count) (pos? reused-count))
|
||
(shui/toast! (t :icon.upload/all-existed-success reused-count)
|
||
:success)
|
||
|
||
(seq rejected-files)
|
||
(shui/toast! (t :icon.upload/skipped-non-images-error (count rejected-files))
|
||
:error)
|
||
|
||
:else nil))
|
||
|
||
;; Update completion status
|
||
(reset! *upload-status
|
||
(str "Upload complete. " (count (remove nil? new-entities)) " images added"))
|
||
|
||
;; Clear status after 3 seconds
|
||
(js/setTimeout #(reset! *upload-status "") 3000)
|
||
|
||
(when-let [first-asset (first entities)]
|
||
(let [image-data {:asset-uuid (str (:block/uuid first-asset))
|
||
:asset-type (:logseq.property.asset/type first-asset)}]
|
||
(on-chosen* nil
|
||
(if (= :avatar mode)
|
||
{:type :avatar
|
||
:id (:id synthesized-avatar-context)
|
||
:label (:label synthesized-avatar-context)
|
||
:data (merge (:data synthesized-avatar-context) image-data)}
|
||
{:type :image
|
||
:id (str "image-" (:block/uuid first-asset))
|
||
:label (or (:block/title first-asset) "")
|
||
:data image-data})))))))))
|
||
|
||
;; Handle file upload with smart multi-file preview
|
||
handle-upload (fn [files]
|
||
(let [file-count (count files)]
|
||
(if (> file-count 3)
|
||
;; Show preview confirmation for >3 files
|
||
(shui/popup-show!
|
||
(multi-file-preview
|
||
files
|
||
#(do (shui/popup-hide!) (process-upload files))
|
||
#(shui/popup-hide!))
|
||
{:align :center
|
||
:content-props {:class "w-96"}})
|
||
;; Auto-upload for 1-3 files
|
||
(process-upload files))))
|
||
|
||
;; Feature detection for Async Clipboard API.
|
||
clipboard-supported?
|
||
(boolean (some-> js/navigator .-clipboard .-read))
|
||
|
||
;; Click the hidden <input type=file> to open the native file picker.
|
||
trigger-upload!
|
||
(fn []
|
||
(when-let [input (js/document.getElementById "asset-upload-input")]
|
||
(.click input)))
|
||
|
||
;; Shared "asset added via URL" side-effect: refresh list + apply icon.
|
||
;; Used by both the URL pane popup and the clipboard :url branch.
|
||
on-url-asset-entity-added
|
||
(fn [asset-entity]
|
||
;; Refresh asset list
|
||
(p/let [updated-assets (<get-image-assets)]
|
||
(reset! *loaded-assets (or (seq updated-assets) [])))
|
||
;; Select the new asset
|
||
(let [image-data {:asset-uuid (str (:block/uuid asset-entity))
|
||
:asset-type (:logseq.property.asset/type asset-entity)}]
|
||
(on-chosen* nil
|
||
(if (= :avatar mode)
|
||
{:type :avatar
|
||
:id (:id synthesized-avatar-context)
|
||
:label (:label synthesized-avatar-context)
|
||
:data (merge (:data synthesized-avatar-context) image-data)}
|
||
{:type :image
|
||
:id (str "image-" (:block/uuid asset-entity))
|
||
:label (or (:block/title asset-entity) "")
|
||
:data image-data}))))
|
||
|
||
;; Read the system clipboard and route to upload / URL-save / toast.
|
||
handle-clipboard-paste
|
||
(fn self
|
||
([] (self nil))
|
||
([^js event]
|
||
;; Trigger the shortcut-badge press animation (echoes the keystroke
|
||
;; for ⌘V pastes, provides visual feedback for button clicks too).
|
||
(shui/shortcut-press! "mod+v")
|
||
(-> (p/let [result (<read-clipboard-image event)]
|
||
(case (:kind result)
|
||
:image
|
||
(handle-upload [(:file result)])
|
||
|
||
:url
|
||
(let [repo (state/get-current-repo)
|
||
url (:url result)
|
||
asset-name (extract-filename-from-url url)]
|
||
(-> (<save-url-asset! repo url asset-name)
|
||
(p/then (fn [asset-entity]
|
||
(when asset-entity
|
||
(on-url-asset-entity-added asset-entity))))
|
||
(p/catch (fn [err]
|
||
(shui/toast! (url-save-error-copy err) :error)))))
|
||
|
||
:none
|
||
(shui/toast! (t :icon.clipboard/no-image-or-url-warning) :warning)
|
||
|
||
:error
|
||
(shui/toast! (t :icon.clipboard/read-error) :warning)))
|
||
(p/catch (fn [err]
|
||
(log/error :icon/clipboard-paste-failed {:error err})
|
||
(shui/toast! (url-save-error-copy err) :error))))))
|
||
|
||
;; Keep the DOM paste listener pointed at the freshest closure.
|
||
_ (reset! (::paste-handler state) handle-clipboard-paste)]
|
||
[:div.asset-picker
|
||
{:id "asset-picker-modal"
|
||
:class [(when avatar-mode? "avatar-mode")
|
||
(when (rum/react *drag-active?) "drag-active")]
|
||
;; Cascades shape into the asset-grid tile rules so cropped
|
||
;; thumbnails match the avatar tile's silhouette. CSS reads
|
||
;; `[data-avatar-shape="rounded-rect"]` to swap the
|
||
;; `.avatar-mode` `rounded-full` for the same 22% radius the
|
||
;; avatar root uses (icon.css:1855-1858). Only present in
|
||
;; avatar-mode — image-mode tiles keep their default rounded
|
||
;; squares regardless.
|
||
:data-avatar-shape (when avatar-mode? (name current-shape))
|
||
:on-drag-enter (fn [e]
|
||
(.preventDefault e)
|
||
(.stopPropagation e)
|
||
(swap! *drag-depth inc)
|
||
(when (= @*drag-depth 1)
|
||
(reset! *drag-active? true)
|
||
(when-let [bd (.querySelector (.-currentTarget e) ".bd-scroll")]
|
||
(set! (.. bd -style -overflowY) "hidden"))
|
||
(when-let [vs (.querySelector (.-currentTarget e) "[data-virtuoso-scroller]")]
|
||
(set! (.. vs -style -overflowY) "hidden"))))
|
||
:on-drag-over (fn [e]
|
||
(.preventDefault e)
|
||
(.stopPropagation e))
|
||
:on-drag-leave (fn [e]
|
||
(.preventDefault e)
|
||
(.stopPropagation e)
|
||
(swap! *drag-depth dec)
|
||
(when (<= @*drag-depth 0)
|
||
(reset! *drag-depth 0)
|
||
(reset! *drag-active? false)
|
||
(when-let [bd (.querySelector (.-currentTarget e) ".bd-scroll")]
|
||
(set! (.. bd -style -overflowY) ""))
|
||
(when-let [vs (.querySelector (.-currentTarget e) "[data-virtuoso-scroller]")]
|
||
(set! (.. vs -style -overflowY) ""))))
|
||
:on-drop (fn [e]
|
||
(.preventDefault e)
|
||
(.stopPropagation e)
|
||
(reset! *drag-depth 0)
|
||
(reset! *drag-active? false)
|
||
(when-let [bd (.querySelector (.-currentTarget e) ".bd-scroll")]
|
||
(set! (.. bd -style -overflowY) ""))
|
||
(when-let [vs (.querySelector (.-currentTarget e) "[data-virtuoso-scroller]")]
|
||
(set! (.. vs -style -overflowY) ""))
|
||
(let [files (array-seq (.. e -dataTransfer -files))]
|
||
(handle-upload files)))}
|
||
|
||
;; ARIA live region for status announcements
|
||
[:div.sr-only
|
||
{:role "status"
|
||
:aria-live "polite"
|
||
:aria-atomic "true"}
|
||
(rum/react *upload-status)]
|
||
|
||
;; Drag overlay hint
|
||
(when @*drag-active?
|
||
[:div.drag-overlay-hint
|
||
[:div.corner.tl] [:div.corner.tr]
|
||
[:div.corner.bl] [:div.corner.br]
|
||
(shui/tabler-icon "upload" {:size 26})
|
||
[:div.text-group
|
||
[:span.title (t :icon.upload/drop-overlay-title)]
|
||
[:span.subtitle (t :icon.upload/format-list)]]])
|
||
|
||
;; Topbar: back | Avatar/Image tabs | trash, then separator, then search.
|
||
;; Each focusable stop carries `data-topbar-stop` so the keyboard-nav
|
||
;; controller can rove DOM focus across them with ArrowLeft/Right.
|
||
[:div.asset-picker-topbar
|
||
[:div.asset-picker-tabrow
|
||
[:div.asset-picker-back
|
||
[:button.back-button
|
||
{:on-click on-back
|
||
:data-topbar-stop "back"}
|
||
(shui/tabler-icon "chevron-left" {:size 16})
|
||
[:span (t :icon/back)]]]
|
||
[:div.asset-picker-tabs-slot
|
||
;; Avatar/Image is a value selector, not a content tab — both modes
|
||
;; show the same image grid; only the resulting icon's :type/shape
|
||
;; differs. Render as a pilled segmented control with radiogroup
|
||
;; ARIA semantics. Manual activation (Enter) is intentional: a mode
|
||
;; flip writes to the DB when an asset is already selected.
|
||
(ui/segmented-control
|
||
{:options [[:avatar (t :icon.asset-mode/avatar)] [:image (t :icon.asset-mode/image)]]
|
||
:active mode
|
||
:on-change (fn [m _e] (on-mode-change m))
|
||
:aria-label "Icon rendering mode"
|
||
:button-attrs {:data-topbar-stop "tab"}})]
|
||
;; Right-side action group. Holds the color trigger (Avatar mode
|
||
;; only) and the trash button. Bundling them under one grid slot
|
||
;; keeps the topbar's three-column layout (back / segment / actions)
|
||
;; intact when the color trigger appears or disappears.
|
||
[:div.asset-picker-topbar-actions
|
||
;; Color trigger — Avatar mode only. Mirrors the icon-picker's
|
||
;; topbar trigger (same component, same `*color` atom) so backing
|
||
;; out updates the parent in lockstep. Hidden in Image mode since
|
||
;; image assets aren't tinted; an explicit popup-id lets
|
||
;; on-mode-change dismiss the popover when the user toggles to
|
||
;; Image while it's open.
|
||
(when (and avatar-mode? *color)
|
||
(color-picker *color
|
||
(fn [c]
|
||
;; Sync first, then commit. The on-chosen wrapper
|
||
;; receives the recolored avatar; without the
|
||
;; sync, a parent re-render would see stale
|
||
;; @*color. Mirrors the icon-picker callback at
|
||
;; icon.cljs:5642-5662.
|
||
(reset! *color c)
|
||
(let [icon (or (when (= :avatar (:type current-icon)) current-icon)
|
||
synthesized-avatar-context)]
|
||
(on-chosen* nil
|
||
(-> icon
|
||
(assoc :color c)
|
||
(assoc-in [:data :color] c)
|
||
(assoc-in [:data :backgroundColor] c))
|
||
true)))
|
||
:on-hover! (when (or preview-target-db-id (seq preview-target-db-ids))
|
||
(fn [c]
|
||
;; Additive — preserves any
|
||
;; tile-hover `:icon` so moving
|
||
;; from a tile to a color swatch
|
||
;; keeps the tile shape visible
|
||
;; with the swatch color overlaid.
|
||
(merge-into-icon-preview!
|
||
(assoc preview-base-target :color c))))
|
||
:on-hover-end! (when (or preview-target-db-id (seq preview-target-db-ids))
|
||
(fn []
|
||
(dissoc-icon-preview-field! preview-base-target :color)))
|
||
:button-attrs {:data-topbar-stop "color"}
|
||
:popup-id :asset-picker-color))
|
||
;; Trash button mirrors the outer icon-picker's three modes (per plan).
|
||
;; on-delete is the 1-arg shim from icon-search that forwards the action
|
||
;; keyword through the parent on-chosen. Use `cond` (not `case`) — see
|
||
;; the outer trash site for the CLJS-case-vs-React-child gotcha.
|
||
(let [trash-icon (shui/tabler-icon "trash" {:size 17})
|
||
reset-and-call (fn [action]
|
||
(reset-picker-transient-state!
|
||
{:*pending-icon *pending-icon
|
||
:*upload-status *upload-status})
|
||
(on-delete action))]
|
||
(cond
|
||
(= delete-mode :hidden) nil
|
||
|
||
(= delete-mode :remove)
|
||
(shui/button {:variant :outline :size :sm :data-action "del"
|
||
:data-topbar-stop "trash"
|
||
:title (t :icon/remove-icon)
|
||
:aria-label (t :icon/remove-icon)
|
||
:on-click #(reset-and-call :remove)}
|
||
trash-icon)
|
||
|
||
(= delete-mode :suppress)
|
||
(shui/button {:variant :outline :size :sm :data-action "del"
|
||
:data-topbar-stop "trash"
|
||
:title (t :icon/hide-inherited-icon)
|
||
:aria-label (t :icon/hide-inherited-icon)
|
||
:on-click #(reset-and-call :remove-entirely)}
|
||
trash-icon)
|
||
|
||
(= delete-mode :two-option)
|
||
(shui/dropdown-menu
|
||
(shui/dropdown-menu-trigger
|
||
{:as-child true}
|
||
(shui/button {:variant :outline :size :sm :data-action "del"
|
||
:data-topbar-stop "trash"
|
||
:title (t :icon/remove-icon-options)
|
||
:aria-label (t :icon/remove-icon-options)
|
||
:aria-haspopup "menu"}
|
||
trash-icon))
|
||
(shui/dropdown-menu-content
|
||
{:side "bottom" :align "end"}
|
||
(shui/dropdown-menu-item
|
||
{:on-select #(reset-and-call :revert)}
|
||
(shui/tabler-icon "arrow-back-up" {:size 14 :class "mr-2 opacity-80"})
|
||
(t :icon/revert-to-default))
|
||
(shui/dropdown-menu-item
|
||
{:on-select #(reset-and-call :remove-entirely)}
|
||
(shui/tabler-icon "trash" {:size 14 :class "mr-2 opacity-80"})
|
||
(t :icon/remove-entirely))))))]]
|
||
;; Reuse icon-picker-separator class so the divider gets the same
|
||
;; themed treatment as the icon picker's (lx-gray-05 →
|
||
;; --ls-border-color middle step → themed teal in OG instead of
|
||
;; the shadcn default's washed-out bg-border at 50% opacity).
|
||
(shui/separator {:class "my-0 icon-picker-separator"})
|
||
[:div.asset-picker-search
|
||
[:div.search-input
|
||
(shui/tabler-icon "search" {:size 16 :class "ls-icon-search"})
|
||
(shui/input
|
||
{:type "search"
|
||
:aria-label (t :icon.asset-search/placeholder)
|
||
:placeholder (t :icon.asset-search/placeholder)
|
||
:value search-q
|
||
:auto-focus true
|
||
:ref *search-input-ref
|
||
:on-focus (fn [_]
|
||
(reset! *focus-region :search)
|
||
(reset! *highlighted-index nil))
|
||
;; Auto-route a pasted URL to the asset-fetch path. The root
|
||
;; paste listener at the picker modal skips when focus is in
|
||
;; the search input + clipboard is text, so URLs pasted into
|
||
;; this input would otherwise become an empty fuzzy-filter
|
||
;; query (the "No matching images" failure mode users hit).
|
||
;; Detection: scheme prefix regex + `js/URL.` constructor
|
||
;; (cheap gate first, then strict validate). On match:
|
||
;; preventDefault stops the URL from entering the input,
|
||
;; then `<save-url-asset!` runs (which already validates
|
||
;; MIME, rejects HTML, caps size, follows redirects). On
|
||
;; failure we restore the URL to the input + toast the
|
||
;; reason via `url-save-error-copy` so the user can copy /
|
||
;; edit / paste elsewhere — paste content is never lost.
|
||
:on-paste (fn [^js e]
|
||
(let [text (some-> e .-clipboardData (.getData "text"))
|
||
trimmed (some-> text string/trim)
|
||
url? (and trimmed
|
||
(re-matches #"^https?://\S+$" trimmed)
|
||
(try (js/URL. trimmed) true
|
||
(catch :default _ false)))]
|
||
(when url?
|
||
(.preventDefault e)
|
||
(shui/shortcut-press! "mod+v")
|
||
(let [repo (state/get-current-repo)
|
||
asset-name (extract-filename-from-url trimmed)]
|
||
(-> (<save-url-asset! repo trimmed asset-name)
|
||
(p/then (fn [asset-entity]
|
||
(when asset-entity
|
||
(on-url-asset-entity-added asset-entity))))
|
||
(p/catch (fn [err]
|
||
(reset! *search-q trimmed)
|
||
(update-web-query! trimmed)
|
||
(shui/toast! (url-save-error-copy err) :error))))))))
|
||
:on-change (fn [e]
|
||
(let [v (util/evalue e)]
|
||
(reset! *search-q v)
|
||
(reset! *focus-region :search)
|
||
(reset! *highlighted-index nil)
|
||
;; Update debounced web query
|
||
(update-web-query! v)))
|
||
:on-key-down (fn [^js e]
|
||
(let [code (.-keyCode e)]
|
||
(cond
|
||
;; Escape: clear query or close picker (parity with icon-picker).
|
||
(= code 27)
|
||
(do (util/stop e)
|
||
(if (string/blank? @*search-q)
|
||
(shui/popup-hide!)
|
||
(do (reset! *search-q "")
|
||
(update-web-query! ""))))
|
||
|
||
;; Up / Shift+Tab: enter the topbar at the active mode tab.
|
||
(or (= code 38)
|
||
(and (= code 9) (.-shiftKey e)))
|
||
(do (util/stop e)
|
||
(reset! *focus-region :topbar)
|
||
(reset! *highlighted-index nil)
|
||
(when-let [^js cnt (some-> (rum/deref *search-input-ref)
|
||
(.closest ".asset-picker"))]
|
||
;; Land on the active mode tab; fall back to the first
|
||
;; topbar stop if no tab is marked active.
|
||
(when-let [el (or (.querySelector cnt
|
||
"[data-topbar-stop='tab'][data-active='true']")
|
||
(.querySelector cnt "[data-topbar-stop]"))]
|
||
(.focus el))))
|
||
|
||
;; Tab / Down: enter grid at first item.
|
||
(or (and (= code 9) (not (.-shiftKey e)))
|
||
(= code 40))
|
||
(do (util/stop e)
|
||
(reset! *focus-region :grid)
|
||
(reset! *highlighted-index 0))
|
||
|
||
;; Enter: fire the ghost-highlighted first result
|
||
;; (first tile in document order that has the ghost class).
|
||
(= code 13)
|
||
(when (nil? @*highlighted-index)
|
||
(when-let [^js cnt (some-> (rum/deref *search-input-ref)
|
||
(.closest ".asset-picker"))]
|
||
(when-let [btn (.querySelector cnt ".is-ghost-highlighted")]
|
||
(util/stop e)
|
||
(.click btn)))))))})
|
||
;; Rounded-circle clear button (matches icon-search). Visible
|
||
;; only when the input has content; shares the same on-brand
|
||
;; affordance instead of letting the browser render its native
|
||
;; cancel-X (hidden via CSS).
|
||
(when-not (string/blank? search-q)
|
||
[:a.x {:on-click (fn [_]
|
||
(reset! *search-q "")
|
||
(update-web-query! "")
|
||
(some-> (rum/deref *search-input-ref) (.focus)))}
|
||
(shui/tabler-icon "x" {:size 14})])]]]
|
||
|
||
;; Body - scrollable content area with top/bottom margin
|
||
(let [;; Get recently used asset UUIDs and resolve to asset entities
|
||
used-uuids (get-used-assets)
|
||
used-assets (->> used-uuids
|
||
(map (fn [uuid-str]
|
||
(some #(when (= (str (:block/uuid %)) uuid-str) %)
|
||
assets)))
|
||
(remove nil?))
|
||
;; Build the "Recently used" row: current selection first (if not already in list), then recently used
|
||
recently-used-row (if current-asset
|
||
;; Put current asset first, then others (excluding current)
|
||
(take 5 (cons current-asset
|
||
(remove #(= (:block/uuid %) (:block/uuid current-asset))
|
||
used-assets)))
|
||
;; No current selection, just show recently used
|
||
(take 5 used-assets))
|
||
recently-used-count (count recently-used-row)
|
||
section-states (rum/react *section-states)
|
||
recently-used-expanded? (get section-states "Recently used" true)
|
||
available-expanded? (get section-states "Available assets" true)
|
||
;; Set of source URLs already saved as assets locally. Threaded into
|
||
;; web-images-section so each tile knows whether it's a saved image
|
||
;; and can render the green "saved" badge instead of the default
|
||
;; globe overlay. Same set is used by handle-web-image-select to
|
||
;; route saved-tile clicks to the existing asset (no re-download).
|
||
saved-source-urls (->> assets
|
||
(keep :logseq.property.asset/source-url)
|
||
(remove string/blank?)
|
||
set)
|
||
;; Keyboard navigation: flat-items + sections mirror the icon-picker model.
|
||
;; Include only sections that are currently rendered and expanded so
|
||
;; flat indices align with visible DOM buttons. Web tiles that match
|
||
;; a saved asset stay in the list — they're still visible on screen
|
||
;; (with the saved badge) so the keyboard cursor must reach them.
|
||
recent-nav-row (when (and recently-used-expanded?
|
||
(seq recently-used-row)
|
||
(string/blank? search-q))
|
||
recently-used-row)
|
||
web-nav-list (when (not (string/blank? effective-web-query))
|
||
(vec (or web-images [])))
|
||
empty-state? (and available-expanded?
|
||
(not loading?)
|
||
(empty? filtered-assets)
|
||
(empty? assets))
|
||
search-miss? (and available-expanded?
|
||
(not loading?)
|
||
(empty? filtered-assets)
|
||
(seq assets)
|
||
(not (string/blank? search-q)))
|
||
available-nav-list (when (and available-expanded?
|
||
(not loading?)
|
||
(seq filtered-assets))
|
||
(vec filtered-assets))
|
||
{:keys [flat-items sections]}
|
||
(let [*items (atom [])
|
||
*secs (atom [])
|
||
add! (fn [label cols its]
|
||
(let [its (vec its) c (count its)]
|
||
(when (pos? c)
|
||
(swap! *secs conj {:label label :start (count @*items) :count c :cols cols})
|
||
(swap! *items into its))))]
|
||
(add! "Recently used" 5
|
||
(map (fn [a] {:type :asset :id (str "recent-" (:block/uuid a))})
|
||
recent-nav-row))
|
||
(add! "Web images" 5
|
||
(map (fn [w] {:type :web :id (str "web-" (:url w))})
|
||
web-nav-list))
|
||
(cond
|
||
(seq available-nav-list)
|
||
(add! "Available assets" 5
|
||
(map (fn [a] {:type :asset :id (str "asset-" (:block/uuid a))})
|
||
available-nav-list))
|
||
|
||
empty-state?
|
||
(add! "Empty actions" 1
|
||
(concat
|
||
(when clipboard-supported? [{:type :clipboard-row :id "clipboard-row"}])
|
||
[{:type :upload-row :id "upload-row"}])))
|
||
{:flat-items @*items :sections @*secs})
|
||
highlighted-id (when (and highlighted-idx (< highlighted-idx (count flat-items)))
|
||
(:id (nth flat-items highlighted-idx)))
|
||
;; Ghost-highlight: when the search input has focus and no tile is
|
||
;; arrow-selected, preview the first *tile* so Enter picks it.
|
||
;; Action rows (clipboard / upload) are excluded — Enter from
|
||
;; search is meant to pick a media item, not fire a CTA.
|
||
ghost-highlighted-id (when (and (= @*focus-region :search)
|
||
(nil? highlighted-idx))
|
||
(some (fn [it]
|
||
(when (#{:asset :web} (:type it))
|
||
(:id it)))
|
||
flat-items))]
|
||
[:div.bd.bd-scroll
|
||
;; Invisible controller: attaches a single capture-phase keydown listener
|
||
;; to .asset-picker and dispatches grid keys into *highlighted-index.
|
||
(keyboard-nav-controller
|
||
{:*focus-region *focus-region
|
||
:*highlighted-index *highlighted-index
|
||
:*input-ref *search-input-ref
|
||
:flat-items flat-items
|
||
:sections sections
|
||
:container-selector ".asset-picker"
|
||
:topbar-selector ".asset-picker-topbar [data-topbar-stop]"
|
||
:on-escape (fn []
|
||
(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))
|
||
;; Hover preview from a child sub-picker (Fallback → Icon…
|
||
;; broadcasts a fully-wrapped avatar via icon-search's
|
||
;; hover-wrap-fn). When the preview targets *this* page
|
||
;; and is itself an avatar, overlay it so the band's tile
|
||
;; mirrors what the page-title shows in lockstep.
|
||
hover-preview (state/sub :ui/icon-hover-preview)
|
||
;; Hover preview targets *this* picker when both the
|
||
;; entity and the property scope match. Without the
|
||
;; property gate, opening the Default Icon picker would
|
||
;; tint the page-title's separately-mounted asset-picker
|
||
;; tile (and vice versa).
|
||
hover-match? (and hover-preview
|
||
(icon-preview-matches? hover-preview preview-target-db-id property))
|
||
;; Two flavors of preview to consume:
|
||
;; 1. Full icon override — broadcast by tile hover/keyboard
|
||
;; nav in the fallback sub-picker (icon already wrapped
|
||
;; as an avatar via `hover-wrap-fn`).
|
||
;; 2. Color-only preview — broadcast by color-swatch
|
||
;; hover (no `:icon`, only `:color`). For these we
|
||
;; overlay the color onto the committed avatar so the
|
||
;; tile flashes the previewed tint without losing the
|
||
;; current shape/fallback.
|
||
hover-icon-override (when (and hover-match?
|
||
(= :avatar (get-in hover-preview [:icon :type])))
|
||
(:icon hover-preview))
|
||
hover-color (when hover-match?
|
||
(let [c (:color hover-preview)]
|
||
(when (and c (not (string/blank? c)) (not= c "inherit"))
|
||
c)))
|
||
;; Committed (hover-free) base: what the avatar would
|
||
;; render if no preview were active. Used as the input
|
||
;; to dropdown-row hover broadcasts so we don't feedback-
|
||
;; loop a hover-preview onto itself, and as the base for
|
||
;; the color-only overlay above.
|
||
;; Read the optimistic ::pending-icon mirror first so the
|
||
;; tile renders the just-committed value during the brief
|
||
;; window before the entity-write round-trip lands and
|
||
;; `current-icon` refreshes. Cleared in :will-remount.
|
||
committed-icon (or (when (and pending-icon (= :avatar (:type pending-icon)))
|
||
pending-icon)
|
||
(when (= :avatar (:type current-icon)) current-icon)
|
||
synthesized-avatar-context)
|
||
hover-icon (or hover-icon-override
|
||
(when hover-color
|
||
(-> committed-icon
|
||
(assoc-in [:data :color] hover-color)
|
||
(assoc-in [:data :backgroundColor] hover-color))))
|
||
preview-icon (or hover-icon committed-icon)
|
||
current-shape (or (get-in preview-icon [:data :shape]) :circle)
|
||
current-fb-type (or (get-in preview-icon [:data :fallback-type]) :letters)
|
||
current-fb-icon (get-in preview-icon [:data :fallback-icon])
|
||
set-shape! (fn [new-shape]
|
||
(on-chosen* nil
|
||
(assoc-in preview-icon [:data :shape] new-shape)
|
||
true))
|
||
;; All three commit fns clear `:ui/icon-hover-preview`
|
||
;; (so the asset-picker tile stops reading a stale
|
||
;; hover-icon and falls back to the freshly-committed
|
||
;; value) and close the Fallback menu chain via the
|
||
;; controlled `::fallback-menu-open?` atom. Closing the
|
||
;; parent menu Radix-cascades the close into the
|
||
;; sub-content too, so a tile-pick in the sub-picker
|
||
;; dismisses the entire menu — matching the standard
|
||
;; "click an item to commit and dismiss" pattern.
|
||
close-fallback-menu! (fn []
|
||
(dissoc-icon-preview-field! preview-base-target :icon)
|
||
(reset! (::fallback-menu-open? state) false))
|
||
set-fallback-letters! (fn []
|
||
(on-chosen* nil
|
||
(-> preview-icon
|
||
(assoc-in [:data :fallback-type] :letters)
|
||
(update :data dissoc :fallback-icon))
|
||
true)
|
||
(close-fallback-menu!))
|
||
set-fallback-icon! (fn [icon-name]
|
||
(on-chosen* nil
|
||
(-> preview-icon
|
||
(assoc-in [:data :fallback-type] :icon)
|
||
(assoc-in [:data :fallback-icon] icon-name))
|
||
true)
|
||
(close-fallback-menu!))
|
||
set-fallback-emoji! (fn [emoji-id]
|
||
(on-chosen* nil
|
||
(-> preview-icon
|
||
(assoc-in [:data :fallback-type] :emoji)
|
||
(assoc-in [:data :fallback-icon] emoji-id))
|
||
true)
|
||
(close-fallback-menu!))
|
||
;; Live preview while hovering Shape / Fallback dropdown
|
||
;; rows. Broadcast a synthetic avatar (committed config
|
||
;; with the hovered field changed) into
|
||
;; `:ui/icon-hover-preview` so the band's tile and the
|
||
;; page-title trigger render the previewed result without
|
||
;; committing it. Mouse-leave clears so the avatar
|
||
;; reverts to the committed state. Same channel the
|
||
;; fallback sub-picker uses on tile hover.
|
||
preview-shape-on-hover!
|
||
(fn [shape]
|
||
(when preview-target-db-id
|
||
(merge-into-icon-preview!
|
||
(assoc preview-base-target
|
||
:icon (assoc-in committed-icon [:data :shape] shape)))))
|
||
preview-fallback-on-hover!
|
||
(fn [fb-type]
|
||
(when preview-target-db-id
|
||
(let [base committed-icon
|
||
;; For Letters: drop fallback-icon so the avatar
|
||
;; shows initials. For Icon: prefer the user's
|
||
;; previously-picked icon (if any) so hovering
|
||
;; "Icon…" previews their actual choice; fall
|
||
;; back to a `circle-dashed` placeholder when
|
||
;; nothing has been picked yet, signalling
|
||
;; "an icon will go here".
|
||
next-icon (cond
|
||
(= fb-type :letters)
|
||
(-> base
|
||
(assoc-in [:data :fallback-type] :letters)
|
||
(update :data dissoc :fallback-icon))
|
||
|
||
(= fb-type :icon)
|
||
(-> base
|
||
(assoc-in [:data :fallback-type] :icon)
|
||
(assoc-in [:data :fallback-icon]
|
||
(or current-fb-icon "circle-dashed"))))]
|
||
(merge-into-icon-preview!
|
||
(assoc preview-base-target :icon next-icon)))))
|
||
clear-preview-on-leave!
|
||
(fn []
|
||
(when preview-target-db-id
|
||
(dissoc-icon-preview-field! preview-base-target :icon)))
|
||
reset-style! (fn []
|
||
;; Phase 1+2: Reset clears any divergence from
|
||
;; the system defaults — shape back to :circle
|
||
;; and fallback back to :letters (no icon).
|
||
(when (or (not= current-shape :circle)
|
||
(not= current-fb-type :letters))
|
||
(on-chosen* nil
|
||
(-> preview-icon
|
||
(assoc-in [:data :shape] :circle)
|
||
(assoc-in [:data :fallback-type] :letters)
|
||
(update :data dissoc :fallback-icon))
|
||
true)))
|
||
style-dirty? (or (not= current-shape :circle)
|
||
(not= current-fb-type :letters))
|
||
;; Resting-banner copy: scope on the left ("Default" /
|
||
;; "Custom"), style descriptor on the right ("Letters,
|
||
;; circle"). State-as-label per IA design — the avatar
|
||
;; tile renders the *visual*, the banner answers "where
|
||
;; does this come from?".
|
||
;; TODO future: detect class-default inheritance to
|
||
;; surface "From #Company" as a third scope value.
|
||
has-image? (some? (get-in preview-icon [:data :asset-uuid]))
|
||
;; Strip image asset data so the avatar tile always
|
||
;; renders the FALLBACK config (color + shape +
|
||
;; letters/icon/emoji) — even when an image is layered
|
||
;; on top in the resolved icon. The descriptor next to
|
||
;; the tile already says "Image, rectangle" so the
|
||
;; user knows the image is set; the picked asset is
|
||
;; visible in the grid below with its selected ring.
|
||
;; The customize-band's whole purpose is to edit the
|
||
;; fallback layer, so the tile previews exactly that
|
||
;; layer in lockstep with the controls — otherwise the
|
||
;; image hides every fallback edit and the controls
|
||
;; feel inert.
|
||
fallback-preview-icon (cond-> preview-icon
|
||
has-image? (update :data dissoc :asset-uuid :asset-type))
|
||
scope-label (if (or has-image? style-dirty?)
|
||
(t :icon.avatar-scope/custom)
|
||
(t :icon.avatar-scope/default))
|
||
descriptor-fb (cond
|
||
has-image? (t :icon.mode/image)
|
||
(and (= current-fb-type :icon) current-fb-icon)
|
||
(or (not-empty (humanize-icon-name current-fb-icon))
|
||
(t :icon.fallback/icon))
|
||
(and (= current-fb-type :emoji) current-fb-icon)
|
||
(or (not-empty
|
||
(humanize-icon-name
|
||
(string/replace current-fb-icon "_" "-")))
|
||
(t :icon.fallback/emoji))
|
||
:else (t :icon.fallback/letters))
|
||
descriptor-shape (case current-shape
|
||
:rounded-rect (t :icon.shape/rectangle-descriptor)
|
||
(t :icon.shape/circle-descriptor))
|
||
descriptor (str descriptor-fb ", " descriptor-shape)
|
||
;; Wrap-fn shared by the Icon… sub-menu's icon-search.
|
||
;; Each hovered tile broadcasts as an avatar with the
|
||
;; parent's shape/color/initials so page-icon readers
|
||
;; render the wrapped avatar during hover, not the bare
|
||
;; tile. Dispatches on tile :type so emojis also wrap
|
||
;; with `:fallback-type :emoji` rather than falling back
|
||
;; to letters.
|
||
fallback-hover-wrap-fn
|
||
(fn [item]
|
||
(let [glyph-id (or (get-in item [:data :value])
|
||
(:id item))
|
||
kind (cond
|
||
(= :emoji (:type item)) :emoji
|
||
(#{:icon :tabler-icon} (:type item)) :icon)]
|
||
(when (and glyph-id kind)
|
||
(-> preview-icon
|
||
(assoc-in [:data :fallback-type] kind)
|
||
(assoc-in [:data :fallback-icon] glyph-id)))))]
|
||
;; 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")}
|
||
;; Single click target spanning the resting row (avatar +
|
||
;; meta + Edit). Sits as an invisible overlay above
|
||
;; `.cb-content` so the avatar and banner text remain a
|
||
;; clean visual layer underneath while clicks anywhere on
|
||
;; the row hit one accessible-named <button>. Disabled
|
||
;; (pointer-events: none, opacity: 0) in the expanded
|
||
;; state so the dropdown chips inside `.cb-rows` are
|
||
;; reachable without being nested under a parent button.
|
||
;; aria-controls links to the expanded panel id below.
|
||
(when-not expanded?
|
||
[:button.cb-row-trigger
|
||
{:type "button"
|
||
:on-click #(swap! (::customize-expanded? state) not)
|
||
:aria-label (str scope-label " · " descriptor ". Customize avatar.")
|
||
:aria-expanded expanded?
|
||
:aria-controls "asset-picker-cb-rows"}])
|
||
[:div.cb-content
|
||
[:div.cb-avatar-trigger
|
||
{:aria-hidden "true"}
|
||
[:div.preview-avatar
|
||
(icon fallback-preview-icon {:size 56})]]
|
||
[:div.cb-meta-stage
|
||
;; Resting state: visible banner content. Click target
|
||
;; lives on `.cb-row-trigger` above (covers this entire
|
||
;; row). `.cb-banner` is now a presentation-only div —
|
||
;; not interactive, no aria-expanded — so the row reads
|
||
;; as one button to assistive tech.
|
||
[:div.cb-banner
|
||
{:aria-hidden "true"}
|
||
[:div.banner-text
|
||
[:span.banner-scope scope-label]
|
||
[:span.banner-sep "·"]
|
||
[:span.banner-descriptor descriptor]]
|
||
[:span.banner-edit (t :icon.avatar-band/edit)]]
|
||
[:div.cb-rows
|
||
{:id "asset-picker-cb-rows"
|
||
:role "region"
|
||
:aria-label (t :icon.avatar-band/region-aria-label)}
|
||
[:div.cb-row
|
||
[:span.cb-label (t :icon.avatar-band/shape-label)]
|
||
(shui/dropdown-menu
|
||
(shui/dropdown-menu-trigger
|
||
{:as-child true}
|
||
[:button.cb-chip
|
||
{:type "button"
|
||
:data-topbar-stop "shape"
|
||
:aria-label (t :icon.avatar-band/shape-aria-label)}
|
||
[: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 (t :icon.shape/rectangle)
|
||
(t :icon.shape/circle))]
|
||
(shui/tabler-icon "chevron-down" {:size 11 :class "cb-chip-chevron"})])
|
||
(shui/dropdown-menu-content
|
||
{:align "end"
|
||
;; Whole-content mouse-leave clears the preview so
|
||
;; closing the dropdown without picking reverts the
|
||
;; avatar to its committed state. Per-item leave
|
||
;; would briefly flash baseline as the cursor moves
|
||
;; between rows.
|
||
:on-mouse-leave clear-preview-on-leave!}
|
||
;; `:on-focus` (not `:on-mouse-enter`) — Radix
|
||
;; DropdownMenuItem programmatically focuses the
|
||
;; highlighted item via `pointermove → item.focus()`
|
||
;; (and arrow-key nav does the same), so onFocus
|
||
;; fires reliably for both keyboard and mouse. Plain
|
||
;; mouseenter is suppressed for the item already
|
||
;; under the cursor at open time, which made the
|
||
;; first hover silently no-op.
|
||
;; Leading tabler icons follow the codebase's canonical
|
||
;; dropdown-menu-item pattern (see deps/shui/src/logseq/
|
||
;; shui/demo.cljs:18-50): tabler icon as the first
|
||
;; child with `scale-90 pr-1 opacity-80` so the icon
|
||
;; reads slightly smaller and dimmer than the label.
|
||
(shui/dropdown-menu-item
|
||
{:on-click #(set-shape! :circle)
|
||
:on-focus #(preview-shape-on-hover! :circle)}
|
||
(shui/tabler-icon "circle" {:class "scale-90 pr-1 opacity-80"})
|
||
(t :icon.shape/circle))
|
||
(shui/dropdown-menu-item
|
||
{:on-click #(set-shape! :rounded-rect)
|
||
:on-focus #(preview-shape-on-hover! :rounded-rect)}
|
||
(shui/tabler-icon "square-rounded" {:class "scale-90 pr-1 opacity-80"})
|
||
(t :icon.shape/rectangle))))]
|
||
[:div.cb-row
|
||
[:span.cb-label (t :icon.avatar-band/fallback-label)]
|
||
(shui/dropdown-menu
|
||
;; Controlled open state — see `::fallback-menu-open?`
|
||
;; comment above. The map of props goes as the first
|
||
;; arg to dropdown-menu, before the trigger/content
|
||
;; children.
|
||
{:open @(::fallback-menu-open? state)
|
||
:on-open-change #(reset! (::fallback-menu-open? state) %)}
|
||
(shui/dropdown-menu-trigger
|
||
{:as-child true}
|
||
[:button.cb-chip
|
||
{:type "button"
|
||
:data-topbar-stop "fallback"
|
||
:aria-label (t :icon.avatar-band/fallback-aria-label)}
|
||
;; Glyph reflects current fallback. Letters → "Aa";
|
||
;; Icon → the actual chosen tabler icon; Emoji →
|
||
;; the chosen emoji glyph. All sized at 11px for
|
||
;; an at-a-glance match against the rendered avatar.
|
||
[:span.cb-chip-glyph
|
||
(cond
|
||
(and (= current-fb-type :icon) current-fb-icon)
|
||
(shui/tabler-icon current-fb-icon {:size 11})
|
||
|
||
(and (= current-fb-type :emoji) current-fb-icon)
|
||
[:em-emoji {:id current-fb-icon
|
||
:size 11
|
||
:style {:line-height 1}}]
|
||
|
||
:else
|
||
[:span.glyph-letters (t :icon.avatar-fallback/letters-glyph)])]
|
||
[:span.cb-chip-label
|
||
(cond
|
||
(and (= current-fb-type :icon) current-fb-icon)
|
||
(or (not-empty (humanize-icon-name current-fb-icon))
|
||
(t :icon.fallback/icon))
|
||
|
||
(and (= current-fb-type :emoji) current-fb-icon)
|
||
;; Emoji shortcodes are typically `snake_case`;
|
||
;; humanize-icon-name handles dashes but not
|
||
;; underscores — replace underscores with spaces
|
||
;; first so `white_check_mark` reads as "White
|
||
;; check mark", not "White_check_mark".
|
||
(or (not-empty
|
||
(humanize-icon-name
|
||
(string/replace current-fb-icon "_" "-")))
|
||
(t :icon.fallback/emoji))
|
||
|
||
:else
|
||
(t :icon.fallback/letters))]
|
||
(shui/tabler-icon "chevron-down" {:size 11 :class "cb-chip-chevron"})])
|
||
(shui/dropdown-menu-content
|
||
{:align "end"
|
||
:on-mouse-leave clear-preview-on-leave!}
|
||
;; Same canonical pattern as Shape — tabler icon
|
||
;; first, label after. `letter-case` is the closest
|
||
;; tabler glyph to the chip's "Aa" cue. `circle-dashed`
|
||
;; on the sub-trigger matches the placeholder we
|
||
;; broadcast on hover when no fallback-icon has been
|
||
;; committed yet, so menu and live-preview agree.
|
||
(shui/dropdown-menu-item
|
||
{:on-click set-fallback-letters!
|
||
:on-focus #(preview-fallback-on-hover! :letters)}
|
||
(shui/tabler-icon "letter-case" {:class "scale-90 pr-1 opacity-80"})
|
||
(t :icon.fallback/letters))
|
||
;; Sub-menu pattern (matches content.cljs's "Add reaction"
|
||
;; → emoji picker): "Icon…" expands to the side instead
|
||
;; of dismissing the parent menu and re-opening a popup
|
||
;; underneath. Keeps the user's mental thread on
|
||
;; "configuring the fallback" instead of dropping them
|
||
;; into a context-less floater.
|
||
(shui/dropdown-menu-sub
|
||
(shui/dropdown-menu-sub-trigger
|
||
{:on-focus #(preview-fallback-on-hover! :icon)}
|
||
(shui/tabler-icon "circle-dashed" {:class "scale-90 pr-1 opacity-80"})
|
||
(t :icon.fallback/icon-submenu))
|
||
;; `dropdown-menu-sub-content` ships with `p-1` baked
|
||
;; into shui's popup-core defaults, AND content.cljs's
|
||
;; "Add reaction" pattern wraps its picker in another
|
||
;; `[:div.p-1]` — stacking the two yields 8px of total
|
||
;; gutter. Override the outer one to 0 with `!p-0`
|
||
;; (the popup's own border/shadow already separates
|
||
;; the surface from the page) and keep the inner
|
||
;; `[:div.p-1]` so the picker's chrome doesn't read
|
||
;; cramped against the rounded popup edge.
|
||
(shui/dropdown-menu-sub-content
|
||
{:class "!p-0"
|
||
;; Radix's FocusScope runs its auto-focus pass
|
||
;; *synchronously* inside `onOpenAutoFocus`. Calling
|
||
;; `preventDefault` alone leaves focus on whatever
|
||
;; the browser's autoFocus pass picked (a tab
|
||
;; button — the first focusable in our DOM order),
|
||
;; not the search input. The cleanest fix is to
|
||
;; ride Radix's own focus-management window:
|
||
;; preventDefault to stop the container auto-aim,
|
||
;; then synchronously focus the search input from
|
||
;; the same handler. We're still inside FocusScope's
|
||
;; mount tick, so this lands as the canonical
|
||
;; initial-focus and no later pass overrides it.
|
||
;; (Researched in Radix's react-focus-scope source —
|
||
;; `focusFirst` runs inline, no internal setTimeout
|
||
;; or rAF, so a `setTimeout 0` from a `:did-mount`
|
||
;; can't reliably win the race.)
|
||
:onOpenAutoFocus (fn [^js e]
|
||
(.preventDefault e)
|
||
(when-let [^js content (.-currentTarget e)]
|
||
(when-let [^js input (.querySelector content
|
||
"input.icon-search-input")]
|
||
(.focus input))))}
|
||
[:div.p-1
|
||
(icon-search
|
||
{:on-chosen (fn [_e icon & _rest]
|
||
;; Dispatch on the tile's :type so
|
||
;; emojis route to set-fallback-emoji!
|
||
;; (which writes :fallback-type :emoji)
|
||
;; and tabler icons route to the
|
||
;; existing :icon path.
|
||
(let [glyph-id (or (get-in icon [:data :value])
|
||
(:id icon))]
|
||
(cond
|
||
(and (= :emoji (:type icon)) glyph-id)
|
||
(set-fallback-emoji! glyph-id)
|
||
|
||
(and (#{:icon :tabler-icon} (:type icon)) glyph-id)
|
||
(set-fallback-icon! glyph-id))))
|
||
;; "All" lets the user start typing and search
|
||
;; emojis + icons together — common case where
|
||
;; the user knows the keyword (e.g. "smile") but
|
||
;; doesn't care which kind of glyph it lands on.
|
||
;; Non-icon/non-emoji tiles inside the All tab
|
||
;; (text/avatar/image) are silently ignored by
|
||
;; the on-chosen dispatch above — they don't
|
||
;; map to a meaningful avatar fallback.
|
||
:allowed-tabs [:all :icon :emoji]
|
||
;; Suppress image-asset search results in this sub-picker —
|
||
;; a fallback icon for an avatar must be an icon or emoji,
|
||
;; not a photo. Without this, typing in the search input
|
||
;; would surface user image assets as picks that, when
|
||
;; selected, would write nonsensical avatar fallbacks.
|
||
:no-assets? true
|
||
:icon-value (when (and (#{:icon :emoji} current-fb-type) current-fb-icon)
|
||
{:type (if (= :emoji current-fb-type) :emoji :icon)
|
||
:data {:value current-fb-icon}})
|
||
;; Mirror the parent avatar's color into the sub-
|
||
;; picker so the icon grid tints match the avatar
|
||
;; the user is configuring. `@*color` is the
|
||
;; *in-flight* color from the asset-picker's color
|
||
;; swatch (set the moment the user picks a swatch,
|
||
;; before any commit hits the avatar's `:data`).
|
||
;; Falls back to the avatar's stored color and
|
||
;; lets icon-search's own preset/storage logic win
|
||
;; only if both are blank.
|
||
:initial-color (or (some-> *color deref
|
||
(#(when (and % (not= % "inherit")) %)))
|
||
(get-in preview-icon [:data :color]))
|
||
:page-title page-title
|
||
:preview-target-db-id preview-target-db-id
|
||
:hover-wrap-fn fallback-hover-wrap-fn
|
||
;; Propagate the asset-picker's property scope to
|
||
;; the sub-picker so its hover broadcasts go to
|
||
;; the same surface (e.g. when editing the class
|
||
;; default-icon, the sub-picker's icon-grid hovers
|
||
;; reach only the Default Icon field, not the
|
||
;; page-title icon).
|
||
:property property
|
||
;; Suppress the picker's own color swatch and
|
||
;; delete button — the parent asset-picker
|
||
;; already owns both for the whole avatar, and
|
||
;; duplicates here can drift / cause bad states
|
||
;; (e.g. deleting the avatar from a sub-picker
|
||
;; that's only configuring its fallback).
|
||
:color-btn? false
|
||
:del-btn? false})]))))]]]]
|
||
[:div.cb-rail-wrap
|
||
[:div.cb-rail
|
||
[:button.lx-toolbar-action.lx-toolbar-reset-link
|
||
{:type "button"
|
||
:on-click reset-style!
|
||
:data-topbar-stop "reset"
|
||
:disabled (not style-dirty?)
|
||
:aria-label (t :icon.avatar-band/reset-aria-label)
|
||
:tab-index (if expanded? 0 -1)}
|
||
(shui/tabler-icon "rotate" {:size 12})
|
||
[:span (t :ui/reset)]]
|
||
[:button.lx-toolbar-action.cb-done
|
||
{:type "button"
|
||
:on-click #(reset! (::customize-expanded? state) false)
|
||
:data-topbar-stop "done"
|
||
:aria-label (t :icon.avatar-band/close-aria-label)
|
||
:tab-index (if expanded? 0 -1)}
|
||
[:span (t :icon.avatar-band/done-button)]]]]]))
|
||
|
||
;; "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
|
||
(section-header {:title "Recently used"
|
||
:count recently-used-count
|
||
:expanded? recently-used-expanded?
|
||
:on-toggle #(swap! *section-states update "Recently used" (fn [v] (if (nil? v) false (not v))))})
|
||
(when recently-used-expanded?
|
||
[:div.asset-picker-grid.recently-used-row
|
||
{:class (when avatar-mode? "avatar-mode")}
|
||
(for [asset recently-used-row
|
||
:let [item-id (str "recent-" (:block/uuid asset))]]
|
||
(rum/with-key
|
||
(image-asset-item asset {:on-chosen on-chosen
|
||
:avatar-context effective-avatar-context
|
||
:selected? (= (str (:block/uuid asset)) current-asset-uuid)
|
||
:item-id item-id
|
||
:highlighted? (= highlighted-id item-id)
|
||
:ghost-highlighted? (= ghost-highlighted-id item-id)})
|
||
item-id))])])
|
||
|
||
;; "Web images" section - Wikipedia Commons images
|
||
(when-not (string/blank? effective-web-query)
|
||
(web-images-section
|
||
{:query effective-web-query
|
||
;; True on the first keystroke, before the 500ms debounce has
|
||
;; caught web-query up to search-q. Lets the child switch to
|
||
;; skeletons immediately instead of waiting for the debounce.
|
||
:user-typing? (and (not (string/blank? search-q))
|
||
(not= search-q web-query))
|
||
:avatar-context effective-avatar-context
|
||
:on-select handle-web-image-select
|
||
:*result-sink *web-images-result
|
||
:highlighted-id highlighted-id
|
||
:ghost-highlighted-id ghost-highlighted-id
|
||
:saved-source-urls saved-source-urls}))
|
||
|
||
;; "Available assets" section — header is hidden when there are no
|
||
;; assets at all (the action rows below communicate the zero state on
|
||
;; their own). The header reappears as soon as the user has assets,
|
||
;; including during search, where "· 0" conveys "no matches".
|
||
[:div.pane-section
|
||
(when (seq assets)
|
||
(section-header {:title "Available assets"
|
||
:count asset-count
|
||
:expanded? available-expanded?
|
||
:on-toggle #(swap! *section-states update "Available assets" (fn [v] (if (nil? v) false (not v))))}))
|
||
|
||
;; Asset grid. While loading we render an empty grid; the web-image
|
||
;; skeletons + sync-asset placeholder cover the brief gap, so a
|
||
;; second spinner here would just add noise.
|
||
(when available-expanded?
|
||
[:div.asset-picker-grid
|
||
{:class (when avatar-mode? "avatar-mode")}
|
||
(cond
|
||
loading?
|
||
nil
|
||
|
||
(seq filtered-assets)
|
||
(for [asset filtered-assets
|
||
:let [item-id (str "asset-" (:block/uuid asset))]]
|
||
(rum/with-key
|
||
(image-asset-item asset {:on-chosen on-chosen
|
||
:avatar-context effective-avatar-context
|
||
:selected? (= (str (:block/uuid asset)) current-asset-uuid)
|
||
:item-id item-id
|
||
:highlighted? (= highlighted-id item-id)
|
||
:ghost-highlighted? (= ghost-highlighted-id item-id)})
|
||
item-id))
|
||
|
||
:else
|
||
(if (and (seq assets) (not (string/blank? search-q)))
|
||
;; Search returned no results
|
||
[:div.asset-picker-empty
|
||
(shui/tabler-icon "search-off" {:size 32})
|
||
[:span.text-sm (t :icon.asset-search/empty)]]
|
||
;; No assets uploaded yet — show action rows instead of a placeholder
|
||
[:div.asset-picker-empty-actions
|
||
(when clipboard-supported?
|
||
[:button.asset-picker-empty-row
|
||
{:type "button"
|
||
:data-item-id "clipboard-row"
|
||
:class (util/classnames
|
||
[{:is-highlighted (= highlighted-id "clipboard-row")
|
||
:is-ghost-highlighted (= ghost-highlighted-id "clipboard-row")}])
|
||
:on-click (fn [_] (handle-clipboard-paste))}
|
||
[:div.row-icon (shui/tabler-icon "clipboard" {:size 22})]
|
||
[:div.row-body
|
||
[:div.row-title (t :icon.asset/paste-from-clipboard-title)]
|
||
[:div.row-subtitle (t :icon.asset/paste-from-clipboard-desc)]]
|
||
[:div.row-shortcut
|
||
(shui/shortcut "mod+v" {:style :combo})]])
|
||
|
||
;; Upload row: keyboard-nav clicks the button via data-item-id,
|
||
;; which propagates to the <label for> and fires the hidden input.
|
||
[:label.asset-picker-empty-row
|
||
{:for "asset-upload-input"
|
||
:role "button"
|
||
:data-item-id "upload-row"
|
||
:tab-index 0
|
||
:class (util/classnames
|
||
[{:is-highlighted (= highlighted-id "upload-row")
|
||
:is-ghost-highlighted (= ghost-highlighted-id "upload-row")}])}
|
||
[:div.row-icon (shui/tabler-icon "folder" {:size 22})]
|
||
[:div.row-body
|
||
[:div.row-title (t :icon.asset/add-from-computer-title)]
|
||
[:div.row-subtitle (t :icon.asset/add-from-computer-desc)]]
|
||
[:div.row-chevron (shui/tabler-icon "chevron-right" {:size 16})]]]))])]])
|
||
|
||
;; Hidden file input lives at the top level so both the empty-state
|
||
;; "Add from your computer" row and the footer-hint "browse" link can
|
||
;; reference it via <label for="asset-upload-input">.
|
||
[:input#asset-upload-input.hidden
|
||
{:type "file"
|
||
:accept "image/*"
|
||
:multiple true
|
||
:on-change (fn [e]
|
||
(let [files (array-seq (.-files (.-target e)))]
|
||
(handle-upload files)))}]
|
||
|
||
;; Footer hint — only when we have assets or are loading. Zero-state
|
||
;; replaces this bar with the empty-state rows above.
|
||
(when (or loading? (seq @*loaded-assets))
|
||
[:div.asset-picker-footer-hint
|
||
[:span.tip-label (t :icon.asset/tip-label)]
|
||
[:span.tip-body
|
||
(if (util/mobile?)
|
||
;; Phone: every verb is a real control. iOS Safari won't deliver
|
||
;; paste events to the popover root reliably, so paste-a-link is
|
||
;; a button that calls handle-clipboard-paste directly (mirrors
|
||
;; the empty-state clipboard-row).
|
||
(i18n/interpolate-rich-text-node
|
||
(t :icon.asset/tip-mobile)
|
||
[[:button.tip-link
|
||
{:type "button"
|
||
:on-click (fn [_] (handle-clipboard-paste))}
|
||
(t :icon.asset/tip-link-paste)]
|
||
[:label.tip-link {:for "asset-upload-input" :tab-index 0}
|
||
(t :icon.asset/tip-link-browse)]
|
||
[:label.tip-link {:tab-index 0}
|
||
[:input.hidden
|
||
{:type "file"
|
||
:accept "image/*"
|
||
:capture "environment"
|
||
:on-change (fn [e]
|
||
(let [files (array-seq (.. e -target -files))]
|
||
(handle-upload files)))}]
|
||
(t :icon.asset/tip-link-take-picture)]])
|
||
;; Desktop / iPad: passive hint. Drop + paste rely on the
|
||
;; existing global handlers attached to the picker root.
|
||
[:<>
|
||
(i18n/interpolate-rich-text-node
|
||
(t :icon.asset/tip-desktop)
|
||
[[:label.tip-link {:for "asset-upload-input" :tab-index 0}
|
||
(t :icon.asset/tip-link-browse)]])
|
||
" "
|
||
[:span.tip-sep "·"]
|
||
" "
|
||
(shui/shortcut "mod+v" {:style :combo})])]])]))
|
||
|
||
(defn open-image-asset-picker!
|
||
"Opens the asset picker popup for selecting an image icon.
|
||
Used for clickable placeholders and error states in image icons."
|
||
[^js e page-id page-title current-icon]
|
||
(shui/popup-show!
|
||
(.-target e)
|
||
(fn [{:keys [id]}]
|
||
(asset-picker
|
||
{:on-chosen (fn [_e icon-data & [keep-popup?]]
|
||
(when icon-data
|
||
(property-handler/set-block-property!
|
||
page-id :logseq.property/icon icon-data))
|
||
(when-not keep-popup?
|
||
(shui/popup-hide! id)))
|
||
:on-back #(shui/popup-hide! id)
|
||
:on-delete nil
|
||
:del-btn? false
|
||
:current-icon current-icon
|
||
:page-title page-title}))
|
||
{:align :start
|
||
:content-props {:class "ls-icon-picker"
|
||
:onEscapeKeyDown #(.preventDefault %)}}))
|
||
|
||
(rum/defc all-cp < rum/reactive
|
||
[opts]
|
||
(let [used-items (->> (get-used-items)
|
||
;; Drop context-dependent types: :text and :avatar derive
|
||
;; per-block values (initials etc.) so cross-page recall
|
||
;; is meaningless. :image is dropped because the asset
|
||
;; may have been deleted/moved (photo-off tiles), and
|
||
;; the asset-picker already has its own "Recently used
|
||
;; assets" row that's the natural home for image recall.
|
||
(remove #(#{:text :avatar :image} (:type %))))
|
||
emoji-items (->> (take 32 emojis)
|
||
(map (fn [emoji]
|
||
{:type :emoji
|
||
:id (:id emoji)
|
||
:label (or (:name emoji) (:id emoji))
|
||
:data {:value (:id emoji)}})))
|
||
icon-items (->> (take 48 (get-tabler-icons))
|
||
(map (fn [icon-name]
|
||
{:type :icon
|
||
:id (str "icon-" icon-name)
|
||
:label icon-name
|
||
:data {:value icon-name}})))
|
||
opts (assoc opts :virtual-list? false)
|
||
;; Read section states reactively
|
||
section-states (rum/react *section-states)
|
||
;; Scope highlights to only the active section (prevents duplicate highlighting)
|
||
scope-opts (fn [section-label o]
|
||
(cond-> o
|
||
(not= section-label (:highlighted-section o))
|
||
(dissoc :highlighted-id)
|
||
(not= section-label (:ghost-highlighted-section o))
|
||
(dissoc :ghost-highlighted-id)))]
|
||
[:div.all-pane.pb-2
|
||
;; Recently used - collapsible
|
||
(when (seq used-items)
|
||
(pane-section "Recently used" used-items
|
||
(assoc (scope-opts "Recently used" opts)
|
||
:collapsible? true
|
||
:keyboard-hint "alt mod 1"
|
||
:expanded? (get section-states "Recently used" true))))
|
||
|
||
;; Emojis - collapsible
|
||
(pane-section "Emojis"
|
||
emoji-items
|
||
(assoc (scope-opts "Emojis" opts)
|
||
:collapsible? true
|
||
:keyboard-hint "alt mod 2"
|
||
:total-count (count emojis)
|
||
:expanded? (get section-states "Emojis" true)))
|
||
|
||
;; Icons - collapsible
|
||
(pane-section "Icons"
|
||
icon-items
|
||
(assoc (scope-opts "Icons" opts)
|
||
:collapsible? true
|
||
:keyboard-hint "alt mod 3"
|
||
:total-count (count (get-tabler-icons))
|
||
:expanded? (get section-states "Icons" true)))]))
|
||
|
||
(rum/defc tab-observer
|
||
"Re-runs the search when tab changes (if there's a query), preserving the search text."
|
||
[tab {:keys [q *result assets no-assets?]}]
|
||
(hooks/use-effect!
|
||
(fn []
|
||
;; Re-run search with existing query for new tab context
|
||
(when-not (string/blank? q)
|
||
(p/let [result (search q tab assets {:no-assets? no-assets?})]
|
||
(reset! *result result))))
|
||
[tab q])
|
||
nil)
|
||
|
||
;; ============================================================
|
||
;; Keyboard navigation system
|
||
;; Three tab stops: :tabs -> :search -> :grid
|
||
;; Uses index-based highlighting with data attributes (no DOM focus on grid items)
|
||
;; ============================================================
|
||
|
||
(defn- compute-flat-items
|
||
"Compute the flat navigable item list and section metadata for the current view.
|
||
Returns {:items [icon-item ...] :sections [{:start N :count N :cols N} ...]}."
|
||
[tab result section-states & [{:keys [show-used?]}]]
|
||
(let [build-sections (fn [& groups]
|
||
(loop [gs groups offset 0 items [] sections []]
|
||
(if-let [g (first gs)]
|
||
(let [its (vec (or (:items g) []))
|
||
c (count its)]
|
||
(if (pos? c)
|
||
(recur (rest gs) (+ offset c)
|
||
(into items its)
|
||
(conj sections {:start offset :count c :cols (:cols g) :label (:label g)}))
|
||
(recur (rest gs) offset items sections)))
|
||
{:items items :sections sections})))]
|
||
(cond
|
||
;; Custom tab always shows its 3 buttons (search doesn't apply — Custom
|
||
;; is for entering a *new* text/avatar/image, not searching presets).
|
||
(= tab :custom)
|
||
{:items [{:type :custom-text :id "custom-text"}
|
||
{:type :custom-avatar :id "custom-avatar"}
|
||
{:type :custom-image :id "custom-image"}]
|
||
:sections [{:start 0 :count 3 :cols custom-tab-cols}]}
|
||
|
||
;; Search results active. Tabs are content-type categories — keep the
|
||
;; query persistent across tabs but only show matches that fit the
|
||
;; current tab's type. :all shows everything, :emoji only emoji matches,
|
||
;; :icon only icon matches, Assets section only on :all (since assets
|
||
;; aren't a category the icon/emoji tabs are scoped to).
|
||
(seq result)
|
||
(let [tab-allows-emojis? (contains? #{:all :emoji} tab)
|
||
tab-allows-icons? (contains? #{:all :icon} tab)
|
||
tab-allows-assets? (= :all tab)]
|
||
(build-sections
|
||
{:label "Emojis"
|
||
:items (when (and tab-allows-emojis?
|
||
(seq (:emojis result))
|
||
(get section-states "Emojis" true))
|
||
(:emojis result))
|
||
:cols icon-grid-cols}
|
||
{:label "Icons"
|
||
:items (when (and tab-allows-icons?
|
||
(seq (:icons result))
|
||
(get section-states "Icons" true))
|
||
(:icons result))
|
||
:cols icon-grid-cols}
|
||
{:label "Assets"
|
||
:items (when (and tab-allows-assets?
|
||
(seq (:assets result))
|
||
(get section-states "Assets" true))
|
||
(:assets result))
|
||
:cols asset-search-grid-cols}))
|
||
|
||
;; All tab: recently used + emojis + icons (non-virtualized, limited items)
|
||
(= tab :all)
|
||
(build-sections
|
||
{:label "Recently used"
|
||
:items (when (get section-states "Recently used" true)
|
||
(->> (get-used-items)
|
||
;; Drop context-dependent types: :text and :avatar derive
|
||
;; per-block values (initials etc.) so cross-page recall
|
||
;; is meaningless. :image is dropped because the asset
|
||
;; may have been deleted/moved (photo-off tiles), and
|
||
;; the asset-picker already has its own "Recently used
|
||
;; assets" row that's the natural home for image recall.
|
||
(remove #(#{:text :avatar :image} (:type %)))))
|
||
:cols icon-grid-cols}
|
||
{:label "Emojis"
|
||
:items (when (get section-states "Emojis" true)
|
||
(->> (take 32 emojis)
|
||
(map (fn [emoji]
|
||
{:type :emoji :id (:id emoji)
|
||
:label (or (:name emoji) (:id emoji))
|
||
:data {:value (:id emoji)}}))))
|
||
:cols icon-grid-cols}
|
||
{:label "Icons"
|
||
:items (when (get section-states "Icons" true)
|
||
(->> (take 48 (get-tabler-icons))
|
||
(map (fn [icon-name]
|
||
{:type :icon :id (str "icon-" icon-name)
|
||
:label icon-name :data {:value icon-name}}))))
|
||
:cols icon-grid-cols})
|
||
|
||
;; Emojis tab: full emoji list, optionally preceded by recently-used
|
||
;; emojis when :show-used? is true (reaction-picker context).
|
||
(= tab :emoji)
|
||
(build-sections
|
||
(when show-used?
|
||
{:label "Recently used"
|
||
:items (when (get section-states "Recently used" true)
|
||
(->> (get-used-items)
|
||
(filterv #(= :emoji (:type %)))))
|
||
:cols icon-grid-cols})
|
||
{:label "Emojis"
|
||
:items (when (get section-states "Emojis" true)
|
||
(mapv (fn [emoji]
|
||
{:type :emoji :id (:id emoji)
|
||
:label (or (:name emoji) (:id emoji))
|
||
:data {:value (:id emoji)}})
|
||
emojis))
|
||
:cols icon-grid-cols})
|
||
|
||
;; Icons tab: full icon list
|
||
(= tab :icon)
|
||
(let [items (vec (map (fn [icon-name]
|
||
{:type :icon :id (str "icon-" icon-name)
|
||
:label icon-name :data {:value icon-name}})
|
||
(get-tabler-icons)))]
|
||
{:items items :sections [{:start 0 :count (count items) :cols icon-grid-cols}]})
|
||
|
||
:else {:items [] :sections []})))
|
||
|
||
(defn- section-for-index
|
||
"Find which section index contains the given flat index."
|
||
[idx sections]
|
||
(some (fn [[si sec]]
|
||
(when (and (>= idx (:start sec))
|
||
(< idx (+ (:start sec) (:count sec))))
|
||
si))
|
||
(map-indexed vector sections)))
|
||
|
||
(defn- move-grid-highlight
|
||
"Section-aware 2D grid navigation.
|
||
Returns new index, or nil to signal 'move to search'."
|
||
[current-index direction sections]
|
||
(when (and (seq sections) (some? current-index))
|
||
(let [total (+ (:start (last sections)) (:count (last sections)))
|
||
si (section-for-index current-index sections)]
|
||
(when si
|
||
(let [sec (nth sections si)
|
||
local-idx (- current-index (:start sec))
|
||
cols (:cols sec)
|
||
row (quot local-idx cols)
|
||
col (rem local-idx cols)
|
||
n-rows (js/Math.ceil (/ (:count sec) cols))
|
||
next-sec (fn [i] (when (< (inc i) (count sections)) (inc i)))
|
||
prev-sec (fn [i] (when (pos? i) (dec i)))]
|
||
(case direction
|
||
:down
|
||
(let [next-row (inc row)]
|
||
(if (< next-row n-rows)
|
||
;; Next row in this section (clamp to last item for partial rows)
|
||
(min (+ (:start sec) (* next-row cols) col)
|
||
(+ (:start sec) (dec (:count sec))))
|
||
;; Jump to next section, same column clamped
|
||
(when-let [nsi (next-sec si)]
|
||
(let [nsec (nth sections nsi)
|
||
target-col (min col (dec (min (:cols nsec) (:count nsec))))]
|
||
(+ (:start nsec) target-col)))))
|
||
|
||
:up
|
||
(if (pos? row)
|
||
;; Previous row in this section
|
||
(+ (:start sec) (* (dec row) cols) col)
|
||
;; Jump to previous section's last row, same column clamped
|
||
(when-let [psi (prev-sec si)]
|
||
(let [psec (nth sections psi)
|
||
pcols (:cols psec)
|
||
last-row (dec (js/Math.ceil (/ (:count psec) pcols)))
|
||
candidate (+ (:start psec) (* last-row pcols) col)
|
||
max-idx (+ (:start psec) (dec (:count psec)))]
|
||
(min candidate max-idx))))
|
||
|
||
:right
|
||
(let [next-idx (inc current-index)
|
||
sec-end (+ (:start sec) (:count sec))]
|
||
(if (< next-idx sec-end)
|
||
next-idx
|
||
(when-let [nsi (next-sec si)]
|
||
(:start (nth sections nsi)))))
|
||
|
||
:left
|
||
(if (> current-index (:start sec))
|
||
(dec current-index)
|
||
(when-let [psi (prev-sec si)]
|
||
(let [psec (nth sections psi)]
|
||
(+ (:start psec) (dec (:count psec))))))
|
||
|
||
:home 0
|
||
:end (dec total)
|
||
nil))))))
|
||
|
||
(defn- tab-items
|
||
"Returns the ordered tab IDs for keyboard navigation."
|
||
[]
|
||
[:all :emoji :icon :custom])
|
||
|
||
(rum/defc keyboard-nav-controller
|
||
"Unified keyboard navigation controller for picker-style popovers.
|
||
Manages three tab stops: :tabs, :search, :grid.
|
||
Highlighting is React-props-driven (no DOM attribute manipulation).
|
||
|
||
Options:
|
||
:*focus-region — atom holding :search | :grid | :tabs | nil
|
||
:*highlighted-index — atom holding flat index into :flat-items (or nil)
|
||
:*input-ref — ref to the search input
|
||
:flat-items — flat seq of items with stable :id each
|
||
:sections — seq of {:start :count :cols :label} maps
|
||
:*virtuoso-ref — optional virtuoso scroll-container ref
|
||
:*tab — optional tab atom (icon-picker only; enables ⌥⌘1/2/3
|
||
section-collapse and the :tabs-region rove)
|
||
:container-selector — CSS selector of the scoping root (default
|
||
`.cp__emoji-icon-picker`). Tile lookups and the
|
||
keydown listener are scoped to this ancestor.
|
||
:on-escape — called for Escape in the :tabs region (default
|
||
`shui/popup-hide!`)
|
||
:topbar-selector — optional CSS selector for a heterogeneous toolbar
|
||
(e.g. `.asset-picker-topbar [data-topbar-stop]`).
|
||
When set, the controller honors a `:topbar`
|
||
focus-region: ArrowLeft/Right rove DOM focus across
|
||
the matched elements, Enter clicks the focused one,
|
||
ArrowDown/Tab/Escape return to search, Shift+Tab
|
||
jumps to the grid (if any)."
|
||
[{:keys [*focus-region *highlighted-index *tab *input-ref flat-items sections
|
||
*virtuoso-ref container-selector on-escape topbar-selector]
|
||
:or {container-selector ".cp__emoji-icon-picker"
|
||
on-escape shui/popup-hide!}}]
|
||
(let [*el-ref (rum/use-ref nil)
|
||
get-cnt #(some-> (rum/deref *el-ref) (.closest container-selector))
|
||
|
||
focus-search! (fn []
|
||
(reset! *focus-region :search)
|
||
(reset! *highlighted-index nil)
|
||
(some-> (rum/deref *input-ref) (.focus)))
|
||
|
||
focus-grid! (fn [idx]
|
||
(let [idx (or idx 0)
|
||
idx (min idx (max 0 (dec (count flat-items))))]
|
||
(reset! *focus-region :grid)
|
||
(reset! *highlighted-index idx)
|
||
;; Move DOM focus to the new tile so activeElement
|
||
;; matches `.is-highlighted` — keeps the WAI-APG
|
||
;; roving-focus pattern (single visible ring on the
|
||
;; current tile) and ensures Enter/Space target the
|
||
;; right button. data-item-id is rendered on every
|
||
;; tile regardless of highlight state, so the lookup
|
||
;; works against the current DOM without waiting for
|
||
;; a re-render.
|
||
(when-let [cnt (get-cnt)]
|
||
(when (< idx (count flat-items))
|
||
(let [item-id (:id (nth flat-items idx))]
|
||
(when-let [^js btn (.querySelector cnt (str "[data-item-id='" item-id "']"))]
|
||
(.focus btn)))))))
|
||
|
||
focus-tabs! (fn [& [tab-id]]
|
||
;; If the picker provided a topbar-selector, use the
|
||
;; richer :topbar region (DOM rove across all topbar
|
||
;; stops). Otherwise fall back to the legacy :tabs
|
||
;; region (atom-mutation-only).
|
||
(reset! *focus-region (if topbar-selector :topbar :tabs))
|
||
(reset! *highlighted-index nil)
|
||
(when-let [cnt (get-cnt)]
|
||
(let [selector (if tab-id
|
||
(str "[data-tab-id='" (name tab-id) "'].tab-item")
|
||
"[data-active='true'].tab-item")]
|
||
(when-let [tab-el (.querySelector cnt selector)]
|
||
(.focus tab-el)))))
|
||
|
||
select-highlighted! (fn []
|
||
(when-let [idx @*highlighted-index]
|
||
(when (< idx (count flat-items))
|
||
(let [item-id (:id (nth flat-items idx))]
|
||
(when-let [cnt (get-cnt)]
|
||
(when-let [btn (.querySelector cnt (str "[data-item-id='" item-id "']"))]
|
||
(.click btn)))))))
|
||
|
||
handle-grid-keys (fn [^js e]
|
||
(let [key (.-key e)
|
||
code (.-keyCode e)
|
||
idx (or @*highlighted-index 0)]
|
||
(cond
|
||
(or (= code 13) (= key " "))
|
||
(do (util/stop e) (select-highlighted!))
|
||
|
||
(= code 27)
|
||
(do (util/stop e) (focus-search!))
|
||
|
||
(and (= code 9) (not (.-shiftKey e)))
|
||
(do (util/stop e) (focus-tabs!))
|
||
|
||
(and (= code 9) (.-shiftKey e))
|
||
(do (util/stop e) (focus-search!))
|
||
|
||
(= code 37) ;; Left
|
||
(do (util/stop e)
|
||
(if-let [new-idx (move-grid-highlight idx :left sections)]
|
||
(focus-grid! new-idx)
|
||
(focus-search!)))
|
||
|
||
(= code 39) ;; Right
|
||
(do (util/stop e)
|
||
(when-let [new-idx (move-grid-highlight idx :right sections)]
|
||
(focus-grid! new-idx)))
|
||
|
||
(= code 38) ;; Up
|
||
(do (util/stop e)
|
||
(if-let [new-idx (move-grid-highlight idx :up sections)]
|
||
(focus-grid! new-idx)
|
||
(focus-search!)))
|
||
|
||
(= code 40) ;; Down
|
||
(do (util/stop e)
|
||
(when-let [new-idx (move-grid-highlight idx :down sections)]
|
||
(focus-grid! new-idx)))
|
||
|
||
(= code 36) ;; Home
|
||
(do (util/stop e) (focus-grid! 0))
|
||
|
||
(= code 35) ;; End
|
||
(do (util/stop e) (focus-grid! (dec (count flat-items))))
|
||
|
||
;; Type-through: printable character -> redirect to search
|
||
(and (= 1 (count key))
|
||
(not (.-metaKey e))
|
||
(not (.-ctrlKey e))
|
||
(not (.-altKey e)))
|
||
(focus-search!))))
|
||
|
||
handle-tabs-keys (fn [^js e]
|
||
(let [code (.-keyCode e)
|
||
tabs (tab-items)
|
||
current-tab @*tab
|
||
current-idx (.indexOf tabs current-tab)]
|
||
(cond
|
||
(= code 39)
|
||
(do (util/stop e)
|
||
(let [next-idx (mod (inc current-idx) (count tabs))
|
||
next-tab (nth tabs next-idx)]
|
||
(reset! *tab next-tab)
|
||
(focus-tabs! next-tab)))
|
||
|
||
(= code 37)
|
||
(do (util/stop e)
|
||
(let [prev-idx (mod (+ current-idx (dec (count tabs))) (count tabs))
|
||
prev-tab (nth tabs prev-idx)]
|
||
(reset! *tab prev-tab)
|
||
(focus-tabs! prev-tab)))
|
||
|
||
(or (= code 13) (= (.-key e) " "))
|
||
(do (util/stop e))
|
||
|
||
(or (= code 40) (and (= code 9) (not (.-shiftKey e))))
|
||
(do (util/stop e) (focus-search!))
|
||
|
||
(and (= code 9) (.-shiftKey e))
|
||
(do (util/stop e)
|
||
(when (pos? (count flat-items))
|
||
(focus-grid! 0)))
|
||
|
||
(= code 27)
|
||
(do (util/stop e) (on-escape)))))
|
||
|
||
;; Topbar region: heterogeneous mix of buttons (e.g. back, mode tabs,
|
||
;; trash, color swatch). Uses real DOM focus + click semantics. When
|
||
;; arrow-rove lands on a [role=tab] element, also auto-click so tabs
|
||
;; auto-activate (matches icon-picker's existing tabs-region behavior);
|
||
;; non-tab stops only move focus and require Enter to commit.
|
||
handle-topbar-keys
|
||
(fn [^js e]
|
||
(when-let [cnt (and topbar-selector (get-cnt))]
|
||
(let [code (.-keyCode e)
|
||
stops (vec (array-seq (.querySelectorAll cnt topbar-selector)))
|
||
active js/document.activeElement
|
||
idx (.indexOf stops active)
|
||
tab? (fn [^js el] (= (.getAttribute el "role") "tab"))
|
||
focus! (fn [^js el]
|
||
(when el
|
||
(.focus el)
|
||
(when (tab? el) (.click el))))]
|
||
(cond
|
||
;; Right: next stop (no wrap; stop at edge)
|
||
(= code 39)
|
||
(do (util/stop e)
|
||
(when (and (>= idx 0) (< (inc idx) (count stops)))
|
||
(focus! (nth stops (inc idx)))))
|
||
|
||
;; Left: prev stop (no wrap; stop at edge)
|
||
(= code 37)
|
||
(do (util/stop e)
|
||
(when (pos? idx)
|
||
(focus! (nth stops (dec idx)))))
|
||
|
||
;; Enter / Space: native click on focused stop
|
||
(or (= code 13) (= (.-key e) " "))
|
||
(do (util/stop e)
|
||
(when (>= idx 0) (.click (nth stops idx))))
|
||
|
||
;; Down / Tab: return to search
|
||
(or (= code 40) (and (= code 9) (not (.-shiftKey e))))
|
||
(do (util/stop e) (focus-search!))
|
||
|
||
;; Shift+Tab: jump into grid if any, else back to search
|
||
(and (= code 9) (.-shiftKey e))
|
||
(do (util/stop e)
|
||
(if (pos? (count flat-items))
|
||
(focus-grid! 0)
|
||
(focus-search!)))
|
||
|
||
;; Escape: return to search (parity with grid)
|
||
(= code 27)
|
||
(do (util/stop e) (focus-search!))))))
|
||
|
||
;; Refs for latest handler versions (avoids stale closures)
|
||
*grid-handler-ref (hooks/use-ref handle-grid-keys)
|
||
_ (set! (.-current *grid-handler-ref) handle-grid-keys)
|
||
*tabs-handler-ref (hooks/use-ref handle-tabs-keys)
|
||
_ (set! (.-current *tabs-handler-ref) handle-tabs-keys)
|
||
*topbar-handler-ref (hooks/use-ref handle-topbar-keys)
|
||
_ (set! (.-current *topbar-handler-ref) handle-topbar-keys)
|
||
|
||
keydown-handler
|
||
(hooks/use-callback
|
||
(fn [^js e]
|
||
(let [region @*focus-region
|
||
code (.-keyCode e)]
|
||
(if (and *tab (util/meta-key? e) (.-altKey e))
|
||
;; Alt+meta + 1/2/3/4 toggles section collapse on the All tab
|
||
;; (icon-picker only). Mac: ⌥⌘1-4 — Win/Linux: Ctrl+Alt+1-4.
|
||
;; 4 toggles the Assets section (only visible in search results).
|
||
(when (= @*tab :all)
|
||
(let [section-name (case (.-keyCode e)
|
||
49 "Recently used"
|
||
50 "Emojis"
|
||
51 "Icons"
|
||
52 "Assets"
|
||
nil)]
|
||
(when section-name
|
||
(swap! *section-states update section-name (fn [v] (if (nil? v) false (not v))))
|
||
(reset! *highlighted-index nil)
|
||
(util/stop e))))
|
||
(case region
|
||
:grid ((.-current *grid-handler-ref) e)
|
||
:tabs ((.-current *tabs-handler-ref) e)
|
||
:topbar ((.-current *topbar-handler-ref) e)
|
||
nil))))
|
||
[])]
|
||
|
||
;; Scroll highlighted item into view (highlighting itself is React-props-driven)
|
||
(hooks/use-effect!
|
||
(fn []
|
||
(when-let [idx @*highlighted-index]
|
||
(if-let [virt (some-> *virtuoso-ref deref)]
|
||
;; Virtuoso: scroll to row
|
||
(when-let [si (section-for-index idx sections)]
|
||
(let [sec (nth sections si)
|
||
local-idx (- idx (:start sec))
|
||
row (quot local-idx (:cols sec))]
|
||
(.scrollToIndex virt #js {:index row :align "center" :behavior "auto"})))
|
||
;; Non-virtualized: scrollIntoView on the button
|
||
(when-let [cnt (get-cnt)]
|
||
(when (< idx (count flat-items))
|
||
(let [item-id (:id (nth flat-items idx))]
|
||
(when-let [btn (.querySelector cnt (str "[data-item-id='" item-id "']"))]
|
||
(.scrollIntoView btn #js {:block "nearest" :behavior "instant"}))))))))
|
||
[@*highlighted-index])
|
||
|
||
;; Attach global keydown handler
|
||
(hooks/use-effect!
|
||
(fn []
|
||
(when-let [cnt (get-cnt)]
|
||
(.addEventListener cnt "keydown" keydown-handler true)
|
||
#(.removeEventListener cnt "keydown" keydown-handler true)))
|
||
[])
|
||
|
||
[:span.absolute.hidden {:ref *el-ref}]))
|
||
|
||
(defn- preset-hex?
|
||
"True when `hex` (any color-picker stored value) matches one of the
|
||
preset values. Preset values are themselves CSS-var strings (e.g.
|
||
`var(--rx-indigo-10)`) when sourced from `colors/variable`, so a
|
||
plain `=` is sufficient."
|
||
[hex preset-values]
|
||
(boolean (and hex (some #(= hex %) preset-values))))
|
||
|
||
(defn- custom-active?
|
||
"True when the current color is set, non-default, and doesn't match
|
||
any of the named presets — i.e. a custom hex picked through the
|
||
rainbow tile."
|
||
[color preset-values]
|
||
(boolean (and color
|
||
(not= color "inherit")
|
||
(not (preset-hex? color preset-values)))))
|
||
|
||
(rum/defc color-swatches-popover
|
||
"Popover content for the color-picker. Renders the **control column**
|
||
(Default tile + custom-rainbow tile) on the left, a 1px vertical rule,
|
||
then a 4×2 preset grid on the right. Auto-focuses the currently-
|
||
selected swatch on open. Arrow keys walk the DOM-order swatch list
|
||
linearly (Home/End jump to ends); the visual layout is responsible
|
||
for putting the right neighbour in the right slot."
|
||
[{:keys [colors color set-color! set-hover! on-select!
|
||
on-hover! on-hover-end!
|
||
on-custom-click! custom-active? picker-open?]}]
|
||
(let [*parent (rum/use-ref nil)
|
||
;; Split entries: first is Default (no value), rest are presets
|
||
default-entry (first colors)
|
||
preset-entries (vec (rest colors))
|
||
;; Build a 4-wide row layout: 4 + 4 = 8 presets. Pad shorter rows.
|
||
cols 4
|
||
rows (partition-all cols preset-entries)
|
||
render-preset
|
||
(fn [{value :value label :label hint :hint :as _entry}]
|
||
(let [active? (= value color)
|
||
swatch-key (or value "none")]
|
||
(shui/tooltip-provider
|
||
{:key swatch-key :delay-duration 300}
|
||
(shui/tooltip
|
||
(shui/tooltip-trigger
|
||
{:as-child true}
|
||
[:button.color-swatch
|
||
{:role "radio"
|
||
:aria-checked (str active?)
|
||
:aria-label label
|
||
;; Roving tabindex: only one element is in the tab
|
||
;; order at a time. Active preset wins, otherwise we
|
||
;; defer to the control col tiles.
|
||
:tab-index (if active? "0" "-1")
|
||
:class (when active? "is-selected")
|
||
:style (when value {"--swatch-color" value})
|
||
:on-mouse-enter (fn []
|
||
(set-hover! {:color value})
|
||
(some-> on-hover! (apply [value])))
|
||
:on-focus (fn []
|
||
(set-hover! {:color value})
|
||
(some-> on-hover! (apply [value])))
|
||
:on-click (fn []
|
||
(set-color! value)
|
||
(set-hover! nil)
|
||
(some-> on-hover-end! (apply []))
|
||
(some-> on-select! (apply [value]))
|
||
(shui/popup-hide!))}
|
||
[:span.swatch-fill {:style {:background-color value}}]])
|
||
(shui/tooltip-content
|
||
{:side "top" :align "center" :show-arrow true}
|
||
[:div.text-center
|
||
[:div.font-medium label]
|
||
(when hint
|
||
[:div.text-xs.mt-0.5
|
||
{:style {:color "var(--lx-gray-11, var(--ls-primary-text-color, var(--rx-gray-11)))"}}
|
||
hint])])))))]
|
||
|
||
;; On mount: land focus on (1) selected preset, (2) custom-rainbow
|
||
;; if custom is active, (3) Default tile, (4) first focusable.
|
||
;; Deferred a tick so it runs after Radix's onOpenAutoFocus.
|
||
(hooks/use-effect!
|
||
(fn []
|
||
(js/setTimeout
|
||
(fn []
|
||
(when-let [^js parent (rum/deref *parent)]
|
||
(when-let [^js btn (or (.querySelector parent ".color-swatch.is-selected")
|
||
(when custom-active?
|
||
(.querySelector parent ".color-swatch--custom"))
|
||
(.querySelector parent ".color-swatch"))]
|
||
(.focus btn))))
|
||
0))
|
||
[])
|
||
|
||
[:div.color-picker-presets
|
||
{:role "radiogroup"
|
||
:aria-label (t :icon.color/picker-aria-label)
|
||
:ref *parent
|
||
:on-mouse-leave (fn []
|
||
(set-hover! nil)
|
||
(some-> on-hover-end! (apply [])))
|
||
:on-key-down
|
||
(fn [^js e]
|
||
(when-let [^js parent (rum/deref *parent)]
|
||
(let [code (.-keyCode e)
|
||
;; All swatch stops in DOM order: control[0..1] then
|
||
;; preset[0..7], grouped 4-per-row.
|
||
stops (vec (array-seq (.querySelectorAll parent ".color-swatch")))
|
||
n (count stops)
|
||
active js/document.activeElement
|
||
idx (.indexOf stops active)
|
||
go! (fn [^js el] (some-> el .focus))
|
||
in-ctl? (fn [i] (and (>= i 0) (< i 2)))
|
||
in-grid? (fn [i] (and (>= i 2) (< i n)))
|
||
;; Preset zone is laid out 4-wide, so col = (i-2) mod 4
|
||
;; and row = (i-2) div 4. There are exactly 2 preset rows.
|
||
preset-row (fn [i] (quot (- i 2) cols))
|
||
preset-col (fn [i] (mod (- i 2) cols))]
|
||
(cond
|
||
;; ── Right ─────────────────────────────────────────────
|
||
(= code 39)
|
||
(do (util/stop e)
|
||
(cond
|
||
;; Control col → enter same-row preset col 0
|
||
(in-ctl? idx)
|
||
(let [target (+ 2 (* idx cols))]
|
||
(when (< target n) (go! (nth stops target))))
|
||
;; Preset → next stop within the row, wrap at row end
|
||
(in-grid? idx)
|
||
(let [row (preset-row idx)
|
||
col (preset-col idx)
|
||
next-col (mod (inc col) cols)
|
||
target (+ 2 (* row cols) next-col)]
|
||
(when (< target n) (go! (nth stops target))))
|
||
:else
|
||
(go! (nth stops (mod (inc (max idx -1)) n)))))
|
||
|
||
;; ── Left ──────────────────────────────────────────────
|
||
(= code 37)
|
||
(do (util/stop e)
|
||
(cond
|
||
;; Preset col 0 → jump to same-row control tile
|
||
(and (in-grid? idx) (zero? (preset-col idx)))
|
||
(go! (nth stops (preset-row idx)))
|
||
;; Other preset → previous in-row
|
||
(in-grid? idx)
|
||
(let [row (preset-row idx)
|
||
col (preset-col idx)
|
||
prev-col (mod (dec col) cols)
|
||
target (+ 2 (* row cols) prev-col)]
|
||
(go! (nth stops target)))
|
||
;; Control col → wrap to last preset of the row
|
||
(in-ctl? idx)
|
||
(let [target (+ 2 (* idx cols) (dec cols))]
|
||
(when (< target n) (go! (nth stops target))))
|
||
:else
|
||
(go! (nth stops (mod (dec (if (>= idx 0) idx n)) n)))))
|
||
|
||
;; ── Down ──────────────────────────────────────────────
|
||
(= code 40)
|
||
(do (util/stop e)
|
||
;; When the picker pane is open, ArrowDown from the
|
||
;; bottom row (or from the Custom tile) hops into the
|
||
;; hex input rather than wrapping. Lets keyboard users
|
||
;; flow swatches → hex without leaving via Tab.
|
||
(let [hop-to-pane!
|
||
(fn []
|
||
(and picker-open?
|
||
(when-let [^js root (.closest parent ".color-picker-popover")]
|
||
(when-let [^js inp (.querySelector root ".color-picker-hex-input")]
|
||
(.focus inp)
|
||
true))))]
|
||
(cond
|
||
;; Default → Custom (toggle within control col)
|
||
(= idx 0) (go! (nth stops 1))
|
||
;; Custom → hop to hex input if pane open, else wrap
|
||
(= idx 1) (when-not (hop-to-pane!) (go! (nth stops 0)))
|
||
;; Preset row 0 → preset row 1, same column
|
||
(and (in-grid? idx) (= 0 (preset-row idx)))
|
||
(let [target (+ 2 cols (preset-col idx))]
|
||
(when (< target n) (go! (nth stops target))))
|
||
;; Preset row 1 → hop to hex input if open, else wrap
|
||
(and (in-grid? idx) (= 1 (preset-row idx)))
|
||
(when-not (hop-to-pane!)
|
||
(go! (nth stops (+ 2 (preset-col idx)))))
|
||
:else
|
||
(go! (nth stops (mod (inc (max idx -1)) n))))))
|
||
|
||
;; ── Up ────────────────────────────────────────────────
|
||
(= code 38)
|
||
(do (util/stop e)
|
||
(cond
|
||
(= idx 0) (go! (nth stops 1))
|
||
(= idx 1) (go! (nth stops 0))
|
||
(and (in-grid? idx) (= 1 (preset-row idx)))
|
||
(go! (nth stops (+ 2 (preset-col idx))))
|
||
(and (in-grid? idx) (= 0 (preset-row idx)))
|
||
(let [target (+ 2 cols (preset-col idx))]
|
||
(when (< target n) (go! (nth stops target))))
|
||
:else
|
||
(go! (nth stops (mod (dec (if (>= idx 0) idx n)) n)))))
|
||
|
||
;; Home: first
|
||
(= code 36)
|
||
(do (util/stop e) (go! (first stops)))
|
||
|
||
;; End: last
|
||
(= code 35)
|
||
(do (util/stop e) (go! (last stops)))))))}
|
||
|
||
;; Control column: Default tile (top), custom-rainbow tile (bottom)
|
||
[:div.control-col
|
||
;; Default — corresponds to the original first entry (`:value nil`)
|
||
(let [{value :value label :label hint :hint} default-entry
|
||
active? (and (= value color) (not custom-active?))]
|
||
(shui/tooltip-provider
|
||
{:delay-duration 300}
|
||
(shui/tooltip
|
||
(shui/tooltip-trigger
|
||
{:as-child true}
|
||
[:button.color-swatch.color-swatch--default
|
||
{:role "radio"
|
||
:aria-checked (str active?)
|
||
:aria-label label
|
||
:tab-index (if active? "0" "-1")
|
||
:class (when active? "is-selected")
|
||
:on-mouse-enter (fn []
|
||
(set-hover! {:color value})
|
||
(some-> on-hover! (apply [value])))
|
||
:on-focus (fn []
|
||
(set-hover! {:color value})
|
||
(some-> on-hover! (apply [value])))
|
||
:on-click (fn []
|
||
(set-color! value)
|
||
(set-hover! nil)
|
||
(some-> on-hover-end! (apply []))
|
||
(some-> on-select! (apply [value]))
|
||
(shui/popup-hide!))}
|
||
[:span.swatch-empty
|
||
(shui/tabler-icon "slash" {:size 14})]])
|
||
(shui/tooltip-content
|
||
{:side "top" :align "center" :show-arrow true}
|
||
[:div.text-center
|
||
[:div.font-medium label]
|
||
(when hint
|
||
[:div.text-xs.mt-0.5
|
||
{:style {:color "var(--lx-gray-11, var(--ls-primary-text-color, var(--rx-gray-11)))"}}
|
||
hint])]))))
|
||
|
||
;; Custom — opens the picker pane. aria-expanded reflects pane state.
|
||
(shui/tooltip-provider
|
||
{:delay-duration 300}
|
||
(shui/tooltip
|
||
(shui/tooltip-trigger
|
||
{:as-child true}
|
||
[:button.color-swatch.color-swatch--custom
|
||
{:role "radio"
|
||
:aria-checked (str (boolean custom-active?))
|
||
:aria-label (t :icon.color/custom)
|
||
:aria-expanded (str (boolean picker-open?))
|
||
:tab-index (if custom-active? "0" "-1")
|
||
:class (when custom-active? "is-selected")
|
||
:on-click (fn [] (some-> on-custom-click! (apply [])))
|
||
:on-key-down (fn [^js e]
|
||
(when (or (= (.-key e) "Enter")
|
||
(= (.-key e) " "))
|
||
(.preventDefault e)
|
||
(some-> on-custom-click! (apply []))))}
|
||
[:span.swatch-fill.swatch-fill--rainbow]])
|
||
(shui/tooltip-content
|
||
{:side "top" :align "center" :show-arrow true}
|
||
[:div.text-center
|
||
[:div.font-medium (t :icon.color/custom)]
|
||
[:div.text-xs.mt-0.5
|
||
{:style {:color "var(--lx-gray-11, var(--ls-primary-text-color, var(--rx-gray-11)))"}}
|
||
(t :icon.color/custom-hint)]])))]
|
||
|
||
;; Vertical 1px rule between control col and preset grid
|
||
[:div.divider-rule]
|
||
|
||
;; 4-wide × 2-row preset grid
|
||
[:div.preset-grid
|
||
(for [[r-idx row] (map-indexed vector rows)]
|
||
[:div.preset-grid__row {:key (str "row-" r-idx)}
|
||
(for [entry row]
|
||
(render-preset entry))])]]))
|
||
|
||
;; Forward declaration: `color-picker-pane` (next) renders `recents-lane`
|
||
;; conditionally inside its body, but the recents-lane defn lives below
|
||
;; for readability. Declaring silences the :undeclared-var warning.
|
||
(declare recents-lane)
|
||
|
||
(def ^:private placeholder-hex
|
||
"Neutral grey-blue used in two no-input surfaces: the hex input's
|
||
placeholder ghost text (\"example of expected format\") and the
|
||
react-colorful SV pad's starting position when no color is set yet.
|
||
The a1/b2/c3 alphabetical pattern is the self-documentation —
|
||
it's a memorable demo value, not a designed color."
|
||
"#a1b2c3")
|
||
|
||
(rum/defc color-picker-pane
|
||
"Custom-color picker pane shown below the swatch grid when the user
|
||
clicks the rainbow tile. Hosts a hex input + react-colorful's
|
||
HexColorPicker (combined SV pad + hue slider). Animates open/close
|
||
via the CSS-Grid 0fr↔1fr trick."
|
||
[{:keys [color hex-input set-hex-input!
|
||
hex-invalid? set-hex-invalid!
|
||
set-hover! on-hover! on-hover-end!
|
||
on-commit! on-escape!
|
||
recents
|
||
open?]}]
|
||
(let [*hex-ref (rum/use-ref nil)
|
||
*pane-ref (rum/use-ref nil)
|
||
*pad-ref (rum/use-ref nil)
|
||
;; Resolve the typed value once. `:hex` is the canonical hex when
|
||
;; resolution succeeds (any kind of match). `picked` reflects only
|
||
;; exact-resolvable values for purposes of contrast indicator.
|
||
resolved (colors/resolve-color hex-input)
|
||
active-color (or (:hex resolved) color placeholder-hex)
|
||
;; Compute the contrast-adjusted hex for BOTH light and dark themes
|
||
;; against canonical surfaces. This lets the indicator surface how
|
||
;; the pick will render in EACH mode, not just the current one — so
|
||
;; the user notices cross-theme issues at pick time.
|
||
picked (:hex resolved)
|
||
both-themes (when picked (colors/adjust-for-both-themes picked))
|
||
adjusted? (boolean (:differs? both-themes))
|
||
;; Ghost suffix: alphabetically-first XKCD prefix completion. nil
|
||
;; when input is empty / hex / exact match / no candidate.
|
||
ghost (colors/prefix-completion hex-input)
|
||
;; Capture the input's resolved CSS font shorthand once after mount.
|
||
;; Used by `colors/measure-text-px` to position the ghost <span>.
|
||
[input-font set-input-font!] (rum/use-state nil)]
|
||
;; When the pane opens, autofocus the hex input.
|
||
(hooks/use-effect!
|
||
(fn []
|
||
(when open?
|
||
(js/setTimeout
|
||
(fn []
|
||
(when-let [^js el (rum/deref *hex-ref)]
|
||
(.focus el)
|
||
(.select el)))
|
||
80)))
|
||
[open?])
|
||
;; Capture the input's computed font once on mount so the ghost can be
|
||
;; measured with pixel-perfect alignment.
|
||
(hooks/use-effect!
|
||
(fn []
|
||
(when-let [^js el (rum/deref *hex-ref)]
|
||
(set-input-font! (.-font (js/getComputedStyle el)))))
|
||
[])
|
||
;; Strip react-colorful's two interactive sliders (SV pad + hue) from
|
||
;; the Tab order. The library hard-codes `tabIndex={0}` on them and
|
||
;; offers no prop to opt out. Mouse/touch interaction is unaffected.
|
||
;; Keyboard users navigate swatches → hex → recents directly via
|
||
;; Tab/Shift+Tab and arrow shortcuts; the pad is mouse/touch only.
|
||
(hooks/use-effect!
|
||
(fn []
|
||
(when-let [^js root (rum/deref *pad-ref)]
|
||
(doseq [^js node (array-seq (.querySelectorAll root ".react-colorful__interactive"))]
|
||
(.setAttribute node "tabindex" "-1"))))
|
||
[])
|
||
;; Tab guard for the collapse animation. The pane stays in the DOM
|
||
;; while CSS Grid animates from 1fr→0fr, so its hex input + pad +
|
||
;; recents would otherwise remain in the focus tree even when not
|
||
;; visible. `inert` removes them; toggling via effect keeps the
|
||
;; data-open transition in sync.
|
||
(hooks/use-effect!
|
||
(fn []
|
||
(when-let [^js el (rum/deref *pane-ref)]
|
||
(set! (.-inert el) (not open?))))
|
||
[open?])
|
||
[:div.color-picker-pane
|
||
{:ref *pane-ref
|
||
:data-open (str (boolean open?))}
|
||
[:div.color-picker-pane__inner
|
||
;; Hex input row
|
||
[:div.color-picker-hex-row
|
||
[:input.color-picker-hex-input
|
||
{:ref *hex-ref
|
||
:type "text"
|
||
:value (or hex-input "")
|
||
:placeholder placeholder-hex
|
||
:spell-check false
|
||
:auto-complete "off"
|
||
:aria-label (t :icon.color/hex-aria-label)
|
||
:aria-invalid (str (boolean hex-invalid?))
|
||
:class (when hex-invalid? "is-invalid")
|
||
:on-change (fn [^js e]
|
||
(let [v (.. e -target -value)
|
||
r (colors/resolve-color v)]
|
||
(set-hex-input! v)
|
||
;; Mid-typing: clear invalid flag if the new value
|
||
;; could still become a valid hex on commit.
|
||
(when hex-invalid?
|
||
(set-hex-invalid! false))
|
||
;; Live-preview when the value resolves (any match
|
||
;; kind: hex, css, exact, OR prefix). Prefix matches
|
||
;; preview but won't commit until promoted/exact.
|
||
;; `set-hover!` drives the picker grid's local
|
||
;; tint; `on-hover!` propagates to the page icon
|
||
;; rendered outside the popover.
|
||
(when-let [hex (:hex r)]
|
||
(set-hover! {:color hex})
|
||
(some-> on-hover! (apply [hex])))))
|
||
:on-blur (fn [_e]
|
||
(let [r (colors/resolve-color hex-input)]
|
||
(if (and r (contains? #{:hex :css :exact} (:match r)))
|
||
(do (set-hex-input! (:hex r))
|
||
(set-hex-invalid! false))
|
||
(when (and hex-input
|
||
(not (string/blank? hex-input)))
|
||
(set-hex-invalid! true)))))
|
||
:on-key-down (fn [^js e]
|
||
(cond
|
||
(= (.-key e) "Enter")
|
||
(let [r (colors/resolve-color hex-input)]
|
||
(if (and r (contains? #{:hex :css :exact} (:match r)))
|
||
(do (.preventDefault e)
|
||
(set-hex-input! (:hex r))
|
||
(set-hex-invalid! false)
|
||
(some-> on-commit! (apply [(:hex r)])))
|
||
(set-hex-invalid! true)))
|
||
|
||
(= (.-key e) "Escape")
|
||
(do (.preventDefault e)
|
||
(some-> on-escape! (apply [])))
|
||
|
||
;; Tab promotes ghost to full match (when ghost
|
||
;; visible). When no ghost, default Tab (focus
|
||
;; next) is preserved.
|
||
(and (= (.-key e) "Tab")
|
||
(not (.-shiftKey e))
|
||
(some? ghost))
|
||
(let [full (:full ghost)
|
||
hex (:hex ghost)]
|
||
(.preventDefault e)
|
||
(set-hex-input! full)
|
||
(set-hex-invalid! false)
|
||
(when hex
|
||
(set-hover! {:color hex})
|
||
(some-> on-hover! (apply [hex]))))
|
||
|
||
;; ArrowRight at end of input promotes ghost.
|
||
;; Otherwise default cursor move is preserved.
|
||
(and (= (.-key e) "ArrowRight")
|
||
(some? ghost)
|
||
(let [^js el (.-target e)]
|
||
(and (= (.-selectionStart el)
|
||
(.-selectionEnd el))
|
||
(= (.-selectionStart el)
|
||
(count hex-input)))))
|
||
(let [full (:full ghost)
|
||
hex (:hex ghost)]
|
||
(.preventDefault e)
|
||
(set-hex-input! full)
|
||
(set-hex-invalid! false)
|
||
(when hex
|
||
(set-hover! {:color hex})
|
||
(some-> on-hover! (apply [hex]))))
|
||
|
||
;; ArrowUp → focus the swatches grid. Lands on
|
||
;; the active swatch when one is selected, else
|
||
;; the custom-rainbow tile. Single-line input
|
||
;; has no meaningful Up cursor target, so we
|
||
;; reclaim the key for cross-region nav.
|
||
(= (.-key e) "ArrowUp")
|
||
(when-let [^js root (some-> (.-target e)
|
||
(.closest ".color-picker-popover"))]
|
||
(when-let [^js btn (or (.querySelector root ".color-swatch.is-selected")
|
||
(.querySelector root ".color-swatch--custom"))]
|
||
(.preventDefault e)
|
||
(.focus btn)))
|
||
|
||
;; ArrowDown → focus the first recent. Skips
|
||
;; the SV pad / hue slider (which sit outside
|
||
;; the Tab order). No-op when no recents exist.
|
||
(and (= (.-key e) "ArrowDown") (seq recents))
|
||
(when-let [^js root (some-> (.-target e)
|
||
(.closest ".color-picker-popover"))]
|
||
(when-let [^js btn (.querySelector root
|
||
".color-picker-recents__row .color-swatch--recent")]
|
||
(.preventDefault e)
|
||
(.focus btn)))))}]
|
||
;; Ghost suffix: muted suggestion text rendered after the typed
|
||
;; value when a prefix completion exists. Hidden when the input is
|
||
;; in an invalid state to avoid noise.
|
||
(when (and ghost (not hex-invalid?))
|
||
(let [typed-width (when input-font
|
||
(colors/measure-text-px input-font (or hex-input "")))]
|
||
[:span.color-picker-hex-input-ghost
|
||
{:aria-hidden "true"
|
||
:style (when typed-width
|
||
{:left (str "calc(10px + " typed-width "px)")})}
|
||
(:suffix ghost)]))
|
||
;; Contrast indicator: visible when the picked hex would render
|
||
;; differently in EITHER light or dark theme. Shows a half-pie
|
||
;; preview — left half = dark mode rendered color, right half =
|
||
;; light mode rendered color — matching the recents lane's split
|
||
;; swatch motif. Tooltip explains both adjusted hexes.
|
||
(when adjusted?
|
||
(let [{:keys [light dark]} both-themes
|
||
picked-name (some-> picked colors/hex->name colors/humanize-name)]
|
||
(shui/tooltip-provider
|
||
{:delay-duration 200}
|
||
(shui/tooltip
|
||
(shui/tooltip-trigger
|
||
{:as-child true}
|
||
[:span.color-picker-contrast-indicator
|
||
{:aria-label (t :icon.color/contrast-aria-label
|
||
(or picked-name picked) dark light)}
|
||
[:span.contrast-split-swatch
|
||
{:style {"--dark-color" dark
|
||
"--light-color" light}}]])
|
||
(shui/tooltip-content
|
||
{:side "top" :align "center" :show-arrow true}
|
||
[:div
|
||
;; Title: picked color name if reverse-lookup hits, else
|
||
;; the generic "Contrast adjusted".
|
||
[:div.text-sm.font-medium (or picked-name (t :icon.color/contrast-title))]
|
||
[:div.text-xs.opacity-70.mt-1
|
||
[:div.flex.items-center.gap-1.5
|
||
[:span.contrast-tooltip-dot {:style {:background-color dark}}]
|
||
[:span (str (t :icon.color/contrast-dark-label) " ")] [:span.font-mono dark]]
|
||
[:div.flex.items-center.gap-1.5.mt-0.5
|
||
[:span.contrast-tooltip-dot {:style {:background-color light}}]
|
||
[:span (str (t :icon.color/contrast-light-label) " ")] [:span.font-mono light]]]])))))]
|
||
|
||
;; SV pad + Hue slider via react-colorful's HexColorPicker
|
||
[:div.color-picker-pad-row
|
||
{:ref *pad-ref}
|
||
(hex-color-picker
|
||
{:color active-color
|
||
:on-change (fn [^js hex]
|
||
(let [hex (string/lower-case hex)]
|
||
(set-hex-input! hex)
|
||
(set-hex-invalid! false)
|
||
;; `set-hover!` drives the picker grid's local
|
||
;; tint; `on-hover!` propagates the live drag
|
||
;; preview to the page icon outside the popover.
|
||
(set-hover! {:color hex})
|
||
(some-> on-hover! (apply [hex]))))
|
||
:on-mouse-up (fn [_e]
|
||
(when-let [hex (colors/parse-hex hex-input)]
|
||
(some-> on-commit! (apply [hex]))))
|
||
:on-touch-end (fn [_e]
|
||
(when-let [hex (colors/parse-hex hex-input)]
|
||
(some-> on-commit! (apply [hex]))))})]
|
||
;; Recents lane lives inside the pane so it shares the popover bg
|
||
;; pocket and animates in/out with the pane reveal.
|
||
(when (seq recents)
|
||
(recents-lane
|
||
{:recents recents
|
||
:hex-input hex-input
|
||
:set-hover! set-hover!
|
||
:on-hover! on-hover!
|
||
:on-select! on-commit!
|
||
:on-escape! on-escape!
|
||
:on-up! (fn []
|
||
(when-let [^js el (rum/deref *hex-ref)]
|
||
(.focus el)
|
||
(.select el)))
|
||
:on-down! (fn []
|
||
(when-let [^js root (some-> (rum/deref *pane-ref)
|
||
(.closest ".color-picker-popover"))]
|
||
(when-let [^js btn (or (.querySelector root ".color-swatch.is-selected")
|
||
(.querySelector root ".color-swatch--custom")
|
||
(.querySelector root ".color-swatch"))]
|
||
(.focus btn))))}))]]))
|
||
|
||
(rum/defc recents-lane
|
||
"Horizontal row of recently-used custom colors (cap: `frontend.handler.icon-color/max-recents`).
|
||
Header label matches existing pane-section typography (12px Inter Medium muted).
|
||
|
||
Keyboard model: roving tabindex (one Tab stop into the row, arrows
|
||
rove within). ArrowUp leaves to the hex input; ArrowDown wraps to
|
||
the swatches grid (closing the vertical loop). Escape collapses the
|
||
pane back to the swatches grid."
|
||
[{:keys [recents hex-input on-select! set-hover! on-hover!
|
||
on-escape! on-up! on-down!]}]
|
||
(when (seq recents)
|
||
(let [*parent (rum/use-ref nil)
|
||
;; Active recent index for roving tabindex. Default 0 so the
|
||
;; first Tab into the row lands on the leftmost swatch.
|
||
[active-idx set-active-idx!] (rum/use-state 0)]
|
||
[:div.color-picker-recents
|
||
[:div.color-picker-recents__header (t :icon.color/recents-title)]
|
||
[:div.color-picker-recents__row
|
||
{:ref *parent
|
||
:role "radiogroup"
|
||
:aria-label (t :icon.color/recents-aria-label)
|
||
:on-key-down
|
||
(fn [^js e]
|
||
(when-let [^js parent (rum/deref *parent)]
|
||
(let [stops (vec (array-seq (.querySelectorAll parent ".color-swatch--recent")))
|
||
n (count stops)
|
||
;; Recents flex-wrap into rows of 7 (CSS-driven). Detect
|
||
;; the visual row width by counting how many leading
|
||
;; stops share the first stop's offsetTop — robust even
|
||
;; if the row width changes later.
|
||
cols (if (zero? n)
|
||
0
|
||
(let [first-top (.-offsetTop ^js (first stops))]
|
||
(count (take-while #(= (.-offsetTop ^js %) first-top) stops))))
|
||
focused js/document.activeElement
|
||
idx (max 0 (.indexOf stops focused))
|
||
row (if (pos? cols) (quot idx cols) 0)
|
||
col (if (pos? cols) (mod idx cols) idx)
|
||
row-start (* row cols)
|
||
row-end (min (+ row-start cols) n)
|
||
row-width (- row-end row-start)
|
||
go! (fn [i]
|
||
(set-active-idx! i)
|
||
(some-> ^js (nth stops i) .focus))]
|
||
(cond
|
||
;; Left/Right wrap WITHIN the current row only.
|
||
(= (.-key e) "ArrowLeft")
|
||
(do (util/stop e)
|
||
(go! (+ row-start (mod (dec col) row-width))))
|
||
|
||
(= (.-key e) "ArrowRight")
|
||
(do (util/stop e)
|
||
(go! (+ row-start (mod (inc col) row-width))))
|
||
|
||
(= (.-key e) "Home")
|
||
(do (util/stop e) (go! 0))
|
||
|
||
(= (.-key e) "End")
|
||
(do (util/stop e) (go! (dec n)))
|
||
|
||
;; ArrowUp: previous row at same column, or escape to
|
||
;; hex input when already in the top row.
|
||
(= (.-key e) "ArrowUp")
|
||
(do (util/stop e)
|
||
(if (pos? row)
|
||
(go! (+ (* (dec row) cols) col))
|
||
(some-> on-up! (apply []))))
|
||
|
||
;; ArrowDown: next row at same column (clamped to last
|
||
;; available when the row is partial), or escape to the
|
||
;; swatches grid when there's no row below.
|
||
(= (.-key e) "ArrowDown")
|
||
(let [next-row-start (* (inc row) cols)]
|
||
(util/stop e)
|
||
(if (< next-row-start n)
|
||
(let [next-row-end (min (+ next-row-start cols) n)]
|
||
(go! (min (+ next-row-start col) (dec next-row-end))))
|
||
(some-> on-down! (apply []))))
|
||
|
||
;; Escape collapses the pane (same callback the hex
|
||
;; input uses) so the user can back out of the picker
|
||
;; from any region.
|
||
(= (.-key e) "Escape")
|
||
(do (util/stop e) (some-> on-escape! (apply [])))))))}
|
||
(for [[i hex] (map-indexed vector recents)]
|
||
(let [{:keys [light dark differs?]} (or (colors/adjust-for-both-themes hex)
|
||
{:light hex :dark hex :differs? false})
|
||
picked-name (some-> hex colors/hex->name colors/humanize-name)
|
||
checked? (and hex-input (= hex hex-input))]
|
||
(shui/tooltip-provider
|
||
{:key hex :delay-duration 300}
|
||
(shui/tooltip
|
||
(shui/tooltip-trigger
|
||
{:as-child true}
|
||
[:button.color-swatch.color-swatch--recent
|
||
{:role "radio"
|
||
:aria-checked (str (boolean checked?))
|
||
:aria-label (or picked-name hex)
|
||
:tab-index (if (= i active-idx) "0" "-1")
|
||
:class (when checked? "is-selected")
|
||
:on-mouse-enter (fn []
|
||
(when set-hover!
|
||
(set-hover! {:color hex}))
|
||
(some-> on-hover! (apply [hex])))
|
||
:on-focus (fn []
|
||
(set-active-idx! i)
|
||
(when set-hover!
|
||
(set-hover! {:color hex}))
|
||
(some-> on-hover! (apply [hex])))
|
||
:on-click (fn [] (some-> on-select! (apply [hex])))}
|
||
;; Half-pie split: left half = dark-mode rendering, right
|
||
;; half = light-mode rendering. When picked needs no
|
||
;; adjustment in either mode, both halves match and the
|
||
;; swatch reads as a solid circle.
|
||
[:span.swatch-fill
|
||
{:class (when differs? "is-split")
|
||
:style {"--dark-color" dark
|
||
"--light-color" light}}]])
|
||
(shui/tooltip-content
|
||
{:side "top" :align "center" :show-arrow true}
|
||
[:div.text-center
|
||
;; Title: humanized name when reverse-lookup hits, else
|
||
;; the picked hex itself.
|
||
[:div.font-medium (or picked-name hex)]
|
||
;; Dual-mode hex display only when the picked color
|
||
;; renders differently across themes.
|
||
(when differs?
|
||
[:div.text-xs.opacity-70.mt-0.5
|
||
[:div.flex.items-center.gap-1.justify-center
|
||
[:span.font-mono dark] [:span "·"] [:span.font-mono light]]])])))))]])))
|
||
|
||
(rum/defc color-picker-popover
|
||
"Whole popover body: swatch grid + animated picker pane + recents lane.
|
||
Owns the local picker-mode / hex-input / hex-invalid? / recents state
|
||
so it survives across pane open/close and recent-color picks while
|
||
the popup remains mounted."
|
||
[{:keys [colors color set-color! set-hover!
|
||
on-select! on-hover! on-hover-end!]}]
|
||
(let [preset-values (->> colors (map :value) (filter some?) vec)
|
||
custom? (custom-active? color preset-values)
|
||
[picker-mode set-picker-mode!] (rum/use-state (if custom? :custom :presets))
|
||
[hex-input set-hex-input!] (rum/use-state (when custom? color))
|
||
[hex-invalid? set-hex-invalid!] (rum/use-state false)
|
||
[recents set-recents!] (rum/use-state [])
|
||
open? (= picker-mode :custom)
|
||
;; Ref captures the latest hex-input + committed color so the unmount
|
||
;; cleanup sees current values (the cleanup closure has empty deps).
|
||
*latest (rum/use-ref nil)
|
||
commit! (fn [hex]
|
||
(icon-color/add-recent! hex)
|
||
(set-recents! (icon-color/get-recents))
|
||
(set-color! hex)
|
||
(set-hover! nil)
|
||
(some-> on-hover-end! (apply []))
|
||
(some-> on-select! (apply [hex]))
|
||
(shui/popup-hide!))
|
||
focus-rainbow! (fn []
|
||
(js/setTimeout
|
||
(fn []
|
||
(when-let [^js btn (js/document.querySelector
|
||
".color-swatch--custom")]
|
||
(.focus btn)))
|
||
0))]
|
||
;; Refresh recents on mount.
|
||
(hooks/use-effect!
|
||
(fn []
|
||
(set-recents! (icon-color/get-recents)))
|
||
[])
|
||
;; Track the latest hex-input + color for the unmount cleanup.
|
||
(hooks/use-effect!
|
||
(fn []
|
||
(rum/set-ref! *latest {:hex-input hex-input :color color}))
|
||
[hex-input color])
|
||
;; Commit pending hex on unmount (e.g. user dragged the SV pad then
|
||
;; clicked outside the popover without releasing inside it — the
|
||
;; on-mouse-up never fires for outside-bounds releases since react-
|
||
;; colorful uses document-level pointer listeners).
|
||
(hooks/use-effect!
|
||
(fn []
|
||
(fn []
|
||
(let [{:keys [hex-input color]} (rum/deref *latest)
|
||
hex (colors/parse-hex hex-input)]
|
||
(when (and hex (not= hex color))
|
||
(icon-color/add-recent! hex)
|
||
(set-color! hex)
|
||
(some-> on-select! (apply [hex]))))))
|
||
[])
|
||
[:div.color-picker-popover
|
||
(color-swatches-popover
|
||
{:colors colors
|
||
:color color
|
||
:set-color! set-color!
|
||
:set-hover! set-hover!
|
||
:on-select! on-select!
|
||
:on-hover! on-hover!
|
||
:on-hover-end! on-hover-end!
|
||
:custom-active? custom?
|
||
:picker-open? open?
|
||
:on-custom-click! (fn []
|
||
(set-picker-mode! (if open? :presets :custom)))})
|
||
(color-picker-pane
|
||
{:color (when custom? color)
|
||
:hex-input hex-input
|
||
:set-hex-input! set-hex-input!
|
||
:hex-invalid? hex-invalid?
|
||
:set-hex-invalid! set-hex-invalid!
|
||
:set-hover! set-hover!
|
||
:on-hover! on-hover!
|
||
:on-hover-end! on-hover-end!
|
||
:on-commit! commit!
|
||
:on-escape! (fn []
|
||
(set-picker-mode! :presets)
|
||
(set-hex-invalid! false)
|
||
(focus-rainbow!))
|
||
:recents recents
|
||
:open? open?})]))
|
||
|
||
(rum/defc color-picker
|
||
[*color on-select! & {:keys [on-hover! on-hover-end! button-attrs after-close! popup-id]}]
|
||
(let [;; Defensive: never let the CSS sentinel "inherit" leak into React state.
|
||
initial-color (let [v @*color] (when (and v (not= v "inherit")) v))
|
||
[color, set-color!] (rum/use-state initial-color)
|
||
[hover, set-hover!] (rum/use-state nil)
|
||
;; hover is nil = not hovering, or {:color X} where X may be nil ("no color")
|
||
effective-color (if hover (:color hover) color)
|
||
*el (rum/use-ref nil)
|
||
palette [{:value nil :label "Default"
|
||
:hint "Inherits the surrounding text color"}
|
||
{:value (colors/variable :gray :10) :label "Gray"}
|
||
{:value (colors/variable :indigo :10) :label "Indigo"}
|
||
{:value (colors/variable :cyan :10) :label "Cyan"}
|
||
{:value (colors/variable :green :10) :label "Green"}
|
||
{:value (colors/variable :orange :10) :label "Orange"}
|
||
{:value (colors/variable :tomato :10) :label "Tomato"}
|
||
{:value (colors/variable :pink :10) :label "Pink"}
|
||
{:value (colors/variable :red :10) :label "Red"}]
|
||
content-fn (fn []
|
||
(color-picker-popover
|
||
{:colors palette
|
||
:color color
|
||
:set-color! set-color!
|
||
:set-hover! set-hover!
|
||
:on-select! on-select!
|
||
:on-hover! on-hover!
|
||
:on-hover-end! on-hover-end!}))]
|
||
;; Display effect on the picker root — fires for both hover and committed
|
||
;; color. Combined with the per-cell `--r`/`--c` `transition-delay` in CSS,
|
||
;; every change to the var (hover preview OR commit) plays the diagonal
|
||
;; wave across the grid. Rapid hover sweeps gracefully retarget mid-flight
|
||
;; because each cell's delay holds its current value until activation.
|
||
(hooks/use-effect!
|
||
(fn []
|
||
(when-let [^js picker (some-> (rum/deref *el) (.closest ".cp__emoji-icon-picker"))]
|
||
;; Contrast surface = the popover's elevated background (one level
|
||
;; above the page bg). The icon-picker grid renders against this
|
||
;; surface, so contrast must be measured here, not against the page.
|
||
(let [raw (if (string/blank? effective-color) "inherit" effective-color)
|
||
popover-bg (colors/read-bg-var "--ls-secondary-background-color")
|
||
c (if (and (string/starts-with? raw "#") popover-bg)
|
||
(colors/adjust-for-contrast raw popover-bg 3.0)
|
||
raw)]
|
||
(.setProperty (.-style picker) "--ls-color-icon-preset" c)
|
||
(if (= c "inherit")
|
||
(.remove (.-classList picker) "icon-colored")
|
||
(.add (.-classList picker) "icon-colored")))))
|
||
[effective-color])
|
||
;; Commit effect — only fires on actual selection. Persists + propagates to *color.
|
||
(hooks/use-effect!
|
||
(fn []
|
||
(let [c (if (string/blank? color) "inherit" color)]
|
||
(storage/set :ls-icon-color-preset c))
|
||
(reset! *color color))
|
||
[color])
|
||
;; Cleanup — clear external preview when picker unmounts.
|
||
(hooks/use-effect!
|
||
(fn []
|
||
(fn []
|
||
(some-> on-hover-end! (apply []))))
|
||
[])
|
||
|
||
[:button.color-picker-trigger
|
||
(merge button-attrs
|
||
{:ref *el
|
||
:on-click (fn [^js e]
|
||
(shui/popup-show!
|
||
(.-target e) content-fn
|
||
(cond-> {;; Disable shui's own focus-restore (a 16ms
|
||
;; setTimeout in popup/core.cljs:107-111 that
|
||
;; .focuses `.closest("[tabindex='0']")` of
|
||
;; the trigger). For our color trigger that
|
||
;; resolves to the active tab in the icon
|
||
;; picker's topbar (roving tabindex), which
|
||
;; would override the picker's manual focus
|
||
;; placement after color commit.
|
||
:focus-trigger? false
|
||
:content-props
|
||
{:side "bottom"
|
||
:side-offset 6
|
||
;; Also prevent Radix's default focus-restore
|
||
;; on close. By default it focuses *shui's*
|
||
;; hidden floating trigger button (rendered
|
||
;; at body level), outside the icon picker
|
||
;; subtree → capture-phase keydown listener
|
||
;; stops receiving arrow keys.
|
||
:onCloseAutoFocus (fn [^js e]
|
||
(.preventDefault e)
|
||
(some-> after-close! (apply [])))}}
|
||
;; Caller-supplied id lets external code dismiss
|
||
;; this popover by name (e.g. asset-picker hides
|
||
;; it when the user toggles segment to Image).
|
||
popup-id (assoc :id popup-id))))})
|
||
(if color
|
||
;; Mirror the recents-lane swatch: when the picked color renders
|
||
;; differently in light vs dark themes, split the trigger fill into
|
||
;; a half-pie (dark left / light right) so the cross-mode behavior
|
||
;; is visible at a glance — even before the popover is opened.
|
||
(let [{:keys [light dark differs?]} (or (colors/adjust-for-both-themes color)
|
||
{:light color :dark color :differs? false})]
|
||
[:span.color-picker-fill
|
||
{:class (when differs? "is-split")
|
||
:style {:background-color (when-not differs? color)
|
||
"--dark-color" dark
|
||
"--light-color" light}}])
|
||
[:span.color-picker-empty
|
||
(shui/tabler-icon "slash" {:size 12})])]))
|
||
|
||
(rum/defcs text-picker < rum/reactive
|
||
(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)
|
||
title (or (:page-title opts)
|
||
(some-> (state/get-current-page)
|
||
(db/get-page)
|
||
(: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])
|
||
selected-color (:selected-color opts)
|
||
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) (or existing-color selected-color))
|
||
(reset! (::mode s) (or existing-mode "initials"))
|
||
s))
|
||
:will-unmount (fn [s]
|
||
(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? delete-mode page-title]}]
|
||
(let [delete-mode (or delete-mode (if del-btn? :remove :hidden))
|
||
*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) "?")
|
||
derived-abbreviated (derive-abbreviated title)
|
||
build-icon (fn [text-override]
|
||
(let [text (or text-override
|
||
(if (string/blank? @*text-value) derived-initials @*text-value))]
|
||
{:type :text
|
||
:id (str "text-" text)
|
||
:label text
|
||
:data (cond-> {:value text}
|
||
@*color (assoc :color @*color)
|
||
@*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)))
|
||
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
|
||
[:div.text-picker-back
|
||
[:button.back-button
|
||
{:on-click (fn []
|
||
(persist!)
|
||
(on-back))}
|
||
(shui/tabler-icon "chevron-left" {:size 16})
|
||
[:span (t :icon/back)]]
|
||
[:div.text-picker-actions
|
||
(color-picker *color (fn [c]
|
||
(reset! *color c)
|
||
(let [text (if (string/blank? @*text-value) derived-initials @*text-value)
|
||
icon-item {:type :text
|
||
:id (str "text-" text)
|
||
:label text
|
||
:data (cond-> {:value text}
|
||
c (assoc :color c)
|
||
@*alignment (assoc :alignment @*alignment)
|
||
@*mode (assoc :mode @*mode))}]
|
||
(on-chosen nil icon-item true))))
|
||
;; Trash button — mirrors the outer icon-picker's three modes.
|
||
;; ::deleted? must be flipped before any branch so the will-unmount
|
||
;; doesn't re-commit the about-to-be-deleted text icon.
|
||
(let [trash-icon (shui/tabler-icon "trash" {:size 17})
|
||
flag-delete-and-call (fn [action]
|
||
(reset! *deleted? true)
|
||
(on-delete action))]
|
||
(cond
|
||
(= delete-mode :hidden) nil
|
||
|
||
(= delete-mode :remove)
|
||
(shui/button {:variant :outline :size :sm :data-action "del"
|
||
:title (t :icon/remove-icon)
|
||
:aria-label (t :icon/remove-icon)
|
||
:on-click #(flag-delete-and-call :remove)}
|
||
trash-icon)
|
||
|
||
(= delete-mode :suppress)
|
||
(shui/button {:variant :outline :size :sm :data-action "del"
|
||
:title (t :icon/hide-inherited-icon)
|
||
:aria-label (t :icon/hide-inherited-icon)
|
||
:on-click #(flag-delete-and-call :remove-entirely)}
|
||
trash-icon)
|
||
|
||
(= delete-mode :two-option)
|
||
(shui/dropdown-menu
|
||
(shui/dropdown-menu-trigger
|
||
{:as-child true}
|
||
(shui/button {:variant :outline :size :sm :data-action "del"
|
||
:title (t :icon/remove-icon-options)
|
||
:aria-label (t :icon/remove-icon-options)
|
||
:aria-haspopup "menu"}
|
||
trash-icon))
|
||
(shui/dropdown-menu-content
|
||
{:side "bottom" :align "end"}
|
||
(shui/dropdown-menu-item
|
||
{:on-select #(flag-delete-and-call :revert)}
|
||
(shui/tabler-icon "arrow-back-up" {:size 14 :class "mr-2 opacity-80"})
|
||
(t :icon/revert-to-default))
|
||
(shui/dropdown-menu-item
|
||
{:on-select #(flag-delete-and-call :remove-entirely)}
|
||
(shui/tabler-icon "trash" {:size 14 :class "mr-2 opacity-80"})
|
||
(t :icon/remove-entirely))))))]]
|
||
(shui/separator {:class "my-0 opacity-50"})]
|
||
|
||
;; Body
|
||
[:div.text-picker-body
|
||
;; 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
|
||
;; `:color? true` wraps the SVG in `.ls-icon-color-wrap`
|
||
;; so the gallery preview's text picks up the user's
|
||
;; chosen color via `currentColor` (same fix as the
|
||
;; Custom-tab Text tile in custom-tab-cp).
|
||
(icon (build-icon display-text) {:size 36 :color? true})
|
||
(shui/tabler-icon "pencil" {:size 20}))]
|
||
[:span.text-picker-gallery-label label]]))]
|
||
|
||
;; Themed via the shared `icon-picker-separator` class instead of
|
||
;; shadcn's `bg-border opacity-50` default, which fades the line
|
||
;; to near-invisible in dark OG. `-mx-3` keeps the edge-to-edge
|
||
;; geometry that the original layout depended on.
|
||
(shui/separator {:class "my-0 -mx-3 icon-picker-separator"})
|
||
|
||
;; Controls row: Text input + Alignment side by side
|
||
[:div.text-picker-controls-row
|
||
;; Text input
|
||
[:div.text-picker-section.flex-1
|
||
[:label (t :icon.text-picker/text-input-label)]
|
||
(shui/input
|
||
{:size "sm"
|
||
:auto-focus true
|
||
:max-length 8
|
||
:placeholder derived-initials
|
||
:value @*text-value
|
||
:on-change (fn [e]
|
||
(let [v (util/evalue e)]
|
||
(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! *mode "initials"))
|
||
(persist!))})]
|
||
|
||
;; Alignment
|
||
[:div.text-picker-section
|
||
[:label (t :icon.text-picker/alignment-label)]
|
||
[:div.text-picker-alignment
|
||
(shui/button-group
|
||
(for [align ["left" "center" "right"]
|
||
:let [active? (= @*alignment align)]]
|
||
(shui/button
|
||
{:key align
|
||
:variant (if active? :secondary :outline)
|
||
:size :sm
|
||
:on-click (fn []
|
||
(reset! *alignment align)
|
||
(persist!))}
|
||
(shui/tabler-icon (str "align-" align) {:size 16}))))]]]]]))
|
||
|
||
(rum/defc icon-hover-effects
|
||
"Phantom function-component hosting React hooks for icon-hover-preview.
|
||
`icon-search` is a class component (rum/defcs + rum/reactive mixin) and
|
||
can't host hooks itself, so the lifecycle effects live here. Renders nil.
|
||
|
||
- On `current-id` change → broadcast hovered item or clear when invalid.
|
||
Deps on `current-id` (a stable string) so refires when flat-items
|
||
shift under a stable index — section collapse, search refilter.
|
||
- On unmount → clear preview. Catches every popover-close path
|
||
(Esc, click-outside, commit, programmatic) without threading a
|
||
callback through Radix's onCloseAutoFocus."
|
||
[{:keys [current-id current-item broadcast! clear!]}]
|
||
(hooks/use-effect!
|
||
(fn []
|
||
(if current-item
|
||
(broadcast! current-item)
|
||
(clear!)))
|
||
[current-id])
|
||
(hooks/use-effect!
|
||
(fn [] (fn [] (clear!)))
|
||
[])
|
||
nil)
|
||
|
||
(defn- compute-delete-mode
|
||
"Classify the picker's delete affordance based on entity + property scope.
|
||
|
||
Returns one of:
|
||
- `:two-option` — entity has an own icon AND a class default-icon would
|
||
inherit if the override were retracted. Picker opens a dropdown:
|
||
`[↩ Revert to default]` retracts; `[🗑 Remove entirely]` writes
|
||
`{:type :none}`.
|
||
- `:remove` — entity has an own icon but no class inheritance source
|
||
(or scope is class default-icon itself, which has no inheritance above
|
||
it). Single-click retract.
|
||
- `:suppress` — entity has NO own icon but IS inheriting a class
|
||
default-icon. Single-click writes `{:type :none}` to hide the
|
||
inherited icon on this entity only. Recovery is via the page-title
|
||
'Restore icon' affordance.
|
||
- `:hidden` — nothing to act on: no own icon and no class default
|
||
to suppress, OR entity is already suppressed via `:type :none`
|
||
(restoration lives in the page-title affordance, not here).
|
||
|
||
Reads `:block/tags` + `:logseq.property.class/default-icon` so
|
||
`db-mixins/query` registers them as render deps — the trash UI updates
|
||
when a class's default-icon changes."
|
||
[entity property]
|
||
(let [own (when entity (get entity property))
|
||
none? (= :none (:type own))
|
||
has-real-own? (and own (not none?))
|
||
scope-page-icon? (= property :logseq.property/icon)
|
||
;; Inheritance only applies to the page-icon scope. The class
|
||
;; default-icon picker IS the source — nothing above it.
|
||
class-default (when (and scope-page-icon? entity)
|
||
(some :logseq.property.class/default-icon
|
||
(sort-by :db/id (:block/tags entity))))
|
||
tag-icon (when (and scope-page-icon? entity)
|
||
(some :logseq.property/icon
|
||
(sort-by :db/id (:block/tags entity))))
|
||
;; Class entity whose own icon equals its default-icon — block.cljs's
|
||
;; sync-clear path at :3974-3980 already handles this as one action.
|
||
synced-class? (and entity
|
||
(ldb/class? entity)
|
||
(= own (:logseq.property/icon entity))
|
||
(= own (:logseq.property.class/default-icon entity)))]
|
||
(cond
|
||
none? :hidden
|
||
;; No own override but a class default-icon inherits in.
|
||
;; Single-click "Hide inherited" writes :none sentinel.
|
||
;; Tag-icon inheritance (a tag's OWN icon, not its class default)
|
||
;; is intentionally excluded — that's not a "user opted into a
|
||
;; default" relationship, so suppression has no clear UX story there.
|
||
(and (not has-real-own?) class-default) :suppress
|
||
(not has-real-own?) :hidden
|
||
synced-class? :remove
|
||
(or class-default tag-icon) :two-option
|
||
:else :remove)))
|
||
|
||
(rum/defcs ^:large-vars/cleanup-todo icon-search < rum/reactive db-mixins/query
|
||
(rum/local "" ::q)
|
||
(rum/local nil ::result)
|
||
(rum/local :search ::focus-region)
|
||
(rum/local nil ::highlighted-index)
|
||
(rum/local :all ::tab)
|
||
(rum/local false ::input-focused?)
|
||
(rum/local nil ::virtuoso-ref)
|
||
(rum/local :icon-picker ::view) ;; Default view, updated in :will-mount for avatars/images
|
||
(rum/local nil ::asset-picker-initial-mode) ;; Optional :avatar | :image override when navigating from Custom tab tiles
|
||
;; Holds the result of an async worker fetch for "all instances of
|
||
;; the class being edited", used by the class default-icon picker
|
||
;; to broadcast hover-preview to inheriting instance rows. Fetched
|
||
;; in :will-mount because the main-thread conn doesn't have the
|
||
;; full instance set (Logseq partial-replicates from worker) — so
|
||
;; sync `(:block/_tags class)` access returns empty.
|
||
(rum/local nil ::fetched-inheritor-ids)
|
||
;; Per-instance drag-and-drop state for the OUTER icon-search drop zone.
|
||
;; Independent of the asset-picker's own drag state, even when nested —
|
||
;; previously they collided on a shared module-global atom.
|
||
(rum/local false ::drag-active?)
|
||
(rum/local 0 ::drag-depth)
|
||
;; Cached local image assets, loaded once on mount. Search filters in-memory
|
||
;; against this atom on every keystroke (microsecond cost vs. DB-query
|
||
;; latency). Sync `get-image-assets` populates it immediately; the worker's
|
||
;; async result overwrites once it lands. Mirrors the asset-picker's pattern.
|
||
(rum/local nil ::loaded-assets)
|
||
{:will-mount (fn [s]
|
||
(let [opts (first (:rum/args s))
|
||
icon-value (:icon-value opts)
|
||
normalized (normalize-icon icon-value)
|
||
*view (::view s)
|
||
*tab (::tab s)
|
||
*fetched-inheritor-ids (::fetched-inheritor-ids s)
|
||
;; Prefer current icon's color; fall back to last-used preset.
|
||
;; "inherit" is a CSS-layer sentinel (--ls-color-icon-preset),
|
||
;; not a real color — drop it before it reaches React state.
|
||
denull #(when (and % (not= % "inherit")) %)
|
||
icon-color (denull (get-in normalized [:data :color]))
|
||
;; `initial-color` lets a parent (e.g. the avatar
|
||
;; customize band's "Icon…" sub-picker) seed the
|
||
;; color atom so the picker grid renders in the
|
||
;; parent's chosen tint instead of the last-used
|
||
;; preset. Takes precedence over icon-value's
|
||
;; color so a sub-picker for an icon-less avatar
|
||
;; still inherits the avatar's color.
|
||
initial-color (denull (:initial-color opts))
|
||
stored (denull (storage/get :ls-icon-color-preset))
|
||
;; If the caller restricts the available tabs (e.g.
|
||
;; the avatar customize band's icon-fallback sub-picker
|
||
;; passes `:allowed-tabs [:all :emoji :icon]` to drop
|
||
;; the Custom tab), seed `*tab` to the first allowed
|
||
;; entry so we don't land on a hidden tab.
|
||
allowed (some-> (:allowed-tabs opts) set)
|
||
;; For class default-icon edits, fetch the inheriting
|
||
;; instance ids from the worker — main-thread conn
|
||
;; doesn't have them. Resolves into the local atom;
|
||
;; the render reads via `(rum/react)` so re-render
|
||
;; fires when the promise lands.
|
||
preview-target-db-id (:preview-target-db-id opts)
|
||
property (or (:property opts) :logseq.property/icon)
|
||
*loaded-assets (::loaded-assets s)
|
||
no-assets? (:no-assets? opts)]
|
||
;; Load image assets once for search filtering. Skip
|
||
;; entirely when the caller opted out (avatar fallback
|
||
;; sub-picker etc.) — those contexts shouldn't surface
|
||
;; image assets in search results.
|
||
(when-not no-assets?
|
||
(let [sync-assets (get-image-assets)]
|
||
(when (seq sync-assets)
|
||
(reset! *loaded-assets sync-assets)))
|
||
(-> (<get-image-assets)
|
||
(p/then (fn [async-assets]
|
||
(when (some? @*loaded-assets)
|
||
(reset! *loaded-assets (vec async-assets)))
|
||
;; First load may land empty when sync was
|
||
;; empty too — populate so search can match.
|
||
(when (and (nil? @*loaded-assets)
|
||
(seq async-assets))
|
||
(reset! *loaded-assets (vec async-assets)))))
|
||
(p/catch (fn [_] nil))))
|
||
;; Avatar/image icons open asset picker, text icons open text-picker
|
||
(when (contains? #{:avatar :image :text} (:type normalized))
|
||
(reset! *view (if (= :text (:type normalized)) :text-picker :asset-picker)))
|
||
(when (and allowed (not (allowed @*tab)))
|
||
(reset! *tab (first allowed)))
|
||
(when (and preview-target-db-id
|
||
(= property :logseq.property.class/default-icon)
|
||
(not (seq (:preview-inheritor-db-ids opts))))
|
||
(-> (db-async/<get-tag-objects (state/get-current-repo) preview-target-db-id)
|
||
(p/then (fn [instances]
|
||
(reset! *fetched-inheritor-ids
|
||
(->> instances
|
||
(remove :logseq.property/icon)
|
||
(map :db/id)
|
||
set
|
||
not-empty))))
|
||
(p/catch (fn [_] nil))))
|
||
(assoc s ::color (atom (or initial-color icon-color stored))
|
||
::input-ref (rum/create-ref)
|
||
::result-ref (rum/create-ref))))
|
||
:did-mount (fn [s]
|
||
;; Belt-and-braces for non-Radix call sites where the
|
||
;; picker is opened as a free-floating popup (`shui/popup-show!`
|
||
;; rather than `dropdown-menu-sub-content`). The Radix
|
||
;; sub-menu path now focuses synchronously via
|
||
;; `onOpenAutoFocus` (see avatar fallback wiring at
|
||
;; icon.cljs:~3850), which is more reliable than racing
|
||
;; FocusScope on a `setTimeout 0`. This `:did-mount` is a
|
||
;; safety net so existing call sites that don't pass
|
||
;; their own focus handoff still get the input focused.
|
||
(js/setTimeout
|
||
(fn []
|
||
(when-let [^js input (rum/deref (::input-ref s))]
|
||
(when (not= js/document.activeElement input)
|
||
(.focus input))))
|
||
0)
|
||
;; Apply the initial color tint to the picker root so the
|
||
;; icon grid renders in the parent's color even when the
|
||
;; color-picker swatch is suppressed (e.g. the avatar
|
||
;; fallback sub-picker passes `:color-btn? false`). The
|
||
;; visible tint is driven by the `--ls-color-icon-preset`
|
||
;; CSS variable + the `icon-colored` class — both are
|
||
;; normally set by the color-picker swatch's use-effect
|
||
;; (icon.cljs:~5530); replicate that here on first paint
|
||
;; so a `:color-btn? false` picker still picks up the
|
||
;; caller's `:initial-color`.
|
||
(let [c @(::color s)]
|
||
(when (and c (not (string/blank? c)) (not= c "inherit"))
|
||
(js/setTimeout
|
||
(fn []
|
||
(when-let [^js input (rum/deref (::input-ref s))]
|
||
(when-let [^js picker (.closest input ".cp__emoji-icon-picker")]
|
||
(.setProperty (.-style picker) "--ls-color-icon-preset" c)
|
||
(.add (.-classList picker) "icon-colored"))))
|
||
0)))
|
||
s)}
|
||
[state {:keys [on-chosen del-btn? icon-value page-title preview-target-db-id preview-target-db-ids
|
||
preview-inheritor-db-ids
|
||
allowed-tabs hover-wrap-fn color-btn? property hide-topbar?]
|
||
:or {color-btn? true
|
||
;; `property` scopes hover-preview broadcasts so two pickers
|
||
;; on the same entity but different properties (e.g.
|
||
;; page-title's `:logseq.property/icon` and class
|
||
;; default-icon's `:logseq.property.class/default-icon`)
|
||
;; don't leak previews into each other's surfaces. Default
|
||
;; to `:logseq.property/icon` because that's the dominant
|
||
;; case; callers editing other fields pass their own.
|
||
property :logseq.property/icon}
|
||
:as opts}]
|
||
;; `color-btn?` defaults to true so existing call sites are unchanged;
|
||
;; the avatar fallback sub-picker passes `:color-btn? false` because
|
||
;; the parent asset-picker already exposes a color swatch for the
|
||
;; whole avatar (and `del-btn?` is similarly suppressed).
|
||
(let [*q (::q state)
|
||
*result (::result state)
|
||
*tab (::tab state)
|
||
;; Per-instance drag state for the outer drop zone (see mixin comment).
|
||
*drag-active? (::drag-active? state)
|
||
*drag-depth (::drag-depth state)
|
||
*color (::color state)
|
||
*input-focused? (::input-focused? state)
|
||
*view (::view state)
|
||
*asset-picker-initial-mode (::asset-picker-initial-mode state)
|
||
*loaded-assets (::loaded-assets state)
|
||
*input-ref (::input-ref state)
|
||
*result-ref (::result-ref state)
|
||
*virtuoso-ref (::virtuoso-ref state)
|
||
result @*result
|
||
;; Live preview broadcast: writes the hovered icon (mouse hover or
|
||
;; keyboard nav) into the global :ui/icon-hover-preview slot, which
|
||
;; the page-icon readers (get-node-icon-cp, icon-picker-trigger-icon)
|
||
;; sub. Carries `@*color` along so the previewed icon shows in the
|
||
;; user's pending tint, not the icon's stored color.
|
||
;;
|
||
;; Allow-list of types whose render path can produce a meaningful
|
||
;; page icon.
|
||
previewable-tile-type? #{:icon :tabler-icon :emoji :text :avatar :image :image-placeholder}
|
||
;; Custom-tab buttons broadcast the *synthesized* preview item
|
||
;; that the button itself renders (mirrors custom-tab-cp:1865-
|
||
;; 1929), not the navigational marker. Keyboard hover on "Text"
|
||
;; previews the page icon as the same Pe-style text icon shown in
|
||
;; the button; "Avatar" → the PE circle; "Image" → a photo glyph.
|
||
;; Synthesized inline so we avoid plumbing values out of custom-
|
||
;; tab-cp (which is a child component).
|
||
derived-title (or page-title
|
||
(some-> (state/get-current-page) (db/get-page) (:block/title)))
|
||
custom-text-value (if (string/blank? @*q)
|
||
(derive-initials derived-title)
|
||
(subs @*q 0 (min 8 (count @*q))))
|
||
custom-avatar-value (if (string/blank? @*q)
|
||
(derive-avatar-initials derived-title)
|
||
(subs @*q 0 (min 3 (count @*q))))
|
||
custom-bg (or (when-not (string/blank? @*color) @*color)
|
||
(colors/variable :gray :09))
|
||
custom-fg (or (when-not (string/blank? @*color) @*color)
|
||
(colors/variable :gray :09))
|
||
custom-preview-items
|
||
{:custom-text (when custom-text-value
|
||
{:type :text
|
||
:id (str "text-" custom-text-value)
|
||
:label custom-text-value
|
||
:data (cond-> {:value custom-text-value}
|
||
(not (string/blank? @*color)) (assoc :color @*color))})
|
||
:custom-avatar (when custom-avatar-value
|
||
{:type :avatar
|
||
:id (str "avatar-" custom-avatar-value)
|
||
:label custom-avatar-value
|
||
:data {:value custom-avatar-value
|
||
:backgroundColor custom-bg
|
||
:color custom-fg}})
|
||
;; Image has no concrete asset yet (clicking the button opens the
|
||
;; asset-picker), so we preview Logseq's universal "no icon yet,
|
||
;; click to add" placeholder — plus inside a dashed rounded
|
||
;; square. Avoids reading as a committed photo-themed icon.
|
||
:custom-image {:type :image-placeholder
|
||
:id "image-placeholder"}}
|
||
;; Derive the inheritor set via async worker query. The
|
||
;; main-thread conn is a partial subset of the worker's data
|
||
;; — instances aren't eagerly loaded, so `(:block/_tags
|
||
;; class)` returns empty here even when the worker has them.
|
||
;; Mirrors the precedent at `value.cljs:1131`.
|
||
;; Caller-passed `:preview-inheritor-db-ids` still wins so
|
||
;; explicit batch flows (selection toolbar) can override.
|
||
*fetched-inheritor-ids (::fetched-inheritor-ids state)
|
||
derived-inheritor-db-ids (or (not-empty (set preview-inheritor-db-ids))
|
||
(rum/react *fetched-inheritor-ids))
|
||
preview-targets-set? (or preview-target-db-id
|
||
(seq preview-target-db-ids)
|
||
(seq derived-inheritor-db-ids))
|
||
;; Every preview write carries `:property` so readers in
|
||
;; different fields editing the same entity (page-title icon vs
|
||
;; class default-icon) can isolate by property. Default
|
||
;; `:logseq.property/icon` matches the implicit scope of
|
||
;; sidebar / cmdk / page-title rendering — unscoped callers
|
||
;; keep working as before.
|
||
;;
|
||
;; Two independent scopes:
|
||
;; PRIMARY — `:db-id` (singleton) or `:db-ids` (set of
|
||
;; peers, e.g. batch-select). `:property` is
|
||
;; the property they all render.
|
||
;; INHERITOR — `:inheritor-db-ids` carries entities that
|
||
;; inherit from the primary entity but render
|
||
;; a DIFFERENT property. Used by the class
|
||
;; default-icon picker: the class itself is
|
||
;; primary (rendering `:logseq.property.class/default-icon`),
|
||
;; instances are inheritors (rendering
|
||
;; `:logseq.property/icon`, falling back through
|
||
;; `get-node-icon`'s inheritance).
|
||
;;
|
||
;; Splitting the two scopes keeps the class entity's OWN
|
||
;; `:logseq.property/icon` (page-title, sidebar) outside the
|
||
;; preview — it's a different property than what's under edit
|
||
;; and shouldn't tint when the user is editing default-icon.
|
||
preview-base-target (cond-> {:property property}
|
||
preview-target-db-id (assoc :db-id preview-target-db-id)
|
||
(seq preview-target-db-ids) (assoc :db-ids (set preview-target-db-ids))
|
||
(seq derived-inheritor-db-ids)
|
||
(assoc :inheritor-property :logseq.property/icon
|
||
:inheritor-db-ids (set derived-inheritor-db-ids)))
|
||
clear-tile-hover!
|
||
(fn []
|
||
(when preview-targets-set?
|
||
;; Drop only the `:icon` field — keep any active `:color`
|
||
;; from the color popover so moving the mouse off a tile
|
||
;; into the color swatches doesn't wipe the color overlay
|
||
;; on the way. `dissoc-icon-preview-field!` clears the
|
||
;; whole slot when no visual fields remain, so cleanup is
|
||
;; still complete when neither tile nor color is active.
|
||
(dissoc-icon-preview-field! preview-base-target :icon)))
|
||
broadcast-tile-hover!
|
||
(fn [item]
|
||
;; Custom-tab navigational markers (:custom-text/:custom-avatar/
|
||
;; :custom-image) map to the synthesized preview items above.
|
||
;; Everything else falls through with its own type.
|
||
;; When `hover-wrap-fn` is set (sub-picker context, e.g. avatar
|
||
;; fallback), it transforms each tile into the shape the
|
||
;; *parent* picker wants to preview — so a bare tabler-icon
|
||
;; tile broadcasts as a fully-wrapped avatar with the parent's
|
||
;; shape/color/initials. Without the wrap, page-icon readers
|
||
;; would render the bare icon during hover, defeating the
|
||
;; live-preview affordance.
|
||
(let [resolved (or (get custom-preview-items (:type item)) item)
|
||
wrapped (or (when hover-wrap-fn (hover-wrap-fn resolved))
|
||
resolved)]
|
||
(cond
|
||
(not (previewable-tile-type? (:type wrapped)))
|
||
(clear-tile-hover!)
|
||
|
||
preview-targets-set?
|
||
(let [normalized (normalize-icon wrapped)]
|
||
;; Merge — preserves any active `:color` from a color
|
||
;; popover hover so the previewed tile reads with the
|
||
;; previewed tint. Without the merge, the color slot
|
||
;; would clobber on every tile hover and the user
|
||
;; would lose their color preview while picking.
|
||
(merge-into-icon-preview!
|
||
(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
|
||
;; from the live entity. The static del-btn? prop is captured in the popup
|
||
;; closure and goes stale across keep-popup? flows (e.g. picking a color
|
||
;; on an inherited icon, which writes the icon to the entity for the first
|
||
;; time). Treat the :none sentinel (set on delete) as "no icon".
|
||
;; `property` selects the right entity attribute — `:logseq.property/icon`
|
||
;; for page-icon pickers, `:logseq.property.class/default-icon` for the
|
||
;; class default-icon picker. Without this, a default-icon picker would
|
||
;; read the unrelated `:logseq.property/icon` and stay stale across commits.
|
||
;; Reactively derive both `del-btn?` (a boolean, kept for back-compat
|
||
;; with downstream callers) and `delete-mode` (a keyword that drives
|
||
;; the new dropdown UX — see `compute-delete-mode`). Both subscribe
|
||
;; to the live entity via model/sub-block.
|
||
live-entity (when preview-target-db-id (model/sub-block preview-target-db-id))
|
||
delete-mode (if preview-target-db-id
|
||
(compute-delete-mode live-entity property)
|
||
(if del-btn? :remove :hidden))
|
||
del-btn? (not= delete-mode :hidden)
|
||
;; 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)
|
||
;; Pick the right entity attribute based on
|
||
;; scope — `:logseq.property/icon` for the
|
||
;; page-icon picker, `:logseq.property.class/
|
||
;; default-icon` for the class default-icon
|
||
;; picker. The Default Icon commit writes to
|
||
;; the latter, so without this lookup the
|
||
;; reactive override returned the unrelated
|
||
;; `:logseq.property/icon` (often nil or the
|
||
;; old page-icon value) and the asset-picker
|
||
;; tile + Fallback chip stayed frozen at the
|
||
;; pre-edit state.
|
||
(get property))
|
||
icon-value)
|
||
icon-value)
|
||
normalized-icon-value (normalize-icon icon-value)
|
||
opts (assoc opts
|
||
:input-focused? @*input-focused?
|
||
:*virtuoso-ref *virtuoso-ref
|
||
:on-tile-hover! broadcast-tile-hover!
|
||
:on-chosen (fn [e m & [keep-popup?]]
|
||
(let [icon-item (normalize-icon m)
|
||
can-have-color? (contains? #{:icon :avatar :text} (:type icon-item))
|
||
;; Update color if user selected one from picker
|
||
;; Skip for :text — text-picker manages its own color
|
||
m' (if (and can-have-color? (not (string/blank? @*color)) (not (= :text (:type icon-item))))
|
||
(cond-> m
|
||
;; For icons: set color (top-level for block.cljs select-keys, nested for icon-cp)
|
||
(= :icon (:type icon-item))
|
||
(-> (assoc :color @*color)
|
||
(assoc-in [:data :color] @*color))
|
||
|
||
;; For avatars: set both color (text) and backgroundColor
|
||
(= :avatar (:type icon-item))
|
||
(-> (assoc :color @*color)
|
||
(assoc-in [:data :color] @*color)
|
||
(assoc-in [:data :backgroundColor] @*color)))
|
||
m)]
|
||
(and on-chosen (on-chosen e m' keep-popup?))
|
||
(when (:type icon-item) (add-used-item! icon-item)))))
|
||
*focus-region (::focus-region state)
|
||
*highlighted-index (::highlighted-index state)
|
||
;; Use `rum/react` (not bare deref) so changes to
|
||
;; `*highlighted-index` from `keyboard-nav-controller` arrow-key
|
||
;; handlers re-render icon-search. Without this, `highlighted-id`
|
||
;; below was computed once at popup-show and never refreshed —
|
||
;; the phantom `icon-hover-effects` component (which broadcasts
|
||
;; the highlighted item to `:ui/icon-hover-preview`) never saw a
|
||
;; fresh `current-id`, so keyboard nav silently no-op'd while
|
||
;; mouse hover worked. One subscription is enough; the other
|
||
;; reads can stay as bare derefs once the component is hooked up.
|
||
highlighted-idx (rum/react *highlighted-index)
|
||
section-states @*section-states
|
||
{flat-items :items sections :sections} (compute-flat-items @*tab result section-states
|
||
{:show-used? (:show-used? opts)})
|
||
highlighted-id (when-let [idx highlighted-idx]
|
||
(when (< idx (count flat-items))
|
||
(:id (nth flat-items idx))))
|
||
highlighted-section (when-let [idx highlighted-idx]
|
||
(when-let [si (section-for-index idx sections)]
|
||
(:label (nth sections si))))
|
||
ghost-highlighted-id (when (and (= @*focus-region :search)
|
||
(nil? highlighted-idx)
|
||
(pos? (count flat-items)))
|
||
(:id (first flat-items)))
|
||
ghost-highlighted-section (when ghost-highlighted-id
|
||
(:label (first sections)))
|
||
opts (assoc opts
|
||
:highlighted-id highlighted-id
|
||
:highlighted-section highlighted-section
|
||
:ghost-highlighted-id ghost-highlighted-id
|
||
:ghost-highlighted-section ghost-highlighted-section
|
||
:focus-region @*focus-region)
|
||
reset-q! #(when-let [^js input (rum/deref *input-ref)]
|
||
(reset! *q "")
|
||
(reset! *result {})
|
||
(reset! *focus-region :search)
|
||
(reset! *highlighted-index nil)
|
||
(clear-tile-hover!)
|
||
(set! (. input -value) "")
|
||
(util/schedule
|
||
(fn []
|
||
(when (not= js/document.activeElement input)
|
||
(.focus input))
|
||
(util/scroll-to (rum/deref *result-ref) 0 false))))]
|
||
(case @*view
|
||
:asset-picker
|
||
;; Level 2: Asset Picker view
|
||
(asset-picker {:on-chosen (fn [e icon-data & [keep-popup?]]
|
||
;; 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)
|
||
;; Now 1-arg — the sub-picker's own dropdown supplies the
|
||
;; action keyword (:revert | :remove | :remove-entirely).
|
||
;; Fall back to :remove if called without an action (defensive).
|
||
:on-delete (fn [& [action]]
|
||
(reset-picker-transient-state!
|
||
{:*asset-picker-initial-mode *asset-picker-initial-mode})
|
||
(on-chosen nil nil (or action :remove)))
|
||
:del-btn? del-btn?
|
||
:delete-mode delete-mode
|
||
:current-icon normalized-icon-value
|
||
:avatar-context (when (= :avatar (:type normalized-icon-value))
|
||
normalized-icon-value)
|
||
;; Custom-tab tile clicks set this so the asset-picker
|
||
;; lands on the requested tab; otherwise nil and it
|
||
;; falls back to current-icon / avatar-context cues.
|
||
:initial-mode @*asset-picker-initial-mode
|
||
:page-title page-title
|
||
;; Threaded so the asset-picker can host its own color
|
||
;; trigger (Avatar mode only) without diverging state —
|
||
;; it observes and writes the same atom the icon-picker's
|
||
;; topbar trigger uses, so backing out reflects the new
|
||
;; color in the parent immediately.
|
||
:*color *color
|
||
;; Same db-id used by the icon-picker for live hover
|
||
;; 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-ids preview-target-db-ids
|
||
:preview-inheritor-db-ids preview-inheritor-db-ids
|
||
;; Property scope for hover-preview isolation —
|
||
;; flows down so asset-picker's own preview writes
|
||
;; (Shape/Fallback hovers, color swatch) carry the
|
||
;; same scope and target only the right surfaces.
|
||
:property property})
|
||
|
||
:text-picker
|
||
;; Level 2: Text Picker view
|
||
(text-picker {:on-chosen (:on-chosen opts)
|
||
:on-back #(reset! *view :icon-picker)
|
||
:on-delete (fn [& [action]]
|
||
(reset-picker-transient-state!
|
||
{:*asset-picker-initial-mode *asset-picker-initial-mode})
|
||
(on-chosen nil nil (or action :remove)))
|
||
:del-btn? del-btn?
|
||
:delete-mode delete-mode
|
||
:current-icon normalized-icon-value
|
||
:selected-color @*color
|
||
:page-title page-title})
|
||
|
||
;; Default - Level 1: Icon Picker view
|
||
[:div.cp__emoji-icon-picker
|
||
{:data-keep-selection true
|
||
:class (when (rum/react *drag-active?) "drag-active")
|
||
:on-drag-enter (fn [e]
|
||
(.preventDefault e)
|
||
(.stopPropagation e)
|
||
(swap! *drag-depth inc)
|
||
(when (= @*drag-depth 1)
|
||
(reset! *drag-active? true)
|
||
;; Lock scroll behind overlay
|
||
(when-let [bd (.querySelector (.-currentTarget e) ".bd-scroll")]
|
||
(set! (.. bd -style -overflowY) "hidden"))
|
||
(when-let [vs (.querySelector (.-currentTarget e) "[data-virtuoso-scroller]")]
|
||
(set! (.. vs -style -overflowY) "hidden"))))
|
||
:on-drag-over (fn [e]
|
||
(.preventDefault e)
|
||
(.stopPropagation e))
|
||
:on-drag-leave (fn [e]
|
||
(.preventDefault e)
|
||
(.stopPropagation e)
|
||
(swap! *drag-depth dec)
|
||
(when (<= @*drag-depth 0)
|
||
(reset! *drag-depth 0)
|
||
(reset! *drag-active? false)
|
||
;; Restore scroll
|
||
(when-let [bd (.querySelector (.-currentTarget e) ".bd-scroll")]
|
||
(set! (.. bd -style -overflowY) ""))
|
||
(when-let [vs (.querySelector (.-currentTarget e) "[data-virtuoso-scroller]")]
|
||
(set! (.. vs -style -overflowY) ""))))
|
||
:on-drop (fn [e]
|
||
(.preventDefault e)
|
||
(.stopPropagation e)
|
||
(reset! *drag-depth 0)
|
||
(reset! *drag-active? false)
|
||
;; Restore scroll
|
||
(when-let [bd (.querySelector (.-currentTarget e) ".bd-scroll")]
|
||
(set! (.. bd -style -overflowY) ""))
|
||
(when-let [vs (.querySelector (.-currentTarget e) "[data-virtuoso-scroller]")]
|
||
(set! (.. vs -style -overflowY) ""))
|
||
(let [files (array-seq (.. e -dataTransfer -files))
|
||
file (first files)
|
||
repo (state/get-current-repo)]
|
||
(when file
|
||
(let [file-type (.-type file)
|
||
ext (some-> file-type (string/split "/") second keyword)]
|
||
(if (contains? config/image-formats ext)
|
||
(p/let [entity (<save-image-asset! repo file)]
|
||
(when entity
|
||
(let [image-data {:asset-uuid (str (:block/uuid entity))
|
||
:asset-type (:logseq.property.asset/type entity)}
|
||
avatar-ctx (when (= :avatar (:type normalized-icon-value))
|
||
normalized-icon-value)]
|
||
(on-chosen nil
|
||
(if avatar-ctx
|
||
{:type :avatar
|
||
:id (:id avatar-ctx)
|
||
:label (:label avatar-ctx)
|
||
:data (merge (:data avatar-ctx) image-data)}
|
||
{:type :image
|
||
:id (str "image-" (:block/uuid entity))
|
||
:label (or (:block/title entity) "")
|
||
:data image-data})))))
|
||
(shui/toast! (t :icon.upload/non-image-warning)
|
||
:warning))))))}
|
||
|
||
;; Drag overlay hint
|
||
(when @*drag-active?
|
||
[:div.drag-overlay-hint
|
||
[:div.corner.tl] [:div.corner.tr]
|
||
[:div.corner.bl] [:div.corner.br]
|
||
(shui/tabler-icon "upload" {:size 26})
|
||
[:div.text-group
|
||
[:span.title (t :icon.upload/drop-icon-overlay-title)]
|
||
[:span.subtitle (t :icon.upload/format-list)]]])
|
||
|
||
;; Phantom component hosting hover-preview lifecycle hooks.
|
||
;; Renders nothing; lives here because icon-search itself can't
|
||
;; host React hooks (class component via rum/reactive mixin).
|
||
(icon-hover-effects
|
||
{:current-id highlighted-id
|
||
:current-item (when (and highlighted-idx
|
||
(< highlighted-idx (count flat-items)))
|
||
(nth flat-items highlighted-idx))
|
||
:broadcast! broadcast-tile-hover!
|
||
:clear! clear-tile-hover!})
|
||
|
||
;; Always-mount invisible controllers. Lifted out of `.tabs-section`
|
||
;; so they keep working when the topbar is hidden via `:hide-topbar?`
|
||
;; (reaction pickers). They render nil; mounting them anywhere in
|
||
;; the picker tree gives keyboard-nav + tab-change side-effects.
|
||
(tab-observer @*tab {:q @*q :*result *result
|
||
:assets (rum/react *loaded-assets)
|
||
:no-assets? (:no-assets? opts)})
|
||
(keyboard-nav-controller
|
||
{:*focus-region *focus-region
|
||
:*highlighted-index *highlighted-index
|
||
:*tab *tab
|
||
:*input-ref *input-ref
|
||
:flat-items flat-items
|
||
:sections sections
|
||
:*virtuoso-ref *virtuoso-ref
|
||
:topbar-selector ".cp__emoji-icon-picker .tabs-section [data-topbar-stop]"})
|
||
|
||
;; Topbar: tabs + separator + search. Whole topbar collapses to
|
||
;; just the search input when `:hide-topbar?` is true (reactions:
|
||
;; emoji-only, no tabs, no color picker).
|
||
[:div.icon-picker-topbar
|
||
(when-not hide-topbar?
|
||
[:div.tabs-section {:role "tablist"}
|
||
(ui/tab-items
|
||
{:tabs (let [all-tabs [[:all (t :icon/tab-all)] [:emoji (t :icon/tab-emojis)]
|
||
[:icon (t :icon/tab-icons)] [:custom (t :icon/tab-custom)]]]
|
||
(if-let [allowed (some-> allowed-tabs set)]
|
||
(filterv (fn [[id _]] (allowed id)) all-tabs)
|
||
all-tabs))
|
||
:active @*tab
|
||
:on-change (fn [id ^js e]
|
||
(reset! *tab id)
|
||
(reset! *highlighted-index nil)
|
||
;; Only return focus to search for genuine mouse
|
||
;; clicks. Programmatic .click() from keyboard
|
||
;; arrow-rove (handle-topbar-keys auto-activate)
|
||
;; has e.detail = 0; real clicks are >= 1. Keeps
|
||
;; arrow nav inside the topbar region.
|
||
;;
|
||
;; Move DOM focus to the input alongside the
|
||
;; region reset — otherwise the keyboard-nav-
|
||
;; controller routes the next keypress to the
|
||
;; :search branch (which is no-op) while the
|
||
;; input itself can't fire its own on-key-down
|
||
;; because it isn't focused.
|
||
(when (and e (pos? (.-detail e)))
|
||
(reset! *focus-region :search)
|
||
(some-> (rum/deref *input-ref) (.focus))))
|
||
:button-attrs {:data-topbar-stop "tab"}
|
||
:tab-id-prefix "icon-picker"
|
||
:panel-id "icon-picker-panel"})
|
||
[:div.tab-actions
|
||
;; Color picker — gated by `color-btn?` so sub-picker call
|
||
;; sites (e.g. avatar fallback) can suppress it. The parent
|
||
;; picker already owns the avatar's color, and a duplicate
|
||
;; here can drift from the parent's value.
|
||
(when color-btn?
|
||
(color-picker *color (fn [c]
|
||
;; Synchronously update *color before calling
|
||
;; on-chosen. The on-chosen wrapper above re-applies
|
||
;; @*color over `m`, so without this it would over-
|
||
;; write the freshly-picked color with the previous
|
||
;; one (color-picker's React state hasn't propagated
|
||
;; to the *color atom yet — its useEffect runs after
|
||
;; this synchronous callback).
|
||
(reset! *color c)
|
||
(cond
|
||
(or (= :icon (:type normalized-icon-value))
|
||
(= :text (:type normalized-icon-value)))
|
||
(on-chosen nil (-> normalized-icon-value
|
||
(assoc :color c)
|
||
(assoc-in [:data :color] c)) true)
|
||
|
||
(= :avatar (:type normalized-icon-value))
|
||
(on-chosen nil (-> normalized-icon-value
|
||
(assoc :color c)
|
||
(assoc-in [:data :color] c)
|
||
(assoc-in [:data :backgroundColor] c)) true)))
|
||
;; After Radix's FocusScope unmounts (the popover
|
||
;; close), restore focus to the highlighted tile so
|
||
;; activeElement matches `.is-highlighted`. Running
|
||
;; in :after-close! (not on-select!) bypasses
|
||
;; Radix's FocusScope trap which would otherwise
|
||
;; undo the focus while the popover is mounted.
|
||
:after-close! (fn []
|
||
(let [^js cnt (some-> (rum/deref *input-ref) (.closest ".cp__emoji-icon-picker"))
|
||
idx @*highlighted-index
|
||
btn (when (and idx cnt)
|
||
(.querySelector cnt "button.is-highlighted"))]
|
||
(cond
|
||
;; Highlighted icon present — restore focus to
|
||
;; the tile so the user resumes where they left
|
||
;; off in the grid.
|
||
btn
|
||
(do (reset! *focus-region :grid)
|
||
(.focus btn))
|
||
|
||
;; No highlight to return to (e.g. user opened
|
||
;; the color picker without first navigating to
|
||
;; an icon). Fall back to the search input so
|
||
;; focus stays *inside* the picker container —
|
||
;; the capture-phase keydown listener only fires
|
||
;; for keys whose target is in the subtree, so
|
||
;; without this fallback the picker would appear
|
||
;; visually open but reject all keys.
|
||
cnt
|
||
(do (reset! *focus-region :search)
|
||
(some-> (rum/deref *input-ref) (.focus))))))
|
||
:on-hover! (when (or preview-target-db-id (seq preview-target-db-ids))
|
||
(fn [c]
|
||
;; Additive — preserves any
|
||
;; `:icon` from a tile hover
|
||
;; so the previewed tile shows
|
||
;; with the previewed color
|
||
;; overlaid.
|
||
(merge-into-icon-preview!
|
||
(assoc preview-base-target :color c))))
|
||
:on-hover-end! (when (or preview-target-db-id (seq preview-target-db-ids))
|
||
(fn []
|
||
(dissoc-icon-preview-field! preview-base-target :color)))
|
||
:button-attrs {:data-topbar-stop "color"}))
|
||
;; delete button — single-click in :remove mode, dropdown in :two-option mode.
|
||
;; NOTE: use `cond` (not `case`) — CLJS `case` with keyword tests + a
|
||
;; nil branch + Radix dropdown-menu children somehow leaks a keyword
|
||
;; into the React child tree. Reproduced specifically on icon-free
|
||
;; pages (delete-mode = :hidden → case returns nil → still throws
|
||
;; "Objects are not valid as a React child"). `cond` avoids the bug.
|
||
(let [trash-icon (shui/tabler-icon "trash" {:size 17})
|
||
reset-and-call (fn [action]
|
||
(reset-picker-transient-state!
|
||
{:*asset-picker-initial-mode *asset-picker-initial-mode})
|
||
(on-chosen nil nil action))]
|
||
(cond
|
||
(= delete-mode :hidden) nil
|
||
|
||
(= delete-mode :remove)
|
||
(shui/button {:variant :outline :size :sm :data-action "del"
|
||
:data-topbar-stop "trash"
|
||
:title (t :icon/remove-icon)
|
||
:aria-label (t :icon/remove-icon)
|
||
:on-click #(reset-and-call :remove)}
|
||
trash-icon)
|
||
|
||
(= delete-mode :suppress)
|
||
;; Same trash glyph + single click as :remove, but the action
|
||
;; is :remove-entirely (writes :none) to hide the inherited
|
||
;; class default-icon on this entity. Tooltip differentiates.
|
||
(shui/button {:variant :outline :size :sm :data-action "del"
|
||
:data-topbar-stop "trash"
|
||
:title (t :icon/hide-inherited-icon)
|
||
:aria-label (t :icon/hide-inherited-icon)
|
||
:on-click #(reset-and-call :remove-entirely)}
|
||
trash-icon)
|
||
|
||
(= delete-mode :two-option)
|
||
(shui/dropdown-menu
|
||
(shui/dropdown-menu-trigger
|
||
{:as-child true}
|
||
(shui/button {:variant :outline :size :sm :data-action "del"
|
||
:data-topbar-stop "trash"
|
||
:title (t :icon/remove-icon-options)
|
||
:aria-label (t :icon/remove-icon-options)
|
||
:aria-haspopup "menu"}
|
||
trash-icon))
|
||
(shui/dropdown-menu-content
|
||
{:side "bottom" :align "end"}
|
||
(shui/dropdown-menu-item
|
||
{:on-select #(reset-and-call :revert)}
|
||
(shui/tabler-icon "arrow-back-up" {:size 14 :class "mr-2 opacity-80"})
|
||
(t :icon/revert-to-default))
|
||
(shui/dropdown-menu-item
|
||
{:on-select #(reset-and-call :remove-entirely)}
|
||
(shui/tabler-icon "trash" {:size 14 :class "mr-2 opacity-80"})
|
||
(t :icon/remove-entirely))))))]])
|
||
|
||
(when-not hide-topbar?
|
||
(shui/separator {:class "my-0 icon-picker-separator"}))
|
||
|
||
[:div.search-section
|
||
[:div.search-input
|
||
(shui/tabler-icon "search" {:size 16})
|
||
[(shui/input
|
||
{:auto-focus true
|
||
:class "icon-search-input"
|
||
:ref *input-ref
|
||
:type "search"
|
||
:aria-label (if hide-topbar?
|
||
(t :icon/search-emojis)
|
||
(t :icon/search-all-aria-label))
|
||
:placeholder (if hide-topbar?
|
||
(t :icon/search-emojis)
|
||
(t :icon/search-all-placeholder))
|
||
:default-value ""
|
||
:on-focus #(do (reset! *focus-region :search)
|
||
(reset! *input-focused? true))
|
||
:on-blur #(reset! *input-focused? false)
|
||
:on-key-down (fn [^js e]
|
||
(let [code (.-keyCode e)]
|
||
(cond
|
||
;; Escape: clear search or close picker
|
||
(= code 27)
|
||
(do (util/stop e)
|
||
(if (string/blank? @*q)
|
||
(shui/popup-hide!)
|
||
(reset-q!)))
|
||
|
||
;; Up Arrow / Shift+Tab: move to topbar at the active tab
|
||
(or (= code 38)
|
||
(and (= code 9) (.-shiftKey e)))
|
||
(do (util/stop e)
|
||
(reset! *focus-region :topbar)
|
||
(reset! *highlighted-index nil)
|
||
(when-let [^js cnt (some-> (rum/deref *input-ref) (.closest ".cp__emoji-icon-picker"))]
|
||
(when-let [active-tab (.querySelector cnt "[data-active='true'].tab-item")]
|
||
(.focus active-tab))))
|
||
|
||
;; Tab / Down Arrow: enter grid at first item
|
||
(or (and (= code 9) (not (.-shiftKey e)))
|
||
(= code 40))
|
||
(do (util/stop e)
|
||
(when (pos? (count flat-items))
|
||
(reset! *focus-region :grid)
|
||
(reset! *highlighted-index 0)))
|
||
|
||
;; Enter: select ghost-highlighted item (first result)
|
||
(= code 13)
|
||
(when (and (nil? @*highlighted-index)
|
||
(pos? (count flat-items)))
|
||
(let [item (first flat-items)
|
||
item-id (:id item)]
|
||
(when-let [^js cnt (some-> (rum/deref *input-ref) (.closest ".cp__emoji-icon-picker"))]
|
||
(when-let [btn (.querySelector cnt (str "[data-item-id='" item-id "']"))]
|
||
(.click btn)
|
||
(util/stop e))))))))
|
||
:on-change (debounce
|
||
(fn [e]
|
||
(reset! *q (util/evalue e))
|
||
(reset! *focus-region :search)
|
||
(reset! *highlighted-index nil)
|
||
(if (string/blank? @*q)
|
||
(reset! *result {})
|
||
(p/let [result (search @*q @*tab
|
||
@*loaded-assets
|
||
{:no-assets? (:no-assets? opts)})]
|
||
(reset! *result result))))
|
||
200)})]
|
||
(when-not (string/blank? @*q)
|
||
[:a.x {:on-click reset-q!} (shui/tabler-icon "x" {:size 14})])]]]
|
||
|
||
;; Body
|
||
[:div.bd.bd-scroll
|
||
{:ref *result-ref
|
||
:class (or (some-> @*tab (name)) "other")
|
||
;; Mouse leaves the grid region → clear hover preview. Catches
|
||
;; the case where a tile gets unmounted (Virtuoso scroll, search
|
||
;; refilter) while the mouse is still inside the popover, so its
|
||
;; on-mouse-out never fires.
|
||
;;
|
||
;; Skip the clear when the cursor is heading INTO a Radix
|
||
;; popover (the color picker is portal'd to body and lives
|
||
;; inside `[data-radix-popper-content-wrapper]`). Without
|
||
;; this, the tile preview wipes the moment the mouse crosses
|
||
;; the grid edge to reach a color swatch — and the resulting
|
||
;; color hover lands on the committed icon instead of the
|
||
;; tile the user just had previewed. The check lets the
|
||
;; preview persist across the grid→popover transition; full
|
||
;; cleanup still runs on commit and on picker unmount.
|
||
:on-mouse-leave (fn [^js e]
|
||
;; `relatedTarget` can be `null` (mouse left
|
||
;; the window) OR a non-Element node (text
|
||
;; node when crossing certain DOM boundaries
|
||
;; in some browsers). Guard with `instanceof
|
||
;; js/Element` before calling `.closest`,
|
||
;; which is Element-only.
|
||
(let [related (.-relatedTarget e)
|
||
to-popover? (and related
|
||
(instance? js/Element related)
|
||
(.closest related "[data-radix-popper-content-wrapper]"))]
|
||
(when-not to-popover?
|
||
(clear-tile-hover!))))}
|
||
[:div.content-pane
|
||
(cond-> {:id "icon-picker-panel"}
|
||
;; Pane semantics depend on whether a tablist is rendered.
|
||
;; When the topbar is hidden (reactions), there's no tab to
|
||
;; label this panel, so we drop the tabpanel role + linkage —
|
||
;; the content is just an emoji grid inside the popover.
|
||
(not hide-topbar?)
|
||
(assoc :role "tabpanel"
|
||
:aria-labelledby (str "icon-picker-tab-" (name @*tab))))
|
||
;; Custom tab always shows its own content (Text/Avatar/Image buttons)
|
||
(if (= @*tab :custom)
|
||
(custom-tab-cp *q page-title *color *view *asset-picker-initial-mode icon-value opts)
|
||
;; Other tabs: show search results if present, else show tab content.
|
||
;; Tabs scope the search results by content type — :all shows both,
|
||
;; :emoji only emojis, :icon only icons. Mirrors the same gate in
|
||
;; compute-flat-items so the visible grid and the keyboard-nav
|
||
;; flat-items list stay in sync.
|
||
(if (seq result)
|
||
(let [section-states (rum/react *section-states)
|
||
tab-allows-emojis? (contains? #{:all :emoji} @*tab)
|
||
tab-allows-icons? (contains? #{:all :icon} @*tab)
|
||
tab-allows-assets? (= :all @*tab)
|
||
has-emojis? (and tab-allows-emojis? (seq (:emojis result)))
|
||
has-icons? (and tab-allows-icons? (seq (:icons result)))
|
||
has-assets? (and tab-allows-assets? (seq (:assets result)))
|
||
sections-visible (count (filter true? [has-emojis? has-icons? has-assets?]))
|
||
collapsible? (> sections-visible 1)]
|
||
(if (or has-emojis? has-icons? has-assets?)
|
||
[:div.flex.flex-1.flex-col.search-result
|
||
;; Emojis section
|
||
(when has-emojis?
|
||
(pane-section
|
||
"Emojis"
|
||
(:emojis result)
|
||
(assoc opts
|
||
:collapsible? collapsible?
|
||
:keyboard-hint (when collapsible? "alt mod 2")
|
||
:total-count (count (:emojis result))
|
||
:virtual-list? false
|
||
:expanded? (get section-states "Emojis" true))))
|
||
|
||
;; Icons section
|
||
(when has-icons?
|
||
(pane-section
|
||
"Icons"
|
||
(:icons result)
|
||
(assoc opts
|
||
:collapsible? collapsible?
|
||
:keyboard-hint (when collapsible? "alt mod 3")
|
||
:total-count (count (:icons result))
|
||
:virtual-list? false
|
||
:expanded? (get section-states "Icons" true))))
|
||
|
||
;; Assets section — bypasses pane-section for two reasons:
|
||
;; (1) the 64px tiles render in a dedicated 5-col grid
|
||
;; (.asset-picker-grid) rather than the 36px flex-wrap row
|
||
;; that pane-section's `.its` layout produces; (2) the
|
||
;; `.pane-section` class sets `color: var(--ls-color-icon-
|
||
;; preset)` to tint Tabler SVGs via currentColor — useful
|
||
;; for icons/emojis but a bug for image tiles, whose hover
|
||
;; border-color rule falls back to currentColor when the
|
||
;; undefined `--rx-accent-09` variable is dereferenced. By
|
||
;; avoiding `.pane-section` here we match the asset-picker's
|
||
;; cascade (neutral hover border, blue selected border).
|
||
(when has-assets?
|
||
[:div.assets-search-section
|
||
(section-header {:title "Assets"
|
||
:count (count (:assets result))
|
||
:total-count (count (:assets result))
|
||
:expanded? (get section-states "Assets" true)
|
||
:keyboard-hint (when collapsible? "alt mod 4")
|
||
:on-toggle (when collapsible?
|
||
#(swap! *section-states update "Assets"
|
||
(fn [v] (if (nil? v) false (not v)))))
|
||
:focus-region @*focus-region
|
||
:simple? (not collapsible?)})
|
||
(when (get section-states "Assets" true)
|
||
[:div.asset-picker-grid.assets-search-grid
|
||
(for [item (:assets result)
|
||
:let [my-id (:id item)
|
||
asset (:asset item)]]
|
||
(rum/with-key
|
||
(image-asset-item asset
|
||
(assoc opts
|
||
:variant :search
|
||
:item-id my-id
|
||
:highlighted? (= my-id (:highlighted-id opts))
|
||
:ghost-highlighted? (= my-id (:ghost-highlighted-id opts))))
|
||
my-id))])])]
|
||
;; Search returned no results
|
||
[:div.search-empty-state
|
||
(shui/tabler-icon "search-off" {:size 36})
|
||
[:span.title (t :icon/search-empty-title)]
|
||
[:span.subtitle (t :icon/search-empty-desc)]]))
|
||
[: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-trigger-icon < rum/reactive
|
||
"Reactive sub-component so the trigger icon re-renders on hover-preview changes
|
||
without forcing the parent (which uses React hooks) into a class component.
|
||
`property` scopes the preview match — defaults to `:logseq.property/icon`
|
||
so existing callers keep their behavior; property/value.cljs's
|
||
`default-icon-row` passes `:logseq.property.class/default-icon` so its
|
||
trigger only reflects previews from a Default-Icon-scoped picker."
|
||
[icon-value preview-target-db-id icon-props & [property]]
|
||
(let [property (or property :logseq.property/icon)
|
||
preview (when preview-target-db-id (state/sub :ui/icon-hover-preview))
|
||
preview-active? (and preview
|
||
(icon-preview-matches? preview preview-target-db-id property))
|
||
preview-icon (when preview-active? (:icon preview))
|
||
;; Source: previewed icon (cross-type swap) or the committed value.
|
||
;; Both go through the same color-overlay path below.
|
||
base-value (or preview-icon icon-value)
|
||
;; IMPORTANT: pre-normalize before mutating. The icon fn's normalize-icon
|
||
;; early-exits when :data is present, so adding [:data :color] to a non-
|
||
;; unified shape (e.g. {:type :icon :id "house"} with no :data :value)
|
||
;; bypasses normalization and the render cond fails → icon disappears.
|
||
;; Only apply the color override when the preview state carries
|
||
;; an *explicit* `:color`. Earlier this branch fell back to
|
||
;; "inherit" whenever any preview was active, which clobbered an
|
||
;; avatar's `:backgroundColor` to "inherit" — killing the
|
||
;; visible chip — even for shape/fallback-only previews that
|
||
;; don't intend to change color. Now: a non-color preview keeps
|
||
;; the avatar's own `:backgroundColor`/`:color` from `:data`.
|
||
preview-color (when preview-active? (:color preview))
|
||
effective-icon-value (if (and preview-active? (map? base-value))
|
||
(let [normalized (normalize-icon base-value)
|
||
avatar? (= :avatar (:type normalized))]
|
||
(cond-> normalized
|
||
preview-color (assoc-in [:data :color] preview-color)
|
||
(and preview-color avatar?)
|
||
(assoc-in [:data :backgroundColor] preview-color)))
|
||
base-value)]
|
||
(icon effective-icon-value (merge {:color? true} icon-props))))
|
||
|
||
(rum/defc icon-picker
|
||
[icon-value {:keys [empty-label disabled? initial-open? del-btn? on-chosen icon-props popup-opts button-opts page-title preview-target-db-id preview-target-db-ids preview-inheritor-db-ids default-icon? property]
|
||
:or {property :logseq.property/icon}}]
|
||
(let [*trigger-ref (rum/use-ref nil)
|
||
;; Optimistic post-commit override. Holds the just-committed
|
||
;; icon-value during the ~15ms SharedWorker round-trip between
|
||
;; the DB write and the entity update propagating back via the
|
||
;; reactive read chain. Without this, the page-icon trigger
|
||
;; reader falls back to the (still-old) entity for that window
|
||
;; and visibly flashes the previous icon.
|
||
;;
|
||
;; Cleared automatically by the use-effect below when icon-value
|
||
;; (passed by parent) catches up — Logseq's hooks/use-effect!
|
||
;; uses Clojure value equality (logseq.shui.hooks/memo-deps), so
|
||
;; the dep [icon-value] fires when the map's *content* changes,
|
||
;; not on every render reference flip.
|
||
[pending-icon set-pending-icon!] (rum/use-state nil)
|
||
_ (hooks/use-effect!
|
||
(fn [] (set-pending-icon! nil))
|
||
[icon-value])
|
||
effective-icon-value (or pending-icon icon-value)
|
||
normalized-icon-value (normalize-icon effective-icon-value)
|
||
content-fn
|
||
(if config/publishing?
|
||
(constantly [])
|
||
(fn [{:keys [id]}]
|
||
(icon-search
|
||
{:on-chosen (fn [e icon-value & [keep-popup?]]
|
||
;; Set the optimistic local mirror BEFORE the
|
||
;; async DB write fires. Lives at this
|
||
;; outermost wrapper so every commit path
|
||
;; benefits (custom-tab tiles, search-result
|
||
;; tiles, asset-picker image picks, text-
|
||
;; picker close commits) — they all funnel
|
||
;; through this on-chosen.
|
||
(set-pending-icon! icon-value)
|
||
;; Forward the third arg as-is — it carries either
|
||
;; `keep-popup?` (a bool, for in-picker partial
|
||
;; commits) or an `action` keyword (for delete
|
||
;; flows like :revert / :remove-entirely). The
|
||
;; downstream on-chosen handles both shapes; we
|
||
;; just need to NOT drop it.
|
||
(on-chosen e icon-value keep-popup?)
|
||
(when-not (true? keep-popup?) (shui/popup-hide! id)))
|
||
:icon-value normalized-icon-value
|
||
:page-title page-title
|
||
:del-btn? del-btn?
|
||
:preview-target-db-id preview-target-db-id
|
||
:preview-target-db-ids preview-target-db-ids
|
||
:preview-inheritor-db-ids preview-inheritor-db-ids
|
||
:default-icon? default-icon?
|
||
:property property})))]
|
||
(hooks/use-effect!
|
||
(fn []
|
||
(when initial-open?
|
||
(js/setTimeout #(some-> (rum/deref *trigger-ref) (.click)) 32)))
|
||
[initial-open?])
|
||
|
||
;; NOTE: an earlier auto-heal use-effect ran `heal-dangling-asset-icon` on
|
||
;; every `[icon-value]` change and called `on-chosen` with the healed value
|
||
;; (nil for :image, stripped data for :avatar). On page reload the asset
|
||
;; entity often isn't hydrated into the renderer's conn yet — the lookup
|
||
;; raced and returned nil, so the heal nuked the icon and persisted the
|
||
;; loss. The renderer already shows nothing when the asset is genuinely
|
||
;; missing (icon.cljs:478-482) without mutating the stored value, which
|
||
;; lets a slow-hydrating asset reappear once it lands. If a user wants to
|
||
;; clear a permanently dangling icon they can use the trash affordance.
|
||
|
||
;; trigger — render from `effective-icon-value` so the just-committed
|
||
;; icon shows immediately, before the entity reactive read catches up.
|
||
(let [has-icon? (some? effective-icon-value)]
|
||
(shui/button
|
||
(merge
|
||
{:ref *trigger-ref
|
||
:variant :ghost
|
||
:size :sm
|
||
:class (if has-icon? "px-1 leading-none text-muted-foreground hover:text-foreground"
|
||
"font-normal text-sm px-[0.5px] text-muted-foreground hover:text-foreground")
|
||
:on-click (fn [^js e]
|
||
(when-not disabled?
|
||
(shui/popup-show! (.-target e) content-fn
|
||
(medley/deep-merge
|
||
{:align :start
|
||
:id :ls-icon-picker
|
||
:content-props {:class "ls-icon-picker"
|
||
:onEscapeKeyDown #(.preventDefault %)}}
|
||
popup-opts))))}
|
||
button-opts)
|
||
(if has-icon?
|
||
(if (vector? effective-icon-value) ; hiccup
|
||
effective-icon-value
|
||
(icon-picker-trigger-icon effective-icon-value preview-target-db-id icon-props property))
|
||
(or empty-label (t :ui/empty)))))))
|