Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 35 additions & 6 deletions whtree/modules/tollium/lib/components/svg.whlib
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
<?wh

LOADLIB "wh::crypto.whlib";
LOADLIB "wh::datetime.whlib";
LOADLIB "wh::files.whlib";
LOADLIB "wh::float.whlib";

Expand All @@ -17,6 +18,7 @@ PUBLIC OBJECTTYPE TolliumSVGImage EXTEND TolliumComponentBase <
//

STRING __svg_source;
STRING __res_url;

RECORD __css_vars;
STRING css_vars_hash;
Expand Down Expand Up @@ -86,18 +88,27 @@ PUBLIC OBJECTTYPE TolliumSVGImage EXTEND TolliumComponentBase <
UPDATE PUBLIC MACRO TolliumWebRender() {
IF (this->dirtyflags.fully) {
RECORD compinfo := CELL[
src := this->svg_source,
vars := this->css_vars,
this->hint,
unmasked_events := GetUnmask(this, ["click"]),
action := GetComponentName(this->__action),
this->opacity,
];
IF (this->__res_url != "")
INSERT CELL url := this->__res_url INTO compinfo;
ELSE
INSERT CELL src := this->svg_source INTO compinfo;

this->owner->tolliumcontroller->SendComponent(this, compinfo);
} ELSE {
IF (this->dirtyflags.source)
this->ToddUpdate([ type := "source", src := this->svg_source ]);
IF (this->dirtyflags.source) {
RECORD compinfo := [ type := "source" ];
IF (this->__res_url != "")
INSERT CELL url := this->__res_url INTO compinfo;
ELSE
INSERT CELL src := this->svg_source INTO compinfo;
this->ToddUpdate(compinfo);
}

IF (this->dirtyflags.vars)
this->ToddUpdate([ type := "vars", vars := this->css_vars ]);
Expand Down Expand Up @@ -143,9 +154,10 @@ PUBLIC OBJECTTYPE TolliumSVGImage EXTEND TolliumComponentBase <
RETURN "100px";
}

MACRO SetSVGSource(STRING svg_source) {
IF (this->__svg_source != svg_source) {
MACRO SetSVGSource(STRING svg_source, STRING res_url DEFAULTSTO "") {
IF (this->__svg_source != svg_source OR res_url != this->__res_url) {
this->__svg_source := svg_source;
this->__res_url := res_url;
this->ExtUpdatedSource();
}
}
Expand Down Expand Up @@ -232,6 +244,14 @@ PUBLIC OBJECTTYPE TolliumSVGImage EXTEND TolliumComponentBase <
// Public API
//

/** Load an svg by absolute resource path
@long This loads an svg using an absolute resource path (eg. `mod::mymodule/web/img/image.svg`). Only SVG files are
supported. When an empty string is given, the component is cleared.

The SVG is sent as raw xml to the browser, unless the given resource is a public module asset (eg.
`mod::mymodule/public/image.svg`), in which case it is fetched client-side.
@param resource The resource to load
*/
PUBLIC MACRO LoadResource(STRING resource) {
IF (resource = "") {
this->svg_source := "";
Expand All @@ -240,6 +260,15 @@ PUBLIC OBJECTTYPE TolliumSVGImage EXTEND TolliumComponentBase <
RECORD svg := WrapBlob(RetrieveWebHareResource(resource).data, GetNameFromPath(resource));
IF (svg.mimetype != "image/svg+xml")
THROW NEW TolliumException(this, `Only SVG resources can be loaded in an <svg> component`);
this->svg_source := BlobToString(svg.data);
// Only fetch public module assets client-side; other resource urls (eg. webdesign web/ folders) aren't always served
STRING res_url;
IF (resource LIKE "mod::?*/public/*") {
res_url := GetModuleResourceURL(resource);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

you're going to check for existence in the next line of code, but if that fails you'll still send a URL to a resource you now know will trigger a 404 (and triggers dynamic page work)

// Add the modification time to the url, so the browser refetches the svg when it's changed on disk
RECORD props := GetDiskFileProperties(GetWebHareResourceDiskPath(resource));
IF (res_url != "" AND RecordExists(props))
res_url := res_url || "?mtime=" || GetUnixTimestamp(props.modified);
}
this->SetSVGSource(BlobToString(svg.data), res_url);
}
>;
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,22 @@ declare global {
}

interface SVGAttributes extends ActionableAttributes {
src: string;
// Raw SVG source (not set if url is set)
src?: string;
// URL to fetch the SVG from (eg. a public module asset)
url?: string;
vars?: Record<string, string>;
opacity?: number;
}

type SVGUpdate = {
type: "source";
src: string;
url?: never;
} | {
type: "source";
url: string;
src?: never;
} | {
type: "opacity";
opacity: number;
Expand All @@ -37,6 +45,9 @@ export class ObjSVGImage extends ActionableComponent<SVGAttributes> {
componenttype = "svg";
private clickable = false;
private svgSrc = "";
private svgUrl = "";
// Incremented on every source change, so a slow fetch doesn't overwrite a newer source
private sourceGeneration = 0;
private opacity = 1;
private wrapperNode!: HTMLDivElement;
// Shadow root holding the SVG, so its <style> elements and ids don't leak into (or collide with) the rest of the document
Expand Down Expand Up @@ -130,20 +141,46 @@ export class ObjSVGImage extends ActionableComponent<SVGAttributes> {
this.node.append(this.wrapperNode);
}

private updateNode(data: { src?: string; opacity?: number }) {
if (data.src !== undefined && data.src !== this.svgSrc) {
const svgNode = this.importSVG(data.src);
if (svgNode)
this.svgRoot.replaceChildren(svgNode);
else
this.svgRoot.replaceChildren();
private updateNode(data: { src?: string; url?: string; opacity?: number }) {
if (data.url) {
if (data.url !== this.svgUrl) {
const url = data.url;
const generation = ++this.sourceGeneration;
this.svgUrl = url;
this.loadSVGFromURL(url, generation).catch(e => {
console.error(`Could not load SVG from '${url}'`, e);
if (generation === this.sourceGeneration)
this.showSVG("");
});
}
} else if (data.src !== undefined && (data.src !== this.svgSrc || this.svgUrl)) {
++this.sourceGeneration;
this.svgUrl = "";
this.showSVG(data.src);
}
if (data.opacity !== undefined && data.opacity !== this.opacity) {
this.opacity = data.opacity;
this.wrapperNode.style.opacity = String(this.opacity);
}
}

private showSVG(src: string) {
const svgNode = this.importSVG(src);
if (svgNode)
this.svgRoot.replaceChildren(svgNode);
else
this.svgRoot.replaceChildren();
}

private async loadSVGFromURL(url: string, generation: number) {
const response = await fetch(url);
if (!response.ok)
throw new Error(`HTTP status ${response.status}`);
const src = await response.text();
if (generation === this.sourceGeneration) // Ignore if the source was changed while we were fetching
this.showSVG(src);
}

private updateCSSVars(cssVars?: Record<string, string>) {
// Remove existing variables (i.e. all explicitly set custom properties on the wrapper node) that don't have a new value
// Iterating a CSSStyleDeclaration yields its set property names (incl. custom properties); copy as we're modifying it
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -602,6 +602,11 @@ PUBLIC OBJECTTYPE SVGTest EXTEND TolliumScreenBase
^svg->onclick := DEFAULT FUNCTION PTR;
}

MACRO DoLoadPublic()
{
^svg->LoadResource("mod::webhare_testsuite/public/img/svgtest.svg");
}

MACRO DoActionClick()
{
this->AddLog("action");
Expand Down
4 changes: 4 additions & 0 deletions whtree/modules/webhare_testsuite/public/img/svgtest.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
Expand Up @@ -471,6 +471,7 @@
<action name="updateopacity" onexecute="doupdateopacity" title="S03 Update opacity" />
<action name="clearaction" onexecute="doclearaction" title="S04 Clear action" />
<action name="clearonclick" onexecute="doclearonclick" title="S05 Clear onclick" />
<action name="loadpublic" onexecute="doloadpublic" title="S06 Load public resource" />
<action name="actionclick" onexecute="doactionclick" title="Click" />
</actions>

Expand All @@ -481,6 +482,7 @@
<item action="updateopacity" />
<item action="clearaction" />
<item action="clearonclick" />
<item action="loadpublic" />
</menu>
</menus>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -110,5 +110,29 @@ test.runTests(
test.click(test.qR(getSVGRoot(), "#dot"));
await test.waitForUI();
test.eq(/^action\nclick box \d+ \d+\nclick dot \d+ \d+$/, getLog(), "Clicking should not have logged anything");
},

"Load public resource by url",
async function () {
test.click(test.getMenu(["S06"]));
await test.waitForUI();
// The SVG is fetched asynchronously after the update is applied
const triangle = await test.wait(() => getSVGRoot().querySelector<SVGElement>("svg #triangle"));
test.assert(!getSVGRoot().querySelector("#dot"), "The old SVG should have been replaced");
test.eq("rgb(0, 0, 255)", getComputedStyle(triangle).fill, "CSS variables should apply to an SVG loaded by url");

const win = getSVGComp().ownerDocument.defaultView!;
const fetched = win.performance.getEntriesByType("resource").map(entry => new URL(entry.name)).find(url => url.pathname === "/.wh/mod/webhare_testsuite/public/img/svgtest.svg");
test.assert(fetched, "The SVG should have been fetched from its public url");
test.eq(/^\d+$/, fetched.searchParams.get("mtime") ?? "", "The url should contain the modification time for cache busting");
},

"Switch from url back to the same source",
async function () {
// S02 sets the same source as before; it should still replace the SVG loaded by url
test.click(test.getMenu(["S02"]));
await test.waitForUI();
test.assert(!getSVGRoot().querySelector("#triangle"), "The SVG loaded by url should have been replaced");
test.assert(getSVGRoot().querySelector("svg #dot"));
}
]);
Loading