From 2c9159288fb284a9d3d54310daffdfa65a4e5c59 Mon Sep 17 00:00:00 2001 From: Aitor Moreno Date: Wed, 7 Jan 2026 12:18:05 +0100 Subject: [PATCH 01/20] :bug: Fix previous styles lost when changing selected text --- CHANGES.md | 2 +- .../editor/controllers/SelectionController.js | 38 +++++++++++-------- 2 files changed, 23 insertions(+), 17 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index f6d861db12..c391f09d0d 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -29,7 +29,7 @@ - Fix missing text color token from selected shapes in selected colors list [Taiga #12956](https://tree.taiga.io/project/penpot/issue/12956) - Fix dropdown option width in Guides columns dropdown [Taiga #12959](https://tree.taiga.io/project/penpot/issue/12959) - Fix typos on download modal [Taiga #12865](https://tree.taiga.io/project/penpot/issue/12865) - +- Fix problem with text editor maintaining previous styles [Taiga #12835](https://tree.taiga.io/project/penpot/issue/12835) ## 2.12.1 diff --git a/frontend/text-editor/src/editor/controllers/SelectionController.js b/frontend/text-editor/src/editor/controllers/SelectionController.js index 2586aab148..add28d65d7 100644 --- a/frontend/text-editor/src/editor/controllers/SelectionController.js +++ b/frontend/text-editor/src/editor/controllers/SelectionController.js @@ -242,7 +242,6 @@ export class SelectionController extends EventTarget { continue; } let styleValue = element.style.getPropertyValue(styleName); - if (styleName === "font-family") { styleValue = sanitizeFontFamily(styleValue); } @@ -277,22 +276,29 @@ export class SelectionController extends EventTarget { this.#applyDefaultStylesToCurrentStyle(); const root = startNode.parentElement.parentElement.parentElement; this.#applyStylesFromElementToCurrentStyle(root); - // FIXME: I don't like this approximation. Having to iterate nodes twice - // is bad for performance. I think we need another way of "computing" - // the cascade. - for (const textNode of this.#textNodeIterator.iterateFrom( - startNode, - endNode, - )) { - const paragraph = textNode.parentElement.parentElement; + if (startNode === endNode) { + const paragraph = startNode.parentElement.parentElement; this.#applyStylesFromElementToCurrentStyle(paragraph); - } - for (const textNode of this.#textNodeIterator.iterateFrom( - startNode, - endNode, - )) { - const textSpan = textNode.parentElement; - this.#mergeStylesFromElementToCurrentStyle(textSpan); + const textSpan = startNode.parentElement; + this.#applyStylesFromElementToCurrentStyle(textSpan); + } else { + // FIXME: I don't like this approximation. Having to iterate nodes twice + // is bad for performance. I think we need another way of "computing" + // the cascade. + for (const textNode of this.#textNodeIterator.iterateFrom( + startNode, + endNode, + )) { + const paragraph = textNode.parentElement.parentElement; + this.#applyStylesFromElementToCurrentStyle(paragraph); + } + for (const textNode of this.#textNodeIterator.iterateFrom( + startNode, + endNode, + )) { + const textSpan = textNode.parentElement; + this.#mergeStylesFromElementToCurrentStyle(textSpan); + } } return this; } From f0687fd1f729b11d325c0c49cc86fa8b3e968039 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bel=C3=A9n=20Albeza?= Date: Thu, 8 Jan 2026 16:06:52 +0100 Subject: [PATCH 02/20] :tada: Make workspace loader to wait for first render --- frontend/src/app/main/ui/ds/z-index.scss | 2 ++ frontend/src/app/main/ui/workspace.cljs | 24 +++++++++++++++++++++--- frontend/src/app/main/ui/workspace.scss | 8 +++++++- 3 files changed, 30 insertions(+), 4 deletions(-) diff --git a/frontend/src/app/main/ui/ds/z-index.scss b/frontend/src/app/main/ui/ds/z-index.scss index 4d0e6c592e..3cdb47366d 100644 --- a/frontend/src/app/main/ui/ds/z-index.scss +++ b/frontend/src/app/main/ui/ds/z-index.scss @@ -10,6 +10,7 @@ $z-index-200: 200; $z-index-300: 300; $z-index-400: 400; $z-index-500: 500; +$z-index-600: 600; :global(:root) { --z-index-auto: #{$z-index-auto}; // Index for elements such as workspace, rulers ... @@ -18,4 +19,5 @@ $z-index-500: 500; --z-index-set: #{$z-index-300}; // Index for configuration elements like modals, color picker, grid edition elements --z-index-dropdown: #{$z-index-400}; // Index for dropdown like elements, selects, menus, dropdowns --z-index-notifications: #{$z-index-500}; // Index for notification + --z-index-loaders: #{$z-index-600}; // Index for loaders } diff --git a/frontend/src/app/main/ui/workspace.cljs b/frontend/src/app/main/ui/workspace.cljs index 0b44af4511..53a1dc2621 100644 --- a/frontend/src/app/main/ui/workspace.cljs +++ b/frontend/src/app/main/ui/workspace.cljs @@ -217,6 +217,10 @@ design-tokens? (features/use-feature "design-tokens/v1") + wasm-renderer-enabled? (features/use-feature "render-wasm/v1") + + first-frame-rendered? (mf/use-state false) + background-color (:background-color wglobal)] (mf/with-effect [] @@ -241,6 +245,17 @@ (when (and file-loaded? (not page-id)) (st/emit! (dcm/go-to-workspace :file-id file-id ::rt/replace true)))) + (mf/with-effect [file-id page-id] + (reset! first-frame-rendered? false)) + + (mf/with-effect [] + (let [handle-wasm-render + (fn [_] + (reset! first-frame-rendered? true)) + listener-key (events/listen globals/document "penpot:wasm:render" handle-wasm-render)] + (fn [] + (events/unlistenByKey listener-key)))) + [:> (mf/provider ctx/current-project-id) {:value project-id} [:> (mf/provider ctx/current-file-id) {:value file-id} [:> (mf/provider ctx/current-page-id) {:value page-id} @@ -249,15 +264,18 @@ [:> modal-container*] [:section {:class (stl/css :workspace) :style {:background-color background-color - :touch-action "none"}} + :touch-action "none" + :position "relative"}} [:> context-menu*] - (if (and file-loaded? page-id) + (when (and file-loaded? page-id) [:> workspace-inner* {:page-id page-id :file-id file-id :file file :wglobal wglobal - :layout layout}] + :layout layout}]) + (when (or (not (and file-loaded? page-id)) + (and wasm-renderer-enabled? (not @first-frame-rendered?))) [:> workspace-loader*])]]]]]])) (mf/defc workspace-page* diff --git a/frontend/src/app/main/ui/workspace.scss b/frontend/src/app/main/ui/workspace.scss index d6c21429dd..5cd617bab4 100644 --- a/frontend/src/app/main/ui/workspace.scss +++ b/frontend/src/app/main/ui/workspace.scss @@ -20,7 +20,13 @@ } .workspace-loader { - grid-area: viewport; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + z-index: var(--z-index-loaders); + background-color: var(--color-background-primary); } .workspace-content { From 042a3a4080e936a40986e2fabac3e4fdc98cf58a Mon Sep 17 00:00:00 2001 From: Alejandro Alonso Date: Fri, 9 Jan 2026 13:33:08 +0100 Subject: [PATCH 03/20] :bug: Fix wasm playgrounds --- frontend/resources/wasm-playground/clips.html | 2 +- frontend/resources/wasm-playground/masks.html | 2 +- frontend/resources/wasm-playground/paths.html | 2 +- frontend/resources/wasm-playground/plus.html | 2 +- frontend/resources/wasm-playground/rects.html | 2 +- frontend/resources/wasm-playground/texts.html | 4 ++-- 6 files changed, 7 insertions(+), 7 deletions(-) diff --git a/frontend/resources/wasm-playground/clips.html b/frontend/resources/wasm-playground/clips.html index e4f8bef576..ba1951e56a 100644 --- a/frontend/resources/wasm-playground/clips.html +++ b/frontend/resources/wasm-playground/clips.html @@ -23,7 +23,7 @@ - \ No newline at end of file + From 3e99ad036c8f8be77744b54f847f77f0576e0135 Mon Sep 17 00:00:00 2001 From: Alejandro Alonso Date: Tue, 13 Jan 2026 06:55:53 +0100 Subject: [PATCH 04/20] :tada: Avoid unnecesary saves and restores --- render-wasm/src/render.rs | 127 +++++++++++++++++++++++++------------- render-wasm/src/shapes.rs | 56 ++++++++++++++++- 2 files changed, 139 insertions(+), 44 deletions(-) diff --git a/render-wasm/src/render.rs b/render-wasm/src/render.rs index a0763e6d40..12092c8f30 100644 --- a/render-wasm/src/render.rs +++ b/render-wasm/src/render.rs @@ -398,12 +398,7 @@ impl RenderState { } fn frame_clip_layer_blur(shape: &Shape) -> Option { - match shape.shape_type { - Type::Frame(_) if shape.clip() => shape.blur.filter(|blur| { - !blur.hidden && blur.blur_type == BlurType::LayerBlur && blur.value > 0. - }), - _ => None, - } + shape.frame_clip_layer_blur() } /// Runs `f` with `ignore_nested_blurs` temporarily forced to `true`. @@ -605,9 +600,17 @@ impl RenderState { | strokes_surface_id as u32 | innershadows_surface_id as u32 | text_drop_shadows_surface_id as u32; - self.surfaces.apply_mut(surface_ids, |s| { - s.canvas().save(); - }); + + // Only save canvas state if we have clipping or transforms + // For simple shapes without clipping, skip expensive save/restore + let needs_save = + clip_bounds.is_some() || offset.is_some() || !shape.transform.is_identity(); + + if needs_save { + self.surfaces.apply_mut(surface_ids, |s| { + s.canvas().save(); + }); + } let antialias = shape.should_use_antialias(self.get_scale()); @@ -908,9 +911,13 @@ impl RenderState { if apply_to_current_surface { self.apply_drawing_to_render_canvas(Some(&shape)); } - self.surfaces.apply_mut(surface_ids, |s| { - s.canvas().restore(); - }); + + // Only restore if we saved (optimization for simple shapes) + if needs_save { + self.surfaces.apply_mut(surface_ids, |s| { + s.canvas().restore(); + }); + } } pub fn update_render_context(&mut self, tile: tiles::Tile) { @@ -1117,35 +1124,41 @@ impl RenderState { self.nested_fills.push(Vec::new()); } - let mut paint = skia::Paint::default(); - paint.set_blend_mode(element.blend_mode().into()); - paint.set_alpha_f(element.opacity()); + // Only create save_layer if actually needed + // For simple shapes with default opacity and blend mode, skip expensive save_layer + let needs_layer = element.needs_layer() || mask; - if let Some(frame_blur) = Self::frame_clip_layer_blur(element) { - let scale = self.get_scale(); - let sigma = frame_blur.value * scale; - if let Some(filter) = skia::image_filters::blur((sigma, sigma), None, None, None) { - paint.set_image_filter(filter); + if needs_layer { + let mut paint = skia::Paint::default(); + paint.set_blend_mode(element.blend_mode().into()); + paint.set_alpha_f(element.opacity()); + + if let Some(frame_blur) = Self::frame_clip_layer_blur(element) { + let scale = self.get_scale(); + let sigma = frame_blur.value * scale; + if let Some(filter) = skia::image_filters::blur((sigma, sigma), None, None, None) { + paint.set_image_filter(filter); + } } - } - // When we're rendering the mask shape we need to set a special blend mode - // called 'destination-in' that keeps the drawn content within the mask. - // @see https://skia.org/docs/user/api/skblendmode_overview/ - if mask { - let mut mask_paint = skia::Paint::default(); - mask_paint.set_blend_mode(skia::BlendMode::DstIn); - let mask_rec = skia::canvas::SaveLayerRec::default().paint(&mask_paint); + // When we're rendering the mask shape we need to set a special blend mode + // called 'destination-in' that keeps the drawn content within the mask. + // @see https://skia.org/docs/user/api/skblendmode_overview/ + if mask { + let mut mask_paint = skia::Paint::default(); + mask_paint.set_blend_mode(skia::BlendMode::DstIn); + let mask_rec = skia::canvas::SaveLayerRec::default().paint(&mask_paint); + self.surfaces + .canvas(SurfaceId::Current) + .save_layer(&mask_rec); + } + + let layer_rec = skia::canvas::SaveLayerRec::default().paint(&paint); self.surfaces .canvas(SurfaceId::Current) - .save_layer(&mask_rec); + .save_layer(&layer_rec); } - let layer_rec = skia::canvas::SaveLayerRec::default().paint(&paint); - self.surfaces - .canvas(SurfaceId::Current) - .save_layer(&layer_rec); - self.focus_mode.enter(&element.id); } @@ -1217,7 +1230,15 @@ impl RenderState { ); } - self.surfaces.canvas(SurfaceId::Current).restore(); + // Only restore if we created a layer (optimization for simple shapes) + let needs_layer = element.needs_layer() + || (matches!(element.shape_type, Type::Group(_)) + && matches!(element.shape_type, Type::Group(g) if g.masked)); + + if needs_layer { + self.surfaces.canvas(SurfaceId::Current).restore(); + } + self.focus_mode.exit(&element.id); } @@ -1463,12 +1484,31 @@ impl RenderState { if !node_render_state.is_root() { let transformed_element: Cow = Cow::Borrowed(element); - let scale = self.get_scale(); - let extrect = transformed_element.extrect(tree, scale); - let is_visible = extrect.intersects(self.render_area) - && !transformed_element.hidden - && !transformed_element.visually_insignificant(scale, tree); + // Aggressive early exit: check hidden and selrect first (fastest checks) + if transformed_element.hidden { + continue; + } + + let selrect = transformed_element.selrect(); + if !selrect.intersects(self.render_area) { + continue; + } + + // For simple shapes without effects, selrect check is sufficient + // Only calculate expensive extrect for shapes with effects that might extend bounds + let scale = self.get_scale(); + let has_effects = transformed_element.has_effects_that_extend_bounds(); + + let is_visible = if !has_effects { + // Simple shape: selrect check is sufficient, skip expensive extrect + !transformed_element.visually_insignificant(scale, tree) + } else { + // Shape with effects: need extrect for accurate bounds + let extrect = transformed_element.extrect(tree, scale); + extrect.intersects(self.render_area) + && !transformed_element.visually_insignificant(scale, tree) + }; if self.options.is_debug_visible() { let shape_extrect_bounds = @@ -1515,6 +1555,8 @@ impl RenderState { _ => None, }; + let element_extrect = element.extrect(tree, scale); + for shadow in element.drop_shadows_visible() { let paint = skia::Paint::default(); let layer_rec = skia::canvas::SaveLayerRec::default().paint(&paint); @@ -1526,7 +1568,7 @@ impl RenderState { // First pass: Render shadow in black to establish alpha mask self.render_drop_black_shadow( element, - &element.extrect(tree, scale), + &element_extrect, shadow, clip_bounds.clone(), scale, @@ -1546,9 +1588,10 @@ impl RenderState { .get_nested_shadow_clip_bounds(element, shadow); if !matches!(shadow_shape.shape_type, Type::Text(_)) { + let shadow_extrect = shadow_shape.extrect(tree, scale); self.render_drop_black_shadow( shadow_shape, - &shadow_shape.extrect(tree, scale), + &shadow_extrect, shadow, clip_bounds, scale, diff --git a/render-wasm/src/shapes.rs b/render-wasm/src/shapes.rs index cb334a6f00..3ddb297fb1 100644 --- a/render-wasm/src/shapes.rs +++ b/render-wasm/src/shapes.rs @@ -920,10 +920,27 @@ impl Shape { } Type::Group(_) | Type::Frame(_) if !self.clip_content => { + // Use selrect as a fast approximation first, then calculate + // extrect only if needed. This avoids expensive recursive extrect calculations + // for children that don't significantly expand the bounds. for child_id in self.children_ids_iter(false) { if let Some(child_shape) = shapes_pool.get(child_id) { - let child_extrect = child_shape.calculate_extrect(shapes_pool, scale); - rect.join(child_extrect); + // Fast path: check if child has effects that might expand bounds + // If no effects, selrect is likely sufficient + let has_effects = !child_shape.shadows.is_empty() + || child_shape.blur.is_some() + || !child_shape.strokes.is_empty() + || matches!(child_shape.shape_type, Type::Group(_) | Type::Frame(_)); + + if has_effects { + // Calculate full extrect for shapes with effects + let child_extrect = child_shape.calculate_extrect(shapes_pool, scale); + rect.join(child_extrect); + } else { + // No effects, selrect is sufficient (much faster) + let child_selrect = child_shape.selrect(); + rect.join(child_selrect); + } } } } @@ -1419,6 +1436,41 @@ impl Shape { !self.fills.is_empty() } + /// Checks if this shape needs a layer for rendering due to visual effects + /// (opacity < 1.0, non-default blend mode, or frame clip layer blur) + pub fn needs_layer(&self) -> bool { + self.opacity() < 1.0 + || self.blend_mode().0 != skia::BlendMode::SrcOver + || self.has_frame_clip_layer_blur() + } + + /// Checks if this frame has clip layer blur (affects children) + /// A frame has clip layer blur if it clips content and has layer blur + pub fn has_frame_clip_layer_blur(&self) -> bool { + self.frame_clip_layer_blur().is_some() + } + + /// Returns the frame clip layer blur if this frame has one + /// A frame has clip layer blur if it clips content and has layer blur + pub fn frame_clip_layer_blur(&self) -> Option { + use crate::shapes::BlurType; + match self.shape_type { + Type::Frame(_) if self.clip_content => self.blur.filter(|blur| { + !blur.hidden && blur.blur_type == BlurType::LayerBlur && blur.value > 0.0 + }), + _ => None, + } + } + + /// Checks if this shape has visual effects that might extend its bounds beyond selrect + /// Shapes with these effects require expensive extrect calculation for accurate visibility checks + pub fn has_effects_that_extend_bounds(&self) -> bool { + !self.shadows.is_empty() + || self.blur.is_some() + || !self.strokes.is_empty() + || matches!(self.shape_type, Type::Group(_) | Type::Frame(_)) + } + pub fn count_visible_inner_strokes(&self) -> usize { self.visible_strokes() .filter(|s| s.kind == StrokeKind::Inner) From 1810df232b7f142cb3a8298c125023605ec774fc Mon Sep 17 00:00:00 2001 From: Alejandro Alonso Date: Tue, 13 Jan 2026 06:56:06 +0100 Subject: [PATCH 05/20] :tada: Ignore frames and groups when they have no visual extra information --- render-wasm/src/render.rs | 100 ++++++++++++++++++++++++++++---------- render-wasm/src/shapes.rs | 81 ++++++++++++++++++++++-------- 2 files changed, 137 insertions(+), 44 deletions(-) diff --git a/render-wasm/src/render.rs b/render-wasm/src/render.rs index 12092c8f30..449ec4eef0 100644 --- a/render-wasm/src/render.rs +++ b/render-wasm/src/render.rs @@ -10,7 +10,6 @@ mod shadows; mod strokes; mod surfaces; pub mod text; - mod ui; use skia_safe::{self as skia, Matrix, RRect, Rect}; @@ -53,6 +52,25 @@ pub struct NodeRenderState { mask: bool, } +/// Get simplified children of a container, flattening nested flattened containers +fn get_simplified_children<'a>(tree: ShapesPoolRef<'a>, shape: &'a Shape) -> Vec { + let mut result = Vec::new(); + + for child_id in shape.children_ids_iter(false) { + if let Some(child) = tree.get(child_id) { + if child.can_flatten() { + // Child is flattened: recursively get its simplified children + result.extend(get_simplified_children(tree, child)); + } else { + // Child is not flattened: add it directly + result.push(*child_id); + } + } + } + + result +} + impl NodeRenderState { pub fn is_root(&self) -> bool { self.id.is_nil() @@ -1038,6 +1056,7 @@ impl RenderState { // reorder by distance to the center. self.current_tile = None; self.render_in_progress = true; + self.apply_drawing_to_render_canvas(None); if sync_render { @@ -1478,31 +1497,42 @@ impl RenderState { } if visited_children { - self.render_shape_exit(element, visited_mask); + // Skip render_shape_exit for flattened containers + if !element.can_flatten() { + self.render_shape_exit(element, visited_mask); + } continue; } if !node_render_state.is_root() { let transformed_element: Cow = Cow::Borrowed(element); - // Aggressive early exit: check hidden and selrect first (fastest checks) + // Aggressive early exit: check hidden first (fastest check) if transformed_element.hidden { continue; } - let selrect = transformed_element.selrect(); - if !selrect.intersects(self.render_area) { - 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. + let is_container = matches!( + transformed_element.shape_type, + crate::shapes::Type::Frame(_) | crate::shapes::Type::Group(_) + ); - // For simple shapes without effects, selrect check is sufficient - // Only calculate expensive extrect for shapes with effects that might extend bounds let scale = self.get_scale(); let has_effects = transformed_element.has_effects_that_extend_bounds(); - let is_visible = if !has_effects { + let is_visible = if is_container { + // Containers (frames/groups) must always use extrect to include nested content + let extrect = transformed_element.extrect(tree, scale); + extrect.intersects(self.render_area) + && !transformed_element.visually_insignificant(scale, tree) + } else if !has_effects { // Simple shape: selrect check is sufficient, skip expensive extrect - !transformed_element.visually_insignificant(scale, tree) + let selrect = transformed_element.selrect(); + selrect.intersects(self.render_area) + && !transformed_element.visually_insignificant(scale, tree) } else { // Shape with effects: need extrect for accurate bounds let extrect = transformed_element.extrect(tree, scale); @@ -1521,7 +1551,12 @@ impl RenderState { } } - self.render_shape_enter(element, mask); + // Skip render_shape_enter/exit for flattened containers + // If a container was flattened, it doesn't affect children visually, so we skip + // the expensive enter/exit operations and process children directly + if !element.can_flatten() { + self.render_shape_enter(element, mask); + } if !node_render_state.is_root() && self.focus_mode.is_active() { let scale: f32 = self.get_scale(); @@ -1725,14 +1760,18 @@ impl RenderState { self.apply_drawing_to_render_canvas(Some(element)); } - match element.shape_type { - Type::Frame(_) if Self::frame_clip_layer_blur(element).is_some() => { - self.nested_blurs.push(None); + // Skip nested state updates for flattened containers + // Flattened containers don't affect children, so we don't need to track their state + if !element.can_flatten() { + match element.shape_type { + Type::Frame(_) if Self::frame_clip_layer_blur(element).is_some() => { + self.nested_blurs.push(None); + } + Type::Frame(_) | Type::Group(_) => { + self.nested_blurs.push(element.blur); + } + _ => {} } - Type::Frame(_) | Type::Group(_) => { - self.nested_blurs.push(element.blur); - } - _ => {} } // Set the node as visited_children before processing children @@ -1747,24 +1786,35 @@ impl RenderState { if element.is_recursive() { let children_clip_bounds = node_render_state.get_children_clip_bounds(element, None); - let mut children_ids: Vec<_> = element.children_ids_iter(false).collect(); + + let children_ids: Vec<_> = if element.can_flatten() { + // Container was flattened: get simplified children (which skip this level) + get_simplified_children(tree, element) + } else { + // Container not flattened: use original children + element.children_ids_iter(false).copied().collect() + }; // Z-index ordering on Layouts - if element.has_layout() { + let children_ids = if element.has_layout() { + let mut ids = children_ids; if element.is_flex() && !element.is_flex_reverse() { - children_ids.reverse(); + ids.reverse(); } - children_ids.sort_by(|id1, id2| { + ids.sort_by(|id1, id2| { let z1 = tree.get(id1).map(|s| s.z_index()).unwrap_or(0); let z2 = tree.get(id2).map(|s| s.z_index()).unwrap_or(0); z2.cmp(&z1) }); - } + ids + } else { + children_ids + }; for child_id in children_ids.iter() { self.pending_nodes.push(NodeRenderState { - id: **child_id, + id: *child_id, visited_children: false, clip_bounds: children_clip_bounds.clone(), visited_mask: false, diff --git a/render-wasm/src/shapes.rs b/render-wasm/src/shapes.rs index 3ddb297fb1..e90478c36a 100644 --- a/render-wasm/src/shapes.rs +++ b/render-wasm/src/shapes.rs @@ -920,27 +920,15 @@ impl Shape { } Type::Group(_) | Type::Frame(_) if !self.clip_content => { - // Use selrect as a fast approximation first, then calculate - // extrect only if needed. This avoids expensive recursive extrect calculations - // for children that don't significantly expand the bounds. + // For frames and groups, we must always calculate extrect for all children + // to ensure accurate bounds that include nested content across all tiles. + // Using selrect for children can cause frames to be incorrectly omitted from + // tiles where they have nested content. for child_id in self.children_ids_iter(false) { if let Some(child_shape) = shapes_pool.get(child_id) { - // Fast path: check if child has effects that might expand bounds - // If no effects, selrect is likely sufficient - let has_effects = !child_shape.shadows.is_empty() - || child_shape.blur.is_some() - || !child_shape.strokes.is_empty() - || matches!(child_shape.shape_type, Type::Group(_) | Type::Frame(_)); - - if has_effects { - // Calculate full extrect for shapes with effects - let child_extrect = child_shape.calculate_extrect(shapes_pool, scale); - rect.join(child_extrect); - } else { - // No effects, selrect is sufficient (much faster) - let child_selrect = child_shape.selrect(); - rect.join(child_selrect); - } + // Always calculate full extrect for children to ensure accurate bounds + let child_extrect = child_shape.calculate_extrect(shapes_pool, scale); + rect.join(child_extrect); } } } @@ -1436,6 +1424,61 @@ impl Shape { !self.fills.is_empty() } + /// Determines if this frame or group can be flattened (doesn't affect children visually) + /// A container can be flattened if it has no visual effects that affect its children + /// and doesn't render its own content (no fills/strokes) + pub fn can_flatten(&self) -> bool { + // Only frames and groups can be flattened + if !matches!(self.shape_type, Type::Frame(_) | Type::Group(_)) { + return false; + } + + // Cannot flatten if it has visual effects that affect children: + + if self.clip_content { + return false; + } + + if !self.transform.is_identity() { + return false; + } + + if self.opacity != 1.0 { + return false; + } + + if self.blend_mode() != BlendMode::default() { + return false; + } + + if self.blur.is_some() { + return false; + } + + if !self.shadows.is_empty() { + return false; + } + + if let Type::Group(group) = &self.shape_type { + if group.masked { + return false; + } + } + + if self.hidden { + return false; + } + + // If the container itself has fills/strokes, it renders something visible + // We cannot flatten containers that render their own background/border + // because they need to be rendered even if they don't affect children + if self.has_fills() || self.has_visible_strokes() { + return false; + } + + true + } + /// Checks if this shape needs a layer for rendering due to visual effects /// (opacity < 1.0, non-default blend mode, or frame clip layer blur) pub fn needs_layer(&self) -> bool { From 35e3b7f19a04836a817e6570d46b55a70d284e19 Mon Sep 17 00:00:00 2001 From: Alejandro Alonso Date: Mon, 12 Jan 2026 10:23:47 +0100 Subject: [PATCH 06/20] :tada: Root ids refactor --- render-wasm/src/render.rs | 39 +++++++++++++++++++++++++++++++-------- 1 file changed, 31 insertions(+), 8 deletions(-) diff --git a/render-wasm/src/render.rs b/render-wasm/src/render.rs index 449ec4eef0..d3ac74b422 100644 --- a/render-wasm/src/render.rs +++ b/render-wasm/src/render.rs @@ -294,6 +294,10 @@ pub(crate) struct RenderState { /// where we must render shapes without inheriting ancestor layer blurs. Toggle it through /// `with_nested_blurs_suppressed` to ensure it's always restored. pub ignore_nested_blurs: bool, + /// Cached root_ids and root_ids_map to avoid recalculating them every frame + /// These are invalidated when the tree structure changes + cached_root_ids: Option>, + cached_root_ids_map: Option>, } pub fn get_cache_size(viewbox: Viewbox, scale: f32) -> skia::ISize { @@ -366,6 +370,8 @@ impl RenderState { focus_mode: FocusMode::new(), touched_ids: HashSet::default(), ignore_nested_blurs: false, + cached_root_ids: None, + cached_root_ids_map: None, } } @@ -1896,14 +1902,37 @@ impl RenderState { .canvas(SurfaceId::Current) .clear(self.background_color); - let root_ids = { - if let Some(shape_id) = base_object { + // Get or compute root_ids and root_ids_map (cached to avoid recalculation every frame) + let root_ids_map = { + let root_ids = if let Some(shape_id) = base_object { vec![*shape_id] } else { let Some(root) = tree.get(&Uuid::nil()) else { return Err(String::from("Root shape not found")); }; root.children_ids(false) + }; + + // Check if cache is valid (same root_ids) + let cache_valid = self.cached_root_ids.as_ref() + .map(|cached| cached.as_slice() == root_ids.as_slice()) + .unwrap_or(false); + + if cache_valid { + // Use cached map + self.cached_root_ids_map.as_ref().unwrap().clone() + } else { + // Recompute and cache + let root_ids_map: std::collections::HashMap = root_ids + .iter() + .enumerate() + .map(|(i, id)| (*id, i)) + .collect(); + + self.cached_root_ids = Some(root_ids.clone()); + self.cached_root_ids_map = Some(root_ids_map.clone()); + + root_ids_map } }; @@ -1914,12 +1943,6 @@ impl RenderState { if !self.surfaces.has_cached_tile_surface(next_tile) { if let Some(ids) = self.tiles.get_shapes_at(next_tile) { - let root_ids_map: std::collections::HashMap = root_ids - .iter() - .enumerate() - .map(|(i, id)| (*id, i)) - .collect(); - // We only need first level shapes let mut valid_ids: Vec = ids .iter() From ee766e85a0b49847c1adf7c21c3722169dadd52a Mon Sep 17 00:00:00 2001 From: Alejandro Alonso Date: Mon, 12 Jan 2026 12:03:03 +0100 Subject: [PATCH 07/20] :tada: Wasm render dirty surfaces --- render-wasm/src/render.rs | 67 ++++++++++++------ render-wasm/src/render/fills.rs | 2 +- render-wasm/src/render/filters.rs | 2 +- render-wasm/src/render/shadows.rs | 2 +- render-wasm/src/render/strokes.rs | 6 +- render-wasm/src/render/surfaces.rs | 105 +++++++++++++++++++++++------ render-wasm/src/render/text.rs | 6 +- 7 files changed, 141 insertions(+), 49 deletions(-) diff --git a/render-wasm/src/render.rs b/render-wasm/src/render.rs index d3ac74b422..9e0fddeec3 100644 --- a/render-wasm/src/render.rs +++ b/render-wasm/src/render.rs @@ -540,38 +540,59 @@ impl RenderState { let paint = skia::Paint::default(); - self.surfaces - .draw_into(SurfaceId::TextDropShadows, SurfaceId::Current, Some(&paint)); + // Only draw surfaces that have content (dirty flag optimization) + if self.surfaces.is_dirty(SurfaceId::TextDropShadows) { + self.surfaces + .draw_into(SurfaceId::TextDropShadows, SurfaceId::Current, Some(&paint)); + } - self.surfaces - .draw_into(SurfaceId::Fills, SurfaceId::Current, Some(&paint)); + if self.surfaces.is_dirty(SurfaceId::Fills) { + self.surfaces + .draw_into(SurfaceId::Fills, SurfaceId::Current, Some(&paint)); + } let mut render_overlay_below_strokes = false; if let Some(shape) = shape { render_overlay_below_strokes = shape.has_fills(); } - if render_overlay_below_strokes { + if render_overlay_below_strokes && self.surfaces.is_dirty(SurfaceId::InnerShadows) { self.surfaces .draw_into(SurfaceId::InnerShadows, SurfaceId::Current, Some(&paint)); } - self.surfaces - .draw_into(SurfaceId::Strokes, SurfaceId::Current, Some(&paint)); + if self.surfaces.is_dirty(SurfaceId::Strokes) { + self.surfaces + .draw_into(SurfaceId::Strokes, SurfaceId::Current, Some(&paint)); + } - if !render_overlay_below_strokes { + if !render_overlay_below_strokes && self.surfaces.is_dirty(SurfaceId::InnerShadows) { self.surfaces .draw_into(SurfaceId::InnerShadows, SurfaceId::Current, Some(&paint)); } - let surface_ids = SurfaceId::Strokes as u32 - | SurfaceId::Fills as u32 - | SurfaceId::InnerShadows as u32 - | SurfaceId::TextDropShadows as u32; + // Build mask of dirty surfaces that need clearing + let mut dirty_surfaces_to_clear = 0u32; + if self.surfaces.is_dirty(SurfaceId::Strokes) { + dirty_surfaces_to_clear |= SurfaceId::Strokes as u32; + } + if self.surfaces.is_dirty(SurfaceId::Fills) { + dirty_surfaces_to_clear |= SurfaceId::Fills as u32; + } + if self.surfaces.is_dirty(SurfaceId::InnerShadows) { + dirty_surfaces_to_clear |= SurfaceId::InnerShadows as u32; + } + if self.surfaces.is_dirty(SurfaceId::TextDropShadows) { + dirty_surfaces_to_clear |= SurfaceId::TextDropShadows as u32; + } - self.surfaces.apply_mut(surface_ids, |s| { - s.canvas().clear(skia::Color::TRANSPARENT); - }); + if dirty_surfaces_to_clear != 0 { + self.surfaces.apply_mut(dirty_surfaces_to_clear, |s| { + s.canvas().clear(skia::Color::TRANSPARENT); + }); + // Clear dirty flags for surfaces we just cleared + self.surfaces.clear_dirty(dirty_surfaces_to_clear); + } } pub fn clear_focus_mode(&mut self) { @@ -710,16 +731,18 @@ impl RenderState { matrix.pre_concat(&svg_transform); } - self.surfaces.canvas(fills_surface_id).concat(&matrix); + self.surfaces + .canvas_and_mark_dirty(fills_surface_id) + .concat(&matrix); if let Some(svg) = shape.svg.as_ref() { - svg.render(self.surfaces.canvas(fills_surface_id)) + svg.render(self.surfaces.canvas_and_mark_dirty(fills_surface_id)); } else { let font_manager = skia::FontMgr::from(self.fonts().font_provider().clone()); let dom_result = skia::svg::Dom::from_str(&sr.content, font_manager); match dom_result { Ok(dom) => { - dom.render(self.surfaces.canvas(fills_surface_id)); + dom.render(self.surfaces.canvas_and_mark_dirty(fills_surface_id)); shape.to_mut().set_svg(dom); } Err(e) => { @@ -1914,7 +1937,9 @@ impl RenderState { }; // Check if cache is valid (same root_ids) - let cache_valid = self.cached_root_ids.as_ref() + let cache_valid = self + .cached_root_ids + .as_ref() .map(|cached| cached.as_slice() == root_ids.as_slice()) .unwrap_or(false); @@ -1928,10 +1953,10 @@ impl RenderState { .enumerate() .map(|(i, id)| (*id, i)) .collect(); - + self.cached_root_ids = Some(root_ids.clone()); self.cached_root_ids_map = Some(root_ids_map.clone()); - + root_ids_map } }; diff --git a/render-wasm/src/render/fills.rs b/render-wasm/src/render/fills.rs index a2a6e748a6..1d8ad98084 100644 --- a/render-wasm/src/render/fills.rs +++ b/render-wasm/src/render/fills.rs @@ -18,7 +18,7 @@ fn draw_image_fill( } let size = image.unwrap().dimensions(); - let canvas = render_state.surfaces.canvas(surface_id); + let canvas = render_state.surfaces.canvas_and_mark_dirty(surface_id); let container = &shape.selrect; let path_transform = shape.to_path_transform(); diff --git a/render-wasm/src/render/filters.rs b/render-wasm/src/render/filters.rs index bddd5ab26a..832fc32d88 100644 --- a/render-wasm/src/render/filters.rs +++ b/render-wasm/src/render/filters.rs @@ -41,7 +41,7 @@ where F: FnOnce(&mut RenderState, SurfaceId), { if let Some((image, scale)) = render_into_filter_surface(render_state, bounds, draw_fn) { - let canvas = render_state.surfaces.canvas(target_surface); + let canvas = render_state.surfaces.canvas_and_mark_dirty(target_surface); // If we scaled down, we need to scale the source rect and adjust the destination if scale < 1.0 { diff --git a/render-wasm/src/render/shadows.rs b/render-wasm/src/render/shadows.rs index f5f4fbfccf..64a6d7533a 100644 --- a/render-wasm/src/render/shadows.rs +++ b/render-wasm/src/render/shadows.rs @@ -135,7 +135,7 @@ pub fn render_text_shadows( let canvas = render_state .surfaces - .canvas(surface_id.unwrap_or(SurfaceId::TextDropShadows)); + .canvas_and_mark_dirty(surface_id.unwrap_or(SurfaceId::TextDropShadows)); for shadow in shadows { let shadow_layer = SaveLayerRec::default().paint(shadow); diff --git a/render-wasm/src/render/strokes.rs b/render-wasm/src/render/strokes.rs index 5e4f02c8e3..103831013a 100644 --- a/render-wasm/src/render/strokes.rs +++ b/render-wasm/src/render/strokes.rs @@ -387,7 +387,7 @@ fn draw_image_stroke_in_container( } let size = image.unwrap().dimensions(); - let canvas = render_state.surfaces.canvas(surface_id); + let canvas = render_state.surfaces.canvas_and_mark_dirty(surface_id); let container = &shape.selrect; let path_transform = shape.to_path_transform(); let svg_attrs = shape.svg_attrs.as_ref(); @@ -606,7 +606,7 @@ fn render_internal( let scale = render_state.get_scale(); let target_surface = surface_id.unwrap_or(SurfaceId::Strokes); - let canvas = render_state.surfaces.canvas(target_surface); + let canvas = render_state.surfaces.canvas_and_mark_dirty(target_surface); let selrect = shape.selrect; let path_transform = shape.to_path_transform(); let svg_attrs = shape.svg_attrs.as_ref(); @@ -688,7 +688,7 @@ pub fn render_text_paths( let scale = render_state.get_scale(); let canvas = render_state .surfaces - .canvas(surface_id.unwrap_or(SurfaceId::Strokes)); + .canvas_and_mark_dirty(surface_id.unwrap_or(SurfaceId::Strokes)); let selrect = &shape.selrect; let svg_attrs = shape.svg_attrs.as_ref(); let mut paint: skia_safe::Handle<_> = diff --git a/render-wasm/src/render/surfaces.rs b/render-wasm/src/render/surfaces.rs index fe0edbb455..00792109d8 100644 --- a/render-wasm/src/render/surfaces.rs +++ b/render-wasm/src/render/surfaces.rs @@ -55,6 +55,8 @@ pub struct Surfaces { tiles: TileTextureCache, sampling_options: skia::SamplingOptions, margins: skia::ISize, + // Tracks which surfaces have content (dirty flag bitmask) + dirty_surfaces: u32, } #[allow(dead_code)] @@ -105,6 +107,7 @@ impl Surfaces { tiles, sampling_options, margins, + dirty_surfaces: 0, } } @@ -147,10 +150,51 @@ impl Surfaces { None } + /// 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. + pub fn canvas_and_mark_dirty(&mut self, id: SurfaceId) -> &skia::Canvas { + // Automatically mark render surfaces as dirty when accessed + // This tracks which surfaces have content for optimization + match id { + SurfaceId::Fills + | SurfaceId::Strokes + | SurfaceId::InnerShadows + | SurfaceId::TextDropShadows => { + self.mark_dirty(id); + } + _ => {} + } + self.canvas(id) + } + + /// Returns a mutable reference to the canvas without any side effects. + /// Use this when you only need to read or manipulate the canvas state + /// without marking the surface as dirty. pub fn canvas(&mut self, id: SurfaceId) -> &skia::Canvas { self.get_mut(id).canvas() } + /// Marks a surface as having content (dirty) + pub fn mark_dirty(&mut self, id: SurfaceId) { + self.dirty_surfaces |= id as u32; + } + + /// Checks if a surface has content + pub fn is_dirty(&self, id: SurfaceId) -> bool { + (self.dirty_surfaces & id as u32) != 0 + } + + /// Clears the dirty flag for a surface or set of surfaces + pub fn clear_dirty(&mut self, ids: u32) { + self.dirty_surfaces &= !ids; + } + + /// Clears all dirty flags + pub fn clear_all_dirty(&mut self) { + self.dirty_surfaces = 0; + } + pub fn flush_and_submit(&mut self, gpu_state: &mut GpuState, id: SurfaceId) { let surface = self.get_mut(id); gpu_state.context.flush_and_submit_surface(surface, None); @@ -159,9 +203,12 @@ impl Surfaces { pub fn draw_into(&mut self, from: SurfaceId, to: SurfaceId, paint: Option<&skia::Paint>) { let sampling_options = self.sampling_options; - self.get_mut(from) - .clone() - .draw(self.canvas(to), (0.0, 0.0), sampling_options, paint); + self.get_mut(from).clone().draw( + self.canvas_and_mark_dirty(to), + (0.0, 0.0), + sampling_options, + paint, + ); } pub fn apply_mut(&mut self, ids: u32, mut f: impl FnMut(&mut skia::Surface)) { @@ -212,18 +259,33 @@ impl Surfaces { pub fn update_render_context(&mut self, render_area: skia::Rect, scale: f32) { let translation = self.get_render_context_translation(render_area, scale); - self.apply_mut( - SurfaceId::Fills as u32 - | SurfaceId::Strokes as u32 - | SurfaceId::InnerShadows as u32 - | SurfaceId::TextDropShadows as u32, - |s| { - let canvas = s.canvas(); - canvas.reset_matrix(); - canvas.scale((scale, scale)); - canvas.translate(translation); - }, - ); + + // When context changes (zoom/pan/tile), clear all render surfaces first + // to remove any residual content from previous tiles, then mark as dirty + // so they get redrawn with new transformations + let surface_ids = SurfaceId::Fills as u32 + | SurfaceId::Strokes as u32 + | SurfaceId::InnerShadows as u32 + | SurfaceId::TextDropShadows as u32; + + // Clear surfaces before updating transformations to remove residual content + self.apply_mut(surface_ids, |s| { + s.canvas().clear(skia::Color::TRANSPARENT); + }); + + // Mark all render surfaces as dirty so they get redrawn + self.mark_dirty(SurfaceId::Fills); + self.mark_dirty(SurfaceId::Strokes); + self.mark_dirty(SurfaceId::InnerShadows); + self.mark_dirty(SurfaceId::TextDropShadows); + + // Update transformations + self.apply_mut(surface_ids, |s| { + let canvas = s.canvas(); + canvas.reset_matrix(); + canvas.scale((scale, scale)); + canvas.translate(translation); + }); } #[inline] @@ -264,19 +326,21 @@ impl Surfaces { pub fn draw_rect_to(&mut self, id: SurfaceId, shape: &Shape, paint: &Paint) { if let Some(corners) = shape.shape_type.corners() { let rrect = RRect::new_rect_radii(shape.selrect, &corners); - self.canvas(id).draw_rrect(rrect, paint); + self.canvas_and_mark_dirty(id).draw_rrect(rrect, paint); } else { - self.canvas(id).draw_rect(shape.selrect, paint); + self.canvas_and_mark_dirty(id) + .draw_rect(shape.selrect, paint); } } pub fn draw_circle_to(&mut self, id: SurfaceId, shape: &Shape, paint: &Paint) { - self.canvas(id).draw_oval(shape.selrect, paint); + self.canvas_and_mark_dirty(id) + .draw_oval(shape.selrect, paint); } pub fn draw_path_to(&mut self, id: SurfaceId, shape: &Shape, paint: &Paint) { if let Some(path) = shape.get_skia_path() { - self.canvas(id).draw_path(&path, paint); + self.canvas_and_mark_dirty(id).draw_path(&path, paint); } } @@ -304,6 +368,9 @@ impl Surfaces { self.canvas(SurfaceId::UI) .clear(skia::Color::TRANSPARENT) .reset_matrix(); + + // Clear all dirty flags after reset + self.clear_all_dirty(); } pub fn cache_current_tile_texture( diff --git a/render-wasm/src/render/text.rs b/render-wasm/src/render/text.rs index 58f10cbc6c..ba14112921 100644 --- a/render-wasm/src/render/text.rs +++ b/render-wasm/src/render/text.rs @@ -192,7 +192,7 @@ pub fn render( } } - let canvas = render_state.surfaces.canvas(target_surface); + let canvas = render_state.surfaces.canvas_and_mark_dirty(target_surface); render_text_on_canvas(canvas, shape, paragraph_builders, shadow, blur); return; } @@ -371,7 +371,7 @@ pub fn render_as_path( ) { let canvas = render_state .surfaces - .canvas(surface_id.unwrap_or(SurfaceId::Fills)); + .canvas_and_mark_dirty(surface_id.unwrap_or(SurfaceId::Fills)); for (path, paint) in paths { // Note: path can be empty @@ -397,7 +397,7 @@ pub fn render_position_data( let rect = Rect::from_xywh(pd.x, pd.y, pd.width, pd.height); render_state .surfaces - .canvas(surface_id) + .canvas_and_mark_dirty(surface_id) .draw_rect(rect, &paint); } } From 4a8e02987f19c0a9494bd685bedac0d24c6524cf Mon Sep 17 00:00:00 2001 From: Alejandro Alonso Date: Tue, 13 Jan 2026 16:59:33 +0100 Subject: [PATCH 08/20] :bug: Fix mask erros on save/restore optimizations --- render-wasm/src/render.rs | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/render-wasm/src/render.rs b/render-wasm/src/render.rs index 9e0fddeec3..e0d138d2f7 100644 --- a/render-wasm/src/render.rs +++ b/render-wasm/src/render.rs @@ -1174,7 +1174,10 @@ impl RenderState { // Only create save_layer if actually needed // For simple shapes with default opacity and blend mode, skip expensive save_layer - let needs_layer = element.needs_layer() || mask; + // Groups with masks need a layer to properly handle the mask rendering + let needs_layer = element.needs_layer() + || (matches!(element.shape_type, Type::Group(g) if g.masked)) + || mask; if needs_layer { let mut paint = skia::Paint::default(); @@ -1279,9 +1282,9 @@ impl RenderState { } // Only restore if we created a layer (optimization for simple shapes) - let needs_layer = element.needs_layer() - || (matches!(element.shape_type, Type::Group(_)) - && matches!(element.shape_type, Type::Group(g) if g.masked)); + // Groups with masks need restore to properly handle the mask rendering + let needs_layer = + element.needs_layer() || (matches!(element.shape_type, Type::Group(g) if g.masked)); if needs_layer { self.surfaces.canvas(SurfaceId::Current).restore(); From d593e299e3d23dc9c17f4f362ad0719fec707fcb Mon Sep 17 00:00:00 2001 From: Alejandro Alonso Date: Tue, 13 Jan 2026 19:27:34 +0100 Subject: [PATCH 09/20] :bug: Fix mask erros on save/restore optimizations --- render-wasm/src/render.rs | 31 ++++++++++++++----------------- render-wasm/src/shapes.rs | 1 + 2 files changed, 15 insertions(+), 17 deletions(-) diff --git a/render-wasm/src/render.rs b/render-wasm/src/render.rs index e0d138d2f7..6fb8e85745 100644 --- a/render-wasm/src/render.rs +++ b/render-wasm/src/render.rs @@ -1172,12 +1172,22 @@ impl RenderState { self.nested_fills.push(Vec::new()); } + // When we're rendering the mask shape we need to set a special blend mode + // called 'destination-in' that keeps the drawn content within the mask. + // @see https://skia.org/docs/user/api/skblendmode_overview/ + if mask { + let mut mask_paint = skia::Paint::default(); + mask_paint.set_blend_mode(skia::BlendMode::DstIn); + let mask_rec = skia::canvas::SaveLayerRec::default().paint(&mask_paint); + self.surfaces + .canvas(SurfaceId::Current) + .save_layer(&mask_rec); + } + // Only create save_layer if actually needed // For simple shapes with default opacity and blend mode, skip expensive save_layer // Groups with masks need a layer to properly handle the mask rendering - let needs_layer = element.needs_layer() - || (matches!(element.shape_type, Type::Group(g) if g.masked)) - || mask; + let needs_layer = element.needs_layer(); if needs_layer { let mut paint = skia::Paint::default(); @@ -1192,18 +1202,6 @@ impl RenderState { } } - // When we're rendering the mask shape we need to set a special blend mode - // called 'destination-in' that keeps the drawn content within the mask. - // @see https://skia.org/docs/user/api/skblendmode_overview/ - if mask { - let mut mask_paint = skia::Paint::default(); - mask_paint.set_blend_mode(skia::BlendMode::DstIn); - let mask_rec = skia::canvas::SaveLayerRec::default().paint(&mask_paint); - self.surfaces - .canvas(SurfaceId::Current) - .save_layer(&mask_rec); - } - let layer_rec = skia::canvas::SaveLayerRec::default().paint(&paint); self.surfaces .canvas(SurfaceId::Current) @@ -1283,8 +1281,7 @@ impl RenderState { // Only restore if we created a layer (optimization for simple shapes) // Groups with masks need restore to properly handle the mask rendering - let needs_layer = - element.needs_layer() || (matches!(element.shape_type, Type::Group(g) if g.masked)); + let needs_layer = element.needs_layer(); if needs_layer { self.surfaces.canvas(SurfaceId::Current).restore(); diff --git a/render-wasm/src/shapes.rs b/render-wasm/src/shapes.rs index e90478c36a..e464b27437 100644 --- a/render-wasm/src/shapes.rs +++ b/render-wasm/src/shapes.rs @@ -1485,6 +1485,7 @@ impl Shape { self.opacity() < 1.0 || self.blend_mode().0 != skia::BlendMode::SrcOver || self.has_frame_clip_layer_blur() + || (matches!(self.shape_type, Type::Group(g) if g.masked)) } /// Checks if this frame has clip layer blur (affects children) From c60d74df62c7a1faf40aa979e16236bdc97701f8 Mon Sep 17 00:00:00 2001 From: Alejandro Alonso Date: Wed, 14 Jan 2026 07:10:26 +0100 Subject: [PATCH 10/20] :bug: Fix nested frames border clipping --- .../get-file-frame-nested-clipping.json | 3057 +++++++++++------ ...s-a-file-with-nested-clipping-frames-1.png | Bin 23765 -> 25965 bytes render-wasm/src/render.rs | 11 +- 3 files changed, 1990 insertions(+), 1078 deletions(-) diff --git a/frontend/playwright/data/render-wasm/get-file-frame-nested-clipping.json b/frontend/playwright/data/render-wasm/get-file-frame-nested-clipping.json index 1a4d016d8f..6e68edf16b 100644 --- a/frontend/playwright/data/render-wasm/get-file-frame-nested-clipping.json +++ b/frontend/playwright/data/render-wasm/get-file-frame-nested-clipping.json @@ -1,1089 +1,1996 @@ { - "~:features": { - "~#set": [ - "fdata/path-data", - "plugins/runtime", - "design-tokens/v1", - "variants/v1", - "layout/grid", - "styles/v2", - "fdata/pointer-map", - "fdata/objects-map", - "render-wasm/v1", - "components/v2", - "fdata/shape-data-type" - ] - }, - "~:team-id": "~ueba8fa2e-4140-8084-8005-448635d7a724", - "~: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": "Nested clipping", - "~:revn": 44, - "~:modified-at": "~m1764151542189", - "~:vern": 0, - "~:id": "~u44471494-966a-8178-8006-c5bd93f0fe72", - "~:is-shared": false, - "~:migrations": { - "~#ordered-set": [ - "legacy-2", - "legacy-3", - "legacy-5", - "legacy-6", - "legacy-7", - "legacy-8", - "legacy-9", - "legacy-10", - "legacy-11", - "legacy-12", - "legacy-13", - "legacy-14", - "legacy-16", - "legacy-17", - "legacy-18", - "legacy-19", - "legacy-25", - "legacy-26", - "legacy-27", - "legacy-28", - "legacy-29", - "legacy-31", - "legacy-32", - "legacy-33", - "legacy-34", - "legacy-36", - "legacy-37", - "legacy-38", - "legacy-39", - "legacy-40", - "legacy-41", - "legacy-42", - "legacy-43", - "legacy-44", - "legacy-45", - "legacy-46", - "legacy-47", - "legacy-48", - "legacy-49", - "legacy-50", - "legacy-51", - "legacy-52", - "legacy-53", - "legacy-54", - "legacy-55", - "legacy-56", - "legacy-57", - "legacy-59", - "legacy-62", - "legacy-65", - "legacy-66", - "legacy-67", - "0001-remove-tokens-from-groups", - "0002-normalize-bool-content-v2", - "0002-clean-shape-interactions", - "0003-fix-root-shape", - "0003-convert-path-content-v2", - "0005-deprecate-image-type", - "0006-fix-old-texts-fills", - "0008-fix-library-colors-v4", - "0009-clean-library-colors", - "0009-add-partial-text-touched-flags", - "0010-fix-swap-slots-pointing-non-existent-shapes", - "0011-fix-invalid-text-touched-flags", - "0012-fix-position-data", - "0013-fix-component-path", - "0013-clear-invalid-strokes-and-fills", - "0014-fix-tokens-lib-duplicate-ids", - "0014-clear-components-nil-objects", - "0015-fix-text-attrs-blank-strings", - "0015-clean-shadow-color", - "0016-copy-fills-from-position-data-to-text-node" - ] - }, - "~:version": 67, - "~:project-id": "~ueba8fa2e-4140-8084-8005-448635da32b4", - "~:created-at": "~m1764144613130", - "~:backend": "legacy-db", - "~:data": { - "~:pages": [ - "~u44471494-966a-8178-8006-c5bd93f0fe73" - ], - "~:pages-index": { - "~u44471494-966a-8178-8006-c5bd93f0fe73": { - "~:objects": { - "~u00000000-0000-0000-0000-000000000000": { - "~#shape": { - "~:y": 0, - "~:hide-fill-on-export": false, - "~:transform": { - "~#matrix": { - "~:a": 1, - "~:b": 0, - "~:c": 0, - "~:d": 1, - "~:e": 0, - "~:f": 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 - } - } - ], - "~:r2": 0, - "~:proportion-lock": false, - "~:transform-inverse": { - "~#matrix": { - "~:a": 1, - "~:b": 0, - "~:c": 0, - "~:d": 1, - "~:e": 0, - "~:f": 0 - } - }, - "~:r3": 0, - "~:r1": 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, - "~:r4": 0, - "~:selrect": { - "~#rect": { + "~:features": { + "~#set": [ + "fdata/path-data", + "design-tokens/v1", + "variants/v1", + "layout/grid", + "fdata/pointer-map", + "fdata/objects-map", + "components/v2", + "fdata/shape-data-type" + ] + }, + "~:team-id": "~ueba8fa2e-4140-8084-8005-448635d7a724", + "~: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": "Nested clipping", + "~:revn": 44, + "~:modified-at": "~m1764151542189", + "~:vern": 0, + "~:id": "~u44471494-966a-8178-8006-c5bd93f0fe72", + "~:is-shared": false, + "~:migrations": { + "~#ordered-set": [ + "legacy-2", + "legacy-3", + "legacy-5", + "legacy-6", + "legacy-7", + "legacy-8", + "legacy-9", + "legacy-10", + "legacy-11", + "legacy-12", + "legacy-13", + "legacy-14", + "legacy-16", + "legacy-17", + "legacy-18", + "legacy-19", + "legacy-25", + "legacy-26", + "legacy-27", + "legacy-28", + "legacy-29", + "legacy-31", + "legacy-32", + "legacy-33", + "legacy-34", + "legacy-36", + "legacy-37", + "legacy-38", + "legacy-39", + "legacy-40", + "legacy-41", + "legacy-42", + "legacy-43", + "legacy-44", + "legacy-45", + "legacy-46", + "legacy-47", + "legacy-48", + "legacy-49", + "legacy-50", + "legacy-51", + "legacy-52", + "legacy-53", + "legacy-54", + "legacy-55", + "legacy-56", + "legacy-57", + "legacy-59", + "legacy-62", + "legacy-65", + "legacy-66", + "legacy-67", + "0001-remove-tokens-from-groups", + "0002-normalize-bool-content-v2", + "0002-clean-shape-interactions", + "0003-fix-root-shape", + "0003-convert-path-content-v2", + "0005-deprecate-image-type", + "0006-fix-old-texts-fills", + "0008-fix-library-colors-v4", + "0009-clean-library-colors", + "0009-add-partial-text-touched-flags", + "0010-fix-swap-slots-pointing-non-existent-shapes", + "0011-fix-invalid-text-touched-flags", + "0012-fix-position-data", + "0013-fix-component-path", + "0013-clear-invalid-strokes-and-fills", + "0014-fix-tokens-lib-duplicate-ids", + "0014-clear-components-nil-objects", + "0015-fix-text-attrs-blank-strings", + "0015-clean-shadow-color", + "0016-copy-fills-from-position-data-to-text-node" + ] + }, + "~:version": 67, + "~:project-id": "~ueba8fa2e-4140-8084-8005-448635da32b4", + "~:created-at": "~m1768375757989", + "~:backend": "legacy-db", + "~:data": { + "~:pages": [ + "~u44471494-966a-8178-8006-c5bd93f0fe73" + ], + "~:pages-index": { + "~u44471494-966a-8178-8006-c5bd93f0fe73": { + "~:id": "~ubf963303-bcd6-8000-8007-6a0bc8b60ccb", + "~:name": "Page 1", + "~:objects": { + "~u00000000-0000-0000-0000-000000000000": { + "~#shape": { + "~:y": 0, + "~:hide-fill-on-export": false, + "~:transform": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:rotation": 0, + "~:name": "Root Frame", + "~:width": 0.01, + "~:type": "~:frame", + "~:points": [ + { + "~#point": { "~:x": 0, - "~:y": 0, - "~:width": 0.01, - "~:height": 0.01, - "~:x1": 0, - "~:y1": 0, - "~:x2": 0.01, - "~:y2": 0.01 + "~:y": 0 } }, - "~:fills": [ - { - "~:fill-color": "#FFFFFF", - "~:fill-opacity": 1 - } - ], - "~:flip-x": null, - "~:height": 0.01, - "~:flip-y": null, - "~:shapes": [ - "~u571478fd-6386-8085-8007-2b11cd2fc79a", - "~u1a629c22-3d11-80b1-8007-2b2bf3d82765", - "~u1a629c22-3d11-80b1-8007-2b2c061d3786" - ] - } - }, - "~u571478fd-6386-8085-8007-2b11c3aa600f": { - "~#shape": { - "~:y": 440, - "~:transform": { - "~#matrix": { - "~:a": 1, - "~:b": 0, - "~:c": 0, - "~:d": 1, - "~:e": 0, - "~:f": 0 + { + "~#point": { + "~:x": 0.01, + "~:y": 0 } }, - "~:rotation": 0, - "~:grow-type": "~:fixed", - "~:hide-in-viewer": false, - "~:name": "Rectangle", - "~:width": 456, - "~:type": "~:rect", - "~:points": [ - { - "~#point": { - "~:x": 669, - "~:y": 440 - } - }, - { - "~#point": { - "~:x": 1125, - "~:y": 440 - } - }, - { - "~#point": { - "~:x": 1125, - "~:y": 609 - } - }, - { - "~#point": { - "~:x": 669, - "~:y": 609 - } - } - ], - "~:r2": 0, - "~:proportion-lock": false, - "~:transform-inverse": { - "~#matrix": { - "~:a": 1, - "~:b": 0, - "~:c": 0, - "~:d": 1, - "~:e": 0, - "~:f": 0 + { + "~#point": { + "~:x": 0.01, + "~:y": 0.01 } }, - "~:r3": 0, - "~:constraints-v": "~:top", - "~:constraints-h": "~:left", - "~:r1": 0, - "~:id": "~u571478fd-6386-8085-8007-2b11c3aa600f", - "~:parent-id": "~u571478fd-6386-8085-8007-2b11bf4e9c11", - "~:frame-id": "~u571478fd-6386-8085-8007-2b11bf4e9c11", - "~:strokes": [], - "~:x": 669, - "~:proportion": 1, - "~:r4": 0, - "~:selrect": { - "~#rect": { - "~:x": 669, - "~:y": 440, - "~:width": 456, - "~:height": 169, - "~:x1": 669, - "~:y1": 440, - "~:x2": 1125, - "~:y2": 609 + { + "~#point": { + "~:x": 0, + "~:y": 0.01 } - }, - "~:fills": [ - { - "~:fill-color": "#B1B2B5", - "~:fill-opacity": 1 - } - ], - "~:flip-x": null, - "~:height": 169, - "~:flip-y": null - } - }, - "~u571478fd-6386-8085-8007-2b11cd2fc79a": { - "~#shape": { - "~:y": 204, - "~:hide-fill-on-export": false, - "~:transform": { - "~#matrix": { - "~:a": 1, - "~:b": 0, - "~:c": 0, - "~:d": 1, - "~:e": 0, - "~:f": 0 - } - }, - "~:rotation": 0, - "~:grow-type": "~:fixed", - "~:hide-in-viewer": false, - "~:name": "Board", - "~:width": 535, - "~:type": "~:frame", - "~:points": [ - { - "~#point": { - "~:x": 333, - "~:y": 204 - } - }, - { - "~#point": { - "~:x": 868, - "~:y": 204 - } - }, - { - "~#point": { - "~:x": 868, - "~:y": 851 - } - }, - { - "~#point": { - "~:x": 333, - "~:y": 851 - } - } - ], - "~:r2": 0, - "~:proportion-lock": false, - "~:transform-inverse": { - "~#matrix": { - "~:a": 1, - "~:b": 0, - "~:c": 0, - "~:d": 1, - "~:e": 0, - "~:f": 0 - } - }, - "~:r3": 0, - "~:r1": 0, - "~:id": "~u571478fd-6386-8085-8007-2b11cd2fc79a", - "~:parent-id": "~u00000000-0000-0000-0000-000000000000", - "~:frame-id": "~u00000000-0000-0000-0000-000000000000", - "~:strokes": [], - "~:x": 333, - "~:proportion": 1, - "~:r4": 0, - "~:selrect": { - "~#rect": { - "~:x": 333, - "~:y": 204, - "~:width": 535, - "~:height": 647, - "~:x1": 333, - "~:y1": 204, - "~:x2": 868, - "~:y2": 851 - } - }, - "~:fills": [ - { - "~:fill-color": "#FFFFFF", - "~:fill-opacity": 1 - } - ], - "~:flip-x": null, - "~:height": 647, - "~:flip-y": null, - "~:shapes": [ - "~u571478fd-6386-8085-8007-2b11bf4e9c11" - ] - } - }, - "~u1a629c22-3d11-80b1-8007-2b2c061d3788": { - "~#shape": { - "~:y": 1173, - "~:transform": { - "~#matrix": { - "~:a": 1, - "~:b": 0, - "~:c": 0, - "~:d": 1, - "~:e": 0, - "~:f": 0 - } - }, - "~:rotation": 0, - "~:grow-type": "~:fixed", - "~:hide-in-viewer": false, - "~:name": "Rectangle", - "~:width": 456, - "~:type": "~:rect", - "~:points": [ - { - "~#point": { - "~:x": 1254, - "~:y": 1173 - } - }, - { - "~#point": { - "~:x": 1710, - "~:y": 1173 - } - }, - { - "~#point": { - "~:x": 1710, - "~:y": 1342 - } - }, - { - "~#point": { - "~:x": 1254, - "~:y": 1342 - } - } - ], - "~:r2": 0, - "~:proportion-lock": false, - "~:transform-inverse": { - "~#matrix": { - "~:a": 1, - "~:b": 0, - "~:c": 0, - "~:d": 1, - "~:e": 0, - "~:f": 0 - } - }, - "~:r3": 0, - "~:constraints-v": "~:top", - "~:constraints-h": "~:left", - "~:r1": 0, - "~:id": "~u1a629c22-3d11-80b1-8007-2b2c061d3788", - "~:parent-id": "~u1a629c22-3d11-80b1-8007-2b2c061d3787", - "~:frame-id": "~u1a629c22-3d11-80b1-8007-2b2c061d3787", - "~:strokes": [], - "~:x": 1254, - "~:proportion": 1, - "~:r4": 0, - "~:selrect": { - "~#rect": { - "~:x": 1254, - "~:y": 1173, - "~:width": 456, - "~:height": 169, - "~:x1": 1254, - "~:y1": 1173, - "~:x2": 1710, - "~:y2": 1342 - } - }, - "~:fills": [ - { - "~:fill-color": "#B1B2B5", - "~:fill-opacity": 1 - } - ], - "~:flip-x": null, - "~:height": 169, - "~:flip-y": null - } - }, - "~u1a629c22-3d11-80b1-8007-2b2c061d3787": { - "~#shape": { - "~:y": 1042, - "~:hide-fill-on-export": false, - "~:transform": { - "~#matrix": { - "~:a": 1, - "~:b": 0, - "~:c": 0, - "~:d": 1, - "~:e": 0, - "~:f": 0 - } - }, - "~:rotation": 0, - "~:grow-type": "~:fixed", - "~:hide-in-viewer": true, - "~:name": "Board", - "~:width": 518, - "~:type": "~:frame", - "~:points": [ - { - "~#point": { - "~:x": 1106, - "~:y": 1042 - } - }, - { - "~#point": { - "~:x": 1624, - "~:y": 1042 - } - }, - { - "~#point": { - "~:x": 1624, - "~:y": 1466 - } - }, - { - "~#point": { - "~:x": 1106, - "~:y": 1466 - } - } - ], - "~:r2": 0, - "~:proportion-lock": false, - "~:transform-inverse": { - "~#matrix": { - "~:a": 1, - "~:b": 0, - "~:c": 0, - "~:d": 1, - "~:e": 0, - "~:f": 0 - } - }, - "~:r3": 0, - "~:constraints-v": "~:top", - "~:constraints-h": "~:left", - "~:r1": 0, - "~:id": "~u1a629c22-3d11-80b1-8007-2b2c061d3787", - "~:parent-id": "~u1a629c22-3d11-80b1-8007-2b2c061d3786", - "~:frame-id": "~u1a629c22-3d11-80b1-8007-2b2c061d3786", - "~:strokes": [], - "~:x": 1106, - "~:proportion": 1, - "~:r4": 0, - "~:selrect": { - "~#rect": { - "~:x": 1106, - "~:y": 1042, - "~:width": 518, - "~:height": 424, - "~:x1": 1106, - "~:y1": 1042, - "~:x2": 1624, - "~:y2": 1466 - } - }, - "~:fills": [ - { - "~:fill-color": "#dc0606", - "~:fill-opacity": 1 - } - ], - "~:flip-x": null, - "~:height": 424, - "~:flip-y": null, - "~:shapes": [ - "~u1a629c22-3d11-80b1-8007-2b2c061d3788" - ] - } - }, - "~u571478fd-6386-8085-8007-2b11bf4e9c11": { - "~#shape": { - "~:y": 309, - "~:hide-fill-on-export": false, - "~:transform": { - "~#matrix": { - "~:a": 1, - "~:b": 0, - "~:c": 0, - "~:d": 1, - "~:e": 0, - "~:f": 0 - } - }, - "~:rotation": 0, - "~:grow-type": "~:fixed", - "~:hide-in-viewer": true, - "~:name": "Board", - "~:width": 518, - "~:type": "~:frame", - "~:points": [ - { - "~#point": { - "~:x": 521, - "~:y": 309 - } - }, - { - "~#point": { - "~:x": 1039, - "~:y": 309 - } - }, - { - "~#point": { - "~:x": 1039, - "~:y": 733 - } - }, - { - "~#point": { - "~:x": 521, - "~:y": 733 - } - } - ], - "~:r2": 0, - "~:proportion-lock": false, - "~:transform-inverse": { - "~#matrix": { - "~:a": 1, - "~:b": 0, - "~:c": 0, - "~:d": 1, - "~:e": 0, - "~:f": 0 - } - }, - "~:r3": 0, - "~:constraints-v": "~:top", - "~:constraints-h": "~:left", - "~:r1": 0, - "~:id": "~u571478fd-6386-8085-8007-2b11bf4e9c11", - "~:parent-id": "~u571478fd-6386-8085-8007-2b11cd2fc79a", - "~:frame-id": "~u571478fd-6386-8085-8007-2b11cd2fc79a", - "~:strokes": [], - "~:x": 521, - "~:proportion": 1, - "~:r4": 0, - "~:selrect": { - "~#rect": { - "~:x": 521, - "~:y": 309, - "~:width": 518, - "~:height": 424, - "~:x1": 521, - "~:y1": 309, - "~:x2": 1039, - "~:y2": 733 - } - }, - "~:fills": [ - { - "~:fill-color": "#dc0606", - "~:fill-opacity": 1 - } - ], - "~:flip-x": null, - "~:height": 424, - "~:flip-y": null, - "~:shapes": [ - "~u571478fd-6386-8085-8007-2b11c3aa600f" - ] - } - }, - "~u1a629c22-3d11-80b1-8007-2b2c061d3786": { - "~#shape": { - "~:y": 937, - "~:hide-fill-on-export": false, - "~:transform": { - "~#matrix": { - "~:a": 1, - "~:b": 0, - "~:c": 0, - "~:d": 1, - "~:e": 0, - "~:f": 0 - } - }, - "~:rotation": 0, - "~:grow-type": "~:fixed", - "~:hide-in-viewer": false, - "~:name": "Board", - "~:width": 535, - "~:type": "~:frame", - "~:points": [ - { - "~#point": { - "~:x": 918, - "~:y": 937 - } - }, - { - "~#point": { - "~:x": 1453, - "~:y": 937 - } - }, - { - "~#point": { - "~:x": 1453, - "~:y": 1584 - } - }, - { - "~#point": { - "~:x": 918, - "~:y": 1584 - } - } - ], - "~:r2": 0, - "~:proportion-lock": false, - "~:transform-inverse": { - "~#matrix": { - "~:a": 1, - "~:b": 0, - "~:c": 0, - "~:d": 1, - "~:e": 0, - "~:f": 0 - } - }, - "~:r3": 0, - "~:blur": { - "~:id": "~u1a629c22-3d11-80b1-8007-2b2c031523df", - "~:type": "~:layer-blur", - "~:value": 4, - "~:hidden": false - }, - "~:r1": 0, - "~:id": "~u1a629c22-3d11-80b1-8007-2b2c061d3786", - "~:parent-id": "~u00000000-0000-0000-0000-000000000000", - "~:frame-id": "~u00000000-0000-0000-0000-000000000000", - "~:strokes": [], - "~:x": 918, - "~:proportion": 1, - "~:shadow": [ - { - "~:id": "~u1a629c22-3d11-80b1-8007-2b2c0899422b", - "~:style": "~:drop-shadow", - "~:color": { - "~:color": "#000000", - "~:opacity": 1 - }, - "~:offset-x": 40, - "~:offset-y": 40, - "~:blur": 4, - "~:spread": 0, - "~:hidden": false - } - ], - "~:r4": 0, - "~:selrect": { - "~#rect": { - "~:x": 918, - "~:y": 937, - "~:width": 535, - "~:height": 647, - "~:x1": 918, - "~:y1": 937, - "~:x2": 1453, - "~:y2": 1584 - } - }, - "~:fills": [ - { - "~:fill-color": "#FFFFFF", - "~:fill-opacity": 1 - } - ], - "~:flip-x": null, - "~:height": 647, - "~:flip-y": null, - "~:shapes": [ - "~u1a629c22-3d11-80b1-8007-2b2c061d3787" - ] - } - }, - "~u1a629c22-3d11-80b1-8007-2b2bf3d82765": { - "~#shape": { - "~:y": 937, - "~:hide-fill-on-export": false, - "~:transform": { - "~#matrix": { - "~:a": 1, - "~:b": 0, - "~:c": 0, - "~:d": 1, - "~:e": 0, - "~:f": 0 - } - }, - "~:rotation": 0, - "~:grow-type": "~:fixed", - "~:hide-in-viewer": false, - "~:name": "Board", - "~:width": 535, - "~:type": "~:frame", - "~:points": [ - { - "~#point": { - "~:x": 333, - "~:y": 937 - } - }, - { - "~#point": { - "~:x": 868, - "~:y": 937 - } - }, - { - "~#point": { - "~:x": 868, - "~:y": 1584 - } - }, - { - "~#point": { - "~:x": 333, - "~:y": 1584 - } - } - ], - "~:r2": 0, - "~:proportion-lock": false, - "~:transform-inverse": { - "~#matrix": { - "~:a": 1, - "~:b": 0, - "~:c": 0, - "~:d": 1, - "~:e": 0, - "~:f": 0 - } - }, - "~:r3": 0, - "~:blur": { - "~:id": "~u1a629c22-3d11-80b1-8007-2b2c031523df", - "~:type": "~:layer-blur", - "~:value": 4, - "~:hidden": false - }, - "~:r1": 0, - "~:id": "~u1a629c22-3d11-80b1-8007-2b2bf3d82765", - "~:parent-id": "~u00000000-0000-0000-0000-000000000000", - "~:frame-id": "~u00000000-0000-0000-0000-000000000000", - "~:strokes": [], - "~:x": 333, - "~:proportion": 1, - "~:r4": 0, - "~:selrect": { - "~#rect": { - "~:x": 333, - "~:y": 937, - "~:width": 535, - "~:height": 647, - "~:x1": 333, - "~:y1": 937, - "~:x2": 868, - "~:y2": 1584 - } - }, - "~:fills": [ - { - "~:fill-color": "#FFFFFF", - "~:fill-opacity": 1 - } - ], - "~:flip-x": null, - "~:height": 647, - "~:flip-y": null, - "~:shapes": [ - "~u1a629c22-3d11-80b1-8007-2b2bf3d82766" - ] - } - }, - "~u1a629c22-3d11-80b1-8007-2b2bf3d82766": { - "~#shape": { - "~:y": 1042, - "~:hide-fill-on-export": false, - "~:transform": { - "~#matrix": { - "~:a": 1, - "~:b": 0, - "~:c": 0, - "~:d": 1, - "~:e": 0, - "~:f": 0 - } - }, - "~:rotation": 0, - "~:grow-type": "~:fixed", - "~:hide-in-viewer": true, - "~:name": "Board", - "~:width": 518, - "~:type": "~:frame", - "~:points": [ - { - "~#point": { - "~:x": 521, - "~:y": 1042 - } - }, - { - "~#point": { - "~:x": 1039, - "~:y": 1042 - } - }, - { - "~#point": { - "~:x": 1039, - "~:y": 1466 - } - }, - { - "~#point": { - "~:x": 521, - "~:y": 1466 - } - } - ], - "~:r2": 0, - "~:proportion-lock": false, - "~:transform-inverse": { - "~#matrix": { - "~:a": 1, - "~:b": 0, - "~:c": 0, - "~:d": 1, - "~:e": 0, - "~:f": 0 - } - }, - "~:r3": 0, - "~:constraints-v": "~:top", - "~:constraints-h": "~:left", - "~:r1": 0, - "~:id": "~u1a629c22-3d11-80b1-8007-2b2bf3d82766", - "~:parent-id": "~u1a629c22-3d11-80b1-8007-2b2bf3d82765", - "~:frame-id": "~u1a629c22-3d11-80b1-8007-2b2bf3d82765", - "~:strokes": [], - "~:x": 521, - "~:proportion": 1, - "~:r4": 0, - "~:selrect": { - "~#rect": { - "~:x": 521, - "~:y": 1042, - "~:width": 518, - "~:height": 424, - "~:x1": 521, - "~:y1": 1042, - "~:x2": 1039, - "~:y2": 1466 - } - }, - "~:fills": [ - { - "~:fill-color": "#dc0606", - "~:fill-opacity": 1 - } - ], - "~:flip-x": null, - "~:height": 424, - "~:flip-y": null, - "~:shapes": [ - "~u1a629c22-3d11-80b1-8007-2b2bf3d82767" - ] - } - }, - "~u1a629c22-3d11-80b1-8007-2b2bf3d82767": { - "~#shape": { - "~:y": 1173, - "~:transform": { - "~#matrix": { - "~:a": 1, - "~:b": 0, - "~:c": 0, - "~:d": 1, - "~:e": 0, - "~:f": 0 - } - }, - "~:rotation": 0, - "~:grow-type": "~:fixed", - "~:hide-in-viewer": false, - "~:name": "Rectangle", - "~:width": 456, - "~:type": "~:rect", - "~:points": [ - { - "~#point": { - "~:x": 669, - "~:y": 1173 - } - }, - { - "~#point": { - "~:x": 1125, - "~:y": 1173 - } - }, - { - "~#point": { - "~:x": 1125, - "~:y": 1342 - } - }, - { - "~#point": { - "~:x": 669, - "~:y": 1342 - } - } - ], - "~:r2": 0, - "~:proportion-lock": false, - "~:transform-inverse": { - "~#matrix": { - "~:a": 1, - "~:b": 0, - "~:c": 0, - "~:d": 1, - "~:e": 0, - "~:f": 0 - } - }, - "~:r3": 0, - "~:constraints-v": "~:top", - "~:constraints-h": "~:left", - "~:r1": 0, - "~:id": "~u1a629c22-3d11-80b1-8007-2b2bf3d82767", - "~:parent-id": "~u1a629c22-3d11-80b1-8007-2b2bf3d82766", - "~:frame-id": "~u1a629c22-3d11-80b1-8007-2b2bf3d82766", - "~:strokes": [], - "~:x": 669, - "~:proportion": 1, - "~:r4": 0, - "~:selrect": { - "~#rect": { - "~:x": 669, - "~:y": 1173, - "~:width": 456, - "~:height": 169, - "~:x1": 669, - "~:y1": 1173, - "~:x2": 1125, - "~:y2": 1342 - } - }, - "~:fills": [ - { - "~:fill-color": "#B1B2B5", - "~:fill-opacity": 1 - } - ], - "~:flip-x": null, - "~:height": 169, - "~:flip-y": null - } + } + ], + "~:r2": 0, + "~:proportion-lock": false, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:page-id": "~ubf963303-bcd6-8000-8007-6a0bc8b60ccb", + "~:r3": 0, + "~:r1": 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, + "~:r4": 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": [ + "~ue5199cd7-69e2-8001-8007-6a1bc188c72e", + "~ue5199cd7-69e2-8001-8007-6a1bccaf0d96", + "~ue5199cd7-69e2-8001-8007-6a1bc188c731", + "~ue5199cd7-69e2-8001-8007-6a1beb087a2f", + "~ue5199cd7-69e2-8001-8007-6a1bc188c734", + "~ue5199cd7-69e2-8001-8007-6a1beb087a2c" + ] } }, - "~:id": "~u1dc9717a-2217-80f7-8007-2b11bac2823f", - "~:name": "Page 1" + "~ue5199cd7-69e2-8001-8007-6a1beb087a2d": { + "~#shape": { + "~:y": 1864.99995803833, + "~:hide-fill-on-export": false, + "~:transform": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:rotation": 0, + "~:grow-type": "~:fixed", + "~:hide-in-viewer": true, + "~:name": "Board", + "~:width": 518, + "~:type": "~:frame", + "~:points": [ + { + "~#point": { + "~:x": 1006, + "~:y": 1864.99995803833 + } + }, + { + "~#point": { + "~:x": 1524, + "~:y": 1864.99995803833 + } + }, + { + "~#point": { + "~:x": 1524, + "~:y": 2288.99995803833 + } + }, + { + "~#point": { + "~:x": 1006, + "~:y": 2288.99995803833 + } + } + ], + "~:r2": 0, + "~:proportion-lock": false, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:page-id": "~ubf963303-bcd6-8000-8007-6a0bc8b60ccb", + "~:r3": 0, + "~:constraints-v": "~:top", + "~:constraints-h": "~:left", + "~:r1": 0, + "~:id": "~ue5199cd7-69e2-8001-8007-6a1beb087a2d", + "~:parent-id": "~ue5199cd7-69e2-8001-8007-6a1beb087a2c", + "~:frame-id": "~ue5199cd7-69e2-8001-8007-6a1beb087a2c", + "~:strokes": [ + { + "~:stroke-alignment": "~:inner", + "~:stroke-style": "~:solid", + "~:stroke-color": "#000000", + "~:stroke-opacity": 1, + "~:stroke-width": 10 + } + ], + "~:x": 1006, + "~:proportion": 1, + "~:r4": 0, + "~:selrect": { + "~#rect": { + "~:x": 1006, + "~:y": 1864.99995803833, + "~:width": 518, + "~:height": 424, + "~:x1": 1006, + "~:y1": 1864.99995803833, + "~:x2": 1524, + "~:y2": 2288.99995803833 + } + }, + "~:fills": [ + { + "~:fill-color": "#dc0606", + "~:fill-opacity": 1 + } + ], + "~:flip-x": null, + "~:height": 424, + "~:flip-y": null, + "~:shapes": [ + "~ue5199cd7-69e2-8001-8007-6a1beb087a2e" + ] + } + }, + "~ue5199cd7-69e2-8001-8007-6a1beb087a2c": { + "~#shape": { + "~:y": 1759.99995803833, + "~:hide-fill-on-export": false, + "~:transform": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:rotation": 0, + "~:grow-type": "~:fixed", + "~:hide-in-viewer": false, + "~:name": "Board", + "~:width": 535, + "~:type": "~:frame", + "~:points": [ + { + "~#point": { + "~:x": 818, + "~:y": 1759.99995803833 + } + }, + { + "~#point": { + "~:x": 1353, + "~:y": 1759.99995803833 + } + }, + { + "~#point": { + "~:x": 1353, + "~:y": 2406.99995803833 + } + }, + { + "~#point": { + "~:x": 818, + "~:y": 2406.99995803833 + } + } + ], + "~:r2": 0, + "~:proportion-lock": false, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:page-id": "~ubf963303-bcd6-8000-8007-6a0bc8b60ccb", + "~:r3": 0, + "~:blur": { + "~:id": "~u1a629c22-3d11-80b1-8007-2b2c031523df", + "~:type": "~:layer-blur", + "~:value": 4, + "~:hidden": false + }, + "~:r1": 0, + "~:id": "~ue5199cd7-69e2-8001-8007-6a1beb087a2c", + "~:parent-id": "~u00000000-0000-0000-0000-000000000000", + "~:frame-id": "~u00000000-0000-0000-0000-000000000000", + "~:strokes": [], + "~:x": 818, + "~:proportion": 1, + "~:shadow": [ + { + "~:id": "~u1a629c22-3d11-80b1-8007-2b2c0899422b", + "~:style": "~:drop-shadow", + "~:color": { + "~:opacity": 1, + "~:color": "#000000" + }, + "~:offset-x": 40, + "~:offset-y": 40, + "~:blur": 4, + "~:spread": 0, + "~:hidden": false + } + ], + "~:r4": 0, + "~:selrect": { + "~#rect": { + "~:x": 818, + "~:y": 1759.99995803833, + "~:width": 535, + "~:height": 647, + "~:x1": 818, + "~:y1": 1759.99995803833, + "~:x2": 1353, + "~:y2": 2406.99995803833 + } + }, + "~:fills": [ + { + "~:fill-color": "#FFFFFF", + "~:fill-opacity": 1 + } + ], + "~:flip-x": null, + "~:height": 647, + "~:flip-y": null, + "~:shapes": [ + "~ue5199cd7-69e2-8001-8007-6a1beb087a2d" + ] + } + }, + "~ue5199cd7-69e2-8001-8007-6a1beb087a2f": { + "~#shape": { + "~:y": 1759.99995803833, + "~:hide-fill-on-export": false, + "~:transform": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:rotation": 0, + "~:grow-type": "~:fixed", + "~:hide-in-viewer": false, + "~:name": "Board", + "~:width": 535, + "~:type": "~:frame", + "~:points": [ + { + "~#point": { + "~:x": 233, + "~:y": 1759.99995803833 + } + }, + { + "~#point": { + "~:x": 768, + "~:y": 1759.99995803833 + } + }, + { + "~#point": { + "~:x": 768, + "~:y": 2406.99995803833 + } + }, + { + "~#point": { + "~:x": 233, + "~:y": 2406.99995803833 + } + } + ], + "~:r2": 0, + "~:proportion-lock": false, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:page-id": "~ubf963303-bcd6-8000-8007-6a0bc8b60ccb", + "~:r3": 0, + "~:blur": { + "~:id": "~u1a629c22-3d11-80b1-8007-2b2c031523df", + "~:type": "~:layer-blur", + "~:value": 4, + "~:hidden": false + }, + "~:r1": 0, + "~:id": "~ue5199cd7-69e2-8001-8007-6a1beb087a2f", + "~:parent-id": "~u00000000-0000-0000-0000-000000000000", + "~:frame-id": "~u00000000-0000-0000-0000-000000000000", + "~:strokes": [], + "~:x": 233, + "~:proportion": 1, + "~:r4": 0, + "~:selrect": { + "~#rect": { + "~:x": 233, + "~:y": 1759.99995803833, + "~:width": 535, + "~:height": 647, + "~:x1": 233, + "~:y1": 1759.99995803833, + "~:x2": 768, + "~:y2": 2406.99995803833 + } + }, + "~:fills": [ + { + "~:fill-color": "#FFFFFF", + "~:fill-opacity": 1 + } + ], + "~:flip-x": null, + "~:height": 647, + "~:flip-y": null, + "~:shapes": [ + "~ue5199cd7-69e2-8001-8007-6a1beb087a30" + ] + } + }, + "~ue5199cd7-69e2-8001-8007-6a1bc188c72f": { + "~#shape": { + "~:y": 375, + "~:hide-fill-on-export": false, + "~:transform": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:rotation": 0, + "~:grow-type": "~:fixed", + "~:hide-in-viewer": true, + "~:name": "Board", + "~:width": 518, + "~:type": "~:frame", + "~:points": [ + { + "~#point": { + "~:x": 421, + "~:y": 375 + } + }, + { + "~#point": { + "~:x": 939, + "~:y": 375 + } + }, + { + "~#point": { + "~:x": 939, + "~:y": 799 + } + }, + { + "~#point": { + "~:x": 421, + "~:y": 799 + } + } + ], + "~:r2": 0, + "~:proportion-lock": false, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:page-id": "~ubf963303-bcd6-8000-8007-6a0bc8b60ccb", + "~:r3": 0, + "~:constraints-v": "~:top", + "~:constraints-h": "~:left", + "~:r1": 0, + "~:id": "~ue5199cd7-69e2-8001-8007-6a1bc188c72f", + "~:parent-id": "~ue5199cd7-69e2-8001-8007-6a1bc188c72e", + "~:frame-id": "~ue5199cd7-69e2-8001-8007-6a1bc188c72e", + "~:strokes": [], + "~:x": 421, + "~:proportion": 1, + "~:r4": 0, + "~:selrect": { + "~#rect": { + "~:x": 421, + "~:y": 375, + "~:width": 518, + "~:height": 424, + "~:x1": 421, + "~:y1": 375, + "~:x2": 939, + "~:y2": 799 + } + }, + "~:fills": [ + { + "~:fill-color": "#dc0606", + "~:fill-opacity": 1 + } + ], + "~:flip-x": null, + "~:height": 424, + "~:flip-y": null, + "~:shapes": [ + "~ue5199cd7-69e2-8001-8007-6a1bc188c730" + ] + } + }, + "~ue5199cd7-69e2-8001-8007-6a1beb087a2e": { + "~#shape": { + "~:y": 1995.99995803833, + "~:transform": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:rotation": 0, + "~:grow-type": "~:fixed", + "~:hide-in-viewer": false, + "~:name": "Rectangle", + "~:width": 456, + "~:type": "~:rect", + "~:points": [ + { + "~#point": { + "~:x": 1154, + "~:y": 1995.99995803833 + } + }, + { + "~#point": { + "~:x": 1610, + "~:y": 1995.99995803833 + } + }, + { + "~#point": { + "~:x": 1610, + "~:y": 2164.99995803833 + } + }, + { + "~#point": { + "~:x": 1154, + "~:y": 2164.99995803833 + } + } + ], + "~:r2": 0, + "~:proportion-lock": false, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:page-id": "~ubf963303-bcd6-8000-8007-6a0bc8b60ccb", + "~:r3": 0, + "~:constraints-v": "~:top", + "~:constraints-h": "~:left", + "~:r1": 0, + "~:id": "~ue5199cd7-69e2-8001-8007-6a1beb087a2e", + "~:parent-id": "~ue5199cd7-69e2-8001-8007-6a1beb087a2d", + "~:frame-id": "~ue5199cd7-69e2-8001-8007-6a1beb087a2d", + "~:strokes": [], + "~:x": 1154, + "~:proportion": 1, + "~:r4": 0, + "~:selrect": { + "~#rect": { + "~:x": 1154, + "~:y": 1995.99995803833, + "~:width": 456, + "~:height": 169, + "~:x1": 1154, + "~:y1": 1995.99995803833, + "~:x2": 1610, + "~:y2": 2164.99995803833 + } + }, + "~:fills": [ + { + "~:fill-color": "#B1B2B5", + "~:fill-opacity": 1 + } + ], + "~:flip-x": null, + "~:height": 169, + "~:flip-y": null + } + }, + "~ue5199cd7-69e2-8001-8007-6a1bc188c72e": { + "~#shape": { + "~:y": 270, + "~:hide-fill-on-export": false, + "~:transform": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:rotation": 0, + "~:grow-type": "~:fixed", + "~:hide-in-viewer": false, + "~:name": "Board", + "~:width": 535, + "~:type": "~:frame", + "~:points": [ + { + "~#point": { + "~:x": 233, + "~:y": 270 + } + }, + { + "~#point": { + "~:x": 768, + "~:y": 270 + } + }, + { + "~#point": { + "~:x": 768, + "~:y": 917 + } + }, + { + "~#point": { + "~:x": 233, + "~:y": 917 + } + } + ], + "~:r2": 0, + "~:proportion-lock": false, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:page-id": "~ubf963303-bcd6-8000-8007-6a0bc8b60ccb", + "~:r3": 0, + "~:r1": 0, + "~:id": "~ue5199cd7-69e2-8001-8007-6a1bc188c72e", + "~:parent-id": "~u00000000-0000-0000-0000-000000000000", + "~:frame-id": "~u00000000-0000-0000-0000-000000000000", + "~:strokes": [], + "~:x": 233, + "~:proportion": 1, + "~:r4": 0, + "~:selrect": { + "~#rect": { + "~:x": 233, + "~:y": 270, + "~:width": 535, + "~:height": 647, + "~:x1": 233, + "~:y1": 270, + "~:x2": 768, + "~:y2": 917 + } + }, + "~:fills": [ + { + "~:fill-color": "#FFFFFF", + "~:fill-opacity": 1 + } + ], + "~:flip-x": null, + "~:height": 647, + "~:flip-y": null, + "~:shapes": [ + "~ue5199cd7-69e2-8001-8007-6a1bc188c72f" + ] + } + }, + "~ue5199cd7-69e2-8001-8007-6a1bccaf0d98": { + "~#shape": { + "~:y": 506, + "~:transform": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:rotation": 0, + "~:grow-type": "~:fixed", + "~:hide-in-viewer": false, + "~:name": "Rectangle", + "~:width": 456, + "~:type": "~:rect", + "~:points": [ + { + "~#point": { + "~:x": 1154, + "~:y": 506 + } + }, + { + "~#point": { + "~:x": 1610, + "~:y": 506 + } + }, + { + "~#point": { + "~:x": 1610, + "~:y": 675 + } + }, + { + "~#point": { + "~:x": 1154, + "~:y": 675 + } + } + ], + "~:r2": 0, + "~:proportion-lock": false, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:page-id": "~ubf963303-bcd6-8000-8007-6a0bc8b60ccb", + "~:r3": 0, + "~:constraints-v": "~:top", + "~:constraints-h": "~:left", + "~:r1": 0, + "~:id": "~ue5199cd7-69e2-8001-8007-6a1bccaf0d98", + "~:parent-id": "~ue5199cd7-69e2-8001-8007-6a1bccaf0d97", + "~:frame-id": "~ue5199cd7-69e2-8001-8007-6a1bccaf0d97", + "~:strokes": [], + "~:x": 1154, + "~:proportion": 1, + "~:r4": 0, + "~:selrect": { + "~#rect": { + "~:x": 1154, + "~:y": 506, + "~:width": 456, + "~:height": 169, + "~:x1": 1154, + "~:y1": 506, + "~:x2": 1610, + "~:y2": 675 + } + }, + "~:fills": [ + { + "~:fill-color": "#B1B2B5", + "~:fill-opacity": 1 + } + ], + "~:flip-x": null, + "~:height": 169, + "~:flip-y": null + } + }, + "~ue5199cd7-69e2-8001-8007-6a1bc188c735": { + "~#shape": { + "~:y": 1108, + "~:hide-fill-on-export": false, + "~:transform": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:rotation": 0, + "~:grow-type": "~:fixed", + "~:hide-in-viewer": true, + "~:name": "Board", + "~:width": 518, + "~:type": "~:frame", + "~:points": [ + { + "~#point": { + "~:x": 1006, + "~:y": 1108 + } + }, + { + "~#point": { + "~:x": 1524, + "~:y": 1108 + } + }, + { + "~#point": { + "~:x": 1524, + "~:y": 1532 + } + }, + { + "~#point": { + "~:x": 1006, + "~:y": 1532 + } + } + ], + "~:r2": 0, + "~:proportion-lock": false, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:page-id": "~ubf963303-bcd6-8000-8007-6a0bc8b60ccb", + "~:r3": 0, + "~:constraints-v": "~:top", + "~:constraints-h": "~:left", + "~:r1": 0, + "~:id": "~ue5199cd7-69e2-8001-8007-6a1bc188c735", + "~:parent-id": "~ue5199cd7-69e2-8001-8007-6a1bc188c734", + "~:frame-id": "~ue5199cd7-69e2-8001-8007-6a1bc188c734", + "~:strokes": [], + "~:x": 1006, + "~:proportion": 1, + "~:r4": 0, + "~:selrect": { + "~#rect": { + "~:x": 1006, + "~:y": 1108, + "~:width": 518, + "~:height": 424, + "~:x1": 1006, + "~:y1": 1108, + "~:x2": 1524, + "~:y2": 1532 + } + }, + "~:fills": [ + { + "~:fill-color": "#dc0606", + "~:fill-opacity": 1 + } + ], + "~:flip-x": null, + "~:height": 424, + "~:flip-y": null, + "~:shapes": [ + "~ue5199cd7-69e2-8001-8007-6a1bc188c736" + ] + } + }, + "~ue5199cd7-69e2-8001-8007-6a1bc188c734": { + "~#shape": { + "~:y": 1003, + "~:hide-fill-on-export": false, + "~:transform": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:rotation": 0, + "~:grow-type": "~:fixed", + "~:hide-in-viewer": false, + "~:name": "Board", + "~:width": 535, + "~:type": "~:frame", + "~:points": [ + { + "~#point": { + "~:x": 818, + "~:y": 1003 + } + }, + { + "~#point": { + "~:x": 1353, + "~:y": 1003 + } + }, + { + "~#point": { + "~:x": 1353, + "~:y": 1650 + } + }, + { + "~#point": { + "~:x": 818, + "~:y": 1650 + } + } + ], + "~:r2": 0, + "~:proportion-lock": false, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:page-id": "~ubf963303-bcd6-8000-8007-6a0bc8b60ccb", + "~:r3": 0, + "~:blur": { + "~:id": "~u1a629c22-3d11-80b1-8007-2b2c031523df", + "~:type": "~:layer-blur", + "~:value": 4, + "~:hidden": false + }, + "~:r1": 0, + "~:id": "~ue5199cd7-69e2-8001-8007-6a1bc188c734", + "~:parent-id": "~u00000000-0000-0000-0000-000000000000", + "~:frame-id": "~u00000000-0000-0000-0000-000000000000", + "~:strokes": [], + "~:x": 818, + "~:proportion": 1, + "~:shadow": [ + { + "~:id": "~u1a629c22-3d11-80b1-8007-2b2c0899422b", + "~:style": "~:drop-shadow", + "~:color": { + "~:opacity": 1, + "~:color": "#000000" + }, + "~:offset-x": 40, + "~:offset-y": 40, + "~:blur": 4, + "~:spread": 0, + "~:hidden": false + } + ], + "~:r4": 0, + "~:selrect": { + "~#rect": { + "~:x": 818, + "~:y": 1003, + "~:width": 535, + "~:height": 647, + "~:x1": 818, + "~:y1": 1003, + "~:x2": 1353, + "~:y2": 1650 + } + }, + "~:fills": [ + { + "~:fill-color": "#FFFFFF", + "~:fill-opacity": 1 + } + ], + "~:flip-x": null, + "~:height": 647, + "~:flip-y": null, + "~:shapes": [ + "~ue5199cd7-69e2-8001-8007-6a1bc188c735" + ] + } + }, + "~ue5199cd7-69e2-8001-8007-6a1bccaf0d97": { + "~#shape": { + "~:y": 375, + "~:hide-fill-on-export": false, + "~:transform": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:rotation": 0, + "~:grow-type": "~:fixed", + "~:hide-in-viewer": true, + "~:name": "Board", + "~:width": 518, + "~:type": "~:frame", + "~:points": [ + { + "~#point": { + "~:x": 1006, + "~:y": 375 + } + }, + { + "~#point": { + "~:x": 1524, + "~:y": 375 + } + }, + { + "~#point": { + "~:x": 1524, + "~:y": 799 + } + }, + { + "~#point": { + "~:x": 1006, + "~:y": 799 + } + } + ], + "~:r2": 0, + "~:proportion-lock": false, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:page-id": "~ubf963303-bcd6-8000-8007-6a0bc8b60ccb", + "~:r3": 0, + "~:constraints-v": "~:top", + "~:constraints-h": "~:left", + "~:r1": 0, + "~:id": "~ue5199cd7-69e2-8001-8007-6a1bccaf0d97", + "~:parent-id": "~ue5199cd7-69e2-8001-8007-6a1bccaf0d96", + "~:frame-id": "~ue5199cd7-69e2-8001-8007-6a1bccaf0d96", + "~:strokes": [ + { + "~:stroke-alignment": "~:inner", + "~:stroke-style": "~:solid", + "~:stroke-color": "#000000", + "~:stroke-opacity": 1, + "~:stroke-width": 10 + } + ], + "~:x": 1006, + "~:proportion": 1, + "~:r4": 0, + "~:selrect": { + "~#rect": { + "~:x": 1006, + "~:y": 375, + "~:width": 518, + "~:height": 424, + "~:x1": 1006, + "~:y1": 375, + "~:x2": 1524, + "~:y2": 799 + } + }, + "~:fills": [ + { + "~:fill-color": "#dc0606", + "~:fill-opacity": 1 + } + ], + "~:flip-x": null, + "~:height": 424, + "~:flip-y": null, + "~:shapes": [ + "~ue5199cd7-69e2-8001-8007-6a1bccaf0d98" + ] + } + }, + "~ue5199cd7-69e2-8001-8007-6a1bc188c736": { + "~#shape": { + "~:y": 1239, + "~:transform": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:rotation": 0, + "~:grow-type": "~:fixed", + "~:hide-in-viewer": false, + "~:name": "Rectangle", + "~:width": 456, + "~:type": "~:rect", + "~:points": [ + { + "~#point": { + "~:x": 1154, + "~:y": 1239 + } + }, + { + "~#point": { + "~:x": 1610, + "~:y": 1239 + } + }, + { + "~#point": { + "~:x": 1610, + "~:y": 1408 + } + }, + { + "~#point": { + "~:x": 1154, + "~:y": 1408 + } + } + ], + "~:r2": 0, + "~:proportion-lock": false, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:page-id": "~ubf963303-bcd6-8000-8007-6a0bc8b60ccb", + "~:r3": 0, + "~:constraints-v": "~:top", + "~:constraints-h": "~:left", + "~:r1": 0, + "~:id": "~ue5199cd7-69e2-8001-8007-6a1bc188c736", + "~:parent-id": "~ue5199cd7-69e2-8001-8007-6a1bc188c735", + "~:frame-id": "~ue5199cd7-69e2-8001-8007-6a1bc188c735", + "~:strokes": [], + "~:x": 1154, + "~:proportion": 1, + "~:r4": 0, + "~:selrect": { + "~#rect": { + "~:x": 1154, + "~:y": 1239, + "~:width": 456, + "~:height": 169, + "~:x1": 1154, + "~:y1": 1239, + "~:x2": 1610, + "~:y2": 1408 + } + }, + "~:fills": [ + { + "~:fill-color": "#B1B2B5", + "~:fill-opacity": 1 + } + ], + "~:flip-x": null, + "~:height": 169, + "~:flip-y": null + } + }, + "~ue5199cd7-69e2-8001-8007-6a1bccaf0d96": { + "~#shape": { + "~:y": 270, + "~:hide-fill-on-export": false, + "~:transform": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:rotation": 0, + "~:grow-type": "~:fixed", + "~:hide-in-viewer": false, + "~:name": "Board", + "~:width": 535, + "~:type": "~:frame", + "~:points": [ + { + "~#point": { + "~:x": 818, + "~:y": 270 + } + }, + { + "~#point": { + "~:x": 1353, + "~:y": 270 + } + }, + { + "~#point": { + "~:x": 1353, + "~:y": 917 + } + }, + { + "~#point": { + "~:x": 818, + "~:y": 917 + } + } + ], + "~:r2": 0, + "~:proportion-lock": false, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:page-id": "~ubf963303-bcd6-8000-8007-6a0bc8b60ccb", + "~:r3": 0, + "~:r1": 0, + "~:id": "~ue5199cd7-69e2-8001-8007-6a1bccaf0d96", + "~:parent-id": "~u00000000-0000-0000-0000-000000000000", + "~:frame-id": "~u00000000-0000-0000-0000-000000000000", + "~:strokes": [], + "~:x": 818, + "~:proportion": 1, + "~:r4": 0, + "~:selrect": { + "~#rect": { + "~:x": 818, + "~:y": 270, + "~:width": 535, + "~:height": 647, + "~:x1": 818, + "~:y1": 270, + "~:x2": 1353, + "~:y2": 917 + } + }, + "~:fills": [ + { + "~:fill-color": "#FFFFFF", + "~:fill-opacity": 1 + } + ], + "~:flip-x": null, + "~:height": 647, + "~:flip-y": null, + "~:shapes": [ + "~ue5199cd7-69e2-8001-8007-6a1bccaf0d97" + ] + } + }, + "~ue5199cd7-69e2-8001-8007-6a1beb087a31": { + "~#shape": { + "~:y": 1995.99995803833, + "~:transform": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:rotation": 0, + "~:grow-type": "~:fixed", + "~:hide-in-viewer": false, + "~:name": "Rectangle", + "~:width": 456, + "~:type": "~:rect", + "~:points": [ + { + "~#point": { + "~:x": 569, + "~:y": 1995.99995803833 + } + }, + { + "~#point": { + "~:x": 1025, + "~:y": 1995.99995803833 + } + }, + { + "~#point": { + "~:x": 1025, + "~:y": 2164.99995803833 + } + }, + { + "~#point": { + "~:x": 569, + "~:y": 2164.99995803833 + } + } + ], + "~:r2": 0, + "~:proportion-lock": false, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:page-id": "~ubf963303-bcd6-8000-8007-6a0bc8b60ccb", + "~:r3": 0, + "~:constraints-v": "~:top", + "~:constraints-h": "~:left", + "~:r1": 0, + "~:id": "~ue5199cd7-69e2-8001-8007-6a1beb087a31", + "~:parent-id": "~ue5199cd7-69e2-8001-8007-6a1beb087a30", + "~:frame-id": "~ue5199cd7-69e2-8001-8007-6a1beb087a30", + "~:strokes": [], + "~:x": 569, + "~:proportion": 1, + "~:r4": 0, + "~:selrect": { + "~#rect": { + "~:x": 569, + "~:y": 1995.99995803833, + "~:width": 456, + "~:height": 169, + "~:x1": 569, + "~:y1": 1995.99995803833, + "~:x2": 1025, + "~:y2": 2164.99995803833 + } + }, + "~:fills": [ + { + "~:fill-color": "#B1B2B5", + "~:fill-opacity": 1 + } + ], + "~:flip-x": null, + "~:height": 169, + "~:flip-y": null + } + }, + "~ue5199cd7-69e2-8001-8007-6a1bc188c731": { + "~#shape": { + "~:y": 1003, + "~:hide-fill-on-export": false, + "~:transform": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:rotation": 0, + "~:grow-type": "~:fixed", + "~:hide-in-viewer": false, + "~:name": "Board", + "~:width": 535, + "~:type": "~:frame", + "~:points": [ + { + "~#point": { + "~:x": 233, + "~:y": 1003 + } + }, + { + "~#point": { + "~:x": 768, + "~:y": 1003 + } + }, + { + "~#point": { + "~:x": 768, + "~:y": 1650 + } + }, + { + "~#point": { + "~:x": 233, + "~:y": 1650 + } + } + ], + "~:r2": 0, + "~:proportion-lock": false, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:page-id": "~ubf963303-bcd6-8000-8007-6a0bc8b60ccb", + "~:r3": 0, + "~:blur": { + "~:id": "~u1a629c22-3d11-80b1-8007-2b2c031523df", + "~:type": "~:layer-blur", + "~:value": 4, + "~:hidden": false + }, + "~:r1": 0, + "~:id": "~ue5199cd7-69e2-8001-8007-6a1bc188c731", + "~:parent-id": "~u00000000-0000-0000-0000-000000000000", + "~:frame-id": "~u00000000-0000-0000-0000-000000000000", + "~:strokes": [], + "~:x": 233, + "~:proportion": 1, + "~:r4": 0, + "~:selrect": { + "~#rect": { + "~:x": 233, + "~:y": 1003, + "~:width": 535, + "~:height": 647, + "~:x1": 233, + "~:y1": 1003, + "~:x2": 768, + "~:y2": 1650 + } + }, + "~:fills": [ + { + "~:fill-color": "#FFFFFF", + "~:fill-opacity": 1 + } + ], + "~:flip-x": null, + "~:height": 647, + "~:flip-y": null, + "~:shapes": [ + "~ue5199cd7-69e2-8001-8007-6a1bc188c732" + ] + } + }, + "~ue5199cd7-69e2-8001-8007-6a1beb087a30": { + "~#shape": { + "~:y": 1864.99995803833, + "~:hide-fill-on-export": false, + "~:transform": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:rotation": 0, + "~:grow-type": "~:fixed", + "~:hide-in-viewer": true, + "~:name": "Board", + "~:width": 518, + "~:type": "~:frame", + "~:points": [ + { + "~#point": { + "~:x": 421, + "~:y": 1864.99995803833 + } + }, + { + "~#point": { + "~:x": 939, + "~:y": 1864.99995803833 + } + }, + { + "~#point": { + "~:x": 939, + "~:y": 2288.99995803833 + } + }, + { + "~#point": { + "~:x": 421, + "~:y": 2288.99995803833 + } + } + ], + "~:r2": 0, + "~:proportion-lock": false, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:page-id": "~ubf963303-bcd6-8000-8007-6a0bc8b60ccb", + "~:r3": 0, + "~:constraints-v": "~:top", + "~:constraints-h": "~:left", + "~:r1": 0, + "~:id": "~ue5199cd7-69e2-8001-8007-6a1beb087a30", + "~:parent-id": "~ue5199cd7-69e2-8001-8007-6a1beb087a2f", + "~:frame-id": "~ue5199cd7-69e2-8001-8007-6a1beb087a2f", + "~:strokes": [ + { + "~:stroke-alignment": "~:inner", + "~:stroke-style": "~:solid", + "~:stroke-color": "#000000", + "~:stroke-opacity": 1, + "~:stroke-width": 10 + } + ], + "~:x": 421, + "~:proportion": 1, + "~:r4": 0, + "~:selrect": { + "~#rect": { + "~:x": 421, + "~:y": 1864.99995803833, + "~:width": 518, + "~:height": 424, + "~:x1": 421, + "~:y1": 1864.99995803833, + "~:x2": 939, + "~:y2": 2288.99995803833 + } + }, + "~:fills": [ + { + "~:fill-color": "#dc0606", + "~:fill-opacity": 1 + } + ], + "~:flip-x": null, + "~:height": 424, + "~:flip-y": null, + "~:shapes": [ + "~ue5199cd7-69e2-8001-8007-6a1beb087a31" + ] + } + }, + "~ue5199cd7-69e2-8001-8007-6a1bc188c730": { + "~#shape": { + "~:y": 506, + "~:transform": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:rotation": 0, + "~:grow-type": "~:fixed", + "~:hide-in-viewer": false, + "~:name": "Rectangle", + "~:width": 456, + "~:type": "~:rect", + "~:points": [ + { + "~#point": { + "~:x": 569, + "~:y": 506 + } + }, + { + "~#point": { + "~:x": 1025, + "~:y": 506 + } + }, + { + "~#point": { + "~:x": 1025, + "~:y": 675 + } + }, + { + "~#point": { + "~:x": 569, + "~:y": 675 + } + } + ], + "~:r2": 0, + "~:proportion-lock": false, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:page-id": "~ubf963303-bcd6-8000-8007-6a0bc8b60ccb", + "~:r3": 0, + "~:constraints-v": "~:top", + "~:constraints-h": "~:left", + "~:r1": 0, + "~:id": "~ue5199cd7-69e2-8001-8007-6a1bc188c730", + "~:parent-id": "~ue5199cd7-69e2-8001-8007-6a1bc188c72f", + "~:frame-id": "~ue5199cd7-69e2-8001-8007-6a1bc188c72f", + "~:strokes": [], + "~:x": 569, + "~:proportion": 1, + "~:r4": 0, + "~:selrect": { + "~#rect": { + "~:x": 569, + "~:y": 506, + "~:width": 456, + "~:height": 169, + "~:x1": 569, + "~:y1": 506, + "~:x2": 1025, + "~:y2": 675 + } + }, + "~:fills": [ + { + "~:fill-color": "#B1B2B5", + "~:fill-opacity": 1 + } + ], + "~:flip-x": null, + "~:height": 169, + "~:flip-y": null + } + }, + "~ue5199cd7-69e2-8001-8007-6a1bc188c733": { + "~#shape": { + "~:y": 1239, + "~:transform": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:rotation": 0, + "~:grow-type": "~:fixed", + "~:hide-in-viewer": false, + "~:name": "Rectangle", + "~:width": 456, + "~:type": "~:rect", + "~:points": [ + { + "~#point": { + "~:x": 569, + "~:y": 1239 + } + }, + { + "~#point": { + "~:x": 1025, + "~:y": 1239 + } + }, + { + "~#point": { + "~:x": 1025, + "~:y": 1408 + } + }, + { + "~#point": { + "~:x": 569, + "~:y": 1408 + } + } + ], + "~:r2": 0, + "~:proportion-lock": false, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:page-id": "~ubf963303-bcd6-8000-8007-6a0bc8b60ccb", + "~:r3": 0, + "~:constraints-v": "~:top", + "~:constraints-h": "~:left", + "~:r1": 0, + "~:id": "~ue5199cd7-69e2-8001-8007-6a1bc188c733", + "~:parent-id": "~ue5199cd7-69e2-8001-8007-6a1bc188c732", + "~:frame-id": "~ue5199cd7-69e2-8001-8007-6a1bc188c732", + "~:strokes": [], + "~:x": 569, + "~:proportion": 1, + "~:r4": 0, + "~:selrect": { + "~#rect": { + "~:x": 569, + "~:y": 1239, + "~:width": 456, + "~:height": 169, + "~:x1": 569, + "~:y1": 1239, + "~:x2": 1025, + "~:y2": 1408 + } + }, + "~:fills": [ + { + "~:fill-color": "#B1B2B5", + "~:fill-opacity": 1 + } + ], + "~:flip-x": null, + "~:height": 169, + "~:flip-y": null + } + }, + "~ue5199cd7-69e2-8001-8007-6a1bc188c732": { + "~#shape": { + "~:y": 1108, + "~:hide-fill-on-export": false, + "~:transform": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:rotation": 0, + "~:grow-type": "~:fixed", + "~:hide-in-viewer": true, + "~:name": "Board", + "~:width": 518, + "~:type": "~:frame", + "~:points": [ + { + "~#point": { + "~:x": 421, + "~:y": 1108 + } + }, + { + "~#point": { + "~:x": 939, + "~:y": 1108 + } + }, + { + "~#point": { + "~:x": 939, + "~:y": 1532 + } + }, + { + "~#point": { + "~:x": 421, + "~:y": 1532 + } + } + ], + "~:r2": 0, + "~:proportion-lock": false, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:page-id": "~ubf963303-bcd6-8000-8007-6a0bc8b60ccb", + "~:r3": 0, + "~:constraints-v": "~:top", + "~:constraints-h": "~:left", + "~:r1": 0, + "~:id": "~ue5199cd7-69e2-8001-8007-6a1bc188c732", + "~:parent-id": "~ue5199cd7-69e2-8001-8007-6a1bc188c731", + "~:frame-id": "~ue5199cd7-69e2-8001-8007-6a1bc188c731", + "~:strokes": [], + "~:x": 421, + "~:proportion": 1, + "~:r4": 0, + "~:selrect": { + "~#rect": { + "~:x": 421, + "~:y": 1108, + "~:width": 518, + "~:height": 424, + "~:x1": 421, + "~:y1": 1108, + "~:x2": 939, + "~:y2": 1532 + } + }, + "~:fills": [ + { + "~:fill-color": "#dc0606", + "~:fill-opacity": 1 + } + ], + "~:flip-x": null, + "~:height": 424, + "~:flip-y": null, + "~:shapes": [ + "~ue5199cd7-69e2-8001-8007-6a1bc188c733" + ] + } + } } - }, - "~:id": "~u44471494-966a-8178-8006-c5bd93f0fe72", - "~:options": { - "~:components-v2": true, - "~:base-font-size": "16px" } + }, + "~:id": "~u44471494-966a-8178-8006-c5bd93f0fe72", + "~:options": { + "~:components-v2": true, + "~:base-font-size": "16px" } - } \ No newline at end of file + } +} \ No newline at end of file diff --git a/frontend/playwright/ui/render-wasm-specs/shapes.spec.js-snapshots/Renders-a-file-with-nested-clipping-frames-1.png b/frontend/playwright/ui/render-wasm-specs/shapes.spec.js-snapshots/Renders-a-file-with-nested-clipping-frames-1.png index c0821c53f89ed5d53af8821c07e90d2c5088ed1d..67c2af4f41d0a174b943f55b4ee4ab5e759c35fd 100644 GIT binary patch literal 25965 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, + ) { if visited_mask { // Because masked groups needs two rendering passes (first drawing // the content and then drawing the mask), we need to do an @@ -1268,7 +1273,7 @@ impl RenderState { element_strokes.to_mut().clip_content = false; self.render_shape( &element_strokes, - None, + clip_bounds, SurfaceId::Fills, SurfaceId::Strokes, SurfaceId::InnerShadows, @@ -1528,7 +1533,7 @@ impl RenderState { if visited_children { // Skip render_shape_exit for flattened containers if !element.can_flatten() { - self.render_shape_exit(element, visited_mask); + self.render_shape_exit(element, visited_mask, clip_bounds); } continue; } From a3119bef5e38984071287f03881d98c4123c41b0 Mon Sep 17 00:00:00 2001 From: Elena Torro Date: Fri, 9 Jan 2026 12:56:01 +0100 Subject: [PATCH 11/20] :wrench: Show message and button to reload the page when WebGL context is lost --- frontend/src/app/main/ui/static.cljs | 14 ++++++++++++++ frontend/src/app/render_wasm/api.cljs | 5 +++-- frontend/translations/en.po | 9 +++++++++ frontend/translations/es.po | 9 +++++++++ 4 files changed, 35 insertions(+), 2 deletions(-) diff --git a/frontend/src/app/main/ui/static.cljs b/frontend/src/app/main/ui/static.cljs index 1140ee319b..96ec92fd8b 100644 --- a/frontend/src/app/main/ui/static.cljs +++ b/frontend/src/app/main/ui/static.cljs @@ -308,6 +308,16 @@ [:div {:class (stl/css :sign-info)} [:button {:on-click on-click} (tr "labels.retry")]]])) +(mf/defc webgl-context-lost* + [] + (let [on-reload (mf/use-fn #(js/location.reload))] + [:> error-container* {} + [:div {:class (stl/css :main-message)} (tr "labels.webgl-context-lost.main-message")] + [:div {:class (stl/css :desc-message)} (tr "labels.webgl-context-lost.desc-message")] + [:div {:class (stl/css :buttons-container)} + [:> button* {:variant "primary" :on-click on-reload} + (tr "labels.reload-page")]]])) + (defn- generate-report [data] (try @@ -437,6 +447,7 @@ (rx/of default) (rx/throw cause))))))) + (mf/defc exception-section* {::mf/private true} [{:keys [data route] :as props}] @@ -469,6 +480,9 @@ :service-unavailable [:> service-unavailable*] + :webgl-context-lost + [:> webgl-context-lost*] + [:> internal-error* props]))) (mf/defc context-wrapper* diff --git a/frontend/src/app/render_wasm/api.cljs b/frontend/src/app/render_wasm/api.cljs index f2e1d133a4..3059462cd6 100644 --- a/frontend/src/app/render_wasm/api.cljs +++ b/frontend/src/app/render_wasm/api.cljs @@ -10,6 +10,7 @@ ["react-dom/server" :as rds] [app.common.data :as d] [app.common.data.macros :as dm] + [app.common.exceptions :as ex] [app.common.files.helpers :as cfh] [app.common.logging :as log] [app.common.math :as mth] @@ -21,7 +22,6 @@ [app.common.types.text :as txt] [app.common.uuid :as uuid] [app.config :as cf] - [app.main.data.render-wasm :as drw] [app.main.refs :as refs] [app.main.render :as render] [app.main.store :as st] @@ -1236,7 +1236,8 @@ (dom/prevent-default event) (reset! wasm/context-lost? true) (log/warn :hint "WebGL context lost") - (st/emit! (drw/context-lost))) + (ex/raise :type :webgl-context-lost + :hint "WebGL context lost")) (defn init-canvas-context [canvas] diff --git a/frontend/translations/en.po b/frontend/translations/en.po index fedd5e7e82..8524ffd135 100644 --- a/frontend/translations/en.po +++ b/frontend/translations/en.po @@ -2521,6 +2521,9 @@ msgstr "Release notes" msgid "labels.reload-file" msgstr "Reload file" +msgid "labels.reload-page" +msgstr "Reload page" + #: src/app/main/ui/workspace/libraries.cljs, src/app/main/ui/dashboard/team.cljs #, unused msgid "labels.remove" @@ -8475,6 +8478,12 @@ msgstr "Recent" msgid "labels.deleted" msgstr "Deleted" +msgid "labels.webgl-context-lost.main-message" +msgstr "Oops! The canvas context was lost" + +msgid "labels.webgl-context-lost.desc-message" +msgstr "WebGL has stopped working. Please reload the page to reset it" + msgid "dashboard.restore-all-deleted-button" msgstr "Restore All" diff --git a/frontend/translations/es.po b/frontend/translations/es.po index 2101493c92..ff4edb86de 100644 --- a/frontend/translations/es.po +++ b/frontend/translations/es.po @@ -2502,6 +2502,9 @@ msgstr "Notas de versión" msgid "labels.reload-file" msgstr "Recargar archivo" +msgid "labels.reload-page" +msgstr "Recargar página" + #: src/app/main/ui/workspace/libraries.cljs, src/app/main/ui/dashboard/team.cljs #, unused msgid "labels.remove" @@ -8328,6 +8331,12 @@ msgstr "Recientes" msgid "labels.deleted" msgstr "Eliminados" +msgid "labels.webgl-context-lost.main-message" +msgstr "Ups! Se ha perdido el contexto del canvas" + +msgid "labels.webgl-context-lost.desc-message" +msgstr "WebGL ha dejado de funcionar. Por favor, recarga la página para restaurarlo" + msgid "dashboard.restore-all-deleted-button" msgstr "Restaurar todo" From 84f750da0d502f6ba1ec4f236889ee268d885ff3 Mon Sep 17 00:00:00 2001 From: Alejandro Alonso Date: Thu, 15 Jan 2026 08:45:21 +0100 Subject: [PATCH 12/20] :tada: Skip heavy effects in fast mode Avoid blur and shadow passes for text and shapes when FAST_MODE is enabled. --- render-wasm/src/render.rs | 237 ++++++++++++++++++++++---------------- 1 file changed, 137 insertions(+), 100 deletions(-) diff --git a/render-wasm/src/render.rs b/render-wasm/src/render.rs index b3f77d54fb..381553b5c2 100644 --- a/render-wasm/src/render.rs +++ b/render-wasm/src/render.rs @@ -658,6 +658,7 @@ impl RenderState { } let antialias = shape.should_use_antialias(self.get_scale()); + let fast_mode = self.options.is_fast_mode(); // set clipping if let Some(clips) = clip_bounds.as_ref() { @@ -714,6 +715,9 @@ impl RenderState { } else if shape_has_blur { shape.to_mut().set_blur(None); } + if fast_mode { + shape.to_mut().set_blur(None); + } let center = shape.center(); let mut matrix = shape.transform; @@ -758,18 +762,8 @@ impl RenderState { }); let text_content = text_content.new_bounds(shape.selrect()); - let mut drop_shadows = shape.drop_shadow_paints(); - - if let Some(inherited_shadows) = self.get_inherited_drop_shadows() { - drop_shadows.extend(inherited_shadows); - } - - let inner_shadows = shape.inner_shadow_paints(); - let blur_filter = shape.image_filter(1.); let count_inner_strokes = shape.count_visible_inner_strokes(); let mut paragraph_builders = text_content.paragraph_builder_group_from_text(None); - let mut paragraphs_with_shadows = - text_content.paragraph_builder_group_from_text(Some(true)); let mut stroke_paragraphs_list = shape .visible_strokes() .rev() @@ -783,62 +777,8 @@ impl RenderState { ) }) .collect::>(); - - let mut stroke_paragraphs_with_shadows_list = shape - .visible_strokes() - .rev() - .map(|stroke| { - text::stroke_paragraph_builder_group_from_text( - &text_content, - stroke, - &shape.selrect(), - count_inner_strokes, - Some(true), - ) - }) - .collect::>(); - - if let Some(parent_shadows) = parent_shadows { - if !shape.has_visible_strokes() { - for shadow in parent_shadows { - text::render( - Some(self), - None, - &shape, - &mut paragraphs_with_shadows, - text_drop_shadows_surface_id.into(), - Some(&shadow), - blur_filter.as_ref(), - ); - } - } else { - shadows::render_text_shadows( - self, - &shape, - &mut paragraphs_with_shadows, - &mut stroke_paragraphs_with_shadows_list, - text_drop_shadows_surface_id.into(), - &parent_shadows, - &blur_filter, - ); - } - } else { - // 1. Text drop shadows - if !shape.has_visible_strokes() { - for shadow in &drop_shadows { - text::render( - Some(self), - None, - &shape, - &mut paragraphs_with_shadows, - text_drop_shadows_surface_id.into(), - Some(shadow), - blur_filter.as_ref(), - ); - } - } - - // 2. Text fills + if fast_mode { + // Fast path: render fills and strokes only (skip shadows/blur). text::render( Some(self), None, @@ -846,21 +786,9 @@ impl RenderState { &mut paragraph_builders, Some(fills_surface_id), None, - blur_filter.as_ref(), + None, ); - // 3. Stroke drop shadows - shadows::render_text_shadows( - self, - &shape, - &mut paragraphs_with_shadows, - &mut stroke_paragraphs_with_shadows_list, - text_drop_shadows_surface_id.into(), - &drop_shadows, - &blur_filter, - ); - - // 4. Stroke fills for stroke_paragraphs in stroke_paragraphs_list.iter_mut() { text::render( Some(self), @@ -869,34 +797,134 @@ impl RenderState { stroke_paragraphs, Some(strokes_surface_id), None, - blur_filter.as_ref(), + None, ); } + } else { + let mut drop_shadows = shape.drop_shadow_paints(); - // 5. Stroke inner shadows - shadows::render_text_shadows( - self, - &shape, - &mut paragraphs_with_shadows, - &mut stroke_paragraphs_with_shadows_list, - Some(innershadows_surface_id), - &inner_shadows, - &blur_filter, - ); + if let Some(inherited_shadows) = self.get_inherited_drop_shadows() { + drop_shadows.extend(inherited_shadows); + } - // 6. Fill Inner shadows - if !shape.has_visible_strokes() { - for shadow in &inner_shadows { + let inner_shadows = shape.inner_shadow_paints(); + let blur_filter = shape.image_filter(1.); + let mut paragraphs_with_shadows = + text_content.paragraph_builder_group_from_text(Some(true)); + let mut stroke_paragraphs_with_shadows_list = shape + .visible_strokes() + .rev() + .map(|stroke| { + text::stroke_paragraph_builder_group_from_text( + &text_content, + stroke, + &shape.selrect(), + count_inner_strokes, + Some(true), + ) + }) + .collect::>(); + + if let Some(parent_shadows) = parent_shadows { + if !shape.has_visible_strokes() { + for shadow in parent_shadows { + text::render( + Some(self), + None, + &shape, + &mut paragraphs_with_shadows, + text_drop_shadows_surface_id.into(), + Some(&shadow), + blur_filter.as_ref(), + ); + } + } else { + shadows::render_text_shadows( + self, + &shape, + &mut paragraphs_with_shadows, + &mut stroke_paragraphs_with_shadows_list, + text_drop_shadows_surface_id.into(), + &parent_shadows, + &blur_filter, + ); + } + } else { + // 1. Text drop shadows + if !shape.has_visible_strokes() { + for shadow in &drop_shadows { + text::render( + Some(self), + None, + &shape, + &mut paragraphs_with_shadows, + text_drop_shadows_surface_id.into(), + Some(shadow), + blur_filter.as_ref(), + ); + } + } + + // 2. Text fills + text::render( + Some(self), + None, + &shape, + &mut paragraph_builders, + Some(fills_surface_id), + None, + blur_filter.as_ref(), + ); + + // 3. Stroke drop shadows + shadows::render_text_shadows( + self, + &shape, + &mut paragraphs_with_shadows, + &mut stroke_paragraphs_with_shadows_list, + text_drop_shadows_surface_id.into(), + &drop_shadows, + &blur_filter, + ); + + // 4. Stroke fills + for stroke_paragraphs in stroke_paragraphs_list.iter_mut() { text::render( Some(self), None, &shape, - &mut paragraphs_with_shadows, - Some(innershadows_surface_id), - Some(shadow), + stroke_paragraphs, + Some(strokes_surface_id), + None, blur_filter.as_ref(), ); } + + // 5. Stroke inner shadows + shadows::render_text_shadows( + self, + &shape, + &mut paragraphs_with_shadows, + &mut stroke_paragraphs_with_shadows_list, + Some(innershadows_surface_id), + &inner_shadows, + &blur_filter, + ); + + // 6. Fill Inner shadows + if !shape.has_visible_strokes() { + for shadow in &inner_shadows { + text::render( + Some(self), + None, + &shape, + &mut paragraphs_with_shadows, + Some(innershadows_surface_id), + Some(shadow), + blur_filter.as_ref(), + ); + } + } } } } @@ -936,16 +964,25 @@ impl RenderState { None, antialias, ); - shadows::render_stroke_inner_shadows( + if !fast_mode { + shadows::render_stroke_inner_shadows( + self, + shape, + stroke, + antialias, + innershadows_surface_id, + ); + } + } + + if !fast_mode { + shadows::render_fill_inner_shadows( self, shape, - stroke, antialias, innershadows_surface_id, ); } - - shadows::render_fill_inner_shadows(self, shape, antialias, innershadows_surface_id); // bools::debug_render_bool_paths(self, shape, shapes, modifiers, structure); } }; From afc914f486cf063d24d8bcd6a188b824f35f41a2 Mon Sep 17 00:00:00 2001 From: Alejandro Alonso Date: Thu, 15 Jan 2026 12:47:15 +0100 Subject: [PATCH 13/20] :tada: Render simple shapes directly on Current Bypass intermediate surfaces for simple shapes without effects. --- render-wasm/src/render.rs | 70 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 70 insertions(+) diff --git a/render-wasm/src/render.rs b/render-wasm/src/render.rs index 381553b5c2..a361ffd7c6 100644 --- a/render-wasm/src/render.rs +++ b/render-wasm/src/render.rs @@ -659,6 +659,76 @@ impl RenderState { let antialias = shape.should_use_antialias(self.get_scale()); let fast_mode = self.options.is_fast_mode(); + let has_nested_fills = self + .nested_fills + .last() + .is_some_and(|fills| !fills.is_empty()); + let has_inherited_blur = !self.ignore_nested_blurs + && self.nested_blurs.iter().flatten().any(|blur| { + !blur.hidden && blur.blur_type == BlurType::LayerBlur && blur.value > 0.0 + }); + let can_render_directly = apply_to_current_surface + && clip_bounds.is_none() + && offset.is_none() + && parent_shadows.is_none() + && !shape.needs_layer() + && shape.blur.is_none() + && !has_inherited_blur + && shape.shadows.is_empty() + && shape.transform.is_identity() + && matches!( + shape.shape_type, + Type::Rect(_) | Type::Circle | Type::Path(_) | Type::Bool(_) + ) + && !(shape.fills.is_empty() && has_nested_fills) + && !shape + .svg_attrs + .as_ref() + .is_some_and(|attrs| attrs.fill_none); + + if can_render_directly { + let scale = self.get_scale(); + let translation = self + .surfaces + .get_render_context_translation(self.render_area, scale); + self.surfaces.apply_mut(SurfaceId::Current as u32, |s| { + let canvas = s.canvas(); + canvas.save(); + canvas.scale((scale, scale)); + canvas.translate(translation); + }); + + for fill in shape.fills().rev() { + fills::render(self, shape, fill, antialias, SurfaceId::Current); + } + + for stroke in shape.visible_strokes().rev() { + strokes::render( + self, + shape, + stroke, + Some(SurfaceId::Current), + None, + antialias, + ); + } + + self.surfaces.apply_mut(SurfaceId::Current as u32, |s| { + s.canvas().restore(); + }); + + if self.options.is_debug_visible() { + let shape_selrect_bounds = self.get_shape_selrect_bounds(shape); + debug::render_debug_shape(self, Some(shape_selrect_bounds), None); + } + + if needs_save { + self.surfaces.apply_mut(surface_ids, |s| { + s.canvas().restore(); + }); + } + return; + } // set clipping if let Some(clips) = clip_bounds.as_ref() { From 311e124658081bd8f7c73ded0557b941a1e7bfc7 Mon Sep 17 00:00:00 2001 From: Alejandro Alonso Date: Thu, 15 Jan 2026 08:46:55 +0100 Subject: [PATCH 14/20] :tada: Reduce extrect work in tile traversal Avoid repeated extrect calculations and simplify root ordering per tile. --- render-wasm/src/render.rs | 103 +++++++++++--------------------------- 1 file changed, 30 insertions(+), 73 deletions(-) diff --git a/render-wasm/src/render.rs b/render-wasm/src/render.rs index a361ffd7c6..1d80780bb1 100644 --- a/render-wasm/src/render.rs +++ b/render-wasm/src/render.rs @@ -294,10 +294,6 @@ pub(crate) struct RenderState { /// where we must render shapes without inheriting ancestor layer blurs. Toggle it through /// `with_nested_blurs_suppressed` to ensure it's always restored. pub ignore_nested_blurs: bool, - /// Cached root_ids and root_ids_map to avoid recalculating them every frame - /// These are invalidated when the tree structure changes - cached_root_ids: Option>, - cached_root_ids_map: Option>, } pub fn get_cache_size(viewbox: Viewbox, scale: f32) -> skia::ISize { @@ -370,8 +366,6 @@ impl RenderState { focus_mode: FocusMode::new(), touched_ids: HashSet::default(), ignore_nested_blurs: false, - cached_root_ids: None, - cached_root_ids_map: None, } } @@ -1631,6 +1625,8 @@ impl RenderState { "Error: Element with root_id {} not found in the tree.", node_render_state.id ))?; + let scale = self.get_scale(); + let mut extrect: Option = None; // If the shape is not in the tile set, then we add them. if self.tiles.get_tiles_of(node_id).is_none() { @@ -1661,29 +1657,21 @@ impl RenderState { crate::shapes::Type::Frame(_) | crate::shapes::Type::Group(_) ); - let scale = self.get_scale(); let has_effects = transformed_element.has_effects_that_extend_bounds(); - let is_visible = if is_container { - // Containers (frames/groups) must always use extrect to include nested content - let extrect = transformed_element.extrect(tree, scale); - extrect.intersects(self.render_area) - && !transformed_element.visually_insignificant(scale, tree) - } else if !has_effects { - // Simple shape: selrect check is sufficient, skip expensive extrect - let selrect = transformed_element.selrect(); - selrect.intersects(self.render_area) + let is_visible = if is_container || has_effects { + let element_extrect = + extrect.get_or_insert_with(|| transformed_element.extrect(tree, scale)); + element_extrect.intersects(self.render_area) && !transformed_element.visually_insignificant(scale, tree) } else { - // Shape with effects: need extrect for accurate bounds - let extrect = transformed_element.extrect(tree, scale); - extrect.intersects(self.render_area) + let selrect = transformed_element.selrect(); + selrect.intersects(self.render_area) && !transformed_element.visually_insignificant(scale, tree) }; if self.options.is_debug_visible() { - let shape_extrect_bounds = - self.get_shape_extrect_bounds(&transformed_element, tree); + let shape_extrect_bounds = self.get_shape_extrect_bounds(element, tree); debug::render_debug_shape(self, None, Some(shape_extrect_bounds)); } @@ -1700,7 +1688,6 @@ impl RenderState { } if !node_render_state.is_root() && self.focus_mode.is_active() { - let scale: f32 = self.get_scale(); let translation = self .surfaces .get_render_context_translation(self.render_area, scale); @@ -1731,8 +1718,6 @@ impl RenderState { _ => None, }; - let element_extrect = element.extrect(tree, scale); - for shadow in element.drop_shadows_visible() { let paint = skia::Paint::default(); let layer_rec = skia::canvas::SaveLayerRec::default().paint(&paint); @@ -1742,9 +1727,11 @@ impl RenderState { .save_layer(&layer_rec); // First pass: Render shadow in black to establish alpha mask + let element_extrect = + extrect.get_or_insert_with(|| element.extrect(tree, scale)); self.render_drop_black_shadow( element, - &element_extrect, + element_extrect, shadow, clip_bounds.clone(), scale, @@ -1759,15 +1746,13 @@ impl RenderState { if shadow_shape.hidden { continue; } - let clip_bounds = node_render_state .get_nested_shadow_clip_bounds(element, shadow); if !matches!(shadow_shape.shape_type, Type::Text(_)) { - let shadow_extrect = shadow_shape.extrect(tree, scale); self.render_drop_black_shadow( shadow_shape, - &shadow_extrect, + &shadow_shape.extrect(tree, scale), shadow, clip_bounds, scale, @@ -1981,6 +1966,16 @@ impl RenderState { allow_stop: bool, ) -> Result<(), String> { let mut should_stop = false; + let root_ids = { + if let Some(shape_id) = base_object { + vec![*shape_id] + } else { + let Some(root) = tree.get(&Uuid::nil()) else { + return Err(String::from("Root shape not found")); + }; + root.children_ids(false) + } + }; while !should_stop { if let Some(current_tile) = self.current_tile { @@ -2037,42 +2032,6 @@ impl RenderState { .canvas(SurfaceId::Current) .clear(self.background_color); - // Get or compute root_ids and root_ids_map (cached to avoid recalculation every frame) - let root_ids_map = { - let root_ids = if let Some(shape_id) = base_object { - vec![*shape_id] - } else { - let Some(root) = tree.get(&Uuid::nil()) else { - return Err(String::from("Root shape not found")); - }; - root.children_ids(false) - }; - - // Check if cache is valid (same root_ids) - let cache_valid = self - .cached_root_ids - .as_ref() - .map(|cached| cached.as_slice() == root_ids.as_slice()) - .unwrap_or(false); - - if cache_valid { - // Use cached map - self.cached_root_ids_map.as_ref().unwrap().clone() - } else { - // Recompute and cache - let root_ids_map: std::collections::HashMap = root_ids - .iter() - .enumerate() - .map(|(i, id)| (*id, i)) - .collect(); - - self.cached_root_ids = Some(root_ids.clone()); - self.cached_root_ids_map = Some(root_ids_map.clone()); - - root_ids_map - } - }; - // If we finish processing every node rendering is complete // let's check if there are more pending nodes if let Some(next_tile) = self.pending_tiles.pop() { @@ -2080,15 +2039,13 @@ impl RenderState { if !self.surfaces.has_cached_tile_surface(next_tile) { if let Some(ids) = self.tiles.get_shapes_at(next_tile) { - // We only need first level shapes - let mut valid_ids: Vec = ids - .iter() - .filter(|id| root_ids_map.contains_key(id)) - .copied() - .collect(); - - // These shapes for the tile should be ordered as they are in the parent node - valid_ids.sort_by_key(|id| root_ids_map.get(id).unwrap_or(&usize::MAX)); + // We only need first level shapes, in the same order as the parent node + let mut valid_ids = Vec::with_capacity(ids.len()); + for root_id in root_ids.iter() { + if ids.contains(root_id) { + valid_ids.push(*root_id); + } + } self.pending_nodes.extend(valid_ids.into_iter().map(|id| { NodeRenderState { From c411aefc6c15342807c3e3f2bc43a3550369def4 Mon Sep 17 00:00:00 2001 From: Alejandro Alonso Date: Thu, 15 Jan 2026 09:22:23 +0100 Subject: [PATCH 15/20] :bug: Fix rotated shapes extrect calculation --- render-wasm/src/shapes.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/render-wasm/src/shapes.rs b/render-wasm/src/shapes.rs index e464b27437..59e2c2fc18 100644 --- a/render-wasm/src/shapes.rs +++ b/render-wasm/src/shapes.rs @@ -1512,6 +1512,8 @@ impl Shape { !self.shadows.is_empty() || self.blur.is_some() || !self.strokes.is_empty() + || !self.transform.is_identity() + || !math::is_close_to(self.rotation, 0.0) || matches!(self.shape_type, Type::Group(_) | Type::Frame(_)) } From e33e8a8c3b2f8a236f9d862b491d601f2fd32bc5 Mon Sep 17 00:00:00 2001 From: Elena Torro Date: Wed, 14 Jan 2026 12:15:01 +0100 Subject: [PATCH 16/20] :wrench: Lookup page objects only when value changes --- frontend/src/app/main/refs.cljs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/app/main/refs.cljs b/frontend/src/app/main/refs.cljs index 76d02544cc..3577842a07 100644 --- a/frontend/src/app/main/refs.cljs +++ b/frontend/src/app/main/refs.cljs @@ -305,7 +305,7 @@ (l/derived #(dsh/lookup-shape % page-id shape-id) st/state =)) (def workspace-page-objects - (l/derived dsh/lookup-page-objects st/state)) + (l/derived dsh/lookup-page-objects st/state identical?)) (def workspace-read-only? (l/derived :read-only? workspace-global)) From a8dfd19338ce2972cd07f4efeacd283f7e65d757 Mon Sep 17 00:00:00 2001 From: Elena Torro Date: Wed, 14 Jan 2026 12:20:35 +0100 Subject: [PATCH 17/20] :wrench: Add performance debugging logs --- frontend/src/app/main/data/event.cljs | 74 +++++++++++++++++++++++ frontend/src/app/main/data/workspace.cljs | 6 ++ 2 files changed, 80 insertions(+) diff --git a/frontend/src/app/main/data/event.cljs b/frontend/src/app/main/data/event.cljs index d3b1555e6c..41daafdda6 100644 --- a/frontend/src/app/main/data/event.cljs +++ b/frontend/src/app/main/data/event.cljs @@ -61,6 +61,11 @@ ;; Def micro-benchmark iterations (def micro-benchmark-iterations 1e6) +;; Performance logs +(defonce ^:private longtask-observer* (atom nil)) +(defonce ^:private stall-timer* (atom nil)) +(defonce ^:private current-op* (atom nil)) + ;; --- CONTEXT (defn- collect-context @@ -464,3 +469,72 @@ (defn event [props] (ptk/data-event ::event props)) + +;; --- DEVTOOLS PERF LOGGING + +(defn install-long-task-observer! [] + (when (and (some? (.-PerformanceObserver js/window)) (nil? @longtask-observer*)) + (let [observer (js/PerformanceObserver. + (fn [list _] + (doseq [entry (.getEntries list)] + (let [dur (.-duration entry) + start (.-startTime entry) + attrib (.-attribution entry) + attrib-count (when attrib (.-length attrib)) + first-attrib (when (and attrib-count (> attrib-count 0)) (aget attrib 0)) + attrib-name (when first-attrib (.-name first-attrib)) + attrib-ctype (when first-attrib (.-containerType first-attrib)) + attrib-cid (when first-attrib (.-containerId first-attrib)) + attrib-csrc (when first-attrib (.-containerSrc first-attrib))] + + (.warn js/console (str "[perf] long task " (Math/round dur) "ms at " (Math/round start) "ms" + (when first-attrib + (str " attrib:name=" attrib-name + " ctype=" attrib-ctype + " cid=" attrib-cid + " csrc=" attrib-csrc))))))))] + (.observe observer #js{:entryTypes #js["longtask"]}) + (reset! longtask-observer* observer)))) + +(defn start-event-loop-stall-logger! + "Log event loop stalls by measuring setInterval drift. + interval-ms: base interval + threshold-ms: drift over which we report" + [interval-ms threshold-ms] + (when (nil? @stall-timer*) + (let [last (atom (.now js/performance)) + id (js/setInterval + (fn [] + (let [now (.now js/performance) + expected (+ @last interval-ms) + drift (- now expected) + current-op @current-op* + measures (.getEntriesByType js/performance "measure") + mlen (.-length measures) + last-measure (when (> mlen 0) (aget measures (dec mlen))) + meas-name (when last-measure (.-name last-measure)) + meas-detail (when last-measure (.-detail last-measure)) + meas-count (when meas-detail (unchecked-get meas-detail "count"))] + (reset! last now) + (when (> drift threshold-ms) + (.warn js/console + (str "[perf] event loop stall: " (Math/round drift) "ms" + (when current-op (str " op=" current-op)) + (when meas-name (str " last=" meas-name)) + (when meas-count (str " count=" meas-count))))))) + interval-ms)] + (reset! stall-timer* id)))) + +(defn init! + "Install perf observers in dev builds. Safe to call multiple times." + [] + (when ^boolean js/goog.DEBUG + (install-long-task-observer!) + (start-event-loop-stall-logger! 50 100) + ;; Expose simple API on window for manual control in devtools + (let [api #js {:reset (fn [] + (try + (.clearMarks js/performance) + (.clearMeasures js/performance) + (catch :default _ nil)))}] + (aset js/window "PenpotPerf" api)))) diff --git a/frontend/src/app/main/data/workspace.cljs b/frontend/src/app/main/data/workspace.cljs index 6e71bcf2a6..4d71f1ec46 100644 --- a/frontend/src/app/main/data/workspace.cljs +++ b/frontend/src/app/main/data/workspace.cljs @@ -347,6 +347,12 @@ (with-meta {:team-id team-id :file-id file-id})))))) + ;; Install dev perf observers once the workspace is ready + (->> stream + (rx/filter (ptk/type? ::workspace-initialized)) + (rx/take 1) + (rx/map (fn [_] (ev/init!)))) + (->> stream (rx/filter (ptk/type? ::dps/persistence-notification)) (rx/take 1) From 34737ddfc9ea3a6e2606e8b0070337548053b493 Mon Sep 17 00:00:00 2001 From: Elena Torro Date: Wed, 14 Jan 2026 12:26:23 +0100 Subject: [PATCH 18/20] :wrench: Always lookup over a set --- common/src/app/common/files/helpers.cljc | 27 ++++++++++++++---------- 1 file changed, 16 insertions(+), 11 deletions(-) diff --git a/common/src/app/common/files/helpers.cljc b/common/src/app/common/files/helpers.cljc index 2e6b9f688e..f669d34aac 100644 --- a/common/src/app/common/files/helpers.cljc +++ b/common/src/app/common/files/helpers.cljc @@ -526,20 +526,25 @@ ids)) (defn clean-loops - "Clean a list of ids from circular references." + "Clean a list of ids from circular references. Optimized fast-path for single selections." [objects ids] - (let [parent-selected? - (fn [id] - (let [parents (get-parent-ids objects id)] - (some ids parents))) + (if (<= (count ids) 1) + ;; For single selection, there can't be circularity; return as ordered-set. + (into (d/ordered-set) ids) + (let [ids-set (if (set? ids) ids (set ids)) + parent-selected? + (fn [id] + ;; Stop early as soon as we find any selected parent + (let [parents (get-parent-ids objects id)] + (some #(contains? ids-set %) parents))) - add-element - (fn [result id] - (cond-> result - (not (parent-selected? id)) - (conj id)))] + add-element + (fn [result id] + (cond-> result + (not (parent-selected? id)) + (conj id)))] - (reduce add-element (d/ordered-set) ids))) + (reduce add-element (d/ordered-set) ids)))) (defn- indexed-shapes "Retrieves a vector with the indexes for each element in the layer From e0fd8bac81badc51d759ba71590a583ef621eafc Mon Sep 17 00:00:00 2001 From: Elena Torro Date: Wed, 14 Jan 2026 13:06:09 +0100 Subject: [PATCH 19/20] :wrench: Optimize sidebar performance for deeply nested shapes - Batch hover highlights using RAF to avoid long tasks from rapid events - Run parent expansion asynchronously to not block selection - Lazy-load children in layer items using IntersectionObserver - Clarify expand-all-parents logic with explicit bindings --- .../src/app/main/data/workspace/collapse.cljs | 14 +- .../app/main/data/workspace/selection.cljs | 11 +- .../main/ui/workspace/sidebar/layer_item.cljs | 143 +++++++++++++++--- 3 files changed, 132 insertions(+), 36 deletions(-) diff --git a/frontend/src/app/main/data/workspace/collapse.cljs b/frontend/src/app/main/data/workspace/collapse.cljs index f805c238c8..1143a6f4d8 100644 --- a/frontend/src/app/main/data/workspace/collapse.cljs +++ b/frontend/src/app/main/data/workspace/collapse.cljs @@ -18,13 +18,13 @@ ptk/UpdateEvent (update [_ state] (let [expand-fn (fn [expanded] - (merge expanded - (->> ids - (map #(cfh/get-parent-ids objects %)) - flatten - (remove #(= % uuid/zero)) - (map (fn [id] {id true})) - (into {}))))] + (let [parents-seqs (map (fn [x] (cfh/get-parent-ids objects x)) ids) + flat-parents (apply concat parents-seqs) + non-root-parents (remove #(= % uuid/zero) flat-parents) + distinct-parents (into #{} non-root-parents)] + (merge expanded + (into {} + (map (fn [id] {id true}) distinct-parents)))))] (update-in state [:workspace-local :expanded] expand-fn))))) diff --git a/frontend/src/app/main/data/workspace/selection.cljs b/frontend/src/app/main/data/workspace/selection.cljs index 5424a280d9..45c323a860 100644 --- a/frontend/src/app/main/data/workspace/selection.cljs +++ b/frontend/src/app/main/data/workspace/selection.cljs @@ -264,10 +264,13 @@ ptk/WatchEvent (watch [_ state _] - (let [objects (dsh/lookup-page-objects state)] - (rx/of - (dwc/expand-all-parents ids objects) - ::dwsp/interrupt))))) + (let [objects (dsh/lookup-page-objects state) + ;; Schedule expanding parents asynchronously to avoid blocking + ;; the event loop + expand-s (->> (rx/of (dwc/expand-all-parents ids objects)) + (rx/observe-on :async)) + interrupt-s (rx/of ::dwsp/interrupt)] + (rx/merge expand-s interrupt-s))))) (defn select-all [] diff --git a/frontend/src/app/main/ui/workspace/sidebar/layer_item.cljs b/frontend/src/app/main/ui/workspace/sidebar/layer_item.cljs index ecb85db113..9ee7677a4c 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/layer_item.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/layer_item.cljs @@ -33,9 +33,24 @@ [okulary.core :as l] [rumext.v2 :as mf])) +;; Coalesce sidebar hover highlights to 1 frame to avoid long tasks +(defonce ^:private sidebar-hover-queue (atom {:enter #{} :leave #{}})) +(defonce ^:private sidebar-hover-pending? (atom false)) + +(defn- schedule-sidebar-hover-flush [] + (when (compare-and-set! sidebar-hover-pending? false true) + (ts/raf + (fn [] + (let [{:keys [enter leave]} (swap! sidebar-hover-queue (constantly {:enter #{} :leave #{}}))] + (reset! sidebar-hover-pending? false) + (when (seq leave) + (apply st/emit! (map dw/dehighlight-shape leave))) + (when (seq enter) + (apply st/emit! (map dw/highlight-shape enter)))))))) + (mf/defc layer-item-inner {::mf/wrap-props false} - [{:keys [item depth parent-size name-ref children ref + [{:keys [item depth parent-size name-ref children ref style ;; Flags read-only? highlighted? selected? component-tree? filtered? expanded? dnd-over? dnd-over-top? dnd-over-bot? hide-toggle? @@ -82,7 +97,8 @@ :dnd-over dnd-over? :dnd-over-top dnd-over-top? :dnd-over-bot dnd-over-bot? - :root-board parent-board?)} + :root-board parent-board?) + :style style} [:span {:class (stl/css-case :tab-indentation true :filtered filtered?) @@ -166,10 +182,12 @@ children])) +;; Memoized for performance (mf/defc layer-item {::mf/props :obj - ::mf/memo true} - [{:keys [index item selected objects sortable? filtered? depth parent-size component-child? highlighted]}] + ::mf/wrap [mf/memo]} + [{:keys [index item selected objects sortable? filtered? depth parent-size component-child? highlighted style render-children?] + :or {render-children? true}}] (let [id (:id item) blocked? (:blocked item) hidden? (:hidden item) @@ -246,13 +264,21 @@ (mf/use-fn (mf/deps id) (fn [_] - (st/emit! (dw/highlight-shape id)))) + (swap! sidebar-hover-queue (fn [{:keys [enter leave] :as q}] + (-> q + (assoc :enter (conj enter id)) + (assoc :leave (disj leave id))))) + (schedule-sidebar-hover-flush))) on-pointer-leave (mf/use-fn (mf/deps id) (fn [_] - (st/emit! (dw/dehighlight-shape id)))) + (swap! sidebar-hover-queue (fn [{:keys [enter leave] :as q}] + (-> q + (assoc :enter (disj enter id)) + (assoc :leave (conj leave id))))) + (schedule-sidebar-hover-flush))) on-context-menu (mf/use-fn @@ -338,14 +364,18 @@ component-tree? (or component-child? (ctk/instance-root? item) (ctk/instance-head? item)) enable-drag (mf/use-fn #(reset! drag-disabled* false)) - disable-drag (mf/use-fn #(reset! drag-disabled* true))] + disable-drag (mf/use-fn #(reset! drag-disabled* true)) + + ;; Lazy loading of child elements via IntersectionObserver + children-count* (mf/use-state 0) + children-count (deref children-count*) + lazy-ref (mf/use-ref nil) + observer-var (mf/use-var nil) + chunk-size 50] (mf/with-effect [selected? selected] (let [single? (= (count selected) 1) node (mf/ref-val ref) - ;; NOTE: Neither get-parent-at nor get-parent-with-selector - ;; work if the component template changes, so we need to - ;; seek for an alternate solution. Maybe use-context? scroll-node (dom/get-parent-with-data node "scroll-container") parent-node (dom/get-parent-at node 2) first-child-node (dom/get-first-child parent-node) @@ -363,6 +393,61 @@ #(when (some? subid) (rx/dispose! subid)))) + ;; Setup scroll-driven lazy loading when expanded + ;; and ensures selected children are loaded immediately + (mf/with-effect [expanded? (:shapes item) selected] + (let [shapes-vec (:shapes item) + total (count shapes-vec)] + (if expanded? + (let [;; Children are rendered in reverse order, so index 0 in render = last in shapes-vec + ;; Find if any selected id is a direct child and get its render index + selected-child-render-idx + (when (and (> total chunk-size) (seq selected)) + (let [shapes-reversed (vec (reverse shapes-vec))] + (some (fn [sel-id] + (let [idx (.indexOf shapes-reversed sel-id)] + (when (>= idx 0) idx))) + selected))) + ;; Load at least enough to include the selected child plus extra + ;; for context (so it can be centered in the scroll view) + min-count (if selected-child-render-idx + (+ selected-child-render-idx chunk-size) + chunk-size) + current @children-count* + new-count (min total (max current chunk-size min-count))] + (reset! children-count* new-count)) + (reset! children-count* 0))) + (fn [] + (when-let [obs ^js @observer-var] + (.disconnect obs) + (reset! observer-var nil)))) + + ;; Re-observe sentinel whenever children-count changes (sentinel moves) + (mf/with-effect [children-count expanded?] + (let [total (count (:shapes item)) + node (mf/ref-val ref) + scroll-node (dom/get-parent-with-data node "scroll-container") + lazy-node (mf/ref-val lazy-ref)] + ;; Disconnect previous observer + (when-let [obs ^js @observer-var] + (.disconnect obs) + (reset! observer-var nil)) + ;; Setup new observer if there are more children to load + (when (and expanded? + (< children-count total) + scroll-node + lazy-node) + (let [cb (fn [entries] + (when (and (seq entries) + (.-isIntersecting (first entries))) + ;; Load next chunk when sentinel intersects + (let [current @children-count* + next-count (min total (+ current chunk-size))] + (reset! children-count* next-count)))) + observer (js/IntersectionObserver. cb #js {:root scroll-node})] + (.observe observer lazy-node) + (reset! observer-var observer))))) + [:& layer-item-inner {:ref dref :item item @@ -387,24 +472,32 @@ :on-enable-drag enable-drag :on-disable-drag disable-drag :on-toggle-visibility toggle-visibility - :on-toggle-blocking toggle-blocking} + :on-toggle-blocking toggle-blocking + :style style} - (when (and (:shapes item) expanded?) + (when (and render-children? + (:shapes item) + expanded?) [:div {:class (stl/css-case :element-children true :parent-selected selected? :sticky-children parent-board?) :data-testid (dm/str "children-" id)} - (for [[index id] (reverse (d/enumerate (:shapes item)))] - (when-let [item (get objects id)] - [:& layer-item - {:item item - :highlighted highlighted - :selected selected - :index index - :objects objects - :key (dm/str id) - :sortable? sortable? - :depth depth - :parent-size parent-size - :component-child? component-tree?}]))])])) + (let [all-children (reverse (d/enumerate (:shapes item))) + visible (take children-count all-children)] + (for [[index id] visible] + (when-let [item (get objects id)] + [:& layer-item + {:item item + :highlighted highlighted + :selected selected + :index index + :objects objects + :key (dm/str id) + :sortable? sortable? + :depth depth + :parent-size parent-size + :component-child? component-tree?}]))) + (when (< children-count (count (:shapes item))) + [:div {:ref lazy-ref + :style {:min-height 1}}])])])) From 53bc647783116b8c68b305d2697a051f6bd18069 Mon Sep 17 00:00:00 2001 From: Elena Torro Date: Fri, 16 Jan 2026 12:45:45 +0100 Subject: [PATCH 20/20] :wrench: Fix shape selection from canvas to sidebar --- .../main/ui/workspace/sidebar/options.cljs | 28 +++++++++++++++---- 1 file changed, 22 insertions(+), 6 deletions(-) diff --git a/frontend/src/app/main/ui/workspace/sidebar/options.cljs b/frontend/src/app/main/ui/workspace/sidebar/options.cljs index 8e0ba6ad8f..d5a1f181dd 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options.cljs @@ -116,13 +116,29 @@ (->> (dm/get-in grid-edition [edition :selected]) (map #(dm/get-in objects [edition :layout-grid-cells %]))) - shapes-with-children - (mf/with-memo [selected objects shapes] - (let [xform (comp (remove nil?) - (mapcat #(cfh/get-children-ids objects %))) - selected (into selected xform selected)] - (sequence (keep (d/getf objects)) selected))) + shapes-with-children* + (mf/use-state nil) + _ (mf/use-effect + (mf/deps selected objects shapes) + (fn [] + (reset! shapes-with-children* nil) + (let [result + (loop [queue (into #queue [] selected) + visited selected] + (if-let [id (peek queue)] + (let [shape (get objects id) + children (:shapes shape)] + (if (seq children) + (let [new-children (remove visited children)] + (recur (into (pop queue) new-children) + (into visited new-children))) + (recur (pop queue) visited))) + (sequence (keep (d/getf objects)) visited)))] + (reset! shapes-with-children* result)))) + + shapes-with-children + (deref shapes-with-children*) total-selected (count selected)]