Initial import of LogDarioSeq baseline

Establishes version control for the personal Logseq DB fork before
starting the server-client architecture migration and domain features
described in docs/agent-guide and the approved implementation plan.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-25 15:32:15 +02:00
commit 40631362b3
2016 changed files with 543898 additions and 0 deletions

17
deps/shui/.clj-kondo/config.edn vendored Normal file
View File

@@ -0,0 +1,17 @@
{ :config-in-ns
;; :used-underscored-binding is turned off for components because of false positive
;; for rum/defcs and _state.
{all-components {:linters {:used-underscored-binding {:level :off}}}}
:linters
{;; Disable until it doesn't trigger false positives on rum/defcontext
:earmuffed-var-not-dynamic {:level :off}}
:hooks {:analyze-call {rum.core/defc hooks.rum/defc
rum.core/defcs hooks.rum/defcs
clojure.string/join hooks.path-invalid-construct/string-join}}
:lint-as {rum.core/defcc rum.core/defc
rum.core/with-context clojure.core/let
rum.core/defcontext clojure.core/def
rum.core/defc clojure.core/defn
rum.core/defcs clojure.core/defn
frontend.react/defc clojure.core/defn}}

11
deps/shui/README.md vendored Normal file
View File

@@ -0,0 +1,11 @@
## Description
Shui is the component library for logseq. It has 3 main goals:
1. Provide an abstraction for specific components, separate from the main codebase
2. Provide a consistent look and feel for the future of logseq
3. Provide ready to use components to plugin authors to allow for a more consistent better user experience of plugin authors and users
## API
This library is under the parent namespace `logseq.shui`.

10
deps/shui/deps.edn vendored Normal file
View File

@@ -0,0 +1,10 @@
{:paths ["src"]
:deps
{org.clojure/clojure {:mvn/version "1.12.4"}
org.clojure/clojurescript {:mvn/version "1.12.134"}
funcool/promesa {:mvn/version "11.0.678"}
io.factorhouse/hsx {:git/url "https://github.com/logseq/hsx"
:sha "21615ffa2aa530cc830e3464b793a53ef4fdd18c"}
medley/medley {:mvn/version "1.4.0"}
cljs-bean/cljs-bean {:mvn/version "1.9.0"}}}

View File

@@ -0,0 +1,61 @@
(ns logseq.shui.base.core
(:require [cljs-bean.core :as bean]
["react" :as react]
[io.factorhouse.hsx.core :as hsx]
[logseq.shui.icon.v2 :as tabler-icon]
[logseq.shui.util :as util]))
(def button-base (util/ui-wrap "Button" {:static? false}))
(def link (util/ui-wrap "Link"))
;; Note: used for the shui popup trigger
(defn trigger-as
([as & props-or-children]
(let [[props children] [(first props-or-children) (rest props-or-children)]
props' (cond->
{:on-key-down #(case (.-key %)
(" " "Enter")
(do (some-> (.-target %) (.click))
(.preventDefault %)
(.stopPropagation %))
:dune)}
(map? props)
(merge props))
children (if (map? props) children (cons props children))]
(into [as props'] children))))
;; Note: fix the custom trigger content
;; for the {:as-child true} menu trigger
(defn trigger-child-wrap
[& props-and-children]
(let [props (first props-and-children)
children (rest props-and-children)
children (if (map? props) children (cons props children))
children (when (seq children) (hsx/create-element children))
props (if (map? props) props {})]
(apply react/createElement "div" (bean/->js props) children)))
;; Note: keep this as a plain function
;; to be compatible for the radix as-child option
(defn button
[& props-and-children]
(let [props (first props-and-children)
children (rest props-and-children)
children (if (map? props) children (cons props children))
props' (if (map? props) props {})]
(apply button-base props' children)))
(defn button-icon
[variant icon-name {:keys [icon-props button-size] :as props} child]
(button
(merge (dissoc props :icon-props :button-size)
{:variant variant
:size :icon
:data-button :icon
:style (when button-size {:width button-size :height button-size})})
(tabler-icon/root (name icon-name) (merge {:size 20} icon-props))
child))
(def button-ghost-icon (partial button-icon :ghost))
(def button-outline-icon (partial button-icon :outline))
(def button-secondary-icon (partial button-icon :secondary))

1365
deps/shui/src/logseq/shui/components.cljs vendored Normal file

File diff suppressed because it is too large Load Diff

629
deps/shui/src/logseq/shui/demo.cljs vendored Normal file
View File

@@ -0,0 +1,629 @@
(ns logseq.shui.demo
(:require [dommy.core :refer-macros [sel1]]
[frontend.handler.notification :as notification]
[logseq.shui.dialog.core :as dialog-core]
[logseq.shui.form.core :refer [yup] :as form-core]
[logseq.shui.hooks :as hooks]
[logseq.shui.ui :as shui]
[promesa.core :as p]
[io.factorhouse.hsx.core :as hsx]))
(hsx/defc section-item
[title children]
[:section.mb-4
[:h2.text-xl.font-semibold.py-2.italic.opacity-50 title]
[:div.py-4 children]])
(hsx/defc sample-dropdown-menu-content
[]
(let [icon #(shui/tabler-icon (name %1) {:class "scale-90 pr-1 opacity-80"})]
(shui/dropdown-menu-content
{:class "w-56"
:on-click (fn [^js e] (some-> (.-target e) (.-innerText)
(#(identity ["You select: " [:b.text-red-700 %1]])) (shui/toast! :info)))}
(shui/dropdown-menu-label "My Account")
(shui/dropdown-menu-separator)
(shui/dropdown-menu-group
;; items
(shui/dropdown-menu-item (icon :user) "Profile" (shui/dropdown-menu-shortcut "⌘P"))
(shui/dropdown-menu-item (icon :brand-mastercard) [:span "Billing"] (shui/dropdown-menu-shortcut "⌘B"))
(shui/dropdown-menu-item (icon :adjustments-alt) [:span "Settings"] (shui/dropdown-menu-shortcut "⌘,"))
(shui/dropdown-menu-item (icon :keyboard) [:span "Keyboard shortcuts"]))
(shui/dropdown-menu-separator)
;; group
(shui/dropdown-menu-group
;; items
(shui/dropdown-menu-item (icon :users) "Team")
;; sub menu
(shui/dropdown-menu-sub
(shui/dropdown-menu-sub-trigger
(icon :user-plus) [:span "Invite users"])
(shui/dropdown-menu-sub-content
(shui/dropdown-menu-item (icon :mail) "Email")
(shui/dropdown-menu-item (icon :message) "Message")
(shui/dropdown-menu-item (icon :dots-circle-horizontal) "More...")))
;; menu item
(shui/dropdown-menu-item (icon :plus) "New Team" (shui/dropdown-menu-shortcut "⌘+T")))
(shui/dropdown-menu-separator)
(shui/dropdown-menu-item (icon :brand-github) "GitHub")
(shui/dropdown-menu-item {:disabled true} (icon :cloud) "Cloud API")
(shui/dropdown-menu-separator)
(shui/dropdown-menu-item (icon :logout) "Logout" (shui/dropdown-menu-shortcut "⌘+Q")))))
(hsx/defc sample-context-menu-content
[]
(let [icon #(shui/tabler-icon (name %1) {:class "scale-90 pr-1 opacity-80"})]
(shui/context-menu
;; trigger
(shui/context-menu-trigger
[:div.border.px-6.py-12.border-dashed.rounded.text-center.select-none
{:key "ctx-menu-click"}
[:span.opacity-50 "Right click here"]])
;; content
(shui/context-menu-content
{:class "w-60 max-h-[80vh] overflow-auto"}
(shui/context-menu-item
(icon "arrow-left")
"Back"
(shui/context-menu-shortcut "⌘["))
(shui/context-menu-item {:disabled true}
(icon "arrow-right")
"Forward"
(shui/context-menu-shortcut "⌘]"))
(shui/context-menu-item
(icon "refresh")
"Reload"
(shui/context-menu-shortcut "⌘R"))
;; Sub menu
(shui/context-menu-sub
(shui/context-menu-sub-trigger {:inset true} "More tools")
(shui/context-menu-sub-content {:class "w-48"}
(shui/context-menu-item "Save page As..."
(shui/context-menu-shortcut "⇧⌘S"))
(shui/context-menu-item "Create Shortcut...")
(shui/context-menu-item "Name Window...")
(shui/context-menu-separator)
(shui/context-menu-item "Developer Tools")))
;; more
(shui/context-menu-separator)
(shui/context-menu-checkbox-item {:checked true}
"Show Bookmarks Bar" (shui/context-menu-shortcut "⌘⇧B"))
(shui/context-menu-checkbox-item "Show Full URLs")
(shui/context-menu-separator)
(shui/context-menu-radio-group {:value "pedro"}
(shui/context-menu-label {:inset true} "People")
(shui/context-menu-separator)
(shui/context-menu-radio-item {:value "pedro"} "Pedro Duarte")
(shui/context-menu-radio-item {:value "colm"} "Colm Tuite"))))))
(hsx/defc sample-tabs
[]
(shui/tabs
{:defaultValue "account"
:className "w-[400px]"}
(shui/tabs-list
(shui/tabs-trigger
{:value "account"}
"Account")
(shui/tabs-trigger
{:value "password"}
"Password"))
(shui/tabs-content
{:value "account"}
"Make changes to your account here.")
(shui/tabs-content
{:value "password"}
"Change your password here.")))
(hsx/defc sample-form-basic
[]
[:div.border.p-6.rounded.bg-gray-01
(let [form-ctx (form-core/use-form
{:defaultValues {:username ""
:agreement true
:notification "all"
:bio ""}
:yupSchema (-> (.object yup)
(.shape #js {:username (-> (.string yup) (.required))})
(.required))})
handle-submit (:handleSubmit form-ctx)
on-submit-valid (handle-submit
(fn [^js e]
(js/console.log "[form] submit: " e)
(js/alert (js/JSON.stringify e nil 2))))]
(shui/form-provider form-ctx
[:form
{:on-submit on-submit-valid}
;; field item
(shui/form-field {:name "username"}
(fn [field error]
(shui/form-item
(shui/form-label "Username")
(shui/form-control
(shui/input (merge {:placeholder "Username"} field)))
(shui/form-description
(if error
[:b.text-red-800 (:message error)]
"This is your public display name.")))))
(shui/form-field {:name "bio"}
(fn [field _error]
(shui/form-item
{:class "pt-4"}
(shui/form-control
(shui/textarea (merge {:placeholder "Bio text..."} field))))))
;; radio
(shui/form-field {:name "notification"}
;; item render
(fn [field]
(shui/form-item
{:class "space-y-3 my-4"}
(shui/form-control
(shui/radio-group
{:value (:value field)
:on-value-change (:onChange field)
:class "flex flex-col space-y-3"}
(shui/form-item
{:class "flex flex-row space-x-2 items-center space-y-0"}
(shui/form-control
(shui/radio-group-item {:value "all"}))
(shui/form-label "All"))
(shui/form-item
{:class "flex flex-row space-x-2 items-center space-y-0"}
(shui/form-control
(shui/radio-group-item {:value "direct"}))
(shui/form-label "Direct messages and mentions")))))))
[:hr]
;; checkbox
(shui/form-field {:name "agreement"}
(fn [field]
(shui/form-item
{:class "flex justify-start items-center space-x-2 space-y-0 my-3 pr-3"}
(shui/form-control
(shui/checkbox {:checked (:value field)
:on-checked-change (:onChange field)}))
(shui/form-label {:class "font-normal cursor-pointer"} "Agreement terms"))))
;; actions
[:div.relative.px-2
(shui/button {:type "submit" :class "!absolute right-0 top-[-40px]"} "Submit")]]))])
(hsx/defc sample-date-picker
[]
(let [[open? set-open!] (hooks/use-state false)
[date set-date!] (hooks/use-state (js/Date.))]
(shui/popover
{:open open?
:on-open-change (fn [o] (set-open! o))}
;; trigger
(shui/popover-trigger
{:as-child true
:class "w-2/3"}
(shui/input
{:type :text
:placeholder "pick a date"
:default-value (.toDateString date)}))
;; content
(shui/popover-content
{:on-open-auto-focus #(.preventDefault %)
:side-offset 8
:class "p-0"}
(shui/calendar
{:selected date
:on-day-click
(fn [^js d]
(set-date! d)
(set-open! false))})))))
(hsx/defc sample-dialog-basic
[]
(let [[open? set-open!] (hooks/use-state false)]
(shui/dialog
{:open open?
:on-open-change #(set-open! %)}
(shui/dialog-trigger
{:as-child true}
(shui/button {:variant :outline}
(shui/tabler-icon "notification") "Open as modal locally"))
(shui/dialog-content
(shui/dialog-header
(shui/dialog-title "Header")
(shui/dialog-description
"Description"))
[:div.max-h-96.overflow-y-auto
{:class "-mx-6"}
[:section.px-6
(repeat 8 [:p "Your custom content"])]]
(shui/dialog-footer
(shui/button
{:on-click #(set-open! false)
:size :md} "🍄 * Footer"))))))
(hsx/defc sample-notifications
[]
(let [[last-dismissed set-last-dismissed!] (hooks/use-state nil)
progress-id :demo/notification-progress
callback-id :demo/notification-callback]
[:div.flex.flex-col.gap-3
[:div.flex.flex-row.flex-wrap.gap-2
(shui/button
{:variant :outline
:size :sm
:on-click #(notification/show! "Saved successfully" :success)}
"Success")
(shui/button
{:variant :outline
:size :sm
:on-click #(notification/show! "Background sync is running" :info)}
"Info")
(shui/button
{:variant :outline
:size :sm
:on-click #(notification/show! "Storage space is almost full" :warning false)}
"Warning")
(shui/button
{:variant :destructive
:size :sm
:on-click #(notification/show! "Upload failed. Fix it before retrying." :error false)}
"Persistent error")]
[:div.flex.flex-row.flex-wrap.gap-2
(shui/button
{:class "primary-green"
:size :sm
:on-click (fn []
(notification/show! "Syncing notifications demo..." :info true progress-id 10000)
(js/setTimeout
#(notification/show! "Notifications demo synced" :success true progress-id 1500)
1200))}
"Update same id")
(shui/button
{:variant :secondary
:size :sm
:on-click #(notification/clear! progress-id)}
"Clear updated one")
(shui/button
{:variant :secondary
:size :sm
:on-click #(notification/show! "Dismiss me to trigger callback" :warning true callback-id 4000
(fn [uid] (set-last-dismissed! (name uid))))}
"Dismiss callback")
(shui/button
{:variant :ghost
:size :sm
:on-click notification/clear-all!}
"Clear all")]
[:div.text-sm.opacity-70
"Last dismissed notification: "
[:code (or last-dismissed "none")]]]))
(hsx/defc page []
(shui/tooltip-provider
[:div.sm:p-10
[:hr]
[:input
{:type "checkbox" :on-change #(js/console.log "===>> onChange:" % (.-value (.-target %)))}]
(shui/checkbox {:on-click
(fn [^js e] (js/console.log "==>> click:"
(set! (. (.-target e) -checked) (.-state (.-dataset (.-target e))))
(.-checked (.-target e))))
:on-checked-change #(js/console.log "==>> on checked change:" %)} "abc")
[:h1.text-3xl.font-bold "Logseq UI"]
[:hr]
;; Button
(section-item "Button"
[:div.flex.flex-row.flex-wrap.gap-2
(let [[loading? set-loading!] (hooks/use-state false)]
(shui/button
{:size :sm
:on-click (fn []
(set-loading! true)
(js/setTimeout #(set-loading! false) 5000))
:disabled loading?}
(when loading?
(shui/tabler-icon "loader2" {:class "animate-spin"}))
"Logseq Classic Button"
(shui/tabler-icon "arrow-right")))
(shui/button {:variant :outline :size :sm} "Outline")
(shui/button {:variant :secondary :size :sm} "Secondary")
(shui/button {:disabled true :size :sm} "Disabled")
(shui/button {:variant :destructive :size :sm} "Destructive")
(shui/button {:class "primary-green" :size :sm} "Custom (.primary-green)")
(shui/button {:variant :ghost :size :sm} "Ghost")
(shui/button {:variant :link :size :sm} "Link")
(shui/button
{:variant :icon
:size :sm}
[:a.flex.items-center.text-blue-rx-10.hover:text-blue-rx-10-alpha
{:href "https://x.com/logseq" :target "_blank"}
(shui/tabler-icon "brand-twitter" {:size 15})])])
;; Toast
(section-item "Toast"
[:div.flex.flex-row.flex-wrap.gap-2
(shui/button
{:size :md
:variant :outline
:on-click #(shui/toast!
"Check for updates ..."
(nth [:success :error :default :info :warning] (rand-int 3))
{:title (if (odd? (js/Date.now)) "History of China" "")
:duration 3000})}
"Open random toast"
(shui/tabler-icon "arrow-right"))
(shui/button
{:variant :secondary
:size :md
:on-click (fn []
(shui/toast!
(fn [{:keys [id dismiss! update!]}]
[:b.text-red-700
[:div.flex.items-center.gap-2
(shui/tabler-icon "info-circle")
(str "#(" id ") ")
(.toLocaleString (js/Date.))]
[:div.flex.flex-row.gap-2
(shui/button
{:on-click #(dismiss! id) :size :sm}
"x close")
(shui/button
{:on-click #(update! {:title (js/Date.now)
:action [:b (shui/button {:on-click (fn [] (shui/toast-dismiss!))} "clear all")]})
:size :sm}
"x update")]])
:default
{:duration 3000 :onDismiss #(js/console.log "===>> dismiss?:" %1)}))}
(shui/tabler-icon "apps")
"Toast callback handle")
(shui/button
{:on-click #(shui/toast! "A message from SoundCloud..."
{:class "text-orange-rx-10"
:icon [:b.pl-1 (shui/tabler-icon "brand-soundcloud" {:size 20})]
:duration 3000})
:class "primary-orange"
:size :md}
"Custom icon")])
(section-item "Notification"
(sample-notifications))
[:div.flex.flex-row.space-x-16.items-center
;; Tips
(section-item "Tips"
[:div.flex.flex-row.flex-wrap.gap-2
(shui/tooltip-provider
(shui/tooltip
(shui/tooltip-trigger
(shui/button
{:variant :outline
:on-click #(dialog-core/open! [:h1.text-9xl.text-center.scale-110 "🍄"])}
"Tip for hint?"))
(shui/tooltip-content
{:class "w-42 px-8 py-4 text-xl border-green-rx-08 bg-green-rx-07-alpha"}
"🍄")))])
;; Avatar
(section-item "Avatar"
[:div.flex.flex-row.space-x-6.items-center
(shui/avatar
(shui/avatar-image {:src "https://avatars.githubusercontent.com/u/63385289?s=200&v=4"})
(shui/avatar-fallback "L"))
(shui/avatar
(shui/avatar-fallback "CH"))])]
;; Badge
(section-item "Badge"
[:div.flex.flex-row.flex-wrap.gap-2
(shui/badge "Default")
(shui/badge {:variant :outline} "Outline")
(shui/badge {:variant :secondary} "Secondary")
(shui/badge {:variant :destructive} "Destructive")
(shui/badge {:class "primary-yellow"} "Custom (.primary-yellow)")])
[:div.grid.sm:grid-cols-3.sm:gap-8
;; Dropdown
(section-item "Dropdown"
(shui/dropdown-menu
(shui/tooltip
(shui/tooltip-trigger
(shui/dropdown-menu-trigger
{:as-child true}
(shui/button {:variant :outline}
(shui/tabler-icon "list") "Open dropdown menu")))
(shui/tooltip-content "test hide?"))
(sample-dropdown-menu-content)))
;; Context menu
[:div.col-span-2
(section-item "Context Menu"
(sample-context-menu-content))]]
(section-item "Tabs" (sample-tabs))
;; Dialog
(section-item "Dialog"
[:div.flex.flex-row.flex-wrap.gap-2
(sample-dialog-basic)
(let [fc #(dialog-core/open!
(fn []
[:div.py-2
[:p [:strong "a modal dialog from `open!`"]]
[:p "You can put any content here, and even use shui components!"]
[:p (shui/button {:on-click (fn [] (dialog-core/alert! [:p "nest"] {}))} "open nested dialog")]])
{:title "Title"})]
(shui/button
{:on-click fc}
"Imperative API: open!"))
(shui/button
{:class "primary-yellow"
:on-click (fn []
(-> (dialog-core/alert!
"a alert dialog from `alert!`"
{:title [:div.flex.flex-row.space-x-2.items-center
(shui/tabler-icon "alert-triangle" {:size 18})
[:span "Alert"]]})
(p/then #(js/console.log "=> alert (promise): " %))))}
"Imperative API: alert!")
(shui/button
{:class "primary-green"
:on-click (fn []
(-> (dialog-core/confirm!
"a alert dialog from `confirm!`"
{:title [:div.flex.flex-row.space-x-2.items-center
(shui/tabler-icon "alert-triangle" {:size 18})
[:span "Confirm"]]})
(p/then #(js/console.log "=> confirm (promise): " %))
(p/catch #(js/console.log "=> confirm (promise): " %))))}
"Imperative API: confirm!")])
;; Alert
(section-item "Alert"
[:<>
(shui/alert
{:class "text-orange-rx-09 border-orange-rx-07-alpha mb-4"}
(shui/tabler-icon "brand-soundcloud")
(shui/alert-title "Title is SoundCloud")
(shui/alert-description
"content: radix colors for Logseq"))
(shui/alert
(shui/tabler-icon "brand-github")
(shui/alert-title "GitHub")
(shui/alert-description
"content: radix colors for Logseq"))])
;; Slider
[:div.grid.sm:grid-cols-8.gap-4
[:div.col-span-4.mr-6
(section-item "Slider" (shui/slider))]
[:div.col-span-1
(section-item "Switch"
(shui/switch {:size :sm :class "relative top-[-8px]"}))]
[:div.col-span-3.pl-4.pr-2
(section-item "Select"
(shui/select
{:on-value-change (fn [v] (shui/toast! v :info))}
;; trigger
(shui/select-trigger
(shui/select-value {:placeholder "Select a fruit"}))
;; content
(shui/select-content
(shui/select-group
(shui/select-label "Fruits")
(shui/select-item {:value "apple"} "Apple")
(shui/select-item {:value "pear"} "Pear")
(shui/select-item {:value "grapes"} "Grapes")))))]]
;; Form
(section-item "Form"
[:<>
(sample-form-basic)])
;; Card
[:div.grid.sm:grid-cols-2.sm:gap-8
(section-item "Card"
(shui/card
(shui/card-header
(shui/card-title "Title")
(shui/card-description "Description"))
(shui/card-content "This is content")
(shui/card-footer "Footer")))
(section-item "Skeleton"
(shui/card
(shui/card-header
(shui/card-title
(shui/skeleton {:class "h-4 w-1/2"}))
(shui/card-description
(shui/skeleton {:class "h-2 w-full"})))
(shui/card-content
(shui/skeleton {:class "h-3 mb-1"})
(shui/skeleton {:class "h-3 mb-1"})
(shui/skeleton {:class "h-3 w-2/3"}))
(shui/card-footer
(shui/skeleton {:class "h-4 w-full mb-2"}))))]
;; Calendar
[:div.grid.sm:grid-cols-2.sm:gap-8
(section-item "Calendar"
(shui/card
{:class "inline-flex"}
(shui/calendar {:on-day-click #(shui/toast! (.toString %) :success)})))
(section-item "Date Picker"
(sample-date-picker))]
[:hr.mb-80]]))
(defn- get-head-container
[]
(sel1 "#head"))
(defn- get-main-scroll-container
[]
(sel1 "#main-content-container"))
(hsx/defc sticky-table
[]
(let [el-ref (hooks/use-ref nil)]
(hooks/use-effect!
(fn []
(let [^js container (get-main-scroll-container)
^js el (hooks/deref el-ref)
^js cls (.-classList el)
*ticking? (volatile! false)
el-top (-> el (.getBoundingClientRect) (.-top))
head-top (-> (get-head-container) (js/getComputedStyle) (.-height) (js/parseInt))
translate (fn [offset]
(set! (. (.-style el) -transform) (str "translate3d(0, " offset "px , 0)"))
(if (zero? offset)
(.remove cls "translated")
(.add cls "translated")))
*last-offset (volatile! 0)
handle (fn []
(let [scroll-top (js/parseInt (.-scrollTop container))
offset (if (> (+ scroll-top head-top) el-top)
(+ (- scroll-top el-top) head-top 1) 0)
offset (js/parseInt offset)
last-offset @*last-offset]
(if (and (not (zero? last-offset))
(not= offset last-offset))
(let [dir (if (neg? (- offset last-offset)) -1 1)]
(loop [offset' (+ last-offset dir)]
(translate offset')
(if (and (not= offset offset')
(< (abs (- offset offset')) 100))
(recur (+ offset' dir))
(translate offset))))
(translate offset))
(vreset! *last-offset offset)))
handler (fn [^js _e]
(when (not @*ticking?)
(js/window.requestAnimationFrame
#(do (handle) (vreset! *ticking? false)))
(vreset! *ticking? true)))]
(.addEventListener container "scroll" handler)
#(.removeEventListener container "scroll" handler)))
[])
[:div.charlie-table
[:div.charlie-table-header
{:ref el-ref}
[:strong "header"]]
[:div.charlie-table-content
[:strong "content"]]]))

392
deps/shui/src/logseq/shui/demo2.cljs vendored Normal file
View File

@@ -0,0 +1,392 @@
(ns logseq.shui.demo2
(:require [cljs-bean.core :as bean]
[clojure.string :as string]
[frontend.components.icon :refer [emojis-cp emojis icon-search]]
[frontend.storage :as storage]
[logseq.shui.hooks :as hooks]
[logseq.shui.popup.core :refer [update-popup! get-popup]]
[logseq.shui.select.multi :refer [x-select-content]]
[logseq.shui.ui :as shui]
[promesa.core :as p]
[io.factorhouse.hsx.core :as hsx]))
(defn do-fetch!
([action] (do-fetch! action nil))
([action query-str]
(-> (js/window.fetch
(str "https://movies-api14.p.rapidapi.com/" (name action) (when query-str (str "?" query-str)))
#js {:method "GET"
:headers #js {:X-RapidAPI-Key "808ffd08c0mshc67d496f6024b46p164350jsn7b35179966c9",
:X-RapidAPI-Host "movies-api14.p.rapidapi.com"}})
(p/then #(.json %)))))
(hsx/defc multi-select-demo
[]
[:div.sm:p-10
[:h1.text-3xl.font-bold.border-b.pb-4.mb-8
"Multi X Select"]
(let [[items set-items!] (hooks/use-state [])
[q set-q!] (hooks/use-state "")
[fetching? set-fetching?] (hooks/use-state nil)
[selected-items set-selected-items!]
(hooks/use-state (storage/get :ls-demo-multi-selected-items))
rm-item! (fn [item-or-id]
(set-selected-items!
(remove #(or (= item-or-id %)
(= item-or-id (str (:id %))))
selected-items)))
add-item! (fn [item] (set-selected-items! (conj selected-items item)))
[open? set-open!] (hooks/use-state false)]
(hooks/use-effect!
(fn []
(storage/set :ls-demo-multi-selected-items selected-items))
[selected-items])
(shui/card
(shui/card-header
(shui/card-title "Search Movies")
(shui/card-description "x multiselect for the remote items"))
(shui/card-content
;; Basic
(shui/dropdown-menu
{:open open?}
;; trigger
(shui/dropdown-menu-trigger
[:div.border.p-2.rounded.w-full.cursor-pointer.flex.items-center.gap-1.flex-wrap
{:on-click (fn [^js e]
(let [^js target (.-target e)]
(if-let [^js c (some-> target (.closest ".close"))]
(some-> (.-dataset c) (.-k) (rm-item!))
(set-open! true))))}
(for [{:keys [id original_title class poster_path]} selected-items]
(shui/badge {:variant :secondary :class (str class " group relative")}
[:span.flex.items-center.gap-1.flex-nowrap
[:img {:src poster_path :class "w-[16px] scale-75"}]
[:b original_title]]
(shui/button
{:variant :destructive
:size :icon
:data-k id
:class "!rounded-full !h-4 !w-4 absolute top-[-7px] right-[-3px] group-hover:visible invisible close"}
(shui/tabler-icon "x" {:size 12}))))
(shui/button {:variant :link :size :sm} "+")])
;; content
(x-select-content items selected-items
{;; test item render
:open? open?
:close! #(set-open! false)
:search-enabled? true
:search-key q
:search-fn (fn [items]
(when (not fetching?) items))
:on-search-key-change (fn [v]
(set-q! v)
(if (string/blank? v)
(set-items! [])
(when (not fetching?)
(set-fetching? true)
(-> (do-fetch! :search (str "query=" v))
(p/then #(when-let [ret (bean/->clj %)]
(when-let [items (:contents ret)]
(set-items! (map (fn [item] (assoc item :id (:_id item))) (take 12 items))))))
(p/finally #(set-fetching? false))))))
:item-render (fn [item {:keys [selected?]}]
(if item
(shui/dropdown-menu-checkbox-item
{:checked selected?
:on-click (fn []
(if selected?
(rm-item! item)
(add-item! item))
;(set-open! false)
)}
[:div.flex.items-center.gap-2
[:span [:img {:src (:poster_path item)
:class "w-[20px]"}]]
[:span.flex.flex-col
[:b (:original_title item)]
[:small.opacity-50
{:class "text-[10px]"}
(:release_date item)]]])
(shui/dropdown-menu-separator)))
:head-render (fn [] (when (and fetching? (not (string/blank? q)))
[:b.flex.items-center.justify-center.py-4
(shui/tabler-icon "loader" {:class "animate-spin"})]))
;:foot-render (fn [] [:b "footer"])
:content-props
{:align "start"
:class "w-80"}})))))
[:hr]
(let [items [{:key 1 :value "Apple" :class "bg-gray-800 text-gray-50"}
{:key 2 :value "Orange" :class "bg-orange-700 text-gray-50"}
{:key 3 :value "Pear"}
{:key 4 :value "Banana" :class "bg-yellow-700 text-gray-700"}]
[selected-items set-selected-items!]
(hooks/use-state [(second items)])
[search? set-search?] (hooks/use-state false)
rm-item! (fn [item] (set-selected-items! (remove #(= item %) selected-items)))
add-item! (fn [item] (set-selected-items! (conj selected-items item)))
on-chosen (fn [item {:keys [selected?]}]
(if (true? selected?)
(rm-item! item) (add-item! item)))
[open? set-open!] (hooks/use-state false)]
(shui/card
(shui/card-header
(shui/card-title "Basic")
(shui/card-description "x multiselect for shui"))
(shui/card-content
[:label.block.flex.items-center.pb-3.cursor-pointer
(shui/checkbox {:checked search?
:on-click #(set-search? (not search?))})
[:small.pl-2 "Enable basic search input"]]
;; Basic
(shui/dropdown-menu
{:open open?}
;; trigger
(shui/dropdown-menu-trigger
[:p.border.p-2.rounded.w-full.cursor-pointer
{:on-click #(set-open! true)}
(for [{:keys [key value class]} selected-items]
(shui/badge {:variant :secondary :class class} (str "#" key " " value)))
(shui/button {:variant :link :size :sm} "+")])
;; content
(x-select-content items selected-items
{:close! #(set-open! false)
:search-enabled? search?
:search-key-render (fn [q {:keys [items]}]
(when (and (not (string/blank? q))
(not (seq items)))
[:b.flex.items-center.justify-center.py-4.gap-2.font-normal.opacity-80
(shui/tabler-icon "lemon") [:small "No fruits!"]]))
:on-chosen on-chosen
:value-render (fn [v {:keys [selected?]}]
(if selected?
[:b.text-red-800 v]
[:b.text-green-800 v]))
:content-props
{:class "w-48"}})))))
[:hr]
(let [[items _set-items!]
(hooks/use-state
[{:key 1 :value "Apple" :class "bg-gray-800 text-gray-50"}
{:key 2 :value "Orange" :class "bg-orange-700 text-gray-50"}
nil
{:key 3 :value "Pear"}
{:key 4 :value "Banana" :class "bg-yellow-700 text-gray-700"}])
[selected-items set-selected-items!]
(hooks/use-state [(last items) (first items)])
rm-item! (fn [item] (set-selected-items! (remove #(= item %) selected-items)))
add-item! (fn [item] (set-selected-items! (conj selected-items item)))
_on-chosen (fn [item {:keys [selected?]}]
(if (true? selected?)
(rm-item! item) (add-item! item)))
[open? set-open!] (hooks/use-state false)]
(shui/card
(shui/card-header
(shui/card-title "Search & Custom")
(shui/card-description "x multiselect for shui"))
(shui/card-content
;; Basic
(shui/dropdown-menu
{:open open?}
;; trigger
(shui/dropdown-menu-trigger
[:p.border.p-2.rounded.w-full.cursor-pointer
{:on-click #(set-open! true)}
(for [{:keys [key value class]} selected-items]
(shui/badge {:variant :secondary :class class} (str "#" key " " value)))
(shui/button {:variant :link :size :sm} "+")])
;; content
(x-select-content items selected-items
{;; test item render
:open? open?
:close! #(set-open! false)
:search-enabled? true
:item-render (fn [item {:keys [selected?]}]
(if item
(shui/dropdown-menu-checkbox-item
{:checked selected?
:on-click (fn []
(if selected?
(rm-item! item)
(add-item! item)))}
(:value item))
(shui/dropdown-menu-separator)))
:search-key-render
(fn [k {:keys [_items x-item exist-fn]}]
(when (and
(not (string/blank? k))
(not (exist-fn)))
(x-item
{:on-click (fn []
(shui/toast! (str "Create: " k) :warning)
(set-open! false))}
(str "+ create: " k))))
;:head-render (fn [] [:b "header"])
;:foot-render (fn [] [:b "footer"])
:content-props
{:align "start"
:class "w-48"}})))))])
(hsx/defc icon-picker-demo
[]
[:div.sm:p-10
[:h1.text-3xl.font-bold.border-b.pb-4.mb-8
"UI X Emojis & Icons Picker"]
[:div.border.rounded.bg-gray-01.overflow-hidden
{:class "w-fit"}
(icon-search {})]])
(hsx/defc popup-demo
[]
[:div.sm:p-10
[:h1.text-3xl.font-bold.border-b.pb-4 "UI X Popup"]
(let [[emoji set-emoji!] (hooks/use-state nil)
[q set-q!] (hooks/use-state "")
*q-ref (hooks/use-ref nil)
emoji-picker
(fn [_nested?]
[:p.py-4
"Choose a inline "
[:a.underline
{:on-click
#(shui/popup-show! %
(fn [_config]
[:div.max-h-72.overflow-auto.p-1
(emojis-cp (take 80 emojis)
{:on-chosen
(fn [_ t]
(set-emoji! t)
(shui/popup-hide-all!))})])
{:content-props {:class "w-72 p-0"}
:as-dropdown? true})}
(if emoji [:strong.px-1.text-6xl [:em-emoji emoji]] "emoji :O")] "."])]
[:<>
(emoji-picker nil)
[:p.py-4
(shui/button
{:variant :secondary
:on-click #(shui/popup-show! %
(fn []
[:p.p-4
(emoji-picker true)]))}
"Play a nested x popup.")]
[:p.py-4
(let [gen-content
(fn [q]
[:p.x-input-popup-content.bg-green-rx-06
(shui/button {:on-click #(shui/toast! "Just a joke :)")} "play a magic")
(emoji-picker true)
[:strong.px-1.text-6xl q]])]
(shui/input
{:placeholder "Select a fruit."
:ref *q-ref
:value q
:on-change (fn [^js e]
(let [val (.-value (.-target e))]
(set-q! val)
(update-popup! :select-a-fruit-input [:content] (gen-content val))))
:class "w-1/5"
:on-focus (fn [^js e]
(let [id :select-a-fruit-input
[_ popup] (get-popup id)]
(if (not popup)
(shui/popup-show! (.-target e)
(gen-content q)
{:id id
:align "start"
:content-props
{:class "x-input-popup-content"
:onPointerDownOutside
(fn [^js e]
(js/console.log "===>> onPointerDownOutside:" e (hooks/deref *q-ref))
(when-let [q-ref (hooks/deref *q-ref)]
(let [^js target (or (.-relatedTarget e)
(.-target e))]
(js/console.log "t:" target)
(when (and
(not (.contains q-ref target))
(not (.closest target ".x-input-popup-content")))
(shui/popup-hide! id)))))
:onOpenAutoFocus #(.preventDefault %)}})
;; update content
(update-popup! id [:content]
(gen-content q)))))
;:on-blur (fn [^js e]
; (let [^js target (.-relatedTarget e)]
; (js/console.log "==>>>" target)
; (when-not (.closest target ".x-input-popup-content")
; (hide-x-popup! :select-a-fruit-input))))
}))]
[:div.w-full.p-4.border.rounded.dotted.h-48.mt-8.bg-gray-02
{:on-click #(shui/popup-show! %
(->> (range 8)
(map (fn [it]
(shui/dropdown-menu-item
{:on-select (fn []
(shui/toast! it)
(shui/popup-hide-all!))}
[:strong it]))))
{:as-dropdown? true
:content-props {:class "w-48"}})
:on-context-menu #(shui/popup-show! %
[:h1.text-3xl.font-bold "hi x popup for custom context menu!"])}]])])
(hsx/defc custom-trigger-content
[]
[:p
[:code "more content"] [:br]
(shui/input {:auto-focus true}) [:br]
(shui/button "select sth")])
(hsx/defc sample-dropdown-trigger
[]
[:div.py-4
[:h1.text-3xl.font-bold.border-b.pb-4 "Sample dropdown/menu trigger"]
[:div.py-4
(shui/dropdown-menu
(shui/dropdown-menu-trigger
{:as-child true}
(shui/trigger-child-wrap
{:class "border p-6 border"}
(custom-trigger-content)))
(shui/dropdown-menu-content
(shui/dropdown-menu-item "A item")
(shui/dropdown-menu-item "B item")
(shui/dropdown-menu-item "C item")))]])
(hsx/defc page
[]
(sample-dropdown-trigger))

View File

@@ -0,0 +1,372 @@
(ns logseq.shui.dialog.core
(:require [io.factorhouse.hsx.core :as hsx]
[logseq.shui.base.core :as base]
[logseq.shui.form.core :as form]
[logseq.shui.hooks :as hooks]
[logseq.shui.util :as util]
[medley.core :as medley]
[promesa.core :as p]))
;; provider
(def dialog (util/ui-wrap "Dialog"))
(def dialog-portal (util/ui-wrap "DialogPortal"))
(def alert-dialog (util/ui-wrap "AlertDialog"))
(def alert-dialog-portal (util/ui-wrap "AlertDialogPortal"))
;; ui
(def dialog-overlay (util/ui-wrap "DialogOverlay"))
(def dialog-close (util/ui-wrap "DialogClose"))
(def dialog-trigger (util/ui-wrap "DialogTrigger"))
(def dialog-content (util/ui-wrap "DialogContent"))
(def dialog-header (util/ui-wrap "DialogHeader"))
(def dialog-footer (util/ui-wrap "DialogFooter"))
(def dialog-title (util/ui-wrap "DialogTitle"))
(def dialog-description (util/ui-wrap "DialogDescription"))
(def alert-dialog-overlay (util/ui-wrap "AlertDialogOverlay"))
(def alert-dialog-trigger (util/ui-wrap "AlertDialogTrigger"))
(def alert-dialog-content (util/ui-wrap "AlertDialogContent"))
(def alert-dialog-header (util/ui-wrap "AlertDialogHeader"))
(def alert-dialog-title (util/ui-wrap "AlertDialogTitle"))
(def alert-dialog-description (util/ui-wrap "AlertDialogDescription"))
(def alert-dialog-footer (util/ui-wrap "AlertDialogFooter"))
(def alert-dialog-action (util/ui-wrap "AlertDialogAction"))
(def alert-dialog-cancel (util/ui-wrap "AlertDialogCancel"))
(defn interpret-vals
[config ks & args]
(reduce (fn [config k]
(let [v (get config k)
v (if (fn? v) (apply v args) v)]
(if (vector? v) (assoc config k (hsx/create-element v)) config)))
config ks))
;; {:id :title :description :content :footer :open? :on-close ...}
(def ^:private *dialogs (atom []))
(def ^:private *id (atom 0))
(def ^:private gen-id #(reset! *id (inc @*id)))
(defn get-dialog
[id]
(when id
(some->> (medley/indexed @*dialogs)
(filter #(= id (:id (second %)))) (first))))
(defn- open-dialogs
[]
(filter :open? @*dialogs))
(defn update-dialog!
[id ks val & {:keys [closing?]}]
(when-let [[index config] (get-dialog id)]
(let [ks (if (coll? ks) ks [ks])
config (if (nil? val)
(medley/dissoc-in config ks)
(assoc-in config ks val))]
(swap! *dialogs assoc index config)
(when (and (false? (:open? config))
(fn? (:on-close config))
(not closing?))
((:on-close config) id)))))
(defn upsert-dialog!
[config]
(when-let [_id (:id config)]
(swap! *dialogs conj config)))
(defn detach-dialog!
[id]
(when-let [[index] (get-dialog id)]
(swap! *dialogs #(->> % (medley/remove-nth index) (vec)))))
(defn has-dialog?
[]
(boolean (some :open? @*dialogs)))
(defn- close-prevented?
[handler-result ^js event-details ^js native-event]
(when (or (false? handler-result)
(some-> native-event (.-defaultPrevented))
(some-> event-details (.-isCanceled)))
(some-> event-details (.cancel))
true))
(defn- call-close-handler!
[handler ^js event-details]
(let [native-event (some-> event-details (.-event))
result (when (fn? handler) (handler native-event))]
(close-prevented? result event-details native-event)))
;; apis
(declare close!)
(defn open!
[content-or-config & config']
(let [config (if (map? content-or-config)
content-or-config
{:content content-or-config})
content (:content config)
generated-id (gen-id)
config (merge {:id generated-id :open? true} config (first config'))
id (:id config)
config (cond-> config
(nil? (:close config))
(assoc :close #(close! id)))
config (cond-> config
(fn? content)
(assoc :content (content config)))]
(upsert-dialog! (update config :content-props
(fn [content-props]
(merge {:onCloseAutoFocus #(.preventDefault %)}
content-props))))))
(defn alert!
[content-or-config & config']
(let [deferred (p/deferred)]
(open! content-or-config
(merge {:alert? :default :deferred deferred} (first config')))
(p/promise deferred)))
(defn confirm!
[content-or-config & config']
(alert! content-or-config (assoc (first config') :alert? :confirm)))
(defn get-last-dialog-id
[]
(some-> (last (open-dialogs)) (:id)))
(defn get-first-dialog-id
[]
(some-> (first (open-dialogs)) (:id)))
(defn- top-dialog?
[id]
(= id (get-last-dialog-id)))
(defn- prevent-dismiss!
[^js event-details]
(some-> event-details (.-event) (.preventDefault))
(some-> event-details (.cancel))
true)
(defn close!
([] (close! (get-last-dialog-id)))
([id] (update-dialog! id :open? false {:closing? true})))
(defn close-all! []
(doseq [{:keys [id]} @*dialogs]
(close! id)))
;; components
(hsx/defc dialog-inner
[config]
(let [{:keys [id title description content footer on-open-change align open?
auto-width? close-btn? root-props content-props
onEscapeKeyDown onPointerDownOutside]} config
props (dissoc config
:id :title :description :content :footer :auto-width? :close-btn?
:close :align :on-open-change :open? :root-props :content-props
:onEscapeKeyDown :onPointerDownOutside)
props (assoc-in props [:overlay-props :data-align] (name (or align :center)))]
(hooks/use-effect!
(fn []
(when (false? open?)
(detach-dialog! id)))
[open?])
(dialog
(merge root-props
{:key (str "dialog-" id)
:open open?
:on-open-change (fn [v e]
(let [set-open! #(update-dialog! id :open? %)
reason (some-> e (.-reason))
escape-handler (or (:onEscapeKeyDown content-props)
onEscapeKeyDown)
pointer-handler (or (:onPointerDownOutside content-props)
onPointerDownOutside)
prevented? (when (false? v)
(cond
(and (contains? #{"escape-key" "outside-press"} reason)
(not (top-dialog? id)))
(prevent-dismiss! e)
(= reason "escape-key")
(call-close-handler! escape-handler e)
(= reason "outside-press")
(call-close-handler! pointer-handler e)
:else false))]
(when-not prevented?
(if (fn? on-open-change)
(on-open-change {:value v :set-open! set-open!})
(set-open! v)))))})
(let [onPointerDownOutside (:onPointerDownOutside content-props)
onEscapeKeyDown (:onEscapeKeyDown content-props)
handle-key-escape! (fn [^js e]
(if (top-dialog? id)
(if (fn? onEscapeKeyDown)
(onEscapeKeyDown e)
(do
(.preventDefault e)
(close! id)))
(.preventDefault e)))
handle-pointer-down-outside! (fn [^js e]
(if (top-dialog? id)
(when (fn? onPointerDownOutside)
(onPointerDownOutside e))
(.preventDefault e)))
content-props (-> content-props
(dissoc :onEscapeKeyDown :onPointerDownOutside)
(assoc :onEscapeKeyDown handle-key-escape!
:onPointerDownOutside handle-pointer-down-outside!))]
(dialog-content
(cond-> (merge props content-props)
auto-width? (assoc :data-auto-width true)
(false? close-btn?) (assoc :data-close-btn false))
;; Title component is required for accessible dialog content.
(dialog-title {:class (when (nil? title) "hidden")} title)
(when description (dialog-description description))
(when content
[:div.ui__dialog-main-content content])
(when footer
(dialog-footer footer)))))))
(hsx/defc alert-inner
[config]
(let [{:keys [id title description content footer deferred open? ok-label
root-props content-props]} config
props (dissoc config
:id :title :description :content :footer :deferred :open? :alert? :ok-label
:close :on-close :root-props :content-props
:outside-cancel? :cancel-label :data-reminder :data-reminder-label)
ok-label (or ok-label "OK")]
(hooks/use-effect!
(fn []
(when (false? open?)
(detach-dialog! id)))
[open?])
(alert-dialog
(merge root-props
{:key (str "alert-" id)
:open open?
:on-open-change (fn [v e]
(let [reason (some-> e (.-reason))]
(if (and (false? v)
(= reason "escape-key")
(not (top-dialog? id)))
(prevent-dismiss! e)
(update-dialog! id :open? v))))})
(alert-dialog-content (merge props content-props)
(when (or title description)
(alert-dialog-header
{:class "ui__alert-dialog-header"}
(when title (alert-dialog-title title))
(when description (alert-dialog-description description))))
(when content
[:div.ui__alert-dialog-main-content content])
(alert-dialog-footer
{:class "ui__alert-dialog-footer"}
(if footer
footer
[:<>
(base/button
{:key "ok"
:on-click #(do (close!) (p/resolve! deferred true))
:size :sm} ok-label)])))
)))
(hsx/defc confirm-inner
[config]
(let [{:keys [id deferred outside-cancel? data-reminder data-reminder-label
cancel-label ok-label]} config
reminder? (boolean (and id data-reminder))
[ready?, set-ready!] (hooks/use-state (not reminder?))
*ok-ref (hooks/use-ref nil)
*reminder-ref (hooks/use-ref nil)
cancel-label (or cancel-label "Cancel")
ok-label (or ok-label "OK")
footer [:<>
[:span.flex.items-center.pt-1
(when (and id data-reminder data-reminder-label)
[:label.flex.items-center.gap-1.text-sm
(form/checkbox {:ref *reminder-ref})
[:span.opacity-50 data-reminder-label]])]
[:span.flex.gap-2
(base/button
{:key "cancel"
:on-click #(do (close!) (p/reject! deferred false))
:variant :outline
:size :sm} cancel-label)
(base/button
{:key "ok"
:ref *ok-ref
:on-click (fn []
(when-let [^js reminder (and id data-reminder (hooks/deref *reminder-ref))]
(when (= "checked" (.-state (.-dataset reminder)))
(js/localStorage.setItem (str id) (js/Date.now))))
(close!)
(p/resolve! deferred true))
:size :sm} ok-label)]]]
(hooks/use-effect!
(fn []
(when ready?
(let [timeout (js/setTimeout
#(some-> (hooks/deref *ok-ref) (.focus)) 128)]
#(js/clearTimeout timeout))))
[ready?])
(hooks/use-effect!
(fn []
(try
(if-let [reminder-v (and reminder? (js/localStorage.getItem (str id)))]
(if (< (- (js/Date.now) reminder-v) (* 1000 60 10))
(do (detach-dialog! id) (p/resolve! deferred true))
(set-ready! true))
(set-ready! true))
(catch js/Error _e
(set-ready! true))))
[])
(when ready?
(alert-inner
(assoc config
:data-mode :confirm
:overlay-props
{:on-click #(when outside-cancel? (close!) (p/reject! deferred nil))}
:footer footer)))))
(defn- render-dialog
[config]
(let [id (:id config)
alert? (:alert? config)
config (interpret-vals config
[:title :description :content :footer]
{:id id})]
(case alert?
:default
(alert-inner config)
:confirm
(confirm-inner config)
(dialog-inner config))))
(defn- render-dialogs
[configs]
(when-let [configs (seq (filter map? configs))]
(into [:<>] (map render-dialog configs))))
(hsx/defc install-dialogs
[]
(let [[dialogs _set-dialogs!] (util/use-atom *dialogs)]
(render-dialogs dialogs)))

View File

@@ -0,0 +1,58 @@
(ns logseq.shui.form.core
(:require [io.factorhouse.hsx.core :as hsx]
[logseq.shui.util :as util]
[cljs-bean.core :as bean]))
;; State
(def form-provider (util/ui-wrap "Form" {:static? false}))
(def form-field' (util/ui-wrap "FormField" {:static? false}))
(hsx/defc form-field
[render' & args]
(let [[props render']
(if (map? render')
[render' (first args)]
[(first args) render'])
_ (assert (contains? props :name) ":name is required for <ui/form-field>")
render (fn [^js ctx]
;; TODO: convert field-state?
(render'
(bean/bean (.-field ctx))
(some-> (.-fieldState ctx) (.-error) (bean/bean))
(bean/bean (.-fieldState ctx))
ctx))]
(form-field' (assoc props :render render))))
(def form-control (util/ui-wrap "FormControl" {:static? false}))
(def ^js yup (util/ui-get "yup"))
(def yup-resolver (util/ui-get "yupResolver"))
;; Hooks
;; https://react-hook-form.com/docs/useform#resolver
(def use-form' (util/ui-get "useForm"))
(def use-form-context (util/ui-get "useFormContext"))
(defn use-form
([] (use-form {}))
([opts]
(let [yup-schema (:yupSchema opts)
^js form-methods (use-form' (bean/->js
(cond-> opts
(not (nil? yup-schema))
(assoc :resolver (yup-resolver yup-schema)))))]
;; NOTE: just shallow convert return object!
(bean/bean form-methods))))
;; UI
(def form-item (util/ui-wrap "FormItem"))
(def form-label (util/ui-wrap "FormLabel"))
(def form-description (util/ui-wrap "FormDescription"))
(def form-message (util/ui-wrap "FormMessage"))
(def input (util/ui-wrap "Input"))
(def textarea (util/ui-wrap "Textarea"))
(def switch (util/ui-wrap "Switch"))
(def checkbox (util/ui-wrap "Checkbox"))
(def radio-group (util/ui-wrap "RadioGroup"))
(def radio-group-item (util/ui-wrap "RadioGroupItem"))

View File

@@ -0,0 +1,25 @@
(ns logseq.shui.form.password
(:require [clojure.string :as string]
[logseq.shui.base.core :as base-core]
[logseq.shui.form.core :as form-core]
[logseq.shui.hooks :as hooks]
[logseq.shui.icon.v2 :as icon-v2]
[io.factorhouse.hsx.core :as hsx]))
(hsx/defc toggle-password
[option]
(let [[visible? set-visible!] (hooks/use-state false)]
[:div.ls-toggle-password-input.relative
(form-core/input
(merge
option
{:type (or (:type option)
(if visible? "text" "password"))}))
(when-not (string/blank? (:value option))
(base-core/button
{:variant :ghost
:class "absolute right-1"
:style {:top 6}
:size :sm
:on-click #(set-visible! (not visible?))}
(icon-v2/root (if visible? "eye-off" "eye"))))]))

291
deps/shui/src/logseq/shui/hooks.cljs vendored Normal file
View File

@@ -0,0 +1,291 @@
(ns logseq.shui.hooks
"React custom hooks."
(:refer-clojure :exclude [deref])
(:require ["react" :as react]
[goog.functions :as gfun]))
(defn- memo-deps
[equal-fn deps]
(let [equal-fn (or equal-fn =)
^js deps-ref (react/useRef deps)]
(when-not (equal-fn (.-current deps-ref) deps)
(set! (.-current deps-ref) deps))
(.-current deps-ref)))
(defn- deps-array
[deps equal-fn]
(if (empty? deps)
#js[]
#js[(memo-deps equal-fn deps)]))
(defn- effect-result
[result]
(if (fn? result)
result
js/undefined))
(defn use-memo
[f deps & {:keys [equal-fn]}]
(react/useMemo f (deps-array deps equal-fn)))
(defn use-effect!
"setup-fn will be invoked every render of component when no deps arg provided"
([setup-fn]
(assert (fn? setup-fn) "use-effect! setup-fn should be a function")
(react/useEffect (fn []
(let [result (setup-fn)]
(effect-result result)))))
([setup-fn deps & {:keys [equal-fn]}]
(assert (fn? setup-fn) "use-effect! setup-fn should be a function")
(react/useEffect (fn []
(let [result (setup-fn)]
(effect-result result)))
(deps-array deps equal-fn))))
(defn use-layout-effect!
([setup-fn]
(assert (fn? setup-fn) "use-layout-effect! setup-fn should be a function")
(react/useLayoutEffect (fn []
(let [result (setup-fn)]
(effect-result result)))))
([setup-fn deps & {:keys [equal-fn]}]
(assert (fn? setup-fn) "use-layout-effect! setup-fn should be a function")
(react/useLayoutEffect (fn []
(let [result (setup-fn)]
(effect-result result)))
(deps-array deps equal-fn))))
(defn use-callback
[callback deps & {:keys [equal-fn]}]
(react/useCallback callback (deps-array deps equal-fn)))
(defn- event-target
[target]
(cond
(fn? target)
(target)
(and target (not (undefined? (.-current target))))
(.-current target)
:else
target))
(defn use-event-listener
([target event-name handler deps]
(use-event-listener target event-name handler deps nil))
([target event-name handler deps options]
(use-effect!
(fn []
(when-let [target' (event-target target)]
(.addEventListener target' event-name handler options)
#(.removeEventListener target' event-name handler options)))
(into [target event-name options] deps))))
(defn use-window-keydown
([handler deps]
(use-window-keydown handler deps nil))
([handler deps options]
(use-event-listener js/window "keydown" handler deps options)))
(defn use-window-keyup
([handler deps]
(use-window-keyup handler deps nil))
([handler deps options]
(use-event-listener js/window "keyup" handler deps options)))
(defn use-hide-on-esc-or-outside
[{:keys [active? root-ref on-hide ignore-class outside-event outside-options]
:or {ignore-class "ignore-outside-event"
outside-event "mousedown"}}]
(let [outside-handler (use-callback
(fn [e]
(when (and active?
(not (some-> (event-target root-ref)
(.contains (.-target e))))
(not (some-> e .-target .-classList (.contains ignore-class))))
(on-hide e)))
[active? root-ref on-hide ignore-class])
keydown-handler (use-callback
(fn [e]
(when (and active? (= 27 (.-keyCode e)))
(on-hide e)))
[active? on-hide])]
(use-event-listener js/window outside-event outside-handler [outside-handler] outside-options)
(use-window-keydown keydown-handler [keydown-handler])))
;;; React hooks
(def use-ref react/useRef)
(def create-ref react/createRef)
(defn deref [ref] (.-current ref))
(defn set-ref! [ref value] (set! (.-current ref) value))
(defn- choose-value [nv cv]
(if (= nv cv)
cv
nv))
;; borrowed from https://github.com/pitch-io/uix/blob/master/core/src/uix/hooks/alpha.cljs
(defn- use-clojure-aware-updater
"Replicates React's behaviour when updating state with identical JS value,
but using Clojure's value equality here"
[updater]
(react/useCallback
(fn [v & args]
(updater
(fn [cv]
(if (fn? v)
(choose-value (apply v cv args) cv)
(choose-value v cv)))))
#js [updater]))
(defn use-state [value]
(let [[state set-state] (react/useState value)
set-state (use-clojure-aware-updater set-state)]
#js [state set-state]))
(defn- clojure-aware-reducer-updater
"Same as `use-clojure-primitive-aware-updater` but for `use-reducer`"
[f]
(fn [state action]
(choose-value (f state action) state)))
(defn use-reducer
([f value]
(let [updater (clojure-aware-reducer-updater f)]
(react/useReducer updater value)))
([f value init-state]
(let [updater (clojure-aware-reducer-updater f)]
(react/useReducer updater value init-state))))
;;; other custom hooks
(defn use-debounced-value
"Return the debounced value"
[value msec]
(let [[debounced-value set-value!] (use-state value)
cb (use-callback (gfun/debounce set-value! msec) [])]
(use-effect! #(cb value) [value])
debounced-value))
(defn- is-touch-event? [e]
(exists? (.-touches e)))
(defn- prevent-default [e]
(when (and (is-touch-event? e)
(< (.-length (.-touches e)) 2)
(.-preventDefault e))
(.preventDefault e)))
(defn use-long-press
[{:keys [on-click on-long-press prevent-default? delay]
:or {prevent-default? true
delay 300}}]
(let [[long-press-triggered set-long-press-triggered] (use-state false)
timeout-ref (use-ref nil)
target-ref (use-ref nil)
start (use-callback
(fn [e]
(when (and prevent-default? (.-target e))
(.addEventListener (.-target e) "touchend" prevent-default #js {:passive false})
(set! (.-current target-ref) (.-target e)))
(set! (.-current timeout-ref)
(js/setTimeout
(fn []
(on-long-press e)
(set-long-press-triggered true))
delay)))
[on-long-press delay prevent-default?])
clear (use-callback
(fn [_e should-trigger-click]
(when (.-current timeout-ref)
(js/clearTimeout (.-current timeout-ref)))
(when (and (or (nil? should-trigger-click) should-trigger-click)
(not long-press-triggered))
(on-click))
(set-long-press-triggered false)
(when (and prevent-default? (.-current target-ref))
(.removeEventListener (.-current target-ref) "touchend" prevent-default)))
[prevent-default? on-click long-press-triggered])]
{:onMouseDown #(start %)
:onTouchStart #(start %)
:onMouseUp #(clear % true)
:onMouseLeave #(clear % false)
:onTouchEnd #(clear % true)}))
(defn- use-atom-fn
[a getter-fn setter-fn]
(let [[val set-val] (use-state (getter-fn @a))]
(use-effect!
(fn []
(let [current-value (getter-fn @a)]
(when-not (= val current-value)
(set-val current-value)))
(let [id (str (random-uuid))]
(add-watch a id (fn [_ _ prev-state next-state]
(let [prev-value (getter-fn prev-state)
next-value (getter-fn next-state)]
(when-not (= prev-value next-value)
(set-val next-value)))))
#(remove-watch a id)))
[a])
[val #(swap! a setter-fn %)]))
(defn use-atom
"(use-atom my-atom)"
[a]
(use-atom-fn a identity (fn [_ v] v)))
(defn use-atom-value
[a]
(first (use-atom a)))
(defn use-atom-in
[a ks]
(let [ks (if (keyword? ks) [ks] ks)]
(use-atom-fn a #(get-in % ks) (fn [a' v] (assoc-in a' ks v)))))
(defn use-modal-state
[initial-open?]
(let [open-atom (use-memo #(atom (boolean initial-open?)) [])
[open-value] (use-atom open-atom)
close-fn #(reset! open-atom false)
open-fn #(reset! open-atom true)
toggle-fn #(swap! open-atom not)]
{:open? open-value
:open-atom open-atom
:close-fn close-fn
:open-fn open-fn
:toggle-fn toggle-fn}))
(defn use-mounted
[]
(let [mounted-ref (use-ref false)]
(use-effect!
(fn []
(set-ref! mounted-ref true)
#(set-ref! mounted-ref false))
[])
#(deref mounted-ref)))
(defn use-bounding-client-rect
"Return a callback ref and the current bounding client rect for that node."
([] (use-bounding-client-rect nil))
([tick]
(let [[ref set-ref] (use-state nil)
[rect set-rect] (use-state nil)]
(use-effect!
(if ref
(fn []
(let [update-rect #(set-rect (.getBoundingClientRect ref))
update-from-observer! (fn [entries]
(when (some-> entries (aget 0) .-contentRect)
(update-rect)))
observer (js/ResizeObserver. update-from-observer!)]
(update-rect)
(.observe observer ref)
#(.disconnect observer)))
#())
[ref tick])
[set-ref rect])))

58
deps/shui/src/logseq/shui/icon/v2.cljs vendored Normal file
View File

@@ -0,0 +1,58 @@
(ns logseq.shui.icon.v2
(:require
["@tabler/icons-react" :as tabler-icons]
[camel-snake-kebab.core :as csk]
[clojure.string :as string]
[goog.object :as gobj]
[goog.string :as gstring]
[io.factorhouse.hsx.core :as hsx]
[logseq.shui.util :as shui-utils]))
(def ^:private tabler-extension-icon-names
#{"add-link" "app-feature" "block" "block-search" "cloud-exclamation"
"connector" "group" "h-auto" "heading-off" "internal-link"
"link-to-block" "link-to-page" "link-to-whiteboard" "move-to-sidebar-right"
"new-block" "new-page" "new-whiteboard" "new-whiteboard-element"
"object-compact" "object-expanded" "open-as-page" "page" "page-search"
"references-hide" "references-show" "select-cursor" "text" "ungroup"
"whiteboard" "whiteboard-element" "whiteboard-search"})
(defn- font-icon
[name extension? opts]
[:span.ui__icon (merge {:class
(gstring/format
(str "%s-" name
(when (:class opts)
(str " " (string/trim (:class opts)))))
(if extension? "tie tie" "ti ti"))}
(dissoc opts :class :extension? :font?))])
(defn- svg-icon
[icons name icon-component-name class opts]
(let [f (shui-utils/component-wrap icons icon-component-name)]
[:span.ui__icon.ti
{:class (str "ls-icon-" name " " class)}
(f (merge {:size 18}
(shui-utils/map-keys->camel-case
(dissoc opts :class :extension? :font?))))]))
(hsx/defc root
([name] (root name nil))
([name {:keys [extension? font? class] :as opts}]
(when (and (string? name)
(not (string/blank? name)))
(let [^js js-tabler-icons (gobj/get js/window "tablerIcons")
icon-component-name (str "Icon" (csk/->PascalCase name))]
(cond
font?
(font-icon name extension? opts)
(and js-tabler-icons (gobj/get js-tabler-icons icon-component-name))
(svg-icon js-tabler-icons name icon-component-name class opts)
(and (not extension?) (gobj/get tabler-icons icon-component-name))
(svg-icon tabler-icons name icon-component-name class opts)
:else
(font-icon name (or extension?
(contains? tabler-extension-icon-names name)) opts))))))

View File

@@ -0,0 +1,418 @@
(ns logseq.shui.popup.core
(:require [dommy.core :as d]
[io.factorhouse.hsx.core :as hsx]
[logseq.shui.util :as util :refer [use-atom]]
[medley.core :as medley]))
;; ui
(def button (util/ui-wrap "Button"))
(def popover (util/ui-wrap "Popover"))
(def popover-trigger (util/ui-wrap "PopoverTrigger"))
(def popover-content (util/ui-wrap "PopoverContent"))
(def popover-arrow (util/ui-wrap "PopoverArrow"))
(def popover-close (util/ui-wrap "PopoverClose"))
(def popover-remove-scroll (util/ui-wrap "PopoverRemoveScroll"))
(def dropdown-menu (util/ui-wrap "DropdownMenu"))
(def dropdown-menu-trigger (util/ui-wrap "DropdownMenuTrigger"))
(def dropdown-menu-content (util/ui-wrap "DropdownMenuContent"))
(def dropdown-menu-arrow (util/ui-wrap "DropdownMenuArrow"))
(def dropdown-menu-group (util/ui-wrap "DropdownMenuGroup"))
(def dropdown-menu-item (util/ui-wrap "DropdownMenuItem"))
(def dropdown-menu-checkbox-item (util/ui-wrap "DropdownMenuCheckboxItem"))
(def dropdown-menu-radio-group (util/ui-wrap "DropdownMenuRadioGroup"))
(def dropdown-menu-radio-item (util/ui-wrap "DropdownMenuRadioItem"))
(def dropdown-menu-label (util/ui-wrap "DropdownMenuLabel"))
(def dropdown-menu-separator (util/ui-wrap "DropdownMenuSeparator"))
(def dropdown-menu-shortcut (util/ui-wrap "DropdownMenuShortcut"))
(def dropdown-menu-sub (util/ui-wrap "DropdownMenuSub"))
(def dropdown-menu-sub-content (util/ui-wrap "DropdownMenuSubContent"))
(def dropdown-menu-sub-trigger (util/ui-wrap "DropdownMenuSubTrigger"))
;; {:id :open? false :content nil :position [0 0] :root-props nil :content-props nil}
(defonce ^:private *popups (atom []))
(defonce ^:private *id (atom 0))
(defonce ^:private gen-id #(reset! *id (inc @*id)))
(defonce ^:private *last-hidden (atom nil))
(def *opened-sub-menus (atom #{}))
(defn get-popup
[id]
(when id
(some->> (medley/indexed @*popups)
(filter #(= id (:id (second %)))) (first))))
(defn get-popups [] @*popups)
(defn get-last-popup [] (last @*popups))
(defn upsert-popup!
[config]
(when-let [id (:id config)]
(if-let [[index config'] (get-popup id)]
(swap! *popups assoc index (merge config' config))
(swap! *popups conj config)) id))
(defn update-popup!
[id ks val]
(when-let [[index config] (get-popup id)]
(let [ks (if (coll? ks) ks [ks])
config (if (nil? val)
(medley/dissoc-in config ks)
(assoc-in config ks val))]
(swap! *popups assoc index config))))
(defn detach-popup!
([id] (detach-popup! id false))
([id skip-focus?]
(let [[index config] (get-popup id)]
(when index
(swap! *popups #(->> % (medley/remove-nth index) (vec)))
(let [{:keys [auto-focus? target trigger-id]} config]
(when (and (not skip-focus?) auto-focus? target)
(when-let [target (if trigger-id (js/document.getElementById trigger-id) target)]
(d/add-class! target "ls-popup-closed")
(.focus target))))))))
(declare hide!)
(defn- same-popup-target?
[^js target ^js target']
(and target target'
(or (= target target')
(and (instance? js/Node target')
(.-contains target)
(.contains target target')))))
(defn- recently-hidden-target?
[target]
(let [{hidden-target :target until :until} @*last-hidden]
(and target
hidden-target
(number? until)
(< (js/Date.now) until)
(same-popup-target? hidden-target target))))
(defn- element?
[target]
(and (exists? js/Element)
(instance? js/Element target)))
(defn- native-event [^js event]
(or (some-> event (.-nativeEvent)) event))
(defn- event-target [^js event]
(let [^js native-event (native-event event)]
(or (some-> event (.-target))
(some-> native-event (.-target))
(some-> event (.-currentTarget))
(some-> native-event (.-currentTarget)))))
(defn- event-related-target [^js event]
(let [^js native-event (native-event event)]
(or (some-> event (.-relatedTarget))
(some-> native-event (.-relatedTarget))
(some-> event (.-toElement))
(some-> native-event (.-toElement)))))
(defn- event-composed-path [^js event]
(let [^js native-event (native-event event)]
(cond
(fn? (some-> native-event (.-composedPath)))
(array-seq (.composedPath native-event))
(fn? (some-> event (.-composedPath)))
(array-seq (.composedPath event)))))
(defn- close-targets
[^js event-details]
(let [event (some-> event-details (.-event))]
(remove nil?
(concat [(some-> event-details (.-trigger))
(event-related-target event)
(event-target event)]
(event-composed-path event)))))
(def ^:private menu-transition-close-reasons
#{"trigger-hover" "trigger-focus" "list-navigation" "sibling-open"})
(defn- input-target?
[target]
(and (element? target)
(or (instance? js/HTMLInputElement target)
(instance? js/HTMLTextAreaElement target)
(instance? js/HTMLSelectElement target)
(some? (.closest target "[contenteditable='true']")))))
(defn- prevent-base-ui-handler!
[^js event]
(when (fn? (.-preventBaseUIHandler event))
(.preventBaseUIHandler event)))
(defn- menu-key-down-handler
[content-props disable-menu-handlers?]
(fn [^js event]
(some-> content-props :on-key-down (apply [event]))
(when (or disable-menu-handlers?
(input-target? (event-target event)))
(prevent-base-ui-handler! event))))
(defn- stop-toggle-event! [^js event]
(let [^js native-event (native-event event)]
(doseq [^js e (distinct (remove nil? [event native-event]))]
(when (fn? (.-preventDefault e))
(.preventDefault e))
(when (fn? (.-stopPropagation e))
(.stopPropagation e))
(when (fn? (.-stopImmediatePropagation e))
(.stopImmediatePropagation e)))))
(defn- clear-selection!
[]
(some-> js/window
(.getSelection)
(.removeAllRanges)))
(defn- consume-toggle-event! [^js event]
(stop-toggle-event! event)
(clear-selection!))
(defn- close-canceled?
[handler ^js event-details]
(let [native-event (some-> event-details (.-event))
result (when (fn? handler) (handler native-event))]
(when (or (false? result)
(some-> native-event (.-defaultPrevented))
(some-> event-details (.-isCanceled)))
(some-> event-details (.cancel))
true)))
(defn show!
[^js event content & {:keys [id as-mask? as-dropdown? as-content?
focus-trigger? align root-props content-props
on-before-hide on-after-hide trigger-id] :as opts}]
(let [provided-id id
id (or provided-id (gen-id))
;; _ (prn :debug :show :id id)
*target (volatile! nil)
pointer-event? (or (and (exists? js/MouseEvent)
(instance? js/MouseEvent (or (.-nativeEvent event) event)))
(instance? js/goog.events.BrowserEvent event))
position (cond
(vector? event) event
pointer-event?
(let [event' (or (.-nativeEvent event) event)]
(vreset! *target (some->> [(.-currentTarget event)
(.-currentTarget event')
(.-target event')
(.-target event)]
(filter element?)
first))
[(.-clientX event') (.-clientY event')])
(and (exists? js/Element)
(instance? js/Element event))
(let [^js rect (.getBoundingClientRect event)
left (.-left rect)
width (.-width rect)
height (.-height rect)
bottom (.-bottom rect)]
(vreset! *target event)
[(+ left (case (keyword align)
:start 0
:end width
(/ width 2)))
(- (- bottom height)
;; minus default offset
(if as-mask? 6 0))
width (if as-mask? 1 height)])
(and (vector event) (= (count event) 2) (every? integer? event))
event
:else [0 0])
target @*target
opened (or (some->> (get-popups) (filter #(= id (:id %))) (first))
(when (and (nil? provided-id) target)
(some #(when (same-popup-target? target (:target %)) %)
(get-popups))))]
(cond
(recently-hidden-target? target)
(do
(reset! *last-hidden nil)
(consume-toggle-event! event)
nil)
opened
(do
(consume-toggle-event! event)
(if (and (number? (:ignore-opening-outside-press-until opened))
(< (js/Date.now) (:ignore-opening-outside-press-until opened)))
(update-popup! (:id opened) :ignore-opening-outside-press-until nil)
(hide! (:id opened) 0 {:event event :skip-focus? true :suppress-next-show? true}))
(:id opened))
:else
(do
(when (element? target)
(d/remove-class! target "ls-popup-closed")
(d/set-attr! target "data-popup-active" (if (keyword? id) (name id) (str id))))
(upsert-popup!
(merge opts
{:id id :target target
:trigger-id trigger-id
:ignore-opening-outside-press-until (+ (js/Date.now) 150)
:focus-trigger? focus-trigger?
:open? true :content content :position position
:as-dropdown? as-dropdown?
:as-content? as-content?
:root-props root-props
:on-before-hide on-before-hide
:on-after-hide on-after-hide
:content-props (cond-> content-props
(not (nil? align))
(assoc :align (name align)))}))
id))))
(defn hide!
([] (when-let [id (some-> (get-popups) (last) :id)] (hide! id 0)))
([id] (hide! id 0 {}))
([id delay] (hide! id delay {}))
([id delay {:keys [_all? ^js event skip-focus? suppress-next-show?]}]
;; (prn :debug :hide id)
(when-let [popup (get-popup id)]
(let [config (last popup)
target (:target config)
f (fn []
(when suppress-next-show?
(reset! *last-hidden {:target target
:until (+ (js/Date.now) 150)}))
(detach-popup! id skip-focus?)
(some-> (:on-after-hide config) (apply [])))]
(when (not (false? (some-> (:on-before-hide config) (apply [event]))))
(when (and (not skip-focus?)
(not (false? (:focus-trigger? config)))
(some? target)
(= js/document (.-ownerDocument target))
(.-isConnected target)
(fn? (.-focus target)))
(js/setTimeout #(.focus target) 16))
(some-> target (d/remove-attr! "data-popup-active"))
(if (and (number? delay) (> delay 0))
(js/setTimeout f delay)
(f)))))))
(defn hide-all!
[]
(doseq [{:keys [id]} @*popups]
(hide! id 0 {:all? true})))
(defn- virtual-anchor
[target x y _width height]
(let [height (if (and (number? height) (> height 0)) height 1)
rect #js {:x x :y y :left x :top y :right (inc x) :bottom (+ y height)
:width 1 :height height}
anchor #js {:getBoundingClientRect #(js/Object.assign #js {} rect)}]
(when (element? target)
(aset anchor "contextElement" target))
anchor))
(defn- anchor-props
[content-props target position]
(let [[x y width height] position]
{:anchor (virtual-anchor target x y width height)
:position-method (or (:position-method content-props)
(:positionMethod content-props)
"fixed")}))
(defn- anchor-height
[height]
(if (and (number? height) (> height 0)) height 1))
(hsx/defc x-popup
[{:keys [id open? content position as-dropdown? as-content? force-popover?
auto-side? as-mask? _auto-focus? target root-props content-props
ignore-opening-outside-press-until _on-before-hide _on-after-hide]
:as _props}]
(when-let [[_x y _width height] position]
(let [use-menu? (not force-popover?)
popup-root (if use-menu? dropdown-menu popover)
popup-trigger (if use-menu? dropdown-menu-trigger popover-trigger)
popup-content (if use-menu? dropdown-menu-content popover-content)
auto-side-fn (fn []
(let [vh js/window.innerHeight
height (anchor-height height)
[th bh] [y (- vh (+ y height))]]
(if (> bh 280)
"bottom"
(if (> (- th bh) 100)
"top" "bottom"))))
auto-side? (if (boolean? auto-side?) auto-side? true)
content-props (cond-> content-props
(and (not as-mask?) auto-side?) (assoc :side (auto-side-fn)))
handle-open-change! (fn [open? ^js e]
(when-not (false? (some-> root-props (:onOpenChange) (apply [open? e])))
(when-not open?
(let [native-event (some-> e (.-event))
last-popup? (= (:id (get-last-popup)) id)
reason (some-> e (.-reason))
targets (close-targets e)
target-toggle? (some #(same-popup-target? target %) targets)
opening-outside-press? (and (= reason "outside-press")
(number? ignore-opening-outside-press-until)
(< (js/Date.now) ignore-opening-outside-press-until))
focus-transition? (= reason "focus-out")
menu-transition? (and use-menu?
(contains? menu-transition-close-reasons reason))
handler (case reason
"escape-key" (:onEscapeKeyDown content-props)
"outside-press" (:onPointerDownOutside content-props)
nil)]
(if (or (not last-popup?)
target-toggle?
opening-outside-press?
menu-transition?
focus-transition?)
(some-> e (.cancel))
(when-not (close-canceled? handler e)
(hide! id 1 {:event native-event
:skip-focus? (= reason "outside-press")
:suppress-next-show? (= reason "outside-press")})))))))]
(let [disable-menu-handlers? (and use-menu? (not as-dropdown?))
content-props (cond-> (merge content-props
(anchor-props content-props target position))
as-mask?
(assoc :data-as-mask true)
use-menu?
(assoc :on-key-down
(menu-key-down-handler content-props disable-menu-handlers?))
disable-menu-handlers?
(assoc :on-pointer-move prevent-base-ui-handler!))
content (if (fn? content)
(content (cond-> {:id id}
as-content?
(assoc :content-props content-props))) content)]
(popup-root
(merge root-props {:open open?
:onOpenChange handle-open-change!})
(popup-trigger
{:as-child true}
(button {:class "fixed p-0 opacity-0 pointer-events-none"
:tab-index -1
:aria-hidden true
:style {:height 1
:width 1
:top -10000
:left -10000}}
""))
(if as-content?
content
(popup-content content-props content)))))))
(hsx/defc install-popups
[]
(let [[popups _set-popups!] (use-atom *popups)]
[:<>
(for [config popups
:when (and (map? config) (:id config) (not (:all? config)))]
^{:key (:id config)}
[x-popup config])]))

View File

@@ -0,0 +1,14 @@
(ns logseq.shui.select.core
(:require [logseq.shui.util :as util]))
(def select (util/ui-wrap "Select"))
(def select-group (util/ui-wrap "SelectGroup"))
(def select-value (util/ui-wrap "SelectValue"))
(def select-trigger (util/ui-wrap "SelectTrigger"))
(def select-icon (util/ui-wrap "SelectIcon"))
(def select-content (util/ui-wrap "SelectContent"))
(def select-label (util/ui-wrap "SelectLabel"))
(def select-item (util/ui-wrap "SelectItem"))
(def select-separator (util/ui-wrap "SelectSeparator"))
(def select-scroll-up-button (util/ui-wrap "SelectScrollUpButton"))
(def select-scroll-down-button (util/ui-wrap "SelectScrollDownButton"))

View File

@@ -0,0 +1,180 @@
(ns logseq.shui.select.multi
(:require [clojure.string :as string]
[io.factorhouse.hsx.core :as hsx]
[logseq.shui.form.core :as form]
[logseq.shui.hooks :as hooks]
[logseq.shui.popup.core :as shui-popup]))
(defn- get-k [item]
(if (map? item)
(some->> ((juxt :id :key :label) item)
(remove nil?)
(first))
item))
(defn- get-v
[item]
(if (string? item)
item (or (:title item) (:value item))))
(hsx/defc search-input
[input-props & {:keys [on-enter valid-search-key?]}]
(let [*el (hooks/use-ref nil)
[down set-down!] (hooks/use-state 0)]
(hooks/use-effect!
(fn []
(when-let [^js item (and (> down 0)
(some-> (hooks/deref *el)
(.closest ".head")
(.-nextSibling)))]
(some-> (if valid-search-key? (.-nextSibling item) item)
(.focus))))
[down])
[:div.search-input
{:ref *el}
(form/input
(merge {:on-key-up #(case (.-key %)
"ArrowDown" (set-down! (inc down))
"ArrowUp" nil
"Enter" (when (fn? on-enter) (on-enter))
:dune)
:auto-focus true}
input-props))]))
(defn- simple-search-fn
[items q]
(let [q (some-> q (string/trim) (string/lower-case))]
(if (string/blank? q)
items
(filter #(some-> (get-v %)
(string/lower-case)
(string/includes? q)) items))))
(hsx/defc x-select-content
[items selected-items & {:keys [on-chosen item-render value-render
head-render foot-render open? close!
search-enabled? search-key on-search-key-change
search-input-placeholder
search-fn search-key-render
item-props content-props]}]
(let [x-content shui-popup/dropdown-menu-content
x-item shui-popup/dropdown-menu-item
*head-ref (hooks/use-ref nil)
[search-key1 set-search-key!] (hooks/use-state search-key)
search-key1' (some-> search-key1 (string/trim) (string/lower-case))
valid-search-key? (and search-enabled? (not (string/blank? search-key1')))
get-content-el #(some-> % (.closest ".ui__dropdown-menu-content"))
get-item-nodes #(some-> % (get-content-el) (.querySelectorAll ".ui__dropdown-menu-item") (js->clj))
focus-search-input! (fn [^js target]
(when (and search-enabled? (not= "INPUT" (.-nodeName target)))
;; focus search input
(some-> (get-content-el target)
(.querySelector "input")
(.focus))))
items (if search-enabled?
(if (fn? search-fn)
(search-fn items search-key1)
(simple-search-fn items search-key1))
items)
close1! #(when (fn? close!) (close!))]
(hooks/use-effect!
(fn []
(when (fn? on-search-key-change)
(on-search-key-change search-key1')))
[search-key1'])
(hooks/use-effect!
(fn []
(when-let [t (when (and search-enabled? (false? open?))
(js/setTimeout #(set-search-key! "") 500))]
#(js/clearTimeout t)))
[open?])
(x-content
(merge
{:onInteractOutside close1!
:onEscapeKeyDown close1!
:on-key-down (fn [^js e]
(when-let [^js target (.-target e)]
(case (.-key e)
"ArrowUp"
(when (= (some-> (get-item-nodes target) (first))
js/document.activeElement)
(focus-search-input! target))
"l" (when (or (.-metaKey e) (.-ctrlKey e))
(focus-search-input! target))
:dune)))
:class (str (:class content-props)
" ui__multi-select-content"
(when valid-search-key? " has-search-key"))}
(dissoc content-props :class))
;; header
(when (or search-enabled? (fn? head-render))
[:div.head
{:ref *head-ref}
(when search-enabled?
(search-input
{:value search-key1
:placeholder (or search-input-placeholder "")
:on-key-down (fn [^js e]
(.stopPropagation e)
(case (.-key e)
"Escape" (if (string/blank? search-key1)
(some-> (.-target e) (.closest ".ui__dropdown-menu-content") (.focus))
(set-search-key! ""))
:dune))
:on-change #(set-search-key! (.-value (.-target %)))}
{:on-enter (fn []
(when-let [head-el (and (not (string/blank? search-key1'))
(hooks/deref *head-ref))]
(when-let [^js item (.-nextSibling head-el)]
(when (contains? #{"menuitemcheckbox" "menuitem"}
(.getAttribute item "role"))
(.click item)))))
:valid-search-key? valid-search-key?}))
(when head-render (head-render))])
;; items
(for [item items
:let [selected? (some #(let [k (get-k item)
k' (get-k %)]
(or (= item %)
(and (not (nil? k))
(not (nil? k'))
(= k k'))))
selected-items)]]
(if (fn? item-render)
(item-render item {:x-item x-item :selected? selected?})
(let [k (get-k item)
v (get-v item)]
(when k
(let [opts {:selected? selected?}
on-click' (:on-click item-props)
on-click (fn [e]
;; TODO: return value
(when (fn? on-click') (on-click' e))
(when (fn? on-chosen)
(on-chosen item opts)))]
(x-item (merge {:data-k k :on-click on-click} item-props)
[:span.flex.items-center.gap-2.w-full
(form/checkbox {:checked selected?})
(let [v' (if (fn? v) (v item opts) v)]
(if (fn? value-render)
(value-render v' (assoc opts :item item)) v'))]))))))
(when (and search-enabled?
(fn? search-key-render))
(let [exist-fn (fn []
(and (not (string/blank? search-key1))
(seq items)
(contains? (into #{} (map #(some-> (get-v %) (string/lower-case)) items))
(string/lower-case search-key1))))]
(search-key-render search-key1
{:items items :x-item x-item :exist-fn exist-fn})))
;; footer
(when (fn? foot-render)
[:div.foot
(foot-render)]))))

407
deps/shui/src/logseq/shui/shortcut.cljs vendored Normal file
View File

@@ -0,0 +1,407 @@
(ns logseq.shui.shortcut
"Unified keyboard shortcut component with three styles: combo, separate, and compact.
Expected shortcut formats:
- Combo keys (simultaneous): \"shift+cmd\", [\"shift\" \"cmd\"], or [[\"shift\" \"cmd\"]]
- Separate keys (sequential): [\"cmd\" \"up\"], or [\"cmd\" \"k\"]
- Compact: same formats but with :style :compact
Platform mapping:
- :mod or \"mod\" maps to ⌘ on macOS, Ctrl on Windows/Linux
- Other keys are mapped via print-shortcut-key function"
(:require [clojure.string :as string]
[goog.userAgent]
[io.factorhouse.hsx.core :as hsx]))
(def mac? goog.userAgent/MAC)
(defn print-shortcut-key
"Maps logical keys to display keys, with platform-specific handling.
Supports :mod for platform-agnostic modifier key.
Automatically uppercases single letter keys (a-z) while preserving
multi-character keys like Ctrl, Backspace, Delete, Opt, etc."
[key]
(let [result (if (coll? key)
(string/join "+" key)
(case (if (string? key)
(string/lower-case key)
key)
("cmd" "command" "mod" "⌘") (if mac? "⌘" "Ctrl")
("meta") (if mac? "⌘" "⊞")
("return" "enter" "⏎") "⏎"
("shift" "⇧") "⇧"
("alt" "option" "opt" "⌥") (if mac? "⌥" "Alt")
("ctrl" "control" "⌃") "Ctrl"
("space" " ") "Space"
("up" "↑") "↑"
("down" "↓") "↓"
("left" "←") "←"
("right" "→") "→"
("tab") "Tab"
("open-square-bracket") "["
("close-square-bracket") "]"
("dash") "-"
("semicolon") ";"
("equals") "="
("single-quote") "'"
("backslash") "\\"
("comma") ","
("period") "."
("slash") "/"
("grave-accent") "`"
("page-up") "PgUp"
("page-down") "PgDn"
("esc" "escape") "Esc"
("backspace") "⌫"
("delete") "Delete"
("caps-lock" "capslock") "⇪"
(nil) ""
(name key)))]
;; If result is a single letter (a-z), uppercase it
;; Otherwise, capitalize only if it's a single character (for symbols)
(cond
(and (= (count result) 1)
(re-matches #"[a-z]" result))
(string/upper-case result)
(= (count result) 1)
result
:else
(string/capitalize result))))
(defn- flatten-keys
"Recursively flattens nested collections, preserving strings."
[coll]
(mapcat (fn [x]
(if (and (coll? x) (not (string? x)))
(flatten-keys x)
[x]))
coll))
(defn- normalize-binding
"Normalizes a shortcut binding to a string format for data attributes.
Examples: 'cmd+k', 'shift+cmd+k', 'cmd up'
Handles 'mod' -> 'meta' on Mac, 'ctrl' on Windows/Linux for consistency with shortcut system."
[binding]
(let [normalize-key (fn [k]
(cond
(string? k) (let [lowered (string/lower-case k)]
;; Convert 'mod' to 'meta' on Mac, 'ctrl' on Windows/Linux
;; to match what the shortcut system uses
(if (= lowered "mod")
(if mac? "meta" "ctrl")
lowered))
(keyword? k) (name k)
(symbol? k) (name k)
(number? k) (str k)
:else (str k)))]
(cond
(string? binding)
(let [trimmed (string/trim binding)
normalized (string/lower-case trimmed)]
;; Normalize modifier aliases to canonical form (meta/ctrl)
(-> normalized
(string/replace #"\bmod\b" (if mac? "meta" "ctrl"))
(string/replace #"\b(?:cmd|command)\b" "meta")
(string/replace #"(?:\bopt\b|\boption\b|⌥)" "alt")))
(coll? binding)
(if (and (coll? (first binding)) (> (count binding) 1) (every? coll? binding))
;; Chord sequence: normalize each group separately, join with space
(string/join " " (map (fn [group]
(string/join "+" (map normalize-key (flatten-keys group))))
binding))
;; Single combo group
(let [keys (flatten-keys binding)]
(string/join "+" (map normalize-key keys))))
(keyword? binding)
(name binding)
(symbol? binding)
(name binding)
:else
(str binding))))
(defn- detect-style
"Automatically detects style from shortcut format.
Returns :combo, :separate, or :compact"
[shortcut]
(cond
(string? shortcut)
(if (string/includes? shortcut "+")
:combo
:separate)
(coll? shortcut)
(let [first-item (first shortcut)]
(cond
(coll? first-item) :combo ; nested collection means combo
(string/includes? (str first-item) "+") :combo
:else :separate))
:else :separate))
(defn- parse-shortcuts
"Parses shortcut string into structured format.
Handles ' | ' separator for multiple shortcuts."
[s]
(->> (string/split s #" \| ")
(map (fn [x]
(->> (string/split x #" ")
(map #(if (string/includes? % "+")
(string/split % #"\+")
%)))))))
(def ^:private press-animation-ms 160)
(def ^:private row-shimmer-ms 750)
(defn- highlight-row!
"Add or remove the row highlight class on the closest shortcut row ancestor.
On first trigger, forces a reflow to start the CSS animation.
On repeated triggers, the class stays applied (no reflow) and the
removal timer is reset so it fires after the last trigger."
[^js container add?]
(when-let [^js row (or (.closest container ".shui-shortcut-row, .shortcut-row")
(.-parentElement container))]
(if add?
(let [already? (.contains (.-classList row) "shui-shortcut-row--pressed")]
;; Cancel any pending removal
(when-let [t (.-__sweepTimer row)]
(js/clearTimeout t)
(set! (.-__sweepTimer row) nil))
;; Only force reflow on first trigger to start the animation
(when-not already?
(.add (.-classList row) "shui-shortcut-row--pressed"))
;; Schedule removal after the last trigger
(set! (.-__sweepTimer row)
(js/setTimeout
(fn []
(.remove (.-classList row) "shui-shortcut-row--pressed")
(set! (.-__sweepTimer row) nil))
row-shimmer-ms)))
(do
(when-let [t (.-__sweepTimer row)]
(js/clearTimeout t)
(set! (.-__sweepTimer row) nil))
(.remove (.-classList row) "shui-shortcut-row--pressed")))))
(defn- animate-element!
"Add pressed class, optionally highlight row, then auto-reset after animation.
Key badge uses a simple clearTimeout+reset pattern to avoid stale removals."
[^js el ^js container highlight-row?]
(.add (.-classList el) "shui-shortcut-key-pressed")
(when highlight-row? (highlight-row! container true))
;; Clear any pending badge removal, then schedule a new one
(when-let [t (.-__badgeTimer el)]
(js/clearTimeout t))
(set! (.-__badgeTimer el)
(js/setTimeout
(fn []
(.remove (.-classList el) "shui-shortcut-key-pressed")
(set! (.-__badgeTimer el) nil))
press-animation-ms)))
(defn shortcut-press!
"Central helper to trigger key press animation.
For combo keys, animates the container (the whole keycap depresses).
For separate keys, animates individual kbd elements.
Optionally highlights parent row.
Args:
- binding: normalized shortcut binding string (e.g., \"cmd+k\")
- highlight-row?: if true, also highlights parent row (default: false)"
([binding] (shortcut-press! binding false))
([binding highlight-row?]
(let [normalized (normalize-binding binding)
selector (str "[data-shortcut-binding=\"" normalized "\"]")
containers (.querySelectorAll js/document selector)]
(doseq [^js container (array-seq containers)]
(if (.contains (.-classList container) "shui-shortcut-combo")
;; Combo keys: animate the container as a unit (one keycap)
(animate-element! container container highlight-row?)
;; Separate keys: animate each kbd individually
(let [keys (.querySelectorAll container "kbd.shui-shortcut-key")]
(if (> (.-length keys) 0)
(doseq [^js key-el (array-seq keys)]
(animate-element! key-el container highlight-row?))
(animate-element! container container highlight-row?))))))))
(hsx/defc combo-keys
"Renders combo keys (simultaneous key combinations) with separator."
[keys binding {:keys [aria-label aria-hidden? glow?]}]
(let [key-elements (map print-shortcut-key keys)
normalized-binding (normalize-binding binding)
container-class (str "shui-shortcut-combo" (when glow? " shui-shortcut-glow"))
container-attrs (cond-> {:class container-class
:data-shortcut-binding normalized-binding
:style {:white-space "nowrap"}}
aria-label (assoc :aria-label aria-label)
aria-hidden? (assoc :aria-hidden "true"))]
[:div container-attrs
(for [[index key-text] (map-indexed vector key-elements)]
(list
(when (< 0 index)
[:span.shui-shortcut-separator {:key (str "sep-" index)}])
[:kbd.shui-shortcut-key
{:key (str "combo-key-" index)
:aria-hidden (if aria-label "true" "false")}
key-text]))]))
(hsx/defc separate-keys
"Renders separate keys (sequential key presses) with 4px gap."
[keys binding {:keys [aria-label aria-hidden? glow?]}]
(let [key-elements (map print-shortcut-key keys)
normalized-binding (normalize-binding binding)
container-class (str "shui-shortcut-separate" (when glow? " shui-shortcut-glow"))
container-attrs (cond-> {:class container-class
:data-shortcut-binding normalized-binding
:style {:white-space "nowrap"
:gap "4px"}}
aria-label (assoc :aria-label aria-label)
aria-hidden? (assoc :aria-hidden "true"))]
[:div container-attrs
(for [[index key-text] (map-indexed vector key-elements)]
[:kbd.shui-shortcut-key
{:key (str "separate-key-" index)
:aria-hidden (if aria-label "true" "false")}
key-text])]))
(hsx/defc compact-keys
"Renders compact style (text-only, minimal styling)."
[keys binding {:keys [aria-label aria-hidden?]}]
(let [key-elements (map print-shortcut-key keys)
normalized-binding (normalize-binding binding)
container-attrs (cond-> {:class "shui-shortcut-compact"
:data-shortcut-binding normalized-binding
:style {:white-space "nowrap"}}
aria-label (assoc :aria-label aria-label)
aria-hidden? (assoc :aria-hidden "true"))]
[:div container-attrs
(for [[index key-text] (map-indexed vector key-elements)]
[:span
{:key (str "compact-key-" index)
:style {:display "inline-block"
:margin-right "2px"}}
key-text])]))
(hsx/defc chord-sequence-keys
"Renders a chord sequence (multi-step key combinations) with 'then' separators.
E.g., [['⌘' 'c'] ['⌘' 'r']] renders as: [⌘ C] then [⌘ R]"
[groups binding {:keys [aria-label aria-hidden? glow? chord-separator]
:or {chord-separator "then"}}]
(let [normalized-binding (normalize-binding binding)
container-attrs (cond-> {:class "shui-shortcut-chord"
:data-shortcut-binding normalized-binding
:style {:white-space "nowrap"
:display "inline-flex"
:align-items "center"
:gap "8px"}}
aria-label (assoc :aria-label aria-label)
aria-hidden? (assoc :aria-hidden "true"))]
[:div container-attrs
(for [[gi group] (map-indexed vector groups)]
(list
(when (> gi 0)
[:span.shui-shortcut-chord-sep
{:key (str "chord-sep-" gi)
:style {:font-size "10px"
:opacity 0.45}}
chord-separator])
(let [key-elements (map print-shortcut-key group)]
[:span
{:key (str "chord-group-" gi)
:class (str "shui-shortcut-combo" (when glow? " shui-shortcut-glow"))
:style {:display "inline-flex"
:align-items "center"
:white-space "nowrap"}}
(for [[ki key-text] (map-indexed vector key-elements)]
(list
(when (< 0 ki)
[:span.shui-shortcut-separator {:key (str "gsep-" gi "-" ki)}])
[:kbd.shui-shortcut-key
{:key (str "chord-key-" gi "-" ki)
:aria-hidden (if aria-label "true" "false")}
key-text]))])))]))
(hsx/defc root
"Main shortcut component with automatic style detection.
Props:
- :style - :combo, :separate, :compact, or :auto (default: :auto)
- :aria-label - accessibility label for container
- :aria-hidden? - if true, hides from screen readers (default: false for decorative hints)
- :glow? - if true, adds inner glow effect to combo/separate keys (default: true)
- :raw-binding - raw binding format for data-shortcut-binding (for animation matching).
If not provided, will normalize from shortcut prop."
[shortcut & {:keys [style aria-label aria-hidden? glow? raw-binding chord-separator]
:or {style :auto
aria-hidden? false
glow? true}}]
(when (and shortcut (seq shortcut))
(let [shortcuts (if (coll? shortcut)
(if (every? string? shortcut)
[shortcut] ; single shortcut as vector
(if (string? (first shortcut))
[shortcut] ; single shortcut string
shortcut)) ; multiple shortcuts
(parse-shortcuts shortcut))
opts (cond-> {:aria-label aria-label
:aria-hidden? aria-hidden?
:glow? glow?}
chord-separator (assoc :chord-separator chord-separator))]
(for [[index binding] (map-indexed vector shortcuts)]
(let [;; Chord sequence: multiple nested groups like [["⌘" "c"] ["⌘" "r"]]
chord-sequence? (and (coll? binding)
(> (count binding) 1)
(every? coll? binding))
detected-style (if (= style :auto)
(detect-style binding)
style)
keys (cond
(string? binding)
(if (string/includes? binding "+")
(string/split binding #"\+") ; combo: "cmd+k" -> ["cmd" "k"]
(string/split binding #" ")) ; separate: "cmd k" -> ["cmd" "k"]
(and (coll? binding) (coll? (first binding)))
(first binding) ; combo: nested collection like [["shift" "cmd"]]
(coll? binding)
(let [flattened (mapcat (fn [item]
(cond
(coll? item) item
(and (string? item) (string/includes? item "+"))
(string/split item #"\+") ; split combo strings like "meta+caps-lock"
:else [item]))
binding)]
(if (every? string? flattened)
flattened ; separate: flat collection like ["cmd" "k"] or ["⇧" "g"]
(map str flattened))) ; convert any non-strings to strings
:else
[(str binding)])
;; Use raw-binding if provided, otherwise normalize from binding
binding-for-data (cond
(nil? raw-binding) binding
(and (coll? raw-binding) (= (count raw-binding) 1)) (first raw-binding)
:else raw-binding)
render-fn (case detected-style
:combo combo-keys
:separate separate-keys
:compact compact-keys
separate-keys)] ; fallback
[:span
{:key (str "shortcut-" index)
:style {:display "inline-flex"
:align-items "center"
:white-space "nowrap"}}
(when (< 0 index)
[:span.text-gray-11.text-sm {:key (str "sep-" index)
:style {:margin "0 4px"}} "|"])
(if chord-sequence?
(chord-sequence-keys binding binding-for-data opts)
(render-fn keys binding-for-data opts))])))))

View File

@@ -0,0 +1,276 @@
(ns logseq.shui.table.core
"Table"
(:require [clojure.set :as set]
[dommy.core :refer-macros [sel1] :as dom]
[frontend.util :as util]
[goog.object :as gobj]
[io.factorhouse.hsx.core :as hsx]
[logseq.shui.hooks :as hooks]
[logseq.shui.table.impl :as impl]))
(defn- get-head-container
[]
#_{:clj-kondo/ignore [:unresolved-namespace]}
(if (and js/window (gobj/get js/window "isCapacitorNew"))
(sel1 "ion-header")
(sel1 "#head")))
(defn- row-selected?
[row row-selection]
(let [id (:db/id row)]
(or
(and (:selected-all? row-selection)
;; exclude ids
(not (contains? (:excluded-ids row-selection) id)))
(and (not (:selected-all? row-selection))
;; included ids
(contains? (:selected-ids row-selection) id)))))
(defn- select-some?
[row-selection rows]
(or
(and (seq (:selected-ids row-selection))
(some (:selected-ids row-selection) rows))
(and (seq (:excluded-ids row-selection))
(not= (count rows) (count (:excluded-ids row-selection))))))
(defn- select-all?
[row-selection rows]
(and (seq (:selected-ids row-selection))
(set/subset? (set rows)
(:selected-ids row-selection))))
(defn- toggle-selected-all!
[table value set-row-selection!]
(let [group-by-property (get-in table [:state :group-by-property])
row-selection (get-in table [:state :row-selection])]
(cond
(and group-by-property value)
(let [new-selection (update row-selection :selected-ids
(fn [ids]
(set/union (set ids) (set (:rows table)))))]
(set-row-selection! new-selection))
value
(set-row-selection! {:selected-all? value})
group-by-property
(let [new-selection (update row-selection :selected-ids
(fn [ids]
(set/difference (set ids) (set (:rows table)))))]
(set-row-selection! new-selection))
:else
(set-row-selection! {}))))
(defn- set-conj
[col item]
(if (seq col)
(conj (if (set? col) col (set col)) item)
(conj #{} item)))
(defn- row-toggle-selected!
[row value set-row-selection! row-selection]
(let [id (:db/id row)
new-selection (if (:selected-all? row-selection)
(update row-selection :excluded-ids (if value disj set-conj) id)
(update row-selection :selected-ids (if value set-conj disj) id))]
(set-row-selection! new-selection)))
(defn- column-set-sorting!
[column set-sorting! sorting asc?]
(let [id (:id column)
existing-column (some (fn [item] (when (= (:id item) id) item)) sorting)
value (->> (if existing-column
(if (nil? asc?)
(remove (fn [item] (= (:id item) id)) sorting)
(map (fn [item] (if (= (:id item) id) (assoc item :asc? asc?) item)) sorting))
(when-not (nil? asc?)
(into [{:id id :asc? asc?}] sorting)))
(remove nil?)
vec)]
(set-sorting! value)
value))
(defn get-selection-rows
[row-selection rows]
(if (:selected-all? row-selection)
(let [excluded-ids (:excluded-ids row-selection)]
(if (seq excluded-ids)
(remove #(excluded-ids %) rows)
rows))
(let [selected-ids (:selected-ids row-selection)]
(when (seq selected-ids)
(filter #(selected-ids %) rows)))))
(defn table-option
[{:keys [data columns state data-fns]
:as option}]
(let [{:keys [sorting row-filter row-selection visible-columns]} state
{:keys [set-sorting! set-visible-columns! set-row-selection!]} data-fns
columns' (impl/visible-columns columns visible-columns)
filtered-rows (impl/rows {:rows data
:columns columns
:sorting sorting
:row-filter row-filter})]
(assoc option
;; visible columns
:columns columns'
;; filtered rows
:rows filtered-rows
;; fns
:column-visible? (fn [column] (impl/column-visible? column visible-columns))
:column-toggle-visibility (fn [column v] (set-visible-columns! (assoc visible-columns (impl/column-id column) v)))
:selected-all? (or (and (:selected-all? row-selection)
(nil? (seq (:excluded-ids row-selection))))
(select-all? row-selection filtered-rows))
:selected-some? (select-some? row-selection filtered-rows)
:row-selected? (fn [row] (row-selected? row row-selection))
:row-toggle-selected! (fn [row-selection row value] (row-toggle-selected! row value set-row-selection! row-selection))
:toggle-selected-all! (fn [table value]
(toggle-selected-all! table value set-row-selection!))
:column-set-sorting! (fn [sorting column asc?] (column-set-sorting! column set-sorting! sorting asc?)))))
(defn- get-prop-and-children
[prop-and-children]
(let [prop (when (map? (first prop-and-children)) (first prop-and-children))]
(if prop
[prop (rest prop-and-children)]
[{} prop-and-children])))
(hsx/defc table
[& prop-and-children]
(let [[prop children] (get-prop-and-children prop-and-children)]
(into
[:div (merge {:class "ls-table w-full caption-bottom text-sm table-fixed"}
prop)]
children)))
(defn- remove-sticky-header
[]
(let [existing-headings (dom/sel ".ls-fixed")]
(doseq [node existing-headings]
(dom/remove-class! node "ls-fixed"))))
(defn- use-sticky-element!
[target-ref container enabled?]
(hooks/use-effect!
(fn []
(when enabled?
(let [^js/HTMLElement target (hooks/deref target-ref)
^js/HTMLElement container (or (.closest target ".sidebar-item-list") container)
^js/HTMLElement table-el (.closest target ".ls-table-rows")]
(when (and container table-el)
(let [^js/DOMTokenList target-cls (.-classList target)
^js/HTMLElement table-footer (some-> table-el (.querySelector ".ls-table-footer"))
^js/HTMLElement page-el (.closest target ".page-inner")
*el-top (volatile! (-> target (.getBoundingClientRect) (.-top)))
head-height (-> (get-head-container) (.-offsetHeight))
update-target-top! (fn []
(when (not (.contains target-cls "ls-fixed"))
(vreset! *el-top (+ (-> target (.getBoundingClientRect) (.-top))
(.-scrollTop container)))))
update-footer! (fn []
(let [tw (.-scrollWidth table-el)]
(when (and table-footer (number? tw) (> tw 0))
(set! (. (.-style table-footer) -width) (str tw "px")))))
update-target! (fn []
(if (.contains target-cls "ls-fixed")
(let [^js/DOMRect rect (-> table-el (.getBoundingClientRect))
width (.-clientWidth table-el)
left (.-left rect)]
(set! (. (.-style target) -width) (str width "px"))
(set! (. (.-style target) -left) (str left "px")))
(do
(set! (. (.-style target) -width) "auto")
(set! (. (.-style target) -left) "0px")))
;; update scroll
(set! (. target -scrollLeft) (.-scrollLeft table-el)))
;; target observer
target-observe! (fn [_e]
(let [first-visible-table (some #(when (util/el-visible-in-viewport? % true) %)
(dom/sel container ".ls-table-rows"))]
(when (= table-el first-visible-table)
(let [table-bottom (.-bottom (.getBoundingClientRect table-el))
table-top (.-top (.getBoundingClientRect table-el))]
(if (and (< table-top head-height)
(> table-bottom 100))
(do
(remove-sticky-header)
(.add target-cls "ls-fixed"))
(.remove target-cls "ls-fixed"))
(update-target!)))))
resize-observer (js/ResizeObserver. update-target!)
page-resize-observer (js/ResizeObserver. (fn [] (update-target-top!)))]
;; events
(.observe resize-observer container)
(.observe resize-observer table-el)
(some->> page-el (.observe page-resize-observer))
(.addEventListener container "scroll" target-observe!)
(.addEventListener table-el "scroll" update-target!)
(.addEventListener table-el "resize" update-target!)
(update-footer!)
;; teardown
#(do (.removeEventListener container "scroll" target-observe!)
(.disconnect resize-observer)
(.disconnect page-resize-observer)))))))
[enabled? container]))
(defn- mobile?
[]
(when-let [user-agent js/navigator.userAgent]
(re-find #"Mobi" user-agent)))
(hsx/defc table-header
[& prop-and-children]
(let [[prop children] (get-prop-and-children prop-and-children)
el-ref (hooks/use-ref nil)
_ (use-sticky-element! el-ref (:main-container prop) (not (mobile?)))]
(into
[:div.ls-table-header
(merge {:class "border-y transition-colors bg-gray-01"
:ref el-ref
:style {:z-index 9}}
(dissoc prop :main-container))]
children)))
(hsx/defc table-footer
[& children]
(into [:div.ls-table-footer.fade-in.faster] children))
(hsx/defc table-row
[& prop-and-children]
(let [[prop children] (get-prop-and-children prop-and-children)]
(into
[:div.ls-table-row.ls-block.flex.flex-row.items-center
(merge {:class "border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted bg-gray-01 items-stretch"}
prop)]
children)))
(hsx/defc table-cell
[& prop-and-children]
(let [[prop children] (get-prop-and-children prop-and-children)]
[:div.ls-table-cell.flex.relative.h-full (dissoc prop :select? :add-property?)
(into
[:div {:class (str "flex align-middle w-full overflow-x-clip items-center"
(cond
(:select? prop)
" px-0"
(:add-property? prop)
""
:else
" border-r px-2"))}]
children)]))
(hsx/defc table-actions
[& prop-and-children]
(let [[prop children] (get-prop-and-children prop-and-children)
el-ref (hooks/use-ref nil)]
(into
[:div.ls-table-actions.flex.flex-row.items-center.gap-1.bg-gray-01
(merge {:ref el-ref
:style {:z-index 101}}
prop)]
children)))

View File

@@ -0,0 +1,22 @@
(ns logseq.shui.table.impl
"Table impl")
(defn column-id
[column]
(assert (some? (:id column)) "No id specified for this column")
(:id column))
(defn column-visible?
[column visible-columns]
(let [value (get visible-columns (column-id column))]
(not (false? value))))
(defn visible-columns
[columns visible-columns']
(filter #(column-visible? % visible-columns') columns))
(defn rows
[{:keys [row-filter]
:as opts}]
(let [rows' (:rows opts)]
(if row-filter (filter row-filter rows') rows')))

View File

@@ -0,0 +1,98 @@
(ns logseq.shui.toaster.core
(:require [cljs-bean.core :as bean]
[io.factorhouse.hsx.core :as hsx]
[logseq.shui.hooks :as hooks]
[logseq.shui.util :as util]))
(defonce ^:private Toaster (util/ui-wrap "Toaster"))
(defonce ^:private *toast (atom nil))
(defonce ^:private *pending-toasts (atom []))
(defn gen-id []
((util/ui-get "genToastId")))
(defn use-toast []
(when-let [use-toast' (util/ui-get "useToast")]
(let [^js js-toast (use-toast')
toast-fn! (.-toast js-toast)
dismiss! (.-dismiss js-toast)]
[(fn [s]
(let [^js s (bean/->js s)]
(toast-fn! s)))
dismiss!])))
(defn update-html-props
[v]
(update-keys v
#(case %
:class :className
:for :htmlFor
:on-dismiss :onDismiss
:on-open-change :onOpenChange
%)))
(defn interpret-vals
[config ks & args]
(reduce (fn [config k]
(let [v (get config k)
v (if (fn? v) (apply v args) v)]
(if (vector? v) (assoc config k (hsx/create-element v)) config)))
config ks))
(defn toast!
([content-or-config] (toast! content-or-config :default nil))
([content-or-config status] (toast! content-or-config status nil))
([content-or-config status opts]
(if-let [{:keys [toast dismiss]} @*toast]
(let [config (if (map? content-or-config)
content-or-config
(-> {:description content-or-config}
(merge (if (map? status) status {:variant status}))))
config (update-html-props (merge config opts))
id (or (:id config) (gen-id))
config (assoc config :id id)
config (interpret-vals config [:title :description :action :icon]
{:id id :dismiss! #(dismiss id) :update! #(toast! (assoc %1 :id id))})]
(js->clj (toast (clj->js config))))
(let [config (if (map? content-or-config)
content-or-config
(-> {:description content-or-config}
(merge (if (map? status) status {:variant status}))))
config (update-html-props (merge config opts))
id (or (:id config) (gen-id))
config (assoc config :id id)]
(swap! *pending-toasts conj config)
{"id" id}))))
(defn dismiss!
([] (dismiss! nil))
([id]
(if id
(swap! *pending-toasts
(fn [pending-toasts]
(vec (remove #(= id (:id %)) pending-toasts))))
(reset! *pending-toasts []))
(when-let [{:keys [dismiss]} @*toast]
;; dismiss all
(dismiss))))
(hsx/defc install-toaster
[]
(let [^js js-toast ((util/ui-get "useToast"))]
(hooks/use-effect!
(fn []
(let [toast-api {:toast (.-toast js-toast)
:dismiss (.-dismiss js-toast)
:update (.-update js-toast)}]
(reset! *toast toast-api)
(doseq [config @*pending-toasts]
(let [id (:id config)
config (interpret-vals config [:title :description :action :icon]
{:id id
:dismiss! #((:dismiss toast-api) id)
:update! #(toast! (assoc %1 :id id))})]
((:toast toast-api) (clj->js config))))
(reset! *pending-toasts []))
#())
[])
[:<> (Toaster)]))

165
deps/shui/src/logseq/shui/ui.cljs vendored Normal file
View File

@@ -0,0 +1,165 @@
(ns logseq.shui.ui
(:require [logseq.shui.base.core :as base-core]
[logseq.shui.dialog.core :as dialog-core]
[logseq.shui.form.core :as form-core]
[logseq.shui.form.password :as form-password]
[logseq.shui.icon.v2 :as icon-v2]
[logseq.shui.popup.core :as popup-core]
[logseq.shui.select.core :as select-core]
[logseq.shui.select.multi :as select-multi]
[logseq.shui.shortcut :as shui.shortcut]
[logseq.shui.table.core :as table-core]
[logseq.shui.toaster.core :as toaster-core]
[logseq.shui.util :as util]))
(def button base-core/button)
(def button-icon base-core/button-icon)
(def button-ghost-icon base-core/button-ghost-icon)
(def button-outline-icon base-core/button-outline-icon)
(def button-secondary-icon base-core/button-secondary-icon)
(def button-group (util/ui-wrap "ButtonGroup"))
(def toolbar (util/ui-wrap "Toolbar"))
(def toolbar-group (util/ui-wrap "ToolbarGroup"))
(def toolbar-button (util/ui-wrap "ToolbarButton"))
(def toolbar-input (util/ui-wrap "ToolbarInput"))
(def toolbar-link (util/ui-wrap "ToolbarLink"))
(def toolbar-separator (util/ui-wrap "ToolbarSeparator"))
(def link base-core/link)
(def trigger-as base-core/trigger-as)
(def trigger-child-wrap base-core/trigger-child-wrap)
(def shortcut shui.shortcut/root)
(def shortcut-press! shui.shortcut/shortcut-press!)
(def ^:export tabler-icon icon-v2/root)
(def alert (util/ui-wrap "Alert"))
(def alert-title (util/ui-wrap "AlertTitle"))
(def alert-description (util/ui-wrap "AlertDescription"))
(def slider (util/ui-wrap "Slider"))
(def slider-track (util/ui-wrap "SliderTrack"))
(def slider-range (util/ui-wrap "SliderRange"))
(def slider-thumb (util/ui-wrap "SliderThumb"))
(def separator (util/ui-wrap "Separator"))
(def badge (util/ui-wrap "Badge"))
(def skeleton (util/ui-wrap "Skeleton"))
(def calendar (util/ui-wrap "Calendar"))
(def avatar (util/ui-wrap "Avatar"))
(def avatar-image (util/ui-wrap "AvatarImage"))
(def avatar-fallback (util/ui-wrap "AvatarFallback"))
(def input form-core/input)
(def textarea form-core/textarea)
(def switch form-core/switch)
(def checkbox form-core/checkbox)
(def radio-group form-core/radio-group)
(def radio-group-item form-core/radio-group-item)
(def popover popup-core/popover)
(def popover-trigger popup-core/popover-trigger)
(def popover-content popup-core/popover-content)
(def popover-arrow popup-core/popover-arrow)
(def tooltip (util/ui-wrap "Tooltip"))
(def tooltip-trigger (util/ui-wrap "TooltipTrigger"))
(def tooltip-portal (util/ui-wrap "TooltipPortal"))
(def tooltip-content (util/ui-wrap "TooltipContent"))
(def tooltip-provider (util/ui-wrap "TooltipProvider"))
(def tooltip-arrow (util/ui-wrap "TooltipArrow"))
(def card (util/ui-wrap "Card"))
(def card-header (util/ui-wrap "CardHeader"))
(def card-title (util/ui-wrap "CardTitle"))
(def card-description (util/ui-wrap "CardDescription"))
(def card-content (util/ui-wrap "CardContent"))
(def card-footer (util/ui-wrap "CardFooter"))
(def form-provider form-core/form-provider)
(def form-item form-core/form-item)
(def form-label form-core/form-label)
(def form-description form-core/form-description)
(def form-message form-core/form-message)
(def form-field form-core/form-field)
(def form-control form-core/form-control)
(def select select-core/select)
(def select-group select-core/select-group)
(def select-value select-core/select-value)
(def select-trigger select-core/select-trigger)
(def select-icon select-core/select-icon)
(def select-content select-core/select-content)
(def select-label select-core/select-label)
(def select-item select-core/select-item)
(def select-separator select-core/select-separator)
(def select-scroll-up-button select-core/select-scroll-up-button)
(def select-scroll-down-button select-core/select-scroll-down-button)
(def dropdown-menu popup-core/dropdown-menu)
(def dropdown-menu-trigger popup-core/dropdown-menu-trigger)
(def dropdown-menu-content popup-core/dropdown-menu-content)
(def dropdown-menu-group popup-core/dropdown-menu-group)
(def dropdown-menu-item popup-core/dropdown-menu-item)
(def dropdown-menu-checkbox-item popup-core/dropdown-menu-checkbox-item)
(def dropdown-menu-radio-group popup-core/dropdown-menu-radio-group)
(def dropdown-menu-radio-item popup-core/dropdown-menu-radio-item)
(def dropdown-menu-label popup-core/dropdown-menu-label)
(def dropdown-menu-separator popup-core/dropdown-menu-separator)
(def dropdown-menu-shortcut popup-core/dropdown-menu-shortcut)
(def dropdown-menu-sub popup-core/dropdown-menu-sub)
(def dropdown-menu-sub-content popup-core/dropdown-menu-sub-content)
(def dropdown-menu-sub-trigger popup-core/dropdown-menu-sub-trigger)
(def context-menu (util/ui-wrap "ContextMenu"))
(def context-menu-trigger (util/ui-wrap "ContextMenuTrigger"))
(def context-menu-content (util/ui-wrap "ContextMenuContent"))
(def context-menu-item (util/ui-wrap "ContextMenuItem"))
(def context-menu-checkbox-item (util/ui-wrap "ContextMenuCheckboxItem"))
(def context-menu-radio-item (util/ui-wrap "ContextMenuRadioItem"))
(def context-menu-label (util/ui-wrap "ContextMenuLabel"))
(def context-menu-separator (util/ui-wrap "ContextMenuSeparator"))
(def context-menu-shortcut (util/ui-wrap "ContextMenuShortcut"))
(def context-menu-group (util/ui-wrap "ContextMenuGroup"))
(def context-menu-portal (util/ui-wrap "ContextMenuPortal"))
(def context-menu-sub (util/ui-wrap "ContextMenuSub"))
(def context-menu-sub-content (util/ui-wrap "ContextMenuSubContent"))
(def context-menu-sub-trigger (util/ui-wrap "ContextMenuSubTrigger"))
(def context-menu-radio-group (util/ui-wrap "ContextMenuRadioGroup"))
;; tabs
(def tabs (util/ui-wrap "Tabs"))
(def tabs-list (util/ui-wrap "TabsList"))
(def tabs-trigger (util/ui-wrap "TabsTrigger"))
(def tabs-indicator (util/ui-wrap "TabsIndicator"))
(def tabs-content (util/ui-wrap "TabsContent"))
(def dialog dialog-core/dialog)
(def dialog-portal dialog-core/dialog-portal)
(def dialog-overlay dialog-core/dialog-overlay)
(def dialog-close dialog-core/dialog-close)
(def dialog-trigger dialog-core/dialog-trigger)
(def dialog-content dialog-core/dialog-content)
(def dialog-header dialog-core/dialog-header)
(def dialog-footer dialog-core/dialog-footer)
(def dialog-title dialog-core/dialog-title)
(def dialog-description dialog-core/dialog-description)
(def toast! toaster-core/toast!)
(def toast-dismiss! toaster-core/dismiss!)
(def dialog-open! dialog-core/open!)
(def dialog-alert! dialog-core/alert!)
(def dialog-confirm! dialog-core/confirm!)
(def dialog-close! dialog-core/close!)
(def dialog-close-all! dialog-core/close-all!)
(def dialog-get dialog-core/get-dialog)
(def popup-show! popup-core/show!)
(def popup-hide! popup-core/hide!)
(def popup-hide-all! popup-core/hide-all!)
(def multi-select-content select-multi/x-select-content)
(def table-option table-core/table-option)
(def table table-core/table)
(def table-header table-core/table-header)
(def table-footer table-core/table-footer)
(def table-row table-core/table-row)
(def table-cell table-core/table-cell)
(def table-actions table-core/table-actions)
(def table-get-selection-rows table-core/get-selection-rows)
(def toggle-password form-password/toggle-password)

113
deps/shui/src/logseq/shui/util.cljs vendored Normal file
View File

@@ -0,0 +1,113 @@
(ns logseq.shui.util
(:require
["uniqolor" :as uniqolor]
["react" :as react]
[cljs-bean.core :as bean]
[clojure.set :refer [rename-keys]]
[clojure.string :as string]
[clojure.walk :as w]
[goog.object :as gobj]
[io.factorhouse.hsx.core :as hsx]
[logseq.shui.components :as components]
[logseq.shui.hooks :as hooks]))
(goog-define NODETEST false)
(defn kebab-case->camel-case
"Converts from kebab case to camel case, eg: on-click to onClick"
[input]
(let [words (string/split input #"-")
capitalize (->> (rest words)
(map #(apply str (string/upper-case (first %)) (rest %))))]
(apply str (first words) capitalize)))
(defn map-keys->camel-case
"Stringify all the keys of a cljs hashmap and converts them
from kebab case to camel case. If :html-props option is specified,
then rename the html properties values to their dom equivalent
before conversion"
[data & {:keys [html-props]}]
(let [convert-to-camel (fn [[key value]]
(let [k (name key)]
[(if-not (or (string/starts-with? k "data-")
(string/starts-with? k "aria-"))
(kebab-case->camel-case k) k) value]))]
(w/postwalk (fn [x]
(if (map? x)
(let [new-map (if html-props
(rename-keys x {:class :className :for :htmlFor})
x)]
(into {} (map convert-to-camel new-map)))
x))
data)))
(def dev? goog.DEBUG)
(defn uuid-color
[uuid-str]
(some-> (uniqolor uuid-str
#js {:saturation #js [55, 70]
:lightness 70
:differencePoint 60})
(aget "color")))
(defn get-path
"Returns the component path."
[component-name]
(string/split (name component-name) #"\."))
(defn adapt-class [react-class & args]
(let [[opts children] (if (map? (first args))
[(first args) (rest args)]
[{} args])
children (some->> children (remove nil?))
children (map hsx/create-element children)
;; convert any options key value to a React element, if
;; a valid html element tag is used.
vector->react-elems (fn [[key val]]
(if (sequential? val)
[key (hsx/create-element val)]
[key val]))
new-options (into {} (map vector->react-elems opts))
react-class (if dev? (react-class) react-class)]
(apply react/createElement react-class
;; sablono html-to-dom-attrs does not work for nested hash-maps
(bean/->js (map-keys->camel-case new-options :html-props true))
children)))
(def use-atom hooks/use-atom)
(def use-mounted hooks/use-mounted)
(defn- same-args?
[^js prev-props ^js next-props]
(= (.-args prev-props)
(.-args next-props)))
(defn react->component [c static?]
(if static?
(let [class (fn [^js props]
(apply adapt-class c (.-args props)))
memo-class (if-some [memo (.-memo react)]
(memo class same-args?)
class)]
(fn [& args]
(react/createElement memo-class #js {:args args})))
(partial adapt-class c)))
(defn component-wrap
"Returns the component by the given component name."
[^js ns name & {:keys [static?] :or {static? false}}]
(let [path (get-path name)
;; lazy calculating is for HMR from ts
cp #(gobj/getValueByKeys ns (clj->js path))]
(react->component (if dev? cp (cp)) static?)))
(def ui-wrap
(partial component-wrap components/registry))
(defn ui-get
[name]
(if NODETEST
#js {}
(some-> components/registry (aget name))))