chains of thought
This commit is contained in:
parent
32dbe887ee
commit
5251b087cc
|
|
@ -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>
|
||||||
|
|
@ -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();
|
||||||
|
|
@ -27,6 +27,7 @@
|
||||||
<li><a href="/arrow">Arrow</a></li>
|
<li><a href="/arrow">Arrow</a></li>
|
||||||
<li><a href="/canvasify">Canvasify</a></li>
|
<li><a href="/canvasify">Canvasify</a></li>
|
||||||
<li><a href="/spreadsheet">Spreadsheet</a></li>
|
<li><a href="/spreadsheet">Spreadsheet</a></li>
|
||||||
|
<li><a href="/chains-of-thought/index.html">Chains of thought</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,11 @@ export class AbstractArrow extends HTMLElement {
|
||||||
|
|
||||||
#sourceRect!: DOMRectReadOnly;
|
#sourceRect!: DOMRectReadOnly;
|
||||||
#sourceElement: Element | null = null;
|
#sourceElement: Element | null = null;
|
||||||
|
|
||||||
|
get sourceElement() {
|
||||||
|
return this.#sourceElement;
|
||||||
|
}
|
||||||
|
|
||||||
#sourceCallback = (entry: VisualObserverEntry) => {
|
#sourceCallback = (entry: VisualObserverEntry) => {
|
||||||
this.#sourceRect = entry.contentRect;
|
this.#sourceRect = entry.contentRect;
|
||||||
this.update();
|
this.update();
|
||||||
|
|
@ -40,6 +45,11 @@ export class AbstractArrow extends HTMLElement {
|
||||||
|
|
||||||
#targetRect!: DOMRectReadOnly;
|
#targetRect!: DOMRectReadOnly;
|
||||||
#targetElement: Element | null = null;
|
#targetElement: Element | null = null;
|
||||||
|
|
||||||
|
get targetElement() {
|
||||||
|
return this.#targetElement;
|
||||||
|
}
|
||||||
|
|
||||||
#targetCallback = (entry: VisualObserverEntry) => {
|
#targetCallback = (entry: VisualObserverEntry) => {
|
||||||
this.#targetRect = entry.contentRect;
|
this.#targetRect = entry.contentRect;
|
||||||
this.update();
|
this.update();
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -36,6 +36,7 @@ styles.replaceSync(`
|
||||||
content: '';
|
content: '';
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: -10px -10px -10px -10px;
|
inset: -10px -10px -10px -10px;
|
||||||
|
z-index: -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
::slotted(*) {
|
::slotted(*) {
|
||||||
|
|
@ -132,7 +133,7 @@ styles.replaceSync(`
|
||||||
|
|
||||||
// TODO: add z coordinate?
|
// TODO: add z coordinate?
|
||||||
export class SpatialGeometry extends HTMLElement {
|
export class SpatialGeometry extends HTMLElement {
|
||||||
static tagName = 'spatial-geometry';
|
static tagName = 'spatial-geometry' as const;
|
||||||
|
|
||||||
static register() {
|
static register() {
|
||||||
customElements.define(this.tagName, this);
|
customElements.define(this.tagName, this);
|
||||||
|
|
@ -412,3 +413,9 @@ export class SpatialGeometry extends HTMLElement {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface HTMLElementTagNameMap {
|
||||||
|
[SpatialGeometry.tagName]: SpatialGeometry;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,7 @@ styles.replaceSync(`
|
||||||
`);
|
`);
|
||||||
|
|
||||||
export class SpatialInk extends HTMLElement {
|
export class SpatialInk extends HTMLElement {
|
||||||
static tagName = 'spatial-ink';
|
static tagName = 'spatial-ink' as const;
|
||||||
|
|
||||||
static register() {
|
static register() {
|
||||||
customElements.define(this.tagName, this);
|
customElements.define(this.tagName, this);
|
||||||
|
|
@ -202,3 +202,9 @@ export class SpatialInk extends HTMLElement {
|
||||||
return d.join(' ');
|
return d.join(' ');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface HTMLElementTagNameMap {
|
||||||
|
[SpatialInk.tagName]: SpatialInk;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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]])
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue