205 lines
4.9 KiB
TypeScript
205 lines
4.9 KiB
TypeScript
import getStroke, { StrokeOptions } from 'perfect-freehand';
|
|
|
|
export type Point = [x: number, y: number, pressure: number];
|
|
|
|
export type Stroke = number[][];
|
|
|
|
// TODO: look into any-pointer media queries to tell if the user has a mouse or touch screen
|
|
// https://developer.mozilla.org/en-US/docs/Web/CSS/@media/any-pointer
|
|
const styles = new CSSStyleSheet();
|
|
styles.replaceSync(`
|
|
:host, svg {
|
|
display: block;
|
|
height: 100%;
|
|
width: 100%;
|
|
touch-action: none;
|
|
pointer-events: none;
|
|
}
|
|
|
|
:host(:state(drawing)) {
|
|
position: fixed;
|
|
inset: 0 0 0 0;
|
|
cursor: var(--tracing-cursor, crosshair);
|
|
}
|
|
`);
|
|
|
|
export class SpatialInk extends HTMLElement {
|
|
static tagName = 'spatial-ink';
|
|
|
|
static register() {
|
|
customElements.define(this.tagName, this);
|
|
}
|
|
|
|
#internals = this.attachInternals();
|
|
|
|
#svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
|
#path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
|
|
|
#size = Number(this.getAttribute('size') || 16);
|
|
|
|
get size() {
|
|
return this.#size;
|
|
}
|
|
set size(size) {
|
|
this.#size = size;
|
|
this.#update();
|
|
}
|
|
|
|
#thinning = Number(this.getAttribute('thinning') || 0.5);
|
|
|
|
get thinning() {
|
|
return this.#thinning;
|
|
}
|
|
set thinning(thinning) {
|
|
this.#thinning = thinning;
|
|
this.#update();
|
|
}
|
|
|
|
#smoothing = Number(this.getAttribute('smoothing') || 0.5);
|
|
|
|
get smoothing() {
|
|
return this.#smoothing;
|
|
}
|
|
set smoothing(smoothing) {
|
|
this.#smoothing = smoothing;
|
|
this.#update();
|
|
}
|
|
|
|
#streamline = Number(this.getAttribute('streamline') || 0.5);
|
|
|
|
get streamline() {
|
|
return this.#streamline;
|
|
}
|
|
set streamline(streamline) {
|
|
this.#streamline = streamline;
|
|
this.#update();
|
|
}
|
|
|
|
#simulatePressure = this.getAttribute('streamline') === 'false' ? false : true;
|
|
|
|
get simulatePressure() {
|
|
return this.#simulatePressure;
|
|
}
|
|
set simulatePressure(simulatePressure) {
|
|
this.#simulatePressure = simulatePressure;
|
|
this.#update();
|
|
}
|
|
|
|
#points: Point[] = JSON.parse(this.getAttribute('points') || '[]');
|
|
|
|
get points() {
|
|
return this.#points;
|
|
}
|
|
set points(points) {
|
|
this.#points = points;
|
|
this.#update();
|
|
}
|
|
|
|
constructor() {
|
|
super();
|
|
|
|
const shadowRoot = this.attachShadow({ mode: 'open', delegatesFocus: true });
|
|
shadowRoot.adoptedStyleSheets.push(styles);
|
|
this.#svg.appendChild(this.#path);
|
|
shadowRoot.appendChild(this.#svg);
|
|
}
|
|
|
|
connectedCallback() {
|
|
this.#update();
|
|
}
|
|
|
|
getPathBox() {
|
|
return this.#path.getBBox();
|
|
}
|
|
|
|
setViewBox() {
|
|
this.#svg.viewBox;
|
|
}
|
|
|
|
#tracingPromise: PromiseWithResolvers<void> | null = null;
|
|
|
|
// TODO: cancel trace?
|
|
draw(event?: PointerEvent) {
|
|
if (event?.type === 'pointerdown') {
|
|
this.handleEvent(event);
|
|
} else {
|
|
this.addEventListener('pointerdown', this);
|
|
}
|
|
this.#tracingPromise = Promise.withResolvers();
|
|
return this.#tracingPromise.promise;
|
|
}
|
|
|
|
addPoint(point: Point) {
|
|
this.#points.push(point);
|
|
this.#update();
|
|
}
|
|
|
|
handleEvent(event: PointerEvent) {
|
|
switch (event.type) {
|
|
case 'pointerdown': {
|
|
if (event.button !== 0 || event.ctrlKey) return;
|
|
|
|
this.points = [];
|
|
this.addPoint([event.offsetX, event.offsetY, event.pressure]);
|
|
this.addEventListener('lostpointercapture', this);
|
|
this.addEventListener('pointermove', this);
|
|
this.setPointerCapture(event.pointerId);
|
|
this.#internals.states.add('drawing');
|
|
return;
|
|
}
|
|
case 'pointermove': {
|
|
this.addPoint([event.offsetX, event.offsetY, event.pressure]);
|
|
return;
|
|
}
|
|
case 'lostpointercapture': {
|
|
this.removeEventListener('pointerdown', this);
|
|
this.removeEventListener('pointermove', this);
|
|
this.removeEventListener('lostpointercapture', this);
|
|
this.#internals.states.delete('drawing');
|
|
this.#tracingPromise?.resolve();
|
|
this.#tracingPromise = null;
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
#update() {
|
|
const options: StrokeOptions = {
|
|
size: this.#size,
|
|
thinning: this.#thinning,
|
|
smoothing: this.#smoothing,
|
|
streamline: this.#streamline,
|
|
simulatePressure: this.#simulatePressure,
|
|
// TODO: figure out how to expose these as attributes
|
|
easing: (t) => t,
|
|
start: {
|
|
taper: 100,
|
|
easing: (t) => t,
|
|
cap: true,
|
|
},
|
|
end: {
|
|
taper: 100,
|
|
easing: (t) => t,
|
|
cap: true,
|
|
},
|
|
};
|
|
this.#path.setAttribute('d', this.#getSvgPathFromStroke(getStroke(this.#points, options)));
|
|
}
|
|
|
|
#getSvgPathFromStroke(stroke: Stroke): string {
|
|
if (stroke.length === 0) return '';
|
|
|
|
const d = stroke.reduce(
|
|
(acc, [x0, y0], i, arr) => {
|
|
const [x1, y1] = arr[(i + 1) % arr.length];
|
|
acc.push(x0, y0, (x0 + x1) / 2, (y0 + y1) / 2);
|
|
return acc;
|
|
},
|
|
['M', ...stroke[0], 'Q']
|
|
);
|
|
|
|
d.push('Z');
|
|
return d.join(' ');
|
|
}
|
|
}
|