editor.cljs 36 KB

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