Skip to content
Merged
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
15 changes: 15 additions & 0 deletions html-form-ports/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
# JointJS: HTML Form Ports

The HTML Form Ports demo showcases a small data-mapping application built from elements that render HTML inside a `<foreignObject>`. A form element has a port directly under each of its fields; interface elements (input and output) are lists of items with a port next to each row. All ports belong to a single ports group with an absolute position layout — the port coordinates are measured from the rendered HTML, so they stay aligned with the content regardless of the layout. Values propagate along the mapping links: from the input interface into the form's input fields, through the form's computed fields (filled dynamically from the input fields), and on to the output interface. Form values are kept in sync with the element model in both directions.

## Available Versions

- [TypeScript](./ts/)

## How to Run

```bash
cd ts
npm install
npm run dev
```
Binary file added html-form-ports/screenshot.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
12 changes: 12 additions & 0 deletions html-form-ports/ts/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>HTML Form Ports</title>
</head>
<body>
<div id="paper-container"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
24 changes: 24 additions & 0 deletions html-form-ports/ts/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
{
"name": "@joint/demo-html-form-ports-ts",
"version": "4.2.3",
"main": "src/main.ts",
"homepage": "https://jointjs.com",
"author": {
"name": "client IO",
"url": "https://client.io"
},
"license": "http://jointjs.com/license",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build"
},
"devDependencies": {
"vite": "^7.3.1",
"typescript": "^5.9.3"
},
"dependencies": {
"@joint/core": "4.2.4"
}
}
239 changes: 239 additions & 0 deletions html-form-ports/ts/src/form-element.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,239 @@
import { dia, util } from '@joint/core';

import { HtmlPortsElementView } from './html-ports-view';

import type { mvc } from '@joint/core';

export interface FieldDefinition {
name: string;
label: string;
placeholder?: string;
// Computed fields are read-only — their values are derived from the
// input fields and their ports are meant to be mapped to an output.
computed?: boolean;
// CSS flex-basis of the field wrapper — fields wrap into rows freely
// (e.g. two fields in the first row, one full-width field in the second).
basis: string;
}

export const FIELDS: FieldDefinition[] = [
{ name: 'firstName', label: 'First name', placeholder: 'John', basis: '40%' },
{ name: 'lastName', label: 'Last name', placeholder: 'Doe', basis: '40%' },
{ name: 'company', label: 'Company', placeholder: 'Acme', basis: '100%' },
{ name: 'fullName', label: 'Full name', computed: true, basis: '100%' },
{ name: 'email', label: 'Email', computed: true, basis: '100%' }
];

// Vertical distance between the bottom of an input and the center of its port.
const PORT_OFFSET = 12;

export class FormElement extends dia.Element {

defaults(): Partial<dia.Element.Attributes> {
return {
...super.defaults,
type: 'FormElement',
size: { width: 280, height: 360 },
title: 'Form',
fields: {
firstName: '',
lastName: '',
company: '',
fullName: '',
email: ''
},
attrs: {
root: {
cursor: 'move',
magnet: false
},
body: {
width: 'calc(w)',
height: 'calc(h)',
fill: '#ffffff',
stroke: '#a0a0a0',
strokeWidth: 1,
rx: 6,
ry: 6
},
fo: {
x: 0,
y: 0,
width: 'calc(w)',
height: 'calc(h)'
}
},
ports: {
groups: {
// A single ports group. The layout is absolute — the exact
// port coordinates are measured from the rendered HTML
// inputs by the FormElementView.
field: {
position: 'absolute',
attrs: {
portBody: {
magnet: true,
r: 5,
fill: '#4666e5',
stroke: '#4666e5',
strokeWidth: 2,
cursor: 'crosshair'
}
},
markup: util.svg`
<circle @selector="portBody" />
`
}
},
items: FIELDS.map((field) => ({
id: field.name,
group: 'field',
// Placeholder coordinates — updated by the view after render.
args: { x: 0, y: 0 },
// Visually distinguish the in ports (editable fields,
// hollow) from the out ports (computed fields, solid) —
// only the fill differs, so both look the same size.
attrs: field.computed ? {} : {
portBody: {
fill: '#ffffff'
}
}
}))
}
};
}

preinitialize(): void {
this.markup = util.svg`
<rect @selector="body" />
<foreignObject @selector="fo" />
`;
}

initialize(attributes?: dia.Element.Attributes, options?: dia.Cell.Options): void {
super.initialize(attributes, options);
this.on('change:fields', () => this.updateComputedFields());
this.updateComputedFields();
}

// Fill the computed fields from the input fields.
protected updateComputedFields(): void {
const { firstName = '', lastName = '', company = '' } = this.get('fields');
const fullName = [firstName, lastName].filter(Boolean).join(' ');
const email = (firstName && lastName && company)
? `${firstName}.${lastName}@${company}.com`.replace(/\s+/g, '').toLowerCase()
: '';
this.prop('fields/fullName', fullName);
this.prop('fields/email', email);
}
}

export class FormElementView extends HtmlPortsElementView {

private form: HTMLFormElement | null = null;

override presentationAttributes(): dia.CellView.PresentationAttributes {
return dia.ElementView.addPresentationAttributes({
size: ['PORT_POSITIONS'],
fields: ['FORM_FIELDS']
});
}

override initFlag(): dia.CellView.FlagLabel {
return ['RENDER', 'FORM_FIELDS'];
}

override events(): mvc.EventsHash {
return {
'input input': 'onFieldInput'
};
}

override confirmUpdate(flags: number, opt: Record<string, unknown>): number {
let remaining = super.confirmUpdate(flags, opt);
if (this.hasFlag(remaining, 'FORM_FIELDS')) {
this.updateFields();
remaining = this.removeFlag(remaining, 'FORM_FIELDS');
}
return remaining;
}

override onRender(): void {
this.renderForm();
this.updateFields();
}

protected renderForm(): void {
const fo = this.findNode('fo') as SVGForeignObjectElement;
if (!fo) return;
fo.replaceChildren();
const form = document.createElement('form');
form.className = 'form-element';
const title = document.createElement('div');
title.className = 'form-element-title';
title.textContent = String(this.model.get('title') ?? '');
form.appendChild(title);
let dividerRendered = false;
FIELDS.forEach((field) => {
if (field.computed && !dividerRendered) {
const divider = document.createElement('div');
divider.className = 'form-element-divider';
divider.textContent = 'Computed';
form.appendChild(divider);
dividerRendered = true;
}
const wrapper = document.createElement('label');
wrapper.className = 'form-element-field';
wrapper.style.flexBasis = field.basis;
const caption = document.createElement('span');
caption.textContent = field.label;
wrapper.appendChild(caption);
const control = document.createElement('input');
control.type = 'text';
control.placeholder = field.placeholder ?? '';
control.dataset.field = field.name;
if (field.computed) {
control.readOnly = true;
control.classList.add('computed');
}
wrapper.appendChild(control);
form.appendChild(wrapper);
});
fo.appendChild(form);
this.form = form;
}

protected onFieldInput(evt: dia.Event): void {
const control = evt.target as HTMLInputElement;
const { field } = control.dataset;
if (!field) return;
this.model.prop(['fields', field], control.value);
}

protected updateFields(): void {
const { form, model } = this;
if (!form) return;
form.querySelectorAll<HTMLInputElement>('[data-field]').forEach((control) => {
const value = String(model.prop(['fields', control.dataset.field]) ?? '');
// Avoid resetting the caret of the control the user is typing in.
if (control.value !== value) {
control.value = value;
}
});
}

// Place each port directly under its input.
protected updatePortPositions(): void {
const { form } = this;
if (!form) return;
const args: Record<string, dia.Point> = {};
form.querySelectorAll<HTMLElement>('[data-field]').forEach((control) => {
const rect = this.localRect(control);
args[control.dataset.field!] = {
x: rect.x + rect.width / 2,
y: rect.y + rect.height + PORT_OFFSET
};
});
this.setPortArgs(args);
}
}
61 changes: 61 additions & 0 deletions html-form-ports/ts/src/html-ports-view.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { dia, util } from '@joint/core';

import type { g } from '@joint/core';

// Base view for elements whose ports are aligned with their rendered HTML
// content. The port coordinates are measured from the DOM, so they stay in
// sync with the HTML layout (rows, wrapping, CSS changes) — the ports only
// need a single group with an absolute position layout.
export abstract class HtmlPortsElementView extends dia.ElementView {

override presentationAttributes(): dia.CellView.PresentationAttributes {
return dia.ElementView.addPresentationAttributes({
size: ['PORT_POSITIONS']
});
}

// Note: 'PORT_POSITIONS' is intentionally not part of the init flags.
// The initial measurement happens in `onMount()` — the HTML has no
// dimensions before the view is attached to the DOM.
override confirmUpdate(flags: number, opt: Record<string, unknown>): number {
let remaining = super.confirmUpdate(flags, opt);
if (this.hasFlag(remaining, 'PORT_POSITIONS')) {
this.updatePortPositions();
remaining = this.removeFlag(remaining, 'PORT_POSITIONS');
}
return remaining;
}

protected override onMount(isInitialMount: boolean): void {
super.onMount(isInitialMount);
// The HTML can only be measured once the view is attached to the DOM.
// Defer to the next frame so the resulting port update is processed
// outside of the current paper update batch.
util.nextFrame(() => {
if (this.paper) this.updatePortPositions();
});
}

// The bounding box of an HTML node in the element's local coordinate
// system (zoom-safe).
protected localRect(node: Element): g.Rect {
const rect = this.paper!.clientToLocalRect(node.getBoundingClientRect());
const position = this.model.position();
return rect.translate(-position.x, -position.y);
}

// Update the coordinates of multiple ports in a single 'ports' change,
// so the ports are only re-rendered once.
protected setPortArgs(args: Record<string, dia.Point>): void {
const { model } = this;
const items: dia.Element.Port[] = (model.get('ports')?.items ?? []).map(
(item: dia.Element.Port) => {
const itemArgs = (item.id === undefined) ? null : args[item.id];
return itemArgs ? { ...item, args: itemArgs } : item;
}
);
model.prop('ports/items', items, { rewrite: true });
}

protected abstract updatePortPositions(): void;
}
Loading
Loading