173 lines
4.7 KiB
TypeScript
173 lines
4.7 KiB
TypeScript
export type Shape = 'rectangle' | 'circle' | 'triangle';
|
|
|
|
// Can we make adding new shapes extensible via a static property?
|
|
const shapes = new Set(['rectangle', 'circle', 'triangle']);
|
|
|
|
export type Vector = { x: number; y: number; movementX: number; movementY: number };
|
|
|
|
// Should the move event bubble?
|
|
export class MoveEvent extends CustomEvent<Vector> {
|
|
constructor(vector: Vector) {
|
|
super('move', { detail: vector, cancelable: true, bubbles: true });
|
|
}
|
|
}
|
|
|
|
// TODO: add z coordinate?
|
|
export class SpatialGeometry extends HTMLElement {
|
|
static tagName = 'spatial-geometry';
|
|
|
|
static register() {
|
|
customElements.define(this.tagName, this);
|
|
}
|
|
|
|
static observedAttributes = ['type', 'x', 'y'];
|
|
|
|
constructor() {
|
|
super();
|
|
|
|
this.addEventListener('pointerdown', this);
|
|
this.addEventListener('lostpointercapture', this);
|
|
this.addEventListener('touchstart', this);
|
|
this.addEventListener('dragstart', this);
|
|
}
|
|
|
|
#type: Shape = 'rectangle';
|
|
get type(): Shape {
|
|
return this.#type;
|
|
}
|
|
|
|
set type(type: Shape) {
|
|
this.setAttribute('type', type);
|
|
}
|
|
|
|
#previousX = 0;
|
|
#x = 0;
|
|
get x(): number {
|
|
return this.#x;
|
|
}
|
|
|
|
set x(x: number) {
|
|
this.setAttribute('x', x.toString());
|
|
}
|
|
|
|
#previousY = 0;
|
|
#y = 0;
|
|
get y(): number {
|
|
return this.#y;
|
|
}
|
|
|
|
set y(y: number) {
|
|
this.setAttribute('y', y.toString());
|
|
}
|
|
|
|
attributeChangedCallback(name: string, _oldValue: string, newValue: string) {
|
|
if (name === 'x') {
|
|
this.#previousX = this.#x;
|
|
this.#x = Number(newValue);
|
|
this.#requestUpdate('x');
|
|
} else if (name === 'y') {
|
|
this.#previousY = this.#y;
|
|
this.#y = Number(newValue);
|
|
this.#requestUpdate('y');
|
|
} else if (name === 'type') {
|
|
if (shapes.has(newValue)) {
|
|
this.#type = newValue as Shape;
|
|
this.#requestUpdate('type');
|
|
}
|
|
}
|
|
}
|
|
|
|
disconnectedCallback() {
|
|
cancelAnimationFrame(this.#rAFId);
|
|
}
|
|
|
|
// Similar to `Element.getClientBoundingRect()`, but returns an SVG path that precisely outlines the shape.
|
|
// We might also want some kind of utility function that maps a path into an approximate set of vertices.
|
|
getBoundingPath(): string {
|
|
return '';
|
|
}
|
|
|
|
handleEvent(event: PointerEvent) {
|
|
switch (event.type) {
|
|
case 'pointerdown': {
|
|
if (event.button !== 0 || event.ctrlKey) return;
|
|
|
|
this.addEventListener('pointermove', this);
|
|
this.setPointerCapture(event.pointerId);
|
|
this.style.userSelect = 'none';
|
|
return;
|
|
}
|
|
case 'pointermove': {
|
|
this.x += event.movementX;
|
|
this.y += event.movementY;
|
|
return;
|
|
}
|
|
case 'lostpointercapture': {
|
|
this.style.userSelect = '';
|
|
this.removeEventListener('pointermove', this);
|
|
return;
|
|
}
|
|
case 'touchstart':
|
|
case 'dragstart': {
|
|
event.preventDefault();
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
#updatedProperties = new Set<string>();
|
|
#rAFId = -1;
|
|
#isUpdating = false;
|
|
|
|
#requestUpdate(property: string) {
|
|
this.#updatedProperties.add(property);
|
|
|
|
if (this.#isUpdating) return;
|
|
|
|
this.#isUpdating = true;
|
|
this.#rAFId = requestAnimationFrame(() => {
|
|
this.#isUpdating = false;
|
|
this.#update(this.#updatedProperties);
|
|
this.#updatedProperties.clear();
|
|
this.#rAFId = -1;
|
|
});
|
|
}
|
|
|
|
// Any updates that should be batched should happen here like updating the DOM or emitting events should be executed here.
|
|
#update(updatedProperties: Set<string>) {
|
|
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({
|
|
x: this.#x,
|
|
y: this.#y,
|
|
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 {
|
|
// Revert changes to movement
|
|
this.#x = this.#previousX;
|
|
this.#y = this.#previousY;
|
|
}
|
|
}
|
|
}
|
|
}
|