From c194f9f6b71e65f2cad1f5998d929bd26c4603cf Mon Sep 17 00:00:00 2001 From: Jiekang Tian Date: Thu, 23 Jul 2026 08:55:53 +0800 Subject: [PATCH] feat: improve cross-page tiling and document rendering Add continuous tile-drop ghosts with pointer anchoring, cancellation handling, temporary Alt reversal, and undoable empty-source cleanup. Choose pointer-directed cells for multi-plot retile layouts, retain sparse grid slots, and invalidate previews when the target cell changes. Refresh the active canvas after source removal to avoid stale-index crashes. Cull document painting to page bounds and expose optional render statistics for document paints and line scanning. Update related tests and documentation. --- crates/app/src/ui/canvas/interactions.rs | 29 +-- crates/app/src/ui/canvas/mod.rs | 71 ++++++-- crates/app/src/ui/canvas/tiling.rs | 126 ++++++++++++- crates/app/src/ui/settings_dialog.rs | 8 + crates/core/src/actions/mod.rs | 6 +- crates/core/src/actions/tests/tiling.rs | 61 ++++++- crates/core/src/actions/transfer.rs | 63 +++++-- crates/core/src/layout.rs | 43 +++-- crates/core/src/layout/visual_spacing.rs | 166 ++++++++++++++---- crates/core/src/settings/model.rs | 4 + crates/core/src/state/app_impl.rs | 7 + crates/core/src/state/app_impl_io.rs | 1 + crates/core/src/state/app_state.rs | 3 + crates/core/src/state/tile_drop.rs | 47 +++++ crates/render/src/lib.rs | 2 + crates/render/src/screen.rs | 97 +++++----- crates/render/src/screen_stats.rs | 32 ++++ crates/render/src/screen_tests.rs | 75 ++++++++ .../content/docs/guides/layout-and-export.md | 14 ++ .../src/content/docs/reference/preferences.md | 3 + .../docs/zh-cn/guides/layout-and-export.md | 11 ++ .../docs/zh-cn/reference/preferences.md | 3 + 22 files changed, 734 insertions(+), 138 deletions(-) create mode 100644 crates/render/src/screen_stats.rs diff --git a/crates/app/src/ui/canvas/interactions.rs b/crates/app/src/ui/canvas/interactions.rs index 3d3e6d0..0c8edb2 100644 --- a/crates/app/src/ui/canvas/interactions.rs +++ b/crates/app/src/ui/canvas/interactions.rs @@ -267,15 +267,24 @@ pub(crate) fn handle_object_interactions( ui: &Ui, _resp: &egui::Response, ) { - let (hover, primary_down, primary_pressed, primary_released, shift) = ui.input(|i| { - ( - i.pointer.hover_pos(), - i.pointer.primary_down(), - i.pointer.primary_pressed(), - i.pointer.primary_released(), - i.modifiers.shift, - ) - }); + let (hover, primary_down, primary_pressed, primary_released, shift, alt, esc, focused) = ui + .input(|i| { + ( + i.pointer.hover_pos(), + i.pointer.primary_down(), + i.pointer.primary_pressed(), + i.pointer.primary_released(), + i.modifiers.shift, + i.modifiers.alt, + i.key_pressed(egui::Key::Escape), + i.focused, + ) + }); + + if (esc || !focused) && matches!(app.interaction(), Interaction::Object(_)) { + app.cancel_interaction(); + return; + } if primary_pressed { let Some(screen_pos) = hover else { @@ -410,7 +419,7 @@ pub(crate) fn handle_object_interactions( app.session.ui.snap_guides.clear(); if let Interaction::Object(drag) = app.take_interaction() { if let Some(preview) = app.session.ui.tile_drop.take() { - commit_tile_drop(app, ci, drag, preview); + commit_tile_drop(app, drag, preview, alt); } else if active { finish_object_drag(app, ci, drag); } diff --git a/crates/app/src/ui/canvas/mod.rs b/crates/app/src/ui/canvas/mod.rs index 190a6f5..954a7d0 100644 --- a/crates/app/src/ui/canvas/mod.rs +++ b/crates/app/src/ui/canvas/mod.rs @@ -62,6 +62,42 @@ pub(crate) use slices::*; pub(crate) use snap::*; pub(crate) use tiling::*; +fn finite_rect_intersects(a: egui::Rect, b: egui::Rect) -> bool { + let finite = |r: egui::Rect| { + [r.min.x, r.min.y, r.max.x, r.max.y] + .iter() + .all(|value| value.is_finite()) + }; + finite(a) + && finite(b) + && a.max.x >= b.min.x + && b.max.x >= a.min.x + && a.max.y >= b.min.y + && b.max.y >= a.min.y +} + +#[cfg(test)] +mod culling_tests { + use super::finite_rect_intersects; + + #[test] + fn edge_contact_is_visible_and_non_finite_is_not() { + let clip = egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(10.0, 10.0)); + assert!(finite_rect_intersects( + egui::Rect::from_min_max(egui::pos2(10.0, 2.0), egui::pos2(20.0, 8.0)), + clip, + )); + assert!(!finite_rect_intersects( + egui::Rect::from_min_max(egui::pos2(11.0, 2.0), egui::pos2(20.0, 8.0)), + clip, + )); + assert!(!finite_rect_intersects( + egui::Rect::from_min_max(egui::pos2(f32::NAN, 0.0), egui::pos2(1.0, 1.0)), + clip, + )); + } +} + #[derive(Clone, Copy)] pub(crate) enum CanvasInteractionClearScope { Transient, @@ -112,7 +148,6 @@ pub fn render_central(app: &mut PlotxApp, ui: &mut Ui) { }; // `dispatch_frame_gesture` may have switched the active frame. let ci = app.session.active_canvas.unwrap_or(ci); - let page = page_screen_rect(app.session.board, &app.doc.canvases[ci], rect); // Processing direct manipulation must update the document before its plots // are painted, so the changed spectrum is visible in this same frame. @@ -149,6 +184,15 @@ pub fn render_central(app: &mut PlotxApp, ui: &mut Ui) { } } + // A tile drop may remove the now-empty source canvas and switch focus to the + // target in the same UI pass. Refresh both values before any post-interaction + // painting; the old index can otherwise refer to a different canvas or be out + // of bounds when the source was the last page. + let Some(ci) = app.session.active_canvas else { + return; + }; + let page = page_screen_rect(app.session.board, &app.doc.canvases[ci], rect); + let frame_stroke = Stroke::new(1.0_f32, ui.visuals().widgets.noninteractive.bg_stroke.color); // Pages float on the board the way chrome cards float on the workspace: a // soft shadow keeps a white page legible on the light workspace fill. @@ -158,17 +202,23 @@ pub fn render_central(app: &mut PlotxApp, ui: &mut Ui) { spread: 0, color: Color32::from_black_alpha(if ui.visuals().dark_mode { 110 } else { 36 }), }; - for other in 0..app.doc.canvases.len() { - if other == ci { - continue; - } + let clip = painter.clip_rect(); + for other in (0..app.doc.canvases.len()) + .filter(|&other| other != ci) + .chain(std::iter::once(ci)) + { let other_page = page_screen_rect(app.session.board, &app.doc.canvases[other], rect); - painter.add(page_shadow.as_shape(frame_card_rect(other_page), header_corner_radius())); - paint_document(app, other, rect, &painter); - painter.rect_stroke(other_page, 0.0, frame_stroke, StrokeKind::Inside); + let card = frame_card_rect(other_page); + if finite_rect_intersects(card.expand(12.0), clip) { + painter.add(page_shadow.as_shape(card, header_corner_radius())); + } + if finite_rect_intersects(other_page, clip) { + paint_document(app, other, rect, &painter); + if other != ci { + painter.rect_stroke(other_page, 0.0, frame_stroke, StrokeKind::Inside); + } + } } - painter.add(page_shadow.as_shape(frame_card_rect(page), header_corner_radius())); - paint_document(app, ci, rect, &painter); paint_frame_headers(app, rect, ui, &painter); paint_frame_captions(app, rect, ui, &painter); render_inline_panel_note_editor(app, rect, ui); @@ -179,6 +229,7 @@ pub fn render_central(app: &mut PlotxApp, ui: &mut Ui) { paint_marquee(app, ci, rect, &painter, chrome); paint_panel_label_selection(app, ci, rect, &painter, chrome); paint_object_selection(app, ci, rect, page, &painter, chrome); + paint_tile_ghost(app, &painter, chrome); paint_tile_preview(app, rect, &painter, chrome); super::canvas_size::page_size_chrome(app, ci, page, rect, ui); if pointer_owned { diff --git a/crates/app/src/ui/canvas/tiling.rs b/crates/app/src/ui/canvas/tiling.rs index b09e88c..5535192 100644 --- a/crates/app/src/ui/canvas/tiling.rs +++ b/crates/app/src/ui/canvas/tiling.rs @@ -47,7 +47,21 @@ pub(crate) fn update_tile_drop( existing_ids.len(), [pointer_page.x, pointer_page.y], ); - let cache_key = tile_cache_key(drag, target, page_pt, layout, &existing_ids, region); + let pointer_cell = plotx_core::layout::tiling_drop_cell( + page_pt, + &layout, + existing_ids.len() + 1, + [pointer_page.x, pointer_page.y], + ); + let cache_key = tile_cache_key( + drag, + target, + page_pt, + layout, + &existing_ids, + region, + pointer_cell, + ); if app .session .ui @@ -55,6 +69,12 @@ pub(crate) fn update_tile_drop( .as_ref() .is_some_and(|preview| preview.cache_key == cache_key) { + if let Some(preview) = app.session.ui.tile_drop.as_mut() { + preview.pointer_screen = [p.x, p.y]; + } + if let Some(object) = app.doc.canvases[drag.canvas].object_mut(drag.object) { + object.frame = drag.before; + } return true; } let existing_items: Vec<_> = existing_ids @@ -77,7 +97,22 @@ pub(crate) fn update_tile_drop( target, newcomer: plan.newcomer, existing: plan.existing, + pointer_screen: [p.x, p.y], + anchor: [ + ((drag.start_pointer[0] - drag.before.x) / drag.before.width.max(f32::EPSILON)) + .clamp(0.0, 1.0), + ((drag.start_pointer[1] - drag.before.y) / drag.before.height.max(f32::EPSILON)) + .clamp(0.0, 1.0), + ], }); + app.session.status = if app.keep_empty_source_canvas { + "Hold Alt to remove the empty source canvas.".into() + } else { + "Hold Alt to keep the empty source canvas.".into() + }; + if let Some(object) = app.doc.canvases[drag.canvas].object_mut(drag.object) { + object.frame = drag.before; + } true } @@ -88,6 +123,7 @@ fn tile_cache_key( target_layout: plotx_core::layout::PageLayout, target_existing_ids: &[ObjectId], region: plotx_core::layout::TilingDropRegion, + pointer_cell: Option, ) -> TileDropCacheKey { TileDropCacheKey { source_canvas: drag.canvas, @@ -97,6 +133,7 @@ fn tile_cache_key( target_layout, target_existing_ids: target_existing_ids.to_vec(), region, + pointer_cell, } } @@ -113,26 +150,78 @@ fn layout_item(canvas: &CanvasDocument, id: ObjectId) -> Option>, removed: Vec<(usize, CanvasObject)>, inserted: Vec, existing_before: Vec<(ObjectId, ObjectFrame)>, diff --git a/crates/core/src/actions/tests/tiling.rs b/crates/core/src/actions/tests/tiling.rs index 93dc95f..668ac98 100644 --- a/crates/core/src/actions/tests/tiling.rs +++ b/crates/core/src/actions/tests/tiling.rs @@ -21,7 +21,8 @@ fn tile_drop_transfers_reframes_and_round_trips() { // Pointer in the right region of the target page. let plan = compute_tiling_plan(page, &layout, &ids, [page[0] * 0.9, page[1] * 0.5]); let existing_after_frame = plan.existing[0].1; - let action = Action::tile_drop(&app, 0, newcomer, 1, plan.newcomer, plan.existing).unwrap(); + let action = + Action::tile_drop(&app, 0, newcomer, 1, plan.newcomer, plan.existing, false).unwrap(); app.execute_action(action); assert_eq!(app.doc.canvases[0].objects.len(), src_before - 1); @@ -52,6 +53,61 @@ fn tile_drop_transfers_reframes_and_round_trips() { assert_eq!(app.doc.canvases[1].object(existing).unwrap().frame, ex); } +fn assert_empty_source_removal_round_trip(from: usize, to: usize) { + let mut app = sample_app(); + push_canvas(&mut app, 0, "second", [120.0, 80.0]); + app.session.active_canvas = Some(from); + let source_snapshot = app.doc.canvases[from].clone(); + let newcomer = source_snapshot.objects[0].id; + let target_name = app.doc.canvases[to].name.clone(); + let page = app.doc.canvases[to].size_pt(); + let plan = compute_tiling_plan( + page, + &app.doc.canvases[to].layout, + &app.doc.canvases[to].plot_object_ids(), + [page[0] * 0.9, page[1] * 0.5], + ); + let action = + Action::tile_drop(&app, from, newcomer, to, plan.newcomer, plan.existing, true).unwrap(); + app.execute_action(action); + assert_eq!(app.doc.canvases.len(), 1); + assert_eq!(app.doc.canvases[0].name, target_name); + assert_eq!(app.session.active_canvas, Some(0)); + + app.undo(); + assert_eq!(app.doc.canvases.len(), 2); + assert_eq!(app.doc.canvases[from].name, source_snapshot.name); + assert_eq!( + app.doc.canvases[from].objects.len(), + source_snapshot.objects.len() + ); + assert_eq!( + app.doc.canvases[from].objects[0].id, + source_snapshot.objects[0].id + ); + assert_eq!( + app.doc.canvases[from].objects[0].frame, + source_snapshot.objects[0].frame + ); + assert_eq!(app.doc.canvases[from].board_pos, source_snapshot.board_pos); + assert_eq!(app.session.active_canvas, Some(from)); + + app.redo(); + assert_eq!(app.doc.canvases.len(), 1); + assert_eq!(app.doc.canvases[0].name, target_name); + assert_eq!(app.session.active_canvas, Some(0)); +} + +#[test] +fn tile_drop_removes_empty_source_before_target_atomically() { + assert_empty_source_removal_round_trip(0, 1); +} + +#[test] +fn tile_drop_removes_empty_source_after_target_atomically() { + assert_empty_source_removal_round_trip(1, 0); +} + #[test] fn cancelling_interaction_clears_tile_preview_cache() { let mut app = sample_app(); @@ -64,10 +120,13 @@ fn cancelling_interaction_clears_tile_preview_cache() { target_layout: crate::layout::PageLayout::default(), target_existing_ids: vec![2], region: crate::layout::TilingDropRegion::Left, + pointer_cell: None, }, target: 1, newcomer: ObjectFrame::new(0.0, 0.0, 50.0, 80.0), existing: Vec::new(), + pointer_screen: [0.0; 2], + anchor: [0.5; 2], }); app.cancel_interaction(); assert!(app.session.ui.tile_drop.is_none()); diff --git a/crates/core/src/actions/transfer.rs b/crates/core/src/actions/transfer.rs index e29507f..d75c6e8 100644 --- a/crates/core/src/actions/transfer.rs +++ b/crates/core/src/actions/transfer.rs @@ -85,6 +85,7 @@ impl Action { to: usize, newcomer_frame: crate::state::ObjectFrame, existing_after: Vec<(ObjectId, crate::state::ObjectFrame)>, + remove_empty_source: bool, ) -> Option { let Action::TransferObjects { removed, @@ -97,14 +98,25 @@ impl Action { return None; }; inserted.first_mut()?.frame = newcomer_frame; + let src = app.doc.canvases.get(from)?; let dst = app.doc.canvases.get(to)?; let existing_before = existing_after .iter() .filter_map(|&(id, _)| dst.object(id).map(|o| (id, o.frame))) .collect(); + let source_will_be_empty = src.objects.len() == removed.len(); + let source_canvas_before = + (remove_empty_source && source_will_be_empty).then(|| Box::new(src.clone())); + let target_index_after = if source_canvas_before.is_some() && from < to { + to - 1 + } else { + to + }; Some(Self::TileDrop { - from, - to, + source_index_before: from, + target_index_before: to, + target_index_after, + source_canvas_before, removed, inserted, existing_before, @@ -226,8 +238,10 @@ impl PlotxApp { /// the target with the newcomer selected. pub(super) fn apply_tile_drop(&mut self, action: &Action) { let Action::TileDrop { - from, - to, + source_index_before, + target_index_before, + target_index_after, + source_canvas_before, removed, inserted, existing_after, @@ -236,7 +250,13 @@ impl PlotxApp { else { return; }; - let (from, to) = (*from, *to); + let (from, to) = (*source_index_before, *target_index_before); + // Validate every index before mutating either canvas. A stale history + // entry must be an all-or-nothing no-op, never a half-applied transfer. + if from == to || from >= self.doc.canvases.len() || to >= self.doc.canvases.len() { + self.clear_transfer_transients(); + return; + } for &(id, frame) in existing_after { self.set_object_frame(to, id, frame); } @@ -267,6 +287,10 @@ impl PlotxApp { self.set_object_frame(to, id, frame); } } + if source_canvas_before.is_some() { + self.doc.canvases.remove(from); + } + let to = *target_index_after; self.session.active_canvas = Some(to); self.session.ui.selection = Selection::Objects(ids); let active = self.doc.canvases.get(to).and_then(|c| c.active_dataset()); @@ -280,8 +304,10 @@ impl PlotxApp { /// source slot, and restore the pre-drop active canvas and selection. pub(super) fn revert_tile_drop(&mut self, action: &Action) { let Action::TileDrop { - from, - to, + source_index_before, + target_index_before, + target_index_after, + source_canvas_before, removed, inserted, existing_before, @@ -292,8 +318,21 @@ impl PlotxApp { else { return; }; - let (from, to, active_before) = (*from, *to, *active_before); - if let Some(dst) = self.doc.canvases.get_mut(to) { + let (from, to, active_before) = + (*source_index_before, *target_index_before, *active_before); + if let Some(source) = source_canvas_before { + if from > self.doc.canvases.len() { + self.clear_transfer_transients(); + return; + } + self.doc.canvases.insert(from, (**source).clone()); + } + let current_target = if source_canvas_before.is_some() { + to + } else { + *target_index_after + }; + if let Some(dst) = self.doc.canvases.get_mut(current_target) { for object in inserted { dst.objects.retain(|o| o.id != object.id); if dst.selected_object == Some(object.id) { @@ -302,9 +341,11 @@ impl PlotxApp { } } for &(id, frame) in existing_before { - self.set_object_frame(to, id, frame); + self.set_object_frame(current_target, id, frame); } - if let Some(src) = self.doc.canvases.get_mut(from) { + if source_canvas_before.is_none() + && let Some(src) = self.doc.canvases.get_mut(from) + { for (slot, object) in removed { let at = (*slot).min(src.objects.len()); src.next_object_id = src.next_object_id.max(object.id + 1); diff --git a/crates/core/src/layout.rs b/crates/core/src/layout.rs index d319b45..7162b0e 100644 --- a/crates/core/src/layout.rs +++ b/crates/core/src/layout.rs @@ -7,7 +7,7 @@ mod visual_spacing; pub use visual_spacing::{ GutterPreset, LayoutItem, OccupiedGrid, SpacingMode, TilingDropRegion, arrange_grid, compute_tiling_plan_for_items, infer_occupied_grid, layout_item, outer_axis_cells, - tiling_drop_region, + tiling_drop_cell, tiling_drop_region, }; /// Grid presets offered in the Arrange menu, as `(label, rows, cols)`. @@ -384,11 +384,8 @@ pub struct TilingPlan { pub existing: Vec<(ObjectId, ObjectFrame)>, } -/// Compute where a dropped plot and the target's existing plots tile a page: -/// - 0 existing → the newcomer fills the page. -/// - 1 existing → a two-way split; the pointer's half goes to the newcomer, the -/// complementary half to the existing plot, separated by the layout gutter. -/// - 2+ existing → an even grid re-tile of all N+1 plots (newcomer appended last). +/// Compute where a dropped plot and the target's existing plots tile a page. With +/// two or more existing plots, the pointer selects the newcomer's grid cell. pub fn compute_tiling_plan( page_pt: [f32; 2], layout: &PageLayout, @@ -409,13 +406,11 @@ pub fn compute_tiling_plan( existing: vec![(existing_ids[0], other)], } } - _ => grid_retile(page_pt, layout, existing_ids), + _ => grid_retile(page_pt, layout, existing_ids, pointer_page), } } -/// Two-way page split. The pointer decides the axis and side: whichever of x/y is -/// further from the page centre picks left/right vs top/bottom (ties favour -/// left/right). Returns `(pointer_side_frame, opposite_frame)` separated by `g`. +/// Two-way split; the pointer chooses the axis and side, with ties favouring left/right. fn split_two(page_pt: [f32; 2], g: f32, p: [f32; 2]) -> (ObjectFrame, ObjectFrame) { let [w, h] = page_pt; let nx = if w > 0.0 { p[0] / w } else { 0.5 }; @@ -441,9 +436,13 @@ fn split_two(page_pt: [f32; 2], g: f32, p: [f32; 2]) -> (ObjectFrame, ObjectFram } } -/// Even grid re-tile of all N+1 plots into a near-square grid (existing keep their -/// order, newcomer takes the next free cell). -fn grid_retile(page_pt: [f32; 2], layout: &PageLayout, existing_ids: &[ObjectId]) -> TilingPlan { +/// Even-grid re-tile; existing plots keep order while the pointer selects the new cell. +fn grid_retile( + page_pt: [f32; 2], + layout: &PageLayout, + existing_ids: &[ObjectId], + pointer_page: [f32; 2], +) -> TilingPlan { let n = existing_ids.len() + 1; let (rows, cols) = even_grid_dims(n); let grid_layout = PageLayout { @@ -452,13 +451,22 @@ fn grid_retile(page_pt: [f32; 2], layout: &PageLayout, existing_ids: &[ObjectId] ..*layout }; let cells = grid_frames(page_pt, &grid_layout); + let newcomer_cell = visual_spacing::tiling_drop_cell(page_pt, &grid_layout, n, pointer_page) + .unwrap_or(existing_ids.len()); let existing = existing_ids .iter() - .zip(&cells) - .map(|(&id, &cell)| (id, cell)) + .enumerate() + .map(|(index, &id)| { + let cell = if index < newcomer_cell { + index + } else { + index + 1 + }; + (id, cells[cell]) + }) .collect(); TilingPlan { - newcomer: cells[existing_ids.len()], + newcomer: cells[newcomer_cell], existing, } } @@ -516,6 +524,9 @@ mod tests { assert!(f.x >= -0.01 && f.y >= -0.01); assert!(f.x + f.width <= page[0] + 0.01 && f.y + f.height <= page[1] + 0.01); } + let bottom_right = + compute_tiling_plan(page, &PageLayout::default(), &[1, 2], [395.0, 295.0]); + assert!(bottom_right.newcomer.x > page[0] * 0.5 && bottom_right.newcomer.y > page[1] * 0.5); } #[test] diff --git a/crates/core/src/layout/visual_spacing.rs b/crates/core/src/layout/visual_spacing.rs index d9a723d..bcdad5f 100644 --- a/crates/core/src/layout/visual_spacing.rs +++ b/crates/core/src/layout/visual_spacing.rs @@ -48,7 +48,7 @@ pub enum TilingDropRegion { Right, Top, Bottom, - /// Retiling three or more objects is independent of pointer direction. + /// Retiling multiple objects; the selected grid cell is tracked separately. Retile, } @@ -83,6 +83,51 @@ pub fn tiling_drop_region( } } +/// Return the row-major cell nearest the pointer in the grid that can hold the +/// existing plots plus the newcomer. The grid intentionally retains spare cells +/// (for example, a 2 × 2 grid for three plots) so a drop can choose any quadrant +/// instead of being forced into the next occupied cell. +pub fn tiling_drop_cell( + page_pt: [f32; 2], + layout: &PageLayout, + total_count: usize, + pointer_page: [f32; 2], +) -> Option { + if total_count < 2 { + return None; + } + let (rows, cols) = even_grid_dims(total_count); + let grid_layout = PageLayout { + rows, + cols, + ..*layout + }; + let pointer = [ + if pointer_page[0].is_finite() { + pointer_page[0] + } else { + page_pt[0] * 0.5 + }, + if pointer_page[1].is_finite() { + pointer_page[1] + } else { + page_pt[1] * 0.5 + }, + ]; + grid_frames(page_pt, &grid_layout) + .iter() + .enumerate() + .min_by(|(_, left), (_, right)| { + let distance = |frame: &ObjectFrame| { + let dx = pointer[0] - (frame.x + frame.width * 0.5); + let dy = pointer[1] - (frame.y + frame.height * 0.5); + dx * dx + dy * dy + }; + distance(left).total_cmp(&distance(right)) + }) + .map(|(index, _)| index) +} + pub fn layout_item(id: ObjectId, figure: &plotx_figure::Figure, frame: ObjectFrame) -> LayoutItem { let margins = plotx_render::axis_layout(figure, frame.width, frame.height).margins; LayoutItem { @@ -165,27 +210,52 @@ pub fn arrange_grid( layout: &PageLayout, items: &[LayoutItem], ) -> Vec<(ObjectId, ObjectFrame)> { - if layout.spacing_mode == SpacingMode::Frame { - let ids: Vec = items.iter().map(|item| item.id).collect(); - return assign_grid(page_pt, layout, &ids); + let capacity = (layout.rows.max(1) as usize).saturating_mul(layout.cols.max(1) as usize); + let mut slots = vec![None; capacity]; + for (slot, item) in slots.iter_mut().zip(items.iter().copied()) { + *slot = Some(item); } + arrange_grid_slots(page_pt, layout, &slots) + .into_iter() + .flatten() + .collect() +} + +fn arrange_grid_slots( + page_pt: [f32; 2], + layout: &PageLayout, + slots: &[Option], +) -> Vec> { let rows = layout.rows.max(1) as usize; let cols = layout.cols.max(1) as usize; - let occupied = items.len().min(rows * cols); + let capacity = rows * cols; + let slots = &slots[..slots.len().min(capacity)]; + if layout.spacing_mode == SpacingMode::Frame { + let cells = grid_frames(page_pt, layout); + return slots + .iter() + .enumerate() + .map(|(index, item)| item.map(|item| (item.id, cells[index]))) + .collect(); + } let gutter = layout.gutter_pt(); let mut col_gaps = vec![0.0_f32; cols.saturating_sub(1)]; let mut row_gaps = vec![0.0_f32; rows.saturating_sub(1)]; - for index in 0..occupied { + for index in 0..slots.len() { let row = index / cols; let col = index % cols; - if col + 1 < cols && index + 1 < occupied { - col_gaps[col] = col_gaps[col] - .max((gutter - items[index].insets[1] - items[index + 1].insets[3]).max(0.0)); + if col + 1 < cols + && let (Some(left), Some(right)) = (slots[index], slots[index + 1]) + { + col_gaps[col] = col_gaps[col].max((gutter - left.insets[1] - right.insets[3]).max(0.0)); } let below = index + cols; - if row + 1 < rows && below < occupied { - row_gaps[row] = row_gaps[row] - .max((gutter - items[index].insets[2] - items[below].insets[0]).max(0.0)); + if row + 1 < rows + && below < slots.len() + && let (Some(above), Some(below)) = (slots[index], slots[below]) + { + row_gaps[row] = + row_gaps[row].max((gutter - above.insets[2] - below.insets[0]).max(0.0)); } } let [mt, mr, mb, ml] = layout.margins_pt(); @@ -213,14 +283,13 @@ pub fn arrange_grid( for row in 1..rows { y[row] = y[row - 1] + cell_h + row_gaps[row - 1]; } - items + slots .iter() - .take(occupied) .enumerate() .map(|(index, item)| { let row = index / cols; let col = index % cols; - (item.id, ObjectFrame::new(x[col], y[row], cell_w, cell_h)) + item.map(|item| (item.id, ObjectFrame::new(x[col], y[row], cell_w, cell_h))) }) .collect() } @@ -275,17 +344,36 @@ pub fn compute_tiling_plan_for_items( cols, ..*layout }; - let mut items = existing_items.to_vec(); - items.push(newcomer); - let mut frames = arrange_grid(page_pt, &grid_layout, &items); + let newcomer_cell = tiling_drop_cell( + page_pt, + &grid_layout, + existing_items.len() + 1, + pointer_page, + ) + .unwrap_or(existing_items.len()); + let capacity = (rows as usize).saturating_mul(cols as usize); + let mut slots = vec![None; capacity]; + let mut existing = existing_items.iter().copied(); + for (index, slot) in slots.iter_mut().enumerate() { + *slot = if index == newcomer_cell { + Some(newcomer) + } else { + existing.next() + }; + } + let frames = arrange_grid_slots(page_pt, &grid_layout, &slots); let newcomer = frames - .pop() + .get(newcomer_cell) + .and_then(|frame| *frame) .map(|(_, frame)| frame) .unwrap_or_else(|| ObjectFrame::new(0.0, 0.0, page_pt[0], page_pt[1])); - TilingPlan { - newcomer, - existing: frames, - } + let existing = frames + .into_iter() + .enumerate() + .filter(|(index, _)| *index != newcomer_cell) + .filter_map(|(_, frame)| frame) + .collect(); + TilingPlan { newcomer, existing } } } } @@ -352,21 +440,23 @@ mod tests { assert_eq!(split.newcomer, apply[1].1); let third = item(3, 6.0); - let retile = compute_tiling_plan_for_items( - page, - &layout, - &[existing, newcomer], - third, - [10.0, 10.0], - ); - let grid = PageLayout { - rows: 2, - cols: 2, - ..layout - }; - let apply = arrange_grid(page, &grid, &[existing, newcomer, third]); - assert_eq!(retile.existing, apply[..2]); - assert_eq!(retile.newcomer, apply[2].1); + for (pointer, right, bottom) in [ + ([10.0, 10.0], false, false), + ([390.0, 10.0], true, false), + ([10.0, 290.0], false, true), + ([390.0, 290.0], true, true), + ] { + let retile = + compute_tiling_plan_for_items(page, &layout, &[existing, newcomer], third, pointer); + assert_eq!(retile.existing.len(), 2); + assert_eq!(retile.newcomer.x > page[0] * 0.5, right); + assert_eq!(retile.newcomer.y > page[1] * 0.5, bottom); + assert!(retile.newcomer.x >= 0.0 && retile.newcomer.y >= 0.0); + assert!( + retile.newcomer.x + retile.newcomer.width <= page[0] + 0.01 + && retile.newcomer.y + retile.newcomer.height <= page[1] + 0.01 + ); + } } #[test] diff --git a/crates/core/src/settings/model.rs b/crates/core/src/settings/model.rs index ba8169b..94de884 100644 --- a/crates/core/src/settings/model.rs +++ b/crates/core/src/settings/model.rs @@ -53,6 +53,9 @@ fn default_auto_check() -> bool { pub struct GeneralSettings { #[serde(default = "default_snap_enabled")] pub snap_enabled: bool, + /// Keep a source canvas after tiling away its final object. + #[serde(default)] + pub keep_empty_source_canvas: bool, /// Number of complete previous project files retained beside the project. /// Zero disables save-time backups; crash-recovery snapshots are separate. #[serde(default = "default_project_backup_generations")] @@ -250,6 +253,7 @@ impl Default for GeneralSettings { fn default() -> Self { Self { snap_enabled: default_snap_enabled(), + keep_empty_source_canvas: false, project_backup_generations: default_project_backup_generations(), } } diff --git a/crates/core/src/state/app_impl.rs b/crates/core/src/state/app_impl.rs index 4dcba43..06856bf 100644 --- a/crates/core/src/state/app_impl.rs +++ b/crates/core/src/state/app_impl.rs @@ -28,6 +28,7 @@ impl PlotxApp { pub fn new_with_settings(settings: crate::settings::Settings) -> Self { Self { + keep_empty_source_canvas: settings.general.keep_empty_source_canvas, doc: SharedDocument::new(Document { datasets: Vec::new(), canvases: Vec::new(), @@ -393,6 +394,12 @@ impl PlotxApp { n.integrals = drag.before; } } + Interaction::Object(drag) => { + self.set_object_frame(drag.canvas, drag.object, drag.before); + for (id, frame) in drag.others { + self.set_object_frame(drag.canvas, id, frame); + } + } _ => {} } self.session.ui.tile_drop = None; diff --git a/crates/core/src/state/app_impl_io.rs b/crates/core/src/state/app_impl_io.rs index 5c96502..57d0f17 100644 --- a/crates/core/src/state/app_impl_io.rs +++ b/crates/core/src/state/app_impl_io.rs @@ -86,6 +86,7 @@ impl PlotxApp { /// egui concern and is applied separately by the app shell. pub fn apply_settings(&mut self, settings: &crate::settings::Settings) { self.session.ui.snap_enabled = settings.general.snap_enabled; + self.keep_empty_source_canvas = settings.general.keep_empty_source_canvas; self.session.canvas_accent = settings.appearance.canvas_accent; if !settings.general.snap_enabled { self.session.ui.snap_guides.clear(); diff --git a/crates/core/src/state/app_state.rs b/crates/core/src/state/app_state.rs index afd5358..dbeb743 100644 --- a/crates/core/src/state/app_state.rs +++ b/crates/core/src/state/app_state.rs @@ -3,6 +3,9 @@ use super::{Session, SharedDocument}; pub struct PlotxApp { pub doc: SharedDocument, pub session: Session, + /// Live, already-applied tiling preference. This deliberately does not read + /// the Preferences draft or disk during pointer movement. + pub keep_empty_source_canvas: bool, } /// Live UI-scale state of the monitor under the window: the settings key it is diff --git a/crates/core/src/state/tile_drop.rs b/crates/core/src/state/tile_drop.rs index 1633942..a0a577e 100644 --- a/crates/core/src/state/tile_drop.rs +++ b/crates/core/src/state/tile_drop.rs @@ -7,6 +7,24 @@ pub struct TileDropPreview { pub target: usize, pub newcomer: ObjectFrame, pub existing: Vec<(ObjectId, ObjectFrame)>, + /// Current cursor in screen pixels and its clamped relative grab point in + /// the source's pre-drag frame. These are independent of the target layout. + pub pointer_screen: [f32; 2], + pub anchor: [f32; 2], +} + +impl TileDropPreview { + pub fn ghost_frame(&self, before: ObjectFrame, zoom: f32) -> ObjectFrame { + let zoom = if zoom.is_finite() { zoom.max(0.0) } else { 0.0 }; + let width = before.width.max(0.0) * zoom; + let height = before.height.max(0.0) * zoom; + ObjectFrame::new( + self.pointer_screen[0] - self.anchor[0].clamp(0.0, 1.0) * width, + self.pointer_screen[1] - self.anchor[1].clamp(0.0, 1.0) * height, + width, + height, + ) + } } /// Every input that can change an auto-tiling preview without moving the pointer @@ -20,4 +38,33 @@ pub struct TileDropCacheKey { pub target_layout: crate::layout::PageLayout, pub target_existing_ids: Vec, pub region: crate::layout::TilingDropRegion, + pub pointer_cell: Option, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ghost_keeps_clamped_grab_anchor_and_before_size() { + let preview = TileDropPreview { + cache_key: TileDropCacheKey { + source_canvas: 0, + source_object: 1, + target_canvas: 1, + target_page_pt: [100.0; 2], + target_layout: crate::layout::PageLayout::default(), + target_existing_ids: vec![], + region: crate::layout::TilingDropRegion::Left, + pointer_cell: None, + }, + target: 1, + newcomer: ObjectFrame::new(0.0, 0.0, 5.0, 5.0), + existing: vec![], + pointer_screen: [90.0, 70.0], + anchor: [0.25, 2.0], + }; + let ghost = preview.ghost_frame(ObjectFrame::new(2.0, 3.0, 40.0, 20.0), 2.0); + assert_eq!(ghost, ObjectFrame::new(70.0, 30.0, 80.0, 40.0)); + } } diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 4417c6e..b60e500 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -4,6 +4,8 @@ pub mod contour; pub mod integral; +#[cfg(feature = "screen")] +mod screen_stats; pub mod svg; mod ticks; diff --git a/crates/render/src/screen.rs b/crates/render/src/screen.rs index 51a8c88..7447503 100644 --- a/crates/render/src/screen.rs +++ b/crates/render/src/screen.rs @@ -1,3 +1,5 @@ +pub use crate::screen_stats::RenderStats; +use crate::screen_stats::visible_source_len; use crate::{ AXIS_LINE_WIDTH, Document, DocumentItem, DocumentObject, DocumentOverlay, DocumentViewport, LegendMark, OUTER_PAD, OverlayAlign, OverlayKind, OverlayShape, OverlayShapeKind, OverlayText, @@ -31,6 +33,16 @@ pub fn show(ui: &mut Ui, fig: &Figure) { /// size (margins, fonts, strokes, offsets) is a page-unit constant multiplied by /// it here, so the whole figure stays proportional at any zoom. pub fn paint(painter: &egui::Painter, outer: Rect, fig: &Figure, scale: f32) { + paint_with_stats(painter, outer, fig, scale, None); +} + +pub fn paint_with_stats( + painter: &egui::Painter, + outer: Rect, + fig: &Figure, + scale: f32, + mut stats: Option<&mut RenderStats>, +) { let ty = fig.typography; let layout = axis_layout(fig, outer.width / scale, outer.height / scale); let margins = layout.margins.scaled(scale); @@ -279,6 +291,9 @@ pub fn paint(painter: &egui::Painter, outer: Rect, fig: &Figure, scale: f32) { } match series.kind { SeriesKind::Line if series.points.len() >= 2 => { + if let Some(stats) = stats.as_deref_mut() { + stats.line_series_visited += 1; + } let columns = line_columns(plot.width, painter.ctx().pixels_per_point()); let visible = screen_line_points( &series.points, @@ -286,6 +301,16 @@ pub fn paint(painter: &egui::Painter, outer: Rect, fig: &Figure, scale: f32) { fig.x.min.max(fig.x.max), columns, ); + if let Some(stats) = stats.as_deref_mut() { + if matches!(visible, Cow::Owned(_)) { + stats.line_source_points_scanned += visible_source_len( + &series.points, + fig.x.min.min(fig.x.max), + fig.x.min.max(fig.x.max), + ); + } + stats.line_points_emitted += visible.len(); + } let pts: Vec = visible .iter() .map(|p| { @@ -575,71 +600,53 @@ pub fn paint_document( document: &Document<'_>, viewport: DocumentViewport, ) { + paint_document_with_stats(painter, screen, document, viewport, None); +} + +pub fn paint_document_with_stats( + painter: &egui::Painter, + screen: Rect, + document: &Document<'_>, + viewport: DocumentViewport, + mut stats: Option<&mut RenderStats>, +) { + if let Some(stats) = stats.as_deref_mut() { + stats.documents_painted += 1; + } let page = Rect::new( screen.left + viewport.pan[0], screen.top + viewport.pan[1], document.width * viewport.zoom, document.height * viewport.zoom, ); - painter.rect_filled( - egui::Rect::from_min_size( - Pos2::new(page.left, page.top), - Vec2::new(page.width, page.height), - ), - 0.0, - col(document.background), + let page_rect = egui::Rect::from_min_size( + Pos2::new(page.left, page.top), + Vec2::new(page.width, page.height), ); + // Screen documents are page-clipped. Besides matching physical-page + // semantics, this makes the page body the complete culling bound used by + // the board; SVG and EMF paths are intentionally unaffected. + let painter = painter.with_clip_rect(page_rect); + painter.rect_filled(page_rect, 0.0, col(document.background)); for item in &document.items { match item { - DocumentItem::Plot(object) => paint_document_object(painter, page, object, viewport), + DocumentItem::Plot(object) => { + paint_document_object(&painter, page, object, viewport, stats.as_deref_mut()) + } DocumentItem::Overlay(overlay) => { - paint_document_overlay(painter, page, overlay, viewport) + paint_document_overlay(&painter, page, overlay, viewport) } } } } -#[cfg(test)] -mod visibility_tests { - use super::*; - use plotx_figure::Axis; - - #[test] - fn hidden_axis_text_keeps_screen_axis_and_tick_shapes() { - let mut fig = Figure::new( - "", - Axis::new("UNIQUE_X_TITLE", 0.0, 90_000.0), - Axis::new("UNIQUE_Y_TITLE", -90_000.0, 90_000.0), - ); - fig.x.show_tick_labels = false; - fig.x.show_label = false; - fig.y.show_tick_labels = false; - fig.y.show_label = false; - let ctx = egui::Context::default(); - let output = ctx.run_ui(egui::RawInput::default(), |ui| { - paint(ui.painter(), Rect::new(0.0, 0.0, 400.0, 300.0), &fig, 1.0); - }); - let text = output - .shapes - .iter() - .filter(|shape| matches!(shape.shape, egui::Shape::Text(_))) - .count(); - let lines = output - .shapes - .iter() - .filter(|shape| matches!(shape.shape, egui::Shape::LineSegment { .. })) - .count(); - assert_eq!(text, 0); - assert!(lines > 2, "axis and tick marks remain on screen"); - } -} - fn paint_document_object( painter: &egui::Painter, page: Rect, object: &DocumentObject, viewport: DocumentViewport, + stats: Option<&mut RenderStats>, ) { if !object.visible { return; @@ -650,7 +657,7 @@ fn paint_document_object( object.frame.width * viewport.zoom, object.frame.height * viewport.zoom, ); - paint(painter, frame, object.figure, viewport.zoom); + paint_with_stats(painter, frame, object.figure, viewport.zoom, stats); if let Some(title) = &object.title { let pos = Pos2::new( frame.left + title.position[0] * viewport.zoom, diff --git a/crates/render/src/screen_stats.rs b/crates/render/src/screen_stats.rs new file mode 100644 index 0000000..9804541 --- /dev/null +++ b/crates/render/src/screen_stats.rs @@ -0,0 +1,32 @@ +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct RenderStats { + pub documents_painted: usize, + pub line_series_visited: usize, + /// Source points in the x-visible slice inspected by line pooling. This is + /// zero when pooling is unnecessary. + pub line_source_points_scanned: usize, + pub line_points_emitted: usize, +} + +pub(crate) fn visible_source_len(points: &[[f64; 2]], x_min: f64, x_max: f64) -> usize { + let first_x = points.first().map(|p| p[0]); + let last_x = points.last().map(|p| p[0]); + let (start, end) = match (first_x, last_x) { + (Some(first), Some(last)) if first < last => ( + points.partition_point(|p| p[0] < x_min).saturating_sub(1), + points + .partition_point(|p| p[0] <= x_max) + .saturating_add(1) + .min(points.len()), + ), + (Some(first), Some(last)) if first > last => ( + points.partition_point(|p| p[0] > x_max).saturating_sub(1), + points + .partition_point(|p| p[0] >= x_min) + .saturating_add(1) + .min(points.len()), + ), + _ => (0, points.len()), + }; + end.saturating_sub(start.min(end)) +} diff --git a/crates/render/src/screen_tests.rs b/crates/render/src/screen_tests.rs index d1cd3fd..bfb7aa7 100644 --- a/crates/render/src/screen_tests.rs +++ b/crates/render/src/screen_tests.rs @@ -1,5 +1,40 @@ use super::{MAX_LINE_COLUMNS, MIN_LINE_COLUMNS, line_columns, screen_line_points}; +#[test] +fn hidden_axis_text_keeps_screen_axis_and_tick_shapes() { + use plotx_figure::{Axis, Figure}; + let mut fig = Figure::new( + "", + Axis::new("UNIQUE_X_TITLE", 0.0, 90_000.0), + Axis::new("UNIQUE_Y_TITLE", -90_000.0, 90_000.0), + ); + fig.x.show_tick_labels = false; + fig.x.show_label = false; + fig.y.show_tick_labels = false; + fig.y.show_label = false; + let ctx = egui::Context::default(); + let output = ctx.run_ui(egui::RawInput::default(), |ui| { + super::paint( + ui.painter(), + crate::Rect::new(0.0, 0.0, 400.0, 300.0), + &fig, + 1.0, + ); + }); + let text = output + .shapes + .iter() + .filter(|shape| matches!(shape.shape, egui::Shape::Text(_))) + .count(); + let lines = output + .shapes + .iter() + .filter(|shape| matches!(shape.shape, egui::Shape::LineSegment { .. })) + .count(); + assert_eq!(text, 0); + assert!(lines > 2, "axis and tick marks remain on screen"); +} + #[test] fn long_trace_is_bounded_and_keeps_narrow_extrema() { let mut points: Vec<_> = (0..2_000_000).map(|index| [index as f64, 0.0]).collect(); @@ -78,3 +113,43 @@ fn columns_track_device_pixels_within_bounds() { assert_eq!(line_columns(900.0, 2.0), 3_600); assert_eq!(line_columns(9_000.0, 2.0), MAX_LINE_COLUMNS); } + +#[test] +fn render_stats_count_document_and_define_pooled_line_work() { + use plotx_figure::{Axis, Color, Figure, Series}; + let mut fig = Figure::new("", Axis::new("x", 0.0, 10_000.0), Axis::new("y", -1.0, 1.0)); + fig.series.push(Series::line( + "trace", + (0..10_000).map(|i| [i as f64, (i % 3) as f64]).collect(), + )); + let document = crate::Document { + width: 400.0, + height: 300.0, + background: Color::rgb(255, 255, 255), + items: vec![crate::DocumentItem::Plot(crate::DocumentObject { + id: "plot".into(), + frame: crate::Rect::new(0.0, 0.0, 400.0, 300.0), + figure: &fig, + visible: true, + title: None, + })], + }; + let ctx = egui::Context::default(); + let mut stats = super::RenderStats::default(); + let _ = ctx.run_ui(egui::RawInput::default(), |ui| { + super::paint_document_with_stats( + ui.painter(), + crate::Rect::new(0.0, 0.0, 400.0, 300.0), + &document, + crate::DocumentViewport { + zoom: 1.0, + pan: [0.0; 2], + }, + Some(&mut stats), + ); + }); + assert_eq!(stats.documents_painted, 1); + assert_eq!(stats.line_series_visited, 1); + assert_eq!(stats.line_source_points_scanned, 10_000); + assert!(stats.line_points_emitted <= MIN_LINE_COLUMNS * 2 + 2); +} diff --git a/docs/src/content/docs/guides/layout-and-export.md b/docs/src/content/docs/guides/layout-and-export.md index 763a06d..5377305 100644 --- a/docs/src/content/docs/guides/layout-and-export.md +++ b/docs/src/content/docs/guides/layout-and-export.md @@ -70,6 +70,20 @@ values. The basis applies wherever PlotX places plots for you — **Apply grid**, and dragging a plot onto a page that already holds one. +Dragging a plot onto another page moves it there and re-tiles the destination. +During the drag the plot travels with the pointer, keeping the point you grabbed +under the cursor, and the destination page draws where every plot will sit once +you release. On a page that already holds two or more plots, the whole page +re-tiles into an even grid and the arriving plot takes the cell you are pointing +at. + +If the move leaves the source page empty, PlotX deletes that page as part of the +drop, so the move and the deletion undo together. Hold `Alt` as you release to +keep the empty page instead; the status bar shows which way `Alt` will flip the +current drop. To keep empty source pages by default, turn on **Keep source canvas +when tiling its last object** in Preferences → General — `Alt` then removes them +for that one drop. + With the Select tool active, each non-zero page margin is drawn as a dashed line across the page, showing the content area you are laying out into; a margin of zero draws no line. Turning on the layout grid adds the cell diff --git a/docs/src/content/docs/reference/preferences.md b/docs/src/content/docs/reference/preferences.md index 95f12b4..858e26e 100644 --- a/docs/src/content/docs/reference/preferences.md +++ b/docs/src/content/docs/reference/preferences.md @@ -11,6 +11,9 @@ restores everything except your recent-files list. - **Object snapping** — snap plots and shapes to guides while dragging (also toggleable from the toolbar). +- **Keep source canvas when tiling its last object** — keep a page that a + drag-to-tile move empties, instead of deleting it along with the drop. Off by + default; hold `Alt` while releasing to reverse the choice for a single drop. - **Project backup copies** — keep a chosen number of complete previous saves as hidden files beside each project. Each copy can be as large as the project; choose Off to disable. diff --git a/docs/src/content/docs/zh-cn/guides/layout-and-export.md b/docs/src/content/docs/zh-cn/guides/layout-and-export.md index 190e4e0..3bde734 100644 --- a/docs/src/content/docs/zh-cn/guides/layout-and-export.md +++ b/docs/src/content/docs/zh-cn/guides/layout-and-export.md @@ -59,6 +59,17 @@ ACS、Elsevier、PNAS 和 IEEE,数值取自各出版社的作图规范)、 凡是由 PlotX 自动摆放图形的场合都遵循该依据——**Apply grid**,以及把图形 拖到已有图形的页面上时的自动平铺。 +把图拖到另一页面,即把它移到该页并重新平铺目标页。拖动过程中图形跟随 +指针移动,按下时抓住的那一点始终停在光标下,目标页面上同时画出松手后 +各图所在的位置。若目标页面上已有两个及以上的图,整页会重排成均匀网格, +拖入的图落在指针所指的那一格里。 + +如果这次移动把源页面清空,PlotX 会随本次拖放一并删除该页面,移动与删除 +同属一步撤销/重做。释放鼠标时按住 `Alt` 则保留这个空页面;状态栏会提示 +当前这次拖放按住 `Alt` 会变成哪种结果。若希望默认保留空的源页面,可在 +偏好设置 → General 中打开 **Keep source canvas when tiling its last +object**——此后按住 `Alt` 就只为这一次拖放删除空页面。 + 使用 Select 工具时,每条非零页边距都会画成一条贯穿页面的虚线,标示出正在 排版的内容区;设为 0 的一边不画线。打开布局网格会另外显示单元格轮廓, 拖动时出现的吸附参考线使用对比色。 diff --git a/docs/src/content/docs/zh-cn/reference/preferences.md b/docs/src/content/docs/zh-cn/reference/preferences.md index bca74c2..dcedb97 100644 --- a/docs/src/content/docs/zh-cn/reference/preferences.md +++ b/docs/src/content/docs/zh-cn/reference/preferences.md @@ -11,6 +11,9 @@ description: 偏好设置窗口中的每一项设置,按类别列出。 - **Object snapping**——拖动时把图形和形状吸附到参考线(也可在工具栏 中切换)。 +- **Keep source canvas when tiling its last object**——拖放平铺把页面上最后 + 一个对象移走后,保留这个空页面,而不随本次拖放一起删除。默认关闭;释放 + 鼠标时按住 `Alt` 可仅为本次拖放反转此选择。 - **Project backup copies**——在每个项目旁以隐藏文件保留指定数量的完整 历史保存。每份副本可能与项目一样大;选 Off 关闭。 - **Automatic updates** 与 **Update channel**——见