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="/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>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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: '';
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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