folk-canvas/labs/folk-ink.ts

140 lines
3.8 KiB
TypeScript

import { getStroke, StrokeOptions } from 'perfect-freehand';
import { FolkElement } from '@lib/folk-element';
import { property } from '@lit/reactive-element/decorators.js';
import { css, PropertyValues } from '@lit/reactive-element';
import { getSvgPathFromStroke } from '@lib/utils';
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
declare global {
interface HTMLElementTagNameMap {
'folk-ink': FolkInk;
}
}
export class FolkInk extends FolkElement {
static override tagName = 'folk-ink';
static override styles = css`
: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);
}
`;
@property({ type: Number, reflect: true }) size = 16;
@property({ type: Number, reflect: true }) thinning = 0.5;
@property({ type: Number, reflect: true }) smoothing = 0.5;
@property({ type: Number, reflect: true }) streamline = 0.5;
@property({ type: Boolean, reflect: true }) simulatePressure = true;
@property({ type: Array }) points: Point[] = [];
#internals = this.attachInternals();
#svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
#path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
#tracingPromise: PromiseWithResolvers<void> | null = null;
override createRenderRoot() {
const root = super.createRenderRoot();
this.#svg.appendChild(this.#path);
root.appendChild(this.#svg);
return root;
}
getPathBox() {
return this.#path.getBBox();
}
// 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.requestUpdate('points');
}
handleEvent(event: PointerEvent) {
switch (event.type) {
// for some reason adding a point on pointer down causes a bug
case 'pointerdown': {
if (event.button !== 0 || event.ctrlKey) return;
this.points = [];
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(changedProperties: PropertyValues<this>) {
super.update(changedProperties);
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', getSvgPathFromStroke(getStroke(this.points, options)));
}
}