use TransformDOMRect in folk-shape

This commit is contained in:
Orion Reed 2024-12-06 17:29:23 -05:00
parent 9817a9fd2b
commit 9f99ae5bae
1 changed files with 189 additions and 269 deletions

View File

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