335 lines
12 KiB
JavaScript
335 lines
12 KiB
JavaScript
'use strict';
|
|
/**
|
|
* paste_view.js — Logic for the paste view page.
|
|
* Depends on: crypto.js (PasteCrypto), app.js (copyToClipboard)
|
|
*
|
|
* Reads the site name from <meta name="site-name"> so no Jinja injection
|
|
* is needed in this external file.
|
|
*/
|
|
|
|
let _decryptedPaste = null;
|
|
|
|
document.addEventListener('DOMContentLoaded', async () => {
|
|
let rawPayload;
|
|
try {
|
|
const island = document.getElementById('encryptedPayload');
|
|
if (!island) return; // Not a paste view page
|
|
rawPayload = JSON.parse(island.textContent);
|
|
} catch (e) {
|
|
showError('Bad Data', 'Could not read the paste payload.');
|
|
return;
|
|
}
|
|
|
|
// Detect format from the data itself.
|
|
// Encrypted pastes: "base64url:base64url" string.
|
|
// Plaintext pastes: a JSON object.
|
|
const isEncrypted = typeof rawPayload === 'string' &&
|
|
/^[A-Za-z0-9_-]+:[A-Za-z0-9_-]+$/.test(rawPayload);
|
|
|
|
if (isEncrypted) {
|
|
const keyBase64 = window.location.hash.slice(1);
|
|
if (!keyBase64) {
|
|
showError('No Key',
|
|
'The decryption key is missing from the URL. ' +
|
|
'Use the full link including the # part.');
|
|
return;
|
|
}
|
|
try {
|
|
const key = await PasteCrypto.importKey(keyBase64);
|
|
const plaintext = await PasteCrypto.decrypt(rawPayload, key);
|
|
_decryptedPaste = JSON.parse(plaintext);
|
|
} catch (e) {
|
|
showError('Decryption Failed', 'Wrong key or tampered data.');
|
|
return;
|
|
}
|
|
} else {
|
|
// Plaintext paste — rawPayload is already the parsed JSON object.
|
|
try {
|
|
_decryptedPaste = (typeof rawPayload === 'object')
|
|
? rawPayload
|
|
: JSON.parse(rawPayload);
|
|
} catch (e) {
|
|
showError('Bad Data', 'Could not parse paste data.');
|
|
return;
|
|
}
|
|
}
|
|
|
|
renderPaste(_decryptedPaste);
|
|
initPasteActions();
|
|
initLineHighlight(); // register Prism hook before initLineNumbers triggers Prism
|
|
initLineNumbers();
|
|
initDeletion();
|
|
});
|
|
|
|
function initPasteActions() {
|
|
const rawBtn = document.getElementById('rawBtn');
|
|
const copyBtn = document.getElementById('copyBtn');
|
|
const downloadBtn = document.getElementById('downloadBtn');
|
|
|
|
if (rawBtn) rawBtn.addEventListener('click', rawView);
|
|
if (copyBtn) copyBtn.addEventListener('click', copyPaste);
|
|
if (downloadBtn) downloadBtn.addEventListener('click', downloadPaste);
|
|
}
|
|
|
|
function initLineNumbers() {
|
|
const toggle = document.getElementById('lineNoToggle');
|
|
const viewPre = document.getElementById('viewPre');
|
|
if (!toggle || !viewPre) return;
|
|
|
|
// Load preference from localStorage (default to checked)
|
|
const stored = localStorage.getItem('show_line_numbers');
|
|
const isEnabled = (stored === null) ? true : (stored === 'true');
|
|
toggle.checked = isEnabled;
|
|
|
|
const updateLines = () => {
|
|
const checked = toggle.checked;
|
|
if (checked) {
|
|
viewPre.classList.add('line-numbers');
|
|
} else {
|
|
viewPre.classList.remove('line-numbers');
|
|
const existing = viewPre.querySelector('.line-numbers-rows');
|
|
if (existing) existing.remove();
|
|
}
|
|
localStorage.setItem('show_line_numbers', checked);
|
|
|
|
// Always re-highlight so Prism's line-numbers plugin runs (works for plain text too)
|
|
const code = document.getElementById('codeBlock');
|
|
if (code) {
|
|
Prism.highlightElement(code);
|
|
}
|
|
};
|
|
|
|
toggle.addEventListener('change', updateLines);
|
|
|
|
// Apply initial state (calls Prism so line numbers render without needing a toggle)
|
|
updateLines();
|
|
}
|
|
|
|
function renderPaste(paste) {
|
|
const siteName = document.querySelector('meta[name="site-name"]')?.content || '';
|
|
const title = paste.title || 'Untitled';
|
|
document.title = title + (siteName ? ' \u2014 ' + siteName : '');
|
|
document.getElementById('navPasteTitle').textContent = title;
|
|
|
|
const lang = paste.language || 'text';
|
|
const prismLangMap = { text: false, html: 'markup', xml: 'markup', docker: 'docker' };
|
|
const prismLang = (lang in prismLangMap) ? prismLangMap[lang] : lang;
|
|
|
|
const codeBlock = document.getElementById('codeBlock');
|
|
const viewPre = document.getElementById('viewPre');
|
|
codeBlock.textContent = paste.content || '';
|
|
if (prismLang) {
|
|
codeBlock.className = 'language-' + prismLang;
|
|
viewPre.className = 'language-' + prismLang;
|
|
Prism.highlightElement(codeBlock);
|
|
}
|
|
|
|
document.getElementById('viewFull').style.display = 'block';
|
|
}
|
|
|
|
function showError(title, detail) {
|
|
document.getElementById('errorTitle').textContent = title;
|
|
document.getElementById('errorDetail').textContent = detail;
|
|
document.getElementById('errorState').style.display = 'block';
|
|
}
|
|
|
|
function rawView() {
|
|
const key = window.location.hash;
|
|
const url = window.location.href.split('?')[0].split('#')[0] + '/raw' + key;
|
|
window.open(url, '_blank');
|
|
}
|
|
|
|
async function copyPaste() {
|
|
if (!_decryptedPaste) return;
|
|
const ok = await copyToClipboard(_decryptedPaste.content);
|
|
const btn = document.getElementById('copyBtn');
|
|
if (btn) {
|
|
const t = btn.textContent;
|
|
btn.textContent = ok ? 'Copied!' : 'Failed';
|
|
setTimeout(() => btn.textContent = t, 1500);
|
|
}
|
|
}
|
|
|
|
function downloadPaste() {
|
|
if (!_decryptedPaste) return;
|
|
const { title = 'untitled', content = '', language = 'text' } = _decryptedPaste;
|
|
const extMap = {
|
|
javascript: '.js', typescript: '.ts', python: '.py', java: '.java',
|
|
c: '.c', cpp: '.cpp', csharp: '.cs', html: '.html',
|
|
css: '.css', scss: '.scss', sql: '.sql', json: '.json',
|
|
yaml: '.yaml',xml: '.xml', bash: '.sh', powershell: '.ps1',
|
|
php: '.php', ruby: '.rb', go: '.go', rust: '.rs',
|
|
swift: '.swift',kotlin: '.kt', markdown:'.md', diff: '.diff',
|
|
docker: '', nginx: '.conf', toml: '.toml', ini: '.ini',
|
|
};
|
|
const filename = title.replace(/[^a-z0-9.\-]/gi, '_') + (extMap[language] ?? '.txt');
|
|
const blob = new Blob([content], { type: 'text/plain' });
|
|
const url = URL.createObjectURL(blob);
|
|
const a = Object.assign(document.createElement('a'), { href: url, download: filename });
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
document.body.removeChild(a);
|
|
URL.revokeObjectURL(url);
|
|
}
|
|
|
|
function initDeletion() {
|
|
const pasteId = window.location.pathname.split('/').pop();
|
|
const token = localStorage.getItem('del_' + pasteId);
|
|
const deleteBtn = document.getElementById('deleteBtn');
|
|
|
|
if (token && deleteBtn) {
|
|
deleteBtn.style.display = 'inline-block';
|
|
deleteBtn.addEventListener('click', async () => {
|
|
if (!confirm('Are you sure you want to delete this paste? This action cannot be undone.')) return;
|
|
|
|
deleteBtn.disabled = true;
|
|
deleteBtn.textContent = '...';
|
|
|
|
try {
|
|
const resp = await fetch('/api/delete/' + pasteId, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ token })
|
|
});
|
|
const result = await resp.json();
|
|
if (result.success) {
|
|
localStorage.removeItem('del_' + pasteId);
|
|
window.location.href = '/';
|
|
} else {
|
|
alert('Error: ' + (result.error || 'Unknown error'));
|
|
deleteBtn.disabled = false;
|
|
deleteBtn.textContent = 'Delete';
|
|
}
|
|
} catch (err) {
|
|
console.error(err);
|
|
alert('Failed to delete paste. Connection error.');
|
|
deleteBtn.disabled = false;
|
|
deleteBtn.textContent = 'Delete';
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
// ── Line highlight / linking ──────────────────────────────────────────────────
|
|
|
|
let _hlAnchor = null; // anchor line for shift-click range
|
|
let _hlStart = null;
|
|
let _hlEnd = null;
|
|
let _hlScrolled = false;
|
|
|
|
function initLineHighlight() {
|
|
// Parse ?L=5 or ?L=5-12 from the URL
|
|
const lParam = new URLSearchParams(window.location.search).get('L');
|
|
if (lParam) {
|
|
const m = lParam.match(/^(\d+)(?:-(\d+))?$/);
|
|
if (m) {
|
|
_hlStart = parseInt(m[1], 10);
|
|
_hlEnd = m[2] ? parseInt(m[2], 10) : _hlStart;
|
|
_hlAnchor = _hlStart;
|
|
}
|
|
}
|
|
|
|
// After every Prism highlight, wire gutter clicks and redraw highlight bars.
|
|
// initLineNumbers() calls Prism, which fires this hook.
|
|
Prism.hooks.add('complete', function (env) {
|
|
if (env.element && env.element.id === 'codeBlock') {
|
|
_wireLineClicks();
|
|
if (_hlStart !== null) {
|
|
_drawHighlight(_hlStart, _hlEnd);
|
|
if (!_hlScrolled) {
|
|
_scrollToLine(_hlStart);
|
|
_hlScrolled = true;
|
|
}
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
function _wireLineClicks() {
|
|
const viewPre = document.getElementById('viewPre');
|
|
if (!viewPre) return;
|
|
viewPre.querySelectorAll('.line-numbers-rows > span').forEach((span, i) => {
|
|
if (span.dataset.lineWired) return;
|
|
span.dataset.lineWired = '1';
|
|
const lineNum = i + 1;
|
|
span.title = `Line ${lineNum}`;
|
|
span.addEventListener('click', (e) => {
|
|
if (e.shiftKey && _hlAnchor !== null) {
|
|
const a = Math.min(_hlAnchor, lineNum);
|
|
const b = Math.max(_hlAnchor, lineNum);
|
|
_hlStart = a; _hlEnd = b;
|
|
_drawHighlight(a, b);
|
|
_updateLineUrl(a, b);
|
|
} else if (_hlStart === lineNum && _hlEnd === lineNum) {
|
|
// Clicking the sole highlighted line → clear
|
|
_hlAnchor = null; _hlStart = null; _hlEnd = null;
|
|
_clearHighlight();
|
|
_clearLineUrl();
|
|
} else {
|
|
_hlAnchor = lineNum;
|
|
_hlStart = lineNum;
|
|
_hlEnd = lineNum;
|
|
_drawHighlight(lineNum, lineNum);
|
|
_updateLineUrl(lineNum, lineNum);
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
function _clearHighlight() {
|
|
const viewPre = document.getElementById('viewPre');
|
|
if (!viewPre) return;
|
|
viewPre.querySelectorAll('.line-hl').forEach(el => el.remove());
|
|
viewPre.querySelectorAll('.line-numbers-rows > span').forEach(
|
|
s => s.classList.remove('hl-active')
|
|
);
|
|
}
|
|
|
|
function _clearLineUrl() {
|
|
const url = new URL(window.location.href);
|
|
url.searchParams.delete('L');
|
|
history.replaceState(null, '', url.toString());
|
|
}
|
|
|
|
function _drawHighlight(start, end) {
|
|
const viewPre = document.getElementById('viewPre');
|
|
const code = document.getElementById('codeBlock');
|
|
if (!viewPre || !code) return;
|
|
|
|
// Remove old bars and gutter classes
|
|
viewPre.querySelectorAll('.line-hl').forEach(el => el.remove());
|
|
viewPre.querySelectorAll('.line-numbers-rows > span').forEach(
|
|
(s, i) => s.classList.toggle('hl-active', i + 1 >= start && i + 1 <= end)
|
|
);
|
|
|
|
const lineH = parseFloat(getComputedStyle(code).lineHeight) || 22.4;
|
|
const padTop = parseFloat(getComputedStyle(viewPre).paddingTop) || 16;
|
|
|
|
const frag = document.createDocumentFragment();
|
|
for (let i = start; i <= end; i++) {
|
|
const bar = document.createElement('div');
|
|
bar.className = 'line-hl';
|
|
bar.style.top = (padTop + (i - 1) * lineH) + 'px';
|
|
bar.style.height = lineH + 'px';
|
|
frag.appendChild(bar);
|
|
}
|
|
// Insert before <code> so bars sit below code text in z-order
|
|
viewPre.insertBefore(frag, viewPre.firstChild);
|
|
}
|
|
|
|
function _scrollToLine(lineNum) {
|
|
const code = document.getElementById('codeBlock');
|
|
const viewPre = document.getElementById('viewPre');
|
|
const scroller = document.querySelector('.view-full');
|
|
if (!code || !viewPre || !scroller) return;
|
|
const lineH = parseFloat(getComputedStyle(code).lineHeight) || 22.4;
|
|
const padTop = parseFloat(getComputedStyle(viewPre).paddingTop) || 16;
|
|
scroller.scrollTop = Math.max(0, padTop + (lineNum - 1) * lineH - 80);
|
|
}
|
|
|
|
function _updateLineUrl(start, end) {
|
|
const url = new URL(window.location.href);
|
|
url.searchParams.set('L', start === end ? String(start) : `${start}-${end}`);
|
|
history.replaceState(null, '', url.toString());
|
|
}
|