Merge pull request #7205 from dotCooCoo/fix/svg-interactivity-hide-script-paths

SVG Interactivity: replies name JavaScript files outside the read roots by file name
This commit is contained in:
BFlat
2026-11-14 02:40:06 +07:00
committed by GitHub
8 changed files with 273 additions and 22 deletions
+32 -11
View File
@@ -3,10 +3,14 @@
//! document links (`svgInteractivity.addFile`, `removeFile`). SVG export writes the events as
//! event attributes (`onclick="…"`) and the files as `<script xlink:href>` elements; SVG import
//! reads both back as text. The panel reads everything with `svgInteractivity.info`.
//!
//! While read roots are in force, a reply names a linked file outside them by its file name alone
//! ([`links::shown_script`]), and `removeFile` also takes that name for it.
use serde_json::{Value, json};
use vectorcraft_doc::{Document, MAX_SVG_SCRIPTS, NodeId, SVG_EVENTS};
use vectorcraft_doc::{Document, LinkInfo, MAX_SVG_SCRIPTS, NodeId, SVG_EVENTS};
use super::links::{self, LinkBase};
use super::overprint::edit_counted;
use super::{CommandSpec, Result, Session, bad, cmd, has_doc, str_param, targets};
@@ -17,7 +21,7 @@ pub fn specs() -> Vec<CommandSpec> {
"SVG Interactivity",
[],
None,
"{ids?|id?} → {ids, events: [{event, script}…] in the panel's order | null (the objects' events differ), files: [the linked JavaScript files…], eventNames: [the events the panel lists…]} the SVG Interactivity panel's values for ids or the selection",
"{ids?|id?} → {ids, events: [{event, script}…] in the panel's order | null (the objects' events differ), files: [the linked JavaScript files…; with automation read roots in force, a file outside them by its file name alone, a URL as it is], eventNames: [the events the panel lists…]} the SVG Interactivity panel's values for ids or the selection",
has_doc,
info
),
@@ -53,7 +57,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Add JavaScript File",
[],
None,
"{file: path or URL (written as given, without surrounding spaces)} link a JavaScript file to the document, after the others: SVG export writes a <script xlink:href> for each linked file. A file already linked stays where it is; a document links at most 64 files → {files}",
"{file: path or URL (written as given, without surrounding spaces)} link a JavaScript file to the document, after the others: SVG export writes a <script xlink:href> for each linked file. A file already linked (the same text) stays where it is; a document links at most 64 files → {files} (as svgInteractivity.info gives them)",
has_doc,
add_file
),
@@ -62,7 +66,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Remove JavaScript File",
[],
None,
"{file} unlink a JavaScript file from the document → {files}",
"{file} unlink a JavaScript file from the document: the file linked as `file` or, with automation read roots in force, a file outside them whose file name is `file`; an error, unlinking nothing, when `file` fits more than one → {files} (as svgInteractivity.info gives them)",
has_doc,
remove_file
),
@@ -111,7 +115,8 @@ fn edit_events(s: &mut Session, p: &Value, cmd: &str, label: &str, f: impl Fn(&m
fn info(s: &mut Session, p: &Value) -> Result<Value> {
let ids = targets(s, p)?;
let d = &s.doc()?.doc;
let st = s.doc()?;
let d = &st.doc;
let lists: Vec<Vec<(String, String)>> = ids.iter().map(|id| events_of(d, *id)).collect();
let events = lists
.first()
@@ -120,7 +125,7 @@ fn info(s: &mut Session, p: &Value) -> Result<Value> {
Ok(json!({
"ids": ids.iter().map(|id| id.0).collect::<Vec<_>>(),
"events": events,
"files": d.svg_scripts,
"files": shown_files(st),
"eventNames": SVG_EVENTS,
}))
}
@@ -157,8 +162,14 @@ fn file_param(p: &Value, cmd: &str) -> Result<String> {
f.map(str::to_string).ok_or_else(|| bad(cmd, "give `file`, the JavaScript file's path or URL"))
}
/// The JavaScript files `st` links, in order, as a reply to the caller asking now may name them
/// ([`links::shown_script`]).
fn shown_files(st: &crate::DocState) -> Vec<String> {
st.doc.svg_scripts.iter().map(|f| links::shown_script(st, f).into_owned()).collect()
}
fn files(s: &Session) -> Result<Value> {
Ok(json!({ "files": s.doc()?.doc.svg_scripts }))
Ok(json!({ "files": shown_files(s.doc()?) }))
}
fn add_file(s: &mut Session, p: &Value) -> Result<Value> {
@@ -180,11 +191,21 @@ fn add_file(s: &mut Session, p: &Value) -> Result<Value> {
fn remove_file(s: &mut Session, p: &Value) -> Result<Value> {
const C: &str = "svgInteractivity.removeFile";
let file = file_param(p, C)?;
if !s.doc()?.doc.svg_scripts.contains(&file) {
return Err(bad(C, format!("{file:?} isn't linked")));
}
let st = s.doc()?;
let linked = &st.doc.svg_scripts;
// A file a reply names by its file name alone is also unlinked by that name, unless the name
// also fits another linked file.
let doc_path = LinkBase::of(st).doc;
let named: Vec<&String> =
linked.iter().filter(|f| **f != file && LinkInfo::new(f.as_str()).name() == file && links::script_hidden(f, doc_path)).collect();
let stored = match (linked.contains(&file), named.as_slice()) {
(true, []) => file,
(false, [f]) => (*f).clone(),
(false, []) => return Err(bad(C, format!("{file:?} isn't linked"))),
_ => return Err(bad(C, format!("{file:?} names more than one linked JavaScript file"))),
};
s.edit("Remove JavaScript File", |d, _| {
d.svg_scripts.retain(|f| *f != file);
d.svg_scripts.retain(|f| *f != stored);
Ok(())
})?;
files(s)
+14 -2
View File
@@ -1173,6 +1173,19 @@ pub(crate) fn path_shown(path: &str, doc_path: Option<&str>) -> bool {
crate::file_access::current().is_none() || fileio::document_path(path, doc_path).is_ok_and(|p| crate::file_access::check_read(&p).is_ok())
}
/// Is the linked JavaScript file `f` (of the document saved at `doc_path`) named by its file name
/// alone in a reply to the caller asking now? Only a path [`path_shown`] does not allow; a URL is
/// always shown as it is.
pub(crate) fn script_hidden(f: &str, doc_path: Option<&str>) -> bool {
!path_shown(f, doc_path) && !matches!(fileio::document_path(f, doc_path), Err(craft_docpath::Refused::Url))
}
/// The linked JavaScript file `f` of the open document `st` as a reply to the caller asking now
/// may name it: as it is, or by its file name alone where [`script_hidden`] says so.
pub fn shown_script<'a>(st: &crate::DocState, f: &'a str) -> Cow<'a, str> {
if script_hidden(f, LinkBase::of(st).doc) { Cow::Owned(LinkInfo::new(f).name().to_string()) } else { Cow::Borrowed(f) }
}
/// `row[key] = path` when [`path_shown`] says so; otherwise `row` has no `key`.
pub(crate) fn note_path(row: &mut Value, key: &str, path: &str, doc_path: Option<&str>) {
if path_shown(path, doc_path) {
@@ -1556,8 +1569,7 @@ pub(crate) fn with_hidden_paths_named<'a>(d: &'a Document, base: LinkBase) -> Co
}
}
}
let script_hidden = |f: &str| !path_shown(f, doc_path) && !matches!(fileio::document_path(f, doc_path), Err(craft_docpath::Refused::Url));
for f in d.svg_scripts.iter_mut().filter(|f| script_hidden(f)) {
for f in d.svg_scripts.iter_mut().filter(|f| script_hidden(f, doc_path)) {
*f = LinkInfo::new(f.as_str()).name().to_string();
}
Cow::Owned(d)
+119
View File
@@ -3,6 +3,7 @@
use serde_json::{Value, json};
use crate::tests_links::{Folder, save, write};
use crate::*;
fn session() -> Session {
@@ -110,6 +111,124 @@ fn javascript_files_are_linked_once_and_unlinked() {
assert_eq!(files(&mut s), json!(["menu.js", "https://example.com/a.js"]));
}
/// `cmd` with `p` for a caller whose read roots are `read` and whose write roots are `write`.
fn confined(s: &mut Session, read: &[&Folder], write: &[&Folder], cmd: &str, p: Value) -> std::result::Result<Value, String> {
let read: Vec<&std::path::Path> = read.iter().map(|f| f.0.as_path()).collect();
let write: Vec<&std::path::Path> = write.iter().map(|f| f.0.as_path()).collect();
let roots = crate::file_access::AutomationRoots::new(&read, &write).unwrap();
crate::file_access::confine(roots.as_ref(), || s.execute(cmd, &p)).map_err(|e| e.to_string())
}
/// `info`, `addFile` and `removeFile` for a caller with read roots name a linked file outside them
/// by its file name, a relative one taken from the document's folder; a file inside them and a URL
/// are named as they are, and the document keeps every path.
#[test]
fn javascript_file_replies_hide_paths_outside_read_roots() {
let (away, home) = (Folder::new("js-replies-away"), Folder::new("js-replies-home"));
let (outside, inside) = (away.file("menu.js"), home.file("in.js"));
for f in [&outside, &inside, &away.file("lib/rel.js")] {
write(f, b"void 0;");
}
let url = "https://example.com/a.js";
let mut s = session();
for f in [outside.as_str(), inside.as_str(), url, "lib/rel.js"] {
run(&mut s, "svgInteractivity.addFile", json!({ "file": f }));
}
save(&mut s, &away.file("poster.vectorcraft"));
let stored = s.doc().unwrap().doc.svg_scripts.clone();
let shown = json!(["menu.js", inside, url, "rel.js"]);
let info = confined(&mut s, &[&home], &[], "svgInteractivity.info", json!({})).unwrap();
assert_eq!(info["files"], shown, "{info}");
let added = confined(&mut s, &[&home], &[], "svgInteractivity.addFile", json!({"file": "https://example.com/b.js"})).unwrap();
assert_eq!(added["files"], json!(["menu.js", inside, url, "rel.js", "https://example.com/b.js"]), "{added}");
let removed = confined(&mut s, &[&home], &[], "svgInteractivity.removeFile", json!({"file": "https://example.com/b.js"})).unwrap();
assert_eq!(removed["files"], shown, "{removed}");
assert!(!format!("{info}{added}{removed}").contains("js-replies-away"));
// A caller with only write roots reads none of the paths.
let info = confined(&mut s, &[], &[&home], "svgInteractivity.info", json!({})).unwrap();
assert_eq!(info["files"], json!(["menu.js", "in.js", url, "rel.js"]), "{info}");
// A caller whose read roots hold the files, and one without roots, sees every path.
assert_eq!(confined(&mut s, &[&away, &home], &[], "svgInteractivity.info", json!({})).unwrap()["files"], json!(stored));
assert_eq!(run(&mut s, "svgInteractivity.info", json!({}))["files"], json!(stored));
assert_eq!(s.doc().unwrap().doc.svg_scripts, stored, "the document keeps its paths");
}
/// A caller with read roots unlinks a file a reply names by its file name alone by that name, as
/// one undo step; the name is not taken for a file it may read, nor without read roots.
#[test]
fn hidden_javascript_file_can_be_removed_by_unique_shown_name() {
let (away, home) = (Folder::new("js-remove-away"), Folder::new("js-remove-home"));
let (outside, inside) = (away.file("menu.js"), home.file("other.js"));
let mut s = session();
for f in [&outside, &inside] {
run(&mut s, "svgInteractivity.addFile", json!({ "file": f }));
}
assert!(s.execute("svgInteractivity.removeFile", &json!({"file": "menu.js"})).is_err(), "no name without read roots");
let e = confined(&mut s, &[&home], &[], "svgInteractivity.removeFile", json!({"file": "other.js"})).unwrap_err();
assert!(e.contains("\"other.js\" isn't linked"), "a file inside the roots is unlinked by its path: {e}");
let steps = s.doc().unwrap().history.undo.len();
let r = confined(&mut s, &[&home], &[], "svgInteractivity.removeFile", json!({"file": "menu.js"})).unwrap();
assert_eq!(r["files"], json!([inside]), "{r}");
assert_eq!(s.doc().unwrap().doc.svg_scripts, [inside.as_str()]);
assert_eq!(s.doc().unwrap().history.undo.len(), steps + 1);
run(&mut s, "edit.undo", json!({}));
assert_eq!(s.doc().unwrap().doc.svg_scripts, [outside.as_str(), inside.as_str()]);
// A caller that knows the whole path unlinks the file by it.
let r = confined(&mut s, &[&home], &[], "svgInteractivity.removeFile", json!({ "file": outside })).unwrap();
assert_eq!(r["files"], json!([inside]), "{r}");
}
/// Two linked files outside the read roots with the same file name: that name unlinks neither,
/// and the error names no path.
#[test]
fn ambiguous_hidden_javascript_file_name_is_refused_without_edit() {
let (away, home) = (Folder::new("js-ambiguous-away"), Folder::new("js-ambiguous-home"));
let (a, b) = (away.file("a/menu.js"), away.file("b/menu.js"));
let mut s = session();
for f in [&a, &b] {
run(&mut s, "svgInteractivity.addFile", json!({ "file": f }));
}
let steps = s.doc().unwrap().history.undo.len();
let e = confined(&mut s, &[&home], &[], "svgInteractivity.removeFile", json!({"file": "menu.js"})).unwrap_err();
assert!(e.contains("\"menu.js\" names more than one linked JavaScript file") && !e.contains("js-ambiguous-away"), "{e}");
assert_eq!(s.doc().unwrap().doc.svg_scripts, [a.as_str(), b.as_str()]);
assert_eq!(s.doc().unwrap().history.undo.len(), steps, "no undo step");
let e = confined(&mut s, &[&home], &[], "svgInteractivity.removeFile", json!({"file": "nav.js"})).unwrap_err();
assert!(e.contains("\"nav.js\" isn't linked") && !e.contains("js-ambiguous-away"), "{e}");
// A file linked as the name itself, beside one outside the roots shown by that name: neither.
run(&mut s, "svgInteractivity.removeFile", json!({ "file": b }));
run(&mut s, "svgInteractivity.addFile", json!({"file": "menu.js"}));
let e = confined(&mut s, &[&home], &[], "svgInteractivity.removeFile", json!({"file": "menu.js"})).unwrap_err();
assert!(e.contains("names more than one"), "{e}");
assert_eq!(s.doc().unwrap().doc.svg_scripts, [a.as_str(), "menu.js"]);
// Without read roots the name is the file linked as it.
run(&mut s, "svgInteractivity.removeFile", json!({"file": "menu.js"}));
assert_eq!(s.doc().unwrap().doc.svg_scripts, [a.as_str()]);
}
/// `addFile` takes a file the same text links as already linked: a file name that a reply shows for
/// a different file is linked as one more file, within the 64-file limit.
#[test]
fn adding_a_shown_name_does_not_deduplicate_a_different_hidden_path() {
let (away, home) = (Folder::new("js-add-away"), Folder::new("js-add-home"));
let mut s = session();
let outside = away.file("0.js");
run(&mut s, "svgInteractivity.addFile", json!({ "file": outside }));
let r = confined(&mut s, &[&home], &[], "svgInteractivity.addFile", json!({"file": "0.js"})).unwrap();
assert_eq!(r["files"], json!(["0.js", "0.js"]), "{r}");
assert_eq!(s.doc().unwrap().doc.svg_scripts, [outside.as_str(), "0.js"]);
run(&mut s, "edit.undo", json!({}));
for i in 1..64 {
run(&mut s, "svgInteractivity.addFile", json!({ "file": away.file(&format!("{i}.js")) }));
}
let e = confined(&mut s, &[&home], &[], "svgInteractivity.addFile", json!({"file": "0.js"})).unwrap_err();
assert!(e.contains("at most 64"), "a 65th file is refused: {e}");
let steps = s.doc().unwrap().history.undo.len();
let r = confined(&mut s, &[&home], &[], "svgInteractivity.addFile", json!({ "file": outside })).unwrap();
assert_eq!(r["files"].as_array().map(Vec::len), Some(64), "the same path is already linked");
assert_eq!(s.doc().unwrap().history.undo.len(), steps);
}
#[test]
fn a_document_links_at_most_64_javascript_files() {
let mut s = session();
+13 -3
View File
@@ -251,7 +251,7 @@ pub fn inspect(app: &VectorcraftApp, ctx: &egui::Context) -> Value {
// the layout.
if let Some(fields) = ui.as_object_mut() {
fields.remove("floating_panels");
leave_out_unreadable_paths(fields);
leave_out_unreadable_paths(app, fields);
}
json!({
"scriptRunning": app.session.script_running(),
@@ -1091,8 +1091,10 @@ fn leave_out_unreadable_values(v: &mut Value) {
/// Takes out of `ui.inspect`'s copy of the UI state the paths the request may not read: recent files,
/// a library panel's file, folder preferences (saved, and in an open Preferences dialog) and the paths
/// in recorded actions' steps outside the read root. The UI state itself is not changed.
fn leave_out_unreadable_paths(ui: &mut serde_json::Map<String, Value>) {
/// in recorded actions' steps outside the read root. The file selected in an open JavaScript Files
/// dialog is named as `svgInteractivity.info` names the active document's linked files. The UI state
/// itself is not changed.
fn leave_out_unreadable_paths(app: &VectorcraftApp, ui: &mut serde_json::Map<String, Value>) {
if let Some(Value::Array(files)) = ui.get_mut("recent_files") {
files.retain(|f| f.as_str().is_some_and(readable));
}
@@ -1115,6 +1117,14 @@ fn leave_out_unreadable_paths(ui: &mut serde_json::Map<String, Value>) {
{
leave_out_unreadable_folders(fields);
}
if let Some(Value::Object(dialog)) = ui.get_mut("dialog")
&& dialog.get("kind").and_then(Value::as_str) == Some(crate::dialogs::javascript_files::KIND)
&& let Some(Value::Object(fields)) = dialog.get_mut("fields")
&& let Some(Value::String(selected)) = fields.get_mut("selected")
&& let Some(st) = app.session.active()
{
*selected = vectorcraft_engine::cmd::links::shown_script(st, selected).into_owned();
}
}
/// Takes out of `prefs` the folder preferences ([`PREF_FOLDERS`]) the request may not read, as written
+60 -5
View File
@@ -6,8 +6,12 @@
//! linked file. Add and Remove act at once. The dialog has only Close.
//!
//! Fields: `file` (the path or URL to link), `selected` (a linked file).
//!
//! In a frame automation drives with read roots in force, the list names a file outside them by
//! its file name alone, as `svgInteractivity.info` does; `selected` keeps the file as linked.
use serde_json::{Value, json};
use vectorcraft_engine::cmd::links::shown_script;
use super::DialogSpec;
use crate::picks::PickRequest;
@@ -63,18 +67,36 @@ enum Action {
Remove,
}
/// The active document's linked files, each as it is linked and as the list shows it
/// ([`shown_script`]).
fn listed(app: &VectorcraftApp) -> Vec<(String, String)> {
app.session.active().map_or_else(Vec::new, |st| st.doc.svg_scripts.iter().map(|f| (f.clone(), shown_script(st, f).into_owned())).collect())
}
/// The row of `files` ([`listed`]) that `selected` names: the one row linked or shown as it. A
/// click selects a file as it is linked; a request may also give it as the list shows it. A name
/// that fits several rows selects none, as `svgInteractivity.removeFile` refuses it.
fn selected_row(files: &[(String, String)], selected: &str) -> Option<usize> {
let mut rows = files.iter().enumerate().filter(|(_, (linked, shown))| linked == selected || shown == selected);
match (rows.next(), rows.next()) {
(Some((i, _)), None) => Some(i),
_ => None,
}
}
fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
let files: Vec<String> = app.session.active().map(|st| st.doc.svg_scripts.clone()).unwrap_or_default();
let files = listed(app);
let selected = d.str("selected");
let row = selected_row(&files, &selected);
let mut act = None;
widgets::list_box(ui, |ui| {
egui::ScrollArea::vertical().id_salt("javascript-files").min_scrolled_height(140.0).max_height(140.0).show(ui, |ui| {
ui.set_width(ui.available_width());
ui.set_min_height(140.0);
// Paths and URLs are data: shown as they are.
for f in &files {
if ui.selectable_label(*f == selected, f.as_str()).clicked() {
act = Some(Action::Select(f.clone()));
for (i, (linked, shown)) in files.iter().enumerate() {
if ui.selectable_label(row == Some(i), shown.as_str()).clicked() {
act = Some(Action::Select(linked.clone()));
}
}
});
@@ -94,7 +116,7 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {
if ui.add_enabled_ui(!d.str("file").trim().is_empty(), |ui| widgets::flat_button(ui, tl!("Add"), 64.0)).inner.clicked() {
act = Some(Action::Add);
}
if ui.add_enabled_ui(files.contains(&selected), |ui| widgets::flat_button(ui, tl!("Remove"), 64.0)).inner.clicked() {
if ui.add_enabled_ui(row.is_some(), |ui| widgets::flat_button(ui, tl!("Remove"), 64.0)).inner.clicked() {
act = Some(Action::Remove);
}
});
@@ -164,6 +186,39 @@ mod tests {
assert!(texts.iter().any(|t| t == "https://example.com/b.js"), "the field shows `file`: {texts:?}");
}
/// In a frame drawn with read roots in force, the list names a linked file outside them by its
/// file name alone, and that name unlinks it; the person sees the whole path.
#[test]
fn automation_javascript_files_dialog_hides_outside_root_paths() {
let base = std::env::temp_dir().join(format!("vc-js-dialog-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&base);
std::fs::create_dir_all(base.join("in")).unwrap();
let outside = base.join("outside").join("menu.js").to_string_lossy().into_owned();
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.session.execute("file.new", &json!({"width": 100, "height": 100})).unwrap();
app.session.execute("svgInteractivity.addFile", &json!({ "file": outside })).unwrap();
let mut d = Dialog::new(KIND, json!({ "file": "", "selected": "" }));
let texts = frame(&mut app, &mut d);
assert!(texts.contains(&outside), "{texts:?}");
let roots = vectorcraft_engine::file_access::AutomationRoots::new(&[&base.join("in")], &[]).unwrap();
let texts = vectorcraft_engine::file_access::confine(roots.as_ref(), || frame(&mut app, &mut d));
assert!(texts.iter().any(|t| t == "menu.js") && !texts.iter().any(|t| t.contains("vc-js-dialog")), "{texts:?}");
// A row keeps the file as linked, so a click selects it by its whole path, which stays
// selected in the person's list; a name a request gives selects it too.
let rows = vectorcraft_engine::file_access::confine(roots.as_ref(), || listed(&app));
assert_eq!(rows, [(outside.clone(), "menu.js".to_string())]);
assert_eq!((selected_row(&rows, &outside), selected_row(&rows, "menu.js")), (Some(0), Some(0)));
// A name two rows show selects neither.
let twins = [rows[0].clone(), ("/elsewhere/menu.js".to_string(), "menu.js".to_string())];
assert_eq!((selected_row(&twins, "menu.js"), selected_row(&twins, "/elsewhere/menu.js")), (None, Some(1)));
assert_eq!(listed(&app), [(outside.clone(), outside.clone())], "the person's");
// Remove sends the selected name.
let removed = vectorcraft_engine::file_access::confine(roots.as_ref(), || app.run("svgInteractivity.removeFile", json!({"file": "menu.js"})));
assert!(removed.is_ok(), "{removed:?}");
assert!(app.session.active().is_some_and(|st| st.doc.svg_scripts.is_empty()));
let _ = std::fs::remove_dir_all(base);
}
/// Choose… puts the picked file in File or URL as a `file:///` URL, with no `\` and with
/// spaces percent-encoded.
#[test]
+24
View File
@@ -545,6 +545,30 @@ fn ui_inspect_checks_a_folder_preference_as_written() {
let _ = std::fs::remove_dir_all(base);
}
/// `ui.inspect` names the file selected in JavaScript Files by its file name alone when it is
/// outside the read root, and keeps one inside it; the person sees the whole path.
#[test]
fn ui_inspect_hides_a_selected_javascript_file_outside_the_read_root() {
let (mut app, tx, base) = confined("inspect-javascript-files");
let (inside, outside) = (s(base.join("in/menu.js")), s(base.join("outside/menu.js")));
app.run("file.new", json!({"width": 100, "height": 100})).unwrap();
for f in [&inside, &outside] {
app.run("svgInteractivity.addFile", json!({ "file": f })).unwrap();
}
let kind = crate::dialogs::javascript_files::KIND;
app.ui.dialog = Some(Dialog::new(kind, json!({"file": "", "selected": outside})));
let selected = |v: &Value| v["ui"]["dialog"]["fields"]["selected"].clone();
assert_eq!(selected(&crate::control::inspect(&app, &egui::Context::default())), json!(outside), "the person's");
let r = request(&mut app, &tx, "ui.inspect", json!({}));
assert_eq!(selected(&r["result"]), "menu.js", "{r}");
assert!(!r.to_string().contains(&json!(outside).to_string().trim_matches('"').to_string()), "{r}");
app.ui.dialog = Some(Dialog::new(kind, json!({"file": "", "selected": inside})));
let r = request(&mut app, &tx, "ui.inspect", json!({}));
assert_eq!(selected(&r["result"]), json!(inside), "{r}");
assert_eq!(app.ui.dialog.as_ref().map(|d| d.str("selected")), Some(inside), "the dialog is not changed");
let _ = std::fs::remove_dir_all(base);
}
/// `ui.inspect` checks a folder preference as the app uses it, trimmed, as well: with the working
/// folder as the read root, a path outside it with a space before it reads as a name in the working
/// folder, and is still left out, because the app trims it and uses the path outside.
+10 -1
View File
@@ -230,6 +230,10 @@ the suite) keep the agent to the folders you give it:
value and a linked JavaScript file in `svg_scripts` outside them by its file name alone (a JavaScript file given
as a URL is kept), and leaves out the `folder` of `export_settings` (the settings Export
for Screens last used) when that folder is outside them. The document itself is not changed.
- `svgInteractivity.info`, `svgInteractivity.addFile` and `svgInteractivity.removeFile` name a linked JavaScript
file outside the read roots by its file name alone in `files`, as `document.json` does, and `removeFile` takes that
name for it (see SVG interactivity). In a frame a request drives, the JavaScript Files dialog lists such a file by
its file name, and `ui_inspect` gives the file selected in the dialog the same way.
- `plugin.list`, `plugin.safeMode`, `file.recovery.*` and `ui_inspect`'s `scriptReport` leave out the paths
outside the read roots, also those the person or an earlier request put there. In `plugin.list`, the plug-in
folder last loaded is `null` in `folder.folder`, and a failed file in `folder.failed` and a plug-in's `source`
@@ -4883,7 +4887,12 @@ files) and `eventNames` (the 19 events the panel lists, from `onfocusin` to `ons
event's script where they have one (an empty `script` removes it); `svgInteractivity.removeEvent {event, ids?}` and
`svgInteractivity.clearEvents {ids?}` remove one event or all of them. Each is one undo step.
`svgInteractivity.addFile {file}` and `svgInteractivity.removeFile {file}` link and unlink a JavaScript file (a path
or a URL, kept as given without surrounding spaces); a document links at most 64 files. `ui.javascriptFilesDialog {}`
or a URL, kept as given without surrounding spaces); a document links at most 64 files. `addFile` takes a file as
already linked only when the same text is linked. With automation read roots in force, `info`, `addFile` and
`removeFile` name a linked file outside them by its file name alone in `files` (a relative path is taken from the
document's folder, and a URL is kept), and `removeFile` also unlinks such a file by that name. When the name fits
more than one linked file (two such files, or such a file and a file linked as that name), `removeFile` returns an
error and unlinks nothing. `ui.javascriptFilesDialog {}`
opens the JavaScript Files dialog, whose Choose… puts a picked file in as a `file:///` URL. SVG export writes each event
as an event attribute on the object's element (`onclick="…"`; with the SVG Tiny 1.2 profile it leaves them out with a
warning) and each file as `<script xlink:href>`. SVG import reads the event attributes the panel lists and the
+1
View File
@@ -834,6 +834,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
- Medium Dark theme, categorized and Advanced toolbars (a tool group's flyout opens on a right press, a long press or a press on its corner triangle), 35 pt document tabs, 33 pt panel tabs.
- Hint bar, contextual task bar, 19 dock panels with ≡ menus.
- Window › SVG Interactivity attaches JavaScript to the selected objects' events (`onclick`, `onmouseover` and 17 more) and links JavaScript files to the document (Link JavaScript Files…); SVG export writes the events as event attributes and the files as `<script xlink:href>`, SVG import reads both back as text without running them, and the native format keeps them (`svgInteractivity.*`).
- With automation read roots in force, `svgInteractivity.info`, `addFile` and `removeFile`, the JavaScript Files dialog in a frame a request drives and `ui_inspect`'s selected file name a linked JavaScript file outside the roots by its file name alone, as `document.json` does; `removeFile` unlinks it by that name and refuses a name that fits more than one linked file (`tests/interactivity.rs`).
- Numeric fields keep the pointer's pressed character as the text-drag start when movement exceeds the click threshold (#1326); a completed focus click selects the unit too. Pixel/point, plain and optional fields are regression-tested in both directions, before/after focus, with replacement text; keyboard focus, double-click, arrows and wheel keep their prior behavior. Native egui input confirms partial replacement gives 17 px instead of 700 px, one Undo/Redo, window captures and an exact saved-document read in a separate CLI process. A focus click released more than 1 pt from its press keeps the caret, and a tap within 1 pt still selects all (#6659; short-drag and jitter-tap regressions in `tests/numfields.rs`).
- Unit fields and length strings take `%` in arithmetic: `3cm*50%` reads as 1.5 cm, and `50pt+25%` reads as 62.5 pt.
- The contextual task bar's handle drags it anywhere on the canvas (kept whole on it as the window resizes); unpinned it keeps that offset as it follows the selection, and its More Options menu hides it, pins its position across selection changes (`window.taskBar.pin`), resets it under the selection (`window.taskBar.reset`) and shows the Properties panel. As in Illustrator the position isn't kept across launches. Not done: the bar's variants for other tools (only the selection tools, the Type tools and the Artboard tool show it).