diff --git a/demo/ink.html b/demo/ink.html new file mode 100644 index 0000000..e53c43c --- /dev/null +++ b/demo/ink.html @@ -0,0 +1,38 @@ + + + + + + Ink + + + + + + + + diff --git a/package-lock.json b/package-lock.json index fab9620..f33120a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,8 @@ "version": "0.1.0", "license": "MIT", "dependencies": { - "leaflet": "^1.9.4" + "leaflet": "^1.9.4", + "perfect-freehand": "^1.1.0" }, "devDependencies": { "@types/leaflet": "^1.9.12", @@ -699,16 +700,21 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/perfect-freehand": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/perfect-freehand/-/perfect-freehand-1.2.2.tgz", + "integrity": "sha512-eh31l019WICQ03pkF3FSzHxB8n07ItqIQ++G5UV8JX0zVOXzgTGCqnRR0jJ2h9U8/2uW4W4mtGJELt9kEV0CFQ==" + }, "node_modules/picocolors": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.1.tgz", - "integrity": "sha512-anP1Z8qwhkbmu7MFP5iTt+wQKXgwzf7zTyGlcdzabySa9vd0Xt392U0rVmz9poOaBj0uHJKyyo9/upk0HrEQew==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.0.tgz", + "integrity": "sha512-TQ92mBOW0l3LeMeyLV6mzy/kWr8lkd/hp3mTg7wYK7zJhuBStmGMBG0BdeDZS/dZx1IukaX6Bk11zcln25o1Aw==", "dev": true }, "node_modules/postcss": { - "version": "8.4.41", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.41.tgz", - "integrity": "sha512-TesUflQ0WKZqAvg52PWL6kHgLKP6xB6heTOdoYM0Wt2UHyxNa4K25EZZMgKns3BH1RLVbZCREPpLY0rhnNoHVQ==", + "version": "8.4.47", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.47.tgz", + "integrity": "sha512-56rxCq7G/XfB4EkXq9Egn5GCqugWvDFjafDOThIdMBsI15iqPqR5r15TfSr1YPYeEI19YeaXMCbY6u88Y76GLQ==", "dev": true, "funding": [ { @@ -726,8 +732,8 @@ ], "dependencies": { "nanoid": "^3.3.7", - "picocolors": "^1.0.1", - "source-map-js": "^1.2.0" + "picocolors": "^1.1.0", + "source-map-js": "^1.2.1" }, "engines": { "node": "^10 || ^12 || >=14" @@ -769,9 +775,9 @@ } }, "node_modules/source-map-js": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.0.tgz", - "integrity": "sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", "dev": true, "engines": { "node": ">=0.10.0" @@ -797,14 +803,14 @@ "dev": true }, "node_modules/vite": { - "version": "5.4.1", - "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.1.tgz", - "integrity": "sha512-1oE6yuNXssjrZdblI9AfBbHCC41nnyoVoEZxQnID6yvQZAFBzxxkqoFLtHUMkYunL8hwOLEjgTuxpkRxvba3kA==", + "version": "5.4.6", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.6.tgz", + "integrity": "sha512-IeL5f8OO5nylsgzd9tq4qD2QqI0k2CQLGrWD0rCN0EQJZpBK5vJAx0I+GDkMOXxQX/OfFHMuLIx6ddAxGX/k+Q==", "dev": true, "dependencies": { "esbuild": "^0.21.3", - "postcss": "^8.4.41", - "rollup": "^4.13.0" + "postcss": "^8.4.43", + "rollup": "^4.20.0" }, "bin": { "vite": "bin/vite.js" diff --git a/package.json b/package.json index dbadea5..4e554b5 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ "vite": "^5.0.0" }, "dependencies": { - "leaflet": "^1.9.4" + "leaflet": "^1.9.4", + "perfect-freehand": "^1.1.0" } } diff --git a/src/canvas/spatial-ink.ts b/src/canvas/spatial-ink.ts new file mode 100644 index 0000000..2633960 --- /dev/null +++ b/src/canvas/spatial-ink.ts @@ -0,0 +1,194 @@ +import getStroke, { StrokeOptions } from 'perfect-freehand'; + +export type Point = [x: number, y: number, pressure: number]; + +export type Stroke = number[][]; + +const styles = new CSSStyleSheet(); +styles.replaceSync(` + svg { + height: 100%; + width: 100%; + touch-action: none; + } + + :host(:state(tracing)) svg { + position: fixed; + inset: 0 0 0 0; + z-index: calc(infinity); + } +`); + +export class SpatialInk extends HTMLElement { + static tagName = 'spatial-ink'; + + static register() { + customElements.define(this.tagName, this); + } + + #internals = this.attachInternals(); + + #path = document.createElementNS('http://www.w3.org/2000/svg', 'path'); + + #stroke: Stroke = []; + + #d = ''; + + #size = Number(this.getAttribute('size') || 32); + + 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); + const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + svg.appendChild(this.#path); + shadowRoot.appendChild(svg); + } + + connectedCallback() { + this.#update(); + } + + trace() { + this.points = []; + this.#internals.states.add('tracing'); + this.addEventListener('pointerdown', this); + this.addEventListener('lostpointercapture', this); + } + + 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.addEventListener('pointermove', this); + this.setPointerCapture(event.pointerId); + this.addPoint([event.pageX, event.pageY, event.pressure]); + return; + } + case 'pointermove': { + this.addPoint([event.pageX, event.pageY, event.pressure]); + return; + } + case 'lostpointercapture': { + this.#internals.states.delete('tracing'); + this.removeEventListener('pointermove', this); + this.removeEventListener('pointerdown', this); + this.removeEventListener('lostpointercapture', this); + 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: 0, + easing: (t) => t, + cap: true, + }, + end: { + taper: 100, + easing: (t) => t, + cap: true, + }, + }; + + this.#stroke = getStroke(this.#points, options); + this.#d = this.#getSvgPathFromStroke(this.#stroke); + this.#path.setAttribute('d', this.#d); + } + + #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(' '); + } +}