import { css, html } from './common/tags'; import { ResizeObserverManager } from './common/resize-observer'; import type { Point } from './common/types'; import { Vector } from './common/Vector'; const resizeObserver = new ResizeObserverManager(); export type Shape = 'rectangle' | 'circle' | 'triangle'; type RotatedDOMRect = DOMRect & { /** in degrees */ rotation: number; /** Returns the center point in worldspace coordinates */ center(): Point; /** Returns the four corners in worldspace coordinates, in clockwise order */ corners(): [Point, Point, Point, Point]; /** Returns all the vertices in worldspace coordinates */ vertices(): Point[]; }; 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(css` :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 { 'folk-shape': FolkShape; } } // TODO: add z coordinate? export class FolkShape extends HTMLElement { static tagName = 'folk-shape'; static define() { 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; #previousRotation = 0; // TODO: consider using radians instead of degrees #rotation = Number(this.getAttribute('rotate')) || 0; get rotation(): number { return this.#rotation; } set rotation(rotation: number) { this.#previousRotation = this.#rotation; this.#rotation = rotation; 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 = html`
`; 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'])); } getClientRect(): RotatedDOMRect { const { x, y, width, height, rotation } = this; const radians = (rotation * Math.PI) / 180; return { x, y, width, height, left: x, top: y, right: x + width, bottom: y + height, rotation, center(): Point { return { x: this.x + this.width / 2, y: this.y + this.height / 2, }; }, vertices(): Point[] { // TODO: Implement return []; }, corners() { const center = this.center(); const radians = (this.rotation * Math.PI) / 180; const { x, y, width, height } = this; return [ Vector.rotateAround({ x, y }, center, radians), Vector.rotateAround({ x: x + width, y }, center, radians), Vector.rotateAround({ x: x + width, y: y + height }, center, radians), Vector.rotateAround({ x, y: y + height }, center, radians), ]; }, toJSON: undefined as any, }; } // 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.#rotation; 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')) { let newWidth = this.width; let newHeight = this.height; let newX = this.x; let newY = this.y; if (part.includes('-n')) { const proposedHeight = this.height - event.movementY; if (proposedHeight > 0) { newHeight = proposedHeight; newY = this.y + event.movementY; } } if (part.endsWith('e')) { const proposedWidth = this.width + event.movementX; if (proposedWidth > 0) { newWidth = proposedWidth; } } if (part.includes('-s')) { const proposedHeight = this.height + event.movementY; if (proposedHeight > 0) { newHeight = proposedHeight; } } if (part.endsWith('w')) { const proposedWidth = this.width - event.movementX; if (proposedWidth > 0) { newWidth = proposedWidth; newX = this.x + event.movementX; } } this.width = newWidth; this.height = newHeight; this.x = newX; this.y = newY; 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.rotation = 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(); #isUpdating = false; async #requestUpdate(property: string) { this.#updatedProperties.add(property); if (this.#isUpdating) return; this.#isUpdating = true; await true; this.#isUpdating = false; this.#update(this.#updatedProperties); this.#updatedProperties.clear(); } // 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.#rotation - this.#previousRotation })); if (notCancelled) { if (updatedProperties.has('rotate')) { this.style.rotate = `${this.#rotation}deg`; } } else { this.#rotation = this.#previousRotation; } } } #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; } } }; }