✨ Improve path operations and edition
@ -18,6 +18,7 @@
|
|||||||
[app.common.types.path.helpers :as helpers]
|
[app.common.types.path.helpers :as helpers]
|
||||||
[app.common.types.path.impl :as impl]
|
[app.common.types.path.impl :as impl]
|
||||||
[app.common.types.path.segment :as segment]
|
[app.common.types.path.segment :as segment]
|
||||||
|
[app.common.types.path.selection :as selection]
|
||||||
[app.common.types.path.shape-to-path :as stp]
|
[app.common.types.path.shape-to-path :as stp]
|
||||||
[app.common.types.path.subpath :as subpath]))
|
[app.common.types.path.subpath :as subpath]))
|
||||||
|
|
||||||
@ -209,6 +210,120 @@
|
|||||||
(let [content (impl/path-data content)]
|
(let [content (impl/path-data content)]
|
||||||
(segment/get-points content)))
|
(segment/get-points content)))
|
||||||
|
|
||||||
|
(defn segment-entries
|
||||||
|
"Selectable path segments with their command index and endpoints."
|
||||||
|
[content]
|
||||||
|
(let [content (impl/path-data content)]
|
||||||
|
(segment/segment-entries content)))
|
||||||
|
|
||||||
|
(defn single-line?
|
||||||
|
"True when the content is a single straight segment: a move-to
|
||||||
|
followed by exactly one line-to."
|
||||||
|
[content]
|
||||||
|
(and (some? content)
|
||||||
|
(= 2 (count content))
|
||||||
|
(= :move-to (:command (nth content 0)))
|
||||||
|
(= :line-to (:command (nth content 1)))))
|
||||||
|
|
||||||
|
(defn close-loops
|
||||||
|
"Closes subpaths whose endpoints meet and returns PathData."
|
||||||
|
[content]
|
||||||
|
(-> (subpath/close-loops content)
|
||||||
|
(impl/from-plain)))
|
||||||
|
|
||||||
|
(defn extract-content
|
||||||
|
"Extracts selected segments and segments between selected nodes into new
|
||||||
|
subpaths."
|
||||||
|
[content {:keys [nodes segments]}]
|
||||||
|
(let [content (impl/path-data content)
|
||||||
|
nodes (or nodes #{})
|
||||||
|
segments (or segments #{})
|
||||||
|
selected? (fn [{:keys [index from-index to-index]}]
|
||||||
|
(or (contains? segments index)
|
||||||
|
(and (contains? nodes from-index)
|
||||||
|
(contains? nodes to-index))))
|
||||||
|
entries (filterv selected? (segment/segment-entries content))
|
||||||
|
plain (loop [entries (seq entries)
|
||||||
|
prev nil
|
||||||
|
result (transient [])]
|
||||||
|
(if-let [{:keys [from from-index to segment] :as entry} (first entries)]
|
||||||
|
(let [result (cond-> result
|
||||||
|
(not= from-index (:to-index prev))
|
||||||
|
(conj! {:command :move-to
|
||||||
|
:params {:x (:x from) :y (:y from)}}))
|
||||||
|
result (conj! result
|
||||||
|
(if (= :close-path (:command segment))
|
||||||
|
{:command :line-to
|
||||||
|
:params {:x (:x to) :y (:y to)}}
|
||||||
|
segment))]
|
||||||
|
(recur (next entries) entry result))
|
||||||
|
(persistent! result)))]
|
||||||
|
(-> (close-subpaths (impl/path-data plain))
|
||||||
|
(close-loops))))
|
||||||
|
|
||||||
|
(defn splice-content
|
||||||
|
"Appends sub-content as new subpaths."
|
||||||
|
[content sub-content]
|
||||||
|
(impl/path-data (into (vec content) (vec sub-content))))
|
||||||
|
|
||||||
|
(defn- move-segment-end
|
||||||
|
"Moves a segment endpoint and its incoming handle by `delta`."
|
||||||
|
[segment {dx :x dy :y}]
|
||||||
|
(cond-> (-> segment
|
||||||
|
(update-in [:params :x] + dx)
|
||||||
|
(update-in [:params :y] + dy))
|
||||||
|
(= :curve-to (:command segment))
|
||||||
|
(-> (update-in [:params :c2x] + dx)
|
||||||
|
(update-in [:params :c2y] + dy))))
|
||||||
|
|
||||||
|
(defn- segment->end
|
||||||
|
"Returns the command arriving at an entry's end node as a drawable segment."
|
||||||
|
[{:keys [to segment]}]
|
||||||
|
(if (= :close-path (:command segment))
|
||||||
|
{:command :line-to :params {:x (:x to) :y (:y to)}}
|
||||||
|
segment))
|
||||||
|
|
||||||
|
(defn- reverse-segment
|
||||||
|
"Reverses a segment toward `from`, swapping curve handles."
|
||||||
|
[segment {fx :x fy :y}]
|
||||||
|
(if (= :curve-to (:command segment))
|
||||||
|
(let [{:keys [c1x c1y c2x c2y]} (:params segment)]
|
||||||
|
{:command :curve-to
|
||||||
|
:params {:x fx :y fy :c1x c2x :c1y c2y :c2x c1x :c2y c1y}})
|
||||||
|
{:command :line-to :params {:x fx :y fy}}))
|
||||||
|
|
||||||
|
(defn duplicate-node-content
|
||||||
|
"Copies a node and its incident segments, keeping their far ends attached.
|
||||||
|
Returns copied content and the relative indices of the new node."
|
||||||
|
[content index node-offset]
|
||||||
|
(let [content (impl/path-data content)
|
||||||
|
entries (segment-entries content)
|
||||||
|
incident (filterv #(or (= index (:to-index %))
|
||||||
|
(= index (:from-index %)))
|
||||||
|
entries)]
|
||||||
|
(if (seq incident)
|
||||||
|
(reduce (fn [{:keys [content selected]} {:keys [from to to-index segment] :as entry}]
|
||||||
|
(let [incoming? (= index to-index)
|
||||||
|
start (if incoming? from to)
|
||||||
|
end (if incoming?
|
||||||
|
(segment->end entry)
|
||||||
|
(reverse-segment segment from))
|
||||||
|
end (cond-> end
|
||||||
|
(some? node-offset) (move-segment-end node-offset))]
|
||||||
|
{:content (conj content
|
||||||
|
{:command :move-to
|
||||||
|
:params {:x (:x start) :y (:y start)}}
|
||||||
|
end)
|
||||||
|
:selected (conj selected (inc (count content)))}))
|
||||||
|
{:content [] :selected #{}}
|
||||||
|
incident)
|
||||||
|
(when-let [{:keys [x y]} (:params (nth content index nil))]
|
||||||
|
{:content [{:command :move-to
|
||||||
|
:params (if (some? node-offset)
|
||||||
|
{:x (+ x (:x node-offset)) :y (+ y (:y node-offset))}
|
||||||
|
{:x x :y y})}]
|
||||||
|
:selected #{0}}))))
|
||||||
|
|
||||||
(defn calc-selrect
|
(defn calc-selrect
|
||||||
"Calculate selrect from a content. The content can be in a PathData
|
"Calculate selrect from a content. The content can be in a PathData
|
||||||
instance or plain vector of segments."
|
instance or plain vector of segments."
|
||||||
@ -279,6 +394,11 @@
|
|||||||
[points & {:keys [close]}]
|
[points & {:keys [close]}]
|
||||||
(segment/points->content points :close close))
|
(segment/points->content points :close close))
|
||||||
|
|
||||||
|
(defn smooth-points->content
|
||||||
|
"Fits smooth path content through `points`."
|
||||||
|
[points tolerance]
|
||||||
|
(segment/smooth-points->content points tolerance))
|
||||||
|
|
||||||
(defn closest-point
|
(defn closest-point
|
||||||
"Returns the closest point in the path to position, at a given precision."
|
"Returns the closest point in the path to position, at a given precision."
|
||||||
[content position precision]
|
[content position precision]
|
||||||
@ -304,6 +424,30 @@
|
|||||||
(let [content (impl/path-data content)]
|
(let [content (impl/path-data content)]
|
||||||
(segment/split-segments content points value)))
|
(segment/split-segments content points value)))
|
||||||
|
|
||||||
|
(defn is-curve-point?
|
||||||
|
"True when a node has at least one visible handler."
|
||||||
|
[content point]
|
||||||
|
(let [content (impl/path-data content)]
|
||||||
|
(boolean (segment/is-curve? content point))))
|
||||||
|
|
||||||
|
(defn collapse-handler
|
||||||
|
"Collapses a handler onto its node and simplifies flat curves to lines."
|
||||||
|
[content index prefix]
|
||||||
|
(let [content (impl/path-data content)]
|
||||||
|
(segment/collapse-handler content index prefix)))
|
||||||
|
|
||||||
|
(defn toggle-segment-curve
|
||||||
|
"Toggles a segment between a line and a curve."
|
||||||
|
[content index]
|
||||||
|
(let [content (impl/path-data content)]
|
||||||
|
(segment/toggle-segment-curve content index)))
|
||||||
|
|
||||||
|
(defn remove-segments
|
||||||
|
"Removes segments, opening their subpaths and dropping empty ones."
|
||||||
|
[content indices]
|
||||||
|
(let [content (impl/path-data content)]
|
||||||
|
(segment/remove-segments content indices)))
|
||||||
|
|
||||||
(defn remove-nodes
|
(defn remove-nodes
|
||||||
"Removes the given points from content, reconstructing paths as needed."
|
"Removes the given points from content, reconstructing paths as needed."
|
||||||
[content points]
|
[content points]
|
||||||
@ -323,10 +467,49 @@
|
|||||||
(segment/join-nodes content points)))
|
(segment/join-nodes content points)))
|
||||||
|
|
||||||
(defn separate-nodes
|
(defn separate-nodes
|
||||||
"Removes the segments between the given points."
|
"Removes segments between points or splits one node into offset open ends."
|
||||||
[content points]
|
([content points]
|
||||||
|
(let [content (impl/path-data content)]
|
||||||
|
(segment/separate-nodes content points)))
|
||||||
|
([content points offset]
|
||||||
|
(let [content (impl/path-data content)]
|
||||||
|
(segment/separate-nodes content points offset))))
|
||||||
|
|
||||||
|
(defn flip-content
|
||||||
|
"Flips selected nodes and handles across their bounding box."
|
||||||
|
[content indices axis]
|
||||||
(let [content (impl/path-data content)]
|
(let [content (impl/path-data content)]
|
||||||
(segment/separate-nodes content points)))
|
(selection/flip-content content indices axis)))
|
||||||
|
|
||||||
|
(defn align-content
|
||||||
|
"Aligns selected nodes and handles within their bounding box."
|
||||||
|
[content indices axis]
|
||||||
|
(let [content (impl/path-data content)]
|
||||||
|
(selection/align-content content indices axis)))
|
||||||
|
|
||||||
|
(defn distribute-content
|
||||||
|
"Distributes selected nodes evenly along `axis`."
|
||||||
|
[content indices axis]
|
||||||
|
(let [content (impl/path-data content)]
|
||||||
|
(selection/distribute-content content indices axis)))
|
||||||
|
|
||||||
|
(defn set-nodes-coordinate
|
||||||
|
"Sets one coordinate of selected nodes and their handles."
|
||||||
|
[content indices axis value]
|
||||||
|
(let [content (impl/path-data content)]
|
||||||
|
(selection/set-nodes-coordinate content indices axis value)))
|
||||||
|
|
||||||
|
(defn set-handler-points
|
||||||
|
"Moves each handler in `pts` to its target point."
|
||||||
|
[content pts]
|
||||||
|
(let [content (impl/path-data content)]
|
||||||
|
(selection/set-handler-points content pts)))
|
||||||
|
|
||||||
|
(defn translate-selected-nodes
|
||||||
|
"Moves selected nodes and their handles by `delta`."
|
||||||
|
[content indices delta]
|
||||||
|
(let [content (impl/path-data content)]
|
||||||
|
(selection/translate-selected-nodes content indices delta)))
|
||||||
|
|
||||||
(defn- calc-bool-content*
|
(defn- calc-bool-content*
|
||||||
"Calculate the boolean content from shape and objects. Returns plain
|
"Calculate the boolean content from shape and objects. Returns plain
|
||||||
|
|||||||
209
common/src/app/common/types/path/fit.cljc
Normal file
@ -0,0 +1,209 @@
|
|||||||
|
;; 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 Sucursal en España SL
|
||||||
|
|
||||||
|
(ns app.common.types.path.fit
|
||||||
|
"Curve fitting helpers."
|
||||||
|
(:require
|
||||||
|
[app.common.geom.point :as gpt]
|
||||||
|
[app.common.math :as mth]
|
||||||
|
[app.common.types.path.helpers :as helpers]))
|
||||||
|
|
||||||
|
(defn- chord-length-params
|
||||||
|
"Returns normalized chord-length parameters for `points`."
|
||||||
|
[points]
|
||||||
|
(let [dists (->> (map gpt/distance points (rest points))
|
||||||
|
(reductions + 0)
|
||||||
|
(vec))
|
||||||
|
total (peek dists)]
|
||||||
|
(if (mth/almost-zero? total)
|
||||||
|
(let [n (max 1 (dec (count points)))]
|
||||||
|
(mapv #(/ (double %) n) (range (count points))))
|
||||||
|
(mapv #(/ % total) dists))))
|
||||||
|
|
||||||
|
(defn fit-cubic
|
||||||
|
"Fits one cubic through `points` with fixed endpoints and tangents."
|
||||||
|
([points tan1 tan2]
|
||||||
|
(let [points (vec points)]
|
||||||
|
(fit-cubic points (chord-length-params points) tan1 tan2)))
|
||||||
|
([points params tan1 tan2]
|
||||||
|
(let [points (vec points)
|
||||||
|
p0 (first points)
|
||||||
|
p3 (peek points)
|
||||||
|
|
||||||
|
[c00 c01 c11 x0 x1]
|
||||||
|
(reduce
|
||||||
|
(fn [[c00 c01 c11 x0 x1] [point u]]
|
||||||
|
(let [u' (- 1.0 u)
|
||||||
|
b0 (* u' u' u')
|
||||||
|
b1 (* 3.0 u u' u')
|
||||||
|
b2 (* 3.0 u u u')
|
||||||
|
b3 (* u u u)
|
||||||
|
a1 (gpt/scale tan1 b1)
|
||||||
|
a2 (gpt/scale tan2 b2)
|
||||||
|
tmp (-> point
|
||||||
|
(gpt/subtract (gpt/scale p0 (+ b0 b1)))
|
||||||
|
(gpt/subtract (gpt/scale p3 (+ b2 b3))))]
|
||||||
|
[(+ c00 (gpt/dot a1 a1))
|
||||||
|
(+ c01 (gpt/dot a1 a2))
|
||||||
|
(+ c11 (gpt/dot a2 a2))
|
||||||
|
(+ x0 (gpt/dot a1 tmp))
|
||||||
|
(+ x1 (gpt/dot a2 tmp))]))
|
||||||
|
[0.0 0.0 0.0 0.0 0.0]
|
||||||
|
(map vector points params))
|
||||||
|
|
||||||
|
det-c (- (* c00 c11) (* c01 c01))
|
||||||
|
alpha1 (when-not (mth/almost-zero? det-c)
|
||||||
|
(/ (- (* x0 c11) (* x1 c01)) det-c))
|
||||||
|
alpha2 (when-not (mth/almost-zero? det-c)
|
||||||
|
(/ (- (* c00 x1) (* c01 x0)) det-c))
|
||||||
|
|
||||||
|
chord (gpt/distance p0 p3)
|
||||||
|
epsilon (* 0.000001 chord)
|
||||||
|
|
||||||
|
[alpha1 alpha2]
|
||||||
|
(if (or (nil? alpha1) (nil? alpha2)
|
||||||
|
(< alpha1 epsilon) (< alpha2 epsilon))
|
||||||
|
[(/ chord 3.0) (/ chord 3.0)]
|
||||||
|
[alpha1 alpha2])]
|
||||||
|
|
||||||
|
[(gpt/add p0 (gpt/scale tan1 alpha1))
|
||||||
|
(gpt/add p3 (gpt/scale tan2 alpha2))])))
|
||||||
|
|
||||||
|
(defn- curve-d1
|
||||||
|
"Returns the first derivative at `t`."
|
||||||
|
[[start end h1 h2] t]
|
||||||
|
(let [t' (- 1.0 t)
|
||||||
|
a (* 3.0 t' t')
|
||||||
|
b (* 6.0 t' t)
|
||||||
|
c (* 3.0 t t)]
|
||||||
|
(gpt/point (+ (* a (- (:x h1) (:x start)))
|
||||||
|
(* b (- (:x h2) (:x h1)))
|
||||||
|
(* c (- (:x end) (:x h2))))
|
||||||
|
(+ (* a (- (:y h1) (:y start)))
|
||||||
|
(* b (- (:y h2) (:y h1)))
|
||||||
|
(* c (- (:y end) (:y h2)))))))
|
||||||
|
|
||||||
|
(defn- curve-d2
|
||||||
|
"Returns the second derivative at `t`."
|
||||||
|
[[start end h1 h2] t]
|
||||||
|
(let [t' (- 1.0 t)]
|
||||||
|
(gpt/point (+ (* 6.0 t' (+ (:x h2) (* -2.0 (:x h1)) (:x start)))
|
||||||
|
(* 6.0 t (+ (:x end) (* -2.0 (:x h2)) (:x h1))))
|
||||||
|
(+ (* 6.0 t' (+ (:y h2) (* -2.0 (:y h1)) (:y start)))
|
||||||
|
(* 6.0 t (+ (:y end) (* -2.0 (:y h2)) (:y h1)))))))
|
||||||
|
|
||||||
|
(defn- refine-parameter
|
||||||
|
"Moves `u` toward the closest point on `curve`."
|
||||||
|
[curve point u]
|
||||||
|
(let [d (gpt/subtract (helpers/curve-values curve u) point)
|
||||||
|
d1 (curve-d1 curve u)
|
||||||
|
d2 (curve-d2 curve u)
|
||||||
|
den (+ (gpt/dot d1 d1) (gpt/dot d d2))]
|
||||||
|
(if (mth/almost-zero? den)
|
||||||
|
u
|
||||||
|
(mth/clamp (- u (/ (gpt/dot d d1) den)) 0.0 1.0))))
|
||||||
|
|
||||||
|
(defn- max-fit-error
|
||||||
|
"Returns the largest interior fit error and its index."
|
||||||
|
[points params curve]
|
||||||
|
(let [n (count points)]
|
||||||
|
(loop [i 1
|
||||||
|
max-err 0.0
|
||||||
|
split (quot n 2)]
|
||||||
|
(if (>= i (dec n))
|
||||||
|
[max-err split]
|
||||||
|
(let [d (gpt/subtract (helpers/curve-values curve (nth params i))
|
||||||
|
(nth points i))
|
||||||
|
err (gpt/dot d d)]
|
||||||
|
(if (> err max-err)
|
||||||
|
(recur (inc i) err i)
|
||||||
|
(recur (inc i) max-err split)))))))
|
||||||
|
|
||||||
|
(def ^:private ^:const max-fit-iterations 4)
|
||||||
|
|
||||||
|
(defn- fit-curve*
|
||||||
|
"Fits one or more curves through at least two points."
|
||||||
|
[points tan1 tan2 tol2]
|
||||||
|
(let [n (count points)
|
||||||
|
p0 (first points)
|
||||||
|
p3 (peek points)]
|
||||||
|
(if (= n 2)
|
||||||
|
(let [alpha (/ (gpt/distance p0 p3) 3.0)]
|
||||||
|
[[p0 p3
|
||||||
|
(gpt/add p0 (gpt/scale tan1 alpha))
|
||||||
|
(gpt/add p3 (gpt/scale tan2 alpha))]])
|
||||||
|
|
||||||
|
(let [params (chord-length-params points)
|
||||||
|
[h1 h2] (fit-cubic points params tan1 tan2)
|
||||||
|
curve [p0 p3 h1 h2]
|
||||||
|
[err split] (max-fit-error points params curve)
|
||||||
|
|
||||||
|
[curve err split]
|
||||||
|
(if (and (> err tol2) (<= err (* 16.0 tol2)))
|
||||||
|
(loop [it 0
|
||||||
|
params params
|
||||||
|
curve curve
|
||||||
|
err err
|
||||||
|
split split]
|
||||||
|
(if (or (>= it max-fit-iterations) (<= err tol2))
|
||||||
|
[curve err split]
|
||||||
|
(let [params (mapv #(refine-parameter curve %1 %2) points params)
|
||||||
|
[h1 h2] (fit-cubic points params tan1 tan2)
|
||||||
|
curve [p0 p3 h1 h2]
|
||||||
|
[err split] (max-fit-error points params curve)]
|
||||||
|
(recur (inc it) params curve err split))))
|
||||||
|
[curve err split])]
|
||||||
|
|
||||||
|
(if (<= err tol2)
|
||||||
|
[curve]
|
||||||
|
(let [split (mth/clamp split 1 (- n 2))
|
||||||
|
center (let [v (gpt/to-vec (nth points (inc split))
|
||||||
|
(nth points (dec split)))]
|
||||||
|
(if (mth/almost-zero? (gpt/length v))
|
||||||
|
(gpt/unit (gpt/to-vec (nth points split)
|
||||||
|
(nth points (dec split))))
|
||||||
|
(gpt/unit v)))]
|
||||||
|
(into (fit-curve* (subvec points 0 (inc split)) tan1 center tol2)
|
||||||
|
(fit-curve* (subvec points split) (gpt/negate center) tan2 tol2))))))))
|
||||||
|
|
||||||
|
(def ^:private default-corner-angle 60.0)
|
||||||
|
|
||||||
|
(defn- corner-index?
|
||||||
|
"True when point `i` turns more than `corner-angle` degrees."
|
||||||
|
[points i corner-angle]
|
||||||
|
(let [v-in (gpt/to-vec (nth points (dec i)) (nth points i))
|
||||||
|
v-out (gpt/to-vec (nth points i) (nth points (inc i)))]
|
||||||
|
(and (not (mth/almost-zero? (gpt/length v-in)))
|
||||||
|
(not (mth/almost-zero? (gpt/length v-out)))
|
||||||
|
(> (gpt/angle-with-other v-in v-out) corner-angle))))
|
||||||
|
|
||||||
|
(defn fit-curve
|
||||||
|
"Fits chained cubic curves through `points` within `tolerance`."
|
||||||
|
([points tolerance]
|
||||||
|
(fit-curve points tolerance default-corner-angle))
|
||||||
|
([points tolerance corner-angle]
|
||||||
|
(let [points (reduce (fn [acc point]
|
||||||
|
(if (and (seq acc)
|
||||||
|
(< (gpt/distance (peek acc) point) 0.01))
|
||||||
|
acc
|
||||||
|
(conj acc point)))
|
||||||
|
[]
|
||||||
|
points)
|
||||||
|
n (count points)]
|
||||||
|
(when (>= n 2)
|
||||||
|
(let [tol2 (* (double tolerance) (double tolerance))
|
||||||
|
corners (into [] (filter #(corner-index? points % corner-angle))
|
||||||
|
(range 1 (dec n)))
|
||||||
|
bounds (concat [0] corners [(dec n)])]
|
||||||
|
(into []
|
||||||
|
(mapcat (fn [[a b]]
|
||||||
|
(let [span (subvec points a (inc b))
|
||||||
|
m (count span)]
|
||||||
|
(when (>= m 2)
|
||||||
|
(let [tan1 (gpt/unit (gpt/to-vec (nth span 0) (nth span 1)))
|
||||||
|
tan2 (gpt/unit (gpt/to-vec (nth span (dec m)) (nth span (- m 2))))]
|
||||||
|
(fit-curve* span tan1 tan2 tol2))))))
|
||||||
|
(partition 2 1 bounds)))))))
|
||||||
@ -72,16 +72,9 @@
|
|||||||
nil))
|
nil))
|
||||||
|
|
||||||
(defn- closest-angle
|
(defn- closest-angle
|
||||||
|
"Snaps an angle (in degrees) to the nearest 15° increment."
|
||||||
[angle]
|
[angle]
|
||||||
(cond
|
(mth/round angle 15))
|
||||||
(or (> angle 337.5) (<= angle 22.5)) 0
|
|
||||||
(and (> angle 22.5) (<= angle 67.5)) 45
|
|
||||||
(and (> angle 67.5) (<= angle 112.5)) 90
|
|
||||||
(and (> angle 112.5) (<= angle 157.5)) 135
|
|
||||||
(and (> angle 157.5) (<= angle 202.5)) 180
|
|
||||||
(and (> angle 202.5) (<= angle 247.5)) 225
|
|
||||||
(and (> angle 247.5) (<= angle 292.5)) 270
|
|
||||||
(and (> angle 292.5) (<= angle 337.5)) 315))
|
|
||||||
|
|
||||||
(defn position-fixed-angle
|
(defn position-fixed-angle
|
||||||
[point from-point]
|
[point from-point]
|
||||||
@ -119,6 +112,13 @@
|
|||||||
(gpt/point (-> segment :params :c1x) (-> segment :params :c1y))
|
(gpt/point (-> segment :params :c1x) (-> segment :params :c1y))
|
||||||
(gpt/point (-> segment :params :c2x) (-> segment :params :c2y))]))
|
(gpt/point (-> segment :params :c2x) (-> segment :params :c2y))]))
|
||||||
|
|
||||||
|
(defn entry->bezier
|
||||||
|
"Returns a segment entry as `[start end h1 h2]`."
|
||||||
|
[{:keys [from to segment]}]
|
||||||
|
(if (= :curve-to (:command segment))
|
||||||
|
(command->bezier segment from)
|
||||||
|
[from to from to]))
|
||||||
|
|
||||||
(declare curve-extremities)
|
(declare curve-extremities)
|
||||||
(declare curve-values)
|
(declare curve-values)
|
||||||
|
|
||||||
@ -189,6 +189,70 @@
|
|||||||
|
|
||||||
(gpt/point (coord-v :x) (coord-v :y)))))
|
(gpt/point (coord-v :x) (coord-v :y)))))
|
||||||
|
|
||||||
|
(defn curve-closest-t
|
||||||
|
"Finds the cubic parameter closest to `position`."
|
||||||
|
[[start end h1 h2] position precision]
|
||||||
|
(let [d (fn [t] (gpt/distance position (curve-values start end h1 h2 t)))]
|
||||||
|
(loop [t1 0.0
|
||||||
|
t2 1.0]
|
||||||
|
(if (<= (mth/abs (- t1 t2)) precision)
|
||||||
|
t1
|
||||||
|
(let [ht (+ t1 (/ (- t2 t1) 2))
|
||||||
|
ht1 (+ t1 (/ (- t2 t1) 4))
|
||||||
|
ht2 (+ t1 (/ (* 3 (- t2 t1)) 4))
|
||||||
|
|
||||||
|
[t1 t2] (cond
|
||||||
|
(< (d ht1) (d ht2)) [t1 ht]
|
||||||
|
(< (d ht2) (d ht1)) [ht t2]
|
||||||
|
(and (< (d ht) (d t1)) (< (d ht) (d t2))) [ht1 ht2]
|
||||||
|
(< (d t1) (d t2)) [t1 ht]
|
||||||
|
:else [ht t2])]
|
||||||
|
(recur (double t1) (double t2)))))))
|
||||||
|
|
||||||
|
(def ^:private arc-length-samples
|
||||||
|
"Samples for approximating a cubic's length."
|
||||||
|
100)
|
||||||
|
|
||||||
|
(defn curve-arc-length-t
|
||||||
|
"Finds the cubic parameter at half its arc length."
|
||||||
|
[[start end h1 h2]]
|
||||||
|
(let [n arc-length-samples
|
||||||
|
pts (mapv (fn [i] (curve-values start end h1 h2 (/ (double i) n)))
|
||||||
|
(range (inc n)))
|
||||||
|
dists (->> (map gpt/distance pts (rest pts))
|
||||||
|
(reductions + 0.0)
|
||||||
|
(vec))
|
||||||
|
total (peek dists)]
|
||||||
|
(if (mth/almost-zero? total)
|
||||||
|
0.5
|
||||||
|
(let [half (/ total 2.0)
|
||||||
|
i (loop [i 0]
|
||||||
|
(if (and (< (inc i) (count dists))
|
||||||
|
(< (nth dists (inc i)) half))
|
||||||
|
(recur (inc i))
|
||||||
|
i))
|
||||||
|
d0 (nth dists i)
|
||||||
|
d1 (nth dists (inc i))
|
||||||
|
frac (if (mth/almost-zero? (- d1 d0))
|
||||||
|
0.0
|
||||||
|
(/ (- half d0) (- d1 d0)))]
|
||||||
|
(/ (+ i frac) n)))))
|
||||||
|
|
||||||
|
(defn bend-curve-deltas
|
||||||
|
"Returns the smallest handler deltas that move the point at `t` to `target`."
|
||||||
|
[curve t target]
|
||||||
|
(let [t' (- 1.0 t)
|
||||||
|
b (* 3.0 t' t' t)
|
||||||
|
c (* 3.0 t' t t)
|
||||||
|
delta (gpt/subtract target (curve-values curve t))
|
||||||
|
denom (+ (* b b) (* c c))]
|
||||||
|
(if (mth/almost-zero? denom)
|
||||||
|
{:c1x 0.0 :c1y 0.0 :c2x 0.0 :c2y 0.0}
|
||||||
|
(let [k1 (/ b denom)
|
||||||
|
k2 (/ c denom)]
|
||||||
|
{:c1x (* k1 (:x delta)) :c1y (* k1 (:y delta))
|
||||||
|
:c2x (* k2 (:x delta)) :c2y (* k2 (:y delta))}))))
|
||||||
|
|
||||||
(defn solve-roots*
|
(defn solve-roots*
|
||||||
"Solvers a quadratic or cubic equation given by the parameters a b c d.
|
"Solvers a quadratic or cubic equation given by the parameters a b c d.
|
||||||
|
|
||||||
|
|||||||
@ -13,8 +13,10 @@
|
|||||||
[app.common.geom.point :as gpt]
|
[app.common.geom.point :as gpt]
|
||||||
[app.common.geom.rect :as grc]
|
[app.common.geom.rect :as grc]
|
||||||
[app.common.math :as mth]
|
[app.common.math :as mth]
|
||||||
|
[app.common.types.path.fit :as fit]
|
||||||
[app.common.types.path.helpers :as helpers]
|
[app.common.types.path.helpers :as helpers]
|
||||||
[app.common.types.path.impl :as impl]
|
[app.common.types.path.impl :as impl]
|
||||||
|
[app.common.types.path.subpath :as subpath]
|
||||||
[clojure.set :as set]))
|
[clojure.set :as set]))
|
||||||
|
|
||||||
#?(:clj (set! *warn-on-reflection* true))
|
#?(:clj (set! *warn-on-reflection* true))
|
||||||
@ -139,6 +141,42 @@
|
|||||||
(gpt/point x y)))
|
(gpt/point x y)))
|
||||||
[])))
|
[])))
|
||||||
|
|
||||||
|
(defn segment-entries
|
||||||
|
"Returns selectable segments with their command index and endpoints."
|
||||||
|
[content]
|
||||||
|
(loop [index 0
|
||||||
|
pending (seq content)
|
||||||
|
previous nil
|
||||||
|
previous-index nil
|
||||||
|
subpath-start nil
|
||||||
|
subpath-start-index nil
|
||||||
|
result []]
|
||||||
|
(if-let [{:keys [command] :as segment} (first pending)]
|
||||||
|
(let [close-path? (= command :close-path)
|
||||||
|
move-to? (= command :move-to)
|
||||||
|
point (if close-path?
|
||||||
|
subpath-start
|
||||||
|
(helpers/segment->point segment))
|
||||||
|
point-index (if close-path? subpath-start-index index)
|
||||||
|
result (cond-> result
|
||||||
|
(and previous point (not move-to?))
|
||||||
|
(conj {:index index
|
||||||
|
:from previous
|
||||||
|
:from-index previous-index
|
||||||
|
:to point
|
||||||
|
:to-index point-index
|
||||||
|
:segment segment}))
|
||||||
|
subpath-start (if move-to? point subpath-start)
|
||||||
|
subpath-start-index (if move-to? index subpath-start-index)]
|
||||||
|
(recur (inc index)
|
||||||
|
(next pending)
|
||||||
|
point
|
||||||
|
point-index
|
||||||
|
subpath-start
|
||||||
|
subpath-start-index
|
||||||
|
result))
|
||||||
|
result)))
|
||||||
|
|
||||||
;; FIXME: incorrect API, don't need full shape
|
;; FIXME: incorrect API, don't need full shape
|
||||||
(defn path->lines
|
(defn path->lines
|
||||||
"Given a path returns a list of lines that approximate the path"
|
"Given a path returns a list of lines that approximate the path"
|
||||||
@ -312,34 +350,6 @@
|
|||||||
|
|
||||||
(impl/from-plain content)))
|
(impl/from-plain content)))
|
||||||
|
|
||||||
(defn- line->curve
|
|
||||||
[from-p segment]
|
|
||||||
|
|
||||||
(let [to-p (helpers/segment->point segment)
|
|
||||||
|
|
||||||
v (gpt/to-vec from-p to-p)
|
|
||||||
d (gpt/distance from-p to-p)
|
|
||||||
|
|
||||||
dv1 (-> (gpt/normal-left v)
|
|
||||||
(gpt/scale (/ d 3)))
|
|
||||||
|
|
||||||
h1 (gpt/add from-p dv1)
|
|
||||||
|
|
||||||
dv2 (-> (gpt/to-vec to-p h1)
|
|
||||||
(gpt/unit)
|
|
||||||
(gpt/scale (/ d 3)))
|
|
||||||
|
|
||||||
h2 (gpt/add to-p dv2)]
|
|
||||||
(-> segment
|
|
||||||
(assoc :command :curve-to)
|
|
||||||
(update :params (fn [params]
|
|
||||||
;; ensure plain map
|
|
||||||
(-> (into {} params)
|
|
||||||
(assoc :c1x (:x h1))
|
|
||||||
(assoc :c1y (:y h1))
|
|
||||||
(assoc :c2x (:x h2))
|
|
||||||
(assoc :c2y (:y h2))))))))
|
|
||||||
|
|
||||||
;; FIXME: optimize
|
;; FIXME: optimize
|
||||||
(defn is-curve?
|
(defn is-curve?
|
||||||
[content point]
|
[content point]
|
||||||
@ -353,111 +363,123 @@
|
|||||||
(mapcat #(list (:next-p %) (:prev-p %)))
|
(mapcat #(list (:next-p %) (:prev-p %)))
|
||||||
(remove nil?)))
|
(remove nil?)))
|
||||||
|
|
||||||
|
(defn- curve-neighbourhood
|
||||||
|
"Returns the adjacent segments and points for one node."
|
||||||
|
[content index]
|
||||||
|
(let [segment (get content index)
|
||||||
|
prev-i (dec index)
|
||||||
|
prev (when (not= :move-to (:command segment))
|
||||||
|
(get content prev-i))
|
||||||
|
next-i (inc index)
|
||||||
|
next (get content next-i)
|
||||||
|
next (when (not= :move-to (:command next)) next)]
|
||||||
|
{:index index
|
||||||
|
:prev-i (when (some? prev) prev-i)
|
||||||
|
:prev-c prev
|
||||||
|
:prev-p (helpers/segment->point prev)
|
||||||
|
:next-i (when (some? next) next-i)
|
||||||
|
:next-c next
|
||||||
|
:next-p (helpers/segment->point next)
|
||||||
|
:segment segment}))
|
||||||
|
|
||||||
|
(defn- smooth-tangent
|
||||||
|
"Returns tangent data for a smooth curve node."
|
||||||
|
[content point indices neighbourhoods neighbour-points]
|
||||||
|
(let [[first-point second-point] (vec neighbour-points)
|
||||||
|
prev-neighbour (some :prev-p neighbourhoods)
|
||||||
|
next-neighbour (some :next-p neighbourhoods)
|
||||||
|
seam? (and (= 2 (count indices))
|
||||||
|
(= :move-to (:command (get content (first indices))))
|
||||||
|
(let [end-index (last indices)]
|
||||||
|
(or (= end-index (dec (count content)))
|
||||||
|
(= :close-path
|
||||||
|
(:command (get content (inc end-index)))))))
|
||||||
|
first-unit (gpt/unit (gpt/to-vec point first-point))
|
||||||
|
second-unit (gpt/unit (gpt/to-vec point second-point))
|
||||||
|
angle-tangent (let [delta (gpt/subtract second-unit first-unit)]
|
||||||
|
(if (mth/almost-zero? (gpt/length delta))
|
||||||
|
(gpt/perpendicular first-unit)
|
||||||
|
(gpt/unit delta)))
|
||||||
|
tangent (if seam?
|
||||||
|
(let [chord (gpt/to-vec prev-neighbour next-neighbour)]
|
||||||
|
(if (mth/almost-zero? (gpt/length chord))
|
||||||
|
angle-tangent
|
||||||
|
(gpt/unit chord)))
|
||||||
|
angle-tangent)
|
||||||
|
length (/ (min (gpt/distance point first-point)
|
||||||
|
(gpt/distance point second-point))
|
||||||
|
3)]
|
||||||
|
{:tangent tangent
|
||||||
|
:length length
|
||||||
|
:seam? seam?
|
||||||
|
:prev-neighbour prev-neighbour
|
||||||
|
:next-neighbour next-neighbour}))
|
||||||
|
|
||||||
|
(defn- smooth-handle
|
||||||
|
"Returns a smooth handle toward `neighbour`."
|
||||||
|
[point {:keys [tangent length seam? prev-neighbour next-neighbour]} neighbour]
|
||||||
|
(when (some? neighbour)
|
||||||
|
(let [direction (gpt/unit (gpt/to-vec point neighbour))
|
||||||
|
side (cond
|
||||||
|
(and seam? (= neighbour prev-neighbour)) -1
|
||||||
|
(and seam? (= neighbour next-neighbour)) 1
|
||||||
|
:else (if (neg? (gpt/dot direction tangent)) -1 1))]
|
||||||
|
(gpt/add point (gpt/scale tangent (* side length))))))
|
||||||
|
|
||||||
|
(defn- apply-smooth-neighbour
|
||||||
|
"Adds smooth handles around one matching node."
|
||||||
|
[content point tangent-data {:keys [index prev-p next-p next-i]}]
|
||||||
|
(let [curr-command (:command (get content index))
|
||||||
|
next-command (:command (get content next-i))
|
||||||
|
prev-h (smooth-handle point tangent-data prev-p)
|
||||||
|
next-h (smooth-handle point tangent-data next-p)]
|
||||||
|
(cond-> content
|
||||||
|
(and (= :line-to curr-command) (some? prev-p))
|
||||||
|
(update index helpers/update-curve-to prev-p prev-h)
|
||||||
|
|
||||||
|
(and (= :line-to next-command) (some? next-p))
|
||||||
|
(update next-i helpers/update-curve-to next-h next-p)
|
||||||
|
|
||||||
|
(and (= :curve-to curr-command) (some? prev-p))
|
||||||
|
(update index update-handler :c2 prev-h)
|
||||||
|
|
||||||
|
(and (= :curve-to next-command) (some? next-p))
|
||||||
|
(update next-i update-handler :c1 next-h))))
|
||||||
|
|
||||||
|
(defn- corner-handle
|
||||||
|
[point neighbour]
|
||||||
|
(gpt/add point (gpt/scale (gpt/to-vec point neighbour) (/ 1 3))))
|
||||||
|
|
||||||
|
(defn- apply-corner-neighbour
|
||||||
|
"Adds independent handles around one matching node."
|
||||||
|
[content point {:keys [index segment prev-p next-c next-i next-p]}]
|
||||||
|
(cond-> content
|
||||||
|
(and (= :line-to (:command segment)) (some? prev-p))
|
||||||
|
(update index helpers/update-curve-to prev-p (corner-handle point prev-p))
|
||||||
|
|
||||||
|
(and (= :curve-to (:command segment)) (some? prev-p))
|
||||||
|
(update index update-handler :c2 (corner-handle point prev-p))
|
||||||
|
|
||||||
|
(and (= :line-to (:command next-c)) (some? next-p))
|
||||||
|
(update next-i helpers/update-curve-to (corner-handle point next-p) next-p)
|
||||||
|
|
||||||
|
(and (= :curve-to (:command next-c)) (some? next-p))
|
||||||
|
(update next-i update-handler :c1 (corner-handle point next-p))))
|
||||||
|
|
||||||
(defn make-curve-point
|
(defn make-curve-point
|
||||||
"Changes the content to make the point a 'curve'. The handlers will be
|
"Adds curve handles to every node at `point`."
|
||||||
positioned in the same vector that results from the previous->next
|
|
||||||
points but with fixed length; return a plain segments vector"
|
|
||||||
[content point]
|
[content point]
|
||||||
|
(let [indices (vec (point-indices content point))
|
||||||
(let [;; We perform this operation before because it can be
|
content (vec content)
|
||||||
;; optimized with internal reduction so is better to use the
|
neighbourhoods (mapv #(curve-neighbourhood content %) indices)
|
||||||
;; PathData type before converting it to plain vector.
|
neighbour-points (into #{} xf:mapcat-points neighbourhoods)]
|
||||||
indices
|
(if (= (count neighbour-points) 2)
|
||||||
(point-indices content point)
|
(let [tangent-data (smooth-tangent
|
||||||
|
content point indices neighbourhoods neighbour-points)]
|
||||||
;; We transform content to a plain format for execute the
|
(reduce #(apply-smooth-neighbour %1 point tangent-data %2)
|
||||||
;; algorithm because right now is the only way to execute it
|
content
|
||||||
content
|
neighbourhoods))
|
||||||
(vec content)
|
(reduce #(apply-corner-neighbour %1 point %2) content neighbourhoods))))
|
||||||
|
|
||||||
vectors
|
|
||||||
(map (fn [index]
|
|
||||||
(let [segment (get content index)
|
|
||||||
prev-i (dec index)
|
|
||||||
prev (when (not (= :move-to (:command segment)))
|
|
||||||
(get content prev-i))
|
|
||||||
next-i (inc index)
|
|
||||||
next (get content next-i)
|
|
||||||
next (when (not (= :move-to (:command next)))
|
|
||||||
next)]
|
|
||||||
{:index index
|
|
||||||
:prev-i (when (some? prev) prev-i)
|
|
||||||
:prev-c prev
|
|
||||||
:prev-p (helpers/segment->point prev)
|
|
||||||
:next-i (when (some? next) next-i)
|
|
||||||
:next-c next
|
|
||||||
:next-p (helpers/segment->point next)
|
|
||||||
:segment segment}))
|
|
||||||
indices)
|
|
||||||
|
|
||||||
points
|
|
||||||
(into #{} xf:mapcat-points vectors)]
|
|
||||||
|
|
||||||
(if (= (count points) 2)
|
|
||||||
(let [[fpoint spoint] (vec points)
|
|
||||||
v1 (gpt/to-vec fpoint point)
|
|
||||||
v2 (gpt/to-vec fpoint spoint)
|
|
||||||
vp (gpt/project v1 v2)
|
|
||||||
vh (gpt/subtract v1 vp)
|
|
||||||
|
|
||||||
add-curve
|
|
||||||
(fn [content {:keys [index prev-p next-p next-i]}]
|
|
||||||
(let [curr-segment (get content index)
|
|
||||||
curr-command (get curr-segment :command)
|
|
||||||
|
|
||||||
next-segment (get content next-i)
|
|
||||||
next-command (get next-segment :command)
|
|
||||||
|
|
||||||
;; New handlers for prev-point and next-point
|
|
||||||
prev-h
|
|
||||||
(when (some? prev-p) (gpt/add prev-p vh))
|
|
||||||
|
|
||||||
next-h
|
|
||||||
(when (some? next-p) (gpt/add next-p vh))
|
|
||||||
|
|
||||||
;; Correct 1/3 to the point improves the curve
|
|
||||||
prev-correction
|
|
||||||
(when (some? prev-h) (gpt/scale (gpt/to-vec prev-h point) (/ 1 3)))
|
|
||||||
|
|
||||||
next-correction
|
|
||||||
(when (some? next-h) (gpt/scale (gpt/to-vec next-h point) (/ 1 3)))
|
|
||||||
|
|
||||||
prev-h
|
|
||||||
(when (some? prev-h) (gpt/add prev-h prev-correction))
|
|
||||||
|
|
||||||
next-h
|
|
||||||
(when (some? next-h) (gpt/add next-h next-correction))]
|
|
||||||
|
|
||||||
(cond-> content
|
|
||||||
(and (= :line-to curr-command) (some? prev-p))
|
|
||||||
(update index helpers/update-curve-to prev-p prev-h)
|
|
||||||
|
|
||||||
(and (= :line-to next-command) (some? next-p))
|
|
||||||
(update next-i helpers/update-curve-to next-h next-p)
|
|
||||||
|
|
||||||
(and (= :curve-to curr-command) (some? prev-p))
|
|
||||||
(update index update-handler :c2 prev-h)
|
|
||||||
|
|
||||||
(and (= :curve-to next-command) (some? next-p))
|
|
||||||
(update next-i update-handler :c1 next-h))))]
|
|
||||||
|
|
||||||
(reduce add-curve content vectors))
|
|
||||||
|
|
||||||
(let [add-curve
|
|
||||||
(fn [content {:keys [index segment prev-p next-c next-i]}]
|
|
||||||
(cond-> content
|
|
||||||
(= :line-to (:command segment))
|
|
||||||
(update index #(line->curve prev-p %))
|
|
||||||
|
|
||||||
(= :curve-to (:command segment))
|
|
||||||
(update index #(line->curve prev-p %))
|
|
||||||
|
|
||||||
(= :line-to (:command next-c))
|
|
||||||
(update next-i #(line->curve point %))
|
|
||||||
|
|
||||||
(= :curve-to (:command next-c))
|
|
||||||
(update next-i #(line->curve point %))))]
|
|
||||||
(reduce add-curve content vectors)))))
|
|
||||||
|
|
||||||
(defn get-segments-with-points
|
(defn get-segments-with-points
|
||||||
"Given a content and a set of points return all the segments in the path
|
"Given a content and a set of points return all the segments in the path
|
||||||
@ -528,6 +550,103 @@
|
|||||||
|
|
||||||
(into [] (mapcat process-segments) (d/enumerate content))))
|
(into [] (mapcat process-segments) (d/enumerate content))))
|
||||||
|
|
||||||
|
(defn collapse-handler
|
||||||
|
"Collapses a handler onto its node and simplifies flat curves to lines."
|
||||||
|
[content index prefix]
|
||||||
|
(let [content (vec content)
|
||||||
|
node (handler->node content index prefix)
|
||||||
|
[cx cy] (helpers/prefix->coords prefix)]
|
||||||
|
(if (and (some? node)
|
||||||
|
(= :curve-to (dm/get-in content [index :command])))
|
||||||
|
(impl/from-plain
|
||||||
|
(-> content
|
||||||
|
(assoc-in [index :params cx] (:x node))
|
||||||
|
(assoc-in [index :params cy] (:y node))
|
||||||
|
(remove-line-curves)))
|
||||||
|
(impl/from-plain content))))
|
||||||
|
|
||||||
|
(def ^:private curve-toggle-bow
|
||||||
|
"Perpendicular handle offset used when curving a line."
|
||||||
|
0.25)
|
||||||
|
|
||||||
|
(defn toggle-segment-curve
|
||||||
|
"Toggles a segment between a line and a bowed curve."
|
||||||
|
[content index]
|
||||||
|
(let [content (vec content)
|
||||||
|
segment (get content index)
|
||||||
|
from (helpers/segment->point (get content (dec index)))
|
||||||
|
to (helpers/segment->point segment)]
|
||||||
|
(impl/from-plain
|
||||||
|
(case (:command segment)
|
||||||
|
:line-to
|
||||||
|
(if (some? from)
|
||||||
|
(let [v (gpt/to-vec from to)
|
||||||
|
perp (gpt/scale (gpt/point (- (:y v)) (:x v)) curve-toggle-bow)
|
||||||
|
h1 (-> from (gpt/add (gpt/scale v (/ 1 3))) (gpt/add perp))
|
||||||
|
h2 (-> from (gpt/add (gpt/scale v (/ 2 3))) (gpt/add perp))]
|
||||||
|
(update content index helpers/update-curve-to h1 h2))
|
||||||
|
content)
|
||||||
|
|
||||||
|
:curve-to
|
||||||
|
(assoc content index {:command :line-to
|
||||||
|
:params (select-keys (:params segment) [:x :y])})
|
||||||
|
|
||||||
|
content))))
|
||||||
|
|
||||||
|
(defn- subpath-start-indices
|
||||||
|
"Returns the starting command index for every command in `content`."
|
||||||
|
[content]
|
||||||
|
(loop [i 0
|
||||||
|
start 0
|
||||||
|
result (transient [])]
|
||||||
|
(if (>= i (count content))
|
||||||
|
(persistent! result)
|
||||||
|
(let [start (if (= :move-to (:command (nth content i))) i start)]
|
||||||
|
(recur (inc i) start (conj! result start))))))
|
||||||
|
|
||||||
|
(defn remove-segments
|
||||||
|
"Removes segments and opens their subpaths. Closing segments become
|
||||||
|
lines when needed to preserve geometry."
|
||||||
|
[content indices]
|
||||||
|
(let [content (vec content)
|
||||||
|
indices (set indices)
|
||||||
|
starts (subpath-start-indices content)
|
||||||
|
|
||||||
|
broken (into #{} (keep #(nth starts % nil)) indices)
|
||||||
|
|
||||||
|
content
|
||||||
|
(into []
|
||||||
|
(comp
|
||||||
|
(map-indexed
|
||||||
|
(fn [i cmd]
|
||||||
|
(cond
|
||||||
|
(contains? indices i)
|
||||||
|
(when-not (= :close-path (:command cmd))
|
||||||
|
{:command :move-to
|
||||||
|
:params (select-keys (:params cmd) [:x :y])})
|
||||||
|
|
||||||
|
;; Preserve the closing edge of broken subpaths.
|
||||||
|
(and (= :close-path (:command cmd))
|
||||||
|
(contains? broken (nth starts i)))
|
||||||
|
{:command :line-to
|
||||||
|
:params (-> (nth content (nth starts i))
|
||||||
|
(get :params)
|
||||||
|
(select-keys [:x :y]))}
|
||||||
|
|
||||||
|
:else cmd)))
|
||||||
|
(remove nil?))
|
||||||
|
content)
|
||||||
|
|
||||||
|
subpaths
|
||||||
|
(reduce (fn [acc cmd]
|
||||||
|
(if (or (= :move-to (:command cmd)) (empty? acc))
|
||||||
|
(conj acc [cmd])
|
||||||
|
(update acc (dec (count acc)) conj cmd)))
|
||||||
|
[]
|
||||||
|
content)]
|
||||||
|
(impl/from-plain
|
||||||
|
(into [] (comp (filter #(> (count %) 1)) cat) subpaths))))
|
||||||
|
|
||||||
;; FIXME: rename to next-segment
|
;; FIXME: rename to next-segment
|
||||||
(defn next-node
|
(defn next-node
|
||||||
"Calculates the next-node to be inserted."
|
"Calculates the next-node to be inserted."
|
||||||
@ -543,78 +662,237 @@
|
|||||||
:params (helpers/make-curve-params position prev-handler)}
|
:params (helpers/make-curve-params position prev-handler)}
|
||||||
:else {:command :move-to
|
:else {:command :move-to
|
||||||
:params position})))
|
:params position})))
|
||||||
(defn remove-nodes
|
(def ^:private ^:const chain-samples-per-segment 8)
|
||||||
"Removes from content the points given. Will try to reconstruct the paths
|
|
||||||
to keep everything consistent"
|
|
||||||
[content points]
|
|
||||||
|
|
||||||
|
(defn- chain-samples
|
||||||
|
"Returns ordered samples along a segment chain."
|
||||||
|
[chain]
|
||||||
|
(into [(:start (first chain))]
|
||||||
|
(mapcat
|
||||||
|
(fn [{:keys [start end segment]}]
|
||||||
|
(let [ts (map #(/ (double %) chain-samples-per-segment)
|
||||||
|
(range 1 (inc chain-samples-per-segment)))]
|
||||||
|
(if (= :curve-to (:command segment))
|
||||||
|
(let [curve (helpers/command->bezier segment start)]
|
||||||
|
(map #(helpers/curve-values curve %) ts))
|
||||||
|
(map #(helpers/line-values [start end] %) ts)))))
|
||||||
|
chain))
|
||||||
|
|
||||||
|
(defn- chain-tangent
|
||||||
|
"Returns an inward unit tangent at one end of a chain."
|
||||||
|
[{:keys [start end segment]} at-start? origin samples]
|
||||||
|
(let [tangent
|
||||||
|
(if (= :curve-to (:command segment))
|
||||||
|
(let [curve (helpers/command->bezier segment start)]
|
||||||
|
(cond-> (helpers/curve-tangent curve (if at-start? 0 1))
|
||||||
|
(not at-start?) (gpt/negate)))
|
||||||
|
(if at-start?
|
||||||
|
(gpt/to-vec start end)
|
||||||
|
(gpt/to-vec end start)))
|
||||||
|
tangent (gpt/unit tangent)]
|
||||||
|
(if (gpt/almost-zero? tangent)
|
||||||
|
(->> samples
|
||||||
|
(map #(gpt/to-vec origin %))
|
||||||
|
(remove gpt/almost-zero?)
|
||||||
|
(map gpt/unit)
|
||||||
|
(first))
|
||||||
|
tangent)))
|
||||||
|
|
||||||
|
(defn- flat-chain?
|
||||||
|
"True when a sampled chain is nearly straight."
|
||||||
|
[start end samples]
|
||||||
|
(or (mth/almost-zero? (gpt/distance start end))
|
||||||
|
(every? #(< (gpt/point-line-distance % start end) 0.01) samples)))
|
||||||
|
|
||||||
|
(defn- restore-split-curve
|
||||||
|
"Rejoins two untouched De Casteljau pieces into one cubic."
|
||||||
|
[chain]
|
||||||
|
(when (= 2 (count chain))
|
||||||
|
(let [{left-segment :segment left-start :start} (first chain)
|
||||||
|
{right-segment :segment} (second chain)]
|
||||||
|
(when (and (= :curve-to (:command left-segment))
|
||||||
|
(= :curve-to (:command right-segment)))
|
||||||
|
(let [[start split left-h1 left-h2 :as left-curve]
|
||||||
|
(helpers/command->bezier left-segment left-start)
|
||||||
|
[_ end right-h1 right-h2 :as right-curve]
|
||||||
|
(helpers/command->bezier right-segment split)
|
||||||
|
left-length (gpt/distance left-h2 split)
|
||||||
|
right-length (gpt/distance split right-h1)]
|
||||||
|
(when (and (not (mth/almost-zero? left-length))
|
||||||
|
(not (mth/almost-zero? right-length)))
|
||||||
|
(let [t (/ left-length (+ left-length right-length))
|
||||||
|
original-h1 (-> (gpt/to-vec start left-h1)
|
||||||
|
(gpt/scale (/ 1.0 t))
|
||||||
|
(gpt/add start))
|
||||||
|
original-h2 (-> (gpt/to-vec end right-h2)
|
||||||
|
(gpt/scale (/ 1.0 (- 1.0 t)))
|
||||||
|
(gpt/add end))
|
||||||
|
candidate [start end original-h1 original-h2]
|
||||||
|
[left' right'] (helpers/curve-split candidate t)]
|
||||||
|
(when (every? true?
|
||||||
|
(map gpt/close?
|
||||||
|
(concat left-curve right-curve)
|
||||||
|
(concat left' right')))
|
||||||
|
(helpers/make-curve-to end original-h1 original-h2)))))))))
|
||||||
|
|
||||||
|
(defn- approximate-chain
|
||||||
|
"Replaces a segment chain with a line or fitted curve."
|
||||||
|
[chain]
|
||||||
|
(or (restore-split-curve chain)
|
||||||
|
(let [start (:start (first chain))
|
||||||
|
end (:end (peek chain))
|
||||||
|
samples (chain-samples chain)
|
||||||
|
tan1 (chain-tangent (first chain) true start (rest samples))
|
||||||
|
tan2 (chain-tangent (peek chain) false end (rest (rseq samples)))]
|
||||||
|
(if (or (flat-chain? start end samples)
|
||||||
|
(nil? tan1)
|
||||||
|
(nil? tan2))
|
||||||
|
(helpers/make-line-to end)
|
||||||
|
(let [[h1 h2] (fit/fit-cubic samples tan1 tan2)]
|
||||||
|
(helpers/make-curve-to end h1 h2))))))
|
||||||
|
|
||||||
|
(defn- split-content-subpaths
|
||||||
|
"Splits plain path commands into subpath command vectors."
|
||||||
|
[content]
|
||||||
|
(reduce
|
||||||
|
(fn [subpaths segment]
|
||||||
|
(if (= :move-to (:command segment))
|
||||||
|
(conj subpaths [segment])
|
||||||
|
(if (seq subpaths)
|
||||||
|
(update subpaths (dec (count subpaths)) conj segment)
|
||||||
|
subpaths)))
|
||||||
|
[]
|
||||||
|
content))
|
||||||
|
|
||||||
|
(defn- removed-point-joins-subpaths?
|
||||||
|
"True when a removed point is an endpoint shared by open subpaths."
|
||||||
|
[subpaths points]
|
||||||
|
(let [open-endpoints
|
||||||
|
(keep (fn [subpath]
|
||||||
|
(let [start (some-> subpath first helpers/segment->point)
|
||||||
|
end (some-> subpath peek helpers/segment->point)]
|
||||||
|
(when (and (some? start)
|
||||||
|
(some? end)
|
||||||
|
(not (subpath/pt= start end)))
|
||||||
|
#{start end})))
|
||||||
|
subpaths)]
|
||||||
|
(some (fn [point]
|
||||||
|
(< 1 (count (filter (fn [endpoints]
|
||||||
|
(some #(subpath/pt= point %) endpoints))
|
||||||
|
open-endpoints))))
|
||||||
|
points)))
|
||||||
|
|
||||||
|
(defn- rotate-removed-closed-start
|
||||||
|
"Rotates a closed subpath so a removed seam becomes an interior node."
|
||||||
|
[subpath points]
|
||||||
|
(let [subpath (vec subpath)
|
||||||
|
close? (= :close-path (:command (peek subpath)))
|
||||||
|
body (cond-> subpath close? pop)
|
||||||
|
start (some-> body first helpers/segment->point)
|
||||||
|
end (some-> body peek helpers/segment->point)
|
||||||
|
closed? (or close? (= start end))]
|
||||||
|
(if-not (and closed? (contains? points start))
|
||||||
|
subpath
|
||||||
|
(let [segments (subvec body 1)
|
||||||
|
;; Materialize an implicit close segment before rotating.
|
||||||
|
segments (cond-> segments
|
||||||
|
(and close? (not= start end))
|
||||||
|
(conj (helpers/make-line-to start)))
|
||||||
|
new-start-index
|
||||||
|
(first
|
||||||
|
(keep-indexed
|
||||||
|
(fn [index segment]
|
||||||
|
(when-not (contains? points (helpers/segment->point segment))
|
||||||
|
index))
|
||||||
|
segments))]
|
||||||
|
(if (nil? new-start-index)
|
||||||
|
[]
|
||||||
|
(let [new-start (helpers/segment->point
|
||||||
|
(nth segments new-start-index))
|
||||||
|
rotated (into []
|
||||||
|
(concat
|
||||||
|
(subvec segments (inc new-start-index))
|
||||||
|
(subvec segments 0 (inc new-start-index))))]
|
||||||
|
(cond-> (into [(helpers/make-move-to new-start)] rotated)
|
||||||
|
close? (conj {:command :close-path :params {}}))))))))
|
||||||
|
|
||||||
|
(defn- remove-nodes*
|
||||||
|
"Removes interior nodes from prepared content."
|
||||||
|
[content points]
|
||||||
|
(loop [result []
|
||||||
|
pending []
|
||||||
|
subpath-start nil
|
||||||
|
prev-point nil
|
||||||
|
segments (seq content)]
|
||||||
|
|
||||||
|
(if (nil? segments)
|
||||||
|
;; Drop subpaths left with only a start point.
|
||||||
|
(into [] (comp (filter #(> (count %) 1)) cat) result)
|
||||||
|
|
||||||
|
(let [segment (first segments)
|
||||||
|
move? (= :move-to (:command segment))
|
||||||
|
close? (= :close-path (:command segment))
|
||||||
|
point (if close? subpath-start (helpers/segment->point segment))
|
||||||
|
remove? (and (not close?) (contains? points point))
|
||||||
|
|
||||||
|
;; Start a result subpath for each move command.
|
||||||
|
result (if move? (conj result []) result)
|
||||||
|
head (dec (count result))
|
||||||
|
subpath (peek result)
|
||||||
|
|
||||||
|
[result pending]
|
||||||
|
(cond
|
||||||
|
;; Collect removed interior nodes until the next kept node.
|
||||||
|
remove?
|
||||||
|
[result (if (seq subpath)
|
||||||
|
(conj pending {:start prev-point :end point :segment segment})
|
||||||
|
[])]
|
||||||
|
|
||||||
|
move?
|
||||||
|
[(update result head conj segment) []]
|
||||||
|
|
||||||
|
;; Promote the first kept node to the subpath start.
|
||||||
|
(empty? subpath)
|
||||||
|
[(update result head conj (helpers/make-move-to point)) []]
|
||||||
|
|
||||||
|
(seq pending)
|
||||||
|
(if (and close? (contains? points subpath-start))
|
||||||
|
;; Close straight onto the new start.
|
||||||
|
[(update result head conj segment) []]
|
||||||
|
(let [chain (conj pending {:start prev-point :end point :segment segment})
|
||||||
|
approx (approximate-chain chain)
|
||||||
|
;; The close command already draws a zero-length replacement.
|
||||||
|
skip? (and close?
|
||||||
|
(= :line-to (:command approx))
|
||||||
|
(< (gpt/distance (:start (first chain)) point) 0.01))
|
||||||
|
result (cond-> result
|
||||||
|
(not skip?) (update head conj approx)
|
||||||
|
close? (update head conj segment))]
|
||||||
|
[result []]))
|
||||||
|
|
||||||
|
:else
|
||||||
|
[(update result head conj segment) []])]
|
||||||
|
|
||||||
|
(recur result
|
||||||
|
pending
|
||||||
|
(if move? point subpath-start)
|
||||||
|
point
|
||||||
|
(next segments))))))
|
||||||
|
|
||||||
|
(defn remove-nodes
|
||||||
|
"Removes nodes and joins surrounding segments with a fitted replacement."
|
||||||
|
[content points]
|
||||||
(if (empty? points)
|
(if (empty? points)
|
||||||
content
|
content
|
||||||
|
(let [subpaths (split-content-subpaths content)
|
||||||
(let [content (d/with-prev content)]
|
content (if (removed-point-joins-subpaths? subpaths points)
|
||||||
|
(subpath/close-subpaths content)
|
||||||
(loop [result []
|
content)
|
||||||
last-handler nil
|
content (into []
|
||||||
[cur-segment prev-segment] (first content)
|
(mapcat #(rotate-removed-closed-start % points))
|
||||||
content (rest content)]
|
(split-content-subpaths
|
||||||
|
content))]
|
||||||
(if (nil? cur-segment)
|
(remove-nodes* content points))))
|
||||||
;; The result with be an array of arrays were every entry is a subpath
|
|
||||||
(->> result
|
|
||||||
;; remove empty and only 1 node subpaths
|
|
||||||
(filter #(> (count %) 1))
|
|
||||||
;; flatten array-of-arrays plain array
|
|
||||||
(flatten)
|
|
||||||
(into []))
|
|
||||||
|
|
||||||
(let [move? (= :move-to (:command cur-segment))
|
|
||||||
curve? (= :curve-to (:command cur-segment))
|
|
||||||
|
|
||||||
;; When the old command was a move we start a subpath
|
|
||||||
result (if move? (conj result []) result)
|
|
||||||
|
|
||||||
subpath (peek result)
|
|
||||||
|
|
||||||
point (helpers/segment->point cur-segment)
|
|
||||||
|
|
||||||
old-prev-point (helpers/segment->point prev-segment)
|
|
||||||
new-prev-point (helpers/segment->point (peek subpath))
|
|
||||||
|
|
||||||
remove? (contains? points point)
|
|
||||||
|
|
||||||
|
|
||||||
;; We store the first handler for the first curve to be removed to
|
|
||||||
;; use it for the first handler of the regenerated path
|
|
||||||
cur-handler (cond
|
|
||||||
(and (not last-handler) remove? curve?)
|
|
||||||
(select-keys (:params cur-segment) [:c1x :c1y])
|
|
||||||
|
|
||||||
(not remove?)
|
|
||||||
nil
|
|
||||||
|
|
||||||
:else
|
|
||||||
last-handler)
|
|
||||||
|
|
||||||
cur-segment (cond-> cur-segment
|
|
||||||
;; If we're starting a subpath and it's not a move make it a move
|
|
||||||
(and (not move?) (empty? subpath))
|
|
||||||
(assoc :command :move-to
|
|
||||||
:params (select-keys (:params cur-segment) [:x :y]))
|
|
||||||
|
|
||||||
;; If have a curve the first handler will be relative to the previous
|
|
||||||
;; point. We change the handler to the new previous point
|
|
||||||
(and curve? (seq subpath) (not= old-prev-point new-prev-point))
|
|
||||||
(update :params merge last-handler))
|
|
||||||
|
|
||||||
head-idx (dec (count result))
|
|
||||||
|
|
||||||
result (cond-> result
|
|
||||||
(not remove?)
|
|
||||||
(update head-idx conj cur-segment))]
|
|
||||||
(recur result
|
|
||||||
cur-handler
|
|
||||||
(first content)
|
|
||||||
(rest content))))))))
|
|
||||||
|
|
||||||
(defn join-nodes
|
(defn join-nodes
|
||||||
"Creates new segments between points that weren't previously.
|
"Creates new segments between points that weren't previously.
|
||||||
@ -649,41 +927,119 @@
|
|||||||
|
|
||||||
(into content new-content)))
|
(into content new-content)))
|
||||||
|
|
||||||
|
(def ^:private separate-node-offset (gpt/point 8 8))
|
||||||
|
|
||||||
|
(defn- separate-node
|
||||||
|
"Splits a node into offset open ends, preserving adjacent handles."
|
||||||
|
[content point offset]
|
||||||
|
(let [content (vec content)
|
||||||
|
n (count content)
|
||||||
|
{ox :x oy :y} offset
|
||||||
|
seg? (fn [c] (and (some? c)
|
||||||
|
(not= :move-to (:command c))
|
||||||
|
(not= :close-path (:command c))))]
|
||||||
|
(loop [i 0
|
||||||
|
k 0
|
||||||
|
result (transient [])]
|
||||||
|
(if (>= i n)
|
||||||
|
(persistent! result)
|
||||||
|
(let [cmd (nth content i)
|
||||||
|
nxt (nth content (inc i) nil)
|
||||||
|
at-p? (and (not= :close-path (:command cmd))
|
||||||
|
(= point (helpers/segment->point cmd)))]
|
||||||
|
(cond
|
||||||
|
;; Offset a subpath start.
|
||||||
|
(and at-p? (= :move-to (:command cmd)))
|
||||||
|
(let [off (gpt/point (* k ox) (* k oy))]
|
||||||
|
(recur (inc i) (inc k)
|
||||||
|
(conj! result (-> cmd
|
||||||
|
(update-in [:params :x] + (:x off))
|
||||||
|
(update-in [:params :y] + (:y off))))))
|
||||||
|
|
||||||
|
;; Split an interior node into two subpaths.
|
||||||
|
(and at-p? (seg? cmd) (seg? nxt))
|
||||||
|
(let [off (gpt/point (* k ox) (* k oy))
|
||||||
|
cmd' (cond-> (-> cmd
|
||||||
|
(update-in [:params :x] + (:x off))
|
||||||
|
(update-in [:params :y] + (:y off)))
|
||||||
|
(= :curve-to (:command cmd))
|
||||||
|
(-> (update-in [:params :c2x] + (:x off))
|
||||||
|
(update-in [:params :c2y] + (:y off))))
|
||||||
|
k2 (inc k)
|
||||||
|
off2 (gpt/point (* k2 ox) (* k2 oy))
|
||||||
|
mv (helpers/make-move-to (gpt/add point off2))
|
||||||
|
nxt' (cond-> nxt
|
||||||
|
(= :curve-to (:command nxt))
|
||||||
|
(-> (update-in [:params :c1x] + (:x off2))
|
||||||
|
(update-in [:params :c1y] + (:y off2))))]
|
||||||
|
(recur (+ i 2) (inc k2)
|
||||||
|
(-> result (conj! cmd') (conj! mv) (conj! nxt'))))
|
||||||
|
|
||||||
|
;; Open and offset a closed seam.
|
||||||
|
(and at-p? (seg? cmd) (= :close-path (:command nxt)))
|
||||||
|
(let [off (gpt/point (* k ox) (* k oy))
|
||||||
|
cmd' (cond-> (-> cmd
|
||||||
|
(update-in [:params :x] + (:x off))
|
||||||
|
(update-in [:params :y] + (:y off)))
|
||||||
|
(= :curve-to (:command cmd))
|
||||||
|
(-> (update-in [:params :c2x] + (:x off))
|
||||||
|
(update-in [:params :c2y] + (:y off))))]
|
||||||
|
;; Drop the close command so the seam stays open.
|
||||||
|
(recur (+ i 2) (inc k) (conj! result cmd')))
|
||||||
|
|
||||||
|
;; Offset the end of an open subpath.
|
||||||
|
(and at-p? (seg? cmd) (not= :close-path (:command nxt)))
|
||||||
|
(let [off (gpt/point (* k ox) (* k oy))]
|
||||||
|
(recur (inc i) (inc k)
|
||||||
|
(conj! result (cond-> (-> cmd
|
||||||
|
(update-in [:params :x] + (:x off))
|
||||||
|
(update-in [:params :y] + (:y off)))
|
||||||
|
(= :curve-to (:command cmd))
|
||||||
|
(-> (update-in [:params :c2x] + (:x off))
|
||||||
|
(update-in [:params :c2y] + (:y off)))))))
|
||||||
|
|
||||||
|
:else
|
||||||
|
(recur (inc i) k (conj! result cmd))))))))
|
||||||
|
|
||||||
(defn separate-nodes
|
(defn separate-nodes
|
||||||
"Removes the segments between the points given"
|
"Removes segments between points or splits one node into offset open ends."
|
||||||
[content points]
|
([content points]
|
||||||
|
(separate-nodes content points separate-node-offset))
|
||||||
|
([content points offset]
|
||||||
|
(if (= 1 (count points))
|
||||||
|
(separate-node (vec content) (first points) offset)
|
||||||
|
|
||||||
(let [content (d/with-prev content)]
|
(let [content (d/with-prev content)]
|
||||||
(loop [result []
|
(loop [result []
|
||||||
[cur-segment prev-segment] (first content)
|
[cur-segment prev-segment] (first content)
|
||||||
content (rest content)]
|
content (rest content)]
|
||||||
|
|
||||||
(if (nil? cur-segment)
|
(if (nil? cur-segment)
|
||||||
(->> result
|
(->> result
|
||||||
(filter #(> (count %) 1))
|
(filter #(> (count %) 1))
|
||||||
(flatten)
|
(flatten)
|
||||||
(into []))
|
(into []))
|
||||||
|
|
||||||
(let [prev-point (helpers/segment->point prev-segment)
|
(let [prev-point (helpers/segment->point prev-segment)
|
||||||
cur-point (helpers/segment->point cur-segment)
|
cur-point (helpers/segment->point cur-segment)
|
||||||
|
|
||||||
cur-segment (cond-> cur-segment
|
cur-segment (cond-> cur-segment
|
||||||
(and (contains? points prev-point)
|
(and (contains? points prev-point)
|
||||||
(contains? points cur-point))
|
(contains? points cur-point))
|
||||||
|
|
||||||
(assoc :command :move-to
|
(assoc :command :move-to
|
||||||
:params (select-keys (:params cur-segment) [:x :y])))
|
:params (select-keys (:params cur-segment) [:x :y])))
|
||||||
|
|
||||||
move? (= :move-to (:command cur-segment))
|
move? (= :move-to (:command cur-segment))
|
||||||
|
|
||||||
result (if move? (conj result []) result)
|
result (if move? (conj result []) result)
|
||||||
head-idx (dec (count result))
|
head-idx (dec (count result))
|
||||||
|
|
||||||
result (-> result
|
result (-> result
|
||||||
(update head-idx conj cur-segment))]
|
(update head-idx conj cur-segment))]
|
||||||
(recur result
|
(recur result
|
||||||
(first content)
|
(first content)
|
||||||
(rest content)))))))
|
(rest content)))))))))
|
||||||
|
|
||||||
|
|
||||||
(defn- add-to-set
|
(defn- add-to-set
|
||||||
@ -753,9 +1109,10 @@
|
|||||||
(mapv replace-command))))
|
(mapv replace-command))))
|
||||||
|
|
||||||
(defn merge-nodes
|
(defn merge-nodes
|
||||||
"Reduces the contiguous segments in points to a single point"
|
"Joins and merges `points` into one point."
|
||||||
[content points]
|
[content points]
|
||||||
(let [segments (get-segments-with-points content points)]
|
(let [content (join-nodes content points)
|
||||||
|
segments (get-segments-with-points content points)]
|
||||||
(if (seq segments)
|
(if (seq segments)
|
||||||
(let [point->merge-point (-> segments
|
(let [point->merge-point (-> segments
|
||||||
(group-segments)
|
(group-segments)
|
||||||
@ -889,3 +1246,16 @@
|
|||||||
(conj result {:command :close-path})
|
(conj result {:command :close-path})
|
||||||
result)]
|
result)]
|
||||||
(impl/from-plain result))))))
|
(impl/from-plain result))))))
|
||||||
|
|
||||||
|
(defn smooth-points->content
|
||||||
|
"Fits smooth path content through `points`, falling back to lines."
|
||||||
|
[points tolerance]
|
||||||
|
(let [curves (when (>= (count points) 3)
|
||||||
|
(fit/fit-curve points tolerance))]
|
||||||
|
(if (empty? curves)
|
||||||
|
(points->content points)
|
||||||
|
(impl/from-plain
|
||||||
|
(into [(helpers/make-move-to (ffirst curves))]
|
||||||
|
(map (fn [[_ end h1 h2]]
|
||||||
|
(helpers/make-curve-to end h1 h2)))
|
||||||
|
curves)))))
|
||||||
|
|||||||
208
common/src/app/common/types/path/selection.cljc
Normal file
@ -0,0 +1,208 @@
|
|||||||
|
;; 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 Sucursal en España SL
|
||||||
|
|
||||||
|
(ns app.common.types.path.selection
|
||||||
|
"Transforms selected path nodes and handlers."
|
||||||
|
(:require
|
||||||
|
[app.common.data :as d]
|
||||||
|
[app.common.geom.point :as gpt]
|
||||||
|
[app.common.types.path.helpers :as helpers]
|
||||||
|
[app.common.types.path.impl :as impl]))
|
||||||
|
|
||||||
|
(def align-nodes-axis
|
||||||
|
"Valid alignment axes."
|
||||||
|
#{:hleft :hcenter :hright :vtop :vcenter :vbottom})
|
||||||
|
|
||||||
|
(def distribute-nodes-axis
|
||||||
|
"Valid distribution axes."
|
||||||
|
#{:horizontal :vertical})
|
||||||
|
|
||||||
|
(defn- selected-node-entries
|
||||||
|
"Returns selected nodes as `[index point]` pairs."
|
||||||
|
[content indices]
|
||||||
|
(into []
|
||||||
|
(comp (filter (fn [[i seg]]
|
||||||
|
(and (contains? indices i)
|
||||||
|
(not= :close-path (:command seg)))))
|
||||||
|
(map (fn [[i seg]] [i (helpers/segment->point seg)])))
|
||||||
|
(d/enumerate content)))
|
||||||
|
|
||||||
|
(defn- expand-coincident-node-indices
|
||||||
|
"Includes every command that represents a selected logical node."
|
||||||
|
[content indices]
|
||||||
|
(let [indices (set indices)
|
||||||
|
points (into #{} (map second) (selected-node-entries content indices))]
|
||||||
|
(into indices
|
||||||
|
(comp
|
||||||
|
(filter (fn [[_ segment]]
|
||||||
|
(and (not= :close-path (:command segment))
|
||||||
|
(contains? points (helpers/segment->point segment)))))
|
||||||
|
(map first))
|
||||||
|
(d/enumerate content))))
|
||||||
|
|
||||||
|
(defn- translate-nodes
|
||||||
|
"Moves selected nodes and handlers by their node deltas."
|
||||||
|
[content indices deltas]
|
||||||
|
(let [node-sel? (fn [i] (contains? indices i))
|
||||||
|
add-delta (fn [params xk yk delta]
|
||||||
|
(if (and delta (contains? params xk))
|
||||||
|
(-> params
|
||||||
|
(update xk + (:x delta))
|
||||||
|
(update yk + (:y delta)))
|
||||||
|
params))
|
||||||
|
move-cmd (fn [i {:keys [command params] :as seg}]
|
||||||
|
(let [curve? (= :curve-to command)
|
||||||
|
params (cond-> params
|
||||||
|
(and (not= :close-path command) (node-sel? i))
|
||||||
|
(add-delta :x :y (get deltas i))
|
||||||
|
|
||||||
|
(and curve? (node-sel? (dec i)))
|
||||||
|
(add-delta :c1x :c1y (get deltas (dec i)))
|
||||||
|
|
||||||
|
(and curve? (node-sel? i))
|
||||||
|
(add-delta :c2x :c2y (get deltas i)))]
|
||||||
|
(assoc seg :params params)))]
|
||||||
|
(into [] (map-indexed move-cmd) content)))
|
||||||
|
|
||||||
|
(defn flip-content
|
||||||
|
"Flips selected nodes and handlers across their bounds."
|
||||||
|
[content indices axis]
|
||||||
|
(let [content (vec content)
|
||||||
|
indices (set indices)
|
||||||
|
node-sel? (fn [i] (contains? indices i))
|
||||||
|
positions (into []
|
||||||
|
(comp (filter (fn [[i seg]]
|
||||||
|
(and (node-sel? i)
|
||||||
|
(not= :close-path (:command seg)))))
|
||||||
|
(map (fn [[_ seg]] (helpers/segment->point seg))))
|
||||||
|
(d/enumerate content))]
|
||||||
|
(if (empty? positions)
|
||||||
|
(impl/from-plain content)
|
||||||
|
(let [xs (map :x positions)
|
||||||
|
ys (map :y positions)
|
||||||
|
cx (/ (+ (reduce min xs) (reduce max xs)) 2.0)
|
||||||
|
cy (/ (+ (reduce min ys) (reduce max ys)) 2.0)
|
||||||
|
flip-x? (= axis :horizontal)
|
||||||
|
reflect (fn [params xk yk]
|
||||||
|
(if flip-x?
|
||||||
|
(cond-> params
|
||||||
|
(contains? params xk) (update xk #(- (* 2.0 cx) %)))
|
||||||
|
(cond-> params
|
||||||
|
(contains? params yk) (update yk #(- (* 2.0 cy) %)))))
|
||||||
|
flip-cmd (fn [i {:keys [command params] :as seg}]
|
||||||
|
(let [curve? (= :curve-to command)
|
||||||
|
params (cond-> params
|
||||||
|
(and (not= :close-path command) (node-sel? i))
|
||||||
|
(reflect :x :y)
|
||||||
|
|
||||||
|
(and curve? (node-sel? (dec i)))
|
||||||
|
(reflect :c1x :c1y)
|
||||||
|
|
||||||
|
(and curve? (node-sel? i))
|
||||||
|
(reflect :c2x :c2y))]
|
||||||
|
(assoc seg :params params)))]
|
||||||
|
(impl/from-plain
|
||||||
|
(into [] (map-indexed flip-cmd) content))))))
|
||||||
|
|
||||||
|
(defn align-content
|
||||||
|
"Aligns two or more selected nodes within their bounds."
|
||||||
|
[content indices axis]
|
||||||
|
(let [content (vec content)
|
||||||
|
indices (set indices)
|
||||||
|
entries (selected-node-entries content indices)]
|
||||||
|
(if (< (count entries) 2)
|
||||||
|
(impl/from-plain content)
|
||||||
|
(let [pts (map second entries)
|
||||||
|
xs (map :x pts)
|
||||||
|
ys (map :y pts)
|
||||||
|
minx (reduce min xs)
|
||||||
|
maxx (reduce max xs)
|
||||||
|
miny (reduce min ys)
|
||||||
|
maxy (reduce max ys)
|
||||||
|
[coord target] (case axis
|
||||||
|
:hleft [:x minx]
|
||||||
|
:hcenter [:x (/ (+ minx maxx) 2.0)]
|
||||||
|
:hright [:x maxx]
|
||||||
|
:vtop [:y miny]
|
||||||
|
:vcenter [:y (/ (+ miny maxy) 2.0)]
|
||||||
|
:vbottom [:y maxy])
|
||||||
|
deltas (into {}
|
||||||
|
(map (fn [[i p]]
|
||||||
|
[i (if (= coord :x)
|
||||||
|
(gpt/point (- target (:x p)) 0)
|
||||||
|
(gpt/point 0 (- target (:y p))))]))
|
||||||
|
entries)]
|
||||||
|
(impl/from-plain (translate-nodes content indices deltas))))))
|
||||||
|
|
||||||
|
(defn set-nodes-coordinate
|
||||||
|
"Sets one coordinate of selected nodes and handlers."
|
||||||
|
[content indices axis value]
|
||||||
|
(let [content (vec content)
|
||||||
|
indices (expand-coincident-node-indices content indices)
|
||||||
|
entries (selected-node-entries content indices)
|
||||||
|
deltas (into {}
|
||||||
|
(map (fn [[i p]]
|
||||||
|
[i (if (= axis :x)
|
||||||
|
(gpt/point (- value (:x p)) 0)
|
||||||
|
(gpt/point 0 (- value (:y p))))]))
|
||||||
|
entries)]
|
||||||
|
(impl/from-plain (translate-nodes content indices deltas))))
|
||||||
|
|
||||||
|
(defn set-handler-points
|
||||||
|
"Moves handlers to their target points."
|
||||||
|
[content pts]
|
||||||
|
(impl/from-plain
|
||||||
|
(reduce
|
||||||
|
(fn [content [[index prefix] pt]]
|
||||||
|
(if (= :curve-to (:command (get content index)))
|
||||||
|
(let [[cx cy] (if (= prefix :c1) [:c1x :c1y] [:c2x :c2y])]
|
||||||
|
(-> content
|
||||||
|
(assoc-in [index :params cx] (:x pt))
|
||||||
|
(assoc-in [index :params cy] (:y pt))))
|
||||||
|
content))
|
||||||
|
(vec content)
|
||||||
|
pts)))
|
||||||
|
|
||||||
|
(defn translate-selected-nodes
|
||||||
|
"Moves selected nodes and handlers by `delta`."
|
||||||
|
[content indices delta]
|
||||||
|
(let [content (vec content)
|
||||||
|
indices (expand-coincident-node-indices content indices)]
|
||||||
|
(impl/from-plain
|
||||||
|
(translate-nodes content indices (into {} (map (fn [i] [i delta])) indices)))))
|
||||||
|
|
||||||
|
(defn distribute-content
|
||||||
|
"Distributes three or more selected positions along `axis`."
|
||||||
|
[content indices axis]
|
||||||
|
(let [content (vec content)
|
||||||
|
indices (set indices)
|
||||||
|
entries (selected-node-entries content indices)
|
||||||
|
horizontal? (= axis :horizontal)
|
||||||
|
coord (fn [p] (if horizontal? (:x p) (:y p)))
|
||||||
|
groups (->> entries
|
||||||
|
(group-by (fn [[_ p]] [(:x p) (:y p)]))
|
||||||
|
(mapv (fn [[_ es]]
|
||||||
|
{:point (second (first es))
|
||||||
|
:indices (mapv first es)})))]
|
||||||
|
(if (< (count groups) 3)
|
||||||
|
(impl/from-plain content)
|
||||||
|
(let [sorted (sort-by (comp coord :point) groups)
|
||||||
|
lo (coord (:point (first sorted)))
|
||||||
|
hi (coord (:point (last sorted)))
|
||||||
|
step (/ (- hi lo) (dec (count sorted)))
|
||||||
|
deltas (into {}
|
||||||
|
(comp
|
||||||
|
(map-indexed
|
||||||
|
(fn [k {:keys [point indices]}]
|
||||||
|
(let [target (+ lo (* k step))
|
||||||
|
d (- target (coord point))
|
||||||
|
dp (if horizontal?
|
||||||
|
(gpt/point d 0)
|
||||||
|
(gpt/point 0 d))]
|
||||||
|
(map (fn [i] [i dp]) indices))))
|
||||||
|
cat)
|
||||||
|
sorted)]
|
||||||
|
(impl/from-plain (translate-nodes content indices deltas))))))
|
||||||
@ -28,13 +28,17 @@
|
|||||||
(defn add-subpath-command
|
(defn add-subpath-command
|
||||||
"Adds a command to the subpath"
|
"Adds a command to the subpath"
|
||||||
[subpath command]
|
[subpath command]
|
||||||
(let [command (if (= :close-path (:command command))
|
(let [close? (= :close-path (:command command))]
|
||||||
(helpers/make-line-to (:from subpath))
|
(if (and close? (pt= (:from subpath) (:to subpath)))
|
||||||
command)
|
;; Avoid adding a duplicate node at an already closed seam.
|
||||||
p (helpers/segment->point command)]
|
subpath
|
||||||
(-> subpath
|
(let [command (if close?
|
||||||
(assoc :to p)
|
(helpers/make-line-to (:from subpath))
|
||||||
(update :data conj command))))
|
command)
|
||||||
|
p (helpers/segment->point command)]
|
||||||
|
(-> subpath
|
||||||
|
(assoc :to p)
|
||||||
|
(update :data conj command))))))
|
||||||
|
|
||||||
(defn reverse-command
|
(defn reverse-command
|
||||||
"Reverses a single command"
|
"Reverses a single command"
|
||||||
@ -189,6 +193,27 @@
|
|||||||
|
|
||||||
(into [] xf-mapcat-data closed-subpaths)))
|
(into [] xf-mapcat-data closed-subpaths)))
|
||||||
|
|
||||||
|
(defn- close-loop
|
||||||
|
"Adds an explicit close command when a subpath's endpoints meet."
|
||||||
|
[{:keys [from to data] :as subpath}]
|
||||||
|
(let [last-seg (peek data)]
|
||||||
|
(if (or (< (count data) 2)
|
||||||
|
(= :close-path (:command last-seg))
|
||||||
|
(not (pt= from to)))
|
||||||
|
subpath
|
||||||
|
(let [data (cond-> data
|
||||||
|
(= :line-to (:command last-seg)) (pop))]
|
||||||
|
(assoc subpath
|
||||||
|
:to from
|
||||||
|
:data (conj data {:command :close-path :params {}}))))))
|
||||||
|
|
||||||
|
(defn close-loops
|
||||||
|
"Adds close commands to subpaths whose endpoints meet."
|
||||||
|
[content]
|
||||||
|
(->> (get-subpaths content)
|
||||||
|
(mapv close-loop)
|
||||||
|
(into [] xf-mapcat-data)))
|
||||||
|
|
||||||
;; FIXME: revisit this fn impl for perfromance
|
;; FIXME: revisit this fn impl for perfromance
|
||||||
(defn reverse-content
|
(defn reverse-content
|
||||||
"Given a content reverse the order of the commands"
|
"Given a content reverse the order of the commands"
|
||||||
|
|||||||
@ -532,8 +532,9 @@
|
|||||||
{:type :path
|
{:type :path
|
||||||
:name "Path"
|
:name "Path"
|
||||||
:fills []
|
:fills []
|
||||||
|
;; Paths use centered strokes by default.
|
||||||
:strokes [{:stroke-style :solid
|
:strokes [{:stroke-style :solid
|
||||||
:stroke-alignment :inner
|
:stroke-alignment :center
|
||||||
:stroke-width 1
|
:stroke-width 1
|
||||||
:stroke-color clr/black
|
:stroke-color clr/black
|
||||||
:stroke-opacity 1}]})
|
:stroke-opacity 1}]})
|
||||||
|
|||||||
@ -171,10 +171,12 @@ test("Selection size badge shows dimensions for path shapes", async ({ page }) =
|
|||||||
// Workaround: hover viewport first to avoid nil mouse position crash
|
// Workaround: hover viewport first to avoid nil mouse position crash
|
||||||
await workspacePage.viewport.hover();
|
await workspacePage.viewport.hover();
|
||||||
|
|
||||||
// Draw a path
|
// Draw a path with two segments; a single straight segment shows
|
||||||
|
// endpoint controls instead of the size badge
|
||||||
await workspacePage.pathButton.click();
|
await workspacePage.pathButton.click();
|
||||||
await workspacePage.clickAt(779, 163);
|
await workspacePage.clickAt(779, 163);
|
||||||
await workspacePage.clickAt(951, 258);
|
await workspacePage.clickAt(951, 258);
|
||||||
|
await workspacePage.clickAt(1050, 163);
|
||||||
|
|
||||||
// Finish drawing (commits path, path enters edition mode)
|
// Finish drawing (commits path, path enters edition mode)
|
||||||
await page.keyboard.press("Escape");
|
await page.keyboard.press("Escape");
|
||||||
@ -187,6 +189,36 @@ test("Selection size badge shows dimensions for path shapes", async ({ page }) =
|
|||||||
await expect(badgeText).toHaveText(/\d+\.?\d* x \d+\.?\d*/);
|
await expect(badgeText).toHaveText(/\d+\.?\d* x \d+\.?\d*/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("Selection size badge is hidden for straight line paths", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
const workspacePage = new WasmWorkspacePage(page);
|
||||||
|
await workspacePage.setupEmptyFile();
|
||||||
|
await workspacePage.mockRPC(
|
||||||
|
"update-file?id=*",
|
||||||
|
"workspace/update-file-empty.json",
|
||||||
|
);
|
||||||
|
|
||||||
|
await workspacePage.goToWorkspace();
|
||||||
|
|
||||||
|
// Workaround: hover viewport first to avoid nil mouse position crash
|
||||||
|
await workspacePage.viewport.hover();
|
||||||
|
|
||||||
|
// Draw a path with a single straight segment
|
||||||
|
await workspacePage.pathButton.click();
|
||||||
|
await workspacePage.clickAt(779, 163);
|
||||||
|
await workspacePage.clickAt(951, 258);
|
||||||
|
|
||||||
|
// Finish drawing (commits path, path enters edition mode)
|
||||||
|
await page.keyboard.press("Escape");
|
||||||
|
|
||||||
|
// Exit edition mode (path stays selected)
|
||||||
|
await page.keyboard.press("Escape");
|
||||||
|
|
||||||
|
await expect(page.locator(".line-controls")).toBeVisible();
|
||||||
|
await expect(page.locator(".selection-size-badge")).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
test("User makes a group", async ({ page }) => {
|
test("User makes a group", async ({ page }) => {
|
||||||
const workspacePage = new WasmWorkspacePage(page);
|
const workspacePage = new WasmWorkspacePage(page);
|
||||||
await workspacePage.setupEmptyFile();
|
await workspacePage.setupEmptyFile();
|
||||||
|
|||||||
6
frontend/resources/images/cursors/draw-add.svg
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 16 16">
|
||||||
|
<g transform="scale(0.76190476)">
|
||||||
|
<path d="M17.2916 13.4999C17.7886 13.4999 18.1919 13.9033 18.192 14.4003V15.8075H19.5992C20.0962 15.8075 20.4995 16.2109 20.4996 16.7079V17.2919C20.4996 17.7889 20.0963 18.1923 19.5992 18.1923H18.192V19.5995C18.192 20.0965 17.7887 20.4999 17.2916 20.4999H16.7076C16.2108 20.4997 15.8073 20.0964 15.8073 19.5995V18.1923H14.4C13.9031 18.1921 13.4996 17.7888 13.4996 17.2919V16.7079C13.4997 16.211 13.9032 15.8077 14.4 15.8075H15.8073V14.4003C15.8073 13.9034 16.2108 13.5001 16.7076 13.4999H17.2916ZM0.88147 -0.0987549L1.00256 -0.0782471L1.99182 0.146362C2.3212 0.21982 2.65134 0.291628 2.98108 0.361206C4.00487 0.577222 5.31171 0.836914 6.47229 1.01648C7.39815 1.15971 8.48521 1.32758 9.51331 1.64441C10.5453 1.96253 11.6032 2.45468 12.4391 3.30359C14.1063 4.99746 14.7467 6.98154 14.6783 9.37097L15.8883 10.5809C16.4936 11.1862 16.4936 12.168 15.8883 12.7733L12.7731 15.8885C12.1678 16.4938 11.186 16.4938 10.5807 15.8885L9.37073 14.6786C6.98134 14.747 4.99719 14.1065 3.30334 12.4393C2.45441 11.6035 1.96229 10.5455 1.64417 9.51355C1.32731 8.48543 1.15947 7.39842 1.01624 6.47253C0.836669 5.31192 0.576975 4.00516 0.360962 2.98132C0.221743 2.3215 0.0738358 1.66228 -0.0784912 1.00281C-0.218776 0.394559 0.283552 -0.155568 0.88147 -0.0987549Z" fill="white"/>
|
||||||
|
<path d="M17.2922 14.0001C17.513 14.0001 17.6923 14.1788 17.6926 14.3995V16.3077H19.5998C19.8205 16.3077 19.9999 16.4865 20.0002 16.7072V17.2921C20.0002 17.513 19.8207 17.6925 19.5998 17.6925H17.6926V19.5997C17.6926 19.8206 17.5131 20.0001 17.2922 20.0001H16.7072C16.4865 19.9999 16.3078 19.8205 16.3078 19.5997V17.6925H14.3996C14.1789 17.6923 14.0002 17.5129 14.0002 17.2921V16.7072C14.0004 16.4866 14.179 16.308 14.3996 16.3077H16.3078V14.3995C16.308 14.179 16.4867 14.0004 16.7072 14.0001H17.2922ZM0.889832 0.408325C1.55119 0.561087 2.21384 0.710545 2.87811 0.850708C3.90528 1.06744 5.22258 1.32923 6.39667 1.51086C7.32828 1.65499 8.37875 1.81778 9.36639 2.12219C10.3562 2.42732 11.3274 2.88674 12.0832 3.65442C13.6878 5.28471 14.2849 7.19577 14.174 9.57336L15.5353 10.9347C15.9452 11.3448 15.9453 12.01 15.5353 12.42L12.4201 15.5353C12.0101 15.9453 11.3448 15.9452 10.9348 15.5353L9.57343 14.174C7.19581 14.2849 5.28478 13.6877 3.65448 12.0831C2.88678 11.3274 2.42739 10.3562 2.12225 9.36633C1.81784 8.37867 1.65505 7.32823 1.51093 6.39661C1.32929 5.2225 1.06749 3.90523 0.850769 2.87805C0.710614 2.21378 0.56115 1.55113 0.408386 0.889771C0.342017 0.601058 0.601117 0.34194 0.889832 0.408325ZM7.1496 6.35266C7.77157 6.05022 8.54221 6.15652 9.05878 6.67297C9.71018 7.32455 9.71021 8.38079 9.05878 9.03235C8.40723 9.6839 7.35102 9.68379 6.6994 9.03235C6.18548 8.51832 6.07823 7.7533 6.37518 7.13293L1.93964 2.71301C2.1554 3.73878 2.41539 5.04925 2.59784 6.22864C2.74397 7.17322 2.8961 8.14467 3.17303 9.04309C3.44934 9.93927 3.83806 10.7193 4.42694 11.299C5.85147 12.701 7.63567 13.1258 9.54999 13.0695C9.83791 13.0611 10.1196 13.1647 10.3234 13.3683L11.6769 14.7218L14.7219 11.6769L13.3683 10.3234C13.1648 10.1196 13.0611 9.83786 13.0695 9.54993C13.1258 7.63562 12.701 5.8514 11.299 4.42688C10.7194 3.838 9.93933 3.44928 9.04315 3.17297C8.14473 2.89604 7.17327 2.74391 6.2287 2.59778C5.05377 2.41601 3.74882 2.15681 2.72479 1.94153L7.1496 6.35266Z" fill="black"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 3.3 KiB |
6
frontend/resources/images/cursors/draw-node.svg
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 16 16">
|
||||||
|
<g transform="scale(0.76190476)">
|
||||||
|
<path d="M16.9996 13.7997C18.7669 13.7997 20.1997 15.2326 20.1998 16.9999C20.1998 18.7672 18.7669 20.2001 16.9996 20.2001C15.2325 20.1999 13.7994 18.7671 13.7994 16.9999C13.7995 15.2328 15.2326 13.7999 16.9996 13.7997ZM0.88147 -0.0987549L1.00256 -0.0782471L1.99182 0.146362C2.3212 0.21982 2.65134 0.291628 2.98108 0.361206C4.00487 0.577222 5.31171 0.836914 6.47229 1.01648C7.39815 1.15971 8.48521 1.32758 9.51331 1.64441C10.5453 1.96253 11.6032 2.45468 12.4391 3.30359C14.1063 4.99746 14.7467 6.98154 14.6783 9.37097L15.8883 10.5809C16.4936 11.1862 16.4936 12.168 15.8883 12.7733L12.7731 15.8885C12.1678 16.4938 11.186 16.4938 10.5807 15.8885L9.37073 14.6786C6.98134 14.747 4.99719 14.1065 3.30334 12.4393C2.45441 11.6035 1.96229 10.5455 1.64417 9.51355C1.32731 8.48543 1.15947 7.39842 1.01624 6.47253C0.836669 5.31192 0.576975 4.00516 0.360962 2.98132C0.221743 2.3215 0.0738358 1.66228 -0.0784912 1.00281C-0.218776 0.394559 0.283552 -0.155568 0.88147 -0.0987549Z" fill="white"/>
|
||||||
|
<path d="M17.0002 14.2999C18.4913 14.3 19.7004 15.509 19.7004 17.0001C19.7003 18.4911 18.4912 19.7002 17.0002 19.7003C15.5091 19.7003 14.3001 18.4912 14.3 17.0001C14.3 15.5089 15.509 14.2999 17.0002 14.2999ZM17.0002 15.7003C16.2822 15.7003 15.7004 16.2821 15.7004 17.0001C15.7005 17.718 16.2823 18.2999 17.0002 18.2999C17.718 18.2998 18.2999 17.7179 18.3 17.0001C18.3 16.2822 17.7181 15.7004 17.0002 15.7003ZM0.889821 0.408319C1.55118 0.561081 2.21383 0.710539 2.8781 0.850702C3.90527 1.06743 5.22257 1.32922 6.39666 1.51086C7.32827 1.65498 8.37874 1.81777 9.36638 2.12219C10.3562 2.42732 11.3274 2.88673 12.0832 3.65441C13.6878 5.2847 14.2849 7.19577 14.174 9.57336L15.5353 10.9347C15.9452 11.3447 15.9453 12.01 15.5353 12.42L12.4201 15.5353C12.0101 15.9453 11.3448 15.9452 10.9347 15.5353L9.57342 14.1739C7.1958 14.2849 5.28477 13.6877 3.65447 12.0831C2.88677 11.3274 2.42738 10.3562 2.12224 9.36633C1.81783 8.37867 1.65504 7.32822 1.51092 6.3966C1.32928 5.2225 1.06748 3.90522 0.850759 2.87805C0.710604 2.21377 0.56114 1.55112 0.408376 0.889764C0.342007 0.601052 0.601107 0.341934 0.889821 0.408319ZM7.14959 6.35266C7.77156 6.05021 8.5422 6.15651 9.05877 6.67297C9.71017 7.32454 9.7102 8.38079 9.05877 9.03234C8.40722 9.68389 7.35101 9.68378 6.69939 9.03234C6.18547 8.51831 6.07822 7.75329 6.37517 7.13293L1.93963 2.71301C2.15539 3.73878 2.41538 5.04924 2.59783 6.22863C2.74396 7.17321 2.89609 8.14466 3.17302 9.04308C3.44933 9.93926 3.83805 10.7193 4.42693 11.2989C5.85146 12.701 7.63566 13.1258 9.54998 13.0695C9.8379 13.0611 10.1196 13.1647 10.3234 13.3683L11.6769 14.7218L14.7219 11.6769L13.3683 10.3234C13.1648 10.1196 13.0611 9.83785 13.0695 9.54992C13.1258 7.63561 12.701 5.8514 11.299 4.42687C10.7194 3.838 9.93932 3.44927 9.04314 3.17297C8.14472 2.89603 7.17326 2.7439 6.22869 2.59777C5.05376 2.41601 3.74881 2.1568 2.72478 1.94152L7.14959 6.35266Z" fill="black"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.9 KiB |
6
frontend/resources/images/cursors/draw-remove.svg
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 16 16">
|
||||||
|
<g transform="scale(0.76190476)">
|
||||||
|
<path d="M19.5992 15.8075C20.0962 15.8075 20.4995 16.211 20.4996 16.7079V17.2919C20.4996 17.7889 20.0963 18.1923 19.5992 18.1923H14.4C13.9031 18.1921 13.4996 17.7888 13.4996 17.2919V16.7079C13.4998 16.2111 13.9032 15.8077 14.4 15.8075H19.5992ZM0.88147 -0.0987549L1.00256 -0.0782471L1.99182 0.146362C2.3212 0.21982 2.65134 0.291628 2.98108 0.361206C4.00487 0.577222 5.31171 0.836914 6.47229 1.01648C7.39815 1.15971 8.48521 1.32758 9.51331 1.64441C10.5453 1.96253 11.6032 2.45468 12.4391 3.30359C14.1063 4.99746 14.7467 6.98154 14.6783 9.37097L15.8883 10.5809C16.4936 11.1862 16.4936 12.168 15.8883 12.7733L12.7731 15.8885C12.1678 16.4938 11.186 16.4938 10.5807 15.8885L9.37073 14.6786C6.98134 14.747 4.99719 14.1065 3.30334 12.4393C2.45441 11.6035 1.96229 10.5455 1.64417 9.51355C1.32731 8.48543 1.15947 7.39842 1.01624 6.47253C0.836669 5.31192 0.576975 4.00516 0.360962 2.98132C0.221743 2.3215 0.0738358 1.66228 -0.0784912 1.00281C-0.218776 0.394559 0.283552 -0.155568 0.88147 -0.0987549Z" fill="white"/>
|
||||||
|
<path d="M19.5998 16.3077C19.8207 16.3077 20.0002 16.4872 20.0002 16.7081V17.2921C20.0002 17.513 19.8207 17.6925 19.5998 17.6925H14.3996C14.1789 17.6923 14.0002 17.5129 14.0002 17.2921V16.7081C14.0002 16.4874 14.1789 16.308 14.3996 16.3077H19.5998ZM0.889832 0.408325C1.55119 0.561087 2.21384 0.710545 2.87811 0.850708C3.90528 1.06744 5.22258 1.32923 6.39667 1.51086C7.32828 1.65499 8.37875 1.81778 9.36639 2.12219C10.3562 2.42732 11.3274 2.88674 12.0832 3.65442C13.6878 5.28471 14.2849 7.19577 14.174 9.57336L15.5353 10.9347C15.9452 11.3448 15.9453 12.01 15.5353 12.42L12.4201 15.5353C12.0101 15.9453 11.3448 15.9452 10.9348 15.5353L9.57343 14.174C7.19581 14.2849 5.28478 13.6877 3.65448 12.0831C2.88678 11.3274 2.42739 10.3562 2.12225 9.36633C1.81784 8.37867 1.65505 7.32823 1.51093 6.39661C1.32929 5.2225 1.06749 3.90523 0.850769 2.87805C0.710614 2.21378 0.56115 1.55113 0.408386 0.889771C0.342017 0.601058 0.601117 0.34194 0.889832 0.408325ZM7.1496 6.35266C7.77157 6.05022 8.54221 6.15652 9.05878 6.67297C9.71018 7.32455 9.71021 8.38079 9.05878 9.03235C8.40723 9.6839 7.35102 9.68379 6.6994 9.03235C6.18548 8.51832 6.07823 7.7533 6.37518 7.13293L1.93964 2.71301C2.1554 3.73878 2.41539 5.04925 2.59784 6.22864C2.74397 7.17322 2.8961 8.14467 3.17303 9.04309C3.44934 9.93927 3.83806 10.7193 4.42694 11.299C5.85147 12.701 7.63567 13.1258 9.54999 13.0695C9.83791 13.0611 10.1196 13.1647 10.3234 13.3683L11.6769 14.7218L14.7219 11.6769L13.3683 10.3234C13.1648 10.1196 13.0611 9.83786 13.0695 9.54993C13.1258 7.63562 12.701 5.8514 11.299 4.42688C10.7194 3.838 9.93933 3.44928 9.04315 3.17297C8.14473 2.89604 7.17327 2.74391 6.2287 2.59778C5.05377 2.41601 3.74882 2.15681 2.72479 1.94153L7.1496 6.35266Z" fill="black"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.8 KiB |
6
frontend/resources/images/cursors/draw.svg
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 16 16">
|
||||||
|
<g transform="scale(0.94117647)">
|
||||||
|
<path d="M0.88185 -0.0985746L1.00294 -0.0780668L1.9922 0.146543C2.32172 0.22003 2.65158 0.291781 2.98146 0.361386C4.00537 0.577427 5.31198 0.837091 6.47267 1.01666C7.39865 1.15991 8.48547 1.32767 9.51369 1.64459C10.5458 1.96275 11.6036 2.45467 12.4395 3.30377C14.1067 4.9977 14.7472 6.98162 14.6787 9.37115L15.8887 10.5811C16.494 11.1864 16.494 12.1682 15.8887 12.7735L12.7735 15.8887C12.1681 16.494 11.1864 16.494 10.5811 15.8887L9.37111 14.6788C6.98155 14.7472 4.99766 14.1068 3.30372 12.4395C2.4546 11.6036 1.96271 10.5458 1.64455 9.51373C1.32763 8.48551 1.15987 7.3987 1.01662 6.47271C0.837047 5.31203 0.577372 4.00542 0.361342 2.9815C0.222105 2.32158 0.0742392 1.66256 -0.0781112 1.00299C-0.218667 0.394482 0.283656 -0.15568 0.88185 -0.0985746Z" fill="white"/>
|
||||||
|
<path d="M2.87793 0.850614C3.90513 1.06735 5.22231 1.32912 6.39648 1.51077C7.32814 1.6549 8.37849 1.81766 9.36621 2.1221C10.3561 2.42723 11.3272 2.88659 12.083 3.65433C13.6877 5.28465 14.2847 7.1956 14.1738 9.57327L15.5352 10.9346C15.9452 11.3447 15.9452 12.0099 15.5352 12.42L12.4199 15.5352C12.0099 15.9452 11.3446 15.9452 10.9346 15.5352L9.57324 14.1739C7.19554 14.2848 5.28463 13.6878 3.65429 12.083C2.88654 11.3272 2.4272 10.3561 2.12207 9.36624C1.81763 8.37852 1.65487 7.32817 1.51074 6.39651C1.32909 5.22234 1.06731 3.90516 0.850582 2.87796C0.710427 2.21368 0.5614 1.55158 0.408635 0.890223C0.341875 0.601198 0.601167 0.341906 0.890192 0.408666C1.55155 0.561427 2.21365 0.710451 2.87793 0.850614ZM7.14941 6.35257C7.7714 6.05007 8.542 6.1564 9.05859 6.67288C9.71016 7.32445 9.71009 8.38064 9.05859 9.03226C8.40699 9.68386 7.35082 9.68386 6.69922 9.03226C6.18528 8.51821 6.07801 7.75322 6.375 7.13284L1.93945 2.71292C2.15522 3.73871 2.41519 5.04909 2.59765 6.22854C2.74379 7.17318 2.8959 8.14452 3.17285 9.043C3.44916 9.93925 3.83784 10.7192 4.42675 11.2989C5.85131 12.701 7.63521 13.1262 9.5496 13.0698C9.83784 13.0613 10.1199 13.1648 10.3238 13.3687L11.6768 14.7217L14.7217 11.6768L13.3687 10.3238C13.1648 10.1199 13.0613 9.83787 13.0698 9.54963C13.1261 7.63525 12.701 5.85134 11.2988 4.42679C10.7192 3.83787 9.93923 3.44919 9.04296 3.17288C8.14449 2.89593 7.17315 2.74382 6.22851 2.59768C5.0535 2.41591 3.74866 2.15672 2.72461 1.94143L7.14941 6.35257Z" fill="black"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.3 KiB |
8
frontend/resources/images/cursors/move-add.svg
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 16 16">
|
||||||
|
<g transform="scale(0.76190476)">
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M15.2917 11.4996C15.7886 11.4996 16.1919 11.9031 16.1921 12.4V13.8072H17.5993C18.0962 13.8072 18.4995 14.2107 18.4997 14.7076V15.2916C18.4997 15.7886 18.0964 16.1919 17.5993 16.1919H16.1921V17.5992C16.1921 18.0962 15.7888 18.4996 15.2917 18.4996H14.7077C14.2108 18.4994 13.8073 18.0961 13.8073 17.5992V16.1919H12.4001C11.9032 16.1918 11.4997 15.7885 11.4997 15.2916V14.7076C11.5 14.2108 11.9033 13.8073 12.4001 13.8072H13.8073V12.4C13.8076 11.9032 14.2109 11.4997 14.7077 11.4996H15.2917Z" fill="white"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M-0.0266525 1.14995C-0.31627 0.454553 0.319614 -0.242906 1.01143 -0.0727049L1.15011 -0.0268064L17.2966 6.70073C18.0785 7.02731 18.0168 8.1558 17.2038 8.39507L10.3933 10.3931L8.39522 17.2037C8.15608 18.0168 7.02745 18.0786 6.70089 17.2964L-0.0266525 1.14995Z" fill="white"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M0.434899 0.957365C0.297544 0.627676 0.627671 0.297533 0.95736 0.434904L17.1038 7.16244C17.4513 7.30772 17.4234 7.80926 17.0619 7.91537L9.99056 9.99057L7.91537 17.0619C7.80926 17.4234 7.3077 17.4514 7.16244 17.1038L0.434899 0.957365ZM7.41634 14.8538L8.94955 9.62826C9.04541 9.30163 9.30162 9.04541 9.62826 8.94955L14.8538 7.41635L2.10384 2.10385L7.41634 14.8538Z" fill="black"/>
|
||||||
|
<path d="M15.2923 12.0004C15.5131 12.0004 15.6926 12.179 15.6927 12.3998V14.308H17.5999C17.8208 14.308 18.0002 14.4866 18.0003 14.7074V15.2924C18.0003 15.5133 17.8209 15.6927 17.5999 15.6927H15.6927V17.6C15.6927 17.8209 15.5132 18.0004 15.2923 18.0004H14.7074C14.4866 18.0002 14.308 17.8208 14.308 17.6V15.6927H12.3997C12.179 15.6926 12.0003 15.5132 12.0003 15.2924V14.7074C12.0005 14.4867 12.1791 14.3081 12.3997 14.308H14.308V12.3998C14.3081 12.1791 14.4867 12.0005 14.7074 12.0004H15.2923Z" fill="black"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.9 KiB |
6
frontend/resources/images/cursors/move-copy.svg
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 16 16">
|
||||||
|
<g transform="scale(0.76190476)">
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M2.97335 1.14995C2.68373 0.454553 3.31961 -0.242906 4.01143 -0.0727049L4.15011 -0.0268064L20.2966 6.70073C21.0785 7.02732 21.0168 8.15579 20.2038 8.39507L17.5954 9.15972C17.4941 9.26352 17.3646 9.34774 17.2038 9.39507L13.3542 10.524L11.3952 17.2037C11.1561 18.0168 10.0275 18.0786 9.70089 17.2964L9.09054 15.8316L8.39522 18.2037C8.15608 19.0168 7.02745 19.0786 6.70089 18.2964L-0.0266525 2.14995C-0.31627 1.45455 0.319614 0.757094 1.01143 0.927295L1.15011 0.973194L3.26632 1.85503L2.97335 1.14995Z" fill="white"/>
|
||||||
|
<path d="M0.434906 1.95752C0.297569 1.62792 0.627771 1.29704 0.957367 1.43408L17.1039 8.1626C17.4513 8.30789 17.4234 8.80942 17.0619 8.91553L9.99057 10.9907L7.91537 18.062C7.80921 18.4238 7.30745 18.451 7.16245 18.103L0.434906 1.95752ZM10.9154 17.062C10.8092 17.4235 10.3078 17.4514 10.1624 17.104L9.31381 15.0688L9.806 13.3901L10.4164 14.854L11.5296 11.0591L12.7845 10.6909L10.9154 17.062ZM7.41635 15.854L8.94955 10.6284C9.04541 10.3018 9.30163 10.0456 9.62827 9.94971L14.8539 8.4165L2.10385 3.104L7.41635 15.854ZM3.43491 0.95752C3.29755 0.627831 3.62768 0.297688 3.95737 0.435059L20.1039 7.1626C20.4513 7.3079 20.4234 7.80942 20.0619 7.91553L17.847 8.56494C17.8603 8.21699 17.6817 7.86151 17.2962 7.70068L17.1263 7.62939L17.8539 7.4165L5.10385 2.104L5.36362 2.729L3.92221 2.12842L3.43491 0.95752Z" fill="black"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
7
frontend/resources/images/cursors/move-curve.svg
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 16 16">
|
||||||
|
<g transform="scale(0.76190476)">
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M17.072 10.9283C17.6585 10.3534 18.5993 10.358 19.1814 10.939C19.7668 11.5248 19.7679 12.4744 19.1823 13.0601C18.6218 13.6206 17.7286 13.6436 17.1394 13.1314C17.0389 13.1452 16.9321 13.1632 16.821 13.1861C16.0895 13.3369 15.1513 13.6976 14.3034 14.5455C13.4556 15.3934 13.0949 16.3315 12.9441 17.063C12.9211 17.1743 12.9033 17.2812 12.8894 17.3814C13.4016 17.9707 13.3786 18.8638 12.8181 19.4244C12.2324 20.0099 11.2827 20.0098 10.697 19.4244C10.1162 18.8425 10.1119 17.9015 10.6862 17.315C10.7082 17.1044 10.738 16.8696 10.7898 16.6187C11.0062 15.5692 11.5302 14.2094 12.7487 12.9908C13.9673 11.7722 15.3271 11.2482 16.3767 11.0318C16.6275 10.9801 16.862 10.9501 17.072 10.9283Z" fill="white"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M-0.0266525 1.14995C-0.31627 0.454553 0.319614 -0.242906 1.01143 -0.0727049L1.15011 -0.0268064L17.2966 6.70073C18.0785 7.02732 18.0168 8.15579 17.2038 8.39507L10.3933 10.3931L8.39522 17.2037C8.15608 18.0168 7.02745 18.0786 6.70089 17.2964L-0.0266525 1.14995Z" fill="white"/>
|
||||||
|
<path d="M17.4141 11.292C17.8043 10.902 18.4376 10.9026 18.8282 11.292C19.2187 11.6826 19.2187 12.3166 18.8282 12.7071C18.4376 13.0972 17.8045 13.0974 17.4141 12.7071C17.3827 12.6756 17.3536 12.6419 17.3272 12.6075C17.1485 12.6238 16.9436 12.6504 16.7207 12.6963C15.9168 12.8621 14.8825 13.2602 13.9502 14.1924C13.0183 15.1245 12.6199 16.1581 12.4541 16.962C12.4081 17.1853 12.3806 17.3914 12.3643 17.5704C12.3988 17.5968 12.4333 17.6248 12.4649 17.6563C12.8552 18.0467 12.8551 18.6798 12.4649 19.0704C12.0744 19.4609 11.4414 19.4608 11.0508 19.0704C10.661 18.6798 10.6605 18.0466 11.0508 17.6563C11.0859 17.6213 11.1236 17.5903 11.1622 17.5616C11.1806 17.3185 11.2148 17.0328 11.2793 16.7198C11.4809 15.7425 11.9673 14.4781 13.1016 13.3438C14.2359 12.2095 15.5003 11.7231 16.4776 11.5215C16.7903 11.4571 17.0755 11.4237 17.3184 11.4053C17.3474 11.3663 17.3787 11.3275 17.4141 11.292ZM0.434617 0.957086C0.297691 0.627629 0.62762 0.297673 0.957078 0.434626L17.1036 7.16216C17.4511 7.30744 17.4232 7.80898 17.0616 7.91509L9.99028 9.99029L7.91509 17.0616C7.80897 17.4232 7.30742 17.4511 7.16216 17.1036L0.434617 0.957086ZM7.41606 14.8536L8.94927 9.62798C9.04515 9.3015 9.30149 9.04515 9.62798 8.94927L14.8536 7.41607L2.10356 2.10357L7.41606 14.8536Z" fill="black"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.4 KiB |
8
frontend/resources/images/cursors/move-handles.svg
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 16 16">
|
||||||
|
<g transform="scale(0.76190476)">
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M17.6599 10.5835C18.2457 9.9979 19.1952 9.9979 19.781 10.5835C20.3662 11.1693 20.3664 12.119 19.781 12.7046C19.5027 12.9829 19.1422 13.124 18.78 13.1382L17.5485 14.3697C17.8471 15.2433 17.6505 16.2501 16.9528 16.9478C16.2552 17.6455 15.2483 17.842 14.3747 17.5435L13.1433 18.775C13.1291 19.1372 12.988 19.4976 12.7097 19.7759C12.1241 20.3614 11.1744 20.3612 10.5886 19.7759C10.0029 19.1902 10.0029 18.2406 10.5886 17.6548C10.8668 17.3766 11.2265 17.2336 11.5886 17.2193L12.82 15.9878C12.5222 15.1145 12.7194 14.109 13.4167 13.4117C14.1141 12.7144 15.1196 12.5171 15.9929 12.815L17.2243 11.5835C17.2386 11.2215 17.3816 10.8618 17.6599 10.5835Z" fill="white"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M-0.0266525 1.14995C-0.31627 0.454553 0.319614 -0.242906 1.01143 -0.0727049L1.15011 -0.0268064L17.2966 6.70073C18.0785 7.02732 18.0168 8.15579 17.2038 8.39507L10.3933 10.3931L8.39522 17.2037C8.15608 18.0168 7.02745 18.0786 6.70089 17.2964L-0.0266525 1.14995Z" fill="white"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M18.013 10.9369C18.4035 10.5464 19.0366 10.5464 19.4271 10.9369C19.8173 11.3274 19.8175 11.9605 19.4271 12.3509C19.1958 12.5822 18.8791 12.6748 18.5785 12.6322L16.9574 14.2542C17.351 15.0082 17.2312 15.9599 16.598 16.5931C15.9647 17.2263 15.0131 17.3461 14.2591 16.9525L12.637 18.5736C12.6797 18.8742 12.587 19.191 12.3558 19.4222C11.9654 19.8125 11.3322 19.8124 10.9417 19.4222C10.5513 19.0317 10.5512 18.3977 10.9417 18.0072C11.1729 17.7762 11.489 17.6834 11.7894 17.7259L13.4105 16.1048C13.0168 15.3508 13.1366 14.3983 13.7699 13.765C14.4032 13.1317 15.3557 13.0119 16.1097 13.4056L17.7308 11.7845C17.6882 11.4839 17.7818 11.1681 18.013 10.9369ZM15.7494 14.6136C15.4369 14.3014 14.9308 14.3013 14.6185 14.6136C14.3062 14.926 14.3063 15.4321 14.6185 15.7445C14.9309 16.0569 15.4369 16.0568 15.7494 15.7445C16.0618 15.4321 16.0618 14.926 15.7494 14.6136Z" fill="black"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M0.434899 0.957365C0.297544 0.627676 0.627671 0.297533 0.95736 0.434904L17.1038 7.16244C17.4513 7.30772 17.4234 7.80926 17.0619 7.91537L9.99056 9.99057L7.91537 17.0619C7.80926 17.4234 7.3077 17.4514 7.16244 17.1038L0.434899 0.957365ZM7.41634 14.8538L8.94955 9.62826C9.04541 9.30163 9.30162 9.04541 9.62826 8.94955L14.8538 7.41635L2.10384 2.10385L7.41634 14.8538Z" fill="black"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.5 KiB |
8
frontend/resources/images/cursors/move-move.svg
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 16 16">
|
||||||
|
<g transform="scale(0.76190476)">
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M16.4148 10.9996C16.8049 11.3899 16.8046 12.0231 16.4148 12.4136C16.2192 12.6091 15.962 12.7069 15.7058 12.7066L15.7067 13.7076H16.7067C16.7067 13.4517 16.8046 13.1948 16.9997 12.9996C17.3902 12.6094 18.0234 12.6095 18.4138 12.9996L20.1208 14.7066L18.4138 16.4136C18.0234 16.8037 17.3902 16.8047 16.9997 16.4146C16.8047 16.2194 16.7067 15.9624 16.7067 15.7066H15.7067V16.7066C15.9626 16.7065 16.2185 16.8045 16.4138 16.9996L16.4148 17.0005C16.8046 17.3911 16.8039 18.0233 16.4138 18.4136L14.7067 20.1207L12.9997 18.4136C12.6096 18.0231 12.6094 17.39 12.9997 16.9996C13.1949 16.8044 13.451 16.7066 13.7067 16.7066V15.7066H12.7067C12.7068 15.9625 12.609 16.2184 12.4138 16.4136C12.0233 16.804 11.3903 16.8042 10.9997 16.4136L9.29268 14.7066L10.9997 12.9996C11.3901 12.6093 12.0233 12.6096 12.4138 12.9996C12.609 13.1948 12.7068 13.4516 12.7067 13.7076H13.7067V12.7076C13.451 12.7075 13.1949 12.6097 12.9997 12.4146C12.6094 12.0242 12.6097 11.3911 12.9997 11.0005L14.7077 9.29253L16.4148 10.9996Z" fill="white"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M-0.0266525 1.14995C-0.31627 0.454553 0.319614 -0.242906 1.01143 -0.0727049L1.15011 -0.0268064L17.2966 6.70073C18.0785 7.02731 18.0168 8.1558 17.2038 8.39507L10.3933 10.3931L8.39522 17.2037C8.15608 18.0168 7.02745 18.0786 6.70089 17.2964L-0.0266525 1.14995Z" fill="white"/>
|
||||||
|
<path d="M16.061 11.354C16.2558 11.5493 16.2561 11.8659 16.061 12.061C15.8659 12.2561 15.5493 12.2558 15.354 12.061L15.2075 11.9145V14.2075H17.5005L17.354 14.061C17.1588 13.8657 17.1588 13.5492 17.354 13.354C17.5492 13.1587 17.8657 13.1587 18.061 13.354L19.4145 14.7075L18.061 16.061C17.8659 16.2561 17.5493 16.2558 17.354 16.061C17.1588 15.8657 17.1588 15.5492 17.354 15.354L17.5005 15.2075H15.2075V17.5005L15.354 17.354C15.5492 17.1587 15.8657 17.1587 16.061 17.354C16.2558 17.5493 16.2561 17.8659 16.061 18.061L14.7075 19.4145L13.354 18.061C13.1588 17.8657 13.1588 17.5492 13.354 17.354C13.5492 17.1587 13.8657 17.1587 14.061 17.354L14.2075 17.5005V15.2075H11.9145L12.061 15.354C12.2558 15.5493 12.2561 15.8659 12.061 16.061C11.8659 16.2561 11.5493 16.2558 11.354 16.061L10.0005 14.7075L11.354 13.354C11.5492 13.1587 11.8657 13.1587 12.061 13.354C12.2558 13.5493 12.2561 13.8659 12.061 14.061L11.9145 14.2075H14.2075V11.9145L14.061 12.061C13.8659 12.2561 13.5493 12.2558 13.354 12.061C13.1588 11.8657 13.1588 11.5492 13.354 11.354L14.7075 10.0005L16.061 11.354Z" fill="black"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M0.435036 0.957487C0.297681 0.627798 0.627808 0.297655 0.957497 0.435026L17.104 7.16257C17.4513 7.30791 17.4234 7.80934 17.062 7.91549L9.9907 9.99069L7.9155 17.062C7.80936 17.4234 7.30791 17.4513 7.16258 17.104L0.435036 0.957487ZM7.41648 14.854L8.94968 9.62839C9.04554 9.30175 9.30176 9.04553 9.6284 8.94967L14.854 7.41647L2.10398 2.10397L7.41648 14.854Z" fill="black"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 3.0 KiB |
7
frontend/resources/images/cursors/move-node.svg
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 16 16">
|
||||||
|
<g transform="scale(0.76190476)">
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M14.9997 11.7994C16.7669 11.7994 18.1997 13.2325 18.1999 14.9996C18.1999 16.7669 16.767 18.1998 14.9997 18.1998C13.2325 18.1996 11.7995 16.7668 11.7995 14.9996C11.7998 13.2326 13.2327 11.7995 14.9997 11.7994Z" fill="white"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M-0.0266525 1.14995C-0.31627 0.454553 0.319614 -0.242906 1.01143 -0.0727049L1.15011 -0.0268064L17.2966 6.70073C18.0785 7.02732 18.0168 8.15579 17.2038 8.39507L10.3933 10.3931L8.39522 17.2037C8.15608 18.0168 7.02745 18.0786 6.70089 17.2964L-0.0266525 1.14995Z" fill="white"/>
|
||||||
|
<path d="M15.0003 12.3002C16.4913 12.3004 17.7005 13.5093 17.7005 15.0004C17.7003 16.4913 16.4912 17.7004 15.0003 17.7006C13.5093 17.7006 12.3003 16.4914 12.3001 15.0004C12.3001 13.5092 13.5092 12.3002 15.0003 12.3002ZM0.434906 0.957397C0.297551 0.627708 0.627678 0.297565 0.957367 0.434937L17.1039 7.16248C17.4513 7.30775 17.4234 7.80929 17.0619 7.91541L9.99057 9.9906L7.91537 17.0619C7.80926 17.4235 7.30771 17.4514 7.16245 17.1039L0.434906 0.957397ZM15.0003 13.7006C14.2824 13.7006 13.7005 14.2824 13.7005 15.0004C13.7007 15.7182 14.2825 16.3002 15.0003 16.3002C15.718 16.3 16.3 15.7181 16.3001 15.0004C16.3001 14.2825 15.7181 13.7007 15.0003 13.7006ZM7.41635 14.8539L8.94955 9.6283C9.04541 9.30166 9.30163 9.04544 9.62827 8.94958L14.8539 7.41638L2.10385 2.10388L7.41635 14.8539Z" fill="black"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.5 KiB |
7
frontend/resources/images/cursors/move-remove.svg
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 16 16">
|
||||||
|
<g transform="scale(0.76190476)">
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M-0.0266525 1.14995C-0.31627 0.454553 0.319614 -0.242906 1.01143 -0.0727049L1.15011 -0.0268064L17.2966 6.70073C18.0785 7.02731 18.0168 8.1558 17.2038 8.39507L10.3933 10.3931L8.39522 17.2037C8.15608 18.0168 7.02745 18.0786 6.70089 17.2964L-0.0266525 1.14995Z" fill="white"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M17.5993 13.8072C18.0962 13.8072 18.4995 14.2107 18.4997 14.7076V15.2916C18.4997 15.7886 18.0964 16.1919 17.5993 16.1919H12.4001C11.9032 16.1918 11.4997 15.7885 11.4997 15.2916V14.7076C11.5 14.2108 11.9033 13.8073 12.4001 13.8072H17.5993Z" fill="white"/>
|
||||||
|
<path d="M0.435028 0.95752C0.297673 0.627831 0.6278 0.297688 0.957489 0.435059L17.104 7.1626C17.4513 7.30795 17.4234 7.80937 17.062 7.91553L9.99069 9.99072L7.9155 17.062C7.80935 17.4235 7.30791 17.4513 7.16257 17.104L0.435028 0.95752ZM17.6001 14.3081C17.8209 14.3081 18.0004 14.4867 18.0005 14.7075V15.2925C18.0003 15.5133 17.8209 15.6919 17.6001 15.6919H12.3999C12.1791 15.6918 12.0006 15.5133 12.0005 15.2925V14.7075C12.0005 14.4867 12.1791 14.3082 12.3999 14.3081H17.6001ZM7.41647 14.854L8.94968 9.62842C9.04554 9.30178 9.30175 9.04557 9.62839 8.94971L14.854 7.4165L2.10397 2.104L7.41647 14.854Z" fill="black"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.3 KiB |
6
frontend/resources/images/cursors/move.svg
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 16 16">
|
||||||
|
<g transform="scale(0.76190476)">
|
||||||
|
<path d="M-0.0270493 1.14959C-0.316667 0.454186 0.319218 -0.243272 1.01104 -0.0730711L1.14971 -0.0271726L17.2962 6.70037C18.0787 7.02675 18.0168 8.15559 17.2034 8.3947L10.3929 10.3927L8.39483 17.2033C8.15571 18.0167 7.02688 18.0786 6.70049 17.2961L-0.0270493 1.14959Z" fill="white"/>
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M17.1034 7.1621C17.4514 7.3071 17.4239 7.80898 17.0622 7.91514L9.99053 9.99041L7.91526 17.0621C7.8091 17.4238 7.30723 17.4513 7.16222 17.1033L0.434499 0.957459C0.297119 0.627761 0.627881 0.296997 0.957579 0.434377L17.1034 7.1621ZM7.41631 14.8537L8.95 9.6278C9.04586 9.30117 9.30129 9.04573 9.62792 8.94987L14.8538 7.41619L2.10381 2.10369L7.41631 14.8537Z" fill="black"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 835 B |
3
frontend/resources/images/icons/handlers-equal.svg
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M3.005,13L6.588,9.414M9.413,6.586L12.995,3M2.152,13.854C1.957,13.658 1.957,13.342 2.152,13.147C2.348,12.951 2.664,12.951 2.859,13.147C3.054,13.342 3.054,13.658 2.859,13.854C2.664,14.049 2.347,14.049 2.152,13.854M13.141,2.854C12.946,2.658 12.946,2.342 13.141,2.146C13.336,1.951 13.653,1.951 13.848,2.146C14.043,2.342 14.043,2.658 13.848,2.854C13.653,3.049 13.336,3.049 13.141,2.854M6.587,9.414C5.807,8.633 5.807,7.367 6.587,6.586C7.368,5.805 8.633,5.805 9.413,6.586C9.413,6.586 9.413,6.586 9.413,6.586C10.193,7.367 10.193,8.633 9.413,9.414C8.633,10.195 7.368,10.195 6.588,9.414C6.588,9.414 6.587,9.414 6.587,9.414"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 744 B |
3
frontend/resources/images/icons/handlers-independent.svg
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M4.573,14L4.629,13.503L4.853,10.488M6.488,7.086L12.073,2.5M4.22,14.354C4.415,14.549 4.732,14.549 4.927,14.354C5.122,14.158 5.122,13.842 4.927,13.647C4.843,13.563 4.738,13.515 4.629,13.503C4.483,13.487 4.332,13.535 4.22,13.647C4.025,13.842 4.025,14.158 4.22,14.354M12.219,2.353C12.415,2.549 12.731,2.549 12.927,2.353C13.122,2.158 13.122,1.842 12.927,1.646C12.731,1.451 12.415,1.451 12.219,1.646C12.024,1.842 12.024,2.158 12.219,2.353M3.659,9.914C3.993,10.248 4.417,10.44 4.853,10.488C5.435,10.552 6.041,10.361 6.488,9.914C7.268,9.133 7.268,7.867 6.488,7.086C6.488,7.086 6.488,7.086 6.488,7.086C5.707,6.305 4.44,6.305 3.659,7.086C2.878,7.867 2.878,9.133 3.659,9.914"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 795 B |
3
frontend/resources/images/icons/handlers-mirror.svg
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M3.005,13L4.589,11.414M7.415,8.586L12.995,3M2.152,13.854C1.957,13.658 1.957,13.342 2.152,13.147C2.347,12.951 2.664,12.951 2.859,13.147C3.054,13.342 3.054,13.658 2.859,13.854C2.664,14.049 2.347,14.049 2.152,13.854M13.141,2.854C12.946,2.658 12.946,2.342 13.141,2.146C13.336,1.951 13.653,1.951 13.848,2.146C14.043,2.342 14.043,2.658 13.848,2.854C13.653,3.049 13.336,3.049 13.141,2.854M4.589,11.414C3.809,10.633 3.809,9.367 4.589,8.586C5.37,7.805 6.635,7.805 7.415,8.586C7.415,8.586 7.415,8.586 7.415,8.586C8.195,9.367 8.195,10.633 7.415,11.414C6.635,12.195 5.37,12.195 4.589,11.414C4.589,11.414 4.589,11.414 4.589,11.414"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 749 B |
3
frontend/resources/images/icons/snap.svg
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M7.893,10.058L10.493,12.658L10.493,12.659M1.62,7.229L5.758,3.091C7.035,1.813 8.897,1.314 10.642,1.782C12.387,2.249 13.75,3.612 14.218,5.357C14.686,7.102 14.187,8.963 12.91,10.241L10.493,12.658L8.772,14.38C8.466,14.686 7.97,14.686 7.664,14.38L6.171,12.887C5.865,12.581 5.865,12.086 6.171,11.78L7.893,10.058L10.091,7.86C10.629,7.322 10.629,6.449 10.091,5.91C9.552,5.372 8.679,5.372 8.14,5.91L5.942,8.108L4.221,9.829C3.915,10.135 3.419,10.135 3.113,9.829L1.62,8.337C1.314,8.031 1.314,7.535 1.62,7.229M5.942,8.108L5.942,8.108L3.342,5.508"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 665 B |
@ -41,6 +41,7 @@
|
|||||||
// APP COLORS
|
// APP COLORS
|
||||||
--app-white: #fff; // Used in several places
|
--app-white: #fff; // Used in several places
|
||||||
--app-black: #000; // Used on interactions, measurements and editor files
|
--app-black: #000; // Used on interactions, measurements and editor files
|
||||||
|
--app-pink: #f49ef7; // Used in path selection
|
||||||
|
|
||||||
// SOCIAL LOGIN BUTTONS
|
// SOCIAL LOGIN BUTTONS
|
||||||
--google-login-background: #4285f4;
|
--google-login-background: #4285f4;
|
||||||
|
|||||||
@ -1554,6 +1554,7 @@
|
|||||||
(dm/export dwt/update-dimensions)
|
(dm/export dwt/update-dimensions)
|
||||||
(dm/export dwt/change-orientation)
|
(dm/export dwt/change-orientation)
|
||||||
(dm/export dwt/start-rotate)
|
(dm/export dwt/start-rotate)
|
||||||
|
(dm/export dwt/start-move-line-point)
|
||||||
(dm/export dwt/increase-rotation)
|
(dm/export dwt/increase-rotation)
|
||||||
(dm/export dwt/start-move-selected)
|
(dm/export dwt/start-move-selected)
|
||||||
(dm/export dwt/move-selected)
|
(dm/export dwt/move-selected)
|
||||||
|
|||||||
@ -39,6 +39,7 @@
|
|||||||
[app.main.data.notifications :as ntf]
|
[app.main.data.notifications :as ntf]
|
||||||
[app.main.data.persistence :as dps]
|
[app.main.data.persistence :as dps]
|
||||||
[app.main.data.workspace.media :as dwm]
|
[app.main.data.workspace.media :as dwm]
|
||||||
|
[app.main.data.workspace.path.clipboard :as path-cp]
|
||||||
[app.main.data.workspace.selection :as dws]
|
[app.main.data.workspace.selection :as dws]
|
||||||
[app.main.data.workspace.shapes :as dwsh]
|
[app.main.data.workspace.shapes :as dwsh]
|
||||||
[app.main.data.workspace.texts :as dwtxt]
|
[app.main.data.workspace.texts :as dwtxt]
|
||||||
@ -291,8 +292,9 @@
|
|||||||
(rx/mapcat
|
(rx/mapcat
|
||||||
(fn [pdata]
|
(fn [pdata]
|
||||||
(case (:type pdata)
|
(case (:type pdata)
|
||||||
:copied-props (rx/of (paste-transit-props pdata))
|
:copied-props (rx/of (paste-transit-props pdata))
|
||||||
:copied-shapes (rx/of (paste-transit-shapes pdata))
|
:copied-shapes (rx/of (paste-transit-shapes pdata))
|
||||||
|
:copied-path-content (rx/of (path-cp/paste-nodes-as-shape (:content pdata)))
|
||||||
(rx/empty)))))
|
(rx/empty)))))
|
||||||
|
|
||||||
:else
|
:else
|
||||||
|
|||||||
@ -11,6 +11,7 @@
|
|||||||
[app.common.types.modifiers :as ctm]
|
[app.common.types.modifiers :as ctm]
|
||||||
[app.common.types.shape :as cts]
|
[app.common.types.shape :as cts]
|
||||||
[app.main.data.helpers :as dsh]
|
[app.main.data.helpers :as dsh]
|
||||||
|
[app.main.data.workspace.path.state :as path.state]
|
||||||
[app.main.data.workspace.shapes :as dwsh]
|
[app.main.data.workspace.shapes :as dwsh]
|
||||||
[app.main.data.workspace.undo :as dwu]
|
[app.main.data.workspace.undo :as dwu]
|
||||||
[app.main.worker :as mw]
|
[app.main.worker :as mw]
|
||||||
@ -23,9 +24,16 @@
|
|||||||
(ptk/reify ::clear-drawing
|
(ptk/reify ::clear-drawing
|
||||||
ptk/UpdateEvent
|
ptk/UpdateEvent
|
||||||
(update [_ state]
|
(update [_ state]
|
||||||
(if preserve-tool?
|
(let [path-editing? (path.state/editing? state)]
|
||||||
(update state :workspace-drawing dissoc :object :lock)
|
(cond
|
||||||
(dissoc state :workspace-drawing))))))
|
path-editing?
|
||||||
|
(update state :workspace-drawing select-keys [:object])
|
||||||
|
|
||||||
|
preserve-tool?
|
||||||
|
(update state :workspace-drawing dissoc :object :lock)
|
||||||
|
|
||||||
|
:else
|
||||||
|
(dissoc state :workspace-drawing)))))))
|
||||||
|
|
||||||
(defn handle-finish-drawing
|
(defn handle-finish-drawing
|
||||||
[]
|
[]
|
||||||
@ -97,6 +105,6 @@
|
|||||||
(rx/of (dwu/commit-undo-transaction (:id shape))))
|
(rx/of (dwu/commit-undo-transaction (:id shape))))
|
||||||
(rx/empty)))))
|
(rx/empty)))))
|
||||||
|
|
||||||
;; Delay so the mouse event can read the drawing state
|
;; Let the current mouse event finish before clearing drawing state.
|
||||||
(->> (rx/of (clear-drawing {:preserve-tool? (= tool :curve)}))
|
(->> (rx/of (clear-drawing {:preserve-tool? (= tool :curve)}))
|
||||||
(rx/delay 0)))))))
|
(rx/delay 0)))))))
|
||||||
|
|||||||
@ -26,6 +26,9 @@
|
|||||||
|
|
||||||
(def ^:const simplify-tolerance 0.3)
|
(def ^:const simplify-tolerance 0.3)
|
||||||
|
|
||||||
|
;; Maximum curve-fit deviation in board units.
|
||||||
|
(def ^:const smooth-tolerance 1)
|
||||||
|
|
||||||
(defn- setup-frame
|
(defn- setup-frame
|
||||||
[]
|
[]
|
||||||
(ptk/reify ::setup-frame
|
(ptk/reify ::setup-frame
|
||||||
@ -82,7 +85,7 @@
|
|||||||
(update-in state [:workspace-drawing :object]
|
(update-in state [:workspace-drawing :object]
|
||||||
(fn [{:keys [::points] :as shape}]
|
(fn [{:keys [::points] :as shape}]
|
||||||
(let [points (ups/simplify points simplify-tolerance)
|
(let [points (ups/simplify points simplify-tolerance)
|
||||||
content (path/points->content points)
|
content (path/smooth-points->content points smooth-tolerance)
|
||||||
selrect (path/calc-selrect content)
|
selrect (path/calc-selrect content)
|
||||||
points (grc/rect->points selrect)]
|
points (grc/rect->points selrect)]
|
||||||
|
|
||||||
@ -117,4 +120,3 @@
|
|||||||
(setup-frame)
|
(setup-frame)
|
||||||
(finish-drawing)
|
(finish-drawing)
|
||||||
(common/handle-finish-drawing)))))))
|
(common/handle-finish-drawing)))))))
|
||||||
|
|
||||||
|
|||||||
@ -8,6 +8,7 @@
|
|||||||
(:require
|
(:require
|
||||||
[app.main.data.helpers :as dsh]
|
[app.main.data.helpers :as dsh]
|
||||||
[app.main.data.workspace.path.common :as dwpc]
|
[app.main.data.workspace.path.common :as dwpc]
|
||||||
|
[app.main.data.workspace.path.state :as path.state]
|
||||||
[app.main.features :as features]
|
[app.main.features :as features]
|
||||||
[app.render-wasm.api :as wasm.api]
|
[app.render-wasm.api :as wasm.api]
|
||||||
[beicon.v2.core :as rx]
|
[beicon.v2.core :as rx]
|
||||||
@ -48,26 +49,32 @@
|
|||||||
|
|
||||||
(defn clear-edition-mode
|
(defn clear-edition-mode
|
||||||
[]
|
[]
|
||||||
(ptk/reify ::clear-edition-mode
|
(let [path-id (volatile! nil)]
|
||||||
ptk/UpdateEvent
|
(ptk/reify ::clear-edition-mode
|
||||||
(update [_ state]
|
ptk/UpdateEvent
|
||||||
(-> state
|
(update [_ state]
|
||||||
(update :workspace-local dissoc :edition :edit-path)
|
(let [edition-id (get-in state [:workspace-local :edition])
|
||||||
(update :workspace-drawing dissoc :object :lock)
|
path-editing? (path.state/editing? state)]
|
||||||
(dissoc :workspace-grid-edition)
|
(vreset! path-id (when path-editing? edition-id))
|
||||||
(dissoc :workspace-wasm-editor-styles)))
|
(-> state
|
||||||
|
(update :workspace-local dissoc :edition)
|
||||||
|
(cond-> (not path-editing?)
|
||||||
|
(update :workspace-local dissoc :edit-path)
|
||||||
|
|
||||||
ptk/WatchEvent
|
(not path-editing?)
|
||||||
(watch [_ state _]
|
(update :workspace-drawing dissoc :object :lock))
|
||||||
(let [id (get-in state [:workspace-local :edition])]
|
(dissoc :workspace-grid-edition)
|
||||||
(rx/concat
|
(dissoc :workspace-wasm-editor-styles))))
|
||||||
(when (some? id)
|
|
||||||
(dwpc/finish-path)))))
|
|
||||||
|
|
||||||
ptk/EffectEvent
|
ptk/WatchEvent
|
||||||
(effect [_ state _]
|
(watch [_ _ _]
|
||||||
(when (features/active-feature? state "text-editor-wasm/v1")
|
(if (some? @path-id)
|
||||||
;; NOTE: the WASM text editor is disposed by the v3 editor component on
|
(rx/of (dwpc/finish-path))
|
||||||
;; unmount, *after* it finalizes its content.
|
(rx/empty)))
|
||||||
(wasm.api/request-render "clear-edition-mode")))))
|
|
||||||
|
|
||||||
|
ptk/EffectEvent
|
||||||
|
(effect [_ state _]
|
||||||
|
(when (features/active-feature? state "text-editor-wasm/v1")
|
||||||
|
;; NOTE: the WASM text editor is disposed by the v3 editor component on
|
||||||
|
;; unmount, *after* it finalizes its content.
|
||||||
|
(wasm.api/request-render "clear-edition-mode"))))))
|
||||||
|
|||||||
@ -7,6 +7,7 @@
|
|||||||
(ns app.main.data.workspace.path
|
(ns app.main.data.workspace.path
|
||||||
(:require
|
(:require
|
||||||
[app.common.data.macros :as dm]
|
[app.common.data.macros :as dm]
|
||||||
|
[app.main.data.workspace.path.clipboard :as clipboard]
|
||||||
[app.main.data.workspace.path.drawing :as drawing]
|
[app.main.data.workspace.path.drawing :as drawing]
|
||||||
[app.main.data.workspace.path.edition :as edition]
|
[app.main.data.workspace.path.edition :as edition]
|
||||||
[app.main.data.workspace.path.selection :as selection]
|
[app.main.data.workspace.path.selection :as selection]
|
||||||
@ -19,31 +20,59 @@
|
|||||||
(dm/export drawing/close-path-drag-start)
|
(dm/export drawing/close-path-drag-start)
|
||||||
(dm/export drawing/change-edit-mode)
|
(dm/export drawing/change-edit-mode)
|
||||||
(dm/export drawing/reset-last-handler)
|
(dm/export drawing/reset-last-handler)
|
||||||
|
(dm/export drawing/on-draw-node-pointer-down)
|
||||||
|
(dm/export drawing/on-draw-segment-pointer-down)
|
||||||
|
(dm/export drawing/start-move-prev-handler)
|
||||||
|
|
||||||
;; Edition
|
;; Edition
|
||||||
(dm/export edition/start-move-handler)
|
(dm/export edition/start-move-handler)
|
||||||
(dm/export edition/start-move-path-point)
|
(dm/export edition/start-move-path-point)
|
||||||
|
(dm/export edition/start-move-path-segment)
|
||||||
(dm/export edition/start-path-edit)
|
(dm/export edition/start-path-edit)
|
||||||
(dm/export edition/create-node-at-position)
|
(dm/export edition/create-node-at-position)
|
||||||
(dm/export edition/move-selected)
|
(dm/export edition/move-selected)
|
||||||
|
|
||||||
|
;; Clipboard
|
||||||
|
(dm/export clipboard/copy-selected-nodes)
|
||||||
|
(dm/export clipboard/cut-selected-nodes)
|
||||||
|
(dm/export clipboard/paste-nodes)
|
||||||
|
(dm/export clipboard/duplicate-selected)
|
||||||
|
|
||||||
;; Selection
|
;; Selection
|
||||||
(dm/export selection/handle-area-selection)
|
(dm/export selection/handle-area-selection)
|
||||||
(dm/export selection/select-node)
|
(dm/export selection/select-node)
|
||||||
|
(dm/export selection/select-segment)
|
||||||
|
(dm/export selection/select-handler)
|
||||||
(dm/export selection/path-handler-enter)
|
(dm/export selection/path-handler-enter)
|
||||||
(dm/export selection/path-handler-leave)
|
(dm/export selection/path-handler-leave)
|
||||||
|
(dm/export selection/path-segment-enter)
|
||||||
|
(dm/export selection/path-segment-leave)
|
||||||
(dm/export selection/path-pointer-enter)
|
(dm/export selection/path-pointer-enter)
|
||||||
(dm/export selection/path-pointer-leave)
|
(dm/export selection/path-pointer-leave)
|
||||||
|
(dm/export selection/select-all-nodes)
|
||||||
|
(dm/export selection/deselect-all)
|
||||||
|
|
||||||
;; Path tools
|
;; Path tools
|
||||||
(dm/export tools/make-curve)
|
(dm/export tools/make-curve)
|
||||||
(dm/export tools/make-corner)
|
(dm/export tools/make-corner)
|
||||||
(dm/export tools/add-node)
|
(dm/export tools/add-node)
|
||||||
(dm/export tools/remove-node)
|
(dm/export tools/remove-node)
|
||||||
|
(dm/export tools/delete-selected)
|
||||||
|
(dm/export tools/delete-selected-with-segments)
|
||||||
(dm/export tools/merge-nodes)
|
(dm/export tools/merge-nodes)
|
||||||
(dm/export tools/join-nodes)
|
(dm/export tools/join-nodes)
|
||||||
(dm/export tools/separate-nodes)
|
(dm/export tools/separate-nodes)
|
||||||
(dm/export tools/toggle-snap)
|
(dm/export tools/toggle-snap)
|
||||||
|
(dm/export tools/set-handler-type)
|
||||||
|
(dm/export tools/toggle-node-curve)
|
||||||
|
(dm/export tools/toggle-segment-curve)
|
||||||
|
(dm/export tools/remove-segment)
|
||||||
|
(dm/export tools/remove-node-with-segments)
|
||||||
|
(dm/export tools/remove-handler)
|
||||||
|
(dm/export tools/flip-nodes)
|
||||||
|
(dm/export tools/align-nodes)
|
||||||
|
(dm/export tools/distribute-nodes)
|
||||||
|
(dm/export tools/set-selection-coordinate)
|
||||||
|
|
||||||
;; Undo/redo
|
;; Undo/redo
|
||||||
(dm/export undo/undo-path)
|
(dm/export undo/undo-path)
|
||||||
|
|||||||
@ -6,86 +6,57 @@
|
|||||||
|
|
||||||
(ns app.main.data.workspace.path.changes
|
(ns app.main.data.workspace.path.changes
|
||||||
(:require
|
(:require
|
||||||
[app.common.data.macros :as dm]
|
|
||||||
[app.common.files.changes-builder :as pcb]
|
[app.common.files.changes-builder :as pcb]
|
||||||
[app.common.types.path :as path]
|
[app.common.types.path :as path]
|
||||||
[app.main.data.changes :as dch]
|
[app.main.data.changes :as dch]
|
||||||
[app.main.data.helpers :as dsh]
|
[app.main.data.helpers :as dsh]
|
||||||
[app.main.data.workspace.path.state :as st]
|
[app.main.data.workspace.shapes :as dwsh]
|
||||||
[beicon.v2.core :as rx]
|
[beicon.v2.core :as rx]
|
||||||
[potok.v2.core :as ptk]))
|
[potok.v2.core :as ptk]))
|
||||||
|
|
||||||
(defn generate-path-changes
|
(defn- normalize-content
|
||||||
"Generates changes to update the new content of the shape"
|
"Normalizes path content for persistence."
|
||||||
[it objects page-id shape old-content new-content]
|
[content preserve-move-to]
|
||||||
|
(-> (if (and (not preserve-move-to)
|
||||||
|
(= (-> content last :command) :move-to))
|
||||||
|
(take (dec (count content)) content)
|
||||||
|
content)
|
||||||
|
(path/close-loops)))
|
||||||
|
|
||||||
(assert (path/content? old-content))
|
(defn finalize-path-content
|
||||||
(assert (path/content? new-content))
|
[id]
|
||||||
|
(ptk/reify ::finalize-path-content
|
||||||
(let [shape-id (:id shape)
|
ptk/WatchEvent
|
||||||
|
(watch [it state _]
|
||||||
;; We set the old values so the update-shapes works
|
(let [page-id (:current-page-id state)
|
||||||
objects
|
objects (dsh/lookup-page-objects state page-id)
|
||||||
(update objects shape-id
|
shape (get objects id)
|
||||||
(fn [shape]
|
old-content (get-in state [:workspace-local :edit-path id :old-content])
|
||||||
(-> shape
|
edit-content (get-in state [:workspace-drawing :object :content])
|
||||||
(assoc :content old-content)
|
new-content (some-> edit-content (normalize-content false))]
|
||||||
(path/update-geometry))))
|
(cond
|
||||||
|
;; Ignore differences introduced only by normalization.
|
||||||
changes
|
(or (nil? shape)
|
||||||
(-> (pcb/empty-changes it page-id)
|
(nil? old-content)
|
||||||
(pcb/with-objects objects))
|
(nil? edit-content)
|
||||||
|
(= old-content edit-content)
|
||||||
new-content
|
(= (path/close-loops old-content) new-content))
|
||||||
(path/content new-content)]
|
(rx/empty)
|
||||||
|
|
||||||
(cond
|
|
||||||
;; https://tree.taiga.io/project/penpot/issue/2366
|
|
||||||
(nil? shape-id)
|
|
||||||
changes
|
|
||||||
|
|
||||||
(empty? new-content)
|
|
||||||
(-> changes
|
|
||||||
(pcb/remove-objects [shape-id])
|
|
||||||
(pcb/resize-parents [shape-id]))
|
|
||||||
|
|
||||||
:else
|
|
||||||
(-> changes
|
|
||||||
(pcb/update-shapes [shape-id]
|
|
||||||
(fn [shape]
|
|
||||||
(-> shape
|
|
||||||
(assoc :content new-content)
|
|
||||||
(path/update-geometry))))
|
|
||||||
(pcb/resize-parents [shape-id])))))
|
|
||||||
|
|
||||||
(defn save-path-content
|
|
||||||
([]
|
|
||||||
(save-path-content {}))
|
|
||||||
([{:keys [preserve-move-to] :or {preserve-move-to false}}]
|
|
||||||
(ptk/reify ::save-path-content
|
|
||||||
ptk/UpdateEvent
|
|
||||||
(update [_ state]
|
|
||||||
(let [content (st/get-path state :content)
|
|
||||||
content (if (and (not preserve-move-to)
|
|
||||||
(= (-> content last :command) :move-to))
|
|
||||||
(path/content (take (dec (count content)) content))
|
|
||||||
(path/content content))]
|
|
||||||
(st/set-content state content)))
|
|
||||||
|
|
||||||
ptk/WatchEvent
|
|
||||||
(watch [it state _]
|
|
||||||
(let [page-id (:current-page-id state)
|
|
||||||
local (get state :workspace-local)
|
|
||||||
id (get local :edition)
|
|
||||||
objects (dsh/lookup-page-objects state page-id)]
|
|
||||||
|
|
||||||
;; NOTE: we proceed only if the shape is present on the
|
|
||||||
;; objects, if shape is a ephimeral drawing shape, we should
|
|
||||||
;; do nothing
|
|
||||||
(when-let [shape (get objects id)]
|
|
||||||
(when-let [old-content (dm/get-in local [:edit-path id :old-content])]
|
|
||||||
(let [new-content (get shape :content)
|
|
||||||
changes (generate-path-changes it objects page-id shape old-content new-content)]
|
|
||||||
(rx/of (dch/commit-changes changes))))))))))
|
|
||||||
|
|
||||||
|
(empty? new-content)
|
||||||
|
(let [changes (-> (pcb/empty-changes it page-id)
|
||||||
|
(pcb/with-objects objects)
|
||||||
|
(pcb/remove-objects [id])
|
||||||
|
(pcb/resize-parents [id]))]
|
||||||
|
(rx/of (dch/commit-changes changes)))
|
||||||
|
|
||||||
|
:else
|
||||||
|
(rx/of
|
||||||
|
(dwsh/update-shapes
|
||||||
|
[id]
|
||||||
|
(fn [shape]
|
||||||
|
(-> shape
|
||||||
|
(path/convert-to-path)
|
||||||
|
(assoc :content new-content)
|
||||||
|
(path/update-geometry)))
|
||||||
|
{:reg-objects? true})))))))
|
||||||
|
|||||||
209
frontend/src/app/main/data/workspace/path/clipboard.cljs
Normal file
@ -0,0 +1,209 @@
|
|||||||
|
;; 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 Sucursal en España SL
|
||||||
|
|
||||||
|
(ns app.main.data.workspace.path.clipboard
|
||||||
|
(:require
|
||||||
|
[app.common.data.macros :as dm]
|
||||||
|
[app.common.geom.point :as gpt]
|
||||||
|
[app.common.math :as mth]
|
||||||
|
[app.common.transit :as t]
|
||||||
|
[app.common.types.path :as path]
|
||||||
|
[app.main.data.helpers :as dsh]
|
||||||
|
[app.main.data.workspace.path.common :as common]
|
||||||
|
[app.main.data.workspace.path.drawing :as drawing]
|
||||||
|
[app.main.data.workspace.path.edition :as edition]
|
||||||
|
[app.main.data.workspace.path.helpers :as helpers]
|
||||||
|
[app.main.data.workspace.path.state :as st]
|
||||||
|
[app.main.data.workspace.path.tools :as tools]
|
||||||
|
[app.main.data.workspace.shapes :as dwsh]
|
||||||
|
[app.main.streams :as ms]
|
||||||
|
[app.util.clipboard :as clipboard]
|
||||||
|
[beicon.v2.core :as rx]
|
||||||
|
[potok.v2.core :as ptk]))
|
||||||
|
|
||||||
|
(def ^:private clipboard-type :copied-path-content)
|
||||||
|
|
||||||
|
(defn- on-clipboard-error
|
||||||
|
[cause]
|
||||||
|
(js/console.error "clipboard blocked:" cause)
|
||||||
|
(rx/empty))
|
||||||
|
|
||||||
|
(defn copy-selected-nodes
|
||||||
|
"Copies the selected path content to the clipboard."
|
||||||
|
[]
|
||||||
|
(ptk/reify ::copy-selected-nodes
|
||||||
|
ptk/WatchEvent
|
||||||
|
(watch [_ state _]
|
||||||
|
(let [id (st/get-path-id state)
|
||||||
|
content (st/get-path state :content)
|
||||||
|
selection (st/get-selection state id)
|
||||||
|
fragment (some-> content (path/extract-content selection))]
|
||||||
|
(when (seq fragment)
|
||||||
|
(let [data (t/encode-str {:type clipboard-type
|
||||||
|
:content fragment}
|
||||||
|
{:type :json-verbose})]
|
||||||
|
(->> (rx/from (clipboard/to-clipboard data))
|
||||||
|
(rx/catch on-clipboard-error)
|
||||||
|
(rx/ignore))))))))
|
||||||
|
|
||||||
|
(defn cut-selected-nodes
|
||||||
|
"Copies and removes the current path selection."
|
||||||
|
[]
|
||||||
|
(ptk/reify ::cut-selected-nodes
|
||||||
|
ptk/WatchEvent
|
||||||
|
(watch [_ _ _]
|
||||||
|
(rx/of (copy-selected-nodes)
|
||||||
|
(tools/delete-selected)))))
|
||||||
|
|
||||||
|
(def ^:private paste-offset (gpt/point 10 10))
|
||||||
|
|
||||||
|
(defn- collision-step
|
||||||
|
"Returns the non-negative paste-offset step that makes two nodes coincide."
|
||||||
|
[pasted existing]
|
||||||
|
(let [delta (gpt/subtract existing pasted)
|
||||||
|
x-step (/ (:x delta) (:x paste-offset))
|
||||||
|
y-step (/ (:y delta) (:y paste-offset))]
|
||||||
|
(when (and (not (neg? x-step))
|
||||||
|
(= x-step y-step)
|
||||||
|
(= x-step (mth/floor x-step)))
|
||||||
|
(long x-step))))
|
||||||
|
|
||||||
|
(defn- available-offset-step
|
||||||
|
"Returns the first paste-offset step with no node collisions."
|
||||||
|
[existing pasted]
|
||||||
|
(let [blocked
|
||||||
|
(reduce
|
||||||
|
(fn [blocked pasted-point]
|
||||||
|
(reduce
|
||||||
|
(fn [blocked existing-point]
|
||||||
|
(if-let [step (collision-step pasted-point existing-point)]
|
||||||
|
(conj blocked step)
|
||||||
|
blocked))
|
||||||
|
blocked
|
||||||
|
existing))
|
||||||
|
#{}
|
||||||
|
pasted)]
|
||||||
|
;; At most (count blocked) non-negative steps can be unavailable.
|
||||||
|
(some #(when-not (contains? blocked %) %)
|
||||||
|
(range (inc (count blocked))))))
|
||||||
|
|
||||||
|
(defn- center-content-at
|
||||||
|
"Centers `sub-content` on `target` using its node bounds."
|
||||||
|
[sub-content target]
|
||||||
|
(let [pts (path/get-points sub-content)
|
||||||
|
xs (map :x pts)
|
||||||
|
ys (map :y pts)
|
||||||
|
center (gpt/point (/ (+ (reduce min xs) (reduce max xs)) 2)
|
||||||
|
(/ (+ (reduce min ys) (reduce max ys)) 2))]
|
||||||
|
(path/move-content sub-content (gpt/subtract target center))))
|
||||||
|
|
||||||
|
(defn- offset-pasted-content
|
||||||
|
"Offsets pasted content until its nodes do not overlap existing nodes."
|
||||||
|
[content sub-content]
|
||||||
|
(let [existing (into #{} (path/get-points content))
|
||||||
|
pasted (path/get-points sub-content)
|
||||||
|
step (available-offset-step existing pasted)]
|
||||||
|
(if (zero? step)
|
||||||
|
sub-content
|
||||||
|
(path/move-content sub-content (gpt/scale paste-offset step)))))
|
||||||
|
|
||||||
|
(defn paste-content
|
||||||
|
"Pastes path content into the edited path at the pointer."
|
||||||
|
[sub-content]
|
||||||
|
(ptk/reify ::paste-content
|
||||||
|
ptk/UpdateEvent
|
||||||
|
(update [_ state]
|
||||||
|
(let [id (st/get-path-id state)]
|
||||||
|
(if (and (some? id)
|
||||||
|
(some? (dm/get-in state [:workspace-local :edit-path id]))
|
||||||
|
(seq sub-content))
|
||||||
|
(let [content (st/get-path state :content)
|
||||||
|
base (count content)
|
||||||
|
target (deref ms/mouse-position)
|
||||||
|
;; Center the fragment at the pointer.
|
||||||
|
sub-content (cond-> sub-content
|
||||||
|
(some? target) (center-content-at target))
|
||||||
|
sub-content (offset-pasted-content content sub-content)
|
||||||
|
new-content (path/splice-content content sub-content)
|
||||||
|
pasted (into #{}
|
||||||
|
(map #(+ base %))
|
||||||
|
(helpers/node-indices sub-content))]
|
||||||
|
(-> state
|
||||||
|
(st/set-content new-content)
|
||||||
|
(update-in (st/get-path-location state) path/update-geometry)
|
||||||
|
(assoc-in [:workspace-local :edit-path id :selection]
|
||||||
|
(assoc helpers/empty-selection :nodes pasted))))
|
||||||
|
state)))
|
||||||
|
|
||||||
|
ptk/WatchEvent
|
||||||
|
(watch [_ state _]
|
||||||
|
;; Enter move mode with the pasted nodes selected.
|
||||||
|
(when (some? (dm/get-in state [:workspace-local :edition]))
|
||||||
|
(rx/of (drawing/change-edit-mode :move))))))
|
||||||
|
|
||||||
|
(defn paste-nodes-as-shape
|
||||||
|
"Creates a path shape from copied content at the pointer."
|
||||||
|
[sub-content]
|
||||||
|
(ptk/reify ::paste-nodes-as-shape
|
||||||
|
ptk/WatchEvent
|
||||||
|
(watch [_ state _]
|
||||||
|
(let [content (path/content sub-content)
|
||||||
|
id (st/get-path-id state)
|
||||||
|
editing? (and (some? id)
|
||||||
|
(some? (dm/get-in state [:workspace-local :edit-path id])))]
|
||||||
|
(when (and (not editing?) (seq (path/get-points content)))
|
||||||
|
(let [target (or (deref ms/mouse-position)
|
||||||
|
(dsh/get-viewport-center state))
|
||||||
|
moved (center-content-at content target)
|
||||||
|
mrect (path/calc-selrect moved)]
|
||||||
|
(rx/of
|
||||||
|
(dwsh/create-and-add-shape
|
||||||
|
:path (:x target) (:y target)
|
||||||
|
{:content moved
|
||||||
|
;; Keep the shape at the content position.
|
||||||
|
:x (:x mrect)
|
||||||
|
:y (:y mrect)
|
||||||
|
:width (:width mrect)
|
||||||
|
:height (:height mrect)
|
||||||
|
:name "Path"}))))))))
|
||||||
|
|
||||||
|
(defn paste-nodes
|
||||||
|
"Pastes copied path content into the edited path."
|
||||||
|
[]
|
||||||
|
(ptk/reify ::paste-nodes
|
||||||
|
ptk/WatchEvent
|
||||||
|
(watch [_ _ _]
|
||||||
|
(->> (clipboard/from-navigator)
|
||||||
|
(rx/filter #(= (.-type ^js %) "application/transit+json"))
|
||||||
|
(rx/mapcat #(rx/from (.text ^js %)))
|
||||||
|
(rx/map t/decode-str)
|
||||||
|
(rx/filter #(and (map? %) (= clipboard-type (:type %))))
|
||||||
|
(rx/take 1)
|
||||||
|
(rx/mapcat (fn [{:keys [content]}]
|
||||||
|
;; Drop a pending segment before splicing.
|
||||||
|
(rx/of (common/cancel-pending-segment)
|
||||||
|
(paste-content content))))
|
||||||
|
(rx/catch on-clipboard-error)))))
|
||||||
|
|
||||||
|
(defn duplicate-selected
|
||||||
|
"Duplicates the current node and segment selection."
|
||||||
|
[]
|
||||||
|
(ptk/reify ::duplicate-selected
|
||||||
|
ptk/WatchEvent
|
||||||
|
(watch [_ state _]
|
||||||
|
(let [id (st/get-path-id state)
|
||||||
|
content (st/get-path state :content)
|
||||||
|
selection (st/get-selection state id)
|
||||||
|
zoom (dm/get-in state [:workspace-local :zoom] 1)
|
||||||
|
result (helpers/duplicate-selection-content
|
||||||
|
content selection (edition/duplicate-offset zoom))]
|
||||||
|
(when (seq (:sub result))
|
||||||
|
(rx/concat
|
||||||
|
;; Drop a pending segment before splicing.
|
||||||
|
(rx/of (common/cancel-pending-segment)
|
||||||
|
(edition/splice-duplicated result))
|
||||||
|
(when (some? (dm/get-in state [:workspace-local :edition]))
|
||||||
|
(rx/of (drawing/change-edit-mode :move)))))))))
|
||||||
@ -17,6 +17,23 @@
|
|||||||
[state]
|
[state]
|
||||||
(dissoc state :last-point :prev-handler :drag-handler :preview))
|
(dissoc state :last-point :prev-handler :drag-handler :preview))
|
||||||
|
|
||||||
|
(defn- drop-trailing-move-to
|
||||||
|
"Drops a trailing subpath start without segments."
|
||||||
|
[content]
|
||||||
|
(if (= :move-to (-> content last :command))
|
||||||
|
(path/content (take (dec (count content)) content))
|
||||||
|
content))
|
||||||
|
|
||||||
|
(defn- update-object-content
|
||||||
|
[state f]
|
||||||
|
(let [location (st/get-path-location state)
|
||||||
|
object (get-in state location)
|
||||||
|
content (some-> (:content object) f)]
|
||||||
|
(cond-> state
|
||||||
|
(some? content)
|
||||||
|
(assoc-in location (cond-> (assoc object :content content)
|
||||||
|
(seq content) (path/update-geometry))))))
|
||||||
|
|
||||||
(defn finish-path
|
(defn finish-path
|
||||||
[]
|
[]
|
||||||
(ptk/reify ::finish-path
|
(ptk/reify ::finish-path
|
||||||
@ -25,4 +42,15 @@
|
|||||||
(let [id (st/get-path-id state)]
|
(let [id (st/get-path-id state)]
|
||||||
(-> state
|
(-> state
|
||||||
(update-in [:workspace-local :edit-path id] clean-edit-state)
|
(update-in [:workspace-local :edit-path id] clean-edit-state)
|
||||||
(update-in (st/get-path-location state :content) path/close-subpaths))))))
|
(update-object-content (comp path/close-subpaths drop-trailing-move-to)))))))
|
||||||
|
|
||||||
|
(defn cancel-pending-segment
|
||||||
|
"Cancels the pending segment without leaving draw mode."
|
||||||
|
[]
|
||||||
|
(ptk/reify ::cancel-pending-segment
|
||||||
|
ptk/UpdateEvent
|
||||||
|
(update [_ state]
|
||||||
|
(let [id (st/get-path-id state)]
|
||||||
|
(-> state
|
||||||
|
(update-in [:workspace-local :edit-path id] clean-edit-state)
|
||||||
|
(update-object-content drop-trailing-move-to))))))
|
||||||
|
|||||||
@ -20,13 +20,14 @@
|
|||||||
[app.main.data.workspace.drawing.common :as dwdc]
|
[app.main.data.workspace.drawing.common :as dwdc]
|
||||||
[app.main.data.workspace.edition :as dwe]
|
[app.main.data.workspace.edition :as dwe]
|
||||||
[app.main.data.workspace.pages :as-alias dwpg]
|
[app.main.data.workspace.pages :as-alias dwpg]
|
||||||
[app.main.data.workspace.path.changes :as changes]
|
|
||||||
[app.main.data.workspace.path.common :as common]
|
[app.main.data.workspace.path.common :as common]
|
||||||
|
[app.main.data.workspace.path.edition :as edition]
|
||||||
[app.main.data.workspace.path.helpers :as helpers]
|
[app.main.data.workspace.path.helpers :as helpers]
|
||||||
[app.main.data.workspace.path.state :as st]
|
[app.main.data.workspace.path.state :as st]
|
||||||
[app.main.data.workspace.path.streams :as streams]
|
[app.main.data.workspace.path.streams :as streams]
|
||||||
|
[app.main.data.workspace.path.tools :as tools]
|
||||||
[app.main.data.workspace.path.undo :as undo]
|
[app.main.data.workspace.path.undo :as undo]
|
||||||
[app.main.data.workspace.shapes :as dwsh]
|
[app.main.streams :as ms]
|
||||||
[app.util.mouse :as mse]
|
[app.util.mouse :as mse]
|
||||||
[beicon.v2.core :as rx]
|
[beicon.v2.core :as rx]
|
||||||
[potok.v2.core :as ptk]))
|
[potok.v2.core :as ptk]))
|
||||||
@ -35,36 +36,75 @@
|
|||||||
(declare check-changed-content)
|
(declare check-changed-content)
|
||||||
(declare change-edit-mode)
|
(declare change-edit-mode)
|
||||||
|
|
||||||
(defn- end-path-event?
|
(defn start-created-path-edition
|
||||||
|
[id]
|
||||||
|
(ptk/reify ::start-created-path-edition
|
||||||
|
ptk/WatchEvent
|
||||||
|
(watch [_ _ _]
|
||||||
|
(rx/of (dwe/start-edition-mode id)
|
||||||
|
(edition/start-path-edit id)
|
||||||
|
(change-edit-mode :draw)))))
|
||||||
|
|
||||||
|
;; Draw-loop stop signals either restart the same path or exit drawing.
|
||||||
|
|
||||||
|
(defn restart-draw-loop?
|
||||||
|
"True when drawing restarts on the same path."
|
||||||
|
[event]
|
||||||
|
(or (= (ptk/type event) ::common/finish-path)
|
||||||
|
(and ^boolean (mse/mouse-event? event)
|
||||||
|
^boolean (mse/mouse-double-click-event? event))))
|
||||||
|
|
||||||
|
(defn- exit-draw-loop?
|
||||||
|
"True when the draw loop exits."
|
||||||
[event]
|
[event]
|
||||||
(let [type (ptk/type event)]
|
(let [type (ptk/type event)]
|
||||||
(or
|
(or (= type ::dwe/clear-edition-mode)
|
||||||
(= type ::common/finish-path)
|
(= type ::dwpg/finalize-page)
|
||||||
(= type :app.main.data.workspace.path.shortcuts/esc-pressed)
|
(dwe/interrupt? event))))
|
||||||
(= type :app.main.data.workspace.common/clear-edition-mode)
|
|
||||||
(= type :app.main.data.workspace.edition/clear-edition-mode)
|
(defn- end-path-event?
|
||||||
(= type ::dwpg/finalize-page)
|
"True when the draw loop should stop."
|
||||||
(= event :interrupt) ;; ESC
|
[event]
|
||||||
(and ^boolean (mse/mouse-event? event)
|
(or (restart-draw-loop? event)
|
||||||
^boolean (mse/mouse-double-click-event? event)))))
|
(exit-draw-loop? event)))
|
||||||
|
|
||||||
|
(def ^:private draw-insert-threshold
|
||||||
|
"Maximum screen distance for inserting a node on a segment."
|
||||||
|
16)
|
||||||
|
|
||||||
(defn preview-next-point
|
(defn preview-next-point
|
||||||
[{:keys [x y shift?]}]
|
[{:keys [x y shift?]}]
|
||||||
(ptk/reify ::preview-next-point
|
(ptk/reify ::preview-next-point
|
||||||
ptk/UpdateEvent
|
ptk/UpdateEvent
|
||||||
(update [_ state]
|
(update [_ state]
|
||||||
(let [id (st/get-path-id state)
|
(let [id (st/get-path-id state)
|
||||||
fix-angle? shift?
|
edit-path (get-in state [:workspace-local :edit-path id])]
|
||||||
last-point (get-in state [:workspace-local :edit-path id :last-point])
|
;; Freeze the next-point preview during modifier drags.
|
||||||
position (cond-> (gpt/point x y)
|
(if (seq (:content-modifiers edit-path))
|
||||||
fix-angle? (path.helpers/position-fixed-angle last-point))
|
state
|
||||||
content (st/get-path state :content)
|
(let [fix-angle? shift?
|
||||||
|
{:keys [last-point prev-handler]} edit-path
|
||||||
|
content (st/get-path state :content)
|
||||||
|
zoom (dm/get-in state [:workspace-local :zoom] 1)
|
||||||
|
raw-pos @ms/mouse-position
|
||||||
|
|
||||||
{:keys [last-point prev-handler]}
|
;; Segment insertion uses the exact on-curve preview point.
|
||||||
(get-in state [:workspace-local :edit-path id])
|
insert-point (when (and (seq (:segments (:hover edit-path)))
|
||||||
|
(gpt/point? raw-pos))
|
||||||
|
(helpers/insertion-point
|
||||||
|
content raw-pos (/ draw-insert-threshold zoom) true))
|
||||||
|
|
||||||
segment (path/next-node content position last-point prev-handler)]
|
position (cond
|
||||||
(assoc-in state [:workspace-local :edit-path id :preview] segment)))))
|
(some? insert-point)
|
||||||
|
insert-point
|
||||||
|
|
||||||
|
fix-angle?
|
||||||
|
(path.helpers/position-fixed-angle (gpt/point x y) last-point)
|
||||||
|
|
||||||
|
:else
|
||||||
|
(gpt/point x y))
|
||||||
|
segment (path/next-node content position last-point prev-handler)]
|
||||||
|
(assoc-in state [:workspace-local :edit-path id :preview] segment)))))))
|
||||||
|
|
||||||
(defn add-node
|
(defn add-node
|
||||||
[{:keys [x y shift?]}]
|
[{:keys [x y shift?]}]
|
||||||
@ -141,34 +181,84 @@
|
|||||||
(rx/of (preview-next-point handler)
|
(rx/of (preview-next-point handler)
|
||||||
(undo/merge-head))))))
|
(undo/merge-head))))))
|
||||||
|
|
||||||
(defn close-path-drag-start
|
(defn drag-prev-handler
|
||||||
[position]
|
"Moves the current node's forward handle while drawing."
|
||||||
(ptk/reify ::close-path-drag-start
|
[{:keys [x y alt? shift?]}]
|
||||||
|
(ptk/reify ::drag-prev-handler
|
||||||
|
ptk/UpdateEvent
|
||||||
|
(update [_ state]
|
||||||
|
(let [id (st/get-path-id state)
|
||||||
|
content (st/get-path state :content)
|
||||||
|
index (count content)
|
||||||
|
position (path.helpers/segment->point (nth content (dec index)))
|
||||||
|
|
||||||
|
handler-position
|
||||||
|
(cond-> (gpt/point x y)
|
||||||
|
shift? (path.helpers/position-fixed-angle position))
|
||||||
|
|
||||||
|
dx (- (:x handler-position) (:x position))
|
||||||
|
dy (- (:y handler-position) (:y position))
|
||||||
|
|
||||||
|
;; Alt leaves the opposite handle unchanged.
|
||||||
|
rejoin? (not alt?)
|
||||||
|
|
||||||
|
modifiers (helpers/move-handler-modifiers content index :c1 false false rejoin? dx dy)]
|
||||||
|
(-> state
|
||||||
|
(update-in [:workspace-local :edit-path id] dissoc :prev-handler)
|
||||||
|
(assoc-in [:workspace-local :edit-path id :content-modifiers] modifiers)
|
||||||
|
(assoc-in [:workspace-local :edit-path id :drag-handler] handler-position))))))
|
||||||
|
|
||||||
|
(defn start-move-prev-handler
|
||||||
|
"Starts dragging the current node's forward handle."
|
||||||
|
[]
|
||||||
|
(ptk/reify ::start-move-prev-handler
|
||||||
ptk/WatchEvent
|
ptk/WatchEvent
|
||||||
(watch [_ state stream]
|
(watch [_ state stream]
|
||||||
(let [content (st/get-path state :content)
|
(let [stopper (rx/merge
|
||||||
handlers (-> (path/get-handlers content)
|
(mse/drag-stopper stream)
|
||||||
(get position))
|
(rx/filter end-path-event? stream))
|
||||||
|
|
||||||
[idx prefix] (when (= (count handlers) 1)
|
drag-events
|
||||||
(first handlers))
|
|
||||||
|
|
||||||
drag-events-stream
|
|
||||||
(->> (streams/position-stream state)
|
(->> (streams/position-stream state)
|
||||||
(rx/map #(drag-handler position idx prefix %))
|
(rx/map drag-prev-handler)
|
||||||
(rx/take-until
|
(rx/take-until stopper))]
|
||||||
(rx/merge
|
(streams/drag-stream
|
||||||
(mse/drag-stopper stream)
|
(rx/concat
|
||||||
(rx/filter end-path-event? stream))))]
|
(rx/of (edition/set-drag-cursor "move-handles"))
|
||||||
|
drag-events
|
||||||
|
(rx/of (finish-drag))))))))
|
||||||
|
|
||||||
(rx/concat
|
(defn close-path-drag-start
|
||||||
(rx/of (add-node position))
|
([position]
|
||||||
(streams/drag-stream
|
(close-path-drag-start position "draw-node"))
|
||||||
(rx/concat
|
([position cursor]
|
||||||
drag-events-stream
|
(ptk/reify ::close-path-drag-start
|
||||||
(rx/of (finish-drag))
|
ptk/WatchEvent
|
||||||
(rx/of (close-path-drag-end))))
|
(watch [_ state stream]
|
||||||
(rx/of (common/finish-path)))))))
|
(let [content (st/get-path state :content)
|
||||||
|
handlers (-> (path/get-handlers content)
|
||||||
|
(get position))
|
||||||
|
|
||||||
|
[idx prefix] (when (= (count handlers) 1)
|
||||||
|
(first handlers))
|
||||||
|
|
||||||
|
drag-events-stream
|
||||||
|
(->> (streams/position-stream state)
|
||||||
|
(rx/map #(drag-handler position idx prefix %))
|
||||||
|
(rx/take-until
|
||||||
|
(rx/merge
|
||||||
|
(mse/drag-stopper stream)
|
||||||
|
(rx/filter end-path-event? stream))))]
|
||||||
|
|
||||||
|
(rx/concat
|
||||||
|
(rx/of (add-node position))
|
||||||
|
(streams/drag-stream
|
||||||
|
(rx/concat
|
||||||
|
(rx/of (edition/set-drag-cursor cursor))
|
||||||
|
drag-events-stream
|
||||||
|
(rx/of (finish-drag))
|
||||||
|
(rx/of (close-path-drag-end))))
|
||||||
|
(rx/of (common/finish-path))))))))
|
||||||
|
|
||||||
(defn close-path-drag-end []
|
(defn close-path-drag-end []
|
||||||
(ptk/reify ::close-path-drag-end
|
(ptk/reify ::close-path-drag-end
|
||||||
@ -177,30 +267,27 @@
|
|||||||
(let [id (st/get-path-id state)]
|
(let [id (st/get-path-id state)]
|
||||||
(update-in state [:workspace-local :edit-path id] dissoc :prev-handler)))))
|
(update-in state [:workspace-local :edit-path id] dissoc :prev-handler)))))
|
||||||
|
|
||||||
(defn start-path-from-point [position]
|
(defn start-path-from-point
|
||||||
(ptk/reify ::start-path-from-point
|
([position]
|
||||||
ptk/WatchEvent
|
(start-path-from-point position "draw-node"))
|
||||||
(watch [_ state stream]
|
([position cursor]
|
||||||
(let [stopper (rx/merge
|
(ptk/reify ::start-path-from-point
|
||||||
(mse/drag-stopper stream)
|
ptk/WatchEvent
|
||||||
(rx/filter end-path-event? stream))
|
(watch [_ state stream]
|
||||||
|
(let [stopper (rx/merge
|
||||||
|
(mse/drag-stopper stream)
|
||||||
|
(rx/filter end-path-event? stream))
|
||||||
|
|
||||||
drag-events (->> (streams/position-stream state)
|
drag-events (->> (streams/position-stream state)
|
||||||
(rx/map #(drag-handler %))
|
(rx/map #(drag-handler %))
|
||||||
(rx/take-until stopper))]
|
(rx/take-until stopper))]
|
||||||
(rx/concat
|
(rx/concat
|
||||||
(rx/of (add-node position))
|
(rx/of (add-node position))
|
||||||
(streams/drag-stream
|
(streams/drag-stream
|
||||||
(rx/concat
|
(rx/concat
|
||||||
drag-events
|
(rx/of (edition/set-drag-cursor cursor))
|
||||||
(rx/of (finish-drag)))))))))
|
drag-events
|
||||||
|
(rx/of (finish-drag))))))))))
|
||||||
(defn make-node-events-stream
|
|
||||||
[stream]
|
|
||||||
(->> stream
|
|
||||||
(rx/filter (ptk/type? ::close-path-drag-start))
|
|
||||||
(rx/take 1)
|
|
||||||
(rx/merge-map #(rx/empty))))
|
|
||||||
|
|
||||||
(defn make-drag-stream
|
(defn make-drag-stream
|
||||||
[state stream down-event]
|
[state stream down-event]
|
||||||
@ -229,7 +316,12 @@
|
|||||||
ptk/UpdateEvent
|
ptk/UpdateEvent
|
||||||
(update [_ state]
|
(update [_ state]
|
||||||
(let [id (st/get-path-id state)]
|
(let [id (st/get-path-id state)]
|
||||||
(assoc-in state [:workspace-local :edit-path id :edit-mode] :draw)))
|
(update-in state [:workspace-local :edit-path id]
|
||||||
|
(fn [edit-state]
|
||||||
|
(-> edit-state
|
||||||
|
(assoc :edit-mode :draw)
|
||||||
|
;; Keep explicit snap choices across draw restarts.
|
||||||
|
(update :snap-toggled (fnil identity true)))))))
|
||||||
|
|
||||||
ptk/WatchEvent
|
ptk/WatchEvent
|
||||||
(watch [_ state stream]
|
(watch [_ state stream]
|
||||||
@ -243,26 +335,29 @@
|
|||||||
(rx/filter end-path-event?)
|
(rx/filter end-path-event?)
|
||||||
(rx/share))
|
(rx/share))
|
||||||
|
|
||||||
|
stop-event
|
||||||
|
(volatile! nil)
|
||||||
|
|
||||||
stoper-stream
|
stoper-stream
|
||||||
(->> stream
|
(->> stream
|
||||||
(rx/filter (ptk/type? ::start-edition))
|
(rx/filter (ptk/type? ::start-edition))
|
||||||
(rx/merge end-stream))
|
(rx/merge end-stream)
|
||||||
|
(rx/tap #(vreset! stop-event %))
|
||||||
|
(rx/share))
|
||||||
|
|
||||||
;; Mouse move preview
|
;; Mouse move preview
|
||||||
mousemove-events
|
mousemove-events
|
||||||
(->> (streams/position-stream state)
|
(->> (streams/position-stream state)
|
||||||
(rx/map #(preview-next-point %)))
|
(rx/map #(preview-next-point %)))
|
||||||
|
|
||||||
;; From mouse down we can have: click, drag and double click
|
;; Viewport clicks add nodes; node clicks handle closing separately.
|
||||||
mousedown-events
|
mousedown-events
|
||||||
(->> mouse-down
|
(->> mouse-down
|
||||||
;; We just ignore the mouse event and stream down the
|
;; We just ignore the mouse event and stream down the
|
||||||
;; last position event
|
;; last position event
|
||||||
(rx/with-latest-from #(-> %2) (streams/position-stream state))
|
(rx/with-latest-from #(-> %2) (streams/position-stream state))
|
||||||
;; We change to the stream that emits the first event
|
|
||||||
(rx/switch-map
|
(rx/switch-map
|
||||||
#(rx/race (make-node-events-stream stream)
|
#(make-drag-stream state stream %))
|
||||||
(make-drag-stream state stream %)))
|
|
||||||
(rx/take-until end-stream))]
|
(rx/take-until end-stream))]
|
||||||
|
|
||||||
(->> (rx/concat
|
(->> (rx/concat
|
||||||
@ -270,7 +365,11 @@
|
|||||||
(->> (rx/merge mousemove-events
|
(->> (rx/merge mousemove-events
|
||||||
mousedown-events)
|
mousedown-events)
|
||||||
(rx/take-until stoper-stream))
|
(rx/take-until stoper-stream))
|
||||||
(rx/of (ptk/data-event ::end-edition))))))))
|
(->> (rx/of nil)
|
||||||
|
(rx/map (fn [_]
|
||||||
|
(ptk/data-event
|
||||||
|
::end-edition
|
||||||
|
{:restart? (restart-draw-loop? @stop-event)}))))))))))
|
||||||
|
|
||||||
(defn setup-frame
|
(defn setup-frame
|
||||||
[]
|
[]
|
||||||
@ -299,8 +398,20 @@
|
|||||||
(cond-> (some? drop-index)
|
(cond-> (some? drop-index)
|
||||||
(with-meta {:index drop-index})))))))))
|
(with-meta {:index drop-index})))))))))
|
||||||
|
|
||||||
|
(defn- close-drawn-loops
|
||||||
|
"Adds explicit close commands to completed loops."
|
||||||
|
[]
|
||||||
|
(ptk/reify ::close-drawn-loops
|
||||||
|
ptk/UpdateEvent
|
||||||
|
(update [_ state]
|
||||||
|
(d/update-in-when state [:workspace-drawing :object]
|
||||||
|
(fn [object]
|
||||||
|
(-> object
|
||||||
|
(update :content path/close-loops)
|
||||||
|
(path/update-geometry)))))))
|
||||||
|
|
||||||
(defn- handle-drawing-end
|
(defn- handle-drawing-end
|
||||||
[shape-id]
|
[shape-id restart?]
|
||||||
(ptk/reify ::handle-drawing-end
|
(ptk/reify ::handle-drawing-end
|
||||||
ptk/UpdateEvent
|
ptk/UpdateEvent
|
||||||
(update [_ state]
|
(update [_ state]
|
||||||
@ -313,15 +424,26 @@
|
|||||||
ptk/WatchEvent
|
ptk/WatchEvent
|
||||||
(watch [_ state _]
|
(watch [_ state _]
|
||||||
(when-let [content (dm/get-in state [:workspace-drawing :object :content])]
|
(when-let [content (dm/get-in state [:workspace-drawing :object :content])]
|
||||||
(if (> (count content) 1)
|
(cond
|
||||||
(rx/of (setup-frame)
|
(and (> (count content) 1) restart?)
|
||||||
|
(rx/of (common/finish-path)
|
||||||
|
(close-drawn-loops)
|
||||||
|
(setup-frame)
|
||||||
(dwdc/handle-finish-drawing)
|
(dwdc/handle-finish-drawing)
|
||||||
(dwe/start-edition-mode shape-id)
|
(start-created-path-edition shape-id))
|
||||||
(change-edit-mode :draw))
|
|
||||||
(rx/of (dwdc/handle-finish-drawing)))))))
|
(> (count content) 1)
|
||||||
|
(rx/of (close-drawn-loops)
|
||||||
|
(setup-frame)
|
||||||
|
(dwdc/handle-finish-drawing)
|
||||||
|
(dwe/clear-edition-mode))
|
||||||
|
|
||||||
|
:else
|
||||||
|
(rx/of (dwdc/handle-finish-drawing)
|
||||||
|
(dwe/clear-edition-mode)))))))
|
||||||
|
|
||||||
(defn handle-drawing
|
(defn handle-drawing
|
||||||
"Hanndle the start of drawing new path shape"
|
"Starts drawing a path."
|
||||||
[]
|
[]
|
||||||
(ptk/reify ::handle-new-shape
|
(ptk/reify ::handle-new-shape
|
||||||
ptk/UpdateEvent
|
ptk/UpdateEvent
|
||||||
@ -337,8 +459,10 @@
|
|||||||
(->> stream
|
(->> stream
|
||||||
(rx/filter (ptk/type? ::end-edition))
|
(rx/filter (ptk/type? ::end-edition))
|
||||||
(rx/take 1)
|
(rx/take 1)
|
||||||
|
;; Let the stop event settle before finishing the drawing.
|
||||||
(rx/observe-on :async)
|
(rx/observe-on :async)
|
||||||
(rx/map (partial handle-drawing-end shape-id))))))))
|
(rx/map (fn [event]
|
||||||
|
(handle-drawing-end shape-id (:restart? (deref event)))))))))))
|
||||||
|
|
||||||
(declare start-draw-mode*)
|
(declare start-draw-mode*)
|
||||||
|
|
||||||
@ -347,12 +471,22 @@
|
|||||||
(ptk/reify ::start-draw-mode
|
(ptk/reify ::start-draw-mode
|
||||||
ptk/UpdateEvent
|
ptk/UpdateEvent
|
||||||
(update [_ state]
|
(update [_ state]
|
||||||
(let [id (dm/get-in state [:workspace-local :edition])
|
(let [id (dm/get-in state [:workspace-local :edition])
|
||||||
objects (dsh/lookup-page-objects state)
|
objects (dsh/lookup-page-objects state)
|
||||||
content (dm/get-in objects [id :content])]
|
shape (get objects id)
|
||||||
(if content
|
drawing (dm/get-in state [:workspace-drawing :object])
|
||||||
(update-in state [:workspace-local :edit-path id] assoc :old-content content)
|
old-content (dm/get-in state [:workspace-local :edit-path id :old-content])
|
||||||
state)))
|
drawing (or drawing
|
||||||
|
(some-> shape
|
||||||
|
(path/convert-to-path objects)
|
||||||
|
(update :content path/close-subpaths)
|
||||||
|
(path/update-geometry)))]
|
||||||
|
(cond-> state
|
||||||
|
drawing
|
||||||
|
(assoc-in [:workspace-drawing :object] drawing)
|
||||||
|
|
||||||
|
(and drawing (nil? old-content))
|
||||||
|
(assoc-in [:workspace-local :edit-path id :old-content] (:content drawing)))))
|
||||||
|
|
||||||
ptk/WatchEvent
|
ptk/WatchEvent
|
||||||
(watch [_ _ _]
|
(watch [_ _ _]
|
||||||
@ -369,23 +503,52 @@
|
|||||||
|
|
||||||
(if (= :draw mode)
|
(if (= :draw mode)
|
||||||
(rx/concat
|
(rx/concat
|
||||||
(rx/of (dwsh/update-shapes [id] path/convert-to-path))
|
|
||||||
(rx/of (start-edition id))
|
(rx/of (start-edition id))
|
||||||
(->> stream
|
(->> stream
|
||||||
(rx/filter (ptk/type? ::end-edition))
|
(rx/filter (ptk/type? ::end-edition))
|
||||||
(rx/take 1)
|
(rx/take 1)
|
||||||
(rx/mapcat (fn [_]
|
(rx/mapcat (fn [event]
|
||||||
(rx/of (check-changed-content)
|
(if (:restart? (deref event))
|
||||||
(start-draw-mode*))))))
|
(rx/of (common/finish-path)
|
||||||
|
(check-changed-content)
|
||||||
|
(start-draw-mode*))
|
||||||
|
(rx/empty))))))
|
||||||
(rx/empty))))))
|
(rx/empty))))))
|
||||||
|
|
||||||
|
(defn- enter-draw-from-selected-node
|
||||||
|
"Starts a new segment from the only selected node."
|
||||||
|
[state id]
|
||||||
|
(let [selection (get (st/get-selection state id) :nodes #{})
|
||||||
|
last-point (dm/get-in state [:workspace-local :edit-path id :last-point])
|
||||||
|
content (st/get-path state :content)]
|
||||||
|
(if (and (nil? last-point)
|
||||||
|
(= 1 (count selection))
|
||||||
|
(some? content)
|
||||||
|
(helpers/node? content (first selection)))
|
||||||
|
(let [index (first selection)
|
||||||
|
pos (helpers/node-position content index)
|
||||||
|
last-idx (dec (count content))
|
||||||
|
tip? (and (= index last-idx)
|
||||||
|
(not= :close-path (:command (nth content index nil))))
|
||||||
|
state (assoc-in state [:workspace-local :edit-path id :last-point] pos)]
|
||||||
|
(if tip?
|
||||||
|
state
|
||||||
|
(update-in state (st/get-path-location state)
|
||||||
|
(fn [shape]
|
||||||
|
(-> shape
|
||||||
|
(update :content path/append-segment
|
||||||
|
{:command :move-to :params (select-keys pos [:x :y])})
|
||||||
|
(path/update-geometry))))))
|
||||||
|
state)))
|
||||||
|
|
||||||
(defn change-edit-mode
|
(defn change-edit-mode
|
||||||
[mode]
|
[mode]
|
||||||
(ptk/reify ::change-edit-mode
|
(ptk/reify ::change-edit-mode
|
||||||
ptk/UpdateEvent
|
ptk/UpdateEvent
|
||||||
(update [_ state]
|
(update [_ state]
|
||||||
(if-let [id (dm/get-in state [:workspace-local :edition])]
|
(if-let [id (dm/get-in state [:workspace-local :edition])]
|
||||||
(d/update-in-when state [:workspace-local :edit-path id] assoc :edit-mode mode)
|
(cond-> (d/update-in-when state [:workspace-local :edit-path id] assoc :edit-mode mode)
|
||||||
|
(= mode :draw) (enter-draw-from-selected-node id))
|
||||||
state))
|
state))
|
||||||
|
|
||||||
ptk/WatchEvent
|
ptk/WatchEvent
|
||||||
@ -405,6 +568,98 @@
|
|||||||
(let [id (st/get-path-id state)]
|
(let [id (st/get-path-id state)]
|
||||||
(assoc-in state [:workspace-local :edit-path id :prev-handler] nil)))))
|
(assoc-in state [:workspace-local :edit-path id :prev-handler] nil)))))
|
||||||
|
|
||||||
|
(defn on-draw-node-pointer-down
|
||||||
|
"Handles node clicks and drags in draw mode."
|
||||||
|
[index position alt? mod?]
|
||||||
|
(ptk/reify ::on-draw-node-pointer-down
|
||||||
|
ptk/WatchEvent
|
||||||
|
(watch [_ state _]
|
||||||
|
(let [id (st/get-path-id state)
|
||||||
|
content (st/get-path state :content)
|
||||||
|
node-pos (when (and (some? content)
|
||||||
|
(< index (count content))
|
||||||
|
(helpers/node? content index))
|
||||||
|
(helpers/node-position content index))
|
||||||
|
last-point (dm/get-in state [:workspace-local :edit-path id :last-point])
|
||||||
|
pending-origin? (and (some? node-pos) (= last-point node-pos))]
|
||||||
|
(cond
|
||||||
|
(and mod? alt?)
|
||||||
|
(rx/concat
|
||||||
|
(rx/of (tools/remove-node-with-segments index))
|
||||||
|
(if pending-origin?
|
||||||
|
(rx/of (common/cancel-pending-segment))
|
||||||
|
(rx/empty)))
|
||||||
|
|
||||||
|
mod?
|
||||||
|
(streams/drag-stream
|
||||||
|
(rx/of (edition/set-drag-cursor "move-handles")
|
||||||
|
(edition/curve-config-node-drag index))
|
||||||
|
(rx/of (tools/toggle-node-curve index)))
|
||||||
|
|
||||||
|
alt?
|
||||||
|
(if (some? node-pos)
|
||||||
|
(rx/concat
|
||||||
|
(rx/of (tools/remove-node node-pos))
|
||||||
|
(if pending-origin?
|
||||||
|
(rx/of (common/cancel-pending-segment))
|
||||||
|
(rx/empty)))
|
||||||
|
(rx/empty))
|
||||||
|
|
||||||
|
(= last-point position)
|
||||||
|
(rx/of (reset-last-handler))
|
||||||
|
|
||||||
|
(nil? last-point)
|
||||||
|
(rx/of (start-path-from-point position))
|
||||||
|
|
||||||
|
:else
|
||||||
|
(rx/of (close-path-drag-start position)))))))
|
||||||
|
|
||||||
|
(defn on-draw-segment-pointer-down
|
||||||
|
"Handles segment clicks and drags in draw mode."
|
||||||
|
[index alt? mod?]
|
||||||
|
(ptk/reify ::on-draw-segment-pointer-down
|
||||||
|
ptk/WatchEvent
|
||||||
|
(watch [_ state _]
|
||||||
|
(let [id (st/get-path-id state)
|
||||||
|
zoom (dm/get-in state [:workspace-local :zoom] 1)
|
||||||
|
content (st/get-path state :content)
|
||||||
|
position @ms/mouse-position
|
||||||
|
last-point (dm/get-in state [:workspace-local :edit-path id :last-point])]
|
||||||
|
(cond
|
||||||
|
alt?
|
||||||
|
(let [entry (d/seek #(= index (:index %)) (helpers/segment-entries content))
|
||||||
|
pending-here? (and (some? last-point)
|
||||||
|
(some? entry)
|
||||||
|
(or (= last-point (:from entry))
|
||||||
|
(= last-point (:to entry))))]
|
||||||
|
(rx/concat
|
||||||
|
(rx/of (tools/remove-segment index))
|
||||||
|
(if pending-here?
|
||||||
|
(rx/of (common/cancel-pending-segment))
|
||||||
|
(rx/empty))))
|
||||||
|
|
||||||
|
mod?
|
||||||
|
(let [entry (d/seek #(= index (:index %)) (helpers/segment-entries content))
|
||||||
|
bendable? (and (some? entry)
|
||||||
|
(not= :close-path (:command (:segment entry))))]
|
||||||
|
(streams/drag-stream
|
||||||
|
(if bendable?
|
||||||
|
(rx/of (edition/set-drag-cursor "move-curve")
|
||||||
|
(edition/bend-selected-segment index position))
|
||||||
|
(rx/empty))
|
||||||
|
(rx/of (tools/toggle-segment-curve index))))
|
||||||
|
|
||||||
|
:else
|
||||||
|
(let [insert-point (helpers/insertion-point
|
||||||
|
content position (/ draw-insert-threshold zoom) true)]
|
||||||
|
(if (some? insert-point)
|
||||||
|
(rx/concat
|
||||||
|
(rx/of (edition/create-node-at-position (meta insert-point)))
|
||||||
|
(if (some? last-point)
|
||||||
|
(rx/of (close-path-drag-start insert-point "draw-add"))
|
||||||
|
(rx/of (start-path-from-point insert-point "draw-add"))))
|
||||||
|
(rx/empty))))))))
|
||||||
|
|
||||||
(defn check-changed-content
|
(defn check-changed-content
|
||||||
[]
|
[]
|
||||||
(ptk/reify ::check-changed-content
|
(ptk/reify ::check-changed-content
|
||||||
@ -418,10 +673,11 @@
|
|||||||
|
|
||||||
(cond
|
(cond
|
||||||
(and (not= content old-content) (not empty-content?))
|
(and (not= content old-content) (not empty-content?))
|
||||||
(rx/of (changes/save-path-content))
|
(rx/empty)
|
||||||
|
|
||||||
|
;; Exit through the path edition stop event.
|
||||||
(= mode :draw)
|
(= mode :draw)
|
||||||
(rx/of :interrupt)
|
(rx/of (dwe/clear-edition-mode))
|
||||||
|
|
||||||
:else
|
:else
|
||||||
(rx/of
|
(rx/of
|
||||||
|
|||||||
@ -8,10 +8,10 @@
|
|||||||
(:require
|
(:require
|
||||||
[app.common.data :as d]
|
[app.common.data :as d]
|
||||||
[app.common.data.macros :as dm]
|
[app.common.data.macros :as dm]
|
||||||
|
[app.common.files.helpers :as cfh]
|
||||||
[app.common.geom.point :as gpt]
|
[app.common.geom.point :as gpt]
|
||||||
[app.common.types.path :as path]
|
[app.common.types.path :as path]
|
||||||
[app.common.types.path.helpers :as path.helpers]
|
[app.common.types.path.helpers :as path.helpers]
|
||||||
[app.main.data.changes :as dch]
|
|
||||||
[app.main.data.helpers :as dsh]
|
[app.main.data.helpers :as dsh]
|
||||||
[app.main.data.workspace.edition :as dwe]
|
[app.main.data.workspace.edition :as dwe]
|
||||||
[app.main.data.workspace.path.changes :as changes]
|
[app.main.data.workspace.path.changes :as changes]
|
||||||
@ -19,32 +19,178 @@
|
|||||||
[app.main.data.workspace.path.selection :as selection]
|
[app.main.data.workspace.path.selection :as selection]
|
||||||
[app.main.data.workspace.path.state :as st]
|
[app.main.data.workspace.path.state :as st]
|
||||||
[app.main.data.workspace.path.streams :as streams]
|
[app.main.data.workspace.path.streams :as streams]
|
||||||
|
[app.main.data.workspace.path.tools :as tools]
|
||||||
[app.main.data.workspace.path.undo :as undo]
|
[app.main.data.workspace.path.undo :as undo]
|
||||||
[app.main.data.workspace.shapes :as dwsh]
|
|
||||||
[app.main.streams :as ms]
|
[app.main.streams :as ms]
|
||||||
|
[app.render-wasm.svg-fills :as svg-fills]
|
||||||
[app.util.mouse :as mse]
|
[app.util.mouse :as mse]
|
||||||
[beicon.v2.core :as rx]
|
[beicon.v2.core :as rx]
|
||||||
|
[beicon.v2.operators :as rxo]
|
||||||
[potok.v2.core :as ptk]))
|
[potok.v2.core :as ptk]))
|
||||||
|
|
||||||
(defn modify-handler [id index prefix dx dy match-opposite?]
|
(defn- handler-modifier-delta
|
||||||
(ptk/reify ::modify-handler
|
[modifiers index prefix]
|
||||||
|
(let [[cx cy] (path.helpers/prefix->coords prefix)]
|
||||||
|
(gpt/point (dm/get-in modifiers [index cx] 0)
|
||||||
|
(dm/get-in modifiers [index cy] 0))))
|
||||||
|
|
||||||
|
(defn- remove-handler-modifier
|
||||||
|
[modifiers [index prefix]]
|
||||||
|
(let [[cx cy] (path.helpers/prefix->coords prefix)
|
||||||
|
modifiers (update modifiers index dissoc cx cy)]
|
||||||
|
(cond-> modifiers
|
||||||
|
(empty? (get modifiers index)) (dissoc index))))
|
||||||
|
|
||||||
|
(defn- stored-handler-drag-mode
|
||||||
|
"Returns a handler's stored drag mode, ignoring stale mirror state."
|
||||||
|
[content handler-types index prefix]
|
||||||
|
(case (get handler-types (helpers/handler-node-index index prefix))
|
||||||
|
:mirror (if (helpers/handlers-joined? content index prefix)
|
||||||
|
:mirror
|
||||||
|
:smart)
|
||||||
|
:aligned :aligned
|
||||||
|
:independent :independent
|
||||||
|
:smart))
|
||||||
|
|
||||||
|
(defn- active-selected-handlers
|
||||||
|
"Returns valid handlers for the current drag."
|
||||||
|
[content primary selected-handlers move-selection?]
|
||||||
|
(let [handlers (if move-selection? selected-handlers #{primary})
|
||||||
|
handlers (into #{}
|
||||||
|
(filter (fn [[index _]]
|
||||||
|
(= :curve-to (:command (nth content index nil)))))
|
||||||
|
handlers)]
|
||||||
|
(cond-> handlers
|
||||||
|
(empty? handlers) (conj primary))))
|
||||||
|
|
||||||
|
(defn- handler-drag-modifiers
|
||||||
|
"Returns modifiers for one dragged handler."
|
||||||
|
[content handler-types selected-handlers start-modifiers move-delta mode
|
||||||
|
move-selection? [index prefix]]
|
||||||
|
(let [start-delta (handler-modifier-delta start-modifiers index prefix)
|
||||||
|
delta (gpt/add start-delta move-delta)
|
||||||
|
opposite-id (path/opposite-index content index prefix)
|
||||||
|
opposite-selected? (and move-selection?
|
||||||
|
(contains? selected-handlers opposite-id))
|
||||||
|
joined? (helpers/handlers-joined? content index prefix)
|
||||||
|
handler-mode (if move-selection?
|
||||||
|
(stored-handler-drag-mode
|
||||||
|
content handler-types index prefix)
|
||||||
|
mode)
|
||||||
|
modifiers (case handler-mode
|
||||||
|
:aligned
|
||||||
|
(helpers/align-handler-modifiers
|
||||||
|
content index prefix (:x delta) (:y delta))
|
||||||
|
|
||||||
|
:mirror
|
||||||
|
(helpers/move-handler-modifiers
|
||||||
|
content index prefix true true true (:x delta) (:y delta))
|
||||||
|
|
||||||
|
:independent
|
||||||
|
(helpers/move-handler-modifiers
|
||||||
|
content index prefix false false false (:x delta) (:y delta))
|
||||||
|
|
||||||
|
(helpers/move-handler-modifiers
|
||||||
|
content index prefix false
|
||||||
|
(and joined? (not opposite-selected?))
|
||||||
|
false (:x delta) (:y delta)))]
|
||||||
|
(cond-> modifiers
|
||||||
|
opposite-selected? (remove-handler-modifier opposite-id))))
|
||||||
|
|
||||||
|
(defn- selected-handler-modifiers
|
||||||
|
"Combines modifiers for all dragged handlers."
|
||||||
|
[content handler-types selected-handlers start-modifiers move-delta mode move-selection?]
|
||||||
|
(reduce
|
||||||
|
(fn [modifiers handler-id]
|
||||||
|
(d/deep-merge
|
||||||
|
modifiers
|
||||||
|
(handler-drag-modifiers
|
||||||
|
content handler-types selected-handlers start-modifiers move-delta
|
||||||
|
mode move-selection? handler-id)))
|
||||||
|
{}
|
||||||
|
selected-handlers))
|
||||||
|
|
||||||
|
(defn- transient-prev-handler
|
||||||
|
"Returns the mirrored transient drawing handler."
|
||||||
|
[content [index prefix] handler-mode moving-handler edit-mode prev-handler]
|
||||||
|
(when (and (= edit-mode :draw)
|
||||||
|
(= prefix :c2)
|
||||||
|
(= index (dec (count content)))
|
||||||
|
(some? prev-handler)
|
||||||
|
(not= handler-mode :independent))
|
||||||
|
(let [node (path/handler->node content index prefix)
|
||||||
|
mode (if (= handler-mode :mirror) :mirror :aligned)]
|
||||||
|
(helpers/opposite-handler-target node moving-handler prev-handler mode))))
|
||||||
|
|
||||||
|
(defn modify-selected-handlers
|
||||||
|
"Moves selected handlers using each node's handler mode."
|
||||||
|
[id primary start-modifiers dx dy mode move-selection?]
|
||||||
|
(ptk/reify ::modify-selected-handlers
|
||||||
ptk/UpdateEvent
|
ptk/UpdateEvent
|
||||||
(update [_ state]
|
(update [_ state]
|
||||||
|
(let [content (st/get-path state :content)
|
||||||
(let [content (st/get-path state :content)
|
handler-types (dm/get-in state
|
||||||
modifiers (helpers/move-handler-modifiers content index prefix false match-opposite? dx dy)
|
[:workspace-local :edit-path id :handler-types]
|
||||||
[cx cy] (if (= prefix :c1) [:c1x :c1y] [:c2x :c2y])
|
{})
|
||||||
point (gpt/point (+ (dm/get-in content [index :params cx]) dx)
|
selected-handlers (active-selected-handlers
|
||||||
(+ (dm/get-in content [index :params cy]) dy))]
|
content primary
|
||||||
|
(dm/get-in state
|
||||||
|
[:workspace-local :edit-path id :selection :handlers]
|
||||||
|
#{})
|
||||||
|
move-selection?)
|
||||||
|
move-delta (gpt/point dx dy)
|
||||||
|
moved-modifiers (selected-handler-modifiers
|
||||||
|
content handler-types selected-handlers start-modifiers
|
||||||
|
move-delta mode move-selection?)
|
||||||
|
modifiers (d/deep-merge start-modifiers moved-modifiers)
|
||||||
|
[primary-index primary-prefix] primary
|
||||||
|
primary-mode (if move-selection?
|
||||||
|
(stored-handler-drag-mode
|
||||||
|
content handler-types primary-index primary-prefix)
|
||||||
|
mode)
|
||||||
|
primary-handler (path/get-handler-point content primary-index primary-prefix)
|
||||||
|
primary-delta (gpt/add
|
||||||
|
(handler-modifier-delta start-modifiers
|
||||||
|
primary-index
|
||||||
|
primary-prefix)
|
||||||
|
move-delta)
|
||||||
|
moving-handler (gpt/add primary-handler primary-delta)
|
||||||
|
edit-mode (dm/get-in state [:workspace-local :edit-path id :edit-mode])
|
||||||
|
prev-handler (dm/get-in state [:workspace-local :edit-path id :prev-handler])
|
||||||
|
new-prev-handler (transient-prev-handler
|
||||||
|
content primary primary-mode moving-handler
|
||||||
|
edit-mode prev-handler)]
|
||||||
(-> state
|
(-> state
|
||||||
(update-in [:workspace-local :edit-path id :content-modifiers] merge modifiers)
|
(assoc-in [:workspace-local :edit-path id :content-modifiers] modifiers)
|
||||||
(assoc-in [:workspace-local :edit-path id :moving-handler] point))))))
|
(assoc-in [:workspace-local :edit-path id :moving-handler] moving-handler)
|
||||||
|
(cond-> (some? new-prev-handler)
|
||||||
|
(assoc-in [:workspace-local :edit-path id :prev-handler] new-prev-handler)))))))
|
||||||
|
|
||||||
|
(defn- apply-content-modifiers*
|
||||||
|
[id new-content]
|
||||||
|
(ptk/reify ::apply-content-modifiers*
|
||||||
|
ptk/UpdateEvent
|
||||||
|
(update [_ state]
|
||||||
|
(cond-> (-> state
|
||||||
|
(st/set-content new-content)
|
||||||
|
(update-in [:workspace-local :edit-path id]
|
||||||
|
dissoc
|
||||||
|
:content-modifiers
|
||||||
|
:moving-nodes
|
||||||
|
:moving-handler))
|
||||||
|
(seq new-content)
|
||||||
|
(update-in (st/get-path-location state) path/update-geometry)))
|
||||||
|
|
||||||
|
ptk/WatchEvent
|
||||||
|
(watch [_ _ _]
|
||||||
|
;; Moving modifiers keep node indices stable.
|
||||||
|
(when (empty? new-content)
|
||||||
|
(rx/of (dwe/clear-edition-mode))))))
|
||||||
|
|
||||||
(defn apply-content-modifiers []
|
(defn apply-content-modifiers []
|
||||||
(ptk/reify ::apply-content-modifiers
|
(ptk/reify ::apply-content-modifiers
|
||||||
ptk/WatchEvent
|
ptk/WatchEvent
|
||||||
(watch [it state _]
|
(watch [_ state _]
|
||||||
(let [id (st/get-path-id state)
|
(let [id (st/get-path-id state)
|
||||||
shape (st/get-path state)
|
shape (st/get-path state)
|
||||||
|
|
||||||
@ -52,24 +198,47 @@
|
|||||||
(dm/get-in state [:workspace-local :edit-path id :content-modifiers])]
|
(dm/get-in state [:workspace-local :edit-path id :content-modifiers])]
|
||||||
(if (or (nil? shape) (nil? content-modifiers))
|
(if (or (nil? shape) (nil? content-modifiers))
|
||||||
(rx/of (dwe/clear-edition-mode))
|
(rx/of (dwe/clear-edition-mode))
|
||||||
(let [page-id (get state :current-page-id state)
|
(let [content (get shape :content)
|
||||||
objects (dsh/lookup-page-objects state)
|
new-content (path/apply-content-modifiers content content-modifiers)]
|
||||||
|
(when (some? new-content)
|
||||||
|
(rx/of (apply-content-modifiers* id new-content)))))))))
|
||||||
|
|
||||||
content (get shape :content)
|
(def ^:private merge-drop-distance
|
||||||
new-content (path/apply-content-modifiers content content-modifiers)
|
"Maximum screen distance for merging dropped nodes."
|
||||||
|
10)
|
||||||
|
|
||||||
old-points (path/get-points content)
|
(defn merge-dragged-on-drop
|
||||||
new-points (path/get-points new-content)
|
"Merges the closest moved and stationary nodes after a drag."
|
||||||
point-change (->> (map hash-map old-points new-points) (reduce merge))]
|
[]
|
||||||
|
(ptk/reify ::merge-dragged-on-drop
|
||||||
|
ptk/WatchEvent
|
||||||
|
(watch [_ state _]
|
||||||
|
(let [id (st/get-path-id state)
|
||||||
|
content (st/get-path state :content)
|
||||||
|
selection (st/get-selection state id)
|
||||||
|
|
||||||
(when (and (some? new-content) (some? shape))
|
;; Include endpoints of selected segments.
|
||||||
(let [changes (changes/generate-path-changes it objects page-id shape (:content shape) new-content)]
|
moved-indices (into (get selection :nodes #{})
|
||||||
(if (empty? new-content)
|
(helpers/segment-node-indices content (get selection :segments #{})))
|
||||||
(rx/of (dch/commit-changes changes)
|
moved (helpers/node-positions content moved-indices)
|
||||||
(dwe/clear-edition-mode))
|
moved-set (set moved)
|
||||||
(rx/of (dch/commit-changes changes)
|
|
||||||
(selection/update-selection point-change)
|
zoom (dm/get-in state [:workspace-local :zoom] 1)
|
||||||
(fn [state] (update-in state [:workspace-local :edit-path id] dissoc :content-modifiers :moving-nodes :moving-handler))))))))))))
|
threshold (/ merge-drop-distance zoom)
|
||||||
|
others (remove moved-set (path/get-points content))
|
||||||
|
|
||||||
|
pairs (->> moved
|
||||||
|
(keep (fn [p]
|
||||||
|
(let [near (filter #(<= (gpt/distance % p) threshold) others)]
|
||||||
|
(when (seq near)
|
||||||
|
(let [t (apply min-key #(gpt/distance % p) near)]
|
||||||
|
[p t (gpt/distance t p)]))))))
|
||||||
|
best (when (seq pairs)
|
||||||
|
(apply min-key #(nth % 2) pairs))]
|
||||||
|
(if (some? best)
|
||||||
|
(let [[p t _] best]
|
||||||
|
(rx/of (tools/process-path-tool #{p t} path/merge-nodes)))
|
||||||
|
(rx/empty))))))
|
||||||
|
|
||||||
(defn modify-content-point
|
(defn modify-content-point
|
||||||
[content {dx :x dy :y} modifiers point]
|
[content {dx :x dy :y} modifiers point]
|
||||||
@ -93,59 +262,184 @@
|
|||||||
(reduce modify-handler $ handler-indices))))
|
(reduce modify-handler $ handler-indices))))
|
||||||
|
|
||||||
(defn set-move-modifier
|
(defn set-move-modifier
|
||||||
[points move-modifier]
|
"Adds a move delta for selected nodes and handlers."
|
||||||
|
[points handler-ids move-modifier]
|
||||||
(ptk/reify ::set-modifiers
|
(ptk/reify ::set-modifiers
|
||||||
ptk/UpdateEvent
|
ptk/UpdateEvent
|
||||||
(update [_ state]
|
(update [_ state]
|
||||||
(let [id (st/get-path-id state)
|
(let [id (st/get-path-id state)
|
||||||
content (st/get-path state :content)
|
content (st/get-path state :content)
|
||||||
modifiers-reducer (partial modify-content-point content move-modifier)
|
{dx :x dy :y} move-modifier
|
||||||
|
|
||||||
content-modifiers (dm/get-in state [:workspace-local :edit-path id :content-modifiers] {})
|
content-modifiers (dm/get-in state [:workspace-local :edit-path id :content-modifiers] {})
|
||||||
content-modifiers (->> points
|
|
||||||
(reduce modifiers-reducer content-modifiers))]
|
content-modifiers
|
||||||
|
(->> points
|
||||||
|
(reduce (partial modify-content-point content move-modifier) content-modifiers))
|
||||||
|
|
||||||
|
content-modifiers
|
||||||
|
(->> handler-ids
|
||||||
|
(reduce (fn [modifiers [index prefix]]
|
||||||
|
(let [cx (d/prefix-keyword prefix :x)
|
||||||
|
cy (d/prefix-keyword prefix :y)]
|
||||||
|
(update modifiers index assoc cx dx cy dy)))
|
||||||
|
content-modifiers))]
|
||||||
|
|
||||||
(-> state
|
(-> state
|
||||||
(assoc-in [:workspace-local :edit-path id :content-modifiers] content-modifiers))))))
|
(assoc-in [:workspace-local :edit-path id :content-modifiers] content-modifiers))))))
|
||||||
|
|
||||||
|
(defn- move-node-indices
|
||||||
|
[state node-indices from-point to-point]
|
||||||
|
(let [id (st/get-path-id state)
|
||||||
|
content (st/get-path state :content)
|
||||||
|
to-point (cond-> to-point
|
||||||
|
(:shift? to-point) (path.helpers/position-fixed-angle from-point))
|
||||||
|
delta (gpt/subtract to-point from-point)
|
||||||
|
points (helpers/node-positions content node-indices)
|
||||||
|
reducer (partial modify-content-point content delta)
|
||||||
|
modifiers (dm/get-in state [:workspace-local :edit-path id :content-modifiers] {})
|
||||||
|
modifiers (reduce reducer modifiers points)]
|
||||||
|
(-> state
|
||||||
|
(assoc-in [:workspace-local :edit-path id :moving-nodes] true)
|
||||||
|
(assoc-in [:workspace-local :edit-path id :content-modifiers] modifiers))))
|
||||||
|
|
||||||
(defn move-selected-path-point [from-point to-point]
|
(defn move-selected-path-point [from-point to-point]
|
||||||
(ptk/reify ::move-point
|
(ptk/reify ::move-point
|
||||||
ptk/UpdateEvent
|
ptk/UpdateEvent
|
||||||
(update [_ state]
|
(update [_ state]
|
||||||
(let [id (st/get-path-id state)
|
(let [id (st/get-path-id state)
|
||||||
content (st/get-path state :content)
|
selected-nodes (dm/get-in state
|
||||||
to-point (cond-> to-point
|
[:workspace-local :edit-path id :selection :nodes]
|
||||||
(:shift? to-point) (path.helpers/position-fixed-angle from-point))
|
#{})]
|
||||||
|
(move-node-indices state selected-nodes from-point to-point)))))
|
||||||
|
|
||||||
delta (gpt/subtract to-point from-point)
|
(defn move-selected-path-segment [from-point to-point]
|
||||||
|
(ptk/reify ::move-segment
|
||||||
|
ptk/UpdateEvent
|
||||||
|
(update [_ state]
|
||||||
|
(let [id (st/get-path-id state)
|
||||||
|
content (st/get-path state :content)
|
||||||
|
selection (st/get-selection state id)
|
||||||
|
node-indices (helpers/selected-node-indices content selection)]
|
||||||
|
(move-node-indices state node-indices from-point to-point)))))
|
||||||
|
|
||||||
modifiers-reducer (partial modify-content-point content delta)
|
(defn- clear-drag-cursor []
|
||||||
|
(ptk/reify ::clear-drag-cursor
|
||||||
|
ptk/UpdateEvent
|
||||||
|
(update [_ state]
|
||||||
|
(let [id (st/get-path-id state)]
|
||||||
|
(d/update-in-when state [:workspace-local :edit-path id] dissoc :drag-cursor)))))
|
||||||
|
|
||||||
points (dm/get-in state [:workspace-local :edit-path id :selected-points] #{})
|
(defn set-drag-cursor
|
||||||
|
"Shows `cursor` until the current drag stops."
|
||||||
|
[cursor]
|
||||||
|
(ptk/reify ::set-drag-cursor
|
||||||
|
ptk/UpdateEvent
|
||||||
|
(update [_ state]
|
||||||
|
(let [id (st/get-path-id state)]
|
||||||
|
(d/update-in-when state [:workspace-local :edit-path id] assoc :drag-cursor cursor)))
|
||||||
|
|
||||||
modifiers (dm/get-in state [:workspace-local :edit-path id :content-modifiers] {})
|
ptk/WatchEvent
|
||||||
modifiers (->> points
|
(watch [_ _ stream]
|
||||||
(reduce modifiers-reducer modifiers))]
|
(->> (rx/merge
|
||||||
|
(mse/drag-stopper stream)
|
||||||
(-> state
|
(rx/filter streams/finish-edition? stream))
|
||||||
(assoc-in [:workspace-local :edit-path id :moving-nodes] true)
|
(rx/take 1)
|
||||||
(assoc-in [:workspace-local :edit-path id :content-modifiers] modifiers))))))
|
(rx/map #(clear-drag-cursor))))))
|
||||||
|
|
||||||
(declare drag-selected-points)
|
(declare drag-selected-points)
|
||||||
|
|
||||||
|
(def ^:private duplicate-screen-offset 10)
|
||||||
|
|
||||||
|
(defn duplicate-offset
|
||||||
|
"Returns a duplicate offset that stays constant in screen pixels."
|
||||||
|
[zoom]
|
||||||
|
(let [step (/ duplicate-screen-offset zoom)]
|
||||||
|
(gpt/point step step)))
|
||||||
|
|
||||||
|
(defn splice-duplicated
|
||||||
|
"Adds duplicate subpaths and selects their new nodes."
|
||||||
|
[{:keys [sub selected]}]
|
||||||
|
(ptk/reify ::splice-duplicated
|
||||||
|
ptk/UpdateEvent
|
||||||
|
(update [_ state]
|
||||||
|
(let [id (st/get-path-id state)]
|
||||||
|
(if (and (some? id) (seq sub))
|
||||||
|
(let [content (st/get-path state :content)
|
||||||
|
base (count content)
|
||||||
|
new-content (path/splice-content content sub)
|
||||||
|
pasted (into #{} (map #(+ base %)) selected)]
|
||||||
|
(-> state
|
||||||
|
(st/set-content new-content)
|
||||||
|
(update-in (st/get-path-location state) path/update-geometry)
|
||||||
|
(assoc-in [:workspace-local :edit-path id :selection]
|
||||||
|
(assoc helpers/empty-selection :nodes pasted))))
|
||||||
|
state)))))
|
||||||
|
|
||||||
|
(defn- duplicate-and-drag
|
||||||
|
"Duplicates the selection and drags the copy from `start-position`."
|
||||||
|
[start-position]
|
||||||
|
(ptk/reify ::duplicate-and-drag
|
||||||
|
ptk/WatchEvent
|
||||||
|
(watch [_ state _]
|
||||||
|
(let [id (st/get-path-id state)
|
||||||
|
content (st/get-path state :content)
|
||||||
|
selection (st/get-selection state id)
|
||||||
|
zoom (dm/get-in state [:workspace-local :zoom] 1)
|
||||||
|
result (helpers/duplicate-selection-content
|
||||||
|
content selection (duplicate-offset zoom))]
|
||||||
|
(if (seq (:sub result))
|
||||||
|
(rx/of (splice-duplicated result)
|
||||||
|
(drag-selected-points start-position))
|
||||||
|
(rx/of (drag-selected-points start-position)))))))
|
||||||
|
|
||||||
|
(declare curve-config-node-drag)
|
||||||
|
|
||||||
(defn start-move-path-point
|
(defn start-move-path-point
|
||||||
[position shift?]
|
"Handles node clicks and drags in move mode."
|
||||||
|
[index shift? alt? mod?]
|
||||||
(ptk/reify ::start-move-path-point
|
(ptk/reify ::start-move-path-point
|
||||||
ptk/WatchEvent
|
ptk/WatchEvent
|
||||||
(watch [_ state _]
|
(watch [_ state _]
|
||||||
(let [id (dm/get-in state [:workspace-local :edition])
|
(let [id (st/get-path-id state)
|
||||||
selected-points (dm/get-in state [:workspace-local :edit-path id :selected-points] #{})
|
selected-nodes (get (st/get-selection state id) :nodes #{})
|
||||||
selected? (contains? selected-points position)]
|
selected? (contains? selected-nodes index)
|
||||||
(streams/drag-stream
|
content (st/get-path state :content)
|
||||||
(rx/of
|
position (when (and (some? content)
|
||||||
(dwsh/update-shapes [id] path/convert-to-path)
|
(< index (count content))
|
||||||
(when-not selected? (selection/select-node position shift?))
|
(helpers/node? content index))
|
||||||
(drag-selected-points @ms/mouse-position))
|
(helpers/node-position content index))]
|
||||||
(rx/of (selection/select-node position shift?)))))))
|
(cond
|
||||||
|
(and mod? alt?)
|
||||||
|
(streams/drag-stream
|
||||||
|
(rx/empty)
|
||||||
|
(if (some? position)
|
||||||
|
(rx/of (tools/remove-node-with-segments index))
|
||||||
|
(rx/empty)))
|
||||||
|
|
||||||
|
mod?
|
||||||
|
(streams/drag-stream
|
||||||
|
(rx/of (set-drag-cursor "move-handles")
|
||||||
|
(curve-config-node-drag index))
|
||||||
|
(rx/of (tools/toggle-node-curve index)))
|
||||||
|
|
||||||
|
alt?
|
||||||
|
(streams/drag-stream
|
||||||
|
(rx/of
|
||||||
|
(set-drag-cursor "move-copy")
|
||||||
|
(when-not selected? (selection/select-node index false))
|
||||||
|
(duplicate-and-drag @ms/mouse-position))
|
||||||
|
(if (some? position)
|
||||||
|
(rx/of (tools/remove-node position))
|
||||||
|
(rx/of (selection/select-node index false))))
|
||||||
|
|
||||||
|
:else
|
||||||
|
(streams/drag-stream
|
||||||
|
(rx/of
|
||||||
|
(set-drag-cursor "move-move")
|
||||||
|
(when-not selected? (selection/select-node index shift?))
|
||||||
|
(drag-selected-points @ms/mouse-position))
|
||||||
|
(rx/of (selection/select-node index shift?))))))))
|
||||||
|
|
||||||
(defn drag-selected-points
|
(defn drag-selected-points
|
||||||
[start-position]
|
[start-position]
|
||||||
@ -156,11 +450,13 @@
|
|||||||
|
|
||||||
id (dm/get-in state [:workspace-local :edition])
|
id (dm/get-in state [:workspace-local :edition])
|
||||||
|
|
||||||
selected-points (dm/get-in state [:workspace-local :edit-path id :selected-points] #{})
|
content (st/get-path state :content)
|
||||||
|
|
||||||
|
selected-nodes (get (st/get-selection state id) :nodes #{})
|
||||||
|
selected-points (helpers/node-positions content selected-nodes)
|
||||||
|
|
||||||
start-position (apply min-key #(gpt/distance start-position %) selected-points)
|
start-position (apply min-key #(gpt/distance start-position %) selected-points)
|
||||||
|
|
||||||
content (st/get-path state :content)
|
|
||||||
points (path/get-points content)]
|
points (path/get-points content)]
|
||||||
|
|
||||||
(rx/concat
|
(rx/concat
|
||||||
@ -169,8 +465,216 @@
|
|||||||
(streams/move-points-stream start-position selected-points)
|
(streams/move-points-stream start-position selected-points)
|
||||||
(rx/map #(move-selected-path-point start-position %))
|
(rx/map #(move-selected-path-point start-position %))
|
||||||
(rx/take-until stopper))
|
(rx/take-until stopper))
|
||||||
|
(rx/of (apply-content-modifiers)
|
||||||
|
(merge-dragged-on-drop)))))))
|
||||||
|
|
||||||
|
(declare drag-selected-segments)
|
||||||
|
(declare bend-selected-segment)
|
||||||
|
(declare create-node-at-position)
|
||||||
|
|
||||||
|
(defn start-move-path-segment
|
||||||
|
"Handles segment clicks and drags in move mode."
|
||||||
|
[index shift? alt? mod?]
|
||||||
|
(ptk/reify ::start-move-path-segment
|
||||||
|
ptk/WatchEvent
|
||||||
|
(watch [_ state _]
|
||||||
|
(let [id (st/get-path-id state)
|
||||||
|
zoom (dm/get-in state [:workspace-local :zoom] 1)
|
||||||
|
content (st/get-path state :content)
|
||||||
|
selection (st/get-selection state id)
|
||||||
|
selected-segments (get selection :segments #{})
|
||||||
|
;; Both selected endpoints also select their segment for dragging.
|
||||||
|
segment-ends (helpers/segment-node-indices content #{index})
|
||||||
|
selected? (or (contains? selected-segments index)
|
||||||
|
(and (seq segment-ends)
|
||||||
|
(every? (get selection :nodes #{}) segment-ends)))
|
||||||
|
position @ms/mouse-position
|
||||||
|
threshold (/ helpers/segment-insert-threshold zoom)]
|
||||||
|
(cond
|
||||||
|
(and mod? alt?)
|
||||||
|
(streams/drag-stream
|
||||||
|
(rx/empty)
|
||||||
|
(rx/of (tools/remove-segment index)))
|
||||||
|
|
||||||
|
mod?
|
||||||
|
(let [entry (d/seek #(= index (:index %)) (helpers/segment-entries content))
|
||||||
|
bend? (and (some? entry)
|
||||||
|
(not= :close-path (:command (:segment entry))))]
|
||||||
|
(streams/drag-stream
|
||||||
|
(rx/of (set-drag-cursor "move-curve")
|
||||||
|
(if bend?
|
||||||
|
(bend-selected-segment index position)
|
||||||
|
(drag-selected-segments position)))
|
||||||
|
(rx/of (tools/toggle-segment-curve index))))
|
||||||
|
|
||||||
|
alt?
|
||||||
|
(let [insert-point (helpers/insertion-point content position threshold true)]
|
||||||
|
(streams/drag-stream
|
||||||
|
(rx/of
|
||||||
|
(set-drag-cursor "move-copy")
|
||||||
|
(when-not selected? (selection/select-segment index false))
|
||||||
|
(duplicate-and-drag position))
|
||||||
|
(if (some? insert-point)
|
||||||
|
(rx/of (create-node-at-position (meta insert-point)))
|
||||||
|
(rx/of (selection/select-segment index false)))))
|
||||||
|
|
||||||
|
:else
|
||||||
|
(let [insert-point (when-not shift?
|
||||||
|
(helpers/insertion-point content position threshold false))
|
||||||
|
click-event (if (some? insert-point)
|
||||||
|
(create-node-at-position (meta insert-point))
|
||||||
|
(selection/select-segment index shift?))]
|
||||||
|
(streams/drag-stream
|
||||||
|
(rx/of
|
||||||
|
(set-drag-cursor "move-move")
|
||||||
|
(when-not selected? (selection/select-segment index shift?))
|
||||||
|
(drag-selected-segments position))
|
||||||
|
(rx/of click-event))))))))
|
||||||
|
|
||||||
|
(defn- segment-entry
|
||||||
|
[content index]
|
||||||
|
(d/seek #(= index (:index %)) (helpers/segment-entries content)))
|
||||||
|
|
||||||
|
(defn drag-selected-segments
|
||||||
|
[start-position]
|
||||||
|
(ptk/reify ::drag-selected-segments
|
||||||
|
ptk/WatchEvent
|
||||||
|
(watch [_ state stream]
|
||||||
|
(let [stopper (mse/drag-stopper stream)
|
||||||
|
id (dm/get-in state [:workspace-local :edition])
|
||||||
|
content (st/get-path state :content)
|
||||||
|
selection (st/get-selection state id)
|
||||||
|
node-indices (helpers/selected-node-indices content selection)
|
||||||
|
selected-points (helpers/node-positions content node-indices)
|
||||||
|
points (path/get-points content)]
|
||||||
|
(if (empty? selected-points)
|
||||||
|
(rx/empty)
|
||||||
|
(rx/concat
|
||||||
|
(->> points
|
||||||
|
(streams/move-points-stream start-position selected-points)
|
||||||
|
(rx/map #(move-selected-path-segment start-position %))
|
||||||
|
(rx/take-until stopper))
|
||||||
|
(rx/of (apply-content-modifiers)
|
||||||
|
(merge-dragged-on-drop))))))))
|
||||||
|
|
||||||
|
(defn bend-segment-modifier
|
||||||
|
"Bends segment `index` so its point at `t` reaches `target`."
|
||||||
|
[index base-curve t target]
|
||||||
|
(ptk/reify ::bend-segment-modifier
|
||||||
|
ptk/UpdateEvent
|
||||||
|
(update [_ state]
|
||||||
|
(let [id (st/get-path-id state)
|
||||||
|
deltas (path.helpers/bend-curve-deltas base-curve t target)
|
||||||
|
modifiers (dm/get-in state [:workspace-local :edit-path id :content-modifiers] {})]
|
||||||
|
(assoc-in state [:workspace-local :edit-path id :content-modifiers]
|
||||||
|
(assoc modifiers index deltas))))))
|
||||||
|
|
||||||
|
(defn bend-selected-segment
|
||||||
|
[index start-position]
|
||||||
|
(ptk/reify ::bend-selected-segment
|
||||||
|
ptk/WatchEvent
|
||||||
|
(watch [_ state stream]
|
||||||
|
(let [stopper (mse/drag-stopper stream)
|
||||||
|
content (st/get-path state :content)
|
||||||
|
entry (segment-entry content index)
|
||||||
|
base-curve (path.helpers/entry->bezier entry)
|
||||||
|
;; Keep the grabbed curve parameter fixed during the drag.
|
||||||
|
t (path.helpers/curve-closest-t base-curve start-position 0.001)]
|
||||||
|
(rx/concat
|
||||||
|
(->> ms/mouse-position
|
||||||
|
(rx/filter gpt/point?)
|
||||||
|
(rx/map streams/to-pixel-snap)
|
||||||
|
(rx/map #(bend-segment-modifier index base-curve t %))
|
||||||
|
(rx/take-until stopper))
|
||||||
(rx/of (apply-content-modifiers)))))))
|
(rx/of (apply-content-modifiers)))))))
|
||||||
|
|
||||||
|
(defn- curve-config-modifier
|
||||||
|
"Pulls out smooth node handles toward `position`."
|
||||||
|
[node in-index in-base in-neighbour out-index out-base out-neighbour position]
|
||||||
|
(ptk/reify ::curve-config-modifier
|
||||||
|
ptk/UpdateEvent
|
||||||
|
(update [_ state]
|
||||||
|
(let [id (st/get-path-id state)
|
||||||
|
v (gpt/to-vec node position)
|
||||||
|
both? (and (some? in-index) (some? out-index))
|
||||||
|
|
||||||
|
;; Pick which handle follows the pointer from the drag direction.
|
||||||
|
ref (when (and (some? in-neighbour) (some? out-neighbour))
|
||||||
|
(gpt/subtract (gpt/unit (gpt/to-vec node out-neighbour))
|
||||||
|
(gpt/unit (gpt/to-vec node in-neighbour))))
|
||||||
|
s (if (and both? (some? ref) (neg? (gpt/dot v ref))) -1 1)
|
||||||
|
|
||||||
|
out-handle (if both? (gpt/add node (gpt/scale v s)) (gpt/add node v))
|
||||||
|
in-handle (if both? (gpt/subtract node (gpt/scale v s)) (gpt/add node v))
|
||||||
|
|
||||||
|
modifiers (dm/get-in state [:workspace-local :edit-path id :content-modifiers] {})
|
||||||
|
modifiers (cond-> modifiers
|
||||||
|
(some? in-index)
|
||||||
|
(assoc in-index
|
||||||
|
{:c2x (- (:x in-handle) (:x in-base))
|
||||||
|
:c2y (- (:y in-handle) (:y in-base))})
|
||||||
|
|
||||||
|
(some? out-index)
|
||||||
|
(assoc out-index
|
||||||
|
{:c1x (- (:x out-handle) (:x out-base))
|
||||||
|
:c1y (- (:y out-handle) (:y out-base))}))]
|
||||||
|
(assoc-in state [:workspace-local :edit-path id :content-modifiers] modifiers)))))
|
||||||
|
|
||||||
|
(defn curve-config-node-drag
|
||||||
|
"Replaces a node's handles with a smooth mirrored pair during a drag."
|
||||||
|
[index]
|
||||||
|
(ptk/reify ::curve-config-node-drag
|
||||||
|
ptk/WatchEvent
|
||||||
|
(watch [_ state stream]
|
||||||
|
(let [content (st/get-path state :content)
|
||||||
|
node (when (and (some? content)
|
||||||
|
(< index (count content))
|
||||||
|
(helpers/node? content index))
|
||||||
|
(helpers/node-position content index))
|
||||||
|
in-cmd (nth content index nil)
|
||||||
|
out-cmd (nth content (inc index) nil)
|
||||||
|
in? (contains? #{:line-to :curve-to} (:command in-cmd))
|
||||||
|
out? (contains? #{:line-to :curve-to} (:command out-cmd))
|
||||||
|
;; New curve handles start at the node.
|
||||||
|
in-base (when in?
|
||||||
|
(if (= :curve-to (:command in-cmd))
|
||||||
|
(path/get-handler in-cmd :c2)
|
||||||
|
node))
|
||||||
|
out-base (when out?
|
||||||
|
(if (= :curve-to (:command out-cmd))
|
||||||
|
(path/get-handler out-cmd :c1)
|
||||||
|
node))
|
||||||
|
;; Neighbours keep handles on their matching leg.
|
||||||
|
in-neighbour (when in? (helpers/node-position content (dec index)))
|
||||||
|
out-neighbour (when out? (helpers/node-position content (inc index)))
|
||||||
|
stopper (rx/merge
|
||||||
|
(mse/drag-stopper stream)
|
||||||
|
(->> stream
|
||||||
|
(rx/filter streams/finish-edition?)))]
|
||||||
|
(if (and (some? node) (or in? out?))
|
||||||
|
(rx/concat
|
||||||
|
(->> ms/mouse-position
|
||||||
|
(rx/filter gpt/point?)
|
||||||
|
;; Apply Shift changes without waiting for pointer movement.
|
||||||
|
(rx/combine-latest-with ms/keyboard-shift)
|
||||||
|
(rx/map (fn [[position shift?]]
|
||||||
|
(assoc position :shift? shift?)))
|
||||||
|
(rx/map
|
||||||
|
(fn [{:keys [x y shift?]}]
|
||||||
|
(let [position (cond-> (gpt/point x y)
|
||||||
|
shift? (path.helpers/position-fixed-angle node))]
|
||||||
|
(curve-config-modifier node
|
||||||
|
(when in? index)
|
||||||
|
in-base
|
||||||
|
in-neighbour
|
||||||
|
(when out? (inc index))
|
||||||
|
out-base
|
||||||
|
out-neighbour
|
||||||
|
position))))
|
||||||
|
(rx/take-until stopper))
|
||||||
|
(rx/of (apply-content-modifiers)))
|
||||||
|
(rx/empty))))))
|
||||||
|
|
||||||
(defn- get-displacement
|
(defn- get-displacement
|
||||||
"Retrieve the correct displacement delta point for the
|
"Retrieve the correct displacement delta point for the
|
||||||
provided direction speed and distances thresholds."
|
provided direction speed and distances thresholds."
|
||||||
@ -213,7 +717,20 @@
|
|||||||
current-move (dm/get-in state [:workspace-local :edit-path id :current-move])]
|
current-move (dm/get-in state [:workspace-local :edit-path id :current-move])]
|
||||||
;; id can be null if we just selected the tool but we didn't start drawing
|
;; id can be null if we just selected the tool but we didn't start drawing
|
||||||
(if (and id (= same-event current-move))
|
(if (and id (= same-event current-move))
|
||||||
(let [points (dm/get-in state [:workspace-local :edit-path id :selected-points] #{})
|
(let [content (st/get-path state :content)
|
||||||
|
selection (st/get-selection state id)
|
||||||
|
selected-nodes (get selection :nodes #{})
|
||||||
|
selected-segments (get selection :segments #{})
|
||||||
|
selected-handlers (get selection :handlers #{})
|
||||||
|
|
||||||
|
;; Move nodes rigidly and handlers independently.
|
||||||
|
node-indices (into selected-nodes
|
||||||
|
(helpers/segment-node-indices content selected-segments))
|
||||||
|
points (helpers/node-positions content node-indices)
|
||||||
|
handler-ids (into #{}
|
||||||
|
(filter (fn [[index _]]
|
||||||
|
(= :curve-to (:command (nth content index nil)))))
|
||||||
|
selected-handlers)
|
||||||
|
|
||||||
move-events (->> stream
|
move-events (->> stream
|
||||||
(rx/filter (ptk/type? ::move-selected))
|
(rx/filter (ptk/type? ::move-selected))
|
||||||
@ -226,12 +743,11 @@
|
|||||||
mov-vec (gpt/multiply (get-displacement direction) scale)]
|
mov-vec (gpt/multiply (get-displacement direction) scale)]
|
||||||
|
|
||||||
(rx/concat
|
(rx/concat
|
||||||
(rx/of (dwsh/update-shapes [id] path/convert-to-path))
|
|
||||||
(rx/merge
|
(rx/merge
|
||||||
(->> move-events
|
(->> move-events
|
||||||
(rx/take-until stopper)
|
(rx/take-until stopper)
|
||||||
(rx/scan #(gpt/add %1 mov-vec) (gpt/point 0 0))
|
(rx/scan #(gpt/add %1 mov-vec) (gpt/point 0 0))
|
||||||
(rx/map #(set-move-modifier points %)))
|
(rx/map #(set-move-modifier points handler-ids %)))
|
||||||
|
|
||||||
;; First event is not read by the stream so we need to send it again
|
;; First event is not read by the stream so we need to send it again
|
||||||
(rx/of (move-selected direction shift?)))
|
(rx/of (move-selected direction shift?)))
|
||||||
@ -240,53 +756,131 @@
|
|||||||
(finish-move-selected))))
|
(finish-move-selected))))
|
||||||
(rx/empty)))))))
|
(rx/empty)))))))
|
||||||
|
|
||||||
|
(declare drag-selected-handlers)
|
||||||
|
|
||||||
|
(defn- handler-drag-mode
|
||||||
|
"Returns the live handler matching mode for a drag."
|
||||||
|
[plain-mode mod? alt?]
|
||||||
|
(cond
|
||||||
|
(and mod? alt?) :aligned
|
||||||
|
mod? :mirror
|
||||||
|
alt? :independent
|
||||||
|
:else plain-mode))
|
||||||
|
|
||||||
|
(defn- handler-drag-cursor
|
||||||
|
[mod? alt?]
|
||||||
|
(if (or mod? alt?) "move-handles" "move-move"))
|
||||||
|
|
||||||
(defn start-move-handler
|
(defn start-move-handler
|
||||||
[index prefix]
|
"Handles handler clicks and drags in both edit modes."
|
||||||
|
[index prefix shift? alt? mod?]
|
||||||
(ptk/reify ::start-move-handler
|
(ptk/reify ::start-move-handler
|
||||||
|
ptk/WatchEvent
|
||||||
|
(watch [_ state _]
|
||||||
|
(let [id (st/get-path-id state)
|
||||||
|
handler-id [index prefix]
|
||||||
|
content (st/get-path state :content)
|
||||||
|
selected-handlers (dm/get-in state
|
||||||
|
[:workspace-local :edit-path id :selection :handlers]
|
||||||
|
#{})
|
||||||
|
selected? (contains? selected-handlers handler-id)
|
||||||
|
|
||||||
|
handler-types (dm/get-in state [:workspace-local :edit-path id :handler-types] {})
|
||||||
|
plain-mode (stored-handler-drag-mode
|
||||||
|
content handler-types index prefix)]
|
||||||
|
(cond
|
||||||
|
(and mod? alt?)
|
||||||
|
(streams/drag-stream
|
||||||
|
(rx/of (set-drag-cursor (handler-drag-cursor mod? alt?))
|
||||||
|
(drag-selected-handlers handler-id plain-mode))
|
||||||
|
(rx/empty))
|
||||||
|
|
||||||
|
(or mod? alt?)
|
||||||
|
(streams/drag-stream
|
||||||
|
(rx/of (set-drag-cursor (handler-drag-cursor mod? alt?))
|
||||||
|
(drag-selected-handlers handler-id plain-mode))
|
||||||
|
(rx/of (tools/remove-handler index prefix)))
|
||||||
|
|
||||||
|
:else
|
||||||
|
(streams/drag-stream
|
||||||
|
(rx/of
|
||||||
|
(set-drag-cursor (handler-drag-cursor mod? alt?))
|
||||||
|
(when-not selected?
|
||||||
|
(selection/select-handler index prefix shift?))
|
||||||
|
(drag-selected-handlers handler-id plain-mode))
|
||||||
|
(rx/of (selection/select-handler index prefix shift?))))))))
|
||||||
|
|
||||||
|
(defn drag-selected-handlers
|
||||||
|
"Drags selected handlers using the live matching mode."
|
||||||
|
[[index prefix :as primary] plain-mode]
|
||||||
|
(ptk/reify ::drag-selected-handlers
|
||||||
ptk/WatchEvent
|
ptk/WatchEvent
|
||||||
(watch [_ state stream]
|
(watch [_ state stream]
|
||||||
(let [id (dm/get-in state [:workspace-local :edition])
|
(let [id (st/get-path-id state)
|
||||||
cx (d/prefix-keyword prefix :x)
|
content (st/get-path state :content)
|
||||||
cy (d/prefix-keyword prefix :y)
|
points (path/get-points content)
|
||||||
|
start-modifiers (dm/get-in state
|
||||||
modifiers (dm/get-in state [:workspace-local :edit-path id :content-modifiers])
|
[:workspace-local :edit-path id :content-modifiers]
|
||||||
start-delta-x (dm/get-in modifiers [index cx] 0)
|
{})
|
||||||
start-delta-y (dm/get-in modifiers [index cy] 0)
|
start-delta (handler-modifier-delta start-modifiers index prefix)
|
||||||
|
point (path/handler->node content index prefix)
|
||||||
content (st/get-path state :content)
|
handler (-> (path/get-handler-point content index prefix)
|
||||||
points (path/get-points content)
|
(gpt/add start-delta))
|
||||||
|
|
||||||
point (-> content (nth (if (= prefix :c1) (dec index) index)) (path.helpers/segment->point))
|
|
||||||
handler (-> content (nth index) (path/get-handler prefix))
|
|
||||||
|
|
||||||
[op-idx op-prefix] (path/opposite-index content index prefix)
|
[op-idx op-prefix] (path/opposite-index content index prefix)
|
||||||
opposite (path/get-handler-point content op-idx op-prefix)]
|
opposite (when op-idx
|
||||||
|
(-> (path/get-handler-point content op-idx op-prefix)
|
||||||
|
(gpt/add (handler-modifier-delta start-modifiers
|
||||||
|
op-idx
|
||||||
|
op-prefix))))
|
||||||
|
stopper (rx/merge
|
||||||
|
(mse/drag-stopper stream)
|
||||||
|
(->> stream
|
||||||
|
(rx/filter streams/finish-edition?)))
|
||||||
|
|
||||||
(streams/drag-stream
|
handler-events (rx/share
|
||||||
(rx/concat
|
(streams/move-handler-stream handler point handler opposite points))]
|
||||||
(rx/of (dwsh/update-shapes [id] path/convert-to-path))
|
(rx/concat
|
||||||
(->> (streams/move-handler-stream handler point handler opposite points)
|
(rx/merge
|
||||||
|
(->> handler-events
|
||||||
(rx/map
|
(rx/map
|
||||||
(fn [{:keys [x y alt? shift?]}]
|
(fn [{:keys [x y shift? alt? mod?]}]
|
||||||
(let [pos (cond-> (gpt/point x y)
|
(let [position (cond-> (gpt/point x y)
|
||||||
shift? (path.helpers/position-fixed-angle point))]
|
shift? (path.helpers/position-fixed-angle point))
|
||||||
(modify-handler
|
delta (gpt/subtract position handler)
|
||||||
id
|
mode (handler-drag-mode plain-mode mod? alt?)
|
||||||
index
|
move-selection? (not (or mod? alt?))]
|
||||||
prefix
|
(modify-selected-handlers id
|
||||||
(+ start-delta-x (- (:x pos) (:x handler)))
|
primary
|
||||||
(+ start-delta-y (- (:y pos) (:y handler)))
|
start-modifiers
|
||||||
(not alt?)))))
|
(:x delta)
|
||||||
(rx/take-until
|
(:y delta)
|
||||||
(rx/merge
|
mode
|
||||||
(mse/drag-stopper stream)
|
move-selection?))))
|
||||||
(->> stream
|
(rx/take-until stopper))
|
||||||
(rx/filter streams/finish-edition?)))))
|
;; Update the cursor only when the matching mode changes.
|
||||||
|
(->> handler-events
|
||||||
(rx/concat (rx/of (apply-content-modifiers)))))))))
|
(rx/map (fn [{:keys [alt? mod?]}] (handler-drag-cursor mod? alt?)))
|
||||||
|
(rx/pipe (rxo/distinct-contiguous))
|
||||||
|
(rx/map set-drag-cursor)
|
||||||
|
(rx/take-until stopper)))
|
||||||
|
(rx/of (apply-content-modifiers)))))))
|
||||||
|
|
||||||
(declare stop-path-edit)
|
(declare stop-path-edit)
|
||||||
|
|
||||||
|
(defn- resolve-edit-fills
|
||||||
|
"Resolves the fills inherited by the editing copy.
|
||||||
|
Frames stop group fill inheritance."
|
||||||
|
[shape objects]
|
||||||
|
(let [own (svg-fills/resolve-shape-fills shape)]
|
||||||
|
(if (seq own)
|
||||||
|
own
|
||||||
|
(loop [parent-id (:parent-id shape)]
|
||||||
|
(let [parent (get objects parent-id)]
|
||||||
|
(cond
|
||||||
|
(nil? parent) []
|
||||||
|
(cfh/group-shape? parent) (svg-fills/resolve-shape-fills parent)
|
||||||
|
(cfh/frame-shape? parent) []
|
||||||
|
:else (recur (:parent-id parent))))))))
|
||||||
|
|
||||||
(defn start-path-edit
|
(defn start-path-edit
|
||||||
[id]
|
[id]
|
||||||
@ -294,10 +888,15 @@
|
|||||||
ptk/UpdateEvent
|
ptk/UpdateEvent
|
||||||
(update [_ state]
|
(update [_ state]
|
||||||
(let [objects (dsh/lookup-page-objects state)
|
(let [objects (dsh/lookup-page-objects state)
|
||||||
shape (get objects id)]
|
shape (get objects id)
|
||||||
|
shape (-> shape
|
||||||
|
(path/convert-to-path objects)
|
||||||
|
(update :content path/close-subpaths)
|
||||||
|
(path/update-geometry))
|
||||||
|
shape (assoc shape :fills (resolve-edit-fills shape objects))]
|
||||||
|
|
||||||
(-> state
|
(-> state
|
||||||
(st/set-content (path/close-subpaths (:content shape)))
|
(assoc-in [:workspace-drawing :object] shape)
|
||||||
(update-in [:workspace-local :edit-path id]
|
(update-in [:workspace-local :edit-path id]
|
||||||
(fn [state]
|
(fn [state]
|
||||||
(let [state (if state
|
(let [state (if state
|
||||||
@ -305,20 +904,20 @@
|
|||||||
(assoc state :edit-mode :draw)
|
(assoc state :edit-mode :draw)
|
||||||
state)
|
state)
|
||||||
{:edit-mode :move
|
{:edit-mode :move
|
||||||
:selected #{}
|
:selection helpers/empty-selection
|
||||||
:snap-toggled false})]
|
:hover helpers/empty-selection
|
||||||
|
:handler-types {}
|
||||||
|
:snap-toggled true})]
|
||||||
(assoc state :old-content (:content shape))))))))
|
(assoc state :old-content (:content shape))))))))
|
||||||
|
|
||||||
ptk/WatchEvent
|
ptk/WatchEvent
|
||||||
(watch [_ _ stream]
|
(watch [_ _ stream]
|
||||||
(let [stopper (rx/filter #(let [type (ptk/type %)]
|
(let [stopper (rx/filter (ptk/type? ::start-path-edit) stream)]
|
||||||
(= type ::dwe/clear-edition-mode)
|
|
||||||
(= type ::start-path-edit))
|
|
||||||
stream)]
|
|
||||||
(rx/concat
|
(rx/concat
|
||||||
(rx/of (undo/start-path-undo))
|
(rx/of (undo/start-path-undo))
|
||||||
|
;; Finalize once on the canonical edition stop event.
|
||||||
(->> stream
|
(->> stream
|
||||||
(rx/filter #(= % :interrupt))
|
(rx/filter (ptk/type? ::dwe/clear-edition-mode))
|
||||||
(rx/take 1)
|
(rx/take 1)
|
||||||
(rx/map #(stop-path-edit id))
|
(rx/map #(stop-path-edit id))
|
||||||
(rx/take-until stopper)))))))
|
(rx/take-until stopper)))))))
|
||||||
@ -326,29 +925,27 @@
|
|||||||
(defn stop-path-edit
|
(defn stop-path-edit
|
||||||
[id]
|
[id]
|
||||||
(ptk/reify ::stop-path-edit
|
(ptk/reify ::stop-path-edit
|
||||||
ptk/UpdateEvent
|
|
||||||
(update [_ state]
|
|
||||||
(update state :workspace-local dissoc :edit-path id))
|
|
||||||
|
|
||||||
ptk/WatchEvent
|
ptk/WatchEvent
|
||||||
(watch [_ _ _]
|
(watch [_ _ _]
|
||||||
(rx/of (ptk/data-event :layout/update {:ids [id]})))))
|
(rx/of
|
||||||
|
(changes/finalize-path-content id)
|
||||||
|
(fn [state]
|
||||||
|
(-> state
|
||||||
|
(update-in [:workspace-local :edit-path] dissoc id)
|
||||||
|
(update :workspace-drawing dissoc :object :lock)))
|
||||||
|
(ptk/data-event :layout/update {:ids [id]})))))
|
||||||
|
|
||||||
(defn- split-segments
|
(defn- split-segments
|
||||||
[id {:keys [from-p to-p t]}]
|
[_id {:keys [from-p to-p t]}]
|
||||||
(ptk/reify ::split-segments
|
(ptk/reify ::split-segments
|
||||||
ptk/UpdateEvent
|
ptk/UpdateEvent
|
||||||
(update [_ state]
|
(update [_ state]
|
||||||
(let [content (st/get-path state :content)]
|
(let [content (st/get-path state :content)]
|
||||||
(-> state
|
(-> state
|
||||||
(assoc-in [:workspace-local :edit-path id :old-content] content)
|
|
||||||
(st/set-content (-> content
|
(st/set-content (-> content
|
||||||
(path/split-segments #{from-p to-p} t)
|
(path/split-segments #{from-p to-p} t)
|
||||||
(path/content))))))
|
(path/content)))
|
||||||
|
(update-in (st/get-path-location state) path/update-geometry))))))
|
||||||
ptk/WatchEvent
|
|
||||||
(watch [_ _ _]
|
|
||||||
(rx/of (changes/save-path-content {:preserve-move-to true})))))
|
|
||||||
|
|
||||||
(defn create-node-at-position
|
(defn create-node-at-position
|
||||||
[params]
|
[params]
|
||||||
@ -356,5 +953,4 @@
|
|||||||
ptk/WatchEvent
|
ptk/WatchEvent
|
||||||
(watch [_ state _]
|
(watch [_ state _]
|
||||||
(let [id (st/get-path-id state)]
|
(let [id (st/get-path-id state)]
|
||||||
(rx/of (dwsh/update-shapes [id] path/convert-to-path)
|
(rx/of (split-segments id params))))))
|
||||||
(split-segments id params))))))
|
|
||||||
|
|||||||
@ -6,7 +6,11 @@
|
|||||||
|
|
||||||
(ns app.main.data.workspace.path.helpers
|
(ns app.main.data.workspace.path.helpers
|
||||||
(:require
|
(:require
|
||||||
|
[app.common.data :as d]
|
||||||
[app.common.geom.point :as gpt]
|
[app.common.geom.point :as gpt]
|
||||||
|
[app.common.geom.rect :as grc]
|
||||||
|
[app.common.geom.shapes :as gsh]
|
||||||
|
[app.common.geom.shapes.intersect :as gsi]
|
||||||
[app.common.math :as mth]
|
[app.common.math :as mth]
|
||||||
[app.common.types.path :as path]
|
[app.common.types.path :as path]
|
||||||
[app.common.types.path.helpers :as path.helpers]))
|
[app.common.types.path.helpers :as path.helpers]))
|
||||||
@ -25,8 +29,27 @@
|
|||||||
(gpt/to-vec common p1)
|
(gpt/to-vec common p1)
|
||||||
(gpt/to-vec common p2))))
|
(gpt/to-vec common p2))))
|
||||||
|
|
||||||
|
(defn opposite-handler-target
|
||||||
|
"Returns the opposite handler target for mirror or aligned modes."
|
||||||
|
[node handler opposite mode]
|
||||||
|
(if (and (some? node) (some? handler) (some? opposite))
|
||||||
|
(case mode
|
||||||
|
:mirror
|
||||||
|
(gpt/subtract (gpt/scale node 2) handler)
|
||||||
|
|
||||||
|
:aligned
|
||||||
|
(let [handler-vector (gpt/to-vec node handler)]
|
||||||
|
(if (mth/almost-zero? (gpt/length handler-vector))
|
||||||
|
opposite
|
||||||
|
(gpt/subtract node
|
||||||
|
(gpt/scale (gpt/unit handler-vector)
|
||||||
|
(gpt/distance node opposite)))))
|
||||||
|
|
||||||
|
opposite)
|
||||||
|
opposite))
|
||||||
|
|
||||||
(defn- calculate-opposite-delta [node handler opposite match-angle? match-distance? dx dy]
|
(defn- calculate-opposite-delta [node handler opposite match-angle? match-distance? dx dy]
|
||||||
(when (and (some? handler) (some? opposite))
|
(if (and (some? handler) (some? opposite))
|
||||||
(let [;; To match the angle, the angle should be matching (angle between points 180deg)
|
(let [;; To match the angle, the angle should be matching (angle between points 180deg)
|
||||||
angle-handlers (angle-points node handler opposite)
|
angle-handlers (angle-points node handler opposite)
|
||||||
|
|
||||||
@ -54,30 +77,466 @@
|
|||||||
match-distance?
|
match-distance?
|
||||||
(gpt/scale-from node distance-scale))]
|
(gpt/scale-from node distance-scale))]
|
||||||
[(- (:x new-opposite) (:x opposite))
|
[(- (:x new-opposite) (:x opposite))
|
||||||
(- (:y new-opposite) (:y opposite))])))
|
(- (:y new-opposite) (:y opposite))])
|
||||||
|
;; Leave missing opposite handles unchanged.
|
||||||
|
[0 0]))
|
||||||
|
|
||||||
|
(defn handlers-joined?
|
||||||
|
"True when a node's handlers are collinear and opposite."
|
||||||
|
[content index prefix]
|
||||||
|
(let [[op-idx op-prefix] (path/opposite-index content index prefix)
|
||||||
|
node (path/handler->node content index prefix)
|
||||||
|
handler (path/get-handler-point content index prefix)
|
||||||
|
opposite (when op-idx (path/get-handler-point content op-idx op-prefix))]
|
||||||
|
(boolean
|
||||||
|
(and (some? op-idx)
|
||||||
|
(some? handler)
|
||||||
|
(some? opposite)
|
||||||
|
(not= handler node)
|
||||||
|
(not= opposite node)
|
||||||
|
(<= (mth/abs (- 180 (angle-points node handler opposite))) 0.1)))))
|
||||||
|
|
||||||
(defn move-handler-modifiers
|
(defn move-handler-modifiers
|
||||||
[content index prefix match-distance? match-angle? dx dy]
|
([content index prefix match-distance? match-angle? dx dy]
|
||||||
|
(move-handler-modifiers content index prefix match-distance? match-angle? false dx dy))
|
||||||
|
([content index prefix match-distance? match-angle? rejoin? dx dy]
|
||||||
|
|
||||||
(let [[cx cy] (path.helpers/prefix->coords prefix)
|
(let [[cx cy] (path.helpers/prefix->coords prefix)
|
||||||
|
[op-idx op-prefix] (path/opposite-index content index prefix)
|
||||||
|
|
||||||
|
node (path/handler->node content index prefix)
|
||||||
|
handler (path/get-handler-point content index prefix)
|
||||||
|
opposite (path/get-handler-point content op-idx op-prefix)
|
||||||
|
|
||||||
|
[ocx ocy] (path.helpers/prefix->coords op-prefix)
|
||||||
|
[odx ody] (calculate-opposite-delta node handler opposite match-angle? match-distance? dx dy)
|
||||||
|
|
||||||
|
hnv (if (some? handler)
|
||||||
|
(gpt/to-vec node (-> handler (update :x + dx) (update :y + dy)))
|
||||||
|
(gpt/point dx dy))
|
||||||
|
mirrored-opposite (opposite-handler-target
|
||||||
|
node (gpt/add node hnv) opposite :mirror)]
|
||||||
|
|
||||||
|
(-> {}
|
||||||
|
(update index assoc cx dx cy dy)
|
||||||
|
|
||||||
|
(cond->
|
||||||
|
;; Force an exact mirror when rejoining handlers.
|
||||||
|
(and (some? op-idx) rejoin? (not= opposite node))
|
||||||
|
(update op-idx assoc
|
||||||
|
ocx (- (:x mirrored-opposite) (:x opposite))
|
||||||
|
ocy (- (:y mirrored-opposite) (:y opposite)))
|
||||||
|
|
||||||
|
(and (some? op-idx) (not rejoin?) (not= opposite node))
|
||||||
|
(update op-idx assoc ocx odx ocy ody)
|
||||||
|
|
||||||
|
(and (some? op-idx) (= opposite node) match-distance? match-angle?)
|
||||||
|
(update op-idx assoc
|
||||||
|
ocx (- (:x mirrored-opposite) (:x opposite))
|
||||||
|
ocy (- (:y mirrored-opposite) (:y opposite))))))))
|
||||||
|
|
||||||
|
(defn align-handler-modifiers
|
||||||
|
"Moves a handler and aligns its opposite without changing its length."
|
||||||
|
[content index prefix dx dy]
|
||||||
|
(let [[cx cy] (path.helpers/prefix->coords prefix)
|
||||||
[op-idx op-prefix] (path/opposite-index content index prefix)
|
[op-idx op-prefix] (path/opposite-index content index prefix)
|
||||||
|
node (path/handler->node content index prefix)
|
||||||
|
opposite (when (some? op-idx)
|
||||||
|
(path/get-handler-point content op-idx op-prefix))
|
||||||
|
handler (path/get-handler-point content index prefix)
|
||||||
|
modifiers (-> {} (update index assoc cx dx cy dy))]
|
||||||
|
(if (and (some? handler) (some? opposite) (not= opposite node))
|
||||||
|
(let [moved-handler (-> handler (update :x + dx) (update :y + dy))
|
||||||
|
handler-vector (gpt/to-vec node moved-handler)
|
||||||
|
target (opposite-handler-target node moved-handler opposite :aligned)]
|
||||||
|
(if (mth/almost-zero? (gpt/length handler-vector))
|
||||||
|
modifiers
|
||||||
|
(let [[ocx ocy] (path.helpers/prefix->coords op-prefix)]
|
||||||
|
(update modifiers op-idx assoc
|
||||||
|
ocx (- (:x target) (:x opposite))
|
||||||
|
ocy (- (:y target) (:y opposite))))))
|
||||||
|
modifiers)))
|
||||||
|
|
||||||
node (path/handler->node content index prefix)
|
;; --- Per-node handler type (mirror / aligned / independent)
|
||||||
handler (path/get-handler-point content index prefix)
|
|
||||||
opposite (path/get-handler-point content op-idx op-prefix)
|
|
||||||
|
|
||||||
[ocx ocy] (path.helpers/prefix->coords op-prefix)
|
(defn handler-node-index
|
||||||
[odx ody] (calculate-opposite-delta node handler opposite match-angle? match-distance? dx dy)
|
"Returns the anchor command index for a handler."
|
||||||
|
[index prefix]
|
||||||
|
(if (= prefix :c1) (dec index) index))
|
||||||
|
|
||||||
hnv (if (some? handler)
|
(defn node-primary-handler
|
||||||
(gpt/to-vec node (-> handler (update :x + dx) (update :y + dy)))
|
"Returns a curve handler for a node, preferring its incoming handle."
|
||||||
(gpt/point dx dy))]
|
[content node-index]
|
||||||
|
(let [n (count content)
|
||||||
|
out-idx (inc node-index)]
|
||||||
|
(cond
|
||||||
|
(and (>= node-index 0) (< node-index n)
|
||||||
|
(= :curve-to (:command (nth content node-index nil))))
|
||||||
|
[node-index :c2]
|
||||||
|
|
||||||
(-> {}
|
(and (< out-idx n)
|
||||||
(update index assoc cx dx cy dy)
|
(= :curve-to (:command (nth content out-idx nil))))
|
||||||
|
[out-idx :c1]
|
||||||
|
|
||||||
(cond-> (and (some? op-idx) (not= opposite node))
|
:else nil)))
|
||||||
(update op-idx assoc ocx odx ocy ody)
|
|
||||||
|
|
||||||
(and (some? op-idx) (= opposite node) match-distance? match-angle?)
|
(defn handlers-equal-length?
|
||||||
(update op-idx assoc ocx (- (:x hnv)) ocy (- (:y hnv)))))))
|
"True when a node's two handlers are the same distance from the node."
|
||||||
|
[content index prefix]
|
||||||
|
(let [[op-idx op-prefix] (path/opposite-index content index prefix)
|
||||||
|
node (path/handler->node content index prefix)
|
||||||
|
handler (path/get-handler-point content index prefix)
|
||||||
|
opposite (when op-idx (path/get-handler-point content op-idx op-prefix))]
|
||||||
|
(boolean
|
||||||
|
(and (some? handler) (some? opposite)
|
||||||
|
(mth/almost-zero? (- (gpt/distance node handler)
|
||||||
|
(gpt/distance node opposite)))))))
|
||||||
|
|
||||||
|
(defn derive-handler-type
|
||||||
|
"Infers a node's handler type from its geometry."
|
||||||
|
[content node-index]
|
||||||
|
(if-let [[idx prefix] (node-primary-handler content node-index)]
|
||||||
|
(cond
|
||||||
|
(not (handlers-joined? content idx prefix)) :independent
|
||||||
|
(handlers-equal-length? content idx prefix) :mirror
|
||||||
|
:else :aligned)
|
||||||
|
:independent))
|
||||||
|
|
||||||
|
(defn remap-handler-types
|
||||||
|
"Remaps handler types by node position after structural changes."
|
||||||
|
[handler-types old-content new-content]
|
||||||
|
(let [handler-types (or handler-types {})]
|
||||||
|
(if (= (count old-content) (count new-content))
|
||||||
|
handler-types
|
||||||
|
(let [types-by-position
|
||||||
|
(reduce-kv
|
||||||
|
(fn [result index type]
|
||||||
|
(let [segment (nth old-content index nil)]
|
||||||
|
(if (or (nil? segment) (= :close-path (:command segment)))
|
||||||
|
result
|
||||||
|
(update result
|
||||||
|
(path.helpers/segment->point segment)
|
||||||
|
(fnil conj #{})
|
||||||
|
type))))
|
||||||
|
{}
|
||||||
|
handler-types)]
|
||||||
|
(into {}
|
||||||
|
(keep (fn [[index segment]]
|
||||||
|
(when-not (= :close-path (:command segment))
|
||||||
|
(let [types (get types-by-position
|
||||||
|
(path.helpers/segment->point segment))]
|
||||||
|
(when (= 1 (count types))
|
||||||
|
[index (first types)])))))
|
||||||
|
(d/enumerate new-content))))))
|
||||||
|
|
||||||
|
;; Nodes and segments use command indices. Handlers use `[index prefix]`.
|
||||||
|
;; Selection and hover use grouped index sets:
|
||||||
|
;; {:nodes #{index} :segments #{index} :handlers #{[index prefix]}}
|
||||||
|
|
||||||
|
(def empty-selection
|
||||||
|
{:nodes #{} :segments #{} :handlers #{}})
|
||||||
|
|
||||||
|
(defn node?
|
||||||
|
"True when the command at the given content index is a selectable node."
|
||||||
|
[content index]
|
||||||
|
(and (number? index)
|
||||||
|
(<= 0 index)
|
||||||
|
(< index (count content))
|
||||||
|
(not= :close-path (:command (nth content index nil)))))
|
||||||
|
|
||||||
|
(defn node-indices
|
||||||
|
"Indices of every selectable node in the content."
|
||||||
|
[content]
|
||||||
|
(into []
|
||||||
|
(comp (remove (fn [[_ seg]] (= :close-path (:command seg))))
|
||||||
|
(map first))
|
||||||
|
(d/enumerate content)))
|
||||||
|
|
||||||
|
(defn node-position
|
||||||
|
"Position of the node at the given content command index."
|
||||||
|
[content index]
|
||||||
|
(path.helpers/segment->point (nth content index)))
|
||||||
|
|
||||||
|
(defn curve-node?
|
||||||
|
"True when the node at `index` has a visible curve handler."
|
||||||
|
[content index]
|
||||||
|
(when (node? content index)
|
||||||
|
(let [node (node-position content index)
|
||||||
|
incoming (when (= :curve-to (:command (nth content index nil)))
|
||||||
|
(path/get-handler-point content index :c2))
|
||||||
|
outgoing-index (inc index)
|
||||||
|
outgoing (when (= :curve-to (:command (nth content outgoing-index nil)))
|
||||||
|
(path/get-handler-point content outgoing-index :c1))]
|
||||||
|
(boolean (some #(and (some? %) (not= node %)) [incoming outgoing])))))
|
||||||
|
|
||||||
|
(defn node-positions
|
||||||
|
"Set of positions for the given node indices in the content."
|
||||||
|
[content indices]
|
||||||
|
(let [indices (set indices)]
|
||||||
|
(into #{}
|
||||||
|
(comp (filter (fn [[index _]] (contains? indices index)))
|
||||||
|
(map (fn [[_ seg]] (path.helpers/segment->point seg))))
|
||||||
|
(d/enumerate content))))
|
||||||
|
|
||||||
|
(defn nodes-in-rect
|
||||||
|
"Indices of the nodes whose position falls inside the given rect."
|
||||||
|
[content rect]
|
||||||
|
(into #{}
|
||||||
|
(comp (remove (fn [[_ seg]] (= :close-path (:command seg))))
|
||||||
|
(filter (fn [[_ seg]] (gsh/has-point-rect? rect (path.helpers/segment->point seg))))
|
||||||
|
(map first))
|
||||||
|
(d/enumerate content)))
|
||||||
|
|
||||||
|
(def segment-entries
|
||||||
|
"Returns selectable path segments."
|
||||||
|
path/segment-entries)
|
||||||
|
|
||||||
|
(defn segment-node-indices
|
||||||
|
"Unique endpoint-node indices for the selected segment command indices."
|
||||||
|
[content segment-indices]
|
||||||
|
(let [segment-indices (set segment-indices)]
|
||||||
|
(into #{}
|
||||||
|
(comp (filter #(contains? segment-indices (:index %)))
|
||||||
|
(mapcat (juxt :from-index :to-index))
|
||||||
|
(remove nil?))
|
||||||
|
(segment-entries content))))
|
||||||
|
|
||||||
|
(defn check-enabled
|
||||||
|
"Returns path actions enabled for selected node indices."
|
||||||
|
[content selected-nodes]
|
||||||
|
(when content
|
||||||
|
(let [selected-nodes (into #{} (filter #(node? content %)) selected-nodes)
|
||||||
|
selected-segments (filter (fn [{:keys [from-index to-index]}]
|
||||||
|
(and (contains? selected-nodes from-index)
|
||||||
|
(contains? selected-nodes to-index)))
|
||||||
|
(segment-entries content))
|
||||||
|
num-segments (count selected-segments)
|
||||||
|
num-nodes (count selected-nodes)
|
||||||
|
nodes-selected? (seq selected-nodes)
|
||||||
|
segments-selected? (seq selected-segments)
|
||||||
|
max-segments (/ (* num-nodes (dec num-nodes)) 2)
|
||||||
|
curves-selected? (some #(curve-node? content %) selected-nodes)
|
||||||
|
corners-selected? (some #(not (curve-node? content %)) selected-nodes)]
|
||||||
|
{:make-corner (and nodes-selected? curves-selected?)
|
||||||
|
:make-curve (and nodes-selected? corners-selected?)
|
||||||
|
:merge-nodes (and nodes-selected? (>= num-nodes 2))
|
||||||
|
:join-nodes (and nodes-selected? (>= num-nodes 2) (< num-segments max-segments))
|
||||||
|
:separate-nodes (or segments-selected? (= num-nodes 1))})))
|
||||||
|
|
||||||
|
(defn selected-node-indices
|
||||||
|
"Returns selected nodes plus endpoints of selected segments."
|
||||||
|
[content selection]
|
||||||
|
(into (get selection :nodes #{})
|
||||||
|
(segment-node-indices content (get selection :segments #{}))))
|
||||||
|
|
||||||
|
(defn selection-coordinate-rect
|
||||||
|
"Returns the bounds of selected segments, nodes, and handlers."
|
||||||
|
[content selection]
|
||||||
|
(let [segments (get selection :segments #{})
|
||||||
|
node-indices (selected-node-indices content selection)
|
||||||
|
handlers (get selection :handlers #{})
|
||||||
|
segment-rect (when (seq segments)
|
||||||
|
(path/calc-selrect
|
||||||
|
(path/extract-content content {:segments segments})))
|
||||||
|
point-rect (grc/points->rect
|
||||||
|
(into (node-positions content node-indices)
|
||||||
|
(keep (fn [[index prefix]]
|
||||||
|
(path/get-handler-point content index prefix)))
|
||||||
|
handlers))]
|
||||||
|
(grc/join-rects (keep identity [segment-rect point-rect]))))
|
||||||
|
|
||||||
|
(defn handler-target-nodes
|
||||||
|
"Returns nodes targeted by the current node and handler selection."
|
||||||
|
[content selection]
|
||||||
|
(into (selected-node-indices content selection)
|
||||||
|
(map (fn [[idx prefix]] (handler-node-index idx prefix)))
|
||||||
|
(get selection :handlers #{})))
|
||||||
|
|
||||||
|
(defn handler-selection-state
|
||||||
|
"Returns targeted curve nodes and their shared handler mode."
|
||||||
|
[content handler-types target-nodes]
|
||||||
|
(let [curve-nodes (into #{} (filter #(curve-node? content %)) target-nodes)
|
||||||
|
modes (into #{}
|
||||||
|
(map (fn [index]
|
||||||
|
(or (get handler-types index)
|
||||||
|
(derive-handler-type content index))))
|
||||||
|
curve-nodes)]
|
||||||
|
{:nodes curve-nodes
|
||||||
|
:active-type (cond
|
||||||
|
(empty? modes) nil
|
||||||
|
(= 1 (count modes)) (first modes)
|
||||||
|
:else :mixed)}))
|
||||||
|
|
||||||
|
(defn handler-trigger-action
|
||||||
|
"Returns the handler menu action for the active mode."
|
||||||
|
[active-type]
|
||||||
|
(if (= active-type :mixed) :open :select))
|
||||||
|
|
||||||
|
(def segment-insert-threshold
|
||||||
|
"Maximum screen distance for midpoint insertion."
|
||||||
|
12)
|
||||||
|
|
||||||
|
(defn segment-mid-point
|
||||||
|
"Returns a segment's arc-length midpoint with split metadata."
|
||||||
|
[{:keys [from to segment] :as entry}]
|
||||||
|
(let [curve (path.helpers/entry->bezier entry)
|
||||||
|
t (if (= :line-to (:command segment))
|
||||||
|
0.5
|
||||||
|
(path.helpers/curve-arc-length-t curve))]
|
||||||
|
(with-meta (path.helpers/curve-values curve t)
|
||||||
|
{:from-p from :to-p to :t t})))
|
||||||
|
|
||||||
|
(defn insertion-mid-points
|
||||||
|
"Precomputes segment midpoint insertion candidates."
|
||||||
|
[content]
|
||||||
|
(into []
|
||||||
|
(comp (remove #(= :close-path (:command (:segment %))))
|
||||||
|
(map segment-mid-point))
|
||||||
|
(segment-entries content)))
|
||||||
|
|
||||||
|
(defn- closest-insertion-mid-point
|
||||||
|
[mid-points position threshold]
|
||||||
|
(some->> mid-points
|
||||||
|
(reduce
|
||||||
|
(fn [closest mid-point]
|
||||||
|
(let [distance (gpt/distance position mid-point)]
|
||||||
|
(if (and (<= distance threshold)
|
||||||
|
(or (nil? closest)
|
||||||
|
(< distance (first closest))))
|
||||||
|
[distance mid-point]
|
||||||
|
closest)))
|
||||||
|
nil)
|
||||||
|
second))
|
||||||
|
|
||||||
|
(defn insertion-point
|
||||||
|
"Returns the on-path point a nearby click would insert, with split metadata."
|
||||||
|
([content position threshold anywhere?]
|
||||||
|
(insertion-point content position threshold anywhere? nil))
|
||||||
|
([content position threshold anywhere? mid-points]
|
||||||
|
(if anywhere?
|
||||||
|
(let [point (path/closest-point content position 0.01)]
|
||||||
|
(when (and (some? point) (<= (gpt/distance position point) threshold))
|
||||||
|
point))
|
||||||
|
(closest-insertion-mid-point
|
||||||
|
(or mid-points (insertion-mid-points content)) position threshold))))
|
||||||
|
|
||||||
|
(defn- segment-lines
|
||||||
|
[{:keys [from to segment]}]
|
||||||
|
(if (= :curve-to (:command segment))
|
||||||
|
(path.helpers/curve->lines from
|
||||||
|
to
|
||||||
|
(path/get-handler segment :c1)
|
||||||
|
(path/get-handler segment :c2))
|
||||||
|
[[from to]]))
|
||||||
|
|
||||||
|
(defn segments-in-rect
|
||||||
|
"Returns segments that cross or fall inside `rect`."
|
||||||
|
[content rect]
|
||||||
|
(let [rect-lines (gsi/points->lines (grc/rect->points rect))]
|
||||||
|
(into #{}
|
||||||
|
(comp
|
||||||
|
(filter
|
||||||
|
(fn [entry]
|
||||||
|
(let [lines (segment-lines entry)]
|
||||||
|
(or (some (fn [[from to]]
|
||||||
|
(or (grc/contains-point? rect from)
|
||||||
|
(grc/contains-point? rect to)))
|
||||||
|
lines)
|
||||||
|
(gsi/intersects-lines? rect-lines lines)))))
|
||||||
|
(map :index))
|
||||||
|
(segment-entries content))))
|
||||||
|
|
||||||
|
(defn handler-entries
|
||||||
|
"Visible path handlers as `{:identity [index prefix] :point p}` entries."
|
||||||
|
[content]
|
||||||
|
(into []
|
||||||
|
(comp
|
||||||
|
(mapcat
|
||||||
|
(fn [[index segment]]
|
||||||
|
(when (= :curve-to (:command segment))
|
||||||
|
(keep
|
||||||
|
(fn [prefix]
|
||||||
|
(let [handler (path/get-handler-point content index prefix)
|
||||||
|
node (path/handler->node content index prefix)]
|
||||||
|
(when (and handler (not= handler node))
|
||||||
|
{:identity [index prefix]
|
||||||
|
:point handler})))
|
||||||
|
[:c1 :c2])))))
|
||||||
|
(d/enumerate content)))
|
||||||
|
|
||||||
|
(defn handlers-in-rect
|
||||||
|
"Identities of visible path handlers whose control point is inside `rect`."
|
||||||
|
[content rect]
|
||||||
|
(into #{}
|
||||||
|
(comp (filter #(grc/contains-point? rect (:point %)))
|
||||||
|
(map :identity))
|
||||||
|
(handler-entries content)))
|
||||||
|
|
||||||
|
(defn remap-selected-nodes
|
||||||
|
"Remaps selected nodes by position after structural changes."
|
||||||
|
[selected-nodes old-content new-content]
|
||||||
|
(if (empty? selected-nodes)
|
||||||
|
selected-nodes
|
||||||
|
(let [positions (node-positions old-content selected-nodes)]
|
||||||
|
(into #{}
|
||||||
|
(comp (remove (fn [[_ seg]] (= :close-path (:command seg))))
|
||||||
|
(filter (fn [[_ seg]] (contains? positions (path.helpers/segment->point seg))))
|
||||||
|
(map first))
|
||||||
|
(d/enumerate new-content)))))
|
||||||
|
|
||||||
|
(defn- fragment-covered-nodes
|
||||||
|
"Returns nodes already included in a duplicated segment fragment."
|
||||||
|
[content {:keys [nodes segments]}]
|
||||||
|
(let [nodes (or nodes #{})
|
||||||
|
segments (or segments #{})]
|
||||||
|
(into #{}
|
||||||
|
(comp (filter (fn [{:keys [index from-index to-index]}]
|
||||||
|
(or (contains? segments index)
|
||||||
|
(and (contains? nodes from-index)
|
||||||
|
(contains? nodes to-index)))))
|
||||||
|
(mapcat (juxt :from-index :to-index)))
|
||||||
|
(segment-entries content))))
|
||||||
|
|
||||||
|
(defn duplicate-selection-content
|
||||||
|
"Duplicates selected nodes and segments for splicing as new subpaths."
|
||||||
|
[content selection offset]
|
||||||
|
(let [fragment (path/extract-content content selection)
|
||||||
|
fragment (cond-> fragment
|
||||||
|
(and (seq fragment) (some? offset))
|
||||||
|
(path/move-content offset))
|
||||||
|
fragment (vec fragment)
|
||||||
|
covered (fragment-covered-nodes content selection)
|
||||||
|
free (sort (remove covered (get selection :nodes #{})))]
|
||||||
|
(reduce (fn [{:keys [sub selected]} node-index]
|
||||||
|
(if-let [{ext :content ext-selected :selected}
|
||||||
|
(path/duplicate-node-content content node-index offset)]
|
||||||
|
(let [start (count sub)]
|
||||||
|
{:sub (into sub ext)
|
||||||
|
:selected (into selected (map #(+ start %)) ext-selected)})
|
||||||
|
{:sub sub :selected selected}))
|
||||||
|
{:sub fragment :selected (set (node-indices fragment))}
|
||||||
|
free)))
|
||||||
|
|
||||||
|
(defn remap-selection
|
||||||
|
"Remaps a grouped selection after path content changes."
|
||||||
|
[selection old-content new-content]
|
||||||
|
(let [selection (or selection empty-selection)]
|
||||||
|
(if (= (count old-content) (count new-content))
|
||||||
|
(-> selection
|
||||||
|
(update :handlers
|
||||||
|
(fn [handlers]
|
||||||
|
(into #{}
|
||||||
|
(filter (fn [[index _]]
|
||||||
|
(= :curve-to (:command (nth new-content index nil)))))
|
||||||
|
handlers)))
|
||||||
|
;; Drop indices that became subpath breaks.
|
||||||
|
(update :segments
|
||||||
|
(fn [segments]
|
||||||
|
(into #{}
|
||||||
|
(remove (fn [index]
|
||||||
|
(= :move-to (:command (nth new-content index nil)))))
|
||||||
|
segments))))
|
||||||
|
(assoc empty-selection
|
||||||
|
:nodes (remap-selected-nodes (get selection :nodes #{})
|
||||||
|
old-content
|
||||||
|
new-content)))))
|
||||||
|
|||||||
@ -7,9 +7,8 @@
|
|||||||
(ns app.main.data.workspace.path.selection
|
(ns app.main.data.workspace.path.selection
|
||||||
(:require
|
(:require
|
||||||
[app.common.data.macros :as dm]
|
[app.common.data.macros :as dm]
|
||||||
[app.common.geom.point :as gpt]
|
|
||||||
[app.common.geom.rect :as grc]
|
[app.common.geom.rect :as grc]
|
||||||
[app.common.geom.shapes :as gsh]
|
[app.main.data.workspace.path.helpers :as helpers]
|
||||||
[app.main.data.workspace.path.state :as st]
|
[app.main.data.workspace.path.state :as st]
|
||||||
[app.main.streams :as ms]
|
[app.main.streams :as ms]
|
||||||
[app.util.mouse :as mse]
|
[app.util.mouse :as mse]
|
||||||
@ -17,84 +16,140 @@
|
|||||||
[beicon.v2.operators :as rxo]
|
[beicon.v2.operators :as rxo]
|
||||||
[potok.v2.core :as ptk]))
|
[potok.v2.core :as ptk]))
|
||||||
|
|
||||||
(defn path-pointer-enter [position]
|
(defn path-pointer-enter [index]
|
||||||
(ptk/reify ::path-pointer-enter
|
(ptk/reify ::path-pointer-enter
|
||||||
ptk/UpdateEvent
|
ptk/UpdateEvent
|
||||||
(update [_ state]
|
(update [_ state]
|
||||||
(let [id (st/get-path-id state)]
|
(let [id (st/get-path-id state)]
|
||||||
(update-in state [:workspace-local :edit-path id :hover-points] (fnil conj #{}) position)))))
|
(update-in state [:workspace-local :edit-path id :hover :nodes] (fnil conj #{}) index)))))
|
||||||
|
|
||||||
(defn path-pointer-leave [position]
|
(defn path-pointer-leave [index]
|
||||||
(ptk/reify ::path-pointer-leave
|
(ptk/reify ::path-pointer-leave
|
||||||
ptk/UpdateEvent
|
ptk/UpdateEvent
|
||||||
(update [_ state]
|
(update [_ state]
|
||||||
(let [id (st/get-path-id state)]
|
(let [id (st/get-path-id state)]
|
||||||
(update-in state [:workspace-local :edit-path id :hover-points] disj position)))))
|
(update-in state [:workspace-local :edit-path id :hover :nodes] disj index)))))
|
||||||
|
|
||||||
(defn path-handler-enter [index prefix]
|
(defn path-handler-enter [index prefix]
|
||||||
(ptk/reify ::path-handler-enter
|
(ptk/reify ::path-handler-enter
|
||||||
ptk/UpdateEvent
|
ptk/UpdateEvent
|
||||||
(update [_ state]
|
(update [_ state]
|
||||||
(let [id (st/get-path-id state)]
|
(let [id (st/get-path-id state)]
|
||||||
(update-in state [:workspace-local :edit-path id :hover-handlers] (fnil conj #{}) [index prefix])))))
|
(update-in state [:workspace-local :edit-path id :hover :handlers] (fnil conj #{}) [index prefix])))))
|
||||||
|
|
||||||
(defn path-handler-leave [index prefix]
|
(defn path-handler-leave [index prefix]
|
||||||
(ptk/reify ::path-handler-leave
|
(ptk/reify ::path-handler-leave
|
||||||
ptk/UpdateEvent
|
ptk/UpdateEvent
|
||||||
(update [_ state]
|
(update [_ state]
|
||||||
(let [id (st/get-path-id state)]
|
(let [id (st/get-path-id state)]
|
||||||
(update-in state [:workspace-local :edit-path id :hover-handlers] disj [index prefix])))))
|
(update-in state [:workspace-local :edit-path id :hover :handlers] disj [index prefix])))))
|
||||||
|
|
||||||
(defn select-node-area
|
(defn path-segment-enter [index]
|
||||||
[initial-set remove?]
|
(ptk/reify ::path-segment-enter
|
||||||
(ptk/reify ::select-node-area
|
|
||||||
ptk/UpdateEvent
|
ptk/UpdateEvent
|
||||||
(update [_ state]
|
(update [_ state]
|
||||||
(let [selrect (dm/get-in state [:workspace-local :selrect])
|
(let [id (st/get-path-id state)]
|
||||||
id (dm/get-in state [:workspace-local :edition])
|
(update-in state [:workspace-local :edit-path id :hover :segments] (fnil conj #{}) index)))))
|
||||||
content (st/get-path state :content)
|
|
||||||
|
|
||||||
selected-point? (if (some? selrect)
|
(defn path-segment-leave [index]
|
||||||
(partial gsh/has-point-rect? selrect)
|
(ptk/reify ::path-segment-leave
|
||||||
(constantly false))
|
ptk/UpdateEvent
|
||||||
|
(update [_ state]
|
||||||
|
(let [id (st/get-path-id state)]
|
||||||
|
(update-in state [:workspace-local :edit-path id :hover :segments] disj index)))))
|
||||||
|
|
||||||
xform (comp (filter #(not (= (:command %) :close-path)))
|
(defn- select-element
|
||||||
(map (comp gpt/point :params))
|
[state type identity shift?]
|
||||||
(filter selected-point?))
|
(let [id (dm/get-in state [:workspace-local :edition])
|
||||||
positions (if remove?
|
selection (or (st/get-selection state id) helpers/empty-selection)
|
||||||
(apply disj initial-set (into #{} xform content))
|
selected (get selection type #{})
|
||||||
(into initial-set xform content))]
|
selection (cond
|
||||||
|
(and shift? (contains? selected identity))
|
||||||
|
(update selection type disj identity)
|
||||||
|
|
||||||
(cond-> state
|
shift?
|
||||||
(some? id)
|
(update selection type (fnil conj #{}) identity)
|
||||||
(assoc-in [:workspace-local :edit-path id :selected-points] positions))))))
|
|
||||||
|
|
||||||
(defn select-node [position shift?]
|
:else
|
||||||
|
(assoc helpers/empty-selection type #{identity}))]
|
||||||
|
(cond-> state
|
||||||
|
(some? id)
|
||||||
|
(assoc-in [:workspace-local :edit-path id :selection] selection))))
|
||||||
|
|
||||||
|
(defn select-node [index shift?]
|
||||||
(ptk/reify ::select-node
|
(ptk/reify ::select-node
|
||||||
ptk/UpdateEvent
|
ptk/UpdateEvent
|
||||||
(update [_ state]
|
(update [_ state]
|
||||||
(let [id (dm/get-in state [:workspace-local :edition])
|
(select-element state :nodes index shift?))))
|
||||||
selected-points (dm/get-in state [:workspace-local :edit-path id :selected-points] #{})
|
|
||||||
selected-points (cond
|
|
||||||
(and shift? (contains? selected-points position))
|
|
||||||
(disj selected-points position)
|
|
||||||
|
|
||||||
shift?
|
(defn select-segment [index shift?]
|
||||||
(conj selected-points position)
|
(ptk/reify ::select-segment
|
||||||
|
ptk/UpdateEvent
|
||||||
|
(update [_ state]
|
||||||
|
(select-element state :segments index shift?))))
|
||||||
|
|
||||||
:else
|
(defn select-handler [index prefix shift?]
|
||||||
#{position})]
|
(ptk/reify ::select-handler
|
||||||
(cond-> state
|
ptk/UpdateEvent
|
||||||
(some? id)
|
(update [_ state]
|
||||||
(assoc-in [:workspace-local :edit-path id :selected-points] selected-points))))))
|
(select-element state :handlers [index prefix] shift?))))
|
||||||
|
|
||||||
|
(defn- update-area-set
|
||||||
|
[initial-set in-rect remove?]
|
||||||
|
(if remove?
|
||||||
|
(apply disj initial-set in-rect)
|
||||||
|
(into initial-set in-rect)))
|
||||||
|
|
||||||
|
(defn select-path-area
|
||||||
|
[rect initial-selection remove?]
|
||||||
|
(ptk/reify ::select-path-area
|
||||||
|
ptk/UpdateEvent
|
||||||
|
(update [_ state]
|
||||||
|
(if-not (grc/rect? rect)
|
||||||
|
state
|
||||||
|
(let [id (dm/get-in state [:workspace-local :edition])
|
||||||
|
content (st/get-path state :content)
|
||||||
|
|
||||||
|
;; Marquee priority is nodes, segments, then handlers.
|
||||||
|
nodes (helpers/nodes-in-rect content rect)
|
||||||
|
segments (if (empty? nodes)
|
||||||
|
(helpers/segments-in-rect content rect)
|
||||||
|
#{})
|
||||||
|
handlers (if (and (empty? nodes) (empty? segments))
|
||||||
|
(helpers/handlers-in-rect content rect)
|
||||||
|
#{})
|
||||||
|
in-rect {:nodes nodes
|
||||||
|
:segments segments
|
||||||
|
:handlers handlers}
|
||||||
|
selection
|
||||||
|
(reduce-kv
|
||||||
|
(fn [selection type identities]
|
||||||
|
(assoc selection type
|
||||||
|
(update-area-set (get initial-selection type #{})
|
||||||
|
identities
|
||||||
|
remove?)))
|
||||||
|
helpers/empty-selection
|
||||||
|
in-rect)]
|
||||||
|
(cond-> state
|
||||||
|
(some? id)
|
||||||
|
(assoc-in [:workspace-local :edit-path id :selection] selection)))))))
|
||||||
|
|
||||||
(defn deselect-all []
|
(defn deselect-all []
|
||||||
(ptk/reify ::deselect-all
|
(ptk/reify ::deselect-all
|
||||||
ptk/UpdateEvent
|
ptk/UpdateEvent
|
||||||
(update [_ state]
|
(update [_ state]
|
||||||
(let [id (st/get-path-id state)]
|
(let [id (st/get-path-id state)]
|
||||||
(-> state
|
(assoc-in state [:workspace-local :edit-path id :selection] helpers/empty-selection)))))
|
||||||
(assoc-in [:workspace-local :edit-path id :selected-points] #{}))))))
|
|
||||||
|
(defn select-all-nodes []
|
||||||
|
(ptk/reify ::select-all-nodes
|
||||||
|
ptk/UpdateEvent
|
||||||
|
(update [_ state]
|
||||||
|
(let [id (st/get-path-id state)
|
||||||
|
content (st/get-path state :content)
|
||||||
|
selection (assoc helpers/empty-selection
|
||||||
|
:nodes (into #{} (helpers/node-indices content)))]
|
||||||
|
(assoc-in state [:workspace-local :edit-path id :selection] selection)))))
|
||||||
|
|
||||||
(defn update-area-selection
|
(defn update-area-selection
|
||||||
[rect]
|
[rect]
|
||||||
@ -123,10 +178,10 @@
|
|||||||
stopper (mse/drag-stopper stream)
|
stopper (mse/drag-stopper stream)
|
||||||
from-p @ms/mouse-position
|
from-p @ms/mouse-position
|
||||||
|
|
||||||
initial-set
|
initial-selection
|
||||||
(if (or append? remove?)
|
(if (or append? remove?)
|
||||||
(dm/get-in state [:workspace-local :edit-path id :selected-points] #{})
|
(or (st/get-selection state id) helpers/empty-selection)
|
||||||
#{})
|
helpers/empty-selection)
|
||||||
|
|
||||||
selrect-stream
|
selrect-stream
|
||||||
(->> ms/mouse-position
|
(->> ms/mouse-position
|
||||||
@ -141,20 +196,11 @@
|
|||||||
(rx/merge
|
(rx/merge
|
||||||
(->> selrect-stream
|
(->> selrect-stream
|
||||||
(rx/map update-area-selection))
|
(rx/map update-area-selection))
|
||||||
|
;; Limit path hit-testing to once per animation frame.
|
||||||
(->> selrect-stream
|
(->> selrect-stream
|
||||||
(rx/buffer-time 100)
|
(rx/buffer-time 16)
|
||||||
(rx/map last)
|
(rx/map last)
|
||||||
|
(rx/filter some?)
|
||||||
(rx/pipe (rxo/distinct-contiguous))
|
(rx/pipe (rxo/distinct-contiguous))
|
||||||
(rx/map #(select-node-area initial-set remove?))))
|
(rx/map #(select-path-area % initial-selection remove?))))
|
||||||
(rx/of (clear-area-selection))))))))
|
(rx/of (clear-area-selection))))))))
|
||||||
|
|
||||||
(defn update-selection
|
|
||||||
[point-change]
|
|
||||||
(ptk/reify ::update-selection
|
|
||||||
ptk/UpdateEvent
|
|
||||||
(update [_ state]
|
|
||||||
(let [id (st/get-path-id state)
|
|
||||||
selected-points (dm/get-in state [:workspace-local :edit-path id :selected-points] #{})
|
|
||||||
selected-points (into #{} (map point-change) selected-points)]
|
|
||||||
(-> state
|
|
||||||
(assoc-in [:workspace-local :edit-path id :selected-points] selected-points))))))
|
|
||||||
|
|||||||
@ -9,6 +9,7 @@
|
|||||||
[app.common.data :as d]
|
[app.common.data :as d]
|
||||||
[app.common.files.changes-builder :as pcb]
|
[app.common.files.changes-builder :as pcb]
|
||||||
[app.common.files.helpers :as cph]
|
[app.common.files.helpers :as cph]
|
||||||
|
[app.common.geom.matrix :as gmt]
|
||||||
[app.common.geom.shapes :as gsh]
|
[app.common.geom.shapes :as gsh]
|
||||||
[app.common.types.container :as ctn]
|
[app.common.types.container :as ctn]
|
||||||
[app.common.types.path :as path]
|
[app.common.types.path :as path]
|
||||||
@ -28,6 +29,18 @@
|
|||||||
:rx :ry :r1 :r2 :r3 :r4
|
:rx :ry :r1 :r2 :r3 :r4
|
||||||
:metadata])
|
:metadata])
|
||||||
|
|
||||||
|
(defn- flatten-path
|
||||||
|
"Resets a path to axis-aligned geometry."
|
||||||
|
[shape]
|
||||||
|
(-> shape
|
||||||
|
(assoc :rotation 0
|
||||||
|
:flip-x false
|
||||||
|
:flip-y false
|
||||||
|
:transform (gmt/matrix)
|
||||||
|
:transform-inverse (gmt/matrix))
|
||||||
|
(dissoc :selrect :points)
|
||||||
|
(path/update-geometry)))
|
||||||
|
|
||||||
(defn convert-selected-to-path
|
(defn convert-selected-to-path
|
||||||
([]
|
([]
|
||||||
(convert-selected-to-path nil))
|
(convert-selected-to-path nil))
|
||||||
@ -53,18 +66,21 @@
|
|||||||
(pcb/update-shapes
|
(pcb/update-shapes
|
||||||
selected
|
selected
|
||||||
(fn [shape]
|
(fn [shape]
|
||||||
(let [content (wasm.api/shape-to-path (:id shape))]
|
;; Keep path content in world coordinates.
|
||||||
(-> shape
|
(if (cph/path-shape? shape)
|
||||||
(assoc :type :path)
|
(flatten-path shape)
|
||||||
(cond-> (cph/text-shape? shape)
|
(let [content (wasm.api/shape-to-path (:id shape))]
|
||||||
(assoc :fills
|
(-> shape
|
||||||
(->> (txt/node-seq txt/is-text-node? (:content shape))
|
(assoc :type :path)
|
||||||
(map :fills)
|
(cond-> (cph/text-shape? shape)
|
||||||
(first))))
|
(assoc :fills
|
||||||
(cond-> (cph/image-shape? shape)
|
(->> (txt/node-seq txt/is-text-node? (:content shape))
|
||||||
(assoc :fill-image (get shape :metadata)))
|
(map :fills)
|
||||||
(d/without-keys dissoc-attrs)
|
(first))))
|
||||||
(path/update-geometry content)))))
|
(cond-> (cph/image-shape? shape)
|
||||||
|
(assoc :fill-image (get shape :metadata)))
|
||||||
|
(d/without-keys dissoc-attrs)
|
||||||
|
(path/update-geometry content))))))
|
||||||
(pcb/remove-objects children-ids))]
|
(pcb/remove-objects children-ids))]
|
||||||
(rx/of (dch/commit-changes changes)))
|
(rx/of (dch/commit-changes changes)))
|
||||||
|
|
||||||
|
|||||||
@ -9,6 +9,8 @@
|
|||||||
[app.main.data.shortcuts :as ds]
|
[app.main.data.shortcuts :as ds]
|
||||||
[app.main.data.workspace :as dw]
|
[app.main.data.workspace :as dw]
|
||||||
[app.main.data.workspace.path :as drp]
|
[app.main.data.workspace.path :as drp]
|
||||||
|
[app.main.data.workspace.path.common :as drp.common]
|
||||||
|
[app.main.data.workspace.path.state :as drp.state]
|
||||||
[app.main.store :as st]
|
[app.main.store :as st]
|
||||||
[beicon.v2.core :as rx]
|
[beicon.v2.core :as rx]
|
||||||
[potok.v2.core :as ptk]))
|
[potok.v2.core :as ptk]))
|
||||||
@ -19,12 +21,24 @@
|
|||||||
|
|
||||||
;; Shortcuts format https://github.com/ccampbell/mousetrap
|
;; Shortcuts format https://github.com/ccampbell/mousetrap
|
||||||
|
|
||||||
(defn esc-pressed []
|
(defn esc-pressed
|
||||||
|
"Maps Escape to finish, cancel, or exit for the current draw state."
|
||||||
|
[]
|
||||||
(ptk/reify ::esc-pressed
|
(ptk/reify ::esc-pressed
|
||||||
ptk/WatchEvent
|
ptk/WatchEvent
|
||||||
(watch [_ _ _]
|
(watch [_ state _]
|
||||||
;; Not interrupt when we're editing a path
|
(let [id (drp.state/get-path-id state)
|
||||||
(rx/of :interrupt))))
|
pending? (some? (get-in state [:workspace-local :edit-path id :last-point]))
|
||||||
|
edition (get-in state [:workspace-local :edition])]
|
||||||
|
(cond
|
||||||
|
(and pending? (nil? edition))
|
||||||
|
(rx/of (drp.common/finish-path))
|
||||||
|
|
||||||
|
pending?
|
||||||
|
(rx/of (drp.common/cancel-pending-segment))
|
||||||
|
|
||||||
|
:else
|
||||||
|
(rx/of :interrupt))))))
|
||||||
|
|
||||||
(def shortcuts
|
(def shortcuts
|
||||||
{:move-nodes {:tooltip "M"
|
{:move-nodes {:tooltip "M"
|
||||||
@ -45,7 +59,12 @@
|
|||||||
:delete-node {:tooltip (ds/supr)
|
:delete-node {:tooltip (ds/supr)
|
||||||
:command ["del" "backspace"]
|
:command ["del" "backspace"]
|
||||||
:subsections [:path-editor]
|
:subsections [:path-editor]
|
||||||
:fn #(st/emit! (drp/remove-node))}
|
:fn #(st/emit! (drp/delete-selected))}
|
||||||
|
|
||||||
|
:delete-node-and-segments {:tooltip (ds/shift (ds/supr))
|
||||||
|
:command ["shift+del" "shift+backspace"]
|
||||||
|
:subsections [:path-editor]
|
||||||
|
:fn #(st/emit! (drp/delete-selected-with-segments))}
|
||||||
|
|
||||||
:merge-nodes {:tooltip (ds/meta "J")
|
:merge-nodes {:tooltip (ds/meta "J")
|
||||||
:command (ds/c-mod "j")
|
:command (ds/c-mod "j")
|
||||||
@ -78,6 +97,46 @@
|
|||||||
:subsections [:path-editor]
|
:subsections [:path-editor]
|
||||||
:fn #(st/emit! (drp/toggle-snap))}
|
:fn #(st/emit! (drp/toggle-snap))}
|
||||||
|
|
||||||
|
:copy {:tooltip (ds/meta "C")
|
||||||
|
:command (ds/c-mod "c")
|
||||||
|
:subsections [:path-editor]
|
||||||
|
:fn #(st/emit! (drp/copy-selected-nodes))}
|
||||||
|
|
||||||
|
:cut {:tooltip (ds/meta "X")
|
||||||
|
:command (ds/c-mod "x")
|
||||||
|
:subsections [:path-editor]
|
||||||
|
:fn #(st/emit! (drp/cut-selected-nodes))}
|
||||||
|
|
||||||
|
:paste {:tooltip (ds/meta "V")
|
||||||
|
:command (ds/c-mod "v")
|
||||||
|
:subsections [:path-editor]
|
||||||
|
:fn #(st/emit! (drp/paste-nodes))}
|
||||||
|
|
||||||
|
:duplicate {:tooltip (ds/meta "D")
|
||||||
|
:command (ds/c-mod "d")
|
||||||
|
:subsections [:path-editor]
|
||||||
|
:fn #(st/emit! (drp/duplicate-selected))}
|
||||||
|
|
||||||
|
:select-all {:tooltip (ds/meta "A")
|
||||||
|
:command (ds/c-mod "a")
|
||||||
|
:subsections [:path-editor]
|
||||||
|
:fn #(st/emit! (drp/select-all-nodes))}
|
||||||
|
|
||||||
|
:deselect-all {:tooltip (ds/meta (ds/shift "A"))
|
||||||
|
:command (ds/c-mod "shift+a")
|
||||||
|
:subsections [:path-editor]
|
||||||
|
:fn #(st/emit! (drp/deselect-all))}
|
||||||
|
|
||||||
|
:flip-horizontal {:tooltip (ds/shift "H")
|
||||||
|
:command "shift+h"
|
||||||
|
:subsections [:path-editor]
|
||||||
|
:fn #(st/emit! (drp/flip-nodes :horizontal))}
|
||||||
|
|
||||||
|
:flip-vertical {:tooltip (ds/shift "V")
|
||||||
|
:command "shift+v"
|
||||||
|
:subsections [:path-editor]
|
||||||
|
:fn #(st/emit! (drp/flip-nodes :vertical))}
|
||||||
|
|
||||||
:escape {:tooltip (ds/esc)
|
:escape {:tooltip (ds/esc)
|
||||||
:command ["escape" "enter" "v"]
|
:command ["escape" "enter" "v"]
|
||||||
:fn #(st/emit! (esc-pressed))}
|
:fn #(st/emit! (esc-pressed))}
|
||||||
|
|||||||
@ -10,18 +10,50 @@
|
|||||||
[app.common.types.path.shape-to-path :as stp]))
|
[app.common.types.path.shape-to-path :as stp]))
|
||||||
|
|
||||||
(defn get-path-id
|
(defn get-path-id
|
||||||
"Retrieves the currently editing path id"
|
"Returns the active path id.
|
||||||
|
|
||||||
|
The drawing copy is preferred because it also exists during initial path
|
||||||
|
creation, before workspace edition has an id. The edition id is the fallback
|
||||||
|
while an existing path's drawing copy is being established."
|
||||||
[state]
|
[state]
|
||||||
(or (dm/get-in state [:workspace-local :edition])
|
(or (dm/get-in state [:workspace-drawing :object :id])
|
||||||
(dm/get-in state [:workspace-drawing :object :id])))
|
(dm/get-in state [:workspace-local :edition])))
|
||||||
|
|
||||||
|
(defn get-selection
|
||||||
|
"Returns the grouped selection for the active path or the supplied path id."
|
||||||
|
([state]
|
||||||
|
(get-selection state (get-path-id state)))
|
||||||
|
([state id]
|
||||||
|
(dm/get-in state [:workspace-local :edit-path id :selection])))
|
||||||
|
|
||||||
|
(defn current-edit-state
|
||||||
|
([state]
|
||||||
|
(current-edit-state (dm/get-in state [:workspace-local :edit-path])
|
||||||
|
(dm/get-in state [:workspace-local :edition])))
|
||||||
|
([edit-path id]
|
||||||
|
(get edit-path id)))
|
||||||
|
|
||||||
|
(defn editing?
|
||||||
|
([state]
|
||||||
|
(some? (current-edit-state state)))
|
||||||
|
([edit-path id]
|
||||||
|
(some? (current-edit-state edit-path id))))
|
||||||
|
|
||||||
|
(defn drawing?
|
||||||
|
([state]
|
||||||
|
(let [edition (dm/get-in state [:workspace-local :edition])
|
||||||
|
edit-path (dm/get-in state [:workspace-local :edit-path])]
|
||||||
|
(and (nil? edition)
|
||||||
|
(some? (get edit-path (get-path-id state))))))
|
||||||
|
([edit-state edition drawing-tool drawing-object]
|
||||||
|
(or (= :draw (:edit-mode edit-state))
|
||||||
|
(and (nil? edition)
|
||||||
|
(= :path (:type drawing-object))
|
||||||
|
(not= :curve drawing-tool)))))
|
||||||
|
|
||||||
(defn get-path-location
|
(defn get-path-location
|
||||||
[state & ks]
|
[_state & ks]
|
||||||
(if-let [edit-id (dm/get-in state [:workspace-local :edition])]
|
(into [:workspace-drawing :object] ks))
|
||||||
(let [page-id (:current-page-id state)
|
|
||||||
file-id (:current-file-id state)]
|
|
||||||
(into [:files file-id :data :pages-index page-id :objects edit-id] ks))
|
|
||||||
(into [:workspace-drawing :object] ks)))
|
|
||||||
|
|
||||||
(defn get-path
|
(defn get-path
|
||||||
"Retrieves the location of the path object and additionally can pass
|
"Retrieves the location of the path object and additionally can pass
|
||||||
|
|||||||
@ -9,6 +9,7 @@
|
|||||||
[app.common.data.macros :as dm]
|
[app.common.data.macros :as dm]
|
||||||
[app.common.geom.point :as gpt]
|
[app.common.geom.point :as gpt]
|
||||||
[app.common.types.path :as path]
|
[app.common.types.path :as path]
|
||||||
|
[app.main.data.workspace.edition :as-alias dwe]
|
||||||
[app.main.data.workspace.path.state :as pst]
|
[app.main.data.workspace.path.state :as pst]
|
||||||
[app.main.snap :as snap]
|
[app.main.snap :as snap]
|
||||||
[app.main.store :as st]
|
[app.main.store :as st]
|
||||||
@ -20,24 +21,30 @@
|
|||||||
|
|
||||||
(defonce drag-threshold 5)
|
(defonce drag-threshold 5)
|
||||||
|
|
||||||
|
(def ^:private half-pixel-snap-zoom
|
||||||
|
"Zoom threshold for half-pixel snapping."
|
||||||
|
3)
|
||||||
|
|
||||||
(defn dragging? [start zoom]
|
(defn dragging? [start zoom]
|
||||||
(fn [current]
|
(fn [current]
|
||||||
(>= (gpt/distance start current) (/ drag-threshold zoom))))
|
(>= (gpt/distance start current) (/ drag-threshold zoom))))
|
||||||
|
|
||||||
(defn finish-edition? [event]
|
(defn finish-edition?
|
||||||
(= (ptk/type event) :app.main.data.workspace.common/clear-edition-mode))
|
"True for the path edition stop event."
|
||||||
|
[event]
|
||||||
|
(= (ptk/type event) ::dwe/clear-edition-mode))
|
||||||
|
|
||||||
(defn to-pixel-snap [position]
|
(defn to-pixel-snap [position]
|
||||||
(let [layout (get @st/state :workspace-layout)
|
(let [layout (get @st/state :workspace-layout)
|
||||||
snap-pixel? (contains? layout :snap-pixel-grid)]
|
snap-pixel? (contains? layout :snap-pixel-grid)
|
||||||
|
zoom (get-in @st/state [:workspace-local :zoom] 1)]
|
||||||
|
|
||||||
(cond
|
(cond
|
||||||
(or (not snap-pixel?) (not (gpt/point? position)))
|
(or (not snap-pixel?) (not (gpt/point? position)))
|
||||||
position
|
position
|
||||||
|
|
||||||
|
|
||||||
:else
|
:else
|
||||||
(gpt/round position))))
|
(gpt/round-step position (if (> zoom half-pixel-snap-zoom) 0.5 1)))))
|
||||||
|
|
||||||
(defn drag-stream
|
(defn drag-stream
|
||||||
([to-stream]
|
([to-stream]
|
||||||
@ -79,13 +86,71 @@
|
|||||||
(-> (l/derived get-snap st/state)
|
(-> (l/derived get-snap st/state)
|
||||||
(rx/from-atom {:emit-current-value? true}))))
|
(rx/from-atom {:emit-current-value? true}))))
|
||||||
|
|
||||||
|
(def ^:private node-merge-snap-distance
|
||||||
|
"Maximum screen distance for node merge snapping."
|
||||||
|
10)
|
||||||
|
|
||||||
|
(def ^:private neighboring-cell-offsets
|
||||||
|
[[-1 -1] [-1 0] [-1 1]
|
||||||
|
[0 -1] [0 0] [0 1]
|
||||||
|
[1 -1] [1 0] [1 1]])
|
||||||
|
|
||||||
|
(defn- point-cell
|
||||||
|
[point cell-size]
|
||||||
|
[(js/Math.floor (/ (:x point) cell-size))
|
||||||
|
(js/Math.floor (/ (:y point) cell-size))])
|
||||||
|
|
||||||
|
(defn make-node-merge-snap
|
||||||
|
"Builds a stationary-node index and returns its merge snap function."
|
||||||
|
[start-point selected-points points max-distance]
|
||||||
|
(let [selected-points (set selected-points)
|
||||||
|
point-index (reduce
|
||||||
|
(fn [index point]
|
||||||
|
(if (contains? selected-points point)
|
||||||
|
index
|
||||||
|
(update index (point-cell point max-distance) (fnil conj []) point)))
|
||||||
|
{}
|
||||||
|
points)
|
||||||
|
closest-target (fn [closest moved-point]
|
||||||
|
(let [[cell-x cell-y] (point-cell moved-point max-distance)]
|
||||||
|
(reduce
|
||||||
|
(fn [closest [offset-x offset-y]]
|
||||||
|
(reduce
|
||||||
|
(fn [closest target]
|
||||||
|
(let [distance (gpt/distance moved-point target)]
|
||||||
|
(if (and (<= distance max-distance)
|
||||||
|
(or (nil? closest)
|
||||||
|
(< distance (first closest))))
|
||||||
|
[distance (gpt/subtract target moved-point)]
|
||||||
|
closest)))
|
||||||
|
closest
|
||||||
|
(get point-index [(+ cell-x offset-x) (+ cell-y offset-y)] [])))
|
||||||
|
closest
|
||||||
|
neighboring-cell-offsets)))]
|
||||||
|
(fn [position]
|
||||||
|
(let [delta (gpt/subtract position start-point)
|
||||||
|
closest (reduce
|
||||||
|
(fn [closest selected-point]
|
||||||
|
(closest-target closest (gpt/add selected-point delta)))
|
||||||
|
nil
|
||||||
|
selected-points)]
|
||||||
|
(when (some? closest)
|
||||||
|
(gpt/add position (second closest)))))))
|
||||||
|
|
||||||
(defn move-points-stream
|
(defn move-points-stream
|
||||||
[start-point selected-points points]
|
[start-point selected-points points]
|
||||||
|
|
||||||
(let [zoom (get-in @st/state [:workspace-local :zoom] 1)
|
(let [zoom (get-in @st/state [:workspace-local :zoom] 1)
|
||||||
|
snap-pixel? (contains? (get @st/state :workspace-layout) :snap-pixel-grid)
|
||||||
ranges (snap/create-ranges points selected-points)
|
ranges (snap/create-ranges points selected-points)
|
||||||
d-pos (/ snap/snap-path-accuracy zoom)
|
d-pos (/ snap/snap-path-accuracy zoom)
|
||||||
|
|
||||||
|
;; Build the merge index once per pixel-snapped gesture.
|
||||||
|
merge-distance (/ node-merge-snap-distance zoom)
|
||||||
|
node-merge-snap (when snap-pixel?
|
||||||
|
(make-node-merge-snap
|
||||||
|
start-point selected-points points merge-distance))
|
||||||
|
|
||||||
check-path-snap
|
check-path-snap
|
||||||
(fn [[position snap-toggled]]
|
(fn [[position snap-toggled]]
|
||||||
(if snap-toggled
|
(if snap-toggled
|
||||||
@ -93,16 +158,23 @@
|
|||||||
moved-points (->> selected-points (mapv #(gpt/add % delta)))
|
moved-points (->> selected-points (mapv #(gpt/add % delta)))
|
||||||
snap (snap/get-snap-delta moved-points ranges d-pos)]
|
snap (snap/get-snap-delta moved-points ranges d-pos)]
|
||||||
(gpt/add position snap))
|
(gpt/add position snap))
|
||||||
|
position))
|
||||||
|
|
||||||
|
;; Node merge snapping takes priority over the pixel grid.
|
||||||
|
snap-position
|
||||||
|
(fn [[position snap-toggled]]
|
||||||
|
(if (gpt/point? position)
|
||||||
|
(or (when node-merge-snap
|
||||||
|
(node-merge-snap position))
|
||||||
|
(check-path-snap [(to-pixel-snap position) snap-toggled]))
|
||||||
position))]
|
position))]
|
||||||
(->> ms/mouse-position
|
(->> ms/mouse-position
|
||||||
(rx/map to-pixel-snap)
|
|
||||||
(rx/with-latest-from (snap-toggled-stream))
|
(rx/with-latest-from (snap-toggled-stream))
|
||||||
(rx/map check-path-snap)
|
(rx/map snap-position)
|
||||||
(rx/with-latest-from
|
;; Apply keyboard modifiers without waiting for pointer movement.
|
||||||
(fn [position shift? alt?]
|
(rx/combine-latest-with ms/keyboard-shift ms/keyboard-alt)
|
||||||
(assoc position :shift? shift? :alt? alt?))
|
(rx/map (fn [[position shift? alt?]]
|
||||||
ms/mouse-position-shift
|
(assoc position :shift? shift? :alt? alt?))))))
|
||||||
ms/mouse-position-alt))))
|
|
||||||
|
|
||||||
(defn get-angle [node handler opposite]
|
(defn get-angle [node handler opposite]
|
||||||
(when (and (some? node) (some? handler) (some? opposite))
|
(when (and (some? node) (some? handler) (some? opposite))
|
||||||
@ -144,13 +216,13 @@
|
|||||||
(merge position (gpt/add position snap)))))
|
(merge position (gpt/add position snap)))))
|
||||||
position))]
|
position))]
|
||||||
|
|
||||||
|
;; Keep handler movement off the pixel grid.
|
||||||
(->> ms/mouse-position
|
(->> ms/mouse-position
|
||||||
(rx/map to-pixel-snap)
|
(rx/filter gpt/point?)
|
||||||
(rx/with-latest-from
|
;; Apply keyboard modifiers without waiting for pointer movement.
|
||||||
(fn [position shift? alt?]
|
(rx/combine-latest-with ms/keyboard-shift ms/keyboard-alt ms/keyboard-mod)
|
||||||
(assoc position :shift? shift? :alt? alt?))
|
(rx/map (fn [[position shift? alt? mod?]]
|
||||||
ms/mouse-position-shift
|
(assoc position :shift? shift? :alt? alt? :mod? mod?)))
|
||||||
ms/mouse-position-alt)
|
|
||||||
(rx/with-latest-from (snap-toggled-stream))
|
(rx/with-latest-from (snap-toggled-stream))
|
||||||
(rx/map check-path-snap))))
|
(rx/map check-path-snap))))
|
||||||
|
|
||||||
@ -171,6 +243,8 @@
|
|||||||
(rx/map snap/create-ranges))]
|
(rx/map snap/create-ranges))]
|
||||||
|
|
||||||
(->> ms/mouse-position
|
(->> ms/mouse-position
|
||||||
|
;; The subject can hold nil until the pointer enters the viewport
|
||||||
|
(rx/filter gpt/point?)
|
||||||
(rx/map to-pixel-snap)
|
(rx/map to-pixel-snap)
|
||||||
(rx/with-latest-from ranges-stream (snap-toggled-stream))
|
(rx/with-latest-from ranges-stream (snap-toggled-stream))
|
||||||
(rx/map (fn [[position ranges snap-toggled]]
|
(rx/map (fn [[position ranges snap-toggled]]
|
||||||
@ -178,8 +252,7 @@
|
|||||||
(let [snap (snap/get-snap-delta [position] ranges d-pos)]
|
(let [snap (snap/get-snap-delta [position] ranges d-pos)]
|
||||||
(gpt/add position snap))
|
(gpt/add position snap))
|
||||||
position)))
|
position)))
|
||||||
(rx/with-latest-from
|
;; Apply Shift without waiting for pointer movement.
|
||||||
(fn [position shift? alt?]
|
(rx/combine-latest-with ms/keyboard-shift ms/keyboard-alt)
|
||||||
(assoc position :shift? shift? :alt? alt?))
|
(rx/map (fn [[position shift? alt?]]
|
||||||
ms/mouse-position-shift
|
(assoc position :shift? shift? :alt? alt?))))))
|
||||||
ms/mouse-position-alt))))
|
|
||||||
|
|||||||
@ -6,50 +6,56 @@
|
|||||||
|
|
||||||
(ns app.main.data.workspace.path.tools
|
(ns app.main.data.workspace.path.tools
|
||||||
(:require
|
(:require
|
||||||
|
[app.common.data :as d]
|
||||||
[app.common.data.macros :as dm]
|
[app.common.data.macros :as dm]
|
||||||
|
[app.common.geom.point :as gpt]
|
||||||
[app.common.types.path :as path]
|
[app.common.types.path :as path]
|
||||||
[app.main.data.changes :as dch]
|
|
||||||
[app.main.data.helpers :as dsh]
|
|
||||||
[app.main.data.workspace.edition :as dwe]
|
[app.main.data.workspace.edition :as dwe]
|
||||||
[app.main.data.workspace.path.changes :as changes]
|
[app.main.data.workspace.path.helpers :as helpers]
|
||||||
[app.main.data.workspace.path.state :as st]
|
[app.main.data.workspace.path.state :as st]
|
||||||
[app.main.data.workspace.shapes :as dwsh]
|
[app.main.store :as store]
|
||||||
[beicon.v2.core :as rx]
|
[beicon.v2.core :as rx]
|
||||||
[potok.v2.core :as ptk]))
|
[potok.v2.core :as ptk]))
|
||||||
|
|
||||||
(defn process-path-tool
|
(defn process-path-tool
|
||||||
"Generic function that executes path transformations with the content and selected nodes"
|
"Runs a position-based path tool and remaps the selection."
|
||||||
([tool-fn]
|
([tool-fn]
|
||||||
(process-path-tool nil tool-fn))
|
(process-path-tool nil tool-fn))
|
||||||
([points tool-fn]
|
([points tool-fn]
|
||||||
(ptk/reify ::process-path-tool
|
(ptk/reify ::process-path-tool
|
||||||
ptk/WatchEvent
|
ptk/UpdateEvent
|
||||||
(watch [it state _]
|
(update [_ state]
|
||||||
(let [page-id (get state :current-page-id)
|
(let [shape (st/get-path state)
|
||||||
objects (dsh/lookup-page-objects state page-id)
|
id (st/get-path-id state)
|
||||||
|
|
||||||
shape (st/get-path state)
|
old-content (:content shape)
|
||||||
id (st/get-path-id state)
|
|
||||||
|
|
||||||
selected-points
|
;; Segment selections include their endpoint nodes.
|
||||||
(dm/get-in state [:workspace-local :edit-path id :selected-points] #{})
|
selected-nodes
|
||||||
|
(helpers/selected-node-indices
|
||||||
|
old-content
|
||||||
|
(st/get-selection state id))
|
||||||
|
|
||||||
points
|
points
|
||||||
(or points selected-points)]
|
(or points (helpers/node-positions old-content selected-nodes))]
|
||||||
|
|
||||||
(when (and (seq points) (some? shape))
|
(if (and (seq points) (some? shape))
|
||||||
(let [new-content
|
(let [new-content
|
||||||
(-> (tool-fn (:content shape) points)
|
(-> (tool-fn old-content points)
|
||||||
(path/close-subpaths))
|
(path/close-subpaths))]
|
||||||
|
(-> (cond-> (st/set-content state new-content)
|
||||||
|
(seq new-content)
|
||||||
|
(update-in (st/get-path-location state) path/update-geometry))
|
||||||
|
(update-in [:workspace-local :edit-path id :selection]
|
||||||
|
#(helpers/remap-selection % old-content new-content))
|
||||||
|
(update-in [:workspace-local :edit-path id :handler-types]
|
||||||
|
#(helpers/remap-handler-types % old-content new-content))))
|
||||||
|
state)))
|
||||||
|
|
||||||
changes
|
ptk/WatchEvent
|
||||||
(changes/generate-path-changes it objects page-id shape (:content shape) new-content)]
|
(watch [_ state _]
|
||||||
|
(when (empty? (st/get-path state :content))
|
||||||
(rx/concat
|
(rx/of (dwe/clear-edition-mode)))))))
|
||||||
(rx/of (dwsh/update-shapes [id] path/convert-to-path)
|
|
||||||
(dch/commit-changes changes))
|
|
||||||
(when (empty? new-content)
|
|
||||||
(rx/of (dwe/clear-edition-mode)))))))))))
|
|
||||||
|
|
||||||
(defn make-corner
|
(defn make-corner
|
||||||
([]
|
([]
|
||||||
@ -58,7 +64,9 @@
|
|||||||
(process-path-tool
|
(process-path-tool
|
||||||
(when point #{point})
|
(when point #{point})
|
||||||
(fn [content points]
|
(fn [content points]
|
||||||
(reduce path/make-corner-point content points)))))
|
(->> points
|
||||||
|
(filter #(path/is-curve-point? content %))
|
||||||
|
(reduce path/make-corner-point content))))))
|
||||||
|
|
||||||
(defn make-curve
|
(defn make-curve
|
||||||
([]
|
([]
|
||||||
@ -67,13 +75,154 @@
|
|||||||
(process-path-tool
|
(process-path-tool
|
||||||
(when point #{point})
|
(when point #{point})
|
||||||
(fn [content points]
|
(fn [content points]
|
||||||
(reduce path/make-curve-point content points)))))
|
(->> points
|
||||||
|
(remove #(path/is-curve-point? content %))
|
||||||
|
(reduce path/make-curve-point content))))))
|
||||||
|
|
||||||
|
(defn- apply-handler-type-modifiers
|
||||||
|
"Returns modifiers that reshape a node's handlers to `type`."
|
||||||
|
[content node-index type]
|
||||||
|
(if-let [[idx prefix] (helpers/node-primary-handler content node-index)]
|
||||||
|
(case type
|
||||||
|
:mirror (helpers/move-handler-modifiers content idx prefix true true true 0 0)
|
||||||
|
:aligned (helpers/align-handler-modifiers content idx prefix 0 0)
|
||||||
|
{})
|
||||||
|
{}))
|
||||||
|
|
||||||
|
(defn set-handler-type
|
||||||
|
"Sets and stores the handler behavior of selected nodes."
|
||||||
|
[type]
|
||||||
|
(ptk/reify ::set-handler-type
|
||||||
|
ptk/UpdateEvent
|
||||||
|
(update [_ state]
|
||||||
|
(let [id (st/get-path-id state)
|
||||||
|
content (st/get-path state :content)
|
||||||
|
selection (st/get-selection state id)
|
||||||
|
nodes (helpers/handler-target-nodes content selection)]
|
||||||
|
(if (and (some? content) (seq nodes))
|
||||||
|
(let [modifiers (reduce (fn [acc node-index]
|
||||||
|
(d/deep-merge acc (apply-handler-type-modifiers content node-index type)))
|
||||||
|
{} nodes)
|
||||||
|
new-content (path/apply-content-modifiers content modifiers)]
|
||||||
|
(-> (st/set-content state new-content)
|
||||||
|
(update-in (st/get-path-location state) path/update-geometry)
|
||||||
|
(update-in [:workspace-local :edit-path id :handler-types]
|
||||||
|
(fn [ht] (reduce #(assoc %1 %2 type) (or ht {}) nodes)))))
|
||||||
|
state)))))
|
||||||
|
|
||||||
(defn add-node []
|
(defn add-node []
|
||||||
(process-path-tool (fn [content points] (path/split-segments content points 0.5))))
|
(process-path-tool (fn [content points] (path/split-segments content points 0.5))))
|
||||||
|
|
||||||
(defn remove-node []
|
(defn remove-node
|
||||||
(process-path-tool path/remove-nodes))
|
"Removes nodes and heals the gap with a fitted curve."
|
||||||
|
([]
|
||||||
|
(process-path-tool path/remove-nodes))
|
||||||
|
([point]
|
||||||
|
(process-path-tool #{point} path/remove-nodes)))
|
||||||
|
|
||||||
|
(defn toggle-node-curve
|
||||||
|
"Toggles a node between a corner and a curve."
|
||||||
|
[index]
|
||||||
|
(ptk/reify ::toggle-node-curve
|
||||||
|
ptk/WatchEvent
|
||||||
|
(watch [_ state _]
|
||||||
|
(let [content (st/get-path state :content)]
|
||||||
|
(when (and (some? content)
|
||||||
|
(< index (count content))
|
||||||
|
(helpers/node? content index))
|
||||||
|
(let [point (helpers/node-position content index)]
|
||||||
|
(rx/of (if (path/is-curve-point? content point)
|
||||||
|
(make-corner point)
|
||||||
|
(make-curve point)))))))))
|
||||||
|
|
||||||
|
(defn- update-path-content
|
||||||
|
"Updates path content, geometry, selection, and handler types."
|
||||||
|
[state new-content]
|
||||||
|
(let [id (st/get-path-id state)
|
||||||
|
old-content (st/get-path state :content)]
|
||||||
|
(-> (cond-> (st/set-content state new-content)
|
||||||
|
(seq new-content)
|
||||||
|
(update-in (st/get-path-location state) path/update-geometry))
|
||||||
|
(update-in [:workspace-local :edit-path id :selection]
|
||||||
|
#(helpers/remap-selection % old-content new-content))
|
||||||
|
(update-in [:workspace-local :edit-path id :handler-types]
|
||||||
|
#(helpers/remap-handler-types % old-content new-content)))))
|
||||||
|
|
||||||
|
(defn remove-segments
|
||||||
|
"Removes segments and opens the path at their endpoints."
|
||||||
|
[indices]
|
||||||
|
(ptk/reify ::remove-segments
|
||||||
|
ptk/UpdateEvent
|
||||||
|
(update [_ state]
|
||||||
|
(let [content (st/get-path state :content)]
|
||||||
|
(if (and (some? content) (seq indices))
|
||||||
|
(update-path-content state (path/remove-segments content indices))
|
||||||
|
state)))
|
||||||
|
|
||||||
|
ptk/WatchEvent
|
||||||
|
(watch [_ state _]
|
||||||
|
(when (empty? (st/get-path state :content))
|
||||||
|
(rx/of (dwe/clear-edition-mode))))))
|
||||||
|
|
||||||
|
(defn remove-segment
|
||||||
|
[index]
|
||||||
|
(remove-segments #{index}))
|
||||||
|
|
||||||
|
(defn remove-node-with-segments
|
||||||
|
"Removes a node and its incident segments without healing the gap."
|
||||||
|
[index]
|
||||||
|
(ptk/reify ::remove-node-with-segments
|
||||||
|
ptk/WatchEvent
|
||||||
|
(watch [_ state _]
|
||||||
|
(let [content (st/get-path state :content)
|
||||||
|
incident (into #{}
|
||||||
|
(comp (filter #(or (= index (:to-index %))
|
||||||
|
(= index (:from-index %))))
|
||||||
|
(map :index))
|
||||||
|
(helpers/segment-entries content))]
|
||||||
|
(when (seq incident)
|
||||||
|
(rx/of (remove-segments incident)))))))
|
||||||
|
|
||||||
|
(defn delete-selected-with-segments
|
||||||
|
"Removes selected nodes and their incident segments without healing."
|
||||||
|
[]
|
||||||
|
(ptk/reify ::delete-selected-with-segments
|
||||||
|
ptk/WatchEvent
|
||||||
|
(watch [_ state _]
|
||||||
|
(let [id (st/get-path-id state)
|
||||||
|
content (st/get-path state :content)
|
||||||
|
selected (helpers/selected-node-indices
|
||||||
|
content
|
||||||
|
(st/get-selection state id))
|
||||||
|
incident (into #{}
|
||||||
|
(comp (filter #(or (contains? selected (:to-index %))
|
||||||
|
(contains? selected (:from-index %))))
|
||||||
|
(map :index))
|
||||||
|
(helpers/segment-entries content))]
|
||||||
|
(when (seq incident)
|
||||||
|
(rx/of (remove-segments incident)))))))
|
||||||
|
|
||||||
|
(defn toggle-segment-curve
|
||||||
|
"Toggles a segment between a line and a curve."
|
||||||
|
[index]
|
||||||
|
(ptk/reify ::toggle-segment-curve
|
||||||
|
ptk/UpdateEvent
|
||||||
|
(update [_ state]
|
||||||
|
(let [content (st/get-path state :content)]
|
||||||
|
(if (some? content)
|
||||||
|
(update-path-content state (path/toggle-segment-curve content index))
|
||||||
|
state)))))
|
||||||
|
|
||||||
|
(defn remove-handler
|
||||||
|
"Collapses one handler onto its node."
|
||||||
|
[index prefix]
|
||||||
|
(ptk/reify ::remove-handler
|
||||||
|
ptk/UpdateEvent
|
||||||
|
(update [_ state]
|
||||||
|
(let [content (st/get-path state :content)]
|
||||||
|
(if (some? content)
|
||||||
|
(update-path-content state (path/collapse-handler content index prefix))
|
||||||
|
state)))))
|
||||||
|
|
||||||
(defn merge-nodes []
|
(defn merge-nodes []
|
||||||
(process-path-tool path/merge-nodes))
|
(process-path-tool path/merge-nodes))
|
||||||
@ -81,8 +230,157 @@
|
|||||||
(defn join-nodes []
|
(defn join-nodes []
|
||||||
(process-path-tool path/join-nodes))
|
(process-path-tool path/join-nodes))
|
||||||
|
|
||||||
|
(def ^:private separate-node-screen-offset
|
||||||
|
"Screen offset between separated node ends."
|
||||||
|
8)
|
||||||
|
|
||||||
(defn separate-nodes []
|
(defn separate-nodes []
|
||||||
(process-path-tool path/separate-nodes))
|
;; Keep the visible gap stable across zoom levels.
|
||||||
|
(let [zoom (get-in @store/state [:workspace-local :zoom] 1)
|
||||||
|
step (/ separate-node-screen-offset zoom)
|
||||||
|
offset (gpt/point step step)]
|
||||||
|
(process-path-tool
|
||||||
|
(fn [content points]
|
||||||
|
(path/separate-nodes content points offset)))))
|
||||||
|
|
||||||
|
(defn delete-selected
|
||||||
|
"Heals selected nodes or opens selected segments."
|
||||||
|
[]
|
||||||
|
(ptk/reify ::delete-selected
|
||||||
|
ptk/WatchEvent
|
||||||
|
(watch [_ state _]
|
||||||
|
(let [id (st/get-path-id state)
|
||||||
|
content (st/get-path state :content)
|
||||||
|
selection (st/get-selection state id)
|
||||||
|
nodes (get selection :nodes #{})
|
||||||
|
segments (get selection :segments #{})]
|
||||||
|
(rx/of
|
||||||
|
(cond
|
||||||
|
;; Node selection takes priority in mixed selections.
|
||||||
|
(seq nodes)
|
||||||
|
(process-path-tool (helpers/node-positions content nodes) path/remove-nodes)
|
||||||
|
|
||||||
|
;; Segment-only selection opens the path.
|
||||||
|
(seq segments)
|
||||||
|
(separate-nodes)
|
||||||
|
|
||||||
|
:else
|
||||||
|
(remove-node)))))))
|
||||||
|
|
||||||
|
(defn flip-nodes
|
||||||
|
"Flips selected nodes, or the whole path when none are selected."
|
||||||
|
[axis]
|
||||||
|
(ptk/reify ::flip-nodes
|
||||||
|
ptk/UpdateEvent
|
||||||
|
(update [_ state]
|
||||||
|
(let [id (st/get-path-id state)
|
||||||
|
content (st/get-path state :content)
|
||||||
|
selected (helpers/selected-node-indices
|
||||||
|
content
|
||||||
|
(st/get-selection state id))
|
||||||
|
indices (if (seq selected)
|
||||||
|
selected
|
||||||
|
(helpers/node-indices content))
|
||||||
|
content (path/flip-content content indices axis)]
|
||||||
|
(-> (st/set-content state content)
|
||||||
|
(update-in (st/get-path-location state) path/update-geometry))))))
|
||||||
|
|
||||||
|
(defn align-nodes
|
||||||
|
"Aligns selected nodes and their handles within their bounds."
|
||||||
|
[axis]
|
||||||
|
(ptk/reify ::align-nodes
|
||||||
|
ptk/UpdateEvent
|
||||||
|
(update [_ state]
|
||||||
|
(let [id (st/get-path-id state)
|
||||||
|
content (st/get-path state :content)
|
||||||
|
selected (get (st/get-selection state id) :nodes #{})
|
||||||
|
content (path/align-content content selected axis)]
|
||||||
|
(-> (st/set-content state content)
|
||||||
|
(update-in (st/get-path-location state) path/update-geometry))))))
|
||||||
|
|
||||||
|
(defn distribute-nodes
|
||||||
|
"Distributes selected nodes evenly along `axis`."
|
||||||
|
[axis]
|
||||||
|
(ptk/reify ::distribute-nodes
|
||||||
|
ptk/UpdateEvent
|
||||||
|
(update [_ state]
|
||||||
|
(let [id (st/get-path-id state)
|
||||||
|
content (st/get-path state :content)
|
||||||
|
selected (get (st/get-selection state id) :nodes #{})
|
||||||
|
content (path/distribute-content content selected axis)]
|
||||||
|
(-> (st/set-content state content)
|
||||||
|
(update-in (st/get-path-location state) path/update-geometry))))))
|
||||||
|
|
||||||
|
(defn- axis-point
|
||||||
|
"Copy of `p` with `axis` (`:x`/`:y`) replaced by `value`."
|
||||||
|
[p axis value]
|
||||||
|
(if (= axis :x) (gpt/point value (:y p)) (gpt/point (:x p) value)))
|
||||||
|
|
||||||
|
(defn- handler-target-points
|
||||||
|
"Returns handler targets for an absolute coordinate edit."
|
||||||
|
[content handlers handler-types axis value]
|
||||||
|
(reduce
|
||||||
|
(fn [pts [index prefix]]
|
||||||
|
(let [hp (path/get-handler-point content index prefix)
|
||||||
|
hp' (axis-point hp axis value)
|
||||||
|
node-index (helpers/handler-node-index index prefix)
|
||||||
|
mode (or (get handler-types node-index)
|
||||||
|
(helpers/derive-handler-type content node-index))
|
||||||
|
[op-idx op-prefix] (path/opposite-index content index prefix)
|
||||||
|
pts (assoc pts [index prefix] hp')]
|
||||||
|
(if (and (contains? #{:mirror :aligned} mode) (some? op-idx))
|
||||||
|
(let [node (path/handler->node content index prefix)
|
||||||
|
opp (path/get-handler-point content op-idx op-prefix)
|
||||||
|
opp' (helpers/opposite-handler-target node hp' opp mode)]
|
||||||
|
(assoc pts [op-idx op-prefix] opp'))
|
||||||
|
pts)))
|
||||||
|
{}
|
||||||
|
handlers))
|
||||||
|
|
||||||
|
(defn- translated-handler-target-points
|
||||||
|
"Returns standalone handler targets for a group translation."
|
||||||
|
[content handlers node-indices delta]
|
||||||
|
(into {}
|
||||||
|
(comp
|
||||||
|
(remove (fn [[index prefix]]
|
||||||
|
(contains? node-indices
|
||||||
|
(helpers/handler-node-index index prefix))))
|
||||||
|
(keep (fn [[index prefix :as identity]]
|
||||||
|
(when-let [point (path/get-handler-point content index prefix)]
|
||||||
|
[identity (gpt/add point delta)]))))
|
||||||
|
handlers))
|
||||||
|
|
||||||
|
(defn set-selection-coordinate
|
||||||
|
"Sets one coordinate of the current path selection."
|
||||||
|
[axis value]
|
||||||
|
(ptk/reify ::set-selection-coordinate
|
||||||
|
ptk/UpdateEvent
|
||||||
|
(update [_ state]
|
||||||
|
(let [id (st/get-path-id state)
|
||||||
|
content (st/get-path state :content)
|
||||||
|
selection (st/get-selection state id)
|
||||||
|
htypes (dm/get-in state [:workspace-local :edit-path id :handler-types])
|
||||||
|
segments (get selection :segments #{})
|
||||||
|
handlers (get selection :handlers #{})
|
||||||
|
node-idx (helpers/selected-node-indices content selection)
|
||||||
|
|
||||||
|
new-content
|
||||||
|
(if (seq segments)
|
||||||
|
;; Translate segment selections as one group.
|
||||||
|
(let [rect (helpers/selection-coordinate-rect content selection)
|
||||||
|
cur (if (= axis :x) (dm/get-prop rect :x) (dm/get-prop rect :y))
|
||||||
|
delta (axis-point (gpt/point 0 0) axis (- value cur))
|
||||||
|
htargets (translated-handler-target-points
|
||||||
|
content handlers node-idx delta)]
|
||||||
|
(cond-> (path/translate-selected-nodes content node-idx delta)
|
||||||
|
(seq htargets) (path/set-handler-points htargets)))
|
||||||
|
;; Set node and handler coordinates directly.
|
||||||
|
(let [pts (handler-target-points content handlers htypes axis value)]
|
||||||
|
(cond-> content
|
||||||
|
(seq node-idx) (path/set-nodes-coordinate node-idx axis value)
|
||||||
|
(seq pts) (path/set-handler-points pts))))]
|
||||||
|
(-> (st/set-content state new-content)
|
||||||
|
(update-in (st/get-path-location state) path/update-geometry))))))
|
||||||
|
|
||||||
(defn toggle-snap []
|
(defn toggle-snap []
|
||||||
(ptk/reify ::toggle-snap
|
(ptk/reify ::toggle-snap
|
||||||
|
|||||||
@ -12,8 +12,8 @@
|
|||||||
[app.main.data.workspace.common :as dwc]
|
[app.main.data.workspace.common :as dwc]
|
||||||
[app.main.data.workspace.edition :as-alias dwe]
|
[app.main.data.workspace.edition :as-alias dwe]
|
||||||
[app.main.data.workspace.pages :as-alias dwpg]
|
[app.main.data.workspace.pages :as-alias dwpg]
|
||||||
[app.main.data.workspace.path.changes :as changes]
|
|
||||||
[app.main.data.workspace.path.common :as common]
|
[app.main.data.workspace.path.common :as common]
|
||||||
|
[app.main.data.workspace.path.helpers :as helpers]
|
||||||
[app.main.data.workspace.path.state :as st]
|
[app.main.data.workspace.path.state :as st]
|
||||||
[app.main.store :as store]
|
[app.main.store :as store]
|
||||||
[beicon.v2.core :as rx]
|
[beicon.v2.core :as rx]
|
||||||
@ -28,17 +28,17 @@
|
|||||||
[event]
|
[event]
|
||||||
(= :app.main.data.workspace.common/redo (ptk/type event)))
|
(= :app.main.data.workspace.common/redo (ptk/type event)))
|
||||||
|
|
||||||
|
;; Undo entries skip the render-only preview.
|
||||||
(defn- make-entry [state]
|
(defn- make-entry [state]
|
||||||
(let [id (st/get-path-id state)
|
(let [id (st/get-path-id state)
|
||||||
shape (st/get-path state)]
|
shape (st/get-path state)]
|
||||||
{:content (:content shape)
|
{:content (:content shape)
|
||||||
:selrect (:selrect shape)
|
:selrect (:selrect shape)
|
||||||
:points (:points shape)
|
:points (:points shape)
|
||||||
:preview (get-in state [:workspace-local :edit-path id :preview])
|
|
||||||
:last-point (get-in state [:workspace-local :edit-path id :last-point])
|
:last-point (get-in state [:workspace-local :edit-path id :last-point])
|
||||||
:prev-handler (get-in state [:workspace-local :edit-path id :prev-handler])}))
|
:prev-handler (get-in state [:workspace-local :edit-path id :prev-handler])}))
|
||||||
|
|
||||||
(defn- load-entry [state {:keys [content selrect points preview last-point prev-handler]}]
|
(defn- load-entry [state {:keys [content selrect points last-point prev-handler]}]
|
||||||
(let [id (st/get-path-id state)
|
(let [id (st/get-path-id state)
|
||||||
old-content (st/get-path state :content)]
|
old-content (st/get-path state :content)]
|
||||||
(-> state
|
(-> state
|
||||||
@ -47,11 +47,14 @@
|
|||||||
(d/assoc-in-when (st/get-path-location state :points) points)
|
(d/assoc-in-when (st/get-path-location state :points) points)
|
||||||
(d/update-in-when
|
(d/update-in-when
|
||||||
[:workspace-local :edit-path id]
|
[:workspace-local :edit-path id]
|
||||||
assoc
|
(fn [edit-state]
|
||||||
:preview preview
|
;; Remap the selection to the restored content.
|
||||||
:last-point last-point
|
(cond-> (assoc edit-state
|
||||||
:prev-handler prev-handler
|
:preview nil
|
||||||
:old-content old-content))))
|
:last-point last-point
|
||||||
|
:prev-handler prev-handler)
|
||||||
|
(some? content)
|
||||||
|
(update :selection helpers/remap-selection old-content content)))))))
|
||||||
|
|
||||||
(defn undo-path []
|
(defn undo-path []
|
||||||
(ptk/reify ::undo-path
|
(ptk/reify ::undo-path
|
||||||
@ -72,10 +75,8 @@
|
|||||||
(watch [_ state _]
|
(watch [_ state _]
|
||||||
(let [id (st/get-path-id state)
|
(let [id (st/get-path-id state)
|
||||||
undo-stack (get-in state [:workspace-local :edit-path id :undo-stack])]
|
undo-stack (get-in state [:workspace-local :edit-path id :undo-stack])]
|
||||||
(if (> (:index undo-stack) 0)
|
(when (zero? (:index undo-stack))
|
||||||
(rx/of (changes/save-path-content {:preserve-move-to true}))
|
(rx/of (common/finish-path)
|
||||||
(rx/of (changes/save-path-content {:preserve-move-to true})
|
|
||||||
(common/finish-path)
|
|
||||||
(dwc/show-toolbar)))))))
|
(dwc/show-toolbar)))))))
|
||||||
|
|
||||||
(defn redo-path []
|
(defn redo-path []
|
||||||
@ -90,11 +91,7 @@
|
|||||||
(load-entry entry)
|
(load-entry entry)
|
||||||
(d/assoc-in-when
|
(d/assoc-in-when
|
||||||
[:workspace-local :edit-path id :undo-stack]
|
[:workspace-local :edit-path id :undo-stack]
|
||||||
undo-stack))))
|
undo-stack))))))
|
||||||
|
|
||||||
ptk/WatchEvent
|
|
||||||
(watch [_ _ _]
|
|
||||||
(rx/of (changes/save-path-content)))))
|
|
||||||
|
|
||||||
(defn merge-head
|
(defn merge-head
|
||||||
"Joins the head with the previous undo in one. This is done so when the user changes a
|
"Joins the head with the previous undo in one. This is done so when the user changes a
|
||||||
@ -171,4 +168,3 @@
|
|||||||
(rx/map #(add-undo-entry)))
|
(rx/map #(add-undo-entry)))
|
||||||
|
|
||||||
(rx/of (end-path-undo))))))))))
|
(rx/of (end-path-undo))))))))))
|
||||||
|
|
||||||
|
|||||||
@ -23,6 +23,8 @@
|
|||||||
[app.common.types.component :as ctk]
|
[app.common.types.component :as ctk]
|
||||||
[app.common.types.container :as ctn]
|
[app.common.types.container :as ctn]
|
||||||
[app.common.types.modifiers :as ctm]
|
[app.common.types.modifiers :as ctm]
|
||||||
|
[app.common.types.path :as path]
|
||||||
|
[app.common.types.path.helpers :as path.helpers]
|
||||||
[app.common.types.shape-tree :as ctst]
|
[app.common.types.shape-tree :as ctst]
|
||||||
[app.common.types.shape.attrs :refer [editable-attrs]]
|
[app.common.types.shape.attrs :refer [editable-attrs]]
|
||||||
[app.common.types.shape.layout :as ctl]
|
[app.common.types.shape.layout :as ctl]
|
||||||
@ -363,6 +365,71 @@
|
|||||||
(dwm/apply-modifiers)
|
(dwm/apply-modifiers)
|
||||||
(finish-transform))))))))))))
|
(finish-transform))))))))))))
|
||||||
|
|
||||||
|
(defn start-move-line-point
|
||||||
|
"Drags one endpoint of a straight path while keeping the other fixed."
|
||||||
|
[shape index]
|
||||||
|
(ptk/reify ::start-move-line-point
|
||||||
|
ptk/WatchEvent
|
||||||
|
(watch [_ state stream]
|
||||||
|
(let [id (dm/get-prop shape :id)
|
||||||
|
page-id (:current-page-id state)
|
||||||
|
objects (dsh/lookup-page-objects state page-id)
|
||||||
|
zoom (dm/get-in state [:workspace-local :zoom] 1)
|
||||||
|
layout (:workspace-layout state)
|
||||||
|
focus (:workspace-focus-selected state)
|
||||||
|
|
||||||
|
content (dm/get-prop shape :content)
|
||||||
|
start-point (path.helpers/segment->point (nth content index))
|
||||||
|
other-point (path.helpers/segment->point (nth content (if (zero? index) 1 0)))
|
||||||
|
|
||||||
|
stopper (mse/drag-stopper stream)
|
||||||
|
|
||||||
|
;; Shift constrains the endpoint around the fixed point.
|
||||||
|
position-stream
|
||||||
|
(->> ms/mouse-position
|
||||||
|
(rx/filter some?)
|
||||||
|
(rx/with-latest-from ms/mouse-position-shift)
|
||||||
|
(rx/switch-map
|
||||||
|
(fn [[pos shift?]]
|
||||||
|
(if ^boolean shift?
|
||||||
|
(rx/of (path.helpers/position-fixed-angle pos other-point))
|
||||||
|
(snap/closest-snap-point page-id [shape] objects layout zoom focus pos))))
|
||||||
|
(rx/share))
|
||||||
|
|
||||||
|
move-endpoint
|
||||||
|
(fn [pos save-undo?]
|
||||||
|
(let [delta (gpt/to-vec start-point pos)]
|
||||||
|
(dwsh/update-shapes
|
||||||
|
[id]
|
||||||
|
(fn [_]
|
||||||
|
(-> shape
|
||||||
|
(assoc :content (path/apply-content-modifiers
|
||||||
|
content
|
||||||
|
{index {:x (dm/get-prop delta :x)
|
||||||
|
:y (dm/get-prop delta :y)}}))
|
||||||
|
(path/update-geometry)))
|
||||||
|
{:reg-objects? true :save-undo? save-undo?})))]
|
||||||
|
|
||||||
|
;; Hide selection controls during the drag.
|
||||||
|
(rx/concat
|
||||||
|
(rx/of #(assoc-in % [:workspace-local :transform] :move))
|
||||||
|
;; Subscribe the preview and commit branches together.
|
||||||
|
(rx/merge
|
||||||
|
;; Preview without creating undo entries.
|
||||||
|
(->> position-stream
|
||||||
|
(rx/sample mconst/move-sample-time)
|
||||||
|
(rx/map #(move-endpoint % false))
|
||||||
|
(rx/take-until stopper))
|
||||||
|
;; Commit the final position as one undo step.
|
||||||
|
(->> position-stream
|
||||||
|
(rx/take-until stopper)
|
||||||
|
(rx/last)
|
||||||
|
(rx/mapcat
|
||||||
|
(fn [pos]
|
||||||
|
(rx/of (move-endpoint start-point false)
|
||||||
|
(move-endpoint pos true))))))
|
||||||
|
(rx/of #(assoc-in % [:workspace-local :transform] nil)))))))
|
||||||
|
|
||||||
(defn trigger-bounding-box-cloaking
|
(defn trigger-bounding-box-cloaking
|
||||||
"Trigger the bounding box cloaking (with default timer of 1sec)
|
"Trigger the bounding box cloaking (with default timer of 1sec)
|
||||||
|
|
||||||
|
|||||||
@ -222,6 +222,9 @@
|
|||||||
(def selected-edition
|
(def selected-edition
|
||||||
(l/derived :edition workspace-local))
|
(l/derived :edition workspace-local))
|
||||||
|
|
||||||
|
(def workspace-edit-path
|
||||||
|
(l/derived :edit-path workspace-local))
|
||||||
|
|
||||||
(def current-transform
|
(def current-transform
|
||||||
(l/derived :transform workspace-local))
|
(l/derived :transform workspace-local))
|
||||||
|
|
||||||
|
|||||||
@ -40,9 +40,21 @@
|
|||||||
(init-static-cursor-style style "create-polygon" cur/create-polygon)
|
(init-static-cursor-style style "create-polygon" cur/create-polygon)
|
||||||
(init-static-cursor-style style "create-rectangle" cur/create-rectangle)
|
(init-static-cursor-style style "create-rectangle" cur/create-rectangle)
|
||||||
(init-static-cursor-style style "create-shape" cur/create-shape)
|
(init-static-cursor-style style "create-shape" cur/create-shape)
|
||||||
|
(init-static-cursor-style style "draw" cur/draw)
|
||||||
|
(init-static-cursor-style style "draw-add" cur/draw-add)
|
||||||
|
(init-static-cursor-style style "draw-node" cur/draw-node)
|
||||||
|
(init-static-cursor-style style "draw-remove" cur/draw-remove)
|
||||||
(init-static-cursor-style style "duplicate" cur/duplicate)
|
(init-static-cursor-style style "duplicate" cur/duplicate)
|
||||||
(init-static-cursor-style style "hand" cur/hand)
|
(init-static-cursor-style style "hand" cur/hand)
|
||||||
|
(init-static-cursor-style style "move" cur/move)
|
||||||
|
(init-static-cursor-style style "move-add" cur/move-add)
|
||||||
|
(init-static-cursor-style style "move-copy" cur/move-copy)
|
||||||
|
(init-static-cursor-style style "move-curve" cur/move-curve)
|
||||||
|
(init-static-cursor-style style "move-handles" cur/move-handles)
|
||||||
|
(init-static-cursor-style style "move-move" cur/move-move)
|
||||||
|
(init-static-cursor-style style "move-node" cur/move-node)
|
||||||
(init-static-cursor-style style "move-pointer" cur/move-pointer)
|
(init-static-cursor-style style "move-pointer" cur/move-pointer)
|
||||||
|
(init-static-cursor-style style "move-remove" cur/move-remove)
|
||||||
(init-static-cursor-style style "pen" cur/pen)
|
(init-static-cursor-style style "pen" cur/pen)
|
||||||
(init-static-cursor-style style "pen-node" cur/pen-node)
|
(init-static-cursor-style style "pen-node" cur/pen-node)
|
||||||
(init-static-cursor-style style "pencil" cur/pencil)
|
(init-static-cursor-style style "pencil" cur/pencil)
|
||||||
|
|||||||
@ -17,6 +17,7 @@
|
|||||||
(def default-hotspot-y 12)
|
(def default-hotspot-y 12)
|
||||||
(def default-rotation 0)
|
(def default-rotation 0)
|
||||||
(def default-height 20)
|
(def default-height 20)
|
||||||
|
(def default-width 20)
|
||||||
|
|
||||||
(defn parse-svg [svg-data]
|
(defn parse-svg [svg-data]
|
||||||
(-> svg-data
|
(-> svg-data
|
||||||
@ -51,7 +52,7 @@
|
|||||||
(str/replace #"\s+$" "")))
|
(str/replace #"\s+$" "")))
|
||||||
|
|
||||||
(defn encode-svg-cursor
|
(defn encode-svg-cursor
|
||||||
[id rotation x y height]
|
[id rotation x y height width]
|
||||||
(let [svg-path (str cursor-folder "/" (name id) ".svg")
|
(let [svg-path (str cursor-folder "/" (name id) ".svg")
|
||||||
data (-> svg-path io/resource slurp parse-svg)
|
data (-> svg-path io/resource slurp parse-svg)
|
||||||
data (u/percent-encode data)
|
data (u/percent-encode data)
|
||||||
@ -59,15 +60,16 @@
|
|||||||
data (if rotation
|
data (if rotation
|
||||||
(str/fmt "%3Cg transform='rotate(%s 8,8)'%3E%s%3C/g%3E" rotation data)
|
(str/fmt "%3Cg transform='rotate(%s 8,8)'%3E%s%3C/g%3E" rotation data)
|
||||||
data)]
|
data)]
|
||||||
(str "url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' width='20px' "
|
(str "url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' width='" width "px' "
|
||||||
"height='" height "px' %3E" data "%3C/svg%3E\") " x " " y ", auto")))
|
"height='" height "px' %3E" data "%3C/svg%3E\") " x " " y ", auto")))
|
||||||
|
|
||||||
(defmacro cursor-ref
|
(defmacro cursor-ref
|
||||||
"Creates a static cursor given its name, rotation and x/y hotspot"
|
"Creates a static SVG cursor."
|
||||||
([id] (encode-svg-cursor id default-rotation default-hotspot-x default-hotspot-y default-height))
|
([id] (encode-svg-cursor id default-rotation default-hotspot-x default-hotspot-y default-height default-width))
|
||||||
([id rotation] (encode-svg-cursor id rotation default-hotspot-x default-hotspot-y default-height))
|
([id rotation] (encode-svg-cursor id rotation default-hotspot-x default-hotspot-y default-height default-width))
|
||||||
([id rotation x y] (encode-svg-cursor id rotation x y default-height))
|
([id rotation x y] (encode-svg-cursor id rotation x y default-height default-width))
|
||||||
([id rotation x y height] (encode-svg-cursor id rotation x y height)))
|
([id rotation x y height] (encode-svg-cursor id rotation x y height default-width))
|
||||||
|
([id rotation x y height width] (encode-svg-cursor id rotation x y height width)))
|
||||||
|
|
||||||
(defmacro cursor-fn
|
(defmacro cursor-fn
|
||||||
"Creates a dynamic cursor that can be rotated in runtime"
|
"Creates a dynamic cursor that can be rotated in runtime"
|
||||||
@ -75,7 +77,8 @@
|
|||||||
(let [[cp1 cp2] (-> (encode-svg-cursor id "$$$"
|
(let [[cp1 cp2] (-> (encode-svg-cursor id "$$$"
|
||||||
default-hotspot-x
|
default-hotspot-x
|
||||||
default-hotspot-y
|
default-hotspot-y
|
||||||
default-height)
|
default-height
|
||||||
|
default-width)
|
||||||
(str/split #"\$\$\$"))]
|
(str/split #"\$\$\$"))]
|
||||||
`(fn [rot#]
|
`(fn [rot#]
|
||||||
(str/concat ~cp1 (+ ~initial rot#) ~cp2))))
|
(str/concat ~cp1 (+ ~initial rot#) ~cp2))))
|
||||||
|
|||||||
@ -14,9 +14,21 @@
|
|||||||
(def ^:cursor create-polygon (cursor-ref :create-polygon))
|
(def ^:cursor create-polygon (cursor-ref :create-polygon))
|
||||||
(def ^:cursor create-rectangle (cursor-ref :create-rectangle))
|
(def ^:cursor create-rectangle (cursor-ref :create-rectangle))
|
||||||
(def ^:cursor create-shape (cursor-ref :create-shape))
|
(def ^:cursor create-shape (cursor-ref :create-shape))
|
||||||
|
(def ^:cursor draw (cursor-ref :draw 0 0 0))
|
||||||
|
(def ^:cursor draw-add (cursor-ref :draw-add 0 0 0 25 25))
|
||||||
|
(def ^:cursor draw-node (cursor-ref :draw-node 0 0 0 25 25))
|
||||||
|
(def ^:cursor draw-remove (cursor-ref :draw-remove 0 0 0 25 25))
|
||||||
(def ^:cursor duplicate (cursor-ref :duplicate 0 0 0))
|
(def ^:cursor duplicate (cursor-ref :duplicate 0 0 0))
|
||||||
(def ^:cursor hand (cursor-ref :hand))
|
(def ^:cursor hand (cursor-ref :hand))
|
||||||
|
(def ^:cursor move (cursor-ref :move 0 0 0 25 25))
|
||||||
|
(def ^:cursor move-add (cursor-ref :move-add 0 0 0 25 25))
|
||||||
|
(def ^:cursor move-copy (cursor-ref :move-copy 0 0 0 25 25))
|
||||||
|
(def ^:cursor move-curve (cursor-ref :move-curve 0 0 0 25 25))
|
||||||
|
(def ^:cursor move-handles (cursor-ref :move-handles 0 0 0 25 25))
|
||||||
|
(def ^:cursor move-move (cursor-ref :move-move 0 0 0 25 25))
|
||||||
|
(def ^:cursor move-node (cursor-ref :move-node 0 0 0 25 25))
|
||||||
(def ^:cursor move-pointer (cursor-ref :move-pointer))
|
(def ^:cursor move-pointer (cursor-ref :move-pointer))
|
||||||
|
(def ^:cursor move-remove (cursor-ref :move-remove 0 0 0 25 25))
|
||||||
(def ^:cursor pen (cursor-ref :pen 0 0 0))
|
(def ^:cursor pen (cursor-ref :pen 0 0 0))
|
||||||
(def ^:cursor pen-node (cursor-ref :pen-node 0 0 10 36))
|
(def ^:cursor pen-node (cursor-ref :pen-node 0 0 10 36))
|
||||||
(def ^:cursor pencil (cursor-ref :pencil 0 0 24))
|
(def ^:cursor pencil (cursor-ref :pencil 0 0 24))
|
||||||
|
|||||||
@ -144,6 +144,9 @@
|
|||||||
(def ^:icon group (icon-xref :group))
|
(def ^:icon group (icon-xref :group))
|
||||||
(def ^:icon gutter-horizontal (icon-xref :gutter-horizontal))
|
(def ^:icon gutter-horizontal (icon-xref :gutter-horizontal))
|
||||||
(def ^:icon gutter-vertical (icon-xref :gutter-vertical))
|
(def ^:icon gutter-vertical (icon-xref :gutter-vertical))
|
||||||
|
(def ^:icon handlers-equal (icon-xref :handlers-equal))
|
||||||
|
(def ^:icon handlers-independent (icon-xref :handlers-independent))
|
||||||
|
(def ^:icon handlers-mirror (icon-xref :handlers-mirror))
|
||||||
(def ^:icon help (icon-xref :help))
|
(def ^:icon help (icon-xref :help))
|
||||||
(def ^:icon hide (icon-xref :hide))
|
(def ^:icon hide (icon-xref :hide))
|
||||||
(def ^:icon history (icon-xref :history))
|
(def ^:icon history (icon-xref :history))
|
||||||
@ -219,6 +222,7 @@
|
|||||||
(def ^:icon shown (icon-xref :shown))
|
(def ^:icon shown (icon-xref :shown))
|
||||||
(def ^:icon size-horizontal (icon-xref :size-horizontal))
|
(def ^:icon size-horizontal (icon-xref :size-horizontal))
|
||||||
(def ^:icon size-vertical (icon-xref :size-vertical))
|
(def ^:icon size-vertical (icon-xref :size-vertical))
|
||||||
|
(def ^:icon snap (icon-xref :snap))
|
||||||
(def ^:icon snap-nodes (icon-xref :snap-nodes))
|
(def ^:icon snap-nodes (icon-xref :snap-nodes))
|
||||||
(def ^:icon status-alert (icon-xref :status-alert))
|
(def ^:icon status-alert (icon-xref :status-alert))
|
||||||
(def ^:icon status-tick (icon-xref :status-tick))
|
(def ^:icon status-tick (icon-xref :status-tick))
|
||||||
|
|||||||
@ -9,11 +9,13 @@
|
|||||||
(:require
|
(:require
|
||||||
[app.common.data :as d]
|
[app.common.data :as d]
|
||||||
[app.common.data.macros :as dm]
|
[app.common.data.macros :as dm]
|
||||||
|
[app.common.files.helpers :as cfh]
|
||||||
[app.common.geom.point :as gpt]
|
[app.common.geom.point :as gpt]
|
||||||
[app.common.geom.rect :as grc]
|
[app.common.geom.rect :as grc]
|
||||||
[app.common.geom.shapes :as gsh]
|
[app.common.geom.shapes :as gsh]
|
||||||
[app.common.math :as mth]
|
[app.common.math :as mth]
|
||||||
[app.common.types.component :as ctk]
|
[app.common.types.component :as ctk]
|
||||||
|
[app.common.types.path :as path]
|
||||||
[app.common.uuid :as uuid]
|
[app.common.uuid :as uuid]
|
||||||
[app.main.constants :as mconst]
|
[app.main.constants :as mconst]
|
||||||
[app.main.ui.formats :as fmt]
|
[app.main.ui.formats :as fmt]
|
||||||
@ -214,6 +216,11 @@
|
|||||||
|
|
||||||
single-shape (and (= (count shapes) 1) (first shapes))
|
single-shape (and (= (count shapes) 1) (first shapes))
|
||||||
|
|
||||||
|
;; Straight paths use endpoint controls instead of a size badge.
|
||||||
|
single-line? (and single-shape
|
||||||
|
(cfh/path-shape? single-shape)
|
||||||
|
(path/single-line? (dm/get-prop single-shape :content)))
|
||||||
|
|
||||||
component-color? (if single-shape
|
component-color? (if single-shape
|
||||||
(ctk/instance-head? single-shape)
|
(ctk/instance-head? single-shape)
|
||||||
(every? ctk/instance-head? shapes))
|
(every? ctk/instance-head? shapes))
|
||||||
@ -245,64 +252,65 @@
|
|||||||
text-width (* (count text) badge-char-width)
|
text-width (* (count text) badge-char-width)
|
||||||
badge-width (+ text-width (* 2 badge-padding-x))]
|
badge-width (+ text-width (* 2 badge-padding-x))]
|
||||||
|
|
||||||
(if has-rotation?
|
(when-not ^boolean single-line?
|
||||||
(let [edge (get-edge-for-badge rotation)
|
(if has-rotation?
|
||||||
points (dm/get-prop single-shape :points)
|
(let [edge (get-edge-for-badge rotation)
|
||||||
|
points (dm/get-prop single-shape :points)
|
||||||
|
|
||||||
[ep1 ep2] (get-edge-points points edge)
|
[ep1 ep2] (get-edge-points points edge)
|
||||||
|
|
||||||
mid-point (gpt/lerp ep1 ep2 0.5)
|
mid-point (gpt/lerp ep1 ep2 0.5)
|
||||||
normal (gpt/normal-right (gpt/subtract ep2 ep1))
|
normal (gpt/normal-right (gpt/subtract ep2 ep1))
|
||||||
|
|
||||||
rot-offset (case edge
|
rot-offset (case edge
|
||||||
:bottom 0
|
:bottom 0
|
||||||
:right 270
|
:right 270
|
||||||
:top 180
|
:top 180
|
||||||
:left 90)
|
:left 90)
|
||||||
badge-rot (+ rotation rot-offset)
|
badge-rot (+ rotation rot-offset)
|
||||||
offset (+ badge-gap (/ badge-height 2))
|
offset (+ badge-gap (/ badge-height 2))
|
||||||
|
|
||||||
badge-x (- (/ badge-width 2))
|
badge-x (- (/ badge-width 2))
|
||||||
badge-y (- (/ badge-height 2))
|
badge-y (- (/ badge-height 2))
|
||||||
badge-cx (+ (:x mid-point) (* (:x normal) offset))
|
badge-cx (+ (:x mid-point) (* (:x normal) offset))
|
||||||
badge-cy (+ (:y mid-point) (* (:y normal) offset))]
|
badge-cy (+ (:y mid-point) (* (:y normal) offset))]
|
||||||
|
|
||||||
[:g.selection-size-badge {:pointer-events "none"
|
[:g.selection-size-badge {:pointer-events "none"
|
||||||
:transform (dm/str "translate(" badge-cx "," badge-cy ") rotate(" badge-rot ")")}
|
:transform (dm/str "translate(" badge-cx "," badge-cy ") rotate(" badge-rot ")")}
|
||||||
[:rect {:x badge-x
|
[:rect {:x badge-x
|
||||||
:y badge-y
|
:y badge-y
|
||||||
:width badge-width
|
:width badge-width
|
||||||
:height badge-height
|
:height badge-height
|
||||||
:rx badge-radius
|
:rx badge-radius
|
||||||
:ry badge-radius
|
:ry badge-radius
|
||||||
:style {:fill badge-bg-color}}]
|
:style {:fill badge-bg-color}}]
|
||||||
[:text {:class (stl/css :badge-text)
|
[:text {:class (stl/css :badge-text)
|
||||||
:x 0
|
:x 0
|
||||||
:y 0
|
:y 0
|
||||||
:text-anchor "middle"
|
:text-anchor "middle"
|
||||||
:dominant-baseline "middle"}
|
:dominant-baseline "middle"}
|
||||||
text]])
|
text]])
|
||||||
|
|
||||||
(let [badge-x (- (/ badge-width 2))
|
(let [badge-x (- (/ badge-width 2))
|
||||||
badge-y (- (/ badge-height 2))
|
badge-y (- (/ badge-height 2))
|
||||||
badge-cx (+ (:x selrect) (/ (:width selrect) 2))
|
badge-cx (+ (:x selrect) (/ (:width selrect) 2))
|
||||||
badge-cy (+ (:y selrect) (:height selrect) badge-gap (/ badge-height 2))]
|
badge-cy (+ (:y selrect) (:height selrect) badge-gap (/ badge-height 2))]
|
||||||
|
|
||||||
[:g.selection-size-badge {:pointer-events "none"
|
[:g.selection-size-badge {:pointer-events "none"
|
||||||
:transform (dm/str "translate(" badge-cx "," badge-cy ")")}
|
:transform (dm/str "translate(" badge-cx "," badge-cy ")")}
|
||||||
[:rect {:x badge-x
|
[:rect {:x badge-x
|
||||||
:y badge-y
|
:y badge-y
|
||||||
:width badge-width
|
:width badge-width
|
||||||
:height badge-height
|
:height badge-height
|
||||||
:rx badge-radius
|
:rx badge-radius
|
||||||
:ry badge-radius
|
:ry badge-radius
|
||||||
:style {:fill badge-bg-color}}]
|
:style {:fill badge-bg-color}}]
|
||||||
[:text {:class (stl/css :badge-text)
|
[:text {:class (stl/css :badge-text)
|
||||||
:x 0
|
:x 0
|
||||||
:y 0
|
:y 0
|
||||||
:text-anchor "middle"
|
:text-anchor "middle"
|
||||||
:dominant-baseline "middle"}
|
:dominant-baseline "middle"}
|
||||||
text]]))))
|
text]])))))
|
||||||
|
|
||||||
(mf/defc distance-display* [{:keys [from to zoom bounds]}]
|
(mf/defc distance-display* [{:keys [from to zoom bounds]}]
|
||||||
(let [fixed-x (if (gsh/fully-contained? from to)
|
(let [fixed-x (if (gsh/fully-contained? from to)
|
||||||
@ -384,4 +392,3 @@
|
|||||||
[:> selection-rect* {:type :hover :selrect hover-selrect :zoom zoom}]
|
[:> selection-rect* {:type :hover :selrect hover-selrect :zoom zoom}]
|
||||||
[:> size-display* {:selrect hover-selrect :zoom zoom}]
|
[:> size-display* {:selrect hover-selrect :zoom zoom}]
|
||||||
[:> distance-display* {:from hover-selrect :to selected-selrect :zoom zoom :bounds bounds-selrect}]])])))
|
[:> distance-display* {:from hover-selrect :to selected-selrect :zoom zoom :bounds bounds-selrect}]])])))
|
||||||
|
|
||||||
|
|||||||
@ -501,7 +501,10 @@
|
|||||||
:shortcut-key :start-editing
|
:shortcut-key :start-editing
|
||||||
:on-click do-start-editing}])
|
:on-click do-start-editing}])
|
||||||
|
|
||||||
(when-not (or disable-flatten has-frame? has-path?)
|
;; Flattening a single path bakes its transform.
|
||||||
|
(when (and (not disable-flatten)
|
||||||
|
(not has-frame?)
|
||||||
|
(or (not has-path?) (and single? has-path?)))
|
||||||
[:> menu-entry* {:title (tr "workspace.shape.menu.flatten")
|
[:> menu-entry* {:title (tr "workspace.shape.menu.flatten")
|
||||||
:on-click do-transform-to-path}])
|
:on-click do-transform-to-path}])
|
||||||
|
|
||||||
|
|||||||
@ -12,14 +12,16 @@
|
|||||||
[app.common.types.path :as path]
|
[app.common.types.path :as path]
|
||||||
[app.common.types.path.helpers :as path.helpers]
|
[app.common.types.path.helpers :as path.helpers]
|
||||||
[app.main.data.workspace.path :as drp]
|
[app.main.data.workspace.path :as drp]
|
||||||
|
[app.main.data.workspace.path.helpers :as dwp.helpers]
|
||||||
[app.main.snap :as snap]
|
[app.main.snap :as snap]
|
||||||
[app.main.store :as st]
|
[app.main.store :as st]
|
||||||
[app.main.streams :as ms]
|
[app.main.streams :as ms]
|
||||||
[app.main.ui.css-cursors :as cur]
|
[app.main.ui.css-cursors :as cur]
|
||||||
[app.main.ui.hooks :as hooks]
|
[app.main.ui.hooks :as hooks]
|
||||||
|
[app.main.ui.workspace.viewport.viewport-ref :as uwvv]
|
||||||
[app.util.dom :as dom]
|
[app.util.dom :as dom]
|
||||||
[app.util.keyboard :as kbd]
|
[app.util.keyboard :as kbd]
|
||||||
[clojure.set :refer [map-invert]]
|
[beicon.v2.core :as rx]
|
||||||
[goog.events :as events]
|
[goog.events :as events]
|
||||||
[rumext.v2 :as mf]))
|
[rumext.v2 :as mf]))
|
||||||
|
|
||||||
@ -39,10 +41,55 @@
|
|||||||
(def black-color "var(--app-black)")
|
(def black-color "var(--app-black)")
|
||||||
(def white-color "var(--app-white)")
|
(def white-color "var(--app-white)")
|
||||||
(def gray-color "var(--df-secondary)")
|
(def gray-color "var(--df-secondary)")
|
||||||
|
(def selected-color "var(--app-pink)")
|
||||||
|
|
||||||
|
;; Hover cursors for each edit mode and modifier combination.
|
||||||
|
|
||||||
|
(defn- node-cursor
|
||||||
|
[edit-mode {:keys [shift? mod? alt?]} is-selected any-node-selected?]
|
||||||
|
(if (= edit-mode :draw)
|
||||||
|
(cond
|
||||||
|
^boolean alt? "draw-remove"
|
||||||
|
^boolean mod? "move-handles"
|
||||||
|
:else "draw-node")
|
||||||
|
(cond
|
||||||
|
(and ^boolean mod? ^boolean alt?) "draw-remove"
|
||||||
|
^boolean mod? "move-handles"
|
||||||
|
^boolean alt? "draw-remove"
|
||||||
|
(and ^boolean shift?
|
||||||
|
^boolean any-node-selected?) "move-add"
|
||||||
|
^boolean is-selected "move-move"
|
||||||
|
:else "move-node")))
|
||||||
|
|
||||||
|
(defn- segment-cursor
|
||||||
|
[edit-mode {:keys [shift? mod? alt?]} is-selected insert-preview?]
|
||||||
|
(if (= edit-mode :draw)
|
||||||
|
(cond
|
||||||
|
^boolean alt? "draw-remove"
|
||||||
|
^boolean mod? "move-curve"
|
||||||
|
:else "draw-add")
|
||||||
|
(cond
|
||||||
|
(and ^boolean mod? ^boolean alt?) "draw-remove"
|
||||||
|
^boolean mod? "move-curve"
|
||||||
|
^boolean alt? "draw-add"
|
||||||
|
^boolean shift? "move-add"
|
||||||
|
^boolean insert-preview? "draw-add"
|
||||||
|
^boolean is-selected "move-move"
|
||||||
|
:else nil)))
|
||||||
|
|
||||||
|
(defn- handler-cursor
|
||||||
|
[edit-mode {:keys [shift? mod? alt?]} is-selected]
|
||||||
|
(cond
|
||||||
|
(and ^boolean mod? ^boolean alt?) "move"
|
||||||
|
(or ^boolean mod? ^boolean alt?) "move-remove"
|
||||||
|
(and (= edit-mode :move)
|
||||||
|
^boolean shift?
|
||||||
|
(not ^boolean is-selected)) "move-add"
|
||||||
|
:else "move"))
|
||||||
|
|
||||||
(mf/defc path-point*
|
(mf/defc path-point*
|
||||||
{::mf/private true}
|
{::mf/private true}
|
||||||
[{:keys [position zoom edit-mode is-hover is-selected is-preview is-start-path is-last is-new is-curve]}]
|
[{:keys [index position zoom edit-mode is-hover is-selected is-preview is-new cursor]}]
|
||||||
(let [{:keys [x y]} position
|
(let [{:keys [x y]} position
|
||||||
|
|
||||||
is-draw (= edit-mode :draw)
|
is-draw (= edit-mode :draw)
|
||||||
@ -54,48 +101,37 @@
|
|||||||
|
|
||||||
on-enter
|
on-enter
|
||||||
(mf/use-fn
|
(mf/use-fn
|
||||||
|
(mf/deps index)
|
||||||
(fn [_]
|
(fn [_]
|
||||||
(st/emit! (drp/path-pointer-enter position))))
|
(when (some? index)
|
||||||
|
(st/emit! (drp/path-pointer-enter index)))))
|
||||||
|
|
||||||
on-leave
|
on-leave
|
||||||
(mf/use-fn
|
(mf/use-fn
|
||||||
|
(mf/deps index)
|
||||||
(fn [_]
|
(fn [_]
|
||||||
(st/emit! (drp/path-pointer-leave position))))
|
(when (some? index)
|
||||||
|
(st/emit! (drp/path-pointer-leave index)))))
|
||||||
|
|
||||||
on-pointer-down
|
on-pointer-down
|
||||||
(fn [event]
|
(fn [event]
|
||||||
(when (dom/left-mouse? event)
|
(when (dom/left-mouse? event)
|
||||||
|
(uwvv/capture-pointer event)
|
||||||
(dom/stop-propagation event)
|
(dom/stop-propagation event)
|
||||||
(dom/prevent-default event)
|
(dom/prevent-default event)
|
||||||
|
;; Preview nodes store their split params as metadata.
|
||||||
;; When clicking on a hover point that lies on a segment (has metadata with
|
|
||||||
;; split params), only insert the node — don't also run draw-mode actions which
|
|
||||||
;; would add the same position as an extra endpoint, corrupting the path order
|
|
||||||
;; and misplacing stroke caps.
|
|
||||||
;; FIXME: revisit this, using meta here breaks equality checks
|
;; FIXME: revisit this, using meta here breaks equality checks
|
||||||
(if (and is-new (some? (meta position)))
|
(if (and is-new (some? (meta position)))
|
||||||
(st/emit! (drp/create-node-at-position (meta position)))
|
(st/emit! (drp/create-node-at-position (meta position)))
|
||||||
(let [is-shift (kbd/shift? event)
|
(let [is-shift (kbd/shift? event)
|
||||||
|
is-alt (kbd/alt? event)
|
||||||
is-mod (kbd/mod? event)]
|
is-mod (kbd/mod? event)]
|
||||||
(cond
|
(cond
|
||||||
is-last
|
|
||||||
(st/emit! (drp/reset-last-handler))
|
|
||||||
|
|
||||||
(and is-move is-mod (not is-curve))
|
|
||||||
(st/emit! (drp/make-curve position))
|
|
||||||
|
|
||||||
(and is-move is-mod is-curve)
|
|
||||||
(st/emit! (drp/make-corner position))
|
|
||||||
|
|
||||||
is-move
|
is-move
|
||||||
;; If we're dragging a selected item we don't change the selection
|
(st/emit! (drp/start-move-path-point index is-shift is-alt is-mod))
|
||||||
(st/emit! (drp/start-move-path-point position is-shift))
|
|
||||||
|
|
||||||
(and is-draw is-start-path)
|
is-draw
|
||||||
(st/emit! (drp/start-path-from-point position))
|
(st/emit! (drp/on-draw-node-pointer-down index position is-alt is-mod)))))))]
|
||||||
|
|
||||||
(and is-draw (not is-start-path))
|
|
||||||
(st/emit! (drp/close-path-drag-start position)))))))]
|
|
||||||
|
|
||||||
[:g.path-point
|
[:g.path-point
|
||||||
[:circle.path-point
|
[:circle.path-point
|
||||||
@ -108,7 +144,7 @@
|
|||||||
:stroke (cond ^boolean is-active black-color
|
:stroke (cond ^boolean is-active black-color
|
||||||
^boolean is-preview secondary-color
|
^boolean is-preview secondary-color
|
||||||
:else accent-color)
|
:else accent-color)
|
||||||
:fill (cond is-selected accent-color
|
:fill (cond is-selected selected-color
|
||||||
:else white-color)}}]
|
:else white-color)}}]
|
||||||
[:circle {:cx x
|
[:circle {:cx x
|
||||||
:cy y
|
:cy y
|
||||||
@ -116,21 +152,24 @@
|
|||||||
:on-pointer-down on-pointer-down
|
:on-pointer-down on-pointer-down
|
||||||
:on-pointer-enter on-enter
|
:on-pointer-enter on-enter
|
||||||
:on-pointer-leave on-leave
|
:on-pointer-leave on-leave
|
||||||
:pointer-events (when-not ^boolean is-preview "visible")
|
;; Let insertion preview clicks reach the segment.
|
||||||
:class (cond ^boolean is-draw (cur/get-static "pen-node")
|
:pointer-events (cond ^boolean is-preview nil
|
||||||
^boolean is-move (cur/get-static "pointer-node"))
|
^boolean is-new "none"
|
||||||
|
:else "visible")
|
||||||
|
:class (when (some? cursor) (cur/get-static cursor))
|
||||||
:style {:stroke-width 0
|
:style {:stroke-width 0
|
||||||
:fill "none"}}]]))
|
:fill "none"}}]]))
|
||||||
|
|
||||||
;; FIXME: is-selected prop looks unused
|
|
||||||
|
|
||||||
(mf/defc path-handler*
|
(mf/defc path-handler*
|
||||||
{::mf/private true}
|
{::mf/private true}
|
||||||
[{:keys [index prefix point handler zoom is-selected is-hover edit-mode snap-angle]}]
|
[{:keys [index prefix point handler zoom is-selected is-hover snap-angle cursor on-grab]}]
|
||||||
(let [x (dm/get-prop handler :x)
|
(let [x (dm/get-prop handler :x)
|
||||||
y (dm/get-prop handler :y)
|
y (dm/get-prop handler :y)
|
||||||
is-draw (= edit-mode :draw)
|
|
||||||
is-move (= edit-mode :move)
|
;; Placed handlers and handlers with `on-grab` are interactive.
|
||||||
|
is-interactive
|
||||||
|
(or (some? index)
|
||||||
|
(some? on-grab))
|
||||||
|
|
||||||
is-active
|
is-active
|
||||||
(or ^boolean is-selected
|
(or ^boolean is-selected
|
||||||
@ -148,16 +187,21 @@
|
|||||||
|
|
||||||
on-pointer-down
|
on-pointer-down
|
||||||
(mf/use-fn
|
(mf/use-fn
|
||||||
(mf/deps index prefix is-move)
|
(mf/deps index prefix is-interactive on-grab)
|
||||||
(fn [event]
|
(fn [event]
|
||||||
(when (dom/left-mouse? event)
|
(when (and ^boolean is-interactive (dom/left-mouse? event))
|
||||||
|
(uwvv/capture-pointer event)
|
||||||
(dom/stop-propagation event)
|
(dom/stop-propagation event)
|
||||||
(dom/prevent-default event)
|
(dom/prevent-default event)
|
||||||
|
(if (some? on-grab)
|
||||||
|
(on-grab event)
|
||||||
|
(st/emit! (drp/start-move-handler index
|
||||||
|
prefix
|
||||||
|
(kbd/shift? event)
|
||||||
|
(kbd/alt? event)
|
||||||
|
(kbd/mod? event)))))))]
|
||||||
|
|
||||||
(when ^boolean is-move
|
[:g.handler {:pointer-events (if ^boolean is-interactive "visible" "none")}
|
||||||
(st/emit! (drp/start-move-handler index prefix))))))]
|
|
||||||
|
|
||||||
[:g.handler {:pointer-events (if ^boolean is-draw "none" "visible")}
|
|
||||||
[:line
|
[:line
|
||||||
{:x1 (:x point)
|
{:x1 (:x point)
|
||||||
:y1 (:y point)
|
:y1 (:y point)
|
||||||
@ -194,11 +238,70 @@
|
|||||||
:on-pointer-down on-pointer-down
|
:on-pointer-down on-pointer-down
|
||||||
:on-pointer-enter on-enter
|
:on-pointer-enter on-enter
|
||||||
:on-pointer-leave on-leave
|
:on-pointer-leave on-leave
|
||||||
:class (when ^boolean is-move
|
:class (when (and ^boolean is-interactive (some? cursor))
|
||||||
(cur/get-static "pointer-move"))
|
(cur/get-static cursor))
|
||||||
:style {:fill "none"
|
:style {:fill "none"
|
||||||
:stroke-width 0}}]]))
|
:stroke-width 0}}]]))
|
||||||
|
|
||||||
|
(defn- segment-content
|
||||||
|
[{:keys [from to segment]}]
|
||||||
|
(path/content
|
||||||
|
[{:command :move-to
|
||||||
|
:params from}
|
||||||
|
(if (= :close-path (:command segment))
|
||||||
|
{:command :line-to
|
||||||
|
:params to}
|
||||||
|
segment)]))
|
||||||
|
|
||||||
|
(mf/defc path-segment*
|
||||||
|
{::mf/private true}
|
||||||
|
[{:keys [entry zoom edit-mode is-interactive is-selected is-hover cursor]}]
|
||||||
|
(let [index (:index entry)
|
||||||
|
content (mf/with-memo [entry] (segment-content entry))
|
||||||
|
is-active (or ^boolean is-selected ^boolean is-hover)
|
||||||
|
on-enter (mf/use-fn
|
||||||
|
(mf/deps index)
|
||||||
|
(fn [_]
|
||||||
|
(st/emit! (drp/path-segment-enter index))))
|
||||||
|
on-leave (mf/use-fn
|
||||||
|
(mf/deps index)
|
||||||
|
(fn [_]
|
||||||
|
(st/emit! (drp/path-segment-leave index))))
|
||||||
|
on-pointer-down
|
||||||
|
(mf/use-fn
|
||||||
|
(mf/deps index is-interactive edit-mode)
|
||||||
|
(fn [event]
|
||||||
|
(when (and ^boolean is-interactive (dom/left-mouse? event))
|
||||||
|
(uwvv/capture-pointer event)
|
||||||
|
(dom/stop-propagation event)
|
||||||
|
(dom/prevent-default event)
|
||||||
|
(if (= edit-mode :draw)
|
||||||
|
(st/emit! (drp/on-draw-segment-pointer-down index
|
||||||
|
(kbd/alt? event)
|
||||||
|
(kbd/mod? event)))
|
||||||
|
(st/emit! (drp/start-move-path-segment index
|
||||||
|
(kbd/shift? event)
|
||||||
|
(kbd/alt? event)
|
||||||
|
(kbd/mod? event)))))))]
|
||||||
|
[:g.path-segment {:pointer-events (if ^boolean is-interactive "visible" "none")}
|
||||||
|
(when ^boolean is-active
|
||||||
|
[:path {:d (.toString content)
|
||||||
|
:pointer-events "none"
|
||||||
|
:style {:fill "none"
|
||||||
|
:stroke (if ^boolean is-selected
|
||||||
|
selected-color
|
||||||
|
accent-color)
|
||||||
|
:stroke-width (/ 2 zoom)}}])
|
||||||
|
[:path {:d (.toString content)
|
||||||
|
:on-pointer-down on-pointer-down
|
||||||
|
:on-pointer-enter on-enter
|
||||||
|
:on-pointer-leave on-leave
|
||||||
|
:pointer-events "stroke"
|
||||||
|
:class (when (some? cursor) (cur/get-static cursor))
|
||||||
|
:style {:fill "none"
|
||||||
|
:stroke "transparent"
|
||||||
|
:stroke-width (/ point-radius-active-area zoom)}}]]))
|
||||||
|
|
||||||
(mf/defc path-preview*
|
(mf/defc path-preview*
|
||||||
{::mf/private true}
|
{::mf/private true}
|
||||||
[{:keys [zoom segment from]}]
|
[{:keys [zoom segment from]}]
|
||||||
@ -229,12 +332,8 @@
|
|||||||
|
|
||||||
(mf/defc path-snap*
|
(mf/defc path-snap*
|
||||||
{::mf/private true}
|
{::mf/private true}
|
||||||
[{:keys [selected points zoom]}]
|
[{:keys [selected ranges zoom]}]
|
||||||
(let [ranges
|
(let [snap-matches
|
||||||
(mf/with-memo [selected points]
|
|
||||||
(snap/create-ranges points selected))
|
|
||||||
|
|
||||||
snap-matches
|
|
||||||
(snap/get-snap-delta-match selected ranges (/ 1 zoom))
|
(snap/get-snap-delta-match selected ranges (/ 1 zoom))
|
||||||
|
|
||||||
matches
|
matches
|
||||||
@ -262,34 +361,160 @@
|
|||||||
angle (gpt/angle-with-other v1 v2)]
|
angle (gpt/angle-with-other v1 v2)]
|
||||||
(<= (- 180 angle) 0.1))))
|
(<= (- 180 angle) 0.1))))
|
||||||
|
|
||||||
|
(defn- use-path-modifiers
|
||||||
|
"Tracks keyboard modifiers used by path cursors."
|
||||||
|
[]
|
||||||
|
(let [modifiers* (mf/use-state {:shift? false :mod? false :alt? false})]
|
||||||
|
(hooks/use-stream
|
||||||
|
(mf/with-memo []
|
||||||
|
(rx/combine-latest ms/keyboard-shift ms/keyboard-mod ms/keyboard-alt))
|
||||||
|
(fn [[shift? mod? alt?]]
|
||||||
|
(reset! modifiers* {:shift? (boolean shift?)
|
||||||
|
:mod? (boolean mod?)
|
||||||
|
:alt? (boolean alt?)})))
|
||||||
|
(deref modifiers*)))
|
||||||
|
|
||||||
|
(defn- use-insertion-preview
|
||||||
|
"Tracks the node insertion preview under the pointer."
|
||||||
|
[content zoom move-mode? mid-points]
|
||||||
|
(let [hover-point* (mf/use-state nil)]
|
||||||
|
(hooks/use-stream
|
||||||
|
(mf/with-memo []
|
||||||
|
(rx/combine-latest ms/mouse-position ms/keyboard-mod ms/keyboard-shift ms/keyboard-alt))
|
||||||
|
(mf/deps content zoom move-mode?)
|
||||||
|
(fn [[position mod? shift? alt?]]
|
||||||
|
(if (and ^boolean move-mode?
|
||||||
|
(not shift?)
|
||||||
|
(not mod?)
|
||||||
|
(gpt/point? position))
|
||||||
|
(reset! hover-point*
|
||||||
|
(dwp.helpers/insertion-point
|
||||||
|
content position
|
||||||
|
(/ dwp.helpers/segment-insert-threshold zoom)
|
||||||
|
(boolean alt?)
|
||||||
|
mid-points))
|
||||||
|
(reset! hover-point* nil))))
|
||||||
|
(deref hover-point*)))
|
||||||
|
|
||||||
|
(defn- create-snap-ranges
|
||||||
|
"Builds snap ranges from stationary nodes."
|
||||||
|
[content selected-nodes selected-segments include-all?]
|
||||||
|
(let [points (if include-all?
|
||||||
|
(path/get-points content)
|
||||||
|
(let [moving-indices (into selected-nodes
|
||||||
|
(dwp.helpers/segment-node-indices
|
||||||
|
content selected-segments))
|
||||||
|
moving-positions (dwp.helpers/node-positions content moving-indices)]
|
||||||
|
(into [] (remove moving-positions) (path/get-points content))))]
|
||||||
|
(snap/create-ranges points)))
|
||||||
|
|
||||||
|
(defn- snap-selected-points
|
||||||
|
[content selected-nodes selected-segment-nodes drag-handler preview moving-handler]
|
||||||
|
(cond
|
||||||
|
(some? drag-handler) #{drag-handler}
|
||||||
|
(some? preview) #{(path.helpers/segment->point preview)}
|
||||||
|
(some? moving-handler) #{moving-handler}
|
||||||
|
:else
|
||||||
|
(dwp.helpers/node-positions
|
||||||
|
content (into selected-nodes selected-segment-nodes))))
|
||||||
|
|
||||||
|
(mf/defc path-node*
|
||||||
|
{::mf/private true}
|
||||||
|
[{:keys [index position content handlers zoom edit-mode selected-nodes selected-handlers
|
||||||
|
hover-nodes hover-handlers moving-handler modifiers drag-cursor
|
||||||
|
any-node-selected]}]
|
||||||
|
(let [show-handler? (fn [[handler-index prefix]]
|
||||||
|
(not= position
|
||||||
|
(path/get-handler-point content handler-index prefix)))
|
||||||
|
point-handlers (->> (get handlers position)
|
||||||
|
(filter show-handler?)
|
||||||
|
(not-empty))
|
||||||
|
point-selected? (contains? selected-nodes index)
|
||||||
|
point-hover? (contains? hover-nodes index)
|
||||||
|
matching-handlers? (matching-handler? content position point-handlers)]
|
||||||
|
[:g.path-node {:key (dm/str "node-" index)}
|
||||||
|
[:g.point-handlers
|
||||||
|
(for [[handler-index prefix] point-handlers]
|
||||||
|
(let [handler-position (path/get-handler-point content handler-index prefix)
|
||||||
|
handler-hover? (contains? hover-handlers [handler-index prefix])
|
||||||
|
handler-selected? (contains? selected-handlers [handler-index prefix])]
|
||||||
|
(when (and position handler-position)
|
||||||
|
[:> path-handler*
|
||||||
|
{:key (dm/str handler-index "-" (d/name prefix))
|
||||||
|
:point position
|
||||||
|
:handler handler-position
|
||||||
|
:index handler-index
|
||||||
|
:prefix prefix
|
||||||
|
:zoom zoom
|
||||||
|
:is-selected handler-selected?
|
||||||
|
:is-hover handler-hover?
|
||||||
|
:snap-angle (and (= handler-position moving-handler) matching-handlers?)
|
||||||
|
:edit-mode edit-mode
|
||||||
|
:cursor (or drag-cursor
|
||||||
|
(handler-cursor edit-mode modifiers handler-selected?))}])))]
|
||||||
|
|
||||||
|
[:> path-point* {:index index
|
||||||
|
:position position
|
||||||
|
:zoom zoom
|
||||||
|
:edit-mode edit-mode
|
||||||
|
:is-selected point-selected?
|
||||||
|
:is-hover point-hover?
|
||||||
|
:cursor (or drag-cursor
|
||||||
|
(node-cursor edit-mode modifiers point-selected?
|
||||||
|
any-node-selected))}]]))
|
||||||
|
|
||||||
(mf/defc path-editor*
|
(mf/defc path-editor*
|
||||||
[{:keys [shape zoom state]}]
|
[{:keys [shape zoom state]}]
|
||||||
(let [hover-point (mf/use-state nil)
|
(let [editor-ref (mf/use-ref nil)
|
||||||
editor-ref (mf/use-ref nil)
|
|
||||||
|
|
||||||
{:keys [edit-mode
|
{:keys [edit-mode
|
||||||
drag-handler
|
drag-handler
|
||||||
prev-handler
|
prev-handler
|
||||||
preview
|
preview
|
||||||
content-modifiers
|
content-modifiers
|
||||||
last-point
|
selection
|
||||||
selected-points
|
|
||||||
moving-nodes
|
moving-nodes
|
||||||
moving-handler
|
moving-handler
|
||||||
hover-handlers
|
hover
|
||||||
hover-points
|
snap-toggled
|
||||||
snap-toggled]}
|
drag-cursor]}
|
||||||
state
|
state
|
||||||
|
|
||||||
selected-points
|
move-mode?
|
||||||
(or selected-points #{})
|
(= edit-mode :move)
|
||||||
|
|
||||||
|
draw-mode?
|
||||||
|
(= edit-mode :draw)
|
||||||
|
|
||||||
|
modifiers
|
||||||
|
(use-path-modifiers)
|
||||||
|
|
||||||
|
selected-nodes (get selection :nodes #{})
|
||||||
|
selected-segments (get selection :segments #{})
|
||||||
|
selected-handlers (get selection :handlers #{})
|
||||||
|
hover-nodes (get hover :nodes #{})
|
||||||
|
hover-segments (get hover :segments #{})
|
||||||
|
hover-handlers (get hover :handlers #{})
|
||||||
|
|
||||||
|
any-node-selected?
|
||||||
|
(boolean (seq selected-nodes))
|
||||||
|
|
||||||
|
;; Skip segment hit targets while dragging.
|
||||||
|
dragging?
|
||||||
|
(or (some? drag-cursor)
|
||||||
|
(some? drag-handler))
|
||||||
|
|
||||||
base-content
|
base-content
|
||||||
(get shape :content)
|
(get shape :content)
|
||||||
|
|
||||||
base-points
|
;; Cache segment midpoints used by insertion previews.
|
||||||
(mf/with-memo [base-content]
|
insertion-mid-points
|
||||||
(path/get-points base-content))
|
(mf/with-memo [base-content move-mode?]
|
||||||
|
(when move-mode?
|
||||||
|
(dwp.helpers/insertion-mid-points base-content)))
|
||||||
|
|
||||||
|
hover-point
|
||||||
|
(use-insertion-preview base-content zoom move-mode? insertion-mid-points)
|
||||||
|
|
||||||
content
|
content
|
||||||
(mf/with-memo [base-content content-modifiers]
|
(mf/with-memo [base-content content-modifiers]
|
||||||
@ -299,12 +524,19 @@
|
|||||||
(mf/with-memo [content]
|
(mf/with-memo [content]
|
||||||
(path/get-points content))
|
(path/get-points content))
|
||||||
|
|
||||||
point->base (->> (map hash-map content-points base-points) (reduce merge))
|
;; Pair each node position with its content index.
|
||||||
base->point (map-invert point->base)
|
node-entries
|
||||||
|
(mf/with-memo [content content-points]
|
||||||
|
(mapv vector (dwp.helpers/node-indices content) content-points))
|
||||||
|
|
||||||
points
|
segment-entries
|
||||||
(mf/with-memo [content-points]
|
(mf/with-memo [content dragging?]
|
||||||
(into #{} content-points))
|
(when-not dragging?
|
||||||
|
(dwp.helpers/segment-entries content)))
|
||||||
|
|
||||||
|
selected-segment-nodes
|
||||||
|
(mf/with-memo [content selected-segments]
|
||||||
|
(dwp.helpers/segment-node-indices content selected-segments))
|
||||||
|
|
||||||
last-p
|
last-p
|
||||||
(->> content last path.helpers/segment->point)
|
(->> content last path.helpers/segment->point)
|
||||||
@ -313,8 +545,16 @@
|
|||||||
(mf/with-memo [content]
|
(mf/with-memo [content]
|
||||||
(path/get-handlers content))
|
(path/get-handlers content))
|
||||||
|
|
||||||
is-path-start
|
;; Build snap ranges from stationary nodes.
|
||||||
(not (some? last-point))
|
snap-dragging-handler?
|
||||||
|
(boolean (or (some? drag-handler)
|
||||||
|
(some? preview)
|
||||||
|
(some? moving-handler)))
|
||||||
|
|
||||||
|
snap-ranges
|
||||||
|
(mf/with-memo [base-content selected-nodes selected-segments snap-dragging-handler?]
|
||||||
|
(create-snap-ranges
|
||||||
|
base-content selected-nodes selected-segments snap-dragging-handler?))
|
||||||
|
|
||||||
show-snap?
|
show-snap?
|
||||||
(and ^boolean snap-toggled
|
(and ^boolean snap-toggled
|
||||||
@ -329,23 +569,41 @@
|
|||||||
(st/emit! :interrupt)))]
|
(st/emit! :interrupt)))]
|
||||||
#(events/unlistenByKey key)))
|
#(events/unlistenByKey key)))
|
||||||
|
|
||||||
(hooks/use-stream
|
|
||||||
ms/mouse-position
|
|
||||||
(mf/deps base-content zoom)
|
|
||||||
(fn [position]
|
|
||||||
(when-let [point (path/closest-point base-content position (/ 0.01 zoom))]
|
|
||||||
(reset! hover-point (when (< (gpt/distance position point) (/ 10 zoom)) point)))))
|
|
||||||
|
|
||||||
[:g.path-editor {:ref editor-ref}
|
[:g.path-editor {:ref editor-ref}
|
||||||
[:path {:d (.toString content)
|
[:path {:d (.toString content)
|
||||||
:style {:fill "none"
|
:style {:fill "none"
|
||||||
:stroke accent-color
|
:stroke accent-color
|
||||||
:strokeWidth (/ 1 zoom)}}]
|
:strokeWidth (/ 1 zoom)}}]
|
||||||
|
(for [{:keys [index] :as entry} segment-entries]
|
||||||
|
(let [is-selected (or (contains? selected-segments index)
|
||||||
|
;; Select segments between selected endpoints.
|
||||||
|
(and (contains? selected-nodes (:from-index entry))
|
||||||
|
(contains? selected-nodes (:to-index entry))))
|
||||||
|
is-hover (contains? hover-segments index)]
|
||||||
|
[:> path-segment*
|
||||||
|
{:key (dm/str "segment-" index)
|
||||||
|
:entry entry
|
||||||
|
:zoom zoom
|
||||||
|
:edit-mode edit-mode
|
||||||
|
:is-interactive (or ^boolean move-mode? ^boolean draw-mode?)
|
||||||
|
:is-selected is-selected
|
||||||
|
:is-hover is-hover
|
||||||
|
:cursor (or drag-cursor
|
||||||
|
(segment-cursor edit-mode modifiers is-selected
|
||||||
|
(and is-hover (some? hover-point))))}]))
|
||||||
(when (and preview (not drag-handler))
|
(when (and preview (not drag-handler))
|
||||||
[:> path-preview* {:segment preview
|
[:> path-preview* {:segment preview
|
||||||
:from last-p
|
:from last-p
|
||||||
:zoom zoom}])
|
:zoom zoom}])
|
||||||
|
|
||||||
|
;; Let insertion preview clicks reach the segment.
|
||||||
|
(when (and ^boolean move-mode? (some? hover-point))
|
||||||
|
[:g.hover-point {:pointer-events "none"}
|
||||||
|
[:> path-point* {:position hover-point
|
||||||
|
:edit-mode edit-mode
|
||||||
|
:is-new true
|
||||||
|
:zoom zoom}]])
|
||||||
|
|
||||||
(when (and drag-handler last-p)
|
(when (and drag-handler last-p)
|
||||||
[:g.drag-handler {:pointer-events "none"}
|
[:g.drag-handler {:pointer-events "none"}
|
||||||
[:> path-handler* {:point last-p
|
[:> path-handler* {:point last-p
|
||||||
@ -353,90 +611,39 @@
|
|||||||
:edit-mode edit-mode
|
:edit-mode edit-mode
|
||||||
:zoom zoom}]])
|
:zoom zoom}]])
|
||||||
|
|
||||||
(when @hover-point
|
(for [[index position] node-entries]
|
||||||
[:g.hover-point
|
[:> path-node* {:key (dm/str "node-" index)
|
||||||
[:> path-point* {:position @hover-point
|
:index index
|
||||||
:edit-mode edit-mode
|
:position position
|
||||||
:is-new true
|
:content content
|
||||||
:is-start-path is-path-start
|
:handlers handlers
|
||||||
:zoom zoom}]])
|
:zoom zoom
|
||||||
|
:edit-mode edit-mode
|
||||||
(for [position points]
|
:selected-nodes selected-nodes
|
||||||
(let [pos-x (dm/get-prop position :x)
|
:selected-handlers selected-handlers
|
||||||
pos-y (dm/get-prop position :y)
|
:hover-nodes hover-nodes
|
||||||
|
:hover-handlers hover-handlers
|
||||||
show-handler?
|
:moving-handler moving-handler
|
||||||
(fn [[index prefix]]
|
:modifiers modifiers
|
||||||
;; FIXME: get-handler-point is executed twice for each
|
:drag-cursor drag-cursor
|
||||||
;; render, this can be optimized
|
:any-node-selected any-node-selected?}])
|
||||||
(let [handler-position (path/get-handler-point content index prefix)]
|
|
||||||
(not= position handler-position)))
|
|
||||||
|
|
||||||
position-handlers
|
|
||||||
(->> (get handlers position)
|
|
||||||
(filter show-handler?)
|
|
||||||
(not-empty))
|
|
||||||
|
|
||||||
point-selected?
|
|
||||||
(contains? selected-points (get point->base position))
|
|
||||||
|
|
||||||
point-hover?
|
|
||||||
(contains? hover-points (get point->base position))
|
|
||||||
|
|
||||||
is-last
|
|
||||||
(= last-point (get point->base position))
|
|
||||||
|
|
||||||
is-curve
|
|
||||||
(boolean position-handlers)]
|
|
||||||
|
|
||||||
[:g.path-node {:key (dm/str pos-x "-" pos-y)}
|
|
||||||
[:g.point-handlers {:pointer-events (when (= edit-mode :draw) "none")}
|
|
||||||
(for [[hindex prefix] position-handlers]
|
|
||||||
(let [handler-position (path/get-handler-point content hindex prefix)
|
|
||||||
handler-hover? (contains? hover-handlers [hindex prefix])
|
|
||||||
moving-handler? (= handler-position moving-handler)
|
|
||||||
matching-handler? (matching-handler? content position position-handlers)]
|
|
||||||
|
|
||||||
(when (and position handler-position)
|
|
||||||
[:> path-handler*
|
|
||||||
{:key (dm/str hindex "-" (d/name prefix))
|
|
||||||
:point position
|
|
||||||
:handler handler-position
|
|
||||||
:index hindex
|
|
||||||
:prefix prefix
|
|
||||||
:zoom zoom
|
|
||||||
:is-hover handler-hover?
|
|
||||||
:snap-angle (and moving-handler? matching-handler?)
|
|
||||||
:edit-mode edit-mode}])))]
|
|
||||||
|
|
||||||
[:> path-point* {:position position
|
|
||||||
:zoom zoom
|
|
||||||
:edit-mode edit-mode
|
|
||||||
:is-selected point-selected?
|
|
||||||
:is-hover point-hover?
|
|
||||||
:is-last is-last
|
|
||||||
:is-start-path is-path-start
|
|
||||||
:is-curve is-curve}]]))
|
|
||||||
|
|
||||||
(when (and prev-handler last-p)
|
(when (and prev-handler last-p)
|
||||||
[:g.prev-handler {:pointer-events "none"}
|
[:g.prev-handler
|
||||||
[:> path-handler*
|
[:> path-handler*
|
||||||
{:point last-p
|
{:point last-p
|
||||||
:edit-mode edit-mode
|
:edit-mode edit-mode
|
||||||
:handler prev-handler
|
:handler prev-handler
|
||||||
:zoom zoom}]])
|
:zoom zoom
|
||||||
|
:on-grab (fn [_] (st/emit! (drp/start-move-prev-handler)))
|
||||||
|
:cursor (or drag-cursor
|
||||||
|
(handler-cursor edit-mode modifiers false))}]])
|
||||||
|
|
||||||
(when ^boolean show-snap?
|
(when ^boolean show-snap?
|
||||||
(let [[snap-selected snap-points]
|
(let [snap-selected (snap-selected-points
|
||||||
(cond
|
content selected-nodes selected-segment-nodes
|
||||||
(some? drag-handler) [#{drag-handler} points]
|
drag-handler preview moving-handler)]
|
||||||
(some? preview) [#{(path.helpers/segment->point preview)} points]
|
|
||||||
(some? moving-handler) [#{moving-handler} points]
|
|
||||||
:else
|
|
||||||
[(->> selected-points (map base->point) (into #{}))
|
|
||||||
(->> points (remove selected-points) (into #{}))])]
|
|
||||||
[:g.path-snap {:pointer-events "none"}
|
[:g.path-snap {:pointer-events "none"}
|
||||||
[:> path-snap* {:selected snap-selected
|
[:> path-snap* {:selected snap-selected
|
||||||
:points snap-points
|
:ranges snap-ranges
|
||||||
:zoom zoom}]]))]))
|
:zoom zoom}]]))]))
|
||||||
|
|
||||||
|
|||||||
@ -15,6 +15,7 @@
|
|||||||
[app.main.data.helpers :as dsh]
|
[app.main.data.helpers :as dsh]
|
||||||
[app.main.data.workspace :as udw]
|
[app.main.data.workspace :as udw]
|
||||||
[app.main.data.workspace.common :as dwc]
|
[app.main.data.workspace.common :as dwc]
|
||||||
|
[app.main.data.workspace.path.state :as path.state]
|
||||||
[app.main.features :as features]
|
[app.main.features :as features]
|
||||||
[app.main.refs :as refs]
|
[app.main.refs :as refs]
|
||||||
[app.main.store :as st]
|
[app.main.store :as st]
|
||||||
@ -105,6 +106,18 @@
|
|||||||
drawing (mf/deref refs/workspace-drawing)
|
drawing (mf/deref refs/workspace-drawing)
|
||||||
edition (mf/deref refs/selected-edition)
|
edition (mf/deref refs/selected-edition)
|
||||||
|
|
||||||
|
edit-path
|
||||||
|
(mf/deref refs/workspace-edit-path)
|
||||||
|
|
||||||
|
edit-path-state
|
||||||
|
(path.state/current-edit-state edit-path edition)
|
||||||
|
|
||||||
|
path-editing?
|
||||||
|
(path.state/editing? edit-path edition)
|
||||||
|
|
||||||
|
path-node-count
|
||||||
|
(count (dm/get-in edit-path-state [:selection :nodes]))
|
||||||
|
|
||||||
files
|
files
|
||||||
(mf/deref refs/files)
|
(mf/deref refs/files)
|
||||||
|
|
||||||
@ -152,12 +165,22 @@
|
|||||||
|
|
||||||
[:div {:class (stl/css :element-options :design-options)}
|
[:div {:class (stl/css :element-options :design-options)}
|
||||||
[:> align-options* {:shapes shapes
|
[:> align-options* {:shapes shapes
|
||||||
:objects objects}]
|
:objects objects
|
||||||
[:> bool-options* {:total-selected total-selected
|
:path-edit? path-editing?
|
||||||
:shapes shapes
|
:node-count path-node-count}]
|
||||||
:shapes-with-children shapes-with-children}]
|
(when-not path-editing?
|
||||||
|
[:> bool-options* {:total-selected total-selected
|
||||||
|
:shapes shapes
|
||||||
|
:shapes-with-children shapes-with-children}])
|
||||||
|
|
||||||
(cond
|
(cond
|
||||||
|
;; Show path-specific options during node editing.
|
||||||
|
path-editing?
|
||||||
|
[:> path/path-edition-options*
|
||||||
|
{:shape (get objects edition)
|
||||||
|
:file-id file-id
|
||||||
|
:page-id page-id}]
|
||||||
|
|
||||||
(and edit-grid? (d/not-empty? selected-cells))
|
(and edit-grid? (d/not-empty? selected-cells))
|
||||||
[:> grid-cell/options*
|
[:> grid-cell/options*
|
||||||
{:shape-id (-> (get objects edition)
|
{:shape-id (-> (get objects edition)
|
||||||
|
|||||||
@ -8,6 +8,7 @@
|
|||||||
(:require-macros [app.main.style :as stl])
|
(:require-macros [app.main.style :as stl])
|
||||||
(:require
|
(:require
|
||||||
[app.main.data.workspace :as dw]
|
[app.main.data.workspace :as dw]
|
||||||
|
[app.main.data.workspace.path :as dwdp]
|
||||||
[app.main.data.workspace.shortcuts :as sc]
|
[app.main.data.workspace.shortcuts :as sc]
|
||||||
[app.main.store :as st]
|
[app.main.store :as st]
|
||||||
[app.main.ui.icons :as deprecated-icon]
|
[app.main.ui.icons :as deprecated-icon]
|
||||||
@ -16,30 +17,44 @@
|
|||||||
[rumext.v2 :as mf]))
|
[rumext.v2 :as mf]))
|
||||||
|
|
||||||
(mf/defc align-options*
|
(mf/defc align-options*
|
||||||
[{:keys [shapes objects]}]
|
;; Align path nodes or whole shapes for the current edit mode.
|
||||||
(let [disabled-align
|
[{:keys [shapes objects path-edit? node-count]}]
|
||||||
(not (dw/can-align? shapes objects))
|
(let [node-count (or node-count 0)
|
||||||
|
|
||||||
|
disabled-align
|
||||||
|
(if path-edit?
|
||||||
|
(< node-count 2)
|
||||||
|
(not (dw/can-align? shapes objects)))
|
||||||
|
|
||||||
disabled-distribute
|
disabled-distribute
|
||||||
(not (dw/can-distribute? shapes))
|
(if path-edit?
|
||||||
|
(< node-count 3)
|
||||||
|
(not (dw/can-distribute? shapes)))
|
||||||
|
|
||||||
align-objects
|
align-objects
|
||||||
(mf/use-fn
|
(mf/use-fn
|
||||||
|
(mf/deps path-edit?)
|
||||||
(fn [event]
|
(fn [event]
|
||||||
(let [value (-> (dom/get-current-target event)
|
(let [value (-> (dom/get-current-target event)
|
||||||
(dom/get-data "value")
|
(dom/get-data "value")
|
||||||
(keyword))]
|
(keyword))]
|
||||||
(st/emit! (dw/align-objects value)))))
|
(st/emit! (if path-edit?
|
||||||
|
(dwdp/align-nodes value)
|
||||||
|
(dw/align-objects value))))))
|
||||||
|
|
||||||
distribute-objects
|
distribute-objects
|
||||||
(mf/use-fn
|
(mf/use-fn
|
||||||
|
(mf/deps path-edit?)
|
||||||
(fn [event]
|
(fn [event]
|
||||||
(let [value (-> (dom/get-current-target event)
|
(let [value (-> (dom/get-current-target event)
|
||||||
(dom/get-data "value")
|
(dom/get-data "value")
|
||||||
(keyword))]
|
(keyword))]
|
||||||
(st/emit! (dw/distribute-objects value)))))]
|
(st/emit! (if path-edit?
|
||||||
|
(dwdp/distribute-nodes value)
|
||||||
|
(dw/distribute-objects value))))))]
|
||||||
|
|
||||||
(when-not (and disabled-align disabled-distribute)
|
;; Keep path controls visible while their actions are disabled.
|
||||||
|
(when (or path-edit? (not (and disabled-align disabled-distribute)))
|
||||||
[:div {:class (stl/css :align-options)}
|
[:div {:class (stl/css :align-options)}
|
||||||
[:div {:class (stl/css :align-group-horizontal)}
|
[:div {:class (stl/css :align-group-horizontal)}
|
||||||
[:button {:class (stl/css-case :align-button true
|
[:button {:class (stl/css-case :align-button true
|
||||||
@ -106,4 +121,3 @@
|
|||||||
:data-value "vertical"
|
:data-value "vertical"
|
||||||
:on-click distribute-objects}
|
:on-click distribute-objects}
|
||||||
deprecated-icon/distribute-vertical-spacing]]])))
|
deprecated-icon/distribute-vertical-spacing]]])))
|
||||||
|
|
||||||
|
|||||||
@ -176,6 +176,29 @@
|
|||||||
(identical? (get o-values :proportion-lock)
|
(identical? (get o-values :proportion-lock)
|
||||||
(get n-values :proportion-lock)))))
|
(get n-values :proportion-lock)))))
|
||||||
|
|
||||||
|
(mf/defc node-position-menu*
|
||||||
|
"X and Y inputs for the current path selection."
|
||||||
|
[{:keys [values on-x-change on-y-change]}]
|
||||||
|
;; Match the shape position input layout.
|
||||||
|
[:section {:class (stl/css :element-set)}
|
||||||
|
[:div {:class (stl/css :position)}
|
||||||
|
[:div {:class (stl/css :x-position)
|
||||||
|
:title (tr "workspace.options.x")}
|
||||||
|
[:span {:class (stl/css :icon-text)} "X"]
|
||||||
|
[:> deprecated-input/numeric-input* {:no-validate true
|
||||||
|
:placeholder (if (= :multiple (:x values)) (tr "settings.multiple") "--")
|
||||||
|
:on-change on-x-change
|
||||||
|
:class (stl/css :numeric-input)
|
||||||
|
:value (:x values)}]]
|
||||||
|
[:div {:class (stl/css :y-position)
|
||||||
|
:title (tr "workspace.options.y")}
|
||||||
|
[:span {:class (stl/css :icon-text)} "Y"]
|
||||||
|
[:> deprecated-input/numeric-input* {:no-validate true
|
||||||
|
:placeholder (if (= :multiple (:y values)) (tr "settings.multiple") "--")
|
||||||
|
:on-change on-y-change
|
||||||
|
:class (stl/css :numeric-input)
|
||||||
|
:value (:y values)}]]]])
|
||||||
|
|
||||||
(mf/defc measures-menu*
|
(mf/defc measures-menu*
|
||||||
{::mf/wrap [#(mf/memo' % check-measures-menu-props)]}
|
{::mf/wrap [#(mf/memo' % check-measures-menu-props)]}
|
||||||
[{:keys [ids values applied-tokens type shapes]}]
|
[{:keys [ids values applied-tokens type shapes]}]
|
||||||
|
|||||||
@ -7,8 +7,13 @@
|
|||||||
(ns app.main.ui.workspace.sidebar.options.shapes.path
|
(ns app.main.ui.workspace.sidebar.options.shapes.path
|
||||||
(:require
|
(:require
|
||||||
[app.common.data.macros :as dm]
|
[app.common.data.macros :as dm]
|
||||||
|
[app.common.files.helpers :as cfh]
|
||||||
|
[app.common.types.path :as cpath]
|
||||||
[app.common.types.shape.layout :as ctl]
|
[app.common.types.shape.layout :as ctl]
|
||||||
|
[app.main.data.workspace.path :as drp]
|
||||||
|
[app.main.data.workspace.path.helpers :as path.helpers]
|
||||||
[app.main.refs :as refs]
|
[app.main.refs :as refs]
|
||||||
|
[app.main.store :as st]
|
||||||
[app.main.ui.workspace.sidebar.options.menus.blur :refer [blur-menu*]]
|
[app.main.ui.workspace.sidebar.options.menus.blur :refer [blur-menu*]]
|
||||||
[app.main.ui.workspace.sidebar.options.menus.constraints :refer [constraint-attrs constraints-menu*]]
|
[app.main.ui.workspace.sidebar.options.menus.constraints :refer [constraint-attrs constraints-menu*]]
|
||||||
[app.main.ui.workspace.sidebar.options.menus.exports :refer [exports-menu* exports-attrs]]
|
[app.main.ui.workspace.sidebar.options.menus.exports :refer [exports-menu* exports-attrs]]
|
||||||
@ -17,7 +22,7 @@
|
|||||||
[app.main.ui.workspace.sidebar.options.menus.layer :refer [layer-attrs layer-menu*]]
|
[app.main.ui.workspace.sidebar.options.menus.layer :refer [layer-attrs layer-menu*]]
|
||||||
[app.main.ui.workspace.sidebar.options.menus.layout-container :refer [layout-container-flex-attrs layout-container-menu*]]
|
[app.main.ui.workspace.sidebar.options.menus.layout-container :refer [layout-container-flex-attrs layout-container-menu*]]
|
||||||
[app.main.ui.workspace.sidebar.options.menus.layout-item :refer [layout-item-attrs layout-item-menu*]]
|
[app.main.ui.workspace.sidebar.options.menus.layout-item :refer [layout-item-attrs layout-item-menu*]]
|
||||||
[app.main.ui.workspace.sidebar.options.menus.measures :refer [measure-attrs measures-menu*]]
|
[app.main.ui.workspace.sidebar.options.menus.measures :refer [measure-attrs measures-menu* node-position-menu*]]
|
||||||
[app.main.ui.workspace.sidebar.options.menus.shadow :refer [shadow-menu*]]
|
[app.main.ui.workspace.sidebar.options.menus.shadow :refer [shadow-menu*]]
|
||||||
[app.main.ui.workspace.sidebar.options.menus.stroke :refer [stroke-attrs stroke-menu*]]
|
[app.main.ui.workspace.sidebar.options.menus.stroke :refer [stroke-attrs stroke-menu*]]
|
||||||
[app.main.ui.workspace.sidebar.options.menus.svg-attrs :refer [svg-attrs-menu*]]
|
[app.main.ui.workspace.sidebar.options.menus.svg-attrs :refer [svg-attrs-menu*]]
|
||||||
@ -144,3 +149,96 @@
|
|||||||
:page-id page-id
|
:page-id page-id
|
||||||
:file-id file-id}]]))
|
:file-id file-id}]]))
|
||||||
|
|
||||||
|
(mf/defc path-edition-options*
|
||||||
|
"Options shown while editing a path."
|
||||||
|
[{:keys [shape]}]
|
||||||
|
(let [id (dm/get-prop shape :id)
|
||||||
|
type (dm/get-prop shape :type)
|
||||||
|
ids (mf/with-memo [id] [id])
|
||||||
|
shapes (mf/with-memo [shape] [shape])
|
||||||
|
|
||||||
|
applied-tokens
|
||||||
|
(get shape :applied-tokens)
|
||||||
|
|
||||||
|
measure-values
|
||||||
|
(select-keys shape measure-attrs)
|
||||||
|
|
||||||
|
stroke-values
|
||||||
|
(select-keys shape stroke-attrs)
|
||||||
|
|
||||||
|
;; Read coordinates from the live editing content.
|
||||||
|
edit-path (mf/deref refs/workspace-edit-path)
|
||||||
|
drawing (mf/deref refs/current-drawing-shape)
|
||||||
|
objects (mf/deref refs/workspace-page-objects)
|
||||||
|
selection (get-in edit-path [id :selection])
|
||||||
|
modifiers (get-in edit-path [id :content-modifiers])
|
||||||
|
|
||||||
|
content
|
||||||
|
(mf/with-memo [drawing modifiers]
|
||||||
|
(when-let [base (get drawing :content)]
|
||||||
|
(cpath/apply-content-modifiers base modifiers)))
|
||||||
|
|
||||||
|
;; Show coordinates relative to the parent frame.
|
||||||
|
frame (cfh/get-parent-frame objects shape)
|
||||||
|
in-frame? (and (some? frame) (not (cfh/root? frame)))
|
||||||
|
ox (if in-frame? (dm/get-prop frame :x) 0)
|
||||||
|
oy (if in-frame? (dm/get-prop frame :y) 0)
|
||||||
|
|
||||||
|
;; Segments use selection bounds; nodes and handlers use their positions.
|
||||||
|
node-values
|
||||||
|
(mf/with-memo [content selection ox oy]
|
||||||
|
(when (and (some? content) (some? selection))
|
||||||
|
(let [segments (get selection :segments)
|
||||||
|
handlers (get selection :handlers)
|
||||||
|
nodes (get selection :nodes)]
|
||||||
|
(cond
|
||||||
|
(seq segments)
|
||||||
|
(when-let [rect (path.helpers/selection-coordinate-rect
|
||||||
|
content selection)]
|
||||||
|
{:x (- (dm/get-prop rect :x) ox)
|
||||||
|
:y (- (dm/get-prop rect :y) oy)})
|
||||||
|
|
||||||
|
(or (seq nodes) (seq handlers))
|
||||||
|
(let [positions (into (path.helpers/node-positions content (set nodes))
|
||||||
|
(keep (fn [[i p]] (cpath/get-handler-point content i p)))
|
||||||
|
handlers)]
|
||||||
|
(when (seq positions)
|
||||||
|
(let [xs (into #{} (map #(- (:x %) ox)) positions)
|
||||||
|
ys (into #{} (map #(- (:y %) oy)) positions)]
|
||||||
|
{:x (if (= 1 (count xs)) (first xs) :multiple)
|
||||||
|
:y (if (= 1 (count ys)) (first ys) :multiple)})))))))
|
||||||
|
|
||||||
|
on-node-x-change
|
||||||
|
(mf/use-fn (mf/deps ox)
|
||||||
|
(fn [value] (when (some? value) (st/emit! (drp/set-selection-coordinate :x (+ value ox))))))
|
||||||
|
|
||||||
|
on-node-y-change
|
||||||
|
(mf/use-fn (mf/deps oy)
|
||||||
|
(fn [value] (when (some? value) (st/emit! (drp/set-selection-coordinate :y (+ value oy))))))]
|
||||||
|
|
||||||
|
[:*
|
||||||
|
(when (some? node-values)
|
||||||
|
[:> node-position-menu* {:values node-values
|
||||||
|
:on-x-change on-node-x-change
|
||||||
|
:on-y-change on-node-y-change}])
|
||||||
|
;; Show read-only shape measures when no path element is selected.
|
||||||
|
(when (nil? node-values)
|
||||||
|
[:div {:style {:pointer-events "none" :opacity 0.6}}
|
||||||
|
[:> measures-menu* {:ids ids
|
||||||
|
:type type
|
||||||
|
:applied-tokens applied-tokens
|
||||||
|
:values measure-values
|
||||||
|
:shapes shapes}]])
|
||||||
|
[:> fill/fill-menu*
|
||||||
|
{:ids ids
|
||||||
|
:type type
|
||||||
|
:values shape
|
||||||
|
:applied-tokens applied-tokens}]
|
||||||
|
[:> stroke-menu* {:ids ids
|
||||||
|
:type type
|
||||||
|
:show-caps true
|
||||||
|
:values stroke-values
|
||||||
|
:applied-tokens applied-tokens}]
|
||||||
|
[:> shadow-menu* {:ids ids :values (get shape :shadow)}]
|
||||||
|
[:> blur-menu* {:ids ids
|
||||||
|
:values (select-keys shape [:blur :background-blur])}]]))
|
||||||
|
|||||||
@ -16,6 +16,7 @@
|
|||||||
[app.main.data.workspace.drawing.common :as dwdc]
|
[app.main.data.workspace.drawing.common :as dwdc]
|
||||||
[app.main.data.workspace.mcp :as mcp]
|
[app.main.data.workspace.mcp :as mcp]
|
||||||
[app.main.data.workspace.media :as dwm]
|
[app.main.data.workspace.media :as dwm]
|
||||||
|
[app.main.data.workspace.path.state :as pst]
|
||||||
[app.main.data.workspace.shortcuts :as sc]
|
[app.main.data.workspace.shortcuts :as sc]
|
||||||
[app.main.features :as features]
|
[app.main.features :as features]
|
||||||
[app.main.refs :as refs]
|
[app.main.refs :as refs]
|
||||||
@ -34,16 +35,16 @@
|
|||||||
|
|
||||||
(def ^:private toolbar-hidden-ref
|
(def ^:private toolbar-hidden-ref
|
||||||
(l/derived (fn [state]
|
(l/derived (fn [state]
|
||||||
(let [visibility (get state :hide-toolbar)
|
(let [visibility (get-in state [:workspace-local :hide-toolbar])
|
||||||
path-edit-state (get state :edit-path)
|
selected (get-in state [:workspace-local :selected])
|
||||||
selected (get state :selected)
|
|
||||||
edition (get state :edition)
|
|
||||||
|
|
||||||
is-single (= (count selected) 1)
|
is-single (= (count selected) 1)
|
||||||
is-path-editing (and is-single (some? (get path-edit-state edition)))]
|
;; The path edition bar replaces this toolbar.
|
||||||
|
is-path-editing (and is-single (pst/editing? state))
|
||||||
|
is-path-drawing (pst/drawing? state)]
|
||||||
|
|
||||||
(if is-path-editing true visibility)))
|
(if (or is-path-editing is-path-drawing) true visibility)))
|
||||||
refs/workspace-local))
|
st/state))
|
||||||
|
|
||||||
(def grouped-tools
|
(def grouped-tools
|
||||||
{:shapes {:default-tool :rect
|
{:shapes {:default-tool :rect
|
||||||
|
|||||||
@ -13,7 +13,6 @@
|
|||||||
[app.common.geom.shapes :as gsh]
|
[app.common.geom.shapes :as gsh]
|
||||||
[app.common.types.color :as clr]
|
[app.common.types.color :as clr]
|
||||||
[app.common.types.component :as ctk]
|
[app.common.types.component :as ctk]
|
||||||
[app.common.types.path :as path]
|
|
||||||
[app.common.types.shape :as cts]
|
[app.common.types.shape :as cts]
|
||||||
[app.common.types.shape-tree :as ctt]
|
[app.common.types.shape-tree :as ctt]
|
||||||
[app.common.types.shape.layout :as ctl]
|
[app.common.types.shape.layout :as ctl]
|
||||||
@ -46,6 +45,7 @@
|
|||||||
[app.main.ui.workspace.viewport.hooks :as hooks]
|
[app.main.ui.workspace.viewport.hooks :as hooks]
|
||||||
[app.main.ui.workspace.viewport.interactions :as interactions]
|
[app.main.ui.workspace.viewport.interactions :as interactions]
|
||||||
[app.main.ui.workspace.viewport.outline :as outline]
|
[app.main.ui.workspace.viewport.outline :as outline]
|
||||||
|
[app.main.ui.workspace.viewport.path-state :as path-state]
|
||||||
[app.main.ui.workspace.viewport.pixel-overlay :as pixel-overlay]
|
[app.main.ui.workspace.viewport.pixel-overlay :as pixel-overlay]
|
||||||
[app.main.ui.workspace.viewport.presence :as presence]
|
[app.main.ui.workspace.viewport.presence :as presence]
|
||||||
[app.main.ui.workspace.viewport.rulers :as rulers]
|
[app.main.ui.workspace.viewport.rulers :as rulers]
|
||||||
@ -53,8 +53,7 @@
|
|||||||
[app.main.ui.workspace.viewport.selection :as selection]
|
[app.main.ui.workspace.viewport.selection :as selection]
|
||||||
[app.main.ui.workspace.viewport.snap-distances :as snap-distances]
|
[app.main.ui.workspace.viewport.snap-distances :as snap-distances]
|
||||||
[app.main.ui.workspace.viewport.snap-points :as snap-points]
|
[app.main.ui.workspace.viewport.snap-points :as snap-points]
|
||||||
[app.main.ui.workspace.viewport.top-bar :refer [grid-edition-bar*
|
[app.main.ui.workspace.viewport.top-bar :refer [edition-bars*
|
||||||
path-edition-bar*
|
|
||||||
view-only-bar*]]
|
view-only-bar*]]
|
||||||
[app.main.ui.workspace.viewport.utils :as utils]
|
[app.main.ui.workspace.viewport.utils :as utils]
|
||||||
[app.main.ui.workspace.viewport.viewport-ref :refer [create-viewport-ref]]
|
[app.main.ui.workspace.viewport.viewport-ref :refer [create-viewport-ref]]
|
||||||
@ -177,21 +176,22 @@
|
|||||||
selected-frame (when (= (count selected-frames) 1)
|
selected-frame (when (= (count selected-frames) 1)
|
||||||
(get base-objects (first selected-frames)))
|
(get base-objects (first selected-frames)))
|
||||||
|
|
||||||
edit-path-state (get edit-path edition)
|
{:keys [edit-state
|
||||||
edit-path-mode (get edit-path-state :edit-mode)
|
editing?
|
||||||
|
drawing?
|
||||||
|
editing-shape
|
||||||
|
bar-state
|
||||||
|
bar-shape
|
||||||
|
drawing-shape]}
|
||||||
|
(mf/with-memo [edit-path edition drawing-tool drawing-obj base-objects]
|
||||||
|
(path-state/derive-path-state edit-path edition drawing-tool drawing-obj base-objects))
|
||||||
|
|
||||||
path-editing? (some? edit-path-state)
|
edit-path-state edit-state
|
||||||
path-drawing? (or (= edit-path-mode :draw)
|
path-editing? editing?
|
||||||
(and (= :path (get drawing-obj :type))
|
path-drawing? drawing?
|
||||||
(not= :curve drawing-tool)))
|
path-bar-state bar-state
|
||||||
|
path-bar-shape bar-shape
|
||||||
editing-shape (when edition
|
draw-area-shape drawing-shape
|
||||||
(get base-objects edition))
|
|
||||||
|
|
||||||
editing-shape (mf/with-memo [editing-shape path-editing? base-objects]
|
|
||||||
(if path-editing?
|
|
||||||
(path/convert-to-path editing-shape base-objects)
|
|
||||||
editing-shape))
|
|
||||||
|
|
||||||
create-comment? (= :comments drawing-tool)
|
create-comment? (= :comments drawing-tool)
|
||||||
|
|
||||||
@ -255,8 +255,14 @@
|
|||||||
(seq selected))
|
(seq selected))
|
||||||
show-snap-points? (and (or (contains? layout :dynamic-alignment)
|
show-snap-points? (and (or (contains? layout :dynamic-alignment)
|
||||||
(contains? layout :snap-guides))
|
(contains? layout :snap-guides))
|
||||||
(or drawing-obj transform))
|
(or drawing-obj transform)
|
||||||
show-selrect? (and selrect (empty? drawing) (not text-editing?))
|
(not path-editing?))
|
||||||
|
|
||||||
|
render-objects (mf/with-memo [base-objects path-editing? edition]
|
||||||
|
(cond-> base-objects
|
||||||
|
path-editing?
|
||||||
|
(assoc-in [edition :hidden] true)))
|
||||||
|
show-selrect? (and selrect (or (empty? drawing) path-editing?) (not text-editing?))
|
||||||
show-measures? (and (not transform)
|
show-measures? (and (not transform)
|
||||||
(not path-editing?)
|
(not path-editing?)
|
||||||
(or show-distances? mode-inspect? read-only?))
|
(or show-distances? mode-inspect? read-only?))
|
||||||
@ -311,7 +317,7 @@
|
|||||||
|
|
||||||
(hooks/setup-dom-events zoom disable-paste-ref in-viewport-ref read-only? drawing-tool path-drawing?)
|
(hooks/setup-dom-events zoom disable-paste-ref in-viewport-ref read-only? drawing-tool path-drawing?)
|
||||||
(hooks/setup-viewport-size vport viewport-ref)
|
(hooks/setup-viewport-size vport viewport-ref)
|
||||||
(hooks/setup-cursor cursor alt? mod? space? panning drawing-tool path-drawing? path-editing? z? read-only?)
|
(hooks/setup-cursor cursor alt? mod? space? panning drawing-tool path-drawing? path-editing? (get path-bar-state :drag-cursor) z? read-only?)
|
||||||
(hooks/setup-keyboard alt? mod? space? z? shift?)
|
(hooks/setup-keyboard alt? mod? space? z? shift?)
|
||||||
(hooks/setup-hover-shapes page-id move-stream base-objects selected mod? hover measure-hover
|
(hooks/setup-hover-shapes page-id move-stream base-objects selected mod? hover measure-hover
|
||||||
hover-ids hover-top-frame-id @hover-disabled? focus zoom show-measures? read-only? transform)
|
hover-ids hover-top-frame-id @hover-disabled? focus zoom show-measures? read-only? transform)
|
||||||
@ -332,15 +338,14 @@
|
|||||||
(when-not hide-ui?
|
(when-not hide-ui?
|
||||||
[:> top-toolbar* {:layout layout}])
|
[:> top-toolbar* {:layout layout}])
|
||||||
|
|
||||||
(when (and ^boolean path-editing?
|
[:> edition-bars* {:layout layout
|
||||||
^boolean single-select?)
|
:path-editing path-editing?
|
||||||
[:> path-edition-bar* {:shape editing-shape
|
:path-drawing path-drawing?
|
||||||
:edit-path-state edit-path-state
|
:path-state path-bar-state
|
||||||
:layout layout}])
|
:path-shape path-bar-shape
|
||||||
|
:grid-editing grid-editing?
|
||||||
(when (and ^boolean grid-editing?
|
:grid-shape editing-shape
|
||||||
^boolean single-select?)
|
:single-select single-select?}]])
|
||||||
[:> grid-edition-bar* {:shape editing-shape}])])
|
|
||||||
|
|
||||||
[:div {:class (stl/css :viewport-overlays)}
|
[:div {:class (stl/css :viewport-overlays)}
|
||||||
;; The behaviour inside a foreign object is a bit different that in plain HTML so we wrap
|
;; The behaviour inside a foreign object is a bit different that in plain HTML so we wrap
|
||||||
@ -411,7 +416,7 @@
|
|||||||
[:& (mf/provider use/include-metadata-ctx) {:value (dbg/enabled? :show-export-metadata)}
|
[:& (mf/provider use/include-metadata-ctx) {:value (dbg/enabled? :show-export-metadata)}
|
||||||
;; Render root shape
|
;; Render root shape
|
||||||
[:& shapes/root-shape {:key (str page-id)
|
[:& shapes/root-shape {:key (str page-id)
|
||||||
:objects base-objects
|
:objects render-objects
|
||||||
:active-frames @active-frames
|
:active-frames @active-frames
|
||||||
;; disable thumbnails when previewing a version
|
;; disable thumbnails when previewing a version
|
||||||
:disable-thumbnails (some? preview-id)}]]]]
|
:disable-thumbnails (some? preview-id)}]]]]
|
||||||
@ -593,7 +598,7 @@
|
|||||||
(when (and ^boolean show-draw-area?
|
(when (and ^boolean show-draw-area?
|
||||||
^boolean (cts/shape? drawing-obj))
|
^boolean (cts/shape? drawing-obj))
|
||||||
[:> drawarea/draw-area*
|
[:> drawarea/draw-area*
|
||||||
{:shape drawing-obj
|
{:shape draw-area-shape
|
||||||
:zoom zoom
|
:zoom zoom
|
||||||
:tool drawing-tool}])
|
:tool drawing-tool}])
|
||||||
|
|
||||||
|
|||||||
@ -9,6 +9,7 @@
|
|||||||
(:require
|
(:require
|
||||||
[app.common.data.macros :as dm]
|
[app.common.data.macros :as dm]
|
||||||
[app.common.math :as mth]
|
[app.common.math :as mth]
|
||||||
|
[app.common.types.path :as path]
|
||||||
[app.main.refs :as refs]
|
[app.main.refs :as refs]
|
||||||
[app.main.ui.shapes.path :refer [path-shape]]
|
[app.main.ui.shapes.path :refer [path-shape]]
|
||||||
[app.main.ui.workspace.shapes :as shapes]
|
[app.main.ui.workspace.shapes :as shapes]
|
||||||
@ -20,6 +21,27 @@
|
|||||||
(let [get-fn #(dm/get-in % [:edit-path id])]
|
(let [get-fn #(dm/get-in % [:edit-path id])]
|
||||||
(l/derived get-fn refs/workspace-local)))
|
(l/derived get-fn refs/workspace-local)))
|
||||||
|
|
||||||
|
(def ^:private edit-fill-opacity
|
||||||
|
"Fill opacity used while editing a path."
|
||||||
|
0.8)
|
||||||
|
|
||||||
|
(def ^:private synced-edit-attrs
|
||||||
|
"Visual attributes copied into the live editing shape."
|
||||||
|
[:strokes :shadow :blur :background-blur :opacity :blend-mode])
|
||||||
|
|
||||||
|
(defn- dim-fills
|
||||||
|
[fills]
|
||||||
|
(mapv (fn [fill]
|
||||||
|
(update fill :fill-opacity #(* (or % 1) edit-fill-opacity)))
|
||||||
|
fills))
|
||||||
|
|
||||||
|
(defn path-edit-shape
|
||||||
|
"Builds the path shape rendered during editing."
|
||||||
|
[drawing-obj stored]
|
||||||
|
(-> (cond-> (merge drawing-obj (select-keys stored synced-edit-attrs))
|
||||||
|
(seq (:fills stored)) (assoc :fills (:fills stored)))
|
||||||
|
(update :fills dim-fills)))
|
||||||
|
|
||||||
(mf/defc generic-draw-area*
|
(mf/defc generic-draw-area*
|
||||||
{::mf/private true}
|
{::mf/private true}
|
||||||
[{:keys [shape zoom]}]
|
[{:keys [shape zoom]}]
|
||||||
@ -55,12 +77,36 @@
|
|||||||
|
|
||||||
(mf/defc draw-area*
|
(mf/defc draw-area*
|
||||||
[{:keys [shape zoom tool] :as props}]
|
[{:keys [shape zoom tool] :as props}]
|
||||||
[:g.draw-area
|
(let [shape-id
|
||||||
[:g {:style {:pointer-events "none"}}
|
(dm/get-prop shape :id)
|
||||||
[:& shapes/shape-wrapper {:shape shape}]]
|
|
||||||
|
|
||||||
(case tool
|
edit-path-ref
|
||||||
:path [:> path-draw-area* props]
|
(mf/with-memo [shape-id]
|
||||||
:curve [:& path-shape {:shape shape :zoom zoom}]
|
(make-edit-path-ref shape-id))
|
||||||
#_:default [:> generic-draw-area* props])])
|
|
||||||
|
|
||||||
|
;; Keep command indices unchanged while applying drag modifiers.
|
||||||
|
dragging?
|
||||||
|
(some? (:content-modifiers (mf/deref edit-path-ref)))
|
||||||
|
|
||||||
|
;; Close rendered subpaths while keeping editor content untouched.
|
||||||
|
render-shape
|
||||||
|
(mf/with-memo [shape dragging?]
|
||||||
|
(if (and (= :path (dm/get-prop shape :type)) (not dragging?))
|
||||||
|
(update shape :content #(-> % path/close-subpaths path/close-loops))
|
||||||
|
shape))]
|
||||||
|
[:g.draw-area
|
||||||
|
[:g {:style {:pointer-events "none"}}
|
||||||
|
[:& shapes/shape-wrapper {:shape render-shape}]]
|
||||||
|
|
||||||
|
(cond
|
||||||
|
(= tool :path)
|
||||||
|
[:> path-draw-area* props]
|
||||||
|
|
||||||
|
(= tool :curve)
|
||||||
|
[:& path-shape {:shape shape :zoom zoom}]
|
||||||
|
|
||||||
|
(= (:type shape) :path)
|
||||||
|
nil
|
||||||
|
|
||||||
|
:else
|
||||||
|
[:> generic-draw-area* props])]))
|
||||||
|
|||||||
@ -25,6 +25,7 @@
|
|||||||
[app.main.features :as features]
|
[app.main.features :as features]
|
||||||
[app.main.store :as st]
|
[app.main.store :as st]
|
||||||
[app.main.streams :as ms]
|
[app.main.streams :as ms]
|
||||||
|
[app.main.ui.css-cursors :as cur]
|
||||||
[app.main.ui.hooks :as hooks]
|
[app.main.ui.hooks :as hooks]
|
||||||
[app.main.ui.workspace.shapes.frame.dynamic-modifiers :as sfd]
|
[app.main.ui.workspace.shapes.frame.dynamic-modifiers :as sfd]
|
||||||
[app.main.ui.workspace.viewport.actions :as actions]
|
[app.main.ui.workspace.viewport.actions :as actions]
|
||||||
@ -91,9 +92,9 @@
|
|||||||
(when (not= size vport)
|
(when (not= size vport)
|
||||||
(st/emit! (dw/initialize-viewport (dom/get-client-size prnt)))))))
|
(st/emit! (dw/initialize-viewport (dom/get-client-size prnt)))))))
|
||||||
|
|
||||||
(defn setup-cursor [cursor alt? mod? space? panning drawing-tool drawing-path? path-editing? z? workspace-read-only?]
|
(defn setup-cursor [cursor alt? mod? space? panning drawing-tool drawing-path? path-editing? path-drag-cursor z? workspace-read-only?]
|
||||||
(mf/use-effect
|
(mf/use-effect
|
||||||
(mf/deps @cursor @alt? @mod? @space? panning drawing-tool drawing-path? path-editing? z? workspace-read-only?)
|
(mf/deps @cursor @alt? @mod? @space? panning drawing-tool drawing-path? path-editing? path-drag-cursor z? workspace-read-only?)
|
||||||
(fn []
|
(fn []
|
||||||
(let [show-pen? (or (= drawing-tool :path)
|
(let [show-pen? (or (= drawing-tool :path)
|
||||||
(and drawing-path?
|
(and drawing-path?
|
||||||
@ -108,18 +109,20 @@
|
|||||||
(cond
|
(cond
|
||||||
(and @mod? @space?) (utils/get-cursor :zoom)
|
(and @mod? @space?) (utils/get-cursor :zoom)
|
||||||
(or panning @space?) (utils/get-cursor :hand)
|
(or panning @space?) (utils/get-cursor :hand)
|
||||||
|
;; Keep the drag cursor across the viewport.
|
||||||
|
(some? path-drag-cursor) (cur/get-static path-drag-cursor)
|
||||||
(= drawing-tool :comments) (utils/get-cursor :comments)
|
(= drawing-tool :comments) (utils/get-cursor :comments)
|
||||||
(= drawing-tool :frame) (utils/get-cursor :create-artboard)
|
(= drawing-tool :frame) (utils/get-cursor :create-artboard)
|
||||||
(= drawing-tool :rect) (utils/get-cursor :create-rectangle)
|
(= drawing-tool :rect) (utils/get-cursor :create-rectangle)
|
||||||
(= drawing-tool :circle) (utils/get-cursor :create-ellipse)
|
(= drawing-tool :circle) (utils/get-cursor :create-ellipse)
|
||||||
(and show-zoom? (not @alt?)) (utils/get-cursor :zoom-in)
|
(and show-zoom? (not @alt?)) (utils/get-cursor :zoom-in)
|
||||||
(and show-zoom? @alt?) (utils/get-cursor :zoom-out)
|
(and show-zoom? @alt?) (utils/get-cursor :zoom-out)
|
||||||
show-pen? (utils/get-cursor :pen)
|
show-pen? (utils/get-cursor :draw-path)
|
||||||
(= drawing-tool :curve) (utils/get-cursor :pencil)
|
(= drawing-tool :curve) (utils/get-cursor :pencil)
|
||||||
drawing-tool (utils/get-cursor :create-shape)
|
drawing-tool (utils/get-cursor :create-shape)
|
||||||
|
path-editing? (utils/get-cursor :edit-path)
|
||||||
(and
|
(and
|
||||||
@alt?
|
@alt?
|
||||||
(not path-editing?)
|
|
||||||
(not workspace-read-only?)) (utils/get-cursor :duplicate)
|
(not workspace-read-only?)) (utils/get-cursor :duplicate)
|
||||||
:else (utils/get-cursor :pointer-inner))]
|
:else (utils/get-cursor :pointer-inner))]
|
||||||
|
|
||||||
|
|||||||
@ -7,12 +7,13 @@
|
|||||||
(ns app.main.ui.workspace.viewport.path-actions
|
(ns app.main.ui.workspace.viewport.path-actions
|
||||||
(:require-macros [app.main.style :as stl])
|
(:require-macros [app.main.style :as stl])
|
||||||
(:require
|
(:require
|
||||||
[app.common.types.path.segment :as path.segm]
|
|
||||||
[app.main.data.workspace.path :as drp]
|
[app.main.data.workspace.path :as drp]
|
||||||
|
[app.main.data.workspace.path.helpers :as path.helpers]
|
||||||
[app.main.data.workspace.path.shortcuts :as sc]
|
[app.main.data.workspace.path.shortcuts :as sc]
|
||||||
[app.main.store :as st]
|
[app.main.store :as st]
|
||||||
[app.main.ui.icons :as deprecated-icon]
|
[app.main.ui.icons :as deprecated-icon]
|
||||||
[app.util.i18n :as i18n :refer [tr]]
|
[app.util.i18n :as i18n :refer [tr]]
|
||||||
|
[app.util.timers :as ts]
|
||||||
[rumext.v2 :as mf]))
|
[rumext.v2 :as mf]))
|
||||||
|
|
||||||
(def ^:private pentool-icon
|
(def ^:private pentool-icon
|
||||||
@ -21,12 +22,6 @@
|
|||||||
(def ^:private move-icon
|
(def ^:private move-icon
|
||||||
(deprecated-icon/icon-xref :move (stl/css :move-icon :pathbar-icon)))
|
(deprecated-icon/icon-xref :move (stl/css :move-icon :pathbar-icon)))
|
||||||
|
|
||||||
(def ^:private add-icon
|
|
||||||
(deprecated-icon/icon-xref :add (stl/css :add-icon :pathbar-icon)))
|
|
||||||
|
|
||||||
(def ^:private remove-icon
|
|
||||||
(deprecated-icon/icon-xref :remove (stl/css :remove :pathbar-icon)))
|
|
||||||
|
|
||||||
(def ^:private merge-nodes-icon
|
(def ^:private merge-nodes-icon
|
||||||
(deprecated-icon/icon-xref :merge-nodes (stl/css :merge-nodes-icon :pathbar-icon)))
|
(deprecated-icon/icon-xref :merge-nodes (stl/css :merge-nodes-icon :pathbar-icon)))
|
||||||
|
|
||||||
@ -42,40 +37,152 @@
|
|||||||
(def ^:private to-curve-icon
|
(def ^:private to-curve-icon
|
||||||
(deprecated-icon/icon-xref :to-curve (stl/css :to-curve-icon :pathbar-icon)))
|
(deprecated-icon/icon-xref :to-curve (stl/css :to-curve-icon :pathbar-icon)))
|
||||||
|
|
||||||
(def ^:private snap-nodes-icon
|
(def ^:private snap-icon
|
||||||
(deprecated-icon/icon-xref :snap-nodes (stl/css :snap-nodes-icon :pathbar-icon)))
|
(deprecated-icon/icon-xref :snap (stl/css :snap-icon :pathbar-icon)))
|
||||||
|
|
||||||
(defn check-enabled [content selected-points]
|
;; Handler behavior icons: mirror, aligned, and independent.
|
||||||
(when content
|
(def ^:private handler-mirror-icon
|
||||||
(let [segments (path.segm/get-segments-with-points content selected-points)
|
(deprecated-icon/icon-xref :handlers-equal (stl/css :pathbar-icon)))
|
||||||
num-segments (count segments)
|
|
||||||
num-points (count selected-points)
|
|
||||||
points-selected? (seq selected-points)
|
|
||||||
segments-selected? (seq segments)
|
|
||||||
;; max segments for n points is (n × (n -1)) / 2
|
|
||||||
max-segments (-> num-points
|
|
||||||
(* (- num-points 1))
|
|
||||||
(/ 2))
|
|
||||||
is-curve? (some #(path.segm/is-curve? content %) selected-points)]
|
|
||||||
|
|
||||||
{:make-corner (and points-selected? is-curve?)
|
(def ^:private handler-aligned-icon
|
||||||
:make-curve (and points-selected? (not is-curve?))
|
(deprecated-icon/icon-xref :handlers-mirror (stl/css :pathbar-icon)))
|
||||||
:add-node segments-selected?
|
|
||||||
:remove-node points-selected?
|
(def ^:private handler-independent-icon
|
||||||
:merge-nodes segments-selected?
|
(deprecated-icon/icon-xref :handlers-independent (stl/css :pathbar-icon)))
|
||||||
:join-nodes (and points-selected? (>= num-points 2) (< num-segments max-segments))
|
|
||||||
:separate-nodes segments-selected?})))
|
(defn- handler-type-icon [type]
|
||||||
|
(case type
|
||||||
|
:mirror handler-mirror-icon
|
||||||
|
:aligned handler-aligned-icon
|
||||||
|
:independent handler-independent-icon
|
||||||
|
;; Use the independent icon for mixed selections.
|
||||||
|
:mixed handler-independent-icon
|
||||||
|
handler-independent-icon))
|
||||||
|
|
||||||
|
(defn toolbar-group-visibility
|
||||||
|
[structural-visible? shape-visible? handler-visible?]
|
||||||
|
(let [shape-handler-visible? (or shape-visible? handler-visible?)]
|
||||||
|
{:shape-handler-visible? shape-handler-visible?
|
||||||
|
:node-groups-separator-visible? (and structural-visible? shape-handler-visible?)
|
||||||
|
:snap-separator-visible? (or structural-visible? shape-handler-visible?)}))
|
||||||
|
|
||||||
|
(mf/defc topbar-button*
|
||||||
|
"A path node action button."
|
||||||
|
{::mf/private true}
|
||||||
|
[{:keys [title on-click icon]}]
|
||||||
|
[:button {:class (stl/css :topbar-btn)
|
||||||
|
:title title
|
||||||
|
:on-click on-click}
|
||||||
|
icon])
|
||||||
|
|
||||||
|
(defn- cancel-timer!
|
||||||
|
[timer-ref*]
|
||||||
|
(when-let [timer (mf/ref-val timer-ref*)]
|
||||||
|
(ts/dispose! timer)
|
||||||
|
(mf/set-ref-val! timer-ref* nil)))
|
||||||
|
|
||||||
|
(mf/defc handler-type-menu*
|
||||||
|
"Sets the handler behavior of selected nodes."
|
||||||
|
{::mf/private true}
|
||||||
|
[{:keys [active-type on-select]}]
|
||||||
|
(let [open* (mf/use-state false)
|
||||||
|
open? (deref open*)
|
||||||
|
|
||||||
|
open-timer* (mf/use-ref nil)
|
||||||
|
close-timer* (mf/use-ref nil)
|
||||||
|
|
||||||
|
select
|
||||||
|
(mf/use-fn
|
||||||
|
(mf/deps on-select)
|
||||||
|
(fn [type]
|
||||||
|
(reset! open* false)
|
||||||
|
(on-select type)))
|
||||||
|
|
||||||
|
on-trigger-click
|
||||||
|
(mf/use-fn
|
||||||
|
(mf/deps select active-type)
|
||||||
|
(fn []
|
||||||
|
(case (path.helpers/handler-trigger-action active-type)
|
||||||
|
:open
|
||||||
|
(do
|
||||||
|
(cancel-timer! close-timer*)
|
||||||
|
(cancel-timer! open-timer*)
|
||||||
|
(reset! open* true))
|
||||||
|
|
||||||
|
:select
|
||||||
|
(select active-type))))
|
||||||
|
|
||||||
|
on-display-menu
|
||||||
|
(mf/use-fn
|
||||||
|
(fn []
|
||||||
|
(cancel-timer! close-timer*)
|
||||||
|
(cancel-timer! open-timer*)
|
||||||
|
(mf/set-ref-val!
|
||||||
|
open-timer*
|
||||||
|
(ts/schedule 350
|
||||||
|
#(do
|
||||||
|
(reset! open* true)
|
||||||
|
(mf/set-ref-val! open-timer* nil))))))
|
||||||
|
|
||||||
|
on-hide-menu
|
||||||
|
(mf/use-fn
|
||||||
|
(fn []
|
||||||
|
(cancel-timer! open-timer*)
|
||||||
|
(cancel-timer! close-timer*)
|
||||||
|
(mf/set-ref-val!
|
||||||
|
close-timer*
|
||||||
|
(ts/schedule 350
|
||||||
|
#(do
|
||||||
|
(reset! open* false)
|
||||||
|
(mf/set-ref-val! close-timer* nil))))))]
|
||||||
|
|
||||||
|
(mf/with-effect []
|
||||||
|
(fn []
|
||||||
|
(cancel-timer! open-timer*)
|
||||||
|
(cancel-timer! close-timer*)))
|
||||||
|
|
||||||
|
[:div {:class (stl/css :handler-menu)
|
||||||
|
:on-pointer-enter on-display-menu
|
||||||
|
:on-pointer-leave on-hide-menu}
|
||||||
|
[:button {:class (stl/css :topbar-btn :handler-trigger)
|
||||||
|
:title (tr "workspace.path.actions.handler-type")
|
||||||
|
:aria-haspopup true
|
||||||
|
:aria-expanded open?
|
||||||
|
:on-click on-trigger-click}
|
||||||
|
(handler-type-icon active-type)
|
||||||
|
[:svg {:view-box "0 0 6 6"
|
||||||
|
:aria-hidden true
|
||||||
|
:class (stl/css :flyout-indicator)}
|
||||||
|
[:path {:d "M4,2 L4,3.15 C4,3.62 3.62,4 3.15,4 L2,4"
|
||||||
|
:stroke-linecap "round"}]]]
|
||||||
|
[:div {:class (stl/css-case :handler-popover true :open open?)
|
||||||
|
:data-dont-clear-path true}
|
||||||
|
[:button {:class (stl/css-case :is-toggled (= active-type :mirror) :topbar-btn true)
|
||||||
|
:title (tr "workspace.path.actions.handler-mirror")
|
||||||
|
:on-click #(select :mirror)}
|
||||||
|
handler-mirror-icon]
|
||||||
|
[:button {:class (stl/css-case :is-toggled (= active-type :aligned) :topbar-btn true)
|
||||||
|
:title (tr "workspace.path.actions.handler-aligned")
|
||||||
|
:on-click #(select :aligned)}
|
||||||
|
handler-aligned-icon]
|
||||||
|
[:button {:class (stl/css-case :is-toggled (= active-type :independent) :topbar-btn true)
|
||||||
|
:title (tr "workspace.path.actions.handler-independent")
|
||||||
|
:on-click #(select :independent)}
|
||||||
|
handler-independent-icon]]]))
|
||||||
|
|
||||||
(mf/defc path-actions*
|
(mf/defc path-actions*
|
||||||
[{:keys [shape state]}]
|
[{:keys [shape state]}]
|
||||||
(let [{:keys [edit-mode selected-points snap-toggled]} state
|
(let [{:keys [edit-mode selection snap-toggled]} state
|
||||||
|
|
||||||
content (:content shape)
|
content (:content shape)
|
||||||
|
|
||||||
|
;; Include segment endpoints in node actions.
|
||||||
|
selected-nodes (path.helpers/selected-node-indices content selection)
|
||||||
|
|
||||||
enabled-buttons
|
enabled-buttons
|
||||||
(mf/use-memo
|
(mf/use-memo
|
||||||
(mf/deps content selected-points)
|
(mf/deps content selection)
|
||||||
#(check-enabled content selected-points))
|
#(path.helpers/check-enabled content selected-nodes))
|
||||||
|
|
||||||
on-select-draw-mode
|
on-select-draw-mode
|
||||||
(mf/use-fn
|
(mf/use-fn
|
||||||
@ -87,20 +194,6 @@
|
|||||||
(fn [_]
|
(fn [_]
|
||||||
(st/emit! (drp/change-edit-mode :move))))
|
(st/emit! (drp/change-edit-mode :move))))
|
||||||
|
|
||||||
on-add-node
|
|
||||||
(mf/use-fn
|
|
||||||
(mf/deps (:add-node enabled-buttons))
|
|
||||||
(fn [_]
|
|
||||||
(when (:add-node enabled-buttons)
|
|
||||||
(st/emit! (drp/add-node)))))
|
|
||||||
|
|
||||||
on-remove-node
|
|
||||||
(mf/use-fn
|
|
||||||
(mf/deps (:remove-node enabled-buttons))
|
|
||||||
(fn [_]
|
|
||||||
(when (:remove-node enabled-buttons)
|
|
||||||
(st/emit! (drp/remove-node)))))
|
|
||||||
|
|
||||||
on-merge-nodes
|
on-merge-nodes
|
||||||
(mf/use-fn
|
(mf/use-fn
|
||||||
(mf/deps (:merge-nodes enabled-buttons))
|
(mf/deps (:merge-nodes enabled-buttons))
|
||||||
@ -139,81 +232,93 @@
|
|||||||
on-toggle-snap
|
on-toggle-snap
|
||||||
(mf/use-fn
|
(mf/use-fn
|
||||||
(fn [_]
|
(fn [_]
|
||||||
(st/emit! (drp/toggle-snap))))]
|
(st/emit! (drp/toggle-snap))))
|
||||||
|
|
||||||
|
;; Show node actions only when they apply.
|
||||||
|
structural-visible? (or (:merge-nodes enabled-buttons)
|
||||||
|
(:join-nodes enabled-buttons)
|
||||||
|
(:separate-nodes enabled-buttons))
|
||||||
|
shape-visible? (or (:make-corner enabled-buttons)
|
||||||
|
(:make-curve enabled-buttons))
|
||||||
|
|
||||||
|
;; Resolve selected handlers to their curve nodes.
|
||||||
|
handler-nodes (path.helpers/handler-target-nodes content selection)
|
||||||
|
handler-state (path.helpers/handler-selection-state
|
||||||
|
content (:handler-types state) handler-nodes)
|
||||||
|
active-handler-type (:active-type handler-state)
|
||||||
|
handler-visible? (and (= edit-mode :move) (seq (:nodes handler-state)))
|
||||||
|
|
||||||
|
group-visibility
|
||||||
|
(toolbar-group-visibility structural-visible? shape-visible? handler-visible?)
|
||||||
|
|
||||||
|
node-groups-separator-visible?
|
||||||
|
(:node-groups-separator-visible? group-visibility)
|
||||||
|
|
||||||
|
middle-visible?
|
||||||
|
(:snap-separator-visible? group-visibility)
|
||||||
|
|
||||||
|
on-set-handler-type
|
||||||
|
(mf/use-fn
|
||||||
|
(fn [type]
|
||||||
|
(st/emit! (drp/set-handler-type type))))]
|
||||||
|
|
||||||
[:div {:class (stl/css :sub-actions)
|
[:div {:class (stl/css :sub-actions)
|
||||||
:data-dont-clear-path true}
|
:data-dont-clear-path true}
|
||||||
|
;; Mode: draw / move (always visible)
|
||||||
[:div {:class (stl/css :sub-actions-group)}
|
[:div {:class (stl/css :sub-actions-group)}
|
||||||
|
[:button {:class (stl/css-case :is-toggled (= edit-mode :draw) :topbar-btn true)
|
||||||
;; Draw Mode
|
|
||||||
[:button {:class (stl/css-case :is-toggled (= edit-mode :draw)
|
|
||||||
:topbar-btn true)
|
|
||||||
:title (tr "workspace.path.actions.draw-nodes" (sc/get-tooltip :draw-nodes))
|
:title (tr "workspace.path.actions.draw-nodes" (sc/get-tooltip :draw-nodes))
|
||||||
:on-click on-select-draw-mode}
|
:on-click on-select-draw-mode}
|
||||||
pentool-icon]
|
pentool-icon]
|
||||||
|
[:button {:class (stl/css-case :is-toggled (= edit-mode :move) :topbar-btn true)
|
||||||
;; Edit mode
|
|
||||||
[:button {:class (stl/css-case :is-toggled (= edit-mode :move)
|
|
||||||
:topbar-btn true)
|
|
||||||
:title (tr "workspace.path.actions.move-nodes" (sc/get-tooltip :move-nodes))
|
:title (tr "workspace.path.actions.move-nodes" (sc/get-tooltip :move-nodes))
|
||||||
:on-click on-select-edit-mode}
|
:on-click on-select-edit-mode}
|
||||||
move-icon]]
|
move-icon]]
|
||||||
|
|
||||||
[:div {:class (stl/css :sub-actions-group)}
|
[:div {:class (stl/css :separator)}]
|
||||||
;; Add Node
|
|
||||||
[:button {:disabled (not (:add-node enabled-buttons))
|
|
||||||
:class (stl/css :topbar-btn)
|
|
||||||
:title (tr "workspace.path.actions.add-node" (sc/get-tooltip :add-node))
|
|
||||||
:on-click on-add-node}
|
|
||||||
add-icon]
|
|
||||||
|
|
||||||
;; Remove node
|
;; Structural node ops: merge / join / separate
|
||||||
[:button {:disabled (not (:remove-node enabled-buttons))
|
(when structural-visible?
|
||||||
:class (stl/css :topbar-btn)
|
[:div {:class (stl/css :sub-actions-group)}
|
||||||
:title (tr "workspace.path.actions.delete-node" (sc/get-tooltip :delete-node))
|
(when (:merge-nodes enabled-buttons)
|
||||||
:on-click on-remove-node}
|
[:> topbar-button* {:title (tr "workspace.path.actions.merge-nodes" (sc/get-tooltip :merge-nodes))
|
||||||
remove-icon]]
|
:on-click on-merge-nodes
|
||||||
|
:icon merge-nodes-icon}])
|
||||||
|
(when (:join-nodes enabled-buttons)
|
||||||
|
[:> topbar-button* {:title (tr "workspace.path.actions.join-nodes" (sc/get-tooltip :join-nodes))
|
||||||
|
:on-click on-join-nodes
|
||||||
|
:icon join-nodes-icon}])
|
||||||
|
(when (:separate-nodes enabled-buttons)
|
||||||
|
[:> topbar-button* {:title (tr "workspace.path.actions.separate-nodes" (sc/get-tooltip :separate-nodes))
|
||||||
|
:on-click on-separate-nodes
|
||||||
|
:icon separate-nodes-icon}])])
|
||||||
|
|
||||||
[:div {:class (stl/css :sub-actions-group)}
|
(when node-groups-separator-visible?
|
||||||
;; Merge Nodes
|
[:div {:class (stl/css :separator)}])
|
||||||
[:button {:disabled (not (:merge-nodes enabled-buttons))
|
|
||||||
:class (stl/css :topbar-btn)
|
|
||||||
:title (tr "workspace.path.actions.merge-nodes" (sc/get-tooltip :merge-nodes))
|
|
||||||
:on-click on-merge-nodes}
|
|
||||||
merge-nodes-icon]
|
|
||||||
|
|
||||||
;; Join Nodes
|
;; Node shape and handler-behaviour ops
|
||||||
[:button {:disabled (not (:join-nodes enabled-buttons))
|
(when shape-visible?
|
||||||
:class (stl/css :topbar-btn)
|
[:div {:class (stl/css :sub-actions-group)}
|
||||||
:title (tr "workspace.path.actions.join-nodes" (sc/get-tooltip :join-nodes))
|
(when (:make-corner enabled-buttons)
|
||||||
:on-click on-join-nodes}
|
[:> topbar-button* {:title (tr "workspace.path.actions.make-corner" (sc/get-tooltip :make-corner))
|
||||||
join-nodes-icon]
|
:on-click on-make-corner
|
||||||
|
:icon to-corner-icon}])
|
||||||
|
(when (:make-curve enabled-buttons)
|
||||||
|
[:> topbar-button* {:title (tr "workspace.path.actions.make-curve" (sc/get-tooltip :make-curve))
|
||||||
|
:on-click on-make-curve
|
||||||
|
:icon to-curve-icon}])])
|
||||||
|
|
||||||
;; Separate Nodes
|
;; Handler behaviour of the selected node(s)
|
||||||
[:button {:disabled (not (:separate-nodes enabled-buttons))
|
(when handler-visible?
|
||||||
:class (stl/css :topbar-btn)
|
[:> handler-type-menu* {:active-type active-handler-type
|
||||||
:title (tr "workspace.path.actions.separate-nodes" (sc/get-tooltip :separate-nodes))
|
:on-select on-set-handler-type}])
|
||||||
:on-click on-separate-nodes}
|
|
||||||
separate-nodes-icon]]
|
|
||||||
|
|
||||||
[:div {:class (stl/css :sub-actions-group)}
|
(when middle-visible?
|
||||||
; Make Corner
|
[:div {:class (stl/css :separator)}])
|
||||||
[:button {:disabled (not (:make-corner enabled-buttons))
|
|
||||||
:class (stl/css :topbar-btn)
|
|
||||||
:title (tr "workspace.path.actions.make-corner" (sc/get-tooltip :make-corner))
|
|
||||||
:on-click on-make-corner}
|
|
||||||
to-corner-icon]
|
|
||||||
|
|
||||||
;; Make Curve
|
;; Toggle snap (always visible, pinned to the right)
|
||||||
[:button {:disabled (not (:make-curve enabled-buttons))
|
[:div {:class (stl/css :sub-actions-group :snap-group)}
|
||||||
:class (stl/css :topbar-btn)
|
[:button {:class (stl/css-case :is-toggled snap-toggled :topbar-btn true)
|
||||||
:title (tr "workspace.path.actions.make-curve" (sc/get-tooltip :make-curve))
|
|
||||||
:on-click on-make-curve}
|
|
||||||
to-curve-icon]]
|
|
||||||
[:div {:class (stl/css :sub-actions-group)}
|
|
||||||
;; Toggle snap
|
|
||||||
[:button {:class (stl/css-case :is-toggled snap-toggled
|
|
||||||
:topbar-btn true)
|
|
||||||
:title (tr "workspace.path.actions.snap-nodes" (sc/get-tooltip :snap-nodes))
|
:title (tr "workspace.path.actions.snap-nodes" (sc/get-tooltip :snap-nodes))
|
||||||
:on-click on-toggle-snap}
|
:on-click on-toggle-snap}
|
||||||
snap-nodes-icon]]]))
|
snap-icon]]]))
|
||||||
|
|||||||
@ -5,22 +5,28 @@
|
|||||||
// Copyright (c) KALEIDOS INC Sucursal en España SL
|
// Copyright (c) KALEIDOS INC Sucursal en España SL
|
||||||
|
|
||||||
@use "refactor/common-refactor.scss" as deprecated;
|
@use "refactor/common-refactor.scss" as deprecated;
|
||||||
|
@use "ds/_borders.scss" as *;
|
||||||
|
@use "ds/_sizes.scss" as *;
|
||||||
|
|
||||||
.sub-actions {
|
.sub-actions {
|
||||||
cursor: initial;
|
cursor: initial;
|
||||||
pointer-events: initial;
|
pointer-events: initial;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: deprecated.$s-12;
|
top: var(--sp-xl);
|
||||||
left: 50%;
|
left: 50%;
|
||||||
transform: translateX(-50%);
|
transform: translateX(-50%);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
height: deprecated.$s-56;
|
justify-content: flex-start;
|
||||||
padding: deprecated.$s-8 deprecated.$s-16;
|
|
||||||
border-radius: deprecated.$s-8;
|
// Keep the bar stable as actions change.
|
||||||
gap: deprecated.$s-16;
|
width: $sz-430;
|
||||||
border: deprecated.$s-2 solid var(--panel-border-color);
|
height: $sz-48;
|
||||||
z-index: deprecated.$z-index-3;
|
padding: var(--sp-s);
|
||||||
|
border-radius: $br-8;
|
||||||
|
gap: var(--sp-s);
|
||||||
|
border: $b-2 solid var(--panel-border-color);
|
||||||
|
z-index: var(--z-index-panels);
|
||||||
background-color: var(--color-background-primary);
|
background-color: var(--color-background-primary);
|
||||||
transition:
|
transition:
|
||||||
top 0.3s,
|
top 0.3s,
|
||||||
@ -33,22 +39,88 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
opacity: deprecated.$op-10;
|
opacity: 1;
|
||||||
transition: opacity 0.3s ease;
|
transition: opacity 0.3s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Pin the snap toggle to the right.
|
||||||
|
.snap-group {
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.separator {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: $sz-1;
|
||||||
|
height: $sz-24;
|
||||||
|
margin: 0 var(--sp-xs);
|
||||||
|
background-color: var(--panel-border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.handler-menu {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handler behavior flyout trigger.
|
||||||
|
.handler-trigger {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flyout corner indicator.
|
||||||
|
.flyout-indicator {
|
||||||
|
position: absolute;
|
||||||
|
inset-block-end: var(--sp-xs);
|
||||||
|
inset-inline-end: var(--sp-xs);
|
||||||
|
inline-size: $sz-6;
|
||||||
|
block-size: $sz-6;
|
||||||
|
stroke: var(--pathbar-icon-color);
|
||||||
|
fill: none;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.handler-popover {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + var(--sp-s));
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: var(--sp-xs);
|
||||||
|
gap: var(--sp-xxs);
|
||||||
|
border-radius: $br-8;
|
||||||
|
border: $b-2 solid var(--panel-border-color);
|
||||||
|
background-color: var(--color-background-primary);
|
||||||
|
z-index: var(--z-index-dropdown);
|
||||||
|
|
||||||
|
// Hidden until the flyout opens.
|
||||||
|
opacity: 0;
|
||||||
|
visibility: hidden;
|
||||||
|
pointer-events: none;
|
||||||
|
transition:
|
||||||
|
opacity 80ms ease-out,
|
||||||
|
visibility 80ms linear;
|
||||||
|
|
||||||
|
&.open {
|
||||||
|
opacity: 1;
|
||||||
|
visibility: visible;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.topbar-btn {
|
.topbar-btn {
|
||||||
--pathbar-icon-color: var(--color-foreground-secondary);
|
--pathbar-icon-color: var(--color-foreground-secondary);
|
||||||
|
--button-tertiary-background-color-hover: var(--color-background-tertiary);
|
||||||
|
|
||||||
@extend %button-tertiary;
|
@extend %button-tertiary;
|
||||||
|
|
||||||
height: deprecated.$s-36;
|
height: $sz-32;
|
||||||
width: deprecated.$s-36;
|
width: $sz-32;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
border-radius: deprecated.$s-8;
|
border-radius: $br-8;
|
||||||
border: none;
|
border: none;
|
||||||
margin: 0 deprecated.$s-2;
|
margin: 0 var(--sp-xxs);
|
||||||
|
|
||||||
&.is-toggled {
|
&.is-toggled {
|
||||||
--pathbar-icon-color: var(--button-radio-foreground-color-active);
|
--pathbar-icon-color: var(--button-radio-foreground-color-active);
|
||||||
|
|||||||
39
frontend/src/app/main/ui/workspace/viewport/path_state.cljs
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
;; 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 Sucursal en España SL
|
||||||
|
|
||||||
|
(ns app.main.ui.workspace.viewport.path-state
|
||||||
|
(:require
|
||||||
|
[app.common.types.path :as path]
|
||||||
|
[app.main.data.workspace.path.state :as path.state]
|
||||||
|
[app.main.ui.workspace.viewport.drawarea :as drawarea]))
|
||||||
|
|
||||||
|
(defn derive-path-state
|
||||||
|
"Derives the shared path-editing view model used by classic and WASM viewports."
|
||||||
|
[edit-path edition drawing-tool drawing-object objects]
|
||||||
|
(let [edit-state (path.state/current-edit-state edit-path edition)
|
||||||
|
editing? (path.state/editing? edit-path edition)
|
||||||
|
drawing? (path.state/drawing? edit-state edition drawing-tool drawing-object)
|
||||||
|
editing-shape (when edition
|
||||||
|
(if editing?
|
||||||
|
drawing-object
|
||||||
|
(get objects edition)))
|
||||||
|
editing-shape (if editing?
|
||||||
|
(path/convert-to-path editing-shape objects)
|
||||||
|
editing-shape)
|
||||||
|
bar-state (or edit-state
|
||||||
|
(when drawing?
|
||||||
|
(get edit-path (get drawing-object :id))))
|
||||||
|
bar-shape (or editing-shape drawing-object)
|
||||||
|
drawing-shape (if (and editing? edition)
|
||||||
|
(drawarea/path-edit-shape drawing-object (get objects edition))
|
||||||
|
drawing-object)]
|
||||||
|
{:edit-state edit-state
|
||||||
|
:editing? editing?
|
||||||
|
:drawing? drawing?
|
||||||
|
:editing-shape editing-shape
|
||||||
|
:bar-state bar-state
|
||||||
|
:bar-shape bar-shape
|
||||||
|
:drawing-shape drawing-shape}))
|
||||||
@ -14,6 +14,8 @@
|
|||||||
[app.common.geom.shapes :as gsh]
|
[app.common.geom.shapes :as gsh]
|
||||||
[app.common.types.component :as ctk]
|
[app.common.types.component :as ctk]
|
||||||
[app.common.types.container :as ctn]
|
[app.common.types.container :as ctn]
|
||||||
|
[app.common.types.path :as path]
|
||||||
|
[app.common.types.path.helpers :as path.helpers]
|
||||||
[app.common.types.shape :as cts]
|
[app.common.types.shape :as cts]
|
||||||
[app.main.data.helpers :as dsh]
|
[app.main.data.helpers :as dsh]
|
||||||
[app.main.data.workspace :as dw]
|
[app.main.data.workspace :as dw]
|
||||||
@ -43,7 +45,7 @@
|
|||||||
|
|
||||||
(mf/defc selection-rect
|
(mf/defc selection-rect
|
||||||
{::mf/wrap-props false}
|
{::mf/wrap-props false}
|
||||||
[{:keys [transform rect zoom color on-move-selected on-context-menu]}]
|
[{:keys [transform rect zoom color on-move-selected on-context-menu hide-outline?]}]
|
||||||
(let [x (dm/get-prop rect :x)
|
(let [x (dm/get-prop rect :x)
|
||||||
y (dm/get-prop rect :y)
|
y (dm/get-prop rect :y)
|
||||||
width (dm/get-prop rect :width)
|
width (dm/get-prop rect :width)
|
||||||
@ -71,13 +73,15 @@
|
|||||||
:transform (str transform)
|
:transform (str transform)
|
||||||
:on-pointer-down on-move-selected
|
:on-pointer-down on-move-selected
|
||||||
:on-context-menu on-context-menu
|
:on-context-menu on-context-menu
|
||||||
:style {:stroke color
|
;; Keep hidden outlines draggable.
|
||||||
|
:pointer-events (when ^boolean hide-outline? "all")
|
||||||
|
:style {:stroke (if ^boolean hide-outline? "none" color)
|
||||||
:stroke-width (/ selection-rect-width zoom)
|
:stroke-width (/ selection-rect-width zoom)
|
||||||
:fill "none"}}]))
|
:fill "none"}}]))
|
||||||
|
|
||||||
(defn- calculate-handlers
|
(defn- calculate-handlers
|
||||||
"Calculates selection handlers for the current selection."
|
"Calculates resize and rotation handles for the selection."
|
||||||
[selection shape zoom]
|
[selection shape zoom only-rotation?]
|
||||||
(let [x (dm/get-prop selection :x)
|
(let [x (dm/get-prop selection :x)
|
||||||
y (dm/get-prop selection :y)
|
y (dm/get-prop selection :y)
|
||||||
width (dm/get-prop selection :width)
|
width (dm/get-prop selection :width)
|
||||||
@ -117,69 +121,72 @@
|
|||||||
:props #js {:cx x :cy (+ y height)}}]]
|
:props #js {:cx x :cy (+ y height)}}]]
|
||||||
|
|
||||||
|
|
||||||
(when-not ^boolean horizontal-line?
|
(if ^boolean only-rotation?
|
||||||
(array/conj! result
|
result
|
||||||
#js {:type :resize-side
|
(do
|
||||||
:position :top
|
(when-not ^boolean horizontal-line?
|
||||||
:props #js {:x (if ^boolean small-width?
|
(array/conj! result
|
||||||
(+ x (/ (- width threshold-small) 2))
|
#js {:type :resize-side
|
||||||
x)
|
:position :top
|
||||||
:y y
|
:props #js {:x (if ^boolean small-width?
|
||||||
:length (if ^boolean small-width?
|
(+ x (/ (- width threshold-small) 2))
|
||||||
threshold-small
|
x)
|
||||||
width)
|
:y y
|
||||||
:angle 0
|
:length (if ^boolean small-width?
|
||||||
:align align
|
threshold-small
|
||||||
:show-handler tiny-width?}}
|
width)
|
||||||
#js {:type :resize-side
|
:angle 0
|
||||||
:position :bottom
|
:align align
|
||||||
:props #js {:x (if ^boolean small-width?
|
:show-handler tiny-width?}}
|
||||||
(+ x (/ (+ width threshold-small) 2))
|
#js {:type :resize-side
|
||||||
(+ x width))
|
:position :bottom
|
||||||
:y (+ y height)
|
:props #js {:x (if ^boolean small-width?
|
||||||
:length (if small-width? threshold-small width)
|
(+ x (/ (+ width threshold-small) 2))
|
||||||
:angle 180
|
(+ x width))
|
||||||
:align align
|
:y (+ y height)
|
||||||
:show-handler tiny-width?}}))
|
:length (if small-width? threshold-small width)
|
||||||
|
:angle 180
|
||||||
|
:align align
|
||||||
|
:show-handler tiny-width?}}))
|
||||||
|
|
||||||
(when-not vertical-line?
|
(when-not vertical-line?
|
||||||
(array/conj! result
|
(array/conj! result
|
||||||
#js {:type :resize-side
|
#js {:type :resize-side
|
||||||
:position :right
|
:position :right
|
||||||
:props #js {:x (+ x width)
|
:props #js {:x (+ x width)
|
||||||
:y (if small-height? (+ y (/ (- height threshold-small) 2)) y)
|
:y (if small-height? (+ y (/ (- height threshold-small) 2)) y)
|
||||||
:length (if small-height? threshold-small height)
|
:length (if small-height? threshold-small height)
|
||||||
:angle 90
|
:angle 90
|
||||||
:align align
|
:align align
|
||||||
:show-handler tiny-height?}}
|
:show-handler tiny-height?}}
|
||||||
|
|
||||||
#js {:type :resize-side
|
#js {:type :resize-side
|
||||||
:position :left
|
:position :left
|
||||||
:props #js {:x x
|
:props #js {:x x
|
||||||
:y (if ^boolean small-height?
|
:y (if ^boolean small-height?
|
||||||
(+ y (/ (+ height threshold-small) 2))
|
(+ y (/ (+ height threshold-small) 2))
|
||||||
(+ y height))
|
(+ y height))
|
||||||
:length (if ^boolean small-height?
|
:length (if ^boolean small-height?
|
||||||
threshold-small
|
threshold-small
|
||||||
height)
|
height)
|
||||||
:angle 270
|
:angle 270
|
||||||
:align align
|
:align align
|
||||||
:show-handler tiny-height?}}))
|
:show-handler tiny-height?}}))
|
||||||
|
|
||||||
(when (and (not tiny-width?) (not tiny-height?))
|
(when (and (not tiny-width?) (not tiny-height?))
|
||||||
(array/conj! result
|
(array/conj! result
|
||||||
#js {:type :resize-point
|
#js {:type :resize-point
|
||||||
:position :top-left
|
:position :top-left
|
||||||
:props #js {:cx x :cy y :align align}}
|
:props #js {:cx x :cy y :align align}}
|
||||||
#js {:type :resize-point
|
#js {:type :resize-point
|
||||||
:position :top-right
|
:position :top-right
|
||||||
:props #js {:cx (+ x width) :cy y :align align}}
|
:props #js {:cx (+ x width) :cy y :align align}}
|
||||||
#js {:type :resize-point
|
#js {:type :resize-point
|
||||||
:position :bottom-right
|
:position :bottom-right
|
||||||
:props #js {:cx (+ x width) :cy (+ y height) :align align}}
|
:props #js {:cx (+ x width) :cy (+ y height) :align align}}
|
||||||
#js {:type :resize-point
|
#js {:type :resize-point
|
||||||
:position :bottom-left
|
:position :bottom-left
|
||||||
:props #js {:cx x :cy (+ y height) :align align}}))))
|
:props #js {:cx x :cy (+ y height) :align align}}))))))
|
||||||
|
|
||||||
(mf/defc rotation-handler
|
(mf/defc rotation-handler
|
||||||
{::mf/wrap-props false}
|
{::mf/wrap-props false}
|
||||||
@ -328,7 +335,7 @@
|
|||||||
:stroke-width 0}}]]))
|
:stroke-width 0}}]]))
|
||||||
|
|
||||||
(mf/defc controls-selection*
|
(mf/defc controls-selection*
|
||||||
[{:keys [shape zoom color on-move-selected on-context-menu disabled]}]
|
[{:keys [shape zoom color on-move-selected on-context-menu disabled hide-outline?]}]
|
||||||
(let [selrect-transform (mf/deref refs/workspace-selrect)
|
(let [selrect-transform (mf/deref refs/workspace-selrect)
|
||||||
transform-type (mf/deref refs/current-transform)
|
transform-type (mf/deref refs/current-transform)
|
||||||
[selrect transform] (dsh/get-selrect selrect-transform shape)]
|
[selrect transform] (dsh/get-selrect selrect-transform shape)]
|
||||||
@ -342,12 +349,13 @@
|
|||||||
:transform transform
|
:transform transform
|
||||||
:zoom zoom
|
:zoom zoom
|
||||||
:color color
|
:color color
|
||||||
|
:hide-outline? hide-outline?
|
||||||
:on-move-selected on-move-selected
|
:on-move-selected on-move-selected
|
||||||
:on-context-menu on-context-menu}]])))
|
:on-context-menu on-context-menu}]])))
|
||||||
|
|
||||||
(mf/defc controls-handlers*
|
(mf/defc controls-handlers*
|
||||||
{::mf/private true}
|
{::mf/private true}
|
||||||
[{:keys [shape zoom color on-resize on-rotate disabled]}]
|
[{:keys [shape zoom color on-resize on-rotate disabled only-rotation?]}]
|
||||||
(let [selrect-transform (mf/deref refs/workspace-selrect)
|
(let [selrect-transform (mf/deref refs/workspace-selrect)
|
||||||
transform-type (mf/deref refs/current-transform)
|
transform-type (mf/deref refs/current-transform)
|
||||||
|
|
||||||
@ -374,7 +382,7 @@
|
|||||||
(= transform-type :rotate))))
|
(= transform-type :rotate))))
|
||||||
|
|
||||||
[:g.controls {:pointer-events (if ^boolean disabled "none" "visible")}
|
[:g.controls {:pointer-events (if ^boolean disabled "none" "visible")}
|
||||||
(for [handler (calculate-handlers selrect shape zoom)]
|
(for [handler (calculate-handlers selrect shape zoom only-rotation?)]
|
||||||
(let [type (obj/get handler "type")
|
(let [type (obj/get handler "type")
|
||||||
position (obj/get handler "position")
|
position (obj/get handler "position")
|
||||||
props (obj/get handler "props")
|
props (obj/get handler "props")
|
||||||
@ -482,6 +490,46 @@
|
|||||||
:on-move-selected on-move-selected
|
:on-move-selected on-move-selected
|
||||||
:on-context-menu on-context-menu}]))
|
:on-context-menu on-context-menu}]))
|
||||||
|
|
||||||
|
(mf/defc line-handlers*
|
||||||
|
"Endpoint handles for a straight path."
|
||||||
|
{::mf/private true}
|
||||||
|
[{:keys [shape zoom color disabled]}]
|
||||||
|
(let [read-only? (mf/use-ctx ctx/workspace-read-only?)
|
||||||
|
transform-type (mf/deref refs/current-transform)
|
||||||
|
content (dm/get-prop shape :content)
|
||||||
|
p1 (path.helpers/segment->point (nth content 0))
|
||||||
|
p2 (path.helpers/segment->point (nth content 1))]
|
||||||
|
|
||||||
|
(when (and (not ^boolean read-only?)
|
||||||
|
(not (:blocked shape))
|
||||||
|
(not (or (= transform-type :move)
|
||||||
|
(= transform-type :rotate))))
|
||||||
|
[:g.controls {:pointer-events (if ^boolean disabled "none" "visible")}
|
||||||
|
(for [[index point] [[0 p1] [1 p2]]]
|
||||||
|
(let [x (dm/get-prop point :x)
|
||||||
|
y (dm/get-prop point :y)
|
||||||
|
|
||||||
|
on-pointer-down
|
||||||
|
(fn [event]
|
||||||
|
(when (dom/left-mouse? event)
|
||||||
|
(dom/stop-propagation event)
|
||||||
|
(st/emit! (dw/start-move-line-point shape index))))]
|
||||||
|
[:g.path-point {:key index}
|
||||||
|
[:circle {:cx x
|
||||||
|
:cy y
|
||||||
|
:r (/ resize-point-radius zoom)
|
||||||
|
:style {:stroke-width "1px"
|
||||||
|
:stroke color
|
||||||
|
:fill "var(--app-white)"
|
||||||
|
:vectorEffect "non-scaling-stroke"}}]
|
||||||
|
[:circle {:cx x
|
||||||
|
:cy y
|
||||||
|
:r (/ resize-point-circle-radius zoom)
|
||||||
|
:on-pointer-down on-pointer-down
|
||||||
|
:class (cur/get-static "pointer-node")
|
||||||
|
:style {:fill (if (dbg/enabled? :handlers) "red" "none")
|
||||||
|
:stroke-width 0}}]]))])))
|
||||||
|
|
||||||
(mf/defc single-handlers*
|
(mf/defc single-handlers*
|
||||||
{::mf/private true}
|
{::mf/private true}
|
||||||
[{:keys [shape zoom color disabled]}]
|
[{:keys [shape zoom color disabled]}]
|
||||||
@ -489,6 +537,9 @@
|
|||||||
grow-type (dm/get-prop shape :grow-type)
|
grow-type (dm/get-prop shape :grow-type)
|
||||||
shape-type (dm/get-prop shape :type)
|
shape-type (dm/get-prop shape :type)
|
||||||
|
|
||||||
|
line? (and (cfh/path-shape? shape)
|
||||||
|
(path/single-line? (dm/get-prop shape :content)))
|
||||||
|
|
||||||
on-resize
|
on-resize
|
||||||
(mf/use-fn
|
(mf/use-fn
|
||||||
(mf/deps shape-id shape grow-type shape-type)
|
(mf/deps shape-id shape grow-type shape-type)
|
||||||
@ -526,24 +577,44 @@
|
|||||||
(dom/stop-propagation event)
|
(dom/stop-propagation event)
|
||||||
(st/emit! (dw/start-rotate [shape])))))]
|
(st/emit! (dw/start-rotate [shape])))))]
|
||||||
|
|
||||||
[:> controls-handlers*
|
(if ^boolean line?
|
||||||
{:shape shape
|
[:g.line-controls
|
||||||
:zoom zoom
|
;; Use endpoint controls with corner rotation handles.
|
||||||
:color color
|
[:> controls-handlers*
|
||||||
:disabled disabled
|
{:shape shape
|
||||||
:on-rotate on-rotate
|
:zoom zoom
|
||||||
:on-resize on-resize}]))
|
:color color
|
||||||
|
:disabled disabled
|
||||||
|
:on-rotate on-rotate
|
||||||
|
:on-resize on-resize
|
||||||
|
:only-rotation? true}]
|
||||||
|
[:> line-handlers*
|
||||||
|
{:shape shape
|
||||||
|
:zoom zoom
|
||||||
|
:color color
|
||||||
|
:disabled disabled}]]
|
||||||
|
[:> controls-handlers*
|
||||||
|
{:shape shape
|
||||||
|
:zoom zoom
|
||||||
|
:color color
|
||||||
|
:disabled disabled
|
||||||
|
:on-rotate on-rotate
|
||||||
|
:on-resize on-resize}])))
|
||||||
|
|
||||||
(mf/defc single-selection*
|
(mf/defc single-selection*
|
||||||
{::mf/private true}
|
{::mf/private true}
|
||||||
[{:keys [shape zoom color disabled on-move-selected on-context-menu]}]
|
[{:keys [shape zoom color disabled on-move-selected on-context-menu]}]
|
||||||
[:> controls-selection*
|
(let [line? (and (cfh/path-shape? shape)
|
||||||
{:shape shape
|
(path/single-line? (dm/get-prop shape :content)))]
|
||||||
:zoom zoom
|
[:> controls-selection*
|
||||||
:color color
|
{:shape shape
|
||||||
:disabled disabled
|
:zoom zoom
|
||||||
:on-move-selected on-move-selected
|
:color color
|
||||||
:on-context-menu on-context-menu}])
|
:disabled disabled
|
||||||
|
;; Keep the line body draggable without an outline.
|
||||||
|
:hide-outline? line?
|
||||||
|
:on-move-selected on-move-selected
|
||||||
|
:on-context-menu on-context-menu}]))
|
||||||
|
|
||||||
(mf/defc area*
|
(mf/defc area*
|
||||||
[{:keys [shapes edition zoom disabled on-move-selected on-context-menu]}]
|
[{:keys [shapes edition zoom disabled on-move-selected on-context-menu]}]
|
||||||
|
|||||||
@ -56,3 +56,23 @@
|
|||||||
(mf/defc grid-edition-bar*
|
(mf/defc grid-edition-bar*
|
||||||
[{:keys [shape]}]
|
[{:keys [shape]}]
|
||||||
[:> grid-edition-actions* {:shape shape}])
|
[:> grid-edition-actions* {:shape shape}])
|
||||||
|
|
||||||
|
(mf/defc edition-bars*
|
||||||
|
[{:keys [layout
|
||||||
|
path-editing
|
||||||
|
path-drawing
|
||||||
|
path-state
|
||||||
|
path-shape
|
||||||
|
grid-editing
|
||||||
|
grid-shape
|
||||||
|
single-select]}]
|
||||||
|
[:*
|
||||||
|
(when (or (and ^boolean path-editing ^boolean single-select)
|
||||||
|
(and ^boolean path-drawing (some? path-state)))
|
||||||
|
[:> path-edition-bar* {:shape path-shape
|
||||||
|
:edit-path-state path-state
|
||||||
|
:layout layout}])
|
||||||
|
|
||||||
|
(when (and ^boolean grid-editing ^boolean single-select)
|
||||||
|
[:> grid-edition-bar* {:shape grid-shape}])])
|
||||||
|
|
||||||
|
|||||||
@ -29,6 +29,8 @@
|
|||||||
:create-ellipse (cur/get-static "create-ellipse")
|
:create-ellipse (cur/get-static "create-ellipse")
|
||||||
:pen (cur/get-static "pen")
|
:pen (cur/get-static "pen")
|
||||||
:pencil (cur/get-static "pencil")
|
:pencil (cur/get-static "pencil")
|
||||||
|
:draw-path (cur/get-static "draw")
|
||||||
|
:edit-path (cur/get-static "move")
|
||||||
:create-shape (cur/get-static "create-shape")
|
:create-shape (cur/get-static "create-shape")
|
||||||
:duplicate (cur/get-static "duplicate")
|
:duplicate (cur/get-static "duplicate")
|
||||||
:zoom (cur/get-static "zoom")
|
:zoom (cur/get-static "zoom")
|
||||||
|
|||||||
@ -17,6 +17,11 @@
|
|||||||
(defonce viewport-ref (atom nil))
|
(defonce viewport-ref (atom nil))
|
||||||
(defonce viewport-brect (atom nil))
|
(defonce viewport-brect (atom nil))
|
||||||
|
|
||||||
|
(defn capture-pointer
|
||||||
|
[event]
|
||||||
|
(when-let [viewport @viewport-ref]
|
||||||
|
(.setPointerCapture viewport (.-pointerId event))))
|
||||||
|
|
||||||
(defn- init-observer
|
(defn- init-observer
|
||||||
[node]
|
[node]
|
||||||
(let [on-change-bounds
|
(let [on-change-bounds
|
||||||
|
|||||||
@ -13,7 +13,6 @@
|
|||||||
[app.common.geom.shapes :as gsh]
|
[app.common.geom.shapes :as gsh]
|
||||||
[app.common.types.color :as clr]
|
[app.common.types.color :as clr]
|
||||||
[app.common.types.component :as ctk]
|
[app.common.types.component :as ctk]
|
||||||
[app.common.types.path :as path]
|
|
||||||
[app.common.types.shape :as cts]
|
[app.common.types.shape :as cts]
|
||||||
[app.common.types.shape.layout :as ctl]
|
[app.common.types.shape.layout :as ctl]
|
||||||
[app.main.data.modal :as modal]
|
[app.main.data.modal :as modal]
|
||||||
@ -44,6 +43,7 @@
|
|||||||
[app.main.ui.workspace.viewport.hooks :as hooks]
|
[app.main.ui.workspace.viewport.hooks :as hooks]
|
||||||
[app.main.ui.workspace.viewport.interactions :as interactions]
|
[app.main.ui.workspace.viewport.interactions :as interactions]
|
||||||
[app.main.ui.workspace.viewport.outline :as outline]
|
[app.main.ui.workspace.viewport.outline :as outline]
|
||||||
|
[app.main.ui.workspace.viewport.path-state :as path-state]
|
||||||
[app.main.ui.workspace.viewport.pixel-overlay :as pixel-overlay]
|
[app.main.ui.workspace.viewport.pixel-overlay :as pixel-overlay]
|
||||||
[app.main.ui.workspace.viewport.presence :as presence]
|
[app.main.ui.workspace.viewport.presence :as presence]
|
||||||
[app.main.ui.workspace.viewport.rulers :as rulers]
|
[app.main.ui.workspace.viewport.rulers :as rulers]
|
||||||
@ -51,8 +51,7 @@
|
|||||||
[app.main.ui.workspace.viewport.selection :as selection]
|
[app.main.ui.workspace.viewport.selection :as selection]
|
||||||
[app.main.ui.workspace.viewport.snap-distances :as snap-distances]
|
[app.main.ui.workspace.viewport.snap-distances :as snap-distances]
|
||||||
[app.main.ui.workspace.viewport.snap-points :as snap-points]
|
[app.main.ui.workspace.viewport.snap-points :as snap-points]
|
||||||
[app.main.ui.workspace.viewport.top-bar :refer [grid-edition-bar*
|
[app.main.ui.workspace.viewport.top-bar :refer [edition-bars*
|
||||||
path-edition-bar*
|
|
||||||
view-only-bar*]]
|
view-only-bar*]]
|
||||||
[app.main.ui.workspace.viewport.utils :as utils]
|
[app.main.ui.workspace.viewport.utils :as utils]
|
||||||
[app.main.ui.workspace.viewport.viewport-ref :as vp-ref :refer [create-viewport-ref]]
|
[app.main.ui.workspace.viewport.viewport-ref :as vp-ref :refer [create-viewport-ref]]
|
||||||
@ -282,21 +281,22 @@
|
|||||||
;; Only when we have all the selected shapes in one frame
|
;; Only when we have all the selected shapes in one frame
|
||||||
selected-frame (when (= (count selected-frames) 1) (get base-objects (first selected-frames)))
|
selected-frame (when (= (count selected-frames) 1) (get base-objects (first selected-frames)))
|
||||||
|
|
||||||
edit-path-state (get edit-path edition)
|
{:keys [edit-state
|
||||||
edit-path-mode (get edit-path-state :edit-mode)
|
editing?
|
||||||
|
drawing?
|
||||||
|
editing-shape
|
||||||
|
bar-state
|
||||||
|
bar-shape
|
||||||
|
drawing-shape]}
|
||||||
|
(mf/with-memo [edit-path edition drawing-tool drawing-obj base-objects]
|
||||||
|
(path-state/derive-path-state edit-path edition drawing-tool drawing-obj base-objects))
|
||||||
|
|
||||||
path-editing? (some? edit-path-state)
|
edit-path-state edit-state
|
||||||
path-drawing? (or (= edit-path-mode :draw)
|
path-editing? editing?
|
||||||
(and (= :path (get drawing-obj :type))
|
path-drawing? drawing?
|
||||||
(not= :curve drawing-tool)))
|
path-bar-state bar-state
|
||||||
|
path-bar-shape bar-shape
|
||||||
editing-shape (when edition
|
draw-area-shape drawing-shape
|
||||||
(get base-objects edition))
|
|
||||||
|
|
||||||
editing-shape (mf/with-memo [editing-shape path-editing? base-objects]
|
|
||||||
(if path-editing?
|
|
||||||
(path/convert-to-path editing-shape base-objects)
|
|
||||||
editing-shape))
|
|
||||||
|
|
||||||
create-comment? (= :comments drawing-tool)
|
create-comment? (= :comments drawing-tool)
|
||||||
|
|
||||||
@ -372,8 +372,9 @@
|
|||||||
show-snap-points? (and (or (contains? layout :dynamic-alignment)
|
show-snap-points? (and (or (contains? layout :dynamic-alignment)
|
||||||
(contains? layout :snap-guides))
|
(contains? layout :snap-guides))
|
||||||
(or drawing-obj transform)
|
(or drawing-obj transform)
|
||||||
|
(not path-editing?)
|
||||||
(not page-transition?))
|
(not page-transition?))
|
||||||
show-selrect? (and selrect (empty? drawing) (not text-editing?) (not page-transition?))
|
show-selrect? (and selrect (or (empty? drawing) path-editing?) (not text-editing?) (not page-transition?))
|
||||||
show-measures? (and (not transform)
|
show-measures? (and (not transform)
|
||||||
(not path-editing?)
|
(not path-editing?)
|
||||||
(or show-distances? mode-inspect? read-only?)
|
(or show-distances? mode-inspect? read-only?)
|
||||||
@ -629,13 +630,24 @@
|
|||||||
|
|
||||||
(hooks/setup-dom-events zoom disable-paste-ref in-viewport-ref read-only? drawing-tool path-drawing?)
|
(hooks/setup-dom-events zoom disable-paste-ref in-viewport-ref read-only? drawing-tool path-drawing?)
|
||||||
(hooks/setup-viewport-size vport viewport-ref)
|
(hooks/setup-viewport-size vport viewport-ref)
|
||||||
(hooks/setup-cursor cursor alt? mod? space? panning drawing-tool path-drawing? path-editing? z? read-only?)
|
(hooks/setup-cursor cursor alt? mod? space? panning drawing-tool path-drawing? path-editing? (get path-bar-state :drag-cursor) z? read-only?)
|
||||||
(hooks/setup-keyboard alt? mod? space? z? shift?)
|
(hooks/setup-keyboard alt? mod? space? z? shift?)
|
||||||
(hooks/setup-hover-shapes page-id move-stream base-objects selected mod? hover measure-hover
|
(hooks/setup-hover-shapes page-id move-stream base-objects selected mod? hover measure-hover
|
||||||
hover-ids hover-top-frame-id @hover-disabled? focus zoom show-measures? read-only? transform)
|
hover-ids hover-top-frame-id @hover-disabled? focus zoom show-measures? read-only? transform)
|
||||||
(hooks/setup-shortcuts path-editing? path-drawing? text-editing? grid-editing?)
|
(hooks/setup-shortcuts path-editing? path-drawing? text-editing? grid-editing?)
|
||||||
(hooks/setup-active-frames base-objects hover-ids selected active-frames zoom transform vbox)
|
(hooks/setup-active-frames base-objects hover-ids selected active-frames zoom transform vbox)
|
||||||
|
|
||||||
|
(mf/with-effect [path-editing? edition @initialized?]
|
||||||
|
(when (and path-editing? edition @initialized?)
|
||||||
|
(wasm.api/use-shape edition)
|
||||||
|
(wasm.api/set-shape-hidden true)
|
||||||
|
(wasm.api/request-render "start-path-edition")
|
||||||
|
(fn []
|
||||||
|
(when (wasm.api/initialized?)
|
||||||
|
(wasm.api/use-shape edition)
|
||||||
|
(wasm.api/set-shape-hidden false)
|
||||||
|
(wasm.api/request-render "stop-path-edition")))))
|
||||||
|
|
||||||
[:div {:class (stl/css :viewport) :style #js {"--zoom" zoom} :data-testid "viewport"}
|
[:div {:class (stl/css :viewport) :style #js {"--zoom" zoom} :data-testid "viewport"}
|
||||||
|
|
||||||
(cond
|
(cond
|
||||||
@ -650,15 +662,14 @@
|
|||||||
(when-not hide-ui?
|
(when-not hide-ui?
|
||||||
[:> top-toolbar* {:layout layout}])
|
[:> top-toolbar* {:layout layout}])
|
||||||
|
|
||||||
(when (and ^boolean path-editing?
|
[:> edition-bars* {:layout layout
|
||||||
^boolean single-select?)
|
:path-editing path-editing?
|
||||||
[:> path-edition-bar* {:shape editing-shape
|
:path-drawing path-drawing?
|
||||||
:edit-path-state edit-path-state
|
:path-state path-bar-state
|
||||||
:layout layout}])
|
:path-shape path-bar-shape
|
||||||
|
:grid-editing grid-editing?
|
||||||
(when (and ^boolean grid-editing?
|
:grid-shape editing-shape
|
||||||
^boolean single-select?)
|
:single-select single-select?}]])
|
||||||
[:> grid-edition-bar* {:shape editing-shape}])])
|
|
||||||
|
|
||||||
[:div {:class (stl/css :viewport-overlays)}
|
[:div {:class (stl/css :viewport-overlays)}
|
||||||
(when show-comments?
|
(when show-comments?
|
||||||
@ -863,7 +874,7 @@
|
|||||||
(when (and ^boolean show-draw-area?
|
(when (and ^boolean show-draw-area?
|
||||||
^boolean (cts/shape? drawing-obj))
|
^boolean (cts/shape? drawing-obj))
|
||||||
[:> drawarea/draw-area*
|
[:> drawarea/draw-area*
|
||||||
{:shape drawing-obj
|
{:shape draw-area-shape
|
||||||
:zoom zoom
|
:zoom zoom
|
||||||
:tool drawing-tool}])
|
:tool drawing-tool}])
|
||||||
|
|
||||||
|
|||||||
120
frontend/test/frontend_tests/logic/path_actions_test.cljs
Normal file
@ -0,0 +1,120 @@
|
|||||||
|
;; 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 Sucursal en España SL
|
||||||
|
|
||||||
|
(ns frontend-tests.logic.path-actions-test
|
||||||
|
(:require
|
||||||
|
[app.common.geom.point :as gpt]
|
||||||
|
[app.common.types.path :as path]
|
||||||
|
[app.main.data.workspace.path.helpers :as path.helpers]
|
||||||
|
[app.main.ui.workspace.viewport.path-actions :as path.actions]
|
||||||
|
[cljs.test :as t :include-macros true]
|
||||||
|
[frontend-tests.logic.path-test-helpers :as pth]))
|
||||||
|
|
||||||
|
(t/deftest mixed-corner-and-curve-selection-enables-both-conversions
|
||||||
|
(let [content (pth/mixed-corner-curve-content)
|
||||||
|
points (path/get-points content)
|
||||||
|
enabled (path.helpers/check-enabled content #{0 1})]
|
||||||
|
(t/is (false? (path/is-curve-point? content (first points))))
|
||||||
|
(t/is (true? (path/is-curve-point? content (second points))))
|
||||||
|
(t/is (true? (:make-corner enabled)))
|
||||||
|
(t/is (true? (:make-curve enabled)))))
|
||||||
|
|
||||||
|
(t/deftest action-eligibility-keeps-coincident-node-identities
|
||||||
|
(let [content (path/content
|
||||||
|
[{:command :move-to :params {:x 0 :y 0}}
|
||||||
|
{:command :line-to :params {:x 10 :y 0}}
|
||||||
|
{:command :move-to :params {:x 0 :y 0}}
|
||||||
|
{:command :curve-to
|
||||||
|
:params {:c1x 2 :c1y 2 :c2x 8 :c2y 2 :x 10 :y 0}}])
|
||||||
|
enabled (path.helpers/check-enabled content #{0 2})]
|
||||||
|
(t/is (true? (:make-corner enabled)))
|
||||||
|
(t/is (true? (:make-curve enabled)))
|
||||||
|
(t/is (true? (:merge-nodes enabled)))
|
||||||
|
(t/is (true? (:join-nodes enabled)))))
|
||||||
|
|
||||||
|
(t/deftest toolbar-separators-only-render-between-visible-tool-groups
|
||||||
|
(t/are [structural? shape? handler? expected]
|
||||||
|
(= expected
|
||||||
|
(path.actions/toolbar-group-visibility structural? shape? handler?))
|
||||||
|
false false false
|
||||||
|
{:shape-handler-visible? false
|
||||||
|
:node-groups-separator-visible? false
|
||||||
|
:snap-separator-visible? false}
|
||||||
|
|
||||||
|
true false false
|
||||||
|
{:shape-handler-visible? false
|
||||||
|
:node-groups-separator-visible? false
|
||||||
|
:snap-separator-visible? true}
|
||||||
|
|
||||||
|
false true false
|
||||||
|
{:shape-handler-visible? true
|
||||||
|
:node-groups-separator-visible? false
|
||||||
|
:snap-separator-visible? true}
|
||||||
|
|
||||||
|
true true false
|
||||||
|
{:shape-handler-visible? true
|
||||||
|
:node-groups-separator-visible? true
|
||||||
|
:snap-separator-visible? true}
|
||||||
|
|
||||||
|
true false true
|
||||||
|
{:shape-handler-visible? true
|
||||||
|
:node-groups-separator-visible? true
|
||||||
|
:snap-separator-visible? true}))
|
||||||
|
|
||||||
|
(t/deftest handler-toolbar-represents-equal-and-mixed-multi-node-modes
|
||||||
|
(let [content (path/content
|
||||||
|
[{:command :move-to :params {:x 0 :y 0}}
|
||||||
|
{:command :curve-to
|
||||||
|
:params {:c1x 2 :c1y 0 :c2x 8 :c2y 0 :x 10 :y 0}}
|
||||||
|
{:command :curve-to
|
||||||
|
:params {:c1x 12 :c1y 0 :c2x 18 :c2y 0 :x 20 :y 0}}
|
||||||
|
{:command :curve-to
|
||||||
|
:params {:c1x 22 :c1y 0 :c2x 28 :c2y 0 :x 30 :y 0}}])
|
||||||
|
nodes #{1 2}]
|
||||||
|
;; Matching nodes share one active mode.
|
||||||
|
(t/is (= {:nodes #{1 2} :active-type :mirror}
|
||||||
|
(path.helpers/handler-selection-state content {} nodes)))
|
||||||
|
;; Stored mixed modes return `:mixed`.
|
||||||
|
(t/is (= {:nodes #{1 2} :active-type :mixed}
|
||||||
|
(path.helpers/handler-selection-state content {2 :aligned} nodes)))
|
||||||
|
(t/is (= {:nodes #{1 2} :active-type :aligned}
|
||||||
|
(path.helpers/handler-selection-state
|
||||||
|
content {1 :aligned 2 :aligned} nodes)))
|
||||||
|
(t/is (= :open (path.helpers/handler-trigger-action :mixed)))
|
||||||
|
(t/is (= :select (path.helpers/handler-trigger-action :mirror)))))
|
||||||
|
|
||||||
|
(t/deftest handler-toolbar-detects-derived-independent-and-mirror-targets
|
||||||
|
(let [content (path/content
|
||||||
|
[{:command :move-to :params {:x 0 :y 0}}
|
||||||
|
{:command :curve-to
|
||||||
|
:params {:c1x 2 :c1y 0 :c2x 8 :c2y 0 :x 10 :y 0}}
|
||||||
|
{:command :curve-to
|
||||||
|
:params {:c1x 12 :c1y 0 :c2x 18 :c2y 5 :x 20 :y 0}}])
|
||||||
|
node-targets #{1 2}
|
||||||
|
handler-targets (path.helpers/handler-target-nodes
|
||||||
|
content
|
||||||
|
{:nodes #{}
|
||||||
|
:segments #{}
|
||||||
|
:handlers #{[1 :c2] [2 :c2]}})]
|
||||||
|
(t/is (= :mirror (path.helpers/derive-handler-type content 1)))
|
||||||
|
(t/is (= :independent (path.helpers/derive-handler-type content 2)))
|
||||||
|
(t/is (= {:nodes #{1 2} :active-type :mixed}
|
||||||
|
(path.helpers/handler-selection-state content {} node-targets)))
|
||||||
|
(t/is (= #{1 2} handler-targets))
|
||||||
|
(t/is (= {:nodes #{1 2} :active-type :mixed}
|
||||||
|
(path.helpers/handler-selection-state content {} handler-targets)))))
|
||||||
|
|
||||||
|
(t/deftest opposite-handler-target-matches-handler-mode
|
||||||
|
(let [node (gpt/point 10 0)
|
||||||
|
handler (gpt/point 14 3)
|
||||||
|
opposite (gpt/point 6 0)]
|
||||||
|
(t/is (= (gpt/point 6 -3)
|
||||||
|
(path.helpers/opposite-handler-target node handler opposite :mirror)))
|
||||||
|
(t/is (= 4
|
||||||
|
(gpt/distance
|
||||||
|
node
|
||||||
|
(path.helpers/opposite-handler-target node handler opposite :aligned))))))
|
||||||
|
|
||||||
204
frontend/test/frontend_tests/logic/path_clipboard_test.cljs
Normal file
@ -0,0 +1,204 @@
|
|||||||
|
;; 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 Sucursal en España SL
|
||||||
|
|
||||||
|
|
||||||
|
(ns frontend-tests.logic.path-clipboard-test
|
||||||
|
(:require
|
||||||
|
[app.common.geom.point :as gpt]
|
||||||
|
[app.common.geom.rect :as grc]
|
||||||
|
[app.common.test-helpers.files :as cthf]
|
||||||
|
[app.common.test-helpers.shapes :as cths]
|
||||||
|
[app.common.types.path :as path]
|
||||||
|
[app.main.data.workspace.path.clipboard :as path.clipboard]
|
||||||
|
[app.main.data.workspace.path.edition :as path.edition]
|
||||||
|
[app.main.data.workspace.path.helpers :as path.helpers]
|
||||||
|
[app.main.streams :as ms]
|
||||||
|
[beicon.v2.core :as rx]
|
||||||
|
[cljs.test :as t :include-macros true]
|
||||||
|
[clojure.set :as set]
|
||||||
|
[frontend-tests.helpers.state :as ths]
|
||||||
|
[frontend-tests.logic.path-test-helpers :as pth]
|
||||||
|
[potok.v2.core :as ptk]))
|
||||||
|
|
||||||
|
(t/deftest cut-selected-nodes-copies-then-removes
|
||||||
|
;; Cut emits copy followed by the regular delete action.
|
||||||
|
(let [id (random-uuid)
|
||||||
|
content (pth/selectable-path-content)
|
||||||
|
state (pth/selectable-path-state id content {:nodes #{1} :segments #{} :handlers #{}})
|
||||||
|
events (atom [])
|
||||||
|
_ (->> (ptk/watch (path.clipboard/cut-selected-nodes) state nil)
|
||||||
|
(rx/subs! #(swap! events conj %)))
|
||||||
|
del (atom [])
|
||||||
|
_ (->> (ptk/watch (second @events) state nil)
|
||||||
|
(rx/subs! #(swap! del conj %)))
|
||||||
|
state' (ptk/update (first @del) state)
|
||||||
|
nodes (count (path/get-points (get-in state' [:workspace-drawing :object :content])))]
|
||||||
|
;; two events emitted (copy, then the removal)
|
||||||
|
(t/is (= 2 (count @events)))
|
||||||
|
;; The removal leaves fewer than three nodes.
|
||||||
|
(t/is (< nodes 3))))
|
||||||
|
|
||||||
|
(t/deftest duplicate-selection-content-copies-nodes-and-segments
|
||||||
|
(let [content (path/content
|
||||||
|
[{:command :move-to :params {:x 0 :y 0}}
|
||||||
|
{:command :line-to :params {:x 10 :y 0}}
|
||||||
|
{:command :line-to :params {:x 20 :y 0}}])]
|
||||||
|
;; A lone node copies its incoming segment.
|
||||||
|
(let [{:keys [sub selected]}
|
||||||
|
(path.helpers/duplicate-selection-content
|
||||||
|
content {:nodes #{2} :segments #{}} (gpt/point 10 10))]
|
||||||
|
(t/is (= [[:move-to {:x 10 :y 0}]
|
||||||
|
[:line-to {:x 30 :y 10}]]
|
||||||
|
(mapv (juxt :command :params) sub)))
|
||||||
|
;; only the new endpoint (index 1) is selected, not the attach point
|
||||||
|
(t/is (= #{1} selected)))
|
||||||
|
;; Interior-node copies meet at one offset node.
|
||||||
|
(let [{:keys [sub selected]}
|
||||||
|
(path.helpers/duplicate-selection-content
|
||||||
|
content {:nodes #{1} :segments #{}} (gpt/point 10 10))]
|
||||||
|
(t/is (= [[:move-to {:x 0 :y 0}]
|
||||||
|
[:line-to {:x 20 :y 10}]
|
||||||
|
[:move-to {:x 20 :y 0}]
|
||||||
|
[:line-to {:x 20 :y 10}]]
|
||||||
|
(mapv (juxt :command :params) sub)))
|
||||||
|
(t/is (= #{1 3} selected)))
|
||||||
|
;; Segment copies select both offset endpoints.
|
||||||
|
(let [{:keys [sub selected]}
|
||||||
|
(path.helpers/duplicate-selection-content
|
||||||
|
content {:nodes #{} :segments #{1}} (gpt/point 10 10))]
|
||||||
|
(t/is (= [[:move-to {:x 10 :y 10}]
|
||||||
|
[:line-to {:x 20 :y 10}]]
|
||||||
|
(mapv (juxt :command :params) sub)))
|
||||||
|
(t/is (= #{0 1} selected)))))
|
||||||
|
|
||||||
|
(t/deftest duplicate-offset-stays-constant-in-screen-pixels
|
||||||
|
(t/is (= (gpt/point 10 10) (path.edition/duplicate-offset 1)))
|
||||||
|
(t/is (= (gpt/point 2.5 2.5) (path.edition/duplicate-offset 4)))
|
||||||
|
(t/is (= (gpt/point 20 20) (path.edition/duplicate-offset 0.5))))
|
||||||
|
|
||||||
|
(t/deftest splice-duplicated-appends-copies-and-selects-only-new-nodes
|
||||||
|
(let [id (random-uuid)
|
||||||
|
content (path/content
|
||||||
|
[{:command :move-to :params {:x 0 :y 0}}
|
||||||
|
{:command :line-to :params {:x 10 :y 0}}
|
||||||
|
{:command :line-to :params {:x 20 :y 0}}])
|
||||||
|
result (path.helpers/duplicate-selection-content
|
||||||
|
content {:nodes #{2} :segments #{}} (gpt/point 10 10))
|
||||||
|
state (pth/selectable-path-state id content {:nodes #{2} :segments #{} :handlers #{}})
|
||||||
|
state' (ptk/update (path.edition/splice-duplicated result) state)
|
||||||
|
content' (get-in state' [:workspace-drawing :object :content])]
|
||||||
|
;; Append the copy as a new subpath.
|
||||||
|
(t/is (= 5 (count content')))
|
||||||
|
;; Select only the new endpoint.
|
||||||
|
(t/is (= #{4}
|
||||||
|
(get-in state' [:workspace-local :edit-path id :selection :nodes])))))
|
||||||
|
|
||||||
|
(t/deftest pasting-path-content-splices-and-selects-new-nodes
|
||||||
|
(let [id (random-uuid)
|
||||||
|
content (pth/selectable-path-content)
|
||||||
|
state (pth/selectable-path-state id content
|
||||||
|
{:nodes #{1}
|
||||||
|
:segments #{}
|
||||||
|
:handlers #{}})
|
||||||
|
sub [{:command :move-to :params {:x 30 :y 30}}
|
||||||
|
{:command :line-to :params {:x 40 :y 30}}]
|
||||||
|
;; Center the pasted fragment at the pointer.
|
||||||
|
_ (rx/push! ms/mouse-position (gpt/point 100 100))
|
||||||
|
state' (ptk/update (path.clipboard/paste-content sub) state)
|
||||||
|
content' (vec (get-in state' [:workspace-drawing :object :content]))
|
||||||
|
pasted (subvec content' 3)]
|
||||||
|
(t/is (= (vec content) (subvec content' 0 3)))
|
||||||
|
(t/is (= {:x 95 :y 100} (select-keys (:params (first pasted)) [:x :y])))
|
||||||
|
(t/is (= {:x 105 :y 100} (select-keys (:params (second pasted)) [:x :y])))
|
||||||
|
(t/is (= {:nodes #{3 4}
|
||||||
|
:segments #{}
|
||||||
|
:handlers #{}}
|
||||||
|
(get-in state' [:workspace-local :edit-path id :selection])))))
|
||||||
|
|
||||||
|
(t/deftest pasting-over-identical-nodes-offsets-the-fragment
|
||||||
|
(let [id (random-uuid)
|
||||||
|
content (pth/selectable-path-content)
|
||||||
|
state (pth/selectable-path-state id content path.helpers/empty-selection)
|
||||||
|
;; Same coordinates as the existing segment between nodes 0 and 1
|
||||||
|
sub [{:command :move-to :params {:x 0 :y 0}}
|
||||||
|
{:command :curve-to :params {:c1x 2 :c1y 0
|
||||||
|
:c2x 8 :c2y 0
|
||||||
|
:x 10 :y 0}}]
|
||||||
|
;; Overlapping pasted nodes receive the collision offset.
|
||||||
|
_ (rx/push! ms/mouse-position (gpt/point 5 0))
|
||||||
|
state' (ptk/update (path.clipboard/paste-content sub) state)
|
||||||
|
content' (vec (get-in state' [:workspace-drawing :object :content]))
|
||||||
|
pasted (subvec content' 3)]
|
||||||
|
;; Pasted nodes do not overlap existing nodes.
|
||||||
|
(t/is (= {:x 10 :y 10} (select-keys (:params (first pasted)) [:x :y])))
|
||||||
|
(t/is (= {:x 20 :y 10} (select-keys (:params (second pasted)) [:x :y])))))
|
||||||
|
|
||||||
|
(t/deftest pasting-finds-a-free-offset-after-more-than-one-hundred-collisions
|
||||||
|
(let [id (random-uuid)
|
||||||
|
content (path/content
|
||||||
|
(into [{:command :move-to :params {:x 0 :y 0}}]
|
||||||
|
(map (fn [step]
|
||||||
|
{:command :line-to
|
||||||
|
:params {:x (* step 10) :y (* step 10)}}))
|
||||||
|
(range 1 101)))
|
||||||
|
state (pth/selectable-path-state id content path.helpers/empty-selection)
|
||||||
|
sub (path/content
|
||||||
|
[{:command :move-to :params {:x 0 :y 0}}
|
||||||
|
{:command :line-to :params {:x 1 :y 0}}])
|
||||||
|
_ (rx/push! ms/mouse-position nil)
|
||||||
|
state' (ptk/update (path.clipboard/paste-content sub) state)
|
||||||
|
content' (get-in state' [:workspace-drawing :object :content])
|
||||||
|
pasted (take-last 2 content')]
|
||||||
|
(t/is (= [{:x 1010 :y 1010} {:x 1011 :y 1010}]
|
||||||
|
(mapv #(select-keys (:params %) [:x :y]) pasted)))
|
||||||
|
(t/is (empty? (set/intersection
|
||||||
|
(set (path/get-points content))
|
||||||
|
(set (path/get-points pasted)))))))
|
||||||
|
|
||||||
|
(defn- page-paths
|
||||||
|
[state]
|
||||||
|
(->> (:objects (cthf/current-page (ths/get-file-from-state state)))
|
||||||
|
vals
|
||||||
|
(filter #(= :path (:type %)))))
|
||||||
|
|
||||||
|
(t/deftest pasting-path-nodes-outside-editor-creates-a-new-path-shape
|
||||||
|
(t/async
|
||||||
|
done
|
||||||
|
(let [file (pth/setup-rect-file)
|
||||||
|
store (ths/setup-store file)
|
||||||
|
content (path/content [{:command :move-to :params {:x 0 :y 0}}
|
||||||
|
{:command :line-to :params {:x 40 :y 0}}
|
||||||
|
{:command :line-to :params {:x 40 :y 40}}])
|
||||||
|
target (gpt/point 300 300)]
|
||||||
|
;; a new path shape is centred at the pointer position
|
||||||
|
(rx/push! ms/mouse-position target)
|
||||||
|
(ths/run-store
|
||||||
|
store done
|
||||||
|
[(path.clipboard/paste-nodes-as-shape content)]
|
||||||
|
(fn [new-state]
|
||||||
|
(let [paths (page-paths new-state)
|
||||||
|
pasted (first paths)]
|
||||||
|
(t/is (= 1 (count paths)))
|
||||||
|
(when pasted
|
||||||
|
(t/is (= target (grc/rect->center (:selrect pasted))))
|
||||||
|
(t/is (contains? (get-in new-state [:workspace-local :selected]) (:id pasted))))))))))
|
||||||
|
|
||||||
|
(t/deftest pasting-path-nodes-while-editing-does-not-create-a-shape
|
||||||
|
(t/async
|
||||||
|
done
|
||||||
|
(let [file (pth/setup-rect-file)
|
||||||
|
id (:id (cths/get-shape file :rect1))
|
||||||
|
store (ths/setup-store file)
|
||||||
|
content (path/content [{:command :move-to :params {:x 0 :y 0}}
|
||||||
|
{:command :line-to :params {:x 40 :y 0}}])
|
||||||
|
;; Outside-editor paste does nothing during path editing.
|
||||||
|
events (conj (pth/start-path-edition-events id)
|
||||||
|
(path.clipboard/paste-nodes-as-shape content))]
|
||||||
|
(rx/push! ms/mouse-position (gpt/point 300 300))
|
||||||
|
(ths/run-store
|
||||||
|
store done events
|
||||||
|
(fn [new-state]
|
||||||
|
(t/is (empty? (page-paths new-state))))))))
|
||||||
117
frontend/test/frontend_tests/logic/path_helpers_test.cljs
Normal file
@ -0,0 +1,117 @@
|
|||||||
|
;; 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 Sucursal en España SL
|
||||||
|
|
||||||
|
|
||||||
|
(ns frontend-tests.logic.path-helpers-test
|
||||||
|
(:require
|
||||||
|
[app.common.geom.point :as gpt]
|
||||||
|
[app.common.types.path :as path]
|
||||||
|
[app.main.data.workspace.path.helpers :as path.helpers]
|
||||||
|
[app.main.data.workspace.path.streams :as path.streams]
|
||||||
|
[app.main.store :as st]
|
||||||
|
[cljs.test :as t :include-macros true]
|
||||||
|
[frontend-tests.logic.path-test-helpers :as pth]))
|
||||||
|
|
||||||
|
(t/deftest to-pixel-snap-quantises-to-half-pixels-past-the-zoom-threshold
|
||||||
|
;; Pixel snapping uses half steps above 300% zoom.
|
||||||
|
(let [original @st/state
|
||||||
|
snap (fn [zoom p]
|
||||||
|
(reset! st/state {:workspace-layout #{:snap-pixel-grid}
|
||||||
|
:workspace-local {:zoom zoom}})
|
||||||
|
(path.streams/to-pixel-snap p))]
|
||||||
|
(try
|
||||||
|
;; at or below the threshold, snapping rounds to whole pixels
|
||||||
|
(t/is (= (gpt/point 100 100) (snap 1 (gpt/point 100.4 100.4))))
|
||||||
|
(t/is (= (gpt/point 100 100) (snap 3 (gpt/point 100.4 100.4))))
|
||||||
|
;; above 300% zoom it rounds to the nearest half pixel
|
||||||
|
(t/is (= (gpt/point 100.5 100.5) (snap 6 (gpt/point 100.4 100.4))))
|
||||||
|
;; with pixel snapping off the position passes through unchanged
|
||||||
|
(reset! st/state {:workspace-layout #{} :workspace-local {:zoom 6}})
|
||||||
|
(t/is (= (gpt/point 100.4 100.4)
|
||||||
|
(path.streams/to-pixel-snap (gpt/point 100.4 100.4))))
|
||||||
|
(finally
|
||||||
|
(reset! st/state original)))))
|
||||||
|
|
||||||
|
(t/deftest node-merge-snap-finds-the-closest-target-for-multiple-moving-points
|
||||||
|
(let [start-point (gpt/point 0 0)
|
||||||
|
selected-points #{start-point (gpt/point 100 0)}
|
||||||
|
points (into selected-points
|
||||||
|
[(gpt/point 8 14)
|
||||||
|
(gpt/point 111 10.5)
|
||||||
|
(gpt/point 500 500)])
|
||||||
|
snap-position (path.streams/make-node-merge-snap
|
||||||
|
start-point selected-points points 10)]
|
||||||
|
;; The closest merge target moves the full selection.
|
||||||
|
(t/is (= (gpt/point 11 10.5)
|
||||||
|
(snap-position (gpt/point 10 10))))
|
||||||
|
;; Missing merge targets return no snap delta.
|
||||||
|
(t/is (nil? (snap-position (gpt/point 300 300))))))
|
||||||
|
|
||||||
|
(t/deftest insertion-preview-reuses-precomputed-segment-midpoints
|
||||||
|
(let [content (path/content
|
||||||
|
[{:command :move-to :params {:x 0 :y 0}}
|
||||||
|
{:command :line-to :params {:x 10 :y 0}}
|
||||||
|
{:command :curve-to
|
||||||
|
:params {:c1x 10 :c1y 0
|
||||||
|
:c2x 20 :c2y 10
|
||||||
|
:x 20 :y 0}}
|
||||||
|
{:command :close-path :params {}}])
|
||||||
|
midpoints (path.helpers/insertion-mid-points content)
|
||||||
|
line-mid (first midpoints)
|
||||||
|
curve-mid (second midpoints)]
|
||||||
|
(t/is (= 2 (count midpoints)))
|
||||||
|
(t/is (= (gpt/point 5 0) line-mid))
|
||||||
|
(t/is (= {:from-p (gpt/point 0 0)
|
||||||
|
:to-p (gpt/point 10 0)
|
||||||
|
:t 0.5}
|
||||||
|
(meta line-mid)))
|
||||||
|
(t/is (= line-mid
|
||||||
|
(path.helpers/insertion-point
|
||||||
|
content (gpt/point 5.5 0) 1 false midpoints)))
|
||||||
|
(t/is (nil? (path.helpers/insertion-point
|
||||||
|
content (gpt/point 200 200) 1 false midpoints)))
|
||||||
|
;; Alt/insert-anywhere remains dynamic and ignores the midpoint cache.
|
||||||
|
(t/is (some? (path.helpers/insertion-point
|
||||||
|
content curve-mid 1 true [])))))
|
||||||
|
|
||||||
|
(t/deftest selected-node-indices-folds-segment-endpoints
|
||||||
|
(let [content (pth/selectable-path-content)]
|
||||||
|
;; segment index 1 connects nodes 0 and 1
|
||||||
|
(t/is (= #{0 1}
|
||||||
|
(path.helpers/selected-node-indices content {:nodes #{} :segments #{1}})))
|
||||||
|
;; explicit nodes and segment endpoints are unioned
|
||||||
|
(t/is (= #{0 1 2}
|
||||||
|
(path.helpers/selected-node-indices content {:nodes #{2} :segments #{1}})))))
|
||||||
|
|
||||||
|
(t/deftest remap-selection-follows-content-structure
|
||||||
|
(let [content (pth/selectable-path-content)
|
||||||
|
;; Same command layout: index 1 turned into a line-to
|
||||||
|
corner (path/content
|
||||||
|
(assoc (vec content) 1 {:command :line-to
|
||||||
|
:params {:x 10 :y 0}}))
|
||||||
|
;; Different layout: the middle node was removed
|
||||||
|
shorter (path/content
|
||||||
|
[{:command :move-to
|
||||||
|
:params {:x 0 :y 0}}
|
||||||
|
{:command :line-to
|
||||||
|
:params {:x 20 :y 0}}])
|
||||||
|
selection {:nodes #{1 2}
|
||||||
|
:segments #{2}
|
||||||
|
:handlers #{[1 :c1] [2 :c2]}}]
|
||||||
|
(t/is (= {:nodes #{1 2}
|
||||||
|
:segments #{2}
|
||||||
|
:handlers #{[2 :c2]}}
|
||||||
|
(path.helpers/remap-selection selection content corner)))
|
||||||
|
(t/is (= {:nodes #{1}
|
||||||
|
:segments #{}
|
||||||
|
:handlers #{}}
|
||||||
|
(path.helpers/remap-selection selection content shorter)))))
|
||||||
|
|
||||||
|
(t/deftest handlers-joined-detects-smooth-vs-corner-nodes
|
||||||
|
;; node (10,0): incoming [1 :c2]=(8,0), outgoing [2 :c1]
|
||||||
|
(t/is (path.helpers/handlers-joined? (pth/selectable-path-content) 2 :c1))
|
||||||
|
(t/is (not (path.helpers/handlers-joined? (pth/corner-path-content) 2 :c1))))
|
||||||
|
|
||||||
542
frontend/test/frontend_tests/logic/path_lifecycle_test.cljs
Normal file
@ -0,0 +1,542 @@
|
|||||||
|
;; 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 Sucursal en España SL
|
||||||
|
|
||||||
|
|
||||||
|
(ns frontend-tests.logic.path-lifecycle-test
|
||||||
|
(:require
|
||||||
|
[app.common.data.undo-stack :as u]
|
||||||
|
[app.common.geom.point :as gpt]
|
||||||
|
[app.common.test-helpers.files :as cthf]
|
||||||
|
[app.common.test-helpers.shapes :as cths]
|
||||||
|
[app.common.types.path :as path]
|
||||||
|
[app.main.data.workspace.drawing.common :as dwdc]
|
||||||
|
[app.main.data.workspace.edition :as dwe]
|
||||||
|
[app.main.data.workspace.path.changes :as path.changes]
|
||||||
|
[app.main.data.workspace.path.common :as path.common]
|
||||||
|
[app.main.data.workspace.path.drawing :as path.drawing]
|
||||||
|
[app.main.data.workspace.path.edition :as path.edition]
|
||||||
|
[app.main.data.workspace.path.helpers :as path.helpers]
|
||||||
|
[app.main.data.workspace.path.selection :as path.selection]
|
||||||
|
[app.main.data.workspace.path.shortcuts :as path.shortcuts]
|
||||||
|
[app.main.data.workspace.path.state :as path.state]
|
||||||
|
[app.main.data.workspace.path.streams :as path.streams]
|
||||||
|
[app.main.data.workspace.path.tools :as path.tools]
|
||||||
|
[app.main.data.workspace.path.undo :as path.undo]
|
||||||
|
[beicon.v2.core :as rx]
|
||||||
|
[cljs.test :as t :include-macros true]
|
||||||
|
[frontend-tests.helpers.state :as ths]
|
||||||
|
[frontend-tests.logic.path-test-helpers :as pth]
|
||||||
|
[potok.v2.core :as ptk]))
|
||||||
|
|
||||||
|
(t/deftest path-lifecycle-selectors-use-the-active-path
|
||||||
|
(let [id (random-uuid)
|
||||||
|
edit-state {:edit-mode :draw}
|
||||||
|
edit-path {id edit-state}
|
||||||
|
state {:workspace-local {:edition id
|
||||||
|
:edit-path edit-path}
|
||||||
|
:workspace-drawing {:object {:id id :type :path}}}]
|
||||||
|
(t/is (= edit-state (path.state/current-edit-state state)))
|
||||||
|
(t/is (= edit-state (path.state/current-edit-state edit-path id)))
|
||||||
|
(t/is (path.state/editing? state))
|
||||||
|
(t/is (path.state/editing? edit-path id))
|
||||||
|
(t/is (path.state/drawing? edit-state id :path {:id id :type :path}))))
|
||||||
|
|
||||||
|
(t/deftest path-drawing-selector-finds-new-paths
|
||||||
|
(let [id (random-uuid)
|
||||||
|
state {:workspace-local {:edition nil
|
||||||
|
:edit-path {id {}}}
|
||||||
|
:workspace-drawing {:tool :path
|
||||||
|
:object {:id id :type :path}}}]
|
||||||
|
(t/is (path.state/drawing? state))
|
||||||
|
(t/is (not (path.state/editing? state)))
|
||||||
|
(t/is (path.state/drawing? nil nil :path {:id id :type :path}))
|
||||||
|
(t/is (not (path.state/drawing? nil nil :curve {:id id :type :path})))))
|
||||||
|
|
||||||
|
(t/deftest clear-edition-mode-finishes-path-streams
|
||||||
|
(t/is (path.streams/finish-edition? (dwe/clear-edition-mode)))
|
||||||
|
(t/is (not (path.streams/finish-edition? :interrupt))))
|
||||||
|
|
||||||
|
(t/deftest clear-edition-mode-finishes-active-path-before-finalizing
|
||||||
|
(let [id (random-uuid)
|
||||||
|
event (dwe/clear-edition-mode)
|
||||||
|
state {:workspace-local {:edition id
|
||||||
|
:edit-path {id {:edit-mode :move}}}
|
||||||
|
:workspace-drawing {:object {:id id}}}
|
||||||
|
state' (ptk/update event state)
|
||||||
|
emissions (atom [])]
|
||||||
|
(->> (ptk/watch event state' nil)
|
||||||
|
(rx/subs! #(swap! emissions conj %)))
|
||||||
|
(t/is (nil? (get-in state' [:workspace-local :edition])))
|
||||||
|
(t/is (some? (get-in state' [:workspace-local :edit-path id])))
|
||||||
|
(t/is (= [::path.common/finish-path]
|
||||||
|
(mapv ptk/type @emissions)))))
|
||||||
|
|
||||||
|
(t/deftest clear-non-path-edition-does-not-emit-finish-path
|
||||||
|
(let [id (random-uuid)
|
||||||
|
event (dwe/clear-edition-mode)
|
||||||
|
state {:workspace-local {:edition id}}
|
||||||
|
state' (ptk/update event state)
|
||||||
|
emissions (atom [])]
|
||||||
|
(->> (ptk/watch event state' nil)
|
||||||
|
(rx/subs! #(swap! emissions conj %)))
|
||||||
|
(t/is (nil? (get-in state' [:workspace-local :edition])))
|
||||||
|
(t/is (empty? @emissions))))
|
||||||
|
|
||||||
|
(t/deftest restarting-draw-mode-finishes-pending-subpath
|
||||||
|
(let [file (pth/setup-rect-file)
|
||||||
|
shape (-> (cths/get-shape file :rect1)
|
||||||
|
(path/convert-to-path))
|
||||||
|
id (:id shape)
|
||||||
|
last-point (last (path/get-points (:content shape)))
|
||||||
|
state {:workspace-local
|
||||||
|
{:edition id
|
||||||
|
:edit-path
|
||||||
|
{id {:edit-mode :draw
|
||||||
|
:last-point last-point
|
||||||
|
:preview {:command :line-to
|
||||||
|
:params {:x 150 :y 150}}
|
||||||
|
:old-content (:content shape)}}}
|
||||||
|
:workspace-drawing {:object shape}}
|
||||||
|
stream (rx/subject)
|
||||||
|
emissions (atom [])]
|
||||||
|
(->> (ptk/watch (path.drawing/start-draw-mode*) state stream)
|
||||||
|
(rx/take 4)
|
||||||
|
(rx/subs! #(swap! emissions conj %)))
|
||||||
|
(rx/push! stream (ptk/data-event ::path.drawing/end-edition
|
||||||
|
{:restart? true}))
|
||||||
|
(t/is (= [::path.drawing/start-edition
|
||||||
|
::path.common/finish-path
|
||||||
|
::path.drawing/check-changed-content
|
||||||
|
::path.drawing/start-draw-mode*]
|
||||||
|
(mapv ptk/type @emissions)))
|
||||||
|
(let [state' (ptk/update (second @emissions) state)
|
||||||
|
state'' (ptk/update (path.drawing/preview-next-point
|
||||||
|
{:x 200 :y 200})
|
||||||
|
state')]
|
||||||
|
(t/is (nil? (get-in state' [:workspace-local :edit-path id :last-point])))
|
||||||
|
(t/is (nil? (get-in state' [:workspace-local :edit-path id :preview])))
|
||||||
|
(t/is (= :move-to
|
||||||
|
(get-in state'' [:workspace-local :edit-path id :preview :command]))))))
|
||||||
|
|
||||||
|
(t/deftest escape-does-not-restart-edited-path-draw-loop
|
||||||
|
(let [id (random-uuid)
|
||||||
|
state {:workspace-local
|
||||||
|
{:edition id
|
||||||
|
:edit-path {id {:edit-mode :draw}}}}
|
||||||
|
stream (rx/subject)
|
||||||
|
emissions (atom [])]
|
||||||
|
(t/is (path.drawing/restart-draw-loop? (path.common/finish-path)))
|
||||||
|
(->> (ptk/watch (path.drawing/start-draw-mode*) state stream)
|
||||||
|
(rx/subs! #(swap! emissions conj %)))
|
||||||
|
(rx/push! stream (ptk/data-event ::path.drawing/end-edition
|
||||||
|
{:restart? false}))
|
||||||
|
(t/is (= [::path.drawing/start-edition]
|
||||||
|
(mapv ptk/type @emissions)))))
|
||||||
|
|
||||||
|
(defn- run-handle-drawing-end
|
||||||
|
"Runs the draw-ending flow and passes its events to `callback`."
|
||||||
|
[restart? callback]
|
||||||
|
(let [state (pth/drawing-path-state)
|
||||||
|
stream (rx/subject)
|
||||||
|
emissions (atom [])]
|
||||||
|
(->> (ptk/watch (path.drawing/handle-drawing) state stream)
|
||||||
|
(rx/subs! #(swap! emissions conj %)))
|
||||||
|
(rx/push! stream (ptk/data-event ::path.drawing/end-edition
|
||||||
|
{:restart? restart?}))
|
||||||
|
;; Wait for the asynchronous drawing-end event.
|
||||||
|
(js/setTimeout
|
||||||
|
(fn []
|
||||||
|
(let [end-event (last @emissions)
|
||||||
|
end-emissions (atom [])]
|
||||||
|
(->> (ptk/watch end-event state stream)
|
||||||
|
(rx/subs! #(swap! end-emissions conj %)))
|
||||||
|
(callback @end-emissions))))))
|
||||||
|
|
||||||
|
(t/deftest escape-ending-new-path-draw-does-not-reenter-edition
|
||||||
|
(t/async
|
||||||
|
done
|
||||||
|
(run-handle-drawing-end
|
||||||
|
false
|
||||||
|
(fn [emissions]
|
||||||
|
(t/is (= [::path.drawing/close-drawn-loops
|
||||||
|
::path.drawing/setup-frame
|
||||||
|
::dwdc/handle-finish-drawing
|
||||||
|
::dwe/clear-edition-mode]
|
||||||
|
(mapv ptk/type emissions)))
|
||||||
|
(done)))))
|
||||||
|
|
||||||
|
(t/deftest finishing-new-path-draw-reenters-edition
|
||||||
|
(t/async
|
||||||
|
done
|
||||||
|
(run-handle-drawing-end
|
||||||
|
true
|
||||||
|
(fn [emissions]
|
||||||
|
(t/is (= [::path.common/finish-path
|
||||||
|
::path.drawing/close-drawn-loops
|
||||||
|
::path.drawing/setup-frame
|
||||||
|
::dwdc/handle-finish-drawing
|
||||||
|
::path.drawing/start-created-path-edition]
|
||||||
|
(mapv ptk/type emissions)))
|
||||||
|
(done)))))
|
||||||
|
|
||||||
|
(t/deftest escape-with-pending-segment-cancels-it-and-keeps-drawing
|
||||||
|
(let [id (random-uuid)
|
||||||
|
state {:workspace-local
|
||||||
|
{:edition id
|
||||||
|
:edit-path {id {:edit-mode :draw
|
||||||
|
:last-point (gpt/point 10 10)
|
||||||
|
:preview {:command :line-to
|
||||||
|
:params {:x 20 :y 20}}}}}}
|
||||||
|
emissions (atom [])]
|
||||||
|
(->> (ptk/watch (path.shortcuts/esc-pressed) state nil)
|
||||||
|
(rx/subs! #(swap! emissions conj %)))
|
||||||
|
(t/is (= [::path.common/cancel-pending-segment]
|
||||||
|
(mapv ptk/type @emissions)))
|
||||||
|
(let [state' (ptk/update (first @emissions) state)]
|
||||||
|
(t/is (nil? (get-in state' [:workspace-local :edit-path id :last-point])))
|
||||||
|
(t/is (nil? (get-in state' [:workspace-local :edit-path id :preview])))
|
||||||
|
(t/is (= :draw (get-in state' [:workspace-local :edit-path id :edit-mode]))))))
|
||||||
|
|
||||||
|
(t/deftest escape-while-creating-path-finishes-it-into-edition
|
||||||
|
(let [id (random-uuid)
|
||||||
|
state {:workspace-local
|
||||||
|
{:edit-path {id {:edit-mode :draw
|
||||||
|
:last-point (gpt/point 10 10)}}}
|
||||||
|
:workspace-drawing {:object {:id id :type :path}}}
|
||||||
|
emissions (atom [])]
|
||||||
|
(->> (ptk/watch (path.shortcuts/esc-pressed) state nil)
|
||||||
|
(rx/subs! #(swap! emissions conj %)))
|
||||||
|
;; Finishing creates the shape and clears its pending segment.
|
||||||
|
(t/is (= [::path.common/finish-path]
|
||||||
|
(mapv ptk/type @emissions)))))
|
||||||
|
|
||||||
|
(t/deftest escape-without-pending-segment-interrupts-edition
|
||||||
|
(let [id (random-uuid)
|
||||||
|
state {:workspace-local
|
||||||
|
{:edition id
|
||||||
|
:edit-path {id {:edit-mode :draw}}}}
|
||||||
|
emissions (atom [])]
|
||||||
|
(->> (ptk/watch (path.shortcuts/esc-pressed) state nil)
|
||||||
|
(rx/subs! #(swap! emissions conj %)))
|
||||||
|
(t/is (= [:interrupt] @emissions))))
|
||||||
|
|
||||||
|
(t/deftest editing-path-only-updates-drawing-copy
|
||||||
|
(t/async
|
||||||
|
done
|
||||||
|
(let [file (pth/setup-rect-file)
|
||||||
|
original-rect (cths/get-shape file :rect1)
|
||||||
|
id (:id original-rect)
|
||||||
|
delta (gpt/point 10 5)
|
||||||
|
store (ths/setup-store file)
|
||||||
|
events (conj (pth/start-path-edition-events id)
|
||||||
|
(pth/move-drawing-content delta))]
|
||||||
|
(ths/run-store
|
||||||
|
store done events
|
||||||
|
(fn [new-state]
|
||||||
|
(let [file' (ths/get-file-from-state new-state)
|
||||||
|
stored-shape (cths/get-shape file' :rect1)
|
||||||
|
drawing-copy (get-in new-state [:workspace-drawing :object])]
|
||||||
|
(t/is (= original-rect stored-shape))
|
||||||
|
(t/is (= :path (:type drawing-copy)))
|
||||||
|
(t/is (= (path/move-content
|
||||||
|
(:content (path/convert-to-path original-rect))
|
||||||
|
delta)
|
||||||
|
(:content drawing-copy)))))))))
|
||||||
|
|
||||||
|
(t/deftest unchanged-path-edition-preserves-simple-shape
|
||||||
|
(t/async
|
||||||
|
done
|
||||||
|
(let [file (pth/setup-rect-file)
|
||||||
|
original-rect (cths/get-shape file :rect1)
|
||||||
|
id (:id original-rect)
|
||||||
|
store (ths/setup-store file)
|
||||||
|
events (conj (pth/start-path-edition-events id) :interrupt)]
|
||||||
|
(ths/run-store
|
||||||
|
store done events
|
||||||
|
(fn [new-state]
|
||||||
|
(let [file' (ths/get-file-from-state new-state)
|
||||||
|
stored-shape (cths/get-shape file' :rect1)]
|
||||||
|
(t/is (= original-rect stored-shape))
|
||||||
|
(t/is (= :rect (:type stored-shape)))
|
||||||
|
(t/is (nil? (get-in new-state [:workspace-drawing :object])))))))))
|
||||||
|
|
||||||
|
(t/deftest changed-path-edition-is-persisted-when-finalized
|
||||||
|
(t/async
|
||||||
|
done
|
||||||
|
(let [file (pth/setup-rect-file)
|
||||||
|
original-rect (cths/get-shape file :rect1)
|
||||||
|
id (:id original-rect)
|
||||||
|
delta (gpt/point 10 5)
|
||||||
|
original-path (path/convert-to-path original-rect)
|
||||||
|
;; Persist the rectangle with an explicit close command.
|
||||||
|
changed-content (-> (:content original-path)
|
||||||
|
(path/move-content delta)
|
||||||
|
(path/close-loops))
|
||||||
|
expected-shape (-> original-path
|
||||||
|
(assoc :content changed-content)
|
||||||
|
(path/update-geometry))
|
||||||
|
store (ths/setup-store file)
|
||||||
|
events (into (pth/start-path-edition-events id)
|
||||||
|
[(pth/move-drawing-content delta)
|
||||||
|
:interrupt])]
|
||||||
|
(ths/run-store
|
||||||
|
store done events
|
||||||
|
(fn [new-state]
|
||||||
|
(let [file' (ths/get-file-from-state new-state)
|
||||||
|
stored-shape (cths/get-shape file' :rect1)]
|
||||||
|
(t/is (= expected-shape stored-shape))
|
||||||
|
(t/is (= :path (:type stored-shape)))
|
||||||
|
(t/is (nil? (get-in new-state [:workspace-drawing :object])))))))))
|
||||||
|
|
||||||
|
(t/deftest created-path-edition-cleans-drawing-state-on-exit
|
||||||
|
(t/async
|
||||||
|
done
|
||||||
|
(let [file (pth/setup-rect-file)
|
||||||
|
original-rect (cths/get-shape file :rect1)
|
||||||
|
id (:id original-rect)
|
||||||
|
store (ths/setup-store file)
|
||||||
|
events [(path.drawing/start-created-path-edition id)
|
||||||
|
:interrupt]]
|
||||||
|
(ths/run-store
|
||||||
|
store done events
|
||||||
|
(fn [new-state]
|
||||||
|
(let [file' (ths/get-file-from-state new-state)
|
||||||
|
stored-shape (cths/get-shape file' :rect1)]
|
||||||
|
(t/is (= original-rect stored-shape))
|
||||||
|
(t/is (nil? (get-in new-state [:workspace-local :edition])))
|
||||||
|
(t/is (nil? (get-in new-state [:workspace-local :edit-path id])))
|
||||||
|
(t/is (nil? (get-in new-state [:workspace-drawing :object])))))))))
|
||||||
|
|
||||||
|
(defn- with-dangling-subpath-start
|
||||||
|
[content]
|
||||||
|
(path/content (conj (vec content)
|
||||||
|
{:command :move-to
|
||||||
|
:params {:x 30 :y 40}})))
|
||||||
|
|
||||||
|
(t/deftest cancel-pending-segment-drops-dangling-subpath-start
|
||||||
|
(let [id (random-uuid)
|
||||||
|
content (pth/selectable-path-content)
|
||||||
|
state {:workspace-local
|
||||||
|
{:edition id
|
||||||
|
:edit-path {id {:edit-mode :draw
|
||||||
|
:last-point (gpt/point 30 40)
|
||||||
|
:preview {:command :line-to
|
||||||
|
:params {:x 50 :y 50}}}}}
|
||||||
|
:workspace-drawing
|
||||||
|
{:object {:id id
|
||||||
|
:type :path
|
||||||
|
:content (with-dangling-subpath-start content)}}}
|
||||||
|
state' (ptk/update (path.common/cancel-pending-segment) state)]
|
||||||
|
(t/is (= (vec content)
|
||||||
|
(vec (get-in state' [:workspace-drawing :object :content]))))
|
||||||
|
(t/is (nil? (get-in state' [:workspace-local :edit-path id :last-point])))
|
||||||
|
(t/is (nil? (get-in state' [:workspace-local :edit-path id :preview])))))
|
||||||
|
|
||||||
|
(t/deftest finish-path-drops-dangling-subpath-start
|
||||||
|
(let [id (random-uuid)
|
||||||
|
content (pth/selectable-path-content)
|
||||||
|
state {:workspace-local
|
||||||
|
{:edition id
|
||||||
|
:edit-path {id {:edit-mode :draw
|
||||||
|
:last-point (gpt/point 30 40)}}}
|
||||||
|
:workspace-drawing
|
||||||
|
{:object {:id id
|
||||||
|
:type :path
|
||||||
|
:content (with-dangling-subpath-start content)}}}
|
||||||
|
state' (ptk/update (path.common/finish-path) state)]
|
||||||
|
(t/is (= (vec (path/close-subpaths content))
|
||||||
|
(vec (get-in state' [:workspace-drawing :object :content]))))
|
||||||
|
(t/is (nil? (get-in state' [:workspace-local :edit-path id :last-point])))))
|
||||||
|
|
||||||
|
(t/deftest finalize-ignores-dangling-subpath-start
|
||||||
|
(let [file (pth/setup-rect-file)
|
||||||
|
rect (cths/get-shape file :rect1)
|
||||||
|
id (:id rect)
|
||||||
|
path-shape (path/convert-to-path rect)
|
||||||
|
old-content (:content path-shape)
|
||||||
|
state {:current-file-id (:id file)
|
||||||
|
:current-page-id (cthf/current-page-id file)
|
||||||
|
:files {(:id file) file}
|
||||||
|
:workspace-local
|
||||||
|
{:edition id
|
||||||
|
:edit-path {id {:old-content old-content}}}
|
||||||
|
:workspace-drawing
|
||||||
|
{:object (assoc path-shape
|
||||||
|
:content
|
||||||
|
(with-dangling-subpath-start old-content))}}
|
||||||
|
emissions (atom [])]
|
||||||
|
(->> (ptk/watch (path.changes/finalize-path-content id) state nil)
|
||||||
|
(rx/subs! #(swap! emissions conj %)))
|
||||||
|
(t/is (empty? @emissions))))
|
||||||
|
|
||||||
|
(t/deftest next-point-preview-is-suppressed-during-a-modifier-drag
|
||||||
|
(let [id (random-uuid)
|
||||||
|
content (path/content [{:command :move-to :params {:x 0 :y 0}}
|
||||||
|
{:command :line-to :params {:x 100 :y 0}}])
|
||||||
|
mk (fn [modifiers]
|
||||||
|
;; Draw mode keeps the path in the drawing object.
|
||||||
|
{:workspace-drawing {:object {:id id :type :path :content content}}
|
||||||
|
:workspace-local {:edition nil
|
||||||
|
:zoom 1
|
||||||
|
:edit-path {id {:edit-mode :draw
|
||||||
|
:last-point (gpt/point 100 0)
|
||||||
|
:content-modifiers modifiers}}}})
|
||||||
|
event (path.drawing/preview-next-point {:x 150 :y 40 :shift? false})
|
||||||
|
idle (ptk/update event (mk {}))
|
||||||
|
during (ptk/update event (mk {1 {:c1x 5 :c1y 5}}))]
|
||||||
|
;; no active drag: the next-point preview updates as usual
|
||||||
|
(t/is (some? (get-in idle [:workspace-local :edit-path id :preview])))
|
||||||
|
;; a placed handler is being dragged mid-draw: the preview must not move
|
||||||
|
(t/is (nil? (get-in during [:workspace-local :edit-path id :preview])))))
|
||||||
|
|
||||||
|
(t/deftest dragging-the-current-curve-forward-handle-while-drawing
|
||||||
|
(let [id (random-uuid)
|
||||||
|
content (path/content [{:command :move-to :params {:x 0 :y 0}}
|
||||||
|
{:command :curve-to
|
||||||
|
:params {:x 100 :y 0 :c1x 30 :c1y 0 :c2x 70 :c2y 0}}])
|
||||||
|
mk (fn []
|
||||||
|
;; Store the backward handle and transient forward handle.
|
||||||
|
{:workspace-drawing {:object {:id id :type :path :content content}}
|
||||||
|
:workspace-local {:edition nil
|
||||||
|
:zoom 1
|
||||||
|
:edit-path {id {:edit-mode :draw
|
||||||
|
:last-point (gpt/point 100 0)
|
||||||
|
:prev-handler (gpt/point 130 0)}}}})
|
||||||
|
drag (fn [alt?]
|
||||||
|
(ptk/update (path.drawing/drag-prev-handler
|
||||||
|
{:x 100 :y 50 :alt? alt? :shift? false})
|
||||||
|
(mk)))
|
||||||
|
finish (fn [state] (ptk/update (path.drawing/finish-drag) state))
|
||||||
|
c2-of (fn [state]
|
||||||
|
(-> (get-in state [:workspace-drawing :object :content])
|
||||||
|
(vec) (nth 1) :params (select-keys [:c2x :c2y])))]
|
||||||
|
|
||||||
|
(t/testing "no alt: the forward handle follows the pointer and the committed backward handle mirrors it"
|
||||||
|
(let [dragged (drag false)]
|
||||||
|
;; the forward handle tracks the pointer
|
||||||
|
(t/is (= (gpt/point 100 50)
|
||||||
|
(get-in dragged [:workspace-local :edit-path id :drag-handler])))
|
||||||
|
;; the stale transient forward handle is cleared so it is not double-rendered
|
||||||
|
(t/is (nil? (get-in dragged [:workspace-local :edit-path id :prev-handler])))
|
||||||
|
(let [committed (finish dragged)]
|
||||||
|
;; c2 = 2*node - forward = (100,-50)
|
||||||
|
(t/is (= {:c2x 100 :c2y -50} (c2-of committed)))
|
||||||
|
;; the new forward handle becomes the prev-handler
|
||||||
|
(t/is (= (gpt/point 100 50)
|
||||||
|
(get-in committed [:workspace-local :edit-path id :prev-handler]))))))
|
||||||
|
|
||||||
|
(t/testing "alt: the forward handle moves on its own, the backward handle stays put"
|
||||||
|
(let [committed (finish (drag true))]
|
||||||
|
(t/is (= {:c2x 70 :c2y 0} (c2-of committed)))))))
|
||||||
|
|
||||||
|
(t/deftest dragging-the-current-curve-backward-handle-while-drawing
|
||||||
|
;; Dragging the backward handle mirrors the transient forward handle.
|
||||||
|
(let [id (random-uuid)
|
||||||
|
content (path/content [{:command :move-to :params {:x 0 :y 0}}
|
||||||
|
{:command :curve-to
|
||||||
|
:params {:x 100 :y 0 :c1x 30 :c1y 0 :c2x 70 :c2y 0}}])
|
||||||
|
mk (fn []
|
||||||
|
{:workspace-drawing {:object {:id id :type :path :content content}}
|
||||||
|
:workspace-local {:edition nil
|
||||||
|
:zoom 1
|
||||||
|
:edit-path {id {:edit-mode :draw
|
||||||
|
:last-point (gpt/point 100 0)
|
||||||
|
:prev-handler (gpt/point 130 0)}}}})
|
||||||
|
;; Drag the backward handle to `(70, -40)`.
|
||||||
|
drag (fn [mode]
|
||||||
|
(ptk/update (path.edition/modify-selected-handlers
|
||||||
|
id [1 :c2] {} 0 -40 mode (= mode :smart))
|
||||||
|
(mk)))
|
||||||
|
prev-of (fn [state] (get-in state [:workspace-local :edit-path id :prev-handler]))]
|
||||||
|
|
||||||
|
(t/testing "smart (no modifier): the forward handle mirrors the angle, keeping its own length"
|
||||||
|
;; Keep the forward handle's length while mirroring its angle.
|
||||||
|
(t/is (= (gpt/point 118 24) (prev-of (drag :smart)))))
|
||||||
|
|
||||||
|
(t/testing "mirror (mod): the forward handle full-mirrors to equal length"
|
||||||
|
;; Mirror the forward handle around the node.
|
||||||
|
(t/is (= (gpt/point 130 40) (prev-of (drag :mirror)))))
|
||||||
|
|
||||||
|
(t/testing "independent (alt): the forward handle is left untouched"
|
||||||
|
(t/is (= (gpt/point 130 0) (prev-of (drag :independent)))))))
|
||||||
|
|
||||||
|
(t/deftest path-local-undo-redo-restores-content-and-clears-preview
|
||||||
|
(let [id (random-uuid)
|
||||||
|
content-a (path/content [{:command :move-to :params {:x 0 :y 0}}
|
||||||
|
{:command :line-to :params {:x 10 :y 0}}])
|
||||||
|
content-b (path/content [{:command :move-to :params {:x 0 :y 0}}
|
||||||
|
{:command :line-to :params {:x 10 :y 5}}])
|
||||||
|
base (-> (pth/selectable-path-state id content-a path.helpers/empty-selection)
|
||||||
|
(assoc-in [:workspace-local :edit-path id :undo-stack] (u/make-stack)))
|
||||||
|
;; Capture both content states around a stale preview.
|
||||||
|
s1 (ptk/update (path.undo/add-undo-entry) base)
|
||||||
|
s2 (-> (path.state/set-content s1 content-b)
|
||||||
|
(assoc-in [:workspace-local :edit-path id :preview]
|
||||||
|
{:command :line-to :params {:x 99 :y 99}}))
|
||||||
|
s3 (ptk/update (path.undo/add-undo-entry) s2)
|
||||||
|
s4 (ptk/update (path.undo/undo-path) s3)
|
||||||
|
s5 (ptk/update (path.undo/redo-path) s4)]
|
||||||
|
(t/is (= content-b (path.state/get-path s3 :content)))
|
||||||
|
(t/is (= content-a (path.state/get-path s4 :content)))
|
||||||
|
;; Restoring an entry drops its render-only preview.
|
||||||
|
(t/is (nil? (get-in s4 [:workspace-local :edit-path id :preview])))
|
||||||
|
(t/is (= content-b (path.state/get-path s5 :content)))))
|
||||||
|
|
||||||
|
(t/deftest path-undo-entry-never-captures-the-transient-preview
|
||||||
|
(let [id (random-uuid)
|
||||||
|
state (-> (pth/selectable-path-state id (pth/selectable-path-content)
|
||||||
|
path.helpers/empty-selection)
|
||||||
|
(assoc-in [:workspace-local :edit-path id :undo-stack] (u/make-stack))
|
||||||
|
(assoc-in [:workspace-local :edit-path id :preview]
|
||||||
|
{:command :line-to :params {:x 99 :y 99}}))
|
||||||
|
state' (ptk/update (path.undo/add-undo-entry) state)
|
||||||
|
entry (u/peek (get-in state' [:workspace-local :edit-path id :undo-stack]))]
|
||||||
|
(t/is (some? entry))
|
||||||
|
(t/is (not (contains? entry :preview)))))
|
||||||
|
|
||||||
|
;; Tool operations through the full edition lifecycle.
|
||||||
|
|
||||||
|
(t/deftest tool-make-curve-persists-through-edition-lifecycle
|
||||||
|
(t/async
|
||||||
|
done
|
||||||
|
(let [file (pth/setup-rect-file)
|
||||||
|
rect (cths/get-shape file :rect1)
|
||||||
|
id (:id rect)
|
||||||
|
store (ths/setup-store file)
|
||||||
|
events (into (pth/start-path-edition-events id)
|
||||||
|
[(path.selection/select-node 1 false)
|
||||||
|
(path.tools/make-curve)
|
||||||
|
:interrupt])]
|
||||||
|
(ths/run-store
|
||||||
|
store done events
|
||||||
|
(fn [new-state]
|
||||||
|
(let [stored (cths/get-shape (ths/get-file-from-state new-state) :rect1)]
|
||||||
|
(t/is (= :path (:type stored)))
|
||||||
|
;; make-curve on a corner introduces at least one curve segment
|
||||||
|
(t/is (some #(= :curve-to (:command %)) (seq (:content stored))))
|
||||||
|
(t/is (nil? (get-in new-state [:workspace-drawing :object])))))))))
|
||||||
|
|
||||||
|
(t/deftest tool-remove-node-persists-through-edition-lifecycle
|
||||||
|
(t/async
|
||||||
|
done
|
||||||
|
(let [file (pth/setup-rect-file)
|
||||||
|
rect (cths/get-shape file :rect1)
|
||||||
|
id (:id rect)
|
||||||
|
orig-nodes (count (path/get-points (:content (path/convert-to-path rect))))
|
||||||
|
store (ths/setup-store file)
|
||||||
|
events (into (pth/start-path-edition-events id)
|
||||||
|
[(path.selection/select-node 1 false)
|
||||||
|
(path.tools/remove-node)
|
||||||
|
:interrupt])]
|
||||||
|
(ths/run-store
|
||||||
|
store done events
|
||||||
|
(fn [new-state]
|
||||||
|
(let [stored (cths/get-shape (ths/get-file-from-state new-state) :rect1)]
|
||||||
|
(t/is (= :path (:type stored)))
|
||||||
|
;; removing a node leaves fewer nodes than the converted rect had
|
||||||
|
(t/is (< (count (path/get-points (:content stored))) orig-nodes))
|
||||||
|
(t/is (nil? (get-in new-state [:workspace-drawing :object])))))))))
|
||||||
|
|
||||||
94
frontend/test/frontend_tests/logic/path_test_helpers.cljs
Normal file
@ -0,0 +1,94 @@
|
|||||||
|
;; 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 Sucursal en España SL
|
||||||
|
|
||||||
|
|
||||||
|
(ns frontend-tests.logic.path-test-helpers
|
||||||
|
(:require
|
||||||
|
[app.common.test-helpers.compositions :as ctho]
|
||||||
|
[app.common.test-helpers.files :as cthf]
|
||||||
|
[app.common.test-helpers.shapes :as cths]
|
||||||
|
[app.common.types.path :as path]
|
||||||
|
[app.main.data.workspace.edition :as dwe]
|
||||||
|
[app.main.data.workspace.path.edition :as path.edition]
|
||||||
|
[app.main.data.workspace.path.state :as path.state]))
|
||||||
|
|
||||||
|
(defn setup-rect-file
|
||||||
|
[]
|
||||||
|
(ctho/add-rect (cthf/sample-file :file1)
|
||||||
|
:rect1
|
||||||
|
:x 10
|
||||||
|
:y 20
|
||||||
|
:width 100
|
||||||
|
:height 80))
|
||||||
|
|
||||||
|
(defn start-path-edition-events
|
||||||
|
[id]
|
||||||
|
[(dwe/start-edition-mode id)
|
||||||
|
(path.edition/start-path-edit id)])
|
||||||
|
|
||||||
|
(defn move-drawing-content
|
||||||
|
[delta]
|
||||||
|
(fn [state]
|
||||||
|
(path.state/set-content
|
||||||
|
state
|
||||||
|
(path/move-content (path.state/get-path state :content) delta))))
|
||||||
|
|
||||||
|
(defn drawing-path-state
|
||||||
|
[]
|
||||||
|
(let [file (setup-rect-file)
|
||||||
|
shape (-> (cths/get-shape file :rect1)
|
||||||
|
(path/convert-to-path))]
|
||||||
|
{:workspace-drawing {:object shape}}))
|
||||||
|
|
||||||
|
(defn selectable-path-content
|
||||||
|
[]
|
||||||
|
(path/content
|
||||||
|
[{:command :move-to
|
||||||
|
:params {:x 0 :y 0}}
|
||||||
|
{:command :curve-to
|
||||||
|
:params {:c1x 2 :c1y 0
|
||||||
|
:c2x 8 :c2y 0
|
||||||
|
:x 10 :y 0}}
|
||||||
|
{:command :curve-to
|
||||||
|
:params {:c1x 12 :c1y 0
|
||||||
|
:c2x 18 :c2y 0
|
||||||
|
:x 20 :y 0}}]))
|
||||||
|
|
||||||
|
(defn selectable-path-state
|
||||||
|
[id content selection]
|
||||||
|
{:workspace-local {:edition id
|
||||||
|
:edit-path {id {:selection selection}}}
|
||||||
|
:workspace-drawing {:object {:id id
|
||||||
|
:type :path
|
||||||
|
:content content}}})
|
||||||
|
|
||||||
|
(defn mixed-corner-curve-content
|
||||||
|
[]
|
||||||
|
(path/content
|
||||||
|
[{:command :move-to
|
||||||
|
:params {:x 0 :y 0}}
|
||||||
|
{:command :line-to
|
||||||
|
:params {:x 10 :y 0}}
|
||||||
|
{:command :curve-to
|
||||||
|
:params {:c1x 12 :c1y 4
|
||||||
|
:c2x 18 :c2y 4
|
||||||
|
:x 20 :y 0}}]))
|
||||||
|
|
||||||
|
(defn corner-path-content
|
||||||
|
"Returns selectable content with a corner at `(10, 0)`."
|
||||||
|
[]
|
||||||
|
(path/content
|
||||||
|
[{:command :move-to
|
||||||
|
:params {:x 0 :y 0}}
|
||||||
|
{:command :curve-to
|
||||||
|
:params {:c1x 2 :c1y 0
|
||||||
|
:c2x 8 :c2y 0
|
||||||
|
:x 10 :y 0}}
|
||||||
|
{:command :curve-to
|
||||||
|
:params {:c1x 12 :c1y 6
|
||||||
|
:c2x 18 :c2y 0
|
||||||
|
:x 20 :y 0}}]))
|
||||||
|
|
||||||
804
frontend/test/frontend_tests/logic/path_tools_test.cljs
Normal file
@ -0,0 +1,804 @@
|
|||||||
|
;; 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 Sucursal en España SL
|
||||||
|
|
||||||
|
|
||||||
|
(ns frontend-tests.logic.path-tools-test
|
||||||
|
(:require
|
||||||
|
[app.common.geom.point :as gpt]
|
||||||
|
[app.common.geom.rect :as grc]
|
||||||
|
[app.common.types.path :as path]
|
||||||
|
[app.main.data.workspace.path.drawing :as path.drawing]
|
||||||
|
[app.main.data.workspace.path.edition :as path.edition]
|
||||||
|
[app.main.data.workspace.path.helpers :as path.helpers]
|
||||||
|
[app.main.data.workspace.path.selection :as path.selection]
|
||||||
|
[app.main.data.workspace.path.state :as path.state]
|
||||||
|
[app.main.data.workspace.path.tools :as path.tools]
|
||||||
|
[beicon.v2.core :as rx]
|
||||||
|
[cljs.test :as t :include-macros true]
|
||||||
|
[frontend-tests.logic.path-test-helpers :as pth]
|
||||||
|
[potok.v2.core :as ptk]))
|
||||||
|
|
||||||
|
(t/deftest mixed-node-conversions-only-change-opposite-node-type
|
||||||
|
(let [id (random-uuid)
|
||||||
|
content (pth/mixed-corner-curve-content)
|
||||||
|
points (path/get-points content)
|
||||||
|
corner-point (first points)
|
||||||
|
curve-point (second points)
|
||||||
|
state (pth/selectable-path-state
|
||||||
|
id content
|
||||||
|
{:nodes #{0 1} :segments #{} :handlers #{}})
|
||||||
|
curved-state (ptk/update (path.tools/make-curve) state)
|
||||||
|
cornered-state (ptk/update (path.tools/make-corner) state)
|
||||||
|
curved-content (path.state/get-path curved-state :content)
|
||||||
|
corner-content (path.state/get-path cornered-state :content)]
|
||||||
|
(t/is (path/is-curve-point? curved-content corner-point))
|
||||||
|
(t/is (= (nth content 2) (nth curved-content 2)))
|
||||||
|
(t/is (not (path/is-curve-point? corner-content corner-point)))
|
||||||
|
(t/is (not (path/is-curve-point? corner-content curve-point)))))
|
||||||
|
|
||||||
|
(t/deftest plain-and-shift-selection-work-across-path-element-types
|
||||||
|
(let [id (random-uuid)
|
||||||
|
content (pth/selectable-path-content)
|
||||||
|
state (pth/selectable-path-state
|
||||||
|
id content
|
||||||
|
{:nodes #{0}
|
||||||
|
:segments #{1}
|
||||||
|
:handlers #{[1 :c1]}})
|
||||||
|
state' (ptk/update (path.selection/select-handler 1 :c2 false) state)
|
||||||
|
state'' (ptk/update (path.selection/select-segment 2 true) state')
|
||||||
|
state''' (ptk/update (path.selection/select-handler 1 :c2 true) state'')
|
||||||
|
state'''' (ptk/update (path.selection/select-handler 2 :c1 true) state''')]
|
||||||
|
(t/is (= {:nodes #{}
|
||||||
|
:segments #{}
|
||||||
|
:handlers #{[1 :c2]}}
|
||||||
|
(get-in state' [:workspace-local :edit-path id :selection])))
|
||||||
|
(t/is (= {:nodes #{}
|
||||||
|
:segments #{2}
|
||||||
|
:handlers #{[1 :c2]}}
|
||||||
|
(get-in state'' [:workspace-local :edit-path id :selection])))
|
||||||
|
(t/is (= {:nodes #{}
|
||||||
|
:segments #{2}
|
||||||
|
:handlers #{[2 :c1]}}
|
||||||
|
(get-in state'''' [:workspace-local :edit-path id :selection])))))
|
||||||
|
|
||||||
|
(t/deftest path-area-selection-prioritizes-nodes-over-segments-over-handlers
|
||||||
|
(let [id (random-uuid)
|
||||||
|
content (pth/selectable-path-content)
|
||||||
|
selection path.helpers/empty-selection
|
||||||
|
state (pth/selectable-path-state id content selection)
|
||||||
|
;; Contains segment 1 and handler [1 :c1] but no node
|
||||||
|
rect (grc/make-rect 1 -2 3 4)
|
||||||
|
state' (ptk/update (path.selection/select-path-area
|
||||||
|
rect selection false)
|
||||||
|
state)
|
||||||
|
;; Contains node 0, segment 1 and handler [1 :c1]
|
||||||
|
node-rect (grc/make-rect -1 -1 4 2)
|
||||||
|
state'' (ptk/update (path.selection/select-path-area
|
||||||
|
node-rect selection false)
|
||||||
|
state)]
|
||||||
|
(t/is (= {:nodes #{}
|
||||||
|
:segments #{1}
|
||||||
|
:handlers #{}}
|
||||||
|
(get-in state' [:workspace-local :edit-path id :selection])))
|
||||||
|
(t/is (= {:nodes #{0}
|
||||||
|
:segments #{}
|
||||||
|
:handlers #{}}
|
||||||
|
(get-in state'' [:workspace-local :edit-path id :selection])))))
|
||||||
|
|
||||||
|
(t/deftest path-area-selection-picks-handlers-only-when-nothing-else-is-inside
|
||||||
|
(let [id (random-uuid)
|
||||||
|
;; Curve bulging up to y 7.5 with both handlers on y 10, away
|
||||||
|
;; from the curve itself
|
||||||
|
content (path/content
|
||||||
|
[{:command :move-to
|
||||||
|
:params {:x 0 :y 0}}
|
||||||
|
{:command :curve-to
|
||||||
|
:params {:c1x 0 :c1y 10
|
||||||
|
:c2x 10 :c2y 10
|
||||||
|
:x 10 :y 0}}])
|
||||||
|
selection path.helpers/empty-selection
|
||||||
|
state (pth/selectable-path-state id content selection)
|
||||||
|
;; Contains only the [1 :c1] handler control point
|
||||||
|
handler-rect (grc/make-rect -1 9 2 2)
|
||||||
|
state' (ptk/update (path.selection/select-path-area
|
||||||
|
handler-rect selection false)
|
||||||
|
state)
|
||||||
|
;; Contains both handlers and the top of the curve
|
||||||
|
mixed-rect (grc/make-rect -1 5 12 7)
|
||||||
|
state'' (ptk/update (path.selection/select-path-area
|
||||||
|
mixed-rect selection false)
|
||||||
|
state)]
|
||||||
|
(t/is (= {:nodes #{}
|
||||||
|
:segments #{}
|
||||||
|
:handlers #{[1 :c1]}}
|
||||||
|
(get-in state' [:workspace-local :edit-path id :selection])))
|
||||||
|
(t/is (= {:nodes #{}
|
||||||
|
:segments #{1}
|
||||||
|
:handlers #{}}
|
||||||
|
(get-in state'' [:workspace-local :edit-path id :selection])))))
|
||||||
|
|
||||||
|
(t/deftest path-area-selection-ignores-empty-buffer-emissions
|
||||||
|
(let [id (random-uuid)
|
||||||
|
content (pth/selectable-path-content)
|
||||||
|
selection path.helpers/empty-selection
|
||||||
|
state (pth/selectable-path-state id content selection)]
|
||||||
|
(t/is (= state
|
||||||
|
(ptk/update (path.selection/select-path-area
|
||||||
|
nil selection false)
|
||||||
|
state)))))
|
||||||
|
|
||||||
|
(t/deftest selected-segments-resolve-to-unique-endpoint-nodes
|
||||||
|
(let [content (pth/selectable-path-content)]
|
||||||
|
(t/is (= #{0 1}
|
||||||
|
(path.helpers/segment-node-indices content #{1})))
|
||||||
|
(t/is (= #{0 1 2}
|
||||||
|
(path.helpers/segment-node-indices content #{1 2})))))
|
||||||
|
|
||||||
|
(t/deftest moving-selected-segments-translates-endpoints-and-handlers
|
||||||
|
(let [id (random-uuid)
|
||||||
|
content (pth/selectable-path-content)
|
||||||
|
selection {:nodes #{}
|
||||||
|
:segments #{1}
|
||||||
|
:handlers #{}}
|
||||||
|
state (pth/selectable-path-state id content selection)
|
||||||
|
event (path.edition/move-selected-path-segment
|
||||||
|
(gpt/point 5 0)
|
||||||
|
(gpt/point 8 4))
|
||||||
|
state' (ptk/update event state)
|
||||||
|
modifiers (get-in state' [:workspace-local :edit-path id :content-modifiers])
|
||||||
|
content' (path/apply-content-modifiers content modifiers)]
|
||||||
|
(t/is (= (gpt/point 3 4) (path.helpers/node-position content' 0)))
|
||||||
|
(t/is (= (gpt/point 13 4) (path.helpers/node-position content' 1)))
|
||||||
|
(t/is (= (gpt/point 5 4) (path/get-handler-point content' 1 :c1)))
|
||||||
|
(t/is (= (gpt/point 11 4) (path/get-handler-point content' 1 :c2)))
|
||||||
|
(t/is (= (gpt/point 15 4) (path/get-handler-point content' 2 :c1)))))
|
||||||
|
|
||||||
|
(t/deftest moving-a-segment-between-selected-nodes-moves-the-node-selection
|
||||||
|
;; A segment between selected nodes moves with the node selection.
|
||||||
|
(let [id (random-uuid)
|
||||||
|
content (pth/selectable-path-content)
|
||||||
|
selection {:nodes #{0 1} :segments #{} :handlers #{}}
|
||||||
|
state (pth/selectable-path-state id content selection)
|
||||||
|
event (path.edition/move-selected-path-segment
|
||||||
|
(gpt/point 5 0)
|
||||||
|
(gpt/point 8 4))
|
||||||
|
state' (ptk/update event state)
|
||||||
|
modifiers (get-in state' [:workspace-local :edit-path id :content-modifiers])
|
||||||
|
content' (path/apply-content-modifiers content modifiers)]
|
||||||
|
;; both selected nodes translate by (+3,+4); the unselected node stays put
|
||||||
|
(t/is (= (gpt/point 3 4) (path.helpers/node-position content' 0)))
|
||||||
|
(t/is (= (gpt/point 13 4) (path.helpers/node-position content' 1)))
|
||||||
|
(t/is (= (gpt/point 20 0) (path.helpers/node-position content' 2)))))
|
||||||
|
|
||||||
|
(t/deftest moving-selected-opposite-handlers-translates-both
|
||||||
|
(let [id (random-uuid)
|
||||||
|
content (pth/selectable-path-content)
|
||||||
|
selection {:nodes #{}
|
||||||
|
:segments #{}
|
||||||
|
:handlers #{[1 :c2] [2 :c1]}}
|
||||||
|
state (pth/selectable-path-state id content selection)
|
||||||
|
event (path.edition/modify-selected-handlers
|
||||||
|
id [1 :c2] {} 3 4 :smart true)
|
||||||
|
state' (ptk/update event state)
|
||||||
|
modifiers (get-in state' [:workspace-local :edit-path id :content-modifiers])
|
||||||
|
content' (path/apply-content-modifiers content modifiers)]
|
||||||
|
(t/is (= {:c2x 3 :c2y 4} (get modifiers 1)))
|
||||||
|
(t/is (= {:c1x 3 :c1y 4} (get modifiers 2)))
|
||||||
|
(t/is (= (gpt/point 11 4) (path/get-handler-point content' 1 :c2)))
|
||||||
|
(t/is (= (gpt/point 15 4) (path/get-handler-point content' 2 :c1)))))
|
||||||
|
|
||||||
|
(t/deftest moving-selected-handlers-honours-each-explicit-node-mode
|
||||||
|
(let [id (random-uuid)
|
||||||
|
content (path/content
|
||||||
|
[{:command :move-to :params {:x 0 :y 0}}
|
||||||
|
{:command :curve-to
|
||||||
|
:params {:c1x 2 :c1y 0 :c2x 8 :c2y 0 :x 10 :y 0}}
|
||||||
|
{:command :curve-to
|
||||||
|
:params {:c1x 12 :c1y 0 :c2x 18 :c2y 0 :x 20 :y 0}}
|
||||||
|
{:command :curve-to
|
||||||
|
:params {:c1x 22 :c1y 0 :c2x 28 :c2y 0 :x 30 :y 0}}])
|
||||||
|
selection {:nodes #{}
|
||||||
|
:segments #{}
|
||||||
|
:handlers #{[2 :c1] [3 :c1]}}
|
||||||
|
state (-> (pth/selectable-path-state id content selection)
|
||||||
|
(assoc-in [:workspace-local :edit-path id :handler-types]
|
||||||
|
{1 :independent 2 :mirror}))
|
||||||
|
event (path.edition/modify-selected-handlers
|
||||||
|
id [2 :c1] {} 3 4 :independent true)
|
||||||
|
state' (ptk/update event state)
|
||||||
|
modifiers (get-in state' [:workspace-local :edit-path id :content-modifiers])
|
||||||
|
content' (path/apply-content-modifiers content modifiers)]
|
||||||
|
;; Selected handlers receive the same drag delta.
|
||||||
|
(t/is (= (gpt/point 15 4) (path/get-handler-point content' 2 :c1)))
|
||||||
|
(t/is (= (gpt/point 25 4) (path/get-handler-point content' 3 :c1)))
|
||||||
|
;; Each node applies its own mode to the opposite handle.
|
||||||
|
(t/is (= (gpt/point 8 0) (path/get-handler-point content' 1 :c2)))
|
||||||
|
(t/is (= (gpt/point 15 -4) (path/get-handler-point content' 2 :c2)))))
|
||||||
|
|
||||||
|
(t/deftest arrow-move-nudges-selected-handlers
|
||||||
|
;; Arrow keys nudge selected handlers.
|
||||||
|
(let [id (random-uuid)
|
||||||
|
content (pth/selectable-path-content)
|
||||||
|
state (pth/selectable-path-state id content
|
||||||
|
{:nodes #{} :segments #{} :handlers #{[2 :c1]}})
|
||||||
|
state' (ptk/update (path.edition/set-move-modifier [] #{[2 :c1]} (gpt/point 0 5))
|
||||||
|
state)
|
||||||
|
mods (get-in state' [:workspace-local :edit-path id :content-modifiers])
|
||||||
|
content' (path/apply-content-modifiers content mods)]
|
||||||
|
(t/is (= {:c1x 0 :c1y 5} (get mods 2)))
|
||||||
|
;; [2 :c1] base (12,0) -> (12,5); node 1 and its opposite handle stay put
|
||||||
|
(t/is (= (gpt/point 12 5) (path/get-handler-point content' 2 :c1)))
|
||||||
|
(t/is (= (gpt/point 10 0) (path.helpers/node-position content' 1)))
|
||||||
|
(t/is (= (gpt/point 8 0) (path/get-handler-point content' 1 :c2)))))
|
||||||
|
|
||||||
|
(t/deftest arrow-move-nudges-selected-segment-endpoints
|
||||||
|
;; Arrow keys nudge segment endpoints and their handles.
|
||||||
|
(let [id (random-uuid)
|
||||||
|
content (pth/selectable-path-content)
|
||||||
|
state (pth/selectable-path-state id content
|
||||||
|
{:nodes #{} :segments #{1} :handlers #{}})
|
||||||
|
node-idx (path.helpers/segment-node-indices content #{1})
|
||||||
|
points (path.helpers/node-positions content node-idx)
|
||||||
|
state' (ptk/update (path.edition/set-move-modifier points #{} (gpt/point 0 5))
|
||||||
|
state)
|
||||||
|
mods (get-in state' [:workspace-local :edit-path id :content-modifiers])
|
||||||
|
content' (path/apply-content-modifiers content mods)]
|
||||||
|
;; segment 1 connects node 0 (0,0) and node 1 (10,0); both move by (0,5)
|
||||||
|
(t/is (= (gpt/point 0 5) (path.helpers/node-position content' 0)))
|
||||||
|
(t/is (= (gpt/point 10 5) (path.helpers/node-position content' 1)))
|
||||||
|
;; the endpoint nodes' handles move rigidly with them
|
||||||
|
(t/is (= (gpt/point 2 5) (path/get-handler-point content' 1 :c1)))
|
||||||
|
(t/is (= (gpt/point 12 5) (path/get-handler-point content' 2 :c1)))))
|
||||||
|
|
||||||
|
(t/deftest align-nodes-aligns-selected-nodes-to-an-edge
|
||||||
|
;; Aligning nodes updates the drawing content.
|
||||||
|
(let [id (random-uuid)
|
||||||
|
content (path/content
|
||||||
|
[{:command :move-to :params {:x 0 :y 0}}
|
||||||
|
{:command :line-to :params {:x 10 :y 4}}
|
||||||
|
{:command :line-to :params {:x 4 :y 20}}])
|
||||||
|
state (pth/selectable-path-state id content
|
||||||
|
{:nodes #{0 1 2} :segments #{} :handlers #{}})
|
||||||
|
state' (ptk/update (path.tools/align-nodes :hleft) state)
|
||||||
|
content' (get-in state' [:workspace-drawing :object :content])]
|
||||||
|
;; every selected node's x becomes the min x (0), y is untouched
|
||||||
|
(t/is (= (gpt/point 0 0) (path.helpers/node-position content' 0)))
|
||||||
|
(t/is (= (gpt/point 0 4) (path.helpers/node-position content' 1)))
|
||||||
|
(t/is (= (gpt/point 0 20) (path.helpers/node-position content' 2)))))
|
||||||
|
|
||||||
|
(t/deftest distribute-nodes-spaces-selected-nodes-evenly
|
||||||
|
(let [id (random-uuid)
|
||||||
|
content (path/content
|
||||||
|
[{:command :move-to :params {:x 0 :y 0}}
|
||||||
|
{:command :line-to :params {:x 3 :y 5}}
|
||||||
|
{:command :line-to :params {:x 10 :y 9}}])
|
||||||
|
state (pth/selectable-path-state id content
|
||||||
|
{:nodes #{0 1 2} :segments #{} :handlers #{}})
|
||||||
|
state' (ptk/update (path.tools/distribute-nodes :horizontal) state)
|
||||||
|
content' (get-in state' [:workspace-drawing :object :content])]
|
||||||
|
;; the middle node is centered on x between the two extremes (0 and 10)
|
||||||
|
(t/is (= (gpt/point 5 5) (path.helpers/node-position content' 1)))
|
||||||
|
;; the extreme nodes stay put
|
||||||
|
(t/is (= (gpt/point 0 0) (path.helpers/node-position content' 0)))
|
||||||
|
(t/is (= (gpt/point 10 9) (path.helpers/node-position content' 2)))))
|
||||||
|
|
||||||
|
(t/deftest set-selection-coordinate-moves-selected-points
|
||||||
|
;; Coordinate edits move selected nodes and handlers.
|
||||||
|
(let [id (random-uuid)
|
||||||
|
content (path/content
|
||||||
|
[{:command :move-to :params {:x 0 :y 0}}
|
||||||
|
{:command :line-to :params {:x 10 :y 0}}
|
||||||
|
{:command :line-to :params {:x 20 :y 0}}])]
|
||||||
|
;; single node: only that node moves
|
||||||
|
(let [state (pth/selectable-path-state id content
|
||||||
|
{:nodes #{1} :segments #{} :handlers #{}})
|
||||||
|
state' (ptk/update (path.tools/set-selection-coordinate :y 7) state)
|
||||||
|
content' (get-in state' [:workspace-drawing :object :content])]
|
||||||
|
(t/is (= (gpt/point 10 7) (path.helpers/node-position content' 1)))
|
||||||
|
(t/is (= (gpt/point 0 0) (path.helpers/node-position content' 0)))
|
||||||
|
(t/is (= (gpt/point 20 0) (path.helpers/node-position content' 2))))
|
||||||
|
;; multi node: every selected node's coordinate is set to the value
|
||||||
|
(let [state (pth/selectable-path-state id content
|
||||||
|
{:nodes #{0 2} :segments #{} :handlers #{}})
|
||||||
|
state' (ptk/update (path.tools/set-selection-coordinate :x 5) state)
|
||||||
|
content' (get-in state' [:workspace-drawing :object :content])]
|
||||||
|
(t/is (= (gpt/point 5 0) (path.helpers/node-position content' 0)))
|
||||||
|
(t/is (= (gpt/point 5 0) (path.helpers/node-position content' 2)))
|
||||||
|
(t/is (= (gpt/point 10 0) (path.helpers/node-position content' 1)))))
|
||||||
|
;; a coincident closed-seam node moves as one logical node
|
||||||
|
(let [id (random-uuid)
|
||||||
|
content (path/content
|
||||||
|
[{:command :move-to :params {:x 0 :y 0}}
|
||||||
|
{:command :line-to :params {:x 10 :y 0}}
|
||||||
|
{:command :line-to :params {:x 0 :y 0}}])
|
||||||
|
state (pth/selectable-path-state id content
|
||||||
|
{:nodes #{0} :segments #{} :handlers #{}})
|
||||||
|
state' (ptk/update (path.tools/set-selection-coordinate :y 7) state)
|
||||||
|
content' (get-in state' [:workspace-drawing :object :content])]
|
||||||
|
(t/is (= (gpt/point 0 7) (path.helpers/node-position content' 0)))
|
||||||
|
(t/is (= (gpt/point 0 7) (path.helpers/node-position content' 2))))
|
||||||
|
;; a selected handler on an independent node moves only its own control point
|
||||||
|
(let [id (random-uuid)
|
||||||
|
content (path/content
|
||||||
|
[{:command :move-to :params {:x 0 :y 0}}
|
||||||
|
{:command :curve-to :params {:c1x 2 :c1y 2 :c2x 8 :c2y 2 :x 10 :y 0}}])
|
||||||
|
state (pth/selectable-path-state id content
|
||||||
|
{:nodes #{} :segments #{} :handlers #{[1 :c1]}})
|
||||||
|
state' (ptk/update (path.tools/set-selection-coordinate :x 4) state)
|
||||||
|
curve (nth (get-in state' [:workspace-drawing :object :content]) 1)]
|
||||||
|
;; c1 x set to 4; c1y, c2 and the anchor untouched
|
||||||
|
(t/is (= 4 (get-in curve [:params :c1x])))
|
||||||
|
(t/is (= 2 (get-in curve [:params :c1y])))
|
||||||
|
(t/is (= 8 (get-in curve [:params :c2x])))
|
||||||
|
(t/is (= 10 (get-in curve [:params :x])))))
|
||||||
|
|
||||||
|
(t/deftest set-selection-coordinate-mirrors-opposite-handler
|
||||||
|
;; Moving a mirrored handler updates its opposite.
|
||||||
|
(let [id (random-uuid)
|
||||||
|
;; node 1 (10,0) has collinear equal handles: c2 of cmd1 at (8,-2) and
|
||||||
|
;; c1 of cmd2 at (12,2) — a mirror node by geometry
|
||||||
|
content (path/content
|
||||||
|
[{:command :move-to :params {:x 0 :y 0}}
|
||||||
|
{:command :curve-to :params {:c1x 2 :c1y 0 :c2x 8 :c2y -2 :x 10 :y 0}}
|
||||||
|
{:command :curve-to :params {:c1x 12 :c1y 2 :c2x 18 :c2y 0 :x 20 :y 0}}])
|
||||||
|
state (pth/selectable-path-state id content
|
||||||
|
{:nodes #{} :segments #{} :handlers #{[1 :c2]}})
|
||||||
|
state' (ptk/update (path.tools/set-selection-coordinate :x 6) state)
|
||||||
|
content' (get-in state' [:workspace-drawing :object :content])]
|
||||||
|
;; dragged handle c2 of cmd1 -> x=6 (keeps y=-2)
|
||||||
|
(t/is (= (gpt/point 6 -2) (path/get-handler-point content' 1 :c2)))
|
||||||
|
;; opposite (c1 of cmd2) mirrors it about the node (10,0): 2*10-6=14, 2*0-(-2)=2
|
||||||
|
(t/is (= (gpt/point 14 2) (path/get-handler-point content' 2 :c1)))))
|
||||||
|
|
||||||
|
(t/deftest change-to-draw-mode-starts-a-line-from-the-selected-node
|
||||||
|
(let [id (random-uuid)
|
||||||
|
content (path/content
|
||||||
|
[{:command :move-to :params {:x 0 :y 0}}
|
||||||
|
{:command :line-to :params {:x 10 :y 0}}
|
||||||
|
{:command :line-to :params {:x 20 :y 0}}])]
|
||||||
|
;; a middle node: opens a new subpath (move-to) at the node and makes it the
|
||||||
|
;; pending origin, so the next click draws a line from it
|
||||||
|
(let [state (pth/selectable-path-state id content
|
||||||
|
{:nodes #{1} :segments #{} :handlers #{}})
|
||||||
|
state' (ptk/update (path.drawing/change-edit-mode :draw) state)
|
||||||
|
content' (get-in state' [:workspace-drawing :object :content])]
|
||||||
|
(t/is (= (gpt/point 10 0)
|
||||||
|
(get-in state' [:workspace-local :edit-path id :last-point])))
|
||||||
|
(t/is (= 4 (count content')))
|
||||||
|
(t/is (= :move-to (:command (nth content' 3))))
|
||||||
|
(t/is (= (gpt/point 10 0) (path.helpers/node-position content' 3))))
|
||||||
|
;; the drawing tip: just becomes the pending origin (extends), no new subpath
|
||||||
|
(let [state (pth/selectable-path-state id content
|
||||||
|
{:nodes #{2} :segments #{} :handlers #{}})
|
||||||
|
state' (ptk/update (path.drawing/change-edit-mode :draw) state)
|
||||||
|
content' (get-in state' [:workspace-drawing :object :content])]
|
||||||
|
(t/is (= (gpt/point 20 0)
|
||||||
|
(get-in state' [:workspace-local :edit-path id :last-point])))
|
||||||
|
(t/is (= 3 (count content'))))
|
||||||
|
;; nothing selected: no pending line
|
||||||
|
(let [state (pth/selectable-path-state id content
|
||||||
|
{:nodes #{} :segments #{} :handlers #{}})
|
||||||
|
state' (ptk/update (path.drawing/change-edit-mode :draw) state)]
|
||||||
|
(t/is (nil? (get-in state' [:workspace-local :edit-path id :last-point]))))))
|
||||||
|
|
||||||
|
(t/deftest set-selection-coordinate-translates-segments
|
||||||
|
;; Coordinate edits translate selected segments by their bounds.
|
||||||
|
(let [id (random-uuid)
|
||||||
|
content (path/content
|
||||||
|
[{:command :move-to :params {:x 0 :y 0}}
|
||||||
|
{:command :line-to :params {:x 10 :y 0}}
|
||||||
|
{:command :line-to :params {:x 10 :y 10}}])
|
||||||
|
;; select segment index 2 (the vertical line from (10,0) to (10,10));
|
||||||
|
;; its surrounding rect top-left x is 10
|
||||||
|
state (pth/selectable-path-state id content
|
||||||
|
{:nodes #{} :segments #{2} :handlers #{}})
|
||||||
|
state' (ptk/update (path.tools/set-selection-coordinate :x 30) state)
|
||||||
|
content' (get-in state' [:workspace-drawing :object :content])]
|
||||||
|
;; the segment's endpoint nodes (1 and 2) move +20 in x; node 0 stays
|
||||||
|
(t/is (= (gpt/point 0 0) (path.helpers/node-position content' 0)))
|
||||||
|
(t/is (= (gpt/point 30 0) (path.helpers/node-position content' 1)))
|
||||||
|
(t/is (= (gpt/point 30 10) (path.helpers/node-position content' 2))))
|
||||||
|
;; Moving a segment attached to a closed seam keeps both seam commands together.
|
||||||
|
(let [id (random-uuid)
|
||||||
|
content (path/content
|
||||||
|
[{:command :move-to :params {:x 0 :y 0}}
|
||||||
|
{:command :line-to :params {:x 10 :y 0}}
|
||||||
|
{:command :line-to :params {:x 10 :y 10}}
|
||||||
|
{:command :line-to :params {:x 0 :y 10}}
|
||||||
|
{:command :line-to :params {:x 0 :y 0}}])
|
||||||
|
state (pth/selectable-path-state id content
|
||||||
|
{:nodes #{} :segments #{4} :handlers #{}})
|
||||||
|
state' (ptk/update (path.tools/set-selection-coordinate :x 20) state)
|
||||||
|
content' (get-in state' [:workspace-drawing :object :content])]
|
||||||
|
(t/is (= (gpt/point 20 0) (path.helpers/node-position content' 0)))
|
||||||
|
(t/is (= (gpt/point 20 10) (path.helpers/node-position content' 3)))
|
||||||
|
(t/is (= (gpt/point 20 0) (path.helpers/node-position content' 4)))))
|
||||||
|
|
||||||
|
(t/deftest set-selection-coordinate-translates-mixed-segment-and-node-selection
|
||||||
|
;; Selected segments and nodes translate as one group.
|
||||||
|
(let [id (random-uuid)
|
||||||
|
content (path/content
|
||||||
|
[{:command :move-to :params {:x 0 :y 0}}
|
||||||
|
{:command :line-to :params {:x 10 :y 0}}
|
||||||
|
{:command :line-to :params {:x 20 :y 0}}
|
||||||
|
{:command :line-to :params {:x 30 :y 0}}])
|
||||||
|
;; The combined bounds start at x=0.
|
||||||
|
state (pth/selectable-path-state id content
|
||||||
|
{:nodes #{0} :segments #{3} :handlers #{}})
|
||||||
|
state' (ptk/update (path.tools/set-selection-coordinate :x 10) state)
|
||||||
|
content' (get-in state' [:workspace-drawing :object :content])]
|
||||||
|
(t/is (= (gpt/point 10 0) (path.helpers/node-position content' 0)))
|
||||||
|
(t/is (= (gpt/point 10 0) (path.helpers/node-position content' 1)))
|
||||||
|
(t/is (= (gpt/point 30 0) (path.helpers/node-position content' 2)))
|
||||||
|
(t/is (= (gpt/point 40 0) (path.helpers/node-position content' 3)))))
|
||||||
|
|
||||||
|
(t/deftest set-selection-coordinate-translates-mixed-segment-and-handler-selection
|
||||||
|
;; Standalone selected handlers translate with the group.
|
||||||
|
(let [id (random-uuid)
|
||||||
|
content (path/content
|
||||||
|
[{:command :move-to :params {:x 0 :y 0}}
|
||||||
|
{:command :curve-to
|
||||||
|
:params {:c1x 2 :c1y 2 :c2x 8 :c2y 2 :x 10 :y 0}}
|
||||||
|
{:command :line-to :params {:x 20 :y 0}}
|
||||||
|
{:command :line-to :params {:x 30 :y 0}}])
|
||||||
|
;; The standalone handler makes the bounds start at x=2.
|
||||||
|
state (pth/selectable-path-state id content
|
||||||
|
{:nodes #{}
|
||||||
|
:segments #{3}
|
||||||
|
:handlers #{[1 :c1]}})
|
||||||
|
state' (ptk/update (path.tools/set-selection-coordinate :x 12) state)
|
||||||
|
content' (get-in state' [:workspace-drawing :object :content])]
|
||||||
|
(t/is (= (gpt/point 12 2) (path/get-handler-point content' 1 :c1)))
|
||||||
|
(t/is (= (gpt/point 0 0) (path.helpers/node-position content' 0)))
|
||||||
|
(t/is (= (gpt/point 10 0) (path.helpers/node-position content' 1)))
|
||||||
|
(t/is (= (gpt/point 30 0) (path.helpers/node-position content' 2)))
|
||||||
|
(t/is (= (gpt/point 40 0) (path.helpers/node-position content' 3)))))
|
||||||
|
|
||||||
|
(t/deftest flip-nodes-includes-selected-segment-endpoints
|
||||||
|
(let [id (random-uuid)
|
||||||
|
content (pth/selectable-path-content)
|
||||||
|
state (pth/selectable-path-state id content
|
||||||
|
{:nodes #{} :segments #{1} :handlers #{}})
|
||||||
|
state' (ptk/update (path.tools/flip-nodes :horizontal) state)
|
||||||
|
content' (get-in state' [:workspace-drawing :object :content])]
|
||||||
|
;; segment 1's endpoints (nodes 0 and 1) mirror across their bbox centre (x=5)
|
||||||
|
(t/is (= (gpt/point 10 0) (path.helpers/node-position content' 0)))
|
||||||
|
(t/is (= (gpt/point 0 0) (path.helpers/node-position content' 1)))
|
||||||
|
;; node 2 is not an endpoint of segment 1, so it stays put
|
||||||
|
(t/is (= (gpt/point 20 0) (path.helpers/node-position content' 2)))))
|
||||||
|
|
||||||
|
(t/deftest merge-nodes-includes-selected-segment-endpoints
|
||||||
|
(let [id (random-uuid)
|
||||||
|
content (pth/selectable-path-content)
|
||||||
|
state (pth/selectable-path-state id content
|
||||||
|
{:nodes #{} :segments #{1} :handlers #{}})
|
||||||
|
state' (ptk/update (path.tools/merge-nodes) state)
|
||||||
|
content' (get-in state' [:workspace-drawing :object :content])
|
||||||
|
pts (path/get-points content')]
|
||||||
|
;; segment 1's endpoints (0,0) and (10,0) merge to their midpoint (5,0)
|
||||||
|
(t/is (some #(= (gpt/point 5 0) %) pts))
|
||||||
|
(t/is (< (count pts) 3))))
|
||||||
|
|
||||||
|
(t/deftest delete-selected-opens-segments-else-removes-nodes
|
||||||
|
(let [id (random-uuid)
|
||||||
|
content (path/content
|
||||||
|
[{:command :move-to :params {:x 0 :y 0}}
|
||||||
|
{:command :line-to :params {:x 10 :y 0}}
|
||||||
|
{:command :line-to :params {:x 20 :y 0}}
|
||||||
|
{:command :line-to :params {:x 30 :y 0}}])
|
||||||
|
run (fn [selection]
|
||||||
|
(let [state (pth/selectable-path-state id content selection)
|
||||||
|
events (atom [])]
|
||||||
|
(->> (ptk/watch (path.tools/delete-selected) state nil)
|
||||||
|
(rx/subs! #(swap! events conj %)))
|
||||||
|
;; delete-selected emits a single node-tool event; apply it
|
||||||
|
(ptk/update (first @events) state)))
|
||||||
|
move-tos (fn [st] (->> (get-in st [:workspace-drawing :object :content])
|
||||||
|
vec
|
||||||
|
(filter #(= :move-to (:command %)))
|
||||||
|
count))
|
||||||
|
nodes (fn [st] (count (path/get-points
|
||||||
|
(get-in st [:workspace-drawing :object :content]))))]
|
||||||
|
;; deleting the middle segment (index 2) opens the path into two subpaths
|
||||||
|
(t/is (> (move-tos (run {:nodes #{} :segments #{2} :handlers #{}})) 1))
|
||||||
|
;; Deleting a node leaves fewer than four nodes.
|
||||||
|
(t/is (< (nodes (run {:nodes #{1} :segments #{} :handlers #{}})) 4))
|
||||||
|
;; Mixed node and segment deletion heals the selected node.
|
||||||
|
(let [mixed (run {:nodes #{1} :segments #{2} :handlers #{}})]
|
||||||
|
(t/is (< (nodes mixed) 4))
|
||||||
|
(t/is (= 1 (move-tos mixed))))))
|
||||||
|
|
||||||
|
(t/deftest deleting-a-closed-seam-node-heals-its-adjacent-segments
|
||||||
|
(let [id (random-uuid)
|
||||||
|
content (path/content
|
||||||
|
[{:command :move-to :params {:x 0 :y 0}}
|
||||||
|
{:command :line-to :params {:x 10 :y 0}}
|
||||||
|
{:command :line-to :params {:x 10 :y 10}}
|
||||||
|
{:command :line-to :params {:x 0 :y 10}}
|
||||||
|
{:command :line-to :params {:x 0 :y 0}}])
|
||||||
|
state (pth/selectable-path-state id content
|
||||||
|
{:nodes #{0} :segments #{} :handlers #{}})
|
||||||
|
events (atom [])
|
||||||
|
_ (->> (ptk/watch (path.tools/delete-selected) state nil)
|
||||||
|
(rx/subs! #(swap! events conj %)))
|
||||||
|
state' (ptk/update (first @events) state)
|
||||||
|
content' (vec (get-in state' [:workspace-drawing :object :content]))]
|
||||||
|
(t/is (= [:move-to :line-to :line-to :curve-to]
|
||||||
|
(mapv :command content')))
|
||||||
|
(t/is (= (gpt/point 10 0) (path.helpers/node-position content' 0)))
|
||||||
|
(t/is (= (gpt/point 10 0) (path.helpers/node-position content' 3)))))
|
||||||
|
|
||||||
|
(t/deftest deleting-a-touching-subpath-seam-heals-before-exiting-edition
|
||||||
|
(let [id (random-uuid)
|
||||||
|
content (path/content
|
||||||
|
[{:command :move-to :params {:x 0 :y 10}}
|
||||||
|
{:command :line-to :params {:x -10 :y 7}}
|
||||||
|
{:command :line-to :params {:x -10 :y 3}}
|
||||||
|
{:command :line-to :params {:x 0 :y 0}}
|
||||||
|
{:command :move-to :params {:x 0 :y 0}}
|
||||||
|
{:command :line-to :params {:x 10 :y 3}}
|
||||||
|
{:command :line-to :params {:x 10 :y 7}}
|
||||||
|
{:command :line-to :params {:x 0 :y 10}}])
|
||||||
|
state (pth/selectable-path-state id content
|
||||||
|
{:nodes #{0 7} :segments #{} :handlers #{}})
|
||||||
|
events (atom [])
|
||||||
|
_ (->> (ptk/watch (path.tools/delete-selected) state nil)
|
||||||
|
(rx/subs! #(swap! events conj %)))
|
||||||
|
state' (ptk/update (first @events) state)
|
||||||
|
content' (vec (get-in state' [:workspace-drawing :object :content]))]
|
||||||
|
(t/is (= [:move-to :line-to :line-to :line-to :line-to :curve-to]
|
||||||
|
(mapv :command content')))
|
||||||
|
(t/is (= (gpt/point -10 7) (path.helpers/node-position content' 0)))
|
||||||
|
(t/is (= (gpt/point -10 7) (path.helpers/node-position content' 5)))))
|
||||||
|
|
||||||
|
(t/deftest delete-selected-with-segments-opens-a-gap-around-the-node
|
||||||
|
(let [id (random-uuid)
|
||||||
|
content (path/content
|
||||||
|
[{:command :move-to :params {:x 0 :y 0}}
|
||||||
|
{:command :line-to :params {:x 10 :y 0}}
|
||||||
|
{:command :line-to :params {:x 20 :y 0}}
|
||||||
|
{:command :line-to :params {:x 30 :y 0}}
|
||||||
|
{:command :line-to :params {:x 40 :y 0}}])
|
||||||
|
state (pth/selectable-path-state id content {:nodes #{2} :segments #{} :handlers #{}})
|
||||||
|
events (atom [])
|
||||||
|
_ (->> (ptk/watch (path.tools/delete-selected-with-segments) state nil)
|
||||||
|
(rx/subs! #(swap! events conj %)))
|
||||||
|
state' (ptk/update (first @events) state)
|
||||||
|
content' (get-in state' [:workspace-drawing :object :content])
|
||||||
|
move-tos (->> content' vec (filter #(= :move-to (:command %))) count)
|
||||||
|
nodes (count (path/get-points content'))]
|
||||||
|
;; Removing incident segments opens a gap around the node.
|
||||||
|
(t/is (= 4 nodes))
|
||||||
|
(t/is (= 2 move-tos))))
|
||||||
|
|
||||||
|
(t/deftest group-handler-drag-ignores-stale-handler-identities
|
||||||
|
(let [id (random-uuid)
|
||||||
|
content (path/content
|
||||||
|
[{:command :move-to
|
||||||
|
:params {:x 0 :y 0}}
|
||||||
|
{:command :line-to
|
||||||
|
:params {:x 10 :y 0}}
|
||||||
|
{:command :curve-to
|
||||||
|
:params {:c1x 12 :c1y 0
|
||||||
|
:c2x 18 :c2y 0
|
||||||
|
:x 20 :y 0}}])
|
||||||
|
selection {:nodes #{}
|
||||||
|
:segments #{}
|
||||||
|
;; [1 :c1] points to a line-to and [9 :c2] is out of range
|
||||||
|
:handlers #{[1 :c1] [2 :c1] [9 :c2]}}
|
||||||
|
state (pth/selectable-path-state id content selection)
|
||||||
|
event (path.edition/modify-selected-handlers
|
||||||
|
id [2 :c1] {} 3 4 :smart true)
|
||||||
|
state' (ptk/update event state)
|
||||||
|
modifiers (get-in state' [:workspace-local :edit-path id :content-modifiers])]
|
||||||
|
(t/is (= {:c1x 3 :c1y 4} (get modifiers 2)))
|
||||||
|
(t/is (nil? (get modifiers 1)))
|
||||||
|
(t/is (nil? (get modifiers 9)))))
|
||||||
|
|
||||||
|
(t/deftest handler-drag-smart-keeps-a-smooth-node-smooth
|
||||||
|
(let [id (random-uuid)
|
||||||
|
content (pth/selectable-path-content)
|
||||||
|
state (pth/selectable-path-state id content {:nodes #{} :segments #{}
|
||||||
|
:handlers #{[2 :c1]}})
|
||||||
|
;; Smart mode keeps the handles aligned.
|
||||||
|
event (path.edition/modify-selected-handlers id [2 :c1] {} 0 4 :smart true)
|
||||||
|
state' (ptk/update event state)
|
||||||
|
modifiers (get-in state' [:workspace-local :edit-path id :content-modifiers])
|
||||||
|
content' (path/apply-content-modifiers content modifiers)]
|
||||||
|
(t/is (= (gpt/point 12 4) (path/get-handler-point content' 2 :c1)))
|
||||||
|
;; the opposite rotated to stay collinear -> still a smooth node
|
||||||
|
(t/is (not= (gpt/point 8 0) (path/get-handler-point content' 1 :c2)))
|
||||||
|
(t/is (path.helpers/handlers-joined? content' 2 :c1))))
|
||||||
|
|
||||||
|
(t/deftest handler-drag-independent-breaks-a-smooth-node
|
||||||
|
(let [id (random-uuid)
|
||||||
|
content (pth/selectable-path-content)
|
||||||
|
state (pth/selectable-path-state id content {:nodes #{} :segments #{}
|
||||||
|
:handlers #{[2 :c1]}})
|
||||||
|
;; Independent mode leaves the opposite handle in place.
|
||||||
|
event (path.edition/modify-selected-handlers id [2 :c1] {} 0 4 :independent false)
|
||||||
|
state' (ptk/update event state)
|
||||||
|
modifiers (get-in state' [:workspace-local :edit-path id :content-modifiers])
|
||||||
|
content' (path/apply-content-modifiers content modifiers)]
|
||||||
|
(t/is (= (gpt/point 12 4) (path/get-handler-point content' 2 :c1)))
|
||||||
|
(t/is (= (gpt/point 8 0) (path/get-handler-point content' 1 :c2)))
|
||||||
|
(t/is (not (path.helpers/handlers-joined? content' 2 :c1)))))
|
||||||
|
|
||||||
|
(t/deftest handler-drag-mirror-rejoins-a-corner-node
|
||||||
|
(let [id (random-uuid)
|
||||||
|
content (pth/corner-path-content)
|
||||||
|
state (pth/selectable-path-state id content {:nodes #{} :segments #{}
|
||||||
|
:handlers #{[2 :c1]}})
|
||||||
|
;; Mirror mode matches the opposite handle's angle and length.
|
||||||
|
event (path.edition/modify-selected-handlers id [2 :c1] {} 2 -6 :mirror false)
|
||||||
|
state' (ptk/update event state)
|
||||||
|
modifiers (get-in state' [:workspace-local :edit-path id :content-modifiers])
|
||||||
|
content' (path/apply-content-modifiers content modifiers)]
|
||||||
|
(t/is (= (gpt/point 14 0) (path/get-handler-point content' 2 :c1)))
|
||||||
|
(t/is (= (gpt/point 6 0) (path/get-handler-point content' 1 :c2)))
|
||||||
|
(t/is (path.helpers/handlers-joined? content' 2 :c1))))
|
||||||
|
|
||||||
|
(t/deftest handler-drag-aligned-mirrors-angle-keeping-length
|
||||||
|
(let [id (random-uuid)
|
||||||
|
content (pth/corner-path-content)
|
||||||
|
state (pth/selectable-path-state id content {:nodes #{} :segments #{}
|
||||||
|
:handlers #{}})
|
||||||
|
;; Aligned mode matches the angle and keeps the opposite length.
|
||||||
|
event (path.edition/modify-selected-handlers id [2 :c1] {} -2 -2 :aligned false)
|
||||||
|
state' (ptk/update event state)
|
||||||
|
modifiers (get-in state' [:workspace-local :edit-path id :content-modifiers])
|
||||||
|
content' (path/apply-content-modifiers content modifiers)]
|
||||||
|
(t/is (= (gpt/point 10 4) (path/get-handler-point content' 2 :c1)))
|
||||||
|
(t/is (= (gpt/point 10 -2) (path/get-handler-point content' 1 :c2)))
|
||||||
|
(t/is (path.helpers/handlers-joined? content' 2 :c1))))
|
||||||
|
|
||||||
|
(t/deftest remove-handler-collapses-the-clicked-handler
|
||||||
|
(let [id (random-uuid)
|
||||||
|
content (pth/selectable-path-content)
|
||||||
|
state (pth/selectable-path-state id content
|
||||||
|
{:nodes #{} :segments #{}
|
||||||
|
:handlers #{[1 :c2]}})
|
||||||
|
state' (ptk/update (path.tools/remove-handler 1 :c2) state)
|
||||||
|
content' (get-in state' [:workspace-drawing :object :content])]
|
||||||
|
;; the clicked handler rests on its node (10,0); the others are untouched
|
||||||
|
(t/is (= (gpt/point 10 0) (path/get-handler-point content' 1 :c2)))
|
||||||
|
(t/is (= (gpt/point 2 0) (path/get-handler-point content' 1 :c1)))
|
||||||
|
(t/is (= (gpt/point 12 0) (path/get-handler-point content' 2 :c1)))))
|
||||||
|
|
||||||
|
(t/deftest toggle-segment-curve-switches-line-and-curve
|
||||||
|
(let [id (random-uuid)
|
||||||
|
content (path/content
|
||||||
|
[{:command :move-to :params {:x 0 :y 0}}
|
||||||
|
{:command :line-to :params {:x 30 :y 0}}])
|
||||||
|
state (pth/selectable-path-state id content path.helpers/empty-selection)
|
||||||
|
state' (ptk/update (path.tools/toggle-segment-curve 1) state)
|
||||||
|
content' (get-in state' [:workspace-drawing :object :content])
|
||||||
|
state'' (ptk/update (path.tools/toggle-segment-curve 1) state')
|
||||||
|
content'' (get-in state'' [:workspace-drawing :object :content])]
|
||||||
|
(t/is (= :curve-to (:command (nth content' 1))))
|
||||||
|
;; handles a third along, offset perpendicular (0.25 * length) into a bow
|
||||||
|
(t/is (= (gpt/point 10 7.5) (path/get-handler-point content' 1 :c1)))
|
||||||
|
(t/is (= (gpt/point 20 7.5) (path/get-handler-point content' 1 :c2)))
|
||||||
|
(t/is (= :line-to (:command (nth content'' 1))))))
|
||||||
|
|
||||||
|
(t/deftest remove-segment-opens-the-path-keeping-nodes
|
||||||
|
(let [id (random-uuid)
|
||||||
|
content (path/content
|
||||||
|
[{:command :move-to :params {:x 0 :y 0}}
|
||||||
|
{:command :line-to :params {:x 10 :y 0}}
|
||||||
|
{:command :line-to :params {:x 20 :y 0}}
|
||||||
|
{:command :line-to :params {:x 30 :y 0}}])
|
||||||
|
state (pth/selectable-path-state id content
|
||||||
|
{:nodes #{} :segments #{2} :handlers #{}})
|
||||||
|
state' (ptk/update (path.tools/remove-segment 2) state)
|
||||||
|
content' (get-in state' [:workspace-drawing :object :content])]
|
||||||
|
(t/is (= [[:move-to {:x 0 :y 0}] [:line-to {:x 10 :y 0}]
|
||||||
|
[:move-to {:x 20 :y 0}] [:line-to {:x 30 :y 0}]]
|
||||||
|
(mapv (juxt :command :params) content')))
|
||||||
|
;; the removed segment's now-stale selection is pruned
|
||||||
|
(t/is (= #{} (get-in state' [:workspace-local :edit-path id :selection :segments])))))
|
||||||
|
|
||||||
|
(t/deftest remove-segment-remaps-handler-types-when-node-indices-shift
|
||||||
|
(let [id (random-uuid)
|
||||||
|
content (path/content
|
||||||
|
[{:command :move-to :params {:x 0 :y 0}}
|
||||||
|
{:command :line-to :params {:x 10 :y 0}}
|
||||||
|
{:command :curve-to
|
||||||
|
:params {:c1x 12 :c1y 4 :c2x 18 :c2y 4 :x 20 :y 0}}
|
||||||
|
{:command :curve-to
|
||||||
|
:params {:c1x 22 :c1y -4 :c2x 28 :c2y -4 :x 30 :y 0}}])
|
||||||
|
state (-> (pth/selectable-path-state
|
||||||
|
id content {:nodes #{2} :segments #{} :handlers #{}})
|
||||||
|
(assoc-in [:workspace-local :edit-path id :handler-types]
|
||||||
|
{2 :aligned}))
|
||||||
|
state' (ptk/update (path.tools/remove-segment 1) state)]
|
||||||
|
;; Remap the selected node after dropping the dangling start.
|
||||||
|
(t/is (= 3 (count (get-in state' [:workspace-drawing :object :content]))))
|
||||||
|
(t/is (= #{1} (get-in state' [:workspace-local :edit-path id :selection :nodes])))
|
||||||
|
;; Keep the mode attached to the surviving node.
|
||||||
|
(t/is (= {1 :aligned}
|
||||||
|
(get-in state' [:workspace-local :edit-path id :handler-types])))))
|
||||||
|
|
||||||
|
(t/deftest removing-an-earlier-node-preserves-a-surviving-mirror-mode
|
||||||
|
(let [id (random-uuid)
|
||||||
|
content (path/content
|
||||||
|
[{:command :move-to :params {:x 0 :y 0}}
|
||||||
|
{:command :curve-to
|
||||||
|
:params {:c1x 3 :c1y 0 :c2x 7 :c2y 0 :x 10 :y 0}}
|
||||||
|
{:command :curve-to
|
||||||
|
:params {:c1x 13 :c1y 0 :c2x 17 :c2y 0 :x 20 :y 0}}
|
||||||
|
{:command :curve-to
|
||||||
|
:params {:c1x 23 :c1y 0 :c2x 28 :c2y 0 :x 30 :y 0}}
|
||||||
|
{:command :curve-to
|
||||||
|
:params {:c1x 34 :c1y 0 :c2x 37 :c2y 0 :x 40 :y 0}}
|
||||||
|
{:command :curve-to
|
||||||
|
:params {:c1x 43 :c1y 0 :c2x 47 :c2y 0 :x 50 :y 0}}])
|
||||||
|
state (-> (pth/selectable-path-state
|
||||||
|
id content {:nodes #{0} :segments #{} :handlers #{}})
|
||||||
|
(assoc-in [:workspace-local :edit-path id :handler-types]
|
||||||
|
{3 :mirror}))
|
||||||
|
state' (ptk/update (path.tools/remove-node) state)]
|
||||||
|
;; Geometry alone derives the fourth node as aligned.
|
||||||
|
(t/is (= :aligned (path.helpers/derive-handler-type content 3)))
|
||||||
|
;; Remap the explicit mode with the surviving node.
|
||||||
|
(t/is (= {2 :mirror}
|
||||||
|
(get-in state' [:workspace-local :edit-path id :handler-types])))))
|
||||||
|
|
||||||
|
(t/deftest remove-node-with-segments-opens-a-gap
|
||||||
|
(let [id (random-uuid)
|
||||||
|
content (path/content
|
||||||
|
[{:command :move-to :params {:x 0 :y 0}}
|
||||||
|
{:command :line-to :params {:x 10 :y 0}}
|
||||||
|
{:command :line-to :params {:x 20 :y 0}}
|
||||||
|
{:command :line-to :params {:x 30 :y 0}}])
|
||||||
|
state (pth/selectable-path-state id content path.helpers/empty-selection)
|
||||||
|
emitted (atom [])
|
||||||
|
_ (->> (ptk/watch (path.tools/remove-node-with-segments 1) state nil)
|
||||||
|
(rx/subs! #(swap! emitted conj %)))
|
||||||
|
state' (reduce #(ptk/update %2 %1) state @emitted)
|
||||||
|
content' (get-in state' [:workspace-drawing :object :content])]
|
||||||
|
;; node (10,0) and both incident segments are gone; the (0,0) start is
|
||||||
|
;; left dangling and dropped too, the rest of the path survives
|
||||||
|
(t/is (= [[:move-to {:x 20 :y 0}] [:line-to {:x 30 :y 0}]]
|
||||||
|
(mapv (juxt :command :params) content')))))
|
||||||
|
|
||||||
|
(t/deftest dragging-a-node-or-segment-onto-another-merges-them
|
||||||
|
(let [id (random-uuid)
|
||||||
|
content (path/content
|
||||||
|
[{:command :move-to :params {:x 0 :y 0}}
|
||||||
|
{:command :line-to :params {:x 10 :y 0}}
|
||||||
|
{:command :move-to :params {:x 12 :y 0}}
|
||||||
|
{:command :line-to :params {:x 30 :y 0}}])
|
||||||
|
mk (fn [selection]
|
||||||
|
(-> (pth/selectable-path-state id content selection)
|
||||||
|
(assoc-in [:workspace-local :zoom] 1)))
|
||||||
|
emit-of (fn [state]
|
||||||
|
(let [out (atom [])]
|
||||||
|
(->> (ptk/watch (path.edition/merge-dragged-on-drop)
|
||||||
|
state (rx/subject))
|
||||||
|
(rx/subs! #(swap! out conj %)))
|
||||||
|
@out))
|
||||||
|
welded [{:x 0 :y 0} {:x 11 :y 0} {:x 30 :y 0}]]
|
||||||
|
(t/testing "a single node dropped within range of another node merges them"
|
||||||
|
(let [state (mk {:nodes #{1} :segments #{} :handlers #{}})
|
||||||
|
events (emit-of state)
|
||||||
|
content' (vec (get-in (ptk/update (first events) state)
|
||||||
|
[:workspace-drawing :object :content]))]
|
||||||
|
(t/is (= 1 (count events)))
|
||||||
|
;; Dropped subpath endpoints weld at their midpoint.
|
||||||
|
(t/is (= welded (mapv :params content')))))
|
||||||
|
(t/testing "a dragged segment whose endpoint lands on a node merges too"
|
||||||
|
;; segment 1 (nodes (0,0)-(10,0)); its (10,0) end is within range of (12,0)
|
||||||
|
(let [state (mk {:nodes #{} :segments #{1} :handlers #{}})
|
||||||
|
events (emit-of state)
|
||||||
|
content' (vec (get-in (ptk/update (first events) state)
|
||||||
|
[:workspace-drawing :object :content]))]
|
||||||
|
(t/is (= 1 (count events)))
|
||||||
|
(t/is (= welded (mapv :params content')))))
|
||||||
|
(t/testing "a node dropped with no neighbour in range does not merge"
|
||||||
|
(t/is (empty? (emit-of (mk {:nodes #{3} :segments #{} :handlers #{}})))))))
|
||||||
|
|
||||||
|
;; Path-local undo and redo events use a seeded local stack.
|
||||||
@ -31,6 +31,11 @@
|
|||||||
[frontend-tests.logic.groups-test]
|
[frontend-tests.logic.groups-test]
|
||||||
[frontend-tests.logic.nudge-selected-shapes-test]
|
[frontend-tests.logic.nudge-selected-shapes-test]
|
||||||
[frontend-tests.logic.pasting-in-containers-test]
|
[frontend-tests.logic.pasting-in-containers-test]
|
||||||
|
[frontend-tests.logic.path-actions-test]
|
||||||
|
[frontend-tests.logic.path-clipboard-test]
|
||||||
|
[frontend-tests.logic.path-helpers-test]
|
||||||
|
[frontend-tests.logic.path-lifecycle-test]
|
||||||
|
[frontend-tests.logic.path-tools-test]
|
||||||
[frontend-tests.main-errors-test]
|
[frontend-tests.main-errors-test]
|
||||||
[frontend-tests.plugins.comments-test]
|
[frontend-tests.plugins.comments-test]
|
||||||
[frontend-tests.plugins.context-shapes-test]
|
[frontend-tests.plugins.context-shapes-test]
|
||||||
@ -118,6 +123,11 @@
|
|||||||
'frontend-tests.logic.frame-guides-test
|
'frontend-tests.logic.frame-guides-test
|
||||||
'frontend-tests.logic.groups-test
|
'frontend-tests.logic.groups-test
|
||||||
'frontend-tests.logic.nudge-selected-shapes-test
|
'frontend-tests.logic.nudge-selected-shapes-test
|
||||||
|
'frontend-tests.logic.path-actions-test
|
||||||
|
'frontend-tests.logic.path-clipboard-test
|
||||||
|
'frontend-tests.logic.path-helpers-test
|
||||||
|
'frontend-tests.logic.path-lifecycle-test
|
||||||
|
'frontend-tests.logic.path-tools-test
|
||||||
'frontend-tests.logic.pasting-in-containers-test
|
'frontend-tests.logic.pasting-in-containers-test
|
||||||
'frontend-tests.main-errors-test
|
'frontend-tests.main-errors-test
|
||||||
'frontend-tests.plugins.comments-test
|
'frontend-tests.plugins.comments-test
|
||||||
|
|||||||
@ -5297,6 +5297,14 @@ msgid "shortcuts.delete-node"
|
|||||||
msgstr "Delete node"
|
msgstr "Delete node"
|
||||||
|
|
||||||
#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:103
|
#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:103
|
||||||
|
msgid "shortcuts.delete-node-and-segments"
|
||||||
|
msgstr "Delete node and segments"
|
||||||
|
|
||||||
|
#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:103
|
||||||
|
msgid "shortcuts.deselect-all"
|
||||||
|
msgstr "Deselect all"
|
||||||
|
|
||||||
|
#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:104
|
||||||
msgid "shortcuts.detach-component"
|
msgid "shortcuts.detach-component"
|
||||||
msgstr "Detach component"
|
msgstr "Detach component"
|
||||||
|
|
||||||
@ -8745,6 +8753,22 @@ msgstr "To corner (%s)"
|
|||||||
msgid "workspace.path.actions.make-curve"
|
msgid "workspace.path.actions.make-curve"
|
||||||
msgstr "To curve (%s)"
|
msgstr "To curve (%s)"
|
||||||
|
|
||||||
|
#: src/app/main/ui/workspace/viewport/path_actions.cljs
|
||||||
|
msgid "workspace.path.actions.handler-type"
|
||||||
|
msgstr "Handler behaviour"
|
||||||
|
|
||||||
|
#: src/app/main/ui/workspace/viewport/path_actions.cljs
|
||||||
|
msgid "workspace.path.actions.handler-mirror"
|
||||||
|
msgstr "Equal"
|
||||||
|
|
||||||
|
#: src/app/main/ui/workspace/viewport/path_actions.cljs
|
||||||
|
msgid "workspace.path.actions.handler-aligned"
|
||||||
|
msgstr "Aligned"
|
||||||
|
|
||||||
|
#: src/app/main/ui/workspace/viewport/path_actions.cljs
|
||||||
|
msgid "workspace.path.actions.handler-independent"
|
||||||
|
msgstr "Independent"
|
||||||
|
|
||||||
#: src/app/main/ui/workspace/viewport/path_actions.cljs:181
|
#: src/app/main/ui/workspace/viewport/path_actions.cljs:181
|
||||||
msgid "workspace.path.actions.merge-nodes"
|
msgid "workspace.path.actions.merge-nodes"
|
||||||
msgstr "Merge nodes (%s)"
|
msgstr "Merge nodes (%s)"
|
||||||
|
|||||||
@ -5164,6 +5164,13 @@ msgid "shortcuts.delete-node"
|
|||||||
msgstr "Borrar nodo"
|
msgstr "Borrar nodo"
|
||||||
|
|
||||||
#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:103
|
#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:103
|
||||||
|
msgid "shortcuts.delete-node-and-segments"
|
||||||
|
msgstr "Borrar nodo y segmentos"
|
||||||
|
|
||||||
|
#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:103
|
||||||
|
msgid "shortcuts.deselect-all"
|
||||||
|
msgstr "Deseleccionar todo"
|
||||||
|
|
||||||
msgid "shortcuts.detach-component"
|
msgid "shortcuts.detach-component"
|
||||||
msgstr "Desvincular componente"
|
msgstr "Desvincular componente"
|
||||||
|
|
||||||
@ -9606,6 +9613,22 @@ msgstr "Comentarios (%s)"
|
|||||||
msgid "workspace.toolbar.curve"
|
msgid "workspace.toolbar.curve"
|
||||||
msgstr "Curva (%s)"
|
msgstr "Curva (%s)"
|
||||||
|
|
||||||
|
#: src/app/main/ui/workspace/viewport/path_actions.cljs
|
||||||
|
msgid "workspace.path.actions.handler-type"
|
||||||
|
msgstr "Comportamiento manejador"
|
||||||
|
|
||||||
|
#: src/app/main/ui/workspace/viewport/path_actions.cljs
|
||||||
|
msgid "workspace.path.actions.handler-mirror"
|
||||||
|
msgstr "Igual"
|
||||||
|
|
||||||
|
#: src/app/main/ui/workspace/viewport/path_actions.cljs
|
||||||
|
msgid "workspace.path.actions.handler-aligned"
|
||||||
|
msgstr "Alineado"
|
||||||
|
|
||||||
|
#: src/app/main/ui/workspace/viewport/path_actions.cljs
|
||||||
|
msgid "workspace.path.actions.handler-independent"
|
||||||
|
msgstr "Independiente"
|
||||||
|
|
||||||
#: src/app/main/ui/workspace/top_toolbar.cljs:231
|
#: src/app/main/ui/workspace/top_toolbar.cljs:231
|
||||||
#, unused
|
#, unused
|
||||||
msgid "workspace.toolbar.debug"
|
msgid "workspace.toolbar.debug"
|
||||||
|
|||||||
@ -1504,7 +1504,6 @@ impl Shape {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let path_transform = self.to_path_transform();
|
let path_transform = self.to_path_transform();
|
||||||
let apply_doc_transform = path_transform.is_some();
|
|
||||||
|
|
||||||
for stroke in self.visible_strokes() {
|
for stroke in self.visible_strokes() {
|
||||||
let Some(stroke_region) = stroke_to_path(
|
let Some(stroke_region) = stroke_to_path(
|
||||||
@ -1517,10 +1516,7 @@ impl Shape {
|
|||||||
) else {
|
) else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
let mut sk = stroke_region.to_skia_path(self.svg_attrs.as_ref());
|
let sk = stroke_region.to_skia_path(self.svg_attrs.as_ref());
|
||||||
if apply_doc_transform {
|
|
||||||
sk = sk.make_transform(&self.shape_document_transform());
|
|
||||||
}
|
|
||||||
acc = acc.op(&sk, skia::PathOp::Union).unwrap_or(acc);
|
acc = acc.op(&sk, skia::PathOp::Union).unwrap_or(acc);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||