Merge remote-tracking branch 'origin/staging' into develop

This commit is contained in:
Andrey Antukh 2026-10-07 10:25:01 +02:00
commit a5df69f60f
8 changed files with 300 additions and 111 deletions

2
.gitignore vendored
View File

@ -83,6 +83,7 @@ opencode.json
/web
/library/target/
/library/*.zip
/library/.shadow-cljs
/external
/penpot-nitrate
/test-results/
@ -90,6 +91,7 @@ opencode.json
/blob-report/
/playwright/.cache/
/render-wasm/target/
/render-wasm/.render-wasm-build.lock
/media-processor/dist/
/media-processor/target/
/**/node_modules

View File

@ -23,18 +23,18 @@
(if (:deleted component)
(if (nil? (:objects component))
(do
(log/warn :msg "Adding empty :objects to deleted component"
:component-id (:id component)
(log/warn :hint "adding empty :objects to deleted component"
:component-id (str (:id component))
:component-name (:name component)
:file-id (:id file-data))
:file-id (str (:id file-data)))
(assoc component :objects {}))
component)
(if (contains? component :objects)
(do
(log/warn :msg "Removing :objects from non-deleted component"
:component-id (:id component)
(log/warn :hint "removing :objects from non-deleted component"
:component-id (str (:id component))
:component-name (:name component)
:file-id (:id file-data))
:file-id (str (:id file-data)))
(dissoc component :objects))
component)))))
@ -50,15 +50,15 @@
(fn [shape]
(if (false? (:component-root shape))
(do
(log/warn :msg "Normalizing :component-root false on shape"
:shape-id (:id shape)
(log/warn :hint "normalizing :component-root false on shape"
:shape-id (str (:id shape))
:shape-name (:name shape)
:file-id (:id file-data))
:file-id (str (:id file-data)))
{:result :update :updated-shape (dissoc shape :component-root)})
{:result :keep})))
(catch #?(:clj Throwable :cljs :default) e
(log/warn :msg "Failed to normalize :component-root on shapes"
:file-id (:id file-data)
(log/warn :hint "failed to normalize :component-root on shapes"
:file-id (str (:id file-data))
:cause e)
file-data)))
@ -82,19 +82,19 @@
(not= (:shape-ref shape) (:id near-match))
(nil? (ctk/get-swap-slot shape)))
(let [updated-shape (ctk/set-swap-slot shape (:id near-match))]
(log/warn :msg "Adding missing swap slot to shape"
:shape-id (:id shape)
(log/warn :hint "adding missing swap slot to shape"
:shape-id (str (:id shape))
:shape-name (:name shape)
:swap-slot (:id near-match)
:file-id (:id file)
:container-id (:id container)
:swap-slot (str (:id near-match))
:file-id (str (:id file))
:container-id (str (:id container))
:container-type (:type container))
{:result :update :updated-shape updated-shape})
{:result :keep}))
{:result :keep})))
(catch #?(:clj Throwable :cljs :default) e
(log/warn :msg "Failed to fix missing swap slots on shapes"
:file-id (:id file-data)
(log/warn :hint "failed to fix missing swap slots on shapes"
:file-id (str (:id file-data))
:cause e)
file-data)))
@ -127,21 +127,21 @@
(nil? (:component-file ref-shape))
(dissoc :component-file))]
(log/warn :msg "Syncing component id and file with ref shape"
:shape-id (:id shape)
(log/warn :hint "syncing component id and file with ref shape"
:shape-id (str (:id shape))
:shape-name (:name shape)
:component-id (:component-id shape')
:component-file (:component-file shape')
:ref-shape-id (:id ref-shape)
:file-id (:id file)
:container-id (:id container)
:component-id (str (:component-id shape'))
:component-file (str (:component-file shape'))
:ref-shape-id (str (:id ref-shape))
:file-id (str (:id file))
:container-id (str (:id container))
:container-type (:type container))
{:result :update :updated-shape shape'})
{:result :keep}))
{:result :keep})))
(catch #?(:clj Throwable :cljs :default) e
(log/warn :msg "Failed to sync component id and file with ref shape"
:file-id (:id file-data)
(log/warn :hint "failed to sync component id and file with ref shape"
:file-id (str (:id file-data))
:cause e)
file-data)))]
;; If a copy inside a main is updated, we need to repeat the process for the change to be

View File

@ -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 }) => {

View File

@ -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)

View File

@ -112,6 +112,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]
@ -252,6 +253,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

View File

@ -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))))

View File

@ -1,5 +1,9 @@
# CHANGELOG
## 1.2.0
- Update deps and build it with penpot 2.18.3
## 1.2.0-RC1

View File

@ -1,8 +1,8 @@
{
"name": "@penpot/library",
"version": "1.2.0-RC1",
"version": "1.2.0",
"license": "MPL-2.0",
"author": "Kaleidos INC Sucursal en España SL",
"author": "KALEIDOS SUBSIDIARY SL",
"packageManager": "pnpm@12.8.1+sha512.f64ba907507f5ceafe06c8d38e6052d0179444580ec1279ddd5bfc11cb48aa8a2644b66598e07e761da84872a9fc57d5f902b87fa49d024198d558612aabbe45",
"type": "module",
"repository": {