export type ResizeObserverEntryCallback = (entry: ResizeObserverEntry) => void; class ResizeObserverManager { #elementMap = new WeakMap>(); #elementEntry = new WeakMap(); #vo = new ResizeObserver((entries) => { for (const entry of entries) { this.#elementEntry.set(entry.target, entry); this.#elementMap.get(entry.target)?.forEach((callback) => callback(entry)); } }); observe(target: Element, callback: ResizeObserverEntryCallback): void { let callbacks = this.#elementMap.get(target); if (callbacks === undefined) { this.#vo.observe(target); this.#elementMap.set(target, (callbacks = new Set())); } else { const entry = this.#elementEntry.get(target); if (entry) { callback(entry); } } callbacks.add(callback); } unobserve(target: Element, callback: ResizeObserverEntryCallback): void { const callbacks = this.#elementMap.get(target); if (callbacks === undefined) return; callbacks.delete(callback); if (callbacks.size === 0) { this.#vo.unobserve(target); this.#elementMap.delete(target); } } } const resizeObserver = new ResizeObserverManager(); export type Shape = 'rectangle' | 'circle' | 'triangle'; export type MoveEventDetail = { movementX: number; movementY: number }; export class MoveEvent extends CustomEvent { constructor(detail: MoveEventDetail) { super('move', { detail, cancelable: true, bubbles: true }); } } export type ResizeEventDetail = { movementX: number; movementY: number }; export class ResizeEvent extends CustomEvent { constructor(detail: MoveEventDetail) { super('resize', { detail, cancelable: true, bubbles: true }); } } export type RotateEventDetail = { rotate: number }; export class RotateEvent extends CustomEvent { constructor(detail: RotateEventDetail) { super('rotate', { detail, cancelable: true, bubbles: true }); } } export type Dimension = number | 'auto'; const styles = new CSSStyleSheet(); styles.replaceSync(` :host { display: block; position: absolute; cursor: var(--fc-move, move); box-sizing: border-box; } :host::before { content: ''; position: absolute; inset: -15px -15px -15px -15px; z-index: -1; } div { height: 100%; width: 100%; overflow: hidden; cursor: default; } :host(:focus-within) { z-index: calc(infinity - 1); outline: solid 1px hsl(214, 84%, 56%); } :host(:hover) { outline: solid 2px hsl(214, 84%, 56%); } :host(:state(move)), :host(:state(rotate)), :host(:state(resize-nw)), :host(:state(resize-ne)), :host(:state(resize-se)), :host(:state(resize-sw)), { user-select: none; } [part="resize-nw"], [part="resize-ne"], [part="resize-se"], [part="resize-sw"] { display: block; position: absolute; box-sizing: border-box; padding: 0; background: hsl(210, 20%, 98%); z-index: calc(infinity); width: 13px; aspect-ratio: 1; transform: translate(-50%, -50%); border: 1.5px solid hsl(214, 84%, 56%); border-radius: 2px; } [part="resize-nw"] { top: 0; left: 0; } [part="resize-ne"] { top: 0; left: 100%; } [part="resize-se"] { top: 100%; left: 100%; } [part="resize-sw"] { top: 100%; left: 0; } [part="resize-nw"], [part="resize-se"] { cursor: var(--fc-nwse-resize, nwse-resize) } [part="resize-ne"], [part="resize-sw"] { cursor: var(--fc-nesw-resize, nesw-resize) } [part="rotate"] { z-index: calc(infinity); display: block; position: absolute; box-sizing: border-box; padding: 0; border: 1.5px solid hsl(214, 84%, 56%); border-radius: 50%; background: hsl(210, 20%, 98%); width: 13px; aspect-ratio: 1; top: 0; left: 50%; translate: -50% -150%; cursor: url("data:image/svg+xml,") 16 16, pointer; } :host(:not(:focus-within)) [part^="resize"], :host(:not(:focus-within)) [part="rotate"] { opacity: 0; cursor: default; } `); declare global { interface HTMLElementTagNameMap { 'fc-geometry': FolkGeometry; } } // TODO: add z coordinate? export class FolkGeometry extends HTMLElement { static tagName = 'fc-geometry'; static register() { customElements.define(this.tagName, this); } #internals = this.attachInternals(); #type = (this.getAttribute('type') || 'rectangle') as Shape; get type(): Shape { return this.#type; } set type(type: Shape) { this.setAttribute('type', type); } #previousX = 0; #x = Number(this.getAttribute('x')) || 0; get x() { return this.#x; } set x(x) { this.#previousX = this.#x; this.#x = x; this.#requestUpdate('x'); } #previousY = 0; #y = Number(this.getAttribute('y')) || 0; get y() { return this.#y; } set y(y) { this.#previousY = this.#y; this.#y = y; this.#requestUpdate('y'); } #autoContentRect = this.getBoundingClientRect(); #previousWidth: Dimension = 0; #width: Dimension = 0; get width(): number { if (this.#width === 'auto') { return this.#autoContentRect.width; } return this.#width; } set width(width: Dimension) { if (width === 'auto') { resizeObserver.observe(this, this.#onResize); } else if (this.#width === 'auto' && this.#height !== 'auto') { resizeObserver.unobserve(this, this.#onResize); } this.#previousWidth = this.#width; this.#width = width; this.#requestUpdate('width'); } #previousHeight: Dimension = 0; #height: Dimension = 0; get height(): number { if (this.#height === 'auto') { return this.#autoContentRect.height; } return this.#height; } set height(height: Dimension) { if (height === 'auto') { resizeObserver.observe(this, this.#onResize); } else if (this.#height === 'auto' && this.#width !== 'auto') { resizeObserver.unobserve(this, this.#onResize); } this.#previousHeight = this.#height; this.#height = height; this.#requestUpdate('height'); } #initialRotation = 0; #startAngle = 0; #previousRotate = 0; #rotate = Number(this.getAttribute('rotate')) || 0; get rotate(): number { return this.#rotate; } set rotate(rotate: number) { this.#previousRotate = this.#rotate; this.#rotate = rotate; this.#requestUpdate('rotate'); } constructor() { super(); this.addEventListener('pointerdown', this); const shadowRoot = this.attachShadow({ mode: 'open', delegatesFocus: true, }); shadowRoot.adoptedStyleSheets.push(styles); // Ideally we would creating these lazily on first focus, but the resize handlers need to be around for delegate focus to work. // Maybe can add the first resize handler here, and lazily instantiate the rest when needed? // I can see it becoming important at scale shadowRoot.innerHTML = `
`; this.height = Number(this.getAttribute('height')) || 'auto'; this.width = Number(this.getAttribute('width')) || 'auto'; } connectedCallback() { this.#update(new Set(['type', 'x', 'y', 'height', 'width', 'rotate'])); } disconnectedCallback() { cancelAnimationFrame(this.#rAFId); } // Similar to `Element.getClientBoundingRect()`, but returns an SVG path that precisely outlines the shape. getBoundingPath(): string { return ''; } // We might also want some kind of utility function that maps a path into an approximate set of vertices. getBoundingVertices() { return []; } handleEvent(event: PointerEvent) { switch (event.type) { case 'pointerdown': { if (event.button !== 0 || event.ctrlKey) return; const target = event.composedPath()[0] as HTMLElement; // Store initial angle on rotation start if (target.getAttribute('part') === 'rotate') { // We need to store initial rotation/angle somewhere. // This is a little awkward as we'll want to do *quite a lot* of this kind of thing. // Might be an argument for making elements dumber (i.e. not have them manage their own state) and do this from the outside. // But we also want to preserve the self-sufficient nature of elements' behaviour... // Maybe some kind of shared utility, used by both the element and the outside environment? this.#initialRotation = this.#rotate; const centerX = this.#x + this.width / 2; const centerY = this.#y + this.height / 2; this.#startAngle = Math.atan2(event.clientY - centerY, event.clientX - centerX); } // ignore interactions from slotted elements. if (target !== this && !target.hasAttribute('part')) return; target.addEventListener('pointermove', this); this.addEventListener('lostpointercapture', this); target.setPointerCapture(event.pointerId); const interaction = target.getAttribute('part') || 'move'; this.#internals.states.add(interaction); this.focus(); return; } case 'pointermove': { const target = event.target as HTMLElement; if (target === null) return; if (target === this) { this.x += event.movementX; this.y += event.movementY; return; } const part = target.getAttribute('part'); if (part === null) return; if (part.includes('resize')) { // This triggers a move and resize event :( if (part.includes('-n')) { this.y += event.movementY; this.height -= event.movementY; } if (part.endsWith('e')) { this.width += event.movementX; } if (part.includes('-s')) { this.height += event.movementY; } if (part.endsWith('w')) { this.x += event.movementX; this.width -= event.movementX; } return; } if (part === 'rotate') { const centerX = this.#x + this.width / 2; const centerY = this.#y + this.height / 2; const currentAngle = Math.atan2(event.clientY - centerY, event.clientX - centerX); const deltaAngle = currentAngle - this.#startAngle; this.rotate = this.#initialRotation + (deltaAngle * 180) / Math.PI; return; } return; } case 'lostpointercapture': { const target = event.composedPath()[0] as HTMLElement; const interaction = target.getAttribute('part') || 'move'; this.#internals.states.delete(interaction); target.removeEventListener('pointermove', this); this.removeEventListener('lostpointercapture', this); return; } } } #updatedProperties = new Set(); #rAFId = -1; #isUpdating = false; #requestUpdate(property: string) { this.#updatedProperties.add(property); if (this.#isUpdating) return; this.#isUpdating = true; this.#rAFId = requestAnimationFrame(() => { this.#isUpdating = false; this.#update(this.#updatedProperties); this.#updatedProperties.clear(); this.#rAFId = -1; }); } // Any updates that should be batched should happen here like updating the DOM or emitting events should be executed here. #update(updatedProperties: Set) { if (updatedProperties.has('type')) { // TODO: Update shape styles. For many shapes, we could just use clip-path to style the shape. // If we use relative values in `clip-path: polygon()`, then no JS is needed to style the shape // If `clip-path: path()` is used then we need to update the path in JS. // See https://www.smashingmagazine.com/2024/05/modern-guide-making-css-shapes/ } if (updatedProperties.has('x') || updatedProperties.has('y')) { // Although the change in movement isn't useful inside this component, the outside world might find it helpful to calculate acceleration and other physics const notCancelled = this.dispatchEvent( new MoveEvent({ movementX: this.#x - this.#previousX, movementY: this.#y - this.#previousY, }) ); if (notCancelled) { if (updatedProperties.has('x')) { // In the future, when CSS `attr()` is supported we could define this x/y projection in CSS. this.style.left = `${this.#x}px`; } if (updatedProperties.has('y')) { this.style.top = `${this.#y}px`; } } else { this.#x = this.#previousX; this.#y = this.#previousY; } } if (updatedProperties.has('width') || updatedProperties.has('height')) { // Although the change in resize isn't useful inside this component, the outside world might find it helpful to calculate acceleration and other physics const notCancelled = this.dispatchEvent( new ResizeEvent({ movementX: this.width - (this.#previousWidth === 'auto' ? 0 : this.#previousWidth), movementY: this.height - (this.#previousHeight === 'auto' ? 0 : this.#previousHeight), }) ); if (notCancelled) { if (updatedProperties.has('width')) { this.style.width = this.#width === 'auto' ? '' : `${this.#width}px`; } if (updatedProperties.has('height')) { this.style.height = this.#height === 'auto' ? '' : `${this.#height}px`; } } else { // TODO: Revert changes to position too this.#height = this.#previousHeight; this.#width = this.#previousWidth; } } if (updatedProperties.has('rotate')) { // Although the change in resize isn't useful inside this component, the outside world might find it helpful to calculate acceleration and other physics const notCancelled = this.dispatchEvent(new RotateEvent({ rotate: this.#rotate - this.#previousRotate })); if (notCancelled) { if (updatedProperties.has('rotate')) { this.style.rotate = `${this.#rotate}deg`; } } else { this.#rotate = this.#previousRotate; } } } #onResize = (entry: ResizeObserverEntry) => { const previousRect = this.#autoContentRect; this.#autoContentRect = entry.contentRect; const notCancelled = this.dispatchEvent( new ResizeEvent({ movementX: this.width - (this.#previousWidth === 'auto' ? previousRect.width : this.#previousWidth), movementY: this.height - (this.#previousHeight === 'auto' ? previousRect.height : this.#previousHeight), }) ); if (!notCancelled) { if (this.#height === 'auto') { this.height = previousRect?.height || 0; } if (this.#width === 'auto') { this.width = previousRect?.width || 0; } } }; }