Skip to content

Commit 8473ed7

Browse files
committed
feat: Implement model.off
1 parent b588447 commit 8473ed7

File tree

1 file changed

+151
-73
lines changed

1 file changed

+151
-73
lines changed

inst/htmlwidgets/anyhtmlwidget.js

+151-73
Original file line numberDiff line numberDiff line change
@@ -1,38 +1,113 @@
1+
class CallbackRegistry {
2+
#callbacks = {};
3+
add(name, callback) {
4+
if (!this.#callbacks[name]) {
5+
this.#callbacks[name] = [];
6+
}
7+
this.#callbacks[name].push(callback);
8+
}
9+
remove(name, callback) {
10+
if (!this.#callbacks[name]) {
11+
return [];
12+
}
13+
const callbacks = this.#callbacks[name];
14+
if (callback) {
15+
// Remove a specific callback
16+
return this.#callbacks[name] = callbacks.filter((cb) => cb !== callback);
17+
}
18+
// Remove all callbacks
19+
this.#callbacks[name] = [];
20+
return callbacks;
21+
}
22+
}
23+
24+
/**
25+
* An R-backed implementation of the @anywidget/types AnyModel interface.
26+
*
27+
* @see {@link https://github.com/manzt/anywidget/tree/main/packages/types}
28+
*/
129
class AnyModel {
30+
/** @type {Record<string, any>} */
31+
#state;
32+
/** @type {string} */
33+
#ns_id;
34+
/** @type {WebSocket | undefined} */
35+
#ws = undefined;
36+
/** @type {EventTarget} */
37+
#target = new EventTarget();
38+
/** @type {CallbackRegistry} */
39+
#callbacks = new CallbackRegistry();
40+
/** @type {Set<string>} */
41+
#unsavedKeys = new Set();
42+
43+
/**
44+
* @param {Record<string, any>} state - initial model state
45+
* @param {string} ns_id - the Shiny namespace ID
46+
* @param {WebSocket} [ws] - a WebSocket connection
47+
*/
248
constructor(state, ns_id, ws) {
3-
this.ns_id = ns_id;
4-
this.state = state;
5-
this.target = new EventTarget();
6-
this.ws = ws;
7-
this.unsavedKeys = new Set();
49+
this.#ns_id = ns_id;
50+
this.#state = state;
51+
this.#ws = ws;
852
}
53+
/** @param {string} name */
954
get(name) {
10-
return this.state[name];
55+
return this.#state[name];
1156
}
57+
/**
58+
* @param {string} key
59+
* @param {any} value
60+
*/
1261
set(key, value) {
13-
this.state[key] = value;
14-
this.unsavedKeys.add(key);
15-
this.target.dispatchEvent(
16-
new CustomEvent(`change:${key}`, { detail: value }),
17-
);
62+
this.#state[key] = value;
63+
this.#unsavedKeys.add(key);
64+
this.#target.dispatchEvent(
65+
new CustomEvent(`change:${key}`, { detail: value }),
66+
);
67+
this.#target.dispatchEvent(
68+
new CustomEvent("change", { detail: value }),
69+
);
1870
}
71+
/**
72+
* @param {string} name
73+
* @param {Function} callback
74+
*/
1975
on(name, callback) {
20-
this.target.addEventListener(name, callback);
76+
this.#target.addEventListener(name, callback);
77+
this.#callbacks.add(name, callback);
2178
}
22-
off(name) {
23-
// Not yet implemented
79+
/**
80+
* @param {string} name
81+
* @param {Function} [callback]
82+
*/
83+
off(name, callback) {
84+
for (const cb of this.#callbacks.remove(name, callback)) {
85+
this.#target.removeEventListener(name, cb);
86+
}
87+
}
88+
/**
89+
* @param {any} msg
90+
* @param {unknown} [callbacks]
91+
* @param {ArrayBuffer[]} [buffers]
92+
*/
93+
send(msg, callbacks, buffers) {
94+
// TODO: implement
95+
console.error(`model.send is not yet implemented for anyhtmlwidget`);
2496
}
2597
save_changes() {
2698
const unsavedState = Object.fromEntries(
27-
Array.from(this.unsavedKeys.values())
28-
.map(key => ([key, this.state[key]]))
99+
Array.from(this.#unsavedKeys.values())
100+
.map((key) => [key, this.#state[key]]),
29101
);
30-
this.unsavedKeys = new Set();
31-
if(window && window.Shiny && window.Shiny.setInputValue) {
32-
const eventPrefix = this.ns_id ? `${this.ns_id}-` : '';
33-
Shiny.setInputValue(`${eventPrefix}anyhtmlwidget_on_save_changes`, unsavedState);
34-
} else if(this.ws) {
35-
this.ws.send(JSON.stringify({
102+
this.#unsavedKeys = new Set();
103+
if (window && window.Shiny && window.Shiny.setInputValue) {
104+
const eventPrefix = this.#ns_id ? `${this.#ns_id}-` : "";
105+
Shiny.setInputValue(
106+
`${eventPrefix}anyhtmlwidget_on_save_changes`,
107+
unsavedState,
108+
);
109+
} else if (this.#ws) {
110+
this.#ws.send(JSON.stringify({
36111
type: "on_save_changes",
37112
payload: unsavedState,
38113
}));
@@ -41,82 +116,85 @@ class AnyModel {
41116
}
42117

43118
function emptyElement(el) {
44-
while (el.firstChild) {
45-
el.removeChild(el.firstChild);
46-
}
119+
while (el.firstChild) {
120+
el.removeChild(el.firstChild);
121+
}
47122
}
48123

49124
HTMLWidgets.widget({
50-
name: 'anyhtmlwidget',
51-
type: 'output',
52-
factory: function(el, width, height) {
53-
125+
name: "anyhtmlwidget",
126+
type: "output",
127+
factory: function (el, width, height) {
54128
let widget;
55129
let model;
56130
let cleanup;
57131
let ws;
58132

59133
return {
60-
renderValue: async function(x) {
61-
if(cleanup && typeof cleanup === "function") {
134+
renderValue: async function (x) {
135+
if (cleanup && typeof cleanup === "function") {
62136
cleanup();
63137
cleanup = undefined;
64-
if(ws) {
138+
if (ws) {
65139
ws.close();
66140
ws = undefined;
67141
}
68142
}
69-
// The default can either be an object like { render, initialize }
70-
// or a function that returns this object.
71-
if(!widget) {
72-
const esm = x.esm;
73-
const url = URL.createObjectURL(new Blob([esm], { type: "text/javascript" }));
74-
const mod = await import(/* webpackIgnore: true */ url);
75-
URL.revokeObjectURL(url);
143+
// The default can either be an object like { render, initialize }
144+
// or a function that returns this object.
145+
if (!widget) {
146+
const esm = x.esm;
147+
const url = URL.createObjectURL(
148+
new Blob([esm], { type: "text/javascript" }),
149+
);
150+
const mod = await import(/* webpackIgnore: true */ url);
151+
URL.revokeObjectURL(url);
76152

77-
widget = typeof mod.default === "function"
78-
? await mod.default()
79-
: mod.default;
153+
widget = typeof mod.default === "function"
154+
? await mod.default()
155+
: mod.default;
80156

81-
// TODO: initialize here
82-
}
157+
// TODO: initialize here
158+
}
83159

84-
if(x.port && x.host && !window.Shiny) {
85-
ws = new WebSocket(`ws://${x.host}:${x.port}`);
86-
}
160+
if (x.port && x.host && !window.Shiny) {
161+
ws = new WebSocket(`ws://${x.host}:${x.port}`);
162+
}
87163

88-
model = new AnyModel(x.values, x.ns_id, ws);
164+
model = new AnyModel(x.values, x.ns_id, ws);
89165

90-
if(window && window.Shiny && window.Shiny.addCustomMessageHandler) {
91-
const eventPrefix = x.ns_id ? `${x.ns_id}-` : '';
92-
Shiny.addCustomMessageHandler(`${eventPrefix}anyhtmlwidget_on_change`, ({ key, value }) => {
93-
model.set(key, value);
94-
});
95-
} else if(x.port && x.host) {
96-
ws.onmessage = (event) => {
97-
const { type, payload } = JSON.parse(event.data);
98-
if(type === "on_change") {
99-
const { key, value } = payload;
100-
model.set(key, value);
101-
}
102-
};
103-
}
166+
if (window && window.Shiny && window.Shiny.addCustomMessageHandler) {
167+
const eventPrefix = x.ns_id ? `${x.ns_id}-` : "";
168+
Shiny.addCustomMessageHandler(
169+
`${eventPrefix}anyhtmlwidget_on_change`,
170+
({ key, value }) => {
171+
model.set(key, value);
172+
},
173+
);
174+
} else if (x.port && x.host) {
175+
ws.onmessage = (event) => {
176+
const { type, payload } = JSON.parse(event.data);
177+
if (type === "on_change") {
178+
const { key, value } = payload;
179+
model.set(key, value);
180+
}
181+
};
182+
}
104183

105-
try {
106-
emptyElement(el);
184+
try {
185+
emptyElement(el);
107186
// Register cleanup function.
108-
cleanup = await widget.render({ model, el, width, height });
109-
110-
} catch(e) {
111-
// TODO: re-throw error
112-
}
187+
cleanup = await widget.render({ model, el, width, height });
188+
} catch (e) {
189+
// TODO: re-throw error
190+
}
113191
},
114-
resize: async function(width, height) {
192+
resize: async function (width, height) {
115193
// TODO: emit resize event on window (and let user handle)?
116-
if(widget?.resize) {
194+
if (widget?.resize) {
117195
await widget.resize({ model, el, width, height });
118196
}
119-
}
197+
},
120198
};
121-
}
199+
},
122200
});

0 commit comments

Comments
 (0)