@zikojs/notebook 0.9.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zikojs/notebook",
3
- "version": "0.9.0",
3
+ "version": "0.11.0",
4
4
  "description": "A lightweight, extensible JavaScript notebook component with Markdown, executable cells, persistent state, and pluggable runtimes.",
5
5
  "type": "module",
6
6
  "publishConfig": {
@@ -44,6 +44,7 @@
44
44
  "@codemirror/state": "^6.7.4",
45
45
  "@codemirror/view": "^6.43.11",
46
46
  "@zikojs/lucide": "^0.2.2",
47
+ "@zikojs/console": "latest",
47
48
  "acorn": "^8.18.0",
48
49
  "codemirror": "^6.0.2",
49
50
  "lucide": "^1.44.0",
@@ -52,7 +53,7 @@
52
53
  "remark-rehype": "^11.1.2",
53
54
  "vanjs-core": "^1.6.1",
54
55
  "zextra": "^1.15.0",
55
- "ziko": "^2.0.0-beta.16.0"
56
+ "ziko": "latest"
56
57
  },
57
58
  "scripts": {
58
59
  "test": "echo \"Error: no test specified\" && exit 1",
@@ -166,47 +166,62 @@ export function createCodeEditor(app, cellData, actions) {
166
166
  ? view.state.doc.toString()
167
167
  : cellData.code,
168
168
 
169
- sync: (isActive, isMarkdownView) => {
170
- if (!isMarkdownView) {
171
- if (!view) {
172
- requestAnimationFrame(() => initEditor());
173
- } else {
174
- view.dispatch({
175
- effects: [
176
- languageCompartment.reconfigure(
177
- languageFor(cellData.type)
178
- ),
179
-
180
- readOnlyCompartment.reconfigure(
181
- EditorState.readOnly.of(cellData.readonly)
182
- ),
183
-
184
- editableCompartment.reconfigure(
185
- EditorView.editable.of(!cellData.readonly)
186
- )
187
- ]
188
- });
189
-
190
- const currentValue = view.state.doc.toString();
191
-
192
- if (currentValue !== cellData.code) {
193
- view.dispatch({
194
- changes: {
195
- from: 0,
196
- to: currentValue.length,
197
- insert: cellData.code
198
- }
199
- });
200
- }
169
+
170
+ sync: (isActive, isMarkdownView) => {
171
+ if (!isMarkdownView) {
172
+ if (!view) {
173
+ requestAnimationFrame(() => {
174
+ initEditor();
175
+
176
+ // The editor may only become available after initialization.
177
+ // Check the current active cell, not a potentially stale
178
+ // isActive value captured by this callback.
179
+ if (
180
+ app.store.get("activeCellId") === cellData.id &&
181
+ !cellData.readonly &&
182
+ view
183
+ ) {
184
+ view.focus();
201
185
  }
186
+ });
187
+ } else {
188
+ view.dispatch({
189
+ effects: [
190
+ languageCompartment.reconfigure(
191
+ languageFor(cellData.type)
192
+ ),
193
+ readOnlyCompartment.reconfigure(
194
+ EditorState.readOnly.of(cellData.readonly)
195
+ ),
196
+ editableCompartment.reconfigure(
197
+ EditorView.editable.of(!cellData.readonly)
198
+ )
199
+ ]
200
+ });
201
+
202
+ const currentValue = view.state.doc.toString();
203
+
204
+ if (currentValue !== cellData.code) {
205
+ view.dispatch({
206
+ changes: {
207
+ from: 0,
208
+ to: currentValue.length,
209
+ insert: cellData.code
210
+ }
211
+ });
212
+ }
202
213
 
203
- if (isActive && view && !cellData.readonly) {
204
- requestAnimationFrame(() => view.focus());
205
- }
206
- } else if (view) {
207
- view.destroy();
208
- view = null;
214
+ if (
215
+ isActive &&
216
+ !cellData.readonly
217
+ ) {
218
+ requestAnimationFrame(() => view?.focus());
209
219
  }
210
220
  }
221
+ } else if (view) {
222
+ view.destroy();
223
+ view = null;
224
+ }
225
+ }
211
226
  };
212
227
  }
@@ -64,11 +64,19 @@ export const evaluateCodeAsync = async (
64
64
  "TARGET",
65
65
  "__scope__",
66
66
  `
67
- return (async () => {
68
- with (__scope__) {
67
+ // Bind the active TARGET for this cell evaluation
68
+ if (typeof __Ziko__ !== "undefined" && __Ziko__.__Config__) {
69
+ __Ziko__.__Config__.default.target = TARGET;
70
+ __Ziko__.__Config__.default.autoMount = true;
71
+ }
72
+
73
+ return (async () => {
74
+ with (__scope__) {
75
+ return await (async () => {
69
76
  ${compiledCode}
70
- }
71
- })();
77
+ })();
78
+ }
79
+ })();
72
80
  `,
73
81
  );
74
82
 
package/src/index.d.ts CHANGED
@@ -1,5 +1,97 @@
1
- import type { UIElement } from 'ziko/dom/UIElement'
2
- export declare class UINotebook extends UIElement{
3
1
 
2
+ import type { UIElement } from "ziko/dom/UIElement";
3
+
4
+ export type MaybePromise<T> = T | PromiseLike<T>;
5
+
6
+ export interface NotebookCell {
7
+ id?: number;
8
+ type?: "code" | "markdown";
9
+ code?: MaybePromise<string>;
10
+ readonly?: boolean;
11
+ runtime?: string | null;
12
+ isEditingMarkdown?: boolean;
13
+ execCount?: number | null;
14
+ outputNode?: HTMLElement | null;
15
+ hasDomOutput?: boolean;
16
+ }
17
+
18
+ export type NotebookCells = MaybePromise<NotebookCell[]>;
19
+
20
+ export interface NotebookOptions {
21
+ /**
22
+ * Initial cells, or a Promise resolving to the cells.
23
+ */
24
+ cells?: NotebookCells;
25
+
26
+ importMap?: Record<string, string>;
27
+ runCells?: boolean;
28
+ minLines?: number;
29
+ maxCells?: number;
30
+ showHeader?: boolean;
31
+ codeMirrorConfig?: unknown[] | Record<string, unknown>;
32
+ codeMirrorPlugins?: unknown[];
33
+ runtimePlugins?: Array<{
34
+ name: string;
35
+ [key: string]: unknown;
36
+ }>;
37
+ remarkPlugins?: unknown[];
38
+ rehypePlugins?: unknown[];
39
+ }
40
+
41
+ export declare class UINotebook extends UIElement {
42
+ constructor(options?: NotebookOptions);
43
+
44
+ /**
45
+ * Resolves when initial cells and Promise-based cell code
46
+ * have finished loading.
47
+ *
48
+ * Rejects if the initial cells source fails or does not
49
+ * resolve to an array. Individual code-source failures are
50
+ * displayed in their respective cells.
51
+ */
52
+ readonly ready: Promise<this>;
53
+
54
+ readonly id: string;
55
+
56
+ importMap: Record<string, string>;
57
+ runtimePlugins: NotebookOptions["runtimePlugins"];
58
+ codeMirrorConfig: NotebookOptions["codeMirrorConfig"];
59
+ codeMirrorPlugins: unknown[];
60
+ remarkPlugins: unknown[];
61
+ rehypePlugins: unknown[];
62
+ minLines: number;
63
+ maxCells: number;
64
+
65
+ runCell(id: number): Promise<void>;
66
+ runAllCells(): Promise<void>;
67
+
68
+ addCell(
69
+ type?: "code" | "markdown",
70
+ initialCode?: MaybePromise<string> | null,
71
+ afterId?: number | null,
72
+ readonly?: boolean
73
+ ): number | null;
74
+
75
+ deleteCell(id: number): void;
76
+ setActiveCell(id: number): void;
77
+ focusCell(id: number): void;
78
+ clearOutputs(): void;
79
+ resetScope(): Record<string, unknown>;
80
+ getScope(): Record<string, unknown>;
81
+
82
+ getNotebookData(options?: {
83
+ inputs?: boolean;
84
+ outputs?: boolean;
85
+ }): Array<{
86
+ id: number;
87
+ type: "code" | "markdown";
88
+ readonly: boolean;
89
+ order: number | null;
90
+ code?: string;
91
+ outputHtml?: string | null;
92
+ }>;
4
93
  }
5
- export declare function Notebook() : UINotebook
94
+
95
+ export declare function Notebook(
96
+ options?: NotebookOptions
97
+ ): UINotebook;
package/src/notebook.js CHANGED
@@ -1,22 +1,35 @@
1
+
1
2
  import { NotebookStore } from "./store.js";
2
- import {
3
- Header,
4
- CellItem,
3
+ import {
4
+ Header,
5
+ CellItem,
5
6
  MarkdownView,
6
- ErrorOutput
7
+ ErrorOutput,
7
8
  } from "./components/index.js";
8
9
  import { evaluateCodeAsync } from "./compiler/index.js";
9
10
  import { tags, UIElement } from "ziko/dom";
10
- import { call_with_optional_props } from 'ziko/dom/internal-utils'
11
- import { useState, useDerived } from "ziko/hooks";
12
- const { div } = tags
13
- // const [st, setSt] = useState('hh')
14
- // globalThis.mm = useDerived((n)=>n, [st])
15
- // globalThis.pp = tags.p({}, mm).mount(document.body)
16
- // globalThis.st = st
17
- // globalThis.setSt = setSt
18
-
19
- export class UINotebook extends UIElement{
11
+ import { call_with_optional_props } from "ziko/dom/internal-utils";
12
+
13
+ const { div } = tags;
14
+
15
+ const isPromiseLike = (value) =>
16
+ value != null &&
17
+ (typeof value === "object" || typeof value === "function") &&
18
+ typeof value.then === "function";
19
+
20
+ const resolveCode = async (code) => {
21
+ const resolved = await code;
22
+
23
+ if (typeof resolved !== "string") {
24
+ throw new TypeError(
25
+ `Cell code must resolve to a string; received ${typeof resolved}.`
26
+ );
27
+ }
28
+
29
+ return resolved;
30
+ };
31
+
32
+ export class UINotebook extends UIElement {
20
33
  constructor({
21
34
  cells: initialCells = [],
22
35
  importMap = {},
@@ -28,16 +41,20 @@ export class UINotebook extends UIElement{
28
41
  codeMirrorPlugins = [],
29
42
  runtimePlugins = [],
30
43
  remarkPlugins = [],
31
- rehypePlugins = []
32
-
44
+ rehypePlugins = [],
33
45
  } = {}) {
34
- super({ element : 'div'})
35
- this.setAttr({class : 'ziko-notebook'})
36
- this.id = crypto.randomUUID ? crypto.randomUUID() : 'nb_' + Math.random().toString(36).substring(2, 9);
37
-
46
+ super({ element: "div" });
47
+
48
+ this.setAttr({ class: "ziko-notebook" });
49
+
50
+ this.id = globalThis.crypto?.randomUUID
51
+ ? globalThis.crypto.randomUUID()
52
+ : "nb_" + Math.random().toString(36).substring(2, 9);
53
+
38
54
  if (!window.__notebook_scope_map) {
39
55
  window.__notebook_scope_map = new Map();
40
56
  }
57
+
41
58
  this.resetScope();
42
59
 
43
60
  this.importMap = importMap;
@@ -49,157 +66,371 @@ export class UINotebook extends UIElement{
49
66
  this.minLines = minLines;
50
67
  this.maxCells = maxCells;
51
68
 
52
- const startingCells = maxCells !== Infinity ? initialCells.slice(0, maxCells) : initialCells;
53
- let nextCellId = startingCells.length ? Math.max(...startingCells.map(c => c.id || 0)) + 1 : 1;
69
+ this.nextCellId = 1;
54
70
 
55
71
  this.store = new NotebookStore({
56
- cells: startingCells.map(cell => ({
57
- id: cell.id || nextCellId++,
58
- type: cell.type || "code",
59
- code: cell.code || "",
60
- readonly: cell.readonly ?? false,
61
- runtime: cell.runtime || null,
62
- isEditingMarkdown: cell.readonly ? false : (cell.isEditingMarkdown ?? true),
63
- execCount: cell.execCount ?? null,
64
- outputNode: cell.outputNode || null,
65
- hasDomOutput: cell.hasDomOutput ?? false,
66
- _copiedFlag: false,
67
- _dom: null, _editor: null, _actions: null,
68
- _inPrompt: null, _outPrompt: null, _bodySlot: null, _outputGrid: null, _outputRef: null
69
- })),
70
- activeCellId: startingCells.length ? startingCells[0].id : null,
72
+ cells: [],
73
+ activeCellId: null,
71
74
  executionCounter: 1,
72
- executionQueueState: { isRunning: false, currentIndex: 0, total: 0 },
73
- maxCells: this.maxCells
75
+ executionQueueState: {
76
+ isRunning: false,
77
+ currentIndex: 0,
78
+ total: 0,
79
+ },
80
+ maxCells: this.maxCells,
74
81
  });
75
82
 
76
- this.nextCellId = nextCellId;
77
-
78
83
  this.notebookGlobalShortcuts = {
79
- deleteActive: () => this.deleteCell(this.store.get('activeCellId')),
80
- addMdBelow: () => this.addCell("markdown", "", this.store.get('activeCellId')),
81
- addCodeBelow: () => this.addCell("code", "", this.store.get('activeCellId'))
84
+ deleteActive: () =>
85
+ this.deleteCell(this.store.get("activeCellId")),
86
+ addMdBelow: () =>
87
+ this.addCell("markdown", "", this.store.get("activeCellId")),
88
+ addCodeBelow: () =>
89
+ this.addCell("code", "", this.store.get("activeCellId")),
82
90
  };
83
91
 
84
92
  this.cellsContainer = div({ class: "cells-container" }).element;
93
+
85
94
  this.header = Header({
86
95
  store: this.store,
87
96
  actions: {
88
97
  runAll: () => this.runAllCells(),
89
98
  clearOutputs: () => this.clearOutputs(),
90
- addCode: () => this.addCell("code", "", this.store.get('activeCellId')),
91
- addMarkdown: () => this.addCell("markdown", "", this.store.get('activeCellId')),
92
- deleteActive: () => this.deleteCell(this.store.get('activeCellId')),
93
- exportData: () => console.log("Notebook Data Export:", this.getNotebookData())
94
- }
99
+ addCode: () =>
100
+ this.addCell("code", "", this.store.get("activeCellId")),
101
+ addMarkdown: () =>
102
+ this.addCell("markdown", "", this.store.get("activeCellId")),
103
+ deleteActive: () =>
104
+ this.deleteCell(this.store.get("activeCellId")),
105
+ exportData: () =>
106
+ console.log("Notebook Data Export:", this.getNotebookData()),
107
+ },
95
108
  });
96
109
 
97
- if(!showHeader) this.header.style.display = 'none'
110
+ if (!showHeader) {
111
+ this.header.style.display = "none";
112
+ }
98
113
 
99
114
  this.append(
100
- this.header,
101
- div({ class: "notebook" },
102
- this.cellsContainer)
103
- )
104
-
105
- this._renderAllCells();
115
+ this.header,
116
+ div({ class: "notebook" }, this.cellsContainer)
117
+ );
106
118
 
107
- this.store.on('change:activeCellId', (newId, oldId) => {
119
+ this.store.on("change:activeCellId", (newId, oldId) => {
108
120
  if (oldId !== null) {
109
- const prev = this.store.get('cells').find(c => c.id === oldId);
110
- if (prev) this._refreshCell(prev);
121
+ const previous = this.store
122
+ .get("cells")
123
+ .find((cell) => cell.id === oldId);
124
+
125
+ if (previous?._dom) {
126
+ this._refreshCell(previous);
127
+ }
128
+ }
129
+
130
+ const next = this.store
131
+ .get("cells")
132
+ .find((cell) => cell.id === newId);
133
+
134
+ if (next?._dom) {
135
+ this._refreshCell(next);
111
136
  }
112
- const next = this.store.get('cells').find(c => c.id === newId);
113
- if (next) this._refreshCell(next);
114
137
  });
115
138
 
139
+ // Always expose one readiness Promise, whether the initial
140
+ // data is synchronous or asynchronous.
141
+ this.ready = this._initialize(initialCells);
142
+
143
+ // Prevent an unhandled-rejection warning when callers rely on
144
+ // runCells but don't explicitly await notebook.ready.
145
+ // Awaiting notebook.ready still rejects if the initial cells
146
+ // collection itself fails to load.
147
+ this.ready.catch(() => {});
148
+
116
149
  if (runCells) {
117
- setTimeout(() => { this.runAllCells(); }, 200);
150
+ this.ready
151
+ .then(() => this.runAllCells())
152
+ .catch((error) => {
153
+ console.error("Failed to initialize notebook:", error);
154
+ });
155
+ }
156
+ }
157
+
158
+ /**
159
+ * Resolve the initial cells array, normalize it, render it,
160
+ * and resolve any Promise-based cell code.
161
+ */
162
+ async _initialize(initialCells) {
163
+ const resolvedCells = await initialCells;
164
+
165
+ if (!Array.isArray(resolvedCells)) {
166
+ throw new TypeError(
167
+ "Notebook cells must be an array or a Promise resolving to an array."
168
+ );
169
+ }
170
+
171
+ this._replaceInitialCells(resolvedCells);
172
+ await this._resolvePendingCellCode();
173
+
174
+ return this;
175
+ }
176
+
177
+ /**
178
+ * Normalize and replace initial cells.
179
+ * Used for both synchronous and asynchronous initial data.
180
+ */
181
+ _replaceInitialCells(initialCells) {
182
+ const startingCells =
183
+ this.maxCells !== Infinity
184
+ ? initialCells.slice(0, this.maxCells)
185
+ : initialCells;
186
+
187
+ const ids = startingCells
188
+ .map((cell) => cell.id)
189
+ .filter((id) => Number.isFinite(id));
190
+
191
+ this.nextCellId = ids.length ? Math.max(...ids) + 1 : 1;
192
+
193
+ const normalizedCells = startingCells.map((cell) => {
194
+ const id = cell.id ?? this.nextCellId++;
195
+ const source = cell.code ?? "";
196
+ const pendingCode = isPromiseLike(source)
197
+ ? Promise.resolve(source)
198
+ : null;
199
+
200
+ return {
201
+ id,
202
+ type: cell.type || "code",
203
+ code: pendingCode
204
+ ? ""
205
+ : typeof source === "string"
206
+ ? source
207
+ : "",
208
+ _codePromise: pendingCode,
209
+ _codeLoadError: null,
210
+
211
+ readonly: cell.readonly ?? false,
212
+ runtime: cell.runtime || null,
213
+ isEditingMarkdown: cell.readonly
214
+ ? false
215
+ : (cell.isEditingMarkdown ?? true),
216
+ execCount: cell.execCount ?? null,
217
+ outputNode: cell.outputNode || null,
218
+ hasDomOutput: cell.hasDomOutput ?? false,
219
+
220
+ _copiedFlag: false,
221
+ _dom: null,
222
+ _editor: null,
223
+ _actions: null,
224
+ _inPrompt: null,
225
+ _outPrompt: null,
226
+ _bodySlot: null,
227
+ _outputGrid: null,
228
+ _outputRef: null,
229
+ };
230
+ });
231
+
232
+ this.store.set("cells", normalizedCells);
233
+ this.store.set(
234
+ "activeCellId",
235
+ normalizedCells.length ? normalizedCells[0].id : null
236
+ );
237
+
238
+ this._renderAllCells();
239
+ }
240
+
241
+ /**
242
+ * Resolve every pending code source independently.
243
+ * A single failed code request does not block other cells.
244
+ */
245
+ async _resolvePendingCellCode() {
246
+ const cells = this.store.get("cells");
247
+
248
+ await Promise.all(
249
+ cells.map((cell) => this._resolveCellCode(cell))
250
+ );
251
+ }
252
+
253
+ async _resolveCellCode(cellData) {
254
+ if (!cellData._codePromise) {
255
+ return !cellData._codeLoadError;
256
+ }
257
+
258
+ const sourcePromise = cellData._codePromise;
259
+
260
+ try {
261
+ const code = await resolveCode(sourcePromise);
262
+
263
+ // Ignore stale results if the cell's source was replaced
264
+ // while the previous Promise was still pending.
265
+ if (cellData._codePromise !== sourcePromise) {
266
+ return false;
267
+ }
268
+
269
+ cellData.code = code;
270
+ cellData._codePromise = null;
271
+ cellData._codeLoadError = null;
272
+
273
+ if (cellData._dom) {
274
+ this._refreshCell(cellData);
275
+ }
276
+
277
+ return true;
278
+ } catch (error) {
279
+ if (cellData._codePromise !== sourcePromise) {
280
+ return false;
281
+ }
282
+
283
+ cellData._codePromise = null;
284
+ cellData._codeLoadError = error;
285
+
286
+ if (cellData._outputRef) {
287
+ cellData._outputRef.replaceChildren();
288
+ ErrorOutput(error).mount(cellData._outputRef);
289
+ cellData.hasDomOutput = true;
290
+
291
+ if (cellData._dom) {
292
+ this._refreshCell(cellData);
293
+ }
294
+ }
295
+
296
+ console.error(
297
+ `Failed to load code for notebook cell ${cellData.id}:`,
298
+ error
299
+ );
300
+
301
+ return false;
118
302
  }
119
303
  }
120
304
 
121
- _isMaxCellsReached() { return this.store.get('cells').length >= this.maxCells; }
305
+ _isMaxCellsReached() {
306
+ return this.store.get("cells").length >= this.maxCells;
307
+ }
122
308
 
123
309
  _cellActions(cellData) {
124
310
  const app = this;
311
+
125
312
  return {
126
313
  setActive: () => app.setActiveCell(cellData.id),
314
+
127
315
  runCode: async (autoNext = true) => {
316
+ // If this cell's code is still loading, wait for it.
317
+ await app._resolveCellCode(cellData);
318
+
319
+ if (cellData._codeLoadError) {
320
+ return;
321
+ }
322
+
128
323
  const code = cellData._editor.getValue();
129
324
  cellData.code = code;
130
325
 
131
326
  if (cellData.type === "markdown") {
132
327
  cellData.isEditingMarkdown = false;
133
- if (autoNext) app.nextCell(cellData.id, "markdown");
134
- else app._refreshCell(cellData);
328
+
329
+ if (autoNext) {
330
+ app.nextCell(cellData.id, "markdown");
331
+ } else {
332
+ app._refreshCell(cellData);
333
+ }
334
+
135
335
  return;
136
336
  }
137
337
 
138
338
  if (!code.trim()) return;
139
339
 
140
- cellData._outputRef.innerHTML = "";
340
+ cellData._outputRef.replaceChildren();
341
+
141
342
  let hasError = false;
142
343
 
143
344
  try {
144
345
  const scope = app.getScope();
346
+
145
347
  await evaluateCodeAsync(
146
348
  code,
147
349
  cellData._outputRef,
148
350
  {
149
351
  importMap: app.importMap,
150
352
  plugins: app.runtimePlugins,
151
- runtime: cellData.runtime // <-- Passed dynamically from the cell configuration
353
+ runtime: cellData.runtime,
152
354
  },
153
- scope,
355
+ scope
154
356
  );
155
- } catch (err) {
357
+ } catch (error) {
156
358
  hasError = true;
157
- ErrorOutput(err).mount(cellData._outputRef);
359
+ ErrorOutput(error).mount(cellData._outputRef);
158
360
  }
159
361
 
160
- const counter = this.store.get('executionCounter');
161
- this.store.set('executionCounter', counter + 1);
362
+ const counter = app.store.get("executionCounter");
363
+
364
+ app.store.set("executionCounter", counter + 1);
162
365
  cellData.execCount = counter;
163
- cellData.hasDomOutput = cellData._outputRef.childNodes.length > 0 || hasError;
366
+ cellData.hasDomOutput =
367
+ cellData._outputRef.childNodes.length > 0 || hasError;
164
368
 
165
- const container = div({ class: "output-area" }).element;
166
- while (cellData._outputRef.firstChild) container.appendChild(cellData._outputRef.firstChild);
167
- cellData.outputNode = container;
168
- cellData._outputRef.appendChild(container);
369
+ // Output mounting remains handled by the existing component
370
+ // lifecycle / autoMount behavior.
371
+ if (autoNext && !hasError) {
372
+ app.nextCell(cellData.id, "code");
373
+ }
169
374
 
170
- if (autoNext && !hasError) app.nextCell(cellData.id, "code");
171
375
  app._refreshCell(cellData);
172
376
  },
377
+
173
378
  copyCode: async () => {
174
- const textToCopy = cellData._editor.getValue() || cellData.code;
379
+ const textToCopy =
380
+ cellData._editor.getValue() || cellData.code;
381
+
175
382
  try {
176
383
  await navigator.clipboard.writeText(textToCopy);
177
- } catch (err) {
178
- const temporary = tags.textarea(textToCopy).mount(document.body)
179
- temporary.element.select()
384
+ } catch {
385
+ const temporary = tags.textarea(textToCopy).mount(
386
+ document.body
387
+ );
388
+
389
+ temporary.element.select();
180
390
  document.execCommand("copy");
181
- temporary.unmount()
391
+ temporary.unmount();
182
392
  }
183
393
  },
394
+
184
395
  toggleReadonly: () => {
185
396
  cellData.readonly = !cellData.readonly;
186
- if (cellData.readonly && cellData.type === "markdown") cellData.isEditingMarkdown = false;
397
+
398
+ if (
399
+ cellData.readonly &&
400
+ cellData.type === "markdown"
401
+ ) {
402
+ cellData.isEditingMarkdown = false;
403
+ }
404
+
187
405
  app._refreshCell(cellData);
188
406
  },
407
+
189
408
  toggleType: () => {
190
409
  if (cellData.readonly) return;
191
- cellData.type = cellData.type === "code" ? "markdown" : "code";
410
+
411
+ cellData.type =
412
+ cellData.type === "code" ? "markdown" : "code";
413
+
192
414
  cellData.isEditingMarkdown = true;
193
415
  cellData.hasDomOutput = false;
416
+
194
417
  app._refreshCell(cellData);
195
418
  },
419
+
196
420
  editMarkdown: () => {
197
421
  if (cellData.readonly) return;
422
+
198
423
  cellData.isEditingMarkdown = true;
199
424
  app._refreshCell(cellData);
200
425
  },
201
- addBelow: () => app.addCell(cellData.type, null, cellData.id),
426
+
427
+ addBelow: () => {
428
+ app.setActiveCell(cellData.id);
429
+ app.addCell(cellData.type, null, cellData.id);
430
+ },
431
+
202
432
  deleteCell: () => app.deleteCell(cellData.id),
433
+
203
434
  setRuntime: (newRuntime) => {
204
435
  cellData.runtime = newRuntime;
205
436
  app._refreshCell(cellData);
@@ -208,195 +439,355 @@ export class UINotebook extends UIElement{
208
439
  }
209
440
 
210
441
  _buildCellDom(cellData) {
211
- return CellItem({ cellData, app: this, store: this.store });
442
+ return CellItem({
443
+ cellData,
444
+ app: this,
445
+ store: this.store,
446
+ });
212
447
  }
213
448
 
214
449
  _refreshCell(cellData) {
215
- const activeId = this.store.get('activeCellId');
450
+ if (!cellData._dom) return;
451
+
452
+ const activeId = this.store.get("activeCellId");
216
453
  const isActive = activeId === cellData.id;
217
- cellData._dom.className = `cell ${isActive ? "active" : ""} ${cellData.readonly ? "readonly" : ""}`.trim();
218
454
 
219
- const countText = cellData.execCount ? `[${cellData.execCount}]` : "[ ]";
220
- const isMarkdownView = cellData.type === "markdown" && !cellData.isEditingMarkdown;
455
+ cellData._dom.className =
456
+ `cell ${isActive ? "active" : ""} ${
457
+ cellData.readonly ? "readonly" : ""
458
+ }`.trim();
459
+
460
+ const countText = cellData.execCount
461
+ ? `[${cellData.execCount}]`
462
+ : "[ ]";
221
463
 
222
- cellData._inPrompt.textContent = cellData.type === "code" ? `In ${countText}:` : "";
464
+ const isMarkdownView =
465
+ cellData.type === "markdown" &&
466
+ !cellData.isEditingMarkdown;
467
+
468
+ cellData._inPrompt.textContent =
469
+ cellData.type === "code" ? `In ${countText}:` : "";
223
470
 
224
471
  cellData._editor.sync(isActive, isMarkdownView);
472
+
225
473
  cellData._bodySlot.replaceChildren(
226
- isMarkdownView ? MarkdownView({
227
- cellData,
228
- actions: cellData._actions,
229
- remarkPlugins : this.remarkPlugins,
230
- rehypePlugins : this.rehypePlugins
231
- }) : cellData._editor.dom
474
+ isMarkdownView
475
+ ? MarkdownView({
476
+ cellData,
477
+ actions: cellData._actions,
478
+ remarkPlugins: this.remarkPlugins,
479
+ rehypePlugins: this.rehypePlugins,
480
+ })
481
+ : cellData._editor.dom
232
482
  );
233
483
 
234
- const showOutput = cellData.type === "code" && cellData.hasDomOutput;
235
- cellData._outputGrid.style.display = showOutput ? "grid" : "none";
484
+ const showOutput =
485
+ cellData.type === "code" && cellData.hasDomOutput;
486
+
487
+ cellData._outputGrid.style.display =
488
+ showOutput ? "grid" : "none";
489
+
236
490
  cellData._outPrompt.textContent = `Out ${countText}:`;
237
491
  }
238
492
 
239
493
  _renderAllCells() {
240
- const cells = this.store.get('cells');
241
- this.cellsContainer.replaceChildren(...cells.map(c => c._dom || this._buildCellDom(c)));
494
+ const cells = this.store.get("cells");
495
+
496
+ this.cellsContainer.replaceChildren(
497
+ ...cells.map(
498
+ (cell) => cell._dom || this._buildCellDom(cell)
499
+ )
500
+ );
242
501
  }
243
502
 
244
503
  setActiveCell(id) {
245
- const cells = this.store.get('cells');
246
- const target = cells.find(c => c.id === id);
504
+ const cells = this.store.get("cells");
505
+ const target = cells.find((cell) => cell.id === id);
247
506
 
248
- if (!target) return;
507
+ if (!target) return;
249
508
 
250
- const currentActiveId = this.store.get('activeCellId');
509
+ const currentActiveId = this.store.get("activeCellId");
251
510
 
252
- if (currentActiveId === id) {
253
- return;
254
- }
511
+ if (currentActiveId === id) return;
255
512
 
256
- // Only one cell can be active.
257
- cells.forEach(cell => {
258
- if (cell.id !== id && cell.type === "markdown") {
259
- cell.isEditingMarkdown = false;
260
- }
261
- });
513
+ cells.forEach((cell) => {
514
+ if (cell.id !== id && cell.type === "markdown") {
515
+ cell.isEditingMarkdown = false;
516
+ }
517
+ });
262
518
 
263
- this.store.set('activeCellId', id);
519
+ this.store.set("activeCellId", id);
264
520
 
265
- // Refresh every cell so the previous active cell loses "active".
266
- cells.forEach(cell => {
267
- if (cell._dom) {
268
- this._refreshCell(cell);
269
- }
270
- });
271
- }
272
-
273
- focusCell(targetId) {
274
- // Close editing on other markdown cells before focusing
275
- this.store.get('cells').forEach(c => {
276
- if (c.type === "markdown" && c.isEditingMarkdown && c.id !== targetId) {
277
- c.isEditingMarkdown = false;
278
- this._refreshCell(c);
521
+ cells.forEach((cell) => {
522
+ if (cell._dom) {
523
+ this._refreshCell(cell);
279
524
  }
280
525
  });
526
+ }
527
+
528
+ focusCell(id) {
529
+ const cells = this.store.get("cells");
530
+ const cell = cells.find((c) => c.id === id);
281
531
 
282
- const target = this.store.get('cells').find(c => c.id === targetId);
283
- if (target && target.type === "markdown" && !target.readonly) {
284
- target.isEditingMarkdown = true;
532
+ if (!cell) return;
533
+
534
+ for (const c of cells) {
535
+ if (c.type === "markdown" && c.id !== id) {
536
+ c.isEditingMarkdown = false;
285
537
  }
286
- this.store.set('activeCellId', targetId);
287
538
  }
288
539
 
289
- addCell(type = "code", initialCode = null, afterId = null, readonly = false) {
290
- const cells = [...this.store.get('cells')];
291
- if (cells.length >= this.maxCells) {
292
- console.warn(`Cannot create cell: Maximum cell limit (${this.maxCells}) reached.`);
293
- return null;
540
+ if (cell.type === "markdown" && !cell.readonly) {
541
+ cell.isEditingMarkdown = true;
542
+ }
543
+
544
+ this.store.set("activeCellId", id);
545
+ this._refreshCell(cell);
546
+
547
+ // Scroll the target cell into view when it is outside the viewport.
548
+ requestAnimationFrame(() => {
549
+ const element = cell._dom;
550
+
551
+ if (!element?.isConnected) return;
552
+
553
+ const rect = element.getBoundingClientRect();
554
+ const viewportHeight = window.innerHeight;
555
+
556
+ if (rect.top < 0 || rect.bottom > viewportHeight) {
557
+ element.scrollIntoView({
558
+ behavior: "smooth",
559
+ block: rect.top < 0 ? "start" : "nearest",
560
+ });
294
561
  }
562
+ });
563
+ }
295
564
 
296
- const newId = this.nextCellId++;
297
- const defaultText = type === "markdown" ? "### New Markdown Cell\nClick to edit..." : "";
298
- const newCell = {
299
- id: newId, type, code: initialCode !== null ? initialCode : defaultText, readonly,
300
- isEditingMarkdown: !readonly && type === "markdown" && initialCode === null,
301
- runtime: null,
302
- execCount: null, outputNode: null, hasDomOutput: false, _copiedFlag: false,
303
- _dom: null, _editor: null, _actions: null,
304
- _inPrompt: null, _outPrompt: null, _bodySlot: null, _outputGrid: null, _outputRef: null
305
- };
565
+
566
+
567
+ addCell(type = "code", initialCode = null, afterId = null, readonly = false) {
568
+ const cells = [...this.store.get("cells")];
569
+
570
+ if (cells.length >= this.maxCells) {
571
+ console.warn(
572
+ `Cannot create cell: Maximum cell limit (${this.maxCells}) reached.`
573
+ );
574
+ return null;
575
+ }
306
576
 
307
- if (afterId === null) {
577
+ // Prefer the cell associated with the clicked "+" button.
578
+ // Fall back to the last focused cell, then the last cell.
579
+ const sourceCell =
580
+ (afterId !== null
581
+ ? cells.find((cell) => cell.id === afterId)
582
+ : null) ??
583
+ cells.find(
584
+ (cell) => cell.id === this.store.get("activeCellId")
585
+ ) ??
586
+ cells.at(-1);
587
+
588
+ const newId = this.nextCellId++;
589
+ const defaultText =
590
+ type === "markdown"
591
+ ? "### New Markdown Cell\nClick to edit..."
592
+ : "";
593
+
594
+ const newCell = {
595
+ id: newId,
596
+ type,
597
+ code: initialCode !== null ? initialCode : defaultText,
598
+ readonly,
599
+ isEditingMarkdown:
600
+ !readonly && type === "markdown" && initialCode === null,
601
+ runtime: sourceCell?.runtime ?? null,
602
+ execCount: null,
603
+ outputNode: null,
604
+ hasDomOutput: false,
605
+ _copiedFlag: false,
606
+ _dom: null,
607
+ _editor: null,
608
+ _actions: null,
609
+ _inPrompt: null,
610
+ _outPrompt: null,
611
+ _bodySlot: null,
612
+ _outputGrid: null,
613
+ _outputRef: null,
614
+ };
615
+
616
+ if (afterId === null) {
617
+ cells.push(newCell);
618
+ } else {
619
+ const index = cells.findIndex((cell) => cell.id === afterId);
620
+ if (index === -1) {
308
621
  cells.push(newCell);
309
622
  } else {
310
- const index = cells.findIndex(c => c.id === afterId);
311
623
  cells.splice(index + 1, 0, newCell);
312
624
  }
313
-
314
- this.store.set('cells', cells);
315
- this._renderAllCells();
316
- this.focusCell(newId);
317
- return newId;
318
625
  }
319
626
 
627
+ this.store.set("cells", cells);
628
+ this._renderAllCells();
629
+ this.focusCell(newId);
630
+
631
+ return newId;
632
+ }
633
+
320
634
  deleteCell(id) {
321
- const cells = this.store.get('cells');
635
+ const cells = this.store.get("cells");
636
+
322
637
  if (cells.length <= 1) return;
323
- const index = cells.findIndex(c => c.id === id);
324
- const updatedCells = cells.filter(c => c.id !== id);
325
-
326
- this.store.set('cells', updatedCells);
638
+
639
+ const index = cells.findIndex((cell) => cell.id === id);
640
+ const updatedCells = cells.filter(
641
+ (cell) => cell.id !== id
642
+ );
643
+
644
+ this.store.set("cells", updatedCells);
327
645
  this._renderAllCells();
328
- const nextActiveIndex = Math.min(index, updatedCells.length - 1);
646
+
647
+ const nextActiveIndex = Math.min(
648
+ Math.max(index, 0),
649
+ updatedCells.length - 1
650
+ );
651
+
329
652
  this.focusCell(updatedCells[nextActiveIndex].id);
330
653
  }
331
654
 
332
- nextCell(currentId, defaultType) {
333
- const cells = this.store.get('cells');
334
- const idx = cells.findIndex(c => c.id === currentId);
335
- if (idx === cells.length - 1) this.addCell(defaultType, null, currentId);
336
- else this.focusCell(cells[idx + 1].id);
655
+
656
+ nextCell(currentId, defaultType) {
657
+ const cells = this.store.get("cells");
658
+ const index = cells.findIndex((cell) => cell.id === currentId);
659
+
660
+ if (index === -1) return;
661
+
662
+ if (index === cells.length - 1) {
663
+ const newId = this.addCell(defaultType, null, currentId);
664
+
665
+ if (newId !== null) {
666
+ this.focusCell(newId);
667
+ }
668
+
669
+ return;
337
670
  }
338
671
 
672
+ this.focusCell(cells[index + 1].id);
673
+ }
674
+
339
675
  async runCell(id) {
340
- const cell = this.store.get('cells').find(c => c.id === id);
341
- if (cell && cell._actions) await cell._actions.runCode(false);
676
+ await this.ready;
677
+
678
+ const cell = this.store
679
+ .get("cells")
680
+ .find((item) => item.id === id);
681
+
682
+ if (cell?._actions) {
683
+ await cell._actions.runCode(false);
684
+ }
342
685
  }
343
686
 
344
687
  async runAllCells() {
345
- const cells = this.store.get('cells');
346
- const codeCells = cells.filter(c => c.type === "code");
688
+ await this.ready;
689
+
690
+ const cells = this.store.get("cells");
691
+ const codeCells = cells.filter(
692
+ (cell) => cell.type === "code"
693
+ );
694
+
347
695
  if (codeCells.length === 0) return;
348
696
 
349
- this.store.set('executionQueueState', { isRunning: true, currentIndex: 0, total: codeCells.length });
697
+ this.store.set("executionQueueState", {
698
+ isRunning: true,
699
+ currentIndex: 0,
700
+ total: codeCells.length,
701
+ });
702
+
703
+ try {
704
+ for (const cell of cells) {
705
+ if (cell.type !== "code" || !cell._actions) {
706
+ continue;
707
+ }
350
708
 
351
- for (const cell of cells) {
352
- if (cell.type === "code" && cell._actions) {
353
- this.store.set('executionQueueState', {
354
- isRunning: true,
355
- currentIndex: codeCells.indexOf(cell),
356
- total: codeCells.length
709
+ this.store.set("executionQueueState", {
710
+ isRunning: true,
711
+ currentIndex: codeCells.indexOf(cell),
712
+ total: codeCells.length,
357
713
  });
714
+
358
715
  await cell._actions.runCode(false);
359
716
  }
717
+ } finally {
718
+ this.store.set("executionQueueState", {
719
+ isRunning: false,
720
+ currentIndex: 0,
721
+ total: 0,
722
+ });
360
723
  }
361
-
362
- this.store.set('executionQueueState', { isRunning: false, currentIndex: 0, total: 0 });
363
724
  }
364
725
 
365
726
  clearOutputs() {
366
- this.store.get('cells').forEach(cell => {
727
+ this.store.get("cells").forEach((cell) => {
367
728
  cell.execCount = null;
368
729
  cell.outputNode = null;
369
730
  cell.hasDomOutput = false;
370
- if (cell._outputRef) cell._outputRef.innerHTML = "";
731
+
732
+ if (cell._outputRef) {
733
+ cell._outputRef.replaceChildren();
734
+ }
735
+ });
736
+
737
+ this.store.set("executionCounter", 1);
738
+
739
+ this.store.get("cells").forEach((cell) => {
740
+ this._refreshCell(cell);
371
741
  });
372
- this.store.set('executionCounter', 1);
373
- this.store.get('cells').forEach(c => this._refreshCell(c));
374
742
  }
375
743
 
376
744
  resetScope() {
377
745
  if (!window.__notebook_scope_map) {
378
746
  window.__notebook_scope_map = new Map();
379
747
  }
748
+
380
749
  const newScope = Object.create(null);
750
+
381
751
  window.__notebook_scope_map.set(this.id, newScope);
752
+
382
753
  return newScope;
383
754
  }
384
755
 
385
756
  getScope() {
386
- if (!window.__notebook_scope_map || !window.__notebook_scope_map.has(this.id)) {
757
+ if (
758
+ !window.__notebook_scope_map ||
759
+ !window.__notebook_scope_map.has(this.id)
760
+ ) {
387
761
  return this.resetScope();
388
762
  }
763
+
389
764
  return window.__notebook_scope_map.get(this.id);
390
765
  }
391
766
 
392
- getNotebookData({ inputs = true, outputs = true } = {}) {
393
- return this.store.get('cells').map(cell => {
394
- const exportedCell = { id: cell.id, type: cell.type, readonly: cell.readonly, order: cell.execCount };
395
- if (inputs) exportedCell.code = cell.code;
396
- if (outputs) exportedCell.outputHtml = cell.outputNode ? cell.outputNode.innerHTML : null;
397
- return exportedCell;
398
- });
399
- }
767
+
768
+ getNotebookData({ inputs = true, outputs = true } = {}) {
769
+ return this.store.get("cells").map((cell) => {
770
+ const exportedCell = {
771
+ id: cell.id,
772
+ type: cell.type,
773
+ readonly: cell.readonly,
774
+ runtime: cell.runtime ?? null,
775
+ order: cell.execCount,
776
+ };
777
+
778
+ if (inputs) {
779
+ exportedCell.code = cell._editor?.getValue() ?? cell.code;
780
+ }
781
+
782
+ if (outputs) {
783
+ exportedCell.outputHtml = cell.outputNode
784
+ ? cell.outputNode.innerHTML
785
+ : cell._outputRef?.innerHTML ?? null;
786
+ }
787
+
788
+ return exportedCell;
789
+ });
790
+ }
400
791
  }
401
792
 
402
- export const Notebook = call_with_optional_props(UINotebook)
793
+ export const Notebook = call_with_optional_props(UINotebook);