Merge branch 'paste-buffer-large'
Some checks failed
Publish Docker Image / build-and-push (push) Failing after 4s

Buffers large pastes outside the textarea to prevent the
multi-second UI freeze that happens when megabytes of text get
committed to a <textarea> in one shot. Threshold 256 KB; first 50
lines + a banner render into the textarea, full content uploads
on Save.
This commit is contained in:
2026-05-06 19:18:31 +00:00

View File

@@ -7,8 +7,27 @@ const fileSelectButton = document.getElementById('paste-select-file');
const pasteClipboardButton = document.getElementById('paste-clipboard'); const pasteClipboardButton = document.getElementById('paste-clipboard');
const pasteError = document.getElementById('paste-error'); const pasteError = document.getElementById('paste-error');
/*
* Large-paste buffering. <textarea> rendering is the bottleneck once content
* crosses a few hundred KB; the browser locks up while it lays out millions
* of characters. For pastes / file loads above the threshold, we hold the
* full content in `bufferedContent` and only render a small preview into the
* textarea. The save path uses `bufferedContent` when set.
*/
const PASTE_BUFFER_THRESHOLD_BYTES = 256 * 1024; // 256 KB
const PASTE_PREVIEW_LINES = 50;
let bufferedContent = null;
pasteArea.focus(); pasteArea.focus();
pasteArea.addEventListener('input', reevaluateContentStatus); pasteArea.addEventListener('input', () => {
// User-typed input invalidates the buffer (the textarea is now the
// source of truth). The buffer is only set programmatically, so a
// user-driven `input` event always means edit-from-preview.
if (bufferedContent !== null) {
clearBuffer();
}
reevaluateContentStatus();
});
pasteArea.addEventListener('paste', handlePasteEvent); pasteArea.addEventListener('paste', handlePasteEvent);
pasteSaveButtons.forEach(button => button.addEventListener('click', sendLog)); pasteSaveButtons.forEach(button => button.addEventListener('click', sendLog));
fileSelectButton.addEventListener('click', selectLogFile); fileSelectButton.addEventListener('click', selectLogFile);
@@ -39,7 +58,7 @@ async function sendLog() {
pasteSaveButtons.forEach(button => button.classList.add("btn-working")); pasteSaveButtons.forEach(button => button.classList.add("btn-working"));
try { try {
let log = pasteArea.value; let log = bufferedContent ?? pasteArea.value;
log = applyFilters(log); log = applyFilters(log);
const bodyData = { const bodyData = {
@@ -149,13 +168,56 @@ async function pasteFromClipboard() {
showError("Clipboard is empty."); showError("Clipboard is empty.");
return; return;
} }
pasteArea.value = content; loadContent(content);
reevaluateContentStatus();
} catch (err) { } catch (err) {
showError("Clipboard is empty or not accessible."); showError("Clipboard is empty or not accessible.");
} }
} }
/*
* Single entry point for "place this string in the editor."
* Routes large content into the buffer + preview; small content goes into
* the textarea normally so the user can keep editing it.
*/
function loadContent(text) {
if (text.length > PASTE_BUFFER_THRESHOLD_BYTES) {
loadIntoBuffer(text);
} else {
clearBuffer();
pasteArea.value = text;
reevaluateContentStatus();
}
}
function loadIntoBuffer(text) {
bufferedContent = text;
const lines = text.split('\n');
const sizeMb = (text.length / 1024 / 1024).toFixed(2);
if (lines.length <= PASTE_PREVIEW_LINES) {
pasteArea.value = text;
} else {
const preview = lines.slice(0, PASTE_PREVIEW_LINES).join('\n');
const remaining = lines.length - PASTE_PREVIEW_LINES;
pasteArea.value =
`[Large paste buffered: ${lines.length.toLocaleString()} lines, ${sizeMb} MB.\n` +
` Full content uploads on Save. Edit this textarea to clear the buffer.]\n` +
`\n` +
`--- preview: first ${PASTE_PREVIEW_LINES} of ${lines.length.toLocaleString()} lines ---\n` +
preview +
`\n--- ${remaining.toLocaleString()} more lines hidden ---\n`;
}
pasteArea.readOnly = true;
reevaluateContentStatus();
}
function clearBuffer() {
if (bufferedContent === null) {
return;
}
bufferedContent = null;
pasteArea.readOnly = false;
}
function reevaluateContentStatus() { function reevaluateContentStatus() {
clearError(); clearError();
if (pasteArea.value.length > 0) { if (pasteArea.value.length > 0) {
@@ -184,6 +246,14 @@ async function handlePasteEvent(e) {
if (e.clipboardData?.files?.length > 0) { if (e.clipboardData?.files?.length > 0) {
e.preventDefault(); e.preventDefault();
await loadFileContents(e.clipboardData.files[0]); await loadFileContents(e.clipboardData.files[0]);
return;
}
// Intercept large text pastes before the browser commits them to the
// textarea — assigning multi-MB strings to a <textarea> freezes the page.
const text = e.clipboardData?.getData('text');
if (text && text.length > PASTE_BUFFER_THRESHOLD_BYTES) {
e.preventDefault();
loadIntoBuffer(text);
} }
} }
@@ -220,8 +290,7 @@ async function loadFileContents(file) {
return; return;
} }
pasteArea.value = new TextDecoder().decode(content); loadContent(new TextDecoder().decode(content));
reevaluateContentStatus();
} }
function selectLogFile() { function selectLogFile() {
@@ -355,8 +424,7 @@ async function handleDropEvent(e) {
let files = e.dataTransfer.files; let files = e.dataTransfer.files;
if (files.length !== 1) { if (files.length !== 1) {
if (Array.from(e.dataTransfer.types).includes('text/plain')) { if (Array.from(e.dataTransfer.types).includes('text/plain')) {
pasteArea.value = e.dataTransfer.getData('text/plain'); loadContent(e.dataTransfer.getData('text/plain'));
reevaluateContentStatus();
} }
return; return;
} }