import { FolkGeometry } from '../canvas/fc-geometry'; import { Vertex } from './utils'; import { VisualObserverEntry, VisualObserverManager } from './visual-observer'; const visualObserver = new VisualObserverManager(); const vertexRegex = /(?-?([0-9]*[.])?[0-9]+),\s*(?-?([0-9]*[.])?[0-9]+)/; function parseVertex(str: string): Vertex | null { const results = vertexRegex.exec(str); if (results === null) return null; return { x: Number(results.groups?.x), y: Number(results.groups?.y), }; } function parseCSSSelector(selector: string): string[] { return selector.split('>>>').map((s) => s.trim()); } export class AbstractArrow extends HTMLElement { static tagName = 'abstract-arrow'; static register() { customElements.define(this.tagName, this); } #source = ''; /** A CSS selector for the source of the arrow. */ get source() { return this.#source; } set source(source) { this.#source = source; this.observeSource(); } #sourceRect: DOMRectReadOnly | undefined; get sourceRect() { return this.#sourceRect; } #sourceElement: Element | null = null; get sourceElement() { return this.#sourceElement; } #sourceCallback = (entry: VisualObserverEntry) => { this.#sourceRect = entry.contentRect; this.#update(); }; #sourceHandler = (e: Event) => { const geometry = e.target as FolkGeometry; this.#sourceRect = geometry.getClientRect(); this.#update(); }; #sourceIframeSelector = ''; #sourceIframeRect = DOMRectReadOnly.fromRect(); #sourceIframeChildRect = DOMRectReadOnly.fromRect(); #sourcePostMessage = (event: MessageEvent) => { const iframe = this.#sourceElement as HTMLIFrameElement; if (event.source !== iframe.contentWindow) return; switch (event.data.type) { case 'folk-iframe-ready': { event.source?.postMessage({ type: 'folk-observe-element', selector: this.#sourceIframeSelector, }); return; } case 'folk-element-change': { if (this.#sourceIframeSelector === event.data.selector) { this.#sourceIframeChildRect = event.data.boundingBox; this.#updateSourceIframeRect(); } return; } } }; #sourceIframeCallback = (entry: VisualObserverEntry) => { this.#sourceIframeRect = entry.contentRect; this.#updateSourceIframeRect(); }; #updateSourceIframeRect() { this.#sourceRect = DOMRectReadOnly.fromRect({ x: this.#sourceIframeRect.x + this.#sourceIframeChildRect.x, y: this.#sourceIframeRect.y + this.#sourceIframeChildRect.y, height: this.#sourceIframeChildRect.height, width: this.#sourceIframeChildRect.width, }); this.#update(); } #target = ''; /** A CSS selector for the target of the arrow. */ get target() { return this.#target; } set target(target) { this.#target = target; this.observeTarget(); } #targetRect: DOMRectReadOnly | undefined; get targetRect() { return this.#targetRect; } #targetElement: Element | null = null; get targetElement() { return this.#targetElement; } #targetCallback = (entry: VisualObserverEntry) => { this.#targetRect = entry.contentRect; this.#update(); }; #targetHandler = (e: Event) => { const geometry = e.target as FolkGeometry; this.#targetRect = geometry.getClientRect(); this.#update(); }; #targetIframeSelector = ''; #targetIframeRect = DOMRectReadOnly.fromRect(); #targetIframeChildRect = DOMRectReadOnly.fromRect(); #targetPostMessage = (event: MessageEvent) => { const iframe = this.#targetElement as HTMLIFrameElement; if (event.source !== iframe.contentWindow) return; switch (event.data.type) { case 'folk-iframe-ready': { event.source?.postMessage({ type: 'folk-observe-element', selector: this.#targetIframeSelector, }); return; } case 'folk-element-change': { if (this.#targetIframeSelector === event.data.selector) { this.#targetIframeChildRect = event.data.boundingBox; this.#updateTargetIframeRect(); } return; } } }; #targetIframeCallback = (entry: VisualObserverEntry) => { this.#targetIframeRect = entry.contentRect; this.#updateTargetIframeRect(); }; #updateTargetIframeRect() { this.#targetRect = DOMRectReadOnly.fromRect({ x: this.#targetIframeRect.x + this.#targetIframeChildRect.x, y: this.#targetIframeRect.y + this.#targetIframeChildRect.y, height: this.#targetIframeChildRect.height, width: this.#targetIframeChildRect.width, }); this.#update(); } connectedCallback() { this.source = this.getAttribute('source') || this.#source; this.target = this.getAttribute('target') || this.#target; } disconnectedCallback() { this.unobserveSource(); this.unobserveTarget(); } // TODO: why reparse the vertex? setSourceVertex(vertex: Vertex) { this.target = `${vertex.x},${vertex.y}`; } observeSource() { this.unobserveSource(); const vertex = parseVertex(this.#source); if (vertex) { this.#sourceRect = DOMRectReadOnly.fromRect(vertex); this.#update(); } else { const [selector, iframeSelector] = parseCSSSelector(this.#source); this.#sourceIframeSelector = iframeSelector; this.#sourceElement = document.querySelector(selector); if (this.#sourceElement === null) { throw new Error('source is not a valid element'); } else if (this.#sourceElement instanceof FolkGeometry) { this.#sourceElement.addEventListener('resize', this.#sourceHandler); this.#sourceElement.addEventListener('move', this.#sourceHandler); this.#sourceRect = this.#sourceElement.getBoundingClientRect(); } else if (this.#sourceElement instanceof HTMLIFrameElement && this.#sourceIframeSelector) { window.addEventListener('message', this.#sourcePostMessage); visualObserver.observe(this.#sourceElement, this.#sourceIframeCallback); this.#sourceElement.contentWindow?.postMessage({ type: 'folk-observe-element', selector: this.#sourceIframeSelector, }); } else { visualObserver.observe(this.#sourceElement, this.#sourceCallback); this.#sourceRect = this.#sourceElement.getBoundingClientRect(); } } } unobserveSource() { if (this.#sourceElement === null) return; if (this.#sourceElement instanceof FolkGeometry) { this.#sourceElement.removeEventListener('resize', this.#sourceHandler); this.#sourceElement.removeEventListener('move', this.#sourceHandler); } else if (this.#sourceElement instanceof HTMLIFrameElement && this.#sourceIframeSelector) { window.removeEventListener('message', this.#sourcePostMessage); visualObserver.unobserve(this.#sourceElement, this.#sourceIframeCallback); this.#sourceElement.contentWindow?.postMessage({ type: 'folk-unobserve-element', selector: this.#sourceIframeSelector, }); } else { visualObserver.unobserve(this.#sourceElement, this.#sourceCallback); } } observeTarget() { this.unobserveTarget(); const vertex = parseVertex(this.#target); if (vertex) { this.#targetRect = DOMRectReadOnly.fromRect(vertex); this.#update(); } else { const [selector, iframeSelector] = parseCSSSelector(this.#target); this.#targetIframeSelector = iframeSelector; this.#targetElement = document.querySelector(selector); if (!this.#targetElement) { throw new Error('target is not a valid element'); } else if (this.#targetElement instanceof FolkGeometry) { this.#targetElement.addEventListener('resize', this.#targetHandler); this.#targetElement.addEventListener('move', this.#targetHandler); } else if (this.#targetElement instanceof HTMLIFrameElement && this.#targetIframeSelector) { window.addEventListener('message', this.#targetPostMessage); visualObserver.observe(this.#targetElement, this.#targetIframeCallback); this.#targetElement.contentWindow?.postMessage({ type: 'folk-observe-element', selector: this.#targetIframeSelector, }); } else { visualObserver.observe(this.#targetElement, this.#targetCallback); } this.#targetRect = this.#targetElement.getBoundingClientRect(); } } unobserveTarget() { if (this.#targetElement === null) return; if (this.#targetElement instanceof FolkGeometry) { this.#targetElement.removeEventListener('resize', this.#targetHandler); this.#targetElement.removeEventListener('move', this.#targetHandler); } else if (this.#targetElement instanceof HTMLIFrameElement && this.#targetIframeSelector) { window.removeEventListener('message', this.#targetPostMessage); visualObserver.unobserve(this.#targetElement, this.#targetIframeCallback); this.#targetElement.contentWindow?.postMessage({ type: 'folk-unobserve-element', selector: this.#targetIframeSelector, }); } else { visualObserver.unobserve(this.#targetElement, this.#targetCallback); } } #update() { if (this.#sourceRect === undefined || this.#targetRect === undefined) return; this.render(this.#sourceRect, this.#targetRect); } // @ts-ignore render(sourceRect: DOMRectReadOnly, targetRect: DOMRectReadOnly) {} }