Last active
August 29, 2015 14:21
-
-
Save mathieulegrand/b9da9e102dff3c454e30 to your computer and use it in GitHub Desktop.
Editable DIV as a IRC style input bar (textarea replacement) in ClojureScript
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
(ns potato.keyboard | |
(:require [clojure.string] | |
[goog.dom] | |
[goog.style] | |
[goog.events] | |
[goog.dom.xml] | |
[goog.dom.classlist] | |
[goog.dom.selection] | |
[goog.testing.events] | |
[goog.events.EventTarget] | |
[goog.events.KeyHandler] | |
[goog.events.PasteHandler] | |
[goog.events.BrowserEvent] | |
[goog.dom.Range] | |
[goog.editor.node] | |
[goog.editor.range] | |
[goog.Uri])) | |
(def ENTER 13) | |
(def ESC 27) | |
(defn has-keyboard [] | |
"test whether ?keyboard=1 is set as an URL parameter" | |
(let [param (clojure.string/lower-case (or (.getParameterValue (goog.Uri/parse js/location) "keyboard") ""))] | |
(or (= param "1") (= param "yes") (= param "true")))) | |
(defn content [editable] | |
(goog.dom/getRawTextContent (:div @editable))) | |
(defn empty! [editable] | |
(goog.dom/setTextContent (:div @editable) "") | |
true) | |
(defn destroy! [editable] | |
(swap! editable assoc :active false) | |
(when (:placeholder @editable) | |
(goog.dom/removeNode (:placeholder @editable))) | |
(goog.dom/removeNode (:div @editable)) | |
(swap! (:keyhandler @editable) update-in [:editables-list] (fn [alist] (remove #(= editable %) alist)))) | |
(defn set-placeholder! [editable placeholder-text] | |
(when (:active @editable) | |
(when (and (not (:placeholder @editable)) placeholder-text) | |
(let [editable-div (:div @editable) | |
placeholder (goog.dom/createDom "span" #js {:className "placeholder" | |
:onClick #(.focus editable-div)} placeholder-text)] | |
(when-not (> (count (content editable)) 0) | |
(goog.dom/insertSiblingBefore placeholder editable-div) | |
(swap! editable assoc :placeholder placeholder))) | |
(js/setTimeout #(set-placeholder! editable placeholder-text) 10000)))) | |
(defn reset-cursor! [keyhandler & [editable]] | |
"Place cursor in editable (or default) at the end of the content" | |
(let [editable-div (or editable (:div (deref (:default-editable @keyhandler)))) | |
cursor-position (goog.editor.node/getRightMostLeaf editable-div)] | |
(if (= cursor-position editable-div) | |
(.select (goog.dom.Range/createCaret editable-div 0)) | |
(goog.editor.range/placeCursorNextTo (goog.editor.node/getRightMostLeaf editable-div) false)))) | |
(defn really-focus [mydiv & {:keys [timeout] :or [timeout 100]}] | |
"Really focus a given DIV by calling the Javascript focus now and after a timeout" | |
(.focus mydiv) | |
(js/setTimeout #(.focus mydiv) timeout)) | |
(defn append-editable-div [opts] | |
"append an editable div channel-input within the :parent-node DOM element" | |
;; e.g. (append-editable-div {:id "channel-input" | |
;; :keyboard (potato.keyboard/init dom-body) | |
;; :text-content "Default content" | |
;; :parent-node dom-node}) | |
(let [keyhandler (:keyboard opts) | |
editable-div (goog.dom/createDom "div" #js {:id (or (:id opts) "channel-input") | |
:spellcheck (or (:spellcheck opts) true) | |
:contentEditable true | |
:role "textbox"} (:text-content opts))] | |
(goog.dom/append (:parent-node opts) editable-div) | |
(let [paste-event-handler (goog.events.PasteHandler. editable-div)] | |
(goog.events/listen paste-event-handler (.-PASTE goog.events.PasteHandler/EventType) | |
(fn [event] (.preventDefault event) | |
(let [clipboard-text (.getData (.-clipboardData (.getBrowserEvent event)) "text/plain")] | |
(.execCommand js/document "insertText" false clipboard-text))))) | |
(really-focus editable-div) | |
(reset-cursor! keyhandler editable-div) | |
(let [editable-definition (atom {:active true | |
:locked false | |
:saved-opts opts | |
:keyhandler keyhandler | |
:div editable-div | |
:typing-callback (:typing-callback opts) | |
:new-size-callback (:new-size-callback opts) | |
:previous-height (.-height (goog.style/getSize editable-div))})] | |
(when (:placeholder-text opts) | |
(set-placeholder! editable-definition (:placeholder-text opts))) | |
(if (= (count (:editables-list @keyhandler)) 0) | |
(swap! keyhandler assoc :default-editable editable-definition)) | |
(swap! keyhandler update-in [:editables-list] #(conj % editable-definition)) | |
editable-definition))) | |
(defn set-callback-for-editable [editable event callback] | |
"Add a callback for pre-defined event on the given editable" | |
;; e.g. (set-callback-for-editable editable ENTER #(print "ENTER")) | |
(swap! editable assoc event callback)) | |
(defn- switch-editable [& {:keys [enable disable] :or {enable false disable false}}] | |
"unlocking or locking the editable passed as :enable or :disable" | |
(when-let [dom-node (:div (deref (or enable disable)))] | |
;(.log js/console (if enable "unlocking" "locking") dom-node) | |
(goog.dom.xml/setAttributes dom-node #js {:contentEditable (not disable)}) | |
(if disable | |
(goog.dom.classlist/add dom-node "locked") | |
(goog.dom.classlist/remove dom-node "locked"))) | |
(swap! (or enable disable) assoc :locked (not enable))) | |
(defn- switch-editables [keyhandler mode & [editable]] | |
"proxy function for enable and disable, mode is :enable or :disable" | |
(if editable | |
(doseq [elem (:editables-list @keyhandler)] (if (= editable elem) (switch-editable mode elem))) | |
(doseq [elem (:editables-list @keyhandler)] (switch-editable mode elem)))) | |
(defn disable [keyhandler & [editable]] | |
"Prevent the editable div from receiving input" | |
(switch-editables keyhandler :disable editable)) | |
(defn enable [keyhandler & [editable]] | |
"Enable the editable div to receive input" | |
(switch-editables keyhandler :enable editable)) | |
(defn- is-platform-special? [event] | |
"Return TRUE if we detect Ctrl-TAB, Meta-TAB, Ctrl-C, Meta-C" | |
(and (.-platformModifierKey event) | |
(or (= (or (and goog.userAgent.MAC (.-META goog.events/KeyCodes)) | |
(.-CTRL goog.events/KeyCodes)) (.-keyCode event)) | |
(= 99 (.-keyCode event)) ;; upper-case C letter | |
(= 67 (.-keyCode event))))) ;; lower-case c letter | |
(defn- match-action [event editable]) | |
(defn- act-on-editable-if-matching [event editable] | |
;; the editable needs to be active, not locked, and to match our target | |
(when (or (and (:active @editable) (not (:locked @editable)) | |
(= (.-target event) (:div @editable)))) | |
;; a key was typed in this input area, remove the placeholder | |
(when (:placeholder @editable) | |
(goog.dom/removeNode (:placeholder @editable)) | |
(swap! editable dissoc :placeholder)) | |
;; send a "is typing..." notification back via the callback if defined | |
(let [typing-callback (:typing-callback @editable)] | |
(if typing-callback (typing-callback))) | |
;; check whether the size has changed and callback when it did | |
(let [current-height (.-height (goog.style/getSize (:div @editable)))] | |
(when-not (= current-height (:previous-height @editable)) | |
(swap! editable assoc :previous-height current-height) | |
(let [new-size-callback (:new-size-callback @editable)] | |
(if new-size-callback (new-size-callback))))) | |
;; call the specific keys callback if defined | |
(condp = (.-keyCode event) | |
ENTER (if-not (or (.-shiftKey event) (.-repeat event) (.-ctrlKey event)) | |
(let [on-enter (get @editable ENTER)] | |
(when on-enter (.preventDefault event) (on-enter)))) | |
ESC (let [on-escape (get @editable ESC)] | |
(when on-escape (.preventDefault event) (on-escape))) | |
(match-action event editable)) | |
;; return true when we matched the div | |
true)) | |
(defn- on-key-event [event keyhandler] | |
(let [current-target-tag (clojure.string/lower-case (.-tagName (.-target event))) | |
editables (:editables-list @keyhandler)] | |
;; ignore legacy targets, i.e. the search box and platform specific combos (Ctrl-TAB, Ctrl-V) | |
(when-not (or (is-platform-special? event) (= current-target-tag "input") (= current-target-tag "textarea")) | |
;; look for one single editable that's active and targetted | |
(if-not (some #(act-on-editable-if-matching event %) editables) | |
;; if we didn't find any, then try to move to the default target instead | |
(let [default-target (deref (:default-editable @keyhandler)) | |
new-target-div (:div default-target)] | |
(when (and (:active default-target) (not (:locked default-target))) | |
(really-focus new-target-div) | |
(reset-cursor! keyhandler new-target-div) | |
;; this does not work well on Firefox: the character doesn't get inserted in the div | |
(goog.testing.events/fireKeySequence new-target-div (.-keyCode event)))))))) | |
(defn init [root-node] | |
"initialise a keyhandler for the elements contained in the DOM root-node" | |
(let [listenable (goog.events.KeyHandler. root-node) | |
keyhandler (atom {:editables-list [] | |
:listenable listenable})] | |
(goog.events/listen listenable (.-KEY goog.events.KeyHandler/EventType) #(on-key-event % keyhandler) true) | |
(goog.events/listen root-node (.-BLUR goog.events/EventType) #()) | |
keyhandler)) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment