forma-diagrams 0.5.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Dockerfile +16 -0
- package/LICENSE +21 -0
- package/README.md +113 -0
- package/THIRD_PARTY_NOTICES.md +20 -0
- package/bin/forma +3 -0
- package/deploy/.env.example +11 -0
- package/deploy/Caddyfile +4 -0
- package/deploy/compose.yml +38 -0
- package/dist/assets/IBMPlexSans-Regular-Bl2SjS7V.ttf +0 -0
- package/dist/assets/IBMPlexSans-SemiBold-B9auKknr.ttf +0 -0
- package/dist/assets/elk.bundled-Y7ymokgr.js +24 -0
- package/dist/assets/index-_kg9cdik.css +1 -0
- package/dist/assets/index-al0xDdTG.js +452 -0
- package/dist/fonts/IBMPlexSans-Regular.ttf +0 -0
- package/dist/fonts/IBMPlexSans-SemiBold.ttf +0 -0
- package/dist/fonts/OFL.txt +93 -0
- package/dist/index.html +14 -0
- package/dist/licenses/FORMA_LICENSE.txt +21 -0
- package/dist/licenses/THIRD_PARTY_LICENSES.txt +11601 -0
- package/docs/api.md +41 -0
- package/docs/decisions/001-foundation.md +57 -0
- package/docs/decisions/002-composition-and-human-edits.md +47 -0
- package/docs/decisions/003-general-composition.md +30 -0
- package/docs/decisions/004-library-and-distribution.md +33 -0
- package/docs/decisions/005-organizational-hosting.md +33 -0
- package/docs/decisions/006-hosted-agent-access.md +25 -0
- package/docs/format.md +132 -0
- package/docs/gallery.md +73 -0
- package/docs/images/architecture.png +0 -0
- package/docs/images/editor.png +0 -0
- package/docs/images/gallery/architecture.png +0 -0
- package/docs/images/gallery/decision.png +0 -0
- package/docs/images/gallery/development-signal.png +0 -0
- package/docs/images/gallery/development.png +0 -0
- package/docs/images/gallery/editor-roundtrip.png +0 -0
- package/docs/images/gallery/entities.png +0 -0
- package/docs/images/gallery/mindmap.png +0 -0
- package/docs/images/gallery/organization.png +0 -0
- package/docs/images/gallery/timeline.png +0 -0
- package/docs/images/release.png +0 -0
- package/docs/install.md +50 -0
- package/docs/plans/mvp.md +24 -0
- package/docs/self-hosting.md +167 -0
- package/docs/verification-v2.md +41 -0
- package/docs/verification-v3.md +16 -0
- package/docs/verification-v4.1.md +35 -0
- package/docs/verification-v4.2.md +20 -0
- package/docs/verification-v4.3.md +5 -0
- package/docs/verification-v4.md +31 -0
- package/docs/verification-v5.1.md +7 -0
- package/docs/verification-v5.2.md +7 -0
- package/docs/verification-v5.md +7 -0
- package/docs/verification.md +91 -0
- package/examples/design-systems/atelier.json +53 -0
- package/examples/design-systems/signal.json +53 -0
- package/examples/gallery/architecture.forma.json +178 -0
- package/examples/gallery/decision.forma.json +182 -0
- package/examples/gallery/development-signal.forma.json +299 -0
- package/examples/gallery/development.forma.json +299 -0
- package/examples/gallery/entities.forma.json +123 -0
- package/examples/gallery/mindmap.forma.json +158 -0
- package/examples/gallery/organization.forma.json +151 -0
- package/examples/gallery/timeline.forma.json +138 -0
- package/examples/platform.forma.json +116 -0
- package/examples/release.forma.json +58 -0
- package/examples/roundtrip/agent-continued.forma.json +310 -0
- package/examples/roundtrip/agent-patch.json +7 -0
- package/examples/roundtrip/human-edited.forma.json +309 -0
- package/package.json +81 -0
- package/packages/cli/bin.mjs +3 -0
- package/packages/cli/src/agent-access.ts +232 -0
- package/packages/cli/src/blob-store.ts +241 -0
- package/packages/cli/src/file-library.ts +179 -0
- package/packages/cli/src/google.ts +51 -0
- package/packages/cli/src/hosted.ts +498 -0
- package/packages/cli/src/http.ts +67 -0
- package/packages/cli/src/index.ts +397 -0
- package/packages/cli/src/mcp.ts +114 -0
- package/packages/cli/src/remote.ts +228 -0
- package/packages/cli/src/server.ts +69 -0
- package/packages/cli/src/signed-cookie.ts +42 -0
- package/packages/core/src/align.ts +237 -0
- package/packages/core/src/document.ts +408 -0
- package/packages/core/src/font-metrics.json +1 -0
- package/packages/core/src/geometry.ts +463 -0
- package/packages/core/src/index.ts +12 -0
- package/packages/core/src/inspect.ts +230 -0
- package/packages/core/src/layout.ts +450 -0
- package/packages/core/src/render.ts +182 -0
- package/packages/core/src/resolve-style.ts +29 -0
- package/packages/core/src/scene.ts +40 -0
- package/packages/core/src/styles.ts +122 -0
- package/packages/core/src/text.ts +81 -0
- package/packages/core/src/theme.ts +33 -0
- package/public/fonts/IBMPlexSans-Regular.ttf +0 -0
- package/public/fonts/IBMPlexSans-SemiBold.ttf +0 -0
- package/public/fonts/OFL.txt +93 -0
- package/schema/design-system.v1.schema.json +734 -0
- package/schema/forma.v1.schema.json +273 -0
- package/schema/forma.v2.schema.json +1756 -0
- package/skills/forma/SKILL.md +20 -0
- package/skills/forma/references/composition.md +11 -0
- package/skills/forma/references/hosted.md +28 -0
- package/skills/forma-architecture/SKILL.md +14 -0
- package/skills/forma-entities/SKILL.md +14 -0
- package/skills/forma-flow/SKILL.md +14 -0
- package/skills/forma-mindmap/SKILL.md +14 -0
- package/skills/forma-organization/SKILL.md +14 -0
- package/skills/forma-timeline/SKILL.md +14 -0
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import { readFile, writeFile, rename, unlink, lstat } from 'node:fs/promises';
|
|
2
|
+
import { resolve, dirname, join, basename } from 'node:path';
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
4
|
+
import { z } from 'zod';
|
|
5
|
+
import { parseDocument, serializeDocument } from '../../core/src/index';
|
|
6
|
+
import { HttpError } from './file-library';
|
|
7
|
+
|
|
8
|
+
export function remoteOrigin(value: string) {
|
|
9
|
+
const url = new URL(value);
|
|
10
|
+
if (
|
|
11
|
+
url.username ||
|
|
12
|
+
url.password ||
|
|
13
|
+
url.pathname !== '/' ||
|
|
14
|
+
url.search ||
|
|
15
|
+
url.hash ||
|
|
16
|
+
!(
|
|
17
|
+
url.protocol === 'https:' ||
|
|
18
|
+
(url.protocol === 'http:' && ['localhost', '127.0.0.1'].includes(url.hostname))
|
|
19
|
+
)
|
|
20
|
+
)
|
|
21
|
+
throw new Error(
|
|
22
|
+
'FORMA_REMOTE_URL must be an HTTPS origin (loopback HTTP is allowed for development).',
|
|
23
|
+
);
|
|
24
|
+
return url.origin;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function remoteClient(env: NodeJS.ProcessEnv = process.env) {
|
|
28
|
+
if (!env.FORMA_REMOTE_URL) throw new Error('Set FORMA_REMOTE_URL to your hosted Forma origin.');
|
|
29
|
+
const origin = remoteOrigin(env.FORMA_REMOTE_URL);
|
|
30
|
+
if (env.FORMA_AGENT_TOKEN && env.FORMA_AGENT_TOKEN_FILE)
|
|
31
|
+
throw new Error('Use either FORMA_AGENT_TOKEN or FORMA_AGENT_TOKEN_FILE, not both.');
|
|
32
|
+
let token = env.FORMA_AGENT_TOKEN?.trim();
|
|
33
|
+
if (env.FORMA_AGENT_TOKEN_FILE) {
|
|
34
|
+
const stat = await lstat(env.FORMA_AGENT_TOKEN_FILE);
|
|
35
|
+
if (
|
|
36
|
+
!stat.isFile() ||
|
|
37
|
+
stat.size > 4096 ||
|
|
38
|
+
(process.platform !== 'win32' && (stat.mode & 0o077) !== 0)
|
|
39
|
+
)
|
|
40
|
+
throw new Error('Use a regular private token file (chmod 600), no larger than 4 KB.');
|
|
41
|
+
token = (await readFile(env.FORMA_AGENT_TOKEN_FILE, 'utf8')).trim();
|
|
42
|
+
}
|
|
43
|
+
if (!/^forma_[a-f0-9]{24}_[a-f0-9]{64}$/.test(token ?? ''))
|
|
44
|
+
throw new Error(
|
|
45
|
+
'Set a Forma agent token using FORMA_AGENT_TOKEN or a private FORMA_AGENT_TOKEN_FILE.',
|
|
46
|
+
);
|
|
47
|
+
const request = async (path: string, body?: unknown) => {
|
|
48
|
+
let response: Response;
|
|
49
|
+
try {
|
|
50
|
+
response = await fetch(origin + '/api/agent/' + path, {
|
|
51
|
+
method: body === undefined ? 'GET' : 'POST',
|
|
52
|
+
headers: {
|
|
53
|
+
Authorization: `Bearer ${token}`,
|
|
54
|
+
...(body === undefined ? {} : { 'Content-Type': 'application/json' }),
|
|
55
|
+
},
|
|
56
|
+
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
|
57
|
+
redirect: 'error',
|
|
58
|
+
signal: AbortSignal.timeout(30000),
|
|
59
|
+
});
|
|
60
|
+
} catch {
|
|
61
|
+
throw new Error(
|
|
62
|
+
'Could not reach the configured Forma server. Redirects are not followed; check its exact origin.',
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
const reader = response.body?.getReader();
|
|
66
|
+
let size = 0;
|
|
67
|
+
const chunks: Uint8Array[] = [];
|
|
68
|
+
if (reader)
|
|
69
|
+
while (true) {
|
|
70
|
+
const chunk = await reader.read();
|
|
71
|
+
if (chunk.done) break;
|
|
72
|
+
size += chunk.value.length;
|
|
73
|
+
if (size > 8_000_000) {
|
|
74
|
+
await reader.cancel();
|
|
75
|
+
throw new Error('Server response exceeds 8 MB.');
|
|
76
|
+
}
|
|
77
|
+
chunks.push(chunk.value);
|
|
78
|
+
}
|
|
79
|
+
let data;
|
|
80
|
+
try {
|
|
81
|
+
data = JSON.parse(Buffer.concat(chunks).toString());
|
|
82
|
+
} catch {
|
|
83
|
+
throw new Error('The server returned an invalid response.');
|
|
84
|
+
}
|
|
85
|
+
if (!response.ok)
|
|
86
|
+
throw new HttpError(
|
|
87
|
+
response.status,
|
|
88
|
+
`Forma HTTP ${response.status}: ${data.error ?? 'Request rejected.'}`,
|
|
89
|
+
);
|
|
90
|
+
return data;
|
|
91
|
+
};
|
|
92
|
+
const service = {
|
|
93
|
+
me: () => request('me'),
|
|
94
|
+
list: () => request('diagrams'),
|
|
95
|
+
read: (path: string) => request('document?path=' + encodeURIComponent(path)),
|
|
96
|
+
write: (data: unknown) => request('document', data),
|
|
97
|
+
};
|
|
98
|
+
return { origin, service };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const bindingSchema = z
|
|
102
|
+
.object({
|
|
103
|
+
version: z.literal(1),
|
|
104
|
+
server: z.string(),
|
|
105
|
+
owner: z.string(),
|
|
106
|
+
path: z.string(),
|
|
107
|
+
revision: z.string().regex(/^[a-f0-9]{64}$/),
|
|
108
|
+
})
|
|
109
|
+
.strict();
|
|
110
|
+
|
|
111
|
+
async function atomic(path: string, text: string) {
|
|
112
|
+
const temp = join(dirname(path), `.${basename(path)}.${randomUUID()}.tmp`);
|
|
113
|
+
try {
|
|
114
|
+
await writeFile(temp, text, { flag: 'wx', mode: 0o600 });
|
|
115
|
+
await rename(temp, path);
|
|
116
|
+
} finally {
|
|
117
|
+
await unlink(temp).catch(() => {});
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export async function remoteCommand(args: string[]) {
|
|
122
|
+
const { origin, service } = await remoteClient();
|
|
123
|
+
const operation = args.shift();
|
|
124
|
+
if (operation === 'whoami' && !args.length) return service.me();
|
|
125
|
+
if (operation === 'list' && !args.length) return service.list();
|
|
126
|
+
if (operation === 'pull') {
|
|
127
|
+
const path = args.shift(),
|
|
128
|
+
options: Record<string, string | boolean> = {};
|
|
129
|
+
while (args.length) {
|
|
130
|
+
const key = args.shift();
|
|
131
|
+
if (key === '--overwrite') options[key] = true;
|
|
132
|
+
else if (key === '--output' && args.length) options[key] = args.shift()!;
|
|
133
|
+
else throw new Error('Usage: forma remote pull PATH --output FILE [--overwrite]');
|
|
134
|
+
}
|
|
135
|
+
if (!path || typeof options['--output'] !== 'string')
|
|
136
|
+
throw new Error('Usage: forma remote pull PATH --output FILE [--overwrite]');
|
|
137
|
+
const file = resolve(options['--output']),
|
|
138
|
+
sidecar = file + '.forma-remote.json';
|
|
139
|
+
if (!options['--overwrite'])
|
|
140
|
+
for (const target of [file, sidecar]) {
|
|
141
|
+
try {
|
|
142
|
+
await lstat(target);
|
|
143
|
+
throw new Error(
|
|
144
|
+
'Local file or remote binding exists. Use a new output path, or --overwrite after preserving local edits.',
|
|
145
|
+
);
|
|
146
|
+
} catch (e) {
|
|
147
|
+
if ((e as NodeJS.ErrnoException).code !== 'ENOENT') throw e;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
const value = await service.read(path),
|
|
151
|
+
doc = parseDocument(value.document);
|
|
152
|
+
const binding = bindingSchema.parse({
|
|
153
|
+
version: 1,
|
|
154
|
+
server: origin,
|
|
155
|
+
owner: value.owner,
|
|
156
|
+
path,
|
|
157
|
+
revision: value.revision,
|
|
158
|
+
});
|
|
159
|
+
if (options['--overwrite']) await atomic(file, serializeDocument(doc));
|
|
160
|
+
else await writeFile(file, serializeDocument(doc), { flag: 'wx', mode: 0o600 });
|
|
161
|
+
await atomic(sidecar, JSON.stringify(binding, null, 2) + '\n');
|
|
162
|
+
return { ok: true, command: 'remote pull', output: file, path, revision: binding.revision };
|
|
163
|
+
}
|
|
164
|
+
if (operation === 'push') {
|
|
165
|
+
const source = args.shift();
|
|
166
|
+
let create = false,
|
|
167
|
+
path: string | undefined;
|
|
168
|
+
while (args.length) {
|
|
169
|
+
const key = args.shift();
|
|
170
|
+
if (key === '--create') create = true;
|
|
171
|
+
else if (key === '--path' && args.length) path = args.shift();
|
|
172
|
+
else throw new Error('Usage: forma remote push FILE [--create --path PATH]');
|
|
173
|
+
}
|
|
174
|
+
if (!source) throw new Error('Usage: forma remote push FILE [--create --path PATH]');
|
|
175
|
+
const file = resolve(source),
|
|
176
|
+
sidecar = file + '.forma-remote.json',
|
|
177
|
+
doc = parseDocument(JSON.parse(await readFile(file, 'utf8'))),
|
|
178
|
+
identity = await service.me();
|
|
179
|
+
let revision: string | null = null;
|
|
180
|
+
if (!create) {
|
|
181
|
+
const binding = bindingSchema.parse(JSON.parse(await readFile(sidecar, 'utf8')));
|
|
182
|
+
if (
|
|
183
|
+
binding.server !== origin ||
|
|
184
|
+
binding.owner !== identity.owner ||
|
|
185
|
+
(path && path !== binding.path)
|
|
186
|
+
)
|
|
187
|
+
throw new Error(
|
|
188
|
+
'This file is bound to another server, user or path. Pull the correct document first.',
|
|
189
|
+
);
|
|
190
|
+
path = binding.path;
|
|
191
|
+
revision = binding.revision;
|
|
192
|
+
}
|
|
193
|
+
if (!path) throw new Error('A new file requires --create --path FOLDER/NAME.forma.json.');
|
|
194
|
+
const saved = await service.write({ path, revision, document: doc });
|
|
195
|
+
try {
|
|
196
|
+
await atomic(
|
|
197
|
+
sidecar,
|
|
198
|
+
JSON.stringify(
|
|
199
|
+
bindingSchema.parse({
|
|
200
|
+
version: 1,
|
|
201
|
+
server: origin,
|
|
202
|
+
owner: identity.owner,
|
|
203
|
+
path,
|
|
204
|
+
revision: saved.revision,
|
|
205
|
+
}),
|
|
206
|
+
null,
|
|
207
|
+
2,
|
|
208
|
+
) + '\n',
|
|
209
|
+
);
|
|
210
|
+
} catch {
|
|
211
|
+
throw new Error(
|
|
212
|
+
'The server saved the diagram, but the local revision binding could not be updated. Pull into a new file before another push.',
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
return { ok: true, command: 'remote push', path, revision: saved.revision };
|
|
216
|
+
}
|
|
217
|
+
throw new Error(
|
|
218
|
+
'Usage: forma remote whoami|list|pull PATH --output FILE|push FILE [--create --path PATH]',
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export async function serveMcpStdio() {
|
|
223
|
+
const { service } = await remoteClient();
|
|
224
|
+
const { createMcpAdapter } = await import('./mcp'),
|
|
225
|
+
{ StdioServerTransport } = await import('@modelcontextprotocol/sdk/server/stdio.js');
|
|
226
|
+
// The remote HTTP API still authenticates every operation; no privileges live in the bridge.
|
|
227
|
+
await createMcpAdapter(service).connect(new StdioServerTransport());
|
|
228
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { createServer } from 'node:http';
|
|
2
|
+
import { isAbsolute } from 'node:path';
|
|
3
|
+
import { createFileLibrary, HttpError } from './file-library';
|
|
4
|
+
import { json, readBody, serveAsset } from './http';
|
|
5
|
+
/** Optional loopback adapter. Hosted mode uses a separate authenticated server. */
|
|
6
|
+
export async function createLocalServer(options: {
|
|
7
|
+
directory: string;
|
|
8
|
+
assets: string;
|
|
9
|
+
port?: number;
|
|
10
|
+
}) {
|
|
11
|
+
let library = await createFileLibrary(options.directory),
|
|
12
|
+
mutations = Promise.resolve();
|
|
13
|
+
const server = createServer(async (req, res) => {
|
|
14
|
+
try {
|
|
15
|
+
const port = (server.address() as { port: number }).port;
|
|
16
|
+
if (![`127.0.0.1:${port}`, `localhost:${port}`].includes(req.headers.host ?? ''))
|
|
17
|
+
throw new HttpError(403, 'Unrecognized local host.');
|
|
18
|
+
const origin = `http://${req.headers.host}`;
|
|
19
|
+
if (req.headers.origin && req.headers.origin !== origin)
|
|
20
|
+
throw new HttpError(403, 'Cross-origin access is not allowed.');
|
|
21
|
+
const url = new URL(req.url ?? '/', origin);
|
|
22
|
+
if (url.pathname.startsWith('/api/')) {
|
|
23
|
+
if (req.method === 'GET' && url.pathname === '/api/library') {
|
|
24
|
+
json(res, 200, await library.list());
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
if (req.method === 'GET' && url.pathname === '/api/document') {
|
|
28
|
+
json(
|
|
29
|
+
res,
|
|
30
|
+
200,
|
|
31
|
+
await library.read(
|
|
32
|
+
url.searchParams.get('path') ?? '',
|
|
33
|
+
url.searchParams.get('workspace') ?? '',
|
|
34
|
+
),
|
|
35
|
+
);
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
if (req.method !== 'POST') throw new HttpError(405, 'Unsupported library request.');
|
|
39
|
+
const data = await readBody(req);
|
|
40
|
+
const operation = async () => {
|
|
41
|
+
if (url.pathname === '/api/library') {
|
|
42
|
+
if (typeof data.directory !== 'string' || !isAbsolute(data.directory))
|
|
43
|
+
throw new HttpError(400, 'Enter an absolute folder path.');
|
|
44
|
+
library = await createFileLibrary(data.directory);
|
|
45
|
+
return library.list();
|
|
46
|
+
}
|
|
47
|
+
if (url.pathname === '/api/document') return library.save(data);
|
|
48
|
+
throw new HttpError(404, 'Unknown library operation.');
|
|
49
|
+
};
|
|
50
|
+
const result = mutations.then(operation);
|
|
51
|
+
mutations = result.then(
|
|
52
|
+
() => {},
|
|
53
|
+
() => {},
|
|
54
|
+
);
|
|
55
|
+
json(res, 200, await result);
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
await serveAsset(req, res, url, options.assets, 'local');
|
|
59
|
+
} catch (error) {
|
|
60
|
+
const e = error as NodeJS.ErrnoException & { status?: number };
|
|
61
|
+
json(res, e.status ?? (e.code === 'ENOENT' ? 404 : 400), { error: e.message });
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
await new Promise<void>((resolve, reject) => {
|
|
65
|
+
server.once('error', reject);
|
|
66
|
+
server.listen(options.port ?? 4242, '127.0.0.1', resolve);
|
|
67
|
+
});
|
|
68
|
+
return server;
|
|
69
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { createHmac, timingSafeEqual } from 'node:crypto';
|
|
2
|
+
import { z, type ZodType } from 'zod';
|
|
3
|
+
|
|
4
|
+
export function signCookie(secret: string, payload: unknown) {
|
|
5
|
+
const body = Buffer.from(JSON.stringify(payload)).toString('base64url');
|
|
6
|
+
const mac = createHmac('sha256', secret).update(body).digest('base64url');
|
|
7
|
+
return `${body}.${mac}`;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function verifyCookie<T>(secret: string, value: string | undefined, schema: ZodType<T>) {
|
|
11
|
+
if (!value) return null;
|
|
12
|
+
const split = value.lastIndexOf('.');
|
|
13
|
+
if (split < 1) return null;
|
|
14
|
+
const body = value.slice(0, split),
|
|
15
|
+
mac = value.slice(split + 1);
|
|
16
|
+
const expected = createHmac('sha256', secret).update(body).digest('base64url');
|
|
17
|
+
if (expected.length !== mac.length || !timingSafeEqual(Buffer.from(expected), Buffer.from(mac)))
|
|
18
|
+
return null;
|
|
19
|
+
try {
|
|
20
|
+
return schema.parse(JSON.parse(Buffer.from(body, 'base64url').toString('utf8')));
|
|
21
|
+
} catch {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export const sessionCookieSchema = z.object({
|
|
27
|
+
identity: z.object({
|
|
28
|
+
sub: z.string(),
|
|
29
|
+
email: z.string().optional(),
|
|
30
|
+
email_verified: z.boolean().optional(),
|
|
31
|
+
name: z.string().optional(),
|
|
32
|
+
hd: z.string().optional(),
|
|
33
|
+
}),
|
|
34
|
+
expires: z.number(),
|
|
35
|
+
});
|
|
36
|
+
export const loginCookieSchema = z.object({
|
|
37
|
+
state: z.string(),
|
|
38
|
+
verifier: z.string(),
|
|
39
|
+
nonce: z.string(),
|
|
40
|
+
challenge: z.string(),
|
|
41
|
+
expires: z.number(),
|
|
42
|
+
});
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import type { Point } from './document';
|
|
2
|
+
import type { Box, SceneNode } from './scene';
|
|
3
|
+
import { overlaps } from './geometry';
|
|
4
|
+
|
|
5
|
+
export const alignEdges = ['left', 'center', 'right', 'top', 'middle', 'bottom'] as const;
|
|
6
|
+
export type AlignEdge = (typeof alignEdges)[number];
|
|
7
|
+
export const distributeAxes = ['horizontal', 'vertical'] as const;
|
|
8
|
+
export type DistributeAxis = (typeof distributeAxes)[number];
|
|
9
|
+
export const NEAR_ALIGN_PX = 8;
|
|
10
|
+
|
|
11
|
+
const round = (value: number) => Math.round(value * 100) / 100;
|
|
12
|
+
const horizontal = (edge: AlignEdge) => edge === 'left' || edge === 'center' || edge === 'right';
|
|
13
|
+
|
|
14
|
+
export function edgeValue(box: Box, edge: AlignEdge) {
|
|
15
|
+
if (edge === 'left') return box.x;
|
|
16
|
+
if (edge === 'center') return box.x + box.width / 2;
|
|
17
|
+
if (edge === 'right') return box.x + box.width;
|
|
18
|
+
if (edge === 'top') return box.y;
|
|
19
|
+
if (edge === 'middle') return box.y + box.height / 2;
|
|
20
|
+
return box.y + box.height;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function placeBox(box: Box, edge: AlignEdge, value: number): Point {
|
|
24
|
+
if (edge === 'left') return { x: round(value), y: round(box.y) };
|
|
25
|
+
if (edge === 'center') return { x: round(value - box.width / 2), y: round(box.y) };
|
|
26
|
+
if (edge === 'right') return { x: round(value - box.width), y: round(box.y) };
|
|
27
|
+
if (edge === 'top') return { x: round(box.x), y: round(value) };
|
|
28
|
+
if (edge === 'middle') return { x: round(box.x), y: round(value - box.height / 2) };
|
|
29
|
+
return { x: round(box.x), y: round(value - box.height) };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function rangesOverlap(a0: number, a1: number, b0: number, b1: number) {
|
|
33
|
+
return a0 < b1 - 0.5 && b0 < a1 - 0.5;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function columnPair(a: Box, b: Box) {
|
|
37
|
+
const yOverlap = rangesOverlap(a.y, a.y + a.height, b.y, b.y + b.height);
|
|
38
|
+
const xOverlap = rangesOverlap(a.x, a.x + a.width, b.x, b.x + b.width);
|
|
39
|
+
const centerGap = Math.abs(a.x + a.width / 2 - (b.x + b.width / 2));
|
|
40
|
+
return !yOverlap && (xOverlap || centerGap < Math.max(a.width, b.width));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function rowPair(a: Box, b: Box) {
|
|
44
|
+
const yOverlap = rangesOverlap(a.y, a.y + a.height, b.y, b.y + b.height);
|
|
45
|
+
const xOverlap = rangesOverlap(a.x, a.x + a.width, b.x, b.x + b.width);
|
|
46
|
+
const centerGap = Math.abs(a.y + a.height / 2 - (b.y + b.height / 2));
|
|
47
|
+
return !xOverlap && (yOverlap || centerGap < Math.max(a.height, b.height));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function median(values: number[]) {
|
|
51
|
+
const sorted = [...values].sort((a, b) => a - b);
|
|
52
|
+
const mid = Math.floor(sorted.length / 2);
|
|
53
|
+
return sorted.length % 2 ? sorted[mid]! : (sorted[mid - 1]! + sorted[mid]!) / 2;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function alignmentTarget(boxes: Box[], edge: AlignEdge) {
|
|
57
|
+
if (edge === 'left') return Math.min(...boxes.map((b) => b.x));
|
|
58
|
+
if (edge === 'right') return Math.max(...boxes.map((b) => b.x + b.width));
|
|
59
|
+
if (edge === 'top') return Math.min(...boxes.map((b) => b.y));
|
|
60
|
+
if (edge === 'bottom') return Math.max(...boxes.map((b) => b.y + b.height));
|
|
61
|
+
if (edge === 'center') {
|
|
62
|
+
const min = Math.min(...boxes.map((b) => b.x));
|
|
63
|
+
const max = Math.max(...boxes.map((b) => b.x + b.width));
|
|
64
|
+
return (min + max) / 2;
|
|
65
|
+
}
|
|
66
|
+
const min = Math.min(...boxes.map((b) => b.y));
|
|
67
|
+
const max = Math.max(...boxes.map((b) => b.y + b.height));
|
|
68
|
+
return (min + max) / 2;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export interface AlignmentCluster {
|
|
72
|
+
edge: AlignEdge;
|
|
73
|
+
ids: string[];
|
|
74
|
+
target: number;
|
|
75
|
+
delta: number;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function nearAlignmentClusters(nodes: SceneNode[]): AlignmentCluster[] {
|
|
79
|
+
const found: AlignmentCluster[] = [];
|
|
80
|
+
for (const edge of alignEdges) {
|
|
81
|
+
const parent = nodes.map((_, i) => i);
|
|
82
|
+
const find = (i: number): number => (parent[i] === i ? i : (parent[i] = find(parent[i]!)));
|
|
83
|
+
const unite = (i: number, j: number) => {
|
|
84
|
+
i = find(i);
|
|
85
|
+
j = find(j);
|
|
86
|
+
if (i !== j) parent[i] = j;
|
|
87
|
+
};
|
|
88
|
+
const related = (a: SceneNode, b: SceneNode) =>
|
|
89
|
+
!overlaps(a, b) && (horizontal(edge) ? columnPair(a, b) : rowPair(a, b));
|
|
90
|
+
for (let i = 0; i < nodes.length; i++)
|
|
91
|
+
for (let j = i + 1; j < nodes.length; j++) {
|
|
92
|
+
const gap = Math.abs(edgeValue(nodes[i]!, edge) - edgeValue(nodes[j]!, edge));
|
|
93
|
+
if (gap >= 1 && gap <= NEAR_ALIGN_PX && related(nodes[i]!, nodes[j]!)) unite(i, j);
|
|
94
|
+
}
|
|
95
|
+
const groups = new Map<number, SceneNode[]>();
|
|
96
|
+
for (const [i, node] of nodes.entries()) {
|
|
97
|
+
const root = find(i);
|
|
98
|
+
groups.set(root, [...(groups.get(root) ?? []), node]);
|
|
99
|
+
}
|
|
100
|
+
for (const members of groups.values()) {
|
|
101
|
+
if (members.length < 2) continue;
|
|
102
|
+
const values = members.map((n) => edgeValue(n, edge));
|
|
103
|
+
const delta = round(Math.max(...values) - Math.min(...values));
|
|
104
|
+
if (delta < 1 || delta > NEAR_ALIGN_PX) continue;
|
|
105
|
+
const sizes = members.map((n) => (horizontal(edge) ? n.width : n.height));
|
|
106
|
+
const centers = members.map((n) =>
|
|
107
|
+
horizontal(edge) ? n.x + n.width / 2 : n.y + n.height / 2,
|
|
108
|
+
);
|
|
109
|
+
const centerSpan = Math.max(...centers) - Math.min(...centers);
|
|
110
|
+
const sizeDiff = Math.max(...sizes) - Math.min(...sizes);
|
|
111
|
+
const allowed =
|
|
112
|
+
edge === 'top' || edge === 'bottom'
|
|
113
|
+
? NEAR_ALIGN_PX
|
|
114
|
+
: edge === 'left' || edge === 'right'
|
|
115
|
+
? NEAR_ALIGN_PX + sizeDiff / 2
|
|
116
|
+
: Infinity;
|
|
117
|
+
if (centerSpan > allowed + 0.01) continue;
|
|
118
|
+
const pinned = members.filter((n) => n.pinned);
|
|
119
|
+
if (!pinned.length) continue;
|
|
120
|
+
found.push({
|
|
121
|
+
edge,
|
|
122
|
+
ids: members.map((n) => n.id),
|
|
123
|
+
target: round(
|
|
124
|
+
pinned.length ? median(pinned.map((n) => edgeValue(n, edge))) : median(values),
|
|
125
|
+
),
|
|
126
|
+
delta,
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return found;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const edgePreference = (edge: AlignEdge) =>
|
|
134
|
+
edge === 'center' || edge === 'middle' ? 2 : edge === 'left' || edge === 'top' ? 1 : 0;
|
|
135
|
+
|
|
136
|
+
export function selectedAlignmentClusters(nodes: SceneNode[]) {
|
|
137
|
+
const assignedX = new Set<string>(),
|
|
138
|
+
assignedY = new Set<string>(),
|
|
139
|
+
chosen: AlignmentCluster[] = [];
|
|
140
|
+
const ranked = [...nearAlignmentClusters(nodes)].sort(
|
|
141
|
+
(a, b) =>
|
|
142
|
+
b.ids.length - a.ids.length ||
|
|
143
|
+
a.delta - b.delta ||
|
|
144
|
+
edgePreference(b.edge) - edgePreference(a.edge),
|
|
145
|
+
);
|
|
146
|
+
for (const cluster of ranked) {
|
|
147
|
+
const xAxis = horizontal(cluster.edge);
|
|
148
|
+
const used = xAxis ? assignedX : assignedY;
|
|
149
|
+
const ids = cluster.ids.filter((id) => !used.has(id));
|
|
150
|
+
if (ids.length < 2) continue;
|
|
151
|
+
chosen.push({ ...cluster, ids });
|
|
152
|
+
for (const id of ids) used.add(id);
|
|
153
|
+
}
|
|
154
|
+
return chosen;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export function alignNodes(nodes: SceneNode[], ids: string[], edge: AlignEdge) {
|
|
158
|
+
const boxes = ids.map((id) => {
|
|
159
|
+
const node = nodes.find((n) => n.id === id);
|
|
160
|
+
if (!node) throw new Error(`Unknown node: ${id}`);
|
|
161
|
+
return node;
|
|
162
|
+
});
|
|
163
|
+
if (boxes.length < 2) throw new Error('Alignment needs at least two components.');
|
|
164
|
+
const target = round(alignmentTarget(boxes, edge));
|
|
165
|
+
return boxes.map((node) => ({ id: node.id, position: placeBox(node, edge, target) }));
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export function distributeNodes(nodes: SceneNode[], ids: string[], axis: DistributeAxis) {
|
|
169
|
+
const boxes = ids.map((id) => {
|
|
170
|
+
const node = nodes.find((n) => n.id === id);
|
|
171
|
+
if (!node) throw new Error(`Unknown node: ${id}`);
|
|
172
|
+
return node;
|
|
173
|
+
});
|
|
174
|
+
if (boxes.length < 3) throw new Error('Distribution needs at least three components.');
|
|
175
|
+
const vertical = axis === 'vertical';
|
|
176
|
+
const sorted = [...boxes].sort((a, b) => (vertical ? a.y - b.y : a.x - b.x));
|
|
177
|
+
const start = vertical ? sorted[0]!.y : sorted[0]!.x;
|
|
178
|
+
const end = vertical
|
|
179
|
+
? sorted.at(-1)!.y + sorted.at(-1)!.height
|
|
180
|
+
: sorted.at(-1)!.x + sorted.at(-1)!.width;
|
|
181
|
+
const total = sorted.reduce((sum, n) => sum + (vertical ? n.height : n.width), 0);
|
|
182
|
+
const gap = (end - start - total) / (sorted.length - 1);
|
|
183
|
+
let cursor = start;
|
|
184
|
+
return sorted.map((node) => {
|
|
185
|
+
const position = vertical
|
|
186
|
+
? { x: round(node.x), y: round(cursor) }
|
|
187
|
+
: { x: round(cursor), y: round(node.y) };
|
|
188
|
+
cursor += (vertical ? node.height : node.width) + gap;
|
|
189
|
+
return { id: node.id, position };
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export function snapUnpinned(nodes: SceneNode[]) {
|
|
194
|
+
for (const cluster of selectedAlignmentClusters(nodes)) {
|
|
195
|
+
for (const id of cluster.ids) {
|
|
196
|
+
const node = nodes.find((n) => n.id === id);
|
|
197
|
+
if (!node || node.pinned) continue;
|
|
198
|
+
Object.assign(node, placeBox(node, cluster.edge, cluster.target));
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export function mergeAlignmentPins(
|
|
204
|
+
nodes: SceneNode[],
|
|
205
|
+
operations: { ids: string[]; edge: AlignEdge }[],
|
|
206
|
+
) {
|
|
207
|
+
const positions = new Map(nodes.map((n) => [n.id, { x: n.x, y: n.y }]));
|
|
208
|
+
for (const operation of operations) {
|
|
209
|
+
for (const move of alignNodes(nodes, operation.ids, operation.edge)) {
|
|
210
|
+
const current = positions.get(move.id)!;
|
|
211
|
+
const xAxis = horizontal(operation.edge);
|
|
212
|
+
positions.set(move.id, {
|
|
213
|
+
x: xAxis ? move.position.x : current.x,
|
|
214
|
+
y: xAxis ? current.y : move.position.y,
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
return [...positions]
|
|
219
|
+
.filter(([id, position]) => {
|
|
220
|
+
const node = nodes.find((n) => n.id === id)!;
|
|
221
|
+
return node.x !== position.x || node.y !== position.y;
|
|
222
|
+
})
|
|
223
|
+
.map(([id, position]) => ({ id, position }));
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export function nudgeNodes(
|
|
227
|
+
nodes: SceneNode[],
|
|
228
|
+
ids: string[],
|
|
229
|
+
delta: Point,
|
|
230
|
+
): { id: string; position: Point }[] {
|
|
231
|
+
return ids.flatMap((id) => {
|
|
232
|
+
const node = nodes.find((n) => n.id === id);
|
|
233
|
+
return node
|
|
234
|
+
? [{ id, position: { x: round(node.x + delta.x), y: round(node.y + delta.y) } }]
|
|
235
|
+
: [];
|
|
236
|
+
});
|
|
237
|
+
}
|