feat(shell): canvas click-to-select + drag-to-move

The canvas was pan-only; nodes could only be selected from the
LayerPanel and never moved without editing X/Y in the property
panel. Now:

* Document::node_at_doc_point walks the active page top-most-first
  and returns the topmost node whose aggregate bounds contain the
  document-space point. Children are tested before parents so a
  click on a button-rect inside a Frame selects the rect, not the
  Frame.

* Document::translate_selected moves the selected node by (dx, dy)
  document px. Leaf nodes update bounds.origin directly; container
  nodes (Group / unbounded Frame) translate every descendant that
  carries bounds, so dragging a Group moves the whole subtree.

* WidgetHostNative tracks a NodeDragState. Press over a node ⇒
  select + start node-drag. Cursor-move converts the screen-space
  delta to document space via the live zoom (no canvas_region
  offset needed because deltas are translation-invariant) and
  calls translate_selected. Release clears the drag.

* The Hand tool keeps its pure-pan behaviour. Empty-canvas press
  with any other tool clears the selection + starts a pan-drag,
  same as before.
This commit is contained in:
Kayshen-X 2026-05-10 23:23:28 +08:00
parent 4bb91af968
commit e2aff6c542
2 changed files with 153 additions and 4 deletions

View file

@ -854,6 +854,42 @@ impl Document {
.expect("Document::first_page on empty pages — use Document::empty for a default page")
}
/// Hit-test the active page at a document-space point. Returns
/// the topmost node id whose bounds (or aggregate bounds for
/// containers) contain `point`. Walks children in reverse z-
/// order (last child = top-most) so a stack of overlapping
/// rects resolves to the visually topmost one. `None` if the
/// click is in canvas dead space or no active page exists.
pub fn node_at_doc_point(&self, point: crate::Point2D) -> Option<NodeId> {
let page = self.active_page()?;
for child in page.children.iter().rev() {
if let Some(hit) = hit_test_walk(child, point) {
return Some(hit);
}
}
None
}
/// Translate the selected node by `(dx, dy)` document px. Leaf
/// nodes (with their own bounds) move directly; container
/// nodes (Group / unbounded Frame) translate every descendant
/// that carries bounds. No-op when nothing is selected or the
/// active page is missing.
pub fn translate_selected(&mut self, dx: f32, dy: f32) {
if !self.selected.is_real() {
return;
}
let sel = self.selected;
let Some(page) = self.pages.get_mut(self.active_page_index) else {
return;
};
for child in &mut page.children {
if translate_walk(child, sel, dx, dy) {
return;
}
}
}
/// Apply a parsed property edit to the selected node. Mirrors
/// the TS `useDocumentStore` mutation handlers — only this
/// helper writes back to bounds, so call sites can stay
@ -938,6 +974,61 @@ impl Document {
}
}
/// Recursive helper for `Document::node_at_doc_point` — returns
/// the topmost id whose bounds contain `point`.
fn hit_test_walk(node: &Node, point: crate::Point2D) -> Option<NodeId> {
// Walk children top-most first.
for child in node.children.iter().rev() {
if let Some(hit) = hit_test_walk(child, point) {
return Some(hit);
}
}
// No child covers the point — does this node?
let bounds = node.aggregate_bounds();
if bounds.size.x > 0.0
&& bounds.size.y > 0.0
&& point.x >= bounds.origin.x
&& point.x <= bounds.origin.x + bounds.size.x
&& point.y >= bounds.origin.y
&& point.y <= bounds.origin.y + bounds.size.y
{
return Some(node.id);
}
None
}
/// Recursive helper for `Document::translate_selected`. Returns
/// `true` once `target` has been translated.
fn translate_walk(node: &mut Node, target: NodeId, dx: f32, dy: f32) -> bool {
if node.id == target {
if node.bounds.size.x > 0.0 || node.bounds.size.y > 0.0 {
node.bounds.origin.x += dx;
node.bounds.origin.y += dy;
} else {
for child in &mut node.children {
translate_subtree(child, dx, dy);
}
}
return true;
}
for child in &mut node.children {
if translate_walk(child, target, dx, dy) {
return true;
}
}
false
}
fn translate_subtree(node: &mut Node, dx: f32, dy: f32) {
if node.bounds.size.x > 0.0 || node.bounds.size.y > 0.0 {
node.bounds.origin.x += dx;
node.bounds.origin.y += dy;
}
for child in &mut node.children {
translate_subtree(child, dx, dy);
}
}
/// Recursive helper for `Document::commit_property_edit`. Returns
/// `true` once the edit lands on the matching node.
fn commit_property_walk(node: &mut Node, sel: NodeId, focus: PropertyFocus, value: f32) -> bool {

View file

@ -140,6 +140,11 @@ pub struct WidgetHostNative {
/// within the resize gutter of LayerPanel's right edge or
/// PropertyPanel's left edge.
panel_resize: Option<PanelResize>,
/// Active node-drag — set when the user presses on a node in
/// the canvas with the Select tool. Tracks the document-space
/// cursor anchor so each `apply_cursor_move` translates the
/// selected node by the delta.
node_drag: Option<NodeDragState>,
/// Host-supplied frame timestamp in milliseconds. Drives the
/// caret blink via `jian_core::anim::blink_visible`. The
/// inspector_window runner refreshes this once per
@ -179,6 +184,17 @@ const PANEL_RESIZE_GUTTER: f32 = 4.0;
const PANEL_MIN_WIDTH: f32 = 180.0;
const PANEL_MAX_WIDTH: f32 = 480.0;
/// Active node-drag — tracks the previous cursor position in
/// SCREEN coordinates. Each `apply_cursor_move` divides the
/// screen-space delta by the active zoom to get a doc-space
/// translation, which sidesteps canvas_region offset math (the
/// offset cancels for incremental deltas).
#[derive(Debug, Clone, Copy)]
struct NodeDragState {
last_screen_x: f32,
last_screen_y: f32,
}
#[derive(Debug, Clone, Copy)]
struct ChatDragState {
/// Pointer offset within the panel rect when the drag began.
@ -199,6 +215,7 @@ impl WidgetHostNative {
drag: None,
chat_drag: None,
panel_resize: None,
node_drag: None,
now_ms: 0,
}
}
@ -532,11 +549,34 @@ impl WidgetHostNative {
return true;
}
// 4. Empty-canvas click: clear selection (collapses the
// PropertyPanel) + start a pan-drag. Selection clear
// is the "click blank to deselect" UX the user
// requested.
// 4. Canvas click — the Hand tool always pans; otherwise
// hit-test nodes first (select + start a node-drag), and
// fall back to "click blank to deselect + start pan" only
// when no node is under the cursor.
if self.over_canvas(x, y, viewport_width, viewport_height) {
if matches!(
self.document.tool,
openpencil_shell_core::document::Tool::Hand
) {
self.drag = Some(DragState {
last_x: x,
last_y: y,
});
return false;
}
let (cx0, cy0, _cw, _ch) = self.canvas_region(viewport_width, viewport_height);
let canvas_local = Point2D::new(x - cx0, y - cy0);
let doc_point = self.document.viewport.to_document(canvas_local);
if let Some(node_id) = self.document.node_at_doc_point(doc_point) {
let was_selected = self.document.selected == node_id;
self.document.selected = node_id;
self.node_drag = Some(NodeDragState {
last_screen_x: x,
last_screen_y: y,
});
return !was_selected;
}
// Empty canvas — clear selection + start pan-drag.
let cleared = self.document.selected != openpencil_shell_core::document::NodeId::NONE;
if cleared {
self.document.selected = openpencil_shell_core::document::NodeId::NONE;
@ -553,6 +593,18 @@ impl WidgetHostNative {
/// Cursor-move handler. Drives canvas pan-drag, chat-panel
/// drag, or no-op. Returns whether the host should repaint.
pub fn apply_cursor_move(&mut self, x: f32, y: f32) -> bool {
if let Some(drag) = self.node_drag.as_mut() {
let zoom = self.document.viewport.zoom.max(0.0001);
let dx = (x - drag.last_screen_x) / zoom;
let dy = (y - drag.last_screen_y) / zoom;
drag.last_screen_x = x;
drag.last_screen_y = y;
if dx != 0.0 || dy != 0.0 {
self.document.translate_selected(dx, dy);
return true;
}
return false;
}
if let Some(resize) = self.panel_resize {
let dx = x - resize.start_x;
match resize.kind {
@ -592,6 +644,9 @@ impl WidgetHostNative {
if self.panel_resize.take().is_some() {
return true;
}
if self.node_drag.take().is_some() {
return true;
}
if let Some(d) = self.chat_drag.take() {
let center = Point2D::new(
d.pos_x + AI_CHAT_WIDTH / 2.0,
@ -612,6 +667,9 @@ impl WidgetHostNative {
if self.panel_resize.take().is_some() {
return true;
}
if self.node_drag.take().is_some() {
return true;
}
// If a chat drag was in flight without a known viewport,
// we can't snap; just drop it (best effort).
if self.chat_drag.take().is_some() {