-
Notifications
You must be signed in to change notification settings - Fork 0
/
mod.ts
326 lines (299 loc) · 10.3 KB
/
mod.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
// deno-lint-ignore-file ban-types
import { Inspector, Runtime } from "npm:@observablehq/[email protected]";
import * as stdlib from "npm:@observablehq/[email protected]";
/**
* For convenience, this module re-exports the Observable standard library.
*/
export const library: stdlib.Library = new stdlib.Library();
type ObserverVisibility = "hidden" | "visible";
type Definition = Function;
type Inputs = string[];
/**
* A CelineModule is a wrapper around an Observable runtime, a derived Observable runtime module, and a document.
*
* Its various cell constructors alter both the module and the document to create reactive cells.
*/
export class CelineModule {
/**
* The document object to create elements in.
*/
public document: Document;
/**
* The Observable runtime module to define variables in.
*/
public module: Runtime.Module;
/**
* @public
* @type {stdlib.Library}
* @description For convenience, this class re-exports the Observable standard library.
*/
public library: stdlib.Library = library;
/**
* Creates a new CelineModule instance.
* @param document - The document object to create elements in
* @param module - The Observable runtime module to define variables in.
*/
constructor(document: Document, module: Runtime.Module) {
this.document = document;
this.module = module;
}
/**
* Creates a new CelineModule with a fresh Observable runtime.
* @deprecated Use `usingNewObservableRuntimeAndModule` instead.
* @param document - The document object to create elements in
* @returns A new CelineModule instance
*/
static usingNewObservableRuntime(document: Document): CelineModule {
return CelineModule.usingNewObservableRuntimeAndModule(document);
}
/**
* Creates a new CelineModule with a fresh Observable runtime and module.
* @param document - The document object to create elements in
* @returns A new CelineModule instance
*/
static usingNewObservableRuntimeAndModule(document: Document): CelineModule {
const runtime = new Runtime();
const module = runtime.module();
return new CelineModule(document, module);
}
/**
* Creates a new CelineModule using an existing Observable module.
* This is just an alias of the default constructor.
* @param document - The document object to create elements in
* @param module - The existing Observable runtime module to use
* @returns A new CelineModule instance
*/
static usingExistingObservableModule(
document: Document,
module: Runtime.Module
): CelineModule {
return new CelineModule(document, module);
}
/**
* Creates an Inspector for observing cell output.
* @private
* @param name - The name/id of the element to attach the observer to
* @returns A new Inspector instance
* @throws Error if no element with the specified id is found
*/
private observer(name: string): Inspector {
const div = this.document.createElement("div");
const elementContainer = this.document.getElementById(name);
if (!elementContainer) {
throw new Error(`No element with id ${name} found.
celine tried to find a DOM element with id="${name}" to attach an observer to because some cell with name "${name}" was declared,
but it couldn't find one.
Either:
1) Annotate an element with id="${name}" in your HTML file. This is where the cell's current value will be displayed.
2) Use celine.silent instead of celine.cell if you don't want to display the cell's current value anywhere.`);
}
elementContainer.parentNode!.insertBefore(div, elementContainer);
return new Inspector(div);
}
/**
* Declares a reactive cell that renders its value above its element container.
* The cell can depend on other cells and its definition can return values of type
* T, Promise<T>, Iterator<T>, or AsyncIterator<T>.
*
* The element's id must match the name parameter.
*
* @example
* ```typescript
* // Counter that increments every second
* celine.cell("counter", async function* () {
* let i = 0;
* while (true) {
* await library.Promises.delay(1000);
* yield i++;
* }
* });
*
* // FizzBuzz implementation depending on counter
* celine.cell("fizzbuzz", ["counter"], (counter) => {
* if (counter % 15 === 0) return "FizzBuzz";
* if (counter % 3 === 0) return "Fizz";
* if (counter % 5 === 0) return "Buzz";
* return counter;
* });
* ```
*/
public cell(name: string, inputs: Inputs, definition: Definition): void;
public cell(name: string, definition: Definition): void;
public cell(
name: string,
inputsOrDefinition: Inputs | Definition,
maybeDefinition?: Definition
): void {
this._cell("visible", name, inputsOrDefinition, maybeDefinition);
}
/**
* Declares a cell that doesn't render a value above an element container.
* Otherwise behaves the same as `cell()`.
*
* @example
* ```typescript
* celine.silentCell("hidden", () => {
* return "This string does NOT render above any element";
* });
* ```
*/
public silentCell(name: string, inputs: Inputs, definition: Definition): void;
public silentCell(name: string, definition: Definition): void;
public silentCell(
name: string,
inputsOrDefinition: Inputs | Definition,
maybeDefinition?: Definition
): void {
this._cell("hidden", name, inputsOrDefinition, maybeDefinition);
}
/**
* Internal method for creating cells with specified visibility.
* @private
*/
private _cell(
observerVisibility: ObserverVisibility,
name: string,
inputsOrDefinition: Inputs | Definition,
maybeDefinition?: Definition
): void {
const variable = this.module._scope.get(name) ||
this.module.variable(observerVisibility === "visible" ? this.observer(name) : undefined);
const inputs: Inputs = Array.isArray(inputsOrDefinition)
? inputsOrDefinition
: [];
const definition: Definition = Array.isArray(inputsOrDefinition)
? maybeDefinition as Definition
: inputsOrDefinition;
if (inputs && definition) {
variable.define(name, inputs, definition);
} else {
variable.define(name, definition);
}
}
/**
* Special constructor designed to work with Observable Inputs. It declares two reactive cells:
* - The "name" cell for the value
* - The "viewof name" cell for the DOM element itself
*
* For creating custom inputs, see the Observable "Synchronized Inputs" guide.
*
* @example
* ```typescript
* // Text input with placeholder
* celine.viewof("name", () => {
* return Inputs.text({placeholder: "What's your name?"});
* });
*
* // Greeting that depends on the name input
* celine.cell("greeting", ["name"], (name) => {
* return `Hello, ${name}!`;
* });
* ```
*/
public viewof(name: string, inputs: Inputs, definition: Definition): void;
public viewof(name: string, definition: Definition): void;
public viewof(
name: string,
inputsOrDefinition: Inputs | Definition,
maybeDefinition?: Definition
): void {
this._cell(
"visible",
`viewof ${name}`,
inputsOrDefinition,
maybeDefinition
);
this._cell(
"hidden",
name,
[`viewof ${name}`],
(inpt: any) => library.Generators.input(inpt)
);
}
/**
* Declares a cell and returns a reference that can be mutated.
* Mutations propagate to cells that depend upon it.
*
* @example
* ```typescript
* // Create a mutable reference
* const ref = celine.mutable("ref", 3);
*
* // Create buttons to manipulate the reference
* celine.viewof("increment", () => {
* const increment = () => ++ref.value;
* const reset = () => ref.value = 0;
* return Inputs.button([["Increment", increment], ["Reset", reset]]);
* });
*
* // Display that depends on the reference
* celine.cell("sword", ["ref"], (ref) => {
* return `↜(╰ •ω•)╯ |${'═'.repeat(ref)}═ﺤ`;
* });
* ```
*/
public mutable<T>(name: string, value: T): typeof Mutable<T> {
const m = Mutable(value);
// @ts-ignore - some really scary stuff going on here
this.cell(name, m);
// @ts-ignore - some really scary stuff going on here
return m;
}
/**
* Like {@link mutable}, but doesn't render the value above the element container.
*/
public silentMutable<T>(name: string, value: T): typeof Mutable<T> {
const m = Mutable(value);
// @ts-ignore - some really scary stuff going on here
this.silentCell(name, m);
// @ts-ignore - some really scary stuff going on here
return m;
}
}
/**
* Creates a mutable value wrapper with Observable integration.
* @template T - The type of the mutable value
* @param value - The initial value
* @returns A mutable object with getter/setter for the value
*/
function Mutable<T>(value: T): { value: T } {
let change: (value: T) => void;
return Object.defineProperty(
library.Generators.observe((_: (value: T) => void) => {
change = _;
if (value !== undefined) change(value);
}),
"value",
{
get: () => value,
set: (x: T) => void change((value = x)),
}
) as { value: T };
}
/**
* Sets up automatic reevaluation of editable script elements on blur.
* When a script element marked with the specified class loses focus,
* it will be replaced with a new script element containing the updated content.
*
* @param document - The document object containing the script elements
* @param className - The class name of script elements to watch
*/
export function reevaluateOnBlur(document: Document, className: string): void {
function reevaluate(event: Event) {
const old = event.target as HTMLScriptElement;
const neww = document.createElement("script");
neww.textContent = old.textContent;
for (let i = 0; i < old.attributes.length; i++) {
neww.setAttribute(old.attributes[i].name, old.attributes[i].value || "");
}
// register the blur listener again (given we've made a new script element)
neww.addEventListener("blur", reevaluate);
old.parentNode!.insertBefore(neww, old);
old.parentNode!.removeChild(old);
}
document
.querySelectorAll(`script.${className}[contenteditable='true']`)
.forEach((script: Element) => {
script.addEventListener("blur", reevaluate as EventListener);
});
}