@marimo-team/islands 0.23.15-dev33 → 0.23.15-dev5
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/{ConnectedDataExplorerComponent-7p7rt9iw.js → ConnectedDataExplorerComponent-CU4fZJzG.js} +4 -4
- package/dist/{ErrorBoundary-B_CAG7_e.js → ErrorBoundary-DE6tzZf-.js} +1 -1
- package/dist/{ImageComparisonComponent-1i5IBhon.js → ImageComparisonComponent-CHrI72em.js} +129 -173
- package/dist/assets/__vite-browser-external-BQCLNwri.js +1 -0
- package/dist/assets/worker-DAWRHcPq.js +73 -0
- package/dist/{chat-ui-C0wkSwxB.js → chat-ui-DinOvbWu.js} +3111 -3111
- package/dist/{code-visibility-BI94mK4u.js → code-visibility-DBuB5MVY.js} +864 -946
- package/dist/{debounce-BOD3DbfP.js → constants-T20xxyNf.js} +24 -1
- package/dist/{formats-BX3uDQbB.js → formats-Dzx4J_z1.js} +1 -1
- package/dist/{glide-data-editor-CwZz71BD.js → glide-data-editor-CjTu7ukN.js} +3 -3
- package/dist/{html-to-image-D8Q5SLSJ.js → html-to-image-_wGfk8V-.js} +2241 -2277
- package/dist/{input-BGPrFH3g.js → input-DtsN7xm-.js} +2 -2
- package/dist/main.js +1201 -1297
- package/dist/{mermaid-UdmxG2PZ.js → mermaid-BYqXy_NE.js} +2 -2
- package/dist/{process-output-C_A12w9J.js → process-output-Mh4UrjwM.js} +1 -1
- package/dist/{reveal-component-ELidGmPF.js → reveal-component-DfOZrkd6.js} +605 -618
- package/dist/{spec-DSs9v0xx.js → spec-Cz-Bj1JI.js} +1 -1
- package/dist/style.css +1 -1
- package/dist/{toDate-CKpRx4TS.js → toDate-CWNNlFEX.js} +4 -3
- package/dist/{useAsyncData-BSOyAbac.js → useAsyncData-KfHB8wQR.js} +1 -1
- package/dist/{useDeepCompareMemoize-BTLeWzuR.js → useDeepCompareMemoize-nJMtxhm4.js} +1 -1
- package/dist/{useLifecycle-C6wHjkhW.js → useLifecycle-DegSo0lV.js} +1 -1
- package/dist/{useTheme-Df_vGflw.js → useTheme-6eZ3GOTS.js} +27 -62
- package/dist/{vega-component-DNHEV0u0.js → vega-component-DzyyM9fc.js} +6 -6
- package/package.json +1 -1
- package/src/components/chat/__tests__/chat-utils.test.ts +1 -244
- package/src/components/chat/chat-panel.tsx +67 -196
- package/src/components/chat/chat-utils.ts +2 -111
- package/src/components/editor/SortableCell.tsx +2 -6
- package/src/components/editor/actions/pair-with-agent-modal.tsx +64 -20
- package/src/components/editor/ai/add-cell-with-ai.tsx +5 -14
- package/src/components/editor/ai/ai-completion-editor.tsx +1 -4
- package/src/components/editor/cell/cell-context-menu.tsx +0 -7
- package/src/components/editor/chrome/panels/packages-panel.tsx +1 -11
- package/src/components/editor/columns/cell-column.tsx +4 -34
- package/src/components/editor/columns/sortable-column.tsx +4 -24
- package/src/components/editor/navigation/__tests__/navigation.test.ts +0 -33
- package/src/components/editor/navigation/navigation.ts +1 -8
- package/src/components/editor/notebook-cell.tsx +106 -203
- package/src/components/editor/renderers/cell-array.tsx +13 -26
- package/src/components/editor/renderers/cells-renderer.tsx +2 -8
- package/src/components/editor/renderers/slides-layout/__tests__/plugin.test.ts +0 -29
- package/src/components/editor/renderers/slides-layout/types.ts +0 -4
- package/src/components/editor/renderers/vertical-layout/vertical-layout.tsx +19 -19
- package/src/components/slides/reveal-component.tsx +4 -37
- package/src/components/slides/slide-form.tsx +0 -72
- package/src/core/cells/__tests__/utils.test.ts +0 -59
- package/src/core/cells/utils.ts +0 -51
- package/src/core/codemirror/completion/__tests__/signature-hint.test.ts +3 -97
- package/src/core/codemirror/completion/signature-hint.ts +11 -78
- package/src/core/constants.ts +0 -6
- package/src/core/islands/__tests__/bridge.test.ts +46 -341
- package/src/core/islands/__tests__/parse.test.ts +0 -105
- package/src/core/islands/bootstrap.ts +3 -8
- package/src/core/islands/bridge.ts +23 -116
- package/src/core/islands/components/web-components.tsx +15 -76
- package/src/core/islands/constants.ts +0 -1
- package/src/core/islands/main.ts +3 -69
- package/src/core/islands/parse.ts +23 -70
- package/src/core/islands/worker/worker.tsx +57 -145
- package/src/core/wasm/worker/bootstrap.ts +20 -113
- package/src/css/app/Cell.css +0 -10
- package/src/hooks/useHotkey.ts +4 -29
- package/src/plugins/impl/anywidget/__tests__/host.test.ts +9 -6
- package/src/plugins/impl/anywidget/__tests__/widget-binding.test.ts +61 -22
- package/src/plugins/impl/anywidget/model-proxy.ts +13 -0
- package/src/plugins/impl/anywidget/widget-binding.ts +34 -4
- package/src/plugins/impl/image-comparison/ImageComparisonComponent.tsx +2 -53
- package/src/plugins/impl/matplotlib/__tests__/matplotlib-renderer.test.ts +3 -71
- package/src/plugins/impl/matplotlib/matplotlib-renderer.ts +0 -1
- package/src/theme/useTheme.ts +1 -16
- package/dist/assets/__vite-browser-external-CjNAy01L.js +0 -1
- package/dist/assets/worker-B3XPCb6y.js +0 -98
- package/src/components/chat/__tests__/message-queue.test.tsx +0 -121
- package/src/components/editor/__tests__/output-persistence.test.tsx +0 -241
- package/src/components/editor/actions/__tests__/pair-with-agent-commands.test.ts +0 -153
- package/src/components/editor/actions/pair-with-agent-commands.ts +0 -109
- package/src/components/editor/columns/__tests__/cell-column.test.tsx +0 -105
- package/src/components/editor/renderers/__tests__/cells-renderer.test.tsx +0 -66
- package/src/core/islands/__tests__/main.test.ts +0 -176
- package/src/core/islands/components/__tests__/web-components.test.tsx +0 -214
- package/src/core/islands/worker/__tests__/controller.test.ts +0 -173
- package/src/hooks/__tests__/useHotkey.test.tsx +0 -88
- package/src/plugins/impl/image-comparison/__tests__/ImageComparisonComponent.test.tsx +0 -71
- package/src/theme/__tests__/useTheme.test.ts +0 -68
package/src/hooks/useHotkey.ts
CHANGED
|
@@ -17,31 +17,13 @@ type HotkeyHandler = (
|
|
|
17
17
|
// oxlint-disable-next-line typescript/no-invalid-void-type
|
|
18
18
|
) => boolean | void | undefined | Promise<void>;
|
|
19
19
|
|
|
20
|
-
interface HotkeyOptions {
|
|
21
|
-
/**
|
|
22
|
-
* If true, the hotkey is not handled at all: the callback does not run, the
|
|
23
|
-
* event's default is not prevented, and the action is not registered in the
|
|
24
|
-
* shortcut registry.
|
|
25
|
-
*
|
|
26
|
-
* This differs from passing `Functions.NOOP` as the callback, which still
|
|
27
|
-
* swallows the keystroke (preventDefault) to suppress native browser/OS
|
|
28
|
-
* behavior.
|
|
29
|
-
*/
|
|
30
|
-
disabled?: boolean;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
20
|
/**
|
|
34
21
|
* Registers a hotkey listener for the given shortcut.
|
|
35
22
|
*
|
|
36
23
|
* @param callback The callback to run when the shortcut is pressed. It does not need to be memoized as this hook will always use the latest callback.
|
|
37
24
|
* If the callback returns false, preventDefault will not be called on the event.
|
|
38
25
|
*/
|
|
39
|
-
export function useHotkey(
|
|
40
|
-
shortcut: HotkeyAction,
|
|
41
|
-
callback: HotkeyHandler,
|
|
42
|
-
options: HotkeyOptions = {},
|
|
43
|
-
) {
|
|
44
|
-
const { disabled = false } = options;
|
|
26
|
+
export function useHotkey(shortcut: HotkeyAction, callback: HotkeyHandler) {
|
|
45
27
|
const { registerAction, unregisterAction } = useSetRegisteredAction();
|
|
46
28
|
const hotkeys = useAtomValue(hotkeysAtom);
|
|
47
29
|
|
|
@@ -49,7 +31,7 @@ export function useHotkey(
|
|
|
49
31
|
const memoizeCallback = useEvent((evt?: KeyboardEvent) => callback(evt));
|
|
50
32
|
|
|
51
33
|
const listener = useEvent((e: KeyboardEvent) => {
|
|
52
|
-
if (
|
|
34
|
+
if (e.defaultPrevented) {
|
|
53
35
|
return;
|
|
54
36
|
}
|
|
55
37
|
|
|
@@ -69,18 +51,11 @@ export function useHotkey(
|
|
|
69
51
|
|
|
70
52
|
// Register with the shortcut registry
|
|
71
53
|
useEffect(() => {
|
|
72
|
-
if (!isNOOP
|
|
54
|
+
if (!isNOOP) {
|
|
73
55
|
registerAction(shortcut, memoizeCallback);
|
|
74
56
|
return () => unregisterAction(shortcut);
|
|
75
57
|
}
|
|
76
|
-
}, [
|
|
77
|
-
memoizeCallback,
|
|
78
|
-
shortcut,
|
|
79
|
-
isNOOP,
|
|
80
|
-
disabled,
|
|
81
|
-
registerAction,
|
|
82
|
-
unregisterAction,
|
|
83
|
-
]);
|
|
58
|
+
}, [memoizeCallback, shortcut, isNOOP, registerAction, unregisterAction]);
|
|
84
59
|
}
|
|
85
60
|
|
|
86
61
|
/**
|
|
@@ -277,22 +277,25 @@ describe("WidgetBinding receives a host in render props", () => {
|
|
|
277
277
|
});
|
|
278
278
|
});
|
|
279
279
|
|
|
280
|
-
describe("host-mounted views", () => {
|
|
281
|
-
it("
|
|
282
|
-
const childId = asModelId("host-child
|
|
280
|
+
describe("host-mounted views hydrate like display-mounted ones", () => {
|
|
281
|
+
it("replays current state to a child's render listeners", async () => {
|
|
282
|
+
const childId = asModelId("host-hydration-child");
|
|
283
283
|
const parentController = new AbortController();
|
|
284
284
|
const getModuleSpy = vi.spyOn(WIDGET_DEF_REGISTRY, "getModule");
|
|
285
285
|
try {
|
|
286
286
|
const childWidget = {
|
|
287
287
|
render: vi.fn(({ model, el }) => {
|
|
288
|
-
el.textContent =
|
|
288
|
+
el.textContent = "count is 5";
|
|
289
|
+
model.on("change:count", () => {
|
|
290
|
+
el.textContent = `count is ${model.get("count")}`;
|
|
291
|
+
});
|
|
289
292
|
}),
|
|
290
293
|
};
|
|
291
294
|
const childModel = new Model<ModelState>({ count: 8 }, createMockComm());
|
|
292
295
|
WIDGET_REGISTRY.setModel(childId, childModel);
|
|
293
296
|
WIDGET_REGISTRY.setSpec(childId, {
|
|
294
|
-
url: "./@file/10-host-child
|
|
295
|
-
hash: "hash-host-child
|
|
297
|
+
url: "./@file/10-host-hydration-child.js",
|
|
298
|
+
hash: "hash-host-hydration-child",
|
|
296
299
|
});
|
|
297
300
|
getModuleSpy.mockResolvedValue({ default: childWidget });
|
|
298
301
|
|
|
@@ -337,12 +337,13 @@ describe("WidgetBinding", () => {
|
|
|
337
337
|
{ el: document.createElement("div") },
|
|
338
338
|
{ signal: controller.signal },
|
|
339
339
|
);
|
|
340
|
+
// Called once by the hydration replay at mount, once by the set.
|
|
340
341
|
model.set("count", 1);
|
|
341
|
-
expect(onCount).toHaveBeenCalledTimes(
|
|
342
|
+
expect(onCount).toHaveBeenCalledTimes(2);
|
|
342
343
|
|
|
343
344
|
controller.abort();
|
|
344
345
|
model.set("count", 2);
|
|
345
|
-
expect(onCount).toHaveBeenCalledTimes(
|
|
346
|
+
expect(onCount).toHaveBeenCalledTimes(2);
|
|
346
347
|
});
|
|
347
348
|
|
|
348
349
|
it("should auto-clear initialize listeners on destroy", async () => {
|
|
@@ -365,32 +366,30 @@ describe("WidgetBinding", () => {
|
|
|
365
366
|
});
|
|
366
367
|
});
|
|
367
368
|
|
|
368
|
-
describe("WidgetBinding
|
|
369
|
-
it("
|
|
369
|
+
describe("WidgetBinding hydration replay", () => {
|
|
370
|
+
it("replays current state to render listeners exactly once", async () => {
|
|
370
371
|
const model = new Model<ModelState>({ count: 8 }, createMockComm());
|
|
371
|
-
const
|
|
372
|
-
const onAnyChange = vi.fn();
|
|
372
|
+
const el = document.createElement("div");
|
|
373
373
|
const widgetDef = {
|
|
374
|
-
render: vi.fn(({ model }) => {
|
|
375
|
-
|
|
376
|
-
|
|
374
|
+
render: vi.fn(({ model, el }) => {
|
|
375
|
+
// A widget view that starts with a local default and relies on
|
|
376
|
+
// change events for hydration.
|
|
377
|
+
el.textContent = "count is 5";
|
|
378
|
+
model.on("change:count", () => {
|
|
379
|
+
el.textContent = `count is ${model.get("count")}`;
|
|
380
|
+
});
|
|
377
381
|
}),
|
|
378
382
|
};
|
|
379
383
|
const binding = await createBinding(widgetDef, model);
|
|
380
|
-
const controller = new AbortController();
|
|
381
|
-
await binding.createView(
|
|
382
|
-
{ el: document.createElement("div") },
|
|
383
|
-
{ signal: controller.signal },
|
|
384
|
-
);
|
|
385
|
-
expect(onCount).not.toHaveBeenCalled();
|
|
386
|
-
expect(onAnyChange).not.toHaveBeenCalled();
|
|
387
384
|
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
expect(
|
|
385
|
+
const controller = new AbortController();
|
|
386
|
+
await binding.createView({ el }, { signal: controller.signal });
|
|
387
|
+
expect(el.textContent).toBe("count is 8");
|
|
391
388
|
});
|
|
392
389
|
|
|
393
|
-
it("
|
|
390
|
+
it("does not re-fire listeners of already-mounted views", async () => {
|
|
391
|
+
// Mounting view B must not double-paint view A: the replay is
|
|
392
|
+
// scoped to the listeners the new render attached.
|
|
394
393
|
const model = new Model<ModelState>({ count: 0 }, createMockComm());
|
|
395
394
|
const listeners: Array<ReturnType<typeof vi.fn>> = [];
|
|
396
395
|
const widgetDef = {
|
|
@@ -407,12 +406,52 @@ describe("WidgetBinding render listeners", () => {
|
|
|
407
406
|
{ el: document.createElement("div") },
|
|
408
407
|
{ signal: controller.signal },
|
|
409
408
|
);
|
|
409
|
+
expect(listeners[0]).toHaveBeenCalledTimes(1);
|
|
410
|
+
|
|
411
|
+
await binding.createView(
|
|
412
|
+
{ el: document.createElement("div") },
|
|
413
|
+
{ signal: controller.signal },
|
|
414
|
+
);
|
|
415
|
+
// View B's listener hydrated once; view A's was left alone.
|
|
416
|
+
expect(listeners[1]).toHaveBeenCalledTimes(1);
|
|
417
|
+
expect(listeners[0]).toHaveBeenCalledTimes(1);
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
it("replays the any-change event to its listeners", async () => {
|
|
421
|
+
const model = new Model<ModelState>({ count: 1 }, createMockComm());
|
|
422
|
+
const onAnyChange = vi.fn();
|
|
423
|
+
const widgetDef = {
|
|
424
|
+
render: vi.fn(({ model }) => {
|
|
425
|
+
model.on("change", onAnyChange);
|
|
426
|
+
}),
|
|
427
|
+
};
|
|
428
|
+
const binding = await createBinding(widgetDef, model);
|
|
429
|
+
const controller = new AbortController();
|
|
430
|
+
await binding.createView(
|
|
431
|
+
{ el: document.createElement("div") },
|
|
432
|
+
{ signal: controller.signal },
|
|
433
|
+
);
|
|
434
|
+
expect(onAnyChange).toHaveBeenCalledTimes(1);
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
it("does not replay initialize listeners", async () => {
|
|
438
|
+
// The guarantee is per-view: initialize listeners existed before
|
|
439
|
+
// any view, and replaying at them would fire once per mount.
|
|
440
|
+
const model = new Model<ModelState>({ count: 1 }, createMockComm());
|
|
441
|
+
const initListener = vi.fn();
|
|
442
|
+
const widgetDef = {
|
|
443
|
+
initialize: vi.fn(({ model }) => {
|
|
444
|
+
model.on("change:count", initListener);
|
|
445
|
+
}),
|
|
446
|
+
render: vi.fn(),
|
|
447
|
+
};
|
|
448
|
+
const binding = await createBinding(widgetDef, model);
|
|
449
|
+
const controller = new AbortController();
|
|
410
450
|
await binding.createView(
|
|
411
451
|
{ el: document.createElement("div") },
|
|
412
452
|
{ signal: controller.signal },
|
|
413
453
|
);
|
|
414
|
-
expect(
|
|
415
|
-
expect(listeners[1]).not.toHaveBeenCalled();
|
|
454
|
+
expect(initListener).not.toHaveBeenCalled();
|
|
416
455
|
});
|
|
417
456
|
|
|
418
457
|
it("clears the element before rendering into it", async () => {
|
|
@@ -5,6 +5,15 @@ import type { ModelState } from "./types";
|
|
|
5
5
|
|
|
6
6
|
type ModelEventCallback = Parameters<AnyModel<ModelState>["on"]>[1];
|
|
7
7
|
|
|
8
|
+
/**
|
|
9
|
+
* A listener registered through a model proxy (see the hydration
|
|
10
|
+
* replay in `WidgetBinding.createView`).
|
|
11
|
+
*/
|
|
12
|
+
export interface ProxyRegistration {
|
|
13
|
+
event: string;
|
|
14
|
+
callback: ModelEventCallback;
|
|
15
|
+
}
|
|
16
|
+
|
|
8
17
|
/**
|
|
9
18
|
* Wrap a model so every `on()` call from inside `initialize` or `render`
|
|
10
19
|
* is auto-tied to a lifetime `AbortSignal`. When the signal aborts,
|
|
@@ -18,11 +27,14 @@ type ModelEventCallback = Parameters<AnyModel<ModelState>["on"]>[1];
|
|
|
18
27
|
* The proxy is purely a host-side ergonomic helper. The widget author
|
|
19
28
|
* still writes `model.on("change:foo", handler)` exactly as before; the
|
|
20
29
|
* cleanup signal is supplied transparently.
|
|
30
|
+
*
|
|
31
|
+
* `onRegister`, when given, observes each `on()` registration.
|
|
21
32
|
*/
|
|
22
33
|
// oxlint-disable-next-line marimo/prefer-object-params -- concise internal helper used at protocol call sites
|
|
23
34
|
export function modelProxy<T extends ModelState>(
|
|
24
35
|
model: AnyModel<T>,
|
|
25
36
|
signal: AbortSignal,
|
|
37
|
+
onRegister?: (registration: ProxyRegistration) => void,
|
|
26
38
|
): AnyModel<T> {
|
|
27
39
|
return {
|
|
28
40
|
get(key) {
|
|
@@ -43,6 +55,7 @@ export function modelProxy<T extends ModelState>(
|
|
|
43
55
|
signal.addEventListener("abort", () => model.off(name, callback), {
|
|
44
56
|
once: true,
|
|
45
57
|
});
|
|
58
|
+
onRegister?.({ event: name, callback });
|
|
46
59
|
},
|
|
47
60
|
off(name?: string | null, callback?: ModelEventCallback | null): void {
|
|
48
61
|
model.off(name ?? null, callback ?? null);
|
|
@@ -12,7 +12,7 @@ import { isTrustedVirtualFileUrl } from "@/plugins/core/trusted-url";
|
|
|
12
12
|
import { Logger } from "@/utils/Logger";
|
|
13
13
|
import type { Host } from "./host";
|
|
14
14
|
import type { Model } from "./model";
|
|
15
|
-
import { modelProxy } from "./model-proxy";
|
|
15
|
+
import { modelProxy, type ProxyRegistration } from "./model-proxy";
|
|
16
16
|
import type { ModelState } from "./types";
|
|
17
17
|
|
|
18
18
|
export const experimental: Experimental = {
|
|
@@ -266,8 +266,9 @@ export class WidgetBinding<T extends ModelState = ModelState> {
|
|
|
266
266
|
* when the caller's `signal` fires or the binding is destroyed, and
|
|
267
267
|
* listeners registered inside `render` auto-clear on abort.
|
|
268
268
|
*
|
|
269
|
-
*
|
|
270
|
-
*
|
|
269
|
+
* Hydration guarantee: listeners attached during `render` observe
|
|
270
|
+
* current model state exactly once, after `render` settles. Scoped
|
|
271
|
+
* to this view's listeners; re-firing at other views double-paints.
|
|
271
272
|
*/
|
|
272
273
|
async createView(
|
|
273
274
|
target: { el: HTMLElement },
|
|
@@ -304,8 +305,11 @@ export class WidgetBinding<T extends ModelState = ModelState> {
|
|
|
304
305
|
// Each view gets a host scoped to its own signal so child views tear
|
|
305
306
|
// down with this view.
|
|
306
307
|
const host = this.#createHost(renderSignal);
|
|
308
|
+
const registrations: ProxyRegistration[] = [];
|
|
307
309
|
const renderCleanup = await widget.render({
|
|
308
|
-
model: modelProxy(this.#model, renderSignal)
|
|
310
|
+
model: modelProxy(this.#model, renderSignal, (registration) =>
|
|
311
|
+
registrations.push(registration),
|
|
312
|
+
),
|
|
309
313
|
el: target.el,
|
|
310
314
|
experimental,
|
|
311
315
|
signal: renderSignal,
|
|
@@ -329,6 +333,7 @@ export class WidgetBinding<T extends ModelState = ModelState> {
|
|
|
329
333
|
once: true,
|
|
330
334
|
});
|
|
331
335
|
}
|
|
336
|
+
this.#replayState(registrations, renderSignal);
|
|
332
337
|
}
|
|
333
338
|
|
|
334
339
|
#trackCleanup(cleanup: Cleanup, reason: string): Promise<void> {
|
|
@@ -338,6 +343,31 @@ export class WidgetBinding<T extends ModelState = ModelState> {
|
|
|
338
343
|
return task;
|
|
339
344
|
}
|
|
340
345
|
|
|
346
|
+
/**
|
|
347
|
+
* The hydration guarantee documented on `createView`.
|
|
348
|
+
*/
|
|
349
|
+
#replayState(
|
|
350
|
+
registrations: readonly ProxyRegistration[],
|
|
351
|
+
renderSignal: AbortSignal,
|
|
352
|
+
): void {
|
|
353
|
+
const changePrefix = "change:";
|
|
354
|
+
for (const { event, callback } of registrations) {
|
|
355
|
+
if (renderSignal.aborted) {
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
try {
|
|
359
|
+
if (event.startsWith(changePrefix)) {
|
|
360
|
+
const key = event.slice(changePrefix.length);
|
|
361
|
+
callback(this.#model.get(key));
|
|
362
|
+
} else if (event === "change") {
|
|
363
|
+
callback();
|
|
364
|
+
}
|
|
365
|
+
} catch (error) {
|
|
366
|
+
Logger.error("[WidgetBinding] Error replaying state", error);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
341
371
|
/**
|
|
342
372
|
* Destroy this generation, running initialize/render cleanups and
|
|
343
373
|
* clearing listeners registered through its model proxies.
|
|
@@ -12,13 +12,6 @@ export interface ImageComparisonData {
|
|
|
12
12
|
height?: string;
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
-
// Truncate long sources (e.g. base64 data URLs) so error messages stay
|
|
16
|
-
// readable.
|
|
17
|
-
function truncateSrc(src: string): string {
|
|
18
|
-
const MAX_LENGTH = 100;
|
|
19
|
-
return src.length > MAX_LENGTH ? `${src.slice(0, MAX_LENGTH)}…` : src;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
15
|
const ImageComparisonComponent: React.FC<ImageComparisonData> = ({
|
|
23
16
|
beforeSrc,
|
|
24
17
|
afterSrc,
|
|
@@ -27,61 +20,17 @@ const ImageComparisonComponent: React.FC<ImageComparisonData> = ({
|
|
|
27
20
|
width,
|
|
28
21
|
height,
|
|
29
22
|
}) => {
|
|
30
|
-
const [failedSrcs, setFailedSrcs] = React.useState<ReadonlySet<string>>(
|
|
31
|
-
() => new Set(),
|
|
32
|
-
);
|
|
33
|
-
|
|
34
|
-
React.useEffect(() => {
|
|
35
|
-
setFailedSrcs(new Set());
|
|
36
|
-
}, [beforeSrc, afterSrc]);
|
|
37
|
-
|
|
38
|
-
const handleError = React.useCallback((src: string) => {
|
|
39
|
-
setFailedSrcs((prev) => new Set(prev).add(src));
|
|
40
|
-
}, []);
|
|
41
|
-
|
|
42
23
|
const containerStyle: React.CSSProperties = {
|
|
43
24
|
width: width || "100%",
|
|
44
25
|
height: height || (direction === "vertical" ? "400px" : "auto"),
|
|
45
26
|
maxWidth: "100%",
|
|
46
|
-
// The slider derives its height entirely from its (loaded) images, so a
|
|
47
|
-
// broken/slow-loading source would otherwise collapse it to nothing and
|
|
48
|
-
// render an empty output. Keep a minimum height so it stays visible.
|
|
49
|
-
minHeight: "2rem",
|
|
50
27
|
};
|
|
51
28
|
|
|
52
|
-
// If an image fails to load, the slider collapses to nothing; surface a
|
|
53
|
-
// visible error instead of silently rendering an empty output.
|
|
54
|
-
if (failedSrcs.size > 0) {
|
|
55
|
-
return (
|
|
56
|
-
<div
|
|
57
|
-
style={containerStyle}
|
|
58
|
-
className="flex items-center justify-center rounded border border-destructive/40 bg-destructive/5 p-3 text-sm text-destructive"
|
|
59
|
-
>
|
|
60
|
-
<span>
|
|
61
|
-
Failed to load {failedSrcs.size > 1 ? "images" : "image"}:{" "}
|
|
62
|
-
{[...failedSrcs].map((src) => `"${truncateSrc(src)}"`).join(", ")}
|
|
63
|
-
</span>
|
|
64
|
-
</div>
|
|
65
|
-
);
|
|
66
|
-
}
|
|
67
|
-
|
|
68
29
|
return (
|
|
69
30
|
<div style={containerStyle}>
|
|
70
31
|
<ImgComparisonSlider value={value} direction={direction}>
|
|
71
|
-
<img
|
|
72
|
-
|
|
73
|
-
src={beforeSrc}
|
|
74
|
-
alt="Before"
|
|
75
|
-
width="100%"
|
|
76
|
-
onError={() => handleError(beforeSrc)}
|
|
77
|
-
/>
|
|
78
|
-
<img
|
|
79
|
-
slot="second"
|
|
80
|
-
src={afterSrc}
|
|
81
|
-
alt="After"
|
|
82
|
-
width="100%"
|
|
83
|
-
onError={() => handleError(afterSrc)}
|
|
84
|
-
/>
|
|
32
|
+
<img slot="first" src={beforeSrc} alt="Before" width="100%" />
|
|
33
|
+
<img slot="second" src={afterSrc} alt="After" width="100%" />
|
|
85
34
|
</ImgComparisonSlider>
|
|
86
35
|
</div>
|
|
87
36
|
);
|
|
@@ -1,11 +1,7 @@
|
|
|
1
1
|
/* Copyright 2026 Marimo. All rights reserved. */
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
|
|
5
|
-
MatplotlibRenderer,
|
|
6
|
-
type MatplotlibState,
|
|
7
|
-
visibleForTesting,
|
|
8
|
-
} from "../matplotlib-renderer";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import type { Data } from "../matplotlib-renderer";
|
|
4
|
+
import { visibleForTesting } from "../matplotlib-renderer";
|
|
9
5
|
|
|
10
6
|
const {
|
|
11
7
|
pixelToData,
|
|
@@ -189,67 +185,3 @@ describe("isInAxes", () => {
|
|
|
189
185
|
expect(isInAxes({ x: 300, y: 351 }, LINEAR_AXES)).toBe(false);
|
|
190
186
|
});
|
|
191
187
|
});
|
|
192
|
-
|
|
193
|
-
describe("MatplotlibRenderer", () => {
|
|
194
|
-
afterEach(() => {
|
|
195
|
-
vi.unstubAllGlobals();
|
|
196
|
-
vi.restoreAllMocks();
|
|
197
|
-
});
|
|
198
|
-
|
|
199
|
-
it("restores a new selection when the chart changes", () => {
|
|
200
|
-
vi.stubGlobal(
|
|
201
|
-
"matchMedia",
|
|
202
|
-
vi.fn(() => ({ addEventListener: vi.fn() })),
|
|
203
|
-
);
|
|
204
|
-
vi.stubGlobal(
|
|
205
|
-
"requestAnimationFrame",
|
|
206
|
-
vi.fn(() => 1),
|
|
207
|
-
);
|
|
208
|
-
vi.stubGlobal("cancelAnimationFrame", vi.fn());
|
|
209
|
-
vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue(null);
|
|
210
|
-
|
|
211
|
-
const value = {
|
|
212
|
-
type: "box",
|
|
213
|
-
has_selection: true,
|
|
214
|
-
data: { x_min: 2, x_max: 4, y_min: 2, y_max: 4 },
|
|
215
|
-
} as const;
|
|
216
|
-
const state: MatplotlibState = {
|
|
217
|
-
...LINEAR_AXES,
|
|
218
|
-
axesPixelBounds: [0, 0, 100, 100],
|
|
219
|
-
yBounds: [0, 10],
|
|
220
|
-
chartBase64: "first",
|
|
221
|
-
width: 100,
|
|
222
|
-
height: 100,
|
|
223
|
-
selectionColor: "blue",
|
|
224
|
-
selectionOpacity: 0.15,
|
|
225
|
-
strokeWidth: 2,
|
|
226
|
-
debounce: false,
|
|
227
|
-
value: { has_selection: false },
|
|
228
|
-
setValue: vi.fn(),
|
|
229
|
-
};
|
|
230
|
-
const container = document.createElement("div");
|
|
231
|
-
const controller = new AbortController();
|
|
232
|
-
const renderer = new MatplotlibRenderer(container, {
|
|
233
|
-
state,
|
|
234
|
-
signal: controller.signal,
|
|
235
|
-
});
|
|
236
|
-
|
|
237
|
-
// Cell reruns reuse the renderer, updating the chart and initial value together.
|
|
238
|
-
renderer.update({ ...state, chartBase64: "second", value });
|
|
239
|
-
|
|
240
|
-
const canvas = container.querySelector("canvas");
|
|
241
|
-
expect(canvas).not.toBeNull();
|
|
242
|
-
if (!canvas) {
|
|
243
|
-
return;
|
|
244
|
-
}
|
|
245
|
-
vi.spyOn(canvas, "getBoundingClientRect").mockReturnValue(
|
|
246
|
-
DOMRect.fromRect({ width: 100, height: 100 }),
|
|
247
|
-
);
|
|
248
|
-
canvas.dispatchEvent(
|
|
249
|
-
new MouseEvent("pointermove", { clientX: 30, clientY: 70 }),
|
|
250
|
-
);
|
|
251
|
-
|
|
252
|
-
expect(canvas.style.cursor).toBe("move");
|
|
253
|
-
controller.abort();
|
|
254
|
-
});
|
|
255
|
-
});
|
package/src/theme/useTheme.ts
CHANGED
|
@@ -2,7 +2,6 @@
|
|
|
2
2
|
|
|
3
3
|
import { atom, useAtomValue } from "jotai";
|
|
4
4
|
import { resolvedMarimoConfigAtom } from "@/core/config/config";
|
|
5
|
-
import { KnownQueryParams } from "@/core/constants";
|
|
6
5
|
import { isIslands } from "@/core/islands/utils";
|
|
7
6
|
import { store } from "@/core/state/jotai";
|
|
8
7
|
|
|
@@ -11,16 +10,6 @@ export type ResolvedTheme = "light" | "dark";
|
|
|
11
10
|
|
|
12
11
|
export const THEMES: Theme[] = ["light", "dark", "system"];
|
|
13
12
|
|
|
14
|
-
function themeFromQueryParam(): Theme | undefined {
|
|
15
|
-
if (typeof window === "undefined") {
|
|
16
|
-
return undefined;
|
|
17
|
-
}
|
|
18
|
-
const value = new URLSearchParams(window.location.search).get(
|
|
19
|
-
KnownQueryParams.theme,
|
|
20
|
-
);
|
|
21
|
-
return THEMES.includes(value as Theme) ? (value as Theme) : undefined;
|
|
22
|
-
}
|
|
23
|
-
|
|
24
13
|
const themeAtom = atom((get) => {
|
|
25
14
|
// If it is islands, try a few ways to infer if it is dark mode.
|
|
26
15
|
if (isIslands()) {
|
|
@@ -62,7 +51,7 @@ const themeAtom = atom((get) => {
|
|
|
62
51
|
return "light";
|
|
63
52
|
}
|
|
64
53
|
|
|
65
|
-
return
|
|
54
|
+
return get(resolvedMarimoConfigAtom).display.theme;
|
|
66
55
|
});
|
|
67
56
|
|
|
68
57
|
const prefersDarkModeAtom = atom(false);
|
|
@@ -133,7 +122,3 @@ export function useTheme(): { theme: ResolvedTheme } {
|
|
|
133
122
|
const theme = useAtomValue(resolvedThemeAtom, { store });
|
|
134
123
|
return { theme };
|
|
135
124
|
}
|
|
136
|
-
|
|
137
|
-
export const visibleForTesting = {
|
|
138
|
-
themeFromQueryParam,
|
|
139
|
-
};
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
import{t as e}from"./worker-B3XPCb6y.js";var t=e(((e,t)=>{t.exports={}}));export default t();
|