@1agh/maude 0.24.0 → 0.25.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.
Files changed (50) hide show
  1. package/package.json +8 -8
  2. package/plugins/design/dependencies.json +30 -2
  3. package/plugins/design/dev-server/annotations-context-toolbar.tsx +481 -78
  4. package/plugins/design/dev-server/annotations-layer.tsx +817 -170
  5. package/plugins/design/dev-server/api.ts +15 -1
  6. package/plugins/design/dev-server/bin/_enumerate-artboards-playwright.mjs +2 -2
  7. package/plugins/design/dev-server/bin/_html-playwright.mjs +2 -2
  8. package/plugins/design/dev-server/bin/_pdf-playwright.mjs +25 -8
  9. package/plugins/design/dev-server/bin/_png-playwright.mjs +20 -20
  10. package/plugins/design/dev-server/bin/_pptx-playwright.mjs +2 -2
  11. package/plugins/design/dev-server/bin/_pw-launch.mjs +61 -0
  12. package/plugins/design/dev-server/bin/_screenshot-playwright.mjs +2 -2
  13. package/plugins/design/dev-server/bin/_svg-playwright.mjs +125 -19
  14. package/plugins/design/dev-server/bin/smoke.sh +114 -12
  15. package/plugins/design/dev-server/canvas-arrowheads.ts +220 -0
  16. package/plugins/design/dev-server/canvas-comment-mount.tsx +8 -24
  17. package/plugins/design/dev-server/canvas-cursors.ts +130 -82
  18. package/plugins/design/dev-server/canvas-icons.tsx +169 -0
  19. package/plugins/design/dev-server/canvas-shell.tsx +113 -89
  20. package/plugins/design/dev-server/client/app.jsx +1084 -417
  21. package/plugins/design/dev-server/dist/client.bundle.js +242 -20
  22. package/plugins/design/dev-server/dist/comment-mount.js +40 -62
  23. package/plugins/design/dev-server/export-dialog.tsx +189 -1
  24. package/plugins/design/dev-server/exporters/html.ts +4 -1
  25. package/plugins/design/dev-server/exporters/index.ts +4 -1
  26. package/plugins/design/dev-server/exporters/pdf.ts +7 -1
  27. package/plugins/design/dev-server/exporters/png.ts +21 -2
  28. package/plugins/design/dev-server/exporters/pptx.ts +330 -201
  29. package/plugins/design/dev-server/exporters/scope.ts +107 -11
  30. package/plugins/design/dev-server/exporters/svg.ts +4 -1
  31. package/plugins/design/dev-server/http.ts +40 -9
  32. package/plugins/design/dev-server/input-router.tsx +9 -2
  33. package/plugins/design/dev-server/test/annotations-draw-modifiers.test.ts +206 -0
  34. package/plugins/design/dev-server/test/annotations-layer.test.ts +83 -3
  35. package/plugins/design/dev-server/test/annotations-roundtrip.test.ts +243 -0
  36. package/plugins/design/dev-server/test/canvas-cursors.test.ts +95 -6
  37. package/plugins/design/dev-server/test/canvas-route.test.ts +4 -1
  38. package/plugins/design/dev-server/test/exporters/png.test.ts +24 -1
  39. package/plugins/design/dev-server/test/exporters/pptx-deck.test.ts +112 -0
  40. package/plugins/design/dev-server/test/exporters/pw-launch.test.ts +49 -0
  41. package/plugins/design/dev-server/test/exporters/scope.test.ts +0 -0
  42. package/plugins/design/dev-server/test/fixtures/phase-21-annotations.svg +1 -0
  43. package/plugins/design/dev-server/test/input-router.test.ts +11 -4
  44. package/plugins/design/dev-server/test/sanitize-annotation-svg.test.ts +32 -0
  45. package/plugins/design/dev-server/test/use-annotation-resize.test.ts +200 -0
  46. package/plugins/design/dev-server/test/use-tool-mode.test.tsx +9 -11
  47. package/plugins/design/dev-server/tool-palette.tsx +140 -11
  48. package/plugins/design/dev-server/use-annotation-resize.tsx +208 -61
  49. package/plugins/design/dev-server/use-tool-mode.tsx +55 -9
  50. package/plugins/design/templates/_shell.html +36 -9
@@ -24,12 +24,141 @@ import {
24
24
  useState,
25
25
  } from 'react';
26
26
 
27
+ import { useSelectionSetOptional } from './use-selection-set.tsx';
28
+
27
29
  // ─────────────────────────────────────────────────────────────────────────────
28
30
  // Types
29
31
 
32
+ // ─── cross-origin export bridge ──────────────────────────────────────────────
33
+ // This dialog renders INSIDE the canvas iframe. Since phase-9.1 the canvas is
34
+ // served from a segregated origin (default ON) whose CSP is `connect-src 'self'`
35
+ // and whose route allowlist deliberately excludes the privileged /_api/export +
36
+ // /_api/export-history (DDR-060). A direct in-iframe fetch therefore 403s
37
+ // ("Forbidden (canvas origin)"). When the parent is cross-origin we ask the
38
+ // trusted main shell (app.jsx onMessage) to run the request same-origin via a
39
+ // `dgn` postMessage — the channel comments/selection already use. When the split
40
+ // is OFF (same-origin iframe) we fall through to the direct fetch.
41
+
42
+ /** True when framed by a different origin (the canvas-origin split is on). */
43
+ function isCrossOriginFramed(): boolean {
44
+ if (typeof window === 'undefined' || window.parent === window) return false;
45
+ try {
46
+ // Same-origin parent → this read succeeds → split is OFF, use direct fetch.
47
+ void window.parent.location.href;
48
+ return false;
49
+ } catch {
50
+ return true;
51
+ }
52
+ }
53
+
54
+ /** Parent (main shell) origin, for postMessage targeting. */
55
+ function parentOrigin(): string {
56
+ try {
57
+ if (document.referrer) return new URL(document.referrer).origin;
58
+ } catch {
59
+ /* fall through to wildcard */
60
+ }
61
+ return '*';
62
+ }
63
+
64
+ let bridgeSeq = 0;
65
+
66
+ /**
67
+ * Post one request to the parent shell and resolve with its matching reply.
68
+ * Only accepts a reply from the parent window (source check) with the matching
69
+ * id; rejects on a 60 s timeout so a dropped reply can't hang the dialog.
70
+ */
71
+ function bridgeRequest<T>(
72
+ reqDgn: string,
73
+ resDgn: string,
74
+ extra: Record<string, unknown>
75
+ ): Promise<T> {
76
+ bridgeSeq += 1;
77
+ const id = `exp-${bridgeSeq}`;
78
+ return new Promise((resolve, reject) => {
79
+ const onMsg = (e: MessageEvent) => {
80
+ if (e.source !== window.parent) return;
81
+ const m = e.data as { dgn?: string; id?: string } | null;
82
+ if (!m || typeof m !== 'object' || m.dgn !== resDgn || m.id !== id) return;
83
+ clearTimeout(timer);
84
+ window.removeEventListener('message', onMsg);
85
+ resolve(m as unknown as T);
86
+ };
87
+ const timer = setTimeout(() => {
88
+ window.removeEventListener('message', onMsg);
89
+ reject(new Error('export bridge timed out'));
90
+ }, 60_000);
91
+ window.addEventListener('message', onMsg);
92
+ window.parent.postMessage({ dgn: reqDgn, id, ...extra }, parentOrigin());
93
+ });
94
+ }
95
+
30
96
  export type Format = 'png' | 'pdf' | 'svg' | 'html' | 'pptx' | 'canva' | 'zip';
31
97
  export type Scope = 'selection' | 'artboard' | 'canvas-as-separate' | 'project-raw';
32
98
 
99
+ // ─── PNG size presets (item 1) ───────────────────────────────────────────────
100
+ // Resolution multiplier applied as Chromium `deviceScaleFactor`. The native
101
+ // artboard is 1440×900; 2× → 2880×1800. Default 2× because a single-scale PNG
102
+ // was uselessly small. The shim clamps deviceScaleFactor ≤ 4.
103
+ type PngScale = 1 | 2 | 3;
104
+ const PNG_SCALES: ReadonlyArray<{ value: PngScale; label: string }> = [
105
+ { value: 1, label: '1× (native)' },
106
+ { value: 2, label: '2× (retina)' },
107
+ { value: 3, label: '3× (max)' },
108
+ ];
109
+ const DEFAULT_PNG_SCALE: PngScale = 2;
110
+
111
+ /**
112
+ * The artboard under the viewport centre — the export dialog's notion of "the
113
+ * active artboard" when nothing is selected. getBoundingClientRect is in
114
+ * screen coords (post-zoom) so the centre-distance metric is zoom-invariant.
115
+ * Returns undefined off-DOM (tests) or when the canvas has no artboards.
116
+ */
117
+ function activeArtboardId(): string | undefined {
118
+ if (typeof document === 'undefined' || typeof window === 'undefined') return undefined;
119
+ const screens = Array.from(document.querySelectorAll('[data-dc-screen]'));
120
+ if (!screens.length) return undefined;
121
+ const cx = window.innerWidth / 2;
122
+ const cy = window.innerHeight / 2;
123
+ let best: Element | null = null;
124
+ let bestDist = Number.POSITIVE_INFINITY;
125
+ for (const el of screens) {
126
+ const r = el.getBoundingClientRect();
127
+ const dx = (r.left + r.right) / 2 - cx;
128
+ const dy = (r.top + r.bottom) / 2 - cy;
129
+ const d = dx * dx + dy * dy;
130
+ if (d < bestDist) {
131
+ bestDist = d;
132
+ best = el;
133
+ }
134
+ }
135
+ return best?.getAttribute('data-dc-screen') ?? undefined;
136
+ }
137
+
138
+ /**
139
+ * Snapshot the live canvas selection + active artboard at submit time so the
140
+ * export is independent of whether opening the dialog cleared the persisted
141
+ * `_active.json.selected`. Rides the `options` bag through the cross-origin
142
+ * bridge into `resolveScope` (see scope.ts `ExportScopeHints`). `selSet` is the
143
+ * optional selection-set context — null when the dialog is mounted outside a
144
+ * provider (tests), in which case we still contribute the viewport-centre
145
+ * artboard id.
146
+ */
147
+ function captureScopeHints(
148
+ selSet: {
149
+ selected: Array<{ selector?: string; file?: string; artboardId?: string | null }>;
150
+ } | null
151
+ ): { selection?: { selector: string; file?: string }; artboardId?: string } {
152
+ const out: { selection?: { selector: string; file?: string }; artboardId?: string } = {};
153
+ const sel = selSet?.selected?.[0];
154
+ if (sel?.selector) {
155
+ out.selection = { selector: sel.selector, ...(sel.file ? { file: sel.file } : {}) };
156
+ }
157
+ const artboardId = (sel?.artboardId ?? undefined) || activeArtboardId();
158
+ if (artboardId) out.artboardId = artboardId;
159
+ return out;
160
+ }
161
+
33
162
  const FORMAT_META: Record<Format, { label: string; description: string; defaultExt: string }> = {
34
163
  png: { label: 'PNG', description: 'Raster image, one per artboard.', defaultExt: '.png' },
35
164
  pdf: { label: 'PDF', description: 'Multi-page PDF, one page per artboard.', defaultExt: '.pdf' },
@@ -167,6 +296,15 @@ export function ExportDialogProvider({ children }: { children: ReactNode }): Rea
167
296
  // Pre-load history when the dialog opens; refresh after each export.
168
297
  const loadHistory = useCallback(async () => {
169
298
  try {
299
+ if (isCrossOriginFramed()) {
300
+ const res = await bridgeRequest<{ history?: ExportHistoryEntry[] }>(
301
+ 'export-history-request',
302
+ 'export-history-result',
303
+ {}
304
+ );
305
+ setHistory(Array.isArray(res.history) ? res.history : []);
306
+ return;
307
+ }
170
308
  const r = await fetch('/_api/export-history');
171
309
  if (!r.ok) return;
172
310
  const data = (await r.json()) as { history: ExportHistoryEntry[] };
@@ -213,6 +351,23 @@ export function ExportDialogProvider({ children }: { children: ReactNode }): Rea
213
351
  setSubmitting(true);
214
352
  setStatus(null);
215
353
  try {
354
+ if (isCrossOriginFramed()) {
355
+ // Bridge through the main shell — the iframe can't reach /_api/export
356
+ // (canvas origin 403s it). The parent runs the export + triggers the
357
+ // download, then replies with the saved filename or an error string.
358
+ const res = await bridgeRequest<{ ok?: boolean; filename?: string; error?: string }>(
359
+ 'export-request',
360
+ 'export-result',
361
+ { payload: { format, scope, options } }
362
+ );
363
+ if (!res.ok) {
364
+ setStatus({ text: `Export failed: ${res.error || 'unknown'}`, isError: true });
365
+ return;
366
+ }
367
+ setStatus({ text: `Saved ${res.filename ?? 'export'}`, isError: false });
368
+ void loadHistory();
369
+ return;
370
+ }
216
371
  const r = await fetch('/_api/export', {
217
372
  method: 'POST',
218
373
  headers: { 'content-type': 'application/json' },
@@ -296,6 +451,20 @@ const DialogShell = (() => {
296
451
  const { ref, openState, onClose, onSubmit, history, submitting, status } = props;
297
452
  const [format, setFormat] = useState<Format>('png');
298
453
  const [scope, setScope] = useState<Scope>('artboard');
454
+ const [pngScale, setPngScale] = useState<PngScale>(DEFAULT_PNG_SCALE);
455
+ // Optional — the dialog can be mounted in tests without a provider.
456
+ const selSet = useSelectionSetOptional();
457
+
458
+ // Build the options bag at submit time: snapshot the live selection /
459
+ // active artboard (items 3 & 5) plus the PNG size (item 1).
460
+ const handleSubmit = useCallback(() => {
461
+ const options: Record<string, unknown> = {};
462
+ const hints = captureScopeHints(selSet);
463
+ if (hints.selection) options.selection = hints.selection;
464
+ if (hints.artboardId) options.artboardId = hints.artboardId;
465
+ if (format === 'png') options.scale = pngScale;
466
+ onSubmit(format, scope, options);
467
+ }, [selSet, format, scope, pngScale, onSubmit]);
299
468
 
300
469
  useEffect(() => {
301
470
  if (!openState) return;
@@ -354,6 +523,25 @@ const DialogShell = (() => {
354
523
  </select>
355
524
  <p className="dc-ed-desc">{SCOPE_META[scope].description}</p>
356
525
  </div>
526
+ {format === 'png' && (
527
+ <div>
528
+ <label htmlFor="dc-ed-png-scale">Size</label>
529
+ <select
530
+ id="dc-ed-png-scale"
531
+ value={pngScale}
532
+ onChange={(e) => setPngScale(Number(e.target.value) as PngScale)}
533
+ >
534
+ {PNG_SCALES.map((s) => (
535
+ <option key={s.value} value={s.value}>
536
+ {s.label}
537
+ </option>
538
+ ))}
539
+ </select>
540
+ <p className="dc-ed-desc">
541
+ Resolution multiplier. 2× ≈ 2880×1800 for a 1440×900 artboard.
542
+ </p>
543
+ </div>
544
+ )}
357
545
  </div>
358
546
  {history.length > 0 && (
359
547
  <div className="dc-ed-recent">
@@ -391,7 +579,7 @@ const DialogShell = (() => {
391
579
  type="button"
392
580
  className="dc-ed-primary"
393
581
  disabled={submitting}
394
- onClick={() => onSubmit(format, scope, {})}
582
+ onClick={handleSubmit}
395
583
  >
396
584
  {submitting ? 'Exporting…' : 'Export'}
397
585
  </button>
@@ -40,7 +40,10 @@ async function captureHtml(
40
40
  if (target.multi) {
41
41
  args.push('--multi', '1', '--out-dir', outDir);
42
42
  } else {
43
- args.push('--widen-to-artboard', '1', '--out', path.join(outDir, `${target.canvasSlug}.html`));
43
+ // Widen to the enclosing artboard only when scope.ts requested it; a
44
+ // `selection` target serializes the element exactly.
45
+ if (target.widen) args.push('--widen-to-artboard', '1');
46
+ args.push('--out', path.join(outDir, `${target.canvasSlug}.html`));
44
47
  }
45
48
  const proc = Bun.spawn(['node', ...args], {
46
49
  cwd: path.dirname(HTML_PLAYWRIGHT),
@@ -89,7 +89,10 @@ export async function runExport(args: {
89
89
  resolve: Omit<ResolveScopeArgs, 'scope'>;
90
90
  ctx: ExportContext;
91
91
  }): Promise<ExportResult> {
92
- const targets = await resolveScope({ ...args.resolve, scope: args.scope });
92
+ // Thread the options bag into the resolver so submit-time selection /
93
+ // active-artboard hints (DDR — selection-passthrough) win over the persisted
94
+ // `_active.json`. Additive — existing `resolve` callers stay valid.
95
+ const targets = await resolveScope({ ...args.resolve, scope: args.scope, options: args.options });
93
96
  const adapter = getAdapter(args.format);
94
97
  if (!adapter) {
95
98
  throw new Error(`unknown format: ${args.format}`);
@@ -40,7 +40,13 @@ async function capturePdf(
40
40
  String(timeoutSec),
41
41
  ];
42
42
  if (target.multi) args.push('--multi', '1', '--out-dir', outDir);
43
- else args.push('--out', path.join(outDir, `${target.canvasSlug}.pdf`));
43
+ else {
44
+ // Widen to the enclosing artboard only when scope.ts requested it
45
+ // (artboard-via-descendant fallback). selection / artboard-by-id targets
46
+ // already point at the exact element / screen.
47
+ if (target.widen) args.push('--widen-to-artboard', '1');
48
+ args.push('--out', path.join(outDir, `${target.canvasSlug}.pdf`));
49
+ }
44
50
 
45
51
  const proc = Bun.spawn(['node', ...args], {
46
52
  cwd: path.dirname(PDF_PLAYWRIGHT),
@@ -28,6 +28,17 @@ interface CaptureOptions {
28
28
  timeoutSec?: number;
29
29
  }
30
30
 
31
+ /**
32
+ * Coerce an arbitrary `options.scale` into the 1–3 preset range (default 2×).
33
+ * Exported for unit coverage of the item-1 default/clamp behaviour.
34
+ */
35
+ export function clampScale(raw: unknown): 1 | 2 | 3 {
36
+ const n = Math.round(Number(raw));
37
+ if (n === 1) return 1;
38
+ if (n === 3) return 3;
39
+ return 2;
40
+ }
41
+
31
42
  async function captureElement(
32
43
  target: Extract<Target, { kind: 'element' }>,
33
44
  ctx: ExportContext,
@@ -48,7 +59,12 @@ async function captureElement(
48
59
  if (target.multi) {
49
60
  args.push('--multi', '1', '--out-dir', outDir);
50
61
  } else {
51
- args.push('--widen-to-artboard', '1', '--out', path.join(outDir, `${target.canvasSlug}.png`));
62
+ // Only widen to the enclosing artboard when scope.ts asked for it
63
+ // (artboard-via-descendant fallback). `selection` scope sets widen=false so
64
+ // the capture is the element exactly; artboard-by-id targets the screen
65
+ // element directly and needs no widening.
66
+ if (target.widen) args.push('--widen-to-artboard', '1');
67
+ args.push('--out', path.join(outDir, `${target.canvasSlug}.png`));
52
68
  }
53
69
  const proc = Bun.spawn(['node', ...args], {
54
70
  cwd: path.dirname(PNG_PLAYWRIGHT),
@@ -101,7 +117,10 @@ export async function run(
101
117
 
102
118
  const tmp = mkdtempSync(path.join(tmpdir(), 'maude-png-'));
103
119
  const captureOpts: CaptureOptions = {
104
- scale: (options.scale as 1 | 2 | 3 | undefined) ?? 1,
120
+ // Default a single-scale PNG was uselessly small (item 1). The dialog
121
+ // sends an explicit scale; this default covers direct API / curl callers.
122
+ // Clamped to the 1–3 preset range; the shim re-clamps deviceScaleFactor ≤ 4.
123
+ scale: clampScale(options.scale),
105
124
  timeoutSec: (options.timeoutSec as number | undefined) ?? 8,
106
125
  };
107
126