From 9c8ed135cb516cedaa31b97da8b56c51641d2196 Mon Sep 17 00:00:00 2001 From: Dongcheng Lin Date: Sun, 6 Sep 2026 16:26:58 +0800 Subject: [PATCH 1/4] feat(ui): detach panels into new canvases after a dwell --- crates/app/src/ui/canvas/interactions.rs | 10 +- crates/app/src/ui/canvas/mod.rs | 1 + .../app/src/ui/canvas/panel_interactions.rs | 15 +- .../ui/canvas/panel_interactions/detach.rs | 154 ++++++++++++++++++ .../canvas/panel_interactions/detach/tests.rs | 142 ++++++++++++++++ crates/core/src/actions/mod.rs | 1 + crates/core/src/actions/panel_detach.rs | 115 +++++++++++++ crates/core/src/actions/panel_detach_tests.rs | 102 ++++++++++++ crates/core/src/actions/tests/interaction.rs | 1 + crates/core/src/state/ui_layout_drag.rs | 2 + .../content/docs/guides/layout-and-export.md | 9 + .../docs/zh-cn/guides/layout-and-export.md | 7 + 12 files changed, 554 insertions(+), 5 deletions(-) create mode 100644 crates/app/src/ui/canvas/panel_interactions/detach.rs create mode 100644 crates/app/src/ui/canvas/panel_interactions/detach/tests.rs create mode 100644 crates/core/src/actions/panel_detach.rs create mode 100644 crates/core/src/actions/panel_detach_tests.rs diff --git a/crates/app/src/ui/canvas/interactions.rs b/crates/app/src/ui/canvas/interactions.rs index 9fe01b6..a562171 100644 --- a/crates/app/src/ui/canvas/interactions.rs +++ b/crates/app/src/ui/canvas/interactions.rs @@ -544,7 +544,15 @@ pub(crate) fn handle_object_interactions( })); } } - handle_panel_drag(app, ci, rect, hover, primary_down, primary_released, alt); + handle_panel_drag( + app, + ci, + rect, + hover, + primary_down, + primary_released, + ui.ctx(), + ); let object_drag = match &app.session.ui.interaction { Interaction::Object(d) if d.canvas == ci => Some(d.clone()), _ => None, diff --git a/crates/app/src/ui/canvas/mod.rs b/crates/app/src/ui/canvas/mod.rs index 3c0cd66..9fe279e 100644 --- a/crates/app/src/ui/canvas/mod.rs +++ b/crates/app/src/ui/canvas/mod.rs @@ -264,6 +264,7 @@ pub fn render_central(app: &mut PlotxApp, ui: &mut Ui) { paint_property_readouts(app, ci, rect, &painter, chrome, ui.visuals().dark_mode); paint_wheel_target_hint(app, ci, rect, ui, &painter, chrome, ui.visuals().dark_mode); paint_tile_ghost(app, &painter, chrome); + paint_panel_detach(app, rect, &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/panel_interactions.rs b/crates/app/src/ui/canvas/panel_interactions.rs index 6da1b72..b231556 100644 --- a/crates/app/src/ui/canvas/panel_interactions.rs +++ b/crates/app/src/ui/canvas/panel_interactions.rs @@ -1,5 +1,8 @@ use super::*; +mod detach; +pub(crate) use detach::paint_panel_detach; + pub(crate) fn handle_panel_drag( app: &mut PlotxApp, ci: usize, @@ -7,7 +10,7 @@ pub(crate) fn handle_panel_drag( hover: Option, primary_down: bool, primary_released: bool, - alt: bool, + ctx: &egui::Context, ) { let Some(drag) = (match &app.session.ui.interaction { Interaction::Panel(d) if d.canvas == ci => Some(d.clone()), @@ -16,7 +19,7 @@ pub(crate) fn handle_panel_drag( return; }; let mut active = drag.active; - if primary_down + if (primary_down || primary_released) && let Some(screen_now) = hover && let Some(pointer_page) = screen_to_page_unbounded(app.session.board, &app.doc.canvases[ci], rect, screen_now) @@ -72,14 +75,17 @@ pub(crate) fn handle_panel_drag( } } } + detach::update(app, rect, hover, ctx); if (primary_released || !primary_down) && let Interaction::Panel(drag) = app.take_interaction() && active { - if let Some(preview) = app.session.ui.tile_drop.take() + if primary_released && detach::ready(&drag, ctx.input(|input| input.time)) { + detach::commit(app, &drag); + } else if let Some(preview) = app.session.ui.tile_drop.take() && let Some(source) = tile_source_for_panel(app, &drag) { - commit_tile_drop(app, source, preview, alt); + commit_tile_drop(app, source, preview, ctx.input(|input| input.modifiers.alt)); } else { finish_panel_drag(app, ci, drag); } @@ -138,6 +144,7 @@ pub(crate) fn begin_panel_drag( start_pointer: start, start_pointer_screen: [screen_pos.x, screen_pos.y], active: matches!(kind, ObjectDragKind::Resize(_)), + detached_since: None, })); } diff --git a/crates/app/src/ui/canvas/panel_interactions/detach.rs b/crates/app/src/ui/canvas/panel_interactions/detach.rs new file mode 100644 index 0000000..d59692b --- /dev/null +++ b/crates/app/src/ui/canvas/panel_interactions/detach.rs @@ -0,0 +1,154 @@ +use super::*; + +const DWELL_SECONDS: f64 = 0.5; + +fn fully_detached(app: &PlotxApp, drag: &PanelDrag, rect: EguiRect, pointer: Option) -> bool { + if !drag.active + || drag.kind != ObjectDragKind::Move + || !drag.others.is_empty() + || !pointer.is_some_and(|p| rect.contains(p)) + { + return false; + } + let Some(page) = app.doc.canvases.get(drag.canvas) else { + return false; + }; + let Some(panel) = page.panel(drag.panel) else { + return false; + }; + let frame = panel.frame; + let bounds = EguiRect::from_min_size( + Pos2::new(page.board_pos[0] + frame.x, page.board_pos[1] + frame.y), + Vec2::new(frame.width, frame.height), + ); + bounds.is_finite() + && app.doc.canvases.iter().all(|page| { + let size = page.size_pt(); + !bounds.intersects(EguiRect::from_min_size( + Pos2::new(page.board_pos[0], page.board_pos[1]), + Vec2::new(size[0], size[1]), + )) + }) +} + +pub(super) fn update( + app: &mut PlotxApp, + rect: EguiRect, + pointer: Option, + ctx: &egui::Context, +) { + let Interaction::Panel(drag) = &app.session.ui.interaction else { + return; + }; + let detached = fully_detached(app, drag, rect, pointer); + let now = ctx.input(|input| input.time); + if let Interaction::Panel(drag) = &mut app.session.ui.interaction { + if detached { + drag.detached_since.get_or_insert(now); + if !ready(drag, now) { + ctx.request_repaint_after(std::time::Duration::from_millis(16)); + } + } else { + drag.detached_since = None; + } + } +} + +pub(super) fn ready(drag: &PanelDrag, now: f64) -> bool { + drag.detached_since + .is_some_and(|since| now - since >= DWELL_SECONDS) +} + +pub(super) fn commit(app: &mut PlotxApp, drag: &PanelDrag) { + let Some(page) = app.doc.canvases.get_mut(drag.canvas) else { + return; + }; + let canvas = page.resource_id; + let origin = page.board_pos; + let Some(panel) = page.panel_mut(drag.panel) else { + return; + }; + let board_pos = [origin[0] + panel.frame.x, origin[1] + panel.frame.y]; + panel.frame = drag.before; + match app.detach_panel_action(canvas, drag.panel, board_pos) { + Ok(action) => { + app.execute_action(action); + if let Some(ci) = app.session.active_canvas { + app.select_panel(ci, drag.panel); + } + } + Err(error) => app.session.status = format!("Could not create canvas for Panel: {error}"), + } + app.reset_interaction(); +} + +pub(crate) fn paint_panel_detach( + app: &PlotxApp, + rect: EguiRect, + painter: &egui::Painter, + chrome: ChromeStyle, +) { + let Interaction::Panel(drag) = &app.session.ui.interaction else { + return; + }; + let Some(since) = drag.detached_since else { + return; + }; + let now = painter.ctx().input(|input| input.time); + let is_ready = ready(drag, now); + let Some(pointer) = painter.ctx().input(|input| input.pointer.hover_pos()) else { + return; + }; + let page = &app.doc.canvases[drag.canvas]; + let Some(panel) = page.panel(drag.panel) else { + return; + }; + let bt = BoardTransform::from_board(app.session.board, rect); + let frame = panel.frame; + let r = EguiRect::from_min_size( + bt.page_screen_rect(page).min + Vec2::new(frame.x, frame.y) * bt.zoom, + Vec2::new(frame.width, frame.height) * bt.zoom, + ); + let color = if is_ready { + Color32::from_rgb(36, 180, 110) + } else { + chrome.tile_target_stroke + }; + painter.rect_stroke( + r, + 0.0, + Stroke::new(if is_ready { 3.0_f32 } else { 1.5_f32 }, color), + StrokeKind::Inside, + ); + let size = Vec2::new(196.0, 34.0); + let min = (pointer + Vec2::new(16.0, 20.0)) + .min(rect.max - size) + .max(rect.min); + let badge = EguiRect::from_min_size(min, size); + let visuals = painter.ctx().global_style().visuals.clone(); + painter.rect_filled(badge, 4.0, visuals.extreme_bg_color); + painter.rect_stroke(badge, 4.0, Stroke::new(1.0_f32, color), StrokeKind::Inside); + painter.text( + badge.center() - Vec2::new(0.0, 2.0), + egui::Align2::CENTER_CENTER, + if is_ready { + "Release to create canvas" + } else { + "New canvas" + }, + egui::FontId::proportional(13.0), + visuals.text_color(), + ); + let progress = ((now - since) / DWELL_SECONDS).clamp(0.0, 1.0) as f32; + painter.rect_filled( + EguiRect::from_min_size( + badge.left_bottom() + Vec2::new(4.0, -5.0), + Vec2::new((size.x - 8.0) * progress, 2.0), + ), + 0.0, + color, + ); +} + +#[cfg(test)] +mod tests; diff --git a/crates/app/src/ui/canvas/panel_interactions/detach/tests.rs b/crates/app/src/ui/canvas/panel_interactions/detach/tests.rs new file mode 100644 index 0000000..17f1817 --- /dev/null +++ b/crates/app/src/ui/canvas/panel_interactions/detach/tests.rs @@ -0,0 +1,142 @@ +use super::*; + +fn fixture() -> (PlotxApp, egui::Context, EguiRect, Pos2) { + let mut app = PlotxApp::default(); + let mut page = CanvasDocument::new("Source".into(), [100.0, 100.0]); + let before = ObjectFrame::new(10.0, 10.0, 80.0, 60.0); + let panel = page.create_panel("Panel".into(), before); + app.doc.canvases.push(page); + app.session.active_canvas = Some(0); + app.session.board.world_center = [300.0, 0.0]; + app.session.board.zoom = 1.0; + let rect = EguiRect::from_min_size(Pos2::ZERO, Vec2::new(1000.0, 800.0)); + let origin = BoardTransform::from_board(app.session.board, rect) + .page_screen_rect(&app.doc.canvases[0]) + .min; + app.session.ui.interaction = Interaction::Panel(PanelDrag { + canvas: 0, + panel, + kind: ObjectDragKind::Move, + before, + others: vec![], + children: vec![], + start_pointer: [20.0, 20.0], + start_pointer_screen: [origin.x + 20.0, origin.y + 20.0], + active: false, + detached_since: None, + }); + ( + app, + egui::Context::default(), + rect, + origin + Vec2::new(360.0, 20.0), + ) +} + +fn frame( + app: &mut PlotxApp, + ctx: &egui::Context, + rect: EguiRect, + pointer: Option, + now: f64, + released: bool, +) { + let _ = ctx.run_ui( + egui::RawInput { + time: Some(now), + screen_rect: Some(rect), + ..Default::default() + }, + |ui| { + handle_panel_drag(app, 0, rect, pointer, !released, released, ui.ctx()); + }, + ); +} + +#[test] +fn panel_detach_requires_full_half_second_and_release_and_undo_restores_origin() { + let (mut app, ctx, rect, pointer) = fixture(); + let before = app.doc.canvases[0].panels.clone(); + frame(&mut app, &ctx, rect, Some(pointer), 1.0, false); + frame(&mut app, &ctx, rect, Some(pointer), 1.499, false); + assert!(matches!(&app.session.ui.interaction, Interaction::Panel(d) if !ready(d, 1.499))); + frame(&mut app, &ctx, rect, Some(pointer), 1.5, false); + assert_eq!(app.doc.canvases.len(), 1); + assert!(matches!(&app.session.ui.interaction, Interaction::Panel(d) if ready(d, 1.5))); + frame(&mut app, &ctx, rect, Some(pointer), 1.51, true); + assert_eq!(app.doc.canvases.len(), 2); + assert_eq!(app.doc.canvases[1].board_pos, [350.0, 10.0]); + assert_eq!(app.doc.canvases[1].panels[0].id, before[0].id); + app.undo(); + assert_eq!(app.doc.canvases.len(), 1); + assert_eq!(app.doc.canvases[0].panels, before); + assert!(!app.can_undo()); +} + +#[test] +fn panel_detach_resets_on_partial_overlap_or_pointer_loss_and_quick_release_moves_only() { + let (mut app, ctx, rect, pointer) = fixture(); + frame(&mut app, &ctx, rect, Some(pointer), 1.0, false); + frame(&mut app, &ctx, rect, Some(pointer), 1.5, false); + let overlap = pointer - Vec2::new(100.0, 0.0); + frame(&mut app, &ctx, rect, Some(overlap), 1.6, false); + assert!( + matches!(&app.session.ui.interaction, Interaction::Panel(d) if d.detached_since.is_none()) + ); + frame(&mut app, &ctx, rect, Some(pointer), 1.7, false); + frame(&mut app, &ctx, rect, None, 2.3, false); + assert!( + matches!(&app.session.ui.interaction, Interaction::Panel(d) if d.detached_since.is_none()) + ); + frame(&mut app, &ctx, rect, Some(pointer), 2.4, false); + frame(&mut app, &ctx, rect, Some(pointer), 2.899, true); + assert_eq!(app.doc.canvases.len(), 1); + assert_eq!(app.doc.canvases[0].panels[0].frame.x, 350.0); +} + +#[test] +fn panel_detach_checks_all_canvas_bounds_not_only_pointer_and_escape_cancels() { + let (mut app, ctx, rect, pointer) = fixture(); + let mut other = CanvasDocument::new("Other".into(), [20.0, 20.0]); + other.board_pos = [420.0, 10.0]; + app.doc.canvases.push(other); + frame(&mut app, &ctx, rect, Some(pointer), 1.0, false); + assert!( + matches!(&app.session.ui.interaction, Interaction::Panel(d) if d.detached_since.is_none()) + ); + app.doc.canvases.pop(); + frame(&mut app, &ctx, rect, Some(pointer), 1.1, false); + frame(&mut app, &ctx, rect, Some(pointer), 1.7, false); + app.cancel_interaction(); + assert!(matches!(app.session.ui.interaction, Interaction::Idle)); + assert_eq!(app.doc.canvases[0].panels[0].frame.x, 10.0); + assert!(!app.can_undo()); +} + +#[test] +fn panel_detach_excludes_resize_multi_selection_and_release_back_over_page() { + let (mut app, ctx, rect, pointer) = fixture(); + frame(&mut app, &ctx, rect, Some(pointer), 1.0, false); + if let Interaction::Panel(drag) = &mut app.session.ui.interaction { + drag.others.push((PanelId::new(), drag.before)); + } + frame(&mut app, &ctx, rect, Some(pointer), 1.6, false); + assert!( + matches!(&app.session.ui.interaction, Interaction::Panel(d) if d.detached_since.is_none()) + ); + if let Interaction::Panel(drag) = &mut app.session.ui.interaction { + drag.others.clear(); + } + frame(&mut app, &ctx, rect, Some(pointer), 1.7, false); + frame(&mut app, &ctx, rect, Some(pointer), 2.3, false); + frame( + &mut app, + &ctx, + rect, + Some(pointer - Vec2::new(340.0, 0.0)), + 2.4, + true, + ); + assert_eq!(app.doc.canvases.len(), 1); + assert_eq!(app.doc.canvases[0].panels[0].frame.x, 10.0); +} diff --git a/crates/core/src/actions/mod.rs b/crates/core/src/actions/mod.rs index 8863c6c..e6262b7 100644 --- a/crates/core/src/actions/mod.rs +++ b/crates/core/src/actions/mod.rs @@ -16,6 +16,7 @@ use plotx_processing::{AxisPipeline, Params2D, Preset2D}; mod app_impl; mod arrange; mod panel; +mod panel_detach; mod processing_state; mod transfer; mod zorder; diff --git a/crates/core/src/actions/panel_detach.rs b/crates/core/src/actions/panel_detach.rs new file mode 100644 index 0000000..fd0dac3 --- /dev/null +++ b/crates/core/src/actions/panel_detach.rs @@ -0,0 +1,115 @@ +use super::{Action, PanelActionError, PanelState}; +use crate::state::{CanvasDocument, CanvasId, GroupMember, PanelId, PlotxApp}; + +impl PlotxApp { + /// Move a complete Panel into a fitted page as one reversible transaction. + pub fn detach_panel_action( + &self, + canvas: CanvasId, + panel: PanelId, + board_pos: [f32; 2], + ) -> Result { + let ci = self + .doc + .canvas_index(canvas) + .ok_or_else(|| PanelActionError::Invalid("the canvas no longer exists".to_owned()))?; + if !board_pos.into_iter().all(f32::is_finite) { + return Err(PanelActionError::Invalid( + "invalid board position".to_owned(), + )); + } + let source = &self.doc.canvases[ci]; + let mut moved = source + .panel(panel) + .cloned() + .ok_or(PanelActionError::MissingPanel(panel))?; + if moved.locked || !moved.visible { + return Err(PanelActionError::Invalid( + "the panel must be visible and unlocked".to_owned(), + )); + } + source + .validate_structure() + .map_err(PanelActionError::Invalid)?; + let ids = moved.item_order.clone(); + let mut destination = CanvasDocument::new( + moved.name.clone(), + [ + moved.frame.width * 25.4 / 72.0, + moved.frame.height * 25.4 / 72.0, + ], + ); + destination.board_pos = board_pos; + destination.background = source.background; + destination.panel_label_style = source.panel_label_style; + destination.next_panel_label_slot = source.next_panel_label_slot; + destination.next_object_id = source.next_object_id; + destination.next_group_id = source.next_group_id; + destination.objects = source + .objects + .iter() + .filter(|item| ids.contains(&item.id)) + .cloned() + .collect(); + destination.groups = source + .groups + .iter() + .filter(|group| { + group + .members + .iter() + .all(|member| matches!(member, GroupMember::Content(id) if ids.contains(id))) + }) + .cloned() + .collect(); + destination.x_viewport_links = source.x_viewport_links.clone(); + destination.x_viewport_links.retain_mut(|group| { + group.members.retain(|id| ids.contains(id)); + group.members.len() >= 2 + }); + moved.frame.x = 0.0; + moved.frame.y = 0.0; + destination.panels.push(moved); + destination + .validate_structure() + .map_err(PanelActionError::Invalid)?; + + let before = PanelState::of(source); + let mut after = before.clone(); + after.panels.retain(|item| item.id != panel); + after.objects.retain(|item| !ids.contains(&item.id)); + after.groups.retain_mut(|group| { + group.members.retain(|member| match member { + GroupMember::Panel(id) => *id != panel, + GroupMember::Content(id) => !ids.contains(id), + }); + group.members.len() >= 2 + }); + let mut source_links = source.x_viewport_links.clone(); + source_links.retain_mut(|group| { + group.members.retain(|id| !ids.contains(id)); + group.members.len() >= 2 + }); + Ok(Action::Composite(vec![ + Action::ReplacePanelState { + canvas: ci, + before, + after, + }, + Action::SetXViewportLinks { + canvas: ci, + before: source.x_viewport_links.clone(), + after: source_links, + }, + Action::InsertCanvas { + index: self.doc.canvases.len(), + canvas: Box::new(destination), + active_before: self.session.active_canvas, + }, + ])) + } +} + +#[cfg(test)] +#[path = "panel_detach_tests.rs"] +mod tests; diff --git a/crates/core/src/actions/panel_detach_tests.rs b/crates/core/src/actions/panel_detach_tests.rs new file mode 100644 index 0000000..d2ba58e --- /dev/null +++ b/crates/core/src/actions/panel_detach_tests.rs @@ -0,0 +1,102 @@ +use super::*; +use crate::state::{ + CanvasObject, CanvasObjectKind, LayoutGroup, ObjectFrame, PanelLayout, TextBox, +}; + +#[test] +fn detach_panel_preserves_contents_metadata_groups_and_round_trips() { + let mut app = PlotxApp::default(); + let mut page = CanvasDocument::new("Source".into(), [100.0, 100.0]); + let panel = page.create_panel( + "Composite".into(), + ObjectFrame::new(20.0, 30.0, 180.0, 120.0), + ); + let ids: Vec<_> = (0..3).map(|_| page.allocate_object_id()).collect(); + for &id in &ids { + page.objects.push(CanvasObject { + id, + name: "Text".into(), + frame: ObjectFrame::new(5.0, 10.0, 30.0, 20.0), + locked: false, + visible: true, + kind: CanvasObjectKind::Text(TextBox::label("Kept".into())), + }); + } + let moved = page.panel_mut(panel).unwrap(); + moved.item_order = ids[..2].to_vec(); + moved.note = "Panel note".into(); + moved.layout = PanelLayout::HorizontalStack; + moved.clip_children = true; + page.groups.push(LayoutGroup { + id: 1, + members: ids[..2] + .iter() + .map(|id| GroupMember::Content(*id)) + .collect(), + }); + let before = page.clone(); + app.doc.canvases.push(page); + app.session.active_canvas = Some(0); + let action = app + .detach_panel_action(before.resource_id, panel, [400.0, -200.0]) + .unwrap(); + app.try_execute_action(action).unwrap(); + assert_eq!(app.doc.canvases.len(), 2); + assert_eq!(app.doc.canvases[0].objects.len(), 1); + assert!(app.doc.canvases[0].panels.is_empty()); + assert!(app.doc.canvases[0].groups.is_empty()); + let target = &app.doc.canvases[1]; + let new_id = target.resource_id; + assert_ne!(new_id, before.resource_id); + assert_eq!(target.board_pos, [400.0, -200.0]); + assert_eq!(target.objects.len(), 2); + assert_eq!(target.objects[0].frame, before.objects[0].frame); + assert_eq!(target.groups, before.groups); + let mut expected = before.panel(panel).unwrap().clone(); + expected.frame.x = 0.0; + expected.frame.y = 0.0; + assert_eq!(target.panel(panel), Some(&expected)); + assert!((target.size_pt()[0] - 180.0).abs() < 0.001); + for page in &app.doc.canvases { + page.validate_structure().unwrap(); + } + assert_eq!(app.session.active_canvas, Some(1)); + app.undo(); + assert_eq!(app.doc.canvases.len(), 1); + assert_eq!(app.doc.canvases[0].panels, before.panels); + assert_eq!(app.doc.canvases[0].groups, before.groups); + assert_eq!(app.doc.canvases[0].objects.len(), 3); + assert_eq!(app.session.active_canvas, Some(0)); + assert!(!app.can_undo()); + app.redo(); + assert_eq!(app.doc.canvases[1].resource_id, new_id); + assert_eq!(app.doc.canvases[1].panel(panel), Some(&expected)); +} + +#[test] +fn detach_empty_panel_and_reject_invalid_or_locked_source() { + let mut app = PlotxApp::default(); + let mut page = CanvasDocument::new("Source".into(), [100.0, 100.0]); + let panel = page.create_panel("Empty".into(), ObjectFrame::new(0.0, 0.0, 90.0, 60.0)); + let id = page.resource_id; + app.doc.canvases.push(page); + assert!(app.detach_panel_action(id, panel, [f32::NAN, 0.0]).is_err()); + assert!( + app.detach_panel_action(CanvasId::new(), panel, [1.0, 1.0]) + .is_err() + ); + app.doc.canvases[0].panel_mut(panel).unwrap().locked = true; + assert!(app.detach_panel_action(id, panel, [1.0, 1.0]).is_err()); + app.doc.canvases[0].panel_mut(panel).unwrap().locked = false; + app.try_execute_action(app.detach_panel_action(id, panel, [1.0, 1.0]).unwrap()) + .unwrap(); + assert!( + app.doc.canvases[1] + .panel(panel) + .unwrap() + .item_order + .is_empty() + ); + app.undo(); + assert!(app.doc.canvases[0].panel(panel).is_some()); +} diff --git a/crates/core/src/actions/tests/interaction.rs b/crates/core/src/actions/tests/interaction.rs index 6241f07..de63bde 100644 --- a/crates/core/src/actions/tests/interaction.rs +++ b/crates/core/src/actions/tests/interaction.rs @@ -60,6 +60,7 @@ fn gesture_active_covers_only_the_board_freezing_drags() { before: frame, others: Vec::new(), children: Vec::new(), + detached_since: None, start_pointer: [0.0, 0.0], start_pointer_screen: [0.0, 0.0], active: true, diff --git a/crates/core/src/state/ui_layout_drag.rs b/crates/core/src/state/ui_layout_drag.rs index 019ae62..b794719 100644 --- a/crates/core/src/state/ui_layout_drag.rs +++ b/crates/core/src/state/ui_layout_drag.rs @@ -50,4 +50,6 @@ pub struct PanelDrag { pub start_pointer: [f32; 2], pub start_pointer_screen: [f32; 2], pub active: bool, + /// Start of continuous full separation from every canvas, in UI clock seconds. + pub detached_since: Option, } diff --git a/docs/src/content/docs/guides/layout-and-export.md b/docs/src/content/docs/guides/layout-and-export.md index 21bcff5..af72130 100644 --- a/docs/src/content/docs/guides/layout-and-export.md +++ b/docs/src/content/docs/guides/layout-and-export.md @@ -101,6 +101,15 @@ left to right, then top to bottom. A letter placed over an image automatically switches between black and white for contrast. Manual and locked labels remain visible on a page with one Panel. +Drag a single Panel completely clear of every canvas and keep it outside for +0.5 seconds to prepare a new canvas. The preview shows a progress bar, then a +green outline and **Release to create canvas**. Release to move the whole Panel +onto a new canvas fitted to its current size at that board position. Its contents, +labels, note, and internal layout are retained; the source canvas stays in place. +The move and canvas creation undo together. Returning any part of the Panel to a +canvas resets the timer. Resizing or dragging multiple Panels does not create a +canvas, and pressing `Esc` cancels the gesture. + ## Canvas size The active page shows a size chip above its top-left corner — the current 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 e1e056b..f6e3396 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 @@ -80,6 +80,13 @@ Stack**、**Horizontal Stack** 与 **Grid** 会按选定的间距、内边距和 阅读顺序排列。标签位于图片上时会自动切换黑白颜色以保持清晰;手工标签和锁定 标签在单 Panel 页面中仍会显示。 +将单个 Panel 完全拖离所有 canvas,并在外部持续停留 0.5 秒,即可准备新建 canvas。 +预览会先显示进度条,随后变为绿色边框,并显示 **Release to create canvas**。 +此时松手,整个 Panel 会移入当前位置新建的 canvas,页面尺寸与 Panel 一致。 +内容、标签、备注和内部布局均保留,源 canvas 保持原位;移动和新建可一起撤销。 +Panel 任一部分重新进入 canvas 都会重置计时。调整尺寸或拖动多个 Panel 时不会 +新建 canvas,按 `Esc` 可取消拖动。 + ## 画布尺寸 活动页面左上角上方会显示一个尺寸标签,内容为当前尺寸和匹配到的预设 From a8d20e723b67baa5aea6062a713ee41fcb263726 Mon Sep 17 00:00:00 2001 From: Dongcheng Lin Date: Sun, 6 Sep 2026 17:11:27 +0800 Subject: [PATCH 2/4] refactor(core): keep panel detachment with panel actions --- crates/core/src/actions/mod.rs | 1 - crates/core/src/actions/panel.rs | 3 +++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/core/src/actions/mod.rs b/crates/core/src/actions/mod.rs index e6262b7..8863c6c 100644 --- a/crates/core/src/actions/mod.rs +++ b/crates/core/src/actions/mod.rs @@ -16,7 +16,6 @@ use plotx_processing::{AxisPipeline, Params2D, Preset2D}; mod app_impl; mod arrange; mod panel; -mod panel_detach; mod processing_state; mod transfer; mod zorder; diff --git a/crates/core/src/actions/panel.rs b/crates/core/src/actions/panel.rs index 13da389..10408f7 100644 --- a/crates/core/src/actions/panel.rs +++ b/crates/core/src/actions/panel.rs @@ -4,6 +4,9 @@ use crate::state::{ PanelLabelMode, PanelLayout, PlotxApp, }; +#[path = "panel_detach.rs"] +mod detach; + #[derive(Debug, thiserror::Error, PartialEq, Eq)] pub enum PanelActionError { #[error("canvas {0} does not exist")] From e964d3d337fb9432a2862fad8c63290400a0a94f Mon Sep 17 00:00:00 2001 From: Dongcheng Lin Date: Sun, 6 Sep 2026 21:04:55 +0800 Subject: [PATCH 3/4] fix(ui): give detached panel previews a readable canvas background --- crates/app/src/ui/canvas/mod.rs | 1 + .../app/src/ui/canvas/panel_interactions.rs | 2 +- .../ui/canvas/panel_interactions/detach.rs | 37 +++++++++++++++++-- 3 files changed, 36 insertions(+), 4 deletions(-) diff --git a/crates/app/src/ui/canvas/mod.rs b/crates/app/src/ui/canvas/mod.rs index 9fe279e..2051e85 100644 --- a/crates/app/src/ui/canvas/mod.rs +++ b/crates/app/src/ui/canvas/mod.rs @@ -245,6 +245,7 @@ pub fn render_central(app: &mut PlotxApp, ui: &mut Ui) { } let editor_bounds = board_transform.canvas_editor_screen_rect(&app.doc.canvases[other]); if finite_rect_intersects(editor_bounds, clip) { + paint_panel_detach_background(app, other, rect, &painter); paint_document(app, other, rect, &painter); } if finite_rect_intersects(other_page, clip) && other != ci { diff --git a/crates/app/src/ui/canvas/panel_interactions.rs b/crates/app/src/ui/canvas/panel_interactions.rs index b231556..ce0f94c 100644 --- a/crates/app/src/ui/canvas/panel_interactions.rs +++ b/crates/app/src/ui/canvas/panel_interactions.rs @@ -1,7 +1,7 @@ use super::*; mod detach; -pub(crate) use detach::paint_panel_detach; +pub(crate) use detach::{paint_panel_detach, paint_panel_detach_background}; pub(crate) fn handle_panel_drag( app: &mut PlotxApp, diff --git a/crates/app/src/ui/canvas/panel_interactions/detach.rs b/crates/app/src/ui/canvas/panel_interactions/detach.rs index d59692b..ae0065a 100644 --- a/crates/app/src/ui/canvas/panel_interactions/detach.rs +++ b/crates/app/src/ui/canvas/panel_interactions/detach.rs @@ -121,9 +121,14 @@ pub(crate) fn paint_panel_detach( StrokeKind::Inside, ); let size = Vec2::new(196.0, 34.0); - let min = (pointer + Vec2::new(16.0, 20.0)) - .min(rect.max - size) - .max(rect.min); + let anchor = if r.bottom() + size.y + 8.0 <= rect.bottom() { + r.left_bottom() + Vec2::new(0.0, 8.0) + } else if r.top() - size.y - 8.0 >= rect.top() { + r.left_top() - Vec2::new(0.0, size.y + 8.0) + } else { + pointer + Vec2::new(16.0, 20.0) + }; + let min = anchor.min(rect.max - size).max(rect.min); let badge = EguiRect::from_min_size(min, size); let visuals = painter.ctx().global_style().visuals.clone(); painter.rect_filled(badge, 4.0, visuals.extreme_bg_color); @@ -152,3 +157,29 @@ pub(crate) fn paint_panel_detach( #[cfg(test)] mod tests; + +pub(crate) fn paint_panel_detach_background( + app: &PlotxApp, + ci: usize, + rect: EguiRect, + painter: &egui::Painter, +) { + let Interaction::Panel(drag) = &app.session.ui.interaction else { + return; + }; + if drag.canvas != ci || drag.detached_since.is_none() { + return; + } + let page = &app.doc.canvases[ci]; + let Some(panel) = page.panel(drag.panel) else { + return; + }; + let bt = BoardTransform::from_board(app.session.board, rect); + let frame = panel.frame; + let r = EguiRect::from_min_size( + bt.page_screen_rect(page).min + Vec2::new(frame.x, frame.y) * bt.zoom, + Vec2::new(frame.width, frame.height) * bt.zoom, + ); + let color = page.background; + painter.rect_filled(r, 0.0, Color32::from_rgb(color.r, color.g, color.b)); +} From fdc60d0004d1a6a745f74e857147f6460a023dfb Mon Sep 17 00:00:00 2001 From: Dongcheng Lin Date: Sun, 6 Sep 2026 22:12:34 +0800 Subject: [PATCH 4/4] fix(core): preserve panel detach position and undo context --- .../canvas/panel_interactions/detach/tests.rs | 2 +- crates/core/src/actions/app_impl/apply.rs | 9 ++- crates/core/src/actions/app_impl/mod.rs | 4 +- crates/core/src/actions/build.rs | 1 + crates/core/src/actions/mod.rs | 2 + crates/core/src/actions/panel_detach.rs | 12 ++-- crates/core/src/actions/panel_detach_tests.rs | 60 +++++++++++++++++++ 7 files changed, 80 insertions(+), 10 deletions(-) diff --git a/crates/app/src/ui/canvas/panel_interactions/detach/tests.rs b/crates/app/src/ui/canvas/panel_interactions/detach/tests.rs index 17f1817..fb0fcb3 100644 --- a/crates/app/src/ui/canvas/panel_interactions/detach/tests.rs +++ b/crates/app/src/ui/canvas/panel_interactions/detach/tests.rs @@ -114,7 +114,7 @@ fn panel_detach_checks_all_canvas_bounds_not_only_pointer_and_escape_cancels() { } #[test] -fn panel_detach_excludes_resize_multi_selection_and_release_back_over_page() { +fn panel_detach_excludes_multi_selection_and_release_back_over_page() { let (mut app, ctx, rect, pointer) = fixture(); frame(&mut app, &ctx, rect, Some(pointer), 1.0, false); if let Interaction::Panel(drag) = &mut app.session.ui.interaction { diff --git a/crates/core/src/actions/app_impl/apply.rs b/crates/core/src/actions/app_impl/apply.rs index be1b142..ea77fef 100644 --- a/crates/core/src/actions/app_impl/apply.rs +++ b/crates/core/src/actions/app_impl/apply.rs @@ -316,8 +316,13 @@ impl PlotxApp { self.session.ui.rename = None; } } - Action::InsertCanvas { index, canvas, .. } => { - self.insert_canvas_value(*index, canvas.as_ref().clone()); + Action::InsertCanvas { + index, + canvas, + auto_place, + .. + } => { + self.insert_canvas_value(*index, canvas.as_ref().clone(), *auto_place); } Action::ApplyTheme { canvas, after, .. } => { self.apply_theme_snapshot(*canvas, after); diff --git a/crates/core/src/actions/app_impl/mod.rs b/crates/core/src/actions/app_impl/mod.rs index 7a71550..74a938d 100644 --- a/crates/core/src/actions/app_impl/mod.rs +++ b/crates/core/src/actions/app_impl/mod.rs @@ -204,11 +204,11 @@ impl PlotxApp { } } - fn insert_canvas_value(&mut self, index: usize, mut canvas: CanvasDocument) { + fn insert_canvas_value(&mut self, index: usize, mut canvas: CanvasDocument, auto_place: bool) { if index > self.doc.canvases.len() { return; } - if canvas.board_pos == [0.0, 0.0] { + if auto_place { canvas.board_pos = crate::state::next_board_frame_pos(self, canvas.size_pt()); } self.doc.canvases.insert(index, canvas); diff --git a/crates/core/src/actions/build.rs b/crates/core/src/actions/build.rs index af7ba70..be2487c 100644 --- a/crates/core/src/actions/build.rs +++ b/crates/core/src/actions/build.rs @@ -471,6 +471,7 @@ impl Action { ) -> Self { Self::InsertCanvas { index, + auto_place: canvas.board_pos == [0.0, 0.0], canvas: Box::new(canvas), active_before, } diff --git a/crates/core/src/actions/mod.rs b/crates/core/src/actions/mod.rs index 8863c6c..83e09d9 100644 --- a/crates/core/src/actions/mod.rs +++ b/crates/core/src/actions/mod.rs @@ -469,6 +469,8 @@ pub enum Action { index: usize, canvas: Box, active_before: Option, + /// Resolve a default position on insertion; false preserves an explicit origin. + auto_place: bool, }, /// Apply a document-level style theme to a canvas: its background, the app's /// new-object style defaults, and its existing objects' colours, as one step. diff --git a/crates/core/src/actions/panel_detach.rs b/crates/core/src/actions/panel_detach.rs index fd0dac3..f48ebf4 100644 --- a/crates/core/src/actions/panel_detach.rs +++ b/crates/core/src/actions/panel_detach.rs @@ -90,7 +90,14 @@ impl PlotxApp { group.members.retain(|id| !ids.contains(id)); group.members.len() >= 2 }); + // Undo must restore the source contents before reactivating its dataset context. Ok(Action::Composite(vec![ + Action::InsertCanvas { + index: self.doc.canvases.len(), + canvas: Box::new(destination), + active_before: self.session.active_canvas, + auto_place: false, + }, Action::ReplacePanelState { canvas: ci, before, @@ -101,11 +108,6 @@ impl PlotxApp { before: source.x_viewport_links.clone(), after: source_links, }, - Action::InsertCanvas { - index: self.doc.canvases.len(), - canvas: Box::new(destination), - active_before: self.session.active_canvas, - }, ])) } } diff --git a/crates/core/src/actions/panel_detach_tests.rs b/crates/core/src/actions/panel_detach_tests.rs index d2ba58e..83658da 100644 --- a/crates/core/src/actions/panel_detach_tests.rs +++ b/crates/core/src/actions/panel_detach_tests.rs @@ -3,6 +3,66 @@ use crate::state::{ CanvasObject, CanvasObjectKind, LayoutGroup, ObjectFrame, PanelLayout, TextBox, }; +#[test] +fn detach_panel_undo_restores_active_dataset_and_redo_retains_it() { + let mut app = crate::actions::tests::sample_app(); + let object = app.doc.canvases[0].objects[0].id; + let panel = app.doc.canvases[0].create_panel_for_plot(object).unwrap(); + let canvas = app.doc.canvases[0].resource_id; + assert_eq!(app.active_dataset(), Some(0)); + app.try_execute_action( + app.detach_panel_action(canvas, panel, [500.0, 100.0]) + .unwrap(), + ) + .unwrap(); + for _ in 0..2 { + assert_eq!(app.session.active_canvas, Some(1)); + assert_eq!(app.active_dataset(), Some(0)); + assert!(app.doc.canvases[1].object(object).is_some()); + app.undo(); + assert_eq!(app.session.active_canvas, Some(0)); + assert!(app.doc.canvases[0].panel(panel).is_some()); + assert!(app.doc.canvases[0].object(object).is_some()); + assert_eq!(app.active_dataset(), Some(0)); + assert!(!app.can_undo()); + app.redo(); + } +} + +#[test] +fn detach_panel_preserves_explicit_origin_next_to_existing_canvas() { + let mut app = PlotxApp::default(); + let mut page = CanvasDocument::new("Source".into(), [100.0, 100.0]); + page.board_pos = [100.0, 0.0]; + let panel = page.create_panel("Panel".into(), ObjectFrame::new(0.0, 0.0, 90.0, 60.0)); + let canvas = page.resource_id; + app.doc.canvases.push(page); + app.session.active_canvas = Some(0); + app.try_execute_action(app.detach_panel_action(canvas, panel, [0.0, 0.0]).unwrap()) + .unwrap(); + assert_eq!(app.doc.canvases[1].board_pos, [0.0, 0.0]); + app.undo(); + app.redo(); + assert_eq!(app.doc.canvases[1].board_pos, [0.0, 0.0]); +} + +#[test] +fn ordinary_canvas_insertion_still_auto_places_default_origin() { + let mut app = PlotxApp::default(); + app.doc + .canvases + .push(CanvasDocument::new("Existing".into(), [100.0, 100.0])); + let page = CanvasDocument::new("New".into(), [30.0, 20.0]); + let expected = crate::state::next_board_frame_pos(&app, page.size_pt()); + assert_ne!(expected, [0.0, 0.0]); + app.try_execute_action(Action::insert_canvas(1, page, Some(0))) + .unwrap(); + assert_eq!(app.doc.canvases[1].board_pos, expected); + app.undo(); + app.redo(); + assert_eq!(app.doc.canvases[1].board_pos, expected); +} + #[test] fn detach_panel_preserves_contents_metadata_groups_and_round_trips() { let mut app = PlotxApp::default();