Selaa lähdekoodia

Enhance combobox with improved styling and functionality

- Updated padding in the popover content for better visual consistency.
- Introduced fixed width for combobox and select components to prevent layout shifts.
- Enhanced item rendering logic in combobox to conditionally display separators based on item availability.
- Improved handling of new item patterns and embed rendering in item renderer for better user experience.
- Refined CSS rules for dropdown menus and item results to ensure consistent spacing and overflow behavior.
scheinriese 1 viikko sitten
vanhempi
sitoutus
092416e165

+ 19 - 15
src/main/frontend/components/combobox.cljs

@@ -48,6 +48,7 @@
   (let [base-class "ui__combobox"
         width-class (case width
                      :wide "w-96"
+                     :default "min-w-[14rem] w-[14rem]"  ; Fixed width matching input min-width
                      :auto "w-auto"
                      (string? width) width
                      nil)
@@ -67,21 +68,24 @@
                       :class :header :grouped? :width :container-class)]
     (if show-search-input?
       ;; With search input: wrap results in item-results-wrap container and optionally add separator
-      [:div {:class combined-class}
-       (when (not= show-separator? false)
-         (shui/select-separator))
-       [:div.item-results-wrap
-        (ui/auto-complete
-         items
-         (merge {:on-chosen on-chosen
-                 :on-shift-chosen on-shift-chosen
-                 :get-group-name get-group-name
-                 :empty-placeholder empty-placeholder
-                 :item-render final-item-render
-                 :header header
-                 :grouped? grouped?
-                 :class "cp__select-results"}
-                opts'))]]
+      ;; Only show separator if explicitly enabled AND there are items to display
+      (let [has-items? (seq items)]
+        [:div {:class combined-class}
+         (when (and (not= show-separator? false)
+                    has-items?)  ; Only show separator if there are items
+           (shui/select-separator))
+         [:div.item-results-wrap
+          (ui/auto-complete
+           items
+           (merge {:on-chosen on-chosen
+                   :on-shift-chosen on-shift-chosen
+                   :get-group-name get-group-name
+                   :empty-placeholder empty-placeholder
+                   :item-render final-item-render
+                   :header header
+                   :grouped? grouped?
+                   :class "cp__select-results"}
+                  opts'))]])
       ;; Without search input: simple wrapper around auto-complete
       ;; Ensure ui__combobox class is always applied, then add custom class if provided
       (let [final-class (if class

+ 141 - 30
src/main/frontend/components/combobox/item_renderer.cljs

@@ -3,9 +3,14 @@
    Handles icons, breadcrumbs, new items, query highlighting, multi-select,
    right-side checkboxes, shortcuts, and embeds."
   (:require
+   [cljs.core.match :refer [match]]
    [clojure.string :as string]
    [frontend.components.list-item-icon :as list-item-icon]
+   [frontend.extensions.video.youtube :as youtube]
    [frontend.ui :as ui]
+   [frontend.util :as util]
+   [frontend.util.text :as text-util]
+   [logseq.common.util :as common-util]
    [logseq.shui.ui :as shui]))
 
 (defn- extract-icon
@@ -34,17 +39,22 @@
   "Check if item is a 'new' item based on patterns."
   [item {:keys [new-item-patterns]}]
   (when (seq new-item-patterns)
-    (let [text (if (map? item) (or (:label item) (:value item) (:text item)) (str item))]
+    (let [text (if (map? item) (or (:label item) (:value item) (:text item) (:block/title item)) (str item))]
       (and (string? text)
            (some #(string/starts-with? text %) new-item-patterns)))))
 
 (defn- extract-new-item-text
-  "Extract the text after 'New option:' or similar pattern."
+  "Extract the text after 'New option:' or similar pattern, or quoted text from 'Convert \"text\" to property'."
   [item pattern]
-  (let [text (if (map? item) (or (:label item) (:value item) (:text item)) (str item))]
+  (let [text (if (map? item) (or (:label item) (:value item) (:text item) (:block/title item)) (str item))]
     (when (and (string? text) (string/starts-with? text pattern))
-      (let [parts (string/split text (re-pattern (str pattern " ")) 2)]
-        (second parts)))))
+      (if (= pattern "Convert")
+        ;; Extract quoted text from "Convert \"text\" to property"
+        (when-let [match (re-find #"Convert\s+\"([^\"]+)\"\s+to\s+property" text)]
+          (second match))
+        ;; Extract text after pattern like "New option: "
+        (let [parts (string/split text (re-pattern (str pattern " ")) 2)]
+          (second parts))))))
 
 (defn- highlight-query
   "Highlight query terms in text."
@@ -60,10 +70,10 @@
     (let [value (if extract-value-fn (extract-value-fn item) (:value item))
           checked? (boolean (and selected-choices (contains? @selected-choices value)))]
       (shui/checkbox {:checked checked?
-                     :on-click (fn [e]
-                                 (.preventDefault e))
-                     :disabled (:disabled? item)
-                     :class "mr-1"}))))
+                      :on-click (fn [e]
+                                  (.preventDefault e))
+                      :disabled (:disabled? item)
+                      :class "mr-1"}))))
 
 (defn- render-right-checkbox
   "Render checkbox on the right side."
@@ -72,16 +82,16 @@
     (let [checkbox-data (right-checkbox-fn item)]
       (when checkbox-data
         (shui/checkbox (merge {:class "ml-auto"}
-                             checkbox-data))))))
+                              checkbox-data))))))
 
 (defn- render-right-shortcut
   "Render keyboard shortcut on the right side.
    Hover state is handled via CSS :hover pseudo-class."
   [item chosen? _hover? {:keys [shortcut-fn shortcut-key]}]
   (when-let [shortcut (cond
-                       shortcut-fn (shortcut-fn item)
-                       shortcut-key (get item shortcut-key)
-                       :else nil)]
+                        shortcut-fn (shortcut-fn item)
+                        shortcut-key (get item shortcut-key)
+                        :else nil)]
     [:div {:class "flex gap-1 shui-shortcut-row items-center ml-auto"
            :style {:opacity (if chosen? 1 0.9)
                    :min-height "20px"
@@ -89,13 +99,99 @@
      (shui/shortcut shortcut {:interactive? false
                               :aria-hidden? true})]))
 
+(defn- render-video-embed
+  "Render video embed from URL. Uses youtube component for YouTube (same as macro-video-cp)."
+  [url]
+  (if (common-util/url? url)
+    (let [results (text-util/get-matched-video url)
+          src (match results
+                [_ _ _ (:or "youtube.com" "youtu.be" "y2u.be") _ id _]
+                (if (= (count id) 11)
+                  ["youtube-player" id]
+                  ;; Fallback: construct embed URL even if ID length is unexpected
+                  (str "https://www.youtube.com/embed/" id))
+
+                [_ _ _ "youtube-nocookie.com" _ id _]
+                (str "https://www.youtube-nocookie.com/embed/" id)
+
+                [_ _ _ "loom.com" _ id _]
+                (str "https://www.loom.com/embed/" id)
+
+                [_ _ _ (_ :guard #(string/ends-with? % "vimeo.com")) _ id _]
+                (str "https://player.vimeo.com/video/" id)
+
+                [_ _ _ "bilibili.com" _ id & query]
+                (str "https://player.bilibili.com/player.html?bvid=" id "&high_quality=1&autoplay=0"
+                     (when-let [page (second query)]
+                       (str "&page=" page)))
+
+                :else
+                url)]
+      (if (and (coll? src)
+               (= (first src) "youtube-player"))
+        ;; For YouTube, use youtube.com with enablejsapi=1 exactly as breadcrumbs do
+        ;; This matches the working breadcrumb implementation
+        (let [video-id (last src)
+              t (re-find #"&t=(\d+)" url)
+              width (min (- (util/get-width) 96) 560) ; Same as youtube component
+              height (int (* width (/ 315 560)))
+              embed-url (str "https://www.youtube.com/embed/" video-id "?enablejsapi=1"
+                             (when (seq t) (str "&start=" (nth t 1))))]
+          [:iframe.aspect-video
+           {:key (str "youtube-embed-" video-id) ; Stable key prevents recreation
+            :id (str "youtube-player-" video-id)
+            :allow-full-screen "allowfullscreen"
+            :allow "accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
+            :frame-border "0"
+            :src embed-url
+            :height height
+            :width width}])
+        (when src
+          (let [width (min (- (util/get-width) 96) 400) ; Smaller width for combobox
+                height (int (* width (/ (if (string/includes? src "player.bilibili.com")
+                                          360 315)
+                                        560)))]
+            [:iframe
+             {:allow-full-screen "allowfullscreen"
+              :allow "accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
+              :frame-border "0"
+              :src src
+              :width width
+              :height height}]))))
+    nil))
+
+(defn- parse-embed-macro
+  "Parse embed macro from text like {{video ...}} or {{tweet ...}}.
+   Returns [embed-hiccup remaining-text] or [nil text] if no embed found."
+  [text]
+  (when (string? text)
+    (if-let [match (re-find #"^\{\{(video|tweet|twitter)\s+([^}]+)\}\}(.*)$" text)]
+      (let [[_ macro-name url remaining] match
+            embed-hiccup (cond
+                           (= macro-name "video")
+                           (render-video-embed url)
+
+                           (contains? #{"tweet" "twitter"} macro-name)
+                           (let [id-regex #"/status/(\d+)"]
+                             (when-let [id (cond
+                                             (<= (count url) 15) url
+                                             :else
+                                             (last (re-find id-regex url)))]
+                               (ui/tweet-embed id)))
+
+                           :else nil)]
+        (if embed-hiccup
+          [embed-hiccup (string/trim remaining)]
+          [nil text]))
+      [nil text])))
+
 (defn- render-content
-  "Render the main content area (text, highlighting, etc.)."
+  "Render the main content area (text, highlighting, embeds, etc.)."
   [item chosen? {:keys [text-fn highlight-query? query-fn highlight-fn gap-size] :as config}]
   (let [text (cond
-              text-fn (text-fn item)
-              (map? item) (or (:label item) (:text item) (:value item) (:block/title item))
-              :else (str item))
+               text-fn (text-fn item)
+               (map? item) (or (:label item) (:text item) (:value item) (:block/title item))
+               :else (str item))
         query (when highlight-query? (query-fn))
         gap-class (case gap-size
                     1 "gap-1"
@@ -103,20 +199,35 @@
                     3 "gap-3"
                     "gap-3")]
     (if (is-new-item? item config)
-      ;; Render "New option:" or "New page:" style
+      ;; Render "New option:", "New page:", or "Convert" style
       (let [pattern (first (filter #(let [text-str (if (string? text) text (str text))]
                                       (string/starts-with? text-str %))
                                    (:new-item-patterns config)))
             new-text (when pattern (extract-new-item-text item pattern))
             display-pattern (string/replace pattern #":$" "")]
-        [:div {:class (str "flex flex-row items-center " gap-class)}
-         [:span.text-gray-12 display-pattern ":"]
-         (when new-text
-           [:span.text-gray-11 (str "\"" new-text "\"")])])
-      ;; Regular content with optional highlighting
+        (if (= pattern "Convert")
+          ;; Special styling for "Convert \"text\" to property" - use whitespace-nowrap to prevent awkward breaks
+          [:div {:class (str "flex flex-row items-center " gap-class " whitespace-nowrap")}
+           [:span.text-gray-12 "Convert "]
+           (when new-text
+             [:span.text-gray-11 (str "\"" new-text "\"")])
+           [:span.text-gray-12 " to property"]]
+          ;; Regular "New option:" or "New page:" style
+          [:div {:class (str "flex flex-row items-center " gap-class)}
+           [:span.text-gray-12 display-pattern ":"]
+           (when new-text
+             [:span.text-gray-11 (str "\"" new-text "\"")])]))
+      ;; Regular content with optional highlighting and embeds
       (if (vector? text)
-        text  ; Already hiccup
-        (highlight-query text query highlight-fn)))))
+        text ; Already hiccup
+        (let [[embed-hiccup remaining-text] (parse-embed-macro text)
+              highlighted-text (highlight-query remaining-text query highlight-fn)]
+          (if embed-hiccup
+            [:div.flex.flex-col.gap-1
+             embed-hiccup
+             (when (and remaining-text (not (string/blank? remaining-text)))
+               highlighted-text)]
+            highlighted-text))))))
 
 (defn render-item
   "Unified item renderer for combobox components.
@@ -148,11 +259,11 @@
         header-fn-result (when (:header-fn config) ((:header-fn config) item))
         ;; If show-breadcrumbs? is true, use :header as breadcrumb, otherwise as header
         breadcrumb (when (:show-breadcrumbs? config)
-                    (or item-header  ; Use :header as breadcrumb if available
-                        (when (:breadcrumb-fn config)
-                          ((:breadcrumb-fn config) item))))
+                     (or item-header ; Use :header as breadcrumb if available
+                         (when (:breadcrumb-fn config)
+                           ((:breadcrumb-fn config) item))))
         header (when (not (:show-breadcrumbs? config))
-                (or item-header header-fn-result))
+                 (or item-header header-fn-result))
         gap-size (or (:gap-size config) 3)
         gap-class (case gap-size
                     1 "gap-1"
@@ -170,7 +281,7 @@
                                               :icon icon-name}))
                       (render-content item chosen? (assoc config :gap-size gap-size))]
                      ;; Right side: shortcut or checkbox (hover handled via CSS)
-                     (or (render-right-shortcut item chosen? false config)  ; Use false for hover, CSS will handle it
+                     (or (render-right-shortcut item chosen? false config) ; Use false for hover, CSS will handle it
                          (render-right-checkbox item chosen? config))]]
     (if (or header breadcrumb)
       [:div.flex.flex-col.gap-1

+ 28 - 6
src/main/frontend/components/editor.cljs

@@ -2,6 +2,7 @@
   (:require [clojure.string :as string]
             [dommy.core :as dom]
             [frontend.commands :as commands :refer [*matched-commands]]
+            [frontend.components.block :as block]
             [frontend.components.combobox :as combobox]
             [frontend.components.file-based.datetime :as datetime-comp]
             [frontend.components.list-item-icon :as list-item-icon]
@@ -163,9 +164,9 @@
        ;; Don't show 'New tag' for an internal page because it already shows 'Convert ...'
        (when-not (let [entity (db/get-page q)]
                    (and (ldb/internal-page? entity) (= (:block/title entity) q)))
-         [{:block/title (str (t :new-tag) " " q)}])
+         [{:block/title (str (t :new-tag) ": " q)}])
        partial-matched-pages)
-      (cons {:block/title (str (t :new-page) " " q)}
+      (cons {:block/title (str (t :new-page) ": " q)}
             partial-matched-pages))))
 
 (defn- search-pages
@@ -211,6 +212,7 @@
        (combobox/combobox
         matched-pages'
         {:show-search-input? false
+         :width :wide  ; Wider width for page search to show more content
          :on-chosen   (page-on-chosen-handler embed? input id q pos format)
          :on-enter    (fn []
                         (page-handler/page-not-exists-handler input id q current-pos))
@@ -371,17 +373,37 @@
     (combobox/combobox
      result
      {:show-search-input? false
+      :width :wide  ; Wider width for block/node search to show more content
       :on-chosen   chosen-handler
       :on-enter    non-exist-block-handler
       :empty-placeholder   [:div.text-gray-500.text-sm.px-4.py-2 (t :editor/block-search)]
-      :item-render (fn [{:block/keys [page uuid]}]  ;; content returned from search engine is normalized
+      :item-renderer-config
+      (let [repo (state/sub :git/current-repo)]
+        {:show-breadcrumbs? true
+         :breadcrumb-fn (fn [{:block/keys [page uuid]}]
+                         (when (and page uuid)
+                           (let [page-entity (db/entity [:block/uuid page])
+                                 format (get page-entity :block/format :markdown)]
+                             (block/breadcrumb {:id "block-search-block-parent"
+                                               :block? true
+                                               :search? true}
+                                              repo
+                                              uuid
+                                              {:indent? false}))))
+         :text-fn (fn [{:block/keys [page uuid]}]
+                   (when (and page uuid)
                      (let [page-entity (db/entity [:block/uuid page])
-                           repo (state/sub :git/current-repo)
                            format (get page-entity :block/format :markdown)
                            block (db-model/query-block-by-uuid uuid)
-                           content (:block/title block)]
+                           ;; Use raw-title to preserve original case (important for YouTube video IDs)
+                           content (or (:block/raw-title block) (:block/title block))]
                        (when-not (string/blank? content)
-                         [:.py-2 (search/block-search-result-item repo uuid format content q :block)])))
+                         (search-handler/sanity-search-content format content)))))
+         :highlight-query? true
+         :query-fn (fn [] q)
+         :highlight-fn (fn [query text]
+                        (search-handler/highlight-exact-query text query))
+         :gap-size 3})
       :class       "ac-block-search"})))
 
 (rum/defcs block-search < rum/reactive

+ 6 - 0
src/main/frontend/components/property.css

@@ -239,6 +239,12 @@ input.simple-input:focus {
   .cp__select-main {
     width: fit-content;
     margin: 0;
+    
+    /* Fixed width to prevent jumping - use min-width of input as base */
+    &:not(.w-96):not(.w-auto):not([class*="w-["]) {
+      min-width: 14rem;
+      width: 14rem;
+    }
 
     .ui__dropdown-trigger {
       position: absolute;

+ 7 - 4
src/main/frontend/components/property/value.css

@@ -63,7 +63,7 @@
 
 .ui__dropdown-menu-content .cp__select-main .item-results-wrap,
 .ui__dropdown-menu-content .ui__combobox .item-results-wrap {
-  padding: 0 4px !important;
+  padding: 0 4px !important; /* Remove bottom padding from scrollable container */
   overflow-x: hidden !important;
   overflow-y: auto !important;
   max-height: 300px;
@@ -109,12 +109,15 @@
   box-sizing: border-box;
 }
 
-/* Add breathing room to first and last items (scrolls away naturally) */
-/* Apply padding to #ui__ac-inner instead of margins on items to avoid gaps between all items */
+/* Add breathing room to first and last items */
+/* Match page search approach: padding on #ui__ac-inner */
+/* Disable overflow on #ui__ac-inner when inside item-results-wrap since item-results-wrap is the scrollable container */
 .ui__dropdown-menu-content .cp__select-main .item-results-wrap #ui__ac #ui__ac-inner,
 .ui__dropdown-menu-content .ui__combobox .item-results-wrap #ui__ac #ui__ac-inner {
   padding-top: 4px !important;
-  padding-bottom: 8px !important;
+  padding-bottom: 4px !important;
+  overflow-y: visible !important; /* Disable scrolling - item-results-wrap handles it */
+  overflow-x: hidden !important;
 }
 
 /* Dropdown combobox menu-link styles for cp__select-main and ui__combobox */

+ 48 - 30
src/main/frontend/components/select.cljs

@@ -27,10 +27,26 @@
   {:multi-select? multiple-choices?
    :selected-choices *selected-choices
    :extract-value-fn extract-value-fn
-   :icon-key :icon  ; Extract icon from :icon key in item
-   :new-item-patterns ["New option:"]
+   :icon-fn (fn [item]
+              ;; Return icon based on item type
+              (or (:icon item)
+                  (let [label (if (string? (:label item)) (:label item) "")]
+                    (cond
+                      (string/starts-with? label "New option:")
+                      "plus"
+                      (string/starts-with? label "Convert")
+                      "file" ; Page icon for convert items (they're pages being converted to properties)
+                      :else
+                      "letter-p")))) ; Default to property icon for all other items
+   :icon-variant-fn (fn [item]
+                      ;; Use :create variant only for "New option:" items
+                      (let [label (if (string? (:label item)) (:label item) "")]
+                        (if (string/starts-with? label "New option:")
+                          :create
+                          :default)))
+   :new-item-patterns ["New option:" "Convert"]
    :show-breadcrumbs? true
-   :breadcrumb-fn (fn [item] (:header item))  ; Use :header as breadcrumb
+   :breadcrumb-fn (fn [item] (:header item)) ; Use :header as breadcrumb
    :on-pointer-down util/stop-propagation
    :gap-size 3})
 
@@ -52,14 +68,14 @@
     [:div.input-wrap
      {:style {:margin-bottom "-2px"}}
      [:input.cp__select-input.w-full
-      (merge {:type        "text"
+      (merge {:type "text"
               :class "!p-1.5"
               :placeholder (or input-default-placeholder (t prompt-key))
-              :auto-focus  true
-              :value       input
-              :on-change   (fn [e]
-                             (let [v (util/evalue e)]
-                               (set-input! v)))}
+              :auto-focus true
+              :value input
+              :on-change (fn [e]
+                           (let [v (util/evalue e)]
+                             (set-input! v)))}
              input-opts)]]))
 
 ;; TODO: rewrite using hooks
@@ -160,26 +176,27 @@
                                   {:show-search-input? true
                                    :show-separator? false
                                    :grouped? grouped?
-                                   :item-render item-cp  ; Custom renderer takes precedence
+                                   :width :default ; Fixed width to prevent jumping
+                                   :item-render item-cp ; Custom renderer takes precedence
                                    :item-renderer-config (when (not item-cp)
-                                                          (create-item-renderer-config multiple-choices? *selected-choices extract-chosen-fn))
-                                   :on-chosen         (fn [raw-chosen e]
-                                                        (when clear-input-on-chosen?
-                                                          (reset! *input ""))
-                                                        (let [chosen (extract-chosen-fn raw-chosen)]
-                                                          (if multiple-choices?
-                                                            (if (selected-choices chosen)
-                                                              (do
-                                                                (swap! *selected-choices disj chosen)
-                                                                (when on-chosen (on-chosen chosen false @*selected-choices e)))
-                                                              (do
-                                                                (swap! *selected-choices conj chosen)
-                                                                (when on-chosen (on-chosen chosen true @*selected-choices e))))
-                                                            (do
-                                                              (when (and close-modal? (not multiple-choices?))
-                                                                (state/close-modal!))
-                                                              (when on-chosen
-                                                                (on-chosen chosen true @*selected-choices e))))))
+                                                           (create-item-renderer-config multiple-choices? *selected-choices extract-chosen-fn))
+                                   :on-chosen (fn [raw-chosen e]
+                                                (when clear-input-on-chosen?
+                                                  (reset! *input ""))
+                                                (let [chosen (extract-chosen-fn raw-chosen)]
+                                                  (if multiple-choices?
+                                                    (if (selected-choices chosen)
+                                                      (do
+                                                        (swap! *selected-choices disj chosen)
+                                                        (when on-chosen (on-chosen chosen false @*selected-choices e)))
+                                                      (do
+                                                        (swap! *selected-choices conj chosen)
+                                                        (when on-chosen (on-chosen chosen true @*selected-choices e))))
+                                                    (do
+                                                      (when (and close-modal? (not multiple-choices?))
+                                                        (state/close-modal!))
+                                                      (when on-chosen
+                                                        (on-chosen chosen true @*selected-choices e))))))
                                    :empty-placeholder (empty-placeholder t)})
 
                                  (when (and multiple-choices? (fn? on-apply))
@@ -204,7 +221,8 @@
          :*toggle-fn *toggle})
        [:<>
         (if (fn? input-container) (input-container) input-container)
-        (shui/select-separator)
+        (when (seq search-result) ; Only show separator if there are results
+          (shui/select-separator))
         (results-container-f)])]))
 
 (defn select-config
@@ -278,6 +296,6 @@
                     (select-keys [:on-chosen :empty-placeholder :prompt-key])
                     (assoc :items ((:items-fn select-type-config)))))
        {:id :ls-select-modal
-        :close-btn?  false
+        :close-btn? false
         :align :top
         :content-props {:class "ls-dialog-select"}}))))

+ 1 - 1
src/main/frontend/ui.css

@@ -197,7 +197,7 @@
 .ui__popover-content[data-editor-popup-ref="page-search-hashtag"] #ui__ac.black,
 .ui__popover-content[data-editor-popup-ref="block-search"] #ui__ac.ac-block-search {
   #ui__ac-inner {
-    padding: 0 4px !important;
+    padding: 4px 4px !important;
   }
 
   /* Match the beautiful "Add property" dropdown styling exactly */