diff --git a/frontend/playwright/ui/specs/workspace.spec.js b/frontend/playwright/ui/specs/workspace.spec.js index ee3400079b..051530b877 100644 --- a/frontend/playwright/ui/specs/workspace.spec.js +++ b/frontend/playwright/ui/specs/workspace.spec.js @@ -171,8 +171,8 @@ test("Selection size badge shows dimensions for path shapes", async ({ page }) = // Workaround: hover viewport first to avoid nil mouse position crash await workspacePage.viewport.hover(); - // Draw a path with two segments; a single straight segment shows - // endpoint controls instead of the size badge + // Draw a path with two segments; a single straight segment places + // the size badge along the line instead await workspacePage.pathButton.click(); await workspacePage.clickAt(779, 163); await workspacePage.clickAt(951, 258); @@ -189,7 +189,7 @@ test("Selection size badge shows dimensions for path shapes", async ({ page }) = await expect(badgeText).toHaveText(/\d+\.?\d* x \d+\.?\d*/); }); -test("Selection size badge is hidden for straight line paths", async ({ +test("Selection size badge runs along straight line paths", async ({ page, }) => { const workspacePage = new WasmWorkspacePage(page); @@ -204,7 +204,7 @@ test("Selection size badge is hidden for straight line paths", async ({ // Workaround: hover viewport first to avoid nil mouse position crash await workspacePage.viewport.hover(); - // Draw a path with a single straight segment + // Draw a path with a single straight segment, going down to the right await workspacePage.pathButton.click(); await workspacePage.clickAt(779, 163); await workspacePage.clickAt(951, 258); @@ -216,7 +216,16 @@ test("Selection size badge is hidden for straight line paths", async ({ await page.keyboard.press("Escape"); await expect(page.locator(".line-controls")).toBeVisible(); - await expect(page.locator(".selection-size-badge")).toHaveCount(0); + + // A straight line shows its length, with two decimals at most + const badge = page.locator(".selection-size-badge"); + await expect(badge.locator("text")).toHaveText(/^\d+(\.\d{1,2})?$/); + + // The badge follows the line angle, about 29 degrees + const transform = await badge.getAttribute("transform"); + const rotation = Number(transform.match(/rotate\(([^)]+)\)/)[1]); + expect(rotation).toBeGreaterThan(20); + expect(rotation).toBeLessThan(40); }); test("User makes a group", async ({ page }) => { diff --git a/frontend/src/app/main/ui/measurements.cljs b/frontend/src/app/main/ui/measurements.cljs index b722bf8e1c..17c2d37a28 100644 --- a/frontend/src/app/main/ui/measurements.cljs +++ b/frontend/src/app/main/ui/measurements.cljs @@ -16,6 +16,7 @@ [app.common.math :as mth] [app.common.types.component :as ctk] [app.common.types.path :as path] + [app.common.types.path.helpers :as path.helpers] [app.common.uuid :as uuid] [app.main.constants :as mconst] [app.main.ui.formats :as fmt] @@ -239,28 +240,41 @@ (<= (+ cy (/ badge-height 2)) (+ (:y vbox) (:height vbox))))) -(mf/defc selection-size-badge* - [{:keys [zoom shapes vbox]}] +(defn- line-badge-position + "Badge center and rotation for a badge running along the segment `p1`-`p2`. + The direction is flipped when needed so the text stays upright." + [p1 p2 offset] + (let [dir (gpt/subtract p2 p1) + angle (gpt/angle dir) + upright? (and (> angle -90) (<= angle 90)) + dir (if upright? dir (gpt/negate dir)) + rot (cond + upright? angle + (pos? angle) (- angle 180) + :else (+ angle 180)) + mid-point (gpt/lerp p1 p2 0.5) + normal (gpt/normal-left dir)] + {:cx (+ (:x mid-point) (* (:x normal) offset)) + :cy (+ (:y mid-point) (* (:y normal) offset)) + :rot rot})) + +(defn size-badge-layout + "Geometry of the size badge for the selected `shapes`, in viewport + units. Returns nil when the badge is hidden, otherwise a map with the + badge `:text`, `:width`, `:height`, its center `:cx`/`:cy` and its + rotation `:rot` in degrees." + [shapes zoom vbox] (let [badge-height (/ selection-badge-height zoom) badge-padding-x (/ selection-badge-padding-x zoom) badge-gap (/ selection-badge-vertical-gap zoom) - badge-radius (/ selection-badge-border-radius zoom) badge-char-width (/ selection-badge-char-width zoom) 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 - (ctk/instance-head? single-shape) - (every? ctk/instance-head? shapes)) - badge-bg-color (if component-color? - selection-badge-bg-color-component - selection-badge-bg-color) - rotation (when single-shape (dm/get-prop single-shape :rotation)) has-rotation? (and rotation (not (mth/almost-zero? rotation))) @@ -269,86 +283,101 @@ ;; including component instances and shapes with transforms. selrect (gsh/shapes->rect shapes) - ;; For single shapes we show the original dimensions, - ;; for multiple shapes show the bounding box - shape-width (if single-shape - (or (dm/get-prop single-shape :width) - (:width selrect)) - (:width selrect)) - shape-height (if single-shape - (or (dm/get-prop single-shape :height) - (:height selrect)) - (:height selrect)) + ;; For single shapes we show the same dimensions as the measures + ;; inputs (the shape :selrect, which ignores rotation and also + ;; exists for paths); for multiple shapes show the bounding box + shape-rect (if single-shape + (dm/get-prop single-shape :selrect) + selrect) + shape-width (:width shape-rect) + shape-height (:height shape-rect) - text (dm/str (fmt/format-number shape-width) " x " (fmt/format-number shape-height)) + [line-p1 line-p2] + (when single-line? + (let [content (dm/get-prop single-shape :content)] + [(path.helpers/segment->point (nth content 0)) + (path.helpers/segment->point (nth content 1))])) + + line-length (when single-line? + (gpt/distance line-p1 line-p2)) + + ;; A straight line shows its length (two decimals at most); + ;; other shapes show their width and height. + text (if single-line? + (fmt/format-number line-length) + (dm/str (fmt/format-number shape-width) " x " (fmt/format-number shape-height))) text-width (* (count text) badge-char-width) badge-width (+ text-width (* 2 badge-padding-x)) + offset (+ badge-gap (/ badge-height 2)) - hidden? (or single-line? - (< shape-width badge-width) - (< shape-height badge-height))] + ;; A straight line only has a length; any other shape, paths + ;; included, must fit the badge in both dimensions. + hidden? (if single-line? + (< line-length badge-width) + (or (< shape-width badge-width) + (< shape-height badge-height)))] (when-not ^boolean hidden? - (if has-rotation? - (let [edge (get-edge-for-badge rotation) - points (dm/get-prop single-shape :points) - offset (+ badge-gap (/ badge-height 2)) + (-> (cond + single-line? + (let [position (line-badge-position line-p1 line-p2 offset)] + (if (fits-above-vbox-bottom? (:cy position) badge-height vbox) + position + ;; Same rotation, opposite side of the line. + (let [flipped (line-badge-position line-p1 line-p2 (- offset))] + (assoc flipped :rot (:rot position))))) + has-rotation? + (let [edge (get-edge-for-badge rotation) + points (dm/get-prop single-shape :points) + position (edge-badge-position points edge offset) + position (if (fits-above-vbox-bottom? (:cy position) badge-height vbox) + position + (edge-badge-position points (opposite-edge edge) offset))] ;; Rotation follows the original edge even if the position ;; flips to the opposite one for lack of space, so the text ;; stays upright. - badge-rot (+ rotation (edge-rot-offset edge)) + (assoc position :rot (+ rotation (edge-rot-offset edge)))) - position (edge-badge-position points edge offset) - position (if (fits-above-vbox-bottom? (:cy position) badge-height vbox) - position - (edge-badge-position points (opposite-edge edge) offset)) + :else + (let [below-cy (+ (:y selrect) (:height selrect) offset)] + {:cx (+ (:x selrect) (/ (:width selrect) 2)) + :cy (if (fits-above-vbox-bottom? below-cy badge-height vbox) + below-cy + (- (:y selrect) offset)) + :rot 0})) + (assoc :text text + :width badge-width + :height badge-height))))) - badge-x (- (/ badge-width 2)) - badge-y (- (/ badge-height 2)) - badge-cx (:cx position) - badge-cy (:cy position)] +(mf/defc selection-size-badge* + [{:keys [zoom shapes vbox]}] + (let [single-shape (and (= (count shapes) 1) (first shapes)) + component-color? (if single-shape + (ctk/instance-head? single-shape) + (every? ctk/instance-head? shapes)) + badge-bg-color (if component-color? + selection-badge-bg-color-component + selection-badge-bg-color) + badge-radius (/ selection-badge-border-radius zoom)] - [:g.selection-size-badge {:pointer-events "none" - :transform (dm/str "translate(" badge-cx "," badge-cy ") rotate(" badge-rot ")")} - [:rect {:x badge-x - :y badge-y - :width badge-width - :height badge-height - :rx badge-radius - :ry badge-radius - :style {:fill badge-bg-color}}] - [:text {:class (stl/css :badge-text) - :x 0 - :y 0 - :text-anchor "middle" - :dominant-baseline "middle"} - text]]) - - (let [badge-x (- (/ badge-width 2)) - badge-y (- (/ badge-height 2)) - badge-cx (+ (:x selrect) (/ (:width selrect) 2)) - below-cy (+ (:y selrect) (:height selrect) badge-gap (/ badge-height 2)) - badge-cy (if (fits-above-vbox-bottom? below-cy badge-height vbox) - below-cy - (- (:y selrect) badge-gap (/ badge-height 2)))] - - [:g.selection-size-badge {:pointer-events "none" - :transform (dm/str "translate(" badge-cx "," badge-cy ")")} - [:rect {:x badge-x - :y badge-y - :width badge-width - :height badge-height - :rx badge-radius - :ry badge-radius - :style {:fill badge-bg-color}}] - [:text {:class (stl/css :badge-text) - :x 0 - :y 0 - :text-anchor "middle" - :dominant-baseline "middle"} - text]]))))) + (when-let [{:keys [text width height cx cy rot]} (size-badge-layout shapes zoom vbox)] + [:g.selection-size-badge {:pointer-events "none" + :transform (dm/str "translate(" cx "," cy ") rotate(" rot ")")} + [:rect {:x (- (/ width 2)) + :y (- (/ height 2)) + :width width + :height height + :rx badge-radius + :ry badge-radius + :style {:fill badge-bg-color}}] + [:text {:class (stl/css :badge-text) + :x 0 + :y 0 + :text-anchor "middle" + :dominant-baseline "middle"} + text]]))) (mf/defc distance-display* [{:keys [from to zoom bounds]}] (let [fixed-x (if (gsh/fully-contained? from to) diff --git a/frontend/test/frontend_tests/runner.cljs b/frontend/test/frontend_tests/runner.cljs index 865303404e..7a7da64b1a 100644 --- a/frontend/test/frontend_tests/runner.cljs +++ b/frontend/test/frontend_tests/runner.cljs @@ -108,6 +108,7 @@ [frontend-tests.ui.history-test] [frontend-tests.ui.inspect-stroke-tokens-test] [frontend-tests.ui.layout-container-multiple-test] + [frontend-tests.ui.measurements-test] [frontend-tests.ui.measures-menu-props-test] [frontend-tests.ui.organization-team-switch-test] [frontend-tests.ui.routes-test] @@ -242,6 +243,7 @@ 'frontend-tests.ui.history-test 'frontend-tests.ui.inspect-stroke-tokens-test 'frontend-tests.ui.layout-container-multiple-test + 'frontend-tests.ui.measurements-test 'frontend-tests.ui.measures-menu-props-test 'frontend-tests.ui.organization-team-switch-test 'frontend-tests.ui.routes-test diff --git a/frontend/test/frontend_tests/ui/measurements_test.cljs b/frontend/test/frontend_tests/ui/measurements_test.cljs new file mode 100644 index 0000000000..725e6d2d24 --- /dev/null +++ b/frontend/test/frontend_tests/ui/measurements_test.cljs @@ -0,0 +1,143 @@ +;; 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 SUBSIDIARY SL + +(ns frontend-tests.ui.measurements-test + (:require + [app.common.geom.rect :as grc] + [app.common.math :as mth] + [app.common.types.shape :as cts] + [app.main.ui.measurements :as msr] + [cljs.test :as t :include-macros true])) + +;; At zoom 1 the badge is 16 high, has 6 of horizontal padding, 6.5 per +;; character, and sits 8 away from the shape: its center is 16 away. +;; A flat path selrect has a minimum side of 0.01, which the measures +;; inputs show too. + +(def ^:private big-vbox {:x -1000 :y -1000 :width 5000 :height 5000}) + +(defn- rect + [x y width height] + (cts/setup-shape {:type :rect :x x :y y :width width :height height})) + +(defn- polyline + "A path through `points`, each a [x y] pair." + [& points] + (let [[[x0 y0] & others] points] + (cts/setup-shape + {:type :path + :content (into [{:command :move-to :params {:x x0 :y y0}}] + (map (fn [[x y]] {:command :line-to :params {:x x :y y}})) + others)}))) + +(defn- close-layout? + [expected layout] + (and (some? layout) + (every? (fn [[k v]] + (let [actual (get layout k)] + (if (number? v) + (mth/close? v actual 0.001) + (= v actual)))) + expected))) + +;; --- Plain shapes + +(t/deftest rect-badge-sits-centered-below-the-shape + (let [layout (msr/size-badge-layout [(rect 0 0 100 50)] 1 big-vbox)] + ;; "100 x 50" has 8 characters: 8 * 6.5 + 2 * 6 + (t/is (close-layout? {:text "100 x 50" :width 64 :height 16 + :cx 50 :cy 66 :rot 0} + layout)))) + +(t/deftest rect-narrower-than-the-badge-hides-it + (t/is (nil? (msr/size-badge-layout [(rect 0 0 40 40)] 1 big-vbox)))) + +(t/deftest rect-lower-than-the-badge-hides-it + (t/is (nil? (msr/size-badge-layout [(rect 0 0 200 10)] 1 big-vbox)))) + +(t/deftest badge-size-scales-with-the-zoom + ;; At zoom 2 the badge is 28.75 x 8, so a 40 x 40 rect fits it. + (let [layout (msr/size-badge-layout [(rect 0 0 40 40)] 2 big-vbox)] + (t/is (close-layout? {:text "40 x 40" :width 28.75 :height 8 :cy 48} + layout)))) + +(t/deftest rect-badge-moves-above-at-the-vbox-bottom + (let [vbox {:x 0 :y 0 :width 1000 :height 60} + layout (msr/size-badge-layout [(rect 0 0 100 50)] 1 vbox)] + (t/is (close-layout? {:cx 50 :cy -16 :rot 0} layout)))) + +(t/deftest multiple-shapes-show-the-bounding-box + (let [layout (msr/size-badge-layout [(rect 0 0 100 50) (rect 200 100 100 50)] + 1 big-vbox)] + (t/is (close-layout? {:text "300 x 150" :cx 150 :cy 166} layout)))) + +;; --- Paths + +(t/deftest path-badge-sits-centered-below-the-path + ;; Three points, so not a straight line: it uses the box badge. + (let [layout (msr/size-badge-layout [(polyline [0 0] [100 100] [200 0])] + 1 big-vbox)] + (t/is (close-layout? {:text "200 x 100" :cx 100 :cy 116 :rot 0} + layout)))) + +(t/deftest path-lower-than-the-badge-hides-it + (t/is (nil? (msr/size-badge-layout [(polyline [0 0] [100 10] [200 0])] + 1 big-vbox))) + ;; A flat path, too: its height is the 0.01 minimum. + (t/is (nil? (msr/size-badge-layout [(polyline [0 100] [100 100] [200 100])] + 1 big-vbox)))) + +(t/deftest path-narrower-than-the-badge-hides-it + (t/is (nil? (msr/size-badge-layout [(polyline [0 0] [10 100] [20 0])] + 1 big-vbox)))) + +(t/deftest path-badge-reads-the-selrect-like-the-inputs + ;; The points enclose a larger box than the selrect, as they do on a + ;; rotated path. The text must match the measures inputs. + (let [path (-> (polyline [0 0] [100 50] [200 0]) + (assoc :points (grc/rect->points (grc/make-rect 0 0 300 300)))) + layout (msr/size-badge-layout [path] 1 big-vbox)] + (t/is (= "200 x 50" (:text layout))))) + +;; --- Straight two-point lines + +(t/deftest horizontal-line-badge-runs-below-the-line + (let [layout (msr/size-badge-layout [(polyline [0 50] [200 50])] 1 big-vbox)] + (t/is (close-layout? {:text "200" :cx 100 :cy 66 :rot 0} layout)))) + +(t/deftest line-drawn-right-to-left-keeps-the-text-upright + (let [layout (msr/size-badge-layout [(polyline [200 50] [0 50])] 1 big-vbox)] + (t/is (close-layout? {:cx 100 :cy 66 :rot 0} layout)))) + +(t/deftest vertical-line-badge-runs-along-the-line + (let [down (msr/size-badge-layout [(polyline [0 0] [0 200])] 1 big-vbox) + up (msr/size-badge-layout [(polyline [0 200] [0 0])] 1 big-vbox)] + (t/is (close-layout? {:text "200" :cx -16 :cy 100 :rot 90} down)) + (t/is (close-layout? {:cx -16 :cy 100 :rot 90} up)))) + +(t/deftest diagonal-line-badge-follows-the-line-angle + (let [layout (msr/size-badge-layout [(polyline [0 0] [100 100])] 1 big-vbox) + offset (/ 16 (mth/sqrt 2))] + (t/is (close-layout? {:text "141.42" :cx (- 50 offset) :cy (+ 50 offset) :rot 45} + layout)))) + +(t/deftest line-shorter-than-the-badge-hides-it + ;; "20" needs a 25 wide badge. + (t/is (nil? (msr/size-badge-layout [(polyline [0 0] [20 0])] 1 big-vbox)))) + +(t/deftest line-badge-shows-the-length-with-two-decimals-at-most + (t/is (= "70.71" (:text (msr/size-badge-layout [(polyline [0 0] [50 50])] 1 big-vbox)))) + (t/is (= "50" (:text (msr/size-badge-layout [(polyline [0 0] [30 40])] 1 big-vbox))))) + +(t/deftest diagonal-line-uses-its-length-to-fit-the-badge + ;; Both sides (33) are narrower than the badge for "46.67" (44.5), but + ;; the line is about 46.67 long, so the badge fits along it. + (t/is (some? (msr/size-badge-layout [(polyline [0 0] [33 33])] 1 big-vbox)))) + +(t/deftest line-badge-moves-to-the-other-side-at-the-vbox-bottom + (let [vbox {:x 0 :y 0 :width 1000 :height 60} + layout (msr/size-badge-layout [(polyline [0 50] [200 50])] 1 vbox)] + (t/is (close-layout? {:cx 100 :cy 34 :rot 0} layout))))