diff --git a/src/elements/spatial-geometry.ts b/src/elements/spatial-geometry.ts index c11c207..fe4cb46 100644 --- a/src/elements/spatial-geometry.ts +++ b/src/elements/spatial-geometry.ts @@ -5,7 +5,6 @@ const shapes = new Set(['rectangle', 'circle', 'triangle']); export type MoveEventDetail = { movementX: number; movementY: number }; -// Should the move event bubble? export class MoveEvent extends CustomEvent { constructor(detail: MoveEventDetail) { super('move', { detail, cancelable: true, bubbles: true }); @@ -14,31 +13,42 @@ export class MoveEvent extends CustomEvent { export type ResizeEventDetail = { movementX: number; movementY: number }; -// Should the move event bubble? 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 }); + } +} + const styles = new CSSStyleSheet(); styles.replaceSync(` :host { display: block; position: absolute; - cursor: pointer; + cursor: var(--fc-grab, grab);; +} + +:host(:hover) { + outline: solid 1px hsl(214, 84%, 56%); } :host(:state(moving)) { - cursor: url("data:image/svg+xml,") 16 16, pointer; + cursor: var(--fc-grabbing, grabbing); user-select: none; } -:host(:not(:focus, :focus-within, :state(moving))) [resize-handler] { +:host(:not(:focus-within)) [resize-handler], :host(:not(:focus-within)) [rotation-handler] { opacity: 0; } -:is(:host(:focus), :host(:focus-within), :host(:state(moving))) [resize-handler] { +:host(:focus-within) [resize-handler] { display: block; position: absolute; box-sizing: border-box; @@ -54,7 +64,7 @@ styles.replaceSync(` transform: translate(-50%, -50%); border: 1.5px solid hsl(214, 84%, 56%); border-radius: 2px; - z-index: 2; + z-index: 3; } &[resize-handler="top-left"] { @@ -78,11 +88,11 @@ styles.replaceSync(` } &[resize-handler="top-left"], &[resize-handler="bottom-right"] { - cursor: url("data:image/svg+xml,") 16 16, pointer; + cursor: var(--fc-nwse-resize, nwse-resize) } - + &[resize-handler="top-right"], &[resize-handler="bottom-left"] { - cursor: url("data:image/svg+xml,") 16 16, pointer; + cursor: var(--fc-nesw-resize, nesw-resize) } &[resize-handler="top"], @@ -92,7 +102,7 @@ styles.replaceSync(` background-color: hsl(214, 84%, 56%); background-clip: content-box; border: unset; - z-index: 1; + z-index: 2; } &[resize-handler="top"] { @@ -126,13 +136,58 @@ styles.replaceSync(` &[resize-handler="top"], &[resize-handler="bottom"] { height: 6px; padding: 2px 0; - cursor: url("data:image/svg+xml,") 16 16, pointer; + cursor: var(--fc-ns-resize, ns-resize) } &[resize-handler="right"], &[resize-handler="left"] { width: 6px; padding: 0 2px; - cursor: url("data:image/svg+xml,") 16 16, pointer; + cursor: var(--fc-ew-resize, ew-resize) + } +} + +:host(:focus-within) [rotation-handler] { + display: block; + position: absolute; + box-sizing: border-box; + padding: 0; + border: unset; + background: unset; + + &[rotation-handler="top-left"], + &[rotation-handler="top-right"], + &[rotation-handler="bottom-right"], + &[rotation-handler="bottom-left"] { + width: 13px; + aspect-ratio: 1; + z-index: 2; + } + + &[rotation-handler="top-left"] { + top: 0; + left: 0; + transform: translate(-100%, -100%); + cursor: url("data:image/svg+xml,") 16 16, pointer; + } + + &[rotation-handler="top-right"] { + top: 0; + left: 100%; + transform: translate(0, -100%); + cursor: url("data:image/svg+xml,") 16 16, pointer; + } + + &[rotation-handler="bottom-right"] { + top: 100%; + left: 100%; + cursor: url("data:image/svg+xml,") 16 16, pointer; + } + + &[rotation-handler="bottom-left"] { + top: 100%; + left: 0; + transform: translate(-100%, 0); + cursor: url("data:image/svg+xml,") 16 16, pointer; } }`); @@ -144,7 +199,7 @@ export class SpatialGeometry extends HTMLElement { customElements.define(this.tagName, this); } - static observedAttributes = ['type', 'x', 'y', 'width', 'height']; + static observedAttributes = ['type', 'x', 'y', 'width', 'height', 'rotate']; #internals: ElementInternals; @@ -159,15 +214,14 @@ export class SpatialGeometry extends HTMLElement { 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 = ` - + - - - -`; +`; } #type: Shape = 'rectangle'; @@ -219,6 +273,16 @@ export class SpatialGeometry extends HTMLElement { this.setAttribute('height', height.toString()); } + #previousRotate = 0; + #rotate = 0; + get rotate(): number { + return this.#rotate; + } + + set rotate(rotate: number) { + this.setAttribute('rotate', rotate.toString()); + } + attributeChangedCallback(name: string, _oldValue: string, newValue: string) { if (name === 'x') { this.#previousX = this.#x; @@ -236,6 +300,10 @@ export class SpatialGeometry extends HTMLElement { this.#previousHeight = this.#height; this.#height = Number(newValue); this.#requestUpdate('height'); + } else if (name === 'rotate') { + this.#previousRotate = this.#rotate; + this.#rotate = Number(newValue); + this.#requestUpdate('rotate'); } else if (name === 'type') { if (shapes.has(newValue)) { this.#type = newValue as Shape; @@ -278,27 +346,61 @@ export class SpatialGeometry extends HTMLElement { return; } - const direction = (event.target as HTMLElement).getAttribute('resize-handler'); + const resizeDirection = (event.target as HTMLElement).getAttribute('resize-handler'); - if (direction === null) return; + if (resizeDirection !== null) { + // This triggers a move and resize event :( + if (resizeDirection.includes('top')) { + this.y += event.movementY; + this.height -= event.movementY; + } - if (direction.includes('top')) { - this.y += event.movementY; - this.height -= event.movementY; + if (resizeDirection.includes('right')) { + this.width += event.movementX; + } + + if (resizeDirection.includes('bottom')) { + this.height += event.movementY; + } + + if (resizeDirection.includes('left')) { + this.x += event.movementX; + this.width -= event.movementX; + } + return; } - if (direction.includes('right')) { - this.width += event.movementX; + const rotationDirection = (event.target as HTMLElement).getAttribute('rotation-handler'); + + if (rotationDirection !== null) { + console.log(rotationDirection); + const centerX = (this.#x + this.#width) / 2; + const centerY = (this.#y + this.#height) / 2; + const newAngle = + (Math.atan2(centerY - event.clientX, centerX - event.clientY) * 180) / Math.PI; + console.log(newAngle - this.#rotate); + // this.rotate -= newAngle; + + // When a rotation handler is + // newAngle = (Math.atan2(centerY - mouseY, centerX - mouseX) * 180) / Math.PI - currentAngle; + // if (rotationDirection.includes('top-left')) { + + // } + + // if (rotationDirection.includes('top-right')) { + + // } + + // if (rotationDirection.includes('bottom-right')) { + + // } + + // if (rotationDirection.includes('bottom-left')) { + + // } + return; } - if (direction.includes('bottom')) { - this.height += event.movementY; - } - - if (direction.includes('left')) { - this.x += event.movementX; - this.width -= event.movementX; - } return; } case 'lostpointercapture': { @@ -361,7 +463,6 @@ export class SpatialGeometry extends HTMLElement { this.style.top = `${this.#y}px`; } } else { - // Revert changes to movement this.#x = this.#previousX; this.#y = this.#previousY; } @@ -385,10 +486,25 @@ export class SpatialGeometry extends HTMLElement { this.style.height = `${this.#height}px`; } } else { - // Revert changes to movement + // 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; + } + } } }