diff --git a/src/folk-shape.ts b/src/folk-shape.ts index b4f41ea..7d3f7c4 100644 --- a/src/folk-shape.ts +++ b/src/folk-shape.ts @@ -1,76 +1,22 @@ import { css, html } from './common/tags'; import { ResizeObserverManager } from './common/resize-observer'; import { Point } from './common/types'; -import { TransformDOMRectReadonly } from './common/TransformDOMRect'; +import { TransformDOMRect, TransformDOMRectReadonly } from './common/TransformDOMRect'; import { Vector } from './common/Vector'; import { getResizeCursorUrl, getRotateCursorUrl } from './common/cursors'; +import { TransformEvent } from './common/TransformEvent'; -const resizeObserver = new ResizeObserverManager(); - -export type Shape = 'rectangle' | 'circle' | 'triangle'; - -type Handle = - | 'resize-nw' - | 'resize-ne' - | 'resize-se' - | 'resize-sw' - | 'rotation-nw' - | 'rotation-ne' - | 'rotation-se' - | 'rotation-sw' - | 'move'; - -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; +declare global { + interface HTMLElementTagNameMap { + 'folk-shape': FolkShape; } } +const resizeObserver = new ResizeObserverManager(); + +type ResizeHandle = 'resize-nw' | 'resize-ne' | 'resize-se' | 'resize-sw'; +type RotateHandle = 'rotation-nw' | 'rotation-ne' | 'rotation-se' | 'rotation-sw'; +type Handle = ResizeHandle | RotateHandle | 'move'; export type Dimension = number | 'auto'; const styles = css` @@ -79,6 +25,7 @@ const styles = css` position: absolute; cursor: move; box-sizing: border-box; + transform-origin: 0 0; } :host::before { @@ -210,12 +157,6 @@ const styles = css` } `; -declare global { - interface HTMLElementTagNameMap { - 'folk-shape': FolkShape; - } -} - // TODO: add z coordinate? export class FolkShape extends HTMLElement { static tagName = 'folk-shape'; @@ -227,102 +168,80 @@ export class FolkShape extends HTMLElement { #shadow = this.attachShadow({ mode: 'open' }); #internals = this.attachInternals(); - #dynamicStyles = css``; + #autoContentRect = this.getBoundingClientRect(); + #attrWidth: Dimension = 0; + #attrHeight: Dimension = 0; - #type = (this.getAttribute('type') || 'rectangle') as Shape; - get type(): Shape { - return this.#type; - } + // Used for rotation handling, would love a better way to do this that avoids this clutter. + #initialRotation = 0; + #startAngle = 0; - set type(type: Shape) { - this.setAttribute('type', type); - } - - #previousX = 0; - #x = Number(this.getAttribute('x')) || 0; get x() { - return this.#x; + return this.#rect.x; } set x(x) { - this.#previousX = this.#x; - this.#x = x; + this.#rect.x = x; this.#requestUpdate('x'); } - #previousY = 0; - #y = Number(this.getAttribute('y')) || 0; get y() { - return this.#y; + return this.#rect.y; } set y(y) { - this.#previousY = this.#y; - this.#y = y; + this.#rect.y = y; this.#requestUpdate('y'); } - #autoContentRect = this.getBoundingClientRect(); - - #previousWidth: Dimension = 0; - #width: Dimension = 0; get width(): number { - if (this.#width === 'auto') { + if (this.#attrWidth === 'auto') { return this.#autoContentRect.width; } - return this.#width; + return this.#rect.width; } set width(width: Dimension) { if (width === 'auto') { resizeObserver.observe(this, this.#onAutoResize); - } else if (this.#width === 'auto' && this.#height !== 'auto') { + } else if (this.#attrWidth === 'auto' && this.#attrHeight !== 'auto') { resizeObserver.unobserve(this, this.#onAutoResize); } - this.#previousWidth = this.#width; - this.#width = width; + this.#attrWidth = width; this.#requestUpdate('width'); } - #previousHeight: Dimension = 0; - #height: Dimension = 0; get height(): number { - if (this.#height === 'auto') { + if (this.#attrHeight === 'auto') { return this.#autoContentRect.height; } - return this.#height; + return this.#attrHeight; } set height(height: Dimension) { if (height === 'auto') { resizeObserver.observe(this, this.#onAutoResize); - } else if (this.#height === 'auto' && this.#width !== 'auto') { + } else if (this.#attrHeight === 'auto' && this.#attrWidth !== 'auto') { resizeObserver.unobserve(this, this.#onAutoResize); } - this.#previousHeight = this.#height; - this.#height = height; + this.#attrHeight = 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; + return this.#rect.rotation; } set rotation(rotation: number) { - this.#previousRotation = this.#rotation; - this.#rotation = rotation; + this.#rect.rotation = rotation; this.#requestUpdate('rotation'); } + #rect: TransformDOMRect; + #previousRect: TransformDOMRect; + constructor() { super(); @@ -343,21 +262,28 @@ export class FolkShape extends HTMLElement {
`; - this.height = Number(this.getAttribute('height')) || 'auto'; - this.width = Number(this.getAttribute('width')) || 'auto'; + this.#rect = new TransformDOMRect({ + x: Number(this.getAttribute('x')) || 0, + y: Number(this.getAttribute('y')) || 0, + width: Number(this.getAttribute('width')) || 0, + height: Number(this.getAttribute('height')) || 0, + rotation: (Number(this.getAttribute('rotation')) || 0) * (Math.PI / 180), + }); + + // Initialize previousRect with same values as rect + this.#previousRect = new TransformDOMRect(this.#rect); } #isConnected = false; connectedCallback() { this.setAttribute('tabindex', '0'); this.#isConnected = true; - this.#update(new Set(['type', 'x', 'y', 'height', 'width', 'rotation'])); + this.#update(); } + // TODO: rename to getTransformDOMRect() getClientRect() { - const { x, y, width, height, rotation } = this; - - return new TransformDOMRectReadonly({ x, y, width, height, rotation }); + return new TransformDOMRectReadonly(this.#rect); } // Similar to `Element.getClientBoundingRect()`, but returns an SVG path that precisely outlines the shape. @@ -391,30 +317,43 @@ export class FolkShape extends HTMLElement { if (!anyChange) return; // Get the corner coordinates of the shape for the corresponding handle - const rect = this.getClientRect(); + const rect = this.#rect; - // Map handle names to corner indices - const handleToCornerIndex: Record = { + let vector: Point; + switch (event.key) { + case 'ArrowUp': + vector = { x: 0, y: -MOVEMENT_DELTA }; + break; + case 'ArrowDown': + vector = { x: 0, y: MOVEMENT_DELTA }; + break; + case 'ArrowLeft': + vector = { x: -MOVEMENT_DELTA, y: 0 }; + break; + case 'ArrowRight': + vector = { x: MOVEMENT_DELTA, y: 0 }; + break; + } + + // Map handle names to corner points + const HANDLE_TO_CORNER: Record = { 'resize-nw': rect.topLeft, 'resize-ne': rect.topRight, 'resize-se': rect.bottomRight, 'resize-sw': rect.bottomLeft, }; - const currentPos = handleToCornerIndex[handle]; + const currentPos = rect.toParentSpace(HANDLE_TO_CORNER[handle]); - // Calculate movement based on arrow keys - const isVertical = event.key === 'ArrowUp' || event.key === 'ArrowDown'; - const isIncreasing = event.key === 'ArrowRight' || event.key === 'ArrowDown'; - const delta = isIncreasing ? MOVEMENT_DELTA : -MOVEMENT_DELTA; - - syntheticMouse = { - x: currentPos.x + (isVertical ? 0 : delta), - y: currentPos.y + (isVertical ? delta : 0), + const syntheticMouse = { + x: currentPos.x, + y: currentPos.y, }; + // Calculate movement based on arrow keys + // Process resize using the same logic as mouse events - this.#handleResize(handle, syntheticMouse, focusedElement as HTMLElement); + this.#handleResize(handle as ResizeHandle, syntheticMouse, focusedElement as HTMLElement); event.preventDefault(); return; } @@ -423,11 +362,13 @@ export class FolkShape extends HTMLElement { if (event.altKey) { switch (event.key) { case 'ArrowLeft': - this.rotation -= ROTATION_DELTA; + this.#rect.rotation -= ROTATION_DELTA; + this.#requestUpdate('rotation'); event.preventDefault(); return; case 'ArrowRight': - this.rotation += ROTATION_DELTA; + this.#rect.rotation += ROTATION_DELTA; + this.#requestUpdate('rotation'); event.preventDefault(); return; } @@ -435,19 +376,23 @@ export class FolkShape extends HTMLElement { switch (event.key) { case 'ArrowLeft': - this.x -= MOVEMENT_DELTA; + this.#rect.x -= MOVEMENT_DELTA; + this.#requestUpdate('x'); event.preventDefault(); return; case 'ArrowRight': - this.x += MOVEMENT_DELTA; + this.#rect.x += MOVEMENT_DELTA; + this.#requestUpdate('x'); event.preventDefault(); return; case 'ArrowUp': - this.y -= MOVEMENT_DELTA; + this.#rect.y -= MOVEMENT_DELTA; + this.#requestUpdate('y'); event.preventDefault(); return; case 'ArrowDown': - this.y += MOVEMENT_DELTA; + this.#rect.y += MOVEMENT_DELTA; + this.#requestUpdate('y'); event.preventDefault(); return; } @@ -463,8 +408,8 @@ export class FolkShape extends HTMLElement { // Store initial angle on rotation start if (target.getAttribute('part')?.startsWith('rotation')) { - const center = this.getClientRect().center; - this.#initialRotation = this.#rotation; + const center = this.#rect.center; + this.#initialRotation = this.#rect.rotation; this.#startAngle = Vector.angleFromOrigin({ x: event.clientX, y: event.clientY }, center); } @@ -486,8 +431,10 @@ export class FolkShape extends HTMLElement { if (target === null) return; if (target === this) { - this.x += event.movementX; - this.y += event.movementY; + this.#rect.x += event.movementX; + this.#rect.y += event.movementY; + this.#requestUpdate('x'); + this.#requestUpdate('y'); return; } @@ -496,16 +443,16 @@ export class FolkShape extends HTMLElement { if (handle.includes('resize')) { const mouse = { x: event.clientX, y: event.clientY }; - this.#handleResize(handle, mouse, target, event); + this.#handleResize(handle as ResizeHandle, mouse, target, event); return; } if (handle.startsWith('rotation')) { - const center = this.getClientRect().center; + const center = this.#rect.center; const currentAngle = Vector.angleFromOrigin({ x: event.clientX, y: event.clientY }, center); - this.rotation = this.#initialRotation + (currentAngle - this.#startAngle); + this.#rect.rotation = this.#initialRotation + (currentAngle - this.#startAngle); - let degrees = (this.rotation * 180) / Math.PI; + let degrees = (this.#rect.rotation * 180) / Math.PI; switch (handle) { case 'rotation-ne': degrees = (degrees + 90) % 360; @@ -521,6 +468,7 @@ export class FolkShape extends HTMLElement { const target = event.composedPath()[0] as HTMLElement; const rotateCursor = getRotateCursorUrl(degrees); target.style.setProperty('cursor', rotateCursor); + this.#requestUpdate('rotation'); return; } @@ -558,71 +506,47 @@ export class FolkShape extends HTMLElement { this.#isUpdating = true; await true; this.#isUpdating = false; - this.#update(this.#updatedProperties); + this.#update(); 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) { - this.#dispatchTransformEvent(updatedProperties); + #update() { + this.#dispatchTransformEvent(); } - #dispatchTransformEvent(updatedProperties: Set) { - const event = new TransformEvent(); - + #dispatchTransformEvent() { + const event = new TransformEvent(this.#rect, this.#previousRect); this.dispatchEvent(event); - if (updatedProperties.has('x')) { - if (event.xPrevented) { - this.#x = this.#previousX; - } else { - this.style.left = `${this.#x}px`; - } + if (event.xPrevented) { + this.#rect.x = this.#previousRect.x; + } + if (event.yPrevented) { + this.#rect.y = this.#previousRect.y; + } + if (event.widthPrevented) { + this.#rect.width = this.#previousRect.width; + } + if (event.heightPrevented) { + this.#rect.height = this.#previousRect.height; + } + if (event.rotatePrevented) { + this.#rect.rotation = this.#previousRect.rotation; } - 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`; - } - } + this.style.transform = this.#rect.toCssString(); + this.style.width = this.#attrWidth === 'auto' ? '' : `${this.#rect.width}px`; + this.style.height = this.#attrHeight === 'auto' ? '' : `${this.#rect.height}px`; } #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'])); + this.#dispatchTransformEvent(); }; #updateCursors() { - const degrees = (this.#rotation * 180) / Math.PI; + const degrees = (this.#rect.rotation * 180) / Math.PI; const resizeCursor0 = getResizeCursorUrl(degrees); const resizeCursor90 = getResizeCursorUrl((degrees + 90) % 360); @@ -662,91 +586,87 @@ export class FolkShape extends HTMLElement { this.#dynamicStyles.replaceSync(dynamicStyles); } - // Updated helper method to handle resize operations - #handleResize(handle: Handle, mouse: Point, target: HTMLElement, event?: PointerEvent) { - const rect = this.getClientRect(); + #handleResize(handle: ResizeHandle, pointerPos: Point, target: HTMLElement, event?: PointerEvent) { + const localPointer = this.#rect.toLocalSpace(pointerPos); - // Map each resize handle to its opposite corner index - const OPPOSITE_CORNERS = { - 'resize-se': rect.topLeft, - 'resize-sw': rect.topRight, - 'resize-nw': rect.bottomRight, - 'resize-ne': rect.bottomLeft, - } as const; + switch (handle) { + case 'resize-se': + this.#rect.setBottomRight(localPointer); + break; + case 'resize-sw': + this.#rect.setBottomLeft(localPointer); + break; + case 'resize-nw': + this.#rect.setTopLeft(localPointer); + break; + case 'resize-ne': + this.#rect.setTopRight(localPointer); + break; + } - // Get the opposite corner for the current resize handle - const oppositeCorner = OPPOSITE_CORNERS[handle as keyof typeof OPPOSITE_CORNERS]; + let nextHandle: ResizeHandle = handle; - // Calculate new dimensions based on mouse position and opposite corner - const newCenter = Vector.lerp(oppositeCorner, mouse, 0.5); - const unrotatedHandle = Vector.rotateAround(mouse, newCenter, -this.rotation); - const unrotatedAnchor = Vector.rotateAround(oppositeCorner, newCenter, -this.rotation); + const flipWidth = this.#rect.width < 0; + const flipHeight = this.#rect.height < 0; - const HANDLE_BEHAVIOR = { - 'resize-se': { - flipX: unrotatedHandle.x < unrotatedAnchor.x, - flipY: unrotatedHandle.y < unrotatedAnchor.y, - handleX: 'resize-sw', - handleY: 'resize-ne', - }, - 'resize-sw': { - flipX: unrotatedHandle.x > unrotatedAnchor.x, - flipY: unrotatedHandle.y < unrotatedAnchor.y, - handleX: 'resize-se', - handleY: 'resize-nw', - }, - 'resize-nw': { - flipX: unrotatedHandle.x > unrotatedAnchor.x, - flipY: unrotatedHandle.y > unrotatedAnchor.y, - handleX: 'resize-ne', - handleY: 'resize-sw', - }, - 'resize-ne': { - flipX: unrotatedHandle.x < unrotatedAnchor.x, - flipY: unrotatedHandle.y > unrotatedAnchor.y, - handleX: 'resize-nw', - handleY: 'resize-se', - }, - } as const; + if (flipWidth && flipHeight) { + // Both axes flipped + const oppositeHandleMap: Record = { + 'resize-se': 'resize-nw', + 'resize-sw': 'resize-ne', + 'resize-nw': 'resize-se', + 'resize-ne': 'resize-sw', + }; + nextHandle = oppositeHandleMap[handle]; + } else if (flipWidth) { + // Only X axis flipped + const flipXHandleMap: Record = { + 'resize-se': 'resize-sw', + 'resize-sw': 'resize-se', + 'resize-nw': 'resize-ne', + 'resize-ne': 'resize-nw', + }; + nextHandle = flipXHandleMap[handle]; + } else if (flipHeight) { + // Only Y axis flipped + const flipYHandleMap: Record = { + 'resize-se': 'resize-ne', + 'resize-sw': 'resize-nw', + 'resize-nw': 'resize-sw', + 'resize-ne': 'resize-se', + }; + nextHandle = flipYHandleMap[handle]; + } - // Handle flipping logic - const behavior = HANDLE_BEHAVIOR[handle as keyof typeof HANDLE_BEHAVIOR]; - const hasFlippedX = behavior.flipX; - const hasFlippedY = behavior.flipY; + const newTarget = this.#shadow.querySelector(`[part="${nextHandle}"]`) as HTMLElement; - if (hasFlippedX || hasFlippedY) { - const nextHandle = hasFlippedX ? behavior.handleX : behavior.handleY; - const newTarget = this.#shadow.querySelector(`[part="${nextHandle}"]`) as HTMLElement; + if (newTarget) { + // Update focus for keyboard events + newTarget.focus(); - if (newTarget) { - // Update focus for keyboard events - newTarget.focus(); + // Update handle state + this.#internals.states.delete(handle); + this.#internals.states.add(nextHandle); - // Update handle state - this.#internals.states.delete(handle); - this.#internals.states.add(nextHandle); + // Handle pointer capture swap for mouse events + if (event && 'setPointerCapture' in target) { + // Clean up old handle state + target.removeEventListener('pointermove', this); + target.removeEventListener('lostpointercapture', this); - // Handle pointer capture swap for mouse events - if (event && 'setPointerCapture' in target) { - // Clean up old handle state - target.removeEventListener('pointermove', this); - target.removeEventListener('lostpointercapture', this); + // Set up new handle state + newTarget.addEventListener('pointermove', this); + newTarget.addEventListener('lostpointercapture', this); - // Set up new handle state - newTarget.addEventListener('pointermove', this); - newTarget.addEventListener('lostpointercapture', this); - - // Transfer pointer capture - target.releasePointerCapture(event.pointerId); - newTarget.setPointerCapture(event.pointerId); - } + // Transfer pointer capture + target.releasePointerCapture(event.pointerId); + newTarget.setPointerCapture(event.pointerId); } } - // Update dimensions - this.x = Math.min(unrotatedHandle.x, unrotatedAnchor.x); - this.y = Math.min(unrotatedHandle.y, unrotatedAnchor.y); - this.width = Math.abs(unrotatedAnchor.x - unrotatedHandle.x); - this.height = Math.abs(unrotatedAnchor.y - unrotatedHandle.y); + this.#requestUpdate('x'); + this.#requestUpdate('y'); + this.#requestUpdate('width'); + this.#requestUpdate('height'); } }