From acb3997ed7054b076925230b549f4b4e300e24da Mon Sep 17 00:00:00 2001 From: Aitor Moreno Date: Wed, 29 Apr 2026 15:07:22 +0200 Subject: [PATCH 1/7] :bug: Fix text editor v2 min size --- frontend/src/app/main/ui/workspace/shapes/text/v2_editor.cljs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/frontend/src/app/main/ui/workspace/shapes/text/v2_editor.cljs b/frontend/src/app/main/ui/workspace/shapes/text/v2_editor.cljs index 75f6d4c4d6..b0b89a5102 100644 --- a/frontend/src/app/main/ui/workspace/shapes/text/v2_editor.cljs +++ b/frontend/src/app/main/ui/workspace/shapes/text/v2_editor.cljs @@ -289,7 +289,9 @@ :ref container-ref :data-testid "text-editor-container" :style {:width "var(--editor-container-width)" - :height "var(--editor-container-height)"}} + :height "var(--editor-container-height)" + :min-width "1px" + :min-height "1px"}} ;; We hide the editor when is blurred because otherwise the ;; selection won't let us see the underlying text. Use opacity ;; because display or visibility won't allow to recover focus From de9170d96b02b3647f23ce13b1399063c4d7eb66 Mon Sep 17 00:00:00 2001 From: Alonso Torres Date: Thu, 30 Apr 2026 11:27:50 +0200 Subject: [PATCH 2/7] :bug: Fix z-index for profile menu (#9257) --- frontend/src/app/main/ui/dashboard/sidebar.scss | 1 - 1 file changed, 1 deletion(-) diff --git a/frontend/src/app/main/ui/dashboard/sidebar.scss b/frontend/src/app/main/ui/dashboard/sidebar.scss index 0b5da67e3f..7b83394abe 100644 --- a/frontend/src/app/main/ui/dashboard/sidebar.scss +++ b/frontend/src/app/main/ui/dashboard/sidebar.scss @@ -26,7 +26,6 @@ margin: 0 var(--sp-l) 0 0; border-right: $b-1 solid var(--panel-border-color); background-color: var(--panel-background-color); - z-index: var(--z-index-panels); } //SIDEBAR CONTENT COMPONENT From 27d854ed5bdc292334dbcfbf4973ef78e0b28c72 Mon Sep 17 00:00:00 2001 From: Elena Torro Date: Wed, 29 Apr 2026 11:39:21 +0200 Subject: [PATCH 3/7] :zap: Skip component-sync on pure-translation drag commits --- .../src/app/common/files/changes_builder.cljc | 6 +++++ frontend/src/app/main/data/changes.cljs | 10 ++++--- .../app/main/data/workspace/libraries.cljs | 8 +++++- .../app/main/data/workspace/modifiers.cljs | 27 +++++++++++-------- .../src/app/main/data/workspace/shapes.cljs | 5 ++-- 5 files changed, 39 insertions(+), 17 deletions(-) diff --git a/common/src/app/common/files/changes_builder.cljc b/common/src/app/common/files/changes_builder.cljc index 93ac58d03b..d778c60b36 100644 --- a/common/src/app/common/files/changes_builder.cljc +++ b/common/src/app/common/files/changes_builder.cljc @@ -67,6 +67,12 @@ (some? undo-group) (assoc :undo-group undo-group))) +(defn set-translation? + [changes translation?] + (cond-> changes + translation? + (assoc :translation? true))) + (defn with-page [changes page] (vary-meta changes assoc diff --git a/frontend/src/app/main/data/changes.cljs b/frontend/src/app/main/data/changes.cljs index 7cae1add0f..a2d493f1b8 100644 --- a/frontend/src/app/main/data/changes.cljs +++ b/frontend/src/app/main/data/changes.cljs @@ -122,7 +122,8 @@ (defn commit "Create a commit event instance" [{:keys [commit-id redo-changes undo-changes origin save-undo? features - file-id file-revn file-vern undo-group tags stack-undo? source ignore-wasm?]}] + file-id file-revn file-vern undo-group tags stack-undo? source ignore-wasm? + translation?]}] (assert (cpc/check-changes redo-changes) "expect valid vector of changes for redo-changes") @@ -148,7 +149,8 @@ :undo-group undo-group :tags tags :stack-undo? stack-undo? - :ignore-wasm? ignore-wasm?}] + :ignore-wasm? ignore-wasm? + :translation? translation?}] (ptk/reify ::commit cljs.core/IDeref @@ -186,7 +188,8 @@ - undo-group: if some consecutive changes (or even transactions) share the same undo-group, they will be undone or redone in a single step " - [{:keys [redo-changes undo-changes save-undo? undo-group tags stack-undo? file-id] + [{:keys [redo-changes undo-changes save-undo? undo-group tags stack-undo? file-id + translation?] :or {save-undo? true stack-undo? false undo-group (uuid/next) @@ -216,4 +219,5 @@ (assoc :file-vern (resolve-file-vern state file-id)) (assoc :undo-changes uchg) (assoc :redo-changes rchg) + (assoc :translation? translation?) (commit)))))))) diff --git a/frontend/src/app/main/data/workspace/libraries.cljs b/frontend/src/app/main/data/workspace/libraries.cljs index 6bfbb00acf..9fb3e85feb 100644 --- a/frontend/src/app/main/data/workspace/libraries.cljs +++ b/frontend/src/app/main/data/workspace/libraries.cljs @@ -1382,8 +1382,14 @@ check-changes (fn [[event old-data]] - (if (nil? old-data) + (cond + (nil? old-data) (rx/empty) + + (:translation? event) + (rx/empty) + + :else (let [{:keys [file-id changes save-undo? undo-group]} event changed-components diff --git a/frontend/src/app/main/data/workspace/modifiers.cljs b/frontend/src/app/main/data/workspace/modifiers.cljs index ed8e44e3ca..63a4be935f 100644 --- a/frontend/src/app/main/data/workspace/modifiers.cljs +++ b/frontend/src/app/main/data/workspace/modifiers.cljs @@ -659,15 +659,14 @@ snap-pixel? (and (not ignore-snap-pixel) (contains? (:workspace-layout state) :snap-pixel-grid))] (set-wasm-props! objects prev-wasm-props wasm-props) - (let [structure-entries (parse-structure-modifiers modif-tree)] - (wasm.api/set-structure-modifiers structure-entries) - (let [geometry-entries (parse-geometry-modifiers modif-tree) - modifiers (wasm.api/propagate-modifiers geometry-entries snap-pixel?)] - (wasm.api/set-modifiers modifiers) - (let [ids (into [] xf:map-key geometry-entries) - selrect (wasm.api/get-selection-rect ids)] - (rx/of (set-temporary-selrect selrect) - (set-temporary-modifiers modifiers))))))))) + (wasm.api/set-structure-modifiers (parse-structure-modifiers modif-tree)) + (let [geometry-entries (parse-geometry-modifiers modif-tree) + modifiers (wasm.api/propagate-modifiers geometry-entries snap-pixel?)] + (wasm.api/set-modifiers modifiers) + (let [ids (into [] xf:map-key geometry-entries) + selrect (wasm.api/get-selection-rect ids)] + (rx/of (set-temporary-selrect selrect) + (set-temporary-modifiers modifiers)))))))) (defn propagate-structure-modifiers [modif-tree objects] @@ -701,8 +700,7 @@ ptk/WatchEvent (watch [_ state _] (wasm.api/clean-modifiers) - (let [structure-entries (parse-structure-modifiers modif-tree)] - (wasm.api/set-structure-modifiers structure-entries)) + (wasm.api/set-structure-modifiers (parse-structure-modifiers modif-tree)) ;; Apply property changes (e.g. grow-type) to WASM shapes before ;; propagating geometry, so propagate_modifiers sees the updated state. @@ -722,6 +720,12 @@ transforms (into {} (wasm.api/propagate-modifiers geometry-entries snap-pixel?)) + ;; Pure-translation gesture: every shape's modifier only + ;; contains `:move` operations (no resize/rotate/scale and + ;; no structural mutation) + translation? + (every? #(ctm/only-move? (:modifiers %)) (vals modif-tree)) + ignore-tree (calculate-ignore-tree-wasm transforms objects) @@ -729,6 +733,7 @@ (-> params (assoc :reg-objects? true) (assoc :ignore-tree ignore-tree) + (assoc :translation? translation?) ;; Attributes that can change in the transform. This ;; way we don't have to check all the attributes (assoc :attrs transform-attrs)) diff --git a/frontend/src/app/main/data/workspace/shapes.cljs b/frontend/src/app/main/data/workspace/shapes.cljs index 022dd11d21..dbfc1291c9 100644 --- a/frontend/src/app/main/data/workspace/shapes.cljs +++ b/frontend/src/app/main/data/workspace/shapes.cljs @@ -50,7 +50,7 @@ ([ids update-fn] (update-shapes ids update-fn nil)) ([ids update-fn {:keys [reg-objects? save-undo? stack-undo? attrs ignore-tree page-id - ignore-touched undo-group with-objects? changed-sub-attr] + ignore-touched undo-group with-objects? changed-sub-attr translation?] :or {reg-objects? false save-undo? true stack-undo? false @@ -90,7 +90,8 @@ :ignore-touched ignore-touched :with-objects? with-objects?}) (cond-> undo-group - (pcb/set-undo-group undo-group))) + (pcb/set-undo-group undo-group)) + (pcb/set-translation? translation?)) changes (add-undo-group changes state)] From 97688cb790a89c4d429688558b8300086f37aa7a Mon Sep 17 00:00:00 2001 From: Alejandro Alonso Date: Thu, 30 Apr 2026 14:05:31 +0200 Subject: [PATCH 4/7] :tada: Optimize wasm release profile (thin LTO, size optimizations) --- render-wasm/Cargo.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/render-wasm/Cargo.toml b/render-wasm/Cargo.toml index 77d43ba94d..77c4c10715 100644 --- a/render-wasm/Cargo.toml +++ b/render-wasm/Cargo.toml @@ -40,3 +40,4 @@ uuid = { version = "1.11.0", features = ["v4", "js"] } opt-level = 3 lto = "fat" strip = true +codegen-units = 1 From 17e0b545d226fa579ba48c4d7a4b6fb715214127 Mon Sep 17 00:00:00 2001 From: Alejandro Alonso Date: Tue, 28 Apr 2026 12:15:46 +0200 Subject: [PATCH 5/7] :tada: Cache selection crops from Backbuffer during drag --- render-wasm/src/main.rs | 3 - render-wasm/src/render.rs | 257 ++++++++++++++++++++++++++- render-wasm/src/render/surfaces.rs | 10 ++ render-wasm/src/state/shapes_pool.rs | 12 ++ render-wasm/src/tiles.rs | 69 ++----- 5 files changed, 296 insertions(+), 55 deletions(-) diff --git a/render-wasm/src/main.rs b/render-wasm/src/main.rs index f0a1ab1381..ee53f8a98c 100644 --- a/render-wasm/src/main.rs +++ b/render-wasm/src/main.rs @@ -464,9 +464,6 @@ pub extern "C" fn set_modifiers_start() -> Result<()> { performance::begin_measure!("set_modifiers_start"); state.render_state.options.set_fast_mode(true); state.render_state.options.set_interactive_transform(true); - // Capture the last fully-rendered frame as a stable backdrop for the drag. - // This avoids relying on atlas/cache correctness during fast_mode. - state.render_state.surfaces.copy_target_to_backbuffer(); performance::end_measure!("set_modifiers_start"); }); Ok(()) diff --git a/render-wasm/src/render.rs b/render-wasm/src/render.rs index 8ecd87c5f2..897fb733af 100644 --- a/render-wasm/src/render.rs +++ b/render-wasm/src/render.rs @@ -15,8 +15,7 @@ mod ui; use skia_safe::{self as skia, Matrix, RRect, Rect}; use std::borrow::Cow; -use std::collections::HashMap; -use std::collections::HashSet; +use std::collections::{HashMap, HashSet}; use gpu_state::GpuState; @@ -384,6 +383,15 @@ pub(crate) struct RenderState { /// interactive backdrop exactly once per gesture (first rAF) so we don't /// repeatedly overwrite tiles that have already been updated. pub interactive_target_seeded: bool, + /// GPU crops from `Backbuffer` keyed by shape id. Filled on full-frame completion; during + /// drag, entries for the moved top-level selection are ensured here + pub backbuffer_crop_cache: HashMap, +} + +pub struct InteractiveDragCrop { + pub src_doc_bounds: Rect, + pub src_selrect: Rect, + pub image: skia::Image, } pub fn get_cache_size(viewbox: Viewbox, scale: f32, interest: i32) -> skia::ISize { @@ -403,6 +411,72 @@ pub fn get_cache_size(viewbox: Viewbox, scale: f32, interest: i32) -> skia::ISiz } impl RenderState { + /// Decide whether a top-level node can be served from `backbuffer_crop_cache` during an + /// interactive transform (drag/resize/rotate). + /// + /// We only reuse cached pixels when it is safe and visually correct: + /// - **Top-level only**: cache entries are built for direct children of the root. + /// - **Moved node**: only allow cache reuse for *pure translations* (no scale/rotate/skew), + /// because other transforms would require resampling and can diverge from the live render. + /// - **Other cached nodes**: if the moving bounds overlap this cached crop, invalidate it so + /// we don't show stale content while something moves over/inside it. + fn should_use_cached_top_level_during_interactive( + &mut self, + node_id: Uuid, + tree: ShapesPoolRef, + moved_ids: &[Uuid], + moved_bounds: Option, + ) -> bool { + if !self.backbuffer_crop_cache.contains_key(&node_id) { + return false; + } + let Some(raw) = tree.get_raw(&node_id) else { + return false; + }; + if raw.parent_id != Some(Uuid::nil()) { + return false; + } + + // If this top-level shape itself is being moved, always allow using its cached pixels. + // BUT only for pure translations. For non-translation transforms (scale/rotate/skew), + // cached pixels won't match the live result (and may require resampling), so render live. + if moved_ids.contains(&node_id) { + let Some(m) = tree.get_modifier(&node_id) else { + return false; + }; + return crate::math::is_move_only_matrix(m); + } + + // Invalidate cached top-level pixels whenever the moving content overlaps + // the cached pixel area. Use `src_doc_bounds` because it's the exact bounds + // captured from the Backbuffer crop (more reliable than extents derived + // from layout/layout-less container heuristics). + if let Some(moved) = moved_bounds { + let intersects = self + .backbuffer_crop_cache + .get(&node_id) + .is_some_and(|crop| moved.intersects(crop.src_doc_bounds)); + + if intersects { + // Simplest "automatic invalidation": once something moves over this cached + // area, drop the cached crop so it won't be reused again until the next + // full-frame rebuild. + self.backbuffer_crop_cache.remove(&node_id); + return false; + } + } + true + } + + fn is_recortable_for_drag_crop(&self, shape: &Shape) -> bool { + // "Recortable" (happy path): the shape is fully represented by the pixels + // already in Backbuffer and can be moved as a texture during drag. + shape.blur.is_none() + && shape.shadows.is_empty() + && (shape.opacity - 1.0).abs() <= 1e-4 + && shape.blend_mode().0 == skia::BlendMode::SrcOver + } + pub fn try_new(width: i32, height: i32) -> Result { // This needs to be done once per WebGL context. let mut gpu_state = GpuState::try_new()?; @@ -460,6 +534,7 @@ impl RenderState { cache_cleared_this_render: false, current_tile_had_shapes: false, interactive_target_seeded: false, + backbuffer_crop_cache: HashMap::default(), }) } @@ -1541,6 +1616,97 @@ impl RenderState { } } + fn rebuild_backbuffer_crop_cache(&mut self, tree: ShapesPoolRef) { + self.backbuffer_crop_cache.clear(); + + // Collect candidate shapes that are "recortable" and visible in the current viewport. + // This is intentionally conservative; we only cache shapes that do not overlap with + // ANY other candidate to guarantee the pixels under their bounds belong exclusively + // to that shape in Backbuffer. + let viewport = self.viewbox.area; + let mut candidates: Vec<(Uuid, Rect, Rect)> = Vec::new(); // (id, doc_bounds, selrect) + + let root_ids: Vec = match tree.get(&Uuid::nil()) { + Some(root) => root.children_ids(false), + None => Vec::new(), + }; + + for shape_id in root_ids { + let Some(shape) = tree.get(&shape_id) else { + continue; + }; + if shape.hidden { + continue; + } + if !self.is_recortable_for_drag_crop(shape) { + continue; + } + + let doc_bounds = self.get_cached_extrect(shape, tree, 1.0); + if !doc_bounds.intersects(viewport) { + continue; + } + + // Also require selrect to be visible; used for drag delta placement. + let selrect = shape.selrect(); + if !selrect.intersects(viewport) { + continue; + } + + candidates.push((shape.id, doc_bounds, selrect)); + } + + // Filter out any candidate that overlaps with any other candidate. + let mut non_overlapping: Vec<(Uuid, Rect, Rect)> = Vec::new(); + 'outer: for (i, (id, bounds, selrect)) in candidates.iter().enumerate() { + for (j, (_id2, bounds2, _sel2)) in candidates.iter().enumerate() { + if i == j { + continue; + } + if bounds.intersects(*bounds2) { + continue 'outer; + } + } + non_overlapping.push((*id, *bounds, *selrect)); + } + + // Snapshot from Backbuffer for each accepted shape. + let scale = self.get_scale(); + let vb_left = self.viewbox.area.left; + let vb_top = self.viewbox.area.top; + for (id, doc_bounds, selrect) in non_overlapping { + let left = ((doc_bounds.left - vb_left) * scale).floor() as i32; + let top = ((doc_bounds.top - vb_top) * scale).floor() as i32; + let right = ((doc_bounds.right - vb_left) * scale).ceil() as i32; + let bottom = ((doc_bounds.bottom - vb_top) * scale).ceil() as i32; + if right <= left || bottom <= top { + continue; + } + let src_irect = skia::IRect::new(left, top, right, bottom); + let Some(image) = self + .surfaces + .snapshot_rect(SurfaceId::Backbuffer, src_irect) + else { + continue; + }; + + let src_doc_bounds = Rect::new( + src_irect.left as f32 / scale + vb_left, + src_irect.top as f32 / scale + vb_top, + src_irect.right as f32 / scale + vb_left, + src_irect.bottom as f32 / scale + vb_top, + ); + self.backbuffer_crop_cache.insert( + id, + InteractiveDragCrop { + src_doc_bounds, + src_selrect: selrect, + image, + }, + ); + } + } + pub fn render_from_cache(&mut self, shapes: ShapesPoolRef) { let _start = performance::begin_timed_log!("render_from_cache"); performance::begin_measure!("render_from_cache"); @@ -1906,6 +2072,12 @@ impl RenderState { self.cancel_animation_frame(); self.render_request_id = Some(wapi::request_animation_frame!()); } else { + // A full-quality frame is now complete. Refresh Backbuffer and regenerate + // the per-shape crop cache so interactive drags can reuse pixels. + if !self.options.is_fast_mode() && !self.options.is_interactive_transform() { + self.surfaces.copy_target_to_backbuffer(); + self.rebuild_backbuffer_crop_cache(tree); + } wapi::notify_tiles_render_complete!(); performance::end_measure!("render"); } @@ -2706,6 +2878,29 @@ impl RenderState { target_surface = SurfaceId::Export; } + // During interactive transforms we compute the union of the current bounds of all + // modified shapes (doc-space @ 100% zoom, scale=1.0). This is used as a cheap overlap + // guard to decide when cached top-level crops are unsafe to reuse (something is moving + // over/inside them), without doing expensive ancestor walks per node. + let moved_bounds = + if self.options.is_interactive_transform() && !tree.modifier_ids().is_empty() { + let mut acc: Option = None; + for id in tree.modifier_ids().iter() { + let Some(s) = tree.get(id) else { continue }; + let r = self.get_cached_extrect(s, tree, 1.0); + acc = Some(match acc { + None => r, + Some(mut prev) => { + prev.join(r); + prev + } + }); + } + acc + } else { + None + }; + while let Some(node_render_state) = self.pending_nodes.pop() { let node_id = node_render_state.id; let visited_children = node_render_state.visited_children; @@ -2776,6 +2971,64 @@ impl RenderState { } } + // Interactive drag cache: if this node is cacheable during interactive transform, + // draw it directly from Backbuffer crop on the current tile surface and skip + // traversing/rendering the subtree. + if self.options.is_interactive_transform() { + let use_cached = self.should_use_cached_top_level_during_interactive( + node_id, + tree, + &tree.modifier_ids(), + moved_bounds, + ); + if use_cached { + if let Some(crop) = self.backbuffer_crop_cache.get(&node_id) { + let crop_image = &crop.image; + let crop_src_selrect = crop.src_selrect; + let crop_src_doc_bounds = crop.src_doc_bounds; + + let cur_selrect = tree.get(&node_id).map(|s| s.selrect()); + let (dx, dy) = match cur_selrect { + Some(cur) => ( + cur.left - crop_src_selrect.left, + cur.top - crop_src_selrect.top, + ), + None => (0.0, 0.0), + }; + + let dst_doc_rect = Rect::new( + crop_src_doc_bounds.left + dx, + crop_src_doc_bounds.top + dy, + crop_src_doc_bounds.right + dx, + crop_src_doc_bounds.bottom + dy, + ); + let scale = self.get_scale(); + let translation = self + .surfaces + .get_render_context_translation(self.render_area, scale); + let dst_tile_rect = skia::Rect::from_xywh( + (dst_doc_rect.left + translation.0) * scale, + (dst_doc_rect.top + translation.1) * scale, + dst_doc_rect.width() * scale, + dst_doc_rect.height() * scale, + ); + + // let canvas = self.surfaces.canvas_and_mark_dirty(target_surface); + let canvas = self.surfaces.canvas(target_surface); + canvas.save(); + canvas.reset_matrix(); + canvas.draw_image_rect( + crop_image, + None, + dst_tile_rect, + &skia::Paint::default(), + ); + canvas.restore(); + } + continue; + } + } + let can_flatten = element.can_flatten() && !self.focus_mode.should_focus(&element.id); // Skip render_shape_enter/exit for flattened containers diff --git a/render-wasm/src/render/surfaces.rs b/render-wasm/src/render/surfaces.rs index 3fe27fdebd..688428f5eb 100644 --- a/render-wasm/src/render/surfaces.rs +++ b/render-wasm/src/render/surfaces.rs @@ -493,6 +493,11 @@ impl Surfaces { } } + pub fn snapshot_rect(&mut self, id: SurfaceId, irect: skia::IRect) -> Option { + let surface = self.get_mut(id); + surface.image_snapshot_with_bounds(irect) + } + /// Returns a mutable reference to the canvas and automatically marks /// render surfaces as dirty when accessed. This tracks which surfaces /// have content for optimization purposes. @@ -698,6 +703,11 @@ impl Surfaces { } } + pub fn surface_size(&self, id: SurfaceId) -> (i32, i32) { + let s = self.get(id); + (s.width(), s.height()) + } + /// Copy the current `Target` contents into the persistent `Backbuffer`. /// This is a GPU→GPU copy via Skia (no ReadPixels). pub fn copy_target_to_backbuffer(&mut self) { diff --git a/render-wasm/src/state/shapes_pool.rs b/render-wasm/src/state/shapes_pool.rs index 95e229d3b7..3fd7ff2c51 100644 --- a/render-wasm/src/state/shapes_pool.rs +++ b/render-wasm/src/state/shapes_pool.rs @@ -140,6 +140,18 @@ impl ShapesPoolImpl { Some(&mut self.shapes[idx]) } + /// Returns the current transform modifier matrix for the shape, if any. + pub fn get_modifier(&self, id: &Uuid) -> Option<&skia::Matrix> { + let idx = *self.uuid_to_idx.get(id)?; + self.modifiers.get(&idx) + } + + /// Get a shape by UUID without applying modifiers/structure/scale-content. + pub fn get_raw(&self, id: &Uuid) -> Option<&Shape> { + let idx = *self.uuid_to_idx.get(id)?; + Some(&self.shapes[idx]) + } + /// Get a shape by UUID. Returns the modified shape if modifiers/structure /// are applied, otherwise returns the base shape. pub fn get(&self, id: &Uuid) -> Option<&Shape> { diff --git a/render-wasm/src/tiles.rs b/render-wasm/src/tiles.rs index 432cfeb8ca..6f0786df35 100644 --- a/render-wasm/src/tiles.rs +++ b/render-wasm/src/tiles.rs @@ -209,60 +209,29 @@ impl PendingTiles { } } - // Generate tiles in spiral order from center + // Generate tiles ordered by distance to the center (closest processed first). fn generate_spiral(rect: &TileRect) -> Vec { - let columns = rect.width(); - let rows = rect.height(); - let total = columns * rows; + let cx = rect.center_x(); + let cy = rect.center_y(); - if total <= 0 { - return Vec::new(); + // TileRect is inclusive (x1..=x2, y1..=y2). + let mut tiles = Vec::new(); + for x in rect.x1()..=rect.x2() { + for y in rect.y1()..=rect.y2() { + tiles.push(Tile(x, y)); + } } - let mut result = Vec::with_capacity(total as usize); - let mut cx = rect.center_x(); - let mut cy = rect.center_y(); - - let ratio = (columns as f32 / rows as f32).ceil() as i32; - - let mut direction_current = 0; - let mut direction_total_x = ratio; - let mut direction_total_y = 1; - let mut direction = 0; - let mut current = 0; - - result.push(Tile(cx, cy)); - while current < total { - match direction { - 0 => cx += 1, - 1 => cy += 1, - 2 => cx -= 1, - 3 => cy -= 1, - _ => unreachable!("Invalid direction"), - } - - result.push(Tile(cx, cy)); - - direction_current += 1; - let direction_total = if direction % 2 == 0 { - direction_total_x - } else { - direction_total_y - }; - - if direction_current == direction_total { - if direction % 2 == 0 { - direction_total_x += 1; - } else { - direction_total_y += 1; - } - direction = (direction + 1) % 4; - direction_current = 0; - } - current += 1; - } - result.reverse(); - result + // We pop() from the end, so keep nearest-to-center tiles at the end. + tiles.sort_unstable_by(|a, b| { + let da = (a.x() - cx).abs() + (a.y() - cy).abs(); + let db = (b.x() - cx).abs() + (b.y() - cy).abs(); + da.cmp(&db) + .then_with(|| a.x().cmp(&b.x())) + .then_with(|| a.y().cmp(&b.y())) + }); + tiles.reverse(); + tiles } pub fn update(&mut self, tile_viewbox: &TileViewbox, surfaces: &Surfaces, only_visible: bool) { From f6bd991968e64bb3383e38f7895fa4b298da5d4c Mon Sep 17 00:00:00 2001 From: "alonso.torres" Date: Thu, 30 Apr 2026 16:17:50 +0200 Subject: [PATCH 6/7] :bug: Improved e2e tests stability --- .../playwright/data/workspace/versions-init-2.json | 1 + frontend/playwright/ui/specs/inspect-tab.spec.js | 10 ++++++---- frontend/playwright/ui/specs/versions.spec.js | 3 +++ 3 files changed, 10 insertions(+), 4 deletions(-) create mode 100644 frontend/playwright/data/workspace/versions-init-2.json diff --git a/frontend/playwright/data/workspace/versions-init-2.json b/frontend/playwright/data/workspace/versions-init-2.json new file mode 100644 index 0000000000..a1507a3f83 --- /dev/null +++ b/frontend/playwright/data/workspace/versions-init-2.json @@ -0,0 +1 @@ +{"~: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},"~:has-media-trimmed":false,"~:comment-thread-seqn":0,"~:name":"New File 5","~:revn":2,"~:modified-at":"~m1730197748522","~:vern":2,"~:id":"~u406b7b01-d3e2-80e4-8005-3138ac5d449c","~:is-shared":false,"~:version":55,"~:project-id":"~u3ffbd505-2f26-800f-8004-f34da98bdad8","~:created-at":"~m1730197736824","~:data":{"~:pages":["~u406b7b01-d3e2-80e4-8005-3138ac5d449d"],"~:pages-index":{"~u406b7b01-d3e2-80e4-8005-3138ac5d449d":{"~#penpot/pointer":["~u406b7b01-d3e2-80e4-8005-3138b7cc5f0b",{"~:created-at":"~m1730197748531"}]}},"~:id":"~u406b7b01-d3e2-80e4-8005-3138ac5d449c","~:options":{"~:components-v2":true}}} diff --git a/frontend/playwright/ui/specs/inspect-tab.spec.js b/frontend/playwright/ui/specs/inspect-tab.spec.js index d85266a8c4..064ae644b2 100644 --- a/frontend/playwright/ui/specs/inspect-tab.spec.js +++ b/frontend/playwright/ui/specs/inspect-tab.spec.js @@ -14,14 +14,16 @@ const setupFile = async (workspacePage) => { /get\-file\?/, "workspace/get-file-inspect-tab.json", ); + + await workspacePage.mockRPC( + /update\-file\?/, + "workspace/update-file-empty.json", + ); + await workspacePage.goToWorkspace({ fileId: "7b2da435-6186-815a-8007-0daa95d2f26d", pageId: "ce79274b-11ab-8088-8007-0487ad43f789", }); - await workspacePage.mockRPC( - "update-file?id=*", - "workspace/update-file-empty.json", - ); }; const shapeToLayerName = { diff --git a/frontend/playwright/ui/specs/versions.spec.js b/frontend/playwright/ui/specs/versions.spec.js index 22fbdc08be..2c5fa018f2 100644 --- a/frontend/playwright/ui/specs/versions.spec.js +++ b/frontend/playwright/ui/specs/versions.spec.js @@ -87,6 +87,9 @@ test("Save and restore version", async ({ page }) => { "workspace/versions-restore-snapshot-1.json", ); + await workspacePage.mockRPC(/get\-file\?/, "workspace/versions-init-2.json"); + + await page.getByRole("button", { name: "Open version menu" }).click(); await page.getByRole("button", { name: "Restore" }).click(); await page.getByRole("button", { name: "Restore" }).click(); From e9480208861347770d1aadcaa10c571d9b648c01 Mon Sep 17 00:00:00 2001 From: Alonso Torres Date: Mon, 4 May 2026 10:35:16 +0200 Subject: [PATCH 7/7] :bug: Fix problem with rounding in flex elements --- .../src/shapes/modifiers/flex_layout.rs | 24 +++++++++++++++---- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/render-wasm/src/shapes/modifiers/flex_layout.rs b/render-wasm/src/shapes/modifiers/flex_layout.rs index e4f0d29311..41d3e02687 100644 --- a/render-wasm/src/shapes/modifiers/flex_layout.rs +++ b/render-wasm/src/shapes/modifiers/flex_layout.rs @@ -236,7 +236,11 @@ fn initialize_tracks( let gap_main = if first { 0.0 } else { layout_axis.gap_main }; - let next_main_size = current_track.main_size + child_main_size + gap_main; + let next_main_size = if current_track.shapes.is_empty() { + child_main_size + } else { + current_track.main_size + child_main_size + gap_main + }; let main_space = layout_axis.main_space(); let exceeds_main_space = next_main_size > main_space + TRACK_TOLERANCE; @@ -329,9 +333,19 @@ fn distribute_fill_across_space(layout_axis: &LayoutAxis, tracks: &mut [TrackDat let current = left_space / to_resize_tracks.len() as f32; for i in (0..to_resize_tracks.len()).rev() { let track = &mut to_resize_tracks[i]; - let delta = - f32::min(track.max_across_size, track.across_size + current) - track.across_size; - track.across_size += delta; + + let delta = if math::is_close_to(track.across_size, MIN_SIZE) { + f32::min(track.max_across_size, track.across_size + current) + } else { + f32::min(track.max_across_size, track.across_size + current) - track.across_size + }; + + if math::is_close_to(track.across_size, MIN_SIZE) { + track.across_size = delta; + } else { + track.across_size += delta; + } + left_space -= delta; if (track.across_size - track.max_across_size).abs() < MIN_SIZE { @@ -686,7 +700,7 @@ pub fn reflow_flex_layout( + (nshapes as f32 - 1.0) * layout_axis.gap_main }) .reduce(f32::max) - .unwrap_or(0.01) + .unwrap_or(MIN_SIZE) + layout_axis.padding_main_start + layout_axis.padding_main_end } else {