| |
| |
| |
|
|
|
|
| class NotebookEngine {
|
| constructor() {
|
| this.cells = [];
|
| this.selectedCell = null;
|
| this.metadata = {};
|
| this.initDOM();
|
| }
|
|
|
| |
| |
|
|
| initDOM() {
|
| this.elements = {
|
| cellsContainer: document.getElementById('notebook-cells'),
|
| statusText: document.getElementById('status-text'),
|
| executionStats: document.getElementById('execution-stats'),
|
| metadataList: document.getElementById('metadata-list'),
|
| dependencySvg: document.getElementById('dependency-svg'),
|
| };
|
| }
|
|
|
| |
| |
|
|
| addCell(type = 'code', index = -1) {
|
| if (index === -1) {
|
| index = this.cells.length;
|
| }
|
|
|
| const cell = {
|
| index: index,
|
| type: type,
|
| source: '',
|
| output: '',
|
| hash: '',
|
| executionTime: 0,
|
| status: 'ready',
|
| };
|
|
|
| this.cells.push(cell);
|
| this.renderCell(cell);
|
| return cell;
|
| }
|
|
|
| |
| |
|
|
| renderCell(cell) {
|
| const cellDiv = document.createElement('div');
|
| cellDiv.className = 'notebook-cell';
|
| cellDiv.dataset.index = cell.index;
|
| cellDiv.dataset.cellType = cell.type;
|
|
|
| cellDiv.innerHTML = `
|
| <div class="cell-index">Cell [${cell.index}] • ${cell.type}</div>
|
| <div class="cell-editor">
|
| <textarea placeholder="Enter ${cell.type} here..." spellcheck="false"></textarea>
|
| </div>
|
| <div class="cell-output"></div>
|
| <div class="cell-receipt" style="display: none;"></div>
|
| `;
|
|
|
|
|
| const textarea = cellDiv.querySelector('textarea');
|
| textarea.addEventListener('input', (e) => {
|
| cell.source = e.target.value;
|
| this.updateCellHash(cell);
|
| });
|
|
|
| textarea.addEventListener('keydown', (e) => {
|
| if (e.ctrlKey && e.key === 'Enter') {
|
| this.executeCell(cell.index);
|
| }
|
| });
|
|
|
| cellDiv.addEventListener('click', () => {
|
| this.selectCell(cell.index);
|
| });
|
|
|
| this.elements.cellsContainer.appendChild(cellDiv);
|
| }
|
|
|
| |
| |
|
|
| selectCell(index) {
|
| if (this.selectedCell !== null) {
|
| const prevCell = this.elements.cellsContainer.querySelector(
|
| `[data-index="${this.selectedCell}"]`
|
| );
|
| if (prevCell) {
|
| prevCell.style.borderColor = '';
|
| }
|
| }
|
|
|
| this.selectedCell = index;
|
| const cellDiv = this.elements.cellsContainer.querySelector(
|
| `[data-index="${index}"]`
|
| );
|
| if (cellDiv) {
|
| cellDiv.style.borderColor = 'var(--color-cyan)';
|
| }
|
| }
|
|
|
| |
| |
|
|
| async executeCell(index) {
|
| const cell = this.cells[index];
|
| if (!cell) return;
|
|
|
| cell.status = 'running';
|
| const startTime = performance.now();
|
|
|
| const cellDiv = this.elements.cellsContainer.querySelector(`[data-index="${index}"]`);
|
| const outputDiv = cellDiv.querySelector('.cell-output');
|
|
|
| try {
|
|
|
| await new Promise(resolve => setTimeout(resolve, 500));
|
|
|
|
|
| cell.output = `Output from cell [${index}]: ${cell.source.substring(0, 50)}...`;
|
| outputDiv.textContent = cell.output;
|
| outputDiv.classList.remove('error');
|
| outputDiv.classList.add('success');
|
|
|
| cell.status = 'success';
|
| } catch (error) {
|
| cell.output = `Error: ${error.message}`;
|
| outputDiv.textContent = cell.output;
|
| outputDiv.classList.remove('success');
|
| outputDiv.classList.add('error');
|
|
|
| cell.status = 'error';
|
| }
|
|
|
| cell.executionTime = performance.now() - startTime;
|
| this.updateStats();
|
| }
|
|
|
| |
| |
|
|
| async executeAll() {
|
| this.updateStatus('Executing all cells...');
|
|
|
| for (let i = 0; i < this.cells.length; i++) {
|
| await this.executeCell(i);
|
| }
|
|
|
| this.updateStatus('All cells executed');
|
| }
|
|
|
| |
| |
|
|
| updateCellHash(cell) {
|
| const data = cell.source + cell.output;
|
| let hash = 0;
|
| for (let i = 0; i < data.length; i++) {
|
| const char = data.charCodeAt(i);
|
| hash = ((hash << 5) - hash) + char;
|
| }
|
| cell.hash = Math.abs(hash).toString(16);
|
| }
|
|
|
| |
| |
|
|
| updateStats() {
|
| const totalExecutionTime = this.cells.reduce((sum, c) => sum + c.executionTime, 0);
|
| const totalCells = this.cells.length;
|
|
|
| this.elements.executionStats.textContent =
|
| `${totalCells} cells | ${Math.round(totalExecutionTime)}ms`;
|
|
|
| this.updateMetadata();
|
| }
|
|
|
| |
| |
|
|
| updateMetadata() {
|
| this.elements.metadataList.innerHTML = '';
|
|
|
| const metadata = [
|
| { label: 'Cells', value: this.cells.length },
|
| { label: 'Successful', value: this.cells.filter(c => c.status === 'success').length },
|
| { label: 'Errors', value: this.cells.filter(c => c.status === 'error').length },
|
| { label: 'Total time', value: `${Math.round(this.cells.reduce((s, c) => s + c.executionTime, 0))}ms` },
|
| ];
|
|
|
| for (const item of metadata) {
|
| const div = document.createElement('div');
|
| div.innerHTML = `<span>${item.label}:</span> ${item.value}`;
|
| this.elements.metadataList.appendChild(div);
|
| }
|
| }
|
|
|
| |
| |
|
|
| updateStatus(text) {
|
| this.elements.statusText.textContent = text;
|
| }
|
|
|
| |
| |
|
|
| renderDependencyGraph() {
|
| const svg = this.elements.dependencySvg;
|
| if (!svg) return;
|
|
|
| const width = svg.width.baseVal.value;
|
| const height = svg.height.baseVal.value;
|
|
|
|
|
| svg.innerHTML = '';
|
|
|
|
|
| const nodeCount = Math.min(this.cells.length, 10);
|
| const radius = 15;
|
| const padding = 20;
|
| const cx = width / 2;
|
| const cy = height / 2;
|
| const maxRadius = Math.min(width, height) / 2 - padding;
|
|
|
|
|
| for (let i = 0; i < nodeCount; i++) {
|
| const angle = (i / nodeCount) * 2 * Math.PI;
|
| const x = cx + maxRadius * Math.cos(angle);
|
| const y = cy + maxRadius * Math.sin(angle);
|
|
|
|
|
| const circle = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
|
| circle.setAttribute('cx', x);
|
| circle.setAttribute('cy', y);
|
| circle.setAttribute('r', radius);
|
| svg.appendChild(circle);
|
|
|
|
|
| const text = document.createElementNS('http://www.w3.org/2000/svg', 'text');
|
| text.setAttribute('x', x);
|
| text.setAttribute('y', y + 3);
|
| text.textContent = i;
|
| svg.appendChild(text);
|
| }
|
|
|
|
|
| for (let i = 0; i < nodeCount - 1; i++) {
|
| const angle1 = (i / nodeCount) * 2 * Math.PI;
|
| const angle2 = ((i + 1) / nodeCount) * 2 * Math.PI;
|
|
|
| const x1 = cx + maxRadius * Math.cos(angle1);
|
| const y1 = cy + maxRadius * Math.sin(angle1);
|
| const x2 = cx + maxRadius * Math.cos(angle2);
|
| const y2 = cy + maxRadius * Math.sin(angle2);
|
|
|
| const line = document.createElementNS('http://www.w3.org/2000/svg', 'line');
|
| line.setAttribute('x1', x1);
|
| line.setAttribute('y1', y1);
|
| line.setAttribute('x2', x2);
|
| line.setAttribute('y2', y2);
|
| svg.appendChild(line);
|
| }
|
| }
|
|
|
| |
| |
|
|
| exportJSON() {
|
| return {
|
| title: document.querySelector('#notebook-header h1')?.textContent || 'Untitled',
|
| cells: this.cells.map(c => ({
|
| index: c.index,
|
| type: c.type,
|
| source: c.source,
|
| output: c.output,
|
| })),
|
| timestamp: new Date().toISOString(),
|
| };
|
| }
|
|
|
| |
| |
|
|
| importJSON(data) {
|
| this.cells = [];
|
| this.elements.cellsContainer.innerHTML = '';
|
|
|
| for (const cellData of data.cells || []) {
|
| const cell = this.addCell(cellData.type, cellData.index);
|
| cell.source = cellData.source;
|
| cell.output = cellData.output;
|
|
|
|
|
| const textarea = this.elements.cellsContainer.querySelector(
|
| `[data-index="${cell.index}"] textarea`
|
| );
|
| if (textarea) {
|
| textarea.value = cell.source;
|
| }
|
|
|
| const outputDiv = this.elements.cellsContainer.querySelector(
|
| `[data-index="${cell.index}"] .cell-output`
|
| );
|
| if (outputDiv && cell.output) {
|
| outputDiv.textContent = cell.output;
|
| }
|
| }
|
|
|
| this.updateStats();
|
| this.renderDependencyGraph();
|
| }
|
|
|
| |
| |
|
|
| getStats() {
|
| return {
|
| cellCount: this.cells.length,
|
| successCount: this.cells.filter(c => c.status === 'success').length,
|
| errorCount: this.cells.filter(c => c.status === 'error').length,
|
| totalTime: this.cells.reduce((s, c) => s + c.executionTime, 0),
|
| };
|
| }
|
| }
|
|
|
|
|
| if (document.readyState === 'loading') {
|
| document.addEventListener('DOMContentLoaded', () => {
|
| window.notebookEngine = new NotebookEngine();
|
|
|
|
|
| window.notebookEngine.addCell('code');
|
| window.notebookEngine.addCell('markdown');
|
| window.notebookEngine.addCell('code');
|
|
|
|
|
| document.getElementById('btn-run-all')?.addEventListener('click', () => {
|
| window.notebookEngine.executeAll();
|
| });
|
|
|
|
|
| document.getElementById('btn-export-receipt')?.addEventListener('click', () => {
|
| const data = window.notebookEngine.exportJSON();
|
| const json = JSON.stringify(data, null, 2);
|
| const blob = new Blob([json], { type: 'application/json' });
|
| const url = URL.createObjectURL(blob);
|
| const a = document.createElement('a');
|
| a.href = url;
|
| a.download = `notebook-${Date.now()}.json`;
|
| a.click();
|
| });
|
|
|
| window.notebookEngine.updateStats();
|
| window.notebookEngine.renderDependencyGraph();
|
| });
|
| } else {
|
| window.notebookEngine = new NotebookEngine();
|
| }
|
|
|
|
|
| if (typeof module !== 'undefined' && module.exports) {
|
| module.exports = NotebookEngine;
|
| }
|
|
|