From aedb7f9195320361350ee26bdebe09f20a2a871d Mon Sep 17 00:00:00 2001 From: Jack Storment <88656337+jack-stormentswe@users.noreply.github.com> Date: Wed, 24 Jun 2026 13:32:13 -0600 Subject: [PATCH] :sparkles: Add dedicated Line and Arrow drawing tools (#9146) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * :sparkles: 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 * :lipstick: Fix formatting error Signed-off-by: jack-stormentswe * :bug: Translate line and arrow tooltips in top toolbar Signed-off-by: Luis de Dios * :bug: Add missing namespace Signed-off-by: Luis de Dios * :books: Update copyright notice Signed-off-by: Luis de Dios * Add translations (EN) for toolbar elements Signed-off-by: Luis de Dios * Add translations (ES) for toolbar elements Signed-off-by: Luis de Dios * :recycle: Improve stroke-cap-end update for arrow handling Signed-off-by: Luis de Dios * :bug: 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 * :recycle: Remove unnecessary blank line Signed-off-by: Luis de Dios --------- Signed-off-by: jack-stormentswe Signed-off-by: Jack Storment <88656337+jack-stormentswe@users.noreply.github.com> Signed-off-by: Luis de Dios Co-authored-by: Luis de Dios --- .../src/app/main/data/workspace/drawing.cljs | 5 +- .../app/main/data/workspace/drawing/line.cljs | 153 ++++++++++++++++++ .../app/main/data/workspace/shortcuts.cljs | 10 ++ .../app/main/ui/workspace/top_toolbar.cljs | 15 +- frontend/translations/en.po | 6 + frontend/translations/es.po | 6 + 6 files changed, 188 insertions(+), 7 deletions(-) create mode 100644 frontend/src/app/main/data/workspace/drawing/line.cljs diff --git a/frontend/src/app/main/data/workspace/drawing.cljs b/frontend/src/app/main/data/workspace/drawing.cljs index 03d7545bb8..a2bd2a986e 100644 --- a/frontend/src/app/main/data/workspace/drawing.cljs +++ b/frontend/src/app/main/data/workspace/drawing.cljs @@ -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 diff --git a/frontend/src/app/main/data/workspace/drawing/line.cljs b/frontend/src/app/main/data/workspace/drawing/line.cljs new file mode 100644 index 0000000000..c942a7543c --- /dev/null +++ b/frontend/src/app/main/data/workspace/drawing/line.cljs @@ -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)))))))) diff --git a/frontend/src/app/main/data/workspace/shortcuts.cljs b/frontend/src/app/main/data/workspace/shortcuts.cljs index 5450a6d7a7..26954f8515 100644 --- a/frontend/src/app/main/data/workspace/shortcuts.cljs +++ b/frontend/src/app/main/data/workspace/shortcuts.cljs @@ -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] diff --git a/frontend/src/app/main/ui/workspace/top_toolbar.cljs b/frontend/src/app/main/ui/workspace/top_toolbar.cljs index 8236d21ee3..21893b1ad3 100644 --- a/frontend/src/app/main/ui/workspace/top_toolbar.cljs +++ b/frontend/src/app/main/ui/workspace/top_toolbar.cljs @@ -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} diff --git a/frontend/translations/en.po b/frontend/translations/en.po index fa3f61cae6..0410ccc003 100644 --- a/frontend/translations/en.po +++ b/frontend/translations/en.po @@ -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" diff --git a/frontend/translations/es.po b/frontend/translations/es.po index 6c4a7b6ed0..a24bd2d177 100644 --- a/frontend/translations/es.po +++ b/frontend/translations/es.po @@ -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"