mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-27 11:56:50 -06:00
refactor(engine): split boards/board.rs into board/
This commit is contained in:
+18
-1236
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,147 @@
|
||||
use super::derived::{ends_now, place_route, route};
|
||||
use super::edit::set_label;
|
||||
use super::items::add;
|
||||
use super::*;
|
||||
|
||||
/// One connector end from the params: a layer on this page, or a point.
|
||||
fn end_param(cmd: &str, s: &Session, p: &Value, key: &str, side_key: &str) -> Result<End> {
|
||||
let side = match string(p, side_key) {
|
||||
Some(v) => Side::from_id(&v).ok_or_else(|| bad(cmd, format!("`{side_key}` is auto, top, right, bottom or left")))?,
|
||||
None => Side::Auto,
|
||||
};
|
||||
match get(p, key) {
|
||||
Some(Value::Object(o)) if o.contains_key("x") => {
|
||||
let (x, y) = (num(&Value::Object(o.clone()), "x"), num(&Value::Object(o.clone()), "y"));
|
||||
match (x, y) {
|
||||
(Some(x), Some(y)) if x.is_finite() && y.is_finite() => Ok(End::Point { x, y }),
|
||||
_ => Err(bad(cmd, format!("`{key}` needs finite x and y"))),
|
||||
}
|
||||
}
|
||||
Some(v) => {
|
||||
let node = id_of(v).ok_or_else(|| bad(cmd, format!("`{key}` is a layer id or {{x, y}}")))?;
|
||||
let doc = s.doc()?;
|
||||
let n = doc.get(node)?;
|
||||
if n.is_page() || doc.page_of(node) != Some(s.page()?) {
|
||||
return Err(bad(cmd, format!("`{key}` must be a layer on this page")));
|
||||
}
|
||||
if matches!(n.board, Some(BoardItem::Connector(_))) {
|
||||
return Err(bad(cmd, format!("`{key}` can't be another connector")));
|
||||
}
|
||||
Ok(End::Node { node, side })
|
||||
}
|
||||
None => Err(bad(cmd, format!("`{key}` is required"))),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn connector_cmd(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "board.connector";
|
||||
require(s, cmd)?;
|
||||
let from = end_param(cmd, s, p, "from", "fromSide")?;
|
||||
let to = end_param(cmd, s, p, "to", "toSide")?;
|
||||
if from.node().is_some() && from.node() == to.node() {
|
||||
return Err(bad(cmd, "connect two different layers"));
|
||||
}
|
||||
let routing = match string(p, "style") {
|
||||
Some(v) => routing_of(cmd, &v)?,
|
||||
None => s.tool.board.routing,
|
||||
};
|
||||
let start = cap_of(cmd, p, "startCap")?.unwrap_or(StrokeCap::None);
|
||||
let end = cap_of(cmd, p, "endCap")?.unwrap_or(StrokeCap::TriangleArrow);
|
||||
let c = Connector { from, to, routing };
|
||||
let page = s.page()?;
|
||||
let doc = s.doc_mut()?;
|
||||
let pts = route(doc, &c).ok_or_else(|| bad(cmd, "the ends can't be placed"))?;
|
||||
if pts.windows(2).all(|w| w.first() == w.get(1)) {
|
||||
return Err(bad(cmd, "the ends are in the same place"));
|
||||
}
|
||||
let id = add(doc, page, NodeKind::Vector { network: VectorNetwork::default() }, "Connector", (0.0, 0.0), (0.0, 0.0), |n| {
|
||||
n.fills.clear();
|
||||
n.strokes = vec![Paint::solid(LINE)];
|
||||
n.stroke.weight = LINE_WEIGHT;
|
||||
n.stroke.join = StrokeJoin::Round;
|
||||
n.stroke.cap_start = start;
|
||||
n.stroke.cap_end = end;
|
||||
n.board = Some(BoardItem::Connector(c));
|
||||
})?;
|
||||
place_route(doc, id, &pts, routing);
|
||||
let mut out = json!({"id": id});
|
||||
if let Some(text) = string(p, "label").filter(|t| !t.trim().is_empty()) {
|
||||
out["label"] = json!(set_label(doc, id, &text)?);
|
||||
}
|
||||
s.set_selection(vec![id]);
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The connector `id` names: a connector, or the label on one.
|
||||
pub(super) fn connector_of(cmd: &str, doc: &Document, id: NodeId) -> Result<(NodeId, Connector)> {
|
||||
match doc.get(id)?.board.clone() {
|
||||
Some(BoardItem::Connector(c)) => Ok((id, c)),
|
||||
Some(BoardItem::Label { connector }) => match doc.get(connector)?.board.clone() {
|
||||
Some(BoardItem::Connector(c)) => Ok((connector, c)),
|
||||
_ => Err(bad(cmd, "that isn't a connector")),
|
||||
},
|
||||
_ => Err(bad(cmd, "that isn't a connector")),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn reattach(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "board.reattach";
|
||||
require(s, cmd)?;
|
||||
let id = req_targets(cmd, s, p)?.first().copied().ok_or_else(|| bad(cmd, "select a connector"))?;
|
||||
let (id, mut c) = connector_of(cmd, s.doc()?, id)?;
|
||||
let from = match string(p, "end").as_deref() {
|
||||
Some("from") => true,
|
||||
Some("to") => false,
|
||||
_ => return Err(bad(cmd, "`end` is from or to")),
|
||||
};
|
||||
let end = end_param(cmd, s, p, "target", "side")?;
|
||||
let other = if from { c.to } else { c.from };
|
||||
if end.node().is_some() && end.node() == other.node() {
|
||||
return Err(bad(cmd, "connect two different layers"));
|
||||
}
|
||||
*(if from { &mut c.from } else { &mut c.to }) = end;
|
||||
let doc = s.doc_mut()?;
|
||||
let pts = route(doc, &c).ok_or_else(|| bad(cmd, "the ends can't be placed"))?;
|
||||
if pts.windows(2).all(|w| w.first() == w.get(1)) {
|
||||
return Err(bad(cmd, "the ends are in the same place"));
|
||||
}
|
||||
doc.get_mut(id)?.board = Some(BoardItem::Connector(c));
|
||||
place_route(doc, id, &pts, c.routing);
|
||||
Ok(json!({"id": id}))
|
||||
}
|
||||
|
||||
/// A connector end as `board.connector` and `board.reattach` take it.
|
||||
pub(super) fn end_json(e: End) -> Value {
|
||||
match e {
|
||||
End::Node { node, .. } => json!(node),
|
||||
End::Point { x, y } => json!({"x": x, "y": y}),
|
||||
}
|
||||
}
|
||||
|
||||
/// The end of connector `id` that stays while its other one (`from`: its start) is dragged.
|
||||
pub(super) fn other_end(doc: &Document, id: NodeId, from: bool) -> Option<End> {
|
||||
match &doc.node(id)?.board {
|
||||
Some(BoardItem::Connector(c)) => Some(if from { c.to } else { c.from }),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The selected connector (one, alone and unlocked) and its two ends on the page as drawn.
|
||||
pub fn selected_connector_ends(doc: &Document, sel: &[NodeId]) -> Option<(NodeId, [Point; 2])> {
|
||||
let [id] = sel else { return None };
|
||||
if !matches!(doc.node(*id)?.board, Some(BoardItem::Connector(_))) || crate::tools::locked_in(doc, *id) {
|
||||
return None;
|
||||
}
|
||||
let (a, b) = ends_now(doc, *id)?;
|
||||
Some((*id, [a, b]))
|
||||
}
|
||||
|
||||
/// How near (screen pixels) a press must be to a selected connector's end to drag it.
|
||||
pub const END_HANDLE_PX: f64 = 8.0;
|
||||
|
||||
/// The selected connector's end under `p` at `zoom`, nearest first (`true`: its start).
|
||||
pub(crate) fn end_handle_at(doc: &Document, sel: &[NodeId], p: Point, zoom: f64) -> Option<(NodeId, bool)> {
|
||||
let (id, [a, b]) = selected_connector_ends(doc, sel)?;
|
||||
let (da, db) = ((a - p).hypot(), (b - p).hypot());
|
||||
(da.min(db) * zoom.max(1e-6) <= END_HANDLE_PX).then_some((id, da <= db))
|
||||
}
|
||||
@@ -0,0 +1,339 @@
|
||||
use super::edit::set_font_size;
|
||||
use super::items::{owner_of_text, shape_padding, sticky_font_size, text_of};
|
||||
use super::*;
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Derived data (the normalize pass).
|
||||
|
||||
/// Before text is measured: a sticky's note takes the size its length calls for, and a resized
|
||||
/// shape keeps its text inside its outline.
|
||||
pub(crate) fn settle_text(doc: &mut Document, before: &Document, touched: &BTreeSet<NodeId>) {
|
||||
if !is_board(doc) {
|
||||
return;
|
||||
}
|
||||
for &id in touched {
|
||||
let Some(n) = doc.node(id) else { continue };
|
||||
if let Some(owner) =
|
||||
owner_of_text(doc, id).or(Some(id)).filter(|o| doc.node(*o).is_some_and(|k| matches!(k.board, Some(BoardItem::Sticky { .. }))))
|
||||
&& let Some(t) = text_of(doc, owner)
|
||||
&& let Some(NodeKind::Text(td)) = doc.node(t).map(|k| &k.kind)
|
||||
{
|
||||
let size = sticky_font_size(&td.characters);
|
||||
let off = td.style.font_size != size || td.runs.iter().any(|r| r.style.font_size != size);
|
||||
if off && let Some(NodeKind::Text(td)) = doc.node_mut(t).map(|k| &mut k.kind) {
|
||||
set_font_size(td, size);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if let Some(BoardItem::Shape { shape }) = n.board
|
||||
&& before.node(id).is_none_or(|b| (b.width, b.height) != (n.width, n.height))
|
||||
{
|
||||
let pad = shape_padding(shape, n.width, n.height);
|
||||
if n.layout.as_ref().is_some_and(|l| l.padding != pad)
|
||||
&& let Some(l) = doc.node_mut(id).and_then(|k| k.layout.as_mut())
|
||||
{
|
||||
l.padding = pad;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// After layout: connectors run between their ends again (an end whose layer is gone stays where
|
||||
/// it was), one moved on its own takes its free ends along, and labels sit at their middles. Only
|
||||
/// the connectors and labels an edit can have moved are looked at: touched themselves, or with a
|
||||
/// touched (or gone) end, connector or layer around them.
|
||||
pub(crate) fn settle_routes(doc: &mut Document, before: &Document, touched: &BTreeSet<NodeId>) {
|
||||
if !is_board(doc) || touched.is_empty() {
|
||||
return;
|
||||
}
|
||||
let hit = |doc: &Document, id: NodeId| touched.contains(&id) || doc.ancestors(id).iter().any(|a| touched.contains(a));
|
||||
let (mut connectors, mut labels) = (Vec::new(), Vec::new());
|
||||
for n in doc.nodes() {
|
||||
match &n.board {
|
||||
Some(BoardItem::Connector(c)) => {
|
||||
let ends = [c.from.node(), c.to.node()].into_iter().flatten();
|
||||
if hit(doc, n.id) || ends.into_iter().any(|e| !doc.contains(e) || hit(doc, e)) {
|
||||
connectors.push((n.id, *c));
|
||||
}
|
||||
}
|
||||
Some(BoardItem::Label { connector }) => labels.push((n.id, *connector)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let rerouted: BTreeSet<NodeId> = connectors.iter().map(|(id, _)| *id).collect();
|
||||
for (id, mut c) in connectors {
|
||||
let changed = detach_gone_ends(doc, id, &mut c) | carry_free_ends(doc, before, touched, id, &mut c);
|
||||
if changed && let Some(n) = doc.node_mut(id) {
|
||||
n.board = Some(BoardItem::Connector(c));
|
||||
}
|
||||
if let Some(pts) = route(doc, &c) {
|
||||
place_route(doc, id, &pts, c.routing);
|
||||
}
|
||||
}
|
||||
for (label, connector) in labels {
|
||||
if rerouted.contains(&connector) || touched.contains(&connector) || hit(doc, label) {
|
||||
place_label(doc, label, connector);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The ends of connector `id` (as `c`) whose layer is gone or on another page stay where they are
|
||||
/// drawn, as points. Whether any did.
|
||||
fn detach_gone_ends(doc: &Document, id: NodeId, c: &mut Connector) -> bool {
|
||||
let (page, ends) = (doc.page_of(id), ends_now(doc, id));
|
||||
let mut detached = false;
|
||||
for (end, at) in [(&mut c.from, ends.map(|e| e.0)), (&mut c.to, ends.map(|e| e.1))] {
|
||||
if let (End::Node { node, .. }, Some(p)) = (*end, at)
|
||||
&& (!doc.contains(node) || doc.page_of(node) != page)
|
||||
{
|
||||
*end = End::Point { x: p.x, y: p.y };
|
||||
detached = true;
|
||||
}
|
||||
}
|
||||
detached
|
||||
}
|
||||
|
||||
/// Connector `id` (as `c`) moved by itself (a drag, a nudge: its place changed but not what it
|
||||
/// joins): its free ends move with it. Whether they did.
|
||||
fn carry_free_ends(doc: &Document, before: &Document, touched: &BTreeSet<NodeId>, id: NodeId, c: &mut Connector) -> bool {
|
||||
let (Some(b), Some(n)) = (before.node(id), doc.node(id)) else { return false };
|
||||
if !touched.contains(&id) || b.transform == n.transform || b.board != n.board {
|
||||
return false;
|
||||
}
|
||||
let Some(expected) = route(doc, c).and_then(|pts| finite_box(&route_path(&pts, c.routing))).map(|b| b.origin()) else { return false };
|
||||
let d = (doc.world_transform(id) * Point::ZERO) - expected;
|
||||
if d.hypot() <= 1e-9 {
|
||||
return false;
|
||||
}
|
||||
let mut moved = false;
|
||||
for end in [&mut c.from, &mut c.to] {
|
||||
if let End::Point { x, y } = end {
|
||||
(*x, *y) = (*x + d.x, *y + d.y);
|
||||
moved = true;
|
||||
}
|
||||
}
|
||||
moved
|
||||
}
|
||||
|
||||
/// Label `label` at the middle of connector `connector`; gone with it when it is gone.
|
||||
fn place_label(doc: &mut Document, label: NodeId, connector: NodeId) {
|
||||
let Some(mid) = doc.node(connector).and_then(|_| route_mid(doc, connector)) else {
|
||||
let _ = doc.remove(label);
|
||||
return;
|
||||
};
|
||||
let Some(n) = doc.node(label) else { return };
|
||||
let pw = n.parent.map_or(Affine::IDENTITY, |p| doc.world_transform(p));
|
||||
let local = pw.inverse() * mid;
|
||||
let t = Affine::translate((local.x - n.width / 2.0, local.y - n.height / 2.0));
|
||||
if n.transform != t
|
||||
&& let Some(n) = doc.node_mut(label)
|
||||
{
|
||||
n.transform = t;
|
||||
}
|
||||
}
|
||||
|
||||
/// Connector `id`'s first and last points on the page as it is drawn now.
|
||||
pub(super) fn ends_now(doc: &Document, id: NodeId) -> Option<(Point, Point)> {
|
||||
let NodeKind::Vector { network } = &doc.node(id)?.kind else { return None };
|
||||
let w = doc.world_transform(id);
|
||||
Some((w * network.vertices.first()?.point(), w * network.vertices.last()?.point()))
|
||||
}
|
||||
|
||||
/// The middle of connector `id`'s route by length, on the page.
|
||||
fn route_mid(doc: &Document, id: NodeId) -> Option<Point> {
|
||||
let NodeKind::Vector { network } = &doc.node(id)?.kind else { return None };
|
||||
let w = doc.world_transform(id);
|
||||
let mut pts: Vec<Point> = Vec::new();
|
||||
figcraft_geom::kurbo::flatten(network.stroke_path().iter(), 0.25, |el| {
|
||||
if let PathEl::MoveTo(p) | PathEl::LineTo(p) = el {
|
||||
pts.push(w * p);
|
||||
}
|
||||
});
|
||||
let total: f64 = pts.windows(2).map(|s| (s[1] - s[0]).hypot()).sum();
|
||||
let mut left = total / 2.0;
|
||||
for s in pts.windows(2) {
|
||||
let (a, b) = (s[0], s[1]);
|
||||
let l = (b - a).hypot();
|
||||
if l >= left && l > 0.0 {
|
||||
return Some(a.lerp(b, left / l));
|
||||
}
|
||||
left -= l;
|
||||
}
|
||||
pts.first().copied()
|
||||
}
|
||||
|
||||
/// The path through a route's page points `pts` (see [`route`]): one curve for a curved
|
||||
/// connector, straight runs otherwise.
|
||||
fn route_path(pts: &[Point], routing: Routing) -> BezPath {
|
||||
let mut path = BezPath::new();
|
||||
match (routing, pts) {
|
||||
(Routing::Curved, [p0, c0, c1, p1]) => {
|
||||
path.move_to(*p0);
|
||||
path.curve_to(*c0, *c1, *p1);
|
||||
}
|
||||
_ => {
|
||||
for (i, p) in pts.iter().enumerate() {
|
||||
if i == 0 { path.move_to(*p) } else { path.line_to(*p) }
|
||||
}
|
||||
}
|
||||
}
|
||||
path
|
||||
}
|
||||
|
||||
/// The box a path is drawn in, when it is finite.
|
||||
fn finite_box(path: &BezPath) -> Option<Rect> {
|
||||
let b = path.bounding_box();
|
||||
(b.x0.is_finite() && b.y0.is_finite() && b.x1.is_finite() && b.y1.is_finite()).then_some(b)
|
||||
}
|
||||
|
||||
/// Write the route `pts` (page points, see [`route`]) into connector `id`: its box, place and
|
||||
/// path. Untouched when it already runs there, so a connector that didn't move isn't an edit.
|
||||
pub(super) fn place_route(doc: &mut Document, id: NodeId, pts: &[Point], routing: Routing) {
|
||||
let Some(n) = doc.node(id) else { return };
|
||||
let pw = n.parent.map_or(Affine::IDENTITY, |p| doc.world_transform(p));
|
||||
let local = pw.inverse() * route_path(pts, routing);
|
||||
let Some(b) = finite_box(&local) else { return };
|
||||
let rel = Affine::translate((-b.x0, -b.y0)) * local;
|
||||
let network = match routing {
|
||||
Routing::Curved => VectorNetwork::from_path(&rel),
|
||||
_ => VectorNetwork::from_points(&rel.elements().iter().filter_map(PathEl::end_point).collect::<Vec<_>>(), false),
|
||||
};
|
||||
let t = Affine::translate((b.x0, b.y0));
|
||||
let same =
|
||||
n.transform == t && n.width == b.width() && n.height == b.height() && matches!(&n.kind, NodeKind::Vector { network: old } if *old == network);
|
||||
if same {
|
||||
return;
|
||||
}
|
||||
if let Some(n) = doc.node_mut(id) {
|
||||
n.kind = NodeKind::Vector { network };
|
||||
n.transform = t;
|
||||
(n.width, n.height) = (b.width(), b.height());
|
||||
}
|
||||
}
|
||||
|
||||
/// How far an elbow connector going around the objects it joins keeps from them.
|
||||
const ROOM: f64 = 24.0;
|
||||
|
||||
/// The page points connector `c` runs through: from the middle of the side of each attached layer
|
||||
/// facing the other end (or the side it names), straight, in right angles, or (curved) its two
|
||||
/// ends with the curve's two control points between them.
|
||||
pub fn route(doc: &Document, c: &Connector) -> Option<Vec<Point>> {
|
||||
let bounds = |e: &End| e.node().and_then(|n| doc.world_bounds(n));
|
||||
let centre = |e: &End, b: Option<Rect>| match e {
|
||||
End::Point { x, y } => Some(Point::new(*x, *y)),
|
||||
End::Node { .. } => b.map(|b| b.center()),
|
||||
};
|
||||
let (fb, tb) = (bounds(&c.from), bounds(&c.to));
|
||||
let (fc, tc) = (centre(&c.from, fb)?, centre(&c.to, tb)?);
|
||||
let (p0, s0) = attach(&c.from, fb, tc)?;
|
||||
let (p1, s1) = attach(&c.to, tb, fc)?;
|
||||
let pts = match c.routing {
|
||||
Routing::Straight => tidy(vec![p0, p1]),
|
||||
Routing::Elbow => elbow(p0, s0, p1, s1, &[fb, tb].into_iter().flatten().collect::<Vec<_>>()),
|
||||
Routing::Curved => curve(p0, s0, p1, s1),
|
||||
};
|
||||
pts.iter().all(|p| p.x.is_finite() && p.y.is_finite()).then_some(pts)
|
||||
}
|
||||
|
||||
/// `pts` without repeats or turns that don't turn (three in a line across or down).
|
||||
fn tidy(pts: Vec<Point>) -> Vec<Point> {
|
||||
let mut out: Vec<Point> = Vec::with_capacity(pts.len());
|
||||
for p in pts {
|
||||
if out.last() == Some(&p) {
|
||||
continue;
|
||||
}
|
||||
if let [.., a, b] = out.as_slice()
|
||||
&& ((a.x == b.x && b.x == p.x) || (a.y == b.y && b.y == p.y))
|
||||
{
|
||||
out.pop();
|
||||
}
|
||||
out.push(p);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Where end `e` attaches (its point, or the middle of its layer's side facing `other`) and that
|
||||
/// side (`None`: a free point).
|
||||
fn attach(e: &End, b: Option<Rect>, other: Point) -> Option<(Point, Option<Side>)> {
|
||||
match e {
|
||||
End::Point { x, y } => Some((Point::new(*x, *y), None)),
|
||||
End::Node { side, .. } => {
|
||||
let b = b?;
|
||||
let side = match side {
|
||||
Side::Auto => {
|
||||
let d = other - b.center();
|
||||
// Compare against the box's proportions: a wide box takes the sides more.
|
||||
if d.x.abs() * b.height().max(1e-9) >= d.y.abs() * b.width().max(1e-9) {
|
||||
if d.x >= 0.0 { Side::Right } else { Side::Left }
|
||||
} else if d.y >= 0.0 {
|
||||
Side::Bottom
|
||||
} else {
|
||||
Side::Top
|
||||
}
|
||||
}
|
||||
s => *s,
|
||||
};
|
||||
Some((side.point(b), Some(side)))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A curve from `p0` to `p1` leaving and arriving along the sides' normals (a free end pulls no
|
||||
/// way): the ends and the two control points between them.
|
||||
fn curve(p0: Point, s0: Option<Side>, p1: Point, s1: Option<Side>) -> Vec<Point> {
|
||||
let reach = (p1 - p0).hypot() / 2.0;
|
||||
let pull = |p: Point, s: Option<Side>| p + s.map_or(Vec2::ZERO, |s| s.normal() * reach);
|
||||
vec![p0, pull(p0, s0), pull(p1, s1), p1]
|
||||
}
|
||||
|
||||
/// A right-angled route from `p0` to `p1`, leaving and arriving along the sides' normals (`s`; a
|
||||
/// free end follows the other): the short way when it keeps out of the attached objects' `boxes`,
|
||||
/// else the cheapest of a few ways around them (by length, and turns).
|
||||
fn elbow(p0: Point, s0: Option<Side>, p1: Point, s1: Option<Side>, boxes: &[Rect]) -> Vec<Point> {
|
||||
let direct = tidy(short_elbow(p0, s0.map(Side::horizontal), p1, s1.map(Side::horizontal)));
|
||||
let Some(all) = boxes.iter().copied().reduce(|a, b| a.union(b)) else { return direct };
|
||||
if !crosses(&direct, boxes) {
|
||||
return direct;
|
||||
}
|
||||
// Out of each end by ROOM, then across on one of a few lines: halfway, or clear of the objects.
|
||||
let out = |p: Point, s: Option<Side>| p + s.map_or(Vec2::ZERO, |s| s.normal() * ROOM);
|
||||
let (a, b) = (out(p0, s0), out(p1, s1));
|
||||
let mut ways = vec![vec![p0, a, Point::new(b.x, a.y), b, p1], vec![p0, a, Point::new(a.x, b.y), b, p1]];
|
||||
for x in [(a.x + b.x) / 2.0, all.x0 - ROOM, all.x1 + ROOM] {
|
||||
ways.push(vec![p0, a, Point::new(x, a.y), Point::new(x, b.y), b, p1]);
|
||||
}
|
||||
for y in [(a.y + b.y) / 2.0, all.y0 - ROOM, all.y1 + ROOM] {
|
||||
ways.push(vec![p0, a, Point::new(a.x, y), Point::new(b.x, y), b, p1]);
|
||||
}
|
||||
let cost = |r: &Vec<Point>| r.windows(2).map(|s| (s[1] - s[0]).hypot()).sum::<f64>() + ROOM * r.len() as f64;
|
||||
ways.into_iter().map(tidy).filter(|r| !crosses(r, boxes)).min_by(|x, y| cost(x).total_cmp(&cost(y))).unwrap_or(direct)
|
||||
}
|
||||
|
||||
/// Whether the right-angled route `pts` runs through the inside of any of `boxes` (along an edge,
|
||||
/// or out of one, is outside).
|
||||
fn crosses(pts: &[Point], boxes: &[Rect]) -> bool {
|
||||
const EDGE: f64 = 0.5;
|
||||
pts.windows(2).any(|s| {
|
||||
let (a, b) = (s[0], s[1]);
|
||||
boxes.iter().any(|r| a.x.min(b.x) < r.x1 - EDGE && a.x.max(b.x) > r.x0 + EDGE && a.y.min(b.y) < r.y1 - EDGE && a.y.max(b.y) > r.y0 + EDGE)
|
||||
})
|
||||
}
|
||||
|
||||
/// The short right-angled route from `p0` to `p1` (`h`: the side faces sideways; a free end
|
||||
/// follows the other): halfway across between two sides facing the same way, one turn otherwise.
|
||||
fn short_elbow(p0: Point, h0: Option<bool>, p1: Point, h1: Option<bool>) -> Vec<Point> {
|
||||
let h0 = h0.or(h1).unwrap_or((p1.x - p0.x).abs() >= (p1.y - p0.y).abs());
|
||||
let h1 = h1.unwrap_or(h0);
|
||||
match (h0, h1) {
|
||||
(true, true) => {
|
||||
let mx = (p0.x + p1.x) / 2.0;
|
||||
vec![p0, Point::new(mx, p0.y), Point::new(mx, p1.y), p1]
|
||||
}
|
||||
(false, false) => {
|
||||
let my = (p0.y + p1.y) / 2.0;
|
||||
vec![p0, Point::new(p0.x, my), Point::new(p1.x, my), p1]
|
||||
}
|
||||
(true, false) => vec![p0, Point::new(p1.x, p0.y), p1],
|
||||
(false, true) => vec![p0, Point::new(p0.x, p1.y), p1],
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
use super::connectors::connector_of;
|
||||
use super::derived::ends_now;
|
||||
use super::items::{add, add_text, shape_padding, text_of};
|
||||
use super::*;
|
||||
|
||||
/// The label of connector `id`, if it has one.
|
||||
pub fn label_of(doc: &Document, id: NodeId) -> Option<NodeId> {
|
||||
let parent = doc.node(id)?.parent?;
|
||||
doc.node(parent)?.children.iter().copied().find(|c| doc.node(*c).is_some_and(|n| n.board == Some(BoardItem::Label { connector: id })))
|
||||
}
|
||||
|
||||
/// Give connector `id` the label `text` (empty removes it); the label's id.
|
||||
pub(super) fn set_label(doc: &mut Document, id: NodeId, text: &str) -> Result<Option<NodeId>> {
|
||||
let existing = label_of(doc, id);
|
||||
if text.trim().is_empty() {
|
||||
if let Some(l) = existing {
|
||||
doc.remove(l)?;
|
||||
}
|
||||
return Ok(None);
|
||||
}
|
||||
if let Some(l) = existing {
|
||||
if let Some(t) = text_of(doc, l)
|
||||
&& let Some(NodeKind::Text(td)) = doc.node_mut(t).map(|n| &mut n.kind)
|
||||
{
|
||||
let len = td.char_count();
|
||||
td.replace_range(0, len, text);
|
||||
}
|
||||
return Ok(Some(l));
|
||||
}
|
||||
let parent = doc.node(id).and_then(|n| n.parent).ok_or(DocError::NoNode(id))?;
|
||||
let l = add(doc, parent, NodeKind::Frame, "Label", (0.0, 0.0), (40.0, 24.0), |n| {
|
||||
n.fills = vec![Paint::solid(Color::WHITE)];
|
||||
n.corners.radii = [4.0; 4];
|
||||
n.clip = false;
|
||||
n.layout = Some(AutoLayout { padding: [3.0, 6.0, 3.0, 6.0], ..AutoLayout::default() });
|
||||
n.child_layout.sizing_h = Sizing::Hug;
|
||||
n.child_layout.sizing_v = Sizing::Hug;
|
||||
n.board = Some(BoardItem::Label { connector: id });
|
||||
})?;
|
||||
let t = add_text(doc, l, text, 12.0, INK, TextAlignH::Center, 40.0)?;
|
||||
if let Some(n) = doc.node_mut(t)
|
||||
&& let NodeKind::Text(td) = &mut n.kind
|
||||
{
|
||||
// The label hugs its text: one line per paragraph.
|
||||
td.auto_resize = TextAutoResize::WidthAndHeight;
|
||||
n.child_layout.sizing_h = Sizing::Hug;
|
||||
}
|
||||
Ok(Some(l))
|
||||
}
|
||||
|
||||
pub(super) fn label_cmd(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "board.label";
|
||||
require(s, cmd)?;
|
||||
let id = req_targets(cmd, s, p)?.first().copied().ok_or_else(|| bad(cmd, "select a connector"))?;
|
||||
let (id, _) = connector_of(cmd, s.doc()?, id)?;
|
||||
let text = req_string(cmd, p, "text")?;
|
||||
let label = set_label(s.doc_mut()?, id, &text)?;
|
||||
Ok(json!({"id": id, "label": label}))
|
||||
}
|
||||
|
||||
/// The board objects among `ids`, each with its item (a label counts as its connector's text).
|
||||
fn items(doc: &Document, ids: &[NodeId]) -> Vec<(NodeId, BoardItem)> {
|
||||
ids.iter().filter_map(|i| doc.node(*i).and_then(|n| n.board.clone()).map(|b| (*i, b))).collect()
|
||||
}
|
||||
|
||||
pub(super) fn set_color(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "board.setColor";
|
||||
require(s, cmd)?;
|
||||
let ids = req_targets(cmd, s, p)?;
|
||||
let color = color_param(cmd, p)?.ok_or_else(|| bad(cmd, "`color` is required"))?;
|
||||
let doc = s.doc_mut()?;
|
||||
let mut changed = Vec::new();
|
||||
for (id, item) in items(doc, &ids) {
|
||||
match item {
|
||||
BoardItem::Sticky { .. } => doc.get_mut(id)?.fills = vec![Paint::solid(color)],
|
||||
BoardItem::Shape { .. } => {
|
||||
let Some(outline) = doc.get(id)?.children.first().copied() else { continue };
|
||||
let n = doc.get_mut(outline)?;
|
||||
n.fills = vec![Paint::solid(color)];
|
||||
n.strokes = vec![Paint::solid(line_shade(color))];
|
||||
}
|
||||
BoardItem::Connector(_) | BoardItem::Ink => doc.get_mut(id)?.strokes = vec![Paint::solid(line_shade(color))],
|
||||
BoardItem::Label { .. } | BoardItem::Stamp { .. } | BoardItem::Table => continue,
|
||||
}
|
||||
changed.push(id);
|
||||
}
|
||||
if changed.is_empty() {
|
||||
return Err(bad(cmd, "select stickies, shapes, connectors or ink"));
|
||||
}
|
||||
Ok(json!({"ids": changed}))
|
||||
}
|
||||
|
||||
/// What `board.setStyle` changes (each `None` leaves it).
|
||||
struct Restyle {
|
||||
shape: Option<BoardShape>,
|
||||
routing: Option<Routing>,
|
||||
size: Option<f64>,
|
||||
start: Option<StrokeCap>,
|
||||
end: Option<StrokeCap>,
|
||||
}
|
||||
|
||||
impl Restyle {
|
||||
fn of(cmd: &str, p: &Value) -> Result<Restyle> {
|
||||
let size = num(p, "fontSize")
|
||||
.map(|v| if v.is_finite() && (1.0..=1000.0).contains(&v) { Ok(v) } else { Err(bad(cmd, "`fontSize` is from 1 to 1000")) })
|
||||
.transpose()?;
|
||||
Ok(Restyle {
|
||||
shape: string(p, "shape").map(|v| shape_of(cmd, &v)).transpose()?,
|
||||
routing: string(p, "style").map(|v| routing_of(cmd, &v)).transpose()?,
|
||||
size,
|
||||
start: cap_of(cmd, p, "startCap")?,
|
||||
end: cap_of(cmd, p, "endCap")?,
|
||||
})
|
||||
}
|
||||
|
||||
/// Restyle board object `id` (`item`); whether anything about it changed.
|
||||
fn apply(&self, doc: &mut Document, id: NodeId, item: BoardItem) -> Result<bool> {
|
||||
let changed = match item {
|
||||
BoardItem::Shape { shape } => match self.shape.filter(|n| *n != shape) {
|
||||
Some(new) => {
|
||||
reshape(doc, id, new)?;
|
||||
true
|
||||
}
|
||||
None => false,
|
||||
},
|
||||
BoardItem::Connector(c) => self.restyle_connector(doc, id, c)?,
|
||||
_ => false,
|
||||
};
|
||||
// Text sizes: shapes' and labels' text (stickies size theirs to fit).
|
||||
let sized = match (self.size, &item) {
|
||||
(Some(size), BoardItem::Shape { .. } | BoardItem::Label { .. }) => resize_text(doc, id, size),
|
||||
_ => false,
|
||||
};
|
||||
Ok(changed || sized)
|
||||
}
|
||||
|
||||
fn restyle_connector(&self, doc: &mut Document, id: NodeId, mut c: Connector) -> Result<bool> {
|
||||
let n = doc.get_mut(id)?;
|
||||
if let Some(r) = self.routing {
|
||||
c.routing = r;
|
||||
n.board = Some(BoardItem::Connector(c));
|
||||
}
|
||||
if let Some(cap) = self.start {
|
||||
n.stroke.cap_start = cap;
|
||||
}
|
||||
if let Some(cap) = self.end {
|
||||
n.stroke.cap_end = cap;
|
||||
}
|
||||
Ok(self.routing.is_some() || self.start.is_some() || self.end.is_some())
|
||||
}
|
||||
}
|
||||
|
||||
/// Shape with text `id` takes outline `new` (its text room too).
|
||||
fn reshape(doc: &mut Document, id: NodeId, new: BoardShape) -> Result<()> {
|
||||
let n = doc.get_mut(id)?;
|
||||
n.board = Some(BoardItem::Shape { shape: new });
|
||||
n.name = new.label().into();
|
||||
let (w, h) = (n.width, n.height);
|
||||
if let Some(l) = n.layout.as_mut() {
|
||||
l.padding = shape_padding(new, w, h);
|
||||
}
|
||||
if let Some(o) = doc.get(id)?.children.first().copied()
|
||||
&& let Some(on) = doc.node_mut(o)
|
||||
{
|
||||
on.kind = NodeKind::Vector { network: VectorNetwork::from_path(&new.path(w, h)) };
|
||||
(on.width, on.height, on.transform) = (w, h, Affine::IDENTITY);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Board object `id`'s text at `size`; whether it has text.
|
||||
fn resize_text(doc: &mut Document, id: NodeId, size: f64) -> bool {
|
||||
let Some(t) = text_of(doc, id) else { return false };
|
||||
if let Some(NodeKind::Text(td)) = doc.node_mut(t).map(|n| &mut n.kind) {
|
||||
set_font_size(td, size);
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
pub(super) fn set_style(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "board.setStyle";
|
||||
require(s, cmd)?;
|
||||
let ids = req_targets(cmd, s, p)?;
|
||||
let restyle = Restyle::of(cmd, p)?;
|
||||
let doc = s.doc_mut()?;
|
||||
let mut changed = Vec::new();
|
||||
for (id, item) in items(doc, &ids) {
|
||||
if restyle.apply(doc, id, item)? {
|
||||
changed.push(id);
|
||||
}
|
||||
}
|
||||
if changed.is_empty() {
|
||||
return Err(bad(cmd, "nothing selected takes these (shapes with text, connectors, labels)"));
|
||||
}
|
||||
Ok(json!({"ids": changed}))
|
||||
}
|
||||
|
||||
pub(super) fn set_font_size(td: &mut TextData, size: f64) {
|
||||
td.style.font_size = size;
|
||||
for r in &mut td.runs {
|
||||
r.style.font_size = size;
|
||||
}
|
||||
}
|
||||
|
||||
/// Each layer of `sources` (copied one by one, in order, as `copies`) and its copy: the whole
|
||||
/// subtrees pair up when they have the same shape (an instance made of a main component may not;
|
||||
/// then only the roots do).
|
||||
pub(crate) fn copy_map(doc: &Document, sources: &[NodeId], copies: &[NodeId]) -> BTreeMap<NodeId, NodeId> {
|
||||
let mut map = BTreeMap::new();
|
||||
for (s, c) in sources.iter().zip(copies) {
|
||||
let (a, b) = (doc.subtree(*s), doc.subtree(*c));
|
||||
if a.len() == b.len() {
|
||||
map.extend(a.into_iter().zip(b));
|
||||
} else {
|
||||
map.insert(*s, *c);
|
||||
}
|
||||
}
|
||||
map
|
||||
}
|
||||
|
||||
/// The connectors and labels among copied layers, as `(copy, the original's board item)`, from the
|
||||
/// originals in `doc` (`map`: original → copy).
|
||||
pub(crate) fn copied_items(doc: &Document, map: &BTreeMap<NodeId, NodeId>) -> Vec<(NodeId, BoardItem)> {
|
||||
map.iter().filter_map(|(o, c)| doc.node(*o).and_then(|n| n.board.clone()).map(|b| (*c, b))).collect()
|
||||
}
|
||||
|
||||
/// After a copy (`map`: each copied original → its copy; `originals`: the copies' items as the
|
||||
/// originals had them), copied connectors join the copies of what they joined and labels follow
|
||||
/// the copies of their connectors. From another file (`foreign`), what wasn't copied isn't here: a
|
||||
/// connector's end there stays where it is drawn, as a point, and a label without its connector
|
||||
/// goes.
|
||||
pub(crate) fn rejoin(doc: &mut Document, originals: &[(NodeId, BoardItem)], map: &BTreeMap<NodeId, NodeId>, foreign: bool) {
|
||||
for (copy, item) in originals {
|
||||
let mut item = item.clone();
|
||||
let copied = |n: NodeId| map.contains_key(&n);
|
||||
match &mut item {
|
||||
BoardItem::Connector(c) if foreign => {
|
||||
let ends = ends_now(doc, *copy);
|
||||
for (end, at) in [(&mut c.from, ends.map(|e| e.0)), (&mut c.to, ends.map(|e| e.1))] {
|
||||
if let (End::Node { node, .. }, Some(p)) = (*end, at)
|
||||
&& !copied(node)
|
||||
{
|
||||
*end = End::Point { x: p.x, y: p.y };
|
||||
}
|
||||
}
|
||||
}
|
||||
BoardItem::Label { connector } if foreign && !copied(*connector) => {
|
||||
let _ = doc.remove(*copy);
|
||||
continue;
|
||||
}
|
||||
BoardItem::Connector(_) | BoardItem::Label { .. } => {}
|
||||
_ => continue,
|
||||
}
|
||||
item.remap(map);
|
||||
if let Some(n) = doc.node_mut(*copy) {
|
||||
n.board = Some(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
use super::*;
|
||||
|
||||
/// A new layer at the top of `parent`, at `at` in it, made by `f`.
|
||||
pub(super) fn add(
|
||||
doc: &mut Document,
|
||||
parent: NodeId,
|
||||
kind: NodeKind,
|
||||
name: &str,
|
||||
at: (f64, f64),
|
||||
size: (f64, f64),
|
||||
f: impl FnOnce(&mut Node),
|
||||
) -> Result<NodeId> {
|
||||
let id = doc.alloc_id();
|
||||
let mut n = Node::new(id, kind, name, size.0, size.1);
|
||||
n.transform = Affine::translate(at);
|
||||
f(&mut n);
|
||||
doc.insert(n, parent, None)?;
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
/// A soft shadow under stickies and stamps.
|
||||
fn soft_shadow() -> Effect {
|
||||
Effect {
|
||||
kind: EffectKind::DropShadow(Shadow { color: Color::new(0.0, 0.0, 0.0, 0.16), offset: (0.0, 2.0), radius: 6.0, ..Shadow::default() }),
|
||||
visible: true,
|
||||
}
|
||||
}
|
||||
|
||||
/// A text layer filling its frame's width (the frame's auto layout places it).
|
||||
pub(crate) fn add_text(doc: &mut Document, parent: NodeId, text: &str, size: f64, color: Color, align: TextAlignH, width: f64) -> Result<NodeId> {
|
||||
let first: String = text.lines().next().unwrap_or("").chars().take(64).collect();
|
||||
let name = if first.trim().is_empty() { "Text" } else { first.as_str() };
|
||||
let mut td = TextData {
|
||||
characters: crate::cmd::props::normalize_line_endings(text),
|
||||
auto_resize: TextAutoResize::Height,
|
||||
align_h: align,
|
||||
..TextData::default()
|
||||
};
|
||||
td.style.font_size = size;
|
||||
let id = add(doc, parent, NodeKind::Text(td), name, (0.0, 0.0), (width.max(1.0), size * 1.4), |n| {
|
||||
n.fills = vec![Paint::solid(color)];
|
||||
n.child_layout.sizing_h = Sizing::Fill;
|
||||
})?;
|
||||
if let Some(n) = doc.node(id)
|
||||
&& let Some((w, h)) = figcraft_text::auto_size(n)
|
||||
&& let Some(n) = doc.node_mut(id)
|
||||
{
|
||||
(n.width, n.height) = (w, h);
|
||||
}
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
/// The text size a sticky's note gets: smaller the more it holds (by characters and lines, so
|
||||
/// every copy of the file picks the same size whatever fonts it has).
|
||||
pub fn sticky_font_size(text: &str) -> f64 {
|
||||
let weight = text.chars().count() + 24 * text.matches('\n').count();
|
||||
match weight {
|
||||
0..=24 => 24.0,
|
||||
25..=64 => 20.0,
|
||||
65..=140 => 16.0,
|
||||
141..=280 => 13.0,
|
||||
_ => 11.0,
|
||||
}
|
||||
}
|
||||
|
||||
/// A sticky note's layers: the frame, the note and (when known) its author.
|
||||
fn make_sticky(doc: &mut Document, parent: NodeId, at: (f64, f64), text: &str, color: Color, author: &str) -> Result<(NodeId, NodeId)> {
|
||||
let id = add(doc, parent, NodeKind::Frame, "Sticky", at, (STICKY, STICKY), |n| {
|
||||
n.fills = vec![Paint::solid(color)];
|
||||
n.corners.radii = [2.0; 4];
|
||||
n.effects = vec![soft_shadow()];
|
||||
n.clip = false;
|
||||
n.layout = Some(AutoLayout {
|
||||
mode: LayoutMode::Vertical,
|
||||
gap: 8.0,
|
||||
padding: [STICKY_PAD; 4],
|
||||
main_align: MainAlign::SpaceBetween,
|
||||
..AutoLayout::default()
|
||||
});
|
||||
n.child_layout.sizing_v = Sizing::Hug;
|
||||
n.child_layout.min_h = Some(STICKY);
|
||||
n.board = Some(BoardItem::Sticky { author: author.to_string() });
|
||||
})?;
|
||||
let inner = STICKY - 2.0 * STICKY_PAD;
|
||||
let note = add_text(doc, id, text, sticky_font_size(text), INK, TextAlignH::Left, inner)?;
|
||||
if !author.is_empty() {
|
||||
let a = add_text(doc, id, author, 10.0, AUTHOR_INK, TextAlignH::Left, inner)?;
|
||||
if let Some(n) = doc.node_mut(a) {
|
||||
n.name = "Author".into();
|
||||
}
|
||||
}
|
||||
Ok((id, note))
|
||||
}
|
||||
|
||||
pub(super) fn sticky_cmd(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "board.sticky";
|
||||
require(s, cmd)?;
|
||||
let color = color_param(cmd, p)?.unwrap_or(s.tool.board.sticky);
|
||||
let author = match get(p, "author") {
|
||||
Some(Value::String(a)) => a.trim().chars().filter(|c| !c.is_control()).take(64).collect(),
|
||||
Some(Value::Bool(false)) => String::new(),
|
||||
_ => author(s),
|
||||
};
|
||||
let parent = parent_of(cmd, s, p)?;
|
||||
let text = string(p, "text").unwrap_or_default();
|
||||
let at = (num_or(p, "x", 0.0), num_or(p, "y", 0.0));
|
||||
let (id, note) = make_sticky(s.doc_mut()?, parent, at, &text, color, &author)?;
|
||||
s.set_selection(vec![id]);
|
||||
Ok(json!({"id": id, "text": note}))
|
||||
}
|
||||
|
||||
/// The first text layer inside board object `id` (its note or label).
|
||||
pub fn text_of(doc: &Document, id: NodeId) -> Option<NodeId> {
|
||||
let n = doc.node(id)?;
|
||||
n.board.as_ref().filter(|b| b.has_text())?;
|
||||
n.children.iter().copied().find(|c| doc.node(*c).is_some_and(|k| matches!(k.kind, NodeKind::Text(_))))
|
||||
}
|
||||
|
||||
/// The board object whose text `text` is, if any.
|
||||
pub fn owner_of_text(doc: &Document, text: NodeId) -> Option<NodeId> {
|
||||
let p = doc.node(text)?.parent?;
|
||||
(text_of(doc, p) == Some(text)).then_some(p)
|
||||
}
|
||||
|
||||
/// Start typing in board object `id`'s text (all of it selected). False when it has none.
|
||||
pub fn edit_text(s: &mut Session, id: NodeId) -> Result<bool> {
|
||||
let Some(t) = s.doc().ok().and_then(|d| text_of(d, id)) else { return Ok(false) };
|
||||
textedit::begin(s, t, None, false)?;
|
||||
textedit::key(s, "SelectAll", false, false, false)?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
pub(super) fn next_sticky(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "board.nextSticky";
|
||||
require(s, cmd)?;
|
||||
let editing = s.ds()?.text_edit.as_ref().map(|t| t.id);
|
||||
let doc = s.doc()?;
|
||||
let from = id(p, "id")
|
||||
.or_else(|| editing.and_then(|t| owner_of_text(doc, t)))
|
||||
.or_else(|| s.selection().first().copied().map(|i| owner_of_text(doc, i).unwrap_or(i)))
|
||||
.ok_or_else(|| EngineError::Disabled(cmd.into(), "select a sticky first".into()))?;
|
||||
let n = doc.get(from)?;
|
||||
if !matches!(n.board, Some(BoardItem::Sticky { .. })) {
|
||||
return Err(bad(cmd, "that isn't a sticky"));
|
||||
}
|
||||
let color = n.fills.iter().find_map(Paint::solid_color).unwrap_or(s.tool.board.sticky);
|
||||
let parent = n.parent.ok_or_else(|| bad(cmd, "that sticky has no parent"))?;
|
||||
let at = n.transform.translation();
|
||||
let (dx, dy) = match string(p, "side").as_deref().unwrap_or("right") {
|
||||
"right" => (n.width + STICKY_GAP, 0.0),
|
||||
"left" => (-(STICKY + STICKY_GAP), 0.0),
|
||||
"below" => (0.0, n.height + STICKY_GAP),
|
||||
"above" => (0.0, -(STICKY + STICKY_GAP)),
|
||||
other => return Err(bad(cmd, format!("`side` is right, below, left or above, not `{other}`"))),
|
||||
};
|
||||
let author = author(s);
|
||||
if editing.is_some() {
|
||||
textedit::end(s)?;
|
||||
}
|
||||
let (id, note) = make_sticky(s.doc_mut()?, parent, (at.x + dx, at.y + dy), "", color, &author)?;
|
||||
s.set_selection(vec![id]);
|
||||
if boolean(p, "edit") == Some(true) {
|
||||
// Typing goes on in the new sticky once this step is recorded.
|
||||
s.request_ui("board.editText", json!({"id": id}));
|
||||
}
|
||||
Ok(json!({"id": id, "text": note}))
|
||||
}
|
||||
|
||||
/// A shape with text's layers: the frame, its outline and its text.
|
||||
fn make_shape(
|
||||
doc: &mut Document,
|
||||
parent: NodeId,
|
||||
at: (f64, f64),
|
||||
size: (f64, f64),
|
||||
shape: BoardShape,
|
||||
text: &str,
|
||||
color: Color,
|
||||
) -> Result<(NodeId, NodeId)> {
|
||||
let (w, h) = size;
|
||||
let id = add(doc, parent, NodeKind::Frame, shape.label(), at, size, |n| {
|
||||
n.fills.clear();
|
||||
n.clip = false;
|
||||
n.layout = Some(AutoLayout {
|
||||
mode: LayoutMode::Vertical,
|
||||
padding: shape_padding(shape, w, h),
|
||||
main_align: MainAlign::Center,
|
||||
cross_align: CrossAlign::Center,
|
||||
..AutoLayout::default()
|
||||
});
|
||||
n.board = Some(BoardItem::Shape { shape });
|
||||
})?;
|
||||
add(doc, id, NodeKind::Vector { network: VectorNetwork::from_path(&shape.path(w, h)) }, "Outline", (0.0, 0.0), size, |n| {
|
||||
n.fills = vec![Paint::solid(color)];
|
||||
n.strokes = vec![Paint::solid(line_shade(color))];
|
||||
n.stroke.weight = 1.5;
|
||||
n.stroke.align = StrokeAlign::Inside;
|
||||
n.child_layout.absolute = true;
|
||||
n.constraints = Constraints { horizontal: HConstraint::Scale, vertical: VConstraint::Scale };
|
||||
})?;
|
||||
let [_, r, _, l] = shape_padding(shape, w, h);
|
||||
let label = add_text(doc, id, text, 16.0, INK, TextAlignH::Center, (w - l - r).max(1.0))?;
|
||||
Ok((id, label))
|
||||
}
|
||||
|
||||
pub(super) fn shape_padding(shape: BoardShape, w: f64, h: f64) -> [f64; 4] {
|
||||
let [t, r, b, l] = shape.text_insets();
|
||||
[t * h, r * w, b * h, l * w]
|
||||
}
|
||||
|
||||
pub(super) fn shape_cmd(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "board.shape";
|
||||
require(s, cmd)?;
|
||||
let shape = match string(p, "shape") {
|
||||
Some(v) => shape_of(cmd, &v)?,
|
||||
None => s.tool.board.shape,
|
||||
};
|
||||
let size = |k| {
|
||||
num(p, k)
|
||||
.map_or(Ok(SHAPE), |v| if (1.0..=100_000.0).contains(&v) { Ok(v) } else { Err(bad(cmd, format!("`{k}` is a size from 1 to 100000"))) })
|
||||
};
|
||||
let (w, h) = (size("width")?, size("height")?);
|
||||
let color = color_param(cmd, p)?.unwrap_or(s.tool.board.sticky);
|
||||
let parent = parent_of(cmd, s, p)?;
|
||||
let text = string(p, "text").unwrap_or_default();
|
||||
let at = (num_or(p, "x", 0.0), num_or(p, "y", 0.0));
|
||||
let (id, label) = make_shape(s.doc_mut()?, parent, at, (w, h), shape, &text, color)?;
|
||||
s.set_selection(vec![id]);
|
||||
Ok(json!({"id": id, "text": label}))
|
||||
}
|
||||
|
||||
pub(super) fn stamp_cmd(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "board.stamp";
|
||||
require(s, cmd)?;
|
||||
let stamp = match string(p, "stamp") {
|
||||
Some(v) => stamp_of(cmd, &v)?,
|
||||
None => s.tool.board.stamp,
|
||||
};
|
||||
let (x, y) = (req_num(cmd, p, "x")?, req_num(cmd, p, "y")?);
|
||||
let author = author(s);
|
||||
let page = s.page()?;
|
||||
let half = STAMP_SIZE / 2.0;
|
||||
let doc = s.doc_mut()?;
|
||||
let id = add(doc, page, NodeKind::Frame, stamp.label(), (x - half, y - half), (STAMP_SIZE, STAMP_SIZE), |n| {
|
||||
n.fills = vec![Paint::solid(stamp.color())];
|
||||
n.corners.radii = [half; 4];
|
||||
n.effects = vec![soft_shadow()];
|
||||
n.clip = false;
|
||||
n.board = Some(BoardItem::Stamp { stamp, author });
|
||||
})?;
|
||||
for part in stamp.mark() {
|
||||
let path = figcraft_geom::BezPath::from_svg(part.d).map_err(|e| EngineError::Other(format!("stamp mark: {e}")))?;
|
||||
add(doc, id, NodeKind::Vector { network: VectorNetwork::from_path(&path) }, "Mark", (0.0, 0.0), (STAMP_SIZE, STAMP_SIZE), |n| {
|
||||
if part.filled {
|
||||
n.fills = vec![Paint::solid(Color::WHITE)];
|
||||
} else {
|
||||
n.fills.clear();
|
||||
n.strokes = vec![Paint::solid(Color::WHITE)];
|
||||
n.stroke.weight = 3.0;
|
||||
(n.stroke.cap_start, n.stroke.cap_end) = (StrokeCap::Round, StrokeCap::Round);
|
||||
n.stroke.join = StrokeJoin::Round;
|
||||
n.stroke.align = StrokeAlign::Center;
|
||||
}
|
||||
})?;
|
||||
}
|
||||
s.set_selection(vec![id]);
|
||||
Ok(json!({"id": id}))
|
||||
}
|
||||
|
||||
pub(super) fn ink_cmd(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "board.ink";
|
||||
require(s, cmd)?;
|
||||
let high = boolean(p, "highlighter").unwrap_or(false);
|
||||
let color = color_param(cmd, p)?.unwrap_or(if high { s.tool.board.highlighter } else { s.tool.board.marker });
|
||||
let (weight, opacity) = if high { HIGHLIGHTER } else { MARKER };
|
||||
if get(p, "path").is_none() && get(p, "points").is_none() {
|
||||
return Err(bad(cmd, "`path` or `points` is required"));
|
||||
}
|
||||
let page = s.page()?;
|
||||
let select = boolean(p, "select").unwrap_or(true);
|
||||
let mut q = json!({"type": "vector", "parent": page, "closed": false, "select": select, "name": if high { "Highlight" } else { "Ink" }, "props": {
|
||||
"fills": [], "strokes": [color.to_css_hex()], "strokeWeight": weight, "strokeCap": "round", "strokeJoin": "round", "strokeAlign": "center",
|
||||
}});
|
||||
for k in ["path", "points"] {
|
||||
if let (Some(v), Some(o)) = (get(p, k), q.as_object_mut()) {
|
||||
o.insert(k.into(), v.clone());
|
||||
}
|
||||
}
|
||||
let made = crate::cmd::create::create(s, &q)?;
|
||||
let id = made.get("id").and_then(id_of).ok_or_else(|| EngineError::Other("no stroke made".into()))?;
|
||||
let n = s.doc_mut()?.get_mut(id)?;
|
||||
n.opacity = opacity;
|
||||
if high {
|
||||
n.blend = BlendMode::Multiply;
|
||||
}
|
||||
n.board = Some(BoardItem::Ink);
|
||||
Ok(json!({"id": id}))
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
use super::connectors::{end_json, other_end};
|
||||
use super::derived::ends_now;
|
||||
use super::edit::label_of;
|
||||
use super::items::edit_text;
|
||||
use super::*;
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// The board tools.
|
||||
|
||||
impl Session {
|
||||
/// A press with a board tool: place a sticky, shape or stamp, start a connector or a stroke,
|
||||
/// or start erasing.
|
||||
pub(crate) fn board_down(&mut self, ev: &PointerEvent) -> Result<()> {
|
||||
// A board tool kept while a design file came to the front: back to Move for this press.
|
||||
if !is_board(self.doc()?) {
|
||||
self.tool.tool = ToolId::Move;
|
||||
return self.pointer(ev);
|
||||
}
|
||||
// A sticky still being typed in ends first, so the next object is an undo step of its own.
|
||||
if self.ds()?.text_edit.is_some() {
|
||||
crate::textedit::end(self)?;
|
||||
}
|
||||
match self.tool.tool {
|
||||
ToolId::Sticky => {
|
||||
let half = STICKY / 2.0;
|
||||
let r = self.execute("board.sticky", &json!({"x": ev.x - half, "y": ev.y - half}))?;
|
||||
self.board_placed();
|
||||
if let Some(id) = r.get("id").and_then(id_of) {
|
||||
edit_text(self, id)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
ToolId::BoardShape => {
|
||||
let half = SHAPE / 2.0;
|
||||
self.execute("board.shape", &json!({"x": ev.x - half, "y": ev.y - half}))?;
|
||||
self.board_placed();
|
||||
Ok(())
|
||||
}
|
||||
ToolId::Stamp => {
|
||||
// Stamps go on one after another: the tool stays.
|
||||
self.execute("board.stamp", &json!({"x": ev.x, "y": ev.y}))?;
|
||||
Ok(())
|
||||
}
|
||||
ToolId::Marker | ToolId::Highlighter => {
|
||||
self.tool.gesture = Gesture::Pencil { points: vec![ev.p()], pressure: vec![ev.pressure] };
|
||||
Ok(())
|
||||
}
|
||||
ToolId::Connector => {
|
||||
let from = self.end_at(ev);
|
||||
self.tool.gesture = Gesture::Connect { from, start: ev.p() };
|
||||
Ok(())
|
||||
}
|
||||
ToolId::Eraser => {
|
||||
self.begin_interaction("Erase")?;
|
||||
self.tool.gesture = Gesture::Erase { gone: Vec::new() };
|
||||
self.erase_at(ev)
|
||||
}
|
||||
_ => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
/// After placing an object: back to the Move tool unless tools stay on.
|
||||
fn board_placed(&mut self) {
|
||||
if !self.prefs.keep_tool {
|
||||
self.set_tool(ToolId::Move);
|
||||
}
|
||||
}
|
||||
|
||||
/// What a connector end at the pointer attaches to: the object there, or the point.
|
||||
fn end_at(&self, ev: &PointerEvent) -> End {
|
||||
let hit = (|| {
|
||||
let doc = self.doc().ok()?;
|
||||
let leaf = self.hit(doc, self.page().ok()?, ev.p(), 2.0 / ev.zoom.max(1e-6))?;
|
||||
let t = figcraft_doc::geometry::select_target(doc, leaf, &[], false);
|
||||
let n = doc.node(t)?;
|
||||
(!matches!(n.board, Some(BoardItem::Connector(_)) | Some(BoardItem::Label { .. }) | Some(BoardItem::Ink))).then_some(t)
|
||||
})();
|
||||
match hit {
|
||||
Some(node) => End::Node { node, side: Side::Auto },
|
||||
None => End::Point { x: ev.x, y: ev.y },
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn connect_drag(&mut self, ev: &PointerEvent, from: End, start: Point) {
|
||||
let a = match from {
|
||||
End::Point { x, y } => Point::new(x, y),
|
||||
End::Node { .. } => start,
|
||||
};
|
||||
self.tool.overlay.rubber = Some([a.x, a.y, ev.x, ev.y]);
|
||||
self.tool.overlay.drop_target = from.node();
|
||||
self.tool.gesture = Gesture::Connect { from, start };
|
||||
if let Ok(ds) = self.ds_mut() {
|
||||
ds.revision += 1;
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn connect_up(&mut self, ev: &PointerEvent, from: End, start: Point) -> Result<()> {
|
||||
self.tool.overlay.rubber = None;
|
||||
self.tool.overlay.drop_target = None;
|
||||
// A click, not a drag: nothing to join.
|
||||
if (ev.p() - start).hypot() * ev.zoom.max(1e-6) < 4.0 {
|
||||
return Ok(());
|
||||
}
|
||||
let to = self.end_at(ev);
|
||||
if to.node().is_some() && to.node() == from.node() {
|
||||
return Ok(());
|
||||
}
|
||||
let style = self.tool.board.routing.id();
|
||||
self.execute("board.connector", &json!({"from": end_json(from), "to": end_json(to), "style": style}))?;
|
||||
self.board_placed();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Dragging the selected connector `id`'s end (`from`: its start): a line from its other end
|
||||
/// to the pointer, and the object it would attach to outlined.
|
||||
pub(crate) fn reattach_drag(&mut self, ev: &PointerEvent, id: NodeId, from: bool, start: Point) {
|
||||
self.tool.gesture = Gesture::Reattach { id, from, start };
|
||||
let Ok(doc) = self.doc() else { return };
|
||||
let fixed = ends_now(doc, id).map(|(a, b)| if from { b } else { a });
|
||||
let other = other_end(doc, id, from).and_then(|e| e.node());
|
||||
self.tool.overlay.rubber = fixed.map(|f| [f.x, f.y, ev.x, ev.y]);
|
||||
self.tool.overlay.drop_target = self.end_at(ev).node().filter(|n| Some(*n) != other);
|
||||
if let Ok(ds) = self.ds_mut() {
|
||||
ds.revision += 1;
|
||||
}
|
||||
}
|
||||
|
||||
/// The end let go: attached to the object under the pointer, or free there (`board.reattach`).
|
||||
/// A click, or a drop on the object the other end is on, changes nothing.
|
||||
pub(crate) fn reattach_up(&mut self, ev: &PointerEvent, id: NodeId, from: bool, start: Point) -> Result<()> {
|
||||
self.tool.overlay.rubber = None;
|
||||
if (ev.p() - start).hypot() * ev.zoom.max(1e-6) < 4.0 {
|
||||
return Ok(());
|
||||
}
|
||||
let target = self.end_at(ev);
|
||||
let other = other_end(self.doc()?, id, from).and_then(|e| e.node());
|
||||
if target.node().is_some() && target.node() == other {
|
||||
return Ok(());
|
||||
}
|
||||
self.execute("board.reattach", &json!({"id": id, "end": if from { "from" } else { "to" }, "target": end_json(target)}))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The eraser at the pointer: the marker and highlighter strokes it touches go (until the
|
||||
/// gesture ends, which is one undo step).
|
||||
pub(crate) fn erase_at(&mut self, ev: &PointerEvent) -> Result<()> {
|
||||
let Gesture::Erase { mut gone } = std::mem::take(&mut self.tool.gesture) else { return Ok(()) };
|
||||
let tol = ERASER_PX / ev.zoom.max(1e-6);
|
||||
self.tool.overlay.brush = Some([ev.x, ev.y, tol]);
|
||||
let (doc, page) = (self.doc()?.clone(), self.page()?);
|
||||
if let Some(hit) =
|
||||
doc.hit_test(page, ev.p(), tol, None).filter(|h| doc.node(*h).is_some_and(|n| n.board == Some(BoardItem::Ink)) && !gone.contains(h))
|
||||
{
|
||||
gone.push(hit);
|
||||
let ids = gone.clone();
|
||||
self.preview(|d, sel| {
|
||||
for i in &ids {
|
||||
let _ = d.remove(*i);
|
||||
}
|
||||
sel.retain(|s| !ids.contains(s));
|
||||
Ok(())
|
||||
})?;
|
||||
}
|
||||
self.tool.gesture = Gesture::Erase { gone };
|
||||
if let Ok(ds) = self.ds_mut() {
|
||||
ds.revision += 1;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn erase_up(&mut self) -> Result<()> {
|
||||
self.tool.overlay.brush = None;
|
||||
self.commit_interaction()
|
||||
}
|
||||
|
||||
/// A finished marker or highlighter stroke (page points).
|
||||
pub(crate) fn ink_finish(&mut self, path: &str) -> Result<()> {
|
||||
let high = self.tool.tool == ToolId::Highlighter;
|
||||
self.execute("board.ink", &json!({"path": path, "highlighter": high, "select": false}))?;
|
||||
// Strokes come one after another: the selection stays as it was.
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl Session {
|
||||
/// A double-click with the Move tool on a board object: type in its text (a connector gets a
|
||||
/// label to type in). True when it was one.
|
||||
pub(crate) fn board_double_click(&mut self, doc: &Document, leaf: NodeId, sel: &[NodeId]) -> Result<bool> {
|
||||
let whole = figcraft_doc::geometry::select_target(doc, leaf, sel, false);
|
||||
let editing = self.ds()?.text_edit.as_ref().is_some_and(|te| doc.is_ancestor(whole, te.id));
|
||||
let Some(item) = doc.node(whole).and_then(|n| n.board.clone()).filter(|_| !editing) else { return Ok(false) };
|
||||
self.tool.gesture = Gesture::None;
|
||||
self.cancel_interaction()?;
|
||||
match item {
|
||||
BoardItem::Connector(_) => {
|
||||
let label = match label_of(doc, whole) {
|
||||
Some(l) => l,
|
||||
None => {
|
||||
let r = self.execute("board.label", &json!({"id": whole, "text": "Label"}))?;
|
||||
r.get("label").and_then(id_of).ok_or_else(|| EngineError::Other("no label made".into()))?
|
||||
}
|
||||
};
|
||||
edit_text(self, label)
|
||||
}
|
||||
BoardItem::Table => crate::board_table::edit_cell(self, doc, leaf),
|
||||
b if b.has_text() => edit_text(self, whole),
|
||||
_ => Ok(false),
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user