import { css, html } from './common/tags'; import { ResizeObserverManager } from './common/resize-observer'; import type { Point, RotatedDOMRect } from './common/types'; import { Vector } from './common/Vector'; const resizeObserver = new ResizeObserverManager(); export type Shape = 'rectangle' | 'circle' | 'triangle'; export type TransformEventDetail = { rotate: number; }; // TODO: expose previous and current rects export class TransformEvent extends Event { constructor() { super('transform', { cancelable: true, bubbles: true }); } #xPrevented = false; get xPrevented() { return this.defaultPrevented || this.#xPrevented; } preventX() { this.#xPrevented = true; } #yPrevented = false; get yPrevented() { return this.defaultPrevented || this.#yPrevented; } preventY() { this.#yPrevented = true; } #heightPrevented = false; get heightPrevented() { return this.defaultPrevented || this.#heightPrevented; } preventHeight() { this.#heightPrevented = true; } #widthPrevented = false; get widthPrevented() { return this.defaultPrevented || this.#widthPrevented; } preventWidth() { this.#widthPrevented = true; } #rotatePrevented = false; get rotatePrevented() { return this.defaultPrevented || this.#rotatePrevented; } preventRotate() { this.#rotatePrevented = 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; pointer-events: none; } ::slotted(*) { cursor: default; pointer-events: auto; } :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='rotation'] { 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='rotation'] { 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); } #shadow = this.attachShadow({ mode: 'open', delegatesFocus: true }); #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.#onAutoResize); } else if (this.#width === 'auto' && this.#height !== 'auto') { resizeObserver.unobserve(this, this.#onAutoResize); } 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.#onAutoResize); } else if (this.#height === 'auto' && this.#width !== 'auto') { resizeObserver.unobserve(this, this.#onAutoResize); } this.#previousHeight = this.#height; this.#height = height; this.#requestUpdate('height'); } #initialRotation = 0; #startAngle = 0; #previousRotation = 0; // use degrees in the DOM, but store in radians internally #rotation = (Number(this.getAttribute('rotation')) || 0) * (Math.PI / 180); get rotation(): number { return this.#rotation; } set rotation(rotation: number) { this.#previousRotation = this.#rotation; this.#rotation = rotation; this.#requestUpdate('rotation'); } constructor() { super(); this.addEventListener('pointerdown', this); this.#shadow.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 this.#shadow.innerHTML = html`
`; this.height = Number(this.getAttribute('height')) || 'auto'; this.width = Number(this.getAttribute('width')) || 'auto'; } #isConnected = false; connectedCallback() { this.#isConnected = true; this.#update(new Set(['type', 'x', 'y', 'height', 'width', 'rotation'])); } getClientRect(): RotatedDOMRect { const { x, y, width, height, rotation } = this; 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 { x, y, width, height, rotation } = this; return [ Vector.rotateAround({ x, y }, center, rotation), Vector.rotateAround({ x: x + width, y }, center, rotation), Vector.rotateAround({ x: x + width, y: y + height }, center, rotation), Vector.rotateAround({ x, y: y + height }, center, rotation), ]; }, 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') === 'rotation') { // 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 handle = target.getAttribute('part'); if (handle === null) return; if (handle.includes('resize')) { const { clientX, clientY } = event; const [topLeft, topRight, bottomRight, bottomLeft] = this.getClientRect().corners(); const newCenter: Point = { x: (topLeft.x + clientX) / 2, y: (topLeft.y + clientY) / 2, }; const newTopLeft = Vector.rotateAround(topLeft, newCenter, -this.rotation); let bottomRightAdjusted: Point; if (handle.endsWith('nw')) { bottomRightAdjusted = { x: clientX, y: clientY }; } else if (handle.endsWith('ne')) { bottomRightAdjusted = { x: clientX, y: clientY }; } else if (handle.endsWith('se')) { bottomRightAdjusted = { x: clientX, y: clientY }; } else { bottomRightAdjusted = { x: clientX, y: clientY }; } const newBottomRight = Vector.rotateAround(bottomRightAdjusted, newCenter, -this.rotation); this.x = newTopLeft.x; this.y = newTopLeft.y; this.width = newBottomRight.x - newTopLeft.x; this.height = newBottomRight.y - newTopLeft.y; return; } if (handle === 'rotation') { 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; 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) { if (!this.#isConnected) return; 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/ // } this.#dispatchTransformEvent(updatedProperties); } #dispatchTransformEvent(updatedProperties: Set) { const event = new TransformEvent(); this.dispatchEvent(event); if (updatedProperties.has('x')) { if (event.xPrevented) { this.#x = this.#previousX; } else { this.style.left = `${this.#x}px`; } } if (updatedProperties.has('y')) { if (event.yPrevented) { this.#y = this.#previousY; } else { this.style.top = `${this.#y}px`; } } if (updatedProperties.has('height')) { if (event.heightPrevented) { this.#height = this.#previousHeight; } else { this.style.height = this.#height === 'auto' ? '' : `${this.#height}px`; } } if (updatedProperties.has('width')) { if (event.widthPrevented) { this.#width = this.#previousWidth; } else { this.style.width = this.#width === 'auto' ? '' : `${this.#width}px`; } } if (updatedProperties.has('rotation')) { if (event.rotatePrevented) { this.#rotation = this.#previousRotation; } else { this.style.rotate = `${this.#rotation}rad`; } } } #onAutoResize = (entry: ResizeObserverEntry) => { const previousRect = this.#autoContentRect; this.#autoContentRect = entry.contentRect; this.#previousHeight = previousRect.height; this.#previousWidth = previousRect.width; this.#dispatchTransformEvent(new Set(['width', 'height'])); }; }