@1agh/maude 1.4.6 → 1.5.1
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/apps/studio/annotations/ai-read.ts +288 -0
- package/apps/studio/annotations/ai-write.ts +534 -0
- package/apps/studio/annotations/board-io.ts +94 -0
- package/apps/studio/annotations/board-text.ts +45 -0
- package/apps/studio/annotations/constants.ts +58 -0
- package/apps/studio/annotations/elements/_shared.ts +125 -0
- package/apps/studio/annotations/elements/arrow.model.ts +196 -0
- package/apps/studio/annotations/elements/media.model.ts +79 -0
- package/apps/studio/annotations/elements/pen.model.ts +82 -0
- package/apps/studio/annotations/elements/section.model.ts +70 -0
- package/apps/studio/annotations/elements/shape.model.ts +141 -0
- package/apps/studio/annotations/elements/sticky.model.ts +48 -0
- package/apps/studio/annotations/elements/text.model.ts +52 -0
- package/apps/studio/annotations/fields.ts +347 -0
- package/apps/studio/annotations/fractional-index.ts +234 -0
- package/apps/studio/annotations/legacy/mini-dom.ts +207 -0
- package/apps/studio/annotations/migrate-boot.ts +172 -0
- package/apps/studio/annotations/migrate-cli.ts +37 -0
- package/apps/studio/annotations/migrate-v1.ts +383 -0
- package/apps/studio/annotations/ops-merge.ts +15 -0
- package/apps/studio/annotations/ops.ts +499 -0
- package/apps/studio/annotations/registry.ts +159 -0
- package/apps/studio/annotations/replica.ts +264 -0
- package/apps/studio/annotations/scene.ts +235 -0
- package/apps/studio/annotations/schema.ts +188 -0
- package/apps/studio/annotations/types.ts +77 -0
- package/apps/studio/annotations/ui/board.ts +126 -0
- package/apps/studio/annotations/ui/containment.ts +118 -0
- package/apps/studio/annotations/ui/edit-actions.ts +551 -0
- package/apps/studio/annotations/ui/editor-channel.ts +62 -0
- package/apps/studio/annotations/ui/element-node.tsx +993 -0
- package/apps/studio/annotations/ui/pipeline-context.ts +17 -0
- package/apps/studio/annotations/ui/pointer-pipeline.ts +150 -0
- package/apps/studio/annotations/ui/render-model.ts +264 -0
- package/apps/studio/annotations/ui/scene.tsx +63 -0
- package/apps/studio/annotations/ui/text-editor.tsx +420 -0
- package/apps/studio/annotations/ui/text-session.ts +140 -0
- package/apps/studio/annotations/ui/text-style.ts +111 -0
- package/apps/studio/annotations/ui/world.ts +47 -0
- package/apps/studio/annotations/v1-adapter.ts +428 -0
- package/apps/studio/annotations-align.ts +21 -6
- package/apps/studio/annotations-bindings.ts +2 -0
- package/apps/studio/annotations-context-toolbar.tsx +9 -13
- package/apps/studio/annotations-groups.ts +3 -0
- package/apps/studio/annotations-layer.tsx +923 -1925
- package/apps/studio/annotations-model.ts +65 -4
- package/apps/studio/annotations-sync.ts +4 -47
- package/apps/studio/api.ts +249 -69
- package/apps/studio/bin/_import-figma.mjs +22 -12
- package/apps/studio/bin/annotate.mjs +331 -838
- package/apps/studio/bin/annotate.sh +4 -4
- package/apps/studio/bin/perf.sh +21 -7
- package/apps/studio/bin/read-annotations.mjs +184 -666
- package/apps/studio/bin/read-annotations.sh +9 -5
- package/apps/studio/canvas-artifacts.ts +9 -0
- package/apps/studio/canvas-lib.tsx +13 -1
- package/apps/studio/canvas-shell.tsx +6 -0
- package/apps/studio/client/app.jsx +98 -35
- package/apps/studio/client/hmr.mjs +1 -1
- package/apps/studio/client/panels/git-grouping.js +2 -2
- package/apps/studio/client/tree-expansion.js +217 -0
- package/apps/studio/collab/index.ts +49 -5
- package/apps/studio/collab/persistence.ts +31 -10
- package/apps/studio/collab/registry.ts +64 -26
- package/apps/studio/commands/annotation-ops-command.ts +72 -0
- package/apps/studio/cursors-overlay.tsx +158 -4
- package/apps/studio/dist/client.bundle.js +850 -850
- package/apps/studio/dist/comment-mount.js +2 -2
- package/apps/studio/figma/to-strokes.ts +31 -10
- package/apps/studio/git/endpoints.ts +1 -1
- package/apps/studio/git/service.ts +1 -1
- package/apps/studio/git/watch.ts +1 -1
- package/apps/studio/http.ts +90 -15
- package/apps/studio/server.ts +16 -0
- package/apps/studio/sync/accepted-cold-start.ts +12 -4
- package/apps/studio/sync/agent.ts +8 -4
- package/apps/studio/sync/codec.ts +96 -40
- package/apps/studio/sync/file-membership.ts +12 -2
- package/apps/studio/sync/file-plane.ts +1 -1
- package/apps/studio/sync/index.ts +38 -16
- package/apps/studio/sync/journal-client.ts +5 -0
- package/apps/studio/sync/limits.ts +7 -2
- package/apps/studio/sync/migrate-seed.ts +8 -5
- package/apps/studio/sync/projection.ts +7 -2
- package/apps/studio/sync/remote-docs.ts +34 -0
- package/apps/studio/sync/writer-registry.ts +10 -0
- package/apps/studio/text-caret.ts +11 -2
- package/apps/studio/tree-state.ts +45 -0
- package/apps/studio/undo-stack.ts +2 -2
- package/apps/studio/use-annotation-resize.tsx +48 -23
- package/apps/studio/use-annotation-selection.tsx +9 -2
- package/apps/studio/use-collab.tsx +76 -0
- package/apps/studio/whats-new.json +27 -0
- package/cli/lib/design-link.mjs +5 -1
- package/cli/lib/gitignore-block.mjs +1 -1
- package/cli/lib/gitignore-drift.mjs +2 -1
- package/package.json +9 -8
- package/plugins/design/templates/brief-board.tsx.template +1 -1
- package/apps/studio/annotation-edit-base.ts +0 -36
- package/apps/studio/commands/annotation-strokes-command.ts +0 -137
|
@@ -391,3 +391,37 @@ export function resolvePulledTarget(args: {
|
|
|
391
391
|
}
|
|
392
392
|
return { bodyAbs, fromPath };
|
|
393
393
|
}
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* The listing as an accepted-revisions project states it.
|
|
397
|
+
*
|
|
398
|
+
* In accepted mode the MANIFEST names the live canvases: an open Yjs room is
|
|
399
|
+
* not necessarily an accepted canvas (its author can connect before
|
|
400
|
+
* `doc.create` commits), so the documents come from the manifest, with the
|
|
401
|
+
* transport listing's byte counts where it has them.
|
|
402
|
+
*
|
|
403
|
+
* The same holds for DELETIONS. The transport's tombstones outlive the legacy
|
|
404
|
+
* era: a canvas deleted then and later re-created under the same name through
|
|
405
|
+
* `doc.create` stays tombstoned in the document store while the manifest lists
|
|
406
|
+
* it live — and every peer that honoured the tombstone skipped a live canvas
|
|
407
|
+
* (the web showed it, the desktop never pulled it). A tombstone for a name the
|
|
408
|
+
* manifest lists live is stale and dropped; one for a name the manifest
|
|
409
|
+
* retired, or does not carry, still stands. A peer's OWN just-made deletion is
|
|
410
|
+
* not affected: it is remembered locally and never passes through here.
|
|
411
|
+
*/
|
|
412
|
+
export function acceptedListing(
|
|
413
|
+
listing: RemoteListing | null,
|
|
414
|
+
manifestDocs: readonly { doc: string; retired: boolean }[]
|
|
415
|
+
): RemoteListing {
|
|
416
|
+
const bytesByName = new Map((listing?.documents ?? []).map((d) => [d.name, d.bytes]));
|
|
417
|
+
const documents = manifestDocs
|
|
418
|
+
.filter((d) => !d.retired)
|
|
419
|
+
.map((d) => ({ name: d.doc, bytes: bytesByName.get(d.doc) ?? 1 }));
|
|
420
|
+
const live = new Set(
|
|
421
|
+
manifestDocs.filter((d) => !d.retired).map((d) => slugFromDocName(d.doc) ?? d.doc)
|
|
422
|
+
);
|
|
423
|
+
const tombstones = (listing?.tombstones ?? []).filter(
|
|
424
|
+
(t) => !live.has(slugFromDocName(t.name) ?? t.name)
|
|
425
|
+
);
|
|
426
|
+
return { documents, tombstones };
|
|
427
|
+
}
|
|
@@ -79,6 +79,15 @@ export const WRITER_REGISTRY: Record<string, WriterEntry> = {
|
|
|
79
79
|
via: 'onAnnotationsChanged hook → annotations lane.replace with the base the layer sent',
|
|
80
80
|
test: 'test/sync-accepted-projection.test.ts',
|
|
81
81
|
},
|
|
82
|
+
// DDR-242 §4 — the canvas's op batches. Same lane, same hook: the api applies
|
|
83
|
+
// the ops to the board, then fires onAnnotationsChanged with the pre-batch
|
|
84
|
+
// board as base (the kernel's `annotations.apply` replaces this in Task 12).
|
|
85
|
+
'/_api/annotations/ops': {
|
|
86
|
+
class: 'lane',
|
|
87
|
+
rows: 'S23',
|
|
88
|
+
via: 'onAnnotationsChanged hook → annotations lane.replace with the pre-batch board as base',
|
|
89
|
+
test: 'test/sync-accepted-projection.test.ts',
|
|
90
|
+
},
|
|
82
91
|
// --- accepted history (T27/T28)
|
|
83
92
|
'/_api/project/history': { class: 'read' },
|
|
84
93
|
'/_api/project/restore': {
|
|
@@ -230,6 +239,7 @@ export const WRITER_REGISTRY: Record<string, WriterEntry> = {
|
|
|
230
239
|
'/_api/sync/settings': { class: 'local' },
|
|
231
240
|
'/_api/sync/trash': { class: 'local', rows: 'X15 (restore re-enters through cold start)' },
|
|
232
241
|
'/_api/timeline-media': { class: 'local' },
|
|
242
|
+
'/_api/tree-state': { class: 'local' },
|
|
233
243
|
'/_api/ui-prefs': { class: 'local' },
|
|
234
244
|
'/_api/workspace/disclosure': { class: 'local' },
|
|
235
245
|
'/_api/workspace/sign-in': { class: 'local' },
|
|
@@ -146,7 +146,7 @@ interface CaretRect {
|
|
|
146
146
|
* should hide (no collapsed selection inside the editable / editable not
|
|
147
147
|
* focused / a range selection is active, where the native highlight is the
|
|
148
148
|
* affordance). */
|
|
149
|
-
function caretRectFor(editable: HTMLElement, win: Window): CaretRect | null {
|
|
149
|
+
function caretRectFor(editable: HTMLElement, win: Window): CaretRect | 'native' | null {
|
|
150
150
|
const doc = editable.ownerDocument;
|
|
151
151
|
const active = doc.activeElement;
|
|
152
152
|
if (active !== editable && !editable.contains(active)) return null;
|
|
@@ -165,6 +165,13 @@ function caretRectFor(editable: HTMLElement, win: Window): CaretRect | null {
|
|
|
165
165
|
if (br && br.height > 0) r = br;
|
|
166
166
|
}
|
|
167
167
|
if (r && r.height > 0) return { x: r.left, y: r.top, h: r.height };
|
|
168
|
+
// A caret on a line with no text box yet — Shift+Enter's "\n" puts it at
|
|
169
|
+
// the start of an empty last line — has no client rect in Chromium or
|
|
170
|
+
// WebKit. Guessing a position from the editable's box painted it back at
|
|
171
|
+
// the top-left of line ONE (the "Shift+Enter throws the cursor somewhere
|
|
172
|
+
// weird" report). Only the engine knows where that caret is, so hand the
|
|
173
|
+
// caret back to it: the native caret shows until a rect exists again.
|
|
174
|
+
if ((editable.textContent ?? '').replace(/\u200b/g, '') !== '') return 'native';
|
|
168
175
|
// Empty editable — no rect exists anywhere. Derive a caret box from the
|
|
169
176
|
// editable's padding box + line metrics. (The annotation editors avoid this
|
|
170
177
|
// path by mounting with a zero-width-space JUMP_SENTINEL, but the artboard
|
|
@@ -221,7 +228,9 @@ export function mountCaret(editable: HTMLElement, win: Window): () => void {
|
|
|
221
228
|
|
|
222
229
|
const position = (): void => {
|
|
223
230
|
const rc = caretRectFor(editable, win);
|
|
224
|
-
|
|
231
|
+
// The engine's own caret, while ours can't be placed (see caretRectFor).
|
|
232
|
+
editable.style.caretColor = rc === 'native' ? prevCaretColor || 'auto' : 'transparent';
|
|
233
|
+
if (!rc || rc === 'native') {
|
|
225
234
|
if (shown) {
|
|
226
235
|
caret.style.display = 'none';
|
|
227
236
|
shown = false;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// tree-state.ts — the Files panel's remembered disclosure (issue #124).
|
|
2
|
+
//
|
|
3
|
+
// `dirs` are the repo-relative folder paths the user left expanded (the same
|
|
4
|
+
// `dirPath` the tree rows carry); `sections` maps a section label (PROJECT /
|
|
5
|
+
// UI CANVASES / …) to open/closed. Anything absent is CLOSED — that default is
|
|
6
|
+
// the point of the issue. Stored per project + per member by api.ts under
|
|
7
|
+
// `_canvas-state/`; this module only owns the shape and its bounds, so a
|
|
8
|
+
// hostile or corrupt body can neither grow the file without limit nor smuggle
|
|
9
|
+
// a non-string into a path the client later splits.
|
|
10
|
+
|
|
11
|
+
export interface TreeState {
|
|
12
|
+
dirs: string[];
|
|
13
|
+
sections: Record<string, boolean>;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export const TREE_STATE_MAX_DIRS = 2000;
|
|
17
|
+
export const TREE_STATE_MAX_PATH = 1024;
|
|
18
|
+
export const TREE_STATE_MAX_SECTIONS = 64;
|
|
19
|
+
const MAX_LABEL = 128;
|
|
20
|
+
|
|
21
|
+
/** Coerce anything into a bounded, de-duplicated TreeState. Never throws. */
|
|
22
|
+
export function normalizeTreeState(raw: unknown): TreeState {
|
|
23
|
+
const o =
|
|
24
|
+
raw && typeof raw === 'object' && !Array.isArray(raw) ? (raw as Record<string, unknown>) : {};
|
|
25
|
+
const dirs = new Set<string>();
|
|
26
|
+
if (Array.isArray(o.dirs)) {
|
|
27
|
+
for (const d of o.dirs) {
|
|
28
|
+
if (dirs.size >= TREE_STATE_MAX_DIRS) break;
|
|
29
|
+
if (typeof d !== 'string' || !d || d.length > TREE_STATE_MAX_PATH || d.includes('\0'))
|
|
30
|
+
continue;
|
|
31
|
+
dirs.add(d);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
const sections: Record<string, boolean> = {};
|
|
35
|
+
if (o.sections && typeof o.sections === 'object' && !Array.isArray(o.sections)) {
|
|
36
|
+
let n = 0;
|
|
37
|
+
for (const [k, v] of Object.entries(o.sections as Record<string, unknown>)) {
|
|
38
|
+
if (n >= TREE_STATE_MAX_SECTIONS) break;
|
|
39
|
+
if (typeof v !== 'boolean' || !k || k.length > MAX_LABEL || k === '__proto__') continue;
|
|
40
|
+
sections[k] = v;
|
|
41
|
+
n++;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return { dirs: [...dirs].sort(), sections };
|
|
45
|
+
}
|
|
@@ -69,8 +69,8 @@ export interface CommandRecord<P = unknown> {
|
|
|
69
69
|
export interface CommandSinks {
|
|
70
70
|
/** Wired by canvas-lib `DesignCanvasInner`. */
|
|
71
71
|
layoutPatchFn?: unknown;
|
|
72
|
-
/** Wired by `AnnotationsLayer
|
|
73
|
-
|
|
72
|
+
/** Wired by `AnnotationsLayer` (DDR-242): apply + send an annotation op batch. */
|
|
73
|
+
annotationOpsFn?: unknown;
|
|
74
74
|
/**
|
|
75
75
|
* Wired by `CanvasShell` (canvas-shell.tsx). Applies a single inline source
|
|
76
76
|
* edit (CSS / text / attr) by posting `dgn:'apply-edit'` to the parent shell,
|
|
@@ -18,10 +18,13 @@
|
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
20
|
import { type ReactNode, useCallback, useEffect, useMemo, useRef } from 'react';
|
|
21
|
+
import { defOf } from './annotations/registry.ts';
|
|
22
|
+
import { useAnnotationPipeline } from './annotations/ui/pipeline-context.ts';
|
|
21
23
|
import { anchorPoint, BIND_THRESHOLD_PX, bindCandidate } from './annotations-bindings.ts';
|
|
22
24
|
import {
|
|
23
25
|
type ArrowStroke,
|
|
24
26
|
canRotate,
|
|
27
|
+
type ElementStroke,
|
|
25
28
|
type EllipseStroke,
|
|
26
29
|
HALO_PAD_PX,
|
|
27
30
|
type ImageStroke,
|
|
@@ -141,6 +144,7 @@ export const padDY = (c: Corner): number =>
|
|
|
141
144
|
function isResizable(
|
|
142
145
|
s: Stroke
|
|
143
146
|
): s is
|
|
147
|
+
| ElementStroke
|
|
144
148
|
| RectStroke
|
|
145
149
|
| EllipseStroke
|
|
146
150
|
| PolygonStroke
|
|
@@ -150,6 +154,10 @@ function isResizable(
|
|
|
150
154
|
| ImageStroke
|
|
151
155
|
| LinkStroke
|
|
152
156
|
| SectionStroke {
|
|
157
|
+
if (s.tool === 'element') {
|
|
158
|
+
const caps = defOf(s.el.type)?.caps;
|
|
159
|
+
return !!caps?.box && caps.resizable;
|
|
160
|
+
}
|
|
153
161
|
return (
|
|
154
162
|
s.tool === 'rect' ||
|
|
155
163
|
s.tool === 'ellipse' ||
|
|
@@ -386,6 +394,15 @@ export function resizeStroke(
|
|
|
386
394
|
const box = bboxResizeRotAware(start, b0, corner, wx, wy, mods, start.tool === 'sticky');
|
|
387
395
|
return box as Partial<RectStroke | StickyStroke | PolygonStroke | LinkStroke>;
|
|
388
396
|
}
|
|
397
|
+
if (start.tool === 'element') {
|
|
398
|
+
// A registry type: fit its box, the fields come from its definition.
|
|
399
|
+
const def = defOf(start.el.type);
|
|
400
|
+
const b0 = strokeBBox(start);
|
|
401
|
+
if (!def || !b0) return null;
|
|
402
|
+
const box = bboxResize(b0, corner, wx, wy, mods, !!def.caps.aspectLock);
|
|
403
|
+
const ctx = { origin: { x: 0, y: 0 }, resolve: () => null };
|
|
404
|
+
return { el: { ...start.el, ...def.resize(start.el, box, ctx) } } as Partial<Stroke>;
|
|
405
|
+
}
|
|
389
406
|
if (start.tool === 'image') {
|
|
390
407
|
// Phase 23 — images aspect-LOCK by default and free-resize with Shift held
|
|
391
408
|
// (the inverse of the shape tools — Figma/FigJam image behaviour). Invert
|
|
@@ -560,6 +577,7 @@ export function AnnotationResizeOverlay({ store }: { store: StrokesStoreValue |
|
|
|
560
577
|
const annotSel = useAnnotationSelection();
|
|
561
578
|
const controller = useViewportControllerContext();
|
|
562
579
|
const vp = controller?.viewport ?? null;
|
|
580
|
+
const pipeline = useAnnotationPipeline();
|
|
563
581
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
|
564
582
|
const rafRef = useRef<number | null>(null);
|
|
565
583
|
const dragRef = useRef<{
|
|
@@ -725,6 +743,17 @@ export function AnnotationResizeOverlay({ store }: { store: StrokesStoreValue |
|
|
|
725
743
|
} catch {
|
|
726
744
|
/* some browsers reject capture on synthetic events */
|
|
727
745
|
}
|
|
746
|
+
// The layer's pointer pipeline owns the moves and the release (Task 21);
|
|
747
|
+
// mounted without a layer (tests), the overlay listens itself.
|
|
748
|
+
if (pipeline) {
|
|
749
|
+
const pointerId = e.pointerId;
|
|
750
|
+
pipeline.begin(pointerId, {
|
|
751
|
+
kind: isRotCorner(corner) ? 'rotating' : 'resizing',
|
|
752
|
+
move: onMove,
|
|
753
|
+
up: onUp,
|
|
754
|
+
cancel: (ev) => onUp(ev ?? ({ pointerId } as PointerEvent)),
|
|
755
|
+
});
|
|
756
|
+
}
|
|
728
757
|
};
|
|
729
758
|
const applyResize = (
|
|
730
759
|
clientX: number,
|
|
@@ -810,19 +839,15 @@ export function AnnotationResizeOverlay({ store }: { store: StrokesStoreValue |
|
|
|
810
839
|
matchIds.push(bestH.id);
|
|
811
840
|
}
|
|
812
841
|
}
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
matchIds,
|
|
818
|
-
},
|
|
819
|
-
})
|
|
820
|
-
);
|
|
842
|
+
store.setResizeInfo?.({
|
|
843
|
+
box: { x: patch.x, y: patch.y, w: patch.w, h: patch.h },
|
|
844
|
+
matchIds,
|
|
845
|
+
});
|
|
821
846
|
}
|
|
822
847
|
// FigJam v3 — dragging an arrow ENDPOINT re-anchors its bind: within the
|
|
823
848
|
// magnet threshold of a bindable host the endpoint snaps + binds; free
|
|
824
849
|
// space (or ⌘ held — Figma "suppress snap") clears the bind. The layer
|
|
825
|
-
// paints the candidate halo
|
|
850
|
+
// paints the candidate halo (store.setBindHint).
|
|
826
851
|
if (d.startStroke.tool === 'arrow' && (d.corner === 'ep1' || d.corner === 'ep2')) {
|
|
827
852
|
const zoom = vp?.zoom || 1;
|
|
828
853
|
const cand = suppressBind
|
|
@@ -852,9 +877,7 @@ export function AnnotationResizeOverlay({ store }: { store: StrokesStoreValue |
|
|
|
852
877
|
[d.corner === 'ep1' ? 'startBind' : 'endBind']: undefined,
|
|
853
878
|
} as Partial<Stroke>;
|
|
854
879
|
}
|
|
855
|
-
|
|
856
|
-
new CustomEvent('maude:bind-hint', { detail: { hostId: cand?.hostId ?? null } })
|
|
857
|
-
);
|
|
880
|
+
store.setBindHint?.(cand?.hostId ?? null);
|
|
858
881
|
}
|
|
859
882
|
// Wave H — transient preview tick: no undo record, no PUT. The gesture
|
|
860
883
|
// commits ONCE on pointerup (undo used to walk every resize pixel).
|
|
@@ -898,26 +921,28 @@ export function AnnotationResizeOverlay({ store }: { store: StrokesStoreValue |
|
|
|
898
921
|
);
|
|
899
922
|
// Clear the bind-hint halo + size label regardless of what was dragged
|
|
900
923
|
// (no-ops when nothing was hinted).
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
new CustomEvent('maude:resize-info', { detail: { box: null, matchIds: [] } })
|
|
904
|
-
);
|
|
924
|
+
store?.setBindHint?.(null);
|
|
925
|
+
store?.setResizeInfo?.(null);
|
|
905
926
|
};
|
|
906
927
|
c.addEventListener('pointerdown', onDown);
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
928
|
+
if (!pipeline) {
|
|
929
|
+
document.addEventListener('pointermove', onMove);
|
|
930
|
+
document.addEventListener('pointerup', onUp);
|
|
931
|
+
document.addEventListener('pointercancel', onUp);
|
|
932
|
+
}
|
|
910
933
|
document.addEventListener('keydown', onKey, true);
|
|
911
934
|
document.addEventListener('keyup', onKey, true);
|
|
912
935
|
return () => {
|
|
913
936
|
c.removeEventListener('pointerdown', onDown);
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
937
|
+
if (!pipeline) {
|
|
938
|
+
document.removeEventListener('pointermove', onMove);
|
|
939
|
+
document.removeEventListener('pointerup', onUp);
|
|
940
|
+
document.removeEventListener('pointercancel', onUp);
|
|
941
|
+
}
|
|
917
942
|
document.removeEventListener('keydown', onKey, true);
|
|
918
943
|
document.removeEventListener('keyup', onKey, true);
|
|
919
944
|
};
|
|
920
|
-
}, [selectedStroke, store, screenToWorld, vp]);
|
|
945
|
+
}, [selectedStroke, store, screenToWorld, vp, pipeline]);
|
|
921
946
|
|
|
922
947
|
// Only render when there's exactly one resizable stroke selected. Multi
|
|
923
948
|
// resize is undefined for v1 (no canonical UX); text inherits anchor bbox.
|
|
@@ -24,6 +24,8 @@ export interface AnnotationSelectionValue {
|
|
|
24
24
|
selectedIds: string[];
|
|
25
25
|
replace: (id: string | string[]) => void;
|
|
26
26
|
add: (id: string | string[]) => void;
|
|
27
|
+
/** Drop ids from the selection (shift-click on a selected element). */
|
|
28
|
+
remove: (id: string | string[]) => void;
|
|
27
29
|
toggle: (id: string) => void;
|
|
28
30
|
clear: () => void;
|
|
29
31
|
contains: (id: string) => boolean;
|
|
@@ -55,6 +57,11 @@ export function AnnotationSelectionProvider({ children }: { children: ReactNode
|
|
|
55
57
|
setSelectedIds((prev) => dedupe([...prev, ...incoming]));
|
|
56
58
|
}, []);
|
|
57
59
|
|
|
60
|
+
const remove = useCallback((id: string | string[]) => {
|
|
61
|
+
const drop = new Set(Array.isArray(id) ? id : [id]);
|
|
62
|
+
setSelectedIds((prev) => prev.filter((x) => !drop.has(x)));
|
|
63
|
+
}, []);
|
|
64
|
+
|
|
58
65
|
const toggle = useCallback((id: string) => {
|
|
59
66
|
setSelectedIds((prev) => (prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]));
|
|
60
67
|
}, []);
|
|
@@ -76,8 +83,8 @@ export function AnnotationSelectionProvider({ children }: { children: ReactNode
|
|
|
76
83
|
const contains = useCallback((id: string) => containsRef.current.includes(id), []);
|
|
77
84
|
|
|
78
85
|
const value = useMemo<AnnotationSelectionValue>(
|
|
79
|
-
() => ({ selectedIds, replace, add, toggle, clear, contains }),
|
|
80
|
-
[selectedIds, replace, add, toggle, clear, contains]
|
|
86
|
+
() => ({ selectedIds, replace, add, remove, toggle, clear, contains }),
|
|
87
|
+
[selectedIds, replace, add, remove, toggle, clear, contains]
|
|
81
88
|
);
|
|
82
89
|
|
|
83
90
|
return (
|
|
@@ -106,6 +106,12 @@ export interface CollabAwarenessState {
|
|
|
106
106
|
* annotation-shaped is selected.
|
|
107
107
|
*/
|
|
108
108
|
annotationSelection: string[];
|
|
109
|
+
/**
|
|
110
|
+
* DDR-242 AD5 / Task 22 — an annotation gesture in flight (drag, resize,
|
|
111
|
+
* draw), so peers see it move live. It never touches storage: the op
|
|
112
|
+
* commits once at gesture end, and this goes back to null.
|
|
113
|
+
*/
|
|
114
|
+
annotationGesture?: AnnotationGesture | null;
|
|
109
115
|
viewport: { x: number; y: number; zoom: number };
|
|
110
116
|
/**
|
|
111
117
|
* Soft editing-presence (Phase 30). Set while THIS peer (a human editing via
|
|
@@ -130,6 +136,20 @@ export interface CollabAwarenessState {
|
|
|
130
136
|
|
|
131
137
|
export type ForeignAwareness = Omit<CollabAwarenessState, '__connId'> & { clientID: number };
|
|
132
138
|
|
|
139
|
+
/** A live annotation gesture, in WORLD coordinates. */
|
|
140
|
+
export interface AnnotationGesture {
|
|
141
|
+
kind: 'move' | 'resize' | 'draw';
|
|
142
|
+
/** Elements the gesture moves / resizes (empty while drawing a new one). */
|
|
143
|
+
ids: string[];
|
|
144
|
+
/** move: the drag offset. */
|
|
145
|
+
dx?: number;
|
|
146
|
+
dy?: number;
|
|
147
|
+
/** resize: the box being dragged out; draw: a shape / sticky / section being drawn. */
|
|
148
|
+
box?: { x: number; y: number; w: number; h: number };
|
|
149
|
+
/** draw: pen ink so far, flat [x0,y0,x1,y1,…] (the latest part, capped). */
|
|
150
|
+
points?: number[];
|
|
151
|
+
}
|
|
152
|
+
|
|
133
153
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
134
154
|
// Untrusted-input sanitization at the awareness trust boundary.
|
|
135
155
|
//
|
|
@@ -214,6 +234,58 @@ function sanitizeCursor(raw: unknown): { x: number; y: number } | null {
|
|
|
214
234
|
return isFiniteNum(c.x) && isFiniteNum(c.y) ? { x: c.x, y: c.y } : null;
|
|
215
235
|
}
|
|
216
236
|
|
|
237
|
+
const GESTURE_KINDS = new Set(['move', 'resize', 'draw']);
|
|
238
|
+
const MAX_GESTURE_POINTS = 512; // numbers, i.e. 256 points
|
|
239
|
+
const GESTURE_COORD_LIMIT = 1e6;
|
|
240
|
+
|
|
241
|
+
function gestureNum(v: unknown): number | undefined {
|
|
242
|
+
if (typeof v !== 'number' || !Number.isFinite(v)) return undefined;
|
|
243
|
+
return Math.max(-GESTURE_COORD_LIMIT, Math.min(GESTURE_COORD_LIMIT, v));
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Task 22 — a peer's gesture preview is untrusted like every other awareness
|
|
248
|
+
* field: kind allowlisted, ids through the same token + count gate as the
|
|
249
|
+
* selection, every number finite and clamped, pen points capped.
|
|
250
|
+
*/
|
|
251
|
+
export function sanitizeAnnotationGesture(raw: unknown): AnnotationGesture | null {
|
|
252
|
+
if (!raw || typeof raw !== 'object') return null;
|
|
253
|
+
const g = raw as Record<string, unknown>;
|
|
254
|
+
if (typeof g.kind !== 'string' || !GESTURE_KINDS.has(g.kind)) return null;
|
|
255
|
+
const out: AnnotationGesture = {
|
|
256
|
+
kind: g.kind as AnnotationGesture['kind'],
|
|
257
|
+
ids: sanitizeAnnotationSelection(g.ids),
|
|
258
|
+
};
|
|
259
|
+
const dx = gestureNum(g.dx);
|
|
260
|
+
const dy = gestureNum(g.dy);
|
|
261
|
+
if (dx !== undefined && dy !== undefined) {
|
|
262
|
+
out.dx = dx;
|
|
263
|
+
out.dy = dy;
|
|
264
|
+
}
|
|
265
|
+
const b = g.box as Record<string, unknown> | undefined;
|
|
266
|
+
if (b && typeof b === 'object') {
|
|
267
|
+
const x = gestureNum(b.x);
|
|
268
|
+
const y = gestureNum(b.y);
|
|
269
|
+
const w = gestureNum(b.w);
|
|
270
|
+
const h = gestureNum(b.h);
|
|
271
|
+
if (x !== undefined && y !== undefined && w !== undefined && h !== undefined) {
|
|
272
|
+
out.box = { x, y, w: Math.abs(w), h: Math.abs(h) };
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
if (Array.isArray(g.points)) {
|
|
276
|
+
const pts: number[] = [];
|
|
277
|
+
const src = g.points.slice(-MAX_GESTURE_POINTS);
|
|
278
|
+
for (const v of src) {
|
|
279
|
+
const n = gestureNum(v);
|
|
280
|
+
if (n === undefined) break;
|
|
281
|
+
pts.push(n);
|
|
282
|
+
}
|
|
283
|
+
if (pts.length % 2 === 1) pts.pop();
|
|
284
|
+
if (pts.length) out.points = pts;
|
|
285
|
+
}
|
|
286
|
+
return out;
|
|
287
|
+
}
|
|
288
|
+
|
|
217
289
|
function sanitizeViewport(raw: unknown): { x: number; y: number; zoom: number } {
|
|
218
290
|
const fallback = { x: 0, y: 0, zoom: 1 };
|
|
219
291
|
if (!raw || typeof raw !== 'object') return fallback;
|
|
@@ -284,6 +356,7 @@ export function sanitizeForeignState(clientID: number, state: unknown): ForeignA
|
|
|
284
356
|
cursor: sanitizeCursor(s.cursor),
|
|
285
357
|
selection: sanitizeSelection(s.selection),
|
|
286
358
|
annotationSelection: sanitizeAnnotationSelection(s.annotationSelection),
|
|
359
|
+
annotationGesture: sanitizeAnnotationGesture(s.annotationGesture),
|
|
287
360
|
viewport: sanitizeViewport(s.viewport),
|
|
288
361
|
editing: sanitizeEditingState(s.editing),
|
|
289
362
|
};
|
|
@@ -586,6 +659,7 @@ function createSession(slug: string): CollabSession {
|
|
|
586
659
|
cursor: cur.cursor ?? null,
|
|
587
660
|
selection: cur.selection ?? null,
|
|
588
661
|
annotationSelection: cur.annotationSelection ?? [],
|
|
662
|
+
annotationGesture: null,
|
|
589
663
|
viewport: cur.viewport ?? { x: 0, y: 0, zoom: 1 },
|
|
590
664
|
editing: cur.editing ?? null,
|
|
591
665
|
__connId: connId,
|
|
@@ -894,6 +968,8 @@ export function CollabProvider({ slug, children }: CollabProviderProps): JSX.Ele
|
|
|
894
968
|
cursor: current.cursor ?? null,
|
|
895
969
|
selection: current.selection ?? null,
|
|
896
970
|
annotationSelection: current.annotationSelection ?? [],
|
|
971
|
+
// Kept across other fields' publishes (a cursor move mid-drag).
|
|
972
|
+
annotationGesture: current.annotationGesture ?? null,
|
|
897
973
|
viewport: current.viewport ?? { x: 0, y: 0, zoom: 1 },
|
|
898
974
|
editing: current.editing ?? null,
|
|
899
975
|
__connId: myConnId,
|
|
@@ -1,6 +1,33 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "./whats-new.schema.json",
|
|
3
3
|
"entries": [
|
|
4
|
+
{
|
|
5
|
+
"id": "whiteboard-text-sections-live-previews",
|
|
6
|
+
"version": "1.5.0",
|
|
7
|
+
"date": "2026-09-30",
|
|
8
|
+
"kind": "improvement",
|
|
9
|
+
"title": "Smoother whiteboard editing, live together",
|
|
10
|
+
"summary": "Typing in stickies and shapes now wraps exactly as it will look, works with any input method (accents, Japanese), and is never lost — it reaches teammates within a second, and if someone deletes the note you are typing in, Enter puts it back with your text. Sections move, copy and delete together with everything in them, teammates see your drag or resize live before you let go, and undo only takes back your own changes.",
|
|
11
|
+
"surface": "design-ui"
|
|
12
|
+
},
|
|
13
|
+
{
|
|
14
|
+
"id": "whiteboard-edits-sync-per-element",
|
|
15
|
+
"version": "1.5.0",
|
|
16
|
+
"date": "2026-09-30",
|
|
17
|
+
"kind": "improvement",
|
|
18
|
+
"title": "Whiteboard edits no longer overwrite each other",
|
|
19
|
+
"summary": "Each sticky, shape and arrow now saves on its own, so two people editing the same board keep both changes, and even text typed into the same sticky at once is merged. Existing boards convert automatically on start, and editing text is steadier: the cursor stays on a new line after Shift+Enter, double-clicking a word selects it, and Shift+click removes an item from a selection.",
|
|
20
|
+
"surface": "design-ui"
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
"id": "file-tree-remembers-folders",
|
|
24
|
+
"version": "1.5.0",
|
|
25
|
+
"date": "2026-09-30",
|
|
26
|
+
"kind": "improvement",
|
|
27
|
+
"title": "The file tree remembers your folders",
|
|
28
|
+
"summary": "The Files panel now starts collapsed and keeps the folders you open, even after you switch panels, reload or restart the app. Opening a canvas unfolds the way to it.",
|
|
29
|
+
"surface": "design-ui"
|
|
30
|
+
},
|
|
4
31
|
{
|
|
5
32
|
"id": "comments-stay-where-you-put-them",
|
|
6
33
|
"version": "1.4.6",
|
package/cli/lib/design-link.mjs
CHANGED
|
@@ -809,7 +809,11 @@ function collectAdoptManifest(cwd) {
|
|
|
809
809
|
const files = [];
|
|
810
810
|
try {
|
|
811
811
|
for (const f of readdirSync(base)) {
|
|
812
|
-
if (
|
|
812
|
+
if (
|
|
813
|
+
f.endsWith('.html') ||
|
|
814
|
+
f.endsWith('.annotations.json') ||
|
|
815
|
+
f.endsWith('.annotations.svg')
|
|
816
|
+
) {
|
|
813
817
|
try {
|
|
814
818
|
files.push({ rel: `.design/${f}`, bytes: statSync(resolve(base, f)).size });
|
|
815
819
|
} catch {
|
|
@@ -25,7 +25,7 @@ export function buildBlock(designRel = '.design', { s3Assets = false } = {}) {
|
|
|
25
25
|
// The canonical IGNORED set is the DDR-115 runtime-state taxonomy — kept in
|
|
26
26
|
// lockstep with `apps/studio/git/service.ts` (isMaudeRuntimeState backstop)
|
|
27
27
|
// and the repo's own `.gitignore`. VERSIONED content (canvases, `.meta.json`,
|
|
28
|
-
// `*.annotations.svg
|
|
28
|
+
// `*.annotations.json` (v1 `.svg`), `system/**`, `config.json`) is deliberately absent.
|
|
29
29
|
const lines = [
|
|
30
30
|
BEGIN_MARKER,
|
|
31
31
|
'# Maude design plugin runtime — gitignored even in linked mode (DDR-056/DDR-115).',
|
|
@@ -37,7 +37,8 @@
|
|
|
37
37
|
* `gitignore-block.mjs` and the taxonomy in `apps/studio/git/service.ts`.
|
|
38
38
|
*/
|
|
39
39
|
const VERSIONED_PATTERNS = [
|
|
40
|
-
{ pattern: '*.annotations.
|
|
40
|
+
{ pattern: '*.annotations.json', what: 'canvas draw layers (annotations)' },
|
|
41
|
+
{ pattern: '*.annotations.svg', what: 'canvas draw layers (annotations, v1 form)' },
|
|
41
42
|
{ pattern: '*.meta.json', what: 'canvas sidecars (layout, artboards, tags)' },
|
|
42
43
|
{ pattern: '*.tsx', what: 'canvas bodies' },
|
|
43
44
|
{ pattern: 'config.json', what: 'the project design config' },
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@1agh/maude",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.5.1",
|
|
4
4
|
"description": "Marketplace of Claude Code plugins by Michal Dovrtěl: `design` (canvas-first design iteration) + `flow` (generic agentic workflow loop with .ai second brain). Ships the `maude` CLI (with `mdcc` legacy alias) to scaffold workspace, run the design dev server, and manage configs.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"engines": {
|
|
@@ -58,13 +58,13 @@
|
|
|
58
58
|
"test:e2e:desktop:parity:cloud": "pnpm --filter @maude/desktop-e2e e2e:parity:cloud"
|
|
59
59
|
},
|
|
60
60
|
"optionalDependencies": {
|
|
61
|
-
"@1agh/maude-darwin-arm64": "1.
|
|
62
|
-
"@1agh/maude-darwin-x64": "1.
|
|
63
|
-
"@1agh/maude-linux-arm64": "1.
|
|
64
|
-
"@1agh/maude-linux-arm64-musl": "1.
|
|
65
|
-
"@1agh/maude-linux-x64": "1.
|
|
66
|
-
"@1agh/maude-linux-x64-musl": "1.
|
|
67
|
-
"@1agh/maude-win32-x64": "1.
|
|
61
|
+
"@1agh/maude-darwin-arm64": "1.5.1",
|
|
62
|
+
"@1agh/maude-darwin-x64": "1.5.1",
|
|
63
|
+
"@1agh/maude-linux-arm64": "1.5.1",
|
|
64
|
+
"@1agh/maude-linux-arm64-musl": "1.5.1",
|
|
65
|
+
"@1agh/maude-linux-x64": "1.5.1",
|
|
66
|
+
"@1agh/maude-linux-x64-musl": "1.5.1",
|
|
67
|
+
"@1agh/maude-win32-x64": "1.5.1"
|
|
68
68
|
},
|
|
69
69
|
"files": [
|
|
70
70
|
"cli",
|
|
@@ -131,6 +131,7 @@
|
|
|
131
131
|
"@decimalturn/toml-patch": "3.0.5",
|
|
132
132
|
"ajv": "^8.20.0",
|
|
133
133
|
"ajv-formats": "^3.0.1",
|
|
134
|
+
"diff": "8.0.4",
|
|
134
135
|
"happy-dom": "20.10.6",
|
|
135
136
|
"svgo": "^4.0.1",
|
|
136
137
|
"yaml": "2.9.0"
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
* a VERBATIM brief and inserts the matching generated artboards right here, below
|
|
14
14
|
* this frame — the board becomes a living brief you sketch and Claude fills in.
|
|
15
15
|
*
|
|
16
|
-
* The annotation layer lives in a sibling `<slug>.annotations.
|
|
16
|
+
* The annotation layer lives in a sibling `<slug>.annotations.json` (NEVER in this
|
|
17
17
|
* file), so your notes stay floating on top across every regeneration. No design
|
|
18
18
|
* system tokens or classes are wired in — the board is a neutral surface; the
|
|
19
19
|
* generated artboards bring the DS with them on ingest.
|
|
@@ -1,36 +0,0 @@
|
|
|
1
|
-
// Which SVG an annotation edit names as its base (DDR-241 accepted revisions).
|
|
2
|
-
//
|
|
3
|
-
// The project merges an annotation proposal element by element against the
|
|
4
|
-
// text it was derived from. The canvas used to send the CANONICAL
|
|
5
|
-
// re-serialization of the strokes it held — fine while the stored text is
|
|
6
|
-
// canonical too, and silently fatal when it is not: an element written by an
|
|
7
|
-
// older serializer, a hand edit or any other writer then looks changed on BOTH
|
|
8
|
-
// sides, the merge refuses it as a conflict, and the person's edit is reverted
|
|
9
|
-
// without a word (plan T31, L09 arrowheads on a non-canonical arrow).
|
|
10
|
-
//
|
|
11
|
-
// An edit made on exactly the state this canvas last RECEIVED names the text
|
|
12
|
-
// it received — the project then sees "nothing changed underneath", accepts
|
|
13
|
-
// the edit whole, and the stored text becomes canonical as a side effect. Any
|
|
14
|
-
// other state (edits stacked before the echo returns) is this canvas's own
|
|
15
|
-
// canonical output, which is what the project holds after accepting it.
|
|
16
|
-
|
|
17
|
-
import { type Stroke, strokesToSvg } from './annotations-model.ts';
|
|
18
|
-
|
|
19
|
-
export interface ReceivedAnnotations {
|
|
20
|
-
/** The SVG text exactly as the project delivered it. */
|
|
21
|
-
raw: string;
|
|
22
|
-
/** The same state as this client serializes it. */
|
|
23
|
-
canon: string;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
export function receivedAnnotations(raw: string, strokes: readonly Stroke[]): ReceivedAnnotations {
|
|
27
|
-
return { raw, canon: strokesToSvg(strokes) };
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
export function annotationEditBase(
|
|
31
|
-
before: readonly Stroke[],
|
|
32
|
-
received: ReceivedAnnotations | null
|
|
33
|
-
): string {
|
|
34
|
-
const canon = strokesToSvg(before);
|
|
35
|
-
return received && received.canon === canon ? received.raw : canon;
|
|
36
|
-
}
|