diff --git a/demo/chains-of-thought/index.html b/demo/chains-of-thought/index.html new file mode 100644 index 0000000..555c66e --- /dev/null +++ b/demo/chains-of-thought/index.html @@ -0,0 +1,53 @@ + + + + + + Chains of Thought + + + + + + +
+ + + diff --git a/demo/chains-of-thought/main.ts b/demo/chains-of-thought/main.ts new file mode 100644 index 0000000..a2019f1 --- /dev/null +++ b/demo/chains-of-thought/main.ts @@ -0,0 +1,92 @@ +import { SpatialGeometry } from '../../src/canvas/spatial-geometry.ts'; +import { SpatialConnection } from '../../src/arrows/spatial-connection.ts'; +import { FileSaver } from '../../src/persistence/file.ts'; + +SpatialGeometry.register(); +SpatialConnection.register(); + +interface Thought { + id: string; + text: string; + x: number; + y: number; +} + +interface Connection { + sourceId: string; + targetId: string; +} + +interface ChainOfThought { + thoughts: Thought[]; + connections: Connection[]; +} + +const html = String.raw; + +function renderThought(thought: Thought) { + return html` + + `; +} + +function renderConnection({ sourceId, targetId }: Connection) { + return html``; +} + +function renderChainOfThought({ thoughts, connections }: ChainOfThought) { + return html`${thoughts.map(renderThought).join('')}${connections.map(renderConnection).join('')}`; +} + +function parseChainOfThought(): ChainOfThought { + return { + thoughts: Array.from(document.querySelectorAll('spatial-geometry')).map((el) => ({ + id: el.dataset.id || '', + text: el.querySelector('textarea')?.value || '', + x: el.x, + y: el.y, + })), + connections: Array.from(document.querySelectorAll('spatial-connection')).map((el) => ({ + sourceId: (el.sourceElement as SpatialGeometry).dataset.id || '', + targetId: (el.targetElement as SpatialGeometry).dataset.id || '', + })), + }; +} + +const openButton = document.querySelector('button[name="open"]')!; +const saveButton = document.querySelector('button[name="save"]')!; +const saveAsButton = document.querySelector('button[name="save-as"]')!; +const main = document.querySelector('main')!; +const fileSaver = new FileSaver('chains-of-thought', 'json', 'application/json'); + +async function openFile() { + try { + const text = await fileSaver.open(); + const json = JSON.parse(text); + main.innerHTML = renderChainOfThought(json); + } catch (e) { + // No file handler was persisted or the file is invalid JSON. + console.error(e); + } +} + +async function saveFile(promptNewFile = false) { + fileSaver.save(JSON.stringify(parseChainOfThought(), null, 2), promptNewFile); +} + +openButton.addEventListener('click', () => { + openFile(); +}); + +saveButton.addEventListener('click', () => { + saveFile(); +}); + +saveAsButton.addEventListener('click', () => { + saveFile(true); +}); + +openFile(); diff --git a/demo/index.html b/demo/index.html index 089a513..d0b7399 100644 --- a/demo/index.html +++ b/demo/index.html @@ -27,6 +27,7 @@
  • Arrow
  • Canvasify
  • Spreadsheet
  • +
  • Chains of thought
  • diff --git a/src/arrows/abstract-arrow.ts b/src/arrows/abstract-arrow.ts index efb0796..750256d 100644 --- a/src/arrows/abstract-arrow.ts +++ b/src/arrows/abstract-arrow.ts @@ -23,6 +23,11 @@ export class AbstractArrow extends HTMLElement { #sourceRect!: DOMRectReadOnly; #sourceElement: Element | null = null; + + get sourceElement() { + return this.#sourceElement; + } + #sourceCallback = (entry: VisualObserverEntry) => { this.#sourceRect = entry.contentRect; this.update(); @@ -40,6 +45,11 @@ export class AbstractArrow extends HTMLElement { #targetRect!: DOMRectReadOnly; #targetElement: Element | null = null; + + get targetElement() { + return this.#targetElement; + } + #targetCallback = (entry: VisualObserverEntry) => { this.#targetRect = entry.contentRect; this.update(); diff --git a/src/arrows/spatial-connection.ts b/src/arrows/spatial-connection.ts new file mode 100644 index 0000000..82252c5 --- /dev/null +++ b/src/arrows/spatial-connection.ts @@ -0,0 +1,101 @@ +import { getBoxToBoxArrow } from 'perfect-arrows'; +import { AbstractArrow } from './abstract-arrow'; +import { pointsOnBezierCurves } from './points-on-path'; +import getStroke, { StrokeOptions } from 'perfect-freehand'; + +export type Arrow = [ + /** The x position of the (padded) starting point. */ + sx: number, + /** The y position of the (padded) starting point. */ + sy: number, + /** The x position of the control point. */ + cx: number, + /** The y position of the control point. */ + cy: number, + /** The x position of the (padded) ending point. */ + ex: number, + /** The y position of the (padded) ending point. */ + ey: number, + /** The angle (in radians) for an ending arrowhead. */ + ae: number, + /** The angle (in radians) for a starting arrowhead. */ + as: number, + /** The angle (in radians) for a center arrowhead. */ + ac: number +]; + +export class SpatialConnection extends AbstractArrow { + static tagName = 'spatial-connection' as const; + + #options: StrokeOptions = { + size: 10, + thinning: 0.5, + smoothing: 0.5, + streamline: 0.5, + simulatePressure: true, + // TODO: figure out how to expose these as attributes + easing: (t) => t, + start: { + taper: 50, + easing: (t) => t, + cap: true, + }, + end: { + taper: 0, + easing: (t) => t, + cap: true, + }, + }; + + render(sourceRect: DOMRectReadOnly, targetRect: DOMRectReadOnly) { + const [sx, sy, cx, cy, ex, ey, ae] = getBoxToBoxArrow( + sourceRect.x, + sourceRect.y, + sourceRect.width, + sourceRect.height, + targetRect.x, + targetRect.y, + targetRect.width, + targetRect.height + ) as Arrow; + + const points = pointsOnBezierCurves([ + [sx, sy], + [cx, cy], + [ex, ey], + [ex, ey], + ]); + + const stroke = getStroke(points, this.#options); + const path = getSvgPathFromStroke(stroke); + this.style.clipPath = `path('${path}')`; + this.style.backgroundColor = 'black'; + } +} + +function getSvgPathFromStroke(stroke: number[][]): string { + if (stroke.length === 0) return ''; + + for (const point of stroke) { + point[0] = Math.round(point[0] * 100) / 100; + point[1] = Math.round(point[1] * 100) / 100; + } + + 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(' '); +} + +declare global { + interface HTMLElementTagNameMap { + [SpatialConnection.tagName]: SpatialConnection; + } +} diff --git a/src/canvas/spatial-geometry.ts b/src/canvas/spatial-geometry.ts index 3815a6d..ce65dd1 100644 --- a/src/canvas/spatial-geometry.ts +++ b/src/canvas/spatial-geometry.ts @@ -36,6 +36,7 @@ styles.replaceSync(` content: ''; position: absolute; inset: -10px -10px -10px -10px; + z-index: -1; } ::slotted(*) { @@ -132,7 +133,7 @@ styles.replaceSync(` // TODO: add z coordinate? export class SpatialGeometry extends HTMLElement { - static tagName = 'spatial-geometry'; + static tagName = 'spatial-geometry' as const; static register() { customElements.define(this.tagName, this); @@ -412,3 +413,9 @@ export class SpatialGeometry extends HTMLElement { } } } + +declare global { + interface HTMLElementTagNameMap { + [SpatialGeometry.tagName]: SpatialGeometry; + } +} diff --git a/src/canvas/spatial-ink.ts b/src/canvas/spatial-ink.ts index f38c472..c6b3476 100644 --- a/src/canvas/spatial-ink.ts +++ b/src/canvas/spatial-ink.ts @@ -24,7 +24,7 @@ styles.replaceSync(` `); export class SpatialInk extends HTMLElement { - static tagName = 'spatial-ink'; + static tagName = 'spatial-ink' as const; static register() { customElements.define(this.tagName, this); @@ -202,3 +202,9 @@ export class SpatialInk extends HTMLElement { return d.join(' '); } } + +declare global { + interface HTMLElementTagNameMap { + [SpatialInk.tagName]: SpatialInk; + } +} diff --git a/src/index.ts b/src/index.ts deleted file mode 100644 index e69de29..0000000 diff --git a/src/persistence/file.ts b/src/persistence/file.ts new file mode 100644 index 0000000..47acebf --- /dev/null +++ b/src/persistence/file.ts @@ -0,0 +1,102 @@ +import { KeyValueStore } from './indexeddb'; + +export class FileSaver { + #id; + #fileType; + #fileExtension; + #mimeType; + #store; + #fileHandlerPromise; + + // Feature detection. The API needs to be supported and the app not run in an iframe. + #supportsFileSystemAccess = + 'showSaveFilePicker' in window && + (() => { + try { + return window.self === window.top; + } catch { + return false; + } + })(); + + constructor(id: string, fileType: string, mimeType: string) { + this.#id = id; + this.#fileType = fileType; + this.#fileExtension = `.${this.#fileType}`; + this.#mimeType = mimeType; + this.#store = new KeyValueStore(this.#id); + this.#fileHandlerPromise = this.#loadFileHandler(); + } + + async #loadFileHandler() { + const file = await this.#store.get('file'); + + if (file === undefined) return undefined; + + // We need to request permission since the file handler was persisted. + // Calling `queryPermission` seems unnecessary atm since the browser prompts permission for each session + const previousPermission = await file.queryPermission({ mode: 'readwrite' }); + if (previousPermission === 'granted') return file; + + const newPermission = await file.requestPermission({ mode: 'readwrite' }); + if (newPermission === 'granted') return file; + + return undefined; + } + + async open(): Promise { + let fileHandler = await this.#fileHandlerPromise; + + if (fileHandler === undefined) { + fileHandler = await this.#showFilePicker(); + } + + const file = await fileHandler.getFile(); + const text = await file.text(); + return text; + } + + async save(content: string, promptNewFile = false) { + // TODO: progressively enhance using anchor downloads? + if (!this.#supportsFileSystemAccess) { + throw new Error('File System Access API is not supported.'); + } + + let fileHandler = await this.#fileHandlerPromise; + + if (promptNewFile || fileHandler === undefined) { + fileHandler = await this.#showFilePicker(); + } + + const writer = await fileHandler.createWritable(); + await writer.write(content); + await writer.close(); + } + + async #showFilePicker() { + this.#fileHandlerPromise = window.showSaveFilePicker({ + id: this.#id, + suggestedName: `${this.#id}.${this.#fileType}`, + types: [ + { + description: `${this.#fileType.toUpperCase()} document`, + accept: { [this.#mimeType]: [this.#fileExtension] }, + }, + ], + }); + + const fileHandler = (await this.#fileHandlerPromise)!; + await this.#store.set('file', fileHandler); + return fileHandler; + } +} + +declare global { + var showSaveFilePicker: (args: any) => Promise; + var showOpenFilePicker: (args: any) => Promise; + + interface FileSystemHandle { + queryPermission: (args: any) => Promise; + requestPermission: (args: any) => Promise; + } +} diff --git a/src/persistence/indexeddb.ts b/src/persistence/indexeddb.ts new file mode 100644 index 0000000..56660b0 --- /dev/null +++ b/src/persistence/indexeddb.ts @@ -0,0 +1,78 @@ +export class KeyValueStore { + #db: Promise; + #storeName; + + constructor(name = 'keyval-store') { + this.#storeName = name; + const request = indexedDB.open(name); + request.onupgradeneeded = () => request.result.createObjectStore(name); + + this.#db = this.#promisifyRequest(request); + } + + #promisifyRequest(transaction: IDBRequest) { + return new Promise((resolve, reject) => { + transaction.onsuccess = () => resolve(transaction.result); + transaction.onerror = () => reject(transaction.error); + }); + } + + #promisifyTransaction(transaction: IDBTransaction) { + return new Promise((resolve, reject) => { + transaction.oncomplete = () => resolve(); + transaction.onabort = transaction.onerror = () => reject(transaction.error); + }); + } + + #getStore(mode: 'readonly' | 'readwrite') { + return this.#db.then((db) => db.transaction(this.#storeName, mode).objectStore(this.#storeName)); + } + + get(key: IDBValidKey): Promise { + return this.#getStore('readonly').then((store) => this.#promisifyRequest(store.get(key))); + } + + set(key: IDBValidKey, value: Data) { + return this.#getStore('readwrite').then((store) => { + store.put(value, key); + return this.#promisifyTransaction(store.transaction); + }); + } + + setMany(entries: [IDBValidKey, Data][]) { + return this.#getStore('readwrite').then((store) => { + entries.forEach((entry) => store.put(entry[1], entry[0])); + return this.#promisifyTransaction(store.transaction); + }); + } + + delete(key: IDBValidKey) { + return this.#getStore('readwrite').then((store) => { + store.delete(key); + return this.#promisifyTransaction(store.transaction); + }); + } + + clear() { + return this.#getStore('readwrite').then((store) => { + store.clear(); + return this.#promisifyTransaction(store.transaction); + }); + } + + keys() { + return this.#getStore('readwrite').then((store) => this.#promisifyRequest(store.getAllKeys())); + } + + values(): Promise { + return this.#getStore('readwrite').then((store) => this.#promisifyRequest(store.getAll())); + } + + entries(): Promise<[IDBValidKey, Data][]> { + return this.#getStore('readwrite').then((store) => + Promise.all([this.#promisifyRequest(store.getAllKeys()), this.#promisifyRequest(store.getAll())]).then( + ([keys, values]) => keys.map((key, i) => [key, values[i]]) + ) + ); + } +}