@marimo-team/islands 0.20.5-dev3 → 0.20.5-dev33
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/dist/{Combination-Du-o_hC9.js → Combination-Dk6JxauT.js} +1 -1
- package/dist/{ConnectedDataExplorerComponent-DUS-zJoR.js → ConnectedDataExplorerComponent-B07FkeWC.js} +10 -10
- package/dist/{any-language-editor-BL9o7y0_.js → any-language-editor-BIj11a2e.js} +19 -19
- package/dist/{architectureDiagram-VXUJARFQ-DrJeyFHq.js → architectureDiagram-VXUJARFQ-IZt4NuSd.js} +5 -5
- package/dist/{blockDiagram-VD42YOAC-BJrP6qKc.js → blockDiagram-VD42YOAC-mhFHC3Ty.js} +5 -5
- package/dist/{button-KYalaJYu.js → button-DQpBib29.js} +24 -11
- package/dist/{c4Diagram-YG6GDRKO-Bo4gytQ5.js → c4Diagram-YG6GDRKO-BzStmvfT.js} +4 -4
- package/dist/{channel-IWLGkaBE.js → channel-CUFaIkTh.js} +1 -1
- package/dist/{check-C50jsehH.js → check-DpqPQmzz.js} +1 -1
- package/dist/{chunk-ABZYJK2D-CRwanrkd.js → chunk-ABZYJK2D-7QYXAAhe.js} +1 -1
- package/dist/{chunk-ATLVNIR6-CMMCMvOK.js → chunk-ATLVNIR6-pmHPAPSd.js} +1 -1
- package/dist/{chunk-B4BG7PRW-BNsHrGHG.js → chunk-B4BG7PRW-C9mfKT9i.js} +4 -4
- package/dist/{chunk-DI55MBZ5-DQeYbfMV.js → chunk-DI55MBZ5-IKrK49rX.js} +4 -4
- package/dist/{chunk-EXTU4WIE-CV_DQeaX.js → chunk-EXTU4WIE-BRFl4iNd.js} +1 -1
- package/dist/{chunk-JA3XYJ7Z-Cmt--e0q.js → chunk-JA3XYJ7Z-C9q_MXZQ.js} +2 -2
- package/dist/{chunk-JZLCHNYA-CkyMJnI9.js → chunk-JZLCHNYA-DVjoFib5.js} +4 -4
- package/dist/{chunk-N4CR4FBY-BJfHtJbD.js → chunk-N4CR4FBY-BYr5N5mX.js} +5 -5
- package/dist/{chunk-QN33PNHL-WOLIPUAJ.js → chunk-QN33PNHL-CXfJywHv.js} +1 -1
- package/dist/{chunk-QXUST7PY-DYuD50pU.js → chunk-QXUST7PY-YO0PM8b3.js} +5 -5
- package/dist/{chunk-S3R3BYOJ-CsnX6RKs.js → chunk-S3R3BYOJ-DgI4FlvW.js} +1 -1
- package/dist/{chunk-TZMSLE5B-B3eYTGCw.js → chunk-TZMSLE5B-DSfBOnzx.js} +1 -1
- package/dist/{classDiagram-2ON5EDUG-C7C-oefv.js → classDiagram-2ON5EDUG-CvpnTWzz.js} +10 -10
- package/dist/{classDiagram-v2-WZHVMYZB-UTw37Gg8.js → classDiagram-v2-WZHVMYZB-DEQrBHLI.js} +10 -10
- package/dist/{copy-oc-FcZzt.js → copy-BkBF0Xgk.js} +2 -2
- package/dist/{dagre-6UL2VRFP-BgsUhJrV.js → dagre-6UL2VRFP-DC-emrm5.js} +7 -7
- package/dist/{diagram-PSM6KHXK-BIUUOfKo.js → diagram-PSM6KHXK-BAgNlpL8.js} +6 -6
- package/dist/{diagram-QEK2KX5R-BFjolZQv.js → diagram-QEK2KX5R-BM7QE5WA.js} +4 -4
- package/dist/{diagram-S2PKOQOG-4jfkWoZw.js → diagram-S2PKOQOG-qs4mB1gW.js} +4 -4
- package/dist/dist-B4MxkKHf.js +8 -0
- package/dist/{dist-De9X_Des.js → dist-B9EjSb9T.js} +1 -1
- package/dist/{dist-IW_ARJ3S.js → dist-BFxYppVR.js} +4 -4
- package/dist/{dist-D7ZGWV_9.js → dist-BGZ7TWS9.js} +3 -3
- package/dist/{dist-CwtEWuFb.js → dist-BSfYc7vq.js} +2 -2
- package/dist/{dist-DMS81OrU.js → dist-BUrWeMEP.js} +1 -1
- package/dist/dist-BYghZv6b.js +5 -0
- package/dist/dist-Be-uQhz5.js +6 -0
- package/dist/{dist-Ch_JuCvc.js → dist-BpMlUdNO.js} +3 -3
- package/dist/{dist-C6z8U-ms.js → dist-Bq5eYK43.js} +2 -2
- package/dist/{dist-BFL9TlzD.js → dist-Bq9zYwJs.js} +5 -5
- package/dist/{dist-7ZF--V_D.js → dist-C4K7pumm.js} +2 -2
- package/dist/{dist-Qjf6pcqK.js → dist-CAKwXCWI.js} +2 -2
- package/dist/dist-CB_xf0ju.js +5 -0
- package/dist/{dist-BwQHkjA9.js → dist-CDHl2i1x.js} +4 -4
- package/dist/dist-CK0qFAbF.js +8 -0
- package/dist/{dist-C4XMUaob.js → dist-CPlGUbk-.js} +2 -2
- package/dist/{dist-BT6_J2eq.js → dist-CSEWGuDq.js} +7 -2
- package/dist/dist-CYEk-qrr.js +8 -0
- package/dist/{dist-CYo3w-nC.js → dist-Cl5iM8xL.js} +3 -3
- package/dist/dist-CmKoWpMk.js +5 -0
- package/dist/{dist-I8MQW60_.js → dist-CseYuPtL.js} +2 -2
- package/dist/dist-D1nf4IQl.js +5 -0
- package/dist/{dist-CsqiXw7J.js → dist-D4gcY469.js} +2 -2
- package/dist/{dist-DUxS2paD.js → dist-D5NMgbbv.js} +2 -2
- package/dist/{dist-UYm1IE5s.js → dist-DERtJN02.js} +2 -2
- package/dist/{dist-CFToYDWO.js → dist-DEj2X26M.js} +2 -2
- package/dist/{dist-BuapEdlD.js → dist-DOoqn-VL.js} +70 -67
- package/dist/{dist-BLThQiU4.js → dist-DUretbKK.js} +2 -2
- package/dist/{dist-DEFZ7dnD.js → dist-D_-CGmlh.js} +2 -2
- package/dist/dist-Df3AcKpt.js +6 -0
- package/dist/dist-DgaFHt_I.js +5 -0
- package/dist/dist-Dk10C3ui.js +5 -0
- package/dist/{dist-D0f6Yrrb.js → dist-DodLQWPg.js} +1 -1
- package/dist/dist-DtyPVMHR.js +5 -0
- package/dist/{dist-Cb3cLT39.js → dist-HoZO6brh.js} +2 -2
- package/dist/{dist-Cqpjy6bK.js → dist-RNGn_-uD.js} +1 -1
- package/dist/{dist-BBcqvpvP.js → dist-Ux6dL_VB.js} +1 -1
- package/dist/{dist-B8Y11RWn.js → dist-WIWVvdBh.js} +2 -2
- package/dist/{dist-CB6qhQ8K.js → dist-gc9KgJuA.js} +1 -1
- package/dist/{dist-ovDpXuSB.js → dist-i-ud9aCA.js} +1 -1
- package/dist/dist-ko7WnHAO.js +5 -0
- package/dist/{dist-BTQbjEKU.js → dist-lNe4i1Nm.js} +1 -1
- package/dist/dist-of7gLRFK.js +8 -0
- package/dist/{erDiagram-Q2GNP2WA-Cq5Bz5lG.js → erDiagram-Q2GNP2WA-Dh5nhgY3.js} +10 -10
- package/dist/{error-banner-D0tXnwl4.js → error-banner-BctofTCP.js} +2 -2
- package/dist/{esm-BxMbHo0y.js → esm-BBkPJL8N.js} +29 -27
- package/dist/{flowDiagram-NV44I4VS-6WPJVFl7.js → flowDiagram-NV44I4VS-ChR1Vbmj.js} +10 -10
- package/dist/{ganttDiagram-JELNMOA3-AfDhh9CI.js → ganttDiagram-JELNMOA3-sK0z-5KM.js} +3 -3
- package/dist/{gitGraphDiagram-V2S2FVAM-BRSwuj0Q.js → gitGraphDiagram-V2S2FVAM-9S1VqQrL.js} +3 -3
- package/dist/{glide-data-editor-ByPNTNVG.js → glide-data-editor-DI5VFwRB.js} +63 -63
- package/dist/{infoDiagram-HS3SLOUP-Cmxo6jKx.js → infoDiagram-HS3SLOUP-C5A8b-2O.js} +3 -3
- package/dist/{journeyDiagram-XKPGCS4Q-CKYr8cSR.js → journeyDiagram-XKPGCS4Q-D5BIjS4N.js} +3 -3
- package/dist/{kanban-definition-3W4ZIXB7-DVvAZzQD.js → kanban-definition-3W4ZIXB7-C1vZZabj.js} +7 -7
- package/dist/{label-CV0KYhtH.js → label-Cx28eo0O.js} +5 -5
- package/dist/{loader-eJCvvApN.js → loader-C62dRCuy.js} +1 -1
- package/dist/main.js +1564 -1093
- package/dist/{mermaid-COOB_abB.js → mermaid-BgeZPIms.js} +41 -41
- package/dist/{mindmap-definition-VGOIOE7T-1ExmnvYy.js → mindmap-definition-VGOIOE7T-Cn9_H_5f.js} +9 -9
- package/dist/{pieDiagram-ADFJNKIX-CJlIsdsU.js → pieDiagram-ADFJNKIX-iA0mvRW9.js} +4 -4
- package/dist/{purify.es-CyOIw8ru.js → purify.es-DGenX2XH.js} +67 -67
- package/dist/{quadrantDiagram-AYHSOK5B-BU78RiaH.js → quadrantDiagram-AYHSOK5B-CAcVWXc-.js} +2 -2
- package/dist/{requirementDiagram-UZGBJVZJ-DACHtrFr.js → requirementDiagram-UZGBJVZJ-1HxQ6I5Z.js} +9 -9
- package/dist/{sankeyDiagram-TZEHDZUN-Bzg7_UWs.js → sankeyDiagram-TZEHDZUN-BVJnR4_b.js} +2 -2
- package/dist/{sequenceDiagram-WL72ISMW-agybEe9J.js → sequenceDiagram-WL72ISMW-ByirOtHb.js} +4 -4
- package/dist/{slides-component-B0yK5GXP.js → slides-component-DwvL_HJi.js} +2 -2
- package/dist/{spec-Dq_reDGM.js → spec-B8V2Bcbi.js} +4 -4
- package/dist/{stateDiagram-FKZM4ZOC-DehQAt8g.js → stateDiagram-FKZM4ZOC-DrYNXdQr.js} +10 -10
- package/dist/{stateDiagram-v2-4FDKWEC3-8VzeREl9.js → stateDiagram-v2-4FDKWEC3-C9CFKCSr.js} +10 -10
- package/dist/style.css +1 -1
- package/dist/{timeline-definition-IT6M3QCI-CdCfdaCF.js → timeline-definition-IT6M3QCI-D8B3p7ID.js} +2 -2
- package/dist/{tooltip-CL8m4f9y.js → tooltip-SPkubVH3.js} +3 -3
- package/dist/{types-BwnzGcE4.js → types-DqrGPzsT.js} +517 -406
- package/dist/{useAsyncData-B4hMFGnF.js → useAsyncData-Ioeh75f8.js} +1 -1
- package/dist/{useDeepCompareMemoize-DuPhOXzr.js → useDeepCompareMemoize-DtbTAJq3.js} +4 -4
- package/dist/{useIframeCapabilities-CAt6D2EI.js → useIframeCapabilities-DFGZKWkO.js} +1 -1
- package/dist/{useTheme-BNYQnvu-.js → useTheme-OvBNH9t3.js} +2 -2
- package/dist/{vega-component-DouPy8AI.js → vega-component-B_4Lp3hK.js} +8 -8
- package/dist/{xychartDiagram-PRI3JC2R-rEm_SIsC.js → xychartDiagram-PRI3JC2R-KuxgQuK9.js} +5 -5
- package/package.json +9 -9
- package/src/__mocks__/requests.ts +1 -0
- package/src/components/app-config/ai-config.tsx +10 -0
- package/src/components/data-table/pagination.tsx +36 -30
- package/src/components/datasources/components.tsx +3 -6
- package/src/components/datasources/datasources.tsx +8 -21
- package/src/components/editor/actions/types.ts +6 -1
- package/src/components/editor/actions/useNotebookActions.tsx +50 -13
- package/src/components/editor/chrome/types.ts +17 -0
- package/src/components/editor/controls/command-palette.tsx +7 -0
- package/src/components/editor/controls/keyboard-shortcuts.tsx +3 -1
- package/src/components/editor/file-tree/file-explorer.tsx +48 -62
- package/src/components/editor/file-tree/file-icons.tsx +132 -0
- package/src/components/editor/file-tree/file-viewer.tsx +1 -1
- package/src/components/editor/file-tree/tree-actions.tsx +107 -0
- package/src/components/editor/file-tree/types.ts +2 -96
- package/src/components/editor/header/filename-input.tsx +4 -1
- package/src/components/icons/marimo-icons.tsx +2 -2
- package/src/components/pages/home-page.tsx +5 -5
- package/src/components/storage/components.tsx +0 -38
- package/src/components/storage/storage-file-viewer.tsx +1 -1
- package/src/components/storage/storage-inspector.tsx +28 -45
- package/src/components/ui/command.tsx +2 -0
- package/src/components/ui/links.tsx +1 -0
- package/src/core/ai/tools/__tests__/run-cells-tool.test.ts +79 -0
- package/src/core/ai/tools/run-cells-tool.ts +63 -17
- package/src/core/hotkeys/__tests__/hotkeys.test.ts +64 -1
- package/src/core/hotkeys/hotkeys.ts +29 -3
- package/src/core/islands/bridge.ts +1 -0
- package/src/core/network/__tests__/requests-network.test.ts +17 -0
- package/src/core/network/requests-lazy.ts +1 -0
- package/src/core/network/requests-network.ts +9 -0
- package/src/core/network/requests-static.ts +1 -0
- package/src/core/network/requests-toasting.tsx +1 -0
- package/src/core/network/types.ts +1 -0
- package/src/core/wasm/bridge.ts +1 -0
- package/src/plugins/impl/FileBrowserPlugin.tsx +4 -4
- package/src/plugins/impl/mpl-interactive/MplInteractivePlugin.tsx +309 -0
- package/src/plugins/impl/mpl-interactive/__tests__/mpl-websocket-shim.test.ts +110 -0
- package/src/plugins/impl/mpl-interactive/mpl-websocket-shim.ts +57 -0
- package/src/plugins/plugins.ts +2 -0
- package/src/utils/__tests__/filenames.test.ts +7 -0
- package/src/utils/__tests__/smartMatch.test.ts +61 -0
- package/src/utils/filenames.ts +3 -0
- package/src/utils/smartMatch.ts +62 -0
- package/dist/dist-BAeGo2rp.js +0 -5
- package/dist/dist-BqwCMSEa.js +0 -5
- package/dist/dist-Bum8FwTO.js +0 -6
- package/dist/dist-C0YiOwt_.js +0 -5
- package/dist/dist-C2uPv4iU.js +0 -5
- package/dist/dist-C5hOLsJN.js +0 -8
- package/dist/dist-C9NIAKMs.js +0 -8
- package/dist/dist-CCrzTtvk.js +0 -5
- package/dist/dist-CFS9i1rS.js +0 -8
- package/dist/dist-CyHZuhPH.js +0 -5
- package/dist/dist-CzcjWdIk.js +0 -6
- package/dist/dist-DaYyUSNC.js +0 -5
- package/dist/dist-DpDcJYNh.js +0 -8
- package/dist/dist-U_BfxcPn.js +0 -5
|
@@ -27,11 +27,13 @@ export interface Hotkey {
|
|
|
27
27
|
* @default true
|
|
28
28
|
*/
|
|
29
29
|
editable?: boolean;
|
|
30
|
+
additionalKeywords?: string[];
|
|
30
31
|
}
|
|
31
32
|
|
|
32
33
|
interface ResolvedHotkey {
|
|
33
34
|
name: string;
|
|
34
35
|
key: string;
|
|
36
|
+
additionalKeywords?: string[];
|
|
35
37
|
}
|
|
36
38
|
|
|
37
39
|
type ModKey = "Cmd" | "Ctrl";
|
|
@@ -110,6 +112,7 @@ const DEFAULT_HOT_KEY = {
|
|
|
110
112
|
name: "Run",
|
|
111
113
|
group: "Running Cells",
|
|
112
114
|
key: "Mod-Enter",
|
|
115
|
+
additionalKeywords: ["execute", "submit"],
|
|
113
116
|
},
|
|
114
117
|
"cell.runAndNewBelow": {
|
|
115
118
|
name: "Run and new below",
|
|
@@ -132,6 +135,7 @@ const DEFAULT_HOT_KEY = {
|
|
|
132
135
|
name: "Format cell",
|
|
133
136
|
group: "Editing",
|
|
134
137
|
key: "Mod-b",
|
|
138
|
+
additionalKeywords: ["lint"],
|
|
135
139
|
},
|
|
136
140
|
"cell.viewAsMarkdown": {
|
|
137
141
|
name: "View as Markdown",
|
|
@@ -201,6 +205,7 @@ const DEFAULT_HOT_KEY = {
|
|
|
201
205
|
name: "Delete cell",
|
|
202
206
|
group: "Editing",
|
|
203
207
|
key: "Shift-Backspace",
|
|
208
|
+
additionalKeywords: ["remove"],
|
|
204
209
|
},
|
|
205
210
|
"cell.hideCode": {
|
|
206
211
|
name: "Hide cell code",
|
|
@@ -320,6 +325,7 @@ const DEFAULT_HOT_KEY = {
|
|
|
320
325
|
name: "Save file",
|
|
321
326
|
group: "Other",
|
|
322
327
|
key: "Mod-s",
|
|
328
|
+
additionalKeywords: ["write", "persist"],
|
|
323
329
|
},
|
|
324
330
|
"global.commandPalette": {
|
|
325
331
|
name: "Show command palette",
|
|
@@ -485,23 +491,26 @@ export class HotkeyProvider implements IHotkeyProvider {
|
|
|
485
491
|
}
|
|
486
492
|
|
|
487
493
|
getHotkey(action: HotkeyAction): ResolvedHotkey {
|
|
488
|
-
const { name, key } = this.hotkeys[action];
|
|
494
|
+
const { name, key, additionalKeywords } = this.hotkeys[action];
|
|
489
495
|
if (typeof key === "string") {
|
|
490
496
|
return {
|
|
491
497
|
name,
|
|
492
498
|
key: key.replace("Mod", this.mod),
|
|
499
|
+
additionalKeywords,
|
|
493
500
|
};
|
|
494
501
|
}
|
|
495
502
|
if (key === NOT_SET) {
|
|
496
503
|
return {
|
|
497
504
|
name,
|
|
498
505
|
key: "",
|
|
506
|
+
additionalKeywords,
|
|
499
507
|
};
|
|
500
508
|
}
|
|
501
509
|
const platformKey = key[this.platform] || key.main;
|
|
502
510
|
return {
|
|
503
511
|
name,
|
|
504
512
|
key: platformKey.replace("Mod", this.mod),
|
|
513
|
+
additionalKeywords,
|
|
505
514
|
};
|
|
506
515
|
}
|
|
507
516
|
|
|
@@ -535,10 +544,27 @@ export class OverridingHotkeyProvider extends HotkeyProvider {
|
|
|
535
544
|
|
|
536
545
|
override getHotkey(action: HotkeyAction): ResolvedHotkey {
|
|
537
546
|
const base = super.getHotkey(action);
|
|
538
|
-
const
|
|
547
|
+
const override = this.overrides[action];
|
|
539
548
|
return {
|
|
540
549
|
name: base.name,
|
|
541
|
-
key,
|
|
550
|
+
key: override ? normalizeKeyString(override) : base.key,
|
|
551
|
+
additionalKeywords: base.additionalKeywords,
|
|
542
552
|
};
|
|
543
553
|
}
|
|
544
554
|
}
|
|
555
|
+
|
|
556
|
+
const MODIFIER_RE = /^(cmd|ctrl|alt|shift|meta|mod)$/i;
|
|
557
|
+
|
|
558
|
+
/**
|
|
559
|
+
* Capitalize multi-character base key names so they match the
|
|
560
|
+
* casing that KeyboardEvent.key (and therefore CodeMirror) uses.
|
|
561
|
+
* e.g. "Shift-enter" → "Shift-Enter", "Cmd-backspace" → "Cmd-Backspace"
|
|
562
|
+
*/
|
|
563
|
+
export function normalizeKeyString(key: string): string {
|
|
564
|
+
const parts = key.split("-");
|
|
565
|
+
const last = parts[parts.length - 1];
|
|
566
|
+
if (last.length > 1 && !MODIFIER_RE.test(last)) {
|
|
567
|
+
parts[parts.length - 1] = last.charAt(0).toUpperCase() + last.slice(1);
|
|
568
|
+
}
|
|
569
|
+
return parts.join("-");
|
|
570
|
+
}
|
|
@@ -181,6 +181,7 @@ export class IslandsPyodideBridge implements RunRequests, EditRequests {
|
|
|
181
181
|
sendFileDetails = throwNotImplemented;
|
|
182
182
|
openTutorial = throwNotImplemented;
|
|
183
183
|
exportAsHTML = throwNotImplemented;
|
|
184
|
+
exportAsIPYNB = throwNotImplemented;
|
|
184
185
|
exportAsMarkdown = throwNotImplemented;
|
|
185
186
|
exportAsPDF = throwNotImplemented;
|
|
186
187
|
autoExportAsHTML = throwNotImplemented;
|
|
@@ -113,5 +113,22 @@ describe("createNetworkRequests", () => {
|
|
|
113
113
|
}),
|
|
114
114
|
);
|
|
115
115
|
});
|
|
116
|
+
|
|
117
|
+
it("exportAsIPYNB should call the new endpoint as text", async () => {
|
|
118
|
+
const requests = createNetworkRequests();
|
|
119
|
+
await requests.exportAsIPYNB({
|
|
120
|
+
download: false,
|
|
121
|
+
} as any);
|
|
122
|
+
|
|
123
|
+
expect(mockClient.POST).toHaveBeenCalledWith(
|
|
124
|
+
"/api/export/ipynb",
|
|
125
|
+
expect.objectContaining({
|
|
126
|
+
body: expect.objectContaining({
|
|
127
|
+
download: false,
|
|
128
|
+
}),
|
|
129
|
+
parseAs: "text",
|
|
130
|
+
}),
|
|
131
|
+
);
|
|
132
|
+
});
|
|
116
133
|
});
|
|
117
134
|
});
|
|
@@ -49,6 +49,7 @@ const ACTIONS: Record<keyof AllRequests, Action> = {
|
|
|
49
49
|
|
|
50
50
|
// Export operations start a connection
|
|
51
51
|
exportAsHTML: "startConnection",
|
|
52
|
+
exportAsIPYNB: "startConnection",
|
|
52
53
|
exportAsMarkdown: "startConnection",
|
|
53
54
|
exportAsPDF: "startConnection",
|
|
54
55
|
readCode: "startConnection",
|
|
@@ -381,6 +381,15 @@ export function createNetworkRequests(): EditRequests & RunRequests {
|
|
|
381
381
|
})
|
|
382
382
|
.then(handleResponse);
|
|
383
383
|
},
|
|
384
|
+
exportAsIPYNB: async (request) => {
|
|
385
|
+
return getClient()
|
|
386
|
+
.POST("/api/export/ipynb", {
|
|
387
|
+
body: request,
|
|
388
|
+
parseAs: "text",
|
|
389
|
+
params: getParams(),
|
|
390
|
+
})
|
|
391
|
+
.then(handleResponse);
|
|
392
|
+
},
|
|
384
393
|
exportAsPDF: async (request) => {
|
|
385
394
|
return getClient()
|
|
386
395
|
.POST("/api/export/pdf", {
|
|
@@ -75,6 +75,7 @@ export function createStaticRequests(): EditRequests & RunRequests {
|
|
|
75
75
|
getRunningNotebooks: throwNotInEditMode,
|
|
76
76
|
shutdownSession: throwNotInEditMode,
|
|
77
77
|
exportAsHTML: throwNotInEditMode,
|
|
78
|
+
exportAsIPYNB: throwNotInEditMode,
|
|
78
79
|
exportAsMarkdown: throwNotInEditMode,
|
|
79
80
|
exportAsPDF: throwNotInEditMode,
|
|
80
81
|
autoExportAsHTML: throwNotInEditMode,
|
|
@@ -60,6 +60,7 @@ export function createErrorToastingRequests(
|
|
|
60
60
|
getRunningNotebooks: "Failed to get running notebooks",
|
|
61
61
|
shutdownSession: "Failed to shutdown session",
|
|
62
62
|
exportAsHTML: "Failed to export HTML",
|
|
63
|
+
exportAsIPYNB: "Failed to export ipynb",
|
|
63
64
|
exportAsMarkdown: "Failed to export Markdown",
|
|
64
65
|
exportAsPDF: "Failed to export PDF",
|
|
65
66
|
autoExportAsHTML: "", // No toast
|
|
@@ -180,6 +180,7 @@ export interface EditRequests {
|
|
|
180
180
|
) => Promise<RunningNotebooksResponse>;
|
|
181
181
|
// Export requests
|
|
182
182
|
exportAsHTML: (request: ExportAsHTMLRequest) => Promise<string>;
|
|
183
|
+
exportAsIPYNB: (request: ExportAsIPYNBRequest) => Promise<string>;
|
|
183
184
|
exportAsMarkdown: (request: ExportAsMarkdownRequest) => Promise<string>;
|
|
184
185
|
exportAsPDF: (request: ExportAsPDFRequest) => Promise<Blob>;
|
|
185
186
|
autoExportAsHTML: (request: ExportAsHTMLRequest) => Promise<null>;
|
package/src/core/wasm/bridge.ts
CHANGED
|
@@ -587,6 +587,7 @@ export class PyodideBridge implements RunRequests, EditRequests {
|
|
|
587
587
|
getWorkspaceFiles = throwNotImplemented;
|
|
588
588
|
getRunningNotebooks = throwNotImplemented;
|
|
589
589
|
shutdownSession = throwNotImplemented;
|
|
590
|
+
exportAsIPYNB = throwNotImplemented;
|
|
590
591
|
exportAsPDF = throwNotImplemented;
|
|
591
592
|
autoExportAsHTML = throwNotImplemented;
|
|
592
593
|
autoExportAsMarkdown = throwNotImplemented;
|
|
@@ -4,10 +4,10 @@ import { CornerLeftUp } from "lucide-react";
|
|
|
4
4
|
import { type JSX, useEffect, useState } from "react";
|
|
5
5
|
import { z } from "zod";
|
|
6
6
|
import {
|
|
7
|
-
FILE_TYPE_ICONS,
|
|
8
|
-
type FileType,
|
|
9
|
-
guessFileType,
|
|
10
|
-
} from "@/components/editor/file-tree/
|
|
7
|
+
FILE_ICON as FILE_TYPE_ICONS,
|
|
8
|
+
type FileIconType as FileType,
|
|
9
|
+
guessFileIconType as guessFileType,
|
|
10
|
+
} from "@/components/editor/file-tree/file-icons";
|
|
11
11
|
import { Spinner } from "@/components/icons/spinner";
|
|
12
12
|
import { Button } from "@/components/ui/button";
|
|
13
13
|
import { Checkbox } from "@/components/ui/checkbox";
|
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
|
+
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
3
|
+
|
|
4
|
+
import { useCallback, useEffect, useRef } from "react";
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
import { useEventListener } from "@/hooks/useEventListener";
|
|
7
|
+
import { createPlugin } from "@/plugins/core/builder";
|
|
8
|
+
import { MODEL_MANAGER, type Model } from "@/plugins/impl/anywidget/model";
|
|
9
|
+
import type { ModelState, WidgetModelId } from "@/plugins/impl/anywidget/types";
|
|
10
|
+
import type { IPluginProps } from "@/plugins/types";
|
|
11
|
+
import { downloadBlob } from "@/utils/download";
|
|
12
|
+
import { Logger } from "@/utils/Logger";
|
|
13
|
+
import { MplCommWebSocket } from "./mpl-websocket-shim";
|
|
14
|
+
|
|
15
|
+
const MPL_SCOPE_CLASS = "mpl-interactive-figure";
|
|
16
|
+
|
|
17
|
+
interface Data {
|
|
18
|
+
mplJsUrl: string;
|
|
19
|
+
cssUrl: string;
|
|
20
|
+
toolbarImages: Record<string, string>;
|
|
21
|
+
width: number;
|
|
22
|
+
height: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface ModelIdRef {
|
|
26
|
+
model_id: WidgetModelId;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
declare global {
|
|
30
|
+
interface Window {
|
|
31
|
+
mpl: {
|
|
32
|
+
figure: new (
|
|
33
|
+
id: string,
|
|
34
|
+
ws: MplCommWebSocket,
|
|
35
|
+
ondownload: (figure: MplFigure, format: string) => void,
|
|
36
|
+
element: HTMLElement,
|
|
37
|
+
) => MplFigure;
|
|
38
|
+
toolbar_items: [
|
|
39
|
+
string | null,
|
|
40
|
+
string | null,
|
|
41
|
+
string | null,
|
|
42
|
+
string | null,
|
|
43
|
+
][];
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
interface MplFigure {
|
|
49
|
+
id: string;
|
|
50
|
+
ws: MplCommWebSocket;
|
|
51
|
+
root: HTMLElement;
|
|
52
|
+
send_message: (type: string, properties: Record<string, unknown>) => void;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export const MplInteractivePlugin = createPlugin<ModelIdRef>(
|
|
56
|
+
"marimo-mpl-interactive",
|
|
57
|
+
)
|
|
58
|
+
.withData(
|
|
59
|
+
z.object({
|
|
60
|
+
mplJsUrl: z.string(),
|
|
61
|
+
cssUrl: z.string(),
|
|
62
|
+
toolbarImages: z.record(z.string(), z.string()),
|
|
63
|
+
width: z.number(),
|
|
64
|
+
height: z.number(),
|
|
65
|
+
}),
|
|
66
|
+
)
|
|
67
|
+
.withFunctions({})
|
|
68
|
+
.renderer((props) => <MplInteractiveSlot {...props} />);
|
|
69
|
+
|
|
70
|
+
let mplJsLoading: Promise<void> | null = null;
|
|
71
|
+
|
|
72
|
+
async function ensureMplJs(jsUrl: string): Promise<void> {
|
|
73
|
+
if (window.mpl) {
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
if (mplJsLoading) {
|
|
77
|
+
return mplJsLoading;
|
|
78
|
+
}
|
|
79
|
+
mplJsLoading = new Promise<void>((resolve, reject) => {
|
|
80
|
+
const script = document.createElement("script");
|
|
81
|
+
script.src = jsUrl;
|
|
82
|
+
script.onload = () => resolve();
|
|
83
|
+
script.onerror = () => {
|
|
84
|
+
mplJsLoading = null;
|
|
85
|
+
reject(new Error("Failed to load mpl.js"));
|
|
86
|
+
};
|
|
87
|
+
document.head.append(script);
|
|
88
|
+
});
|
|
89
|
+
return mplJsLoading;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Patch mpl.js toolbar image references to use inline data URIs.
|
|
94
|
+
*
|
|
95
|
+
* mpl.js sets `icon_img.src = '_images/' + image + '.png'` and
|
|
96
|
+
* `icon_img.srcset = '_images/' + image + '_large.png 2x'`.
|
|
97
|
+
*
|
|
98
|
+
* We observe the container for new <img> elements and rewrite their
|
|
99
|
+
* src/srcset to the inlined base64 data URIs.
|
|
100
|
+
*/
|
|
101
|
+
function patchToolbarImages(
|
|
102
|
+
container: HTMLElement,
|
|
103
|
+
toolbarImages: Record<string, string>,
|
|
104
|
+
): () => void {
|
|
105
|
+
const patchImg = (img: HTMLImageElement) => {
|
|
106
|
+
const src = img.getAttribute("src") || "";
|
|
107
|
+
const match = src.match(/_images\/(.+)\.png$/);
|
|
108
|
+
if (match) {
|
|
109
|
+
const name = match[1];
|
|
110
|
+
const dataUri = toolbarImages[name];
|
|
111
|
+
if (dataUri) {
|
|
112
|
+
img.src = dataUri;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
const srcset = img.getAttribute("srcset") || "";
|
|
116
|
+
const srcsetMatch = srcset.match(/_images\/(.+)\.png\s+2x$/);
|
|
117
|
+
if (srcsetMatch) {
|
|
118
|
+
const name = srcsetMatch[1];
|
|
119
|
+
const dataUri = toolbarImages[name];
|
|
120
|
+
if (dataUri) {
|
|
121
|
+
img.srcset = `${dataUri} 2x`;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
// Patch any existing images
|
|
127
|
+
for (const img of container.querySelectorAll("img")) {
|
|
128
|
+
patchImg(img);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Observe for new images added by mpl.js
|
|
132
|
+
const observer = new MutationObserver((mutations) => {
|
|
133
|
+
for (const mutation of mutations) {
|
|
134
|
+
for (const node of mutation.addedNodes) {
|
|
135
|
+
if (node instanceof HTMLImageElement) {
|
|
136
|
+
patchImg(node);
|
|
137
|
+
} else if (node instanceof HTMLElement) {
|
|
138
|
+
for (const img of node.querySelectorAll("img")) {
|
|
139
|
+
patchImg(img);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
observer.observe(container, { childList: true, subtree: true });
|
|
147
|
+
return () => observer.disconnect();
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function injectCss(container: HTMLElement, cssUrl: string): () => void {
|
|
151
|
+
const link = document.createElement("link");
|
|
152
|
+
link.rel = "stylesheet";
|
|
153
|
+
link.href = cssUrl;
|
|
154
|
+
container.append(link);
|
|
155
|
+
return () => link.remove();
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const MplInteractiveSlot = (props: IPluginProps<ModelIdRef, Data>) => {
|
|
159
|
+
const { mplJsUrl, cssUrl, toolbarImages, width, height } = props.data;
|
|
160
|
+
const { model_id: modelId } = props.value;
|
|
161
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
162
|
+
const figureRef = useRef<MplFigure | null>(null);
|
|
163
|
+
const wsRef = useRef<MplCommWebSocket | null>(null);
|
|
164
|
+
|
|
165
|
+
const setupFigure = useCallback(
|
|
166
|
+
async (container: HTMLElement) => {
|
|
167
|
+
// Load mpl.js globally (only once, via <script src>)
|
|
168
|
+
await ensureMplJs(mplJsUrl);
|
|
169
|
+
|
|
170
|
+
if (!window.mpl) {
|
|
171
|
+
Logger.error("mpl.js failed to load");
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Get the model from MODEL_MANAGER
|
|
176
|
+
let model: Model<ModelState>;
|
|
177
|
+
try {
|
|
178
|
+
model = await MODEL_MANAGER.get(modelId);
|
|
179
|
+
} catch {
|
|
180
|
+
Logger.error("Failed to get model for mpl interactive", modelId);
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Create the fake WebSocket
|
|
185
|
+
const fakeWs = new MplCommWebSocket((msg: unknown) => {
|
|
186
|
+
// Send from frontend → backend via model custom message
|
|
187
|
+
model.send(msg);
|
|
188
|
+
});
|
|
189
|
+
wsRef.current = fakeWs;
|
|
190
|
+
|
|
191
|
+
// Listen for backend → frontend messages via model custom events
|
|
192
|
+
const handleCustomMessage = (
|
|
193
|
+
content: { type: string; data?: unknown; format?: string },
|
|
194
|
+
buffers?: readonly DataView[],
|
|
195
|
+
) => {
|
|
196
|
+
if (!content) {
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
if (content.type === "json") {
|
|
201
|
+
fakeWs.receiveJson(content.data);
|
|
202
|
+
} else if (content.type === "binary" && buffers && buffers.length > 0) {
|
|
203
|
+
fakeWs.receiveBinary(buffers[0]);
|
|
204
|
+
} else if (
|
|
205
|
+
content.type === "download" &&
|
|
206
|
+
buffers &&
|
|
207
|
+
buffers.length > 0
|
|
208
|
+
) {
|
|
209
|
+
const fmt = content.format || "png";
|
|
210
|
+
const dv = buffers[0];
|
|
211
|
+
const ab = dv.buffer.slice(
|
|
212
|
+
dv.byteOffset,
|
|
213
|
+
dv.byteOffset + dv.byteLength,
|
|
214
|
+
) as ArrayBuffer;
|
|
215
|
+
downloadBlob(
|
|
216
|
+
new Blob([ab], { type: `image/${fmt}` }),
|
|
217
|
+
`figure.${fmt}`,
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
model.on("msg:custom", handleCustomMessage as any);
|
|
223
|
+
|
|
224
|
+
// Create the mpl figure
|
|
225
|
+
const figId = modelId;
|
|
226
|
+
const ondownload = (_figure: MplFigure, format: string) => {
|
|
227
|
+
// Send download request to backend
|
|
228
|
+
model.send({ type: "download", format });
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
const fig = new window.mpl.figure(figId, fakeWs, ondownload, container);
|
|
232
|
+
figureRef.current = fig;
|
|
233
|
+
|
|
234
|
+
// Set the canvas_div to the backend's figure size so the
|
|
235
|
+
// ResizeObserver doesn't trigger an immediate resize cycle.
|
|
236
|
+
// mpl.js creates: fig.root > [titlebar, canvas_div, toolbar]
|
|
237
|
+
const canvasDiv = fig.root.querySelector<HTMLElement>("div[tabindex]");
|
|
238
|
+
if (canvasDiv) {
|
|
239
|
+
canvasDiv.style.width = `${width}px`;
|
|
240
|
+
canvasDiv.style.height = `${height}px`;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Trigger the onopen callback to start communication
|
|
244
|
+
// mpl.js sends initial messages in onopen
|
|
245
|
+
setTimeout(() => {
|
|
246
|
+
fakeWs.onopen?.();
|
|
247
|
+
}, 0);
|
|
248
|
+
|
|
249
|
+
return () => {
|
|
250
|
+
model.off("msg:custom", handleCustomMessage as any);
|
|
251
|
+
fakeWs.close();
|
|
252
|
+
};
|
|
253
|
+
},
|
|
254
|
+
[modelId, mplJsUrl, width, height],
|
|
255
|
+
);
|
|
256
|
+
|
|
257
|
+
useEffect(() => {
|
|
258
|
+
const container = containerRef.current;
|
|
259
|
+
if (!container) {
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// Clear any previous content (handles re-render / cell re-run)
|
|
264
|
+
container.innerHTML = "";
|
|
265
|
+
|
|
266
|
+
// Inject CSS
|
|
267
|
+
const removeCss = injectCss(container, cssUrl);
|
|
268
|
+
|
|
269
|
+
// Patch toolbar images
|
|
270
|
+
const removeImageObserver = patchToolbarImages(container, toolbarImages);
|
|
271
|
+
|
|
272
|
+
let cleanup: (() => void) | undefined;
|
|
273
|
+
let cancelled = false;
|
|
274
|
+
|
|
275
|
+
setupFigure(container)
|
|
276
|
+
.then((cleanupFn) => {
|
|
277
|
+
if (cancelled) {
|
|
278
|
+
cleanupFn?.();
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
cleanup = cleanupFn;
|
|
282
|
+
})
|
|
283
|
+
.catch((error) => {
|
|
284
|
+
if (!cancelled) {
|
|
285
|
+
Logger.error("Failed to set up MPL interactive figure", error);
|
|
286
|
+
}
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
return () => {
|
|
290
|
+
cancelled = true;
|
|
291
|
+
removeCss();
|
|
292
|
+
removeImageObserver();
|
|
293
|
+
cleanup?.();
|
|
294
|
+
// Clear DOM on unmount so stale content doesn't linger
|
|
295
|
+
container.innerHTML = "";
|
|
296
|
+
};
|
|
297
|
+
}, [modelId, cssUrl, toolbarImages, setupFigure]);
|
|
298
|
+
|
|
299
|
+
// Re-request figure when tab becomes visible
|
|
300
|
+
useEventListener(document, "visibilitychange", () => {
|
|
301
|
+
const fig = figureRef.current;
|
|
302
|
+
if (!document.hidden && fig?.ws?.readyState === WebSocket.OPEN) {
|
|
303
|
+
fig.send_message("refresh", {});
|
|
304
|
+
}
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
// Must match _MPL_SCOPE in from_mpl_interactive.py
|
|
308
|
+
return <div ref={containerRef} className={MPL_SCOPE_CLASS} />;
|
|
309
|
+
};
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
|
+
import { describe, expect, it, vi } from "vitest";
|
|
3
|
+
import { MplCommWebSocket } from "../mpl-websocket-shim";
|
|
4
|
+
|
|
5
|
+
describe("MplCommWebSocket", () => {
|
|
6
|
+
it("starts in OPEN state", () => {
|
|
7
|
+
const ws = new MplCommWebSocket(vi.fn());
|
|
8
|
+
expect(ws.readyState).toBe(WebSocket.OPEN);
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it("send() parses JSON and calls sendFn with parsed object", () => {
|
|
12
|
+
const sendFn = vi.fn();
|
|
13
|
+
const ws = new MplCommWebSocket(sendFn);
|
|
14
|
+
|
|
15
|
+
ws.send(JSON.stringify({ type: "resize", width: 640, height: 480 }));
|
|
16
|
+
|
|
17
|
+
expect(sendFn).toHaveBeenCalledOnce();
|
|
18
|
+
expect(sendFn).toHaveBeenCalledWith({
|
|
19
|
+
type: "resize",
|
|
20
|
+
width: 640,
|
|
21
|
+
height: 480,
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("receiveJson() dispatches MessageEvent with JSON string data", () => {
|
|
26
|
+
const ws = new MplCommWebSocket(vi.fn());
|
|
27
|
+
const handler = vi.fn();
|
|
28
|
+
ws.onmessage = handler;
|
|
29
|
+
|
|
30
|
+
ws.receiveJson({ type: "figure_size", size: [640, 480] });
|
|
31
|
+
|
|
32
|
+
expect(handler).toHaveBeenCalledOnce();
|
|
33
|
+
const event: MessageEvent = handler.mock.calls[0][0];
|
|
34
|
+
expect(event).toBeInstanceOf(MessageEvent);
|
|
35
|
+
expect(event.type).toBe("message");
|
|
36
|
+
expect(JSON.parse(event.data as string)).toEqual({
|
|
37
|
+
type: "figure_size",
|
|
38
|
+
size: [640, 480],
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("receiveJson() is a no-op if onmessage is not set", () => {
|
|
43
|
+
const ws = new MplCommWebSocket(vi.fn());
|
|
44
|
+
ws.receiveJson({ type: "test" });
|
|
45
|
+
expect(ws.onmessage).toBeNull();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("receiveBinary() dispatches MessageEvent with Blob data", () => {
|
|
49
|
+
const ws = new MplCommWebSocket(vi.fn());
|
|
50
|
+
const handler = vi.fn();
|
|
51
|
+
ws.onmessage = handler;
|
|
52
|
+
|
|
53
|
+
// Simulate a PNG-like binary buffer
|
|
54
|
+
const bytes = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0, 1, 2, 3]);
|
|
55
|
+
const dv = new DataView(bytes.buffer);
|
|
56
|
+
|
|
57
|
+
ws.receiveBinary(dv);
|
|
58
|
+
|
|
59
|
+
expect(handler).toHaveBeenCalledOnce();
|
|
60
|
+
const event: MessageEvent = handler.mock.calls[0][0];
|
|
61
|
+
expect(event).toBeInstanceOf(MessageEvent);
|
|
62
|
+
expect(event.data).toBeInstanceOf(Blob);
|
|
63
|
+
expect((event.data as Blob).size).toBe(8);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("receiveBinary() handles DataView with offset", () => {
|
|
67
|
+
const ws = new MplCommWebSocket(vi.fn());
|
|
68
|
+
const handler = vi.fn();
|
|
69
|
+
ws.onmessage = handler;
|
|
70
|
+
|
|
71
|
+
// Create a DataView that's a slice of a larger buffer
|
|
72
|
+
const fullBuffer = new ArrayBuffer(16);
|
|
73
|
+
const fullView = new Uint8Array(fullBuffer);
|
|
74
|
+
fullView.set([0, 0, 0, 0, 0x89, 0x50, 0x4e, 0x47, 0, 1, 2, 3, 0, 0, 0, 0]);
|
|
75
|
+
const dv = new DataView(fullBuffer, 4, 8);
|
|
76
|
+
|
|
77
|
+
ws.receiveBinary(dv);
|
|
78
|
+
|
|
79
|
+
expect(handler).toHaveBeenCalledOnce();
|
|
80
|
+
const blob = handler.mock.calls[0][0].data as Blob;
|
|
81
|
+
expect(blob.size).toBe(8);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("close() sets readyState to CLOSED and fires onclose", () => {
|
|
85
|
+
const ws = new MplCommWebSocket(vi.fn());
|
|
86
|
+
const closeHandler = vi.fn();
|
|
87
|
+
ws.onclose = closeHandler;
|
|
88
|
+
|
|
89
|
+
ws.close();
|
|
90
|
+
|
|
91
|
+
expect(ws.readyState).toBe(WebSocket.CLOSED);
|
|
92
|
+
expect(closeHandler).toHaveBeenCalledOnce();
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("close() does not throw if onclose is not set", () => {
|
|
96
|
+
const ws = new MplCommWebSocket(vi.fn());
|
|
97
|
+
ws.close();
|
|
98
|
+
expect(ws.readyState).toBe(WebSocket.CLOSED);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("onopen can be triggered externally", () => {
|
|
102
|
+
const ws = new MplCommWebSocket(vi.fn());
|
|
103
|
+
const openHandler = vi.fn();
|
|
104
|
+
ws.onopen = openHandler;
|
|
105
|
+
|
|
106
|
+
ws.onopen?.();
|
|
107
|
+
|
|
108
|
+
expect(openHandler).toHaveBeenCalledOnce();
|
|
109
|
+
});
|
|
110
|
+
});
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Fake WebSocket that routes messages through MarimoComm / MODEL_MANAGER
|
|
5
|
+
* instead of a real network WebSocket.
|
|
6
|
+
*
|
|
7
|
+
* mpl.js expects a WebSocket-like object with:
|
|
8
|
+
* - readyState
|
|
9
|
+
* - send(data: string)
|
|
10
|
+
* - onopen / onmessage / onclose callbacks
|
|
11
|
+
*/
|
|
12
|
+
export class MplCommWebSocket {
|
|
13
|
+
readyState: number = WebSocket.OPEN;
|
|
14
|
+
private sendFn: (msg: unknown) => void;
|
|
15
|
+
|
|
16
|
+
onopen: (() => void) | null = null;
|
|
17
|
+
onmessage: ((evt: MessageEvent) => void) | null = null;
|
|
18
|
+
onclose: (() => void) | null = null;
|
|
19
|
+
|
|
20
|
+
constructor(sendFn: (msg: unknown) => void) {
|
|
21
|
+
this.sendFn = sendFn;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Called by mpl.js to send a message to the backend.
|
|
26
|
+
* mpl.js always sends JSON strings.
|
|
27
|
+
*/
|
|
28
|
+
send(data: string): void {
|
|
29
|
+
this.sendFn(JSON.parse(data));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Called when the backend pushes a JSON message via the model custom event.
|
|
34
|
+
*/
|
|
35
|
+
receiveJson(data: unknown): void {
|
|
36
|
+
this.onmessage?.(
|
|
37
|
+
new MessageEvent("message", { data: JSON.stringify(data) }),
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Called when the backend pushes binary data (PNG render) via model custom event.
|
|
43
|
+
*/
|
|
44
|
+
receiveBinary(buffer: DataView): void {
|
|
45
|
+
const ab = buffer.buffer.slice(
|
|
46
|
+
buffer.byteOffset,
|
|
47
|
+
buffer.byteOffset + buffer.byteLength,
|
|
48
|
+
) as ArrayBuffer;
|
|
49
|
+
const blob = new Blob([ab]);
|
|
50
|
+
this.onmessage?.(new MessageEvent("message", { data: blob }));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
close(): void {
|
|
54
|
+
this.readyState = WebSocket.CLOSED;
|
|
55
|
+
this.onclose?.();
|
|
56
|
+
}
|
|
57
|
+
}
|