mirror of
https://github.com/logseq/logseq.git
synced 2026-05-04 19:06:21 +00:00
1139 lines
44 KiB
Clojure
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))))
|