diff --git a/frontend/src/arthur/domain/history.cljs b/frontend/src/arthur/domain/history.cljs index 8727624..2ff1dfa 100644 --- a/frontend/src/arthur/domain/history.cljs +++ b/frontend/src/arthur/domain/history.cljs @@ -69,17 +69,34 @@ (defn record "History `h` with an edit from leaves `before` to `after` at time `now`." - [{:keys [done] :as h} before after now] + [{:keys [done held?] :as h} before after now] (let [[b a] (changes before after) top (peek done)] (cond (empty? a) h - (and top (< (- now (:at top)) gap-ms) (= b (:after top))) - {:done (conj (pop done) (assoc top :after a :at now)) :undone []} + (and top (not (:closed? top)) (= b (:after top)) + (or held? (< (- now (:at top)) gap-ms))) + (assoc h :done (conj (pop done) (assoc top :after a :at now)) :undone []) :else - {:done (conj (vec (take-last (dec depth) done)) - {:before b :after a :at now :label (label before after (keys a))}) - :undone []}))) + (assoc h + :done (conj (vec (take-last (dec depth) done)) + {:before b :after a :at now :label (label before after (keys a))}) + :undone [])))) + +(defn- close [{:keys [done] :as h}] + (cond-> h (seq done) (assoc :done (conj (pop done) (assoc (peek done) :closed? true))))) + +(defn hold + "While a field has focus, everything typed into it is one step, however slowly + — the digits of 45 are seen as 4 and then 45, and undone as one. It starts a + step of its own rather than joining whatever came before." + [h] + (assoc (close h) :held? true)) + +(defn settle + "The field is done with: its step is finished, and nothing joins it." + [h] + (dissoc (close h) :held?)) (defn steps "The labels, newest first: `:done` is what undo would take off, `:undone` diff --git a/frontend/src/arthur/events/edit.cljs b/frontend/src/arthur/events/edit.cljs index e53ddb6..1730285 100644 --- a/frontend/src/arthur/events/edit.cljs +++ b/frontend/src/arthur/events/edit.cljs @@ -61,6 +61,14 @@ (not= (:fps clip) (get-in db [:clip :fps])) (update :clip merge {:fps (:fps clip) :display-fps (:fps clip)}))) +(defn history + "Apply `f` to the loaded entry's undo history, which is not an edit: nothing + is redrawn and nothing becomes unsaved." + [db f] + (if-let [id (store/edit-entry! (:clip/current db) #(update % :history f))] + (assoc db :clip/current id) + db)) + (defn edit "Apply `f` to the loaded clip and return the new db." [db f] diff --git a/frontend/src/arthur/events/history.cljs b/frontend/src/arthur/events/history.cljs index 106b17a..f137a85 100644 --- a/frontend/src/arthur/events/history.cljs +++ b/frontend/src/arthur/events/history.cljs @@ -55,6 +55,9 @@ (rf/reg-event-fx ::undo (fn [{:keys [db]} [_ n]] (steps db history/undo "undone" (or n 1)))) (rf/reg-event-fx ::redo (fn [{:keys [db]} [_ n]] (steps db history/redo "redone" (or n 1)))) +(rf/reg-event-db ::hold (fn [db _] (edit/history db history/hold))) +(rf/reg-event-db ::settle (fn [db _] (edit/history db history/settle))) + (rf/reg-sub ::steps ;; The history is on the entry, outside app-db; the revision is what moves diff --git a/frontend/src/arthur/ui/params.cljs b/frontend/src/arthur/ui/params.cljs index 674614c..85d74f7 100644 --- a/frontend/src/arthur/ui/params.cljs +++ b/frontend/src/arthur/ui/params.cljs @@ -13,6 +13,7 @@ [arthur.domain.node :as node] [arthur.domain.paint :as paint] [arthur.domain.params :as params] + [arthur.events.history :as history] [arthur.events.paint :as paint-events] [arthur.events.playback :as pb] [arthur.events.project :as project] @@ -20,7 +21,8 @@ [arthur.subs.playback :as playback] [arthur.subs.render :as render] [arthur.subs.ui :as sub] - [re-frame.core :as rf])) + [re-frame.core :as rf] + [reagent.core :as r])) (defn- brief "A value that fits the column. The `facts` list puts the whole thing in a @@ -39,14 +41,37 @@ (defn- section [title & body] (into [:section.section [:h2 title]] body)) +(defn- number-input + "A number box that edits as it is typed in or stepped, and is one undo step + from focus to blur. What is typed is kept while it has focus — \"-\" or + \"4.\" is no number yet, and the value it would round-trip to must not + replace it under the caret." + [_] + (let [draft (r/atom nil)] + (fn [{:keys [value parse on-number] :as attrs}] + [:input (merge (dissoc attrs :value :parse :on-number) + {:type "number" + :value (or @draft value "") + :on-focus (fn [e] + (reset! draft (.. e -target -value)) + (rf/dispatch [::history/hold])) + :on-blur (fn [_] + (reset! draft nil) + (rf/dispatch [::history/settle])) + :on-key-down #(when (= "Enter" (.-key %)) (.. % -target blur)) + :on-change (fn [e] + (let [s (.. e -target -value) + n (parse s)] + (when @draft (reset! draft s)) + (when-not (js/isNaN n) (on-number n))))})]))) + (defn- number-field [label value on-change & [placeholder disabled?]] [:label.inspector-field label - [:input {:type "number" :min 1 :step 1 :value (or value "") - :placeholder placeholder - :disabled disabled? - :on-change (fn [e] - (let [v (.. e -target -value)] - (on-change (when (seq v) (js/parseInt v 10)))))}]]) + [number-input {:min 1 :step 1 :value value + :placeholder placeholder + :disabled disabled? + :parse #(if (seq %) (js/parseInt % 10) nil) + :on-number on-change}]]) ;; --------------------------------------------------------------------------- ;; the clip @@ -151,19 +176,12 @@ off? (and keyed? (nil? frame)) deg? (= path [:xform :rot]) put #(rf/dispatch [::project/set-channel sid id path frame %]) - ;; Written on blur or Enter rather than per keystroke, so a typed - ;; number is one edit. Keyed by what it shows, so a seek or an undo - ;; that changes the value puts the new one in the box. field (fn [i x on-number] - (let [shown (if deg? (/ (js/Math.round (* x (/ 18000 js/Math.PI))) 100) x)] - ^{:key (str i " " shown)} - [:input {:type "number" :step "any" :disabled off? - :default-value shown - :on-key-down #(when (= "Enter" (.-key %)) (.. % -target blur)) - :on-blur (fn [e] - (let [n (js/parseFloat (.. e -target -value))] - (when-not (or (js/isNaN n) (= n shown)) - (on-number (if deg? (* n (/ js/Math.PI 180)) n)))))}]))] + ^{:key i} + [number-input {:step "any" :disabled off? + :value (if deg? (/ (js/Math.round (* x (/ 18000 js/Math.PI))) 100) x) + :parse js/parseFloat + :on-number #(on-number (if deg? (* % (/ js/Math.PI 180)) %))}])] [:dd.channel [:button.key {:class (cond (contains? (:keys ch) frame) "on" keyed? "keyed") :disabled (nil? frame) diff --git a/frontend/test/arthur/domain/history_test.cljs b/frontend/test/arthur/domain/history_test.cljs index 7acc6ec..deefb2e 100644 --- a/frontend/test/arthur/domain/history_test.cljs +++ b/frontend/test/arthur/domain/history_test.cljs @@ -23,6 +23,17 @@ (is (= 1 (count (:done h)))) (is (= {"v" 0} (:leaves (history/undo h {"v" 3})))))) +(deftest typing-into-a-field-is-one-step-however-slow + (let [h (-> nil + (history/record {"w" 1} {"w" 2} 0) + history/hold + (history/record {"w" 2} {"w" 4} 100) + (history/record {"w" 4} {"w" 45} 9000) + history/settle + (history/record {"w" 45} {"w" 46} 9100))] + (is (= 3 (count (:done h))) "the edit before focus and the one after blur stand apart") + (is (= {"w" 2} (:leaves (history/undo (:history (history/undo h {"w" 46})) {"w" 45})))))) + (deftest their-write-between-two-of-mine-keeps-them-apart (let [h (-> nil (history/record {"fps" 30} {"fps" 12} 0)