From 4477b2b4a05afaf5cba62bffeb3ef98e93756bec Mon Sep 17 00:00:00 2001 From: Aitor Moreno Date: Tue, 3 Feb 2026 13:31:35 +0100 Subject: [PATCH 1/4] :tada: Compute selection rects from pointer events --- .../main/ui/workspace/viewport/actions.cljs | 109 +++++--- frontend/src/app/render_wasm/api.cljs | 36 +-- frontend/src/app/render_wasm/text_editor.cljs | 24 +- .../src/editor/content/dom/Color.js | 1 + frontend/text-editor/src/playground.js | 3 + render-wasm/pnpm-workspace.yaml | 2 + render-wasm/src/render/text_editor.rs | 20 +- render-wasm/src/shapes.rs | 12 + render-wasm/src/shapes/text.rs | 24 +- render-wasm/src/state/text_editor.rs | 70 ++++- render-wasm/src/wasm/text.rs | 33 +-- render-wasm/src/wasm/text_editor.rs | 251 ++++++++---------- 12 files changed, 343 insertions(+), 242 deletions(-) create mode 100644 render-wasm/pnpm-workspace.yaml diff --git a/frontend/src/app/main/ui/workspace/viewport/actions.cljs b/frontend/src/app/main/ui/workspace/viewport/actions.cljs index f0a3bc3600..041cb6f53a 100644 --- a/frontend/src/app/main/ui/workspace/viewport/actions.cljs +++ b/frontend/src/app/main/ui/workspace/viewport/actions.cljs @@ -19,7 +19,6 @@ [app.main.data.workspace.media :as dwm] [app.main.data.workspace.path :as dwdp] [app.main.data.workspace.specialized-panel :as-alias dwsp] - [app.main.data.workspace.texts :as dwt] [app.main.features :as features] [app.main.refs :as refs] [app.main.store :as st] @@ -50,41 +49,42 @@ (mf/deps id blocked hidden type selected edition drawing-tool text-editing? node-editing? grid-editing? drawing-path? create-comment? @z? @space? panning read-only?) - (fn [bevent] + (fn [event] ;; We need to handle editor related stuff here because ;; handling on editor dom node does not works properly. - (let [target (dom/get-target bevent) + (let [target (dom/get-target event) editor (txu/closest-text-editor-content target)] ;; Capture mouse pointer to detect the movements even if cursor ;; leaves the viewport or the browser itself ;; https://developer.mozilla.org/en-US/docs/Web/API/Element/setPointerCapture (if editor - (.setPointerCapture editor (.-pointerId bevent)) - (.setPointerCapture target (.-pointerId bevent)))) + (.setPointerCapture editor (.-pointerId event)) + (.setPointerCapture target (.-pointerId event)))) - (when (or (dom/class? (dom/get-target bevent) "viewport-controls") - (dom/class? (dom/get-target bevent) "viewport-selrect") - (dom/child? (dom/get-target bevent) (dom/query ".grid-layout-editor"))) + (when (or (dom/class? (dom/get-target event) "viewport-controls") + (dom/class? (dom/get-target event) "viewport-selrect") + (dom/child? (dom/get-target event) (dom/query ".grid-layout-editor"))) - (dom/stop-propagation bevent) + (dom/stop-propagation event) (when-not @z? - (let [event (dom/event->native-event bevent) - ctrl? (kbd/ctrl? event) - meta? (kbd/meta? event) - shift? (kbd/shift? event) - alt? (kbd/alt? event) - mod? (kbd/mod? event) + (let [native-event (dom/event->native-event event) + ctrl? (kbd/ctrl? native-event) + meta? (kbd/meta? native-event) + shift? (kbd/shift? native-event) + alt? (kbd/alt? native-event) + mod? (kbd/mod? native-event) + off-pt (dom/get-offset-position native-event) - left-click? (and (not panning) (dom/left-mouse? bevent)) - middle-click? (and (not panning) (dom/middle-mouse? bevent))] + left-click? (and (not panning) (dom/left-mouse? event)) + middle-click? (and (not panning) (dom/middle-mouse? event))] (cond (or middle-click? (and left-click? @space?)) (do - (dom/prevent-default bevent) + (dom/prevent-default event) (if mod? - (let [raw-pt (dom/get-client-position event) + (let [raw-pt (dom/get-client-position native-event) pt (uwvv/point->viewport raw-pt)] (st/emit! (dw/start-zooming pt))) (st/emit! (dw/start-panning)))) @@ -94,18 +94,23 @@ (st/emit! (mse/->MouseEvent :down ctrl? shift? alt? meta?) ::dwsp/interrupt) + (when (wasm.api/text-editor-is-active?) + (wasm.api/text-editor-pointer-down (.-x off-pt) (.-y off-pt))) + (when (and (not= edition id) (or text-editing? grid-editing?)) (st/emit! (dw/clear-edition-mode)) + ;; FIXME: I think this is not completely correct because this + ;; is going to happen even when clicking or selecting text. ;; Sync and stop WASM text editor when exiting edit mode - (when (and text-editing? - (features/active-feature? @st/state "render-wasm/v1") - wasm.wasm/context-initialized?) - (when-let [{:keys [shape-id content]} (wasm.api/text-editor-sync-content)] - (st/emit! (dwt/v2-update-text-shape-content - shape-id content - :update-name? true - :finalize? true))) - (wasm.api/text-editor-stop))) + #_(when (and text-editing? + (features/active-feature? @st/state "render-wasm/v1") + wasm.wasm/context-initialized?) + (when-let [{:keys [shape-id content]} (wasm.api/text-editor-sync-content)] + (st/emit! (dwt/v2-update-text-shape-content + shape-id content + :update-name? true + :finalize? true))) + (wasm.api/text-editor-stop))) (when (and (not text-editing?) (not blocked) @@ -187,10 +192,14 @@ alt? (kbd/alt? event) meta? (kbd/meta? event) hovering? (some? @hover) + native-event (dom/event->native-event event) + off-pt (dom/get-offset-position native-event) raw-pt (dom/get-client-position event) pt (uwvv/point->viewport raw-pt)] (st/emit! (mse/->MouseEvent :click ctrl? shift? alt? meta?)) + ;; FIXME: Maybe we can transform this into a cond instead + ;; of multiple (when)s. (when (and hovering? (not @space?) (not edition) @@ -198,6 +207,8 @@ (not drawing-tool)) (st/emit! (dw/select-shape (:id @hover) shift?))) + ;; FIXME: Maybe we can move into a function of the kind + ;; "text-editor-on-click" ;; If clicking on a text shape and wasm render is enabled, forward cursor position (when (and hovering? (not @space?) @@ -208,9 +219,7 @@ (when (and (= :text (:type hover-shape)) (features/active-feature? @st/state "text-editor-wasm/v1") wasm.wasm/context-initialized?) - (let [raw-pt (dom/get-client-position event)] - ;; FIXME - (wasm.api/text-editor-set-cursor-from-point (.-x raw-pt) (.-y raw-pt)))))) + (wasm.api/text-editor-set-cursor-from-point (.-x off-pt) (.-y off-pt))))) (when (and @z? (not @space?) @@ -261,6 +270,12 @@ wasm.wasm/context-initialized?) (wasm.api/text-editor-start id))) + (and editable? (= id edition) (not read-only?) + (= type :text) + (features/active-feature? @st/state "text-editor-wasm/v1") + wasm.wasm/context-initialized?) + (wasm.api/text-editor-select-all) + (some? selected-shape) (do (reset! hover selected-shape) @@ -310,20 +325,24 @@ ;; Release pointer on mouse up (.releasePointerCapture target (.-pointerId event))) - (let [event (dom/event->native-event event) - ctrl? (kbd/ctrl? event) - shift? (kbd/shift? event) - alt? (kbd/alt? event) - meta? (kbd/meta? event) + (let [native-event (dom/event->native-event event) + off-pt (dom/get-offset-position native-event) + ctrl? (kbd/ctrl? native-event) + shift? (kbd/shift? native-event) + alt? (kbd/alt? native-event) + meta? (kbd/meta? native-event) - left-click? (= 1 (.-which event)) - middle-click? (= 2 (.-which event))] + left-click? (= 1 (.-which native-event)) + middle-click? (= 2 (.-which native-event))] (when left-click? - (st/emit! (mse/->MouseEvent :up ctrl? shift? alt? meta?))) + (st/emit! (mse/->MouseEvent :up ctrl? shift? alt? meta?)) + + (when (wasm.api/text-editor-is-active?) + (wasm.api/text-editor-pointer-up (.-x off-pt) (.-y off-pt)))) (when middle-click? - (dom/prevent-default event) + (dom/prevent-default native-event) ;; We store this so in Firefox the middle button won't do a paste of the content (mf/set-ref-val! disable-paste-ref true) @@ -381,7 +400,9 @@ (let [last-position (mf/use-var nil)] (mf/use-fn (fn [event] - (let [raw-pt (dom/get-client-position event) + (let [native-event (unchecked-get event "nativeEvent") + off-pt (dom/get-offset-position native-event) + raw-pt (dom/get-client-position event) pt (uwvv/point->viewport raw-pt) ;; We calculate the delta because Safari's MouseEvent.movementX/Y drop @@ -390,6 +411,12 @@ (gpt/subtract raw-pt @last-position) (gpt/point 0 0))] + ;; IMPORTANT! This function, right now it's called on EVERY pointermove. I think + ;; in the future (when we handle the UI in the render) should be better to + ;; have a "wasm.api/pointer-move" function that works as an entry point for + ;; all the pointer-move events. + (wasm.api/text-editor-pointer-move (.-x off-pt) (.-y off-pt)) + (rx/push! move-stream pt) (reset! last-position raw-pt) (st/emit! (mse/->PointerEvent :delta delta diff --git a/frontend/src/app/render_wasm/api.cljs b/frontend/src/app/render_wasm/api.cljs index 849be1089b..e3f55b0d37 100644 --- a/frontend/src/app/render_wasm/api.cljs +++ b/frontend/src/app/render_wasm/api.cljs @@ -87,7 +87,11 @@ (def text-editor-start text-editor/text-editor-start) (def text-editor-stop text-editor/text-editor-stop) (def text-editor-set-cursor-from-point text-editor/text-editor-set-cursor-from-point) +(def text-editor-pointer-down text-editor/text-editor-pointer-down) +(def text-editor-pointer-move text-editor/text-editor-pointer-move) +(def text-editor-pointer-up text-editor/text-editor-pointer-up) (def text-editor-is-active? text-editor/text-editor-is-active?) +(def text-editor-select-all text-editor/text-editor-select-all) (def text-editor-sync-content text-editor/text-editor-sync-content) (def dpr @@ -263,22 +267,6 @@ [attrs] (text-editor/apply-style-to-selection attrs use-shape set-shape-text-content)) -(defn update-text-rect! - [id] - (when wasm/context-initialized? - (mw/emit! - {:cmd :index/update-text-rect - :page-id (:current-page-id @st/state) - :shape-id id - :dimensions (get-text-dimensions id)}))) - -(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 - tc/default-text-content so the renderer receives typography information." - [content] - (or content (tc/v2-default-text-content))) - (defn set-parent-id [id] (let [buffer (uuid/get-u32 id)] @@ -996,6 +984,22 @@ (render-finish) (perf/end-measure "set-view-box::zoom"))))) +(defn update-text-rect! + [id] + (when wasm/context-initialized? + (mw/emit! + {:cmd :index/update-text-rect + :page-id (:current-page-id @st/state) + :shape-id id + :dimensions (get-text-dimensions id)}))) + +(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 + tc/default-text-content so the renderer receives typography information." + [content] + (or content (tc/v2-default-text-content))) + (defn set-object [shape] (perf/begin-measure "set-object") diff --git a/frontend/src/app/render_wasm/text_editor.cljs b/frontend/src/app/render_wasm/text_editor.cljs index 882f24f890..21bcca45d2 100644 --- a/frontend/src/app/render_wasm/text_editor.cljs +++ b/frontend/src/app/render_wasm/text_editor.cljs @@ -27,6 +27,21 @@ (when wasm/context-initialized? (h/call wasm/internal-module "_text_editor_set_cursor_from_point" x y))) +(defn text-editor-pointer-down + [x y] + (when wasm/context-initialized? + (h/call wasm/internal-module "_text_editor_pointer_down" x y))) + +(defn text-editor-pointer-move + [x y] + (when wasm/context-initialized? + (h/call wasm/internal-module "_text_editor_pointer_move" x y))) + +(defn text-editor-pointer-up + [x y] + (when wasm/context-initialized? + (h/call wasm/internal-module "_text_editor_pointer_up" x y))) + (defn text-editor-update-blink [timestamp-ms] (when wasm/context-initialized? @@ -83,9 +98,12 @@ (h/call wasm/internal-module "_text_editor_stop"))) (defn text-editor-is-active? - [] - (when wasm/context-initialized? - (not (zero? (h/call wasm/internal-module "_text_editor_is_active"))))) + ([id] + (when wasm/context-initialized? + (not (zero? (h/call wasm/internal-module "_text_editor_is_active_with_id" id))))) + ([] + (when wasm/context-initialized? + (not (zero? (h/call wasm/internal-module "_text_editor_is_active")))))) (defn text-editor-export-content [] diff --git a/frontend/text-editor/src/editor/content/dom/Color.js b/frontend/text-editor/src/editor/content/dom/Color.js index ba798dd67a..69f4805495 100644 --- a/frontend/text-editor/src/editor/content/dom/Color.js +++ b/frontend/text-editor/src/editor/content/dom/Color.js @@ -76,3 +76,4 @@ export function getFills(fillStyle) { const [color, opacity] = getColor(fillStyle); return `[["^ ","~:fill-color","${color}","~:fill-opacity",${opacity}]]`; } + diff --git a/frontend/text-editor/src/playground.js b/frontend/text-editor/src/playground.js index ba36cfb046..0bdf72db8c 100644 --- a/frontend/text-editor/src/playground.js +++ b/frontend/text-editor/src/playground.js @@ -162,12 +162,15 @@ class TextEditorPlayground { } this.#module.call("use_shape", ...textShape.id); + // FIXME: This function doesn't exists anymore. + /* const caretPosition = this.#module.call( "get_caret_position_at", e.offsetX, e.offsetY, ); console.log("caretPosition", caretPosition); + */ }; #onResize = (_entries) => { diff --git a/render-wasm/pnpm-workspace.yaml b/render-wasm/pnpm-workspace.yaml new file mode 100644 index 0000000000..efc037aa84 --- /dev/null +++ b/render-wasm/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +onlyBuiltDependencies: + - esbuild diff --git a/render-wasm/src/render/text_editor.rs b/render-wasm/src/render/text_editor.rs index be37ce627d..8d4243f80b 100644 --- a/render-wasm/src/render/text_editor.rs +++ b/render-wasm/src/render/text_editor.rs @@ -45,7 +45,11 @@ fn render_cursor( paint.set_color(editor_state.theme.cursor_color); paint.set_anti_alias(true); + let shape_matrix = shape.get_matrix(); + canvas.save(); + canvas.concat(&shape_matrix); canvas.draw_rect(rect, &paint); + canvas.restore(); } fn render_selection( @@ -65,9 +69,14 @@ fn render_selection( paint.set_blend_mode(BlendMode::Multiply); paint.set_color(editor_state.theme.selection_color); paint.set_anti_alias(true); + + let shape_matrix = shape.get_matrix(); + canvas.save(); + canvas.concat(&shape_matrix); for rect in rects { canvas.draw_rect(rect, &paint); } + canvas.restore(); } fn vertical_align_offset( @@ -99,8 +108,6 @@ fn calculate_cursor_rect( return None; } - let selrect = shape.selrect(); - let mut y_offset = vertical_align_offset(shape, &layout_paragraphs); for (idx, laid_out_para) in layout_paragraphs.iter().enumerate() { if idx == cursor.paragraph { @@ -157,8 +164,8 @@ fn calculate_cursor_rect( }; return Some(Rect::from_xywh( - selrect.x() + cursor_x, - selrect.y() + y_offset, + cursor_x, + y_offset, editor_state.theme.cursor_width, cursor_height, )); @@ -182,7 +189,6 @@ fn calculate_selection_rects( let paragraphs = text_content.paragraphs(); let layout_paragraphs: Vec<_> = text_content.layout.paragraphs.iter().flatten().collect(); - let selrect = shape.selrect(); let mut y_offset = vertical_align_offset(shape, &layout_paragraphs); for (para_idx, laid_out_para) in layout_paragraphs.iter().enumerate() { @@ -225,8 +231,8 @@ fn calculate_selection_rects( for text_box in text_boxes { let r = text_box.rect; rects.push(Rect::from_xywh( - selrect.x() + r.left(), - selrect.y() + y_offset + r.top(), + r.left(), + y_offset + r.top(), r.width(), r.height(), )); diff --git a/render-wasm/src/shapes.rs b/render-wasm/src/shapes.rs index 8967b6ee49..9605dabe87 100644 --- a/render-wasm/src/shapes.rs +++ b/render-wasm/src/shapes.rs @@ -258,6 +258,18 @@ pub fn all_with_ancestors( } impl Shape { + pub fn get_relative_point( + point: &Point, + view_matrix: &Matrix, + shape_matrix: &Matrix, + ) -> Option { + let inv_view_matrix = view_matrix.invert()?; + let inv_shape_matrix = shape_matrix.invert()?; + let transform_matrix: Matrix = Matrix::concat(&inv_shape_matrix, &inv_view_matrix); + let shape_relative_point = transform_matrix.map_point(*point); + Some(shape_relative_point) + } + pub fn new(id: Uuid) -> Self { Self { id, diff --git a/render-wasm/src/shapes/text.rs b/render-wasm/src/shapes/text.rs index feaab039fb..1cad369768 100644 --- a/render-wasm/src/shapes/text.rs +++ b/render-wasm/src/shapes/text.rs @@ -112,12 +112,15 @@ impl TextContentSize { } } -#[derive(Debug, Clone, Copy)] +#[derive(Debug, Copy, Clone)] pub struct TextPositionWithAffinity { + #[allow(dead_code)] pub position_with_affinity: PositionWithAffinity, pub paragraph: i32, #[allow(dead_code)] pub span: i32, + #[allow(dead_code)] + pub span_relative_offset: i32, pub offset: i32, } @@ -126,12 +129,14 @@ impl TextPositionWithAffinity { position_with_affinity: PositionWithAffinity, paragraph: i32, span: i32, + span_relative_offset: i32, offset: i32, ) -> Self { Self { position_with_affinity, paragraph, span, + span_relative_offset, offset, } } @@ -421,7 +426,10 @@ impl TextContent { self.bounds = Rect::from_ltrb(p1.x, p1.y, p2.x, p2.y); } - pub fn get_caret_position_at(&self, point: &Point) -> Option { + pub fn get_caret_position_from_shape_coords( + &self, + point: &Point, + ) -> Option { let mut offset_y = 0.0; let layout_paragraphs = self.layout.paragraphs.iter().flatten(); @@ -487,6 +495,7 @@ impl TextContent { paragraph_index, span_index, span_offset, + position_with_affinity.position, )); } } @@ -509,12 +518,23 @@ impl TextContent { 0, // paragraph 0 0, // span 0 0, // offset 0 + 0, )); } None } + pub fn get_caret_position_from_screen_coords( + &self, + point: &Point, + view_matrix: &Matrix, + shape_matrix: &Matrix, + ) -> Option { + let shape_rel_point = Shape::get_relative_point(point, view_matrix, shape_matrix)?; + self.get_caret_position_from_shape_coords(&shape_rel_point) + } + /// Builds the ParagraphBuilders necessary to render /// this text. pub fn paragraph_builder_group_from_text( diff --git a/render-wasm/src/state/text_editor.rs b/render-wasm/src/state/text_editor.rs index d7474cc92f..8c384db2b3 100644 --- a/render-wasm/src/state/text_editor.rs +++ b/render-wasm/src/state/text_editor.rs @@ -1,8 +1,11 @@ #![allow(dead_code)] -use crate::shapes::TextPositionWithAffinity; +use crate::shapes::{TextContent, TextPositionWithAffinity}; use crate::uuid::Uuid; -use skia_safe::Color; +use skia_safe::{ + textlayout::{Affinity, PositionWithAffinity}, + Color, +}; /// Cursor position within text content. /// Uses character offsets for precise positioning. @@ -122,6 +125,9 @@ pub struct TextEditorState { pub theme: TextEditorTheme, pub selection: TextSelection, pub is_active: bool, + // This property indicates that we've started + // selecting something with the pointer. + pub is_pointer_selection_active: bool, pub active_shape_id: Option, pub cursor_visible: bool, pub last_blink_time: f64, @@ -138,6 +144,7 @@ impl TextEditorState { }, selection: TextSelection::new(), is_active: false, + is_pointer_selection_active: false, active_shape_id: None, cursor_visible: true, last_blink_time: 0.0, @@ -151,6 +158,7 @@ impl TextEditorState { self.cursor_visible = true; self.last_blink_time = 0.0; self.selection = TextSelection::new(); + self.is_pointer_selection_active = false; self.pending_events.clear(); } @@ -158,7 +166,65 @@ impl TextEditorState { self.is_active = false; self.active_shape_id = None; self.cursor_visible = false; + self.is_pointer_selection_active = false; self.pending_events.clear(); + self.reset_blink(); + } + + pub fn start_pointer_selection(&mut self) -> bool { + if self.is_pointer_selection_active { + return false; + } + self.is_pointer_selection_active = true; + true + } + + pub fn stop_pointer_selection(&mut self) -> bool { + if !self.is_pointer_selection_active { + return false; + } + self.is_pointer_selection_active = false; + true + } + + pub fn select_all(&mut self, content: &TextContent) -> bool { + self.is_pointer_selection_active = false; + self.set_caret_from_position(TextPositionWithAffinity::new( + PositionWithAffinity { + position: 0, + affinity: Affinity::Downstream, + }, + 0, + 0, + 0, + 0, + )); + let num_paragraphs = (content.paragraphs().len() - 1) as i32; + let Some(last_paragraph) = content.paragraphs().last() else { + return false; + }; + let num_spans = (last_paragraph.children().len() - 1) as i32; + let Some(last_text_span) = last_paragraph.children().last() else { + return false; + }; + let mut offset = 0; + for span in last_paragraph.children() { + offset += span.text.len(); + } + self.extend_selection_from_position(TextPositionWithAffinity::new( + PositionWithAffinity { + position: offset as i32, + affinity: Affinity::Upstream, + }, + num_paragraphs, + num_spans, + last_text_span.text.len() as i32, + offset as i32, + )); + self.reset_blink(); + self.push_event(crate::state::EditorEvent::SelectionChanged); + + true } pub fn set_caret_from_position(&mut self, position: TextPositionWithAffinity) { diff --git a/render-wasm/src/wasm/text.rs b/render-wasm/src/wasm/text.rs index e4617575aa..ab4b14541e 100644 --- a/render-wasm/src/wasm/text.rs +++ b/render-wasm/src/wasm/text.rs @@ -1,16 +1,13 @@ use macros::ToJs; use super::{fills::RawFillData, fonts::RawFontStyle}; -use crate::math::{Matrix, Point}; + use crate::mem::{self, SerializableResult}; 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, - with_state_mut_current_shape, STATE, -}; +use crate::{with_current_shape, with_current_shape_mut, with_state, with_state_mut, STATE}; const RAW_SPAN_DATA_SIZE: usize = std::mem::size_of::(); const RAW_PARAGRAPH_DATA_SIZE: usize = std::mem::size_of::(); @@ -388,32 +385,6 @@ pub extern "C" fn update_shape_text_layout_for(a: u32, b: u32, c: u32, d: u32) { }); } -#[no_mangle] -pub extern "C" fn get_caret_position_at(x: f32, y: f32) -> i32 { - with_state_mut_current_shape!(state, |shape: &Shape| { - if let Type::Text(text_content) = &shape.shape_type { - let mut matrix = Matrix::new_identity(); - let shape_matrix = shape.get_concatenated_matrix(&state.shapes); - let view_matrix = state.render_state.viewbox.get_matrix(); - if let Some(inv_view_matrix) = view_matrix.invert() { - matrix.post_concat(&inv_view_matrix); - matrix.post_concat(&shape_matrix); - - let mapped_point = matrix.map_point(Point::new(x, y)); - - if let Some(position_with_affinity) = - text_content.get_caret_position_at(&mapped_point) - { - return position_with_affinity.position_with_affinity.position; - } - } - } else { - panic!("Trying to get caret position of a shape that it's not a text shape"); - } - }); - -1 -} - const RAW_POSITION_DATA_SIZE: usize = size_of::(); impl From<[u8; RAW_POSITION_DATA_SIZE]> for shapes::PositionData { diff --git a/render-wasm/src/wasm/text_editor.rs b/render-wasm/src/wasm/text_editor.rs index 37758f5bb1..771ee82627 100644 --- a/render-wasm/src/wasm/text_editor.rs +++ b/render-wasm/src/wasm/text_editor.rs @@ -1,5 +1,3 @@ -use macros::ToJs; - use crate::math::{Matrix, Point, Rect}; use crate::mem; use crate::shapes::{Paragraph, Shape, TextContent, Type, VerticalAlign}; @@ -7,6 +5,7 @@ use crate::state::{TextCursor, TextSelection}; use crate::utils::uuid_from_u32_quartet; use crate::utils::uuid_to_u32_quartet; use crate::{with_state, with_state_mut, STATE}; +use macros::ToJs; #[derive(PartialEq, ToJs)] #[repr(u8)] @@ -54,6 +53,17 @@ pub extern "C" fn text_editor_is_active() -> bool { with_state!(state, { state.text_editor_state.is_active }) } +#[no_mangle] +pub extern "C" fn text_editor_is_active_with_id(a: u32, b: u32, c: u32, d: u32) -> bool { + with_state!(state, { + let shape_id = uuid_from_u32_quartet(a, b, c, d); + let Some(active_shape_id) = state.text_editor_state.active_shape_id else { + return false; + }; + state.text_editor_state.is_active && active_shape_id == shape_id + }) +} + #[no_mangle] pub extern "C" fn text_editor_get_active_shape_id(buffer_ptr: *mut u32) { with_state!(state, { @@ -70,45 +80,25 @@ pub extern "C" fn text_editor_get_active_shape_id(buffer_ptr: *mut u32) { } #[no_mangle] -pub extern "C" fn text_editor_select_all() { +pub extern "C" fn text_editor_select_all() -> bool { with_state_mut!(state, { if !state.text_editor_state.is_active { - return; + return false; } let Some(shape_id) = state.text_editor_state.active_shape_id else { - return; + return false; }; let Some(shape) = state.shapes.get(&shape_id) else { - return; + return false; }; let Type::Text(text_content) = &shape.shape_type else { - return; + return false; }; - - let paragraphs = text_content.paragraphs(); - if paragraphs.is_empty() { - return; - } - - let last_para_idx = paragraphs.len() - 1; - let last_para = ¶graphs[last_para_idx]; - let total_chars: usize = last_para - .children() - .iter() - .map(|span| span.text.chars().count()) - .sum(); - - use crate::state::TextCursor; - state.text_editor_state.selection.anchor = TextCursor::new(0, 0); - state.text_editor_state.selection.focus = TextCursor::new(last_para_idx, total_chars); - state.text_editor_state.reset_blink(); - state - .text_editor_state - .push_event(crate::state::EditorEvent::SelectionChanged); - }); + state.text_editor_state.select_all(text_content) + }) } #[no_mangle] @@ -121,146 +111,127 @@ pub extern "C" fn text_editor_poll_event() -> u8 { // ============================================================================ #[no_mangle] -pub extern "C" fn text_editor_set_cursor_from_point(x: f32, y: f32) { +pub extern "C" fn text_editor_pointer_down(x: f32, y: f32) { with_state_mut!(state, { if !state.text_editor_state.is_active { return; } - let Some(shape_id) = state.text_editor_state.active_shape_id else { return; }; - - let (shape_matrix, view_matrix, selrect, vertical_align) = { - let Some(shape) = state.shapes.get(&shape_id) else { - return; - }; - ( - shape.get_concatenated_matrix(&state.shapes), - state.render_state.viewbox.get_matrix(), - shape.selrect(), - shape.vertical_align(), - ) - }; - - let Some(inv_view_matrix) = view_matrix.invert() else { + let Some(shape) = state.shapes.get(&shape_id) else { return; }; - - let Some(inv_shape_matrix) = shape_matrix.invert() else { + let Type::Text(text_content) = &shape.shape_type else { return; }; - - let mut matrix = Matrix::new_identity(); - matrix.post_concat(&inv_view_matrix); - matrix.post_concat(&inv_shape_matrix); - - let mapped_point = matrix.map_point(Point::new(x, y)); - - let Some(shape) = state.shapes.get_mut(&shape_id) else { - return; - }; - - let Type::Text(text_content) = &mut shape.shape_type else { - return; - }; - - if text_content.layout.paragraphs.is_empty() && !text_content.paragraphs().is_empty() { - let bounds = text_content.bounds; - text_content.update_layout(bounds); - } - - // Calculate vertical alignment offset (same as in render/text_editor.rs) - let layout_paragraphs: Vec<_> = text_content.layout.paragraphs.iter().flatten().collect(); - let total_height: f32 = layout_paragraphs.iter().map(|p| p.height()).sum(); - let vertical_offset = match vertical_align { - crate::shapes::VerticalAlign::Center => (selrect.height() - total_height) / 2.0, - crate::shapes::VerticalAlign::Bottom => selrect.height() - total_height, - _ => 0.0, - }; - - // Adjust point: subtract selrect offset and vertical alignment - // The text layout expects coordinates where (0, 0) is the top-left of the text content - let adjusted_point = Point::new( - mapped_point.x - selrect.x(), - mapped_point.y - selrect.y() - vertical_offset, - ); - - if let Some(position) = text_content.get_caret_position_at(&adjusted_point) { + let point = Point::new(x, y); + let view_matrix: Matrix = state.render_state.viewbox.get_matrix(); + let shape_matrix = shape.get_matrix(); + state.text_editor_state.start_pointer_selection(); + if let Some(position) = + text_content.get_caret_position_from_screen_coords(&point, &view_matrix, &shape_matrix) + { state.text_editor_state.set_caret_from_position(position); } }); } #[no_mangle] -pub extern "C" fn text_editor_extend_selection_to_point(x: f32, y: f32) { +pub extern "C" fn text_editor_pointer_move(x: f32, y: f32) { + with_state_mut!(state, { + if !state.text_editor_state.is_active { + return; + } + let view_matrix: Matrix = state.render_state.viewbox.get_matrix(); + let point = Point::new(x, y); + let Some(shape_id) = state.text_editor_state.active_shape_id else { + return; + }; + let Some(shape) = state.shapes.get(&shape_id) else { + return; + }; + let shape_matrix = shape.get_matrix(); + let Some(_shape_rel_point) = Shape::get_relative_point(&point, &view_matrix, &shape_matrix) + else { + return; + }; + if !state.text_editor_state.is_pointer_selection_active { + return; + } + let Type::Text(text_content) = &shape.shape_type else { + return; + }; + + if let Some(position) = + text_content.get_caret_position_from_screen_coords(&point, &view_matrix, &shape_matrix) + { + state + .text_editor_state + .extend_selection_from_position(position); + } + }); +} + +#[no_mangle] +pub extern "C" fn text_editor_pointer_up(x: f32, y: f32) { + with_state_mut!(state, { + if !state.text_editor_state.is_active { + return; + } + let view_matrix: Matrix = state.render_state.viewbox.get_matrix(); + let point = Point::new(x, y); + let Some(shape_id) = state.text_editor_state.active_shape_id else { + return; + }; + let Some(shape) = state.shapes.get(&shape_id) else { + return; + }; + let shape_matrix = shape.get_matrix(); + let Some(_shape_rel_point) = Shape::get_relative_point(&point, &view_matrix, &shape_matrix) + else { + return; + }; + if !state.text_editor_state.is_pointer_selection_active { + return; + } + let Type::Text(text_content) = &shape.shape_type else { + return; + }; + if let Some(position) = + text_content.get_caret_position_from_screen_coords(&point, &view_matrix, &shape_matrix) + { + state + .text_editor_state + .extend_selection_from_position(position); + } + state.text_editor_state.stop_pointer_selection(); + }); +} + +#[no_mangle] +pub extern "C" fn text_editor_set_cursor_from_point(x: f32, y: f32) { with_state_mut!(state, { if !state.text_editor_state.is_active { return; } + let view_matrix: Matrix = state.render_state.viewbox.get_matrix(); + let point = Point::new(x, y); let Some(shape_id) = state.text_editor_state.active_shape_id else { return; }; - - let (shape_matrix, view_matrix, selrect, vertical_align) = { - let Some(shape) = state.shapes.get(&shape_id) else { - return; - }; - ( - shape.get_concatenated_matrix(&state.shapes), - state.render_state.viewbox.get_matrix(), - shape.selrect(), - shape.vertical_align(), - ) - }; - - let Some(inv_view_matrix) = view_matrix.invert() else { + let Some(shape) = state.shapes.get(&shape_id) else { return; }; - - let Some(inv_shape_matrix) = shape_matrix.invert() else { + let shape_matrix = shape.get_matrix(); + let Type::Text(text_content) = &shape.shape_type else { return; }; - - let mut matrix = Matrix::new_identity(); - matrix.post_concat(&inv_view_matrix); - matrix.post_concat(&inv_shape_matrix); - - let mapped_point = matrix.map_point(Point::new(x, y)); - - let Some(shape) = state.shapes.get_mut(&shape_id) else { - return; - }; - - let Type::Text(text_content) = &mut shape.shape_type else { - return; - }; - - if text_content.layout.paragraphs.is_empty() && !text_content.paragraphs().is_empty() { - let bounds = text_content.bounds; - text_content.update_layout(bounds); - } - - // Calculate vertical alignment offset (same as in render/text_editor.rs) - let layout_paragraphs: Vec<_> = text_content.layout.paragraphs.iter().flatten().collect(); - let total_height: f32 = layout_paragraphs.iter().map(|p| p.height()).sum(); - let vertical_offset = match vertical_align { - crate::shapes::VerticalAlign::Center => (selrect.height() - total_height) / 2.0, - crate::shapes::VerticalAlign::Bottom => selrect.height() - total_height, - _ => 0.0, - }; - - // Adjust point: subtract selrect offset and vertical alignment - let adjusted_point = Point::new( - mapped_point.x - selrect.x(), - mapped_point.y - selrect.y() - vertical_offset, - ); - - if let Some(position) = text_content.get_caret_position_at(&adjusted_point) { - state - .text_editor_state - .extend_selection_from_position(position); + if let Some(position) = + text_content.get_caret_position_from_screen_coords(&point, &view_matrix, &shape_matrix) + { + state.text_editor_state.set_caret_from_position(position); } }); } From 84ba6f00024a55818e6edce5b429a185b68062d0 Mon Sep 17 00:00:00 2001 From: Alejandro Alonso Date: Tue, 24 Feb 2026 08:56:16 +0100 Subject: [PATCH 2/4] :tada: Update skia version --- render-wasm/Cargo.lock | 86 ++++++++++++++------------- render-wasm/Cargo.toml | 2 +- render-wasm/_build_env | 2 +- render-wasm/lint | 2 +- render-wasm/src/math.rs | 2 +- render-wasm/src/math/bools.rs | 48 ++++++++------- render-wasm/src/render/fills.rs | 9 ++- render-wasm/src/render/grid_layout.rs | 6 +- render-wasm/src/render/strokes.rs | 82 ++++++++++++------------- render-wasm/src/render/surfaces.rs | 6 +- render-wasm/src/shapes.rs | 2 +- render-wasm/src/shapes/fills.rs | 8 +-- render-wasm/src/shapes/paths.rs | 53 +++++------------ render-wasm/src/shapes/strokes.rs | 7 ++- render-wasm/src/shapes/text_paths.rs | 25 ++++---- render-wasm/test | 2 +- render-wasm/watch_test | 2 +- 17 files changed, 169 insertions(+), 175 deletions(-) diff --git a/render-wasm/Cargo.lock b/render-wasm/Cargo.lock index 9a9d050849..e5c289d99e 100644 --- a/render-wasm/Cargo.lock +++ b/render-wasm/Cargo.lock @@ -202,9 +202,9 @@ checksum = "d2fabcfbdc87f4758337ca535fb41a6d701b65693ce38287d856d1674551ec9b" [[package]] name = "hashbrown" -version = "0.15.0" +version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e087f84d4f86bf4b218b927129862374b72199ae7d8657835f1e89000eea4fb" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" [[package]] name = "heck" @@ -214,9 +214,9 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" [[package]] name = "indexmap" -version = "2.7.1" +version = "2.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c9c992b02b5b4c94ea26e32fe5bccb7aa7d9f390ab5c1221ff895bc7ea8b652" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" dependencies = [ "equivalent", "hashbrown", @@ -253,12 +253,6 @@ version = "3.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" -[[package]] -name = "lazy_static" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" - [[package]] name = "libc" version = "0.2.161" @@ -468,18 +462,27 @@ checksum = "f3cb5ba0dc43242ce17de99c180e96db90b235b8a9fdc9543c96d2209116bd9f" [[package]] name = "serde" -version = "1.0.210" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8e3592472072e6e22e0a54d5904d9febf8508f65fb8552499a1abc7d1078c3a" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.210" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "243902eda00fad750862fc144cea25caca5e20d615af0a81bee94ca738f1df1f" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", @@ -500,11 +503,11 @@ dependencies = [ [[package]] name = "serde_spanned" -version = "0.6.8" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87607cb1398ed59d48732e575a4c28a7a8ebf2454b964fe3f224f2afc07909e1" +checksum = "f8bbf91e5a4d6315eee45e704372590b30e260ee83af6639d64557f51b067776" dependencies = [ - "serde", + "serde_core", ] [[package]] @@ -515,9 +518,9 @@ checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" [[package]] name = "skia-bindings" -version = "0.87.0" +version = "0.93.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "704242769235d2ffe66a2a0a3002661262fc4af08d32807c362d7b0160ee703c" +checksum = "2359f7e30c9da3f322f8ca3d4ec0abbc12a40035ce758309db0cdab07b5d4476" dependencies = [ "bindgen", "cc", @@ -532,13 +535,12 @@ dependencies = [ [[package]] name = "skia-safe" -version = "0.87.0" +version = "0.93.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f7d94f3e7537c71ad4cf132eb26e3be8c8a886ed3649c4525c089041fc312b2" +checksum = "7f9e837ea9d531c9efee8f980bfcdb7226b21db0285b0c3171d8be745829f940" dependencies = [ "base64", "bitflags", - "lazy_static", "percent-encoding", "skia-bindings", "skia-svg-macros", @@ -579,38 +581,43 @@ dependencies = [ [[package]] name = "toml" -version = "0.8.19" +version = "1.0.3+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1ed1f98e3fdc28d6d910e6737ae6ab1a93bf1985935a1193e68f93eeb68d24e" +checksum = "c7614eaf19ad818347db24addfa201729cf2a9b6fdfd9eb0ab870fcacc606c0c" dependencies = [ - "serde", + "indexmap", + "serde_core", "serde_spanned", "toml_datetime", - "toml_edit", + "toml_parser", + "toml_writer", + "winnow", ] [[package]] name = "toml_datetime" -version = "0.6.8" +version = "1.0.0+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0dd7358ecb8fc2f8d014bf86f6f638ce72ba252a2c3a2572f2a795f1d23efb41" +checksum = "32c2555c699578a4f59f0cc68e5116c8d7cabbd45e1409b989d4be085b53f13e" dependencies = [ - "serde", + "serde_core", ] [[package]] -name = "toml_edit" -version = "0.22.22" +name = "toml_parser" +version = "1.0.9+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ae48d6208a266e853d946088ed816055e556cc6028c5e8e2b84d9fa5dd7c7f5" +checksum = "702d4415e08923e7e1ef96cd5727c0dfed80b4d2fa25db9647fe5eb6f7c5a4c4" dependencies = [ - "indexmap", - "serde", - "serde_spanned", - "toml_datetime", "winnow", ] +[[package]] +name = "toml_writer" +version = "1.0.6+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab16f14aed21ee8bfd8ec22513f7287cd4a91aa92e44edfe2c17ddd004e92607" + [[package]] name = "unicode-ident" version = "1.0.13" @@ -775,12 +782,9 @@ checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" [[package]] name = "winnow" -version = "0.6.20" +version = "0.7.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "36c1fec1a2bb5866f07c25f68c26e565c4c200aebb96d7e55710c19d3e8ac49b" -dependencies = [ - "memchr", -] +checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829" [[package]] name = "xattr" diff --git a/render-wasm/Cargo.toml b/render-wasm/Cargo.toml index 82cde41199..ca37fe4104 100644 --- a/render-wasm/Cargo.toml +++ b/render-wasm/Cargo.toml @@ -25,7 +25,7 @@ gl = "0.14.0" glam = "0.24.2" indexmap = "2.7.1" macros = { path = "macros" } -skia-safe = { version = "0.87.0", default-features = false, features = [ +skia-safe = { version = "0.93.1", default-features = false, features = [ "gl", "svg", "textlayout", diff --git a/render-wasm/_build_env b/render-wasm/_build_env index 2bfe0e778f..94716a05e4 100644 --- a/render-wasm/_build_env +++ b/render-wasm/_build_env @@ -10,7 +10,7 @@ fi export BUILD_NAME="${BUILD_NAME:-render-wasm}" export CARGO_BUILD_TARGET=${CARGO_BUILD_TARGET:-"wasm32-unknown-emscripten"}; -export SKIA_BINARIES_URL=${SKIA_BINARIES_URL:-"https://github.com/penpot/skia-binaries/releases/download/0.87.0/skia-binaries-e551f334ad5cbdf43abf-wasm32-unknown-emscripten-gl-svg-textlayout-binary-cache-webp.tar.gz"} +export SKIA_BINARIES_URL=${SKIA_BINARIES_URL:-"https://github.com/penpot/skia-binaries/releases/download/0.93.1/skia-binaries-319323662b1685a112f5-wasm32-unknown-emscripten-gl-svg-textlayout-binary-cache-webp.tar.gz"} # 256 MB of initial heap to perform less # initial calls to memory grow. diff --git a/render-wasm/lint b/render-wasm/lint index aaca98bc27..e94145189a 100755 --- a/render-wasm/lint +++ b/render-wasm/lint @@ -11,7 +11,7 @@ fi . ./_build_env export CARGO_BUILD_TARGET=${CARGO_BUILD_TARGET:-"wasm32-unknown-emscripten"}; -export SKIA_BINARIES_URL=${SKIA_BINARIES_URL:-"https://github.com/penpot/skia-binaries/releases/download/0.87.0/skia-binaries-e551f334ad5cbdf43abf-wasm32-unknown-emscripten-gl-svg-textlayout-binary-cache-webp.tar.gz"} +export SKIA_BINARIES_URL=${SKIA_BINARIES_URL:-"https://github.com/penpot/skia-binaries/releases/download/0.93.1/skia-binaries-319323662b1685a112f5-wasm32-unknown-emscripten-gl-svg-textlayout-binary-cache-webp.tar.gz"} ALLOWED_RULES="-D static_mut_refs" diff --git a/render-wasm/src/math.rs b/render-wasm/src/math.rs index 9392cb00e8..de819a86bb 100644 --- a/render-wasm/src/math.rs +++ b/render-wasm/src/math.rs @@ -356,7 +356,7 @@ impl Bounds { } pub fn from_rect(r: &Rect) -> Self { - let [nw, ne, se, sw] = r.to_quad(); + let [nw, ne, se, sw] = r.to_quad(None); Self::new(nw, ne, se, sw) } diff --git a/render-wasm/src/math/bools.rs b/render-wasm/src/math/bools.rs index 123e1b262a..67de1e874e 100644 --- a/render-wasm/src/math/bools.rs +++ b/render-wasm/src/math/bools.rs @@ -477,30 +477,32 @@ pub fn debug_render_bool_paths( paint.set_alpha_f(1.0); paint.set_style(skia::PaintStyle::Stroke); - let mut path = skia::Path::default(); - path.move_to((b.1.start.x as f32, b.1.start.y as f32)); - - match b.1.handles { - BezierHandles::Linear => { - path.line_to((b.1.end.x as f32, b.1.end.y as f32)); + let path = { + let mut pb = skia::PathBuilder::new(); + pb.move_to((b.1.start.x as f32, b.1.start.y as f32)); + match b.1.handles { + BezierHandles::Linear => { + pb.line_to((b.1.end.x as f32, b.1.end.y as f32)); + } + BezierHandles::Quadratic { handle } => { + pb.quad_to( + (handle.x as f32, handle.y as f32), + (b.1.end.x as f32, b.1.end.y as f32), + ); + } + BezierHandles::Cubic { + handle_start, + handle_end, + } => { + pb.cubic_to( + (handle_start.x as f32, handle_start.y as f32), + (handle_end.x as f32, handle_end.y as f32), + (b.1.end.x as f32, b.1.end.y as f32), + ); + } } - BezierHandles::Quadratic { handle } => { - path.quad_to( - (handle.x as f32, handle.y as f32), - (b.1.end.x as f32, b.1.end.y as f32), - ); - } - BezierHandles::Cubic { - handle_start, - handle_end, - } => { - path.cubic_to( - (handle_start.x as f32, handle_start.y as f32), - (handle_end.x as f32, handle_end.y as f32), - (b.1.end.x as f32, b.1.end.y as f32), - ); - } - } + pb.detach() + }; canvas.draw_path(&path, &paint); let mut v1 = b.1.normal(TValue::Parametric(1.0)); diff --git a/render-wasm/src/render/fills.rs b/render-wasm/src/render/fills.rs index f6f8a2e4ea..399f6bbf19 100644 --- a/render-wasm/src/render/fills.rs +++ b/render-wasm/src/render/fills.rs @@ -51,15 +51,18 @@ fn draw_image_fill( canvas.clip_rect(container, skia::ClipOp::Intersect, antialias); } Type::Circle => { - let mut oval_path = skia::Path::new(); - oval_path.add_oval(container, None); + let oval_path = { + let mut pb = skia::PathBuilder::new(); + pb.add_oval(container, None, None); + pb.detach() + }; canvas.clip_path(&oval_path, skia::ClipOp::Intersect, antialias); } shape_type @ (Type::Path(_) | Type::Bool(_)) => { if let Some(path) = shape_type.path() { if let Some(path_transform) = path_transform { canvas.clip_path( - path.to_skia_path().transform(&path_transform), + &path.to_skia_path().make_transform(&path_transform), skia::ClipOp::Intersect, antialias, ); diff --git a/render-wasm/src/render/grid_layout.rs b/render-wasm/src/render/grid_layout.rs index 699aea8cde..9f5067469e 100644 --- a/render-wasm/src/render/grid_layout.rs +++ b/render-wasm/src/render/grid_layout.rs @@ -24,7 +24,11 @@ pub fn render_overlay(zoom: f32, canvas: &skia::Canvas, shape: &Shape, shapes: S cell.anchor + hv + vv, cell.anchor + vv, ]; - let polygon = skia::Path::polygon(&points, true, None, None); + let polygon = { + let mut pb = skia::PathBuilder::new(); + pb.add_polygon(&points, true); + pb.detach() + }; canvas.draw_path(&polygon, &paint); } } diff --git a/render-wasm/src/render/strokes.rs b/render-wasm/src/render/strokes.rs index d48a41bfa9..38228f32d4 100644 --- a/render-wasm/src/render/strokes.rs +++ b/render-wasm/src/render/strokes.rs @@ -83,8 +83,11 @@ fn draw_stroke_on_circle( if let Some(clip_op) = stroke.clip_op() { let layer_rec = skia::canvas::SaveLayerRec::default().paint(&paint); canvas.save_layer(&layer_rec); - let mut clip_path = skia::Path::new(); - clip_path.add_oval(rect, None); + let clip_path = { + let mut pb = skia::PathBuilder::new(); + pb.add_oval(rect, None, None); + pb.detach() + }; canvas.clip_path(&clip_path, clip_op, antialias); canvas.draw_oval(stroke_rect, &paint); canvas.restore(); @@ -153,8 +156,9 @@ fn draw_stroke_on_path( blur: Option<&ImageFilter>, antialias: bool, ) { - let mut skia_path = path.to_skia_path(); - skia_path.transform(path_transform.unwrap_or(&Matrix::default())); + let skia_path = path + .to_skia_path() + .make_transform(path_transform.unwrap_or(&Matrix::default())); let is_open = path.is_open(); @@ -174,15 +178,7 @@ fn draw_stroke_on_path( } } - handle_stroke_caps( - &mut skia_path, - stroke, - canvas, - is_open, - paint, - blur, - antialias, - ); + handle_stroke_caps(&skia_path, stroke, canvas, is_open, paint, blur, antialias); } fn handle_stroke_cap( @@ -224,7 +220,7 @@ fn handle_stroke_cap( #[allow(clippy::too_many_arguments)] fn handle_stroke_caps( - path: &mut skia::Path, + path: &skia::Path, stroke: &Stroke, canvas: &skia::Canvas, is_open: bool, @@ -232,8 +228,7 @@ fn handle_stroke_caps( blur: Option<&ImageFilter>, _antialias: bool, ) { - let mut points = vec![Point::default(); path.count_points()]; - path.get_points(&mut points); + let mut points = path.points().to_vec(); // Curves can have duplicated points, so let's remove consecutive duplicated points points.dedup(); let c_points = points.len(); @@ -304,13 +299,16 @@ fn draw_square_cap( let mut transformed_points = points; matrix.map_points(&mut transformed_points, &points); - let mut path = skia::Path::new(); - path.move_to(Point::new(center.x, center.y)); - path.move_to(transformed_points[0]); - path.line_to(transformed_points[1]); - path.line_to(transformed_points[2]); - path.line_to(transformed_points[3]); - path.close(); + let path = { + let mut pb = skia::PathBuilder::new(); + pb.move_to(Point::new(center.x, center.y)); + pb.move_to(transformed_points[0]); + pb.line_to(transformed_points[1]); + pb.line_to(transformed_points[2]); + pb.line_to(transformed_points[3]); + pb.close(); + pb.detach() + }; canvas.draw_path(&path, paint); } @@ -338,13 +336,15 @@ fn draw_arrow_cap( let mut transformed_points = points; matrix.map_points(&mut transformed_points, &points); - let mut path = skia::Path::new(); - path.move_to(transformed_points[1]); - path.line_to(transformed_points[0]); - path.line_to(transformed_points[2]); - path.move_to(Point::new(center.x, center.y)); - path.line_to(transformed_points[0]); - + let path = { + let mut pb = skia::PathBuilder::new(); + pb.move_to(transformed_points[1]); + pb.line_to(transformed_points[0]); + pb.line_to(transformed_points[2]); + pb.move_to(Point::new(center.x, center.y)); + pb.line_to(transformed_points[0]); + pb.detach() + }; canvas.draw_path(&path, paint); } @@ -372,12 +372,14 @@ fn draw_triangle_cap( let mut transformed_points = points; matrix.map_points(&mut transformed_points, &points); - let mut path = skia::Path::new(); - path.move_to(transformed_points[0]); - path.line_to(transformed_points[1]); - path.line_to(transformed_points[2]); - path.close(); - + let path = { + let mut pb = skia::PathBuilder::new(); + pb.move_to(transformed_points[0]); + pb.line_to(transformed_points[1]); + pb.line_to(transformed_points[2]); + pb.close(); + pb.detach() + }; canvas.draw_path(&path, paint); } @@ -441,8 +443,7 @@ fn draw_image_stroke_in_container( shape_type @ (Type::Path(_) | Type::Bool(_)) => { if let Some(p) = shape_type.path() { canvas.save(); - let mut path = p.to_skia_path(); - path.transform(&path_transform.unwrap()); + let path = p.to_skia_path().make_transform(&path_transform.unwrap()); let stroke_kind = stroke.render_kind(p.is_open()); match stroke_kind { StrokeKind::Inner => { @@ -464,7 +465,7 @@ fn draw_image_stroke_in_container( canvas.draw_path(&path, &thin_paint); } handle_stroke_caps( - &mut path, + &path, stroke, canvas, is_open, @@ -504,8 +505,7 @@ fn draw_image_stroke_in_container( // Clear outer stroke for paths if necessary. When adding an outer stroke we need to empty the stroke added too in the inner area. if let Type::Path(p) = &shape.shape_type { if stroke.render_kind(p.is_open()) == StrokeKind::Outer { - let mut path = p.to_skia_path(); - path.transform(&path_transform.unwrap()); + let path = p.to_skia_path().make_transform(&path_transform.unwrap()); let mut clear_paint = skia::Paint::default(); clear_paint.set_blend_mode(skia::BlendMode::Clear); clear_paint.set_anti_alias(antialias); diff --git a/render-wasm/src/render/surfaces.rs b/render-wasm/src/render/surfaces.rs index 56d26a48c7..8c6780cd52 100644 --- a/render-wasm/src/render/surfaces.rs +++ b/render-wasm/src/render/surfaces.rs @@ -456,11 +456,7 @@ impl Surfaces { self.current.height() - TILE_SIZE_MULTIPLIER * self.margins.height, ); - let snapshot = self.current.image_snapshot(); - let mut direct_context = self.current.direct_context(); - let tile_image_opt = snapshot - .make_subset(direct_context.as_mut(), rect) - .or_else(|| self.current.image_snapshot_with_bounds(rect)); + let tile_image_opt = self.current.image_snapshot_with_bounds(rect); if let Some(tile_image) = tile_image_opt { // Draw to cache first (takes reference), then move to tile cache diff --git a/render-wasm/src/shapes.rs b/render-wasm/src/shapes.rs index 8967b6ee49..c43962ad59 100644 --- a/render-wasm/src/shapes.rs +++ b/render-wasm/src/shapes.rs @@ -1336,7 +1336,7 @@ impl Shape { if let Some(path) = self.shape_type.path() { let mut skia_path = path.to_skia_path(); if let Some(path_transform) = self.to_path_transform() { - skia_path.transform(&path_transform); + skia_path = skia_path.make_transform(&path_transform); } if let Some(svg_attrs) = &self.svg_attrs { if svg_attrs.fill_rule == FillRule::Evenodd { diff --git a/render-wasm/src/shapes/fills.rs b/render-wasm/src/shapes/fills.rs index 443669c121..cf8a930894 100644 --- a/render-wasm/src/shapes/fills.rs +++ b/render-wasm/src/shapes/fills.rs @@ -51,10 +51,10 @@ impl Gradient { rect.left + self.end.0 * rect.width(), rect.top + self.end.1 * rect.height(), ); - skia::shader::Shader::linear_gradient( + skia::gradient_shader::linear( (start, end), self.colors.as_slice(), - self.offsets.as_slice(), + Some(self.offsets.as_slice()), skia::TileMode::Clamp, None, None, @@ -83,11 +83,11 @@ impl Gradient { transform.pre_scale((self.width * rect.width() / rect.height(), 1.), None); transform.pre_translate((-center.x, -center.y)); - skia::shader::Shader::radial_gradient( + skia::gradient_shader::radial( center, distance, self.colors.as_slice(), - self.offsets.as_slice(), + Some(self.offsets.as_slice()), skia::TileMode::Clamp, None, Some(&transform), diff --git a/render-wasm/src/shapes/paths.rs b/render-wasm/src/shapes/paths.rs index dfdc06ae01..d6d6a8d1ef 100644 --- a/render-wasm/src/shapes/paths.rs +++ b/render-wasm/src/shapes/paths.rs @@ -29,40 +29,28 @@ impl Default for Path { } } -fn to_verb(v: u8) -> skia::path::Verb { - match v { - 0 => skia::path::Verb::Move, - 1 => skia::path::Verb::Line, - 2 => skia::path::Verb::Quad, - 3 => skia::path::Verb::Conic, - 4 => skia::path::Verb::Cubic, - 5 => skia::path::Verb::Close, - _ => skia::path::Verb::Done, - } -} - impl Path { pub fn new(segments: Vec) -> Self { - let mut skia_path = skia::Path::new(); + let mut pb = skia::PathBuilder::new(); let mut start = None; for segment in segments.iter() { let destination = match *segment { Segment::MoveTo(xy) => { start = Some(xy); - skia_path.move_to(xy); + pb.move_to(xy); None } Segment::LineTo(xy) => { - skia_path.line_to(xy); + pb.line_to(xy); Some(xy) } Segment::CurveTo((c1, c2, xy)) => { - skia_path.cubic_to(c1, c2, xy); + pb.cubic_to(c1, c2, xy); Some(xy) } Segment::Close => { - skia_path.close(); + pb.close(); None } }; @@ -71,11 +59,12 @@ impl Path { if math::is_close_to(destination.0, start.0) && math::is_close_to(destination.1, start.1) { - skia_path.close(); + pb.close(); } } } + let skia_path = pb.detach(); let open = subpaths::is_open_path(&segments); Self { @@ -86,38 +75,31 @@ impl Path { } pub fn from_skia_path(path: skia::Path) -> Self { - let nv = path.count_verbs(); - let mut verbs = vec![0; nv]; - path.get_verbs(&mut verbs); - - let np = path.count_points(); - let mut points = Vec::with_capacity(np); - points.resize(np, skia::Point::default()); - path.get_points(&mut points); + let verbs = path.verbs(); + let points = path.points(); let mut segments = Vec::new(); let mut current_point = 0; for verb in verbs { - let verb = to_verb(verb); match verb { - skia::path::Verb::Move => { + skia::PathVerb::Move => { let p = points[current_point]; segments.push(Segment::MoveTo((p.x, p.y))); current_point += 1; } - skia::path::Verb::Line => { + skia::PathVerb::Line => { let p = points[current_point]; segments.push(Segment::LineTo((p.x, p.y))); current_point += 1; } - skia::path::Verb::Quad => { + skia::PathVerb::Quad => { let p1 = points[current_point]; let p2 = points[current_point + 1]; segments.push(Segment::CurveTo(((p1.x, p1.y), (p1.x, p1.y), (p2.x, p2.y)))); current_point += 2; } - skia::path::Verb::Conic => { + skia::PathVerb::Conic => { // TODO: There is no way currently to access the conic weight // to transform this correctly let p1 = points[current_point]; @@ -125,17 +107,14 @@ impl Path { segments.push(Segment::CurveTo(((p1.x, p1.y), (p1.x, p1.y), (p2.x, p2.y)))); current_point += 2; } - skia::path::Verb::Cubic => { + skia::PathVerb::Cubic => { let p1 = points[current_point]; let p2 = points[current_point + 1]; let p3 = points[current_point + 2]; segments.push(Segment::CurveTo(((p1.x, p1.y), (p2.x, p2.y), (p3.x, p3.y)))); current_point += 3; } - skia::path::Verb::Close => { - segments.push(Segment::Close); - } - skia::path::Verb::Done => { + skia::PathVerb::Close => { segments.push(Segment::Close); } } @@ -184,7 +163,7 @@ impl Path { _ => {} }); - self.skia_path.transform(mtx); + self.skia_path = self.skia_path.make_transform(mtx); } pub fn segments(&self) -> &Vec { diff --git a/render-wasm/src/shapes/strokes.rs b/render-wasm/src/shapes/strokes.rs index 599cd83f3d..426d5939c3 100644 --- a/render-wasm/src/shapes/strokes.rs +++ b/render-wasm/src/shapes/strokes.rs @@ -225,13 +225,16 @@ impl Stroke { if self.style != StrokeStyle::Solid { let path_effect = match self.style { StrokeStyle::Dotted => { - let mut circle_path = skia::Path::new(); let width = match self.kind { StrokeKind::Inner => self.width, StrokeKind::Center => self.width / 2.0, StrokeKind::Outer => self.width, }; - circle_path.add_circle((0.0, 0.0), width, None); + let circle_path = { + let mut pb = skia::PathBuilder::new(); + pb.add_circle((0.0, 0.0), width, None); + pb.detach() + }; let advance = self.width + 5.0; skia::PathEffect::path_1d( &circle_path, diff --git a/render-wasm/src/shapes/text_paths.rs b/render-wasm/src/shapes/text_paths.rs index e5b155dbd2..238207152b 100644 --- a/render-wasm/src/shapes/text_paths.rs +++ b/render-wasm/src/shapes/text_paths.rs @@ -101,7 +101,6 @@ impl TextPaths { if let Some((text_blob_path, text_blob_bounds)) = Self::get_text_blob_path(span_text, font, blob_offset_x, blob_offset_y) { - let mut text_path = text_blob_path.clone(); let text_width = font.measure_text(span_text, None).0; let decoration = style_metric.text_style.decoration(); @@ -111,16 +110,20 @@ impl TextPaths { let blob_top = blob_offset_y; let blob_height = text_blob_bounds.height(); - if let Some(decoration_rect) = self.calculate_text_decoration_rect( - decoration.ty, - font_metrics, - blob_left, - blob_top, - text_width, - blob_height, - ) { - text_path.add_rect(decoration_rect, None); - } + let text_path = { + let mut pb = skia::PathBuilder::new_path(&text_blob_path); + if let Some(decoration_rect) = self.calculate_text_decoration_rect( + decoration.ty, + font_metrics, + blob_left, + blob_top, + text_width, + blob_height, + ) { + pb.add_rect(decoration_rect, None, None); + } + pb.detach() + }; let mut paint = style_metric.text_style.foreground(); paint.set_anti_alias(antialias); diff --git a/render-wasm/test b/render-wasm/test index 85d5547d4a..f416e6c6bb 100755 --- a/render-wasm/test +++ b/render-wasm/test @@ -1,7 +1,7 @@ #!/usr/bin/env bash set -x -export SKIA_BINARIES_URL=${SKIA_BINARIES_URL:-"https://github.com/penpot/skia-binaries/releases/download/0.87.0/skia-binaries-e551f334ad5cbdf43abf-x86_64-unknown-linux-gnu-gl-svg-textlayout-binary-cache-webp.tar.gz"} +export SKIA_BINARIES_URL=${SKIA_BINARIES_URL:-"https://github.com/penpot/skia-binaries/releases/download/0.93.1/skia-binaries-319323662b1685a112f5-x86_64-unknown-linux-gnu-gl-svg-textlayout-binary-cache-webp.tar.gz"} export CARGO_BUILD_TARGET=${CARGO_BUILD_TARGET:-"x86_64-unknown-linux-gnu"}; _SCRIPT_DIR=$(dirname $0); diff --git a/render-wasm/watch_test b/render-wasm/watch_test index 5f1346c333..798eb84bf0 100755 --- a/render-wasm/watch_test +++ b/render-wasm/watch_test @@ -1,7 +1,7 @@ #!/usr/bin/env bash _SCRIPT_DIR=$(dirname $0); -export SKIA_BINARIES_URL="https://github.com/penpot/skia-binaries/releases/download/0.87.0/skia-binaries-e551f334ad5cbdf43abf-x86_64-unknown-linux-gnu-gl-svg-textlayout-binary-cache-webp.tar.gz" +export SKIA_BINARIES_URL="https://github.com/penpot/skia-binaries/releases/download/0.93.1/skia-binaries-319323662b1685a112f5-x86_64-unknown-linux-gnu-gl-svg-textlayout-binary-cache-webp.tar.gz" pushd $_SCRIPT_DIR; cargo watch -x "test --bin render_wasm -- --show-output" From 4975f28a3dbc056cf3157c08977a6e812b150a3b Mon Sep 17 00:00:00 2001 From: Alejandro Alonso Date: Tue, 24 Feb 2026 15:53:56 +0100 Subject: [PATCH 3/4] :bug: Fix auto width affects text selection --- frontend/src/app/main/ui/workspace/shapes/text/v2_editor.scss | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/app/main/ui/workspace/shapes/text/v2_editor.scss b/frontend/src/app/main/ui/workspace/shapes/text/v2_editor.scss index b34628c932..642a4cabf3 100644 --- a/frontend/src/app/main/ui/workspace/shapes/text/v2_editor.scss +++ b/frontend/src/app/main/ui/workspace/shapes/text/v2_editor.scss @@ -73,12 +73,12 @@ } .grow-type-auto-width { - [data-itype="inline"], + [data-itype="span"], [data-itype="paragraph"] { white-space: nowrap; } - [data-itype="inline"] { + [data-itype="span"] { white-space-collapse: preserve; } } From e2b5f936f592d5ac626b45b224f54014c8dca82d Mon Sep 17 00:00:00 2001 From: Alejandro Alonso Date: Mon, 23 Feb 2026 07:23:12 +0100 Subject: [PATCH 4/4] :bug: Fix stroke artifacts --- .../get-file-inner-strokes-artifacts.json | 814 ++++++++++++++++++ .../ui/render-wasm-specs/shapes.spec.js | 24 + .../Check-inner-stroke-artifacts-1.png | Bin 0 -> 31969 bytes render-wasm/src/render.rs | 23 +- render-wasm/src/render/fills.rs | 62 +- render-wasm/src/render/shadows.rs | 8 +- render-wasm/src/render/strokes.rs | 46 +- render-wasm/src/render/surfaces.rs | 40 +- render-wasm/src/render/text.rs | 21 +- 9 files changed, 981 insertions(+), 57 deletions(-) create mode 100644 frontend/playwright/data/render-wasm/get-file-inner-strokes-artifacts.json create mode 100644 frontend/playwright/ui/render-wasm-specs/shapes.spec.js-snapshots/Check-inner-stroke-artifacts-1.png diff --git a/frontend/playwright/data/render-wasm/get-file-inner-strokes-artifacts.json b/frontend/playwright/data/render-wasm/get-file-inner-strokes-artifacts.json new file mode 100644 index 0000000000..5987b72225 --- /dev/null +++ b/frontend/playwright/data/render-wasm/get-file-inner-strokes-artifacts.json @@ -0,0 +1,814 @@ +{ + "~:features": { + "~#set": [ + "fdata/path-data", + "plugins/runtime", + "design-tokens/v1", + "variants/v1", + "layout/grid", + "styles/v2", + "fdata/pointer-map", + "fdata/objects-map", + "render-wasm/v1", + "components/v2", + "fdata/shape-data-type" + ] + }, + "~:team-id": "~ueba8fa2e-4140-8084-8005-448635d7a724", + "~:permissions": { + "~:type": "~:membership", + "~:is-owner": true, + "~:is-admin": true, + "~:can-edit": true, + "~:can-read": true, + "~:is-logged": true + }, + "~:has-media-trimmed": false, + "~:comment-thread-seqn": 0, + "~:name": "gaps", + "~:revn": 79, + "~:modified-at": "~m1771855365377", + "~:vern": 0, + "~:id": "~ueffcbebc-b8c8-802f-8007-9a0b2e2c863f", + "~:is-shared": false, + "~:migrations": { + "~#ordered-set": [ + "legacy-2", + "legacy-3", + "legacy-5", + "legacy-6", + "legacy-7", + "legacy-8", + "legacy-9", + "legacy-10", + "legacy-11", + "legacy-12", + "legacy-13", + "legacy-14", + "legacy-16", + "legacy-17", + "legacy-18", + "legacy-19", + "legacy-25", + "legacy-26", + "legacy-27", + "legacy-28", + "legacy-29", + "legacy-31", + "legacy-32", + "legacy-33", + "legacy-34", + "legacy-36", + "legacy-37", + "legacy-38", + "legacy-39", + "legacy-40", + "legacy-41", + "legacy-42", + "legacy-43", + "legacy-44", + "legacy-45", + "legacy-46", + "legacy-47", + "legacy-48", + "legacy-49", + "legacy-50", + "legacy-51", + "legacy-52", + "legacy-53", + "legacy-54", + "legacy-55", + "legacy-56", + "legacy-57", + "legacy-59", + "legacy-62", + "legacy-65", + "legacy-66", + "legacy-67", + "0001-remove-tokens-from-groups", + "0002-normalize-bool-content-v2", + "0002-clean-shape-interactions", + "0003-fix-root-shape", + "0003-convert-path-content-v2", + "0005-deprecate-image-type", + "0006-fix-old-texts-fills", + "0008-fix-library-colors-v4", + "0009-clean-library-colors", + "0009-add-partial-text-touched-flags", + "0010-fix-swap-slots-pointing-non-existent-shapes", + "0011-fix-invalid-text-touched-flags", + "0012-fix-position-data", + "0013-fix-component-path", + "0013-clear-invalid-strokes-and-fills", + "0014-fix-tokens-lib-duplicate-ids", + "0014-clear-components-nil-objects", + "0015-fix-text-attrs-blank-strings", + "0015-clean-shadow-color", + "0016-copy-fills-from-position-data-to-text-node" + ] + }, + "~:version": 67, + "~:project-id": "~ueba8fa2e-4140-8084-8005-448635da32b4", + "~:created-at": "~m1771591980210", + "~:backend": "legacy-db", + "~:data": { + "~:pages": [ + "~ueffcbebc-b8c8-802f-8007-9a0b2e2c8640" + ], + "~:pages-index": { + "~ueffcbebc-b8c8-802f-8007-9a0b2e2c8640": { + "~:objects": { + "~u00000000-0000-0000-0000-000000000000": { + "~#shape": { + "~:y": 0, + "~:hide-fill-on-export": false, + "~:transform": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:rotation": 0, + "~:name": "Root Frame", + "~:width": 0.01, + "~:type": "~:frame", + "~:points": [ + { + "~#point": { + "~:x": 0, + "~:y": 0 + } + }, + { + "~#point": { + "~:x": 0.01, + "~:y": 0 + } + }, + { + "~#point": { + "~:x": 0.01, + "~:y": 0.01 + } + }, + { + "~#point": { + "~:x": 0, + "~:y": 0.01 + } + } + ], + "~:r2": 0, + "~:proportion-lock": false, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:r3": 0, + "~:r1": 0, + "~:id": "~u00000000-0000-0000-0000-000000000000", + "~:parent-id": "~u00000000-0000-0000-0000-000000000000", + "~:frame-id": "~u00000000-0000-0000-0000-000000000000", + "~:strokes": [], + "~:x": 0, + "~:proportion": 1, + "~:r4": 0, + "~:selrect": { + "~#rect": { + "~:x": 0, + "~:y": 0, + "~:width": 0.01, + "~:height": 0.01, + "~:x1": 0, + "~:y1": 0, + "~:x2": 0.01, + "~:y2": 0.01 + } + }, + "~:fills": [ + { + "~:fill-color": "#FFFFFF", + "~:fill-opacity": 1 + } + ], + "~:flip-x": null, + "~:height": 0.01, + "~:flip-y": null, + "~:shapes": [ + "~u36e8a3ad-2b63-8008-8007-9a0b2f24ca4e", + "~ufbc43ead-a2ce-8058-8007-9a0daf843e09", + "~ufbc43ead-a2ce-8058-8007-9a0dbe2f49b8", + "~u5bebb998-d617-801b-8007-9a3fbd5cc804", + "~u80e2fa5a-cd1c-8043-8007-9d8aaca49f40" + ] + } + }, + "~ufbc43ead-a2ce-8058-8007-9a0dbe2f49b8": { + "~#shape": { + "~:y": null, + "~:transform": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:rotation": 0, + "~:grow-type": "~:fixed", + "~:content": { + "~#penpot/path-data": "~bAQAAAAAAAAAAAAAAAAAAAAAAAAD/f5dEM2EsRAIAAAAAAAAAAAAAAAAAAAAAAAAAUhmnRABACkQCAAAAAAAAAAAAAAAAAAAAAAAAAP8/vET//01EAgAAAAAAAAAAAAAAAAAAAAAAAAD/f5dEM2EsRA==" + }, + "~:name": "Path", + "~:width": null, + "~:type": "~:path", + "~:points": [ + { + "~#point": { + "~:x": 1212.00003372852, + "~:y": 553.000012923003 + } + }, + { + "~#point": { + "~:x": 1506.00004755679, + "~:y": 553.000012923003 + } + }, + { + "~#point": { + "~:x": 1506.00004755679, + "~:y": 823.999993849517 + } + }, + { + "~#point": { + "~:x": 1212.00003372852, + "~:y": 823.999993849517 + } + } + ], + "~:r2": 0, + "~:proportion-lock": false, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:r3": 0, + "~:r1": 0, + "~:id": "~ufbc43ead-a2ce-8058-8007-9a0dbe2f49b8", + "~:parent-id": "~u00000000-0000-0000-0000-000000000000", + "~:frame-id": "~u00000000-0000-0000-0000-000000000000", + "~:strokes": [ + { + "~:stroke-alignment": "~:inner", + "~:stroke-style": "~:solid", + "~:stroke-color": "#000000", + "~:stroke-opacity": 1, + "~:stroke-width": 10 + } + ], + "~:x": null, + "~:proportion": 1, + "~:shadow": [], + "~:r4": 0, + "~:selrect": { + "~#rect": { + "~:x": 1212.00003372852, + "~:y": 553.000012923003, + "~:width": 294.000013828278, + "~:height": 270.999980926514, + "~:x1": 1212.00003372852, + "~:y1": 553.000012923003, + "~:x2": 1506.00004755679, + "~:y2": 823.999993849517 + } + }, + "~:fills": [ + { + "~:fill-color": "#ffffff", + "~:fill-opacity": 1 + } + ], + "~:flip-x": null, + "~:height": null, + "~:flip-y": null + } + }, + "~u36e8a3ad-2b63-8008-8007-9a0b2f24ca4e": { + "~#shape": { + "~:y": 122.000001761754, + "~:transform": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:rotation": 0, + "~:hide-in-viewer": false, + "~:name": "Rectangle", + "~:width": 463.999987447937, + "~:type": "~:rect", + "~:points": [ + { + "~#point": { + "~:x": 694.000014750112, + "~:y": 122.000001761754 + } + }, + { + "~#point": { + "~:x": 1158.00000219805, + "~:y": 122.000001761754 + } + }, + { + "~#point": { + "~:x": 1158.00000219805, + "~:y": 499.999980116278 + } + }, + { + "~#point": { + "~:x": 694.000014750112, + "~:y": 499.999980116278 + } + } + ], + "~:r2": 0, + "~:proportion-lock": false, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:r3": 0, + "~:r1": 0, + "~:id": "~u36e8a3ad-2b63-8008-8007-9a0b2f24ca4e", + "~:parent-id": "~u00000000-0000-0000-0000-000000000000", + "~:frame-id": "~u00000000-0000-0000-0000-000000000000", + "~:strokes": [ + { + "~:stroke-alignment": "~:inner", + "~:stroke-style": "~:solid", + "~:stroke-color": "#000000", + "~:stroke-opacity": 1, + "~:stroke-width": 100 + }, + { + "~:stroke-alignment": "~:outer", + "~:stroke-style": "~:solid", + "~:stroke-color": "#000000", + "~:stroke-opacity": 1, + "~:stroke-width": 100 + } + ], + "~:x": 694.000014750113, + "~:proportion": 1, + "~:shadow": [], + "~:r4": 0, + "~:selrect": { + "~#rect": { + "~:x": 694.000014750113, + "~:y": 122.000001761754, + "~:width": 463.999987447937, + "~:height": 377.999978354524, + "~:x1": 694.000014750113, + "~:y1": 122.000001761754, + "~:x2": 1158.00000219805, + "~:y2": 499.999980116278 + } + }, + "~:fills": [ + { + "~:fill-color": "#ffffff", + "~:fill-opacity": 1 + } + ], + "~:flip-x": null, + "~:height": 377.999978354524, + "~:flip-y": null + } + }, + "~ufbc43ead-a2ce-8058-8007-9a0daf843e09": { + "~#shape": { + "~:y": 262.999997589325, + "~:transform": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:rotation": 0, + "~:grow-type": "~:fixed", + "~:hide-in-viewer": false, + "~:name": "Ellipse", + "~:width": 266.000036716461, + "~:type": "~:circle", + "~:points": [ + { + "~#point": { + "~:x": 1271.00000137752, + "~:y": 262.999997589325 + } + }, + { + "~#point": { + "~:x": 1537.00003809398, + "~:y": 262.999997589325 + } + }, + { + "~#point": { + "~:x": 1537.00003809398, + "~:y": 483.000033828949 + } + }, + { + "~#point": { + "~:x": 1271.00000137752, + "~:y": 483.000033828949 + } + } + ], + "~:r2": 0, + "~:proportion-lock": false, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:r3": 0, + "~:r1": 0, + "~:id": "~ufbc43ead-a2ce-8058-8007-9a0daf843e09", + "~:parent-id": "~u00000000-0000-0000-0000-000000000000", + "~:frame-id": "~u00000000-0000-0000-0000-000000000000", + "~:strokes": [ + { + "~:stroke-alignment": "~:inner", + "~:stroke-style": "~:solid", + "~:stroke-color": "#000000", + "~:stroke-opacity": 1, + "~:stroke-width": 10 + } + ], + "~:x": 1271.00000137752, + "~:proportion": 1, + "~:shadow": [ + { + "~:id": "~u9c6321b5-aeab-809f-8007-971f9e232191", + "~:style": "~:drop-shadow", + "~:color": { + "~:color": "#000000", + "~:opacity": 1 + }, + "~:offset-x": 4, + "~:offset-y": 4, + "~:blur": 0, + "~:spread": 0, + "~:hidden": true + } + ], + "~:r4": 0, + "~:selrect": { + "~#rect": { + "~:x": 1271.00000137752, + "~:y": 262.999997589325, + "~:width": 266.000036716461, + "~:height": 220.000036239624, + "~:x1": 1271.00000137752, + "~:y1": 262.999997589325, + "~:x2": 1537.00003809398, + "~:y2": 483.000033828949 + } + }, + "~:fills": [ + { + "~:fill-color": "#ffffff", + "~:fill-opacity": 1 + } + ], + "~:flip-x": null, + "~:height": 220.000036239624, + "~:flip-y": null + } + }, + "~u80e2fa5a-cd1c-8043-8007-9d8aaca49f40": { + "~#shape": { + "~:y": -286.999972473494, + "~:transform": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:rotation": 0, + "~:grow-type": "~:auto-width", + "~:content": { + "~:type": "root", + "~:key": "1srkh8oc2vd", + "~:children": [ + { + "~:type": "paragraph-set", + "~:children": [ + { + "~:line-height": "1.2", + "~:font-style": "normal", + "~:children": [ + { + "~:line-height": "1.2", + "~:font-style": "normal", + "~:typography-ref-id": null, + "~:text-transform": "none", + "~:font-id": "sourcesanspro", + "~:key": "170uyffw5ph", + "~:font-size": "400", + "~:font-weight": "400", + "~:typography-ref-file": null, + "~:font-variant-id": "regular", + "~:text-decoration": "none", + "~:letter-spacing": "0", + "~:fills": [ + { + "~:fill-color": "#ffffff", + "~:fill-opacity": 1 + } + ], + "~:font-family": "sourcesanspro", + "~:text": "HELLO" + } + ], + "~:typography-ref-id": null, + "~:text-transform": "none", + "~:text-align": "left", + "~:font-id": "sourcesanspro", + "~:key": "psg8ayj675", + "~:font-size": "400", + "~:font-weight": "400", + "~:typography-ref-file": null, + "~:text-direction": "ltr", + "~:type": "paragraph", + "~:font-variant-id": "regular", + "~:text-decoration": "none", + "~:letter-spacing": "0", + "~:fills": [ + { + "~:fill-color": "#ffffff", + "~:fill-opacity": 1 + } + ], + "~:font-family": "sourcesanspro" + } + ] + } + ], + "~:vertical-align": "top" + }, + "~:hide-in-viewer": false, + "~:name": "HELLO", + "~:width": 1116.00003953244, + "~:type": "~:text", + "~:points": [ + { + "~#point": { + "~:x": 545.000013504691, + "~:y": -286.999972473494 + } + }, + { + "~#point": { + "~:x": 1661.00005303713, + "~:y": -286.999972473494 + } + }, + { + "~#point": { + "~:x": 1661.00005303713, + "~:y": 193.000017549648 + } + }, + { + "~#point": { + "~:x": 545.000013504691, + "~:y": 193.000017549648 + } + } + ], + "~:transform-inverse": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:id": "~u80e2fa5a-cd1c-8043-8007-9d8aaca49f40", + "~:parent-id": "~u00000000-0000-0000-0000-000000000000", + "~:position-data": [ + { + "~:y": 211.980041503906, + "~:line-height": "1.2", + "~:font-style": "normal", + "~:text-transform": "none", + "~:text-align": "left", + "~:font-id": "sourcesanspro", + "~:font-size": "400", + "~:font-weight": "400", + "~:text-direction": "ltr", + "~:width": 1115.22998046875, + "~:font-variant-id": "regular", + "~:text-decoration": "none", + "~:letter-spacing": "0", + "~:x": 545, + "~:fills": [ + { + "~:fill-color": "#ffffff", + "~:fill-opacity": 1 + } + ], + "~:direction": "ltr", + "~:font-family": "sourcesanspro", + "~:height": 517.960021972656, + "~:text": "HELLO" + } + ], + "~:frame-id": "~u00000000-0000-0000-0000-000000000000", + "~:strokes": [ + { + "~:stroke-style": "~:solid", + "~:stroke-alignment": "~:inner", + "~:stroke-width": 5, + "~:stroke-color": "#000000", + "~:stroke-opacity": 1 + } + ], + "~:x": 545.000013504691, + "~:selrect": { + "~#rect": { + "~:x": 545.000013504691, + "~:y": -286.999972473494, + "~:width": 1116.00003953244, + "~:height": 479.999990023141, + "~:x1": 545.000013504691, + "~:y1": -286.999972473494, + "~:x2": 1661.00005303713, + "~:y2": 193.000017549648 + } + }, + "~:flip-x": null, + "~:height": 479.999990023141, + "~:flip-y": null + } + }, + "~u5bebb998-d617-801b-8007-9a3fbd5cc804": { + "~#shape": { + "~:y": 543.00001095581, + "~:transform": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:rotation": 0, + "~:hide-in-viewer": false, + "~:name": "Rectangle", + "~:width": 463.999987447937, + "~:type": "~:rect", + "~:points": [ + { + "~#point": { + "~:x": 693.999990768432, + "~:y": 543.00001095581 + } + }, + { + "~#point": { + "~:x": 1157.99997821637, + "~:y": 543.00001095581 + } + }, + { + "~#point": { + "~:x": 1157.99997821637, + "~:y": 920.999989310334 + } + }, + { + "~#point": { + "~:x": 693.999990768432, + "~:y": 920.999989310334 + } + } + ], + "~:r2": 0, + "~:proportion-lock": false, + "~:transform-inverse": { + "~#matrix": { + "~:a": 1, + "~:b": 0, + "~:c": 0, + "~:d": 1, + "~:e": 0, + "~:f": 0 + } + }, + "~:r3": 0, + "~:r1": 0, + "~:id": "~u5bebb998-d617-801b-8007-9a3fbd5cc804", + "~:parent-id": "~u00000000-0000-0000-0000-000000000000", + "~:frame-id": "~u00000000-0000-0000-0000-000000000000", + "~:strokes": [ + { + "~:stroke-alignment": "~:inner", + "~:stroke-style": "~:solid", + "~:stroke-color": "#000000", + "~:stroke-opacity": 1, + "~:stroke-width": 100 + } + ], + "~:x": 693.999990768432, + "~:proportion": 1, + "~:shadow": [], + "~:r4": 0, + "~:selrect": { + "~#rect": { + "~:x": 693.999990768432, + "~:y": 543.00001095581, + "~:width": 463.999987447937, + "~:height": 377.999978354524, + "~:x1": 693.999990768432, + "~:y1": 543.00001095581, + "~:x2": 1157.99997821637, + "~:y2": 920.999989310334 + } + }, + "~:fills": [ + { + "~:fill-color": "#ffffff", + "~:fill-opacity": 1 + } + ], + "~:flip-x": null, + "~:height": 377.999978354524, + "~:flip-y": null + } + } + }, + "~:id": "~ueffcbebc-b8c8-802f-8007-9a0b2e2c8640", + "~:name": "Page 1", + "~:background": "#000000" + } + }, + "~:id": "~ueffcbebc-b8c8-802f-8007-9a0b2e2c863f", + "~:options": { + "~:components-v2": true, + "~:base-font-size": "16px" + } + } + } \ No newline at end of file diff --git a/frontend/playwright/ui/render-wasm-specs/shapes.spec.js b/frontend/playwright/ui/render-wasm-specs/shapes.spec.js index 9a4b26809b..242f0bf6d2 100644 --- a/frontend/playwright/ui/render-wasm-specs/shapes.spec.js +++ b/frontend/playwright/ui/render-wasm-specs/shapes.spec.js @@ -432,3 +432,27 @@ test("Keeps component visible when focusing after creating it", async ({ await workspace.hideUI(); await expect(workspace.canvas).toHaveScreenshot(); }); + +test("Check inner stroke artifacts", async ({ + page, +}) => { + const workspace = new WasmWorkspacePage(page); + await workspace.setupEmptyFile(); + await workspace.mockGetFile("render-wasm/get-file-inner-strokes-artifacts.json"); + + await workspace.goToWorkspace({ + id: "effcbebc-b8c8-802f-8007-9a0b2e2c863f", + pageId: "effcbebc-b8c8-802f-8007-9a0b2e2c8640", + }); + await workspace.waitForFirstRenderWithoutUI(); + + const previousRenderCount = await workspace.getRenderCount(); + await page.keyboard.press("ControlOrMeta++"); + await workspace.waitForNextRender(previousRenderCount); + + // Stricter comparison: artifacts are very subtle + await expect(workspace.canvas).toHaveScreenshot({ + maxDiffPixelRatio: 0, + threshold: 0.1, + }); +}); \ No newline at end of file diff --git a/frontend/playwright/ui/render-wasm-specs/shapes.spec.js-snapshots/Check-inner-stroke-artifacts-1.png b/frontend/playwright/ui/render-wasm-specs/shapes.spec.js-snapshots/Check-inner-stroke-artifacts-1.png new file mode 100644 index 0000000000000000000000000000000000000000..8b0fdc9c619dd7bbf4dcdd86b2739feb000e2d8a GIT binary patch literal 31969 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 aTV?Icl%!?jQzGTJzCUtoz}^uvzWg8A;;`NT literal 0 HcmV?d00001 diff --git a/render-wasm/src/render.rs b/render-wasm/src/render.rs index 2265ef4de7..1493b9851e 100644 --- a/render-wasm/src/render.rs +++ b/render-wasm/src/render.rs @@ -642,7 +642,7 @@ impl RenderState { apply_to_current_surface: bool, offset: Option<(f32, f32)>, parent_shadows: Option>, - spread: Option, + outset: Option, ) { let surface_ids = fills_surface_id as u32 | strokes_surface_id as u32 @@ -718,7 +718,7 @@ impl RenderState { &visible_strokes, Some(SurfaceId::Current), antialias, - spread, + outset, ); self.surfaces.apply_mut(SurfaceId::Current as u32, |s| { @@ -860,6 +860,8 @@ impl RenderState { let text_content = text_content.new_bounds(shape.selrect()); let count_inner_strokes = shape.count_visible_inner_strokes(); + // Erode the main text fill by 1px when there are inner strokes, to avoid a visible seam at the glyph edge. + let text_fill_inset = (count_inner_strokes > 0).then(|| 1.0 / self.get_scale()); let text_stroke_blur_outset = Stroke::max_bounds_width(shape.visible_strokes(), false); let mut paragraph_builders = text_content.paragraph_builder_group_from_text(None); @@ -886,6 +888,7 @@ impl RenderState { Some(fills_surface_id), None, None, + text_fill_inset, ); for stroke_paragraphs in stroke_paragraphs_list.iter_mut() { @@ -898,6 +901,7 @@ impl RenderState { None, None, text_stroke_blur_outset, + None, ); } } else { @@ -936,6 +940,7 @@ impl RenderState { text_drop_shadows_surface_id.into(), Some(&shadow), blur_filter.as_ref(), + None, ); } } else { @@ -961,6 +966,7 @@ impl RenderState { text_drop_shadows_surface_id.into(), Some(shadow), blur_filter.as_ref(), + None, ); } } @@ -974,6 +980,7 @@ impl RenderState { Some(fills_surface_id), None, blur_filter.as_ref(), + text_fill_inset, ); // 3. Stroke drop shadows @@ -998,6 +1005,7 @@ impl RenderState { None, blur_filter.as_ref(), text_stroke_blur_outset, + None, ); } @@ -1023,6 +1031,7 @@ impl RenderState { Some(innershadows_surface_id), Some(shadow), blur_filter.as_ref(), + None, ); } } @@ -1070,7 +1079,7 @@ impl RenderState { &fills_to_render, antialias, fills_surface_id, - spread, + outset, ); } } else { @@ -1080,7 +1089,7 @@ impl RenderState { &shape.fills, antialias, fills_surface_id, - spread, + outset, ); } @@ -1096,7 +1105,7 @@ impl RenderState { &visible_strokes, Some(strokes_surface_id), antialias, - spread, + outset, ); if !fast_mode { for stroke in &visible_strokes { @@ -1715,7 +1724,7 @@ impl RenderState { false, Some(shadow.offset), // Offset is geometric None, - Some(shadow.spread), // Spread is geometric + Some(shadow.spread), ); }); @@ -1756,7 +1765,7 @@ impl RenderState { false, Some(shadow.offset), // Offset is geometric None, - Some(shadow.spread), // Spread is geometric + Some(shadow.spread), ); }); diff --git a/render-wasm/src/render/fills.rs b/render-wasm/src/render/fills.rs index 399f6bbf19..6e098c9752 100644 --- a/render-wasm/src/render/fills.rs +++ b/render-wasm/src/render/fills.rs @@ -2,7 +2,15 @@ use skia_safe::{self as skia, Paint, RRect}; use super::{filters, RenderState, SurfaceId}; use crate::render::get_source_rect; -use crate::shapes::{merge_fills, Fill, Frame, ImageFill, Rect, Shape, Type}; +use crate::shapes::{merge_fills, Fill, Frame, ImageFill, Rect, Shape, StrokeKind, Type}; + +/// True when the shape has at least one visible inner stroke. +fn has_inner_stroke(shape: &Shape) -> bool { + let is_open = shape.is_open(); + shape + .visible_strokes() + .any(|s| s.render_kind(is_open) == StrokeKind::Inner) +} fn draw_image_fill( render_state: &mut RenderState, @@ -100,18 +108,33 @@ pub fn render( fills: &[Fill], antialias: bool, surface_id: SurfaceId, - spread: Option, + outset: Option, ) { if fills.is_empty() { return; } + let scale = render_state.get_scale().max(1e-6); + let inset = if has_inner_stroke(shape) { + Some(1.0 / scale) + } else { + None + }; + // Image fills use draw_image_fill which needs render_state for GPU images // and sampling options that get_fill_shader (used by merge_fills) lacks. let has_image_fills = fills.iter().any(|f| matches!(f, Fill::Image(_))); if has_image_fills { for fill in fills.iter().rev() { - render_single_fill(render_state, shape, fill, antialias, surface_id, spread); + render_single_fill( + render_state, + shape, + fill, + antialias, + surface_id, + outset, + inset, + ); } return; } @@ -128,7 +151,7 @@ pub fn render( |state, temp_surface| { let mut filtered_paint = paint.clone(); filtered_paint.set_image_filter(image_filter.clone()); - draw_fill_to_surface(state, shape, temp_surface, &filtered_paint, spread); + draw_fill_to_surface(state, shape, temp_surface, &filtered_paint, outset, inset); }, ) { return; @@ -137,33 +160,35 @@ pub fn render( } } - draw_fill_to_surface(render_state, shape, surface_id, &paint, spread); + draw_fill_to_surface(render_state, shape, surface_id, &paint, outset, inset); } /// Draws a single paint (with a merged shader) to the appropriate surface /// based on the shape type. +/// When `inset` is Some(eps), the fill is inset by eps (e.g. to avoid seam with inner strokes). fn draw_fill_to_surface( render_state: &mut RenderState, shape: &Shape, surface_id: SurfaceId, paint: &Paint, - spread: Option, + outset: Option, + inset: Option, ) { match &shape.shape_type { Type::Rect(_) | Type::Frame(_) => { render_state .surfaces - .draw_rect_to(surface_id, shape, paint, spread); + .draw_rect_to(surface_id, shape, paint, outset, inset); } Type::Circle => { render_state .surfaces - .draw_circle_to(surface_id, shape, paint, spread); + .draw_circle_to(surface_id, shape, paint, outset, inset); } Type::Path(_) | Type::Bool(_) => { render_state .surfaces - .draw_path_to(surface_id, shape, paint, spread); + .draw_path_to(surface_id, shape, paint, outset, inset); } Type::Group(_) => {} _ => unreachable!("This shape should not have fills"), @@ -176,7 +201,8 @@ fn render_single_fill( fill: &Fill, antialias: bool, surface_id: SurfaceId, - spread: Option, + outset: Option, + inset: Option, ) { let mut paint = fill.to_paint(&shape.selrect, antialias); if let Some(image_filter) = shape.image_filter(1.) { @@ -195,7 +221,8 @@ fn render_single_fill( antialias, temp_surface, &filtered_paint, - spread, + outset, + inset, ); }, ) { @@ -212,10 +239,12 @@ fn render_single_fill( antialias, surface_id, &paint, - spread, + outset, + inset, ); } +#[allow(clippy::too_many_arguments)] fn draw_single_fill_to_surface( render_state: &mut RenderState, shape: &Shape, @@ -223,7 +252,8 @@ fn draw_single_fill_to_surface( antialias: bool, surface_id: SurfaceId, paint: &Paint, - spread: Option, + outset: Option, + inset: Option, ) { match (fill, &shape.shape_type) { (Fill::Image(image_fill), _) => { @@ -239,17 +269,17 @@ fn draw_single_fill_to_surface( (_, Type::Rect(_) | Type::Frame(_)) => { render_state .surfaces - .draw_rect_to(surface_id, shape, paint, spread); + .draw_rect_to(surface_id, shape, paint, outset, inset); } (_, Type::Circle) => { render_state .surfaces - .draw_circle_to(surface_id, shape, paint, spread); + .draw_circle_to(surface_id, shape, paint, outset, inset); } (_, Type::Path(_)) | (_, Type::Bool(_)) => { render_state .surfaces - .draw_path_to(surface_id, shape, paint, spread); + .draw_path_to(surface_id, shape, paint, outset, inset); } (_, Type::Group(_)) => { // Groups can have fills but they propagate them to their children diff --git a/render-wasm/src/render/shadows.rs b/render-wasm/src/render/shadows.rs index 4906714389..b5077f0688 100644 --- a/render-wasm/src/render/shadows.rs +++ b/render-wasm/src/render/shadows.rs @@ -109,17 +109,17 @@ fn render_shadow_paint( Type::Rect(_) | Type::Frame(_) => { render_state .surfaces - .draw_rect_to(surface_id, shape, paint, None); + .draw_rect_to(surface_id, shape, paint, None, None); } Type::Circle => { render_state .surfaces - .draw_circle_to(surface_id, shape, paint, None); + .draw_circle_to(surface_id, shape, paint, None, None); } Type::Path(_) | Type::Bool(_) => { render_state .surfaces - .draw_path_to(surface_id, shape, paint, None); + .draw_path_to(surface_id, shape, paint, None, None); } _ => {} } @@ -154,6 +154,7 @@ pub fn render_text_shadows( surface_id, None, blur_filter.as_ref(), + None, ); for stroke_paragraphs in stroke_paragraphs_group.iter_mut() { @@ -165,6 +166,7 @@ pub fn render_text_shadows( surface_id, None, blur_filter.as_ref(), + None, ); } diff --git a/render-wasm/src/render/strokes.rs b/render-wasm/src/render/strokes.rs index 38228f32d4..e18b5f5e63 100644 --- a/render-wasm/src/render/strokes.rs +++ b/render-wasm/src/render/strokes.rs @@ -526,7 +526,7 @@ pub fn render( strokes: &[&Stroke], surface_id: Option, antialias: bool, - spread: Option, + outset: Option, ) { if strokes.is_empty() { return; @@ -541,8 +541,8 @@ pub fn render( // edges semi-transparent and revealing strokes underneath. if let Some(image_filter) = shape.image_filter(1.) { let mut content_bounds = shape.selrect; - // Expand for spread if provided - if let Some(s) = spread.filter(|&s| s > 0.0) { + // Expand for outset if provided + if let Some(s) = outset.filter(|&s| s > 0.0) { content_bounds.outset((s, s)); } let max_margin = strokes @@ -588,7 +588,7 @@ pub fn render( antialias, true, true, - spread, + outset, ); } @@ -608,7 +608,7 @@ pub fn render( surface_id, None, antialias, - spread, + outset, ); } return; @@ -621,7 +621,7 @@ pub fn render( surface_id, antialias, false, - spread, + outset, ); } @@ -642,7 +642,7 @@ fn render_merged( surface_id: Option, antialias: bool, bypass_filter: bool, - spread: Option, + outset: Option, ) { let representative = *strokes .last() @@ -658,8 +658,8 @@ fn render_merged( if !bypass_filter { if let Some(image_filter) = blur_filter.clone() { let mut content_bounds = shape.selrect; - // Expand for spread if provided - if let Some(s) = spread.filter(|&s| s > 0.0) { + // Expand for outset if provided + if let Some(s) = outset.filter(|&s| s > 0.0) { content_bounds.outset((s, s)); } let stroke_margin = representative.bounds_width(shape.is_open()); @@ -694,7 +694,7 @@ fn render_merged( Some(temp_surface), antialias, true, - spread, + outset, ); state.surfaces.apply_mut(temp_surface as u32, |surface| { @@ -711,8 +711,8 @@ fn render_merged( // via SrcOver), matching the non-merged path where strokes[0] is drawn last (on top). let fills: Vec = strokes.iter().map(|s| s.fill.clone()).collect(); - // Expand selrect if spread is provided - let selrect = if let Some(s) = spread.filter(|&s| s > 0.0) { + // Expand selrect if outset is provided + let selrect = if let Some(s) = outset.filter(|&s| s > 0.0) { let mut r = shape.selrect; r.outset((s, s)); r @@ -790,7 +790,7 @@ pub fn render_single( surface_id: Option, shadow: Option<&ImageFilter>, antialias: bool, - spread: Option, + outset: Option, ) { render_single_internal( render_state, @@ -801,7 +801,7 @@ pub fn render_single( antialias, false, false, - spread, + outset, ); } @@ -815,13 +815,13 @@ fn render_single_internal( antialias: bool, bypass_filter: bool, skip_blur: bool, - spread: Option, + outset: Option, ) { if !bypass_filter { if let Some(image_filter) = shape.image_filter(1.) { let mut content_bounds = shape.selrect; - // Expand for spread if provided - if let Some(s) = spread.filter(|&s| s > 0.0) { + // Expand for outset if provided + if let Some(s) = outset.filter(|&s| s > 0.0) { content_bounds.outset((s, s)); } let stroke_margin = stroke.bounds_width(shape.is_open()); @@ -849,7 +849,7 @@ fn render_single_internal( antialias, true, true, - spread, + outset, ); }, ) { @@ -920,18 +920,18 @@ fn render_single_internal( let is_open = path.is_open(); let mut paint = stroke.to_stroked_paint(is_open, &selrect, svg_attrs, antialias); - // Apply spread by increasing stroke width - if let Some(s) = spread.filter(|&s| s > 0.0) { + // Apply outset by increasing stroke width + if let Some(s) = outset.filter(|&s| s > 0.0) { let current_width = paint.stroke_width(); // Path stroke kinds are built differently: // - Center uses the stroke width directly. // - Inner/Outer use a doubled width plus clipping/clearing logic. - // Compensate spread so visual growth is comparable across kinds. - let spread_growth = match stroke.render_kind(is_open) { + // Compensate outset so visual growth is comparable across kinds. + let outset_growth = match stroke.render_kind(is_open) { StrokeKind::Center => s * 2.0, StrokeKind::Inner | StrokeKind::Outer => s * 4.0, }; - paint.set_stroke_width(current_width + spread_growth); + paint.set_stroke_width(current_width + outset_growth); } draw_stroke_on_path( canvas, diff --git a/render-wasm/src/render/surfaces.rs b/render-wasm/src/render/surfaces.rs index 8c6780cd52..5bb227ab04 100644 --- a/render-wasm/src/render/surfaces.rs +++ b/render-wasm/src/render/surfaces.rs @@ -360,16 +360,30 @@ impl Surfaces { id: SurfaceId, shape: &Shape, paint: &Paint, - spread: Option, + outset: Option, + inset: Option, ) { - let rect = if let Some(s) = spread.filter(|&s| s > 0.0) { + let mut rect = if let Some(s) = outset.filter(|&s| s > 0.0) { let mut r = shape.selrect; r.outset((s, s)); r } else { shape.selrect }; + if let Some(eps) = inset.filter(|&e| e > 0.0) { + rect.inset((eps, eps)); + } if let Some(corners) = shape.shape_type.corners() { + let corners = if let Some(eps) = inset.filter(|&e| e > 0.0) { + let mut c = corners; + for r in c.iter_mut() { + r.x = (r.x - eps).max(0.0); + r.y = (r.y - eps).max(0.0); + } + c + } else { + corners + }; let rrect = RRect::new_rect_radii(rect, &corners); self.canvas_and_mark_dirty(id).draw_rrect(rrect, paint); } else { @@ -382,15 +396,19 @@ impl Surfaces { id: SurfaceId, shape: &Shape, paint: &Paint, - spread: Option, + outset: Option, + inset: Option, ) { - let rect = if let Some(s) = spread.filter(|&s| s > 0.0) { + let mut rect = if let Some(s) = outset.filter(|&s| s > 0.0) { let mut r = shape.selrect; r.outset((s, s)); r } else { shape.selrect }; + if let Some(eps) = inset.filter(|&e| e > 0.0) { + rect.inset((eps, eps)); + } self.canvas_and_mark_dirty(id).draw_oval(rect, paint); } @@ -399,17 +417,27 @@ impl Surfaces { id: SurfaceId, shape: &Shape, paint: &Paint, - spread: Option, + outset: Option, + inset: Option, ) { if let Some(path) = shape.get_skia_path() { let canvas = self.canvas_and_mark_dirty(id); - if let Some(s) = spread.filter(|&s| s > 0.0) { + if let Some(s) = outset.filter(|&s| s > 0.0) { // Draw path as a thick stroke to get outset (expanded) silhouette let mut stroke_paint = paint.clone(); stroke_paint.set_stroke_width(s * 2.0); canvas.draw_path(&path, &stroke_paint); } else { canvas.draw_path(&path, paint); + // Inset: avoid seam with inner strokes by clearing a thin border from the fill + if let Some(eps) = inset.filter(|&e| e > 0.0) { + let mut clear_paint = skia::Paint::default(); + clear_paint.set_style(skia::PaintStyle::Stroke); + clear_paint.set_stroke_width(eps * 2.0); + clear_paint.set_blend_mode(skia::BlendMode::Clear); + clear_paint.set_anti_alias(paint.is_anti_alias()); + canvas.draw_path(&path, &clear_paint); + } } } } diff --git a/render-wasm/src/render/text.rs b/render-wasm/src/render/text.rs index 2761962071..42c35b450f 100644 --- a/render-wasm/src/render/text.rs +++ b/render-wasm/src/render/text.rs @@ -166,6 +166,7 @@ pub fn render_with_bounds_outset( shadow: Option<&Paint>, blur: Option<&ImageFilter>, stroke_bounds_outset: f32, + fill_inset: Option, ) { if let Some(render_state) = render_state { let target_surface = surface_id.unwrap_or(SurfaceId::Fills); @@ -193,6 +194,7 @@ pub fn render_with_bounds_outset( paragraph_builders, shadow, Some(&blur_filter_clone), + fill_inset, ); }, ) { @@ -202,15 +204,16 @@ pub fn render_with_bounds_outset( } let canvas = render_state.surfaces.canvas_and_mark_dirty(target_surface); - render_text_on_canvas(canvas, shape, paragraph_builders, shadow, blur); + render_text_on_canvas(canvas, shape, paragraph_builders, shadow, blur, fill_inset); return; } if let Some(canvas) = canvas { - render_text_on_canvas(canvas, shape, paragraph_builders, shadow, blur); + render_text_on_canvas(canvas, shape, paragraph_builders, shadow, blur, fill_inset); } } +#[allow(clippy::too_many_arguments)] pub fn render( render_state: Option<&mut RenderState>, canvas: Option<&Canvas>, @@ -219,6 +222,7 @@ pub fn render( surface_id: Option, shadow: Option<&Paint>, blur: Option<&ImageFilter>, + fill_inset: Option, ) { render_with_bounds_outset( render_state, @@ -229,6 +233,7 @@ pub fn render( shadow, blur, 0.0, + fill_inset, ); } @@ -238,6 +243,7 @@ fn render_text_on_canvas( paragraph_builders: &mut [Vec], shadow: Option<&Paint>, blur: Option<&ImageFilter>, + fill_inset: Option, ) { if let Some(blur_filter) = blur { let mut blur_paint = Paint::default(); @@ -251,6 +257,17 @@ fn render_text_on_canvas( canvas.save_layer(&layer_rec); draw_text(canvas, shape, paragraph_builders); canvas.restore(); + } else if let Some(eps) = fill_inset.filter(|&e| e > 0.0) { + if let Some(erode) = skia_safe::image_filters::erode((eps, eps), None, None) { + let mut layer_paint = Paint::default(); + layer_paint.set_image_filter(erode); + let layer_rec = SaveLayerRec::default().paint(&layer_paint); + canvas.save_layer(&layer_rec); + draw_text(canvas, shape, paragraph_builders); + canvas.restore(); + } else { + draw_text(canvas, shape, paragraph_builders); + } } else { draw_text(canvas, shape, paragraph_builders); }