M4.25: Drop files on the canvas to place them; several placed files load the place cursor

Files dropped on the canvas of an open document are placed at the drop point (linked; Shift
embeds); dropped on the tab bar or with no document open they open and join Open Recent Files.
The canvas is outlined while files are dragged over it. On the web, drag events give the drop
point and the files arrive through the inbox.

File > Place picks several files. file.place.queue loads the place cursor (the `place` tool):
a click places the current file at 100% with its top-left corner there, a drag places it at the
dragged size with its aspect kept, the arrow keys cycle the files and Esc discards one; the
previous tool returns after the last. The cursor shows the file's thumbnail and a count badge.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
bflatastic 2026-10-05 08:28:11 +07:00
parent 20e0935116
commit 76b7d960f2
17 changed files with 834 additions and 78 deletions

View File

@ -26,4 +26,4 @@ rfd = { workspace = true }
wasm-bindgen = "0.2"
wasm-bindgen-futures = "0.4"
js-sys = "0.3"
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration"] }
web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "HtmlAnchorElement", "Blob", "BlobPropertyBag", "Url", "Location", "CssStyleDeclaration", "Event", "EventTarget", "MouseEvent", "DragEvent"] }

View File

@ -1,15 +1,21 @@
//! The browser shell: web `Services`, drag-and-drop, and the eframe web runner.
use std::cell::Cell;
use std::rc::Rc;
use std::sync::{Arc, Mutex};
use vectorcraft_engine::Session;
use vectorcraft_engine::cmd::fileio;
use vectorcraft_ui_egui::place::{PlaceArrival, PlaceInbox};
use vectorcraft_ui_egui::place::{DropTarget, PlaceArrival, PlaceInbox};
use vectorcraft_ui_egui::{Services, VectorcraftApp};
use wasm_bindgen::JsCast as _;
type Inbox = Arc<Mutex<Vec<(String, Vec<u8>)>>>;
/// Where the pointer was over the canvas during the last file drag (CSS px from its top-left) and
/// whether Shift was held: where dropped files land.
type DragPos = Rc<Cell<Option<(f32, f32, bool)>>>;
const CANVAS_ID: &str = "vectorcraft_canvas";
const LOADING_ID: &str = "vectorcraft_loading";
@ -24,6 +30,7 @@ pub fn start() {
log::error!("missing <canvas id=\"{CANVAS_ID}\">");
return;
};
let drag = track_drag(&canvas);
let mut options = eframe::WebOptions::default();
if query().contains("webgl")
&& let eframe::egui_wgpu::WgpuSetup::CreateNew(create) = &mut options.wgpu_options.wgpu_setup
@ -39,8 +46,9 @@ pub fn start() {
log::info!("vectorcraft-web: wgpu backend {:?}", rs.adapter.get_info().backend);
}
let inbox: Inbox = Arc::default();
let app = VectorcraftApp::new(Session::new(), services(inbox.clone(), Arc::default(), cc.egui_ctx.clone()));
Ok(Box::new(WebShell { app, inbox }))
let place_inbox: PlaceInbox = Arc::default();
let app = VectorcraftApp::new(Session::new(), services(inbox.clone(), place_inbox.clone(), cc.egui_ctx.clone()));
Ok(Box::new(WebShell { app, inbox, place_inbox, drag }))
}),
)
.await;
@ -57,29 +65,58 @@ fn query() -> String {
web_sys::window().and_then(|w| w.location().search().ok()).unwrap_or_default()
}
/// Follow file drags over the canvas: drag events carry the pointer position, which egui doesn't
/// get during a drag.
fn track_drag(canvas: &web_sys::HtmlCanvasElement) -> DragPos {
let pos = DragPos::default();
let p = pos.clone();
let on_drag = wasm_bindgen::closure::Closure::<dyn FnMut(web_sys::DragEvent)>::new(move |e: web_sys::DragEvent| {
p.set(Some((e.offset_x() as f32, e.offset_y() as f32, e.shift_key())));
});
for kind in ["dragover", "drop"] {
if let Err(e) = canvas.add_event_listener_with_callback(kind, on_drag.as_ref().unchecked_ref()) {
log::error!("couldn't follow {kind} events: {e:?}");
}
}
// The listener lives as long as the page.
on_drag.forget();
pos
}
/// Wraps the app to read dropped files asynchronously (browsers can't read them synchronously)
/// and feed them through the inbox.
/// and feed them through the inboxes: placed where they were dropped on the canvas, else opened.
struct WebShell {
app: VectorcraftApp,
inbox: Inbox,
place_inbox: PlaceInbox,
drag: DragPos,
}
impl eframe::App for WebShell {
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
let dropped = ctx.input_mut(|i| std::mem::take(&mut i.raw.dropped_files));
for f in dropped {
let inbox = self.inbox.clone();
let ctx = ctx.clone();
wasm_bindgen_futures::spawn_local(async move {
let name = f.path().file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "dropped".into());
match f.bytes_async().await {
Ok(bytes) => {
inbox.lock().unwrap_or_else(|e| e.into_inner()).push((name, bytes));
ctx.request_repaint();
if !dropped.is_empty() {
let at = self.drag.take();
let z = ctx.zoom_factor();
let target = self.app.drop_target(at.map(|(x, y, _)| egui::pos2(x / z, y / z)), at.is_some_and(|a| a.2));
for f in dropped {
let (inbox, place_inbox, ctx) = (self.inbox.clone(), self.place_inbox.clone(), ctx.clone());
wasm_bindgen_futures::spawn_local(async move {
let name = f.path().file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "dropped".into());
match f.bytes_async().await {
Ok(bytes) => {
match target {
DropTarget::Place { at, embed } => {
place_inbox.lock().unwrap_or_else(|e| e.into_inner()).push(PlaceArrival { name, bytes, drop: Some((at, embed)) })
}
DropTarget::Open => inbox.lock().unwrap_or_else(|e| e.into_inner()).push((name, bytes)),
}
ctx.request_repaint();
}
Err(e) => log::error!("couldn't read dropped file {name}: {e}"),
}
Err(e) => log::error!("couldn't read dropped file {name}: {e}"),
}
});
});
}
}
self.app.logic(ctx);
}
@ -98,17 +135,20 @@ fn services(inbox: Inbox, place_inbox: PlaceInbox, ctx: egui::Context) -> Servic
let picked = place_inbox.clone();
let place_ctx = ctx.clone();
Services {
// File → Place…: the picked file goes to the Place dialog.
// File → Place…: the picked files go to the Place dialog.
place_async: Some(Box::new(move || {
let inbox = picked.clone();
let ctx = place_ctx.clone();
wasm_bindgen_futures::spawn_local(async move {
let dialog = fileio::place_filters().fold(rfd::AsyncFileDialog::new().set_title("Place"), |d, (name, exts)| d.add_filter(name, exts));
let Some(file) = dialog.pick_file().await else {
let Some(files) = dialog.pick_files().await else {
return;
};
let bytes = file.read().await;
inbox.lock().unwrap_or_else(|e| e.into_inner()).push(PlaceArrival { name: file.file_name(), bytes });
let mut arrived = vec![];
for f in files {
arrived.push(PlaceArrival { name: f.file_name(), bytes: f.read().await, drop: None });
}
inbox.lock().unwrap_or_else(|e| e.into_inner()).extend(arrived);
ctx.request_repaint();
});
})),

View File

@ -104,6 +104,15 @@ fn services() -> Services {
.pick_file()
.map(|p| p.to_string_lossy().to_string())
})),
pick_open_multi: Some(Box::new(|| {
fileio::place_filters()
.fold(rfd::FileDialog::new().set_title("Place"), |d, (name, exts)| d.add_filter(name, exts))
.pick_files()
.unwrap_or_default()
.into_iter()
.map(|p| p.to_string_lossy().to_string())
.collect()
})),
pick_save: Some(Box::new(|name: &str| rfd::FileDialog::new().set_file_name(name).save_file().map(|p| p.to_string_lossy().to_string()))),
read: Some(Box::new(|p: &str| std::fs::read(p).map_err(|e| e.to_string()))),
write: Some(Box::new(|p: &str, b: &[u8]| std::fs::write(p, b).map_err(|e| e.to_string()))),

View File

@ -10,7 +10,8 @@
//!
//! The art lands centred on `at`, fitted into `rect`, or (Replace) where the replaced object was,
//! with its transform; Template puts it on a new template layer. The images, symbols, patterns and
//! swatches it uses join the document ([`adopt`]).
//! swatches it uses join the document ([`adopt`]). `file.place.queue` loads the place cursor (the
//! `place` tool) with several files, which emits one `file.place` per click or drag.
mod adopt;
@ -26,6 +27,9 @@ use super::*;
use crate::{EngineError, MAX_COORD};
const PLACE: &str = "file.place";
const QUEUE: &str = "file.place.queue";
/// The most files one `file.place.queue` loads.
const MAX_QUEUE: usize = 100;
/// The longest thumbnail side `thumbnail` may ask for (px).
const MAX_THUMBNAIL: f64 = 512.0;
@ -58,6 +62,15 @@ pub fn specs() -> Vec<CommandSpec> {
has_doc,
image_info
),
cmd!(
query "file.place.queue",
"Load Place Cursor",
[],
None,
"{paths?: [path…], files?: [{name, dataBase64}…], link?, template?, page?, crop?, thumbnail?: px} load the place cursor (the `place` tool) with up to 100 files (as file.place reads them): a click places the current file at 100% with its top-left corner there, a drag places it at the dragged size (aspect kept), ←/→ and ↑/↓ cycle the files, Esc discards the current one; each placement is a file.place, and the previous tool returns after the last → {count, files: [{name, format, width, height, thumbnailBase64?}], skipped: [{name, error}]}",
has_doc,
queue
),
]
}
@ -311,6 +324,66 @@ fn template_layer(d: &mut Document, below: Option<NodeId>, name: &str) -> Result
Ok(id)
}
// ---------- file.place.queue ----------
/// The files `file.place.queue` names, as `file.place` params.
fn queued_files(p: &Value) -> Result<Vec<Value>> {
let list = |k: &str| p.get(k).and_then(Value::as_array).map(Vec::as_slice).unwrap_or_default();
let mut out = vec![];
for v in list("paths") {
let path = v.as_str().ok_or_else(|| bad(QUEUE, "paths must be strings"))?;
out.push(json!({ "path": path }));
}
for f in list("files") {
let (Some(name), Some(data)) = (str_param(f, "name"), str_param(f, "dataBase64")) else {
return Err(bad(QUEUE, "files must be {name, dataBase64}"));
};
out.push(json!({ "name": name, "dataBase64": data }));
}
match out.len() {
0 => Err(bad(QUEUE, "give paths or files")),
n if n > MAX_QUEUE => Err(bad(QUEUE, format!("at most {MAX_QUEUE} files"))),
_ => Ok(out),
}
}
fn queue(s: &mut Session, p: &Value) -> Result<Value> {
let thumbnail = p.get("thumbnail").and_then(Value::as_f64);
let (mut entries, mut files, mut skipped) = (vec![], vec![], vec![]);
for mut q in queued_files(p)? {
for k in ["link", "template", "page", "crop"] {
if let Some(v) = p.get(k) {
q[k] = v.clone();
}
}
match load(&q, QUEUE) {
Ok(l) => {
let (w, h) = (l.natural.width(), l.natural.height());
let mut f = json!({ "name": l.name, "format": l.format.id, "width": w, "height": h });
entries.push(json!({ "params": q, "name": l.name, "width": w, "height": h }));
if let Some(px) = thumbnail {
f["thumbnailBase64"] = json!(thumbnail_png(l, px)?);
}
files.push(f);
}
Err(e) => {
let name = str_param(&q, "path").or(str_param(&q, "name")).map(fileio::file_name).unwrap_or_default();
skipped.push(json!({ "name": name, "error": e.to_string() }));
}
}
}
if entries.is_empty() {
let why = skipped.first().and_then(|e| e["error"].as_str()).unwrap_or("nothing to place");
return Err(bad(QUEUE, why));
}
// Reloading the cursor keeps the tool to return to.
let prev = Some(s.tool_id()).filter(|t| *t != "place");
let view = s.last_view;
s.select_tool("place", view)?;
s.set_tool_option("queue", &json!({ "entries": entries, "prev": prev }));
Ok(json!({ "count": files.len(), "files": files, "skipped": skipped }))
}
// ---------- queries ----------
/// The colour mode an image file stores its pixels in.

View File

@ -1,4 +1,5 @@
//! File → Place (`file.place`, `file.place.info`, `image.info`).
//! File → Place (`file.place`, `file.place.info`, `image.info`) and the place cursor
//! (`file.place.queue` and the `place` tool).
use std::sync::Arc;
@ -6,6 +7,7 @@ use serde_json::{Value, json};
use vectorcraft_color::Paint;
use vectorcraft_doc::{AppearanceItem, Node, NodeKind};
use vectorcraft_geom::Rect;
use vectorcraft_tools::{Mods, PointerEvent, PointerKind, ToolKey};
use super::*;
@ -261,6 +263,37 @@ fn info_reports_size_resolution_colour_mode_and_link() {
let _ = std::fs::remove_file(&path);
}
#[test]
fn a_queue_of_3_with_click_drag_and_esc_places_2() {
let mut s = session();
let view = ViewInfo::default();
let files: Vec<Value> = ["a.png", "b.png", "c.png"].iter().map(|n| file(n, &png(40, 20, [9, 9, 9, 255], None))).collect();
let r = s.execute("file.place.queue", &json!({"files": files, "thumbnail": 8})).unwrap();
assert_eq!(r["count"], 3);
assert!(r["files"][0]["thumbnailBase64"].is_string());
assert_eq!(s.tool_id(), "place");
assert_eq!(s.tool_options()["name"], "a.png");
let ev = |kind, x, y| PointerEvent::new(kind, x, y);
// Click: 100% with its top-left corner at the click.
s.pointer(&ev(PointerKind::Down, 10.0, 10.0), view).unwrap();
s.pointer(&ev(PointerKind::Up, 10.0, 10.0), view).unwrap();
let a = s.doc().unwrap().selection.objects[0];
assert_eq!(s.doc().unwrap().doc.node(a).unwrap().geometric_bounds(), Some(Rect::new(10.0, 10.0, 50.0, 30.0)));
// Drag: the dragged size, aspect kept.
s.pointer(&ev(PointerKind::Down, 100.0, 100.0), view).unwrap();
s.pointer(&ev(PointerKind::Drag, 180.0, 110.0), view).unwrap();
s.pointer(&ev(PointerKind::Up, 180.0, 110.0), view).unwrap();
let b = s.doc().unwrap().selection.objects[0];
assert_eq!(s.doc().unwrap().doc.node(b).unwrap().name.as_deref(), Some("b.png"));
assert_eq!(s.doc().unwrap().doc.node(b).unwrap().geometric_bounds(), Some(Rect::new(100.0, 100.0, 180.0, 140.0)));
// Esc discards the last one and asks for the previous tool back.
assert!(s.tool_claims_key(ToolKey::Escape, view));
let reqs = s.tool_key(ToolKey::Escape, Mods::default(), view).unwrap();
assert_eq!(reqs, vec![UiRequest::SwitchTool("selection".into())]);
assert_eq!(top_children(&s).len(), 2);
assert_eq!(s.doc().unwrap().history.undo.len(), 2, "one undo step per placement");
}
#[test]
fn bad_place_params_are_errors() {
let mut s = session();
@ -277,5 +310,8 @@ fn bad_place_params_are_errors() {
] {
assert!(s.execute("file.place", &p).is_err(), "{p}");
}
assert!(s.execute("file.place.queue", &json!({})).is_err());
assert!(s.execute("file.place.queue", &json!({"files": [{"name": "notes.txt", "dataBase64": ""}]})).is_err());
assert_eq!(s.tool_id(), "selection", "a failed queue leaves the tool alone");
assert!(top_children(&s).is_empty());
}

View File

@ -1,4 +1,5 @@
//! File → Place over MCP, headless: `file.place` through `run_command`.
//! File → Place over MCP, headless: `file.place` through `run_command`, and the place cursor driven
//! by pointer gestures and keys.
use serde_json::{Value, json};
@ -38,3 +39,20 @@ fn headless_run_command_file_place() {
assert_eq!(sel.len(), 1);
assert_eq!(sel[0].0, r["ids"][0].as_u64().unwrap());
}
#[test]
fn headless_place_cursor_places_on_clicks_and_esc_ends_it() {
let (mut h, png) = setup();
let files = json!([{"name": "a.png", "dataBase64": png}, {"name": "b.png", "dataBase64": png}, {"name": "c.png", "dataBase64": png}]);
let r = text(&call_tool(&mut h, "run_command", &json!({"command": "file.place.queue", "params": {"files": files}})));
assert_eq!(r["count"], 3);
assert_eq!(h.session.tool_id(), "place");
let click = |x: f64| json!({"events": [{"kind": "down", "x": x, "y": 10}, {"kind": "up", "x": x, "y": 10}]});
text(&call_tool(&mut h, "pointer_gesture", &click(10.0)));
text(&call_tool(&mut h, "pointer_gesture", &click(60.0)));
assert_eq!(images(&h), 2);
let r = text(&call_tool(&mut h, "press_key", &json!({"key": "Escape"})));
assert_eq!(r["handledBy"], "tool");
assert_eq!(h.session.tool_id(), "selection", "the last file discarded: back to the previous tool");
assert_eq!(images(&h), 2);
}

View File

@ -19,6 +19,7 @@ pub mod guides;
pub mod meshblend;
pub mod params;
pub mod pen;
pub mod place;
pub mod select;
pub mod shape;
pub mod symbolism;
@ -334,6 +335,8 @@ pub fn create(id: &str) -> Box<dyn Tool> {
"pen" => Box::new(pen::PenTool::default()),
"type" | "areaType" | "typeOnPath" => Box::new(text::TypeTool::new(id)),
"rectangle" | "roundedRectangle" | "ellipse" | "polygon" | "star" | "lineSegment" => Box::new(shape::ShapeTool::new(id)),
// Not in the toolbar: `file.place.queue` loads it.
"place" => Box::new(place::PlaceTool::default()),
other => symbolism::create(other)
.or_else(|| builder::create(other))
.or_else(|| draw2::create(other))

245
crates/tools/src/place.rs Normal file
View File

@ -0,0 +1,245 @@
//! The place cursor (`file.place.queue` loads it with files): a click places the current file at
//! 100% with its top-left corner at the click, a drag places it at the dragged size (its aspect
//! kept), ←/→ and ↑/↓ cycle the files and Esc discards the current one. Each placement is one
//! `file.place`; after the last file the previous tool comes back.
use serde_json::{Value, json};
use vectorcraft_geom::{Point, Rect};
use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey};
/// One loaded file.
#[derive(Clone, Debug, PartialEq)]
struct Entry {
/// `file.place` params (the file and its options).
params: Value,
name: String,
/// Its size at 100% (pt).
width: f64,
height: f64,
}
#[derive(Default)]
pub struct PlaceTool {
entries: Vec<Entry>,
current: usize,
/// The tool to return to when the last file is placed or discarded.
prev: Option<String>,
/// Pressed at, and the current drag point.
drag: Option<(Point, Point)>,
}
impl PlaceTool {
fn entry(&self) -> Option<&Entry> {
self.entries.get(self.current)
}
/// The box the current file goes into for a press at `a` released at `b`: its 100% size with
/// its top-left corner at `a` for a click (under `tol`), else grown from `a` towards `b` with
/// its aspect kept.
fn target(&self, a: Point, b: Point, tol: f64) -> Option<Rect> {
let e = self.entry()?;
let (dx, dy) = (b.x - a.x, b.y - a.y);
if dx.abs().max(dy.abs()) < tol || e.width <= 0.0 || e.height <= 0.0 {
return Some(Rect::new(a.x, a.y, a.x + e.width, a.y + e.height));
}
let k = (dx.abs() / e.width).max(dy.abs() / e.height);
let (w, h) = (e.width * k, e.height * k);
let x = if dx < 0.0 { a.x - w } else { a.x };
let y = if dy < 0.0 { a.y - h } else { a.y };
Some(Rect::new(x, y, x + w, y + h))
}
/// Drop the current file; after the last one, back to the previous tool.
fn discard(&mut self) -> Vec<Action> {
if self.current < self.entries.len() {
self.entries.remove(self.current);
}
if self.entries.is_empty() {
self.current = 0;
return vec![Action::SwitchTool(self.prev.clone().unwrap_or_else(|| "selection".into()))];
}
self.current %= self.entries.len();
vec![]
}
fn cycle(&mut self, forward: bool) {
let n = self.entries.len();
if n > 0 {
self.current = if forward { (self.current + 1) % n } else { (self.current + n - 1) % n };
}
}
}
impl Tool for PlaceTool {
fn id(&self) -> &'static str {
"place"
}
fn busy(&self) -> bool {
self.drag.is_some()
}
fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec<Action> {
match ev.kind {
PointerKind::Down if self.entry().is_some() => self.drag = Some((ev.pos, ev.pos)),
PointerKind::Drag => {
if let Some((a, _)) = self.drag {
self.drag = Some((a, ev.pos));
}
}
PointerKind::Up => {
let Some((a, _)) = self.drag.take() else { return vec![] };
let (Some(r), Some(e)) = (self.target(a, ev.pos, cx.tol(3.0)), self.entry()) else { return vec![] };
let mut p = e.params.clone();
p["rect"] = json!([r.x0, r.y0, r.width(), r.height()]);
let mut acts = vec![Action::Exec("file.place".into(), p)];
acts.extend(self.discard());
return acts;
}
_ => {}
}
vec![]
}
fn key(&mut self, _cx: &ToolContext, key: ToolKey, _mods: Mods) -> Vec<Action> {
match key {
ToolKey::Escape if self.drag.take().is_some() => vec![],
ToolKey::Escape => self.discard(),
ToolKey::Left | ToolKey::Up => {
self.cycle(false);
vec![]
}
ToolKey::Right | ToolKey::Down => {
self.cycle(true);
vec![]
}
_ => vec![],
}
}
fn claims_key(&self, _cx: &ToolContext, key: ToolKey) -> bool {
!self.entries.is_empty() && matches!(key, ToolKey::Escape | ToolKey::Left | ToolKey::Right | ToolKey::Up | ToolKey::Down)
}
fn overlays(&self, cx: &ToolContext) -> Vec<Overlay> {
let Some((a, b)) = self.drag else { return vec![] };
self.target(a, b, cx.tol(3.0)).map(Overlay::Marquee).into_iter().collect()
}
fn cursor(&self, _cx: &ToolContext, _p: Point, _mods: Mods) -> Cursor {
Cursor::Crosshair
}
/// `{count, current, name, width, height, names}`: what the cursor holds (no file data).
fn options(&self) -> Value {
let e = self.entry();
json!({
"count": self.entries.len(),
"current": self.current,
"name": e.map(|e| &e.name),
"width": e.map(|e| e.width),
"height": e.map(|e| e.height),
"names": self.entries.iter().map(|e| &e.name).collect::<Vec<_>>(),
})
}
/// `queue`: `{entries: [{params, name, width, height}], prev?}` loads the cursor (replacing what
/// it held); `current`: index of the file to place next.
fn set_option(&mut self, key: &str, value: &Value) {
match key {
"queue" => {
let entries: Vec<Entry> = value["entries"]
.as_array()
.into_iter()
.flatten()
.filter_map(|e| {
Some(Entry {
params: e.get("params").filter(|p| p.is_object())?.clone(),
name: e["name"].as_str().unwrap_or_default().to_string(),
width: e["width"].as_f64().filter(|v| v.is_finite())?,
height: e["height"].as_f64().filter(|v| v.is_finite())?,
})
})
.collect();
self.entries = entries;
self.current = 0;
self.drag = None;
if let Some(prev) = value["prev"].as_str() {
self.prev = Some(prev.to_string());
}
}
"current" => {
if let Some(i) = value.as_u64().map(|i| i as usize).filter(|i| *i < self.entries.len()) {
self.current = i;
}
}
_ => {}
}
}
/// Switching tools discards what the cursor holds.
fn deactivate(&mut self, _cx: &ToolContext) -> Vec<Action> {
self.entries.clear();
self.drag = None;
vec![]
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::testutil::{cx, doc_with_rect, paint};
fn loaded(n: usize) -> PlaceTool {
let mut t = PlaceTool::default();
let entries: Vec<Value> =
(0..n).map(|i| json!({"params": {"path": format!("f{i}.png")}, "name": format!("f{i}.png"), "width": 40.0, "height": 20.0})).collect();
t.set_option("queue", &json!({"entries": entries, "prev": "pen"}));
t
}
fn ev(kind: PointerKind, x: f64, y: f64) -> PointerEvent {
PointerEvent::new(kind, x, y)
}
#[test]
fn click_places_at_100_percent_and_drag_keeps_the_aspect() {
let (d, _) = doc_with_rect();
let (s, p) = (Default::default(), paint());
let c = cx(&d, &s, &p);
let mut t = loaded(3);
t.pointer(&c, &ev(PointerKind::Down, 10.0, 10.0));
let acts = t.pointer(&c, &ev(PointerKind::Up, 11.0, 10.0));
assert_eq!(acts, vec![Action::Exec("file.place".into(), json!({"path": "f0.png", "rect": [10.0, 10.0, 40.0, 20.0]}))]);
// A drag up and to the left grows the box from the press point, aspect kept.
t.pointer(&c, &ev(PointerKind::Down, 100.0, 100.0));
t.pointer(&c, &ev(PointerKind::Drag, 20.0, 90.0));
assert_eq!(t.overlays(&c), vec![Overlay::Marquee(Rect::new(20.0, 60.0, 100.0, 100.0))]);
let acts = t.pointer(&c, &ev(PointerKind::Up, 20.0, 90.0));
assert_eq!(acts[0], Action::Exec("file.place".into(), json!({"path": "f1.png", "rect": [20.0, 60.0, 80.0, 40.0]})));
assert_eq!(t.options()["count"], 1);
// Placing the last file returns to the previous tool.
t.pointer(&c, &ev(PointerKind::Down, 0.0, 0.0));
let acts = t.pointer(&c, &ev(PointerKind::Up, 0.0, 0.0));
assert_eq!(acts.last(), Some(&Action::SwitchTool("pen".into())));
assert!(!t.claims_key(&c, ToolKey::Escape), "an empty cursor takes no keys");
}
#[test]
fn arrows_cycle_and_escape_discards() {
let (d, _) = doc_with_rect();
let (s, p) = (Default::default(), paint());
let c = cx(&d, &s, &p);
let mut t = loaded(3);
assert!(t.claims_key(&c, ToolKey::Left) && t.claims_key(&c, ToolKey::Escape));
t.key(&c, ToolKey::Left, Mods::default());
assert_eq!(t.options()["name"], "f2.png");
t.key(&c, ToolKey::Down, Mods::default());
assert_eq!(t.options()["name"], "f0.png");
assert!(t.key(&c, ToolKey::Escape, Mods::default()).is_empty());
assert_eq!(t.options()["names"], json!(["f1.png", "f2.png"]));
t.key(&c, ToolKey::Escape, Mods::default());
assert_eq!(t.key(&c, ToolKey::Escape, Mods::default()), vec![Action::SwitchTool("pen".into())]);
}
}

View File

@ -303,6 +303,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
let overlays = app.session.overlays(view_info);
draw_overlays(&painter, &xf, &overlays, &t);
}
crate::place::paint_drop_highlight(app, ui.ctx(), &painter, rect);
if app.ui.view.rulers && app.ui.screen_mode < 3 {
rulers(ui, full, &xf, app.hover_doc, app.session.general_unit(), &t);
@ -324,6 +325,12 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
} else if let Some(p) = app.hover_doc {
let c = app.session.cursor(p, mods(m, space), view_info);
let painter = ui.ctx().layer_painter(egui::LayerId::new(egui::Order::Tooltip, egui::Id::new("tool-cursor")));
// The loaded place cursor carries the file's thumbnail.
if app.session.tool_id() == "place"
&& let Some(hp) = ui.input(|i| i.pointer.hover_pos())
{
crate::place::paint_cursor(app, ui.ctx(), &painter, hp);
}
// Caps Lock gives precise (crosshair) cursors, like Illustrator; env opt-out for system cursors.
let custom = std::env::var_os("VECTORCRAFT_SYSTEM_CURSORS").is_none();
match ui.input(|i| i.pointer.hover_pos()) {
@ -530,7 +537,13 @@ fn drag_art_out(app: &mut VectorcraftApp, ui: &Ui, resp: &egui::Response, p: Pos
/// Send a pointer event to the active tool and act on UI requests (dialogs, tool switches).
pub fn dispatch(app: &mut VectorcraftApp, ev: &PointerEvent, view: vectorcraft_engine::ViewInfo) {
match app.session.pointer(ev, view) {
let r = app.session.pointer(ev, view);
apply_requests(app, r);
}
/// Act on what a tool event asked the UI for (dialogs, tool switches), or show its error.
pub fn apply_requests(app: &mut VectorcraftApp, r: vectorcraft_engine::Result<Vec<vectorcraft_engine::UiRequest>>) {
match r {
Ok(reqs) => {
for r in reqs {
match r {

View File

@ -1,9 +1,9 @@
//! File → Place: the file picked, a line about what it is, and the Place options row (Link,
//! Template, Replace). OK places it centred in the view, or in place of the selected object
//! ([`crate::place::confirm`]).
//! File → Place: the files picked, one line each about what they are, and the Place options row
//! (Link, Template, Replace). OK places one file centred in the view (or in place of the selected
//! object) or loads the place cursor with several ([`crate::place::confirm`]).
//!
//! Fields: `files` (`[{path} | {name}]`), `link`, `template`, `replace`, and `__replace` (one
//! selected object: Replace applies), `__info` (a line per file).
//! Fields: `files` (`[{path} | {name}]`), `link`, `template`, `replace`, and `__replace` (one file
//! and one selected object: Replace applies), `__info` (a line per file).
use serde_json::{Value, json};
@ -61,6 +61,10 @@ fn body(_: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
}
});
});
if files.len() > 1 {
ui.add_space(4.0);
widgets::dim_label(ui, "Click to place each file at 100%, or drag to size it; arrow keys switch files, Esc skips one.");
}
ui.add_space(10.0);
// The options row.
ui.horizontal(|ui| {

View File

@ -88,9 +88,12 @@ pub struct Services {
pub clipboard_read: Option<Box<dyn FnMut() -> Option<String>>>,
/// Open a URL in the system browser (desktop). Without it, egui opens it (a new tab on the web).
pub open_url: Option<OpenUrlFn>,
/// Web: start an async pick of a file to place (it arrives via `place_inbox`).
/// Show an open dialog for several files (File → Place…); returns their paths (none: cancelled).
pub pick_open_multi: Option<Box<dyn FnMut() -> Vec<String>>>,
/// Web: start an async pick of files to place (they arrive via `place_inbox`).
pub place_async: Option<Box<dyn FnMut()>>,
/// Files that arrived asynchronously to be placed (web: picked for Place).
/// Files that arrived asynchronously to be placed (web: picked for Place, or dropped on the
/// canvas).
pub place_inbox: Option<place::PlaceInbox>,
}
@ -167,7 +170,7 @@ pub struct VectorcraftApp {
/// Windows and Linux: the window has no OS decorations, so the app bar is the title bar (drag,
/// double-click to maximize, caption buttons) and invisible edge zones resize the window.
pub custom_titlebar: bool,
/// File → Place: picked files, the Control bar's image details.
/// File → Place: picked files, the place cursor's thumbnails, the Control bar's image details.
pub place: place::PlaceState,
}
@ -491,21 +494,31 @@ impl VectorcraftApp {
if self.fonts_ready {
shortcuts::handle(self, ctx);
}
// Native only: the web host reads dropped files asynchronously and feeds `Services::inbox`.
// Native only: the web host reads dropped files asynchronously and feeds the inboxes.
#[cfg(not(target_arch = "wasm32"))]
for f in ctx.input(|i| i.raw.dropped_files.clone()) {
let path = f.path().to_path_buf();
let name = path.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "dropped".into());
match f.bytes() {
Ok(b) => {
let p = Some(path.to_string_lossy().to_string()).filter(|s| !s.is_empty());
if let Err(e) = io::open_bytes(self, &name, &b, p) {
self.status(format!("Couldn't open {name}: {e}"));
}
}
self.take_dropped_files(ctx);
}
/// Files dropped on the window: placed where they were dropped on the canvas, else opened.
#[cfg(not(target_arch = "wasm32"))]
fn take_dropped_files(&mut self, ctx: &egui::Context) {
let (dropped, pos, shift) = ctx.input(|i| (i.raw.dropped_files.clone(), i.pointer.latest_pos(), i.modifiers.shift));
if dropped.is_empty() {
return;
}
let target = self.drop_target(pos, shift);
let mut files = vec![];
for f in dropped {
let path = Some(f.path().to_string_lossy().to_string()).filter(|s| !s.is_empty());
let name = path.as_deref().map_or_else(|| "dropped".into(), vectorcraft_engine::cmd::fileio::file_name);
// A file placed by its path is read by the engine.
let bytes = if path.is_some() && target != place::DropTarget::Open { Ok(vec![]) } else { f.bytes() };
match bytes {
Ok(b) => files.push((name, path, b)),
Err(e) => self.status(format!("Couldn't read {name}: {e}")),
}
}
place::drop_files(self, files, target);
}
/// Inject synthetic events (one press/release step per frame).

View File

@ -62,7 +62,7 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
"file.place",
"Place…",
"Cmd+Shift+P",
"{path?} no file: pick one, then the Place dialog (Link, Template, Replace); path (or name+dataBase64, …file.place params): file.place, centred in the view",
"{path? | paths?} no file: pick files, then the Place dialog (Link, Template, Replace); paths: that dialog for them; path (or name+dataBase64, …file.place params): file.place, centred in the view",
),
("file.openRecent1", "Open Recent File 1", "", "{}"),
("file.openRecent2", "Open Recent File 2", "", "{}"),
@ -440,6 +440,7 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<R
}
"file.recentFiles" => Ok(json!(app.ui.recent_files)),
"file.place" => crate::place::run(app, p),
"file.place.queue" => crate::place::queue(app, p),
"file.export.svg" if p.as_object().is_none_or(|o| o.is_empty()) => {
crate::dialogs::svg_options::open(app, crate::dialogs::svg_options::Mode::Export, None);
Ok(Value::Null)

View File

@ -1,32 +1,58 @@
//! File → Place in the app: picking the file, the Place dialog's options and the Control bar's
//! image details. The engine places (`file.place`); this gathers the file and options.
//! File → Place in the app: picking the files, the Place dialog's options, files dropped on the
//! window, the loaded place cursor and the Control bar's image details. The engine places
//! (`file.place`, `file.place.queue`); this gathers the files and options.
use std::sync::{Arc, Mutex};
use serde_json::{Value, json};
use vectorcraft_doc::NodeKind;
use vectorcraft_engine::cmd::fileio;
use vectorcraft_geom::Point;
use crate::VectorcraftApp;
use crate::theme::Tokens;
use crate::theme::{self, Tokens};
use crate::{VectorcraftApp, io};
/// A file to place that arrived asynchronously (web): picked for File → Place.
/// The loaded place cursor's thumbnail size (px on the longer side).
const THUMB: u32 = 64;
/// Where the thumbnail sits from the pointer (points).
const THUMB_OFFSET: egui::Vec2 = egui::vec2(14.0, 14.0);
/// A file to place that arrived asynchronously (web): picked for File → Place (`drop: None`), or
/// dropped on the canvas at a point, embedded or not.
pub struct PlaceArrival {
pub name: String,
pub bytes: Vec<u8>,
pub drop: Option<(Point, bool)>,
}
pub type PlaceInbox = Arc<Mutex<Vec<PlaceArrival>>>;
/// Where files dropped on the window go.
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum DropTarget {
/// The active document's canvas at `at` (document coordinates); `embed` when Shift is held.
Place { at: Point, embed: bool },
/// Opened as documents: no document is open, or the drop missed the canvas (the tab bar).
Open,
}
/// The app's Place state (not saved with the preferences).
#[derive(Default)]
pub struct PlaceState {
/// Web: the bytes of the files picked for the Place dialog, by name.
picked: Vec<(String, Vec<u8>)>,
/// The loaded cursor's thumbnails by file name: decoded when the cursor is loaded, uploaded
/// when first drawn.
thumbs: Vec<(String, Thumb)>,
/// The Control bar's `image.info`, for (document uid, revision, image id).
info: Option<((u64, u64, u64), Value)>,
}
enum Thumb {
Decoded(egui::ColorImage),
Uploaded(egui::TextureHandle),
}
/// Run engine command `id` itself (past the UI's handling of the same id); an error shows in the
/// status bar.
fn engine(app: &mut VectorcraftApp, id: &str, p: &Value) -> Result<Value, String> {
@ -41,10 +67,19 @@ fn engine(app: &mut VectorcraftApp, id: &str, p: &Value) -> Result<Value, String
// ---------- File → Place… ----------
/// File → Place… (`file.place`): with a file (`path` or `name` + `dataBase64`), place it, centred
/// in the view unless `at`, `rect` or `replace` say otherwise; without, pick the file first.
/// in the view unless `at`, `rect` or `replace` say otherwise; with `paths`, the Place dialog for
/// them; with neither, pick the files first.
pub fn run(app: &mut VectorcraftApp, p: &Value) -> Result<Value, String> {
if p.get("path").is_none() && p.get("dataBase64").is_none() {
return pick(app).map(|_| Value::Null);
let files: Vec<Value> = match p.get("paths").and_then(Value::as_array) {
Some(a) => a.iter().filter_map(Value::as_str).map(|path| json!({ "path": path })).collect(),
None => return pick(app).map(|_| Value::Null),
};
if files.is_empty() {
return Err("paths: give at least one file".into());
}
open_dialog(app, files);
return Ok(Value::Null);
}
let mut p = p.clone();
if ["at", "rect", "replace"].iter().all(|k| p.get(k).is_none())
@ -55,14 +90,21 @@ pub fn run(app: &mut VectorcraftApp, p: &Value) -> Result<Value, String> {
engine(app, "file.place", &p)
}
/// Pick the file to place: the Place dialog opens with it (on the web once it arrives).
/// Pick files to place: the Place dialog opens with them (on the web once they arrive).
fn pick(app: &mut VectorcraftApp) -> Result<(), String> {
if let Some(f) = app.services.place_async.as_mut() {
f();
return Ok(());
}
let path = app.services.pick_open.as_mut().and_then(|f| f()).ok_or("cancelled")?;
open_dialog(app, vec![json!({ "path": path })]);
let paths = match (app.services.pick_open_multi.as_mut(), app.services.pick_open.as_mut()) {
(Some(f), _) => f(),
(None, Some(f)) => f().into_iter().collect(),
(None, None) => vec![],
};
if paths.is_empty() {
return Err("cancelled".into());
}
open_dialog(app, paths.into_iter().map(|path| json!({ "path": path })).collect());
Ok(())
}
@ -118,38 +160,171 @@ fn fmt_ppi(v: f64) -> String {
s.strip_suffix(".0").map(str::to_string).unwrap_or(s)
}
/// Place (the dialog's OK): the file is placed centred in the view (or replaces the selection).
/// Place (the dialog's OK): one file is placed centred in the view (or replaces the selection);
/// several load the place cursor.
pub fn confirm(app: &mut VectorcraftApp, d: &crate::state::Dialog) -> Result<Value, String> {
let files: Vec<Value> = d.fields.get("files").and_then(Value::as_array).cloned().unwrap_or_default();
let Some(mut p) = files.first().and_then(|f| source(app, f)) else { return Err("no file to place".into()) };
let link = d.bool("link");
let sources: Vec<Value> = files.iter().filter_map(|f| source(app, f)).collect();
if sources.is_empty() {
return Err("no files to place".into());
}
let (link, template) = (d.bool("link"), d.bool("template"));
app.ui.place_link = link;
app.ui.dialog = None;
p["link"] = json!(link);
p["template"] = json!(d.bool("template"));
if d.bool("replace") && d.bool("__replace") {
p["replace"] = json!(true);
}
let r = run(app, &p);
let r = match &sources[..] {
[one] => {
let mut p = one.clone();
p["link"] = json!(link);
p["template"] = json!(template);
if d.bool("replace") && d.bool("__replace") {
p["replace"] = json!(true);
}
run(app, &p)
}
many => {
let paths: Vec<&Value> = many.iter().filter_map(|s| s.get("path")).collect();
let files: Vec<&Value> = many.iter().filter(|s| s.get("path").is_none()).collect();
queue(app, &json!({ "paths": paths, "files": files, "link": link, "template": template }))
}
};
app.place.picked.clear();
r
}
/// Files picked for File → Place that arrived (web): the Place dialog for them.
// ---------- the place cursor ----------
/// `file.place.queue` with thumbnails for the cursor (unless `thumbnail` is given).
pub fn queue(app: &mut VectorcraftApp, p: &Value) -> Result<Value, String> {
let mut p = p.clone();
if p.get("thumbnail").is_none() {
p["thumbnail"] = json!(THUMB);
}
let r = engine(app, "file.place.queue", &p)?;
app.place.thumbs = r["files"]
.as_array()
.into_iter()
.flatten()
.filter_map(|f| {
let png = vectorcraft_format::base64_decode(f["thumbnailBase64"].as_str()?)?;
let img = image::load_from_memory_with_format(&png, image::ImageFormat::Png).ok()?.to_rgba8();
let color = egui::ColorImage::from_rgba_unmultiplied([img.width() as usize, img.height() as usize], img.as_raw());
Some((f["name"].as_str()?.to_string(), Thumb::Decoded(color)))
})
.collect();
Ok(r)
}
/// The loaded place cursor at `pos`: the current file's thumbnail with the number of files loaded.
pub fn paint_cursor(app: &mut VectorcraftApp, ctx: &egui::Context, painter: &egui::Painter, pos: egui::Pos2) {
let opts = app.session.tool_options();
let count = opts["count"].as_u64().unwrap_or(0);
let name = opts["name"].as_str().unwrap_or_default();
let t = Tokens::get(ctx);
let tex = app.place.thumbs.iter_mut().find(|(n, _)| n == name).map(|(n, th)| {
if let Thumb::Decoded(img) = th {
*th = Thumb::Uploaded(ctx.load_texture(format!("place-thumb-{n}"), std::mem::take(img), egui::TextureOptions::LINEAR));
}
match th {
Thumb::Uploaded(tex) => Some((tex.id(), tex.size_vec2())),
Thumb::Decoded(_) => None,
}
});
let (id, size) = tex.flatten().unzip();
let size = size.unwrap_or(egui::vec2(THUMB as f32, THUMB as f32 * 0.75));
let k = THUMB as f32 / size.x.max(size.y).max(1.0);
let r = egui::Rect::from_min_size(pos + THUMB_OFFSET, size * k);
painter.rect_filled(r.expand(1.0), 0.0, t.panel);
match id {
Some(id) => {
painter.image(id, r, egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)), egui::Color32::from_white_alpha(200));
}
None => {
painter.text(r.center(), egui::Align2::CENTER_CENTER, name, theme::semibold(10.0), t.text_dim);
}
}
painter.rect_stroke(r.expand(1.0), 0.0, egui::Stroke::new(1.0, t.border), egui::StrokeKind::Outside);
if count > 1 {
// The badge: how many files are loaded.
let c = r.right_top() + egui::vec2(-2.0, 2.0);
painter.circle_filled(c, 9.0, t.accent);
painter.text(c, egui::Align2::CENTER_CENTER, count.to_string(), theme::semibold(10.5), egui::Color32::WHITE);
}
}
// ---------- drops ----------
impl VectorcraftApp {
/// Where files dropped with the pointer at `pos` (screen points; `None` when the platform
/// doesn't tell) go: onto the canvas of the open document (Shift embeds them), else opened. An
/// unknown position counts as the middle of the view.
pub fn drop_target(&self, pos: Option<egui::Pos2>, shift: bool) -> DropTarget {
let (Some(rect), Some(v)) = (self.canvas_rect.filter(|_| self.session.active().is_some()), self.view()) else { return DropTarget::Open };
match pos {
Some(p) if !rect.contains(p) => DropTarget::Open,
Some(p) => DropTarget::Place { at: crate::canvas::Xf::new(rect, v).to_doc(p), embed: shift },
None => DropTarget::Place { at: v.center, embed: shift },
}
}
}
/// Files dropped on the window (`(name, path, bytes)`): placed on the canvas or opened (and added
/// to Open Recent Files), as `target` says.
pub fn drop_files(app: &mut VectorcraftApp, files: Vec<(String, Option<String>, Vec<u8>)>, target: DropTarget) {
for (name, path, bytes) in files {
let r = match target {
DropTarget::Place { at, embed } => {
let mut p = match &path {
Some(path) => json!({ "path": path }),
None => json!({ "name": name, "dataBase64": vectorcraft_format::base64_encode(&bytes) }),
};
p["at"] = json!([at.x, at.y]);
p["link"] = json!(!embed);
run(app, &p).map(|_| ())
}
DropTarget::Open => io::open_bytes(app, &name, &bytes, path.clone()).map(|_| {
if let Some(path) = &path {
io::note_recent(app, path);
}
}),
};
if let Err(e) = r {
app.status(format!("Couldn't {} {name}: {e}", if target == DropTarget::Open { "open" } else { "place" }));
}
}
}
/// Files that arrived to be placed (web): drops go where they were dropped, picked files to the
/// Place dialog.
pub fn drain(app: &mut VectorcraftApp) {
let arrived: Vec<PlaceArrival> =
app.services.place_inbox.as_ref().map(|q| std::mem::take(&mut *q.lock().unwrap_or_else(|e| e.into_inner()))).unwrap_or_default();
let mut picked = vec![];
for a in arrived {
picked.push(json!({ "name": a.name }));
app.place.picked.retain(|(n, _)| *n != a.name);
app.place.picked.push((a.name, a.bytes));
match a.drop {
Some((at, embed)) => drop_files(app, vec![(a.name, None, a.bytes)], DropTarget::Place { at, embed }),
None => {
picked.push(json!({ "name": a.name }));
app.place.picked.retain(|(n, _)| *n != a.name);
app.place.picked.push((a.name, a.bytes));
}
}
}
if !picked.is_empty() {
open_dialog(app, picked);
}
}
/// Files dragged over the window: the canvas is outlined where they would be placed.
pub fn paint_drop_highlight(app: &VectorcraftApp, ctx: &egui::Context, painter: &egui::Painter, rect: egui::Rect) {
let hovering = ctx.input(|i| !i.raw.hovered_files.is_empty());
if !hovering || matches!(app.drop_target(ctx.input(|i| i.pointer.latest_pos()), false), DropTarget::Open) {
return;
}
let t = Tokens::get(ctx);
painter.rect_filled(rect, 0.0, t.accent.gamma_multiply(0.08));
painter.rect_stroke(rect.shrink(1.5), 0.0, egui::Stroke::new(3.0, t.accent), egui::StrokeKind::Inside);
}
// ---------- the Control bar ----------
/// The Control bar's details for the one selected image: Linked File or Embedded, its file name,

View File

@ -86,8 +86,13 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context) {
let busy = app.session.tool_busy();
for (k, tk) in [(Key::Enter, ToolKey::Enter), (Key::Escape, ToolKey::Escape)] {
if !typing && ctx.input(|i| i.key_pressed(k)) {
let _ = app.session.tool_key(tk, Mods::default(), view);
if k == Key::Escape && !busy {
// A key the tool claims (Esc with a loaded place cursor) is only the tool's.
let claimed = app.session.tool_claims_key(tk, view);
let r = app.session.tool_key(tk, Mods::default(), view);
if claimed {
crate::canvas::apply_requests(app, r);
}
if k == Key::Escape && !busy && !claimed {
if app.session.active().is_some_and(|d| d.doc.pattern_edit.is_some()) {
let _ = app.run("object.pattern.done", json!({}));
} else if app.session.active().is_some_and(|d| d.isolation.is_some()) {
@ -147,13 +152,19 @@ pub fn handle(app: &mut VectorcraftApp, ctx: &egui::Context) {
return;
}
// Keys the active tool claims ahead of their shortcuts (the Gradient tool's selected stop:
// Delete/Backspace remove it, ←/→ nudge it).
// Delete/Backspace remove it, ←/→ nudge it; the loaded place cursor: the arrows cycle files).
let m = ctx.input(|i| i.modifiers);
for (k, tk) in
[(Key::Delete, ToolKey::Delete), (Key::Backspace, ToolKey::Backspace), (Key::ArrowLeft, ToolKey::Left), (Key::ArrowRight, ToolKey::Right)]
{
for (k, tk) in [
(Key::Delete, ToolKey::Delete),
(Key::Backspace, ToolKey::Backspace),
(Key::ArrowLeft, ToolKey::Left),
(Key::ArrowRight, ToolKey::Right),
(Key::ArrowUp, ToolKey::Up),
(Key::ArrowDown, ToolKey::Down),
] {
if ctx.input(|i| i.key_pressed(k)) && app.session.tool_claims_key(tk, view) && ctx.input_mut(|i| i.consume_key(m, k)) {
let _ = app.session.tool_key(tk, crate::canvas::mods(m, false), view);
let r = app.session.tool_key(tk, crate::canvas::mods(m, false), view);
crate::canvas::apply_requests(app, r);
return;
}
}

View File

@ -1,10 +1,15 @@
//! File → Place in the app: the Place dialog and the Control bar's image details.
//! File → Place in the app: the Place dialog, files dropped on the window, the loaded place
//! cursor and the Control bar's image details.
use std::sync::Arc;
use egui::{Pos2, Shape, vec2};
use serde_json::{Value, json};
use vectorcraft_doc::NodeKind;
use vectorcraft_engine::Session;
use crate::VectorcraftApp;
use crate::canvas::Xf;
/// A `w`×`h` red PNG declaring `ppi`.
fn png(w: u32, h: u32, ppi: f64) -> Vec<u8> {
@ -37,6 +42,34 @@ fn place_picked(app: &mut VectorcraftApp, path: &str) {
app.run("file.place", json!({})).unwrap();
}
#[derive(Debug)]
struct Dropped(std::path::PathBuf);
impl egui::DroppedFile for Dropped {
fn path(&self) -> &std::path::Path {
&self.0
}
fn bytes(&self) -> Result<Vec<u8>, String> {
std::fs::read(&self.0).map_err(|e| e.to_string())
}
}
/// One headless frame of the whole window (800×600) with `events` and `dropped` files.
fn frame(app: &mut VectorcraftApp, ctx: &egui::Context, mut events: Vec<egui::Event>, dropped: &[&str], shift: bool) {
events.push(egui::Event::ModifiersChanged(egui::Modifiers { shift, ..Default::default() }));
let raw = egui::RawInput {
screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))),
events,
dropped_files: dropped.iter().map(|p| Arc::new(Dropped(p.into())) as egui::DroppedFileHandle).collect(),
..Default::default()
};
let mut out = ctx.run_ui(raw, |ui| {
app.logic(ui.ctx());
app.ui(ui);
});
out.textures_delta.clear();
}
fn selected_image(app: &VectorcraftApp) -> vectorcraft_doc::Node {
let st = app.session.active().unwrap();
let n = st.doc.node(st.selection.objects[0]).unwrap().clone();
@ -44,6 +77,84 @@ fn selected_image(app: &VectorcraftApp) -> vectorcraft_doc::Node {
n
}
#[test]
fn a_dropped_png_is_centred_at_the_pointer() {
let mut app = app();
let ctx = egui::Context::default();
// Two frames: fonts, then the canvas lays out.
frame(&mut app, &ctx, vec![], &[], false);
frame(&mut app, &ctx, vec![], &[], false);
let rect = app.canvas_rect.expect("the canvas is laid out");
let pos = rect.center() + vec2(60.0, -40.0);
let path = temp_file("drop.png", &png(20, 10, 72.0));
frame(&mut app, &ctx, vec![egui::Event::PointerMoved(pos)], &[&path], false);
let n = selected_image(&app);
let want = Xf::new(rect, app.view().unwrap()).to_doc(pos);
let c = n.geometric_bounds().unwrap().center();
assert!((c.x - want.x).abs() < 1e-6 && (c.y - want.y).abs() < 1e-6, "{c:?} vs {want:?}");
assert!(matches!(&n.kind, NodeKind::Image(im) if im.link.as_deref() == Some(path.as_str())), "linked by default");
assert_eq!(app.session.documents().len(), 1, "placed, not opened");
// Shift embeds.
frame(&mut app, &ctx, vec![egui::Event::PointerMoved(pos)], &[&path], true);
assert!(matches!(&selected_image(&app).kind, NodeKind::Image(im) if im.link.is_none()));
}
#[test]
fn files_dropped_off_the_canvas_or_with_no_document_open_and_are_recent() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
let ctx = egui::Context::default();
let path = temp_file("open-me.png", &png(8, 8, 72.0));
frame(&mut app, &ctx, vec![], &[], false);
frame(&mut app, &ctx, vec![egui::Event::PointerMoved(Pos2::new(400.0, 300.0))], &[&path], false);
assert_eq!(app.session.documents().len(), 1, "no document: the drop opens");
assert_eq!(app.ui.recent_files.first(), Some(&path));
// Over the tab bar (above the canvas): opened too.
frame(&mut app, &ctx, vec![], &[], false);
let top = app.canvas_rect.unwrap().top();
frame(&mut app, &ctx, vec![egui::Event::PointerMoved(Pos2::new(300.0, top - 10.0))], &[&path], false);
assert_eq!(app.session.documents().len(), 2);
}
#[test]
fn the_place_dialog_lists_the_files_and_loads_the_cursor_with_several() {
let mut app = app();
let a = temp_file("a.png", &png(300, 150, 300.0));
let b = temp_file("b.png", &png(10, 10, 72.0));
app.run("file.place", json!({"paths": [a, b]})).unwrap();
let d = app.ui.dialog.as_ref().expect("the Place dialog");
assert_eq!(d.kind, crate::dialogs::place::KIND);
assert!(d.bool("link") && !d.bool("__replace"), "Link on; Replace needs one file and one selected object");
assert_eq!(d.fields["__info"][0], "300 × 150 px, 300 ppi, RGB (72 pt × 36 pt)");
let text = crate::tests_labels::painted_text(&mut app, |app, ui| crate::dialogs::show(app, ui.ctx()));
for label in ["Place", "a.png", "b.png", "Link", "Template", "Replace", "Cancel"] {
assert!(text.contains(label), "{label} in {text}");
}
crate::dialogs::confirm(&mut app).unwrap();
assert!(app.ui.dialog.is_none());
assert_eq!(app.session.tool_id(), "place");
assert_eq!(app.session.tool_options()["count"], 2);
// The cursor carries the current file's thumbnail and the number of files.
let ctx = egui::Context::default();
crate::theme::install_fonts(&ctx);
let mut shapes = vec![];
for _ in 0..2 {
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| crate::place::paint_cursor(&mut app, ui.ctx(), ui.painter(), Pos2::new(50.0, 50.0)));
out.textures_delta.clear();
shapes = out.shapes;
}
let mut text = String::new();
let mut textured = false;
for s in shapes {
match s.shape {
Shape::Text(t) => text.push_str(t.galley.text()),
Shape::Mesh(m) => textured |= m.texture_id != egui::TextureId::default(),
_ => {}
}
}
assert!(textured, "the thumbnail");
assert_eq!(text, "2", "the badge");
}
#[test]
fn one_file_places_centred_in_the_view_and_replace_swaps_the_selection() {
let mut app = app();

View File

@ -22,7 +22,7 @@
| `ui.dialog.*` on `gradientStop` | `{field: "color" \| "opacity" \| "location", value}` | double-clicking a stop on the Gradient tool's annotator opens its popover (fields `index`, `x`, `y`, `tab`); set `color` (hex), `opacity` or `location` (percentages) and `ui.dialog.confirm` to apply them to the selected stop. `gradient.selectStop {index}` picks the stop the annotator, the panels and Delete/←/→ (`ui.key`) act on |
| `engine.execute` `ui.savePdfDialog` | `{path?, preset?, range?, …document.exportPdf options}` | opens the Save PDF dialog (`file.export.pdf` with no params opens it too). Its fields are the `document.exportPdf` options: set `compatibility` with `ui.dialog.set {field: "compatibility", value: "1.5"}`, a whole section with `{field: "compression", value: {…}}`, the artboards with `__allArtboards: false` and `range`, the visible section with `__section` (General, Compression, Marks and Bleeds, Output, Advanced, Security, Summary). `ui.dialog.confirm` writes `path` (else asks for one), returns `{path, bytes, warnings}` and opens the file when `viewAfterSaving` is set |
| SVG Options (`engine.execute` of `file.export.svg`, `file.saveAs`, `file.saveCopy`) | `{}` / `{path?, svg?: {…}}` | `file.export.svg` without params, and Save As or Save a Copy to an `.svg`/`.svgz` path without SVG options, open the `svgOptions` dialog (`ui.dialog.set` its fields: `styling`, `outlineText`, `images`, `objectIds`, `decimals`, `minify`, `responsive`, `fewerTspans`, `metadata`, `preserveEditing`, `useArtboards`, `allArtboards`, `range`, `showCode`; `ui.dialog.confirm` exports or saves and remembers the choices). With `svg: {…}` (the `document.formats` SVG options) they run directly. `app.save {path?, svg?}` takes the same options; a document saved as SVG saves as SVG again with the same options |
| `engine.execute` `file.place` | `{}` / `{path \| name+dataBase64, …file.place params}` | with no file, picks one and opens the `place` dialog (fields `files`, `link`, `template`, `replace`; `__replace` says whether Replace applies, `__info` describes the file); `ui.dialog.confirm` places it centred in the view, or in place of the selected object. With a file, it runs `file.place` centred in the view |
| `engine.execute` `file.place` | `{paths?}` / `{path \| name+dataBase64, …file.place params}` | with no file, picks files and opens the `place` dialog; with `paths`, opens it for them (fields `files`, `link`, `template`, `replace`; `__replace` says whether Replace applies, `__info` describes each file). `ui.dialog.confirm` places one file centred in the view (or in place of the selected object) and loads the place cursor with several (`file.place.queue`). With a file, it runs `file.place` centred in the view. Files dropped on the canvas are placed at the drop point (linked; Shift embeds), elsewhere or with no document open they open. With the cursor loaded (`ui.inspect` → `tool: "place"`, `toolOptions: {count, current, name, names}`), `ui.pointer` clicks and drags place files and `ui.key` ←/→/↑/↓ and Escape cycle and discard them |
Swatch editors: `engine.execute` with `ui.swatchOptions {name}` opens the `swatchOptions` dialog for a colour swatch
(fields `name`, `spot`, `global`, `mode`: `gray`/`rgb`/`hsb`/`lab`/`cmyk`/`web`, `color`: `"#rrggbb"` or a colour object,

View File

@ -815,9 +815,13 @@ unless `link: false`), an SVG as one group, a PDF/.ai page or a native document'
clipped group, with the images, symbols, patterns and swatches it uses. `at` centres it, `rect` fits it, `replace`
swaps the selected object (keeping its place and transform), `template` puts it on a new template layer.
`file.place.info` describes a file without placing it and `image.info` reports a placed image's link, colour mode and
effective ppi.
effective ppi. `file.place.queue` loads the place cursor (the `place` tool) with several files: headless, drive it
with `pointer_gesture` (a click places at 100% with the top-left corner there, a drag at the dragged size) and
`press_key` (Left/Right/Up/Down cycle, Escape discards the current file).
```json
{"name":"run_command","arguments":{"command":"file.place","params":{"path":"/tmp/photo.jpg","at":[300,200]}}}
{"name":"run_command","arguments":{"command":"file.place","params":{"name":"logo.svg","dataBase64":"PHN2Zy…","rect":[0,0,100,100]}}}
{"name":"run_command","arguments":{"command":"file.place.queue","params":{"paths":["/tmp/a.png","/tmp/b.pdf"]}}}
{"name":"pointer_gesture","arguments":{"events":[{"kind":"down","x":40,"y":40},{"kind":"up","x":40,"y":40}]}}
```