import { getResizeCursorUrl, getRotateCursorUrl } from "./cursors"; import { DOMRectTransform, DOMRectTransformReadonly } from "./DOMRectTransform"; import { FolkElement } from "./folk-element"; import { ResizeManager } from "./resize-manager"; import { css, html } from "./tags"; import { TransformEvent } from "./TransformEvent"; import type { Point } from "./types"; import { MAX_Z_INDEX } from "./utils"; import { Vector } from "./Vector"; import type { PropertyValues } from "@lit/reactive-element"; const resizeManager = new ResizeManager(); type ResizeHandle = | "resize-top-left" | "resize-top-right" | "resize-bottom-right" | "resize-bottom-left"; type RotateHandle = | "rotation-top-left" | "rotation-top-right" | "rotation-bottom-right" | "rotation-bottom-left"; type Handle = ResizeHandle | RotateHandle | "move"; export type Dimension = number | "auto"; type HandleMap = Record; const oppositeHandleMap: HandleMap = { "resize-bottom-right": "resize-top-left", "resize-bottom-left": "resize-top-right", "resize-top-left": "resize-bottom-right", "resize-top-right": "resize-bottom-left", }; const flipXHandleMap: HandleMap = { "resize-bottom-right": "resize-bottom-left", "resize-bottom-left": "resize-bottom-right", "resize-top-left": "resize-top-right", "resize-top-right": "resize-top-left", }; const flipYHandleMap: HandleMap = { "resize-bottom-right": "resize-top-right", "resize-bottom-left": "resize-top-left", "resize-top-left": "resize-bottom-left", "resize-top-right": "resize-bottom-right", }; const styles = css` * { box-sizing: border-box; } :host { display: block; position: absolute; top: 0; left: 0; cursor: move; transform-origin: 0 0; box-sizing: border-box; } :host::before { content: ""; position: absolute; inset: -15px; z-index: -1; } div { height: 100%; width: 100%; overflow: scroll; pointer-events: none; } ::slotted(*) { cursor: default; pointer-events: auto; } :host(:focus-within), :host(:focus-visible) { z-index: calc(${MAX_Z_INDEX} - 1); outline: solid 1px hsl(214, 84%, 56%); } :host(:hover), :host(:state(highlighted)) { outline: solid 2px hsl(214, 84%, 56%); } :host(:state(move)), :host(:state(rotate)), :host(:state(resize-top-left)), :host(:state(resize-top-right)), :host(:state(resize-bottom-right)), :host(:state(resize-bottom-left)) { user-select: none; } [part] { aspect-ratio: 1; display: none; position: absolute; z-index: calc(${MAX_Z_INDEX} - 1); padding: 0; } [part^="resize"] { background: hsl(210, 20%, 98%); width: 9px; transform: translate(-50%, -50%); border: 1.5px solid hsl(214, 84%, 56%); border-radius: 2px; } [part^="rotation"] { opacity: 0; width: 16px; } [part$="top-left"] { top: 0; left: 0; } [part="rotation-top-left"] { translate: -100% -100%; } [part$="top-right"] { top: 0; left: 100%; } [part="rotation-top-right"] { translate: 0% -100%; } [part$="bottom-right"] { top: 100%; left: 100%; } [part="rotation-bottom-right"] { translate: 0% 0%; } [part$="bottom-left"] { top: 100%; left: 0; } [part="rotation-bottom-left"] { translate: -100% 0%; } :host(:focus-within) :is([part^="resize"], [part^="rotation"]) { display: block; } `; declare global { interface HTMLElementTagNameMap { "folk-shape": FolkShape; } } export class FolkShape extends FolkElement { static tagName = "folk-shape"; static styles = styles; #internals = this.attachInternals(); #attrWidth: Dimension = 0; #attrHeight: Dimension = 0; #rect = new DOMRectTransform(); #previousRect = new DOMRectTransform(); #readonlyRect = new DOMRectTransformReadonly(); #handles!: Record; #startAngle = 0; #lastTouchPos: Point | null = null; #isTouchDragging = false; get x() { return this.#rect.x; } set x(x) { this.#previousRect.x = this.#rect.x; this.#rect.x = x; this.requestUpdate("x"); } get y() { return this.#rect.y; } set y(y) { this.#previousRect.y = this.#rect.y; this.#rect.y = y; this.requestUpdate("y"); } get width(): number { return this.#rect.width; } set width(width: Dimension) { if (width === "auto") { resizeManager.observe(this, this.#onAutoResize); } else { if (this.#attrWidth === "auto" && this.#attrHeight !== "auto") { resizeManager.unobserve(this, this.#onAutoResize); } this.#previousRect.width = this.#rect.width; this.#rect.width = width; } this.#attrWidth = width; this.requestUpdate("width"); } get height(): number { return this.#rect.height; } set height(height: Dimension) { if (height === "auto") { resizeManager.observe(this, this.#onAutoResize); } else { if (this.#attrHeight === "auto" && this.#attrWidth !== "auto") { resizeManager.unobserve(this, this.#onAutoResize); } this.#previousRect.height = this.#rect.height; this.#rect.height = height; } this.#attrHeight = height; this.requestUpdate("height"); } get rotation(): number { return this.#rect.rotation; } set rotation(rotation: number) { this.#previousRect.rotation = this.#rect.rotation; this.#rect.rotation = rotation; this.requestUpdate("rotation"); } #highlighted = false; get highlighted() { return this.#highlighted; } set highlighted(highlighted) { if (this.#highlighted === highlighted) return; this.#highlighted = highlighted; highlighted ? this.#internals.states.add("highlighted") : this.#internals.states.delete("highlighted"); } override createRenderRoot() { const root = super.createRenderRoot(); this.addEventListener("pointerdown", this); this.addEventListener("touchstart", this, { passive: false }); this.addEventListener("touchmove", this, { passive: false }); this.addEventListener("touchend", this); this.addEventListener("keydown", this); (root as ShadowRoot).setHTMLUnsafe( html`
`, ); this.#handles = Object.fromEntries( Array.from(root.querySelectorAll("[part]")).map((el) => [ el.getAttribute("part") as ResizeHandle | RotateHandle, el as HTMLElement, ]), ) as Record; this.#updateCursors(); this.x = Number(this.getAttribute("x")) || 0; this.y = Number(this.getAttribute("y")) || 0; this.width = Number(this.getAttribute("width")) || "auto"; this.height = Number(this.getAttribute("height")) || "auto"; this.rotation = (Number(this.getAttribute("rotation")) || 0) * (Math.PI / 180); this.#rect.transformOrigin = { x: 0, y: 0 }; this.#rect.rotateOrigin = { x: 0.5, y: 0.5 }; this.#previousRect = new DOMRectTransform(this.#rect); this.setAttribute("tabindex", "0"); return root; } getTransformDOMRect() { return this.#readonlyRect; } handleEvent(event: PointerEvent | KeyboardEvent | TouchEvent) { // Handle touch events for mobile drag support if (event instanceof TouchEvent) { event.preventDefault(); const target = event.composedPath()[0] as HTMLElement; const isDragHandle = target?.closest?.(".header, [data-drag]") !== null; const isValidDragTarget = target === this || isDragHandle; if (event.type === "touchstart" && event.touches.length === 1) { if (!isValidDragTarget) return; const touch = event.touches[0]; this.#lastTouchPos = { x: touch.clientX, y: touch.clientY }; this.#isTouchDragging = true; this.#internals.states.add("move"); this.focus(); return; } if (event.type === "touchmove" && this.#isTouchDragging && event.touches.length === 1) { const touch = event.touches[0]; if (this.#lastTouchPos) { const zoom = window.visualViewport?.scale ?? 1; const moveDelta = { x: (touch.clientX - this.#lastTouchPos.x) / zoom, y: (touch.clientY - this.#lastTouchPos.y) / zoom, }; this.#lastTouchPos = { x: touch.clientX, y: touch.clientY }; // Apply movement this.#rect.x += moveDelta.x; this.#rect.y += moveDelta.y; this.requestUpdate(); this.#dispatchTransformEvent(); } return; } if (event.type === "touchend") { this.#lastTouchPos = null; this.#isTouchDragging = false; this.#internals.states.delete("move"); return; } return; } const focusedElement = (this.renderRoot as ShadowRoot).activeElement as HTMLElement | null; const target = event.composedPath()[0] as HTMLElement; let handle: Handle | null = null; if (target) { handle = target.getAttribute("part") as Handle | null; } else if (focusedElement) { handle = focusedElement.getAttribute("part") as Handle | null; } // Check if target is a drag handle (header or element with data-drag attribute) const isDragHandle = target?.closest?.(".header, [data-drag]") !== null; if (event instanceof PointerEvent) { event.stopPropagation(); if (event.type === "pointerdown") { // Allow drag from: the host itself, a handle, or a drag handle element if (target !== this && !handle && !isDragHandle) return; if (handle?.startsWith("rotation")) { const parentRotateOrigin = this.#rect.toParentSpace({ x: this.#rect.width * this.#rect.rotateOrigin.x, y: this.#rect.height * this.#rect.rotateOrigin.y, }); const mousePos = { x: event.clientX, y: event.clientY }; this.#startAngle = Vector.angleFromOrigin(mousePos, parentRotateOrigin) - this.#rect.rotation; } target.addEventListener("pointermove", this); target.addEventListener("lostpointercapture", this); target.setPointerCapture(event.pointerId); this.#internals.states.add(handle || "move"); this.focus(); return; } if (event.type === "lostpointercapture") { this.#internals.states.delete(handle || "move"); target.removeEventListener("pointermove", this); target.removeEventListener("lostpointercapture", this); this.#updateCursors(); if (handle?.startsWith("rotation")) { target.style.removeProperty("cursor"); } return; } } let moveDelta: Point | null = null; if (event instanceof KeyboardEvent) { const MOVEMENT_MUL = event.shiftKey ? 20 : 2; const arrowKeys = ["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"]; if (!arrowKeys.includes(event.key)) return; moveDelta = { x: (event.key === "ArrowRight" ? 1 : event.key === "ArrowLeft" ? -1 : 0) * MOVEMENT_MUL, y: (event.key === "ArrowDown" ? 1 : event.key === "ArrowUp" ? -1 : 0) * MOVEMENT_MUL, }; } else if (event.type === "pointermove") { if (!target) return; const zoom = window.visualViewport?.scale ?? 1; moveDelta = { x: event.movementX / zoom, y: event.movementY / zoom, }; } if (!moveDelta) return; if (target === this || isDragHandle || (!handle && event instanceof KeyboardEvent)) { if (event instanceof KeyboardEvent && event.altKey) { const ROTATION_MUL = event.shiftKey ? Math.PI / 12 : Math.PI / 36; const rotationDelta = moveDelta.x !== 0 ? (moveDelta.x > 0 ? ROTATION_MUL : -ROTATION_MUL) : 0; this.rotation += rotationDelta; } else { this.x += moveDelta.x; this.y += moveDelta.y; } event.preventDefault(); return; } if (handle?.startsWith("resize")) { const rect = this.#rect; const corner = { "resize-top-left": rect.topLeft, "resize-top-right": rect.topRight, "resize-bottom-right": rect.bottomRight, "resize-bottom-left": rect.bottomLeft, }[handle as ResizeHandle]; const currentPos = rect.toParentSpace(corner); const mousePos = event instanceof KeyboardEvent ? { x: currentPos.x + moveDelta.x, y: currentPos.y + moveDelta.y } : { x: event.clientX, y: event.clientY }; this.#handleResize( handle as ResizeHandle, mousePos, target, event instanceof PointerEvent ? event : undefined, ); event.preventDefault(); return; } if (handle?.startsWith("rotation") && event instanceof PointerEvent) { const parentRotateOrigin = this.#rect.toParentSpace({ x: this.#rect.width * this.#rect.rotateOrigin.x, y: this.#rect.height * this.#rect.rotateOrigin.y, }); const currentAngle = Vector.angleFromOrigin( { x: event.clientX, y: event.clientY }, parentRotateOrigin, ); this.rotation = currentAngle - this.#startAngle; const degrees = (this.#rect.rotation * 180) / Math.PI; const cursorRotation = { "rotation-top-left": degrees, "rotation-top-right": (degrees + 90) % 360, "rotation-bottom-right": (degrees + 180) % 360, "rotation-bottom-left": (degrees + 270) % 360, }[handle as RotateHandle]; target.style.setProperty("cursor", getRotateCursorUrl(cursorRotation)); return; } } protected override update(changedProperties: PropertyValues): void { this.#dispatchTransformEvent(); super.update(changedProperties); } #dispatchTransformEvent() { const emittedRect = new DOMRectTransform(this.#rect); const event = new TransformEvent(emittedRect, this.#previousRect); this.dispatchEvent(event); if (event.xPrevented) emittedRect.x = this.#previousRect.x; if (event.yPrevented) emittedRect.y = this.#previousRect.y; if (event.widthPrevented) emittedRect.width = this.#previousRect.width; if (event.heightPrevented) emittedRect.height = this.#previousRect.height; if (event.rotatePrevented) emittedRect.rotation = this.#previousRect.rotation; this.style.transform = emittedRect.toCssString(); this.style.width = this.#attrWidth === "auto" ? "" : `${emittedRect.width}px`; this.style.height = this.#attrHeight === "auto" ? "" : `${emittedRect.height}px`; this.#readonlyRect = new DOMRectTransformReadonly(emittedRect); } #onAutoResize = (entry: ResizeObserverEntry) => { this.#previousRect.height = this.#rect.height; this.#rect.height = entry.contentRect.height; this.#previousRect.width = this.#rect.width; this.#rect.width = entry.contentRect.width; this.#dispatchTransformEvent(); }; #updateCursors() { const degrees = (this.#rect.rotation * 180) / Math.PI; const resizeCursor0 = getResizeCursorUrl(degrees); const resizeCursor90 = getResizeCursorUrl((degrees + 90) % 360); this.#handles["resize-top-left"].style.setProperty("cursor", resizeCursor0); this.#handles["resize-bottom-right"].style.setProperty("cursor", resizeCursor0); this.#handles["resize-top-right"].style.setProperty("cursor", resizeCursor90); this.#handles["resize-bottom-left"].style.setProperty("cursor", resizeCursor90); this.#handles["rotation-top-left"].style.setProperty("cursor", getRotateCursorUrl(degrees)); this.#handles["rotation-top-right"].style.setProperty( "cursor", getRotateCursorUrl((degrees + 90) % 360), ); this.#handles["rotation-bottom-right"].style.setProperty( "cursor", getRotateCursorUrl((degrees + 180) % 360), ); this.#handles["rotation-bottom-left"].style.setProperty( "cursor", getRotateCursorUrl((degrees + 270) % 360), ); } #handleResize(handle: ResizeHandle, pointerPos: Point, target: HTMLElement, event?: PointerEvent) { const localPointer = this.#rect.toLocalSpace(pointerPos); switch (handle) { case "resize-bottom-right": this.#rect.bottomRight = localPointer; break; case "resize-bottom-left": this.#rect.bottomLeft = localPointer; break; case "resize-top-left": this.#rect.topLeft = localPointer; break; case "resize-top-right": this.#rect.topRight = localPointer; break; } let nextHandle: ResizeHandle = handle; const flipWidth = this.#rect.width < 0; const flipHeight = this.#rect.height < 0; if (flipWidth && flipHeight) { nextHandle = oppositeHandleMap[handle]; } else if (flipWidth) { nextHandle = flipXHandleMap[handle]; } else if (flipHeight) { nextHandle = flipYHandleMap[handle]; } const newTarget = this.renderRoot.querySelector(`[part="${nextHandle}"]`) as HTMLElement; if (newTarget) { newTarget.focus(); this.#internals.states.delete(handle); this.#internals.states.add(nextHandle); if (event && "setPointerCapture" in target) { target.removeEventListener("pointermove", this); target.removeEventListener("lostpointercapture", this); newTarget.addEventListener("pointermove", this); newTarget.addEventListener("lostpointercapture", this); target.releasePointerCapture(event.pointerId); newTarget.setPointerCapture(event.pointerId); } } this.requestUpdate(); } /** * Serialize shape to JSON for Automerge sync * Subclasses should override and call super.toJSON() */ toJSON(): Record { return { type: "folk-shape", id: this.id, x: this.x, y: this.y, width: this.width, height: this.height, rotation: this.rotation, }; } }