├── .editorconfig ├── .github ├── FUNDING.yml └── workflows │ └── lint.yml ├── .gitignore ├── .npmrc ├── LICENSE ├── README.md ├── index.html ├── index.ts ├── package.json ├── screenshot.png ├── stress.html └── tsconfig.json /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = tab 5 | indent_size = 4 6 | tab_width = 4 7 | end_of_line = lf 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | insert_final_newline = true 11 | 12 | [{*.gyp,*.yml,*.yaml}] 13 | indent_style = space 14 | indent_size = 2 15 | 16 | [{*.py,*.asm}] 17 | indent_style = space 18 | 19 | [*.py] 20 | indent_size = 4 21 | 22 | [*.asm] 23 | indent_size = 8 24 | 25 | [{*.md,*.tsv}] 26 | trim_trailing_whitespace = false 27 | 28 | # Ideal settings - some plugins might support these. 29 | [*.js] 30 | quote_type = single 31 | 32 | [{*.c,*.cc,*.h,*.hh,*.cpp,*.hpp,*.m,*.mm,*.mpp,*.js,*.java,*.go,*.rs,*.php,*.ng,*.jsx,*.ts,*.d,*.cs,*.swift}] 33 | curly_bracket_next_line = false 34 | spaces_around_operators = true 35 | spaces_around_brackets = outside 36 | # close enough to 1TB 37 | indent_brace_style = K&R 38 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: 'qix-' 2 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: Lint 2 | on: [push, pull_request] 3 | jobs: 4 | test: 5 | name: Lint 6 | runs-on: ubuntu-latest 7 | timeout-minutes: 10 8 | steps: 9 | - name: Check out repository 10 | uses: actions/checkout@v2 11 | with: 12 | submodules: recursive 13 | fetch-depth: 0 14 | - name: Install dependencies 15 | run: npm i 16 | - name: Lint commit message 17 | uses: wagoid/commitlint-github-action@v4 18 | - name: Lint source code 19 | run: npm run lint 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /dist/ 2 | node_modules/ 3 | package-lock.json 4 | yarn.lock 5 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | save-exact = true 2 | package-lock = false 3 | update-notifier = false 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2022 Josh Junon 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Node Editor (Web Components) 2 | 3 |
4 | Screenshot of the node editor component demo 5 |
6 |
7 | 8 | A zero dependency, unopinionated 9 | [node editor](https://en.wikipedia.org/wiki/Node_graph_architecture#Node_Graph) 10 | built as a reusable 11 | [web component](https://developer.mozilla.org/en-US/docs/Web/Web_Components). 12 | 13 | ## Features 14 | 15 | - **Zero dependencies.** Just import and you're off! 16 | - **Fully-compliant web components.** Works with React, Vue, Surplus, 17 | or vanilla Javascript applications. 18 | - **Unopinionated.** Style nodes how you want, customizing everything 19 | down to the port handles themselves. Completely stylable. 20 | - **Delightful.** Modeled after the excellent Blender geometry nodes 21 | and Unity's editor system, great care was made to make it feel 22 | nice to use and to look pretty. 23 | 24 | ## Installation 25 | 26 | This library is distributed as an ESM module and released on npm. 27 | 28 | ``` 29 | npm install node-editor 30 | ``` 31 | 32 | While the module itself exports all of the public types 33 | if you need them, it is not _strictly_ necessary to use 34 | the library, and the code is otherwise self-contained/-executing. 35 | 36 | Assuming you're using a bundler or a ` 310 | 311 | 312 | -------------------------------------------------------------------------------- /index.ts: -------------------------------------------------------------------------------- 1 | // TODO turn into attributes 2 | const ZOOM_RATE = 0.085; 3 | const MIN_ZOOM = 0.2; // CANNOT BE ZERO! 4 | const MAX_ZOOM = 1; 5 | const GRID_RESOLUTION = 32; 6 | const GRID_DOT_RADIUS = 1.5; 7 | const PATH_WIDTH = 5; 8 | const PATH_TENSION = 0.5; 9 | const MIN_PATH_TENSION = 50.0; 10 | const MAX_INVERTED_PATH_TENSION = 300; 11 | const DEFAULT_PORT_RADIUS = 7; 12 | const DEFAULT_PORT_COLOR = '#555'; 13 | 14 | const HTML = document.createElement.bind(document); 15 | 16 | function SVG(tag: 'param'): SVGElement; 17 | function SVG( 18 | tag: K 19 | ): SVGElementTagNameMap[K]; 20 | function SVG(tag: any) { 21 | return document.createElementNS('http://www.w3.org/2000/svg', tag); 22 | } 23 | 24 | function roundTo(v: number, to: number): number { 25 | return Math.ceil(v / to) * to; 26 | } 27 | 28 | function attr( 29 | elem: T, 30 | map: { 31 | [key: string]: 32 | | string 33 | | number 34 | | null 35 | | { [key: string]: string | number | null }; 36 | } 37 | ): T { 38 | for (const k of Object.keys(map)) { 39 | const v = map[k]; 40 | if (v === null) { 41 | elem.removeAttribute(k); 42 | } else if (typeof v === 'object') { 43 | for (const nsk of Object.keys(v)) { 44 | const nsv = v[nsk]; 45 | if (nsv === null) { 46 | elem.removeAttributeNS(k, nsk); 47 | } else { 48 | elem.setAttributeNS(k, nsk, nsv.toString()); 49 | } 50 | } 51 | } else { 52 | elem.setAttribute(k, v.toString()); 53 | } 54 | } 55 | return elem; 56 | } 57 | 58 | function setLinkCurve( 59 | elem: SVGPathElement, 60 | x1: number, 61 | y1: number, 62 | x2: number, 63 | y2: number 64 | ) { 65 | x1 = roundTo(x1, 0.1); 66 | y1 = roundTo(y1, 0.1); 67 | x2 = roundTo(x2, 0.1); 68 | y2 = roundTo(y2, 0.1); 69 | 70 | const hDist = x2 - x1; 71 | const t = roundTo( 72 | Math.max( 73 | MIN_PATH_TENSION, 74 | Math.abs(Math.max(-MAX_INVERTED_PATH_TENSION, hDist * PATH_TENSION)) 75 | ), 76 | 0.1 77 | ); 78 | 79 | elem.setAttribute( 80 | 'd', 81 | `M${x1} ${y1}C${x1 + t} ${y1} ${x2 - t} ${y2} ${x2} ${y2}` 82 | ); 83 | } 84 | 85 | interface Connectable { 86 | connectedCallback: () => void; 87 | disconnectedCallback: () => void; 88 | } 89 | 90 | function upgradeAll( 91 | self: T, 92 | tagName: string, 93 | childType: new () => U 94 | ) { 95 | const children = self.getElementsByTagName(tagName); 96 | for (let i = 0, len = children.length; i < len; i++) { 97 | const child = children.item(i); 98 | if (child instanceof childType) { 99 | child.disconnectedCallback(); 100 | child.connectedCallback(); 101 | } 102 | } 103 | } 104 | 105 | function createDebouncedResizeObserver( 106 | cb: (width: number, height: number) => void 107 | ): ResizeObserver { 108 | let lastWidth: number = Infinity; 109 | let lastHeight: number = Infinity; 110 | let ignoreResize: number | null = null; 111 | 112 | const resizeObserver = new ResizeObserver(([{ contentRect, target }]) => { 113 | if (ignoreResize !== null) { 114 | ignoreResize = null; 115 | return; 116 | } 117 | 118 | const newWidth = contentRect.width; 119 | const newHeight = contentRect.height; 120 | 121 | if (newWidth === lastWidth && newHeight === lastHeight) return; 122 | 123 | lastWidth = newWidth; 124 | lastHeight = newHeight; 125 | 126 | try { 127 | resizeObserver.unobserve(target); 128 | cb(newWidth, newHeight); 129 | } finally { 130 | const token = Math.random(); 131 | ignoreResize = token; 132 | setTimeout(() => { 133 | if (ignoreResize === token) ignoreResize = null; 134 | }, 0); 135 | resizeObserver.observe(target); 136 | } 137 | }); 138 | 139 | return resizeObserver; 140 | } 141 | 142 | declare class AttrObservable { 143 | static observedAttributes: string[]; 144 | } 145 | 146 | // Upgrades properties on custom elements that might have 147 | // been primitively set prior to the prototype attachment. 148 | function upgradeProperties(this: T) { 149 | const props = <(keyof T)[] | undefined>( 150 | (this.constructor).observedAttributes 151 | ); 152 | 153 | if (props) { 154 | for (const prop of props) { 155 | if (this.hasOwnProperty(prop)) { 156 | const value = this[prop]; 157 | delete this[prop]; 158 | this[prop] = value; 159 | } 160 | } 161 | } 162 | } 163 | 164 | function findAncestorOfType( 165 | from: Node, 166 | type: new () => T 167 | ): T | undefined { 168 | let cur: Node | null = from; 169 | 170 | do { 171 | if (cur instanceof type) { 172 | return cur; 173 | } 174 | 175 | cur = cur.parentNode; 176 | } while (cur && cur !== document.body); 177 | 178 | return; 179 | } 180 | 181 | // Private members 182 | const internal = Symbol(); 183 | const updateTransform = Symbol(); 184 | const refreshPosition = Symbol(); 185 | const refreshColor = Symbol(); 186 | const refreshConnection = Symbol(); 187 | const notifyConnection = Symbol(); 188 | 189 | class NodeMapViewportStartEvent extends Event { 190 | constructor() { 191 | super('viewportstart', { bubbles: true }); 192 | } 193 | } 194 | 195 | class NodeMapViewportStopEvent extends Event { 196 | constructor() { 197 | super('viewportstop', { bubbles: true }); 198 | } 199 | } 200 | 201 | class NodeMapViewportEvent extends Event { 202 | offsetX: number; 203 | offsetY: number; 204 | zoom: number; 205 | 206 | constructor(offsetX: number, offsetY: number, zoom: number) { 207 | super('viewport', { bubbles: true }); 208 | this.offsetX = offsetX; 209 | this.offsetY = offsetY; 210 | this.zoom = zoom; 211 | } 212 | } 213 | 214 | class NodeEditorTransformEvent extends Event { 215 | x: number; 216 | y: number; 217 | width: number; 218 | height: number; 219 | didResize: boolean; 220 | didMove: boolean; 221 | 222 | constructor(opts: { 223 | x: number; 224 | y: number; 225 | width: number; 226 | height: number; 227 | didResize: boolean; 228 | didMove: boolean; 229 | }) { 230 | super('transform', { bubbles: true }); 231 | this.x = opts.x; 232 | this.y = opts.y; 233 | this.width = opts.width; 234 | this.height = opts.height; 235 | this.didResize = opts.didResize; 236 | this.didMove = opts.didMove; 237 | } 238 | } 239 | 240 | class NodePortPositionEvent extends Event { 241 | x: number; 242 | y: number; 243 | 244 | constructor(x: number, y: number) { 245 | super('position', { bubbles: true }); 246 | this.x = x; 247 | this.y = y; 248 | } 249 | } 250 | 251 | class NodePortColorEvent extends Event { 252 | color: string; 253 | 254 | constructor(color: string) { 255 | super('color', { bubbles: true }); 256 | this.color = color; 257 | } 258 | } 259 | 260 | class NodePortOnlineEvent extends Event { 261 | port: NodePortElement; 262 | 263 | constructor(port: NodePortElement) { 264 | super('online', { bubbles: true }); 265 | this.port = port; 266 | } 267 | } 268 | 269 | class NodePortOfflineEvent extends Event { 270 | port: NodePortElement; 271 | 272 | constructor(port: NodePortElement) { 273 | super('offline', { bubbles: true }); 274 | this.port = port; 275 | } 276 | } 277 | 278 | class NodeEditorAddEvent extends Event { 279 | editor: NodeEditorElement; 280 | 281 | constructor(editor: NodeEditorElement) { 282 | super('add', { bubbles: true }); 283 | this.editor = editor; 284 | } 285 | } 286 | 287 | class NodeEditorRemoveEvent extends Event { 288 | editor: NodeEditorElement; 289 | 290 | constructor(editor: NodeEditorElement) { 291 | super('remove', { bubbles: true }); 292 | this.editor = editor; 293 | } 294 | } 295 | 296 | class NodeNameEvent extends Event { 297 | name: string | null; 298 | oldName: string | null; 299 | 300 | constructor(name: string | null, oldName: string | null) { 301 | super('name', { bubbles: true }); 302 | this.name = name; 303 | this.oldName = oldName; 304 | } 305 | } 306 | 307 | class NodeLinkEvent extends Event { 308 | link: NodeLinkElement; 309 | 310 | constructor(link: NodeLinkElement) { 311 | super('link', { bubbles: true }); 312 | this.link = link; 313 | } 314 | } 315 | 316 | class NodeUnlinkEvent extends Event { 317 | link: NodeLinkElement; 318 | 319 | constructor(link: NodeLinkElement) { 320 | super('unlink', { bubbles: true }); 321 | this.link = link; 322 | } 323 | } 324 | 325 | class NodeConnectEvent extends Event { 326 | link: NodeLinkElement; 327 | 328 | constructor(link: NodeLinkElement, bubbles: boolean) { 329 | super('connect', { bubbles }); 330 | this.link = link; 331 | } 332 | } 333 | 334 | class NodeDisconnectEvent extends Event { 335 | link: NodeLinkElement; 336 | 337 | constructor(link: NodeLinkElement, bubbles: boolean) { 338 | super('disconnect', { bubbles }); 339 | this.link = link; 340 | } 341 | } 342 | 343 | class NodePortElement extends HTMLElement { 344 | private [internal]: { 345 | root: HTMLSpanElement; 346 | portMarker: HTMLSpanElement; 347 | handleSlot: HTMLSlotElement; 348 | resizeObserver: ResizeObserver; 349 | editor?: NodeEditorElement; 350 | defaultPortMarker: HTMLSpanElement; 351 | editorResizeAbort?: AbortController; 352 | handlePosition: [number, number]; 353 | connections: Set; 354 | }; 355 | 356 | static get observedAttributes() { 357 | return ['name', 'color', 'out']; 358 | } 359 | 360 | constructor() { 361 | super(); 362 | const shadow = this.attachShadow({ 363 | mode: 'closed' 364 | }); 365 | 366 | upgradeProperties.call(this); 367 | 368 | const root = HTML('span'); 369 | 370 | const I = (this[internal] = { 371 | root, 372 | portMarker: HTML('span'), 373 | resizeObserver: createDebouncedResizeObserver(() => 374 | this[refreshPosition]() 375 | ), 376 | defaultPortMarker: HTML('span'), 377 | handleSlot: attr(HTML('slot'), { 378 | name: 'handle' 379 | }), 380 | handlePosition: [0, 0], 381 | connections: new Set() 382 | }); 383 | 384 | Object.assign(I.portMarker.style, { 385 | position: 'absolute', 386 | userSelect: 'none', 387 | cursor: 'pointer' 388 | }); 389 | 390 | I.portMarker.appendChild(I.handleSlot); 391 | 392 | shadow.appendChild(root); 393 | shadow.appendChild(I.portMarker); 394 | root.appendChild(HTML('slot')); 395 | 396 | Object.assign(I.defaultPortMarker.style, { 397 | display: 'inline-block', 398 | borderRadius: '50%', 399 | width: `${DEFAULT_PORT_RADIUS * 2}px`, 400 | height: `${DEFAULT_PORT_RADIUS * 2}px`, 401 | backgroundColor: 'var(--port-color)', 402 | border: 'solid 1px #1115' 403 | }); 404 | 405 | I.handleSlot.addEventListener('slotchange', () => { 406 | if (I.handleSlot.assignedNodes().length === 0) { 407 | I.portMarker.appendChild(I.defaultPortMarker); 408 | } else { 409 | I.defaultPortMarker.remove(); 410 | } 411 | }); 412 | 413 | I.portMarker.appendChild(I.defaultPortMarker); 414 | 415 | this[refreshPosition](); 416 | this[refreshColor](); 417 | 418 | this.addEventListener('connect', e => { 419 | I.connections.add((e).link); 420 | this.setAttribute('connections', I.connections.size.toString()); 421 | }); 422 | this.addEventListener('disconnect', e => { 423 | I.connections.delete((e).link); 424 | const count = I.connections.size; 425 | if (count > 0) { 426 | this.setAttribute('connections', count.toString()); 427 | } else { 428 | this.removeAttribute('connections'); 429 | } 430 | }); 431 | } 432 | 433 | get numConnections(): number { 434 | return this[internal].connections.size; 435 | } 436 | 437 | get connections(): NodeLinkElement[] { 438 | return [...this[internal].connections]; 439 | } 440 | 441 | get name(): string | null { 442 | return this.getAttribute('name'); 443 | } 444 | 445 | set name(v: string | null) { 446 | if (v === null) this.removeAttribute('name'); 447 | else this.setAttribute('name', v); 448 | } 449 | 450 | get nodeEditor(): NodeEditorElement | null { 451 | return this[internal].editor ?? null; 452 | } 453 | 454 | get color(): string { 455 | return this.getAttribute('color') || DEFAULT_PORT_COLOR; 456 | } 457 | 458 | set color(v: string | null) { 459 | if (v === null) this.removeAttribute('color'); 460 | else this.setAttribute('color', v); 461 | } 462 | 463 | get isOutputPort(): boolean { 464 | return this.hasAttribute('out'); 465 | } 466 | 467 | set isOutputPort(v: boolean | null) { 468 | this.removeAttribute('out'); 469 | if (v) { 470 | this.setAttribute('out', ''); 471 | } 472 | } 473 | 474 | get handleX(): number { 475 | return this[internal].handlePosition[0]; 476 | } 477 | 478 | get handleY(): number { 479 | return this[internal].handlePosition[1]; 480 | } 481 | 482 | attributeChangedCallback( 483 | name: string, 484 | oldValue: string | null, 485 | newValue: string | null 486 | ) { 487 | switch (name) { 488 | case 'out': 489 | this[refreshPosition](); 490 | break; 491 | case 'color': 492 | this[refreshColor](); 493 | break; 494 | case 'name': { 495 | if (oldValue !== newValue) { 496 | this.dispatchEvent(new NodeNameEvent(newValue, oldValue)); 497 | } 498 | break; 499 | } 500 | } 501 | } 502 | 503 | private [refreshColor]() { 504 | const I = this[internal]; 505 | I.portMarker.style.setProperty('--port-color', this.color); 506 | 507 | this.dispatchEvent(new NodePortColorEvent(this.color)); 508 | } 509 | 510 | private [refreshPosition]() { 511 | const I = this[internal]; 512 | 513 | if (!I.editor) return; 514 | 515 | const marker = 516 | I.handleSlot.assignedNodes().length > 0 517 | ? I.portMarker 518 | : I.defaultPortMarker; 519 | 520 | const { y: editorY, x: editorX, width: editorWidth } = I.editor; 521 | 522 | const { height: portHeight } = I.root.getBoundingClientRect(); 523 | const portY = I.root.offsetTop; 524 | 525 | const { width: markerWidth, height: markerHeight } = 526 | marker.getBoundingClientRect(); 527 | 528 | let xPos = this.hasAttribute('out') ? editorWidth : 0; 529 | let yPos = portY - editorY + portHeight / 2; 530 | 531 | Object.assign(I.portMarker.style, { 532 | position: 'absolute', 533 | left: `${xPos - markerWidth / 2}px`, 534 | top: `${yPos - markerHeight / 2}px` 535 | }); 536 | 537 | I.handlePosition = [xPos + editorX, yPos + editorY]; 538 | this.dispatchEvent(new NodePortPositionEvent(xPos, yPos)); 539 | } 540 | 541 | connectedCallback() { 542 | const I = this[internal]; 543 | 544 | I.editor = findAncestorOfType(this, NodeEditorElement); 545 | 546 | I.resizeObserver.observe(this); 547 | I.resizeObserver.observe(I.portMarker); 548 | 549 | if (I.editor) { 550 | I.editorResizeAbort = new AbortController(); 551 | I.editor.addEventListener( 552 | 'transform', 553 | () => this[refreshPosition](), 554 | { passive: true, signal: I.editorResizeAbort.signal } 555 | ); 556 | } 557 | 558 | this.dispatchEvent(new NodePortOnlineEvent(this)); 559 | 560 | this[refreshPosition](); 561 | } 562 | 563 | disconnectedCallback() { 564 | const I = this[internal]; 565 | 566 | I.resizeObserver.unobserve(this); 567 | I.resizeObserver.unobserve(I.portMarker); 568 | 569 | if (I.editorResizeAbort) { 570 | I.editorResizeAbort.abort(); 571 | delete I.editorResizeAbort; 572 | } 573 | 574 | const ev = new NodePortOfflineEvent(this); 575 | this.dispatchEvent(ev); 576 | if (ev.bubbles && !ev.cancelBubble) I.editor?.dispatchEvent(ev); 577 | 578 | delete I.editor; 579 | } 580 | } 581 | 582 | class NodeTitleElement extends HTMLElement { 583 | private [internal]: { 584 | editor?: NodeEditorElement; 585 | }; 586 | 587 | constructor() { 588 | super(); 589 | this[internal] = {}; 590 | 591 | let dragId: number | null = null; 592 | let dragStart = [0, 0, 0, 0]; 593 | 594 | const onDrag = (e: PointerEvent) => { 595 | const I = this[internal]; 596 | const editor = I.editor; 597 | const zoom = I.editor?.nodeMap?.zoom ?? 1; 598 | 599 | if (editor) { 600 | editor.x = dragStart[0] + (e.clientX - dragStart[2]) / zoom; 601 | editor.y = dragStart[1] + (e.clientY - dragStart[3]) / zoom; 602 | } 603 | }; 604 | 605 | this.addEventListener('pointerdown', e => { 606 | if (dragId !== null) return; 607 | if (e.button !== 0) return; 608 | 609 | const editor = this[internal].editor; 610 | if (!editor) return; 611 | 612 | const abortController = new AbortController(); 613 | 614 | dragStart = [editor.x, editor.y, e.clientX, e.clientY]; 615 | 616 | this.classList.add('dragging'); 617 | this.setPointerCapture(e.pointerId); 618 | this.addEventListener('pointermove', onDrag, { 619 | signal: abortController.signal 620 | }); 621 | dragId = e.pointerId; 622 | 623 | document.addEventListener( 624 | 'pointerup', 625 | e => { 626 | if (e.pointerId !== dragId) return; 627 | this.classList.remove('dragging'); 628 | this.releasePointerCapture(e.pointerId); 629 | dragId = null; 630 | abortController.abort(); 631 | }, 632 | { signal: abortController.signal } 633 | ); 634 | }); 635 | } 636 | 637 | get nodeEditor(): NodeEditorElement | null { 638 | return this[internal].editor ?? null; 639 | } 640 | 641 | connectedCallback() { 642 | const I = this[internal]; 643 | I.editor = findAncestorOfType(this, NodeEditorElement); 644 | } 645 | 646 | disconnectedCallback() { 647 | const I = this[internal]; 648 | delete I.editor; 649 | } 650 | } 651 | 652 | class NodeEditorElement extends HTMLElement { 653 | private [internal]: { 654 | root: HTMLDivElement; 655 | map?: NodeMapElement; 656 | resizeObserver: ResizeObserver; 657 | ports: Map; 658 | }; 659 | 660 | static get observedAttributes() { 661 | return ['name', 'x', 'y', 'width', 'height']; 662 | } 663 | 664 | constructor() { 665 | super(); 666 | 667 | const shadow = this.attachShadow({ 668 | mode: 'closed' 669 | }); 670 | 671 | upgradeProperties.call(this); 672 | 673 | const I = (this[internal] = { 674 | root: attr(HTML('div'), { 675 | part: 'frame' 676 | }), 677 | resizeObserver: createDebouncedResizeObserver(() => 678 | this[updateTransform](true, false) 679 | ), 680 | ports: new Map() 681 | }); 682 | 683 | Object.assign(I.root.style, { 684 | position: 'absolute', 685 | boxSizing: 'border-box' 686 | }); 687 | 688 | I.root.appendChild(HTML('slot')); 689 | shadow.appendChild(I.root); 690 | 691 | this.addEventListener('online', e => { 692 | const I = this[internal]; 693 | const port = (e).port; 694 | if (port.name) { 695 | const existing = I.ports.get(port.name); 696 | if (existing && existing !== port) { 697 | console.warn( 698 | 'ignoring port with duplicate name:', 699 | port.name 700 | ); 701 | return; 702 | } 703 | I.ports.set(port.name, port); 704 | } 705 | }); 706 | 707 | this.addEventListener('offline', e => { 708 | const I = this[internal]; 709 | const port = (e).port; 710 | if (port.name) { 711 | const existing = I.ports.get(port.name); 712 | if (existing && existing === port) { 713 | I.ports.delete(port.name); 714 | } 715 | } 716 | }); 717 | 718 | this.addEventListener('name', e => { 719 | const I = this[internal]; 720 | const { target, oldName } = e; 721 | 722 | if (target instanceof NodePortElement) { 723 | if (oldName) { 724 | const existing = I.ports.get(oldName); 725 | if (existing && existing === target) { 726 | I.ports.delete(oldName); 727 | } 728 | } 729 | 730 | if (target.name) { 731 | const existing = I.ports.get(target.name); 732 | if (existing && existing !== target) { 733 | console.warn( 734 | 'ignoring port with duplicate name:', 735 | target.name 736 | ); 737 | } else { 738 | I.ports.set(target.name, target); 739 | } 740 | } 741 | } 742 | }); 743 | } 744 | 745 | getPort(name: string): NodePortElement | null { 746 | return this[internal].ports.get(name) ?? null; 747 | } 748 | 749 | connectedCallback() { 750 | const I = this[internal]; 751 | 752 | I.map = findAncestorOfType(this, NodeMapElement); 753 | 754 | this.dispatchEvent(new NodeEditorAddEvent(this)); 755 | 756 | upgradeAll(this, 'node-port', NodePortElement); 757 | upgradeAll(this, 'node-title', NodeTitleElement); 758 | 759 | I.resizeObserver.observe(I.root); 760 | } 761 | 762 | disconnectedCallback() { 763 | const I = this[internal]; 764 | 765 | const ev = new NodeEditorRemoveEvent(this); 766 | this.dispatchEvent(ev); 767 | if (ev.bubbles && !ev.cancelBubble) I.map?.dispatchEvent(ev); 768 | 769 | delete I.map; 770 | 771 | I.resizeObserver.unobserve(I.root); 772 | } 773 | 774 | get nodeMap(): NodeMapElement | null { 775 | return this[internal].map ?? null; 776 | } 777 | 778 | get x(): number { 779 | return parseInt(this.getAttribute('x') || '0', 10); 780 | } 781 | 782 | set x(v: number | string | null) { 783 | if (v === null) this.removeAttribute('x'); 784 | else this.setAttribute('x', v.toString()); 785 | } 786 | 787 | get y(): number { 788 | return parseInt(this.getAttribute('y') || '0', 10); 789 | } 790 | 791 | set y(v: number | string | null) { 792 | if (v === null) this.removeAttribute('y'); 793 | else this.setAttribute('y', v.toString()); 794 | } 795 | 796 | get name(): string | null { 797 | return this.getAttribute('name'); 798 | } 799 | 800 | set name(v: string | null) { 801 | if (v === null) this.removeAttribute('name'); 802 | else this.setAttribute('name', v); 803 | } 804 | 805 | get width(): number { 806 | const attributeValue = this.getAttribute('width'); 807 | return attributeValue === null 808 | ? this[internal].root.getBoundingClientRect().width 809 | : parseInt(attributeValue, 10); 810 | } 811 | 812 | set width(v: number | string | null) { 813 | if (v === null) this.removeAttribute('width'); 814 | else this.setAttribute('width', v.toString()); 815 | } 816 | 817 | get height(): number { 818 | const attributeValue = this.getAttribute('height'); 819 | return attributeValue === null 820 | ? this[internal].root.getBoundingClientRect().height 821 | : parseInt(attributeValue, 10); 822 | } 823 | 824 | set height(v: number | string | null) { 825 | if (v === null) this.removeAttribute('height'); 826 | else this.setAttribute('height', v.toString()); 827 | } 828 | 829 | attributeChangedCallback( 830 | name: string, 831 | oldValue: string | null, 832 | newValue: string | null 833 | ) { 834 | switch (name) { 835 | case 'x': 836 | case 'y': 837 | this[updateTransform](false, true); 838 | break; 839 | case 'width': 840 | case 'height': 841 | this[updateTransform](true, false); 842 | break; 843 | case 'name': 844 | if (newValue !== oldValue) { 845 | this.dispatchEvent(new NodeNameEvent(newValue, oldValue)); 846 | } 847 | break; 848 | } 849 | } 850 | 851 | private [updateTransform](didResize: boolean, didMove: boolean) { 852 | const I = this[internal]; 853 | 854 | I.root.style.left = `${this.x}px`; 855 | I.root.style.top = `${this.y}px`; 856 | 857 | const maybeWidth = this.getAttribute('width'); 858 | if (maybeWidth !== null) 859 | I.root.style.width = `${parseInt(maybeWidth, 10)}px`; 860 | const maybeHeight = this.getAttribute('height'); 861 | if (maybeHeight !== null) 862 | I.root.style.height = `${parseInt(maybeHeight, 10)}px`; 863 | 864 | // force re-layout 865 | void I.root.offsetWidth; 866 | 867 | this.dispatchEvent( 868 | new NodeEditorTransformEvent({ 869 | x: this.x, 870 | y: this.y, 871 | width: this.width, 872 | height: this.height, 873 | didResize, 874 | didMove 875 | }) 876 | ); 877 | } 878 | } 879 | 880 | class NodeLinkElement extends HTMLElement { 881 | private [internal]: { 882 | map?: NodeMapElement; 883 | elem: SVGGElement; 884 | rootElem: SVGGElement; 885 | pathElem: SVGPathElement; 886 | fromColorElem: SVGStopElement; 887 | toColorElem: SVGStopElement; 888 | gradientElem: SVGLinearGradientElement; 889 | fromPort: NodePortElement | null; 890 | toPort: NodePortElement | null; 891 | refreshAbort?: AbortController; 892 | connected: boolean; 893 | }; 894 | 895 | static get observedAttributes() { 896 | return ['from', 'in', 'to', 'out']; 897 | } 898 | 899 | constructor() { 900 | super(); 901 | 902 | this.attachShadow({ mode: 'closed' }); 903 | 904 | upgradeProperties.call(this); 905 | 906 | // *sigh* 907 | const gradId = `linkgrad${Math.random().toString().slice(2)}`; 908 | 909 | const I = (this[internal] = { 910 | elem: SVG('g'), 911 | rootElem: SVG('g'), 912 | pathElem: attr(SVG('path'), { 913 | part: 'link', 914 | stroke: `url(#${gradId})`, 915 | 'stroke-width': PATH_WIDTH, 916 | fill: 'none', 917 | d: 'M0,0' 918 | }), 919 | fromColorElem: attr(SVG('stop'), { 920 | offset: '25%', 921 | 'stop-color': DEFAULT_PORT_COLOR 922 | }), 923 | toColorElem: attr(SVG('stop'), { 924 | offset: '75%', 925 | 'stop-color': DEFAULT_PORT_COLOR 926 | }), 927 | fromPort: null, 928 | toPort: null, 929 | gradientElem: attr(SVG('linearGradient'), { 930 | id: gradId, 931 | gradientUnits: 'userSpaceOnUse', 932 | x1: '0', 933 | y1: '0', 934 | x2: '0', 935 | y2: '0' 936 | }), 937 | connected: false 938 | }); 939 | 940 | I.gradientElem.appendChild(I.fromColorElem); 941 | I.gradientElem.appendChild(I.toColorElem); 942 | I.rootElem.appendChild(I.gradientElem); 943 | I.rootElem.appendChild(I.pathElem); 944 | } 945 | 946 | get nodeMap(): NodeMapElement | null { 947 | return this[internal].map ?? null; 948 | } 949 | 950 | get fromName(): string | null { 951 | return this.getAttribute('from') || null; 952 | } 953 | 954 | set fromName(v: string | null) { 955 | if (v === null) this.removeAttribute('from'); 956 | else this.setAttribute('from', v); 957 | } 958 | 959 | get toName(): string | null { 960 | return this.getAttribute('to') || null; 961 | } 962 | 963 | set toName(v: string | null) { 964 | if (v === null) this.removeAttribute('to'); 965 | else this.setAttribute('to', v); 966 | } 967 | 968 | get inName(): string | null { 969 | return this.getAttribute('in') ?? null; 970 | } 971 | 972 | set inName(v: string | null) { 973 | if (v === null) this.removeAttribute('in'); 974 | else this.setAttribute('in', v); 975 | } 976 | 977 | get outName(): string | null { 978 | return this.getAttribute('out') ?? null; 979 | } 980 | 981 | set outName(v: string | null) { 982 | if (v === null) this.removeAttribute('out'); 983 | else this.setAttribute('out', v); 984 | } 985 | 986 | get inPort(): NodePortElement | null { 987 | return this[internal].toPort; 988 | } 989 | 990 | get outPort(): NodePortElement | null { 991 | return this[internal].fromPort; 992 | } 993 | 994 | private [notifyConnection]( 995 | connected: boolean, 996 | fromPort: NodePortElement | null, 997 | toPort: NodePortElement | null 998 | ) { 999 | const I = this[internal]; 1000 | if (I.connected === connected) return; 1001 | 1002 | if (connected) { 1003 | this.dispatchEvent(new NodeConnectEvent(this, true)); 1004 | (fromPort).dispatchEvent( 1005 | new NodeConnectEvent(this, false) 1006 | ); 1007 | (toPort).dispatchEvent( 1008 | new NodeConnectEvent(this, false) 1009 | ); 1010 | } else { 1011 | const ev = new NodeDisconnectEvent(this, true); 1012 | this.dispatchEvent(ev); 1013 | if (!this.parentNode && !ev.cancelBubble) { 1014 | I.map?.dispatchEvent(ev); 1015 | } 1016 | if (fromPort) 1017 | fromPort.dispatchEvent(new NodeDisconnectEvent(this, false)); 1018 | if (toPort) 1019 | toPort.dispatchEvent(new NodeDisconnectEvent(this, false)); 1020 | } 1021 | 1022 | I.connected = connected; 1023 | } 1024 | 1025 | private [refreshConnection](forceDisconnect: boolean = false) { 1026 | const I = this[internal]; 1027 | 1028 | let newFromEditor: NodeEditorElement | null; 1029 | let newToEditor: NodeEditorElement | null; 1030 | let newFromPort: NodePortElement | null; 1031 | let newToPort: NodePortElement | null; 1032 | 1033 | try { 1034 | this[notifyConnection](false, I.fromPort, I.toPort); 1035 | 1036 | I.rootElem.remove(); 1037 | I.refreshAbort?.abort(); 1038 | 1039 | const map = I.map; 1040 | if (!map || forceDisconnect) return; 1041 | 1042 | const { toName, fromName, inName, outName } = this; 1043 | if (!(toName && fromName && inName && outName)) return; 1044 | 1045 | newFromEditor = map.getEditor(fromName); 1046 | newToEditor = map.getEditor(toName); 1047 | 1048 | if (!newFromEditor || !newToEditor) return; 1049 | 1050 | newFromPort = newFromEditor.getPort(outName); 1051 | newToPort = newToEditor.getPort(inName); 1052 | 1053 | if (!newFromPort || !newToPort) return; 1054 | } finally { 1055 | I.fromPort = null; 1056 | I.toPort = null; 1057 | } 1058 | 1059 | I.refreshAbort = new AbortController(); 1060 | 1061 | newFromPort.addEventListener( 1062 | 'position', 1063 | () => this[refreshPosition](), 1064 | { 1065 | passive: true, 1066 | signal: I.refreshAbort.signal 1067 | } 1068 | ); 1069 | 1070 | newToPort.addEventListener('position', () => this[refreshPosition](), { 1071 | passive: true, 1072 | signal: I.refreshAbort.signal 1073 | }); 1074 | 1075 | newFromPort.addEventListener('offline', () => this.remove(), { 1076 | signal: I.refreshAbort.signal 1077 | }); 1078 | 1079 | newToPort.addEventListener('offline', () => this.remove(), { 1080 | signal: I.refreshAbort.signal 1081 | }); 1082 | 1083 | newFromPort.addEventListener('color', () => this[refreshColor](), { 1084 | signal: I.refreshAbort.signal 1085 | }); 1086 | 1087 | newToPort.addEventListener('color', () => this[refreshColor](), { 1088 | signal: I.refreshAbort.signal 1089 | }); 1090 | 1091 | newFromPort.addEventListener( 1092 | 'name', 1093 | () => this[refreshConnection](true), 1094 | { 1095 | signal: I.refreshAbort.signal 1096 | } 1097 | ); 1098 | 1099 | newToPort.addEventListener( 1100 | 'name', 1101 | () => this[refreshConnection](true), 1102 | { 1103 | signal: I.refreshAbort.signal 1104 | } 1105 | ); 1106 | 1107 | newFromEditor.addEventListener( 1108 | 'name', 1109 | () => this[refreshConnection](true), 1110 | { 1111 | signal: I.refreshAbort.signal 1112 | } 1113 | ); 1114 | 1115 | newToEditor.addEventListener( 1116 | 'name', 1117 | () => this[refreshConnection](true), 1118 | { 1119 | signal: I.refreshAbort.signal 1120 | } 1121 | ); 1122 | 1123 | I.fromPort = newFromPort; 1124 | I.toPort = newToPort; 1125 | 1126 | this[refreshPosition](); 1127 | this[refreshColor](); 1128 | 1129 | I.rootElem.addEventListener('mousedown', e => e.preventDefault(), { 1130 | signal: I.refreshAbort.signal 1131 | }); 1132 | 1133 | I.rootElem.addEventListener( 1134 | 'dblclick', 1135 | e => { 1136 | e.preventDefault(); 1137 | e.stopPropagation(); 1138 | this.remove(); 1139 | }, 1140 | { 1141 | signal: I.refreshAbort.signal 1142 | } 1143 | ); 1144 | 1145 | I.elem.appendChild(I.rootElem); 1146 | 1147 | return void this[notifyConnection](true, I.fromPort, I.toPort); 1148 | } 1149 | 1150 | private [refreshPosition]() { 1151 | const I = this[internal]; 1152 | 1153 | const from = I.fromPort; 1154 | const to = I.toPort; 1155 | 1156 | if (!from || !to) { 1157 | I.pathElem.setAttribute('d', 'M0 0'); 1158 | return; 1159 | } 1160 | 1161 | attr(I.gradientElem, { 1162 | x1: from.handleX, 1163 | y1: from.handleY, 1164 | x2: to.handleX, 1165 | y2: to.handleY 1166 | }); 1167 | 1168 | setLinkCurve( 1169 | I.pathElem, 1170 | from.handleX, 1171 | from.handleY, 1172 | to.handleX, 1173 | to.handleY 1174 | ); 1175 | } 1176 | 1177 | private [refreshColor]() { 1178 | const I = this[internal]; 1179 | I.fromColorElem.setAttribute( 1180 | 'stop-color', 1181 | I.fromPort?.color ?? DEFAULT_PORT_COLOR 1182 | ); 1183 | I.toColorElem.setAttribute( 1184 | 'stop-color', 1185 | I.toPort?.color ?? DEFAULT_PORT_COLOR 1186 | ); 1187 | } 1188 | 1189 | attributeChangedCallback() { 1190 | this.dispatchEvent(new NodeUnlinkEvent(this)); 1191 | this.dispatchEvent(new NodeLinkEvent(this)); 1192 | this[refreshConnection](); 1193 | } 1194 | 1195 | connectedCallback() { 1196 | const I = this[internal]; 1197 | I.map = findAncestorOfType(this, NodeMapElement); 1198 | this.dispatchEvent(new NodeLinkEvent(this)); 1199 | this[refreshConnection](); 1200 | } 1201 | 1202 | disconnectedCallback() { 1203 | const I = this[internal]; 1204 | const ev = new NodeUnlinkEvent(this); 1205 | this.dispatchEvent(ev); 1206 | if (ev.bubbles && !ev.cancelBubble) I.map?.dispatchEvent(ev); 1207 | this[refreshConnection](true); 1208 | delete I.map; 1209 | } 1210 | } 1211 | 1212 | class NodeCanvas extends EventTarget { 1213 | elem: SVGSVGElement; 1214 | root: SVGSVGElement; 1215 | panX: number; 1216 | panY: number; 1217 | width: number; 1218 | height: number; 1219 | zoom: number; 1220 | pattern: SVGPatternElement; 1221 | 1222 | constructor() { 1223 | super(); 1224 | 1225 | this.elem = SVG('svg'); 1226 | this.root = SVG('svg'); 1227 | this.pattern = SVG('pattern'); 1228 | this.width = 0; 1229 | this.height = 0; 1230 | this.panX = 0; 1231 | this.panY = 0; 1232 | this.zoom = 1; 1233 | 1234 | this.elem.classList.add('node-canvas-base'); 1235 | this.root.classList.add('node-canvas-root'); 1236 | 1237 | this.root.style.overflow = 'visible'; 1238 | 1239 | const bgDot = attr(SVG('circle'), { 1240 | fill: 'rgba(127,127,127,0.3)', 1241 | r: GRID_DOT_RADIUS, 1242 | cx: GRID_DOT_RADIUS, 1243 | cy: GRID_DOT_RADIUS 1244 | }); 1245 | 1246 | const bgRect = attr(SVG('rect'), { 1247 | fill: 'url(#nodebg)', 1248 | width: '100%', 1249 | height: '100%' 1250 | }); 1251 | 1252 | let dragging = false; 1253 | let dragStart = [0, 0, 0, 0]; 1254 | 1255 | const onDrag = (e: PointerEvent) => { 1256 | const newX = dragStart[0] + (e.clientX - dragStart[2]) / this.zoom; 1257 | const newY = dragStart[1] + (e.clientY - dragStart[3]) / this.zoom; 1258 | this.setPan(newX, newY); 1259 | }; 1260 | 1261 | this.elem.addEventListener('mousedown', e => e.preventDefault()); 1262 | 1263 | this.elem.addEventListener('pointerdown', e => { 1264 | if (dragging) return; 1265 | if (e.button !== 0 && e.button !== 1) return; 1266 | 1267 | if ( 1268 | e.target !== this.elem && 1269 | e.target !== this.root && 1270 | e.target !== bgRect 1271 | ) { 1272 | return; 1273 | } 1274 | 1275 | dragStart = [this.panX, this.panY, e.clientX, e.clientY]; 1276 | 1277 | this.elem.setPointerCapture(e.pointerId); 1278 | this.elem.addEventListener('pointermove', onDrag); 1279 | dragging = true; 1280 | this.dispatchEvent(new NodeMapViewportStartEvent()); 1281 | }); 1282 | 1283 | this.elem.addEventListener('pointerup', e => { 1284 | this.elem.releasePointerCapture(e.pointerId); 1285 | this.elem.removeEventListener('pointermove', onDrag); 1286 | dragging = false; 1287 | this.dispatchEvent(new NodeMapViewportStopEvent()); 1288 | }); 1289 | 1290 | this.elem.addEventListener( 1291 | 'wheel', 1292 | e => { 1293 | if (dragging) return; 1294 | this.pan(-e.clientX / this.zoom, -e.clientY / this.zoom); 1295 | this.setZoom( 1296 | this.zoom + 1297 | -Math.sign(e.deltaY) * 1298 | ZOOM_RATE * 1299 | Math.pow(this.zoom, 0.0001) 1300 | ); 1301 | this.pan(e.clientX / this.zoom, e.clientY / this.zoom); 1302 | }, 1303 | { passive: true } 1304 | ); 1305 | 1306 | this.pattern.id = 'nodebg'; 1307 | attr(this.pattern, { 1308 | viewBox: `0 0 ${GRID_RESOLUTION} ${GRID_RESOLUTION}`, 1309 | width: GRID_RESOLUTION, 1310 | height: GRID_RESOLUTION, 1311 | patternUnits: 'userSpaceOnUse' 1312 | }); 1313 | 1314 | this.pattern.appendChild(bgDot); 1315 | this.elem.appendChild(this.pattern); 1316 | this.elem.appendChild(bgRect); 1317 | this.elem.appendChild(this.root); 1318 | } 1319 | 1320 | appendChild(elem: SVGElement) { 1321 | this.root.appendChild(elem); 1322 | } 1323 | 1324 | viewToWorld(x: number, y: number): [number, number] { 1325 | return [x / this.zoom - this.panX, y / this.zoom - this.panY]; 1326 | } 1327 | 1328 | worldToView(x: number, y: number): [number, number] { 1329 | return [(x + this.panX) * this.zoom, (y + this.panY) * this.zoom]; 1330 | } 1331 | 1332 | getPan(): [number, number] { 1333 | return [this.panX, this.panY]; 1334 | } 1335 | 1336 | setPan(x: number, y: number) { 1337 | this.panX = x; 1338 | this.panY = y; 1339 | 1340 | attr(this.root, { 1341 | x, 1342 | y 1343 | }); 1344 | 1345 | attr(this.pattern, { 1346 | x, 1347 | y 1348 | }); 1349 | 1350 | this.dispatchEvent(new NodeMapViewportEvent(x, y, this.zoom)); 1351 | } 1352 | 1353 | pan(x: number, y: number) { 1354 | this.setPan(this.panX + x, this.panY + y); 1355 | } 1356 | 1357 | setSize(width: number, height: number) { 1358 | this.width = width; 1359 | this.height = height; 1360 | this.updateTransform(); 1361 | } 1362 | 1363 | setZoom(zoom: number) { 1364 | this.zoom = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, zoom)); 1365 | this.updateTransform(); 1366 | this.dispatchEvent( 1367 | new NodeMapViewportEvent(this.panX, this.panY, this.zoom) 1368 | ); 1369 | } 1370 | 1371 | updateTransform() { 1372 | this.elem.setAttribute( 1373 | 'viewBox', 1374 | `0 0 ${this.width / this.zoom} ${this.height / this.zoom}` 1375 | ); 1376 | } 1377 | } 1378 | 1379 | class NodeMapElement extends HTMLElement { 1380 | private [internal]: { 1381 | canvas: NodeCanvas; 1382 | editorCanvas: HTMLDivElement; 1383 | resizeObserver: ResizeObserver; 1384 | editors: Map; 1385 | links: Set; 1386 | ports: Map; 1387 | connections: Map; 1388 | }; 1389 | 1390 | constructor() { 1391 | super(); 1392 | 1393 | const shadow = this.attachShadow({ 1394 | mode: 'closed' 1395 | }); 1396 | 1397 | const I = (this[internal] = { 1398 | canvas: new NodeCanvas(), 1399 | editorCanvas: HTML('div'), 1400 | resizeObserver: createDebouncedResizeObserver((width, height) => 1401 | this[internal].canvas.setSize(width, height) 1402 | ), 1403 | editors: new Map(), 1404 | links: new Set(), 1405 | ports: new Map(), 1406 | connections: new Map() // TODO actually populate 1407 | }); 1408 | 1409 | const style = HTML('style'); 1410 | const root = HTML('div'); 1411 | 1412 | shadow.appendChild(style); 1413 | shadow.appendChild(root); 1414 | root.appendChild(I.canvas.elem); 1415 | root.appendChild(I.editorCanvas); 1416 | 1417 | I.canvas.elem.setAttribute('part', 'background'); 1418 | 1419 | I.editorCanvas.appendChild(HTML('slot')); 1420 | I.editorCanvas.classList.add('canvas'); 1421 | 1422 | I.canvas.addEventListener('viewport', e => { 1423 | e.stopPropagation(); 1424 | const { zoom, offsetX, offsetY } = e; 1425 | const transform = `scale(${zoom}) translate(${offsetX}px, ${offsetY}px)`; 1426 | I.editorCanvas.style.transform = transform; 1427 | }); 1428 | 1429 | I.canvas.addEventListener('viewportstart', e => { 1430 | e.stopPropagation(); 1431 | this.classList.add('panning'); 1432 | }); 1433 | 1434 | I.canvas.addEventListener('viewportstop', e => { 1435 | e.stopPropagation(); 1436 | this.classList.remove('panning'); 1437 | }); 1438 | 1439 | style.textContent = ` 1440 | :host { 1441 | display: flex; 1442 | flex-wrap: wrap; 1443 | height: 100%; 1444 | overflow: hidden; 1445 | } 1446 | 1447 | :host > div { 1448 | width: 100%; 1449 | height: 100%; 1450 | position: relative; 1451 | display: block; 1452 | overflow: hidden; 1453 | } 1454 | 1455 | :host > div > .canvas { 1456 | pointer-events: none; 1457 | transform-origin: top left; 1458 | } 1459 | 1460 | :host > div > .canvas > * { 1461 | pointer-events: auto; 1462 | } 1463 | 1464 | :host > div > * { 1465 | position: absolute; 1466 | left: 0; 1467 | top: 0; 1468 | width: 100%; 1469 | height: 100%; 1470 | } 1471 | `; 1472 | 1473 | const addPort = (port: NodePortElement) => { 1474 | if (I.ports.has(port)) return; 1475 | 1476 | const controller = new AbortController(); 1477 | I.ports.set(port, controller); 1478 | 1479 | port.addEventListener( 1480 | 'pointerdown', 1481 | e => { 1482 | if (e.button !== 0) return; 1483 | 1484 | const pointerId = e.pointerId; 1485 | this.setPointerCapture(e.pointerId); 1486 | 1487 | const dragAbort = new AbortController(); 1488 | 1489 | const linkMarkerElem = attr(SVG('path'), { 1490 | stroke: port.color, 1491 | 'stroke-width': PATH_WIDTH, 1492 | fill: 'none', 1493 | d: `M${port.handleX} ${port.handleY}` 1494 | }); 1495 | let lastTargetPort: null | NodePortElement = null; 1496 | 1497 | dragAbort.signal.addEventListener('abort', () => 1498 | linkMarkerElem.remove() 1499 | ); 1500 | 1501 | I.canvas.appendChild(linkMarkerElem); 1502 | 1503 | this.addEventListener( 1504 | 'pointermove', 1505 | e => { 1506 | const isOut = port.hasAttribute('out'); 1507 | lastTargetPort = null; 1508 | 1509 | const maybeElem = document.elementFromPoint( 1510 | e.pageX, 1511 | e.pageY 1512 | ); 1513 | 1514 | if (maybeElem) { 1515 | const elem: NodePortElement | undefined = 1516 | findAncestorOfType( 1517 | maybeElem, 1518 | NodePortElement 1519 | ); 1520 | 1521 | if ( 1522 | elem && 1523 | elem !== port && 1524 | isOut !== elem.hasAttribute('out') && 1525 | port.nodeEditor !== elem.nodeEditor 1526 | ) { 1527 | lastTargetPort = elem; 1528 | } 1529 | } 1530 | 1531 | const startPoint: [number, number] = [ 1532 | port.handleX, 1533 | port.handleY 1534 | ]; 1535 | const endPoint: [number, number] = lastTargetPort 1536 | ? [ 1537 | lastTargetPort.handleX, 1538 | lastTargetPort.handleY 1539 | ] 1540 | : I.canvas.viewToWorld( 1541 | e.pageX - this.offsetLeft, 1542 | e.pageY - this.offsetTop 1543 | ); 1544 | 1545 | setLinkCurve( 1546 | linkMarkerElem, 1547 | ...(isOut ? startPoint : endPoint), 1548 | ...(isOut ? endPoint : startPoint) 1549 | ); 1550 | }, 1551 | { signal: dragAbort.signal } 1552 | ); 1553 | 1554 | document.addEventListener( 1555 | 'pointerup', 1556 | e => { 1557 | if (e.pointerId !== pointerId) return; 1558 | this.releasePointerCapture(e.pointerId); 1559 | dragAbort.abort(); 1560 | 1561 | if ( 1562 | lastTargetPort && 1563 | !I.connections.get(port)?.has(lastTargetPort) && 1564 | port.nodeEditor?.name && 1565 | lastTargetPort.nodeEditor?.name && 1566 | port.name && 1567 | lastTargetPort.name 1568 | ) { 1569 | const a = [port.nodeEditor.name, port.name]; 1570 | const b = [ 1571 | lastTargetPort.nodeEditor.name, 1572 | lastTargetPort.name 1573 | ]; 1574 | 1575 | const forward = 1576 | port.isOutputPort && 1577 | !lastTargetPort.isOutputPort; 1578 | 1579 | this.appendChild( 1580 | attr(HTML('node-link'), { 1581 | from: forward ? a[0] : b[0], 1582 | to: forward ? b[0] : a[0], 1583 | out: forward ? a[1] : b[1], 1584 | in: forward ? b[1] : a[1] 1585 | }) 1586 | ); 1587 | } 1588 | }, 1589 | { signal: dragAbort.signal } 1590 | ); 1591 | }, 1592 | { signal: controller.signal } 1593 | ); 1594 | }; 1595 | 1596 | const removePort = (port: NodePortElement) => { 1597 | if (!I.ports.has(port)) return; 1598 | I.ports.get(port).abort(); 1599 | I.ports.delete(port); 1600 | }; 1601 | 1602 | const createBoundary = (name: string) => 1603 | this.addEventListener(name, e => e.stopPropagation()); 1604 | createBoundary('transform'); 1605 | createBoundary('position'); 1606 | createBoundary('color'); 1607 | 1608 | this.addEventListener('connect', e => { 1609 | e.stopPropagation(); 1610 | 1611 | const link = (e).link; 1612 | const set = I.connections.get(link.outPort); 1613 | if (set) { 1614 | set.add(link.inPort); 1615 | } else { 1616 | I.connections.set(link.outPort, new Set([link.inPort])); 1617 | } 1618 | }); 1619 | 1620 | this.addEventListener('disconnect', e => { 1621 | e.stopPropagation(); 1622 | 1623 | const link = (e).link; 1624 | const set = I.connections.get(link.outPort); 1625 | if (set) { 1626 | set.delete(link.inPort); 1627 | if (set.size === 0) I.connections.delete(link.outPort); 1628 | } 1629 | }); 1630 | 1631 | this.addEventListener('online', e => { 1632 | e.stopPropagation(); 1633 | 1634 | const port = (e).port; 1635 | addPort(port); 1636 | }); 1637 | 1638 | this.addEventListener('offline', e => { 1639 | e.stopPropagation(); 1640 | 1641 | const port = (e).port; 1642 | removePort(port); 1643 | }); 1644 | 1645 | this.addEventListener('add', e => { 1646 | e.stopPropagation(); 1647 | 1648 | const editor = (e).editor; 1649 | if (editor.name) { 1650 | const existing = I.editors.get(editor.name); 1651 | if (existing && existing !== editor) { 1652 | console.warn( 1653 | 'ignoring editor with duplicate name:', 1654 | editor.name 1655 | ); 1656 | return; 1657 | } 1658 | I.editors.set(editor.name, editor); 1659 | } 1660 | }); 1661 | 1662 | this.addEventListener('remove', e => { 1663 | e.stopPropagation(); 1664 | 1665 | const editor = (e).editor; 1666 | if (editor.name) { 1667 | const existing = I.editors.get(editor.name); 1668 | if (existing && existing === editor) { 1669 | I.editors.delete(editor.name); 1670 | } 1671 | } 1672 | }); 1673 | 1674 | this.addEventListener('name', e => { 1675 | e.stopPropagation(); 1676 | 1677 | const { target, oldName } = e; 1678 | 1679 | if (target instanceof NodeEditorElement) { 1680 | if (oldName) { 1681 | const existing = I.editors.get(oldName); 1682 | if (existing && existing === target) { 1683 | I.editors.delete(oldName); 1684 | } 1685 | } 1686 | 1687 | if (target.name) { 1688 | const existing = I.editors.get(target.name); 1689 | if (existing && existing !== target) { 1690 | console.warn( 1691 | 'ignoring editor with duplicate name:', 1692 | target.name 1693 | ); 1694 | } else { 1695 | I.editors.set(target.name, target); 1696 | } 1697 | } 1698 | } else if (target instanceof NodePortElement) { 1699 | if (oldName && !target.name) removePort(target); 1700 | else if (!oldName && target.name) addPort(target); 1701 | } 1702 | }); 1703 | 1704 | this.addEventListener('link', e => { 1705 | e.stopPropagation(); 1706 | 1707 | const link = e.target; 1708 | if (link instanceof NodeLinkElement) { 1709 | const I = this[internal]; 1710 | I.links.add(link); 1711 | I.canvas.appendChild(link[internal].elem); 1712 | } 1713 | }); 1714 | 1715 | this.addEventListener('unlink', e => { 1716 | e.stopPropagation(); 1717 | 1718 | const link = e.target; 1719 | if (link instanceof NodeLinkElement) { 1720 | const I = this[internal]; 1721 | I.links.delete(link); 1722 | link[internal].elem.remove(); 1723 | } 1724 | }); 1725 | } 1726 | 1727 | getEditor(name: string): NodeEditorElement | null { 1728 | return this[internal].editors.get(name) ?? null; 1729 | } 1730 | 1731 | get zoom(): number { 1732 | return this[internal].canvas.zoom; 1733 | } 1734 | 1735 | connectedCallback() { 1736 | upgradeAll(this, 'node-editor', NodeEditorElement); 1737 | upgradeAll(this, 'node-link', NodeLinkElement); 1738 | 1739 | this[internal].resizeObserver.observe(this); 1740 | } 1741 | 1742 | disconnectedCallback() { 1743 | this[internal].resizeObserver.unobserve(this); 1744 | } 1745 | } 1746 | 1747 | function defineTag(tag: string, cls: new () => T) { 1748 | customElements.define(tag, cls); 1749 | document 1750 | .querySelectorAll(tag) 1751 | .forEach( 1752 | node => !(node instanceof cls) && customElements.upgrade(node) 1753 | ); 1754 | } 1755 | 1756 | defineTag('node-map', NodeMapElement); 1757 | defineTag('node-editor', NodeEditorElement); 1758 | defineTag('node-title', NodeTitleElement); 1759 | defineTag('node-port', NodePortElement); 1760 | defineTag('node-link', NodeLinkElement); 1761 | 1762 | export { 1763 | NodeTitleElement, 1764 | NodePortElement, 1765 | NodeEditorElement, 1766 | NodeLinkElement, 1767 | NodeMapElement, 1768 | NodeEditorTransformEvent, 1769 | NodePortPositionEvent, 1770 | NodePortColorEvent, 1771 | NodePortOnlineEvent, 1772 | NodePortOfflineEvent, 1773 | NodeEditorAddEvent, 1774 | NodeEditorRemoveEvent, 1775 | NodeNameEvent, 1776 | NodeLinkEvent, 1777 | NodeUnlinkEvent, 1778 | NodeConnectEvent, 1779 | NodeDisconnectEvent 1780 | }; 1781 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "node-editor", 3 | "version": "0.2.3", 4 | "description": "Generic node editor web components", 5 | "homepage": "https://github.com/qix-/node-editor", 6 | "repository": "github:qix-/node-editor", 7 | "type": "module", 8 | "types": "dist/index.d.ts", 9 | "bugs": { 10 | "url": "https://github.com/qix-/node-editor/issues" 11 | }, 12 | "scripts": { 13 | "format": "prettier --write --ignore-path .gitignore .", 14 | "lint": "prettier --check --ignore-path .gitignore .", 15 | "format:staged": "pretty-quick --staged", 16 | "lint:commit": "commitlint -x @commitlint/config-conventional --edit", 17 | "build": "tsc", 18 | "prepublish": "rm -rf dist && npm run build" 19 | }, 20 | "files": [ 21 | "dist", 22 | "screenshot.png", 23 | "README.md", 24 | "LICENSE" 25 | ], 26 | "browser": "./dist/index.js", 27 | "exports": "./dist/index.js", 28 | "keywords": [ 29 | "node", 30 | "editor", 31 | "html", 32 | "browser", 33 | "workflow", 34 | "routing", 35 | "router" 36 | ], 37 | "author": "Josh Junon ", 38 | "license": "MIT", 39 | "devDependencies": { 40 | "@commitlint/cli": "16.1.0", 41 | "@commitlint/config-conventional": "16.0.0", 42 | "@vercel/git-hooks": "1.0.0", 43 | "prettier": "2.5.1", 44 | "pretty-quick": "3.1.3", 45 | "typescript": "4.5.5" 46 | }, 47 | "publishConfig": { 48 | "access": "public", 49 | "tag": "latest" 50 | }, 51 | "git": { 52 | "pre-commit": "format:staged", 53 | "commit-msg": "lint:commit" 54 | }, 55 | "prettier": { 56 | "useTabs": true, 57 | "semi": true, 58 | "singleQuote": true, 59 | "jsxSingleQuote": false, 60 | "trailingComma": "none", 61 | "arrowParens": "avoid", 62 | "requirePragma": false, 63 | "insertPragma": false, 64 | "endOfLine": "lf" 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Qix-/node-editor/036db09f665f399cc5154463d0d23784d52e558e/screenshot.png -------------------------------------------------------------------------------- /stress.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Node Editor Test Harness 5 | 6 | 83 | 84 |
85 | 86 | 93 | 94 | Value 95 | 96 |
97 | 98 | 99 | 100 |
101 |
102 | 103 | 104 | 105 | Add 106 | 107 |
108 | 109 | Result 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 |
118 |
119 | 120 | 128 | 129 | Display 130 | 131 |
132 | 133 | 🎉 134 | 0 135 | 136 |
137 |
138 | 139 | 140 | 141 | 142 | 148 |
149 |
150 | 192 | 193 | 194 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es6", 4 | "module": "es2020", 5 | "lib": ["ES2021", "DOM"], 6 | "declaration": true, 7 | "declarationMap": true, 8 | "sourceMap": true, 9 | "outDir": "dist", 10 | "strict": true, 11 | "noImplicitAny": true, 12 | "noUnusedLocals": true, 13 | "noUnusedParameters": true, 14 | "noImplicitReturns": true, 15 | "noFallthroughCasesInSwitch": true, 16 | "moduleResolution": "node" 17 | }, 18 | "include": ["index.ts"], 19 | "exclude": ["node_modules", "dist"] 20 | } 21 | --------------------------------------------------------------------------------