Merge remote-tracking branch 'origin/staging' into develop

This commit is contained in:
Alejandro Alonso 2026-05-04 11:56:07 +02:00
commit 164f0cba7a
16 changed files with 367 additions and 82 deletions

View File

@ -67,6 +67,12 @@
(some? undo-group)
(assoc :undo-group undo-group)))
(defn set-translation?
[changes translation?]
(cond-> changes
translation?
(assoc :translation? true)))
(defn with-page
[changes page]
(vary-meta changes assoc

View File

@ -0,0 +1 @@
{"~:features":{"~#set":["layout/grid","styles/v2","fdata/pointer-map","fdata/objects-map","components/v2","fdata/shape-data-type"]},"~:permissions":{"~:type":"~:membership","~:is-owner":true,"~:is-admin":true,"~:can-edit":true,"~:can-read":true,"~:is-logged":true},"~:has-media-trimmed":false,"~:comment-thread-seqn":0,"~:name":"New File 5","~:revn":2,"~:modified-at":"~m1730197748522","~:vern":2,"~:id":"~u406b7b01-d3e2-80e4-8005-3138ac5d449c","~:is-shared":false,"~:version":55,"~:project-id":"~u3ffbd505-2f26-800f-8004-f34da98bdad8","~:created-at":"~m1730197736824","~:data":{"~:pages":["~u406b7b01-d3e2-80e4-8005-3138ac5d449d"],"~:pages-index":{"~u406b7b01-d3e2-80e4-8005-3138ac5d449d":{"~#penpot/pointer":["~u406b7b01-d3e2-80e4-8005-3138b7cc5f0b",{"~:created-at":"~m1730197748531"}]}},"~:id":"~u406b7b01-d3e2-80e4-8005-3138ac5d449c","~:options":{"~:components-v2":true}}}

View File

@ -14,14 +14,16 @@ const setupFile = async (workspacePage) => {
/get\-file\?/,
"workspace/get-file-inspect-tab.json",
);
await workspacePage.mockRPC(
/update\-file\?/,
"workspace/update-file-empty.json",
);
await workspacePage.goToWorkspace({
fileId: "7b2da435-6186-815a-8007-0daa95d2f26d",
pageId: "ce79274b-11ab-8088-8007-0487ad43f789",
});
await workspacePage.mockRPC(
"update-file?id=*",
"workspace/update-file-empty.json",
);
};
const shapeToLayerName = {

View File

@ -87,6 +87,9 @@ test("Save and restore version", async ({ page }) => {
"workspace/versions-restore-snapshot-1.json",
);
await workspacePage.mockRPC(/get\-file\?/, "workspace/versions-init-2.json");
await page.getByRole("button", { name: "Open version menu" }).click();
await page.getByRole("button", { name: "Restore" }).click();
await page.getByRole("button", { name: "Restore" }).click();

View File

@ -124,7 +124,7 @@
"Create a commit event instance"
[{:keys [commit-id redo-changes undo-changes origin save-undo? features
file-id file-revn file-vern undo-group tags stack-undo? source ignore-wasm?
selected-before]}]
selected-before translation?]}]
(assert (cpc/check-changes redo-changes)
"expect valid vector of changes for redo-changes")
@ -151,7 +151,8 @@
:tags tags
:stack-undo? stack-undo?
:ignore-wasm? ignore-wasm?
:selected-before selected-before}]
:selected-before selected-before
:translation? translation?}]
(ptk/reify ::commit
cljs.core/IDeref
@ -189,7 +190,8 @@
- undo-group: if some consecutive changes (or even transactions) share the same
undo-group, they will be undone or redone in a single step
"
[{:keys [redo-changes undo-changes save-undo? undo-group tags stack-undo? file-id]
[{:keys [redo-changes undo-changes save-undo? undo-group tags stack-undo? file-id
translation?]
:or {save-undo? true
stack-undo? false
undo-group (uuid/next)
@ -222,4 +224,5 @@
(assoc :undo-changes uchg)
(assoc :redo-changes rchg)
(assoc :selected-before selected)
(assoc :translation? translation?)
(commit)))))))))

View File

@ -1382,8 +1382,14 @@
check-changes
(fn [[event old-data]]
(if (nil? old-data)
(cond
(nil? old-data)
(rx/empty)
(:translation? event)
(rx/empty)
:else
(let [{:keys [file-id changes save-undo? undo-group]} event
changed-components

View File

@ -659,15 +659,14 @@
snap-pixel?
(and (not ignore-snap-pixel) (contains? (:workspace-layout state) :snap-pixel-grid))]
(set-wasm-props! objects prev-wasm-props wasm-props)
(let [structure-entries (parse-structure-modifiers modif-tree)]
(wasm.api/set-structure-modifiers structure-entries)
(let [geometry-entries (parse-geometry-modifiers modif-tree)
modifiers (wasm.api/propagate-modifiers geometry-entries snap-pixel?)]
(wasm.api/set-modifiers modifiers)
(let [ids (into [] xf:map-key geometry-entries)
selrect (wasm.api/get-selection-rect ids)]
(rx/of (set-temporary-selrect selrect)
(set-temporary-modifiers modifiers)))))))))
(wasm.api/set-structure-modifiers (parse-structure-modifiers modif-tree))
(let [geometry-entries (parse-geometry-modifiers modif-tree)
modifiers (wasm.api/propagate-modifiers geometry-entries snap-pixel?)]
(wasm.api/set-modifiers modifiers)
(let [ids (into [] xf:map-key geometry-entries)
selrect (wasm.api/get-selection-rect ids)]
(rx/of (set-temporary-selrect selrect)
(set-temporary-modifiers modifiers))))))))
(defn propagate-structure-modifiers
[modif-tree objects]
@ -701,8 +700,7 @@
ptk/WatchEvent
(watch [_ state _]
(wasm.api/clean-modifiers)
(let [structure-entries (parse-structure-modifiers modif-tree)]
(wasm.api/set-structure-modifiers structure-entries))
(wasm.api/set-structure-modifiers (parse-structure-modifiers modif-tree))
;; Apply property changes (e.g. grow-type) to WASM shapes before
;; propagating geometry, so propagate_modifiers sees the updated state.
@ -722,6 +720,12 @@
transforms
(into {} (wasm.api/propagate-modifiers geometry-entries snap-pixel?))
;; Pure-translation gesture: every shape's modifier only
;; contains `:move` operations (no resize/rotate/scale and
;; no structural mutation)
translation?
(every? #(ctm/only-move? (:modifiers %)) (vals modif-tree))
ignore-tree
(calculate-ignore-tree-wasm transforms objects)
@ -729,6 +733,7 @@
(-> params
(assoc :reg-objects? true)
(assoc :ignore-tree ignore-tree)
(assoc :translation? translation?)
;; Attributes that can change in the transform. This
;; way we don't have to check all the attributes
(assoc :attrs transform-attrs))

View File

@ -50,7 +50,7 @@
([ids update-fn] (update-shapes ids update-fn nil))
([ids update-fn
{:keys [reg-objects? save-undo? stack-undo? attrs ignore-tree page-id
ignore-touched undo-group with-objects? changed-sub-attr]
ignore-touched undo-group with-objects? changed-sub-attr translation?]
:or {reg-objects? false
save-undo? true
stack-undo? false
@ -90,7 +90,8 @@
:ignore-touched ignore-touched
:with-objects? with-objects?})
(cond-> undo-group
(pcb/set-undo-group undo-group)))
(pcb/set-undo-group undo-group))
(pcb/set-translation? translation?))
changes
(add-undo-group changes state)]

View File

@ -289,7 +289,9 @@
:ref container-ref
:data-testid "text-editor-container"
:style {:width "var(--editor-container-width)"
:height "var(--editor-container-height)"}}
:height "var(--editor-container-height)"
:min-width "1px"
:min-height "1px"}}
;; We hide the editor when is blurred because otherwise the
;; selection won't let us see the underlying text. Use opacity
;; because display or visibility won't allow to recover focus

View File

@ -40,3 +40,4 @@ uuid = { version = "1.11.0", features = ["v4", "js"] }
opt-level = 3
lto = "fat"
strip = true
codegen-units = 1

View File

@ -464,9 +464,6 @@ pub extern "C" fn set_modifiers_start() -> Result<()> {
performance::begin_measure!("set_modifiers_start");
state.render_state.options.set_fast_mode(true);
state.render_state.options.set_interactive_transform(true);
// Capture the last fully-rendered frame as a stable backdrop for the drag.
// This avoids relying on atlas/cache correctness during fast_mode.
state.render_state.surfaces.copy_target_to_backbuffer();
performance::end_measure!("set_modifiers_start");
});
Ok(())

View File

@ -15,8 +15,7 @@ mod ui;
use skia_safe::{self as skia, Matrix, RRect, Rect};
use std::borrow::Cow;
use std::collections::HashMap;
use std::collections::HashSet;
use std::collections::{HashMap, HashSet};
use gpu_state::GpuState;
@ -384,6 +383,15 @@ pub(crate) struct RenderState {
/// interactive backdrop exactly once per gesture (first rAF) so we don't
/// repeatedly overwrite tiles that have already been updated.
pub interactive_target_seeded: bool,
/// GPU crops from `Backbuffer` keyed by shape id. Filled on full-frame completion; during
/// drag, entries for the moved top-level selection are ensured here
pub backbuffer_crop_cache: HashMap<Uuid, InteractiveDragCrop>,
}
pub struct InteractiveDragCrop {
pub src_doc_bounds: Rect,
pub src_selrect: Rect,
pub image: skia::Image,
}
pub fn get_cache_size(viewbox: Viewbox, scale: f32, interest: i32) -> skia::ISize {
@ -403,6 +411,72 @@ pub fn get_cache_size(viewbox: Viewbox, scale: f32, interest: i32) -> skia::ISiz
}
impl RenderState {
/// Decide whether a top-level node can be served from `backbuffer_crop_cache` during an
/// interactive transform (drag/resize/rotate).
///
/// We only reuse cached pixels when it is safe and visually correct:
/// - **Top-level only**: cache entries are built for direct children of the root.
/// - **Moved node**: only allow cache reuse for *pure translations* (no scale/rotate/skew),
/// because other transforms would require resampling and can diverge from the live render.
/// - **Other cached nodes**: if the moving bounds overlap this cached crop, invalidate it so
/// we don't show stale content while something moves over/inside it.
fn should_use_cached_top_level_during_interactive(
&mut self,
node_id: Uuid,
tree: ShapesPoolRef,
moved_ids: &[Uuid],
moved_bounds: Option<Rect>,
) -> bool {
if !self.backbuffer_crop_cache.contains_key(&node_id) {
return false;
}
let Some(raw) = tree.get_raw(&node_id) else {
return false;
};
if raw.parent_id != Some(Uuid::nil()) {
return false;
}
// If this top-level shape itself is being moved, always allow using its cached pixels.
// BUT only for pure translations. For non-translation transforms (scale/rotate/skew),
// cached pixels won't match the live result (and may require resampling), so render live.
if moved_ids.contains(&node_id) {
let Some(m) = tree.get_modifier(&node_id) else {
return false;
};
return crate::math::is_move_only_matrix(m);
}
// Invalidate cached top-level pixels whenever the moving content overlaps
// the cached pixel area. Use `src_doc_bounds` because it's the exact bounds
// captured from the Backbuffer crop (more reliable than extents derived
// from layout/layout-less container heuristics).
if let Some(moved) = moved_bounds {
let intersects = self
.backbuffer_crop_cache
.get(&node_id)
.is_some_and(|crop| moved.intersects(crop.src_doc_bounds));
if intersects {
// Simplest "automatic invalidation": once something moves over this cached
// area, drop the cached crop so it won't be reused again until the next
// full-frame rebuild.
self.backbuffer_crop_cache.remove(&node_id);
return false;
}
}
true
}
fn is_recortable_for_drag_crop(&self, shape: &Shape) -> bool {
// "Recortable" (happy path): the shape is fully represented by the pixels
// already in Backbuffer and can be moved as a texture during drag.
shape.blur.is_none()
&& shape.shadows.is_empty()
&& (shape.opacity - 1.0).abs() <= 1e-4
&& shape.blend_mode().0 == skia::BlendMode::SrcOver
}
pub fn try_new(width: i32, height: i32) -> Result<RenderState> {
// This needs to be done once per WebGL context.
let mut gpu_state = GpuState::try_new()?;
@ -460,6 +534,7 @@ impl RenderState {
cache_cleared_this_render: false,
current_tile_had_shapes: false,
interactive_target_seeded: false,
backbuffer_crop_cache: HashMap::default(),
})
}
@ -1541,6 +1616,97 @@ impl RenderState {
}
}
fn rebuild_backbuffer_crop_cache(&mut self, tree: ShapesPoolRef) {
self.backbuffer_crop_cache.clear();
// Collect candidate shapes that are "recortable" and visible in the current viewport.
// This is intentionally conservative; we only cache shapes that do not overlap with
// ANY other candidate to guarantee the pixels under their bounds belong exclusively
// to that shape in Backbuffer.
let viewport = self.viewbox.area;
let mut candidates: Vec<(Uuid, Rect, Rect)> = Vec::new(); // (id, doc_bounds, selrect)
let root_ids: Vec<Uuid> = match tree.get(&Uuid::nil()) {
Some(root) => root.children_ids(false),
None => Vec::new(),
};
for shape_id in root_ids {
let Some(shape) = tree.get(&shape_id) else {
continue;
};
if shape.hidden {
continue;
}
if !self.is_recortable_for_drag_crop(shape) {
continue;
}
let doc_bounds = self.get_cached_extrect(shape, tree, 1.0);
if !doc_bounds.intersects(viewport) {
continue;
}
// Also require selrect to be visible; used for drag delta placement.
let selrect = shape.selrect();
if !selrect.intersects(viewport) {
continue;
}
candidates.push((shape.id, doc_bounds, selrect));
}
// Filter out any candidate that overlaps with any other candidate.
let mut non_overlapping: Vec<(Uuid, Rect, Rect)> = Vec::new();
'outer: for (i, (id, bounds, selrect)) in candidates.iter().enumerate() {
for (j, (_id2, bounds2, _sel2)) in candidates.iter().enumerate() {
if i == j {
continue;
}
if bounds.intersects(*bounds2) {
continue 'outer;
}
}
non_overlapping.push((*id, *bounds, *selrect));
}
// Snapshot from Backbuffer for each accepted shape.
let scale = self.get_scale();
let vb_left = self.viewbox.area.left;
let vb_top = self.viewbox.area.top;
for (id, doc_bounds, selrect) in non_overlapping {
let left = ((doc_bounds.left - vb_left) * scale).floor() as i32;
let top = ((doc_bounds.top - vb_top) * scale).floor() as i32;
let right = ((doc_bounds.right - vb_left) * scale).ceil() as i32;
let bottom = ((doc_bounds.bottom - vb_top) * scale).ceil() as i32;
if right <= left || bottom <= top {
continue;
}
let src_irect = skia::IRect::new(left, top, right, bottom);
let Some(image) = self
.surfaces
.snapshot_rect(SurfaceId::Backbuffer, src_irect)
else {
continue;
};
let src_doc_bounds = Rect::new(
src_irect.left as f32 / scale + vb_left,
src_irect.top as f32 / scale + vb_top,
src_irect.right as f32 / scale + vb_left,
src_irect.bottom as f32 / scale + vb_top,
);
self.backbuffer_crop_cache.insert(
id,
InteractiveDragCrop {
src_doc_bounds,
src_selrect: selrect,
image,
},
);
}
}
pub fn render_from_cache(&mut self, shapes: ShapesPoolRef) {
let _start = performance::begin_timed_log!("render_from_cache");
performance::begin_measure!("render_from_cache");
@ -1906,6 +2072,12 @@ impl RenderState {
self.cancel_animation_frame();
self.render_request_id = Some(wapi::request_animation_frame!());
} else {
// A full-quality frame is now complete. Refresh Backbuffer and regenerate
// the per-shape crop cache so interactive drags can reuse pixels.
if !self.options.is_fast_mode() && !self.options.is_interactive_transform() {
self.surfaces.copy_target_to_backbuffer();
self.rebuild_backbuffer_crop_cache(tree);
}
wapi::notify_tiles_render_complete!();
performance::end_measure!("render");
}
@ -2706,6 +2878,29 @@ impl RenderState {
target_surface = SurfaceId::Export;
}
// During interactive transforms we compute the union of the current bounds of all
// modified shapes (doc-space @ 100% zoom, scale=1.0). This is used as a cheap overlap
// guard to decide when cached top-level crops are unsafe to reuse (something is moving
// over/inside them), without doing expensive ancestor walks per node.
let moved_bounds =
if self.options.is_interactive_transform() && !tree.modifier_ids().is_empty() {
let mut acc: Option<Rect> = None;
for id in tree.modifier_ids().iter() {
let Some(s) = tree.get(id) else { continue };
let r = self.get_cached_extrect(s, tree, 1.0);
acc = Some(match acc {
None => r,
Some(mut prev) => {
prev.join(r);
prev
}
});
}
acc
} else {
None
};
while let Some(node_render_state) = self.pending_nodes.pop() {
let node_id = node_render_state.id;
let visited_children = node_render_state.visited_children;
@ -2776,6 +2971,64 @@ impl RenderState {
}
}
// Interactive drag cache: if this node is cacheable during interactive transform,
// draw it directly from Backbuffer crop on the current tile surface and skip
// traversing/rendering the subtree.
if self.options.is_interactive_transform() {
let use_cached = self.should_use_cached_top_level_during_interactive(
node_id,
tree,
&tree.modifier_ids(),
moved_bounds,
);
if use_cached {
if let Some(crop) = self.backbuffer_crop_cache.get(&node_id) {
let crop_image = &crop.image;
let crop_src_selrect = crop.src_selrect;
let crop_src_doc_bounds = crop.src_doc_bounds;
let cur_selrect = tree.get(&node_id).map(|s| s.selrect());
let (dx, dy) = match cur_selrect {
Some(cur) => (
cur.left - crop_src_selrect.left,
cur.top - crop_src_selrect.top,
),
None => (0.0, 0.0),
};
let dst_doc_rect = Rect::new(
crop_src_doc_bounds.left + dx,
crop_src_doc_bounds.top + dy,
crop_src_doc_bounds.right + dx,
crop_src_doc_bounds.bottom + dy,
);
let scale = self.get_scale();
let translation = self
.surfaces
.get_render_context_translation(self.render_area, scale);
let dst_tile_rect = skia::Rect::from_xywh(
(dst_doc_rect.left + translation.0) * scale,
(dst_doc_rect.top + translation.1) * scale,
dst_doc_rect.width() * scale,
dst_doc_rect.height() * scale,
);
// let canvas = self.surfaces.canvas_and_mark_dirty(target_surface);
let canvas = self.surfaces.canvas(target_surface);
canvas.save();
canvas.reset_matrix();
canvas.draw_image_rect(
crop_image,
None,
dst_tile_rect,
&skia::Paint::default(),
);
canvas.restore();
}
continue;
}
}
let can_flatten = element.can_flatten() && !self.focus_mode.should_focus(&element.id);
// Skip render_shape_enter/exit for flattened containers

View File

@ -493,6 +493,11 @@ impl Surfaces {
}
}
pub fn snapshot_rect(&mut self, id: SurfaceId, irect: skia::IRect) -> Option<skia::Image> {
let surface = self.get_mut(id);
surface.image_snapshot_with_bounds(irect)
}
/// Returns a mutable reference to the canvas and automatically marks
/// render surfaces as dirty when accessed. This tracks which surfaces
/// have content for optimization purposes.
@ -698,6 +703,11 @@ impl Surfaces {
}
}
pub fn surface_size(&self, id: SurfaceId) -> (i32, i32) {
let s = self.get(id);
(s.width(), s.height())
}
/// Copy the current `Target` contents into the persistent `Backbuffer`.
/// This is a GPU→GPU copy via Skia (no ReadPixels).
pub fn copy_target_to_backbuffer(&mut self) {

View File

@ -236,7 +236,11 @@ fn initialize_tracks(
let gap_main = if first { 0.0 } else { layout_axis.gap_main };
let next_main_size = current_track.main_size + child_main_size + gap_main;
let next_main_size = if current_track.shapes.is_empty() {
child_main_size
} else {
current_track.main_size + child_main_size + gap_main
};
let main_space = layout_axis.main_space();
let exceeds_main_space = next_main_size > main_space + TRACK_TOLERANCE;
@ -329,9 +333,19 @@ fn distribute_fill_across_space(layout_axis: &LayoutAxis, tracks: &mut [TrackDat
let current = left_space / to_resize_tracks.len() as f32;
for i in (0..to_resize_tracks.len()).rev() {
let track = &mut to_resize_tracks[i];
let delta =
f32::min(track.max_across_size, track.across_size + current) - track.across_size;
track.across_size += delta;
let delta = if math::is_close_to(track.across_size, MIN_SIZE) {
f32::min(track.max_across_size, track.across_size + current)
} else {
f32::min(track.max_across_size, track.across_size + current) - track.across_size
};
if math::is_close_to(track.across_size, MIN_SIZE) {
track.across_size = delta;
} else {
track.across_size += delta;
}
left_space -= delta;
if (track.across_size - track.max_across_size).abs() < MIN_SIZE {
@ -686,7 +700,7 @@ pub fn reflow_flex_layout(
+ (nshapes as f32 - 1.0) * layout_axis.gap_main
})
.reduce(f32::max)
.unwrap_or(0.01)
.unwrap_or(MIN_SIZE)
+ layout_axis.padding_main_start
+ layout_axis.padding_main_end
} else {

View File

@ -140,6 +140,18 @@ impl ShapesPoolImpl {
Some(&mut self.shapes[idx])
}
/// Returns the current transform modifier matrix for the shape, if any.
pub fn get_modifier(&self, id: &Uuid) -> Option<&skia::Matrix> {
let idx = *self.uuid_to_idx.get(id)?;
self.modifiers.get(&idx)
}
/// Get a shape by UUID without applying modifiers/structure/scale-content.
pub fn get_raw(&self, id: &Uuid) -> Option<&Shape> {
let idx = *self.uuid_to_idx.get(id)?;
Some(&self.shapes[idx])
}
/// Get a shape by UUID. Returns the modified shape if modifiers/structure
/// are applied, otherwise returns the base shape.
pub fn get(&self, id: &Uuid) -> Option<&Shape> {

View File

@ -209,60 +209,29 @@ impl PendingTiles {
}
}
// Generate tiles in spiral order from center
// Generate tiles ordered by distance to the center (closest processed first).
fn generate_spiral(rect: &TileRect) -> Vec<Tile> {
let columns = rect.width();
let rows = rect.height();
let total = columns * rows;
let cx = rect.center_x();
let cy = rect.center_y();
if total <= 0 {
return Vec::new();
// TileRect is inclusive (x1..=x2, y1..=y2).
let mut tiles = Vec::new();
for x in rect.x1()..=rect.x2() {
for y in rect.y1()..=rect.y2() {
tiles.push(Tile(x, y));
}
}
let mut result = Vec::with_capacity(total as usize);
let mut cx = rect.center_x();
let mut cy = rect.center_y();
let ratio = (columns as f32 / rows as f32).ceil() as i32;
let mut direction_current = 0;
let mut direction_total_x = ratio;
let mut direction_total_y = 1;
let mut direction = 0;
let mut current = 0;
result.push(Tile(cx, cy));
while current < total {
match direction {
0 => cx += 1,
1 => cy += 1,
2 => cx -= 1,
3 => cy -= 1,
_ => unreachable!("Invalid direction"),
}
result.push(Tile(cx, cy));
direction_current += 1;
let direction_total = if direction % 2 == 0 {
direction_total_x
} else {
direction_total_y
};
if direction_current == direction_total {
if direction % 2 == 0 {
direction_total_x += 1;
} else {
direction_total_y += 1;
}
direction = (direction + 1) % 4;
direction_current = 0;
}
current += 1;
}
result.reverse();
result
// We pop() from the end, so keep nearest-to-center tiles at the end.
tiles.sort_unstable_by(|a, b| {
let da = (a.x() - cx).abs() + (a.y() - cy).abs();
let db = (b.x() - cx).abs() + (b.y() - cy).abs();
da.cmp(&db)
.then_with(|| a.x().cmp(&b.x()))
.then_with(|| a.y().cmp(&b.y()))
});
tiles.reverse();
tiles
}
pub fn update(&mut self, tile_viewbox: &TileViewbox, surfaces: &Surfaces, only_visible: bool) {