Add dedicated Line and Arrow drawing tools (#9146)

*  Add dedicated Line and Arrow drawing tools

Introduce a Line/Arrow toolbar option and a click-drag drawing
interaction that matches Figma's workflow: select the tool, press and
drag to define the line in one gesture, with Shift snapping to 15°
increments. Arrowhead style can be toggled on either endpoint via the
existing stroke-cap controls.

Signed-off-by: jack-stormentswe <crazycoder131@gmail.com>

* 💄 Fix formatting error

Signed-off-by: jack-stormentswe <crazycoder131@gmail.com>

* 🐛 Translate line and arrow tooltips in top toolbar

Signed-off-by: Luis de Dios <luis.dedios@kaleidos.net>

* 🐛 Add missing namespace

Signed-off-by: Luis de Dios <luis.dedios@kaleidos.net>

* 📚 Update copyright notice

Signed-off-by: Luis de Dios <luis.dedios@kaleidos.net>

* Add translations (EN) for toolbar elements

Signed-off-by: Luis de Dios <luis.dedios@kaleidos.net>

* Add translations (ES) for toolbar elements

Signed-off-by: Luis de Dios <luis.dedios@kaleidos.net>

* ♻️ Improve stroke-cap-end update for arrow handling

Signed-off-by: Luis de Dios <luis.dedios@kaleidos.net>

* 🐛 Fix shortcuts select tool but do not replace it in the toolbar

Refactor tool selection logic in top_toolbar.cljs

Signed-off-by: Luis de Dios <luis.dedios@kaleidos.net>

* ♻️ Remove unnecessary blank line

Signed-off-by: Luis de Dios <luis.dedios@kaleidos.net>

---------

Signed-off-by: jack-stormentswe <crazycoder131@gmail.com>
Signed-off-by: Jack Storment <88656337+jack-stormentswe@users.noreply.github.com>
Signed-off-by: Luis de Dios <luis.dedios@kaleidos.net>
Co-authored-by: Luis de Dios <luis.dedios@kaleidos.net>
This commit is contained in:
Jack Storment 2026-06-24 13:32:13 -06:00 committed by GitHub
parent a6c7bd28e8
commit aedb7f9195
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 188 additions and 7 deletions

View File

@ -14,6 +14,7 @@
[app.main.data.workspace.drawing.box :as box]
[app.main.data.workspace.drawing.common :as common]
[app.main.data.workspace.drawing.curve :as curve]
[app.main.data.workspace.drawing.line :as line]
[app.main.data.workspace.layout :as dwlo]
[app.main.data.workspace.path :as path]
[beicon.v2.core :as rx]
@ -101,8 +102,10 @@
(watch [_ _ _]
(rx/of
(case type
:path (path/handle-drawing)
:path (path/handle-drawing)
:curve (curve/handle-drawing)
:line (line/handle-drawing :line)
:arrow (line/handle-drawing :arrow)
(box/handle-drawing type))))))
(defn change-orientation

View File

@ -0,0 +1,153 @@
;; 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.drawing.line
"Drawing handler for the Line (L) and Arrow (Shift+L) tools.
Unlike the Path (P) tool, which builds a multi-node path via
successive clicks, Line/Arrow uses a single press-and-drag
gesture (matching Figma/Sketch/XD). The result is always a
two-node path from the drag start to the drag end. The Arrow
variant additionally pre-configures an arrow stroke cap at
the end so designers do not have to set it manually."
(:require
[app.common.data.macros :as dm]
[app.common.geom.point :as gpt]
[app.common.geom.rect :as grc]
[app.common.geom.shapes.flex-layout :as gslf]
[app.common.geom.shapes.grid-layout :as gslg]
[app.common.math :as mth]
[app.common.types.container :as ctn]
[app.common.types.path :as path]
[app.common.types.shape :as cts]
[app.common.types.shape-tree :as ctst]
[app.common.types.shape.layout :as ctl]
[app.common.uuid :as uuid]
[app.main.data.helpers :as dsh]
[app.main.data.workspace.drawing.common :as common]
[app.main.streams :as ms]
[app.util.mouse :as mse]
[beicon.v2.core :as rx]
[potok.v2.core :as ptk]))
(def ^:private snap-step-deg 15)
(defn- snap-to-angle
"Snaps `point` relative to `origin` to the nearest multiple of
`snap-step-deg` degrees on a circle of the same radius. Used when
Shift is held while drawing a line."
[origin point]
(let [dx (- (:x point) (:x origin))
dy (- (:y point) (:y origin))
r (mth/sqrt (+ (* dx dx) (* dy dy)))
angle (mth/atan2 dy dx)
step (/ (* mth/PI snap-step-deg) 180)
snapped (* step (mth/round (/ angle step)))]
(gpt/point (+ (:x origin) (* r (mth/cos snapped)))
(+ (:y origin) (* r (mth/sin snapped))))))
(defn- update-endpoint
"Rebuilds the two-node line shape with `start` as the first node
and `end-point` as the second. Called on every drag frame."
[start end-point]
(fn [state]
(update-in state [:workspace-drawing :object]
(fn [object]
(let [points [start end-point]
content (path/points->content points)
selrect (path/calc-selrect content)
points' (grc/rect->points selrect)]
(-> object
(assoc :content content)
(assoc :selrect selrect)
(assoc :points points')))))))
(defn- setup-frame
[]
(ptk/reify ::setup-frame
ptk/UpdateEvent
(update [_ state]
(let [objects (dsh/lookup-page-objects state)
content (dm/get-in state [:workspace-drawing :object :content])
position (path/get-handler-point content 0 nil)
frame-id (->> (ctst/top-nested-frame objects position)
(ctn/get-first-valid-parent objects)
:id)
flex-layout? (ctl/flex-layout? objects frame-id)
grid-layout? (ctl/grid-layout? objects frame-id)
drop-index (when flex-layout? (gslf/get-drop-index frame-id objects position))
drop-cell (when grid-layout? (gslg/get-drop-cell 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}))
(cond-> (some? drop-cell)
(with-meta {:cell drop-cell})))))))))
(defn- finalize
"After mouse-up, mark the shape initialized only if it spans more
than a single point. A zero-length drag is a noop (e.g. the user
clicked without dragging)."
[]
(ptk/reify ::finalize
ptk/UpdateEvent
(update [_ state]
(update-in state [:workspace-drawing :object]
(fn [{:keys [content selrect] :as shape}]
(cond-> shape
(or (empty? content)
(nil? selrect)
(<= (count content) 1))
(assoc :initialized? false)))))))
(defn- arrow-strokes
"Default stroke for the Arrow tool: a visible 1px solid line with
a triangular arrow cap on the end node. The Line tool uses the
same defaults minus the cap."
[arrow?]
(cond-> [{:stroke-color "#000000"
:stroke-opacity 1
:stroke-style :solid
:stroke-alignment :center
:stroke-width 1}]
arrow? (update 0 assoc :stroke-cap-end :triangle-arrow)))
(defn handle-drawing
"Runs the click-and-drag interaction for :line or :arrow. Produces
a two-node path shape with stroke (and an arrow cap when arrow?)."
[tool]
(let [arrow? (= tool :arrow)]
(ptk/reify ::handle-drawing
ptk/WatchEvent
(watch [_ _ stream]
(let [stopper (mse/drag-stopper stream)
start @ms/mouse-position
shape (cts/setup-shape {:type :path
:initialized? true
:frame-id uuid/zero
:parent-id uuid/zero
:strokes (arrow-strokes arrow?)
:fills []
:content (path/points->content [start start])})]
(rx/concat
(rx/of #(update % :workspace-drawing assoc :object shape))
(->> ms/mouse-position
(rx/with-latest-from ms/mouse-position-shift)
(rx/map (fn [[point shift?]]
(if shift?
(snap-to-angle start point)
point)))
(rx/map (partial update-endpoint start))
(rx/take-until stopper))
(rx/of
(setup-frame)
(finalize)
(common/handle-finish-drawing))))))))

View File

@ -313,6 +313,16 @@
:subsections [:tools]
:fn #(emit-when-no-readonly (dwd/select-for-drawing :path))}
:draw-line {:tooltip "L"
:command "l"
:subsections [:tools]
:fn #(emit-when-no-readonly (dwd/select-for-drawing :line))}
:draw-arrow {:tooltip (ds/shift "L")
:command "shift+l"
:subsections [:tools]
:fn #(emit-when-no-readonly (dwd/select-for-drawing :arrow))}
:draw-curve {:tooltip (ds/shift "C")
:command "shift+c"
:subsections [:tools]

View File

@ -47,7 +47,9 @@
(def grouped-tools
{:shapes {:default-tool :rect
:tools {:rect {:icon i/rectangle}
:circle {:icon i/ellipse}}}
:circle {:icon i/ellipse}
:line {:icon i/easing-linear}
:arrow {:icon i/stroke-arrow}}}
:free-draw {:default-tool :path
:tools {:path {:icon i/path}
:curve {:icon i/curve}}}})
@ -59,6 +61,8 @@
:frame (tr "workspace.toolbar.frame" (sc/get-tooltip :draw-frame))
:rect (tr "workspace.toolbar.rect" (sc/get-tooltip :draw-rect))
:circle (tr "workspace.toolbar.ellipse" (sc/get-tooltip :draw-ellipse))
:line (tr "workspace.toolbar.line" (sc/get-tooltip :draw-line))
:arrow (tr "workspace.toolbar.arrow" (sc/get-tooltip :draw-arrow))
:text (tr "workspace.toolbar.text" (sc/get-tooltip :draw-text))
:path (tr "workspace.toolbar.path" (sc/get-tooltip :draw-path))
:image (tr "workspace.toolbar.image" (sc/get-tooltip :insert-image))
@ -109,11 +113,7 @@
on-select-tool
(mf/use-fn
(fn [event]
(let [tool (-> (dom/get-current-target event)
(dom/get-data "tool")
(keyword))]
(reset! default-tool* tool)
(on-select-tool event))))
(on-select-tool event)))
on-display-menu
(mf/use-fn
@ -144,6 +144,9 @@
(cancel-timer! open-timer*)
(cancel-timer! close-timer*)))
(mf/with-effect [drawtool group]
(reset! default-tool* (active-group-tool group drawtool)))
[:li {:class (stl/css :toolbar-group)
:on-pointer-enter on-display-menu
:on-pointer-leave on-hide-menu}

View File

@ -9725,6 +9725,9 @@ msgstr "The value is not valid"
msgid "workspace.tokens.warning-name-change"
msgstr "Renaming this token will break any reference to its old name"
msgid "workspace.toolbar.arrow"
msgstr "Arrow (%s)"
#: src/app/main/ui/workspace/sidebar.cljs:159, src/app/main/ui/workspace/sidebar.cljs:166
msgid "workspace.toolbar.assets"
msgstr "Assets"
@ -9762,6 +9765,9 @@ msgstr "Create board. Click and drag to define its size. (%s)"
msgid "workspace.toolbar.image"
msgstr "Image (%s)"
msgid "workspace.toolbar.line"
msgstr "Line (%s)"
#: src/app/main/ui/workspace/top_toolbar.cljs:65, src/app/main/ui/workspace/top_toolbar.cljs:66, src/app/main/ui/workspace/top_toolbar.cljs:77
msgid "workspace.toolbar.mcp"
msgstr "MCP"

View File

@ -9399,6 +9399,9 @@ msgstr ""
"Cambiar el nombre de este token romperá cualquier referencia a su nombre "
"anterior."
msgid "workspace.toolbar.arrow"
msgstr "Flecha (%s)"
#: src/app/main/ui/workspace/sidebar.cljs:159, src/app/main/ui/workspace/sidebar.cljs:166
msgid "workspace.toolbar.assets"
msgstr "Recursos"
@ -9436,6 +9439,9 @@ msgstr "Crear tablero. Click y arrastrar para definir el tamaño. (%s)"
msgid "workspace.toolbar.image"
msgstr "Imagen (%s)"
msgid "workspace.toolbar.line"
msgstr "Línea (%s)"
#: src/app/main/ui/workspace/top_toolbar.cljs:65, src/app/main/ui/workspace/top_toolbar.cljs:66, src/app/main/ui/workspace/top_toolbar.cljs:77
msgid "workspace.toolbar.mcp"
msgstr "MCP"