UNPKG

@jsx6/nodditor

Version:

JSX6 blocky nodes code editor

612 lines (563 loc) 16.6 kB
import { classIf, findParent, fireCustom, getAttr, hSvg, insert, isNode, listen, remove, setAttribute, setSelected, setVisible, toDomNode, } from '@jsx6/jsx6' import { $Or, observeNow } from '@jsx6/signal' import { JsxW, define } from '@jsx6/w' import { ConnectLine } from './ConnectLine.js' import { LineInteraction } from './LineInteraction.js' import { findConnector, recalcPos, updatePos } from './connectorUtil.js' import { getBlocksMinXY } from './getBlocksMinXY.js' import { finalize, listenUntil } from './listenUntil.js' import { moveMenu } from './moveMenu.js' import { pairChanged } from './pairUtils.js' import { updateObserver } from './updateObserver.js' /** @typedef BlockData @property {string} id @property {string} type @property {Array<number>} pos @property {Array<number>} size @property {HTMLElement} el @property {Object} block @property {Map<string, ConnectorData>} map @property {Set<Element>} resizeSet @property {Map<string, ConnectorData>} connectorMap @property {NodeEditor} editor @typedef HTMLBlock_ @property {BlockData} neBlock @typedef {HTMLElement & HTMLBlock_} HTMLBlock @typedef ConnectorData @property {string} id @property {string} dir @property {number} changed @property {string} idFull @property {HTMLElement} el @property {BlockData} root @property {Array<number>} relPos @property {Array<number>} pos @property {number} offsetX @property {number} offsetY @property {Array<number>} size @property {NodeEditor} editor @typedef HTMLConnector_ @property {ConnectorData} ncData @typedef {HTMLElement & HTMLConnector_} HTMLConnector @typedef LinePoint @property {Array<number>} pos @property {ConnectorData} con @property {Array<Function>} listen @property {string} align */ /** * */ export class NodeEditor extends JsxW { static { define('jsx6-nodditor', this) } /** @type {Array<BlockData>} */ blocks = [] /** @type {Array<ConnectLine>} */ lines = [] /** @type {ConnectLine} */ selectedLine /** @type {Array<BlockData>} */ selectedBlocks blockMap = new Map() nodeMap = new Map() /** @type {HTMLElement} */ currentMenu = null /** * @param {ConnectorData} con */ newConnector(con) { this.lineinteraciton.newConnector(con) } /** * * @param {any} block * @param {string} id * @param {Object} [param2] * @returns {BlockData} */ add(block, id, { pos = [0, 0], type = '' } = {}) { setAttribute(block, 'nid', id) let rootNode = /** @type {HTMLBlock}*/ (toDomNode(block)) block.setNodeEditor?.(this) // @ts-ignore rootNode.nodeEditor = this insert(this.contentArea, rootNode) rootNode.style.top = '0' rootNode.style.left = '0' /** @type {BlockData} */ let blockData = (rootNode.neBlock = { id, type, pos: [0, 0], size: [0, 0], el: rootNode, block, map: new Map(), resizeSet: new Set(), connectorMap: new Map(), editor: this, }) this.blocks.push(blockData) this.blockMap.set(id, blockData) this.nodeMap.set(blockData.el, blockData) this.setPos(blockData, pos) this.recheckConnectors(blockData) return blockData } recheckConnectors(blockData) { let { resizeSet } = findConnector(blockData) updateObserver(resizeSet, blockData.resizeSet, this.observer) } /** * * @param {string} id * @returns {BlockData} */ getBlockData(id) { if (typeof id === 'string') return this.blockMap.get(id) if (isNode(id)) return this.nodeMap.get(id) return id // assume it is block data } /** * * @param {string} nid * @returns {Array<number>} */ getPos(nid) { return this.blockMap.get(nid)?.pos } /** * * @param {string | Array<string>} blockId block id * @param {string} [cid] connector id * @returns {ConnectorData} */ getConnector(blockId, cid) { if (blockId instanceof Array) { cid = blockId[1] blockId = blockId[0] } else if (!cid && blockId.includes('/')) { let idx = blockId.indexOf('/') cid = blockId.substring(idx + 1) blockId = blockId.substring(0, idx) } return this.blockMap.get(blockId)?.connectorMap.get(cid) } lineHasConnector(con) { for (let i = 0; i < this.lines.length; i++) { let tmp = this.lines[i] if (tmp.p1.con == con || tmp.p2.con == con) return true } return false } removeLine(line) { let idx = this.lines.indexOf(line) if (idx != -1) { this.lines.splice(idx, 1) remove(line.el) finalize(line) } } removeBlock(block) { let idx = this.blocks.indexOf(block) if (idx != -1) { this.blocks.splice(idx, 1) // todo remove lines connected to it let lines = this.lines.filter(line => line.p1.con?.root.id == block.id || line.p2.con?.root.id == block.id) lines.forEach(l => this.removeLine(l)) remove(block.el) finalize(block) } } getMinXY() { return getBlocksMinXY(this.blocks) } setZoomAndPos(zoom, x, y) { this.zoom = zoom const [minx, miny] = this.getMinXY() this.moveAll(x - minx, y - miny) } resetView(padx = 30, pady = 30) { const [minx, miny] = this.getMinXY() this.moveAll(-minx + padx, -miny + padx) this.fireMoveDone() } moveAll(dx = 0, dy = 0) { this.blocks.forEach(blockData => { let [x, y] = blockData.pos this._setPos(blockData, [x + dx, y + dy]) }) } setPos(nid, pos) { let blockData = this.getBlockData(nid) if (blockData) this._setPos(blockData, pos) } _setPos(blockData, pos) { blockData.pos = pos blockData.connectorMap.forEach(con => { updatePos(con) this.fireCustom(con.el, 'ne-move', { ...con }) }) blockData.el.style.transform = `translate(${pos[0]}px, ${pos[1]}px)` } tpl({ menu = null, ...attr } = {}) { attr.tabindex = '0' super.tpl(attr) this.menuGenerator = menu // @ts-ignore this.lineinteraciton = new LineInteraction(this) const handler = arr => { /** @type {Set<BlockData>} */ let blocksChanged = new Set() let changeTs = Date.now() arr.forEach(e => { let { target, contentRect, borderBoxSize } = e let boxSize = borderBoxSize[0] let size = [boxSize.inlineSize, boxSize.blockSize] if (e.target == this) { this.realWidth = size[0] this.realHeight = size[1] return } if (target.ncData) { /** @type {ConnectorData} */ let ncData = target.ncData if (pairChanged(size, ncData.size)) { // fire change ncData.size = size ncData.changed = changeTs blocksChanged.add(ncData.root) } } else if (target.neBlock) { /** @type {BlockData} */ let neBlock = target.neBlock if (pairChanged(size, neBlock.size)) { // fire change neBlock.size = size blocksChanged.add(neBlock) } } else { let neBlock let p = target while (p && !neBlock) { neBlock = p.neBlock p = p.parentElement } if (neBlock) blocksChanged.add(neBlock) } }) blocksChanged.forEach(block => { this.recheckConnectors(block) block.connectorMap.forEach(con => { let tmp = con.pos recalcPos(con) // changed position or size if (pairChanged(tmp, con.pos) || con.changed == changeTs) { this.fireCustom(con.el, 'ne-move', { ...con }) } }) }) } this.observer = new ResizeObserver(handler) this.observer.observe(this) this.svgLayer = hSvg('svg', { style: 'position:absolute;pointer-events: none; width: 100%; height: 100%;' }) this.contentArea = ( <div style="position:absolute;top:0;left:0;width:100%; height:100%; transform-origin: top left;"> {this.svgLayer} </div> ) this._zoom = 1 let el = this.contentArea // @ts-ignore const { $s } = this // create a signal tht tells if editor has focus to work with blocks or lines // used to decide if delete will try to delete blocks or lines and for other needs this.$focusOrSelecting = $Or($s.isDown, $s.hasFocus) observeNow(this.$focusOrSelecting, f => classIf(el, 'focused', f)) let lx = 0 let ly = 0 let domNode let nid /** @type {BlockData} */ let blockData let ignore el.addEventListener('dragstart', e => { if (blockData) e.preventDefault() }) el.addEventListener('pointerdown', e => { ignore = false let hasDrag let hasBlock let insideMenu domNode = findParent(e.target, p => { if (!p.hasAttribute) return false if (p.hasAttribute('ne-drag')) hasDrag = true if (p.hasAttribute('ne-nodrag')) hasBlock = true if (p == this.currentMenu) { insideMenu = true ignore = true return true } return p.hasAttribute('nid') }) if ((!hasDrag && domNode) || hasBlock || insideMenu) return if (domNode) { nid = getAttr(domNode, 'nid') blockData = this.getBlockData(nid) domNode.startLeft = blockData.pos[0] domNode.startTop = blockData.pos[1] } lx = e.clientX ly = e.clientY $s.isDown = true }) el.addEventListener('pointerup', e => { if (ignore) return if (!$s.isDown()) { this.deselect() return } $s.isDown = false if ($s.isMoving()) el.releasePointerCapture(e.pointerId) $s.isMoving = false this.fireMoveDone(blockData) if ($s.isMoving()) { e.preventDefault() } else { if (blockData) this.selectBlocks([blockData]) } blockData = domNode = nid = undefined //this.focus() }) let _timer el.addEventListener('pointermove', e => { if (ignore) return if (!$s.isDown()) return if (!$s.isMoving()) { // pointer capture inside pointerdown caused clicking to not work // it is better to capture pointer only on pointer down + first movement el.setPointerCapture(e.pointerId) $s.isMoving = true if (blockData) { this.selectBlocks([blockData]) } else { let menu = this.currentMenu if (menu) menu.style.display = 'none' } window.getSelection().removeAllRanges() this.focus() } if (blockData) { const top = domNode.startTop + (-ly + e.clientY) / this._zoom const left = domNode.startLeft + (-lx + e.clientX) / this._zoom if (_timer) cancelAnimationFrame(_timer) _timer = requestAnimationFrame(() => { if (!blockData) return this.setPos(blockData, [left, top]) let menu = this.currentMenu if (menu) moveMenu([blockData], menu, this._zoom) this.fireMove(blockData) }) } else { this.moveAll((-lx + e.clientX) / this._zoom, (-ly + e.clientY) / this._zoom) lx = e.clientX ly = e.clientY } }) const keypress = e => { if ((e.key === 'Delete' || e.key === 'Backspace') && this.$focusOrSelecting()) { if (this.selectedLine) { this.removeLine(this.selectedLine) } else if (this.selectBlocks.length) { this.deleteSelectedBlocks() } e.preventDefault() } } listen(this, 'keydown', keypress) this.onfocus = e => ($s.hasFocus = true) this.onblur = e => ($s.hasFocus = false) return this.contentArea } get zoom() { return this._zoom } set zoom(zoom) { if (this._zoom == zoom) return this._zoom = zoom this.contentArea.style.transform = `scale(${zoom})` this.udpateSize() } udpateSize() { this.contentArea.style.width = this.realWidth / this._zoom + 'px' this.contentArea.style.height = this.realHeight / this._zoom + 'px' } changeZoomMouse(zoom, e) { const rect = this.getBoundingClientRect() this.changeZoom(zoom, e.clientX - rect.x, e.clientY - rect.y) } changeZoomCenter(zoom) { this.changeZoom(zoom, this.realWidth / 2, this.realHeight / 2) } changeZoom(delta, x = 0, y = 0) { const recenter = !!x let zoom = this._zoom let rect, relx, rely if (recenter) { relx = x / zoom rely = y / zoom } let newZoom = this._zoom + delta if (newZoom > 1) newZoom = 1 if (newZoom < 0.3) newZoom = 0.3 if (newZoom != zoom) { if (recenter) { let relx2 = x / newZoom let rely2 = y / newZoom this.moveAll(relx2 - relx, rely2 - rely) this.fireMoveDone() } this.zoom = newZoom } } clear() { this.deleteBlocks([...this.blocks]) this.selectBlocks([]) } deleteSelectedBlocks() { this.deleteBlocks([...this.selectedBlocks]) this.selectBlocks([]) } deleteBlocks(blocks) { blocks.forEach(block => { this.removeBlock(block) }) } /** * * @param {string|Array<string>} c1 * @param {string|Array<string>} c2 * @returns {ConnectLine} */ addConnectorFromTo(c1, c2) { let path = new ConnectLine() path.setPoint1(this.getConnector(c1), false) path.setPoint2(this.getConnector(c2)) return this.addConnector(path) } /** * @param {ConnectLine} con */ addConnector(con) { listenUntil(con, con.el, 'click', e => { this.selectConnector(con) }) insert(this.svgLayer, con.el) this.lines.push(con) return con } /** * * @typedef Menu * @property {Function} afterAdd * * @typedef {HTMLElement & Menu} MenuHtml * * @param {*} blocks */ selectBlocks(blocks) { this.selectedBlocks = blocks let old = this.currentMenu let menu let blockIdMap = {} if (blocks.length) { blocks.forEach(b => { blockIdMap[b.id] = 1 }) this.selectConnector(null) /** @type {MenuHtml} */ menu = this.menuGenerator?.(blocks) if (old && old != menu) setVisible(old, false) if (menu) { setVisible(menu, true) if (menu != old) { menu.style.position = 'absolute' insert(this.contentArea, menu) } moveMenu(blocks, menu, this._zoom) menu.afterAdd?.(blocks) } } else { if (old) setVisible(old, false) } this.currentMenu = menu this.blocks.forEach(p => { /** @type {Element|any} */ let block = p.block let sel = blocks.includes(p) if (block.setSelected) { block.setSelected(sel) } else { setSelected(block, sel) } }) this.lines.forEach(l => { classIf(l.el, 'ne-from-sel-block', blockIdMap[l.p1.con?.root.id]) classIf(l.el, 'ne-to-sel-block', blockIdMap[l.p2.con?.root.id]) }) } selectConnector(con) { if (con) this.selectBlocks([]) this.selectedLine = con this.lines.forEach(p => { p.setSelected(p == con) }) //this.focus() } deselect() { this.selectConnector() this.selectBlocks([]) } /** * * @param {Element} el * @param {string} name * @param {*} [detail] */ fireCustom(el, name, detail = {}) { fireCustom(el, name, detail) if (el != this) fireCustom(this, name, detail) } fireMoveDone(blockData) { this.fireMove(blockData, 'ne-move-done') let menu = this.currentMenu if (menu) { menu.style.display = '' setTimeout(() => { if (this.selectedBlocks?.length) moveMenu(this.selectedBlocks, menu, this._zoom) }) } } fireMove(blockData, evtName = 'ne-move') { if (!blockData) return let { pos, id, el } = blockData this.fireCustom(this, evtName, { top: pos[1], left: pos[1], nid: id, domNode: el, pos }) } }