|
|
|
@@ -1,12 +1,23 @@
|
|
|
|
|
<script lang="ts">
|
|
|
|
|
import { getContext } from 'svelte';
|
|
|
|
|
import { getContext, onMount, onDestroy } from 'svelte';
|
|
|
|
|
import { marked } from 'marked';
|
|
|
|
|
import DOMPurify from 'dompurify';
|
|
|
|
|
import { highlightCode } from '$lib/utils/codeHighlight';
|
|
|
|
|
import {
|
|
|
|
|
createNotebookSession,
|
|
|
|
|
executeNotebookCell,
|
|
|
|
|
stopNotebookSession
|
|
|
|
|
} from '$lib/apis/terminal';
|
|
|
|
|
import Spinner from '../../common/Spinner.svelte';
|
|
|
|
|
import Tooltip from '../../common/Tooltip.svelte';
|
|
|
|
|
import CellEditor from './CellEditor.svelte';
|
|
|
|
|
|
|
|
|
|
const i18n = getContext('i18n');
|
|
|
|
|
|
|
|
|
|
export let notebook: Record<string, unknown>;
|
|
|
|
|
export let filePath: string = '';
|
|
|
|
|
export let baseUrl: string = '';
|
|
|
|
|
export let apiKey: string = '';
|
|
|
|
|
|
|
|
|
|
interface NotebookCell {
|
|
|
|
|
cell_type: 'markdown' | 'code' | 'raw';
|
|
|
|
@@ -33,7 +44,7 @@
|
|
|
|
|
const toStr = (s: string[] | string | undefined): string =>
|
|
|
|
|
Array.isArray(s) ? s.join('') : s ?? '';
|
|
|
|
|
|
|
|
|
|
// Highlight code cells with Shiki
|
|
|
|
|
// ── Syntax highlighting ──────────────────────────────────────────────
|
|
|
|
|
let highlightedCells: Record<number, string> = {};
|
|
|
|
|
|
|
|
|
|
const highlightCell = async (index: number, code: string, language: string) => {
|
|
|
|
@@ -45,7 +56,7 @@
|
|
|
|
|
defaultColor: 'light'
|
|
|
|
|
});
|
|
|
|
|
highlightedCells[index] = html;
|
|
|
|
|
highlightedCells = highlightedCells; // trigger reactivity
|
|
|
|
|
highlightedCells = highlightedCells;
|
|
|
|
|
} catch {
|
|
|
|
|
// fallback handled in template
|
|
|
|
|
}
|
|
|
|
@@ -54,12 +65,13 @@
|
|
|
|
|
$: {
|
|
|
|
|
highlightedCells = {};
|
|
|
|
|
cells.forEach((cell, i) => {
|
|
|
|
|
if (cell.cell_type === 'code') {
|
|
|
|
|
if (cell.cell_type === 'code' && !editingCell[i]) {
|
|
|
|
|
highlightCell(i, toStr(cell.source), lang);
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Markdown / output helpers ────────────────────────────────────────
|
|
|
|
|
const renderMarkdown = (src: string): string =>
|
|
|
|
|
DOMPurify.sanitize(marked.parse(src, { async: false }) as string);
|
|
|
|
|
|
|
|
|
@@ -72,11 +84,7 @@
|
|
|
|
|
for (const [mime, content] of Object.entries(output.data)) {
|
|
|
|
|
if (mime.startsWith('image/')) {
|
|
|
|
|
const raw = Array.isArray(content) ? content.join('') : content;
|
|
|
|
|
if (raw.startsWith('data:')) {
|
|
|
|
|
images.push(raw);
|
|
|
|
|
} else {
|
|
|
|
|
images.push(`data:${mime};base64,${raw}`);
|
|
|
|
|
}
|
|
|
|
|
images.push(raw.startsWith('data:') ? raw : `data:${mime};base64,${raw}`);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return images;
|
|
|
|
@@ -84,8 +92,7 @@
|
|
|
|
|
|
|
|
|
|
const getOutputHtml = (output: NotebookOutput): string | null => {
|
|
|
|
|
if (!output.data?.['text/html']) return null;
|
|
|
|
|
const raw = toStr(output.data['text/html']);
|
|
|
|
|
return DOMPurify.sanitize(raw);
|
|
|
|
|
return DOMPurify.sanitize(toStr(output.data['text/html']));
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const getOutputText = (output: NotebookOutput): string | null => {
|
|
|
|
@@ -93,31 +100,234 @@
|
|
|
|
|
if (output.data?.['text/plain']) return toStr(output.data['text/plain']);
|
|
|
|
|
return null;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// ── Cell editing ─────────────────────────────────────────────────────
|
|
|
|
|
let editingCell: Record<number, boolean> = {};
|
|
|
|
|
let editedSources: Record<number, string> = {};
|
|
|
|
|
|
|
|
|
|
const startEditing = (index: number) => {
|
|
|
|
|
editingCell[index] = true;
|
|
|
|
|
editedSources[index] = toStr(cells[index].source);
|
|
|
|
|
editingCell = editingCell;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const cancelEditing = (index: number) => {
|
|
|
|
|
delete editingCell[index];
|
|
|
|
|
delete editedSources[index];
|
|
|
|
|
editingCell = editingCell;
|
|
|
|
|
highlightCell(index, toStr(cells[index].source), lang);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// ── Session / kernel ─────────────────────────────────────────────────
|
|
|
|
|
let sessionId: string | null = null;
|
|
|
|
|
let kernelReady = false;
|
|
|
|
|
let kernelStarting = false;
|
|
|
|
|
let kernelError: string | null = null;
|
|
|
|
|
let runningCell: number | null = null;
|
|
|
|
|
let runAllActive = false;
|
|
|
|
|
|
|
|
|
|
const canExecute = baseUrl && apiKey && filePath;
|
|
|
|
|
|
|
|
|
|
const startSession = async (): Promise<boolean> => {
|
|
|
|
|
if (!baseUrl || !apiKey || !filePath) return false;
|
|
|
|
|
kernelStarting = true;
|
|
|
|
|
kernelError = null;
|
|
|
|
|
|
|
|
|
|
const result = await createNotebookSession(baseUrl, apiKey, filePath);
|
|
|
|
|
|
|
|
|
|
if ('error' in result) {
|
|
|
|
|
kernelStarting = false;
|
|
|
|
|
kernelError = result.error;
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
sessionId = result.id;
|
|
|
|
|
kernelReady = true;
|
|
|
|
|
kernelStarting = false;
|
|
|
|
|
return true;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const stopSession = async () => {
|
|
|
|
|
if (sessionId && baseUrl && apiKey) {
|
|
|
|
|
await stopNotebookSession(baseUrl, apiKey, sessionId);
|
|
|
|
|
}
|
|
|
|
|
sessionId = null;
|
|
|
|
|
kernelReady = false;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const runCell = async (index: number) => {
|
|
|
|
|
if (runningCell !== null) return;
|
|
|
|
|
if (!kernelReady && !(await startSession())) return;
|
|
|
|
|
|
|
|
|
|
runningCell = index;
|
|
|
|
|
const source = editedSources[index] ?? toStr(cells[index].source);
|
|
|
|
|
|
|
|
|
|
// Apply edits
|
|
|
|
|
if (editedSources[index] !== undefined) {
|
|
|
|
|
cells[index].source = editedSources[index];
|
|
|
|
|
delete editingCell[index];
|
|
|
|
|
delete editedSources[index];
|
|
|
|
|
editingCell = editingCell;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const result = await executeNotebookCell(baseUrl, apiKey, sessionId!, index, source);
|
|
|
|
|
|
|
|
|
|
if ('error' in result) {
|
|
|
|
|
cells[index].outputs = [{
|
|
|
|
|
output_type: 'error',
|
|
|
|
|
ename: 'ExecutionError',
|
|
|
|
|
evalue: result.error,
|
|
|
|
|
traceback: [result.error]
|
|
|
|
|
}];
|
|
|
|
|
} else {
|
|
|
|
|
cells[index].outputs = result.outputs;
|
|
|
|
|
if (result.execution_count !== undefined) {
|
|
|
|
|
cells[index].execution_count = result.execution_count;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
cells = cells;
|
|
|
|
|
runningCell = null;
|
|
|
|
|
highlightCell(index, toStr(cells[index].source), lang);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const runAll = async () => {
|
|
|
|
|
if (runAllActive) return;
|
|
|
|
|
runAllActive = true;
|
|
|
|
|
for (let i = 0; i < cells.length; i++) {
|
|
|
|
|
if (cells[i].cell_type === 'code') {
|
|
|
|
|
await runCell(i);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
runAllActive = false;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const autoResize = (e: Event) => {
|
|
|
|
|
const ta = e.target as HTMLTextAreaElement;
|
|
|
|
|
ta.style.height = 'auto';
|
|
|
|
|
ta.style.height = ta.scrollHeight + 'px';
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
onDestroy(() => {
|
|
|
|
|
if (sessionId) stopSession();
|
|
|
|
|
});
|
|
|
|
|
</script>
|
|
|
|
|
|
|
|
|
|
<div class="notebook-view">
|
|
|
|
|
<!-- Toolbar -->
|
|
|
|
|
{#if baseUrl && apiKey && filePath}
|
|
|
|
|
<div class="nb-toolbar flex items-center gap-1 px-2 py-0.5">
|
|
|
|
|
<button
|
|
|
|
|
class="nb-btn text-[0.6rem]"
|
|
|
|
|
on:click={runAll}
|
|
|
|
|
disabled={runAllActive || runningCell !== null}
|
|
|
|
|
>
|
|
|
|
|
{$i18n.t('Run All')}
|
|
|
|
|
</button>
|
|
|
|
|
{#if kernelReady}
|
|
|
|
|
<button class="nb-btn text-[0.6rem]" on:click={async () => { await stopSession(); await startSession(); }}>
|
|
|
|
|
{$i18n.t('Restart')}
|
|
|
|
|
</button>
|
|
|
|
|
<button class="nb-btn text-[0.6rem]" on:click={stopSession}>
|
|
|
|
|
{$i18n.t('Stop')}
|
|
|
|
|
</button>
|
|
|
|
|
{/if}
|
|
|
|
|
|
|
|
|
|
<div class="flex-1"></div>
|
|
|
|
|
|
|
|
|
|
<div class="flex items-center select-none">
|
|
|
|
|
{#if kernelStarting}
|
|
|
|
|
<Tooltip content={$i18n.t('Starting kernel...')} placement="bottom"><Spinner className="size-2" /></Tooltip>
|
|
|
|
|
{:else if runningCell !== null}
|
|
|
|
|
<Tooltip content={$i18n.t('Running')} placement="bottom"><Spinner className="size-2" /></Tooltip>
|
|
|
|
|
{:else if kernelReady}
|
|
|
|
|
<Tooltip content="Jupyter" placement="bottom"><span class="size-1.5 rounded-full bg-green-500 inline-block"></span></Tooltip>
|
|
|
|
|
{:else}
|
|
|
|
|
<Tooltip content={$i18n.t('No kernel')} placement="bottom"><span class="size-1.5 rounded-full bg-gray-400 dark:bg-gray-600 inline-block"></span></Tooltip>
|
|
|
|
|
{/if}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{#if kernelError}
|
|
|
|
|
<div class="px-2 py-1 text-[0.65rem] text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-950/30 whitespace-pre-wrap font-mono">
|
|
|
|
|
{kernelError}
|
|
|
|
|
</div>
|
|
|
|
|
{/if}
|
|
|
|
|
{/if}
|
|
|
|
|
|
|
|
|
|
<!-- Cells -->
|
|
|
|
|
{#each cells as cell, i}
|
|
|
|
|
<div class="nb-cell nb-cell-{cell.cell_type}">
|
|
|
|
|
{#if cell.cell_type === 'markdown'}
|
|
|
|
|
<div class="nb-markdown prose dark:prose-invert max-w-full text-sm">
|
|
|
|
|
{@html renderMarkdown(toStr(cell.source))}
|
|
|
|
|
</div>
|
|
|
|
|
{#if editingCell[i]}
|
|
|
|
|
<textarea
|
|
|
|
|
class="nb-edit-textarea text-sm"
|
|
|
|
|
bind:value={editedSources[i]}
|
|
|
|
|
on:input={autoResize}
|
|
|
|
|
on:blur={() => cancelEditing(i)}
|
|
|
|
|
on:keydown={(e) => { if (e.key === 'Escape') cancelEditing(i); }}
|
|
|
|
|
></textarea>
|
|
|
|
|
{:else}
|
|
|
|
|
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
|
|
|
|
<div
|
|
|
|
|
class="nb-markdown prose dark:prose-invert max-w-full text-sm cursor-text"
|
|
|
|
|
role="textbox"
|
|
|
|
|
tabindex="0"
|
|
|
|
|
on:dblclick={() => startEditing(i)}
|
|
|
|
|
>
|
|
|
|
|
{@html renderMarkdown(toStr(cell.source))}
|
|
|
|
|
</div>
|
|
|
|
|
{/if}
|
|
|
|
|
{:else if cell.cell_type === 'code'}
|
|
|
|
|
<div class="nb-code-wrap">
|
|
|
|
|
<div class="nb-cell-label">
|
|
|
|
|
{#if cell.execution_count !== undefined && cell.execution_count !== null}
|
|
|
|
|
[{cell.execution_count}]
|
|
|
|
|
{:else}
|
|
|
|
|
[ ]
|
|
|
|
|
<div class="nb-cell-gutter">
|
|
|
|
|
{#if runningCell === i}
|
|
|
|
|
<div class="nb-cell-label"><Spinner className="size-3" /></div>
|
|
|
|
|
{:else if baseUrl && apiKey && filePath}
|
|
|
|
|
<button
|
|
|
|
|
class="nb-run-btn"
|
|
|
|
|
on:click={() => runCell(i)}
|
|
|
|
|
disabled={runningCell !== null}
|
|
|
|
|
title="Run cell (⌘+Enter)"
|
|
|
|
|
>
|
|
|
|
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="size-3">
|
|
|
|
|
<path stroke-linecap="round" stroke-linejoin="round" d="M5.25 5.653c0-.856.917-1.398 1.667-.986l11.54 6.347a1.125 1.125 0 0 1 0 1.972l-11.54 6.347a1.125 1.125 0 0 1-1.667-.986V5.653Z" />
|
|
|
|
|
</svg>
|
|
|
|
|
</button>
|
|
|
|
|
{/if}
|
|
|
|
|
<div class="nb-cell-label">
|
|
|
|
|
{#if cell.execution_count !== undefined && cell.execution_count !== null}
|
|
|
|
|
[{cell.execution_count}]
|
|
|
|
|
{:else}
|
|
|
|
|
[ ]
|
|
|
|
|
{/if}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="nb-code-content">
|
|
|
|
|
{#if highlightedCells[i]}
|
|
|
|
|
<div class="nb-code-source shiki-preview">
|
|
|
|
|
{@html highlightedCells[i]}
|
|
|
|
|
</div>
|
|
|
|
|
{#if editingCell[i]}
|
|
|
|
|
<CellEditor
|
|
|
|
|
value={editedSources[i]}
|
|
|
|
|
{lang}
|
|
|
|
|
on:change={(e) => { editedSources[i] = e.detail; }}
|
|
|
|
|
on:run={() => runCell(i)}
|
|
|
|
|
on:cancel={() => cancelEditing(i)}
|
|
|
|
|
/>
|
|
|
|
|
{:else}
|
|
|
|
|
<pre class="nb-code-source-raw">{toStr(cell.source)}</pre>
|
|
|
|
|
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
|
|
|
|
<div
|
|
|
|
|
class="nb-code-source-clickable"
|
|
|
|
|
role="textbox"
|
|
|
|
|
tabindex="0"
|
|
|
|
|
on:dblclick={() => startEditing(i)}
|
|
|
|
|
>
|
|
|
|
|
{#if highlightedCells[i]}
|
|
|
|
|
<div class="nb-code-source shiki-preview">
|
|
|
|
|
{@html highlightedCells[i]}
|
|
|
|
|
</div>
|
|
|
|
|
{:else}
|
|
|
|
|
<pre class="nb-code-source-raw">{toStr(cell.source)}</pre>
|
|
|
|
|
{/if}
|
|
|
|
|
</div>
|
|
|
|
|
{/if}
|
|
|
|
|
|
|
|
|
|
{#if cell.outputs && cell.outputs.length > 0}
|
|
|
|
@@ -153,9 +363,37 @@
|
|
|
|
|
|
|
|
|
|
<style>
|
|
|
|
|
.notebook-view {
|
|
|
|
|
padding: 0.5rem 0;
|
|
|
|
|
padding: 0;
|
|
|
|
|
font-size: 0.8rem;
|
|
|
|
|
}
|
|
|
|
|
.nb-toolbar {
|
|
|
|
|
position: sticky;
|
|
|
|
|
top: 0;
|
|
|
|
|
z-index: 5;
|
|
|
|
|
background: rgba(255, 255, 255, 0.95);
|
|
|
|
|
backdrop-filter: blur(4px);
|
|
|
|
|
}
|
|
|
|
|
:global(.dark) .nb-toolbar {
|
|
|
|
|
background: rgba(17, 24, 39, 0.95);
|
|
|
|
|
}
|
|
|
|
|
.nb-btn {
|
|
|
|
|
font-size: 0.6rem;
|
|
|
|
|
padding: 0.15rem 0.35rem;
|
|
|
|
|
border-radius: 0.25rem;
|
|
|
|
|
color: #6b7280;
|
|
|
|
|
transition: all 0.15s;
|
|
|
|
|
}
|
|
|
|
|
.nb-btn:hover:not(:disabled) {
|
|
|
|
|
background: rgba(128, 128, 128, 0.1);
|
|
|
|
|
color: #374151;
|
|
|
|
|
}
|
|
|
|
|
:global(.dark) .nb-btn:hover:not(:disabled) {
|
|
|
|
|
color: #d1d5db;
|
|
|
|
|
}
|
|
|
|
|
.nb-btn:disabled {
|
|
|
|
|
opacity: 0.4;
|
|
|
|
|
cursor: not-allowed;
|
|
|
|
|
}
|
|
|
|
|
.nb-cell {
|
|
|
|
|
padding: 0.25rem 0.75rem;
|
|
|
|
|
}
|
|
|
|
@@ -175,21 +413,52 @@
|
|
|
|
|
}
|
|
|
|
|
.nb-code-wrap {
|
|
|
|
|
display: flex;
|
|
|
|
|
gap: 0.5rem;
|
|
|
|
|
gap: 0;
|
|
|
|
|
}
|
|
|
|
|
.nb-cell-label {
|
|
|
|
|
.nb-cell-gutter {
|
|
|
|
|
flex-shrink: 0;
|
|
|
|
|
width: 2.5rem;
|
|
|
|
|
text-align: right;
|
|
|
|
|
display: flex;
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
align-items: center;
|
|
|
|
|
padding-top: 0.35rem;
|
|
|
|
|
gap: 0.15rem;
|
|
|
|
|
}
|
|
|
|
|
.nb-cell-label {
|
|
|
|
|
color: #9ca3af;
|
|
|
|
|
font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, monospace;
|
|
|
|
|
font-size: 0.65rem;
|
|
|
|
|
padding-top: 0.5rem;
|
|
|
|
|
font-size: 0.6rem;
|
|
|
|
|
user-select: none;
|
|
|
|
|
text-align: center;
|
|
|
|
|
}
|
|
|
|
|
:global(.dark) .nb-cell-label {
|
|
|
|
|
color: #4b5563;
|
|
|
|
|
}
|
|
|
|
|
.nb-run-btn {
|
|
|
|
|
display: flex;
|
|
|
|
|
align-items: center;
|
|
|
|
|
justify-content: center;
|
|
|
|
|
width: 1.25rem;
|
|
|
|
|
height: 1.25rem;
|
|
|
|
|
border-radius: 0.25rem;
|
|
|
|
|
color: #9ca3af;
|
|
|
|
|
transition: all 0.15s;
|
|
|
|
|
}
|
|
|
|
|
.nb-run-btn:hover:not(:disabled) {
|
|
|
|
|
color: #059669;
|
|
|
|
|
background: rgba(5, 150, 105, 0.1);
|
|
|
|
|
}
|
|
|
|
|
:global(.dark) .nb-run-btn {
|
|
|
|
|
color: #6b7280;
|
|
|
|
|
}
|
|
|
|
|
:global(.dark) .nb-run-btn:hover:not(:disabled) {
|
|
|
|
|
color: #34d399;
|
|
|
|
|
background: rgba(52, 211, 153, 0.15);
|
|
|
|
|
}
|
|
|
|
|
.nb-run-btn:disabled {
|
|
|
|
|
opacity: 0.3;
|
|
|
|
|
cursor: not-allowed;
|
|
|
|
|
}
|
|
|
|
|
.nb-code-content {
|
|
|
|
|
flex: 1;
|
|
|
|
|
min-width: 0;
|
|
|
|
@@ -200,6 +469,9 @@
|
|
|
|
|
:global(.dark) .nb-code-content {
|
|
|
|
|
border-color: rgba(128, 128, 128, 0.25);
|
|
|
|
|
}
|
|
|
|
|
.nb-code-source-clickable {
|
|
|
|
|
cursor: text;
|
|
|
|
|
}
|
|
|
|
|
.nb-code-source :global(pre.shiki) {
|
|
|
|
|
margin: 0;
|
|
|
|
|
padding: 0.5rem 0.75rem;
|
|
|
|
@@ -207,7 +479,6 @@
|
|
|
|
|
line-height: 1.5;
|
|
|
|
|
border-radius: 0;
|
|
|
|
|
}
|
|
|
|
|
/* Remove line numbers for notebook cells */
|
|
|
|
|
.nb-code-source :global(pre.shiki code > .line::before) {
|
|
|
|
|
display: none;
|
|
|
|
|
}
|
|
|
|
@@ -224,6 +495,33 @@
|
|
|
|
|
background: #161b22;
|
|
|
|
|
color: #e6edf3;
|
|
|
|
|
}
|
|
|
|
|
.nb-code-textarea, .nb-edit-textarea {
|
|
|
|
|
width: 100%;
|
|
|
|
|
padding: 0.5rem 0.75rem;
|
|
|
|
|
font-size: 0.75rem;
|
|
|
|
|
line-height: 1.5;
|
|
|
|
|
font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, monospace;
|
|
|
|
|
background: #fffef5;
|
|
|
|
|
color: #1f2937;
|
|
|
|
|
border: none;
|
|
|
|
|
outline: none;
|
|
|
|
|
resize: none;
|
|
|
|
|
min-height: 2.5rem;
|
|
|
|
|
overflow: hidden;
|
|
|
|
|
}
|
|
|
|
|
:global(.dark) .nb-code-textarea,
|
|
|
|
|
:global(.dark) .nb-edit-textarea {
|
|
|
|
|
background: #1a1d23;
|
|
|
|
|
color: #e6edf3;
|
|
|
|
|
}
|
|
|
|
|
.nb-edit-textarea {
|
|
|
|
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
|
|
|
font-size: 0.8rem;
|
|
|
|
|
background: transparent;
|
|
|
|
|
padding: 0.5rem 0.75rem;
|
|
|
|
|
border: 1px dashed rgba(128, 128, 128, 0.3);
|
|
|
|
|
border-radius: 4px;
|
|
|
|
|
}
|
|
|
|
|
.nb-outputs {
|
|
|
|
|
border-top: 1px solid rgba(128, 128, 128, 0.15);
|
|
|
|
|
}
|
|
|
|
@@ -291,7 +589,6 @@
|
|
|
|
|
color: #6b7280;
|
|
|
|
|
white-space: pre-wrap;
|
|
|
|
|
}
|
|
|
|
|
/* Shiki dark mode for notebook cells */
|
|
|
|
|
:global(.dark) .nb-code-source :global(.shiki),
|
|
|
|
|
:global(.dark) .nb-code-source :global(.shiki span) {
|
|
|
|
|
color: var(--shiki-dark) !important;
|
|
|
|
|