@1agh/maude 0.40.0 → 0.41.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/apps/studio/ai-banner.tsx +2 -2
- package/apps/studio/annotations-layer.tsx +53 -2
- package/apps/studio/api.ts +504 -3
- package/apps/studio/artboard-marquee.tsx +1 -1
- package/apps/studio/bin/_canvas-rects-playwright.mjs +55 -0
- package/apps/studio/bin/_canvas-rects-static.mjs +166 -0
- package/apps/studio/bin/_fetch-asset.mjs +556 -0
- package/apps/studio/bin/annotate.mjs +576 -34
- package/apps/studio/bin/canvas-rects.sh +152 -0
- package/apps/studio/bin/fetch-asset.sh +34 -0
- package/apps/studio/bin/read-annotations.mjs +138 -7
- package/apps/studio/bin/smoke.sh +42 -6
- package/apps/studio/build.ts +21 -0
- package/apps/studio/canvas-comment-mount.tsx +138 -4
- package/apps/studio/canvas-edit.ts +744 -11
- package/apps/studio/canvas-lib.tsx +219 -2
- package/apps/studio/canvas-shell.tsx +487 -20
- package/apps/studio/client/app.jsx +1451 -22
- package/apps/studio/client/comments-overlay.css +130 -126
- package/apps/studio/client/github.js +8 -0
- package/apps/studio/client/styles/3-shell-maude.css +48 -0
- package/apps/studio/comments-overlay.tsx +148 -41
- package/apps/studio/context-menu.tsx +15 -5
- package/apps/studio/contextual-toolbar.tsx +262 -4
- package/apps/studio/cursors-overlay.tsx +4 -4
- package/apps/studio/dist/client.bundle.js +20 -20
- package/apps/studio/dist/comment-mount.js +59 -1
- package/apps/studio/dist/styles.css +1 -1
- package/apps/studio/dom-selection.ts +127 -1
- package/apps/studio/drag-state.ts +24 -0
- package/apps/studio/equal-spacing-detector.ts +205 -0
- package/apps/studio/export-dialog.tsx +1 -1
- package/apps/studio/history.ts +47 -1
- package/apps/studio/http.ts +223 -0
- package/apps/studio/input-router.tsx +12 -0
- package/apps/studio/marquee-overlay.tsx +1 -1
- package/apps/studio/measure-overlay.tsx +241 -0
- package/apps/studio/participants-chrome.tsx +3 -3
- package/apps/studio/sizing-mode.ts +117 -0
- package/apps/studio/spacing-handles.ts +166 -0
- package/apps/studio/test/annotate-write.test.ts +890 -0
- package/apps/studio/test/camera-reveal.test.tsx +173 -0
- package/apps/studio/test/canvas-edit.test.ts +50 -0
- package/apps/studio/test/canvas-origin-gate.test.ts +18 -0
- package/apps/studio/test/canvas-rects.test.ts +198 -0
- package/apps/studio/test/comments-overlay.test.ts +117 -0
- package/apps/studio/test/dom-selection.test.ts +130 -0
- package/apps/studio/test/edit-css-occurrence.test.ts +81 -0
- package/apps/studio/test/edit-scope-api.test.ts +115 -0
- package/apps/studio/test/element-resize.test.ts +136 -0
- package/apps/studio/test/element-structural-api.test.ts +360 -0
- package/apps/studio/test/element-structural-edit.test.ts +233 -0
- package/apps/studio/test/equal-spacing-detector.test.ts +165 -1
- package/apps/studio/test/history-rollback.test.ts +26 -0
- package/apps/studio/test/knob-props-authored.test.ts +87 -0
- package/apps/studio/test/read-annotations.test.ts +154 -0
- package/apps/studio/test/sizing-mode.test.ts +102 -0
- package/apps/studio/test/spacing-handles.test.ts +138 -0
- package/apps/studio/test/specimen-select.test.ts +88 -0
- package/apps/studio/test/tool-palette-insert-anchor.test.ts +84 -0
- package/apps/studio/test/undo-sequence-byte-compare.test.ts +211 -0
- package/apps/studio/tool-palette.tsx +122 -2
- package/apps/studio/undo-hud.tsx +2 -2
- package/apps/studio/use-element-resize.tsx +732 -0
- package/apps/studio/use-keyboard-discipline.tsx +205 -15
- package/apps/studio/use-selection-set.tsx +14 -0
- package/apps/studio/use-spacing-handles.tsx +388 -0
- package/apps/studio/whats-new.json +28 -0
- package/cli/commands/design.mjs +6 -1
- package/cli/commands/design.test.mjs +49 -1
- package/cli/lib/fetch-asset.test.mjs +213 -0
- package/package.json +8 -8
- package/plugins/design/dependencies.json +10 -2
package/apps/studio/http.ts
CHANGED
|
@@ -1409,6 +1409,7 @@ export function createHttp(ctx: Context, api: Api, inspect: Inspect, ai: AiActiv
|
|
|
1409
1409
|
property?: unknown;
|
|
1410
1410
|
value?: unknown;
|
|
1411
1411
|
reset?: unknown;
|
|
1412
|
+
idIndex?: unknown;
|
|
1412
1413
|
}>(req, 8 * 1024);
|
|
1413
1414
|
if (!body) return new Response('body required', { status: 400 });
|
|
1414
1415
|
const result = await api.editCss(body);
|
|
@@ -1780,6 +1781,228 @@ export function createHttp(ctx: Context, api: Api, inspect: Inspect, ai: AiActiv
|
|
|
1780
1781
|
);
|
|
1781
1782
|
},
|
|
1782
1783
|
|
|
1784
|
+
'/_api/delete-element': async (req: Request) => {
|
|
1785
|
+
// Stage I (feature-element-editing-robustness) — delete an element by
|
|
1786
|
+
// data-cd-id. POST { canvas, id, idIndex? } → api.deleteElementOp (reparse
|
|
1787
|
+
// gate; reused-component instance via idIndex). Logs a whole-file undo seq
|
|
1788
|
+
// (Cmd+Z via /_api/reorder-revert — a structural edit churns positional
|
|
1789
|
+
// ids so an inverse descriptor goes stale). Same MAIN-ORIGIN-ONLY trust
|
|
1790
|
+
// boundary as /_api/reorder: absent from CANVAS_SAFE_API + startCanvasServer
|
|
1791
|
+
// routes (DDR-054); sameOriginWrite CSRF + loopback-Host (DNS-rebinding) gated.
|
|
1792
|
+
if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
|
|
1793
|
+
if (!sameOriginWrite(req))
|
|
1794
|
+
return new Response('cross-origin write rejected', { status: 403 });
|
|
1795
|
+
if (!isLoopbackHost(req.headers.get('host')))
|
|
1796
|
+
return new Response('local request required (DNS-rebinding guard)', { status: 403 });
|
|
1797
|
+
const body = await readJson<{ canvas?: unknown; id?: unknown; idIndex?: unknown }>(
|
|
1798
|
+
req,
|
|
1799
|
+
8 * 1024
|
|
1800
|
+
);
|
|
1801
|
+
if (!body) return new Response('body required', { status: 400 });
|
|
1802
|
+
const result = await api.deleteElementOp(body);
|
|
1803
|
+
if (!result.ok) {
|
|
1804
|
+
return Response.json(
|
|
1805
|
+
{ ok: false, error: result.error },
|
|
1806
|
+
{ status: result.status, headers: { 'Cache-Control': 'no-store' } }
|
|
1807
|
+
);
|
|
1808
|
+
}
|
|
1809
|
+
return Response.json(
|
|
1810
|
+
{ ok: true, deletedId: result.deletedId, seq: result.seq },
|
|
1811
|
+
{ status: 200, headers: { 'Cache-Control': 'no-store' } }
|
|
1812
|
+
);
|
|
1813
|
+
},
|
|
1814
|
+
|
|
1815
|
+
'/_api/insert-element': async (req: Request) => {
|
|
1816
|
+
// Stage I — insert a synthesized div/text/image relative to `refId`. POST
|
|
1817
|
+
// { canvas, refId, position, kind, src?, refIndex? } → api.insertElementOp.
|
|
1818
|
+
// Returns the new element's post-transpile id so the shell can select it.
|
|
1819
|
+
// Whole-file undo seq. MAIN-ORIGIN ONLY (absent from both allowlists);
|
|
1820
|
+
// sameOriginWrite + loopback-Host gated. An `image` src is contained to
|
|
1821
|
+
// assets/ in the engine (no remote hotlink / ../ / scheme).
|
|
1822
|
+
if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
|
|
1823
|
+
if (!sameOriginWrite(req))
|
|
1824
|
+
return new Response('cross-origin write rejected', { status: 403 });
|
|
1825
|
+
if (!isLoopbackHost(req.headers.get('host')))
|
|
1826
|
+
return new Response('local request required (DNS-rebinding guard)', { status: 403 });
|
|
1827
|
+
const body = await readJson<{
|
|
1828
|
+
canvas?: unknown;
|
|
1829
|
+
refId?: unknown;
|
|
1830
|
+
position?: unknown;
|
|
1831
|
+
kind?: unknown;
|
|
1832
|
+
src?: unknown;
|
|
1833
|
+
refIndex?: unknown;
|
|
1834
|
+
}>(req, 8 * 1024);
|
|
1835
|
+
if (!body) return new Response('body required', { status: 400 });
|
|
1836
|
+
const result = await api.insertElementOp(body);
|
|
1837
|
+
if (!result.ok) {
|
|
1838
|
+
return Response.json(
|
|
1839
|
+
{ ok: false, error: result.error },
|
|
1840
|
+
{ status: result.status, headers: { 'Cache-Control': 'no-store' } }
|
|
1841
|
+
);
|
|
1842
|
+
}
|
|
1843
|
+
return Response.json(
|
|
1844
|
+
{ ok: true, newId: result.newId, seq: result.seq },
|
|
1845
|
+
{ status: 200, headers: { 'Cache-Control': 'no-store' } }
|
|
1846
|
+
);
|
|
1847
|
+
},
|
|
1848
|
+
|
|
1849
|
+
'/_api/duplicate-element': async (req: Request) => {
|
|
1850
|
+
// Cmd+D (Task L3) — duplicate an element (a copy as the next sibling). POST
|
|
1851
|
+
// { canvas, id, idIndex? } → api.duplicateElementOp. Whole-file undo seq.
|
|
1852
|
+
// MAIN-ORIGIN ONLY; sameOriginWrite + loopback-Host gated (dual-allowlist).
|
|
1853
|
+
if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
|
|
1854
|
+
if (!sameOriginWrite(req))
|
|
1855
|
+
return new Response('cross-origin write rejected', { status: 403 });
|
|
1856
|
+
if (!isLoopbackHost(req.headers.get('host')))
|
|
1857
|
+
return new Response('local request required (DNS-rebinding guard)', { status: 403 });
|
|
1858
|
+
const body = await readJson<{ canvas?: unknown; id?: unknown; idIndex?: unknown }>(
|
|
1859
|
+
req,
|
|
1860
|
+
8 * 1024
|
|
1861
|
+
);
|
|
1862
|
+
if (!body) return new Response('body required', { status: 400 });
|
|
1863
|
+
const result = await api.duplicateElementOp(body);
|
|
1864
|
+
if (!result.ok) {
|
|
1865
|
+
return Response.json(
|
|
1866
|
+
{ ok: false, error: result.error },
|
|
1867
|
+
{ status: result.status, headers: { 'Cache-Control': 'no-store' } }
|
|
1868
|
+
);
|
|
1869
|
+
}
|
|
1870
|
+
return Response.json(
|
|
1871
|
+
{ ok: true, newId: result.newId, seq: result.seq },
|
|
1872
|
+
{ status: 200, headers: { 'Cache-Control': 'no-store' } }
|
|
1873
|
+
);
|
|
1874
|
+
},
|
|
1875
|
+
|
|
1876
|
+
'/_api/insert-artboard': async (req: Request) => {
|
|
1877
|
+
// Stage I4 — insert a new EMPTY artboard from a screen-size preset. POST
|
|
1878
|
+
// { canvas, id, label, width, height } → api.insertArtboardOp (appends a
|
|
1879
|
+
// <DCArtboard id label width height></DCArtboard> after the last artboard;
|
|
1880
|
+
// size JSX-authoritative per DDR-027). Whole-file undo seq. MAIN-ORIGIN
|
|
1881
|
+
// ONLY (absent from both allowlists); sameOriginWrite + loopback-Host gated.
|
|
1882
|
+
if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
|
|
1883
|
+
if (!sameOriginWrite(req))
|
|
1884
|
+
return new Response('cross-origin write rejected', { status: 403 });
|
|
1885
|
+
if (!isLoopbackHost(req.headers.get('host')))
|
|
1886
|
+
return new Response('local request required (DNS-rebinding guard)', { status: 403 });
|
|
1887
|
+
const body = await readJson<{
|
|
1888
|
+
canvas?: unknown;
|
|
1889
|
+
id?: unknown;
|
|
1890
|
+
label?: unknown;
|
|
1891
|
+
width?: unknown;
|
|
1892
|
+
height?: unknown;
|
|
1893
|
+
}>(req, 8 * 1024);
|
|
1894
|
+
if (!body) return new Response('body required', { status: 400 });
|
|
1895
|
+
const result = await api.insertArtboardOp(body);
|
|
1896
|
+
if (!result.ok) {
|
|
1897
|
+
return Response.json(
|
|
1898
|
+
{ ok: false, error: result.error },
|
|
1899
|
+
{ status: result.status, headers: { 'Cache-Control': 'no-store' } }
|
|
1900
|
+
);
|
|
1901
|
+
}
|
|
1902
|
+
return Response.json(
|
|
1903
|
+
{ ok: true, artboardId: result.artboardId, seq: result.seq },
|
|
1904
|
+
{ status: 200, headers: { 'Cache-Control': 'no-store' } }
|
|
1905
|
+
);
|
|
1906
|
+
},
|
|
1907
|
+
|
|
1908
|
+
'/_api/assets': async (req: Request) => {
|
|
1909
|
+
// Stage F1 (feature-element-editing-robustness) — list content-addressed
|
|
1910
|
+
// image/video assets under <designRoot>/assets/ for the AssetPicker (Replace
|
|
1911
|
+
// image / insert image). GET → { assets:[{path,name,ext,kind,size,mtimeMs}] }.
|
|
1912
|
+
// MAIN-ORIGIN ONLY: the picker is a shell dialog; absent from CANVAS_SAFE_API
|
|
1913
|
+
// + startCanvasServer routes (dual-allowlist, DDR-054). Loopback-Host gated
|
|
1914
|
+
// (DNS-rebinding); read-only, so no sameOriginWrite (GET).
|
|
1915
|
+
if (req.method !== 'GET') return new Response('Method not allowed', { status: 405 });
|
|
1916
|
+
if (!isLoopbackHost(req.headers.get('host')))
|
|
1917
|
+
return new Response('local request required (DNS-rebinding guard)', { status: 403 });
|
|
1918
|
+
const r = await api.listAssets();
|
|
1919
|
+
return Response.json(
|
|
1920
|
+
{ ok: true, assets: r.assets },
|
|
1921
|
+
{ headers: { 'Cache-Control': 'no-store' } }
|
|
1922
|
+
);
|
|
1923
|
+
},
|
|
1924
|
+
|
|
1925
|
+
'/_api/edit-scope': async (req: Request) => {
|
|
1926
|
+
// Stage H (feature-element-editing-robustness) — the INV-3 predictability
|
|
1927
|
+
// verdict. GET ?canvas&id&rendered → { ok, scope:'local'|'shared',
|
|
1928
|
+
// componentName, affects, reason }. READ-only (a parse the shell runs on
|
|
1929
|
+
// selection to render the Local/Shared badge). MAIN-ORIGIN ONLY: absent
|
|
1930
|
+
// from CANVAS_SAFE_API + startCanvasServer routes (dual-allowlist, DDR-054);
|
|
1931
|
+
// loopback-Host gated (DNS-rebinding); no sameOriginWrite (GET).
|
|
1932
|
+
if (req.method !== 'GET') return new Response('Method not allowed', { status: 405 });
|
|
1933
|
+
if (!isLoopbackHost(req.headers.get('host')))
|
|
1934
|
+
return new Response('local request required (DNS-rebinding guard)', { status: 403 });
|
|
1935
|
+
const url = new URL(req.url);
|
|
1936
|
+
const result = await api.editScopeOp({
|
|
1937
|
+
canvas: url.searchParams.get('canvas') ?? undefined,
|
|
1938
|
+
id: url.searchParams.get('id') ?? undefined,
|
|
1939
|
+
rendered: url.searchParams.get('rendered') ?? undefined,
|
|
1940
|
+
});
|
|
1941
|
+
if (!result.ok) {
|
|
1942
|
+
return Response.json(
|
|
1943
|
+
{ ok: false, error: result.error },
|
|
1944
|
+
{ status: result.status, headers: { 'Cache-Control': 'no-store' } }
|
|
1945
|
+
);
|
|
1946
|
+
}
|
|
1947
|
+
return Response.json(result, { headers: { 'Cache-Control': 'no-store' } });
|
|
1948
|
+
},
|
|
1949
|
+
|
|
1950
|
+
'/_api/resize-artboard': async (req: Request) => {
|
|
1951
|
+
// Stage D4 — free-hand artboard resize. POST { canvas, artboardId, width?,
|
|
1952
|
+
// height? } → api.resizeArtboardOp (writes the NUMERIC width/height props on
|
|
1953
|
+
// the <DCArtboard id="…">, addressed by its `id` prop since the rendered
|
|
1954
|
+
// <article data-dc-screen> carries no data-cd-id; DDR-027). Whole-file undo
|
|
1955
|
+
// seq. MAIN-ORIGIN ONLY; sameOriginWrite + loopback-Host gated.
|
|
1956
|
+
if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
|
|
1957
|
+
if (!sameOriginWrite(req))
|
|
1958
|
+
return new Response('cross-origin write rejected', { status: 403 });
|
|
1959
|
+
if (!isLoopbackHost(req.headers.get('host')))
|
|
1960
|
+
return new Response('local request required (DNS-rebinding guard)', { status: 403 });
|
|
1961
|
+
const body = await readJson<{
|
|
1962
|
+
canvas?: unknown;
|
|
1963
|
+
artboardId?: unknown;
|
|
1964
|
+
width?: unknown;
|
|
1965
|
+
height?: unknown;
|
|
1966
|
+
}>(req, 8 * 1024);
|
|
1967
|
+
if (!body) return new Response('body required', { status: 400 });
|
|
1968
|
+
const result = await api.resizeArtboardOp(body);
|
|
1969
|
+
if (!result.ok) {
|
|
1970
|
+
return Response.json(
|
|
1971
|
+
{ ok: false, error: result.error },
|
|
1972
|
+
{ status: result.status, headers: { 'Cache-Control': 'no-store' } }
|
|
1973
|
+
);
|
|
1974
|
+
}
|
|
1975
|
+
return Response.json(
|
|
1976
|
+
{ ok: true, seq: result.seq },
|
|
1977
|
+
{ status: 200, headers: { 'Cache-Control': 'no-store' } }
|
|
1978
|
+
);
|
|
1979
|
+
},
|
|
1980
|
+
|
|
1981
|
+
'/_api/delete-artboard': async (req: Request) => {
|
|
1982
|
+
// Delete an artboard by its `id` prop (Backspace / context-menu on a frame).
|
|
1983
|
+
// POST { canvas, artboardId } → api.deleteArtboardOp (removes the
|
|
1984
|
+
// <DCArtboard id="…"> span; refuses the last one). Whole-file undo seq.
|
|
1985
|
+
// MAIN-ORIGIN ONLY; sameOriginWrite + loopback-Host gated (dual-allowlist).
|
|
1986
|
+
if (req.method !== 'POST') return new Response('Method not allowed', { status: 405 });
|
|
1987
|
+
if (!sameOriginWrite(req))
|
|
1988
|
+
return new Response('cross-origin write rejected', { status: 403 });
|
|
1989
|
+
if (!isLoopbackHost(req.headers.get('host')))
|
|
1990
|
+
return new Response('local request required (DNS-rebinding guard)', { status: 403 });
|
|
1991
|
+
const body = await readJson<{ canvas?: unknown; artboardId?: unknown }>(req, 8 * 1024);
|
|
1992
|
+
if (!body) return new Response('body required', { status: 400 });
|
|
1993
|
+
const result = await api.deleteArtboardOp(body);
|
|
1994
|
+
if (!result.ok) {
|
|
1995
|
+
return Response.json(
|
|
1996
|
+
{ ok: false, error: result.error },
|
|
1997
|
+
{ status: result.status, headers: { 'Cache-Control': 'no-store' } }
|
|
1998
|
+
);
|
|
1999
|
+
}
|
|
2000
|
+
return Response.json(
|
|
2001
|
+
{ ok: true, seq: result.seq },
|
|
2002
|
+
{ status: 200, headers: { 'Cache-Control': 'no-store' } }
|
|
2003
|
+
);
|
|
2004
|
+
},
|
|
2005
|
+
|
|
1783
2006
|
'/_api/asset': async (req: Request) => {
|
|
1784
2007
|
// Phase 23 / DDR-148 — binary media upload from the canvas (drag-drop /
|
|
1785
2008
|
// paste). POST raw bytes → content-addressed write under
|
|
@@ -475,6 +475,18 @@ export function useInputRouter(opts: UseInputRouterOptions): void {
|
|
|
475
475
|
if (action.kind !== 'no-op') {
|
|
476
476
|
e.preventDefault();
|
|
477
477
|
e.stopImmediatePropagation();
|
|
478
|
+
// preventDefault above stops mousedown's default FOCUS, so a Cmd-click
|
|
479
|
+
// select would leave the iframe unfocused and every in-canvas keyboard
|
|
480
|
+
// shortcut (arrow-nudge, Cmd+D, copy/paste-style, Delete) dead until a
|
|
481
|
+
// drag happened to focus it (dogfood: "funguje jen když hnu myší").
|
|
482
|
+
// Restore canvas focus explicitly so the keydown listeners fire.
|
|
483
|
+
if (action.kind === 'select') {
|
|
484
|
+
try {
|
|
485
|
+
window.focus();
|
|
486
|
+
} catch {
|
|
487
|
+
/* focus may be rejected outside a user gesture */
|
|
488
|
+
}
|
|
489
|
+
}
|
|
478
490
|
}
|
|
479
491
|
};
|
|
480
492
|
|
|
@@ -54,7 +54,7 @@ function isChromeTarget(t: EventTarget | null): boolean {
|
|
|
54
54
|
const el = t as Element;
|
|
55
55
|
if (
|
|
56
56
|
el.closest(
|
|
57
|
-
'.dc-mm, .dc-zoom-tb, .dc-tool-palette, .dc-context-menu, .dc-cv-halo, .dc-cv-group-bbox, .cm-composer, .cm-thread, .cm-mention-popup, .cm-pin, .dc-annot-svg, .dc-annot-ctx, .dc-tp-popover, .dc-annot-resize-handle, .dc-multi-artboard-tb, .dc-elem-ctx-tb, .dc-cv-eq-spacing-layer'
|
|
57
|
+
'.dc-mm, .dc-zoom-tb, .dc-tool-palette, .dc-context-menu, .dc-cv-halo, .dc-cv-group-bbox, .cm-composer, .cm-thread, .cm-mention-popup, .cm-pin, .dc-annot-svg, .dc-annot-ctx, .dc-tp-popover, .dc-annot-resize-handle, .dc-el-resize-handle, .dc-multi-artboard-tb, .dc-elem-ctx-tb, .dc-cv-eq-spacing-layer'
|
|
58
58
|
)
|
|
59
59
|
) {
|
|
60
60
|
return true;
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file measure-overlay.tsx — Task L7 (feature-element-editing-robustness)
|
|
3
|
+
* @scope apps/studio/measure-overlay.tsx
|
|
4
|
+
* @purpose Alt-hover distance measurement (Figma parity): with exactly one
|
|
5
|
+
* element selected, holding Alt and hovering a DIFFERENT stamped
|
|
6
|
+
* element paints a red guide line + pixel-distance pill between
|
|
7
|
+
* the two boxes' facing edges, on whichever axis(es) actually
|
|
8
|
+
* have a gap. Read-only — never writes anything, no undo, no
|
|
9
|
+
* write-surface implications.
|
|
10
|
+
*
|
|
11
|
+
* Mirrors `equal-spacing-handles.tsx`'s fixed-rAF-follow scaffold
|
|
12
|
+
* (persistent DOM children, no React re-render per frame) and
|
|
13
|
+
* reuses its pairwise-gap sibling `computePairGap`
|
|
14
|
+
* (equal-spacing-detector.ts) for the math.
|
|
15
|
+
*
|
|
16
|
+
* Alt-tracking mirrors `use-cursor-modifiers.tsx`'s
|
|
17
|
+
* `reduceModifiers` reducer, but keeps the result in React state
|
|
18
|
+
* (gating hover RESOLUTION, a real cost) rather than a DOM
|
|
19
|
+
* attribute (that hook only drives CSS cursor swaps).
|
|
20
|
+
*
|
|
21
|
+
* Distances are printed in WORLD px (screen delta ÷ the world's
|
|
22
|
+
* zoom) so the number stays correct at any zoom level — the
|
|
23
|
+
* Stage-A host-scroll-0 invariant is what makes a screen-rect
|
|
24
|
+
* delta ÷ zoom equivalent to a true world-space measurement.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { useEffect, useRef, useState } from 'react';
|
|
28
|
+
import { useArtboardsContext } from './canvas-lib.tsx';
|
|
29
|
+
import { resolveSelectionEl } from './dom-selection.ts';
|
|
30
|
+
import { computePairGap } from './equal-spacing-detector.ts';
|
|
31
|
+
import { resolveHoverTarget } from './input-router.tsx';
|
|
32
|
+
import { type ModifierState, reduceModifiers } from './use-cursor-modifiers.tsx';
|
|
33
|
+
import { useSelectionSet } from './use-selection-set.tsx';
|
|
34
|
+
|
|
35
|
+
const MEASURE_CSS = `
|
|
36
|
+
.dc-cv-measure-layer {
|
|
37
|
+
position: fixed;
|
|
38
|
+
inset: 0;
|
|
39
|
+
pointer-events: none;
|
|
40
|
+
z-index: 7;
|
|
41
|
+
}
|
|
42
|
+
.dc-cv-measure-line {
|
|
43
|
+
position: absolute;
|
|
44
|
+
background: #FF3B30;
|
|
45
|
+
opacity: 0;
|
|
46
|
+
transition: opacity 120ms cubic-bezier(0.4, 0, 0.2, 1);
|
|
47
|
+
}
|
|
48
|
+
.dc-cv-measure-line[data-axis="x"] { height: 1px; }
|
|
49
|
+
.dc-cv-measure-line[data-axis="y"] { width: 1px; }
|
|
50
|
+
.dc-cv-measure-line[data-on="true"] { opacity: 1; }
|
|
51
|
+
.dc-cv-measure-pill {
|
|
52
|
+
position: absolute;
|
|
53
|
+
transform: translate(-50%, -50%);
|
|
54
|
+
font-family: var(--maude-chrome-font-mono, ui-monospace, SFMono-Regular, monospace);
|
|
55
|
+
font-size: 10px;
|
|
56
|
+
padding: 2px 5px;
|
|
57
|
+
background: #FF3B30;
|
|
58
|
+
color: #ffffff;
|
|
59
|
+
border-radius: 2px;
|
|
60
|
+
letter-spacing: 0.02em;
|
|
61
|
+
white-space: nowrap;
|
|
62
|
+
opacity: 0;
|
|
63
|
+
transition: opacity 120ms cubic-bezier(0.4, 0, 0.2, 1);
|
|
64
|
+
}
|
|
65
|
+
.dc-cv-measure-pill[data-on="true"] { opacity: 1; }
|
|
66
|
+
@media (prefers-reduced-motion: reduce) {
|
|
67
|
+
.dc-cv-measure-line, .dc-cv-measure-pill { transition-duration: 1ms; }
|
|
68
|
+
}
|
|
69
|
+
`.trim();
|
|
70
|
+
|
|
71
|
+
function ensureStyles(): void {
|
|
72
|
+
if (typeof document === 'undefined') return;
|
|
73
|
+
if (document.getElementById('dc-cv-measure-css')) return;
|
|
74
|
+
const s = document.createElement('style');
|
|
75
|
+
s.id = 'dc-cv-measure-css';
|
|
76
|
+
s.textContent = MEASURE_CSS;
|
|
77
|
+
document.head.appendChild(s);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
interface ScreenRect {
|
|
81
|
+
x: number;
|
|
82
|
+
y: number;
|
|
83
|
+
w: number;
|
|
84
|
+
h: number;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function rectOf(el: Element): ScreenRect | null {
|
|
88
|
+
const b = (el as HTMLElement).getBoundingClientRect();
|
|
89
|
+
if (b.width === 0 && b.height === 0) return null;
|
|
90
|
+
return { x: b.left, y: b.top, w: b.width, h: b.height };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Persistent line+pill pair for one axis — created once, toggled via
|
|
94
|
+
* `data-on` (opacity transition) rather than added/removed each frame. */
|
|
95
|
+
function ensureAxisNodes(layer: HTMLDivElement, axis: 'x' | 'y'): [HTMLDivElement, HTMLDivElement] {
|
|
96
|
+
let line = layer.querySelector<HTMLDivElement>(`.dc-cv-measure-line[data-axis="${axis}"]`);
|
|
97
|
+
let pill = layer.querySelector<HTMLDivElement>(`.dc-cv-measure-pill[data-axis="${axis}"]`);
|
|
98
|
+
if (!line) {
|
|
99
|
+
line = document.createElement('div');
|
|
100
|
+
line.className = 'dc-cv-measure-line';
|
|
101
|
+
line.dataset.axis = axis;
|
|
102
|
+
layer.appendChild(line);
|
|
103
|
+
}
|
|
104
|
+
if (!pill) {
|
|
105
|
+
pill = document.createElement('div');
|
|
106
|
+
pill.className = 'dc-cv-measure-pill';
|
|
107
|
+
pill.dataset.axis = axis;
|
|
108
|
+
layer.appendChild(pill);
|
|
109
|
+
}
|
|
110
|
+
return [line, pill];
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function hideAxis(layer: HTMLDivElement, axis: 'x' | 'y'): void {
|
|
114
|
+
const [line, pill] = ensureAxisNodes(layer, axis);
|
|
115
|
+
line.dataset.on = 'false';
|
|
116
|
+
pill.dataset.on = 'false';
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function paintAxis(
|
|
120
|
+
layer: HTMLDivElement,
|
|
121
|
+
axis: 'x' | 'y',
|
|
122
|
+
a: ScreenRect,
|
|
123
|
+
b: ScreenRect,
|
|
124
|
+
zoom: number
|
|
125
|
+
): void {
|
|
126
|
+
const g = computePairGap(a, b, axis);
|
|
127
|
+
const [line, pill] = ensureAxisNodes(layer, axis);
|
|
128
|
+
if (!g) {
|
|
129
|
+
line.dataset.on = 'false';
|
|
130
|
+
pill.dataset.on = 'false';
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
const worldGap = Math.round(g.gap / (zoom || 1));
|
|
134
|
+
if (axis === 'x') {
|
|
135
|
+
line.style.left = `${Math.round(g.from)}px`;
|
|
136
|
+
line.style.top = `${Math.round(g.cross)}px`;
|
|
137
|
+
line.style.width = `${Math.round(g.gap)}px`;
|
|
138
|
+
pill.style.left = `${Math.round(g.from + g.gap / 2)}px`;
|
|
139
|
+
pill.style.top = `${Math.round(g.cross)}px`;
|
|
140
|
+
} else {
|
|
141
|
+
line.style.left = `${Math.round(g.cross)}px`;
|
|
142
|
+
line.style.top = `${Math.round(g.from)}px`;
|
|
143
|
+
line.style.height = `${Math.round(g.gap)}px`;
|
|
144
|
+
pill.style.left = `${Math.round(g.cross)}px`;
|
|
145
|
+
pill.style.top = `${Math.round(g.from + g.gap / 2)}px`;
|
|
146
|
+
}
|
|
147
|
+
pill.textContent = `${worldGap}`;
|
|
148
|
+
line.dataset.on = 'true';
|
|
149
|
+
pill.dataset.on = 'true';
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export function MeasureOverlay() {
|
|
153
|
+
ensureStyles();
|
|
154
|
+
const { selected } = useSelectionSet();
|
|
155
|
+
const world = useArtboardsContext();
|
|
156
|
+
const layerRef = useRef<HTMLDivElement | null>(null);
|
|
157
|
+
const [alt, setAlt] = useState(false);
|
|
158
|
+
const [hoverEl, setHoverEl] = useState<Element | null>(null);
|
|
159
|
+
|
|
160
|
+
// Alt + hover tracking, gated together: hover RESOLUTION (a real per-move
|
|
161
|
+
// cost — resolveHoverTarget walks the DOM) only runs while Alt is held.
|
|
162
|
+
useEffect(() => {
|
|
163
|
+
if (typeof document === 'undefined') return;
|
|
164
|
+
let mods: ModifierState = { alt: false, shift: false, meta: false };
|
|
165
|
+
const onKeyChange = (e: KeyboardEvent) => {
|
|
166
|
+
const next = reduceModifiers(mods, e);
|
|
167
|
+
if (next === mods) return;
|
|
168
|
+
mods = next;
|
|
169
|
+
setAlt(mods.alt);
|
|
170
|
+
if (!mods.alt) setHoverEl(null);
|
|
171
|
+
};
|
|
172
|
+
const onPointer = (e: PointerEvent) => {
|
|
173
|
+
const next = reduceModifiers(mods, e);
|
|
174
|
+
if (next !== mods) {
|
|
175
|
+
mods = next;
|
|
176
|
+
setAlt(mods.alt);
|
|
177
|
+
}
|
|
178
|
+
if (!mods.alt) {
|
|
179
|
+
setHoverEl(null);
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
const t = resolveHoverTarget(document, e.clientX, e.clientY, { deep: true });
|
|
183
|
+
setHoverEl(t?.el ?? null);
|
|
184
|
+
};
|
|
185
|
+
const onBlur = () => {
|
|
186
|
+
mods = { alt: false, shift: false, meta: false };
|
|
187
|
+
setAlt(false);
|
|
188
|
+
setHoverEl(null);
|
|
189
|
+
};
|
|
190
|
+
document.addEventListener('keydown', onKeyChange, true);
|
|
191
|
+
document.addEventListener('keyup', onKeyChange, true);
|
|
192
|
+
document.addEventListener('pointermove', onPointer, { passive: true });
|
|
193
|
+
window.addEventListener('blur', onBlur);
|
|
194
|
+
return () => {
|
|
195
|
+
document.removeEventListener('keydown', onKeyChange, true);
|
|
196
|
+
document.removeEventListener('keyup', onKeyChange, true);
|
|
197
|
+
document.removeEventListener('pointermove', onPointer);
|
|
198
|
+
window.removeEventListener('blur', onBlur);
|
|
199
|
+
};
|
|
200
|
+
}, []);
|
|
201
|
+
|
|
202
|
+
const one = selected.length === 1 ? selected[0] : null;
|
|
203
|
+
const zoom = world?.viewport?.zoom || 1;
|
|
204
|
+
const active = alt && !!one && !!hoverEl;
|
|
205
|
+
|
|
206
|
+
useEffect(() => {
|
|
207
|
+
const layer = layerRef.current;
|
|
208
|
+
if (!layer) return;
|
|
209
|
+
const hideBoth = () => {
|
|
210
|
+
hideAxis(layer, 'x');
|
|
211
|
+
hideAxis(layer, 'y');
|
|
212
|
+
};
|
|
213
|
+
if (!active || !one || !hoverEl) {
|
|
214
|
+
hideBoth();
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
let alive = true;
|
|
218
|
+
let rafId: number | null = null;
|
|
219
|
+
const tick = () => {
|
|
220
|
+
rafId = null;
|
|
221
|
+
if (!alive) return;
|
|
222
|
+
const selEl = resolveSelectionEl(document, one);
|
|
223
|
+
const a = selEl && selEl !== hoverEl && hoverEl.isConnected ? rectOf(selEl) : null;
|
|
224
|
+
const b = a ? rectOf(hoverEl) : null;
|
|
225
|
+
if (a && b) {
|
|
226
|
+
paintAxis(layer, 'x', a, b, zoom);
|
|
227
|
+
paintAxis(layer, 'y', a, b, zoom);
|
|
228
|
+
} else {
|
|
229
|
+
hideBoth();
|
|
230
|
+
}
|
|
231
|
+
rafId = requestAnimationFrame(tick);
|
|
232
|
+
};
|
|
233
|
+
rafId = requestAnimationFrame(tick);
|
|
234
|
+
return () => {
|
|
235
|
+
alive = false;
|
|
236
|
+
if (rafId != null) cancelAnimationFrame(rafId);
|
|
237
|
+
};
|
|
238
|
+
}, [active, one, hoverEl, zoom]);
|
|
239
|
+
|
|
240
|
+
return <div ref={layerRef} className="dc-cv-measure-layer" aria-hidden="true" />;
|
|
241
|
+
}
|
|
@@ -29,7 +29,7 @@ const CHROME_CSS = `
|
|
|
29
29
|
display: flex;
|
|
30
30
|
align-items: center;
|
|
31
31
|
pointer-events: auto;
|
|
32
|
-
font-family:
|
|
32
|
+
font-family: system-ui, -apple-system, sans-serif;
|
|
33
33
|
}
|
|
34
34
|
.dc-participant {
|
|
35
35
|
width: 26px;
|
|
@@ -74,7 +74,7 @@ const CHROME_CSS = `
|
|
|
74
74
|
background: var(--maude-chrome-bg-1, #fff);
|
|
75
75
|
color: var(--maude-chrome-fg-0, #111);
|
|
76
76
|
border: 1px solid var(--maude-chrome-border, rgba(0,0,0,0.16));
|
|
77
|
-
border-radius:
|
|
77
|
+
border-radius: 4px;
|
|
78
78
|
font-size: 12px;
|
|
79
79
|
line-height: 1.35;
|
|
80
80
|
white-space: nowrap;
|
|
@@ -93,7 +93,7 @@ const CHROME_CSS = `
|
|
|
93
93
|
background: var(--maude-hud-accent, oklch(56% 0.170 50));
|
|
94
94
|
color: var(--maude-hud-accent-fg, #fff);
|
|
95
95
|
border: none;
|
|
96
|
-
border-radius:
|
|
96
|
+
border-radius: 2px;
|
|
97
97
|
font-family: inherit;
|
|
98
98
|
font-weight: 500;
|
|
99
99
|
font-size: 11px;
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file sizing-mode.ts
|
|
3
|
+
* @purpose Pure Fixed / Hug / Fill sizing-mode logic for the CssKnobs Size
|
|
4
|
+
* control (feature-element-editing-robustness Stage M1). Kept framework-
|
|
5
|
+
* free + in a .ts file so it's unit-testable (app.jsx is bundled JSX).
|
|
6
|
+
*
|
|
7
|
+
* Figma auto-layout parity: a per-axis Fixed / Hug / Fill mode is the single
|
|
8
|
+
* biggest source of layout-behaviour clarity for React/flexbox mockups.
|
|
9
|
+
* - Fixed = an explicit width/height value.
|
|
10
|
+
* - Hug = shrink to children (`fit-content`).
|
|
11
|
+
* - Fill = stretch to fill the parent — and the CORRECT CSS depends on the
|
|
12
|
+
* parent's layout: a flex child fills its MAIN axis via `flex-grow`,
|
|
13
|
+
* its CROSS axis via `align-self: stretch`; a block/grid child fills
|
|
14
|
+
* via `width/height: 100%`. So the mode must know the parent context
|
|
15
|
+
* (captured at selection time in Selection.parentDisplay /
|
|
16
|
+
* .parentFlexDirection — the shell can't reach the iframe later).
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
export type SizeAxis = 'width' | 'height';
|
|
20
|
+
export type SizeMode = 'fixed' | 'hug' | 'fill';
|
|
21
|
+
|
|
22
|
+
export interface ParentLayout {
|
|
23
|
+
display?: string;
|
|
24
|
+
flexDirection?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const HUG_VALUES = new Set(['fit-content', 'max-content', 'min-content']);
|
|
28
|
+
const FILL_VALUES = new Set(['100%', 'fill', '-webkit-fill-available', 'stretch']);
|
|
29
|
+
|
|
30
|
+
function parentIsFlex(p: ParentLayout | undefined): boolean {
|
|
31
|
+
const d = (p?.display || '').trim();
|
|
32
|
+
return d === 'flex' || d === 'inline-flex';
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** True when `axis` is the flex MAIN axis of the parent (row → width, column → height). */
|
|
36
|
+
export function isMainAxis(axis: SizeAxis, p: ParentLayout | undefined): boolean {
|
|
37
|
+
const dir = (p?.flexDirection || 'row').trim();
|
|
38
|
+
const rowMain = dir.startsWith('row'); // row / row-reverse
|
|
39
|
+
return rowMain ? axis === 'width' : axis === 'height';
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Best-effort classification of the current sizing mode for the segmented
|
|
44
|
+
* control's active-state highlight. Reads AUTHORED values first (what the user
|
|
45
|
+
* set), so a value the panel wrote round-trips; defaults to `fixed`. Detection is
|
|
46
|
+
* advisory — the WRITE side (`sizingModePatch`) is what must be deterministic.
|
|
47
|
+
*/
|
|
48
|
+
export function sizingModeOf(
|
|
49
|
+
axis: SizeAxis,
|
|
50
|
+
authored: Record<string, string> = {},
|
|
51
|
+
_computed: Record<string, string> = {},
|
|
52
|
+
parent?: ParentLayout
|
|
53
|
+
): SizeMode {
|
|
54
|
+
const a = (authored[axis] || '').trim().toLowerCase();
|
|
55
|
+
if (HUG_VALUES.has(a)) return 'hug';
|
|
56
|
+
if (FILL_VALUES.has(a)) return 'fill';
|
|
57
|
+
if (parentIsFlex(parent)) {
|
|
58
|
+
const main = isMainAxis(axis, parent);
|
|
59
|
+
if (main && Number.parseFloat(authored['flex-grow'] || '0') >= 1) return 'fill';
|
|
60
|
+
if (!main && (authored['align-self'] || '').trim() === 'stretch') return 'fill';
|
|
61
|
+
}
|
|
62
|
+
// A numeric authored value (px/rem/%…) or nothing authored → Fixed.
|
|
63
|
+
return 'fixed';
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface SizingPatch {
|
|
67
|
+
/** Properties to write (in order), each `[prop, value]`. */
|
|
68
|
+
set: Array<[string, string]>;
|
|
69
|
+
/** Fill-specific properties to REMOVE so a prior Fill doesn't linger. */
|
|
70
|
+
reset: string[];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* The deterministic CSS writes for switching `axis` to `mode`. `currentPx` seeds
|
|
75
|
+
* the Fixed value (the element's current rendered size). Fill is context-aware:
|
|
76
|
+
* a flex child fills its main axis via `flex-grow:1` + `flex-basis:0%`, its cross
|
|
77
|
+
* axis via `align-self:stretch`; a block/grid child via `<axis>: 100%`.
|
|
78
|
+
*/
|
|
79
|
+
export function sizingModePatch(
|
|
80
|
+
axis: SizeAxis,
|
|
81
|
+
mode: SizeMode,
|
|
82
|
+
parent: ParentLayout | undefined,
|
|
83
|
+
currentPx: number
|
|
84
|
+
): SizingPatch {
|
|
85
|
+
const flex = parentIsFlex(parent);
|
|
86
|
+
const main = isMainAxis(axis, parent);
|
|
87
|
+
// Which fill-specific props a non-fill mode must clear so the old Fill doesn't linger.
|
|
88
|
+
const fillProps = flex ? (main ? ['flex-grow', 'flex-basis'] : ['align-self']) : [];
|
|
89
|
+
if (mode === 'fixed') {
|
|
90
|
+
const px = Number.isFinite(currentPx) && currentPx > 0 ? Math.round(currentPx) : 0;
|
|
91
|
+
return { set: [[axis, `${px}px`]], reset: fillProps };
|
|
92
|
+
}
|
|
93
|
+
if (mode === 'hug') {
|
|
94
|
+
return { set: [[axis, 'fit-content']], reset: fillProps };
|
|
95
|
+
}
|
|
96
|
+
// fill
|
|
97
|
+
if (flex) {
|
|
98
|
+
if (main) {
|
|
99
|
+
return {
|
|
100
|
+
set: [
|
|
101
|
+
['flex-grow', '1'],
|
|
102
|
+
['flex-basis', '0%'],
|
|
103
|
+
[axis, 'auto'],
|
|
104
|
+
],
|
|
105
|
+
reset: [],
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
return {
|
|
109
|
+
set: [
|
|
110
|
+
['align-self', 'stretch'],
|
|
111
|
+
[axis, 'auto'],
|
|
112
|
+
],
|
|
113
|
+
reset: [],
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
return { set: [[axis, '100%']], reset: [] };
|
|
117
|
+
}
|