From 32a36a1cadbe4a830157f5d526d2bdb92dc817e1 Mon Sep 17 00:00:00 2001 From: Orion Reed Date: Tue, 3 Dec 2024 16:48:11 -0500 Subject: [PATCH] rotating cursors --- src/common/cursors.ts | 37 +++++++++++++++++ src/folk-shape.ts | 96 ++++++++++++++++++++++++++++++++----------- 2 files changed, 110 insertions(+), 23 deletions(-) create mode 100644 src/common/cursors.ts diff --git a/src/common/cursors.ts b/src/common/cursors.ts new file mode 100644 index 0000000..94f95c3 --- /dev/null +++ b/src/common/cursors.ts @@ -0,0 +1,37 @@ +const resizeCursorCache = new Map(); +const rotateCursorCache = new Map(); + +function getRoundedDegree(degrees: number, interval: number = 1): number { + return (Math.round(degrees / interval) * interval) % 360; +} + +export function getResizeCursorUrl(degrees: number): string { + degrees = getRoundedDegree(degrees); + + // Map degrees greater than 180 to equivalent symmetrical cursor + if (degrees > 180) { + degrees -= 180; + } + + if (!resizeCursorCache.has(degrees)) { + const url = resizeCursorUrl(degrees); + resizeCursorCache.set(degrees, url); + } + return resizeCursorCache.get(degrees)!; +} + +export function getRotateCursorUrl(degrees: number): string { + degrees = getRoundedDegree(degrees); + + if (!rotateCursorCache.has(degrees)) { + const url = rotateCursorUrl(degrees); + rotateCursorCache.set(degrees, url); + } + return rotateCursorCache.get(degrees)!; +} + +const resizeCursorUrl = (degrees: number) => + `url("data:image/svg+xml,") 16 16, nwse-resize`; + +const rotateCursorUrl = (degrees: number) => + `url("data:image/svg+xml,") 16 16, pointer`; diff --git a/src/folk-shape.ts b/src/folk-shape.ts index 34328bb..c1334ab 100644 --- a/src/folk-shape.ts +++ b/src/folk-shape.ts @@ -2,6 +2,7 @@ import { css, html } from './common/tags'; import { ResizeObserverManager } from './common/resize-observer'; import type { Point, RotatedDOMRect } from './common/types'; import { Vector } from './common/Vector'; +import { getResizeCursorUrl, getRotateCursorUrl } from './common/cursors'; const resizeObserver = new ResizeObserverManager(); @@ -18,13 +19,6 @@ type Handle = | 'rotation-sw' | 'move'; -const resizeCursorUrl = (degrees: number) => - `url("data:image/svg+xml,") 16 16, nwse-resize`; -const rotateCursorUrl = (degrees: number) => - `url("data:image/svg+xml,") 16 16, pointer`; - export type TransformEventDetail = { rotate: number; }; @@ -83,7 +77,7 @@ styles.replaceSync(css` :host { display: block; position: absolute; - cursor: var(--fc-move, move); + cursor: move; box-sizing: border-box; } @@ -164,12 +158,12 @@ styles.replaceSync(css` [part='resize-nw'], [part='resize-se'] { - cursor: var(--fc-nwse-resize, url('${resizeCursorUrl(0)}') 16 16, nwse-resize); + cursor: var(--resize-handle-cursor-nw); } [part='resize-ne'], [part='resize-sw'] { - cursor: var(--fc-nesw-resize, url('${resizeCursorUrl(0)}') 16 16, nesw-resize); + cursor: var(--resize-handle-cursor-ne); } [part^='rotation'] { @@ -182,7 +176,7 @@ styles.replaceSync(css` opacity: 0; width: 16px; aspect-ratio: 1; - cursor: var(--fc-rotate, url('${rotateCursorUrl(0)}') 16 16, pointer) !important; + cursor: var(--fc-rotate, url('${getRotateCursorUrl(0)}') 16 16, pointer); } [part='rotation-nw'] { @@ -231,9 +225,10 @@ export class FolkShape extends HTMLElement { } #shadow = this.attachShadow({ mode: 'open' }); - #internals = this.attachInternals(); + #dynamicStyles = new CSSStyleSheet(); + #type = (this.getAttribute('type') || 'rectangle') as Shape; get type(): Shape { return this.#type; @@ -333,7 +328,7 @@ export class FolkShape extends HTMLElement { this.addEventListener('pointerdown', this); this.setAttribute('tabindex', '0'); - this.#shadow.adoptedStyleSheets.push(styles); + this.#shadow.adoptedStyleSheets = [styles, this.#dynamicStyles]; // 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 @@ -531,6 +526,24 @@ export class FolkShape extends HTMLElement { const center = this.getClientRect().center(); const currentAngle = Vector.angleFromOrigin({ x: event.clientX, y: event.clientY }, center); this.rotation = this.#initialRotation + (currentAngle - this.#startAngle); + + let degrees = (this.rotation * 180) / Math.PI; + switch (handle) { + case 'rotation-ne': + degrees = (degrees + 90) % 360; + break; + case 'rotation-se': + degrees = (degrees + 180) % 360; + break; + case 'rotation-sw': + degrees = (degrees + 270) % 360; + break; + } + + const target = event.composedPath()[0] as HTMLElement; + const rotateCursor = getRotateCursorUrl(degrees); + target.style.setProperty('cursor', rotateCursor); + return; } @@ -542,7 +555,12 @@ export class FolkShape extends HTMLElement { this.#internals.states.delete(interaction); this.removeEventListener('pointermove', this); this.removeEventListener('lostpointercapture', this); - document.body.style.cursor = 'default'; + + this.#updateCursors(); + if (target.getAttribute('part')?.startsWith('rotation')) { + target.style.removeProperty('cursor'); + } + return; } } @@ -567,15 +585,6 @@ export class FolkShape extends HTMLElement { // 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('rotation')) { - const degrees = (this.#rotation * 180) / Math.PI; - - this.style.setProperty('--fc-nwse-resize', resizeCursorUrl(degrees)); - this.style.setProperty('--fc-nesw-resize', resizeCursorUrl(degrees + 90)); - this.style.setProperty('--fc-rotate', rotateCursorUrl(degrees)); - document.body.style.cursor = rotateCursorUrl(degrees); - } - this.#dispatchTransformEvent(updatedProperties); } @@ -632,6 +641,47 @@ export class FolkShape extends HTMLElement { this.#previousWidth = previousRect.width; this.#dispatchTransformEvent(new Set(['width', 'height'])); }; + + #updateCursors() { + const degrees = (this.#rotation * 180) / Math.PI; + + const resizeCursor0 = getResizeCursorUrl(degrees); + const resizeCursor90 = getResizeCursorUrl((degrees + 90) % 360); + const rotateCursor0 = getRotateCursorUrl(degrees); + const rotateCursor90 = getRotateCursorUrl((degrees + 90) % 360); + const rotateCursor180 = getRotateCursorUrl((degrees + 180) % 360); + const rotateCursor270 = getRotateCursorUrl((degrees + 270) % 360); + + const dynamicStyles = ` + [part='resize-nw'], + [part='resize-se'] { + cursor: ${resizeCursor0}; + } + + [part='resize-ne'], + [part='resize-sw'] { + cursor: ${resizeCursor90}; + } + + [part='rotation-nw'] { + cursor: ${rotateCursor0}; + } + + [part='rotation-ne'] { + cursor: ${rotateCursor90}; + } + + [part='rotation-se'] { + cursor: ${rotateCursor180}; + } + + [part='rotation-sw'] { + cursor: ${rotateCursor270}; + } + `; + + this.#dynamicStyles.replaceSync(dynamicStyles); + } } if (!customElements.get('folk-shape')) {