ui.cljs 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546
  1. (ns frontend.ui
  2. (:require [rum.core :as rum]
  3. [frontend.rum :as r]
  4. ["react-transition-group" :refer [TransitionGroup CSSTransition]]
  5. ["react-textarea-autosize" :as TextareaAutosize]
  6. [frontend.util :as util]
  7. [frontend.mixins :as mixins]
  8. [frontend.handler.notification :as notification-handler]
  9. [frontend.state :as state]
  10. [frontend.components.svg :as svg]
  11. [clojure.string :as string]
  12. [goog.object :as gobj]
  13. [goog.dom :as gdom]
  14. [medley.core :as medley]
  15. [frontend.ui.date-picker]))
  16. (defonce transition-group (r/adapt-class TransitionGroup))
  17. (defonce css-transition (r/adapt-class CSSTransition))
  18. (defonce textarea (r/adapt-class (gobj/get TextareaAutosize "default")))
  19. (rum/defc ls-textarea < rum/reactive
  20. [{:keys [on-change] :as props}]
  21. (let [skip-composition? (or
  22. (state/sub :editor/show-page-search?)
  23. (state/sub :editor/show-block-search?)
  24. (state/sub :editor/show-template-search?))
  25. composition? (atom false)
  26. set-composition? #(reset! composition? %)
  27. on-composition (fn [e]
  28. (if skip-composition?
  29. (on-change e)
  30. (case e.type
  31. "compositionend" (do (set-composition? false) (on-change e))
  32. (set-composition? true))))
  33. props (assoc props
  34. :on-change (fn [e] (when-not @composition?
  35. (on-change e)))
  36. :on-composition-start on-composition
  37. :on-composition-update on-composition
  38. :on-composition-end on-composition)]
  39. (textarea props)))
  40. (rum/defc dropdown-content-wrapper [state content class]
  41. (let [class (or class
  42. (util/hiccup->class "origin-top-right.absolute.right-0.mt-2.rounded-md.shadow-lg"))]
  43. [:div.dropdown-wrapper
  44. {:class (str class " "
  45. (case state
  46. "entering" "transition ease-out duration-100 transform opacity-0 scale-95"
  47. "entered" "transition ease-out duration-100 transform opacity-100 scale-100"
  48. "exiting" "transition ease-in duration-75 transform opacity-100 scale-100"
  49. "exited" "transition ease-in duration-75 transform opacity-0 scale-95"))}
  50. content]))
  51. ;; public exports
  52. (rum/defcs dropdown < (mixins/modal :open?)
  53. [state content-fn modal-content-fn
  54. & [{:keys [modal-class z-index]
  55. :or {z-index 999}
  56. :as opts}]]
  57. (let [{:keys [open? toggle-fn]} state
  58. modal-content (modal-content-fn state)]
  59. [:div.ml-1.relative {:style {:z-index z-index}}
  60. (content-fn state)
  61. (css-transition
  62. {:in @open? :timeout 0}
  63. (fn [dropdown-state]
  64. (when @open?
  65. (dropdown-content-wrapper dropdown-state modal-content modal-class))))]))
  66. (rum/defc menu-link
  67. [options child]
  68. [:a.block.px-4.py-2.text-sm.text-gray-700.transition.ease-in-out.duration-150.cursor.menu-link
  69. options
  70. child])
  71. (rum/defc dropdown-with-links
  72. [content-fn links {:keys [modal-class links-header z-index] :as opts}]
  73. (dropdown
  74. content-fn
  75. (fn [{:keys [close-fn] :as state}]
  76. [:div.py-1.rounded-md.shadow-xs
  77. (when links-header links-header)
  78. (for [{:keys [options title icon]} links]
  79. (let [new-options
  80. (assoc options
  81. :on-click (fn [e]
  82. (when-let [on-click-fn (:on-click options)]
  83. (on-click-fn e))
  84. (close-fn)))
  85. child [:div
  86. {:style {:display "flex" :flex-direction "row"}}
  87. [:div {:style {:margin-right "8px"}} title]
  88. ;; [:div {:style {:position "absolute" :right "8px"}}
  89. ;; icon]
  90. ]]
  91. (rum/with-key
  92. (menu-link new-options child)
  93. title)))])
  94. opts))
  95. (defn button
  96. [text & {:keys [background on-click href]
  97. :as option}]
  98. (let [class "inline-flex.items-center.px-3.py-2.border.border-transparent.text-sm.leading-4.font-medium.rounded-md.text-white.bg-indigo-600.hover:bg-indigo-700.focus:outline-none.focus:border-indigo-700.focus:shadow-outline-indigo.active:bg-indigo-700.transition.ease-in-out.duration-150.mt-1"
  99. class (if background (string/replace class "indigo" background) class)]
  100. (if href
  101. [:a.button (merge
  102. {:type "button"
  103. :class (util/hiccup->class class)}
  104. (dissoc option :background))
  105. text]
  106. [:button
  107. (merge
  108. {:type "button"
  109. :class (util/hiccup->class class)}
  110. (dissoc option :background))
  111. text])))
  112. (rum/defc notification-content
  113. [state content status uid]
  114. (when (and content status)
  115. (let [[color-class svg]
  116. (case status
  117. :success
  118. ["text-gray-900 dark:text-gray-300 "
  119. [:svg.h-6.w-6.text-green-400
  120. {:stroke "currentColor", :viewBox "0 0 24 24", :fill "none"}
  121. [:path
  122. {:d "M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
  123. :stroke-width "2"
  124. :stroke-linejoin "round"
  125. :stroke-linecap "round"}]]]
  126. :warning
  127. ["text-gray-900"
  128. [:svg.h-6.w-6.text-yellow-500
  129. {:stroke "currentColor", :viewBox "0 0 24 24", :fill "none"}
  130. [:path
  131. {:d "M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
  132. :stroke-width "2"
  133. :stroke-linejoin "round"
  134. :stroke-linecap "round"}]]]
  135. ["text-red-500"
  136. [:svg.h-6.w-6.text-red-500
  137. {:view-box "0 0 20 20", :fill "currentColor"}
  138. [:path
  139. {:clip-rule "evenodd"
  140. :d
  141. "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"
  142. :fill-rule "evenodd"}]]])]
  143. [:div.ui__notifications-content
  144. {:style {:z-index (if (or (= state "exiting")
  145. (= state "exited"))
  146. -1
  147. 99)
  148. :top "3.2em"}}
  149. [:div.max-w-sm.w-full.shadow-lg.rounded-lg.pointer-events-auto.notification-area
  150. {:class (case state
  151. "entering" "transition ease-out duration-300 transform opacity-0 translate-y-2 sm:translate-x-0"
  152. "entered" "transition ease-out duration-300 transform translate-y-0 opacity-100 sm:translate-x-0"
  153. "exiting" "transition ease-in duration-100 opacity-100"
  154. "exited" "transition ease-in duration-100 opacity-0")}
  155. [:div.rounded-lg.shadow-xs.overflow-hidden
  156. [:div.p-4
  157. [:div.flex.items-start
  158. [:div.flex-shrink-0
  159. svg]
  160. [:div.ml-3.w-0.flex-1
  161. [:div.text-sm.leading-5.font-medium {:style {:margin 0}
  162. :class color-class}
  163. content]]
  164. [:div.ml-4.flex-shrink-0.flex
  165. [:button.inline-flex.text-gray-400.focus:outline-none.focus:text-gray-500.transition.ease-in-out.duration-150
  166. {:on-click (fn []
  167. (notification-handler/clear! uid))}
  168. [:svg.h-5.w-5
  169. {:fill "currentColor", :view-Box "0 0 20 20"}
  170. [:path
  171. {:clip-rule "evenodd"
  172. :d
  173. "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"
  174. :fill-rule "evenodd"}]]]]]]]]])))
  175. (rum/defc notification < rum/reactive
  176. []
  177. (let [contents (state/sub :notification/contents)]
  178. (transition-group
  179. {:class-name "notifications ui__notifications"}
  180. (doall (map (fn [el]
  181. (let [k (first el)
  182. v (second el)]
  183. (css-transition
  184. {:timeout 100
  185. :key (name k)}
  186. (fn [state]
  187. (notification-content state (:content v) (:status v) k)))))
  188. contents)))))
  189. (defn checkbox
  190. [option]
  191. [:input.form-checkbox.h-4.w-4.transition.duration-150.ease-in-out
  192. (merge {:type "checkbox"} option)])
  193. (defn badge
  194. [text option]
  195. [: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
  196. option
  197. text])
  198. ;; scroll
  199. (defn get-doc-scroll-top []
  200. (.-scrollTop js/document.documentElement))
  201. (defn main-node
  202. []
  203. (gdom/getElement "main-content"))
  204. (defn get-scroll-top []
  205. (.-scrollTop (main-node)))
  206. (defn get-dynamic-style-node
  207. []
  208. (js/document.getElementById "dynamic-style-scope"))
  209. (defn inject-document-devices-envs!
  210. []
  211. (let [cl (.-classList js/document.documentElement)]
  212. (if util/mac? (.add cl "is-mac"))
  213. (if (util/ios?) (.add cl "is-ios"))
  214. (if (util/safari?) (.add cl "is-safari"))))
  215. (defn inject-dynamic-style-node!
  216. []
  217. (let [style (get-dynamic-style-node)]
  218. (if (nil? style)
  219. (let [node (js/document.createElement "style")]
  220. (set! (.-id node) "dynamic-style-scope")
  221. (.appendChild js/document.head node))
  222. style)))
  223. (defn setup-patch-ios-fixed-bottom-position!
  224. "fix a common issue about ios webpage viewport
  225. when soft keyboard setup"
  226. []
  227. (if (and
  228. (util/ios?)
  229. (not (nil? js/window.visualViewport)))
  230. (let [viewport js/visualViewport
  231. style (get-dynamic-style-node)
  232. sheet (.-sheet style)
  233. raf-pending? (atom false)
  234. set-raf-pending! #(reset! raf-pending? %)
  235. handler
  236. (fn []
  237. (if-not @raf-pending?
  238. (let [f (fn []
  239. (set-raf-pending! false)
  240. (let [vh (+ (.-offsetTop viewport) (.-height viewport))
  241. rule (.. sheet -rules (item 0))
  242. set-top #(set! (.. rule -style -top) (str % "px"))]
  243. (set-top vh)))]
  244. (set-raf-pending! true)
  245. (js/window.requestAnimationFrame f))))]
  246. (.insertRule sheet ".fix-ios-fixed-bottom {bottom:unset !important; transform: translateY(-100%); top: 100vh;}")
  247. (.addEventListener viewport "resize" handler)
  248. (.addEventListener viewport "scroll" handler)
  249. (fn []
  250. (.removeEventListener viewport "resize" handler)
  251. (.removeEventListener viewport "scroll" handler)))))
  252. ;; FIXME: compute the right scroll position when scrolling back to the top
  253. (defn on-scroll
  254. [on-load on-top-reached]
  255. (let [node js/document.documentElement
  256. full-height (gobj/get node "scrollHeight")
  257. scroll-top (gobj/get node "scrollTop")
  258. client-height (gobj/get node "clientHeight")
  259. bottom-reached? (<= (- full-height scroll-top client-height) 100)
  260. top-reached? (= scroll-top 0)]
  261. (when (and bottom-reached? on-load)
  262. (on-load))
  263. (when (and top-reached? on-top-reached)
  264. (on-top-reached))))
  265. (defn attach-listeners
  266. "Attach scroll and resize listeners."
  267. [state]
  268. (let [opts (-> state :rum/args second)
  269. debounced-on-scroll (util/debounce 500 #(on-scroll
  270. (:on-load opts) ; bottom reached
  271. (:on-top-reached opts)))]
  272. (mixins/listen state js/document :scroll debounced-on-scroll)))
  273. (rum/defcs infinite-list <
  274. (mixins/event-mixin attach-listeners)
  275. "Render an infinite list."
  276. [state body {:keys [on-load on-top-reached]
  277. :as opts}]
  278. body)
  279. (rum/defcs auto-complete <
  280. (rum/local 0 ::current-idx)
  281. (mixins/event-mixin
  282. (fn [state]
  283. (mixins/on-key-down
  284. state
  285. {;; up
  286. 38 (fn [_ e]
  287. (let [current-idx (get state ::current-idx)
  288. matched (first (:rum/args state))]
  289. (util/stop e)
  290. (cond
  291. (>= @current-idx 1)
  292. (swap! current-idx dec)
  293. (= @current-idx 0)
  294. (reset! current-idx (dec (count matched)))
  295. :else
  296. nil)
  297. (when-let [element (gdom/getElement (str "ac-" @current-idx))]
  298. (let [ac-inner (gdom/getElement "ui__ac-inner")
  299. element-top (gobj/get element "offsetTop")
  300. scroll-top (- (gobj/get element "offsetTop") 360)]
  301. (set! (.-scrollTop ac-inner) scroll-top)))))
  302. ;; down
  303. 40 (fn [state e]
  304. (let [current-idx (get state ::current-idx)
  305. matched (first (:rum/args state))]
  306. (util/stop e)
  307. (let [total (count matched)]
  308. (if (>= @current-idx (dec total))
  309. (reset! current-idx 0)
  310. (swap! current-idx inc)))
  311. (when-let [element (gdom/getElement (str "ac-" @current-idx))]
  312. (let [ac-inner (gdom/getElement "ui__ac-inner")
  313. element-top (gobj/get element "offsetTop")
  314. scroll-top (- (gobj/get element "offsetTop") 360)]
  315. (set! (.-scrollTop ac-inner) scroll-top)))))
  316. ;; enter
  317. 13 (fn [state e]
  318. (util/stop e)
  319. (let [[matched {:keys [on-chosen on-enter]}] (:rum/args state)]
  320. (let [current-idx (get state ::current-idx)]
  321. (if (and (seq matched)
  322. (> (count matched)
  323. @current-idx))
  324. (on-chosen (nth matched @current-idx) false)
  325. (and on-enter (on-enter state))))))})))
  326. [state matched {:keys [on-chosen
  327. on-shift-chosen
  328. on-enter
  329. empty-div
  330. item-render
  331. class]}]
  332. (let [current-idx (get state ::current-idx)]
  333. [:div#ui__ac {:class class}
  334. (if (seq matched)
  335. [:div#ui__ac-inner.hide-scrollbar
  336. (for [[idx item] (medley/indexed matched)]
  337. (rum/with-key
  338. (menu-link
  339. {:id (str "ac-" idx)
  340. :class (when (= @current-idx idx)
  341. "chosen")
  342. ;; :tab-index -1
  343. :on-click (fn [e]
  344. (util/stop e)
  345. (if (and (gobj/get e "shiftKey") on-shift-chosen)
  346. (on-shift-chosen item)
  347. (on-chosen item)))}
  348. (if item-render (item-render item) item))
  349. idx))]
  350. (when empty-div
  351. empty-div))]))
  352. (def datepicker frontend.ui.date-picker/date-picker)
  353. (defn toggle
  354. [on? on-click]
  355. [:a {:on-click on-click}
  356. [:span.relative.inline-block.flex-shrink-0.h-6.w-11.border-2.border-transparent.rounded-full.cursor-pointer.transition-colors.ease-in-out.duration-200.focus:outline-none.focus:shadow-outline
  357. {:aria-checked "false", :tab-index "0", :role "checkbox"
  358. :class (if on? "bg-indigo-600" "bg-gray-200")}
  359. [:span.inline-block.h-5.w-5.rounded-full.bg-white.shadow.transform.transition.ease-in-out.duration-200
  360. {:class (if on? "translate-x-5" "translate-x-0")
  361. :aria-hidden "true"}]]])
  362. (defn tooltip
  363. ([label children]
  364. (tooltip label children {}))
  365. ([label children {:keys [label-style]}]
  366. [:div.Tooltip {:style {:display "inline"}}
  367. [:div (cond->
  368. {:class "Tooltip__label"}
  369. label-style
  370. (assoc :style label-style))
  371. label]
  372. children]))
  373. (defonce modal-show? (atom false))
  374. (rum/defc modal-overlay
  375. [state]
  376. [:div.fixed.inset-0.transition-opacity
  377. {:class (case state
  378. "entering" "ease-out duration-300 opacity-0"
  379. "entered" "ease-out duration-300 opacity-100"
  380. "exiting" "ease-in duration-200 opacity-100"
  381. "exited" "ease-in duration-200 opacity-0")}
  382. [:div.absolute.inset-0.bg-gray-500.opacity-75]])
  383. (rum/defc modal-panel
  384. [panel-content state close-fn]
  385. [:div.relative.bg-white.rounded-lg.px-4.pt-5.pb-4.overflow-hidden.shadow-xl.transform.transition-all.sm:max-w-lg.sm:w-full.sm:p-6
  386. {:class (case state
  387. "entering" "ease-out duration-300 opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95"
  388. "entered" "ease-out duration-300 opacity-100 translate-y-0 sm:scale-100"
  389. "exiting" "ease-in duration-200 opacity-100 translate-y-0 sm:scale-100"
  390. "exited" "ease-in duration-200 opacity-0 translate-y-4 sm:translate-y-0 sm:scale-95")}
  391. [:div.absolute.top-0.right-0.pt-4.pr-4
  392. [:button.text-gray-400.hover:text-gray-500.focus:outline-none.focus:text-gray-500.transition.ease-in-out.duration-150
  393. {:aria-label "Close"
  394. :type "button"
  395. :on-click close-fn}
  396. [:svg.h-6.w-6
  397. {:stroke "currentColor", :view-box "0 0 24 24", :fill "none"}
  398. [:path
  399. {:d "M6 18L18 6M6 6l12 12"
  400. :stroke-width "2"
  401. :stroke-linejoin "round"
  402. :stroke-linecap "round"}]]]]
  403. (panel-content close-fn)])
  404. (rum/defc modal < rum/reactive
  405. []
  406. (let [modal-panel-content (state/sub :modal/panel-content)
  407. show? (boolean modal-panel-content)
  408. close-fn #(state/close-modal!)
  409. modal-panel-content (or modal-panel-content (fn [close] [:div]))]
  410. [:div.fixed.bottom-0.inset-x-0.px-4.pb-4.sm:inset-0.sm:flex.sm:items-center.sm:justify-center
  411. {:style {:z-index (if show? 10 -1)}}
  412. (css-transition
  413. {:in show? :timeout 0}
  414. (fn [state]
  415. (modal-overlay state)))
  416. (css-transition
  417. {:in show? :timeout 0}
  418. (fn [state]
  419. (modal-panel modal-panel-content state close-fn)))]))
  420. (defn loading
  421. [content]
  422. [:div.flex.flex-row.items-center
  423. [:span.icon.flex.items-center svg/loading]
  424. [:span.text.pl-2 content]])
  425. (rum/defcs foldable <
  426. (rum/local false ::control?)
  427. (rum/local false ::collapsed?)
  428. {:will-mount (fn [state]
  429. (let [args (:rum/args state)]
  430. (when (true? (last args))
  431. (reset! (get state ::collapsed?) true)))
  432. state)}
  433. [state header content default-collapsed?]
  434. (let [control? (get state ::control?)
  435. collapsed? (get state ::collapsed?)]
  436. [:div.flex.flex-col
  437. [:div.content
  438. [:div.flex-1.flex-row.foldable-title {:on-mouse-over #(reset! control? true)
  439. :on-mouse-out #(reset! control? false)}
  440. [:div.flex.flex-row.items-center
  441. [:a.block-control.opacity-50.hover:opacity-100.mr-2
  442. {:style {:width 14
  443. :height 16
  444. :margin-left -24}
  445. :on-click (fn [e]
  446. (util/stop e)
  447. (swap! collapsed? not))}
  448. (cond
  449. @collapsed?
  450. (svg/caret-right)
  451. @control?
  452. (svg/caret-down)
  453. :else
  454. [:span ""])]
  455. (if (fn? header)
  456. (header @collapsed?)
  457. header)]]]
  458. [:div {:class (if @collapsed?
  459. "hidden"
  460. "initial")}
  461. (cond
  462. (and (fn? content) (not @collapsed?))
  463. (content)
  464. (fn? content)
  465. nil
  466. :else
  467. content)]]))
  468. (defn admonition
  469. [type content]
  470. (let [type (name type)]
  471. (when-let [icon (case (string/lower-case type)
  472. "note" svg/note
  473. "tip" svg/tip
  474. "important" svg/important
  475. "caution" svg/caution
  476. "warning" svg/warning
  477. nil)]
  478. [:div.flex.flex-row.admonitionblock.align-items {:class type}
  479. [:div.pr-4.admonition-icon.flex.flex-col.justify-center
  480. {:title (string/upper-case type)} (icon)]
  481. [:div.ml-4.text-lg
  482. content]])))
  483. (rum/defcs catch-error
  484. < {:did-catch
  485. (fn [state error info]
  486. (js/console.dir error)
  487. (assoc state ::error error))}
  488. [{error ::error, c :rum/react-component} error-view view]
  489. (if (some? error)
  490. error-view
  491. view))
  492. (rum/defc select
  493. [options on-change]
  494. [:select.mt-1.form-select.block.w-full.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
  495. {:style {:padding "0 0 0 12px"}
  496. :on-change (fn [e]
  497. (let [value (util/evalue e)]
  498. (on-change value)))}
  499. (for [{:keys [label value selected]} options]
  500. [:option (cond->
  501. {:key label
  502. :value (or value label)}
  503. selected
  504. (assoc :selected selected))
  505. label])])