Skip to content

Commit 34d4a83

Browse files
iframe view generation (#150)
* Working (if janky) view generation example * expose value proxies on simple cells * use proxy to read data + related changes * accept data as input, add a default * fix getAsProxy to point to the right subpath * New feature "$<prop>" will pass raw cell to element * refactored this a bit and now directly uses the cell * use new $data feature * Fix port when launching views * MVP subscription to a cell * Cleanup and document confusion * expose scheduler on the module * allow setting a log on getAsProxy on simple cells * use read logs and scheduler for updates * change default example to counter * standardize on `value` on the wire protocol (before it was a mix of `data` and `value`) --------- Co-authored-by: Bernhard Seefeld <berni@common.tools>
1 parent 050e504 commit 34d4a83

File tree

8 files changed

+259
-1
lines changed

8 files changed

+259
-1
lines changed

typescript/packages/collectathon/view.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ export async function handleViewCommandInteractive(collection: string, initialPr
4040
[viewId, collection, html]
4141
);
4242

43-
const url = `http://localhost:8000/view/${collection}/${viewId}`;
43+
const url = `http://localhost:8001/view/${collection}/${viewId}`;
4444
console.log(`Opening view in browser: ${url}`);
4545
await open(url);
4646

typescript/packages/common-html/src/render.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,7 @@ const bindProps = (
143143
context: Context
144144
): Cancel => {
145145
const [cancel, addCancel] = useCancelGroup();
146+
console.log("binding props", element.tagName, props);
146147
for (const [propKey, propValue] of Object.entries(props)) {
147148
if (isBinding(propValue)) {
148149
const replacement = getContext(context, propValue.path);
@@ -163,6 +164,10 @@ const bindProps = (
163164
} else {
164165
logger.warn("Could not bind event", propKey, propValue);
165166
}
167+
} else if (propKey.startsWith("$")) {
168+
console.log("binding context directly", propKey);
169+
const key = propKey.slice(1);
170+
setProp(element, key, replacement);
166171
} else {
167172
const cancel = effect(replacement, (replacement) => {
168173
// Replacements are set as properties not attributes to avoid

typescript/packages/common-runner/src/cell.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ export interface Cell<T> {
3636
send(value: T): void;
3737
sink(callback: (value: T) => void): () => void;
3838
key<K extends keyof T>(valueKey: K): Cell<T[K]>;
39+
getAsProxy(path?: PropertyKey[], log?: ReactivityLog): CellProxy<T>;
3940
}
4041

4142
export interface ReactiveCell<T> {
@@ -185,6 +186,8 @@ function simpleCell<T>(
185186
},
186187
key: <K extends keyof T>(key: K) =>
187188
cell.asSimpleCell([...path, key], log) as Cell<T[K]>,
189+
getAsProxy: (subPath: PropertyKey[] = [], newLog?: ReactivityLog) =>
190+
createValueProxy(cell, [...path, ...subPath], newLog ?? log),
188191
};
189192
return self;
190193
}

typescript/packages/common-runner/src/index.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,9 @@
11
export { run, charmById } from "./runner.js";
2+
export {
3+
run as addAction,
4+
unschedule as removeAction,
5+
type Action,
6+
} from "./scheduler.js";
27
export type {
38
Cell,
49
ReactiveCell,
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
import { LitElement, html } from "lit-element";
2+
import { customElement, property } from "lit/decorators.js";
3+
import {
4+
Cell,
5+
addAction,
6+
removeAction,
7+
type Action,
8+
type ReactivityLog,
9+
} from "@commontools/common-runner";
10+
import { Ref, createRef, ref } from "lit/directives/ref.js";
11+
12+
@customElement("common-iframe")
13+
export class CommonIframe extends LitElement {
14+
@property({ type: String }) src = "";
15+
// HACK: The UI framework already translates the top level cell into updated
16+
// properties, but we want to only have to deal with one type of listening, so
17+
// we'll add a an extra level of indirection with the "context" property.
18+
@property({ type: Object }) context?: Cell<any> | any;
19+
20+
private iframeRef: Ref<HTMLIFrameElement> = createRef();
21+
22+
private subscriptions: Map<string, Action[]> = new Map();
23+
24+
private handleMessage = (event: MessageEvent) => {
25+
console.log("Received message", event);
26+
if (event.source === this.iframeRef.value?.contentWindow) {
27+
const { type, key, value } = event.data;
28+
if (typeof key !== "string") {
29+
console.error("Invalid key type. Expected string.");
30+
return;
31+
}
32+
if (type === "read" && this.context) {
33+
const value = this.context?.getAsProxy
34+
? this.context?.getAsProxy([key])
35+
: this.context?.[key];
36+
// TODO: This might cause infinite loops, since the data can be a graph.
37+
const copy =
38+
value !== undefined ? JSON.parse(JSON.stringify(value)) : undefined;
39+
console.log("readResponse", key, value);
40+
this.iframeRef.value?.contentWindow?.postMessage(
41+
{ type: "readResponse", key, value: copy },
42+
"*"
43+
);
44+
} else if (type === "write" && this.context) {
45+
this.context.getAsProxy()[key] = value;
46+
} else if (type === "subscribe" && this.context) {
47+
console.log("subscribing", key, this.context);
48+
49+
const action: Action = (log: ReactivityLog) =>
50+
this.notifySubscribers(key, this.context.getAsProxy([key], log));
51+
52+
addAction(action);
53+
if (!this.subscriptions.has(key)) this.subscriptions.set(key, [action]);
54+
else this.subscriptions.get(key)!.push(action);
55+
} else if (type === "unsubscribe" && this.context) {
56+
if (this.subscriptions && this.subscriptions.has(key)) {
57+
const actions = this.subscriptions.get(key);
58+
if (actions && actions.length) removeAction(actions.pop()!);
59+
}
60+
}
61+
}
62+
};
63+
64+
private notifySubscribers(key: string, value: any) {
65+
console.log("notifySubscribers", key, value);
66+
// TODO: This might cause infinite loops, since the data can be a graph.
67+
const copy =
68+
value !== undefined ? JSON.parse(JSON.stringify(value)) : undefined;
69+
this.iframeRef.value?.contentWindow?.postMessage(
70+
{ type: "update", key, value: copy },
71+
"*"
72+
);
73+
}
74+
private boundHandleMessage = this.handleMessage.bind(this);
75+
76+
override connectedCallback() {
77+
super.connectedCallback();
78+
window.addEventListener("message", this.boundHandleMessage);
79+
}
80+
81+
override disconnectedCallback() {
82+
super.disconnectedCallback();
83+
window.removeEventListener("message", this.boundHandleMessage);
84+
}
85+
86+
override render() {
87+
return html`
88+
<iframe
89+
${ref(this.iframeRef)}
90+
sandbox="allow-scripts"
91+
.srcdoc=${this.src}
92+
height="512px"
93+
width="100%"
94+
@load=${this.iframeRef.value?.contentWindow?.postMessage(
95+
{ type: "init" },
96+
"*"
97+
)}
98+
></iframe>
99+
`;
100+
}
101+
}

typescript/packages/lookslike-high-level/src/components/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,4 @@ export * as CommonAnnotation from "./annotation.js";
22
export * as CommonRecipeLink from "./recipe-link.js";
33
export * as CommonCharmLink from "./charm-link.js";
44
export * as CommonWindowManager from "./window-manager.js";
5+
export * as CommonIframe from "./iframe-view.js";

typescript/packages/lookslike-high-level/src/data.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import {
2525
isCellProxyForDereferencing,
2626
} from "@commontools/common-runner";
2727
import { fetchCollections } from "./recipes/fetchCollections.js";
28+
import { iframeExample } from "./recipes/iframeExample.js";
2829

2930
export type Charm = {
3031
[ID]: number;
@@ -56,6 +57,8 @@ export function addCharms(newCharms: CellImpl<any>[]) {
5657
}
5758

5859
addCharms([
60+
run(iframeExample, { }),
61+
run(iframeExample, { prompt: "playable breakout/arkanoid, use `score` to write score, click to start, reset score at start", data: { score: 0, counter: 0 } }),
5962
run(fetchExample, {
6063
url: "https://anotherjesse-restfuljsonblobapi.web.val.run/items",
6164
}),
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
import { html } from "@commontools/common-html";
2+
import {
3+
recipe,
4+
UI,
5+
NAME,
6+
lift,
7+
generateData,
8+
handler,
9+
cell,
10+
} from "@commontools/common-builder";
11+
12+
const formatData = lift(({ obj }) => {
13+
console.log("stringify", obj);
14+
return JSON.stringify(obj, null, 2);
15+
});
16+
17+
const tap = lift((x) => {
18+
console.log(x);
19+
return x;
20+
});
21+
22+
const updateValue = handler<{ detail: { value: string } }, { value: string }>(
23+
({ detail }, state) => detail?.value && (state.value = detail.value)
24+
);
25+
26+
const generate = handler<void, { prompt: string; query: string }>(
27+
(_, state) => {
28+
state.query = state.prompt;
29+
console.log("generarting", state.query);
30+
}
31+
);
32+
33+
const randomize = handler<void, { data: Record<string, any> }>((_, state) => {
34+
for (const key in state.data) {
35+
if (typeof state.data[key] === "number") {
36+
state.data[key] = Math.round(Math.random() * 100); // Generates a random number between 0 and 100
37+
}
38+
}
39+
});
40+
41+
const maybeHTML = lift(({ result }) => result?.html ?? "");
42+
43+
const viewSystemPrompt = `generate a complete HTML document within a json block , e.g.
44+
\`\`\`json
45+
{ html: "..."}
46+
\`\`\`
47+
48+
This must be plain JSON.
49+
50+
the document can and should make use of postMessage to read and write data from the host context. e.g.
51+
52+
document.addEventListener('DOMContentLoaded', function() {
53+
console.log('Initialized!');
54+
55+
window.parent.postMessage({
56+
type: 'subscribe',
57+
key: 'exampleKey'
58+
}, '*');
59+
60+
window.addEventListener('message', function(event) {
61+
if (event.data.type === 'readResponse') {
62+
// use response
63+
console.log('readResponse', event.data.key,event.data.value);
64+
} else if (event.data.type === 'update') {
65+
...
66+
});
67+
});
68+
69+
70+
71+
72+
window.parent.postMessage({
73+
type: 'read',
74+
key: 'exampleKey'
75+
}, '*');
76+
77+
window.parent.postMessage({
78+
type: 'write',
79+
key: 'exampleKey',
80+
value: 'Example data to write'
81+
}, '*');
82+
83+
You can also subscribe and unsubscribe to changes from the keys:
84+
85+
window.parent.postMessage({
86+
type: 'subscribe',
87+
key: 'exampleKey'
88+
}, '*');
89+
90+
You receive 'update' messages with a 'key' and 'value' field.
91+
92+
window.parent.postMessage({
93+
type: 'unsubscribe',
94+
key: 'exampleKey',
95+
}, '*');`;
96+
97+
export const iframeExample = recipe<{ prompt: string; data: any }>(
98+
"iFrame Example",
99+
({ prompt, data }) => {
100+
tap({ data });
101+
prompt.setDefault(
102+
"counter example using write and subscribe with key `counter`"
103+
);
104+
data.setDefault({ message: "hello", counter: 0 });
105+
106+
const query = cell<string>();
107+
const response = generateData<{ html: string }>({
108+
prompt: query,
109+
system: viewSystemPrompt,
110+
});
111+
tap({ response });
112+
tap({ result: response.result });
113+
114+
return {
115+
[NAME]: "iFrame Example",
116+
[UI]: html`<div>
117+
<pre>${formatData({ obj: data })}</pre>
118+
<common-input
119+
value=${prompt}
120+
placeholder="Prompt"
121+
oncommon-input=${updateValue({ value: prompt })}
122+
></common-input>
123+
<common-button onclick=${randomize({ data })}
124+
>Randomize Values</common-button
125+
>
126+
<common-button onclick=${generate({ prompt, query })}
127+
>Generate</common-button
128+
>
129+
130+
<common-iframe
131+
src=${maybeHTML({ result: response.result })}
132+
$context=${data}
133+
></common-iframe>
134+
<pre>${maybeHTML({ result: response.result })}</pre>
135+
</div>`,
136+
response,
137+
data,
138+
};
139+
}
140+
);

0 commit comments

Comments
 (0)