Notebook / index.html
SolarumAsteridion's picture
Update index.html
b52d177 verified
raw
history blame
25.6 kB
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>LaTeX Notepad</title>
<!-- ──────── MathJax ──────── -->
<script>
window.MathJax = {
tex: {
inlineMath: [['$', '$'], ['\\(', '\\)']],
displayMath: [['$$', '$$'], ['\\[', '\\]']],
processEscapes: true,
processEnvironments: true,
},
options: { skipHtmlTags: ['script','noscript','style','textarea','pre'] }
};
</script>
<script
id="MathJax-script"
async
src="https://cdn.jsdelivr.net/npm/mathjax@3/es5/tex-chtml.js"
></script>
<!-- marked.js for Markdown -->
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<!-- Google fonts -->
<link
href="https://fonts.googleapis.com/css2?family=Crimson+Text:wght@400;600&family=Libre+Baskerville:wght@400;700&family=PT+Mono&display=swap"
rel="stylesheet"
/>
<style>
/* ─────────────────────────────────────────
COLOR SYSTEM (light / dark via variables)
───────────────────────────────────────── */
:root {
--desk-bg: #fbf9f5;
--desk-dot: #e2dccd;
--paper-bg: #fffefa;
--paper-text: #222;
--shadow: rgba(0,0,0,.35);
--line: rgba(201,190,170,.18);
--perforation: #d0c6b7;
--tbl-border: #d7cebf;
--blockquote-bg: #fbf8f1;
--blockquote-bar: #ccbfae;
--code-bg: #f4f2ec;
--code-border: #e6e0d2;
--inline-code-bg: #f2efe8;
}
body.dark {
--desk-bg: #2c2a27;
--desk-dot: #3a3733;
--paper-bg: #302e2b;
--paper-text: #e9e7e2;
--shadow: rgba(0,0,0,.55);
--line: rgba(110,103,94,.28);
--perforation: #6d6456;
--tbl-border: #555048;
--blockquote-bg: #38332e;
--blockquote-bar: #6a604e;
--code-bg: #3a3530;
--code-border: #514b42;
--inline-code-bg: #4a443d;
}
/* ─────────────────────────────────────────
GLOBAL "DESK" BACKGROUND
───────────────────────────────────────── */
html,body{height:100%}
body{
margin:0;
background: var(--desk-bg);
background-image: radial-gradient(var(--desk-dot) 1px,transparent 1px);
background-size:14px 14px;
font-family:'Crimson Text','Times New Roman',serif;
color:var(--paper-text);
line-height:1.8;
-webkit-font-smoothing:antialiased;
}
/* ─────────────────────────────────────────
PAPER SHEET
───────────────────────────────────────── */
.container{
max-width:840px;
margin:40px auto;
padding:40px 60px 60px;
background:var(--paper-bg);
color:var(--paper-text);
border:1px solid rgba(0,0,0,.05);
border-radius:12px 12px 10px 10px;
position:relative;
box-shadow:0 18px 40px -22px var(--shadow),
inset 0 2px 6px rgba(0,0,0,.06);
background-size:160px 160px,100% 100%;
}
/* perforation holes */
.container::before{
content:'';
position:absolute;top:26px;bottom:26px;left:30px;width:9px;
background-image:radial-gradient(circle var(--perforation) 0%,var(--perforation) 2px,transparent 3px);
background-size:9px 28px;
background-repeat:repeat-y;
pointer-events:none;
}
/* curled corner */
.container::after{
content:'';position:absolute;top:0;right:0;width:110px;height:110px;
background:
linear-gradient(135deg,rgba(0,0,0,.08) 0%,rgba(0,0,0,0) 42%),
linear-gradient(135deg,var(--paper-bg) 0%,var(--paper-bg) 50%,rgba(255,255,255,0) 51%);
background-size:100% 100%;
border-bottom-left-radius:12px;
transform:translate(1px,-1px);
pointer-events:none;
}
/* ───────── theme toggle ───────── */
#themeToggle{
position:absolute;top:12px;right:14px;
font-size:20px;background:none;border:none;cursor:pointer;
transition:transform .25s;
user-select:none;
z-index:10;
}
#themeToggle:hover{transform:rotate(20deg)scale(1.15)}
/* ───────── settings button ───────── */
#settingsBtn{
position:absolute;top:12px;right:50px;
font-size:20px;background:none;border:none;cursor:pointer;
transition:transform .25s;
user-select:none;
z-index:10;
}
#settingsBtn:hover{transform:rotate(20deg)scale(1.15)}
/* ───────── header ───────── */
.header{text-align:center;margin-bottom:34px;padding-bottom:18px;border-bottom:1px solid rgba(0,0,0,.05)}
h1{font-family:'Libre Baskerville',serif;margin:0;font-size:30px;letter-spacing:.5px}
.subtitle{font-family:'PT Mono',monospace;font-size:14px;color:#666;margin-top:6px;letter-spacing:1px}
/* ───────── content area ───────── */
#content{
min-height:520px;font-size:18px;position:relative;
padding:10px 0 10px 26px;overflow-wrap:break-word;hyphens:auto;
}
#content::before{
content:'';position:absolute;inset:0;
background:repeating-linear-gradient(
0deg,
transparent,transparent 2.65em,
var(--line) 2.65em,var(--line) 2.7em);
pointer-events:none;z-index:1;
}
#content *{position:relative;z-index:2}
.placeholder{color:#888;font-style:italic;text-align:center;padding:110px 20px;user-select:none}
/* ───────── markdown tweaks ───────── */
blockquote{
border-left:4px solid var(--blockquote-bar);
margin:20px 0;padding:15px 26px;
background:var(--blockquote-bg);font-style:italic
}
code{font-family:'PT Mono',monospace;background:var(--inline-code-bg);
padding:2px 6px;border-radius:3px;font-size:.9em}
pre{background:var(--code-bg);padding:16px 20px;border:1px solid var(--code-border);
border-radius:6px;overflow-x:auto;font-family:'PT Mono',monospace}
/* lists */
ol{counter-reset:item;padding-left:0;list-style:none}
ol>li{counter-increment:item;margin:.5em 0 .5em 2em}
ol>li::before{content:counter(item)')';display:inline-block;width:1.5em;margin-left:-2em;text-align:right;font-weight:600}
ol ol>li::before{content:counter(item,lower-alpha)')'}
/* ───────── TABLES ───────── */
table{width:100%;border-collapse:collapse;font-variant-numeric:tabular-nums;margin:1.2em 0}
thead tr{border-bottom:1px solid var(--tbl-border)}
tbody tr:not(:last-child){border-bottom:1px solid var(--tbl-border)}
th,td{padding:.55em .8em;text-align:right}
th{font-weight:600}
/* ───────── processing badge ───────── */
.processing{
position:fixed;top:20px;right:20px;background:#333;color:#fff;
padding:10px 20px;border-radius:6px;font-family:'PT Mono',monospace;
font-size:14px;opacity:0;transition:opacity .25s;z-index:2000
}
.processing.show{opacity:.9}
/* ───────── settings modal ───────── */
.modal{
display:none;
position:fixed;top:0;left:0;width:100%;height:100%;
background:rgba(0,0,0,0.5);z-index:3000;
}
.modal.show{display:flex;align-items:center;justify-content:center}
.modal-content{
background:var(--paper-bg);
color:var(--paper-text);
padding:30px;
border-radius:8px;
max-width:500px;
width:90%;
box-shadow:0 20px 60px rgba(0,0,0,0.3);
}
.modal h2{margin-top:0;font-family:'Libre Baskerville',serif}
.modal label{
display:block;
margin-top:15px;
font-weight:600;
font-size:14px;
font-family:'PT Mono',monospace;
}
.modal input{
width:100%;
padding:8px;
margin-top:5px;
border:1px solid var(--code-border);
border-radius:4px;
background:var(--code-bg);
color:var(--paper-text);
font-family:'PT Mono',monospace;
font-size:13px;
box-sizing: border-box;
}
.modal-buttons{
margin-top:20px;
display:flex;
gap:10px;
justify-content:flex-end;
}
.modal button{
padding:8px 16px;
border:none;
border-radius:4px;
cursor:pointer;
font-family:'PT Mono',monospace;
font-size:14px;
}
.btn-save{
background:#4a5f4a;
color:#fff;
}
.btn-cancel{
background:var(--code-bg);
color:var(--paper-text);
border:1px solid var(--code-border);
}
.api-hint{
font-size:11px;
color:#888;
margin-top:3px;
font-style:italic;
}
/* Styles for the lightweight streaming area */
.mono-stream{
font-family:'PT Mono',monospace;
white-space:pre-wrap;
background:var(--code-bg);
border:1px solid var(--code-border);
padding:12px;border-radius:6px;
margin-top: 8px; /* Add some spacing */
}
/* ───────── copy button styles ───────── */
.copy-btn {
display: inline-block;
margin-left: 10px;
padding: 4px 8px;
background: var(--code-bg);
border: 1px solid var(--code-border);
border-radius: 4px;
font-family: 'PT Mono', monospace;
font-size: 12px;
cursor: pointer;
transition: all 0.2s;
user-select: none;
}
.copy-btn:hover {
background: var(--blockquote-bg);
transform: translateY(-1px);
}
.copy-btn.copied {
background: #4a5f4a;
color: #fff;
border-color: #4a5f4a;
}
.section-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 0;
}
.section-header strong {
margin: 0;
}
/* responsive & print */
@media(max-width:768px){
.container{margin:20px 16px;padding:28px}
#content{font-size:16px}
h1{font-size:24px}
}
@media print{
body{background:#fff}
.container{box-shadow:none;border:none}
.header,.processing,.instructions,#themeToggle,#settingsBtn,.copy-btn{display:none}
}
</style>
</head>
<body>
<div class="container">
<!-- theme icon -->
<button id="themeToggle" title="Toggle dark / light">🌙</button>
<!-- settings icon -->
<button id="settingsBtn" title="API Settings">⚙️</button>
<div class="header">
<h1>LaTeX Notepad</h1>
<div class="subtitle">press ctrl+v anywhere to render</div>
</div>
<div id="content">
<div class="placeholder">
Press <kbd>Ctrl</kbd>+<kbd>V</kbd> (or <kbd></kbd>+<kbd>V</kbd>) to paste and render Markdown / LaTeX<br>
<small style="font-size:14px;opacity:0.8">You can also paste images to OCR and solve them!</small>
</div>
</div>
<div class="instructions" style="text-align:center;font-family:'PT Mono',monospace;font-size:14px;color:#666;font-style:italic;margin-top:22px;">
Tip: you can paste raw Markdown, TeX, or images – they will be processed instantly ✨
</div>
</div>
<div class="processing">Processing…</div>
<!-- Settings Modal -->
<div id="settingsModal" class="modal">
<div class="modal-content">
<h2>API Settings</h2>
<label for="nebiusKey">Nebius API Key:</label>
<input type="password" id="nebiusKey" placeholder="Enter your Nebius API key" autocomplete="off">
<div class="api-hint">Used for OCR image processing</div>
<label for="cerebrasKey">Cerebras API Key:</label>
<input type="password" id="cerebrasKey" placeholder="Enter your Cerebras API key" autocomplete="off">
<div class="api-hint">Used for solving questions</div>
<div class="modal-buttons">
<button class="btn-cancel" onclick="closeSettings()">Cancel</button>
<button class="btn-save" onclick="saveSettings()">Save</button>
</div>
</div>
</div>
<script>
/* ======= processing badge helpers ======= */
const content = document.getElementById('content');
const processingNode = document.querySelector('.processing');
function showProcessing(text = 'Processing…'){
processingNode.textContent = text;
processingNode.classList.add('show');
}
function hideProcessing(){
setTimeout(()=>processingNode.classList.remove('show'),300);
}
/* ======= Copy to clipboard helper ======= */
function copyToClipboard(text, button) {
navigator.clipboard.writeText(text).then(() => {
const originalText = button.textContent;
button.textContent = '✓ Copied';
button.classList.add('copied');
setTimeout(() => {
button.textContent = originalText;
button.classList.remove('copied');
}, 2000);
}).catch(err => {
console.error('Failed to copy:', err);
alert('Failed to copy to clipboard');
});
}
/* ======= markdown + latex pipeline ======= */
function processContent(text){
showProcessing();
const store=[], PL=i=>`%%LATEX_${i}%%`; let idx=0;
const keep=m=>(store.push(m),PL(idx++));
text = text
.replace(/\\\[[\s\S]*?\\\]/g, keep) // \[ ... \]
.replace(/\$\$[\s\S]*?\$\$/g, keep) // $$ ... $$
.replace(/\\\([\s\S]*?\\\)/g, keep) // \( ... \)
.replace(/\$([^\$\n]+?)\$/g, keep); // $ ... $
let html = marked.parse(text);
store.forEach((latex,i)=>{html=html.replaceAll(PL(i),latex)});
content.innerHTML = html;
if(window.MathJax?.typesetPromise){
MathJax.typesetPromise([content]).then(hideProcessing)
.catch(e=>{console.error('MathJax error:',e);hideProcessing()});
}else{hideProcessing()}
}
/* ======= Image to Base64 converter ======= */
async function imageToBase64(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
// Ensure it's a valid data URL and extract base64 part
if (reader.result && reader.result.includes(',')) {
const base64 = reader.result.split(',')[1];
resolve(base64);
} else {
reject(new Error("Failed to read file as Data URL."));
}
};
reader.onerror = () => reject(reader.error);
reader.readAsDataURL(file);
});
}
/* ======= OCR with Nebius API ======= */
async function ocrImage(base64Image) {
const nebiusKey = localStorage.getItem('nebius-api-key');
if (!nebiusKey) {
alert('Please set your Nebius API key in settings (⚙️)');
return null;
}
showProcessing('Extracting text from image...');
try {
const response = await fetch('https://api.studio.nebius.com/v1/chat/completions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': '*/*',
'Authorization': `Bearer ${nebiusKey}`
},
body: JSON.stringify({
model: 'google/gemma-3-27b-it',
messages: [
{
role: 'system',
content: 'GIVE AS TEXT WITH LATEX like $this$ or $$this$$. NEVER USE \\itemize, \\begin{itemize}, \\end{itemize}, \\item or any list environments. Use plain text with numbers or letters for lists. DO NOT SOLVE THE QUESTION. DO NOT OUTPUT ANYTHING BUT THE EXACT FORMAT OF THE QUESTION AS IT APPEARS IN THE IMAGE.'
},
{
role: 'user',
content: [
{ type: 'text', text: 'Image:' },
{
type: 'image_url',
image_url: { url: `data:image/png;base64,${base64Image}` } // Assuming PNG, adjust if needed
}
]
}
]
})
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`OCR API error: ${response.status} - ${errorText}`);
}
const data = await response.json();
return data.choices[0].message.content;
} catch (error) {
console.error('OCR Error:', error);
alert('Error during OCR: ' + error.message);
return null;
}
}
/* ======= UI Helpers for Streaming ======= */
let currentQuestion = '';
let currentAnswer = '';
function beginStreamingUI(question){
currentQuestion = question;
currentAnswer = '';
// Show a lightweight, non-MathJax view while the model streams
content.innerHTML = `
<div>
<div class="section-header">
<p><strong>Question</strong>:</p>
<button class="copy-btn" onclick="copyToClipboard(currentQuestion, this)">📋 Copy</button>
</div>
<div class="mono-stream" id="qStream"></div>
<hr style="opacity:.35; margin: 20px 0;">
<div class="section-header">
<p><strong>Answer</strong>:</p>
<button class="copy-btn" onclick="copyToClipboard(currentAnswer, this)">📋 Copy</button>
</div>
<div class="mono-stream" id="aStream">(generating...)</div>
</div>`;
const qEl = document.getElementById('qStream');
const aEl = document.getElementById('aStream');
qEl.textContent = question; // plain text now; pretty render later
aEl.textContent = ''; // clear "(generating...)"
return { qEl, aEl };
}
function finalizeStreaming(question, fullAnswer){
currentQuestion = question;
currentAnswer = fullAnswer;
// Create HTML with copy buttons
const htmlContent = `
<div class="section-header">
<strong>Question</strong>:
<button class="copy-btn" onclick="copyToClipboard(currentQuestion, this)">📋 Copy</button>
</div>
<div style="margin-bottom: 20px;">${question}</div>
<div class="section-header">
<strong>Answer</strong>:
<button class="copy-btn" onclick="copyToClipboard(currentAnswer, this)">📋 Copy</button>
</div>
<div>${fullAnswer}</div>`;
// Process the content with Markdown and MathJax
processContent(htmlContent);
}
/* ======= Solve with Cerebras API (Streaming Optimization) ======= */
async function solveQuestion(question) {
const cerebrasKey = localStorage.getItem('cerebras-api-key');
if (!cerebrasKey) {
alert('Please set your Cerebras API key in settings (⚙️)');
return null;
}
showProcessing('Solving the question...');
const ui = beginStreamingUI(question); // Prepare the lightweight streaming UI
try {
const response = await fetch('https://api.cerebras.ai/v1/chat/completions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'text/event-stream',
'Authorization': `Bearer ${cerebrasKey}`
},
body: JSON.stringify({
model: 'gpt-oss-120b',
stream: true,
max_tokens: 65536,
temperature: 0.1, // Set temperature to 0.1
reasoning_effort: 'medium', // Set reasoning_effort to 'medium'
// top_p: 1, // Removed as per user's request
messages: [
{ role: 'system', content: 'Solve this Question. Provide a clear, step-by-step solution.' },
{ role: 'user', content: question }
]
})
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Cerebras API error: ${response.status} - ${errorText}`);
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let fullAnswer = '';
let buffer = ''; // buffer for partial SSE frames
let lastFlushTime = 0;
const flushThrottle = 120; // milliseconds to wait between DOM updates
const flushUI = () => {
// Update the lightweight streaming area without MathJax
ui.aEl.textContent = fullAnswer;
currentAnswer = fullAnswer; // Update global variable for copy button
lastFlushTime = performance.now();
};
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
// SSE events are typically separated by '\n\n'
const events = buffer.split('\n\n');
buffer = events.pop() || ''; // Keep any incomplete event for the next chunk
for (const evt of events) {
// Find the 'data:' line, which contains the JSON payload
const dataLine = evt.split('\n').find(line => line.trim().startsWith('data: '));
if (!dataLine) continue;
const data = dataLine.slice(6).trim(); // Remove 'data: ' prefix
if (data === '[DONE]') continue; // End of stream marker
try {
const parsed = JSON.parse(data);
// Extract content, being flexible with potential API response structures
const deltaContent = parsed.choices?.[0]?.delta?.content
?? parsed.choices?.[0]?.message?.content
?? parsed.choices?.[0]?.text // Some APIs might use 'text'
?? '';
if (deltaContent) {
fullAnswer += deltaContent;
// Throttle DOM updates to prevent excessive rendering and jank
if (performance.now() - lastFlushTime > flushThrottle) {
flushUI();
}
}
} catch (e) {
// Ignore errors parsing JSON chunks if it's just partial data
console.error('Error parsing stream chunk data:', e, 'Chunk:', data);
}
}
}
// Final flush to ensure all streamed content is displayed in the lightweight view
flushUI();
// Once streaming is complete, perform the final, heavier render with Markdown and MathJax
finalizeStreaming(question, fullAnswer);
return fullAnswer;
} catch (error) {
console.error('Solving Error:', error);
alert('Error during solving: ' + error.message);
hideProcessing(); // Ensure the processing indicator is hidden on error
return null;
}
}
/* ======= Process image pipeline ======= */
async function processImage(file) {
try {
// Convert image to base64
const base64 = await imageToBase64(file);
// OCR the image
const ocrText = await ocrImage(base64);
if (!ocrText) {
hideProcessing();
return;
}
// Solve the question
const answer = await solveQuestion(ocrText);
// The solveQuestion function now handles hiding the processing indicator
// unless an error occurred, in which case it was hidden earlier.
} catch (error) {
console.error('Image processing error:', error);
alert('Error processing image: ' + error.message);
hideProcessing();
}
}
/* ======= FIXED paste listener - allows normal paste in input fields ======= */
document.addEventListener('paste', async (e) => {
// Check if we're pasting into an input, textarea, or contenteditable element
const activeElement = document.activeElement;
const isInputField = activeElement && (
activeElement.tagName === 'INPUT' ||
activeElement.tagName === 'TEXTAREA' ||
activeElement.isContentEditable === true // Use isContentEditable for modern check
);
// If pasting into an input field, let the browser handle it normally
if (isInputField) {
return; // Don't prevent default, let normal paste happen
}
// Otherwise, handle custom paste logic
e.preventDefault();
// Check for image files first
const items = Array.from(e.clipboardData.items);
const imageItem = items.find(item => item.type.startsWith('image/'));
if (imageItem) {
// Handle image paste
const file = imageItem.getAsFile();
if (file) {
await processImage(file);
} else {
alert("Could not get image file from clipboard.");
}
} else {
// Handle text paste (existing functionality)
const txt = e.clipboardData.getData('text/plain');
if (txt.trim()) processContent(txt);
}
});
/* ======= Settings modal functions ======= */
const settingsBtn = document.getElementById('settingsBtn');
const settingsModal = document.getElementById('settingsModal');
const nebiusKeyInput = document.getElementById('nebiusKey');
const cerebrasKeyInput = document.getElementById('cerebrasKey');
settingsBtn.addEventListener('click', () => {
// Load existing keys
nebiusKeyInput.value = localStorage.getItem('nebius-api-key') || '';
cerebrasKeyInput.value = localStorage.getItem('cerebras-api-key') || '';
settingsModal.classList.add('show');
});
function closeSettings() {
settingsModal.classList.remove('show');
}
function saveSettings() {
const nebiusKey = nebiusKeyInput.value.trim();
const cerebrasKey = cerebrasKeyInput.value.trim();
if (nebiusKey) localStorage.setItem('nebius-api-key', nebiusKey);
if (cerebrasKey) localStorage.setItem('cerebras-api-key', cerebrasKey);
closeSettings();
alert('API keys saved successfully!');
}
// Close modal on escape or background click
settingsModal.addEventListener('click', (e) => {
if (e.target === settingsModal) closeSettings();
});
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && settingsModal.classList.contains('show')) {
closeSettings();
}
});
/* small "bounce" on placeholder click */
content.addEventListener('click',()=>{
const ph=content.querySelector('.placeholder');
if(ph){
ph.style.transform='scale(.97)';
ph.style.transition='transform .12s';
setTimeout(()=>ph.style.transform='scale(1)',120);
}
});
/* smooth fade in */
document.addEventListener('DOMContentLoaded',()=>{
const sheet=document.querySelector('.container');
sheet.style.opacity='0';
setTimeout(()=>{sheet.style.transition='opacity .6s ease';sheet.style.opacity='1'},80);
});
/* ======= theme toggler ======= */
const btn = document.getElementById('themeToggle');
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)');
const savedTheme = localStorage.getItem('note-theme');
initTheme();
btn.addEventListener('click',()=>{
document.body.classList.toggle('dark');
updateIcon();
localStorage.setItem('note-theme',document.body.classList.contains('dark')?'dark':'light');
});
function initTheme(){
if(savedTheme){
document.body.classList.toggle('dark',savedTheme==='dark');
}else if(prefersDark.matches){
document.body.classList.add('dark');
}
updateIcon();
}
function updateIcon(){
btn.textContent=document.body.classList.contains('dark')?'☀️':'🌙';
}
</script>
</body>
</html>