;; This Source Code Form is subject to the terms of the Mozilla Public ;; License, v. 2.0. If a copy of the MPL was not distributed with this ;; file, You can obtain one at http://mozilla.org/MPL/2.0/. ;; ;; Copyright (c) KALEIDOS INC (ns app.main.data.workspace.transforms "Events related with shapes transformations" (:require [app.common.data :as d] [app.common.geom.matrix :as gmt] [app.common.geom.point :as gpt] [app.common.geom.shapes :as gsh] [app.common.math :as mth] [app.common.pages.changes-builder :as pcb] [app.common.pages.common :as cpc] [app.common.pages.helpers :as cph] [app.common.spec :as us] [app.common.types.shape-tree :as ctst] [app.main.data.workspace.changes :as dch] [app.main.data.workspace.collapse :as dwc] [app.main.data.workspace.comments :as-alias dwcm] [app.main.data.workspace.guides :as-alias dwg] [app.main.data.workspace.selection :as dws] [app.main.data.workspace.state-helpers :as wsh] [app.main.data.workspace.undo :as dwu] [app.main.snap :as snap] [app.main.streams :as ms] [beicon.core :as rx] [cljs.spec.alpha :as s] [potok.core :as ptk])) ;; -- Helpers -------------------------------------------------------- ;; For each of the 8 handlers gives the multiplier for resize ;; for example, right will only grow in the x coordinate and left ;; will grow in the inverse of the x coordinate (def ^:private handler-multipliers {:right [ 1 0] :bottom [ 0 1] :left [-1 0] :top [ 0 -1] :top-right [ 1 -1] :top-left [-1 -1] :bottom-right [ 1 1] :bottom-left [-1 1]}) (defn- handler-resize-origin "Given a handler, return the coordinate origin for resizes. This is the opposite of the handler so for right we want the left side as origin of the resize. sx, sy => start x/y mx, my => middle x/y ex, ey => end x/y " [{sx :x sy :y :keys [width height]} handler] (let [mx (+ sx (/ width 2)) my (+ sy (/ height 2)) ex (+ sx width) ey (+ sy height) [x y] (case handler :right [sx my] :bottom [mx sy] :left [ex my] :top [mx ey] :top-right [sx ey] :top-left [ex ey] :bottom-right [sx sy] :bottom-left [ex sy])] (gpt/point x y))) (defn- fix-init-point "Fix the initial point so the resizes are accurate" [initial handler shape] (let [{:keys [x y width height]} (:selrect shape)] (cond-> initial (contains? #{:left :top-left :bottom-left} handler) (assoc :x x) (contains? #{:right :top-right :bottom-right} handler) (assoc :x (+ x width)) (contains? #{:top :top-right :top-left} handler) (assoc :y y) (contains? #{:bottom :bottom-right :bottom-left} handler) (assoc :y (+ y height))))) (defn finish-transform [] (ptk/reify ::finish-transform ptk/UpdateEvent (update [_ state] (update state :workspace-local dissoc :transform)))) ;; -- Temporary modifiers ------------------------------------------- ;; During an interactive transformation of shapes (e.g. when resizing or rotating ;; a group with the mouse), there are a lot of objects that need to be modified ;; (in this case, the group and all its children). ;; ;; To avoid updating the shapes theirselves, and forcing redraw of all components ;; that depend on the "objects" global state, we set a "modifiers" structure, with ;; the changes that need to be applied, and store it in :workspace-modifiers global ;; variable. The viewport reads this and merges it into the objects list it uses to ;; paint the viewport content, redrawing only the objects that have new modifiers. ;; ;; When the interaction is finished (e.g. user releases mouse button), the ;; apply-modifiers event is done, that consolidates all modifiers into the base ;; geometric attributes of the shapes. (declare clear-local-transform) (declare get-ignore-tree) (defn set-modifiers ([ids] (set-modifiers ids nil false)) ([ids modifiers] (set-modifiers ids modifiers false)) ([ids modifiers ignore-constraints] (set-modifiers ids modifiers ignore-constraints false)) ([ids modifiers ignore-constraints ignore-snap-pixel] (us/verify (s/coll-of uuid?) ids) (ptk/reify ::set-modifiers ptk/UpdateEvent (update [_ state] (let [objects (wsh/lookup-page-objects state) ids (into #{} (remove #(get-in objects [% :blocked] false)) ids) snap-pixel? (and (not ignore-snap-pixel) (contains? (:workspace-layout state) :snap-pixel-grid)) modif-tree (gsh/set-objects-modifiers ids objects (constantly modifiers) ignore-constraints snap-pixel?)] (update state :workspace-modifiers merge modif-tree)))))) ;; Rotation use different algorithm to calculate children modifiers (and do not use child constraints). (defn- set-rotation-modifiers ([angle shapes] (set-rotation-modifiers angle shapes (-> shapes gsh/selection-rect gsh/center-selrect))) ([angle shapes center] (ptk/reify ::set-rotation-modifiers ptk/UpdateEvent (update [_ state] (let [objects (wsh/lookup-page-objects state) ids (->> shapes (remove #(get % :blocked false)) (mapcat #(cph/get-children objects (:id %))) (concat shapes) (filter #((cpc/editable-attrs (:type %)) :rotation)) (map :id)) get-modifier (fn [shape] (gsh/rotation-modifiers shape center angle)) modif-tree (gsh/set-objects-modifiers ids objects get-modifier false false)] (update state :workspace-modifiers merge modif-tree)))))) (defn- update-grow-type [shape old-shape] (let [auto-width? (= :auto-width (:grow-type shape)) auto-height? (= :auto-height (:grow-type shape)) changed-width? (not (mth/close? (:width shape) (:width old-shape))) changed-height? (not (mth/close? (:height shape) (:height old-shape))) change-to-fixed? (or (and auto-width? (or changed-height? changed-width?)) (and auto-height? changed-height?))] (cond-> shape change-to-fixed? (assoc :grow-type :fixed)))) (defn apply-modifiers ([] (apply-modifiers nil)) ([{:keys [undo-transation?] :or {undo-transation? true}}] (ptk/reify ::apply-modifiers ptk/WatchEvent (watch [_ state _] (let [objects (wsh/lookup-page-objects state) object-modifiers (get state :workspace-modifiers) ids (keys object-modifiers) ids-with-children (into (vec ids) (mapcat #(cph/get-children-ids objects %)) ids) shapes (map (d/getf objects) ids) ignore-tree (->> (map #(get-ignore-tree object-modifiers objects %) shapes) (reduce merge {}))] (rx/concat (if undo-transation? (rx/of (dwu/start-undo-transaction)) (rx/empty)) (rx/of (ptk/event ::dwg/move-frame-guides ids-with-children) (ptk/event ::dwcm/move-frame-comment-threads ids-with-children) (dch/update-shapes ids (fn [shape] (let [modif (get object-modifiers (:id shape)) text-shape? (cph/text-shape? shape)] (-> shape (merge modif) (gsh/transform-shape) (cond-> text-shape? (update-grow-type shape))))) {:reg-objects? true :ignore-tree ignore-tree ;; Attributes that can change in the transform. This way we don't have to check ;; all the attributes :attrs [:selrect :points :x :y :width :height :content :transform :transform-inverse :rotation :position-data :flip-x :flip-y :grow-type]}) (clear-local-transform)) (if undo-transation? (rx/of (dwu/commit-undo-transaction)) (rx/empty)))))))) (defn- check-delta "If the shape is a component instance, check its relative position respect the root of the component, and see if it changes after applying a transformation." [shape root transformed-shape transformed-root objects modif-tree] (let [root (cond (:component-root? shape) shape (nil? root) (cph/get-root-shape objects shape) :else root) transformed-root (cond (:component-root? transformed-shape) transformed-shape (nil? transformed-root) (as-> (cph/get-root-shape objects transformed-shape) $ (gsh/transform-shape (merge $ (get modif-tree (:id $))))) :else transformed-root) shape-delta (when root (gpt/point (- (gsh/left-bound shape) (gsh/left-bound root)) (- (gsh/top-bound shape) (gsh/top-bound root)))) transformed-shape-delta (when transformed-root (gpt/point (- (gsh/left-bound transformed-shape) (gsh/left-bound transformed-root)) (- (gsh/top-bound transformed-shape) (gsh/top-bound transformed-root)))) ;; There are cases in that the coordinates change slightly (e.g. when ;; rounding to pixel, or when recalculating text positions in different ;; zoom levels). To take this into account, we ignore movements smaller ;; than 1 pixel. distance (if (and shape-delta transformed-shape-delta) (gpt/distance-vector shape-delta transformed-shape-delta) (gpt/point 0 0)) ignore-geometry? (and (< (:x distance) 1) (< (:y distance) 1))] [root transformed-root ignore-geometry?])) (defn- get-ignore-tree "Retrieves a map with the flag `ignore-geometry?` given a tree of modifiers" ([modif-tree objects shape] (get-ignore-tree modif-tree objects shape nil nil {})) ([modif-tree objects shape root transformed-root ignore-tree] (let [children (map (d/getf objects) (:shapes shape)) shape-id (:id shape) transformed-shape (gsh/transform-shape (merge shape (get modif-tree shape-id))) [root transformed-root ignore-geometry?] (check-delta shape root transformed-shape transformed-root objects modif-tree) ignore-tree (assoc ignore-tree shape-id ignore-geometry?) set-child (fn [ignore-tree child] (get-ignore-tree modif-tree objects child root transformed-root ignore-tree))] (reduce set-child ignore-tree children)))) (defn- clear-local-transform [] (ptk/reify ::clear-local-transform ptk/UpdateEvent (update [_ state] (-> state (dissoc :workspace-modifiers) (dissoc ::current-move-selected))))) ;; -- Resize -------------------------------------------------------- (defn start-resize "Enter mouse resize mode, until mouse button is released." [handler ids shape] (letfn [(resize [shape initial layout [point lock? center? point-snap]] (let [{:keys [width height]} (:selrect shape) {:keys [rotation]} shape shape-center (gsh/center-shape shape) shape-transform (:transform shape) shape-transform-inverse (:transform-inverse shape) rotation (or rotation 0) initial (gsh/transform-point-center initial shape-center shape-transform-inverse) initial (fix-init-point initial handler shape) point (gsh/transform-point-center (if (= rotation 0) point-snap point) shape-center shape-transform-inverse) shapev (-> (gpt/point width height)) scale-text (:scale-text layout) ;; Force lock if the scale text mode is active lock? (or lock? scale-text) ;; Vector modifiers depending on the handler handler-mult (let [[x y] (handler-multipliers handler)] (gpt/point x y)) ;; Difference between the origin point in the coordinate system of the rotation deltav (-> (gpt/to-vec initial point) (gpt/multiply handler-mult)) ;; Resize vector scalev (-> (gpt/divide (gpt/add shapev deltav) shapev) (gpt/no-zeros)) scalev (if lock? (let [v (cond (#{:right :left} handler) (:x scalev) (#{:top :bottom} handler) (:y scalev) :else (max (:x scalev) (:y scalev)))] (gpt/point v v)) scalev) ;; Resize origin point given the selected handler handler-origin (handler-resize-origin (:selrect shape) handler) ;; If we want resize from center, displace the shape ;; so it is still centered after resize. displacement (when center? (-> shape-center (gpt/subtract handler-origin) (gpt/multiply scalev) (gpt/add handler-origin) (gpt/subtract shape-center) (gpt/multiply (gpt/point -1 -1)) (gpt/transform shape-transform))) resize-origin (cond-> (gsh/transform-point-center handler-origin shape-center shape-transform) (some? displacement) (gpt/add displacement)) displacement (when (some? displacement) (gmt/translate-matrix displacement))] (rx/of (set-modifiers ids {:displacement displacement :resize-vector scalev :resize-origin resize-origin :resize-transform shape-transform :resize-scale-text scale-text :resize-transform-inverse shape-transform-inverse})))) ;; Unifies the instantaneous proportion lock modifier ;; activated by Shift key and the shapes own proportion ;; lock flag that can be activated on element options. (normalize-proportion-lock [[point shift? alt?]] (let [proportion-lock? (:proportion-lock shape)] [point (or proportion-lock? shift?) alt?]))] (reify ptk/UpdateEvent (update [_ state] (-> state (assoc-in [:workspace-local :transform] :resize))) ptk/WatchEvent (watch [_ state stream] (let [initial-position @ms/mouse-position stoper (rx/filter ms/mouse-up? stream) layout (:workspace-layout state) page-id (:current-page-id state) focus (:workspace-focus-selected state) zoom (get-in state [:workspace-local :zoom] 1) objects (wsh/lookup-page-objects state page-id) resizing-shapes (map #(get objects %) ids)] (rx/concat (->> ms/mouse-position (rx/with-latest-from ms/mouse-position-shift ms/mouse-position-alt) (rx/map normalize-proportion-lock) (rx/switch-map (fn [[point _ _ :as current]] (->> (snap/closest-snap-point page-id resizing-shapes objects layout zoom focus point) (rx/map #(conj current %))))) (rx/mapcat (partial resize shape initial-position layout)) (rx/take-until stoper)) (rx/of (apply-modifiers) (finish-transform)))))))) (defn update-dimensions "Change size of shapes, from the sideber options form. Will ignore pixel snap used in the options side panel" [ids attr value] (us/verify (s/coll-of ::us/uuid) ids) (us/verify #{:width :height} attr) (us/verify ::us/number value) (ptk/reify ::update-dimensions ptk/UpdateEvent (update [_ state] (let [objects (wsh/lookup-page-objects state) snap-pixel? (and (contains? (:workspace-layout state) :snap-pixel-grid) (int? value)) get-modifier (fn [shape] (gsh/resize-modifiers shape attr value)) modif-tree (gsh/set-objects-modifiers ids objects get-modifier false snap-pixel?)] (assoc state :workspace-modifiers modif-tree))) ptk/WatchEvent (watch [_ _ _] (rx/of (apply-modifiers))))) (defn change-orientation "Change orientation of shapes, from the sidebar options form. Will ignore pixel snap used in the options side panel" [ids orientation] (us/verify (s/coll-of ::us/uuid) ids) (us/verify #{:horiz :vert} orientation) (ptk/reify ::change-orientation ptk/UpdateEvent (update [_ state] (let [objects (wsh/lookup-page-objects state) snap-pixel? (contains? (get state :workspace-layout) :snap-pixel-grid) get-modifier (fn [shape] (gsh/change-orientation-modifiers shape orientation)) modif-tree (gsh/set-objects-modifiers ids objects get-modifier false snap-pixel?)] (assoc state :workspace-modifiers modif-tree))) ptk/WatchEvent (watch [_ _ _] (rx/of (apply-modifiers))))) ;; -- Rotate -------------------------------------------------------- (defn start-rotate "Enter mouse rotate mode, until mouse button is released." [shapes] (ptk/reify ::start-rotate ptk/UpdateEvent (update [_ state] (-> state (assoc-in [:workspace-local :transform] :rotate))) ptk/WatchEvent (watch [_ _ stream] (let [stoper (rx/filter ms/mouse-up? stream) group (gsh/selection-rect shapes) group-center (gsh/center-selrect group) initial-angle (gpt/angle @ms/mouse-position group-center) calculate-angle (fn [pos mod? shift?] (let [angle (- (gpt/angle pos group-center) initial-angle) angle (if (neg? angle) (+ 360 angle) angle) angle (if (= angle 360) 0 angle) angle (if mod? (* (mth/floor (/ angle 45)) 45) angle) angle (if shift? (* (mth/floor (/ angle 15)) 15) angle)] angle))] (rx/concat (->> ms/mouse-position (rx/with-latest vector ms/mouse-position-mod) (rx/with-latest vector ms/mouse-position-shift) (rx/map (fn [[[pos mod?] shift?]] (let [delta-angle (calculate-angle pos mod? shift?)] (set-rotation-modifiers delta-angle shapes group-center)))) (rx/take-until stoper)) (rx/of (apply-modifiers) (finish-transform))))))) (defn increase-rotation "Rotate shapes a fixed angle, from a keyboard action." [ids rotation] (ptk/reify ::increase-rotation ptk/WatchEvent (watch [_ state _] (let [page-id (:current-page-id state) objects (wsh/lookup-page-objects state page-id) rotate-shape (fn [shape] (let [delta (- rotation (:rotation shape))] (set-rotation-modifiers delta [shape])))] (rx/concat (rx/from (->> ids (map #(get objects %)) (map rotate-shape))) (rx/of (apply-modifiers))))))) ;; -- Move ---------------------------------------------------------- (declare start-move) (declare start-move-duplicate) (declare calculate-frame-for-move) (declare get-displacement) (defn start-move-selected "Enter mouse move mode, until mouse button is released." ([] (start-move-selected nil false)) ([id shift?] (ptk/reify ::start-move-selected ptk/WatchEvent (watch [_ state stream] (let [initial (deref ms/mouse-position) stopper (rx/filter ms/mouse-up? stream) zoom (get-in state [:workspace-local :zoom] 1) ;; We toggle the selection so we don't have to wait for the event selected (cond-> (wsh/lookup-selected state {:omit-blocked? true}) (some? id) (d/toggle-selection id shift?))] (when (or (d/not-empty? selected) (some? id)) (->> ms/mouse-position (rx/map #(gpt/to-vec initial %)) (rx/map #(gpt/length %)) (rx/filter #(> % (/ 10 zoom))) (rx/take 1) (rx/with-latest vector ms/mouse-position-alt) (rx/mapcat (fn [[_ alt?]] (rx/concat (if (some? id) (rx/of (dws/select-shape id shift?)) (rx/empty)) (if alt? ;; When alt is down we start a duplicate+move (rx/of (start-move-duplicate initial) (dws/duplicate-selected false)) ;; Otherwise just plain old move (rx/of (start-move initial selected)))))) (rx/take-until stopper)))))))) (defn- start-move-duplicate [from-position] (ptk/reify ::start-move-duplicate ptk/UpdateEvent (update [_ state] (-> state (assoc-in [:workspace-local :transform] :move))) ptk/WatchEvent (watch [_ _ stream] (->> stream (rx/filter (ptk/type? ::dws/duplicate-selected)) (rx/take 1) (rx/map #(start-move from-position)))))) (defn- start-move ([from-position] (start-move from-position nil)) ([from-position ids] (ptk/reify ::start-move ptk/UpdateEvent (update [_ state] (-> state (assoc-in [:workspace-local :transform] :move))) ptk/WatchEvent (watch [_ state stream] (let [page-id (:current-page-id state) objects (wsh/lookup-page-objects state page-id) selected (wsh/lookup-selected state {:omit-blocked? true}) ids (if (nil? ids) selected ids) shapes (mapv #(get objects %) ids) stopper (rx/filter ms/mouse-up? stream) layout (get state :workspace-layout) zoom (get-in state [:workspace-local :zoom] 1) focus (:workspace-focus-selected state) fix-axis (fn [[position shift?]] (let [delta (gpt/to-vec from-position position)] (if shift? (if (> (mth/abs (:x delta)) (mth/abs (:y delta))) (gpt/point (:x delta) 0) (gpt/point 0 (:y delta))) delta))) position (->> ms/mouse-position (rx/with-latest-from ms/mouse-position-shift) (rx/map #(fix-axis %))) snap-delta (rx/concat ;; We send the nil first so the stream is not waiting for the first value (rx/of nil) (->> position (rx/throttle 20) (rx/switch-map (fn [pos] (->> (snap/closest-snap-move page-id shapes objects layout zoom focus pos) (rx/map #(vector pos %)))))))] (if (empty? shapes) (rx/of (finish-transform)) (rx/concat (->> position ;; We ask for the snap position but we continue even if the result is not available (rx/with-latest vector snap-delta) ;; We try to use the previous snap so we don't have to wait for the result of the new (rx/map snap/correct-snap-point) (rx/map #(hash-map :displacement (gmt/translate-matrix %))) (rx/map (partial set-modifiers ids)) (rx/take-until stopper)) (rx/of (dwu/start-undo-transaction) (calculate-frame-for-move ids) (apply-modifiers {:undo-transation? false}) (finish-transform) (dwu/commit-undo-transaction))))))))) (s/def ::direction #{:up :down :right :left}) (defn move-selected "Move shapes a fixed increment in one direction, from a keyboard action." [direction shift?] (us/verify ::direction direction) (us/verify boolean? shift?) (let [same-event (js/Symbol "same-event")] (ptk/reify ::move-selected IDeref (-deref [_] direction) ptk/UpdateEvent (update [_ state] (if (nil? (get state ::current-move-selected)) (-> state (assoc-in [:workspace-local :transform] :move) (assoc ::current-move-selected same-event)) state)) ptk/WatchEvent (watch [_ state stream] (if (= same-event (get state ::current-move-selected)) (let [selected (wsh/lookup-selected state {:omit-blocked? true}) nudge (get-in state [:profile :props :nudge] {:big 10 :small 1}) move-events (->> stream (rx/filter (ptk/type? ::move-selected)) (rx/filter #(= direction (deref %)))) stopper (->> move-events (rx/debounce 100) (rx/take 1)) scale (if shift? (gpt/point (or (:big nudge) 10)) (gpt/point (or (:small nudge) 1))) mov-vec (gpt/multiply (get-displacement direction) scale)] (rx/concat (rx/merge (->> move-events (rx/scan #(gpt/add %1 mov-vec) (gpt/point 0 0)) (rx/map #(hash-map :displacement (gmt/translate-matrix %))) (rx/map (partial set-modifiers selected)) (rx/take-until stopper)) (rx/of (move-selected direction shift?))) (rx/of (apply-modifiers) (finish-transform)))) (rx/empty)))))) (s/def ::x number?) (s/def ::y number?) (s/def ::position (s/keys :opt-un [::x ::y])) (defn update-position "Move shapes to a new position, from the sidebar options form." [id position] (us/verify ::us/uuid id) (us/verify ::position position) (ptk/reify ::update-position ptk/WatchEvent (watch [_ state _] (let [page-id (:current-page-id state) objects (wsh/lookup-page-objects state page-id) shape (get objects id) bbox (-> shape :points gsh/points->selrect) cpos (gpt/point (:x bbox) (:y bbox)) pos (gpt/point (or (:x position) (:x bbox)) (or (:y position) (:y bbox))) delta (gpt/subtract pos cpos) displ (gmt/translate-matrix delta)] (rx/of (set-modifiers [id] {:displacement displ} false true) (apply-modifiers [id])))))) (defn- calculate-frame-for-move [ids] (ptk/reify ::calculate-frame-for-move ptk/WatchEvent (watch [it state _] (let [position @ms/mouse-position page-id (:current-page-id state) objects (wsh/lookup-page-objects state page-id) frame-id (ctst/top-nested-frame objects position) lookup (d/getf objects) moving-shapes (->> ids (cph/clean-loops objects) (keep lookup) (remove #(= (:frame-id %) frame-id))) changes (-> (pcb/empty-changes it page-id) (pcb/with-objects objects) (pcb/change-parent frame-id moving-shapes))] (when-not (empty? changes) (rx/of (dch/commit-changes changes) (dwc/expand-collapse frame-id))))))) (defn- get-displacement "Retrieve the correct displacement delta point for the provided direction speed and distances thresholds." [direction] (case direction :up (gpt/point 0 (- 1)) :down (gpt/point 0 1) :left (gpt/point (- 1) 0) :right (gpt/point 1 0))) ;; -- Flip ---------------------------------------------------------- (defn flip-horizontal-selected [] (ptk/reify ::flip-horizontal-selected ptk/WatchEvent (watch [_ state _] (let [objects (wsh/lookup-page-objects state) selected (wsh/lookup-selected state {:omit-blocked? true}) shapes (map #(get objects %) selected) selrect (gsh/selection-rect (->> shapes (map gsh/transform-shape))) origin (gpt/point (:x selrect) (+ (:y selrect) (/ (:height selrect) 2)))] (rx/of (set-modifiers selected {:resize-vector (gpt/point -1.0 1.0) :resize-origin origin :displacement (gmt/translate-matrix (gpt/point (- (:width selrect)) 0))} true) (apply-modifiers)))))) (defn flip-vertical-selected [] (ptk/reify ::flip-vertical-selected ptk/WatchEvent (watch [_ state _] (let [objects (wsh/lookup-page-objects state) selected (wsh/lookup-selected state {:omit-blocked? true}) shapes (map #(get objects %) selected) selrect (gsh/selection-rect (->> shapes (map gsh/transform-shape))) origin (gpt/point (+ (:x selrect) (/ (:width selrect) 2)) (:y selrect))] (rx/of (set-modifiers selected {:resize-vector (gpt/point 1.0 -1.0) :resize-origin origin :displacement (gmt/translate-matrix (gpt/point 0 (- (:height selrect))))} true) (apply-modifiers))))))