HTML inside shapes
In the wonderful world of SVG elements, foreignObject
stands out from the rest as it provides some unique behavior that other SVG elements do not. SVG, like other XML dialects, is namespaced. That means if you want to include elements from a different XML namespace right in your SVG, you need some mechanism to enable this functionality. That's where foreignObject
comes into play. How does this relate to creating diagrams with JointJS? While JointJS provides a lot of functionality and customization for users, we as developers are a habitual bunch. If we want to create some basic interactive elements, we often want to reach for technologies we are already familiar with, and in web land, the dominant force in this regard is still good old HTML.
Historically, recommending foreignObject
to users of JointJS was a little bit tricky, the main reason for this was the inherent lack of support for foreignObject
in Internet Explorer. Due to the necessity of supporting all our users, including those who were still using IE, there was a hesitancy about integrating support into the library fully. Luckily for us, the days in which we had to support IE are now at an end, and all other major browsers have full support, as illustrated by this caniuse reference table.
Now, that we can more confidently recommend the use of foreignObject
with JointJS, embedding HTML text in SVG, creating basic interactive elements like buttons, or working with HTML inputs should be a more straightforward process. With this addition, combining the power of JointJS with foreignObject
opens up a world of possibilities for interactivity within your JointJS diagrams.
In order to get a general idea of how foreignObject
can be utilized in SVG, let's start with a simple example without using JointJS.
Using foreignObjectβ
In the following example, a foreignObject
element is placed within our SVG tag. Note that when using HTML elements within foreignObject
, it's mandatory to include the XHTML namespace on the outermost or root element. In this instance, that is our div
element. This example creates a simple card-like SVG rect
element where the user is easily able to select the text.
<svg version="1.1" width="300" height="130" xmlns="http://www.w3.org/2000/svg">
<style>
div.card__background {
background-color: #131e29;
height: 100%;
}
p.card__text {
color: #F2F2F2;
font: 16px sans-serif;
padding: 10px;
margin: 0;
}
rect.card { fill: url(#gradient); }
.stop1 { stop-color: #ff5c69; }
.stop2 { stop-color: #ff4252; }
.stop3 { stop-color: #ed2637; }
</style>
<defs>
<linearGradient id="gradient">
<stop class="stop1" offset="0%" />
<stop class="stop2" offset="50%" />
<stop class="stop3" offset="100%" />
</linearGradient>
</defs>
<rect x="20" y="20" width="180" height="100" class="card" />
<foreignObject x="26" y="26" width="168" height="88">
<div xmlns="http://www.w3.org/1999/xhtml" class="card__background">
<p class="card__text">Lorem ipsum dolor sit amet, consectetur adipiscing elit.</p>
</div>
</foreignObject>
</svg>
The SVG markup above produces the following element:
Now that we have a better understanding of what foreignObject
is all about, how would we create an equivalent example with JointJS? Using foreignObject
with JointJS is much the same as creating any custom shape
which you can see in the following code example.
import { dia, shapes } from "@joint/core";
const graph = new dia.Graph({}, { cellNamespace: shapes });
const paper = new dia.Paper({
// paper options
preventDefaultViewAction: false
});
const Card = dia.Element.define('example.ForeignObject', {
attrs: {
body: {
width: 'calc(w)',
height: 'calc(h)',
fill: {
type: 'linearGradient',
stops: [
{ offset: 0, color: '#ff5c69' },
{ offset: 0.5, color: '#ff4252' },
{ offset: 1, color: '#ed2637' }
]
}
},
foreignObject: {
width: 'calc(w-12)',
height: 'calc(h-12)',
x: 6,
y: 6
}
},
}, {
markup: [
{
tagName: 'rect',
selector: 'body'
},
{
tagName: 'foreignObject',
selector: 'foreignObject',
children: [
{
tagName: 'div',
namespaceURI: 'http://www.w3.org/1999/xhtml',
selector: 'background',
style: {
backgroundColor: '#131e29',
height: '100%'
},
children: [
{
tagName: 'p',
selector: 'text',
style: {
color: '#F2F2F2',
font: '16px sans-serif',
padding: '10px',
margin: 0,
},
textContent: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit.'
}
]
}
]
}
]
});
const card = new Card();
card.position(10, 10);
card.resize(180, 100);
card.addTo(graph);
In order for our JointJS example to have the equivalent functionality as the first example, we need to make selecting text possible. To do that, we use the paper
preventDefaultViewAction
option which prevents the default action when a Cell is clicked.
While preventDefaultViewAction
handles clicking, we also need to allow the user to select text via CSS. As the default CSS in JointJS sets the user-select
property to 'none'
, we need to override this on our element. The preventDefaultViewAction
option combined with the following CSS override enables users to select text in the same manner as the vanilla SVG example from earlier. We can also adjust the cursor
value in order to see the text cursor.
g[data-type="example.ForeignObject"] {
-webkit-user-select: text; /* prefix needed for Safari */
user-select: text;
}
g[data-type="example.ForeignObject"] p {
cursor: auto;
}
You'll also notice that trying to select text by dragging results in the element moving. If you would like to prevent the element from entering interactive
mode, therefore preventing the element from reacting to events in the default manner, you could take advantage of the preventDefaultInteraction
method.
paper.on('cell:pointerdown', (cellView, evt) => {
cellView.preventDefaultInteraction(evt);
});
Since our foreignObject
contains quite a lot of HTML, we can take advantage of the util.svg
method to make our markup
object more concise. The resulting custom element definition will be more compact, and might prove easier to read for some developers.
const Card = dia.Element.define('example.ForeignObject', {
attrs: {
body: {
width: 'calc(w)',
height: 'calc(h)',
fill: {
type: 'linearGradient',
stops: [
{ offset: 0, color: '#ff5c69' },
{ offset: 0.5, color: '#ff4252' },
{ offset: 1, color: '#ed2637' }
]
}
},
foreignObject: {
width: 'calc(w-12)',
height: 'calc(h-12)',
x: 6,
y: 6
}
},
}, {
// The /* xml */ comment is optional.
// It is used to tell the IDE that the markup is XML.
markup: joint.util.svg/* xml */`
<rect @selector="body"/>
<foreignObject @selector="foreignObject">
<div
xmlns="http://www.w3.org/1999/xhtml"
style="background-color: #131e29; height: 100%;"
>
<p style="color: #F2F2F2; font: 16px sans-serif; padding: 10px; margin: 0;">
Lorem ipsum dolor sit amet, consectetur adipiscing elit.
</p>
</div>
</foreignObject>
`
});
A more interactive exampleβ
One alternative method which diagram libraries utilize to allow users work with HTML is to render HTML on top of an underlying element. While this approach can be successful, it comes with a lot of additional complexity such as the need to keep dimensions and position of the HTML in sync with the element itself. To this day, this approach is used by many competing diagram libraries - especially those that use HTML Canvas rendering.
It's possible to use the HTML overlay approach in JointJS, however since JointJS primarily works with SVG - and SVG was designed to work well with other web standards such as HTML - taking advantage of foreignObject
can allow users of JointJS create HTML-rich elements while avoiding some of the difficulties of other approaches.
Creating a static card-like element was a gentle introduction to working with foreignObject
. For a more realistic example, let's try to create a custom form
element with several interactive controls.
In our previous example, we created an SVG rect
element alongside our foreignObject
. If your shape requires other SVG elements, it's possible to add them in the same manner, but it's also not a requirement. You may find that foreignObject
is sufficient, and it's the only SVG element you wish to define explicitly. In our form
example, that's exactly the route we will take, and you can see it demonstrated in the following code example.
const Form = dia.Element.define('example.Form', {
attrs: {
foreignObject: {
width: 'calc(w)',
height: 'calc(h)'
}
}
}, {
markup: joint.util.svg/* xml */`
<foreignObject @selector="foreignObject">
<div
xmlns="http://www.w3.org/1999/xhtml"
class="outer"
>
<div class="inner">
<form class="form">
<input @selector="name" type="text" name="name" autocomplete="off" placeholder="Your diagram name"/>
<button>
<span>Submit</span>
</button>
</form>
</div>
</div>
</foreignObject>
`
});
As the default styles for our form
are a little boring, we will also add the following CSS just to make our elements a little more presentable. It's also possible to add CSS via an SVG style
element, or inline styles via the style attribute if you prefer, but as we are adding a substantial amount of CSS, we will use a separate stylesheet.
.outer {
background: linear-gradient(92.05deg, hsl(355, 100%, 68%) 12.09%, hsl(355, 100%, 63%) 42.58%, hsl(355, 85%, 54%) 84.96%);
padding: 6px;
width: 100%;
height: 100%;
box-sizing: border-box;
}
.inner {
background-color: hsl(210, 37%, 14%);
width: 100%;
height: 100%;
}
.form {
display: flex;
font-family: sans-serif;
flex-direction: column;
justify-content: space-evenly;
padding: 0 32px;
width: 100%;
height: 100%;
box-sizing: border-box;
}
input {
all: unset;
background: hsl(218, 80%, 2%);
color: white;
cursor: text;
font-size: 1rem;
height: 56px;
outline-color: hsl(355, 100%, 63%);
outline-style: solid;
outline-width: thin;
padding: 0 32px;
}
input::placeholder {
color: hsl(0, 0%, 50%);
}
button {
background-color: hsl(218, 80%, 2%);
border: unset;
cursor: pointer;
display: flex;
justify-content: center;
align-items: center;
font-family: sans-serif;
font-size: 1rem;
font-weight: 600;
height: 56px;
outline-color: hsl(355, 100%, 63%);
outline-style: solid;
outline-width: thin;
}
button span {
background: linear-gradient(92.05deg, hsl(355, 100%, 68%) 12.09%, hsl(355, 100%, 63%) 42.58%, hsl(355, 85%, 54%) 84.96%);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: hsl(0deg 0% 0% / 0%);
font-size: 1.2rem;
}
When working with JointJS, you will have to make decisions about how some HTML elements lose focus. Notably, the default behavior of an HTML form
is that when a user clicks on a blank area, the active input field loses focus. In order to mimic this behavior in JointJS, we can create an event to remove focus when the user clicks on a blank paper
area, or even on any JointJS element.
paper.on('blank:pointerdown cell:pointerdown', () => {
document.activeElement.blur();
});
Alternatively, setting the preventDefaultViewAction
and [preventDefaultBlankAction
]../../../../api/dia/Paper//Paper.mdx#preventdefaultblankaction) paper
options to false
would have the same effect on focus.
Preventing default JointJS and browser behaviorβ
Earlier, we mentioned that we prevented the default action when a Cell is clicked by using preventDefaultViewAction
. You may be asking yourself, why don't we need to use the preventDefaultViewAction
paper
option for this example?
The simple explanation is that JointJS prevents its own default interactions when working with HTML form controls. That means for example, if you try to drag an input
, the element doesn't move as a whole, but it selects the text inside the input. The elements where JointJS prevents its default interaction are as follows:
In the example above, we created a paper
event with the aim of managing focus. One detail you may not have noticed is that the button
text is actually contained within a span
element. If we had not applied the paper
event, and the input
was in focus, clicking on the span
element won't actually cause the input
to lose focus. However, clicking on the button
itself will make the input
lose focus, as button
is one of the elements where JointJS prevents its default interaction.
If we wanted the input
to lose focus when a user clicks on the span
element, utilizing the preventDefaultViewAction
option would accomplish this. A stronger measure would be to use the paper
guard
option as follows guard: (evt) => ['SPAN'].includes(evt.target.tagName)
. When using this option, it's important to note that events are not propagated to the paper, so events such as 'element:pointerdown'
would not be triggered when a user clicks on the span
element. However, including both of these options to manage focus in this instance would be superfluous if the paper
event is already applied.
One of the final items you may wish to take care of when using a form
in JointJS is to prevent the default browser behavior when submitting user data. In order to achieve this, we will create a custom CellView
for our element. It will contain an events hash which specifies a DOM event that will be bound to a method on the view. The key is made up of the event name plus a CSS selector, and the value is a method on our custom view.
In the following example, we specify the event name 'submit'
, plus our CSS element selector 'form'
, and lastly the name of our method 'onSubmit'
. We can prevent that browser refresh by using evt.preventDefault()
, and clear the value of the HTML input value afterwards if we wish to do so.
shapes.example.FormView = dia.ElementView.extend({
events: {
'submit form': 'onSubmit',
'change input': 'onChange'
},
onSubmit: function(evt) {
evt.preventDefault();
this.model.attr('name/props/value', '');
},
onChange: function(evt) {
// Set the value property which is accessible from the model
this.model.attr('name/props/value', evt.target.value);
}
});
Props, a special attributeβ
The last point which we would like to cover when discussing HTML form controls is the special props
attribute. When creating HTML elements, it's possible to define various attributes to initialize certain DOM properties. In some cases (such as 'id'
), this is a one-to-one relationship, but in others (such as 'value'
), the attribute doesn't reflect the property, and it serves more like an initial and current state. When working with JointJS, it wouldn't make sense that users can only set an initial 'value'
attribute for an input
, and then have to access DOM elements themselves to update the 'value'
property. If using foreignObject
, users should be able to set the current 'value'
of an input
through the model at any time they want. It's for this exact reason that we created the special props
attribute, and it can be utilized as follows:
// Example markup using the value attribute (initial value of input)
<input @selector="example" type="text" value="initial" />
...
// Set the value property which is accessible from the model (current value of input)
element.attr('example/props/value', 'current');
...
// For illustration purposes - not required to access DOM elements in this manner
const input = document.querySelector('input');
// Expected values
console.log(input.value); // current (JointJS sets the 'value' DOM property internally)
console.log(input.defaultValue); // initial
console.log(input.getAttribute('value')); // initial
The supported properties when using the props
attribute are:
Caveats!β
While all that we have covered so far is great for users of JointJS, even in this day and age, it's not without some caveats. Firstly, we will cover some minor points regarding syntax that you should be aware of when using HTML within your markup
. Afterwards, we'll look at some browser related quirks which mostly centre around Safari.
Syntaxβ
Boolean Attributesβ
Boolean attributes are those data types where the presence or absence of the attribute represents its truthy or falsy value respectively. Some common examples are required
, disabled
, and readonly
.
Usually, boolean attributes can be utilized in 3 different ways: you can omit the value, use an empty string, or set the value as a case-insensitive match for the attribute's name. In our case, we cannot omit the value, so we must use the 2nd or 3rd option. This is demonstrated in the following example which uses the required
attribute.
// Omitted value will throw error β
<input @selector="name" type="text" id="name" name="name" required />
// Use an empty string β
<input @selector="name" type="text" id="name" name="name" required="" />
// Use a case-insensitive match for the attribute's name β
<input @selector="name" type="text" id="name" name="name" required="required" />
Closing Tagsβ
In the past, developers traditionally used start and end tags for HTML elements. If we wanted some text on the page, we might create the following paragraph element <p>Lorem ipsum dolor sit amet consectetur.</p>
. With the advent of HTML5, it's not strictly necessary to close certain elements which are considered void, i.e., elements which can't have child nodes.
In HTML, it's possible to write a line-break element as <br>
or <br />
. The trailing slash in the tag has no meaning, and browsers simply ignore it. However, in XML, XHTML, and SVG, self closing tags are required in void elements, so a trailing slash is necessary. When working with tagged templates in JointJS, you must use a trailing slash. This can be observed in the following example:
<foreignObject @selector="foreignObject">
<div @selector="content"
xmlns="http://www.w3.org/1999/xhtml"
style="font-family: sans-serif;"
>
<p>Lorem ipsum dolor sit amet consectetur.</p>
<br> // line-break element without trailing slash will throw an error β
<p>Lorem ipsum dolor sit amet consectetur.</p>
</div>
</foreignObject>
...
// Use a trailing slash β
<br/>
HTML Entitiesβ
HTML Entities are used in place of some characters that are reserved depending on the language. For example, the greater than (>) or less than (<) sign cannot be used in HTML as it would cause a conflict with tags. This is also one area to be careful with when using tagged templates. You must use the entity number rather than the entity name. Entity numbers are the ones which use the # symbol. That means using ©
is preferable over ©
.
<foreignObject @selector="foreignObject">
<div @selector="content" xmlns="http://www.w3.org/1999/xhtml">
© // copy HTML entity name will throw an error β
</div>
</foreignObject>
...
// Use an entity number β
©
IDsβ
Since having more than one ID of the same value in the DOM is considered invalid according to the HTML specification, we recommend not including an ID attribute in your HTML Elements. The first scenario where this may be relevant is when choosing CSS selectors. Using a class attribute in your markup
rather than an ID is advised.
The second situation where this proves important is programmatically associating a label
with an input
element. One method of making this association is by providing an 'id'
attribute to an input
, and a 'for'
attribute to its label
. Each of which would have the same value. This has a number of advantages such as screen readers reading the label when the associated input is focused, and also that the focus is passed to the input when the associated label is clicked.
As we don't want to use an ID, we should use an alternative method to make this association such as nesting an input
in its respective label
which will have the same effect.
// Do not use ID attributes in HTML markup β
<p id="paragraph">
Lorem ipsum dolor sit amet, consectetur adipiscing elit.
</p>
// Use a class attribute instead β
<p class="paragraph">
Lorem ipsum dolor sit amet, consectetur adipiscing elit.
</p>
...
// Do not use IDs for associating inputs and labels β
<label>First Name</label>
<input @selector="firstname" type="text"/>
// Use nesting to make this association instead β
<label>First Name
<input @selector="firstname" type="text"/>
</label>
Browser related quirksβ
At the beginning of this tutorial, we mentioned how foreignObject
is fully supported in all browsers. Unfortunately, some inconsistencies do exist, and some unexpected behavior may occur.
Our experience with both Chrome and Firefox has been very positive, but we have ran into some quirks when using Safari. In this section, we will cover some issues we discovered, and some related webkit bugs, but this also isn't an exhaustive list, so you might run into an issue with Safari that isn't mentioned here.
The first issue arose when we tried to add an HTML5 video
element to a shape. While this went as expected with Chrome and Firefox, the video seemed to be rendered in an incorrect position on Safari. After searching for a solution, we stumbled on the following webkit bug.
Upon reading, we couldn't help but notice others suggesting that properties like opacity
, or transform
were also causing issues for Safari users, so we added those styles to our elements, and sure enough, we saw similar results. HTML elements which had opacity
or transform
applied were also in the incorrect position.
Naturally, we also searched the same bug report for any potential solutions, and found suggestions that adding a style of 'position: fixed'
to the outermost div
element fixed some of the issues that people had. Once again, the information proved to be helpful, and the elements seemed to be positioned correctly again. We did notice when transforming the JointJS paper
with a method such as transformToFitContent
, 'position:fixed'
again caused issues with element position. Therefore, if you are using paper
transforms, the default 'position:static'
might be preferable for some use cases.
On further inspection, we realized that we still had one major issue with the video. After dragging our JointJS shape, the video was still rendered in the incorrect position which was disappointing. The silver lining was that the opacity
and transform
applied to our label
and input
respectively were working correctly, and those elements seemed to maintain the correct position. The following is some example markup
we had issues with in Safari. Your mileage may vary.
<foreignObject @selector="foreignObject">
<div @selector="content"
xmlns="http://www.w3.org/1999/xhtml"
style="position: fixed; font-family: sans-serif;"
>
<span style="opacity: 0.5">First Name</span>
<input @selector="firstname" type="text" style="transform: translateX(10px); width: 100%;"/>
<span>Last Name</span>
<input @selector="lastname" type="text" style="width: 100%;"/>
<span>Video</span>
<video @selector="video" controls="controls" width="200">
<source src="path_to_your_example_video.mp4" type="video/mp4" />
</video>
</div>
</foreignObject>
The situation described above, and the resulting issues were discovered mostly through trial and error. That's all well and good, but can we learn anything from the webkit bug reports? It seems that when HTML inside a foreignObject
gets a RenderLayer, the hierarchial position of the RenderLayer is inaccurate which results in the HTML being positioned incorrectly. Let's try to reduce this information to some more actionable points.
Use of the following two properties in Safari should be approached with caution:
transform
opacity
Regarding HTML elements themselves, we've already discovered that users should be wary of video
elements, and if they are positioned correctly. Are there any other elements to be careful with? Unfortunately, Yes! Enter the select
element.
If you've never tested which events are triggered when a user clicks on a select
element, don't worry, we've done it, so you don't have to. It turns out, the events that get triggered are quite inconsistent across all major browsers when interacting with select
. Safari, for example, only triggers 'pointerdown'
when you open select
. As a result of these inconsistencies, JointJS includes select
in the paper
guard
option by default. Therefore, if you interact with a select
element, JointJS will not trigger its paper
events. You as a user are not obliged to do something about this, but it's something you should be aware of.
One last delightful quirk of working with select
in Safari is that when multiple
is set to 'true'
, Safari does not allow you to select an option
with a mouse, but does however allow selection via keyboard navigation.
HTML Elements which show inconsistency in Safari are as follows:
video
select
As stated already, this isn't an exhaustive list of issues, so it's possible you may run into some other inconsistencies with Safari. It's unfortunate that Safari still lags behind other modern browsers in relation to foreignObject
, but there are also reasons to be positive. One reason to have a brighter outlook is that some of the major issues we experienced here are currently being worked on according to this bug report, so hopefully in the not too distant future, the majority of these issues will cease to exist.
This is a section we would like to keep updated as much as possible, so if you encounter any other interesting behavior, you can let us know.
Conclusionβ
We are very happy that we can now confidently recommend the use of foreignObject
to users of JointJS, and that it's well-supported in all major browsers. We consider it a big step forward for creating diagrams with JointJS, and believe it will allow the creation of even more innovative and interactive diagramming solutions. If you want to start using HTML with JointJS, there is no better time to do so, and we can't wait to see what you create.