feat(canvas): pen bezier handle editing — render, handles, drag
Completes pen bezier-handle editing on top of the Stage-1 data model. - canvas_viewport_paint: `flatten_path` — Path nodes with anchor control handles render as tessellated cubic Beziers (16 steps / segment); handle-free paths keep the straight `points` polyline. - canvas_viewport: the Pen-tool anchor overlay now draws each anchor's two control handles (line + dot), with a faint "ghost" dot offset from the anchor when a handle is unset — grab it to create the handle. `path_handle_positions` resolves real / ghost handle positions, shared with the host hit-test. - op-host-native: `path_anchor_hit` now distinguishes anchor body vs handle_in / handle_out (`AnchorDragTarget`); `PathAnchorDragState` carries the target, the anchor's fixed position, and the Shift state. The move handler drags the anchor or a handle — a handle drag sets the anchor's point type on first motion (Shift = independent/broken, else mirrored/smooth) so `set_path_anchor_handle` mirrors the opposite handle. Release commits history only on actual motion. op-editor-ui 148 / op-host-native 64 / op-host-desktop 21 / op-pen-loader 21 tests green (+3 flatten-path units). 🤖 Generated with [Claude Code](https://claude.com/claude-code)
This commit is contained in:
parent
79b51573dd
commit
112d1f08bb
|
|
@ -24,7 +24,7 @@
|
|||
|
||||
use crate::layout_scene::LayoutScene;
|
||||
use crate::layout_scene::NodeKind;
|
||||
use crate::layout_scene::SceneNode;
|
||||
use crate::layout_scene::{SceneAnchor, SceneNode};
|
||||
use crate::theme::Theme;
|
||||
use crate::widgets::editor_state_ext::theme_for;
|
||||
use crate::widgets::{LayoutBox, LayoutCx, PaintCx, Widget, WidgetId};
|
||||
|
|
@ -53,6 +53,26 @@ pub enum SelectionHandle {
|
|||
/// 4 selection corners. Matches the TS `ROTATE_OUTER_RADIUS`.
|
||||
const ROTATE_OUTER_RADIUS: f32 = 16.0;
|
||||
|
||||
/// Screen-px offset of a "ghost" handle dot from its anchor when the
|
||||
/// handle is unset — far enough from the anchor body to grab.
|
||||
pub const PATH_HANDLE_GHOST_PX: f32 = 26.0;
|
||||
|
||||
/// Doc-space positions of a path anchor's incoming + outgoing bezier
|
||||
/// control handles. An unset handle is given a "ghost" position
|
||||
/// offset from the anchor (scaled to `zoom`) so the user can grab it
|
||||
/// to create the handle. Returns `(handle_in, handle_out)`. Shared by
|
||||
/// the overlay painter and the host's handle hit-test.
|
||||
pub fn path_handle_positions(anchor: &SceneAnchor, zoom: f32) -> (Point2D, Point2D) {
|
||||
let ghost = PATH_HANDLE_GHOST_PX / zoom.max(0.0001);
|
||||
let hin = anchor
|
||||
.handle_in
|
||||
.unwrap_or(Point2D::new(anchor.pos.x - ghost, anchor.pos.y));
|
||||
let hout = anchor
|
||||
.handle_out
|
||||
.unwrap_or(Point2D::new(anchor.pos.x + ghost, anchor.pos.y));
|
||||
(hin, hout)
|
||||
}
|
||||
|
||||
/// The three arc-edit handles on a selected Ellipse — start angle,
|
||||
/// sweep (end) angle, and the donut inner-radius.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
|
|
@ -483,24 +503,60 @@ impl<'a> Widget for CanvasViewport<'a> {
|
|||
}
|
||||
|
||||
// 4b. Per-anchor handles for the selected Path node when the
|
||||
// Pen tool is active — surfaces the drag target that
|
||||
// `path_anchor_drag` consumes.
|
||||
// Pen tool is active — anchor dots plus the two bezier
|
||||
// control handles (line + dot; a faint "ghost" dot when
|
||||
// the handle is unset, draggable to create it).
|
||||
if matches!(self.tool, op_editor_core::Tool::Pen) && self.selected_set.len() == 1 {
|
||||
if let Some(page) = self.scene.active_page() {
|
||||
if let Some(node) = page.find(&self.selected) {
|
||||
if matches!(node.kind, NodeKind::Path) {
|
||||
let r = 4.0; // screen-px radius
|
||||
if let Some(node) = self
|
||||
.scene
|
||||
.active_page()
|
||||
.and_then(|p| p.find(&self.selected))
|
||||
{
|
||||
if matches!(node.kind, NodeKind::Path) {
|
||||
let zoom = viewport.zoom;
|
||||
let to_screen = |p: Point2D| {
|
||||
Point2D::new(
|
||||
rect.origin.x + viewport.pan_x + p.x * zoom,
|
||||
rect.origin.y + viewport.pan_y + p.y * zoom,
|
||||
)
|
||||
};
|
||||
let dot = |cx: &mut PaintCx<'_>, c: Point2D, r: f32, fill, line| {
|
||||
let b = Rect {
|
||||
origin: Point2D::new(c.x - r, c.y - r),
|
||||
size: Point2D::new(r * 2.0, r * 2.0),
|
||||
};
|
||||
cx.backend.fill_oval(b, fill);
|
||||
cx.backend.stroke_oval(b, line, 1.5);
|
||||
};
|
||||
let ghost = crate::Color {
|
||||
a: self.theme.primary.a * 0.4,
|
||||
..self.theme.primary
|
||||
};
|
||||
for anchor in &node.path_anchors {
|
||||
let center = to_screen(anchor.pos);
|
||||
let (hin, hout) = path_handle_positions(anchor, zoom);
|
||||
for (pos, is_set) in [
|
||||
(hin, anchor.handle_in.is_some()),
|
||||
(hout, anchor.handle_out.is_some()),
|
||||
] {
|
||||
let hs = to_screen(pos);
|
||||
let tint = if is_set { self.theme.primary } else { ghost };
|
||||
cx.backend.stroke_line(center, hs, tint, 1.0);
|
||||
dot(cx, hs, 3.0, self.theme.background, tint);
|
||||
}
|
||||
// Anchor dot painted last so it sits on top.
|
||||
dot(cx, center, 4.0, self.theme.background, self.theme.primary);
|
||||
}
|
||||
// Paths with no anchor data still show plain dots.
|
||||
if node.path_anchors.is_empty() {
|
||||
for p in &node.points {
|
||||
let center = Point2D::new(
|
||||
rect.origin.x + viewport.pan_x + p.x * viewport.zoom,
|
||||
rect.origin.y + viewport.pan_y + p.y * viewport.zoom,
|
||||
dot(
|
||||
cx,
|
||||
to_screen(*p),
|
||||
4.0,
|
||||
self.theme.background,
|
||||
self.theme.primary,
|
||||
);
|
||||
let bounds = Rect {
|
||||
origin: Point2D::new(center.x - r, center.y - r),
|
||||
size: Point2D::new(r * 2.0, r * 2.0),
|
||||
};
|
||||
cx.backend.fill_oval(bounds, self.theme.background);
|
||||
cx.backend.stroke_oval(bounds, self.theme.primary, 1.5);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -110,6 +110,45 @@ fn paint_ellipse(cx: &mut PaintCx<'_>, node: &SceneNode, world_rect: Rect, zoom:
|
|||
}
|
||||
}
|
||||
|
||||
/// One point on the cubic Bezier `p0→p3` (control points `p1`,`p2`).
|
||||
pub(crate) fn cubic_point(p0: Point2D, p1: Point2D, p2: Point2D, p3: Point2D, t: f32) -> Point2D {
|
||||
let u = 1.0 - t;
|
||||
let (w0, w1, w2, w3) = (u * u * u, 3.0 * u * u * t, 3.0 * u * t * t, t * t * t);
|
||||
Point2D::new(
|
||||
w0 * p0.x + w1 * p1.x + w2 * p2.x + w3 * p3.x,
|
||||
w0 * p0.y + w1 * p1.y + w2 * p2.y + w3 * p3.y,
|
||||
)
|
||||
}
|
||||
|
||||
/// Flatten a Path scene node into a doc-space polyline — cubic
|
||||
/// segments whose endpoints carry handles are tessellated; a
|
||||
/// handle-free path falls back to the straight `points` polyline.
|
||||
pub(crate) fn flatten_path(node: &SceneNode) -> Vec<Point2D> {
|
||||
let anchors = &node.path_anchors;
|
||||
let has_handle = anchors
|
||||
.iter()
|
||||
.any(|a| a.handle_in.is_some() || a.handle_out.is_some());
|
||||
if anchors.len() < 2 || !has_handle {
|
||||
return node.points.clone();
|
||||
}
|
||||
let mut out = Vec::with_capacity(anchors.len() * 16);
|
||||
out.push(anchors[0].pos);
|
||||
for pair in anchors.windows(2) {
|
||||
let (a, b) = (&pair[0], &pair[1]);
|
||||
let (p0, p3) = (a.pos, b.pos);
|
||||
let p1 = a.handle_out.unwrap_or(p0);
|
||||
let p2 = b.handle_in.unwrap_or(p3);
|
||||
if p1 == p0 && p2 == p3 {
|
||||
out.push(p3); // straight segment
|
||||
} else {
|
||||
for i in 1..=16 {
|
||||
out.push(cubic_point(p0, p1, p2, p3, i as f32 / 16.0));
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Recursively paint one resolved [`SceneNode`] and its subtree.
|
||||
///
|
||||
/// `viewport_origin` is the canvas-rect origin shifted by the
|
||||
|
|
@ -252,7 +291,11 @@ pub fn paint_node(
|
|||
viewport_origin.y + p.y * zoom,
|
||||
)
|
||||
};
|
||||
for pair in node.points.windows(2) {
|
||||
// Bezier-aware: when the path carries anchors with control
|
||||
// handles, flatten each cubic segment; otherwise fall back
|
||||
// to the straight `points` polyline.
|
||||
let polyline = flatten_path(node);
|
||||
for pair in polyline.windows(2) {
|
||||
cx.backend
|
||||
.stroke_line(to_world(pair[0]), to_world(pair[1]), color, width);
|
||||
}
|
||||
|
|
@ -371,3 +414,54 @@ mod arc_tests {
|
|||
assert!((last.y - 100.0).abs() < 0.01);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod path_tests {
|
||||
use super::flatten_path;
|
||||
use crate::layout_scene::{NodeKind, SceneAnchor, SceneNode, ScenePointType};
|
||||
use crate::{Point2D, Rect};
|
||||
|
||||
fn anchor(x: f32, y: f32, hout: Option<Point2D>) -> SceneAnchor {
|
||||
SceneAnchor {
|
||||
pos: Point2D::new(x, y),
|
||||
handle_in: None,
|
||||
handle_out: hout,
|
||||
point_type: ScenePointType::Corner,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handle_free_path_falls_back_to_points() {
|
||||
let mut n = SceneNode::leaf("p", NodeKind::Path);
|
||||
n.points = vec![Point2D::new(0.0, 0.0), Point2D::new(10.0, 0.0)];
|
||||
n.path_anchors = vec![anchor(0.0, 0.0, None), anchor(10.0, 0.0, None)];
|
||||
// No handles → straight polyline == points.
|
||||
assert_eq!(flatten_path(&n), n.points);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn curved_segment_tessellates_into_many_points() {
|
||||
let mut n = SceneNode::leaf("p", NodeKind::Path);
|
||||
n.points = vec![Point2D::new(0.0, 0.0), Point2D::new(100.0, 0.0)];
|
||||
n.path_anchors = vec![
|
||||
anchor(0.0, 0.0, Some(Point2D::new(0.0, 50.0))),
|
||||
anchor(100.0, 0.0, None),
|
||||
];
|
||||
let poly = flatten_path(&n);
|
||||
// 1 start point + 16 tessellation steps for the cubic.
|
||||
assert_eq!(poly.len(), 17);
|
||||
assert_eq!(poly[0], Point2D::new(0.0, 0.0));
|
||||
assert_eq!(poly[poly.len() - 1], Point2D::new(100.0, 0.0));
|
||||
// Mid-curve bows toward the +Y handle.
|
||||
assert!(poly[8].y > 1.0, "curve bows toward the handle");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bounds_kept_so_helper_is_pure() {
|
||||
// flatten_path must not mutate the node.
|
||||
let mut n = SceneNode::leaf("p", NodeKind::Path);
|
||||
n.bounds = Rect::xywh(1.0, 2.0, 3.0, 4.0);
|
||||
let _ = flatten_path(&n);
|
||||
assert_eq!(n.bounds, Rect::xywh(1.0, 2.0, 3.0, 4.0));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -101,8 +101,8 @@ pub use property_panel::{PropertyPanel, PropertyPanelAction};
|
|||
pub use toolbar::Toolbar;
|
||||
|
||||
pub use canvas_viewport::{
|
||||
arc_handle_positions, rotation_corner_at_point, selection_handle_at_point, ArcHandle,
|
||||
CanvasViewport, SelectionHandle,
|
||||
arc_handle_positions, path_handle_positions, rotation_corner_at_point,
|
||||
selection_handle_at_point, ArcHandle, CanvasViewport, SelectionHandle,
|
||||
};
|
||||
|
||||
pub use icons::{draw_icon, Icon};
|
||||
|
|
|
|||
|
|
@ -279,20 +279,38 @@ pub(in crate::widget_host) struct LayerDragState {
|
|||
pub(in crate::widget_host) active: bool,
|
||||
}
|
||||
|
||||
/// Path-anchor drag — tracks which anchor of which Path node is
|
||||
/// being dragged by the pen tool. Move dispatches snap the anchor
|
||||
/// to the cursor; release commits a history snapshot ONLY when the
|
||||
/// anchor actually moved (codex CONCERN: a press-release without
|
||||
/// motion pushed a no-op snapshot that polluted the undo stack).
|
||||
/// What a path-anchor drag is editing — the anchor body itself, or
|
||||
/// one of its two bezier control handles.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(in crate::widget_host) enum AnchorDragTarget {
|
||||
/// The anchor point — drag moves the whole anchor.
|
||||
Anchor,
|
||||
/// A bezier control handle.
|
||||
Handle(op_editor_core::pen::PathHandleSide),
|
||||
}
|
||||
|
||||
/// Path-anchor drag — tracks which anchor (or which of its bezier
|
||||
/// handles) of which Path node is being dragged by the pen tool.
|
||||
/// Move dispatches snap the target to the cursor; release commits a
|
||||
/// history snapshot ONLY when it actually moved (codex CONCERN: a
|
||||
/// press-release without motion pushed a no-op snapshot that
|
||||
/// polluted the undo stack).
|
||||
#[derive(Debug, Clone)]
|
||||
pub(in crate::widget_host) struct PathAnchorDragState {
|
||||
pub(in crate::widget_host) node_id: op_editor_core::NodeId,
|
||||
pub(in crate::widget_host) anchor_index: usize,
|
||||
/// Anchor position at drag-start (doc coords) — compared against
|
||||
/// the final position on release to decide whether to push the
|
||||
/// snapshot.
|
||||
/// Whether the anchor body or a handle is being dragged.
|
||||
pub(in crate::widget_host) target: AnchorDragTarget,
|
||||
/// The dragged anchor's absolute doc position, fixed at press —
|
||||
/// handle drags compute their offset relative to it.
|
||||
pub(in crate::widget_host) anchor_doc: op_editor_ui::Point2D,
|
||||
/// Press cursor doc point — compared against the live cursor to
|
||||
/// decide whether the drag actually moved.
|
||||
pub(in crate::widget_host) start_doc: op_editor_ui::Point2D,
|
||||
/// Set to true on the first cursor-move that mutates the anchor.
|
||||
/// Shift held at press — a handle drag with Shift produces
|
||||
/// independent (broken) handles instead of mirrored ones.
|
||||
pub(in crate::widget_host) shift: bool,
|
||||
/// Set to true on the first cursor-move that mutates the target.
|
||||
pub(in crate::widget_host) moved: bool,
|
||||
/// Snapshot captured at drag-start; pushed only if `moved`.
|
||||
pub(in crate::widget_host) pre_drag_snapshot: op_editor_core::EditorSnapshot,
|
||||
|
|
|
|||
|
|
@ -368,16 +368,20 @@ impl WidgetHostNative {
|
|||
}
|
||||
|
||||
/// When the active selection is a single Path node + the Pen
|
||||
/// tool is selected, hit-test whether `(screen_x, screen_y)`
|
||||
/// lands on any anchor handle. Returns Some(index) on hit.
|
||||
/// tool is selected, hit-test whether `(x, y)` lands on an anchor
|
||||
/// or one of its bezier handles. Anchors are checked before
|
||||
/// handles; returns the node id, anchor index, and which target.
|
||||
pub(in crate::widget_host) fn path_anchor_hit(
|
||||
&self,
|
||||
x: f32,
|
||||
y: f32,
|
||||
viewport_w: f32,
|
||||
viewport_h: f32,
|
||||
) -> Option<(String, usize)> {
|
||||
) -> Option<(String, usize, super::AnchorDragTarget)> {
|
||||
use super::AnchorDragTarget;
|
||||
use op_editor_core::pen::PathHandleSide;
|
||||
use op_editor_ui::layout_scene::NodeKind;
|
||||
use op_editor_ui::widgets::path_handle_positions;
|
||||
if !matches!(self.editor_state.tool, op_editor_core::Tool::Pen) {
|
||||
return None;
|
||||
}
|
||||
|
|
@ -392,13 +396,35 @@ impl WidgetHostNative {
|
|||
let (cx0, cy0, _cw, _ch) = self.canvas_region(viewport_w, viewport_h);
|
||||
let zoom = self.editor_state.viewport.zoom.max(0.0001);
|
||||
let canvas_local = Point2D::new(x - cx0, y - cy0);
|
||||
let doc_point = self.editor_state.viewport.to_document(canvas_local);
|
||||
let r2 = 16.0 / (zoom * zoom);
|
||||
for (i, p) in node.points.iter().enumerate() {
|
||||
let dx = doc_point.x - p.x;
|
||||
let dy = doc_point.y - p.y;
|
||||
if dx * dx + dy * dy <= r2 {
|
||||
return Some((sel, i));
|
||||
let doc = self.editor_state.viewport.to_document(canvas_local);
|
||||
// ~7 screen-px grab radius, expressed in doc space.
|
||||
let r2 = 49.0 / (zoom * zoom);
|
||||
let hit = |p: Point2D| (doc.x - p.x).powi(2) + (doc.y - p.y).powi(2) <= r2;
|
||||
// Anchors take priority over handles within their tight body.
|
||||
for (i, a) in node.path_anchors.iter().enumerate() {
|
||||
if hit(a.pos) {
|
||||
return Some((sel.clone(), i, AnchorDragTarget::Anchor));
|
||||
}
|
||||
}
|
||||
for (i, a) in node.path_anchors.iter().enumerate() {
|
||||
let (hin, hout) = path_handle_positions(a, zoom);
|
||||
if hit(hout) {
|
||||
return Some((
|
||||
sel.clone(),
|
||||
i,
|
||||
AnchorDragTarget::Handle(PathHandleSide::Out),
|
||||
));
|
||||
}
|
||||
if hit(hin) {
|
||||
return Some((sel.clone(), i, AnchorDragTarget::Handle(PathHandleSide::In)));
|
||||
}
|
||||
}
|
||||
// Paths without resolved anchor data fall back to `points`.
|
||||
if node.path_anchors.is_empty() {
|
||||
for (i, p) in node.points.iter().enumerate() {
|
||||
if hit(*p) {
|
||||
return Some((sel.clone(), i, AnchorDragTarget::Anchor));
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
|
|
|
|||
|
|
@ -321,23 +321,55 @@ impl WidgetHostNative {
|
|||
}
|
||||
return false;
|
||||
}
|
||||
// Path-anchor drag — always write the current cursor position
|
||||
// (codex BLOCK: drag-back-to-start was being silently dropped).
|
||||
// Path-anchor / handle drag — write the current cursor
|
||||
// position (codex BLOCK: drag-back-to-start was being
|
||||
// silently dropped, so always write).
|
||||
if self.path_anchor_drag.is_some() {
|
||||
use super::AnchorDragTarget;
|
||||
let (cx0, cy0) = self.canvas_origin();
|
||||
let canvas_local = Point2D::new(x - cx0, y - cy0);
|
||||
let doc_point = self.editor_state.viewport.to_document(canvas_local);
|
||||
let (id, idx, start) = {
|
||||
let doc = self.editor_state.viewport.to_document(canvas_local);
|
||||
let (id, idx, target, anchor_doc, start, shift, already_moved) = {
|
||||
let d = self.path_anchor_drag.as_ref().unwrap();
|
||||
(d.node_id.clone(), d.anchor_index, d.start_doc)
|
||||
(
|
||||
d.node_id.clone(),
|
||||
d.anchor_index,
|
||||
d.target,
|
||||
d.anchor_doc,
|
||||
d.start_doc,
|
||||
d.shift,
|
||||
d.moved,
|
||||
)
|
||||
};
|
||||
self.editor_state.set_path_anchor_position(
|
||||
id,
|
||||
idx,
|
||||
(doc_point.x as f64, doc_point.y as f64),
|
||||
);
|
||||
let is_move = (doc.x - start.x).abs() > 0.001 || (doc.y - start.y).abs() > 0.001;
|
||||
match target {
|
||||
AnchorDragTarget::Anchor => {
|
||||
self.editor_state.set_path_anchor_position(
|
||||
id,
|
||||
idx,
|
||||
(doc.x as f64, doc.y as f64),
|
||||
);
|
||||
}
|
||||
AnchorDragTarget::Handle(side) => {
|
||||
// First real move sets the anchor's point type —
|
||||
// Shift = independent (broken) handles, else
|
||||
// mirrored (smooth).
|
||||
if !already_moved && is_move {
|
||||
let pt = if shift {
|
||||
jian_ops_schema::node::PenPathPointType::Independent
|
||||
} else {
|
||||
jian_ops_schema::node::PenPathPointType::Mirrored
|
||||
};
|
||||
self.editor_state
|
||||
.set_path_anchor_point_type(id.clone(), idx, pt);
|
||||
}
|
||||
let delta = ((doc.x - anchor_doc.x) as f64, (doc.y - anchor_doc.y) as f64);
|
||||
self.editor_state
|
||||
.set_path_anchor_handle(id, idx, side, Some(delta));
|
||||
}
|
||||
}
|
||||
self.mark_dirty();
|
||||
if (doc_point.x - start.x).abs() > 0.001 || (doc_point.y - start.y).abs() > 0.001 {
|
||||
if is_move {
|
||||
if let Some(d) = self.path_anchor_drag.as_mut() {
|
||||
d.moved = true;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -348,7 +348,10 @@ fn anchor_press_release_without_motion_does_not_push_history() {
|
|||
host.path_anchor_drag = Some(crate::widget_host::PathAnchorDragState {
|
||||
node_id: NodeId::new("n60"),
|
||||
anchor_index: 1,
|
||||
target: crate::widget_host::AnchorDragTarget::Anchor,
|
||||
anchor_doc: Point2D::new(50.0, 25.0),
|
||||
start_doc: Point2D::new(50.0, 25.0),
|
||||
shift: false,
|
||||
moved: false,
|
||||
pre_drag_snapshot: snap,
|
||||
});
|
||||
|
|
@ -383,7 +386,10 @@ fn anchor_drag_back_to_start_lands_at_start() {
|
|||
host.path_anchor_drag = Some(crate::widget_host::PathAnchorDragState {
|
||||
node_id: NodeId::new("n60"),
|
||||
anchor_index: 1,
|
||||
target: crate::widget_host::AnchorDragTarget::Anchor,
|
||||
anchor_doc: Point2D::new(50.0, 25.0),
|
||||
start_doc: Point2D::new(50.0, 25.0),
|
||||
shift: false,
|
||||
moved: false,
|
||||
pre_drag_snapshot: snap,
|
||||
});
|
||||
|
|
@ -420,7 +426,10 @@ fn anchor_drag_with_motion_pushes_one_history_entry() {
|
|||
host.path_anchor_drag = Some(crate::widget_host::PathAnchorDragState {
|
||||
node_id: NodeId::new("n60"),
|
||||
anchor_index: 1,
|
||||
target: crate::widget_host::AnchorDragTarget::Anchor,
|
||||
anchor_doc: Point2D::new(50.0, 25.0),
|
||||
start_doc: Point2D::new(50.0, 25.0),
|
||||
shift: false,
|
||||
moved: true,
|
||||
pre_drag_snapshot: snap,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -707,23 +707,31 @@ impl WidgetHostNative {
|
|||
// Pen tool: anchor edit / author.
|
||||
if matches!(self.editor_state.tool, Tool::Pen) {
|
||||
if self.editor_state.ui.pen_in_progress.is_none() {
|
||||
if let Some((node_id, anchor_index)) =
|
||||
if let Some((node_id, anchor_index, target)) =
|
||||
self.path_anchor_hit(x, y, viewport_width, viewport_height)
|
||||
{
|
||||
let ec_id = op_editor_core::NodeId::new(&node_id);
|
||||
// Capture starting position for the
|
||||
// history-pollution guard.
|
||||
let start_doc = self
|
||||
// The anchor's fixed absolute position — handle
|
||||
// drags offset their delta against it.
|
||||
let anchor_doc = self
|
||||
.layout_scene
|
||||
.active_page()
|
||||
.and_then(|p| p.find(&node_id))
|
||||
.and_then(|n| n.points.get(anchor_index).copied())
|
||||
.and_then(|n| {
|
||||
n.path_anchors
|
||||
.get(anchor_index)
|
||||
.map(|a| a.pos)
|
||||
.or_else(|| n.points.get(anchor_index).copied())
|
||||
})
|
||||
.unwrap_or(doc_point);
|
||||
let pre = self.editor_state.snapshot_for_history();
|
||||
self.path_anchor_drag = Some(super::PathAnchorDragState {
|
||||
node_id: ec_id,
|
||||
anchor_index,
|
||||
start_doc,
|
||||
target,
|
||||
anchor_doc,
|
||||
start_doc: doc_point,
|
||||
shift: self.shift_held,
|
||||
moved: false,
|
||||
pre_drag_snapshot: pre,
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue