@1agh/maude 0.29.0 → 0.31.0
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/README.md +6 -6
- package/apps/studio/acp/bridge.ts +285 -0
- package/apps/studio/acp/env.ts +48 -0
- package/apps/studio/acp/index.ts +132 -0
- package/apps/studio/acp/probe.ts +112 -0
- package/apps/studio/acp/transcript.ts +149 -0
- package/apps/studio/ai-banner.tsx +0 -1
- package/apps/studio/annotations-align.ts +149 -0
- package/apps/studio/annotations-bindings.ts +197 -0
- package/apps/studio/annotations-context-toolbar.tsx +436 -186
- package/apps/studio/annotations-groups.ts +270 -0
- package/apps/studio/annotations-layer.tsx +1714 -1661
- package/apps/studio/annotations-model.ts +2077 -0
- package/apps/studio/annotations-snap.ts +125 -0
- package/apps/studio/api.ts +433 -182
- package/apps/studio/bin/_png-playwright.mjs +1 -1
- package/apps/studio/bin/annotate.mjs +732 -0
- package/apps/studio/bin/annotate.sh +17 -0
- package/apps/studio/bin/chat-open.sh +44 -0
- package/apps/studio/bin/read-annotations.mjs +152 -17
- package/apps/studio/build.ts +1 -1
- package/apps/studio/canvas-arrowheads.ts +78 -9
- package/apps/studio/canvas-cursors.ts +2 -0
- package/apps/studio/canvas-edit.ts +257 -7
- package/apps/studio/canvas-icons.tsx +105 -0
- package/apps/studio/canvas-lib.tsx +112 -19
- package/apps/studio/canvas-list-watch.ts +177 -0
- package/apps/studio/canvas-shell.tsx +326 -9
- package/apps/studio/client/app.jsx +3579 -250
- package/apps/studio/client/canvas-url.js +5 -0
- package/apps/studio/client/github.js +99 -0
- package/apps/studio/client/index.html +1 -1
- package/apps/studio/client/panels/ChatPanel.jsx +770 -0
- package/apps/studio/client/panels/CollabModelInfographic.jsx +71 -0
- package/apps/studio/client/panels/CreateProject.jsx +334 -0
- package/apps/studio/client/panels/DiffView.jsx +590 -0
- package/apps/studio/client/panels/GitPanel.jsx +767 -0
- package/apps/studio/client/panels/IdentityBar.jsx +294 -0
- package/apps/studio/client/panels/OnboardingWizard.jsx +563 -0
- package/apps/studio/client/panels/RepoBranchSwitcher.jsx +349 -0
- package/apps/studio/client/panels/acp-runtime.js +286 -0
- package/apps/studio/client/panels/chat-markdown.jsx +138 -0
- package/apps/studio/client/panels/git-grouping.js +86 -0
- package/apps/studio/client/styles/0-reset.css +4 -0
- package/apps/studio/client/styles/3-shell-maude.css +1326 -14
- package/apps/studio/client/styles/4-components.css +39 -0
- package/apps/studio/client/styles/5-maude-overrides.css +41 -2
- package/apps/studio/client/styles/6-acp-chat.css +771 -0
- package/apps/studio/client/styles/_index.css +2 -0
- package/apps/studio/client/tour/collab-tour.js +61 -0
- package/apps/studio/client/tour/overlay.jsx +116 -18
- package/apps/studio/client/tour/usage-tour.js +23 -10
- package/apps/studio/client/whats-new.jsx +25 -10
- package/apps/studio/collab/registry.ts +13 -0
- package/apps/studio/collab/room.ts +36 -0
- package/apps/studio/commands/annotation-strokes-command.ts +1 -1
- package/apps/studio/commands/move-artboards-command.ts +1 -1
- package/apps/studio/comments-overlay.tsx +7 -5
- package/apps/studio/context-menu.tsx +4 -3
- package/apps/studio/contextual-toolbar.tsx +14 -0
- package/apps/studio/cursors-overlay.tsx +37 -21
- package/apps/studio/dist/client.bundle.js +31795 -1829
- package/apps/studio/dist/comment-mount.js +97 -9
- package/apps/studio/dist/styles.css +7938 -1599
- package/apps/studio/dom-selection.ts +115 -0
- package/apps/studio/export-dialog.tsx +3 -3
- package/apps/studio/git/endpoints.ts +338 -0
- package/apps/studio/git/service.ts +1334 -0
- package/apps/studio/git/watch.ts +97 -0
- package/apps/studio/github/endpoints.ts +358 -0
- package/apps/studio/github/service.ts +231 -0
- package/apps/studio/github/token.ts +53 -0
- package/apps/studio/history.ts +20 -3
- package/apps/studio/hmr-broadcast.ts +9 -2
- package/apps/studio/http.ts +508 -6
- package/apps/studio/input-router.tsx +7 -0
- package/apps/studio/inspect.ts +37 -8
- package/apps/studio/participants-chrome.tsx +70 -16
- package/apps/studio/paths.ts +12 -0
- package/apps/studio/scaffold-design.ts +57 -0
- package/apps/studio/server.ts +70 -4
- package/apps/studio/sync/agent.ts +311 -57
- package/apps/studio/sync/codec.ts +69 -0
- package/apps/studio/sync/cold-start.ts +198 -0
- package/apps/studio/sync/connection-state.ts +58 -2
- package/apps/studio/sync/hub-link.ts +137 -0
- package/apps/studio/sync/index.ts +563 -221
- package/apps/studio/sync/journal.ts +190 -0
- package/apps/studio/sync/migrate-seed.ts +202 -44
- package/apps/studio/sync/projection.ts +22 -3
- package/apps/studio/sync/status.ts +15 -4
- package/apps/studio/test/acp-bridge.test.ts +127 -0
- package/apps/studio/test/acp-env.test.ts +65 -0
- package/apps/studio/test/acp-origin-gate.test.ts +78 -0
- package/apps/studio/test/acp-transcript.test.ts +112 -0
- package/apps/studio/test/activity.test.ts +1 -7
- package/apps/studio/test/annotate-write.test.ts +184 -0
- package/apps/studio/test/annotations-align.test.ts +88 -0
- package/apps/studio/test/annotations-bindings.test.ts +124 -0
- package/apps/studio/test/annotations-groups.test.ts +231 -0
- package/apps/studio/test/annotations-snap.test.ts +79 -0
- package/apps/studio/test/canvas-create-api.test.ts +72 -0
- package/apps/studio/test/canvas-edit.test.ts +181 -1
- package/apps/studio/test/canvas-list-watch.test.ts +322 -0
- package/apps/studio/test/canvas-meta-api.test.ts +161 -27
- package/apps/studio/test/canvas-origin-gate.test.ts +43 -0
- package/apps/studio/test/chat-markdown.test.tsx +58 -0
- package/apps/studio/test/collab-bridge.test.ts +0 -2
- package/apps/studio/test/collab-room.test.ts +2 -7
- package/apps/studio/test/collab-session-survival.test.tsx +176 -0
- package/apps/studio/test/csp-canvas-shell.test.ts +5 -0
- package/apps/studio/test/csrf-write-guard.test.ts +78 -0
- package/apps/studio/test/editing-presence.test.ts +103 -0
- package/apps/studio/test/figjam-v3-model.test.ts +342 -0
- package/apps/studio/test/fixtures/figjam-v3-groups-bindings.svg +1 -0
- package/apps/studio/test/fixtures/mock-acp-agent.mjs +45 -0
- package/apps/studio/test/git-api.test.ts +0 -0
- package/apps/studio/test/git-branches.test.ts +106 -0
- package/apps/studio/test/git-grouping.test.ts +106 -0
- package/apps/studio/test/git-watch.test.ts +97 -0
- package/apps/studio/test/github-api.test.ts +465 -0
- package/apps/studio/test/hub-link.test.ts +69 -0
- package/apps/studio/test/participants-chrome.test.ts +36 -1
- package/apps/studio/test/shared-doc-migrate.test.ts +224 -14
- package/apps/studio/test/sync-agent.test.ts +272 -2
- package/apps/studio/test/sync-codec.test.ts +65 -0
- package/apps/studio/test/sync-cold-seed-dedup.test.ts +187 -0
- package/apps/studio/test/sync-cold-start.test.ts +304 -0
- package/apps/studio/test/sync-connection-state.test.ts +68 -0
- package/apps/studio/test/sync-hardening.test.ts +0 -8
- package/apps/studio/test/sync-hubs-config.test.ts +0 -1
- package/apps/studio/test/sync-incident-replay.test.ts +211 -0
- package/apps/studio/test/sync-journal.test.ts +176 -0
- package/apps/studio/test/sync-runtime.test.ts +360 -5
- package/apps/studio/test/sync-status.test.ts +66 -0
- package/apps/studio/test/tour-overlay.test.tsx +18 -0
- package/apps/studio/test/use-annotation-resize.test.ts +159 -0
- package/apps/studio/test/use-artboard-drag.test.ts +0 -1
- package/apps/studio/test/use-collab.test.ts +0 -0
- package/apps/studio/test/use-tool-mode.test.tsx +5 -2
- package/apps/studio/tool-palette.tsx +30 -11
- package/apps/studio/undo-hud.tsx +0 -1
- package/apps/studio/use-agent-presence.tsx +4 -2
- package/apps/studio/use-annotation-resize.tsx +484 -54
- package/apps/studio/use-annotation-selection.tsx +16 -4
- package/apps/studio/use-canvas-activity.tsx +2 -2
- package/apps/studio/use-chrome-visibility.tsx +66 -0
- package/apps/studio/use-collab.tsx +434 -202
- package/apps/studio/use-selection-set.tsx +9 -0
- package/apps/studio/use-tool-mode.tsx +5 -1
- package/apps/studio/whats-new.json +208 -10
- package/apps/studio/whats-new.schema.json +18 -2
- package/apps/studio/ws.ts +44 -1
- package/cli/commands/design-link.test.mjs +84 -0
- package/cli/commands/design.mjs +3 -1
- package/cli/commands/design.test.mjs +5 -1
- package/cli/lib/design-link.mjs +51 -1
- package/cli/lib/gitignore-block.mjs +16 -3
- package/cli/lib/gitignore-block.test.mjs +13 -1
- package/package.json +11 -9
- package/plugins/design/dependencies.json +17 -0
- package/plugins/design/templates/_shell.html +58 -12
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
// Repo-level chat transcripts (`<designRoot>/_chat/<chatId>.jsonl`). The bridge
|
|
2
|
+
// appends raw per-update lines; these readers turn them into the chat list (for
|
|
3
|
+
// the switcher) and clean per-turn messages (for hydrating the thread on open).
|
|
4
|
+
|
|
5
|
+
import { existsSync, readdirSync, readFileSync, rmSync, statSync } from 'node:fs';
|
|
6
|
+
import { join } from 'node:path';
|
|
7
|
+
|
|
8
|
+
export interface ChatSummary {
|
|
9
|
+
id: string;
|
|
10
|
+
title: string;
|
|
11
|
+
updated: number; // mtime ms
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface ChatMessagePart {
|
|
15
|
+
type: 'text' | 'tool';
|
|
16
|
+
text?: string;
|
|
17
|
+
toolName?: string;
|
|
18
|
+
done?: boolean;
|
|
19
|
+
}
|
|
20
|
+
export interface ChatMessage {
|
|
21
|
+
role: 'user' | 'assistant';
|
|
22
|
+
parts: ChatMessagePart[];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function chatDir(designRoot: string): string {
|
|
26
|
+
return join(designRoot, '_chat');
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function readLines(file: string): Array<Record<string, unknown>> {
|
|
30
|
+
try {
|
|
31
|
+
return readFileSync(file, 'utf8')
|
|
32
|
+
.split('\n')
|
|
33
|
+
.filter(Boolean)
|
|
34
|
+
.map((l) => {
|
|
35
|
+
try {
|
|
36
|
+
return JSON.parse(l) as Record<string, unknown>;
|
|
37
|
+
} catch {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
})
|
|
41
|
+
.filter((x): x is Record<string, unknown> => x !== null);
|
|
42
|
+
} catch {
|
|
43
|
+
return [];
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** First user line, truncated — the chat's display title. */
|
|
48
|
+
function deriveTitle(lines: Array<Record<string, unknown>>): string {
|
|
49
|
+
const firstUser = lines.find((l) => l.role === 'user' && typeof l.text === 'string');
|
|
50
|
+
const text = (firstUser?.text as string) ?? '';
|
|
51
|
+
const trimmed = text.replace(/\s+/g, ' ').trim();
|
|
52
|
+
return trimmed ? trimmed.slice(0, 60) : 'New chat';
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** List chats newest-first. */
|
|
56
|
+
export function listChats(designRoot: string): ChatSummary[] {
|
|
57
|
+
const dir = chatDir(designRoot);
|
|
58
|
+
if (!existsSync(dir)) return [];
|
|
59
|
+
const out: ChatSummary[] = [];
|
|
60
|
+
for (const name of readdirSync(dir)) {
|
|
61
|
+
if (!name.endsWith('.jsonl')) continue;
|
|
62
|
+
const file = join(dir, name);
|
|
63
|
+
let updated = 0;
|
|
64
|
+
try {
|
|
65
|
+
updated = statSync(file).mtimeMs;
|
|
66
|
+
} catch {
|
|
67
|
+
/* skip unreadable */
|
|
68
|
+
}
|
|
69
|
+
const lines = readLines(file);
|
|
70
|
+
if (lines.length === 0) continue;
|
|
71
|
+
out.push({ id: name.replace(/\.jsonl$/, ''), title: deriveTitle(lines), updated });
|
|
72
|
+
}
|
|
73
|
+
return out.sort((a, b) => b.updated - a.updated);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Delete a chat's transcript. Returns true if a file was removed. */
|
|
77
|
+
export function deleteChat(designRoot: string, chatId: string): boolean {
|
|
78
|
+
const file = join(chatDir(designRoot), `${chatId}.jsonl`);
|
|
79
|
+
if (!existsSync(file)) return false;
|
|
80
|
+
try {
|
|
81
|
+
rmSync(file);
|
|
82
|
+
return true;
|
|
83
|
+
} catch {
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Convert the raw transcript into clean per-turn messages: user lines become
|
|
90
|
+
* user messages; the agent updates between them aggregate into one assistant
|
|
91
|
+
* message (text + tool parts; `available_commands_update` / `usage_update` /
|
|
92
|
+
* thoughts dropped — chrome noise, not the conversation).
|
|
93
|
+
*/
|
|
94
|
+
export function readChatMessages(designRoot: string, chatId: string): ChatMessage[] {
|
|
95
|
+
const file = join(chatDir(designRoot), `${chatId}.jsonl`);
|
|
96
|
+
if (!existsSync(file)) return [];
|
|
97
|
+
const lines = readLines(file);
|
|
98
|
+
const messages: ChatMessage[] = [];
|
|
99
|
+
let assistant: ChatMessage | null = null;
|
|
100
|
+
const toolIndex = new Map<string, number>();
|
|
101
|
+
|
|
102
|
+
const flush = () => {
|
|
103
|
+
if (assistant?.parts.length) messages.push(assistant);
|
|
104
|
+
assistant = null;
|
|
105
|
+
toolIndex.clear();
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
for (const line of lines) {
|
|
109
|
+
if (line.role === 'user' && typeof line.text === 'string') {
|
|
110
|
+
flush();
|
|
111
|
+
messages.push({ role: 'user', parts: [{ type: 'text', text: line.text }] });
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
if (line.role === 'stop') {
|
|
115
|
+
flush();
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
if (line.role !== 'agent') continue;
|
|
119
|
+
const update = line.update as Record<string, unknown> | undefined;
|
|
120
|
+
if (!update) continue;
|
|
121
|
+
if (!assistant) assistant = { role: 'assistant', parts: [] };
|
|
122
|
+
const kind = update.sessionUpdate;
|
|
123
|
+
if (kind === 'agent_message_chunk') {
|
|
124
|
+
const content = update.content as { type?: string; text?: string } | undefined;
|
|
125
|
+
if (content?.type !== 'text' || typeof content.text !== 'string') continue;
|
|
126
|
+
const last = assistant.parts[assistant.parts.length - 1];
|
|
127
|
+
if (last && last.type === 'text') last.text = (last.text ?? '') + content.text;
|
|
128
|
+
else assistant.parts.push({ type: 'text', text: content.text });
|
|
129
|
+
} else if (kind === 'tool_call') {
|
|
130
|
+
const id = String(update.toolCallId ?? '');
|
|
131
|
+
toolIndex.set(id, assistant.parts.length);
|
|
132
|
+
assistant.parts.push({
|
|
133
|
+
type: 'tool',
|
|
134
|
+
toolName: String(update.title ?? update.kind ?? 'tool'),
|
|
135
|
+
done: false,
|
|
136
|
+
});
|
|
137
|
+
} else if (kind === 'tool_call_update') {
|
|
138
|
+
const id = String(update.toolCallId ?? '');
|
|
139
|
+
const idx = toolIndex.get(id);
|
|
140
|
+
const status = update.status;
|
|
141
|
+
if (idx != null && (status === 'completed' || status === 'failed')) {
|
|
142
|
+
const part = assistant.parts[idx];
|
|
143
|
+
if (part) part.done = true;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
flush();
|
|
148
|
+
return messages;
|
|
149
|
+
}
|
|
@@ -183,7 +183,6 @@ export function AiBanner(): JSX.Element | null {
|
|
|
183
183
|
|
|
184
184
|
if (!entry) return null;
|
|
185
185
|
return (
|
|
186
|
-
// biome-ignore lint/a11y/useSemanticElements: <output> is a form-result element; role="status" on a div is the canonical live-region banner pattern (APG).
|
|
187
186
|
<div className="dc-ai-banner" role="status" aria-live="polite">
|
|
188
187
|
<span className="dc-ai-banner__dot" aria-hidden="true" />
|
|
189
188
|
<span>{entry.author} is editing this canvas — your changes may conflict</span>
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file annotations-align.ts — FigJam v3 align + distribute
|
|
3
|
+
* @scope apps/studio/annotations-align.ts
|
|
4
|
+
* @purpose Pure bbox math (no React, no DOM) for the multi-select Align
|
|
5
|
+
* cluster. Units follow FigJam: a whole group aligns as ONE box
|
|
6
|
+
* (its members translate together), an ungrouped stroke is its own
|
|
7
|
+
* unit. Align needs ≥ 2 units, distribute ≥ 3 (first/last pinned,
|
|
8
|
+
* gaps equalized — Figma "distribute spacing" semantics).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { expandIdsToGroups, outermostGroupOf } from './annotations-groups.ts';
|
|
12
|
+
import { type AnchorHost, type Stroke, strokeBBox, translateOne } from './annotations-model.ts';
|
|
13
|
+
|
|
14
|
+
export type AlignEdge = 'left' | 'h-center' | 'right' | 'top' | 'v-center' | 'bottom';
|
|
15
|
+
export type DistributeAxis = 'h' | 'v';
|
|
16
|
+
|
|
17
|
+
interface BBox {
|
|
18
|
+
x: number;
|
|
19
|
+
y: number;
|
|
20
|
+
w: number;
|
|
21
|
+
h: number;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface AlignUnit {
|
|
25
|
+
ids: Set<string>;
|
|
26
|
+
bbox: BBox;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function unionBox(a: BBox, b: BBox): BBox {
|
|
30
|
+
const x = Math.min(a.x, b.x);
|
|
31
|
+
const y = Math.min(a.y, b.y);
|
|
32
|
+
return {
|
|
33
|
+
x,
|
|
34
|
+
y,
|
|
35
|
+
w: Math.max(a.x + a.w, b.x + b.w) - x,
|
|
36
|
+
h: Math.max(a.y + a.h, b.y + b.h) - y,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Partition the (expanded) selection into alignment units. Anchored text is
|
|
42
|
+
* skipped (it has no independent bbox and rides its host via `translateOne`'s
|
|
43
|
+
* no-op); arrows participate — bound endpoints get re-pinned by the caller's
|
|
44
|
+
* `recomputeBoundArrows` pass after the translate.
|
|
45
|
+
*/
|
|
46
|
+
function unitsOf(strokes: readonly Stroke[], ids: readonly string[]): AlignUnit[] {
|
|
47
|
+
const anchors = new Map<string, AnchorHost>();
|
|
48
|
+
for (const s of strokes) {
|
|
49
|
+
if (s.tool === 'rect' || s.tool === 'ellipse' || s.tool === 'polygon') anchors.set(s.id, s);
|
|
50
|
+
}
|
|
51
|
+
const sel = new Set(expandIdsToGroups(ids, strokes));
|
|
52
|
+
const byGroup = new Map<string, AlignUnit>();
|
|
53
|
+
const units: AlignUnit[] = [];
|
|
54
|
+
for (const s of strokes) {
|
|
55
|
+
if (!sel.has(s.id)) continue;
|
|
56
|
+
if (s.tool === 'text' && s.anchorId) continue; // rides its host
|
|
57
|
+
const bb = strokeBBox(s, anchors);
|
|
58
|
+
if (!bb) continue;
|
|
59
|
+
const g = outermostGroupOf(s);
|
|
60
|
+
if (g != null) {
|
|
61
|
+
const existing = byGroup.get(g);
|
|
62
|
+
if (existing) {
|
|
63
|
+
existing.ids.add(s.id);
|
|
64
|
+
existing.bbox = unionBox(existing.bbox, bb);
|
|
65
|
+
} else {
|
|
66
|
+
const unit: AlignUnit = { ids: new Set([s.id]), bbox: bb };
|
|
67
|
+
byGroup.set(g, unit);
|
|
68
|
+
units.push(unit);
|
|
69
|
+
}
|
|
70
|
+
} else {
|
|
71
|
+
units.push({ ids: new Set([s.id]), bbox: bb });
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return units;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function applyDeltas(
|
|
78
|
+
strokes: readonly Stroke[],
|
|
79
|
+
deltas: Map<string, readonly [number, number]>
|
|
80
|
+
): Stroke[] {
|
|
81
|
+
if (deltas.size === 0) return strokes as Stroke[];
|
|
82
|
+
return strokes.map((s) => {
|
|
83
|
+
const d = deltas.get(s.id);
|
|
84
|
+
return d ? translateOne(s, d[0], d[1]) : s;
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Align every unit's edge/center to the selection's union bbox. */
|
|
89
|
+
export function alignStrokes(
|
|
90
|
+
strokes: readonly Stroke[],
|
|
91
|
+
ids: readonly string[],
|
|
92
|
+
edge: AlignEdge
|
|
93
|
+
): Stroke[] {
|
|
94
|
+
const units = unitsOf(strokes, ids);
|
|
95
|
+
if (units.length < 2) return strokes as Stroke[];
|
|
96
|
+
let sel = units[0]?.bbox;
|
|
97
|
+
if (!sel) return strokes as Stroke[];
|
|
98
|
+
for (let i = 1; i < units.length; i++) {
|
|
99
|
+
const u = units[i];
|
|
100
|
+
if (u) sel = unionBox(sel, u.bbox);
|
|
101
|
+
}
|
|
102
|
+
const deltas = new Map<string, readonly [number, number]>();
|
|
103
|
+
for (const u of units) {
|
|
104
|
+
let dx = 0;
|
|
105
|
+
let dy = 0;
|
|
106
|
+
if (edge === 'left') dx = sel.x - u.bbox.x;
|
|
107
|
+
else if (edge === 'h-center') dx = sel.x + sel.w / 2 - (u.bbox.x + u.bbox.w / 2);
|
|
108
|
+
else if (edge === 'right') dx = sel.x + sel.w - (u.bbox.x + u.bbox.w);
|
|
109
|
+
else if (edge === 'top') dy = sel.y - u.bbox.y;
|
|
110
|
+
else if (edge === 'v-center') dy = sel.y + sel.h / 2 - (u.bbox.y + u.bbox.h / 2);
|
|
111
|
+
else dy = sel.y + sel.h - (u.bbox.y + u.bbox.h);
|
|
112
|
+
if (dx !== 0 || dy !== 0) {
|
|
113
|
+
for (const id of u.ids) deltas.set(id, [dx, dy] as const);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return applyDeltas(strokes, deltas);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Equalize the gaps between units along one axis. First and last unit (by
|
|
121
|
+
* leading edge) stay pinned; everything between shifts so every gap is equal.
|
|
122
|
+
*/
|
|
123
|
+
export function distributeStrokes(
|
|
124
|
+
strokes: readonly Stroke[],
|
|
125
|
+
ids: readonly string[],
|
|
126
|
+
axis: DistributeAxis
|
|
127
|
+
): Stroke[] {
|
|
128
|
+
const units = unitsOf(strokes, ids);
|
|
129
|
+
if (units.length < 3) return strokes as Stroke[];
|
|
130
|
+
const lead = (u: AlignUnit): number => (axis === 'h' ? u.bbox.x : u.bbox.y);
|
|
131
|
+
const size = (u: AlignUnit): number => (axis === 'h' ? u.bbox.w : u.bbox.h);
|
|
132
|
+
const sorted = [...units].sort((a, b) => lead(a) - lead(b));
|
|
133
|
+
const first = sorted[0];
|
|
134
|
+
const last = sorted[sorted.length - 1];
|
|
135
|
+
if (!first || !last) return strokes as Stroke[];
|
|
136
|
+
const span = lead(last) + size(last) - lead(first);
|
|
137
|
+
const total = sorted.reduce((m, u) => m + size(u), 0);
|
|
138
|
+
const gap = (span - total) / (sorted.length - 1);
|
|
139
|
+
const deltas = new Map<string, readonly [number, number]>();
|
|
140
|
+
let cursor = lead(first);
|
|
141
|
+
for (const u of sorted) {
|
|
142
|
+
const d = cursor - lead(u);
|
|
143
|
+
if (d !== 0) {
|
|
144
|
+
for (const id of u.ids) deltas.set(id, axis === 'h' ? ([d, 0] as const) : ([0, d] as const));
|
|
145
|
+
}
|
|
146
|
+
cursor += size(u) + gap;
|
|
147
|
+
}
|
|
148
|
+
return applyDeltas(strokes, deltas);
|
|
149
|
+
}
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file annotations-bindings.ts — FigJam v3 connector binding
|
|
3
|
+
* @scope apps/studio/annotations-bindings.ts
|
|
4
|
+
* @purpose Pure helpers (no React, no DOM) for arrow ↔ shape magnetic
|
|
5
|
+
* binding. Semantics follow Excalidraw's binding system + FigJam's
|
|
6
|
+
* connector magnets: bind on proximity (~15 px at 100 % zoom,
|
|
7
|
+
* callers scale by 1/zoom), anchors snap to the {0, 0.5, 1} ×
|
|
8
|
+
* {0, 0.5, 1} magnet grid over the host bbox, bound endpoints are
|
|
9
|
+
* DERIVED from the host on every mutation (`recomputeBoundArrows`),
|
|
10
|
+
* and deleting a host strips the bind but keeps the arrow with the
|
|
11
|
+
* endpoint frozen in place (Excalidraw `fixBindingsAfterDeletion`).
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import {
|
|
15
|
+
type ArrowBind,
|
|
16
|
+
type ArrowStroke,
|
|
17
|
+
rotatePoint,
|
|
18
|
+
type Stroke,
|
|
19
|
+
strokeBBox,
|
|
20
|
+
strokeRotation,
|
|
21
|
+
} from './annotations-model.ts';
|
|
22
|
+
|
|
23
|
+
/** Hosts an arrow endpoint can attach to (shapes + cards, FigJam set). */
|
|
24
|
+
export function isBindable(s: Stroke): boolean {
|
|
25
|
+
return (
|
|
26
|
+
s.tool === 'rect' ||
|
|
27
|
+
s.tool === 'ellipse' ||
|
|
28
|
+
s.tool === 'polygon' ||
|
|
29
|
+
s.tool === 'sticky' ||
|
|
30
|
+
s.tool === 'image'
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Bind proximity threshold in world px at zoom 1 (Excalidraw's 15 px). */
|
|
35
|
+
export const BIND_THRESHOLD_PX = 15;
|
|
36
|
+
|
|
37
|
+
const MAGNETS = [0, 0.5, 1] as const;
|
|
38
|
+
|
|
39
|
+
function snapMagnet(v: number): number {
|
|
40
|
+
let best: number = MAGNETS[0];
|
|
41
|
+
let bestDist = Number.POSITIVE_INFINITY;
|
|
42
|
+
for (const m of MAGNETS) {
|
|
43
|
+
const d = Math.abs(v - m);
|
|
44
|
+
if (d < bestDist) {
|
|
45
|
+
bestDist = d;
|
|
46
|
+
best = m;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return best;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function clamp01(v: number): number {
|
|
53
|
+
return Math.max(0, Math.min(1, v));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Topmost bindable host within `threshold` of the world point, with the anchor
|
|
58
|
+
* snapped to the nearest side/center magnet. `excludeIds` keeps an arrow from
|
|
59
|
+
* binding to a host that is itself part of the current gesture.
|
|
60
|
+
*/
|
|
61
|
+
export function bindCandidate(
|
|
62
|
+
wx: number,
|
|
63
|
+
wy: number,
|
|
64
|
+
strokes: readonly Stroke[],
|
|
65
|
+
threshold: number,
|
|
66
|
+
excludeIds?: ReadonlySet<string>
|
|
67
|
+
): ArrowBind | null {
|
|
68
|
+
for (let i = strokes.length - 1; i >= 0; i--) {
|
|
69
|
+
const s = strokes[i];
|
|
70
|
+
if (!s || !isBindable(s) || excludeIds?.has(s.id)) continue;
|
|
71
|
+
const bb = strokeBBox(s);
|
|
72
|
+
if (!bb || bb.w <= 0 || bb.h <= 0) continue;
|
|
73
|
+
if (
|
|
74
|
+
wx < bb.x - threshold ||
|
|
75
|
+
wx > bb.x + bb.w + threshold ||
|
|
76
|
+
wy < bb.y - threshold ||
|
|
77
|
+
wy > bb.y + bb.h + threshold
|
|
78
|
+
) {
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
return {
|
|
82
|
+
hostId: s.id,
|
|
83
|
+
nx: snapMagnet(clamp01((wx - bb.x) / bb.w)),
|
|
84
|
+
ny: snapMagnet(clamp01((wy - bb.y) / bb.h)),
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** World position of a normalized anchor over the host's bbox. A rotated host
|
|
91
|
+
* carries its magnets with it (the anchor rotates around the bbox center). */
|
|
92
|
+
export function anchorPoint(host: Stroke, nx: number, ny: number): [number, number] | null {
|
|
93
|
+
const bb = strokeBBox(host);
|
|
94
|
+
if (!bb) return null;
|
|
95
|
+
const px = bb.x + nx * bb.w;
|
|
96
|
+
const py = bb.y + ny * bb.h;
|
|
97
|
+
const rot = strokeRotation(host);
|
|
98
|
+
if (rot === 0) return [px, py];
|
|
99
|
+
return rotatePoint(px, py, bb.x + bb.w / 2, bb.y + bb.h / 2, rot);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Re-derive every bound arrow endpoint from its host. Idempotent — an endpoint
|
|
104
|
+
* already at its anchor is left referentially untouched, and the ORIGINAL
|
|
105
|
+
* array is returned when nothing changed (so the drag-commit no-op checks keep
|
|
106
|
+
* working). A bind whose host no longer exists (or stopped being bindable) is
|
|
107
|
+
* stripped with the endpoint frozen in place — the arrow survives unbound.
|
|
108
|
+
*
|
|
109
|
+
* FigJam v3 auto-routing: an AUTO (non-pinned) bind re-picks the side magnet
|
|
110
|
+
* FACING the arrow's other end on every recompute, so a connector keeps a
|
|
111
|
+
* sensible direction as the shapes move around each other. A `pinned` bind
|
|
112
|
+
* (explicit user re-anchor) keeps its magnet.
|
|
113
|
+
*/
|
|
114
|
+
export function recomputeBoundArrows(strokes: readonly Stroke[]): Stroke[] {
|
|
115
|
+
const byId = new Map<string, Stroke>();
|
|
116
|
+
for (const s of strokes) byId.set(s.id, s);
|
|
117
|
+
let mutated = false;
|
|
118
|
+
const out = strokes.map((s) => {
|
|
119
|
+
if (s.tool !== 'arrow' || (!s.startBind && !s.endBind)) return s;
|
|
120
|
+
let next: ArrowStroke = s;
|
|
121
|
+
// Opposite-end reference points for the facing computation. When both
|
|
122
|
+
// ends are bound, face the OTHER HOST's center (stable under endpoint
|
|
123
|
+
// churn); a free end is its literal coordinate.
|
|
124
|
+
const startHost = s.startBind ? byId.get(s.startBind.hostId) : undefined;
|
|
125
|
+
const endHost = s.endBind ? byId.get(s.endBind.hostId) : undefined;
|
|
126
|
+
const centerOf = (host: Stroke | undefined): [number, number] | null => {
|
|
127
|
+
if (!host) return null;
|
|
128
|
+
const bb = strokeBBox(host);
|
|
129
|
+
return bb ? [bb.x + bb.w / 2, bb.y + bb.h / 2] : null;
|
|
130
|
+
};
|
|
131
|
+
const startRef: [number, number] = centerOf(endHost) ?? [s.x2, s.y2];
|
|
132
|
+
const endRef: [number, number] = centerOf(startHost) ?? [s.x1, s.y1];
|
|
133
|
+
const applyEnd = (
|
|
134
|
+
bind: ArrowBind | undefined,
|
|
135
|
+
end: 'start' | 'end',
|
|
136
|
+
ref: [number, number]
|
|
137
|
+
): void => {
|
|
138
|
+
if (!bind) return;
|
|
139
|
+
const host = byId.get(bind.hostId);
|
|
140
|
+
if (!host || !isBindable(host)) {
|
|
141
|
+
const copy: ArrowStroke = { ...next };
|
|
142
|
+
delete copy[end === 'start' ? 'startBind' : 'endBind'];
|
|
143
|
+
next = copy;
|
|
144
|
+
mutated = true;
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
let effective = bind;
|
|
148
|
+
if (!bind.pinned) {
|
|
149
|
+
const facing = facingAnchor(host, ref[0], ref[1]);
|
|
150
|
+
if (facing && (facing.nx !== bind.nx || facing.ny !== bind.ny)) {
|
|
151
|
+
effective = { ...bind, nx: facing.nx, ny: facing.ny };
|
|
152
|
+
next =
|
|
153
|
+
end === 'start' ? { ...next, startBind: effective } : { ...next, endBind: effective };
|
|
154
|
+
mutated = true;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
const pt = anchorPoint(host, effective.nx, effective.ny);
|
|
158
|
+
if (!pt) return;
|
|
159
|
+
if (end === 'start') {
|
|
160
|
+
if (next.x1 !== pt[0] || next.y1 !== pt[1]) {
|
|
161
|
+
next = { ...next, x1: pt[0], y1: pt[1] };
|
|
162
|
+
mutated = true;
|
|
163
|
+
}
|
|
164
|
+
} else if (next.x2 !== pt[0] || next.y2 !== pt[1]) {
|
|
165
|
+
next = { ...next, x2: pt[0], y2: pt[1] };
|
|
166
|
+
mutated = true;
|
|
167
|
+
}
|
|
168
|
+
};
|
|
169
|
+
applyEnd(s.startBind, 'start', startRef);
|
|
170
|
+
applyEnd(next.endBind, 'end', endRef);
|
|
171
|
+
return next;
|
|
172
|
+
});
|
|
173
|
+
return mutated ? out : (strokes as Stroke[]);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Side-magnet anchor on `host` facing the world point `(tx, ty)` — used by the
|
|
178
|
+
* AI annotate verb + quick-create to pick a sensible attachment side without a
|
|
179
|
+
* pointer position: the dominant axis of the direction vector decides the
|
|
180
|
+
* side, the cross axis centers.
|
|
181
|
+
*/
|
|
182
|
+
export function facingAnchor(host: Stroke, tx: number, ty: number): ArrowBind | null {
|
|
183
|
+
const bb = strokeBBox(host);
|
|
184
|
+
if (!bb || bb.w <= 0 || bb.h <= 0) return null;
|
|
185
|
+
const cx = bb.x + bb.w / 2;
|
|
186
|
+
const cy = bb.y + bb.h / 2;
|
|
187
|
+
const dx = tx - cx;
|
|
188
|
+
const dy = ty - cy;
|
|
189
|
+
// Normalize by the half-extents so a wide-but-short card still picks the
|
|
190
|
+
// side the target visually faces.
|
|
191
|
+
const rx = Math.abs(dx) / Math.max(1, bb.w / 2);
|
|
192
|
+
const ry = Math.abs(dy) / Math.max(1, bb.h / 2);
|
|
193
|
+
if (rx >= ry) {
|
|
194
|
+
return { hostId: host.id, nx: dx >= 0 ? 1 : 0, ny: 0.5 };
|
|
195
|
+
}
|
|
196
|
+
return { hostId: host.id, nx: 0.5, ny: dy >= 0 ? 1 : 0 };
|
|
197
|
+
}
|