editor.cljs 35 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801
  1. (ns frontend.components.editor
  2. (:require [clojure.string :as string]
  3. [datascript.impl.entity :as de]
  4. [dommy.core :as dom]
  5. [frontend.commands :as commands :refer [*matched-commands]]
  6. [frontend.components.file-based.datetime :as datetime-comp]
  7. [frontend.components.search :as search]
  8. [frontend.components.svg :as svg]
  9. [frontend.config :as config]
  10. [frontend.context.i18n :refer [t]]
  11. [frontend.date :as date]
  12. [frontend.db :as db]
  13. [frontend.db.model :as db-model]
  14. [frontend.extensions.zotero :as zotero]
  15. [frontend.handler.block :as block-handler]
  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.handler.paste :as paste-handler]
  20. [frontend.handler.property.util :as pu]
  21. [frontend.handler.search :as search-handler]
  22. [frontend.hooks :as hooks]
  23. [frontend.mixins :as mixins]
  24. [frontend.search :refer [fuzzy-search]]
  25. [frontend.state :as state]
  26. [frontend.ui :as ui]
  27. [frontend.util :as util]
  28. [frontend.util.cursor :as cursor]
  29. [frontend.util.keycode :as keycode]
  30. [goog.dom :as gdom]
  31. [goog.string :as gstring]
  32. [logseq.common.util :as common-util]
  33. [logseq.db :as ldb]
  34. [logseq.db.frontend.class :as db-class]
  35. [logseq.graph-parser.property :as gp-property]
  36. [logseq.shui.popup.core :as shui-popup]
  37. [logseq.shui.ui :as shui]
  38. [promesa.core :as p]
  39. [react-draggable]
  40. [rum.core :as rum]))
  41. (defn filter-commands
  42. [page? commands]
  43. (if page?
  44. (filter (fn [item]
  45. (or
  46. (= "Add new property" (first item))
  47. (when (= (count item) 5)
  48. (contains? #{"TASK" "PRIORITY"} (last item))))) commands)
  49. commands))
  50. (rum/defcs commands < rum/reactive
  51. (rum/local [] ::matched-commands)
  52. [s id format]
  53. (let [matched' (util/react *matched-commands)
  54. *matched (::matched-commands s)
  55. _ (when (state/get-editor-action)
  56. (reset! *matched matched'))
  57. page? (db/page? (db/entity (:db/id (state/get-edit-block))))
  58. matched (filter-commands page? @*matched)]
  59. (ui/auto-complete
  60. matched
  61. {:get-group-name
  62. (fn [item]
  63. (when (= (count item) 5) (last item)))
  64. :item-render
  65. (fn [item]
  66. (let [command-name (first item)
  67. command-doc (get item 2)
  68. plugin-id (get-in item [1 1 1 :pid])
  69. doc (when (state/show-command-doc?) command-doc)
  70. options (some-> item (get 3))
  71. icon-name (some-> (if (map? options) (:icon options) options) (name))
  72. command-name (if icon-name
  73. [:span.flex.items-center.gap-1
  74. (shui/tabler-icon icon-name)
  75. [:strong.font-normal command-name]]
  76. command-name)]
  77. (cond
  78. (or plugin-id (vector? doc))
  79. [:div.has-help
  80. {:title plugin-id}
  81. command-name
  82. (when doc (ui/tippy
  83. {:html doc
  84. :interactive true
  85. :fixed-position? true
  86. :position "right"}
  87. [:small (svg/help-circle)]))]
  88. (string? doc)
  89. [:div {:title doc}
  90. command-name]
  91. :else
  92. [:div command-name])))
  93. :on-chosen
  94. (fn [chosen-item]
  95. (let [command (first chosen-item)]
  96. (reset! commands/*current-command command)
  97. (let [command-steps (get (into {} matched) command)
  98. restore-slash? (or
  99. (contains? #{"Today" "Yesterday" "Tomorrow" "Current time"} command)
  100. (and
  101. (not (fn? command-steps))
  102. (not (contains? (set (map first command-steps)) :editor/input))
  103. (not (contains? #{"Date picker" "Template" "Deadline" "Scheduled" "Upload an image"} command))))]
  104. (editor-handler/insert-command! id command-steps
  105. format
  106. {:restore? restore-slash?
  107. :command command}))))
  108. :class
  109. "cp__commands-slash"})))
  110. (defn- page-on-chosen-handler
  111. [embed? input id q pos format]
  112. (if embed?
  113. (fn [chosen-item _e]
  114. (let [value (.-value input)
  115. value' (str (common-util/safe-subs value 0 q)
  116. (common-util/safe-subs value (+ (count q) 4 pos)))]
  117. (state/set-edit-content! (.-id input) value')
  118. (state/clear-editor-action!)
  119. (p/let [page (db/get-page chosen-item)
  120. _ (when-not page (page-handler/<create! chosen-item {:redirect? false
  121. :create-first-block? false}))
  122. page' (db/get-page chosen-item)
  123. current-block (state/get-edit-block)]
  124. (editor-handler/api-insert-new-block! chosen-item
  125. {:block-uuid (:block/uuid current-block)
  126. :sibling? true
  127. :replace-empty-target? true
  128. :other-attrs {:block/link (:db/id page')}}))))
  129. (page-handler/on-chosen-handler input id pos format)))
  130. (rum/defc page-search-aux
  131. [id format embed? db-tag? q current-pos input pos]
  132. (let [db? (config/db-based-graph? (state/get-current-repo))
  133. q (string/trim q)
  134. [matched-pages set-matched-pages!] (rum/use-state nil)
  135. search-f (fn []
  136. (when-not (string/blank? q)
  137. (p/let [result (if db-tag?
  138. (editor-handler/get-matched-classes q)
  139. (editor-handler/<get-matched-blocks q {:nlp-pages? true}))]
  140. (set-matched-pages! result))))]
  141. (rum/use-effect! search-f [(hooks/use-debounced-value q 50)])
  142. (let [matched-pages' (if (string/blank? q)
  143. (if db-tag?
  144. (db-model/get-all-classes (state/get-current-repo) {:except-root-class? true})
  145. (->> (map (fn [title] {:block/title title
  146. :nlp-date? true})
  147. date/nlp-pages)
  148. (take 10)))
  149. ;; reorder, shortest and starts-with first.
  150. (let [matched-pages-with-new-page
  151. (fn [partial-matched-pages]
  152. (if (or (db/page-exists? q (if db-tag?
  153. #{:logseq.class/Tag}
  154. ;; Page existence here should be the same as entity-util/page?.
  155. ;; Don't show 'New page' if a page has any of these tags
  156. db-class/page-classes))
  157. (and db-tag? (some ldb/class? (:block/_alias (db/get-page q)))))
  158. partial-matched-pages
  159. (if db-tag?
  160. (concat [{:block/title (str (t :new-tag) " " q)}]
  161. partial-matched-pages)
  162. (cons {:block/title (str (t :new-page) " " q)}
  163. partial-matched-pages))))]
  164. (if (and (seq matched-pages)
  165. (gstring/caseInsensitiveStartsWith (:block/title (first matched-pages)) q))
  166. (cons (first matched-pages)
  167. (matched-pages-with-new-page (rest matched-pages)))
  168. (matched-pages-with-new-page matched-pages))))]
  169. [:<>
  170. (ui/auto-complete
  171. matched-pages'
  172. {:on-chosen (page-on-chosen-handler embed? input id q pos format)
  173. :on-enter (fn []
  174. (page-handler/page-not-exists-handler input id q current-pos))
  175. :item-render (fn [block _chosen?]
  176. (let [block (if (and (:block/uuid block) (not (de/entity? block)))
  177. (db/entity [:block/uuid (:block/uuid block)])
  178. block)]
  179. [:div.flex.flex-col
  180. (when-not (db/page? block)
  181. (when-let [breadcrumb (state/get-component :block/breadcrumb)]
  182. [:div.text-xs.opacity-70.mb-1 {:style {:margin-left 3}}
  183. (breadcrumb {:search? true} (state/get-current-repo) (:block/uuid block) {})]))
  184. [:div.flex.flex-row.items-center.gap-1
  185. (when-not db-tag?
  186. [:div.flex.items-center
  187. (cond
  188. (:nlp-date? block)
  189. (ui/icon "calendar" {:size 14})
  190. (ldb/class? block)
  191. (ui/icon "hash" {:size 14})
  192. (ldb/property? block)
  193. (ui/icon "letter-p" {:size 14})
  194. (db-model/whiteboard-page? block)
  195. (ui/icon "whiteboard" {:extension? true})
  196. (db/page? block)
  197. (ui/icon "page" {:extension? true})
  198. (or (string/starts-with? (str (:block/title block)) (t :new-tag))
  199. (string/starts-with? (str (:block/title block)) (t :new-page)))
  200. (ui/icon "plus" {:size 14})
  201. :else
  202. (ui/icon "letter-n" {:size 14}))])
  203. (let [title (if db-tag?
  204. (let [target (first (:block/_alias block))]
  205. (if (ldb/class? target)
  206. (str (:block/title block) " -> alias: " (:block/title target))
  207. (:block/title block)))
  208. (block-handler/block-unique-title block))]
  209. (search-handler/highlight-exact-query title q))]]))
  210. :empty-placeholder [:div.text-gray-500.text-sm.px-4.py-2 (if db-tag?
  211. "Search for a tag"
  212. "Search for a node")]
  213. :class "black"})
  214. (when (and db? db-tag? (not (string/blank? q)))
  215. [:p.px-1.opacity-50.text-sm
  216. [:code (if util/mac? "Cmd+Enter" "Ctrl+Enter")]
  217. [:span " to display this tag inline instead of at the end of this node."]])])))
  218. (rum/defc page-search < rum/reactive
  219. {:will-unmount (fn [state]
  220. (reset! commands/*current-command nil)
  221. state)}
  222. "Page or tag searching popup"
  223. [id format]
  224. (let [action (state/sub :editor/action)
  225. db? (config/db-based-graph? (state/get-current-repo))
  226. embed? (and db? (= @commands/*current-command "Page embed"))
  227. tag? (= action :page-search-hashtag)
  228. db-tag? (and db? tag?)]
  229. (let [pos (state/get-editor-last-pos)
  230. input (gdom/getElement id)]
  231. (when input
  232. (let [current-pos (cursor/pos input)
  233. edit-content (state/sub-edit-content)
  234. q (or
  235. (editor-handler/get-selected-text)
  236. (when (= action :page-search-hashtag)
  237. (common-util/safe-subs edit-content pos current-pos))
  238. (when (> (count edit-content) current-pos)
  239. (common-util/safe-subs edit-content pos current-pos))
  240. "")]
  241. (page-search-aux id format embed? db-tag? q current-pos input pos))))))
  242. (defn- search-blocks!
  243. [state result]
  244. (let [[_edit-block _ _ q] (:rum/args state)]
  245. (p/let [matched-blocks (when-not (string/blank? q)
  246. (editor-handler/<get-matched-blocks q))]
  247. (reset! result matched-blocks))))
  248. (defn- block-on-chosen-handler
  249. [embed? input id q format selected-text]
  250. (if embed?
  251. (fn [chosen-item]
  252. (let [pos (state/get-editor-last-pos)
  253. value (.-value input)
  254. value' (str (common-util/safe-subs value 0 q)
  255. (common-util/safe-subs value (+ (count q) 4 pos)))]
  256. (state/set-edit-content! (.-id input) value')
  257. (state/clear-editor-action!)
  258. (let [current-block (state/get-edit-block)
  259. id (:block/uuid chosen-item)
  260. id (if (string? id) (uuid id) id)]
  261. (p/do!
  262. (editor-handler/api-insert-new-block! ""
  263. {:block-uuid (:block/uuid current-block)
  264. :sibling? true
  265. :replace-empty-target? true
  266. :other-attrs {:block/link (:db/id (db/entity [:block/uuid id]))}})
  267. (state/clear-edit!)))))
  268. (editor-handler/block-on-chosen-handler id q format selected-text)))
  269. ;; TODO: use rum/use-effect instead
  270. (rum/defcs block-search-auto-complete < rum/reactive
  271. {:init (fn [state]
  272. (let [result (atom nil)]
  273. (search-blocks! state result)
  274. (assoc state ::result result)))
  275. :did-update (fn [state]
  276. (search-blocks! state (::result state))
  277. state)}
  278. [state _edit-block input id q format selected-text]
  279. (let [result (->> (rum/react (get state ::result))
  280. (remove (fn [b] (or (nil? (:block/uuid b))
  281. (string/blank? (:block/title (db-model/query-block-by-uuid (:block/uuid b))))))))
  282. db? (config/db-based-graph? (state/get-current-repo))
  283. embed? (and db? (= @commands/*current-command "Block embed"))
  284. chosen-handler (block-on-chosen-handler embed? input id q format selected-text)
  285. non-exist-block-handler (editor-handler/block-non-exist-handler input)]
  286. (ui/auto-complete
  287. result
  288. {:on-chosen chosen-handler
  289. :on-enter non-exist-block-handler
  290. :empty-placeholder [:div.text-gray-500.text-sm.px-4.py-2 (t :editor/block-search)]
  291. :item-render (fn [{:block/keys [page uuid]}] ;; content returned from search engine is normalized
  292. (let [page-entity (db/entity [:block/uuid page])
  293. repo (state/sub :git/current-repo)
  294. format (get page-entity :block/format :markdown)
  295. block (db-model/query-block-by-uuid uuid)
  296. content (:block/title block)]
  297. (when-not (string/blank? content)
  298. [:.py-2 (search/block-search-result-item repo uuid format content q :block)])))
  299. :class "ac-block-search"})))
  300. (rum/defcs block-search < rum/reactive
  301. {:will-unmount (fn [state]
  302. (reset! commands/*current-command nil)
  303. (state/clear-search-result!)
  304. state)}
  305. [state id _format]
  306. (let [pos (state/get-editor-last-pos)
  307. input (gdom/getElement id)
  308. [id format] (:rum/args state)
  309. current-pos (cursor/pos input)
  310. edit-content (state/sub-edit-content)
  311. edit-block (state/get-edit-block)
  312. selected-text (editor-handler/get-selected-text)
  313. q (or
  314. selected-text
  315. (when (>= (count edit-content) current-pos)
  316. (subs edit-content pos current-pos)))]
  317. (when input
  318. (block-search-auto-complete edit-block input id q format selected-text))))
  319. (rum/defc template-search-aux
  320. [id q]
  321. (let [[matched-templates set-matched-templates!] (rum/use-state nil)]
  322. (rum/use-effect! (fn []
  323. (p/let [result (editor-handler/<get-matched-templates q)]
  324. (set-matched-templates! result)))
  325. [q])
  326. (ui/auto-complete
  327. matched-templates
  328. {:on-chosen (editor-handler/template-on-chosen-handler id)
  329. :on-enter (fn [_state] (state/clear-editor-action!))
  330. :empty-placeholder [:div.text-gray-500.px-4.py-2.text-sm "Search for a template"]
  331. :item-render (fn [[template _block-db-id]]
  332. template)
  333. :class "black"})))
  334. (rum/defc template-search < rum/reactive
  335. [id _format]
  336. (let [pos (state/get-editor-last-pos)
  337. input (gdom/getElement id)]
  338. (when input
  339. (let [current-pos (cursor/pos input)
  340. edit-content (state/sub-edit-content)
  341. q (or
  342. (when (>= (count edit-content) current-pos)
  343. (subs edit-content pos current-pos))
  344. "")]
  345. (template-search-aux id q)))))
  346. (rum/defc property-search
  347. [id]
  348. (let [input (gdom/getElement id)
  349. [matched-properties set-matched-properties!] (rum/use-state nil)]
  350. (when input
  351. (let [q (or (:searching-property (editor-handler/get-searching-property input))
  352. "")]
  353. (rum/use-effect!
  354. (fn []
  355. (p/let [matched-properties (editor-handler/<get-matched-properties q)]
  356. (set-matched-properties! matched-properties)))
  357. [q])
  358. (let [q-property (string/replace (string/lower-case q) #"\s+" "-")
  359. non-exist-handler (fn [_state]
  360. ((editor-handler/property-on-chosen-handler id q-property) nil))]
  361. (ui/auto-complete
  362. matched-properties
  363. {:on-chosen (editor-handler/property-on-chosen-handler id q-property)
  364. :on-enter non-exist-handler
  365. :empty-placeholder [:div.px-4.py-2.text-sm (str "Create a new property: " q-property)]
  366. :header [:div.px-4.py-2.text-sm.font-medium "Matched properties: "]
  367. :item-render (fn [property] property)
  368. :class "black"}))))))
  369. (rum/defc property-value-search-aux
  370. [id property q]
  371. (let [[values set-values!] (rum/use-state nil)]
  372. (rum/use-effect!
  373. (fn []
  374. (p/let [result (editor-handler/get-matched-property-values property q)]
  375. (set-values! result)))
  376. [property q])
  377. (ui/auto-complete
  378. values
  379. {:on-chosen (editor-handler/property-value-on-chosen-handler id q)
  380. :on-enter (fn [_state]
  381. ((editor-handler/property-value-on-chosen-handler id q) nil))
  382. :empty-placeholder [:div.px-4.py-2.text-sm (str "Create a new property value: " q)]
  383. :header [:div.px-4.py-2.text-sm.font-medium "Matched property values: "]
  384. :item-render (fn [property-value] property-value)
  385. :class "black"})))
  386. (rum/defc property-value-search < rum/reactive
  387. [id]
  388. (let [property (:property (state/get-editor-action-data))
  389. input (gdom/getElement id)]
  390. (when (and input
  391. (not (string/blank? property)))
  392. (let [current-pos (cursor/pos input)
  393. edit-content (state/sub-edit-content)
  394. start-idx (string/last-index-of (subs edit-content 0 current-pos)
  395. gp-property/colons)
  396. q (or
  397. (when (>= current-pos (+ start-idx 2))
  398. (subs edit-content (+ start-idx 2) current-pos))
  399. "")
  400. q (string/triml q)]
  401. (property-value-search-aux id property q)))))
  402. (rum/defc code-block-mode-keyup-listener
  403. [_q _edit-content last-pos current-pos]
  404. (rum/use-effect!
  405. (fn []
  406. (when (< current-pos last-pos)
  407. (state/clear-editor-action!)))
  408. [last-pos current-pos])
  409. [:<>])
  410. (rum/defc code-block-mode-picker < rum/reactive
  411. [id format]
  412. (when-let [modes (some->> js/window.CodeMirror (.-modes) (js/Object.keys) (js->clj) (remove #(= "null" %)))]
  413. (when-let [^js input (gdom/getElement id)]
  414. (let [pos (state/get-editor-last-pos)
  415. current-pos (cursor/pos input)
  416. edit-content (or (state/sub-edit-content) "")
  417. q (or (editor-handler/get-selected-text)
  418. (common-util/safe-subs edit-content pos current-pos)
  419. "")
  420. matched (seq (fuzzy-search modes q))
  421. matched (or matched (if (string/blank? q) modes [q]))]
  422. [:div
  423. (code-block-mode-keyup-listener q edit-content pos current-pos)
  424. (ui/auto-complete matched
  425. {:on-chosen (fn [chosen _click?]
  426. (state/clear-editor-action!)
  427. (let [prefix (str "```" chosen)
  428. last-pattern (str "```" q)]
  429. (editor-handler/insert-command! id
  430. prefix format {:last-pattern last-pattern})
  431. (-> (editor-handler/save-block!
  432. (state/get-current-repo)
  433. (:block/uuid (state/get-edit-block))
  434. (.-value input))
  435. (p/then #(commands/handle-step [:codemirror/focus])))))
  436. :on-enter (fn []
  437. (state/clear-editor-action!)
  438. (commands/handle-step [:codemirror/focus]))
  439. :item-render (fn [mode _chosen?]
  440. [:strong mode])
  441. :class "code-block-mode-picker"})]))))
  442. (rum/defcs editor-input < rum/reactive (rum/local {} ::input-value)
  443. (mixins/event-mixin
  444. (fn [state]
  445. (mixins/on-key-down
  446. state
  447. {;; enter
  448. 13 (fn [state e]
  449. (let [input-value (get state ::input-value)
  450. input-option (:options (state/get-editor-show-input))]
  451. (when (seq @input-value)
  452. ;; no new line input
  453. (util/stop e)
  454. (let [[_id on-submit] (:rum/args state)
  455. command (:command (first input-option))]
  456. (on-submit command @input-value))
  457. (reset! input-value nil))))
  458. ;; escape
  459. 27 (fn [_state _e]
  460. (let [[id _on-submit on-cancel] (:rum/args state)]
  461. (on-cancel id)))})))
  462. [state _id on-submit _on-cancel]
  463. (when-let [action-data (state/get-editor-action-data)]
  464. (let [{:keys [pos options]} action-data
  465. input-value (get state ::input-value)]
  466. (when (seq options)
  467. (let [command (:command (first options))]
  468. [:div.p-2.rounded-md.flex.flex-col.gap-2
  469. (for [{:keys [id placeholder type]} options]
  470. (shui/input
  471. (cond->
  472. {:key (str "modal-input-" (name id))
  473. :type (or type "text")
  474. :auto-complete (if (util/chrome?) "chrome-off" "off")
  475. :on-change (fn [e]
  476. (swap! input-value assoc id (util/evalue e)))}
  477. placeholder
  478. (assoc :placeholder placeholder))))
  479. (ui/button
  480. "Submit"
  481. :on-click
  482. (fn [e]
  483. (util/stop e)
  484. (on-submit command @input-value pos)))])))))
  485. (rum/defc image-uploader < rum/reactive
  486. [id format]
  487. [:div.image-uploader
  488. [:input
  489. {:id "upload-file"
  490. :type "file"
  491. :on-change (fn [e]
  492. (let [files (.-files (.-target e))]
  493. (editor-handler/upload-asset! id files format editor-handler/*asset-uploading? false)))
  494. :hidden true}]])
  495. (defn- set-up-key-down!
  496. [state format]
  497. (mixins/on-key-down
  498. state
  499. {}
  500. {:not-matched-handler (editor-handler/keydown-not-matched-handler format)}))
  501. (defn- set-up-key-up!
  502. [state input']
  503. (mixins/on-key-up
  504. state
  505. {}
  506. (editor-handler/keyup-handler state input')))
  507. (def search-timeout (atom nil))
  508. (defn- setup-key-listener!
  509. [state]
  510. (let [{:keys [id format]} (get-state)
  511. input-id id
  512. input' (gdom/getElement input-id)]
  513. (set-up-key-down! state format)
  514. (set-up-key-up! state input')))
  515. (defn get-editor-style-class
  516. "Get textarea css class according to it's content"
  517. [block content format]
  518. (let [content (if content (str content) "")
  519. heading (pu/get-block-property-value block :logseq.property/heading)
  520. heading (if (true? heading)
  521. (min (inc (:block/level block)) 6)
  522. heading)]
  523. ;; as the function is binding to the editor content, optimization is welcome
  524. (str
  525. (if (or (> (.-length content) 1000)
  526. (string/includes? content "\n"))
  527. "multiline-block"
  528. "uniline-block")
  529. " "
  530. (case format
  531. :markdown
  532. (cond
  533. heading (str "h" heading)
  534. (string/starts-with? content "# ") "h1"
  535. (string/starts-with? content "## ") "h2"
  536. (string/starts-with? content "### ") "h3"
  537. (string/starts-with? content "#### ") "h4"
  538. (string/starts-with? content "##### ") "h5"
  539. (string/starts-with? content "###### ") "h6"
  540. (and (string/starts-with? content "---\n") (.endsWith content "\n---")) "page-properties"
  541. :else "normal-block")
  542. ;; other formats
  543. (cond
  544. heading (str "h" heading)
  545. (and (string/starts-with? content "---\n") (.endsWith content "\n---")) "page-properties"
  546. :else "normal-block")))))
  547. (defn editor-row-height-unchanged?
  548. "Check if the row height of editor textarea is changed, which happens when font-size changed"
  549. []
  550. ;; FIXME: assuming enter key is the only trigger of the height changing (under markdown editing of headlines)
  551. ;; FIXME: looking for an elegant & robust way to track the change of font-size, or wait for our own WYSIWYG text area
  552. (let [last-key (state/get-last-key-code)]
  553. (and (not= keycode/enter (:key-code last-key))
  554. (not= keycode/enter-code (:code last-key)))))
  555. (rum/defc mock-textarea <
  556. rum/static
  557. {:did-update
  558. (fn [state]
  559. (when-not @(:editor/on-paste? @state/state)
  560. (try (editor-handler/handle-last-input)
  561. (catch :default _e
  562. nil)))
  563. (state/set-state! :editor/on-paste? false)
  564. state)}
  565. [content]
  566. [:div#mock-text
  567. {:style {:width "100%"
  568. :height "100%"
  569. :position "absolute"
  570. :visibility "hidden"
  571. :top 0
  572. :left 0}}
  573. (let [content (str content "0")
  574. graphemes (util/split-graphemes content)
  575. graphemes-char-index (reductions #(+ %1 (count %2)) 0 graphemes)]
  576. (for [[idx c] (into (sorted-map) (zipmap graphemes-char-index graphemes))]
  577. (if (= c "\n")
  578. [:span {:id (str "mock-text_" idx)
  579. :key idx} "0" [:br]]
  580. [:span {:id (str "mock-text_" idx)
  581. :key idx} c])))])
  582. (defn- exist-editor-commands-popup?
  583. []
  584. (some->> (shui-popup/get-popups)
  585. (some #(some-> % (:id) (str) (string/starts-with? ":editor.commands")))))
  586. (defn- open-editor-popup!
  587. [id content opts]
  588. (let [input (state/get-input)
  589. line-height (or (when input
  590. (some-> (.-lineHeight (js/window.getComputedStyle input))
  591. (js/parseFloat)
  592. (- 4)))
  593. 20)
  594. {:keys [left top rect]} (cursor/get-caret-pos input)
  595. pos [(+ left (:left rect) -20) (+ top (:top rect) line-height)]
  596. {:keys [root-props content-props]} opts]
  597. (shui/popup-show!
  598. pos content
  599. (merge
  600. {:id (keyword :editor.commands id)
  601. :align :start
  602. :root-props (merge {:onOpenChange #(when-not % (state/clear-editor-action!))} root-props)
  603. :content-props (merge {:onOpenAutoFocus #(.preventDefault %)
  604. :onCloseAutoFocus #(.preventDefault %)
  605. :data-editor-popup-ref (name id)} content-props)
  606. :force-popover? true}
  607. (dissoc opts :root-props :content-props)))))
  608. (rum/defc shui-editor-popups
  609. [id format action _data]
  610. (rum/use-effect!
  611. (fn []
  612. (let [pid (case action
  613. :commands
  614. (open-editor-popup! :commands
  615. (commands id format)
  616. {:content-props {:withoutAnimation false}})
  617. (:block-search :page-search :page-search-hashtag)
  618. (open-editor-popup! action
  619. (if (= :block-search action)
  620. (block-search id format)
  621. (page-search id format))
  622. {:root-props {:onOpenChange
  623. #(when-not %
  624. (when (contains?
  625. #{:block-search :page-search :page-search-hashtag}
  626. (state/get-editor-action))
  627. (state/clear-editor-action!)))}})
  628. :datepicker
  629. (open-editor-popup! :datepicker
  630. (datetime-comp/date-picker id format nil) {})
  631. :input
  632. (open-editor-popup! :input
  633. (editor-input id
  634. (fn [command m]
  635. (editor-handler/handle-command-input command id format m))
  636. (fn []
  637. (editor-handler/handle-command-input-close id)))
  638. {:content-props {:onOpenAutoFocus #()}})
  639. :select-code-block-mode
  640. (open-editor-popup! :code-block-mode-picker
  641. (code-block-mode-picker id format) {})
  642. :template-search
  643. (open-editor-popup! :template-search
  644. (template-search id format) {})
  645. (:property-search :property-value-search)
  646. (open-editor-popup! action
  647. (if (= :property-search action)
  648. (property-search id) (property-value-search id))
  649. {})
  650. :zotero
  651. (open-editor-popup! :zotero
  652. (zotero/zotero-search id) {})
  653. ;; TODO: try remove local model state
  654. false)]
  655. #(when pid
  656. (shui/popup-hide! pid))))
  657. [action])
  658. [:<>])
  659. (rum/defc command-popups <
  660. rum/reactive
  661. "React to atom changes, find and render the correct popup"
  662. [id format]
  663. (let [action (state/sub :editor/action)]
  664. (shui-editor-popups id format action nil)))
  665. (defn- editor-on-hide
  666. [state type e]
  667. (let [action (state/get-editor-action)
  668. [_id config] (:rum/args state)]
  669. (cond
  670. (and (= type :esc) (exist-editor-commands-popup?))
  671. nil
  672. (or (contains?
  673. #{:commands :page-search :page-search-hashtag :block-search :template-search
  674. :property-search :property-value-search :datepicker}
  675. action)
  676. (and (keyword? action)
  677. (= (namespace action) "editor.action")))
  678. (when e (util/stop e))
  679. ;; editor/input component handles Escape directly, so just prevent handling it here
  680. (= :input action)
  681. nil
  682. ;; exit editing mode
  683. :else
  684. (let [select? (= type :esc)]
  685. (when-let [container (gdom/getElement "app-container")]
  686. (dom/remove-class! container "blocks-selection-mode"))
  687. (p/do!
  688. (editor-handler/escape-editing {:select? select?})
  689. (some-> config :on-escape-editing
  690. (apply [(str uuid) (= type :esc)])))))))
  691. (rum/defcs box < rum/reactive
  692. {:init (fn [state]
  693. (assoc state
  694. ::id (str (random-uuid))
  695. ::ref (atom nil)))
  696. :did-mount (fn [state]
  697. (state/set-editor-args! (:rum/args state))
  698. state)
  699. :will-unmount (fn [state]
  700. (state/set-state! :editor/raw-mode-block nil)
  701. state)}
  702. (mixins/event-mixin
  703. (fn [state]
  704. (mixins/hide-when-esc-or-outside
  705. state
  706. {:node @(::ref state)
  707. :on-hide (fn [_state e type]
  708. (when-not (= type :esc)
  709. (editor-on-hide state type e)))})))
  710. (mixins/event-mixin setup-key-listener!)
  711. lifecycle/lifecycle
  712. [state {:keys [format block parent-block]} id config]
  713. (let [*ref (::ref state)
  714. content (state/sub-edit-content (:block/uuid block))
  715. heading-class (get-editor-style-class block content format)
  716. opts (cond->
  717. {:id id
  718. :ref #(reset! *ref %)
  719. :cacheMeasurements (editor-row-height-unchanged?) ;; check when content updated (as the content variable is binded)
  720. :default-value (or content "")
  721. :minRows (if (state/enable-grammarly?) 2 1)
  722. :on-click (editor-handler/editor-on-click! id)
  723. :on-change (editor-handler/editor-on-change! block id search-timeout)
  724. :on-paste (paste-handler/editor-on-paste! id)
  725. :on-key-down (fn [e]
  726. (if-let [on-key-down (:on-key-down config)]
  727. (on-key-down e)
  728. (when (= (util/ekey e) "Escape")
  729. (editor-on-hide state :esc e))))
  730. :auto-focus true
  731. :class heading-class}
  732. (some? parent-block)
  733. (assoc :parentblockid (str (:block/uuid parent-block)))
  734. true
  735. (merge (:editor-opts config)))]
  736. [:div.editor-inner.flex.flex-1 {:class (if block "block-editor" "non-block-editor")}
  737. (ui/ls-textarea opts)
  738. (mock-textarea content)
  739. (command-popups id format)
  740. (when format
  741. (image-uploader id format))]))