mirror of
https://github.com/logseq/logseq.git
synced 2026-05-02 09:56:31 +00:00
* wip: right sidebar
* fix: graph height
* re-enable scrolling on list
* style: indentation
* fix: always expand block when added to sidebar
* fix: show close button on mobile
* wip: right sidebar
* support default and custom icons
* fix whiteboard icon
* fix: header overflow
* remove unneeded dependency
* fix graph icon
* add middle click action and context menu
* add more context menu actions
* add reordering support
* fix dropdown visibility
* dnd fixes and initial state
* varous fixes
* fix event propagation
* enhance accesibility
* area expanded fix
* add translations
* refactoring
* enhance performance
* enhance text overflow
* fix page link
* drag enter fix
* add rounded corners and transition
* add keyboard shortcuts icon
* revert left sidebar change
* left sidebar bg and border
* various left sidebar enhancements
* alignment fixes
* split favorites and recents scrolling
* handle unavailable icon
* handle overflow edge case
* fix and refactor menu shortcuts
* handle zero items
* display nan shortcuts on mouse over
* min height fix
* min height fix
* simplify chevron icon
* minor fixes
* add flashcards shortcut
* revert initial sidebar state changes
* cloud icon fix
* fix border radius
* add missing go-home shortcut
* add clear recent button
* fix typo
* hide scrollbars
* remove transition
* fix: favorites
* fix: border and rounded corners
* fix: dnd delay
* Revert "add clear recent button"
This reverts commit d8c52c35f0.
* fix: keyboard shortcuts
* fix: shortcuts
* remove unused require
1199 lines
47 KiB
Clojure
1199 lines
47 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]]
|
|
[camel-snake-kebab.core :as csk]
|
|
[cljs-bean.core :as bean]
|
|
[clojure.string :as string]
|
|
[datascript.core :as d]
|
|
[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.data-helper :as shortcut-helper]
|
|
[frontend.rum :as r]
|
|
[frontend.state :as state]
|
|
[frontend.storage :as storage]
|
|
[frontend.ui.date-picker]
|
|
[frontend.util :as util]
|
|
[frontend.util.cursor :as cursor]
|
|
[goog.dom :as gdom]
|
|
[goog.functions :refer [debounce]]
|
|
[goog.object :as gobj]
|
|
[lambdaisland.glogi :as log]
|
|
[medley.core :as medley]
|
|
[promesa.core :as p]
|
|
[rum.core :as rum]))
|
|
|
|
(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")))
|
|
(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"))
|
|
|
|
(defn reset-ios-whole-page-offset!
|
|
[]
|
|
(and (util/ios?)
|
|
(util/safari?)
|
|
(js/window.scrollTo 0 0)))
|
|
|
|
(defonce icon-size (if (mobile-util/native-platform?) 26 20))
|
|
|
|
(def built-in-colors
|
|
["yellow"
|
|
"red"
|
|
"pink"
|
|
"green"
|
|
"blue"
|
|
"purple"
|
|
"gray"])
|
|
|
|
(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)]
|
|
;; Passing aria-label as a prop to TextareaAutosize removes the dash
|
|
(.setAttribute el "aria-label" "editing block")
|
|
(. el addEventListener "mouseup"
|
|
#(let [start (util/get-selection-start el)
|
|
end (util/get-selection-end el)]
|
|
(when (and start end)
|
|
(when-let [e (and (not= start end)
|
|
{:caret (cursor/get-caret-pos el)
|
|
:start start :end end
|
|
:text (. (.-value el) substring start end)
|
|
:point {:x (.-x %) :y (.-y %)}})]
|
|
|
|
(plugin-handler/hook-plugin-editor :input-selection-end (bean/->js e)))))))
|
|
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
|
|
{: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?)
|
|
[state content-fn modal-content-fn
|
|
& [{:keys [modal-class z-index trigger-class]
|
|
:or {z-index 999}}]]
|
|
(let [{:keys [open?]} 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]
|
|
(let [sequence (if (string? sequence)
|
|
(-> sequence ;; turn string into sequence
|
|
(string/trim)
|
|
(string/lower-case)
|
|
(string/split #" "))
|
|
sequence)]
|
|
[:span.keyboard-shortcut
|
|
(map-indexed (fn [i key]
|
|
(let [key' (shortcut-helper/decorate-binding (str key))]
|
|
[:code {:key i}
|
|
;; Display "cmd" rather than "meta" to the user to describe the Mac
|
|
;; mod key, because that's what the Mac keyboards actually say.
|
|
(if (= "meta" key')
|
|
(util/meta-key-name)
|
|
key')]))
|
|
sequence)]))
|
|
|
|
(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.px-4.py-2.text-sm.transition.ease-in-out.duration-150.cursor.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)])]))
|
|
|
|
(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))
|
|
|
|
(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
|
|
svg]
|
|
[:div.ml-3.w-0.flex-1
|
|
[:div.text-sm.leading-5.font-medium.whitespace-pre-line {:style {:margin 0}}
|
|
content]]
|
|
[:div.ml-4.flex-shrink-0.flex
|
|
[:button.inline-flex.text-gray-400.focus:outline-none.focus:text-gray-500.transition.ease-in-out.duration-150.notification-close-button
|
|
{:aria-label "Close"
|
|
:on-click (fn []
|
|
(notification/clear! uid))}
|
|
|
|
(icon "x" {:fill "currentColor"})]]]]]]])))
|
|
|
|
(declare button)
|
|
|
|
(rum/defc notification-clear-all
|
|
[]
|
|
[:div.ui__notifications-content
|
|
[:div.pointer-events-auto
|
|
(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]
|
|
[:input.form-checkbox.h-4.w-4.transition.duration-150.ease-in-out
|
|
(merge {:type "checkbox"} option)])
|
|
|
|
(defn main-node
|
|
[]
|
|
(gdom/getElement "main-content-container"))
|
|
|
|
(defn focus-element
|
|
[element]
|
|
(when-let [element ^js (gdom/getElement element)]
|
|
(.focus element)))
|
|
|
|
(defn get-scroll-top []
|
|
(.-scrollTop (main-node)))
|
|
|
|
(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))))
|
|
|
|
(defonce last-scroll-top (atom 0))
|
|
|
|
(defn scroll-down?
|
|
[]
|
|
(let [scroll-top (get-scroll-top)
|
|
down? (>= scroll-top @last-scroll-top)]
|
|
(reset! last-scroll-top scroll-top)
|
|
down?))
|
|
|
|
(defn bottom-reached?
|
|
[node threshold]
|
|
(let [full-height (gobj/get node "scrollHeight")
|
|
scroll-top (gobj/get node "scrollTop")
|
|
client-height (gobj/get node "clientHeight")]
|
|
(<= (- full-height scroll-top client-height) threshold)))
|
|
|
|
(defn on-scroll
|
|
[node {:keys [on-load on-top-reached threshold bottom-reached]
|
|
:or {threshold 500}}]
|
|
(let [scroll-top (gobj/get node "scrollTop")
|
|
bottom-reached? (if (fn? bottom-reached)
|
|
(bottom-reached)
|
|
(bottom-reached? node threshold))
|
|
top-reached? (= scroll-top 0)
|
|
down? (scroll-down?)]
|
|
(when (and bottom-reached? on-load)
|
|
(on-load))
|
|
(when (and (not down?) top-reached? on-top-reached)
|
|
(on-top-reached))))
|
|
|
|
(defn attach-listeners
|
|
"Attach scroll and resize listeners."
|
|
[state]
|
|
(let [list-element-id (first (:rum/args state))
|
|
opts (-> state :rum/args (nth 2))
|
|
node (js/document.getElementById list-element-id)
|
|
debounced-on-scroll (debounce #(on-scroll node opts) 100)]
|
|
(mixins/listen state node :scroll debounced-on-scroll)))
|
|
|
|
(rum/defcs infinite-list <
|
|
(mixins/event-mixin attach-listeners)
|
|
"Render an infinite list."
|
|
[state _list-element-id body {:keys [on-load has-more more more-class]
|
|
:or {more-class "text-sm"}}]
|
|
[:div
|
|
body
|
|
(when has-more
|
|
[:div.w-full.p-4
|
|
[:a.fade-link.text-link.font-bold
|
|
{:on-click on-load
|
|
:class more-class}
|
|
(or more (t :page/earlier))]])])
|
|
|
|
(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)
|
|
:class (when chosen? "chosen")
|
|
:on-mouse-down (fn [e]
|
|
(util/stop e)
|
|
(if (and (gobj/get e "shiftKey") on-shift-chosen)
|
|
(on-shift-chosen item)
|
|
(on-chosen item)))}
|
|
(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))]))
|
|
|
|
(def datepicker frontend.ui.date-picker/date-picker)
|
|
|
|
(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" "")}
|
|
[:span.wrapper.transition-colors.ease-in-out.duration-200
|
|
{:aria-checked (if on? "true" "false"), :tab-index "0", :role "checkbox"
|
|
:class (if on? "bg-indigo-600" "bg-gray-300")}
|
|
[: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]
|
|
(let [default-binding (:binding (get shortcut-config/all-default-keyboard-shortcuts shortcut-name))
|
|
custom-binding (when (state/shortcuts) (get (state/shortcuts) shortcut-name))]
|
|
(or custom-binding default-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?]
|
|
[:div.ui__modal-panel.transform.transition-all.sm:min-w-lg.sm
|
|
{: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")}
|
|
[: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 {:class (if fullscreen? "" "panel-content")}
|
|
(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]
|
|
(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)
|
|
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)}
|
|
:label label}
|
|
(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?)))]))
|
|
|
|
(defn make-confirm-modal
|
|
[{:keys [tag title sub-title sub-checkbox? on-cancel on-confirm]
|
|
:or {on-cancel #()}}]
|
|
(fn [close-fn]
|
|
(let [*sub-checkbox-selected (and sub-checkbox? (atom []))]
|
|
[:div.ui__confirm-modal
|
|
{:class (str "is-" tag)}
|
|
[:div.sm:flex.sm:items-start
|
|
[:div.mx-auto.flex-shrink-0.flex.items-center.justify-center.h-12.w-12.rounded-full.bg-error.sm:mx-0.sm:h-10.sm:w-10
|
|
[:svg.h-6.w-6.text-error
|
|
{:stroke "currentColor", :view-box "0 0 24 24", :fill "none"}
|
|
[:path
|
|
{:d
|
|
"M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
|
|
:stroke-width "2"
|
|
:stroke-linejoin "round"
|
|
:stroke-linecap "round"}]]]
|
|
[:div.mt-3.text-center.sm:mt-0.sm:ml-4.sm:text-left
|
|
[:h2.headline.text-lg.leading-6.font-medium
|
|
(if (keyword? title) (t title) title)]
|
|
[:label.sublabel
|
|
(when sub-checkbox?
|
|
(checkbox
|
|
{:default-value false
|
|
:on-change (fn [e]
|
|
(let [checked (.. e -target -checked)]
|
|
(reset! *sub-checkbox-selected [checked])))}))
|
|
[:h3.subline.text-gray-400
|
|
(if (keyword? sub-title)
|
|
(t sub-title)
|
|
sub-title)]]]]
|
|
|
|
[:div.mt-5.sm:mt-4.sm:flex.sm:flex-row-reverse
|
|
[:span.flex.w-full.rounded-md.shadow-sm.sm:ml-3.sm:w-auto
|
|
[:button.inline-flex.justify-center.w-full.rounded-md.border.border-transparent.px-4.py-2.bg-indigo-600.text-base.leading-6.font-medium.text-white.shadow-sm.hover:bg-indigo-500.focus:outline-none.focus:border-indigo-700.focus:shadow-outline-indigo.transition.ease-in-out.duration-150.sm:text-sm.sm:leading-5
|
|
{:type "button"
|
|
:autoFocus "on"
|
|
:class "ui__modal-enter"
|
|
:on-click #(and (fn? on-confirm)
|
|
(on-confirm % {:close-fn close-fn
|
|
:sub-selected (and *sub-checkbox-selected @*sub-checkbox-selected)}))}
|
|
(t :yes)]]
|
|
[:span.mt-3.flex.w-full.rounded-md.shadow-sm.sm:mt-0.sm:w-auto
|
|
[:button.inline-flex.justify-center.w-full.rounded-md.border.border-gray-300.px-4.py-2.bg-white.text-base.leading-6.font-medium.text-gray-700.shadow-sm.hover:text-gray-500.focus:outline-none.focus:border-blue-300.focus:shadow-outline-blue.transition.ease-in-out.duration-150.sm:text-sm.sm:leading-5
|
|
{:type "button"
|
|
:on-click (comp on-cancel close-fn)}
|
|
(t :cancel)]]]])))
|
|
|
|
(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)
|
|
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}
|
|
(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?)))]))))
|
|
|
|
(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])]]))
|
|
|
|
(defn notify-graph-persist!
|
|
[]
|
|
(notification/show!
|
|
(loading (t :graph/persist))
|
|
:warning))
|
|
|
|
(defn notify-graph-persist-error!
|
|
[]
|
|
(notification/show!
|
|
(t :graph/persist-error)
|
|
:error))
|
|
|
|
(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-mouse-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-mouse-down on-mouse-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-mouse-down on-mouse-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-mouse-down
|
|
_default-collapsed? _init-collapsed]}]
|
|
(let [collapsed? (get state ::collapsed?)
|
|
on-mouse-down (fn [e]
|
|
(util/stop e)
|
|
(swap! collapsed? not)
|
|
(when on-mouse-down
|
|
(on-mouse-down @collapsed?)))]
|
|
[:div.flex.flex-col
|
|
(foldable-title {:on-mouse-down on-mouse-down
|
|
:header header
|
|
:title-trigger? title-trigger?
|
|
:collapsed? collapsed?})
|
|
[:div {:class (if @collapsed? "hidden" "initial")
|
|
:on-mouse-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)
|
|
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 nil))
|
|
([options on-change class]
|
|
[: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
|
|
{:class (or class "form-select")
|
|
:on-change (fn [e]
|
|
(let [value (util/evalue e)]
|
|
(on-change e value)))}
|
|
(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/reactive
|
|
(rum/local false ::mounted?)
|
|
[state {:keys [fixed-position? open? in-editor? html] :as opts} child]
|
|
(let [*mounted? (::mounted? state)
|
|
manual (not= open? nil)
|
|
edit-id (ffirst (state/sub :editor/editing?))
|
|
editing-node (when edit-id (gdom/getElement edit-id))
|
|
editing? (some? editing-node)
|
|
scrolling? (state/sub :ui/scrolling?)
|
|
open? (if manual open? @*mounted?)
|
|
disabled? (boolean
|
|
(or
|
|
(and in-editor?
|
|
;; editing in non-preview containers or scrolling
|
|
(not (util/rec-get-tippy-container editing-node))
|
|
(or editing? scrolling?))
|
|
(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 #(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/defc slider
|
|
[default-value {:keys [min max on-change]}]
|
|
[:input.cursor-pointer
|
|
{:type "range"
|
|
:value (int default-value)
|
|
:min min
|
|
:max max
|
|
:style {:width "100%"}
|
|
:on-change #(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 get-adapt-icon-class
|
|
(memoize (fn [klass] (r/adapt-class klass))))
|
|
|
|
(defn tabler-icon
|
|
[name]
|
|
(gobj/get js/tablerIcons (str "Icon" (csk/->PascalCase name))))
|
|
|
|
(rum/defc icon
|
|
([name] (icon name nil))
|
|
([name {:keys [extension? font? class] :as opts}]
|
|
(when-not (string/blank? name)
|
|
(let [^js jsTablerIcons (gobj/get js/window "tablerIcons")]
|
|
(if (or extension? font? (not jsTablerIcons))
|
|
[:span.ui__icon (merge {:class
|
|
(util/format
|
|
(str "%s-" name
|
|
(when (:class opts)
|
|
(str " " (string/trim (:class opts)))))
|
|
(if extension? "tie tie" "ti ti"))}
|
|
(dissoc opts :class :extension? :font?))]
|
|
|
|
;; tabler svg react
|
|
(when-let [klass (tabler-icon name)]
|
|
(let [f (get-adapt-icon-class klass)]
|
|
[:span.ui__icon.ti
|
|
{:class (str "ls-icon-" name " " class)}
|
|
(f (merge {:size 18} (r/map-keys->camel-case (dissoc opts :class))))])))))))
|
|
|
|
(rum/defc button
|
|
[text & {:keys [background href class intent on-click small? large? title icon icon-props disabled?]
|
|
:or {small? false large? false}
|
|
:as option}]
|
|
(let [klass (if-not intent ".bg-indigo-600.hover:bg-indigo-700.focus:border-indigo-700.active:bg-indigo-700.text-center" intent)
|
|
klass (if background (string/replace klass "indigo" background) klass)
|
|
klass (if small? (str klass ".px-2.py-1") klass)
|
|
klass (if large? (str klass ".text-base") klass)
|
|
klass (if disabled? (str klass "disabled:opacity-75") klass)]
|
|
[:button.ui__button
|
|
(merge
|
|
{:type "button"
|
|
:title title
|
|
:disabled disabled?
|
|
:class (str (util/hiccup->class klass) " " class)}
|
|
(dissoc option :background :class :small? :large? :disabled?)
|
|
(when href
|
|
{:on-click (fn []
|
|
(util/open-url href)
|
|
(when (fn? on-click) (on-click)))}))
|
|
(when icon (frontend.ui/icon icon (merge icon-props {:class (when-not (empty? text) "mr-1")})))
|
|
text]))
|
|
|
|
(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.bg-indigo-200.rounded-full.h-2.5.animate-pulse
|
|
[:div.bg-indigo-600.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.shadow.rounded-md.p-4.w-full.mx-auto.mb-5.fade-in {:style {:height height}}
|
|
[:div.animate-pulse.flex.space-x-4
|
|
[:div.flex-1.space-y-3.py-1
|
|
[:div.h-2.bg-base-4.rounded]
|
|
[:div.space-y-3
|
|
[:div.grid.grid-cols-3.gap-4
|
|
[:div.h-2.bg-base-4.rounded.col-span-2]
|
|
[:div.h-2.bg-base-4.rounded.col-span-1]]
|
|
[:div.h-2.bg-base-4.rounded]]]]])
|
|
|
|
(rum/defc lazy-visible-inner
|
|
[visible? content-fn ref]
|
|
(let [[set-ref rect] (r/use-bounding-client-rect)
|
|
placeholder-height (or (when rect (.-height rect)) 88)]
|
|
[:div.lazy-visibility {:ref ref}
|
|
[:div {:ref set-ref}
|
|
(if visible?
|
|
(when (fn? content-fn)
|
|
[:div.fade-enter
|
|
{:ref #(when-let [^js cls (and % (.-classList %))]
|
|
(.add cls "fade-enter-active"))}
|
|
(content-fn)])
|
|
(lazy-loading-placeholder placeholder-height))]]))
|
|
|
|
(rum/defc lazy-visible
|
|
([content-fn]
|
|
(lazy-visible content-fn nil))
|
|
([content-fn {:keys [trigger-once? _debug-id]
|
|
:or {trigger-once? false}}]
|
|
(let [[visible? set-visible!] (rum/use-state false)
|
|
root-margin 100
|
|
inViewState (useInView #js {:rootMargin (str root-margin "px")
|
|
:triggerOnce trigger-once?
|
|
:onChange (fn [in-view? entry]
|
|
(let [self-top (.-top (.-boundingClientRect entry))]
|
|
(when (or (and (not visible?) in-view?)
|
|
;; hide only the components below the current top for better ux
|
|
(and visible? (not in-view?) (> self-top root-margin)))
|
|
(set-visible! in-view?))))})
|
|
ref (.-ref inViewState)]
|
|
(lazy-visible-inner visible? content-fn ref))))
|
|
|
|
(rum/defc portal
|
|
([children]
|
|
(portal children {:attach-to (fn [] js/document.body)
|
|
:prepend? false}))
|
|
([children {:keys [attach-to prepend?]}]
|
|
(let [[portal-anchor set-portal-anchor] (rum/use-state nil)]
|
|
(rum/use-effect!
|
|
(fn []
|
|
(let [div (js/document.createElement "div")
|
|
attached (or (if (fn? attach-to) (attach-to) attach-to) js/document.body)]
|
|
(.setAttribute div "data-logseq-portal" (str (d/squuid)))
|
|
(if prepend? (.prepend attached div) (.append attached div))
|
|
(set-portal-anchor div)
|
|
#(.remove div)))
|
|
[])
|
|
(when portal-anchor
|
|
(rum/portal (rum/fragment children) portal-anchor)))))
|
|
|
|
(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)]]))
|