editor.cljs 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553
  1. (ns frontend.components.editor
  2. (:require [clojure.string :as string]
  3. [goog.string :as gstring]
  4. [frontend.commands :as commands
  5. :refer [*angle-bracket-caret-pos *first-command-group *matched-block-commands *matched-commands *show-block-commands *show-commands *slash-caret-pos]]
  6. [frontend.components.block :as block]
  7. [frontend.components.datetime :as datetime-comp]
  8. [frontend.components.search :as search]
  9. [frontend.components.svg :as svg]
  10. [frontend.mobile.camera :as mobile-camera]
  11. [frontend.mobile.util :as mobile-util]
  12. [frontend.config :as config]
  13. [frontend.db :as db]
  14. [frontend.db.model :as db-model]
  15. [frontend.extensions.zotero :as zotero]
  16. [frontend.handler.editor :as editor-handler :refer [get-state]]
  17. [frontend.handler.editor.lifecycle :as lifecycle]
  18. [frontend.handler.page :as page-handler]
  19. [frontend.mixins :as mixins]
  20. [frontend.modules.shortcut.core :as shortcut]
  21. [frontend.state :as state]
  22. [frontend.ui :as ui]
  23. [frontend.util :as util]
  24. [frontend.util.cursor :as cursor]
  25. [frontend.util.keycode :as keycode]
  26. [goog.dom :as gdom]
  27. [promesa.core :as p]
  28. [rum.core :as rum]
  29. [frontend.handler.history :as history]
  30. [frontend.mobile.footer :as footer]
  31. [frontend.handler.config :as config-handler]))
  32. (rum/defc commands < rum/reactive
  33. [id format]
  34. (let [matched (util/react *matched-commands)]
  35. (when (util/react *show-commands)
  36. (ui/auto-complete
  37. matched
  38. {:get-group-name
  39. (fn [item]
  40. (get *first-command-group (first item)))
  41. :item-render
  42. (fn [item]
  43. (let [command-name (first item)
  44. command-doc (get item 2)
  45. doc (when (state/show-command-doc?) command-doc)]
  46. (cond
  47. (string? doc)
  48. [:div {:title doc}
  49. command-name]
  50. (vector? doc)
  51. [:div.has-help
  52. command-name
  53. (ui/tippy
  54. {:html doc
  55. :interactive true
  56. :fixed-position? true
  57. :position "right"}
  58. [:small (svg/help-circle)])]
  59. :else
  60. [:div command-name])))
  61. :on-chosen
  62. (fn [chosen-item]
  63. (let [command (first chosen-item)]
  64. (reset! commands/*current-command command)
  65. (let [command-steps (get (into {} matched) command)
  66. restore-slash? (or
  67. (contains? #{"Today" "Yesterday" "Tomorrow" "Current time"} command)
  68. (and
  69. (not (fn? command-steps))
  70. (not (contains? (set (map first command-steps)) :editor/input))
  71. (not (contains? #{"Date picker" "Template" "Deadline" "Scheduled" "Upload an image"} command))))]
  72. (editor-handler/insert-command! id command-steps
  73. format
  74. {:restore? restore-slash?})
  75. (state/pub-event! [:instrument {:type :editor/command-triggered
  76. :payload {:command command}}]))))
  77. :class
  78. "black"}))))
  79. (rum/defc block-commands < rum/reactive
  80. [id format]
  81. (when (util/react *show-block-commands)
  82. (let [matched (util/react *matched-block-commands)]
  83. (ui/auto-complete
  84. (map first matched)
  85. {:on-chosen (fn [chosen]
  86. (editor-handler/insert-command! id (get (into {} matched) chosen)
  87. format
  88. {:last-pattern commands/angle-bracket}))
  89. :class "black"}))))
  90. (defn- in-sidebar? [el]
  91. (not (.contains (.getElementById js/document "left-container") el)))
  92. (rum/defc page-search < rum/reactive
  93. {:will-unmount (fn [state] (reset! editor-handler/*selected-text nil) state)}
  94. "Embedded page searching popup"
  95. [id format]
  96. (when (state/sub :editor/show-page-search?)
  97. (let [pos (state/get-editor-last-pos)
  98. input (gdom/getElement id)]
  99. (when input
  100. (let [current-pos (cursor/pos input)
  101. edit-content (or (state/sub [:editor/content id]) "")
  102. sidebar? (in-sidebar? input)
  103. q (or
  104. @editor-handler/*selected-text
  105. (when (state/sub :editor/show-page-search-hashtag?)
  106. (util/safe-subs edit-content pos current-pos))
  107. (when (> (count edit-content) current-pos)
  108. (util/safe-subs edit-content pos current-pos))
  109. "")
  110. matched-pages (when-not (string/blank? q)
  111. (editor-handler/get-matched-pages q))
  112. matched-pages (cond
  113. (contains? (set (map util/page-name-sanity-lc matched-pages)) (util/page-name-sanity-lc (string/trim q))) ;; if there's a page name fully matched
  114. matched-pages
  115. (string/blank? q)
  116. nil
  117. (empty? matched-pages)
  118. (cons (str "New page: " q) matched-pages)
  119. ;; reorder, shortest and starts-with first.
  120. :else
  121. (let [matched-pages (remove nil? matched-pages)
  122. matched-pages (sort-by
  123. (fn [m]
  124. [(not (gstring/caseInsensitiveStartsWith m q)) (count m)])
  125. matched-pages)]
  126. (if (gstring/caseInsensitiveStartsWith (first matched-pages) q)
  127. (cons (first matched-pages)
  128. (cons (str "New page: " q) (rest matched-pages)))
  129. (cons (str "New page: " q) matched-pages))))]
  130. (ui/auto-complete
  131. matched-pages
  132. {:on-chosen (page-handler/on-chosen-handler input id q pos format)
  133. :on-enter #(page-handler/page-not-exists-handler input id q current-pos)
  134. :item-render (fn [page-name chosen?]
  135. [:div.preview-trigger-wrapper
  136. (block/page-preview-trigger
  137. {:children [:div (search/highlight-exact-query page-name q)]
  138. :open? chosen?
  139. :manual? true
  140. :fixed-position? true
  141. :tippy-distance 24
  142. :tippy-position (if sidebar? "left" "right")}
  143. page-name)])
  144. :empty-placeholder [:div.text-gray-500.text-sm.px-4.py-2 "Search for a page"]
  145. :class "black"}))))))
  146. (rum/defcs block-search-auto-complete < rum/reactive
  147. {:init (fn [state]
  148. (assoc state ::result (atom nil)))
  149. :did-update (fn [state]
  150. (let [result (::result state)
  151. [edit-block _ _ q] (:rum/args state)]
  152. (p/let [matched-blocks (when-not (string/blank? q)
  153. (editor-handler/get-matched-blocks q (:block/uuid edit-block)))]
  154. (reset! result matched-blocks)))
  155. state)}
  156. [state _edit-block input id q format]
  157. (let [result (rum/react (get state ::result))
  158. chosen-handler (editor-handler/block-on-chosen-handler input id q format)
  159. non-exist-block-handler (editor-handler/block-non-exist-handler input)]
  160. (when result
  161. (ui/auto-complete
  162. result
  163. {:on-chosen chosen-handler
  164. :on-enter non-exist-block-handler
  165. :empty-placeholder [:div.text-gray-500.pl-4.pr-4 "Search for a block"]
  166. :item-render (fn [{:block/keys [page uuid]}] ;; content returned from search engine is normalized
  167. (let [page (or (:block/original-name page)
  168. (:block/name page))
  169. repo (state/sub :git/current-repo)
  170. format (db/get-page-format page)
  171. block (db-model/query-block-by-uuid uuid)
  172. content (:block/content block)]
  173. [:.py-2 (search/block-search-result-item repo uuid format content q :block)]))
  174. :class "black"}))))
  175. (rum/defcs block-search < rum/reactive
  176. {:will-unmount (fn [state]
  177. (reset! editor-handler/*selected-text nil)
  178. (state/clear-search-result!)
  179. state)}
  180. [state id _format]
  181. (when (state/sub :editor/show-block-search?)
  182. (let [pos (state/get-editor-last-pos)
  183. input (gdom/getElement id)
  184. [id format] (:rum/args state)
  185. current-pos (cursor/pos input)
  186. edit-content (state/sub [:editor/content id])
  187. edit-block (state/get-edit-block)
  188. q (or
  189. @editor-handler/*selected-text
  190. (when (> (count edit-content) current-pos)
  191. (subs edit-content pos current-pos)))]
  192. (when input
  193. (block-search-auto-complete edit-block input id q format)))))
  194. (rum/defc template-search < rum/reactive
  195. {:will-unmount (fn [state] (reset! editor-handler/*selected-text nil) state)}
  196. [id _format]
  197. (when (state/sub :editor/show-template-search?)
  198. (let [pos (state/get-editor-last-pos)
  199. input (gdom/getElement id)]
  200. (when input
  201. (let [current-pos (cursor/pos input)
  202. edit-content (state/sub [:editor/content id])
  203. q (or
  204. (when (>= (count edit-content) current-pos)
  205. (subs edit-content pos current-pos))
  206. "")
  207. matched-templates (editor-handler/get-matched-templates q)
  208. non-exist-handler (fn [_state]
  209. (state/set-editor-show-template-search! false))]
  210. (ui/auto-complete
  211. matched-templates
  212. {:on-chosen (editor-handler/template-on-chosen-handler id)
  213. :on-enter non-exist-handler
  214. :empty-placeholder [:div.text-gray-500.px-4.py-2.text-sm "Search for a template"]
  215. :item-render (fn [[template _block-db-id]]
  216. template)
  217. :class "black"}))))))
  218. (rum/defcs input < rum/reactive
  219. (rum/local {} ::input-value)
  220. (mixins/event-mixin
  221. (fn [state]
  222. (mixins/on-key-down
  223. state
  224. {;; enter
  225. 13 (fn [state e]
  226. (let [input-value (get state ::input-value)
  227. input-option (get @state/state :editor/show-input)]
  228. (when (seq @input-value)
  229. ;; no new line input
  230. (util/stop e)
  231. (let [[_id on-submit] (:rum/args state)
  232. {:keys [pos]} @*slash-caret-pos
  233. command (:command (first input-option))]
  234. (on-submit command @input-value pos))
  235. (reset! input-value nil))))})))
  236. [state _id on-submit]
  237. (when-let [input-option (state/sub :editor/show-input)]
  238. (let [{:keys [pos]} (util/react *slash-caret-pos)
  239. input-value (get state ::input-value)]
  240. (when (seq input-option)
  241. (let [command (:command (first input-option))]
  242. [:div.p-2.rounded-md.shadow-lg
  243. (for [{:keys [id placeholder type autoFocus] :as input-item} input-option]
  244. [:div.my-3 {:key id}
  245. [:input.form-input.block.w-full.pl-2.sm:text-sm.sm:leading-5
  246. (merge
  247. (cond->
  248. {:key (str "modal-input-" (name id))
  249. :id (str "modal-input-" (name id))
  250. :type (or type "text")
  251. :on-change (fn [e]
  252. (swap! input-value assoc id (util/evalue e)))
  253. :auto-complete (if (util/chrome?) "chrome-off" "off")}
  254. placeholder
  255. (assoc :placeholder placeholder)
  256. autoFocus
  257. (assoc :auto-focus true))
  258. (dissoc input-item :id))]])
  259. (ui/button
  260. "Submit"
  261. :on-click
  262. (fn [e]
  263. (util/stop e)
  264. (on-submit command @input-value pos)))])))))
  265. (rum/defc absolute-modal < rum/static
  266. [cp set-default-width? {:keys [top left rect]}]
  267. (let [max-height 370
  268. max-width 300
  269. offset-top 24
  270. vw-height js/window.innerHeight
  271. vw-width js/window.innerWidth
  272. to-max-height (if (and (seq rect) (> vw-height max-height))
  273. (let [delta-height (- vw-height (+ (:top rect) top offset-top))]
  274. (if (< delta-height max-height)
  275. (- (max (* 2 offset-top) delta-height) 16)
  276. max-height))
  277. max-height)
  278. right-sidebar? (:ui/sidebar-open? @state/state)
  279. editing-key (first (keys (:editor/editing? @state/state)))
  280. *el (rum/use-ref nil)
  281. _ (rum/use-effect! (fn []
  282. (when-let [^js/HTMLElement cnt
  283. (and right-sidebar? editing-key
  284. (js/document.querySelector "#main-content-container"))]
  285. (when (.contains cnt (js/document.querySelector (str "#" editing-key)))
  286. (let [el (rum/deref *el)
  287. ofx (- (.-scrollWidth cnt) (.-clientWidth cnt))]
  288. (when (> ofx 0)
  289. (set! (.-transform (.-style el)) (str "translateX(-" (+ ofx 20) "px)")))))))
  290. [right-sidebar? editing-key])
  291. x-overflow-vw? (when (and (seq rect) (> vw-width max-width))
  292. (let [delta-width (- vw-width (+ (:left rect) left))]
  293. (< delta-width (* max-width 0.5))))
  294. pos-rect (when (and (seq rect) editing-key)
  295. (:rect (cursor/get-caret-pos (state/get-input))))
  296. y-diff (when pos-rect (- (:height pos-rect) (:height rect)))]
  297. [:div.absolute.rounded-md.shadow-lg.absolute-modal
  298. {:ref *el
  299. :class (if x-overflow-vw? "is-overflow-vw-x" "")
  300. :on-mouse-down (fn [e]
  301. (.stopPropagation e))
  302. :style (merge
  303. {:top (+ top offset-top (if (int? y-diff) y-diff 0))
  304. :max-height to-max-height
  305. :max-width 700
  306. ;; TODO: auto responsive fixed size
  307. :width "fit-content"
  308. :z-index 11}
  309. (when set-default-width?
  310. {:width max-width})
  311. (let [^js/HTMLElement textarea
  312. (js/document.querySelector "textarea.ls-textarea")]
  313. (if (<= (.-clientWidth textarea) (+ left (if set-default-width? max-width 500)))
  314. {:right 0}
  315. {:left (if (and y-diff (= y-diff 0)) left 0)})))}
  316. cp]))
  317. (rum/defc transition-cp < rum/reactive
  318. [cp set-default-width? pos]
  319. (when pos
  320. (when-let [pos (rum/react pos)]
  321. (ui/css-transition
  322. {:class-names "fade"
  323. :timeout {:enter 500
  324. :exit 300}}
  325. (absolute-modal cp set-default-width? pos)))))
  326. (rum/defc image-uploader < rum/reactive
  327. [id format]
  328. [:div.image-uploader
  329. [:input
  330. {:id "upload-file"
  331. :type "file"
  332. :on-change (fn [e]
  333. (let [files (.-files (.-target e))]
  334. (editor-handler/upload-asset id files format editor-handler/*asset-uploading? false)))
  335. :hidden true}]
  336. #_:clj-kondo/ignore
  337. (when-let [uploading? (util/react editor-handler/*asset-uploading?)]
  338. (let [processing (util/react editor-handler/*asset-uploading-process)]
  339. (transition-cp
  340. [:div.flex.flex-row.align-center.rounded-md.shadow-sm.bg-base-2.px-1.py-1
  341. (ui/loading
  342. (util/format "Uploading %s%" (util/format "%2d" processing)))]
  343. false
  344. *slash-caret-pos)))])
  345. (defn- set-up-key-down!
  346. [state format]
  347. (mixins/on-key-down
  348. state
  349. {}
  350. {:not-matched-handler (editor-handler/keydown-not-matched-handler format)}))
  351. (defn- set-up-key-up!
  352. [state input input-id search-timeout]
  353. (mixins/on-key-up
  354. state
  355. {}
  356. (editor-handler/keyup-handler state input input-id search-timeout)))
  357. (def search-timeout (atom nil))
  358. (defn- setup-key-listener!
  359. [state]
  360. (let [{:keys [id format]} (get-state)
  361. input-id id
  362. input (gdom/getElement input-id)]
  363. (set-up-key-down! state format)
  364. (set-up-key-up! state input input-id search-timeout)))
  365. (def starts-with? clojure.string/starts-with?)
  366. (defn get-editor-style-class
  367. "Get textarea css class according to it's content"
  368. [content format]
  369. (let [content (if content (str content) "")]
  370. ;; as the function is binding to the editor content, optimization is welcome
  371. (str
  372. "ls-textarea "
  373. (if (or (> (.-length content) 1000)
  374. (string/includes? content "\n"))
  375. "multiline-block"
  376. "uniline-block")
  377. " "
  378. (case format
  379. :markdown
  380. (cond
  381. (starts-with? content "# ") "h1"
  382. (starts-with? content "## ") "h2"
  383. (starts-with? content "### ") "h3"
  384. (starts-with? content "#### ") "h4"
  385. (starts-with? content "##### ") "h5"
  386. (starts-with? content "###### ") "h6"
  387. (and (starts-with? content "---\n") (.endsWith content "\n---")) "page-properties"
  388. :else "normal-block")
  389. ;; other formats
  390. (cond
  391. (and (starts-with? content "---\n") (.endsWith content "\n---")) "page-properties"
  392. :else "normal-block")))))
  393. (defn editor-row-height-unchanged?
  394. "Check if the row height of editor textarea is changed, which happens when font-size changed"
  395. []
  396. ;; FIXME: assuming enter key is the only trigger of the height changing (under markdown editing of headlines)
  397. ;; FIXME: looking for an elegant & robust way to track the change of font-size, or wait for our own WYSIWYG text area
  398. (let [last-key (state/get-last-key-code)]
  399. (and (not= keycode/enter (:key-code last-key))
  400. (not= keycode/enter-code (:code last-key)))))
  401. (rum/defc mock-textarea <
  402. rum/static
  403. {:did-update
  404. (fn [state]
  405. (when-not (:editor/on-paste? @state/state)
  406. (try (editor-handler/handle-last-input)
  407. (catch js/Error _e
  408. nil)))
  409. (state/set-state! :editor/on-paste? false)
  410. state)}
  411. [content]
  412. [:div#mock-text
  413. {:style {:width "100%"
  414. :height "100%"
  415. :position "absolute"
  416. :visibility "hidden"
  417. :top 0
  418. :left 0}}
  419. (let [content (str content "0")]
  420. (for [[idx c] (map-indexed
  421. vector
  422. (string/split content ""))]
  423. (if (= c "\n")
  424. [:span {:id (str "mock-text_" idx)
  425. :key idx} "0" [:br]]
  426. [:span {:id (str "mock-text_" idx)
  427. :key idx} c])))])
  428. (rum/defc mock-textarea-wrapper < rum/reactive
  429. []
  430. (let [content (state/sub-edit-content)]
  431. (mock-textarea content)))
  432. (defn animated-modal
  433. [key component set-default-width? *pos]
  434. (when *pos
  435. (ui/css-transition
  436. {:key key
  437. :class-names {:enter "origin-top-left opacity-0 transform scale-95"
  438. :enter-done "origin-top-left transition opacity-100 transform scale-100"
  439. :exit "origin-top-left transition opacity-0 transform scale-95"}
  440. :timeout {:enter 0
  441. :exit 150}}
  442. (fn [_]
  443. (absolute-modal
  444. component
  445. set-default-width?
  446. *pos)))))
  447. (rum/defc modals < rum/reactive
  448. "React to atom changes, find and render the correct modal"
  449. [id format]
  450. (ui/transition-group
  451. (cond
  452. (and (util/react *show-commands)
  453. (not (state/sub :editor/show-page-search?))
  454. (not (state/sub :editor/show-block-search?))
  455. (not (state/sub :editor/show-template-search?))
  456. (not (state/sub :editor/show-input))
  457. (not (state/sub :editor/show-zotero))
  458. (not (state/sub :editor/show-date-picker?)))
  459. (animated-modal "commands" (commands id format) true (util/react *slash-caret-pos))
  460. (and (util/react *show-block-commands) @*angle-bracket-caret-pos)
  461. (animated-modal "block-commands" (block-commands id format) true (util/react *angle-bracket-caret-pos))
  462. (state/sub :editor/show-page-search?)
  463. (animated-modal "page-search" (page-search id format) true (util/react *slash-caret-pos))
  464. (state/sub :editor/show-block-search?)
  465. (animated-modal "block-search" (block-search id format) false (util/react *slash-caret-pos))
  466. (state/sub :editor/show-template-search?)
  467. (animated-modal "template-search" (template-search id format) true (util/react *slash-caret-pos))
  468. (state/sub :editor/show-date-picker?)
  469. (animated-modal "date-picker" (datetime-comp/date-picker id format nil) false (util/react *slash-caret-pos))
  470. (state/sub :editor/show-input)
  471. (animated-modal "input" (input id
  472. (fn [command m _pos]
  473. (editor-handler/handle-command-input command id format m)))
  474. true (util/react *slash-caret-pos))
  475. (state/sub :editor/show-zotero)
  476. (animated-modal "zotero-search" (zotero/zotero-search id) false (util/react *slash-caret-pos))
  477. :else
  478. nil)))
  479. (rum/defcs box < rum/reactive
  480. {:init (fn [state]
  481. (assoc state ::heading-level (:heading-level (first (:rum/args state)))
  482. ::id (str (random-uuid))))
  483. :did-mount (fn [state]
  484. (state/set-editor-args! (:rum/args state))
  485. state)}
  486. (mixins/event-mixin setup-key-listener!)
  487. (shortcut/mixin :shortcut.handler/block-editing-only)
  488. lifecycle/lifecycle
  489. [state {:keys [format block]} id config]
  490. (let [content (state/sub-edit-content)
  491. heading-class (get-editor-style-class content format)]
  492. [:div.editor-inner {:class (if block "block-editor" "non-block-editor")}
  493. (when (= (state/sub :editor/record-status) "RECORDING")
  494. [:div#audio-record-toolbar
  495. (footer/audio-record-cp)])
  496. (ui/ls-textarea
  497. {:id id
  498. :cacheMeasurements (editor-row-height-unchanged?) ;; check when content updated (as the content variable is binded)
  499. :default-value (or content "")
  500. :minRows (if (state/enable-grammarly?) 2 1)
  501. :on-click (editor-handler/editor-on-click! id)
  502. :on-change (editor-handler/editor-on-change! block id search-timeout)
  503. :on-paste (editor-handler/editor-on-paste! id)
  504. :on-height-change (editor-handler/editor-on-height-change! id)
  505. :auto-focus false
  506. :class heading-class})
  507. (mock-textarea-wrapper)
  508. (modals id format)
  509. (when format
  510. (image-uploader id format))]))