import { css, html } from './common/tags';
import { ResizeObserverManager } from './common/resize-observer';
import type { Point } from './common/types';
import { Vector } from './common/Vector';
const resizeObserver = new ResizeObserverManager();
export type Shape = 'rectangle' | 'circle' | 'triangle';
type RotatedDOMRect = DOMRect & {
/** in degrees */
rotation: number;
/** Returns the center point in worldspace coordinates */
center(): Point;
/** Returns the four corners in worldspace coordinates, in clockwise order */
corners(): [Point, Point, Point, Point];
/** Returns all the vertices in worldspace coordinates */
vertices(): Point[];
};
export type MoveEventDetail = { movementX: number; movementY: number };
export class MoveEvent extends CustomEvent {
constructor(detail: MoveEventDetail) {
super('move', { detail, cancelable: true, bubbles: true });
}
}
export type ResizeEventDetail = { movementX: number; movementY: number };
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 });
}
}
export type Dimension = number | 'auto';
const styles = new CSSStyleSheet();
styles.replaceSync(css`
:host {
display: block;
position: absolute;
cursor: var(--fc-move, move);
box-sizing: border-box;
}
:host::before {
content: '';
position: absolute;
inset: -15px -15px -15px -15px;
z-index: -1;
}
div {
height: 100%;
width: 100%;
overflow: hidden;
cursor: default;
}
:host(:focus-within) {
z-index: calc(infinity - 1);
outline: solid 1px hsl(214, 84%, 56%);
}
:host(:hover) {
outline: solid 2px hsl(214, 84%, 56%);
}
:host(:state(move)),
:host(:state(rotate)),
:host(:state(resize-nw)),
:host(:state(resize-ne)),
:host(:state(resize-se)),
:host(:state(resize-sw)) {
user-select: none;
}
[part='resize-nw'],
[part='resize-ne'],
[part='resize-se'],
[part='resize-sw'] {
display: block;
position: absolute;
box-sizing: border-box;
padding: 0;
background: hsl(210, 20%, 98%);
z-index: calc(infinity);
width: 13px;
aspect-ratio: 1;
transform: translate(-50%, -50%);
border: 1.5px solid hsl(214, 84%, 56%);
border-radius: 2px;
}
[part='resize-nw'] {
top: 0;
left: 0;
}
[part='resize-ne'] {
top: 0;
left: 100%;
}
[part='resize-se'] {
top: 100%;
left: 100%;
}
[part='resize-sw'] {
top: 100%;
left: 0;
}
[part='resize-nw'],
[part='resize-se'] {
cursor: var(--fc-nwse-resize, nwse-resize);
}
[part='resize-ne'],
[part='resize-sw'] {
cursor: var(--fc-nesw-resize, nesw-resize);
}
[part='rotate'] {
z-index: calc(infinity);
display: block;
position: absolute;
box-sizing: border-box;
padding: 0;
border: 1.5px solid hsl(214, 84%, 56%);
border-radius: 50%;
background: hsl(210, 20%, 98%);
width: 13px;
aspect-ratio: 1;
top: 0;
left: 50%;
translate: -50% -150%;
cursor: url("data:image/svg+xml,")
16 16,
pointer;
}
:host(:not(:focus-within)) [part^='resize'],
:host(:not(:focus-within)) [part='rotate'] {
opacity: 0;
cursor: default;
}
`);
declare global {
interface HTMLElementTagNameMap {
'folk-shape': FolkShape;
}
}
// TODO: add z coordinate?
export class FolkShape extends HTMLElement {
static tagName = 'folk-shape';
static define() {
customElements.define(this.tagName, this);
}
#internals = this.attachInternals();
#type = (this.getAttribute('type') || 'rectangle') as Shape;
get type(): Shape {
return this.#type;
}
set type(type: Shape) {
this.setAttribute('type', type);
}
#previousX = 0;
#x = Number(this.getAttribute('x')) || 0;
get x() {
return this.#x;
}
set x(x) {
this.#previousX = this.#x;
this.#x = x;
this.#requestUpdate('x');
}
#previousY = 0;
#y = Number(this.getAttribute('y')) || 0;
get y() {
return this.#y;
}
set y(y) {
this.#previousY = this.#y;
this.#y = y;
this.#requestUpdate('y');
}
#autoContentRect = this.getBoundingClientRect();
#previousWidth: Dimension = 0;
#width: Dimension = 0;
get width(): number {
if (this.#width === 'auto') {
return this.#autoContentRect.width;
}
return this.#width;
}
set width(width: Dimension) {
if (width === 'auto') {
resizeObserver.observe(this, this.#onResize);
} else if (this.#width === 'auto' && this.#height !== 'auto') {
resizeObserver.unobserve(this, this.#onResize);
}
this.#previousWidth = this.#width;
this.#width = width;
this.#requestUpdate('width');
}
#previousHeight: Dimension = 0;
#height: Dimension = 0;
get height(): number {
if (this.#height === 'auto') {
return this.#autoContentRect.height;
}
return this.#height;
}
set height(height: Dimension) {
if (height === 'auto') {
resizeObserver.observe(this, this.#onResize);
} else if (this.#height === 'auto' && this.#width !== 'auto') {
resizeObserver.unobserve(this, this.#onResize);
}
this.#previousHeight = this.#height;
this.#height = height;
this.#requestUpdate('height');
}
#initialRotation = 0;
#startAngle = 0;
#previousRotation = 0;
// TODO: consider using radians instead of degrees
#rotation = Number(this.getAttribute('rotate')) || 0;
get rotation(): number {
return this.#rotation;
}
set rotation(rotation: number) {
this.#previousRotation = this.#rotation;
this.#rotation = rotation;
this.#requestUpdate('rotate');
}
constructor() {
super();
this.addEventListener('pointerdown', this);
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 = html`
`;
this.height = Number(this.getAttribute('height')) || 'auto';
this.width = Number(this.getAttribute('width')) || 'auto';
}
connectedCallback() {
this.#update(new Set(['type', 'x', 'y', 'height', 'width', 'rotate']));
}
getClientRect(): RotatedDOMRect {
const { x, y, width, height, rotation } = this;
const radians = (rotation * Math.PI) / 180;
return {
x,
y,
width,
height,
left: x,
top: y,
right: x + width,
bottom: y + height,
rotation,
center(): Point {
return {
x: this.x + this.width / 2,
y: this.y + this.height / 2,
};
},
vertices(): Point[] {
// TODO: Implement
return [];
},
corners() {
const center = this.center();
const radians = (this.rotation * Math.PI) / 180;
const { x, y, width, height } = this;
return [
Vector.rotateAround({ x, y }, center, radians),
Vector.rotateAround({ x: x + width, y }, center, radians),
Vector.rotateAround({ x: x + width, y: y + height }, center, radians),
Vector.rotateAround({ x, y: y + height }, center, radians),
];
},
toJSON: undefined as any,
};
}
// Similar to `Element.getClientBoundingRect()`, but returns an SVG path that precisely outlines the shape.
getBoundingPath(): string {
return '';
}
// We might also want some kind of utility function that maps a path into an approximate set of vertices.
getBoundingVertices() {
return [];
}
handleEvent(event: PointerEvent) {
switch (event.type) {
case 'pointerdown': {
if (event.button !== 0 || event.ctrlKey) return;
const target = event.composedPath()[0] as HTMLElement;
// Store initial angle on rotation start
if (target.getAttribute('part') === 'rotate') {
// We need to store initial rotation/angle somewhere.
// This is a little awkward as we'll want to do *quite a lot* of this kind of thing.
// Might be an argument for making elements dumber (i.e. not have them manage their own state) and do this from the outside.
// But we also want to preserve the self-sufficient nature of elements' behaviour...
// Maybe some kind of shared utility, used by both the element and the outside environment?
this.#initialRotation = this.#rotation;
const centerX = this.#x + this.width / 2;
const centerY = this.#y + this.height / 2;
this.#startAngle = Math.atan2(event.clientY - centerY, event.clientX - centerX);
}
// ignore interactions from slotted elements.
if (target !== this && !target.hasAttribute('part')) return;
target.addEventListener('pointermove', this);
this.addEventListener('lostpointercapture', this);
target.setPointerCapture(event.pointerId);
const interaction = target.getAttribute('part') || 'move';
this.#internals.states.add(interaction);
this.focus();
return;
}
case 'pointermove': {
const target = event.target as HTMLElement;
if (target === null) return;
if (target === this) {
this.x += event.movementX;
this.y += event.movementY;
return;
}
const part = target.getAttribute('part');
if (part === null) return;
if (part.includes('resize')) {
let newWidth = this.width;
let newHeight = this.height;
let newX = this.x;
let newY = this.y;
if (part.includes('-n')) {
const proposedHeight = this.height - event.movementY;
if (proposedHeight > 0) {
newHeight = proposedHeight;
newY = this.y + event.movementY;
}
}
if (part.endsWith('e')) {
const proposedWidth = this.width + event.movementX;
if (proposedWidth > 0) {
newWidth = proposedWidth;
}
}
if (part.includes('-s')) {
const proposedHeight = this.height + event.movementY;
if (proposedHeight > 0) {
newHeight = proposedHeight;
}
}
if (part.endsWith('w')) {
const proposedWidth = this.width - event.movementX;
if (proposedWidth > 0) {
newWidth = proposedWidth;
newX = this.x + event.movementX;
}
}
this.width = newWidth;
this.height = newHeight;
this.x = newX;
this.y = newY;
return;
}
if (part === 'rotate') {
const centerX = this.#x + this.width / 2;
const centerY = this.#y + this.height / 2;
const currentAngle = Math.atan2(event.clientY - centerY, event.clientX - centerX);
const deltaAngle = currentAngle - this.#startAngle;
this.rotation = this.#initialRotation + (deltaAngle * 180) / Math.PI;
return;
}
return;
}
case 'lostpointercapture': {
const target = event.composedPath()[0] as HTMLElement;
const interaction = target.getAttribute('part') || 'move';
this.#internals.states.delete(interaction);
target.removeEventListener('pointermove', this);
this.removeEventListener('lostpointercapture', this);
return;
}
}
}
#updatedProperties = new Set();
#isUpdating = false;
async #requestUpdate(property: string) {
this.#updatedProperties.add(property);
if (this.#isUpdating) return;
this.#isUpdating = true;
await true;
this.#isUpdating = false;
this.#update(this.#updatedProperties);
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) {
if (updatedProperties.has('type')) {
// TODO: Update shape styles. For many shapes, we could just use clip-path to style the shape.
// If we use relative values in `clip-path: polygon()`, then no JS is needed to style the shape
// If `clip-path: path()` is used then we need to update the path in JS.
// See https://www.smashingmagazine.com/2024/05/modern-guide-making-css-shapes/
}
if (updatedProperties.has('x') || updatedProperties.has('y')) {
// Although the change in movement isn't useful inside this component, the outside world might find it helpful to calculate acceleration and other physics
const notCancelled = this.dispatchEvent(
new MoveEvent({
movementX: this.#x - this.#previousX,
movementY: this.#y - this.#previousY,
})
);
if (notCancelled) {
if (updatedProperties.has('x')) {
// In the future, when CSS `attr()` is supported we could define this x/y projection in CSS.
this.style.left = `${this.#x}px`;
}
if (updatedProperties.has('y')) {
this.style.top = `${this.#y}px`;
}
} else {
this.#x = this.#previousX;
this.#y = this.#previousY;
}
}
if (updatedProperties.has('width') || updatedProperties.has('height')) {
// 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 ResizeEvent({
movementX: this.width - (this.#previousWidth === 'auto' ? 0 : this.#previousWidth),
movementY: this.height - (this.#previousHeight === 'auto' ? 0 : this.#previousHeight),
})
);
if (notCancelled) {
if (updatedProperties.has('width')) {
this.style.width = this.#width === 'auto' ? '' : `${this.#width}px`;
}
if (updatedProperties.has('height')) {
this.style.height = this.#height === 'auto' ? '' : `${this.#height}px`;
}
} else {
// 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.#rotation - this.#previousRotation }));
if (notCancelled) {
if (updatedProperties.has('rotate')) {
this.style.rotate = `${this.#rotation}deg`;
}
} else {
this.#rotation = this.#previousRotation;
}
}
}
#onResize = (entry: ResizeObserverEntry) => {
const previousRect = this.#autoContentRect;
this.#autoContentRect = entry.contentRect;
const notCancelled = this.dispatchEvent(
new ResizeEvent({
movementX: this.width - (this.#previousWidth === 'auto' ? previousRect.width : this.#previousWidth),
movementY: this.height - (this.#previousHeight === 'auto' ? previousRect.height : this.#previousHeight),
})
);
if (!notCancelled) {
if (this.#height === 'auto') {
this.height = previousRect?.height || 0;
}
if (this.#width === 'auto') {
this.width = previousRect?.width || 0;
}
}
};
}