Skip to content

Commit 6bcd2f1

Browse files
committed
feat(remote): expose iframe selection to host pages
Assisted-by: Claude Opus 4.7
1 parent 2254954 commit 6bcd2f1

8 files changed

Lines changed: 124 additions & 57 deletions

File tree

‎doc/examples/angular/datalab-web-demo.component.ts‎

Lines changed: 20 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -71,25 +71,16 @@ interface LogLine {
7171
</button>
7272
7373
<div class="row">
74-
<button
75-
[disabled]="!dlw.ready() || lastId() === null"
76-
(click)="onFft()"
77-
>
78-
calc(fft) on last
74+
<button [disabled]="!dlw.ready()" (click)="onFft()">
75+
calc(fft) on selection
7976
</button>
80-
<button
81-
[disabled]="!dlw.ready() || lastId() === null"
82-
(click)="onGet()"
83-
>
84-
get_signal_xy(last)
77+
<button [disabled]="!dlw.ready()" (click)="onGet()">
78+
get_signal_xy(current)
8579
</button>
8680
</div>
8781
88-
<button
89-
[disabled]="!dlw.ready() || lastId() === null"
90-
(click)="onRemove()"
91-
>
92-
delete_object(last)
82+
<button [disabled]="!dlw.ready()" (click)="onRemove()">
83+
delete_object(current)
9384
</button>
9485
<button [disabled]="!dlw.ready()" (click)="onReset()">reset_all</button>
9586
@@ -180,7 +171,6 @@ interface LogLine {
180171
export class DataLabWebDemoComponent {
181172
protected readonly dlw = inject(DataLabWebService);
182173

183-
protected readonly lastId = signal<string | null>(null);
184174
private readonly lines = signal<LogLine[]>([]);
185175

186176
protected readonly logText = () =>
@@ -222,10 +212,9 @@ export class DataLabWebDemoComponent {
222212
xs[i] = i / N;
223213
ys[i] = Math.sin(2 * Math.PI * 5 * xs[i]);
224214
}
225-
const id = await this.wrap('add_signal', () =>
215+
await this.wrap('add_signal', () =>
226216
this.dlw.addSignal('Sine 5 Hz', xs, ys),
227217
);
228-
if (id) this.lastId.set(id);
229218
}
230219

231220
async onAddImage(): Promise<void> {
@@ -236,34 +225,33 @@ export class DataLabWebDemoComponent {
236225
data[i * N + j] = i + j;
237226
}
238227
}
239-
const id = await this.wrap('add_image', () =>
228+
await this.wrap('add_image', () =>
240229
this.dlw.addImage('Gradient', { width: N, height: N, data }),
241230
);
242-
if (id) this.lastId.set(id);
243231
}
244232

233+
// Operate on whatever the user has selected inside the iframe.
234+
// ``calc(...)`` with ``sources=null`` already defaults to the
235+
// current selection on the DataLab-Web side; the other handlers
236+
// ask the bridge for the focused id explicitly.
245237
onFft(): void {
246-
const id = this.lastId();
247-
if (!id) return this.note('no lastId — add a signal first');
248-
void this.wrap('calc(fft)', () => this.dlw.calc('fft', null, [id]));
238+
void this.wrap('calc(fft)', () => this.dlw.calc('fft'));
249239
}
250240

251-
onGet(): void {
252-
const id = this.lastId();
253-
if (!id) return this.note('no lastId');
254-
void this.wrap('get_signal_xy', () => this.dlw.getSignalXY(id));
241+
async onGet(): Promise<void> {
242+
const sel = await this.dlw.getSelection();
243+
if (sel.length === 0) return this.note('no current selection');
244+
void this.wrap('get_signal_xy', () => this.dlw.getSignalXY(sel[0]));
255245
}
256246

257247
async onRemove(): Promise<void> {
258-
const id = this.lastId();
259-
if (!id) return this.note('no lastId');
260-
await this.wrap('delete_object', () => this.dlw.deleteObject(id));
261-
this.lastId.set(null);
248+
const sel = await this.dlw.getSelection();
249+
if (sel.length === 0) return this.note('no current selection');
250+
await this.wrap('delete_object', () => this.dlw.deleteObject(sel[0]));
262251
}
263252

264253
onReset(): void {
265254
void this.wrap('reset_all', () => this.dlw.resetAll());
266-
this.lastId.set(null);
267255
}
268256

269257
// -----------------------------------------------------------------

‎doc/examples/angular/datalab-web.service.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,12 @@ export class DataLabWebService implements OnDestroy {
175175
return this.guard((c) => c.deleteObject(oid));
176176
}
177177

178+
/** Return the ids currently selected on the active panel inside
179+
* the iframe. Empty array when nothing is selected. */
180+
getSelection(): Promise<string[]> {
181+
return this.guard((c) => c.getSelection());
182+
}
183+
178184
resetAll(): Promise<null> {
179185
return this.guard((c) => c.resetAll());
180186
}

‎packages/sdk/src/DataLabWebClient.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -462,6 +462,12 @@ export class DataLabWebClient {
462462
return this.call("select_objects", { oids, panel }) as Promise<null>;
463463
}
464464

465+
/** Return the ids currently selected on the active panel.
466+
* Empty array when nothing is selected. */
467+
getSelection(): Promise<string[]> {
468+
return this.call("get_selection") as Promise<string[]>;
469+
}
470+
465471
setCurrentPanel(panel: "signal" | "image" | "macro"): Promise<null> {
466472
return this.call("set_current_panel", { panel }) as Promise<null>;
467473
}

‎public/remote-host-example.html‎

Lines changed: 18 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -30,10 +30,10 @@ <h1>Remote control demo</h1>
3030
<button id="btn-add-signal" disabled>add_signal (sine)</button>
3131
<button id="btn-add-image" disabled>add_image (gradient)</button>
3232
<div class="row">
33-
<button id="btn-fft" disabled>calc(fft) on last</button>
34-
<button id="btn-get" disabled>get_signal_xy(last)</button>
33+
<button id="btn-fft" disabled>calc(fft) on selection</button>
34+
<button id="btn-get" disabled>get_signal_xy(current)</button>
3535
</div>
36-
<button id="btn-remove" disabled>delete_object(last)</button>
36+
<button id="btn-remove" disabled>delete_object(current)</button>
3737
<button id="btn-reset" disabled>reset_all</button>
3838
<div id="log" aria-live="polite"></div>
3939
</div>
@@ -76,9 +76,6 @@ <h1>Remote control demo</h1>
7676
// Expose for Playwright (`page.evaluate(() => window.__client.…)`).
7777
window.__client = client;
7878

79-
let lastSignalId = null;
80-
let lastImageId = null;
81-
8279
const buttons = {
8380
version: document.getElementById("btn-version"),
8481
list: document.getElementById("btn-list"),
@@ -112,35 +109,31 @@ <h1>Remote control demo</h1>
112109
const N = 256;
113110
const xs = Array.from({ length: N }, (_, i) => i / N);
114111
const ys = xs.map((x) => Math.sin(2 * Math.PI * 5 * x));
115-
lastSignalId = await wrap("add_signal", () => client.addSignal("Sine 5 Hz", xs, ys));
112+
await wrap("add_signal", () => client.addSignal("Sine 5 Hz", xs, ys));
116113
};
117114
buttons.addImage.onclick = async () => {
118115
const N = 32;
119116
const data = Array.from({ length: N }, (_, i) =>
120117
Array.from({ length: N }, (_, j) => i + j),
121118
);
122-
lastImageId = await wrap("add_image", () => client.addImage("Gradient", data));
123-
};
124-
buttons.fft.onclick = () => {
125-
if (!lastSignalId) return logLine("!", "no signal yet — add a signal first");
126-
return wrap("calc(fft)", () => client.calc("fft", null, [lastSignalId]));
119+
await wrap("add_image", () => client.addImage("Gradient", data));
127120
};
128-
buttons.get.onclick = () => {
129-
if (!lastSignalId) return logLine("!", "no signal yet");
130-
return wrap("get_signal_xy", () => client.getSignalXY(lastSignalId));
121+
// Operate on whatever the user has selected inside the iframe.
122+
// ``client.calc(...)`` with ``sources=null`` already defaults to
123+
// the current selection on the DataLab-Web side.
124+
buttons.fft.onclick = () =>
125+
wrap("calc(fft)", () => client.calc("fft"));
126+
buttons.get.onclick = async () => {
127+
const sel = await client.getSelection();
128+
if (sel.length === 0) return logLine("!", "no current selection");
129+
return wrap("get_signal_xy", () => client.getSignalXY(sel[0]));
131130
};
132131
buttons.remove.onclick = async () => {
133-
const id = lastSignalId ?? lastImageId;
134-
if (!id) return logLine("!", "no object to delete");
135-
await wrap("delete_object", () => client.deleteObject(id));
136-
if (id === lastSignalId) lastSignalId = null;
137-
else lastImageId = null;
138-
};
139-
buttons.reset.onclick = async () => {
140-
await wrap("reset_all", () => client.resetAll());
141-
lastSignalId = null;
142-
lastImageId = null;
132+
const sel = await client.getSelection();
133+
if (sel.length === 0) return logLine("!", "no current selection");
134+
return wrap("delete_object", () => client.deleteObject(sel[0]));
143135
};
136+
buttons.reset.onclick = () => wrap("reset_all", () => client.resetAll());
144137

145138
(async () => {
146139
try {

‎src/App.tsx‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { useWorkspace } from "./runtime/WorkspaceContext";
44
import { useBeforeUnloadGuard } from "./runtime/useBeforeUnloadGuard";
55
import { useDocumentTitle } from "./runtime/useDocumentTitle";
66
import { REMOTE_MODEL_CHANGED_EVENT } from "./runtime/remoteBridge";
7+
import { registerSelectionSource } from "./runtime/selectionState";
78
import type {
89
FeatureDescriptor,
910
H5BrowserFile,
@@ -713,6 +714,18 @@ export default function App() {
713714
[],
714715
);
715716

717+
// Publish selection / panel snapshots to non-React consumers (the
718+
// iframe-embedded remote bridge in particular, so host pages can
719+
// call ``client.getSelection()`` / target the *current* object).
720+
useEffect(() => {
721+
registerSelectionSource(() => ({
722+
ids: selectedIds,
723+
currentId,
724+
panel: activePanel,
725+
}));
726+
return () => registerSelectionSource(null);
727+
}, [selectedIds, currentId, activePanel]);
728+
716729
const handleSwitchPanel = useCallback(
717730
(kind: PanelKind) => {
718731
if (kind === activePanel) return;

‎src/runtime/RuntimeContext.tsx‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { createContext, useContext, useEffect, useMemo, useState } from "react";
22
import type { ReactNode } from "react";
33
import { DataLabRuntime } from "./runtime";
44
import { activateRemoteBridge, type RemoteBridgeHandle } from "./remoteBridge";
5+
import { getCurrentPanel, getSelection } from "./selectionState";
56

67
interface RuntimeContextValue {
78
runtime: DataLabRuntime | null;
@@ -36,6 +37,8 @@ export function RuntimeProvider({ children }: { children: ReactNode }) {
3637
try {
3738
remoteBridgeHandle = activateRemoteBridge(rt, {
3839
version: import.meta.env.VITE_APP_VERSION,
40+
getSelection,
41+
getCurrentPanel,
3942
});
4043
if (remoteBridgeHandle && import.meta.env.DEV) {
4144
console.info(

‎src/runtime/proxyBridge.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -236,6 +236,9 @@ _e = _MODEL._objects[${JSON.stringify(oid)}]
236236
ext.selectObjects?.(a.oids, a.panel);
237237
return null;
238238
},
239+
/** Return the id list currently selected on the active panel.
240+
* Empty when nothing is selected (or no host is wired). */
241+
get_selection: async () => ext.getSelection?.() ?? [],
239242
call_method: async (p: unknown) => {
240243
const a = p as {
241244
name: string;

‎src/runtime/selectionState.ts‎

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
/**
2+
* Tiny module-level registry that lets the React app publish its
3+
* current selection / panel state to non-React consumers (the
4+
* iframe-embedded remote bridge in particular).
5+
*
6+
* The host app calls :func:`registerSelectionSource` once with a
7+
* snapshot getter; every subsequent reader (``remoteBridge`` /
8+
* ``proxyBridge``) goes through :func:`getSelection` /
9+
* :func:`getCurrentPanel`.
10+
*
11+
* Kept intentionally minimal — this is *not* a state container, just a
12+
* forwarding hook for callbacks that already live in the React tree.
13+
*/
14+
15+
export interface SelectionSnapshot {
16+
/** Currently selected object ids on the active panel. May be empty. */
17+
ids: string[];
18+
/** The "current" object id (last clicked / single-selection focus). */
19+
currentId: string | null;
20+
/** Active panel name (``"signal"``, ``"image"``, ``"macro"``, …). */
21+
panel: string;
22+
}
23+
24+
export type SelectionSource = () => SelectionSnapshot;
25+
26+
let source: SelectionSource | null = null;
27+
28+
/** Publish the selection snapshot getter (idempotent — last writer wins). */
29+
export function registerSelectionSource(getter: SelectionSource | null): void {
30+
source = getter;
31+
}
32+
33+
/** Return the current selection ids, or ``[]`` when no source is wired.
34+
*
35+
* When the selection list is empty but a *current* object is set
36+
* (single-row focus without an explicit multi-select), the current id
37+
* is returned as a one-element list. This mirrors DataLab desktop's
38+
* action semantics where commands run on the current object whenever
39+
* no broader selection exists. */
40+
export function getSelection(): string[] {
41+
if (!source) return [];
42+
const snap = source();
43+
if (snap.ids.length > 0) return snap.ids;
44+
return snap.currentId ? [snap.currentId] : [];
45+
}
46+
47+
/** Return the current panel name, or ``"signal"`` as a sane default. */
48+
export function getCurrentPanel(): string {
49+
return source ? source().panel : "signal";
50+
}
51+
52+
/** Return the focused object id (``null`` when nothing is current). */
53+
export function getCurrentObjectId(): string | null {
54+
return source ? source().currentId : null;
55+
}

0 commit comments

Comments
 (0)