Compare commits

12 Commits

Author SHA1 Message Date
240635d30c Merge branch 'entry-content-visibility'
Some checks failed
Publish Docker Image / build-and-push (push) Failing after 3s
2026-05-06 19:39:01 +00:00
47303d6812 perf: skip render of off-screen log cells via content-visibility
With logs up to ~25 000 entries, eagerly painting every grid cell
caused multi-second freezes on page load. Add content-visibility:
auto to the line-number and content cells so the browser defers
their layout / paint until they scroll into view.

The rule lives on the cells (not on .entry itself) because .entry
uses display: contents and produces no box of its own.
contain-intrinsic-size: auto 1.5em lets the browser remember
measured heights after first paint and uses ~one line tall as the
initial placeholder for never-seen cells.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 19:39:01 +00:00
aeef6bc0cd Merge branch 'auto-fold-on-load'
Some checks failed
Publish Docker Image / build-and-push (push) Failing after 4s
2026-05-06 19:31:31 +00:00
415324f340 feat: apply smart fold automatically on page load
Logs with errors now open already smart-folded — every entry within
±25 of an error stays visible, gaps collapse into draggable bars.
Folds can only be expanded individually via per-bar click or
drag; the previous "toggle to unfold everything" path is gone. The
header error-count chip becomes informational only (cursor and
pointer-events stripped so it no longer reads as interactive).

Removed the dead toggleErrors / uncollapseAllErrors / "toggled"
state plumbing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 19:31:31 +00:00
21aaeac884 Merge branch 'errors-fold-drag'
Some checks failed
Publish Docker Image / build-and-push (push) Failing after 4s
Smart fold-around-errors with drag-to-reveal handle. Default ±25
context per error, draggable bars between gaps, click-to-reveal-25
fallback.
2026-05-06 19:27:59 +00:00
4d0d98c604 feat: smart fold around errors with drag-to-reveal handle
Replaces the all-or-nothing "Errors only" collapse with a contextual
fold: ±25 entries around every error stay visible, and runs of
non-error entries with no nearby error collapse into a fold bar.

Each fold bar is draggable. Vertical pointer drag on the bar reveals
or re-hides lines from the top of the hidden range, ~6 px per line.
A click without drag reveals the next 25 lines. When the run is
fully revealed, the bar removes itself. Buttons / scroll behaviour
are unchanged; the existing "X errors" toggle in the header is the
entry point and still un-collapses everything on second click.

Visual: foldable bars get a faint horizontal hatch (suggesting
compressed content), an ns-resize cursor, and a hover/dragging
state that intensifies the hatch toward --accent. The grip-lines
icon flanks the line-count to call out the affordance.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 19:27:59 +00:00
cc1e853f89 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.
2026-05-06 19:18:31 +00:00
0282ab594e fix: buffer large pastes outside the textarea to prevent UI freeze
Pasting (or drag-loading, or file-uploading) several MB of text into
the <textarea> would lock up the browser for seconds while it laid
out millions of characters. The bottleneck is the textarea itself,
not the filter pipeline (filters only run on Save).

Hold above-threshold (256 KB) content in a JS-side `bufferedContent`
variable and render only the first 50 lines + a banner into the
textarea. The Save path uses `bufferedContent` when set, so the
upload sees the full content. The textarea becomes read-only while
the buffer is active; user-typed input invalidates the buffer
automatically (the textarea is the source of truth in normal mode).

A new `loadContent(text)` helper is the single entry point: it
chooses textarea vs. buffer based on length. All call sites
(clipboard read, paste-event for text, file load, text drop) route
through it. Threshold is 256 KB (above which textarea rendering
visibly stutters); preview is 50 lines (enough to recognise the log
without choking the renderer).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 19:18:31 +00:00
8437d62394 Merge branch 'codex-v030-wire'
Some checks failed
Publish Docker Image / build-and-push (push) Failing after 5s
Bumps codex to v0.3.0 and aligns iblogs's save-time PII filter
chain with codex's ProjectZomboidRedactor. Removes the redundant
in-tree IPv4Filter / IPv6Filter chain entries (codex now handles
IPs end-to-end including port suffixes). Adds PZ-specific Steam
ID / player-name / coordinate scrubbing the prior chain never had.
2026-05-06 19:12:50 +00:00
4fced60a83 feat: align save-time redaction with codex v0.3.0 ProjectZomboidRedactor
Bumps the codex constraint from ^0.2.0 to ^0.3.0 to pull the PZ-B42
parser fix and the IP-redaction passes added in the codex v0.3.0
release. Wires codex's ProjectZomboidRedactor into the save-time
Filter chain via a thin ProjectZomboidRedactorFilter wrapper, and
removes the now-redundant IPv4Filter / IPv6Filter chain entries:

- Codex's IPv4 / IPv6 redaction is generic-applicable (not PZ-only)
  and superior to the prior in-tree filters because it consumes the
  port suffix together with the address; previously only the IP was
  scrubbed, leaving e.g. ":27015" visible.
- Codex additionally redacts PZ-specific PII the prior filters never
  touched (Steam IDs, player names, world coordinates).
- The IPv4Filter and IPv6Filter source files are retained on disk
  for easy restore from history if a future paste type proves
  unsuitable for codex-driven IP scrubbing.

UsernameFilter (OS-path username scrubbing — different concern from
PZ player names) and AccessTokenFilter remain untouched.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 19:12:50 +00:00
33fcd0d81f chore(dev): change local dev port to 4217
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 10:59:43 +00:00
f72e2d0936 chore(docker): install git and ca-certificates
Required so Composer can resolve VCS-based dependencies (e.g. forked codex packages) at build time.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 10:59:37 +00:00
9 changed files with 358 additions and 89 deletions

View File

@@ -2,6 +2,7 @@ FROM dunglas/frankenphp:1-php8.5
# System Setup
RUN install-php-extensions mongodb zip
RUN apt-get update && apt-get install -y --no-install-recommends git ca-certificates && rm -rf /var/lib/apt/lists/*
ARG USER=iblogs
RUN useradd ${USER} && \

View File

@@ -21,7 +21,7 @@
"ext-mongodb": "*",
"ext-uri": "*",
"ext-zlib": "*",
"indifferentketchup/codex": "^0.2.0",
"indifferentketchup/codex": "^0.3.0",
"mongodb/mongodb": "2.1.2"
},
"autoload": {

8
composer.lock generated
View File

@@ -4,15 +4,15 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "821a03243eb4b751e38ca3f8f063dd3e",
"content-hash": "c970170e823f1c31130ee1eec742a090",
"packages": [
{
"name": "indifferentketchup/codex",
"version": "v0.2.0",
"version": "v0.3.0",
"source": {
"type": "git",
"url": "https://git.indifferentketchup.com/indifferentketchup/ik-codex",
"reference": "2bd4fe6189c21be5b1fb03e8ac23b1a3c01d747c"
"reference": "656142dbf8979da7d5f06908e5dd53afa1b5e56d"
},
"require": {
"php": ">=8.4"
@@ -47,7 +47,7 @@
}
],
"description": "Generic PHP log parsing and analysis framework.",
"time": "2026-05-01T22:08:43+00:00"
"time": "2026-05-06T19:04:37+00:00"
},
{
"name": "mongodb/mongodb",

View File

@@ -8,7 +8,7 @@ services:
- IBLOGS_WORKER_REQUESTS=1
- FRANKENPHP_WORKERS=4
ports:
- "80:80"
- "4217:80"
volumes:
- ../:/app
- ./dev.ini:/usr/local/etc/php/conf.d/dev.ini

View File

@@ -23,8 +23,7 @@ abstract class Filter implements \JsonSerializable
new TrimFilter(),
new LimitBytesFilter(),
new LimitLinesFilter(),
new IPv4Filter(),
new IPv6Filter(),
new ProjectZomboidRedactorFilter(),
new UsernameFilter(),
new AccessTokenFilter(),
];

View File

@@ -0,0 +1,45 @@
<?php
namespace IndifferentKetchup\Iblogs\Filter;
use IndifferentKetchup\Codex\Util\ProjectZomboid\ProjectZomboidRedactor;
/**
* Save-time wrapper that delegates to codex's ProjectZomboidRedactor.
*
* Codex owns the canonical Project Zomboid PII patterns (Steam IDs, player
* names, world coordinates, plus IPv4 / IPv6 addresses with the v0.3.0
* release). This filter is the single point at which PZ-shaped PII is
* scrubbed on save; it replaces the previous IPv4Filter + IPv6Filter
* stage (whose IP-only matches left port suffixes intact) and adds the
* PZ-specific Steam ID, player-name, and coordinate redaction the generic
* filters never touched.
*
* Codex's IPv4 / IPv6 regexes are generic and apply to non-PZ pastes too;
* the PZ-specific regexes (Steam ID, player name, coords) mostly no-op on
* non-PZ content because they rely on PZ-specific anchors (`76561198`,
* the Steam-ID placeholder, `Combat:` / `Safety:` prefixes, `at` / `[`
* coord wrappers + trailing PvP verbs).
*
* Patterns are encapsulated inside the codex redactor and are not exposed
* to the client-side preview JS (`getData()` returns an empty array).
* Server-side redaction on save is the privacy guarantee; the preview is
* only a UX hint for users about what gets scrubbed.
*/
class ProjectZomboidRedactorFilter extends Filter
{
public function getType(): FilterType
{
return FilterType::REGEX;
}
public function getData(): array
{
return [];
}
public function filter(string $data): string
{
return new ProjectZomboidRedactor()->redact($data);
}
}

View File

@@ -950,6 +950,24 @@ main {
width: 100%;
}
/*
* Skip layout / paint of off-screen log cells until they scroll into
* view. With logs running up to ~25 000 entries the eager render of
* every cell is what causes the multi-second freeze on initial load.
*
* `.entry` itself is `display: contents` (no box), so the rule has to
* land on the actual grid items it produces. `contain-intrinsic-size:
* auto 1.5em` lets the browser remember measured heights after first
* paint and falls back to ~one line tall as the placeholder for
* never-seen cells. Multi-line stack traces correct on first pass; the
* scroll position adjusts once.
*/
.log-inner .line-number-container,
.log-inner .line-content {
content-visibility: auto;
contain-intrinsic-size: auto 1.5em;
}
.log-inner .entry.entry-error .line-content,
.log-inner .entry.entry-error .line-number-container{
background-color: var(--error-bg);
@@ -1088,6 +1106,82 @@ main {
color: var(--accent);
}
/*
* Foldable variant — generated by the smart "errors + 25 context" toggle in
* log.js. The bar is draggable: vertical pointer drag reveals or re-hides
* lines from the top of the hidden range. Visual cues:
* - ns-resize cursor announces the drag direction.
* - Faint horizontal hatch hints at compressed content underneath.
* - Hover and drag states intensify the hatch toward --accent so the
* bar reads as an interactive separator, not decorative chrome.
*/
.collapsed-lines-foldable {
cursor: ns-resize;
user-select: none;
-webkit-user-select: none;
}
.collapsed-lines-foldable .collapsed-lines-count {
background:
repeating-linear-gradient(
to bottom,
transparent 0,
transparent 3px,
color-mix(in srgb, var(--border) 60%, transparent) 3px,
color-mix(in srgb, var(--border) 60%, transparent) 4px
),
var(--surface);
border-top: 1px solid var(--border);
border-bottom: 1px solid var(--border);
transition:
background-color 0.12s ease,
color 0.12s ease,
border-color 0.12s ease,
box-shadow 0.12s ease;
}
.collapsed-lines-foldable:hover .collapsed-lines-count,
.collapsed-lines-dragging .collapsed-lines-count {
background:
repeating-linear-gradient(
to bottom,
transparent 0,
transparent 3px,
color-mix(in srgb, var(--accent) 35%, transparent) 3px,
color-mix(in srgb, var(--accent) 35%, transparent) 4px
),
var(--accent-bg);
border-top-color: color-mix(in srgb, var(--accent) 60%, transparent);
border-bottom-color: color-mix(in srgb, var(--accent) 60%, transparent);
color: var(--accent);
}
.collapsed-lines-foldable:hover .collapsed-lines-count i,
.collapsed-lines-dragging .collapsed-lines-count i {
color: var(--accent);
}
.collapsed-lines-dragging {
cursor: grabbing;
}
.collapsed-lines-dragging .collapsed-lines-count {
box-shadow:
inset 0 0 0 1px color-mix(in srgb, var(--accent) 70%, transparent),
0 6px 16px color-mix(in srgb, var(--accent) 25%, transparent);
}
/*
* The header error-count chip is informational only — the smart fold is
* applied automatically on page load and folds can only be expanded
* individually. Strip the .btn cursor / hover affordance so the chip
* doesn't read as interactive.
*/
#error-toggle {
cursor: default;
pointer-events: none;
}
.log-inner .level {
display: block;
white-space: pre-wrap;

View File

@@ -47,93 +47,155 @@ function scrollToHeight(top, smoothScrollLimit = 10000) {
window.scrollTo({left: 0, top, behavior});
}
/* error collapse toggle */
const toggleErrorsButton = document.getElementById("error-toggle");
if (toggleErrorsButton) {
toggleErrorsButton.addEventListener("click", toggleErrors);
}
/*
* Smart fold around errors.
*
* Every entry within ±ERROR_WINDOW_SIZE of an error stays visible; runs of
* non-error entries with no nearby errors collapse into a draggable fold
* bar. Vertical drag on the bar progressively reveals or re-hides lines;
* a click without drag reveals the next DEFAULT_CLICK_REVEAL lines.
*
* The fold is applied automatically on page load when the log contains
* errors. Folds can only be expanded individually — there is no
* "unfold everything" path. The error-count chip in the header is purely
* informational; it is not interactive.
*/
const ERROR_WINDOW_SIZE = 25;
const PIXELS_PER_LINE_DRAG = 6;
const DEFAULT_CLICK_REVEAL = 25;
const DRAG_PIXEL_THRESHOLD = 3;
function toggleErrors() {
if (toggleErrorsButton.classList.contains("toggled")) {
toggleErrorsButton.classList.remove("toggled");
uncollapseAllErrors();
} else {
toggleErrorsButton.classList.add("toggled");
collapseAllErrors();
}
}
applySmartFold();
function collapseAllErrors() {
let firstNoErrorLine = false;
let lines = document.querySelectorAll('.log-inner > .entry');
let totalLines = lines.length;
for (const [i, line] of lines.entries()) {
let lineNumber = line.querySelector(".line-number").innerHTML;
if (line.classList.contains("entry-no-error")) {
line.style.display = "none";
function applySmartFold() {
const lines = Array.from(document.querySelectorAll('.log-inner > .entry'));
const total = lines.length;
if (!total) return;
if (firstNoErrorLine === false) {
firstNoErrorLine = lineNumber;
}
if (i + 1 === totalLines && firstNoErrorLine) {
line.insertAdjacentElement("afterend", generateCollapsedLines(firstNoErrorLine, lineNumber));
}
} else {
if (firstNoErrorLine) {
line.insertAdjacentElement("beforebegin", generateCollapsedLines(firstNoErrorLine, lineNumber - 1));
firstNoErrorLine = false;
}
// Pass 1: mark entries within ±ERROR_WINDOW_SIZE of any error. If the
// log has no errors, every entry stays visible (we never produce one
// giant fold spanning the whole log).
const mustShow = new Array(total).fill(false);
let sawError = false;
for (let i = 0; i < total; i++) {
if (lines[i].classList.contains("entry-error")) {
sawError = true;
const lo = Math.max(0, i - ERROR_WINDOW_SIZE);
const hi = Math.min(total - 1, i + ERROR_WINDOW_SIZE);
for (let j = lo; j <= hi; j++) mustShow[j] = true;
}
}
if (!sawError) return;
// Pass 2: hide unmarked entries; emit a fold bar at the START of each run.
let runEntries = [];
const flushRun = () => {
if (!runEntries.length) return;
const bar = createFoldBar(runEntries);
runEntries[0].insertAdjacentElement("beforebegin", bar);
runEntries = [];
};
for (let i = 0; i < total; i++) {
if (!mustShow[i]) {
lines[i].style.display = "none";
runEntries.push(lines[i]);
} else {
flushRun();
}
}
flushRun();
}
function uncollapseAllErrors() {
document.querySelectorAll('.entry-no-error').forEach(line => line.style.removeProperty("display"));
document.querySelectorAll('.collapsed-lines').forEach(collapsed => collapsed.remove());
function createFoldBar(entries) {
const bar = document.createElement("div");
bar.classList.add("collapsed-lines", "collapsed-lines-foldable");
bar.appendChild(document.createElement("div")); // line-number column slot
const count = document.createElement("div");
count.classList.add("collapsed-lines-count");
bar.appendChild(count);
bar._hiddenEntries = entries;
bar._revealedCount = 0;
renderFoldLabel(bar);
attachFoldDragHandler(bar);
return bar;
}
function handleCollapsedClick(e) {
let collapsed = e.currentTarget;
let positionElement = document.getElementById(`L${parseInt(collapsed.dataset.end) + 1}`);
let position;
if (positionElement) {
position = positionElement.getBoundingClientRect().top - window.scrollY;
}
for (let i = parseInt(collapsed.dataset.start); i <= parseInt(collapsed.dataset.end); i++) {
document.getElementById(`L${i}`).parentElement.parentElement.style.removeProperty("display");
}
if (positionElement) {
window.scrollTo({
left: 0,
top: positionElement.getBoundingClientRect().top - position - collapsed.offsetHeight,
behavior: "instant"
});
}
collapsed.remove();
function renderFoldLabel(bar) {
const entries = bar._hiddenEntries;
const total = entries.length;
const revealed = bar._revealedCount;
const remaining = total - revealed;
const count = bar.querySelector(".collapsed-lines-count");
count.replaceChildren();
if (remaining <= 0) return;
const firstHiddenLine = parseInt(entries[revealed].querySelector(".line-number").innerHTML, 10);
const lastHiddenLine = parseInt(entries[total - 1].querySelector(".line-number").innerHTML, 10);
const word = remaining === 1 ? "line" : "lines";
const grip = document.createElement("i");
grip.className = "fa-solid fa-grip-lines";
count.appendChild(grip);
count.append(` ${remaining} ${word} hidden · ${firstHiddenLine}${lastHiddenLine} · drag to reveal `);
count.appendChild(grip.cloneNode());
}
function generateCollapsedLines(start, end) {
let count = end - start + 1;
let string = count === 1 ? "line" : "lines";
function applyFoldReveal(bar, n) {
const entries = bar._hiddenEntries;
n = Math.max(0, Math.min(entries.length, n));
if (n === bar._revealedCount) return;
for (let i = 0; i < entries.length; i++) {
if (i < n) entries[i].style.removeProperty("display");
else entries[i].style.display = "none";
}
bar._revealedCount = n;
renderFoldLabel(bar);
}
let collapsedRow = document.createElement("div");
collapsedRow.classList.add("collapsed-lines");
collapsedRow.dataset.start = start;
collapsedRow.dataset.end = end;
collapsedRow.appendChild(document.createElement("div"));
collapsedRow.addEventListener("click", handleCollapsedClick);
function attachFoldDragHandler(bar) {
let dragging = false;
let dragStartY = 0;
let dragStartReveal = 0;
let didDrag = false;
let collapsedLinesCount = document.createElement("div");
collapsedLinesCount.classList.add("collapsed-lines-count");
let icon = document.createElement("i");
icon.classList.add("fa-solid", "fa-angle-up");
collapsedLinesCount.appendChild(icon);
collapsedLinesCount.append(` ${count} ${string} `);
collapsedLinesCount.append(icon.cloneNode());
collapsedRow.appendChild(collapsedLinesCount);
bar.addEventListener("pointerdown", (e) => {
if (e.button !== 0) return;
dragging = true;
didDrag = false;
dragStartY = e.clientY;
dragStartReveal = bar._revealedCount;
bar.setPointerCapture(e.pointerId);
bar.classList.add("collapsed-lines-dragging");
e.preventDefault();
});
return collapsedRow;
bar.addEventListener("pointermove", (e) => {
if (!dragging) return;
const dy = e.clientY - dragStartY;
if (Math.abs(dy) >= DRAG_PIXEL_THRESHOLD) didDrag = true;
const target = dragStartReveal + Math.round(dy / PIXELS_PER_LINE_DRAG);
applyFoldReveal(bar, target);
});
const finish = (e) => {
if (!dragging) return;
dragging = false;
try { bar.releasePointerCapture(e.pointerId); } catch {}
bar.classList.remove("collapsed-lines-dragging");
if (!didDrag) {
// Plain click: reveal the next chunk of lines.
applyFoldReveal(bar, bar._revealedCount + DEFAULT_CLICK_REVEAL);
}
if (bar._revealedCount >= bar._hiddenEntries.length) {
bar.remove();
}
};
bar.addEventListener("pointerup", finish);
bar.addEventListener("pointercancel", finish);
}
/* convert timestamps */

View File

@@ -7,8 +7,27 @@ const fileSelectButton = document.getElementById('paste-select-file');
const pasteClipboardButton = document.getElementById('paste-clipboard');
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.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);
pasteSaveButtons.forEach(button => button.addEventListener('click', sendLog));
fileSelectButton.addEventListener('click', selectLogFile);
@@ -39,7 +58,7 @@ async function sendLog() {
pasteSaveButtons.forEach(button => button.classList.add("btn-working"));
try {
let log = pasteArea.value;
let log = bufferedContent ?? pasteArea.value;
log = applyFilters(log);
const bodyData = {
@@ -149,13 +168,56 @@ async function pasteFromClipboard() {
showError("Clipboard is empty.");
return;
}
pasteArea.value = content;
reevaluateContentStatus();
loadContent(content);
} catch (err) {
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() {
clearError();
if (pasteArea.value.length > 0) {
@@ -184,6 +246,14 @@ async function handlePasteEvent(e) {
if (e.clipboardData?.files?.length > 0) {
e.preventDefault();
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;
}
pasteArea.value = new TextDecoder().decode(content);
reevaluateContentStatus();
loadContent(new TextDecoder().decode(content));
}
function selectLogFile() {
@@ -355,8 +424,7 @@ async function handleDropEvent(e) {
let files = e.dataTransfer.files;
if (files.length !== 1) {
if (Array.from(e.dataTransfer.types).includes('text/plain')) {
pasteArea.value = e.dataTransfer.getData('text/plain');
reevaluateContentStatus();
loadContent(e.dataTransfer.getData('text/plain'));
}
return;
}