ui.cljs 32 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784
  1. (ns frontend.ui
  2. (:require [clojure.string :as string]
  3. [frontend.components.svg :as svg]
  4. [frontend.context.i18n :as i18n]
  5. [frontend.handler.notification :as notification-handler]
  6. [frontend.mixins :as mixins]
  7. [frontend.modules.shortcut.core :as shortcut]
  8. [frontend.rum :as r]
  9. [frontend.state :as state]
  10. [frontend.ui.date-picker]
  11. [frontend.util :as util]
  12. [frontend.util.cursor :as cursor]
  13. [frontend.handler.plugin :as plugin-handler]
  14. [cljs-bean.core :as bean]
  15. [goog.dom :as gdom]
  16. [frontend.modules.shortcut.config :as shortcut-config]
  17. [frontend.modules.shortcut.data-helper :as shortcut-helper]
  18. [promesa.core :as p]
  19. [goog.object :as gobj]
  20. [lambdaisland.glogi :as log]
  21. [medley.core :as medley]
  22. [electron.ipc :as ipc]
  23. ["react-resize-context" :as Resize]
  24. ["react-textarea-autosize" :as TextareaAutosize]
  25. ["react-tippy" :as react-tippy]
  26. ["react-transition-group" :refer [CSSTransition TransitionGroup]]
  27. ["react-tweet-embed" :as react-tweet-embed]
  28. [rum.core :as rum]
  29. [clojure.string :as str]
  30. [frontend.db-mixins :as db-mixins]
  31. [frontend.mobile.util :as mobile-util]))
  32. (defonce transition-group (r/adapt-class TransitionGroup))
  33. (defonce css-transition (r/adapt-class CSSTransition))
  34. (defonce textarea (r/adapt-class (gobj/get TextareaAutosize "default")))
  35. (def resize-provider (r/adapt-class (gobj/get Resize "ResizeProvider")))
  36. (def resize-consumer (r/adapt-class (gobj/get Resize "ResizeConsumer")))
  37. (def Tippy (r/adapt-class (gobj/get react-tippy "Tooltip")))
  38. (def ReactTweetEmbed (r/adapt-class react-tweet-embed))
  39. (defn main-content-position
  40. []
  41. (if (mobile-util/native-ios?)
  42. (- (mobile-util/get-idevice-statusbar-height) 10)
  43. 0))
  44. (rum/defc ls-textarea
  45. < rum/reactive
  46. {:did-mount (fn [state]
  47. (let [^js el (rum/dom-node state)]
  48. (. el addEventListener "mouseup"
  49. #(let [start (.-selectionStart el)
  50. end (.-selectionEnd el)]
  51. (when-let [e (and (not= start end)
  52. {:caret (cursor/get-caret-pos el)
  53. :start start :end end
  54. :text (. (.-value el) substring start end)
  55. :point {:x (.-x %) :y (.-y %)}})]
  56. (plugin-handler/hook-plugin-editor :input-selection-end (bean/->js e))))))
  57. state)}
  58. [{:keys [on-change] :as props}]
  59. (let [skip-composition? (or
  60. (state/sub :editor/show-page-search?)
  61. (state/sub :editor/show-block-search?)
  62. (state/sub :editor/show-template-search?))
  63. on-composition (fn [e]
  64. (if skip-composition?
  65. (on-change e)
  66. (case e.type
  67. "compositionend" (do
  68. (state/set-editor-in-composition! false)
  69. (on-change e))
  70. (state/set-editor-in-composition! true))))
  71. props (assoc props
  72. :on-change (fn [e] (when-not (state/editor-in-composition?)
  73. (on-change e)))
  74. :on-composition-start on-composition
  75. :on-composition-update on-composition
  76. :on-composition-end on-composition)]
  77. (textarea props)))
  78. (rum/defc dropdown-content-wrapper [state content class]
  79. (let [class (or class
  80. (util/hiccup->class "origin-top-right.absolute.right-0.mt-2.rounded-md.shadow-lg"))]
  81. [:div.dropdown-wrapper
  82. {:class (str class " "
  83. (case state
  84. "entering" "transition ease-out duration-100 transform opacity-0 scale-95"
  85. "entered" "transition ease-out duration-100 transform opacity-100 scale-100"
  86. "exiting" "transition ease-in duration-75 transform opacity-100 scale-100"
  87. "exited" "transition ease-in duration-75 transform opacity-0 scale-95"))}
  88. content]))
  89. ;; public exports
  90. (rum/defcs dropdown < (mixins/modal :open?)
  91. [state content-fn modal-content-fn
  92. & [{:keys [modal-class z-index]
  93. :or {z-index 999}
  94. :as opts}]]
  95. (let [{:keys [open? toggle-fn]} state
  96. modal-content (modal-content-fn state)]
  97. [:div.relative {:style {:z-index z-index}}
  98. (content-fn state)
  99. (css-transition
  100. {:in @open? :timeout 0}
  101. (fn [dropdown-state]
  102. (when @open?
  103. (dropdown-content-wrapper dropdown-state modal-content modal-class))))]))
  104. (rum/defc menu-link
  105. [options child]
  106. [:a.block.px-4.py-2.text-sm.transition.ease-in-out.duration-150.cursor.menu-link
  107. options
  108. child])
  109. (rum/defc dropdown-with-links
  110. [content-fn links {:keys [modal-class links-header links-footer z-index] :as opts}]
  111. (dropdown
  112. content-fn
  113. (fn [{:keys [close-fn] :as state}]
  114. [:div.py-1.rounded-md.shadow-xs
  115. (when links-header links-header)
  116. (for [{:keys [options title icon hr]} (if (fn? links) (links) links)]
  117. (let [new-options
  118. (assoc options
  119. :on-click (fn [e]
  120. (when-let [on-click-fn (:on-click options)]
  121. (on-click-fn e))
  122. (close-fn)))
  123. child (if hr
  124. nil
  125. [:div
  126. {:style {:display "flex" :flex-direction "row"}}
  127. [:div {:style {:margin-right "8px"}} title]])]
  128. (if hr
  129. [:hr.my-1]
  130. (rum/with-key
  131. (menu-link new-options child)
  132. title))))
  133. (when links-footer links-footer)])
  134. opts))
  135. (defn button
  136. [text & {:keys [background href class intent on-click small? large?]
  137. :or {small? false large? false}
  138. :as option}]
  139. (let [klass (when-not intent ".bg-indigo-600.hover:bg-indigo-700.focus:border-indigo-700.active:bg-indigo-700.text-center")
  140. klass (if background (string/replace klass "indigo" background) klass)
  141. klass (if small? (str klass ".px-2.py-1") klass)
  142. klass (if large? (str klass ".text-base") klass)]
  143. (if href
  144. [:a.ui__button.is-link
  145. (merge
  146. {:type "button"
  147. :class (str (util/hiccup->class klass) " " class)}
  148. (dissoc option :background :class :small?))
  149. text]
  150. [:button.ui__button
  151. (merge
  152. {:type "button"
  153. :class (str (util/hiccup->class klass) " " class)}
  154. (dissoc option :background :class :small?))
  155. text])))
  156. (rum/defc notification-content
  157. [state content status uid]
  158. (when (and content status)
  159. (let [[color-class svg]
  160. (case status
  161. :success
  162. ["text-gray-900 dark:text-gray-300 "
  163. [:svg.h-6.w-6.text-green-400
  164. {:stroke "currentColor", :viewBox "0 0 24 24", :fill "none"}
  165. [:path
  166. {:d "M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
  167. :stroke-width "2"
  168. :stroke-linejoin "round"
  169. :stroke-linecap "round"}]]]
  170. :warning
  171. ["text-gray-900 dark:text-gray-300 "
  172. [:svg.h-6.w-6.text-yellow-500
  173. {:stroke "currentColor", :viewBox "0 0 24 24", :fill "none"}
  174. [:path
  175. {:d "M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
  176. :stroke-width "2"
  177. :stroke-linejoin "round"
  178. :stroke-linecap "round"}]]]
  179. ["text-red-500"
  180. [:svg.h-6.w-6.text-red-500
  181. {:view-box "0 0 20 20", :fill "currentColor"}
  182. [:path
  183. {:clip-rule "evenodd"
  184. :d
  185. "M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z"
  186. :fill-rule "evenodd"}]]])]
  187. [:div.ui__notifications-content
  188. {:style {:z-index (if (or (= state "exiting")
  189. (= state "exited"))
  190. -1
  191. 99)}}
  192. [:div.max-w-sm.w-full.shadow-lg.rounded-lg.pointer-events-auto.notification-area
  193. {:class (case state
  194. "entering" "transition ease-out duration-300 transform opacity-0 translate-y-2 sm:translate-x-0"
  195. "entered" "transition ease-out duration-300 transform translate-y-0 opacity-100 sm:translate-x-0"
  196. "exiting" "transition ease-in duration-100 opacity-100"
  197. "exited" "transition ease-in duration-100 opacity-0")}
  198. [:div.rounded-lg.shadow-xs {:style {:max-height "calc(100vh - 200px)"
  199. :overflow-y "scroll"
  200. :overflow-x "hidden"}}
  201. [:div.p-4
  202. [:div.flex.items-start
  203. [:div.flex-shrink-0
  204. svg]
  205. [:div.ml-3.w-0.flex-1
  206. [:div.text-sm.leading-5.font-medium {:style {:margin 0}
  207. :class color-class}
  208. content]]
  209. [:div.ml-4.flex-shrink-0.flex
  210. [:button.inline-flex.text-gray-400.focus:outline-none.focus:text-gray-500.transition.ease-in-out.duration-150
  211. {:on-click (fn []
  212. (notification-handler/clear! uid))}
  213. [:svg.h-5.w-5
  214. {:fill "currentColor", :view-Box "0 0 20 20"}
  215. [:path
  216. {:clip-rule "evenodd"
  217. :d
  218. "M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
  219. :fill-rule "evenodd"}]]]]]]]]])))
  220. (rum/defc notification < rum/reactive
  221. []
  222. (let [contents (state/sub :notification/contents)]
  223. (transition-group
  224. {:class-name "notifications ui__notifications"}
  225. (doall (map (fn [el]
  226. (let [k (first el)
  227. v (second el)]
  228. (css-transition
  229. {:timeout 100
  230. :key (name k)}
  231. (fn [state]
  232. (notification-content state (:content v) (:status v) k)))))
  233. contents)))))
  234. (defn checkbox
  235. [option]
  236. [:input.form-checkbox.h-4.w-4.transition.duration-150.ease-in-out
  237. (merge {:type "checkbox"} option)])
  238. (defn badge
  239. [text option]
  240. [:span.inline-flex.items-center.px-2.5.py-0.5.rounded-full.text-xs.font-medium.leading-4.bg-purple-100.text-purple-800
  241. option
  242. text])
  243. ;; scroll
  244. (defn get-doc-scroll-top []
  245. (.-scrollTop js/document.documentElement))
  246. (defn main-node
  247. []
  248. (gdom/getElement "main-content"))
  249. (defn get-scroll-top []
  250. (.-scrollTop (main-node)))
  251. (defn get-dynamic-style-node
  252. []
  253. (js/document.getElementById "dynamic-style-scope"))
  254. (defn inject-document-devices-envs!
  255. []
  256. (let [^js cl (.-classList js/document.documentElement)]
  257. (when util/mac? (.add cl "is-mac"))
  258. (when util/win32? (.add cl "is-win32"))
  259. (when (util/electron?) (.add cl "is-electron"))
  260. (when (util/ios?) (.add cl "is-ios"))
  261. (when (util/mobile?) (.add cl "is-mobile"))
  262. (when (util/safari?) (.add cl "is-safari"))
  263. (when (util/electron?)
  264. (js/window.apis.on "full-screen" #(js-invoke cl (if (= % "enter") "add" "remove") "is-fullscreen"))
  265. (p/then (ipc/ipc :getAppBaseInfo) #(let [{:keys [isFullScreen]} (js->clj % :keywordize-keys true)]
  266. (and isFullScreen (.add cl "is-fullscreen")))))))
  267. (defn inject-dynamic-style-node!
  268. []
  269. (let [style (get-dynamic-style-node)]
  270. (if (nil? style)
  271. (let [node (js/document.createElement "style")]
  272. (set! (.-id node) "dynamic-style-scope")
  273. (.appendChild js/document.head node))
  274. style)))
  275. (defn setup-patch-ios-fixed-bottom-position!
  276. "fix a common issue about ios webpage viewport
  277. when soft keyboard setup"
  278. []
  279. (when (and
  280. (util/ios?)
  281. (not (nil? js/window.visualViewport)))
  282. (let [viewport js/visualViewport
  283. style (get-dynamic-style-node)
  284. sheet (.-sheet style)
  285. raf-pending? (atom false)
  286. set-raf-pending! #(reset! raf-pending? %)
  287. handler
  288. (fn []
  289. (when-not @raf-pending?
  290. (let [f (fn []
  291. (set-raf-pending! false)
  292. (let [vh (+ (.-offsetTop viewport) (.-height viewport))
  293. rule (.. sheet -rules (item 0))
  294. set-top #(set! (.. rule -style -top) (str % "px"))]
  295. (set-top vh)))]
  296. (set-raf-pending! true)
  297. (js/window.requestAnimationFrame f))))]
  298. (.insertRule sheet ".fix-ios-fixed-bottom {bottom:unset !important; transform: translateY(-100%); top: 100vh;}")
  299. (.addEventListener viewport "resize" handler)
  300. (.addEventListener viewport "scroll" handler)
  301. (fn []
  302. (.removeEventListener viewport "resize" handler)
  303. (.removeEventListener viewport "scroll" handler)))))
  304. (defn setup-system-theme-effect!
  305. []
  306. (let [^js schemaMedia (js/window.matchMedia "(prefers-color-scheme: dark)")]
  307. (.addEventListener schemaMedia "change" state/sync-system-theme!)
  308. (state/sync-system-theme!)
  309. #(.removeEventListener schemaMedia "change" state/sync-system-theme!)))
  310. (defn set-global-active-keystroke [val]
  311. (.setAttribute js/document.body "data-active-keystroke" val))
  312. (defn setup-active-keystroke! []
  313. (let [active-keystroke (atom #{})
  314. heads #{:shift :alt :meta :control}
  315. handle-global-keystroke (fn [down? e]
  316. (let [handler (if down? conj disj)
  317. keystroke e.key]
  318. (swap! active-keystroke handler keystroke))
  319. (when (contains? heads (keyword (util/safe-lower-case e.key)))
  320. (set-global-active-keystroke (str/join "+" @active-keystroke))))
  321. keydown-handler (partial handle-global-keystroke true)
  322. keyup-handler (partial handle-global-keystroke false)
  323. clear-all #(do (set-global-active-keystroke "")
  324. (reset! active-keystroke #{}))]
  325. (.addEventListener js/window "keydown" keydown-handler)
  326. (.addEventListener js/window "keyup" keyup-handler)
  327. (.addEventListener js/window "blur" clear-all)
  328. (.addEventListener js/window "visibilitychange" clear-all)
  329. (fn []
  330. (.removeEventListener js/window "keydown" keydown-handler)
  331. (.removeEventListener js/window "keyup" keyup-handler)
  332. (.removeEventListener js/window "blur" clear-all)
  333. (.removeEventListener js/window "visibilitychange" clear-all))))
  334. (defn on-scroll
  335. [node on-load on-top-reached]
  336. (let [full-height (gobj/get node "scrollHeight")
  337. scroll-top (gobj/get node "scrollTop")
  338. client-height (gobj/get node "clientHeight")
  339. bottom-reached? (<= (- full-height scroll-top client-height) 100)
  340. top-reached? (= scroll-top 0)]
  341. (when (and bottom-reached? on-load)
  342. (on-load))
  343. (when (and top-reached? on-top-reached)
  344. (on-top-reached))))
  345. (defn attach-listeners
  346. "Attach scroll and resize listeners."
  347. [state]
  348. (let [list-element-id (first (:rum/args state))
  349. opts (-> state :rum/args (nth 2))
  350. node (js/document.getElementById list-element-id)
  351. debounced-on-scroll (util/debounce 500 #(on-scroll
  352. node
  353. (:on-load opts) ; bottom reached
  354. (:on-top-reached opts)))]
  355. (mixins/listen state node :scroll debounced-on-scroll)))
  356. (rum/defcs infinite-list <
  357. (mixins/event-mixin attach-listeners)
  358. "Render an infinite list."
  359. [state list-element-id body {:keys [on-load has-more on-top-reached]}]
  360. (rum/with-context [[t] i18n/*tongue-context*]
  361. [:div
  362. body
  363. (when has-more
  364. [:a.fade-link.text-link.font-bold.text-4xl
  365. {:on-click on-load}
  366. (t :page/earlier)])]))
  367. (rum/defcs auto-complete <
  368. (rum/local 0 ::current-idx)
  369. (shortcut/mixin :shortcut.handler/auto-complete)
  370. [state
  371. matched
  372. {:keys [on-chosen
  373. on-shift-chosen
  374. get-group-name
  375. empty-div
  376. item-render
  377. class]}]
  378. (let [current-idx (get state ::current-idx)]
  379. [:div#ui__ac {:class class}
  380. (if (seq matched)
  381. [:div#ui__ac-inner.hide-scrollbar
  382. (for [[idx item] (medley/indexed matched)]
  383. [:<>
  384. {:key idx}
  385. (let [item-cp
  386. [:div {:key idx}
  387. (let [chosen? (= @current-idx idx)]
  388. (menu-link
  389. {:id (str "ac-" idx)
  390. :class (when chosen? "chosen")
  391. :on-mouse-enter #(reset! current-idx idx)
  392. :on-mouse-down (fn [e]
  393. (util/stop e)
  394. (if (and (gobj/get e "shiftKey") on-shift-chosen)
  395. (on-shift-chosen item)
  396. (on-chosen item)))}
  397. (if item-render (item-render item chosen?) item)))]]
  398. (if get-group-name
  399. (if-let [group-name (get-group-name item)]
  400. [:div
  401. [:div.ui__ac-group-name group-name]
  402. item-cp]
  403. item-cp)
  404. item-cp))])]
  405. (when empty-div
  406. empty-div))]))
  407. (def datepicker frontend.ui.date-picker/date-picker)
  408. (defn toggle
  409. ([on? on-click] (toggle on? on-click false))
  410. ([on? on-click small?]
  411. [:a.ui__toggle {:on-click on-click
  412. :class (if small? "is-small" "")}
  413. [:span.wrapper.transition-colors.ease-in-out.duration-200
  414. {:aria-checked (if on? "true" "false"), :tab-index "0", :role "checkbox"
  415. :class (if on? "bg-indigo-600" "bg-gray-300")}
  416. [:span.switcher.transform.transition.ease-in-out.duration-200
  417. {:class (if on? (if small? "translate-x-4" "translate-x-5") "translate-x-0")
  418. :aria-hidden "true"}]]]))
  419. ;; `sequence` can be a list of symbols, a list of strings, or a string
  420. (defn render-keyboard-shortcut [sequence]
  421. (let [sequence (if (string? sequence)
  422. (-> sequence ;; turn string into sequence
  423. (str/trim)
  424. (str/lower-case)
  425. (str/split #" |\+"))
  426. sequence)]
  427. [:span.keyboard-shortcut
  428. (map-indexed (fn [i key]
  429. [:code {:key i}
  430. ;; Display "cmd" rather than "meta" to the user to describe the Mac
  431. ;; mod key, because that's what the Mac keyboards actually say.
  432. (if (or (= :meta key) (= "meta" key))
  433. (util/meta-key-name)
  434. (name key))])
  435. sequence)]))
  436. (defn keyboard-shortcut-from-config [shortcut-name]
  437. (let [default-binding (:binding (get shortcut-config/all-default-keyboard-shortcuts shortcut-name))
  438. custom-binding (when (state/shortcuts) (get (state/shortcuts) shortcut-name))
  439. binding (or custom-binding default-binding)]
  440. (shortcut-helper/decorate-binding binding)))
  441. (defonce modal-show? (atom false))
  442. (rum/defc modal-overlay
  443. [state close-fn]
  444. [:div.ui__modal-overlay
  445. {:class (case state
  446. "entering" "ease-out duration-300 opacity-0"
  447. "entered" "ease-out duration-300 opacity-100"
  448. "exiting" "ease-in duration-200 opacity-100"
  449. "exited" "ease-in duration-200 opacity-0")
  450. :on-click close-fn}
  451. [:div.absolute.inset-0.opacity-75]])
  452. (rum/defc modal-panel-content <
  453. mixins/component-editing-mode
  454. [panel-content close-fn]
  455. (panel-content close-fn))
  456. (rum/defc modal-panel
  457. [show? panel-content transition-state close-fn fullscreen? close-btn?]
  458. [:div.ui__modal-panel.transform.transition-all.sm:min-w-lg.sm
  459. {:class (case transition-state
  460. "entering" "ease-out duration-300 opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
  461. "entered" "ease-out duration-300 opacity-100 translate-y-0 sm:scale-100"
  462. "exiting" "ease-in duration-200 opacity-100 translate-y-0 sm:scale-100"
  463. "exited" "ease-in duration-200 opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95")}
  464. [:div.absolute.top-0.right-0.pt-2.pr-2
  465. (when close-btn?
  466. [:a.ui__modal-close.opacity-60.hover:opacity-100
  467. {:aria-label "Close"
  468. :type "button"
  469. :on-click close-fn}
  470. [:svg.h-6.w-6
  471. {:stroke "currentColor", :view-box "0 0 24 24", :fill "none"}
  472. [:path
  473. {:d "M6 18L18 6M6 6l12 12"
  474. :stroke-width "2"
  475. :stroke-linejoin "round"
  476. :stroke-linecap "round"}]]])]
  477. (when show?
  478. [:div {:class (if fullscreen? "" "panel-content")}
  479. (modal-panel-content panel-content close-fn)])])
  480. (rum/defc modal < rum/reactive
  481. (mixins/event-mixin
  482. (fn [state]
  483. (mixins/hide-when-esc-or-outside
  484. state
  485. :on-hide (fn []
  486. (some->
  487. (.querySelector (rum/dom-node state) "button.ui__modal-close")
  488. (.click)))
  489. :outside? false)
  490. (mixins/on-key-down
  491. state
  492. {;; enter
  493. 13 (fn [state e]
  494. (some->
  495. (.querySelector (rum/dom-node state) "button.ui__modal-enter")
  496. (.click)))})))
  497. []
  498. (let [modal-panel-content (state/sub :modal/panel-content)
  499. fullscreen? (state/sub :modal/fullscreen?)
  500. close-btn? (state/sub :modal/close-btn?)
  501. show? (boolean modal-panel-content)
  502. close-fn (fn []
  503. (state/close-modal!)
  504. (state/close-settings!))
  505. modal-panel-content (or modal-panel-content (fn [close] [:div]))]
  506. [:div.ui__modal
  507. {:style {:z-index (if show? 9999 -1)
  508. :top (when (or (mobile-util/native-iphone?)
  509. (and (util/mobile?) (util/ios?)))
  510. 60)}}
  511. (css-transition
  512. {:in show? :timeout 0}
  513. (fn [state]
  514. (modal-overlay state close-fn)))
  515. (css-transition
  516. {:in show? :timeout 0}
  517. (fn [state]
  518. (modal-panel show? modal-panel-content state close-fn fullscreen? close-btn?)))]))
  519. (defn make-confirm-modal
  520. [{:keys [tag title sub-title sub-checkbox? on-cancel on-confirm]
  521. :or {on-cancel #()}
  522. :as opts}]
  523. (fn [close-fn]
  524. (rum/with-context [[t] i18n/*tongue-context*]
  525. (let [*sub-checkbox-selected (and sub-checkbox? (atom []))]
  526. [:div.ui__confirm-modal
  527. {:class (str "is-" tag)}
  528. [:div.sm:flex.sm:items-start
  529. [:div.mx-auto.flex-shrink-0.flex.items-center.justify-center.h-12.w-12.rounded-full.bg-red-100.sm:mx-0.sm:h-10.sm:w-10
  530. [:svg.h-6.w-6.text-red-600
  531. {:stroke "currentColor", :view-box "0 0 24 24", :fill "none"}
  532. [:path
  533. {:d
  534. "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"
  535. :stroke-width "2"
  536. :stroke-linejoin "round"
  537. :stroke-linecap "round"}]]]
  538. [:div.mt-3.text-center.sm:mt-0.sm:ml-4.sm:text-left
  539. [:h2.headline.text-lg.leading-6.font-medium
  540. (if (keyword? title) (t title) title)]
  541. [:label.sublabel
  542. (when sub-checkbox?
  543. (checkbox
  544. {:default-value false
  545. :on-change (fn [e]
  546. (let [checked (.. e -target -checked)]
  547. (reset! *sub-checkbox-selected [checked])))}))
  548. [:h3.subline.text-gray-400
  549. (if (keyword? sub-title)
  550. (t sub-title)
  551. sub-title)]]]]
  552. [:div.mt-5.sm:mt-4.sm:flex.sm:flex-row-reverse
  553. [:span.flex.w-full.rounded-md.shadow-sm.sm:ml-3.sm:w-auto
  554. [: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
  555. {:type "button"
  556. :on-click #(and (fn? on-confirm)
  557. (on-confirm % {:close-fn close-fn
  558. :sub-selected (and *sub-checkbox-selected @*sub-checkbox-selected)}))}
  559. (t :yes)]]
  560. [:span.mt-3.flex.w-full.rounded-md.shadow-sm.sm:mt-0.sm:w-auto
  561. [: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
  562. {:type "button"
  563. :on-click (comp on-cancel close-fn)}
  564. (t :cancel)]]]]))))
  565. (defn loading
  566. [content]
  567. [:div.flex.flex-row.items-center
  568. [:span.icon.flex.items-center svg/loading]
  569. [:span.text.pl-2 content]])
  570. (rum/defc rotating-arrow
  571. [collapsed?]
  572. [:span
  573. {:class (if collapsed? "rotating-arrow collapsed" "rotating-arrow not-collapsed")}
  574. (svg/caret-right)])
  575. (rum/defcs foldable < db-mixins/query rum/reactive
  576. (rum/local false ::control?)
  577. (rum/local false ::collapsed?)
  578. {:will-mount (fn [state]
  579. (let [args (:rum/args state)]
  580. (when (true? (:default-collapsed? (last args)))
  581. (reset! (get state ::collapsed?) true)))
  582. state)}
  583. [state header content {:keys [default-collapsed? title-trigger?]}]
  584. (let [control? (get state ::control?)
  585. collapsed? (get state ::collapsed?)
  586. on-mouse-down (fn [e]
  587. (util/stop e)
  588. (swap! collapsed? not))]
  589. [:div.flex.flex-col
  590. [:div.content
  591. [:div.flex-1.flex-row.foldable-title (cond->
  592. {:on-mouse-over #(reset! control? true)
  593. :on-mouse-out #(reset! control? false)}
  594. title-trigger?
  595. (assoc :on-mouse-down on-mouse-down
  596. :class "cursor"))
  597. [:div.flex.flex-row.items-center
  598. [:a.block-control.opacity-50.hover:opacity-100.mr-2
  599. (cond->
  600. {:style {:width 14
  601. :height 16
  602. :margin-left -30}}
  603. (not title-trigger?)
  604. (assoc :on-mouse-down on-mouse-down))
  605. [:span {:class (if @control? "control-show" "control-hide")}
  606. (rotating-arrow @collapsed?)]]
  607. (if (fn? header)
  608. (header @collapsed?)
  609. header)]]]
  610. [:div {:class (if @collapsed? "hidden" "initial")
  611. :on-mouse-down (fn [e] (.stopPropagation e))}
  612. (if (fn? content)
  613. (if (not @collapsed?) (content) nil)
  614. content)]]))
  615. (defn admonition
  616. [type content]
  617. (let [type (name type)]
  618. (when-let [icon (case (string/lower-case type)
  619. "note" svg/note
  620. "tip" svg/tip
  621. "important" svg/important
  622. "caution" svg/caution
  623. "warning" svg/warning
  624. "pinned" svg/pinned
  625. nil)]
  626. [:div.flex.flex-row.admonitionblock.align-items {:class type}
  627. [:div.pr-4.admonition-icon.flex.flex-col.justify-center
  628. {:title (string/upper-case type)} (icon)]
  629. [:div.ml-4.text-lg
  630. content]])))
  631. (rum/defcs catch-error
  632. < {:did-catch
  633. (fn [state error info]
  634. (js/console.dir error)
  635. (assoc state ::error error))}
  636. [{error ::error, c :rum/react-component} error-view view]
  637. (if (some? error)
  638. (do
  639. (log/error :exception error)
  640. error-view)
  641. view))
  642. (rum/defc select
  643. [options on-change class]
  644. [:select.mt-1.block.px-3.text-base.leading-6.border-gray-300.focus:outline-none.focus:shadow-outline-blue.focus:border-blue-300.sm:text-sm.sm:leading-5.ml-4
  645. {:class (or class "form-select")
  646. :style {:padding "0 0 0 12px"}
  647. :on-change (fn [e]
  648. (let [value (util/evalue e)]
  649. (on-change value)))}
  650. (for [{:keys [label value selected]} options]
  651. [:option (cond->
  652. {:key label
  653. :value (or value label)}
  654. selected
  655. (assoc :selected selected))
  656. label])])
  657. (rum/defcs tippy < rum/static
  658. (rum/local false ::mounted?)
  659. [state {:keys [fixed-position? open?] :as opts} child]
  660. (let [*mounted? (::mounted? state)
  661. mounted? @*mounted?
  662. manual (not= open? nil)]
  663. (Tippy (->
  664. (merge {:arrow true
  665. :sticky true
  666. :delay 600
  667. :theme "customized"
  668. :disabled (not (state/enable-tooltip?))
  669. :unmountHTMLWhenHide true
  670. :open (if manual open? @*mounted?)
  671. :trigger (if manual "manual" "mouseenter focus")
  672. ;; See https://github.com/tvkhoa/react-tippy/issues/13
  673. :popperOptions (if fixed-position?
  674. {:modifiers {:flip {:enabled false}
  675. :hide {:enabled false}
  676. :preventOverflow {:enabled false}}}
  677. {})
  678. :onShow #(reset! *mounted? true)
  679. :onHide #(reset! *mounted? false)}
  680. opts)
  681. (assoc :html (if (or open? mounted?)
  682. (try
  683. (when-let [html (:html opts)]
  684. (if (fn? html)
  685. (html)
  686. [:div.px-2.py-1
  687. html]))
  688. (catch js/Error e
  689. (log/error :exception e)
  690. [:div]))
  691. [:div {:key "tippy"} ""])))
  692. child)))
  693. (defn slider
  694. [default-value {:keys [min max on-change]}]
  695. [:input.cursor-pointer
  696. {:type "range"
  697. :value (int default-value)
  698. :min min
  699. :max max
  700. :style {:width "100%"}
  701. :on-change #(let [value (util/evalue %)]
  702. (on-change value))}])
  703. (rum/defcs tweet-embed < (rum/local true :loading?)
  704. [state id]
  705. (let [*loading? (:loading? state)]
  706. [:div [(when @*loading? [:span.flex.items-center [svg/loading " ... loading"]])
  707. (ReactTweetEmbed
  708. {:id id
  709. :class "contents"
  710. :options {:theme (when (= (state/sub :ui/theme) "dark") "dark")}
  711. :on-tweet-load-success #(reset! *loading? false)})]]))
  712. (defn icon
  713. ([class] (icon class nil))
  714. ([class opts]
  715. [:i (merge {:class (str "ti ti-" class
  716. (when (:class opts)
  717. (str " " (string/trim (:class opts)))))}
  718. (dissoc opts :class))]))
  719. (rum/defc with-shortcut < rum/reactive
  720. [shortcut-key position content]
  721. (let [tooltip? (state/sub :ui/shortcut-tooltip?)]
  722. (if tooltip?
  723. (tippy
  724. {:html [:div.text-sm.font-medium (keyboard-shortcut-from-config shortcut-key)]
  725. :interactive true
  726. :position position
  727. :theme "monospace"
  728. :delay [1000, 100]
  729. :arrow true}
  730. content)
  731. content)))