mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-25 02:07:09 -06:00
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:
@@ -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