Search code examples
clojureclojurescriptreagent

In ClojureScript, delay `:on-click` event and trigger only if `:on-double-click` event is not triggered


What is a simple way to delay :on-click event to see first if :on-double-click event is triggered?

[:div {:on-click (fn [e]
                   ;; listen to double-click event, within 500ms, 
                   ;; if so on-double-click-fn, 
                   ;; if not, on-click-fn
                  )
       :on-double-click (fn [e] 
                          ;; on-click-fn
                         )}]

Thanks!

First attempt:

(defn sleep [timeout]
  (let [maxtime (+ (.getTime (js/Date.)) timeout)]
    (while (< (.getTime (js/Date.)) maxtime))))

[:div {:on-click (fn [e] (sleep 500) (print "single-clicked"))
       :on-double-click (fn [e] (print "double-clicked"))}]

Second attempt:

(def state (atom {:click-count 0}))

(defn handle-click [e click-fns-map]
  (swap! state update :click-count inc)
  (sleep 500)
  (let [click-count (get @state :click-count)]
    (swap! state assoc :click-count 0)
    (cond
      (= click-count 1) ((:on-single-click click-fns-map) e)
      (> click-count 1) ((:on-double-click click-fns-map) e)))))

[:div 
 {:on-mouse-down 
  (fn [e]
    (handle-click e {:on-single-click #(print "single-click")
                     :on-double-click #(print "double-click")}))}]

 ;;=> "single-click"
 ;;=> "single-click"

EDIT:

Based on Taylor Wood's answer, here is an abstraction that wraps html element args and overwrites :on-click and :on-double-click for you.

(defn ensure-single-double-click 
  [{:keys [on-click on-double-click] :as args}]
  (let [waiting? (atom false)]
    (merge 
     args
     {:on-click (fn [e] 
                  (when (compare-and-set! waiting? false true)
                    (js/setTimeout 
                     (fn [] (when @waiting?
                            (on-click %)
                            (reset! waiting? false)))
                     300)))
      :on-double-click (fn [e] 
                         (reset! waiting? false)
                         (on-double-click %))})))

[:a (ensure-single-double-click
      {:style           {:color "blue"} ;; this works
       :on-click        #(print "single-click")
       :on-double-click #(print "double-click")})
    "test"]

Solution

  • Taylor Wood's answer comes close, but compare-and-set! did not protect me from triple clicks (or even more clicks!), because if, say, three clicks happen within 500ms, waiting? will be set to false again the third time around and a second timeout is scheduled. I think this means a new timeout is technically scheduled upon every odd numbered click.

    Luckily, the click event comes with a property called detail, which is set to the number of consecutive clicks. I found it here. The following should solve the OP's problem without allowing for triple clicks:

    :on-click
    (fn [e]
     ; This prevents the click handler from running
     ; a second, third, or other time.
     (when (-> e .-detail (= 1))
       (reset! waiting? true))
       ; Wait an appropriate time for the double click
       ; to happen...
       (js/setTimeout
         (fn []
           ; If we are still waiting for the double click
           ; to happen, it didn't happen!
           (when @waiting?
             (single-click-fn %)
             (reset! waiting? false)))
         500)))
    :on-double-click #(do (reset! waiting? false)
                          (double-click-fn %))
    

    As exotic as triple clicks may sound, they do serve a purpose: to select an entire line of text, so I wouldn't want the user to miss out on that ability.


    The rest is an addendum for those interested in making text selection work on elements that are listening for a single click. I came here from Google looking for how to do this, so maybe it helps someone.

    A challenge I ran into is that my application dictated that the user can perform a double click without releasing the second click at first; a bit like "one-and-a-half-clicks". Why? Because I am listening for clicks on a span, and the user may well perform a double click to select a whole word, but then keep the second click pressed and drag his mouse in order to select additional words next to the original one. The problem is that the double click event handler only fires after the user releases the second click, and so waiting? is not set to false on time.

    I solved this using an :on-mouse-down handler:

    :on-click
    (fn [e]
     ; This prevents the click handler from running
     ; a second, third, or other time.
     (when (-> e .-detail (= 1))
       (reset! waiting? true))
       ; Wait an appropriate time for the double click
       ; to happen...
       (js/setTimeout
         (fn []
           ; If we are still waiting for the double click
           ; to happen, it didn't happen!
           (when @waiting?
             (single-click-fn %)
             (reset! waiting? false)))
         500)))
    :on-double-click #(double-click-fn %)
    :on-mouse-down #(reset! waiting? false)
    

    Remember that the :on-click and :on-double-click handlers only fire upon release (and that the handlers fire in the order of mouse-down, click, double-click), which gives the :on-mouse-down handler an opportunity to set waiting? to false, which is needed if the user hasn't released the mouse yet, because he won't have triggered the :on-double-click event handler.

    Note that now you don't even need to set waiting? to false in your double click handler anymore, because the mouse down handler has already done that by the time the double click handler is run.

    Lastly, in my particular application, it so happens that a user may want to select text without triggering the click handler. In order to do this, he will click on a piece of text, and, without releasing the mouse, drag the cursor to select more text. When the cursor is released, that should not trigger the click event. So I had to additionally track whether the user had made a selection at any time before he let go of the mouse (a sort of "half-click"). For this case, I had to add a couple more things to the component's state (a boolean atom called selection-made? and an event handler function called selection-handler). This case relies on detection of selection, and, since a selection is made on double click, does not need to check the event's detail property anymore to protect against triple or more clicks.

    The whole solution looks like this (but keep in mind that this is specifically for text elements, so just an addition to what the OP asked for):

    (defn component
      []
      (let [waiting? (r/atom false)
            selection-made? (r/atom false)
            selection-handler
            (fn []
              (println "selection-handler running")
              (when (seq (.. js/document getSelection toString))
                (reset! selection-made? true)))]
        (fn []
          [:div
            ; For debugging
            [:pre {} "waiting? " (str @waiting?)]
            [:pre {} "selection-made? " (str @selection-made?)]
            ; Your clickable element
            [:span
              {:on-click
               (fn [e]
                (println "click handler triggered")
                ; Remove the selection handler in any case because
                ; there is a small chance that the selection handler
                ; was triggered without selecting any text (by
                ; holding down the mouse on the text for a little
                ; while without moving it).
                (.removeEventListener js/document "selectionchange" selection-handler)
                (if @selection-made?
                  ; If a selection was made, only perform cleanup.
                  (reset! selection-made? false)
                  ; If no selection was made, treat it as a
                  ; simple click for now...
                  (do
                    (reset! waiting? true)
                    ; Wait an appropriate amount of time for the
                    ; double click to happen...
                    (js/setTimeout
                      (fn []
                        ; If we are still waiting for the double click
                        ; to happen, it didn't happen! The mouse-down
                        ; handler would have set waiting? to false
                        ; by now if it had been clicked a second time.
                        ; (Remember that the mouse down handler runs
                        ; before the click handler since the click handler
                        ; runs only once the mouse is released.
                        (when @waiting?
                          (single-click-fn e)
                          (reset! waiting? false)))
                      500))))
               :on-mouse-down
               (fn [e]
                ; Set this for the click handler in case a double
                ; click is happening.
                (reset! waiting? false)
                ; Only run this if it is a left click, or the event
                ; listener is not removed until a single click on this
                ; segment is performed again, and will listen on
                ; every click everywhere in the window.
                (when (-> e .-button zero?)
                  (js/console.log "mouse down handler running")
                  (.addEventListener js/document "selectionchange" selection-handler)))
               :on-double-click #(double-click-fn %)}
              some content here]])))