Andrey Antukh f8c04949e1
🐛 Fix nil path content crash by exposing safe public API (#8806)
* 🐛 Fix nil path content crash by exposing safe public API

Move nil-safety for path segment helpers to the public API layer
(app.common.types.path) rather than the low-level segment namespace.
Add nil-safe wrappers for get-handlers, opposite-index, get-handler-point,
get-handler, handler->node, point-indices, handler-indices, next-node,
append-segment, points->content, closest-point, make-corner-point,
make-curve-point, split-segments, remove-nodes, merge-nodes, join-nodes,
and separate-nodes. Update all frontend callers to use path/ instead of
path.segment/ for these functions, removing the path.segment require
from helpers, drawing, edition, tools, curve, editor and debug.

Replace ad-hoc nil checks with impl/path-data coercion in all public
wrapper functions in app.common.types.path. The path-data helper
already handles nil by returning an empty PathData instance, which
provides uniform nil-safety across all content-accepting functions.

Update the path-get-points-nil-safe test to expect empty collection
instead of nil, matching the new coercion behavior.

* ♻️ Clean up path segment dead code and add missing tests

Remove dead code from segment.cljc: opposite-handler (duplicate of
calculate-opposite-handler) and path-closest-point-accuracy (unused
constant). Make update-handler and calculate-extremities private as
they are only used internally within segment.cljc.

Add missing tests for path/handler-indices, path/closest-point,
path/make-curve-point and path/merge-nodes. Update extremities tests
to use the local reference implementation instead of the now-private
calculate-extremities. Remove tests for deleted/privatized functions.

Add empty-content guard in path/closest-point wrapper to prevent
ArityException when reducing over zero segments.
2026-04-07 18:54:14 +02:00

430 lines
15 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.main.data.workspace.path.drawing
(:require
[app.common.data :as d]
[app.common.data.macros :as dm]
[app.common.geom.point :as gpt]
[app.common.geom.shapes.flex-layout :as gsl]
[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-tree :as ctst]
[app.common.types.shape.layout :as ctl]
[app.main.data.helpers :as dsh]
[app.main.data.workspace.drawing.common :as dwdc]
[app.main.data.workspace.edition :as dwe]
[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.helpers :as helpers]
[app.main.data.workspace.path.state :as st]
[app.main.data.workspace.path.streams :as streams]
[app.main.data.workspace.path.undo :as undo]
[app.main.data.workspace.shapes :as dwsh]
[app.util.mouse :as mse]
[beicon.v2.core :as rx]
[potok.v2.core :as ptk]))
(declare close-path-drag-end)
(declare check-changed-content)
(declare change-edit-mode)
(defn- end-path-event?
[event]
(let [type (ptk/type event)]
(or
(= type ::common/finish-path)
(= type :app.main.data.workspace.path.shortcuts/esc-pressed)
(= type :app.main.data.workspace.common/clear-edition-mode)
(= type :app.main.data.workspace.edition/clear-edition-mode)
(= type ::dwpg/finalize-page)
(= event :interrupt) ;; ESC
(and ^boolean (mse/mouse-event? event)
^boolean (mse/mouse-double-click-event? event)))))
(defn preview-next-point
[{:keys [x y shift?]}]
(ptk/reify ::preview-next-point
ptk/UpdateEvent
(update [_ state]
(let [id (st/get-path-id state)
fix-angle? shift?
last-point (get-in state [:workspace-local :edit-path id :last-point])
position (cond-> (gpt/point x y)
fix-angle? (path.helpers/position-fixed-angle last-point))
shape (st/get-path state)
{:keys [last-point prev-handler]}
(get-in state [:workspace-local :edit-path id])
segment (path/next-node shape position last-point prev-handler)]
(assoc-in state [:workspace-local :edit-path id :preview] segment)))))
(defn add-node
[{:keys [x y shift?]}]
(ptk/reify ::add-node
ptk/UpdateEvent
(update [_ state]
(let [id (st/get-path-id state)
fix-angle? shift?
{:keys [last-point prev-handler]} (get-in state [:workspace-local :edit-path id])
position (cond-> (gpt/point x y)
fix-angle? (path.helpers/position-fixed-angle last-point))]
(if-not (= last-point position)
(-> state
(assoc-in [:workspace-local :edit-path id :last-point] position)
(update-in [:workspace-local :edit-path id] dissoc :prev-handler)
(update-in [:workspace-local :edit-path id] dissoc :preview)
(update-in (st/get-path-location state) helpers/append-node position last-point prev-handler))
state)))))
(defn drag-handler
([position]
(drag-handler nil nil :c1 position))
([position index prefix {:keys [x y alt? shift?]}]
(ptk/reify ::drag-handler
ptk/UpdateEvent
(update [_ state]
(let [id (st/get-path-id state)
content (st/get-path state :content)
index (or index (count content))
prefix (or prefix :c1)
position (or position (path.helpers/segment->point (nth content (dec index))))
old-handler (path/get-handler-point content index prefix)
handler-position (cond-> (gpt/point x y)
shift? (path.helpers/position-fixed-angle position))
{dx :x dy :y} (if (some? old-handler)
(gpt/add (gpt/to-vec old-handler position)
(gpt/to-vec position handler-position))
(gpt/to-vec position handler-position))
match-opposite? (not alt?)
modifiers (helpers/move-handler-modifiers content index prefix match-opposite? match-opposite? dx dy)]
(-> state
(update-in [:workspace-local :edit-path id :content-modifiers] merge modifiers)
(assoc-in [:workspace-local :edit-path id :drag-handler] handler-position)))))))
(defn finish-drag []
(ptk/reify ::finish-drag
ptk/UpdateEvent
(update [_ state]
(let [id (st/get-path-id state)
modifiers (get-in state [:workspace-local :edit-path id :content-modifiers])
content (-> (st/get-path state :content)
(path/apply-content-modifiers modifiers))
handler (get-in state [:workspace-local :edit-path id :drag-handler])]
(-> state
(st/set-content content)
(update-in [:workspace-local :edit-path id] dissoc :drag-handler)
(update-in [:workspace-local :edit-path id] dissoc :content-modifiers)
(assoc-in [:workspace-local :edit-path id :prev-handler] handler)
(update-in (st/get-path-location state) path/update-geometry))))
ptk/WatchEvent
(watch [_ state _]
(let [id (st/get-path-id state)
handler (get-in state [:workspace-local :edit-path id :prev-handler])]
;; Update the preview because can be outdated after the dragging
(rx/of (preview-next-point handler)
(undo/merge-head))))))
(defn close-path-drag-start
[position]
(ptk/reify ::close-path-drag-start
ptk/WatchEvent
(watch [_ state stream]
(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
drag-events-stream
(rx/of (finish-drag))
(rx/of (close-path-drag-end))))
(rx/of (common/finish-path)))))))
(defn close-path-drag-end []
(ptk/reify ::close-path-drag-end
ptk/UpdateEvent
(update [_ state]
(let [id (st/get-path-id state)]
(update-in state [:workspace-local :edit-path id] dissoc :prev-handler)))))
(defn start-path-from-point [position]
(ptk/reify ::start-path-from-point
ptk/WatchEvent
(watch [_ state stream]
(let [stopper (rx/merge
(mse/drag-stopper stream)
(rx/filter end-path-event? stream))
drag-events (->> (streams/position-stream state)
(rx/map #(drag-handler %))
(rx/take-until stopper))]
(rx/concat
(rx/of (add-node position))
(streams/drag-stream
(rx/concat
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
[state stream down-event]
(assert (gpt/point? down-event)
"should be a point instance")
(let [stopper (rx/merge
(mse/drag-stopper stream)
(rx/filter end-path-event? stream))
drag-events
(->> (streams/position-stream state)
(rx/map #(drag-handler %))
(rx/take-until stopper))]
(rx/concat
(rx/of (add-node down-event))
(streams/drag-stream
(rx/concat
drag-events
(rx/of (finish-drag)))))))
(defn- start-edition
[_id]
(ptk/reify ::start-edition
ptk/UpdateEvent
(update [_ state]
(let [id (st/get-path-id state)]
(assoc-in state [:workspace-local :edit-path id :edit-mode] :draw)))
ptk/WatchEvent
(watch [_ state stream]
(let [mouse-down
(->> stream
(rx/filter mse/mouse-event?)
(rx/filter mse/mouse-down-event?))
end-stream
(->> stream
(rx/filter end-path-event?)
(rx/share))
stoper-stream
(->> stream
(rx/filter (ptk/type? ::start-edition))
(rx/merge end-stream))
;; Mouse move preview
mousemove-events
(->> (streams/position-stream state)
(rx/map #(preview-next-point %)))
;; From mouse down we can have: click, drag and double click
mousedown-events
(->> mouse-down
;; We just ignore the mouse event and stream down the
;; last position event
(rx/with-latest-from #(-> %2) (streams/position-stream state))
;; We change to the stream that emits the first event
(rx/switch-map
#(rx/race (make-node-events-stream stream)
(make-drag-stream state stream %)))
(rx/take-until end-stream))]
(->> (rx/concat
(rx/of (undo/start-path-undo))
(->> (rx/merge mousemove-events
mousedown-events)
(rx/take-until stoper-stream))
(rx/of (ptk/data-event ::end-edition))))))))
(defn setup-frame
[]
(ptk/reify ::setup-frame
ptk/UpdateEvent
(update [_ state]
(let [objects (dsh/lookup-page-objects state)
content (get-in state [:workspace-drawing :object :content] [])
;; FIXME: use native operation for retrieve the first position
position (-> (nth content 0)
(get :params)
(gpt/point))
frame-id (->> (ctst/top-nested-frame objects position)
(ctn/get-first-valid-parent objects) ;; We don't want to change the structure of component copies
:id)
flex-layout? (ctl/flex-layout? objects frame-id)
drop-index (when flex-layout? (gsl/get-drop-index frame-id objects position))]
(update-in state [:workspace-drawing :object]
(fn [object]
(-> object
(assoc :frame-id frame-id)
(assoc :parent-id frame-id)
(cond-> (some? drop-index)
(with-meta {:index drop-index})))))))))
(defn- handle-drawing-end
[shape-id]
(ptk/reify ::handle-drawing-end
ptk/UpdateEvent
(update [_ state]
(let [content (some-> (dm/get-in state [:workspace-drawing :object :content])
(path/check-content))]
(if (> (count content) 1)
(assoc-in state [:workspace-drawing :object :initialized?] true)
state)))
ptk/WatchEvent
(watch [_ state _]
(when-let [content (dm/get-in state [:workspace-drawing :object :content])]
(if (> (count content) 1)
(rx/of (setup-frame)
(dwdc/handle-finish-drawing)
(dwe/start-edition-mode shape-id)
(change-edit-mode :draw))
(rx/of (dwdc/handle-finish-drawing)))))))
(defn handle-drawing
"Hanndle the start of drawing new path shape"
[]
(ptk/reify ::handle-new-shape
ptk/UpdateEvent
(update [_ state]
(let [shape (cts/setup-shape {:type :path})]
(update state :workspace-drawing assoc :object shape)))
ptk/WatchEvent
(watch [_ state stream]
(let [shape-id (dm/get-in state [:workspace-drawing :object :id])]
(rx/concat
(rx/of (start-edition shape-id))
(->> stream
(rx/filter (ptk/type? ::end-edition))
(rx/take 1)
(rx/observe-on :async)
(rx/map (partial handle-drawing-end shape-id))))))))
(declare start-draw-mode*)
(defn start-draw-mode
[]
(ptk/reify ::start-draw-mode
ptk/UpdateEvent
(update [_ state]
(let [id (dm/get-in state [:workspace-local :edition])
objects (dsh/lookup-page-objects state)
content (dm/get-in objects [id :content])]
(if content
(update-in state [:workspace-local :edit-path id] assoc :old-content content)
state)))
ptk/WatchEvent
(watch [_ _ _]
(rx/of (start-draw-mode*)))))
(defn start-draw-mode*
[]
(ptk/reify ::start-draw-mode*
ptk/WatchEvent
(watch [_ state stream]
(let [local (get state :workspace-local)
id (get local :edition)
mode (dm/get-in local [:edit-path id :edit-mode])]
(if (= :draw mode)
(rx/concat
(rx/of (dwsh/update-shapes [id] path/convert-to-path))
(rx/of (start-edition id))
(->> stream
(rx/filter (ptk/type? ::end-edition))
(rx/take 1)
(rx/mapcat (fn [_]
(rx/of (check-changed-content)
(start-draw-mode*))))))
(rx/empty))))))
(defn change-edit-mode
[mode]
(ptk/reify ::change-edit-mode
ptk/UpdateEvent
(update [_ state]
(if-let [id (dm/get-in state [:workspace-local :edition])]
(d/update-in-when state [:workspace-local :edit-path id] assoc :edit-mode mode)
state))
ptk/WatchEvent
(watch [_ state _]
(when-let [id (dm/get-in state [:workspace-local :edition])]
(let [mode (dm/get-in state [:workspace-local :edit-path id :edit-mode])]
(case mode
:move (rx/of (common/finish-path))
:draw (rx/of (start-draw-mode))
(rx/empty)))))))
(defn reset-last-handler
[]
(ptk/reify ::reset-last-handler
ptk/UpdateEvent
(update [_ state]
(let [id (st/get-path-id state)]
(assoc-in state [:workspace-local :edit-path id :prev-handler] nil)))))
(defn check-changed-content
[]
(ptk/reify ::check-changed-content
ptk/WatchEvent
(watch [_ state _]
(let [id (st/get-path-id state)
content (st/get-path state :content)
old-content (get-in state [:workspace-local :edit-path id :old-content])
mode (get-in state [:workspace-local :edit-path id :edit-mode])
empty-content? (empty? content)]
(cond
(and (not= content old-content) (not empty-content?))
(rx/of (changes/save-path-content))
(= mode :draw)
(rx/of :interrupt)
:else
(rx/of
(common/finish-path)
(dwdc/clear-drawing)))))))