@1agh/maude 0.60.4 → 0.60.6
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-layer.tsx +21 -12
- package/apps/studio/api.ts +54 -5
- package/apps/studio/build.ts +10 -0
- package/apps/studio/canvas-comment-mount.tsx +7 -1
- package/apps/studio/canvas-icons.tsx +30 -2
- package/apps/studio/canvas-lib.tsx +41 -6
- package/apps/studio/canvas-shell.tsx +39 -43
- package/apps/studio/client/app.jsx +20 -13
- package/apps/studio/dist/client.bundle.js +1 -1
- package/apps/studio/dist/comment-mount.js +2 -2
- package/apps/studio/dom-selection.ts +67 -0
- package/apps/studio/hmr-broadcast.ts +40 -2
- package/apps/studio/input-router.tsx +26 -2
- package/apps/studio/sync/agent.ts +64 -13
- package/apps/studio/sync/codec.ts +47 -0
- package/apps/studio/sync/cold-start.ts +96 -0
- package/apps/studio/sync/migrate-seed.ts +52 -7
- package/apps/studio/sync/projection.ts +10 -1
- package/apps/studio/test/canvas-meta-api.test.ts +114 -3
- package/apps/studio/test/canvas-wheel-delta.test.ts +42 -0
- package/apps/studio/test/canvas-zoom-floor.test.ts +73 -0
- package/apps/studio/test/comment-mount.test.ts +10 -1
- package/apps/studio/test/dom-selection.test.ts +69 -1
- package/apps/studio/test/hmr-broadcast.test.ts +6 -4
- package/apps/studio/test/hmr-classify.test.ts +18 -1
- package/apps/studio/test/input-router.test.ts +69 -0
- package/apps/studio/test/mode-toggle.test.tsx +226 -0
- package/apps/studio/test/sync-annotations-cold-start.test.ts +302 -0
- package/apps/studio/test/use-tool-mode.test.tsx +33 -3
- package/apps/studio/tool-palette.tsx +86 -24
- package/apps/studio/use-tool-mode.tsx +113 -14
- package/apps/studio/whats-new.json +45 -0
- package/package.json +8 -8
- package/plugins/design/templates/_shell.html +31 -0
|
@@ -956,7 +956,7 @@ function scaleStrokeInGroup(
|
|
|
956
956
|
export function AnnotationsLayer() {
|
|
957
957
|
countRender('annotationRenders');
|
|
958
958
|
ensureAnnotStyles();
|
|
959
|
-
const { tool, setTool, sticky, tools, shapeKind } = useToolMode();
|
|
959
|
+
const { tool, setTool, resetTool, sticky, tools, shapeKind } = useToolMode();
|
|
960
960
|
const theme = useCanvasChromeTheme();
|
|
961
961
|
const controller = useViewportControllerContext();
|
|
962
962
|
const vp = controller?.viewport ?? null;
|
|
@@ -2008,7 +2008,9 @@ export function AnnotationsLayer() {
|
|
|
2008
2008
|
setEditCaretPoint({ x: e.clientX, y: e.clientY });
|
|
2009
2009
|
setEditingId(strokeId);
|
|
2010
2010
|
if (annotSel) annotSel.replace(strokeId);
|
|
2011
|
-
|
|
2011
|
+
// DDR-223 — back to the MODE's resting tool (move in edit, browse in
|
|
2012
|
+
// preview): editing a text annotation shouldn't exit preview.
|
|
2013
|
+
resetTool();
|
|
2012
2014
|
return true;
|
|
2013
2015
|
}
|
|
2014
2016
|
const editableTarget = findEditableElementAt(e.clientX, e.clientY);
|
|
@@ -2020,7 +2022,7 @@ export function AnnotationsLayer() {
|
|
|
2020
2022
|
showCanvasToast(
|
|
2021
2023
|
'This text is filled in from code (a variable) — edit it via chat or /design:edit.'
|
|
2022
2024
|
);
|
|
2023
|
-
|
|
2025
|
+
resetTool();
|
|
2024
2026
|
return true;
|
|
2025
2027
|
}
|
|
2026
2028
|
document.dispatchEvent(
|
|
@@ -2028,6 +2030,9 @@ export function AnnotationsLayer() {
|
|
|
2028
2030
|
detail: { el: editableTarget, clientX: e.clientX, clientY: e.clientY },
|
|
2029
2031
|
})
|
|
2030
2032
|
);
|
|
2033
|
+
// Deliberately `move`, not `resetTool` — editing ARTBOARD content is
|
|
2034
|
+
// an edit-mode activity, so this one flips to edit even from preview
|
|
2035
|
+
// (DDR-223 mode⇄tool sync).
|
|
2031
2036
|
setTool('move');
|
|
2032
2037
|
return true;
|
|
2033
2038
|
}
|
|
@@ -2037,7 +2042,7 @@ export function AnnotationsLayer() {
|
|
|
2037
2042
|
setPendingText({ x: wx, y: wy });
|
|
2038
2043
|
if (annotSel) annotSel.clear();
|
|
2039
2044
|
const stickyOnText = sticky.locked && sticky.tool === 'text';
|
|
2040
|
-
if (!stickyOnText)
|
|
2045
|
+
if (!stickyOnText) resetTool();
|
|
2041
2046
|
return true;
|
|
2042
2047
|
}
|
|
2043
2048
|
return true;
|
|
@@ -2061,6 +2066,7 @@ export function AnnotationsLayer() {
|
|
|
2061
2066
|
annotSel,
|
|
2062
2067
|
sticky,
|
|
2063
2068
|
setTool,
|
|
2069
|
+
resetTool,
|
|
2064
2070
|
]
|
|
2065
2071
|
);
|
|
2066
2072
|
|
|
@@ -2209,21 +2215,24 @@ export function AnnotationsLayer() {
|
|
|
2209
2215
|
showOnceHint('chain', '⌘Enter commits and creates the next one beside it.');
|
|
2210
2216
|
}
|
|
2211
2217
|
}
|
|
2212
|
-
// T18 / T19 — flip the tool back to
|
|
2213
|
-
// mode is locked on this tool
|
|
2214
|
-
//
|
|
2215
|
-
//
|
|
2216
|
-
//
|
|
2217
|
-
//
|
|
2218
|
+
// T18 / T19 — flip the tool back to the MODE's resting tool after every
|
|
2219
|
+
// commit UNLESS sticky mode is locked on this tool (DDR-223: move in edit
|
|
2220
|
+
// — byte-identical to the old hardcode — browse in preview, so drawing an
|
|
2221
|
+
// annotation never silently exits the alive posture). Sticky lets the user
|
|
2222
|
+
// draw many shapes in a row (canonical pattern: tldraw double-click to
|
|
2223
|
+
// lock). Eraser stays armed by default — that tool is destructive, not
|
|
2224
|
+
// constructive. Map a highlighter pen (a 'pen' stroke with the flag) back
|
|
2225
|
+
// to the 'highlighter' tool id so its sticky-lock check matches the
|
|
2226
|
+
// active tool.
|
|
2218
2227
|
const toolJustUsed = cur.tool === 'pen' && cur.highlighter ? 'highlighter' : cur.tool;
|
|
2219
2228
|
if (toolJustUsed !== 'eraser') {
|
|
2220
2229
|
const stickyOnThis = sticky.locked && sticky.tool === toolJustUsed;
|
|
2221
|
-
if (!stickyOnThis)
|
|
2230
|
+
if (!stickyOnThis) resetTool();
|
|
2222
2231
|
}
|
|
2223
2232
|
drawAnchorRef.current = null;
|
|
2224
2233
|
lastDrawPointRef.current = null;
|
|
2225
2234
|
setDrawing(null);
|
|
2226
|
-
}, [isActive, isErase, visible, commitStrokes, annotSel,
|
|
2235
|
+
}, [isActive, isErase, visible, commitStrokes, annotSel, resetTool, sticky]);
|
|
2227
2236
|
|
|
2228
2237
|
// T21 — abort a mid-stroke draw without committing. Dispatched by the
|
|
2229
2238
|
// canvas-shell Esc handler (`maude:cancel-stroke`). Safe to call when
|
package/apps/studio/api.ts
CHANGED
|
@@ -1499,11 +1499,14 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
1499
1499
|
// shared `.meta.json` document. It lives in `_canvas-state/<slug>.view.json`
|
|
1500
1500
|
// ({ viewport }) — gitignored, swept on delete, export-excluded. DISTINCT from
|
|
1501
1501
|
// the legacy `_canvas-state/<slug>.json` ({ sections, viewport:{x,y,scale} })
|
|
1502
|
-
// store: that uses `scale` clamped 0.05–8, this uses `zoom` clamped 0.
|
|
1502
|
+
// store: that uses `scale` clamped 0.05–8, this uses `zoom` clamped 0.02–4, so
|
|
1503
1503
|
// overloading one file would let the two writers clobber each other's shape.
|
|
1504
1504
|
|
|
1505
|
-
/** Validate a candidate viewport — finite x/y, zoom clamped [0.
|
|
1506
|
-
* Phase 4 rule).
|
|
1505
|
+
/** Validate a candidate viewport — finite x/y, zoom clamped [0.02, 4] (the
|
|
1506
|
+
* Phase 4 rule). Mirrors `ZOOM_MIN` in canvas-lib.tsx (issue #91) — keep the
|
|
1507
|
+
* two floors in lockstep or a save/reload round-trip silently re-clamps a
|
|
1508
|
+
* zoom the client just let the user reach. Returns the normalized viewport,
|
|
1509
|
+
* or null when invalid. */
|
|
1507
1510
|
function normalizeViewport(v: unknown): { x: number; y: number; zoom: number } | null {
|
|
1508
1511
|
if (!v || typeof v !== 'object' || Array.isArray(v)) return null;
|
|
1509
1512
|
const vv = v as { x?: unknown; y?: unknown; zoom?: unknown };
|
|
@@ -1512,7 +1515,7 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
1512
1515
|
Number.isFinite(vv.y as number) &&
|
|
1513
1516
|
Number.isFinite(vv.zoom as number)
|
|
1514
1517
|
) {
|
|
1515
|
-
const zoom = Math.min(4, Math.max(0.
|
|
1518
|
+
const zoom = Math.min(4, Math.max(0.02, vv.zoom as number));
|
|
1516
1519
|
return { x: vv.x as number, y: vv.y as number, zoom };
|
|
1517
1520
|
}
|
|
1518
1521
|
return null;
|
|
@@ -1571,6 +1574,41 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
1571
1574
|
return [...new Set(out)];
|
|
1572
1575
|
}
|
|
1573
1576
|
|
|
1577
|
+
// Issue #91 security follow-up — `layout.artboards[]` (DDR-027 position-only
|
|
1578
|
+
// entries) is reachable from the untrusted canvas origin (DDR-054) via the
|
|
1579
|
+
// same PATCH lane as overlays/locked above, but previously had no count or
|
|
1580
|
+
// magnitude bound. `fit()`/`computeFit` (canvas-lib.tsx) now correctly frames
|
|
1581
|
+
// whatever bounding box this describes — before the zoom-floor fix, a hard
|
|
1582
|
+
// 0.1 zoom clamp accidentally masked an unbounded layout into a cropped,
|
|
1583
|
+
// mismatched view; after it, an oversized synced layout can legitimately be
|
|
1584
|
+
// framed whole, promoting every artboard in it to its own GPU layer at once
|
|
1585
|
+
// (canvas-lib.tsx `content-visibility` comment). Cap count + coordinate
|
|
1586
|
+
// magnitude the same way `normalizeOverlays`/`normalizeLocked` cap theirs.
|
|
1587
|
+
const MAX_LAYOUT_ARTBOARDS = 2000;
|
|
1588
|
+
const MAX_LAYOUT_COORD = 1_000_000;
|
|
1589
|
+
|
|
1590
|
+
function normalizeLayoutArtboards(
|
|
1591
|
+
v: unknown
|
|
1592
|
+
): Array<{ id: string; x: number; y: number }> | null {
|
|
1593
|
+
if (!Array.isArray(v) || v.length > MAX_LAYOUT_ARTBOARDS) return null;
|
|
1594
|
+
const out: Array<{ id: string; x: number; y: number }> = [];
|
|
1595
|
+
for (const entry of v) {
|
|
1596
|
+
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) return null;
|
|
1597
|
+
const e = entry as { id?: unknown; x?: unknown; y?: unknown };
|
|
1598
|
+
if (typeof e.id !== 'string' || e.id.length === 0 || e.id.length > 128) return null;
|
|
1599
|
+
if (
|
|
1600
|
+
!Number.isFinite(e.x as number) ||
|
|
1601
|
+
!Number.isFinite(e.y as number) ||
|
|
1602
|
+
Math.abs(e.x as number) > MAX_LAYOUT_COORD ||
|
|
1603
|
+
Math.abs(e.y as number) > MAX_LAYOUT_COORD
|
|
1604
|
+
) {
|
|
1605
|
+
return null;
|
|
1606
|
+
}
|
|
1607
|
+
out.push({ id: e.id, x: e.x as number, y: e.y as number });
|
|
1608
|
+
}
|
|
1609
|
+
return out;
|
|
1610
|
+
}
|
|
1611
|
+
|
|
1574
1612
|
/** Raw view-file contents, tolerant of a missing/corrupt file (→ `{}`) — the
|
|
1575
1613
|
* read half of the read-modify-write both `saveCanvasView` and
|
|
1576
1614
|
* `saveCanvasOverlays` need so writing one lane never clobbers the other. */
|
|
@@ -1819,7 +1857,18 @@ export function createApi(ctx: Context, hooks: ApiHooks): Api {
|
|
|
1819
1857
|
if (patch.layout === null) {
|
|
1820
1858
|
next.layout = undefined;
|
|
1821
1859
|
} else if (typeof patch.layout === 'object' && !Array.isArray(patch.layout)) {
|
|
1822
|
-
|
|
1860
|
+
const rawLayout = patch.layout as Record<string, unknown>;
|
|
1861
|
+
if ('artboards' in rawLayout) {
|
|
1862
|
+
// Bounded — see normalizeLayoutArtboards. An oversized/malformed
|
|
1863
|
+
// `artboards` array is a silent no-op (leaves the prior persisted
|
|
1864
|
+
// layout in place), matching the overlays/locked lanes' convention.
|
|
1865
|
+
const artboards = normalizeLayoutArtboards(rawLayout.artboards);
|
|
1866
|
+
if (artboards !== null) {
|
|
1867
|
+
next.layout = { ...rawLayout, artboards };
|
|
1868
|
+
}
|
|
1869
|
+
} else {
|
|
1870
|
+
next.layout = patch.layout;
|
|
1871
|
+
}
|
|
1823
1872
|
}
|
|
1824
1873
|
// Defensive: a stale inline viewport must never persist in the versioned
|
|
1825
1874
|
// file (the camera lane owns it now). JSON.stringify drops undefined keys.
|
package/apps/studio/build.ts
CHANGED
|
@@ -353,6 +353,16 @@ export function writeCompileEntry(target: PlatformTarget): string {
|
|
|
353
353
|
// Side-effect module: must be imported BEFORE any oxc-parser usage.
|
|
354
354
|
import bindingPath from ${JSON.stringify(bindingSpec)} with { type: 'file' };
|
|
355
355
|
process.env.NAPI_RS_NATIVE_LIBRARY_PATH = bindingPath;
|
|
356
|
+
// DDR-177 — a compiled binary must be self-contained: every runtime-spawned
|
|
357
|
+
// child (canvas-build worker, asset-sweep worker) resolves its JS runtime via
|
|
358
|
+
// resolveBunPath() = MAUDE_BUN_PATH || 'bun'. The desktop app launches this
|
|
359
|
+
// binary with a GUI PATH that has no 'bun', and NOTHING set MAUDE_BUN_PATH —
|
|
360
|
+
// so the asset sweep died with "Executable not found in $PATH" on machines
|
|
361
|
+
// without a user-installed bun. Default it to THIS binary: workerEnv() adds
|
|
362
|
+
// BUN_BE_BUN=1 when MAUDE_BUN_PATH === process.execPath, so children re-enter
|
|
363
|
+
// the sidecar as a plain JS runtime. An explicit launcher-set value (the
|
|
364
|
+
// cell's studio-child.mjs) still wins.
|
|
365
|
+
process.env.MAUDE_BUN_PATH ||= process.execPath;
|
|
356
366
|
`;
|
|
357
367
|
writeFileSync(initPath, initContent);
|
|
358
368
|
|
|
@@ -496,7 +496,13 @@ export interface MountCanvasOptions {
|
|
|
496
496
|
function buildCanvasTree(Canvas: ComponentType, file: string | undefined): ReactNode {
|
|
497
497
|
return createElement(
|
|
498
498
|
MaybeToolProvider,
|
|
499
|
-
|
|
499
|
+
// DDR-223 — this layer's provider instance keeps the DDR-187 posture:
|
|
500
|
+
// bare DS specimens stay ALIVE with native cursors (there is no palette,
|
|
501
|
+
// no editing, nothing to boot into edit for). On a UI canvas the inner
|
|
502
|
+
// canvas-lib ToolProvider (a separate bundle, separate context) owns the
|
|
503
|
+
// real boot posture (edit/`move`); this ancestor instance only carries the
|
|
504
|
+
// comment tool + claim layer and converges on the first tool keydown.
|
|
505
|
+
{ initial: 'browse' as const },
|
|
500
506
|
createElement(
|
|
501
507
|
MaybeSelectionSetProvider,
|
|
502
508
|
null,
|
|
@@ -52,6 +52,31 @@ export function IconBrowse(props: IconProps) {
|
|
|
52
52
|
);
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
+
// DDR-223 addendum 2 — the Preview/Edit/Present toggle glyphs (owner steer
|
|
56
|
+
// 2026-08-15): lucide `eye` + `pencil-ruler` (ISC), redrawn in the house
|
|
57
|
+
// 24 × 24 / 1.75-stroke language. Mode segments deliberately do NOT reuse tool
|
|
58
|
+
// glyphs — a mode is a way of looking at the canvas, not a recalled tool.
|
|
59
|
+
export function IconEye(props: IconProps) {
|
|
60
|
+
return (
|
|
61
|
+
<Svg {...props}>
|
|
62
|
+
<path d="M2.062 12.348a1 1 0 0 1 0-.696 10.75 10.75 0 0 1 19.876 0 1 1 0 0 1 0 .696 10.75 10.75 0 0 1-19.876 0" />
|
|
63
|
+
<circle cx="12" cy="12" r="3" />
|
|
64
|
+
</Svg>
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
export function IconPencilRuler(props: IconProps) {
|
|
68
|
+
return (
|
|
69
|
+
<Svg {...props}>
|
|
70
|
+
<path d="M13 7 8.7 2.7a2.41 2.41 0 0 0-3.4 0L2.7 5.3a2.41 2.41 0 0 0 0 3.4L7 13" />
|
|
71
|
+
<path d="m8 6 2-2" />
|
|
72
|
+
<path d="m18 16 2-2" />
|
|
73
|
+
<path d="m17 11 4.3 4.3c.94.94.94 2.46 0 3.4l-2.6 2.6c-.94.94-2.46.94-3.4 0L11 17" />
|
|
74
|
+
<path d="M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z" />
|
|
75
|
+
<path d="m15 5 4 4" />
|
|
76
|
+
</Svg>
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
55
80
|
export function IconHand(props: IconProps) {
|
|
56
81
|
return (
|
|
57
82
|
<Svg {...props}>
|
|
@@ -146,11 +171,14 @@ export function IconLink(props: IconProps) {
|
|
|
146
171
|
);
|
|
147
172
|
}
|
|
148
173
|
|
|
174
|
+
// DDR-223 addendum 2 — redrawn to the lucide `presentation` glyph so the three
|
|
175
|
+
// mode-segment icons (eye / pencil-ruler / presentation) read as one family.
|
|
149
176
|
export function IconPresentation(props: IconProps) {
|
|
150
177
|
return (
|
|
151
178
|
<Svg {...props}>
|
|
152
|
-
<
|
|
153
|
-
<path d="
|
|
179
|
+
<path d="M2 3h20" />
|
|
180
|
+
<path d="M21 3v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V3" />
|
|
181
|
+
<path d="m7 21 5-5 5 5" />
|
|
154
182
|
</Svg>
|
|
155
183
|
);
|
|
156
184
|
}
|
|
@@ -214,7 +214,15 @@ export function AIPlaceholder({
|
|
|
214
214
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
215
215
|
// Module constants
|
|
216
216
|
|
|
217
|
-
|
|
217
|
+
// Issue #91 — was 0.1. A board spanning more world units than ~10x the
|
|
218
|
+
// viewport could never be framed whole: `fit()` and manual zoom-out both
|
|
219
|
+
// funnel through `clampZoom` (below), so the old floor blocked "fit to
|
|
220
|
+
// screen" from ever reaching the zoom a wide board actually needs. 0.02
|
|
221
|
+
// matches the interactive floor other canvas tools (Figma, tldraw) ship.
|
|
222
|
+
// Keep in lockstep with the mirror clamp in `api.ts`'s `normalizeViewport`
|
|
223
|
+
// (the per-machine camera persisted to `_canvas-state/<slug>.view.json`,
|
|
224
|
+
// DDR-115) — that one re-clamps on save/reload independently of this file.
|
|
225
|
+
const ZOOM_MIN = 0.02;
|
|
218
226
|
const ZOOM_MAX = 4.0;
|
|
219
227
|
const ZOOM_STEP_IN = 1.2;
|
|
220
228
|
const ZOOM_STEP_OUT = 1 / 1.2;
|
|
@@ -685,7 +693,9 @@ function synthDefaultGrid(seeds: ArtboardSeed[]): ArtboardRect[] {
|
|
|
685
693
|
});
|
|
686
694
|
}
|
|
687
695
|
|
|
688
|
-
|
|
696
|
+
// Exported for the same reason as `clampZoom` above — lets a unit test prove
|
|
697
|
+
// the raw fit computation for a wide board isn't what's clamping the zoom.
|
|
698
|
+
export function computeFit(rects: ArtboardRect[], hostEl: HTMLElement, pad = 24): ViewportState {
|
|
689
699
|
if (rects.length === 0) return { x: 0, y: 0, zoom: 1 };
|
|
690
700
|
let xMin = Number.POSITIVE_INFINITY;
|
|
691
701
|
let yMin = Number.POSITIVE_INFINITY;
|
|
@@ -907,7 +917,25 @@ export interface ViewportControllerHandle {
|
|
|
907
917
|
isInteracting: boolean;
|
|
908
918
|
}
|
|
909
919
|
|
|
910
|
-
|
|
920
|
+
// Issue #94 — wheel pan used raw WheelEvent deltas as CSS pixels, but the
|
|
921
|
+
// Wheel Events spec lets a browser report deltas in three different units
|
|
922
|
+
// (`deltaMode`): pixel (0, trackpads and most Chromium mice), line (1,
|
|
923
|
+
// Firefox's default for physical mice — one notch is a small integer like
|
|
924
|
+
// 3), or page (2). Consuming a line-mode `3` as 3px of pan per notch is why
|
|
925
|
+
// wheel-driven panning read as "very slow" while trackpad panning (already
|
|
926
|
+
// pixel-mode) felt fine. LINES_TO_PIXELS mirrors the pixels-per-line browsers
|
|
927
|
+
// themselves use for native line-scrolling at default font size.
|
|
928
|
+
const LINES_TO_PIXELS = 16;
|
|
929
|
+
export function wheelDeltaToPixels(delta: number, deltaMode: number, pageSize: number): number {
|
|
930
|
+
if (deltaMode === 1) return delta * LINES_TO_PIXELS; // DOM_DELTA_LINE
|
|
931
|
+
if (deltaMode === 2) return delta * pageSize; // DOM_DELTA_PAGE
|
|
932
|
+
return delta; // DOM_DELTA_PIXEL (0) — already pixels
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
// Exported (like `revealAxisDelta` in canvas-shell.tsx) so a pure-logic unit
|
|
936
|
+
// test can pin the zoom floor without booting the DOM/host-dependent
|
|
937
|
+
// viewport controller.
|
|
938
|
+
export function clampZoom(z: number): number {
|
|
911
939
|
if (!Number.isFinite(z)) return 1;
|
|
912
940
|
return Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, z));
|
|
913
941
|
}
|
|
@@ -1399,6 +1427,13 @@ export function useViewportController(opts: ViewportControllerOptions): Viewport
|
|
|
1399
1427
|
const rect = host.getBoundingClientRect();
|
|
1400
1428
|
const cx = e.clientX - rect.left;
|
|
1401
1429
|
const cy = e.clientY - rect.top;
|
|
1430
|
+
// Normalize line/page-mode deltas to pixels before anything below
|
|
1431
|
+
// reads them — see `wheelDeltaToPixels` (issue #94). Zoom's own
|
|
1432
|
+
// [-50, 50] clamp already absorbed this for the pinch/ctrl+wheel path,
|
|
1433
|
+
// but pan had no such clamp so a line-mode mouse wheel panned at only
|
|
1434
|
+
// a few px per notch.
|
|
1435
|
+
const dx = wheelDeltaToPixels(e.deltaX, e.deltaMode, host.clientWidth);
|
|
1436
|
+
const dy = wheelDeltaToPixels(e.deltaY, e.deltaMode, host.clientHeight);
|
|
1402
1437
|
// Mac trackpad pinch fires wheel with ctrlKey:true automatically, even
|
|
1403
1438
|
// without a physical Ctrl press — so the same branch covers both
|
|
1404
1439
|
// Ctrl+wheel (mouse) and pinch-zoom (trackpad).
|
|
@@ -1409,7 +1444,7 @@ export function useViewportController(opts: ViewportControllerOptions): Viewport
|
|
|
1409
1444
|
// notch = ±100) onto the same perceived-speed curve. Mouse-wheel
|
|
1410
1445
|
// users still get smooth zoom (clamped notches accumulate at the
|
|
1411
1446
|
// same exp rate), trackpad-pinch users no longer outpace them.
|
|
1412
|
-
const clamped = Math.max(-50, Math.min(50,
|
|
1447
|
+
const clamped = Math.max(-50, Math.min(50, dy));
|
|
1413
1448
|
const factor = Math.exp(-clamped * WHEEL_ZOOM_K);
|
|
1414
1449
|
zoomAt(factor, cx, cy);
|
|
1415
1450
|
return;
|
|
@@ -1419,14 +1454,14 @@ export function useViewportController(opts: ViewportControllerOptions): Viewport
|
|
|
1419
1454
|
// doesn't, Safari sometimes does); some don't. Read whichever axis
|
|
1420
1455
|
// actually carries energy so the gesture lands horizontally either way.
|
|
1421
1456
|
if (e.shiftKey) {
|
|
1422
|
-
const d = Math.abs(
|
|
1457
|
+
const d = Math.abs(dy) >= Math.abs(dx) ? dy : dx;
|
|
1423
1458
|
panBy(-d, 0);
|
|
1424
1459
|
return;
|
|
1425
1460
|
}
|
|
1426
1461
|
// Default: trackpad two-finger scroll → 2D pan. The negation keeps the
|
|
1427
1462
|
// "content follows your fingers" mapping (Mac natural scroll). Mouse
|
|
1428
1463
|
// wheels with only deltaY pan vertically.
|
|
1429
|
-
panBy(-
|
|
1464
|
+
panBy(-dx, -dy);
|
|
1430
1465
|
};
|
|
1431
1466
|
|
|
1432
1467
|
// Safari/WKWebView-only trackpad pinch. WebKit exposes the native pinch via
|
|
@@ -67,15 +67,13 @@ import {
|
|
|
67
67
|
import { ContextualToolbar } from './contextual-toolbar.tsx';
|
|
68
68
|
import { CursorsOverlay } from './cursors-overlay.tsx';
|
|
69
69
|
import {
|
|
70
|
+
buildComposeSelection,
|
|
70
71
|
cssPath,
|
|
71
72
|
deriveFile,
|
|
72
|
-
domPath,
|
|
73
73
|
hoverTargetToSelection,
|
|
74
|
+
openCommentComposer,
|
|
74
75
|
realClasses,
|
|
75
76
|
resolveSelectionEl,
|
|
76
|
-
scopedCdSelector,
|
|
77
|
-
selectorIndex,
|
|
78
|
-
shortText,
|
|
79
77
|
} from './dom-selection.ts';
|
|
80
78
|
import { setElementDragActive } from './drag-state.ts';
|
|
81
79
|
import { EqualSpacingHandles } from './equal-spacing-handles.tsx';
|
|
@@ -1378,33 +1376,17 @@ function buildRegistry(deps: {
|
|
|
1378
1376
|
});
|
|
1379
1377
|
};
|
|
1380
1378
|
|
|
1379
|
+
// issue-90 — right-click "Add comment" (element / artboard-chrome / world)
|
|
1380
|
+
// used to only postMessage the parent shell, which merely mirrors the
|
|
1381
|
+
// target for the StatusBar/sidebar; it never dispatched `cm:open-composer`,
|
|
1382
|
+
// the event `comments-overlay.tsx` actually listens on to open the
|
|
1383
|
+
// composer. Selecting the menu item did nothing. `openCommentComposer`
|
|
1384
|
+
// sends both, matching the working comment-tool-click path
|
|
1385
|
+
// (canvas-comment-mount.tsx's `dropComment`).
|
|
1381
1386
|
const postComposeForTarget = (target: ContextTarget): void => {
|
|
1382
1387
|
if (typeof window === 'undefined') return;
|
|
1383
|
-
const
|
|
1384
|
-
|
|
1385
|
-
: cssPath(target.el);
|
|
1386
|
-
const sel: Selection | null = target.el
|
|
1387
|
-
? {
|
|
1388
|
-
file: deriveFile(),
|
|
1389
|
-
id: target.cdId ?? undefined,
|
|
1390
|
-
selector: composeSelector,
|
|
1391
|
-
artboardId: target.artboardId,
|
|
1392
|
-
index: target.cdId ? selectorIndex(document, composeSelector, target.el) : 0,
|
|
1393
|
-
tag: target.el.tagName.toLowerCase(),
|
|
1394
|
-
classes: realClasses(target.el),
|
|
1395
|
-
text: shortText(target.el, 240),
|
|
1396
|
-
dom_path: domPath(target.el),
|
|
1397
|
-
bounds: (target.el as HTMLElement).getBoundingClientRect
|
|
1398
|
-
? boundsOf(target.el as HTMLElement)
|
|
1399
|
-
: null,
|
|
1400
|
-
html: (target.el.outerHTML ?? '').slice(0, 4000),
|
|
1401
|
-
}
|
|
1402
|
-
: null;
|
|
1403
|
-
try {
|
|
1404
|
-
window.parent.postMessage({ dgn: 'comment-compose', selection: sel }, '*');
|
|
1405
|
-
} catch {
|
|
1406
|
-
/* ignore */
|
|
1407
|
-
}
|
|
1388
|
+
const sel = buildComposeSelection(target, target.clientX, target.clientY);
|
|
1389
|
+
openCommentComposer(sel, target.clientX, target.clientY);
|
|
1408
1390
|
};
|
|
1409
1391
|
|
|
1410
1392
|
const fitItem: MenuItem = {
|
|
@@ -2141,6 +2123,18 @@ function buildRegistry(deps: {
|
|
|
2141
2123
|
],
|
|
2142
2124
|
],
|
|
2143
2125
|
'artboard-chrome': [
|
|
2126
|
+
[
|
|
2127
|
+
// issue-90 — right-click on an artboard's own label/border chrome
|
|
2128
|
+
// (outside its body, where `data-cd-id` never resolves) had no
|
|
2129
|
+
// "Add comment" entry at all; only a hit inside the body counted as
|
|
2130
|
+
// `element`. Anchors at the click point (no element under it).
|
|
2131
|
+
{
|
|
2132
|
+
id: 'add-comment',
|
|
2133
|
+
label: 'Add comment',
|
|
2134
|
+
shortcut: 'C',
|
|
2135
|
+
onSelect: postComposeForTarget,
|
|
2136
|
+
},
|
|
2137
|
+
],
|
|
2144
2138
|
[
|
|
2145
2139
|
{
|
|
2146
2140
|
id: 'fit-one',
|
|
@@ -2273,6 +2267,17 @@ function buildRegistry(deps: {
|
|
|
2273
2267
|
[exportItem('export-artboard', 'Export this artboard…', 'artboard')],
|
|
2274
2268
|
],
|
|
2275
2269
|
world: [
|
|
2270
|
+
// issue-90 — right-click on empty canvas background (no artboard, no
|
|
2271
|
+
// element) had no "Add comment" entry at all. Anchors at the click
|
|
2272
|
+
// point, same as the comment tool's own floating-pin fallback.
|
|
2273
|
+
[
|
|
2274
|
+
{
|
|
2275
|
+
id: 'add-comment',
|
|
2276
|
+
label: 'Add comment',
|
|
2277
|
+
shortcut: 'C',
|
|
2278
|
+
onSelect: postComposeForTarget,
|
|
2279
|
+
},
|
|
2280
|
+
],
|
|
2276
2281
|
[fitItem, resetItem],
|
|
2277
2282
|
[
|
|
2278
2283
|
exportItem('export-canvas', 'Export canvas as separate…', 'canvas-as-separate'),
|
|
@@ -2320,16 +2325,6 @@ function filterRegistryForReadOnly(registry: ContextRegistry): ContextRegistry {
|
|
|
2320
2325
|
return out;
|
|
2321
2326
|
}
|
|
2322
2327
|
|
|
2323
|
-
function boundsOf(el: HTMLElement) {
|
|
2324
|
-
const r = el.getBoundingClientRect();
|
|
2325
|
-
return {
|
|
2326
|
-
x: Math.round(r.left),
|
|
2327
|
-
y: Math.round(r.top),
|
|
2328
|
-
w: Math.round(r.width),
|
|
2329
|
-
h: Math.round(r.height),
|
|
2330
|
-
};
|
|
2331
|
-
}
|
|
2332
|
-
|
|
2333
2328
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
2334
2329
|
// Task A1 (feature-element-editing-robustness) — camera-aware reveal.
|
|
2335
2330
|
//
|
|
@@ -2438,7 +2433,7 @@ function CanvasRouter({
|
|
|
2438
2433
|
distributeArtboards: (axis: 'x' | 'y') => void;
|
|
2439
2434
|
alignArtboards: (mode: 'left' | 'right' | 'center-x' | 'top' | 'bottom' | 'center-y') => void;
|
|
2440
2435
|
}) {
|
|
2441
|
-
const { tool, setTool, clearSticky } = useToolMode();
|
|
2436
|
+
const { tool, setTool, resetTool, clearSticky } = useToolMode();
|
|
2442
2437
|
const selSet = useSelectionSet();
|
|
2443
2438
|
const annotSel = useAnnotationSelection();
|
|
2444
2439
|
const ctxMenu = useContextMenu();
|
|
@@ -3274,10 +3269,11 @@ function CanvasRouter({
|
|
|
3274
3269
|
} catch {
|
|
3275
3270
|
/* non-DOM env */
|
|
3276
3271
|
}
|
|
3277
|
-
// T19 — clear sticky-tool lock + flip back to
|
|
3278
|
-
//
|
|
3272
|
+
// T19 — clear sticky-tool lock + flip back to the mode's resting tool
|
|
3273
|
+
// (DDR-223: move in edit, browse in preview). Esc is the canonical
|
|
3274
|
+
// "back to default state" gesture across canvas apps.
|
|
3279
3275
|
clearSticky();
|
|
3280
|
-
|
|
3276
|
+
resetTool();
|
|
3281
3277
|
ctxMenu.close();
|
|
3282
3278
|
selSet.clear();
|
|
3283
3279
|
annotSel.clear();
|
|
@@ -370,16 +370,20 @@ function shellToast(message, ok = false, action) {
|
|
|
370
370
|
);
|
|
371
371
|
}
|
|
372
372
|
|
|
373
|
-
//
|
|
374
|
-
//
|
|
375
|
-
// Figma
|
|
376
|
-
//
|
|
377
|
-
// `
|
|
378
|
-
|
|
373
|
+
// DDR-223 (issue #93, supersedes DDR-187's boot half) — one-time first-run
|
|
374
|
+
// teaching hint. Authoring canvases now boot into EDIT (`move`/V armed): click
|
|
375
|
+
// selects like Figma, and the mock is inert until the Preview toggle flips the
|
|
376
|
+
// alive posture back on. Teach exactly that ONCE, gated by a NEW localStorage
|
|
377
|
+
// marker (`maude-mode-hint-seen` — deliberately not the old
|
|
378
|
+
// `maude-browse-hint-seen`, which taught the opposite posture; every existing
|
|
379
|
+
// user should see the new hint once). Read-only viewers still boot preview
|
|
380
|
+
// (alive) and keep the DDR-187 wording. Auto-dismisses; the taught gesture
|
|
381
|
+
// (V in read-only) clears it early.
|
|
382
|
+
const MODE_HINT_SEEN = 'maude-mode-hint-seen';
|
|
379
383
|
function browseFirstRunHint(readOnly = false) {
|
|
380
384
|
if (typeof document === 'undefined' || typeof localStorage === 'undefined') return;
|
|
381
385
|
try {
|
|
382
|
-
if (localStorage.getItem(
|
|
386
|
+
if (localStorage.getItem(MODE_HINT_SEEN) === '1') return;
|
|
383
387
|
} catch {
|
|
384
388
|
return;
|
|
385
389
|
}
|
|
@@ -393,18 +397,21 @@ function browseFirstRunHint(readOnly = false) {
|
|
|
393
397
|
'font:12px/1.5 var(--font-ui,system-ui,sans-serif);background:var(--surface-2,#1b1e24);' +
|
|
394
398
|
'color:var(--text-1,#e7eaf0);border:1px solid var(--border-1,#333a45);' +
|
|
395
399
|
'box-shadow:0 10px 34px rgba(0,0,0,.42);opacity:0;transition:opacity 160ms ease;';
|
|
400
|
+
const kbd =
|
|
401
|
+
'<kbd style="padding:1px 6px;border-radius:5px;border:1px solid var(--border-1,#333a45);' +
|
|
402
|
+
'background:var(--surface-3,#262b33);font-family:var(--font-mono,monospace)">V</kbd>';
|
|
396
403
|
el.innerHTML =
|
|
397
|
-
'<span>Your mock is <strong>live</strong> — click things to try it. ' +
|
|
398
|
-
'Press <kbd style="padding:1px 6px;border-radius:5px;border:1px solid var(--border-1,#333a45);' +
|
|
399
|
-
'background:var(--surface-3,#262b33);font-family:var(--font-mono,monospace)">V</kbd> ' +
|
|
400
404
|
// Cloud Phase 25 C2 — a viewer selects to inspect, never to edit; the
|
|
401
405
|
// hint must not promise an editor the role doesn't have.
|
|
402
|
-
(readOnly
|
|
406
|
+
(readOnly
|
|
407
|
+
? `<span>Your mock is <strong>live</strong> — click things to try it. Press ${kbd} to select & inspect.</span>`
|
|
408
|
+
: '<span>You’re in <strong>Edit</strong> — click selects, like Figma. ' +
|
|
409
|
+
'Switch to <strong>Preview</strong> in the toolbar to use the live mock.</span>') +
|
|
403
410
|
'<button type="button" aria-label="Dismiss" style="background:none;border:none;color:inherit;' +
|
|
404
411
|
'cursor:pointer;font-size:15px;line-height:1;opacity:.65;padding:2px">×</button>';
|
|
405
412
|
const dismiss = () => {
|
|
406
413
|
try {
|
|
407
|
-
localStorage.setItem(
|
|
414
|
+
localStorage.setItem(MODE_HINT_SEEN, '1');
|
|
408
415
|
} catch {
|
|
409
416
|
/* private mode */
|
|
410
417
|
}
|
|
@@ -416,7 +423,7 @@ function browseFirstRunHint(readOnly = false) {
|
|
|
416
423
|
if ((e.key === 'v' || e.key === 'V') && !e.metaKey && !e.ctrlKey && !e.altKey) dismiss();
|
|
417
424
|
};
|
|
418
425
|
el.querySelector('button')?.addEventListener('click', dismiss);
|
|
419
|
-
document.addEventListener('keydown', onV, true);
|
|
426
|
+
if (readOnly) document.addEventListener('keydown', onV, true);
|
|
420
427
|
document.body.appendChild(el);
|
|
421
428
|
requestAnimationFrame(() => {
|
|
422
429
|
el.style.opacity = '1';
|