1 | // Copyright (c) Jupyter Development Team.
|
2 | // Distributed under the terms of the Modified BSD License.
|
3 |
|
4 | import { IDisposable } from '@lumino/disposable';
|
5 | import { Message, MessageLoop } from '@lumino/messaging';
|
6 | import { ISignal, Signal } from '@lumino/signaling';
|
7 | import { Widget } from '@lumino/widgets';
|
8 | import * as React from 'react';
|
9 | import { createRoot, Root } from 'react-dom/client';
|
10 |
|
11 | type ReactRenderElement =
|
12 | | Array<React.ReactElement<any>>
|
13 | | React.ReactElement<any>;
|
14 |
|
15 | /**
|
16 | * An abstract class for a Lumino widget which renders a React component.
|
17 | */
|
18 | export abstract class ReactWidget extends Widget {
|
19 | constructor() {
|
20 | super();
|
21 | }
|
22 | /**
|
23 | * Creates a new `ReactWidget` that renders a constant element.
|
24 | * @param element React element to render.
|
25 | */
|
26 | static create(element: ReactRenderElement): ReactWidget {
|
27 | return new (class extends ReactWidget {
|
28 | render() {
|
29 | return element;
|
30 | }
|
31 | })();
|
32 | }
|
33 |
|
34 | /**
|
35 | * Render the content of this widget using the virtual DOM.
|
36 | *
|
37 | * This method will be called anytime the widget needs to be rendered, which
|
38 | * includes layout triggered rendering.
|
39 | *
|
40 | * Subclasses should define this method and return the root React nodes here.
|
41 | */
|
42 | protected abstract render(): ReactRenderElement | null;
|
43 |
|
44 | /**
|
45 | * Called to update the state of the widget.
|
46 | *
|
47 | * The default implementation of this method triggers
|
48 | * VDOM based rendering by calling the `renderDOM` method.
|
49 | */
|
50 | protected onUpdateRequest(msg: Message): void {
|
51 | this.renderPromise = this.renderDOM();
|
52 | }
|
53 |
|
54 | /**
|
55 | * Called after the widget is attached to the DOM
|
56 | */
|
57 | protected onAfterAttach(msg: Message): void {
|
58 | // Make *sure* the widget is rendered.
|
59 | MessageLoop.sendMessage(this, Widget.Msg.UpdateRequest);
|
60 | }
|
61 |
|
62 | /**
|
63 | * Called before the widget is detached from the DOM.
|
64 | */
|
65 | protected onBeforeDetach(msg: Message): void {
|
66 | // Unmount the component so it can tear down.
|
67 | if (this._rootDOM !== null) {
|
68 | this._rootDOM.unmount();
|
69 | this._rootDOM = null;
|
70 | }
|
71 | }
|
72 |
|
73 | /**
|
74 | * Render the React nodes to the DOM.
|
75 | *
|
76 | * @returns a promise that resolves when the rendering is done.
|
77 | */
|
78 | private renderDOM(): Promise<void> {
|
79 | return new Promise<void>(resolve => {
|
80 | const vnode = this.render();
|
81 | if (this._rootDOM === null) {
|
82 | this._rootDOM = createRoot(this.node);
|
83 | }
|
84 | // Split up the array/element cases so type inference chooses the right
|
85 | // signature.
|
86 | if (Array.isArray(vnode)) {
|
87 | this._rootDOM.render(vnode);
|
88 | // Resolves after the widget has been rendered.
|
89 | // https://github.com/reactwg/react-18/discussions/5#discussioncomment-798304
|
90 | requestIdleCallback(() => resolve());
|
91 | } else if (vnode) {
|
92 | this._rootDOM.render(vnode);
|
93 | // Resolves after the widget has been rendered.
|
94 | // https://github.com/reactwg/react-18/discussions/5#discussioncomment-798304
|
95 | requestIdleCallback(() => resolve());
|
96 | } else {
|
97 | // If the virtual node is null, unmount the node content
|
98 | this._rootDOM.unmount();
|
99 | this._rootDOM = null;
|
100 | requestIdleCallback(() => resolve());
|
101 | }
|
102 | });
|
103 | }
|
104 |
|
105 | // Set whenever a new render is triggered and resolved when it is finished.
|
106 | renderPromise?: Promise<void>;
|
107 | private _rootDOM: Root | null = null;
|
108 | }
|
109 |
|
110 | /**
|
111 | * An abstract ReactWidget with a model.
|
112 | */
|
113 | export abstract class VDomRenderer<
|
114 | T extends VDomRenderer.IModel | null = null
|
115 | > extends ReactWidget {
|
116 | /**
|
117 | * Create a new VDomRenderer
|
118 | */
|
119 | constructor(model?: T) {
|
120 | super();
|
121 | this.model = (model ?? null) as unknown as T;
|
122 | }
|
123 | /**
|
124 | * A signal emitted when the model changes.
|
125 | */
|
126 | get modelChanged(): ISignal<this, void> {
|
127 | return this._modelChanged;
|
128 | }
|
129 |
|
130 | /**
|
131 | * Set the model and fire changed signals.
|
132 | */
|
133 | set model(newValue: T) {
|
134 | if (this._model === newValue) {
|
135 | return;
|
136 | }
|
137 |
|
138 | if (this._model) {
|
139 | this._model.stateChanged.disconnect(this.update, this);
|
140 | }
|
141 | this._model = newValue;
|
142 | if (newValue) {
|
143 | newValue.stateChanged.connect(this.update, this);
|
144 | }
|
145 | this.update();
|
146 | this._modelChanged.emit(void 0);
|
147 | }
|
148 |
|
149 | /**
|
150 | * Get the current model.
|
151 | */
|
152 | get model(): T {
|
153 | return this._model;
|
154 | }
|
155 |
|
156 | /**
|
157 | * Dispose this widget.
|
158 | */
|
159 | dispose(): void {
|
160 | if (this.isDisposed) {
|
161 | return;
|
162 | }
|
163 | this._model = null!;
|
164 | super.dispose();
|
165 | }
|
166 |
|
167 | private _model: T;
|
168 | private _modelChanged = new Signal<this, void>(this);
|
169 | }
|
170 |
|
171 | /**
|
172 | * Props for the UseSignal component
|
173 | */
|
174 | export interface IUseSignalProps<SENDER, ARGS> {
|
175 | /**
|
176 | * Phosphor signal to connect to.
|
177 | */
|
178 |
|
179 | signal: ISignal<SENDER, ARGS>;
|
180 | /**
|
181 | * Initial value to use for the sender, used before the signal emits a value.
|
182 | * If not provided, initial sender will be undefined
|
183 | */
|
184 | initialSender?: SENDER;
|
185 | /**
|
186 | * Initial value to use for the args, used before the signal emits a value.
|
187 | * If not provided, initial args will be undefined.
|
188 | */
|
189 | initialArgs?: ARGS;
|
190 | /**
|
191 | * Function mapping the last signal value or initial values to an element to render.
|
192 | *
|
193 | * Note: returns `React.ReactNode` as per
|
194 | * https://github.com/sw-yx/react-typescript-cheatsheet#higher-order-componentsrender-props
|
195 | */
|
196 |
|
197 | children: (sender?: SENDER, args?: ARGS) => React.ReactNode;
|
198 | /**
|
199 | * Given the last signal value, should return whether to update the state or not.
|
200 | *
|
201 | * The default unconditionally returns `true`, so you only have to override if you want
|
202 | * to skip some updates.
|
203 | */
|
204 |
|
205 | shouldUpdate?: (sender: SENDER, args: ARGS) => boolean;
|
206 | }
|
207 |
|
208 | /**
|
209 | * State for the UseSignal component
|
210 | */
|
211 | export interface IUseSignalState<SENDER, ARGS> {
|
212 | value: [SENDER?, ARGS?];
|
213 | }
|
214 |
|
215 | /**
|
216 | * UseSignal provides a way to hook up a Lumino signal to a React element,
|
217 | * so that the element is re-rendered every time the signal fires.
|
218 | *
|
219 | * It is implemented through the "render props" technique, using the `children`
|
220 | * prop as a function to render, so that it can be used either as a prop or as a child
|
221 | * of this element
|
222 | * https://reactjs.org/docs/render-props.html
|
223 | *
|
224 | *
|
225 | * Example as child:
|
226 | *
|
227 | * ```
|
228 | * function LiveButton(isActiveSignal: ISignal<any, boolean>) {
|
229 | * return (
|
230 | * <UseSignal signal={isActiveSignal} initialArgs={true}>
|
231 | * {(_, isActive) => <Button isActive={isActive}>}
|
232 | * </UseSignal>
|
233 | * )
|
234 | * }
|
235 | * ```
|
236 | *
|
237 | * Example as prop:
|
238 | *
|
239 | * ```
|
240 | * function LiveButton(isActiveSignal: ISignal<any, boolean>) {
|
241 | * return (
|
242 | * <UseSignal
|
243 | * signal={isActiveSignal}
|
244 | * initialArgs={true}
|
245 | * children={(_, isActive) => <Button isActive={isActive}>}
|
246 | * />
|
247 | * )
|
248 | * }
|
249 | * ```
|
250 | */
|
251 | export class UseSignal<SENDER, ARGS> extends React.Component<
|
252 | IUseSignalProps<SENDER, ARGS>,
|
253 | IUseSignalState<SENDER, ARGS>
|
254 | > {
|
255 | constructor(props: IUseSignalProps<SENDER, ARGS>) {
|
256 | super(props);
|
257 | this.state = { value: [this.props.initialSender, this.props.initialArgs] };
|
258 | }
|
259 |
|
260 | componentDidMount(): void {
|
261 | this.props.signal.connect(this.slot);
|
262 | }
|
263 |
|
264 | componentWillUnmount(): void {
|
265 | this.props.signal.disconnect(this.slot);
|
266 | }
|
267 |
|
268 | private slot = (sender: SENDER, args: ARGS) => {
|
269 | // skip setting new state if we have a shouldUpdate function and it returns false
|
270 | if (this.props.shouldUpdate && !this.props.shouldUpdate(sender, args)) {
|
271 | return;
|
272 | }
|
273 | this.setState({ value: [sender, args] });
|
274 | };
|
275 |
|
276 | render(): React.ReactNode {
|
277 | return this.props.children(...this.state.value);
|
278 | }
|
279 | }
|
280 |
|
281 | /**
|
282 | * The namespace for VDomRenderer statics.
|
283 | */
|
284 | export namespace VDomRenderer {
|
285 | /**
|
286 | * An interface for a model to be used with vdom rendering.
|
287 | */
|
288 | export interface IModel extends IDisposable {
|
289 | /**
|
290 | * A signal emitted when any model state changes.
|
291 | */
|
292 | readonly stateChanged: ISignal<this, void>;
|
293 | }
|
294 | }
|
295 |
|
296 | /**
|
297 | * Concrete implementation of VDomRenderer model.
|
298 | */
|
299 | export class VDomModel implements VDomRenderer.IModel {
|
300 | /**
|
301 | * A signal emitted when any model state changes.
|
302 | */
|
303 | readonly stateChanged = new Signal<this, void>(this);
|
304 |
|
305 | /**
|
306 | * Test whether the model is disposed.
|
307 | */
|
308 | get isDisposed(): boolean {
|
309 | return this._isDisposed;
|
310 | }
|
311 |
|
312 | /**
|
313 | * Dispose the model.
|
314 | */
|
315 | dispose(): void {
|
316 | if (this.isDisposed) {
|
317 | return;
|
318 | }
|
319 | this._isDisposed = true;
|
320 | Signal.clearData(this);
|
321 | }
|
322 |
|
323 | private _isDisposed = false;
|
324 | }
|