editor.cljs 35 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811
  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.common.util.page-ref :as page-ref]
  34. [logseq.db :as ldb]
  35. [logseq.db.frontend.class :as db-class]
  36. [logseq.graph-parser.property :as gp-property]
  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. (hooks/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. (let [db? (config/db-based-graph? (state/get-current-repo))
  319. embed? (and db? (= @commands/*current-command "Block embed"))
  320. page (when embed? (page-ref/get-page-name edit-content))
  321. embed-block-id (when (and embed? page (common-util/uuid-string? page))
  322. (uuid page))]
  323. (if embed-block-id
  324. (let [f (block-on-chosen-handler true input id q format nil)
  325. block (db/entity embed-block-id)]
  326. (when block (f block))
  327. nil)
  328. (block-search-auto-complete edit-block input id q format selected-text))))))
  329. (rum/defc template-search-aux
  330. [id q]
  331. (let [db-based? (config/db-based-graph?)
  332. [matched-templates set-matched-templates!] (rum/use-state nil)]
  333. (hooks/use-effect! (fn []
  334. (p/let [result (editor-handler/<get-matched-templates q)]
  335. (set-matched-templates! result)))
  336. [q])
  337. (ui/auto-complete
  338. matched-templates
  339. {:on-chosen (editor-handler/template-on-chosen-handler id)
  340. :on-enter (fn [_state] (state/clear-editor-action!))
  341. :empty-placeholder [:div.text-gray-500.px-4.py-2.text-sm "Search for a template"]
  342. :item-render (fn [template]
  343. (if db-based? (:block/title template) (:template template)))
  344. :class "black"})))
  345. (rum/defc template-search < rum/reactive
  346. [id _format]
  347. (let [pos (state/get-editor-last-pos)
  348. input (gdom/getElement id)]
  349. (when input
  350. (let [current-pos (cursor/pos input)
  351. edit-content (state/sub-edit-content)
  352. q (or
  353. (when (>= (count edit-content) current-pos)
  354. (subs edit-content pos current-pos))
  355. "")]
  356. (template-search-aux id q)))))
  357. (rum/defc property-search
  358. [id]
  359. (let [input (gdom/getElement id)
  360. [matched-properties set-matched-properties!] (rum/use-state nil)
  361. [q set-q!] (rum/use-state "")]
  362. (when input
  363. (hooks/use-effect!
  364. (fn []
  365. (.addEventListener input "input" (fn [_e]
  366. (set-q! (or (:searching-property (editor-handler/get-searching-property input)) "")))))
  367. [])
  368. (hooks/use-effect!
  369. (fn []
  370. (p/let [matched-properties (editor-handler/<get-matched-properties q)]
  371. (set-matched-properties! matched-properties)))
  372. [q])
  373. (let [q-property (string/replace (string/lower-case q) #"\s+" "-")
  374. non-exist-handler (fn [_state]
  375. ((editor-handler/property-on-chosen-handler id q-property) nil))]
  376. (ui/auto-complete
  377. matched-properties
  378. {:on-chosen (editor-handler/property-on-chosen-handler id q-property)
  379. :on-enter non-exist-handler
  380. :empty-placeholder [:div.px-4.py-2.text-sm (str "Create a new property: " q-property)]
  381. :header [:div.px-4.py-2.text-sm.font-medium "Matched properties: "]
  382. :item-render (fn [property] property)
  383. :class "black"})))))
  384. (rum/defc property-value-search-aux
  385. [id property q]
  386. (let [[values set-values!] (rum/use-state nil)]
  387. (hooks/use-effect!
  388. (fn []
  389. (p/let [result (editor-handler/get-matched-property-values property q)]
  390. (set-values! result)))
  391. [property q])
  392. (ui/auto-complete
  393. values
  394. {:on-chosen (editor-handler/property-value-on-chosen-handler id q)
  395. :on-enter (fn [_state]
  396. ((editor-handler/property-value-on-chosen-handler id q) nil))
  397. :empty-placeholder [:div.px-4.py-2.text-sm (str "Create a new property value: " q)]
  398. :header [:div.px-4.py-2.text-sm.font-medium "Matched property values: "]
  399. :item-render (fn [property-value] property-value)
  400. :class "black"})))
  401. (rum/defc property-value-search < rum/reactive
  402. [id]
  403. (let [property (:property (state/get-editor-action-data))
  404. input (gdom/getElement id)]
  405. (when (and input
  406. (not (string/blank? property)))
  407. (let [current-pos (cursor/pos input)
  408. edit-content (state/sub-edit-content)
  409. start-idx (string/last-index-of (subs edit-content 0 current-pos)
  410. gp-property/colons)
  411. q (or
  412. (when (>= current-pos (+ start-idx 2))
  413. (subs edit-content (+ start-idx 2) current-pos))
  414. "")
  415. q (string/triml q)]
  416. (property-value-search-aux id property q)))))
  417. (rum/defc code-block-mode-keyup-listener
  418. [_q _edit-content last-pos current-pos]
  419. (hooks/use-effect!
  420. (fn []
  421. (when (< current-pos last-pos)
  422. (state/clear-editor-action!)))
  423. [last-pos current-pos])
  424. [:<>])
  425. (rum/defc code-block-mode-picker < rum/reactive
  426. [id format]
  427. (when-let [modes (some->> js/window.CodeMirror (.-modes) (js/Object.keys) (js->clj) (remove #(= "null" %)))]
  428. (when-let [^js input (gdom/getElement id)]
  429. (let [pos (state/get-editor-last-pos)
  430. current-pos (cursor/pos input)
  431. edit-content (or (state/sub-edit-content) "")
  432. q (or (editor-handler/get-selected-text)
  433. (common-util/safe-subs edit-content pos current-pos)
  434. "")
  435. matched (seq (fuzzy-search modes q))
  436. matched (or matched (if (string/blank? q) modes [q]))]
  437. [:div
  438. (code-block-mode-keyup-listener q edit-content pos current-pos)
  439. (ui/auto-complete matched
  440. {:on-chosen (fn [chosen _click?]
  441. (state/clear-editor-action!)
  442. (let [prefix (str "```" chosen)
  443. last-pattern (str "```" q)]
  444. (editor-handler/insert-command! id
  445. prefix format {:last-pattern last-pattern})
  446. (-> (editor-handler/save-block!
  447. (state/get-current-repo)
  448. (:block/uuid (state/get-edit-block))
  449. (.-value input))
  450. (p/then #(commands/handle-step [:codemirror/focus])))))
  451. :on-enter (fn []
  452. (state/clear-editor-action!)
  453. (commands/handle-step [:codemirror/focus]))
  454. :item-render (fn [mode _chosen?]
  455. [:strong mode])
  456. :class "code-block-mode-picker"})]))))
  457. (rum/defcs editor-input < rum/reactive (rum/local {} ::input-value)
  458. (mixins/event-mixin
  459. (fn [state]
  460. (mixins/on-key-down
  461. state
  462. {;; enter
  463. 13 (fn [state e]
  464. (let [input-value (get state ::input-value)
  465. input-option (:options (state/get-editor-show-input))]
  466. (when (seq @input-value)
  467. ;; no new line input
  468. (util/stop e)
  469. (let [[_id on-submit] (:rum/args state)
  470. command (:command (first input-option))]
  471. (on-submit command @input-value))
  472. (reset! input-value nil))))
  473. ;; escape
  474. 27 (fn [_state _e]
  475. (let [[id _on-submit on-cancel] (:rum/args state)]
  476. (on-cancel id)))})))
  477. [state _id on-submit _on-cancel]
  478. (when-let [action-data (state/get-editor-action-data)]
  479. (let [{:keys [pos options]} action-data
  480. input-value (get state ::input-value)]
  481. (when (seq options)
  482. (let [command (:command (first options))]
  483. [:div.p-2.rounded-md.flex.flex-col.gap-2
  484. (for [{:keys [id placeholder type]} options]
  485. (shui/input
  486. (cond->
  487. {:key (str "modal-input-" (name id))
  488. :type (or type "text")
  489. :auto-complete (if (util/chrome?) "chrome-off" "off")
  490. :on-change (fn [e]
  491. (swap! input-value assoc id (util/evalue e)))}
  492. placeholder
  493. (assoc :placeholder placeholder))))
  494. (ui/button
  495. "Submit"
  496. :on-click
  497. (fn [e]
  498. (util/stop e)
  499. (on-submit command @input-value pos)))])))))
  500. (rum/defc image-uploader < rum/reactive
  501. [id format]
  502. [:div.image-uploader
  503. [:input
  504. {:id "upload-file"
  505. :type "file"
  506. :on-change (fn [e]
  507. (let [files (.-files (.-target e))]
  508. (editor-handler/upload-asset! id files format editor-handler/*asset-uploading? false)))
  509. :hidden true}]])
  510. (defn- set-up-key-down!
  511. [state format]
  512. (mixins/on-key-down
  513. state
  514. {}
  515. {:not-matched-handler (editor-handler/keydown-not-matched-handler format)}))
  516. (defn- set-up-key-up!
  517. [state input']
  518. (mixins/on-key-up
  519. state
  520. {}
  521. (editor-handler/keyup-handler state input')))
  522. (def search-timeout (atom nil))
  523. (defn- setup-key-listener!
  524. [state]
  525. (let [{:keys [id format]} (get-state)
  526. input-id id
  527. input' (gdom/getElement input-id)]
  528. (set-up-key-down! state format)
  529. (set-up-key-up! state input')))
  530. (defn get-editor-style-class
  531. "Get textarea css class according to it's content"
  532. [block content format]
  533. (let [content (if content (str content) "")
  534. heading (pu/get-block-property-value block :logseq.property/heading)
  535. heading (if (true? heading)
  536. (min (inc (:block/level block)) 6)
  537. heading)]
  538. ;; as the function is binding to the editor content, optimization is welcome
  539. (str
  540. (if (or (> (.-length content) 1000)
  541. (string/includes? content "\n"))
  542. "multiline-block"
  543. "uniline-block")
  544. " "
  545. (case format
  546. :markdown
  547. (cond
  548. heading (str "h" heading)
  549. (string/starts-with? content "# ") "h1"
  550. (string/starts-with? content "## ") "h2"
  551. (string/starts-with? content "### ") "h3"
  552. (string/starts-with? content "#### ") "h4"
  553. (string/starts-with? content "##### ") "h5"
  554. (string/starts-with? content "###### ") "h6"
  555. (and (string/starts-with? content "---\n") (.endsWith content "\n---")) "page-properties"
  556. :else "normal-block")
  557. ;; other formats
  558. (cond
  559. heading (str "h" heading)
  560. (and (string/starts-with? content "---\n") (.endsWith content "\n---")) "page-properties"
  561. :else "normal-block")))))
  562. (defn editor-row-height-unchanged?
  563. "Check if the row height of editor textarea is changed, which happens when font-size changed"
  564. []
  565. ;; FIXME: assuming enter key is the only trigger of the height changing (under markdown editing of headlines)
  566. ;; FIXME: looking for an elegant & robust way to track the change of font-size, or wait for our own WYSIWYG text area
  567. (let [last-key (state/get-last-key-code)]
  568. (and (not= keycode/enter (:key-code last-key))
  569. (not= keycode/enter-code (:code last-key)))))
  570. (rum/defc mock-textarea <
  571. rum/static
  572. {:did-update
  573. (fn [state]
  574. (when-not @(:editor/on-paste? @state/state)
  575. (try (editor-handler/handle-last-input)
  576. (catch :default _e
  577. nil)))
  578. (state/set-state! :editor/on-paste? false)
  579. state)}
  580. [content]
  581. [:div#mock-text
  582. {:style {:width "100%"
  583. :height "100%"
  584. :position "absolute"
  585. :visibility "hidden"
  586. :top 0
  587. :left 0}}
  588. (let [content (str content "0")
  589. graphemes (util/split-graphemes content)
  590. graphemes-char-index (reductions #(+ %1 (count %2)) 0 graphemes)]
  591. (for [[idx c] (into (sorted-map) (zipmap graphemes-char-index graphemes))]
  592. (if (= c "\n")
  593. [:span {:id (str "mock-text_" idx)
  594. :key idx} "0" [:br]]
  595. [:span {:id (str "mock-text_" idx)
  596. :key idx} c])))])
  597. (defn- open-editor-popup!
  598. [id content opts]
  599. (let [input (state/get-input)
  600. line-height (or (when input
  601. (some-> (.-lineHeight (js/window.getComputedStyle input))
  602. (js/parseFloat)
  603. (- 4)))
  604. 20)
  605. {:keys [left top rect]} (cursor/get-caret-pos input)
  606. pos [(+ left (:left rect) -20) (+ top (:top rect) line-height)]
  607. {:keys [root-props content-props]} opts]
  608. (shui/popup-show!
  609. pos content
  610. (merge
  611. {:id (keyword :editor.commands id)
  612. :align :start
  613. :root-props (merge {:onOpenChange #(when-not % (state/clear-editor-action!))} root-props)
  614. :content-props (merge {:onOpenAutoFocus #(.preventDefault %)
  615. :onCloseAutoFocus #(.preventDefault %)
  616. :data-editor-popup-ref (name id)} content-props)
  617. :force-popover? true}
  618. (dissoc opts :root-props :content-props)))))
  619. (rum/defc shui-editor-popups
  620. [id format action _data]
  621. (hooks/use-effect!
  622. (fn []
  623. (let [pid (case action
  624. :commands
  625. (open-editor-popup! :commands
  626. (commands id format)
  627. {:content-props {:withoutAnimation false}})
  628. (:block-search :page-search :page-search-hashtag)
  629. (open-editor-popup! action
  630. (if (= :block-search action)
  631. (block-search id format)
  632. (page-search id format))
  633. {:root-props {:onOpenChange
  634. #(when-not %
  635. (when (contains?
  636. #{:block-search :page-search :page-search-hashtag}
  637. (state/get-editor-action))
  638. (state/clear-editor-action!)))}})
  639. :datepicker
  640. (open-editor-popup! :datepicker
  641. (datetime-comp/date-picker id format nil) {})
  642. :input
  643. (open-editor-popup! :input
  644. (editor-input id
  645. (fn [command m]
  646. (editor-handler/handle-command-input command id format m))
  647. (fn []
  648. (editor-handler/handle-command-input-close id)))
  649. {:content-props {:onOpenAutoFocus #()}})
  650. :select-code-block-mode
  651. (open-editor-popup! :code-block-mode-picker
  652. (code-block-mode-picker id format) {})
  653. :template-search
  654. (open-editor-popup! :template-search
  655. (template-search id format) {})
  656. (:property-search :property-value-search)
  657. (open-editor-popup! action
  658. (if (= :property-search action)
  659. (property-search id) (property-value-search id))
  660. {})
  661. :zotero
  662. (open-editor-popup! :zotero
  663. (zotero/zotero-search id) {})
  664. ;; TODO: try remove local model state
  665. false)]
  666. #(when pid
  667. (shui/popup-hide! pid))))
  668. [action])
  669. [:<>])
  670. (rum/defc command-popups <
  671. rum/reactive
  672. "React to atom changes, find and render the correct popup"
  673. [id format]
  674. (let [action (state/sub :editor/action)]
  675. (shui-editor-popups id format action nil)))
  676. (defn- editor-on-hide
  677. [state type e]
  678. (let [action (state/get-editor-action)
  679. [_id config] (:rum/args state)]
  680. (cond
  681. (and (= type :esc) (editor-handler/editor-commands-popup-exists?))
  682. nil
  683. (or (contains?
  684. #{:commands :page-search :page-search-hashtag :block-search :template-search
  685. :property-search :property-value-search :datepicker}
  686. action)
  687. (and (keyword? action)
  688. (= (namespace action) "editor.action")))
  689. (when e (util/stop e))
  690. ;; editor/input component handles Escape directly, so just prevent handling it here
  691. (= :input action)
  692. nil
  693. ;; exit editing mode
  694. :else
  695. (let [select? (= type :esc)]
  696. (when-let [container (gdom/getElement "app-container")]
  697. (dom/remove-class! container "blocks-selection-mode"))
  698. (p/do!
  699. (editor-handler/escape-editing {:select? select?})
  700. (some-> config :on-escape-editing
  701. (apply [(str uuid) (= type :esc)])))))))
  702. (rum/defcs box < rum/reactive
  703. {:init (fn [state]
  704. (assoc state
  705. ::id (str (random-uuid))
  706. ::ref (atom nil)))
  707. :did-mount (fn [state]
  708. (state/set-editor-args! (:rum/args state))
  709. state)
  710. :will-unmount (fn [state]
  711. (state/set-state! :editor/raw-mode-block nil)
  712. state)}
  713. (mixins/event-mixin
  714. (fn [state]
  715. (mixins/hide-when-esc-or-outside
  716. state
  717. {:node @(::ref state)
  718. :on-hide (fn [_state e type]
  719. (when-not (= type :esc)
  720. (editor-on-hide state type e)))})))
  721. (mixins/event-mixin setup-key-listener!)
  722. lifecycle/lifecycle
  723. [state {:keys [format block parent-block]} id config]
  724. (let [*ref (::ref state)
  725. content (state/sub-edit-content (:block/uuid block))
  726. heading-class (get-editor-style-class block content format)
  727. opts (cond->
  728. {:id id
  729. :ref #(reset! *ref %)
  730. :cacheMeasurements (editor-row-height-unchanged?) ;; check when content updated (as the content variable is binded)
  731. :default-value (or content "")
  732. :minRows (if (state/enable-grammarly?) 2 1)
  733. :on-click (editor-handler/editor-on-click! id)
  734. :on-change (editor-handler/editor-on-change! block id search-timeout)
  735. :on-paste (paste-handler/editor-on-paste! id)
  736. :on-key-down (fn [e]
  737. (if-let [on-key-down (:on-key-down config)]
  738. (on-key-down e)
  739. (when (= (util/ekey e) "Escape")
  740. (editor-on-hide state :esc e))))
  741. :auto-focus true
  742. :class heading-class}
  743. (some? parent-block)
  744. (assoc :parentblockid (str (:block/uuid parent-block)))
  745. true
  746. (merge (:editor-opts config)))]
  747. [:div.editor-inner.flex.flex-1 {:class (if block "block-editor" "non-block-editor")}
  748. (ui/ls-textarea opts)
  749. (mock-textarea content)
  750. (command-popups id format)
  751. (when format
  752. (image-uploader id format))]))