dsh-zen-remote 1.1.13 → 1.1.14
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/README.md +3 -2
- package/README.zh-CN.md +3 -2
- package/docs/interface.md +46 -3
- package/lib/client.js +3421 -103
- package/lib/index.js +13 -0
- package/lib/index.js.map +1 -1
- package/lib/share-export.js +347 -0
- package/lib/share-export.js.map +1 -0
- package/lib/types/client/MobileAttachButton.d.ts +8 -1
- package/lib/types/client/MobileAttachButton.d.ts.map +1 -1
- package/lib/types/client/MobileAttachChips.d.ts.map +1 -1
- package/lib/types/client/MobileSessionHeader.d.ts +7 -8
- package/lib/types/client/MobileSessionHeader.d.ts.map +1 -1
- package/lib/types/client/MobileSessionInfo.d.ts.map +1 -1
- package/lib/types/client/effects/keyboard-avoid.d.ts.map +1 -1
- package/lib/types/client/effects/keyboard-guard.d.ts +16 -11
- package/lib/types/client/effects/keyboard-guard.d.ts.map +1 -1
- package/lib/types/client/host-attach.d.ts +38 -0
- package/lib/types/client/host-attach.d.ts.map +1 -0
- package/lib/types/client/index.d.ts.map +1 -1
- package/lib/types/client/locales.d.ts +23 -0
- package/lib/types/client/locales.d.ts.map +1 -1
- package/lib/types/client/share/fetch-share.d.ts +77 -0
- package/lib/types/client/share/fetch-share.d.ts.map +1 -0
- package/lib/types/client/share/markdown.d.ts +97 -0
- package/lib/types/client/share/markdown.d.ts.map +1 -0
- package/lib/types/client/share/png-stitch.d.ts +96 -0
- package/lib/types/client/share/png-stitch.d.ts.map +1 -0
- package/lib/types/client/share/rasterize.d.ts +317 -0
- package/lib/types/client/share/rasterize.d.ts.map +1 -0
- package/lib/types/client/share/share-card.d.ts +93 -0
- package/lib/types/client/share/share-card.d.ts.map +1 -0
- package/lib/types/client/share/share-constants.d.ts +27 -0
- package/lib/types/client/share/share-constants.d.ts.map +1 -0
- package/lib/types/client/share/share-flow.d.ts +76 -0
- package/lib/types/client/share/share-flow.d.ts.map +1 -0
- package/lib/types/client/share/share-preview.d.ts +11 -0
- package/lib/types/client/share/share-preview.d.ts.map +1 -0
- package/lib/types/client/share/share-sheet.d.ts +17 -0
- package/lib/types/client/share/share-sheet.d.ts.map +1 -0
- package/lib/types/client/share/text-layout.d.ts +48 -0
- package/lib/types/client/share/text-layout.d.ts.map +1 -0
- package/lib/types/client/styles/header.css.d.ts +1 -1
- package/lib/types/client/styles/header.css.d.ts.map +1 -1
- package/lib/types/client/styles/layout.css.d.ts +1 -1
- package/lib/types/client/styles/layout.css.d.ts.map +1 -1
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/share-export.d.ts +66 -0
- package/lib/types/share-export.d.ts.map +1 -0
- package/package.json +7 -2
- package/scripts/check-host-shadow.mjs +41 -9
- package/scripts/check-keyboard-avoid.mjs +4 -2
- package/scripts/check-share-image.mjs +970 -0
- package/scripts/check-upload-endpoint.mjs +10 -3
- package/src/client/MobileAttachButton.tsx +13 -0
- package/src/client/MobileAttachChips.tsx +8 -1
- package/src/client/MobileSessionHeader.tsx +90 -30
- package/src/client/MobileSessionInfo.tsx +234 -1
- package/src/client/effects/keyboard-avoid.ts +14 -7
- package/src/client/effects/keyboard-guard.ts +28 -18
- package/src/client/host-attach.ts +54 -0
- package/src/client/index.tsx +16 -0
- package/src/client/locales.ts +46 -0
- package/src/client/share/fetch-share.ts +161 -0
- package/src/client/share/markdown.ts +609 -0
- package/src/client/share/png-stitch.ts +281 -0
- package/src/client/share/rasterize.ts +1078 -0
- package/src/client/share/share-card.tsx +637 -0
- package/src/client/share/share-constants.ts +30 -0
- package/src/client/share/share-flow.ts +166 -0
- package/src/client/share/share-preview.tsx +337 -0
- package/src/client/share/share-sheet.tsx +222 -0
- package/src/client/share/text-layout.ts +147 -0
- package/src/client/styles/header.css.ts +30 -0
- package/src/client/styles/layout.css.ts +5 -2
- package/src/index.ts +13 -0
- package/src/share-export.ts +431 -0
package/lib/client.js
CHANGED
|
@@ -1534,84 +1534,2759 @@ function MobileHeaderActions({ sessionId, useSessions, t }) {
|
|
|
1534
1534
|
const openInfoCard = () => { window.dispatchEvent(new CustomEvent(nav_store_ts_1.SESSION_INFO_EVENT)); };
|
|
1535
1535
|
return ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsx)("button", { type: "button", "data-mobile-nav": "header-back", "aria-label": t('backToList'), title: t('backToList'), onClick: () => window.dispatchEvent(new CustomEvent(nav_store_ts_1.GO_HOME_EVENT)), children: (0, jsx_runtime_1.jsx)(dsh_client_ui_primitives_1.IconChevronLeftOutline14, { size: 20 }) }), tabs.length > 1 && active !== undefined && ((0, jsx_runtime_1.jsxs)("button", { type: "button", "data-mobile-nav": "header-viewrow", "aria-label": t('switchView'), onClick: () => tabs.find((tab) => !tab.active)?.el.click(), children: [(0, jsx_runtime_1.jsx)("span", { "data-mobile-nav": "header-viewrow-label", children: active.label }), (0, jsx_runtime_1.jsx)("span", { "data-mobile-nav": "header-viewrow-dots", "aria-hidden": "true", children: tabs.map((tab, index) => ((0, jsx_runtime_1.jsx)("i", { "data-active": tab.active ? '' : undefined }, index))) })] })), hasActivity && ((0, jsx_runtime_1.jsxs)("div", { "data-mobile-nav": "header-activity", children: [(0, jsx_runtime_1.jsx)(ActivityPill, { kind: "subagent", count: subagents.length, state: subagentState, label: t('infoSubagents', { count: subagents.length }), trigger: SUBAGENT_TRIGGER, onFallback: openInfoCard, Icon: IconSubagentOutline14 }), (0, jsx_runtime_1.jsx)(ActivityPill, { kind: "job", count: jobs.length, state: jobState, label: t('infoJobs', { count: jobs.length }), trigger: JOBS_TRIGGER, onFallback: openInfoCard, Icon: IconTaskOutline14 })] }))] }));
|
|
1536
1536
|
}
|
|
1537
|
+
const BETTER_ROOT = '[data-dsh-better-sidebar]';
|
|
1538
|
+
const BETTER_TOGGLE = '[data-dsh-better-sidebar] button[class$="_toggleButton"]';
|
|
1539
|
+
const OFFICIAL_PANEL = '[data-sidebar-right-panel]';
|
|
1540
|
+
const OFFICIAL_EXPAND = '[data-sidebar-right-expand]';
|
|
1541
|
+
const OFFICIAL_COLLAPSE = '[data-sidebar-right-toggle]';
|
|
1542
|
+
/** Synchronous read of which sidebar backend this host offers. */
|
|
1543
|
+
function readSidebarTarget() {
|
|
1544
|
+
if (document.querySelector(BETTER_ROOT) !== null)
|
|
1545
|
+
return 'better';
|
|
1546
|
+
if (document.querySelector(OFFICIAL_PANEL) !== null)
|
|
1547
|
+
return 'official';
|
|
1548
|
+
return null;
|
|
1549
|
+
}
|
|
1550
|
+
/**
|
|
1551
|
+
* Toggle whichever sidebar `target` names, by clicking the official/plugin
|
|
1552
|
+
* control through its stable marker. The official pair is open-only in the
|
|
1553
|
+
* corner (`ExpandButton` unmounts once shown) and close-only inside the
|
|
1554
|
+
* panel (`data-sidebar-right-toggle` rides the panel's own strip), so the
|
|
1555
|
+
* open state is read off the panel's `data-sidebar-right-open` attribute and
|
|
1556
|
+
* the matching control is clicked. Synthetic `.click()` fires both React
|
|
1557
|
+
* handlers through `display: none` (the tablist precedent this plugin
|
|
1558
|
+
* already relies on for the view switch).
|
|
1559
|
+
*/
|
|
1560
|
+
function toggleSidebarTarget(target) {
|
|
1561
|
+
if (target === 'better') {
|
|
1562
|
+
document.querySelector(BETTER_TOGGLE)?.click();
|
|
1563
|
+
return;
|
|
1564
|
+
}
|
|
1565
|
+
if (target === 'official') {
|
|
1566
|
+
const shown = document.querySelector(`${OFFICIAL_PANEL}[data-sidebar-right-open]`) !== null;
|
|
1567
|
+
const control = shown
|
|
1568
|
+
? document.querySelector(OFFICIAL_COLLAPSE)
|
|
1569
|
+
: document.querySelector(OFFICIAL_EXPAND);
|
|
1570
|
+
control?.click();
|
|
1571
|
+
}
|
|
1572
|
+
}
|
|
1573
|
+
/** Live mirror of {@link readSidebarTarget} — same observer pattern as the
|
|
1574
|
+
* old workbench presence gate it replaces (plugins load after us; the right
|
|
1575
|
+
* panel mounts with the frame). */
|
|
1576
|
+
function useSidebarTarget() {
|
|
1577
|
+
const [target, setTarget] = (0, react_1.useState)(readSidebarTarget);
|
|
1578
|
+
(0, react_1.useEffect)(() => {
|
|
1579
|
+
const check = () => setTarget(readSidebarTarget());
|
|
1580
|
+
const observer = new MutationObserver(check);
|
|
1581
|
+
observer.observe(document.body, { childList: true, subtree: true });
|
|
1582
|
+
check();
|
|
1583
|
+
return () => observer.disconnect();
|
|
1584
|
+
}, []);
|
|
1585
|
+
return target;
|
|
1586
|
+
}
|
|
1587
|
+
/**
|
|
1588
|
+
* Session header, right lane: the session-info entry (S4 owns the actual
|
|
1589
|
+
* sheet — this fires a hook event for it to pick up) and the sidebar entry.
|
|
1590
|
+
* The sidebar button routes per {@link SidebarTarget}: better-sidebar's own
|
|
1591
|
+
* toggle (no public "open the panel" API — BetterSidebarService.openTab only
|
|
1592
|
+
* auto-expands for a content open, not a bare type-only open, so it clicks
|
|
1593
|
+
* the plugin's real toggle through its root marker `[data-dsh-better-
|
|
1594
|
+
* sidebar]` plus the `_toggleButton` class suffix, verified live 2026-08-17),
|
|
1595
|
+
* the official right sidebar's controls, or nothing when neither exists.
|
|
1596
|
+
*/
|
|
1597
|
+
function MobileHeaderUtilities({ t }) {
|
|
1598
|
+
// Better-sidebar phone close button (S3.1 follow-up, 2026-08-17): the
|
|
1599
|
+
// panel's own top-right toggle cluster is hidden below 768px
|
|
1600
|
+
// (styles/compat.css.ts) because it duplicates the workbench button
|
|
1601
|
+
// below — but that cluster is also the panel's ONLY close control, so
|
|
1602
|
+
// hiding it blindly leaves an open panel with no way out. This button is
|
|
1603
|
+
// appended straight to document.body, mirroring the existing
|
|
1604
|
+
// preview-full-toggle pattern in MobileNavOverlay.tsx (raw DOM, not a
|
|
1605
|
+
// React portal — react-dom is not among this plugin's platform-module
|
|
1606
|
+
// imports, see AGENTS.md "client import purity"): never inside the
|
|
1607
|
+
// panel's own subtree (the third party's React re-renders would wipe
|
|
1608
|
+
// it) and never under any transformed/backdrop-filter ancestor (the S4
|
|
1609
|
+
// info-card WebKit lesson in AGENTS.md — position:fixed would re-anchor
|
|
1610
|
+
// to that ancestor instead of the viewport). It clicks the SAME hidden
|
|
1611
|
+
// official toggle the workbench button below uses. Visibility is pure
|
|
1612
|
+
// CSS (styles/compat.css.ts: `body:has([data-dsh-better-sidebar]
|
|
1613
|
+
// [class$="_panel"])` — the panel's class ends in "_panel" only while
|
|
1614
|
+
// open, "_panelHidden" is appended once closed), so this effect only
|
|
1615
|
+
// has to guarantee the node exists — no MutationObserver needed to
|
|
1616
|
+
// track open/closed state. Icon paths copied verbatim from
|
|
1617
|
+
// IconCloseOutline16 (primitives) for the same reason IconInfoOutline16
|
|
1618
|
+
// above is hand-built: this button lives outside the React tree, so it
|
|
1619
|
+
// cannot render a primitives component directly.
|
|
1620
|
+
(0, react_1.useEffect)(() => {
|
|
1621
|
+
const button = document.createElement('button');
|
|
1622
|
+
button.type = 'button';
|
|
1623
|
+
button.dataset.mobileNav = 'better-sidebar-close';
|
|
1624
|
+
button.setAttribute('aria-label', t('workbenchClose'));
|
|
1625
|
+
button.title = t('workbenchClose');
|
|
1626
|
+
// Bottom-center labeled pill (real-device follow-up, 2026-08-17): the
|
|
1627
|
+
// icon markup is a static trusted string (safe as innerHTML), but the
|
|
1628
|
+
// locale label is untrusted-shaped text — built as a real text node via
|
|
1629
|
+
// textContent, not string-concatenated into the same innerHTML, so a
|
|
1630
|
+
// translation can never be parsed as markup.
|
|
1631
|
+
button.innerHTML = '<svg viewBox="0 0 16 16" fill="none" aria-hidden="true">'
|
|
1632
|
+
+ '<path d="M14.1168 13.197L13.197 14.1167L1.8833 2.80303L2.80309 1.88324L14.1168 13.197Z" fill="currentColor"/>'
|
|
1633
|
+
+ '<path d="M13.197 1.88326L14.1168 2.80305L2.80309 14.1168L1.8833 13.197L13.197 1.88326Z" fill="currentColor"/>'
|
|
1634
|
+
+ '</svg>';
|
|
1635
|
+
const label = document.createElement('span');
|
|
1636
|
+
label.textContent = t('workbenchClose');
|
|
1637
|
+
button.appendChild(label);
|
|
1638
|
+
const onClick = () => {
|
|
1639
|
+
document.querySelector('[data-dsh-better-sidebar] button[class$="_toggleButton"]')?.click();
|
|
1640
|
+
};
|
|
1641
|
+
button.addEventListener('click', onClick);
|
|
1642
|
+
document.body.appendChild(button);
|
|
1643
|
+
return () => {
|
|
1644
|
+
button.removeEventListener('click', onClick);
|
|
1645
|
+
button.remove();
|
|
1646
|
+
};
|
|
1647
|
+
}, [t]);
|
|
1648
|
+
// Sidebar routing (2026-09-11): better-sidebar installed → its toggle (the
|
|
1649
|
+
// original design); else the official right sidebar (DSH 0.1.5+); else the
|
|
1650
|
+
// button stays hidden — it used to be a dead control without the plugin
|
|
1651
|
+
// (2026-08-17 user question), and the same must hold for a host with no
|
|
1652
|
+
// right sidebar either. The close pill above keeps its own better-sidebar
|
|
1653
|
+
// CSS :has() gate, unchanged.
|
|
1654
|
+
const sidebar = useSidebarTarget();
|
|
1655
|
+
return ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [(0, jsx_runtime_1.jsx)("button", { type: "button", "data-mobile-nav": "header-info", "aria-label": t('sessionInfo'), title: t('sessionInfo'), onClick: () => window.dispatchEvent(new CustomEvent(nav_store_ts_1.SESSION_INFO_EVENT)), children: (0, jsx_runtime_1.jsx)(IconInfoOutline16, { size: 20 }) }), sidebar !== null && (0, jsx_runtime_1.jsx)("button", { type: "button", "data-mobile-nav": "header-workbench", "data-sidebar-target": sidebar, "aria-label": t(sidebar === 'better' ? 'workbench' : 'sidebar'), title: t(sidebar === 'better' ? 'workbench' : 'sidebar'), onClick: () => { toggleSidebarTarget(sidebar); }, children: (0, jsx_runtime_1.jsx)(dsh_client_ui_primitives_1.IconPanelLeftOutline16, { size: 20 }) })] }));
|
|
1656
|
+
}
|
|
1657
|
+
};
|
|
1658
|
+
__modules["share/fetch-share.js"] = function (require, module, exports) {
|
|
1659
|
+
"use strict";
|
|
1660
|
+
/**
|
|
1661
|
+
* Share-export fetching (ticket 05, PLAN §5.3): the ONE typed fetch over
|
|
1662
|
+
* GET /_dsh/mobile-nav/share-export, shared by the real share flow
|
|
1663
|
+
* (MobileSessionInfo) and the debug preview (?mobile-nav-share-preview=1)
|
|
1664
|
+
* so query assembly and error handling never fork into two copies.
|
|
1665
|
+
*
|
|
1666
|
+
* Module-shape rules (same reason as rasterize.ts): the share-image check
|
|
1667
|
+
* script imports this file through Node type stripping, which rejects JSX
|
|
1668
|
+
* and runs no browser API — so every import of a JSX-bearing module is
|
|
1669
|
+
* `import type` (fully erased) and browser globals appear only inside
|
|
1670
|
+
* function bodies. The query builder and the error taxonomy below are pure
|
|
1671
|
+
* and pinned there.
|
|
1672
|
+
*/
|
|
1673
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
1674
|
+
exports.ShareFetchError = exports.SHARE_TURNS_PRESETS = exports.SHARE_EXPORT_ROUTE = void 0;
|
|
1675
|
+
exports.buildShareExportQuery = buildShareExportQuery;
|
|
1676
|
+
exports.fetchShareExport = fetchShareExport;
|
|
1677
|
+
/** Route the host registers (mirror of src/share-export.ts — the client cannot import host code). */
|
|
1678
|
+
exports.SHARE_EXPORT_ROUTE = '/_dsh/mobile-nav/share-export';
|
|
1679
|
+
/** Preset turn counts the range sheet offers (PLAN §6: presets, no free input). */
|
|
1680
|
+
exports.SHARE_TURNS_PRESETS = [3, 5, 10];
|
|
1681
|
+
/** Localizable fetch failure; ticket 05 maps `code` to locales shareErr*. */
|
|
1682
|
+
class ShareFetchError extends Error {
|
|
1683
|
+
code;
|
|
1684
|
+
status;
|
|
1685
|
+
constructor(code, status, detail) {
|
|
1686
|
+
super(`share-fetch/${code}${status === undefined ? '' : ` (HTTP ${status})`}: ${detail}`);
|
|
1687
|
+
this.name = 'ShareFetchError';
|
|
1688
|
+
this.code = code;
|
|
1689
|
+
this.status = status;
|
|
1690
|
+
}
|
|
1691
|
+
}
|
|
1692
|
+
exports.ShareFetchError = ShareFetchError;
|
|
1693
|
+
/**
|
|
1694
|
+
* Pure: assemble the route query. The session id arrives in its branded
|
|
1695
|
+
* `session-<uuid>` form (useSessions row / session-scoped slot prop) and is
|
|
1696
|
+
* passed through encodeURIComponent — a bare uuid would 404 (PLAN §2 note).
|
|
1697
|
+
*/
|
|
1698
|
+
function buildShareExportQuery(session, range) {
|
|
1699
|
+
const params = [`session=${encodeURIComponent(session)}`];
|
|
1700
|
+
params.push(range.kind === 'last' ? `range=last&turns=${range.turns}` : 'range=all');
|
|
1701
|
+
return `${exports.SHARE_EXPORT_ROUTE}?${params.join('&')}`;
|
|
1702
|
+
}
|
|
1703
|
+
function isShareBlockShape(block) {
|
|
1704
|
+
if (typeof block !== 'object' || block === null)
|
|
1705
|
+
return false;
|
|
1706
|
+
const kind = block.kind;
|
|
1707
|
+
if (kind === 'image')
|
|
1708
|
+
return true;
|
|
1709
|
+
return kind === 'text' && typeof block.text === 'string';
|
|
1710
|
+
}
|
|
1711
|
+
function isShareTurnShape(turn) {
|
|
1712
|
+
if (typeof turn !== 'object' || turn === null)
|
|
1713
|
+
return false;
|
|
1714
|
+
const { role, seq, blocks } = turn;
|
|
1715
|
+
return (role === 'user' || role === 'assistant')
|
|
1716
|
+
&& typeof seq === 'number' && Number.isFinite(seq)
|
|
1717
|
+
&& Array.isArray(blocks) && blocks.every(isShareBlockShape);
|
|
1718
|
+
}
|
|
1719
|
+
/**
|
|
1720
|
+
* Fetch one export and classify every failure.
|
|
1721
|
+
*
|
|
1722
|
+
* Error mapping (the reason a 404 is split in two): this plugin's route
|
|
1723
|
+
* answers 404 + `{error:{code:'session-not-found'}}` for an unknown session,
|
|
1724
|
+
* while a DSH old enough to lack the route answers the web server's own 404
|
|
1725
|
+
* — a different copy (“需要新版 DSH”) than “会话不存在”, decided by whether
|
|
1726
|
+
* our envelope is present.
|
|
1727
|
+
*/
|
|
1728
|
+
async function fetchShareExport(session, range) {
|
|
1729
|
+
let res;
|
|
1730
|
+
try {
|
|
1731
|
+
res = await fetch(buildShareExportQuery(session, range), { cache: 'no-store' });
|
|
1732
|
+
}
|
|
1733
|
+
catch (err) {
|
|
1734
|
+
throw new ShareFetchError('network', undefined, err instanceof Error ? err.message : String(err));
|
|
1735
|
+
}
|
|
1736
|
+
if (!res.ok) {
|
|
1737
|
+
let envelope;
|
|
1738
|
+
try {
|
|
1739
|
+
envelope = await res.json();
|
|
1740
|
+
}
|
|
1741
|
+
catch {
|
|
1742
|
+
// Not JSON — an old DSH's plain 404 page or a gateway error page.
|
|
1743
|
+
envelope = undefined;
|
|
1744
|
+
}
|
|
1745
|
+
const code = envelope?.error?.code;
|
|
1746
|
+
const detail = typeof envelope?.error?.message === 'string' ? envelope.error.message : `HTTP ${res.status}`;
|
|
1747
|
+
if (res.status === 404 && code === 'session-not-found') {
|
|
1748
|
+
throw new ShareFetchError('session-not-found', res.status, detail);
|
|
1749
|
+
}
|
|
1750
|
+
if (res.status === 404) {
|
|
1751
|
+
throw new ShareFetchError('route-missing', res.status, detail);
|
|
1752
|
+
}
|
|
1753
|
+
if (res.status === 400) {
|
|
1754
|
+
throw new ShareFetchError('bad-request', res.status, detail);
|
|
1755
|
+
}
|
|
1756
|
+
throw new ShareFetchError('server', res.status, detail);
|
|
1757
|
+
}
|
|
1758
|
+
let body;
|
|
1759
|
+
try {
|
|
1760
|
+
body = await res.json();
|
|
1761
|
+
}
|
|
1762
|
+
catch (err) {
|
|
1763
|
+
throw new ShareFetchError('bad-body', res.status, err instanceof Error ? err.message : String(err));
|
|
1764
|
+
}
|
|
1765
|
+
if (body.ok !== true || !Array.isArray(body.turns) || !body.turns.every(isShareTurnShape)) {
|
|
1766
|
+
// Loud over lenient: silently dropping malformed rows would ship a
|
|
1767
|
+
// truncated transcript that LOOKS complete — the worst failure mode for
|
|
1768
|
+
// a share image.
|
|
1769
|
+
throw new ShareFetchError('bad-body', res.status, 'body is not a valid share-export payload');
|
|
1770
|
+
}
|
|
1771
|
+
return {
|
|
1772
|
+
turns: body.turns,
|
|
1773
|
+
createdAt: typeof body.createdAt === 'number' && Number.isFinite(body.createdAt) ? body.createdAt : undefined,
|
|
1774
|
+
};
|
|
1775
|
+
}
|
|
1776
|
+
};
|
|
1777
|
+
__modules["share/share-flow.js"] = function (require, module, exports) {
|
|
1778
|
+
"use strict";
|
|
1779
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
1780
|
+
exports.SHARE_NAME_MAX = exports.FALLBACK_SHARE_BASE_NAME = void 0;
|
|
1781
|
+
exports.sanitizeShareFileName = sanitizeShareFileName;
|
|
1782
|
+
exports.shareSliceFileName = shareSliceFileName;
|
|
1783
|
+
exports.shareImageFileName = shareImageFileName;
|
|
1784
|
+
exports.downloadBlobSequence = downloadBlobSequence;
|
|
1785
|
+
exports.deliverShareImages = deliverShareImages;
|
|
1786
|
+
exports.deliverShareImage = deliverShareImage;
|
|
1787
|
+
/** Basename used when the session title cleans down to nothing. */
|
|
1788
|
+
exports.FALLBACK_SHARE_BASE_NAME = 'share-card';
|
|
1789
|
+
/**
|
|
1790
|
+
* Max characters (code points, not UTF-16 units) kept from the title. The
|
|
1791
|
+
* `-NN.png` suffix adds 7; 50 + 7 stays far inside every filesystem's 255
|
|
1792
|
+
* byte-name limit even once UTF-8 doubles the CJK characters.
|
|
1793
|
+
*/
|
|
1794
|
+
exports.SHARE_NAME_MAX = 50;
|
|
1795
|
+
/** XML 1.0-invalid control characters (same set rasterize.ts scrubs). */
|
|
1796
|
+
const CONTROL_CHARS = /[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g;
|
|
1797
|
+
/**
|
|
1798
|
+
* Bidirectional format characters — LRM/RLM plus the LRE/RLE/PDF/LRO/RLO
|
|
1799
|
+
* family: zero-width, and in a filename they visually reorder the characters
|
|
1800
|
+
* around them (the classic `photo\u202Egpj.png` reads as a .jpg-extension
|
|
1801
|
+
* spoof), so they are stripped like controls rather than kept.
|
|
1802
|
+
*/
|
|
1803
|
+
const BIDI_CONTROLS = /[\u200E\u200F\u202A-\u202E]/g;
|
|
1804
|
+
/** Path separators of every mainstream filesystem — removed, not replaced in place. */
|
|
1805
|
+
const PATH_SEPARATORS = /[\\/]/g;
|
|
1806
|
+
/** Any whitespace run — collapsed to a single space. */
|
|
1807
|
+
const WHITESPACE_RUN = /\s+/g;
|
|
1808
|
+
/**
|
|
1809
|
+
* Pure: session title → portable file basename. Path separators, control
|
|
1810
|
+
* characters and bidi format characters are removed (a separator in a
|
|
1811
|
+
* download name lets a hostile title pick the destination directory; a bidi
|
|
1812
|
+
* character visually reorders the name into an extension spoof), whitespace
|
|
1813
|
+
* runs fold to one space, the result is length-capped on code points (no
|
|
1814
|
+
* splitting surrogate pairs) and falls back to {@link FALLBACK_SHARE_BASE_NAME}
|
|
1815
|
+
* when nothing survives.
|
|
1816
|
+
*/
|
|
1817
|
+
function sanitizeShareFileName(title) {
|
|
1818
|
+
const cleaned = title
|
|
1819
|
+
.replace(CONTROL_CHARS, '')
|
|
1820
|
+
.replace(BIDI_CONTROLS, '')
|
|
1821
|
+
.replace(PATH_SEPARATORS, ' ')
|
|
1822
|
+
.replace(WHITESPACE_RUN, ' ')
|
|
1823
|
+
.trim();
|
|
1824
|
+
if (cleaned === '')
|
|
1825
|
+
return exports.FALLBACK_SHARE_BASE_NAME;
|
|
1826
|
+
const chars = Array.from(cleaned);
|
|
1827
|
+
if (chars.length > exports.SHARE_NAME_MAX) {
|
|
1828
|
+
const capped = chars.slice(0, exports.SHARE_NAME_MAX).join('').trim();
|
|
1829
|
+
return capped === '' ? exports.FALLBACK_SHARE_BASE_NAME : capped;
|
|
1830
|
+
}
|
|
1831
|
+
return cleaned;
|
|
1832
|
+
}
|
|
1833
|
+
/**
|
|
1834
|
+
* File name of slice {@link index} (0-based): `<base>-NN.png`. Two digits
|
|
1835
|
+
* cover the rasterizer's 24-slice ceiling with room to spare and sort
|
|
1836
|
+
* lexicographically in every receiver's file list. Legacy multi-file fallback
|
|
1837
|
+
* only (ticket 07 stitches first when the browser supports it).
|
|
1838
|
+
*/
|
|
1839
|
+
function shareSliceFileName(base, index) {
|
|
1840
|
+
return `${base}-${String(index + 1).padStart(2, '0')}.png`;
|
|
1841
|
+
}
|
|
1842
|
+
/**
|
|
1843
|
+
* File name of the stitched single long image (ticket 07): `<base>.png` — no
|
|
1844
|
+
* slice suffix, the deliverable is one file.
|
|
1845
|
+
*/
|
|
1846
|
+
function shareImageFileName(base) {
|
|
1847
|
+
return `${base}.png`;
|
|
1848
|
+
}
|
|
1849
|
+
/** User dismissed the system share panel — not an error, nothing to retry. */
|
|
1850
|
+
function isAbortError(err) {
|
|
1851
|
+
return err instanceof Error && err.name === 'AbortError';
|
|
1852
|
+
}
|
|
1853
|
+
/**
|
|
1854
|
+
* The `<a download>` fallback, as one paced sequence (review 07+08 cleanup —
|
|
1855
|
+
* previously tripled across this module and the debug preview's export):
|
|
1856
|
+
* object URL → programmatic anchor click → delayed revoke per entry, with
|
|
1857
|
+
* consecutive clicks spaced 300ms apart so browsers do not coalesce or block
|
|
1858
|
+
* them. A single entry is simply one click with no pause.
|
|
1859
|
+
*/
|
|
1860
|
+
async function downloadBlobSequence(entries) {
|
|
1861
|
+
for (const [index, entry] of entries.entries()) {
|
|
1862
|
+
const url = URL.createObjectURL(entry.blob);
|
|
1863
|
+
const anchor = document.createElement('a');
|
|
1864
|
+
anchor.href = url;
|
|
1865
|
+
anchor.download = entry.name;
|
|
1866
|
+
anchor.click();
|
|
1867
|
+
window.setTimeout(() => URL.revokeObjectURL(url), 10_000);
|
|
1868
|
+
if (index < entries.length - 1)
|
|
1869
|
+
await new Promise((resolve) => window.setTimeout(resolve, 300));
|
|
1870
|
+
}
|
|
1871
|
+
}
|
|
1872
|
+
/** The `<a download>` fallback: one paced click per file. */
|
|
1873
|
+
async function downloadFiles(files) {
|
|
1874
|
+
await downloadBlobSequence(files.map((file) => ({ blob: file, name: file.name })));
|
|
1875
|
+
}
|
|
1876
|
+
/**
|
|
1877
|
+
* Deliver rasterized slices (legacy multi-file fallback): system share panel
|
|
1878
|
+
* when the browser accepts files, downloads otherwise. A share() rejection
|
|
1879
|
+
* that is NOT the user's own cancel — classically NotAllowedError, because
|
|
1880
|
+
* seconds of rasterizing spent the click's transient activation — falls
|
|
1881
|
+
* through to downloads rather than discarding the finished PNGs.
|
|
1882
|
+
*/
|
|
1883
|
+
async function deliverShareImages(slices, title) {
|
|
1884
|
+
const base = sanitizeShareFileName(title);
|
|
1885
|
+
const files = slices.map((slice, index) => new File([slice.blob], shareSliceFileName(base, index), { type: 'image/png' }));
|
|
1886
|
+
return deliverFiles(files);
|
|
1887
|
+
}
|
|
1888
|
+
/**
|
|
1889
|
+
* Deliver the stitched single long image (ticket 07): ONE `<base>.png` through
|
|
1890
|
+
* the system share panel, or a single download where the Web Share API is
|
|
1891
|
+
* missing/refusing — never a file sequence.
|
|
1892
|
+
*/
|
|
1893
|
+
async function deliverShareImage(png, title) {
|
|
1894
|
+
const base = sanitizeShareFileName(title);
|
|
1895
|
+
return deliverFiles([new File([png], shareImageFileName(base), { type: 'image/png' })]);
|
|
1896
|
+
}
|
|
1897
|
+
/** Shared delivery tail: share panel, else one paced download per file. */
|
|
1898
|
+
async function deliverFiles(input) {
|
|
1899
|
+
const files = [...input]; // mutable shallow copy — the navigator.share shape wants File[]
|
|
1900
|
+
const nav = typeof navigator === 'undefined' ? undefined : navigator;
|
|
1901
|
+
if (typeof nav?.canShare === 'function' && typeof nav.share === 'function' && nav.canShare({ files })) {
|
|
1902
|
+
try {
|
|
1903
|
+
await nav.share({ files });
|
|
1904
|
+
return 'shared';
|
|
1905
|
+
}
|
|
1906
|
+
catch (err) {
|
|
1907
|
+
if (isAbortError(err))
|
|
1908
|
+
return 'cancelled';
|
|
1909
|
+
await downloadFiles(files);
|
|
1910
|
+
return 'downloaded';
|
|
1911
|
+
}
|
|
1912
|
+
}
|
|
1913
|
+
await downloadFiles(files);
|
|
1914
|
+
return 'downloaded';
|
|
1915
|
+
}
|
|
1916
|
+
};
|
|
1917
|
+
__modules["share/png-stitch.js"] = function (require, module, exports) {
|
|
1918
|
+
"use strict";
|
|
1919
|
+
/**
|
|
1920
|
+
* Streaming PNG long-image stitcher (ticket 07, PLAN §4.5).
|
|
1921
|
+
*
|
|
1922
|
+
* The rasterizer still paints slice-by-slice (each slice stays under the iOS
|
|
1923
|
+
* canvas budget), but the DELIVERABLE is one PNG: this module concatenates the
|
|
1924
|
+
* slices' scanlines into a single image — PNG signature + IHDR (whole-image
|
|
1925
|
+
* size, 8-bit RGBA) + IDAT (zlib stream, scanline filter 0) + IEND, CRC32
|
|
1926
|
+
* computed against a self-carried table, compression via the platform's
|
|
1927
|
+
* `CompressionStream('deflate')` whose output already IS the zlib-wrapped
|
|
1928
|
+
* deflate stream the PNG IDAT chunk requires.
|
|
1929
|
+
*
|
|
1930
|
+
* Streaming discipline (the whole point of a custom stitcher): slices are
|
|
1931
|
+
* decoded one at a time (createImageBitmap → offscreen canvas getImageData →
|
|
1932
|
+
* per-row writeRow → release) so the raw pixels of the WHOLE image are never
|
|
1933
|
+
* held; only the compressed output accumulates, as a Blob.
|
|
1934
|
+
*
|
|
1935
|
+
* Module contract (same one share-flow.ts honors): scripts/check-share-image.mjs
|
|
1936
|
+
* imports this file through Node type stripping, which EXECUTES module scope —
|
|
1937
|
+
* so everything here is pure math at module scope, and every browser global
|
|
1938
|
+
* (CompressionStream, createImageBitmap, document, Blob) is touched only
|
|
1939
|
+
* inside function/class bodies. The file is an import-free leaf.
|
|
1940
|
+
*/
|
|
1941
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
1942
|
+
exports.PngStitchWriter = exports.MAX_IDAT_CHUNK_BYTES = exports.IEND_CHUNK = exports.PNG_SIGNATURE = void 0;
|
|
1943
|
+
exports.crc32 = crc32;
|
|
1944
|
+
exports.pngChunk = pngChunk;
|
|
1945
|
+
exports.ihdrData = ihdrData;
|
|
1946
|
+
exports.splitIdatBytes = splitIdatBytes;
|
|
1947
|
+
exports.supportsPngStitch = supportsPngStitch;
|
|
1948
|
+
exports.stitchSliceBlobs = stitchSliceBlobs;
|
|
1949
|
+
/* ============================================================================
|
|
1950
|
+
* PURE BYTES — CRC32 + chunk framing (runs in Node for the check script)
|
|
1951
|
+
* ========================================================================== */
|
|
1952
|
+
/** Standard CRC-32 table (reflected, polynomial 0xEDB88320). */
|
|
1953
|
+
const CRC_TABLE = (() => {
|
|
1954
|
+
const table = new Array(256);
|
|
1955
|
+
for (let n = 0; n < 256; n++) {
|
|
1956
|
+
let c = n;
|
|
1957
|
+
for (let k = 0; k < 8; k++)
|
|
1958
|
+
c = (c & 1) !== 0 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
|
1959
|
+
table[n] = c >>> 0;
|
|
1960
|
+
}
|
|
1961
|
+
return table;
|
|
1962
|
+
})();
|
|
1963
|
+
/**
|
|
1964
|
+
* CRC-32 (PNG variant: no init/xor toggling beyond the standard reflected
|
|
1965
|
+
* form — init 0xFFFFFFFF, final XOR 0xFFFFFFFF). Known answers pinned by the
|
|
1966
|
+
* check script: crc32('123456789') === 0xCBF43926, crc32('IEND') === 0xAE426082.
|
|
1967
|
+
*/
|
|
1968
|
+
function crc32(bytes) {
|
|
1969
|
+
let c = 0xffffffff;
|
|
1970
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
1971
|
+
c = (CRC_TABLE[(c ^ bytes[i]) & 0xff] ^ (c >>> 8)) >>> 0;
|
|
1972
|
+
}
|
|
1973
|
+
return (c ^ 0xffffffff) >>> 0;
|
|
1974
|
+
}
|
|
1975
|
+
/** The 8-byte PNG file signature. */
|
|
1976
|
+
exports.PNG_SIGNATURE = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
|
1977
|
+
/** ASCII bytes of a chunk type string (types are always 4 ASCII letters). */
|
|
1978
|
+
function ascii(value) {
|
|
1979
|
+
const out = new Uint8Array(value.length);
|
|
1980
|
+
for (let i = 0; i < value.length; i++)
|
|
1981
|
+
out[i] = value.charCodeAt(i) & 0xff;
|
|
1982
|
+
return out;
|
|
1983
|
+
}
|
|
1984
|
+
function writeU32BE(target, offset, value) {
|
|
1985
|
+
target[offset] = (value >>> 24) & 0xff;
|
|
1986
|
+
target[offset + 1] = (value >>> 16) & 0xff;
|
|
1987
|
+
target[offset + 2] = (value >>> 8) & 0xff;
|
|
1988
|
+
target[offset + 3] = value & 0xff;
|
|
1989
|
+
}
|
|
1990
|
+
/**
|
|
1991
|
+
* One PNG chunk frame: 4-byte big-endian data length, the type, the data, and
|
|
1992
|
+
* the big-endian CRC32 over TYPE+DATA (never the length field).
|
|
1993
|
+
*/
|
|
1994
|
+
function pngChunk(type, data) {
|
|
1995
|
+
const typeBytes = ascii(type);
|
|
1996
|
+
if (typeBytes.length !== 4)
|
|
1997
|
+
throw new Error(`png-stitch: chunk type must be 4 ASCII letters, got ${JSON.stringify(type)}`);
|
|
1998
|
+
const out = new Uint8Array(12 + data.length);
|
|
1999
|
+
writeU32BE(out, 0, data.length);
|
|
2000
|
+
out.set(typeBytes, 4);
|
|
2001
|
+
out.set(data, 8);
|
|
2002
|
+
const crcInput = new Uint8Array(4 + data.length);
|
|
2003
|
+
crcInput.set(typeBytes, 0);
|
|
2004
|
+
crcInput.set(data, 4);
|
|
2005
|
+
writeU32BE(out, 8 + data.length, crc32(crcInput));
|
|
2006
|
+
return out;
|
|
2007
|
+
}
|
|
2008
|
+
/** IHDR payload: width/height big-endian, 8 bits/channel, RGBA (color type 6), deflate, filter 0, no interlace. */
|
|
2009
|
+
function ihdrData(width, height) {
|
|
2010
|
+
const data = new Uint8Array(13);
|
|
2011
|
+
writeU32BE(data, 0, width);
|
|
2012
|
+
writeU32BE(data, 4, height);
|
|
2013
|
+
data[8] = 8; // bit depth
|
|
2014
|
+
data[9] = 6; // color type: truecolor + alpha
|
|
2015
|
+
data[10] = 0; // compression: deflate
|
|
2016
|
+
data[11] = 0; // filter: adaptive (per scanline byte)
|
|
2017
|
+
data[12] = 0; // interlace: none
|
|
2018
|
+
return data;
|
|
2019
|
+
}
|
|
2020
|
+
/** The IEND chunk is a constant: empty payload, CRC 0xAE426082. */
|
|
2021
|
+
exports.IEND_CHUNK = pngChunk('IEND', new Uint8Array(0));
|
|
2022
|
+
/**
|
|
2023
|
+
* IDAT payload split ceiling (1 MiB): a single IDAT may legally be any size,
|
|
2024
|
+
* but decoders appreciate modest chunks — and splitting keeps the multi-IDAT
|
|
2025
|
+
* path honest since most exports stay under it and emit exactly one.
|
|
2026
|
+
*/
|
|
2027
|
+
exports.MAX_IDAT_CHUNK_BYTES = 1 << 20;
|
|
2028
|
+
/** Split compressed bytes into consecutive IDAT payloads (multi-chunk IDAT is legal and order-significant). */
|
|
2029
|
+
function splitIdatBytes(data, maxBytes) {
|
|
2030
|
+
if (!Number.isInteger(maxBytes) || maxBytes <= 0)
|
|
2031
|
+
throw new Error(`png-stitch: invalid IDAT chunk size ${maxBytes}`);
|
|
2032
|
+
if (data.length === 0)
|
|
2033
|
+
return [new Uint8Array(0)];
|
|
2034
|
+
const parts = [];
|
|
2035
|
+
for (let offset = 0; offset < data.length; offset += maxBytes) {
|
|
2036
|
+
parts.push(data.subarray(offset, Math.min(offset + maxBytes, data.length)));
|
|
2037
|
+
}
|
|
2038
|
+
return parts;
|
|
2039
|
+
}
|
|
2040
|
+
/* ============================================================================
|
|
2041
|
+
* STREAMING WRITER — rows in, Blob out (works in Node 22 too: the check
|
|
2042
|
+
* script produces real PNGs and verifies them with node:zlib)
|
|
2043
|
+
* ========================================================================== */
|
|
2044
|
+
/** Scanline filter type 0 (None) — the single filter byte every row carries. */
|
|
2045
|
+
const FILTER_NONE = new Uint8Array([0]);
|
|
2046
|
+
/**
|
|
2047
|
+
* Row-by-row PNG assembler for one already-known raster size.
|
|
2048
|
+
*
|
|
2049
|
+
* Backpressure: the compressed readable side is drained by a loop started in
|
|
2050
|
+
* the constructor — without a concurrent reader the transform stalls once its
|
|
2051
|
+
* buffers fill and every writeRow would deadlock. Row bytes are NOT copied:
|
|
2052
|
+
* writeRow's awaited write resolves only after the compressor has consumed the
|
|
2053
|
+
* chunk, and the rasterizer feeds disjoint subarrays of an ImageData buffer
|
|
2054
|
+
* that is never mutated after getImageData, so no staging buffer is needed.
|
|
2055
|
+
*/
|
|
2056
|
+
class PngStitchWriter {
|
|
2057
|
+
width;
|
|
2058
|
+
height;
|
|
2059
|
+
rowsWritten = 0;
|
|
2060
|
+
closed = false;
|
|
2061
|
+
writer;
|
|
2062
|
+
drained;
|
|
2063
|
+
constructor(width, height) {
|
|
2064
|
+
if (!Number.isInteger(width) || width <= 0)
|
|
2065
|
+
throw new Error(`png-stitch: invalid width ${width}`);
|
|
2066
|
+
if (!Number.isInteger(height) || height <= 0)
|
|
2067
|
+
throw new Error(`png-stitch: invalid height ${height}`);
|
|
2068
|
+
this.width = width;
|
|
2069
|
+
this.height = height;
|
|
2070
|
+
const compressor = new CompressionStream('deflate');
|
|
2071
|
+
this.writer = compressor.writable.getWriter();
|
|
2072
|
+
const reader = compressor.readable.getReader();
|
|
2073
|
+
this.drained = (async () => {
|
|
2074
|
+
const chunks = [];
|
|
2075
|
+
for (;;) {
|
|
2076
|
+
const { done, value } = await reader.read();
|
|
2077
|
+
if (done === true)
|
|
2078
|
+
break;
|
|
2079
|
+
if (value !== undefined)
|
|
2080
|
+
chunks.push(value);
|
|
2081
|
+
}
|
|
2082
|
+
let total = 0;
|
|
2083
|
+
for (const chunk of chunks)
|
|
2084
|
+
total += chunk.length;
|
|
2085
|
+
const out = new Uint8Array(total);
|
|
2086
|
+
let offset = 0;
|
|
2087
|
+
for (const chunk of chunks) {
|
|
2088
|
+
out.set(chunk, offset);
|
|
2089
|
+
offset += chunk.length;
|
|
2090
|
+
}
|
|
2091
|
+
return out;
|
|
2092
|
+
})();
|
|
2093
|
+
// The drain loop starts here and may reject before finish() ever awaits
|
|
2094
|
+
// it (a writeRow error abandons the writer mid-stream) — mark it handled
|
|
2095
|
+
// from birth so the rejection surfaces only through finish(), never as an
|
|
2096
|
+
// unhandled promise rejection. The derived promise resolves; the original
|
|
2097
|
+
// still rejects for the awaiter.
|
|
2098
|
+
void this.drained.catch(() => { });
|
|
2099
|
+
}
|
|
2100
|
+
/** Feed one scanline (`width × 4` RGBA bytes). The bytes must stay untouched until the returned promise resolves. */
|
|
2101
|
+
async writeRow(rgba) {
|
|
2102
|
+
if (this.closed)
|
|
2103
|
+
throw new Error('png-stitch: writer already finished');
|
|
2104
|
+
if (this.rowsWritten >= this.height)
|
|
2105
|
+
throw new Error(`png-stitch: row ${this.rowsWritten} exceeds the declared height ${this.height}`);
|
|
2106
|
+
if (rgba.length !== this.width * 4) {
|
|
2107
|
+
throw new Error(`png-stitch: row is ${rgba.length} bytes, expected ${this.width * 4} (width ${this.width} × RGBA)`);
|
|
2108
|
+
}
|
|
2109
|
+
await this.writer.write(FILTER_NONE);
|
|
2110
|
+
await this.writer.write(rgba);
|
|
2111
|
+
this.rowsWritten += 1;
|
|
2112
|
+
}
|
|
2113
|
+
/** Close the stream and assemble the file. Rejects unless exactly `height` rows were fed. */
|
|
2114
|
+
async finish() {
|
|
2115
|
+
if (this.closed)
|
|
2116
|
+
throw new Error('png-stitch: writer already finished');
|
|
2117
|
+
if (this.rowsWritten !== this.height) {
|
|
2118
|
+
throw new Error(`png-stitch: declared ${this.height} rows but ${this.rowsWritten} were written — IHDR height must equal Σ slice heights`);
|
|
2119
|
+
}
|
|
2120
|
+
this.closed = true;
|
|
2121
|
+
await this.writer.close();
|
|
2122
|
+
const compressed = await this.drained;
|
|
2123
|
+
const parts = [exports.PNG_SIGNATURE, pngChunk('IHDR', ihdrData(this.width, this.height))];
|
|
2124
|
+
for (const payload of splitIdatBytes(compressed, exports.MAX_IDAT_CHUNK_BYTES)) {
|
|
2125
|
+
parts.push(pngChunk('IDAT', payload));
|
|
2126
|
+
}
|
|
2127
|
+
parts.push(exports.IEND_CHUNK);
|
|
2128
|
+
return new Blob(parts);
|
|
2129
|
+
}
|
|
2130
|
+
}
|
|
2131
|
+
exports.PngStitchWriter = PngStitchWriter;
|
|
2132
|
+
/* ============================================================================
|
|
2133
|
+
* BROWSER ORCHESTRATION — slice blobs in, single PNG Blob out
|
|
2134
|
+
* (never executed by the check script; Node has no createImageBitmap)
|
|
2135
|
+
* ========================================================================== */
|
|
2136
|
+
/**
|
|
2137
|
+
* Feature probe for the stitch path (ticket 07): CompressionStream gates the
|
|
2138
|
+
* whole mechanism (Safari gained it in 16.4, alongside createImageBitmap and
|
|
2139
|
+
* Blob — the trio is checked anyway because each is independently load-bearing).
|
|
2140
|
+
* Unavailable → the caller falls back to the legacy multi-file delivery.
|
|
2141
|
+
*/
|
|
2142
|
+
function supportsPngStitch() {
|
|
2143
|
+
return typeof CompressionStream === 'function' && typeof createImageBitmap === 'function' && typeof Blob === 'function';
|
|
2144
|
+
}
|
|
2145
|
+
/**
|
|
2146
|
+
* Stitch painted slices into one PNG of the shared physical width.
|
|
2147
|
+
*
|
|
2148
|
+
* Per slice: createImageBitmap → draw onto a throwaway canvas → getImageData →
|
|
2149
|
+
* writeRow per scanline → zero the canvas and close the bitmap before the next
|
|
2150
|
+
* slice. getImageData un-premultiplies alpha, which is lossy for translucent
|
|
2151
|
+
* pixels — the share card paints on an opaque background (alpha 255), so the
|
|
2152
|
+
* round-trip is exact here. Every slice must decode at exactly the global
|
|
2153
|
+
* width and its declared height; a mismatch means a painting bug, and the
|
|
2154
|
+
* writer's row accounting would corrupt the file, so it fails loudly instead.
|
|
2155
|
+
*/
|
|
2156
|
+
async function stitchSliceBlobs(parts, width) {
|
|
2157
|
+
if (parts.length === 0)
|
|
2158
|
+
throw new Error('png-stitch: no slices to stitch');
|
|
2159
|
+
if (!Number.isInteger(width) || width <= 0)
|
|
2160
|
+
throw new Error(`png-stitch: invalid stitch width ${width}`);
|
|
2161
|
+
const totalHeight = parts.reduce((sum, part) => sum + part.height, 0);
|
|
2162
|
+
const writer = new PngStitchWriter(width, totalHeight);
|
|
2163
|
+
for (const part of parts) {
|
|
2164
|
+
const bitmap = await createImageBitmap(part.blob);
|
|
2165
|
+
try {
|
|
2166
|
+
if (bitmap.width !== width) {
|
|
2167
|
+
throw new Error(`png-stitch: slice decoded ${bitmap.width}px wide, expected the global ${width}px — cannot concatenate scanlines`);
|
|
2168
|
+
}
|
|
2169
|
+
if (bitmap.height !== part.height) {
|
|
2170
|
+
throw new Error(`png-stitch: slice decoded ${bitmap.height}px tall, planned ${part.height}px`);
|
|
2171
|
+
}
|
|
2172
|
+
const canvas = document.createElement('canvas');
|
|
2173
|
+
canvas.width = width;
|
|
2174
|
+
canvas.height = part.height;
|
|
2175
|
+
try {
|
|
2176
|
+
const ctx = canvas.getContext('2d', { willReadFrequently: true });
|
|
2177
|
+
if (ctx === null)
|
|
2178
|
+
throw new Error('png-stitch: 2d context unavailable');
|
|
2179
|
+
ctx.drawImage(bitmap, 0, 0);
|
|
2180
|
+
const image = ctx.getImageData(0, 0, width, part.height);
|
|
2181
|
+
const bytesPerRow = width * 4;
|
|
2182
|
+
for (let y = 0; y < part.height; y++) {
|
|
2183
|
+
// Zero-copy row view over the ImageData buffer (its data is a
|
|
2184
|
+
// Uint8ClampedArray; a same-offset Uint8Array view shares the bytes).
|
|
2185
|
+
const row = new Uint8Array(image.data.buffer, image.data.byteOffset + y * bytesPerRow, bytesPerRow);
|
|
2186
|
+
await writer.writeRow(row);
|
|
2187
|
+
}
|
|
2188
|
+
}
|
|
2189
|
+
finally {
|
|
2190
|
+
canvas.width = 0;
|
|
2191
|
+
canvas.height = 0;
|
|
2192
|
+
}
|
|
2193
|
+
}
|
|
2194
|
+
finally {
|
|
2195
|
+
bitmap.close();
|
|
2196
|
+
}
|
|
2197
|
+
}
|
|
2198
|
+
return writer.finish();
|
|
2199
|
+
}
|
|
2200
|
+
};
|
|
2201
|
+
__modules["share/share-constants.js"] = function (require, module, exports) {
|
|
2202
|
+
"use strict";
|
|
2203
|
+
/**
|
|
2204
|
+
* Template constants the share card and its rasterizer MUST agree on
|
|
2205
|
+
* (tickets 03/04; the width-lifting formulas in rasterize.ts invert the
|
|
2206
|
+
* template's own constraints, so a diverging copy would mis-bucket wide
|
|
2207
|
+
* content). Formerly duplicated as literals in share-card.tsx and re-derived
|
|
2208
|
+
* in rasterize.ts with a regex lock in the check script; both sides now
|
|
2209
|
+
* import this single module, and scripts/check-share-image.mjs asserts its
|
|
2210
|
+
* values directly.
|
|
2211
|
+
*
|
|
2212
|
+
* Hard rule for this file: a pure leaf — no imports, no JSX, no DOM, no
|
|
2213
|
+
* module side effects. Node type stripping executes every value import, and
|
|
2214
|
+
* both the check script and the client bundler rely on this module being
|
|
2215
|
+
* loadable anywhere.
|
|
2216
|
+
*/
|
|
2217
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
2218
|
+
exports.USER_BUBBLE_PAD_X = exports.USER_BUBBLE_MAX_RATIO = exports.WIDE_SLICE_WIDTH = exports.BASE_CARD_WIDTH = void 0;
|
|
2219
|
+
/** Base logical card width (share-card.tsx default render width). */
|
|
2220
|
+
exports.BASE_CARD_WIDTH = 390;
|
|
2221
|
+
/** Widened slice width for wide content (PLAN §5.2 “更大逻辑宽 ~780”). */
|
|
2222
|
+
exports.WIDE_SLICE_WIDTH = 780;
|
|
2223
|
+
/**
|
|
2224
|
+
* User-bubble width cap relative to its row (`maxWidth` in share-card.tsx,
|
|
2225
|
+
* e.g. 0.82 → '82%'). cardWidthForUserBubble inverts this percentage into a
|
|
2226
|
+
* division, so the two sides must be the same number.
|
|
2227
|
+
*/
|
|
2228
|
+
exports.USER_BUBBLE_MAX_RATIO = 0.82;
|
|
2229
|
+
/** Horizontal padding inside the user bubble (its `padding` X component). */
|
|
2230
|
+
exports.USER_BUBBLE_PAD_X = 14;
|
|
2231
|
+
};
|
|
2232
|
+
__modules["share/rasterize.js"] = function (require, module, exports) {
|
|
2233
|
+
"use strict";
|
|
2234
|
+
/**
|
|
2235
|
+
* Share-card slicing + rasterization (ticket 04, PLAN §5.2).
|
|
2236
|
+
*
|
|
2237
|
+
* Turns a rendered ShareCard DOM tree (ticket 03: fully inline-styled, marked
|
|
2238
|
+
* with data-share-card / -header / -turn / -role / -footer) into a sequence of
|
|
2239
|
+
* PNG blobs: slice by measured turn heights under a per-slice canvas pixel
|
|
2240
|
+
* budget, then paint each slice through
|
|
2241
|
+
* `<svg><foreignObject>` → Image → canvas → PNG export. The SVG document
|
|
2242
|
+
* carries each slice's PHYSICAL target size (intrinsic = canvas), so the image
|
|
2243
|
+
* rasterizes sharp at the canvas's own resolution and the draw is a 1:1 copy.
|
|
2244
|
+
*
|
|
2245
|
+
* File layout, and why it matters:
|
|
2246
|
+
* - The top half is PURE planning math (no DOM, no React). It may import
|
|
2247
|
+
* ONLY the pure constants module of this directory (share-constants.ts):
|
|
2248
|
+
* scripts/check-share-image.mjs imports this file through Node type
|
|
2249
|
+
* stripping, which EXECUTES every value import — JSX or DOM code would
|
|
2250
|
+
* break the check, and any other module would widen the client bundle.
|
|
2251
|
+
* - The DOM half below only MEASURES and EXECUTES the pure plan's decisions:
|
|
2252
|
+
* slice grouping, width lifting, scale factors, pixel-ratio fallback and
|
|
2253
|
+
* truncation are all decided by the pure functions, and the SVG document the
|
|
2254
|
+
* painter draws is built by a pure serializer — so tests can pin all of it.
|
|
2255
|
+
*
|
|
2256
|
+
* Release policy per slice (PLAN §5.2): each slice is painted, blobbed and its
|
|
2257
|
+
* canvas dropped before the next slice is built — the export never holds more
|
|
2258
|
+
* than one live canvas. Ticket 07 adds the `unified` planning mode whose
|
|
2259
|
+
* single physical width lets png-stitch.ts concatenate the slice PNGs into
|
|
2260
|
+
* one long image.
|
|
2261
|
+
*/
|
|
2262
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
2263
|
+
exports.ShareRasterizeError = exports.ULTRA_WIDTH_MARGIN = exports.PIXEL_RATIO_STEPS = exports.MAX_SLICES = exports.SLICE_PIXEL_BUDGET = void 0;
|
|
2264
|
+
exports.ratioChain = ratioChain;
|
|
2265
|
+
exports.slicePixelCount = slicePixelCount;
|
|
2266
|
+
exports.rasterGeometry = rasterGeometry;
|
|
2267
|
+
exports.buildSvgDocument = buildSvgDocument;
|
|
2268
|
+
exports.cardWidthForContent = cardWidthForContent;
|
|
2269
|
+
exports.cardWidthForUserBubble = cardWidthForUserBubble;
|
|
2270
|
+
exports.requiredCardWidth = requiredCardWidth;
|
|
2271
|
+
exports.bucketTurnWidth = bucketTurnWidth;
|
|
2272
|
+
exports.ultraSliceWidth = ultraSliceWidth;
|
|
2273
|
+
exports.planShareSlices = planShareSlices;
|
|
2274
|
+
exports.unifiedCardWidth = unifiedCardWidth;
|
|
2275
|
+
exports.planShareSlicesUnified = planShareSlicesUnified;
|
|
2276
|
+
exports.stitchPixelWidth = stitchPixelWidth;
|
|
2277
|
+
exports.rasterGeometryStitch = rasterGeometryStitch;
|
|
2278
|
+
exports.ensureProbe = ensureProbe;
|
|
2279
|
+
exports.rasterizeShareCard = rasterizeShareCard;
|
|
2280
|
+
const share_constants_ts_1 = require("./share/share-constants.js");
|
|
2281
|
+
/* ============================================================================
|
|
2282
|
+
* PURE PLANNING — no DOM, no React, imports only share-constants.ts
|
|
2283
|
+
* ========================================================================== */
|
|
2284
|
+
/** Per-slice physical pixel budget (iOS canvas hard cap 16.7M minus margin). */
|
|
2285
|
+
exports.SLICE_PIXEL_BUDGET = 12_000_000;
|
|
2286
|
+
/** Slice count ceiling; beyond it the head is dropped and the first slice marked. */
|
|
2287
|
+
exports.MAX_SLICES = 24;
|
|
2288
|
+
/** Pixel-ratio fallback ladder, walked top-down from min(devicePixelRatio, 2). */
|
|
2289
|
+
exports.PIXEL_RATIO_STEPS = [2, 1.5, 1];
|
|
2290
|
+
/** Safety px added around computed ultra widths (sub-pixel + rounding slack). */
|
|
2291
|
+
exports.ULTRA_WIDTH_MARGIN = 2;
|
|
2292
|
+
/** Localizable rasterize failure. `code` drives UI copy, `detail` is diagnostic. */
|
|
2293
|
+
class ShareRasterizeError extends Error {
|
|
2294
|
+
code;
|
|
2295
|
+
detail;
|
|
2296
|
+
constructor(code, detail) {
|
|
2297
|
+
super(`share-rasterize/${code}: ${detail}`);
|
|
2298
|
+
this.code = code;
|
|
2299
|
+
this.detail = detail;
|
|
2300
|
+
}
|
|
2301
|
+
}
|
|
2302
|
+
exports.ShareRasterizeError = ShareRasterizeError;
|
|
2303
|
+
/** Descending pixel-ratio candidates for an already-clamped cap; never empty. */
|
|
2304
|
+
function ratioChain(cap) {
|
|
2305
|
+
const chain = exports.PIXEL_RATIO_STEPS.filter((step) => step <= cap + 1e-9);
|
|
2306
|
+
return chain.length > 0 ? chain : [1];
|
|
2307
|
+
}
|
|
2308
|
+
/** Physical pixels a slice occupies once painted (rounding up, canvas-style). */
|
|
2309
|
+
function slicePixelCount(outWidth, outHeight, pixelRatio) {
|
|
2310
|
+
return Math.ceil(outWidth * pixelRatio) * Math.ceil(outHeight * pixelRatio);
|
|
2311
|
+
}
|
|
2312
|
+
/* ---- SVG raster document (pure; the painter only feeds it strings) --------- */
|
|
2313
|
+
const XHTML_NS = 'http://www.w3.org/1999/xhtml';
|
|
2314
|
+
const SVG_NS = 'http://www.w3.org/2000/svg';
|
|
2315
|
+
/** XML 1.0 forbids these; DOM text from transcripts can theoretically carry them. */
|
|
2316
|
+
const INVALID_XML_CHARS = /[\u0000-\u0008\u000B\u000C\u000E-\u001F\uFFFE\uFFFF]/g;
|
|
2317
|
+
/**
|
|
2318
|
+
* Physical target size for one slice's raster. The svg/foreignObject
|
|
2319
|
+
* width/height attributes MUST be these numbers, never the logical ones: the
|
|
2320
|
+
* browser rasterizes an SVG-as-image at its INTRINSIC size, so logical
|
|
2321
|
+
* attributes make WebKit decode a small bitmap that drawImage then upscales
|
|
2322
|
+
* (blurry on 2x screens) — and an unbounded ultra renderWidth would ask the
|
|
2323
|
+
* decoder for an unbounded bitmap, sailing past the pixel budget before any
|
|
2324
|
+
* canvas is allocated. The logical tree instead rides an inner wrapper scaled
|
|
2325
|
+
* by `transformScale`, so the canvas draw is a 1:1 copy. Sizes are clamped
|
|
2326
|
+
* into `budgetPx` (floor per side when clamping, so the product provably fits
|
|
2327
|
+
* even where the unclamped per-side ceil would not have).
|
|
2328
|
+
*/
|
|
2329
|
+
function rasterGeometry(renderWidth, logicalHeight, scale, pixelRatio, budgetPx) {
|
|
2330
|
+
let transformScale = scale * pixelRatio;
|
|
2331
|
+
let width = Math.max(1, Math.ceil(renderWidth * transformScale));
|
|
2332
|
+
let height = Math.max(1, Math.ceil(logicalHeight * transformScale));
|
|
2333
|
+
if (width * height > budgetPx) {
|
|
2334
|
+
transformScale *= Math.sqrt(budgetPx / (width * height));
|
|
2335
|
+
width = Math.max(1, Math.floor(renderWidth * transformScale));
|
|
2336
|
+
height = Math.max(1, Math.floor(logicalHeight * transformScale));
|
|
2337
|
+
}
|
|
2338
|
+
return { width, height, transformScale };
|
|
2339
|
+
}
|
|
2340
|
+
/**
|
|
2341
|
+
* Serialize one slice subtree into a standalone SVG document sized at its
|
|
2342
|
+
* PHYSICAL target: svg + foreignObject carry the physical px, the xhtml
|
|
2343
|
+
* wrapper carries the logical size and a scale() transform, so the image
|
|
2344
|
+
* rasterizes at exactly the resolution the canvas wants (no upscaled copy).
|
|
2345
|
+
* `bodyHtml` is the prepared slice clone's outerHTML — namespace attributes
|
|
2346
|
+
* must already be baked in by the caller (prepareXmlSerialization).
|
|
2347
|
+
*/
|
|
2348
|
+
function buildSvgDocument(bodyHtml, spec) {
|
|
2349
|
+
const body = bodyHtml.replace(INVALID_XML_CHARS, '');
|
|
2350
|
+
const wrapper = `<div xmlns="${XHTML_NS}" style="width:${spec.renderWidth}px;height:${spec.logicalHeight}px;transform:scale(${spec.transformScale});transform-origin:0 0">${body}</div>`;
|
|
2351
|
+
return `<svg xmlns="${SVG_NS}" width="${spec.width}" height="${spec.height}"><foreignObject width="${spec.width}" height="${spec.height}">${wrapper}</foreignObject></svg>`;
|
|
2352
|
+
}
|
|
2353
|
+
/**
|
|
2354
|
+
* Card width that fits `contentWidth` of assistant-style rows: the row spans
|
|
2355
|
+
* the card's whole content box, so only the card padding stacks on top.
|
|
2356
|
+
*/
|
|
2357
|
+
function cardWidthForContent(contentWidth, cardPaddingX) {
|
|
2358
|
+
return Math.ceil(contentWidth) + 2 * Math.ceil(cardPaddingX) + exports.ULTRA_WIDTH_MARGIN;
|
|
2359
|
+
}
|
|
2360
|
+
/**
|
|
2361
|
+
* Card width that fits `contentWidth` inside a user bubble: the bubble is
|
|
2362
|
+
* capped at {@link USER_BUBBLE_MAX_RATIO} of the row and adds its own padding,
|
|
2363
|
+
* so the percentage constraint inverts into a division.
|
|
2364
|
+
*/
|
|
2365
|
+
function cardWidthForUserBubble(contentWidth, cardPaddingX) {
|
|
2366
|
+
const inner = Math.ceil(contentWidth) + 2 * share_constants_ts_1.USER_BUBBLE_PAD_X;
|
|
2367
|
+
return Math.ceil(inner / share_constants_ts_1.USER_BUBBLE_MAX_RATIO) + 2 * Math.ceil(cardPaddingX) + exports.ULTRA_WIDTH_MARGIN;
|
|
2368
|
+
}
|
|
2369
|
+
/** Card width a turn's measured content needs, by turn role. */
|
|
2370
|
+
function requiredCardWidth(role, contentWidth, cardPaddingX) {
|
|
2371
|
+
return role === 'user' ? cardWidthForUserBubble(contentWidth, cardPaddingX) : cardWidthForContent(contentWidth, cardPaddingX);
|
|
2372
|
+
}
|
|
2373
|
+
/** Bucket one turn: base width / widened (~780) / wider-than-wide (scale-to-fit). */
|
|
2374
|
+
function bucketTurnWidth(role, contentWidth, cardPaddingX, baseWidth, wideWidth) {
|
|
2375
|
+
const required = requiredCardWidth(role, contentWidth, cardPaddingX);
|
|
2376
|
+
if (required <= baseWidth)
|
|
2377
|
+
return 'base';
|
|
2378
|
+
if (required <= wideWidth)
|
|
2379
|
+
return 'wide';
|
|
2380
|
+
return 'ultra';
|
|
2381
|
+
}
|
|
2382
|
+
/**
|
|
2383
|
+
* Scale-to-fit geometry for content whose needed card width exceeds the wide
|
|
2384
|
+
* width: render the slice at full natural width, shrink geometrically on
|
|
2385
|
+
* output (pinch-zoom in the image viewer recovers readability — PLAN §5.2).
|
|
2386
|
+
*/
|
|
2387
|
+
function ultraSliceWidth(role, contentWidth, cardPaddingX, wideWidth) {
|
|
2388
|
+
const renderWidth = requiredCardWidth(role, contentWidth, cardPaddingX);
|
|
2389
|
+
return { renderWidth, scale: Math.min(1, wideWidth / renderWidth) };
|
|
2390
|
+
}
|
|
2391
|
+
function sameRun(a, b) {
|
|
2392
|
+
return a.renderWidth === b.renderWidth && a.scale === b.scale;
|
|
2393
|
+
}
|
|
2394
|
+
function packingPixels(p, extras, pixelRatio) {
|
|
2395
|
+
const height = (extras.paddingY + p.children) * p.scale;
|
|
2396
|
+
return slicePixelCount(p.renderWidth * p.scale, height, pixelRatio);
|
|
2397
|
+
}
|
|
2398
|
+
/**
|
|
2399
|
+
* Greedy first-fit over runs of consecutive turns sharing the same geometry.
|
|
2400
|
+
* A turn is never split; a run never mixes widths (a 390px turn must not ride
|
|
2401
|
+
* a 780px slice — mid-sequence line-length jumps read as a glitch). The first
|
|
2402
|
+
* turn of a run always starts a slice even when it alone busts the budget
|
|
2403
|
+
* (un-splittable); the caller's ratio walk notices and degrades.
|
|
2404
|
+
*/
|
|
2405
|
+
function greedyPacking(turns, extras, pixelRatio, budgetPx) {
|
|
2406
|
+
const out = [];
|
|
2407
|
+
let i = 0;
|
|
2408
|
+
while (i < turns.length) {
|
|
2409
|
+
let j = i + 1;
|
|
2410
|
+
while (j < turns.length && sameRun(turns[j], turns[i]))
|
|
2411
|
+
j += 1;
|
|
2412
|
+
let cur;
|
|
2413
|
+
let children = 0;
|
|
2414
|
+
for (let k = i; k < j; k++) {
|
|
2415
|
+
const turn = turns[k];
|
|
2416
|
+
const projected = children + (cur === undefined ? 0 : extras.gap) + turn.height;
|
|
2417
|
+
const candidate = cur === undefined
|
|
2418
|
+
? { from: k, to: k, renderWidth: turn.renderWidth, scale: turn.scale, children: turn.height }
|
|
2419
|
+
: { ...cur, to: k, children: projected };
|
|
2420
|
+
if (cur !== undefined && packingPixels(candidate, extras, pixelRatio) > budgetPx) {
|
|
2421
|
+
out.push(cur);
|
|
2422
|
+
cur = { from: k, to: k, renderWidth: turn.renderWidth, scale: turn.scale, children: turn.height };
|
|
2423
|
+
children = turn.height;
|
|
2424
|
+
}
|
|
2425
|
+
else {
|
|
2426
|
+
cur = candidate;
|
|
2427
|
+
children = projected;
|
|
2428
|
+
}
|
|
2429
|
+
}
|
|
2430
|
+
if (cur !== undefined)
|
|
2431
|
+
out.push(cur);
|
|
2432
|
+
i = j;
|
|
2433
|
+
}
|
|
2434
|
+
return out;
|
|
2435
|
+
}
|
|
2436
|
+
/**
|
|
2437
|
+
* Ratio ladder + tail-keeping truncation shared by both planners. Order of
|
|
2438
|
+
* levers (PLAN §5.2):
|
|
2439
|
+
* 1. pick the highest pixel ratio whose greedy packing fits both the budget
|
|
2440
|
+
* on every slice and the {@link PlanOptions.maxSlices} count — the ratio is
|
|
2441
|
+
* GLOBAL, a property of the whole card's plan, never of one slice;
|
|
2442
|
+
* 2. at the lowest ratio, keep the LAST maxSlices slices and drop the head
|
|
2443
|
+
* (a share image is of the RECENT conversation — with range=last the user
|
|
2444
|
+
* explicitly named the newest turns, which head-keeping would discard).
|
|
2445
|
+
*/
|
|
2446
|
+
function walkRatioAndTruncate(turns, extras, opts) {
|
|
2447
|
+
const lastRatio = opts.ratios.length > 0 ? opts.ratios[opts.ratios.length - 1] : 1;
|
|
2448
|
+
if (turns.length === 0) {
|
|
2449
|
+
return { packing: [], pixelRatio: lastRatio, truncated: false, droppedTurns: 0 };
|
|
2450
|
+
}
|
|
2451
|
+
let packing;
|
|
2452
|
+
let pixelRatio = lastRatio;
|
|
2453
|
+
for (const candidate of opts.ratios) {
|
|
2454
|
+
const p = greedyPacking(turns, extras, candidate, opts.budgetPx);
|
|
2455
|
+
if (p.length <= opts.maxSlices && p.every((s) => packingPixels(s, extras, candidate) <= opts.budgetPx)) {
|
|
2456
|
+
packing = p;
|
|
2457
|
+
pixelRatio = candidate;
|
|
2458
|
+
break;
|
|
2459
|
+
}
|
|
2460
|
+
}
|
|
2461
|
+
let truncated = false;
|
|
2462
|
+
let droppedTurns = 0;
|
|
2463
|
+
if (packing === undefined) {
|
|
2464
|
+
const p = greedyPacking(turns, extras, lastRatio, opts.budgetPx);
|
|
2465
|
+
// Keep the TAIL: the head is the only droppable direction (see doc above).
|
|
2466
|
+
const kept = p.slice(Math.max(0, p.length - opts.maxSlices));
|
|
2467
|
+
const firstKept = kept[0];
|
|
2468
|
+
droppedTurns = firstKept === undefined ? turns.length : firstKept.from;
|
|
2469
|
+
truncated = droppedTurns > 0;
|
|
2470
|
+
packing = kept;
|
|
2471
|
+
pixelRatio = lastRatio;
|
|
2472
|
+
}
|
|
2473
|
+
return { packing, pixelRatio, truncated, droppedTurns };
|
|
2474
|
+
}
|
|
2475
|
+
/** Reserve the header/note/footer chrome on the first/last packing rows (mutates). */
|
|
2476
|
+
function attachChrome(packing, extras, truncated) {
|
|
2477
|
+
const first = packing[0];
|
|
2478
|
+
first.children += extras.headerHeight + extras.gap;
|
|
2479
|
+
if (truncated)
|
|
2480
|
+
first.children += extras.noteHeight + extras.gap;
|
|
2481
|
+
const last = packing[packing.length - 1];
|
|
2482
|
+
last.children += extras.footerHeight + extras.gap;
|
|
2483
|
+
}
|
|
2484
|
+
/**
|
|
2485
|
+
* Plan the slice sequence (legacy multi-file geometry, ticket 04). Beyond the
|
|
2486
|
+
* shared walk, any slice still over budget (an un-splittable giant turn, or
|
|
2487
|
+
* the header/footer chrome nudging a full slice past the line) is shrunk
|
|
2488
|
+
* uniformly PER SLICE (the `lever`) — geometric scaling keeps completeness,
|
|
2489
|
+
* zoom restores size. Per-slice levers make physical widths diverge, which is
|
|
2490
|
+
* fine for separate files and exactly what the stitched planner removes.
|
|
2491
|
+
*
|
|
2492
|
+
* Deterministic pure function of its inputs.
|
|
2493
|
+
*/
|
|
2494
|
+
function planShareSlices(turns, extras, opts) {
|
|
2495
|
+
const { packing, pixelRatio, truncated, droppedTurns } = walkRatioAndTruncate(turns, extras, opts);
|
|
2496
|
+
if (packing.length === 0) {
|
|
2497
|
+
return { pixelRatio, slices: [], truncated: false, droppedTurns: 0 };
|
|
2498
|
+
}
|
|
2499
|
+
attachChrome(packing, extras, truncated);
|
|
2500
|
+
const count = packing.length;
|
|
2501
|
+
const slices = packing.map((p, index) => {
|
|
2502
|
+
const height = extras.paddingY + p.children;
|
|
2503
|
+
// Budget lever: the sqrt bound ignores the canvas ceil rounding, so shrink
|
|
2504
|
+
// iteratively (sub-1% deficits converge in one or two steps).
|
|
2505
|
+
let scale = p.scale;
|
|
2506
|
+
if (slicePixelCount(p.renderWidth * scale, height * scale, pixelRatio) > opts.budgetPx) {
|
|
2507
|
+
const bound = Math.sqrt(opts.budgetPx / (p.renderWidth * height)) / pixelRatio;
|
|
2508
|
+
scale = Math.min(p.scale, bound);
|
|
2509
|
+
for (let attempt = 0; attempt < 24 && slicePixelCount(p.renderWidth * scale, height * scale, pixelRatio) > opts.budgetPx; attempt++) {
|
|
2510
|
+
scale *= 0.997;
|
|
2511
|
+
}
|
|
2512
|
+
}
|
|
2513
|
+
const lever = p.scale > 0 ? scale / p.scale : 1;
|
|
2514
|
+
return {
|
|
2515
|
+
from: p.from,
|
|
2516
|
+
to: p.to,
|
|
2517
|
+
renderWidth: p.renderWidth,
|
|
2518
|
+
scale,
|
|
2519
|
+
height,
|
|
2520
|
+
outWidth: p.renderWidth * scale,
|
|
2521
|
+
outHeight: height * scale,
|
|
2522
|
+
lever,
|
|
2523
|
+
first: index === 0,
|
|
2524
|
+
last: index === count - 1,
|
|
2525
|
+
truncated: truncated && index === 0,
|
|
2526
|
+
};
|
|
2527
|
+
});
|
|
2528
|
+
return { pixelRatio, slices, truncated, droppedTurns };
|
|
2529
|
+
}
|
|
2530
|
+
/* ---- stitch-mode unified planning (ticket 07, PLAN §4.5) -------------------- */
|
|
2531
|
+
/**
|
|
2532
|
+
* Global logical width of a stitched export: 390 unless ANY turn needs wide
|
|
2533
|
+
* content, then 780 for the WHOLE card — one long image must not mix widths
|
|
2534
|
+
* (scanlines concatenate only when every slice has the same pixel width; a
|
|
2535
|
+
* mid-image line-length jump would read as a glitch anyway).
|
|
2536
|
+
*/
|
|
2537
|
+
function unifiedCardWidth(anyTurnNeedsWide) {
|
|
2538
|
+
return anyTurnNeedsWide ? share_constants_ts_1.WIDE_SLICE_WIDTH : share_constants_ts_1.BASE_CARD_WIDTH;
|
|
2539
|
+
}
|
|
2540
|
+
/**
|
|
2541
|
+
* Plan the slice sequence for a stitched single-PNG export. Same walk and
|
|
2542
|
+
* truncation semantics as {@link planShareSlices}, but the budget lever is
|
|
2543
|
+
* GLOBAL: one multiplier shared by every slice, so every slice's output width
|
|
2544
|
+
* is identical and the PNG stitcher can concatenate scanlines directly. The
|
|
2545
|
+
* inputs must already be normalized to the global width by the caller (the
|
|
2546
|
+
* DOM half measures the card once at {@link unifiedCardWidth}; turns still
|
|
2547
|
+
* wider than that keep their per-turn ultra scale-to-fit, which lands on the
|
|
2548
|
+
* same output width by construction).
|
|
2549
|
+
*/
|
|
2550
|
+
function planShareSlicesUnified(turns, extras, opts) {
|
|
2551
|
+
const { packing, pixelRatio, truncated, droppedTurns } = walkRatioAndTruncate(turns, extras, opts);
|
|
2552
|
+
if (packing.length === 0) {
|
|
2553
|
+
return { pixelRatio, slices: [], truncated: false, droppedTurns: 0 };
|
|
2554
|
+
}
|
|
2555
|
+
attachChrome(packing, extras, truncated);
|
|
2556
|
+
// GLOBAL lever: when any un-splittable slice busts the budget at the chosen
|
|
2557
|
+
// ratio, the most-shrinking slice governs a single multiplier applied to all
|
|
2558
|
+
// of them — widths stay uniform (the stitch invariant) at the cost of
|
|
2559
|
+
// shrinking well-behaved slices too, which only happens when some single
|
|
2560
|
+
// turn is taller than the whole budget.
|
|
2561
|
+
let lever = 1;
|
|
2562
|
+
const pixelsOf = (p, multiplier) => {
|
|
2563
|
+
const height = (extras.paddingY + p.children) * p.scale * multiplier;
|
|
2564
|
+
return slicePixelCount(p.renderWidth * p.scale * multiplier, height, pixelRatio);
|
|
2565
|
+
};
|
|
2566
|
+
if (packing.some((p) => pixelsOf(p, 1) > opts.budgetPx)) {
|
|
2567
|
+
let bound = 1;
|
|
2568
|
+
for (const p of packing) {
|
|
2569
|
+
const logical = extras.paddingY + p.children;
|
|
2570
|
+
const sliceBound = Math.sqrt(opts.budgetPx / (p.renderWidth * logical)) / pixelRatio;
|
|
2571
|
+
bound = Math.min(bound, sliceBound / p.scale);
|
|
2572
|
+
}
|
|
2573
|
+
lever = Math.min(1, bound);
|
|
2574
|
+
for (let attempt = 0; attempt < 24 && packing.some((p) => pixelsOf(p, lever) > opts.budgetPx); attempt++) {
|
|
2575
|
+
lever *= 0.997;
|
|
2576
|
+
}
|
|
2577
|
+
}
|
|
2578
|
+
const count = packing.length;
|
|
2579
|
+
const slices = packing.map((p, index) => {
|
|
2580
|
+
const height = extras.paddingY + p.children;
|
|
2581
|
+
const scale = p.scale * lever;
|
|
2582
|
+
return {
|
|
2583
|
+
from: p.from,
|
|
2584
|
+
to: p.to,
|
|
2585
|
+
renderWidth: p.renderWidth,
|
|
2586
|
+
scale,
|
|
2587
|
+
height,
|
|
2588
|
+
outWidth: p.renderWidth * scale,
|
|
2589
|
+
outHeight: height * scale,
|
|
2590
|
+
lever,
|
|
2591
|
+
first: index === 0,
|
|
2592
|
+
last: index === count - 1,
|
|
2593
|
+
truncated: truncated && index === 0,
|
|
2594
|
+
};
|
|
2595
|
+
});
|
|
2596
|
+
return { pixelRatio, slices, truncated, droppedTurns };
|
|
2597
|
+
}
|
|
2598
|
+
/**
|
|
2599
|
+
* Authoritative physical width of a stitched plan — the IHDR width of the
|
|
2600
|
+
* single long image. Every slice's own `outWidth × pixelRatio` must already
|
|
2601
|
+
* agree; the max absorbs the ±ε float noise of ultra scales
|
|
2602
|
+
* (renderWidth × W/renderWidth), so one integer serves the whole card and the
|
|
2603
|
+
* painter FORCES it (rasterGeometryStitch) instead of re-deriving it per slice.
|
|
2604
|
+
*/
|
|
2605
|
+
function stitchPixelWidth(plan) {
|
|
2606
|
+
if (plan.slices.length === 0)
|
|
2607
|
+
return 0;
|
|
2608
|
+
const widest = plan.slices.reduce((max, s) => Math.max(max, s.outWidth), 0);
|
|
2609
|
+
return Math.ceil(widest * plan.pixelRatio);
|
|
2610
|
+
}
|
|
2611
|
+
/**
|
|
2612
|
+
* Physical raster geometry of one slice in stitch mode. The width is pinned to
|
|
2613
|
+
* `globalWidth` so every slice's canvas — and the scanlines the stitcher reads
|
|
2614
|
+
* from it — is exactly as wide as the final image; a slice that freshly
|
|
2615
|
+
* measures past the per-slice budget would need a height CLIP (never a width
|
|
2616
|
+
* shrink: that would break concatenation, never aspect distortion), and the
|
|
2617
|
+
* painter rejects that case instead of clipping (see `clamped`). The
|
|
2618
|
+
* planner's global lever is the mechanism that keeps slices in budget; this
|
|
2619
|
+
* clamp is the loud tripwire behind it.
|
|
2620
|
+
*/
|
|
2621
|
+
function rasterGeometryStitch(renderWidth, logicalHeight, globalWidth, budgetPx) {
|
|
2622
|
+
if (!Number.isInteger(globalWidth) || globalWidth <= 0)
|
|
2623
|
+
throw new Error(`share-rasterize: invalid stitch width ${globalWidth}`);
|
|
2624
|
+
const transformScale = globalWidth / renderWidth;
|
|
2625
|
+
let height = Math.max(1, Math.ceil(logicalHeight * transformScale));
|
|
2626
|
+
let clamped = false;
|
|
2627
|
+
if (globalWidth * height > budgetPx) {
|
|
2628
|
+
height = Math.max(1, Math.floor(budgetPx / globalWidth));
|
|
2629
|
+
clamped = true;
|
|
2630
|
+
}
|
|
2631
|
+
return { width: globalWidth, height, transformScale, clamped };
|
|
2632
|
+
}
|
|
2633
|
+
/* ============================================================================
|
|
2634
|
+
* DOM EXECUTION — measures only, executes the pure plan
|
|
2635
|
+
* ========================================================================== */
|
|
2636
|
+
/** Overflow below this many px is rounding noise, not wide content. */
|
|
2637
|
+
const OVERFLOW_EPSILON = 1;
|
|
2638
|
+
/** Default truncation copy: counts the dropped head turns. */
|
|
2639
|
+
const DEFAULT_TRUNCATED_NOTE = (droppedTurns) => `已省略前 ${droppedTurns} 轮`;
|
|
2640
|
+
/**
|
|
2641
|
+
* Offscreen measurement/render host. A shadow root so page stylesheets (which
|
|
2642
|
+
* the later SVG-image render cannot load) do not reach the clones — inside
|
|
2643
|
+
* the shadow tree only inherited properties flow through, and the card
|
|
2644
|
+
* template resets the inheritable ones (font/color/line-height) inline on its
|
|
2645
|
+
* root, making the two contexts agree. position:fixed/-9999px keeps it laid
|
|
2646
|
+
* out (measurement needs real layout) but invisible.
|
|
2647
|
+
*/
|
|
2648
|
+
function createOffscreenHost() {
|
|
2649
|
+
const host = document.createElement('div');
|
|
2650
|
+
host.setAttribute('data-share-rasterize-host', '');
|
|
2651
|
+
host.style.position = 'fixed';
|
|
2652
|
+
host.style.left = '-9999px';
|
|
2653
|
+
host.style.top = '0';
|
|
2654
|
+
const root = host.attachShadow({ mode: 'open' });
|
|
2655
|
+
document.body.appendChild(host);
|
|
2656
|
+
return { root, dispose: () => { host.remove(); } };
|
|
2657
|
+
}
|
|
2658
|
+
function cloneCardAt(card, width) {
|
|
2659
|
+
const clone = card.cloneNode(true);
|
|
2660
|
+
clone.style.width = `${width}px`;
|
|
2661
|
+
return clone;
|
|
2662
|
+
}
|
|
2663
|
+
/**
|
|
2664
|
+
* Natural content width of a turn: the widest descendant box that actually
|
|
2665
|
+
* overflows its own container (`white-space: pre` code/table segments — their
|
|
2666
|
+
* extent is container-independent). Non-overflowing descendants report their
|
|
2667
|
+
* box width, which is a layout result, not a need, so they are skipped.
|
|
2668
|
+
*/
|
|
2669
|
+
function contentExtent(turn) {
|
|
2670
|
+
let max = 0;
|
|
2671
|
+
for (const el of turn.querySelectorAll('*')) {
|
|
2672
|
+
if (el.scrollWidth > el.clientWidth + OVERFLOW_EPSILON && el.scrollWidth > max) {
|
|
2673
|
+
max = el.scrollWidth;
|
|
2674
|
+
}
|
|
2675
|
+
}
|
|
2676
|
+
return max;
|
|
2677
|
+
}
|
|
2678
|
+
/**
|
|
2679
|
+
* Clone the card at `width`, measure everything the planner needs, drop the
|
|
2680
|
+
* clone. All reads (offsetHeight/scrollWidth/getComputedStyle) force sync
|
|
2681
|
+
* layout, so the numbers are stable the moment the clone is attached — no
|
|
2682
|
+
* frame timing involved.
|
|
2683
|
+
*/
|
|
2684
|
+
function measureCardAt(root, card, width) {
|
|
2685
|
+
const clone = cloneCardAt(card, width);
|
|
2686
|
+
root.appendChild(clone);
|
|
2687
|
+
const turns = [];
|
|
2688
|
+
for (const el of Array.from(clone.querySelectorAll('[data-share-turn]'))) {
|
|
2689
|
+
const raw = el.getAttribute('data-share-turn');
|
|
2690
|
+
if (raw === null)
|
|
2691
|
+
continue;
|
|
2692
|
+
const seq = Number(raw);
|
|
2693
|
+
if (!Number.isFinite(seq))
|
|
2694
|
+
continue;
|
|
2695
|
+
turns.push({
|
|
2696
|
+
seq,
|
|
2697
|
+
role: el.getAttribute('data-share-role') === 'user' ? 'user' : 'assistant',
|
|
2698
|
+
height: el.offsetHeight,
|
|
2699
|
+
content: contentExtent(el),
|
|
2700
|
+
});
|
|
2701
|
+
}
|
|
2702
|
+
const computed = getComputedStyle(clone);
|
|
2703
|
+
const header = clone.querySelector('[data-share-header]');
|
|
2704
|
+
const footer = clone.querySelector('[data-share-footer]');
|
|
2705
|
+
const measure = {
|
|
2706
|
+
turns,
|
|
2707
|
+
headerHeight: header?.offsetHeight ?? 0,
|
|
2708
|
+
footerHeight: footer?.offsetHeight ?? 0,
|
|
2709
|
+
gap: parsePx(computed.rowGap),
|
|
2710
|
+
paddingY: parsePx(computed.paddingTop) + parsePx(computed.paddingBottom),
|
|
2711
|
+
paddingX: parsePx(computed.paddingLeft),
|
|
2712
|
+
};
|
|
2713
|
+
clone.remove();
|
|
2714
|
+
return measure;
|
|
2715
|
+
}
|
|
2716
|
+
function parsePx(value) {
|
|
2717
|
+
const n = Number.parseFloat(value);
|
|
2718
|
+
return Number.isFinite(n) ? n : 0;
|
|
2719
|
+
}
|
|
2720
|
+
function measureElementHeight(root, el) {
|
|
2721
|
+
root.appendChild(el);
|
|
2722
|
+
const h = el.offsetHeight;
|
|
2723
|
+
el.remove();
|
|
2724
|
+
return h;
|
|
2725
|
+
}
|
|
2726
|
+
/**
|
|
2727
|
+
* Measure an ultra turn at its planned width, re-deciding up to three times if
|
|
2728
|
+
* the formula missed a constraint the DOM knows better (nested percentage
|
|
2729
|
+
* boxes): widening the card never shrinks `pre` content, so re-measuring
|
|
2730
|
+
* converges. When it still does not fit after the third pass the geometry is
|
|
2731
|
+
* exported anyway (completeness over sharpness) but warned about once — a
|
|
2732
|
+
* silent clip would look like data loss.
|
|
2733
|
+
*/
|
|
2734
|
+
function measureUltraTurn(root, card, turn, cardPaddingX, targetWidth) {
|
|
2735
|
+
let geo = ultraSliceWidth(turn.role, turn.content, cardPaddingX, targetWidth);
|
|
2736
|
+
let height = turn.height;
|
|
2737
|
+
let scrollWidth = 0;
|
|
2738
|
+
let fits = false;
|
|
2739
|
+
for (let attempt = 0; attempt < 3 && !fits; attempt++) {
|
|
2740
|
+
const clone = cloneCardAt(card, geo.renderWidth);
|
|
2741
|
+
root.appendChild(clone);
|
|
2742
|
+
const el = clone.querySelector(`[data-share-turn="${turn.seq}"]`);
|
|
2743
|
+
const extent = el !== null ? contentExtent(el) : turn.content;
|
|
2744
|
+
height = el?.offsetHeight ?? height;
|
|
2745
|
+
scrollWidth = el?.scrollWidth ?? 0;
|
|
2746
|
+
fits = el === null || scrollWidth <= geo.renderWidth;
|
|
2747
|
+
clone.remove();
|
|
2748
|
+
if (!fits)
|
|
2749
|
+
geo = ultraSliceWidth(turn.role, extent, cardPaddingX, targetWidth);
|
|
2750
|
+
}
|
|
2751
|
+
if (!fits) {
|
|
2752
|
+
console.warn(`[dsh-zen-remote] share-image: ultra turn ${turn.seq} still wider than its slice after 3 re-measures (content ${scrollWidth}px > render width ${geo.renderWidth}px); exporting may clip its right edge`);
|
|
2753
|
+
}
|
|
2754
|
+
return { height, renderWidth: geo.renderWidth, scale: geo.scale };
|
|
2755
|
+
}
|
|
2756
|
+
/** Theme probes for the truncation note, read off the live card (inline styles). */
|
|
2757
|
+
function readNoteTheme(card) {
|
|
2758
|
+
let tertiary = '#81858c';
|
|
2759
|
+
let border = 'rgba(0, 0, 0, 0.1)';
|
|
2760
|
+
let fontFamily = '';
|
|
2761
|
+
try {
|
|
2762
|
+
fontFamily = getComputedStyle(card).fontFamily;
|
|
2763
|
+
const caption = card.querySelector('[data-share-footer] span:last-of-type');
|
|
2764
|
+
if (caption !== null)
|
|
2765
|
+
tertiary = getComputedStyle(caption).color;
|
|
2766
|
+
const rule = card.querySelector('[data-share-footer] > div:first-child');
|
|
2767
|
+
if (rule !== null)
|
|
2768
|
+
border = getComputedStyle(rule).backgroundColor;
|
|
2769
|
+
}
|
|
2770
|
+
catch {
|
|
2771
|
+
// getComputedStyle on a detached card — the defaults above carry the note.
|
|
2772
|
+
}
|
|
2773
|
+
return { tertiary, border, fontFamily };
|
|
2774
|
+
}
|
|
2775
|
+
/** The「已省略前 X 轮」marker bar — inline-styled like everything the card renders. */
|
|
2776
|
+
function buildTruncationNote(text, theme) {
|
|
2777
|
+
const el = document.createElement('div');
|
|
2778
|
+
el.setAttribute('data-share-truncated', '');
|
|
2779
|
+
el.style.display = 'flex';
|
|
2780
|
+
el.style.alignItems = 'center';
|
|
2781
|
+
el.style.gap = '6px';
|
|
2782
|
+
el.style.padding = '8px 12px';
|
|
2783
|
+
el.style.border = `1px dashed ${theme.border}`;
|
|
2784
|
+
el.style.borderRadius = '8px';
|
|
2785
|
+
el.style.fontSize = '12px';
|
|
2786
|
+
el.style.lineHeight = '1.5';
|
|
2787
|
+
el.style.color = theme.tertiary;
|
|
2788
|
+
if (theme.fontFamily !== '')
|
|
2789
|
+
el.style.fontFamily = theme.fontFamily;
|
|
2790
|
+
el.style.overflow = 'visible';
|
|
2791
|
+
el.textContent = text;
|
|
2792
|
+
return el;
|
|
2793
|
+
}
|
|
2794
|
+
/**
|
|
2795
|
+
* Clone the card and prune it down to exactly one slice's content: the
|
|
2796
|
+
* planned turns, the header only on the first slice, the footer only on the
|
|
2797
|
+
* last, plus the truncation note right under the header of a truncated first
|
|
2798
|
+
* slice. The root's own flex gap/padding keep the layout identical to the
|
|
2799
|
+
* planner's arithmetic (children + inter-child gaps + padding).
|
|
2800
|
+
*/
|
|
2801
|
+
function buildSliceClone(card, root, slice, seqs, note) {
|
|
2802
|
+
const clone = cloneCardAt(card, slice.renderWidth);
|
|
2803
|
+
for (const el of Array.from(clone.querySelectorAll('[data-share-turn]'))) {
|
|
2804
|
+
const raw = el.getAttribute('data-share-turn');
|
|
2805
|
+
if (raw === null || !seqs.has(Number(raw)))
|
|
2806
|
+
el.remove();
|
|
2807
|
+
}
|
|
2808
|
+
const header = clone.querySelector('[data-share-header]');
|
|
2809
|
+
if (!slice.first)
|
|
2810
|
+
header?.remove();
|
|
2811
|
+
if (slice.truncated && note !== undefined) {
|
|
2812
|
+
const noteNode = note.cloneNode(true);
|
|
2813
|
+
if (header !== null && header.parentElement !== null)
|
|
2814
|
+
header.parentElement.insertBefore(noteNode, header.nextSibling);
|
|
2815
|
+
else
|
|
2816
|
+
clone.insertBefore(noteNode, clone.firstChild);
|
|
2817
|
+
}
|
|
2818
|
+
if (!slice.last)
|
|
2819
|
+
clone.querySelector('[data-share-footer]')?.remove();
|
|
2820
|
+
root.appendChild(clone);
|
|
2821
|
+
return clone;
|
|
2822
|
+
}
|
|
2823
|
+
/**
|
|
2824
|
+
* Make an HTML subtree survive XML parsing: the foreignObject child needs the
|
|
2825
|
+
* xhtml namespace (HTML serialization never emits it), and inline `<svg>`
|
|
2826
|
+
* glyphs (the image placeholder icon) need theirs — without it they parse as
|
|
2827
|
+
* xhtml-namespace elements named "svg" and silently render as nothing.
|
|
2828
|
+
*/
|
|
2829
|
+
function prepareXmlSerialization(root) {
|
|
2830
|
+
root.setAttribute('xmlns', XHTML_NS);
|
|
2831
|
+
for (const el of Array.from(root.querySelectorAll('*'))) {
|
|
2832
|
+
if (el.namespaceURI === SVG_NS)
|
|
2833
|
+
el.setAttribute('xmlns', SVG_NS);
|
|
2834
|
+
}
|
|
2835
|
+
}
|
|
2836
|
+
/**
|
|
2837
|
+
* Describe a source URL for an error surface WITHOUT embedding its content:
|
|
2838
|
+
* the SVG data URL carries the whole card (session transcript), so only the
|
|
2839
|
+
* scheme prefix and total length are kept.
|
|
2840
|
+
*/
|
|
2841
|
+
function describeImageUrl(url) {
|
|
2842
|
+
if (!url.startsWith('data:'))
|
|
2843
|
+
return url.slice(0, 120);
|
|
2844
|
+
const comma = url.indexOf(',');
|
|
2845
|
+
const prefix = comma === -1 ? url.slice(0, 48) : url.slice(0, comma);
|
|
2846
|
+
return `${prefix} (${url.length} chars, payload omitted)`;
|
|
2847
|
+
}
|
|
2848
|
+
function loadImage(url) {
|
|
2849
|
+
return new Promise((resolve, reject) => {
|
|
2850
|
+
const img = new Image();
|
|
2851
|
+
img.onload = () => { resolve(img); };
|
|
2852
|
+
img.onerror = () => { reject(new ShareRasterizeError('image-load', describeImageUrl(url))); };
|
|
2853
|
+
img.src = url;
|
|
2854
|
+
});
|
|
2855
|
+
}
|
|
2856
|
+
function canvasToPng(canvas) {
|
|
2857
|
+
return new Promise((resolve, reject) => {
|
|
2858
|
+
canvas.toBlob((blob) => {
|
|
2859
|
+
if (blob === null)
|
|
2860
|
+
reject(new ShareRasterizeError('canvas', 'toBlob returned null'));
|
|
2861
|
+
else
|
|
2862
|
+
resolve(blob);
|
|
2863
|
+
}, 'image/png');
|
|
2864
|
+
});
|
|
2865
|
+
}
|
|
2866
|
+
function readBackground(el) {
|
|
2867
|
+
const bg = getComputedStyle(el).backgroundColor;
|
|
2868
|
+
return bg === '' || bg === 'rgba(0, 0, 0, 0)' || bg === 'transparent' ? '#ffffff' : bg;
|
|
2869
|
+
}
|
|
2870
|
+
/**
|
|
2871
|
+
* Paint one prepared slice clone: serialize → SVG data URL (encodeURIComponent
|
|
2872
|
+
* — transcript text is not ASCII) sized at the slice's PHYSICAL target → Image
|
|
2873
|
+
* whose intrinsic size already IS the canvas size → 1:1 canvas copy → PNG
|
|
2874
|
+
* blob. The canvas is released (zeroed) before returning; only the blob is
|
|
2875
|
+
* kept. Output height is taken from the freshly measured clone, not the
|
|
2876
|
+
* planner's estimate — reflow at slice width is authoritative — and any
|
|
2877
|
+
* resulting budget overshoot is clamped by rasterGeometry (legacy) or
|
|
2878
|
+
* REFUSED with ShareRasterizeError('slice-over-budget') by
|
|
2879
|
+
* rasterGeometryStitch (unified: width pinned to `globalWidth`, a floor'd
|
|
2880
|
+
* height would silently clip the bottom — review 07+08).
|
|
2881
|
+
*/
|
|
2882
|
+
async function paintSlice(clone, slice, pixelRatio, globalWidth) {
|
|
2883
|
+
const logicalHeight = Math.ceil(clone.offsetHeight);
|
|
2884
|
+
let width;
|
|
2885
|
+
let height;
|
|
2886
|
+
let transformScale;
|
|
2887
|
+
if (globalWidth === undefined) {
|
|
2888
|
+
const geo = rasterGeometry(slice.renderWidth, logicalHeight, slice.scale, pixelRatio, exports.SLICE_PIXEL_BUDGET);
|
|
2889
|
+
width = geo.width;
|
|
2890
|
+
height = geo.height;
|
|
2891
|
+
transformScale = geo.transformScale;
|
|
2892
|
+
}
|
|
2893
|
+
else {
|
|
2894
|
+
const geo = rasterGeometryStitch(slice.renderWidth, logicalHeight, globalWidth, exports.SLICE_PIXEL_BUDGET);
|
|
2895
|
+
if (geo.clamped) {
|
|
2896
|
+
// Review 07+08 (plan B): floor'ing the height would silently cut the
|
|
2897
|
+
// bottom off the slice — data loss the user cannot see. The planner's
|
|
2898
|
+
// global lever should have kept every slice in budget; reaching here
|
|
2899
|
+
// means it did not, and the honest answer is a localized failure, not a
|
|
2900
|
+
// quietly truncated image.
|
|
2901
|
+
const needed = Math.ceil(logicalHeight * geo.transformScale);
|
|
2902
|
+
const detail = `slice ${slice.from}-${slice.to} measured ${needed}px tall at stitch width ${globalWidth}px, but the canvas budget caps it at ${geo.height}px — refusing a silent bottom clip`;
|
|
2903
|
+
console.warn(`[dsh-zen-remote] share-image: ${detail} (the global lever should have prevented this)`);
|
|
2904
|
+
throw new ShareRasterizeError('slice-over-budget', detail);
|
|
2905
|
+
}
|
|
2906
|
+
width = geo.width;
|
|
2907
|
+
height = geo.height;
|
|
2908
|
+
transformScale = geo.transformScale;
|
|
2909
|
+
}
|
|
2910
|
+
prepareXmlSerialization(clone);
|
|
2911
|
+
const svg = buildSvgDocument(clone.outerHTML, {
|
|
2912
|
+
renderWidth: slice.renderWidth,
|
|
2913
|
+
logicalHeight,
|
|
2914
|
+
width,
|
|
2915
|
+
height,
|
|
2916
|
+
transformScale,
|
|
2917
|
+
});
|
|
2918
|
+
const url = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`;
|
|
2919
|
+
const img = await loadImage(url);
|
|
2920
|
+
const canvas = document.createElement('canvas');
|
|
2921
|
+
canvas.width = width;
|
|
2922
|
+
canvas.height = height;
|
|
2923
|
+
const ctx = canvas.getContext('2d');
|
|
2924
|
+
if (ctx === null)
|
|
2925
|
+
throw new ShareRasterizeError('canvas', '2d context unavailable');
|
|
2926
|
+
ctx.fillStyle = readBackground(clone);
|
|
2927
|
+
ctx.fillRect(0, 0, width, height);
|
|
2928
|
+
ctx.imageSmoothingQuality = 'high';
|
|
2929
|
+
// 1:1 copy — the SVG's intrinsic size is the physical target, so nothing is
|
|
2930
|
+
// upscaled here (the old logical-intrinsic + drawImage(target) pair made
|
|
2931
|
+
// WebKit hand back a small bitmap this call had to enlarge: blurry on 2x).
|
|
2932
|
+
ctx.drawImage(img, 0, 0);
|
|
2933
|
+
const blob = await canvasToPng(canvas);
|
|
2934
|
+
canvas.width = 0;
|
|
2935
|
+
canvas.height = 0;
|
|
2936
|
+
return { blob, width, height };
|
|
2937
|
+
}
|
|
2938
|
+
let probeOutcome = 'unknown';
|
|
2939
|
+
const PROBE_SIZE = 40;
|
|
2940
|
+
const PROBE_CELLS = ['#e8322c', '#2c55e8', '#28a832', '#e8a028'];
|
|
2941
|
+
/**
|
|
2942
|
+
* 2×2 feature probe (PLAN §5.2): before the first export, rasterize a tiny
|
|
2943
|
+
* known card (four solid color cells through the exact foreignObject → Image
|
|
2944
|
+
* → canvas path) and read the pixels back. Old Safari renders foreignObject
|
|
2945
|
+
* images as nothing — exporting then would produce blank PNGs, so the probe
|
|
2946
|
+
* fails loudly once per page load instead. Failure is sticky.
|
|
2947
|
+
*/
|
|
2948
|
+
async function ensureProbe() {
|
|
2949
|
+
if (probeOutcome === 'ok')
|
|
2950
|
+
return;
|
|
2951
|
+
if (probeOutcome === 'failed') {
|
|
2952
|
+
throw new ShareRasterizeError('probe-failed', 'the SVG foreignObject probe already failed on this page');
|
|
2953
|
+
}
|
|
2954
|
+
const cells = PROBE_CELLS.map((color) => `<div style="width:20px;height:20px;background:${color}"></div>`).join('');
|
|
2955
|
+
const svg = `<svg xmlns="${SVG_NS}" width="${PROBE_SIZE}" height="${PROBE_SIZE}"><foreignObject width="${PROBE_SIZE}" height="${PROBE_SIZE}"><div xmlns="${XHTML_NS}" style="width:${PROBE_SIZE}px;height:${PROBE_SIZE}px;margin:0;display:flex;flex-wrap:wrap">${cells}</div></foreignObject></svg>`;
|
|
2956
|
+
try {
|
|
2957
|
+
const img = await loadImage(`data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`);
|
|
2958
|
+
const canvas = document.createElement('canvas');
|
|
2959
|
+
canvas.width = PROBE_SIZE;
|
|
2960
|
+
canvas.height = PROBE_SIZE;
|
|
2961
|
+
const ctx = canvas.getContext('2d', { willReadFrequently: true });
|
|
2962
|
+
if (ctx === null)
|
|
2963
|
+
throw new Error('2d context unavailable');
|
|
2964
|
+
ctx.drawImage(img, 0, 0);
|
|
2965
|
+
const data = ctx.getImageData(0, 0, PROBE_SIZE, PROBE_SIZE).data;
|
|
2966
|
+
const at = (x, y, channel) => data[(y * PROBE_SIZE + x) * 4 + channel] ?? 0;
|
|
2967
|
+
// Cell centers: red top-left, blue top-right (generous thresholds —
|
|
2968
|
+
// color management may shift values a little, blankness shifts them a lot).
|
|
2969
|
+
const red = at(10, 10, 0) >= 150 && at(10, 10, 1) < 150 && at(10, 10, 2) < 150;
|
|
2970
|
+
const blue = at(30, 10, 2) >= 150 && at(30, 10, 0) < 150 && at(30, 10, 1) < 150;
|
|
2971
|
+
canvas.width = 0;
|
|
2972
|
+
canvas.height = 0;
|
|
2973
|
+
if (!red || !blue)
|
|
2974
|
+
throw new Error('probe canvas came back blank (foreignObject not rendered)');
|
|
2975
|
+
probeOutcome = 'ok';
|
|
2976
|
+
}
|
|
2977
|
+
catch (err) {
|
|
2978
|
+
probeOutcome = 'failed';
|
|
2979
|
+
console.error('[dsh-zen-remote] share-image rasterize probe failed — this browser cannot paint SVG foreignObject content', err);
|
|
2980
|
+
throw new ShareRasterizeError('probe-failed', err instanceof Error ? err.message : String(err));
|
|
2981
|
+
}
|
|
2982
|
+
}
|
|
2983
|
+
/* ---- pipeline -------------------------------------------------------------- */
|
|
2984
|
+
/**
|
|
2985
|
+
* Rasterize a rendered share card into a PNG sequence.
|
|
2986
|
+
*
|
|
2987
|
+
* Pipeline: probe once → measure a clone at base width → bucket turns (pure)
|
|
2988
|
+
* → measure wide/ultra turns at their final widths → plan slices (pure) →
|
|
2989
|
+
* per slice: prune a clone to the slice, measure, serialize into SVG, paint,
|
|
2990
|
+
* release. The returned blobs are owned by the caller (object URLs etc.).
|
|
2991
|
+
*
|
|
2992
|
+
* `unified` (ticket 07) flips both planning decisions the stitch depends on:
|
|
2993
|
+
* one global logical width (re-measured once at 390 or 780 — a card that
|
|
2994
|
+
* lifts ANY turn to wide renders every turn wide) and one global budget
|
|
2995
|
+
* lever, so `stitchWidth` comes back as the single physical width every slice
|
|
2996
|
+
* shares and png-stitch.ts can concatenate the scanlines into one PNG.
|
|
2997
|
+
*/
|
|
2998
|
+
async function rasterizeShareCard(card, options = {}) {
|
|
2999
|
+
await ensureProbe();
|
|
3000
|
+
const cap = Math.min(2, Math.max(1, options.pixelRatioCap ?? (typeof window === 'undefined' ? 1 : window.devicePixelRatio || 1)));
|
|
3001
|
+
const host = createOffscreenHost();
|
|
3002
|
+
try {
|
|
3003
|
+
const base = measureCardAt(host.root, card, share_constants_ts_1.BASE_CARD_WIDTH);
|
|
3004
|
+
if (base.turns.length === 0) {
|
|
3005
|
+
throw new ShareRasterizeError('empty-card', 'no [data-share-turn] elements found under the card root');
|
|
3006
|
+
}
|
|
3007
|
+
const noteTheme = readNoteTheme(card);
|
|
3008
|
+
const noteFor = (droppedTurns) => buildTruncationNote((options.truncatedNote ?? DEFAULT_TRUNCATED_NOTE)(droppedTurns), noteTheme);
|
|
3009
|
+
const extrasOf = (note) => ({
|
|
3010
|
+
gap: base.gap,
|
|
3011
|
+
paddingY: base.paddingY,
|
|
3012
|
+
headerHeight: base.headerHeight,
|
|
3013
|
+
footerHeight: base.footerHeight,
|
|
3014
|
+
noteHeight: measureElementHeight(host.root, note),
|
|
3015
|
+
});
|
|
3016
|
+
const planOpts = { budgetPx: exports.SLICE_PIXEL_BUDGET, maxSlices: exports.MAX_SLICES, ratios: ratioChain(cap) };
|
|
3017
|
+
let note = noteFor(0);
|
|
3018
|
+
let plan;
|
|
3019
|
+
let stitchWidth;
|
|
3020
|
+
const inputs = [];
|
|
3021
|
+
if (options.unified === true) {
|
|
3022
|
+
// Global width first: any wide-needing turn widens the WHOLE card, then
|
|
3023
|
+
// everything is (re)measured at that one width. Turns still wider than
|
|
3024
|
+
// the global width keep the per-turn ultra scale-to-fit — which lands on
|
|
3025
|
+
// the same output width by construction (renderWidth × W/renderWidth).
|
|
3026
|
+
const needsWide = base.turns.some((turn) => bucketTurnWidth(turn.role, turn.content, base.paddingX, share_constants_ts_1.BASE_CARD_WIDTH, share_constants_ts_1.WIDE_SLICE_WIDTH) !== 'base');
|
|
3027
|
+
const cardWidth = unifiedCardWidth(needsWide);
|
|
3028
|
+
const measure = cardWidth === share_constants_ts_1.BASE_CARD_WIDTH ? base : measureCardAt(host.root, card, cardWidth);
|
|
3029
|
+
for (const turn of measure.turns) {
|
|
3030
|
+
if (requiredCardWidth(turn.role, turn.content, measure.paddingX) <= cardWidth) {
|
|
3031
|
+
inputs.push({ height: turn.height, renderWidth: cardWidth, scale: 1 });
|
|
3032
|
+
}
|
|
3033
|
+
else {
|
|
3034
|
+
const ultra = measureUltraTurn(host.root, card, turn, measure.paddingX, cardWidth);
|
|
3035
|
+
inputs.push({ height: ultra.height, renderWidth: ultra.renderWidth, scale: ultra.scale });
|
|
3036
|
+
}
|
|
3037
|
+
}
|
|
3038
|
+
plan = planShareSlicesUnified(inputs, extrasOf(note), planOpts);
|
|
3039
|
+
if (plan.truncated) {
|
|
3040
|
+
// Re-plan once with the real dropped count baked into the note (its text
|
|
3041
|
+
// length can change the bar's height); droppedTurns itself does not
|
|
3042
|
+
// depend on noteHeight, so this converges in one step.
|
|
3043
|
+
note = noteFor(plan.droppedTurns);
|
|
3044
|
+
plan = planShareSlicesUnified(inputs, extrasOf(note), planOpts);
|
|
3045
|
+
}
|
|
3046
|
+
stitchWidth = stitchPixelWidth(plan);
|
|
3047
|
+
}
|
|
3048
|
+
else {
|
|
3049
|
+
const buckets = base.turns.map((turn) => bucketTurnWidth(turn.role, turn.content, base.paddingX, share_constants_ts_1.BASE_CARD_WIDTH, share_constants_ts_1.WIDE_SLICE_WIDTH));
|
|
3050
|
+
const wideHeights = buckets.includes('wide')
|
|
3051
|
+
? new Map(measureCardAt(host.root, card, share_constants_ts_1.WIDE_SLICE_WIDTH).turns.map((t) => [t.seq, t.height]))
|
|
3052
|
+
: undefined;
|
|
3053
|
+
for (let i = 0; i < base.turns.length; i++) {
|
|
3054
|
+
const turn = base.turns[i];
|
|
3055
|
+
const bucket = buckets[i];
|
|
3056
|
+
if (bucket === 'base') {
|
|
3057
|
+
inputs.push({ height: turn.height, renderWidth: share_constants_ts_1.BASE_CARD_WIDTH, scale: 1 });
|
|
3058
|
+
}
|
|
3059
|
+
else if (bucket === 'wide') {
|
|
3060
|
+
inputs.push({ height: wideHeights?.get(turn.seq) ?? turn.height, renderWidth: share_constants_ts_1.WIDE_SLICE_WIDTH, scale: 1 });
|
|
3061
|
+
}
|
|
3062
|
+
else {
|
|
3063
|
+
const ultra = measureUltraTurn(host.root, card, turn, base.paddingX, share_constants_ts_1.WIDE_SLICE_WIDTH);
|
|
3064
|
+
inputs.push({ height: ultra.height, renderWidth: ultra.renderWidth, scale: ultra.scale });
|
|
3065
|
+
}
|
|
3066
|
+
}
|
|
3067
|
+
plan = planShareSlices(inputs, extrasOf(note), planOpts);
|
|
3068
|
+
if (plan.truncated) {
|
|
3069
|
+
note = noteFor(plan.droppedTurns);
|
|
3070
|
+
plan = planShareSlices(inputs, extrasOf(note), planOpts);
|
|
3071
|
+
}
|
|
3072
|
+
}
|
|
3073
|
+
options.onProgress?.(0, plan.slices.length);
|
|
3074
|
+
const slices = [];
|
|
3075
|
+
for (let index = 0; index < plan.slices.length; index++) {
|
|
3076
|
+
const slice = plan.slices[index];
|
|
3077
|
+
const seqSet = new Set(base.turns.slice(slice.from, slice.to + 1).map((t) => t.seq));
|
|
3078
|
+
const clone = buildSliceClone(card, host.root, slice, seqSet, slice.truncated ? note : undefined);
|
|
3079
|
+
const painted = await paintSlice(clone, slice, plan.pixelRatio, stitchWidth);
|
|
3080
|
+
clone.remove();
|
|
3081
|
+
slices.push({
|
|
3082
|
+
index,
|
|
3083
|
+
blob: painted.blob,
|
|
3084
|
+
width: painted.width,
|
|
3085
|
+
height: painted.height,
|
|
3086
|
+
logicalWidth: slice.renderWidth,
|
|
3087
|
+
scale: slice.scale,
|
|
3088
|
+
fromSeq: base.turns[slice.from].seq,
|
|
3089
|
+
toSeq: base.turns[slice.to].seq,
|
|
3090
|
+
});
|
|
3091
|
+
options.onProgress?.(index + 1, plan.slices.length);
|
|
3092
|
+
}
|
|
3093
|
+
return { pixelRatio: plan.pixelRatio, truncated: plan.truncated, droppedTurns: plan.droppedTurns, slices, stitchWidth };
|
|
3094
|
+
}
|
|
3095
|
+
finally {
|
|
3096
|
+
host.dispose();
|
|
3097
|
+
}
|
|
3098
|
+
}
|
|
3099
|
+
};
|
|
3100
|
+
__modules["share/text-layout.js"] = function (require, module, exports) {
|
|
3101
|
+
"use strict";
|
|
3102
|
+
/**
|
|
3103
|
+
* Pure text → segment fold for share-card text blocks (ticket 03).
|
|
3104
|
+
*
|
|
3105
|
+
* Deliberately NOT a markdown parser: the transcripts the share card renders
|
|
3106
|
+
* are *mostly* markdown, and the readability wins that matter on a phone-size
|
|
3107
|
+
* image are structures recognizable from line shape alone — fenced/indented
|
|
3108
|
+
* code, list items, pipe tables. Everything else stays one pre-wrapped
|
|
3109
|
+
* paragraph, which degrades gracefully (raw text still reads), instead of
|
|
3110
|
+
* half-parsing markup into broken pseudo-HTML.
|
|
3111
|
+
*
|
|
3112
|
+
* No React and no DOM in this file on purpose: the share-image check script
|
|
3113
|
+
* (ticket 04) imports it through Node type stripping, which rejects JSX, so
|
|
3114
|
+
* the fold must live in a JSX-free module.
|
|
3115
|
+
*/
|
|
3116
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3117
|
+
exports.segmentShareText = segmentShareText;
|
|
3118
|
+
/** ``` / ~~~ fence opener; a language tag after it is allowed and dropped. */
|
|
3119
|
+
const FENCE_OPEN = /^ {0,3}(```+|~~~+)/;
|
|
3120
|
+
/** A closing fence is the same run of the same character, alone on a line. */
|
|
3121
|
+
const FENCE_CLOSE = {
|
|
3122
|
+
'`': /^ {0,3}`{3,}\s*$/,
|
|
3123
|
+
'~': /^ {0,3}~{3,}\s*$/,
|
|
3124
|
+
};
|
|
3125
|
+
/** 4+ leading spaces or a tab — the classic indented-code threshold. */
|
|
3126
|
+
const INDENTED = /^(?: {4,}|\t)/;
|
|
3127
|
+
/** `- item` / `* item` / `+ item` / `12. item` / `12) item`. */
|
|
3128
|
+
const LIST_ITEM = /^ {0,3}([-*+]|\d{1,3}[.)]) +(.*)$/;
|
|
3129
|
+
/** Pipe-table row; must be followed by another pipe row to OPEN a table block
|
|
3130
|
+
* (a single stray `|` line stays an ordinary paragraph). */
|
|
3131
|
+
const TABLE_ROW = /^\s*\|.*\|/;
|
|
3132
|
+
function isBlank(line) {
|
|
3133
|
+
return line.trim() === '';
|
|
3134
|
+
}
|
|
3135
|
+
/**
|
|
3136
|
+
* Fold one text block's raw string into display segments, top to bottom.
|
|
3137
|
+
*
|
|
3138
|
+
* Rules, in the order each line is tested:
|
|
3139
|
+
* - ```/~~~ fences switch to verbatim code until the closing fence (or the
|
|
3140
|
+
* end of the block — an unterminated fence must not swallow nothing);
|
|
3141
|
+
* - a 4-space/tab indent opens verbatim code; a blank line between two
|
|
3142
|
+
* indented lines stays inside the block (lazy continuation);
|
|
3143
|
+
* - two consecutive pipe rows open a table block, collected while rows last;
|
|
3144
|
+
* - a `-`/`*`/`+`/`N.`/`N)` line opens a list, collected while items last;
|
|
3145
|
+
* - blank lines end the current paragraph; anything else accumulates into the
|
|
3146
|
+
* current paragraph (rendered pre-wrap, so manual line breaks survive).
|
|
3147
|
+
* @param text - raw text-block content from the share-export route.
|
|
3148
|
+
* @returns the segments in reading order; never contains empty segments.
|
|
3149
|
+
*/
|
|
3150
|
+
function segmentShareText(text) {
|
|
3151
|
+
const segments = [];
|
|
3152
|
+
const lines = text.replace(/\r\n?/g, '\n').split('\n');
|
|
3153
|
+
let pending = [];
|
|
3154
|
+
const flushPending = () => {
|
|
3155
|
+
if (pending.length > 0) {
|
|
3156
|
+
segments.push({ kind: 'para', text: pending.join('\n') });
|
|
3157
|
+
pending = [];
|
|
3158
|
+
}
|
|
3159
|
+
};
|
|
3160
|
+
let i = 0;
|
|
3161
|
+
while (i < lines.length) {
|
|
3162
|
+
const line = lines[i] ?? '';
|
|
3163
|
+
const fence = line.match(FENCE_OPEN);
|
|
3164
|
+
if (fence !== null) {
|
|
3165
|
+
flushPending();
|
|
3166
|
+
const close = FENCE_CLOSE[fence[1]?.[0] ?? '`'] ?? FENCE_CLOSE['`'];
|
|
3167
|
+
const body = [];
|
|
3168
|
+
i += 1;
|
|
3169
|
+
while (i < lines.length && !close.test(lines[i] ?? '')) {
|
|
3170
|
+
body.push(lines[i] ?? '');
|
|
3171
|
+
i += 1;
|
|
3172
|
+
}
|
|
3173
|
+
// Skip the closing fence; when the block never closed, this steps past
|
|
3174
|
+
// the end and the loop simply ends.
|
|
3175
|
+
i += 1;
|
|
3176
|
+
if (body.length > 0)
|
|
3177
|
+
segments.push({ kind: 'code', lines: body });
|
|
3178
|
+
continue;
|
|
3179
|
+
}
|
|
3180
|
+
if (INDENTED.test(line)) {
|
|
3181
|
+
flushPending();
|
|
3182
|
+
const body = [line];
|
|
3183
|
+
i += 1;
|
|
3184
|
+
while (i < lines.length) {
|
|
3185
|
+
const next = lines[i] ?? '';
|
|
3186
|
+
if (INDENTED.test(next)) {
|
|
3187
|
+
body.push(next);
|
|
3188
|
+
i += 1;
|
|
3189
|
+
continue;
|
|
3190
|
+
}
|
|
3191
|
+
if (isBlank(next) && INDENTED.test(lines[i + 1] ?? '')) {
|
|
3192
|
+
body.push('');
|
|
3193
|
+
i += 1;
|
|
3194
|
+
continue;
|
|
3195
|
+
}
|
|
3196
|
+
break;
|
|
3197
|
+
}
|
|
3198
|
+
segments.push({ kind: 'code', lines: body });
|
|
3199
|
+
continue;
|
|
3200
|
+
}
|
|
3201
|
+
if (TABLE_ROW.test(line) && TABLE_ROW.test(lines[i + 1] ?? '')) {
|
|
3202
|
+
flushPending();
|
|
3203
|
+
const body = [];
|
|
3204
|
+
while (i < lines.length && TABLE_ROW.test(lines[i] ?? '')) {
|
|
3205
|
+
body.push(lines[i] ?? '');
|
|
3206
|
+
i += 1;
|
|
3207
|
+
}
|
|
3208
|
+
segments.push({ kind: 'table', lines: body });
|
|
3209
|
+
continue;
|
|
3210
|
+
}
|
|
3211
|
+
if (isBlank(line)) {
|
|
3212
|
+
flushPending();
|
|
3213
|
+
i += 1;
|
|
3214
|
+
continue;
|
|
3215
|
+
}
|
|
3216
|
+
const item = line.match(LIST_ITEM);
|
|
3217
|
+
if (item !== null) {
|
|
3218
|
+
flushPending();
|
|
3219
|
+
const items = [];
|
|
3220
|
+
while (i < lines.length) {
|
|
3221
|
+
const m = (lines[i] ?? '').match(LIST_ITEM);
|
|
3222
|
+
if (m === null)
|
|
3223
|
+
break;
|
|
3224
|
+
items.push({ marker: m[1] ?? '-', text: m[2] ?? '' });
|
|
3225
|
+
i += 1;
|
|
3226
|
+
}
|
|
3227
|
+
segments.push({ kind: 'list', items });
|
|
3228
|
+
continue;
|
|
3229
|
+
}
|
|
3230
|
+
pending.push(line);
|
|
3231
|
+
i += 1;
|
|
3232
|
+
}
|
|
3233
|
+
flushPending();
|
|
3234
|
+
return segments;
|
|
3235
|
+
}
|
|
3236
|
+
};
|
|
3237
|
+
__modules["share/markdown.js"] = function (require, module, exports) {
|
|
3238
|
+
"use strict";
|
|
3239
|
+
/**
|
|
3240
|
+
* Pure lenient Markdown-subset parser for the share card's assistant text
|
|
3241
|
+
* (ticket 09, PLAN §5.1 v1.2).
|
|
3242
|
+
*
|
|
3243
|
+
* Scope = the constructs assistant transcripts actually use: ATX headings,
|
|
3244
|
+
* paragraphs, ```/~~~ fenced code (with the language tag), 4-space indented
|
|
3245
|
+
* code, blockquotes, ordered/unordered lists (two indent levels, 2-space
|
|
3246
|
+
* multiples), GFM pipe tables with an alignment row, thematic breaks, and
|
|
3247
|
+
* own-line image placeholders; inline bold/italic/strikethrough/code/link with
|
|
3248
|
+
* backslash escapes. Nothing else — no HTML passthrough, no reference links,
|
|
3249
|
+
* no setext headings, no syntax highlighting (PLAN §8 scope discipline).
|
|
3250
|
+
*
|
|
3251
|
+
* Leniency beats strictness ON PURPOSE (PLAN §5.1): a share image that shows
|
|
3252
|
+
* stray asterisks is worse than one that shows plain text. Every marker pair
|
|
3253
|
+
* must CLOSE before it counts — unclosed, empty or implausible markers fall
|
|
3254
|
+
* back to their literal characters, so arbitrary prose round-trips as itself.
|
|
3255
|
+
* The parser is also total: any input yields a tree (empty input yields []).
|
|
3256
|
+
* Totality is enforced, not assumed: recursion (nested quotes, emphasis inside
|
|
3257
|
+
* link text) carries a depth counter and a pathological 5000-deep "> > > …"
|
|
3258
|
+
* degrades to literal paragraphs instead of overflowing the stack; one inline
|
|
3259
|
+
* run longer than {@link INLINE_PLAIN_MAX} skips inline parsing entirely —
|
|
3260
|
+
* every close-scan is then bounded, so no amount of unclosed markers can
|
|
3261
|
+
* regress past linear time.
|
|
3262
|
+
*
|
|
3263
|
+
* Hard rule for this file: no imports, no JSX, no DOM, no module side effects.
|
|
3264
|
+
* scripts/check-share-image.mjs loads it through Node type stripping, which
|
|
3265
|
+
* EXECUTES every value import, and the client bundler inlines it next to
|
|
3266
|
+
* share-card without new externals.
|
|
3267
|
+
*/
|
|
3268
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3269
|
+
exports.parseMarkdown = parseMarkdown;
|
|
3270
|
+
/* ---- shared character helpers ------------------------------------------------ */
|
|
3271
|
+
/** Whitespace test for the opener/closer flanking rules; EOL counts as space. */
|
|
3272
|
+
function isWs(ch) {
|
|
3273
|
+
return ch === undefined || ch === ' ' || ch === '\t' || ch === '\n';
|
|
3274
|
+
}
|
|
3275
|
+
/** CommonMark's escapable set: one ASCII punctuation char after a backslash. */
|
|
3276
|
+
const ESCAPABLE = new Set('!"#$%&\'()*+,-./:;<=>?@[\\]^_`{|}~'.split(''));
|
|
3277
|
+
/**
|
|
3278
|
+
* Nesting ceiling shared by the block and inline recursion (a quote inside a
|
|
3279
|
+
* quote, emphasis inside link text). Real transcripts never approach it; past
|
|
3280
|
+
* the ceiling the nested content degrades to literal text instead of growing
|
|
3281
|
+
* the native stack — a synthetic 5000-deep "> " chain used to throw.
|
|
3282
|
+
*/
|
|
3283
|
+
const MAX_DEPTH = 64;
|
|
3284
|
+
/**
|
|
3285
|
+
* One inline run longer than this parses as plain text, no markers. Keeps the
|
|
3286
|
+
* close-marker scans bounded: unclosed "*a *a *a …" prose is quadratic in the
|
|
3287
|
+
* classic scan-per-opener shape, and a share-card paragraph this long (4k+
|
|
3288
|
+
* chars with no blank line) has no plausible emphasis anyway.
|
|
3289
|
+
*/
|
|
3290
|
+
const INLINE_PLAIN_MAX = 4096;
|
|
3291
|
+
/* ---- inline parser ----------------------------------------------------------- */
|
|
3292
|
+
/**
|
|
3293
|
+
* Parse one inline run into spans. Scans left to right; backslash escapes and
|
|
3294
|
+
* code spans are handled first (their content is literal), then emphasis and
|
|
3295
|
+
* links. Anything that does not close cleanly is emitted as literal text.
|
|
3296
|
+
*/
|
|
3297
|
+
function parseInline(raw, depth = 0) {
|
|
3298
|
+
// Depth or length guard (see the constants above): degrade to plain text.
|
|
3299
|
+
if (depth >= MAX_DEPTH || raw.length > INLINE_PLAIN_MAX) {
|
|
3300
|
+
return raw === '' ? [] : [{ kind: 'text', text: raw }];
|
|
3301
|
+
}
|
|
3302
|
+
const spans = [];
|
|
3303
|
+
let buf = '';
|
|
3304
|
+
const flush = () => {
|
|
3305
|
+
if (buf !== '') {
|
|
3306
|
+
spans.push({ kind: 'text', text: buf });
|
|
3307
|
+
buf = '';
|
|
3308
|
+
}
|
|
3309
|
+
};
|
|
3310
|
+
const n = raw.length;
|
|
3311
|
+
let i = 0;
|
|
3312
|
+
while (i < n) {
|
|
3313
|
+
const ch = raw[i];
|
|
3314
|
+
if (ch === '\\') {
|
|
3315
|
+
const next = raw[i + 1];
|
|
3316
|
+
if (next !== undefined && ESCAPABLE.has(next)) {
|
|
3317
|
+
buf += next;
|
|
3318
|
+
i += 2;
|
|
3319
|
+
}
|
|
3320
|
+
else {
|
|
3321
|
+
buf += '\\';
|
|
3322
|
+
i += 1;
|
|
3323
|
+
}
|
|
3324
|
+
continue;
|
|
3325
|
+
}
|
|
3326
|
+
if (ch === '`') {
|
|
3327
|
+
const run = tickRun(raw, i);
|
|
3328
|
+
const close = findTickRun(raw, i + run, run);
|
|
3329
|
+
if (close !== -1) {
|
|
3330
|
+
flush();
|
|
3331
|
+
spans.push({ kind: 'code', text: raw.slice(i + run, close) });
|
|
3332
|
+
i = close + run;
|
|
3333
|
+
}
|
|
3334
|
+
else {
|
|
3335
|
+
buf += '`';
|
|
3336
|
+
i += 1;
|
|
3337
|
+
}
|
|
3338
|
+
continue;
|
|
3339
|
+
}
|
|
3340
|
+
if (ch === '*') {
|
|
3341
|
+
const run = starRun(raw, i);
|
|
3342
|
+
// Try the longest marker first (*** = bold(italic)), then **, then *.
|
|
3343
|
+
const lengths = run >= 3 ? [3, 2, 1] : run === 2 ? [2, 1] : [1];
|
|
3344
|
+
let matched = false;
|
|
3345
|
+
for (const len of lengths) {
|
|
3346
|
+
if (matched)
|
|
3347
|
+
break;
|
|
3348
|
+
// Left-flanking: an opener must be immediately followed by a
|
|
3349
|
+
// non-space character (so "5 * 3" never italicizes).
|
|
3350
|
+
if (isWs(raw[i + len]))
|
|
3351
|
+
continue;
|
|
3352
|
+
const close = findStarClose(raw, i + len, len);
|
|
3353
|
+
if (close === -1)
|
|
3354
|
+
continue;
|
|
3355
|
+
const content = raw.slice(i + len, close);
|
|
3356
|
+
if (content.trim() === '')
|
|
3357
|
+
continue;
|
|
3358
|
+
flush();
|
|
3359
|
+
const inner = parseInline(content, depth + 1);
|
|
3360
|
+
if (len === 3)
|
|
3361
|
+
spans.push({ kind: 'bold', spans: [{ kind: 'italic', spans: inner }] });
|
|
3362
|
+
else if (len === 2)
|
|
3363
|
+
spans.push({ kind: 'bold', spans: inner });
|
|
3364
|
+
else
|
|
3365
|
+
spans.push({ kind: 'italic', spans: inner });
|
|
3366
|
+
i = close + len;
|
|
3367
|
+
matched = true;
|
|
3368
|
+
}
|
|
3369
|
+
if (!matched) {
|
|
3370
|
+
buf += '*';
|
|
3371
|
+
i += 1;
|
|
3372
|
+
}
|
|
3373
|
+
continue;
|
|
3374
|
+
}
|
|
3375
|
+
if (ch === '~' && raw[i + 1] === '~') {
|
|
3376
|
+
const close = findStrikeClose(raw, i + 2);
|
|
3377
|
+
if (close !== -1 && raw.slice(i + 2, close).trim() !== '') {
|
|
3378
|
+
flush();
|
|
3379
|
+
spans.push({ kind: 'strike', spans: parseInline(raw.slice(i + 2, close), depth + 1) });
|
|
3380
|
+
i = close + 2;
|
|
3381
|
+
continue;
|
|
3382
|
+
}
|
|
3383
|
+
// Unclosed ~~ (or a lone ~): literal tilde, one char at a time.
|
|
3384
|
+
buf += '~';
|
|
3385
|
+
i += 1;
|
|
3386
|
+
continue;
|
|
3387
|
+
}
|
|
3388
|
+
if (ch === '[') {
|
|
3389
|
+
const link = tryLink(raw, i, depth);
|
|
3390
|
+
if (link !== undefined) {
|
|
3391
|
+
flush();
|
|
3392
|
+
spans.push(link.span);
|
|
3393
|
+
i = link.end;
|
|
3394
|
+
continue;
|
|
3395
|
+
}
|
|
3396
|
+
buf += '[';
|
|
3397
|
+
i += 1;
|
|
3398
|
+
continue;
|
|
3399
|
+
}
|
|
3400
|
+
buf += ch;
|
|
3401
|
+
i += 1;
|
|
3402
|
+
}
|
|
3403
|
+
flush();
|
|
3404
|
+
return spans;
|
|
3405
|
+
}
|
|
3406
|
+
/** Length of the backtick run starting at `start`. */
|
|
3407
|
+
function tickRun(raw, start) {
|
|
3408
|
+
let n = 0;
|
|
3409
|
+
while (raw[start + n] === '`')
|
|
3410
|
+
n += 1;
|
|
3411
|
+
return n;
|
|
3412
|
+
}
|
|
3413
|
+
/** Length of the asterisk run starting at `start`. */
|
|
3414
|
+
function starRun(raw, start) {
|
|
3415
|
+
let n = 0;
|
|
3416
|
+
while (raw[start + n] === '*')
|
|
3417
|
+
n += 1;
|
|
3418
|
+
return n;
|
|
3419
|
+
}
|
|
3420
|
+
/**
|
|
3421
|
+
* Find the closing backtick run of EXACT length `len` at/after `from` (a code
|
|
3422
|
+
* span closes only on a matching run, per CommonMark). Escapes do not apply
|
|
3423
|
+
* inside code spans, so every tick is a tick.
|
|
3424
|
+
*/
|
|
3425
|
+
function findTickRun(raw, from, len) {
|
|
3426
|
+
let j = from;
|
|
3427
|
+
while (j + len <= raw.length) {
|
|
3428
|
+
if (raw[j] !== '`') {
|
|
3429
|
+
j += 1;
|
|
3430
|
+
continue;
|
|
3431
|
+
}
|
|
3432
|
+
let k = 0;
|
|
3433
|
+
while (k < len && raw[j + k] === '`')
|
|
3434
|
+
k += 1;
|
|
3435
|
+
if (k === len && raw[j + len] !== '`')
|
|
3436
|
+
return j;
|
|
3437
|
+
while (raw[j] === '`')
|
|
3438
|
+
j += 1; // inside a longer run: skip it whole
|
|
3439
|
+
}
|
|
3440
|
+
return -1;
|
|
3441
|
+
}
|
|
3442
|
+
/**
|
|
3443
|
+
* Find a `*` closer of length `len` at/after `from`: the run must match EXACTLY
|
|
3444
|
+
* (a longer run cannot close a shorter marker — `*a **b** c*` must find its
|
|
3445
|
+
* single-star closer past the inner bold pair, not the first half of it), be
|
|
3446
|
+
* preceded by a non-space character (right-flanking), and escaped stars are
|
|
3447
|
+
* skipped. Mirrors findTickRun's exact-length guard.
|
|
3448
|
+
*/
|
|
3449
|
+
function findStarClose(raw, from, len) {
|
|
3450
|
+
let j = from;
|
|
3451
|
+
while (j + len <= raw.length) {
|
|
3452
|
+
if (raw[j] === '\\') {
|
|
3453
|
+
j += 2;
|
|
3454
|
+
continue;
|
|
3455
|
+
}
|
|
3456
|
+
if (raw[j] === '*') {
|
|
3457
|
+
let k = 0;
|
|
3458
|
+
while (k < len && raw[j + k] === '*')
|
|
3459
|
+
k += 1;
|
|
3460
|
+
if (k === len && raw[j + len] !== '*' && raw[j - 1] !== undefined && !isWs(raw[j - 1]))
|
|
3461
|
+
return j;
|
|
3462
|
+
while (raw[j] === '*')
|
|
3463
|
+
j += 1;
|
|
3464
|
+
continue;
|
|
3465
|
+
}
|
|
3466
|
+
j += 1;
|
|
3467
|
+
}
|
|
3468
|
+
return -1;
|
|
3469
|
+
}
|
|
3470
|
+
/**
|
|
3471
|
+
* Find a `~~` closer at/after `from`, preceded by a non-space character. The
|
|
3472
|
+
* run must be exactly two tildes — a `~~~` fence-looking run is not a strike
|
|
3473
|
+
* closer (same exact-length rule as findStarClose).
|
|
3474
|
+
*/
|
|
3475
|
+
function findStrikeClose(raw, from) {
|
|
3476
|
+
let j = from;
|
|
3477
|
+
while (j + 2 <= raw.length) {
|
|
3478
|
+
if (raw[j] === '\\') {
|
|
3479
|
+
j += 2;
|
|
3480
|
+
continue;
|
|
3481
|
+
}
|
|
3482
|
+
if (raw[j] === '~') {
|
|
3483
|
+
if (raw[j + 1] === '~' && raw[j + 2] !== '~' && raw[j - 1] !== undefined && !isWs(raw[j - 1]))
|
|
3484
|
+
return j;
|
|
3485
|
+
// Not a closer (start of a longer run, or wrong flanking): skip the
|
|
3486
|
+
// WHOLE run — advancing one char would let positions 2–3 of a `~~~`
|
|
3487
|
+
// triple pose as an exact `~~` closer.
|
|
3488
|
+
while (raw[j] === '~')
|
|
3489
|
+
j += 1;
|
|
3490
|
+
continue;
|
|
3491
|
+
}
|
|
3492
|
+
j += 1;
|
|
3493
|
+
}
|
|
3494
|
+
return -1;
|
|
3495
|
+
}
|
|
3496
|
+
/**
|
|
3497
|
+
* Try to read `[text](href)` starting at the `[` at `start`. The destination
|
|
3498
|
+
* must be a bare URL (no whitespace, no parentheses) — that requirement is
|
|
3499
|
+
* what keeps prose like "[see (note)](aside)" from turning into links.
|
|
3500
|
+
* Empty link text displays the URL itself.
|
|
3501
|
+
*/
|
|
3502
|
+
function tryLink(raw, start, depth) {
|
|
3503
|
+
let j = start + 1;
|
|
3504
|
+
while (j < raw.length && raw[j] !== ']') {
|
|
3505
|
+
j += raw[j] === '\\' ? 2 : 1;
|
|
3506
|
+
}
|
|
3507
|
+
if (j >= raw.length || raw[j + 1] !== '(')
|
|
3508
|
+
return undefined;
|
|
3509
|
+
let k = j + 2;
|
|
3510
|
+
while (k < raw.length && raw[k] !== ')') {
|
|
3511
|
+
k += raw[k] === '\\' ? 2 : 1;
|
|
3512
|
+
}
|
|
3513
|
+
if (k >= raw.length)
|
|
3514
|
+
return undefined;
|
|
3515
|
+
const href = raw.slice(j + 2, k);
|
|
3516
|
+
if (/[\s()]/.test(href))
|
|
3517
|
+
return undefined;
|
|
3518
|
+
const text = raw.slice(start + 1, j);
|
|
3519
|
+
return {
|
|
3520
|
+
span: { kind: 'link', spans: parseInline(text === '' ? href : text, depth + 1), href },
|
|
3521
|
+
end: k + 1,
|
|
3522
|
+
};
|
|
3523
|
+
}
|
|
3524
|
+
/* ---- block parser ------------------------------------------------------------ */
|
|
3525
|
+
/** ```/~~~ fence opener; the info string after it becomes the language tag. */
|
|
3526
|
+
const FENCE_OPEN = /^ {0,3}(`{3,}|~{3,})[ \t]*(.*)$/;
|
|
3527
|
+
/** 4+ leading spaces or a tab — the indented-code threshold (as text-layout). */
|
|
3528
|
+
const INDENTED = /^(?: {4,}|\t)/;
|
|
3529
|
+
/** 3+ of one char (- * _) with optional spaces between — a thematic break. */
|
|
3530
|
+
const HR = /^ {0,3}([-*_])(?:[ \t]*\1){2,}[ \t]*$/;
|
|
3531
|
+
/** ATX heading: 1–6 hashes, then space(s) + text (or nothing). */
|
|
3532
|
+
const HEADING = /^ {0,3}(#{1,6})(?:[ \t]+(.*))?$/;
|
|
3533
|
+
/** Own-line image: `` with a bare-URL src (alt may be empty). */
|
|
3534
|
+
const IMAGE_LINE = /^ {0,3}!\[([^[\]]*)\]\(([^()[\]\s]*)\)[ \t]*$/;
|
|
3535
|
+
/** Blockquote prefix; the single space after `>` is consumed with it. */
|
|
3536
|
+
const QUOTE_PREFIX = /^ {0,3}>[ \t]?/;
|
|
3537
|
+
/**
|
|
3538
|
+
* List item: up to 8 leading spaces (the block loop routes ≥4-space lines at
|
|
3539
|
+
* document level to indented code, so inside a list collection the wider
|
|
3540
|
+
* indents simply land on the nested level), then a bullet or `N.`/`N)` marker
|
|
3541
|
+
* and space-separated content. The space is REQUIRED after the marker, which
|
|
3542
|
+
* is what keeps "3.14" a paragraph.
|
|
3543
|
+
*/
|
|
3544
|
+
const LIST_ITEM = /^( {0,8})([-*+]|\d{1,9}[.)])(?:[ \t]+(.*))?$/;
|
|
3545
|
+
/** One delimiter-row cell: ---, :--, --:, :-: (at least one hyphen). */
|
|
3546
|
+
const DELIM_CELL = /^:?-+:?$/;
|
|
3547
|
+
/** Split a pipe row into trimmed cells; edge pipes are dropped when empty. */
|
|
3548
|
+
function splitCells(line) {
|
|
3549
|
+
let cells = line.split('|');
|
|
3550
|
+
if (cells.length > 1) {
|
|
3551
|
+
if (cells[0].trim() === '')
|
|
3552
|
+
cells = cells.slice(1);
|
|
3553
|
+
if (cells.length > 1 && cells[cells.length - 1].trim() === '')
|
|
3554
|
+
cells = cells.slice(0, -1);
|
|
3555
|
+
}
|
|
3556
|
+
return cells.map((cell) => cell.trim());
|
|
3557
|
+
}
|
|
3558
|
+
/** Delimiter-row test: has a pipe, and every cell is ---/:--/--:/:-:. */
|
|
3559
|
+
function isDelimiterRow(line) {
|
|
3560
|
+
if (!line.includes('|'))
|
|
3561
|
+
return false;
|
|
3562
|
+
const cells = splitCells(line);
|
|
3563
|
+
return cells.length > 0 && cells.every((cell) => cell !== '' && DELIM_CELL.test(cell));
|
|
3564
|
+
}
|
|
3565
|
+
/** Column alignment from one delimiter cell. */
|
|
3566
|
+
function alignOf(cell) {
|
|
3567
|
+
const left = cell.startsWith(':');
|
|
3568
|
+
const right = cell.endsWith(':');
|
|
3569
|
+
if (left && right)
|
|
3570
|
+
return 'center';
|
|
3571
|
+
if (right)
|
|
3572
|
+
return 'right';
|
|
3573
|
+
if (left)
|
|
3574
|
+
return 'left';
|
|
3575
|
+
return null;
|
|
3576
|
+
}
|
|
3577
|
+
/** Parse a list-item line; undefined when the line is not an item. */
|
|
3578
|
+
function matchListItem(line) {
|
|
3579
|
+
const m = line.match(LIST_ITEM);
|
|
3580
|
+
if (m === null)
|
|
3581
|
+
return undefined;
|
|
3582
|
+
const marker = m[2];
|
|
3583
|
+
const ordered = /^\d/.test(marker);
|
|
3584
|
+
return {
|
|
3585
|
+
indent: m[1].length,
|
|
3586
|
+
ordered,
|
|
3587
|
+
number: ordered ? Number.parseInt(marker, 10) : undefined,
|
|
3588
|
+
text: m[3] ?? '',
|
|
3589
|
+
};
|
|
3590
|
+
}
|
|
3591
|
+
/** Count leading spaces (a tab counts as the 4 that make indented code). */
|
|
3592
|
+
function leadingSpaces(line) {
|
|
3593
|
+
if (line.startsWith('\t'))
|
|
3594
|
+
return 4;
|
|
3595
|
+
let n = 0;
|
|
3596
|
+
while (line[n] === ' ')
|
|
3597
|
+
n += 1;
|
|
3598
|
+
return n;
|
|
3599
|
+
}
|
|
3600
|
+
/** Parse a run of lines into blocks (also the recursion entry for quotes). */
|
|
3601
|
+
function parseBlocks(lines, depth = 0) {
|
|
3602
|
+
const blocks = [];
|
|
3603
|
+
let para = [];
|
|
3604
|
+
const flushPara = () => {
|
|
3605
|
+
if (para.length > 0) {
|
|
3606
|
+
blocks.push({ kind: 'para', spans: parseInline(para.join('\n'), depth) });
|
|
3607
|
+
para = [];
|
|
3608
|
+
}
|
|
3609
|
+
};
|
|
3610
|
+
let i = 0;
|
|
3611
|
+
while (i < lines.length) {
|
|
3612
|
+
const line = lines[i];
|
|
3613
|
+
if (line.trim() === '') {
|
|
3614
|
+
flushPara();
|
|
3615
|
+
i += 1;
|
|
3616
|
+
continue;
|
|
3617
|
+
}
|
|
3618
|
+
const fence = line.match(FENCE_OPEN);
|
|
3619
|
+
if (fence !== null) {
|
|
3620
|
+
flushPara();
|
|
3621
|
+
const ch = fence[1][0];
|
|
3622
|
+
const close = ch === '`' ? /^ {0,3}`{3,}[ \t]*$/ : /^ {0,3}~{3,}[ \t]*$/;
|
|
3623
|
+
const body = [];
|
|
3624
|
+
i += 1;
|
|
3625
|
+
while (i < lines.length && !close.test(lines[i])) {
|
|
3626
|
+
body.push(lines[i]);
|
|
3627
|
+
i += 1;
|
|
3628
|
+
}
|
|
3629
|
+
// Past the closing fence; an unterminated fence just ends the block.
|
|
3630
|
+
i += 1;
|
|
3631
|
+
const info = fence[2]?.trim() ?? '';
|
|
3632
|
+
blocks.push({ kind: 'code', lang: info === '' ? undefined : info, lines: body });
|
|
3633
|
+
continue;
|
|
3634
|
+
}
|
|
3635
|
+
// Indented code cannot interrupt a paragraph (CommonMark) — with one open
|
|
3636
|
+
// the line is an ordinary continuation below.
|
|
3637
|
+
if (para.length === 0 && INDENTED.test(line)) {
|
|
3638
|
+
const strip = (s) => s.replace(/^(?: {4}|\t)/, '');
|
|
3639
|
+
const body = [strip(line)];
|
|
3640
|
+
i += 1;
|
|
3641
|
+
while (i < lines.length) {
|
|
3642
|
+
const next = lines[i];
|
|
3643
|
+
if (INDENTED.test(next)) {
|
|
3644
|
+
body.push(strip(next));
|
|
3645
|
+
i += 1;
|
|
3646
|
+
continue;
|
|
3647
|
+
}
|
|
3648
|
+
// A blank line between indented lines stays inside the block (lazy).
|
|
3649
|
+
if (next.trim() === '' && INDENTED.test(lines[i + 1] ?? '')) {
|
|
3650
|
+
body.push('');
|
|
3651
|
+
i += 1;
|
|
3652
|
+
continue;
|
|
3653
|
+
}
|
|
3654
|
+
break;
|
|
3655
|
+
}
|
|
3656
|
+
blocks.push({ kind: 'code', lang: undefined, lines: body });
|
|
3657
|
+
continue;
|
|
3658
|
+
}
|
|
3659
|
+
if (HR.test(line)) {
|
|
3660
|
+
flushPara();
|
|
3661
|
+
blocks.push({ kind: 'hr' });
|
|
3662
|
+
i += 1;
|
|
3663
|
+
continue;
|
|
3664
|
+
}
|
|
3665
|
+
const heading = line.match(HEADING);
|
|
3666
|
+
if (heading !== null) {
|
|
3667
|
+
flushPara();
|
|
3668
|
+
blocks.push({ kind: 'heading', level: heading[1].length, spans: parseInline((heading[2] ?? '').trim(), depth) });
|
|
3669
|
+
i += 1;
|
|
3670
|
+
continue;
|
|
3671
|
+
}
|
|
3672
|
+
const image = line.match(IMAGE_LINE);
|
|
3673
|
+
if (image !== null) {
|
|
3674
|
+
flushPara();
|
|
3675
|
+
blocks.push({ kind: 'image', alt: image[1] ?? '', src: image[2] ?? '' });
|
|
3676
|
+
i += 1;
|
|
3677
|
+
continue;
|
|
3678
|
+
}
|
|
3679
|
+
if (line.includes('|') && isDelimiterRow(lines[i + 1] ?? '')) {
|
|
3680
|
+
flushPara();
|
|
3681
|
+
const headerCells = splitCells(line);
|
|
3682
|
+
const alignCells = splitCells(lines[i + 1]);
|
|
3683
|
+
const align = headerCells.map((_, c) => (c < alignCells.length ? alignOf(alignCells[c]) : null));
|
|
3684
|
+
i += 2;
|
|
3685
|
+
const rows = [];
|
|
3686
|
+
while (i < lines.length) {
|
|
3687
|
+
const row = lines[i];
|
|
3688
|
+
if (row.trim() === '' || !row.includes('|'))
|
|
3689
|
+
break;
|
|
3690
|
+
rows.push(splitCells(row));
|
|
3691
|
+
i += 1;
|
|
3692
|
+
}
|
|
3693
|
+
blocks.push({
|
|
3694
|
+
kind: 'table',
|
|
3695
|
+
align,
|
|
3696
|
+
header: headerCells.map((cell) => parseInline(cell, depth)),
|
|
3697
|
+
// Rows ride the header's column count: short rows pad, extras drop.
|
|
3698
|
+
rows: rows.map((cells) => headerCells.map((_, c) => parseInline(cells[c] ?? '', depth))),
|
|
3699
|
+
});
|
|
3700
|
+
continue;
|
|
3701
|
+
}
|
|
3702
|
+
if (/^ {0,3}>/.test(line)) {
|
|
3703
|
+
flushPara();
|
|
3704
|
+
const inner = [];
|
|
3705
|
+
while (i < lines.length && /^ {0,3}>/.test(lines[i])) {
|
|
3706
|
+
inner.push(lines[i].replace(QUOTE_PREFIX, ''));
|
|
3707
|
+
i += 1;
|
|
3708
|
+
}
|
|
3709
|
+
// Depth guard (MAX_DEPTH): past the ceiling the quoted lines degrade to
|
|
3710
|
+
// a plain paragraph instead of recursing — a synthetic "> > > …" chain
|
|
3711
|
+
// deep enough used to overflow the native stack.
|
|
3712
|
+
blocks.push(depth + 1 >= MAX_DEPTH
|
|
3713
|
+
? { kind: 'para', spans: [{ kind: 'text', text: inner.join('\n') }] }
|
|
3714
|
+
: { kind: 'quote', children: parseBlocks(inner, depth + 1) });
|
|
3715
|
+
continue;
|
|
3716
|
+
}
|
|
3717
|
+
{
|
|
3718
|
+
const item = matchListItem(line);
|
|
3719
|
+
// Only 0–3-space indents OPEN a list at block level; deeper indents are
|
|
3720
|
+
// indented code (para closed) or paragraph continuation (para open).
|
|
3721
|
+
// Inside collectList the wider indents land on the nested level.
|
|
3722
|
+
if (item !== undefined && item.indent <= 3) {
|
|
3723
|
+
flushPara();
|
|
3724
|
+
const collected = collectList(lines, i, depth);
|
|
3725
|
+
blocks.push(collected.block);
|
|
3726
|
+
i = collected.next;
|
|
3727
|
+
continue;
|
|
3728
|
+
}
|
|
3729
|
+
}
|
|
3730
|
+
para.push(line);
|
|
3731
|
+
i += 1;
|
|
3732
|
+
}
|
|
3733
|
+
flushPara();
|
|
3734
|
+
return blocks;
|
|
3735
|
+
}
|
|
1537
3736
|
/**
|
|
1538
|
-
*
|
|
1539
|
-
*
|
|
1540
|
-
*
|
|
1541
|
-
*
|
|
1542
|
-
*
|
|
1543
|
-
*
|
|
1544
|
-
*
|
|
1545
|
-
* suffix (verified live: 2026-08-17). Safe no-op when the plugin, or any
|
|
1546
|
-
* other workbench-style plugin sharing that convention, is not installed.
|
|
3737
|
+
* Collect one list block starting at `start` (a list-item line).
|
|
3738
|
+
*
|
|
3739
|
+
* Rules: marker lines with ≥2 leading spaces nest under the last top-level
|
|
3740
|
+
* item (capped at the parser's two levels); a non-marker line indented ≥2
|
|
3741
|
+
* spaces lazily continues the deepest open item; a blank line keeps the list
|
|
3742
|
+
* open only when the next non-blank line is a same-kind item (loose lists);
|
|
3743
|
+
* anything else ends the block.
|
|
1547
3744
|
*/
|
|
1548
|
-
function
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
},
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
3745
|
+
function collectList(lines, start, depth) {
|
|
3746
|
+
const ordered = matchListItem(lines[start]).ordered;
|
|
3747
|
+
const items = [];
|
|
3748
|
+
const deepest = () => {
|
|
3749
|
+
const top = items[items.length - 1];
|
|
3750
|
+
if (top === undefined)
|
|
3751
|
+
return undefined;
|
|
3752
|
+
return top.children[top.children.length - 1] ?? top;
|
|
3753
|
+
};
|
|
3754
|
+
let i = start;
|
|
3755
|
+
while (i < lines.length) {
|
|
3756
|
+
const line = lines[i];
|
|
3757
|
+
if (line.trim() === '') {
|
|
3758
|
+
let j = i + 1;
|
|
3759
|
+
while (j < lines.length && lines[j].trim() === '')
|
|
3760
|
+
j += 1;
|
|
3761
|
+
const nextItem = j < lines.length ? matchListItem(lines[j]) : undefined;
|
|
3762
|
+
if (nextItem !== undefined && nextItem.ordered === ordered) {
|
|
3763
|
+
i = j;
|
|
3764
|
+
continue;
|
|
3765
|
+
}
|
|
3766
|
+
break;
|
|
3767
|
+
}
|
|
3768
|
+
const m = matchListItem(line);
|
|
3769
|
+
if (m !== undefined) {
|
|
3770
|
+
if (m.ordered !== ordered)
|
|
3771
|
+
break;
|
|
3772
|
+
const raw = { text: [m.text], number: m.number, children: [] };
|
|
3773
|
+
if (m.indent >= 2 && items.length > 0)
|
|
3774
|
+
items[items.length - 1].children.push(raw);
|
|
3775
|
+
else
|
|
3776
|
+
items.push(raw);
|
|
3777
|
+
i += 1;
|
|
3778
|
+
continue;
|
|
3779
|
+
}
|
|
3780
|
+
if (leadingSpaces(line) >= 2) {
|
|
3781
|
+
const target = deepest();
|
|
3782
|
+
if (target !== undefined) {
|
|
3783
|
+
target.text.push(line.trim());
|
|
3784
|
+
i += 1;
|
|
3785
|
+
continue;
|
|
3786
|
+
}
|
|
3787
|
+
}
|
|
3788
|
+
break;
|
|
3789
|
+
}
|
|
3790
|
+
const finish = (raw) => ({
|
|
3791
|
+
spans: parseInline(raw.text.join('\n'), depth),
|
|
3792
|
+
number: raw.number,
|
|
3793
|
+
children: raw.children.map(finish),
|
|
3794
|
+
});
|
|
3795
|
+
return { block: { kind: 'list', ordered, items: items.map(finish) }, next: i };
|
|
3796
|
+
}
|
|
3797
|
+
/**
|
|
3798
|
+
* Parse a raw text block into the share card's Markdown tree.
|
|
3799
|
+
* @param text - raw text-block content from the share-export route.
|
|
3800
|
+
* @returns blocks in reading order; `[]` for blank input.
|
|
3801
|
+
*/
|
|
3802
|
+
function parseMarkdown(text) {
|
|
3803
|
+
return parseBlocks(text.replace(/\r\n?/g, '\n').split('\n'));
|
|
3804
|
+
}
|
|
3805
|
+
};
|
|
3806
|
+
__modules["share/share-card.js"] = function (require, module, exports) {
|
|
3807
|
+
"use strict";
|
|
3808
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3809
|
+
exports.DEFAULT_SHARE_THEME = void 0;
|
|
3810
|
+
exports.readShareTheme = readShareTheme;
|
|
3811
|
+
exports.ShareCard = ShareCard;
|
|
3812
|
+
const jsx_runtime_1 = require("react/jsx-runtime");
|
|
3813
|
+
const text_layout_ts_1 = require("./share/text-layout.js");
|
|
3814
|
+
const markdown_ts_1 = require("./share/markdown.js");
|
|
3815
|
+
const share_constants_ts_1 = require("./share/share-constants.js");
|
|
3816
|
+
/** Light-palette fallback (resolved values from the host theme package). */
|
|
3817
|
+
exports.DEFAULT_SHARE_THEME = {
|
|
3818
|
+
text: '#0f1115',
|
|
3819
|
+
textSecondary: '#61666b',
|
|
3820
|
+
textTertiary: '#81858c',
|
|
3821
|
+
cardBg: '#ffffff',
|
|
3822
|
+
codeBg: '#f9fafb',
|
|
3823
|
+
bubbleBg: '#0f1115',
|
|
3824
|
+
bubbleText: '#ffffff',
|
|
3825
|
+
accent: '#4176e6',
|
|
3826
|
+
border: 'rgba(0, 0, 0, 0.1)',
|
|
3827
|
+
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif',
|
|
3828
|
+
monoFamily: '"SF Mono", "JetBrains Mono", "Fira Code", Consolas, Menlo, Courier, monospace',
|
|
3829
|
+
};
|
|
3830
|
+
/**
|
|
3831
|
+
* Read the host theme once (per render call). A raw value that still contains
|
|
3832
|
+
* `var(` means the engine returned it un-substituted — treated as missing so
|
|
3833
|
+
* the default palette wins over a broken declaration.
|
|
3834
|
+
* @returns concrete theme values for this render.
|
|
3835
|
+
*/
|
|
3836
|
+
function readShareTheme() {
|
|
3837
|
+
const fallback = exports.DEFAULT_SHARE_THEME;
|
|
3838
|
+
if (typeof document === 'undefined')
|
|
3839
|
+
return fallback;
|
|
3840
|
+
// body first (see file header), then documentElement as a generic fallback.
|
|
3841
|
+
const sources = [getComputedStyle(document.body), getComputedStyle(document.documentElement)];
|
|
3842
|
+
const read = (name) => {
|
|
3843
|
+
for (const computed of sources) {
|
|
3844
|
+
const raw = computed.getPropertyValue(name).trim();
|
|
3845
|
+
if (raw !== '' && !raw.includes('var('))
|
|
3846
|
+
return raw;
|
|
3847
|
+
}
|
|
3848
|
+
return undefined;
|
|
3849
|
+
};
|
|
3850
|
+
return {
|
|
3851
|
+
text: read('--dsw-alias-label-primary') ?? fallback.text,
|
|
3852
|
+
textSecondary: read('--dsw-alias-label-secondary') ?? fallback.textSecondary,
|
|
3853
|
+
textTertiary: read('--dsw-alias-label-tertiary') ?? fallback.textTertiary,
|
|
3854
|
+
cardBg: read('--dsw-alias-bg-base') ?? fallback.cardBg,
|
|
3855
|
+
codeBg: read('--dsw-alias-markdown-code-block') ?? fallback.codeBg,
|
|
3856
|
+
bubbleBg: read('--dsw-alias-brand-primary') ?? fallback.bubbleBg,
|
|
3857
|
+
bubbleText: read('--dsw-alias-label-primary-foreground') ?? fallback.bubbleText,
|
|
3858
|
+
accent: read('--dsw-alias-state-business-primary') ?? fallback.accent,
|
|
3859
|
+
border: read('--dsw-alias-border-l2') ?? fallback.border,
|
|
3860
|
+
fontFamily: read('--dsw-font-family') ?? fallback.fontFamily,
|
|
3861
|
+
monoFamily: read('--ds-font-family-code') ?? fallback.monoFamily,
|
|
3862
|
+
};
|
|
3863
|
+
}
|
|
3864
|
+
/**
|
|
3865
|
+
* The bubble-width cap as the CSS percentage string the template renders —
|
|
3866
|
+
* `Math.round` guards the float multiply (0.82 × 100), and the value comes
|
|
3867
|
+
* from share-constants.ts so the rasterizer's width formulas invert exactly
|
|
3868
|
+
* this number (see USER_BUBBLE_MAX_RATIO).
|
|
3869
|
+
*/
|
|
3870
|
+
const USER_BUBBLE_MAX_WIDTH = `${Math.round(share_constants_ts_1.USER_BUBBLE_MAX_RATIO * 100)}%`;
|
|
3871
|
+
/** Locale-following short date+time; the share image is dated content. */
|
|
3872
|
+
function formatStamp(ms) {
|
|
3873
|
+
const date = new Date(ms);
|
|
3874
|
+
try {
|
|
3875
|
+
return new Intl.DateTimeFormat(undefined, {
|
|
3876
|
+
year: 'numeric',
|
|
3877
|
+
month: 'short',
|
|
3878
|
+
day: 'numeric',
|
|
3879
|
+
hour: '2-digit',
|
|
3880
|
+
minute: '2-digit',
|
|
3881
|
+
}).format(date);
|
|
3882
|
+
}
|
|
3883
|
+
catch {
|
|
3884
|
+
return date.toISOString().slice(0, 16).replace('T', ' ');
|
|
3885
|
+
}
|
|
3886
|
+
}
|
|
3887
|
+
/**
|
|
3888
|
+
* The share card. A pure template: no hooks, no effects, no context — the
|
|
3889
|
+
* same JSX must render identically inside the page (preview) and inside a
|
|
3890
|
+
* cloned `<foreignObject>` (rasterize).
|
|
3891
|
+
*/
|
|
3892
|
+
function ShareCard({ title, subtitle, createdAt, turns, width, copy }) {
|
|
3893
|
+
const theme = readShareTheme();
|
|
3894
|
+
const logicalWidth = width ?? share_constants_ts_1.BASE_CARD_WIDTH;
|
|
3895
|
+
// "N 轮" counts user anchors (one exchange = user prompt + its answers),
|
|
3896
|
+
// matching how the host route counts turns for range=last.
|
|
3897
|
+
const turnCount = turns.filter((turn) => turn.role === 'user').length;
|
|
3898
|
+
const stamp = createdAt !== undefined && Number.isFinite(createdAt) ? formatStamp(createdAt) : undefined;
|
|
3899
|
+
const metaParts = [
|
|
3900
|
+
...(subtitle !== undefined && subtitle !== '' ? [subtitle] : []),
|
|
3901
|
+
...(stamp !== undefined ? [stamp] : []),
|
|
3902
|
+
copy.turnsLabel(turnCount),
|
|
3903
|
+
];
|
|
3904
|
+
return ((0, jsx_runtime_1.jsxs)("div", { "data-share-card": "", style: {
|
|
3905
|
+
width: `${logicalWidth}px`,
|
|
3906
|
+
boxSizing: 'border-box',
|
|
3907
|
+
display: 'flex',
|
|
3908
|
+
flexDirection: 'column',
|
|
3909
|
+
gap: '14px',
|
|
3910
|
+
padding: '20px 18px 16px',
|
|
3911
|
+
background: theme.cardBg,
|
|
3912
|
+
color: theme.text,
|
|
3913
|
+
fontFamily: theme.fontFamily,
|
|
3914
|
+
fontSize: '15px',
|
|
3915
|
+
lineHeight: 1.65,
|
|
3916
|
+
// Pin the INHERITABLE typography so the page render (the preview and
|
|
3917
|
+
// the rasterizer's measuring clones live in a shadow root, which still
|
|
3918
|
+
// inherits these from the host cascade) and the SVG-image render (a
|
|
3919
|
+
// fresh context at engine defaults) lay out identically — any unpinned
|
|
3920
|
+
// property is a channel for host-style drift to change text wrapping
|
|
3921
|
+
// between measurement and raster, invalidating the rasterizer's
|
|
3922
|
+
// numbers. Every value below is the CSS initial value.
|
|
3923
|
+
letterSpacing: 'normal',
|
|
3924
|
+
wordBreak: 'normal',
|
|
3925
|
+
overflowWrap: 'normal',
|
|
3926
|
+
hyphens: 'manual',
|
|
3927
|
+
textAlign: 'start',
|
|
3928
|
+
textTransform: 'none',
|
|
3929
|
+
tabSize: 8,
|
|
3930
|
+
fontVariantLigatures: 'normal',
|
|
3931
|
+
// Never hide horizontal content: wide code/tables must stay visible
|
|
3932
|
+
// (and measurable by ticket 04) — see the file header.
|
|
3933
|
+
overflow: 'visible',
|
|
3934
|
+
}, children: [(0, jsx_runtime_1.jsxs)("div", { "data-share-header": "", style: { display: 'flex', flexDirection: 'column', gap: '6px' }, children: [(0, jsx_runtime_1.jsx)("div", { style: { fontSize: '17px', fontWeight: 600, lineHeight: 1.4 }, children: title }), (0, jsx_runtime_1.jsx)("div", { style: { fontSize: '12px', lineHeight: 1.5, color: theme.textSecondary }, children: metaParts.join(' · ') }), (0, jsx_runtime_1.jsx)("div", { style: { height: '1px', marginTop: '6px', background: theme.border } })] }), turns.map((turn) => turn.role === 'user'
|
|
3935
|
+
? ((0, jsx_runtime_1.jsx)("div", { "data-share-turn": turn.seq, "data-share-role": turn.role, style: { display: 'flex', justifyContent: 'flex-end' }, children: (0, jsx_runtime_1.jsx)("div", { style: {
|
|
3936
|
+
maxWidth: USER_BUBBLE_MAX_WIDTH,
|
|
3937
|
+
display: 'flex',
|
|
3938
|
+
flexDirection: 'column',
|
|
3939
|
+
gap: '8px',
|
|
3940
|
+
padding: `9px ${share_constants_ts_1.USER_BUBBLE_PAD_X}px`,
|
|
3941
|
+
background: theme.bubbleBg,
|
|
3942
|
+
color: theme.bubbleText,
|
|
3943
|
+
borderRadius: '16px',
|
|
3944
|
+
// Sharp corner on the trailing edge = the chat-bubble tail.
|
|
3945
|
+
borderBottomRightRadius: '4px',
|
|
3946
|
+
overflow: 'visible',
|
|
3947
|
+
}, children: (0, jsx_runtime_1.jsx)(ShareBlocks, { blocks: turn.blocks, role: turn.role, theme: theme, copy: copy }) }) }, turn.seq))
|
|
3948
|
+
: ((0, jsx_runtime_1.jsx)("div", { "data-share-turn": turn.seq, "data-share-role": turn.role, style: { display: 'flex', flexDirection: 'column', gap: '8px', overflow: 'visible' }, children: (0, jsx_runtime_1.jsx)(ShareBlocks, { blocks: turn.blocks, role: turn.role, theme: theme, copy: copy }) }, turn.seq))), (0, jsx_runtime_1.jsxs)("div", { "data-share-footer": "", style: { display: 'flex', flexDirection: 'column', gap: '8px' }, children: [(0, jsx_runtime_1.jsx)("div", { style: { height: '1px', background: theme.border } }), (0, jsx_runtime_1.jsxs)("div", { style: { display: 'flex', alignItems: 'center', gap: '6px', fontSize: '11px', lineHeight: 1.4, color: theme.textTertiary }, children: [(0, jsx_runtime_1.jsx)("span", { style: { width: '3px', height: '12px', borderRadius: '2px', background: theme.accent } }), (0, jsx_runtime_1.jsxs)("span", { children: [copy.generatedBy, stamp !== undefined ? ` · ${stamp}` : ''] })] })] })] }));
|
|
3949
|
+
}
|
|
3950
|
+
/** Render one row's blocks in order (text flows, images become badges). */
|
|
3951
|
+
function ShareBlocks({ blocks, role, theme, copy }) {
|
|
3952
|
+
return ((0, jsx_runtime_1.jsx)(jsx_runtime_1.Fragment, { children: blocks.map((block, index) => block.kind === 'text'
|
|
3953
|
+
? (role === 'assistant'
|
|
3954
|
+
// Assistant prose is Markdown (ticket 09); user messages stay
|
|
3955
|
+
// plain text — the Chat view renders them the same way.
|
|
3956
|
+
? (0, jsx_runtime_1.jsx)(ShareMarkdown, { text: block.text, theme: theme, copy: copy }, index)
|
|
3957
|
+
: (0, jsx_runtime_1.jsx)(ShareText, { text: block.text, theme: theme }, index))
|
|
3958
|
+
: (0, jsx_runtime_1.jsx)(ImageBadge, { theme: theme, label: copy.image }, index)) }));
|
|
3959
|
+
}
|
|
3960
|
+
/** User-message typesetting: the plain-text fold of ticket 03, unchanged. */
|
|
3961
|
+
function ShareText({ text, theme }) {
|
|
3962
|
+
const segments = (0, text_layout_ts_1.segmentShareText)(text);
|
|
3963
|
+
if (segments.length === 0)
|
|
3964
|
+
return null;
|
|
3965
|
+
return ((0, jsx_runtime_1.jsx)("div", { style: { display: 'flex', flexDirection: 'column', gap: '8px', overflow: 'visible' }, children: segments.map((segment, index) => ((0, jsx_runtime_1.jsx)(ShareSegment, { segment: segment, theme: theme }, index))) }));
|
|
3966
|
+
}
|
|
3967
|
+
function ShareSegment({ segment, theme }) {
|
|
3968
|
+
switch (segment.kind) {
|
|
3969
|
+
case 'code':
|
|
3970
|
+
case 'table':
|
|
3971
|
+
// Monospace + `pre` (NOT pre-wrap): the line's natural width wins, the
|
|
3972
|
+
// block may visually overflow the card edge, and ticket 04 measures
|
|
3973
|
+
// that width to widen the slice — wrapping here would destroy the only
|
|
3974
|
+
// signal the width probe has.
|
|
3975
|
+
return ((0, jsx_runtime_1.jsx)("div", { style: {
|
|
3976
|
+
fontFamily: theme.monoFamily,
|
|
3977
|
+
fontSize: '12.5px',
|
|
3978
|
+
lineHeight: 1.6,
|
|
3979
|
+
whiteSpace: 'pre',
|
|
3980
|
+
background: theme.codeBg,
|
|
3981
|
+
borderRadius: '8px',
|
|
3982
|
+
padding: '10px 12px',
|
|
3983
|
+
overflow: 'visible',
|
|
3984
|
+
}, children: segment.lines.join('\n') }));
|
|
3985
|
+
case 'list':
|
|
3986
|
+
return ((0, jsx_runtime_1.jsx)("div", { style: { display: 'flex', flexDirection: 'column', gap: '4px' }, children: segment.items.map((item, index) => ((0, jsx_runtime_1.jsxs)("div", { style: { display: 'flex', gap: '8px' }, children: [(0, jsx_runtime_1.jsx)("span", { style: { flexShrink: 0, color: theme.textTertiary }, children: item.marker }), (0, jsx_runtime_1.jsx)("span", { style: { flex: 1, minWidth: 0, whiteSpace: 'pre-wrap', overflow: 'visible' }, children: item.text })] }, index))) }));
|
|
3987
|
+
case 'para':
|
|
3988
|
+
default:
|
|
3989
|
+
return ((0, jsx_runtime_1.jsx)("div", { style: { whiteSpace: 'pre-wrap', overflow: 'visible' }, children: segment.text }));
|
|
3990
|
+
}
|
|
3991
|
+
}
|
|
3992
|
+
/* ---- markdown rendering (ticket 09, PLAN §5.1 v1.2) -------------------------
|
|
3993
|
+
* Assistant text renders through the pure parser in markdown.ts. Same red
|
|
3994
|
+
* lines as the rest of the card: inline styles only (an SVG-as-image render
|
|
3995
|
+
* loads no stylesheets), and NEVER hide horizontal content — code lines and
|
|
3996
|
+
* table cells keep their natural width so the rasterizer's width probe can
|
|
3997
|
+
* lift the slice (inline emphasis wraps instead, which changes nothing). */
|
|
3998
|
+
/** Assistant text block → parsed blocks → self-styled Markdown rendering. */
|
|
3999
|
+
function ShareMarkdown({ text, theme, copy }) {
|
|
4000
|
+
const blocks = (0, markdown_ts_1.parseMarkdown)(text);
|
|
4001
|
+
if (blocks.length === 0)
|
|
4002
|
+
return null;
|
|
4003
|
+
return ((0, jsx_runtime_1.jsx)("div", { style: { display: 'flex', flexDirection: 'column', gap: '8px', overflow: 'visible' }, children: blocks.map((block, index) => ((0, jsx_runtime_1.jsx)(MdBlockView, { block: block, theme: theme, copy: copy }, index))) }));
|
|
4004
|
+
}
|
|
4005
|
+
/** Heading ladder over the card's 15px body; h6 demotes to the secondary tone. */
|
|
4006
|
+
function mdHeadingStyle(level, theme) {
|
|
4007
|
+
switch (level) {
|
|
4008
|
+
case 1: return { fontSize: '20px', fontWeight: 700, lineHeight: 1.4 };
|
|
4009
|
+
case 2: return { fontSize: '18px', fontWeight: 700, lineHeight: 1.4 };
|
|
4010
|
+
case 3: return { fontSize: '16.5px', fontWeight: 600, lineHeight: 1.45 };
|
|
4011
|
+
case 4: return { fontSize: '15.5px', fontWeight: 600, lineHeight: 1.5 };
|
|
4012
|
+
case 5: return { fontSize: '15px', fontWeight: 600, lineHeight: 1.5 };
|
|
4013
|
+
default: return { fontSize: '15px', fontWeight: 600, lineHeight: 1.5, color: theme.textSecondary };
|
|
4014
|
+
}
|
|
4015
|
+
}
|
|
4016
|
+
/** One markdown block. Every branch keeps `overflow: 'visible'` (file header). */
|
|
4017
|
+
function MdBlockView({ block, theme, copy }) {
|
|
4018
|
+
switch (block.kind) {
|
|
4019
|
+
case 'heading':
|
|
4020
|
+
return ((0, jsx_runtime_1.jsx)("div", { style: { ...mdHeadingStyle(block.level, theme), overflow: 'visible' }, children: (0, jsx_runtime_1.jsx)(MdSpans, { spans: block.spans, theme: theme }) }));
|
|
4021
|
+
case 'para':
|
|
4022
|
+
return ((0, jsx_runtime_1.jsx)("div", { style: { whiteSpace: 'pre-wrap', overflow: 'visible' }, children: (0, jsx_runtime_1.jsx)(MdSpans, { spans: block.spans, theme: theme }) }));
|
|
4023
|
+
case 'code':
|
|
4024
|
+
return ((0, jsx_runtime_1.jsxs)("div", { style: {
|
|
4025
|
+
background: theme.codeBg,
|
|
4026
|
+
borderRadius: '8px',
|
|
4027
|
+
padding: '10px 12px',
|
|
4028
|
+
display: 'flex',
|
|
4029
|
+
flexDirection: 'column',
|
|
4030
|
+
gap: '6px',
|
|
4031
|
+
overflow: 'visible',
|
|
4032
|
+
}, children: [block.lang !== undefined ? ((0, jsx_runtime_1.jsx)("div", { style: { fontSize: '11px', lineHeight: 1.4, color: theme.textTertiary }, children: block.lang })) : null, (0, jsx_runtime_1.jsx)("div", { style: {
|
|
4033
|
+
fontFamily: theme.monoFamily,
|
|
4034
|
+
fontSize: '12.5px',
|
|
4035
|
+
lineHeight: 1.6,
|
|
4036
|
+
whiteSpace: 'pre',
|
|
4037
|
+
overflow: 'visible',
|
|
4038
|
+
}, children: block.lines.join('\n') })] }));
|
|
4039
|
+
case 'quote':
|
|
4040
|
+
return ((0, jsx_runtime_1.jsx)("div", { style: {
|
|
4041
|
+
borderLeft: `3px solid ${theme.border}`,
|
|
4042
|
+
paddingLeft: '12px',
|
|
4043
|
+
paddingRight: '2px',
|
|
4044
|
+
color: theme.textSecondary,
|
|
4045
|
+
display: 'flex',
|
|
4046
|
+
flexDirection: 'column',
|
|
4047
|
+
gap: '6px',
|
|
4048
|
+
overflow: 'visible',
|
|
4049
|
+
}, children: block.children.map((child, index) => ((0, jsx_runtime_1.jsx)(MdBlockView, { block: child, theme: theme, copy: copy }, index))) }));
|
|
4050
|
+
case 'list':
|
|
4051
|
+
return (0, jsx_runtime_1.jsx)(MdListView, { items: block.items, depth: 0, theme: theme });
|
|
4052
|
+
case 'table':
|
|
4053
|
+
return (0, jsx_runtime_1.jsx)(MdTableView, { block: block, theme: theme });
|
|
4054
|
+
case 'hr':
|
|
4055
|
+
return (0, jsx_runtime_1.jsx)("div", { style: { height: '1px', background: theme.border, overflow: 'visible' } });
|
|
4056
|
+
case 'image':
|
|
4057
|
+
// Markdown image: same placeholder row an image block renders (the
|
|
4058
|
+
// picture lives on the far end of a URL the share image cannot fetch).
|
|
4059
|
+
return (0, jsx_runtime_1.jsx)(ImageBadge, { theme: theme, label: copy.image });
|
|
4060
|
+
}
|
|
4061
|
+
}
|
|
4062
|
+
/** List rows; one nesting indent under the parent item's content column. */
|
|
4063
|
+
function MdListView({ items, depth, theme }) {
|
|
4064
|
+
return ((0, jsx_runtime_1.jsx)("div", { style: { display: 'flex', flexDirection: 'column', gap: '4px', ...(depth > 0 ? { marginLeft: '18px' } : null) }, children: items.map((item, index) => {
|
|
4065
|
+
const ordered = item.number !== undefined;
|
|
4066
|
+
return ((0, jsx_runtime_1.jsxs)("div", { style: { display: 'flex', gap: '8px' }, children: [(0, jsx_runtime_1.jsx)("span", { style: {
|
|
4067
|
+
flexShrink: 0,
|
|
4068
|
+
color: theme.textTertiary,
|
|
4069
|
+
// A fixed marker column keeps 1.–9. dots right-aligned.
|
|
4070
|
+
minWidth: ordered ? '20px' : undefined,
|
|
4071
|
+
textAlign: ordered ? 'right' : undefined,
|
|
4072
|
+
}, children: ordered ? `${item.number}.` : '•' }), (0, jsx_runtime_1.jsxs)("div", { style: { flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column', gap: '4px' }, children: [(0, jsx_runtime_1.jsx)("div", { style: { whiteSpace: 'pre-wrap', overflow: 'visible' }, children: (0, jsx_runtime_1.jsx)(MdSpans, { spans: item.spans, theme: theme }) }), item.children.length > 0 ? (0, jsx_runtime_1.jsx)(MdListView, { items: item.children, depth: depth + 1, theme: theme }) : null] })] }, index));
|
|
4073
|
+
}) }));
|
|
4074
|
+
}
|
|
4075
|
+
/** Column alignment → CSS text-align ('start' keeps the card's direction). */
|
|
4076
|
+
function mdAlignText(align) {
|
|
4077
|
+
if (align === 'center')
|
|
4078
|
+
return 'center';
|
|
4079
|
+
if (align === 'right')
|
|
4080
|
+
return 'right';
|
|
4081
|
+
return 'start';
|
|
4082
|
+
}
|
|
4083
|
+
/** GFM table as a natural-width grid: nowrap cells, hairline borders. */
|
|
4084
|
+
function MdTableView({ block, theme }) {
|
|
4085
|
+
const cellBase = { padding: '6px 10px', border: `1px solid ${theme.border}` };
|
|
4086
|
+
return ((0, jsx_runtime_1.jsxs)("table", { style: { borderCollapse: 'collapse', fontSize: '13px', lineHeight: 1.5, overflow: 'visible' }, children: [(0, jsx_runtime_1.jsx)("thead", { children: (0, jsx_runtime_1.jsx)("tr", { children: block.header.map((cell, index) => ((0, jsx_runtime_1.jsx)("th", { style: {
|
|
4087
|
+
...cellBase,
|
|
4088
|
+
background: theme.codeBg,
|
|
4089
|
+
fontWeight: 600,
|
|
4090
|
+
textAlign: mdAlignText(block.align[index] ?? null),
|
|
4091
|
+
whiteSpace: 'nowrap',
|
|
4092
|
+
overflow: 'visible',
|
|
4093
|
+
}, children: (0, jsx_runtime_1.jsx)(MdSpans, { spans: cell, theme: theme }) }, index))) }) }), (0, jsx_runtime_1.jsx)("tbody", { children: block.rows.map((row, rowIndex) => ((0, jsx_runtime_1.jsx)("tr", { children: row.map((cell, colIndex) => ((0, jsx_runtime_1.jsx)("td", { style: {
|
|
4094
|
+
...cellBase,
|
|
4095
|
+
textAlign: mdAlignText(block.align[colIndex] ?? null),
|
|
4096
|
+
// Natural width, same rationale as code lines above.
|
|
4097
|
+
whiteSpace: 'nowrap',
|
|
4098
|
+
overflow: 'visible',
|
|
4099
|
+
}, children: (0, jsx_runtime_1.jsx)(MdSpans, { spans: cell, theme: theme }) }, colIndex))) }, rowIndex))) })] }));
|
|
4100
|
+
}
|
|
4101
|
+
/** Render an inline span sequence (raw strings for plain text). */
|
|
4102
|
+
function MdSpans({ spans, theme }) {
|
|
4103
|
+
if (spans.length === 0)
|
|
4104
|
+
return null;
|
|
4105
|
+
return ((0, jsx_runtime_1.jsx)(jsx_runtime_1.Fragment, { children: spans.map((span, index) => ((0, jsx_runtime_1.jsx)(MdSpanView, { span: span, theme: theme }, index))) }));
|
|
4106
|
+
}
|
|
4107
|
+
/**
|
|
4108
|
+
* One inline span. Plain <span>s with explicit styles, never semantic tags:
|
|
4109
|
+
* the SVG-image render applies its own UA cascade, and only explicit values
|
|
4110
|
+
* lay out identically to the measured page render.
|
|
4111
|
+
*/
|
|
4112
|
+
function MdSpanView({ span, theme }) {
|
|
4113
|
+
switch (span.kind) {
|
|
4114
|
+
case 'text':
|
|
4115
|
+
return span.text;
|
|
4116
|
+
case 'bold':
|
|
4117
|
+
return (0, jsx_runtime_1.jsx)("span", { style: { fontWeight: 700 }, children: (0, jsx_runtime_1.jsx)(MdSpans, { spans: span.spans, theme: theme }) });
|
|
4118
|
+
case 'italic':
|
|
4119
|
+
return (0, jsx_runtime_1.jsx)("span", { style: { fontStyle: 'italic' }, children: (0, jsx_runtime_1.jsx)(MdSpans, { spans: span.spans, theme: theme }) });
|
|
4120
|
+
case 'strike':
|
|
4121
|
+
return (0, jsx_runtime_1.jsx)("span", { style: { textDecoration: 'line-through' }, children: (0, jsx_runtime_1.jsx)(MdSpans, { spans: span.spans, theme: theme }) });
|
|
4122
|
+
case 'code':
|
|
4123
|
+
return ((0, jsx_runtime_1.jsx)("span", { style: {
|
|
4124
|
+
fontFamily: theme.monoFamily,
|
|
4125
|
+
fontSize: '0.92em',
|
|
4126
|
+
background: theme.codeBg,
|
|
4127
|
+
borderRadius: '4px',
|
|
4128
|
+
padding: '1px 5px',
|
|
4129
|
+
}, children: span.text }));
|
|
4130
|
+
case 'link':
|
|
4131
|
+
// Colored text only: the share image is not clickable, and the URL is
|
|
4132
|
+
// deliberately NOT repeated next to the label.
|
|
4133
|
+
return (0, jsx_runtime_1.jsx)("span", { style: { color: theme.accent }, children: (0, jsx_runtime_1.jsx)(MdSpans, { spans: span.spans, theme: theme }) });
|
|
4134
|
+
}
|
|
4135
|
+
}
|
|
4136
|
+
/** Image attachments stay placeholders in v1 (files live on the host disk). */
|
|
4137
|
+
function ImageBadge({ theme, label }) {
|
|
4138
|
+
return ((0, jsx_runtime_1.jsxs)("div", { style: {
|
|
4139
|
+
display: 'flex',
|
|
4140
|
+
alignItems: 'center',
|
|
4141
|
+
gap: '6px',
|
|
4142
|
+
padding: '8px 12px',
|
|
4143
|
+
border: `1px dashed ${theme.border}`,
|
|
4144
|
+
borderRadius: '8px',
|
|
4145
|
+
fontSize: '12px',
|
|
4146
|
+
color: theme.textTertiary,
|
|
4147
|
+
overflow: 'visible',
|
|
4148
|
+
}, children: [(0, jsx_runtime_1.jsxs)("svg", { viewBox: "0 0 16 16", width: "14", height: "14", "aria-hidden": "true", style: { flexShrink: 0 }, children: [(0, jsx_runtime_1.jsx)("rect", { x: "1.5", y: "2.5", width: "13", height: "11", rx: "2", fill: "none", stroke: "currentColor", strokeWidth: "1.2" }), (0, jsx_runtime_1.jsx)("circle", { cx: "5.5", cy: "6.5", r: "1.3", fill: "currentColor" }), (0, jsx_runtime_1.jsx)("path", { d: "M2.5 12 L6.5 8 L9 10.5 L11.5 8 L13.5 10", fill: "none", stroke: "currentColor", strokeWidth: "1.2", strokeLinejoin: "round", strokeLinecap: "round" })] }), (0, jsx_runtime_1.jsx)("span", { children: label })] }));
|
|
4149
|
+
}
|
|
4150
|
+
};
|
|
4151
|
+
__modules["share/share-sheet.js"] = function (require, module, exports) {
|
|
4152
|
+
"use strict";
|
|
4153
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
4154
|
+
exports.ShareRangeSheet = ShareRangeSheet;
|
|
4155
|
+
const jsx_runtime_1 = require("react/jsx-runtime");
|
|
4156
|
+
/**
|
|
4157
|
+
* Share-range bottom sheet (ticket 05, PLAN §6): the small picker that opens
|
|
4158
|
+
* from the session-info sheet's 分享图 action — whole conversation or one of
|
|
4159
|
+
* the last-N-turns presets (3/5/10, no free input).
|
|
4160
|
+
*
|
|
4161
|
+
* Mounts INSIDE the info layer's DOM as a DIRECT child of
|
|
4162
|
+
* `[data-mobile-nav="info-layer"]` — a sibling of the info card, never a
|
|
4163
|
+
* child of the card itself (a card-internal mount clipped this sheet's mask
|
|
4164
|
+
* to the card's box, anchored its bottom to the card's scrollable area, and
|
|
4165
|
+
* let taps outside the card reach the info mask underneath — closing both
|
|
4166
|
+
* layers at once and stranding the share layer's history entry; review
|
|
4167
|
+
* 2026-09-10). That placement buys three things for free: the ≥768px
|
|
4168
|
+
* `display:none` of info.css.ts keeps the desktop pixel-identical no-op
|
|
4169
|
+
* (this feature adds no page CSS, PLAN §9.4), the sheet paints in the
|
|
4170
|
+
* header's z:70 promotion while the info sheet is open, and the info layer
|
|
4171
|
+
* (itself a fixed stacking context, inset:0) is the containing block — so
|
|
4172
|
+
* this sheet's own absolute inset:0 overlay is a fullscreen mask and its
|
|
4173
|
+
* bottom anchor is the viewport safe area, exactly like the info card's.
|
|
4174
|
+
* The back stack is the caller's business: the caller pushes one SHARE_LAYER
|
|
4175
|
+
* entry on top of the info layer's, so a back gesture closes exactly this
|
|
4176
|
+
* sheet — one layer per press, same discipline as history-nav.ts demands.
|
|
4177
|
+
*
|
|
4178
|
+
* All-inline styles like everything else in the share feature; theme values
|
|
4179
|
+
* read the host CSS variables with the same fallbacks info.css.ts declares,
|
|
4180
|
+
* and the entry animations reuse base.css.ts's existing keyframes (referenced
|
|
4181
|
+
* from inline `animation`, which adds no stylesheet rules). Reduced-motion is
|
|
4182
|
+
* honored by checking the media query at mount — inline styles cannot carry
|
|
4183
|
+
* a media query themselves.
|
|
4184
|
+
*/
|
|
4185
|
+
const react_1 = require("react");
|
|
4186
|
+
const dsh_client_ui_primitives_1 = require("@deepseek-ai/dsh-client-ui-primitives");
|
|
4187
|
+
const fetch_share_ts_1 = require("./share/fetch-share.js");
|
|
4188
|
+
/** One-time page-load probe: skip the entry animations when the user asks for less motion. */
|
|
4189
|
+
function prefersReducedMotion() {
|
|
4190
|
+
return typeof window !== 'undefined' && typeof window.matchMedia === 'function'
|
|
4191
|
+
&& window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
4192
|
+
}
|
|
4193
|
+
const MASK_STYLE = {
|
|
4194
|
+
position: 'absolute',
|
|
4195
|
+
inset: 0,
|
|
4196
|
+
border: 'none',
|
|
4197
|
+
background: 'var(--dsw-alias-bg-mask-3, rgba(0, 0, 0, .45))',
|
|
4198
|
+
};
|
|
4199
|
+
const SHEET_STYLE = {
|
|
4200
|
+
position: 'absolute',
|
|
4201
|
+
left: '8px',
|
|
4202
|
+
right: '8px',
|
|
4203
|
+
bottom: 'calc(var(--mnav-sab, 0px) + 8px)',
|
|
4204
|
+
boxSizing: 'border-box',
|
|
4205
|
+
padding: '12px',
|
|
4206
|
+
borderRadius: '16px',
|
|
4207
|
+
background: 'var(--dsw-alias-bg-layer-2, #ffffff)',
|
|
4208
|
+
boxShadow: '0 8px 32px rgba(0, 0, 0, .28)',
|
|
4209
|
+
};
|
|
4210
|
+
const HEAD_STYLE = {
|
|
4211
|
+
display: 'flex',
|
|
4212
|
+
alignItems: 'center',
|
|
4213
|
+
justifyContent: 'space-between',
|
|
4214
|
+
gap: 8,
|
|
4215
|
+
marginBottom: 10,
|
|
4216
|
+
};
|
|
4217
|
+
const TITLE_STYLE = {
|
|
4218
|
+
fontSize: 15,
|
|
4219
|
+
fontWeight: 600,
|
|
4220
|
+
color: 'var(--dsw-alias-label-primary, inherit)',
|
|
4221
|
+
};
|
|
4222
|
+
const CLOSE_STYLE = {
|
|
4223
|
+
display: 'inline-flex',
|
|
4224
|
+
alignItems: 'center',
|
|
4225
|
+
justifyContent: 'center',
|
|
4226
|
+
width: 34,
|
|
4227
|
+
height: 34,
|
|
4228
|
+
flex: 'none',
|
|
4229
|
+
padding: 0,
|
|
4230
|
+
border: 'none',
|
|
4231
|
+
borderRadius: '50%',
|
|
4232
|
+
background: 'transparent',
|
|
4233
|
+
color: 'var(--dsw-alias-label-secondary, inherit)',
|
|
4234
|
+
cursor: 'pointer',
|
|
4235
|
+
touchAction: 'manipulation',
|
|
4236
|
+
WebkitTapHighlightColor: 'transparent',
|
|
4237
|
+
};
|
|
4238
|
+
const OPTION_BASE = {
|
|
4239
|
+
display: 'flex',
|
|
4240
|
+
alignItems: 'center',
|
|
4241
|
+
gap: 8,
|
|
4242
|
+
width: '100%',
|
|
4243
|
+
minHeight: 48,
|
|
4244
|
+
padding: '0 14px',
|
|
4245
|
+
border: 'none',
|
|
4246
|
+
borderRadius: 12,
|
|
4247
|
+
background: 'transparent',
|
|
4248
|
+
color: 'var(--dsw-alias-label-primary, inherit)',
|
|
4249
|
+
fontFamily: 'inherit',
|
|
4250
|
+
fontSize: 14,
|
|
4251
|
+
textAlign: 'start',
|
|
4252
|
+
cursor: 'pointer',
|
|
4253
|
+
touchAction: 'manipulation',
|
|
4254
|
+
WebkitTapHighlightColor: 'transparent',
|
|
4255
|
+
};
|
|
4256
|
+
const CONFIRM_STYLE = {
|
|
4257
|
+
width: '100%',
|
|
4258
|
+
minHeight: 48,
|
|
4259
|
+
marginTop: 10,
|
|
4260
|
+
padding: '0 14px',
|
|
4261
|
+
border: 'none',
|
|
4262
|
+
borderRadius: 12,
|
|
4263
|
+
background: 'var(--dsw-alias-brand-primary, #0f1115)',
|
|
4264
|
+
color: 'var(--dsw-alias-label-primary-foreground, #ffffff)',
|
|
4265
|
+
fontFamily: 'inherit',
|
|
4266
|
+
fontSize: 14,
|
|
4267
|
+
fontWeight: 600,
|
|
4268
|
+
cursor: 'pointer',
|
|
4269
|
+
touchAction: 'manipulation',
|
|
4270
|
+
WebkitTapHighlightColor: 'transparent',
|
|
4271
|
+
};
|
|
4272
|
+
/** The share-range sheet. Rendered only while open (the caller owns the layer stack). */
|
|
4273
|
+
function ShareRangeSheet({ busy, onConfirm, onClose, t }) {
|
|
4274
|
+
const [preset, setPreset] = (0, react_1.useState)('all');
|
|
4275
|
+
const [reduced] = (0, react_1.useState)(prefersReducedMotion);
|
|
4276
|
+
const enter = reduced ? undefined : 'dsh-mobile-nav-sheet-up .22s var(--ds-ease-out, ease-in-out)';
|
|
4277
|
+
const fade = reduced ? undefined : 'dsh-mobile-nav-fade .18s var(--ds-ease-out, ease-in-out)';
|
|
4278
|
+
const options = [
|
|
4279
|
+
{ key: 'all', label: t('sharePickerAll'), value: 'all' },
|
|
4280
|
+
...fetch_share_ts_1.SHARE_TURNS_PRESETS.map((count) => ({ key: `last-${count}`, label: t('sharePickerLast', { count }), value: count })),
|
|
4281
|
+
];
|
|
4282
|
+
return ((0, jsx_runtime_1.jsxs)("div", { "data-mobile-nav": "share-range-layer", style: { position: 'absolute', inset: 0, zIndex: 2 }, children: [(0, jsx_runtime_1.jsx)("div", { "data-mobile-nav": "share-range-mask", role: "button", tabIndex: -1, "aria-label": t('infoClose'), onClick: onClose, style: fade === undefined ? MASK_STYLE : { ...MASK_STYLE, animation: fade } }), (0, jsx_runtime_1.jsxs)("div", { "data-mobile-nav": "share-range-sheet", role: "dialog", "aria-modal": "true", "aria-label": t('sharePickerTitle'), style: enter === undefined ? SHEET_STYLE : { ...SHEET_STYLE, animation: enter }, children: [(0, jsx_runtime_1.jsxs)("div", { style: HEAD_STYLE, children: [(0, jsx_runtime_1.jsx)("span", { style: TITLE_STYLE, children: t('sharePickerTitle') }), (0, jsx_runtime_1.jsx)("button", { type: "button", "aria-label": t('infoClose'), onClick: onClose, style: CLOSE_STYLE, children: (0, jsx_runtime_1.jsx)(dsh_client_ui_primitives_1.IconCloseOutline16, { size: 16 }) })] }), (0, jsx_runtime_1.jsx)("div", { role: "radiogroup", "aria-label": t('sharePickerTitle'), style: { display: 'flex', flexDirection: 'column', gap: 2 }, children: options.map((option) => {
|
|
4283
|
+
const selected = preset === option.value;
|
|
4284
|
+
return ((0, jsx_runtime_1.jsxs)("button", { type: "button", role: "radio", "aria-checked": selected, disabled: busy, "data-selected": selected ? '' : undefined, onClick: () => { setPreset(option.value); }, style: {
|
|
4285
|
+
...OPTION_BASE,
|
|
4286
|
+
background: selected ? 'var(--dsw-alias-interactive-bg-hover, rgba(0, 0, 0, .06))' : 'transparent',
|
|
4287
|
+
fontWeight: selected ? 600 : 400,
|
|
4288
|
+
}, children: [(0, jsx_runtime_1.jsx)("span", { children: option.label }), selected && ((0, jsx_runtime_1.jsx)("span", { style: { marginLeft: 'auto', display: 'inline-flex', color: 'var(--dsw-alias-label-primary, inherit)' }, children: (0, jsx_runtime_1.jsx)(dsh_client_ui_primitives_1.IconCheckOutline16, { size: 16 }) }))] }, option.key));
|
|
4289
|
+
}) }), (0, jsx_runtime_1.jsx)("button", { type: "button", disabled: busy, onClick: () => { onConfirm(preset); }, style: { ...CONFIRM_STYLE, opacity: busy ? 0.6 : 1 }, children: busy ? t('sharePickerBusy') : t('sharePickerConfirm') })] })] }));
|
|
1615
4290
|
}
|
|
1616
4291
|
};
|
|
1617
4292
|
__modules["MobileSessionInfo.js"] = function (require, module, exports) {
|
|
@@ -1625,8 +4300,20 @@ const store_ts_1 = require("./compat/store.js");
|
|
|
1625
4300
|
const types_ts_1 = require("./compat/types.js");
|
|
1626
4301
|
const nav_store_ts_1 = require("./nav-store.js");
|
|
1627
4302
|
const history_nav_ts_1 = require("./history-nav.js");
|
|
4303
|
+
const fetch_share_ts_1 = require("./share/fetch-share.js");
|
|
4304
|
+
const share_flow_ts_1 = require("./share/share-flow.js");
|
|
4305
|
+
const png_stitch_ts_1 = require("./share/png-stitch.js");
|
|
4306
|
+
const rasterize_ts_1 = require("./share/rasterize.js");
|
|
4307
|
+
const share_card_tsx_1 = require("./share/share-card.js");
|
|
4308
|
+
const share_sheet_tsx_1 = require("./share/share-sheet.js");
|
|
1628
4309
|
/** Layer id for the info sheet, so Android back closes it before leaving the session. */
|
|
1629
4310
|
const INFO_LAYER = 'session-info';
|
|
4311
|
+
/**
|
|
4312
|
+
* Layer id for the share-range sheet. Pushed ON TOP of the info layer's own
|
|
4313
|
+
* entry, so one back gesture closes exactly the picker — never both layers
|
|
4314
|
+
* at once (the one-direction rule in history-nav.ts).
|
|
4315
|
+
*/
|
|
4316
|
+
const SHARE_LAYER = 'session-info-share';
|
|
1630
4317
|
const MobileSessionHeader_tsx_1 = require("./MobileSessionHeader.js");
|
|
1631
4318
|
/* ---- StatsLine-identical formatting -------------------------------------
|
|
1632
4319
|
* Ported (not imported — the source functions are module-private to
|
|
@@ -1692,9 +4379,18 @@ function MobileSessionInfo({ sessionId, useSessions, useProjection, forkSession,
|
|
|
1692
4379
|
const [open, setOpen] = (0, react_1.useState)(false);
|
|
1693
4380
|
const [busy, setBusy] = (0, react_1.useState)(false);
|
|
1694
4381
|
const [error, setError] = (0, react_1.useState)(null);
|
|
4382
|
+
// Degraded-outcome notice (review 07+08): the multi-file delivery SUCCEEDED,
|
|
4383
|
+
// so "操作失败:…" would lie — this row says what the user actually got,
|
|
4384
|
+
// without the error template. Rendered in its own row (below), never through
|
|
4385
|
+
// setError.
|
|
4386
|
+
const [notice, setNotice] = (0, react_1.useState)(null);
|
|
4387
|
+
const [shareOpen, setShareOpen] = (0, react_1.useState)(false);
|
|
4388
|
+
const [shareData, setShareData] = (0, react_1.useState)(null);
|
|
4389
|
+
const shareCardHostRef = (0, react_1.useRef)(null);
|
|
1695
4390
|
(0, react_1.useEffect)(() => {
|
|
1696
4391
|
const onOpen = () => {
|
|
1697
4392
|
setError(null);
|
|
4393
|
+
setNotice(null);
|
|
1698
4394
|
setOpen(true);
|
|
1699
4395
|
// Stack a history entry on top of the session's: Android back now
|
|
1700
4396
|
// closes this sheet first and only then leaves the session.
|
|
@@ -1709,6 +4405,109 @@ function MobileSessionInfo({ sessionId, useSessions, useProjection, forkSession,
|
|
|
1709
4405
|
const jobCount = useSessions((s) => s.jobsBySession[sessionId]?.length ?? 0);
|
|
1710
4406
|
const stats = useProjection('sessionStats');
|
|
1711
4407
|
const usage = useProjection('tokenUsage');
|
|
4408
|
+
/* ---- share-image flow (ticket 05) --------------------------------------
|
|
4409
|
+
* The range sheet renders INSIDE this info layer's DOM — a DIRECT child of
|
|
4410
|
+
* the layer, a sibling of the info card, never inside the card itself (a
|
|
4411
|
+
* card-internal mount clipped the mask to the card's box, anchored the
|
|
4412
|
+
* sheet's bottom to the card's scrollable area, and let outside taps fall
|
|
4413
|
+
* through to the info mask, closing both layers at once and stranding
|
|
4414
|
+
* SHARE_LAYER as a dead history entry; review 2026-09-10): info.css.ts's
|
|
4415
|
+
* ≥768px display:none covers it (this feature adds no page CSS), and the
|
|
4416
|
+
* info layer's own z:70 header promotion carries it. Its history entry
|
|
4417
|
+
* sits above INFO_LAYER's, so back closes the picker alone. These helpers
|
|
4418
|
+
* live before the `if (!open) return null` below because the pipeline
|
|
4419
|
+
* effect may still be settling when the sheet unmounts. */
|
|
4420
|
+
// Rewind rather than flip the flag — same one-direction rule as `close`.
|
|
4421
|
+
const closeShare = () => {
|
|
4422
|
+
if ((0, history_nav_ts_1.hasLayer)(SHARE_LAYER))
|
|
4423
|
+
(0, history_nav_ts_1.popLayer)(SHARE_LAYER);
|
|
4424
|
+
else
|
|
4425
|
+
setShareOpen(false);
|
|
4426
|
+
};
|
|
4427
|
+
// Errors land on the info sheet's existing error row (ticket 05); the
|
|
4428
|
+
// share sheet closes first so the row is not hidden behind its mask.
|
|
4429
|
+
const shareErrorMessage = (err) => {
|
|
4430
|
+
if (err instanceof fetch_share_ts_1.ShareFetchError) {
|
|
4431
|
+
switch (err.code) {
|
|
4432
|
+
case 'route-missing': return t('shareErrRouteMissing');
|
|
4433
|
+
case 'session-not-found': return t('shareErrSessionNotFound');
|
|
4434
|
+
case 'bad-request': return t('shareErrBadRequest');
|
|
4435
|
+
case 'network': return t('shareErrNetwork');
|
|
4436
|
+
case 'server': return t('shareErrServer');
|
|
4437
|
+
case 'bad-body': return t('shareErrBody');
|
|
4438
|
+
}
|
|
4439
|
+
}
|
|
4440
|
+
if (err instanceof rasterize_ts_1.ShareRasterizeError) {
|
|
4441
|
+
if (err.code === 'probe-failed')
|
|
4442
|
+
return t('shareErrProbe');
|
|
4443
|
+
if (err.code === 'slice-over-budget')
|
|
4444
|
+
return t('shareErrSliceBudget');
|
|
4445
|
+
return t('shareErrRender');
|
|
4446
|
+
}
|
|
4447
|
+
return err instanceof Error ? err.message : String(err);
|
|
4448
|
+
};
|
|
4449
|
+
const shareFailed = (err) => {
|
|
4450
|
+
setBusy(false);
|
|
4451
|
+
setShareData(null);
|
|
4452
|
+
closeShare();
|
|
4453
|
+
setError(shareErrorMessage(err));
|
|
4454
|
+
};
|
|
4455
|
+
// Pipeline stage two. Stage one (onShareConfirm below) fetches and commits
|
|
4456
|
+
// the data, which renders the hidden ShareCard; this effect then runs
|
|
4457
|
+
// AFTER that commit, when the card element exists. The state → render →
|
|
4458
|
+
// effect gap is the React-idiomatic bridge over "DOM ready" without
|
|
4459
|
+
// react-dom (not in the client externals whitelist). Ticket 07: when the
|
|
4460
|
+
// browser can stitch (CompressionStream), the slices are planned with the
|
|
4461
|
+
// unified global width/ratio and concatenated into ONE PNG before delivery;
|
|
4462
|
+
// a runtime stitch failure falls back to the legacy per-slice multi-file
|
|
4463
|
+
// delivery, and either degraded form lands on the notice row below (never
|
|
4464
|
+
// the error row — the delivery itself succeeded, review 07+08).
|
|
4465
|
+
(0, react_1.useEffect)(() => {
|
|
4466
|
+
if (shareData === null)
|
|
4467
|
+
return;
|
|
4468
|
+
const card = shareCardHostRef.current?.querySelector('[data-share-card]') ?? null;
|
|
4469
|
+
if (card === null) {
|
|
4470
|
+
shareFailed(new Error(t('shareErrRender')));
|
|
4471
|
+
return;
|
|
4472
|
+
}
|
|
4473
|
+
const stitch = (0, png_stitch_ts_1.supportsPngStitch)();
|
|
4474
|
+
(0, rasterize_ts_1.rasterizeShareCard)(card, {
|
|
4475
|
+
pixelRatioCap: Math.min(window.devicePixelRatio || 1, 2),
|
|
4476
|
+
truncatedNote: (droppedTurns) => t('shareTruncatedNote', { count: droppedTurns }),
|
|
4477
|
+
unified: stitch,
|
|
4478
|
+
})
|
|
4479
|
+
.then(async (result) => {
|
|
4480
|
+
const title = row?.displayTitle ?? '';
|
|
4481
|
+
if (stitch && result.stitchWidth !== undefined) {
|
|
4482
|
+
try {
|
|
4483
|
+
const png = await (0, png_stitch_ts_1.stitchSliceBlobs)(result.slices, result.stitchWidth);
|
|
4484
|
+
await (0, share_flow_ts_1.deliverShareImage)(png, title);
|
|
4485
|
+
return null;
|
|
4486
|
+
}
|
|
4487
|
+
catch (err) {
|
|
4488
|
+
// Runtime stitch failure (a slice decoded at the wrong size, a
|
|
4489
|
+
// compressor error, …) — the rasterized slices are finished PNGs
|
|
4490
|
+
// either way, so deliver them multi-file instead of failing the
|
|
4491
|
+
// whole export. The original error stays on the console for
|
|
4492
|
+
// diagnosis; the notice row tells the user what they got (no
|
|
4493
|
+
// "操作失败" prefix — the delivery succeeded, review 07+08).
|
|
4494
|
+
console.error('[dsh-zen-remote] share-image: stitch failed — delivering the painted slices as multiple images instead', err);
|
|
4495
|
+
await (0, share_flow_ts_1.deliverShareImages)(result.slices, title);
|
|
4496
|
+
return t('shareStitchFailed');
|
|
4497
|
+
}
|
|
4498
|
+
}
|
|
4499
|
+
await (0, share_flow_ts_1.deliverShareImages)(result.slices, title);
|
|
4500
|
+
return t('shareStitchUnsupported');
|
|
4501
|
+
})
|
|
4502
|
+
.then((degraded) => {
|
|
4503
|
+
setBusy(false);
|
|
4504
|
+
setShareData(null);
|
|
4505
|
+
closeShare();
|
|
4506
|
+
setNotice(degraded);
|
|
4507
|
+
})
|
|
4508
|
+
.catch(shareFailed);
|
|
4509
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps -- shareData is the trigger; helpers only touch setters and module functions.
|
|
4510
|
+
}, [shareData]);
|
|
1712
4511
|
if (!open)
|
|
1713
4512
|
return null;
|
|
1714
4513
|
// Rewind rather than flip the flag — see the one-direction rule in
|
|
@@ -1767,10 +4566,50 @@ function MobileSessionInfo({ sessionId, useSessions, useProjection, forkSession,
|
|
|
1767
4566
|
const onExport = () => {
|
|
1768
4567
|
void downloadSessionLog(sessionId);
|
|
1769
4568
|
};
|
|
4569
|
+
const onShare = () => {
|
|
4570
|
+
setError(null);
|
|
4571
|
+
setNotice(null);
|
|
4572
|
+
setShareData(null);
|
|
4573
|
+
setShareOpen(true);
|
|
4574
|
+
(0, history_nav_ts_1.pushLayer)({ id: SHARE_LAYER, close: () => setShareOpen(false) });
|
|
4575
|
+
};
|
|
4576
|
+
// Pipeline stage one: fetch the selected range. Stage two (rasterize +
|
|
4577
|
+
// deliver) lives in the shareData effect above — the hidden card must be
|
|
4578
|
+
// committed to the DOM before the rasterizer can clone it.
|
|
4579
|
+
const onShareConfirm = (choice) => {
|
|
4580
|
+
const range = choice === 'all' ? { kind: 'all' } : { kind: 'last', turns: choice };
|
|
4581
|
+
setBusy(true);
|
|
4582
|
+
setError(null);
|
|
4583
|
+
// Session id straight off the useSessions row (its branded
|
|
4584
|
+
// session-<uuid> form — a bare uuid 404s, PLAN §2), slot prop as backup.
|
|
4585
|
+
(0, fetch_share_ts_1.fetchShareExport)(row?.id ?? sessionId, range)
|
|
4586
|
+
.then((data) => {
|
|
4587
|
+
if (data.turns.length === 0)
|
|
4588
|
+
throw new Error(t('shareErrEmpty'));
|
|
4589
|
+
setShareData(data);
|
|
4590
|
+
})
|
|
4591
|
+
.catch(shareFailed);
|
|
4592
|
+
};
|
|
1770
4593
|
const cacheHit = usage === undefined ? null : cacheHitPercent(usage);
|
|
1771
4594
|
const tokensEmpty = usage === undefined || (billedInputTokens(usage) === 0 && usage.outputTokens === 0);
|
|
1772
4595
|
const cwd = row?.cwd === undefined || row.cwd === '' ? undefined : (0, store_ts_1.workspaceTitleOf)(row.cwd);
|
|
1773
4596
|
const preset = (0, types_ts_1.agentPresetOf)(row);
|
|
4597
|
+
// Known-empty sessions (stats observed, zero turns) disable the share
|
|
4598
|
+
// button outright; unknown-stats sessions stay enabled and let the fetch's
|
|
4599
|
+
// empty-turns guard speak. Known 口径 gap, accepted for v1: the projection
|
|
4600
|
+
// counts a turn only once one of its steps has CLOSED (the fold increments
|
|
4601
|
+
// on step/end — dsh-session-stats projection.js), so a session whose only
|
|
4602
|
+
// content is a still-streaming turn, or user messages with no assistant
|
|
4603
|
+
// step yet, also reads turns === 0 and is over-disabled here. Wrong
|
|
4604
|
+
// direction but fail-safe: the button is merely greyed (no misleading
|
|
4605
|
+
// error), and every session that passes through still meets the fetch-side
|
|
4606
|
+
// empty-turns guard (shareErrEmpty below), which is exact.
|
|
4607
|
+
const shareEmpty = stats !== undefined && stats.turns === 0;
|
|
4608
|
+
const shareCardCopy = {
|
|
4609
|
+
turnsLabel: (count) => t('shareCardTurns', { count }),
|
|
4610
|
+
generatedBy: t('shareCardGeneratedBy'),
|
|
4611
|
+
image: t('shareCardImage'),
|
|
4612
|
+
};
|
|
1774
4613
|
const cells = [
|
|
1775
4614
|
{ label: t('infoStatTurns'), value: stats === undefined ? NA : String(stats.turns), sub: undefined },
|
|
1776
4615
|
{ label: t('infoStatSteps'), value: stats === undefined ? NA : String(stats.steps), sub: undefined },
|
|
@@ -1808,7 +4647,15 @@ function MobileSessionInfo({ sessionId, useSessions, useProjection, forkSession,
|
|
|
1808
4647
|
if (!tab.active)
|
|
1809
4648
|
tab.el.click();
|
|
1810
4649
|
close();
|
|
1811
|
-
}, children: tab.label }, tab.label))) })), (0, jsx_runtime_1.jsx)("button", { type: "button", "data-mobile-nav": "info-close", "aria-label": t('infoClose'), onClick: close, children: (0, jsx_runtime_1.jsx)(dsh_client_ui_primitives_1.IconCloseOutline16, { size: 16 }) })] }), (0, jsx_runtime_1.jsxs)("div", { "data-mobile-nav": "info-badges", children: [preset !== undefined && (0, jsx_runtime_1.jsx)("span", { "data-mobile-nav": "info-badge", children: preset }), subagentCount > 0 && ((0, jsx_runtime_1.jsx)("span", { "data-mobile-nav": "info-badge", children: t('infoSubagents', { count: subagentCount }) })), jobCount > 0 && ((0, jsx_runtime_1.jsx)("span", { "data-mobile-nav": "info-badge", children: t('infoJobs', { count: jobCount }) })), (0, jsx_runtime_1.jsx)("span", { "data-mobile-nav": "info-badge-cwd", children: cwd ?? t('infoCwdFallback') })] }), (0, jsx_runtime_1.jsx)("div", { "data-mobile-nav": "info-stats", children: cells.map((cell) => ((0, jsx_runtime_1.jsxs)("div", { "data-mobile-nav": "info-stat", children: [(0, jsx_runtime_1.jsx)("span", { "data-mobile-nav": "info-stat-value", children: cell.value }), (0, jsx_runtime_1.jsx)("span", { "data-mobile-nav": "info-stat-label", children: cell.label }), cell.sub !== undefined && (0, jsx_runtime_1.jsx)("span", { "data-mobile-nav": "info-stat-sub", children: cell.sub })] }, cell.label))) }), error !== null && (0, jsx_runtime_1.jsx)("div", { "data-mobile-nav": "info-error", children: t('infoActionError', { message: error }) }),
|
|
4650
|
+
}, children: tab.label }, tab.label))) })), (0, jsx_runtime_1.jsx)("button", { type: "button", "data-mobile-nav": "info-close", "aria-label": t('infoClose'), onClick: close, children: (0, jsx_runtime_1.jsx)(dsh_client_ui_primitives_1.IconCloseOutline16, { size: 16 }) })] }), (0, jsx_runtime_1.jsxs)("div", { "data-mobile-nav": "info-badges", children: [preset !== undefined && (0, jsx_runtime_1.jsx)("span", { "data-mobile-nav": "info-badge", children: preset }), subagentCount > 0 && ((0, jsx_runtime_1.jsx)("span", { "data-mobile-nav": "info-badge", children: t('infoSubagents', { count: subagentCount }) })), jobCount > 0 && ((0, jsx_runtime_1.jsx)("span", { "data-mobile-nav": "info-badge", children: t('infoJobs', { count: jobCount }) })), (0, jsx_runtime_1.jsx)("span", { "data-mobile-nav": "info-badge-cwd", children: cwd ?? t('infoCwdFallback') })] }), (0, jsx_runtime_1.jsx)("div", { "data-mobile-nav": "info-stats", children: cells.map((cell) => ((0, jsx_runtime_1.jsxs)("div", { "data-mobile-nav": "info-stat", children: [(0, jsx_runtime_1.jsx)("span", { "data-mobile-nav": "info-stat-value", children: cell.value }), (0, jsx_runtime_1.jsx)("span", { "data-mobile-nav": "info-stat-label", children: cell.label }), cell.sub !== undefined && (0, jsx_runtime_1.jsx)("span", { "data-mobile-nav": "info-stat-sub", children: cell.sub })] }, cell.label))) }), error !== null && (0, jsx_runtime_1.jsx)("div", { "data-mobile-nav": "info-error", children: t('infoActionError', { message: error }) }), notice !== null && ((0, jsx_runtime_1.jsx)("div", { "data-mobile-nav": "info-notice", role: "status", style: {
|
|
4651
|
+
marginBottom: 8,
|
|
4652
|
+
padding: '8px 10px',
|
|
4653
|
+
borderRadius: 10,
|
|
4654
|
+
background: 'var(--dsw-alias-interactive-bg-hover, rgba(0, 0, 0, .06))',
|
|
4655
|
+
color: 'var(--dsw-alias-label-secondary, rgba(0, 0, 0, .6))',
|
|
4656
|
+
fontSize: 12,
|
|
4657
|
+
lineHeight: 1.4,
|
|
4658
|
+
}, children: notice })), (0, jsx_runtime_1.jsxs)("div", { "data-mobile-nav": "info-actions", children: [(0, jsx_runtime_1.jsxs)("button", { type: "button", "data-mobile-nav": "info-action", disabled: busy, onClick: onExport, children: [(0, jsx_runtime_1.jsx)(dsh_client_ui_primitives_1.IconDownloadOutline16, { size: 16 }), (0, jsx_runtime_1.jsx)("span", { children: t('infoExport') })] }), (0, jsx_runtime_1.jsxs)("button", { type: "button", "data-mobile-nav": "info-action", disabled: busy, onClick: onRename, children: [(0, jsx_runtime_1.jsx)(dsh_client_ui_primitives_1.IconEditOutline16, { size: 16 }), (0, jsx_runtime_1.jsx)("span", { children: t('infoRename') })] }), (0, jsx_runtime_1.jsxs)("button", { type: "button", "data-mobile-nav": "info-action", disabled: busy, onClick: onFork, children: [(0, jsx_runtime_1.jsx)(dsh_client_ui_primitives_1.IconBranchOutline16, { size: 16 }), (0, jsx_runtime_1.jsx)("span", { children: t('infoFork') })] }), (0, jsx_runtime_1.jsxs)("button", { type: "button", "data-mobile-nav": "info-action", "data-mobile-nav-danger": "", disabled: busy, onClick: onArchive, children: [(0, jsx_runtime_1.jsx)(dsh_client_ui_primitives_1.IconArchiveOutline20, { size: 20 }), (0, jsx_runtime_1.jsx)("span", { children: t('infoArchive') })] }), (0, jsx_runtime_1.jsxs)("button", { type: "button", "data-mobile-nav": "info-action", disabled: busy || shareEmpty, onClick: onShare, children: [(0, jsx_runtime_1.jsx)(dsh_client_ui_primitives_1.IconShareOutline16, { size: 16 }), (0, jsx_runtime_1.jsx)("span", { children: t('shareAction') })] })] }), shareData !== null && ((0, jsx_runtime_1.jsx)("div", { ref: shareCardHostRef, "aria-hidden": "true", style: { position: 'fixed', left: '-9999px', top: 0, pointerEvents: 'none' }, children: (0, jsx_runtime_1.jsx)(share_card_tsx_1.ShareCard, { title: row?.displayTitle ?? t('sessionInfo'), ...(preset !== undefined ? { subtitle: preset } : {}), ...(shareData.createdAt !== undefined ? { createdAt: shareData.createdAt } : {}), turns: shareData.turns, copy: shareCardCopy }) }))] }), shareOpen && ((0, jsx_runtime_1.jsx)(share_sheet_tsx_1.ShareRangeSheet, { busy: busy, onConfirm: onShareConfirm, onClose: closeShare, t: t }))] }));
|
|
1812
4659
|
}
|
|
1813
4660
|
};
|
|
1814
4661
|
__modules["attach-upload.js"] = function (require, module, exports) {
|
|
@@ -1957,6 +4804,63 @@ function releaseThumbnailsExcept(keep) {
|
|
|
1957
4804
|
}
|
|
1958
4805
|
}
|
|
1959
4806
|
};
|
|
4807
|
+
__modules["host-attach.js"] = function (require, module, exports) {
|
|
4808
|
+
"use strict";
|
|
4809
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
4810
|
+
exports.HOST_ATTACH_INPUT = void 0;
|
|
4811
|
+
exports.hostAttachPresent = hostAttachPresent;
|
|
4812
|
+
exports.useOwnAttachUi = useOwnAttachUi;
|
|
4813
|
+
const react_1 = require("react");
|
|
4814
|
+
/**
|
|
4815
|
+
* The host composer's own hidden file picker, when the running DSH ships one.
|
|
4816
|
+
*
|
|
4817
|
+
* DSH 0.1.5 grew a native attachment flow (dsh-attachment / dsh-client-file-
|
|
4818
|
+
* upload): the official tool row carries a second `_add` paperclip whose click
|
|
4819
|
+
* drives an `<input type="file" hidden>` that is a DIRECT sibling of the
|
|
4820
|
+
* buttons inside `_tools`. That input is the one structural, locale-free
|
|
4821
|
+
* proof that the host implements composer attachment upload — the button
|
|
4822
|
+
* itself can only be told apart from the "+" command menu by its localized
|
|
4823
|
+
* aria-label, and the capability prop (`addFiles`) lives on the composer
|
|
4824
|
+
* bar's own inject face, which never reaches `conversation.input.left`
|
|
4825
|
+
* registrants. Scoped to `_tools` children so a third-party plugin parking
|
|
4826
|
+
* its own file input somewhere else in the composer cannot be misread as
|
|
4827
|
+
* the official feature; `:not([data-mobile-nav])` keeps OUR picker
|
|
4828
|
+
* (`attach-picker`) out of the match.
|
|
4829
|
+
*/
|
|
4830
|
+
exports.HOST_ATTACH_INPUT = '[data-slot="conversation.composer.bar"] [class$="_tools"] > input[type="file"]:not([data-mobile-nav])';
|
|
4831
|
+
/** Synchronous read: does this host's composer ship its own attach picker? */
|
|
4832
|
+
function hostAttachPresent() {
|
|
4833
|
+
return document.querySelector(exports.HOST_ATTACH_INPUT) !== null;
|
|
4834
|
+
}
|
|
4835
|
+
/**
|
|
4836
|
+
* True while OUR attachment UI should mount (the host has none of its own).
|
|
4837
|
+
*
|
|
4838
|
+
* The rule this encodes (2026-09-11): where the host implements attachment
|
|
4839
|
+
* upload, the phone composer shows exactly ONE attach control — the host's —
|
|
4840
|
+
* and this plugin's S7 button and chips row stand down; on a host without
|
|
4841
|
+
* one (DSH ≤ 0.1.2), both load as they always have.
|
|
4842
|
+
*
|
|
4843
|
+
* `useLayoutEffect`, not `useEffect`: on the composer's very first mount our
|
|
4844
|
+
* slot child renders in the same React pass as the host's tool row, so the
|
|
4845
|
+
* state initializer below reads the DOM BEFORE either exists — the layout
|
|
4846
|
+
* effect re-checks after the commit and before the browser paints, so the
|
|
4847
|
+
* demotion to hidden (or, on an old host, the decision to stay) never
|
|
4848
|
+
* reaches the screen as a flash. The MutationObserver covers the lifetime
|
|
4849
|
+
* after that: the picker's existence only really changes with the page, but
|
|
4850
|
+
* a dynamic host plugin or a reload must not strand a stale verdict.
|
|
4851
|
+
*/
|
|
4852
|
+
function useOwnAttachUi() {
|
|
4853
|
+
const [own, setOwn] = (0, react_1.useState)(() => !hostAttachPresent());
|
|
4854
|
+
(0, react_1.useLayoutEffect)(() => {
|
|
4855
|
+
const check = () => setOwn(!hostAttachPresent());
|
|
4856
|
+
const observer = new MutationObserver(check);
|
|
4857
|
+
observer.observe(document.body, { childList: true, subtree: true });
|
|
4858
|
+
check();
|
|
4859
|
+
return () => observer.disconnect();
|
|
4860
|
+
}, []);
|
|
4861
|
+
return own;
|
|
4862
|
+
}
|
|
4863
|
+
};
|
|
1960
4864
|
__modules["MobileAttachButton.js"] = function (require, module, exports) {
|
|
1961
4865
|
"use strict";
|
|
1962
4866
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
@@ -1965,6 +4869,7 @@ const jsx_runtime_1 = require("react/jsx-runtime");
|
|
|
1965
4869
|
const react_1 = require("react");
|
|
1966
4870
|
const dsh_client_ui_primitives_1 = require("@deepseek-ai/dsh-client-ui-primitives");
|
|
1967
4871
|
const attach_upload_ts_1 = require("./attach-upload.js");
|
|
4872
|
+
const host_attach_ts_1 = require("./host-attach.js");
|
|
1968
4873
|
/** Post one file to the host route and return the workspace-relative path it landed on. */
|
|
1969
4874
|
async function uploadFile(sessionId, file) {
|
|
1970
4875
|
const response = await fetch((0, attach_upload_ts_1.uploadUrl)(sessionId, file.name), {
|
|
@@ -1990,6 +4895,13 @@ async function uploadFile(sessionId, file) {
|
|
|
1990
4895
|
* the CLIENT's picker — no `accept` attribute on purpose, so iOS offers the
|
|
1991
4896
|
* full 相册 / 拍照 / 选取文件 sheet rather than one of them.
|
|
1992
4897
|
*
|
|
4898
|
+
* DSH 0.1.5 gave the official tool row its own paperclip (the host attachment
|
|
4899
|
+
* flow), which made TWO identical controls on a phone. This button now stands
|
|
4900
|
+
* down entirely while the host ships its own picker (`host-attach.ts` probes
|
|
4901
|
+
* for it) — the official flow uploads into `~/.dsh/attachments` and renders
|
|
4902
|
+
* its own rail, so the composer keeps exactly one attach affordance. On a
|
|
4903
|
+
* host without one (DSH ≤ 0.1.2) everything below loads exactly as before.
|
|
4904
|
+
*
|
|
1993
4905
|
* Every picked file takes the SAME path: upload to the node half, then append
|
|
1994
4906
|
* `@.dsh-uploads/name` to the draft through `inputActions.setDraft` (the
|
|
1995
4907
|
* official write path — no DOM value poking). S7.1 removed the split that used
|
|
@@ -2005,12 +4917,17 @@ function MobileAttachButton({ t, sessionId, useInput, inputActions }) {
|
|
|
2005
4917
|
const [busy, setBusy] = (0, react_1.useState)(false);
|
|
2006
4918
|
const [error, setError] = (0, react_1.useState)(null);
|
|
2007
4919
|
const draft = useInput((state) => state.draft);
|
|
4920
|
+
const ownUi = (0, host_attach_ts_1.useOwnAttachUi)();
|
|
2008
4921
|
// Uploads are awaited, so the `draft` captured at pick time goes stale — the
|
|
2009
4922
|
// user can keep typing while a file is in flight. The ref is refreshed on
|
|
2010
4923
|
// every render (useInput re-renders us on each draft change), so the loop
|
|
2011
4924
|
// below can rebase onto whatever the composer holds right now.
|
|
2012
4925
|
const liveDraft = (0, react_1.useRef)(draft);
|
|
2013
4926
|
liveDraft.current = draft;
|
|
4927
|
+
// The host's own attach button owns this seat now — render nothing (the
|
|
4928
|
+
// hidden picker input goes with it). All hooks stay above this line.
|
|
4929
|
+
if (!ownUi)
|
|
4930
|
+
return null;
|
|
2014
4931
|
const pick = async (event) => {
|
|
2015
4932
|
const files = Array.from(event.target.files ?? []);
|
|
2016
4933
|
// Reset first: picking the same file twice in a row fires no change event
|
|
@@ -2056,6 +4973,7 @@ const jsx_runtime_1 = require("react/jsx-runtime");
|
|
|
2056
4973
|
const react_1 = require("react");
|
|
2057
4974
|
const dsh_client_ui_primitives_1 = require("@deepseek-ai/dsh-client-ui-primitives");
|
|
2058
4975
|
const attach_upload_ts_1 = require("./attach-upload.js");
|
|
4976
|
+
const host_attach_ts_1 = require("./host-attach.js");
|
|
2059
4977
|
/**
|
|
2060
4978
|
* Attachment preview row (S7.1), in `conversation.input.dock` — the official
|
|
2061
4979
|
* full-width row above the composer card, which composer.css.ts already lays
|
|
@@ -2084,10 +5002,17 @@ const attach_upload_ts_1 = require("./attach-upload.js");
|
|
|
2084
5002
|
*/
|
|
2085
5003
|
function MobileAttachChips({ t, useInput, inputActions }) {
|
|
2086
5004
|
const draft = useInput((state) => state.draft);
|
|
2087
|
-
const
|
|
5005
|
+
const ownUi = (0, host_attach_ts_1.useOwnAttachUi)();
|
|
2088
5006
|
(0, react_1.useEffect)(() => {
|
|
2089
5007
|
(0, attach_upload_ts_1.releaseThumbnailsExcept)((0, attach_upload_ts_1.mentionsIn)(draft));
|
|
2090
5008
|
}, [draft]);
|
|
5009
|
+
// Same gate as MobileAttachButton (host-attach.ts): where the host ships
|
|
5010
|
+
// its own attachment flow, our chips row stands down too — the official
|
|
5011
|
+
// rail owns the preview seat. The effect above still runs, so object URLs
|
|
5012
|
+
// from OUR flow get revoked even after the switch.
|
|
5013
|
+
if (!ownUi)
|
|
5014
|
+
return null;
|
|
5015
|
+
const attachments = (0, attach_upload_ts_1.mentionsIn)(draft);
|
|
2091
5016
|
if (attachments.length === 0)
|
|
2092
5017
|
return null;
|
|
2093
5018
|
return ((0, jsx_runtime_1.jsx)("div", { "data-mobile-nav": "attach-chips", children: attachments.map((relPath) => {
|
|
@@ -2707,8 +5632,11 @@ exports.LAYOUT_CSS = `/* ---------- mobile-only layout ---------- */
|
|
|
2707
5632
|
first-child's last-child, so without this exclusion it silently matched
|
|
2708
5633
|
this generic selector too — measured 358px (100vw-32px) instead of the
|
|
2709
5634
|
374px its own left:8px/right:8px margins specify. Same category of bug
|
|
2710
|
-
as the ZuhsRW directory-picker collision below; same fix shape.
|
|
2711
|
-
|
|
5635
|
+
as the ZuhsRW directory-picker collision below; same fix shape.
|
|
5636
|
+
:not([data-mobile-nav="share-range-sheet"]) (share-image, 2026-09-10):
|
|
5637
|
+
the share range picker rides the same role="dialog" aria-modal bottom
|
|
5638
|
+
-sheet shape and the same margins, so it hit the identical clamp. */
|
|
5639
|
+
[aria-modal="true"]:not(:has(> :first-child > :last-child > button)):not([data-mobile-nav="info-sheet"]):not([data-mobile-nav="share-range-sheet"]) {
|
|
2712
5640
|
max-width: calc(100vw - 32px) !important;
|
|
2713
5641
|
}
|
|
2714
5642
|
/* Nav bar: hide the "Settings" caption (redundant on a full-width sheet)
|
|
@@ -4493,6 +7421,11 @@ __modules["styles/header.css.js"] = function (require, module, exports) {
|
|
|
4493
7421
|
// nav.crumbs breadcrumb chain, last segment = the title
|
|
4494
7422
|
// div.headerActions [data-slot="conversation.session.header.actions"]
|
|
4495
7423
|
// div.headerUtilities [data-slot="conversation.session.header.utilities"]
|
|
7424
|
+
// div.headerCorner [data-conversation-header-corner]
|
|
7425
|
+
// [data-slot="conversation.session.header.corner"]
|
|
7426
|
+
// (DSH 0.1.5: the right sidebar's ExpandButton —
|
|
7427
|
+
// hidden below, our workbench button routes to
|
|
7428
|
+
// its controls instead)
|
|
4496
7429
|
// div.tabs[role="tablist"] Chat/Trajectory, only when there is >1 view
|
|
4497
7430
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
4498
7431
|
exports.HEADER_CSS = void 0;
|
|
@@ -4761,6 +7694,31 @@ exports.HEADER_CSS = `/* ---------- session header five-piece reflow (< 768px) -
|
|
|
4761
7694
|
[data-phase] header [data-slot="conversation.session.header.utilities"] > * {
|
|
4762
7695
|
display: none !important;
|
|
4763
7696
|
}
|
|
7697
|
+
/* DSH 0.1.5's third titleRow child: the right sidebar's ExpandButton
|
|
7698
|
+
(dsh-client-ui-sidebar-right, slot conversation.session.header.corner
|
|
7699
|
+
inside div.headerCorner). Left alone it auto-placed into an implicit
|
|
7700
|
+
4th grid column at the screen's top-right corner — a second panel-left
|
|
7701
|
+
icon next to our Workbench button, reading as "the hidden official
|
|
7702
|
+
sidebar toggle is back" (reported 2026-09-11). The button itself is the
|
|
7703
|
+
one our workbench control forwards to when better-sidebar is absent
|
|
7704
|
+
(MobileSessionHeader's toggleSidebarTarget), and a display:none button
|
|
7705
|
+
still receives synthetic .click() (the tablist precedent), so hiding it
|
|
7706
|
+
costs nothing.
|
|
7707
|
+
Anchors, in the specificity order they NEED (measured live, 2026-09-11):
|
|
7708
|
+
the corner div must be beaten with (0,3,1) — the utilities un-hide rule
|
|
7709
|
+
above spells header > :first-child > :last-child at exactly that
|
|
7710
|
+
weight, and with 0.1.5's third titleRow child the corner IS that
|
|
7711
|
+
:last-child now, so its flex !important outranks any lighter hide (a
|
|
7712
|
+
single-attribute hide loses at (0,2,1) — both spellings were tried
|
|
7713
|
+
live). Chaining BOTH of the corner's own anchors on one selector — the
|
|
7714
|
+
host's data-conversation-header-corner contract attribute AND the
|
|
7715
|
+
class suffix — reaches the tie, and the later source order here wins
|
|
7716
|
+
it. The slot wrapper rule below needs no help: nothing !important
|
|
7717
|
+
fights it, so its (0,2,1) settles the button. */
|
|
7718
|
+
[data-phase] header [data-conversation-header-corner][class$="_headerCorner"],
|
|
7719
|
+
[data-phase] header [data-slot="conversation.session.header.corner"] {
|
|
7720
|
+
display: none !important;
|
|
7721
|
+
}
|
|
4764
7722
|
/* Native background-task / subagent entries (rewritten for DSH 0.1.1,
|
|
4765
7723
|
2026-08-21). Three upstream changes killed the old "park the root as a
|
|
4766
7724
|
zero-width anchor, hide the trigger, forward the tap" design at once:
|
|
@@ -6190,6 +9148,7 @@ exports.zh = {
|
|
|
6190
9148
|
'sessionInfo': '会话信息',
|
|
6191
9149
|
'workbench': '工作台',
|
|
6192
9150
|
'workbenchClose': '关闭工作台',
|
|
9151
|
+
'sidebar': '侧边栏',
|
|
6193
9152
|
'switchView': '切换视图',
|
|
6194
9153
|
'attach': '添加附件',
|
|
6195
9154
|
'attachBusy': '上传中…',
|
|
@@ -6217,6 +9176,28 @@ exports.zh = {
|
|
|
6217
9176
|
'infoActionError': '操作失败:{message}',
|
|
6218
9177
|
'turnFold': '过程 · {count} 步',
|
|
6219
9178
|
'turnFoldRunning': '进行中 · {count} 步',
|
|
9179
|
+
'shareCardTurns': '{count} 轮对话',
|
|
9180
|
+
'shareCardGeneratedBy': '由 DeepSeek Harness 生成',
|
|
9181
|
+
'shareCardImage': '图片附件',
|
|
9182
|
+
'shareAction': '分享图',
|
|
9183
|
+
'sharePickerTitle': '分享范围',
|
|
9184
|
+
'sharePickerAll': '全部对话',
|
|
9185
|
+
'sharePickerLast': '最近 {count} 轮',
|
|
9186
|
+
'sharePickerConfirm': '生成分享图',
|
|
9187
|
+
'sharePickerBusy': '生成中…',
|
|
9188
|
+
'shareTruncatedNote': '内容过长,已省略前 {count} 轮',
|
|
9189
|
+
'shareErrRouteMissing': '当前 DSH 版本未提供分享图接口,请更新 DSH 后重试',
|
|
9190
|
+
'shareErrSessionNotFound': '会话不存在或已被删除',
|
|
9191
|
+
'shareErrBadRequest': '分享请求无效,请重试',
|
|
9192
|
+
'shareErrNetwork': '网络连接失败,请检查网络后重试',
|
|
9193
|
+
'shareErrServer': '服务暂时不可用,请稍后重试',
|
|
9194
|
+
'shareErrBody': '分享数据异常,请重试',
|
|
9195
|
+
'shareErrProbe': '当前浏览器无法生成分享图',
|
|
9196
|
+
'shareErrRender': '生成分享图失败,请重试',
|
|
9197
|
+
'shareErrSliceBudget': '分享图单段内容超出画布上限,请缩小分享范围后重试',
|
|
9198
|
+
'shareErrEmpty': '会话还没有可分享的内容',
|
|
9199
|
+
'shareStitchUnsupported': '当前浏览器暂不支持合成单张长图,已导出为多张图片',
|
|
9200
|
+
'shareStitchFailed': '合成单张长图失败,已改为多张图片导出',
|
|
6220
9201
|
'settings': '设置',
|
|
6221
9202
|
'chipTaskboard': '任务看板',
|
|
6222
9203
|
'chipSsh': 'SSH',
|
|
@@ -6243,6 +9224,7 @@ exports.en = {
|
|
|
6243
9224
|
'sessionInfo': 'Session info',
|
|
6244
9225
|
'workbench': 'Workbench',
|
|
6245
9226
|
'workbenchClose': 'Close workbench',
|
|
9227
|
+
'sidebar': 'Sidebar',
|
|
6246
9228
|
'switchView': 'Switch view',
|
|
6247
9229
|
'attach': 'Add attachment',
|
|
6248
9230
|
'attachBusy': 'Uploading…',
|
|
@@ -6270,6 +9252,28 @@ exports.en = {
|
|
|
6270
9252
|
'infoActionError': 'Action failed: {message}',
|
|
6271
9253
|
'turnFold': 'Process · {count} steps',
|
|
6272
9254
|
'turnFoldRunning': 'Working · {count} steps',
|
|
9255
|
+
'shareCardTurns': '{count} turns',
|
|
9256
|
+
'shareCardGeneratedBy': 'Made with DeepSeek Harness',
|
|
9257
|
+
'shareCardImage': 'Image attachment',
|
|
9258
|
+
'shareAction': 'Share image',
|
|
9259
|
+
'sharePickerTitle': 'Share range',
|
|
9260
|
+
'sharePickerAll': 'Whole conversation',
|
|
9261
|
+
'sharePickerLast': 'Last {count} turns',
|
|
9262
|
+
'sharePickerConfirm': 'Generate',
|
|
9263
|
+
'sharePickerBusy': 'Generating…',
|
|
9264
|
+
'shareTruncatedNote': 'Long conversation — first {count} turns omitted',
|
|
9265
|
+
'shareErrRouteMissing': 'This DSH version has no share-image endpoint — please update DSH',
|
|
9266
|
+
'shareErrSessionNotFound': 'Session not found or deleted',
|
|
9267
|
+
'shareErrBadRequest': 'Invalid share request — please try again',
|
|
9268
|
+
'shareErrNetwork': 'Network error — check the connection and try again',
|
|
9269
|
+
'shareErrServer': 'Service temporarily unavailable — try again later',
|
|
9270
|
+
'shareErrBody': 'Unexpected share data — please try again',
|
|
9271
|
+
'shareErrProbe': 'This browser cannot render share images',
|
|
9272
|
+
'shareErrRender': 'Failed to generate the share image — please try again',
|
|
9273
|
+
'shareErrSliceBudget': 'One slice of the share image exceeded the canvas limit — try a smaller share range',
|
|
9274
|
+
'shareErrEmpty': 'Nothing to share in this session yet',
|
|
9275
|
+
'shareStitchUnsupported': 'This browser cannot stitch one long image — exported as multiple images instead',
|
|
9276
|
+
'shareStitchFailed': 'Could not stitch one long image — exported as multiple images instead',
|
|
6273
9277
|
'settings': 'Settings',
|
|
6274
9278
|
'chipTaskboard': 'Task board',
|
|
6275
9279
|
'chipSsh': 'SSH',
|
|
@@ -8845,24 +11849,29 @@ const INTENT_WINDOW_MS = 1000;
|
|
|
8845
11849
|
/**
|
|
8846
11850
|
* S9 — keep the phone keyboard down until the user asks for it.
|
|
8847
11851
|
*
|
|
8848
|
-
* dsh-client-ui-conversation focuses the composer
|
|
8849
|
-
* sessionId change (
|
|
8850
|
-
*
|
|
8851
|
-
* software keyboard over half the screen every time a
|
|
11852
|
+
* dsh-client-ui-conversation focuses the composer's editing host on every
|
|
11853
|
+
* sessionId change (0.1.2: `el.focus({preventScroll:true})` on the textarea;
|
|
11854
|
+
* 0.1.5: the same on the Lexical contenteditable). Sensible on desktop; on a
|
|
11855
|
+
* phone it pops the software keyboard over half the screen every time a
|
|
11856
|
+
* session opens.
|
|
8852
11857
|
*
|
|
8853
|
-
* Rule: focus on the composer
|
|
8854
|
-
* it — a tap on the
|
|
8855
|
-
*
|
|
8856
|
-
*
|
|
8857
|
-
* send) is blurred.
|
|
11858
|
+
* Rule: focus on the composer field survives only when the user asked for
|
|
11859
|
+
* it — a tap on the field itself or typing on a hardware keyboard. Anything
|
|
11860
|
+
* else (session-open autofocus, push-deep-link opens, the refocus side
|
|
11861
|
+
* effects of the other composer buttons — the official attach paperclip's
|
|
11862
|
+
* `keepFocus` mousedown, the slash-command toggle, send) is blurred. That
|
|
11863
|
+
* attach case is not hypothetical: 0.1.5's official paperclip refocuses the
|
|
11864
|
+
* editor on MOUSEDOWN, and while this guard was blind to the contenteditable
|
|
11865
|
+
* (2026-09-11 report) every tap on it popped the keyboard and shoved the
|
|
11866
|
+
* composer up the screen.
|
|
8858
11867
|
*
|
|
8859
11868
|
* Two triggers, because one is not enough:
|
|
8860
11869
|
* - focusin catches the autofocus the moment it happens;
|
|
8861
11870
|
* - a body MutationObserver re-runs the check after transcript swaps
|
|
8862
|
-
* (opening a session re-renders the flow but may reuse the same
|
|
11871
|
+
* (opening a session re-renders the flow but may reuse the same field,
|
|
8863
11872
|
* and a focus that landed before this plugin loaded never fired focusin
|
|
8864
11873
|
* for us at all).
|
|
8865
|
-
* Once focus is user-granted it stays granted until the
|
|
11874
|
+
* Once focus is user-granted it stays granted until the field blurs, so
|
|
8866
11875
|
* the observer never yanks a keyboard the user opened (e.g. while the agent
|
|
8867
11876
|
* streams and the user pauses typing).
|
|
8868
11877
|
*/
|
|
@@ -8873,7 +11882,12 @@ function installKeyboardGuard(ctx) {
|
|
|
8873
11882
|
let granted = false;
|
|
8874
11883
|
let observer = null;
|
|
8875
11884
|
let frame = 0;
|
|
8876
|
-
|
|
11885
|
+
/** The composer's editing host: the `data-composer-input` contenteditable
|
|
11886
|
+
* DSH 0.1.5 binds Lexical to, or the textarea older hosts rendered (the
|
|
11887
|
+
* attribute also sat on that textarea — both spellings match both hosts,
|
|
11888
|
+
* belt and braces). */
|
|
11889
|
+
const composerField = (node) => node instanceof HTMLElement && node.closest(COMPOSER) !== null
|
|
11890
|
+
&& (node.hasAttribute('data-composer-input') || node.tagName === 'TEXTAREA')
|
|
8877
11891
|
? node
|
|
8878
11892
|
: null;
|
|
8879
11893
|
const onPointerDown = (event) => {
|
|
@@ -8881,18 +11895,18 @@ function installKeyboardGuard(ctx) {
|
|
|
8881
11895
|
// composer control (slash-command toggle, attach, model menu, send)
|
|
8882
11896
|
// must not: several of them refocus the input as a side effect, which
|
|
8883
11897
|
// popped the keyboard on every command-button tap.
|
|
8884
|
-
if (
|
|
11898
|
+
if (composerField(event.target) !== null) {
|
|
8885
11899
|
lastIntent = Date.now();
|
|
8886
11900
|
granted = true;
|
|
8887
11901
|
}
|
|
8888
11902
|
};
|
|
8889
11903
|
const onKeyDown = () => {
|
|
8890
11904
|
lastIntent = Date.now();
|
|
8891
|
-
if (
|
|
11905
|
+
if (composerField(document.activeElement) !== null)
|
|
8892
11906
|
granted = true;
|
|
8893
11907
|
};
|
|
8894
11908
|
const sweep = () => {
|
|
8895
|
-
const el =
|
|
11909
|
+
const el = composerField(document.activeElement);
|
|
8896
11910
|
if (el === null)
|
|
8897
11911
|
return;
|
|
8898
11912
|
if (granted || Date.now() - lastIntent < INTENT_WINDOW_MS) {
|
|
@@ -8902,12 +11916,12 @@ function installKeyboardGuard(ctx) {
|
|
|
8902
11916
|
el.blur();
|
|
8903
11917
|
};
|
|
8904
11918
|
const onFocusIn = (event) => {
|
|
8905
|
-
if (
|
|
11919
|
+
if (composerField(event.target) === null)
|
|
8906
11920
|
return;
|
|
8907
11921
|
sweep();
|
|
8908
11922
|
};
|
|
8909
11923
|
const onFocusOut = (event) => {
|
|
8910
|
-
if (
|
|
11924
|
+
if (composerField(event.target) === null)
|
|
8911
11925
|
return;
|
|
8912
11926
|
granted = false;
|
|
8913
11927
|
};
|
|
@@ -9146,9 +12160,16 @@ function installKeyboardAvoid(ctx) {
|
|
|
9146
12160
|
tuning = loaded.keyboard;
|
|
9147
12161
|
pad = safetyPad(navigator.userAgent, tuning.safetyPadPx);
|
|
9148
12162
|
});
|
|
9149
|
-
|
|
12163
|
+
/** The composer's editing host: the `data-composer-input` contenteditable
|
|
12164
|
+
* DSH 0.1.5 binds Lexical to, or the textarea of older hosts (the
|
|
12165
|
+
* attribute sat on that textarea too). Same recognition as S9's guard —
|
|
12166
|
+
* while this checked TEXTAREA only, the whole focus machinery below was
|
|
12167
|
+
* blind to the 0.1.5 contenteditable and the dumb-keyboard estimate
|
|
12168
|
+
* never ran. */
|
|
12169
|
+
const composerField = (node) => node instanceof HTMLElement && node.closest(COMPOSER) !== null
|
|
12170
|
+
&& (node.hasAttribute('data-composer-input') || node.tagName === 'TEXTAREA');
|
|
9150
12171
|
const sync = () => {
|
|
9151
|
-
const focused =
|
|
12172
|
+
const focused = composerField(document.activeElement);
|
|
9152
12173
|
if (!focused)
|
|
9153
12174
|
baseline = viewport.height;
|
|
9154
12175
|
const geometric = keyboardLift({
|
|
@@ -9194,7 +12215,7 @@ function installKeyboardAvoid(ctx) {
|
|
|
9194
12215
|
sync();
|
|
9195
12216
|
}
|
|
9196
12217
|
const el = document.activeElement;
|
|
9197
|
-
if (
|
|
12218
|
+
if (composerField(el) && el instanceof HTMLElement)
|
|
9198
12219
|
el.blur();
|
|
9199
12220
|
};
|
|
9200
12221
|
/** True when a touch outside the composer means "the reader moved on". */
|
|
@@ -9217,7 +12238,7 @@ function installKeyboardAvoid(ctx) {
|
|
|
9217
12238
|
retract();
|
|
9218
12239
|
};
|
|
9219
12240
|
const onFocusIn = (event) => {
|
|
9220
|
-
if (!
|
|
12241
|
+
if (!composerField(event.target))
|
|
9221
12242
|
return;
|
|
9222
12243
|
// Focus not born from a touch (hardware keyboard, programmatic) raises
|
|
9223
12244
|
// no on-screen keyboard — never estimate for it.
|
|
@@ -9257,7 +12278,7 @@ function installKeyboardAvoid(ctx) {
|
|
|
9257
12278
|
// Keyboard is up (touch focus on a phone) yet the viewport never
|
|
9258
12279
|
// reacted: the browser cannot see it. Estimate until blur, and
|
|
9259
12280
|
// remember the verdict so the next focus skips the wait.
|
|
9260
|
-
if (!
|
|
12281
|
+
if (!composerField(document.activeElement))
|
|
9261
12282
|
return;
|
|
9262
12283
|
localStorage.setItem(DUMB_KEY, '1');
|
|
9263
12284
|
estimate = estimatedLift(window.innerHeight, tuning);
|
|
@@ -9266,7 +12287,7 @@ function installKeyboardAvoid(ctx) {
|
|
|
9266
12287
|
probeTimer = window.setTimeout(step, PROBE_INTERVAL_MS);
|
|
9267
12288
|
};
|
|
9268
12289
|
const onFocusOut = (event) => {
|
|
9269
|
-
if (!
|
|
12290
|
+
if (!composerField(event.target))
|
|
9270
12291
|
return;
|
|
9271
12292
|
stopProbe();
|
|
9272
12293
|
if (estimate === 0)
|
|
@@ -9317,6 +12338,288 @@ function installKeyboardAvoid(ctx) {
|
|
|
9317
12338
|
}, 'dsh-mobile-nav: composer keyboard avoid');
|
|
9318
12339
|
}
|
|
9319
12340
|
};
|
|
12341
|
+
__modules["share/share-preview.js"] = function (require, module, exports) {
|
|
12342
|
+
"use strict";
|
|
12343
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
12344
|
+
exports.SharePreview = SharePreview;
|
|
12345
|
+
const jsx_runtime_1 = require("react/jsx-runtime");
|
|
12346
|
+
/**
|
|
12347
|
+
* Debug preview for the share-card template (ticket 03) + PNG export entry
|
|
12348
|
+
* (ticket 04, single long image since ticket 07): ?mobile-nav-share-preview=1
|
|
12349
|
+
* renders the card at the top of the page so the layout can be eyeballed at
|
|
12350
|
+
* any viewport, and the caption bar's 导出 PNG button drives the full
|
|
12351
|
+
* slice→rasterize→stitch pipeline, downloading ONE stitched PNG — the
|
|
12352
|
+
* acceptance path before ticket 05 wires the real UI.
|
|
12353
|
+
*
|
|
12354
|
+
* Debug-surface conventions follow debug.ts: the entry only mounts with the
|
|
12355
|
+
* URL param (the slot entry is not even registered without it — zero impact
|
|
12356
|
+
* otherwise, the same exemption ?mobile-nav-inset= enjoys), and its chrome
|
|
12357
|
+
* copy is hardcoded Chinese rather than locale keys, because it never ships
|
|
12358
|
+
* to end users. Only the card's own baked-in strings go through `t`.
|
|
12359
|
+
*
|
|
12360
|
+
* Data comes through fetch-share.ts (ticket 05) — the same typed fetch,
|
|
12361
|
+
* query builder and error taxonomy the real share flow uses; only the error
|
|
12362
|
+
* SURFACE differs (this panel folds any failure back to the fixture and
|
|
12363
|
+
* prints the code, the real UI maps codes to locale copy).
|
|
12364
|
+
*/
|
|
12365
|
+
const react_1 = require("react");
|
|
12366
|
+
const types_ts_1 = require("./compat/types.js");
|
|
12367
|
+
const fetch_share_ts_1 = require("./share/fetch-share.js");
|
|
12368
|
+
const fetch_share_ts_2 = require("./share/fetch-share.js");
|
|
12369
|
+
const png_stitch_ts_1 = require("./share/png-stitch.js");
|
|
12370
|
+
const rasterize_ts_1 = require("./share/rasterize.js");
|
|
12371
|
+
const share_card_tsx_1 = require("./share/share-card.js");
|
|
12372
|
+
const share_flow_ts_1 = require("./share/share-flow.js");
|
|
12373
|
+
/* ---- fixture ---------------------------------------------------------------
|
|
12374
|
+
* Exercises every block kind the template knows, and since ticket 09 every
|
|
12375
|
+
* Markdown construct the assistant path renders: headings, inline emphasis /
|
|
12376
|
+
* code / links / escapes, fenced code with a language tag and a deliberately
|
|
12377
|
+
* over-long line (wide-content acceptance), an aligned pipe table, block
|
|
12378
|
+
* quote, ordered + nested unordered lists, thematic break, indented code, a
|
|
12379
|
+
* Markdown image line, and image placeholders. Fixed timestamp so the header
|
|
12380
|
+
* is deterministic between reloads. */
|
|
12381
|
+
const FIXTURE_CREATED_AT = Date.UTC(2026, 8, 10, 4, 0); // 2026-09-10 12:00 +08:00
|
|
12382
|
+
const FIXTURE_TITLE = '示例会话 · 网关错误率报表';
|
|
12383
|
+
const FIXTURE_TURNS = [
|
|
12384
|
+
{
|
|
12385
|
+
role: 'user',
|
|
12386
|
+
seq: 1,
|
|
12387
|
+
blocks: [{ kind: 'text', text: '帮我把这份原始日志清洗成表格,算出每个服务的错误率,再给一个可以复用的脚本。' }],
|
|
12388
|
+
},
|
|
12389
|
+
{
|
|
12390
|
+
role: 'assistant',
|
|
12391
|
+
seq: 2,
|
|
12392
|
+
blocks: [
|
|
12393
|
+
{
|
|
12394
|
+
kind: 'text',
|
|
12395
|
+
text: [
|
|
12396
|
+
'清洗结果如下(错误率 = 错误 / 请求),口径见 [统计说明](#stats):',
|
|
12397
|
+
'',
|
|
12398
|
+
'## 服务错误率',
|
|
12399
|
+
'',
|
|
12400
|
+
'| 服务 | 请求 | 错误 | 错误率 | 备注 |',
|
|
12401
|
+
'| :--- | ---: | ---: | :---: | --- |',
|
|
12402
|
+
'| lan-gate | 12040 | 36 | 0.30% | 配对墙拦截计入 |',
|
|
12403
|
+
'| api-gateway | 98213 | 121 | 0.12% | 5xx 全量 |',
|
|
12404
|
+
'| web-ui-static | 330912 | 2 | 0.00% | CDN 回源 |',
|
|
12405
|
+
'',
|
|
12406
|
+
'复用脚本(长行不裁剪,超出卡片宽是预期行为,切片宽度自适应交给栅格化):',
|
|
12407
|
+
'',
|
|
12408
|
+
'```python',
|
|
12409
|
+
'def error_rate(total_requests: int, errors: int) -> float:',
|
|
12410
|
+
' return errors / total_requests if total_requests else 0.0',
|
|
12411
|
+
'print(f"{error_rate(98213, 121):.2%}") # -> 0.12%',
|
|
12412
|
+
'```',
|
|
12413
|
+
'',
|
|
12414
|
+
'### 排版要点',
|
|
12415
|
+
'',
|
|
12416
|
+
'行内支持 **粗体**、*斜体*、***粗斜体***、~~删除线~~、`行内代码`、[链接着色](https://example.com/docs) 与转义 \\*字面星号\\*。',
|
|
12417
|
+
'',
|
|
12418
|
+
'无序清单(两级缩进):',
|
|
12419
|
+
'',
|
|
12420
|
+
'- 只取近 24 小时的聚合数据',
|
|
12421
|
+
' - 请求量不足 1000 的服务不参与排名',
|
|
12422
|
+
' - 按错误率降序输出',
|
|
12423
|
+
'- 长行项目照常参与宽度测量',
|
|
12424
|
+
'',
|
|
12425
|
+
'有序步骤:',
|
|
12426
|
+
'',
|
|
12427
|
+
'1. 拉取聚合指标',
|
|
12428
|
+
'2. 计算错误率',
|
|
12429
|
+
' 1. 过滤零流量服务',
|
|
12430
|
+
' 2. 保留两位小数',
|
|
12431
|
+
'3. 输出报表',
|
|
12432
|
+
'',
|
|
12433
|
+
'> 引用块:以上口径与 [监控面板](https://example.com/monitor) 一致,',
|
|
12434
|
+
'> 以 5 分钟粒度聚合,空窗期按 0 处理。',
|
|
12435
|
+
'',
|
|
12436
|
+
'---',
|
|
12437
|
+
'',
|
|
12438
|
+
'另外附一个历史口径的配置片段(缩进代码):',
|
|
12439
|
+
'',
|
|
12440
|
+
' window_seconds = 86400',
|
|
12441
|
+
' min_requests = 1000',
|
|
12442
|
+
'',
|
|
12443
|
+
'架构示意图:',
|
|
12444
|
+
'',
|
|
12445
|
+
'',
|
|
12446
|
+
].join('\n'),
|
|
12447
|
+
},
|
|
12448
|
+
],
|
|
12449
|
+
},
|
|
12450
|
+
{
|
|
12451
|
+
role: 'user',
|
|
12452
|
+
seq: 3,
|
|
12453
|
+
blocks: [
|
|
12454
|
+
{ kind: 'text', text: '很好。再把现在的部署架构画一张图给我。' },
|
|
12455
|
+
{ kind: 'image' },
|
|
12456
|
+
],
|
|
12457
|
+
},
|
|
12458
|
+
{
|
|
12459
|
+
role: 'assistant',
|
|
12460
|
+
seq: 4,
|
|
12461
|
+
blocks: [
|
|
12462
|
+
{ kind: 'text', text: '架构图放在这里了(分享图 v1 中图片附件以占位呈现):' },
|
|
12463
|
+
{ kind: 'image' },
|
|
12464
|
+
{ kind: 'text', text: '需要我把表格导出成 CSV 的话说一声。' },
|
|
12465
|
+
],
|
|
12466
|
+
},
|
|
12467
|
+
];
|
|
12468
|
+
const FIXTURE_STATE = {
|
|
12469
|
+
source: 'fixture',
|
|
12470
|
+
turns: FIXTURE_TURNS,
|
|
12471
|
+
createdAt: FIXTURE_CREATED_AT,
|
|
12472
|
+
error: null,
|
|
12473
|
+
};
|
|
12474
|
+
/**
|
|
12475
|
+
* Share-card preview panel. Fixed at the top of the overlay layer, scrollable
|
|
12476
|
+
* (long transcripts must be inspectable), all-inline-styled like the card —
|
|
12477
|
+
* this feature adds no page CSS by design (PLAN §9.4).
|
|
12478
|
+
*/
|
|
12479
|
+
function SharePreview({ useSessions, t }) {
|
|
12480
|
+
const [gone, setGone] = (0, react_1.useState)(false);
|
|
12481
|
+
const [state, setState] = (0, react_1.useState)(FIXTURE_STATE);
|
|
12482
|
+
const [exportBusy, setExportBusy] = (0, react_1.useState)(null);
|
|
12483
|
+
const [exportNote, setExportNote] = (0, react_1.useState)(null);
|
|
12484
|
+
const cardHostRef = (0, react_1.useRef)(null);
|
|
12485
|
+
const currentId = useSessions((s) => s.current);
|
|
12486
|
+
const row = useSessions((s) => (s.current === undefined ? undefined : s.byId[s.current]));
|
|
12487
|
+
// Try the real route whenever a session is open; any failure (route not
|
|
12488
|
+
// mounted on this host, network, bad body) silently reverts to the fixture
|
|
12489
|
+
// — the preview must always render something. The fetch itself is the
|
|
12490
|
+
// production one (fetch-share.ts), so this exercises the exact query and
|
|
12491
|
+
// validation the real share flow sends.
|
|
12492
|
+
(0, react_1.useEffect)(() => {
|
|
12493
|
+
if (currentId === undefined) {
|
|
12494
|
+
setState(FIXTURE_STATE);
|
|
12495
|
+
return;
|
|
12496
|
+
}
|
|
12497
|
+
let cancelled = false;
|
|
12498
|
+
(0, fetch_share_ts_1.fetchShareExport)(currentId, { kind: 'all' })
|
|
12499
|
+
.then((data) => {
|
|
12500
|
+
if (cancelled)
|
|
12501
|
+
return;
|
|
12502
|
+
setState({ source: 'live', turns: data.turns, createdAt: data.createdAt, error: null });
|
|
12503
|
+
})
|
|
12504
|
+
.catch((err) => {
|
|
12505
|
+
if (cancelled)
|
|
12506
|
+
return;
|
|
12507
|
+
// Debug affordance: the typed code (+HTTP status) says WHICH path
|
|
12508
|
+
// failed; the real UI maps these codes to locale copy instead.
|
|
12509
|
+
const detail = err instanceof fetch_share_ts_2.ShareFetchError
|
|
12510
|
+
? `${err.code}${err.status === undefined ? '' : ` (HTTP ${err.status})`}`
|
|
12511
|
+
: err instanceof Error ? err.message : String(err);
|
|
12512
|
+
setState({ ...FIXTURE_STATE, error: detail });
|
|
12513
|
+
});
|
|
12514
|
+
return () => {
|
|
12515
|
+
cancelled = true;
|
|
12516
|
+
};
|
|
12517
|
+
}, [currentId]);
|
|
12518
|
+
if (gone)
|
|
12519
|
+
return null;
|
|
12520
|
+
// Full pipeline on the live card element: probe → measure → plan (unified
|
|
12521
|
+
// when the browser can stitch) → paint → stitch, then a single long-image
|
|
12522
|
+
// download (ticket 07 — the caption notes the final dimensions); browsers
|
|
12523
|
+
// without CompressionStream keep the per-slice downloads.
|
|
12524
|
+
const runExport = async () => {
|
|
12525
|
+
const card = cardHostRef.current?.querySelector('[data-share-card]') ?? null;
|
|
12526
|
+
if (card === null) {
|
|
12527
|
+
setExportNote('导出失败:未找到分享卡元素');
|
|
12528
|
+
return;
|
|
12529
|
+
}
|
|
12530
|
+
setExportBusy('0/?');
|
|
12531
|
+
setExportNote(null);
|
|
12532
|
+
try {
|
|
12533
|
+
const stitch = (0, png_stitch_ts_1.supportsPngStitch)();
|
|
12534
|
+
const result = await (0, rasterize_ts_1.rasterizeShareCard)(card, {
|
|
12535
|
+
pixelRatioCap: Math.min(window.devicePixelRatio || 1, 2),
|
|
12536
|
+
truncatedNote: (droppedTurns) => t('shareTruncatedNote', { count: droppedTurns }),
|
|
12537
|
+
onProgress: (done, total) => setExportBusy(`${done}/${total}`),
|
|
12538
|
+
unified: stitch,
|
|
12539
|
+
});
|
|
12540
|
+
const tail = result.truncated ? `,已省略前 ${result.droppedTurns} 轮` : '';
|
|
12541
|
+
if (stitch && result.stitchWidth !== undefined) {
|
|
12542
|
+
const png = await (0, png_stitch_ts_1.stitchSliceBlobs)(result.slices, result.stitchWidth);
|
|
12543
|
+
const height = result.slices.reduce((sum, slice) => sum + slice.height, 0);
|
|
12544
|
+
await (0, share_flow_ts_1.downloadBlobSequence)([{ blob: png, name: 'share-card.png' }]);
|
|
12545
|
+
setExportNote(`已导出长图 ${result.stitchWidth}×${height}(${result.slices.length} 片拼接,像素比 ${result.pixelRatio}${tail})`);
|
|
12546
|
+
}
|
|
12547
|
+
else {
|
|
12548
|
+
// The same paced anchor-download sequence the real delivery falls
|
|
12549
|
+
// back to (review 07+08 cleanup — this was a third inline copy).
|
|
12550
|
+
await (0, share_flow_ts_1.downloadBlobSequence)(result.slices.map((slice, index) => ({ blob: slice.blob, name: (0, share_flow_ts_1.shareSliceFileName)('share-card', index) })));
|
|
12551
|
+
setExportNote(`已导出 ${result.slices.length} 张分片(浏览器不支持拼接,像素比 ${result.pixelRatio}${tail})`);
|
|
12552
|
+
}
|
|
12553
|
+
console.info('[share-preview] rasterized', result);
|
|
12554
|
+
}
|
|
12555
|
+
catch (err) {
|
|
12556
|
+
console.error('[share-preview] PNG export failed', err);
|
|
12557
|
+
setExportNote(`导出失败:${err instanceof Error ? err.message : String(err)}`);
|
|
12558
|
+
}
|
|
12559
|
+
finally {
|
|
12560
|
+
setExportBusy(null);
|
|
12561
|
+
}
|
|
12562
|
+
};
|
|
12563
|
+
const copy = {
|
|
12564
|
+
turnsLabel: (count) => t('shareCardTurns', { count }),
|
|
12565
|
+
generatedBy: t('shareCardGeneratedBy'),
|
|
12566
|
+
image: t('shareCardImage'),
|
|
12567
|
+
};
|
|
12568
|
+
const preset = (0, types_ts_1.agentPresetOf)(row);
|
|
12569
|
+
return ((0, jsx_runtime_1.jsxs)("div", { style: PANEL_STYLE, children: [(0, jsx_runtime_1.jsxs)("div", { style: CAPTION_STYLE, children: [(0, jsx_runtime_1.jsxs)("span", { children: ["\u5206\u4EAB\u5361\u9884\u89C8\uFF08?mobile-nav-share-preview=1\uFF09 \u00B7 ", state.source === 'live' ? '会话数据' : 'fixture 数据', state.error !== null ? ` · 拉取失败:${state.error}` : ''] }), (0, jsx_runtime_1.jsx)("button", { type: "button", style: EXPORT_STYLE, disabled: exportBusy !== null, onClick: () => { void runExport(); }, children: exportBusy !== null ? `导出中 ${exportBusy}` : '导出 PNG' }), exportNote !== null
|
|
12570
|
+
? (0, jsx_runtime_1.jsx)("span", { style: { overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }, children: exportNote })
|
|
12571
|
+
: null, (0, jsx_runtime_1.jsx)("button", { type: "button", style: CLOSE_STYLE, onClick: () => setGone(true), children: "\u2715" })] }), (0, jsx_runtime_1.jsx)("div", { ref: cardHostRef, style: { padding: '16px', display: 'flex', justifyContent: 'center', minHeight: '120px' }, children: (0, jsx_runtime_1.jsx)(share_card_tsx_1.ShareCard, { title: state.source === 'live' ? (row?.displayTitle ?? FIXTURE_TITLE) : FIXTURE_TITLE, ...(preset !== undefined ? { subtitle: preset } : {}), ...(state.createdAt !== undefined ? { createdAt: state.createdAt } : {}), turns: state.turns, copy: copy }) })] }));
|
|
12572
|
+
}
|
|
12573
|
+
/* Debug chrome styles (inline like everything else in this feature). */
|
|
12574
|
+
const PANEL_STYLE = {
|
|
12575
|
+
position: 'absolute',
|
|
12576
|
+
top: 0,
|
|
12577
|
+
left: 0,
|
|
12578
|
+
right: 0,
|
|
12579
|
+
maxHeight: '100%',
|
|
12580
|
+
overflow: 'auto',
|
|
12581
|
+
zIndex: 30,
|
|
12582
|
+
// The shell overlay layer is pointer-events:none with pointer-events:auto
|
|
12583
|
+
// restored on children; explicit keeps this panel usable regardless.
|
|
12584
|
+
pointerEvents: 'auto',
|
|
12585
|
+
background: 'rgba(20, 22, 26, 0.08)',
|
|
12586
|
+
};
|
|
12587
|
+
const CAPTION_STYLE = {
|
|
12588
|
+
position: 'sticky',
|
|
12589
|
+
top: 0,
|
|
12590
|
+
zIndex: 1,
|
|
12591
|
+
display: 'flex',
|
|
12592
|
+
alignItems: 'center',
|
|
12593
|
+
gap: '8px',
|
|
12594
|
+
padding: '6px 12px',
|
|
12595
|
+
background: 'rgba(15, 17, 21, 0.82)',
|
|
12596
|
+
color: '#fff',
|
|
12597
|
+
font: '12px/1.6 ui-monospace, SFMono-Regular, Menlo, monospace',
|
|
12598
|
+
};
|
|
12599
|
+
const CLOSE_STYLE = {
|
|
12600
|
+
marginLeft: '8px',
|
|
12601
|
+
border: 'none',
|
|
12602
|
+
borderRadius: '6px',
|
|
12603
|
+
background: 'rgba(255, 255, 255, 0.14)',
|
|
12604
|
+
color: '#fff',
|
|
12605
|
+
font: 'inherit',
|
|
12606
|
+
lineHeight: 1,
|
|
12607
|
+
padding: '4px 8px',
|
|
12608
|
+
cursor: 'pointer',
|
|
12609
|
+
};
|
|
12610
|
+
const EXPORT_STYLE = {
|
|
12611
|
+
marginLeft: 'auto',
|
|
12612
|
+
border: 'none',
|
|
12613
|
+
borderRadius: '6px',
|
|
12614
|
+
background: 'rgba(255, 255, 255, 0.22)',
|
|
12615
|
+
color: '#fff',
|
|
12616
|
+
font: 'inherit',
|
|
12617
|
+
lineHeight: 1,
|
|
12618
|
+
padding: '4px 8px',
|
|
12619
|
+
cursor: 'pointer',
|
|
12620
|
+
whiteSpace: 'nowrap',
|
|
12621
|
+
};
|
|
12622
|
+
};
|
|
9320
12623
|
__modules["index.js"] = function (require, module, exports) {
|
|
9321
12624
|
"use strict";
|
|
9322
12625
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
@@ -9345,6 +12648,7 @@ const native_trigger_overlay_ts_1 = require("./effects/native-trigger-overlay.js
|
|
|
9345
12648
|
const welcome_notice_ts_1 = require("./effects/welcome-notice.js");
|
|
9346
12649
|
const keyboard_guard_ts_1 = require("./effects/keyboard-guard.js");
|
|
9347
12650
|
const keyboard_avoid_ts_1 = require("./effects/keyboard-avoid.js");
|
|
12651
|
+
const share_preview_tsx_1 = require("./share/share-preview.js");
|
|
9348
12652
|
const locales_ts_1 = require("./locales.js");
|
|
9349
12653
|
/** Required services (cordis fiber inject — the loader passes all module exports as an object plugin). */
|
|
9350
12654
|
exports.inject = ['slots', 'layout', 'locale', 'sessionLogDownload', 'sessions', 'workspaces'];
|
|
@@ -9562,6 +12866,20 @@ function apply(ctx) {
|
|
|
9562
12866
|
toggleSidebar: () => ctx.layout.toggleSidebar(),
|
|
9563
12867
|
}),
|
|
9564
12868
|
}, MobileDrawerFooter_tsx_1.MobileDrawerFooter));
|
|
12869
|
+
// Share-card debug preview (ticket 03): renders the share-card template at
|
|
12870
|
+
// the top of the page so its layout is inspectable before the rasterizer
|
|
12871
|
+
// (ticket 04) exists. Registered ONLY with the URL param, so the no-param
|
|
12872
|
+
// path — including desktop — stays bit-for-bit untouched (same debug-param
|
|
12873
|
+
// exemption as ?mobile-nav-inset=, see debug.ts).
|
|
12874
|
+
if (new URLSearchParams(location.search).has('mobile-nav-share-preview')) {
|
|
12875
|
+
ctx.slots.inject('shell.overlay', () => ctx.slots.register({
|
|
12876
|
+
name: 'shell.overlay',
|
|
12877
|
+
id: 'share-preview',
|
|
12878
|
+
// Above the phone home screen (order 20) within the same overlay layer.
|
|
12879
|
+
order: 30,
|
|
12880
|
+
locale: locales_ts_1.NS,
|
|
12881
|
+
}, share_preview_tsx_1.SharePreview));
|
|
12882
|
+
}
|
|
9565
12883
|
}
|
|
9566
12884
|
};
|
|
9567
12885
|
var __cache = {};
|