chains of thought

This commit is contained in:
“chrisshank” 2024-10-16 13:00:46 -07:00
parent 32dbe887ee
commit 5251b087cc
10 changed files with 452 additions and 2 deletions

View File

@ -0,0 +1,53 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Chains of Thought</title>
<style>
* {
box-sizing: border-box;
}
html {
height: 100%;
}
body {
min-height: 100%;
position: relative;
margin: 0;
}
spatial-connection {
display: block;
position: absolute;
inset: 0 0 0 0;
}
spatial-geometry {
&::part(rotate),
&::part(resize-nw),
&::part(resize-ne),
&::part(resize-se),
&::part(resize-sw) {
display: none;
}
}
textarea {
width: 100%;
height: 100%;
resize: none;
field-sizing: content;
}
</style>
</head>
<body>
<button name="open">Open</button>
<button name="save">Save</button>
<button name="save-as">Save As</button>
<main></main>
<script type="module" src="./main.ts"></script>
</body>
</html>

View File

@ -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` <spatial-geometry data-id="${thought.id}" x="${thought.x}" y="${thought.y}" width="200" height="100">
<textarea>${thought.text}</textarea>
</spatial-geometry>`;
}
function renderConnection({ sourceId, targetId }: Connection) {
return html`<spatial-connection
source="spatial-geometry[data-id='${sourceId}']"
target="spatial-geometry[data-id='${targetId}']"
></spatial-connection>`;
}
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();

View File

@ -27,6 +27,7 @@
<li><a href="/arrow">Arrow</a></li>
<li><a href="/canvasify">Canvasify</a></li>
<li><a href="/spreadsheet">Spreadsheet</a></li>
<li><a href="/chains-of-thought/index.html">Chains of thought</a></li>
</ul>
</body>
</html>

View File

@ -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();

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

102
src/persistence/file.ts Normal file
View File

@ -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<FileSystemFileHandle>(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<string> {
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<FileSystemFileHandle>;
var showOpenFilePicker: (args: any) => Promise<FileSystemFileHandle[]>;
interface FileSystemHandle {
queryPermission: (args: any) => Promise<string>;
requestPermission: (args: any) => Promise<string>;
}
}

View File

@ -0,0 +1,78 @@
export class KeyValueStore<Data> {
#db: Promise<IDBDatabase>;
#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<T>(transaction: IDBRequest<T>) {
return new Promise<T>((resolve, reject) => {
transaction.onsuccess = () => resolve(transaction.result);
transaction.onerror = () => reject(transaction.error);
});
}
#promisifyTransaction(transaction: IDBTransaction) {
return new Promise<void>((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<Data | undefined> {
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<Data[]> {
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]])
)
);
}
}