From effb8bcf104c89478808e73c12225a59714ba567 Mon Sep 17 00:00:00 2001 From: "alonso.torres" Date: Mon, 11 May 2026 15:35:10 +0200 Subject: [PATCH 01/36] :bug: Fix problem when editing text not being redrawn --- render-wasm/src/render.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/render-wasm/src/render.rs b/render-wasm/src/render.rs index f25d3f65bc..41d7869cc0 100644 --- a/render-wasm/src/render.rs +++ b/render-wasm/src/render.rs @@ -21,6 +21,7 @@ use options::RenderOptions; pub use surfaces::{SurfaceId, Surfaces}; use crate::error::{Error, Result}; +use crate::math; use crate::shapes::{ all_with_ancestors, radius_to_sigma, Blur, BlurType, Corners, Fill, Shadow, Shape, SolidColor, Stroke, StrokeKind, TextContent, Type, @@ -452,7 +453,8 @@ impl RenderState { }; // Only allow using the cached pixels for pure translations. // For non-translation transforms (scale/rotate/skew), cached pixels won't match. - if !crate::math::is_move_only_matrix(m) { + // If the transform is the identity means a reflow, we need to redraw as well. + if math::identitish(m) || !math::is_move_only_matrix(m) { return false; } @@ -3008,6 +3010,7 @@ impl RenderState { &tree.modifier_ids(), moved_bounds, ); + if use_cached { if let Some(crop) = self.backbuffer_crop_cache.get(&node_id) { let crop_image = &crop.image; From bd3ca6f8e56869a18731544099c35bd4ab2824b6 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Tue, 12 May 2026 09:33:33 +0200 Subject: [PATCH 02/36] :books: Update changelog --- CHANGES.md | 37 +++++++++++++++++++------------------ 1 file changed, 19 insertions(+), 18 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index cb4fba79e5..489f51ff1a 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,32 +1,33 @@ # CHANGELOG -## 2.15.0 (Unreleased) +## 2.15.0 ### :sparkles: New features & Enhancements -- Add MCP server integration [Github #9174](https://github.com/penpot/penpot/issues/9174) -- Add chunked upload API for large media and binary files (removes previous upload size limits) [Github #8909](https://github.com/penpot/penpot/pull/8909) -- Improve team name validation [Github #9176](https://github.com/penpot/penpot/pull/9176) +- Add MCP server integration [Github #9174](https://github.com/penpot/penpot/issues/9174) +- Add chunked upload API for large media and binary files (removes previous upload size limits) [Github #9516](https://github.com/penpot/penpot/issues/9516) +- Add anonymous telemetry event collection [Github #9467](https://github.com/penpot/penpot/issues/9467) +- Improve team name validation [Github #9517](https://github.com/penpot/penpot/issues/9517) +- Enhance readability of applied tokens in plugins API [Github #9175](https://github.com/penpot/penpot/issues/9175) +- Encourage use of flex/grid layouts in designs generated via MCP [Github #9081](https://github.com/penpot/penpot/issues/9081) +- Improve MCP server logging, adding Loki support [Github #9415](https://github.com/penpot/penpot/issues/9415) +- Add security headers to Nginx on Docker images [Github #9519](https://github.com/penpot/penpot/issues/9519) ### :bug: Bugs fixed -- Fix MCP integrations URL copy action to match the URL displayed in settings [Github #9238](https://github.com/penpot/penpot/issues/9238) -- Fix Plugin API token methods rejecting JS array of strings [Github #9162](https://github.com/penpot/penpot/issues/9162) -- Harden Nginx responses with standard security headers and hide upstream `X-Powered-By` headers -- Fix keep-alive interval leak in PluginBridge (by @opcode81) [Github #9435](https://github.com/penpot/penpot/pull/9435) -- Fix MCP "active in another tab" notification not clearing (by @Dexterity104) [Github #9321](https://github.com/penpot/penpot/pull/9321) -- Fix swapped analytics event names on MCP tab-switch dialog (by @Dexterity104) [Github #9322](https://github.com/penpot/penpot/pull/9322) -- Fix MCP ReplServer binding to all interfaces (0.0.0.0) instead of localhost, allowing unauthenticated RCE [Github #9400] (https://github.com/penpot/penpot/pull/9400) -- Fix incorrect handling of version restore operation [Github #9041](https://github.com/penpot/penpot/pull/9041) -- Fix SSRF in media URL import and restrict unauthenticated asset access to public buckets only [Github #9390](https://github.com/penpot/penpot/pull/9390) - Fix text edition mode not exited when changing selection, blocking token application [Github #9346](https://github.com/penpot/penpot/issues/9346) -- Use base64 envelope for Uint8Array task results to avoid JSON expansion (by @opcode81) [Github #9431](https://github.com/penpot/penpot/pull/9431) -- Fix empty warning on login [Github #9056](https://github.com/penpot/penpot/pull/9056) -- Fix layer hierarchy to match old and new SCSS [Github #9126](https://github.com/penpot/penpot/pull/9126) -- Fix multiple selection on shapes with token applied to stroke color [Github #9110](https://github.com/penpot/penpot/pull/9110) -- Fix onboarding modals appearing behind libraries and templates panel [Github #9178](https://github.com/penpot/penpot/pull/9178) +- Reduce memory usage of MCP server when handling images (by @opcode81) [Github #9420](https://github.com/penpot/penpot/issues/9420) +- Fix Plugin API token methods rejecting JS array of strings (by @boskodev790) [Github #9162](https://github.com/penpot/penpot/issues/9162) - Fix release notes modal appearing behind the dashboard sidebar (by @RenzoMXD) [Github #8296](https://github.com/penpot/penpot/issues/8296) +- Fix empty warning on login [Github #9520](https://github.com/penpot/penpot/issues/9520) - Fix maximum call stack size exceeded in SSE read-stream [Github #9470](https://github.com/penpot/penpot/issues/9470) +- Fix incorrect handling of version restore operation [Github #9515](https://github.com/penpot/penpot/issues/9515) +- Fix MCP ReplServer binding to all interfaces (0.0.0.0) instead of localhost, allowing unauthenticated RCE [Github #9518](https://github.com/penpot/penpot/issues/9518) +- Fix MCP integrations URL copy action to match the URL displayed in settings [Github #9238](https://github.com/penpot/penpot/issues/9238) +- Fix swapped analytics event names on MCP tab-switch dialog (by @Dexterity104) [Github #9496](https://github.com/penpot/penpot/issues/9496) +- Fix multiple selection on shapes with token applied to stroke color [Github #9522](https://github.com/penpot/penpot/issues/9522) +- Fix onboarding modals appearing behind libraries and templates panel [Github #9521](https://github.com/penpot/penpot/issues/9521) +- Fix keep-alive interval leak in PluginBridge (by @opcode81) [Github #9430](https://github.com/penpot/penpot/issues/9430) ## 2.14.5 From 463017b5c91f5290d3bf302e39036f51fce4f7d8 Mon Sep 17 00:00:00 2001 From: Elena Torro Date: Tue, 12 May 2026 09:47:00 +0200 Subject: [PATCH 03/36] :bug: Fix blur on page transition --- .../main/ui/workspace/sidebar/sitemap.cljs | 20 +++---- .../app/main/ui/workspace/viewport_wasm.cljs | 1 - frontend/src/app/render_wasm/api.cljs | 52 +++---------------- 3 files changed, 19 insertions(+), 54 deletions(-) diff --git a/frontend/src/app/main/ui/workspace/sidebar/sitemap.cljs b/frontend/src/app/main/ui/workspace/sidebar/sitemap.cljs index 06be0b5d94..5d3a1b95dd 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/sitemap.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/sitemap.cljs @@ -84,15 +84,17 @@ ;; - If the user clicks again during the transition, keep showing the original (A) snapshot (if (and (features/active-feature? @st/state "render-wasm/v1") (not= id current-page-id)) - (do - (-> (wasm.api/apply-canvas-blur) - (p/finally - (fn [] - ;; NOTE: it seems we need two RAF so the blur is actually applied and visible - ;; in the canvas :( - (timers/raf - (fn [] - (timers/raf navigate-fn))))))) + (-> (if @wasm.api/page-transition? + (p/resolved nil) + (wasm.api/capture-canvas-snapshot-url)) + (p/finally + (fn [] + (wasm.api/apply-canvas-blur) + ;; NOTE: it seems we need two RAF so the blur is actually applied and visible + ;; in the canvas :( + (timers/raf + (fn [] + (timers/raf navigate-fn)))))) (navigate-fn))))) on-delete diff --git a/frontend/src/app/main/ui/workspace/viewport_wasm.cljs b/frontend/src/app/main/ui/workspace/viewport_wasm.cljs index dd07e7ca67..d23ac367e7 100644 --- a/frontend/src/app/main/ui/workspace/viewport_wasm.cljs +++ b/frontend/src/app/main/ui/workspace/viewport_wasm.cljs @@ -420,7 +420,6 @@ (vreset! unmounted? true) (when-let [timeout-id @timeout-id-ref] (js/clearTimeout timeout-id)) - (wasm.api/end-page-transition!) (wasm.api/clear-canvas))))) (mf/with-effect [show-text-editor? workspace-editor-state edition] diff --git a/frontend/src/app/render_wasm/api.cljs b/frontend/src/app/render_wasm/api.cljs index e086e8963c..a9d9380ede 100644 --- a/frontend/src/app/render_wasm/api.cljs +++ b/frontend/src/app/render_wasm/api.cljs @@ -81,25 +81,8 @@ (defonce transition-tiles-handler* (atom nil)) (defonce snapshot-tiles-handler* (atom nil)) -(def ^:private transition-blur-css "blur(4px)") (def ^:private snapshot-capture-debounce-ms 250) -(defn- set-transition-blur! - [] - (when-let [canvas ^js wasm/canvas] - (dom/set-style! canvas "filter" transition-blur-css)) - (when-let [nodes (.querySelectorAll ^js ug/document ".blurrable")] - (doseq [^js node (array-seq nodes)] - (dom/set-style! node "filter" transition-blur-css)))) - -(defn- clear-transition-blur! - [] - (when-let [canvas ^js wasm/canvas] - (dom/set-style! canvas "filter" "")) - (when-let [nodes (.querySelectorAll ^js ug/document ".blurrable")] - (doseq [^js node (array-seq nodes)] - (dom/set-style! node "filter" "")))) - (defn set-transition-image-from-background! "Sets `transition-image-url*` to a data URL representing a solid background color." [background] @@ -121,8 +104,7 @@ (when-let [prev @transition-tiles-handler*] (.removeEventListener ^js ug/document "penpot:wasm:tiles-complete" prev)) (reset! transition-tiles-handler* nil) - (reset! transition-image-url* nil) - (clear-transition-blur!)) + (reset! transition-image-url* nil)) (defn- set-transition-tiles-complete-handler! "Installs a tiles-complete handler bound to the current transition epoch. @@ -2161,33 +2143,15 @@ (let [already? @page-transition? epoch (begin-page-transition!)] (set-transition-tiles-complete-handler! epoch end-page-transition!) - ;; Two-phase transition: - ;; - Apply CSS blur to the live canvas immediately (no async wait), so the user - ;; sees the transition right away. - ;; - In parallel, capture a `blob:` snapshot URL; once ready, switch the overlay - ;; to that fixed image (and guard with `epoch` to avoid stale async updates). - (set-transition-blur!) ;; Lock the snapshot for the whole transition: if the user clicks to another page ;; while the transition is active, keep showing the original page snapshot until - ;; the final target page finishes rendering. - (if already? - (p/resolved nil) - (do - ;; If we already have a snapshot URL, use it immediately. - (when-let [url wasm/canvas-snapshot-url] - (when (string? url) - (reset! transition-image-url* url))) - - ;; Capture a fresh snapshot asynchronously and update the overlay as soon - ;; as it is ready (guarded by `epoch` to avoid stale async updates). - (-> (capture-canvas-snapshot-url) - (p/then (fn [url] - (when (and (string? url) - @page-transition? - (= epoch @transition-epoch*)) - (reset! transition-image-url* url)) - url)) - (p/catch (fn [_] nil))))))) + ;; the final target page finishes rendering. The caller (sitemap on-click) is + ;; responsible for ensuring `wasm/canvas-snapshot-url` was freshly captured + ;; before invoking us. + (when-not already? + (when-let [url wasm/canvas-snapshot-url] + (when (string? url) + (reset! transition-image-url* url)))))) (defn render-shape-pixels [shape-id scale] From 11a72abdcdbfbde9ca1c0097247b00ef278d7367 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Tue, 12 May 2026 10:04:12 +0200 Subject: [PATCH 04/36] :paperclip: Update version on mcp server --- mcp/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mcp/package.json b/mcp/package.json index b983fa6a19..bf3802c3d8 100644 --- a/mcp/package.json +++ b/mcp/package.json @@ -1,6 +1,6 @@ { "name": "@penpot/mcp", - "version": "2.15.0-rc.3", + "version": "2.15.0", "description": "MCP server for Penpot integration", "bin": { "penpot-mcp": "./bin/mcp-local.js" From 328efd4e167ddffa21f9a1bc3ff5b91e26609b7a Mon Sep 17 00:00:00 2001 From: Yamila Moreno Date: Tue, 12 May 2026 10:06:13 +0200 Subject: [PATCH 05/36] :books: Add notice regarding architectural constraints with MCP Server (#9423) --- docs/technical-guide/configuration.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/technical-guide/configuration.md b/docs/technical-guide/configuration.md index ad4579fcde..6d6f37bd56 100644 --- a/docs/technical-guide/configuration.md +++ b/docs/technical-guide/configuration.md @@ -415,6 +415,12 @@ In a high-availability (HA) scenario, managing the state outside of replicas is - Valkey: Penpot only needs one Valkey instance to function correctly. Due to the nature of the data it manages, replication isn't even essential. - User media storage: This should not be configured with local storage but rather with centralized storage, such as Kubernetes PVC or S3. + +__Since version 2.15.0__ + +Starting with version 2.15, we have introduced the MCP server. Due to architectural constraints, using the MCP server requires running only a single instance of Penpot. +If the MCP server is not installed, then Penpot can scale normally and multiple application instances may be deployed without restrictions. + ## Backend This section enumerates the backend only configuration variables. From f62a89dcc21bd8d3b225b15ed40c46c6cd0f9baa Mon Sep 17 00:00:00 2001 From: Luis de Dios Date: Tue, 12 May 2026 10:25:16 +0200 Subject: [PATCH 06/36] :bug: Fix font selection position is hiding fonts (#9499) --- .../src/app/main/ui/workspace/sidebar/options/menus/text.scss | 2 -- 1 file changed, 2 deletions(-) diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/text.scss b/frontend/src/app/main/ui/workspace/sidebar/options/menus/text.scss index e589c4dd79..ca1d49435e 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/text.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/text.scss @@ -9,8 +9,6 @@ .element-set { @include sidebar.option-grid-structure; - - position: relative; } .element-title { From a126379cc76b4dddf091dad938830a1664192d97 Mon Sep 17 00:00:00 2001 From: Aitor Moreno Date: Fri, 8 May 2026 11:10:14 +0200 Subject: [PATCH 07/36] :recycle: Refactor State into DesignState --- render-wasm/src/globals.rs | 138 ++++++++++++ render-wasm/src/main.rs | 226 +++----------------- render-wasm/src/shapes/text.rs | 1 - render-wasm/src/state.rs | 12 +- render-wasm/src/state/shapes_pool.rs | 16 ++ render-wasm/src/utils.rs | 5 +- render-wasm/src/wasm/blend.rs | 2 +- render-wasm/src/wasm/blurs.rs | 2 +- render-wasm/src/wasm/fills.rs | 1 - render-wasm/src/wasm/fills/image.rs | 7 +- render-wasm/src/wasm/layouts.rs | 2 +- render-wasm/src/wasm/layouts/align.rs | 2 +- render-wasm/src/wasm/layouts/constraints.rs | 2 +- render-wasm/src/wasm/layouts/flex.rs | 2 +- render-wasm/src/wasm/layouts/grid.rs | 2 +- render-wasm/src/wasm/paths.rs | 2 +- render-wasm/src/wasm/paths/bools.rs | 2 +- render-wasm/src/wasm/shadows.rs | 2 +- render-wasm/src/wasm/shapes/base_props.rs | 4 +- render-wasm/src/wasm/shapes/mod.rs | 2 +- render-wasm/src/wasm/strokes.rs | 1 - render-wasm/src/wasm/svg_attrs.rs | 2 +- render-wasm/src/wasm/text.rs | 4 +- render-wasm/src/wasm/text_editor.rs | 44 ++-- render-wasm/src/wasm/transforms.rs | 2 +- 25 files changed, 233 insertions(+), 252 deletions(-) create mode 100644 render-wasm/src/globals.rs diff --git a/render-wasm/src/globals.rs b/render-wasm/src/globals.rs new file mode 100644 index 0000000000..d2ed2196f6 --- /dev/null +++ b/render-wasm/src/globals.rs @@ -0,0 +1,138 @@ +use macros::wasm_error; + +use crate::mem; +use crate::render::{gpu_state::GpuState, RenderState}; +use crate::state::{State, TextEditorState}; + +static mut DESIGN_STATE: *mut State = std::ptr::null_mut(); + +/// Design State. +pub(crate) fn get_design_state() -> &'static mut State { + unsafe { + debug_assert!(!DESIGN_STATE.is_null(), "Design State is null"); + &mut *DESIGN_STATE + } +} + +/// GPU State. +static mut GPU_STATE: *mut GpuState = std::ptr::null_mut(); + +#[inline(always)] +pub(crate) fn get_gpu_state() -> &'static mut GpuState { + unsafe { + debug_assert!(!GPU_STATE.is_null(), "GPU State is null"); + &mut *GPU_STATE + } +} + +/// Render State. +static mut RENDER_STATE: *mut RenderState = std::ptr::null_mut(); + +#[inline(always)] +pub(crate) fn get_render_state() -> &'static mut RenderState { + unsafe { + debug_assert!(!RENDER_STATE.is_null(), "Render State is null"); + &mut *RENDER_STATE + } +} + +/// Text Editor State +static mut TEXT_EDITOR_STATE: *mut TextEditorState = std::ptr::null_mut(); + +#[inline(always)] +pub(crate) fn get_text_editor_state() -> &'static mut TextEditorState { + unsafe { + debug_assert!(!TEXT_EDITOR_STATE.is_null(), "Text Editor state is null"); + &mut *TEXT_EDITOR_STATE + } +} + +// FIXME: These with_state* macros should be using our CriticalError instead of expect. +// But to do that, we need to not use them at domain-level (i.e. in business logic), just +// in the context of the wasm call. +#[macro_export] +macro_rules! with_state { + ($state:ident, $block:block) => {{ + use $crate::globals::get_design_state; + let $state = get_design_state(); + $block + }}; +} + +#[macro_export] +macro_rules! with_current_shape_mut { + ($state:ident, |$shape:ident: &mut Shape| $block:block) => { + use $crate::globals::get_design_state; + let $state = get_design_state(); + $state.touch_current(); + if let Some($shape) = $state.current_shape_mut() { + $block + } + }; +} + +#[macro_export] +macro_rules! with_current_shape { + ($state:ident, |$shape:ident: &Shape| $block:block) => { + use $crate::globals::get_design_state; + let $state = get_design_state(); + if let Some($shape) = $state.current_shape() { + $block + } + }; +} + +/// Initializes GPUState. +fn gpu_init() { + unsafe { + let gpu_state = GpuState::try_new().expect("Cannot initialize GPU State"); + GPU_STATE = Box::into_raw(Box::new(gpu_state)); + } +} + +/// Initializes RenderState. +fn render_init(width: i32, height: i32) { + unsafe { + let render_state = + RenderState::try_new(width, height).expect("Cannot intialize RenderState"); + RENDER_STATE = Box::into_raw(Box::new(render_state)); + } +} + +/// Initializes DesignState. +fn design_init() { + unsafe { + let design_state = State::new(); + DESIGN_STATE = Box::into_raw(Box::new(design_state)); + } +} + +fn text_editor_init() { + unsafe { + let text_editor_state = TextEditorState::new(); + TEXT_EDITOR_STATE = Box::into_raw(Box::new(text_editor_state)); + } +} + +#[no_mangle] +#[wasm_error] +pub extern "C" fn init(width: i32, height: i32) -> Result<()> { + gpu_init(); + render_init(width, height); + text_editor_init(); + design_init(); + Ok(()) +} + +#[no_mangle] +#[wasm_error] +pub extern "C" fn clean_up() -> Result<()> { + // Cancel the current animation frame if it exists so + // it won't try to render without context + let render_state = get_render_state(); + render_state.cancel_animation_frame(); + render_state.prepare_context_loss_cleanup(); + unsafe { DESIGN_STATE = std::ptr::null_mut() } + mem::free_bytes()?; + Ok(()) +} diff --git a/render-wasm/src/main.rs b/render-wasm/src/main.rs index f6a1f74f3c..bbec95e81f 100644 --- a/render-wasm/src/main.rs +++ b/render-wasm/src/main.rs @@ -1,6 +1,7 @@ #[cfg(target_arch = "wasm32")] mod emscripten; mod error; +mod globals; mod math; mod mem; mod performance; @@ -18,181 +19,26 @@ use std::collections::HashMap; #[allow(unused_imports)] use crate::error::{Error, Result}; -use crate::state::TextEditorState; + +use globals::{get_design_state, get_gpu_state, get_render_state}; + use macros::wasm_error; use math::{Bounds, Matrix}; use mem::SerializableResult; -use render::{gpu_state::GpuState, RenderState}; use shapes::{StructureEntry, StructureEntryType, TransformEntry}; use skia_safe as skia; -use state::State; use utils::uuid_from_u32_quartet; use uuid::Uuid; -pub(crate) static mut STATE: Option> = None; -pub(crate) static mut TEXT_EDITOR_STATE: *mut TextEditorState = std::ptr::null_mut(); - -#[inline(always)] -pub fn get_text_editor_state() -> &'static mut TextEditorState { - unsafe { - debug_assert!(!TEXT_EDITOR_STATE.is_null(), "Text Editor state is null"); - &mut *TEXT_EDITOR_STATE - } -} - -/// GPU State. -static mut GPU_STATE: *mut GpuState = std::ptr::null_mut(); - -#[inline(always)] -pub(crate) fn get_gpu_state() -> &'static mut GpuState { - unsafe { - debug_assert!(!GPU_STATE.is_null(), "GPU State is null"); - &mut *GPU_STATE - } -} - -/// Render State. -static mut RENDER_STATE: *mut RenderState = std::ptr::null_mut(); - -#[inline(always)] -pub(crate) fn get_render_state() -> &'static mut RenderState { - unsafe { - debug_assert!(!RENDER_STATE.is_null(), "Render State is null"); - &mut *RENDER_STATE - } -} - -// FIXME: These with_state* macros should be using our CriticalError instead of expect. -// But to do that, we need to not use them at domain-level (i.e. in business logic), just -// in the context of the wasm call. -#[macro_export] -macro_rules! with_state_mut { - ($state:ident, $block:block) => {{ - let $state = unsafe { - #[allow(static_mut_refs)] - STATE.as_mut() - } - .expect("Got an invalid state pointer"); - $block - }}; -} - -#[macro_export] -macro_rules! with_state { - ($state:ident, $block:block) => {{ - let $state = unsafe { - #[allow(static_mut_refs)] - STATE.as_ref() - } - .expect("Got an invalid state pointer"); - $block - }}; -} - -#[macro_export] -macro_rules! with_current_shape_mut { - ($state:ident, |$shape:ident: &mut Shape| $block:block) => { - let $state = unsafe { - #[allow(static_mut_refs)] - STATE.as_mut() - } - .expect("Got an invalid state pointer"); - - $state.touch_current(); - - if let Some($shape) = $state.current_shape_mut() { - $block - } - }; -} - -#[macro_export] -macro_rules! with_current_shape { - ($state:ident, |$shape:ident: &Shape| $block:block) => { - let $state = unsafe { - #[allow(static_mut_refs)] - STATE.as_ref() - } - .expect("Got an invalid state pointer"); - if let Some($shape) = $state.current_shape() { - $block - } - }; -} - -#[macro_export] -macro_rules! with_state_mut_current_shape { - ($state:ident, |$shape:ident: &Shape| $block:block) => { - let $state = unsafe { - #[allow(static_mut_refs)] - STATE.as_mut() - } - .expect("Got an invalid state pointer"); - if let Some($shape) = $state.current_shape() { - $block - } - }; -} - -/// Initializes GPU. -fn gpu_init() { - unsafe { - let gpu_state = GpuState::try_new().expect("Cannot initialize GPU State"); - GPU_STATE = Box::into_raw(Box::new(gpu_state)); - } -} - -/// Initializes RenderState. -fn render_init(width: i32, height: i32) { - unsafe { - let render_state = - RenderState::try_new(width, height).expect("Cannot intialize RenderState"); - RENDER_STATE = Box::into_raw(Box::new(render_state)); - } -} - -#[no_mangle] -#[wasm_error] -pub extern "C" fn init(width: i32, height: i32) -> Result<()> { - gpu_init(); - render_init(width, height); - unsafe { - let state_box = Box::new(State::new()); - STATE = Some(state_box); - TEXT_EDITOR_STATE = Box::into_raw(Box::new(TextEditorState::new())); - } - Ok(()) -} - #[no_mangle] #[wasm_error] pub extern "C" fn set_browser(browser: u8) -> Result<()> { - with_state_mut!(state, { + with_state!(state, { state.set_browser(browser); }); Ok(()) } -#[no_mangle] -#[wasm_error] -pub extern "C" fn clean_up() -> Result<()> { - // Cancel the current animation frame if it exists so - // it won't try to render without context - unsafe { - #[allow(static_mut_refs)] - if STATE.is_some() { - // Cancel the current animation frame if it exists so - // it won't try to render without context. - let render_state = get_render_state(); - render_state.cancel_animation_frame(); - render_state.prepare_context_loss_cleanup(); - } - STATE = None; - } - mem::free_bytes()?; - Ok(()) -} - #[no_mangle] #[wasm_error] pub extern "C" fn set_render_options(debug: u32, dpr: f32) -> Result<()> { @@ -255,7 +101,7 @@ pub extern "C" fn set_max_atlas_texture_size(max_px: i32) -> Result<()> { #[no_mangle] #[wasm_error] pub extern "C" fn set_canvas_background(raw_color: u32) -> Result<()> { - with_state_mut!(state, { + with_state!(state, { let color = skia::Color::new(raw_color); state.set_background_color(color); state.rebuild_tiles_shallow(); @@ -267,7 +113,7 @@ pub extern "C" fn set_canvas_background(raw_color: u32) -> Result<()> { #[no_mangle] #[wasm_error] pub extern "C" fn render(timestamp: i32) -> Result<()> { - with_state_mut!(state, { + with_state!(state, { state.rebuild_touched_tiles(); // Drain the throttled modifier-tile invalidation accumulated // since the previous rAF. set_modifiers skips this work during @@ -290,7 +136,7 @@ pub extern "C" fn render(timestamp: i32) -> Result<()> { #[no_mangle] #[wasm_error] pub extern "C" fn render_sync() -> Result<()> { - with_state_mut!(state, { + with_state!(state, { state.rebuild_tiles(); state .render_sync(0) @@ -302,7 +148,7 @@ pub extern "C" fn render_sync() -> Result<()> { #[no_mangle] #[wasm_error] pub extern "C" fn render_sync_shape(a: u32, b: u32, c: u32, d: u32) -> Result<()> { - with_state_mut!(state, { + with_state!(state, { let id = uuid_from_u32_quartet(a, b, c, d); state.use_shape(id); @@ -328,7 +174,7 @@ pub extern "C" fn render_sync_shape(a: u32, b: u32, c: u32, d: u32) -> Result<() #[no_mangle] #[wasm_error] pub extern "C" fn render_from_cache(_: i32) -> Result<()> { - with_state_mut!(state, { + with_state!(state, { // Don't cancel the animation frame — let the async render // continue populating the tile HashMap in the background. // process_animation_frame skips flush_and_submit in fast @@ -351,7 +197,7 @@ pub extern "C" fn set_preview_mode(enabled: bool) -> Result<()> { #[no_mangle] #[wasm_error] pub extern "C" fn render_preview() -> Result<()> { - with_state_mut!(state, { + with_state!(state, { state.render_preview(performance::get_time()); }); Ok(()) @@ -361,7 +207,7 @@ pub extern "C" fn render_preview() -> Result<()> { #[no_mangle] #[wasm_error] pub extern "C" fn begin_loading() -> Result<()> { - with_state_mut!(state, { + with_state!(state, { state.loading = true; }); Ok(()) @@ -372,7 +218,7 @@ pub extern "C" fn begin_loading() -> Result<()> { #[no_mangle] #[wasm_error] pub extern "C" fn end_loading() -> Result<()> { - with_state_mut!(state, { + with_state!(state, { state.loading = false; }); Ok(()) @@ -394,7 +240,7 @@ pub extern "C" fn render_loading_overlay() -> Result<()> { #[no_mangle] #[wasm_error] pub extern "C" fn process_animation_frame(timestamp: i32) -> Result<()> { - let result = with_state_mut!(state, { state.process_animation_frame(timestamp) }); + let result = with_state!(state, { state.process_animation_frame(timestamp) }); if let Err(err) = result { eprintln!("process_animation_frame error: {}", err); } @@ -449,7 +295,7 @@ pub extern "C" fn set_view_start() -> Result<()> { #[no_mangle] #[wasm_error] pub extern "C" fn set_view_end() -> Result<()> { - with_state_mut!(state, { + with_state!(state, { performance::begin_measure!("set_view_end"); let render_state = get_render_state(); render_state.options.set_fast_mode(false); @@ -518,7 +364,7 @@ pub extern "C" fn set_modifiers_end() -> Result<()> { #[no_mangle] #[wasm_error] pub extern "C" fn clear_focus_mode() -> Result<()> { - with_state_mut!(state, { + with_state!(state, { state.clear_focus_mode(); }); Ok(()) @@ -534,7 +380,7 @@ pub extern "C" fn set_focus_mode() -> Result<()> { .map(|data| Uuid::try_from(data).map_err(|e| Error::RecoverableError(e.to_string()))) .collect::>>()?; - with_state_mut!(state, { + with_state!(state, { state.set_focus_mode(entries); }); Ok(()) @@ -543,7 +389,7 @@ pub extern "C" fn set_focus_mode() -> Result<()> { #[no_mangle] #[wasm_error] pub extern "C" fn init_shapes_pool(capacity: usize) -> Result<()> { - with_state_mut!(state, { + with_state!(state, { state.init_shapes_pool(capacity); }); Ok(()) @@ -552,7 +398,7 @@ pub extern "C" fn init_shapes_pool(capacity: usize) -> Result<()> { #[no_mangle] #[wasm_error] pub extern "C" fn use_shape(a: u32, b: u32, c: u32, d: u32) -> Result<()> { - with_state_mut!(state, { + with_state!(state, { let id = uuid_from_u32_quartet(a, b, c, d); state.use_shape(id); }); @@ -562,7 +408,7 @@ pub extern "C" fn use_shape(a: u32, b: u32, c: u32, d: u32) -> Result<()> { #[no_mangle] #[wasm_error] pub extern "C" fn touch_shape(a: u32, b: u32, c: u32, d: u32) -> Result<()> { - with_state_mut!(state, { + with_state!(state, { let shape_id = uuid_from_u32_quartet(a, b, c, d); state.touch_shape(shape_id); }); @@ -572,7 +418,7 @@ pub extern "C" fn touch_shape(a: u32, b: u32, c: u32, d: u32) -> Result<()> { #[no_mangle] #[wasm_error] pub extern "C" fn set_parent(a: u32, b: u32, c: u32, d: u32) -> Result<()> { - with_state_mut!(state, { + with_state!(state, { let id = uuid_from_u32_quartet(a, b, c, d); state.set_parent_for_current_shape(id); }); @@ -658,7 +504,7 @@ fn set_children_set(entries: Vec) -> Result<()> { } }); - with_state_mut!(state, { + with_state!(state, { let Some(parent_id) = parent_id else { return Err(Error::RecoverableError( "set_children_set: Parent ID not found".to_string(), @@ -891,7 +737,7 @@ pub extern "C" fn get_selection_rect() -> Result<*mut u8> { }) .collect(); - let result_bound = with_state_mut!(state, { + let result_bound = with_state!(state, { let bbs: Vec<_> = entries .iter() .flat_map(|id| state.shapes.get(id).map(|b| b.bounds())) @@ -938,7 +784,7 @@ pub extern "C" fn set_structure_modifiers() -> Result<()> { }) .collect::>>()?; - with_state_mut!(state, { + with_state!(state, { let mut structure = HashMap::new(); let mut scale_content = HashMap::new(); for entry in entries { @@ -977,7 +823,7 @@ pub extern "C" fn set_structure_modifiers() -> Result<()> { #[no_mangle] #[wasm_error] pub extern "C" fn clean_modifiers() -> Result<()> { - with_state_mut!(state, { + with_state!(state, { let render_state = get_render_state(); let prev_modifier_ids = state.shapes.clean_all(); // Skip the tile-cache cleanup during interactive transform: the @@ -1008,7 +854,7 @@ pub extern "C" fn set_modifiers() -> Result<()> { ids.push(entry.id); } - with_state_mut!(state, { + with_state!(state, { state.set_modifiers(modifiers); // TO CHECK if !get_render_state().options.is_interactive_transform() { @@ -1021,28 +867,14 @@ pub extern "C" fn set_modifiers() -> Result<()> { #[no_mangle] #[wasm_error] pub extern "C" fn start_temp_objects() -> Result<()> { - unsafe { - #[allow(static_mut_refs)] - let mut state = STATE.take().ok_or(Error::CriticalError( - "Got an invalid state pointer".to_string(), - ))?; - state = Box::new(state.start_temp_objects()?); - STATE = Some(state); - } + get_design_state().start_temp_objects()?; Ok(()) } #[no_mangle] #[wasm_error] pub extern "C" fn end_temp_objects() -> Result<()> { - unsafe { - #[allow(static_mut_refs)] - let mut state = STATE.take().ok_or(Error::CriticalError( - "Got an invalid state pointer".to_string(), - ))?; - state = Box::new(state.end_temp_objects()?); - STATE = Some(state); - } + get_design_state().end_temp_objects()?; Ok(()) } @@ -1061,7 +893,7 @@ pub extern "C" fn render_shape_pixels( return Err(Error::CriticalError("Scale is not finite".to_string())); } - with_state_mut!(state, { + with_state!(state, { let (data, width, height) = state.render_shape_pixels(&id, scale, performance::get_time())?; diff --git a/render-wasm/src/shapes/text.rs b/render-wasm/src/shapes/text.rs index fcc9066777..98e0a4e6c9 100644 --- a/render-wasm/src/shapes/text.rs +++ b/render-wasm/src/shapes/text.rs @@ -26,7 +26,6 @@ use crate::math::Point; use crate::shapes::{self, merge_fills, Shape, VerticalAlign}; use crate::utils::{get_fallback_fonts, get_font_collection}; use crate::Uuid; -use crate::STATE; // TODO: maybe move this to the wasm module? pub type ParagraphBuilderGroup = Vec; diff --git a/render-wasm/src/state.rs b/render-wasm/src/state.rs index 9591294abf..5eed9d66fb 100644 --- a/render-wasm/src/state.rs +++ b/render-wasm/src/state.rs @@ -38,26 +38,26 @@ impl State { // Creates a new temporary shapes pool. // Will panic if a previous temporary pool exists. - pub fn start_temp_objects(mut self) -> Result { + pub fn start_temp_objects(&mut self) -> Result<()> { if self.saved_shapes.is_some() { return Err(Error::CriticalError( "Tried to start a temp objects while the previous have not been restored" .to_string(), )); } - self.saved_shapes = Some(self.shapes); + self.saved_shapes = Some(self.shapes.clone()); self.shapes = ShapesPool::new(); - Ok(self) + Ok(()) } // Disposes of the temporary shapes pool restoring the normal pool // Will panic if a there is no temporary pool. - pub fn end_temp_objects(mut self) -> Result { - self.shapes = self.saved_shapes.ok_or(Error::CriticalError( + pub fn end_temp_objects(&mut self) -> Result<()> { + self.shapes = self.saved_shapes.clone().ok_or(Error::CriticalError( "Tried to end temp objects but not content to be restored is present".to_string(), ))?; self.saved_shapes = None; - Ok(self) + Ok(()) } pub fn render_from_cache(&mut self) { diff --git a/render-wasm/src/state/shapes_pool.rs b/render-wasm/src/state/shapes_pool.rs index 32d95f8a3a..9f19dbc50a 100644 --- a/render-wasm/src/state/shapes_pool.rs +++ b/render-wasm/src/state/shapes_pool.rs @@ -398,3 +398,19 @@ impl Default for ShapesPoolImpl { Self::new() } } + +impl Clone for ShapesPoolImpl { + fn clone(&self) -> Self { + ShapesPoolImpl { + shapes: self.shapes.clone(), + counter: self.counter, + uuid_to_idx: self.uuid_to_idx.clone(), + // The modified_shape_cache is a derived/computed cache; reset it on clone + // so it gets lazily rebuilt on demand rather than cloning OnceCell state. + modified_shape_cache: HashMap::default(), + modifiers: self.modifiers.clone(), + structure: self.structure.clone(), + scale_content: self.scale_content.clone(), + } + } +} diff --git a/render-wasm/src/utils.rs b/render-wasm/src/utils.rs index 5d5c9a4c10..206b91dbb0 100644 --- a/render-wasm/src/utils.rs +++ b/render-wasm/src/utils.rs @@ -2,8 +2,7 @@ use crate::get_render_state; use crate::skia::textlayout::FontCollection; use crate::skia::Image; use crate::uuid::Uuid; -use crate::with_state_mut; -use crate::STATE; +use crate::with_state; use std::collections::HashSet; pub fn uuid_from_u32_quartet(a: u32, b: u32, c: u32, d: u32) -> Uuid { @@ -35,7 +34,7 @@ pub fn get_fallback_fonts() -> &'static HashSet { } pub fn get_font_collection() -> &'static FontCollection { - with_state_mut!(state, { state.font_collection() }) + with_state!(state, { state.font_collection() }) } #[derive(Debug, Clone, Copy)] diff --git a/render-wasm/src/wasm/blend.rs b/render-wasm/src/wasm/blend.rs index d0dcbad118..832547cecb 100644 --- a/render-wasm/src/wasm/blend.rs +++ b/render-wasm/src/wasm/blend.rs @@ -2,7 +2,7 @@ use macros::ToJs; use skia_safe as skia; use crate::shapes::BlendMode; -use crate::{with_current_shape_mut, STATE}; +use crate::with_current_shape_mut; #[derive(Debug, PartialEq, Clone, Copy, ToJs)] #[repr(u8)] diff --git a/render-wasm/src/wasm/blurs.rs b/render-wasm/src/wasm/blurs.rs index e8e1a4242c..022439ab44 100644 --- a/render-wasm/src/wasm/blurs.rs +++ b/render-wasm/src/wasm/blurs.rs @@ -1,7 +1,7 @@ use macros::ToJs; use crate::shapes::{Blur, BlurType}; -use crate::{with_current_shape_mut, STATE}; +use crate::with_current_shape_mut; #[derive(Debug, Clone, Copy, PartialEq, ToJs)] #[repr(u8)] diff --git a/render-wasm/src/wasm/fills.rs b/render-wasm/src/wasm/fills.rs index 19fc49e575..2e652b11b1 100644 --- a/render-wasm/src/wasm/fills.rs +++ b/render-wasm/src/wasm/fills.rs @@ -3,7 +3,6 @@ use macros::{wasm_error, ToJs}; use crate::mem; use crate::shapes; use crate::with_current_shape_mut; -use crate::STATE; mod gradient; mod image; diff --git a/render-wasm/src/wasm/fills/image.rs b/render-wasm/src/wasm/fills/image.rs index 5d4db56a71..ce245055fb 100644 --- a/render-wasm/src/wasm/fills/image.rs +++ b/render-wasm/src/wasm/fills/image.rs @@ -4,8 +4,7 @@ use crate::mem; use crate::shapes::Fill; use crate::state::State; use crate::uuid::Uuid; -use crate::with_state_mut; -use crate::STATE; +use crate::with_state; use crate::{shapes::ImageFill, utils::uuid_from_u32_quartet}; use macros::wasm_error; @@ -106,7 +105,7 @@ pub extern "C" fn store_image() -> Result<()> { let image_bytes = &bytes[IMAGE_HEADER_SIZE..]; - with_state_mut!(state, { + with_state!(state, { if let Err(msg) = get_render_state().add_image(ids.image_id, is_thumbnail, image_bytes) { eprintln!("{}", msg); } @@ -176,7 +175,7 @@ pub extern "C" fn store_image_from_texture() -> Result<()> { .map_err(|_| Error::CriticalError("Invalid bytes for height".to_string()))?, ); - with_state_mut!(state, { + with_state!(state, { if let Err(msg) = get_render_state().add_image_from_gl_texture( ids.image_id, is_thumbnail, diff --git a/render-wasm/src/wasm/layouts.rs b/render-wasm/src/wasm/layouts.rs index 81ea59240d..d179188e51 100644 --- a/render-wasm/src/wasm/layouts.rs +++ b/render-wasm/src/wasm/layouts.rs @@ -1,5 +1,5 @@ use crate::shapes::Sizing; -use crate::{with_current_shape_mut, STATE}; +use crate::with_current_shape_mut; use macros::ToJs; mod align; diff --git a/render-wasm/src/wasm/layouts/align.rs b/render-wasm/src/wasm/layouts/align.rs index 42810588d9..75ce0816cb 100644 --- a/render-wasm/src/wasm/layouts/align.rs +++ b/render-wasm/src/wasm/layouts/align.rs @@ -3,7 +3,7 @@ use macros::ToJs; use crate::shapes::{ AlignContent, AlignItems, AlignSelf, JustifyContent, JustifyItems, JustifySelf, VerticalAlign, }; -use crate::{with_current_shape_mut, STATE}; +use crate::with_current_shape_mut; #[derive(Debug, Clone, PartialEq, Copy, ToJs)] #[repr(u8)] diff --git a/render-wasm/src/wasm/layouts/constraints.rs b/render-wasm/src/wasm/layouts/constraints.rs index 057e739241..8760ed5592 100644 --- a/render-wasm/src/wasm/layouts/constraints.rs +++ b/render-wasm/src/wasm/layouts/constraints.rs @@ -1,7 +1,7 @@ use macros::ToJs; use crate::shapes::{ConstraintH, ConstraintV}; -use crate::{with_current_shape_mut, STATE}; +use crate::with_current_shape_mut; #[derive(Debug, Clone, PartialEq, Copy, ToJs)] #[repr(u8)] diff --git a/render-wasm/src/wasm/layouts/flex.rs b/render-wasm/src/wasm/layouts/flex.rs index 10d1f991e8..2935165fcf 100644 --- a/render-wasm/src/wasm/layouts/flex.rs +++ b/render-wasm/src/wasm/layouts/flex.rs @@ -1,5 +1,5 @@ use crate::shapes::{FlexDirection, WrapType}; -use crate::{with_current_shape_mut, STATE}; +use crate::with_current_shape_mut; use macros::ToJs; use super::align; diff --git a/render-wasm/src/wasm/layouts/grid.rs b/render-wasm/src/wasm/layouts/grid.rs index 87a7e45f28..15284250c2 100644 --- a/render-wasm/src/wasm/layouts/grid.rs +++ b/render-wasm/src/wasm/layouts/grid.rs @@ -4,7 +4,7 @@ use crate::get_render_state; use crate::mem; use crate::shapes::{GridCell, GridDirection, GridTrack, GridTrackType}; use crate::uuid::Uuid; -use crate::{uuid_from_u32_quartet, with_current_shape_mut, with_state, STATE}; +use crate::{uuid_from_u32_quartet, with_current_shape_mut, with_state}; use super::align; diff --git a/render-wasm/src/wasm/paths.rs b/render-wasm/src/wasm/paths.rs index 7690a9001f..730bb6fe85 100644 --- a/render-wasm/src/wasm/paths.rs +++ b/render-wasm/src/wasm/paths.rs @@ -6,7 +6,7 @@ use std::sync::{Mutex, OnceLock}; use crate::error::{Error, Result}; use crate::shapes::{stroke_to_path, Path, Segment, ToPath}; -use crate::{mem, with_current_shape, with_current_shape_mut, STATE}; +use crate::{mem, with_current_shape, with_current_shape_mut}; const RAW_SEGMENT_DATA_SIZE: usize = size_of::(); diff --git a/render-wasm/src/wasm/paths/bools.rs b/render-wasm/src/wasm/paths/bools.rs index 0e6636e2f2..3858641f0a 100644 --- a/render-wasm/src/wasm/paths/bools.rs +++ b/render-wasm/src/wasm/paths/bools.rs @@ -5,7 +5,7 @@ use crate::math; use crate::shapes::BoolType; use crate::uuid::Uuid; use crate::{mem, SerializableResult}; -use crate::{with_current_shape_mut, with_state, STATE}; +use crate::{with_current_shape_mut, with_state}; use std::mem::size_of; #[allow(unused_imports)] diff --git a/render-wasm/src/wasm/shadows.rs b/render-wasm/src/wasm/shadows.rs index c7a95aacfe..57ecae2613 100644 --- a/render-wasm/src/wasm/shadows.rs +++ b/render-wasm/src/wasm/shadows.rs @@ -2,7 +2,7 @@ use macros::ToJs; use skia_safe as skia; use crate::shapes::{Shadow, ShadowStyle}; -use crate::{with_current_shape_mut, STATE}; +use crate::with_current_shape_mut; #[derive(Debug, Clone, Copy, PartialEq, ToJs)] #[repr(u8)] diff --git a/render-wasm/src/wasm/shapes/base_props.rs b/render-wasm/src/wasm/shapes/base_props.rs index 5e0146f276..e9b6a6e7b0 100644 --- a/render-wasm/src/wasm/shapes/base_props.rs +++ b/render-wasm/src/wasm/shapes/base_props.rs @@ -4,7 +4,7 @@ use crate::utils::uuid_from_u32_quartet; use crate::uuid::Uuid; use crate::wasm::blend::RawBlendMode; use crate::wasm::layouts::constraints::{RawConstraintH, RawConstraintV}; -use crate::{with_state_mut, STATE}; +use crate::with_state; #[allow(unused_imports)] use crate::error::{Error, Result}; @@ -128,7 +128,7 @@ pub extern "C" fn set_shape_base_props() -> Result<()> { let parent_id = raw.parent_id(); let shape_type = RawShapeType::from(raw.shape_type); - with_state_mut!(state, { + with_state!(state, { state.use_shape(id); state.set_parent_for_current_shape(parent_id); state.touch_current(); diff --git a/render-wasm/src/wasm/shapes/mod.rs b/render-wasm/src/wasm/shapes/mod.rs index 3f32e824c3..901feb57f8 100644 --- a/render-wasm/src/wasm/shapes/mod.rs +++ b/render-wasm/src/wasm/shapes/mod.rs @@ -3,7 +3,7 @@ mod base_props; use macros::ToJs; use crate::shapes::{Bool, Frame, Group, Path, Rect, SVGRaw, TextContent, Type}; -use crate::{with_current_shape_mut, STATE}; +use crate::with_current_shape_mut; #[derive(Debug, Clone, PartialEq, ToJs)] #[repr(u8)] diff --git a/render-wasm/src/wasm/strokes.rs b/render-wasm/src/wasm/strokes.rs index 21a7e321ff..452eb4ee2a 100644 --- a/render-wasm/src/wasm/strokes.rs +++ b/render-wasm/src/wasm/strokes.rs @@ -3,7 +3,6 @@ use macros::ToJs; use crate::mem; use crate::shapes::{self, StrokeCap, StrokeStyle}; use crate::with_current_shape_mut; -use crate::STATE; #[derive(Debug, Clone, PartialEq, Copy, ToJs)] #[repr(u8)] diff --git a/render-wasm/src/wasm/svg_attrs.rs b/render-wasm/src/wasm/svg_attrs.rs index 73c146eb2c..ac4e7c376a 100644 --- a/render-wasm/src/wasm/svg_attrs.rs +++ b/render-wasm/src/wasm/svg_attrs.rs @@ -1,7 +1,7 @@ use macros::ToJs; use crate::shapes::{FillRule, StrokeLineCap, StrokeLineJoin, SvgAttrs}; -use crate::{with_current_shape_mut, STATE}; +use crate::with_current_shape_mut; #[derive(PartialEq, ToJs)] #[repr(u8)] diff --git a/render-wasm/src/wasm/text.rs b/render-wasm/src/wasm/text.rs index 88726de91e..c97ec81276 100644 --- a/render-wasm/src/wasm/text.rs +++ b/render-wasm/src/wasm/text.rs @@ -7,7 +7,7 @@ use crate::shapes::{ self, GrowType, Shape, TextAlign, TextDecoration, TextDirection, TextTransform, Type, }; use crate::utils::{uuid_from_u32, uuid_from_u32_quartet}; -use crate::{with_current_shape, with_current_shape_mut, with_state, with_state_mut, STATE}; +use crate::{with_current_shape, with_current_shape_mut, with_state}; use crate::error::Error; @@ -386,7 +386,7 @@ pub extern "C" fn update_shape_text_layout() { #[no_mangle] pub extern "C" fn update_shape_text_layout_for(a: u32, b: u32, c: u32, d: u32) { - with_state_mut!(state, { + with_state!(state, { let shape_id = uuid_from_u32_quartet(a, b, c, d); if let Some(shape) = state.shapes.get_mut(&shape_id) { update_text_layout(shape); diff --git a/render-wasm/src/wasm/text_editor.rs b/render-wasm/src/wasm/text_editor.rs index 10853c98e5..6fc4a3e20a 100644 --- a/render-wasm/src/wasm/text_editor.rs +++ b/render-wasm/src/wasm/text_editor.rs @@ -1,7 +1,8 @@ use macros::{wasm_error, ToJs}; -use crate::get_text_editor_state; +use crate::globals::{get_render_state, get_text_editor_state}; use crate::math::{Matrix, Point, Rect}; +use crate::mem; use crate::render::text_editor as text_editor_render; use crate::render::SurfaceId; use crate::shapes::{Shape, TextAlign, TextContent, TextPositionWithAffinity, Type, VerticalAlign}; @@ -12,8 +13,7 @@ use crate::wasm::fills::RawFillData; use crate::wasm::text::{ helpers as text_helpers, RawTextAlign, RawTextDecoration, RawTextDirection, RawTextTransform, }; -use crate::{get_render_state, mem}; -use crate::{with_state, with_state_mut, STATE}; +use crate::with_state; use skia_safe::Color; #[derive(PartialEq, ToJs)] @@ -42,7 +42,7 @@ pub extern "C" fn text_editor_apply_theme(selection_color: u32, cursor_color: u3 #[no_mangle] pub extern "C" fn text_editor_focus(a: u32, b: u32, c: u32, d: u32) -> bool { - with_state_mut!(state, { + with_state!(state, { let shape_id = uuid_from_u32_quartet(a, b, c, d); let Some(shape) = state.shapes.get(&shape_id) else { @@ -107,7 +107,7 @@ pub extern "C" fn text_editor_get_active_shape_id(buffer_ptr: *mut u32) { #[no_mangle] pub extern "C" fn text_editor_select_all() -> bool { - with_state_mut!(state, { + with_state!(state, { if !get_text_editor_state().has_focus { return false; } @@ -129,7 +129,7 @@ pub extern "C" fn text_editor_select_all() -> bool { #[no_mangle] pub extern "C" fn text_editor_select_word_boundary(x: f32, y: f32) { - with_state_mut!(state, { + with_state!(state, { if !get_text_editor_state().has_focus { return; } @@ -164,7 +164,7 @@ pub extern "C" fn text_editor_poll_event() -> u8 { #[no_mangle] pub extern "C" fn text_editor_pointer_down(x: f32, y: f32) { - with_state_mut!(state, { + with_state!(state, { if !get_text_editor_state().has_focus { return; } @@ -188,7 +188,7 @@ pub extern "C" fn text_editor_pointer_down(x: f32, y: f32) { #[no_mangle] pub extern "C" fn text_editor_pointer_move(x: f32, y: f32) { - with_state_mut!(state, { + with_state!(state, { if !get_text_editor_state().has_focus { return; } @@ -222,7 +222,7 @@ pub extern "C" fn text_editor_pointer_move(x: f32, y: f32) { #[no_mangle] pub extern "C" fn text_editor_pointer_up(x: f32, y: f32) { - with_state_mut!(state, { + with_state!(state, { if !get_text_editor_state().has_focus { return; } @@ -249,7 +249,7 @@ pub extern "C" fn text_editor_pointer_up(x: f32, y: f32) { #[no_mangle] pub extern "C" fn text_editor_set_cursor_from_offset(x: f32, y: f32) { - with_state_mut!(state, { + with_state!(state, { // We need this flag to prevent handling the click behavior // just after a pointerup event. if get_text_editor_state().is_click_event_skipped { @@ -282,7 +282,7 @@ pub extern "C" fn text_editor_set_cursor_from_offset(x: f32, y: f32) { #[no_mangle] pub extern "C" fn text_editor_set_cursor_from_point(x: f32, y: f32) { - with_state_mut!(state, { + with_state!(state, { if !get_text_editor_state().has_focus { return; } @@ -330,7 +330,7 @@ pub extern "C" fn text_editor_composition_end() -> Result<()> { Err(_) => return Ok(()), }; - with_state_mut!(state, { + with_state!(state, { if !get_text_editor_state().has_focus { return Ok(()); } @@ -386,7 +386,7 @@ pub extern "C" fn text_editor_composition_update() -> Result<()> { Err(_) => return Ok(()), }; - with_state_mut!(state, { + with_state!(state, { if !get_text_editor_state().has_focus { return Ok(()); } @@ -444,7 +444,7 @@ pub extern "C" fn text_editor_insert_text() -> Result<()> { Err(_) => return Ok(()), }; - with_state_mut!(state, { + with_state!(state, { if !get_text_editor_state().has_focus { return Ok(()); } @@ -498,7 +498,7 @@ pub extern "C" fn text_editor_insert_text() -> Result<()> { #[no_mangle] pub extern "C" fn text_editor_delete_backward(word_boundary: bool) { - with_state_mut!(state, { + with_state!(state, { if !get_text_editor_state().has_focus { return; } @@ -522,7 +522,7 @@ pub extern "C" fn text_editor_delete_backward(word_boundary: bool) { #[no_mangle] pub extern "C" fn text_editor_delete_forward(word_boundary: bool) { - with_state_mut!(state, { + with_state!(state, { if !get_text_editor_state().has_focus { return; } @@ -546,7 +546,7 @@ pub extern "C" fn text_editor_delete_forward(word_boundary: bool) { #[no_mangle] pub extern "C" fn text_editor_insert_paragraph() { - with_state_mut!(state, { + with_state!(state, { if !get_text_editor_state().has_focus { return; } @@ -578,7 +578,7 @@ pub extern "C" fn text_editor_move_cursor( word_boundary: bool, extend_selection: bool, ) { - with_state_mut!(state, { + with_state!(state, { if !get_text_editor_state().has_focus { return; } @@ -610,7 +610,7 @@ pub extern "C" fn text_editor_move_cursor( #[no_mangle] pub extern "C" fn text_editor_get_cursor_rect() -> *mut u8 { - with_state_mut!(state, { + with_state!(state, { if !get_text_editor_state().has_focus || !get_text_editor_state().cursor_visible { return std::ptr::null_mut(); } @@ -644,7 +644,7 @@ pub extern "C" fn text_editor_get_cursor_rect() -> *mut u8 { #[no_mangle] pub extern "C" fn text_editor_get_current_styles() -> *mut u8 { - with_state_mut!(state, { + with_state!(state, { if !get_text_editor_state().has_focus { return std::ptr::null_mut(); } @@ -812,7 +812,7 @@ pub extern "C" fn text_editor_get_current_styles() -> *mut u8 { #[no_mangle] pub extern "C" fn text_editor_get_selection_rects() -> *mut u8 { - with_state_mut!(state, { + with_state!(state, { if !get_text_editor_state().has_focus { return std::ptr::null_mut(); } @@ -858,7 +858,7 @@ pub extern "C" fn text_editor_update_blink(timestamp_ms: f32) { #[no_mangle] pub extern "C" fn text_editor_render_overlay() { - with_state_mut!(state, { + with_state!(state, { let Some(shape_id) = get_text_editor_state().active_shape_id else { return; }; diff --git a/render-wasm/src/wasm/transforms.rs b/render-wasm/src/wasm/transforms.rs index b0e0a2d84d..87989c545b 100644 --- a/render-wasm/src/wasm/transforms.rs +++ b/render-wasm/src/wasm/transforms.rs @@ -7,7 +7,7 @@ use skia_safe as skia; use crate::mem; use crate::shapes::{self, TransformEntry, TransformEntrySource}; use crate::utils::uuid_from_u32_quartet; -use crate::{with_state, STATE}; +use crate::with_state; #[derive(Debug, PartialEq, Clone, Copy, ToJs)] #[repr(u8)] From 847d55bfb427991761a0d97491648ca73d6fe281 Mon Sep 17 00:00:00 2001 From: Elena Torro Date: Mon, 11 May 2026 16:22:22 +0200 Subject: [PATCH 08/36] :bug: Update component thumbnails to match current shape --- .../app/main/data/workspace/thumbnails.cljs | 21 ++++++---- frontend/src/app/main/refs.cljs | 6 +++ .../ui/workspace/sidebar/assets/common.cljs | 40 ++++++++++++++----- 3 files changed, 50 insertions(+), 17 deletions(-) diff --git a/frontend/src/app/main/data/workspace/thumbnails.cljs b/frontend/src/app/main/data/workspace/thumbnails.cljs index ffa3181a4e..cce984b413 100644 --- a/frontend/src/app/main/data/workspace/thumbnails.cljs +++ b/frontend/src/app/main/data/workspace/thumbnails.cljs @@ -96,12 +96,14 @@ ptk/UpdateEvent (update [_ state] - (update state :thumbnails - (fn [thumbs] - (if-let [uri (get thumbs object-id)] - (do (vreset! pending uri) - (dissoc thumbs object-id)) - thumbs)))) + (-> state + (update :thumbnails + (fn [thumbs] + (if-let [uri (get thumbs object-id)] + (do (vreset! pending uri) + (dissoc thumbs object-id)) + thumbs))) + (update :thumbnails-meta dissoc object-id))) ptk/WatchEvent (watch [_ _ _] @@ -124,10 +126,13 @@ (ptk/reify ::assoc-thumbnail ptk/UpdateEvent (update [_ state] - (let [prev-uri (dm/get-in state [:thumbnails object-id])] + (let [prev-uri (dm/get-in state [:thumbnails object-id]) + now (.now js/Date)] (some->> prev-uri (vreset! prev-uri*)) (l/trc :hint "assoc thumbnail" :object-id object-id :uri uri) - (update state :thumbnails assoc object-id uri))) + (-> state + (update :thumbnails assoc object-id uri) + (update :thumbnails-meta assoc object-id {:rendered-at now})))) ptk/EffectEvent (effect [_ _ _] diff --git a/frontend/src/app/main/refs.cljs b/frontend/src/app/main/refs.cljs index 01bb43a0cb..a68f577b6b 100644 --- a/frontend/src/app/main/refs.cljs +++ b/frontend/src/app/main/refs.cljs @@ -588,6 +588,12 @@ (cf/resolve-media))) st/state)) +(defn workspace-thumbnail-rendered-at + [object-id] + (l/derived + #(dm/get-in % [:thumbnails-meta object-id :rendered-at]) + st/state)) + (def workspace-text-modifier (l/derived :workspace-text-modifier st/state)) diff --git a/frontend/src/app/main/ui/workspace/sidebar/assets/common.cljs b/frontend/src/app/main/ui/workspace/sidebar/assets/common.cljs index 28eb7e4699..419ab73531 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/assets/common.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/assets/common.cljs @@ -324,14 +324,35 @@ current-page-id (mf/deref refs/current-page-id) thumbnail-requested? (mf/use-ref false) - thumbnail-uri* + object-id (mf/with-memo [file-id page-id root-id] - (let [object-id (thc/fmt-object-id file-id page-id root-id "component")] - (refs/workspace-thumbnail-by-id object-id))) + (thc/fmt-object-id file-id page-id root-id "component")) + + thumbnail-uri* + (mf/with-memo [object-id] + (refs/workspace-thumbnail-by-id object-id)) thumbnail-uri (mf/deref thumbnail-uri*) + rendered-at* + (mf/with-memo [object-id] + (refs/workspace-thumbnail-rendered-at object-id)) + + rendered-at + (mf/deref rendered-at*) + + modified-at + (some-> (:modified-at component) (.getTime)) + + ;; Stale if there's no in-session render record + ;; or the component was modified after the last render + stale? + (and (some? thumbnail-uri) + (or (nil? rendered-at) + (and (some? modified-at) + (> modified-at rendered-at)))) + on-error (mf/use-fn (mf/deps @retry) @@ -340,20 +361,21 @@ (inc retry))))] ;; Lazy WASM thumbnail rendering: when the component becomes - ;; visible, has no cached thumbnail, and lives on the current page - ;; trigger a render. Ref is used to avoid triggering multiple renders - ;; while the component is still not rendered and the thumbnail URI - ;; is not available. + ;; visible and either has no cached thumbnail or the cached one is + ;; stale relative to the last recorded edit, trigger a render. Ref + ;; is used to avoid triggering multiple renders while the previous + ;; render is in flight. (mf/use-effect - (mf/deps is-hidden thumbnail-uri wasm? current-page-id file-id page-id) + (mf/deps is-hidden thumbnail-uri stale? wasm? current-page-id file-id page-id) (fn [] - (if (some? thumbnail-uri) + (if (and (some? thumbnail-uri) (not stale?)) (mf/set-ref-val! thumbnail-requested? false) (when (and wasm? (not is-hidden) (not (mf/ref-val thumbnail-requested?)) (= page-id current-page-id)) (mf/set-ref-val! thumbnail-requested? true) (st/emit! (dwt.wasm/render-thumbnail file-id page-id root-id)))))) (if (and (some? thumbnail-uri) + (not stale?) (or (contains? cf/flags :component-thumbnails) wasm?)) [:& component-svg-thumbnail From 76e3df5836a31ff97ce94e3270c5f91fe0e75be6 Mon Sep 17 00:00:00 2001 From: Elena Torro Date: Mon, 11 May 2026 17:18:18 +0200 Subject: [PATCH 09/36] :bug: Use extrect to capture component's thumbnail --- .../main/data/workspace/thumbnails_wasm.cljs | 5 +++-- frontend/src/app/render_wasm/api.cljs | 18 ++++++++++++++++++ render-wasm/src/main.rs | 19 +++++++++++++++++++ 3 files changed, 40 insertions(+), 2 deletions(-) diff --git a/frontend/src/app/main/data/workspace/thumbnails_wasm.cljs b/frontend/src/app/main/data/workspace/thumbnails_wasm.cljs index ff82e15972..507714578c 100644 --- a/frontend/src/app/main/data/workspace/thumbnails_wasm.cljs +++ b/frontend/src/app/main/data/workspace/thumbnails_wasm.cljs @@ -63,8 +63,9 @@ (try (let [objects (dsh/lookup-page-objects @st/state file-id page-id)] (if-let [frame (get objects frame-id)] - (let [{:keys [width height]} (:selrect frame) - max-size (mth/max width height) + (let [{ext-w :width ext-h :height} (wasm.api/get-shape-extrect frame-id) + {sel-w :width sel-h :height} (:selrect frame) + max-size (mth/max (or ext-w sel-w) (or ext-h sel-h)) scale (mth/max 1 (/ target-size max-size)) png-bytes (wasm.api/render-shape-pixels frame-id scale)] (if (or (nil? png-bytes) (zero? (.-length png-bytes))) diff --git a/frontend/src/app/render_wasm/api.cljs b/frontend/src/app/render_wasm/api.cljs index a9d9380ede..edb1a4f0bf 100644 --- a/frontend/src/app/render_wasm/api.cljs +++ b/frontend/src/app/render_wasm/api.cljs @@ -2172,6 +2172,24 @@ (mem/free) result)) +(defn get-shape-extrect + [shape-id] + (let [buffer (uuid/get-u32 shape-id) + offset (h/call wasm/internal-module "_get_shape_extrect" + (aget buffer 0) + (aget buffer 1) + (aget buffer 2) + (aget buffer 3))] + (when (and (number? offset) (pos? offset)) + (let [heapf32 (mem/get-heap-f32) + base (mem/->offset-32 offset) + x (aget heapf32 base) + y (aget heapf32 (+ base 1)) + w (aget heapf32 (+ base 2)) + h (aget heapf32 (+ base 3))] + (mem/free) + {:x x :y y :width w :height h})))) + (defn init-wasm-module [module] (let [default-fn (unchecked-get module "default") diff --git a/render-wasm/src/main.rs b/render-wasm/src/main.rs index bbec95e81f..74cc7478fe 100644 --- a/render-wasm/src/main.rs +++ b/render-wasm/src/main.rs @@ -878,6 +878,25 @@ pub extern "C" fn end_temp_objects() -> Result<()> { Ok(()) } +#[no_mangle] +#[wasm_error] +pub extern "C" fn get_shape_extrect(a: u32, b: u32, c: u32, d: u32) -> Result<*mut u8> { + let id = uuid_from_u32_quartet(a, b, c, d); + + with_state!(state, { + let Some(shape) = state.shapes.get(&id) else { + return Err(Error::CriticalError("Shape not found".to_string())); + }; + let extrect = get_render_state().get_cached_extrect(shape, &state.shapes, 1.0); + let mut buf = Vec::with_capacity(16); + buf.extend_from_slice(&extrect.x().to_le_bytes()); + buf.extend_from_slice(&extrect.y().to_le_bytes()); + buf.extend_from_slice(&extrect.width().to_le_bytes()); + buf.extend_from_slice(&extrect.height().to_le_bytes()); + Ok(mem::write_bytes(buf)) + }) +} + #[no_mangle] #[wasm_error] pub extern "C" fn render_shape_pixels( From ade0d2d0a8a648dbb4b1188f40e9b6dca8593e5a Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Tue, 12 May 2026 10:18:30 +0200 Subject: [PATCH 10/36] :paperclip: Update changelog with PR info --- CHANGES.md | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/CHANGES.md b/CHANGES.md index 489f51ff1a..70ce402a2d 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -5,29 +5,50 @@ ### :sparkles: New features & Enhancements - Add MCP server integration [Github #9174](https://github.com/penpot/penpot/issues/9174) + (PR: [#9032](https://github.com/penpot/penpot/pull/9032), [#9321](https://github.com/penpot/penpot/pull/9321)) - Add chunked upload API for large media and binary files (removes previous upload size limits) [Github #9516](https://github.com/penpot/penpot/issues/9516) + (PR: [#8909](https://github.com/penpot/penpot/pull/8909)) - Add anonymous telemetry event collection [Github #9467](https://github.com/penpot/penpot/issues/9467) + (PR: [#9065](https://github.com/penpot/penpot/pull/9065), [#9483](https://github.com/penpot/penpot/pull/9483)) - Improve team name validation [Github #9517](https://github.com/penpot/penpot/issues/9517) + (PR: [#9176](https://github.com/penpot/penpot/pull/9176)) - Enhance readability of applied tokens in plugins API [Github #9175](https://github.com/penpot/penpot/issues/9175) + (PR: [#8607](https://github.com/penpot/penpot/pull/8607)) - Encourage use of flex/grid layouts in designs generated via MCP [Github #9081](https://github.com/penpot/penpot/issues/9081) + (PR: [#9084](https://github.com/penpot/penpot/pull/9084)) - Improve MCP server logging, adding Loki support [Github #9415](https://github.com/penpot/penpot/issues/9415) + (PR: [#9425](https://github.com/penpot/penpot/pull/9425)) - Add security headers to Nginx on Docker images [Github #9519](https://github.com/penpot/penpot/issues/9519) + (PR: [#9473](https://github.com/penpot/penpot/pull/9473)) ### :bug: Bugs fixed - Fix text edition mode not exited when changing selection, blocking token application [Github #9346](https://github.com/penpot/penpot/issues/9346) + (PR: [#9355](https://github.com/penpot/penpot/pull/9355)) - Reduce memory usage of MCP server when handling images (by @opcode81) [Github #9420](https://github.com/penpot/penpot/issues/9420) + (PR: [#9431](https://github.com/penpot/penpot/pull/9431)) - Fix Plugin API token methods rejecting JS array of strings (by @boskodev790) [Github #9162](https://github.com/penpot/penpot/issues/9162) + (PR: [#9166](https://github.com/penpot/penpot/pull/9166)) - Fix release notes modal appearing behind the dashboard sidebar (by @RenzoMXD) [Github #8296](https://github.com/penpot/penpot/issues/8296) + (PR: [#9126](https://github.com/penpot/penpot/pull/9126), [#9233](https://github.com/penpot/penpot/pull/9233)) - Fix empty warning on login [Github #9520](https://github.com/penpot/penpot/issues/9520) + (PR: [#9056](https://github.com/penpot/penpot/pull/9056)) - Fix maximum call stack size exceeded in SSE read-stream [Github #9470](https://github.com/penpot/penpot/issues/9470) + (PR: [#9484](https://github.com/penpot/penpot/pull/9484)) - Fix incorrect handling of version restore operation [Github #9515](https://github.com/penpot/penpot/issues/9515) + (PR: [#9041](https://github.com/penpot/penpot/pull/9041)) - Fix MCP ReplServer binding to all interfaces (0.0.0.0) instead of localhost, allowing unauthenticated RCE [Github #9518](https://github.com/penpot/penpot/issues/9518) + (PR: [#9400](https://github.com/penpot/penpot/pull/9400)) - Fix MCP integrations URL copy action to match the URL displayed in settings [Github #9238](https://github.com/penpot/penpot/issues/9238) + (PR: [#9239](https://github.com/penpot/penpot/pull/9239)) - Fix swapped analytics event names on MCP tab-switch dialog (by @Dexterity104) [Github #9496](https://github.com/penpot/penpot/issues/9496) + (PR: [#9322](https://github.com/penpot/penpot/pull/9322)) - Fix multiple selection on shapes with token applied to stroke color [Github #9522](https://github.com/penpot/penpot/issues/9522) + (PR: [#9110](https://github.com/penpot/penpot/pull/9110)) - Fix onboarding modals appearing behind libraries and templates panel [Github #9521](https://github.com/penpot/penpot/issues/9521) + (PR: [#9178](https://github.com/penpot/penpot/pull/9178)) - Fix keep-alive interval leak in PluginBridge (by @opcode81) [Github #9430](https://github.com/penpot/penpot/issues/9430) + (PR: [#9435](https://github.com/penpot/penpot/pull/9435)) ## 2.14.5 From 9ebf7875ea20806425db52488aa2ae0cd80f9250 Mon Sep 17 00:00:00 2001 From: Alejandro Alonso Date: Fri, 8 May 2026 09:41:39 +0200 Subject: [PATCH 11/36] :bug: Fix WASM viewport interaction lifecycle for pan/zoom --- frontend/src/app/main/data/workspace.cljs | 1 + .../src/app/main/data/workspace/viewport.cljs | 55 ++++++++++++--- .../main/data/workspace/viewport_wasm.cljs | 30 ++++++++ .../src/app/main/data/workspace/zoom.cljs | 69 +++++++++++++------ frontend/src/app/main/ui/workspace.cljs | 3 +- .../app/main/ui/workspace/viewport_wasm.cljs | 7 +- frontend/src/app/render_wasm/api.cljs | 34 +++++++-- 7 files changed, 158 insertions(+), 41 deletions(-) create mode 100644 frontend/src/app/main/data/workspace/viewport_wasm.cljs diff --git a/frontend/src/app/main/data/workspace.cljs b/frontend/src/app/main/data/workspace.cljs index 4d224baa63..28123c294f 100644 --- a/frontend/src/app/main/data/workspace.cljs +++ b/frontend/src/app/main/data/workspace.cljs @@ -1578,6 +1578,7 @@ (dm/export dwv/initialize-viewport) (dm/export dwv/update-viewport-position) (dm/export dwv/update-viewport-size) +(dm/export dwv/sync-wasm-workspace-viewport) (dm/export dwv/start-panning) (dm/export dwv/finish-panning) diff --git a/frontend/src/app/main/data/workspace/viewport.cljs b/frontend/src/app/main/data/workspace/viewport.cljs index bf203c10a7..67fb95eb7f 100644 --- a/frontend/src/app/main/data/workspace/viewport.cljs +++ b/frontend/src/app/main/data/workspace/viewport.cljs @@ -16,13 +16,19 @@ [app.common.math :as mth] [app.main.data.event :as ev] [app.main.data.helpers :as dsh] + [app.main.data.workspace.viewport-wasm :as dwvw] [app.util.mouse :as mse] [beicon.v2.core :as rx] [potok.v2.core :as ptk])) -(defn- render-context-lost? - [state] - (true? (get-in state [:render-state :lost]))) +(defn sync-wasm-workspace-viewport + "Effect-only: pushes the current workspace zoom/view box to WASM after other + events (e.g. `update-viewport-size`) have updated the store." + [] + (ptk/reify ::sync-wasm-workspace-viewport + ptk/EffectEvent + (effect [_ state _] + (dwvw/maybe-sync-workspace-local-viewport! state)))) (defn initialize-viewport [{:keys [width height] :as size}] @@ -86,7 +92,11 @@ (update [_ state] (update state :workspace-local (fn [local] - (setup state local))))))) + (setup state local)))) + + ptk/EffectEvent + (effect [_ state _] + (dwvw/maybe-sync-workspace-local-viewport! state))))) (defn calculate-centered-viewbox "Updates the viewbox coordinates for a given center position" @@ -105,9 +115,13 @@ (ptk/reify ::update-viewport-position-center ptk/UpdateEvent (update [_ state] - (if (render-context-lost? state) + (if (dwvw/render-context-lost? state) state - (update state :workspace-local calculate-centered-viewbox position))))) + (update state :workspace-local calculate-centered-viewbox position))) + + ptk/EffectEvent + (effect [_ state _] + (dwvw/maybe-sync-workspace-local-viewport! state)))) (defn update-viewport-position [{:keys [x y] :or {x identity y identity}}] @@ -124,13 +138,17 @@ ptk/UpdateEvent (update [_ state] - (if (render-context-lost? state) + (if (dwvw/render-context-lost? state) state (update-in state [:workspace-local :vbox] (fn [vbox] (-> vbox (update :x x) - (update :y y)))))))) + (update :y y)))))) + + ptk/EffectEvent + (effect [_ state _] + (dwvw/maybe-sync-workspace-local-viewport! state)))) (defn update-viewport-size [resize-type {:keys [width height] :as size}] @@ -174,16 +192,27 @@ (assoc-in [:vbox :width] vbox-width') (assoc-in [:vbox :height] vbox-height'))))))))) +(defn- activate-panning [] + (ptk/reify ::activate-panning + ptk/UpdateEvent + (update [_ state] + (-> state + (assoc-in [:workspace-local :panning] true))) + + ptk/EffectEvent + (effect [_ state _] + (dwvw/maybe-view-interaction-start! state)))) + (defn start-panning [] (ptk/reify ::start-panning ptk/WatchEvent (watch [_ state stream] (let [stopper (->> stream (rx/filter (ptk/type? ::finish-panning))) zoom (get-in state [:workspace-local :zoom])] - (when (and (not (render-context-lost? state)) + (when (and (not (dwvw/render-context-lost? state)) (not (get-in state [:workspace-local :panning]))) (rx/concat - (rx/of #(-> % (assoc-in [:workspace-local :panning] true))) + (rx/of (activate-panning)) (->> stream (rx/filter mse/pointer-event?) (rx/filter #(some? (mse/get-pointer-movement %))) @@ -200,4 +229,8 @@ ptk/UpdateEvent (update [_ state] (-> state - (update :workspace-local dissoc :panning))))) + (update :workspace-local dissoc :panning))) + + ptk/EffectEvent + (effect [_ state _] + (dwvw/maybe-view-interaction-end! state)))) diff --git a/frontend/src/app/main/data/workspace/viewport_wasm.cljs b/frontend/src/app/main/data/workspace/viewport_wasm.cljs new file mode 100644 index 0000000000..4115589ab0 --- /dev/null +++ b/frontend/src/app/main/data/workspace/viewport_wasm.cljs @@ -0,0 +1,30 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC + +(ns app.main.data.workspace.viewport-wasm + (:require + [app.main.features :as features] + [app.render-wasm.api :as wasm.api])) + +(defn render-context-lost? + [state] + (true? (get-in state [:render-state :lost]))) + +(defn maybe-sync-workspace-local-viewport! + "When `render-wasm/v1` is active, pushes workspace zoom and vbox into WASM." + [state] + (when (and (features/active-feature? state "render-wasm/v1") (not (render-context-lost? state))) + (wasm.api/sync-workspace-local-viewport! state))) + +(defn maybe-view-interaction-start! + [state] + (when (and (features/active-feature? state "render-wasm/v1") (not (render-context-lost? state))) + (wasm.api/view-interaction-start!))) + +(defn maybe-view-interaction-end! + [state] + (when (and (features/active-feature? state "render-wasm/v1") (not (render-context-lost? state))) + (wasm.api/view-interaction-end!))) \ No newline at end of file diff --git a/frontend/src/app/main/data/workspace/zoom.cljs b/frontend/src/app/main/data/workspace/zoom.cljs index 2984024803..91aa48ad89 100644 --- a/frontend/src/app/main/data/workspace/zoom.cljs +++ b/frontend/src/app/main/data/workspace/zoom.cljs @@ -16,15 +16,12 @@ [app.common.geom.shapes :as gsh] [app.main.data.event :as ev] [app.main.data.helpers :as dsh] + [app.main.data.workspace.viewport-wasm :as dwvw] [app.main.streams :as ms] [app.util.mouse :as mse] [beicon.v2.core :as rx] [potok.v2.core :as ptk])) -(defn- render-context-lost? - [state] - (true? (get-in state [:render-state :lost]))) - (defn impl-update-zoom [{:keys [vbox] :as local} center zoom] (let [new-zoom (if (fn? zoom) (zoom (:zoom local)) zoom) @@ -47,11 +44,15 @@ ptk/UpdateEvent (update [_ state] - (if (render-context-lost? state) + (if (dwvw/render-context-lost? state) state (let [center (if (= center ::auto) @ms/mouse-position center)] (update state :workspace-local - #(impl-update-zoom % center (fn [z] (min (* z 1.3) 200)))))))))) + #(impl-update-zoom % center (fn [z] (min (* z 1.3) 200))))))) + + ptk/EffectEvent + (effect [_ state _] + (dwvw/maybe-sync-workspace-local-viewport! state))))) (defn decrease-zoom ([] @@ -62,11 +63,15 @@ ptk/UpdateEvent (update [_ state] - (if (render-context-lost? state) + (if (dwvw/render-context-lost? state) state (let [center (if (= center ::auto) @ms/mouse-position center)] (update state :workspace-local - #(impl-update-zoom % center (fn [z] (max (/ z 1.3) 0.01)))))))))) + #(impl-update-zoom % center (fn [z] (max (/ z 1.3) 0.01))))))) + + ptk/EffectEvent + (effect [_ state _] + (dwvw/maybe-sync-workspace-local-viewport! state))))) (defn set-zoom ([scale] @@ -77,7 +82,7 @@ ptk/UpdateEvent (update [_ state] - (if (render-context-lost? state) + (if (dwvw/render-context-lost? state) state (let [vp (dm/get-in state [:workspace-local :vbox]) x (+ (:x vp) (/ (:width vp) 2)) @@ -86,22 +91,30 @@ (update state :workspace-local #(impl-update-zoom % center (fn [z] (-> (* z scale) (max 0.01) - (min 200))))))))))) + (min 200)))))))) + + ptk/EffectEvent + (effect [_ state _] + (dwvw/maybe-sync-workspace-local-viewport! state))))) (def reset-zoom (ptk/reify ::reset-zoom ptk/UpdateEvent (update [_ state] - (if (render-context-lost? state) + (if (dwvw/render-context-lost? state) state (update state :workspace-local - #(impl-update-zoom % nil 1)))))) + #(impl-update-zoom % nil 1)))) + + ptk/EffectEvent + (effect [_ state _] + (dwvw/maybe-sync-workspace-local-viewport! state)))) (def zoom-to-fit-all (ptk/reify ::zoom-to-fit-all ptk/UpdateEvent (update [_ state] - (if (render-context-lost? state) + (if (dwvw/render-context-lost? state) state (let [page-id (:current-page-id state) objects (dsh/lookup-page-objects state page-id) @@ -116,13 +129,17 @@ (-> local (assoc :zoom zoom) (assoc :zoom-inverse (/ 1 zoom)) - (update :vbox merge srect))))))))))) + (update :vbox merge srect))))))))) + + ptk/EffectEvent + (effect [_ state _] + (dwvw/maybe-sync-workspace-local-viewport! state)))) (def zoom-to-selected-shape (ptk/reify ::zoom-to-selected-shape ptk/UpdateEvent (update [_ state] - (if (render-context-lost? state) + (if (dwvw/render-context-lost? state) state (let [selected (dsh/lookup-selected state)] (if (empty? selected) @@ -139,14 +156,18 @@ (-> local (assoc :zoom zoom) (assoc :zoom-inverse (/ 1 zoom)) - (update :vbox merge srect)))))))))))) + (update :vbox merge srect)))))))))) + + ptk/EffectEvent + (effect [_ state _] + (dwvw/maybe-sync-workspace-local-viewport! state)))) (defn fit-to-shapes [ids] (ptk/reify ::fit-to-shapes ptk/UpdateEvent (update [_ state] - (if (or (render-context-lost? state) (empty? ids)) + (if (or (dwvw/render-context-lost? state) (empty? ids)) state (let [page-id (:current-page-id state) objects (dsh/lookup-page-objects state page-id) @@ -164,16 +185,21 @@ (-> local (assoc :zoom zoom) (assoc :zoom-inverse (/ 1 zoom)) - (update :vbox merge srect)))))))))) + (update :vbox merge srect)))))))) + + ptk/EffectEvent + (effect [_ state _] + (dwvw/maybe-sync-workspace-local-viewport! state)))) (defn start-zooming [pt] (ptk/reify ::start-zooming ptk/WatchEvent (watch [_ state stream] (let [stopper (->> stream (rx/filter (ptk/type? ::finish-zooming)))] - (when (and (not (render-context-lost? state)) + (when (and (not (dwvw/render-context-lost? state)) (not (get-in state [:workspace-local :zooming]))) (rx/concat + (rx/of (fn [s] (dwvw/maybe-view-interaction-start! s) s)) (rx/of #(-> % (assoc-in [:workspace-local :zooming] true))) (->> stream (rx/filter mse/pointer-event?) @@ -189,4 +215,7 @@ ptk/UpdateEvent (update [_ state] (-> state - (update :workspace-local dissoc :zooming))))) + (update :workspace-local dissoc :zooming))) + ptk/EffectEvent + (effect [_ state _] + (dwvw/maybe-view-interaction-end! state)))) diff --git a/frontend/src/app/main/ui/workspace.cljs b/frontend/src/app/main/ui/workspace.cljs index 0ef0936d22..9bcf142358 100644 --- a/frontend/src/app/main/ui/workspace.cljs +++ b/frontend/src/app/main/ui/workspace.cljs @@ -77,7 +77,8 @@ (mf/deps vport) (fn [resize-type size] (when (and vport (not= size vport)) - (st/emit! (dw/update-viewport-size resize-type size))))) + (st/emit! (dw/update-viewport-size resize-type size) + (dw/sync-wasm-workspace-viewport))))) on-resize-palette (mf/use-fn diff --git a/frontend/src/app/main/ui/workspace/viewport_wasm.cljs b/frontend/src/app/main/ui/workspace/viewport_wasm.cljs index d23ac367e7..ce90438c45 100644 --- a/frontend/src/app/main/ui/workspace/viewport_wasm.cljs +++ b/frontend/src/app/main/ui/workspace/viewport_wasm.cljs @@ -436,7 +436,8 @@ (mf/with-effect [vport] (when (and @canvas-init? @initialized?) - (wasm.api/resize-viewbox (:width vport) (:height vport)))) + (wasm.api/resize-viewbox (:width vport) (:height vport)) + (wasm.api/set-view-box zoom vbox))) (mf/with-effect [@canvas-init? preview-blend] (when (and @canvas-init? preview-blend) @@ -467,10 +468,6 @@ (wasm.api/clear-focus-mode) (wasm.api/set-focus-mode focus))))) - (mf/with-effect [vbox zoom] - (when (and @canvas-init? @initialized?) - (wasm.api/set-view-box zoom vbox))) - (mf/with-effect [background] (when (and @canvas-init? @initialized?) (wasm.api/set-canvas-background background))) diff --git a/frontend/src/app/render_wasm/api.cljs b/frontend/src/app/render_wasm/api.cljs index edb1a4f0bf..f3b6cdc244 100644 --- a/frontend/src/app/render_wasm/api.cljs +++ b/frontend/src/app/render_wasm/api.cljs @@ -208,6 +208,8 @@ (def ^:const DEBOUNCE_DELAY_MS 100) +(defonce ^:private view-interaction-active? (atom false)) + ;; Time budget (ms) per chunk of shape processing before yielding to browser (def ^:private ^:const CHUNK_TIME_BUDGET_MS 8) ;; Threshold below which we use synchronous processing (no chunking overhead) @@ -1146,14 +1148,26 @@ (= result 1)) false)) +(defn view-interaction-start! + [] + (when-not @view-interaction-active? + (h/call wasm/internal-module "_set_view_start") + (reset! view-interaction-active? true))) + +(defn view-interaction-end! + [] + (when @view-interaction-active? + (perf/begin-measure "render-finish") + (h/call wasm/internal-module "_set_view_end") + (perf/end-measure "render-finish") + (reset! view-interaction-active? false))) + (def render-finish (letfn [(do-render [] ;; Check if context is still initialized before executing ;; to prevent errors when navigating quickly (when (and wasm/context-initialized? (not @wasm/context-lost?)) - (perf/begin-measure "render-finish") - (h/call wasm/internal-module "_set_view_end") - (perf/end-measure "render-finish") + (view-interaction-end!) ;; Use async _render: visible tiles render synchronously ;; (no yield), interest-area tiles render progressively ;; via rAF. _set_view_end already rebuilt the tile @@ -1167,7 +1181,7 @@ (defn set-view-box [zoom vbox] (perf/begin-measure "set-view-box") - (h/call wasm/internal-module "_set_view_start") + (view-interaction-start!) (h/call wasm/internal-module "_set_view" zoom (- (:x vbox)) (- (:y vbox))) (perf/end-measure "set-view-box") @@ -1176,6 +1190,16 @@ (render-finish) (perf/end-measure "render-from-cache")) +(defn sync-workspace-local-viewport! + "Pushes `[:workspace-local :zoom]` and `:vbox` into WASM." + [state] + (when (and wasm/context-initialized? + (not @wasm/context-lost?)) + (let [zoom (get-in state [:workspace-local :zoom]) + vbox (get-in state [:workspace-local :vbox])] + (when (and zoom vbox) + (set-view-box zoom vbox))))) + (defn- ensure-text-content "Guarantee that the shape always sends a valid text tree to WASM. When the content is nil (freshly created text) we fall back to @@ -1344,6 +1368,7 @@ ;; Rebuild the tile index so _render knows which shapes ;; map to which tiles after a page switch. (h/call wasm/internal-module "_set_view_end") + (reset! view-interaction-active? false) ;; Text layouts must run after _end_loading (they ;; depend on state that is only correct when loading @@ -1402,6 +1427,7 @@ ;; Rebuild the tile index so _render knows which shapes ;; map to which tiles after a page switch. (h/call wasm/internal-module "_set_view_end") + (reset! view-interaction-active? false) (process-pending shapes thumbnails full (fn [] (if render-callback From 947f6d392db49127e1aeffae5efff6da8f4f9b09 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Tue, 12 May 2026 18:30:19 +0200 Subject: [PATCH 12/36] :tada: Add chunked upload support for font variants (#9551) * :sparkles: Add additional logging and validation for image upload * :tada: Add chunked upload support for font variants Extend the font variant upload flow across frontend, backend, and common to support the standardized chunked upload protocol. **Backend:** - Add \`:font-max-file-size\` config default (30 MiB) and schema entry - Add \`validate-font-size!\` in \`media.clj\` (mirrors \`validate-media-size!\`, raises \`:font-max-file-size-reached\`) - Extend \`schema:create-font-variant\` to accept either \`:data\` (legacy bytes or chunk-vector) or \`:uploads\` (new chunked session map), with a validator requiring exactly one - Add \`prepare-font-data-from-uploads\`: assembles each chunked session via \`cmedia/assemble-chunks\`, validates type+size - Add \`prepare-font-data-from-legacy\`: normalises legacy byte/chunk entries, writing to a tempfile (joining via SequenceInputStream), validates type+size - Add structured logging ("init"/"end") with \`:size\`, \`:mtypes\`, and \`:elapsed\` in \`create-font-variant\` **Frontend:** - \`upload-blob-chunked\` accepts a per-caller \`:chunk-size\` option - Add \`font-upload-chunk-size\` (10 MiB) and \`upload-font-variant\` fn that uploads each mtype as a separate chunked session - \`on-upload*\` in dashboard fonts now calls \`upload-font-variant\` instead of issuing \`create-font-variant\` RPC directly - \`process-upload\` stores raw ArrayBuffer instead of chunking client-side **Common:** - Replace \`"font/opentype"\` with \`"font/woff2"\` in \`font-types\` **Tests:** - 25 tests / 224 assertions covering all three upload paths (direct bytes, legacy chunk-vector, new chunked sessions), size validation, and media type validation Signed-off-by: Andrey Antukh * :paperclip: Add a script for check the commit format locally --------- Signed-off-by: Andrey Antukh --- backend/src/app/config.clj | 2 + backend/src/app/media.clj | 37 +- backend/src/app/rpc/commands/fonts.clj | 139 +++-- backend/src/app/rpc/commands/media.clj | 85 ++- backend/src/app/rpc/commands/profile.clj | 1 + backend/src/app/rpc/commands/teams.clj | 1 + backend/test/backend_tests/rpc_font_test.clj | 500 +++++++++++++++++- common/src/app/common/media.cljc | 4 +- frontend/src/app/main/data/fonts.cljs | 61 ++- frontend/src/app/main/data/uploads.cljs | 11 +- frontend/src/app/main/ui/dashboard/fonts.cljs | 3 +- scripts/check-commit | 208 ++++++++ 12 files changed, 949 insertions(+), 103 deletions(-) create mode 100755 scripts/check-commit diff --git a/backend/src/app/config.clj b/backend/src/app/config.clj index b9aef15d04..1fde3aa13c 100644 --- a/backend/src/app/config.clj +++ b/backend/src/app/config.clj @@ -72,6 +72,7 @@ :telemetry-uri "https://telemetry.penpot.app/" :media-max-file-size (* 1024 1024 30) ; 30MiB + :font-max-file-size (* 1024 1024 30) ; 30MiB :ldap-user-query "(|(uid=:username)(mail=:username))" :ldap-attrs-username "uid" @@ -120,6 +121,7 @@ [:auto-file-snapshot-timeout {:optional true} ::ct/duration] [:media-max-file-size {:optional true} ::sm/int] + [:font-max-file-size {:optional true} ::sm/int] [:deletion-delay {:optional true} ::ct/duration] [:file-clean-delay {:optional true} ::ct/duration] [:telemetry-enabled {:optional true} ::sm/boolean] diff --git a/backend/src/app/media.clj b/backend/src/app/media.clj index 58151a800c..ff13c3572c 100644 --- a/backend/src/app/media.clj +++ b/backend/src/app/media.clj @@ -38,9 +38,6 @@ org.im4java.core.ConvertCmd org.im4java.core.IMOperation)) -(def default-max-file-size - (* 1024 1024 10)) ; 10 MiB - (def schema:upload [:map {:title "Upload"} [:filename :string] @@ -79,6 +76,20 @@ max-size))) upload)) +(defn validate-font-size! + "Validates that the font file `upload` does not exceed the configured + `:font-max-file-size` limit. Accepts the same map shape as + `validate-media-size!` — requires a `:size` key in bytes." + [upload] + (let [max-size (cf/get :font-max-file-size)] + (when (> (:size upload) max-size) + (ex/raise :type :restriction + :code :font-max-file-size-reached + :hint (str/ffmt "the uploaded font size % is greater than the maximum %" + (:size upload) + max-size))) + upload)) + (defmulti process :cmd) (defmulti process-error class) @@ -296,9 +307,7 @@ [{:keys [::http/client]} uri] (letfn [(parse-and-validate [{:keys [status headers] :as response}] (let [size (some-> (get headers "content-length") d/parse-integer) - mtype (get headers "content-type") - format (cm/mtype->format mtype) - max-size (cf/get :media-max-file-size default-max-file-size)] + mtype (get headers "content-type")] (when-not (<= 200 status 299) (ex/raise :type :validation @@ -310,19 +319,9 @@ :code :unknown-size :hint "seems like the url points to resource with unknown size")) - (when (> size max-size) - (ex/raise :type :validation - :code :file-too-large - :hint (str/ffmt "the file size % is greater than the maximum %" - size - default-max-file-size))) - - (when (nil? format) - (ex/raise :type :validation - :code :media-type-not-allowed - :hint "seems like the url points to an invalid media object")) - - {:size size :mtype mtype :format format}))] + (-> {:size size :mtype mtype} + (validate-media-type!) + (validate-media-size!))))] (let [{:keys [body] :as response} (try diff --git a/backend/src/app/rpc/commands/fonts.clj b/backend/src/app/rpc/commands/fonts.clj index 03c66a968f..6d86efd798 100644 --- a/backend/src/app/rpc/commands/fonts.clj +++ b/backend/src/app/rpc/commands/fonts.clj @@ -9,6 +9,8 @@ [app.binfile.common :as bfc] [app.common.data.macros :as dm] [app.common.exceptions :as ex] + [app.common.logging :as l] + [app.common.media :as cm] [app.common.schema :as sm] [app.common.time :as ct] [app.common.uuid :as uuid] @@ -21,6 +23,7 @@ [app.rpc :as-alias rpc] [app.rpc.climit :as-alias climit] [app.rpc.commands.files :as files] + [app.rpc.commands.media :as cmedia] [app.rpc.commands.projects :as projects] [app.rpc.commands.teams :as teams] [app.rpc.doc :as-alias doc] @@ -29,6 +32,8 @@ [app.storage :as sto] [app.storage.tmp :as tmp] [app.util.services :as sv] + [cuerdas.core :as str] + [datoteka.fs :as fs] [datoteka.io :as io]) (:import java.io.InputStream @@ -87,32 +92,92 @@ (declare create-font-variant) (def ^:private schema:create-font-variant - [:map {:title "create-font-variant"} - [:team-id ::sm/uuid] - [:data [:map-of ::sm/text [:or ::sm/bytes - [::sm/vec ::sm/bytes]]]] - [:font-id ::sm/uuid] - [:font-family ::sm/text] - [:font-weight [::sm/one-of {:format "number"} valid-weight]] - [:font-style [::sm/one-of {:format "string"} valid-style]]]) + [:and + [:map {:title "create-font-variant"} + [:team-id ::sm/uuid] + [:font-id ::sm/uuid] + [:font-family ::sm/text] + [:font-weight [::sm/one-of {:format "number"} valid-weight]] + [:font-style [::sm/one-of {:format "string"} valid-style]] + [:data {:optional true} [:map-of ::sm/text [:or ::sm/bytes [::sm/vec ::sm/bytes]]]] + [:uploads {:optional true} [:map-of ::sm/text ::sm/uuid]]] + [:fn {:error/message "one of :data or :uploads is required"} + (fn [{:keys [data uploads]}] + (or (seq data) (seq uploads)))]]) ;; FIXME: IMPORTANT: refactor this, we should not hold a whole db ;; connection around the font creation +(defn- prepare-font-data-from-uploads + "Assembles each chunked-upload session in `uploads` (a `{mtype → + session-id}` map) into a temp file, validates the media type and + size of every entry, and returns a `{mtype → path}` data map." + [cfg {:keys [uploads] :as params}] + (let [data (reduce-kv + (fn [acc mtype session-id] + (let [assembled (cmedia/assemble-chunks cfg session-id)] + (-> {:mtype mtype :size (:size assembled)} + (media/validate-media-type! cm/font-types) + (media/validate-font-size!)) + (assoc acc mtype (:path assembled)))) + {} + uploads)] + + (-> params + (assoc :data data) + (dissoc :uploads)))) + +(defn- prepare-font-data-from-legacy + "Validates the media type and size of every entry in the legacy + `:data` map (a `{mtype → bytes | [bytes]}` map). Normalises every + entry to a tempfile. Returns params with a normalised + `{mtype → path}` data map." + [{:keys [data] :as params}] + (let [data (reduce-kv + (fn [acc mtype content] + (let [tmp (tmp/tempfile :prefix "penpot.tempfont." :suffix "") + chunks (if (vector? content) content [content]) + streams (map io/input-stream chunks) + streams (Collections/enumeration streams)] + + ;; Generate the tempfile from all chunks + (with-open [^OutputStream output (io/output-stream tmp) + ^InputStream input (SequenceInputStream. streams)] + (io/copy input output)) + + ;; Validate + (-> {:mtype mtype :size (fs/size tmp)} + (media/validate-media-type! cm/font-types) + (media/validate-font-size!)) + + (assoc acc mtype tmp))) + {} + data)] + (assoc params :data data))) + (sv/defmethod ::create-font-variant + "Upload a font variant. Font data may be provided either as a + Transit-encoded `:data` map (keyed by mime-type) for small fonts, or + as an `:uploads` map (keyed by mime-type, values are upload-session + UUIDs from the chunked-upload API) for large fonts. Exactly one of + the two must be present." {::doc/added "1.18" + ::doc/changes ["2.16" "Add :uploads param for chunked upload support"] ::climit/id [[:process-font/by-profile ::rpc/profile-id] [:process-font/global]] ::webhooks/event? true ::sm/params schema:create-font-variant} - [cfg {:keys [::rpc/profile-id team-id] :as params}] + [cfg {:keys [::rpc/profile-id team-id uploads] :as params}] (db/tx-run! cfg (fn [{:keys [::db/conn] :as cfg}] (teams/check-edition-permissions! conn profile-id team-id) (quotes/check! cfg {::quotes/id ::quotes/font-variants-per-team ::quotes/profile-id profile-id ::quotes/team-id team-id}) - (create-font-variant cfg (assoc params :profile-id profile-id))))) + (let [params (if (some? uploads) + (prepare-font-data-from-uploads cfg params) + (prepare-font-data-from-legacy params))] + (create-font-variant cfg (assoc params :profile-id profile-id)))))) (defn create-font-variant [{:keys [::sto/storage ::db/conn]} {:keys [data] :as params}] @@ -127,23 +192,6 @@ :hint "invalid font upload, unable to generate missing font assets")) data)) - (process-chunks [chunks] - (let [tmp (tmp/tempfile :prefix "penpot.tempfont." :suffix "") - streams (map io/input-stream chunks) - streams (Collections/enumeration streams)] - (with-open [^OutputStream output (io/output-stream tmp) - ^InputStream input (SequenceInputStream. streams)] - (io/copy input output)) - tmp)) - - (join-chunks [data] - (reduce-kv (fn [data mtype content] - (if (vector? content) - (assoc data mtype (process-chunks content)) - data)) - data - data)) - (prepare-font [data mtype] (when-let [resource (get data mtype)] @@ -185,11 +233,38 @@ :otf-file-id (:id otf) :ttf-file-id (:id ttf)}))] - (let [data (join-chunks data) - data (generate-missing data) - assets (persist-fonts-files! data) - result (insert-font-variant! assets)] - (vary-meta result assoc ::audit/replace-props (update params :data (comp vec keys)))))) + (let [tpoint (ct/tpoint) + mtypes (vec (keys data)) + total-size (reduce-kv (fn [acc _ content] + (+ acc (if (bytes? content) + (alength ^bytes content) + (fs/size content)))) + 0 + data)] + + (l/dbg :hint "create-font-variant" + :step "init" + :font-family (:font-family params) + :font-weight (:font-weight params) + :font-style (:font-style params) + :mtypes (str/join mtypes ",") + :size total-size) + + (let [data (generate-missing data) + assets (persist-fonts-files! data) + result (insert-font-variant! assets) + elapsed (tpoint)] + + (l/dbg :hint "create-font-variant" + :step "end" + :font-family (:font-family params) + :font-weight (:font-weight params) + :font-style (:font-style params) + :mtypes (str/join mtypes ",") + :size total-size + :elapsed (ct/format-duration elapsed)) + + (vary-meta result assoc ::audit/replace-props (update params :data (comp vec keys))))))) ;; --- UPDATE FONT FAMILY diff --git a/backend/src/app/rpc/commands/media.clj b/backend/src/app/rpc/commands/media.clj index 5bea17d379..405bdb8117 100644 --- a/backend/src/app/rpc/commands/media.clj +++ b/backend/src/app/rpc/commands/media.clj @@ -8,6 +8,7 @@ (:require [app.common.data :as d] [app.common.exceptions :as ex] + [app.common.logging :as l] [app.common.schema :as sm] [app.common.time :as ct] [app.common.uuid :as uuid] @@ -58,8 +59,8 @@ (db/run! cfg (fn [{:keys [::db/conn] :as cfg}] ;; We get the minimal file for proper checking if ;; file is not already deleted - (let [_ (files/get-minimal-file conn file-id) - mobj (create-file-media-object cfg params)] + (let [_ (files/get-minimal-file conn file-id) + mobj (create-file-media-object cfg params)] (db/update! conn :file {:modified-at (ct/now) @@ -149,20 +150,49 @@ (defn- create-file-media-object [{:keys [::sto/storage ::db/conn] :as cfg} - {:keys [id file-id is-local name content]}] - (let [result (process-image content) - image (sto/put-object! storage (::image result)) - thumb (when-let [params (::thumb result)] - (sto/put-object! storage params))] + {:keys [id file-id is-local name content from-url? from-chunks?]}] - (db/exec-one! conn [sql:create-file-media-object - (or id (uuid/next)) - file-id is-local name - (:id image) - (:id thumb) - (:width result) - (:height result) - (:mtype result)]))) + (let [tpoint (ct/tpoint) + id (or id (uuid/next)) + origin (cond + from-url? + "url" + from-chunks? + "chunks" + :else + "direct")] + + (l/dbg :hint "create file-media-object" + :step "init" + :id (str id) + :mtype (:mtype content) + :size (:size content) + :path (str (:path content)) + :origin origin) + + (let [result (process-image content) + image (sto/put-object! storage (::image result)) + thumb (when-let [params (::thumb result)] + (sto/put-object! storage params)) + elapsed (tpoint)] + + (l/dbg :hint "create file-media-object" + :step "end" + :id (str id) + :mtype (:mtype content) + :size (:size content) + :path (str (:path content)) + :origin origin + :elapsed (ct/format-duration elapsed)) + + (db/exec-one! conn [sql:create-file-media-object + id + file-id is-local name + (:id image) + (:id thumb) + (:width result) + (:height result) + (:mtype result)])))) ;; --- Create File Media Object (from URL) @@ -198,6 +228,7 @@ [cfg {:keys [url name] :as params}] (let [content (media/download-image cfg url) params (-> params + (assoc :from-url? true) (assoc :content content) (assoc :name (d/nilv name "unknown")))] @@ -305,7 +336,14 @@ :hint "chunk index is out of range for this session" :session-id session-id :total-chunks (:total-chunks session) - :index index))) + :index index)) + + + (l/trc :hint "upload-chunk" + :session-id session-id + :chunk (str index "/" (:total-chunks session)) + :size (:size content) + :path (:path content))) (let [storage (sto/resolve cfg) data (sto/content (:path content))] @@ -399,14 +437,15 @@ (db/tx-run! cfg (fn [{:keys [::db/conn] :as cfg}] - (let [{:keys [path size]} (assemble-chunks cfg session-id) - content {:filename "upload" - :size size - :path path - :mtype mtype} - _ (media/validate-media-type! content) + (let [content (assemble-chunks cfg session-id) + content (-> content + (assoc :filename (str "upload:" name)) + (assoc :mtype mtype) + (media/validate-media-type!) + (media/validate-media-size!)) mobj (create-file-media-object cfg (assoc params - :id (or id (uuid/next)) + :id id + :from-chunks? true :content content))] (db/update! conn :file diff --git a/backend/src/app/rpc/commands/profile.clj b/backend/src/app/rpc/commands/profile.clj index efe99c4a70..a9e766bd9b 100644 --- a/backend/src/app/rpc/commands/profile.clj +++ b/backend/src/app/rpc/commands/profile.clj @@ -264,6 +264,7 @@ [cfg {:keys [::rpc/profile-id file] :as params}] ;; Validate incoming mime type (media/validate-media-type! file #{"image/jpeg" "image/png" "image/webp"}) + (media/validate-media-size! file) (update-profile-photo cfg (assoc params :profile-id profile-id))) (defn update-profile-photo diff --git a/backend/src/app/rpc/commands/teams.clj b/backend/src/app/rpc/commands/teams.clj index 79d2008be9..3ebd34fe00 100644 --- a/backend/src/app/rpc/commands/teams.clj +++ b/backend/src/app/rpc/commands/teams.clj @@ -827,6 +827,7 @@ ;; Validate incoming mime type (media/validate-media-type! file #{"image/jpeg" "image/png" "image/webp"}) + (media/validate-media-size! file) (update-team-photo cfg (assoc params :profile-id profile-id))) (defn update-team-photo diff --git a/backend/test/backend_tests/rpc_font_test.clj b/backend/test/backend_tests/rpc_font_test.clj index d68f657c59..dce0348e63 100644 --- a/backend/test/backend_tests/rpc_font_test.clj +++ b/backend/test/backend_tests/rpc_font_test.clj @@ -17,7 +17,9 @@ [clojure.test :as t] [datoteka.fs :as fs] [datoteka.io :as io] - [mockery.core :refer [with-mocks]])) + [mockery.core :refer [with-mocks]]) + (:import + java.io.RandomAccessFile)) (t/use-fixtures :once th/state-init) (t/use-fixtures :each th/database-reset) @@ -292,3 +294,499 @@ (let [error (:error out) error-data (ex-data error)] (t/is (th/ex-info? error)))))) + +;; ----------------------------------------------------------------------- +;; Helpers for chunked-upload font tests +;; ----------------------------------------------------------------------- + +(defn- split-bytes-into-chunks + "Splits `data` (byte array) into chunks of at most `chunk-size` bytes. + Returns a vector of byte arrays." + [^bytes data chunk-size] + (let [length (alength data)] + (loop [offset 0 chunks []] + (if (>= offset length) + chunks + (let [remaining (- length offset) + size (min chunk-size remaining) + buf (byte-array size)] + (System/arraycopy data offset buf 0 size) + (recur (+ offset size) (conj chunks buf))))))) + +(defn- make-chunk-mfile + "Writes `data` (byte array) to a tempfile and returns a map + compatible with the upload-chunk :content parameter." + [^bytes data mtype] + (let [tmp (fs/create-tempfile :dir "/tmp/penpot" :prefix "test-font-chunk-")] + (io/write* tmp data) + {:filename "chunk" + :path tmp + :mtype mtype + :size (alength data)})) + +(defn- create-upload-session! + "Creates an upload session for `prof` with `total-chunks`. Returns the session-id UUID." + [prof total-chunks] + (let [out (th/command! {::th/type :create-upload-session + ::rpc/profile-id (:id prof) + :total-chunks total-chunks})] + (t/is (nil? (:error out))) + (:session-id (:result out)))) + +(defn- upload-font-chunked! + "Splits `font-bytes` into chunks of `chunk-size` bytes, creates an upload + session, uploads all chunks, and returns the session-id UUID." + [prof ^bytes font-bytes mtype chunk-size] + (let [chunks (split-bytes-into-chunks font-bytes chunk-size) + session-id (create-upload-session! prof (count chunks))] + (doseq [[idx chunk-data] (map-indexed vector chunks)] + (let [mfile (make-chunk-mfile chunk-data mtype) + out (th/command! {::th/type :upload-chunk + ::rpc/profile-id (:id prof) + :session-id session-id + :index idx + :content mfile})] + (t/is (nil? (:error out))))) + session-id)) + +(defn- assert-font-variant-result + "Checks that a successful create-font-variant result has valid UUIDs and + the expected scalar fields matching `params`." + [params result] + (t/is (uuid? (:id result))) + (t/is (uuid? (:ttf-file-id result))) + (t/is (uuid? (:otf-file-id result))) + (t/is (uuid? (:woff1-file-id result))) + (t/are [k] (= (get params k) (get result k)) + :team-id + :font-id + :font-family + :font-weight + :font-style)) + +;; ----------------------------------------------------------------------- +;; Path 1 – Normal (direct :data bytes) +;; ----------------------------------------------------------------------- + +(t/deftest create-font-variant-normal-ttf + (with-mocks [mock {:target 'app.rpc.quotes/check! :return nil}] + (let [prof (th/create-profile* 1 {:is-active true}) + team-id (:default-team-id prof) + font-id (uuid/custom 10 10) + data (-> (io/resource "backend_tests/test_files/font-1.ttf") (io/read*)) + params {::th/type :create-font-variant + ::rpc/profile-id (:id prof) + :team-id team-id + :font-id font-id + :font-family "chunked-test" + :font-weight 400 + :font-style "normal" + :data {"font/ttf" data}} + out (th/command! params)] + (t/is (= 1 (:call-count @mock))) + (t/is (nil? (:error out))) + (assert-font-variant-result params (:result out))))) + +(t/deftest create-font-variant-normal-otf + (with-mocks [mock {:target 'app.rpc.quotes/check! :return nil}] + (let [prof (th/create-profile* 1 {:is-active true}) + team-id (:default-team-id prof) + font-id (uuid/custom 10 11) + data (-> (io/resource "backend_tests/test_files/font-1.otf") (io/read*)) + params {::th/type :create-font-variant + ::rpc/profile-id (:id prof) + :team-id team-id + :font-id font-id + :font-family "chunked-test" + :font-weight 400 + :font-style "normal" + :data {"font/otf" data}} + out (th/command! params)] + (t/is (= 1 (:call-count @mock))) + (t/is (nil? (:error out))) + (assert-font-variant-result params (:result out))))) + +(t/deftest create-font-variant-normal-woff + (with-mocks [mock {:target 'app.rpc.quotes/check! :return nil}] + (let [prof (th/create-profile* 1 {:is-active true}) + team-id (:default-team-id prof) + font-id (uuid/custom 10 12) + data (-> (io/resource "backend_tests/test_files/font-1.woff") (io/read*)) + params {::th/type :create-font-variant + ::rpc/profile-id (:id prof) + :team-id team-id + :font-id font-id + :font-family "chunked-test" + :font-weight 400 + :font-style "normal" + :data {"font/woff" data}} + out (th/command! params)] + (t/is (= 1 (:call-count @mock))) + (t/is (nil? (:error out))) + (assert-font-variant-result params (:result out))))) + +;; ----------------------------------------------------------------------- +;; Path 2 – Legacy chunking (:data with vector of byte-arrays per mtype) +;; ----------------------------------------------------------------------- + +(t/deftest create-font-variant-legacy-chunked-ttf + "Upload a TTF via the legacy :data path where each mtype value is a + vector of byte-array chunks (4 MiB each) instead of a single byte-array." + (with-mocks [mock {:target 'app.rpc.quotes/check! :return nil}] + (let [prof (th/create-profile* 1 {:is-active true}) + team-id (:default-team-id prof) + font-id (uuid/custom 10 20) + full-bytes (-> (io/resource "backend_tests/test_files/font-1.ttf") (io/read*)) + ;; Simulate 4 MiB legacy chunks – font is small so a single chunk suffices + chunks (split-bytes-into-chunks full-bytes (* 4 1024 1024)) + params {::th/type :create-font-variant + ::rpc/profile-id (:id prof) + :team-id team-id + :font-id font-id + :font-family "legacy-chunked" + :font-weight 700 + :font-style "italic" + :data {"font/ttf" (vec chunks)}} + out (th/command! params)] + (t/is (= 1 (:call-count @mock))) + (t/is (nil? (:error out))) + (assert-font-variant-result params (:result out))))) + +(t/deftest create-font-variant-legacy-chunked-woff + "Upload a WOFF via the legacy :data path with multiple sub-4 KiB chunks + to exercise the SequenceInputStream concatenation path." + (with-mocks [mock {:target 'app.rpc.quotes/check! :return nil}] + (let [prof (th/create-profile* 1 {:is-active true}) + team-id (:default-team-id prof) + font-id (uuid/custom 10 21) + full-bytes (-> (io/resource "backend_tests/test_files/font-1.woff") (io/read*)) + ;; Split into small chunks to exercise the SequenceInputStream path + chunks (split-bytes-into-chunks full-bytes 512) + params {::th/type :create-font-variant + ::rpc/profile-id (:id prof) + :team-id team-id + :font-id font-id + :font-family "legacy-chunked-woff" + :font-weight 400 + :font-style "normal" + :data {"font/woff" (vec chunks)}} + out (th/command! params)] + (t/is (= 1 (:call-count @mock))) + (t/is (nil? (:error out))) + (assert-font-variant-result params (:result out))))) + +;; ----------------------------------------------------------------------- +;; Path 3 – New standardized chunked upload (:uploads map) +;; ----------------------------------------------------------------------- + +(t/deftest create-font-variant-chunked-upload-ttf + "Upload a TTF via the new :uploads path (chunked-upload API)." + (with-mocks [mock {:target 'app.rpc.quotes/check! :return nil}] + (let [prof (th/create-profile* 1 {:is-active true}) + team-id (:default-team-id prof) + font-id (uuid/custom 10 30) + font-bytes (-> (io/resource "backend_tests/test_files/font-1.ttf") (io/read*)) + session-id (upload-font-chunked! prof font-bytes "font/ttf" (* 4 1024 1024)) + params {::th/type :create-font-variant + ::rpc/profile-id (:id prof) + :team-id team-id + :font-id font-id + :font-family "new-chunked" + :font-weight 400 + :font-style "normal" + :uploads {"font/ttf" session-id}} + out (th/command! params)] + ;; quotes/check! is called at least once (for the font-variant quota) plus + ;; once during session creation — assert it fired at least once. + (t/is (>= (:call-count @mock) 1)) + (t/is (nil? (:error out))) + (assert-font-variant-result params (:result out))))) + +(t/deftest create-font-variant-chunked-upload-otf + "Upload an OTF via the new :uploads path." + (with-mocks [mock {:target 'app.rpc.quotes/check! :return nil}] + (let [prof (th/create-profile* 1 {:is-active true}) + team-id (:default-team-id prof) + font-id (uuid/custom 10 31) + font-bytes (-> (io/resource "backend_tests/test_files/font-1.otf") (io/read*)) + session-id (upload-font-chunked! prof font-bytes "font/otf" (* 4 1024 1024)) + params {::th/type :create-font-variant + ::rpc/profile-id (:id prof) + :team-id team-id + :font-id font-id + :font-family "new-chunked-otf" + :font-weight 400 + :font-style "normal" + :uploads {"font/otf" session-id}} + out (th/command! params)] + (t/is (>= (:call-count @mock) 1)) + (t/is (nil? (:error out))) + (assert-font-variant-result params (:result out))))) + +(t/deftest create-font-variant-chunked-upload-woff + "Upload a WOFF via the new :uploads path." + (with-mocks [mock {:target 'app.rpc.quotes/check! :return nil}] + (let [prof (th/create-profile* 1 {:is-active true}) + team-id (:default-team-id prof) + font-id (uuid/custom 10 32) + font-bytes (-> (io/resource "backend_tests/test_files/font-1.woff") (io/read*)) + session-id (upload-font-chunked! prof font-bytes "font/woff" (* 4 1024 1024)) + params {::th/type :create-font-variant + ::rpc/profile-id (:id prof) + :team-id team-id + :font-id font-id + :font-family "new-chunked-woff" + :font-weight 400 + :font-style "normal" + :uploads {"font/woff" session-id}} + out (th/command! params)] + (t/is (>= (:call-count @mock) 1)) + (t/is (nil? (:error out))) + (assert-font-variant-result params (:result out))))) + +(t/deftest create-font-variant-chunked-upload-multi-chunk + "Upload a WOFF split into many small chunks to exercise multi-chunk assembly." + (with-mocks [mock {:target 'app.rpc.quotes/check! :return nil}] + (let [prof (th/create-profile* 1 {:is-active true}) + team-id (:default-team-id prof) + font-id (uuid/custom 10 33) + font-bytes (-> (io/resource "backend_tests/test_files/font-1.woff") (io/read*)) + ;; Use a chunk-size smaller than 4 MiB to force multiple chunks while + ;; staying within the 20-chunk-per-session quota limit (29836 / 2000 = ~15 chunks). + session-id (upload-font-chunked! prof font-bytes "font/woff" 2000) + params {::th/type :create-font-variant + ::rpc/profile-id (:id prof) + :team-id team-id + :font-id font-id + :font-family "multi-chunk-woff" + :font-weight 400 + :font-style "normal" + :uploads {"font/woff" session-id}} + out (th/command! params)] + (t/is (>= (:call-count @mock) 1)) + (t/is (nil? (:error out))) + (assert-font-variant-result params (:result out))))) + +;; ----------------------------------------------------------------------- +;; Error cases +;; ----------------------------------------------------------------------- + +(t/deftest create-font-variant-missing-data-and-uploads + "Neither :data nor :uploads is present — schema validation must reject it." + (let [prof (th/create-profile* 1 {:is-active true}) + team-id (:default-team-id prof) + font-id (uuid/custom 10 40) + params {::th/type :create-font-variant + ::rpc/profile-id (:id prof) + :team-id team-id + :font-id font-id + :font-family "bad" + :font-weight 400 + :font-style "normal"} + out (th/command! params)] + (t/is (some? (:error out))) + (t/is (= :validation (-> out :error ex-data :type))))) + +(t/deftest create-font-variant-chunked-upload-missing-chunks + "When only some chunks are uploaded the assembly step must fail." + (with-mocks [_mock {:target 'app.rpc.quotes/check! :return nil}] + (let [prof (th/create-profile* 1 {:is-active true}) + team-id (:default-team-id prof) + font-id (uuid/custom 10 41) + font-bytes (-> (io/resource "backend_tests/test_files/font-1.ttf") (io/read*)) + ;; 5000-byte chunks → 68640/5000 = 14 chunks; declare 15 but only upload 13 + chunks (split-bytes-into-chunks font-bytes 5000) + ;; Declare one extra chunk so assembly will fail (not all chunks present) + session-id (create-upload-session! prof (inc (count chunks)))] + + ;; Upload all real chunks except the last one (omit it so the session is incomplete) + (doseq [[idx chunk-data] (map-indexed vector (butlast chunks))] + (let [mfile (make-chunk-mfile chunk-data "font/ttf") + out (th/command! {::th/type :upload-chunk + ::rpc/profile-id (:id prof) + :session-id session-id + :index idx + :content mfile})] + (t/is (nil? (:error out))))) + + (let [out (th/command! {::th/type :create-font-variant + ::rpc/profile-id (:id prof) + :team-id team-id + :font-id font-id + :font-family "missing-chunks" + :font-weight 400 + :font-style "normal" + :uploads {"font/ttf" session-id}})] + (t/is (some? (:error out))))))) + +(t/deftest create-font-variant-chunked-upload-invalid-session + "Passing a non-existent session-id must fail at assembly time." + (with-mocks [_mock {:target 'app.rpc.quotes/check! :return nil}] + (let [prof (th/create-profile* 1 {:is-active true}) + team-id (:default-team-id prof) + font-id (uuid/custom 10 42) + out (th/command! {::th/type :create-font-variant + ::rpc/profile-id (:id prof) + :team-id team-id + :font-id font-id + :font-family "bad-session" + :font-weight 400 + :font-style "normal" + :uploads {"font/ttf" (uuid/next)}})] + (t/is (some? (:error out)))))) + +;; ----------------------------------------------------------------------- +;; Font size validation tests +;; ----------------------------------------------------------------------- + +(t/deftest create-font-variant-size-exceeded-normal + "Direct :data upload exceeding font-max-file-size must be rejected." + (with-mocks [_mock {:target 'app.rpc.quotes/check! :return nil}] + (with-redefs [app.config/config (assoc app.config/config :font-max-file-size 1)] + (let [prof (th/create-profile* 1 {:is-active true}) + team-id (:default-team-id prof) + font-id (uuid/custom 10 50) + data (-> (io/resource "backend_tests/test_files/font-1.ttf") (io/read*)) + params {::th/type :create-font-variant + ::rpc/profile-id (:id prof) + :team-id team-id + :font-id font-id + :font-family "size-exceeded" + :font-weight 400 + :font-style "normal" + :data {"font/ttf" data}} + out (th/command! params)] + (t/is (some? (:error out))) + (t/is (= :restriction (-> out :error ex-data :type))) + (t/is (= :font-max-file-size-reached (-> out :error ex-data :code))))))) + +(t/deftest create-font-variant-size-exceeded-legacy-chunked + "Legacy :data chunk-vector upload exceeding font-max-file-size must be rejected." + (with-mocks [_mock {:target 'app.rpc.quotes/check! :return nil}] + (with-redefs [app.config/config (assoc app.config/config :font-max-file-size 1)] + (let [prof (th/create-profile* 1 {:is-active true}) + team-id (:default-team-id prof) + font-id (uuid/custom 10 51) + full-bytes (-> (io/resource "backend_tests/test_files/font-1.woff") (io/read*)) + chunks (split-bytes-into-chunks full-bytes (* 4 1024 1024)) + params {::th/type :create-font-variant + ::rpc/profile-id (:id prof) + :team-id team-id + :font-id font-id + :font-family "size-exceeded-legacy" + :font-weight 400 + :font-style "normal" + :data {"font/woff" (vec chunks)}} + out (th/command! params)] + (t/is (some? (:error out))) + (t/is (= :restriction (-> out :error ex-data :type))) + (t/is (= :font-max-file-size-reached (-> out :error ex-data :code))))))) + +(t/deftest create-font-variant-size-exceeded-chunked-upload + "New :uploads path exceeding font-max-file-size must be rejected after assembly." + (with-mocks [_mock {:target 'app.rpc.quotes/check! :return nil}] + (let [prof (th/create-profile* 1 {:is-active true}) + team-id (:default-team-id prof) + font-id (uuid/custom 10 52) + font-bytes (-> (io/resource "backend_tests/test_files/font-1.ttf") (io/read*)) + session-id (upload-font-chunked! prof font-bytes "font/ttf" (* 4 1024 1024))] + (with-redefs [app.config/config (assoc app.config/config :font-max-file-size 1)] + (let [out (th/command! {::th/type :create-font-variant + ::rpc/profile-id (:id prof) + :team-id team-id + :font-id font-id + :font-family "size-exceeded-chunked" + :font-weight 400 + :font-style "normal" + :uploads {"font/ttf" session-id}})] + (t/is (some? (:error out))) + (t/is (= :restriction (-> out :error ex-data :type))) + (t/is (= :font-max-file-size-reached (-> out :error ex-data :code)))))))) + +(t/deftest create-font-variant-size-within-limit + "Upload exactly at the limit must succeed." + (with-mocks [_mock {:target 'app.rpc.quotes/check! :return nil}] + (let [prof (th/create-profile* 1 {:is-active true}) + team-id (:default-team-id prof) + font-id (uuid/custom 10 53) + font-bytes (-> (io/resource "backend_tests/test_files/font-1.ttf") (io/read*)) + font-size (alength ^bytes font-bytes)] + (with-redefs [app.config/config (assoc app.config/config :font-max-file-size font-size)] + (let [params {::th/type :create-font-variant + ::rpc/profile-id (:id prof) + :team-id team-id + :font-id font-id + :font-family "size-at-limit" + :font-weight 400 + :font-style "normal" + :data {"font/ttf" font-bytes}} + out (th/command! params)] + (t/is (nil? (:error out))) + (assert-font-variant-result params (:result out))))))) + +;; ----------------------------------------------------------------------- +;; Font media-type validation tests +;; ----------------------------------------------------------------------- + +(t/deftest create-font-variant-invalid-type-normal + "Direct :data upload with a disallowed mtype must be rejected." + (with-mocks [_mock {:target 'app.rpc.quotes/check! :return nil}] + (let [prof (th/create-profile* 1 {:is-active true}) + team-id (:default-team-id prof) + font-id (uuid/custom 10 60) + data (-> (io/resource "backend_tests/test_files/font-1.ttf") (io/read*)) + params {::th/type :create-font-variant + ::rpc/profile-id (:id prof) + :team-id team-id + :font-id font-id + :font-family "invalid-type" + :font-weight 400 + :font-style "normal" + :data {"application/octet-stream" data}} + out (th/command! params)] + (t/is (some? (:error out))) + (t/is (= :validation (-> out :error ex-data :type))) + (t/is (= :media-type-not-allowed (-> out :error ex-data :code)))))) + +(t/deftest create-font-variant-invalid-type-legacy-chunked + "Legacy :data chunk-vector upload with a disallowed mtype must be rejected." + (with-mocks [_mock {:target 'app.rpc.quotes/check! :return nil}] + (let [prof (th/create-profile* 1 {:is-active true}) + team-id (:default-team-id prof) + font-id (uuid/custom 10 61) + full-bytes (-> (io/resource "backend_tests/test_files/font-1.woff") (io/read*)) + chunks (split-bytes-into-chunks full-bytes (* 4 1024 1024)) + params {::th/type :create-font-variant + ::rpc/profile-id (:id prof) + :team-id team-id + :font-id font-id + :font-family "invalid-type-legacy" + :font-weight 400 + :font-style "normal" + :data {"image/png" (vec chunks)}} + out (th/command! params)] + (t/is (some? (:error out))) + (t/is (= :validation (-> out :error ex-data :type))) + (t/is (= :media-type-not-allowed (-> out :error ex-data :code)))))) + +(t/deftest create-font-variant-invalid-type-chunked-upload + "New :uploads path with a disallowed mtype must be rejected after assembly." + (with-mocks [_mock {:target 'app.rpc.quotes/check! :return nil}] + (let [prof (th/create-profile* 1 {:is-active true}) + team-id (:default-team-id prof) + font-id (uuid/custom 10 62) + font-bytes (-> (io/resource "backend_tests/test_files/font-1.ttf") (io/read*)) + ;; Upload the bytes under a valid session but lie about the mtype + ;; when calling create-font-variant. + session-id (upload-font-chunked! prof font-bytes "font/ttf" (* 4 1024 1024)) + out (th/command! {::th/type :create-font-variant + ::rpc/profile-id (:id prof) + :team-id team-id + :font-id font-id + :font-family "invalid-type-chunked" + :font-weight 400 + :font-style "normal" + :uploads {"image/jpeg" session-id}})] + (t/is (some? (:error out))) + (t/is (= :validation (-> out :error ex-data :type))) + (t/is (= :media-type-not-allowed (-> out :error ex-data :code)))))) diff --git a/common/src/app/common/media.cljc b/common/src/app/common/media.cljc index 4cdf8488ce..721bea2dc6 100644 --- a/common/src/app/common/media.cljc +++ b/common/src/app/common/media.cljc @@ -12,8 +12,8 @@ (def font-types #{"font/ttf" "font/woff" - "font/otf" - "font/opentype"}) + "font/woff2" + "font/otf"}) (def image-types #{"image/jpeg" diff --git a/frontend/src/app/main/data/fonts.cljs b/frontend/src/app/main/data/fonts.cljs index 63c7e61bf5..0864247f5c 100644 --- a/frontend/src/app/main/data/fonts.cljs +++ b/frontend/src/app/main/data/fonts.cljs @@ -14,6 +14,7 @@ [app.common.uuid :as uuid] [app.main.data.event :as ev] [app.main.data.notifications :as ntf] + [app.main.data.uploads :as uploads] [app.main.fonts :as fonts] [app.main.repo :as rp] [app.main.store :as st] @@ -24,24 +25,14 @@ [cuerdas.core :as str] [potok.v2.core :as ptk])) -(def ^:const default-chunk-size - (* 1024 1024 4)) ;; 4MiB - -(defn- chunk-array - [data chunk-size] - (let [total-size (alength data)] - (loop [offset 0 - chunks []] - (if (< offset total-size) - (let [end (min (+ offset chunk-size) total-size) - chunk (.subarray ^js data offset end)] - (recur end (conj chunks chunk))) - chunks)))) - ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; General purpose events & IMPL ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +(def ^:private font-upload-chunk-size + "Size in bytes of each chunk when uploading font files (10 MiB)." + (* 1024 1024 10)) + (defn fonts-fetched [fonts] (letfn [;; Prepare font to the internal font database format. @@ -94,9 +85,44 @@ (->> (rp/cmd! :get-font-variants {:team-id team-id}) (rx/map fonts-fetched))))) +(defn upload-font-variant + "Uploads a single font variant item using the chunked upload API. + + For each mime-type in `data`, creates a Blob and uploads it via the + session-based chunked upload. Once all sessions are created, calls + `create-font-variant` with the resulting `:uploads` map so the server + can assemble the chunks and materialise the final font-variant record. + + Returns an observable that emits the created font-variant." + [{:keys [data team-id font-id font-family font-weight font-style] :as _item}] + ;; Upload each mtype as a separate chunked session in parallel, collect + ;; all [mtype session-id] pairs, then call create-font-variant with :uploads. + (->> (rx/from (seq data)) + (rx/mapcat (fn [[mtype buffer]] + (let [blob (js/Blob. #js [buffer] #js {:type mtype})] + (->> (uploads/upload-blob-chunked blob :chunk-size font-upload-chunk-size) + (rx/map (fn [{:keys [session-id]}] + [mtype session-id])))))) + (rx/reduce (fn [acc [mtype session-id]] + (assoc acc mtype session-id)) + {}) + (rx/mapcat (fn [uploads] + (rp/cmd! :create-font-variant + {:team-id team-id + :font-id font-id + :font-family font-family + :font-weight font-weight + :font-style font-style + :uploads uploads}))))) + (defn process-upload "Given a seq of blobs and the team id, creates a ready-to-use fonts - map with temporal ID's associated to each font entry." + map with temporal ID's associated to each font entry. + + Each font entry's `:data` is a map of `{mtype -> ArrayBuffer}`. The + raw `ArrayBuffer` is kept as-is so that `upload-font-variant` can + wrap it in a `Blob` and hand it directly to `upload-blob-chunked` + without any intermediate client-side chunking." [blobs team-id] (letfn [(prepare [{:keys [font type name data] :as params}] (let [family (or (.getEnglishName ^js font "preferredFamily") @@ -130,9 +156,8 @@ (not= hhea-descender win-descent) (and f-selection (or (not= hhea-ascender os2-ascent) - (not= hhea-descender os2-descent)))) - data (js/Uint8Array. data)] - {:content {:data (chunk-array data default-chunk-size) + (not= hhea-descender os2-descent))))] + {:content {:data data :name name :type type} :font-family (or family "") diff --git a/frontend/src/app/main/data/uploads.cljs b/frontend/src/app/main/data/uploads.cljs index 06e87f02d9..2721cd99cb 100644 --- a/frontend/src/app/main/data/uploads.cljs +++ b/frontend/src/app/main/data/uploads.cljs @@ -24,10 +24,6 @@ [app.main.repo :as rp] [beicon.v2.core :as rx])) -;; Size of each upload chunk in bytes. Reads the penpotUploadChunkSize global -;; variable at startup; defaults to 25 MiB (overridden in production). -(def ^:private chunk-size cf/upload-chunk-size) - (def ^:private max-parallel-chunk-uploads "Maximum number of chunk upload requests that may be in-flight at the same time within a single chunked upload session." @@ -44,8 +40,11 @@ Returns an observable that emits exactly one map: `{:session-id }` - The caller is responsible for the final step (assemble / import)." - [blob] + The caller is responsible for the final step (assemble / import). + + The optional `opts` map accepts: + `:chunk-size` – size in bytes of each chunk (default: `cf/upload-chunk-size`, 25 MiB)." + [blob & {:keys [chunk-size] :or {chunk-size cf/upload-chunk-size}}] (let [total-size (.-size blob) total-chunks (js/Math.ceil (/ total-size chunk-size))] (->> (rp/cmd! :create-upload-session diff --git a/frontend/src/app/main/ui/dashboard/fonts.cljs b/frontend/src/app/main/ui/dashboard/fonts.cljs index f84d694893..92942b9c52 100644 --- a/frontend/src/app/main/ui/dashboard/fonts.cljs +++ b/frontend/src/app/main/ui/dashboard/fonts.cljs @@ -14,7 +14,6 @@ [app.main.data.fonts :as df] [app.main.data.modal :as modal] [app.main.data.notifications :as ntf] - [app.main.repo :as rp] [app.main.store :as st] [app.main.ui.components.context-menu-a11y :refer [context-menu*]] [app.main.ui.components.file-uploader :refer [file-uploader]] @@ -110,7 +109,7 @@ (mf/use-fn (fn [{:keys [id] :as item}] (swap! uploading* conj id) - (->> (rp/cmd! :create-font-variant item) + (->> (df/upload-font-variant item) (rx/delay-at-least 2000) (rx/subs! (fn [font] (swap! fonts* dissoc id) diff --git a/scripts/check-commit b/scripts/check-commit new file mode 100755 index 0000000000..478aee5156 --- /dev/null +++ b/scripts/check-commit @@ -0,0 +1,208 @@ +#!/usr/bin/env python3 +""" +Check commit messages against Penpot's commit guidelines. + +Validates commit messages using the rules defined in: + - .github/workflows/commit-checker.yml (regex pattern) + - CONTRIBUTING.md (formatting rules, subject length, DCO) + +By default, checks HEAD. Use --commit to specify a different commit. + +Usage: + ./scripts/check-commit + ./scripts/check-commit --commit HEAD~1 + ./scripts/check-commit -c abc1234 +""" + +import argparse +import re +import subprocess +import sys + +# ── Emoji list ─────────────────────────────────────────────────────────────── +# Combined from commit-checker.yml AND CONTRIBUTING.md +VALID_EMOJIS = ( + "lipstick|globe_with_meridians|wrench|books|" + "arrow_up|arrow_down|zap|ambulance|construction|" + "boom|fire|whale|bug|sparkles|paperclip|tada|" + "recycle|rewind|construction_worker|rocket" +) + +# ── Regex from .github/workflows/commit-checker.yml ────────────────────────── +# Matches: +# 1) ":emoji: " +# 2) "Merge|Revert|Reapply ... without trailing dot" +COMMIT_PATTERN = re.compile( + r"^((:(" + VALID_EMOJIS + r"):\s[A-Z].*[^.]))$" +) + +MERGE_PATTERN = re.compile(r"^(Merge|Revert|Reapply).+[^.]$") + +# ═══════════════════════════════════════════════════════════════════════════════ +# Helpers +# ═══════════════════════════════════════════════════════════════════════════════ + +def run_git(args): + """Run a git command and return (returncode, stdout, stderr).""" + try: + result = subprocess.run( + ["git"] + args, + capture_output=True, + text=True, + check=False, + ) + return result.returncode, result.stdout, result.stderr + except FileNotFoundError: + print("ERROR: git not found. Is it installed?", file=sys.stderr) + sys.exit(1) + + +def get_commit_message(commit_ref): + """Return the full commit message for *commit_ref*.""" + rc, out, err = run_git(["log", "--format=%B", "-n", "1", commit_ref]) + if rc != 0: + print(f"ERROR: could not read commit {commit_ref}: {err.strip()}", file=sys.stderr) + sys.exit(1) + if not out.strip(): + print(f"ERROR: commit {commit_ref} has no message", file=sys.stderr) + sys.exit(1) + return out.rstrip("\n") + + +# ═══════════════════════════════════════════════════════════════════════════════ +# Validators +# ═══════════════════════════════════════════════════════════════════════════════ + +def check_regex(message): + """Check the commit message against the CI regex pattern.""" + # Normalise: strip trailing newlines for single-line matching + first_line = message.split("\n")[0] + + if MERGE_PATTERN.match(first_line): + return True, None + + if COMMIT_PATTERN.match(first_line): + return True, None + + return False, ( + "Commit subject must match one of:\n" + " :emoji: \n" + " Merge|Revert|Reapply \n" + f"Got: {first_line!r}" + ) + + +def check_subject_length(message): + """Subject line must be ≤ 90 characters.""" + first_line = message.split("\n")[0] + if len(first_line) > 90: + return False, ( + f"Subject line exceeds 90 characters ({len(first_line)} chars):\n" + f" {first_line}" + ) + return True, None + + +def check_subject_no_trailing_dot(message): + """Subject line must not end with a period ('.').""" + first_line = message.split("\n")[0] + if first_line.endswith("."): + return False, ( + "Subject line must not end with a period:\n" + f" {first_line}" + ) + return True, None + + +def check_subject_capitalized(message): + """Subject must be capitalized, but only if it's a regular commit (not Merge/Revert/Reapply).""" + first_line = message.split("\n")[0] + + # Skip check for Merge/Revert/Reapply commits + if MERGE_PATTERN.match(first_line): + return True, None + + # Strip emoji prefix before checking capitalization + emoji_match = re.match(r"^:([a-z_]+):\s+(.*)", first_line) + if emoji_match: + rest = emoji_match.group(2) + else: + rest = first_line + + if rest and not rest[0].isupper(): + return False, ( + "Subject line must start with a capital letter " + "(after the emoji prefix):\n" + f" {first_line}" + ) + return True, None + + +def check_body_blank_line(message): + """If a body exists, there must be a blank line between subject and body.""" + lines = message.split("\n") + if len(lines) >= 3 and lines[1] != "": + return False, ( + "A blank line must separate the subject from the body." + ) + return True, None + + +def check_signed_off_by(message): + """Check for the DCO Signed-off-by line (required for code changes).""" + if "Signed-off-by:" not in message: + return False, ( + "Missing 'Signed-off-by:' line in the commit footer.\n" + " Add it with 'git commit -s' or append it manually:\n" + " Signed-off-by: Your Real Name " + ) + return True, None + + +# ═══════════════════════════════════════════════════════════════════════════════ + +def main(): + parser = argparse.ArgumentParser( + description="Check a commit message against Penpot commit guidelines." + ) + parser.add_argument( + "-c", "--commit", + default="HEAD", + help="Commit to check (default: HEAD)", + ) + args = parser.parse_args() + + commit_ref = args.commit + message = get_commit_message(commit_ref) + + print(f"Checking commit {commit_ref} ...\n") + + validators = [ + ("Regex pattern", check_regex), + ("Subject ≤ 90 chars", check_subject_length), + ("No trailing period in subject", check_subject_no_trailing_dot), + ("Subject capitalized", check_subject_capitalized), + ("Blank line after subject", check_body_blank_line), + ] + + all_ok = True + + for name, validator in validators: + ok, error_msg = validator(message) + status = "✓" if ok else "✗" + print(f" [{status}] {name}") + if not ok: + all_ok = False + print(f" {error_msg}", file=sys.stderr) + + print() + if all_ok: + print("All checks passed.") + sys.exit(0) + else: + print("Some checks FAILED. See messages above.", file=sys.stderr) + sys.exit(1) + + +if __name__ == "__main__": + main() From eb22c59e5a9c5f86091169e04bdd99c0446c00e4 Mon Sep 17 00:00:00 2001 From: Yamila Moreno Date: Tue, 12 May 2026 18:31:13 +0200 Subject: [PATCH 13/36] :whale: Add penpot-mcp service to official docker-compose.yml --- docker/images/docker-compose.yaml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docker/images/docker-compose.yaml b/docker/images/docker-compose.yaml index b4ecc2b41d..acdde97884 100644 --- a/docker/images/docker-compose.yaml +++ b/docker/images/docker-compose.yaml @@ -176,6 +176,12 @@ services: PENPOT_SMTP_TLS: false PENPOT_SMTP_SSL: false + penpot-mcp: + image: "penpotapp/mcp:${PENPOT_VERSION:-latest}" + restart: always + networks: + - penpot + penpot-exporter: image: "penpotapp/exporter:${PENPOT_VERSION:-latest}" restart: always From 02c3d2c27ce95169c36a8acec191ceaf217274d1 Mon Sep 17 00:00:00 2001 From: Yamila Moreno Date: Tue, 12 May 2026 18:38:17 +0200 Subject: [PATCH 14/36] :whale: Add mcp server to release workflow --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 053dd3ff0e..c0890324c8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -63,7 +63,7 @@ jobs: echo "$PUB_DOCKER_PASSWORD" | skopeo login --username "$PUB_DOCKER_USERNAME" --password-stdin docker.io - IMAGES=("frontend" "backend" "exporter" "storybook") + IMAGES=("frontend" "backend" "exporter" "mcp" "storybook") SHORT_TAG=${TAG%.*} for image in "${IMAGES[@]}"; do From e5c99231da06942e312323fb5ca4ed35caa01265 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Tue, 12 May 2026 18:43:08 +0200 Subject: [PATCH 15/36] :books: Update changelog --- CHANGES.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/CHANGES.md b/CHANGES.md index 70ce402a2d..089847821e 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,5 +1,12 @@ # CHANGELOG +## 2.15.1 + +### :sparkles: New features & Enhancements + +- Add support for chunked uploading of fonts [Github #9560](https://github.com/penpot/penpot/issues/9560) + + ## 2.15.0 ### :sparkles: New features & Enhancements From db7fcfcb1ac3a42702baabbf8aa23751ec024abe Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Tue, 12 May 2026 19:06:25 +0200 Subject: [PATCH 16/36] :bug: Fix metrics for rpc methods --- backend/src/app/main.clj | 18 ++++++------------ 1 file changed, 6 insertions(+), 12 deletions(-) diff --git a/backend/src/app/main.clj b/backend/src/app/main.clj index 7dac2b59f6..018e6e301d 100644 --- a/backend/src/app/main.clj +++ b/backend/src/app/main.clj @@ -61,21 +61,15 @@ ::mdef/help "A total number of bytes processed by update-file." ::mdef/type :counter} - :rpc-mutation-timing - {::mdef/name "penpot_rpc_mutation_timing" - ::mdef/help "RPC mutation method call timing." + :rpc-main-timing + {::mdef/name "penpot_rpc_main_timing" + ::mdef/help "RPC command method call timing for main" ::mdef/labels ["name"] ::mdef/type :histogram} - :rpc-command-timing - {::mdef/name "penpot_rpc_command_timing" - ::mdef/help "RPC command method call timing." - ::mdef/labels ["name"] - ::mdef/type :histogram} - - :rpc-query-timing - {::mdef/name "penpot_rpc_query_timing" - ::mdef/help "RPC query method call timing." + :rpc-management-timing + {::mdef/name "penpot_rpc_management_timing" + ::mdef/help "RPC command method call timing for management." ::mdef/labels ["name"] ::mdef/type :histogram} From 4289cad9ab3c47a2240b6d946076798bd40ddbd0 Mon Sep 17 00:00:00 2001 From: Yamila Moreno Date: Tue, 12 May 2026 23:28:20 +0200 Subject: [PATCH 17/36] :whale: Improve nginx configuration for MCP server (#9565) --- docker/images/files/nginx-entrypoint.sh | 5 +++-- docker/images/files/nginx.conf.template | 6 +++--- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/docker/images/files/nginx-entrypoint.sh b/docker/images/files/nginx-entrypoint.sh index 9ce2b9261d..e791ac64f0 100644 --- a/docker/images/files/nginx-entrypoint.sh +++ b/docker/images/files/nginx-entrypoint.sh @@ -34,9 +34,10 @@ update_flags /var/www/app/js/config.js export PENPOT_BACKEND_URI=${PENPOT_BACKEND_URI:-http://penpot-backend:6060} export PENPOT_EXPORTER_URI=${PENPOT_EXPORTER_URI:-http://penpot-exporter:6061} export PENPOT_NITRATE_URI=${PENPOT_NITRATE_URI:-http://penpot-nitrate:3000} -export PENPOT_MCP_URI=${PENPOT_MCP_URI:-http://penpot-mcp} +export PENPOT_MCP_URI=${PENPOT_MCP_URI:-http://penpot-mcp:4401} +export PENPOT_MCP_URI_WS=${PENPOT_MCP_URI_WS:-http://penpot-mcp:4402} export PENPOT_HTTP_SERVER_MAX_BODY_SIZE=${PENPOT_HTTP_SERVER_MAX_BODY_SIZE:-367001600} # Default to 350MiB -envsubst "\$PENPOT_BACKEND_URI,\$PENPOT_EXPORTER_URI,\$PENPOT_NITRATE_URI,\$PENPOT_MCP_URI,\$PENPOT_HTTP_SERVER_MAX_BODY_SIZE" \ +envsubst "\$PENPOT_BACKEND_URI,\$PENPOT_EXPORTER_URI,\$PENPOT_NITRATE_URI,\$PENPOT_MCP_URI,\$PENPOT_MCP_URI_WS,\$PENPOT_HTTP_SERVER_MAX_BODY_SIZE" \ < /tmp/nginx.conf.template > /etc/nginx/nginx.conf PENPOT_DEFAULT_INTERNAL_RESOLVER="$(awk 'BEGIN{ORS=" "} $1=="nameserver" { sub(/%.*$/,"",$2); print ($2 ~ ":")? "["$2"]": $2}' /etc/resolv.conf)" diff --git a/docker/images/files/nginx.conf.template b/docker/images/files/nginx.conf.template index f365cbd512..c182856e6b 100644 --- a/docker/images/files/nginx.conf.template +++ b/docker/images/files/nginx.conf.template @@ -142,17 +142,17 @@ http { location /mcp/ws { proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection 'upgrade'; - proxy_pass $PENPOT_MCP_URI:4402; + proxy_pass $PENPOT_MCP_URI_WS; proxy_http_version 1.1; } location /mcp/stream { - proxy_pass $PENPOT_MCP_URI:4401/mcp; + proxy_pass $PENPOT_MCP_URI/mcp; proxy_http_version 1.1; } location /mcp/sse { - proxy_pass $PENPOT_MCP_URI:4401/sse; + proxy_pass $PENPOT_MCP_URI/sse; proxy_http_version 1.1; } From 382efe34490fbd33fd74a1c4144e360fee22ab5d Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Tue, 12 May 2026 23:33:39 +0200 Subject: [PATCH 18/36] :books: Update changelog --- CHANGES.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/CHANGES.md b/CHANGES.md index 089847821e..f4c466d5d5 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,5 +1,12 @@ # CHANGELOG +## 2.15.2 + +### :bug: Bugs fixed + +- Fix mcp related internal config for docker images [Github #9565](https://github.com/penpot/penpot/pull/9565) + + ## 2.15.1 ### :sparkles: New features & Enhancements From d4dade2c3ebd8784e5b4b0ad9c012a9848ebe02a Mon Sep 17 00:00:00 2001 From: Yamila Moreno Date: Wed, 13 May 2026 07:48:06 +0200 Subject: [PATCH 19/36] :whale: Pin minor version in docker-compose.yaml --- docker/images/docker-compose.yaml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docker/images/docker-compose.yaml b/docker/images/docker-compose.yaml index acdde97884..7c631b06c0 100644 --- a/docker/images/docker-compose.yaml +++ b/docker/images/docker-compose.yaml @@ -78,7 +78,7 @@ services: # - "443:443" penpot-frontend: - image: "penpotapp/frontend:${PENPOT_VERSION:-latest}" + image: "penpotapp/frontend:${PENPOT_VERSION:-2.15}" restart: always ports: - 9001:8080 @@ -108,7 +108,7 @@ services: << : [*penpot-flags, *penpot-http-body-size, *penpot-public-uri] penpot-backend: - image: "penpotapp/backend:${PENPOT_VERSION:-latest}" + image: "penpotapp/backend:${PENPOT_VERSION:-2.15}" restart: always volumes: @@ -177,13 +177,13 @@ services: PENPOT_SMTP_SSL: false penpot-mcp: - image: "penpotapp/mcp:${PENPOT_VERSION:-latest}" + image: "penpotapp/mcp:${PENPOT_VERSION:-2.15}" restart: always networks: - penpot penpot-exporter: - image: "penpotapp/exporter:${PENPOT_VERSION:-latest}" + image: "penpotapp/exporter:${PENPOT_VERSION:-2.15}" restart: always depends_on: From fffafdab939f38c5dbb0029865d763c9ce31b382 Mon Sep 17 00:00:00 2001 From: Pablo Alba Date: Wed, 13 May 2026 11:29:05 +0200 Subject: [PATCH 20/36] :bug: Fix library updates reappear after file is reloaded (#9563) * :bug: Fix library updates reappear after file is reloaded Summary Migrate synced_at timestamps to a standalone file_library_sync table to ensure sync state is tracked for both direct and transitive libraries. Problem Transitive libraries (libraries imported by other libraries) are not stored as direct rows in file_library_rel. Because the system previously coupled synced_at directly to the file_library_rel schema, transitive libraries lacked a persistent location for their sync timestamps. This caused sync states to be lost or incorrectly reported for nested dependencies. Changes Schema Migration: Created file_library_sync and migrated existing synced_at values from file_library_rel. Decoupling: Removed tight Foreign Key coupling to allow sync rows to exist independently of specific relationship records. Persistent Writes: Added upsert-file-library-sync! helper. Updated all import, duplication, and RPC write paths (v1/v2/v3 importers, link-file-library) to ensure every write persists a sync row. Unified Reads: Updated both direct and recursive/transitive library queries to fetch synced_at from the new table. Testing: Added regression tests to verify that sync rows are correctly created/updated even when a transitive relation is absent in file_library_rel. Impact This fix ensures that the system accurately records and retrieves sync states for the entire library dependency tree, resolving the bug where nested libraries appeared out of sync. * :sparkles: MR review --- CHANGES.md | 5 +- backend/src/app/binfile/common.clj | 86 ++++++++++++------- backend/src/app/binfile/v1.clj | 8 +- backend/src/app/binfile/v2.clj | 6 +- backend/src/app/binfile/v3.clj | 8 +- backend/src/app/migrations.clj | 5 +- .../0149-mod-file-library-rel-synced-at.sql | 19 ++++ backend/src/app/rpc/commands/files.clj | 13 +-- backend/src/app/rpc/commands/management.clj | 8 +- backend/test/backend_tests/rpc_file_test.clj | 66 ++++++++++++++ 10 files changed, 173 insertions(+), 51 deletions(-) create mode 100644 backend/src/app/migrations/sql/0149-mod-file-library-rel-synced-at.sql diff --git a/CHANGES.md b/CHANGES.md index 05587d16ff..d1324adeb9 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -123,12 +123,13 @@ - Fix plugin parse-point returning plain map instead of Point record (by @FairyPigDev) [Github #9129](https://github.com/penpot/penpot/pull/9129) - Fix `:heigth` typo in clipboard frame-same-size? (by @iot2edge) [Github #9250](https://github.com/penpot/penpot/pull/9250) - Fix Settings and Notifications "Update Settings" button enabled state when form has no changes (by @moorsecopers99) [Github #9090](https://github.com/penpot/penpot/issues/9090) +- Fix library updates reappear after being applied and the file is reloaded [Taiga #14040](https://tree.taiga.io/project/penpot/issue/14040) ## 2.15.0 (Unreleased) ### :sparkles: New features & Enhancements -- Add MCP server integration [Github #9174](https://github.com/penpot/penpot/issues/9174) +- Add MCP server integration [Github #9174](https://github.com/penpot/penpot/issues/9174) - Add chunked upload API for large media and binary files (removes previous upload size limits) [Github #8909](https://github.com/penpot/penpot/pull/8909) - Improve team name validation [Github #9176](https://github.com/penpot/penpot/pull/9176) @@ -166,7 +167,6 @@ - Fix email blacklisting [Github #9122](https://github.com/penpot/penpot/pull/9122) - Fix removeChild errors from unmount race conditions [Github #8927](https://github.com/penpot/penpot/pull/8927) - ## 2.14.3 ### :sparkles: New features & Enhancements @@ -196,7 +196,6 @@ - Fix typo `:podition` in swap-shapes grid cell - Fix multiple selection on shapes with token applied to stroke color - ## 2.14.2 ### :sparkles: New features & Enhancements diff --git a/backend/src/app/binfile/common.clj b/backend/src/app/binfile/common.clj index f57b8775df..f2e582c633 100644 --- a/backend/src/app/binfile/common.clj +++ b/backend/src/app/binfile/common.clj @@ -440,11 +440,28 @@ (db/run! cfg (fn [{:keys [::db/conn]}] (let [ids (db/create-array conn "uuid" ids) - sql (str "SELECT flr.* FROM file_library_rel AS flr " - " JOIN file AS l ON (flr.library_file_id = l.id) " - " WHERE flr.file_id = ANY(?) AND l.deleted_at IS NULL")] + sql (str "SELECT flr.*," + " fls.synced_at" + " FROM file_library_rel AS flr" + " JOIN file AS l" + " ON flr.library_file_id = l.id" + " LEFT JOIN file_library_sync AS fls" + " ON fls.file_id = flr.file_id" + " AND fls.library_file_id = flr.library_file_id" + " WHERE flr.file_id = ANY(?)" + " AND l.deleted_at IS NULL;")] (db/exec! conn [sql ids]))))) +(def ^:private sql:upsert-file-library-sync + "INSERT INTO file_library_sync (file_id, library_file_id, synced_at) + VALUES (?::uuid, ?::uuid, ?::timestamptz) + ON CONFLICT (file_id, library_file_id) + DO UPDATE SET synced_at = EXCLUDED.synced_at;") + +(defn upsert-file-library-sync! + [conn {:keys [file-id library-file-id synced-at]}] + (db/exec-one! conn [sql:upsert-file-library-sync file-id library-file-id synced-at])) + (def ^:private sql:get-libraries "WITH RECURSIVE libs AS ( SELECT fl.id @@ -799,32 +816,41 @@ (def ^:private sql:get-file-libraries "WITH RECURSIVE libs AS ( - SELECT fl.*, flr.synced_at - FROM file AS fl - JOIN file_library_rel AS flr ON (flr.library_file_id = fl.id) - WHERE flr.file_id = ?::uuid - UNION - SELECT fl.*, flr.synced_at - FROM file AS fl - JOIN file_library_rel AS flr ON (flr.library_file_id = fl.id) - JOIN libs AS l ON (flr.file_id = l.id) - ) - SELECT l.id, - l.features, - l.project_id, - p.team_id, - l.created_at, - l.modified_at, - l.deleted_at, - l.name, - l.revn, - l.vern, - l.synced_at, - l.is_shared, - l.version - FROM libs AS l - INNER JOIN project AS p ON (p.id = l.project_id) - WHERE l.deleted_at IS NULL;") + SELECT fl.* + FROM file AS fl + JOIN file_library_rel AS flr + ON flr.library_file_id = fl.id + WHERE flr.file_id = ?::uuid + + UNION + + SELECT fl.* + FROM file AS fl + JOIN file_library_rel AS flr + ON flr.library_file_id = fl.id + JOIN libs AS l + ON flr.file_id = l.id + ) + SELECT l.id, + l.features, + l.project_id, + p.team_id, + l.created_at, + l.modified_at, + l.deleted_at, + l.name, + l.revn, + l.vern, + l.is_shared, + l.version, + fls.synced_at + FROM libs AS l + JOIN project AS p + ON p.id = l.project_id + LEFT JOIN file_library_sync AS fls + ON fls.file_id = ?::uuid + AND fls.library_file_id = l.id + WHERE l.deleted_at IS NULL;") (defn get-file-libraries [conn file-id] @@ -834,7 +860,7 @@ ;; completly useless (map #(assoc % :is-indirect false)) (map decode-row-features)) - (db/exec! conn [sql:get-file-libraries file-id]))) + (db/exec! conn [sql:get-file-libraries file-id file-id]))) (defn get-resolved-file-libraries "Get all file libraries including itself. Returns an instance of diff --git a/backend/src/app/binfile/v1.clj b/backend/src/app/binfile/v1.clj index 75f6f36994..ae2fc04e0c 100644 --- a/backend/src/app/binfile/v1.clj +++ b/backend/src/app/binfile/v1.clj @@ -573,7 +573,6 @@ ;; Insert all file relations (doseq [{:keys [library-file-id] :as rel} rels] (let [rel (-> rel - (assoc :synced-at timestamp) (update :file-id bfc/lookup-index) (update :library-file-id bfc/lookup-index))] @@ -583,7 +582,12 @@ :file-id (:file-id rel) :lib-id (:library-file-id rel) ::l/sync? true) - (db/insert! conn :file-library-rel rel)) + (let [rel-params (dissoc rel :synced-at)] + (db/insert! conn :file-library-rel rel-params) + (bfc/upsert-file-library-sync! conn {:file-id (:file-id rel-params) + :library-file-id (:library-file-id rel-params) + :synced-at (or (:synced-at rel) + timestamp)}))) (l/warn :hint "ignoring file library link" :file-id (:file-id rel) diff --git a/backend/src/app/binfile/v2.clj b/backend/src/app/binfile/v2.clj index c8acf2dc99..5adf25bd34 100644 --- a/backend/src/app/binfile/v2.clj +++ b/backend/src/app/binfile/v2.clj @@ -314,10 +314,10 @@ (doseq [rel (read-obj cfg :file-rels file-id)] (let [rel (-> rel (update :file-id bfc/lookup-index) - (update :library-file-id bfc/lookup-index) - (assoc :synced-at timestamp))] + (update :library-file-id bfc/lookup-index))] (db/insert! conn :file-library-rel rel - ::db/return-keys false))) + ::db/return-keys false) + (bfc/upsert-file-library-sync! conn (assoc rel :synced-at timestamp)))) (doseq [media (read-seq cfg :file-media-object file-id)] (let [media (-> media diff --git a/backend/src/app/binfile/v3.clj b/backend/src/app/binfile/v3.clj index b4ed065317..c6d3a7d6bd 100644 --- a/backend/src/app/binfile/v3.clj +++ b/backend/src/app/binfile/v3.clj @@ -824,10 +824,10 @@ :file-id (str file-id) :lib-id (str libr-id) ::l/sync? true) - (db/insert! conn :file-library-rel - {:synced-at timestamp - :file-id file-id - :library-file-id libr-id}))))) + (let [rel-params {:file-id file-id + :library-file-id libr-id}] + (db/insert! conn :file-library-rel rel-params) + (bfc/upsert-file-library-sync! conn (assoc rel-params :synced-at timestamp))))))) (defn- import-storage-objects [{:keys [::bfc/input ::entries ::bfc/timestamp] :as cfg}] diff --git a/backend/src/app/migrations.clj b/backend/src/app/migrations.clj index bd661f22cf..5ea79ff50c 100644 --- a/backend/src/app/migrations.clj +++ b/backend/src/app/migrations.clj @@ -481,7 +481,10 @@ :fn (mg/resource "app/migrations/sql/0147-add-upload-session-table.sql")} {:name "0148-add-variant-name-team-font-variant" - :fn (mg/resource "app/migrations/sql/0148-add-variant-name-team-font-variant.sql")}]) + :fn (mg/resource "app/migrations/sql/0148-add-variant-name-team-font-variant.sql")} + + {:name "0149-mod-file-library-rel-synced-at" + :fn (mg/resource "app/migrations/sql/0149-mod-file-library-rel-synced-at.sql")}]) (defn apply-migrations! [pool name migrations] diff --git a/backend/src/app/migrations/sql/0149-mod-file-library-rel-synced-at.sql b/backend/src/app/migrations/sql/0149-mod-file-library-rel-synced-at.sql new file mode 100644 index 0000000000..7929cfc8de --- /dev/null +++ b/backend/src/app/migrations/sql/0149-mod-file-library-rel-synced-at.sql @@ -0,0 +1,19 @@ +CREATE TABLE file_library_sync ( + file_id uuid NOT NULL, + library_file_id uuid NOT NULL, + synced_at timestamptz NOT NULL DEFAULT clock_timestamp(), + + PRIMARY KEY (file_id, library_file_id) +); + +INSERT INTO file_library_sync (file_id, library_file_id, synced_at) +SELECT file_id, library_file_id, synced_at + FROM file_library_rel; + +-- DEPRECATED: the `synced_at` column on `file_library_rel` is deprecated +-- and will be removed in a future migration. It's kept temporarily +-- for backward compatibility while data is migrated to `file_library_sync`. +COMMENT ON COLUMN file_library_rel.synced_at IS + 'DEPRECATED: will be removed in a future migration; kept temporarily for backward compatibility'; + + diff --git a/backend/src/app/rpc/commands/files.clj b/backend/src/app/rpc/commands/files.clj index 346ff8b0fc..d53e1bca7a 100644 --- a/backend/src/app/rpc/commands/files.clj +++ b/backend/src/app/rpc/commands/files.clj @@ -1064,7 +1064,10 @@ (defn link-file-to-library [conn {:keys [file-id library-id] :as params}] - (db/exec-one! conn [sql:link-file-to-library file-id library-id])) + (db/exec-one! conn [sql:link-file-to-library file-id library-id]) + (bfc/upsert-file-library-sync! conn {:file-id file-id + :library-file-id library-id + :synced-at (ct/now)})) (def ^:private schema:link-file-to-library @@ -1118,11 +1121,9 @@ (defn update-sync [conn {:keys [file-id library-id] :as params}] - (db/update! conn :file-library-rel - {:synced-at (ct/now)} - {:file-id file-id - :library-file-id library-id} - {::db/return-keys true})) + (bfc/upsert-file-library-sync! conn {:file-id file-id + :library-file-id library-id + :synced-at (ct/now)})) (def ^:private schema:update-file-library-sync-status [:map {:title "update-file-library-sync-status"} diff --git a/backend/src/app/rpc/commands/management.clj b/backend/src/app/rpc/commands/management.clj index ead821f79a..f8c6c11144 100644 --- a/backend/src/app/rpc/commands/management.clj +++ b/backend/src/app/rpc/commands/management.clj @@ -72,10 +72,14 @@ (doseq [params (sequence (comp (map #(bfc/remap-id % :file-id)) (map #(bfc/remap-id % :library-file-id)) - (map #(assoc % :synced-at timestamp)) (map #(assoc % :created-at timestamp))) flibs)] - (db/insert! conn :file-library-rel params ::db/return-keys false)) + (let [rel-params (dissoc params :synced-at)] + (db/insert! conn :file-library-rel rel-params ::db/return-keys false) + (bfc/upsert-file-library-sync! conn {:file-id (:file-id rel-params) + :library-file-id (:library-file-id rel-params) + :synced-at (or (:synced-at params) + timestamp)}))) (doseq [params (sequence (comp (map #(bfc/remap-id % :id)) diff --git a/backend/test/backend_tests/rpc_file_test.clj b/backend/test/backend_tests/rpc_file_test.clj index d45dec0453..5faa31481b 100644 --- a/backend/test/backend_tests/rpc_file_test.clj +++ b/backend/test/backend_tests/rpc_file_test.clj @@ -918,6 +918,72 @@ (t/is (th/ex-info? error)) (t/is (th/ex-of-type? error :not-found))))) +(t/deftest link-file-to-library-creates-sync-row + (let [profile (th/create-profile* 1) + file1 (th/create-file* 1 {:project-id (:default-project-id profile) + :profile-id (:id profile) + :is-shared true}) + file2 (th/create-file* 2 {:project-id (:default-project-id profile) + :profile-id (:id profile)}) + data {::th/type :link-file-to-library + ::rpc/profile-id (:id profile) + :file-id (:id file2) + :library-id (:id file1)} + out (th/command! data) + rel (th/db-get :file-library-rel {:file-id (:id file2) + :library-file-id (:id file1)}) + sync (th/db-get :file-library-sync {:file-id (:id file2) + :library-file-id (:id file1)})] + + (t/is (nil? (:error out))) + (t/is (some? rel)) + (t/is (some? sync)) + (t/is (some? (:synced-at sync))))) + +(t/deftest update-file-library-sync-status-updates-sync-row + (let [profile (th/create-profile* 1) + file1 (th/create-file* 1 {:project-id (:default-project-id profile) + :profile-id (:id profile) + :is-shared true}) + file2 (th/create-file* 2 {:project-id (:default-project-id profile) + :profile-id (:id profile)}) + _ (th/link-file-to-library* {:file-id (:id file2) + :library-id (:id file1)}) + before (th/db-get :file-library-sync {:file-id (:id file2) + :library-file-id (:id file1)}) + _ (th/sleep 10) + data {::th/type :update-file-library-sync-status + ::rpc/profile-id (:id profile) + :file-id (:id file2) + :library-id (:id file1)} + out (th/command! data) + after (th/db-get :file-library-sync {:file-id (:id file2) + :library-file-id (:id file1)})] + + (t/is (nil? (:error out))) + (t/is (some? before)) + (t/is (some? after)) + (t/is (pos? (compare (:synced-at after) (:synced-at before)))))) + +(t/deftest update-file-library-sync-status-without-link-creates-sync-row + (let [profile (th/create-profile* 1) + file1 (th/create-file* 1 {:project-id (:default-project-id profile) + :profile-id (:id profile) + :is-shared true}) + file2 (th/create-file* 2 {:project-id (:default-project-id profile) + :profile-id (:id profile)}) + data {::th/type :update-file-library-sync-status + ::rpc/profile-id (:id profile) + :file-id (:id file2) + :library-id (:id file1)} + out (th/command! data) + sync (th/db-get :file-library-sync {:file-id (:id file2) + :library-file-id (:id file1)})] + + (t/is (nil? (:error out))) + (t/is (some? sync)) + (t/is (some? (:synced-at sync))))) + (t/deftest deletion (let [profile1 (th/create-profile* 1) From 4df707c0ff06063875ce010b219e516f28fd476b Mon Sep 17 00:00:00 2001 From: Yamila Moreno Date: Wed, 13 May 2026 11:53:10 +0200 Subject: [PATCH 21/36] :whale: Add enable-mcp to docker-compose as default behaviour --- docker/images/docker-compose.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/images/docker-compose.yaml b/docker/images/docker-compose.yaml index 7c631b06c0..25d419a354 100644 --- a/docker/images/docker-compose.yaml +++ b/docker/images/docker-compose.yaml @@ -24,7 +24,7 @@ # WARNING: if you're exposing Penpot to the internet, you should remove the flags # 'disable-secure-session-cookies' and 'disable-email-verification' x-flags: &penpot-flags - PENPOT_FLAGS: disable-email-verification enable-smtp enable-prepl-server disable-secure-session-cookies + PENPOT_FLAGS: disable-email-verification enable-smtp enable-prepl-server disable-secure-session-cookies enable-mcp x-uri: &penpot-public-uri PENPOT_PUBLIC_URI: http://localhost:9001 From 9c61aa4f1722a0ae8cd844595374da3e9ce15e04 Mon Sep 17 00:00:00 2001 From: Alejandro Alonso Date: Wed, 13 May 2026 12:46:07 +0200 Subject: [PATCH 22/36] :bug: Fix clamp backbuffer crop origin for partially off-screen shapes --- render-wasm/src/render.rs | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/render-wasm/src/render.rs b/render-wasm/src/render.rs index 41d7869cc0..f34f099b7d 100644 --- a/render-wasm/src/render.rs +++ b/render-wasm/src/render.rs @@ -1721,6 +1721,16 @@ impl RenderState { && doc_bounds.top >= viewport.top && doc_bounds.right <= viewport.right && doc_bounds.bottom <= viewport.bottom; + + // When the shape extends beyond the viewport to the left or top, + // `left`/`top` are negative. Skia clamps `makeImageSnapshot` to the + // surface bounds, so the returned image actually starts at pixel 0 — + // not at the negative coordinate. Store the clamped value so that + // `doc_left`/`doc_top` computed during the drag reflects the true + // image origin in the backbuffer. + let capture_src_left = left.max(0); + let capture_src_top = top.max(0); + self.backbuffer_crop_cache.insert( id, InteractiveDragCrop { @@ -1729,8 +1739,8 @@ impl RenderState { fits_viewport_at_capture, capture_vb_left: vb_left, capture_vb_top: vb_top, - capture_src_left: src_irect.left, - capture_src_top: src_irect.top, + capture_src_left, + capture_src_top, image, }, ); From 1a3b0578142bdf231f4f3c764bb74413f8c44341 Mon Sep 17 00:00:00 2001 From: Alejandro Alonso Date: Wed, 13 May 2026 11:25:37 +0200 Subject: [PATCH 23/36] :bug: Fix atlas corruption when dragging large shapes after zoom change --- CHANGES.md | 1 + render-wasm/src/render.rs | 19 +++++++++++ render-wasm/src/render/surfaces.rs | 52 ++++++++++++++++++++++++++++++ 3 files changed, 72 insertions(+) diff --git a/CHANGES.md b/CHANGES.md index d1324adeb9..683cba1efd 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -62,6 +62,7 @@ ### :bug: Bugs fixed +- Fix render-wasm atlas corruption when dragging large shapes after a zoom or pan change (stale multi-zoom-level pixels no longer appear at the old shape position). - Fix Alt/Option to draw shapes from center point (by @offreal) [Github #8361](https://github.com/penpot/penpot/pull/8361) - Add token name on broken token pill on sidebar [Taiga #13527](https://tree.taiga.io/project/penpot/issue/13527) - Fix tooltip activated when tab change [Taiga #13627](https://tree.taiga.io/project/penpot/issue/13627) diff --git a/render-wasm/src/render.rs b/render-wasm/src/render.rs index f34f099b7d..2fc04534a2 100644 --- a/render-wasm/src/render.rs +++ b/render-wasm/src/render.rs @@ -3507,6 +3507,25 @@ impl RenderState { let mut result = HashSet::::with_capacity(old_tiles.len()); + // When the shape has an active modifier (i.e. is being moved/resized), + // clear its OLD doc-space extent from the atlas using the raw + // (pre-modifier) shape. The per-tile clearing done later via + // `clear_tile_in_atlas` only covers tiles tracked in `atlas_tile_doc_rects` + // at the current zoom level. However, the atlas may also contain stale + // pixels from previous zoom levels (tiles are larger / smaller in doc + // space at different zoom scales) that were never re-tracked after a zoom + // change. Clearing the full raw extrect here removes all such residual + // content without growing the atlas. + // + // We intentionally skip this when there is NO modifier so that plain + // zoom / pan tile-index rebuilds do NOT invalidate valid atlas content. + if tree.get_modifier(&shape.id).is_some() { + if let Some(raw_shape) = tree.get_raw(&shape.id) { + let old_extrect = raw_shape.extrect(tree, 1.0); + self.surfaces.clear_doc_rect_in_atlas_clipped(old_extrect); + } + } + // First, remove the shape from all tiles where it was previously located for tile in old_tiles { self.tiles.remove_shape_at(tile, shape.id); diff --git a/render-wasm/src/render/surfaces.rs b/render-wasm/src/render/surfaces.rs index f4a3456e9b..381d69c730 100644 --- a/render-wasm/src/render/surfaces.rs +++ b/render-wasm/src/render/surfaces.rs @@ -396,6 +396,58 @@ impl Surfaces { Ok(()) } + /// Clears a doc-space rect from the atlas **without** growing it. + /// + /// Unlike [`clear_doc_rect_in_atlas`], this method clips `doc_rect` to the + /// current atlas bounds and skips silently if there is no overlap. Use this + /// when evicting stale shape content (e.g. before a drag re-render) where + /// growing the atlas to accommodate an out-of-range rect would be wasteful. + pub fn clear_doc_rect_in_atlas_clipped(&mut self, doc_rect: skia::Rect) { + if !self.has_atlas() || doc_rect.is_empty() { + return; + } + + let atlas_scale = self.atlas_scale.max(0.01); + let atlas_doc_right = self.atlas_origin.x + (self.atlas_size.width as f32) / atlas_scale; + let atlas_doc_bottom = self.atlas_origin.y + (self.atlas_size.height as f32) / atlas_scale; + + // Intersect with current atlas bounds in doc space. + let mut clipped = doc_rect; + let atlas_bounds = skia::Rect::from_ltrb( + self.atlas_origin.x, + self.atlas_origin.y, + atlas_doc_right, + atlas_doc_bottom, + ); + if !clipped.intersect(atlas_bounds) { + return; + } + + // Apply atlas_doc_bounds clamping. + if let Some(bounds) = self.atlas_doc_bounds { + if !clipped.intersect(bounds) { + return; + } + } + + if clipped.is_empty() { + return; + } + + let dst = skia::Rect::from_xywh( + (clipped.left - self.atlas_origin.x) * atlas_scale, + (clipped.top - self.atlas_origin.y) * atlas_scale, + clipped.width() * atlas_scale, + clipped.height() * atlas_scale, + ); + + let canvas = self.atlas.canvas(); + canvas.save(); + canvas.clip_rect(dst, None, true); + canvas.clear(skia::Color::TRANSPARENT); + canvas.restore(); + } + pub fn clear_tiles(&mut self) { self.tiles.clear(); } From da85e02a6fd1990233dc18a94ca8282e73098140 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Wed, 13 May 2026 14:14:10 +0200 Subject: [PATCH 24/36] :arrow_up: Update dependencies (#9597) * :arrow_up: Update dependencies * :paperclip: Fix playwright dep --- backend/deps.edn | 21 +- common/deps.edn | 20 +- docker/images/Dockerfile.backend | 12 +- docker/images/Dockerfile.exporter | 2 +- docker/images/Dockerfile.frontend | 2 +- docker/images/Dockerfile.storybook | 2 +- exporter/package.json | 10 +- exporter/pnpm-lock.yaml | 532 ++++++++++------------------- frontend/package.json | 12 +- frontend/pnpm-lock.yaml | 346 +++++++++++-------- 10 files changed, 408 insertions(+), 551 deletions(-) diff --git a/backend/deps.edn b/backend/deps.edn index af73aecbc8..399e7162f8 100644 --- a/backend/deps.edn +++ b/backend/deps.edn @@ -3,7 +3,7 @@ :deps {penpot/common {:local/root "../common"} - org.clojure/clojure {:mvn/version "1.12.4"} + org.clojure/clojure {:mvn/version "1.12.5"} org.clojure/tools.namespace {:mvn/version "1.5.0"} com.github.luben/zstd-jni {:mvn/version "1.5.7-4"} @@ -17,7 +17,7 @@ io.prometheus/simpleclient_httpserver {:mvn/version "0.16.0"} - io.lettuce/lettuce-core {:mvn/version "6.8.1.RELEASE"} + io.lettuce/lettuce-core {:mvn/version "7.5.1.RELEASE"} ;; Minimal dependencies required by lettuce, we need to include them ;; explicitly because clojure dependency management does not support ;; yet the BOM format. @@ -28,18 +28,18 @@ com.google.guava/guava {:mvn/version "33.4.8-jre"} funcool/yetti - {:git/tag "v11.9" - :git/sha "5fad7a9" + {:git/tag "v11.10" + :git/sha "88701f4" :git/url "https://github.com/funcool/yetti.git" :exclusions [org.slf4j/slf4j-api]} com.github.seancorfield/next.jdbc - {:mvn/version "1.3.1070"} + {:mvn/version "1.3.1093"} metosin/reitit-core {:mvn/version "0.9.1"} - nrepl/nrepl {:mvn/version "1.4.0"} + nrepl/nrepl {:mvn/version "1.7.0"} - org.postgresql/postgresql {:mvn/version "42.7.9"} + org.postgresql/postgresql {:mvn/version "42.7.11"} org.xerial/sqlite-jdbc {:mvn/version "3.50.3.0"} com.zaxxer/HikariCP {:mvn/version "7.0.2"} @@ -49,7 +49,7 @@ buddy/buddy-hashers {:mvn/version "2.0.167"} buddy/buddy-sign {:mvn/version "3.6.1-359"} - com.github.ben-manes.caffeine/caffeine {:mvn/version "3.2.3"} + com.github.ben-manes.caffeine/caffeine {:mvn/version "3.2.4"} org.jsoup/jsoup {:mvn/version "1.21.2"} org.im4java/im4java @@ -57,7 +57,8 @@ :git/sha "e2b3e16" :git/url "https://github.com/penpot/im4java"} - org.lz4/lz4-java {:mvn/version "1.8.0"} + at.yawk.lz4/lz4-java + {:mvn/version "1.11.0"} org.clojars.pntblnk/clj-ldap {:mvn/version "0.0.17"} @@ -66,7 +67,7 @@ ;; Pretty Print specs pretty-spec/pretty-spec {:mvn/version "0.1.4"} - software.amazon.awssdk/s3 {:mvn/version "2.41.21"}} + software.amazon.awssdk/s3 {:mvn/version "2.44.4"}} :paths ["src" "resources" "target/classes"] :aliases diff --git a/common/deps.edn b/common/deps.edn index 01b6d33df4..329efa3260 100644 --- a/common/deps.edn +++ b/common/deps.edn @@ -1,23 +1,23 @@ {:deps - {org.clojure/clojure {:mvn/version "1.12.4"} - org.clojure/data.json {:mvn/version "2.5.1"} + {org.clojure/clojure {:mvn/version "1.12.5"} + org.clojure/data.json {:mvn/version "2.5.2"} org.clojure/tools.cli {:mvn/version "1.1.230"} org.clojure/test.check {:mvn/version "1.1.1"} - org.clojure/data.fressian {:mvn/version "1.1.0"} + org.clojure/data.fressian {:mvn/version "1.1.1"} org.clojure/clojurescript {:mvn/version "1.12.42"} org.apache.commons/commons-pool2 {:mvn/version "2.12.1"} ;; Logging - org.apache.logging.log4j/log4j-api {:mvn/version "2.25.3"} - org.apache.logging.log4j/log4j-core {:mvn/version "2.25.3"} - org.apache.logging.log4j/log4j-web {:mvn/version "2.25.3"} - org.apache.logging.log4j/log4j-jul {:mvn/version "2.25.3"} - org.apache.logging.log4j/log4j-slf4j2-impl {:mvn/version "2.25.3"} - org.slf4j/slf4j-api {:mvn/version "2.0.17"} + org.apache.logging.log4j/log4j-api {:mvn/version "2.26.0"} + org.apache.logging.log4j/log4j-core {:mvn/version "2.26.0"} + org.apache.logging.log4j/log4j-web {:mvn/version "2.26.0"} + org.apache.logging.log4j/log4j-jul {:mvn/version "2.26.0"} + org.apache.logging.log4j/log4j-slf4j2-impl {:mvn/version "2.26.0"} + org.slf4j/slf4j-api {:mvn/version "2.0.18"} pl.tkowalcz.tjahzi/log4j2-appender {:mvn/version "0.9.41"} - selmer/selmer {:mvn/version "1.12.70"} + selmer/selmer {:mvn/version "1.13.1"} criterium/criterium {:mvn/version "0.4.6"} metosin/jsonista {:mvn/version "0.3.13"} diff --git a/docker/images/Dockerfile.backend b/docker/images/Dockerfile.backend index c3d08916a6..c27d2c4363 100644 --- a/docker/images/Dockerfile.backend +++ b/docker/images/Dockerfile.backend @@ -5,7 +5,7 @@ ENV LANG='C.UTF-8' \ LC_ALL='C.UTF-8' \ JAVA_HOME="/opt/jdk" \ DEBIAN_FRONTEND=noninteractive \ - NODE_VERSION=v22.22.0 \ + NODE_VERSION=v24.15.0 \ TZ=Etc/UTC RUN set -ex; \ @@ -46,12 +46,12 @@ RUN set -eux; \ ARCH="$(dpkg --print-architecture)"; \ case "${ARCH}" in \ aarch64|arm64) \ - ESUM='9903c6b19183a33725ca1dfdae5b72400c9d00995c76fafc4a0d31c5152f33f7'; \ - BINARY_URL='https://cdn.azul.com/zulu/bin/zulu25.32.21-ca-jdk25.0.2-linux_aarch64.tar.gz'; \ + ESUM='cc1b459dc442d7422b46a3b5fe52acaea54879fa7913e29a05650cef54687f5f'; \ + BINARY_URL='https://cdn.azul.com/zulu/bin/zulu26.30.11-ca-jdk26.0.1-linux_aarch64.tar.gz'; \ ;; \ amd64|x86_64) \ - ESUM='946ad9766d98fc6ab495a1a120072197db54997f6925fb96680f1ecd5591db4e'; \ - BINARY_URL='https://cdn.azul.com/zulu/bin/zulu25.32.21-ca-jdk25.0.2-linux_x64.tar.gz'; \ + ESUM='7d6663ea8d4298df65de065e32f9f449745ff607d30ba5d13777cb92e9d4613d'; \ + BINARY_URL='https://cdn.azul.com/zulu/bin/zulu26.30.11-ca-jdk26.0.1-linux_x64.tar.gz'; \ ;; \ *) \ echo "Unsupported arch: ${ARCH}"; \ @@ -68,7 +68,7 @@ RUN set -eux; \ --no-header-files \ --no-man-pages \ --strip-debug \ - --add-modules java.base,jdk.management.agent,java.se,jdk.compiler,jdk.javadoc,jdk.attach,jdk.unsupported,jdk.jfr,jdk.jcmd \ + --add-modules java.base,jdk.net,jdk.management.agent,java.se,jdk.compiler,jdk.javadoc,jdk.attach,jdk.unsupported,jdk.jfr,jdk.jcmd \ --output /opt/jre; FROM ubuntu:24.04 AS image diff --git a/docker/images/Dockerfile.exporter b/docker/images/Dockerfile.exporter index 03be19d2f3..0049c6dd76 100644 --- a/docker/images/Dockerfile.exporter +++ b/docker/images/Dockerfile.exporter @@ -3,7 +3,7 @@ LABEL maintainer="Penpot " ENV LANG=en_US.UTF-8 \ LC_ALL=en_US.UTF-8 \ - NODE_VERSION=v22.22.0 \ + NODE_VERSION=v24.15.0 \ DEBIAN_FRONTEND=noninteractive \ PATH=/opt/node/bin:/opt/imagick/bin:$PATH \ PLAYWRIGHT_BROWSERS_PATH=/opt/penpot/browsers diff --git a/docker/images/Dockerfile.frontend b/docker/images/Dockerfile.frontend index 1b44b9c3a1..3e0edbf002 100644 --- a/docker/images/Dockerfile.frontend +++ b/docker/images/Dockerfile.frontend @@ -1,4 +1,4 @@ -FROM nginxinc/nginx-unprivileged:1.29.1 +FROM nginxinc/nginx-unprivileged:1.30.0 LABEL maintainer="Penpot " USER root diff --git a/docker/images/Dockerfile.storybook b/docker/images/Dockerfile.storybook index 24e6acc5cf..9cccbe799b 100644 --- a/docker/images/Dockerfile.storybook +++ b/docker/images/Dockerfile.storybook @@ -1,4 +1,4 @@ -FROM nginxinc/nginx-unprivileged:1.29.1 +FROM nginxinc/nginx-unprivileged:1.30.0 LABEL maintainer="Penpot " USER root diff --git a/exporter/package.json b/exporter/package.json index ba6570f3cc..e05895882c 100644 --- a/exporter/package.json +++ b/exporter/package.json @@ -11,22 +11,22 @@ }, "type": "module", "dependencies": { - "archiver": "^7.0.1", + "archiver": "^8.0.0", "cookies": "^0.9.1", "date-fns": "^4.1.0", "generic-pool": "^3.9.0", "inflation": "^2.1.0", - "ioredis": "^5.8.2", - "playwright": "^1.57.0", + "ioredis": "^5.10.1", + "playwright": "^1.60.0", "raw-body": "^3.0.2", "source-map-support": "^0.5.21", "svgo": "penpot/svgo#v3.1", - "undici": "^7.16.0", + "undici": "^8.2.0", "xml-js": "^1.6.11", "xregexp": "^5.1.2" }, "devDependencies": { - "ws": "^8.18.3" + "ws": "^8.20.1" }, "scripts": { "clear:shadow-cache": "rm -rf .shadow-cljs && rm -rf target", diff --git a/exporter/pnpm-lock.yaml b/exporter/pnpm-lock.yaml index 78e28acc49..7acfafe825 100644 --- a/exporter/pnpm-lock.yaml +++ b/exporter/pnpm-lock.yaml @@ -9,8 +9,8 @@ importers: .: dependencies: archiver: - specifier: ^7.0.1 - version: 7.0.1 + specifier: ^8.0.0 + version: 8.0.0 cookies: specifier: ^0.9.1 version: 0.9.1 @@ -24,11 +24,11 @@ importers: specifier: ^2.1.0 version: 2.1.0 ioredis: - specifier: ^5.8.2 - version: 5.8.2 + specifier: ^5.10.1 + version: 5.10.1 playwright: - specifier: ^1.57.0 - version: 1.57.0 + specifier: ^1.60.0 + version: 1.60.0 raw-body: specifier: ^3.0.2 version: 3.0.2 @@ -39,8 +39,8 @@ importers: specifier: penpot/svgo#v3.1 version: https://codeload.github.com/penpot/svgo/tar.gz/a46262c12c0d967708395972c374eb2adead4180 undici: - specifier: ^7.16.0 - version: 7.16.0 + specifier: ^8.2.0 + version: 8.2.0 xml-js: specifier: ^1.6.11 version: 1.6.11 @@ -49,8 +49,8 @@ importers: version: 5.1.2 devDependencies: ws: - specifier: ^8.18.3 - version: 8.18.3 + specifier: ^8.20.1 + version: 8.20.1 packages: @@ -58,16 +58,8 @@ packages: resolution: {integrity: sha512-h7iEYiW4HebClDEhtvFObtPmIvrd1SSfpI9EhOeKk4CtIK/ngBWFpuhCzhdmRKtg71ylcue+9I6dv54XYO1epQ==} engines: {node: '>=6.9.0'} - '@ioredis/commands@1.4.0': - resolution: {integrity: sha512-aFT2yemJJo+TZCmieA7qnYGQooOS7QfNmYrzGtsYd3g9j5iDP8AimYYAesf79ohjbLG12XxC4nG5DyEnC88AsQ==} - - '@isaacs/cliui@8.0.2': - resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} - engines: {node: '>=12'} - - '@pkgjs/parseargs@0.11.0': - resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} - engines: {node: '>=14'} + '@ioredis/commands@1.5.1': + resolution: {integrity: sha512-JH8ZL/ywcJyR9MmJ5BNqZllXNZQqQbnVZOqpPQqE1vHiFgAw4NHbvE0FOduNU8IX9babitBT46571OnPTT0Zcw==} '@trysound/sax@0.2.0': resolution: {integrity: sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA==} @@ -77,43 +69,24 @@ packages: resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==} engines: {node: '>=6.5'} - ansi-regex@5.0.1: - resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} - engines: {node: '>=8'} - - ansi-regex@6.2.2: - resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} - engines: {node: '>=12'} - - ansi-styles@4.3.0: - resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} - engines: {node: '>=8'} - - ansi-styles@6.2.3: - resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} - engines: {node: '>=12'} - - archiver-utils@5.0.2: - resolution: {integrity: sha512-wuLJMmIBQYCsGZgYLTy5FIB2pF6Lfb6cXMSF8Qywwk3t20zWnAi7zLcQFdKQmIB8wyZpY5ER38x08GbwtR2cLA==} - engines: {node: '>= 14'} - - archiver@7.0.1: - resolution: {integrity: sha512-ZcbTaIqJOfCc03QwD468Unz/5Ir8ATtvAHsK+FdXbDIbGfihqh9mrvdcYunQzqn4HrvWWaFyaxJhGZagaJJpPQ==} - engines: {node: '>= 14'} + archiver@8.0.0: + resolution: {integrity: sha512-fV1orZfsnPn9BaSByR/qE67rJCLJEy2Ox5bq7nJh+jquWaNh6Sfec75kJ2T6PtdGUbPQlrVoSVCEOa5SdiTQ1g==} + engines: {node: '>=18'} async@3.2.6: resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==} - b4a@1.7.3: - resolution: {integrity: sha512-5Q2mfq2WfGuFp3uS//0s6baOJLMoVduPYVeNmDYxu5OUA1/cBfvr2RIS7vi62LdNj/urk1hfmj867I3qt6uZ7Q==} + b4a@1.8.1: + resolution: {integrity: sha512-aiqre1Nr0B/6DgE2N5vwTc+2/oQZ4Wh1t4NznYY4E00y8LCt6NqdRv81so00oo27D8MVKTpUa/MwUUtBLXCoDw==} peerDependencies: react-native-b4a: '*' peerDependenciesMeta: react-native-b4a: optional: true - balanced-match@1.0.2: - resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} bare-events@2.8.2: resolution: {integrity: sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ==} @@ -123,14 +96,48 @@ packages: bare-abort-controller: optional: true + bare-fs@4.7.1: + resolution: {integrity: sha512-WDRsyVN52eAx/lBamKD6uyw8H4228h/x0sGGGegOamM2cd7Pag88GfMQalobXI+HaEUxpCkbKQUDOQqt9wawRw==} + engines: {bare: '>=1.16.0'} + peerDependencies: + bare-buffer: '*' + peerDependenciesMeta: + bare-buffer: + optional: true + + bare-os@3.9.1: + resolution: {integrity: sha512-6M5XjcnsygQNPMCMPXSK379xrJFiZ/AEMNBmFEmQW8d/789VQATvriyi5r0HYTL9TkQ26rn3kgdTG3aisbrXkQ==} + engines: {bare: '>=1.14.0'} + + bare-path@3.0.0: + resolution: {integrity: sha512-tyfW2cQcB5NN8Saijrhqn0Zh7AnFNsnczRcuWODH0eYAXBsJ5gVxAUuNr7tsHSC6IZ77cA0SitzT+s47kot8Mw==} + + bare-stream@2.13.1: + resolution: {integrity: sha512-Vp0cnjYyrEC4whYTymQ+YZi6pBpfiICZO3cfRG8sy67ZNWe951urv1x4eW1BKNngw3U+3fPYb5JQvHbCtxH7Ow==} + peerDependencies: + bare-abort-controller: '*' + bare-buffer: '*' + bare-events: '*' + peerDependenciesMeta: + bare-abort-controller: + optional: true + bare-buffer: + optional: true + bare-events: + optional: true + + bare-url@2.4.3: + resolution: {integrity: sha512-Kccpc7ACfXaxfeInfqKcZtW4pT5YBn1mesc4sCsun6sRwtbJ4h+sNOaksUpYEJUKfN65YWC6Bw2OJEFiKxq8nQ==} + base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} boolbase@1.0.0: resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} - brace-expansion@2.0.2: - resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==} + brace-expansion@5.0.6: + resolution: {integrity: sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==} + engines: {node: 18 || 20 || >=22} buffer-crc32@1.0.0: resolution: {integrity: sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w==} @@ -150,16 +157,9 @@ packages: resolution: {integrity: sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==} engines: {node: '>=0.10.0'} - color-convert@2.0.1: - resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} - engines: {node: '>=7.0.0'} - - color-name@1.1.4: - resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} - - compress-commons@6.0.2: - resolution: {integrity: sha512-6FqVXeETqWPoGcfzrXb37E50NP0LXT8kAMu5ooZayhWWdgEY4lBEEcbQNXtkuKQsGduxiIcI4gOTsxTmuq/bSg==} - engines: {node: '>= 14'} + compress-commons@7.0.1: + resolution: {integrity: sha512-g0S8KAD8qf4+V//pr3BfB1aBnARLXNz2Gx+jmHU0LEriUuoQUOPOulVquHKTJ8+EAIIO7fhseNDr9wK5Q9FKBQ==} + engines: {node: '>=18'} cookies@0.9.1: resolution: {integrity: sha512-TG2hpqe4ELx54QER/S3HQ9SRVnQnGBtKUz5bLQWtYAQ+o6GpgMs6sYUvaiJjVxb+UXwhRhAEP3m7LbsIZ77Hmw==} @@ -176,13 +176,9 @@ packages: engines: {node: '>=0.8'} hasBin: true - crc32-stream@6.0.0: - resolution: {integrity: sha512-piICUB6ei4IlTv1+653yq5+KoqfBYmj9bw6LqXoOneTMDXk5nM1qt12mFW1caG3LlJXEKW1Bp0WggEmIfQB34g==} - engines: {node: '>= 14'} - - cross-spawn@7.0.6: - resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} - engines: {node: '>= 8'} + crc32-stream@7.0.1: + resolution: {integrity: sha512-IBWsY8xznyQrcHn8h4bC8/4ErNke5elzgG8GcqF4RFPw6aHkWWRc7Tgw6upjaTX/CT/yQgqYENkxYsTYN+hW2g==} + engines: {node: '>=18'} css-select@5.2.2: resolution: {integrity: sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==} @@ -236,15 +232,6 @@ packages: domutils@3.2.2: resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==} - eastasianwidth@0.2.0: - resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} - - emoji-regex@8.0.0: - resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} - - emoji-regex@9.2.2: - resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} - entities@4.5.0: resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} engines: {node: '>=0.12'} @@ -263,10 +250,6 @@ packages: fast-fifo@1.3.2: resolution: {integrity: sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==} - foreground-child@3.3.1: - resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} - engines: {node: '>=14'} - fsevents@2.3.2: resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -276,13 +259,6 @@ packages: resolution: {integrity: sha512-hymDOu5B53XvN4QT9dBmZxPX4CWhBPPLguTZ9MMFeFa/Kg0xWVfylOVNlJji/E7yTZWFd/q9GO5TxDLq156D7g==} engines: {node: '>= 4'} - glob@10.5.0: - resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==} - hasBin: true - - graceful-fs@4.2.11: - resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} - http-errors@2.0.1: resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} engines: {node: '>= 0.8'} @@ -301,27 +277,17 @@ packages: inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} - ioredis@5.8.2: - resolution: {integrity: sha512-C6uC+kleiIMmjViJINWk80sOQw5lEzse1ZmvD+S/s8p8CWapftSaC+kocGTx6xrbrJ4WmYQGC08ffHLr6ToR6Q==} + ioredis@5.10.1: + resolution: {integrity: sha512-HuEDBTI70aYdx1v6U97SbNx9F1+svQKBDo30o0b9fw055LMepzpOOd0Ccg9Q6tbqmBSJaMuY0fB7yw9/vjBYCA==} engines: {node: '>=12.22.0'} - is-fullwidth-code-point@3.0.0: - resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} - engines: {node: '>=8'} - - is-stream@2.0.1: - resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} - engines: {node: '>=8'} + is-stream@4.0.1: + resolution: {integrity: sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==} + engines: {node: '>=18'} isarray@1.0.0: resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==} - isexe@2.0.0: - resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} - - jackspeak@3.4.3: - resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} - keygrip@1.1.0: resolution: {integrity: sha512-iYSchDJ+liQ8iwbSI2QqsQOvqv58eJCEanyJPJi+Khyu8smkcKSFUCbPwzFcL7YVtZ6eONjqRX/38caJ7QjRAQ==} engines: {node: '>= 0.6'} @@ -340,26 +306,15 @@ packages: lodash@4.17.21: resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} - lru-cache@10.4.3: - resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} - mdn-data@2.0.28: resolution: {integrity: sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g==} mdn-data@2.12.2: resolution: {integrity: sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==} - minimatch@5.1.6: - resolution: {integrity: sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==} - engines: {node: '>=10'} - - minimatch@9.0.5: - resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==} - engines: {node: '>=16 || 14 >=14.17'} - - minipass@7.1.2: - resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==} - engines: {node: '>=16 || 14 >=14.17'} + minimatch@10.2.5: + resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} + engines: {node: 18 || 20 || >=22} ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -371,24 +326,13 @@ packages: nth-check@2.1.1: resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} - package-json-from-dist@1.0.1: - resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} - - path-key@3.1.1: - resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} - engines: {node: '>=8'} - - path-scurry@1.11.1: - resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} - engines: {node: '>=16 || 14 >=14.18'} - - playwright-core@1.57.0: - resolution: {integrity: sha512-agTcKlMw/mjBWOnD6kFZttAAGHgi/Nw0CZ2o6JqWSbMlI219lAFLZZCyqByTsvVAJq5XA5H8cA6PrvBRpBWEuQ==} + playwright-core@1.60.0: + resolution: {integrity: sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==} engines: {node: '>=18'} hasBin: true - playwright@1.57.0: - resolution: {integrity: sha512-ilYQj1s8sr2ppEJ2YVadYBN0Mb3mdo9J0wQ+UuDhzYqURwSoW4n1Xs5vs7ORwgDGmyEh33tRMeS8KhdkMoLXQw==} + playwright@1.60.0: + resolution: {integrity: sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==} engines: {node: '>=18'} hasBin: true @@ -410,8 +354,9 @@ packages: resolution: {integrity: sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - readdir-glob@1.1.3: - resolution: {integrity: sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA==} + readdir-glob@3.0.0: + resolution: {integrity: sha512-AhNB2KgKeVJr16nK9LLZbJNWnYoT23ZrumNKFDebHBdkC8KHSqWo871JAUhoWC/RtjEVdqNMFpM6qrwRbaUqpw==} + engines: {node: '>=18'} redis-errors@1.2.0: resolution: {integrity: sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==} @@ -436,18 +381,6 @@ packages: setprototypeof@1.2.0: resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} - shebang-command@2.0.0: - resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} - engines: {node: '>=8'} - - shebang-regex@3.0.0: - resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} - engines: {node: '>=8'} - - signal-exit@4.1.0: - resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} - engines: {node: '>=14'} - source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -466,16 +399,8 @@ packages: resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} engines: {node: '>= 0.8'} - streamx@2.23.0: - resolution: {integrity: sha512-kn+e44esVfn2Fa/O0CPFcex27fjIL6MkVae0Mm6q+E6f0hWv578YCERbv+4m02cjxvDsPKLnmxral/rR6lBMAg==} - - string-width@4.2.3: - resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} - engines: {node: '>=8'} - - string-width@5.1.2: - resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} - engines: {node: '>=12'} + streamx@2.25.0: + resolution: {integrity: sha512-0nQuG6jf1w+wddNEEXCF4nTg3LtufWINB5eFEN+5TNZW7KWJp6x87+JFL43vaAUPyCfH1wID+mNVyW6OHtFamg==} string_decoder@1.1.1: resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==} @@ -483,24 +408,19 @@ packages: string_decoder@1.3.0: resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} - strip-ansi@6.0.1: - resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} - engines: {node: '>=8'} - - strip-ansi@7.1.2: - resolution: {integrity: sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==} - engines: {node: '>=12'} - svgo@https://codeload.github.com/penpot/svgo/tar.gz/a46262c12c0d967708395972c374eb2adead4180: resolution: {tarball: https://codeload.github.com/penpot/svgo/tar.gz/a46262c12c0d967708395972c374eb2adead4180} version: 4.0.0 engines: {node: '>=16.0.0'} - tar-stream@3.1.7: - resolution: {integrity: sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==} + tar-stream@3.2.0: + resolution: {integrity: sha512-ojzvCvVaNp6aOTFmG7jaRD0meowIAuPc3cMMhSgKiVWws1GyHbGd/xvnyuRKcKlMpt3qvxx6r0hreCNITP9hIg==} - text-decoder@1.2.3: - resolution: {integrity: sha512-3/o9z3X0X0fTupwsYvR03pJ/DjWuqqrfwBgTQzdWDiQSm9KitAyz/9WqsT2JQW7KV2m+bC2ol/zqpW37NHxLaA==} + teex@1.0.1: + resolution: {integrity: sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg==} + + text-decoder@1.2.7: + resolution: {integrity: sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==} toidentifier@1.0.1: resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} @@ -510,9 +430,9 @@ packages: resolution: {integrity: sha512-LxhtAkPDTkVCMQjt2h6eBVY28KCjikZqZfMcC15YBeNjkgUpdCfBu5HoiOTDu86v6smE8yOjyEktJ8hlbANHQA==} engines: {node: '>=0.6.x'} - undici@7.16.0: - resolution: {integrity: sha512-QEg3HPMll0o3t2ourKwOeUAZ159Kn9mx5pnzHRQO8+Wixmh88YdZRiIwat0iNzNNXn0yoEtXJqFpyW7eM8BV7g==} - engines: {node: '>=20.18.1'} + undici@8.2.0: + resolution: {integrity: sha512-Z+4Hx9GE26Lh9Upwfnc8C7SsrpBPGaM/Gm6kMFtiG7c+5IvQKlXi/t+9x9DrrCh29cww5TSP9YdVaBcnLDs5fQ==} + engines: {node: '>=22.19.0'} unpipe@1.0.0: resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} @@ -521,21 +441,8 @@ packages: util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} - which@2.0.2: - resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} - engines: {node: '>= 8'} - hasBin: true - - wrap-ansi@7.0.0: - resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} - engines: {node: '>=10'} - - wrap-ansi@8.1.0: - resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} - engines: {node: '>=12'} - - ws@8.18.3: - resolution: {integrity: sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==} + ws@8.20.1: + resolution: {integrity: sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==} engines: {node: '>=10.0.0'} peerDependencies: bufferutil: ^4.0.1 @@ -553,9 +460,9 @@ packages: xregexp@5.1.2: resolution: {integrity: sha512-6hGgEMCGhqCTFEJbqmWrNIPqfpdirdGWkqshu7fFZddmTSfgv5Sn9D2SaKloR79s5VUiUlpwzg3CM3G6D3VIlw==} - zip-stream@6.0.1: - resolution: {integrity: sha512-zK7YHHz4ZXpW89AHXUPbQVGKI7uvkd3hzusTdotCg1UxyaVtg0zFJSTfW/Dq5f7OBBVnq6cZIaC8Ti4hb6dtCA==} - engines: {node: '>= 14'} + zip-stream@7.0.5: + resolution: {integrity: sha512-dSvYKdvLsAHCDqPOhIwk/q5CvuWtTB3Dgpoe0uVEFjTzIOAmsQpprX25InCvrvJsirEbu1OHyy67n/kAj1Sw/w==} + engines: {node: '>=18'} snapshots: @@ -563,19 +470,7 @@ snapshots: dependencies: core-js-pure: 3.47.0 - '@ioredis/commands@1.4.0': {} - - '@isaacs/cliui@8.0.2': - dependencies: - string-width: 5.1.2 - string-width-cjs: string-width@4.2.3 - strip-ansi: 7.1.2 - strip-ansi-cjs: strip-ansi@6.0.1 - wrap-ansi: 8.1.0 - wrap-ansi-cjs: wrap-ansi@7.0.0 - - '@pkgjs/parseargs@0.11.0': - optional: true + '@ioredis/commands@1.5.1': {} '@trysound/sax@0.2.0': {} @@ -583,54 +478,67 @@ snapshots: dependencies: event-target-shim: 5.0.1 - ansi-regex@5.0.1: {} - - ansi-regex@6.2.2: {} - - ansi-styles@4.3.0: + archiver@8.0.0: dependencies: - color-convert: 2.0.1 - - ansi-styles@6.2.3: {} - - archiver-utils@5.0.2: - dependencies: - glob: 10.5.0 - graceful-fs: 4.2.11 - is-stream: 2.0.1 - lazystream: 1.0.1 - lodash: 4.17.21 - normalize-path: 3.0.0 - readable-stream: 4.7.0 - - archiver@7.0.1: - dependencies: - archiver-utils: 5.0.2 async: 3.2.6 buffer-crc32: 1.0.0 + is-stream: 4.0.1 + lazystream: 1.0.1 + normalize-path: 3.0.0 readable-stream: 4.7.0 - readdir-glob: 1.1.3 - tar-stream: 3.1.7 - zip-stream: 6.0.1 + readdir-glob: 3.0.0 + tar-stream: 3.2.0 + zip-stream: 7.0.5 transitivePeerDependencies: - bare-abort-controller + - bare-buffer - react-native-b4a async@3.2.6: {} - b4a@1.7.3: {} + b4a@1.8.1: {} - balanced-match@1.0.2: {} + balanced-match@4.0.4: {} bare-events@2.8.2: {} + bare-fs@4.7.1: + dependencies: + bare-events: 2.8.2 + bare-path: 3.0.0 + bare-stream: 2.13.1(bare-events@2.8.2) + bare-url: 2.4.3 + fast-fifo: 1.3.2 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + + bare-os@3.9.1: {} + + bare-path@3.0.0: + dependencies: + bare-os: 3.9.1 + + bare-stream@2.13.1(bare-events@2.8.2): + dependencies: + streamx: 2.25.0 + teex: 1.0.1 + optionalDependencies: + bare-events: 2.8.2 + transitivePeerDependencies: + - react-native-b4a + + bare-url@2.4.3: + dependencies: + bare-path: 3.0.0 + base64-js@1.5.1: {} boolbase@1.0.0: {} - brace-expansion@2.0.2: + brace-expansion@5.0.6: dependencies: - balanced-match: 1.0.2 + balanced-match: 4.0.4 buffer-crc32@1.0.0: {} @@ -645,17 +553,11 @@ snapshots: cluster-key-slot@1.1.2: {} - color-convert@2.0.1: - dependencies: - color-name: 1.1.4 - - color-name@1.1.4: {} - - compress-commons@6.0.2: + compress-commons@7.0.1: dependencies: crc-32: 1.2.2 - crc32-stream: 6.0.0 - is-stream: 2.0.1 + crc32-stream: 7.0.1 + is-stream: 4.0.1 normalize-path: 3.0.0 readable-stream: 4.7.0 @@ -670,17 +572,11 @@ snapshots: crc-32@1.2.2: {} - crc32-stream@6.0.0: + crc32-stream@7.0.1: dependencies: crc-32: 1.2.2 readable-stream: 4.7.0 - cross-spawn@7.0.6: - dependencies: - path-key: 3.1.1 - shebang-command: 2.0.0 - which: 2.0.2 - css-select@5.2.2: dependencies: boolbase: 1.0.0 @@ -733,12 +629,6 @@ snapshots: domelementtype: 2.3.0 domhandler: 5.0.3 - eastasianwidth@0.2.0: {} - - emoji-regex@8.0.0: {} - - emoji-regex@9.2.2: {} - entities@4.5.0: {} event-target-shim@5.0.1: {} @@ -753,27 +643,11 @@ snapshots: fast-fifo@1.3.2: {} - foreground-child@3.3.1: - dependencies: - cross-spawn: 7.0.6 - signal-exit: 4.1.0 - fsevents@2.3.2: optional: true generic-pool@3.9.0: {} - glob@10.5.0: - dependencies: - foreground-child: 3.3.1 - jackspeak: 3.4.3 - minimatch: 9.0.5 - minipass: 7.1.2 - package-json-from-dist: 1.0.1 - path-scurry: 1.11.1 - - graceful-fs@4.2.11: {} - http-errors@2.0.1: dependencies: depd: 2.0.0 @@ -792,9 +666,9 @@ snapshots: inherits@2.0.4: {} - ioredis@5.8.2: + ioredis@5.10.1: dependencies: - '@ioredis/commands': 1.4.0 + '@ioredis/commands': 1.5.1 cluster-key-slot: 1.1.2 debug: 4.4.3 denque: 2.1.0 @@ -806,20 +680,10 @@ snapshots: transitivePeerDependencies: - supports-color - is-fullwidth-code-point@3.0.0: {} - - is-stream@2.0.1: {} + is-stream@4.0.1: {} isarray@1.0.0: {} - isexe@2.0.0: {} - - jackspeak@3.4.3: - dependencies: - '@isaacs/cliui': 8.0.2 - optionalDependencies: - '@pkgjs/parseargs': 0.11.0 - keygrip@1.1.0: dependencies: tsscmp: 1.0.6 @@ -834,21 +698,13 @@ snapshots: lodash@4.17.21: {} - lru-cache@10.4.3: {} - mdn-data@2.0.28: {} mdn-data@2.12.2: {} - minimatch@5.1.6: + minimatch@10.2.5: dependencies: - brace-expansion: 2.0.2 - - minimatch@9.0.5: - dependencies: - brace-expansion: 2.0.2 - - minipass@7.1.2: {} + brace-expansion: 5.0.6 ms@2.1.3: {} @@ -858,20 +714,11 @@ snapshots: dependencies: boolbase: 1.0.0 - package-json-from-dist@1.0.1: {} + playwright-core@1.60.0: {} - path-key@3.1.1: {} - - path-scurry@1.11.1: + playwright@1.60.0: dependencies: - lru-cache: 10.4.3 - minipass: 7.1.2 - - playwright-core@1.57.0: {} - - playwright@1.57.0: - dependencies: - playwright-core: 1.57.0 + playwright-core: 1.60.0 optionalDependencies: fsevents: 2.3.2 @@ -904,9 +751,9 @@ snapshots: process: 0.11.10 string_decoder: 1.3.0 - readdir-glob@1.1.3: + readdir-glob@3.0.0: dependencies: - minimatch: 5.1.6 + minimatch: 10.2.5 redis-errors@1.2.0: {} @@ -924,14 +771,6 @@ snapshots: setprototypeof@1.2.0: {} - shebang-command@2.0.0: - dependencies: - shebang-regex: 3.0.0 - - shebang-regex@3.0.0: {} - - signal-exit@4.1.0: {} - source-map-js@1.2.1: {} source-map-support@0.5.21: @@ -945,27 +784,15 @@ snapshots: statuses@2.0.2: {} - streamx@2.23.0: + streamx@2.25.0: dependencies: events-universal: 1.0.1 fast-fifo: 1.3.2 - text-decoder: 1.2.3 + text-decoder: 1.2.7 transitivePeerDependencies: - bare-abort-controller - react-native-b4a - string-width@4.2.3: - dependencies: - emoji-regex: 8.0.0 - is-fullwidth-code-point: 3.0.0 - strip-ansi: 6.0.1 - - string-width@5.1.2: - dependencies: - eastasianwidth: 0.2.0 - emoji-regex: 9.2.2 - strip-ansi: 7.1.2 - string_decoder@1.1.1: dependencies: safe-buffer: 5.1.2 @@ -974,14 +801,6 @@ snapshots: dependencies: safe-buffer: 5.2.1 - strip-ansi@6.0.1: - dependencies: - ansi-regex: 5.0.1 - - strip-ansi@7.1.2: - dependencies: - ansi-regex: 6.2.2 - svgo@https://codeload.github.com/penpot/svgo/tar.gz/a46262c12c0d967708395972c374eb2adead4180: dependencies: '@trysound/sax': 0.2.0 @@ -990,18 +809,27 @@ snapshots: csso: 5.0.5 lodash: 4.17.21 - tar-stream@3.1.7: + tar-stream@3.2.0: dependencies: - b4a: 1.7.3 + b4a: 1.8.1 + bare-fs: 4.7.1 fast-fifo: 1.3.2 - streamx: 2.23.0 + streamx: 2.25.0 + transitivePeerDependencies: + - bare-abort-controller + - bare-buffer + - react-native-b4a + + teex@1.0.1: + dependencies: + streamx: 2.25.0 transitivePeerDependencies: - bare-abort-controller - react-native-b4a - text-decoder@1.2.3: + text-decoder@1.2.7: dependencies: - b4a: 1.7.3 + b4a: 1.8.1 transitivePeerDependencies: - react-native-b4a @@ -1009,29 +837,13 @@ snapshots: tsscmp@1.0.6: {} - undici@7.16.0: {} + undici@8.2.0: {} unpipe@1.0.0: {} util-deprecate@1.0.2: {} - which@2.0.2: - dependencies: - isexe: 2.0.0 - - wrap-ansi@7.0.0: - dependencies: - ansi-styles: 4.3.0 - string-width: 4.2.3 - strip-ansi: 6.0.1 - - wrap-ansi@8.1.0: - dependencies: - ansi-styles: 6.2.3 - string-width: 5.1.2 - strip-ansi: 7.1.2 - - ws@8.18.3: {} + ws@8.20.1: {} xml-js@1.6.11: dependencies: @@ -1041,8 +853,8 @@ snapshots: dependencies: '@babel/runtime-corejs3': 7.28.4 - zip-stream@6.0.1: + zip-stream@7.0.5: dependencies: - archiver-utils: 5.0.2 - compress-commons: 6.0.2 + compress-commons: 7.0.1 + normalize-path: 3.0.0 readable-stream: 4.7.0 diff --git a/frontend/package.json b/frontend/package.json index 0aaa88ed4d..698642a25c 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -55,16 +55,16 @@ "@penpot/text-editor": "workspace:./text-editor", "@penpot/tokenscript": "workspace:./packages/tokenscript", "@penpot/ui": "workspace:./packages/ui", - "@playwright/test": "1.59.0", + "@playwright/test": "1.60.0", "@storybook/addon-docs": "10.1.11", "@storybook/addon-themes": "10.1.11", "@storybook/addon-vitest": "10.1.11", "@storybook/react-vite": "10.1.11", "@tokens-studio/sd-transforms": "1.2.11", "@types/node": "^25.0.3", - "@vitest/browser": "4.0.18", - "@vitest/browser-playwright": "^4.0.18", - "@vitest/coverage-v8": "4.0.18", + "@vitest/browser": "4.1.6", + "@vitest/browser-playwright": "^4.1.6", + "@vitest/coverage-v8": "4.1.6", "@zip.js/zip.js": "2.8.11", "autoprefixer": "^10.4.21", "compression": "^1.8.1", @@ -89,7 +89,7 @@ "npm-run-all": "^4.1.5", "opentype.js": "^1.3.4", "p-limit": "^6.2.0", - "playwright": "1.59.0", + "playwright": "1.60.0", "postcss": "^8.5.4", "postcss-clean": "^1.2.2", "postcss-modules": "^6.0.1", @@ -116,7 +116,7 @@ "typescript": "^5.9.2", "ua-parser-js": "2.0.7", "vite": "^7.3.1", - "vitest": "^4.0.18", + "vitest": "^4.1.6", "wait-on": "^9.0.3", "wasm-pack": "^0.13.1", "watcher": "^2.3.1", diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 5d14fe10a4..47593ba463 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -35,8 +35,8 @@ importers: specifier: workspace:./packages/ui version: link:packages/ui '@playwright/test': - specifier: 1.59.0 - version: 1.59.0 + specifier: 1.60.0 + version: 1.60.0 '@storybook/addon-docs': specifier: 10.1.11 version: 10.1.11(@types/react@19.2.13)(esbuild@0.28.0)(rollup@4.57.1)(storybook@10.1.11(@testing-library/dom@10.4.0)(prettier@3.5.3)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(vite@7.3.1(@types/node@25.2.1)(sass-embedded@1.97.3)(sass@1.97.3)(yaml@2.8.2)) @@ -45,7 +45,7 @@ importers: version: 10.1.11(storybook@10.1.11(@testing-library/dom@10.4.0)(prettier@3.5.3)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)) '@storybook/addon-vitest': specifier: 10.1.11 - version: 10.1.11(@vitest/browser-playwright@4.0.18)(@vitest/browser@4.0.18(vite@7.3.1(@types/node@25.2.1)(sass-embedded@1.97.3)(sass@1.97.3)(yaml@2.8.2))(vitest@4.0.18))(@vitest/runner@4.0.18)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(storybook@10.1.11(@testing-library/dom@10.4.0)(prettier@3.5.3)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(vitest@4.0.18) + version: 10.1.11(@vitest/browser-playwright@4.1.6)(@vitest/browser@4.1.6)(@vitest/runner@4.1.6)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(storybook@10.1.11(@testing-library/dom@10.4.0)(prettier@3.5.3)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(vitest@4.1.6) '@storybook/react-vite': specifier: 10.1.11 version: 10.1.11(esbuild@0.28.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(rollup@4.57.1)(storybook@10.1.11(@testing-library/dom@10.4.0)(prettier@3.5.3)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(typescript@5.9.3)(vite@7.3.1(@types/node@25.2.1)(sass-embedded@1.97.3)(sass@1.97.3)(yaml@2.8.2)) @@ -56,14 +56,14 @@ importers: specifier: ^25.0.3 version: 25.2.1 '@vitest/browser': - specifier: 4.0.18 - version: 4.0.18(vite@7.3.1(@types/node@25.2.1)(sass-embedded@1.97.3)(sass@1.97.3)(yaml@2.8.2))(vitest@4.0.18) + specifier: 4.1.6 + version: 4.1.6(vite@7.3.1(@types/node@25.2.1)(sass-embedded@1.97.3)(sass@1.97.3)(yaml@2.8.2))(vitest@4.1.6) '@vitest/browser-playwright': - specifier: ^4.0.18 - version: 4.0.18(playwright@1.59.0)(vite@7.3.1(@types/node@25.2.1)(sass-embedded@1.97.3)(sass@1.97.3)(yaml@2.8.2))(vitest@4.0.18) + specifier: ^4.1.6 + version: 4.1.6(playwright@1.60.0)(vite@7.3.1(@types/node@25.2.1)(sass-embedded@1.97.3)(sass@1.97.3)(yaml@2.8.2))(vitest@4.1.6) '@vitest/coverage-v8': - specifier: 4.0.18 - version: 4.0.18(@vitest/browser@4.0.18(vite@7.3.1(@types/node@25.2.1)(sass-embedded@1.97.3)(sass@1.97.3)(yaml@2.8.2))(vitest@4.0.18))(vitest@4.0.18) + specifier: 4.1.6 + version: 4.1.6(@vitest/browser@4.1.6)(vitest@4.1.6) '@zip.js/zip.js': specifier: 2.8.11 version: 2.8.11(patch_hash=7b556bbd426f152eb086f0126a53900e369a95cf64357c380b7c8d8e940c3d95) @@ -137,8 +137,8 @@ importers: specifier: ^6.2.0 version: 6.2.0 playwright: - specifier: 1.59.0 - version: 1.59.0 + specifier: 1.60.0 + version: 1.60.0 postcss: specifier: ^8.5.4 version: 8.5.6 @@ -218,8 +218,8 @@ importers: specifier: ^7.3.1 version: 7.3.1(@types/node@25.2.1)(sass-embedded@1.97.3)(sass@1.97.3)(yaml@2.8.2) vitest: - specifier: ^4.0.18 - version: 4.0.18(@types/node@25.2.1)(@vitest/browser-playwright@4.0.18)(jsdom@27.4.0(canvas@3.2.1))(sass-embedded@1.97.3)(sass@1.97.3)(yaml@2.8.2) + specifier: ^4.1.6 + version: 4.1.6(@types/node@25.2.1)(@vitest/browser-playwright@4.1.6)(@vitest/coverage-v8@4.1.6)(jsdom@27.4.0(canvas@3.2.1))(vite@7.3.1(@types/node@25.2.1)(sass-embedded@1.97.3)(sass@1.97.3)(yaml@2.8.2)) wait-on: specifier: ^9.0.3 version: 9.0.3 @@ -334,7 +334,7 @@ importers: version: 25.2.1 '@vitest/browser': specifier: ^1.6.0 - version: 1.6.1(playwright@1.58.0)(vitest@1.6.1) + version: 1.6.1(playwright@1.60.0)(vitest@1.6.1) '@vitest/coverage-v8': specifier: ^1.6.0 version: 1.6.1(vitest@1.6.1) @@ -352,7 +352,7 @@ importers: version: 27.4.0(canvas@3.2.1) playwright: specifier: ^1.45.1 - version: 1.58.0 + version: 1.60.0 prettier: specifier: ^3.7.4 version: 3.8.1 @@ -453,6 +453,11 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + '@babel/parser@7.29.3': + resolution: {integrity: sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==} + engines: {node: '>=6.0.0'} + hasBin: true + '@babel/plugin-syntax-jsx@7.28.6': resolution: {integrity: sha512-wgEmr06G6sIpqr8YDwA2dSRTE3bJ+V0IfpzfSY3Lfgd7YWOaAdlykvJi13ZKBt8cZHfgH1IXN+CL656W3uUa4w==} engines: {node: '>=6.9.0'} @@ -528,6 +533,9 @@ packages: resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} engines: {node: '>=18'} + '@blazediff/core@1.9.1': + resolution: {integrity: sha512-ehg3jIkYKulZh+8om/O25vkvSsXXwC+skXmyA87FFx6A/45eqOkZsBltMw/TVteb0mloiGT8oGRTcjRAz66zaA==} + '@bufbuild/protobuf@2.11.0': resolution: {integrity: sha512-sBXGT13cpmPR5BMgHE6UEEfEaShh5Ror6rfN3yEK5si7QVrtZg8LEPQb0VVhiLRUslD2yLnXtnRzG035J/mZXQ==} @@ -1574,8 +1582,8 @@ packages: engines: {node: '>=18'} hasBin: true - '@playwright/test@1.59.0': - resolution: {integrity: sha512-TOA5sTLd49rTDaZpYpvCQ9hGefHQq/OYOyCVnGqS2mjMfX+lGZv2iddIJd0I48cfxqSPttS9S3OuLKyylHcO1w==} + '@playwright/test@1.60.0': + resolution: {integrity: sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==} engines: {node: '>=18'} hasBin: true @@ -2106,11 +2114,11 @@ packages: peerDependencies: vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 - '@vitest/browser-playwright@4.0.18': - resolution: {integrity: sha512-gfajTHVCiwpxRj1qh0Sh/5bbGLG4F/ZH/V9xvFVoFddpITfMta9YGow0W6ZpTTORv2vdJuz9TnrNSmjKvpOf4g==} + '@vitest/browser-playwright@4.1.6': + resolution: {integrity: sha512-4csoeyl/qwHyxU2zNL0++WaoDr8YJDXOQPwWPNJoTZ+QzcdO3INYKgF5Zfz730Io7zbkuv914aZmfQ+QE+1Hvw==} peerDependencies: playwright: '*' - vitest: 4.0.18 + vitest: 4.1.6 '@vitest/browser@1.6.1': resolution: {integrity: sha512-9ZYW6KQ30hJ+rIfJoGH4wAub/KAb4YrFzX0kVLASvTm7nJWVC5EAv5SlzlXVl3h3DaUq5aqHlZl77nmOPnALUQ==} @@ -2127,21 +2135,21 @@ packages: webdriverio: optional: true - '@vitest/browser@4.0.18': - resolution: {integrity: sha512-gVQqh7paBz3gC+ZdcCmNSWJMk70IUjDeVqi+5m5vYpEHsIwRgw3Y545jljtajhkekIpIp5Gg8oK7bctgY0E2Ng==} + '@vitest/browser@4.1.6': + resolution: {integrity: sha512-ynsspTubXGSpa58JFJ24xIQt4z4A25epSbugEyaTmmrV1//Wec9EgE/LtoaC6yxUrXi5P7erGHRrkdZIHaVQuA==} peerDependencies: - vitest: 4.0.18 + vitest: 4.1.6 '@vitest/coverage-v8@1.6.1': resolution: {integrity: sha512-6YeRZwuO4oTGKxD3bijok756oktHSIm3eczVVzNe3scqzuhLwltIF3S9ZL/vwOVIpURmU6SnZhziXXAfw8/Qlw==} peerDependencies: vitest: 1.6.1 - '@vitest/coverage-v8@4.0.18': - resolution: {integrity: sha512-7i+N2i0+ME+2JFZhfuz7Tg/FqKtilHjGyGvoHYQ6iLV0zahbsJ9sljC9OcFcPDbhYKCet+sG8SsVqlyGvPflZg==} + '@vitest/coverage-v8@4.1.6': + resolution: {integrity: sha512-36l628fQ/9a/8ihy97eOtEnvWQEdqULQOJtcaxtoNq0G1w3Mxd4szSahOaMM9/NGyZ+hyKcMtIW/WIxq0XQViQ==} peerDependencies: - '@vitest/browser': 4.0.18 - vitest: 4.0.18 + '@vitest/browser': 4.1.6 + vitest: 4.1.6 peerDependenciesMeta: '@vitest/browser': optional: true @@ -2152,8 +2160,8 @@ packages: '@vitest/expect@3.2.4': resolution: {integrity: sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==} - '@vitest/expect@4.0.18': - resolution: {integrity: sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==} + '@vitest/expect@4.1.6': + resolution: {integrity: sha512-7EHDquPthALSV0jhhjgEW8FXaviMx7rSqu8W6oqCoAuOhKov814P99QDV1pxMA3QPv21YudvJngIhjrNI4opLg==} '@vitest/mocker@3.2.4': resolution: {integrity: sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==} @@ -2166,11 +2174,11 @@ packages: vite: optional: true - '@vitest/mocker@4.0.18': - resolution: {integrity: sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ==} + '@vitest/mocker@4.1.6': + resolution: {integrity: sha512-MCFc63czMjEInOlcY2cpQCvCN+KgbAn+60xu9cMgP4sKaLC5JNAKw7JH8QdAnoAC88hW1IiSNZ+GgVXlN1UcMQ==} peerDependencies: msw: ^2.4.9 - vite: ^6.0.0 || ^7.0.0-0 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 peerDependenciesMeta: msw: optional: true @@ -2180,20 +2188,20 @@ packages: '@vitest/pretty-format@3.2.4': resolution: {integrity: sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==} - '@vitest/pretty-format@4.0.18': - resolution: {integrity: sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==} + '@vitest/pretty-format@4.1.6': + resolution: {integrity: sha512-h5SxD/IzNhZYnrSZRsUZQIC+vD0GY8cUvq0iwsmkFKixRCKLLWqCXa/FIQ4S1R+sI+PGoojkHsdNrbZiM9Qpgw==} '@vitest/runner@1.6.1': resolution: {integrity: sha512-3nSnYXkVkf3mXFfE7vVyPmi3Sazhb/2cfZGGs0JRzFsPFvAMBEcrweV1V1GsrstdXeKCTXlJbvnQwGWgEIHmOA==} - '@vitest/runner@4.0.18': - resolution: {integrity: sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw==} + '@vitest/runner@4.1.6': + resolution: {integrity: sha512-nOPCmn2+yD0ZNmKdsXGv/UxMMWbMuKeD6GyYncNwdkYDxpQvrPSKYj2rWuDjC2Y4b6w6hjip5dBKFzEUuZe3vA==} '@vitest/snapshot@1.6.1': resolution: {integrity: sha512-WvidQuWAzU2p95u8GAKlRMqMyN1yOJkGHnx3M1PL9Raf7AQ1kwLKg04ADlCa3+OXUZE7BceOhVZiuWAbzCKcUQ==} - '@vitest/snapshot@4.0.18': - resolution: {integrity: sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA==} + '@vitest/snapshot@4.1.6': + resolution: {integrity: sha512-YhsdE6xAVfTDmzjxL2ZDUvjj+ZsgyOKe+TdQzqkD72wIOmHka8NuGQ6NpTNZv9D2Z63fbwWKJPeVpEw4EQgYxw==} '@vitest/spy@1.6.1': resolution: {integrity: sha512-MGcMmpGkZebsMZhbQKkAf9CX5zGvjkBTqf8Zx3ApYWXr3wG+QvEu2eXWfnIIWYSJExIp4V9FCKDEeygzkYrXMw==} @@ -2201,8 +2209,8 @@ packages: '@vitest/spy@3.2.4': resolution: {integrity: sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==} - '@vitest/spy@4.0.18': - resolution: {integrity: sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw==} + '@vitest/spy@4.1.6': + resolution: {integrity: sha512-JFKxMx6udhwKh/Ldo270e17QX710vgunMkuPAvXjHSvC6oqLWAHhVhjg/I71q0u0CBSErIODV1Kjv0FQNSWjdg==} '@vitest/ui@1.6.1': resolution: {integrity: sha512-xa57bCPGuzEFqGjPs3vVLyqareG8DX0uMkr5U/v5vLv5/ZUrBrPL7gzxzTJedEyZxFMfsozwTIbbYfEQVo3kgg==} @@ -2215,8 +2223,8 @@ packages: '@vitest/utils@3.2.4': resolution: {integrity: sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==} - '@vitest/utils@4.0.18': - resolution: {integrity: sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==} + '@vitest/utils@4.1.6': + resolution: {integrity: sha512-FxIY+U81R3LGKCxaHHFRQ5+g6/iRgGLmeHWdp2Amj4ljQRrEIWHmZyDfDYBRZlpyqA7qKxtS9DD1dhk8RnRIVQ==} '@volar/language-core@2.4.28': resolution: {integrity: sha512-w4qhIJ8ZSitgLAkVay6AbcnC7gP3glYM3fYwKV3srj8m494E3xtrCv6E+bWviiK/8hs6e6t1ij1s2Endql7vzQ==} @@ -2420,8 +2428,8 @@ packages: resolution: {integrity: sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg==} engines: {node: '>=4'} - ast-v8-to-istanbul@0.3.11: - resolution: {integrity: sha512-Qya9fkoofMjCBNVdWINMjB5KZvkYfaO9/anwkWnjxibpWUxo5iHl2sOdP7/uAqaRuUYuoo8rDwnbaaKVFxoUvw==} + ast-v8-to-istanbul@1.0.0: + resolution: {integrity: sha512-1fSfIwuDICFA4LKkCzRPO7F0hzFf0B7+Xqrl27ynQaa+Rh0e1Es0v6kWHPott3lU10AyAr7oKHa65OppjLn3Rg==} async-function@1.0.0: resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==} @@ -2499,8 +2507,8 @@ packages: brace-expansion@1.1.12: resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==} - brace-expansion@1.1.13: - resolution: {integrity: sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==} + brace-expansion@1.1.14: + resolution: {integrity: sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==} brace-expansion@2.0.2: resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==} @@ -3066,8 +3074,8 @@ packages: resolution: {integrity: sha512-BrUQ0cPTB/IwXj23HtwHjS9n7O4h9FX94b4xc5zlTHxeLgTAdzYUDyy6KdExAl9lbN5rtfe44xpjpmj9grxs5w==} engines: {node: '>= 0.4'} - es-module-lexer@1.7.0: - resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + es-module-lexer@2.1.0: + resolution: {integrity: sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==} es-object-atoms@1.1.1: resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} @@ -4435,6 +4443,10 @@ packages: resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} engines: {node: '>=12'} + picomatch@4.0.4: + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} + engines: {node: '>=12'} + pidtree@0.3.1: resolution: {integrity: sha512-qQbW94hLHEqCg7nhby4yRC7G2+jYHY4Rguc2bjw7Uug4GIJuu1tvf2uHaZv5Q8zdt+WKJ6qK1FOI6amaWUo5FA==} engines: {node: '>=0.10'} @@ -4444,10 +4456,6 @@ packages: resolution: {integrity: sha512-C3FsVNH1udSEX48gGX1xfvwTWfsYWj5U+8/uK15BGzIGrKoUpghX8hWZwa/OFnakBiiVNmBvemTJR5mcy7iPcg==} engines: {node: '>=4'} - pixelmatch@7.1.0: - resolution: {integrity: sha512-1wrVzJ2STrpmONHKBy228LM1b84msXDUoAzVEl0R8Mz4Ce6EPr+IVtxm8+yvrqLYMHswREkjYFaMxnyGnaY3Ng==} - hasBin: true - pkg-types@1.3.1: resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} @@ -4459,8 +4467,8 @@ packages: engines: {node: '>=18'} hasBin: true - playwright-core@1.59.0: - resolution: {integrity: sha512-PW/X/IoZ6BMUUy8rpwHEZ8Kc0IiLIkgKYGNFaMs5KmQhcfLILNx9yCQD0rnWeWfz1PNeqcFP1BsihQhDOBCwZw==} + playwright-core@1.60.0: + resolution: {integrity: sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==} engines: {node: '>=18'} hasBin: true @@ -4469,8 +4477,8 @@ packages: engines: {node: '>=18'} hasBin: true - playwright@1.59.0: - resolution: {integrity: sha512-wihGScriusvATUxmhfENxg0tj1vHEFeIwxlnPFKQTOQVd7aG08mUfvvniRP/PtQOC+2Bs52kBOC/Up1jTXeIbw==} + playwright@1.60.0: + resolution: {integrity: sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==} engines: {node: '>=18'} hasBin: true @@ -5105,6 +5113,9 @@ packages: std-env@3.10.0: resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + std-env@4.1.0: + resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==} + stop-iteration-iterator@1.1.0: resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} engines: {node: '>= 0.4'} @@ -5309,14 +5320,18 @@ packages: tinycolor2@1.6.0: resolution: {integrity: sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw==} - tinyexec@1.0.2: - resolution: {integrity: sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==} + tinyexec@1.1.2: + resolution: {integrity: sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA==} engines: {node: '>=18'} tinyglobby@0.2.15: resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} engines: {node: '>=12.0.0'} + tinyglobby@0.2.16: + resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==} + engines: {node: '>=12.0.0'} + tinypool@0.8.4: resolution: {integrity: sha512-i11VH5gS6IFeLY3gMBQ00/MmLncVP7JLXOw1vlgkytLmJK7QnEr7NXf0LBdxfmNPAeyetukOk0bOYrJrFGjYJQ==} engines: {node: '>=14.0.0'} @@ -5325,8 +5340,8 @@ packages: resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==} engines: {node: '>=14.0.0'} - tinyrainbow@3.0.3: - resolution: {integrity: sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==} + tinyrainbow@3.1.0: + resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} engines: {node: '>=14.0.0'} tinyspy@2.2.1: @@ -5634,20 +5649,23 @@ packages: jsdom: optional: true - vitest@4.0.18: - resolution: {integrity: sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==} + vitest@4.1.6: + resolution: {integrity: sha512-6lvjbS3p9b4CrdCmguzbh2/4uoXhGE2q71R4OX5sqF9R1bo9Xd6fGrMAfvp5wnCzlBnFVdCOp6onuTQVbo8iUQ==} engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} hasBin: true peerDependencies: '@edge-runtime/vm': '*' '@opentelemetry/api': ^1.9.0 '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 - '@vitest/browser-playwright': 4.0.18 - '@vitest/browser-preview': 4.0.18 - '@vitest/browser-webdriverio': 4.0.18 - '@vitest/ui': 4.0.18 + '@vitest/browser-playwright': 4.1.6 + '@vitest/browser-preview': 4.1.6 + '@vitest/browser-webdriverio': 4.1.6 + '@vitest/coverage-istanbul': 4.1.6 + '@vitest/coverage-v8': 4.1.6 + '@vitest/ui': 4.1.6 happy-dom: '*' jsdom: '*' + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 peerDependenciesMeta: '@edge-runtime/vm': optional: true @@ -5661,6 +5679,10 @@ packages: optional: true '@vitest/browser-webdriverio': optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true '@vitest/ui': optional: true happy-dom: @@ -5780,6 +5802,18 @@ packages: utf-8-validate: optional: true + ws@8.20.1: + resolution: {integrity: sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + wsl-utils@0.1.0: resolution: {integrity: sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==} engines: {node: '>=18'} @@ -5960,6 +5994,10 @@ snapshots: dependencies: '@babel/types': 7.29.0 + '@babel/parser@7.29.3': + dependencies: + '@babel/types': 7.29.0 + '@babel/plugin-syntax-jsx@7.28.6(@babel/core@7.29.0)': dependencies: '@babel/core': 7.29.0 @@ -6049,6 +6087,8 @@ snapshots: '@bcoe/v8-coverage@1.0.2': {} + '@blazediff/core@1.9.1': {} + '@bufbuild/protobuf@2.11.0': {} '@bundled-es-modules/deepmerge@4.3.1': @@ -6796,9 +6836,9 @@ snapshots: dependencies: playwright: 1.58.0 - '@playwright/test@1.59.0': + '@playwright/test@1.60.0': dependencies: - playwright: 1.59.0 + playwright: 1.60.0 '@polka/url@1.0.0-next.29': {} @@ -7010,16 +7050,16 @@ snapshots: storybook: 10.1.11(@testing-library/dom@10.4.0)(prettier@3.5.3)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) ts-dedent: 2.2.0 - '@storybook/addon-vitest@10.1.11(@vitest/browser-playwright@4.0.18)(@vitest/browser@4.0.18(vite@7.3.1(@types/node@25.2.1)(sass-embedded@1.97.3)(sass@1.97.3)(yaml@2.8.2))(vitest@4.0.18))(@vitest/runner@4.0.18)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(storybook@10.1.11(@testing-library/dom@10.4.0)(prettier@3.5.3)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(vitest@4.0.18)': + '@storybook/addon-vitest@10.1.11(@vitest/browser-playwright@4.1.6)(@vitest/browser@4.1.6)(@vitest/runner@4.1.6)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(storybook@10.1.11(@testing-library/dom@10.4.0)(prettier@3.5.3)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(vitest@4.1.6)': dependencies: '@storybook/global': 5.0.0 '@storybook/icons': 2.0.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3) storybook: 10.1.11(@testing-library/dom@10.4.0)(prettier@3.5.3)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) optionalDependencies: - '@vitest/browser': 4.0.18(vite@7.3.1(@types/node@25.2.1)(sass-embedded@1.97.3)(sass@1.97.3)(yaml@2.8.2))(vitest@4.0.18) - '@vitest/browser-playwright': 4.0.18(playwright@1.59.0)(vite@7.3.1(@types/node@25.2.1)(sass-embedded@1.97.3)(sass@1.97.3)(yaml@2.8.2))(vitest@4.0.18) - '@vitest/runner': 4.0.18 - vitest: 4.0.18(@types/node@25.2.1)(@vitest/browser-playwright@4.0.18)(jsdom@27.4.0(canvas@3.2.1))(sass-embedded@1.97.3)(sass@1.97.3)(yaml@2.8.2) + '@vitest/browser': 4.1.6(vite@7.3.1(@types/node@25.2.1)(sass-embedded@1.97.3)(sass@1.97.3)(yaml@2.8.2))(vitest@4.1.6) + '@vitest/browser-playwright': 4.1.6(playwright@1.60.0)(vite@7.3.1(@types/node@25.2.1)(sass-embedded@1.97.3)(sass@1.97.3)(yaml@2.8.2))(vitest@4.1.6) + '@vitest/runner': 4.1.6 + vitest: 4.1.6(@types/node@25.2.1)(@vitest/browser-playwright@4.1.6)(@vitest/coverage-v8@4.1.6)(jsdom@27.4.0(canvas@3.2.1))(vite@7.3.1(@types/node@25.2.1)(sass-embedded@1.97.3)(sass@1.97.3)(yaml@2.8.2)) transitivePeerDependencies: - react - react-dom @@ -7290,39 +7330,39 @@ snapshots: transitivePeerDependencies: - supports-color - '@vitest/browser-playwright@4.0.18(playwright@1.59.0)(vite@7.3.1(@types/node@25.2.1)(sass-embedded@1.97.3)(sass@1.97.3)(yaml@2.8.2))(vitest@4.0.18)': + '@vitest/browser-playwright@4.1.6(playwright@1.60.0)(vite@7.3.1(@types/node@25.2.1)(sass-embedded@1.97.3)(sass@1.97.3)(yaml@2.8.2))(vitest@4.1.6)': dependencies: - '@vitest/browser': 4.0.18(vite@7.3.1(@types/node@25.2.1)(sass-embedded@1.97.3)(sass@1.97.3)(yaml@2.8.2))(vitest@4.0.18) - '@vitest/mocker': 4.0.18(vite@7.3.1(@types/node@25.2.1)(sass-embedded@1.97.3)(sass@1.97.3)(yaml@2.8.2)) - playwright: 1.59.0 - tinyrainbow: 3.0.3 - vitest: 4.0.18(@types/node@25.2.1)(@vitest/browser-playwright@4.0.18)(jsdom@27.4.0(canvas@3.2.1))(sass-embedded@1.97.3)(sass@1.97.3)(yaml@2.8.2) + '@vitest/browser': 4.1.6(vite@7.3.1(@types/node@25.2.1)(sass-embedded@1.97.3)(sass@1.97.3)(yaml@2.8.2))(vitest@4.1.6) + '@vitest/mocker': 4.1.6(vite@7.3.1(@types/node@25.2.1)(sass-embedded@1.97.3)(sass@1.97.3)(yaml@2.8.2)) + playwright: 1.60.0 + tinyrainbow: 3.1.0 + vitest: 4.1.6(@types/node@25.2.1)(@vitest/browser-playwright@4.1.6)(@vitest/coverage-v8@4.1.6)(jsdom@27.4.0(canvas@3.2.1))(vite@7.3.1(@types/node@25.2.1)(sass-embedded@1.97.3)(sass@1.97.3)(yaml@2.8.2)) transitivePeerDependencies: - bufferutil - msw - utf-8-validate - vite - '@vitest/browser@1.6.1(playwright@1.58.0)(vitest@1.6.1)': + '@vitest/browser@1.6.1(playwright@1.60.0)(vitest@1.6.1)': dependencies: '@vitest/utils': 1.6.1 magic-string: 0.30.21 sirv: 2.0.4 vitest: 1.6.1(@types/node@25.2.1)(@vitest/browser@1.6.1)(@vitest/ui@1.6.1)(jsdom@27.4.0(canvas@3.2.1))(sass-embedded@1.97.3)(sass@1.97.3) optionalDependencies: - playwright: 1.58.0 + playwright: 1.60.0 - '@vitest/browser@4.0.18(vite@7.3.1(@types/node@25.2.1)(sass-embedded@1.97.3)(sass@1.97.3)(yaml@2.8.2))(vitest@4.0.18)': + '@vitest/browser@4.1.6(vite@7.3.1(@types/node@25.2.1)(sass-embedded@1.97.3)(sass@1.97.3)(yaml@2.8.2))(vitest@4.1.6)': dependencies: - '@vitest/mocker': 4.0.18(vite@7.3.1(@types/node@25.2.1)(sass-embedded@1.97.3)(sass@1.97.3)(yaml@2.8.2)) - '@vitest/utils': 4.0.18 + '@blazediff/core': 1.9.1 + '@vitest/mocker': 4.1.6(vite@7.3.1(@types/node@25.2.1)(sass-embedded@1.97.3)(sass@1.97.3)(yaml@2.8.2)) + '@vitest/utils': 4.1.6 magic-string: 0.30.21 - pixelmatch: 7.1.0 pngjs: 7.0.0 sirv: 3.0.2 - tinyrainbow: 3.0.3 - vitest: 4.0.18(@types/node@25.2.1)(@vitest/browser-playwright@4.0.18)(jsdom@27.4.0(canvas@3.2.1))(sass-embedded@1.97.3)(sass@1.97.3)(yaml@2.8.2) - ws: 8.19.0 + tinyrainbow: 3.1.0 + vitest: 4.1.6(@types/node@25.2.1)(@vitest/browser-playwright@4.1.6)(@vitest/coverage-v8@4.1.6)(jsdom@27.4.0(canvas@3.2.1))(vite@7.3.1(@types/node@25.2.1)(sass-embedded@1.97.3)(sass@1.97.3)(yaml@2.8.2)) + ws: 8.20.1 transitivePeerDependencies: - bufferutil - msw @@ -7348,21 +7388,21 @@ snapshots: transitivePeerDependencies: - supports-color - '@vitest/coverage-v8@4.0.18(@vitest/browser@4.0.18(vite@7.3.1(@types/node@25.2.1)(sass-embedded@1.97.3)(sass@1.97.3)(yaml@2.8.2))(vitest@4.0.18))(vitest@4.0.18)': + '@vitest/coverage-v8@4.1.6(@vitest/browser@4.1.6)(vitest@4.1.6)': dependencies: '@bcoe/v8-coverage': 1.0.2 - '@vitest/utils': 4.0.18 - ast-v8-to-istanbul: 0.3.11 + '@vitest/utils': 4.1.6 + ast-v8-to-istanbul: 1.0.0 istanbul-lib-coverage: 3.2.2 istanbul-lib-report: 3.0.1 istanbul-reports: 3.2.0 magicast: 0.5.2 obug: 2.1.1 - std-env: 3.10.0 - tinyrainbow: 3.0.3 - vitest: 4.0.18(@types/node@25.2.1)(@vitest/browser-playwright@4.0.18)(jsdom@27.4.0(canvas@3.2.1))(sass-embedded@1.97.3)(sass@1.97.3)(yaml@2.8.2) + std-env: 4.1.0 + tinyrainbow: 3.1.0 + vitest: 4.1.6(@types/node@25.2.1)(@vitest/browser-playwright@4.1.6)(@vitest/coverage-v8@4.1.6)(jsdom@27.4.0(canvas@3.2.1))(vite@7.3.1(@types/node@25.2.1)(sass-embedded@1.97.3)(sass@1.97.3)(yaml@2.8.2)) optionalDependencies: - '@vitest/browser': 4.0.18(vite@7.3.1(@types/node@25.2.1)(sass-embedded@1.97.3)(sass@1.97.3)(yaml@2.8.2))(vitest@4.0.18) + '@vitest/browser': 4.1.6(vite@7.3.1(@types/node@25.2.1)(sass-embedded@1.97.3)(sass@1.97.3)(yaml@2.8.2))(vitest@4.1.6) '@vitest/expect@1.6.1': dependencies: @@ -7378,14 +7418,14 @@ snapshots: chai: 5.3.3 tinyrainbow: 2.0.0 - '@vitest/expect@4.0.18': + '@vitest/expect@4.1.6': dependencies: '@standard-schema/spec': 1.1.0 '@types/chai': 5.2.3 - '@vitest/spy': 4.0.18 - '@vitest/utils': 4.0.18 + '@vitest/spy': 4.1.6 + '@vitest/utils': 4.1.6 chai: 6.2.2 - tinyrainbow: 3.0.3 + tinyrainbow: 3.1.0 '@vitest/mocker@3.2.4(vite@7.3.1(@types/node@25.2.1)(sass-embedded@1.97.3)(sass@1.97.3)(yaml@2.8.2))': dependencies: @@ -7395,9 +7435,9 @@ snapshots: optionalDependencies: vite: 7.3.1(@types/node@25.2.1)(sass-embedded@1.97.3)(sass@1.97.3)(yaml@2.8.2) - '@vitest/mocker@4.0.18(vite@7.3.1(@types/node@25.2.1)(sass-embedded@1.97.3)(sass@1.97.3)(yaml@2.8.2))': + '@vitest/mocker@4.1.6(vite@7.3.1(@types/node@25.2.1)(sass-embedded@1.97.3)(sass@1.97.3)(yaml@2.8.2))': dependencies: - '@vitest/spy': 4.0.18 + '@vitest/spy': 4.1.6 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: @@ -7407,9 +7447,9 @@ snapshots: dependencies: tinyrainbow: 2.0.0 - '@vitest/pretty-format@4.0.18': + '@vitest/pretty-format@4.1.6': dependencies: - tinyrainbow: 3.0.3 + tinyrainbow: 3.1.0 '@vitest/runner@1.6.1': dependencies: @@ -7417,9 +7457,9 @@ snapshots: p-limit: 5.0.0 pathe: 1.1.2 - '@vitest/runner@4.0.18': + '@vitest/runner@4.1.6': dependencies: - '@vitest/utils': 4.0.18 + '@vitest/utils': 4.1.6 pathe: 2.0.3 '@vitest/snapshot@1.6.1': @@ -7428,9 +7468,10 @@ snapshots: pathe: 1.1.2 pretty-format: 29.7.0 - '@vitest/snapshot@4.0.18': + '@vitest/snapshot@4.1.6': dependencies: - '@vitest/pretty-format': 4.0.18 + '@vitest/pretty-format': 4.1.6 + '@vitest/utils': 4.1.6 magic-string: 0.30.21 pathe: 2.0.3 @@ -7442,7 +7483,7 @@ snapshots: dependencies: tinyspy: 4.0.4 - '@vitest/spy@4.0.18': {} + '@vitest/spy@4.1.6': {} '@vitest/ui@1.6.1(vitest@1.6.1)': dependencies: @@ -7468,10 +7509,11 @@ snapshots: loupe: 3.2.1 tinyrainbow: 2.0.0 - '@vitest/utils@4.0.18': + '@vitest/utils@4.1.6': dependencies: - '@vitest/pretty-format': 4.0.18 - tinyrainbow: 3.0.3 + '@vitest/pretty-format': 4.1.6 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.0 '@volar/language-core@2.4.28': dependencies: @@ -7710,7 +7752,7 @@ snapshots: dependencies: tslib: 2.8.1 - ast-v8-to-istanbul@0.3.11: + ast-v8-to-istanbul@1.0.0: dependencies: '@jridgewell/trace-mapping': 0.3.31 estree-walker: 3.0.3 @@ -7806,7 +7848,7 @@ snapshots: balanced-match: 1.0.2 concat-map: 0.0.1 - brace-expansion@1.1.13: + brace-expansion@1.1.14: dependencies: balanced-match: 1.0.2 concat-map: 0.0.1 @@ -8427,7 +8469,7 @@ snapshots: iterator.prototype: 1.1.5 safe-array-concat: 1.1.3 - es-module-lexer@1.7.0: {} + es-module-lexer@2.1.0: {} es-object-atoms@1.1.1: dependencies: @@ -8843,6 +8885,10 @@ snapshots: optionalDependencies: picomatch: 4.0.3 + fdir@6.5.0(picomatch@4.0.4): + optionalDependencies: + picomatch: 4.0.4 + fecha@4.2.3: {} fflate@0.8.2: {} @@ -9584,7 +9630,7 @@ snapshots: magicast@0.5.2: dependencies: - '@babel/parser': 7.29.0 + '@babel/parser': 7.29.3 '@babel/types': 7.29.0 source-map-js: 1.2.1 @@ -9664,7 +9710,7 @@ snapshots: minimatch@3.1.5: dependencies: - brace-expansion: 1.1.13 + brace-expansion: 1.1.14 minimatch@9.0.1: dependencies: @@ -9986,14 +10032,12 @@ snapshots: picomatch@4.0.3: {} + picomatch@4.0.4: {} + pidtree@0.3.1: {} pify@3.0.0: {} - pixelmatch@7.1.0: - dependencies: - pngjs: 7.0.0 - pkg-types@1.3.1: dependencies: confbox: 0.1.8 @@ -10008,7 +10052,7 @@ snapshots: playwright-core@1.58.0: {} - playwright-core@1.59.0: {} + playwright-core@1.60.0: {} playwright@1.58.0: dependencies: @@ -10016,9 +10060,9 @@ snapshots: optionalDependencies: fsevents: 2.3.2 - playwright@1.59.0: + playwright@1.60.0: dependencies: - playwright-core: 1.59.0 + playwright-core: 1.60.0 optionalDependencies: fsevents: 2.3.2 @@ -10702,6 +10746,8 @@ snapshots: std-env@3.10.0: {} + std-env@4.1.0: {} + stop-iteration-iterator@1.1.0: dependencies: es-errors: 1.3.0 @@ -10999,18 +11045,23 @@ snapshots: tinycolor2@1.6.0: {} - tinyexec@1.0.2: {} + tinyexec@1.1.2: {} tinyglobby@0.2.15: dependencies: fdir: 6.5.0(picomatch@4.0.3) picomatch: 4.0.3 + tinyglobby@0.2.16: + dependencies: + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + tinypool@0.8.4: {} tinyrainbow@2.0.0: {} - tinyrainbow@3.0.3: {} + tinyrainbow@3.1.0: {} tinyspy@2.2.1: {} @@ -11296,7 +11347,7 @@ snapshots: why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 25.2.1 - '@vitest/browser': 1.6.1(playwright@1.58.0)(vitest@1.6.1) + '@vitest/browser': 1.6.1(playwright@1.60.0)(vitest@1.6.1) '@vitest/ui': 1.6.1(vitest@1.6.1) jsdom: 27.4.0(canvas@3.2.1) transitivePeerDependencies: @@ -11309,44 +11360,35 @@ snapshots: - supports-color - terser - vitest@4.0.18(@types/node@25.2.1)(@vitest/browser-playwright@4.0.18)(jsdom@27.4.0(canvas@3.2.1))(sass-embedded@1.97.3)(sass@1.97.3)(yaml@2.8.2): + vitest@4.1.6(@types/node@25.2.1)(@vitest/browser-playwright@4.1.6)(@vitest/coverage-v8@4.1.6)(jsdom@27.4.0(canvas@3.2.1))(vite@7.3.1(@types/node@25.2.1)(sass-embedded@1.97.3)(sass@1.97.3)(yaml@2.8.2)): dependencies: - '@vitest/expect': 4.0.18 - '@vitest/mocker': 4.0.18(vite@7.3.1(@types/node@25.2.1)(sass-embedded@1.97.3)(sass@1.97.3)(yaml@2.8.2)) - '@vitest/pretty-format': 4.0.18 - '@vitest/runner': 4.0.18 - '@vitest/snapshot': 4.0.18 - '@vitest/spy': 4.0.18 - '@vitest/utils': 4.0.18 - es-module-lexer: 1.7.0 + '@vitest/expect': 4.1.6 + '@vitest/mocker': 4.1.6(vite@7.3.1(@types/node@25.2.1)(sass-embedded@1.97.3)(sass@1.97.3)(yaml@2.8.2)) + '@vitest/pretty-format': 4.1.6 + '@vitest/runner': 4.1.6 + '@vitest/snapshot': 4.1.6 + '@vitest/spy': 4.1.6 + '@vitest/utils': 4.1.6 + es-module-lexer: 2.1.0 expect-type: 1.3.0 magic-string: 0.30.21 obug: 2.1.1 pathe: 2.0.3 - picomatch: 4.0.3 - std-env: 3.10.0 + picomatch: 4.0.4 + std-env: 4.1.0 tinybench: 2.9.0 - tinyexec: 1.0.2 - tinyglobby: 0.2.15 - tinyrainbow: 3.0.3 + tinyexec: 1.1.2 + tinyglobby: 0.2.16 + tinyrainbow: 3.1.0 vite: 7.3.1(@types/node@25.2.1)(sass-embedded@1.97.3)(sass@1.97.3)(yaml@2.8.2) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 25.2.1 - '@vitest/browser-playwright': 4.0.18(playwright@1.59.0)(vite@7.3.1(@types/node@25.2.1)(sass-embedded@1.97.3)(sass@1.97.3)(yaml@2.8.2))(vitest@4.0.18) + '@vitest/browser-playwright': 4.1.6(playwright@1.60.0)(vite@7.3.1(@types/node@25.2.1)(sass-embedded@1.97.3)(sass@1.97.3)(yaml@2.8.2))(vitest@4.1.6) + '@vitest/coverage-v8': 4.1.6(@vitest/browser@4.1.6)(vitest@4.1.6) jsdom: 27.4.0(canvas@3.2.1) transitivePeerDependencies: - - jiti - - less - - lightningcss - msw - - sass - - sass-embedded - - stylus - - sugarss - - terser - - tsx - - yaml vscode-uri@3.1.0: {} @@ -11490,6 +11532,8 @@ snapshots: ws@8.19.0: {} + ws@8.20.1: {} + wsl-utils@0.1.0: dependencies: is-wsl: 3.1.0 From bf880467b4980a245ad2e4225c8eb584b9f7bf45 Mon Sep 17 00:00:00 2001 From: Pablo Alba Date: Wed, 13 May 2026 15:35:42 +0200 Subject: [PATCH 25/36] :bug: Fix dependency libraries visible in UI after unlinking main library (#9511) --- CHANGES.md | 1 + .../app/main/data/workspace/libraries.cljs | 33 ++++++++++++++++--- 2 files changed, 30 insertions(+), 4 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index 683cba1efd..6691112759 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -125,6 +125,7 @@ - Fix `:heigth` typo in clipboard frame-same-size? (by @iot2edge) [Github #9250](https://github.com/penpot/penpot/pull/9250) - Fix Settings and Notifications "Update Settings" button enabled state when form has no changes (by @moorsecopers99) [Github #9090](https://github.com/penpot/penpot/issues/9090) - Fix library updates reappear after being applied and the file is reloaded [Taiga #14040](https://tree.taiga.io/project/penpot/issue/14040) +- Fix dependency libraries remaining visible in UI after unlinking main library [Taiga #14020](https://tree.taiga.io/project/penpot/issue/14020) ## 2.15.0 (Unreleased) diff --git a/frontend/src/app/main/data/workspace/libraries.cljs b/frontend/src/app/main/data/workspace/libraries.cljs index 0685502a3c..5128f054d5 100644 --- a/frontend/src/app/main/data/workspace/libraries.cljs +++ b/frontend/src/app/main/data/workspace/libraries.cljs @@ -1558,6 +1558,27 @@ :variants-count variants-count :library-used-in (:used-in library-usage)})))))))))) +(defn cleanup-unlinked-libraries + "Remove libraries from state that are no longer linked to the given file. + This is used after unlinking a library to clean up transitive dependencies." + [file-id libraries] + (ptk/reify ::cleanup-unlinked-libraries + ptk/UpdateEvent + (update [_ state] + (let [linked-ids (into #{} (map :id) libraries)] + (update state :files + (fn [files] + (reduce-kv + (fn [acc id file] + (if (and (= (:library-of file) file-id) + (not (contains? linked-ids id)) + (not= id file-id)) + (dissoc acc id) + acc)) + files + files))))))) + + (defn unlink-file-from-library [file-id library-id] (ptk/reify ::detach-library @@ -1573,7 +1594,11 @@ ptk/WatchEvent (watch [_ _ _] - (let [params {:file-id file-id - :library-id library-id}] - (->> (rp/cmd! :unlink-file-from-library params) - (rx/ignore)))))) + ;; Unlink the library, then fetch the current list of linked libraries + ;; and remove any that are no longer linked (e.g., transitive dependencies) + (->> (rp/cmd! :unlink-file-from-library {:file-id file-id :library-id library-id}) + (rx/mapcat (fn [_] + (rp/cmd! :get-file-libraries {:file-id file-id}))) + (rx/map (partial cleanup-unlinked-libraries file-id)))))) + + From b125c2cabb80530991c7df66fc36bd189795475d Mon Sep 17 00:00:00 2001 From: Luis de Dios Date: Wed, 13 May 2026 15:51:24 +0200 Subject: [PATCH 26/36] :bug: Fix remove resize cursor CSS for inputs (#9590) * :bug: Remove cursor CSS for all inputs - Restore the default cursor for the dashboard inputs. - Make the numeric-input component from DS to work as expected. * :bug: Fix remove drag-to-change behaviour from old numeric-input --- frontend/resources/styles/common/base.scss | 8 -- .../styles/common/refactor/basic-rules.scss | 8 -- .../app/main/ui/components/numeric_input.cljs | 94 ++----------------- .../main/ui/ds/controls/numeric_input.cljs | 3 - .../sidebar/options/rows/color_row.scss | 10 -- 5 files changed, 10 insertions(+), 113 deletions(-) diff --git a/frontend/resources/styles/common/base.scss b/frontend/resources/styles/common/base.scss index 37df67b3e8..e8761a123f 100644 --- a/frontend/resources/styles/common/base.scss +++ b/frontend/resources/styles/common/base.scss @@ -27,14 +27,6 @@ body { width: 100vw; height: 100vh; overflow: hidden; - - &.cursor-drag-scrub { - cursor: ew-resize !important; - - * { - cursor: ew-resize !important; - } - } } #app { diff --git a/frontend/resources/styles/common/refactor/basic-rules.scss b/frontend/resources/styles/common/refactor/basic-rules.scss index efac327f36..2c9da12a90 100644 --- a/frontend/resources/styles/common/refactor/basic-rules.scss +++ b/frontend/resources/styles/common/refactor/basic-rules.scss @@ -378,14 +378,6 @@ border: $s-1 solid var(--input-border-color); color: var(--input-foreground-color); - &:not(:focus-within) { - cursor: ew-resize; - - input { - cursor: ew-resize; - } - } - span, label { @extend %input-label; diff --git a/frontend/src/app/main/ui/components/numeric_input.cljs b/frontend/src/app/main/ui/components/numeric_input.cljs index bec4efd044..3ad3f108ae 100644 --- a/frontend/src/app/main/ui/components/numeric_input.cljs +++ b/frontend/src/app/main/ui/components/numeric_input.cljs @@ -63,11 +63,6 @@ ;; Last value input by the user we need to store to save on unmount last-value* (mf/use-var value) - ;; Drag scrubbing state - drag-state* (mf/use-ref :idle) - drag-start-x* (mf/use-ref 0) - drag-start-val* (mf/use-ref 0) - parse-value (mf/use-fn (mf/deps min-value max-value value nillable? default integer?) @@ -222,80 +217,15 @@ (mf/use-callback (mf/deps on-focus select-on-focus?) (fn [event] - (when-not (= :dragging (mf/ref-val drag-state*)) - (reset! last-value* (parse-value)) - (let [target (dom/get-target event)] - (when on-focus - (mf/set-ref-val! dirty-ref true) - (on-focus event)) - - (when select-on-focus? - (dom/select-text! target) - ;; In webkit browsers the mouseup event will be called after the on-focus causing and unselect - (.addEventListener target "mouseup" dom/prevent-default #js {:once true})))))) - - on-scrub-pointer-down - (mf/use-fn - (mf/deps value value-str min-value max-value default) - (fn [event] - (let [disabled? (unchecked-get props "disabled") - node (mf/ref-val ref) - is-focused (and (some? node) (dom/active? node))] - (when-not (or disabled? is-focused (= :multiple value-str)) - (let [client-x (.-clientX event) - start-val (or value default 0)] - (mf/set-ref-val! drag-state* :maybe-dragging) - (mf/set-ref-val! drag-start-x* client-x) - (mf/set-ref-val! drag-start-val* start-val) - (dom/capture-pointer event)))))) - - on-scrub-pointer-move - (mf/use-fn - (mf/deps apply-value update-input step-value min-value max-value) - (fn [event] - (let [state (mf/ref-val drag-state*)] - (when (or (= state :maybe-dragging) (= state :dragging)) - (let [client-x (.-clientX event) - start-x (mf/ref-val drag-start-x*) - delta-x (- client-x start-x)] - (when (and (= state :maybe-dragging) - (>= (js/Math.abs delta-x) 3)) - (mf/set-ref-val! drag-state* :dragging) - (dom/add-class! (dom/get-body) "cursor-drag-scrub")) - (when (= (mf/ref-val drag-state*) :dragging) - (let [effective-step (cond - (.-shiftKey event) (* step-value 10) - (.-ctrlKey event) (* step-value 0.1) - :else step-value) - steps (js/Math.round (/ delta-x 1)) - new-val (+ (mf/ref-val drag-start-val*) - (* steps effective-step)) - new-val (cond-> new-val - (d/num? min-value) (mth/max min-value) - (d/num? max-value) (mth/min max-value))] - (update-input new-val) - (apply-value event new-val)))))))) - - on-scrub-pointer-up - (mf/use-fn - (mf/deps ref) - (fn [event] - (let [state (mf/ref-val drag-state*)] - (when (= state :maybe-dragging) - (mf/set-ref-val! drag-state* :idle) - (dom/release-pointer event) - (when-let [node (mf/ref-val ref)] - (dom/focus! node))) - (when (= state :dragging) - (mf/set-ref-val! drag-state* :idle) - (dom/remove-class! (dom/get-body) "cursor-drag-scrub") - (dom/release-pointer event))))) - - on-scrub-lost-pointer-capture - (mf/use-fn - (fn [_event] - (mf/set-ref-val! drag-state* :idle) - (dom/remove-class! (dom/get-body) "cursor-drag-scrub"))) + (reset! last-value* (parse-value)) + (let [target (dom/get-target event)] + (when on-focus + (mf/set-ref-val! dirty-ref true) + (on-focus event)) + (when select-on-focus? + (dom/select-text! target) + ;; In webkit browsers the mouseup event will be called after the on-focus causing and unselect + (.addEventListener target "mouseup" dom/prevent-default #js {:once true}))))) props (-> (obj/clone props) (obj/unset! "selectOnFocus") @@ -310,11 +240,7 @@ (obj/set! "title" title) (obj/set! "onKeyDown" handle-key-down) (obj/set! "onBlur" handle-blur) - (obj/set! "onFocus" handle-focus) - (obj/set! "onPointerDown" on-scrub-pointer-down) - (obj/set! "onPointerMove" on-scrub-pointer-move) - (obj/set! "onPointerUp" on-scrub-pointer-up) - (obj/set! "onLostPointerCapture" on-scrub-lost-pointer-capture))] + (obj/set! "onFocus" handle-focus))] (mf/with-effect [value] (when-let [input-node (mf/ref-val ref)] diff --git a/frontend/src/app/main/ui/ds/controls/numeric_input.cljs b/frontend/src/app/main/ui/ds/controls/numeric_input.cljs index d25773c9a7..4b7caf8622 100644 --- a/frontend/src/app/main/ui/ds/controls/numeric_input.cljs +++ b/frontend/src/app/main/ui/ds/controls/numeric_input.cljs @@ -531,7 +531,6 @@ (when (and (= state :maybe-dragging) (>= (js/Math.abs delta-x) 3)) (mf/set-ref-val! drag-state* :dragging) - (dom/add-class! (dom/get-body) "cursor-drag-scrub") (when (fn? on-change-start) (on-change-start))) (when (= (mf/ref-val drag-state*) :dragging) @@ -559,7 +558,6 @@ (dom/focus! node))) (when (= state :dragging) (mf/set-ref-val! drag-state* :idle) - (dom/remove-class! (dom/get-body) "cursor-drag-scrub") (dom/release-pointer event) (when (fn? on-change-end) (on-change-end))))))) @@ -571,7 +569,6 @@ (when-not is-token-applied? (let [was-dragging (= :dragging (mf/ref-val drag-state*))] (mf/set-ref-val! drag-state* :idle) - (dom/remove-class! (dom/get-body) "cursor-drag-scrub") (when (and was-dragging (fn? on-change-end)) (on-change-end)))))) diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/rows/color_row.scss b/frontend/src/app/main/ui/workspace/sidebar/options/rows/color_row.scss index c2d6485284..931a7431ca 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/rows/color_row.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/options/rows/color_row.scss @@ -196,11 +196,6 @@ display: flex; align-items: center; - - &:not(:focus-within) { - cursor: ew-resize; - } - block-size: $sz-32; inline-size: px2rem(60); padding-inline-start: var(--sp-xs); @@ -260,11 +255,6 @@ margin: var(--sp-xxs) 0; padding: 0 0 0 px2rem(6); color: var(--color-foreground-primary); - cursor: ew-resize; - - &:focus { - cursor: text; - } &[disabled] { opacity: 0.5; From c65b24495b5a590a4b99707c4b0a4d6fabf461e2 Mon Sep 17 00:00:00 2001 From: Alonso Torres Date: Wed, 13 May 2026 15:54:05 +0200 Subject: [PATCH 27/36] :bug: Fix several issues with color picker (#9558) --- .gitignore | 3 + .../app/main/ui/workspace/colorpicker.cljs | 7 +- .../ui/workspace/viewport/pixel_overlay.cljs | 213 ++++++++++++------ 3 files changed, 157 insertions(+), 66 deletions(-) diff --git a/.gitignore b/.gitignore index dc4861f51f..b295d6aeac 100644 --- a/.gitignore +++ b/.gitignore @@ -86,3 +86,6 @@ /**/.yarn/* /.pnpm-store /.vscode +/.idea +/.claude +/.playwright-mcp diff --git a/frontend/src/app/main/ui/workspace/colorpicker.cljs b/frontend/src/app/main/ui/workspace/colorpicker.cljs index 6dfd4f1798..c1f8ea396a 100644 --- a/frontend/src/app/main/ui/workspace/colorpicker.cljs +++ b/frontend/src/app/main/ui/workspace/colorpicker.cljs @@ -376,7 +376,12 @@ ;; Initialize colorpicker state (mf/with-effect [] (st/emit! (dc/initialize-colorpicker on-change active-fill-tab)) - (partial st/emit! (dc/finalize-colorpicker))) + ;; Always deactivate picking mode on unmount so that :picking-color? never + ;; stays true if the modal closes for any reason other than the normal + ;; pointer-up path (e.g. ESC, navigation, programmatic hide). + (fn [] + (st/emit! (dc/stop-picker) + (dc/finalize-colorpicker)))) ;; Update colorpicker with external color changes (mf/with-effect [data] diff --git a/frontend/src/app/main/ui/workspace/viewport/pixel_overlay.cljs b/frontend/src/app/main/ui/workspace/viewport/pixel_overlay.cljs index 73c6428a5f..1cf70067a6 100644 --- a/frontend/src/app/main/ui/workspace/viewport/pixel_overlay.cljs +++ b/frontend/src/app/main/ui/workspace/viewport/pixel_overlay.cljs @@ -53,26 +53,22 @@ new-canvas)))))))) (defn process-pointer-move - [viewport-node canvas canvas-image-data zoom-view-context client-x client-y] + [viewport-node canvas canvas-image-data zoom-view-context last-picked-color client-x client-y] (when-let [image-data (mf/ref-val canvas-image-data)] (when-let [zoom-view-node (dom/get-element "picker-detail")] (when-not (mf/ref-val zoom-view-context) (mf/set-ref-val! zoom-view-context (.getContext zoom-view-node "2d"))) - (let [canvas-width 260 + (let [canvas-width 260 canvas-height 140 {brx :left bry :top} (dom/get-bounding-rect viewport-node) x (mth/floor (- client-x brx)) y (mth/floor (- client-y bry)) - zoom-context (mf/ref-val zoom-view-context) - offset (* (+ (* y (unchecked-get image-data "width")) x) 4) - rgba (unchecked-get image-data "data") + img-width (unchecked-get image-data "width") + img-height (unchecked-get image-data "height") - r (obj/get rgba (+ 0 offset)) - g (obj/get rgba (+ 1 offset)) - b (obj/get rgba (+ 2 offset)) - a (obj/get rgba (+ 3 offset)) + zoom-context (mf/ref-val zoom-view-context) sx (- x 32) sy (if (cfg/check-browser? :safari) y (- y 17)) @@ -82,13 +78,27 @@ dy 0 dw canvas-width dh canvas-height] + (when (obj/get zoom-context "imageSmoothingEnabled") (obj/set! zoom-context "imageSmoothingEnabled" false)) (.clearRect zoom-context 0 0 canvas-width canvas-height) (.drawImage zoom-context canvas sx sy sw sh dx dy dw dh) - (js/requestAnimationFrame - (fn [] - (st/emit! (dwc/pick-color [r g b a])))))))) + + ;; Only pick color when cursor is within canvas bounds to avoid garbage pixels + (when (and (>= x 0) (< x img-width) (>= y 0) (< y img-height)) + (let [offset (* (+ (* y img-width) x) 4) + rgba (unchecked-get image-data "data") + r (obj/get rgba (+ 0 offset)) + g (obj/get rgba (+ 1 offset)) + b (obj/get rgba (+ 2 offset)) + a (obj/get rgba (+ 3 offset)) + color [r g b a]] + ;; Store latest color synchronously so the click handler always reads + ;; the correct pixel even before the rAF fires (fixes race condition) + (mf/set-ref-val! last-picked-color color) + (js/requestAnimationFrame + (fn [] + (st/emit! (dwc/pick-color color)))))))))) (mf/defc pixel-overlay @@ -103,8 +113,14 @@ canvas-context (.getContext canvas "2d" #js {:willReadFrequently true}) canvas-image-data (mf/use-ref nil) zoom-view-context (mf/use-ref nil) - canvas-ready (mf/use-state false) - initial-mouse-pos (mf/use-state {:x 0 :y 0}) + ;; Holds the last successfully picked [r g b a] synchronously so that + ;; the pointer-down handler always has the current pixel, regardless of + ;; whether the rAF-deferred store update has fired yet. + last-picked-color (mf/use-ref nil) + ;; Use a ref (not state) so tracking the cursor doesn't cause re-renders. + ;; Updated by both on-mouse-enter and a document-level pointermove listener + ;; so that the position is always current when the canvas first becomes ready. + initial-mouse-pos (mf/use-ref {:x 0 :y 0}) update-str (rx/subject) handle-keydown @@ -121,8 +137,15 @@ (fn [event] (dom/prevent-default event) (dom/stop-propagation event) - (st/emit! (dwu/start-undo-transaction :mouse-down-picker) - (dwc/pick-color-select true (kbd/shift? event))))) + ;; Emit pick-color synchronously with the latest pixel colour before + ;; pick-color-select, so the colorpicker effect never sees a stale value. + (let [color (mf/ref-val last-picked-color)] + (if (some? color) + (st/emit! (dwu/start-undo-transaction :mouse-down-picker) + (dwc/pick-color color) + (dwc/pick-color-select true (kbd/shift? event))) + (st/emit! (dwu/start-undo-transaction :mouse-down-picker) + (dwc/pick-color-select true (kbd/shift? event))))))) handle-pointer-up-picker (mf/use-callback @@ -143,16 +166,20 @@ :result "image-bitmap"}] (->> (fonts/render-font-styles-cached fonts) (rx/map (fn [styles] - (assoc result - :styles styles))) + (assoc result :styles styles))) (rx/mapcat thr/render-node) (rx/subs! (fn [image-bitmap] (.drawImage canvas-context image-bitmap 0 0) (let [width (unchecked-get canvas "width") height (unchecked-get canvas "height") - image-data (.getImageData canvas-context 0 0 width height)] + image-data (.getImageData canvas-context 0 0 width height) + ;; Read current mouse position from ref so the zoom + ;; is populated immediately even without a mouse-move. + {mx :x my :y} (mf/ref-val initial-mouse-pos)] (mf/set-ref-val! canvas-image-data image-data) - (reset! canvas-ready true)))))))) + (process-pointer-move viewport-node canvas canvas-image-data + zoom-view-context last-picked-color + mx my)))))))) handle-svg-change (mf/use-callback @@ -163,19 +190,28 @@ (mf/use-callback (mf/deps viewport-node) (fn [event] - (let [x (.-clientX event) - y (.-clientY event)] - (reset! initial-mouse-pos {:x x - :y y})))) + (mf/set-ref-val! initial-mouse-pos + {:x (.-clientX event) + :y (.-clientY event)}))) + handle-pointer-move-picker (mf/use-callback (mf/deps viewport-node) (fn [event] - (process-pointer-move viewport-node canvas canvas-image-data zoom-view-context (.-clientX event) (.-clientY event))))] + (process-pointer-move viewport-node canvas canvas-image-data zoom-view-context + last-picked-color (.-clientX event) (.-clientY event))))] (when (obj/get canvas-context "imageSmoothingEnabled") (obj/set! canvas-context "imageSmoothingEnabled" false)) + ;; Move focus to the overlay div on mount so the eyedropper button loses + ;; :focus styling immediately. Without this, prevent-default on pointer-down + ;; keeps focus on the button and it looks "selected" even after picking. + (mf/use-effect + (fn [] + (when-let [node (dom/get-element "pixel-overlay")] + (.focus node)))) + (mf/use-effect (fn [] (let [listener (events/listen ug/document "keydown" handle-keydown)] @@ -202,12 +238,17 @@ ;; Disconnect on unmount #(.disconnect observer)))) + ;; Track the cursor position at document level so initial-mouse-pos is always + ;; current when the canvas first becomes ready — even when the picker is opened + ;; via the "i" shortcut and the cursor hasn't entered/moved over the overlay yet. (mf/use-effect - (mf/deps viewport-node @canvas-ready) (fn [] - (when canvas-ready - (let [{:keys [x y]} @initial-mouse-pos] - (process-pointer-move viewport-node canvas canvas-image-data zoom-view-context x y))))) + (let [listener (events/listen ug/document "pointermove" + (fn [e] + (mf/set-ref-val! initial-mouse-pos + {:x (.-clientX e) + :y (.-clientY e)})))] + #(events/unlistenByKey listener)))) [:div {:id "pixel-overlay" :tab-index 0 @@ -218,12 +259,13 @@ :on-mouse-enter handle-mouse-enter}])) -(defn process-pointer-move-wasm [viewport-node canvas canvas-image-data zoom-view-context client-x client-y] +(defn process-pointer-move-wasm + [viewport-node canvas canvas-image-data zoom-view-context last-picked-color client-x client-y] (when-let [image-data (mf/ref-val canvas-image-data)] (when-let [zoom-view-node (dom/get-element "picker-detail")] (when-not (mf/ref-val zoom-view-context) (mf/set-ref-val! zoom-view-context (.getContext zoom-view-node "2d"))) - (let [zoom-view-width 260 + (let [zoom-view-width 260 zoom-view-height 140 {brx :left bry :top} (dom/get-bounding-rect viewport-node) x (mth/floor (- client-x brx)) @@ -232,31 +274,39 @@ canvas-x (* x wasm.api/dpr) canvas-y (* y wasm.api/dpr) - zoom-context (mf/ref-val zoom-view-context) - ;; the image-data we have is an array of pixels, starting from the - ;; bottom-left corner; so we need to calculate the offset accordingly - inverted-y (- (.-height image-data) canvas-y) - offset (* (+ (* inverted-y (.-width image-data)) canvas-x) 4) - rgba (.-data image-data) + img-width (.-width image-data) + img-height (.-height image-data) - r (obj/get rgba (+ 0 offset)) - g (obj/get rgba (+ 1 offset)) - b (obj/get rgba (+ 2 offset)) - a (obj/get rgba (+ 3 offset)) + zoom-context (mf/ref-val zoom-view-context) sx (- canvas-x 32) sy (if (cfg/check-browser? :safari) canvas-y (- canvas-y 17)) sw 65 sh 35] + (when (obj/get zoom-context "imageSmoothingEnabled") (obj/set! zoom-context "imageSmoothingEnabled" false)) (.clearRect zoom-context 0 0 zoom-view-width zoom-view-height) (.drawImage zoom-context canvas sx sy sw sh 0 0 zoom-view-width zoom-view-height) - ;; FIXME: this is throttled to avoid getting stuck in an inifinite react - ;; update loop. We should fix the global state instead. - (js/requestAnimationFrame - (fn [] - (st/emit! (dwc/pick-color [r g b a])))))))) + + ;; Only pick color when cursor is within canvas bounds to avoid garbage pixels + (when (and (>= canvas-x 0) (< canvas-x img-width) (>= canvas-y 0) (< canvas-y img-height)) + (let [;; image-data pixels start from the bottom-left corner; invert y accordingly + inverted-y (- img-height canvas-y) + offset (* (+ (* inverted-y img-width) canvas-x) 4) + rgba (.-data image-data) + r (obj/get rgba (+ 0 offset)) + g (obj/get rgba (+ 1 offset)) + b (obj/get rgba (+ 2 offset)) + a (obj/get rgba (+ 3 offset)) + color [r g b a]] + ;; Store latest color synchronously so the click handler always reads + ;; the correct pixel even before the rAF fires (fixes race condition) + (mf/set-ref-val! last-picked-color color) + ;; rAF throttles state updates to avoid an infinite React re-render loop + (js/requestAnimationFrame + (fn [] + (st/emit! (dwc/pick-color color)))))))))) (mf/defc pixel-overlay-wasm* {::mf/wrap-props false} @@ -266,7 +316,14 @@ canvas-context (mf/use-ref nil) canvas-image-data (mf/use-ref nil) zoom-view-context (mf/use-ref nil) - initial-mouse-pos (mf/use-state {:x 0 :y 0}) + ;; Holds the last successfully picked [r g b a] synchronously so that + ;; the pointer-down handler always has the current pixel, regardless of + ;; whether the rAF-deferred store update has fired yet. + last-picked-color (mf/use-ref nil) + ;; Use a ref (not state) so tracking the cursor doesn't cause re-renders. + ;; Updated by both on-mouse-enter and a document-level pointermove listener + ;; so that the position is always current when the canvas first becomes ready. + initial-mouse-pos (mf/use-ref {:x 0 :y 0}) update-str (rx/subject) handle-keydown @@ -283,8 +340,15 @@ (fn [event] (dom/prevent-default event) (dom/stop-propagation event) - (st/emit! (dwu/start-undo-transaction :mouse-down-picker) - (dwc/pick-color-select true (kbd/shift? event))))) + ;; Emit pick-color synchronously with the latest pixel colour before + ;; pick-color-select, so the colorpicker effect never sees a stale value. + (let [color (mf/ref-val last-picked-color)] + (if (some? color) + (st/emit! (dwu/start-undo-transaction :mouse-down-picker) + (dwc/pick-color color) + (dwc/pick-color-select true (kbd/shift? event))) + (st/emit! (dwu/start-undo-transaction :mouse-down-picker) + (dwc/pick-color-select true (kbd/shift? event))))))) handle-pointer-up-picker (mf/use-callback @@ -300,12 +364,18 @@ (mf/deps canvas-context) (fn [] (when-let [canvas-context (mf/ref-val canvas-context)] - (let [width (.-width canvas) + (let [width (.-width canvas) height (.-height canvas) - buffer (js/Uint8ClampedArray. (* width height 4)) - _ (.readPixels canvas-context 0 0 width height (.-RGBA canvas-context) (.-UNSIGNED_BYTE canvas-context) buffer) - image-data (js/ImageData. buffer width height)] - (mf/set-ref-val! canvas-image-data image-data))))) + buffer (js/Uint8ClampedArray. (* width height 4)) + _ (.readPixels canvas-context 0 0 width height (.-RGBA canvas-context) (.-UNSIGNED_BYTE canvas-context) buffer) + image-data (js/ImageData. buffer width height) + ;; Read current mouse position from ref so the zoom + ;; is populated immediately even without a mouse-move. + {mx :x my :y} (mf/ref-val initial-mouse-pos)] + (mf/set-ref-val! canvas-image-data image-data) + (process-pointer-move-wasm viewport-node canvas canvas-image-data + zoom-view-context last-picked-color + mx my))))) handle-canvas-changed (mf/use-callback @@ -316,25 +386,33 @@ (mf/use-callback (mf/deps viewport-node) (fn [event] - (let [x (.-clientX event) - y (.-clientY event)] - (reset! initial-mouse-pos {:x x - :y y})))) + (mf/set-ref-val! initial-mouse-pos + {:x (.-clientX event) + :y (.-clientY event)}))) + handle-pointer-move-picker (mf/use-callback (mf/deps viewport-node) (fn [event] - (process-pointer-move-wasm viewport-node canvas canvas-image-data zoom-view-context (.-clientX event) (.-clientY event))))] + (process-pointer-move-wasm viewport-node canvas canvas-image-data zoom-view-context last-picked-color (.-clientX event) (.-clientY event))))] (mf/use-effect (mf/deps canvas) (fn [] - (let [context (.getContext canvas "webgl2" #js {:willReadFrequently true, :preserveDrawingBuffer true})] + (let [context (.getContext canvas "webgl2" #js {:willReadFrequently true :preserveDrawingBuffer true})] (mf/set-ref-val! canvas-context context)))) + ;; Move focus to the overlay div on mount so the eyedropper button loses + ;; :focus styling immediately. Without this, prevent-default on pointer-down + ;; keeps focus on the button and it looks "selected" even after picking. + (mf/use-effect + (fn [] + (when-let [node (dom/get-element "pixel-overlay")] + (.focus node)))) + (mf/use-effect (fn [] - (let [listener (events/listen ug/document "keydown" handle-keydown)] + (let [listener (events/listen ug/document "keydown" handle-keydown)] #(events/unlistenByKey listener)))) (mf/use-effect @@ -350,12 +428,17 @@ (fn [] (.removeEventListener ug/document "penpot:wasm:render" handle-canvas-changed))) + ;; Track the cursor position at document level so initial-mouse-pos is always + ;; current when the canvas first becomes ready — even when the picker is opened + ;; via the "i" shortcut and the cursor hasn't entered/moved over the overlay yet. (mf/use-effect - (mf/deps viewport-node canvas canvas-image-data zoom-view-context) (fn [] - (when (some? canvas) - (let [{:keys [x y]} @initial-mouse-pos] - (process-pointer-move-wasm viewport-node canvas canvas-image-data zoom-view-context x y))))) + (let [listener (events/listen ug/document "pointermove" + (fn [e] + (mf/set-ref-val! initial-mouse-pos + {:x (.-clientX e) + :y (.-clientY e)})))] + #(events/unlistenByKey listener)))) [:div {:id "pixel-overlay" :tab-index 0 From c56f5cc01b87fec2404b58566f2f5929661dbc74 Mon Sep 17 00:00:00 2001 From: Alonso Torres Date: Wed, 13 May 2026 15:57:30 +0200 Subject: [PATCH 28/36] :bug: Fix problem with colorpicker in multiselect color with texts (#9549) --- .../sidebar/options/menus/color_selection.cljs | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/color_selection.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/menus/color_selection.cljs index 06b3fac9d2..0145c0bc30 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/color_selection.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/color_selection.cljs @@ -90,7 +90,7 @@ (d/without-nils)) prev-color (d/seek (partial get groups) prev-colors) color-operations-old (get groups old-color) - color-operations-prev (get groups prev-colors) + color-operations-prev (get groups prev-color) color-operations (or color-operations-prev color-operations-old) old-color (or prev-color old-color)] [color-operations old-color])) @@ -115,11 +115,17 @@ ;; TODO: Review if this is still necessary. prev-colors-ref (mf/use-ref nil) + ;; Always keep this ref pointing to the latest groups so that on-change + ;; (which may be captured stale by the colorpicker's rx subscription) can + ;; still read the current groups and find the correct color operations. + groups-ref (mf/use-ref nil) + _ (mf/set-ref-val! groups-ref groups) + on-change (mf/use-fn - (mf/deps groups) (fn [old-color new-color from-picker?] - (let [prev-colors (mf/ref-val prev-colors-ref) + (let [groups (mf/ref-val groups-ref) + prev-colors (mf/ref-val prev-colors-ref) [color-operations old-color] (retrieve-color-operations groups old-color prev-colors)] ;; TODO: Review if this is still necessary. From a5da9449b50130a14285bd74729c74a4ff1d5ead Mon Sep 17 00:00:00 2001 From: Aitor Moreno Date: Mon, 11 May 2026 15:44:07 +0200 Subject: [PATCH 29/36] :recycle: Refactor how target and backbuffer works --- frontend/src/debug.cljs | 9 ++ render-wasm/src/render.rs | 104 +++++++++-------------- render-wasm/src/render/debug.rs | 56 ++++++++++++- render-wasm/src/render/options.rs | 8 -- render-wasm/src/render/surfaces.rs | 128 ++++++++++++++--------------- render-wasm/src/render/ui.rs | 13 +-- 6 files changed, 170 insertions(+), 148 deletions(-) diff --git a/frontend/src/debug.cljs b/frontend/src/debug.cljs index 4ae8be8b17..985a936bcc 100644 --- a/frontend/src/debug.cljs +++ b/frontend/src/debug.cljs @@ -165,6 +165,15 @@ (js/console.warn "[debug] render-wasm module not ready or missing _debug_atlas_base64") "")))) +(defn ^:export wasmSurfaceConsole + "Logs the render-wasm surface id as an image in the JS console." + [id] + (let [module wasm/internal-module + f (when module (unchecked-get module "_debug_surface_console"))] + (if (fn? f) + (wasm.h/call module "_debug_surface_console" id) + (js/console.warn "[debug] render-wasm module not ready or missing _debug_surface_console")))) + (defn ^:export wasmCacheConsole "Logs the current render-wasm cache surface as an image in the JS console." [] diff --git a/render-wasm/src/render.rs b/render-wasm/src/render.rs index 2fc04534a2..545755ba81 100644 --- a/render-wasm/src/render.rs +++ b/render-wasm/src/render.rs @@ -804,7 +804,12 @@ impl RenderState { Ok(()) } + pub fn flush(&mut self) { + self.surfaces.flush(SurfaceId::Backbuffer); + } + pub fn flush_and_submit(&mut self) { + self.surfaces.copy_backbuffer_to_target(); self.surfaces.flush_and_submit(SurfaceId::Target); } @@ -816,7 +821,7 @@ impl RenderState { /// This is currently not being used, but it's set there for testing purposes on /// upcoming tasks pub fn render_loading_overlay(&mut self) { - let canvas = self.surfaces.canvas(SurfaceId::Target); + let canvas = self.surfaces.canvas(SurfaceId::Backbuffer); let skia::ISize { width, height } = canvas.base_layer_size(); canvas.save(); @@ -863,8 +868,11 @@ impl RenderState { // the interaction ends. if self.options.is_interactive_transform() { let tile_rect = self.get_current_aligned_tile_bounds()?; - self.surfaces - .draw_current_tile_direct_target_only(&tile_rect, self.background_color); + self.surfaces.draw_current_tile_direct( + &tile_rect, + self.background_color, + surfaces::DrawOnCache::No, + ); return Ok(()); } @@ -879,10 +887,12 @@ impl RenderState { // In fast mode the viewport is moving (pan/zoom) so Cache surface // positions would be wrong — only save to the tile HashMap. let tile_rect = self.get_current_aligned_tile_bounds()?; + let current_tile = *self .current_tile .as_ref() .ok_or(Error::CriticalError("Current tile not found".to_string()))?; + self.surfaces.cache_current_tile_texture( &self.tile_viewbox, ¤t_tile, @@ -1759,7 +1769,7 @@ impl RenderState { // and drawing from it avoids mixing a partially-updated Cache surface with missing tiles. if self.options.is_fast_mode() && self.render_in_progress && self.surfaces.has_atlas() { self.surfaces - .draw_atlas_to_target(self.viewbox, self.options.dpr, bg_color); + .draw_atlas_to_backbuffer(self.viewbox, self.options.dpr, bg_color); if self.options.is_debug_visible() { debug::render(self); @@ -1827,7 +1837,7 @@ impl RenderState { if !cache_covers { // Early return only if atlas exists; otherwise keep cache path. if self.surfaces.has_atlas() { - self.surfaces.draw_atlas_to_target( + self.surfaces.draw_atlas_to_backbuffer( self.viewbox, self.options.dpr, bg_color, @@ -1849,7 +1859,7 @@ impl RenderState { // Setup canvas transform { - let canvas = self.surfaces.canvas(SurfaceId::Target); + let canvas = self.surfaces.canvas(SurfaceId::Backbuffer); canvas.save(); canvas.scale((navigate_zoom, navigate_zoom)); canvas.translate((translate_x, translate_y)); @@ -1857,10 +1867,10 @@ impl RenderState { } // Draw directly from cache surface, avoiding snapshot overhead - self.surfaces.draw_cache_to_target(); + self.surfaces.draw_cache_to_backbuffer(); // Restore canvas state - self.surfaces.canvas(SurfaceId::Target).restore(); + self.surfaces.canvas(SurfaceId::Backbuffer).restore(); // During pure pan (same zoom), draw tiles from the HashMap // on top of the scaled Cache surface. Cached tile textures @@ -1967,7 +1977,6 @@ impl RenderState { if !self.interactive_target_seeded { // Seed from the last presented frame; this is stable even when // fast_mode skips cache updates and regardless of atlas coverage. - self.surfaces.seed_target_from_backbuffer(); self.interactive_target_seeded = true; } } else { @@ -2027,6 +2036,7 @@ impl RenderState { self.nested_shadows.clear(); // reorder by distance to the center. self.current_tile = None; + self.render_in_progress = true; self.apply_drawing_to_render_canvas(None, SurfaceId::Current); @@ -2090,38 +2100,24 @@ impl RenderState { timestamp: i32, ) -> Result<()> { performance::begin_measure!("process_animation_frame"); + self.render_shape_tree_partial(base_object, tree, timestamp, true)?; + if self.render_in_progress { - if tree.len() != 0 { - self.render_shape_tree_partial(base_object, tree, timestamp, true)?; - } - - // In a pure viewport interaction (pan/zoom), render_from_cache - // owns the Target surface — skip flush so we don't present - // stale tile positions. The rAF still populates the Cache - // surface and tile HashMap so render_from_cache progressively - // shows more complete content. - // - // During interactive shape transforms (drag/resize/rotate) we - // still need to flush every rAF so the user sees the updated - // shape position — render_from_cache is not in the loop here. - if !self.options.is_viewport_interaction() { - self.flush_and_submit(); - } - - if self.render_in_progress { - self.cancel_animation_frame(); - self.render_request_id = Some(wapi::request_animation_frame!()); - } else { - // A full-quality frame is now complete. Refresh Backbuffer and regenerate - // the per-shape crop cache so interactive drags can reuse pixels. - if !self.options.is_fast_mode() && !self.options.is_interactive_transform() { - self.surfaces.copy_target_to_backbuffer(); - self.rebuild_backbuffer_crop_cache(tree); - } - wapi::notify_tiles_render_complete!(); - performance::end_measure!("render"); + self.flush(); + self.cancel_animation_frame(); + self.render_request_id = Some(wapi::request_animation_frame!()); + } else { + // A full-quality frame is now complete. Refresh Backbuffer and regenerate + // the per-shape crop cache so interactive drags can reuse pixels. + if !self.options.is_fast_mode() && !self.options.is_interactive_transform() { + self.surfaces.copy_backbuffer_to_target(); + self.rebuild_backbuffer_crop_cache(tree); } + self.flush_and_submit(); + wapi::notify_tiles_render_complete!(); + performance::end_measure!("render"); } + performance::end_measure!("process_animation_frame"); Ok(()) } @@ -2132,9 +2128,7 @@ impl RenderState { tree: ShapesPoolRef, timestamp: i32, ) -> Result<()> { - if tree.len() != 0 { - self.render_shape_tree_partial(base_object, tree, timestamp, false)?; - } + self.render_shape_tree_partial(base_object, tree, timestamp, false)?; self.flush_and_submit(); Ok(()) } @@ -3308,6 +3302,7 @@ impl RenderState { return Ok(()); } performance::end_measure!("render_shape_tree::uncached"); + let tile_rect = self.get_current_tile_bounds()?; // Composite if the walker did work in this PAF (`!is_empty`) OR // the tile has unfinished work from a previous PAF @@ -3317,8 +3312,11 @@ impl RenderState { if self.options.is_interactive_transform() { // During drag, avoid snapshot-based caching. Draw Current directly // into Target (and Cache) to reduce stalls. - self.surfaces - .draw_current_tile_direct(&tile_rect, self.background_color); + self.surfaces.draw_current_tile_direct( + &tile_rect, + self.background_color, + surfaces::DrawOnCache::Yes, + ); } else { self.apply_render_to_final_canvas(tile_rect)?; } @@ -3331,25 +3329,6 @@ impl RenderState { tile_rect, ); } - } else { - self.surfaces.apply_mut(SurfaceId::Target as u32, |s| { - let mut paint = skia::Paint::default(); - paint.set_color(self.background_color); - s.canvas().draw_rect(tile_rect, &paint); - }); - // Keep Cache surface coherent for render_from_cache. - if !self.options.is_fast_mode() { - if !self.cache_cleared_this_render { - self.surfaces.clear_cache(self.background_color); - self.cache_cleared_this_render = true; - } - let aligned_rect = self.get_aligned_tile_bounds(current_tile); - self.surfaces.apply_mut(SurfaceId::Cache as u32, |s| { - let mut paint = skia::Paint::default(); - paint.set_color(self.background_color); - s.canvas().draw_rect(aligned_rect, &paint); - }); - } } } } @@ -3422,7 +3401,6 @@ impl RenderState { } self.render_in_progress = false; - self.surfaces.gc(); // Mark cache as valid for render_from_cache. diff --git a/render-wasm/src/render/debug.rs b/render-wasm/src/render/debug.rs index 266a9043de..cddf371851 100644 --- a/render-wasm/src/render/debug.rs +++ b/render-wasm/src/render/debug.rs @@ -187,8 +187,52 @@ pub fn render_debug_shape( } } -#[cfg(target_arch = "wasm32")] #[allow(dead_code)] +#[cfg(target_arch = "wasm32")] +pub fn trap() { + run_script!("debugger"); +} + +#[allow(dead_code)] +#[cfg(target_arch = "wasm32")] +#[derive(Debug, PartialEq)] +pub enum SurfaceBackendKind { + BackendTexture, // GPU Framebuffer (Texture) + BackendRenderTarget, // GPU Framebuffer (Renderbuffer) + Raster, // CPU + Unknown, +} + +#[allow(dead_code)] +#[cfg(target_arch = "wasm32")] +pub fn classify_surface_backend(surface: &mut skia::Surface) -> SurfaceBackendKind { + if skia::gpu::surfaces::get_backend_texture( + surface, + skia_safe::surface::BackendHandleAccess::FlushRead, + ) + .is_some() + { + return SurfaceBackendKind::BackendTexture; + } + + if skia::gpu::surfaces::get_backend_render_target( + surface, + skia_safe::surface::BackendHandleAccess::FlushRead, + ) + .is_some() + { + return SurfaceBackendKind::BackendRenderTarget; + } + + if surface.peek_pixels().is_some() { + return SurfaceBackendKind::Raster; + } + + SurfaceBackendKind::Unknown +} + +#[allow(dead_code)] +#[cfg(target_arch = "wasm32")] pub fn console_debug_surface(render_state: &mut RenderState, id: SurfaceId) { let base64_image = render_state .surfaces @@ -198,6 +242,8 @@ pub fn console_debug_surface(render_state: &mut RenderState, id: SurfaceId) { run_script!(format!("console.log('%c ', 'font-size: 1px; background: url(data:image/png;base64,{base64_image}) no-repeat; padding: 100px; background-size: contain;')")); } +#[allow(dead_code)] +#[cfg(target_arch = "wasm32")] pub fn console_debug_surface_base64(render_state: &mut RenderState, id: SurfaceId) { let base64_image = render_state .surfaces @@ -258,3 +304,11 @@ pub extern "C" fn debug_atlas_base64() -> Result<()> { console_debug_surface_base64(get_render_state(), SurfaceId::Atlas); Ok(()) } + +#[no_mangle] +#[wasm_error] +#[cfg(target_arch = "wasm32")] +pub extern "C" fn debug_surface_console(id: SurfaceId) -> Result<()> { + console_debug_surface(get_render_state(), id); + Ok(()) +} diff --git a/render-wasm/src/render/options.rs b/render-wasm/src/render/options.rs index 0f80b28bcb..8142f1f52c 100644 --- a/render-wasm/src/render/options.rs +++ b/render-wasm/src/render/options.rs @@ -96,14 +96,6 @@ impl RenderOptions { self.interactive_transform = enabled; } - /// True only when the viewport is the one being moved (pan/zoom) - /// and the dedicated `render_from_cache` path owns Target - /// presentation. In this mode `process_animation_frame` must not - /// flush to avoid presenting stale tile positions. - pub fn is_viewport_interaction(&self) -> bool { - self.fast_mode && !self.interactive_transform - } - pub fn is_text_editor_v3(&self) -> bool { self.flags & TEXT_EDITOR_V3 == TEXT_EDITOR_V3 } diff --git a/render-wasm/src/render/surfaces.rs b/render-wasm/src/render/surfaces.rs index 381d69c730..6949e1ba2f 100644 --- a/render-wasm/src/render/surfaces.rs +++ b/render-wasm/src/render/surfaces.rs @@ -26,6 +26,12 @@ const TILE_SIZE_MULTIPLIER: i32 = 2; const MAX_ATLAS_TEXTURE_SIZE: i32 = 4096; const DEFAULT_MAX_ATLAS_TEXTURE_SIZE: i32 = 1024; +#[derive(Debug, PartialEq)] +pub enum DrawOnCache { + Yes, + No, +} + #[repr(u32)] #[derive(Debug, PartialEq, Clone, Copy)] pub enum SurfaceId { @@ -456,16 +462,21 @@ impl Surfaces { self.atlas_size.width > 0 && self.atlas_size.height > 0 } - /// Draw the persistent atlas onto the target using the current viewbox transform. + /// Draw the persistent atlas onto the backbuffer using the current viewbox transform. /// Intended for fast pan/zoom-out previews (avoids per-tile composition). - /// Clears Target to `background` first so atlas-uncovered regions don't + /// Clears Backbuffer to `background` first so atlas-uncovered regions don't /// show stale content when the atlas only partially covers the viewport. - pub fn draw_atlas_to_target(&mut self, viewbox: Viewbox, dpr: f32, background: skia::Color) { + pub fn draw_atlas_to_backbuffer( + &mut self, + viewbox: Viewbox, + dpr: f32, + background: skia::Color, + ) { if !self.has_atlas() { return; } - let canvas = self.target.canvas(); + let canvas = self.backbuffer.canvas(); canvas.save(); canvas.reset_matrix(); let size = canvas.base_layer_size(); @@ -597,6 +608,12 @@ impl Surfaces { self.dirty_surfaces = 0; } + pub fn flush(&mut self, id: SurfaceId) { + let gpu_state = get_gpu_state(); + let surface = self.get_mut(id); + gpu_state.context.flush_surface(surface); + } + pub fn flush_and_submit(&mut self, id: SurfaceId) { let gpu_state = get_gpu_state(); let surface = self.get_mut(id); @@ -614,12 +631,12 @@ impl Surfaces { ); } - /// Draws the cache surface directly to the target canvas. + /// Draws the cache surface directly to the backbuffer canvas. /// This avoids creating an intermediate snapshot, reducing GPU stalls. - pub fn draw_cache_to_target(&mut self) { + pub fn draw_cache_to_backbuffer(&mut self) { let sampling_options = self.sampling_options; - self.cache.clone().draw( - self.target.canvas(), + self.cache.draw( + self.backbuffer.canvas(), (0.0, 0.0), sampling_options, Some(&skia::Paint::default()), @@ -715,7 +732,7 @@ impl Surfaces { }); } - #[inline] + #[inline(always)] pub fn get_mut(&mut self, id: SurfaceId) -> &mut skia::Surface { match id { SurfaceId::Target => &mut self.target, @@ -735,6 +752,7 @@ impl Surfaces { } } + #[inline(always)] fn get(&self, id: SurfaceId) -> &skia::Surface { match id { SurfaceId::Target => &self.target, @@ -759,23 +777,23 @@ impl Surfaces { (s.width(), s.height()) } - /// Copy the current `Target` contents into the persistent `Backbuffer`. + /// Copy the current `Backbuffer` contents into `Target`. /// This is a GPU→GPU copy via Skia (no ReadPixels). - pub fn copy_target_to_backbuffer(&mut self) { + pub fn copy_backbuffer_to_target(&mut self) { let sampling_options = self.sampling_options; - self.target.clone().draw( - self.backbuffer.canvas(), + self.backbuffer.draw( + self.target.canvas(), (0.0, 0.0), sampling_options, Some(&skia::Paint::default()), ); } - /// Seed `Target` from `Backbuffer` (last presented frame). - pub fn seed_target_from_backbuffer(&mut self) { + /// Seed `Backbuffer` from `Target` (last presented frame). + pub fn seed_backbuffer_from_target(&mut self) { let sampling_options = self.sampling_options; - self.backbuffer.clone().draw( - self.target.canvas(), + self.target.draw( + self.backbuffer.canvas(), (0.0, 0.0), sampling_options, Some(&skia::Paint::default()), @@ -801,7 +819,6 @@ impl Surfaces { .target .new_surface_with_dimensions(dim) .ok_or(Error::CriticalError("Failed to create surface".to_string()))?; - // The rest are tile size surfaces Ok(()) } @@ -1052,17 +1069,17 @@ impl Surfaces { let mut paint = skia::Paint::default(); paint.set_color(color); - self.target.canvas().draw_rect(rect, &paint); + self.backbuffer.canvas().draw_rect(rect, &paint); - self.target + self.backbuffer .canvas() .draw_image_rect(&image, None, rect, &skia::Paint::default()); } } - /// Draws a cached tile texture to the Cache surface at the given + /// Draws a cached tile texture to the Cache self.backbuffer at the given /// cache-aligned rect. This keeps the Cache surface in sync with - /// Target so that `render_from_cache` (used during pan) has the + /// Backbuffer so that `render_from_cache` (used during pan) has the /// full scene including tiles served from the texture cache. pub fn draw_cached_tile_to_cache( &mut self, @@ -1083,53 +1100,14 @@ impl Surfaces { } } - /// Draws the current tile directly to the target and cache surfaces without + /// Draws the current tile directly to the backbuffer and cache surfaces without /// creating a snapshot. This avoids GPU stalls from ReadPixels but doesn't /// populate the tile texture cache (suitable for one-shot renders like tests). - pub fn draw_current_tile_direct(&mut self, tile_rect: &skia::Rect, color: skia::Color) { - let sampling_options = self.sampling_options; - let src_rect = IRect::from_xywh( - self.margins.width, - self.margins.height, - self.current.width() - TILE_SIZE_MULTIPLIER * self.margins.width, - self.current.height() - TILE_SIZE_MULTIPLIER * self.margins.height, - ); - let src_rect_f = skia::Rect::from(src_rect); - - // Draw background - let mut paint = skia::Paint::default(); - paint.set_color(color); - self.target.canvas().draw_rect(tile_rect, &paint); - - // Draw current surface directly to target (no snapshot) - self.current.clone().draw( - self.target.canvas(), - ( - tile_rect.left - src_rect_f.left, - tile_rect.top - src_rect_f.top, - ), - sampling_options, - None, - ); - - // Also draw to cache for render_from_cache - self.current.clone().draw( - self.cache.canvas(), - ( - tile_rect.left - src_rect_f.left, - tile_rect.top - src_rect_f.top, - ), - sampling_options, - None, - ); - } - - /// Same as `draw_current_tile_direct` but draws only into Target. - /// Useful during interactive transforms to reduce extra GPU work. - pub fn draw_current_tile_direct_target_only( + pub fn draw_current_tile_direct( &mut self, tile_rect: &skia::Rect, color: skia::Color, + draw_on_cache: DrawOnCache, ) { let sampling_options = self.sampling_options; let src_rect = IRect::from_xywh( @@ -1140,12 +1118,15 @@ impl Surfaces { ); let src_rect_f = skia::Rect::from(src_rect); + let backbuffer_canvas = self.backbuffer.canvas(); + // Draw background let mut paint = skia::Paint::default(); paint.set_color(color); - self.target.canvas().draw_rect(tile_rect, &paint); + backbuffer_canvas.draw_rect(tile_rect, &paint); - self.current.clone().draw( - self.target.canvas(), + // Draw current surface directly to target (no snapshot) + self.current.draw( + backbuffer_canvas, ( tile_rect.left - src_rect_f.left, tile_rect.top - src_rect_f.top, @@ -1153,6 +1134,19 @@ impl Surfaces { sampling_options, None, ); + + // Also draw to cache for render_from_cache + if draw_on_cache == DrawOnCache::Yes { + self.current.draw( + self.cache.canvas(), + ( + tile_rect.left - src_rect_f.left, + tile_rect.top - src_rect_f.top, + ), + sampling_options, + None, + ); + } } /// Full cache reset: clears both the tile texture cache and the cache canvas. diff --git a/render-wasm/src/render/ui.rs b/render-wasm/src/render/ui.rs index 243d775812..71ff44eec6 100644 --- a/render-wasm/src/render/ui.rs +++ b/render-wasm/src/render/ui.rs @@ -6,21 +6,15 @@ use crate::shapes::{Layout, Type}; pub fn render(render_state: &mut RenderState, shapes: ShapesPoolRef) { let canvas = render_state.surfaces.canvas(SurfaceId::UI); + let viewbox = render_state.viewbox; + let zoom = viewbox.zoom * render_state.options.dpr; + let show_grid_id = render_state.show_grid; canvas.clear(Color4f::new(0.0, 0.0, 0.0, 0.0)); canvas.save(); - - let viewbox = render_state.viewbox; - let zoom = viewbox.zoom * render_state.options.dpr; - canvas.scale((zoom, zoom)); - canvas.translate((-viewbox.area.left, -viewbox.area.top)); - let canvas = render_state.surfaces.canvas(SurfaceId::UI); - - let show_grid_id = render_state.show_grid; - if let Some(id) = show_grid_id { if let Some(shape) = shapes.get(&id) { grid_layout::render_overlay( @@ -67,6 +61,7 @@ pub fn render(render_state: &mut RenderState, shapes: ShapesPoolRef) { } canvas.restore(); + render_state.surfaces.draw_into( SurfaceId::UI, SurfaceId::Target, From 38c62a465f6fe20e339824a65e2cde829b0479e0 Mon Sep 17 00:00:00 2001 From: Elena Torro Date: Wed, 13 May 2026 12:21:49 +0200 Subject: [PATCH 30/36] :bug: Reflow only when needed --- render-wasm/src/shapes/modifiers.rs | 48 +++++++++++++++++------------ render-wasm/src/shapes/text.rs | 43 ++++++++++++++++++++++++-- 2 files changed, 69 insertions(+), 22 deletions(-) diff --git a/render-wasm/src/shapes/modifiers.rs b/render-wasm/src/shapes/modifiers.rs index 895cf890c2..32752cbbc3 100644 --- a/render-wasm/src/shapes/modifiers.rs +++ b/render-wasm/src/shapes/modifiers.rs @@ -167,6 +167,7 @@ fn set_pixel_precision(transform: &mut Matrix, bounds: &mut Bounds) { } } +#[allow(clippy::too_many_arguments)] fn propagate_transform( entry: TransformEntry, pixel_precision: bool, @@ -175,6 +176,7 @@ fn propagate_transform( bounds: &mut HashMap, modifiers: &mut HashMap, reflown: &mut HashSet, + reflowed_shapes: &mut HashSet, ) -> Result<()> { let Some(shape) = state.shapes.get(&entry.id) else { return Ok(()); @@ -190,7 +192,11 @@ fn propagate_transform( if !is_close_to(shape_bounds_before.width(), shape_bounds_after.width()) || !is_close_to(shape_bounds_before.height(), shape_bounds_after.height()) { - if let Type::Text(text_content) = &mut shape.shape_type.clone() { + if let Type::Text(text_content) = &shape.shape_type { + let width_changed = + !is_close_to(shape_bounds_before.width(), shape_bounds_after.width()); + let height_changed = + !is_close_to(shape_bounds_before.height(), shape_bounds_after.height()); let resized_selrect = math::Rect::from_xywh( shape.selrect.left(), shape.selrect.top(), @@ -199,12 +205,15 @@ fn propagate_transform( ); match text_content.grow_type() { GrowType::AutoHeight => { - // For auto-height, always update layout when width changes - // because the new width affects how text wraps - let width_changed = - !is_close_to(shape_bounds_before.width(), shape_bounds_after.width()); - if width_changed || text_content.needs_update_layout() { - text_content.update_layout(resized_selrect); + let height_before = text_content.size.height; + let new_height = if width_changed { + let mut clone = text_content.clone(); + clone.update_layout(resized_selrect); + clone.size.height + } else { + height_before + }; + if !is_close_to(height_before, new_height) && reflowed_shapes.insert(shape.id) { entries.push_back(Modifier::reflow(shape.id, false)); if let Some(parent_id) = shape.parent_id { @@ -215,31 +224,28 @@ fn propagate_transform( } } } - let height = text_content.size.height; let resize_transform = math::resize_matrix( &shape_bounds_after, &shape_bounds_after, shape_bounds_after.width(), - height, + new_height, ); shape_bounds_after = shape_bounds_after.transform(&resize_transform); transform.post_concat(&resize_transform); } GrowType::AutoWidth => { - // For auto-width, always update layout when height changes - // because the new height affects how text flows - let height_changed = - !is_close_to(shape_bounds_before.height(), shape_bounds_after.height()); - if height_changed || text_content.needs_update_layout() { - text_content.update_layout(resized_selrect); - } - let width = text_content.width(); - let height = text_content.size.height; + let (new_width, new_height) = if height_changed { + let mut clone = text_content.clone(); + clone.update_layout(resized_selrect); + (clone.width(), clone.size.height) + } else { + (text_content.width(), text_content.size.height) + }; let resize_transform = math::resize_matrix( &shape_bounds_after, &shape_bounds_after, - width, - height, + new_width, + new_height, ); shape_bounds_after = shape_bounds_after.transform(&resize_transform); transform.post_concat(&resize_transform); @@ -404,6 +410,7 @@ pub fn propagate_modifiers( // In order for loop to eventualy finish, we limit the flex reflow to just // one (the reflown set). while !entries.is_empty() { + let mut reflowed_shapes = HashSet::::new(); while let Some(modifier) = entries.pop_front() { match modifier { Modifier::Transform(entry, pixel) => propagate_transform( @@ -414,6 +421,7 @@ pub fn propagate_modifiers( &mut bounds, &mut modifiers, &mut reflown, + &mut reflowed_shapes, )?, Modifier::Reflow(id, force_reflow) => { if force_reflow { diff --git a/render-wasm/src/shapes/text.rs b/render-wasm/src/shapes/text.rs index 98e0a4e6c9..e3a1037175 100644 --- a/render-wasm/src/shapes/text.rs +++ b/render-wasm/src/shapes/text.rs @@ -333,13 +333,26 @@ pub fn calculate_normalized_line_height( normalized_line_height } -#[derive(Debug, PartialEq, Clone)] +#[derive(Debug, Clone)] pub struct TextContent { pub paragraphs: Vec, pub bounds: Rect, pub grow_type: GrowType, pub size: TextContentSize, pub layout: TextContentLayout, + content_version: u64, + layout_version: u64, + layout_width: Option, +} + +impl PartialEq for TextContent { + fn eq(&self, other: &Self) -> bool { + self.paragraphs == other.paragraphs + && self.bounds == other.bounds + && self.grow_type == other.grow_type + && self.size == other.size + && self.layout == other.layout + } } impl TextContent { @@ -350,6 +363,9 @@ impl TextContent { grow_type, size: TextContentSize::default(), layout: TextContentLayout::new(), + content_version: 0, + layout_version: 0, + layout_width: None, } } @@ -362,6 +378,9 @@ impl TextContent { grow_type, size: TextContentSize::new_with_size(bounds.width(), bounds.height()), layout: TextContentLayout::new(), + content_version: 0, + layout_version: 0, + layout_width: None, } } @@ -385,6 +404,7 @@ impl TextContent { pub fn add_paragraph(&mut self, paragraph: Paragraph) { self.paragraphs.push(paragraph); + self.content_version = self.content_version.wrapping_add(1); } pub fn paragraphs(&self) -> &[Paragraph] { @@ -392,6 +412,7 @@ impl TextContent { } pub fn paragraphs_mut(&mut self) -> &mut Vec { + self.content_version = self.content_version.wrapping_add(1); &mut self.paragraphs } @@ -408,7 +429,10 @@ impl TextContent { } pub fn set_grow_type(&mut self, grow_type: GrowType) { - self.grow_type = grow_type; + if self.grow_type != grow_type { + self.grow_type = grow_type; + self.content_version = self.content_version.wrapping_add(1); + } } /// Compute a tight text rect from laid-out Skia paragraphs using glyph @@ -891,6 +915,15 @@ impl TextContent { } pub fn update_layout(&mut self, selrect: Rect) -> TextContentSize { + if !self.layout.needs_update() + && self.layout_version == self.content_version + && self + .layout_width + .is_some_and(|w| (w - selrect.width()).abs() < f32::EPSILON) + { + return self.size; + } + self.size.set_size(selrect.width(), selrect.height()); match self.grow_type() { @@ -915,6 +948,9 @@ impl TextContent { self.size.max_width = placeholder_width; } + self.layout_version = self.content_version; + self.layout_width = Some(selrect.width()); + self.size } @@ -1048,6 +1084,9 @@ impl Default for TextContent { grow_type: GrowType::Fixed, size: TextContentSize::default(), layout: TextContentLayout::new(), + content_version: 0, + layout_version: 0, + layout_width: None, } } } From 374c64da74d9f37d89a9bbc664068f9aea080c4c Mon Sep 17 00:00:00 2001 From: Elena Torro Date: Wed, 13 May 2026 17:08:38 +0200 Subject: [PATCH 31/36] :bug: Fix text focus on empty text --- .../app/main/ui/workspace/shapes/text/v2_editor.cljs | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/frontend/src/app/main/ui/workspace/shapes/text/v2_editor.cljs b/frontend/src/app/main/ui/workspace/shapes/text/v2_editor.cljs index b0b89a5102..779e7097aa 100644 --- a/frontend/src/app/main/ui/workspace/shapes/text/v2_editor.cljs +++ b/frontend/src/app/main/ui/workspace/shapes/text/v2_editor.cljs @@ -290,8 +290,8 @@ :data-testid "text-editor-container" :style {:width "var(--editor-container-width)" :height "var(--editor-container-height)" - :min-width "1px" - :min-height "1px"}} + :min-width "var(--editor-container-min-width, 1px)" + :min-height "var(--editor-container-min-height, 1px)"}} ;; We hide the editor when is blurred because otherwise the ;; selection won't let us see the underlying text. Use opacity ;; because display or visibility won't allow to recover focus @@ -381,7 +381,7 @@ render-wasm? (mf/use-memo #(features/active-feature? @st/state "render-wasm/v1")) - [{:keys [x y width height]} transform] + [{:keys [x y width height selrect-width selrect-height]} transform] (if render-wasm? (let [{:keys [width height]} (wasm.api/get-text-dimensions shape-id) selrect-transform (mf/deref refs/workspace-selrect) @@ -403,7 +403,8 @@ "bottom" (+ y (- selrect-height height)) "center" (+ y (/ (- selrect-height height) 2)) y)] - [(assoc selrect :y y :width overlay-width :height max-height) transform]) + [(assoc selrect :y y :width overlay-width :height max-height + :selrect-width selrect-width :selrect-height selrect-height) transform]) (let [bounds (gst/shape->rect shape) x (mth/min (dm/get-prop bounds :x) @@ -422,6 +423,8 @@ (obj/merge! #js {"--editor-container-width" "auto" "--editor-container-height" "auto" + "--editor-container-min-width" (dm/str selrect-width "px") + "--editor-container-min-height" (dm/str selrect-height "px") "--fallback-families" (if (seq fallback-families) (dm/str (str/join ", " fallback-families)) "sourcesanspro") :display "flex"}) From ae282eb6e29517d50aa0cdecc6ab254bdaa651a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bel=C3=A9n=20Albeza?= Date: Wed, 13 May 2026 16:06:56 +0200 Subject: [PATCH 32/36] :bug: Fix rendering glitch when exiting focus mode --- frontend/src/app/main/data/workspace/selection.cljs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/frontend/src/app/main/data/workspace/selection.cljs b/frontend/src/app/main/data/workspace/selection.cljs index d0ce0c5c2f..f9202e8ebd 100644 --- a/frontend/src/app/main/data/workspace/selection.cljs +++ b/frontend/src/app/main/data/workspace/selection.cljs @@ -27,6 +27,7 @@ [app.main.data.workspace.pages :as-alias dwpg] [app.main.data.workspace.specialized-panel :as-alias dwsp] [app.main.data.workspace.undo :as dwu] + [app.main.data.workspace.viewport-wasm :as dwvw] [app.main.data.workspace.zoom :as dwz] [app.main.refs :as refs] [app.main.router :as rt] @@ -602,6 +603,10 @@ (assoc :workspace-pre-focus (:workspace-local state))) state)))) + ptk/EffectEvent + (effect [_ state _] + (dwvw/maybe-sync-workspace-local-viewport! state)) + ptk/WatchEvent (watch [_ state stream] (let [stopper (rx/filter #(or (= ::toggle-focus-mode (ptk/type %)) From 4c9e1d401523cdc77055d2eeb964642a49b1ab33 Mon Sep 17 00:00:00 2001 From: Luis de Dios Date: Thu, 14 May 2026 08:53:23 +0200 Subject: [PATCH 33/36] :bug: Fix linting error --- frontend/src/app/main/ui/components/numeric_input.cljs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/frontend/src/app/main/ui/components/numeric_input.cljs b/frontend/src/app/main/ui/components/numeric_input.cljs index 3ad3f108ae..c94ea3c152 100644 --- a/frontend/src/app/main/ui/components/numeric_input.cljs +++ b/frontend/src/app/main/ui/components/numeric_input.cljs @@ -214,7 +214,7 @@ (dom/blur! node))))) handle-focus - (mf/use-callback + (mf/use-fn (mf/deps on-focus select-on-focus?) (fn [event] (reset! last-value* (parse-value)) @@ -223,9 +223,9 @@ (mf/set-ref-val! dirty-ref true) (on-focus event)) (when select-on-focus? - (dom/select-text! target) - ;; In webkit browsers the mouseup event will be called after the on-focus causing and unselect - (.addEventListener target "mouseup" dom/prevent-default #js {:once true}))))) + (dom/select-text! target) + ;; In webkit browsers the mouseup event will be called after the on-focus causing and unselect + (.addEventListener target "mouseup" dom/prevent-default #js {:once true}))))) props (-> (obj/clone props) (obj/unset! "selectOnFocus") From abc290582daa389f3361bb8b63134d2017e8d03b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bel=C3=A9n=20Albeza?= Date: Tue, 12 May 2026 15:31:04 +0200 Subject: [PATCH 34/36] :bug: Fix text styles sometimes being overidden when chaging shape text attrs --- .../src/app/main/data/workspace/texts.cljs | 27 ++++++++++++++----- 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/frontend/src/app/main/data/workspace/texts.cljs b/frontend/src/app/main/data/workspace/texts.cljs index d49f05a8f3..d492a43b4b 100644 --- a/frontend/src/app/main/data/workspace/texts.cljs +++ b/frontend/src/app/main/data/workspace/texts.cljs @@ -36,6 +36,7 @@ [app.main.fonts :as fonts] [app.main.router :as rt] [app.render-wasm.api :as wasm.api] + [app.render-wasm.text-editor :as wasm.text-editor] [app.util.text-editor :as ted] [app.util.text.content.styles :as styles] [app.util.timers :as ts] @@ -465,13 +466,23 @@ (when-not (some? (get-in state [:workspace-editor-state id])) (let [objects (dsh/lookup-page-objects state) shape (get objects id) + wasm? (features/active-feature? state "render-wasm/v1") update-node? (fn [node] (or (txt/is-text-node? node) (txt/is-paragraph-node? node))) shape-ids (cond (cfh/text-shape? shape) [id] - (cfh/group-shape? shape) (cfh/get-children-ids objects id))] - (rx/of (dwsh/update-shapes shape-ids #(txt/update-text-content % update-node? d/txt-merge attrs)))))))) + (cfh/group-shape? shape) (cfh/get-children-ids objects id)) + ;; Keep WASM editor cache in sync with merged :content so a following + ;; `apply-styles-to-selection` in `update-attrs` does not read stale + ;; `shape-text-contents` and overwrite per-run fills (e.g. line-height). + merge-shape + (fn [sh] + (let [updated-shape (txt/update-text-content sh update-node? d/txt-merge attrs)] + (when wasm? + (wasm.text-editor/cache-shape-text-content! (:id updated-shape) (:content updated-shape))) + updated-shape))] + (rx/of (dwsh/update-shapes shape-ids merge-shape))))))) (defn migrate-node [node] @@ -851,11 +862,13 @@ (effect [_ state _] (when (features/active-feature? state "text-editor/v2") (when-let [instance (:workspace-editor state)] - (let [attrs-to-override (some-> (editor.v2/getCurrentStyle instance) - (styles/get-styles-from-style-declaration)) - overriden-attrs (merge attrs-to-override attrs) - styles (styles/attrs->styles overriden-attrs)] - (editor.v2/applyStylesToSelection instance styles))))))) + (when (seq attrs) + ;; DOM `getCurrentStyle` reflects one resolved style (e.g. caret color). Merging + ;; it with sidebar `attrs` and applying to the whole selection collapses mixed + ;; fills/fonts when the user only changes one property (e.g. line-height). + ;; Apply only the explicit attributes from this action. + (let [styles (styles/attrs->styles attrs)] + (editor.v2/applyStylesToSelection instance styles)))))))) (defn update-all-attrs [ids attrs] From 8d51a88326ada92e01ebe483625afa229d2bed9b Mon Sep 17 00:00:00 2001 From: Alejandro Alonso Date: Thu, 14 May 2026 09:46:01 +0200 Subject: [PATCH 35/36] :bug: Fix grid not visible --- render-wasm/src/render.rs | 59 ++++++++++++++++----------------------- render-wasm/src/shapes.rs | 3 ++ 2 files changed, 27 insertions(+), 35 deletions(-) diff --git a/render-wasm/src/render.rs b/render-wasm/src/render.rs index 545755ba81..44c5c7e7af 100644 --- a/render-wasm/src/render.rs +++ b/render-wasm/src/render.rs @@ -809,7 +809,19 @@ impl RenderState { } pub fn flush_and_submit(&mut self) { + self.surfaces.flush_and_submit(SurfaceId::Target); + } + + /// Copy the clean (no UI overlay) Backbuffer to Target, draw UI/debug overlays + /// on top of Target, then present. Backbuffer is left clean so it can be reused + /// as-is across interactive-transform frames without stale overlay pixels. + pub fn present_frame(&mut self, tree: ShapesPoolRef) { self.surfaces.copy_backbuffer_to_target(); + if self.options.is_debug_visible() { + debug::render(self); + } + ui::render(self, tree); + debug::render_wasm_label(self); self.surfaces.flush_and_submit(SurfaceId::Target); } @@ -1771,14 +1783,7 @@ impl RenderState { self.surfaces .draw_atlas_to_backbuffer(self.viewbox, self.options.dpr, bg_color); - if self.options.is_debug_visible() { - debug::render(self); - } - - ui::render(self, shapes); - debug::render_wasm_label(self); - - self.flush_and_submit(); + self.present_frame(shapes); performance::end_measure!("render_from_cache"); performance::end_timed_log!("render_from_cache", _start); return; @@ -1843,13 +1848,7 @@ impl RenderState { bg_color, ); - if self.options.is_debug_visible() { - debug::render(self); - } - - ui::render(self, shapes); - debug::render_wasm_label(self); - self.flush_and_submit(); + self.present_frame(shapes); performance::end_measure!("render_from_cache"); performance::end_timed_log!("render_from_cache", _start); return; @@ -1902,14 +1901,7 @@ impl RenderState { } } - if self.options.is_debug_visible() { - debug::render(self); - } - - ui::render(self, shapes); - debug::render_wasm_label(self); - - self.flush_and_submit(); + self.present_frame(shapes); } performance::end_measure!("render_from_cache"); @@ -2103,17 +2095,21 @@ impl RenderState { self.render_shape_tree_partial(base_object, tree, timestamp, true)?; if self.render_in_progress { + // Partial frame: just flush GPU work. The display shows the last + // fully submitted frame; no need to copy or draw UI overlays here. self.flush(); self.cancel_animation_frame(); self.render_request_id = Some(wapi::request_animation_frame!()); } else { - // A full-quality frame is now complete. Refresh Backbuffer and regenerate - // the per-shape crop cache so interactive drags can reuse pixels. + // A full-quality frame is now complete. Rebuild the per-shape crop + // cache from the clean Backbuffer (no UI overlay yet) so that + // interactive drag backgrounds don't include the grid overlay. if !self.options.is_fast_mode() && !self.options.is_interactive_transform() { - self.surfaces.copy_backbuffer_to_target(); self.rebuild_backbuffer_crop_cache(tree); } - self.flush_and_submit(); + // present_frame: copy clean Backbuffer → Target, draw UI/debug + // overlays on Target only, then flush. Backbuffer stays overlay-free. + self.present_frame(tree); wapi::notify_tiles_render_complete!(); performance::end_measure!("render"); } @@ -2129,7 +2125,7 @@ impl RenderState { timestamp: i32, ) -> Result<()> { self.render_shape_tree_partial(base_object, tree, timestamp, false)?; - self.flush_and_submit(); + self.present_frame(tree); Ok(()) } @@ -3415,13 +3411,6 @@ impl RenderState { self.cached_viewbox = self.viewbox; } - if self.options.is_debug_visible() { - debug::render(self); - } - - ui::render(self, tree); - debug::render_wasm_label(self); - Ok(()) } diff --git a/render-wasm/src/shapes.rs b/render-wasm/src/shapes.rs index 83ecb89023..0b26cd632f 100644 --- a/render-wasm/src/shapes.rs +++ b/render-wasm/src/shapes.rs @@ -1495,6 +1495,8 @@ impl Shape { // Outsets (strokes, shadows, blur, children) are translation-invariant, // so the cached extrect can be shifted instead of invalidated. + // The bounds cache must always be invalidated so that callers such as + // grid_cell_data get the updated position after a drag. if math::is_move_only_matrix(transform) { let tx = transform.translate_x(); let ty = transform.translate_y(); @@ -1506,6 +1508,7 @@ impl Shape { rect.height(), ); } + self.invalidate_bounds(); } else { self.invalidate_extrect(); self.invalidate_bounds(); From 55dd6d2b003a36f40c94fd403ac3d8215e6b938a Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Thu, 14 May 2026 10:53:05 +0200 Subject: [PATCH 36/36] :wrench: Add cache to github tests CI worflow. (#9621) * :sparkles: Remove usage of RELEASE placeholder on deps.edn * :wrench: Add Maven cache to CI --------- Co-authored-by: Yamila Moreno --- .github/workflows/tests.yml | 69 +++++++++++++++++++++++++++++++------ backend/deps.edn | 10 +++--- common/deps.edn | 8 ++--- exporter/deps.edn | 4 +-- library/deps.edn | 10 +++--- 5 files changed, 74 insertions(+), 27 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index afcffb0ae7..e4b2d49efc 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -24,7 +24,11 @@ jobs: if: ${{ !github.event.pull_request.draft }} name: "Linter" runs-on: penpot-runner-02 - container: penpotapp/devenv:latest + container: + image: penpotapp/devenv:latest + volumes: + - /tmp/.m2:/root/.m2 + - /tmp/.gitlibs:/root/.gitlibs steps: - name: Checkout repository @@ -84,7 +88,11 @@ jobs: if: ${{ !github.event.pull_request.draft }} name: "Common Tests" runs-on: penpot-runner-02 - container: penpotapp/devenv:latest + container: + image: penpotapp/devenv:latest + volumes: + - /tmp/.m2:/root/.m2 + - /tmp/.gitlibs:/root/.gitlibs steps: - name: Checkout repository @@ -99,7 +107,11 @@ jobs: if: ${{ !github.event.pull_request.draft }} name: Plugins Runtime Linter & Tests runs-on: penpot-runner-02 - container: penpotapp/devenv:latest + container: + image: penpotapp/devenv:latest + volumes: + - /tmp/.m2:/root/.m2 + - /tmp/.gitlibs:/root/.gitlibs steps: - uses: actions/checkout@v6 @@ -150,7 +162,11 @@ jobs: if: ${{ !github.event.pull_request.draft }} name: "Frontend Tests" runs-on: penpot-runner-02 - container: penpotapp/devenv:latest + container: + image: penpotapp/devenv:latest + volumes: + - /tmp/.m2:/root/.m2 + - /tmp/.gitlibs:/root/.gitlibs steps: - name: Checkout repository @@ -172,7 +188,11 @@ jobs: if: ${{ !github.event.pull_request.draft }} name: "Render WASM Tests" runs-on: penpot-runner-02 - container: penpotapp/devenv:latest + container: + image: penpotapp/devenv:latest + volumes: + - /tmp/.m2:/root/.m2 + - /tmp/.gitlibs:/root/.gitlibs steps: - name: Checkout repository @@ -197,7 +217,11 @@ jobs: if: ${{ !github.event.pull_request.draft }} name: "Backend Tests" runs-on: penpot-runner-02 - container: penpotapp/devenv:latest + container: + image: penpotapp/devenv:latest + volumes: + - /tmp/.m2:/root/.m2 + - /tmp/.gitlibs:/root/.gitlibs services: postgres: @@ -237,7 +261,11 @@ jobs: if: ${{ !github.event.pull_request.draft }} name: "Library Tests" runs-on: penpot-runner-02 - container: penpotapp/devenv:latest + container: + image: penpotapp/devenv:latest + volumes: + - /tmp/.m2:/root/.m2 + - /tmp/.gitlibs:/root/.gitlibs steps: - name: Checkout repository @@ -252,7 +280,11 @@ jobs: if: ${{ !github.event.pull_request.draft }} name: "Build Integration Bundle" runs-on: penpot-runner-02 - container: penpotapp/devenv:latest + container: + image: penpotapp/devenv:latest + volumes: + - /tmp/.m2:/root/.m2 + - /tmp/.gitlibs:/root/.gitlibs steps: - name: Checkout repository @@ -273,7 +305,12 @@ jobs: if: ${{ !github.event.pull_request.draft }} name: "Integration Tests 1/3" runs-on: penpot-runner-02 - container: penpotapp/devenv:latest + container: + image: penpotapp/devenv:latest + volumes: + - /tmp/.m2:/root/.m2 + - /tmp/.gitlibs:/root/.gitlibs + needs: build-integration steps: @@ -304,7 +341,12 @@ jobs: if: ${{ !github.event.pull_request.draft }} name: "Integration Tests 2/3" runs-on: penpot-runner-02 - container: penpotapp/devenv:latest + container: + image: penpotapp/devenv:latest + volumes: + - /tmp/.m2:/root/.m2 + - /tmp/.gitlibs:/root/.gitlibs + needs: build-integration steps: @@ -335,7 +377,12 @@ jobs: if: ${{ !github.event.pull_request.draft }} name: "Integration Tests 3/3" runs-on: penpot-runner-02 - container: penpotapp/devenv:latest + container: + image: penpotapp/devenv:latest + volumes: + - /tmp/.m2:/root/.m2 + - /tmp/.gitlibs:/root/.gitlibs + needs: build-integration steps: diff --git a/backend/deps.edn b/backend/deps.edn index 399e7162f8..d53cf9a6c2 100644 --- a/backend/deps.edn +++ b/backend/deps.edn @@ -73,11 +73,11 @@ :aliases {:dev {:extra-deps - {com.bhauman/rebel-readline {:mvn/version "RELEASE"} + {com.bhauman/rebel-readline {:mvn/version "0.1.5"} clojure-humanize/clojure-humanize {:mvn/version "0.2.2"} - org.clojure/data.csv {:mvn/version "RELEASE"} - com.clojure-goes-fast/clj-async-profiler {:mvn/version "RELEASE"} - mockery/mockery {:mvn/version "RELEASE"}} + org.clojure/data.csv {:mvn/version "1.1.1"} + com.clojure-goes-fast/clj-async-profiler {:mvn/version "2.0.0-beta1"} + mockery/mockery {:mvn/version "0.1.4"}} :extra-paths ["test" "dev"]} :build @@ -93,7 +93,7 @@ :extra-deps {lambdaisland/kaocha {:mvn/version "1.91.1392"}}} :outdated - {:extra-deps {com.github.liquidz/antq {:mvn/version "RELEASE"}} + {:extra-deps {com.github.liquidz/antq {:mvn/version "2.11.1276"}} :main-opts ["-m" "antq.core"]} :jmx-remote diff --git a/common/deps.edn b/common/deps.edn index 329efa3260..74fcef0f28 100644 --- a/common/deps.edn +++ b/common/deps.edn @@ -55,12 +55,12 @@ :aliases {:dev {:extra-deps - {org.clojure/tools.namespace {:mvn/version "RELEASE"} + {org.clojure/tools.namespace {:mvn/version "1.5.0"} thheller/shadow-cljs {:mvn/version "3.2.0"} - com.clojure-goes-fast/clj-async-profiler {:mvn/version "RELEASE"} - com.bhauman/rebel-readline {:mvn/version "RELEASE"} + com.clojure-goes-fast/clj-async-profiler {:mvn/version "2.0.0-beta1"} + com.bhauman/rebel-readline {:mvn/version "0.1.5"} criterium/criterium {:mvn/version "0.4.6"} - mockery/mockery {:mvn/version "RELEASE"}} + mockery/mockery {:mvn/version "0.1.4"}} :extra-paths ["test" "dev"]} :build diff --git a/exporter/deps.edn b/exporter/deps.edn index 1c5cd1fb55..08e3a3cdd8 100644 --- a/exporter/deps.edn +++ b/exporter/deps.edn @@ -2,12 +2,12 @@ :deps {penpot/common {:local/root "../common"} org.clojure/clojure {:mvn/version "1.12.2"} - binaryage/devtools {:mvn/version "RELEASE"} + binaryage/devtools {:mvn/version "1.0.7"} metosin/reitit-core {:mvn/version "0.9.1"} } :aliases {:outdated - {:extra-deps {com.github.liquidz/antq {:mvn/version "RELEASE"} + {:extra-deps {com.github.liquidz/antq {:mvn/version"2.11.1276"} ;; org.slf4j/slf4j-nop {:mvn/version "RELEASE"} } :main-opts ["-m" "antq.core"]} diff --git a/library/deps.edn b/library/deps.edn index 9edca59650..18861c635e 100644 --- a/library/deps.edn +++ b/library/deps.edn @@ -9,12 +9,12 @@ :aliases {:outdated - {:extra-deps {com.github.liquidz/antq {:mvn/version "RELEASE"}} + {:extra-deps {com.github.liquidz/antq {:mvn/version "2.11.1276"}} :main-opts ["-m" "antq.core"]} :jvm-repl {:extra-deps - {com.bhauman/rebel-readline {:mvn/version "RELEASE"}} + {com.bhauman/rebel-readline {:mvn/version "0.1.5"}} :main-opts ["-m" "rebel-readline.main"] :jvm-opts ["--sun-misc-unsafe-memory-access=allow"]} @@ -22,9 +22,9 @@ {:extra-paths ["dev"] :extra-deps {thheller/shadow-cljs {:mvn/version "3.2.1"} - com.bhauman/rebel-readline {:mvn/version "RELEASE"} - org.clojure/tools.namespace {:mvn/version "RELEASE"} - criterium/criterium {:mvn/version "RELEASE"}}} + com.bhauman/rebel-readline {:mvn/version "0.1.5"} + org.clojure/tools.namespace {:mvn/version "1.5.0"} + criterium/criterium {:mvn/version "0.4.6"}}} :shadow-cljs {:main-opts ["-m" "shadow.cljs.devtools.cli"]