@zikojs/notebook 0.15.0 → 0.15.1

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