With wrap on, a logical line occupies several visual rows in the textarea but the gutter showed one number per logical line, so every number after the first wrapped line drifted off its text (the paste view fixed this in #167; the editor gutter did not). Measure the wrapped row count per logical line with a hidden mirror div sharing the editor's font and wrapping rules, and render one .gutline block per visual row with the number on the first row of its logical line. Re-measure on input, wrap toggle and resize. Verified: gutter scrollHeight == textarea scrollHeight with zero diff at 1400x900 and 375x812, wrap on and off.
488 lines
18 KiB
JavaScript
488 lines
18 KiB
JavaScript
// #139: editor page logic (gutter, expiry, burn, attachments, create).
|
|
const $ = id => document.getElementById(id);
|
|
const content = $('content'), gutter = $('gutter');
|
|
|
|
// #274: with wrap on, a logical line occupies several VISUAL rows in the
|
|
// textarea, so one number per logical line drifts off its text (same bug the
|
|
// paste view fixed in #167). A textarea can't be split into spans, so the
|
|
// wrapped row count per logical line is measured with a hidden mirror div
|
|
// that shares the editor's font, line metrics and wrapping rules, and the
|
|
// gutter renders one .gutline block per visual row with the number on the
|
|
// FIRST row of its logical line (fillers elsewhere).
|
|
let mirror = null;
|
|
function measureRows(lines) {
|
|
if (!mirror) {
|
|
mirror = document.createElement('div');
|
|
mirror.style.position = 'absolute';
|
|
mirror.style.visibility = 'hidden';
|
|
mirror.style.top = '0';
|
|
mirror.style.left = '-9999px';
|
|
document.body.appendChild(mirror);
|
|
}
|
|
const cs = getComputedStyle(content);
|
|
mirror.style.font = cs.font;
|
|
mirror.style.lineHeight = cs.lineHeight;
|
|
mirror.style.whiteSpace = 'pre-wrap';
|
|
mirror.style.overflowWrap = 'anywhere';
|
|
mirror.style.wordBreak = 'break-all';
|
|
mirror.style.width = (content.clientWidth - parseFloat(cs.paddingLeft) - parseFloat(cs.paddingRight)) + 'px';
|
|
const lh = parseFloat(cs.lineHeight) || 1;
|
|
const starts = [];
|
|
let total = 0;
|
|
const n = Math.max(lines.length, 1);
|
|
for (let i = 0; i < n; i++) {
|
|
// A trailing newline yields an empty last line: it still occupies one row.
|
|
mirror.textContent = lines[i] + '\n';
|
|
let rows = Math.max(1, Math.round(mirror.getBoundingClientRect().height / lh));
|
|
starts.push(total);
|
|
total += rows;
|
|
}
|
|
return { starts, total };
|
|
}
|
|
|
|
function updateGutter() {
|
|
const lines = content.value.split('\n');
|
|
const n = Math.max(lines.length, 1);
|
|
if (!document.documentElement.hasAttribute('data-wrap')) {
|
|
let s = '';
|
|
for (let i = 1; i <= n; i++) s += i + '\n';
|
|
gutter.textContent = s.slice(0, -1);
|
|
return;
|
|
}
|
|
const { starts, total } = measureRows(lines);
|
|
gutter.textContent = '';
|
|
const frag = document.createDocumentFragment();
|
|
const spans = [];
|
|
for (let r = 0; r < total; r++) {
|
|
const c = document.createElement('span');
|
|
c.className = 'gutline';
|
|
c.textContent = '\u00a0';
|
|
spans.push(c);
|
|
frag.appendChild(c);
|
|
}
|
|
gutter.appendChild(frag);
|
|
for (let j = 0; j < starts.length; j++) spans[starts[j]].textContent = String(j + 1);
|
|
}
|
|
content.addEventListener('input', updateGutter);
|
|
// #274: the wrap toggle and width changes re-wrap the textarea; re-measure.
|
|
new MutationObserver(updateGutter).observe(document.documentElement, { attributes: true, attributeFilter: ['data-wrap'] });
|
|
window.addEventListener('resize', updateGutter);
|
|
// #259: the editor scrolls itself; keep the gutter's numbers in step with it.
|
|
content.addEventListener('scroll', () => { gutter.scrollTop = content.scrollTop; });
|
|
updateGutter();
|
|
|
|
function toast(msg, kind) {
|
|
let t = document.querySelector('.toast');
|
|
if (!t) { t = document.createElement('div'); t.className = 'toast'; document.body.appendChild(t); }
|
|
t.textContent = msg;
|
|
t.classList.remove('success', 'error');
|
|
if (kind === 'success') t.classList.add('success');
|
|
if (kind === 'error') t.classList.add('error');
|
|
t.classList.add('show');
|
|
clearTimeout(t._h);
|
|
t._h = setTimeout(() => t.classList.remove('show'), 2000);
|
|
}
|
|
const setHidden = (id, hid) => { const el = $(id); if (el) el.classList.toggle('hidden', hid); };
|
|
$('haspw').addEventListener('change', e => setHidden('pwrow', !e.target.checked));
|
|
$('burn').addEventListener('change', e => setHidden('burnrow', !e.target.checked));
|
|
document.querySelectorAll('input[name="exp"]').forEach(r => r.addEventListener('change', () => {
|
|
setHidden('customexp-row', document.querySelector('input[name="exp"]:checked').value !== 'custom');
|
|
setHidden('customexp-err', true);
|
|
}));
|
|
|
|
// compose the expires_in Go-duration string when Custom is checked (#48).
|
|
// Returns the string, or null with an inline error shown.
|
|
function composeCustomExpiry() {
|
|
const n = parseInt($('expnum').value, 10);
|
|
const unit = $('expunit').value;
|
|
let mins = NaN;
|
|
if (n > 0) {
|
|
if (unit === 'm') mins = n;
|
|
else if (unit === 'h') mins = n * 60;
|
|
else if (unit === 'd') mins = n * 1440;
|
|
else if (unit === 'w') mins = n * 10080;
|
|
else if (unit === 'mo') mins = n * 43200; // months counted as 30 days
|
|
}
|
|
const err = $('customexp-err');
|
|
if (!(mins >= 1)) {
|
|
err.textContent = 'Enter a duration of at least 1 minute.';
|
|
err.classList.remove('hidden');
|
|
return null;
|
|
}
|
|
if (mins > 525600) { // more than 1 year
|
|
err.textContent = 'Custom expiry cannot exceed 1 year.';
|
|
err.classList.remove('hidden');
|
|
return null;
|
|
}
|
|
err.classList.add('hidden');
|
|
// compose as h (+d/m remainders); Go parses '336h', '90m', '6h30m' fine
|
|
const hours = Math.floor(mins / 60), rem = mins % 60;
|
|
if (rem === 0) return hours + 'h';
|
|
if (hours === 0) return rem + 'm';
|
|
return hours + 'h' + rem + 'm';
|
|
}
|
|
|
|
$('pwreveal').addEventListener('click', () => {
|
|
const pw = $('password');
|
|
const show = pw.type === 'password';
|
|
pw.type = show ? 'text' : 'password';
|
|
$('pwreveal').classList.toggle('off', !show);
|
|
$('pwreveal').title = show ? 'Hide password' : 'Show password';
|
|
});
|
|
|
|
let guessed = ''; // last auto-detected language, '' = user override
|
|
|
|
// #105: map backend machine-readable error codes to plain-language guidance.
|
|
// Unknown codes fall back to a generic message; the technical detail stays
|
|
// in the API response for API consumers.
|
|
const ERROR_MESSAGES = {
|
|
slug_taken: 'That Custom URL is already taken. Try another.',
|
|
slug_reserved: 'That Custom URL is reserved. Try another.',
|
|
slug_invalid: 'Please keep the Custom URL under 64 characters, using only letters, numbers, dashes, or underscores.',
|
|
content_empty: 'Write or paste something first.',
|
|
content_too_large: 'This paste is too large. The limit is 5 MB.',
|
|
file_too_large: 'File is too large. The limit is 25 MB.',
|
|
one_file_only: 'Choose either text or a file for now.',
|
|
expiry_invalid: 'Please pick an expiry between 1 minute and 1 year.',
|
|
rate_limited: 'Too many tries. Wait a minute and try again.',
|
|
};
|
|
const GENERIC_ERROR = 'Something went wrong. Please try again.';
|
|
|
|
function friendlyError(data) {
|
|
return ERROR_MESSAGES[data && data.code] || GENERIC_ERROR;
|
|
}
|
|
|
|
// #105: color the result box by outcome — success (ok), error (err),
|
|
// warning (warn) — with a colored left border (CSS .result-ok/.result-err).
|
|
function setResultKind(kind) {
|
|
const card = $('result-card');
|
|
card.classList.remove('result-ok', 'result-err', 'result-warn');
|
|
if (kind) card.classList.add('result-' + kind);
|
|
}
|
|
|
|
function showResult(html, kind) {
|
|
$('result').innerHTML = html;
|
|
$('result').dataset.token = kind === 'ok' ? ($('result').dataset.token || '') : ($('result').dataset.token || '');
|
|
setHidden('result-card', false);
|
|
setResultKind(kind === 'ok' ? 'ok' : (kind === 'warn' ? 'warn' : 'err'));
|
|
}
|
|
function defaultFilename(lang) {
|
|
const names = {
|
|
python: 'Python.py', go: 'main.go', javascript: 'script.js', typescript: 'index.ts',
|
|
rust: 'main.rs', c: 'main.c', cpp: 'main.cpp', java: 'Main.java', bash: 'script.sh',
|
|
sql: 'query.sql', yaml: 'config.yaml', json: 'data.json', html: 'index.html',
|
|
css: 'style.css', xml: 'doc.xml', php: 'index.php', ruby: 'main.rb',
|
|
perl: 'main.pl', lua: 'main.lua', dockerfile: 'Dockerfile', toml: 'config.toml',
|
|
ini: 'config.ini', diff: 'changes.diff',
|
|
markdown: 'notes.md', text: 'Text.txt',
|
|
};
|
|
return names[lang] || '';
|
|
}
|
|
// fill default filename when title is still blank
|
|
function maybeSetDefaultTitle(lang) {
|
|
const title = $('title');
|
|
if (lang && !title.value.trim()) {
|
|
const fn = defaultFilename(lang);
|
|
if (fn) title.value = fn;
|
|
}
|
|
}
|
|
|
|
async function guessLang() {
|
|
if (!content.value.trim()) return;
|
|
try {
|
|
const res = await fetch('/api/guess-language', {
|
|
method: 'POST',
|
|
headers: {'Content-Type': 'application/json'},
|
|
body: JSON.stringify({content: content.value}),
|
|
});
|
|
const data = await res.json();
|
|
if (res.ok && data.language) {
|
|
guessed = data.language;
|
|
$('language').value = data.language;
|
|
maybeSetDefaultTitle(data.language);
|
|
}
|
|
} catch(e) {}
|
|
}
|
|
|
|
// refresh button: always re-detect, even if user picked something
|
|
$('reguess').addEventListener('click', guessLang);
|
|
|
|
// auto-guess when pasting into the editor
|
|
content.addEventListener('paste', () => setTimeout(guessLang, 0));
|
|
|
|
async function create() {
|
|
// #38: file attached -> file paste (1 file = 1 paste; text is ignored)
|
|
if (attachedFile) return createFilePaste();
|
|
|
|
const body = {
|
|
content: content.value,
|
|
title: $('title').value || null,
|
|
language: $('language').value || null,
|
|
custom_slug: $('custom').value || null,
|
|
burn_after_read: $('burn').checked,
|
|
};
|
|
if ($('burn').checked) body.burn_after_reads = parseInt($('burnreads').value, 10) || 1;
|
|
if ($('haspw').checked) body.password = $('password').value;
|
|
const exp = document.querySelector('input[name="exp"]:checked').value;
|
|
if (exp === 'custom') {
|
|
const dur = composeCustomExpiry();
|
|
if (dur === null) { toast('Check the custom expiry', 'error'); return; }
|
|
body.expires_in = dur;
|
|
} else if (exp) {
|
|
body.expires_in = exp;
|
|
}
|
|
|
|
const res = await fetch('/api/pastes', {
|
|
method: 'POST',
|
|
headers: {'Content-Type': 'application/json'},
|
|
body: JSON.stringify(body),
|
|
});
|
|
const data = await res.json();
|
|
if (!res.ok) {
|
|
showResult(friendlyError(data), 'err');
|
|
toast('Create failed', 'error');
|
|
return;
|
|
}
|
|
finishCreate(data);
|
|
}
|
|
|
|
// shared success handling for both create paths (#38): result box, copy
|
|
// button, password auto-unlock, then redirect to the paste.
|
|
function finishCreate(data) {
|
|
const url = location.origin + '/' + (data.custom_slug || data.id);
|
|
// #260 attempt 2: .swapbtn markup — label and checkmark share one grid
|
|
// cell, so the button width is static and feedback is a class toggle.
|
|
showResult('<a href="' + url + '">' + url + '</a> <button class="btn btn-icon swapbtn" id="result-copy" title="Copy URL" type="button"><span class="swap-label">Copy</span><span class="swap-check">✓</span></button>', 'ok');
|
|
$('result').dataset.token = data.deletion_token || '';
|
|
const copyBtn = document.getElementById('result-copy');
|
|
copyBtn.addEventListener('click', () => {
|
|
try {
|
|
navigator.clipboard.writeText(url);
|
|
copyBtn.classList.add('ok'); // in-place success feedback (#53)
|
|
setTimeout(() => copyBtn.classList.remove('ok'), 2000);
|
|
} catch(e) { toast('Copy failed', 'error'); }
|
|
});
|
|
// token carried via sessionStorage, never in the URL (#143)
|
|
const dest = '/' + data.id + '?created=1';
|
|
try { sessionStorage.setItem('deletion_token_' + data.id, data.deletion_token || ''); } catch(e) {}
|
|
// password-protected: unlock now with the password we already have (#26)
|
|
if ($('haspw').checked && data.id) {
|
|
const fd = new FormData();
|
|
fd.append('password', $('password').value);
|
|
fd.append('next', dest);
|
|
try {
|
|
fetch('/' + data.id, {method: 'POST', body: fd}).finally(() => { location.href = dest; });
|
|
return;
|
|
} catch(e) {}
|
|
}
|
|
// show the paste
|
|
location.href = dest;
|
|
}
|
|
$('create').addEventListener('click', create);
|
|
|
|
// #38: file attachment support — 1 file = 1 paste. Three input paths:
|
|
// Ctrl+V paste of a file, upload button (file picker), drag-and-drop.
|
|
const MAX_FILE_BYTES = 25 * 1024 * 1024;
|
|
let attachedFile = null; // the single attached File, or null
|
|
|
|
function humanSize(n) {
|
|
if (n >= 1048576) return (n / 1048576).toFixed(1) + ' MB';
|
|
if (n >= 1024) return (n / 1024).toFixed(1) + ' KB';
|
|
return n + ' B';
|
|
}
|
|
|
|
// #171: text-file extensions -> language key for title placeholder
|
|
// convention (e.g. Python.py); fallback Text.
|
|
const LANG_BY_EXT = {
|
|
py: 'python', go: 'go', js: 'javascript', mjs: 'javascript', ts: 'typescript',
|
|
tsx: 'typescript', rs: 'rust', c: 'c', h: 'c', cpp: 'cpp', cc: 'cpp', hpp: 'cpp',
|
|
java: 'java', cs: 'csharp', sh: 'bash', bash: 'bash', sql: 'sql', yml: 'yaml',
|
|
yaml: 'yaml', json: 'json', html: 'html', htm: 'html', css: 'css', xml: 'xml',
|
|
php: 'php', rb: 'ruby', pl: 'perl', lua: 'lua', toml: 'toml', ini: 'ini',
|
|
diff: 'diff', md: 'markdown', txt: 'text', log: 'text',
|
|
};
|
|
const TEXT_EXTS = new Set(Object.keys(LANG_BY_EXT));
|
|
|
|
function extOf(name) {
|
|
const i = name.lastIndexOf('.');
|
|
return i >= 0 ? name.slice(i + 1).toLowerCase() : '';
|
|
}
|
|
|
|
// #171: when a file is attached it takes over the main editing area.
|
|
// Images render fitted into the editor area; text files load their
|
|
// content into the editor. Title auto-fills only if still blank (house
|
|
// rule: never overwrite a typed title).
|
|
const IMAGE_RE = /^image\//;
|
|
let previewURL = null;
|
|
|
|
// #171: CSP img-src only allows 'self' and data:, so blob: URLs are
|
|
// blocked — read the file as a data: URL via FileReader instead.
|
|
function readFileDataURL(file, cb) {
|
|
const r = new FileReader();
|
|
r.onload = () => cb(r.result);
|
|
r.readAsDataURL(file);
|
|
}
|
|
|
|
async function showFileInEditor(file) {
|
|
const wrap = document.querySelector('.editor-wrap');
|
|
const img = $('file-preview');
|
|
if (IMAGE_RE.test(file.type)) {
|
|
readFileDataURL(file, (dataURL) => {
|
|
previewURL = dataURL;
|
|
img.src = dataURL;
|
|
img.alt = file.name;
|
|
wrap.classList.add('previewing');
|
|
img.classList.remove('hidden');
|
|
});
|
|
return;
|
|
}
|
|
wrap.classList.remove('previewing');
|
|
img.classList.add('hidden');
|
|
if (!TEXT_EXTS.has(extOf(file.name))) return; // unknown binary: leave editor alone
|
|
try {
|
|
const text = await file.text();
|
|
// #184: the file replaces the main editing area, so the text always
|
|
// loads over whatever was in the editor (same rule as images, which
|
|
// hide the editor entirely).
|
|
content.value = text;
|
|
updateGutter();
|
|
guessLang();
|
|
} catch (e) {}
|
|
}
|
|
|
|
function clearPreview() {
|
|
const wrap = document.querySelector('.editor-wrap');
|
|
const img = $('file-preview');
|
|
wrap.classList.remove('previewing');
|
|
img.classList.add('hidden');
|
|
img.removeAttribute('src');
|
|
if (previewURL) { URL.revokeObjectURL(previewURL); previewURL = null; }
|
|
}
|
|
|
|
function setAttachedFile(file) {
|
|
if (!file) return clearAttachedFile();
|
|
if (file.size > MAX_FILE_BYTES) {
|
|
toast('File is too large. The limit is 25 MB.', 'error');
|
|
return;
|
|
}
|
|
attachedFile = file;
|
|
renderFileChip();
|
|
setHidden('file-text-note', false);
|
|
// #233: title auto-fill — the file's own name always wins when the
|
|
// title is still blank; fallback is date.fileextension (e.g.
|
|
// 2026-09-10.txt) when the name is missing or unusable. Never
|
|
// overwrite a typed title.
|
|
if (!$('title').value.trim()) {
|
|
const raw = (file.name || '').trim();
|
|
if (raw) {
|
|
$('title').value = raw;
|
|
} else {
|
|
const d = new Date();
|
|
const iso = d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0');
|
|
const ext = extOf(raw || file.name);
|
|
$('title').value = ext ? iso + '.' + ext : iso;
|
|
}
|
|
}
|
|
showFileInEditor(file);
|
|
}
|
|
|
|
function clearAttachedFile() {
|
|
attachedFile = null;
|
|
$('file-input').value = '';
|
|
renderFileChip();
|
|
setHidden('file-text-note', true);
|
|
clearPreview();
|
|
}
|
|
|
|
function renderFileChip() {
|
|
const slot = $('file-chip-slot');
|
|
slot.innerHTML = '';
|
|
if (!attachedFile) return;
|
|
const chip = document.createElement('div');
|
|
chip.className = 'file-chip';
|
|
chip.innerHTML = '<span class="file-chip-name"></span>' +
|
|
'<span class="file-chip-size">' + humanSize(attachedFile.size) + '</span>' +
|
|
'<button type="button" class="file-chip-remove" title="Remove file">×</button>';
|
|
chip.querySelector('.file-chip-name').textContent = attachedFile.name;
|
|
chip.querySelector('.file-chip-remove').addEventListener('click', clearAttachedFile);
|
|
slot.appendChild(chip);
|
|
}
|
|
|
|
// (1) file picker via the dropzone click
|
|
$('dropzone').addEventListener('click', () => $('file-input').click());
|
|
$('file-input').addEventListener('change', () => {
|
|
if ($('file-input').files.length) setAttachedFile($('file-input').files[0]);
|
|
});
|
|
|
|
// (2) drag-and-drop onto the dropzone (and the page broadly)
|
|
const dz = $('dropzone');
|
|
['dragenter', 'dragover'].forEach(ev => {
|
|
document.addEventListener(ev, e => {
|
|
if (!e.dataTransfer || ![...e.dataTransfer.types].includes('Files')) return;
|
|
e.preventDefault();
|
|
dz.classList.add('dragover');
|
|
});
|
|
});
|
|
['dragleave', 'drop'].forEach(ev => {
|
|
document.addEventListener(ev, e => {
|
|
if (ev === 'drop') e.preventDefault();
|
|
if (e.target === dz || ev === 'drop') dz.classList.remove('dragover');
|
|
});
|
|
});
|
|
document.addEventListener('drop', e => {
|
|
if (!e.dataTransfer || !e.dataTransfer.files.length) return;
|
|
e.preventDefault();
|
|
setAttachedFile(e.dataTransfer.files[0]);
|
|
});
|
|
|
|
// (3) Ctrl+V of a file anywhere on the page
|
|
document.addEventListener('paste', e => {
|
|
const files = e.clipboardData && e.clipboardData.files;
|
|
if (!files || !files.length) return; // normal text paste into the editor
|
|
e.preventDefault();
|
|
setAttachedFile(files[0]);
|
|
});
|
|
|
|
// file create path: POST multipart. 1 file = 1 paste: when a file is
|
|
// attached the editor text is ignored (server enforces this too).
|
|
async function createFilePaste() {
|
|
const fd = new FormData();
|
|
fd.append('file', attachedFile);
|
|
if ($('title').value) fd.append('title', $('title').value);
|
|
if ($('custom').value) fd.append('custom_slug', $('custom').value);
|
|
if ($('haspw').checked) fd.append('password', $('password').value);
|
|
if ($('burn').checked) {
|
|
fd.append('burn_after_read', 'true');
|
|
fd.append('burn_after_reads', String(parseInt($('burnreads').value, 10) || 1));
|
|
}
|
|
if ($('unlisted').checked) fd.append('visibility', 'unlisted');
|
|
const exp = document.querySelector('input[name="exp"]:checked').value;
|
|
if (exp === 'custom') {
|
|
const dur = composeCustomExpiry();
|
|
if (dur === null) { toast('Check the custom expiry', 'error'); return; }
|
|
fd.append('expires_in', dur);
|
|
} else if (exp) {
|
|
fd.append('expires_in', exp);
|
|
}
|
|
const res = await fetch('/api/pastes', { method: 'POST', body: fd });
|
|
const data = await res.json();
|
|
if (!res.ok) {
|
|
showResult(friendlyError(data), 'err');
|
|
toast('Create failed', 'error');
|
|
return;
|
|
}
|
|
finishCreate(data);
|
|
}
|
|
|
|
// reset stale result state when returning via Back (bfcache) (#28)
|
|
window.addEventListener('pageshow', e => {
|
|
if (!e.persisted) return;
|
|
const rc = document.getElementById('result-card');
|
|
if (rc) rc.classList.add('hidden');
|
|
const r = document.getElementById('result');
|
|
if (r) { r.innerHTML = 'empty'; delete r.dataset.token; }
|
|
});
|
|
document.addEventListener('keydown', e => {
|
|
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') { e.preventDefault(); create(); }
|
|
});
|