diff --git a/common/src/app/common/geom/shapes/bounds.cljc b/common/src/app/common/geom/shapes/bounds.cljc index 3da3a1eef8..95b215c686 100644 --- a/common/src/app/common/geom/shapes/bounds.cljc +++ b/common/src/app/common/geom/shapes/bounds.cljc @@ -89,14 +89,23 @@ ([shape] (get-shape-filter-bounds shape false)) ([shape ignore-shadow-margin?] - (if (or (and (cfh/svg-raw-shape? shape) - (not= :svg (dm/get-in shape [:content :tag]))) - ;; If no shadows or blur, we return the selrect as is - (and (empty? (-> shape :shadow)) - (or (nil? (:blur shape)) - (not= :layer-blur (-> shape :blur :type)) - (zero? (-> shape :blur :value (or 0)))))) + (cond + ;; SVG raw elements (non-root) don't have proper rotated points; use selrect + (and (cfh/svg-raw-shape? shape) + (not= :svg (dm/get-in shape [:content :tag]))) (dm/get-prop shape :selrect) + + ;; No shadows or blur: use the axis-aligned bounding box from the actual + ;; (possibly rotated) points. Using selrect here would be wrong for rotated + ;; shapes because selrect stores the unrotated rectangle, not the screen-space bbox. + (and (empty? (-> shape :shadow)) + (or (nil? (:blur shape)) + (not= :layer-blur (-> shape :blur :type)) + (zero? (-> shape :blur :value (or 0))))) + (-> (dm/get-prop shape :points) + (grc/points->rect)) + + :else (let [filters (shape->filters shape) blur-value (case (-> shape :blur :type) :layer-blur (or (-> shape :blur :value) 0) diff --git a/frontend/playwright/data/viewer/get-file-fragment-rotated-board-stroke.json b/frontend/playwright/data/viewer/get-file-fragment-rotated-board-stroke.json new file mode 100644 index 0000000000..af47e655bb --- /dev/null +++ b/frontend/playwright/data/viewer/get-file-fragment-rotated-board-stroke.json @@ -0,0 +1,195 @@ +{ + "~:id": "~uaa5cc0bb-91ff-81b9-8004-77dfae2d9e7c", + "~:file-id": "~uaa5cc0bb-91ff-81b9-8004-77df9cd3edb1", + "~:created-at": "~m1717759268004", + "~:data": { + "~:options": {}, + "~:objects": { + "~u00000000-0000-0000-0000-000000000000": { + "~#shape": { + "~:y": 0, + "~:hide-fill-on-export": false, + "~:transform": { + "~#matrix": { + "~:a": 1.0, + "~:b": 0.0, + "~:c": 0.0, + "~:d": 1.0, + "~:e": 0.0, + "~:f": 0.0 + } + }, + "~:rotation": 0, + "~:name": "Root Frame", + "~:width": 0.01, + "~:type": "~:frame", + "~:points": [ + { + "~#point": { + "~:x": 0, + "~:y": 0 + } + }, + { + "~#point": { + "~:x": 0.01, + "~:y": 0 + } + }, + { + "~#point": { + "~:x": 0.01, + "~:y": 0.01 + } + }, + { + "~#point": { + "~:x": 0, + "~:y": 0.01 + } + } + ], + "~:proportion-lock": false, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1.0, + "~:b": 0.0, + "~:c": 0.0, + "~:d": 1.0, + "~:e": 0.0, + "~:f": 0.0 + } + }, + "~:id": "~u00000000-0000-0000-0000-000000000000", + "~:parent-id": "~u00000000-0000-0000-0000-000000000000", + "~:frame-id": "~u00000000-0000-0000-0000-000000000000", + "~:strokes": [], + "~:x": 0, + "~:proportion": 1.0, + "~:selrect": { + "~#rect": { + "~:x": 0, + "~:y": 0, + "~:width": 0.01, + "~:height": 0.01, + "~:x1": 0, + "~:y1": 0, + "~:x2": 0.01, + "~:y2": 0.01 + } + }, + "~:fills": [ + { + "~:fill-color": "#FFFFFF", + "~:fill-opacity": 1 + } + ], + "~:flip-x": null, + "~:height": 0.01, + "~:flip-y": null, + "~:shapes": [ + "~ubc508673-9e3b-80bf-8004-77dfa30a2b13" + ] + } + }, + "~ubc508673-9e3b-80bf-8004-77dfa30a2b13": { + "~#shape": { + "~:y": 100, + "~:hide-fill-on-export": false, + "~:transform": { + "~#matrix": { + "~:a": 0.5735764363510460, + "~:b": 0.8191520442889918, + "~:c": -0.8191520442889918, + "~:d": 0.5735764363510460, + "~:e": 0.0, + "~:f": 0.0 + } + }, + "~:rotation": 55, + "~:grow-type": "~:fixed", + "~:hide-in-viewer": false, + "~:name": "Board", + "~:width": 80, + "~:type": "~:frame", + "~:points": [ + { + "~#point": { + "~:x": 166.208, + "~:y": 92.816 + } + }, + { + "~#point": { + "~:x": 212.096, + "~:y": 158.352 + } + }, + { + "~#point": { + "~:x": 113.792, + "~:y": 227.184 + } + }, + { + "~#point": { + "~:x": 67.904, + "~:y": 161.648 + } + } + ], + "~:proportion-lock": false, + "~:transform-inverse": { + "~#matrix": { + "~:a": 0.5735764363510460, + "~:b": -0.8191520442889918, + "~:c": 0.8191520442889918, + "~:d": 0.5735764363510460, + "~:e": 0.0, + "~:f": 0.0 + } + }, + "~:id": "~ubc508673-9e3b-80bf-8004-77dfa30a2b13", + "~:parent-id": "~u00000000-0000-0000-0000-000000000000", + "~:frame-id": "~u00000000-0000-0000-0000-000000000000", + "~:strokes": [ + { + "~:stroke-color": "#FF0000", + "~:stroke-opacity": 1, + "~:stroke-style": "~:solid", + "~:stroke-alignment": "~:outer", + "~:stroke-width": 20 + } + ], + "~:x": 100, + "~:proportion": 1, + "~:selrect": { + "~#rect": { + "~:x": 100, + "~:y": 100, + "~:width": 80, + "~:height": 120, + "~:x1": 100, + "~:y1": 100, + "~:x2": 180, + "~:y2": 220 + } + }, + "~:fills": [ + { + "~:fill-color": "#FFFFFF", + "~:fill-opacity": 1 + } + ], + "~:flip-x": null, + "~:height": 120, + "~:flip-y": null, + "~:shapes": [], + "~:show-content": true + } + } + }, + "~:id": "~uaa5cc0bb-91ff-81b9-8004-77df9cd3edb2", + "~:name": "Page 1" + } +} diff --git a/frontend/playwright/data/viewer/get-view-only-bundle-rotated-board-stroke.json b/frontend/playwright/data/viewer/get-view-only-bundle-rotated-board-stroke.json new file mode 100644 index 0000000000..3bd07bf48a --- /dev/null +++ b/frontend/playwright/data/viewer/get-view-only-bundle-rotated-board-stroke.json @@ -0,0 +1,86 @@ +{ + "~:users": [ + { + "~:id": "~u0515a066-e303-8169-8004-73eb4018f4e0", + "~:email": "leia@example.com", + "~:name": "Princesa Leia", + "~:fullname": "Princesa Leia", + "~:is-active": true + } + ], + "~:fonts": [], + "~:project": { + "~:id": "~u0515a066-e303-8169-8004-73eb401b5d55", + "~:name": "Drafts", + "~:team-id": "~u0515a066-e303-8169-8004-73eb401977a6" + }, + "~:share-links": [], + "~:libraries": [], + "~:file": { + "~:features": { + "~#set": [ + "layout/grid", + "styles/v2", + "fdata/pointer-map", + "fdata/objects-map", + "components/v2", + "fdata/shape-data-type" + ] + }, + "~:has-media-trimmed": false, + "~:comment-thread-seqn": 0, + "~:name": "Rotated Board Stroke Test", + "~:revn": 1, + "~:modified-at": "~m1717759268010", + "~:id": "~uaa5cc0bb-91ff-81b9-8004-77df9cd3edb1", + "~:is-shared": false, + "~:version": 48, + "~:project-id": "~u0515a066-e303-8169-8004-73eb401b5d55", + "~:created-at": "~m1717759250257", + "~:data": { + "~:id": "~uaa5cc0bb-91ff-81b9-8004-77df9cd3edb1", + "~:options": { + "~:components-v2": true + }, + "~:pages": [ + "~uaa5cc0bb-91ff-81b9-8004-77df9cd3edb2" + ], + "~:pages-index": { + "~uaa5cc0bb-91ff-81b9-8004-77df9cd3edb2": { + "~#penpot/pointer": [ + "~uaa5cc0bb-91ff-81b9-8004-77dfae2d9e7c", + { + "~:created-at": "~m1717759268024" + } + ] + } + } + } + }, + "~:team": { + "~:id": "~u0515a066-e303-8169-8004-73eb401977a6", + "~:created-at": "~m1717493865581", + "~:modified-at": "~m1717493865581", + "~:name": "Default", + "~:is-default": true, + "~:features": { + "~#set": [ + "layout/grid", + "styles/v2", + "fdata/pointer-map", + "fdata/objects-map", + "components/v2", + "fdata/shape-data-type" + ] + } + }, + "~:permissions": { + "~:type": "~:membership", + "~:is-owner": true, + "~:is-admin": true, + "~:can-edit": true, + "~:can-read": true, + "~:is-logged": true, + "~:in-team": true + } +} diff --git a/frontend/playwright/ui/pages/ViewerPage.js b/frontend/playwright/ui/pages/ViewerPage.js index 5aaf2e0c37..034b25e2f3 100644 --- a/frontend/playwright/ui/pages/ViewerPage.js +++ b/frontend/playwright/ui/pages/ViewerPage.js @@ -71,6 +71,21 @@ export class ViewerPage extends BaseWebSocketPage { ); } + async setupFileWithRotatedBoardStroke() { + await this.mockRPC( + /get\-view\-only\-bundle\?/, + "viewer/get-view-only-bundle-rotated-board-stroke.json", + ); + await this.mockRPC( + "get-comment-threads?file-id=*", + "workspace/get-comment-threads-empty.json", + ); + await this.mockRPC( + "get-file-fragment?file-id=*&fragment-id=*", + "viewer/get-file-fragment-rotated-board-stroke.json", + ); + } + async setupFileWithComments() { await this.mockRPC( /get\-view\-only\-bundle\?/, diff --git a/frontend/playwright/ui/specs/viewer-rotated-board-stroke.spec.js b/frontend/playwright/ui/specs/viewer-rotated-board-stroke.spec.js new file mode 100644 index 0000000000..05fda576e8 --- /dev/null +++ b/frontend/playwright/ui/specs/viewer-rotated-board-stroke.spec.js @@ -0,0 +1,47 @@ +import { test, expect } from "@playwright/test"; +import { ViewerPage } from "../pages/ViewerPage"; + +// Issue 8257: outer stroke of a rotated board is cropped in View Mode. +// The SVG viewport must be large enough to contain the stroke of a rotated board. +// A 55° rotated board (80×120) with a 20px outer stroke has a rotated bounding box +// of ~144×134px. The viewport must be at least ~202×192px (bbox + stroke margin). + +test.beforeEach(async ({ page }) => { + await ViewerPage.init(page); +}); + +const rotatedBoardFileId = "aa5cc0bb-91ff-81b9-8004-77df9cd3edb1"; +const rotatedBoardPageId = "aa5cc0bb-91ff-81b9-8004-77df9cd3edb2"; + +test("Viewer shows full outer stroke of a rotated board without clipping", async ({ + page, +}) => { + const viewer = new ViewerPage(page); + await viewer.setupLoggedInUser(); + await viewer.setupFileWithRotatedBoardStroke(); + + await viewer.goToViewer({ + fileId: rotatedBoardFileId, + pageId: rotatedBoardPageId, + }); + + // Wait for the viewer SVG to be rendered + const svg = page.locator("svg[class*='not-fixed']").first(); + await expect(svg).toBeVisible(); + + // The SVG viewBox must be large enough to contain the rotated board plus its + // 20px outer stroke. For a 55° rotated board (80×120): + // - The axis-aligned bounding box of the rotated frame is ~144×134px + // - The outer stroke (20px) adds sqrt(2)*20 ≈ 29px margin on each side + // - So the viewport must be at least ~202×192px + // + // Before the fix, the viewer used the unrotated selrect (80×120) as the viewport, + // causing the stroke to be heavily clipped. + const viewBox = await svg.getAttribute("viewBox"); + const [, , vbWidth, vbHeight] = viewBox.split(" ").map(Number); + + // The unrotated selrect is 80×120. If the viewport is close to those dimensions, + // the stroke is being clipped (bug). The fixed viewport should be much larger. + expect(vbWidth).toBeGreaterThan(150); + expect(vbHeight).toBeGreaterThan(150); +}); diff --git a/frontend/src/app/main/ui/viewer.cljs b/frontend/src/app/main/ui/viewer.cljs index f86208a8b7..5867a7cc14 100644 --- a/frontend/src/app/main/ui/viewer.cljs +++ b/frontend/src/app/main/ui/viewer.cljs @@ -53,9 +53,9 @@ (defn- calculate-size "Calculate the total size we must reserve for the frame, including possible paddings - added because shadows or blur." + added because shadows, blur, or strokes." [objects frame zoom] - (let [{:keys [x y width height]} (gsb/get-object-bounds objects frame)] + (let [{:keys [x y width height]} (gsb/get-object-bounds objects frame {:ignore-margin? false})] {:base-width width :base-height height :x x