From 5f2d7a1ff159ffe2a71fb8fc74ae4595daaad698 Mon Sep 17 00:00:00 2001 From: "alonso.torres" Date: Thu, 8 Oct 2026 09:05:34 +0200 Subject: [PATCH] :bug: Fix issues with auto-width/height --- .../main/data/workspace/drawing/common.cljs | 13 +- .../app/main/data/workspace/wasm_text.cljs | 26 +-- .../shapes/text/text_edition_outline.cljs | 24 ++- .../ui/workspace/shapes/text/v3_editor.cljs | 40 ++-- frontend/src/app/render_wasm/api.cljs | 13 +- frontend/src/app/render_wasm/text_editor.cljs | 1 + frontend/src/app/util/text/geometry.cljs | 56 +++++ .../data/workspace_texts_test.cljs | 86 ++++++++ frontend/test/frontend_tests/runner.cljs | 2 +- render-wasm/src/render.rs | 4 - render-wasm/src/shapes/modifiers.rs | 61 +++++- render-wasm/src/shapes/text.rs | 138 +++++++++++-- render-wasm/src/state/shapes_pool.rs | 11 + render-wasm/src/wasm/text_editor.rs | 191 ++++++++++++++---- 14 files changed, 544 insertions(+), 122 deletions(-) create mode 100644 frontend/src/app/util/text/geometry.cljs diff --git a/frontend/src/app/main/data/workspace/drawing/common.cljs b/frontend/src/app/main/data/workspace/drawing/common.cljs index 930d91a280..c48d23b303 100644 --- a/frontend/src/app/main/data/workspace/drawing/common.cljs +++ b/frontend/src/app/main/data/workspace/drawing/common.cljs @@ -18,6 +18,7 @@ [app.main.data.workspace.shapes :as dwsh] [app.main.data.workspace.undo :as dwu] [app.main.worker :as mw] + [app.util.text.writing-mode :as wm] [beicon.v2.core :as rx] [potok.v2.core :as ptk])) @@ -46,6 +47,15 @@ (assoc :points (grc/rect->points selrect)) (assoc :grow-type :fixed)))) +(defn click-draw-text + "Place the start of an auto-width text at the clicked point." + [shape] + (-> shape + (assoc :height 17 :width 4 :grow-type :auto-width :selrect nil :points nil) + (cts/setup-shape) + (cond-> (wm/vertical-text-content? (:content shape)) + (gsh/transform-shape (ctm/move-modifiers -4 0))))) + (defn clear-drawing ([] (clear-drawing nil)) ([{:keys [preserve-tool?]}] @@ -103,8 +113,7 @@ (click-draw-path) (and click-draw? text?) - (-> (assoc :height 17 :width 4 :grow-type :auto-width) - (cts/setup-shape)) + (click-draw-text) :always (dissoc :initialized? :click-draw?))] diff --git a/frontend/src/app/main/data/workspace/wasm_text.cljs b/frontend/src/app/main/data/workspace/wasm_text.cljs index 3471e9fc6e..c0824a65b5 100644 --- a/frontend/src/app/main/data/workspace/wasm_text.cljs +++ b/frontend/src/app/main/data/workspace/wasm_text.cljs @@ -16,7 +16,6 @@ [app.common.geom.point :as gpt] [app.common.render-wasm.api.select :as wselect] [app.common.types.modifiers :as ctm] - [app.common.types.text.cjk-layout :as cjk] [app.main.data.helpers :as dsh] [app.main.data.workspace :as-alias dw] [app.main.data.workspace.modifiers :as dwm] @@ -25,6 +24,7 @@ [app.main.data.workspace.undo :as dwu] [app.render-wasm.api :as wasm.api] [app.render-wasm.api.fonts :as wasm.fonts] + [app.util.text.geometry :as text.geom] [beicon.v2.core :as rx] [potok.v2.core :as ptk])) @@ -35,25 +35,7 @@ auto-height keeps the physical height as its wrap budget and grows width." [selrect grow-type content dimension] (when (or (= :fixed grow-type) (some? dimension)) - (let [vertical? (cjk/vertical-text-content? content)] - {:width (cond - (= :fixed grow-type) - (:width selrect) - - (and (= :auto-height grow-type) (not vertical?)) - (:width selrect) - - :else - (:width dimension)) - :height (cond - (= :fixed grow-type) - (:height selrect) - - (and (= :auto-height grow-type) vertical?) - (:height selrect) - - :else - (:height dimension))}))) + (text.geom/resolve-size selrect grow-type content dimension))) (defn get-wasm-text-new-size "Computes the new {width, height} for a text shape from WASM text layout. @@ -83,12 +65,12 @@ ([shape] (resize-wasm-text-modifiers shape (:content shape))) - ([{:keys [id points selrect] :as shape} content] + ([{:keys [id selrect] :as shape} content] (when-let [new-size (get-wasm-text-new-size shape content)] (let [width-scale (/ (:width new-size) (:width selrect)) height-scale (/ (:height new-size) (:height selrect)) resize-v (gpt/point width-scale height-scale) - origin (first points)] + origin (text.geom/resize-origin (assoc shape :content content))] {id {:modifiers (ctm/resize-modifiers diff --git a/frontend/src/app/main/ui/workspace/shapes/text/text_edition_outline.cljs b/frontend/src/app/main/ui/workspace/shapes/text/text_edition_outline.cljs index 43d2b492d0..32e2dd4be7 100644 --- a/frontend/src/app/main/ui/workspace/shapes/text/text_edition_outline.cljs +++ b/frontend/src/app/main/ui/workspace/shapes/text/text_edition_outline.cljs @@ -13,6 +13,7 @@ [app.main.refs :as refs] [app.main.store :as st] [app.render-wasm.api :as wasm.api] + [app.util.text.geometry :as text.geom] [rumext.v2 :as mf])) (mf/defc text-edition-outline* @@ -21,14 +22,23 @@ (let [selrect-transform (mf/deref refs/workspace-selrect) [selrect transform] (dsh/get-selrect selrect-transform shape) - ;; While editing, the committed selrect lags the text (geometry is - ;; finalize-only), so measure the live WASM text for the growing axes. - live (wasm.api/get-text-dimensions (:id shape)) - {:keys [width height]} (dwt/merge-live-text-dimensions - (:grow-type shape) selrect live)] + ;; IME previews never enter the store. Subscribe here so only the + ;; outline updates; re-rendering the capture surface aborts real IMEs. + dimension* (mf/use-state nil) + dimension (or @dimension* (wasm.api/get-text-dimensions (:id shape))) + {:keys [x y width height]} + (text.geom/live-rect selrect (:grow-type shape) (:content shape) dimension)] + (mf/use-effect + (mf/deps (:id shape)) + (fn [] + (let [update-bounds! (fn [_] + (reset! dimension* (wasm.api/get-text-dimensions (:id shape))))] + (.addEventListener js/document "penpot:wasm:render" update-bounds!) + (update-bounds! nil) + #(.removeEventListener js/document "penpot:wasm:render" update-bounds!)))) [:rect.main.viewport-selrect - {:x (:x selrect) - :y (:y selrect) + {:x x + :y y :width width :height height :transform transform diff --git a/frontend/src/app/main/ui/workspace/shapes/text/v3_editor.cljs b/frontend/src/app/main/ui/workspace/shapes/text/v3_editor.cljs index 09698b1901..cd8d70a87b 100644 --- a/frontend/src/app/main/ui/workspace/shapes/text/v3_editor.cljs +++ b/frontend/src/app/main/ui/workspace/shapes/text/v3_editor.cljs @@ -23,6 +23,7 @@ [app.render-wasm.text-editor :as text-editor] [app.util.dom :as dom] [app.util.keyboard :as kbd] + [app.util.text.geometry :as text.geom] [app.util.timers :as ts] [cuerdas.core :as str] [rumext.v2 :as mf])) @@ -380,34 +381,33 @@ (font-family-from-font-id (:font-id font))) fallback-fonts) [{:keys [x y width height ime-width ime-left]} transform] - (let [{:keys [width height]} (wasm.api/get-text-dimensions shape-id) + (let [dimension (wasm.api/get-text-dimensions shape-id) selrect-transform (mf/deref refs/workspace-selrect) vbox (mf/deref refs/vbox) [selrect transform] (dsh/get-selrect selrect-transform shape) - selrect-height (:height selrect) - selrect-width (:width selrect) - max-width (max width selrect-width) - max-height (max height selrect-height) - ;; During auto-width editing the shape width is trimmed to the content, so an - ;; empty text box ends up only a few pixels wide. That is not enough room for - ;; the caret and the contenteditable overlay may fail to receive input when it - ;; is that small. Expand the overlay by one viewport width for auto-width texts - ;; (mirroring the v2 editor) so typing works and the caret is not clipped. + live-rect (text.geom/live-rect selrect (:grow-type shape) (:content shape) dimension) + ;; Reserve room for composition without changing the capture DOM + ;; mid-IME. Vertical columns grow leftward; their inline axis is y. + auto-width? (= (:grow-type shape) :auto-width) + auto-grow? (not= (:grow-type shape) :fixed) viewport-width (or (:width vbox) 0) - overlay-width (if (= (:grow-type shape) :auto-width) - (+ max-width viewport-width) - max-width) + viewport-height (or (:height vbox) 0) + left (min (:x selrect) (:x live-rect)) + top (min (:y selrect) (:y live-rect)) + right (max (+ (:x selrect) (:width selrect)) + (+ (:x live-rect) (:width live-rect))) + bottom (max (+ (:y selrect) (:height selrect)) + (+ (:y live-rect) (:height live-rect))) + x (if (and vertical? auto-grow?) (- left viewport-width) left) + overlay-width (+ (- right x) (if (and (not vertical?) auto-width?) viewport-width 0)) + overlay-height (+ (- bottom top) + (if (if vertical? auto-width? auto-grow?) viewport-height 0)) ime-padding (ime-overlay-padding vertical? viewport-width) - valign (-> shape :content :vertical-align) - y (:y selrect) - y (case valign - "bottom" (+ y (- selrect-height height)) - "center" (+ y (/ (- selrect-height height) 2)) - y)] + y top] ;; Widen the foreignObject (not the clip) toward the IME anchor, or ;; the browser scrolls to reveal the caret and cancels the offset. [(assoc selrect - :y y :width overlay-width :height max-height + :x x :y y :width overlay-width :height overlay-height :ime-width (+ overlay-width (:right ime-padding)) :ime-left (:left ime-padding)) transform]) diff --git a/frontend/src/app/render_wasm/api.cljs b/frontend/src/app/render_wasm/api.cljs index b187816fa8..67021b3019 100644 --- a/frontend/src/app/render_wasm/api.cljs +++ b/frontend/src/app/render_wasm/api.cljs @@ -60,6 +60,7 @@ [app.util.i18n :as i18n :refer [tr]] [app.util.modules :as mod] [app.util.text.content :as tc] + [app.util.text.geometry :as text.geom] [app.util.timers :as timers] [beicon.v2.core :as rx] [cuerdas.core :as str] @@ -1705,13 +1706,11 @@ (defn- text-selrect-stale? "Check if the WASM-measured dimensions of an auto-grow text shape differ from its stored selrect (same 0.1px tolerance as the classic renderer)." - [{:keys [id selrect grow-type]}] - (when-let [{:keys [width height]} (get-text-dimensions id)] - (case grow-type - :auto-width (or (not (mth/close? width (:width selrect) 0.1)) - (not (mth/close? height (:height selrect) 0.1))) - :auto-height (not (mth/close? height (:height selrect) 0.1)) - false))) + [{:keys [id selrect grow-type content]}] + (when-let [dimension (get-text-dimensions id)] + (let [{:keys [width height]} (text.geom/resolve-size selrect grow-type content dimension)] + (or (not (mth/close? width (:width selrect) 0.1)) + (not (mth/close? height (:height selrect) 0.1)))))) (defonce ^:private pending-stale-selrect-ids (atom #{})) (defonce ^:private stale-selrect-sync-token (atom 0)) diff --git a/frontend/src/app/render_wasm/text_editor.cljs b/frontend/src/app/render_wasm/text_editor.cljs index b95dc91b42..c0af201b4d 100644 --- a/frontend/src/app/render_wasm/text_editor.cljs +++ b/frontend/src/app/render_wasm/text_editor.cljs @@ -720,6 +720,7 @@ (when (and before after) (normalize-selection {:anchor-para (:para before) :anchor-offset (:offset before) :focus-para (:para after) :focus-offset (:offset after)}))) + (defn- para-char-count [para] (apply + (map (fn [span] (count (:text span))) (:children para)))) diff --git a/frontend/src/app/util/text/geometry.cljs b/frontend/src/app/util/text/geometry.cljs new file mode 100644 index 0000000000..2ff6ed4bac --- /dev/null +++ b/frontend/src/app/util/text/geometry.cljs @@ -0,0 +1,56 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS SUBSIDIARY SL + +(ns app.util.text.geometry + (:require + [app.common.geom.point :as gpt] + [app.common.types.text.cjk-layout :as cjk])) + +(defn- live-axis + "Measured axis, or the committed one when the measure is missing or not + positive, so a failed WASM measure never shows as `0`." + [measured committed] + (if (and (number? measured) (pos? measured)) measured committed)) + +(defn resolve-size + "Auto-height grows along the block axis: height horizontally, width vertically." + [selrect grow-type content dimension] + (when (or (= :fixed grow-type) (some? dimension)) + (let [vertical? (cjk/vertical-text-content? content)] + {:width (if (or (= :fixed grow-type) + (and (= :auto-height grow-type) (not vertical?))) + (:width selrect) + (live-axis (:width dimension) (:width selrect))) + :height (if (or (= :fixed grow-type) + (and (= :auto-height grow-type) vertical?)) + (:height selrect) + (live-axis (:height dimension) (:height selrect)))}))) + +(defn- block-anchor + [content] + (if (cjk/vertical-text-content? content) + (case (:vertical-align content) "center" 0.5 "bottom" 0 1) + (case (:vertical-align content) "center" 0.5 "bottom" 1 0))) + +(defn resize-origin + "Keep the text's block alignment anchored when its measured size changes." + [{:keys [points content]}] + (gpt/lerp (nth points 0) + (nth points (if (cjk/vertical-text-content? content) 1 3)) + (block-anchor content))) + +(defn live-rect + "Bounds of live editor content, before its geometry is committed." + [selrect grow-type content dimension] + (if-let [{:keys [width height]} (resolve-size selrect grow-type content dimension)] + (let [vertical? (cjk/vertical-text-content? content) + anchor (block-anchor content)] + (assoc selrect + :x (+ (:x selrect) (if vertical? (* anchor (- (:width selrect) width)) 0)) + :y (+ (:y selrect) (if vertical? 0 (* anchor (- (:height selrect) height)))) + :width width + :height height)) + selrect)) diff --git a/frontend/test/frontend_tests/data/workspace_texts_test.cljs b/frontend/test/frontend_tests/data/workspace_texts_test.cljs index 67e840fbde..8167f4aa48 100644 --- a/frontend/test/frontend_tests/data/workspace_texts_test.cljs +++ b/frontend/test/frontend_tests/data/workspace_texts_test.cljs @@ -8,12 +8,14 @@ (:require [app.common.geom.point :as gpt] [app.common.geom.rect :as grc] + [app.common.geom.shapes :as gsh] [app.common.test-helpers.files :as cthf] [app.common.test-helpers.shapes :as cths] [app.common.types.modifiers :as ctm] [app.common.types.shape :as cts] [app.common.types.text :as txt] [app.common.uuid :as uuid] + [app.main.data.workspace.drawing.common :as drawing] [app.main.data.workspace.modifiers :as dwm] [app.main.data.workspace.shapes :as dwsh] [app.main.data.workspace.texts :as dwt] @@ -21,6 +23,7 @@ [app.main.data.workspace.wasm-text :as dwwt] [app.main.ui.shapes.text.styles :as text.styles] [app.main.ui.workspace.shapes.text.viewport-texts-html :as vth] + [app.util.text.geometry :as text.geom] [beicon.v2.core :as rx] [cljs.test :as t :include-macros true] [frontend-tests.helpers.state :as ths] @@ -44,6 +47,89 @@ (t/is (= {:width 100 :height 360} (dwwt/resolve-text-size selrect :auto-height content dimension))))) +(t/deftest vertical-auto-grow-keeps-the-block-anchor + (doseq [[align expected-x] [["top" -90] ["center" -40] ["bottom" 10]]] + (let [shape (cts/setup-shape + {:type :text :x 10 :y 20 :width 100 :height 200 + :grow-type :auto-height + :content (assoc (text-content "vertical-rl") :vertical-align align)}) + resized (with-redefs [dwwt/get-wasm-text-new-size + (fn ([_] {:width 200 :height 200}) + ([_ _] {:width 200 :height 200}))] + (let [modifiers (dwwt/resize-wasm-text-modifiers shape)] + (gsh/transform-shape shape (get-in modifiers [(:id shape) :modifiers]))))] + (t/is (= expected-x (:x resized)) align) + (t/is (= 200 (:height resized)) align)))) + +(t/deftest live-vertical-bounds-grow-left-and-keep-the-wrap-height + (let [selrect {:x 10 :y 20 :width 100 :height 200} + content (text-content "vertical-rl")] + (t/is (= {:x -130 :y 20 :width 240 :height 200} + (text.geom/live-rect selrect :auto-height content {:width 240 :height 360}))) + (t/is (= {:x -130 :y 20 :width 240 :height 360} + (text.geom/live-rect selrect :auto-width content {:width 240 :height 360}))) + (t/is (= selrect (text.geom/live-rect selrect :fixed content {:width 240 :height 360}))) + (t/is (= selrect (text.geom/live-rect selrect :auto-width content nil))))) + +(t/deftest live-horizontal-bounds-grow-down-and-keep-the-wrap-width + (let [selrect {:x 10 :y 20 :width 100 :height 200} + content (text-content "horizontal-tb")] + (t/is (= {:x 10 :y 20 :width 100 :height 360} + (text.geom/live-rect selrect :auto-height content {:width 240 :height 360}))) + (t/is (= {:x 10 :y 20 :width 240 :height 360} + (text.geom/live-rect selrect :auto-width content {:width 240 :height 360}))))) + +(t/deftest live-bounds-keep-committed-axes-when-the-measure-is-missing + (let [selrect {:x 10 :y 20 :width 100 :height 200} + content (text-content "horizontal-tb")] + (t/is (= selrect (text.geom/live-rect selrect :auto-width content {:width 0 :height 0}))) + (t/is (= selrect (text.geom/live-rect selrect :auto-width content {:width nil :height nil}))) + (t/is (= {:x 10 :y 20 :width 100 :height 360} + (text.geom/live-rect selrect :auto-width content {:width 0 :height 360}))))) + +(t/deftest vertical-auto-grow-keeps-the-anchor-after-rotation + (let [base (cts/setup-shape + {:type :text :x 10 :y 20 :width 100 :height 200 + :grow-type :auto-height :content (text-content "vertical-rl")}) + shape (gsh/transform-shape base (ctm/rotation-modifiers base (gpt/point 60 120) 45)) + resized (with-redefs [dwwt/get-wasm-text-new-size + (fn ([_] {:width 200 :height 200}) + ([_ _] {:width 200 :height 200}))] + (let [modifiers (dwwt/resize-wasm-text-modifiers shape)] + (gsh/transform-shape shape (get-in modifiers [(:id shape) :modifiers]))))] + (t/is (gpt/almost-zero? (gpt/subtract (nth (:points shape) 1) (nth (:points resized) 1)))))) + +(t/deftest clicking-vertical-text-anchors-its-right-edge-at-the-click + (with-redefs [cts/wasm-enabled? true] + (let [shape (drawing/click-draw-text + (cts/setup-shape {:type :text :x 100 :y 50 :width 1 :height 1 + :content (text-content "vertical-rl")}))] + (t/is (= 100 (+ (:x shape) (:width shape)))) + (t/is (= 50 (:y shape))) + (t/is (= [4 17] [(:width shape) (:height shape)])) + (t/is (= :auto-width (:grow-type shape)))))) + +(t/deftest clicking-horizontal-text-keeps-its-left-edge-at-the-click + (let [shape (drawing/click-draw-text + (cts/setup-shape {:type :text :x 100 :y 50 :width 1 :height 1 + :content (text-content "horizontal-tb")}))] + (t/is (= 100 (:x shape))) + (t/is (= 50 (:y shape))) + (t/is (= [4 17] [(:width shape) (:height shape)])))) + +(t/deftest horizontal-auto-grow-keeps-the-block-anchor + (doseq [[align expected-y] [["top" 20] ["center" -60] ["bottom" -140]]] + (let [shape (cts/setup-shape + {:type :text :x 10 :y 20 :width 100 :height 200 + :grow-type :auto-height + :content (assoc (text-content "horizontal-tb") :vertical-align align)}) + resized (with-redefs [dwwt/get-wasm-text-new-size + (fn ([_] {:width 100 :height 360}) + ([_ _] {:width 100 :height 360}))] + (let [modifiers (dwwt/resize-wasm-text-modifiers shape)] + (gsh/transform-shape shape (get-in modifiers [(:id shape) :modifiers]))))] + (t/is (= expected-y (:y resized)) align)))) + (t/deftest vertical-grow-type-resize-axes-are-remapped (t/is (= :auto-height (dwm/next-grow-type :auto-width (gpt/point 1 2) true))) diff --git a/frontend/test/frontend_tests/runner.cljs b/frontend/test/frontend_tests/runner.cljs index 19ee5bfd54..2600f87571 100644 --- a/frontend/test/frontend_tests/runner.cljs +++ b/frontend/test/frontend_tests/runner.cljs @@ -94,8 +94,8 @@ [frontend-tests.render-wasm.text-editor-caret-color-test] [frontend-tests.render-wasm.text-editor-content-range-test] [frontend-tests.render-wasm.text-paste-test] - [frontend-tests.render-wasm.webgl-test] [frontend-tests.render-wasm.texts-test] + [frontend-tests.render-wasm.webgl-test] [frontend-tests.router-test] [frontend-tests.svg-fills-test] [frontend-tests.svg-filters-test] diff --git a/render-wasm/src/render.rs b/render-wasm/src/render.rs index 4dfb4e6ba7..9b63239d64 100644 --- a/render-wasm/src/render.rs +++ b/render-wasm/src/render.rs @@ -4919,10 +4919,6 @@ impl RenderState { (self.viewbox.zoom - self.cached_viewbox.zoom).abs() > f32::EPSILON } - pub fn mark_touched(&mut self, uuid: Uuid) { - self.mark_touched_with_prev(uuid, None); - } - pub fn mark_touched_with_prev(&mut self, uuid: Uuid, prev_extrect: Option) { if self.touched_ids.insert(uuid) { if let Some(rect) = prev_extrect.filter(|r| !r.is_empty()) { diff --git a/render-wasm/src/shapes/modifiers.rs b/render-wasm/src/shapes/modifiers.rs index 78d17085ea..3076ef017f 100644 --- a/render-wasm/src/shapes/modifiers.rs +++ b/render-wasm/src/shapes/modifiers.rs @@ -13,7 +13,7 @@ use crate::error::Result; use crate::shapes; use crate::shapes::{ ConstraintH, ConstraintV, Frame, Group, GrowType, Layout, Modifier, PixelPrecision, Shape, - TransformEntry, TransformEntrySource, Type, + TransformEntry, TransformEntrySource, Type, VerticalAlign, }; use crate::state::{ShapesPoolRef, State}; use crate::uuid::Uuid; @@ -306,12 +306,25 @@ fn propagate_transform( } } } - let resize_transform = math::resize_matrix( + let mut resize_transform = math::resize_matrix( &shape_bounds_after, &shape_bounds_after, new_width, new_height, ); + if text_content.is_vertical() { + // Reflow adds columns toward the block end. Keep the + // right/center/left anchor set by vertical alignment. + let anchor = match shape.vertical_align() { + VerticalAlign::Top => shape_bounds_after.ne, + VerticalAlign::Center => { + (shape_bounds_after.nw + shape_bounds_after.ne) * 0.5 + } + VerticalAlign::Bottom => shape_bounds_after.nw, + }; + resize_transform + .post_translate(anchor - resize_transform.map_point(anchor)); + } shape_bounds_after = shape_bounds_after.transform(&resize_transform); transform.post_concat(&resize_transform); } @@ -599,6 +612,50 @@ mod tests { use crate::shapes::*; use crate::state::ShapesPool; + #[test] + fn vertical_auto_height_resize_keeps_the_right_edge() { + let mut resources = + crate::render::RenderResources::try_new_headless().expect("headless resources"); + let _guard = crate::globals::TestRenderResourcesGuard::install(&mut resources); + let rect = math::Rect::from_xywh(10.0, 20.0, 100.0, 200.0); + let mut content = TextContent::new(rect, GrowType::AutoHeight); + let mut paragraph = Paragraph::new( + TextAlign::Left, + TextDirection::LTR, + None, + None, + 1.2, + 0.0, + vec![TextSpan { + text: "あいうえおかきくけこ".to_string(), + font_size: 20.0, + ..TextSpan::default() + }], + ); + paragraph.set_writing_mode(WritingMode::VerticalRl); + content.add_paragraph(paragraph); + let size = content.update_layout(rect); + let id = Uuid::new_v4(); + let mut state = State::new(); + state.shapes.initialize(1); + let shape = state.shapes.add_shape(id); + shape.set_selrect(10.0, 20.0, 10.0 + size.width, 220.0); + shape.set_shape_type(Type::Text(content)); + let before = shape.bounds(); + let transform = math::resize_matrix(&before, &before, size.width, 60.0); + let result = propagate_modifiers( + &state, + &[TransformEntry::from_input(id, transform)], + PixelPrecision::Disabled, + ) + .unwrap(); + let transform = result.iter().find(|e| e.id == id).unwrap().transform; + let after = before.transform(&transform); + assert!(after.width() > before.width()); + assert!(is_close_to(after.height(), 60.0)); + assert!(is_close_to(after.ne.x, before.ne.x)); + } + #[test] fn test_propagate_shape() { let parent_id = Uuid::new_v4(); diff --git a/render-wasm/src/shapes/text.rs b/render-wasm/src/shapes/text.rs index f8daba1624..20f484f4ed 100644 --- a/render-wasm/src/shapes/text.rs +++ b/render-wasm/src/shapes/text.rs @@ -47,9 +47,15 @@ pub fn modifier_changes_text_layout(base: &Shape, modifier: &Matrix) -> bool { let after = before.transform(modifier); match text_content.grow_type() { GrowType::AutoWidth => !crate::math::is_close_to(before.height(), after.height()), - GrowType::AutoHeight | GrowType::Fixed => { - !crate::math::is_close_to(before.width(), after.width()) + GrowType::AutoHeight if text_content.is_vertical() => { + !crate::math::is_close_to(before.height(), after.height()) } + GrowType::AutoHeight => !crate::math::is_close_to(before.width(), after.width()), + GrowType::Fixed if text_content.is_vertical() => { + !crate::math::is_close_to(before.width(), after.width()) + || !crate::math::is_close_to(before.height(), after.height()) + } + GrowType::Fixed => !crate::math::is_close_to(before.width(), after.width()), } } @@ -487,6 +493,7 @@ pub struct TextContent { content_version: u64, layout_version: u64, layout_width: Option, + layout_height: Option, /// Canvas origin used when absolute fill shaders (image/gradient) were baked /// into cached Skia paragraphs. Kept across move clones so paint can /// translate glyphs + shaders together. See `cached_layout_paint_offset`. @@ -518,6 +525,7 @@ impl TextContent { content_version: 0, layout_version: 0, layout_width: None, + layout_height: None, layout_paint_origin: None, layout_paint_size: None, } @@ -536,6 +544,7 @@ impl TextContent { content_version: 0, layout_version: 0, layout_width: None, + layout_height: None, layout_paint_origin: None, layout_paint_size: None, } @@ -1009,15 +1018,16 @@ impl TextContent { } pub fn content_rect(&self, selrect: &Rect, valign: VerticalAlign) -> Rect { - // Vertical content anchors to the shape's right edge and always - // aligns to the top; vertical-align does not apply. + // Auto-grow bounds follow the same block anchor as the vertical paint. if self.is_vertical() { - let (width, height) = if self.grow_type() == GrowType::AutoWidth { - (self.size.width, self.size.height) - } else { - (selrect.width(), selrect.height()) + let (width, height) = match self.grow_type() { + GrowType::AutoWidth => (self.size.width, self.size.height), + GrowType::AutoHeight => (self.size.width, selrect.height()), + GrowType::Fixed => (selrect.width(), selrect.height()), }; - return Rect::from_xywh(selrect.right() - width, selrect.y(), width, height); + let x = selrect.x() + + super::text_vertical::block_axis_offset(selrect.width(), width, valign); + return Rect::from_xywh(x, selrect.y(), width, height); } let x = selrect.x(); @@ -1563,6 +1573,7 @@ impl TextContent { pub fn force_next_layout_update(&mut self) { self.layout_width = None; + self.layout_height = None; self.layout_paint_origin = None; self.layout_paint_size = None; self.layout.cached_extrect.set(None); @@ -1577,11 +1588,20 @@ impl TextContent { // match the container we are laying out for. self.set_xywh(selrect.x(), selrect.y(), selrect.width(), selrect.height()); - // Auto-width ignores selrect width so get-text-dimensions can reuse the cached layout. - let layout_matches_container = self.grow_type() == GrowType::AutoWidth - || self - .layout_width - .is_some_and(|w| (w - selrect.width()).abs() < f32::EPSILON); + // Vertical columns wrap by height, horizontal lines by width. + let width_matches = self + .layout_width + .is_some_and(|w| (w - selrect.width()).abs() < f32::EPSILON); + let height_matches = self + .layout_height + .is_some_and(|h| (h - selrect.height()).abs() < f32::EPSILON); + let layout_matches_container = match self.grow_type() { + GrowType::AutoWidth => true, + GrowType::AutoHeight if self.is_vertical() => height_matches, + GrowType::AutoHeight => width_matches, + GrowType::Fixed if self.is_vertical() => width_matches && height_matches, + GrowType::Fixed => width_matches, + }; if !self.layout.needs_update() && self.layout_version == self.content_version @@ -1592,6 +1612,7 @@ impl TextContent { } self.size.set_size(selrect.width(), selrect.height()); + self.layout_height = Some(selrect.height()); if self.is_vertical() { // Vertical writing takes sizes from the vertical pass, so the @@ -1641,11 +1662,15 @@ impl TextContent { } } - if self.is_empty() { + if self.is_empty() && self.grow_type() != GrowType::Fixed { let (placeholder_width, placeholder_height) = self.placeholder_dimensions(selrect); - self.size.width = placeholder_width; - self.size.height = placeholder_height; - self.size.max_width = placeholder_width; + if self.grow_type() == GrowType::AutoWidth || self.is_vertical() { + self.size.width = placeholder_width; + self.size.max_width = placeholder_width; + } + if self.grow_type() == GrowType::AutoWidth || !self.is_vertical() { + self.size.height = placeholder_height; + } } self.layout_version = self.content_version; @@ -1683,6 +1708,18 @@ impl TextContent { /// If that fails we fall back to the previous WASM size or the incoming /// selrect dimensions. fn placeholder_dimensions(&self, selrect: Rect) -> (f32, f32) { + if self.is_vertical() { + let layout = self.vertical_layout(&selrect); + let font_size = self + .paragraphs + .first() + .and_then(|p| p.children().first()) + .map_or(14.0, |s| s.font_size); + return ( + layout.width.ceil().max(DEFAULT_TEXT_CONTENT_SIZE), + font_size, + ); + } if let Some(paragraph) = self.paragraphs.first() { if let Some(span) = paragraph.children().first() { let fonts = get_font_collection(); @@ -1799,6 +1836,7 @@ impl Default for TextContent { content_version: 0, layout_version: 0, layout_width: None, + layout_height: None, layout_paint_origin: None, layout_paint_size: None, } @@ -2745,6 +2783,67 @@ mod tests { use crate::globals::TestRenderResourcesGuard; use crate::render::RenderResources; + fn vertical_content(text: &str, grow_type: GrowType) -> TextContent { + let mut content = TextContent::new(Rect::from_xywh(10.0, 20.0, 100.0, 200.0), grow_type); + let mut paragraph = test_paragraph(&[text]); + paragraph.set_writing_mode(WritingMode::VerticalRl); + paragraph.children_mut()[0].font_size = 20.0; + content.add_paragraph(paragraph); + content + } + + #[test] + fn vertical_auto_height_reflows_when_only_height_changes() { + let mut resources = + crate::render::RenderResources::try_new_headless().expect("headless resources"); + let _guard = crate::globals::TestRenderResourcesGuard::install(&mut resources); + let mut content = vertical_content("あいうえおかきくけこ", GrowType::AutoHeight); + let tall = content.update_layout(Rect::from_xywh(10.0, 20.0, 100.0, 240.0)); + let short = content.update_layout(Rect::from_xywh(10.0, 20.0, 100.0, 60.0)); + assert!(short.width > tall.width); + assert_eq!(short.height, 60.0); + let restored = content.update_layout(Rect::from_xywh(10.0, 20.0, 100.0, 240.0)); + assert_eq!(restored.width, tall.width); + } + + #[test] + fn vertical_height_modifier_requires_layout() { + let mut resources = + crate::render::RenderResources::try_new_headless().expect("headless resources"); + let _guard = crate::globals::TestRenderResourcesGuard::install(&mut resources); + let content = vertical_content("あいうえお", GrowType::AutoHeight); + let shape = text_shape_with_cached_layout(content); + assert!(modifier_changes_text_layout( + &shape, + &Matrix::scale((1.0, 0.5)) + )); + } + + #[test] + fn empty_auto_height_preserves_the_wrap_budget() { + let mut resources = + crate::render::RenderResources::try_new_headless().expect("headless resources"); + let _guard = crate::globals::TestRenderResourcesGuard::install(&mut resources); + let mut vertical = vertical_content("", GrowType::AutoHeight); + let size = vertical.update_layout(Rect::from_xywh(10.0, 20.0, 100.0, 200.0)); + assert_eq!(size.height, 200.0); + assert!(size.width >= 20.0); + let mut horizontal = vertical_content("", GrowType::AutoHeight); + horizontal.paragraphs_mut()[0].set_writing_mode(WritingMode::HorizontalTb); + let size = horizontal.update_layout(Rect::from_xywh(10.0, 20.0, 100.0, 200.0)); + assert_eq!(size.width, 100.0); + } + + #[test] + fn empty_fixed_text_preserves_its_container() { + let mut resources = + crate::render::RenderResources::try_new_headless().expect("headless resources"); + let _guard = crate::globals::TestRenderResourcesGuard::install(&mut resources); + let mut content = vertical_content("", GrowType::Fixed); + let size = content.update_layout(Rect::from_xywh(10.0, 20.0, 100.0, 200.0)); + assert_eq!((size.width, size.height), (100.0, 200.0)); + } + #[test] fn a_warichu_span_shows_no_ruby_and_reserves_no_room_for_it() { let span = TextSpan { @@ -3080,6 +3179,9 @@ mod tests { #[test] fn has_usable_paint_layout_false_when_rotated_and_resized() { + let mut resources = + crate::render::RenderResources::try_new_headless().expect("headless resources"); + let _guard = crate::globals::TestRenderResourcesGuard::install(&mut resources); let mut content = sample_text_content(); content.layout.paragraphs = Rc::new(vec![vec![]]); content.layout_width = Some(200.0); diff --git a/render-wasm/src/state/shapes_pool.rs b/render-wasm/src/state/shapes_pool.rs index c877399250..1ce2264558 100644 --- a/render-wasm/src/state/shapes_pool.rs +++ b/render-wasm/src/state/shapes_pool.rs @@ -270,6 +270,17 @@ impl ShapesPoolImpl { }) } + /// Drop both the base bounds and any transformed snapshot after a direct edit. + pub fn invalidate_shape_extrect(&mut self, id: &Uuid) { + let Some(idx) = self.uuid_to_idx.get(id).copied() else { + return; + }; + self.shapes[idx].invalidate_extrect(); + if let Some(cell) = self.modified_shape_cache.get_mut(&idx) { + *cell = OnceCell::new(); + } + } + /// Drops the extrect cache of every ancestor whose extrect is affected by this shape /// stopping at the first ancestor that clips. pub fn invalidate_ancestors_extrect(&mut self, id: &Uuid) { diff --git a/render-wasm/src/wasm/text_editor.rs b/render-wasm/src/wasm/text_editor.rs index 34835103d9..a16df85d13 100644 --- a/render-wasm/src/wasm/text_editor.rs +++ b/render-wasm/src/wasm/text_editor.rs @@ -396,6 +396,21 @@ pub extern "C" fn text_editor_set_cursor_from_point(x: f32, y: f32) { // TEXT OPERATIONS // ============================================================================ +fn text_content_for_edit(state: &mut State, shape_id: Uuid) -> Option<&mut TextContent> { + if !matches!(state.shapes.get_raw(&shape_id)?.shape_type, Type::Text(_)) { + return None; + } + // Capture old tile coverage before changing the preview, then drop the + // shape snapshot and bounds so new glyphs and erased previews get indexed. + state.touch_shape(shape_id); + state.shapes.invalidate_shape_extrect(&shape_id); + let shape = state.shapes.get_mut(&shape_id)?; + match &mut shape.shape_type { + Type::Text(content) => Some(content), + _ => None, + } +} + #[no_mangle] #[wasm_error] pub extern "C" fn text_editor_composition_start() -> Result<()> { @@ -424,11 +439,7 @@ pub extern "C" fn text_editor_composition_end() -> Result<()> { return Ok(()); }; - let Some(shape) = state.shapes.get_mut(&shape_id) else { - return Ok(()); - }; - - let Type::Text(text_content) = &mut shape.shape_type else { + let Some(text_content) = text_content_for_edit(state, shape_id) else { return Ok(()); }; @@ -457,8 +468,6 @@ pub extern "C" fn text_editor_composition_end() -> Result<()> { get_text_editor_state().push_event(crate::state::TextEditorEvent::ContentChanged); get_text_editor_state().push_event(crate::state::TextEditorEvent::NeedsLayout); - get_render_state().mark_touched(shape_id); - get_text_editor_state().composition.end(); }); @@ -484,11 +493,7 @@ pub extern "C" fn text_editor_composition_update() -> Result<()> { return Ok(()); }; - let Some(shape) = state.shapes.get_mut(&shape_id) else { - return Ok(()); - }; - - let Type::Text(text_content) = &mut shape.shape_type else { + let Some(text_content) = text_content_for_edit(state, shape_id) else { return Ok(()); }; @@ -512,8 +517,6 @@ pub extern "C" fn text_editor_composition_update() -> Result<()> { get_text_editor_state().reset_blink(); get_text_editor_state().push_event(crate::state::TextEditorEvent::ContentChanged); get_text_editor_state().push_event(crate::state::TextEditorEvent::NeedsLayout); - - get_render_state().mark_touched(shape_id); }); crate::mem::free_bytes()?; @@ -568,11 +571,7 @@ pub extern "C" fn text_editor_insert_text() -> Result<()> { return Ok(()); }; - let Some(shape) = state.shapes.get_mut(&shape_id) else { - return Ok(()); - }; - - let Type::Text(text_content) = &mut shape.shape_type else { + let Some(text_content) = text_content_for_edit(state, shape_id) else { return Ok(()); }; @@ -602,8 +601,6 @@ pub extern "C" fn text_editor_insert_text() -> Result<()> { get_text_editor_state().reset_blink(); get_text_editor_state().push_event(TextEditorEvent::ContentChanged); get_text_editor_state().push_event(TextEditorEvent::NeedsLayout); - - get_render_state().mark_touched(shape_id); }); crate::mem::free_bytes()?; @@ -621,16 +618,11 @@ pub extern "C" fn text_editor_delete_backward(word_boundary: bool) { return; }; - let Some(shape) = state.shapes.get_mut(&shape_id) else { - return; - }; - - let Type::Text(text_content) = &mut shape.shape_type else { + let Some(text_content) = text_content_for_edit(state, shape_id) else { return; }; get_text_editor_state().delete_backward(text_content, word_boundary); - get_render_state().mark_touched(shape_id); }); } @@ -645,16 +637,11 @@ pub extern "C" fn text_editor_delete_forward(word_boundary: bool) { return; }; - let Some(shape) = state.shapes.get_mut(&shape_id) else { - return; - }; - - let Type::Text(text_content) = &mut shape.shape_type else { + let Some(text_content) = text_content_for_edit(state, shape_id) else { return; }; get_text_editor_state().delete_forward(text_content, word_boundary); - get_render_state().mark_touched(shape_id); }); } @@ -669,16 +656,11 @@ pub extern "C" fn text_editor_insert_paragraph() { return; }; - let Some(shape) = state.shapes.get_mut(&shape_id) else { - return; - }; - - let Type::Text(text_content) = &mut shape.shape_type else { + let Some(text_content) = text_content_for_edit(state, shape_id) else { return; }; get_text_editor_state().insert_paragraph(text_content); - get_render_state().mark_touched(shape_id); }); } @@ -1188,3 +1170,134 @@ pub extern "C" fn text_get_range_origin( Ok(true) }) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::shapes::{Group, GrowType, Paragraph, TextDirection, TextSpan, WritingMode}; + use crate::tiles; + + fn preview_state(grow_type: GrowType, writing_mode: WritingMode) -> (State, Uuid) { + let id = Uuid::new_v4(); + let mut state = State::new(); + let shape = state.shapes.add_shape(id); + shape.set_selrect(250.0, 250.0, 274.0, 310.0); + let mut content = TextContent::new(shape.selrect(), grow_type); + let span = TextSpan { + text: "あ".to_string(), + font_size: 20.0, + ..TextSpan::default() + }; + let mut paragraph = Paragraph::new( + TextAlign::Left, + TextDirection::LTR, + None, + None, + 1.2, + 0.0, + vec![span], + ); + paragraph.set_writing_mode(writing_mode); + content.add_paragraph(paragraph); + content.update_layout(shape.selrect()); + shape.set_shape_type(Type::Text(content)); + (state, id) + } + + fn replace_preview(state: &mut State, id: Uuid, editor: &mut TextEditorState, text: &str) { + let content = text_content_for_edit(state, id).expect("text shape"); + editor.composition.update(text); + let selection = editor.composition.get_selection(&editor.selection); + text_helpers::delete_selection_range(content, &selection); + let cursor = selection.start(); + editor.composition.start = Some(cursor); + let caret = + text_helpers::insert_text_with_newlines(content, &cursor, text).unwrap_or(cursor); + editor.selection.set_caret(caret); + content.layout.clear(); + update_text_layout_if_needed(state, id); + } + + #[test] + fn composition_preview_reindexes_tiles_as_text_grows_and_shrinks() { + let mut resources = crate::render::RenderResources::try_new_headless().expect("resources"); + let _guard = crate::globals::TestRenderResourcesGuard::install(&mut resources); + let long_preview = "あいうえおかきくけこさしすせそたちつてとなにぬねの".repeat(4); + for grow_type in [GrowType::AutoWidth, GrowType::AutoHeight] { + for writing_mode in [WritingMode::HorizontalTb, WritingMode::VerticalRl] { + let (mut state, id) = preview_state(grow_type, writing_mode); + let original = state.shapes.get(&id).unwrap().extrect(&state.shapes, 1.0); + let mut editor = TextEditorState::new(); + editor.composition.start(); + for text in [long_preview.as_str(), "日本語", ""] { + replace_preview(&mut state, id, &mut editor, text); + let shape = state.shapes.get(&id).unwrap(); + let mut uncached = shape.clone(); + uncached.invalidate_extrect(); + let expected = uncached.extrect(&state.shapes, 1.0); + let actual = shape.extrect(&state.shapes, 1.0); + assert_eq!( + actual, expected, + "{grow_type:?} {writing_mode:?} preview {text}" + ); + let current_tiles = tiles::get_tiles_for_rect(actual, tiles::TILE_SIZE); + let original_tiles = tiles::get_tiles_for_rect(original, tiles::TILE_SIZE); + if text.chars().count() > 20 { + assert_ne!(current_tiles, original_tiles, "preview reaches new tiles"); + } else if text.is_empty() { + assert_eq!( + current_tiles, original_tiles, + "cancellation restores coverage" + ); + } + } + assert_eq!( + state.shapes.get(&id).unwrap().extrect(&state.shapes, 1.0), + original + ); + } + } + } + + #[test] + fn composition_preview_updates_cached_group_bounds() { + let mut resources = crate::render::RenderResources::try_new_headless().expect("resources"); + let _guard = crate::globals::TestRenderResourcesGuard::install(&mut resources); + let (mut state, id) = preview_state(GrowType::AutoWidth, WritingMode::HorizontalTb); + let parent_id = Uuid::new_v4(); + let parent = state.shapes.add_shape(parent_id); + parent.set_shape_type(Type::Group(Group::default())); + parent.clip_content = false; + parent.children.push(id); + state.shapes.get_mut(&id).unwrap().set_parent(parent_id); + let original = state + .shapes + .get(&parent_id) + .unwrap() + .extrect(&state.shapes, 1.0); + let mut editor = TextEditorState::new(); + editor.composition.start(); + replace_preview(&mut state, id, &mut editor, "あいうえおかきくけこ"); + let parent = state.shapes.get(&parent_id).unwrap(); + assert!(parent.extrect(&state.shapes, 1.0).right() > original.right()); + } + + #[test] + fn composition_preview_updates_modified_shape_snapshot() { + let mut resources = crate::render::RenderResources::try_new_headless().expect("resources"); + let _guard = crate::globals::TestRenderResourcesGuard::install(&mut resources); + let (mut state, id) = preview_state(GrowType::AutoWidth, WritingMode::HorizontalTb); + state + .shapes + .set_modifiers(std::collections::HashMap::from([( + id, + Matrix::translate((20.0, 30.0)), + )])); + let original = state.shapes.get(&id).unwrap().extrect(&state.shapes, 1.0); + let mut editor = TextEditorState::new(); + editor.composition.start(); + replace_preview(&mut state, id, &mut editor, "あいうえおかきくけこ"); + let shape = state.shapes.get(&id).unwrap(); + assert!(shape.extrect(&state.shapes, 1.0).right() > original.right()); + } +}