@1agh/maude 1.2.0 → 1.3.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 +3 -1
- package/apps/studio/acp/index.ts +43 -7
- package/apps/studio/annotations-layer.tsx +191 -116
- package/apps/studio/annotations-model.ts +39 -0
- package/apps/studio/annotations-sync.ts +50 -0
- package/apps/studio/api.ts +689 -36
- package/apps/studio/bin/annotate.mjs +3 -1
- package/apps/studio/bin/server-up.sh +17 -2
- package/apps/studio/canvas-edit.ts +391 -7
- package/apps/studio/canvas-lib.tsx +82 -18
- package/apps/studio/canvas-notice-message.ts +16 -0
- package/apps/studio/canvas-notifications.tsx +19 -0
- package/apps/studio/canvas-shell.tsx +57 -6
- package/apps/studio/client/app.jsx +686 -241
- package/apps/studio/client/apply-edit-request.ts +55 -0
- package/apps/studio/client/export-center.jsx +111 -61
- package/apps/studio/client/github.js +38 -1
- package/apps/studio/client/index-loader.ts +76 -0
- package/apps/studio/client/panels/CloudBar.jsx +52 -6
- package/apps/studio/client/panels/GitPanel.jsx +103 -0
- package/apps/studio/client/panels/OnboardingWizard.jsx +36 -7
- package/apps/studio/client/panels/RepoBranchSwitcher.jsx +92 -27
- package/apps/studio/client/panels/SourceConflictPanel.jsx +166 -0
- package/apps/studio/client/panels/SyncPanel.jsx +113 -0
- package/apps/studio/client/panels/TeamProjects.jsx +376 -0
- package/apps/studio/client/panels/file-deep-link-dialog.jsx +54 -0
- package/apps/studio/client/photo-knobs.jsx +3 -0
- package/apps/studio/client/share-dialog.jsx +91 -0
- package/apps/studio/client/share-link.js +94 -0
- package/apps/studio/client/styles/3-shell-maude.css +35 -18
- package/apps/studio/client/styles/4-components.css +18 -1
- package/apps/studio/client/tree-row-menu.jsx +48 -3
- package/apps/studio/client/whats-new.jsx +26 -32
- package/apps/studio/cloud/endpoints.ts +132 -0
- package/apps/studio/collab/index.ts +4 -0
- package/apps/studio/collab/persistence.ts +5 -1
- package/apps/studio/collab/registry.ts +9 -3
- package/apps/studio/collab/room.ts +13 -2
- package/apps/studio/context.ts +59 -0
- package/apps/studio/dist/client.bundle.js +1586 -1554
- package/apps/studio/dist/runtime/.min-sizes.json +1 -0
- package/apps/studio/dist/runtime/sonner.js +1 -0
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/http.ts +289 -18
- package/apps/studio/inspect.ts +19 -0
- package/apps/studio/managed-projects.ts +150 -0
- package/apps/studio/notifications.tsx +292 -0
- package/apps/studio/runtime-bundle.ts +2 -0
- package/apps/studio/server.ts +59 -3
- package/apps/studio/sync/accepted-cold-start.ts +225 -0
- package/apps/studio/sync/accepted-link.ts +320 -0
- package/apps/studio/sync/action-stage.ts +343 -0
- package/apps/studio/sync/agent.ts +12 -60
- package/apps/studio/sync/cell-file-events.ts +3 -0
- package/apps/studio/sync/codec.ts +73 -6
- package/apps/studio/sync/ctl-provider.ts +15 -2
- package/apps/studio/sync/document-discovery.ts +81 -0
- package/apps/studio/sync/file-membership.ts +10 -2
- package/apps/studio/sync/file-plane.ts +309 -26
- package/apps/studio/sync/index.ts +1078 -19
- package/apps/studio/sync/migrate-seed.ts +110 -19
- package/apps/studio/sync/poke.ts +4 -2
- package/apps/studio/sync/presentation.ts +285 -2
- package/apps/studio/sync/projection.ts +820 -23
- package/apps/studio/sync/repeated-module.ts +73 -0
- package/apps/studio/sync/revision-barrier.ts +129 -0
- package/apps/studio/sync/seed-repair.ts +46 -0
- package/apps/studio/sync/source-merge.ts +100 -0
- package/apps/studio/sync/source-ops.ts +289 -0
- package/apps/studio/sync/source-recovery.ts +70 -0
- package/apps/studio/sync/source-validation.ts +56 -0
- package/apps/studio/sync/status.ts +79 -1
- package/apps/studio/sync/transaction-client.ts +460 -0
- package/apps/studio/sync/writer-registry.ts +236 -0
- package/apps/studio/text-caret.ts +35 -0
- package/apps/studio/undo-hud.tsx +9 -87
- package/apps/studio/use-canvas-media-drop.tsx +4 -39
- package/apps/studio/whats-new.json +101 -0
- package/cli/lib/harness/codex-runtime.mjs +4 -1
- package/package.json +9 -8
- package/plugins/design/dependencies.json +3 -3
- package/plugins/design/templates/_shell.html +47 -0
- package/plugins/flow/dependencies.json +3 -3
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Maude
|
|
2
2
|
|
|
3
|
-
A personal marketplace of Claude Code plugins. Two plugins today, plus a `maude` CLI for scaffolding and running the bundled dev tooling.
|
|
3
|
+
A personal marketplace of Claude Code and Codex plugins. Two plugins today, plus a `maude` CLI for scaffolding and running the bundled dev tooling.
|
|
4
4
|
|
|
5
5
|
<!-- Demo video lives at https://github.com/1aGh/maude/releases/download/v0.16.0/demo.mp4 once uploaded.
|
|
6
6
|
Until then the inline tag below stays a soft 404; landing page at https://maude.sh autoplays the same file. -->
|
|
@@ -18,6 +18,8 @@ Plus the **`maude`** CLI — `maude init` scaffolds a fresh `.ai/` workspace fro
|
|
|
18
18
|
|
|
19
19
|
## Quick start
|
|
20
20
|
|
|
21
|
+
**Codex CLI:** use the [native plugin installation guide](docs/native-plugins.md). It uses the same workflow procedures; Studio ACP support is a separate follow-up.
|
|
22
|
+
|
|
21
23
|
> **Using Maude Desktop?** With only **Claude Code installed**, the desktop app's built-in chat panel auto-loads the `design` + `flow` plugins for its session — `/design:*` and `/flow:*` just work, nothing to install (power users who already installed the plugins see a no-op, no double-load). The steps below are the **manual / power-user path** — for the `maude` CLI, the web `maude design serve` flow, or driving `/design:*` from your own terminal Claude Code. Full detail: [maude.sh/desktop](https://maude.sh/desktop).
|
|
22
24
|
|
|
23
25
|
### 1. Add the marketplace inside Claude Code
|
package/apps/studio/acp/index.ts
CHANGED
|
@@ -101,14 +101,28 @@ const BEAT_MS = 4000;
|
|
|
101
101
|
|
|
102
102
|
interface AgentActivityTracker {
|
|
103
103
|
onUpdate(update: SessionUpdate): void;
|
|
104
|
-
/**
|
|
105
|
-
|
|
104
|
+
/**
|
|
105
|
+
* Turn finished (normal, cancel, error) or socket closed — clear banners.
|
|
106
|
+
* T16: `done` publishes what the turn wrote as ONE project action; anything
|
|
107
|
+
* else (cancel, error, token limit, reap) keeps it unpublished for the
|
|
108
|
+
* person's decision. Default `failed` — only a clean end publishes.
|
|
109
|
+
*/
|
|
110
|
+
endTurn(outcome?: 'done' | 'failed'): void;
|
|
111
|
+
/** The prompt this turn answers — the action's history label. */
|
|
112
|
+
beginTurn?(prompt: string): void;
|
|
106
113
|
}
|
|
107
114
|
|
|
108
115
|
/** Exported for tests (acp-ai-activity.test.ts); not part of the Acp surface. */
|
|
109
|
-
export function createAgentActivityTracker(
|
|
116
|
+
export function createAgentActivityTracker(
|
|
117
|
+
ctx: Context,
|
|
118
|
+
ai: AiActivity,
|
|
119
|
+
chatId = 'default'
|
|
120
|
+
): AgentActivityTracker {
|
|
110
121
|
const kindByToolCall = new Map<string, string>(); // toolCallId → kind
|
|
111
122
|
const lastBeat = new Map<string, number>(); // ai-activity key → last beat ms
|
|
123
|
+
const actionKey = `acp:${chatId}`;
|
|
124
|
+
let turnLabel = 'Claude edit';
|
|
125
|
+
let actionOpen = false;
|
|
112
126
|
|
|
113
127
|
function keyFor(p: unknown): string | null {
|
|
114
128
|
if (typeof p !== 'string' || !p) return null;
|
|
@@ -145,6 +159,11 @@ export function createAgentActivityTracker(ctx: Context, ai: AiActivity): AgentA
|
|
|
145
159
|
for (const p of candidates) {
|
|
146
160
|
const key = keyFor(p);
|
|
147
161
|
if (!key) continue;
|
|
162
|
+
if (!actionOpen) {
|
|
163
|
+
// T16 — the turn's first canvas edit opens its project action.
|
|
164
|
+
actionOpen = true;
|
|
165
|
+
ctx.syncControl?.current?.()?.beginAiAction?.(actionKey, turnLabel);
|
|
166
|
+
}
|
|
148
167
|
const last = lastBeat.get(key);
|
|
149
168
|
if (last == null) {
|
|
150
169
|
ai.start(key, AGENT_AUTHOR);
|
|
@@ -156,13 +175,25 @@ export function createAgentActivityTracker(ctx: Context, ai: AiActivity): AgentA
|
|
|
156
175
|
}
|
|
157
176
|
}
|
|
158
177
|
|
|
159
|
-
function endTurn(): void {
|
|
178
|
+
function endTurn(outcome: 'done' | 'failed' = 'failed'): void {
|
|
160
179
|
for (const key of lastBeat.keys()) ai.end(key);
|
|
161
180
|
lastBeat.clear();
|
|
162
181
|
kindByToolCall.clear();
|
|
182
|
+
if (actionOpen) {
|
|
183
|
+
actionOpen = false;
|
|
184
|
+
void ctx.syncControl
|
|
185
|
+
?.current?.()
|
|
186
|
+
?.endAiAction?.(actionKey, outcome)
|
|
187
|
+
?.catch(() => {});
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function beginTurn(prompt: string): void {
|
|
192
|
+
const words = prompt.replace(/\s+/g, ' ').trim().slice(0, 80);
|
|
193
|
+
turnLabel = words ? `Claude: ${words}` : 'Claude edit';
|
|
163
194
|
}
|
|
164
195
|
|
|
165
|
-
return { onUpdate, endTurn };
|
|
196
|
+
return { onUpdate, endTurn, beginTurn };
|
|
166
197
|
}
|
|
167
198
|
|
|
168
199
|
// ── Detached bridge lifetime (feature-acp-write-path-scope Addendum, Task 8) ──
|
|
@@ -328,7 +359,7 @@ export function createAcp(ctx: Context, aiActivity?: AiActivity): Acp {
|
|
|
328
359
|
return null;
|
|
329
360
|
}
|
|
330
361
|
}
|
|
331
|
-
const tracker = aiActivity ? createAgentActivityTracker(ctx, aiActivity) : null;
|
|
362
|
+
const tracker = aiActivity ? createAgentActivityTracker(ctx, aiActivity, chatId) : null;
|
|
332
363
|
// Declared before the bridge so the callbacks below can close over it —
|
|
333
364
|
// they fan out to `entry.sinks`, which changes as sockets come and go,
|
|
334
365
|
// rather than capturing one socket the way the per-socket design did.
|
|
@@ -548,9 +579,14 @@ export function createAcp(ctx: Context, aiActivity?: AiActivity): Acp {
|
|
|
548
579
|
// first await, so a socket that closes during the spawn still counts as a
|
|
549
580
|
// live turn rather than an abandoned bridge.
|
|
550
581
|
entry.turnActive = true;
|
|
582
|
+
entry.tracker?.beginTurn?.(text);
|
|
583
|
+
let turnOutcome: 'done' | 'failed' = 'failed';
|
|
551
584
|
try {
|
|
552
585
|
await bridge.ensureStarted();
|
|
553
586
|
const { stopReason } = await bridge.prompt(text, sanitizeChatId(chatId));
|
|
587
|
+
// Only a turn that ended on its own publishes; a cancel, a refusal or a
|
|
588
|
+
// token limit leaves its edits for the person (T16).
|
|
589
|
+
if (stopReason === 'end_turn') turnOutcome = 'done';
|
|
554
590
|
broadcast(entry, { t: 'connected', sessionId: bridge.sessionId });
|
|
555
591
|
broadcast(entry, { t: 'turn-end', stopReason });
|
|
556
592
|
} catch (err) {
|
|
@@ -564,7 +600,7 @@ export function createAcp(ctx: Context, aiActivity?: AiActivity): Acp {
|
|
|
564
600
|
// RC5 — the turn is over (success, error, or cancel-induced stop): clear
|
|
565
601
|
// every "Claude is editing …" banner this turn raised. The 30 s heartbeat
|
|
566
602
|
// grace still covers a crashed dev-server round-trip.
|
|
567
|
-
entry.tracker?.endTurn();
|
|
603
|
+
entry.tracker?.endTurn(turnOutcome);
|
|
568
604
|
// The turn was the only reason a detached bridge was being kept alive; now
|
|
569
605
|
// that it's done, let the ordinary TTL run out rather than holding the
|
|
570
606
|
// subprocess indefinitely for a client that never came back.
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { createAnnotationEchoGuard, observeAnnotationSnapshots } from './annotations-sync.ts';
|
|
1
2
|
/**
|
|
2
3
|
* @file annotations-layer.tsx — FigJam-style annotation overlay
|
|
3
4
|
* @scope apps/studio/annotations-layer.tsx
|
|
@@ -76,6 +77,7 @@ import {
|
|
|
76
77
|
type EllipseStroke,
|
|
77
78
|
FILL_PALETTE,
|
|
78
79
|
fmtEqual,
|
|
80
|
+
grownStickyBox,
|
|
79
81
|
HALO_PAD_PX,
|
|
80
82
|
HIGHLIGHTER_PALETTE,
|
|
81
83
|
HIGHLIGHTER_WIDTHS,
|
|
@@ -169,7 +171,7 @@ import { buildAnnotationStrokesRecord } from './commands/annotation-strokes-comm
|
|
|
169
171
|
import { ensureMenuStyles as ensureCtxMenuStyles } from './context-menu.tsx';
|
|
170
172
|
import { crossedDragThreshold, type Tool } from './input-router.tsx';
|
|
171
173
|
import { createMediaCommitChain, type MediaCommitResult } from './media-commit-chain.ts';
|
|
172
|
-
import { mountCaret, placeCaretAt } from './text-caret.ts';
|
|
174
|
+
import { collapseEntrySelectAll, mountCaret, placeCaretAt } from './text-caret.ts';
|
|
173
175
|
import {
|
|
174
176
|
AnnotationResizeOverlay,
|
|
175
177
|
bboxResize,
|
|
@@ -414,7 +416,7 @@ function resolveAssetHref(href: string): string {
|
|
|
414
416
|
* recognize — keeping the `<image>` element, dropping only the attribute —
|
|
415
417
|
* so the server's STORED + broadcast SVG silently diverges from whatever the
|
|
416
418
|
* client just sent. That divergence defeats the collab-echo self-suppression
|
|
417
|
-
* guard (the `
|
|
419
|
+
* guard (the `annotationEchoRef` operation history, near `putStrokes` below): the
|
|
418
420
|
* echo's content no longer matches anything we recorded as "already
|
|
419
421
|
* applied", so a real `setStrokesState` fires from the (href-stripped)
|
|
420
422
|
* server copy — which can wipe out a SIBLING stroke's still-in-flight
|
|
@@ -467,6 +469,17 @@ export function reconcileCommit(
|
|
|
467
469
|
* ones that are still purely local, not-yet-synced optimistic previews (an
|
|
468
470
|
* ephemeral blob:/data: href image mid-upload) that a foreign broadcast could
|
|
469
471
|
* never have known about in the first place.
|
|
472
|
+
*
|
|
473
|
+
* issue-106 deliberately does NOT carve out the stroke a local editor has open.
|
|
474
|
+
* That was tried: hold back its `text` so a peer's commit could not overwrite
|
|
475
|
+
* the editor. But this function's output IS the persisted store — it is
|
|
476
|
+
* serialized to `*.annotations.svg` and re-broadcast on the next commit of ANY
|
|
477
|
+
* stroke — so holding a field back doesn't shield a view, it silently reverts
|
|
478
|
+
* the peer's write and republishes the stale text under the local user's name,
|
|
479
|
+
* for as long as the editor stays open. The editor never needed it: the clobber
|
|
480
|
+
* was React rewriting an UNCONTROLLED contentEditable, which `useSessionValue`
|
|
481
|
+
* fixes at the DOM, and the commit reads `innerText` off the node rather than
|
|
482
|
+
* off the stroke. Last-write-wins stays the model here.
|
|
470
483
|
*/
|
|
471
484
|
export function reconcileForeignEcho(
|
|
472
485
|
prev: readonly Stroke[],
|
|
@@ -1101,7 +1114,7 @@ export function AnnotationsLayer() {
|
|
|
1101
1114
|
// the resurrection to every collab peer). Tracked explicitly here instead
|
|
1102
1115
|
// of inferred: `deleteStrokes` records every id it removes; the swap
|
|
1103
1116
|
// checks (and consumes) this before assuming absence means lag. Bounded
|
|
1104
|
-
// like
|
|
1117
|
+
// like the annotation echo history below — only ever holds ids a delete has
|
|
1105
1118
|
// touched, which is small in practice, but capped for safety.
|
|
1106
1119
|
const DELETED_STROKE_IDS_CAP = 128;
|
|
1107
1120
|
const deletedStrokeIdsRef = useRef<Set<string>>(new Set());
|
|
@@ -1140,29 +1153,10 @@ export function AnnotationsLayer() {
|
|
|
1140
1153
|
if (!ghostCapable || !visible) setGhost(null);
|
|
1141
1154
|
}, [ghostCapable, visible]);
|
|
1142
1155
|
|
|
1143
|
-
//
|
|
1144
|
-
//
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
// drop), the server's broadcast of an EARLIER commit can arrive after a
|
|
1148
|
-
// LATER local commit has already moved "last applied" on — so the earlier
|
|
1149
|
-
// echo no longer matches, gets misread as a foreign change, and rolls
|
|
1150
|
-
// local state BACK to that stale snapshot. This is the confirmed cause of
|
|
1151
|
-
// images silently vanishing after a multi-file drop (live-tested: 12
|
|
1152
|
-
// mismatched/misapplied echoes correlated exactly with lost strokes across
|
|
1153
|
-
// a 30-batch stress run). Track a bounded HISTORY of our own recent writes
|
|
1154
|
-
// instead of just the latest one, so an out-of-order echo of any recent
|
|
1155
|
-
// self-write is still recognized and suppressed.
|
|
1156
|
-
const RECENT_SELF_SVG_CAP = 64;
|
|
1157
|
-
const recentSelfSvgsRef = useRef<Set<string>>(new Set());
|
|
1158
|
-
const rememberSelfSvg = useCallback((svg: string) => {
|
|
1159
|
-
const set = recentSelfSvgsRef.current;
|
|
1160
|
-
set.add(svg);
|
|
1161
|
-
if (set.size > RECENT_SELF_SVG_CAP) {
|
|
1162
|
-
const oldest = set.values().next().value;
|
|
1163
|
-
if (oldest !== undefined) set.delete(oldest);
|
|
1164
|
-
}
|
|
1165
|
-
}, []);
|
|
1156
|
+
// Match specific authored operations, including delayed earlier PUTs. A
|
|
1157
|
+
// peer undo/delete can legitimately return to any previously rendered SVG.
|
|
1158
|
+
const annotationEchoRef = useRef(createAnnotationEchoGuard());
|
|
1159
|
+
const annotationsChangedRef = useRef(false);
|
|
1166
1160
|
useEffect(() => {
|
|
1167
1161
|
const file = deriveFile();
|
|
1168
1162
|
fileRef.current = file;
|
|
@@ -1173,11 +1167,10 @@ export function AnnotationsLayer() {
|
|
|
1173
1167
|
})
|
|
1174
1168
|
.then((r) => (r.ok ? r.text() : ''))
|
|
1175
1169
|
.then((text) => {
|
|
1176
|
-
if (cancelled) return;
|
|
1170
|
+
if (cancelled || annotationsChangedRef.current) return;
|
|
1177
1171
|
const loaded = svgToStrokes(text);
|
|
1178
1172
|
if (loaded.length) {
|
|
1179
1173
|
setStrokesState(loaded);
|
|
1180
|
-
rememberSelfSvg(text);
|
|
1181
1174
|
}
|
|
1182
1175
|
})
|
|
1183
1176
|
.catch(() => {
|
|
@@ -1186,37 +1179,18 @@ export function AnnotationsLayer() {
|
|
|
1186
1179
|
return () => {
|
|
1187
1180
|
cancelled = true;
|
|
1188
1181
|
};
|
|
1189
|
-
}, [
|
|
1190
|
-
|
|
1191
|
-
// Phase 8 Task 5 — observe the Y.Map.annotations for live updates from
|
|
1192
|
-
// other tabs. Bail when the incoming SVG STRING matches any RECENT
|
|
1193
|
-
// self-write (covers the local echo round-trip, including an
|
|
1194
|
-
// out-of-order one, without missing real foreign changes). The prior
|
|
1195
|
-
// length+first/last-id check was wrong: a resize / move keeps the same id
|
|
1196
|
-
// list, so all three predicates matched even though geometry changed —
|
|
1197
|
-
// foreign edits silently disappeared.
|
|
1182
|
+
}, []);
|
|
1183
|
+
|
|
1198
1184
|
const collab = useCollab();
|
|
1199
1185
|
useEffect(() => {
|
|
1200
1186
|
if (!collab) return;
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
if (typeof svg !== 'string' || !svg) return;
|
|
1205
|
-
if (recentSelfSvgsRef.current.has(svg)) return;
|
|
1206
|
-
rememberSelfSvg(svg);
|
|
1187
|
+
return observeAnnotationSnapshots(collab.doc, (svg, writeId) => {
|
|
1188
|
+
annotationsChangedRef.current = true;
|
|
1189
|
+
if (annotationEchoRef.current.isOwn(svg, writeId)) return;
|
|
1207
1190
|
const incoming = svgToStrokes(svg);
|
|
1208
1191
|
setStrokesState((prev) => reconcileForeignEcho(prev, incoming));
|
|
1209
|
-
};
|
|
1210
|
-
|
|
1211
|
-
map.observe(apply);
|
|
1212
|
-
return () => {
|
|
1213
|
-
try {
|
|
1214
|
-
map.unobserve(apply);
|
|
1215
|
-
} catch {
|
|
1216
|
-
/* doc destroyed before unmount */
|
|
1217
|
-
}
|
|
1218
|
-
};
|
|
1219
|
-
}, [collab, rememberSelfSvg]);
|
|
1192
|
+
});
|
|
1193
|
+
}, [collab]);
|
|
1220
1194
|
|
|
1221
1195
|
const undoStack = useUndoStackOptional();
|
|
1222
1196
|
const undoSinks = useUndoSinks();
|
|
@@ -1252,50 +1226,55 @@ export function AnnotationsLayer() {
|
|
|
1252
1226
|
* we push a command, so the server only sees one PUT per edit instead
|
|
1253
1227
|
* of two-step racing.
|
|
1254
1228
|
*/
|
|
1255
|
-
const putStrokes = useCallback(
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1229
|
+
const putStrokes = useCallback((next: readonly Stroke[], before: readonly Stroke[]) => {
|
|
1230
|
+
// See reconcileCommit — a direct setStrokesState(next) here can
|
|
1231
|
+
// clobber a sibling file's concurrent optimistic insert; folding
|
|
1232
|
+
// blindly against `prev` (no baseline) can just as easily revert this
|
|
1233
|
+
// very mutation's own delete. `before` (this command's own baseline)
|
|
1234
|
+
// disambiguates the two.
|
|
1235
|
+
annotationsChangedRef.current = true;
|
|
1236
|
+
setStrokesState((prev) => reconcileCommit(prev, before, next));
|
|
1237
|
+
const file = fileRef.current;
|
|
1238
|
+
if (!file) return Promise.resolve();
|
|
1239
|
+
const persistable = next.some(isEphemeralHref) ? next.filter((s) => !isEphemeralHref(s)) : next;
|
|
1240
|
+
const svg = strokesToSvg(persistable);
|
|
1241
|
+
// What this edit was derived from — the project merges a peer's
|
|
1242
|
+
// concurrent strokes from it instead of replacing them (DDR-241).
|
|
1243
|
+
const persistableBefore = before.some(isEphemeralHref)
|
|
1244
|
+
? before.filter((s) => !isEphemeralHref(s))
|
|
1245
|
+
: before;
|
|
1246
|
+
const base = strokesToSvg(persistableBefore);
|
|
1247
|
+
// Phase 8 Task 5 — record the SVG we just authored locally so the
|
|
1248
|
+
// server-broadcast echo (PUT → onAnnotationsChanged → syncRoom* →
|
|
1249
|
+
// Y.Map.observe) doesn't trigger a redundant setStrokesState.
|
|
1250
|
+
const writeId = crypto.randomUUID();
|
|
1251
|
+
annotationEchoRef.current.remember(writeId, svg);
|
|
1252
|
+
const dispatch = () =>
|
|
1253
|
+
fetch('/_api/annotations', {
|
|
1254
|
+
method: 'PUT',
|
|
1255
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1256
|
+
body: JSON.stringify({ file, svg, writeId, base }),
|
|
1257
|
+
})
|
|
1258
|
+
.then((r) => {
|
|
1259
|
+
// A refused save (403 read-only, 405 at a proxy door) previously
|
|
1260
|
+
// dissolved here without a trace — the user kept drawing on state
|
|
1261
|
+
// that never reached disk, a peer, or a reload (the cloud
|
|
1262
|
+
// canvas-writes RCA). Optimistic local state is still the right
|
|
1263
|
+
// UX; a persistence failure being INVISIBLE is not.
|
|
1264
|
+
if (!r.ok) {
|
|
1265
|
+
annotationEchoRef.current.forget(writeId);
|
|
1266
|
+
console.warn(`[annotations] save refused (${r.status}) — strokes are local-only`);
|
|
1267
|
+
}
|
|
1268
|
+
return undefined;
|
|
1278
1269
|
})
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
}
|
|
1288
|
-
return undefined;
|
|
1289
|
-
})
|
|
1290
|
-
.catch(() => {
|
|
1291
|
-
/* swallow — user sees uncommitted state until the next stroke */
|
|
1292
|
-
});
|
|
1293
|
-
const chained = putChainRef.current.then(dispatch, dispatch);
|
|
1294
|
-
putChainRef.current = chained;
|
|
1295
|
-
return chained;
|
|
1296
|
-
},
|
|
1297
|
-
[rememberSelfSvg]
|
|
1298
|
-
);
|
|
1270
|
+
.catch(() => {
|
|
1271
|
+
annotationEchoRef.current.forget(writeId);
|
|
1272
|
+
/* Pending persistence UX is handled by the project outbox work. */
|
|
1273
|
+
});
|
|
1274
|
+
const chained = putChainRef.current.then(dispatch, dispatch);
|
|
1275
|
+
putChainRef.current = chained;
|
|
1276
|
+
return chained;
|
|
1277
|
+
}, []);
|
|
1299
1278
|
|
|
1300
1279
|
// Register the strokes put sink with the undo provider so the rebuilt
|
|
1301
1280
|
// AnnotationStrokesCommand (after a canvas switch + return) routes through
|
|
@@ -1745,14 +1724,14 @@ export function AnnotationsLayer() {
|
|
|
1745
1724
|
// chain-driven setStrokesState queued in the same batch.
|
|
1746
1725
|
setStrokesState((prev) => prev.filter((s) => s.id !== id));
|
|
1747
1726
|
deletedStrokeIdsRef.current.delete(id); // never reached the chain — nothing to consume there
|
|
1748
|
-
showCanvasToast(`Image upload failed — ${res.error}
|
|
1727
|
+
showCanvasToast(`Image upload failed — ${res.error}`, 'error');
|
|
1749
1728
|
}
|
|
1750
1729
|
URL.revokeObjectURL(blobUrl);
|
|
1751
1730
|
});
|
|
1752
1731
|
};
|
|
1753
1732
|
probe.onerror = () => {
|
|
1754
1733
|
URL.revokeObjectURL(blobUrl);
|
|
1755
|
-
showCanvasToast('Could not read that image file');
|
|
1734
|
+
showCanvasToast('Could not read that image file', 'error');
|
|
1756
1735
|
};
|
|
1757
1736
|
probe.src = blobUrl;
|
|
1758
1737
|
},
|
|
@@ -1797,7 +1776,7 @@ export function AnnotationsLayer() {
|
|
|
1797
1776
|
const h = mediaKind === 'video' ? MEDIAREF_VIDEO_H : MEDIAREF_DEFAULT_H;
|
|
1798
1777
|
void uploadAsset(file).then((res) => {
|
|
1799
1778
|
if (!('path' in res)) {
|
|
1800
|
-
showCanvasToast(`Couldn't add ${mediaKind}: ${res.error}
|
|
1779
|
+
showCanvasToast(`Couldn't add ${mediaKind}: ${res.error}`, 'error');
|
|
1801
1780
|
return;
|
|
1802
1781
|
}
|
|
1803
1782
|
const id = rid();
|
|
@@ -2884,13 +2863,24 @@ export function AnnotationsLayer() {
|
|
|
2884
2863
|
// Phase 21 — sticky body edit. Sticky text is freeform (newlines preserved,
|
|
2885
2864
|
// no trim) and the card persists even when blank, so this only updates text.
|
|
2886
2865
|
const commitStickyText = useCallback(
|
|
2887
|
-
(id: string, text: string, fmt?: EditorFmt) => {
|
|
2866
|
+
(id: string, text: string, fmt?: EditorFmt, measuredH?: number) => {
|
|
2888
2867
|
const prev = strokesRef.current;
|
|
2889
2868
|
const existing = prev.find((s) => s.id === id && s.tool === 'sticky') as
|
|
2890
2869
|
| StickyStroke
|
|
2891
2870
|
| undefined;
|
|
2892
|
-
if (!existing
|
|
2893
|
-
|
|
2871
|
+
if (!existing) return;
|
|
2872
|
+
// issue-106 — grow the card to whatever the editor actually rendered, so
|
|
2873
|
+
// the lines the user just typed survive the return to the clipped read
|
|
2874
|
+
// view. Null when the text already fits (the common case).
|
|
2875
|
+
// The growth rides along with a real edit — it is NOT itself a reason to
|
|
2876
|
+
// write. Otherwise opening a peer's already-overflowing note and clicking
|
|
2877
|
+
// away, having typed nothing, would produce an undo entry, a
|
|
2878
|
+
// full-document PUT and a sync broadcast.
|
|
2879
|
+
if (existing.text === text && fmtEqual(existing, fmt)) return;
|
|
2880
|
+
const grown = measuredH == null ? null : grownStickyBox(existing, measuredH);
|
|
2881
|
+
const next = prev.map((s) =>
|
|
2882
|
+
s.id === id ? { ...existing, text, ...normFmt(fmt), ...(grown ?? {}) } : s
|
|
2883
|
+
);
|
|
2894
2884
|
commitStrokes(prev, next, 'edit sticky');
|
|
2895
2885
|
},
|
|
2896
2886
|
[commitStrokes]
|
|
@@ -2970,14 +2960,14 @@ export function AnnotationsLayer() {
|
|
|
2970
2960
|
editingTargetRef.current = editingTarget;
|
|
2971
2961
|
|
|
2972
2962
|
const commitEditing = useCallback(
|
|
2973
|
-
(text: string, fmt?: EditorFmt) => {
|
|
2963
|
+
(text: string, fmt?: EditorFmt, measuredH?: number) => {
|
|
2974
2964
|
const target = editingTargetRef.current;
|
|
2975
2965
|
setEditingId(null);
|
|
2976
2966
|
setPendingText(null);
|
|
2977
2967
|
setEditCaretPoint(null);
|
|
2978
2968
|
if (!target) return;
|
|
2979
2969
|
if (target.kind === 'anchored') commitText(target.anchorId, text, fmt);
|
|
2980
|
-
else if (target.kind === 'sticky') commitStickyText(target.sticky.id, text, fmt);
|
|
2970
|
+
else if (target.kind === 'sticky') commitStickyText(target.sticky.id, text, fmt, measuredH);
|
|
2981
2971
|
else if (target.kind === 'standalone') commitStandaloneText(target.text.id, text, fmt);
|
|
2982
2972
|
else if (target.kind === 'section') {
|
|
2983
2973
|
const label = text.trim().replace(/\s*\n+\s*/g, ' ') || 'Section';
|
|
@@ -3902,7 +3892,7 @@ function AnnotationsSvg({
|
|
|
3902
3892
|
editCaretPoint: { x: number; y: number } | null;
|
|
3903
3893
|
/** Live default ink (theme-aware) for a not-yet-born pending text caret. */
|
|
3904
3894
|
inkColor: string;
|
|
3905
|
-
onCommitEdit: (text: string, fmt?: EditorFmt) => void;
|
|
3895
|
+
onCommitEdit: (text: string, fmt?: EditorFmt, measuredH?: number) => void;
|
|
3906
3896
|
onCancelEdit: () => void;
|
|
3907
3897
|
}) {
|
|
3908
3898
|
const [, force] = useState({});
|
|
@@ -4118,7 +4108,7 @@ function AnnotEditors({
|
|
|
4118
4108
|
anchoredExisting: TextStroke | undefined;
|
|
4119
4109
|
caretPoint: { x: number; y: number } | null;
|
|
4120
4110
|
inkColor: string;
|
|
4121
|
-
onCommitEdit: (text: string, fmt?: EditorFmt) => void;
|
|
4111
|
+
onCommitEdit: (text: string, fmt?: EditorFmt, measuredH?: number) => void;
|
|
4122
4112
|
onCancelEdit: () => void;
|
|
4123
4113
|
}) {
|
|
4124
4114
|
const target = worldRef?.current ?? null;
|
|
@@ -4346,6 +4336,33 @@ function useEditorCaret(
|
|
|
4346
4336
|
return { onPointerDown, onPointerUp };
|
|
4347
4337
|
}
|
|
4348
4338
|
|
|
4339
|
+
/**
|
|
4340
|
+
* issue-106 — the body an annotation editor hands React as its contentEditable
|
|
4341
|
+
* children, snapshotted for the life of ONE edit session.
|
|
4342
|
+
*
|
|
4343
|
+
* These editors are uncontrolled: the user types straight into the DOM. If the
|
|
4344
|
+
* rendered children value changes while the session is open, React writes the
|
|
4345
|
+
* new string into the live node and everything typed since — line breaks
|
|
4346
|
+
* included — is gone. That is exactly what a peer's commit arriving over
|
|
4347
|
+
* shared-doc sync (DDR-064) used to do, and what the hazard note above
|
|
4348
|
+
* `useEditorFormat` warns about for local mutations. Snapshotting per target id
|
|
4349
|
+
* makes the children a constant for the session, so no store update can reach
|
|
4350
|
+
* the DOM; the commit path reads `innerText` from the node itself, which is the
|
|
4351
|
+
* user's real content either way. Re-keys when the editor is reused for a
|
|
4352
|
+
* different stroke.
|
|
4353
|
+
*
|
|
4354
|
+
* `listType` is snapshotted through the same hook: the body is prefixed with
|
|
4355
|
+
* list markers at OPEN time and stripped again at commit, so if a peer flipped
|
|
4356
|
+
* the stroke's list style mid-session the strip would run a different rule than
|
|
4357
|
+
* the prefix did — turning a bullet into a literal "• " in the stored text, or
|
|
4358
|
+
* eating a leading "1. " the user actually typed.
|
|
4359
|
+
*/
|
|
4360
|
+
function useSessionValue<T>(id: string, compute: () => T): T {
|
|
4361
|
+
const ref = useRef<{ id: string; value: T } | null>(null);
|
|
4362
|
+
if (ref.current === null || ref.current.id !== id) ref.current = { id, value: compute() };
|
|
4363
|
+
return ref.current.value;
|
|
4364
|
+
}
|
|
4365
|
+
|
|
4349
4366
|
function TextEditor({
|
|
4350
4367
|
anchorId,
|
|
4351
4368
|
host,
|
|
@@ -4364,9 +4381,10 @@ function TextEditor({
|
|
|
4364
4381
|
const ref = useRef<HTMLDivElement | null>(null);
|
|
4365
4382
|
// Show list markers WHILE editing so the read↔edit swap doesn't flicker
|
|
4366
4383
|
// (item 4c) — stripped back to raw text on commit.
|
|
4367
|
-
const
|
|
4368
|
-
const
|
|
4369
|
-
|
|
4384
|
+
const sessionListType = useSessionValue(anchorId, () => existing?.listType);
|
|
4385
|
+
const initial = useSessionValue(anchorId, () =>
|
|
4386
|
+
listPrefixedBody(existing?.text ?? '', sessionListType)
|
|
4387
|
+
);
|
|
4370
4388
|
// Cmd/Ctrl+B/I/U formatting while editing (item 4d).
|
|
4371
4389
|
const {
|
|
4372
4390
|
fmtRef,
|
|
@@ -4385,8 +4403,8 @@ function TextEditor({
|
|
|
4385
4403
|
// effect below can depend on it directly instead of its own copy of
|
|
4386
4404
|
// existing?.listType (lint/correctness/useExhaustiveDependencies).
|
|
4387
4405
|
const toCommittedText = useCallback(
|
|
4388
|
-
(raw: string) => stripEditorMarkers(stripJumpSentinel(raw),
|
|
4389
|
-
[
|
|
4406
|
+
(raw: string) => stripEditorMarkers(stripJumpSentinel(raw), sessionListType),
|
|
4407
|
+
[sessionListType]
|
|
4390
4408
|
);
|
|
4391
4409
|
|
|
4392
4410
|
// Caret-at-click on entry + custom blinking caret + click re-placement
|
|
@@ -4471,6 +4489,14 @@ function TextEditor({
|
|
|
4471
4489
|
onCancel();
|
|
4472
4490
|
return;
|
|
4473
4491
|
}
|
|
4492
|
+
// issue-106 — Shift+Enter ADDS a line. On keyboard entry the whole
|
|
4493
|
+
// body is still select-all'd (the retype convention), so letting the
|
|
4494
|
+
// break replace the selection wiped the text. Collapse that entry
|
|
4495
|
+
// selection to its end first; a user's own partial selection is left
|
|
4496
|
+
// alone. See collapseEntrySelectAll.
|
|
4497
|
+
if (e.key === 'Enter' && e.shiftKey && ref.current) {
|
|
4498
|
+
collapseEntrySelectAll(ref.current, window);
|
|
4499
|
+
}
|
|
4474
4500
|
// Unified across every text surface: plain Enter commits,
|
|
4475
4501
|
// Shift+Enter inserts a newline (falls through untouched). ⌘/Ctrl
|
|
4476
4502
|
// +Enter also commits AND chains a connected sibling (quick-create).
|
|
@@ -4504,7 +4530,7 @@ function StickyEditor({
|
|
|
4504
4530
|
}: {
|
|
4505
4531
|
sticky: StickyStroke;
|
|
4506
4532
|
caretPoint?: { x: number; y: number } | null;
|
|
4507
|
-
onCommit: (text: string, fmt?: EditorFmt) => void;
|
|
4533
|
+
onCommit: (text: string, fmt?: EditorFmt, measuredH?: number) => void;
|
|
4508
4534
|
onCancel: () => void;
|
|
4509
4535
|
}) {
|
|
4510
4536
|
// A flex-centered contentEditable (NOT a textarea) so the edit view matches
|
|
@@ -4513,6 +4539,8 @@ function StickyEditor({
|
|
|
4513
4539
|
// Esc cancels; blur commits; Cmd/Ctrl+B/I/U format (unified with the others).
|
|
4514
4540
|
const ref = useRef<HTMLDivElement | null>(null);
|
|
4515
4541
|
const doneRef = useRef(false);
|
|
4542
|
+
const sessionListType = useSessionValue(sticky.id, () => sticky.listType);
|
|
4543
|
+
const sessionBody = useSessionValue(sticky.id, () => stickyBodyText(sticky));
|
|
4516
4544
|
const {
|
|
4517
4545
|
fmtRef,
|
|
4518
4546
|
style: fmtStyle,
|
|
@@ -4528,7 +4556,16 @@ function StickyEditor({
|
|
|
4528
4556
|
const commit = () => {
|
|
4529
4557
|
if (doneRef.current) return;
|
|
4530
4558
|
doneRef.current = true;
|
|
4531
|
-
|
|
4559
|
+
const el = ref.current;
|
|
4560
|
+
// issue-106 — the editor IS the measurement: same class, same width, same
|
|
4561
|
+
// font as the committed body, and laid out in world units (the pan/zoom
|
|
4562
|
+
// transform on `.dc-world` doesn't change layout), so its scrollHeight is
|
|
4563
|
+
// the height the card needs. No offscreen measuring rig required.
|
|
4564
|
+
onCommit(
|
|
4565
|
+
stripEditorMarkers(el?.innerText ?? '', sessionListType),
|
|
4566
|
+
fmtRef.current,
|
|
4567
|
+
el?.scrollHeight
|
|
4568
|
+
);
|
|
4532
4569
|
};
|
|
4533
4570
|
// FigJam v3 — a toolbar click steals focus for a tick; don't treat it as
|
|
4534
4571
|
// "done editing" (the button's onMouseDown preventDefault usually stops the
|
|
@@ -4544,10 +4581,33 @@ function StickyEditor({
|
|
|
4544
4581
|
const y = Math.min(sticky.y, sticky.y + sticky.h);
|
|
4545
4582
|
const w = Math.abs(sticky.w);
|
|
4546
4583
|
const h = Math.abs(sticky.h);
|
|
4584
|
+
const r = sticky.cornerRadius ?? STICKY_CORNER_RADIUS;
|
|
4547
4585
|
return (
|
|
4548
4586
|
<div
|
|
4549
4587
|
data-annot-editor="1"
|
|
4550
|
-
style={{
|
|
4588
|
+
style={{
|
|
4589
|
+
position: 'absolute',
|
|
4590
|
+
left: x,
|
|
4591
|
+
top: y,
|
|
4592
|
+
width: w,
|
|
4593
|
+
// issue-106 — NOT a fixed `height: h`. The card clips at `overflow:
|
|
4594
|
+
// hidden`, so on a sticky that is already full the line Shift+Enter
|
|
4595
|
+
// inserts landed outside the box: the keystroke worked, nothing moved
|
|
4596
|
+
// on screen, and it read as "shift+enter does nothing". While the
|
|
4597
|
+
// editor is open it grows downward instead, painting its own paper so
|
|
4598
|
+
// the overflow still reads as the note (the SVG card behind is still
|
|
4599
|
+
// the old size until the commit below persists the grown height).
|
|
4600
|
+
minHeight: h,
|
|
4601
|
+
zIndex: 5,
|
|
4602
|
+
// `backgroundColor`, never the `background` shorthand: `color` comes
|
|
4603
|
+
// verbatim from a synced stroke's `fill` (peer-controlled, and the
|
|
4604
|
+
// sanitizer does not touch `fill`), and the shorthand would accept a
|
|
4605
|
+
// `url(...)` — an outbound request from the studio origin the moment a
|
|
4606
|
+
// note is opened. `background-color` cannot take one.
|
|
4607
|
+
backgroundColor: sticky.color,
|
|
4608
|
+
// Mirrors stickyCornerPath: TL/TR/BL rounded, BR sharp.
|
|
4609
|
+
borderRadius: `${r}px ${r}px 0 ${r}px`,
|
|
4610
|
+
}}
|
|
4551
4611
|
>
|
|
4552
4612
|
<div
|
|
4553
4613
|
ref={ref}
|
|
@@ -4561,6 +4621,11 @@ function StickyEditor({
|
|
|
4561
4621
|
outline: 'none',
|
|
4562
4622
|
cursor: 'text',
|
|
4563
4623
|
...CARET_FIX_STYLE,
|
|
4624
|
+
// Beats `.dc-sticky-body { height: 100%; overflow: hidden }` for the
|
|
4625
|
+
// duration of the edit — see the wrapper note above.
|
|
4626
|
+
height: 'auto',
|
|
4627
|
+
minHeight: h,
|
|
4628
|
+
overflow: 'visible',
|
|
4564
4629
|
}}
|
|
4565
4630
|
onBlur={onBlur}
|
|
4566
4631
|
onPointerDown={caretHandlers.onPointerDown}
|
|
@@ -4573,6 +4638,11 @@ function StickyEditor({
|
|
|
4573
4638
|
onCancel();
|
|
4574
4639
|
return;
|
|
4575
4640
|
}
|
|
4641
|
+
// issue-106 — see the same guard in TextEditor: Shift+Enter must add
|
|
4642
|
+
// a line, never consume the entry select-all and delete the body.
|
|
4643
|
+
if (e.key === 'Enter' && e.shiftKey && ref.current) {
|
|
4644
|
+
collapseEntrySelectAll(ref.current, window);
|
|
4645
|
+
}
|
|
4576
4646
|
// Unified: plain Enter commits, Shift+Enter inserts a newline.
|
|
4577
4647
|
// ⌘/Ctrl+Enter also commits AND chains the next sticky beside it.
|
|
4578
4648
|
if (e.key === 'Enter' && !e.shiftKey) {
|
|
@@ -4588,7 +4658,7 @@ function StickyEditor({
|
|
|
4588
4658
|
>
|
|
4589
4659
|
{/* Show the list markers while editing (item 4c) so the read↔edit swap
|
|
4590
4660
|
doesn't flicker; stripped back to raw text on commit. */}
|
|
4591
|
-
{
|
|
4661
|
+
{sessionBody}
|
|
4592
4662
|
</div>
|
|
4593
4663
|
</div>
|
|
4594
4664
|
);
|
|
@@ -4736,6 +4806,11 @@ function StandaloneTextEditor({
|
|
|
4736
4806
|
onCancel();
|
|
4737
4807
|
return;
|
|
4738
4808
|
}
|
|
4809
|
+
// issue-106 — same guard as the other two editors, for the multi-line
|
|
4810
|
+
// case only (a singleLine field commits on Shift+Enter anyway).
|
|
4811
|
+
if (e.key === 'Enter' && e.shiftKey && !singleLine && ref.current) {
|
|
4812
|
+
collapseEntrySelectAll(ref.current, window);
|
|
4813
|
+
}
|
|
4739
4814
|
// Unified: plain Enter commits, Shift+Enter inserts a newline. A
|
|
4740
4815
|
// singleLine field (section rename) is a title — Shift+Enter commits
|
|
4741
4816
|
// too rather than adding a newline the one-line chip can't show.
|