mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-24 17:56:50 -06:00
Window: the SVG Interactivity panel attaches JavaScript events and files for SVG export
Window › SVG Interactivity was a disabled menu item. VectorCraft could not attach JavaScript to an object's events or link JavaScript files for SVG export. The SVG Interactivity panel lists 19 events, from onfocusin to onscroll. Pick one, type its JavaScript and press Enter to add it to the selected objects; the list shows their events. Remove Selected Entry removes the picked event, and the panel menu's Clear Events removes all of them. Link JavaScript Files… opens the JavaScript Files dialog, which adds a path or URL to the document's list and removes the selected one. Its Choose… button puts a picked file in as a file:/// URL. The commands are svgInteractivity.info, setEvent, removeEvent, clearEvents, addFile and removeFile, and ui.javascriptFilesDialog. Each edit is one undo step. A document links at most 64 files. SVG export writes each event as an event attribute on the object's element and each file as a <script xlink:href> element. With the SVG Tiny 1.2 profile, export leaves the events out with a warning. SVG import reads the listed event attributes and the script links back as text. It never reads a linked file or runs a script, and it leaves inline scripts and the links after the first 64 out with a warning. The native format keeps both. An unnamed <g> with data-* attributes of its own around a single object now imports as a group. Before, import folded it into the object, which then took the group's data-* or, when it had its own, dropped the group's. An unnamed <g> with event attributes stays a group in the same way. The SVG import fuzz tests now include event attributes and <script> elements. Docs: docs/mcp.md, docs/control-protocol.md, docs/target-app-parity.md, docs/ui-parity.md.
This commit is contained in:
@@ -100,7 +100,7 @@ pub use metadata::{CopyrightStatus, DocMetadata};
|
||||
pub use node::Knockout;
|
||||
pub use node::Scaling;
|
||||
pub use node::{Extras, ExtrasMut, ImageObject, LAYER_COLORS, LayerColor, LiveShape, Node, NodeExtras, NodeId, NodeKind, OpacityMask};
|
||||
pub use node::{ImageMap, ObjectAttributes};
|
||||
pub use node::{ImageMap, MAX_SVG_SCRIPTS, ObjectAttributes, SVG_EVENTS};
|
||||
pub use orient::OrientedBox;
|
||||
pub use pattern::{Overlap, PatternDef, PatternEdit, RepeatKind, RepeatSpec, TileType};
|
||||
pub use perspective::PerspectiveAttachment;
|
||||
@@ -731,6 +731,10 @@ pub struct Document {
|
||||
/// export settings are Export for Screens' ([`Document::export_settings`]).
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub assets: Vec<ExportAsset>,
|
||||
/// Window › SVG Interactivity › Link JavaScript Files: the JavaScript files (paths or URLs, as
|
||||
/// given) SVG export links with `<script>` elements, in order.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub svg_scripts: Vec<String>,
|
||||
/// Puppet Warp pins on the selected artwork while the tool edits it ([`PuppetPins`]): editing
|
||||
/// state, never saved.
|
||||
#[serde(skip)]
|
||||
@@ -826,6 +830,7 @@ impl Document {
|
||||
slices_clip_to_artboard: true,
|
||||
print_setup: None,
|
||||
assets: vec![],
|
||||
svg_scripts: vec![],
|
||||
puppet: None,
|
||||
dimensions: false,
|
||||
index: Default::default(),
|
||||
|
||||
@@ -1706,8 +1706,41 @@ pub struct ObjectAttributes {
|
||||
/// `("pivot", "100,180")`), kept from import to export (`object.setProps {data}`).
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub data: Vec<(String, String)>,
|
||||
/// Window › SVG Interactivity: the JavaScript each event runs, in the panel's order
|
||||
/// (`("onclick", "alert(1)")`, one entry per event of [`SVG_EVENTS`]). SVG export writes them as
|
||||
/// the element's event attributes; nothing in VectorCraft runs them.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub events: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
/// The events the SVG Interactivity panel attaches JavaScript to, by the names of the SVG event
|
||||
/// attributes that run it.
|
||||
pub const SVG_EVENTS: [&str; 19] = [
|
||||
"onfocusin",
|
||||
"onfocusout",
|
||||
"onactivate",
|
||||
"onmousedown",
|
||||
"onmouseup",
|
||||
"onclick",
|
||||
"onmouseover",
|
||||
"onmousemove",
|
||||
"onmouseout",
|
||||
"onkeydown",
|
||||
"onkeypress",
|
||||
"onkeyup",
|
||||
"onload",
|
||||
"onerror",
|
||||
"onabort",
|
||||
"onunload",
|
||||
"onzoom",
|
||||
"onresize",
|
||||
"onscroll",
|
||||
];
|
||||
|
||||
/// The most JavaScript files a document links (`Document::svg_scripts`): SVG import keeps the
|
||||
/// first ones and warns about the rest, and `svgInteractivity.addFile` refuses another.
|
||||
pub const MAX_SVG_SCRIPTS: usize = 64;
|
||||
|
||||
/// The Attributes panel's Image Map shapes.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
//! Window → SVG Interactivity: the JavaScript an object runs on each event in web output
|
||||
//! (`svgInteractivity.setEvent`, `removeEvent`, `clearEvents`) and the JavaScript files the
|
||||
//! 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`.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::{Document, MAX_SVG_SCRIPTS, NodeId, SVG_EVENTS};
|
||||
|
||||
use super::overprint::edit_counted;
|
||||
use super::*;
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(
|
||||
query "svgInteractivity.info",
|
||||
"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",
|
||||
has_doc,
|
||||
info
|
||||
),
|
||||
cmd!(
|
||||
"svgInteractivity.setEvent",
|
||||
"Add Event",
|
||||
[],
|
||||
None,
|
||||
"{event: \"onclick\"|\"onmouseover\"|… (svgInteractivity.info's eventNames), script: string (the JavaScript; \"\" removes the event), ids?|id?} set the JavaScript an event runs on ids or the selection, replacing the event's script where it has one, as one undo step; SVG export writes it as the event attribute → {changed: objects}",
|
||||
has_doc,
|
||||
set_event
|
||||
),
|
||||
cmd!(
|
||||
"svgInteractivity.removeEvent",
|
||||
"Remove Selected Entry",
|
||||
[],
|
||||
None,
|
||||
"{event, ids?|id?} remove an event and its JavaScript from ids or the selection, as one undo step → {changed: objects}",
|
||||
has_doc,
|
||||
remove_event
|
||||
),
|
||||
cmd!(
|
||||
"svgInteractivity.clearEvents",
|
||||
"Clear Events",
|
||||
[],
|
||||
None,
|
||||
"{ids?|id?} remove every event from ids or the selection, as one undo step → {changed: objects}",
|
||||
has_doc,
|
||||
clear_events
|
||||
),
|
||||
cmd!(
|
||||
"svgInteractivity.addFile",
|
||||
"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}",
|
||||
has_doc,
|
||||
add_file
|
||||
),
|
||||
cmd!(
|
||||
"svgInteractivity.removeFile",
|
||||
"Remove JavaScript File",
|
||||
[],
|
||||
None,
|
||||
"{file} unlink a JavaScript file from the document → {files}",
|
||||
has_doc,
|
||||
remove_file
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
/// `p`'s `event`, one of [`SVG_EVENTS`] (any case).
|
||||
fn event_param(p: &Value, cmd: &str) -> Result<&'static str> {
|
||||
let e = str_param(p, "event").ok_or_else(|| bad(cmd, "give `event`, such as onclick"))?;
|
||||
SVG_EVENTS
|
||||
.iter()
|
||||
.find(|n| n.eq_ignore_ascii_case(e.trim()))
|
||||
.copied()
|
||||
.ok_or_else(|| bad(cmd, format!("`event` must be one of {}", SVG_EVENTS.join(", "))))
|
||||
}
|
||||
|
||||
/// The events of object `id` in `d` (none for a missing object).
|
||||
fn events_of(d: &Document, id: NodeId) -> Vec<(String, String)> {
|
||||
d.node(id).and_then(|n| n.extras().attrs.as_deref()).map(|a| a.events.clone()).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Change the events of `ids` (or the selection) with `f`, as one undo step named `label`; an
|
||||
/// object whose events `f` leaves as they were isn't changed → `{changed: objects}`.
|
||||
fn edit_events(s: &mut Session, p: &Value, cmd: &str, label: &str, f: impl Fn(&mut Vec<(String, String)>)) -> Result<Value> {
|
||||
let ids = targets(s, p)?;
|
||||
if ids.is_empty() {
|
||||
return Err(bad(cmd, "select the objects (or give their ids)"));
|
||||
}
|
||||
let changed = edit_counted(s, label, |d| {
|
||||
let mut changed = 0;
|
||||
for id in &ids {
|
||||
let mut events = events_of(d, *id);
|
||||
let before = events.clone();
|
||||
f(&mut events);
|
||||
if events != before
|
||||
&& let Some(n) = d.node_mut(*id)
|
||||
{
|
||||
n.edit_attrs(|a| a.events = events);
|
||||
changed += 1;
|
||||
}
|
||||
}
|
||||
Ok(changed)
|
||||
})?;
|
||||
Ok(json!({ "changed": changed }))
|
||||
}
|
||||
|
||||
fn info(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let ids = targets(s, p)?;
|
||||
let d = &s.doc()?.doc;
|
||||
let lists: Vec<Vec<(String, String)>> = ids.iter().map(|id| events_of(d, *id)).collect();
|
||||
let events = lists
|
||||
.first()
|
||||
.filter(|first| lists.iter().all(|l| l == *first))
|
||||
.map(|l| l.iter().map(|(event, script)| json!({ "event": event, "script": script })).collect::<Vec<_>>());
|
||||
Ok(json!({
|
||||
"ids": ids.iter().map(|id| id.0).collect::<Vec<_>>(),
|
||||
"events": events,
|
||||
"files": d.svg_scripts,
|
||||
"eventNames": SVG_EVENTS,
|
||||
}))
|
||||
}
|
||||
|
||||
fn set_event(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "svgInteractivity.setEvent";
|
||||
let event = event_param(p, C)?;
|
||||
let script = p.get("script").map(|v| v.as_str().ok_or_else(|| bad(C, "`script` must be a string"))).transpose()?;
|
||||
let script = script.ok_or_else(|| bad(C, "give `script`, the JavaScript the event runs (\"\" removes the event)"))?.trim().to_string();
|
||||
edit_events(s, p, C, "Add Event", |events| {
|
||||
if script.is_empty() {
|
||||
events.retain(|(e, _)| e != event);
|
||||
} else if let Some((_, js)) = events.iter_mut().find(|(e, _)| e == event) {
|
||||
js.clone_from(&script);
|
||||
} else {
|
||||
events.push((event.to_string(), script.clone()));
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn remove_event(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "svgInteractivity.removeEvent";
|
||||
let event = event_param(p, C)?;
|
||||
edit_events(s, p, C, "Remove Selected Entry", |events| events.retain(|(e, _)| e != event))
|
||||
}
|
||||
|
||||
fn clear_events(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
edit_events(s, p, "svgInteractivity.clearEvents", "Clear Events", Vec::clear)
|
||||
}
|
||||
|
||||
/// `p`'s `file`, trimmed and not empty.
|
||||
fn file_param(p: &Value, cmd: &str) -> Result<String> {
|
||||
let f = str_param(p, "file").map(str::trim).filter(|f| !f.is_empty());
|
||||
f.map(str::to_string).ok_or_else(|| bad(cmd, "give `file`, the JavaScript file's path or URL"))
|
||||
}
|
||||
|
||||
fn files(s: &Session) -> Result<Value> {
|
||||
Ok(json!({ "files": s.doc()?.doc.svg_scripts }))
|
||||
}
|
||||
|
||||
fn add_file(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
const C: &str = "svgInteractivity.addFile";
|
||||
let file = file_param(p, C)?;
|
||||
let linked = &s.doc()?.doc.svg_scripts;
|
||||
if !linked.contains(&file) {
|
||||
if linked.len() >= MAX_SVG_SCRIPTS {
|
||||
return Err(bad(C, format!("a document links at most {MAX_SVG_SCRIPTS} JavaScript files")));
|
||||
}
|
||||
s.edit("Add JavaScript File", |d, _| {
|
||||
d.svg_scripts.push(file);
|
||||
Ok(())
|
||||
})?;
|
||||
}
|
||||
files(s)
|
||||
}
|
||||
|
||||
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")));
|
||||
}
|
||||
s.edit("Remove JavaScript File", |d, _| {
|
||||
d.svg_scripts.retain(|f| *f != file);
|
||||
Ok(())
|
||||
})?;
|
||||
files(s)
|
||||
}
|
||||
@@ -34,6 +34,7 @@ mod halftone;
|
||||
pub mod help;
|
||||
pub mod importmatches;
|
||||
pub(crate) mod inline;
|
||||
mod interactivity;
|
||||
mod intertwine;
|
||||
mod layer;
|
||||
mod layerpanel;
|
||||
@@ -263,6 +264,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
|
||||
v.extend(library::specs());
|
||||
v.extend(expand::specs());
|
||||
v.extend(attributes::specs());
|
||||
v.extend(interactivity::specs());
|
||||
v.extend(newart::specs());
|
||||
v.extend(colortheme::specs());
|
||||
v.extend(fileio::pdf::specs());
|
||||
|
||||
@@ -1997,6 +1997,8 @@ mod tests_ids_without_selection;
|
||||
#[cfg(test)]
|
||||
mod tests_inline;
|
||||
#[cfg(test)]
|
||||
mod tests_interactivity;
|
||||
#[cfg(test)]
|
||||
mod tests_intertwine;
|
||||
#[cfg(test)]
|
||||
mod tests_journal;
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
//! Window → SVG Interactivity (`svgInteractivity.*`): the events objects run JavaScript on and the
|
||||
//! JavaScript files the document links, with what SVG export and the native format keep of them.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::*;
|
||||
|
||||
fn session() -> Session {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn run(s: &mut Session, id: &str, p: Value) -> Value {
|
||||
s.execute(id, &p).unwrap()
|
||||
}
|
||||
|
||||
fn rect(s: &mut Session, x: f64) -> NodeId {
|
||||
NodeId(run(s, "shape.rectangle", json!({"x": x, "y": 10, "width": 30, "height": 30}))["id"].as_u64().unwrap())
|
||||
}
|
||||
|
||||
fn events(s: &Session, id: NodeId) -> Vec<(String, String)> {
|
||||
s.doc().unwrap().doc.node(id).unwrap().extras().attrs.as_deref().map(|a| a.events.clone()).unwrap_or_default()
|
||||
}
|
||||
|
||||
fn pair(e: &str, js: &str) -> (String, String) {
|
||||
(e.to_string(), js.to_string())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn events_are_added_replaced_removed_and_cleared_in_one_undo_step_each() {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 10.0);
|
||||
assert_eq!(run(&mut s, "svgInteractivity.info", json!({}))["events"], json!([]));
|
||||
assert_eq!(run(&mut s, "svgInteractivity.setEvent", json!({"event": "onclick", "script": " alert(1) "}))["changed"], 1);
|
||||
run(&mut s, "svgInteractivity.setEvent", json!({"event": "OnMouseOver", "script": "hover()"}));
|
||||
assert_eq!(events(&s, a), [pair("onclick", "alert(1)"), pair("onmouseover", "hover()")], "in order, names in lower case, scripts trimmed");
|
||||
// Setting an event again replaces its script where it is.
|
||||
run(&mut s, "svgInteractivity.setEvent", json!({"event": "onclick", "script": "go()"}));
|
||||
assert_eq!(events(&s, a), [pair("onclick", "go()"), pair("onmouseover", "hover()")]);
|
||||
let i = run(&mut s, "svgInteractivity.info", json!({}));
|
||||
assert_eq!(i["events"], json!([{"event": "onclick", "script": "go()"}, {"event": "onmouseover", "script": "hover()"}]));
|
||||
assert_eq!(i["eventNames"].as_array().unwrap().len(), 19);
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(events(&s, a)[0], pair("onclick", "alert(1)"), "one step");
|
||||
run(&mut s, "edit.redo", json!({}));
|
||||
// Remove Selected Entry, and an empty script, remove one event.
|
||||
run(&mut s, "svgInteractivity.removeEvent", json!({"event": "onclick"}));
|
||||
assert_eq!(events(&s, a), [pair("onmouseover", "hover()")]);
|
||||
run(&mut s, "svgInteractivity.setEvent", json!({"event": "onmouseover", "script": ""}));
|
||||
assert!(s.doc().unwrap().doc.node(a).unwrap().extras().attrs.is_none(), "no events left: no attributes");
|
||||
// Clear Events removes all of them.
|
||||
run(&mut s, "svgInteractivity.setEvent", json!({"event": "onload", "script": "init()"}));
|
||||
run(&mut s, "svgInteractivity.setEvent", json!({"event": "onkeyup", "script": "key()"}));
|
||||
assert_eq!(run(&mut s, "svgInteractivity.clearEvents", json!({}))["changed"], 1);
|
||||
assert!(events(&s, a).is_empty());
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(events(&s, a).len(), 2, "one step");
|
||||
// Nothing to change: no undo step.
|
||||
let steps = s.doc().unwrap().history.undo.len();
|
||||
assert_eq!(run(&mut s, "svgInteractivity.removeEvent", json!({"event": "onclick"}))["changed"], 0);
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), steps);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn info_shows_no_events_where_the_objects_differ() {
|
||||
let mut s = session();
|
||||
let (a, b) = (rect(&mut s, 10.0), rect(&mut s, 50.0));
|
||||
run(&mut s, "svgInteractivity.setEvent", json!({"ids": [a.0], "event": "onclick", "script": "a()"}));
|
||||
assert!(events(&s, b).is_empty());
|
||||
run(&mut s, "select.set", json!({"ids": [a.0, b.0]}));
|
||||
assert_eq!(run(&mut s, "svgInteractivity.info", json!({}))["events"], Value::Null);
|
||||
// Given to both, the event goes on both.
|
||||
assert_eq!(run(&mut s, "svgInteractivity.setEvent", json!({"event": "onclick", "script": "both()"}))["changed"], 2);
|
||||
assert_eq!(run(&mut s, "svgInteractivity.info", json!({}))["events"], json!([{"event": "onclick", "script": "both()"}]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bad_events_and_scripts_are_refused() {
|
||||
let mut s = session();
|
||||
rect(&mut s, 10.0);
|
||||
for p in [
|
||||
json!({"script": "x()"}),
|
||||
json!({"event": "onfoo", "script": "x()"}),
|
||||
json!({"event": "onclick"}),
|
||||
json!({"event": "onclick", "script": 3}),
|
||||
json!({"event": "onclick", "script": "x()", "ids": "1"}),
|
||||
] {
|
||||
assert!(s.execute("svgInteractivity.setEvent", &p).is_err(), "{p}");
|
||||
}
|
||||
run(&mut s, "select.none", json!({}));
|
||||
assert!(s.execute("svgInteractivity.setEvent", &json!({"event": "onclick", "script": "x()"})).is_err(), "nothing selected");
|
||||
assert!(s.execute("svgInteractivity.clearEvents", &json!({})).is_err(), "nothing selected");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn javascript_files_are_linked_once_and_unlinked() {
|
||||
let mut s = session();
|
||||
let files = |s: &mut Session| run(s, "svgInteractivity.info", json!({}))["files"].clone();
|
||||
assert_eq!(run(&mut s, "svgInteractivity.addFile", json!({"file": " menu.js "}))["files"], json!(["menu.js"]));
|
||||
run(&mut s, "svgInteractivity.addFile", json!({"file": "https://example.com/a.js"}));
|
||||
let steps = s.doc().unwrap().history.undo.len();
|
||||
run(&mut s, "svgInteractivity.addFile", json!({"file": "menu.js"}));
|
||||
assert_eq!(files(&mut s), json!(["menu.js", "https://example.com/a.js"]), "a file already linked stays where it is");
|
||||
assert_eq!(s.doc().unwrap().history.undo.len(), steps, "and makes no undo step");
|
||||
assert!(s.execute("svgInteractivity.addFile", &json!({"file": " "})).is_err());
|
||||
assert!(s.execute("svgInteractivity.removeFile", &json!({"file": "other.js"})).is_err());
|
||||
assert_eq!(run(&mut s, "svgInteractivity.removeFile", json!({"file": "menu.js"}))["files"], json!(["https://example.com/a.js"]));
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert_eq!(files(&mut s), json!(["menu.js", "https://example.com/a.js"]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_document_links_at_most_64_javascript_files() {
|
||||
let mut s = session();
|
||||
for i in 0..64 {
|
||||
run(&mut s, "svgInteractivity.addFile", json!({"file": format!("{i}.js")}));
|
||||
}
|
||||
assert!(s.execute("svgInteractivity.addFile", &json!({"file": "more.js"})).is_err(), "a 65th file is refused");
|
||||
assert_eq!(run(&mut s, "svgInteractivity.addFile", json!({"file": "0.js"}))["files"].as_array().unwrap().len(), 64, "a linked one isn't");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn events_and_files_survive_a_native_round_trip_and_reach_svg() {
|
||||
let mut s = session();
|
||||
let a = rect(&mut s, 10.0);
|
||||
run(&mut s, "svgInteractivity.setEvent", json!({"event": "onclick", "script": "show(\"menu\") && x < 1"}));
|
||||
run(&mut s, "svgInteractivity.addFile", json!({"file": "menu.js"}));
|
||||
let doc = s.doc().unwrap().doc.clone();
|
||||
let back = vectorcraft_format::load(&vectorcraft_format::save(&doc, false)).unwrap();
|
||||
assert_eq!(back.node(a).unwrap().extras().attrs, doc.node(a).unwrap().extras().attrs);
|
||||
assert_eq!(back.svg_scripts, ["menu.js"]);
|
||||
let svg = run(&mut s, "document.serialize", json!({"format": "svg"}))["text"].as_str().unwrap().to_string();
|
||||
assert!(svg.contains(" onclick=\"show("menu") && x < 1\""), "{svg}");
|
||||
assert!(svg.contains("<script xlink:href=\"menu.js\"/>"), "{svg}");
|
||||
}
|
||||
@@ -389,6 +389,9 @@ fn arb_svg_attr() -> impl Strategy<Value = String> {
|
||||
"preserveAspectRatio",
|
||||
"overflow",
|
||||
"data-vc-blend",
|
||||
// SVG Interactivity: event attributes, kept as text.
|
||||
"onclick",
|
||||
"onload",
|
||||
]);
|
||||
let value = prop_oneof![
|
||||
arb_num(),
|
||||
@@ -460,6 +463,7 @@ fn arb_svg_element(depth: u32) -> BoxedStrategy<String> {
|
||||
"feDropShadow",
|
||||
"feComponentTransfer",
|
||||
"feFuncA",
|
||||
"script",
|
||||
]);
|
||||
let attrs = || prop::collection::vec(arb_svg_attr(), 0..6).prop_map(|v| v.concat());
|
||||
let leaf = (tag.clone(), attrs(), "[a-z ]{0,6}").prop_map(|(t, a, txt)| format!("<{t} id=\"e\"{a} href=\"#e\">{txt}</{t}>"));
|
||||
|
||||
@@ -6,7 +6,7 @@ use std::sync::Arc;
|
||||
|
||||
use vectorcraft_color::{BlendMode, GradientKind, GradientPaint, Paint};
|
||||
use vectorcraft_doc::{
|
||||
AppearanceItem, Document, FillLayer, LineCap, LineJoin, Node, NodeId, NodeKind, StrokeAlign, StrokeLayer, TextKind, TextObject,
|
||||
AppearanceItem, Document, FillLayer, LineCap, LineJoin, Node, NodeId, NodeKind, SVG_EVENTS, StrokeAlign, StrokeLayer, TextKind, TextObject,
|
||||
};
|
||||
use vectorcraft_doc::{CharStyle, ImageBlob, Justify};
|
||||
use vectorcraft_effects::stroke::{self, Written, WrittenShape};
|
||||
@@ -142,6 +142,15 @@ fn write(doc: &Document, opts: &ExportOptions, native: Option<&[u8]>, id_prefix:
|
||||
out.push_str(&w.indent(1));
|
||||
out.push_str(&format!("<desc>{}</desc>{nl}", xml_escape(&doc.metadata.description)));
|
||||
}
|
||||
// Window › SVG Interactivity › Link JavaScript Files.
|
||||
for file in &doc.svg_scripts {
|
||||
if file.is_empty() || !valid_xml_value(file) {
|
||||
w.warn("JavaScript files with empty names or invalid XML characters were left out of SVG");
|
||||
continue;
|
||||
}
|
||||
out.push_str(&w.indent(1));
|
||||
out.push_str(&format!("<script xlink:href=\"{}\"/>{nl}", xml_attr_escape(file)));
|
||||
}
|
||||
let latin1 = encoding == Encoding::Latin1;
|
||||
if latin1 {
|
||||
latin1_refs(&mut out);
|
||||
@@ -637,8 +646,34 @@ impl Writer<'_> {
|
||||
self.non_latin1_names |= k.chars().any(|c| u32::from(c) > 0xff);
|
||||
out.push_str(&format!(" data-{k}=\"{}\"", xml_attr_escape(v)));
|
||||
}
|
||||
self.event_attrs(n, &mut out);
|
||||
out
|
||||
}
|
||||
/// Window › SVG Interactivity: `n`'s events as event attributes (`onclick="…"`), added to
|
||||
/// `out`. The SVG Tiny 1.2 profile leaves them out with a warning.
|
||||
fn event_attrs(&mut self, n: &Node, out: &mut String) {
|
||||
let events = n.extras().attrs.as_deref().map_or(&[][..], |a| a.events.as_slice());
|
||||
if events.is_empty() {
|
||||
return;
|
||||
}
|
||||
if self.tiny() {
|
||||
self.warn("SVG Tiny 1.2 has no event attributes: SVG Interactivity events were left out");
|
||||
return;
|
||||
}
|
||||
let mut seen = HashSet::new();
|
||||
for (event, script) in events {
|
||||
// Native data can name anything: only the panel's events are written, once each.
|
||||
if !SVG_EVENTS.contains(&event.as_str()) || !valid_xml_value(script) {
|
||||
self.warn("SVG Interactivity events with unknown names or invalid XML characters were left out of SVG");
|
||||
continue;
|
||||
}
|
||||
if !seen.insert(event.as_str()) {
|
||||
self.warn("Repeated SVG Interactivity events were written only once in SVG");
|
||||
continue;
|
||||
}
|
||||
out.push_str(&format!(" {event}=\"{}\"", xml_attr_escape(script)));
|
||||
}
|
||||
}
|
||||
fn matrix(&self, m: Affine) -> String {
|
||||
let c = m.as_coeffs();
|
||||
format!("matrix({} {} {} {} {} {})", self.num(c[0]), self.num(c[1]), self.num(c[2]), self.num(c[3]), self.num(c[4]), self.num(c[5]))
|
||||
|
||||
@@ -8,8 +8,8 @@ use std::sync::Arc;
|
||||
use usvg::roxmltree;
|
||||
use vectorcraft_color::{BlendMode, Color, Gradient, GradientGeom, GradientKind, GradientPaint, GradientStop, Paint};
|
||||
use vectorcraft_doc::{
|
||||
Appearance, AppearanceItem, Dash, Document, FillLayer, ImageBlob, ImageObject, LayerColor, LineCap, LineJoin, Node, NodeId, NodeKind, PatternDef,
|
||||
StrokeLayer, Unit,
|
||||
Appearance, AppearanceItem, Dash, Document, FillLayer, ImageBlob, ImageObject, LayerColor, LineCap, LineJoin, MAX_SVG_SCRIPTS, Node, NodeId,
|
||||
NodeKind, PatternDef, SVG_EVENTS, StrokeLayer, Unit,
|
||||
};
|
||||
use vectorcraft_geom::{Affine, BezPath, FillRule, PathData, Point, Rect, Vec2, shapes};
|
||||
|
||||
@@ -90,10 +90,12 @@ pub(crate) fn import(svg: &str, opts: &ImportOptions) -> Result<(Document, Vec<S
|
||||
files,
|
||||
non_scaling: found.non_scaling,
|
||||
data: found.data,
|
||||
events: found.events,
|
||||
sublayers: found.sublayers,
|
||||
sublayer_groups: HashSet::new(),
|
||||
screen: k,
|
||||
};
|
||||
im.doc.svg_scripts = found.scripts;
|
||||
if im.files.nested_text() {
|
||||
im.warn("text inside an SVG image isn't imported".into());
|
||||
}
|
||||
@@ -166,6 +168,7 @@ pub(crate) fn import(svg: &str, opts: &ImportOptions) -> Result<(Document, Vec<S
|
||||
im.add_unclipped(&mut l, 0, std::mem::take(&mut loose));
|
||||
l.visible = !im.hidden.contains(g.id());
|
||||
im.keep_data(g.id(), &mut l);
|
||||
im.keep_events(g.id(), &mut l);
|
||||
im.doc.layers.push(Arc::new(l));
|
||||
}
|
||||
} else {
|
||||
@@ -372,6 +375,8 @@ struct Importer {
|
||||
non_scaling: HashSet<String>,
|
||||
/// The `data-*` attributes of elements, by id ([`user_data`]).
|
||||
data: HashMap<String, Vec<(String, String)>>,
|
||||
/// The event attributes of elements, by id ([`user_events`]).
|
||||
events: HashMap<String, Vec<(String, String)>>,
|
||||
/// Ids of the `<g>` elements marked as layers ([`marked_layer`]).
|
||||
sublayers: HashSet<String>,
|
||||
/// The groups made of them, made sublayers where they sit in a layer ([`promote_sublayers`]).
|
||||
@@ -488,6 +493,10 @@ struct Found {
|
||||
non_scaling: HashSet<String>,
|
||||
/// The `data-*` attributes of elements, by id ([`user_data`]).
|
||||
data: HashMap<String, Vec<(String, String)>>,
|
||||
/// The event attributes of elements, by id ([`user_events`]).
|
||||
events: HashMap<String, Vec<(String, String)>>,
|
||||
/// The JavaScript files `<script>` elements link to ([`linked_scripts`]).
|
||||
scripts: Vec<String>,
|
||||
/// Ids of the `<g>` elements marked as layers ([`marked_layer`]).
|
||||
sublayers: HashSet<String>,
|
||||
warnings: Vec<String>,
|
||||
@@ -574,6 +583,8 @@ impl Edits {
|
||||
/// * undisplayed objects (`display: none`, as Save writes hidden layers) are shown and recorded,
|
||||
/// to come back hidden; one something links to (a `<use>` template) stays as it is;
|
||||
/// * shapes with a non-scaling stroke get an id when they have none, recorded ([`vector_effect`]);
|
||||
/// * objects with event attributes get an id when they have none, recorded with them
|
||||
/// ([`user_events`]), and the `<script>` links are recorded ([`linked_scripts`]);
|
||||
/// * linked files are read ([`files`]);
|
||||
/// * percentage lengths in a `<symbol>` with a `viewBox` become numbers ([`symbol_units`]);
|
||||
/// * what style sheet rules with a `:not()` set goes in `style` attributes ([`not_rules`]).
|
||||
@@ -582,9 +593,13 @@ fn prepass<'s>(svg: &'s str, xml: &roxmltree::Document, opts: &ImportOptions) ->
|
||||
// (`:` for prefixed elements such as `<svg:image>`.)
|
||||
let css_transform = has_css_transform(svg);
|
||||
let not = svg.contains(":not(");
|
||||
let events = SVG_EVENTS.iter().any(|e| svg.contains(e));
|
||||
if !css_transform
|
||||
&& !not
|
||||
&& !["<a", "display", "<use", ":use", "<image", ":image", vector_effect::NON_SCALING, "data-", "groupmode"].iter().any(|t| svg.contains(t))
|
||||
&& !events
|
||||
&& !["<a", "display", "<use", ":use", "<image", ":image", vector_effect::NON_SCALING, "data-", "groupmode", "script"]
|
||||
.iter()
|
||||
.any(|t| svg.contains(t))
|
||||
{
|
||||
return (svg.into(), found);
|
||||
}
|
||||
@@ -603,6 +618,17 @@ fn prepass<'s>(svg: &'s str, xml: &roxmltree::Document, opts: &ImportOptions) ->
|
||||
}
|
||||
}
|
||||
}
|
||||
if events {
|
||||
for e in elements().filter(|e| EVENT_ELEMENTS.contains(&e.tag_name().name())) {
|
||||
let events = user_events(e);
|
||||
if !events.is_empty() {
|
||||
found.events.insert(edits.id(svg, e), events);
|
||||
}
|
||||
}
|
||||
}
|
||||
if svg.contains("script") {
|
||||
found.scripts = linked_scripts(xml, &mut found.warnings);
|
||||
}
|
||||
found.sublayers = elements().filter(|g| marked_layer(*g)).filter_map(|g| g.attribute("id")).map(str::to_string).collect();
|
||||
let symbols: HashSet<&str> = elements().filter(|n| n.tag_name().name() == "symbol").filter_map(|n| n.attribute("id")).collect();
|
||||
for u in elements().filter(|n| n.tag_name().name() == "use") {
|
||||
@@ -700,6 +726,44 @@ fn user_data(e: XNode) -> Vec<(String, String)> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The elements whose event attributes are kept: the objects, and type.
|
||||
const EVENT_ELEMENTS: &[&str] = &["g", "a", "use", "path", "rect", "circle", "ellipse", "line", "polyline", "polygon", "image", "text"];
|
||||
|
||||
/// The event attributes of element `e` that the SVG Interactivity panel lists ([`SVG_EVENTS`]),
|
||||
/// in order: the JavaScript is kept as text and never run.
|
||||
fn user_events(e: XNode) -> Vec<(String, String)> {
|
||||
e.attributes()
|
||||
.filter(|a| a.namespace().is_none() && SVG_EVENTS.contains(&a.name()))
|
||||
.map(|a| (a.name().to_string(), a.value().to_string()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The JavaScript files the `<script>` elements link to, in order and each once, at most
|
||||
/// [`MAX_SVG_SCRIPTS`] (the file is untrusted): the links are kept as text, and the files are never
|
||||
/// read or run. Inline scripts and the links past the limit are left out with a warning.
|
||||
fn linked_scripts(xml: &roxmltree::Document, warnings: &mut Vec<String>) -> Vec<String> {
|
||||
let mut out: Vec<String> = vec![];
|
||||
let mut over = false;
|
||||
for s in xml.descendants().filter(|n| n.is_element() && n.tag_name().name() == "script") {
|
||||
match href(s) {
|
||||
Some(h) if out.iter().any(|o| o == h) => {}
|
||||
Some(h) if out.len() < MAX_SVG_SCRIPTS => out.push(h.to_string()),
|
||||
Some(_) => over = true,
|
||||
None if s.text().is_some_and(|t| !t.trim().is_empty()) => {
|
||||
let w = "inline scripts were left out: only linked JavaScript files are kept".to_string();
|
||||
if !warnings.contains(&w) {
|
||||
warnings.push(w);
|
||||
}
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
if over {
|
||||
warnings.push(format!("more than {MAX_SVG_SCRIPTS} JavaScript files are linked: only the first {MAX_SVG_SCRIPTS} were kept"));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Make `n` link to `url`, unless it links somewhere already (an inner link wins).
|
||||
fn link(n: &mut Node, url: &str) {
|
||||
if n.url().is_none() {
|
||||
@@ -845,6 +909,7 @@ impl Importer {
|
||||
out.visible = false;
|
||||
}
|
||||
self.keep_data(n.id(), &mut out);
|
||||
self.keep_events(n.id(), &mut out);
|
||||
Some(out)
|
||||
}
|
||||
|
||||
@@ -856,6 +921,14 @@ impl Importer {
|
||||
}
|
||||
}
|
||||
|
||||
/// Give `n` the event attributes of element `id`, unless it has events already (what an inner
|
||||
/// element made of it keeps).
|
||||
fn keep_events(&self, id: &str, n: &mut Node) {
|
||||
if let Some(events) = self.events.get(id).filter(|_| n.extras().attrs.as_ref().is_none_or(|a| a.events.is_empty())) {
|
||||
n.edit_attrs(|a| a.events = events.clone());
|
||||
}
|
||||
}
|
||||
|
||||
/// A gradient's stops, midpoints restored.
|
||||
fn stops(&self, g: &usvg::BaseGradient) -> Vec<GradientStop> {
|
||||
gradient_stops(g, self.midpoints.get(g.id()).map(Vec::as_slice))
|
||||
@@ -1058,8 +1131,10 @@ impl Importer {
|
||||
}
|
||||
// An id-less wrapper around a single object (usvg adds these for opacity/transform on
|
||||
// shapes): fold its opacity, blend and effects into the object (unless the object
|
||||
// blends or masks before the effects). A text takes its own mask too.
|
||||
if (is_text || id.is_empty() && mask.is_none())
|
||||
// blends or masks before the effects). A text takes its own mask too. A group with
|
||||
// `data-*` or event attributes of its own stays a group, and keeps them.
|
||||
let own = self.data.contains_key(g.id()) || self.events.contains_key(g.id());
|
||||
if (is_text || id.is_empty() && mask.is_none() && !own)
|
||||
&& children.len() == 1
|
||||
&& (g.blend_mode() == usvg::BlendMode::Normal || children[0].blend == BlendMode::Normal)
|
||||
&& (effects.is_empty() || children[0].blend == BlendMode::Normal && children[0].extras().mask.is_none())
|
||||
@@ -1398,9 +1473,10 @@ impl Importer {
|
||||
std::mem::take(&mut self.labels),
|
||||
std::mem::take(&mut self.non_scaling),
|
||||
std::mem::take(&mut self.data),
|
||||
std::mem::take(&mut self.events),
|
||||
);
|
||||
let art = self.group_node(tree.root(), acc);
|
||||
(self.links, self.hidden, self.uses, self.labels, self.non_scaling, self.data) = outer;
|
||||
(self.links, self.hidden, self.uses, self.labels, self.non_scaling, self.data, self.events) = outer;
|
||||
art
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,6 +51,22 @@ fn imported_decimal_whitespace_references_survive_export() {
|
||||
}
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn an_unnamed_group_around_one_object_keeps_its_own_data() {
|
||||
let with_data = |doc: &Document| {
|
||||
let mut found = vec![];
|
||||
doc.walk(|n| {
|
||||
if let Some(a) = n.extras().attrs.as_deref().filter(|a| !a.data.is_empty()) {
|
||||
found.push((n.children().is_some(), a.data.clone()));
|
||||
}
|
||||
});
|
||||
found
|
||||
};
|
||||
let doc = import(&svg(r#"<g data-role="menu"><rect width="20" height="20" data-role="item"/></g>"#)).unwrap();
|
||||
let want = [(true, vec![("role".to_string(), "menu".to_string())]), (false, vec![("role".to_string(), "item".to_string())])];
|
||||
assert_eq!(with_data(&doc), want);
|
||||
assert_eq!(with_data(&import(&export(&doc, &ExportOptions::default())).unwrap()), want);
|
||||
}
|
||||
fn encoded_text(out: &Output) -> String {
|
||||
let bytes = out.bytes();
|
||||
match out.encoding {
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
//! SVG Interactivity: event attributes (https://www.w3.org/TR/SVG11/script.html#EventAttributes)
|
||||
//! and `<script xlink:href>` links (https://www.w3.org/TR/SVG11/script.html#ScriptElement), written
|
||||
//! by export and read back by import as text that nothing runs.
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||
|
||||
use std::sync::Mutex;
|
||||
|
||||
use proptest::prelude::*;
|
||||
use usvg::roxmltree;
|
||||
use vectorcraft_doc::{Document, Node};
|
||||
use vectorcraft_svg::{Encoding, ExportOptions, ImportOptions, Profile, export, export_full, import, import_with};
|
||||
|
||||
fn svg(body: &str) -> String {
|
||||
format!(r#"<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="100" height="100">{body}</svg>"#)
|
||||
}
|
||||
|
||||
fn named<'a>(doc: &'a Document, name: &str) -> &'a Node {
|
||||
let mut found = None;
|
||||
doc.walk(|n| {
|
||||
if n.name.as_deref() == Some(name) {
|
||||
found = Some(n);
|
||||
}
|
||||
});
|
||||
found.unwrap()
|
||||
}
|
||||
|
||||
fn events(doc: &Document, name: &str) -> Vec<(String, String)> {
|
||||
named(doc, name).extras().attrs.as_deref().map(|a| a.events.clone()).unwrap_or_default()
|
||||
}
|
||||
|
||||
fn pair(e: &str, js: &str) -> (String, String) {
|
||||
(e.to_string(), js.to_string())
|
||||
}
|
||||
|
||||
/// A rectangle named `shape` with `events`, in a document linking `files`.
|
||||
fn document(events: &[(&str, &str)], files: &[&str]) -> Document {
|
||||
let mut doc = import(&svg(r#"<rect id="shape" width="20" height="20"/>"#)).unwrap();
|
||||
let id = named(&doc, "shape").id;
|
||||
doc.node_mut(id).unwrap().edit_attrs(|a| a.events = events.iter().map(|(e, js)| pair(e, js)).collect());
|
||||
doc.svg_scripts = files.iter().map(|f| f.to_string()).collect();
|
||||
doc
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn events_are_written_as_event_attributes() {
|
||||
let doc = document(&[("onclick", "show(\"menu\") && x < 1"), ("onmouseover", "a\nb")], &[]);
|
||||
let text = export(&doc, &ExportOptions::default());
|
||||
let xml = roxmltree::Document::parse(&text).unwrap();
|
||||
let rect = xml.descendants().find(|n| n.attribute("id") == Some("shape")).unwrap();
|
||||
assert_eq!(rect.attribute("onclick"), Some("show(\"menu\") && x < 1"), "{text}");
|
||||
assert_eq!(rect.attribute("onmouseover"), Some("a\nb"), "a line break stays one: {text}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_and_repeated_events_and_the_tiny_profile_write_none() {
|
||||
let doc = document(&[("onclick", "a()"), ("onclick", "b()"), ("style", "x"), ("onfoo", "y()")], &[]);
|
||||
let out = export_full(&doc, &ExportOptions::default(), None);
|
||||
assert_eq!(out.svg.matches("onclick=").count(), 1, "{}", out.svg);
|
||||
assert!(out.svg.contains("onclick=\"a()\"") && !out.svg.contains("onfoo") && !out.svg.contains("style=\"x\""), "{}", out.svg);
|
||||
assert_eq!(out.warnings.len(), 2, "{:?}", out.warnings);
|
||||
let tiny = export_full(&doc, &ExportOptions { profile: Profile::Tiny12, ..Default::default() }, None);
|
||||
assert!(!tiny.svg.contains("onclick"), "{}", tiny.svg);
|
||||
assert!(tiny.warnings.iter().any(|w| w.contains("SVG Tiny 1.2 has no event attributes")), "{:?}", tiny.warnings);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn linked_files_are_written_as_script_elements_in_order() {
|
||||
let doc = document(&[], &["menu.js", "https://example.com/a.js?x=1&y=2"]);
|
||||
let text = export(&doc, &ExportOptions::default());
|
||||
let xml = roxmltree::Document::parse(&text).unwrap();
|
||||
let xlink = "http://www.w3.org/1999/xlink";
|
||||
let links: Vec<_> = xml.descendants().filter(|n| n.tag_name().name() == "script").filter_map(|n| n.attribute((xlink, "href"))).collect();
|
||||
assert_eq!(links, ["menu.js", "https://example.com/a.js?x=1&y=2"], "{text}");
|
||||
for profile in [Profile::Svg11, Profile::Tiny12] {
|
||||
assert!(export(&doc, &ExportOptions { profile, minify: true, ..Default::default() }).contains("<script xlink:href=\"menu.js\"/>"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn import_reads_back_events_and_linked_files() {
|
||||
let doc = document(&[("onclick", "show(\"menu\") && x < 1"), ("onload", "init()")], &["menu.js", "lib/b.js"]);
|
||||
for minify in [false, true] {
|
||||
let back = import(&export(&doc, &ExportOptions { minify, ..Default::default() })).unwrap();
|
||||
assert_eq!(events(&back, "shape"), events(&doc, "shape"));
|
||||
assert_eq!(back.svg_scripts, doc.svg_scripts);
|
||||
}
|
||||
}
|
||||
|
||||
proptest! {
|
||||
#![proptest_config(ProptestConfig { cases: 64, failure_persistence: None, ..ProptestConfig::default() })]
|
||||
|
||||
/// Any script and any file name come back from an export as they went in, in UTF-8 and in
|
||||
/// ISO 8859-1 (other characters written as references), minified or not.
|
||||
#[test]
|
||||
fn any_script_and_file_name_survive_the_round_trip(script in "[\\PC\t\n]{1,40}", file in "\\PC{1,30}", minify: bool, latin1: bool) {
|
||||
let doc = document(&[("onclick", script.as_str())], &[file.as_str()]);
|
||||
let encoding = if latin1 { Encoding::Latin1 } else { Encoding::Utf8 };
|
||||
let out = export_full(&doc, &ExportOptions { minify, encoding, ..Default::default() }, None);
|
||||
let back = import(&out.svg).map_err(|e| TestCaseError::fail(format!("{e}\n{}", out.svg)))?;
|
||||
prop_assert_eq!(events(&back, "shape"), vec![pair("onclick", &script)]);
|
||||
prop_assert_eq!(back.svg_scripts, vec![file]);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn import_keeps_events_of_groups_type_and_unnamed_shapes() {
|
||||
let doc = import(&svg(
|
||||
r#"<g id="menu" onmouseover="open()"><circle r="5" onclick="pick(1)"/></g><text id="label" x="10" y="20" onclick="say()">Hi</text>"#,
|
||||
))
|
||||
.unwrap();
|
||||
assert_eq!(events(&doc, "menu"), [pair("onmouseover", "open()")]);
|
||||
let mut circle = vec![];
|
||||
doc.walk(|n| {
|
||||
if n.path_data().is_some() {
|
||||
circle.extend(n.extras().attrs.as_deref().map(|a| a.events.clone()).unwrap_or_default());
|
||||
}
|
||||
});
|
||||
assert_eq!(circle, [pair("onclick", "pick(1)")]);
|
||||
assert_eq!(events(&doc, "label"), [pair("onclick", "say()")]);
|
||||
}
|
||||
|
||||
/// An unnamed group around one object keeps its own events, apart from the object's, and keeps
|
||||
/// them through an export.
|
||||
#[test]
|
||||
fn import_keeps_an_unnamed_group_with_events_apart_from_its_object() {
|
||||
let with_events = |doc: &Document| {
|
||||
let mut found = vec![];
|
||||
doc.walk(|n| {
|
||||
let events = n.extras().attrs.as_deref().map(|a| a.events.clone()).unwrap_or_default();
|
||||
if !events.is_empty() {
|
||||
found.push((n.children().is_some(), events));
|
||||
}
|
||||
});
|
||||
found
|
||||
};
|
||||
let doc =
|
||||
import(&svg(r#"<g onclick="outer()"><rect width="20" height="20" onclick="inner()"/></g><g onmouseover="hover()"><circle r="5"/></g>"#))
|
||||
.unwrap();
|
||||
let want = [(true, vec![pair("onclick", "outer()")]), (false, vec![pair("onclick", "inner()")]), (true, vec![pair("onmouseover", "hover()")])];
|
||||
assert_eq!(with_events(&doc), want);
|
||||
assert_eq!(with_events(&import(&export(&doc, &ExportOptions::default())).unwrap()), want);
|
||||
}
|
||||
|
||||
/// The file is untrusted: event attributes and script links are kept as text only. Import reads no
|
||||
/// script file, runs nothing, keeps no inline script and no attribute the panel doesn't list.
|
||||
#[test]
|
||||
fn import_never_reads_or_runs_scripts() {
|
||||
let asked = Mutex::new(Vec::<String>::new());
|
||||
let read = |p: &str| -> Option<(Vec<u8>, vectorcraft_doc::LinkInfo)> {
|
||||
asked.lock().unwrap().push(p.to_string());
|
||||
None
|
||||
};
|
||||
let text = svg(r#"<script xlink:href="evil.js"/><script href="evil.js"/><script type="text/ecmascript">document.title = "x";</script>
|
||||
<image xlink:href="photo.png" width="10" height="10"/>
|
||||
<rect id="shape" width="20" height="20" onclick="fetch('https://example.com')" onfoo="x()" data-a="1"/>"#);
|
||||
let (doc, warnings) = import_with(&text, &ImportOptions { folder: Some("/tmp"), read: Some(&read) }).unwrap();
|
||||
let asked = asked.lock().unwrap().clone();
|
||||
assert!(asked.iter().any(|p| p.contains("photo.png")) && !asked.iter().any(|p| p.contains("evil")), "read {asked:?}");
|
||||
assert_eq!(doc.svg_scripts, ["evil.js"], "each link once, the inline script left out");
|
||||
assert!(warnings.iter().any(|w| w.contains("inline scripts were left out")), "{warnings:?}");
|
||||
assert_eq!(events(&doc, "shape"), [pair("onclick", "fetch('https://example.com')")]);
|
||||
assert_eq!(doc.title, "Untitled-1", "the inline script didn't run");
|
||||
}
|
||||
/// Import keeps the first 64 different `<script>` links and warns about the ones after them.
|
||||
#[test]
|
||||
fn import_keeps_the_first_64_linked_files_and_warns_about_the_rest() {
|
||||
let links = |n: usize| (0..n).map(|i| format!(r#"<script xlink:href="{i}.js"/>"#)).collect::<String>();
|
||||
let first: Vec<String> = (0..64).map(|i| format!("{i}.js")).collect();
|
||||
let warned = |w: &[String]| w.iter().any(|w| w.contains("more than 64 JavaScript files are linked"));
|
||||
let (doc, warnings) = import_with(&svg(&(links(64) + r#"<script xlink:href="0.js"/>"#)), &ImportOptions::default()).unwrap();
|
||||
assert_eq!(doc.svg_scripts, first);
|
||||
assert!(!warned(&warnings), "a repeated link is not one too many: {warnings:?}");
|
||||
let (doc, warnings) = import_with(&svg(&links(65)), &ImportOptions::default()).unwrap();
|
||||
assert_eq!(doc.svg_scripts, first);
|
||||
assert!(warned(&warnings), "{warnings:?}");
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
//! JavaScript Files (the SVG Interactivity panel's Link JavaScript Files…): the JavaScript files
|
||||
//! the active document links, in a list. Add links the path or URL in File or URL
|
||||
//! (`svgInteractivity.addFile`; Choose… puts a picked file's `file:///` URL there where the host
|
||||
//! has an open dialog), and Remove unlinks the file selected in the list
|
||||
//! (`svgInteractivity.removeFile`). SVG export writes a `<script xlink:href>` element for each
|
||||
//! 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).
|
||||
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::DialogSpec;
|
||||
use crate::picks::PickRequest;
|
||||
use crate::state::Dialog;
|
||||
use crate::{FilePick, VectorcraftApp, widgets};
|
||||
|
||||
/// The dialog kind of JavaScript Files.
|
||||
pub const KIND: &str = "javascriptFiles";
|
||||
|
||||
pub(super) const SPEC: DialogSpec = DialogSpec {
|
||||
heading: |_| tl!("JavaScript Files").into(),
|
||||
body,
|
||||
confirm: |app, _| {
|
||||
app.ui.dialog = None;
|
||||
Ok(Value::Null)
|
||||
},
|
||||
ok: None,
|
||||
min_width: 420.0,
|
||||
..DialogSpec::FORM
|
||||
};
|
||||
|
||||
/// Open JavaScript Files for the active document.
|
||||
pub fn open(app: &mut VectorcraftApp) -> Result<Value, String> {
|
||||
if app.session.active().is_none() {
|
||||
return Err("no document open".into());
|
||||
}
|
||||
app.ui.dialog = Some(Dialog::new(KIND, json!({ "file": "", "selected": "" })));
|
||||
Ok(Value::Null)
|
||||
}
|
||||
|
||||
/// The extension of JavaScript files.
|
||||
const JS_EXTS: &[&str] = &["js"];
|
||||
|
||||
/// The open dialog for a JavaScript file.
|
||||
fn pick() -> FilePick {
|
||||
FilePick { filters: vec![("JavaScript", JS_EXTS)], ..Default::default() }
|
||||
}
|
||||
|
||||
/// Choose…: the file picked in the open dialog into File or URL, as a `file:///` URL
|
||||
/// ([`crate::io::file_url`]).
|
||||
fn choose(app: &mut VectorcraftApp, d: &mut Dialog) -> Result<(), String> {
|
||||
if let Some(path) = crate::picks::open(app, &pick()) {
|
||||
d.fields.insert("file".into(), json!(crate::io::file_url(&path)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// What a button asked for, done once the dialog is drawn.
|
||||
enum Action {
|
||||
Select(String),
|
||||
Choose,
|
||||
Add,
|
||||
Remove,
|
||||
}
|
||||
|
||||
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 selected = d.str("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()));
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
ui.add_space(8.0);
|
||||
widgets::label_row(ui, tl!("File or URL:"), 84.0, |ui| {
|
||||
if let Some(v) = widgets::exact_text_field(ui, "javascript-file", &d.str("file"), 240.0) {
|
||||
d.fields.insert("file".into(), json!(v));
|
||||
}
|
||||
if crate::picks::can(app, &PickRequest::Open(pick())) && widgets::flat_button(ui, tl!("Choose…"), 64.0).clicked() {
|
||||
act = Some(Action::Choose);
|
||||
}
|
||||
});
|
||||
ui.add_space(6.0);
|
||||
ui.horizontal(|ui| {
|
||||
ui.spacing_mut().item_spacing.x = 4.0;
|
||||
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() {
|
||||
act = Some(Action::Remove);
|
||||
}
|
||||
});
|
||||
let r = match act {
|
||||
None => Ok(()),
|
||||
Some(Action::Select(f)) => {
|
||||
d.fields.insert("selected".into(), json!(f));
|
||||
Ok(())
|
||||
}
|
||||
// The open dialog, shown off the UI thread, fills the dialog as it is then.
|
||||
Some(Action::Choose) => crate::picks::in_dialog(app, d, choose),
|
||||
Some(Action::Add) => {
|
||||
let file = d.str("file").trim().to_string();
|
||||
app.run("svgInteractivity.addFile", json!({ "file": file })).map(|_| {
|
||||
d.fields.insert("selected".into(), json!(file));
|
||||
d.fields.insert("file".into(), json!(""));
|
||||
})
|
||||
}
|
||||
Some(Action::Remove) => app.run("svgInteractivity.removeFile", json!({ "file": selected })).map(|_| {
|
||||
d.fields.insert("selected".into(), json!(""));
|
||||
}),
|
||||
};
|
||||
if let Err(e) = r {
|
||||
app.status(e);
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use vectorcraft_engine::Session;
|
||||
|
||||
/// One headless frame of the dialog's body: the texts drawn.
|
||||
fn frame(app: &mut VectorcraftApp, d: &mut Dialog) -> Vec<String> {
|
||||
fn texts(s: &egui::Shape, out: &mut Vec<String>) {
|
||||
match s {
|
||||
egui::Shape::Text(t) => out.push(t.galley.text().to_string()),
|
||||
egui::Shape::Vec(v) => v.iter().for_each(|s| texts(s, out)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let ctx = egui::Context::default();
|
||||
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| {
|
||||
body(app, ui, d);
|
||||
});
|
||||
out.textures_delta.clear();
|
||||
let mut v = vec![];
|
||||
out.shapes.iter().for_each(|c| texts(&c.shape, &mut v));
|
||||
v
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opens_on_the_documents_linked_files() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
assert!(open(&mut app).is_err(), "no document");
|
||||
app.session.execute("file.new", &json!({"width": 100, "height": 100})).unwrap();
|
||||
app.session.execute("svgInteractivity.addFile", &json!({"file": "scripts/menu.js"})).unwrap();
|
||||
crate::menus::invoke(&mut app, "ui.javascriptFilesDialog", json!({}));
|
||||
assert_eq!(app.ui.dialog.as_ref().map(|d| d.kind.as_str()), Some(KIND));
|
||||
let mut d = app.ui.dialog.clone().unwrap();
|
||||
// (Only paths are checked: tests running alongside change the language labels are drawn in.)
|
||||
let texts = frame(&mut app, &mut d);
|
||||
assert!(texts.iter().any(|t| t == "scripts/menu.js"), "{texts:?}");
|
||||
d.fields.insert("file".into(), json!("https://example.com/b.js"));
|
||||
let texts = frame(&mut app, &mut d);
|
||||
assert!(texts.iter().any(|t| t == "https://example.com/b.js"), "the field shows `file`: {texts:?}");
|
||||
}
|
||||
|
||||
/// Choose… puts the picked file in File or URL as a `file:///` URL, with no `\` and with
|
||||
/// spaces percent-encoded.
|
||||
#[test]
|
||||
fn choose_puts_the_picked_file_in_as_a_file_url() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
app.session.execute("file.new", &json!({"width": 100, "height": 100})).unwrap();
|
||||
let picked = std::env::temp_dir().join("my menu.js").to_string_lossy().into_owned();
|
||||
app.services.pick_open = Some(Box::new(move |_: &FilePick| Some(picked.clone())));
|
||||
let mut d = Dialog::new(KIND, json!({ "file": "", "selected": "" }));
|
||||
choose(&mut app, &mut d).unwrap();
|
||||
let file = d.str("file");
|
||||
assert!(file.starts_with("file:///") && file.ends_with("/my%20menu.js") && !file.contains('\\'), "{file}");
|
||||
}
|
||||
}
|
||||
@@ -45,6 +45,7 @@ pub mod graphic_style_options;
|
||||
pub mod halftone;
|
||||
pub mod hyphenation;
|
||||
pub mod import_pdf;
|
||||
pub mod javascript_files;
|
||||
pub mod layer_options;
|
||||
pub mod layers_panel_options;
|
||||
pub mod liquify;
|
||||
@@ -267,6 +268,7 @@ registry! {
|
||||
Hyphenation: [hyphenation::KIND] => hyphenation::SPEC,
|
||||
SavePdf: [save_pdf::KIND] => save_pdf::SPEC,
|
||||
SvgOptions: [svg_options::KIND] => svg_options::SPEC,
|
||||
JavaScriptFiles: [javascript_files::KIND] => javascript_files::SPEC,
|
||||
NewDocumentMore: [new_document::MORE] => new_document::MORE_SPEC,
|
||||
Place: [place::KIND] => place::SPEC,
|
||||
RasterOptions: ["pngOptions", "jpgOptions", "webpOptions", "gifOptions", "png8Options"] => png_options::SPEC,
|
||||
|
||||
@@ -2935,6 +2935,16 @@
|
||||
Superscript: Горен индекс:
|
||||
SVG Filters SVG филтри
|
||||
SVG Interactivity SVG интерактивност
|
||||
Event: Събитие:
|
||||
JavaScript: JavaScript:
|
||||
Add Event Добавяне на събитие
|
||||
Remove Selected Entry Премахване на избрания запис
|
||||
Clear Events Изчистване на събитията
|
||||
Link JavaScript Files… Свързване на файлове JavaScript…
|
||||
JavaScript Files Файлове JavaScript
|
||||
File or URL: Файл или URL адрес:
|
||||
Add JavaScript File Добавяне на файл JavaScript
|
||||
Remove JavaScript File Премахване на файл JavaScript
|
||||
SVG Options SVG опции
|
||||
SVG Profile: SVG профил:
|
||||
Swap Fill and Stroke Размяна на запълването и щриха
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -612,6 +612,16 @@
|
||||
Separations Preview Náhled separací
|
||||
Stroke Tah
|
||||
SVG Interactivity Interaktivita SVG
|
||||
Event: Událost:
|
||||
JavaScript: JavaScript:
|
||||
Add Event Přidat událost
|
||||
Remove Selected Entry Odebrat vybranou položku
|
||||
Clear Events Vymazat události
|
||||
Link JavaScript Files… Propojit soubory JavaScript…
|
||||
JavaScript Files Soubory JavaScript
|
||||
File or URL: Soubor nebo URL:
|
||||
Add JavaScript File Přidat soubor JavaScript
|
||||
Remove JavaScript File Odebrat soubor JavaScript
|
||||
Swatches Vzorník
|
||||
Symbols Symboly
|
||||
Transparency Průhlednost
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -2963,6 +2963,16 @@ effect Points Punkte
|
||||
Superscript: Hochgestellt:
|
||||
SVG Filters SVG-Filter
|
||||
SVG Interactivity SVG-Interaktivität
|
||||
Event: Ereignis:
|
||||
JavaScript: JavaScript:
|
||||
Add Event Ereignis hinzufügen
|
||||
Remove Selected Entry Ausgewählten Eintrag entfernen
|
||||
Clear Events Ereignisse löschen
|
||||
Link JavaScript Files… JavaScript-Dateien verknüpfen…
|
||||
JavaScript Files JavaScript-Dateien
|
||||
File or URL: Datei oder URL:
|
||||
Add JavaScript File JavaScript-Datei hinzufügen
|
||||
Remove JavaScript File JavaScript-Datei entfernen
|
||||
SVG Options SVG-Optionen
|
||||
SVG Profile: SVG-Profil:
|
||||
Swap Fill and Stroke Fläche und Kontur vertauschen
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -3314,6 +3314,16 @@ theme Appearance Εμφάνιση
|
||||
Superscript (on the roadmap) Εκθέτης (προγραμματισμένη λειτουργία)
|
||||
SVG Filters Φίλτρα SVG
|
||||
SVG Interactivity Διαδραστικότητα SVG
|
||||
Event: Συμβάν:
|
||||
JavaScript: JavaScript:
|
||||
Add Event Προσθήκη συμβάντος
|
||||
Remove Selected Entry Αφαίρεση επιλεγμένης καταχώρισης
|
||||
Clear Events Αφαίρεση όλων των συμβάντων
|
||||
Link JavaScript Files… Σύνδεση αρχείων JavaScript…
|
||||
JavaScript Files Αρχεία JavaScript
|
||||
File or URL: Αρχείο ή διεύθυνση URL:
|
||||
Add JavaScript File Προσθήκη αρχείου JavaScript
|
||||
Remove JavaScript File Αφαίρεση αρχείου JavaScript
|
||||
SVG Options Επιλογές SVG
|
||||
SVG Profile: Προφίλ SVG:
|
||||
Swap Fill and Stroke Εναλλαγή γεμίσματος και περιγράμματος
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 1903 and column 19.
|
@@ -2994,6 +2994,16 @@
|
||||
Superscript: Superíndice:
|
||||
SVG Filters Filtros SVG
|
||||
SVG Interactivity Interactividad SVG
|
||||
Event: Evento:
|
||||
JavaScript: JavaScript:
|
||||
Add Event Agregar evento
|
||||
Remove Selected Entry Quitar entrada seleccionada
|
||||
Clear Events Borrar eventos
|
||||
Link JavaScript Files… Vincular archivos JavaScript…
|
||||
JavaScript Files Archivos JavaScript
|
||||
File or URL: Archivo o URL:
|
||||
Add JavaScript File Agregar archivo JavaScript
|
||||
Remove JavaScript File Quitar archivo JavaScript
|
||||
SVG Options Opciones SVG
|
||||
SVG Profile: Perfil SVG:
|
||||
Swap Fill and Stroke Intercambiar relleno y trazo
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -603,6 +603,16 @@
|
||||
Separations Preview Erottelujen esikatselu
|
||||
Stroke Viiva
|
||||
SVG Interactivity SVG-vuorovaikutteisuus
|
||||
Event: Tapahtuma:
|
||||
JavaScript: JavaScript:
|
||||
Add Event Lisää tapahtuma
|
||||
Remove Selected Entry Poista valittu merkintä
|
||||
Clear Events Tyhjennä tapahtumat
|
||||
Link JavaScript Files… Linkitä JavaScript-tiedostoja…
|
||||
JavaScript Files JavaScript-tiedostot
|
||||
File or URL: Tiedosto tai URL:
|
||||
Add JavaScript File Lisää JavaScript-tiedosto
|
||||
Remove JavaScript File Poista JavaScript-tiedosto
|
||||
Swatches Värimallit
|
||||
Symbols Symbolit
|
||||
Transparency Läpinäkyvyys
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -2991,6 +2991,16 @@
|
||||
Superscript: Exposant :
|
||||
SVG Filters Filtres SVG
|
||||
SVG Interactivity Interactivité SVG
|
||||
Event: Événement :
|
||||
JavaScript: JavaScript :
|
||||
Add Event Ajouter un événement
|
||||
Remove Selected Entry Supprimer l’entrée sélectionnée
|
||||
Clear Events Effacer les événements
|
||||
Link JavaScript Files… Lier des fichiers JavaScript…
|
||||
JavaScript Files Fichiers JavaScript
|
||||
File or URL: Fichier ou URL :
|
||||
Add JavaScript File Ajouter un fichier JavaScript
|
||||
Remove JavaScript File Supprimer le fichier JavaScript
|
||||
SVG Options Options SVG
|
||||
SVG Profile: Profil SVG :
|
||||
Swap Fill and Stroke Permuter le fond et le contour
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -3646,6 +3646,16 @@ mark-style Japanese Japán
|
||||
SVG 1.1 SVG 1.1
|
||||
SVG Filters SVG-szűrők
|
||||
SVG Interactivity SVG-interaktivitás
|
||||
Event: Esemény:
|
||||
JavaScript: JavaScript:
|
||||
Add Event Esemény hozzáadása
|
||||
Remove Selected Entry Kijelölt bejegyzés eltávolítása
|
||||
Clear Events Események törlése
|
||||
Link JavaScript Files… JavaScript-fájlok csatolása…
|
||||
JavaScript Files JavaScript-fájlok
|
||||
File or URL: Fájl vagy URL:
|
||||
Add JavaScript File JavaScript-fájl hozzáadása
|
||||
Remove JavaScript File JavaScript-fájl eltávolítása
|
||||
SVG Options SVG-beállítások
|
||||
SVG Profile: SVG-profil:
|
||||
Swallowtail Fecskefarok
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -3013,6 +3013,16 @@ mark-style Japanese Giapponese
|
||||
Superscript: Apice:
|
||||
SVG Filters Filtri SVG
|
||||
SVG Interactivity Interattività SVG
|
||||
Event: Evento:
|
||||
JavaScript: JavaScript:
|
||||
Add Event Aggiungi evento
|
||||
Remove Selected Entry Rimuovi voce selezionata
|
||||
Clear Events Cancella eventi
|
||||
Link JavaScript Files… Collega file JavaScript…
|
||||
JavaScript Files File JavaScript
|
||||
File or URL: File o URL:
|
||||
Add JavaScript File Aggiungi file JavaScript
|
||||
Remove JavaScript File Rimuovi file JavaScript
|
||||
SVG Options Opzioni SVG
|
||||
SVG Profile: Profilo SVG:
|
||||
Swap Fill and Stroke Inverti riempimento e traccia
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -585,6 +585,16 @@
|
||||
Separations Preview 分版プレビュー
|
||||
Stroke 線
|
||||
SVG Interactivity SVG インタラクティビティ
|
||||
Event: イベント:
|
||||
JavaScript: JavaScript:
|
||||
Add Event イベントを追加
|
||||
Remove Selected Entry 選択した項目を削除
|
||||
Clear Events イベントを消去
|
||||
Link JavaScript Files… JavaScript ファイルをリンク…
|
||||
JavaScript Files JavaScript ファイル
|
||||
File or URL: ファイルまたは URL:
|
||||
Add JavaScript File JavaScript ファイルを追加
|
||||
Remove JavaScript File JavaScript ファイルを削除
|
||||
Swatches スウォッチ
|
||||
Symbols シンボル
|
||||
Transparency 透明
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -583,6 +583,16 @@
|
||||
Separations Preview 분판 미리 보기
|
||||
Stroke 선
|
||||
SVG Interactivity SVG 상호작용
|
||||
Event: 이벤트:
|
||||
JavaScript: JavaScript:
|
||||
Add Event 이벤트 추가
|
||||
Remove Selected Entry 선택한 항목 제거
|
||||
Clear Events 이벤트 지우기
|
||||
Link JavaScript Files… JavaScript 파일 연결…
|
||||
JavaScript Files JavaScript 파일
|
||||
File or URL: 파일 또는 URL:
|
||||
Add JavaScript File JavaScript 파일 추가
|
||||
Remove JavaScript File JavaScript 파일 제거
|
||||
Swatches 견본
|
||||
Symbols 심볼
|
||||
Transparency 투명도
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -560,6 +560,16 @@
|
||||
Separations Preview Podgląd rozbarwień
|
||||
Stroke Obrys
|
||||
SVG Interactivity Interaktywność SVG
|
||||
Event: Zdarzenie:
|
||||
JavaScript: JavaScript:
|
||||
Add Event Dodaj zdarzenie
|
||||
Remove Selected Entry Usuń wybrany wpis
|
||||
Clear Events Wyczyść zdarzenia
|
||||
Link JavaScript Files… Połącz pliki JavaScript…
|
||||
JavaScript Files Pliki JavaScript
|
||||
File or URL: Plik lub URL:
|
||||
Add JavaScript File Dodaj plik JavaScript
|
||||
Remove JavaScript File Usuń plik JavaScript
|
||||
Swatches Próbki
|
||||
Symbols Symbole
|
||||
Transparency Przezroczystość
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -2103,6 +2103,16 @@
|
||||
SAVED SALVO
|
||||
SVG Filters Filtros SVG
|
||||
SVG Interactivity Interatividade SVG
|
||||
Event: Evento:
|
||||
JavaScript: JavaScript:
|
||||
Add Event Adicionar Evento
|
||||
Remove Selected Entry Remover Entrada Selecionada
|
||||
Clear Events Limpar Eventos
|
||||
Link JavaScript Files… Vincular Arquivos JavaScript…
|
||||
JavaScript Files Arquivos JavaScript
|
||||
File or URL: Arquivo ou URL:
|
||||
Add JavaScript File Adicionar Arquivo JavaScript
|
||||
Remove JavaScript File Remover Arquivo JavaScript
|
||||
SVG Options Opções SVG
|
||||
SVG Profile: Perfil SVG:
|
||||
Same Igual
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -3009,6 +3009,16 @@ mark-style Japanese Японский
|
||||
Superscript: Верхний индекс:
|
||||
SVG Filters Фильтры SVG
|
||||
SVG Interactivity Интерактивность SVG
|
||||
Event: Событие:
|
||||
JavaScript: JavaScript:
|
||||
Add Event Добавить событие
|
||||
Remove Selected Entry Убрать выбранную запись
|
||||
Clear Events Очистить события
|
||||
Link JavaScript Files… Связать файлы JavaScript…
|
||||
JavaScript Files Файлы JavaScript
|
||||
File or URL: Файл или URL:
|
||||
Add JavaScript File Добавить файл JavaScript
|
||||
Remove JavaScript File Убрать файл JavaScript
|
||||
SVG Options Параметры SVG
|
||||
SVG Profile: Профиль SVG:
|
||||
Swap Fill and Stroke Поменять заливку и обводку
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -2935,6 +2935,16 @@
|
||||
Superscript: Upphöjd:
|
||||
SVG Filters SVG-filter
|
||||
SVG Interactivity SVG-interaktivitet
|
||||
Event: Händelse:
|
||||
JavaScript: JavaScript:
|
||||
Add Event Lägg till händelse
|
||||
Remove Selected Entry Ta bort markerad post
|
||||
Clear Events Rensa händelser
|
||||
Link JavaScript Files… Länka JavaScript-filer…
|
||||
JavaScript Files JavaScript-filer
|
||||
File or URL: Fil eller URL:
|
||||
Add JavaScript File Lägg till JavaScript-fil
|
||||
Remove JavaScript File Ta bort JavaScript-fil
|
||||
SVG Options SVG-alternativ
|
||||
SVG Profile: SVG-profil:
|
||||
Swap Fill and Stroke Byt plats på fyllning och kontur
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 5 and column 25.
|
@@ -3006,6 +3006,16 @@ effect Points Точки
|
||||
Superscript: Верхній індекс:
|
||||
SVG Filters Фільтри SVG
|
||||
SVG Interactivity Інтерактивність SVG
|
||||
Event: Подія:
|
||||
JavaScript: JavaScript:
|
||||
Add Event Додати подію
|
||||
Remove Selected Entry Прибрати вибраний запис
|
||||
Clear Events Очистити події
|
||||
Link JavaScript Files… Зв’язати файли JavaScript…
|
||||
JavaScript Files Файли JavaScript
|
||||
File or URL: Файл або URL:
|
||||
Add JavaScript File Додати файл JavaScript
|
||||
Remove JavaScript File Прибрати файл JavaScript
|
||||
SVG Options Параметри SVG
|
||||
SVG Profile: Профіль SVG:
|
||||
Swap Fill and Stroke Поміняти заливку та обведення
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 722 and column 40.
|
@@ -3349,6 +3349,16 @@ mark-style Japanese Kiểu Nhật
|
||||
Superscript: Chỉ số trên:
|
||||
SVG Filters Bộ lọc SVG
|
||||
SVG Interactivity Tương tác SVG
|
||||
Event: Sự kiện:
|
||||
JavaScript: JavaScript:
|
||||
Add Event Thêm sự kiện
|
||||
Remove Selected Entry Gỡ mục đã chọn
|
||||
Clear Events Xóa sự kiện
|
||||
Link JavaScript Files… Liên kết tệp JavaScript…
|
||||
JavaScript Files Tệp JavaScript
|
||||
File or URL: Tệp hoặc URL:
|
||||
Add JavaScript File Thêm tệp JavaScript
|
||||
Remove JavaScript File Gỡ tệp JavaScript
|
||||
SVG Options Tùy chọn SVG
|
||||
SVG Profile: Hồ sơ SVG:
|
||||
Swap Fill and Stroke Đổi màu tô và nét
|
||||
|
||||
|
@@ -2868,6 +2868,16 @@ mark-style Japanese 日式
|
||||
Superscript: 上标:
|
||||
SVG Filters SVG 滤镜
|
||||
SVG Interactivity SVG 互动性
|
||||
Event: 事件:
|
||||
JavaScript: JavaScript:
|
||||
Add Event 添加事件
|
||||
Remove Selected Entry 移除所选条目
|
||||
Clear Events 清除事件
|
||||
Link JavaScript Files… 链接 JavaScript 文件…
|
||||
JavaScript Files JavaScript 文件
|
||||
File or URL: 文件或 URL:
|
||||
Add JavaScript File 添加 JavaScript 文件
|
||||
Remove JavaScript File 移除 JavaScript 文件
|
||||
SVG Options SVG 选项
|
||||
SVG Profile: SVG 配置文件:
|
||||
Swap Fill and Stroke 交换填色和描边
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 2 and column 73.
|
@@ -2986,6 +2986,16 @@ mark-style Japanese 日式
|
||||
Superscript: 上標:
|
||||
SVG Filters SVG 濾鏡
|
||||
SVG Interactivity SVG 互動性
|
||||
Event: 事件:
|
||||
JavaScript: JavaScript:
|
||||
Add Event 新增事件
|
||||
Remove Selected Entry 移除選取的項目
|
||||
Clear Events 清除事件
|
||||
Link JavaScript Files… 連結 JavaScript 檔案…
|
||||
JavaScript Files JavaScript 檔案
|
||||
File or URL: 檔案或 URL:
|
||||
Add JavaScript File 新增 JavaScript 檔案
|
||||
Remove JavaScript File 移除 JavaScript 檔案
|
||||
SVG Options SVG 選項
|
||||
SVG Profile: SVG 描述檔:
|
||||
Swap Fill and Stroke 對調填色和筆畫
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 47.
|
@@ -630,6 +630,12 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[
|
||||
"",
|
||||
"{url?} open url, else the selection's URL (attributes.info), in the web browser (the Attributes panel's Browser button) → {url}",
|
||||
),
|
||||
(
|
||||
"ui.javascriptFilesDialog",
|
||||
"Link JavaScript Files…",
|
||||
"",
|
||||
"{} open JavaScript Files for the active document (dialog `javascriptFiles`, fields `file`: a path or URL and `selected`: a linked file; the SVG Interactivity panel's Link JavaScript Files button): its list shows svgInteractivity.info's files, Add runs svgInteractivity.addFile with file and Remove runs svgInteractivity.removeFile with selected",
|
||||
),
|
||||
(
|
||||
"ui.spotColors",
|
||||
"Spot Colors…",
|
||||
@@ -1537,6 +1543,7 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<R
|
||||
},
|
||||
"ui.expandDialog" => crate::dialogs::expand::open(app),
|
||||
"attributes.openUrl" => crate::panels::attributes::open_url(app, p),
|
||||
"ui.javascriptFilesDialog" => crate::panels::svg_interactivity::files_dialog(app, p),
|
||||
"ui.spotColors" => crate::dialogs::spot_colors::open(app),
|
||||
"ui.menuDialog" => match s("command").as_deref() {
|
||||
Some(c) if crate::dialogs::envelope::opens(c) => crate::dialogs::envelope::open(app, c),
|
||||
@@ -2886,7 +2893,7 @@ pub fn menu_tree_named(english_names: bool) -> Vec<(&'static str, Vec<Item>)> {
|
||||
panel("Properties", "properties"),
|
||||
panel("Separations Preview", "separations"),
|
||||
panel("Stroke", "stroke"),
|
||||
todo("SVG Interactivity"),
|
||||
panel("SVG Interactivity", crate::panels::svg_interactivity::ID),
|
||||
panel("Swatches", "swatches"),
|
||||
panel("Symbols", "symbols"),
|
||||
panel("Transform", "transform"),
|
||||
|
||||
@@ -37,6 +37,7 @@ pub mod pattern_options;
|
||||
pub mod properties;
|
||||
pub mod separations;
|
||||
pub mod stroke;
|
||||
pub mod svg_interactivity;
|
||||
pub mod swatches;
|
||||
pub mod symbols;
|
||||
pub mod tabs;
|
||||
@@ -171,6 +172,7 @@ pub fn show_icon_panel(app: &mut VectorcraftApp, ui: &mut Ui, id: &str) {
|
||||
"tabs" => tabs::show(app, ui),
|
||||
flattener_preview::ID => flattener_preview::show(app, ui),
|
||||
"attributes" => attributes::show(app, ui),
|
||||
svg_interactivity::ID => svg_interactivity::show(app, ui),
|
||||
variables::ID => variables::show(app, ui),
|
||||
"colorThemes" => color_themes::show(app, ui),
|
||||
links::ID => links::show(app, ui),
|
||||
@@ -218,6 +220,7 @@ pub fn panel_menu_items(app: &mut VectorcraftApp, ui: &mut Ui, id: &str) -> bool
|
||||
"libraries" => libraries::menu(app, ui),
|
||||
flattener_preview::ID => flattener_preview::menu(app, ui),
|
||||
"attributes" => attributes::menu(app, ui),
|
||||
svg_interactivity::ID => svg_interactivity::menu(app, ui),
|
||||
"colorThemes" => color_themes::menu(app, ui),
|
||||
"layers" => layers::menu(app, ui),
|
||||
links::ID => links::menu(app, ui),
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
//! SVG Interactivity panel (Window → SVG Interactivity): the JavaScript the selected objects run on
|
||||
//! each event in web output. Pick an Event, type its JavaScript and press Enter to add it
|
||||
//! (`svgInteractivity.setEvent`, which an empty field turns into a removal). The list shows the
|
||||
//! objects' events, and a click on one picks it to edit. Remove Selected Entry
|
||||
//! (`svgInteractivity.removeEvent`) and the panel menu's Clear Events (`svgInteractivity.clearEvents`)
|
||||
//! remove them; Link JavaScript Files… opens the JavaScript Files dialog (`ui.javascriptFilesDialog`).
|
||||
//! Everything reads `svgInteractivity.info`.
|
||||
|
||||
use egui::Ui;
|
||||
use serde_json::{Value, json};
|
||||
use vectorcraft_doc::{NodeId, SVG_EVENTS};
|
||||
|
||||
use super::{pstate, selection_len, set_pstate};
|
||||
use crate::VectorcraftApp;
|
||||
use crate::widgets::{self, menu_item};
|
||||
|
||||
pub const ID: &str = "svgInteractivity";
|
||||
/// Panel state: the event picked (an entry of [`SVG_EVENTS`]).
|
||||
const EVENT: &str = "svgi-event";
|
||||
const LABEL_W: f32 = 70.0;
|
||||
|
||||
/// The panel's values: whether there are targets, and their events (`None` where they differ).
|
||||
#[derive(Clone, Debug, Default)]
|
||||
struct Info {
|
||||
has: bool,
|
||||
events: Option<Vec<(String, String)>>,
|
||||
}
|
||||
|
||||
/// `svgInteractivity.info`, read again only when the document or the selection changes.
|
||||
fn state(app: &mut VectorcraftApp, ctx: &egui::Context) -> Info {
|
||||
type Key = (u64, u64, usize, Option<NodeId>, Option<NodeId>);
|
||||
let Some(st) = app.session.active() else { return Info::default() };
|
||||
let sel = &st.selection.objects;
|
||||
let key: Key = (st.uid, st.revision, sel.len(), sel.first().copied(), sel.last().copied());
|
||||
let id = egui::Id::new("svgi-state");
|
||||
if let Some((k, v)) = ctx.data(|d| d.get_temp::<(Key, Info)>(id))
|
||||
&& k == key
|
||||
{
|
||||
return v;
|
||||
}
|
||||
let r = app.session.execute("svgInteractivity.info", &json!({})).unwrap_or_default();
|
||||
let events = r["events"]
|
||||
.as_array()
|
||||
.map(|a| a.iter().filter_map(|e| Some((e["event"].as_str()?.to_string(), e["script"].as_str()?.to_string()))).collect::<Vec<_>>());
|
||||
let v = Info { has: r["ids"].as_array().is_some_and(|a| !a.is_empty()), events };
|
||||
ctx.data_mut(|d| d.insert_temp(id, (key, v.clone())));
|
||||
v
|
||||
}
|
||||
|
||||
/// The event picked in the panel (the first of [`SVG_EVENTS`] until one is).
|
||||
fn picked(ctx: &egui::Context) -> &'static str {
|
||||
let e: String = pstate(ctx, EVENT);
|
||||
SVG_EVENTS.iter().find(|n| **n == e).copied().unwrap_or(SVG_EVENTS[0])
|
||||
}
|
||||
|
||||
pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
let ctx = ui.ctx().clone();
|
||||
let info = state(app, &ctx);
|
||||
let event = picked(&ctx);
|
||||
let events = info.events.clone().unwrap_or_default();
|
||||
widgets::label_row(ui, tl!("Event:"), LABEL_W, |ui| {
|
||||
ui.add_enabled_ui(info.has, |ui| {
|
||||
let width = (ui.available_width() - 4.0).max(80.0);
|
||||
if let Some(i) = widgets::dropdown_names(ui, "svgi-event", event, &SVG_EVENTS, width) {
|
||||
set_pstate(&ctx, EVENT, SVG_EVENTS.get(i).copied().unwrap_or(event).to_string());
|
||||
}
|
||||
});
|
||||
});
|
||||
// Blank where the objects' events differ; empty where none of them has the event.
|
||||
let script = info.events.as_ref().map(|l| l.iter().find(|(e, _)| e == event).map_or("", |(_, js)| js.as_str()));
|
||||
widgets::label_row(ui, tl!("JavaScript:"), LABEL_W, |ui| {
|
||||
ui.add_enabled_ui(info.has, |ui| {
|
||||
let width = (ui.available_width() - 4.0).max(80.0);
|
||||
if let Some(js) = widgets::text_field(ui, ("svgi-script", event), script, width, 1) {
|
||||
app.run("svgInteractivity.setEvent", json!({ "event": event, "script": js })).ok();
|
||||
}
|
||||
});
|
||||
});
|
||||
ui.add_space(4.0);
|
||||
widgets::list_box(ui, |ui| {
|
||||
egui::ScrollArea::vertical().id_salt("svgi-events").max_height(160.0).auto_shrink([false, true]).show(ui, |ui| {
|
||||
ui.set_min_height(90.0);
|
||||
for (e, js) in &events {
|
||||
// Event names and scripts are data: shown as they are.
|
||||
if ui.selectable_label(e == event, format!("{e}: {js}")).clicked() {
|
||||
set_pstate(&ctx, EVENT, e.clone());
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
if !info.has {
|
||||
widgets::dim_label(ui, if selection_len(app) == 0 { tl!("No Selection") } else { "" });
|
||||
}
|
||||
let has_doc = app.session.active().is_some();
|
||||
let listed = events.iter().any(|(e, _)| e == event);
|
||||
let (mut link, mut remove) = (false, false);
|
||||
widgets::bottom_bar(ui, |ui| {
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
remove = widgets::icon_button_enabled(ui, "trash-2", tl!("Remove Selected Entry"), false, listed, 24.0).clicked();
|
||||
link = widgets::icon_button_enabled(ui, "file-plus", tl!("Link JavaScript Files…"), false, has_doc, 24.0).clicked();
|
||||
});
|
||||
});
|
||||
if remove {
|
||||
app.run("svgInteractivity.removeEvent", json!({ "event": event })).ok();
|
||||
}
|
||||
if link {
|
||||
crate::menus::invoke(app, "ui.javascriptFilesDialog", json!({}));
|
||||
}
|
||||
}
|
||||
|
||||
/// The panel (≡) menu: Clear Events.
|
||||
pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) {
|
||||
let info = state(app, ui.ctx());
|
||||
// (Where the objects' events differ, some of them have events.)
|
||||
let any = info.has && info.events.as_ref().is_none_or(|l| !l.is_empty());
|
||||
if menu_item(ui, tl!("Clear Events"), any, false) {
|
||||
app.run("svgInteractivity.clearEvents", json!({})).ok();
|
||||
}
|
||||
}
|
||||
|
||||
/// `ui.javascriptFilesDialog {}`: open the JavaScript Files dialog for the active document.
|
||||
pub(crate) fn files_dialog(app: &mut VectorcraftApp, _p: &Value) -> Result<Value, String> {
|
||||
crate::dialogs::javascript_files::open(app)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use vectorcraft_engine::Session;
|
||||
|
||||
/// Run the panel and its menu for one headless frame of `ctx`: the texts drawn.
|
||||
fn frame(app: &mut VectorcraftApp, ctx: &egui::Context) -> Vec<String> {
|
||||
fn texts(s: &egui::Shape, out: &mut Vec<String>) {
|
||||
match s {
|
||||
egui::Shape::Text(t) => out.push(t.galley.text().to_string()),
|
||||
egui::Shape::Vec(v) => v.iter().for_each(|s| texts(s, out)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let mut out = ctx.run_ui(egui::RawInput::default(), |ui| {
|
||||
show(app, ui);
|
||||
menu(app, ui);
|
||||
});
|
||||
out.textures_delta.clear();
|
||||
let mut v = vec![];
|
||||
out.shapes.iter().for_each(|c| texts(&c.shape, &mut v));
|
||||
v
|
||||
}
|
||||
|
||||
fn run(app: &mut VectorcraftApp, id: &str, p: Value) -> Value {
|
||||
app.session.execute(id, &p).unwrap()
|
||||
}
|
||||
|
||||
/// Window › SVG Interactivity opens the panel, as Window's other panel items do.
|
||||
#[test]
|
||||
fn window_svg_interactivity_opens_the_panel() {
|
||||
let app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
let entries = crate::menus::menu_entries(&app);
|
||||
let panel = entries
|
||||
.iter()
|
||||
.find(|e| e.command.as_deref() == Some("window.panel") && e.params.get("panel").and_then(Value::as_str) == Some(ID))
|
||||
.expect("the SVG Interactivity panel is listed");
|
||||
assert_eq!((panel.path.clone(), panel.label.as_str(), panel.enabled), (vec!["Window".to_string()], "SVG Interactivity", true));
|
||||
}
|
||||
|
||||
// (Only names and scripts are checked: they are shown as they are, while tests running
|
||||
// alongside change the language the labels are drawn in.)
|
||||
#[test]
|
||||
fn panel_lists_the_selections_events() {
|
||||
let mut app = VectorcraftApp::new(Session::new(), Default::default());
|
||||
let ctx = egui::Context::default();
|
||||
run(&mut app, "file.new", json!({"width": 100, "height": 100}));
|
||||
let texts = frame(&mut app, &ctx);
|
||||
assert!(texts.iter().any(|t| t == "onfocusin"), "the first event is picked: {texts:?}");
|
||||
run(&mut app, "shape.rectangle", json!({"x": 0, "y": 0, "width": 50, "height": 50}));
|
||||
run(&mut app, "svgInteractivity.setEvent", json!({"event": "onclick", "script": "alert(1)"}));
|
||||
run(&mut app, "svgInteractivity.setEvent", json!({"event": "onmouseover", "script": "hover()"}));
|
||||
let texts = frame(&mut app, &ctx);
|
||||
for want in ["onclick: alert(1)", "onmouseover: hover()"] {
|
||||
assert!(texts.iter().any(|t| t == want), "{want} in {texts:?}");
|
||||
}
|
||||
assert!(!texts.iter().any(|t| t == "alert(1)"), "{texts:?}");
|
||||
// With an event picked, the field shows its script.
|
||||
set_pstate(&ctx, EVENT, "onclick".to_string());
|
||||
let texts = frame(&mut app, &ctx);
|
||||
assert!(texts.iter().any(|t| t == "onclick") && texts.iter().any(|t| t == "alert(1)"), "{texts:?}");
|
||||
// Two objects whose events differ show none.
|
||||
let b = run(&mut app, "shape.ellipse", json!({"x": 60, "y": 0, "width": 30, "height": 30}))["id"].clone();
|
||||
let a = app.session.active().unwrap().doc.layers[0].children().unwrap()[0].id.0;
|
||||
run(&mut app, "select.set", json!({"ids": [a, b]}));
|
||||
let texts = frame(&mut app, &ctx);
|
||||
assert!(!texts.iter().any(|t| t.starts_with("onclick:") || t == "alert(1)"), "{texts:?}");
|
||||
}
|
||||
}
|
||||
@@ -152,6 +152,7 @@ pub const ICON_PANELS: &[(&str, &str, &str)] = &[
|
||||
("magicWand", "Magic Wand", "wand-sparkles"),
|
||||
(crate::panels::flattener_preview::ID, "Flattener Preview", "eye"),
|
||||
("attributes", "Attributes", "settings"),
|
||||
(crate::panels::svg_interactivity::ID, "SVG Interactivity", "spline-pointer"),
|
||||
("colorThemes", "Color Themes", "sun"),
|
||||
(crate::panels::links::ID, "Links", "link"),
|
||||
(crate::panels::asset_export::ID, "Asset Export", "share-2"),
|
||||
|
||||
@@ -409,6 +409,12 @@ Attributes panel: `window.panel {panel: "attributes"}` (Cmd+F11) shows it; its c
|
||||
`path.reverse {reversed}` and `path.setFillRule {rule}`, and its Browser button runs the UI command
|
||||
`attributes.openUrl {url?}`, which opens the URL (default: the selection's) in the web browser and answers `{url}`.
|
||||
|
||||
SVG Interactivity panel: `window.panel {panel: "svgInteractivity"}` shows it; its controls run
|
||||
`svgInteractivity.setEvent`, `svgInteractivity.removeEvent` and `svgInteractivity.clearEvents`, and its Link JavaScript
|
||||
Files… button runs the UI command `ui.javascriptFilesDialog`, which opens the `javascriptFiles` dialog (fields `file`
|
||||
and `selected`). The dialog's Add and Remove buttons run `svgInteractivity.addFile {file}` and
|
||||
`svgInteractivity.removeFile {file}` at once, and `ui.dialog.confirm` closes it.
|
||||
|
||||
Spot Colors: the Swatches panel menu's Spot Colors… (`ui.spotColors`) opens the `spotColors` dialog (field `useLab`:
|
||||
true shows and separates spot colours defined in Lab from their Lab values, false from their working-CMYK
|
||||
equivalents); `ui.dialog.confirm` runs `swatch.spotOptions {useLab}` as one undo step.
|
||||
|
||||
+22
@@ -1743,6 +1743,28 @@ paths and compound paths, and `path.reverse {reversed?}` makes subpaths run coun
|
||||
{"name":"run_command","arguments":{"command":"path.setFillRule","params":{"rule":"evenOdd"}}}
|
||||
```
|
||||
|
||||
## SVG interactivity
|
||||
|
||||
The SVG Interactivity panel (`window.panel {panel: "svgInteractivity"}`) reads `svgInteractivity.info {ids?}`:
|
||||
`events` (`[{event, script}]` in the panel's order, null where the objects differ), `files` (the linked JavaScript
|
||||
files) and `eventNames` (the 19 events the panel lists, from `onfocusin` to `onscroll`).
|
||||
`svgInteractivity.setEvent {event, script, ids?}` sets the JavaScript an event runs on the objects, replacing the
|
||||
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 {}`
|
||||
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
|
||||
`<script>` links back as text; it never reads a script file or runs a script, and leaves inline scripts and the links
|
||||
after the first 64 out with a warning. An unnamed `<g>` with event attributes of its own stays a group, apart from the object in
|
||||
it. The native format keeps the events and the files.
|
||||
|
||||
```json
|
||||
{"name":"run_command","arguments":{"command":"svgInteractivity.setEvent","params":{"ids":[12],"event":"onclick","script":"showMenu()"}}}
|
||||
{"name":"run_command","arguments":{"command":"svgInteractivity.addFile","params":{"file":"menu.js"}}}
|
||||
```
|
||||
|
||||
## Removing anchor points
|
||||
|
||||
`path.removeAnchors {}` (Object › Path › Remove Anchor Points) removes the direct-selected anchors (`select.anchors
|
||||
|
||||
@@ -430,6 +430,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
|
||||
- **UI:** Illustrator 2026 layout restyled to measured values:
|
||||
- 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.*`).
|
||||
- Numeric fields keep the pointer's pressed character as the text-drag start when movement exceeds the click threshold (#1326, in part); 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. Short movements still counted as clicks can select all; that case remains open.
|
||||
- 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 and the Artboard tool show it).
|
||||
|
||||
@@ -214,6 +214,9 @@ panel-menu commands such as Break Link to Symbol), so they can be given shortcut
|
||||
| Pathfinder panel on one group selected alone; a greyed-out button says what it needs | partial | #1450; the group's members are combined; one plain path selected alone stays disabled (the public documentation doesn't say what the operations do to a single path) |
|
||||
| Pathfinder Divide: an open cutting line splits a filled path | done | #1307; straight, crossed, bent and curved cutters, holes and frontmost appearance regression-tested; native automation: independent piece movement, one Undo/Redo, saved-document round trip and window capture; existing bounded-map fallback remains |
|
||||
| Canvas context menu | done | Layers rows, panels and type have none (gap) |
|
||||
| Blend panel (30.7) | missing | Blend Options dialog exists |
|
||||
| SVG Interactivity panel: JavaScript for each object's events and the document's linked JavaScript files, written to SVG and read back as text | done | `svgInteractivity.*`, `ui.javascriptFilesDialog` |
|
||||
| Retype, Comments, Version History panels | missing | the last two are cloud features (out of scope) |
|
||||
| Blend panel (30.7) | partial | Window › Blend: Make, Spacing, Orientation, Reverse Front to Back, Reverse Spine, Replace Spine, Expand and Release, running the Object › Blend commands; the easing and color controls are missing |
|
||||
| SVG Interactivity, Retype, Comments, Version History panels | missing | the last two are cloud features (out of scope) |
|
||||
| Workspaces (save, reset, switch) | done | |
|
||||
|
||||
Reference in New Issue
Block a user