Merge pull request #2437 from dotCooCoo/feat/svg-interactivity

Window: the SVG Interactivity panel attaches JavaScript events and files for SVG export
This commit is contained in:
BFlat
2026-10-17 15:53:20 +07:00
committed by GitHub
40 changed files with 1294 additions and 9 deletions
+6 -1
View File
@@ -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(),
+33
View File
@@ -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")]
+191
View File
@@ -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)
}
+2
View File
@@ -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());
+2
View File
@@ -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;
+136
View File
@@ -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(&quot;menu&quot;) &amp;&amp; x &lt; 1\""), "{svg}");
assert!(svg.contains("<script xlink:href=\"menu.js\"/>"), "{svg}");
}
+4
View File
@@ -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}>"));
+36 -1
View File
@@ -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]))
+82 -6
View File
@@ -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
}
}
+16
View File
@@ -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 {
+176
View File
@@ -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}");
}
}
+2
View 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,
+10
View File
@@ -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.
+10
View File
@@ -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.
+10
View File
@@ -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.
+10
View File
@@ -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.
+10
View File
@@ -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.
+10
View File
@@ -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.
+10
View File
@@ -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.
+10
View File
@@ -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.
+10
View File
@@ -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.
+10
View File
@@ -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.
+10
View File
@@ -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.
+10
View File
@@ -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.
+10
View File
@@ -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.
+10
View File
@@ -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.
+10
View File
@@ -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.
+10
View File
@@ -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.
+10
View File
@@ -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
1 # Vietnamese (vi) catalog. Same format as zh-hant.tsv: context <TAB> source <TAB> translation
3349
3350
3351
3352
3353
3354
3355
3356
3357
3358
3359
3360
3361
3362
3363
3364
+10
View File
@@ -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.
+10
View File
@@ -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.
+8 -1
View File
@@ -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"),
+3
View File
@@ -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:?}");
}
}
+1
View File
@@ -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"),
+6
View File
@@ -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
View File
@@ -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
+1
View File
@@ -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).
+3
View File
@@ -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 | |