@1agh/maude 1.0.6 → 1.0.8
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/bin/_html-playwright.mjs +198 -19
- package/apps/studio/bin/_pdf-playwright.mjs +1 -0
- package/apps/studio/bin/_png-playwright.mjs +28 -8
- package/apps/studio/bin/_pptx-playwright.mjs +4 -1
- package/apps/studio/bin/_pw-launch.mjs +48 -0
- package/apps/studio/bin/_svg-playwright.mjs +63 -149
- package/apps/studio/bin/_video-playwright.mjs +123 -11
- package/apps/studio/canvas-edit.ts +33 -0
- package/apps/studio/canvas-lib.tsx +136 -0
- package/apps/studio/client/app.jsx +316 -28
- package/apps/studio/client/export-lane.js +191 -0
- package/apps/studio/dist/client.bundle.js +907 -907
- package/apps/studio/dist/runtime/.min-sizes.json +2 -1
- package/apps/studio/dist/runtime/dom-to-svg.js +31 -0
- package/apps/studio/export-dialog.tsx +24 -19
- package/apps/studio/exporters/_browser-bundles.ts +30 -0
- package/apps/studio/exporters/_runtime.ts +31 -1
- package/apps/studio/exporters/capture-chrome.ts +111 -0
- package/apps/studio/exporters/capture-core.ts +947 -0
- package/apps/studio/exporters/format-scopes.ts +88 -0
- package/apps/studio/exporters/html.ts +3 -2
- package/apps/studio/exporters/jobs.ts +180 -18
- package/apps/studio/exporters/pdf.ts +21 -1
- package/apps/studio/exporters/pptx.ts +46 -11
- package/apps/studio/exporters/remote.ts +94 -11
- package/apps/studio/exporters/scope.ts +14 -3
- package/apps/studio/exporters/svg.ts +17 -3
- package/apps/studio/exporters/video.ts +75 -8
- package/apps/studio/http.ts +175 -7
- package/apps/studio/runtime-bundle.ts +6 -0
- package/apps/studio/test/canvas-hide-chrome.test.ts +30 -0
- package/apps/studio/test/capture-core.test.ts +32 -0
- package/apps/studio/test/export-assemble.test.ts +53 -0
- package/apps/studio/test/export-browser-lane.test.ts +126 -0
- package/apps/studio/test/export-capture-fidelity.test.ts +146 -0
- package/apps/studio/test/export-capture-hygiene.test.ts +675 -0
- package/apps/studio/test/export-e2e-lanes.test.ts +857 -0
- package/apps/studio/test/export-format-scope-coherence.test.ts +89 -0
- package/apps/studio/test/exporters/jobs.test.ts +73 -0
- package/apps/studio/test/exporters/scope.test.ts +33 -0
- package/apps/studio/test/fixtures/tiny-subset.woff2 +0 -0
- package/apps/studio/test/pdf-print-boxes.test.ts +33 -0
- package/apps/studio/test/photo-canvas-bundle.test.ts +18 -0
- package/apps/studio/test/workspace-containment.test.ts +8 -0
- package/apps/studio/whats-new.json +18 -0
- package/apps/studio/workspace-mode.ts +15 -2
- package/package.json +8 -8
- package/plugins/design/templates/_shell.html +2 -1
|
@@ -124,6 +124,16 @@ import {
|
|
|
124
124
|
diffLayoutPositions,
|
|
125
125
|
} from './commands/move-artboards-command.ts';
|
|
126
126
|
import { scopedCdSelector, selectorIndex, shortText } from './dom-selection.ts';
|
|
127
|
+
import { applyCaptureChrome } from './exporters/capture-chrome.ts';
|
|
128
|
+
import {
|
|
129
|
+
type DomToSvgApi,
|
|
130
|
+
pinArtboard,
|
|
131
|
+
rasterizeSvg,
|
|
132
|
+
resetWorldPlane,
|
|
133
|
+
restoreArtboard,
|
|
134
|
+
restoreWorldPlane,
|
|
135
|
+
svgForElement,
|
|
136
|
+
} from './exporters/capture-core.ts';
|
|
127
137
|
// Photo editor (feature-photo-editor) — schema.ts is DEPENDENCY-FREE (no pixi),
|
|
128
138
|
// so this static import is safe and adds zero bundle cost. The WebGL compositor
|
|
129
139
|
// (photo/pipeline.ts, which imports pixi.js) is loaded via a DYNAMIC import
|
|
@@ -1787,8 +1797,134 @@ export function DesignCanvas(props: DesignCanvasProps) {
|
|
|
1787
1797
|
}
|
|
1788
1798
|
DesignCanvas.displayName = 'DesignCanvas';
|
|
1789
1799
|
|
|
1800
|
+
// DDR-231 (hybrid export lanes) — the workspace `browser` export lane.
|
|
1801
|
+
//
|
|
1802
|
+
// In workspace mode the studio shell cannot capture the canvas itself: the
|
|
1803
|
+
// iframe is a separate (untrusted, DDR-054) origin, unreachable by DOM from
|
|
1804
|
+
// the shell. So the SHELL ASKS and the CANVAS CAPTURES — this bridge listens
|
|
1805
|
+
// for an `export-capture` postMessage from the embedding parent, renders the
|
|
1806
|
+
// requested artboards through the shared capture spine (capture-core.ts — the
|
|
1807
|
+
// same code the playwright SVG shim injects, DDR-231's single-spine rule) and
|
|
1808
|
+
// replies with per-artboard Blobs (structured-clonable across the origin
|
|
1809
|
+
// split). dom-to-svg loads LAZILY through the importmap (`dom-to-svg` runtime
|
|
1810
|
+
// bundle — the pixi.js pattern), so a canvas that never exports pays zero
|
|
1811
|
+
// bundle cost.
|
|
1812
|
+
//
|
|
1813
|
+
// Capture mutates live DOM (world zoom reset + artboard pinned to origin) and
|
|
1814
|
+
// restores everything afterwards — the member is looking at this plane. The
|
|
1815
|
+
// shell overlays a veil during capture to cover the brief reflow.
|
|
1816
|
+
function useExportCaptureBridge() {
|
|
1817
|
+
useEffect(() => {
|
|
1818
|
+
if (typeof window === 'undefined' || window.parent === window) return;
|
|
1819
|
+
let busy = false;
|
|
1820
|
+
const onMsg = (e: MessageEvent) => {
|
|
1821
|
+
// Only the embedding shell may drive capture — a message from any other
|
|
1822
|
+
// window (a nested iframe, an opened popup) is ignored outright.
|
|
1823
|
+
if (e.source !== window.parent) return;
|
|
1824
|
+
const m = e.data as {
|
|
1825
|
+
dgn?: string;
|
|
1826
|
+
id?: unknown;
|
|
1827
|
+
format?: unknown;
|
|
1828
|
+
artboardIds?: unknown;
|
|
1829
|
+
scale?: unknown;
|
|
1830
|
+
} | null;
|
|
1831
|
+
if (!m || m.dgn !== 'export-capture' || typeof m.id !== 'string') return;
|
|
1832
|
+
const requestId = m.id;
|
|
1833
|
+
// DDR-231 security (L1): reply to the ASKING origin, never `'*'`. The
|
|
1834
|
+
// done-message carries the rendered artboard blobs; a wildcard target
|
|
1835
|
+
// would leak them to whatever page happens to be the parent if the
|
|
1836
|
+
// shell's `frame-ancestors` CSP were ever absent (tests / misconfig).
|
|
1837
|
+
// `e.origin` here is the embedding shell (we already gated
|
|
1838
|
+
// `e.source === window.parent`); fall back to `'*'` only for an opaque
|
|
1839
|
+
// `"null"` origin where a targeted post is impossible.
|
|
1840
|
+
const replyOrigin = e.origin && e.origin !== 'null' ? e.origin : '*';
|
|
1841
|
+
const reply = (msg: Record<string, unknown>) => {
|
|
1842
|
+
try {
|
|
1843
|
+
window.parent.postMessage(msg, replyOrigin);
|
|
1844
|
+
} catch {
|
|
1845
|
+
/* parent gone — nothing to deliver to */
|
|
1846
|
+
}
|
|
1847
|
+
};
|
|
1848
|
+
if (busy) {
|
|
1849
|
+
reply({
|
|
1850
|
+
dgn: 'export-capture-error',
|
|
1851
|
+
id: requestId,
|
|
1852
|
+
message: 'a capture is already running on this canvas',
|
|
1853
|
+
});
|
|
1854
|
+
return;
|
|
1855
|
+
}
|
|
1856
|
+
busy = true;
|
|
1857
|
+
const format = m.format === 'svg' ? 'svg' : 'png';
|
|
1858
|
+
const scale = Math.max(1, Math.min(8, Number(m.scale) || 1));
|
|
1859
|
+
const wanted = Array.isArray(m.artboardIds)
|
|
1860
|
+
? m.artboardIds.filter((s): s is string => typeof s === 'string')
|
|
1861
|
+
: null;
|
|
1862
|
+
void (async () => {
|
|
1863
|
+
const savedWorld = resetWorldPlane();
|
|
1864
|
+
// Hold the chrome suppression across the WHOLE batch. `svgForElement`
|
|
1865
|
+
// applies it per target too (ref-counted), but a 10-artboard deck would
|
|
1866
|
+
// otherwise add and remove the style ten times — ten forced reflows,
|
|
1867
|
+
// and the member watching the canvas sees the chrome flicker.
|
|
1868
|
+
const releaseChrome = applyCaptureChrome(document);
|
|
1869
|
+
try {
|
|
1870
|
+
const all = Array.from(document.querySelectorAll('[data-dc-screen]'));
|
|
1871
|
+
const targets = wanted?.length
|
|
1872
|
+
? all.filter((el) => wanted.includes(el.getAttribute('data-dc-screen') ?? ''))
|
|
1873
|
+
: all;
|
|
1874
|
+
if (!targets.length) throw new Error('no artboards matched the capture request');
|
|
1875
|
+
const api = (await import('dom-to-svg')) as unknown as DomToSvgApi;
|
|
1876
|
+
const items: { name: string; type: string; blob: Blob }[] = [];
|
|
1877
|
+
for (let i = 0; i < targets.length; i += 1) {
|
|
1878
|
+
const el = targets[i] as Element;
|
|
1879
|
+
const abId = el.getAttribute('data-dc-screen') || `artboard-${i + 1}`;
|
|
1880
|
+
const prevPos = pinArtboard(el);
|
|
1881
|
+
try {
|
|
1882
|
+
const svg = await svgForElement(el, api);
|
|
1883
|
+
if (format === 'svg') {
|
|
1884
|
+
items.push({
|
|
1885
|
+
name: `${abId}.svg`,
|
|
1886
|
+
type: 'image/svg+xml',
|
|
1887
|
+
blob: new Blob([svg], { type: 'image/svg+xml' }),
|
|
1888
|
+
});
|
|
1889
|
+
} else {
|
|
1890
|
+
items.push({
|
|
1891
|
+
name: `${abId}.png`,
|
|
1892
|
+
type: 'image/png',
|
|
1893
|
+
blob: await rasterizeSvg(svg, scale),
|
|
1894
|
+
});
|
|
1895
|
+
}
|
|
1896
|
+
} finally {
|
|
1897
|
+
restoreArtboard(el, prevPos);
|
|
1898
|
+
}
|
|
1899
|
+
reply({
|
|
1900
|
+
dgn: 'export-capture-progress',
|
|
1901
|
+
id: requestId,
|
|
1902
|
+
current: i + 1,
|
|
1903
|
+
total: targets.length,
|
|
1904
|
+
});
|
|
1905
|
+
}
|
|
1906
|
+
reply({ dgn: 'export-capture-done', id: requestId, items });
|
|
1907
|
+
} catch (err) {
|
|
1908
|
+
reply({
|
|
1909
|
+
dgn: 'export-capture-error',
|
|
1910
|
+
id: requestId,
|
|
1911
|
+
message: err instanceof Error ? err.message : String(err),
|
|
1912
|
+
});
|
|
1913
|
+
} finally {
|
|
1914
|
+
releaseChrome();
|
|
1915
|
+
restoreWorldPlane(savedWorld);
|
|
1916
|
+
busy = false;
|
|
1917
|
+
}
|
|
1918
|
+
})();
|
|
1919
|
+
};
|
|
1920
|
+
window.addEventListener('message', onMsg);
|
|
1921
|
+
return () => window.removeEventListener('message', onMsg);
|
|
1922
|
+
}, []);
|
|
1923
|
+
}
|
|
1924
|
+
|
|
1790
1925
|
function DesignCanvasInner({ children, controls }: DesignCanvasProps) {
|
|
1791
1926
|
ensureEngineStyles();
|
|
1927
|
+
useExportCaptureBridge();
|
|
1792
1928
|
|
|
1793
1929
|
const hostRef = useRef<HTMLDivElement | null>(null);
|
|
1794
1930
|
const worldRef = useRef<HTMLDivElement | null>(null);
|
|
@@ -12,6 +12,11 @@ import { createRoot } from 'react-dom/client';
|
|
|
12
12
|
// import that Bun erases), so this pulls only string constants into the client
|
|
13
13
|
// bundle — no React, no input-router. See the tool-cursor handler below.
|
|
14
14
|
import { resolveToolCursor } from '../canvas-cursors.ts';
|
|
15
|
+
import {
|
|
16
|
+
defaultScopeForFormat,
|
|
17
|
+
isScopeValidForFormat,
|
|
18
|
+
validScopesForFormat,
|
|
19
|
+
} from '../exporters/format-scopes.ts';
|
|
15
20
|
// feature-3-web-artboards T5 — the single source (grid-track-handles.ts) for
|
|
16
21
|
// the Inspector's Grid-section track parser/serializer, shared with the
|
|
17
22
|
// on-canvas gutter-drag overlay so both edit the SAME track-list shape.
|
|
@@ -28,6 +33,15 @@ import { sizingModeOf, sizingModePatch } from '../sizing-mode.ts';
|
|
|
28
33
|
// above (a type-only SyncStatusSnapshot import that Bun erases).
|
|
29
34
|
import { syncPresentation } from '../sync/presentation.ts';
|
|
30
35
|
import { canvasUrl } from './canvas-url.js';
|
|
36
|
+
import {
|
|
37
|
+
BROWSER_CAPTURE_FORMATS,
|
|
38
|
+
BROWSER_SERVABLE_FORMATS,
|
|
39
|
+
browserCaptureEligible,
|
|
40
|
+
captureDeckViaBrowser,
|
|
41
|
+
captureScale,
|
|
42
|
+
sanitizeCapturedItems,
|
|
43
|
+
recordBrowserExport,
|
|
44
|
+
} from './export-lane.js';
|
|
31
45
|
import { TreeRowMenu, useRowMenu } from './tree-row-menu.jsx';
|
|
32
46
|
import { useTreeDrag } from './use-tree-drag.js';
|
|
33
47
|
import ChatPanel from './panels/ChatPanel.jsx';
|
|
@@ -1167,18 +1181,6 @@ const EXPORT_SCOPE_LABELS = {
|
|
|
1167
1181
|
'canvas-as-separate': 'Canvas · artboards separate',
|
|
1168
1182
|
'project-raw': 'Whole project (raw)',
|
|
1169
1183
|
};
|
|
1170
|
-
const EXPORT_VALID_SCOPES = {
|
|
1171
|
-
png: ['selection', 'artboard', 'canvas-as-separate'],
|
|
1172
|
-
pdf: ['selection', 'artboard', 'canvas-as-separate'],
|
|
1173
|
-
svg: ['selection', 'artboard', 'canvas-as-separate'],
|
|
1174
|
-
html: ['artboard', 'canvas-as-separate'],
|
|
1175
|
-
pptx: ['canvas-as-separate'],
|
|
1176
|
-
mp4: ['artboard'],
|
|
1177
|
-
gif: ['artboard'],
|
|
1178
|
-
webm: ['artboard'],
|
|
1179
|
-
canva: ['canvas-as-separate'],
|
|
1180
|
-
zip: ['project-raw'],
|
|
1181
|
-
};
|
|
1182
1184
|
const PNG_SCALES = [
|
|
1183
1185
|
{ value: 1, label: '1× (native)' },
|
|
1184
1186
|
{ value: 2, label: '2× (retina)' },
|
|
@@ -1530,6 +1532,21 @@ function AssetPicker({
|
|
|
1530
1532
|
);
|
|
1531
1533
|
}
|
|
1532
1534
|
|
|
1535
|
+
// Direct download for a browser-lane capture. export-center's autoDownloadBlob
|
|
1536
|
+
// fetches a JOB's bytes — a browser capture has no job, just the Blob.
|
|
1537
|
+
function downloadCapturedBlob(name, blob) {
|
|
1538
|
+
const url = URL.createObjectURL(blob);
|
|
1539
|
+
const a = document.createElement('a');
|
|
1540
|
+
a.href = url;
|
|
1541
|
+
a.download = name || 'export';
|
|
1542
|
+
document.body.appendChild(a);
|
|
1543
|
+
a.click();
|
|
1544
|
+
a.remove();
|
|
1545
|
+
// Revoke on a delay — an immediate revoke can race the download start in
|
|
1546
|
+
// some engines (the click only queues the fetch of the object URL).
|
|
1547
|
+
setTimeout(() => URL.revokeObjectURL(url), 10_000);
|
|
1548
|
+
}
|
|
1549
|
+
|
|
1533
1550
|
function ExportDialog({
|
|
1534
1551
|
mode,
|
|
1535
1552
|
initialScope,
|
|
@@ -1539,24 +1556,38 @@ function ExportDialog({
|
|
|
1539
1556
|
activeArtboardId = null,
|
|
1540
1557
|
selection = null,
|
|
1541
1558
|
exportLane = 'local',
|
|
1559
|
+
onBrowserCapture = null,
|
|
1542
1560
|
onClose,
|
|
1543
1561
|
}) {
|
|
1544
1562
|
// feature-cloud-export-render-workers — on a cell with no render service
|
|
1545
1563
|
// (`lane === 'none'`), formats that render through a browser are offered
|
|
1546
1564
|
// disabled-with-a-reason instead of firing a request the proxy 404s. ZIP
|
|
1547
1565
|
// (JSZip over project files) and the AI-handoff card (clipboard copy) need
|
|
1548
|
-
// no browser and
|
|
1566
|
+
// no browser — and DDR-231's browser lane keeps png/svg live in every
|
|
1567
|
+
// workspace lane too (captured by the member's own browser, no service).
|
|
1549
1568
|
const laneBlocked = useCallback(
|
|
1550
1569
|
(c) =>
|
|
1551
|
-
(exportLane === 'none' &&
|
|
1570
|
+
(exportLane === 'none' &&
|
|
1571
|
+
!c.handoff &&
|
|
1572
|
+
c.format !== 'zip' &&
|
|
1573
|
+
!BROWSER_SERVABLE_FORMATS.has(c.format)) ||
|
|
1552
1574
|
// Canva bundles project files the render service can't reach — desktop only
|
|
1553
1575
|
// in every workspace lane (exporters/remote.ts REMOTE_UNSUPPORTED_FORMATS).
|
|
1554
1576
|
(exportLane !== 'local' && c.format === 'canva'),
|
|
1555
1577
|
[exportLane]
|
|
1556
1578
|
);
|
|
1557
|
-
const [sel, setSel] = useState(mode === 'handoff' ? 'shadcn' :
|
|
1558
|
-
|
|
1559
|
-
|
|
1579
|
+
const [sel, setSel] = useState(mode === 'handoff' ? 'shadcn' : 'png');
|
|
1580
|
+
// DDR-231 Phase 2 T4 — seed from the shared table, and only honour an
|
|
1581
|
+
// incoming hint the CHOSEN FORMAT can actually render. A hint the format
|
|
1582
|
+
// can't take (a context menu's `project-raw`, a re-run of a zip history
|
|
1583
|
+
// entry) used to survive into the request and reach the render service as
|
|
1584
|
+
// an unrenderable file-tree target: "invalid render job".
|
|
1585
|
+
// The dialog opens on the PNG card; the format-change effect below re-seeds
|
|
1586
|
+
// the scope whenever the member picks another one.
|
|
1587
|
+
const [scope, setScope] = useState(() =>
|
|
1588
|
+
initialScope && isScopeValidForFormat('png', initialScope)
|
|
1589
|
+
? initialScope
|
|
1590
|
+
: defaultScopeForFormat('png')
|
|
1560
1591
|
);
|
|
1561
1592
|
const [scale, setScale] = useState(2);
|
|
1562
1593
|
// feature-2-print-artboards T4/T6 — PNG resolution (folds scale + dpi, see
|
|
@@ -1580,7 +1611,7 @@ function ExportDialog({
|
|
|
1580
1611
|
const [status, setStatus] = useState(null); // { ok, msg }
|
|
1581
1612
|
const [recent, setRecent] = useState([]);
|
|
1582
1613
|
const card = EXPORT_CARDS.find((c) => c.id === sel) || EXPORT_CARDS[0];
|
|
1583
|
-
const validScopes = card.handoff ? [] :
|
|
1614
|
+
const validScopes = card.handoff ? [] : validScopesForFormat(card.format);
|
|
1584
1615
|
// Long-comp tiers (feature-enhanced-video-editing Task 1). 900 frames is the
|
|
1585
1616
|
// safe tier (the frame-step fallback runs ~2.5 s/frame and 1080p captures
|
|
1586
1617
|
// have died from memory pressure above it); 3600 is the server's default cap
|
|
@@ -1601,10 +1632,22 @@ function ExportDialog({
|
|
|
1601
1632
|
const exportOverCap = card.temporal && exportCompFrames > EXPORT_DEFAULT_CAP;
|
|
1602
1633
|
|
|
1603
1634
|
// Keep the scope valid for the chosen format (pptx/zip etc. only allow a
|
|
1604
|
-
// subset)
|
|
1635
|
+
// subset). Both dialogs and the server now read one table
|
|
1636
|
+
// (exporters/format-scopes.ts) — see DDR-231 Phase 2 T4.
|
|
1605
1637
|
useEffect(() => {
|
|
1606
|
-
if (validScopes.length && !validScopes.includes(scope))
|
|
1607
|
-
|
|
1638
|
+
if (validScopes.length && !validScopes.includes(scope)) {
|
|
1639
|
+
setScope(defaultScopeForFormat(card.format));
|
|
1640
|
+
}
|
|
1641
|
+
}, [validScopes, scope, card.format]);
|
|
1642
|
+
|
|
1643
|
+
// DDR-231 T7 — wake the render service the moment the dialog opens on a
|
|
1644
|
+
// remote-lane workspace: the multi-GB Chromium container starts booting
|
|
1645
|
+
// while the member is still picking format/scope, so a video/PDF job lands
|
|
1646
|
+
// on a warm (or at least warming) service instead of a cold one.
|
|
1647
|
+
useEffect(() => {
|
|
1648
|
+
if (exportLane !== 'remote') return;
|
|
1649
|
+
fetch('/_api/export-warmup').catch(() => {});
|
|
1650
|
+
}, [exportLane]);
|
|
1608
1651
|
|
|
1609
1652
|
const loadRecent = useCallback(() => {
|
|
1610
1653
|
fetch('/_api/export-history')
|
|
@@ -1678,6 +1721,119 @@ function ExportDialog({
|
|
|
1678
1721
|
// the in-canvas dialog's captureScopeHints.
|
|
1679
1722
|
if (activeArtboardId) options.artboardId = activeArtboardId;
|
|
1680
1723
|
if (selection?.selector) options.selection = selection;
|
|
1724
|
+
// Which canvas FILE this dialog is exporting — the server's `_active.json`
|
|
1725
|
+
// lags a tab switch, and a job resolved against the stale file renders the
|
|
1726
|
+
// wrong canvas (with this dialog's artboardId, which then never matches).
|
|
1727
|
+
if (activePath && activePath !== SYSTEM_TAB) options.canvasFile = activePath;
|
|
1728
|
+
// DDR-231 — the browser lane: in a workspace, png/svg of the active
|
|
1729
|
+
// artboard is captured by the member's OWN browser (the canvas already
|
|
1730
|
+
// renders here) — instant, no fleet wake. Everything else continues to
|
|
1731
|
+
// the jobs lane below.
|
|
1732
|
+
// NOTE the format gate. `browserCaptureEligible` answers for the whole
|
|
1733
|
+
// browser lane, pptx included — but this branch is the SINGLE-ARTBOARD
|
|
1734
|
+
// capture that downloads what the bridge returns verbatim. Without the
|
|
1735
|
+
// gate, a pptx export fell in here, asked the bridge for a "pptx" (which it
|
|
1736
|
+
// renders as PNG), and died in `sanitizeCapturedItems` on "not a valid
|
|
1737
|
+
// pptx" — silently degrading to the worker lane, which is why the deck
|
|
1738
|
+
// arrived minutes later with none of the browser lane's fixes in it. The
|
|
1739
|
+
// dedicated deck branch below was unreachable. Found by the T7 export E2E.
|
|
1740
|
+
const browserEligible =
|
|
1741
|
+
BROWSER_CAPTURE_FORMATS.has(card.format) &&
|
|
1742
|
+
browserCaptureEligible({
|
|
1743
|
+
exportLane,
|
|
1744
|
+
format: card.format,
|
|
1745
|
+
scope,
|
|
1746
|
+
artboardId: options.artboardId,
|
|
1747
|
+
}) &&
|
|
1748
|
+
typeof onBrowserCapture === 'function';
|
|
1749
|
+
if (browserEligible) {
|
|
1750
|
+
try {
|
|
1751
|
+
const capScale = captureScale(options);
|
|
1752
|
+
setStatus({ ok: true, msg: 'Capturing…' });
|
|
1753
|
+
const items = await onBrowserCapture({
|
|
1754
|
+
format: card.format,
|
|
1755
|
+
artboardIds: [options.artboardId],
|
|
1756
|
+
scale: capScale,
|
|
1757
|
+
onProgress: (current, total) =>
|
|
1758
|
+
setStatus({ ok: true, msg: `Capturing ${current}/${total}…` }),
|
|
1759
|
+
});
|
|
1760
|
+
// Validate before writing to disk — the capture bridge shares a window
|
|
1761
|
+
// with tenant TSX, which can forge a reply (DDR-231 security pass, F1):
|
|
1762
|
+
// caps count/bytes, sniffs magic, forces name+MIME.
|
|
1763
|
+
const safeItems = await sanitizeCapturedItems(items, card.format);
|
|
1764
|
+
for (const it of safeItems) downloadCapturedBlob(it.name, it.blob);
|
|
1765
|
+
// DDR-231 Phase 2 T6 — the export is DONE and the member should see
|
|
1766
|
+
// that, in the dialog and in the ledger. Phase 1 closed the modal the
|
|
1767
|
+
// instant the download was handed off, so a browser-lane export left
|
|
1768
|
+
// no trace anywhere ("v exports dialog nic nevidim").
|
|
1769
|
+
for (const it of safeItems) {
|
|
1770
|
+
await recordBrowserExport({ format: card.format, scope, filename: it.name });
|
|
1771
|
+
}
|
|
1772
|
+
setBusy(false);
|
|
1773
|
+
setStatus({
|
|
1774
|
+
ok: true,
|
|
1775
|
+
msg: `Saved ${safeItems.map((it) => it.name).join(', ')} to your downloads.`,
|
|
1776
|
+
});
|
|
1777
|
+
return;
|
|
1778
|
+
} catch (err) {
|
|
1779
|
+
if (exportLane !== 'remote') {
|
|
1780
|
+
setStatus({ ok: false, msg: `Capture failed: ${(err && err.message) || err}` });
|
|
1781
|
+
setBusy(false);
|
|
1782
|
+
return;
|
|
1783
|
+
}
|
|
1784
|
+
// A render service exists — degrade to the slower jobs lane instead of
|
|
1785
|
+
// a dead end (and the fallback keeps the worker path exercised).
|
|
1786
|
+
}
|
|
1787
|
+
}
|
|
1788
|
+
// DDR-231 — the pptx deck: capture every artboard as PNG in THIS browser,
|
|
1789
|
+
// compose in-cell (/_api/export-assemble — the zip containment class).
|
|
1790
|
+
if (
|
|
1791
|
+
browserCaptureEligible({ exportLane, format: card.format, scope }) &&
|
|
1792
|
+
card.format === 'pptx' &&
|
|
1793
|
+
typeof onBrowserCapture === 'function'
|
|
1794
|
+
) {
|
|
1795
|
+
try {
|
|
1796
|
+
setStatus({ ok: true, msg: 'Capturing artboards…' });
|
|
1797
|
+
const deckName =
|
|
1798
|
+
activePath && activePath !== SYSTEM_TAB
|
|
1799
|
+
? basename(activePath).replace(/\.[^.]+$/, '')
|
|
1800
|
+
: 'export';
|
|
1801
|
+
const { filename, blob } = await captureDeckViaBrowser({
|
|
1802
|
+
capture: onBrowserCapture,
|
|
1803
|
+
name: deckName,
|
|
1804
|
+
onProgress: (current, total) =>
|
|
1805
|
+
setStatus({ ok: true, msg: `Capturing ${current}/${total}…` }),
|
|
1806
|
+
// Composition happens in-cell AFTER the last capture, and it is not
|
|
1807
|
+
// instant for a 10-slide deck. Without this the status sat on
|
|
1808
|
+
// "Capturing 10/10…" through the whole assemble step and the deck
|
|
1809
|
+
// then appeared out of nowhere — the reported "modal closes, nothing
|
|
1810
|
+
// happens, and after a while it downloads by itself".
|
|
1811
|
+
onAssemble: () => setStatus({ ok: true, msg: 'Assembling deck…' }),
|
|
1812
|
+
});
|
|
1813
|
+
downloadCapturedBlob(filename, blob);
|
|
1814
|
+
await recordBrowserExport({ format: card.format, scope, filename });
|
|
1815
|
+
setBusy(false);
|
|
1816
|
+
setStatus({ ok: true, msg: `Saved ${filename} to your downloads.` });
|
|
1817
|
+
return;
|
|
1818
|
+
} catch (err) {
|
|
1819
|
+
if (exportLane !== 'remote') {
|
|
1820
|
+
setStatus({ ok: false, msg: `Deck export failed: ${(err && err.message) || err}` });
|
|
1821
|
+
setBusy(false);
|
|
1822
|
+
return;
|
|
1823
|
+
}
|
|
1824
|
+
// Render service available — degrade to the jobs lane below.
|
|
1825
|
+
}
|
|
1826
|
+
}
|
|
1827
|
+
if (exportLane === 'none' && BROWSER_CAPTURE_FORMATS.has(card.format)) {
|
|
1828
|
+
// Without a render service the browser can only capture what it renders:
|
|
1829
|
+
// the active artboard. Other scopes have nowhere to run.
|
|
1830
|
+
setStatus({
|
|
1831
|
+
ok: false,
|
|
1832
|
+
msg: 'Without the render service this workspace exports PNG/SVG of the active artboard only — switch Scope to “Active artboard”, or ask your admin to add maude-render.',
|
|
1833
|
+
});
|
|
1834
|
+
setBusy(false);
|
|
1835
|
+
return;
|
|
1836
|
+
}
|
|
1681
1837
|
try {
|
|
1682
1838
|
// feature-background-export-notification-center — enqueue and close
|
|
1683
1839
|
// immediately; the menubar notification center owns status, progress,
|
|
@@ -1725,6 +1881,10 @@ function ExportDialog({
|
|
|
1725
1881
|
<button
|
|
1726
1882
|
type="button"
|
|
1727
1883
|
key={c.id}
|
|
1884
|
+
// Stable hooks for the export E2E harnesses (agent-browser web
|
|
1885
|
+
// + desktop-e2e native) — data-testid convention, DDR-231
|
|
1886
|
+
// Phase 2 T7/T8.
|
|
1887
|
+
data-testid={`export-format-${c.id}`}
|
|
1728
1888
|
className={'st-fmt' + (c.id === sel ? ' is-on' : '')}
|
|
1729
1889
|
disabled={laneBlocked(c)}
|
|
1730
1890
|
title={
|
|
@@ -1745,10 +1905,11 @@ function ExportDialog({
|
|
|
1745
1905
|
</div>
|
|
1746
1906
|
{exportLane === 'none' && (
|
|
1747
1907
|
<div className="st-dialog-note" data-testid="export-lane-note">
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
1908
|
+
PNG and SVG of the active artboard — and the PPTX deck — export right
|
|
1909
|
+
here in your browser; ZIP and AI handoff work too. PDF, video and other
|
|
1910
|
+
multi-artboard exports need the render service, which this workspace
|
|
1911
|
+
doesn't have configured — use the desktop app, or ask your admin to
|
|
1912
|
+
add the <code> maude-render</code> service (see the self-hosting docs).
|
|
1752
1913
|
</div>
|
|
1753
1914
|
)}
|
|
1754
1915
|
{!card.handoff && (
|
|
@@ -1758,6 +1919,7 @@ function ExportDialog({
|
|
|
1758
1919
|
</label>
|
|
1759
1920
|
<select
|
|
1760
1921
|
id="st-export-scope"
|
|
1922
|
+
data-testid="export-scope"
|
|
1761
1923
|
className="st-select"
|
|
1762
1924
|
value={scope}
|
|
1763
1925
|
onChange={(e) => setScope(e.target.value)}
|
|
@@ -1940,12 +2102,14 @@ function ExportDialog({
|
|
|
1940
2102
|
<div
|
|
1941
2103
|
className={'callout ' + (status.ok ? 'callout--success' : 'callout--error')}
|
|
1942
2104
|
style={{ fontSize: 12 }}
|
|
2105
|
+
data-testid="export-status"
|
|
2106
|
+
data-ok={status.ok ? '1' : '0'}
|
|
1943
2107
|
>
|
|
1944
2108
|
{status.msg}
|
|
1945
2109
|
</div>
|
|
1946
2110
|
)}
|
|
1947
2111
|
{recent.length > 0 && (
|
|
1948
|
-
<div className="st-export-recent">
|
|
2112
|
+
<div className="st-export-recent" data-testid="export-recent">
|
|
1949
2113
|
<div className="st-rp-hd">Recent</div>
|
|
1950
2114
|
{recent.map((h, i) => (
|
|
1951
2115
|
<div className="st-export-recent-row" key={i}>
|
|
@@ -1963,7 +2127,13 @@ function ExportDialog({
|
|
|
1963
2127
|
<button type="button" className="btn btn--ghost" onClick={onClose}>
|
|
1964
2128
|
Cancel
|
|
1965
2129
|
</button>
|
|
1966
|
-
<button
|
|
2130
|
+
<button
|
|
2131
|
+
type="button"
|
|
2132
|
+
className="btn btn--primary"
|
|
2133
|
+
data-testid="export-submit"
|
|
2134
|
+
disabled={busy}
|
|
2135
|
+
onClick={doExport}
|
|
2136
|
+
>
|
|
1967
2137
|
<StIcon name="download" size={14} />
|
|
1968
2138
|
{card.handoff
|
|
1969
2139
|
? 'Copy handoff command'
|
|
@@ -10286,6 +10456,58 @@ function App() {
|
|
|
10286
10456
|
[activePath]
|
|
10287
10457
|
);
|
|
10288
10458
|
|
|
10459
|
+
// DDR-231 (hybrid export lanes) — ask the active canvas iframe to capture
|
|
10460
|
+
// its own artboards (canvas-lib's useExportCaptureBridge) and hand back the
|
|
10461
|
+
// blobs. The shell can't reach the cross-origin canvas DOM, and the sandbox
|
|
10462
|
+
// omits allow-downloads, so this is the division of labour: the CANVAS
|
|
10463
|
+
// captures (it renders the pixels), the SHELL downloads. Resolves with
|
|
10464
|
+
// [{ name, type, blob }]; rejects on canvas error or timeout.
|
|
10465
|
+
const browserCaptureSeq = useRef(0);
|
|
10466
|
+
const captureFromCanvas = useCallback(
|
|
10467
|
+
({ format, artboardIds = null, scale = 1, onProgress, timeoutMs = 120_000 }) =>
|
|
10468
|
+
new Promise((resolve, reject) => {
|
|
10469
|
+
const el = activePath ? iframesRef.current.get(activePath) : null;
|
|
10470
|
+
if (!el || !el.contentWindow) {
|
|
10471
|
+
reject(new Error('no active canvas to capture'));
|
|
10472
|
+
return;
|
|
10473
|
+
}
|
|
10474
|
+
const cw = el.contentWindow;
|
|
10475
|
+
browserCaptureSeq.current += 1;
|
|
10476
|
+
const id = `cap-${Date.now().toString(36)}-${browserCaptureSeq.current}`;
|
|
10477
|
+
let settled = false;
|
|
10478
|
+
const finish = (fn, arg) => {
|
|
10479
|
+
if (settled) return;
|
|
10480
|
+
settled = true;
|
|
10481
|
+
window.removeEventListener('message', onMsg);
|
|
10482
|
+
clearTimeout(timer);
|
|
10483
|
+
fn(arg);
|
|
10484
|
+
};
|
|
10485
|
+
const onMsg = (e) => {
|
|
10486
|
+
// Only answers from the iframe we asked — a message with a matching
|
|
10487
|
+
// id from any other window is ignored.
|
|
10488
|
+
if (e.source !== cw) return;
|
|
10489
|
+
const m = e.data;
|
|
10490
|
+
if (!m || m.id !== id) return;
|
|
10491
|
+
if (m.dgn === 'export-capture-progress') onProgress?.(m.current, m.total);
|
|
10492
|
+
else if (m.dgn === 'export-capture-done')
|
|
10493
|
+
finish(resolve, Array.isArray(m.items) ? m.items : []);
|
|
10494
|
+
else if (m.dgn === 'export-capture-error')
|
|
10495
|
+
finish(reject, new Error(m.message || 'capture failed'));
|
|
10496
|
+
};
|
|
10497
|
+
const timer = setTimeout(
|
|
10498
|
+
() => finish(reject, new Error('capture timed out — the canvas did not answer')),
|
|
10499
|
+
timeoutMs
|
|
10500
|
+
);
|
|
10501
|
+
window.addEventListener('message', onMsg);
|
|
10502
|
+
try {
|
|
10503
|
+
cw.postMessage({ dgn: 'export-capture', id, format, artboardIds, scale }, '*');
|
|
10504
|
+
} catch (err) {
|
|
10505
|
+
finish(reject, err);
|
|
10506
|
+
}
|
|
10507
|
+
}),
|
|
10508
|
+
[activePath]
|
|
10509
|
+
);
|
|
10510
|
+
|
|
10289
10511
|
// ── feature-photo-editor — the Photo tab's three channels ─────────────────
|
|
10290
10512
|
// (1) live preview: broadcast the edit DOWN to the active canvas iframe, whose
|
|
10291
10513
|
// canvas-lib `PhotoPreviewBridge` bakes the composite and swaps it directly
|
|
@@ -12991,6 +13213,16 @@ function App() {
|
|
|
12991
13213
|
// report status back to the iframe. Origin is already validated
|
|
12992
13214
|
// (e.origin === expectedOrigin) above, so only the real canvas iframe
|
|
12993
13215
|
// can ask — this is NOT a generic fetch proxy.
|
|
13216
|
+
//
|
|
13217
|
+
// DDR-231 security (M1): additionally require the request come from the
|
|
13218
|
+
// ACTIVE canvas — captureFromCanvas + the browser-lane branch capture
|
|
13219
|
+
// and auto-download `activePath`'s pixels regardless of who asked, so
|
|
13220
|
+
// WITHOUT this gate a background (untrusted, DDR-054) same-origin canvas
|
|
13221
|
+
// could `postMessage({dgn:'export-request'})` with no user gesture and
|
|
13222
|
+
// force a silent download of whatever the user is currently looking at.
|
|
13223
|
+
// Same one-liner every sibling mutating branch already uses.
|
|
13224
|
+
const activeWin = activePath ? iframesRef.current.get(activePath)?.contentWindow : null;
|
|
13225
|
+
if (e.source !== activeWin) return;
|
|
12994
13226
|
void runBridgedExport(e.source, m.id, m.payload);
|
|
12995
13227
|
} else if (m.dgn === 'export-history-request' && m.id) {
|
|
12996
13228
|
// Same bridge for the dialog's Recent tab (/_api/export-history is
|
|
@@ -13001,11 +13233,65 @@ function App() {
|
|
|
13001
13233
|
// Reply target for the export bridge: the canvas iframe's own origin.
|
|
13002
13234
|
const replyOrigin = cfg?.canvasOrigin || window.location.origin;
|
|
13003
13235
|
async function runBridgedExport(source, id, payload) {
|
|
13236
|
+
// The in-canvas dialog exports the canvas it LIVES IN — which the M1
|
|
13237
|
+
// gate above just proved is `activePath`. Stamp it so scope resolution
|
|
13238
|
+
// never falls back to the server's (asynchronously written, possibly
|
|
13239
|
+
// stale) `_active.json` — same rule as the shell dialog's canvasFile.
|
|
13240
|
+
// OVERWRITE, not fill-if-absent: the M1 gate above proved the sender IS
|
|
13241
|
+
// the active canvas iframe (untrusted, DDR-054), so a canvasFile IT
|
|
13242
|
+
// supplied must not survive — the bridged export renders the canvas it
|
|
13243
|
+
// lives in, full stop (security review W2).
|
|
13244
|
+
if (payload?.options && activePath && activePath !== SYSTEM_TAB) {
|
|
13245
|
+
payload.options.canvasFile = activePath;
|
|
13246
|
+
}
|
|
13004
13247
|
const reply = (msg) => {
|
|
13005
13248
|
try {
|
|
13006
13249
|
if (source) source.postMessage({ dgn: 'export-result', id, ...msg }, replyOrigin);
|
|
13007
13250
|
} catch {}
|
|
13008
13251
|
};
|
|
13252
|
+
// DDR-231 — the browser lane, mirrored for the IN-CANVAS dialog: png/svg
|
|
13253
|
+
// of a known artboard is captured by the asking canvas itself (the shell
|
|
13254
|
+
// relays the request back over the export-capture bridge and downloads
|
|
13255
|
+
// the blobs — the sandboxed iframe can't download, the shell can).
|
|
13256
|
+
const opts = payload?.options || {};
|
|
13257
|
+
const bridgedBrowserEligible = browserCaptureEligible({
|
|
13258
|
+
exportLane: cfg.exportLane || 'local',
|
|
13259
|
+
format: payload?.format,
|
|
13260
|
+
scope: payload?.scope,
|
|
13261
|
+
artboardId: opts.artboardId,
|
|
13262
|
+
});
|
|
13263
|
+
if (bridgedBrowserEligible) {
|
|
13264
|
+
try {
|
|
13265
|
+
if (payload.format === 'pptx') {
|
|
13266
|
+
const { filename, blob } = await captureDeckViaBrowser({
|
|
13267
|
+
capture: captureFromCanvas,
|
|
13268
|
+
name: activePath ? activePath.replace(/^.*\//, '').replace(/\.[^.]+$/, '') : 'export',
|
|
13269
|
+
});
|
|
13270
|
+
downloadCapturedBlob(filename, blob);
|
|
13271
|
+
} else {
|
|
13272
|
+
const capScale = captureScale(opts);
|
|
13273
|
+
const items = await captureFromCanvas({
|
|
13274
|
+
format: payload.format,
|
|
13275
|
+
artboardIds: [opts.artboardId],
|
|
13276
|
+
scale: capScale,
|
|
13277
|
+
});
|
|
13278
|
+
const safeItems = await sanitizeCapturedItems(items, payload.format);
|
|
13279
|
+
for (const it of safeItems) downloadCapturedBlob(it.name, it.blob);
|
|
13280
|
+
}
|
|
13281
|
+
reply({ ok: true, browser: true });
|
|
13282
|
+
return;
|
|
13283
|
+
} catch {
|
|
13284
|
+
if (cfg.exportLane !== 'remote') {
|
|
13285
|
+
reply({
|
|
13286
|
+
ok: false,
|
|
13287
|
+
error:
|
|
13288
|
+
'Capturing in the browser failed and this workspace has no render service to fall back to — try again, or ask your admin to add maude-render.',
|
|
13289
|
+
});
|
|
13290
|
+
return;
|
|
13291
|
+
}
|
|
13292
|
+
// Render service available — degrade to the jobs lane below.
|
|
13293
|
+
}
|
|
13294
|
+
}
|
|
13009
13295
|
// feature-cloud-export-render-workers — a workspace with no render
|
|
13010
13296
|
// service can't produce browser-rendered formats; answer the in-canvas
|
|
13011
13297
|
// dialog with the reason instead of relaying a request the proxy 404s.
|
|
@@ -13014,7 +13300,7 @@ function App() {
|
|
|
13014
13300
|
reply({
|
|
13015
13301
|
ok: false,
|
|
13016
13302
|
error:
|
|
13017
|
-
'This format needs the render service, which this workspace doesn’t have configured.
|
|
13303
|
+
'This format needs the render service, which this workspace doesn’t have configured. PNG/SVG of the active artboard and the PPTX deck export straight from the browser; ZIP works too. For the rest use the desktop app or ask your admin to add maude-render.',
|
|
13018
13304
|
});
|
|
13019
13305
|
return;
|
|
13020
13306
|
}
|
|
@@ -13068,6 +13354,7 @@ function App() {
|
|
|
13068
13354
|
broadcastChrome,
|
|
13069
13355
|
activePath,
|
|
13070
13356
|
postToActiveCanvas,
|
|
13357
|
+
captureFromCanvas,
|
|
13071
13358
|
]);
|
|
13072
13359
|
|
|
13073
13360
|
// Tell the active canvas iframe to drop any persistent selection (canvas
|
|
@@ -15831,6 +16118,7 @@ function App() {
|
|
|
15831
16118
|
activeArtboardId={selected?.artboardId ?? canvasActiveArtboard ?? null}
|
|
15832
16119
|
selection={selected?.selector ? { selector: selected.selector, file: selected.file } : null}
|
|
15833
16120
|
exportLane={cfg.exportLane || 'local'}
|
|
16121
|
+
onBrowserCapture={captureFromCanvas}
|
|
15834
16122
|
onClose={() => setExportDialog(null)}
|
|
15835
16123
|
/>
|
|
15836
16124
|
)}
|