Files
logseq/src/main/frontend/ui.cljs
2024-08-06 14:17:29 +08:00

1139 lines
44 KiB
Clojure

(ns frontend.ui
"Main ns for reusable components"
(:require ["@logseq/react-tweet-embed" :as react-tweet-embed]
["react-intersection-observer" :as react-intersection-observer]
["react-resize-context" :as Resize]
["react-textarea-autosize" :as TextareaAutosize]
["react-tippy" :as react-tippy]
["react-transition-group" :refer [CSSTransition TransitionGroup]]
["react-virtuoso" :refer [Virtuoso]]
["@emoji-mart/data" :as emoji-data]
["emoji-mart" :as emoji-mart]
[cljs-bean.core :as bean]
[clojure.string :as string]
[electron.ipc :as ipc]
[frontend.components.svg :as svg]
[frontend.config :as config]
[frontend.context.i18n :refer [t]]
[frontend.db-mixins :as db-mixins]
[frontend.handler.notification :as notification]
[frontend.handler.plugin :as plugin-handler]
[frontend.mixins :as mixins]
[frontend.mobile.util :as mobile-util]
[frontend.modules.shortcut.config :as shortcut-config]
[frontend.modules.shortcut.core :as shortcut]
[frontend.modules.shortcut.utils :as shortcut-utils]
[frontend.rum :as r]
[frontend.state :as state]
[frontend.storage :as storage]
[frontend.util :as util]
[frontend.util.cursor :as cursor]
[goog.dom :as gdom]
[goog.object :as gobj]
[lambdaisland.glogi :as log]
[logseq.shui.icon.v2 :as shui.icon.v2]
[logseq.shui.popup.core :as shui-popup]
[medley.core :as medley]
[promesa.core :as p]
[rum.core :as rum]
[logseq.shui.ui :as shui]))
(declare icon)
(defonce transition-group (r/adapt-class TransitionGroup))
(defonce css-transition (r/adapt-class CSSTransition))
(defonce textarea (r/adapt-class (gobj/get TextareaAutosize "default")))
(defonce virtualized-list (r/adapt-class Virtuoso))
(def resize-provider (r/adapt-class (gobj/get Resize "ResizeProvider")))
(def resize-consumer (r/adapt-class (gobj/get Resize "ResizeConsumer")))
(def Tippy (r/adapt-class (gobj/get react-tippy "Tooltip")))
(def ReactTweetEmbed (r/adapt-class react-tweet-embed))
(def useInView (gobj/get react-intersection-observer "useInView"))
(defonce _emoji-init-data ((gobj/get emoji-mart "init") #js {:data emoji-data}))
;; (def EmojiPicker (r/adapt-class (gobj/get Picker "default")))
(defonce icon-size (if (mobile-util/native-platform?) 26 20))
(defn shui-popups? [] (some-> (shui-popup/get-popups) (count) (> 0)))
(defn last-shui-preview-popup?
[]
(= "ls-preview-popup"
(some-> (shui-popup/get-last-popup) :content-props :class)))
(defn hide-popups-until-preview-popup!
[]
(while (and (shui-popups?)
(not (last-shui-preview-popup?)))
(shui/popup-hide!)))
(def built-in-colors
["yellow"
"red"
"pink"
"green"
"blue"
"purple"
"gray"])
(defn ->block-background-color
[color]
(if (some #{color} built-in-colors)
(str "var(--ls-highlight-color-" color ")")
color))
(defn built-in-color?
[color]
(some #{color} built-in-colors))
(rum/defc menu-background-color
[add-bgcolor-fn rm-bgcolor-fn]
[:div.flex.flex-row.justify-between.py-1.px-2.items-center
[:div.flex.flex-row.justify-between.flex-1.mx-2.mt-2
(for [color built-in-colors]
[:a
{:key (str "key-" color)
:title (t (keyword "color" color))
:on-click #(add-bgcolor-fn color)}
[:div.heading-bg {:style {:background-color (str "var(--color-" color "-500)")}}]])
[:a
{:title (t :remove-background)
:on-click rm-bgcolor-fn}
[:div.heading-bg.remove "-"]]]])
(rum/defc ls-textarea
< rum/reactive
{:did-mount (fn [state]
(let [^js el (rum/dom-node state)
*mouse-point (volatile! nil)]
;; Passing aria-label as a prop to TextareaAutosize removes the dash
(.setAttribute el "aria-label" "editing block")
(doto el
(.addEventListener "select"
#(let [start (util/get-selection-start el)
end (util/get-selection-end el)]
(when (and start end)
(when-let [e (and (not= start end)
(let [caret-pos (cursor/get-caret-pos el)]
{:caret caret-pos
:start start :end end
:text (. (.-value el) substring start end)
:point (select-keys (or @*mouse-point caret-pos) [:x :y])}))]
(plugin-handler/hook-plugin-editor :input-selection-end (bean/->js e))
(vreset! *mouse-point nil)))))
(.addEventListener "mouseup" #(vreset! *mouse-point {:x (.-x %) :y (.-y %)}))))
state)
:will-unmount (fn [state]
(when-let [on-unmount (:on-unmount (first (:rum/args state)))]
(on-unmount))
state)}
[{:keys [on-change] :as props}]
(let [skip-composition? (state/sub :editor/action)
on-composition (fn [e]
(if skip-composition?
(on-change e)
(case e.type
"compositionend" (do
(state/set-editor-in-composition! false)
(on-change e))
(state/set-editor-in-composition! true))))
props (assoc props
:on-change (fn [e] (when-not (state/editor-in-composition?)
(on-change e)))
:on-composition-start on-composition
:on-composition-update on-composition
:on-composition-end on-composition)]
(textarea props)))
(rum/defc dropdown-content-wrapper
< {:did-mount (fn [state]
(let [k (inc (count (state/sub :modal/dropdowns)))
args (:rum/args state)]
(state/set-state! [:modal/dropdowns k] (second args))
(assoc state ::k k)))
:will-unmount (fn [state]
(state/update-state! :modal/dropdowns #(dissoc % (::k state)))
state)}
[dropdown-state _close-fn content class style-opts]
(let [class (or class
(util/hiccup->class "origin-top-right.absolute.right-0.mt-2"))]
[:div.dropdown-wrapper.max-h-screen.overflow-y-auto
{:style style-opts
:class (str class " "
(case dropdown-state
"entering" "transition ease-out duration-100 transform opacity-0 scale-95"
"entered" "transition ease-out duration-100 transform opacity-100 scale-100"
"exiting" "transition ease-in duration-75 transform opacity-100 scale-100"
"exited" "transition ease-in duration-75 transform opacity-0 scale-95"))}
content]))
;; public exports
(rum/defcs dropdown < (mixins/modal :open?)
{:init (fn [state]
(let [opts (if (map? (last (:rum/args state)))
(last (:rum/args state))
(->> (drop 2 (:rum/args state))
(partition 2)
(map vec)
(into {})))]
(when (:initial-open? opts)
(reset! (:open? state) true))
(let [on-toggle (:on-toggle opts)]
(when (fn? on-toggle)
(add-watch (:open? state) ::listen-open-value
(fn [_ _ _ _]
(on-toggle @(:open? state)))))))
state)}
[state content-fn modal-content-fn
& [{:keys [modal-class z-index trigger-class _initial-open? *toggle-fn
_on-toggle]
:or {z-index 999}}]]
(let [{:keys [open?]} state
_ (when (and (util/atom? *toggle-fn)
(nil? @*toggle-fn)
(:toggle-fn state))
(reset! *toggle-fn (:toggle-fn state)))
modal-content (modal-content-fn state)
close-fn (:close-fn state)]
[:div.relative.ui__dropdown-trigger {:class trigger-class}
(content-fn state)
(css-transition
{:in @open? :timeout 0}
(fn [dropdown-state]
(when @open?
(dropdown-content-wrapper dropdown-state close-fn modal-content modal-class {:z-index z-index}))))]))
;; `sequence` can be a list of symbols, a list of strings, or a string
(defn render-keyboard-shortcut [sequence & {:as opts}]
(let [sequence (if (string? sequence)
(-> sequence ;; turn string into sequence
(string/trim)
(string/lower-case)
(string/split #" "))
sequence)]
[:span.keyboard-shortcut
(shui/shortcut sequence opts)]))
(rum/defc menu-link
[{:keys [only-child? no-padding? class shortcut] :as options} child]
(if only-child?
[:div.menu-link
(dissoc options :only-child?) child]
[:a.flex.justify-between.menu-link
(cond-> options
(true? no-padding?)
(assoc :class (str class " no-padding"))
true
(dissoc :no-padding?))
[:span.flex-1 child]
(when shortcut
[:span.ml-1 (render-keyboard-shortcut shortcut {:interactive? false})])]))
(rum/defc dropdown-with-links
[content-fn links
{:keys [outer-header outer-footer links-header links-footer] :as opts}]
(dropdown
content-fn
(fn [{:keys [close-fn]}]
(let [links-children
(let [links (if (fn? links) (links) links)
links (remove nil? links)]
(for [{:keys [options title icon key hr hover-detail item _as-link?]} links]
(let [new-options
(merge options
(cond->
{:title hover-detail
:on-click (fn [e]
(when-not (false? (when-let [on-click-fn (:on-click options)]
(on-click-fn e)))
(close-fn)))}
key
(assoc :key key)))
child (if hr
nil
(or item
[:div.flex.items-center
(when icon icon)
[:div.title-wrap {:style {:margin-right "8px"
:margin-left "4px"}} title]]))]
(if hr
[:hr.menu-separator {:key (or key "dropdown-hr")}]
(rum/with-key
(menu-link new-options child)
title)))))
wrapper-children
[:.menu-links-wrapper
(when links-header links-header)
links-children
(when links-footer links-footer)]]
(if (or outer-header outer-footer)
[:.menu-links-outer
outer-header wrapper-children outer-footer]
wrapper-children)))
opts))
(declare button)
(rum/defc notification-content
[state content status uid]
(when (and content status)
(let [svg
(if (keyword? status)
(case status
:success
(icon "circle-check" {:class "text-success" :size "20"})
:warning
(icon "alert-circle" {:class "text-warning" :size "20"})
:error
(icon "circle-x" {:class "text-error" :size "20"})
(icon "info-circle" {:class "text-indigo-500" :size "20"}))
status)]
[:div.ui__notifications-content
{:style
(when (or (= state "exiting")
(= state "exited"))
{:z-index -1})}
[:div.max-w-sm.w-full.shadow-lg.rounded-lg.pointer-events-auto.notification-area
{:class (case state
"entering" "transition ease-out duration-300 transform opacity-0 translate-y-2 sm:translate-x-0"
"entered" "transition ease-out duration-300 transform translate-y-0 opacity-100 sm:translate-x-0"
"exiting" "transition ease-in duration-100 opacity-100"
"exited" "transition ease-in duration-100 opacity-0")}
[:div.rounded-lg.shadow-xs {:style {:max-height "calc(100vh - 200px)"
:overflow-y "auto"
:overflow-x "hidden"}}
[:div.p-4
[:div.flex.items-start
[:div.flex-shrink-0.pt-2
svg]
[:div.ml-3.w-0.flex-1.pt-2
[:div.text-sm.leading-5.font-medium.whitespace-pre-line {:style {:margin 0}}
content]]
[:div.flex-shrink-0.flex {:style {:margin-top -9
:margin-right -18}}
(button
{:button-props {:aria-label "Close"}
:variant :ghost
:class "hover:bg-transparent hover:text-foreground"
:on-click (fn []
(notification/clear! uid))
:icon "x"})]]]]]])))
(declare button)
(rum/defc notification-clear-all
[]
[:div.ui__notifications-content
[:div.pointer-events-auto.notification-clear
(button (t :notification/clear-all)
:intent "logseq"
:on-click (fn []
(notification/clear-all!)))]])
(rum/defc notification < rum/reactive
[]
(let [contents (state/sub :notification/contents)]
(transition-group
{:class-name "notifications ui__notifications"}
(let [notifications (map (fn [el]
(let [k (first el)
v (second el)]
(css-transition
{:timeout 100
:key (name k)}
(fn [state]
(notification-content state (:content v) (:status v) k)))))
contents)
clear-all (when (> (count contents) 1)
(css-transition
{:timeout 100
:k "clear-all"}
(fn [_state]
(notification-clear-all))))
items (if clear-all (cons clear-all notifications) notifications)]
(doall items)))))
(rum/defc humanity-time-ago
[input opts]
(let [time-fn (fn []
(try
(util/time-ago input)
(catch :default e
(js/console.error e)
input)))
[time set-time] (rum/use-state (time-fn))]
(rum/use-effect!
(fn []
(let [timer (js/setInterval
#(set-time (time-fn)) (* 1000 30))]
#(js/clearInterval timer)))
[])
[:span.ui__humanity-time (merge {} opts) time]))
(defn checkbox
[option]
(let [on-change' (:on-change option)
on-click' (:on-click option)
option (cond-> (dissoc option :on-change :on-click)
(or on-change' on-click')
(assoc :on-click
(fn [^js e]
(some-> on-click' (apply [e]))
(let [checked? (= (.-state (.-dataset (.-target e))) "checked")]
(set! (. (.-target e) -checked) (not checked?))
(some-> on-change' (apply [e]))))))]
(shui/checkbox
(merge option
{:disabled (or (:disabled option) config/publishing?)}))))
(defn main-node
[]
(gdom/getElement "main-content-container"))
(defn focus-element
[element]
(when-let [element ^js (gdom/getElement element)]
(.focus element)))
(defn get-dynamic-style-node
[]
(js/document.getElementById "dynamic-style-scope"))
(defn inject-document-devices-envs!
[]
(let [^js cl (.-classList js/document.documentElement)]
(when config/publishing? (.add cl "is-publish-mode"))
(when util/mac? (.add cl "is-mac"))
(when util/win32? (.add cl "is-win32"))
(when util/linux? (.add cl "is-linux"))
(when (util/electron?) (.add cl "is-electron"))
(when (util/ios?) (.add cl "is-ios"))
(when (util/mobile?) (.add cl "is-mobile"))
(when (util/safari?) (.add cl "is-safari"))
(when (mobile-util/native-ios?) (.add cl "is-native-ios"))
(when (mobile-util/native-android?) (.add cl "is-native-android"))
(when (mobile-util/native-iphone?) (.add cl "is-native-iphone"))
(when (mobile-util/native-iphone-without-notch?) (.add cl "is-native-iphone-without-notch"))
(when (mobile-util/native-ipad?) (.add cl "is-native-ipad"))
(when (util/electron?)
(doseq [[event function]
[["persist-zoom-level" #(storage/set :zoom-level %)]
["restore-zoom-level" #(when-let [zoom-level (storage/get :zoom-level)] (js/window.apis.setZoomLevel zoom-level))]
["full-screen" #(do (js-invoke cl (if (= % "enter") "add" "remove") "is-fullscreen")
(state/set-state! :electron/window-fullscreen? (= % "enter")))]
["maximize" #(state/set-state! :electron/window-maximized? %)]]]
(.on js/window.apis event function))
(p/then (ipc/ipc :getAppBaseInfo) #(let [{:keys [isFullScreen isMaximized]} (js->clj % :keywordize-keys true)]
(when isFullScreen
(.add cl "is-fullscreen")
(state/set-state! :electron/window-fullscreen? true))
(when isMaximized (state/set-state! :electron/window-maximized? true)))))))
(defn inject-dynamic-style-node!
[]
(let [style (get-dynamic-style-node)]
(if (nil? style)
(let [node (js/document.createElement "style")]
(set! (.-id node) "dynamic-style-scope")
(.appendChild js/document.head node))
style)))
(defn apply-custom-theme-effect! [theme]
(when config/lsp-enabled?
(when-let [custom-theme (state/sub [:ui/custom-theme (keyword theme)])]
;; If the name is nil, the user has not set a custom theme (initially {:mode light/dark}).
;; The url is not used because the default theme does not have an url.
(if (some? (:name custom-theme))
(js/LSPluginCore.selectTheme (bean/->js custom-theme)
(bean/->js {:emit false}))
(state/set-state! :plugin/selected-theme (:url custom-theme))))))
(defn setup-system-theme-effect!
[]
(let [^js schemaMedia (js/window.matchMedia "(prefers-color-scheme: dark)")]
(try (.addEventListener schemaMedia "change" state/sync-system-theme!)
(catch :default _error
(.addListener schemaMedia state/sync-system-theme!)))
(state/sync-system-theme!)
#(try (.removeEventListener schemaMedia "change" state/sync-system-theme!)
(catch :default _error
(.removeListener schemaMedia state/sync-system-theme!)))))
(defn set-global-active-keystroke [val]
(.setAttribute js/document.body "data-active-keystroke" val))
(defn setup-active-keystroke! []
(let [active-keystroke (atom #{})
heads #{:shift :alt :meta :control}
handle-global-keystroke (fn [down? e]
(let [handler (if down? conj disj)
keystroke e.key]
(swap! active-keystroke handler keystroke))
(when (contains? heads (keyword (util/safe-lower-case e.key)))
(set-global-active-keystroke (string/join "+" @active-keystroke))))
keydown-handler (partial handle-global-keystroke true)
keyup-handler (partial handle-global-keystroke false)
clear-all #(do (set-global-active-keystroke "")
(reset! active-keystroke #{}))]
(.addEventListener js/window "keydown" keydown-handler)
(.addEventListener js/window "keyup" keyup-handler)
(.addEventListener js/window "blur" clear-all)
(.addEventListener js/window "visibilitychange" clear-all)
(fn []
(.removeEventListener js/window "keydown" keydown-handler)
(.removeEventListener js/window "keyup" keyup-handler)
(.removeEventListener js/window "blur" clear-all)
(.removeEventListener js/window "visibilitychange" clear-all))))
(defn setup-viewport-listeners! []
(when-let [^js vw (gobj/get js/window "visualViewport")]
(let [handler #(state/set-state! :ui/viewport {:width (.-width vw) :height (.-height vw) :scale (.-scale vw)})]
(.addEventListener js/window.visualViewport "resize" handler)
(handler)
#(.removeEventListener js/window.visualViewport "resize" handler))))
(rum/defcs auto-complete <
(rum/local 0 ::current-idx)
(shortcut/mixin* :shortcut.handler/auto-complete)
[state
matched
{:keys [on-chosen
on-shift-chosen
get-group-name
empty-placeholder
item-render
class
header]}]
(let [*current-idx (get state ::current-idx)]
[:div#ui__ac {:class class}
(if (seq matched)
[:div#ui__ac-inner.hide-scrollbar
(when header header)
(for [[idx item] (medley/indexed matched)]
[:<>
{:key idx}
(let [item-cp
[:div.menu-link-wrap
{:key idx
;; mouse-move event to indicate that cursor moved by user
:on-mouse-move #(reset! *current-idx idx)}
(let [chosen? (= @*current-idx idx)]
(menu-link
{:id (str "ac-" idx)
:tab-index "0"
:class (when chosen? "chosen")
;; TODO: should have more tests on touch devices
;:on-pointer-down #(util/stop %)
:on-click (fn [e]
(util/stop e)
(if (and (gobj/get e "shiftKey") on-shift-chosen)
(on-shift-chosen item)
(on-chosen item e)))}
(if item-render (item-render item chosen?) item)))]]
(if get-group-name
(if-let [group-name (get-group-name item)]
[:div
[:div.ui__ac-group-name group-name]
item-cp]
item-cp)
item-cp))])]
(when empty-placeholder
empty-placeholder))]))
(defn toggle
([on? on-click] (toggle on? on-click false))
([on? on-click small?]
[:a.ui__toggle {:on-click on-click
:class (if small? "is-small" "")
:tab-index "0"
:on-key-down (fn [e] (when (and e (= (.-key e) "Enter"))
(util/stop e)
(on-click e)))}
[:span.wrapper.transition-colors.ease-in-out.duration-200
{:aria-checked (if on? "true" "false"), :tab-index "0", :role "checkbox"
:class (if on? "ui__toggle-background-on" "ui__toggle-background-off")}
[:span.switcher.transform.transition.ease-in-out.duration-200
{:class (if on? (if small? "translate-x-4" "translate-x-5") "translate-x-0")
:aria-hidden "true"}]]]))
(defn keyboard-shortcut-from-config [shortcut-name & {:keys [pick-first?]}]
(let [built-in-binding (:binding (get shortcut-config/all-built-in-keyboard-shortcuts shortcut-name))
custom-binding (when (state/shortcuts) (get (state/shortcuts) shortcut-name))
binding (or custom-binding built-in-binding)]
(if (and pick-first? (coll? binding))
(first binding)
(shortcut-utils/decorate-binding binding))))
(rum/defc modal-overlay
[state close-fn close-backdrop?]
[:div.ui__modal-overlay
{:class (case state
"entering" "ease-out duration-300 opacity-0"
"entered" "ease-out duration-300 opacity-100"
"exiting" "ease-in duration-200 opacity-100"
"exited" "ease-in duration-200 opacity-0")
:on-click #(when close-backdrop? (close-fn))}
[:div.absolute.inset-0.opacity-75]])
(rum/defc modal-panel-content <
mixins/component-editing-mode
[panel-content close-fn]
(panel-content close-fn))
(rum/defc modal-panel
[show? panel-content transition-state close-fn fullscreen? close-btn? style]
[:div.ui__modal-panel.transform.transition-all.sm:min-w-lg.sm
(cond->
{:class (case transition-state
"entering" "ease-out duration-300 opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
"entered" "ease-out duration-300 opacity-100 translate-y-0 sm:scale-100"
"exiting" "ease-in duration-200 opacity-100 translate-y-0 sm:scale-100"
"exited" "ease-in duration-200 opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95")}
(seq style)
(assoc :style style))
[:div.ui__modal-close-wrap
(when-not (false? close-btn?)
[:a.ui__modal-close
{:aria-label "Close"
:type "button"
:on-click close-fn}
[:svg.h-6.w-6
{:stroke "currentColor", :view-box "0 0 24 24", :fill "none"}
[:path
{:d "M6 18L18 6M6 6l12 12"
:stroke-width "2"
:stroke-linejoin "round"
:stroke-linecap "round"}]]])]
(when show?
[:div (cond-> {:class (if fullscreen? "" "panel-content")}
(seq style)
(assoc :style style))
(modal-panel-content panel-content close-fn)])])
(rum/defc modal < rum/reactive
(mixins/event-mixin
(fn [state]
(mixins/hide-when-esc-or-outside
state
:on-hide (fn []
(some->
(.querySelector (rum/dom-node state) "button.ui__modal-close")
(.click)))
:outside? false)
(mixins/on-key-down
state
{;; enter
13 (fn [state e]
(.preventDefault e)
(some->
(.querySelector (rum/dom-node state) "button.ui__modal-enter")
(.click)))})))
[]
(let [modal-panel-content (state/sub :modal/panel-content)
fullscreen? (state/sub :modal/fullscreen?)
close-btn? (state/sub :modal/close-btn?)
close-backdrop? (state/sub :modal/close-backdrop?)
show? (state/sub :modal/show?)
label (state/sub :modal/label)
style (state/sub :modal/style)
class (state/sub :modal/class)
close-fn (fn []
(state/close-modal!)
(state/close-settings!))
modal-panel-content (or modal-panel-content (fn [_close] [:div]))]
[:div.ui__modal
{:style {:z-index (if show? 999 -1)
:display (if show? "flex" "none")}
:label label
:class class}
(css-transition
{:in show? :timeout 0}
(fn [state]
(modal-overlay state close-fn close-backdrop?)))
(css-transition
{:in show? :timeout 0}
(fn [state]
(modal-panel show? modal-panel-content state close-fn fullscreen? close-btn? style)))]))
(rum/defc sub-modal < rum/reactive
[]
(when-let [modals (seq (state/sub :modal/subsets))]
(for [[idx modal] (medley/indexed modals)]
(let [id (:modal/id modal)
modal-panel-content (:modal/panel-content modal)
close-btn? (:modal/close-btn? modal)
close-backdrop? (:modal/close-backdrop? modal)
show? (:modal/show? modal)
label (:modal/label modal)
style (:modal/style modal)
class (:modal/class modal)
close-fn (fn []
(state/close-sub-modal! id))
modal-panel-content (or modal-panel-content (fn [_close] [:div]))]
[:div.ui__modal.is-sub-modal
{:style {:z-index (if show? (+ 999 idx) -1)}
:label label
:class class}
(css-transition
{:in show? :timeout 0}
(fn [state]
(modal-overlay state close-fn close-backdrop?)))
(css-transition
{:in show? :timeout 0}
(fn [state]
(modal-panel show? modal-panel-content state close-fn false close-btn? style)))]))))
(defn loading
([] (loading (t :loading)))
([content] (loading content nil))
([content opts]
[:div.flex.flex-row.items-center.inline.icon-loading
[:span.icon.flex.items-center (svg/loader-fn opts)
(when-not (string/blank? content)
[:span.text.pl-2 content])]]))
(rum/defc rotating-arrow
[collapsed?]
[:span
{:class (if collapsed? "rotating-arrow collapsed" "rotating-arrow not-collapsed")}
(svg/caret-right)])
(rum/defcs foldable-title <
(rum/local false ::control?)
[state {:keys [on-pointer-down header title-trigger? collapsed?]}]
(let [control? (get state ::control?)]
[:div.content
[:div.flex-1.flex-row.foldable-title (cond->
{:on-mouse-over #(reset! control? true)
:on-mouse-out #(reset! control? false)}
title-trigger?
(assoc :on-pointer-down on-pointer-down
:class "cursor"))
[:div.flex.flex-row.items-center
(when-not (mobile-util/native-platform?)
[:a.block-control.opacity-50.hover:opacity-100.mr-2
(cond->
{:style {:width 14
:height 16
:margin-left -30}}
(not title-trigger?)
(assoc :on-pointer-down on-pointer-down))
[:span {:class (if (or @control? @collapsed?) "control-show cursor-pointer" "control-hide")}
(rotating-arrow @collapsed?)]])
(if (fn? header)
(header @collapsed?)
header)]]]))
(rum/defcs foldable < db-mixins/query rum/reactive
(rum/local false ::collapsed?)
{:will-mount (fn [state]
(let [args (:rum/args state)]
(when (true? (:default-collapsed? (last args)))
(reset! (get state ::collapsed?) true)))
state)
:did-mount (fn [state]
(when-let [f (:init-collapsed (last (:rum/args state)))]
(f (::collapsed? state)))
state)}
[state header content {:keys [title-trigger? on-pointer-down class
_default-collapsed? _init-collapsed]}]
(let [collapsed? (get state ::collapsed?)
on-pointer-down (fn [e]
(util/stop e)
(swap! collapsed? not)
(when on-pointer-down
(on-pointer-down @collapsed?)))]
[:div.flex.flex-col
{:class class}
(foldable-title {:on-pointer-down on-pointer-down
:header header
:title-trigger? title-trigger?
:collapsed? collapsed?})
[:div {:class (if @collapsed? "hidden" "initial")
:on-pointer-down (fn [e] (.stopPropagation e))}
(if (fn? content)
(if (not @collapsed?) (content) nil)
content)]]))
(rum/defc admonition
[type content]
(let [type (name type)]
(when-let [icon (case (string/lower-case type)
"note" svg/note
"tip" svg/tip
"important" svg/important
"caution" svg/caution
"warning" svg/warning
"pinned" svg/pinned
nil)]
[:div.flex.flex-row.admonitionblock.align-items {:class type}
[:div.pr-4.admonition-icon.flex.flex-col.justify-center
{:title (string/capitalize type)} (icon)]
[:div.ml-4.text-lg
content]])))
(rum/defcs catch-error
< {:did-catch
(fn [state error _info]
(log/error :exception error)
(assoc state ::error error))}
[{error ::error, c :rum/react-component} error-view view]
(if (some? error)
(if (fn? error-view) (error-view error) error-view)
view))
(rum/defcs catch-error-and-notify
< {:did-catch
(fn [state error _info]
(log/error :exception error)
(notification/show!
(str "Error caught by UI!\n " error)
:error)
(assoc state ::error error))}
[{error ::error, c :rum/react-component} error-view view]
(if (some? error)
error-view
view))
(rum/defc block-error
"Well styled error message for blocks"
[title {:keys [content section-attrs]}]
[:section.border.mt-1.p-1.cursor-pointer.block-content-fallback-ui
section-attrs
[:div.flex.justify-between.items-center.px-1
[:h5.text-error.pb-1 title]
[:a.text-xs.opacity-50.hover:opacity-80
{:href "https://github.com/logseq/logseq/issues/new?labels=from:in-app&template=bug_report.yaml"
:target "_blank"} "report issue"]]
(when content [:pre.m-0.text-sm content])])
(def component-error
"Well styled error message for higher level components. Currently same as
block-error but this could change"
block-error)
(rum/defc select
([options on-change]
(select options on-change {}))
([options on-change select-options]
[:select.pl-6.block.text-base.leading-6.border-gray-300.focus:outline-none.focus:shadow-outline-blue.focus:border-blue-300.sm:text-sm.sm:leading-5
(merge
{:class "form-select"
:on-change (fn [e]
(let [value (util/evalue e)]
(on-change e value)))}
select-options)
(for [{:keys [label value selected disabled]
:or {selected false disabled false}} options]
[:option (cond->
{:key label
:value (or value label)} ;; NOTE: value might be an empty string, `or` is safe here
disabled
(assoc :disabled disabled)
selected
(assoc :selected selected))
label])]))
(rum/defc radio-list
[options on-change class]
[:div.ui__radio-list
{:class class}
(for [{:keys [label value selected]} options]
[:label
{:key (str "radio-list-" label)}
[:input.form-radio
{:value value
:type "radio"
:on-change #(on-change (util/evalue %))
:checked selected}]
label])])
(rum/defc checkbox-list
[options on-change class]
(let [checked-vals
(->> options (filter :selected) (map :value) (into #{}))
on-item-change
(fn [^js e]
(let [^js target (.-target e)
checked? (.-checked target)
value (.-value target)]
(on-change
(into []
(if checked?
(conj checked-vals value)
(disj checked-vals value))))))]
[:div.ui__checkbox-list
{:class class}
(for [{:keys [label value selected]} options]
[:label
{:key (str "check-list-" label)}
[:input.form-checkbox
{:value value
:type "checkbox"
:on-change on-item-change
:checked selected}]
label])]))
(rum/defcs tippy < rum/static
(rum/local false ::mounted?)
[state {:keys [fixed-position? open? html] :as opts} child]
(let [*mounted? (::mounted? state)
manual (not= open? nil)
open? (if manual open? @*mounted?)
disabled? (not (state/enable-tooltip?))]
(Tippy (->
(merge {:arrow true
:sticky true
:delay 600
:theme "customized"
:disabled disabled?
:unmountHTMLWhenHide true
:open (if disabled? false open?)
:trigger (if manual "manual" "mouseenter focus")
;; See https://github.com/tvkhoa/react-tippy/issues/13
:popperOptions {:modifiers {:flip {:enabled (not fixed-position?)}
:hide {:enabled false}
:preventOverflow {:enabled false}}}
:onShow #(when-not (or (state/editing?)
@(:ui/scrolling? @state/state))
(reset! *mounted? true))
:onHide #(reset! *mounted? false)}
opts)
(assoc :html (or
(when open?
(try
(when html
(if (fn? html)
(html)
[:div.px-2.py-1
html]))
(catch :default e
(log/error :exception e)
[:div])))
[:div {:key "tippy"} ""])))
(rum/fragment {:key "tippy-children"} child))))
(rum/defcs slider < rum/reactive
{:init (fn [state]
(assoc state ::value (atom (first (:rum/args state)))))}
[state _default-value {:keys [min max on-change]}]
(let [*value (::value state)
value (rum/react *value)
value' (int value)]
(assert (int? value'))
[:input.cursor-pointer
{:type "range"
:value value'
:min min
:max max
:style {:width "100%"}
:on-change #(let [value (util/evalue %)]
(reset! *value value))
:on-pointer-up #(let [value (util/evalue %)]
(on-change value))}]))
(rum/defcs tweet-embed < (rum/local true :loading?)
[state id]
(let [*loading? (:loading? state)]
[:div [(when @*loading? [:span.flex.items-center [svg/loading " ... loading"]])
(ReactTweetEmbed
{:id id
:class "contents"
:options {:theme (when (= (state/sub :ui/theme) "dark") "dark")}
:on-tweet-load-success #(reset! *loading? false)})]]))
(def icon shui.icon.v2/root)
(rum/defc button-inner
[text & {:keys [theme background variant href size class intent small? icon icon-props disabled? button-props]
:or {small? false}
:as opts}]
(let [button-props (merge
(dissoc opts
:theme :background :href :variant :class :intent :small? :icon :icon-props :disabled? :button-props)
button-props)
props (merge {:variant (cond
(= theme :gray) :ghost
(= background "gray") :secondary
(= background "red") :destructive
(= intent "link") :ghost
:else (or variant :default))
:href href
:size (if small? :xs (or size :sm))
:icon icon
:class (if (and (string? background)
(not (contains? #{"gray" "red"} background)))
(str class " primary-" background) class)
:muted disabled?}
button-props)
icon (when icon (shui/tabler-icon icon icon-props))
href? (not (string/blank? href))
text (cond
href? [:a {:href href :target "_blank"
:style {:color "inherit"}} text]
:else text)
children [icon text]]
(shui/button props children)))
(defn button
[text & {:keys []
:as opts}]
(if (map? text)
(button-inner nil text)
(button-inner text opts)))
(rum/defc point
([] (point "bg-red-600" 5 nil))
([klass size {:keys [class style] :as opts}]
[:span.ui__point.overflow-hidden.rounded-full.inline-block
(merge {:class (str (util/hiccup->class klass) " " class)
:style (merge {:width size :height size} style)}
(dissoc opts :style :class))]))
(rum/defc type-icon
[{:keys [name class title extension?]}]
[:.type-icon {:class class
:title title}
(icon name {:extension? extension?})])
(rum/defc with-shortcut < rum/reactive
< {:key-fn (fn [key pos] (str "shortcut-" key pos))}
[shortcut-key position content]
(let [tooltip? (state/sub :ui/shortcut-tooltip?)]
(if tooltip?
(tippy
{:html [:div.text-sm.font-medium (keyboard-shortcut-from-config shortcut-key)]
:interactive true
:position position
:theme "monospace"
:delay [1000, 100]
:arrow true}
content)
content)))
(rum/defc progress-bar
[width]
{:pre (integer? width)}
[:div.w-full.rounded-full.h-2.5.animate-pulse.bg-gray-06-alpha
[:div.bg-gray-09-alpha.h-2.5.rounded-full {:style {:width (str width "%")}
:transition "width 1s"}]])
(rum/defc progress-bar-with-label
[width label-left label-right]
{:pre (integer? width)}
[:div
[:div.flex.justify-between.mb-1
[:span.text-base
label-left]
[:span.text-sm.font-medium
label-right]]
(progress-bar width)])
(rum/defc lazy-loading-placeholder
[height]
[:div {:style {:height height}}])
(rum/defc lazy-visible-inner
[visible? content-fn ref fade-in?]
(let [[set-ref rect] (r/use-bounding-client-rect)
placeholder-height (or (when rect (.-height rect)) 24)]
[:div.lazy-visibility {:ref ref}
[:div {:ref set-ref}
(if visible?
(when (fn? content-fn)
(if fade-in?
[:div.fade-enter
{:ref #(when-let [^js cls (and % (.-classList %))]
(.add cls "fade-enter-active"))}
(content-fn)]
(content-fn)))
(lazy-loading-placeholder placeholder-height))]]))
(rum/defc lazy-visible
([content-fn]
(lazy-visible content-fn nil))
([content-fn {:keys [initial-state trigger-once? fade-in? root-margin _debug-id]
:or {initial-state false
trigger-once? true
fade-in? true
root-margin 100}}]
(let [[visible? set-visible!] (rum/use-state initial-state)
inViewState (useInView #js {:initialInView initial-state
:rootMargin (str root-margin "px")
:triggerOnce trigger-once?
:onChange (fn [in-view? _entry]
(when-not (= in-view? visible?)
(set-visible! in-view?)))})
ref (.-ref inViewState)]
(lazy-visible-inner visible? content-fn ref fade-in?))))
(rum/defc menu-heading
([add-heading-fn auto-heading-fn rm-heading-fn]
(menu-heading nil add-heading-fn auto-heading-fn rm-heading-fn))
([heading add-heading-fn auto-heading-fn rm-heading-fn]
[:div.flex.flex-row.justify-between.pb-2.pt-1.px-2.items-center
[:div.flex.flex-row.justify-between.flex-1.px-1
(for [i (range 1 7)]
(rum/with-key (button
""
:disabled? (and (some? heading) (= heading i))
:icon (str "h-" i)
:title (t :heading i)
:class "to-heading-button"
:on-click #(add-heading-fn i)
:intent "link"
:small? true)
(str "key-h-" i)))
(button
""
:icon "h-auto"
:disabled? (and (some? heading) (true? heading))
:icon-props {:extension? true}
:class "to-heading-button"
:title (t :auto-heading)
:on-click auto-heading-fn
:intent "link"
:small? true)
(button
""
:icon "heading-off"
:disabled? (and (some? heading) (not heading))
:icon-props {:extension? true}
:class "to-heading-button"
:title (t :remove-heading)
:on-click rm-heading-fn
:intent "link"
:small? true)]]))
(rum/defc tooltip
[trigger tooltip-content]
(shui/tooltip-provider
(shui/tooltip
(shui/tooltip-trigger
trigger)
(shui/tooltip-content
tooltip-content))))
(comment
(rum/defc emoji-picker
[opts]
(EmojiPicker. (assoc opts :data emoji-data))))