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:
parent
4bb91af968
commit
e2aff6c542
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
Loading…
Reference in a new issue