whiteboard.cljs 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294
  1. (ns frontend.components.whiteboard
  2. "Whiteboard related components"
  3. (:require [cljs.math :as math]
  4. [frontend.components.content :as content]
  5. [frontend.components.page :as page]
  6. [frontend.components.reference :as reference]
  7. [frontend.context.i18n :refer [t]]
  8. [frontend.db.model :as model]
  9. [frontend.handler.common :as common-handler]
  10. [frontend.handler.route :as route-handler]
  11. [frontend.handler.user :as user-handler]
  12. [frontend.handler.whiteboard :as whiteboard-handler]
  13. [frontend.rum :refer [use-bounding-client-rect use-breakpoint
  14. use-click-outside]]
  15. [frontend.state :as state]
  16. [frontend.ui :as ui]
  17. [frontend.util :as util]
  18. [promesa.core :as p]
  19. [rum.core :as rum]
  20. [shadow.loader :as loader]))
  21. (defonce tldraw-loaded? (atom false))
  22. (rum/defc tldraw-app < rum/reactive
  23. {:init (fn [state]
  24. (p/let [_ (loader/load :tldraw)]
  25. (reset! tldraw-loaded? true))
  26. state)}
  27. [name shape-id]
  28. (let [loaded? (rum/react tldraw-loaded?)
  29. draw-component (when loaded?
  30. (resolve 'frontend.extensions.tldraw/tldraw-app))]
  31. (when draw-component
  32. (draw-component name shape-id))))
  33. ;; TODO: make it reactive to db changes
  34. (rum/defc tldraw-preview < rum/reactive
  35. {:init (fn [state]
  36. (p/let [_ (loader/load :tldraw)]
  37. (reset! tldraw-loaded? true))
  38. state)}
  39. [page-name]
  40. (let [loaded? (rum/react tldraw-loaded?)
  41. tldr (whiteboard-handler/page-name->tldr! page-name)
  42. generate-preview (when loaded?
  43. (resolve 'frontend.extensions.tldraw/generate-preview))]
  44. (when generate-preview
  45. (generate-preview tldr))))
  46. ;; TODO: use frontend.ui instead of making a new one
  47. (rum/defc dropdown
  48. [label children show? outside-click-hander portal?]
  49. (let [[anchor-ref anchor-rect] (use-bounding-client-rect show?)
  50. [content-ref content-rect] (use-bounding-client-rect show?)
  51. offset-x (when (and anchor-rect content-rect)
  52. (if portal?
  53. (let [offset-x (+ (* 0.5 (- (.-width anchor-rect) (.-width content-rect)))
  54. (.-x anchor-rect))
  55. vp-w (.-innerWidth js/window)
  56. right (+ offset-x (.-width content-rect) 16)
  57. offset-x (if (> right vp-w) (- offset-x (- right vp-w)) offset-x)]
  58. offset-x)
  59. (* 0.5 (- (.-width anchor-rect) (.-width content-rect)))))
  60. offset-y (when (and anchor-rect content-rect)
  61. (+ (.-y anchor-rect) (.-height anchor-rect) 8))
  62. click-outside-ref (use-click-outside outside-click-hander)
  63. [d-open set-d-open] (rum/use-state false)
  64. _ (rum/use-effect! (fn [] (js/setTimeout #(set-d-open show?) 100))
  65. [show?])]
  66. [:div.inline-block.dropdown-anchor {:ref anchor-ref}
  67. label
  68. (if portal?
  69. ;; FIXME: refactor the following code
  70. (ui/portal
  71. [:div.fixed.shadow-lg.color-level.px-2.rounded-lg.transition.md:w-64.lg:w-128.overflow-auto
  72. {:ref (juxt content-ref click-outside-ref)
  73. :style {:opacity (if d-open 1 0)
  74. :pointer-events (if d-open "auto" "none")
  75. :transform (str "translateY(" (if d-open 0 10) "px)")
  76. :min-height "40px"
  77. :max-height "420px"
  78. :left offset-x
  79. :top offset-y}}
  80. (when d-open children)])
  81. [:div.absolute.shadow-lg.color-level.px-2.rounded-lg.transition.md:w-64.lg:w-128.overflow-auto
  82. {:ref (juxt content-ref click-outside-ref)
  83. :style {:opacity (if d-open 1 0)
  84. :pointer-events (if d-open "auto" "none")
  85. :transform (str "translateY(" (if d-open 0 10) "px)")
  86. :min-height "40px"
  87. :max-height "420px"
  88. :left offset-x}}
  89. (when d-open children)])]))
  90. (rum/defc dropdown-menu
  91. [{:keys [label children classname hover? portal?]}]
  92. (let [[open-flag set-open-flag] (rum/use-state 0)
  93. open? (> open-flag (if hover? 0 1))
  94. d-open-flag (rum/use-memo #(util/debounce 200 set-open-flag) [])]
  95. (dropdown
  96. [:div {:class (str classname (when open? " open"))
  97. :on-mouse-enter (fn [] (d-open-flag #(if (= % 0) 1 %)))
  98. :on-mouse-leave (fn [] (d-open-flag #(if (= % 2) % 0)))
  99. :on-click (fn [e]
  100. (util/stop e)
  101. (d-open-flag (fn [o] (if (not= o 2) 2 0))))}
  102. (if (fn? label) (label open?) label)]
  103. children open? #(set-open-flag 0) portal?)))
  104. ;; TODO: move to frontend.components.reference
  105. ;; TODO: reactivity when ref count change
  106. (rum/defc references-count < rum/static
  107. "Shows a references count for any block or page.
  108. When clicked, a dropdown menu will show the reference details"
  109. ([page-name-or-uuid classname]
  110. (references-count page-name-or-uuid classname nil))
  111. ([page-name-or-uuid classname {:keys [render-fn
  112. hover?
  113. portal?]
  114. :or {portal? true}}]
  115. (let [page-entity (model/get-page page-name-or-uuid)
  116. block-uuid (:block/uuid page-entity)
  117. refs-count (count (:block/_refs page-entity))]
  118. (when (> refs-count 0)
  119. (dropdown-menu {:classname classname
  120. :label (fn [open?]
  121. [:div.inline-flex.items-center.gap-2
  122. [:div.open-page-ref-link refs-count]
  123. (when render-fn (render-fn open? refs-count))])
  124. :hover? hover?
  125. :portal? portal?
  126. :children (reference/block-linked-references block-uuid)})))))
  127. (defn- get-page-display-name
  128. [page-name]
  129. (let [page-entity (model/get-page page-name)]
  130. (or
  131. (get-in page-entity [:block/properties :title] nil)
  132. (:block/original-name page-entity)
  133. page-name)))
  134. ;; This is not accurate yet
  135. (defn- get-page-human-update-time
  136. [page-name]
  137. (let [page-entity (model/get-page page-name)
  138. {:block/keys [updated-at created-at]} page-entity]
  139. (str (if (= created-at updated-at) "Created " "Edited ")
  140. (util/time-ago (js/Date. updated-at)))))
  141. (rum/defc dashboard-preview-card
  142. [page-name {:keys [checked on-checked-change show-checked?]}]
  143. [:div.dashboard-card.dashboard-preview-card.cursor-pointer.hover:shadow-lg
  144. {:data-checked checked
  145. :style {:filter (if (and show-checked? (not checked)) "opacity(0.5)" "none")}
  146. :on-click
  147. (fn [e]
  148. (util/stop e)
  149. (if show-checked?
  150. (on-checked-change (not checked))
  151. (route-handler/redirect-to-whiteboard! page-name)))}
  152. [:div.dashboard-card-title
  153. [:div.flex.w-full.items-center
  154. [:div.dashboard-card-title-name.font-bold
  155. (if (parse-uuid page-name)
  156. [:span.opacity-50 (t :untitled)]
  157. (get-page-display-name page-name))]
  158. [:div.flex-1]
  159. [:div.dashboard-card-checkbox
  160. {:tab-index -1
  161. :style {:visibility (when show-checked? "visible")}
  162. :on-click util/stop-propagation}
  163. (ui/checkbox {:checked checked
  164. :on-change (fn [] (on-checked-change (not checked)))})]]
  165. [:div.flex.w-full.opacity-50
  166. [:div (get-page-human-update-time page-name)]
  167. [:div.flex-1]
  168. (references-count page-name nil {:hover? true})]]
  169. [:div.p-4.h-64.flex.justify-center
  170. (tldraw-preview page-name)]])
  171. (rum/defc dashboard-create-card
  172. []
  173. [:div.dashboard-card.dashboard-create-card.cursor-pointer#tl-create-whiteboard
  174. {:on-click
  175. (fn [e]
  176. (util/stop e)
  177. (whiteboard-handler/create-new-whiteboard-and-redirect!))}
  178. (ui/icon "plus")
  179. [:span.dashboard-create-card-caption.select-none
  180. "New whiteboard"]])
  181. (rum/defc whiteboard-dashboard
  182. []
  183. (if (state/enable-whiteboards?)
  184. (let [whiteboards (->> (model/get-all-whiteboards (state/get-current-repo))
  185. (sort-by :block/updated-at)
  186. reverse)
  187. whiteboard-names (map :block/name whiteboards)
  188. [ref rect] (use-bounding-client-rect)
  189. [container-width] (when rect [(.-width rect) (.-height rect)])
  190. cols (cond (< container-width 600) 1
  191. (< container-width 900) 2
  192. (< container-width 1200) 3
  193. :else 4)
  194. total-whiteboards (count whiteboards)
  195. empty-cards (- (max (* (math/ceil (/ (inc total-whiteboards) cols)) cols) (* 2 cols))
  196. (inc total-whiteboards))
  197. [checked-page-names set-checked-page-names] (rum/use-state #{})
  198. has-checked? (not-empty checked-page-names)]
  199. [:<>
  200. [:h1.select-none.flex.items-center.whiteboard-dashboard-title.title
  201. [:div "All whiteboards"
  202. [:span.opacity-50
  203. (str " · " total-whiteboards)]]
  204. [:div.flex-1]
  205. (when has-checked?
  206. [:button.ui__button.m-0.py-1.inline-flex.items-center.bg-red-800
  207. {:on-click
  208. (fn []
  209. (state/set-modal! (page/batch-delete-dialog
  210. (map (fn [name]
  211. (some (fn [w] (when (= (:block/name w) name) w)) whiteboards))
  212. checked-page-names)
  213. false route-handler/redirect-to-whiteboard-dashboard!)))}
  214. [:span.flex.gap-2.items-center
  215. [:span.opacity-50 (ui/icon "trash" {:style {:font-size 15}})]
  216. (t :delete)
  217. [:span.opacity-50
  218. (str " · " (count checked-page-names))]]])]
  219. [:div
  220. {:ref ref}
  221. [:div.gap-8.grid.grid-rows-auto
  222. {:style {:visibility (when (nil? container-width) "hidden")
  223. :grid-template-columns (str "repeat(" cols ", minmax(0, 1fr))")}}
  224. (dashboard-create-card)
  225. (for [whiteboard-name whiteboard-names]
  226. [:<> {:key whiteboard-name}
  227. (dashboard-preview-card whiteboard-name
  228. {:show-checked? has-checked?
  229. :checked (boolean (checked-page-names whiteboard-name))
  230. :on-checked-change (fn [checked]
  231. (set-checked-page-names (if checked
  232. (conj checked-page-names whiteboard-name)
  233. (disj checked-page-names whiteboard-name))))})])
  234. (for [n (range empty-cards)]
  235. [:div.dashboard-card.dashboard-bg-card {:key n}])]]])
  236. [:div "This feature is not publicly available yet."]))
  237. (rum/defc whiteboard-page
  238. [page-name block-id]
  239. (let [[ref bp] (use-breakpoint)]
  240. [:div.absolute.w-full.h-full.whiteboard-page
  241. ;; makes sure the whiteboard will not cover the borders
  242. {:key page-name
  243. :ref ref
  244. :data-breakpoint (name bp)
  245. :style {:padding "0.5px" :z-index 0
  246. :transform "translateZ(0)"
  247. :text-rendering "geometricPrecision"
  248. :-webkit-font-smoothing "subpixel-antialiased"}}
  249. [:div.whiteboard-page-title-root
  250. [:div.whiteboard-page-title
  251. {:style {:color "var(--ls-primary-text-color)"
  252. :user-select "none"}
  253. :on-context-menu (fn [e]
  254. (util/stop e)
  255. (common-handler/show-custom-context-menu!
  256. e
  257. (content/page-title-custom-context-menu-content page-name))
  258. (state/set-state! :page-title/context nil))}
  259. (page/page-title page-name
  260. [:span.text-lg
  261. (ui/icon "whiteboard" {:extension? true})]
  262. (get-page-display-name page-name)
  263. nil
  264. false)]
  265. [:div.whiteboard-page-refs
  266. (references-count page-name
  267. "text-md px-3 py-2 cursor-default whiteboard-page-refs-count"
  268. {:hover? true
  269. :render-fn (fn [open? refs-count] [:span.whiteboard-page-refs-count-label
  270. (if (> refs-count 1) "References" "Reference")
  271. (ui/icon (if open? "references-hide" "references-show")
  272. {:extension? true})])})]]
  273. (tldraw-app page-name block-id)]))
  274. (rum/defc whiteboard-route
  275. [route-match]
  276. (when (user-handler/alpha-user?)
  277. (let [name (get-in route-match [:parameters :path :name])
  278. {:keys [block-id]} (get-in route-match [:parameters :query])]
  279. (whiteboard-page name block-id))))