Compare commits
12 Commits
iblogs-boo
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 240635d30c | |||
| 47303d6812 | |||
| aeef6bc0cd | |||
| 415324f340 | |||
| 21aaeac884 | |||
| 4d0d98c604 | |||
| cc1e853f89 | |||
| 0282ab594e | |||
| 8437d62394 | |||
| 4fced60a83 | |||
| 33fcd0d81f | |||
| f72e2d0936 |
@@ -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} && \
|
||||
|
||||
@@ -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
8
composer.lock
generated
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
];
|
||||
|
||||
45
src/Filter/ProjectZomboidRedactorFilter.php
Normal file
45
src/Filter/ProjectZomboidRedactorFilter.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user