diff --git a/common/src/app/common/types/text.cljc b/common/src/app/common/types/text.cljc index e0330b3264..6068cfc829 100644 --- a/common/src/app/common/types/text.cljc +++ b/common/src/app/common/types/text.cljc @@ -78,6 +78,11 @@ text-transform-attrs text-fills)) +(def text-span-attrs + "Inline text span attrs. Line-height is paragraph-level in the DOM editor; + it may still be stored redundantly on span nodes." + (vec (remove #{:line-height} text-node-attrs))) + (defn text-node-attr? [attr] (d/index-of text-node-attrs attr)) @@ -317,9 +322,16 @@ "Given two content text structures, conformed by maps and vectors, compare them, and returns a set with the attributes that have changed. This is independent of the text structure, so if the structure changes - but the attributes are the same, it will return an empty set." + but the attributes are the same, it will return an empty set. + + Line-height on text nodes is ignored: it is a paragraph-level attribute + and may be stored redundantly on spans (e.g. after token apply)." [a b] - (let [diff-attrs (compare-text-content a b + (let [strip-span-line-height + #(transform-nodes is-text-node? (fn [node] (dissoc node :line-height)) %) + a (strip-span-line-height a) + b (strip-span-line-height b) + diff-attrs (compare-text-content a b {:text-cb identity :attribute-cb (fn [acc attr] (conj acc attr))})] (if-not (contains? diff-attrs :text-content-structure) diff --git a/common/test/common_tests/types/text_test.cljc b/common/test/common_tests/types/text_test.cljc index f6dea90560..b63a6db6e1 100644 --- a/common/test/common_tests/types/text_test.cljc +++ b/common/test/common_tests/types/text_test.cljc @@ -76,8 +76,31 @@ (assoc-in content-base [:children 0 :children 0 :children 0 :font-family] "Arial")) (def content-changed-line-height + (assoc-in content-base [:children 0 :children 0 :line-height] "1.5")) + +(def content-redundant-span-line-height (assoc-in content-base [:children 0 :children 0 :children 0 :line-height] "1.5")) +;; Token apply may store line-height on paragraph and spans; after a DOM +;; round-trip spans no longer carry it (paragraph-level in the editor). +(def content-token-like-line-height + (-> content-base + (assoc-in [:children 0 :children 0 :line-height] 1.4) + (assoc-in [:children 0 :children 0 :children 0 :line-height] 1.4))) + +(def content-after-editor-roundtrip + (update-in content-token-like-line-height + [:children 0 :children 0 :children 0] + dissoc :line-height)) + +;; from_dom used to merge default nil typography refs on import. +(def content-explicit-nil-typography-refs + (-> content-base + (assoc-in [:children 0 :children 0 :typography-ref-id] nil) + (assoc-in [:children 0 :children 0 :typography-ref-file] nil) + (assoc-in [:children 0 :children 0 :children 0 :typography-ref-id] nil) + (assoc-in [:children 0 :children 0 :children 0 :typography-ref-file] nil))) + (def content-changed-letter-spacing (assoc-in content-base [:children 0 :children 0 :children 0 :letter-spacing] "2")) @@ -185,6 +208,10 @@ ;; Other text-node-attr categories attrs-font-family (cttx/get-diff-attrs content-base content-changed-font-family) attrs-line-height (cttx/get-diff-attrs content-base content-changed-line-height) + attrs-span-line-height (cttx/get-diff-attrs content-base content-redundant-span-line-height) + attrs-roundtrip-line-height (cttx/get-diff-attrs content-token-like-line-height + content-after-editor-roundtrip) + attrs-nil-typography-refs (cttx/get-diff-attrs content-base content-explicit-nil-typography-refs) attrs-letter-spacing (cttx/get-diff-attrs content-base content-changed-letter-spacing) attrs-text-decoration (cttx/get-diff-attrs content-base content-changed-text-decoration) attrs-text-transform (cttx/get-diff-attrs content-base content-changed-text-transform) @@ -215,6 +242,9 @@ ;; Each text-node-attr category reports correct attr key (t/is (= #{:font-family} attrs-font-family)) (t/is (= #{:line-height} attrs-line-height)) + (t/is (= #{} attrs-span-line-height)) + (t/is (= #{} attrs-roundtrip-line-height)) + (t/is (= #{} attrs-nil-typography-refs)) (t/is (= #{:letter-spacing} attrs-letter-spacing)) (t/is (= #{:text-decoration} attrs-text-decoration)) (t/is (= #{:text-transform} attrs-text-transform)) diff --git a/frontend/src/app/util/text/content/from_dom.cljs b/frontend/src/app/util/text/content/from_dom.cljs index 4d016313b5..7b56b014c1 100644 --- a/frontend/src/app/util/text/content/from_dom.cljs +++ b/frontend/src/app/util/text/content/from_dom.cljs @@ -51,13 +51,19 @@ [_ style-decode] (get styles/mapping key)] (style-decode (.getPropertyValue style style-name))) (let [style-name (styles/get-style-name key)] - (styles/normalize-attr-value key (.getPropertyValue style style-name))))] - (assoc acc key (if (value-empty? value) (get defaults key) value)))) + (styles/normalize-attr-value key (.getPropertyValue style style-name)))) + default (get defaults key) + final-value (if (value-empty? value) default value)] + ;; Omit attrs with no CSS value when the default is nil (e.g. + ;; typography-ref-id). Avoids polluting round-tripped content. + (if (and (value-empty? value) (nil? default)) + acc + (assoc acc key final-value)))) {} attrs))) (defn get-text-span-styles [element] - (get-attrs-from-styles element txt/text-node-attrs (txt/get-default-text-attrs))) + (get-attrs-from-styles element txt/text-span-attrs (txt/get-default-text-attrs))) (defn get-paragraph-styles [element] diff --git a/frontend/src/app/util/text/content/to_dom.cljs b/frontend/src/app/util/text/content/to_dom.cljs index 373b49bdc9..cd7ab9d5aa 100644 --- a/frontend/src/app/util/text/content/to_dom.cljs +++ b/frontend/src/app/util/text/content/to_dom.cljs @@ -94,9 +94,8 @@ (defn get-text-span-styles [inline paragraph] - (let [node (if (= "" (:text inline)) paragraph inline) - styles (get-styles-from-attrs node txt/text-node-attrs txt/default-text-attrs)] - (dissoc styles :line-height))) + (let [node (if (= "" (:text inline)) paragraph inline)] + (get-styles-from-attrs node txt/text-span-attrs txt/default-text-attrs))) (defn normalize-spaces "Add zero-width spaces after forward slashes to enable word breaking" diff --git a/render-wasm/src/render.rs b/render-wasm/src/render.rs index 9dda9ce11d..cc26d6de45 100644 --- a/render-wasm/src/render.rs +++ b/render-wasm/src/render.rs @@ -2003,89 +2003,12 @@ impl RenderState { pub fn render_from_cache(&mut self, shapes: ShapesPoolRef) { let _start = performance::begin_timed_log!("render_from_cache"); performance::begin_measure!("render_from_cache"); - let bg_color = self.background_color; - - // During fast mode (pan/zoom), if a previous full-quality render still has pending tiles, - // always prefer the persistent atlas. The atlas is incrementally updated as tiles finish, - // and drawing from it avoids mixing a partially-updated Cache surface with missing tiles. - if self.options.is_fast_mode() && !self.surfaces.atlas.is_empty() { - self.surfaces - .draw_atlas_to_backbuffer(self.viewbox, bg_color); - - self.present_frame(shapes); - performance::end_measure!("render_from_cache"); - performance::end_timed_log!("render_from_cache", _start); - return; - } - - // Check if we have a valid cached viewbox (non-zero dimensions indicate valid cache) - if self.cached_viewbox.area.width() > 0.0 { - // Scale and translate the target according to the cached data - let navigate_zoom = self.viewbox.zoom / self.cached_viewbox.zoom; - - let interest = self.options.dpr_viewport_interest_area_threshold; - let TileRect(start_tile_x, start_tile_y, _, _) = - tiles::get_tiles_for_viewbox_with_interest(&self.cached_viewbox, interest); - let offset_x = self.viewbox.area.left * self.cached_viewbox.zoom * self.options.dpr; - let offset_y = self.viewbox.area.top * self.cached_viewbox.zoom * self.options.dpr; - let translate_x = (start_tile_x as f32 * tiles::TILE_SIZE) - offset_x; - let translate_y = (start_tile_y as f32 * tiles::TILE_SIZE) - offset_y; - - // For zoom-out, prefer cache only if it fully covers the viewport. - // Otherwise, atlas will provide a more correct full-viewport preview. - let zooming_out = self.viewbox.zoom < self.cached_viewbox.zoom; - if zooming_out { - let cache_dim = self.surfaces.cache_dimensions(); - let cache_w = cache_dim.width as f32; - let cache_h = cache_dim.height as f32; - - // Viewport in target pixels. - let vw = self.viewbox.dpr_width().max(1.0); - let vh = self.viewbox.dpr_height().max(1.0); - - // Inverse-map viewport corners into cache coordinates. - // target = (cache * navigate_zoom) translated by (translate_x, translate_y) (in cache coords). - // => cache = (target / navigate_zoom) - translate - let inv = if navigate_zoom.abs() > f32::EPSILON { - 1.0 / navigate_zoom - } else { - 0.0 - }; - - // let cx0 = (0.0 * inv) - translate_x; - // let cy0 = (0.0 * inv) - translate_y; - // NOTA: 0.0 * inv => siempre 0 - let cx0 = -translate_x; - let cy0 = -translate_y; - let cx1 = (vw * inv) - translate_x; - let cy1 = (vh * inv) - translate_y; - - let min_x = cx0.min(cx1); - let min_y = cy0.min(cy1); - let max_x = cx0.max(cx1); - let max_y = cy0.max(cy1); - - let cache_covers = - min_x >= 0.0 && min_y >= 0.0 && max_x <= cache_w && max_y <= cache_h; - if !cache_covers { - // Early return only if atlas exists; otherwise keep cache path. - if !self.surfaces.atlas.is_empty() { - self.surfaces - .draw_atlas_to_backbuffer(self.viewbox, bg_color); - - self.present_frame(shapes); - performance::end_measure!("render_from_cache"); - performance::end_timed_log!("render_from_cache", _start); - return; - } - } - } - - // Draw directly from cache surface, avoiding snapshot overhead - self.surfaces.draw_cache_to_backbuffer(); - - self.present_frame(shapes); - } + self.surfaces.draw_combined_atlas_to_backbuffer( + &self.viewbox, + &self.tile_viewbox, + self.background_color, + ); + self.present_frame(shapes); performance::end_measure!("render_from_cache"); performance::end_timed_log!("render_from_cache", _start); diff --git a/render-wasm/src/render/rulers.rs b/render-wasm/src/render/rulers.rs index a33215b942..510979a2f8 100644 --- a/render-wasm/src/render/rulers.rs +++ b/render-wasm/src/render/rulers.rs @@ -28,13 +28,17 @@ const CANVAS_BORDER_RADIUS: f32 = 12.0; // distinct offsets for the two and we mirror that. const SELECTION_LABEL_BASELINE: f32 = 13.6; -// Selection-label gradient mask: matches the SVG `selection-gradient-start` -// and `selection-gradient-end` defs. The mask is `OVER_NUMBER_SIZE` screen -// pixels long, with the opaque part starting `OVER_NUMBER_PERCENT` of the -// way through the rect (40% from the outside edge, 60% from the inside). +// Selection-label gradient mask: fades the tick labels behind the selection +// band. The mask is `OVER_NUMBER_SIZE` screen pixels long. `OVER_NUMBER_PERCENT` +// is the fraction of the mask that sits *outside* the band; at 1.0 the mask's +// inner edge lands exactly on the band edge, so the dark shadow begins where +// the green selection area ends and fades outward (it never bleeds under the +// band). `GRADIENT_FADE_FRACTION` is the share of the mask spent on the +// transparent→opaque ramp at the outer end. const OVER_NUMBER_SIZE: f32 = 100.0; -const OVER_NUMBER_PERCENT: f32 = 0.75; +const OVER_NUMBER_PERCENT: f32 = 1.0; const GRADIENT_FADE_FRACTION: f32 = 0.4; +const OVER_NUMBER_OPACITY: f32 = 0.8; fn calculate_step_size(zoom: f32) -> f32 { if zoom <= 0.0 { @@ -308,23 +312,27 @@ fn draw_selection_x(ctx: &RenderCtx, sel: Rect, offset: f32) { ); let text_y = ctx.vy + SELECTION_LABEL_BASELINE * zi; - let pad_x = 4.0 * zi; + // Both labels use the same half-bar gap from the band so the start (left) + // and end (right) are spaced symmetrically. + let gap = (RULER_AREA_SIZE / 2.0) * zi; let left_label = format_label(sel.left - offset); let right_label = format_label(sel.right - offset); let (lw_font, _) = ctx.font.measure_str(&left_label, None); - // The right label is anchored at its left edge, so we don't need its - // measured width. - let lx = sel.left - pad_x - lw_font * zi; - let rx = sel.right + pad_x; + let lx = sel.left - gap - lw_font * zi; + let rx = sel.right + gap; let mut text_paint = Paint::default(); text_paint.set_color(ctx.state.accent_color); text_paint.set_anti_alias(true); + + // 1. Left label canvas.save(); canvas.translate((lx, text_y)); canvas.scale((zi, zi)); canvas.draw_str(&left_label, Point::new(0.0, 0.0), ctx.font, &text_paint); canvas.restore(); + + // 2. Right label canvas.save(); canvas.translate((rx, text_y)); canvas.scale((zi, zi)); @@ -341,7 +349,7 @@ enum MaskAxis { /// labels behind the selection band. `fade_to_end` flips it from /// transparent→opaque (before the band) to opaque→transparent (after). fn draw_mask(ctx: &RenderCtx, rect: Rect, axis: MaskAxis, fade_to_end: bool) { - let opaque = ctx.state.bg_color; + let opaque = with_alpha(ctx.state.bg_color, OVER_NUMBER_OPACITY); let transparent = with_alpha(ctx.state.bg_color, 0.0); let (colors, offsets): (&[skia::Color; 3], &[f32; 3]) = if fade_to_end { ( @@ -377,11 +385,10 @@ fn draw_selection_y(ctx: &RenderCtx, sel: Rect, offset: f32) { let canvas = ctx.canvas; let zi = ctx.zi; - let pad_y = 4.0 * zi; let top_label = format_label(sel.top - offset); let bottom_label = format_label(sel.bottom - offset); - // Top label's draw position doesn't depend on its own width (LX is just - // pad_y/zi), so we only need bw_font for the bottom label's right-anchor. + // Both labels sit a half-bar gap from the band; only the bottom label's + // origin depends on its own width (it reads toward the band). let (bw_font, _) = ctx.font.measure_str(&bottom_label, None); // Mask first (gradient bg over tick labels behind), then band, then @@ -416,33 +423,27 @@ fn draw_selection_y(ctx: &RenderCtx, sel: Rect, offset: f32) { let mut text_paint = Paint::default(); text_paint.set_color(ctx.state.accent_color); text_paint.set_anti_alias(true); - // Both labels read bottom-to-top on screen (after the -90° rotation - // local +x points upward). With the transform stack - // (translate→rotate→scale) and a draw at code-(LX, 0), the actual - // origin in document coords is (text_x, pivot_y − LX·zi). - // - // Top label: want origin just above sel.top, reading upward from - // there, so pivot_y − LX·zi = sel.top − pad_y ⇒ LX = pad_y/zi. + // Both labels read bottom-to-top on screen + // 1. Top label canvas.save(); canvas.translate((text_x, sel.top)); canvas.rotate(-90.0, None); canvas.scale((zi, zi)); canvas.draw_str( &top_label, - Point::new(pad_y / zi, 0.0), + Point::new(RULER_AREA_SIZE / 2.0, 0.0), ctx.font, &text_paint, ); canvas.restore(); - // Bottom label: want the text END at sel.bottom + pad_y and origin - // at sel.bottom + pad_y + bw so it reads upward toward the band. + // 2. Bottom label canvas.save(); canvas.translate((text_x, sel.bottom)); canvas.rotate(-90.0, None); canvas.scale((zi, zi)); canvas.draw_str( &bottom_label, - Point::new(-bw_font - pad_y / zi, 0.0), + Point::new(-bw_font - RULER_AREA_SIZE / 2.0, 0.0), ctx.font, &text_paint, ); diff --git a/render-wasm/src/render/surfaces.rs b/render-wasm/src/render/surfaces.rs index 4f9952ca79..dc91011abc 100644 --- a/render-wasm/src/render/surfaces.rs +++ b/render-wasm/src/render/surfaces.rs @@ -590,6 +590,57 @@ impl Surfaces { canvas.restore(); } + /// Fast pan/zoom preview: draw the doc atlas as backdrop, then overlay HQ + /// cached tile textures placed via their stored document rects (pan + scale). + pub fn draw_combined_atlas_to_backbuffer( + &mut self, + viewbox: &Viewbox, + tile_viewbox: &TileViewbox, + background: skia::Color, + ) { + self.draw_atlas_to_backbuffer(*viewbox, background); + + // Tile textures are keyed by grid index but positioned in document space. + // Without `tile_doc_rects` we cannot displace/scale them correctly (e.g. + // right after zoom invalidation); the atlas backdrop alone is enough. + if self.atlas.tile_doc_rects.is_empty() { + return; + } + + let batch = self.tiles.build_atlas_draw_batch_for_doc_rects( + viewbox, + tile_viewbox, + &self.atlas.tile_doc_rects, + ); + if batch.is_empty() { + return; + } + + if self.tiles.needs_snapshot() || self.tile_atlas_image.is_none() { + self.tile_atlas_image = Some(self.tile_atlas.image_snapshot()); + self.tiles.snapshot(); + } + let Some(atlas_image) = self.tile_atlas_image.as_ref() else { + return; + }; + + let canvas = self.backbuffer.canvas(); + canvas.save(); + canvas.reset_matrix(); + + canvas.draw_atlas( + atlas_image, + &batch.transforms, + &batch.textures, + None, + skia::BlendMode::SrcOver, + self.atlas_sampling_options, + None, + None, + ); + canvas.restore(); + } + pub fn margins(&self) -> skia::ISize { self.margins } @@ -716,18 +767,6 @@ impl Surfaces { ); } - /// Draws the cache surface directly to the backbuffer canvas. - /// This avoids creating an intermediate snapshot, reducing GPU stalls. - pub fn draw_cache_to_backbuffer(&mut self) { - let sampling_options = self.sampling_options; - self.cache.draw( - self.backbuffer.canvas(), - (0.0, 0.0), - sampling_options, - Some(&skia::Paint::default()), - ); - } - pub fn cache_dimensions(&self) -> skia::ISize { skia::ISize::new(self.cache.width(), self.cache.height()) } @@ -1516,6 +1555,17 @@ pub struct TileTextureCache { removed: HashSet, } +pub struct AtlasDrawBatch { + pub transforms: Vec, + pub textures: Vec, +} + +impl AtlasDrawBatch { + pub fn is_empty(&self) -> bool { + self.transforms.is_empty() + } +} + impl TileTextureCache { pub fn new(texture_size: i32, capacity: usize) -> Self { Self { @@ -1615,6 +1665,76 @@ impl TileTextureCache { } } + pub fn build_atlas_draw_batch_for_doc_rects( + &self, + viewbox: &Viewbox, + tile_viewbox: &TileViewbox, + tile_doc_rects: &HashMap, + ) -> AtlasDrawBatch { + let mut transforms = Vec::new(); + let mut textures = Vec::new(); + + let s = viewbox.get_scale(); + let view_doc = viewbox.area; + + for y in tile_viewbox.visible_rect.top()..=tile_viewbox.visible_rect.bottom() { + for x in tile_viewbox.visible_rect.left()..=tile_viewbox.visible_rect.right() { + let tile = Tile(x, y); + + let Some(tile_ref) = self.grid.get(&tile) else { + continue; + }; + + if self.removed.contains(&tile) { + continue; + } + + let doc_rect = tile_doc_rects + .get(&tile) + .copied() + .unwrap_or_else(|| tiles::get_tile_rect(tile, s)); + if doc_rect.is_empty() || !doc_rect.intersects(view_doc) { + continue; + } + + let scos = doc_rect.width() * s / self.tile_size; + let tx = (doc_rect.left + viewbox.pan.x) * s; + let ty = (doc_rect.top + viewbox.pan.y) * s; + + transforms.push(skia::RSXform::new(scos, 0.0, (tx, ty))); + textures.push(tile_ref.rect); + } + } + + // Cached tiles from a previous zoom level use indices outside visible_rect; + // place them via their stored document rect, not the current grid walk above. + for (&tile, tile_ref) in &self.grid { + if tile_viewbox.is_visible(&tile) || self.removed.contains(&tile) { + continue; + } + + let doc_rect = tile_doc_rects + .get(&tile) + .copied() + .unwrap_or_else(|| tiles::get_tile_rect(tile, s)); + if doc_rect.is_empty() || !doc_rect.intersects(view_doc) { + continue; + } + + let tx = (doc_rect.left + viewbox.pan.x) * s; + let ty = (doc_rect.top + viewbox.pan.y) * s; + let scos = doc_rect.width() * s / self.tile_size; + + transforms.push(skia::RSXform::new(scos, 0.0, (tx, ty))); + textures.push(tile_ref.rect); + } + + AtlasDrawBatch { + transforms, + textures, + } + } + pub fn has(&self, tile: Tile) -> bool { self.grid.contains_key(&tile) && !self.removed.contains(&tile) }