From 9f5e89d5f8dd6a8597e43bfe0d90affae82f0167 Mon Sep 17 00:00:00 2001 From: Alejandro Alonso Date: Tue, 2 Jun 2026 09:56:51 +0200 Subject: [PATCH 01/92] :tada: Basic viewer with wasm --- common/src/app/common/flags.cljc | 1 + frontend/src/app/main/data/viewer.cljs | 4 +- frontend/src/app/main/render.cljs | 29 +-- frontend/src/app/main/render_viewer_wasm.cljs | 241 ++++++++++++++++++ frontend/src/app/main/ui/inspect/render.cljs | 2 +- .../src/app/main/ui/viewer/interactions.cljs | 66 ++--- frontend/src/app/main/ui/viewer/shapes.cljs | 74 ++++++ .../app/main/ui/viewer/viewport_common.cljs | 67 +++++ .../src/app/main/ui/viewer/viewport_wasm.cljs | 160 ++++++++++++ frontend/src/app/render_wasm/api.cljs | 46 ++++ render-wasm/src/main.rs | 25 ++ render-wasm/src/render.rs | 126 ++++++++- render-wasm/src/render/surfaces.rs | 35 +++ render-wasm/src/state.rs | 8 + 14 files changed, 811 insertions(+), 73 deletions(-) create mode 100644 frontend/src/app/main/render_viewer_wasm.cljs create mode 100644 frontend/src/app/main/ui/viewer/viewport_common.cljs create mode 100644 frontend/src/app/main/ui/viewer/viewport_wasm.cljs diff --git a/common/src/app/common/flags.cljc b/common/src/app/common/flags.cljc index 0584d0f0f7..79f94d2639 100644 --- a/common/src/app/common/flags.cljc +++ b/common/src/app/common/flags.cljc @@ -169,6 +169,7 @@ :mcp :background-blur + :available-viewer-wasm :stroke-path}) (def all-flags diff --git a/frontend/src/app/main/data/viewer.cljs b/frontend/src/app/main/data/viewer.cljs index 3b2ca792b8..d05df4bd13 100644 --- a/frontend/src/app/main/data/viewer.cljs +++ b/frontend/src/app/main/data/viewer.cljs @@ -17,6 +17,7 @@ [app.common.types.shape-tree :as ctt] [app.common.types.shape.interactions :as ctsi] [app.common.uuid :as uuid] + [app.config :as cf] [app.main.data.comments :as dcmt] [app.main.data.common :as dcm] [app.main.data.event :as ev] @@ -219,7 +220,8 @@ (ptk/reify ::update-page-position-data ptk/WatchEvent (watch [_ state _] - (if (features/active-feature? state "render-wasm/v1") + (if (and (features/active-feature? state "render-wasm/v1") + (contains? cf/flags :available-viewer-wasm)) (let [objects (dsh/lookup-page-objects state file-id page-id) shapes diff --git a/frontend/src/app/main/render.cljs b/frontend/src/app/main/render.cljs index 7c6ee76e1e..5bd1a63f6d 100644 --- a/frontend/src/app/main/render.cljs +++ b/frontend/src/app/main/render.cljs @@ -30,6 +30,7 @@ [app.common.types.shape.layout :as ctl] [app.config :as cfg] [app.main.fonts :as fonts] + [app.main.render-viewer-wasm :as rwv] [app.main.ui.context :as muc] [app.main.ui.shapes.bool :as bool] [app.main.ui.shapes.circle :as circle] @@ -524,32 +525,6 @@ (for [shape shapes] [:& shape-wrapper {:key (dm/str (:id shape)) :shape shape}])]]])) -(defn render-to-canvas - [objects canvas bounds scale object-id on-render] - (let [width (.-width canvas) - height (.-height canvas) - os-canvas (js/OffscreenCanvas. width height)] - (try - (when (wasm.api/init-canvas-context os-canvas) - (wasm.api/initialize-viewport - objects scale bounds - :background-opacity 0 - :on-render - (fn [] - (wasm.api/render-sync-shape object-id) - (ts/raf - (fn [] - (let [bitmap (.transferToImageBitmap os-canvas) - ctx2d (.getContext canvas "2d")] - (.clearRect ctx2d 0 0 width height) - (.drawImage ctx2d bitmap 0 0) - (dom/set-attribute! canvas "id" (dm/str "screenshot-" object-id)) - (wasm.api/clear-canvas) - (on-render))))))) - (catch :default e - (js/console.error "Error initializing canvas context:" e) - false)))) - (mf/defc object-wasm {::mf/wrap [mf/memo]} [{:keys [objects object-id skip-children scale on-render] :as props}] @@ -574,7 +549,7 @@ (p/fmap (fn [ready?] (when ready? - (render-to-canvas objects canvas bounds scale object-id on-render)))))))) + (rwv/render-to-canvas objects canvas bounds scale object-id on-render)))))))) [:canvas {:ref canvas-ref :width (* scale width) diff --git a/frontend/src/app/main/render_viewer_wasm.cljs b/frontend/src/app/main/render_viewer_wasm.cljs new file mode 100644 index 0000000000..7de082b675 --- /dev/null +++ b/frontend/src/app/main/render_viewer_wasm.cljs @@ -0,0 +1,241 @@ +;; 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.render-viewer-wasm + "WASM offscreen rendering for the shared viewer (snapshot + fixed-scroll)." + (:require + [app.common.data.macros :as dm] + [app.render-wasm.api :as wasm.api] + [app.render-wasm.wasm :as wasm] + [app.util.dom :as dom] + [app.util.timers :as ts] + [goog.events :as events] + [promesa.core :as p] + [rumext.v2 :as mf])) + +;; The WASM module is a single global instance; serialize offscreen work. +(defonce ^:private wasm-render-queue (atom (p/resolved nil))) + +(defn- enqueue-wasm-render! + [task] + (let [next-p (-> @wasm-render-queue + (p/handle (fn [_ _] (task))))] + (reset! wasm-render-queue (p/handle next-p (fn [_ _] nil))) + next-p)) + +(defonce ^:private viewer-snapshot + (atom {:os-canvas nil + :page-key nil + :canvas-w 0 + :canvas-h 0})) + +(defn- reset-viewer-snapshot! [] + (reset! viewer-snapshot + {:os-canvas nil + :page-key nil + :canvas-w 0 + :canvas-h 0})) + +(defn- draw-bitmap! + [canvas os-canvas object-id vis-w vis-h finish] + (ts/raf + (fn [] + (let [ctx2d (.getContext canvas "2d")] + (.clearRect ctx2d 0 0 vis-w vis-h) + ;; Draw directly from OffscreenCanvas so it can be reused across passes. + (.drawImage ctx2d os-canvas 0 0 vis-w vis-h) + (dom/set-attribute! canvas "id" (str "screenshot-" object-id)) + (finish))))) + +(defn- viewer-disable-wasm-ui-overlay! + "Workspace WASM UI (rulers + rounded viewport frame) is composited in + `present_frame`; the viewer must not show that chrome." + [] + (wasm.api/set-rulers-frame-visible! false) + (wasm.api/set-rulers-visible! false)) + +(defn- viewer-apply-layer-mask! + [include-ids clear-fills-ids] + (wasm.api/clear-render-include-filter!) + (when (seq include-ids) + (wasm.api/set-render-include-filter! include-ids)) + (doseq [id clear-fills-ids] + (wasm.api/use-shape id) + (wasm.api/clear-shape-fills!))) + +(defn- viewer-restore-layer-mask! + [page-objects clear-fills-ids] + (wasm.api/clear-render-include-filter!) + (doseq [id clear-fills-ids] + (wasm.api/use-shape id) + (wasm.api/set-shape-fills id (get-in page-objects [id :fills] []) false))) + +(defn- viewer-do-render! + [page-objects canvas os-canvas object-id vis-w vis-h scale size + include-ids clear-fills-ids finish] + (viewer-disable-wasm-ui-overlay!) + (viewer-apply-layer-mask! include-ids clear-fills-ids) + (wasm.api/set-viewer-viewport! scale size) + (wasm.api/render-sync-shape object-id) + (viewer-restore-layer-mask! page-objects clear-fills-ids) + (draw-bitmap! canvas os-canvas object-id vis-w vis-h finish)) + +(defn- render-to-canvas* + [objects canvas bounds scale object-id on-render] + (p/create + (fn [resolve _reject] + (let [width (.-width canvas) + height (.-height canvas) + prev-disable @wasm/disable-request-render? + finish (fn [] + (reset! wasm/disable-request-render? prev-disable) + (when (fn? on-render) (on-render)) + (resolve nil))] + (try + (reset! wasm/disable-request-render? true) + (let [os-canvas (js/OffscreenCanvas. width height)] + (if (wasm.api/init-canvas-context os-canvas) + (wasm.api/initialize-viewport + objects scale bounds + :background-opacity 0 + :force-sync true + :on-render + (fn [] + (viewer-disable-wasm-ui-overlay!) + (wasm.api/render-sync-shape object-id) + (draw-bitmap! canvas os-canvas object-id width height + (fn [] + (wasm.api/clear-canvas {:lose-browser-context? false}) + (reset-viewer-snapshot!) + (finish))))) + (finish))) + (catch :default e + (js/console.error "Error initializing canvas context:" e) + (finish))))))) + +(defn render-to-canvas + "One-shot WASM render into `canvas` (exports, thumbnails). Serialized globally." + [objects canvas bounds scale object-id on-render] + (enqueue-wasm-render! + (fn [] + (render-to-canvas* objects canvas bounds scale object-id on-render)))) + +(defn- render-viewer-frame* + [page-key page-objects canvas size scale object-id on-render + {:keys [include-ids clear-fills-ids] :or {clear-fills-ids #{}}}] + (p/create + (fn [resolve _reject] + (let [prev-disable @wasm/disable-request-render? + finish (fn [] + (reset! wasm/disable-request-render? prev-disable) + (when (fn? on-render) (on-render)) + (resolve nil)) + vis-w (.-width canvas) + vis-h (.-height canvas) + snap @viewer-snapshot + same-page? (and (some? page-key) (identical? page-key (:page-key snap))) + same-size? (and (= vis-w (:canvas-w snap)) (= vis-h (:canvas-h snap))) + os (:os-canvas snap) + do-render! (fn [os-canvas] + (viewer-do-render! page-objects canvas os-canvas object-id + vis-w vis-h scale size include-ids + clear-fills-ids finish))] + + (reset! wasm/disable-request-render? true) + + (try + (if (and same-page? (wasm.api/initialized?) os) + (do + (when-not same-size? + (wasm.api/resize-offscreen-canvas! os vis-w vis-h) + (swap! viewer-snapshot assoc :canvas-w vis-w :canvas-h vis-h)) + (do-render! os)) + (let [os-canvas (js/OffscreenCanvas. vis-w vis-h)] + (when (wasm.api/initialized?) + (wasm.api/clear-canvas {:lose-browser-context? false})) + (if (wasm.api/init-canvas-context os-canvas) + (do + (reset! viewer-snapshot + {:os-canvas os-canvas + :page-key page-key + :canvas-w vis-w + :canvas-h vis-h}) + (wasm.api/initialize-viewport + page-objects scale size + :background-opacity 0 + :force-sync true + :on-render #(do-render! os-canvas))) + (finish)))) + (catch :default e + (js/console.error "viewer-snapshot: render error" e) + (finish))))))) + +(defn- use-fixed-scroll-sync! + [enabled? layer-ref] + (mf/use-layout-effect + (mf/deps enabled?) + (fn [] + (when enabled? + (let [section (dom/get-element "viewer-section") + sync! + (fn [] + (when-let [layer (mf/ref-val layer-ref)] + (dom/set-style! layer "transform" + (dm/str "translate(" + (or (dom/get-h-scroll-pos section) 0) "px, " + (or (dom/get-scroll-pos section) 0) "px)"))))] + (when section + (sync!) + (let [key (events/listen section "scroll" (fn [_] (sync!)))] + #(events/unlistenByKey key)))))))) + +(defn- use-viewer-wasm-layers! + [page-id page-objects size scale frame-id not-fixed-ref fixed-ref + not-fixed-include-ids fixed-include-ids fixed-clear-fills-ids] + (mf/use-layout-effect + (mf/deps page-id page-objects size scale frame-id + not-fixed-include-ids fixed-include-ids fixed-clear-fills-ids) + (fn [] + (when (get page-objects frame-id) + (->> @wasm.api/module + (p/fmap + (fn [ready?] + (when ready? + (let [not-fixed-canvas (mf/ref-val not-fixed-ref) + fixed-canvas (mf/ref-val fixed-ref) + passes + (cond-> [] + not-fixed-canvas + (conj {:canvas not-fixed-canvas + :opts (cond-> {} + (seq not-fixed-include-ids) + (assoc :include-ids not-fixed-include-ids))}) + + (and fixed-canvas (seq fixed-include-ids)) + (conj {:canvas fixed-canvas + :opts (cond-> {:include-ids fixed-include-ids} + (seq fixed-clear-fills-ids) + (assoc :clear-fills-ids fixed-clear-fills-ids))}))] + (when (seq passes) + (enqueue-wasm-render! + (fn [] + (reduce (fn [chain {:keys [canvas opts]}] + (p/then chain + #(render-viewer-frame* page-id page-objects + canvas size scale frame-id + nil opts))) + (p/resolved nil) + passes))))))))))))) + +(defn use-viewer-wasm-viewport! + "WASM render passes and fixed-scroll DOM sync for the viewer viewport." + [page-id page-objects size scale frame-id + not-fixed-ref fixed-ref fixed-scroll-layer-ref + not-fixed-include-ids fixed-include-ids fixed-clear-fills-ids] + (use-fixed-scroll-sync! (some? fixed-scroll-layer-ref) fixed-scroll-layer-ref) + (use-viewer-wasm-layers! page-id page-objects size scale frame-id + not-fixed-ref fixed-ref + not-fixed-include-ids fixed-include-ids fixed-clear-fills-ids)) diff --git a/frontend/src/app/main/ui/inspect/render.cljs b/frontend/src/app/main/ui/inspect/render.cljs index 87cdb901f7..25e223f9e5 100644 --- a/frontend/src/app/main/ui/inspect/render.cljs +++ b/frontend/src/app/main/ui/inspect/render.cljs @@ -23,7 +23,7 @@ [app.main.ui.shapes.shape :refer [shape-container]] [app.main.ui.shapes.svg-raw :as svg-raw] [app.main.ui.shapes.text :as text] - [app.main.ui.viewer.interactions :refer [prepare-objects]] + [app.main.ui.viewer.viewport-common :refer [prepare-objects]] [app.util.dom :as dom] [app.util.object :as obj] [rumext.v2 :as mf])) diff --git a/frontend/src/app/main/ui/viewer/interactions.cljs b/frontend/src/app/main/ui/viewer/interactions.cljs index 35138ef829..0b1589b36b 100644 --- a/frontend/src/app/main/ui/viewer/interactions.cljs +++ b/frontend/src/app/main/ui/viewer/interactions.cljs @@ -9,53 +9,26 @@ (:require [app.common.data :as d] [app.common.data.macros :as dm] - [app.common.files.helpers :as cfh] [app.common.geom.point :as gpt] - [app.common.geom.shapes :as gsh] - [app.common.types.modifiers :as ctm] [app.common.types.page :as ctp] [app.common.uuid :as uuid] + [app.config :as cf] [app.main.data.comments :as dcm] [app.main.data.viewer :as dv] + [app.main.features :as features] [app.main.store :as st] [app.main.ui.components.dropdown :refer [dropdown]] [app.main.ui.hooks :as h] [app.main.ui.icons :as deprecated-icon] [app.main.ui.viewer.shapes :as shapes] + [app.main.ui.viewer.viewport-common :as vpc] + [app.main.ui.viewer.viewport-wasm :as viewport.wasm] [app.util.dom :as dom] [app.util.i18n :as i18n :refer [tr]] [app.util.keyboard :as kbd] [goog.events :as events] [rumext.v2 :as mf])) -(defn prepare-objects - [frame size delta objects] - (let [frame-id (:id frame) - vector (-> (gpt/point (:x size) (:y size)) - (gpt/add delta) - (gpt/negate)) - update-fn #(d/update-when %1 %2 gsh/transform-shape (ctm/move-modifiers vector))] - (->> (cfh/get-children-ids objects frame-id) - (into [frame-id]) - (reduce update-fn objects)))) - -(defn get-fixed-ids - [objects] - (let [fixed-ids (filter cfh/fixed-scroll? (vals objects)) - - ;; we have to consider the children if the fixed element is a group - fixed-children-ids - (into #{} (mapcat #(cfh/get-children-ids objects (:id %)) fixed-ids)) - - parent-children-ids - (->> fixed-ids - (mapcat #(cons (:id %) (cfh/get-parent-ids objects (:id %)))) - (remove #(= % uuid/zero))) - - fixed-ids - (concat fixed-children-ids parent-children-ids)] - fixed-ids)) - (mf/defc viewport-svg {::mf/wrap [mf/memo] ::mf/wrap-props false} @@ -74,7 +47,7 @@ objects (:objects page) objects (cond-> objects fixed? (assoc-in [(:id frame) :fixed-scroll] true)) - fixed-ids (get-fixed-ids objects) + fixed-ids (vpc/get-fixed-ids objects) not-fixed-ids (->> (remove (set fixed-ids) (keys objects)) @@ -86,7 +59,7 @@ (map (d/getf objects)) (concat [frame]) (d/index-by :id) - (prepare-objects frame size delta))) + (vpc/prepare-objects frame size delta))) objects-fixed (mf/with-memo [fixed-ids page frame size delta] @@ -175,7 +148,10 @@ page (unchecked-get props "page") frame (unchecked-get props "frame") base (unchecked-get props "base-frame") - fixed? (unchecked-get props "fixed?")] + fixed? (unchecked-get props "fixed?") + + render-wasm? (and (features/use-feature "render-wasm/v1") + (contains? cf/flags :available-viewer-wasm))] (mf/with-effect [mode] (let [on-click @@ -210,13 +186,21 @@ (events/unlistenByKey key2) (events/unlistenByKey key3)))) - [:& viewport-svg {:page page - :frame frame - :base base - :offset offset - :size size - :delta delta - :fixed? fixed?}])) + (if ^boolean render-wasm? + [:& viewport.wasm/viewport-wasm {:page page + :frame frame + :base base + :offset offset + :size size + :delta delta + :fixed? fixed?}] + [:& viewport-svg {:page page + :frame frame + :base base + :offset offset + :size size + :delta delta + :fixed? fixed?}]))) (mf/defc flows-menu* {::mf/wrap [mf/memo]} diff --git a/frontend/src/app/main/ui/viewer/shapes.cljs b/frontend/src/app/main/ui/viewer/shapes.cljs index 880c515c49..9f407b6714 100644 --- a/frontend/src/app/main/ui/viewer/shapes.cljs +++ b/frontend/src/app/main/ui/viewer/shapes.cljs @@ -294,6 +294,80 @@ :pointer-events "none" :transform (gsh/transform-str shape)}]))) +;; --- WASM viewer hotspots --- +;; In WASM viewer mode the frame pixels come from a WASM snapshot, so we don't +;; render the SVG visuals at all. We only render the actionable areas (hotspots) +;; on top of the image: a transparent hit/highlight rect per interactive shape, +;; wired to the same interaction handlers as the regular SVG tree. + +(mf/defc hotspot* + {::mf/wrap-props false} + [props] + (let [shape (unchecked-get props "shape") + all-objects (unchecked-get props "all-objects") + base-frame (mf/use-ctx base-frame-ctx) + frame-offset (mf/use-ctx frame-offset-ctx) + show-interactions (mf/deref ref:viewer-show-interactions) + overlays (mf/deref refs/viewer-overlays) + interactions (:interactions shape) + {:keys [x y width height]} (:selrect shape) + + on-pd (mf/use-fn (mf/deps shape base-frame frame-offset all-objects overlays) + #(on-pointer-down % shape base-frame frame-offset all-objects overlays)) + on-pu (mf/use-fn (mf/deps shape base-frame frame-offset all-objects overlays) + #(on-pointer-up % shape base-frame frame-offset all-objects overlays)) + on-pe (mf/use-fn (mf/deps shape base-frame frame-offset all-objects overlays) + #(on-pointer-enter % shape base-frame frame-offset all-objects overlays)) + on-pl (mf/use-fn (mf/deps shape base-frame frame-offset all-objects overlays) + #(on-pointer-leave % shape base-frame frame-offset all-objects overlays))] + + (mf/with-effect [] + (let [sems (on-load shape base-frame frame-offset all-objects overlays)] + (partial run! tm/dispose! sems))) + + [:g {:style {:cursor (when (ctsi/actionable? interactions) "pointer")} + :on-pointer-down on-pd + :on-pointer-up on-pu + :on-pointer-enter on-pe + :on-pointer-leave on-pl} + [:rect {:x (- x 1) + :y (- y 1) + :width (+ width 2) + :height (+ height 2) + :fill "var(--color-accent-tertiary)" + :stroke "var(--color-accent-tertiary)" + :stroke-width (if show-interactions 1 0) + :fill-opacity (if show-interactions 0.2 0) + ;; This rect is the only hit target, so it must always capture + ;; pointer events even when fully transparent. + :pointer-events "all" + :transform (gsh/transform-str shape)}]])) + +(mf/defc frame-hotspots* + "Renders interaction hotspots for a frame subtree (WASM viewer mode). + `objects` must be the prepared (vbox-space) objects and `frame` the prepared + frame; only shapes with interactions produce a hotspot. + + Optional `shape-filter` is a predicate that receives the shape id and returns + true when it should be included (used to split fixed-scroll vs normal layers)." + {::mf/wrap-props false} + [props] + (let [objects (unchecked-get props "objects") + all-objects (or (unchecked-get props "all-objects") objects) + shape-filter (unchecked-get props "shape-filter") + frame (unchecked-get props "frame") + frame-id (:id frame) + ids (cond->> (cons frame-id (cfh/get-children-ids objects frame-id)) + shape-filter (filter shape-filter)) + hotspots (->> ids + (keep #(get objects %)) + (filter (fn [s] (and (not (:hidden s)) + (seq (:interactions s))))))] + [:* (for [shape hotspots] + [:& hotspot* {:key (str (:id shape)) + :shape shape + :all-objects all-objects}])])) + ;; TODO: use-memo use-fn diff --git a/frontend/src/app/main/ui/viewer/viewport_common.cljs b/frontend/src/app/main/ui/viewer/viewport_common.cljs new file mode 100644 index 0000000000..e2ebb1b881 --- /dev/null +++ b/frontend/src/app/main/ui/viewer/viewport_common.cljs @@ -0,0 +1,67 @@ +;; 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.ui.viewer.viewport-common + "Shared object preparation for viewer viewports (SVG and WASM)." + (:require + [app.common.data :as d] + [app.common.files.helpers :as cfh] + [app.common.geom.point :as gpt] + [app.common.geom.shapes :as gsh] + [app.common.types.modifiers :as ctm] + [app.common.uuid :as uuid])) + +(defn prepare-objects + [frame size delta objects] + (let [frame-id (:id frame) + vector (-> (gpt/point (:x size) (:y size)) + (gpt/add delta) + (gpt/negate)) + update-fn #(d/update-when %1 %2 gsh/transform-shape (ctm/move-modifiers vector))] + (->> (cfh/get-children-ids objects frame-id) + (into [frame-id]) + (reduce update-fn objects)))) + +(defn get-fixed-ids + [objects] + (let [fixed-ids (filter cfh/fixed-scroll? (vals objects)) + + fixed-children-ids + (into #{} (mapcat #(cfh/get-children-ids objects (:id %)) fixed-ids)) + + parent-children-ids + (->> fixed-ids + (mapcat #(cons (:id %) (cfh/get-parent-ids objects (:id %)))) + (remove #(= % uuid/zero))) + + fixed-ids + (concat fixed-children-ids parent-children-ids)] + fixed-ids)) + +(defn frame-fixed-mask-ids + "Fixed-layer shape ids inside `frame-id` (same rules as `get-fixed-ids`)." + [objects frame-id] + (when frame-id + (let [subtree (into #{} (cfh/get-children-ids-with-self objects frame-id))] + (into #{} + (filter #(contains? subtree %) + (get-fixed-ids objects)))))) + +(defn prepare-page-objects + "Transform all page objects into vbox-space (for overlay positioning)." + [objects size delta] + (let [vector (-> (gpt/point (:x size) (:y size)) + (gpt/add delta) + (gpt/negate)) + update-fn #(d/update-when %1 %2 gsh/transform-shape (ctm/move-modifiers vector)) + ids (->> (keys objects) (remove #(= % uuid/zero)))] + (reduce update-fn objects ids))) + +(defn viewer-scale + [size] + (if (and (:base-width size) (pos? (:base-width size))) + (/ (:width size) (:base-width size)) + 1)) diff --git a/frontend/src/app/main/ui/viewer/viewport_wasm.cljs b/frontend/src/app/main/ui/viewer/viewport_wasm.cljs new file mode 100644 index 0000000000..2c9b5a31e4 --- /dev/null +++ b/frontend/src/app/main/ui/viewer/viewport_wasm.cljs @@ -0,0 +1,160 @@ +;; 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.ui.viewer.viewport-wasm + (:require-macros [app.main.style :as stl]) + (:require + [app.common.files.helpers :as cfh] + [app.common.geom.point :as gpt] + [app.main.render-viewer-wasm :as rwv] + [app.main.ui.viewer.shapes :as shapes] + [app.main.ui.viewer.viewport-common :as vpc] + [app.util.object :as obj] + [rumext.v2 :as mf])) + +(defn- canvas-dimensions + [scale size] + {:width (js/Math.round (* scale (:base-width size))) + :height (js/Math.round (* scale (:base-height size)))}) + +(defn- frame-hotspots-props + "frame-hotspots* uses ::mf/wrap-props false and expects string keys." + [prepared prepared-all prepared-frame shape-filter] + (let [props #js {"objects" prepared + "all-objects" prepared-all + "frame" prepared-frame}] + (when shape-filter + (obj/set! props "shape-filter" shape-filter)) + props)) + +(mf/defc wasm-hotspots-svg + [{:keys [vbox size class prepared prepared-all prepared-frame shape-filter]}] + [:svg {:view-box vbox + :width (:width size) + :height (:height size) + :version "1.1" + :xmlnsXlink "http://www.w3.org/1999/xlink" + :xmlns "http://www.w3.org/2000/svg" + :fill "none" + :style {:position "absolute" + :top 0 + :left 0} + :class class} + [:& shapes/frame-hotspots* + (frame-hotspots-props prepared prepared-all prepared-frame shape-filter)]]) + +(mf/defc wasm-layer + [{:keys [canvas-ref scale size vbox svg-props]}] + (let [{:keys [width height]} (canvas-dimensions scale size)] + [:div {:style {:position "absolute" + :top 0 + :left 0}} + [:canvas {:ref canvas-ref :width width :height height :style {:width "100%" + :height "100%" + :background "transparent" + :pointer-events "none"}}] + [:& wasm-hotspots-svg (assoc svg-props :vbox vbox :size size)]])) + +(defn- fixed-scroll-layer-ids + [objects frame-id has-fixed?] + (let [frame-subtree-ids (into #{} (cfh/get-children-ids-with-self objects frame-id)) + fixed-mask-ids (when has-fixed? (vpc/frame-fixed-mask-ids objects frame-id)) + fixed-mask-set (or fixed-mask-ids #{}) + not-fixed-include-ids + (when has-fixed? + (into [] + (distinct + (conj (->> frame-subtree-ids + (remove #(contains? fixed-mask-set %))) + frame-id)))) + fixed-include-ids + (when has-fixed? + (vec (conj (or fixed-mask-ids #{}) frame-id))) + fixed-clear-fills-ids (when has-fixed? #{frame-id})] + {:fixed-mask-set fixed-mask-set + :not-fixed-include-ids not-fixed-include-ids + :fixed-include-ids fixed-include-ids + :fixed-clear-fills-ids fixed-clear-fills-ids})) + +(mf/defc viewport-wasm + {::mf/wrap [mf/memo] + ::mf/wrap-props false} + [props] + (let [page (unchecked-get props "page") + frame (unchecked-get props "frame") + base (unchecked-get props "base") + offset (unchecked-get props "offset") + size (unchecked-get props "size") + delta (or (unchecked-get props "delta") (gpt/point 0 0)) + vbox (:vbox size) + fixed? (true? (unchecked-get props "fixed?")) + + fixed-layer-ref (mf/use-ref nil) + not-fixed-wasm-ref (mf/use-ref nil) + fixed-wasm-ref (mf/use-ref nil) + + objects (:objects page) + frame-id (:id frame) + scale (vpc/viewer-scale size) + page-id (:id page) + + frame (cond-> frame fixed? (assoc :fixed-scroll true)) + objects (cond-> objects fixed? (assoc-in [frame-id :fixed-scroll] true)) + + has-fixed? + (and (not fixed?) + (some #(cfh/fixed-scroll? (get objects %)) + (cfh/get-children-ids objects frame-id))) + + prepared + (mf/with-memo [objects frame size delta] + (vpc/prepare-objects frame size delta objects)) + + prepared-all + (mf/with-memo [objects size delta] + (vpc/prepare-page-objects objects size delta)) + + {:keys [fixed-mask-set not-fixed-include-ids fixed-include-ids fixed-clear-fills-ids]} + (mf/with-memo [objects frame-id has-fixed?] + (fixed-scroll-layer-ids objects frame-id has-fixed?)) + + prepared-frame (get prepared frame-id) + + svg-base {:prepared prepared + :prepared-all prepared-all + :prepared-frame prepared-frame}] + + (rwv/use-viewer-wasm-viewport! + page-id objects size scale frame-id + not-fixed-wasm-ref fixed-wasm-ref + (when has-fixed? fixed-layer-ref) + not-fixed-include-ids fixed-include-ids fixed-clear-fills-ids) + + [:& (mf/provider shapes/base-frame-ctx) {:value (get prepared-all (:id base))} + [:& (mf/provider shapes/frame-offset-ctx) {:value offset} + [:* + [:& wasm-layer + {:canvas-ref not-fixed-wasm-ref + :scale scale + :size size + :vbox vbox + :svg-props (assoc svg-base + :class (if has-fixed? + (stl/css :not-fixed) + (when fixed? (stl/css :fixed))) + :shape-filter (when has-fixed? + #(not (contains? fixed-mask-set %))))}] + + (when has-fixed? + [:div {:ref fixed-layer-ref} + [:& wasm-layer + {:canvas-ref fixed-wasm-ref + :scale scale + :size size + :vbox vbox + :svg-props (assoc svg-base + :class (stl/css :not-fixed) + :shape-filter #(contains? fixed-mask-set %))}]])]]])) diff --git a/frontend/src/app/render_wasm/api.cljs b/frontend/src/app/render_wasm/api.cljs index c4ae182fc0..71402806b4 100644 --- a/frontend/src/app/render_wasm/api.cljs +++ b/frontend/src/app/render_wasm/api.cljs @@ -931,6 +931,13 @@ [hidden] (h/call wasm/internal-module "_set_shape_hidden" hidden)) +(defn clear-shape-fills! + "Clear the fills of the currently-selected shape (call `use-shape` first). + Equivalent to `set-shape-fills` with an empty collection." + [] + (when (initialized?) + (h/call wasm/internal-module "_clear_shape_fills"))) + (defn set-shape-bool-type [bool-type] (h/call wasm/internal-module "_set_shape_bool_type" (sr/translate-bool-type bool-type))) @@ -1666,6 +1673,27 @@ (h/call wasm/internal-module "_set_focus_mode") (request-render "set-focus-mode")))) +(defn clear-render-include-filter! + "Clear the viewer include filter (render all shapes in the subtree again)." + [] + (when (initialized?) + (h/call wasm/internal-module "_clear_render_include_filter"))) + +(defn set-render-include-filter! + "Restrict the next render to `shape-ids` and descendants of whitelisted nodes. + Used for viewer fixed-scroll layers; does not change shape hidden flags." + [shape-ids] + (when (and (initialized?) (seq shape-ids)) + (let [ids (vec shape-ids) + size (mem/get-alloc-size ids UUID-U8-SIZE) + heap (mem/get-heap-u32) + offset (mem/alloc->offset-32 size)] + (reduce (fn [offset id] + (mem.h32/write-uuid offset heap id)) + offset + ids) + (h/call wasm/internal-module "_set_render_include_filter")))) + (defn set-structure-modifiers [entries] (when-not ^boolean (empty? entries) @@ -1865,6 +1893,24 @@ [width height] (h/call wasm/internal-module "_resize_viewbox" width height)) +(defn set-viewer-viewport! + "Update viewer zoom/pan and rebuild the tile index (frame hops in the viewer). + `vbox` must have at least `:x` and `:y` keys (design-space top-left corner)." + [zoom vbox] + (h/call wasm/internal-module "_set_view" zoom (- (:x vbox)) (- (:y vbox))) + (when (initialized?) + (h/call wasm/internal-module "_set_view_end") + (reset! view-interaction-active? false))) + +(defn resize-offscreen-canvas! + "Resize a persistent OffscreenCanvas to new physical-pixel dimensions and + update the WASM render surfaces accordingly (via `_resize_viewbox`). The + design state (shape pool) is preserved so `set-objects` is not needed again." + [canvas new-physical-w new-physical-h] + (set! (.-width canvas) new-physical-w) + (set! (.-height canvas) new-physical-h) + (resize-viewbox (/ new-physical-w dpr) (/ new-physical-h dpr))) + (defn- debug-flags [] (cond-> 0 diff --git a/render-wasm/src/main.rs b/render-wasm/src/main.rs index a0939cdd95..83b3ad23f8 100644 --- a/render-wasm/src/main.rs +++ b/render-wasm/src/main.rs @@ -151,6 +151,31 @@ pub extern "C" fn render_blurred_snapshot(blur_radius: f32) -> Result<()> { Ok(()) } +#[no_mangle] +#[wasm_error] +pub extern "C" fn clear_render_include_filter() -> Result<()> { + with_state!(state, { + state.clear_include_filter(); + }); + Ok(()) +} + +#[no_mangle] +#[wasm_error] +pub extern "C" fn set_render_include_filter() -> Result<()> { + let bytes = mem::bytes(); + + let entries: Vec = bytes + .chunks(size_of::<::BytesType>()) + .map(|data| Uuid::try_from(data).map_err(|e| Error::RecoverableError(e.to_string()))) + .collect::>>()?; + + with_state!(state, { + state.set_include_filter(entries); + }); + Ok(()) +} + #[no_mangle] #[wasm_error] pub extern "C" fn render_sync() -> Result<()> { diff --git a/render-wasm/src/render.rs b/render-wasm/src/render.rs index 1f7d9842b2..f5712be7e6 100644 --- a/render-wasm/src/render.rs +++ b/render-wasm/src/render.rs @@ -372,6 +372,10 @@ pub(crate) struct RenderState { pub show_grid: Option, pub rulers: RulerState, pub focus_mode: FocusMode, + /// Viewer-only whitelist for fixed-scroll layer passes. + pub include_filter: Option>, + /// Frame id passed as `base_object` for viewer renders; always traversed. + pub viewer_render_root: Option, pub touched_ids: HashSet, /// Temporary flag used for off-screen passes (drop-shadow masks, filter surfaces, etc.) /// where we must render shapes without inheriting ancestor layer blurs. Toggle it through @@ -568,6 +572,8 @@ impl RenderState { show_grid: None, rulers: RulerState::default(), focus_mode: FocusMode::new(), + include_filter: None, + viewer_render_root: None, touched_ids: HashSet::default(), ignore_nested_blurs: false, preview_mode: false, @@ -850,7 +856,14 @@ impl RenderState { /// on top of Target, then present. Backbuffer is left clean so it can be reused /// as-is across interactive-transform frames without stale overlay pixels. pub fn present_frame(&mut self, tree: ShapesPoolRef) { - self.surfaces.copy_backbuffer_to_target(); + // Viewer masked passes render a partial scene onto a transparent backbuffer. + // SrcOver would keep pass-1 pixels wherever the backbuffer stays transparent. + if self.viewer_masked_pass() { + self.surfaces.clear_target(skia::Color::TRANSPARENT); + self.surfaces.copy_backbuffer_to_target_replace(); + } else { + self.surfaces.copy_backbuffer_to_target(); + } if self.options.is_debug_visible() { debug::render(self); } @@ -942,6 +955,20 @@ impl RenderState { return Ok(()); } + // Viewer masked passes render a partial scene. Reusing the tile texture cache would + // SrcOver-blend onto textures from the previous pass and leak pixels into the blob. + if self.viewer_masked_pass() { + // Use viewbox-aligned bounds (not grid-snapped) to match interactive-transform + // compositing and avoid a visible offset vs the DOM canvas. + let tile_rect = self.get_current_tile_bounds()?; + self.surfaces.draw_current_tile_into_backbuffer( + &tile_rect, + self.background_color, + surfaces::DrawOnCache::No, + ); + return Ok(()); + } + let fast_mode = self.options.is_fast_mode(); // Decide *now* (at the first real cache blit) whether we need to clear Cache. // This avoids clearing Cache on renders that don't actually paint tiles (e.g. hover/UI), @@ -1043,6 +1070,55 @@ impl RenderState { self.focus_mode.set_shapes(shapes); } + pub fn clear_include_filter(&mut self) { + self.include_filter = None; + } + + pub fn set_include_filter(&mut self, shapes: Vec) { + self.include_filter = Some(shapes.into_iter().collect()); + } + + fn viewer_masked_pass(&self) -> bool { + self.include_filter.is_some() + } + + fn reset_viewer_masked_surfaces(&mut self) { + self.surfaces.clear_backbuffer(self.background_color); + self.surfaces.clear_tile_atlas(); + } + + /// True when the shape or any descendant is whitelisted. + pub fn shape_visible_in_include_filter(&self, shape_id: &Uuid, tree: ShapesPoolRef) -> bool { + let Some(ref include) = self.include_filter else { + return true; + }; + if include.contains(shape_id) { + return true; + } + let Some(shape) = tree.get(shape_id) else { + return false; + }; + shape + .children_ids_iter(false) + .any(|child_id| self.shape_visible_in_include_filter(child_id, tree)) + } + + /// When an include whitelist is active, only those ids are painted. + fn shape_should_paint_for_viewer_layer(&self, shape_id: &Uuid) -> bool { + match &self.include_filter { + Some(include) => include.contains(shape_id), + None => true, + } + } + + /// Viewer layer mask: traverse whitelisted subtrees; paint only listed ids. + pub fn shape_visible_for_viewer_layer(&self, shape_id: &Uuid, tree: ShapesPoolRef) -> bool { + if self.viewer_render_root.as_ref() == Some(shape_id) { + return true; + } + self.shape_visible_in_include_filter(shape_id, tree) + } + fn get_inherited_drop_shadows(&self) -> Option> { let drop_shadows: Vec<&Shadow> = self .nested_shadows @@ -2113,6 +2189,13 @@ impl RenderState { self.interactive_target_seeded = false; } + // Viewer fixed-scroll passes reuse the same WASM context; `reset` does not + // clear Backbuffer, so pass 2 would otherwise keep pass-1 pixels in regions + // that render no shapes for the current mask. Target is cleared in present_frame. + if self.viewer_masked_pass() { + self.reset_viewer_masked_surfaces(); + } + let surface_ids = SurfaceId::Strokes as u32 | SurfaceId::Fills as u32 | SurfaceId::InnerShadows as u32 @@ -3146,6 +3229,33 @@ impl RenderState { continue; } + if !self.shape_visible_for_viewer_layer(&node_id, tree) { + continue; + } + + // Ancestors needed to reach whitelisted descendants: traverse only. + if self.include_filter.is_some() + && self.shape_visible_for_viewer_layer(&node_id, tree) + && !self.shape_should_paint_for_viewer_layer(&node_id) + { + if element.is_recursive() { + let children_ids: Vec<_> = + element.children_ids_iter(false).copied().collect(); + let children_ids = sort_z_index(tree, element, children_ids); + for child_id in children_ids.iter() { + self.pending_nodes.push(NodeRenderState { + id: *child_id, + visited_children: false, + clip_bounds: clip_bounds.clone(), + visited_mask: false, + mask: false, + flattened: false, + }); + } + } + continue; + } + // For frames and groups, we must use extrect because they can have nested content // that extends beyond their selrect. Using selrect for early exit would incorrectly // skip frames/groups that have nested content in the current tile. @@ -3428,6 +3538,7 @@ impl RenderState { allow_stop: bool, ) -> Result { let mut should_stop = false; + self.viewer_render_root = base_object.copied(); let root_ids = { if let Some(shape_id) = base_object { vec![*shape_id] @@ -3443,7 +3554,10 @@ impl RenderState { if let Some(current_tile) = self.current_tile { // NOTE: For now we don't need to cover the case where the tile // is not cached because everything will be handled from draw_atlas. - if !self.surfaces.has_cached_tile_surface(current_tile) { + // Viewer masked passes (include_filter) must not reuse cached tiles from + // a previous pass; otherwise pass-1 pixels can leak into pass 2. + if self.viewer_masked_pass() || !self.surfaces.has_cached_tile_surface(current_tile) + { performance::begin_measure!("render_shape_tree::uncached"); let (is_empty, early_return) = self .render_shape_tree_partial_uncached(tree, timestamp, allow_stop, false)?; @@ -3454,6 +3568,7 @@ impl RenderState { } if early_return { + self.viewer_render_root = None; return Ok(FrameType::Partial); } performance::end_measure!("render_shape_tree::uncached"); @@ -3504,12 +3619,15 @@ impl RenderState { // empty tile. self.current_tile_had_shapes = false; + let viewer_masked_pass = self.viewer_masked_pass(); + let Some(ids) = self.tiles.get_shapes_at(next_tile) else { // If the tile is empty we do not need to render it. continue; }; - if self.surfaces.has_cached_tile_surface(next_tile) { + // Never skip based on cached surfaces during viewer masked passes. + if !viewer_masked_pass && self.surfaces.has_cached_tile_surface(next_tile) { // If the tile is cached, then we do not need to // render it. continue; @@ -3563,6 +3681,8 @@ impl RenderState { } } + self.viewer_render_root = None; + // Mark cache as valid for render_from_cache. // Only update for full-quality renders (non-fast mode). // An async render can complete while fast mode is active diff --git a/render-wasm/src/render/surfaces.rs b/render-wasm/src/render/surfaces.rs index 04d6948371..867b657c7b 100644 --- a/render-wasm/src/render/surfaces.rs +++ b/render-wasm/src/render/surfaces.rs @@ -864,6 +864,30 @@ impl Surfaces { ); } + /// Replace `Target` pixels with `Backbuffer` (Src blend). + /// + /// Used for viewer masked passes: transparent backbuffer regions must not + /// preserve prior `Target` content from an earlier pass. + pub fn copy_backbuffer_to_target_replace(&mut self) { + let sampling_options = self.sampling_options; + let mut paint = skia::Paint::default(); + paint.set_blend_mode(skia::BlendMode::Src); + self.backbuffer.draw( + self.target.canvas(), + (0.0, 0.0), + sampling_options, + Some(&paint), + ); + } + + pub fn clear_target(&mut self, color: skia::Color) { + self.target.canvas().clear(color); + } + + pub fn clear_tile_atlas(&mut self) { + self.tile_atlas.canvas().clear(skia::Color::TRANSPARENT); + } + /// Seed `Backbuffer` from `Target` (last presented frame). pub fn seed_backbuffer_from_target(&mut self) { let sampling_options = self.sampling_options; @@ -1026,6 +1050,17 @@ impl Surfaces { } } + /// Full backbuffer clear (viewer layer passes must not reuse prior pass pixels). + pub fn clear_backbuffer(&mut self, color: skia::Color) { + self.backbuffer.canvas().clear(color); + } + + pub fn clear_backbuffer_rect(&mut self, rect: skia::Rect, color: skia::Color) { + let mut paint = Paint::default(); + paint.set_color(color); + self.backbuffer.canvas().draw_rect(rect, &paint); + } + pub fn reset(&mut self, color: skia::Color) { self.canvas(SurfaceId::Fills).restore_to_count(1); self.canvas(SurfaceId::InnerShadows).restore_to_count(1); diff --git a/render-wasm/src/state.rs b/render-wasm/src/state.rs index 970c9cef1a..3b6835fd30 100644 --- a/render-wasm/src/state.rs +++ b/render-wasm/src/state.rs @@ -118,6 +118,14 @@ impl State { get_render_state().set_focus_mode(shapes); } + pub fn clear_include_filter(&mut self) { + get_render_state().clear_include_filter(); + } + + pub fn set_include_filter(&mut self, shapes: Vec) { + get_render_state().set_include_filter(shapes); + } + pub fn init_shapes_pool(&mut self, capacity: usize) { self.shapes.initialize(capacity); } From 27ba1ffbe06aa96f9a6cc0a7748d62d3015e56dc Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Mon, 8 Jun 2026 14:38:47 +0200 Subject: [PATCH 02/92] :paperclip: Update version on mcp/package.json --- mcp/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mcp/package.json b/mcp/package.json index 2318a80951..7c1a12d5e8 100644 --- a/mcp/package.json +++ b/mcp/package.json @@ -1,6 +1,6 @@ { "name": "@penpot/mcp", - "version": "2.16.0-rc.1.206", + "version": "2.16.0-rc.9.11", "description": "MCP server for Penpot integration", "bin": { "penpot-mcp": "./bin/mcp-local.js" From 3444c0589f77e91906a4930c3c62ff015bdae72a Mon Sep 17 00:00:00 2001 From: Alonso Torres Date: Mon, 8 Jun 2026 17:56:21 +0200 Subject: [PATCH 03/92] :bug: Fix parallel environments css hot reload (#10064) --- frontend/shadow-cljs.edn | 25 ++++++++++++------------- 1 file changed, 12 insertions(+), 13 deletions(-) diff --git a/frontend/shadow-cljs.edn b/frontend/shadow-cljs.edn index 4b2dea2b38..9956823eeb 100644 --- a/frontend/shadow-cljs.edn +++ b/frontend/shadow-cljs.edn @@ -9,14 +9,12 @@ {:target :esm :output-dir "resources/public/js/" :asset-path "/js" - ;; :devtools is dev-only, so it lives under :dev -- shadow merges that map - ;; for `watch`/`compile` but not `release`, keeping :devtools-url out of - ;; release entirely (shadow spec-checks it as non-empty-string? whenever the - ;; key is present, even in release). In the devenv SHADOW_SERVER_URL is - ;; always set per workspace (see defaults.env / manage.sh). - :dev {:devtools {:watch-dir "resources/public" - :reload-strategy :full - :devtools-url #shadow/env ["SHADOW_SERVER_URL" :default ""]}} + :devtools {:watch-dir "resources/public" + :reload-strategy :full} + + :dev {;; allows remote-relay per parallel environment + ;; inside :dev so the integration tests won't use it + :devtools {:devtools-url #shadow/env ["SHADOW_SERVER_URL" :default ""]}} :build-options {:manifest-name "manifest.json"} :modules {:shared @@ -92,11 +90,12 @@ {:target :browser :output-dir "resources/public/js/worker/" :asset-path "/js/worker" - ;; Dev-only; see the :main build above for why :devtools lives under :dev. - :dev {:devtools {:devtools-url #shadow/env ["SHADOW_SERVER_URL" :default ""] - :browser-inject :main - :watch-dir "resources/public" - :reload-strategy :full}} + :devtools {:watch-dir "resources/public" + :reload-strategy :full + :browser-inject :main} + :dev {;; allows remote-relay per parallel environment + ;; inside :dev so the integration tests won't use it + :devtools {:devtools-url #shadow/env ["SHADOW_SERVER_URL" :default ""]}} :build-options {:manifest-name "manifest.json"} :modules {:main From 70e8dbb38a64d61a8962eee7297a4b6d661cbeb4 Mon Sep 17 00:00:00 2001 From: "alonso.torres" Date: Thu, 28 May 2026 08:41:09 +0200 Subject: [PATCH 04/92] :bug: Fix cropped outer stroke of rotated board in view mode --- common/src/app/common/geom/shapes/bounds.cljc | 23 ++- ...et-file-fragment-rotated-board-stroke.json | 195 ++++++++++++++++++ ...view-only-bundle-rotated-board-stroke.json | 86 ++++++++ frontend/playwright/ui/pages/ViewerPage.js | 15 ++ .../specs/viewer-rotated-board-stroke.spec.js | 47 +++++ frontend/src/app/main/ui/viewer.cljs | 4 +- 6 files changed, 361 insertions(+), 9 deletions(-) create mode 100644 frontend/playwright/data/viewer/get-file-fragment-rotated-board-stroke.json create mode 100644 frontend/playwright/data/viewer/get-view-only-bundle-rotated-board-stroke.json create mode 100644 frontend/playwright/ui/specs/viewer-rotated-board-stroke.spec.js 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 From d249fd106a0f260398ca49587d14d5c3571f8cf6 Mon Sep 17 00:00:00 2001 From: Alonso Torres Date: Tue, 9 Jun 2026 11:17:06 +0200 Subject: [PATCH 05/92] :bug: Fix theme problem after update (#9955) --- .../src/app/app.component.ts | 14 +++++--------- .../contrast-plugin/src/app/app.component.ts | 19 +++++-------------- .../icons-plugin/src/app/app.component.ts | 15 ++++++--------- .../src/app/app.component.ts | 14 +++++--------- .../src/app/app.component.ts | 15 +++++---------- .../src/app/app.component.ts | 15 ++++++--------- .../table-plugin/src/app/app.component.ts | 13 +++++-------- 7 files changed, 37 insertions(+), 68 deletions(-) diff --git a/plugins/apps/colors-to-tokens-plugin/src/app/app.component.ts b/plugins/apps/colors-to-tokens-plugin/src/app/app.component.ts index 7403a5aceb..1e1e204541 100644 --- a/plugins/apps/colors-to-tokens-plugin/src/app/app.component.ts +++ b/plugins/apps/colors-to-tokens-plugin/src/app/app.component.ts @@ -1,6 +1,5 @@ -import { Component, effect, inject, linkedSignal } from '@angular/core'; +import { Component, effect, linkedSignal } from '@angular/core'; import { toSignal } from '@angular/core/rxjs-interop'; -import { ActivatedRoute } from '@angular/router'; import type { PluginMessageEvent, PluginUIEvent, @@ -8,7 +7,7 @@ import type { SetColorsPluginEvent, TokenFileExtraData, } from '../model'; -import { filter, fromEvent, map, merge, take } from 'rxjs'; +import { filter, fromEvent, map, merge, of } from 'rxjs'; import { transformToToken } from './utils/transform-to-token'; import { SvgComponent } from './components/svg.component'; @@ -70,14 +69,11 @@ import { SvgComponent } from './components/svg.component'; }, }) export class AppComponent { - route = inject(ActivatedRoute); messages$ = fromEvent>(window, 'message'); - initialTheme$ = this.route.queryParamMap.pipe( - map((params) => params.get('theme')), - filter((theme) => !!theme), - take(1), - ); + initialTheme$ = of( + new URLSearchParams(window.location.search).get('theme'), + ).pipe(filter((theme) => !!theme)); theme = toSignal( merge( diff --git a/plugins/apps/contrast-plugin/src/app/app.component.ts b/plugins/apps/contrast-plugin/src/app/app.component.ts index b482460f78..6c4e9263bf 100644 --- a/plugins/apps/contrast-plugin/src/app/app.component.ts +++ b/plugins/apps/contrast-plugin/src/app/app.component.ts @@ -1,17 +1,11 @@ -import { - ChangeDetectionStrategy, - Component, - computed, - inject, -} from '@angular/core'; +import { ChangeDetectionStrategy, Component, computed } from '@angular/core'; import { toSignal } from '@angular/core/rxjs-interop'; -import { ActivatedRoute } from '@angular/router'; import type { PluginMessageEvent, PluginUIEvent, ThemePluginEvent, } from '../model'; -import { filter, fromEvent, map, merge, take } from 'rxjs'; +import { filter, fromEvent, map, merge, of } from 'rxjs'; import { CommonModule } from '@angular/common'; import { Shape } from '@penpot/plugin-types'; @@ -118,14 +112,11 @@ import { Shape } from '@penpot/plugin-types'; changeDetection: ChangeDetectionStrategy.OnPush, }) export class AppComponent { - #route = inject(ActivatedRoute); #messages$ = fromEvent>(window, 'message'); - #initialTheme$ = this.#route.queryParamMap.pipe( - map((params) => params.get('theme')), - filter((theme) => !!theme), - take(1), - ); + #initialTheme$ = of( + new URLSearchParams(window.location.search).get('theme'), + ).pipe(filter((theme) => !!theme)); selection = toSignal( this.#messages$.pipe( diff --git a/plugins/apps/icons-plugin/src/app/app.component.ts b/plugins/apps/icons-plugin/src/app/app.component.ts index 4c3ebc6ed3..ee24b3e356 100644 --- a/plugins/apps/icons-plugin/src/app/app.component.ts +++ b/plugins/apps/icons-plugin/src/app/app.component.ts @@ -1,10 +1,10 @@ -import { Component, inject, signal } from '@angular/core'; -import { ActivatedRoute, RouterModule } from '@angular/router'; +import { Component, signal } from '@angular/core'; +import { RouterModule } from '@angular/router'; import { FeatherIconNames, icons } from 'feather-icons'; import { IconButtonComponent } from './components/icon-button/icon-button.component'; import { IconSearchComponent } from './components/icon-search/icon-search.component'; import { toSignal } from '@angular/core/rxjs-interop'; -import { filter, fromEvent, map, merge, take } from 'rxjs'; +import { filter, fromEvent, map, merge, of } from 'rxjs'; import { PluginMessageEvent } from '../model'; @Component({ @@ -36,7 +36,6 @@ import { PluginMessageEvent } from '../model'; }, }) export class AppComponent { - public route = inject(ActivatedRoute); public icons = signal(icons); public iconKeys = signal(Object.keys(icons) as FeatherIconNames[]); public messages$ = fromEvent>( @@ -44,11 +43,9 @@ export class AppComponent { 'message', ); - public initialTheme$ = this.route.queryParamMap.pipe( - map((params) => params.get('theme')), - filter((theme) => !!theme), - take(1), - ); + public initialTheme$ = of( + new URLSearchParams(window.location.search).get('theme'), + ).pipe(filter((theme) => !!theme)); public theme = toSignal( merge( diff --git a/plugins/apps/lorem-ipsum-plugin/src/app/app.component.ts b/plugins/apps/lorem-ipsum-plugin/src/app/app.component.ts index 4c91dc0ba9..1016cc4a4e 100644 --- a/plugins/apps/lorem-ipsum-plugin/src/app/app.component.ts +++ b/plugins/apps/lorem-ipsum-plugin/src/app/app.component.ts @@ -1,13 +1,12 @@ -import { Component, inject } from '@angular/core'; +import { Component } from '@angular/core'; import { toSignal } from '@angular/core/rxjs-interop'; import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms'; -import { ActivatedRoute } from '@angular/router'; import type { GenerationTypes, PluginMessageEvent, PluginUIEvent, } from '../model'; -import { filter, fromEvent, map, merge, take } from 'rxjs'; +import { filter, fromEvent, map, merge, of } from 'rxjs'; @Component({ imports: [ReactiveFormsModule], @@ -67,14 +66,11 @@ import { filter, fromEvent, map, merge, take } from 'rxjs'; }, }) export class AppComponent { - route = inject(ActivatedRoute); messages$ = fromEvent>(window, 'message'); - initialTheme$ = this.route.queryParamMap.pipe( - map((params) => params.get('theme')), - filter((theme) => !!theme), - take(1), - ); + initialTheme$ = of( + new URLSearchParams(window.location.search).get('theme'), + ).pipe(filter((theme) => !!theme)); theme = toSignal( merge( diff --git a/plugins/apps/poc-tokens-plugin/src/app/app.component.ts b/plugins/apps/poc-tokens-plugin/src/app/app.component.ts index 8b60e40d8d..089649f6ac 100644 --- a/plugins/apps/poc-tokens-plugin/src/app/app.component.ts +++ b/plugins/apps/poc-tokens-plugin/src/app/app.component.ts @@ -1,7 +1,6 @@ -import { Component, inject } from '@angular/core'; +import { Component } from '@angular/core'; import { toSignal } from '@angular/core/rxjs-interop'; -import { ActivatedRoute } from '@angular/router'; -import { fromEvent, map, filter, take, merge } from 'rxjs'; +import { fromEvent, map, filter, merge, of } from 'rxjs'; import { PluginMessageEvent, PluginUIEvent } from '../model'; type TokenTheme = { @@ -39,18 +38,14 @@ type TokensGroup = [string, Token[]]; }, }) export class AppComponent { - public route = inject(ActivatedRoute); - public messages$ = fromEvent>( window, 'message', ); - public initialTheme$ = this.route.queryParamMap.pipe( - map((params) => params.get('theme')), - filter((theme) => !!theme), - take(1), - ); + public initialTheme$ = of( + new URLSearchParams(window.location.search).get('theme'), + ).pipe(filter((theme) => !!theme)); public theme = toSignal( merge( diff --git a/plugins/apps/rename-layers-plugin/src/app/app.component.ts b/plugins/apps/rename-layers-plugin/src/app/app.component.ts index bc6fc72c59..70055739cc 100644 --- a/plugins/apps/rename-layers-plugin/src/app/app.component.ts +++ b/plugins/apps/rename-layers-plugin/src/app/app.component.ts @@ -1,5 +1,5 @@ -import { Component, ElementRef, ViewChild, inject } from '@angular/core'; -import { ActivatedRoute, RouterModule } from '@angular/router'; +import { Component, ElementRef, ViewChild } from '@angular/core'; +import { RouterModule } from '@angular/router'; import { toSignal } from '@angular/core/rxjs-interop'; import { CommonModule } from '@angular/common'; import type { @@ -7,7 +7,7 @@ import type { ReplaceText, ThemePluginEvent, } from '../app/model'; -import { filter, fromEvent, map, merge, take } from 'rxjs'; +import { filter, fromEvent, map, merge, of } from 'rxjs'; import { FormsModule } from '@angular/forms'; import { Shape } from '@penpot/plugin-types'; @@ -24,7 +24,6 @@ export class AppComponent { @ViewChild('searchElement') public searchElement!: ElementRef; @ViewChild('addElement') public addElement!: ElementRef; - route = inject(ActivatedRoute); messages$ = fromEvent>(window, 'message'); public textToReplace: ReplaceText = { search: '', @@ -38,11 +37,9 @@ export class AppComponent { this.sendMessage({ type: 'ready' }); } - initialTheme$ = this.route.queryParamMap.pipe( - map((params) => params.get('theme')), - filter((theme) => !!theme), - take(1), - ); + initialTheme$ = of( + new URLSearchParams(window.location.search).get('theme'), + ).pipe(filter((theme) => !!theme)); theme = toSignal( merge( diff --git a/plugins/apps/table-plugin/src/app/app.component.ts b/plugins/apps/table-plugin/src/app/app.component.ts index cb13e22061..4cce7de045 100644 --- a/plugins/apps/table-plugin/src/app/app.component.ts +++ b/plugins/apps/table-plugin/src/app/app.component.ts @@ -1,5 +1,5 @@ import { Component, inject } from '@angular/core'; -import { ActivatedRoute, RouterModule } from '@angular/router'; +import { RouterModule } from '@angular/router'; import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop'; import type { @@ -8,7 +8,7 @@ import type { TableConfigEvent, TableOptions, } from '../app/model'; -import { filter, fromEvent, map, merge, take } from 'rxjs'; +import { filter, fromEvent, map, merge, of, take } from 'rxjs'; import { FormBuilder, ReactiveFormsModule, FormGroup } from '@angular/forms'; @Component({ @@ -37,14 +37,11 @@ export class AppComponent { alternateRows: [false], }); - route = inject(ActivatedRoute); messages$ = fromEvent>(window, 'message'); - initialTheme$ = this.route.queryParamMap.pipe( - map((params) => params.get('theme')), - filter((theme) => !!theme), - take(1), - ); + initialTheme$ = of( + new URLSearchParams(window.location.search).get('theme'), + ).pipe(filter((theme) => !!theme)); theme = toSignal( merge( From 744c1b98c0b58016f1ca975e24514fd22d84d4c6 Mon Sep 17 00:00:00 2001 From: Codex Date: Fri, 15 May 2026 13:48:39 +0000 Subject: [PATCH 06/92] :bug: Anchor variant switch geometry to target Preserve real size overrides during variant switches without copying stale absolute composite geometry from the source variant. Signed-off-by: Codex --- common/src/app/common/logic/libraries.cljc | 37 ++++++++ .../logic/variants_switch_test.cljc | 90 ++++++++++++++++--- 2 files changed, 115 insertions(+), 12 deletions(-) diff --git a/common/src/app/common/logic/libraries.cljc b/common/src/app/common/logic/libraries.cljc index cec89730f9..d18f7019ec 100644 --- a/common/src/app/common/logic/libraries.cljc +++ b/common/src/app/common/logic/libraries.cljc @@ -2101,6 +2101,39 @@ (grc/rect->center selrect) (or (:transform current-shape) (gmt/matrix))))))) + +(defn- switch-geom-change-value + [prev-shape current-shape attr] + ;; Composite geometry stores absolute coordinates. When preserving a size + ;; override across variants, keep the target variant's position and only carry + ;; the previous dimensions; otherwise :x/:y can disagree with :selrect/:points. + (let [prev-selrect (:selrect prev-shape) + current-selrect (:selrect current-shape) + final-width (:width prev-selrect) + final-height (:height prev-selrect) + x (:x current-selrect) + y (:y current-selrect) + selrect (assoc current-selrect + :width final-width + :height final-height + :x x + :y y + :x1 x + :y1 y + :x2 (+ x final-width) + :y2 (+ y final-height))] + (case attr + :selrect + selrect + + :points + (-> selrect + (grc/rect->points) + (gsh/transform-points + (grc/rect->center selrect) + (or (:transform current-shape) (gmt/matrix))))))) + + (defn- equal-geometry? "Returns true when the value of `attr` in `shape` is considered equal to the corresponding value in `origin-shape`, ignoring positional @@ -2270,6 +2303,10 @@ (contains? #{:points :selrect :width :height} attr)) (switch-fixed-layout-geom-change-value previous-shape current-shape origin-ref-shape attr) + (and (contains? #{:points :selrect} attr) + (not path-change?)) + (switch-geom-change-value previous-shape current-shape attr) + :else (get previous-shape attr))) diff --git a/common/test/common_tests/logic/variants_switch_test.cljc b/common/test/common_tests/logic/variants_switch_test.cljc index f796f59c5d..ed9eeae783 100644 --- a/common/test/common_tests/logic/variants_switch_test.cljc +++ b/common/test/common_tests/logic/variants_switch_test.cljc @@ -2866,18 +2866,14 @@ (t/is (= (get-in rect02' [:selrect :width]) 150)))) -(t/deftest test-switch-when-source-master-child-has-touched-geometry - ;; Regression: when the previous-shape's geometry has sub-pixel drift +(t/deftest test-switch-skips-composite-geometry-with-subpixel-drift + ;; Regression: when the previous-shape's geometry only has sub-pixel drift ;; relative to its source master (a state produced by interactive transform ;; modifiers, e.g. alt-drag duplicate of a variant whose children are - ;; component copies), the equal-geometry? guard in update-attrs-on-switch - ;; uses exact equality and fails. The :else branch then copies - ;; previous-shape's :selrect verbatim onto the freshly-instantiated target, - ;; leaving :y correct (the per-attr y skip catches that) but :selrect.y - ;; stale. The shape ends up internally inconsistent (:y disagrees with - ;; :selrect.y); the renderer reads :selrect, so the child appears at the - ;; source variant's position inside a parent that has resized to the - ;; target's dimensions — the visible "cut off" symptom. + ;; component copies), equal-geometry? must classify it as unchanged and skip + ;; copying composite geometry. Otherwise, :selrect/:points can carry stale + ;; absolute positions from the source variant onto the freshly-instantiated + ;; target, producing the visible "cut off" symptom. (let [;; ==== Setup ;; A self-contained Input/Button-like component, plus a variant ;; container whose two variants each instance that component @@ -2911,8 +2907,8 @@ ;; The copy carries an Input/Button instance (Frame1). Introduce ;; sub-pixel drift in its :width and :selrect.width — the kind of ;; floating-point error produced by the alt-drag modifier path in - ;; production. This drift is what defeats equal-geometry?'s - ;; exact-equality comparison and lets the bug surface. + ;; production. The drift is small enough to be treated as unchanged + ;; geometry by equal-geometry?. page (thf/current-page file) copy01 (ths/get-shape file :copy01) copy-btn-id (->> (cfh/get-children-ids-with-self (:objects page) (:id copy01)) @@ -3035,3 +3031,73 @@ (t/is (= target-rel-y actual-rel-y) (str "path :selrect.y should match target master layout (expected " target-rel-y " got " actual-rel-y ")")))) + + +(t/deftest test-switch-preserves-size-override-at-target-position + (let [move-to (fn [shape x y] + (gsh/move shape (gpt/point (- x (:x shape)) + (- y (:y shape))))) + + ;; ==== Setup: each variant contains the same nested component instance. + ;; The nested instance has identical size in both variants, but a different + ;; position relative to the variant root. + file (-> (thf/sample-file :file1) + (tho/add-simple-component + :nested-component :nested-main :nested-label + :root-params {:width 100 :height 50} + :child-params {:width 30 :height 10}) + (thv/add-variant-with-copy + :v01 :c01 :m01 :c02 :m02 :r01 :r02 :nested-component)) + + page (thf/current-page file) + r01 (ths/get-shape file :r01) + r02 (ths/get-shape file :r02) + changes (cls/generate-update-shapes (pcb/empty-changes nil (:id page)) + #{(:id r01) (:id r02)} + (fn [shape] + (cond + (= (:id shape) (:id r01)) (move-to shape 20 100) + (= (:id shape) (:id r02)) (move-to shape 20 70) + :else shape)) + (:objects page) + {}) + file (thf/apply-changes file changes) + + file (thc/instantiate-component file :c01 + :copy01 + :children-labels [:copy-r01]) + page (thf/current-page file) + copy01 (ths/get-shape file :copy01) + copy-r01 (get-in page [:objects (-> copy01 :shapes first)]) + + ;; This is a real geometry override, not float drift. The switch should + ;; preserve the overridden size while anchoring composite geometry to + ;; the target variant's position. + changes (cls/generate-update-shapes (pcb/empty-changes nil (:id page)) + #{(:id copy-r01)} + (fn [shape] + (let [new-width 150 + sr (:selrect shape) + new-sr (-> sr + (assoc :width new-width) + (assoc :x2 (+ (:x1 sr) new-width)))] + (-> shape + (assoc :width new-width) + (assoc :selrect new-sr) + (assoc :touched #{:geometry-group})))) + (:objects page) + {}) + file (thf/apply-changes file changes) + + ;; ==== Action + file' (tho/swap-component-in-shape file :copy01 :c02 {:new-shape-label :copy02 :keep-touched? true}) + + page' (thf/current-page file') + copy02' (ths/get-shape file' :copy02) + rect02' (get-in page' [:objects (-> copy02' :shapes first)])] + + ;; The width override is preserved, but the target variant position remains + ;; authoritative for absolute composite geometry. + (t/is (= 150 (:width rect02'))) + (t/is (= (+ (:y copy02') 70) (:y rect02'))) + (t/is (= (:y rect02') (get-in rect02' [:selrect :y]))))) From 06c83553fde0bbb68116b482fa17e38830652254 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Tue, 9 Jun 2026 12:59:55 +0200 Subject: [PATCH 07/92] :books: Add creating issues workflow to serena memory --- .serena/memories/critical-info.md | 2 +- .serena/memories/workflow/creating-issues.md | 160 +++++++++++++++++++ 2 files changed, 161 insertions(+), 1 deletion(-) create mode 100644 .serena/memories/workflow/creating-issues.md diff --git a/.serena/memories/critical-info.md b/.serena/memories/critical-info.md index 17bb15bc55..496bcf6f2e 100644 --- a/.serena/memories/critical-info.md +++ b/.serena/memories/critical-info.md @@ -9,7 +9,7 @@ You are working on the GitHub project `penpot/penpot`, a monorepo. # Development workflow -- Commit only when explicitly asked. Commit/PR format + changelog: `mem:workflow/creating-commits`, `mem:workflow/creating-prs`. +- Commit only when explicitly asked. Commit/PR format + changelog: `mem:workflow/creating-commits`, `mem:workflow/creating-prs`. Issue creation (titles, labels, body templates, Issue Types): `mem:workflow/creating-issues`. - You have access to the GitHub CLI `gh` or corresponding MCP tools. - Issues are also managed on Taiga. Read issues using the `read_taiga_issue` tool. - Before writing code, analyze the task in depth and describe your plan. If the task is complex, break it down into atomic steps. diff --git a/.serena/memories/workflow/creating-issues.md b/.serena/memories/workflow/creating-issues.md new file mode 100644 index 0000000000..42a07f7887 --- /dev/null +++ b/.serena/memories/workflow/creating-issues.md @@ -0,0 +1,160 @@ +# Creating Issues + +Create GitHub issues only on explicit request. Use `gh` CLI authenticated to `penpot/penpot`. + +## Title Derivation + +Derive the title from the source material (bug report, user feedback, feature request, etc.) — not from any pre-existing title which may be auto-generated or stale. + +### Bug titles (descriptive present tense) + +Describe the symptom as it appears to the user. Format: `[Where] [present-tense verb] when [condition]`. + +- *"Plugin API crashes when setting text fills"* +- *"Canvas renders glitches when zooming quickly"* +- *"French Canada locale falls back to French (fr) translations"* + +Do **not** start bug titles with "Fix" or any imperative verb — state what's broken, not command a fix. + +### Feature / Enhancement titles (imperative mood) + +Command what should be built. Format: `[Imperative verb] [what] in/on [where]`. + +- *"Add customizable dash and gap length controls to dashed strokes in the sidebar"* +- *"Show user, timestamp, and hash in the workspace history panel like git commits"* + +### Universal rules + +- **Include the "where"** — specify the UI location or module (e.g. "in the sidebar", "on the stroke options") +- **No prefixes** — strip `bug:`, `feature:`, `feat:`, `:bug:`, `:sparkles:`, `[PENPOT FEEDBACK]`, etc. +- **No emoji** — plain text only +- **Be specific** — prefer concrete detail over generality +- **Two problems → cover both** — if the description has two distinct but related issues, capture both joined by "and" + +## Metadata + +| Field | Rule | +|-------|------| +| **Labels** | `bug` (crashes/regressions) · `enhancement` (new features) · `community contribution` (PRs from non-core) · skip workflow labels (`backport candidate`, `team-qa`) | +| **Milestone** | Use the current or next planned milestone. Fetch available milestones: `gh api repos/penpot/penpot/milestones --jq '.[].title'`. If unsure, omit. | +| **Project** | Always `Main` (project number 8). Use `--project "Main"` flag. | +| **Issue Type** | See Issue Type section below. Cannot be set via `gh issue create` — use GraphQL after creation. | + +## Issue Body Template + +Write the body to a temp file to avoid shell quoting issues: + +**Bug template:** +```markdown +### Description + + + +### Steps to reproduce + +1. +2. + +### Expected behavior + + + +### Affected versions + + +``` + +**Enhancement template:** +```markdown +### Description + + + +### Use case + + + +### Affected versions + + +``` + +## Creating the Issue + +```bash +cat > /tmp/issue-body.md << 'ISSUE_BODY' + +ISSUE_BODY + +gh issue create \ + --repo penpot/penpot \ + --title "" \ + --label "