296 lines
11 KiB
HTML
296 lines
11 KiB
HTML
<!doctype html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||
<title>Config/Text File Editor – Upload • Edit • Download (Tall, Fixed)</title>
|
||
<style>
|
||
:root{
|
||
--bg:#0b0f14; --panel:#121922; --muted:#1a2430; --text:#e6edf3; --sub:#9fb0c3;
|
||
--accent:#4aa3ff; --accent-2:#22c55e; --warn:#f59e0b; --err:#ef4444; --border:#243241;
|
||
}
|
||
*{box-sizing:border-box}
|
||
body{margin:0; background:var(--bg); color:var(--text); font:14px/1.45 system-ui, Segoe UI, Roboto, Ubuntu, Arial, sans-serif}
|
||
header{display:flex; align-items:center; gap:.75rem; padding:10px 14px; background:linear-gradient(180deg,var(--panel),#0e1420); border-bottom:1px solid var(--border)}
|
||
h1{font-size:16px; margin:0; font-weight:600}
|
||
.spacer{flex:1}
|
||
button,.btn,input[type=file],select{border:1px solid var(--border); background:var(--muted); color:var(--text); border-radius:10px; padding:8px 12px; cursor:pointer}
|
||
button:hover,.btn:hover{background:#223046}
|
||
button.primary{background:var(--accent); color:#06121f; border-color:#2d7cd6; font-weight:600}
|
||
.toolbar{display:flex; gap:.5rem; flex-wrap:wrap; padding:10px 14px; border-bottom:1px solid var(--border); background:#0f1622}
|
||
.filelabel{padding:8px 12px; border:1px dashed var(--border); border-radius:10px; background:var(--panel)}
|
||
.meta{margin-left:8px; color:var(--sub)}
|
||
/* Taller viewport for editor */
|
||
.editor-wrap{position:relative; height:calc(100dvh - 160px);} /* header+toolbar+statusbar */
|
||
.editor{display:grid; grid-template-columns: 64px 1fr; height:100%;}
|
||
.gutter{user-select:none; background:#0a0f18; border-right:1px solid var(--border); color:#5d7089; padding:8px 6px; overflow:hidden; font:12px/1.5 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; text-align:right; white-space:pre}
|
||
textarea#text{border:0; outline:none; background:#0b111a; color:#e6edf3; padding:8px 10px; font:12px/1.5 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; resize:none; width:100%; height:100%}
|
||
.dropzone{border:2px dashed var(--border); margin:12px; padding:24px; border-radius:12px; text-align:center; color:var(--sub)}
|
||
.dropzone.drag{border-color:var(--accent); color:var(--accent)}
|
||
.statusbar{display:flex; gap:10px; align-items:center; padding:8px 12px; border-top:1px solid var(--border); background:#0e1520; color:var(--sub);}
|
||
.statusbar input, .statusbar select{padding:6px 8px; border-radius:8px; border:1px solid var(--border); background:#0b111a; color:#e6edf3}
|
||
.find{display:flex; gap:6px; align-items:center}
|
||
.pill{font-size:11px; padding:2px 6px; border-radius:999px; border:1px solid var(--border); background:#0b111a; color:#9fb0c3}
|
||
.hidden{display:none !important}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<header>
|
||
<h1>Config/Text File Editor (Tall)</h1>
|
||
<div class="spacer"></div>
|
||
<button id="downloadBtn" class="primary" title="Download (Ctrl/Cmd+S)">Download</button>
|
||
</header>
|
||
|
||
<div class="toolbar">
|
||
<label class="filelabel" for="file">Choose file…</label>
|
||
<input id="file" type="file" accept=".cfg,.conf,.txt,.json,.ini,.yaml,.yml,.sh,.rsc,.cnf,.env,*/*" hidden>
|
||
<button id="pasteBtn">Paste</button>
|
||
<button id="wrapBtn">Toggle wrap</button>
|
||
<label>Line endings:
|
||
<select id="eolSel">
|
||
<option value="auto">Auto</option>
|
||
<option value="lf">LF (\n)</option>
|
||
<option value="crlf">CRLF (\r\n)</option>
|
||
</select>
|
||
</label>
|
||
<span id="meta" class="meta">No file</span>
|
||
</div>
|
||
|
||
<div id="drop" class="dropzone">Drop a text file here</div>
|
||
|
||
<div class="editor-wrap hidden" id="editorWrap">
|
||
<div class="editor">
|
||
<div id="gutter" class="gutter"></div>
|
||
<textarea id="text" spellcheck="false" placeholder="Open a file to begin…"></textarea>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="statusbar">
|
||
<div class="find">
|
||
<input id="findInput" placeholder="Find… (Enter)">
|
||
<input id="replaceInput" placeholder="Replace with…">
|
||
<button id="findNextBtn">Find next</button>
|
||
<button id="replaceBtn">Replace</button>
|
||
<button id="replaceAllBtn">Replace all</button>
|
||
<span id="matchCount" class="pill">0 matches</span>
|
||
</div>
|
||
<div class="spacer"></div>
|
||
<span id="pos" class="pill">Ln 1, Col 1</span>
|
||
</div>
|
||
|
||
<script>
|
||
(function(){
|
||
'use strict';
|
||
|
||
// State
|
||
let filename = 'untitled.txt';
|
||
let originalEOL = '\\n'; // default LF
|
||
let wrap = false;
|
||
|
||
const $ = s => document.querySelector(s);
|
||
const $$ = s => Array.from(document.querySelectorAll(s));
|
||
const fileInput = $('#file');
|
||
const pasteBtn = $('#pasteBtn');
|
||
const wrapBtn = $('#wrapBtn');
|
||
const eolSel = $('#eolSel');
|
||
const drop = $('#drop');
|
||
const editorWrap = $('#editorWrap');
|
||
const gutter = $('#gutter');
|
||
const ta = $('#text');
|
||
const meta = $('#meta');
|
||
const downloadBtn = $('#downloadBtn');
|
||
const findInput = $('#findInput');
|
||
const replaceInput = $('#replaceInput');
|
||
const findNextBtn = $('#findNextBtn');
|
||
const replaceBtn = $('#replaceBtn');
|
||
const replaceAllBtn = $('#replaceAllBtn');
|
||
const matchCount = $('#matchCount');
|
||
const pos = $('#pos');
|
||
|
||
// Utilities
|
||
function detectEOL(s){
|
||
const crlf = (s.match(/\\r\\n/g)||[]).length;
|
||
const lf = (s.match(/(?<!\\r)\\n/g)||[]).length;
|
||
return crlf > lf ? '\\r\\n' : '\\n';
|
||
}
|
||
function setEditorVisible(v){
|
||
editorWrap.classList.toggle('hidden', !v);
|
||
drop.classList.toggle('hidden', v);
|
||
}
|
||
function updateMeta(size){ meta.textContent = filename + (size!==undefined ? ` — ${size} bytes` : ''); }
|
||
function setEOLMode(mode){
|
||
if (mode==='auto'){ originalEOL = detectEOL(ta.value); return; }
|
||
originalEOL = (mode==='crlf') ? '\\r\\n' : '\\n';
|
||
}
|
||
function normalizeToEOL(s, eol){
|
||
return s.replace(/\\r?\\n/g, eol);
|
||
}
|
||
function getDownloadName(){
|
||
const dot = filename.lastIndexOf('.');
|
||
if (dot > 0){
|
||
return filename.slice(0, dot) + '-edited' + filename.slice(dot);
|
||
}
|
||
return filename + '-edited';
|
||
}
|
||
function saveToLocal(){
|
||
try { localStorage.setItem('editor:'+filename, ta.value); } catch {}
|
||
}
|
||
function maybeLoadFromLocal(){
|
||
try {
|
||
const v = localStorage.getItem('editor:'+filename);
|
||
if (v != null) { ta.value = v; renderGutter(); }
|
||
} catch {}
|
||
}
|
||
|
||
// File handling
|
||
fileInput.addEventListener('change', async (e)=>{
|
||
if (!e.target.files.length) return;
|
||
const f = e.target.files[0];
|
||
filename = f.name;
|
||
const buf = await f.arrayBuffer();
|
||
let text;
|
||
try {
|
||
// Attempt decode as UTF-8; fallback naive
|
||
text = new TextDecoder('utf-8', {fatal:false}).decode(new Uint8Array(buf));
|
||
} catch { text = String(buf); }
|
||
originalEOL = detectEOL(text);
|
||
ta.value = text;
|
||
renderGutter();
|
||
setEditorVisible(true);
|
||
updateMeta(f.size);
|
||
eolSel.value = 'auto';
|
||
maybeLoadFromLocal();
|
||
ta.focus();
|
||
saveToLocal();
|
||
});
|
||
|
||
// Drag & drop
|
||
;['dragenter','dragover'].forEach(ev=> drop.addEventListener(ev, (e)=>{ e.preventDefault(); drop.classList.add('drag'); }));
|
||
;['dragleave','drop'].forEach(ev=> drop.addEventListener(ev, (e)=>{ e.preventDefault(); drop.classList.remove('drag'); }));
|
||
drop.addEventListener('drop', async (e)=>{
|
||
const f = e.dataTransfer.files?.[0]; if (!f) return;
|
||
fileInput.files = e.dataTransfer.files; // so change handler can reuse logic
|
||
const evt = new Event('change'); fileInput.dispatchEvent(evt);
|
||
});
|
||
|
||
pasteBtn.addEventListener('click', async ()=>{
|
||
try{
|
||
const text = await navigator.clipboard.readText();
|
||
if (!text) return alert('Clipboard is empty.');
|
||
filename = 'pasted.txt';
|
||
ta.value = text;
|
||
originalEOL = detectEOL(text);
|
||
setEditorVisible(true);
|
||
renderGutter();
|
||
updateMeta(text.length);
|
||
eolSel.value = 'auto';
|
||
ta.focus();
|
||
saveToLocal();
|
||
}catch{ alert('Clipboard permission denied. Paste with Ctrl/Cmd+V into the editor.'); }
|
||
});
|
||
|
||
eolSel.addEventListener('change', ()=>{
|
||
setEOLMode(eolSel.value);
|
||
});
|
||
|
||
wrapBtn.addEventListener('click', ()=>{
|
||
wrap = !wrap;
|
||
ta.style.whiteSpace = wrap ? 'pre-wrap' : 'pre';
|
||
});
|
||
|
||
// Editor behavior
|
||
function renderGutter(){
|
||
const lines = ta.value.split(/\\r?\\n/).length;
|
||
let s = '';
|
||
for (let i=1;i<=lines;i++){ s += (i + '\\n'); }
|
||
gutter.textContent = s;
|
||
gutter.scrollTop = ta.scrollTop;
|
||
}
|
||
ta.addEventListener('input', ()=>{ renderGutter(); saveToLocal(); highlightFind(); updateCaretPos(); });
|
||
ta.addEventListener('scroll', ()=>{ gutter.scrollTop = ta.scrollTop; });
|
||
ta.addEventListener('keyup', updateCaretPos);
|
||
ta.addEventListener('click', updateCaretPos);
|
||
function updateCaretPos(){
|
||
const start = ta.selectionStart;
|
||
const pre = ta.value.slice(0, start);
|
||
const line = pre.split(/\\r?\\n/).length;
|
||
const lastBreak = Math.max(pre.lastIndexOf('\\n'), pre.lastIndexOf('\\r'));
|
||
const col = start - (lastBreak >= 0 ? lastBreak : -1);
|
||
pos.textContent = `Ln ${line}, Col ${col}`;
|
||
}
|
||
|
||
// Find / Replace (simple)
|
||
function highlightFind(){
|
||
const q = findInput.value;
|
||
if (!q){ matchCount.textContent = '0 matches'; return; }
|
||
const re = new RegExp(q.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&'), 'g');
|
||
const m = ta.value.match(re);
|
||
matchCount.textContent = (m? m.length : 0) + ' matches';
|
||
}
|
||
findInput.addEventListener('input', highlightFind);
|
||
findInput.addEventListener('keydown', (e)=>{ if (e.key==='Enter') findNext(); });
|
||
findNextBtn.addEventListener('click', findNext);
|
||
function findNext(){
|
||
const q = findInput.value;
|
||
if (!q) return;
|
||
const start = ta.selectionEnd;
|
||
const idx = ta.value.indexOf(q, start);
|
||
const pos = idx >= 0 ? idx : ta.value.indexOf(q, 0);
|
||
if (pos >= 0){
|
||
ta.focus();
|
||
ta.setSelectionRange(pos, pos+q.length);
|
||
ta.scrollTop = ta.scrollHeight * (pos / ta.value.length);
|
||
updateCaretPos();
|
||
}
|
||
highlightFind();
|
||
}
|
||
replaceBtn.addEventListener('click', ()=>{
|
||
const q = findInput.value; if (!q) return;
|
||
const r = replaceInput.value ?? '';
|
||
if (ta.selectionStart !== ta.selectionEnd && ta.value.slice(ta.selectionStart, ta.selectionEnd) === q){
|
||
const before = ta.value.slice(0, ta.selectionStart);
|
||
const after = ta.value.slice(ta.selectionEnd);
|
||
const pos0 = ta.selectionStart;
|
||
ta.value = before + r + after;
|
||
ta.setSelectionRange(pos0, pos0 + r.length);
|
||
renderGutter(); saveToLocal(); highlightFind(); updateCaretPos();
|
||
} else {
|
||
findNext();
|
||
}
|
||
});
|
||
replaceAllBtn.addEventListener('click', ()=>{
|
||
const q = findInput.value; if (!q) return;
|
||
const r = replaceInput.value ?? '';
|
||
const re = new RegExp(q.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&'), 'g');
|
||
ta.value = ta.value.replace(re, r);
|
||
renderGutter(); saveToLocal(); highlightFind(); updateCaretPos();
|
||
});
|
||
|
||
// Download
|
||
function doDownload(){
|
||
let text = ta.value;
|
||
const mode = eolSel.value;
|
||
if (mode==='lf') text = normalizeToEOL(text, '\\n');
|
||
else if (mode==='crlf') text = normalizeToEOL(text, '\\r\\n');
|
||
else text = normalizeToEOL(text, originalEOL); // auto preserves original
|
||
const blob = new Blob([text], {type:'text/plain'});
|
||
const a = document.createElement('a');
|
||
a.href = URL.createObjectURL(blob);
|
||
a.download = getDownloadName();
|
||
a.click();
|
||
setTimeout(()=>URL.revokeObjectURL(a.href), 1200);
|
||
}
|
||
downloadBtn.addEventListener('click', doDownload);
|
||
window.addEventListener('keydown', (e)=>{
|
||
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase()==='s'){ e.preventDefault(); doDownload(); }
|
||
});
|
||
|
||
// Init
|
||
setEditorVisible(false);
|
||
ta.style.whiteSpace = 'pre'; // default no wrap
|
||
renderGutter();
|
||
updateCaretPos();
|
||
})();
|
||
</script>
|
||
</body>
|
||
</html> |