I have written a Typescript-based custom web element which gets data from an input property, and uses it to draw SVG elements.
Essentially, the component has an svg
element with some preset children in its template, and in its constructor it gets a reference to this svg
element. Whenever it gets data from its snapshot
property, it calls a render()
function, which uses these data to build more SVG elements and add them as children of either g#base
or g#operations
:
// custom web element
const template = document.createElement("template");
template.innerHTML = `
<svg id="snapshot">
<g id="base"></g>
<g id="operations"></g>
</svg>
`;
export class SnapshotViewComponent extends HTMLElement {
private readonly _svg: SVGSVGElement;
private _snapshot?: Snapshot;
public get snapshot(): Snapshot | undefined {
return this._snapshot;
}
public set snapshot(value: Snapshot | undefined | null) {
if (this._snapshot === value) {
return;
}
this._snapshot = value || undefined;
this.render();
}
constructor() {
super();
this.attachShadow({ mode: "open" });
this.shadowRoot?.appendChild(template.content.cloneNode(true));
this._svg = this.shadowRoot?.querySelector<SVGSVGElement>("#snapshot")!;
}
// ...
}
// register the custom element
customElements.define("gve-snapshot-view", SnapshotViewComponent);
Among the SVG elements, some are positioned one next to the other, using getBBox()
to measure them for this purpose. The component creates and measures SVG elements like in this code snippet (in a loop):
// custom web element logic
// create a text element for the character appending it to the g element
const text = document.createElementNS(SVG_NS, "text") as SVGTextElement;
text.textContent = char.data;
text.setAttribute("id", id);
text.setAttribute("x", x.toString());
text.setAttribute("y", y.toString());
text.setAttribute("dominant-baseline", "hanging");
g.appendChild(text);
// add style and class features to the text element
const { cstyle, cclass } = this.addStyleAndClassFromFeatures(
text,
char.features
);
// measure the bounding box of the text element
const bbox = text.getBBox();
// use bbox to place characters one after another...
This works perfectly in a vanilla HTML page like this:
<!-- sample HTML host page -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<!-- import the web element -->
<script type="module" src="./dist/index.js"></script>
<!-- import the consumer code -->
<script type="module" src="consumer.js"></script>
</head>
<body>
<!-- the custom web element -->
<gve-snapshot-view></gve-snapshot-view>
</body>
</html>
The consumer JS code sets data and I can see them displayed as expected:
// consumer code for the element in HTML
document.addEventListener("DOMContentLoaded", (event) => {
const snapshot = { /* ... data ... */ };
let component = document.querySelector("gve-snapshot-view");
if (!component) {
console.error("Component not found");
} else {
component.snapshot = snapshot;
}
});
I am then packing this component with NPM and importing it into an Angular 18 application. There, an Angular component is in charge of letting users edit data modeled as a Snapshot
; the web element is bound to edited data, and whenever this edited data changes, it must render its SVG. So:
- I install the NPM packaged component in the Angular app.
- I add
schemas: [CUSTOM_ELEMENTS_SCHEMA]
to the component’sschemas
(this is a module-less Angular 18 app) to allow for custom elements in its template. - I add in its template the custom web element selector with data binding:
<gve-snapshot-view [snapshot]="snapshot" />
where snapshot
is a public member of the Angular component, and gets replaced with a new object instance whenever there is an update.
Now I can see from the log that the web element receives the data (snapshot
in code) and builds the SVG elements as expected; BUT the problem is that whenever it calls getBBox()
on a built element, like in the above example for text
, this returns a 0 size. This causes all the SVG elements to be displayed one on top of the other.
This code works in the vanilla HTML page, but not in Angular. So I assume that Angular or zone are interfering with the web element interactions with the DOM. Probably the SVG elements added to the web element’s DOM are not yet rendered when they are measured with getBBox()
. This does not happen in the test HTML page, but it happens in Angular, maybe because of zone. As a quick fix attempt, I tried to wrap the update of snapshot
in the host Angular component within a runOutsideAngular
function, like this:
// Angular host component
@Input()
public get snapshot(): Snapshot | undefined {
return this._snapshot;
}
public set snapshot(value: Snapshot | undefined | null) {
if (this._snapshot === value) {
return;
}
// set data outside zone
this._zone.runOutsideAngular(() => {
this._snapshot = value || undefined;
});
this.updateForm(this._snapshot);
}
Now, whenever I update data, I just do this.snapshot = newData;
to trigger this. Anyway, nothing changed, and I still get 0-sized bounding boxes.
So I wonder about the best strategy to work around this. The reason for using a vanilla web element outside of Angular is right to have a lighter component which just deals with the DOM in its own template, which should be a black box for the Angular host. Not only this would be much more complex in Angular, where touching the DOM is usually not the way to go; but I have also the requirement of providing this rendition logic as a custom web element, because other clients will be consuming it (e.g. vanilla JS, Blazor, React, etc.). So, having a custom web element allows me to keep all the DOM manipulations inside its black box, and reuse it in many different frameworks.
On the Angular side, I would like a solution which does not burdens the web element with complex hacks just to let it work seamlessly in that environment. For instance:
-
I know of SVG helper libraries like svg.js, but this being an Angular-related timing issue I am not sure whether it will be of help in this case, nor I want to add a third-party dependency just to create SVG elements unless I am forced to.
-
if this is a timing issue, and I have somehow to delay the measurement of newly created SVG elements to a time when they are effectively rendered, this would require refactoring all the inner logic of the component; but even in this case, how can I be sure about the right time for measuring SVG elements? I can think of placing the logic inside a
requestAnimationFrame
, which should delay what’s inside it until the next frame, when hopefully the SVG elements will be rendered; or usingMutationObserver
on the children of the web element’ssvg
element. In any case, this would require the component logic to be split: no longer a single loop which adds character by character astext
SVG elements, but first adding them, then wait somehow, and then move them. In turn, this also implies some trick to avoid flickering or letting users see a messed up text before its characters are moved to the right positions, like e.g. setting their initial opacity to 0, and then restoring it back later when they are positioned.
Of course, none of these approaches is ideal, but before trying any I am open to suggestions if anyone has experience in consuming (not building) custom web elements in Angular.