🐛 Fix issues with auto-width/height

This commit is contained in:
alonso.torres 2026-10-08 09:05:34 +02:00
parent 9c8f8df253
commit 5f2d7a1ff1
14 changed files with 544 additions and 122 deletions

View File

@ -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?))]

View File

@ -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

View File

@ -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

View File

@ -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])

View File

@ -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))

View File

@ -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))))

View File

@ -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))

View File

@ -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)))

View File

@ -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]

View File

@ -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<Rect>) {
if self.touched_ids.insert(uuid) {
if let Some(rect) = prev_extrect.filter(|r| !r.is_empty()) {

View File

@ -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();

View File

@ -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<f32>,
layout_height: Option<f32>,
/// 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);

View File

@ -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) {

View File

@ -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());
}
}