penpot/frontend/src/app/worker/selection.cljs
2023-11-16 11:07:36 +01:00

249 lines
9.6 KiB
Clojure

;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC
(ns app.worker.selection
(:require
[app.common.data :as d]
[app.common.files.helpers :as cfh]
[app.common.files.indices :as cfi]
[app.common.geom.point :as gpt]
[app.common.geom.rect :as grc]
[app.common.geom.shapes :as gsh]
[app.common.geom.shapes.text :as gst]
[app.common.types.modifiers :as ctm]
[app.common.uuid :as uuid]
[app.util.quadtree :as qdt]
[app.worker.impl :as impl]
[clojure.set :as set]
[okulary.core :as l]))
;; FIXME: performance shape & rect static props
(def ^:const padding-percent 0.10)
(defonce state (l/atom {}))
(defn make-index-shape
[objects parents-index clip-parents-index]
(fn [index shape]
(let [{:keys [x y width height]}
(cond
(and ^boolean (cfh/text-shape? shape)
^boolean (some? (:position-data shape))
^boolean (d/not-empty? (:position-data shape)))
(gst/shape->bounds shape)
:else
(grc/points->rect (:points shape)))
shape-bound #js {:x x :y y :width width :height height}
parents (get parents-index (:id shape))
clip-parents (get clip-parents-index (:id shape))
frame (when (and (not= :frame (:type shape))
(not= (:frame-id shape) uuid/zero))
(get objects (:frame-id shape)))]
(qdt/insert index
(:id shape)
shape-bound
(assoc shape
:frame frame
:clip-parents clip-parents
:parents parents)))))
(defn objects-bounds
"Calculates the bounds of the quadtree given a objects map."
[objects]
(-> objects
(dissoc uuid/zero)
vals
gsh/shapes->rect))
(defn add-padding-bounds
"Adds a padding to the bounds defined as a percent in the constant `padding-percent`.
For a value of 0.1 will add a 20% width increase (2 x padding)"
[bounds]
(let [width-pad (* (:width bounds) padding-percent)
height-pad (* (:height bounds) padding-percent)]
(-> bounds
(update :x - width-pad)
(update :x1 - width-pad)
(update :x2 + width-pad)
(update :y1 - height-pad)
(update :y2 + height-pad)
(update :width + width-pad width-pad)
(update :height + height-pad height-pad))))
(defn- create-index
[objects]
(let [shapes (-> objects (dissoc uuid/zero) vals)
parents-index (cfi/generate-child-all-parents-index objects)
clip-parents-index (cfi/create-clip-index objects parents-index)
root-shapes (cfh/get-immediate-children objects uuid/zero)
bounds (-> root-shapes gsh/shapes->rect add-padding-bounds)
index-shape (make-index-shape objects parents-index clip-parents-index)
initial-quadtree (qdt/create (clj->js bounds))
index (reduce index-shape initial-quadtree shapes)]
{:index index :bounds bounds}))
(defn- update-index
[{index :index :as data} old-objects new-objects]
(let [changes? (fn [id]
(not= (get old-objects id)
(get new-objects id)))
changed-ids (into #{}
(comp (filter #(not= % uuid/zero))
(filter changes?)
(mapcat #(into [%] (cfh/get-children-ids new-objects %))))
(set/union (set (keys old-objects))
(set (keys new-objects))))
shapes (->> changed-ids (mapv #(get new-objects %)) (filterv (comp not nil?)))
parents-index (cfi/generate-child-all-parents-index new-objects shapes)
clip-parents-index (cfi/create-clip-index new-objects parents-index)
new-index (qdt/remove-all index changed-ids)
index-shape (make-index-shape new-objects parents-index clip-parents-index)
index (reduce index-shape new-index shapes)]
(assoc data :index index)))
(defn- query-index
[{index :index} rect frame-id full-frame? include-frames? ignore-groups? clip-children? using-selrect?]
(let [result (-> (qdt/search index (clj->js rect))
(es6-iterator-seq))
;; Check if the shape matches the filter criteria
match-criteria?
(fn [shape]
(and (not (:hidden shape))
(or (= :frame (:type shape)) ;; We return frames even if blocked
(not (:blocked shape)))
(or (not frame-id) (= frame-id (:frame-id shape)))
(case (:type shape)
:frame include-frames?
(:bool :group) (not ignore-groups?)
true)
(or (not full-frame?)
(not= :frame (:type shape))
(and (d/not-empty? (:shapes shape))
(gsh/rect-contains-shape? rect shape))
(and (empty? (:shapes shape))
(gsh/overlaps? shape rect)))))
overlaps-outer-shape?
(fn [shape]
(let [padding (->> (:strokes shape)
(map #(case (get % :stroke-alignment :center)
:center (:stroke-width % 0)
:outer (* 2 (:stroke-width % 0))
:inner 0))
(reduce d/max 0))
scalev (gpt/point (/ (+ (:width shape) padding)
(:width shape))
(/ (+ (:height shape) padding)
(:height shape)))
outer-shape (-> shape
(gsh/transform-shape (-> (ctm/empty)
(ctm/resize scalev (gsh/shape->center shape)))))]
(gsh/overlaps? outer-shape rect)))
overlaps-inner-shape?
(fn [shape]
(let [padding (->> (:strokes shape)
(map #(case (get % :stroke-alignment :center)
:center (:stroke-width % 0)
:outer 0
:inner (* 2 (:stroke-width % 0))))
(reduce d/max 0))
scalev (gpt/point (/ (- (:width shape) padding)
(:width shape))
(/ (- (:height shape) padding)
(:height shape)))
inner-shape (-> shape
(gsh/transform-shape (-> (ctm/empty)
(ctm/resize scalev (gsh/shape->center shape)))))]
(gsh/overlaps? inner-shape rect)))
overlaps-path?
(fn [shape]
(let [padding (->> (:strokes shape)
(map :stroke-width)
(reduce d/max 0))
rect (grc/center->rect rect padding padding)]
(gsh/overlaps-path? shape rect false)))
overlaps?
(fn [shape]
(if (and (false? using-selrect?) (empty? (:fills shape)))
(case (:type shape)
;; If the shape has no fills the overlap depends on the stroke
:rect (and (overlaps-outer-shape? shape) (not (overlaps-inner-shape? shape)))
:circle (and (overlaps-outer-shape? shape) (not (overlaps-inner-shape? shape)))
:path (overlaps-path? shape)
(gsh/overlaps? shape rect))
(gsh/overlaps? shape rect)))
overlaps-parent?
(fn [clip-parents]
(->> clip-parents (some (comp not overlaps?)) not))]
;; Shapes after filters of overlapping and criteria
(into (d/ordered-set)
(comp (map #(unchecked-get % "data"))
(filter match-criteria?)
(filter overlaps?)
(filter (if clip-children?
(comp overlaps-parent? :clip-parents)
(constantly true)))
(map :id))
result)))
(defmethod impl/handler :selection/initialize-page-index
[{:keys [page] :as message}]
(letfn [(add-page [state {:keys [id objects] :as page}]
(assoc state id (create-index objects)))]
(swap! state add-page page)
nil))
(defmethod impl/handler :selection/update-page-index
[{:keys [page-id old-page new-page] :as message}]
(swap! state update page-id
(fn [index]
(let [old-objects (:objects old-page)
new-objects (:objects new-page)
old-bounds (:bounds index)
new-bounds (objects-bounds new-objects)]
;; If the new bounds are contained within the old bounds
;; we can update the index. Otherwise we need to
;; re-create it.
(if (and (some? index)
(grc/contains-rect? old-bounds new-bounds))
(update-index index old-objects new-objects)
(create-index new-objects)))))
nil)
(defmethod impl/handler :selection/query
[{:keys [page-id rect frame-id full-frame? include-frames? ignore-groups? clip-children? using-selrect?]
:or {full-frame? false include-frames? false clip-children? true using-selrect? false}
:as message}]
(when-let [index (get @state page-id)]
(query-index index rect frame-id full-frame? include-frames? ignore-groups? clip-children? using-selrect?)))