item_renderer.cljs 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297
  1. (ns frontend.components.combobox.item-renderer
  2. "Unified item renderer for combobox components.
  3. Handles icons, breadcrumbs, new items, query highlighting, multi-select,
  4. right-side checkboxes, shortcuts, and embeds."
  5. (:require
  6. [cljs.core.match :refer [match]]
  7. [clojure.string :as string]
  8. [frontend.components.list-item-icon :as list-item-icon]
  9. [frontend.extensions.video.youtube :as youtube]
  10. [frontend.ui :as ui]
  11. [frontend.util :as util]
  12. [frontend.util.text :as text-util]
  13. [logseq.common.util :as common-util]
  14. [logseq.shui.ui :as shui]))
  15. (defn- extract-icon
  16. "Extract icon name from item using icon-fn or icon-key."
  17. [item {:keys [icon-fn icon-key icon]}]
  18. (cond
  19. icon-fn (icon-fn item)
  20. icon-key (get item icon-key)
  21. icon icon
  22. :else nil))
  23. (defn- extract-icon-variant
  24. "Determine icon variant based on item and config."
  25. [item {:keys [icon-variant icon-variant-fn new-item-patterns]}]
  26. (cond
  27. icon-variant-fn (icon-variant-fn item)
  28. icon-variant icon-variant
  29. (some (fn [pattern]
  30. (let [text (if (map? item) (or (:label item) (:value item) (:text item)) (str item))]
  31. (and (string? text) (string/starts-with? text pattern))))
  32. new-item-patterns)
  33. :create
  34. :else :default))
  35. (defn- is-new-item?
  36. "Check if item is a 'new' item based on patterns."
  37. [item {:keys [new-item-patterns]}]
  38. (when (seq new-item-patterns)
  39. (let [text (if (map? item) (or (:label item) (:value item) (:text item) (:block/title item)) (str item))]
  40. (and (string? text)
  41. (some #(string/starts-with? text %) new-item-patterns)))))
  42. (defn- extract-new-item-text
  43. "Extract the text after 'New option:' or similar pattern, or quoted text from 'Convert \"text\" to property'."
  44. [item pattern]
  45. (let [text (if (map? item) (or (:label item) (:value item) (:text item) (:block/title item)) (str item))]
  46. (when (and (string? text) (string/starts-with? text pattern))
  47. (if (= pattern "Convert")
  48. ;; Extract quoted text from "Convert \"text\" to property"
  49. (when-let [match (re-find #"Convert\s+\"([^\"]+)\"\s+to\s+property" text)]
  50. (second match))
  51. ;; Extract text after pattern like "New option: "
  52. (let [parts (string/split text (re-pattern (str pattern " ")) 2)]
  53. (second parts))))))
  54. (defn- highlight-query
  55. "Highlight query terms in text."
  56. [text query highlight-fn]
  57. (if (and (string? query) (not (string/blank? query)) highlight-fn)
  58. (highlight-fn query text)
  59. (if (string? text) [:span text] text)))
  60. (defn- render-left-checkbox
  61. "Render checkbox on the left side for multi-select."
  62. [item chosen? {:keys [multi-select? selected-choices extract-value-fn]}]
  63. (when multi-select?
  64. (let [value (if extract-value-fn (extract-value-fn item) (:value item))
  65. checked? (boolean (and selected-choices (contains? @selected-choices value)))]
  66. (shui/checkbox {:checked checked?
  67. :on-click (fn [e]
  68. (.preventDefault e))
  69. :disabled (:disabled? item)
  70. :class "mr-1"}))))
  71. (defn- render-right-checkbox
  72. "Render checkbox on the right side."
  73. [item chosen? {:keys [right-checkbox-fn]}]
  74. (when right-checkbox-fn
  75. (let [checkbox-data (right-checkbox-fn item)]
  76. (when checkbox-data
  77. (shui/checkbox (merge {:class "ml-auto"}
  78. checkbox-data))))))
  79. (defn- render-right-shortcut
  80. "Render keyboard shortcut on the right side.
  81. Hover state is handled via CSS :hover pseudo-class."
  82. [item chosen? _hover? {:keys [shortcut-fn shortcut-key]}]
  83. (when-let [shortcut (cond
  84. shortcut-fn (shortcut-fn item)
  85. shortcut-key (get item shortcut-key)
  86. :else nil)]
  87. [:div {:class "flex gap-1 shui-shortcut-row items-center ml-auto"
  88. :style {:opacity (if chosen? 1 0.9)
  89. :min-height "20px"
  90. :flex-wrap "nowrap"}}
  91. (shui/shortcut shortcut {:interactive? false
  92. :aria-hidden? true})]))
  93. (defn- render-video-embed
  94. "Render video embed from URL. Uses youtube component for YouTube (same as macro-video-cp)."
  95. [url]
  96. (if (common-util/url? url)
  97. (let [results (text-util/get-matched-video url)
  98. src (match results
  99. [_ _ _ (:or "youtube.com" "youtu.be" "y2u.be") _ id _]
  100. (if (= (count id) 11)
  101. ["youtube-player" id]
  102. ;; Fallback: construct embed URL even if ID length is unexpected
  103. (str "https://www.youtube.com/embed/" id))
  104. [_ _ _ "youtube-nocookie.com" _ id _]
  105. (str "https://www.youtube-nocookie.com/embed/" id)
  106. [_ _ _ "loom.com" _ id _]
  107. (str "https://www.loom.com/embed/" id)
  108. [_ _ _ (_ :guard #(string/ends-with? % "vimeo.com")) _ id _]
  109. (str "https://player.vimeo.com/video/" id)
  110. [_ _ _ "bilibili.com" _ id & query]
  111. (str "https://player.bilibili.com/player.html?bvid=" id "&high_quality=1&autoplay=0"
  112. (when-let [page (second query)]
  113. (str "&page=" page)))
  114. :else
  115. url)]
  116. (if (and (coll? src)
  117. (= (first src) "youtube-player"))
  118. ;; For YouTube, use youtube.com with enablejsapi=1 exactly as breadcrumbs do
  119. ;; This matches the working breadcrumb implementation
  120. (let [video-id (last src)
  121. t (re-find #"&t=(\d+)" url)
  122. width (min (- (util/get-width) 96) 560) ; Same as youtube component
  123. height (int (* width (/ 315 560)))
  124. embed-url (str "https://www.youtube.com/embed/" video-id "?enablejsapi=1"
  125. (when (seq t) (str "&start=" (nth t 1))))]
  126. [:iframe.aspect-video
  127. {:key (str "youtube-embed-" video-id) ; Stable key prevents recreation
  128. :id (str "youtube-player-" video-id)
  129. :allow-full-screen "allowfullscreen"
  130. :allow "accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
  131. :frame-border "0"
  132. :src embed-url
  133. :height height
  134. :width width}])
  135. (when src
  136. (let [width (min (- (util/get-width) 96) 400) ; Smaller width for combobox
  137. height (int (* width (/ (if (string/includes? src "player.bilibili.com")
  138. 360 315)
  139. 560)))]
  140. [:iframe
  141. {:allow-full-screen "allowfullscreen"
  142. :allow "accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
  143. :frame-border "0"
  144. :src src
  145. :width width
  146. :height height}]))))
  147. nil))
  148. (defn- parse-embed-macro
  149. "Parse embed macro from text like {{video ...}} or {{tweet ...}}.
  150. Returns [embed-hiccup remaining-text] or [nil text] if no embed found."
  151. [text]
  152. (when (string? text)
  153. (if-let [match (re-find #"^\{\{(video|tweet|twitter)\s+([^}]+)\}\}(.*)$" text)]
  154. (let [[_ macro-name url remaining] match
  155. embed-hiccup (cond
  156. (= macro-name "video")
  157. (render-video-embed url)
  158. (contains? #{"tweet" "twitter"} macro-name)
  159. (let [id-regex #"/status/(\d+)"]
  160. (when-let [id (cond
  161. (<= (count url) 15) url
  162. :else
  163. (last (re-find id-regex url)))]
  164. (ui/tweet-embed id)))
  165. :else nil)]
  166. (if embed-hiccup
  167. [embed-hiccup (string/trim remaining)]
  168. [nil text]))
  169. [nil text])))
  170. (defn- render-content
  171. "Render the main content area (text, highlighting, embeds, etc.)."
  172. [item chosen? {:keys [text-fn highlight-query? query-fn highlight-fn gap-size] :as config}]
  173. (let [text (cond
  174. text-fn (text-fn item)
  175. (map? item) (or (:label item) (:text item) (:value item) (:block/title item))
  176. :else (str item))
  177. query (when highlight-query? (query-fn))
  178. gap-class (case gap-size
  179. 1 "gap-1"
  180. 2 "gap-2"
  181. 3 "gap-3"
  182. "gap-3")]
  183. (if (is-new-item? item config)
  184. ;; Render "New option:", "New page:", or "Convert" style
  185. (let [pattern (first (filter #(let [text-str (if (string? text) text (str text))]
  186. (string/starts-with? text-str %))
  187. (:new-item-patterns config)))
  188. new-text (when pattern (extract-new-item-text item pattern))
  189. display-pattern (string/replace pattern #":$" "")]
  190. (if (= pattern "Convert")
  191. ;; Special styling for "Convert \"text\" to property" - use whitespace-nowrap to prevent awkward breaks
  192. [:div {:class (str "flex flex-row items-center " gap-class " whitespace-nowrap")}
  193. [:span.text-gray-12 "Convert "]
  194. (when new-text
  195. [:span.text-gray-11 (str "\"" new-text "\"")])
  196. [:span.text-gray-12 " to property"]]
  197. ;; Regular "New option:" or "New page:" style
  198. [:div {:class (str "flex flex-row items-center " gap-class)}
  199. [:span.text-gray-12 display-pattern ":"]
  200. (when new-text
  201. [:span.text-gray-11 (str "\"" new-text "\"")])]))
  202. ;; Regular content with optional highlighting and embeds
  203. (if (vector? text)
  204. text ; Already hiccup
  205. (let [[embed-hiccup remaining-text] (parse-embed-macro text)
  206. highlighted-text (highlight-query remaining-text query highlight-fn)]
  207. (if embed-hiccup
  208. [:div.flex.flex-col.gap-1
  209. embed-hiccup
  210. (when (and remaining-text (not (string/blank? remaining-text)))
  211. highlighted-text)]
  212. highlighted-text))))))
  213. (defn render-item
  214. "Unified item renderer for combobox components.
  215. Returns hiccup for rendering a combobox item.
  216. Config options:
  217. - :icon-fn (fn [item] icon-name) or :icon-key :icon or :icon string
  218. - :icon-variant :default|:create|:raw|:checkbox or :icon-variant-fn (fn [item] variant)
  219. - :show-breadcrumbs? boolean
  220. - :breadcrumb-fn (fn [item] breadcrumb-hiccup)
  221. - :new-item-patterns [\"New page:\" \"New option:\"] - patterns to detect new items
  222. - :highlight-query? boolean
  223. - :query-fn (fn [] current-query-string)
  224. - :highlight-fn (fn [query text] highlighted-hiccup)
  225. - :multi-select? boolean - show checkbox on left for multi-select
  226. - :selected-choices atom - set of selected values for multi-select
  227. - :extract-value-fn (fn [item] value) - extract value for multi-select
  228. - :right-checkbox-fn (fn [item] {:checked? bool :on-checked-change fn}) - checkbox on right
  229. - :shortcut-fn (fn [item] shortcut) or :shortcut-key :shortcut - shortcut on right
  230. - :text-fn (fn [item] text-content) - extract text from item
  231. - :header-fn (fn [item] header-hiccup) - header above item (or use :header key in item)
  232. - :gap-size 1|2|3 - spacing between icon and text (default 3)
  233. - :embed-renderer (fn [item] embed-hiccup) - render embeds
  234. - :class string - additional classes for row"
  235. [item chosen? config]
  236. (let [icon-name (extract-icon item config)
  237. icon-variant (extract-icon-variant item config)
  238. item-header (:header item)
  239. header-fn-result (when (:header-fn config) ((:header-fn config) item))
  240. ;; If show-breadcrumbs? is true, use :header as breadcrumb, otherwise as header
  241. breadcrumb (when (:show-breadcrumbs? config)
  242. (or item-header ; Use :header as breadcrumb if available
  243. (when (:breadcrumb-fn config)
  244. ((:breadcrumb-fn config) item))))
  245. header (when (not (:show-breadcrumbs? config))
  246. (or item-header header-fn-result))
  247. gap-size (or (:gap-size config) 3)
  248. gap-class (case gap-size
  249. 1 "gap-1"
  250. 2 "gap-2"
  251. 3 "gap-3"
  252. "gap-3")
  253. row-content [:div.flex.flex-row.items-center.justify-between.w-full
  254. {:class (when chosen? "chosen")
  255. :on-pointer-down (when (:on-pointer-down config) #((:on-pointer-down config) %))}
  256. ;; Left side: checkbox (multi-select), icon, content
  257. [:div {:class (str "flex flex-row items-center " gap-class)}
  258. (render-left-checkbox item chosen? config)
  259. (when icon-name
  260. (list-item-icon/root {:variant icon-variant
  261. :icon icon-name}))
  262. (render-content item chosen? (assoc config :gap-size gap-size))]
  263. ;; Right side: shortcut or checkbox (hover handled via CSS)
  264. (or (render-right-shortcut item chosen? false config) ; Use false for hover, CSS will handle it
  265. (render-right-checkbox item chosen? config))]]
  266. (if (or header breadcrumb)
  267. [:div.flex.flex-col.gap-1
  268. {:class (or (:class config) "")}
  269. (when breadcrumb
  270. [:div.text-xs.opacity-70.mb-1
  271. breadcrumb])
  272. (when header
  273. header)
  274. row-content]
  275. [:div {:class (or (:class config) "")}
  276. row-content])))