Procházet zdrojové kódy

Merge pull request #774 from logseq/refactor/editor

Change editor input as uncontrolled component & try composition input for UX (by xyhp915)
Charlie před 5 roky
rodič
revize
ed2b68d863

+ 2 - 0
package.json

@@ -25,11 +25,13 @@
         "release-publishing": "run-s cljs:release-publishing gulp:build",
         "dev-release-app": "run-s cljs:dev-release-app gulp:build",
         "clean": "gulp clean",
+        "test": "run-s cljs:test cljs:run-test",
         "gulp:watch": "gulp watch",
         "gulp:build": "NODE_ENV=production gulp build",
         "cljs:watch": "clojure -M:cljs watch app publishing",
         "cljs:release": "clojure -M:cljs release app publishing",
         "cljs:test": "clojure -A:test compile test",
+        "cljs:run-test": "node static/tests.js",
         "cljs:watch-app": "clojure -M:cljs watch app",
         "cljs:release-app": "clojure -M:cljs release app",
         "cljs:release-publishing": "clojure -M:cljs release publishing",

+ 19 - 21
resources/css/common.css

@@ -236,25 +236,6 @@ li {
   white-space: pre-line;
 }
 
-.editor textarea {
-  border: none;
-  border-radius: 0;
-  background: transparent;
-  padding: 0;
-}
-
-.non-block-editor textarea, pre {
-  display: block;
-  padding: 0.5rem;
-  box-shadow: 0 0 0 1px rgba(0, 0, 0, .02);
-  border-radius: 4px;
-}
-
-.non-block-editor textarea {
-  background: #F6F8FA;
-  background: var(--ls-secondary-background-color);
-}
-
 pre {
   background: #F6F8FA;
   background: var(--ls-secondary-background-color);
@@ -1056,8 +1037,8 @@ button.context-menu-option {
 }
 
 .content img {
-  margin-top: 1rem;
-  margin-bottom: 1rem;
+  margin-top: .5rem;
+  margin-bottom: .5rem;
 }
 
 a.login {
@@ -1455,6 +1436,23 @@ a.tag:hover {
   padding: 5px;
 }
 
+// auto complete
+#ui__ac {
+  @apply py-1 rounded-md shadow-xs bg-base-3;
+}
+
+#ui__ac-inner {
+  max-height: 400px;
+  overflow-x: hidden;
+  overflow-y: auto;
+  position: relative;
+  -webkit-overflow-scrolling: touch;
+
+  > .menu-link {
+    padding: 6px 0;
+  }
+}
+
 /* endregion */
 
 /* Hide scrollbar for IE, Edge and Firefox */

+ 4 - 6
src/main/frontend/components/block.cljs

@@ -157,10 +157,8 @@
   (let [href (if (util/starts-with? href "http")
                href
                (get-file-absolute-path config href))]
-    [:img.rounded-sm.shadow-xl.mb-2.mt-2
-     {:class "object-contain object-center"
-      :loading "lazy"
-      :style {:max-height "24rem"}
+    [:img.rounded-sm.shadow-xl
+     {:loading "lazy"
       ;; :on-error (fn [])
       :src href
       :title (second (first label))}]))
@@ -613,10 +611,10 @@
                                   nil)]
             (when-not (string/blank? youtube-id)
               [:iframe
-               {:allowfullscreen "allowfullscreen"
+               {:allow-full-screen "allowfullscreen"
                 :allow
                 "accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
-                :frameborder "0"
+                :frame-border "0"
                 :src (str "https://www.youtube.com/embed/" youtube-id)
                 :height "315"
                 :width "560"}])))

+ 15 - 0
src/main/frontend/components/block.css

@@ -0,0 +1,15 @@
+.blocks-container {
+}
+
+.block-content {
+}
+
+.block-children {
+}
+
+.ls-block {
+}
+
+.block-content img {
+  width: 100%;
+}

+ 154 - 164
src/main/frontend/components/editor.cljs

@@ -268,8 +268,7 @@
                       {:keys [pos]} @*slash-caret-pos
                       command (:command (first input-option))]
                   (on-submit command @input-value pos))
-                (reset! input-value nil))))}
-      nil)))
+                (reset! input-value nil))))})))
   {:did-update
    (fn [state]
      (when-let [show-input (state/get-editor-show-input)]
@@ -360,8 +359,6 @@
            input-id id
            input (gdom/getElement input-id)
            repo (:block/repo block)]
-       ;; (.addEventListener input "paste" (fn [event]
-       ;;                                    (editor-handler/append-paste-doc! format event)))
        (mixins/on-key-down
         state
         {;; enter
@@ -415,160 +412,158 @@
                      (not (editor-handler/in-auto-complete? input)))
                 (editor-handler/on-up-down state e false)))
          ;; backspace
-         8 (fn [state e]
-             (let [node (gdom/getElement input-id)
-                   current-pos (:pos (util/get-caret-pos node))
-                   value (gobj/get node "value")
-                   deleted (and (> current-pos 0)
-                                (util/nth-safe value (dec current-pos)))
-                   selected-start (gobj/get node "selectionStart")
-                   selected-end (gobj/get node "selectionEnd")
-                   block-id (:block-id (first (:rum/args state)))
-                   page (state/get-current-page)]
-               (cond
-                 (not= selected-start selected-end)
-                 nil
-
-                 (and (zero? current-pos)
-                      ;; not the top block in a block page
-                      (not (and page
-                                (util/uuid-string? page)
-                                (= (medley/uuid page) block-id))))
-
-                 (editor-handler/delete-block! state repo e)
-
-                 (and (> current-pos 1)
-                      (= (util/nth-safe value (dec current-pos)) commands/slash))
-                 (do
-                   (reset! *slash-caret-pos nil)
-                   (reset! *show-commands false))
-
-                 (and (> current-pos 1)
-                      (= (util/nth-safe value (dec current-pos)) commands/angle-bracket))
-                 (do
-                   (reset! *angle-bracket-caret-pos nil)
-                   (reset! *show-block-commands false))
-
-                 ;; pair
-                 (and
-                  deleted
-                  (contains?
-                   (set (keys editor-handler/delete-map))
-                   deleted)
-                  (>= (count value) (inc current-pos))
-                  (= (util/nth-safe value current-pos)
-                     (get editor-handler/delete-map deleted)))
-
-                 (do
-                   (util/stop e)
-                   (commands/delete-pair! id)
-                   (cond
-                     (and (= deleted "[") (state/get-editor-show-page-search?))
-                     (state/set-editor-show-page-search! false)
-
-                     (and (= deleted "(") (state/get-editor-show-block-search?))
-                     (state/set-editor-show-block-search! false)
-
-                     :else
-                     nil))
-
-                 ;; deleting hashtag
-                 (and (= deleted "#") (state/get-editor-show-page-search-hashtag?))
-                 (state/set-editor-show-page-search-hashtag! false)
-
-                 :else
-                 nil)))
+         8  (fn [state e]
+              (let [node (gdom/getElement input-id)
+                    current-pos (:pos (util/get-caret-pos node))
+                    value (gobj/get node "value")
+                    deleted (and (> current-pos 0)
+                                 (util/nth-safe value (dec current-pos)))
+                    selected-start (gobj/get node "selectionStart")
+                    selected-end (gobj/get node "selectionEnd")
+                    block-id (:block-id (first (:rum/args state)))
+                    page (state/get-current-page)]
+                (cond
+                  (not= selected-start selected-end)
+                  nil
+
+                  (and (zero? current-pos)
+                       ;; not the top block in a block page
+                       (not (and page
+                                 (util/uuid-string? page)
+                                 (= (medley/uuid page) block-id))))
+                  (editor-handler/delete-block! state repo e)
+
+                  (and (> current-pos 1)
+                       (= (util/nth-safe value (dec current-pos)) commands/slash))
+                  (do
+                    (reset! *slash-caret-pos nil)
+                    (reset! *show-commands false))
+
+                  (and (> current-pos 1)
+                       (= (util/nth-safe value (dec current-pos)) commands/angle-bracket))
+                  (do
+                    (reset! *angle-bracket-caret-pos nil)
+                    (reset! *show-block-commands false))
+
+                  ;; pair
+                  (and
+                   deleted
+                   (contains?
+                    (set (keys editor-handler/delete-map))
+                    deleted)
+                   (>= (count value) (inc current-pos))
+                   (= (util/nth-safe value current-pos)
+                      (get editor-handler/delete-map deleted)))
+
+                  (do
+                    (util/stop e)
+                    (commands/delete-pair! id)
+                    (cond
+                      (and (= deleted "[") (state/get-editor-show-page-search?))
+                      (state/set-editor-show-page-search! false)
+
+                      (and (= deleted "(") (state/get-editor-show-block-search?))
+                      (state/set-editor-show-block-search! false)
+
+                      :else
+                      nil))
+
+                  ;; deleting hashtag
+                  (and (= deleted "#") (state/get-editor-show-page-search-hashtag?))
+                  (state/set-editor-show-page-search-hashtag! false)
+
+                  :else
+                  nil)))
          ;; tab
-         9 (fn [state e]
-             (let [input-id (state/get-edit-input-id)
-                   input (and input-id (gdom/getElement id))
-                   pos (and input (:pos (util/get-caret-pos input)))]
-               (when-not (state/get-editor-show-input)
-                 (util/stop e)
-                 (let [direction (if (gobj/get e "shiftKey") ; shift+tab move to left
-                                   :left
-                                   :right)]
-                   (p/let [_ (editor-handler/adjust-block-level! state direction)]
-                     (and input pos (js/setTimeout #(when-let [input (gdom/getElement input-id)]
-                                                      (util/move-cursor-to input pos))
-                                                   0)))))))}
-        (fn [e key-code]
-          (let [key (gobj/get e "key")
-                value (gobj/get input "value")
-                pos (:pos (util/get-caret-pos input))]
-            (cond
-              (or
-               (and (= key "#")
-                    (and
-                     (> pos 0)
-                     (= "#" (util/nth-safe value (dec pos)))))
-               (and (= key " ")
-                    (state/get-editor-show-page-search-hashtag?)))
-              (state/set-editor-show-page-search-hashtag! false)
-
-              (and
-               (not= key-code 8) ;; backspace
+         9  (fn [state e]
+              (let [input-id (state/get-edit-input-id)
+                    input (and input-id (gdom/getElement id))
+                    pos (and input (:pos (util/get-caret-pos input)))]
+                (when-not (state/get-editor-show-input)
+                  (util/stop e)
+                  (let [direction (if (gobj/get e "shiftKey") ; shift+tab move to left
+                                    :left
+                                    :right)]
+                    (p/let [_ (editor-handler/adjust-block-level! state direction)]
+                      (and input pos (js/setTimeout #(when-let [input (gdom/getElement input-id)]
+                                                       (util/move-cursor-to input pos))
+                                                    0)))))))}
+        {:not-matched-handler
+         (fn [e key-code]
+           (let [key (gobj/get e "key")
+                 value (gobj/get input "value")
+                 pos (:pos (util/get-caret-pos input))]
+             (cond
+               (or
+                (and (= key "#")
+                     (and
+                      (> pos 0)
+                      (= "#" (util/nth-safe value (dec pos)))))
+                (and (= key " ")
+                     (state/get-editor-show-page-search-hashtag?)))
+               (state/set-editor-show-page-search-hashtag! false)
+
                (or
                 (editor-handler/surround-by? input "#" " ")
                 (editor-handler/surround-by? input "#" :end)
-                (= key "#")))
-              (do
-                (commands/handle-step [:editor/search-page-hashtag])
-                (state/set-last-pos! (:pos (util/get-caret-pos input)))
-                (reset! commands/*slash-caret-pos (util/get-caret-pos input)))
-
-              (and
-               (= key " ")
-               (state/get-editor-show-page-search-hashtag?))
-              (state/set-editor-show-page-search-hashtag! false)
-
-              (and
-               (contains? (set/difference (set (keys editor-handler/reversed-autopair-map))
-                                          #{"`"})
-                          key)
-               (= (editor-handler/get-current-input-char input) key))
-              (do
-                (util/stop e)
-                (util/cursor-move-forward input 1))
+                (= key "#"))
+               (do
+                 (commands/handle-step [:editor/search-page-hashtag])
+                 (state/set-last-pos! (:pos (util/get-caret-pos input)))
+                 (reset! commands/*slash-caret-pos (util/get-caret-pos input)))
+
+               (and
+                (= key " ")
+                (state/get-editor-show-page-search-hashtag?))
+               (state/set-editor-show-page-search-hashtag! false)
+
+               (and
+                (contains? (set/difference (set (keys editor-handler/reversed-autopair-map))
+                                           #{"`"})
+                           key)
+                (= (editor-handler/get-current-input-char input) key))
+               (do
+                 (util/stop e)
+                 (util/cursor-move-forward input 1))
 
-              (contains? (set (keys editor-handler/autopair-map)) key)
-              (do
-                (util/stop e)
-                (editor-handler/autopair input-id key format nil)
-                (cond
-                  (editor-handler/surround-by? input "[[" "]]")
-                  (do
-                    (commands/handle-step [:editor/search-page])
-                    (reset! commands/*slash-caret-pos (util/get-caret-pos input)))
-                  (editor-handler/surround-by? input "((" "))")
-                  (do
-                    (commands/handle-step [:editor/search-block :reference])
-                    (reset! commands/*slash-caret-pos (util/get-caret-pos input)))
-                  :else
-                  nil))
-
-              (let [sym "$"]
-                (and (= key sym)
-                     (>= (count value) 1)
-                     (> pos 0)
-                     (= (nth value (dec pos)) sym)
-                     (if (> (count value) pos)
-                       (not= (nth value pos) sym)
-                       true)))
-              (commands/simple-insert! input-id "$$" {:backward-pos 2})
-
-              (let [sym "^"]
-                (and (= key sym)
-                     (>= (count value) 1)
-                     (> pos 0)
-                     (= (nth value (dec pos)) sym)
-                     (if (> (count value) pos)
-                       (not= (nth value pos) sym)
-                       true)))
-              (commands/simple-insert! input-id "^^" {:backward-pos 2})
-
-              :else
-              nil))))
+               (contains? (set (keys editor-handler/autopair-map)) key)
+               (do
+                 (util/stop e)
+                 (editor-handler/autopair input-id key format nil)
+                 (cond
+                   (editor-handler/surround-by? input "[[" "]]")
+                   (do
+                     (commands/handle-step [:editor/search-page])
+                     (reset! commands/*slash-caret-pos (util/get-caret-pos input)))
+                   (editor-handler/surround-by? input "((" "))")
+                   (do
+                     (commands/handle-step [:editor/search-block :reference])
+                     (reset! commands/*slash-caret-pos (util/get-caret-pos input)))
+                   :else
+                   nil))
+
+               (let [sym "$"]
+                 (and (= key sym)
+                      (>= (count value) 1)
+                      (> pos 0)
+                      (= (nth value (dec pos)) sym)
+                      (if (> (count value) pos)
+                        (not= (nth value pos) sym)
+                        true)))
+               (commands/simple-insert! input-id "$$" {:backward-pos 2})
+
+               (let [sym "^"]
+                 (and (= key sym)
+                      (>= (count value) 1)
+                      (> pos 0)
+                      (= (nth value (dec pos)) sym)
+                      (if (> (count value) pos)
+                        (not= (nth value pos) sym)
+                        true)))
+               (commands/simple-insert! input-id "^^" {:backward-pos 2})
+
+               :else
+               nil)))})
        (mixins/on-key-up
         state
         {}
@@ -676,7 +671,7 @@
                                 (not (string/blank? value))
                                 (not= (string/trim value) (string/trim content)))
                            (let [old-page-name (db/get-file-page path false)]
-                             (page-handler/rename-when-alter-title-propertiy! old-page-name path format content value)
+                             (page-handler/rename-when-alter-title-property! old-page-name path format content value)
                              (file/alter-file (state/get-current-repo) path (string/trim value)
                                               {:re-render-root? true}))))
                        (when-not (contains? #{:insert :indent-outdent} (state/get-editor-op))
@@ -685,24 +680,19 @@
   [state {:keys [on-hide dummy? node format block block-parent-id]
           :or {dummy? false}
           :as option} id config]
-  (let [content (state/sub [:editor/content id])]
-    [:div.editor {:style {:position "relative"
-                          :display "flex"
-                          :flex "1 1 0%"}
-                  :class (if block "block-editor" "non-block-editor")}
+  (let [content (state/get-edit-content)]
+    [:div.editor-inner {:class (if block "block-editor" "non-block-editor")}
      (when config/mobile? (mobile-bar state id))
-     (ui/textarea
+     (ui/ls-textarea
       {:id id
-       :value (or content "")
+       :cacheMeasurements true
+       :default-value (or content "")
        :minRows (if (state/enable-grammarly?) 2 1)
        :on-click (fn [_e]
                    (let [input (gdom/getElement id)
                          current-pos (:pos (util/get-caret-pos input))]
                      (state/set-edit-pos! current-pos)
                      (editor-handler/close-autocomplete-if-outside input)))
-       :on-key-down (fn [_e]
-                      (let [current-pos (:pos (util/get-caret-pos (gdom/getElement id)))]
-                        (state/set-edit-pos! current-pos)))
        :on-change (fn [e]
                     (let [value (util/evalue e)
                           current-pos (:pos (util/get-caret-pos (gdom/getElement id)))]

+ 28 - 0
src/main/frontend/components/editor.css

@@ -0,0 +1,28 @@
+.editor-wrapper {
+
+}
+
+.editor-inner {
+  position: relative;
+  display: flex;
+}
+
+.editor-inner textarea {
+  border: none;
+  border-radius: 0;
+  background: transparent;
+  padding: 0;
+}
+
+.non-block-editor textarea, pre {
+  display: block;
+  padding: 0.5rem;
+  box-shadow: 0 0 0 1px rgba(0, 0, 0, .02);
+  border-radius: 4px;
+}
+
+.non-block-editor textarea {
+  background: #F6F8FA;
+  background: var(--ls-secondary-background-color);
+}
+

+ 2 - 4
src/main/frontend/components/sidebar.cljs

@@ -294,9 +294,7 @@
               (when-let [repo-url (state/get-current-repo)]
                 (when-not (state/get-edit-input-id)
                   (util/stop e)
-                  (state/set-modal! commit/add-commit-message)))))}
-      (fn [e key-code]
-        nil))))
+                  (state/set-modal! commit/add-commit-message)))))})))
   {:did-mount (fn [state]
                 (keyboards/bind-shortcuts!)
                 state)}
@@ -359,4 +357,4 @@
          ;;   :on-click (fn []
          ;;               (state/set-left-sidebar-open! (not (state/get-left-sidebar-open?))))}
          ;;  (if (state/sub :ui/left-sidebar-open?) "<" ">")]
-         )])))
+)])))

+ 1 - 1
src/main/frontend/handler.cljs

@@ -134,7 +134,7 @@
 (defn set-save-before-unload! []
   (.addEventListener js/window "beforeunload"
                      (fn [e]
-                       (when (state/repos-need-to-be-stored?)
+                       (when (and (not config/dev?) (state/repos-need-to-be-stored?))
                          (let [notification-id (atom nil)]
                            (let [id (notification/show!
                                      [:div

+ 1 - 1
src/main/frontend/handler/page.cljs

@@ -357,7 +357,7 @@
 
           (ui-handler/re-render-root!))))))
 
-(defn rename-when-alter-title-propertiy!
+(defn rename-when-alter-title-property!
   [page path format original-content content]
   (when (and page (contains? config/mldoc-support-formats format))
     (let [old-name page

+ 13 - 8
src/main/frontend/mixins.cljs

@@ -102,14 +102,19 @@
                 (when all-handler (all-handler e key-code)))))))
 
 (defn on-key-down
-  [state keycode-map all-handler]
-  (let [node (rum/dom-node state)]
-    (listen state js/window "keydown"
-            (fn [e]
-              (let [key-code (.-keyCode e)]
-                (when-let [f (get keycode-map key-code)]
-                  (f state e))
-                (when all-handler (all-handler e key-code)))))))
+  ([state keycode-map]
+   (on-key-down state keycode-map {}))
+  ([state keycode-map {:keys [not-matched-handler all-handler]}]
+   (let [node (rum/dom-node state)]
+     (listen state js/window "keydown"
+             (fn [e]
+               (let [key-code (.-keyCode e)]
+                 (if-let [f (get keycode-map key-code)]
+                   (f state e)
+                   (when (and not-matched-handler (fn? not-matched-handler))
+                     (not-matched-handler e key-code)))
+                 (when (and all-handler (fn? all-handler))
+                   (all-handler e key-code))))))))
 
 (defn event-mixin
   ([attach-listeners]

+ 1 - 1
src/main/frontend/state.cljs

@@ -285,7 +285,7 @@
   [input-id value]
   (when input-id
     (when-let [input (gdom/getElement input-id)]
-      (gobj/set input "value" value))
+      (util/set-change-value input value))
     (update-state! :editor/content (fn [m]
                                      (assoc m input-id value)))
     ;; followers

+ 25 - 18
src/main/frontend/ui.cljs

@@ -17,6 +17,22 @@
 (defonce transition-group (r/adapt-class TransitionGroup))
 (defonce css-transition (r/adapt-class CSSTransition))
 (defonce textarea (r/adapt-class (gobj/get TextareaAutosize "default")))
+
+(rum/defc ls-textarea [{:keys [on-change] :as -props}]
+  (let [composition? (atom false)
+        set-composition? #(reset! composition? %)
+        on-composition (fn [e]
+                         (case e.type
+                           "compositionend" (do (set-composition? false) (on-change e))
+                           (set-composition? true)))
+        props (assoc -props
+                     :on-change (fn [e] (when-not @composition?
+                                          (on-change e)))
+                     :on-composition-start on-composition
+                     :on-composition-update on-composition
+                     :on-composition-end on-composition)]
+    (textarea props)))
+
 (rum/defc dropdown-content-wrapper [state content class]
   (let [class (or class
                   (util/hiccup->class "origin-top-right.absolute.right-0.mt-2.rounded-md.shadow-lg"))]
@@ -292,7 +308,7 @@
                 :else
                 nil)
               (when-let [element (gdom/getElement (str "ac-" @current-idx))]
-                (let [ac-inner (gdom/getElement "ac-inner")
+                (let [ac-inner (gdom/getElement "ui__ac-inner")
                       element-top (gobj/get element "offsetTop")
                       scroll-top (- (gobj/get element "offsetTop") 360)]
                   (set! (.-scrollTop ac-inner) scroll-top)))))
@@ -306,7 +322,7 @@
                   (reset! current-idx 0)
                   (swap! current-idx inc)))
               (when-let [element (gdom/getElement (str "ac-" @current-idx))]
-                (let [ac-inner (gdom/getElement "ac-inner")
+                (let [ac-inner (gdom/getElement "ui__ac-inner")
                       element-top (gobj/get element "offsetTop")
                       scroll-top (- (gobj/get element "offsetTop") 360)]
                   (set! (.-scrollTop ac-inner) scroll-top)))))
@@ -320,8 +336,7 @@
                          (> (count matched)
                             @current-idx))
                   (on-chosen (nth matched @current-idx) false)
-                  (and on-enter (on-enter state))))))}
-      nil)))
+                  (and on-enter (on-enter state))))))})))
   [state matched {:keys [on-chosen
                          on-shift-chosen
                          on-enter
@@ -329,24 +344,16 @@
                          item-render
                          class]}]
   (let [current-idx (get state ::current-idx)]
-    [:div.py-1.rounded-md.shadow-xs.bg-base-3 {:class class}
+    [:div#ui__ac {:class class}
      (if (seq matched)
-       [:div#ac-inner
-        {:style {:max-height 400
-                 :overflow "hidden"
-                 :overflow-x "hidden"
-                 :overflow-y "auto"
-                 :position "relative"
-                 "-webkit-overflow-scrolling" "touch"}}
+       [:div#ui__ac-inner
         (for [[idx item] (medley/indexed matched)]
           (rum/with-key
             (menu-link
-             {:id (str "ac-" idx)
-              :style {:padding-top 6
-                      :padding-bottom 6}
-              :class (when (= @current-idx idx)
-                       "chosen")
-              ;; :tab-index -1
+             {:id       (str "ac-" idx)
+              :class    (when (= @current-idx idx)
+                          "chosen")
+                ;; :tab-index -1
               :on-click (fn [e]
                           (util/stop e)
                           (if (and (gobj/get e "shiftKey") on-shift-chosen)

+ 17 - 17
src/main/frontend/ui/date_picker.cljs

@@ -183,30 +183,30 @@
    (fn [state]
      (let [{:keys [on-change on-switch deadline-or-schedule?]} (last (:rum/args state))]
        (mixins/on-key-down
-       state
-       {;; enter, current day
-        13 (fn [state e]
-             (when on-change
-               (when-not deadline-or-schedule?
-                 (on-change e @*internal-model))))
+        state
+        {;; enter, current day
+         13 (fn [state e]
+              (when on-change
+                (when-not deadline-or-schedule?
+                  (on-change e @*internal-model))))
 
         ;; left, previous day
-        37 (fn [state e]
-             (swap! *internal-model inc-date -1))
+         37 (fn [state e]
+              (swap! *internal-model inc-date -1))
 
         ;; right, next day
-        39 (fn [state e]
-             (swap! *internal-model inc-date 1))
+         39 (fn [state e]
+              (swap! *internal-model inc-date 1))
 
         ;; up, one week ago
-        38 (fn [state e]
-             (swap! *internal-model inc-week -1))
+         38 (fn [state e]
+              (swap! *internal-model inc-week -1))
         ;; down, next week
-        40 (fn [state e]
-             (swap! *internal-model inc-week 1))}
-       (fn [e key-code]
-         (when (contains? #{13 37 38 39 40} key-code)
-           (util/stop e)))))))
+         40 (fn [state e]
+              (swap! *internal-model inc-week 1))}
+        {:all-handler (fn [e key-code]
+                        (when (contains? #{13 37 38 39 40} key-code)
+                          (util/stop e)))}))))
   {:init (fn [state]
            (reset! *internal-model (first (:rum/args state)))
            state)}

+ 9 - 4
src/main/frontend/util.cljs

@@ -38,6 +38,11 @@
   [event]
   (gobj/getValueByKeys event "target" "value"))
 
+(defn set-change-value
+  "compatible change event for React"
+  [node value]
+  (utils/triggerInputChange node value))
+
 (defn p-handle
   ([p ok-handler]
    (p-handle p ok-handler (fn [error]
@@ -253,7 +258,7 @@
   (try
     (bean/->clj ((gobj/get caret-pos "position") input))
     (catch js/Error e
-      nil)))
+      (js/console.error e))))
 
 (defn minimize-html
   [s]
@@ -434,12 +439,12 @@
 
 (defn textarea-cursor-first-row?
   [input line-height]
-  (< (:top (get-caret-pos input)) line-height))
+  (<= (:top (get-caret-pos input)) line-height))
 
 (defn textarea-cursor-end-row?
   [input line-height]
-  (> (+ (:top (get-caret-pos input)) line-height)
-     (get-textarea-height input)))
+  (>= (+ (:top (get-caret-pos input)) line-height)
+      (get-textarea-height input)))
 
 (defn safe-split-first [pattern s]
   (if-let [first-index (string/index-of s pattern)]

+ 57 - 34
src/main/frontend/utils.js

@@ -1,74 +1,97 @@
+if (typeof window === 'undefined') {
+  global.window = {}
+}
+
 // Copy from https://github.com/primetwig/react-nestable/blob/dacea9dc191399a3520f5dc7623f5edebc83e7b7/dist/utils.js
-export var closest = function closest(target, selector) {
+export var closest = function closest (target, selector) {
   // closest(e.target, '.field')
   while (target) {
-    if (target.matches && target.matches(selector)) return target;
-    target = target.parentNode;
+    if (target.matches && target.matches(selector)) return target
+    target = target.parentNode
   }
-  return null;
-};
+  return null
+}
 
-export var getOffsetRect = function getOffsetRect(elem) {
+export var getOffsetRect = function getOffsetRect (elem) {
   // (1)
-  var box = elem.getBoundingClientRect();
+  var box = elem.getBoundingClientRect()
 
-  var body = document.body;
-  var docElem = document.documentElement;
+  var body = document.body
+  var docElem = document.documentElement
 
   // (2)
-  var scrollTop = window.pageYOffset || docElem.scrollTop || body.scrollTop;
-  var scrollLeft = window.pageXOffset || docElem.scrollLeft || body.scrollLeft;
+  var scrollTop = window.pageYOffset || docElem.scrollTop || body.scrollTop
+  var scrollLeft = window.pageXOffset || docElem.scrollLeft || body.scrollLeft
 
   // (3)
-  var clientTop = docElem.clientTop || body.clientTop || 0;
-  var clientLeft = docElem.clientLeft || body.clientLeft || 0;
+  var clientTop = docElem.clientTop || body.clientTop || 0
+  var clientLeft = docElem.clientLeft || body.clientLeft || 0
 
   // (4)
-  var top = box.top + scrollTop - clientTop;
-  var left = box.left + scrollLeft - clientLeft;
+  var top = box.top + scrollTop - clientTop
+  var left = box.left + scrollLeft - clientLeft
 
-  return { top: Math.round(top), left: Math.round(left) };
-};
+  return { top: Math.round(top), left: Math.round(left) }
+}
 
 // jquery focus
-export var focus = function( elem ) {
+export var focus = function (elem) {
   return elem === document.activeElement &&
     document.hasFocus() &&
-    !!( elem.type || elem.href || ~elem.tabIndex );
+    !!(elem.type || elem.href || ~elem.tabIndex)
 }
 
 // copied from https://stackoverflow.com/a/32180863
 export var timeConversion = function (millisec) {
-  var seconds = (millisec / 1000).toFixed(0);
-  var minutes = (millisec / (1000 * 60)).toFixed(0);
-  var hours = (millisec / (1000 * 60 * 60)).toFixed(1);
-  var days = (millisec / (1000 * 60 * 60 * 24)).toFixed(1);
+  var seconds = (millisec / 1000).toFixed(0)
+  var minutes = (millisec / (1000 * 60)).toFixed(0)
+  var hours = (millisec / (1000 * 60 * 60)).toFixed(1)
+  var days = (millisec / (1000 * 60 * 60 * 24)).toFixed(1)
 
   if (seconds < 60) {
-    return seconds + "s";
+    return seconds + 's'
   } else if (minutes < 60) {
-    return minutes + "m";
+    return minutes + 'm'
   } else if (hours < 24) {
-    return hours + "h";
+    return hours + 'h'
   } else {
-    return days + "d"
+    return days + 'd'
   }
 }
 
-export var getSelectionText = function() {
-  const selection = (window.getSelection() || '').toString().trim();
+export var getSelectionText = function () {
+  const selection = (window.getSelection() || '').toString().trim()
   if (selection) {
-    return selection;
+    return selection
   }
 
   // Firefox fix
-  const activeElement = window.document.activeElement;
+  const activeElement = window.document.activeElement
   if (activeElement) {
     if (activeElement.tagName === 'INPUT' || activeElement.tagName === 'TEXTAREA') {
-      const el = activeElement;
-      return el.value.slice(el.selectionStart || 0, el.selectionEnd || 0);
+      const el = activeElement
+      return el.value.slice(el.selectionStart || 0, el.selectionEnd || 0)
     }
   }
 
-  return '';
+  return ''
+}
+
+const inputTypes = [
+  window.HTMLInputElement,
+  window.HTMLSelectElement,
+  window.HTMLTextAreaElement,
+]
+
+export const triggerInputChange = (node, value = '', name = 'change') => {
+
+  // only process the change on elements we know have a value setter in their constructor
+  if (inputTypes.indexOf(node.__proto__.constructor) > -1) {
+
+    const setValue = Object.getOwnPropertyDescriptor(node.__proto__, 'value').set
+    const event = new Event('change', { bubbles: true })
+
+    setValue.call(node, value)
+    node.dispatchEvent(event)
+  }
 }