commits
tags
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>NFO / ANS EDITOR</title>
<style>
@import url('https://fonts.googleapis.com/css2?family=Share+Tech+Mono&display=swap');
@font-face {
font-family: 'IBM VGA 8x16';
src: url('ibm_vga_8x16.woff') format('woff');
font-weight: normal; font-style: normal;
}
:root {
--bg:#0a0a0a; --border:#1a1a1a; --cyan:#00e5cc;
--cyan-dk:#006b5e; --white:#cccccc; --dim:#3a3a3a; --dim2:#555555;
}
*{margin:0;padding:0;box-sizing:border-box}
html,body{background:var(--bg);color:var(--white);font-family:'Share Tech Mono','Courier New',monospace;font-size:13px;min-height:100vh}
#topbar{position:sticky;top:0;z-index:100;background:#000;border-bottom:1px solid var(--border);display:flex;align-items:center;gap:12px;padding:7px 16px;flex-wrap:wrap}
.logo{color:var(--cyan);font-size:13px;letter-spacing:3px;text-shadow:0 0 10px rgba(0,229,204,.35);white-space:nowrap}
.logo em{color:var(--dim2);font-style:normal}
.tb-btn{background:none;border:1px solid var(--cyan-dk);color:var(--cyan);font-family:inherit;font-size:11px;padding:3px 10px;cursor:pointer;letter-spacing:1px;transition:all .15s;white-space:nowrap}
.tb-btn:hover{background:var(--cyan-dk);color:#fff}
.tb-btn.dim{border-color:var(--dim);color:var(--dim2)}
.tb-btn.dim:hover{border-color:var(--cyan-dk);color:var(--cyan)}
.tb-btn.hidden{display:none}
.tb-btn.active{border-color:var(--cyan);background:rgba(0,229,204,.08);color:var(--cyan)}
#filename{color:var(--dim2);font-size:11px;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0}
#width-wrap{display:none;align-items:center;gap:6px}
#width-wrap.visible{display:flex}
#width-wrap label{color:var(--dim2);font-size:11px}
#width-sel{background:#000;border:1px solid var(--dim);color:var(--cyan);font-family:inherit;font-size:11px;padding:2px 6px;cursor:pointer}
#file-input{display:none}
#img-input{display:none}
#dropzone{display:flex;flex-direction:column;align-items:center;justify-content:center;min-height:calc(100vh - 45px);cursor:pointer;transition:background .2s;gap:14px}
#dropzone.over{background:rgba(0,229,204,.03)}
.drop-box{border:1px dashed var(--cyan-dk);padding:36px 56px;text-align:center;pointer-events:none}
.drop-title{color:var(--cyan);font-size:13px;letter-spacing:3px;margin-bottom:10px}
.drop-sub{color:var(--dim2);font-size:11px;line-height:1.9;letter-spacing:1px}
.deco{color:var(--dim);font-size:11px;pointer-events:none;user-select:none}
#output-wrap{display:none;padding:16px 16px 80px;overflow:auto}
#output-wrap.visible{display:block}
#output-wrap.centered{display:flex !important;justify-content:center;align-items:flex-start}
#canvas-wrap{position:relative;display:inline-block;line-height:0}
#screen{display:block;image-rendering:-webkit-optimize-contrast;image-rendering:pixelated;cursor:crosshair}
#screen:focus{outline:none}
#cursor-el{position:absolute;pointer-events:none;box-sizing:border-box;border:1px solid var(--cyan);display:none;animation:cblink 1s step-end infinite}
@keyframes cblink{50%{opacity:0}}
#statusbar{position:fixed;bottom:0;left:0;right:0;background:#000;border-top:1px solid var(--border);padding:3px 16px;font-size:11px;color:var(--dim2);display:flex;gap:20px;flex-wrap:wrap;z-index:80}
#statusbar b{color:var(--cyan-dk);font-weight:normal}
::-webkit-scrollbar{width:5px;height:5px}
::-webkit-scrollbar-track{background:#000}
::-webkit-scrollbar-thumb{background:var(--cyan-dk)}
#editbar{display:none;position:fixed;bottom:0;left:0;right:0;z-index:90;background:#000;border-top:1px solid var(--cyan-dk);padding:5px 16px;gap:8px;align-items:center;flex-wrap:wrap}
#editbar.visible{display:flex}
.eb-label{color:var(--dim2);font-size:10px;letter-spacing:1px;-webkit-user-select:none;user-select:none;white-space:nowrap}
.eb-sep{width:1px;height:16px;background:var(--dim);flex-shrink:0}
.csw{width:16px;height:16px;display:inline-block;cursor:pointer;border:1px solid #333;flex-shrink:0;transition:transform .1s,border-color .1s;position:relative}
.csw:hover{border-color:var(--cyan);transform:scale(1.3);z-index:1}
.csw.sel{border:2px solid #fff !important}
.eb-btn{background:none;border:1px solid var(--dim);color:var(--dim2);font-family:inherit;font-size:10px;padding:2px 8px;cursor:pointer;letter-spacing:1px;transition:all .15s;white-space:nowrap}
.eb-btn:hover{border-color:var(--cyan-dk);color:var(--cyan)}
.eb-btn.active{border-color:var(--cyan);color:var(--cyan)}
#active-char-wrap{display:flex;align-items:center;gap:4px;cursor:pointer}
#active-char-box{display:inline-flex;align-items:center;justify-content:center;width:22px;height:22px;border:1px solid var(--cyan-dk);font-family:'IBM VGA 8x16',monospace;font-size:16px;line-height:1;background:#000;color:#fff}
#active-char-wrap:hover #active-char-box{border-color:var(--cyan)}
#charpicker{display:none;position:fixed;z-index:200;background:#000;border:1px solid var(--cyan-dk);padding:6px;box-shadow:0 0 20px rgba(0,0,0,.9)}
#charpicker.visible{display:block}
#canvas-bg-picker{display:none;position:fixed;z-index:200;background:#000;border:1px solid var(--cyan-dk);padding:6px;box-shadow:0 0 20px rgba(0,0,0,.9)}
#canvas-bg-picker.visible{display:block}
#cbp-grid{display:grid;grid-template-columns:repeat(8,22px);gap:2px}
#canvas-bg-swatch{width:22px;height:22px;border:1px solid var(--cyan-dk);cursor:pointer;flex-shrink:0}
#screen.eyedropper{cursor:crosshair !important}
#new-wrap{position:relative;display:inline-block}
#new-menu{display:none;position:absolute;top:calc(100% + 2px);left:0;background:#000;border:1px solid var(--cyan-dk);z-index:200;flex-direction:column;min-width:100%;white-space:nowrap}
#new-menu.visible{display:flex}
#new-menu .tb-btn{border:none;border-bottom:1px solid var(--border);text-align:left;width:100%;padding:4px 12px}
#new-menu .tb-btn:last-child{border-bottom:none}
#na-text{width:100%;height:130px;background:#000;border:1px solid var(--border);color:#aaa;font-family:'Share Tech Mono','Courier New',monospace;font-size:11px;padding:5px;resize:vertical;box-sizing:border-box;margin:8px 0}
#export-wrap{position:relative;display:none}
#export-wrap.visible{display:inline-block}
#export-menu{display:none;position:absolute;top:calc(100% + 2px);left:0;background:#000;border:1px solid var(--cyan-dk);z-index:200;flex-direction:column;min-width:100%}
#export-menu.visible{display:flex}
#export-menu .tb-btn{border:none;border-bottom:1px solid var(--border);text-align:left;width:100%;padding:4px 12px}
#export-menu .tb-btn:last-child{border-bottom:none}
#canvas-bg-swatch:hover{border-color:var(--cyan)}
/* Tab bar */
#tabbar{position:sticky;top:0;z-index:95;background:#000;border-bottom:1px solid var(--border);display:flex;align-items:stretch;overflow-x:auto;flex-shrink:0}
.tab{background:none;border:none;border-right:1px solid var(--border);color:var(--dim2);font-family:inherit;font-size:11px;padding:4px 10px 4px 12px;cursor:pointer;letter-spacing:1px;white-space:nowrap;display:flex;align-items:center;gap:5px;flex-shrink:0}
.tab:hover{background:rgba(255,255,255,.03);color:var(--white)}
.tab.active{background:rgba(0,229,204,.05);color:var(--cyan);border-bottom:2px solid var(--cyan)}
.tab-x{opacity:.4;font-size:13px;line-height:1;cursor:pointer;padding:0 2px;-webkit-user-select:none;user-select:none}
.tab-x:hover{opacity:1}
#tab-add{background:none;border:none;color:var(--dim2);font-family:inherit;font-size:16px;line-height:1;padding:0 12px;cursor:pointer}
#tab-add:hover{color:var(--cyan)}
#cp-grid{display:grid;grid-template-columns:repeat(32,18px);gap:1px}
.cpc{width:18px;height:18px;display:flex;align-items:center;justify-content:center;font-family:'IBM VGA 8x16',monospace;font-size:13px;line-height:1;cursor:pointer;color:#aaa;border:1px solid transparent;-webkit-user-select:none;user-select:none}
.cpc:hover{background:var(--cyan-dk);color:#fff}
.cpc.sel{border-color:var(--cyan);color:var(--cyan)}
#new-dlg,#new-ansi-dlg{display:none;position:fixed;z-index:200;top:50%;left:50%;transform:translate(-50%,-50%);background:#0a0a0a;border:1px solid var(--cyan-dk);padding:24px;min-width:260px}
#new-dlg.visible,#new-ansi-dlg.visible{display:block}
#new-dlg h3,#new-ansi-dlg h3{color:var(--cyan);font-size:11px;letter-spacing:2px;margin-bottom:16px}
.dlg-row{display:flex;align-items:center;gap:10px;margin-bottom:10px}
.dlg-row label{color:var(--dim2);font-size:11px;width:70px}
.dlg-row input{background:#000;border:1px solid var(--dim);color:var(--cyan);font-family:inherit;font-size:11px;padding:3px 8px;width:90px}
.dlg-btns{display:flex;gap:8px;margin-top:16px;justify-content:flex-end}
#overlay{display:none;position:fixed;inset:0;z-index:150;background:rgba(0,0,0,.6)}
#overlay.visible{display:block}
#help-overlay{display:none;position:fixed;inset:0;z-index:300;background:rgba(0,0,0,.82);align-items:center;justify-content:center}
#help-overlay.visible{display:flex}
#help-box{background:#0a0a0a;border:1px solid var(--cyan-dk);padding:28px 32px;max-width:580px;width:90%;max-height:90vh;overflow-y:auto;box-shadow:0 0 40px rgba(0,229,204,.08)}
#help-box h2{color:var(--cyan);font-size:11px;letter-spacing:4px;margin-bottom:20px;text-align:center}
.help-section{margin-bottom:18px}
.help-section h3{color:var(--dim2);font-size:10px;letter-spacing:2px;margin-bottom:8px;border-bottom:1px solid var(--border);padding-bottom:4px}
.help-row{display:flex;gap:12px;margin-bottom:5px;align-items:baseline}
.help-key{color:var(--cyan);font-size:11px;min-width:160px;flex-shrink:0}
.help-key kbd{background:#111;border:1px solid var(--cyan-dk);padding:1px 5px;border-radius:2px;font-family:inherit;font-size:10px}
.help-desc{color:var(--white);font-size:11px;opacity:.75}
#help-close{display:block;margin:20px auto 0;background:none;border:1px solid var(--cyan-dk);color:var(--cyan);font-family:inherit;font-size:11px;padding:4px 20px;cursor:pointer;letter-spacing:1px}
#help-close:hover{background:var(--cyan-dk)}
#help-titlebar{display:flex;align-items:center;justify-content:space-between;margin-bottom:20px}
#help-titlebar h2{margin-bottom:0}
#os-toggle{display:flex;border:1px solid var(--cyan-dk);font-size:10px}
.os-btn{background:none;color:var(--dim2);border:none;padding:3px 10px;font-family:inherit;cursor:pointer;letter-spacing:1px}
.os-btn.active{background:var(--cyan-dk);color:#fff}
.color-btn{width:28px;height:18px;cursor:pointer;border:1px solid var(--cyan-dk);flex-shrink:0;transition:border-color .1s}
.color-btn:hover{border-color:var(--cyan)}
#color256-picker{display:none;position:fixed;z-index:200;background:#0a0a0a;border:1px solid var(--cyan-dk);padding:8px;box-shadow:0 0 24px rgba(0,0,0,.95)}
#color256-picker.visible{display:block}
#c256-title{color:var(--cyan);font-size:10px;letter-spacing:2px;margin-bottom:5px}
.c256-lbl{color:var(--dim2);font-size:9px;letter-spacing:1px;margin:5px 0 2px}
#c256-sys-grid{display:grid;grid-template-columns:repeat(16,16px);gap:1px;margin-bottom:4px}
#c256-cube-grid{display:flex;gap:4px;margin-bottom:4px}
.c256-plane{display:grid;grid-template-columns:repeat(6,9px);gap:1px}
#c256-gray-grid{display:grid;grid-template-columns:repeat(24,12px);gap:1px}
.c256c{cursor:pointer;border:1px solid transparent;box-sizing:border-box;position:relative}
.c256c:hover{border-color:#fff;transform:scale(1.3);z-index:1}
.c256c.sel{border:2px solid #fff !important}
#c256-transp{display:none;margin-top:6px}
#c256-transp-btn{color:var(--dim2);font-size:10px;cursor:pointer;border:1px solid var(--dim);padding:2px 10px;letter-spacing:1px;display:inline-block}
#c256-transp-btn:hover{color:var(--cyan);border-color:var(--cyan-dk)}
#c256-transp-btn.sel{color:var(--cyan);border-color:var(--cyan)}
/* Image convert dialog */
#img-dlg{display:none;position:fixed;z-index:200;top:50%;left:50%;transform:translate(-50%,-50%);background:#0a0a0a;border:1px solid var(--cyan-dk);padding:24px;min-width:280px}
#img-dlg.visible{display:block}
#img-dlg h3{color:var(--cyan);font-size:11px;letter-spacing:2px;margin-bottom:16px}
.dlg-check{display:flex;align-items:center;gap:8px;margin-bottom:10px}
.dlg-check label{color:var(--dim2);font-size:11px}
.dlg-check input[type=checkbox]{accent-color:var(--cyan);cursor:pointer}
#img-preview-wrap{margin:12px 0;text-align:center;min-height:40px}
#img-preview{max-width:100%;max-height:200px;image-rendering:pixelated}
</style>
</head>
<body>
<div id="topbar">
<div class="logo">NFO<em>::</em>ANS<em>::</em>EDITOR</div>
<button type="button" class="tb-btn" onclick="openWithPicker()" title="Ctrl+O">[ OPEN ]</button>
<div id="new-wrap">
<button type="button" class="tb-btn dim" onclick="toggleNewMenu()" title="Ctrl+N">[ NEW ▾ ]</button>
<div id="new-menu">
<button type="button" class="tb-btn dim" onclick="showNewDlg();hideNewMenu()">[ EMPTY ]</button>
<button type="button" class="tb-btn dim" onclick="newFromClipboard()">[ FROM CLIPBOARD ]</button>
<button type="button" class="tb-btn dim" onclick="showNewAnsiDlg();hideNewMenu()">[ FROM ANSI CODE ]</button>
</div>
</div>
<button type="button" class="tb-btn dim" onclick="document.getElementById('img-input').click()" title="Konvertera bild till ANSI">[ IMAGE → ANS ]</button>
<input type="file" id="file-input" accept=".nfo,.ans,.txt,.diz,.asc,.ansi">
<input type="file" id="img-input" accept="image/*" title="Välj bild att konvertera till ANSI">
<div id="filename">drop .nfo / .ans anywhere</div>
<div id="width-wrap">
<label>cols:</label>
<select id="width-sel" onchange="rerender()">
<option value="80">80</option>
<option value="160">160</option>
<option value="0">auto</option>
</select>
</div>
<button type="button" id="save-btn" class="tb-btn dim" onclick="saveANS()" title="Ctrl+S">[ SAVE ]</button>
<div id="export-wrap">
<button type="button" id="export-btn" class="tb-btn dim" onclick="toggleExportMenu()">[ EXPORT ▾ ]</button>
<div id="export-menu">
<button type="button" class="tb-btn dim" onclick="exportANS();hideExportMenu()">[ .ANS ]</button>
<button type="button" class="tb-btn dim" onclick="exportNFO();hideExportMenu()">[ .NFO plain ]</button>
<button type="button" class="tb-btn dim" onclick="exportHTML();hideExportMenu()">[ .HTML ]</button>
</div>
</div>
<button type="button" id="edit-btn" class="tb-btn dim hidden" onclick="toggleEdit()" title="Esc (exit edit)">[ EDIT ]</button>
<button type="button" class="tb-btn dim" onclick="showHelp()" title="?">[ ? ]</button>
</div>
<div id="tabbar"></div>
<div id="help-overlay" onclick="hideHelp()">
<div id="help-box" onclick="event.stopPropagation()">
<div id="help-titlebar">
<h2>KEYBOARD SHORTCUTS</h2>
<div id="os-toggle">
<button type="button" id="os-win" class="os-btn active" onclick="setOsMode('win')">WIN</button>
<button type="button" id="os-mac" class="os-btn" onclick="setOsMode('mac')">MAC</button>
</div>
</div>
<div class="help-section">
<h3>GLOBAL</h3>
<div class="help-row"><span class="help-key"><kbd>Ctrl</kbd> + <kbd>O</kbd></span><span class="help-desc">Öppna fil</span></div>
<div class="help-row"><span class="help-key"><kbd>Ctrl</kbd> + <kbd>N</kbd></span><span class="help-desc">Ny canvas</span></div>
<div class="help-row"><span class="help-key"><kbd>Ctrl</kbd> + <kbd>S</kbd></span><span class="help-desc">Exportera ANS</span></div>
<div class="help-row"><span class="help-key"><kbd>Ctrl</kbd> + <kbd>Shift</kbd> + <kbd>S</kbd></span><span class="help-desc">Exportera HTML</span></div>
<div class="help-row"><span class="help-key"><kbd>Ctrl</kbd> + <kbd>Z</kbd></span><span class="help-desc">Ångra</span></div>
<div class="help-row"><span class="help-key"><kbd>Ctrl</kbd> + <kbd>Y</kbd></span><span class="help-desc">Gör om</span></div>
</div>
<div class="help-section">
<h3>EDIT-LÄGE</h3>
<div class="help-row"><span class="help-key"><kbd>Esc</kbd></span><span class="help-desc">Avsluta edit-läge</span></div>
<div class="help-row"><span class="help-key"><kbd>Tab</kbd></span><span class="help-desc">Växla TEXT ↔ DRAW</span></div>
<div class="help-row"><span class="help-key"><kbd>F2</kbd></span><span class="help-desc">Öppna teckenpanel</span></div>
<div class="help-row"><span class="help-key"><kbd>Ctrl</kbd> + <kbd>Delete</kbd></span><span class="help-desc">Radera aktuell rad</span></div>
<div class="help-row"><span class="help-key"><kbd>Ctrl</kbd> + <kbd>Insert</kbd></span><span class="help-desc">Infoga tom rad</span></div>
<div class="help-row"><span class="help-key"><kbd>Ctrl</kbd> + <kbd>+</kbd></span><span class="help-desc">Zooma in</span></div>
<div class="help-row"><span class="help-key"><kbd>Ctrl</kbd> + <kbd>-</kbd></span><span class="help-desc">Zooma ut</span></div>
</div>
<div class="help-section">
<h3>TEXT-LÄGE</h3>
<div class="help-row"><span class="help-key"><kbd>Piltangenter</kbd></span><span class="help-desc">Flytta markör</span></div>
<div class="help-row"><span class="help-key"><kbd>Home</kbd> / <kbd>End</kbd></span><span class="help-desc">Radstart / radslut</span></div>
<div class="help-row"><span class="help-key"><kbd>Enter</kbd></span><span class="help-desc">Ny rad</span></div>
<div class="help-row"><span class="help-key"><kbd>Backspace</kbd></span><span class="help-desc">Radera tecken till vänster</span></div>
<div class="help-row"><span class="help-key"><kbd>Delete</kbd></span><span class="help-desc">Radera tecken vid markör</span></div>
<div class="help-row"><span class="help-key">Valfritt tecken</span><span class="help-desc">Skriv med aktiv FG/BG-färg</span></div>
</div>
<div class="help-section">
<h3>MARKERING & KLIPPBORD</h3>
<div class="help-row"><span class="help-key"><kbd>Ctrl</kbd> + <kbd>C</kbd></span><span class="help-desc">Kopiera markering (till app + systemklippbord)</span></div>
<div class="help-row"><span class="help-key"><kbd>Ctrl</kbd> + <kbd>X</kbd></span><span class="help-desc">Klipp ut markering</span></div>
<div class="help-row"><span class="help-key"><kbd>Ctrl</kbd> + <kbd>V</kbd></span><span class="help-desc">Klistra in — även extern ASCII-art från systemklippbord</span></div>
</div>
<div class="help-section">
<h3>DRAW-LÄGE</h3>
<div class="help-row"><span class="help-key">Klick</span><span class="help-desc">Måla cell med aktivt tecken</span></div>
<div class="help-row"><span class="help-key">Klick + drag</span><span class="help-desc">Måla längs drag</span></div>
<div class="help-row"><span class="help-key"><kbd>F2</kbd></span><span class="help-desc">Välj aktivt tecken</span></div>
</div>
<button type="button" id="help-close" onclick="hideHelp()">[ STÄNG ]</button>
</div>
</div>
<div id="editbar">
<button type="button" class="eb-btn active" id="mode-text-btn" onclick="setMode('text')" title="Tab">[ TEXT ]</button>
<button type="button" class="eb-btn" id="mode-draw-btn" onclick="setMode('draw')" title="Tab">[ DRAW ]</button>
<button type="button" class="eb-btn" id="mode-sel-btn" onclick="setMode('select')" title="Markera område">[ SEL ]</button>
<button type="button" class="eb-btn" id="pick-btn" onclick="toggleEyedropper()" title="Högerklick på canvas plockar också färg">[ PICK ]</button>
<div class="eb-sep"></div>
<div id="active-char-wrap" onclick="toggleCharPicker(event)" title="F2 — pick character">
<span class="eb-label">CHAR:</span>
<div id="active-char-box">█</div>
</div>
<div class="eb-sep"></div>
<span class="eb-label">FG:</span>
<div id="cp-fg-box" class="color-btn" onclick="openColorPicker('fg',event)" title="Teckenfärg (klicka för att byta)"></div>
<div class="eb-sep"></div>
<span class="eb-label">BG:</span>
<div id="cp-bg-box" class="color-btn" onclick="openColorPicker('bg',event)" title="Bakgrundsfärg (klicka för att byta)"></div>
<div class="eb-sep"></div>
<span class="eb-label">YTA:</span>
<div id="canvas-bg-swatch" title="Arbetsytans bakgrundsfärg — klicka för att byta" onclick="toggleCanvasBgPicker(event)"></div>
<div class="eb-sep"></div>
<button type="button" class="eb-btn" onclick="deleteRow()" title="Ctrl+Delete">[ DEL ROW ]</button>
<button type="button" class="eb-btn" onclick="insertRow()" title="Ctrl+Insert">[ INS ROW ]</button>
<button type="button" class="eb-btn" id="center-btn" onclick="toggleCenter()">[ CENTER ]</button>
<button type="button" class="eb-btn" onclick="adjFont(1)" title="Ctrl++">[ A+ ]</button>
<button type="button" class="eb-btn" onclick="adjFont(-1)" title="Ctrl+-">[ A- ]</button>
<div class="eb-sep"></div>
<button type="button" class="eb-btn" onclick="undo()" title="Ctrl+Z">[ ↩ UNDO ]</button>
<button type="button" class="eb-btn" onclick="redo()" title="Ctrl+Y / Ctrl+Shift+Z">[ ↪ REDO ]</button>
<div class="eb-sep"></div>
<span class="eb-label" id="cur-pos">0,0</span>
</div>
<div id="charpicker"><div id="cp-grid"></div></div>
<div id="canvas-bg-picker"><div id="cbp-grid"></div></div>
<div id="color256-picker">
<div id="c256-title">FG</div>
<div class="c256-lbl">SYSTEM</div>
<div id="c256-sys-grid"></div>
<div class="c256-lbl">FÄRGKUB</div>
<div id="c256-cube-grid"></div>
<div class="c256-lbl">GRÅSKALA</div>
<div id="c256-gray-grid"></div>
<div id="c256-transp"><span id="c256-transp-btn" onmousedown="event.preventDefault()" onclick="setActiveBg(-1);hideColorPicker()">[ INGEN BG ]</span></div>
</div>
<div id="overlay" onclick="hideActiveDialog()"></div>
<div id="new-dlg">
<h3>NEW CANVAS</h3>
<div class="dlg-row"><label>Columns</label><input type="number" id="dlg-cols" value="80" min="20" max="320"></div>
<div class="dlg-row"><label>Rows</label><input type="number" id="dlg-rows" value="25" min="5" max="300"></div>
<div class="dlg-btns">
<button type="button" class="eb-btn" onclick="hideNewDlg()">[ CANCEL ]</button>
<button class="eb-btn active" onclick="createNewCanvas()">[ CREATE ]</button>
</div>
</div>
<div id="new-ansi-dlg">
<h3>NEW FROM ANSI CODE</h3>
<div class="dlg-row"><label>Cols</label><input type="number" id="na-cols" value="80" min="20" max="320"></div>
<textarea id="na-text" placeholder="Klistra in ANSI-kod (ESC-sekvenser) här..."></textarea>
<div class="dlg-btns">
<button type="button" class="eb-btn" onclick="hideNewAnsiDlg()">[ CANCEL ]</button>
<button class="eb-btn active" onclick="createFromAnsiCode()">[ CREATE ]</button>
</div>
</div>
<div id="img-dlg">
<h3>BILD → ANSI</h3>
<div class="dlg-row"><label>Kolonner</label><input type="number" id="img-cols" value="80" min="10" max="320" oninput="imgAutoRows()"></div>
<div class="dlg-row"><label>Rader</label><input type="number" id="img-rows" value="40" min="5" max="300"></div>
<div class="dlg-check"><input type="checkbox" id="img-ascii" onchange="imgAsciiToggle()"><label for="img-ascii">ASCII-tecken <span id="img-ascii-ramp" style="color:var(--dim2);font-size:10px;letter-spacing:1px"> .:-=+*#%@</span></label></div>
<div class="dlg-check" id="img-half-row"><input type="checkbox" id="img-half" checked><label for="img-half">Halvblock ▀ (dubbel vertikal upplösning)</label></div>
<div class="dlg-check"><input type="checkbox" id="img-256"><label for="img-256">256 färger <span style="color:var(--dim2);font-size:10px">(bättre färgnoggrannhet)</span></label></div>
<div class="dlg-check"><input type="checkbox" id="img-dither" checked><label for="img-dither">Dithering (bättre färgövergångar)</label></div>
<div class="dlg-check"><input type="checkbox" id="img-aspect" checked onchange="imgAutoRows()"><label for="img-aspect">Bevara proportioner</label></div>
<div id="img-preview-wrap"></div>
<div class="dlg-btns">
<button type="button" class="eb-btn" onclick="hideImgDlg()">[ AVBRYT ]</button>
<button type="button" class="eb-btn active" onclick="doImgConvert()">[ KONVERTERA ]</button>
</div>
</div>
<div id="dropzone" onclick="document.getElementById('file-input').click()">
<div class="deco">\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/</div>
<div class="drop-box">
<div class="drop-title">DROP FILE HERE</div>
<div class="drop-sub">.nfo .ans .diz .asc .txt<br>CP437 + ANSI color + cursor forward<br><br>click to browse · or [ NEW ] for blank canvas</div>
</div>
<div class="deco">\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/\/</div>
</div>
<div id="output-wrap">
<div id="canvas-wrap">
<canvas id="screen" tabindex="0"></canvas>
<div id="cursor-el"></div>
</div>
</div>
<div id="statusbar">
<div>file: <b id="s-name">—</b></div>
<div>size: <b id="s-size">—</b></div>
<div>type: <b id="s-type">—</b></div>
<div>dims: <b id="s-dims">—</b></div>
<div>encoding: <b>CP437</b></div>
</div>
<script>
'use strict';
const CP437 = new Uint32Array([
0,0x263A,0x263B,0x2665,0x2666,0x2663,0x2660,0x2022,0x25D8,0x25CB,0x25D9,0x2642,0x2640,0x266A,0x266B,0x263C,
0x25BA,0x25C4,0x2195,0x203C,0xB6,0xA7,0x25AC,0x21A8,0x2191,0x2193,0x2192,0x2190,0x221F,0x2194,0x25B2,0x25BC,
0x20,0x21,0x22,0x23,0x24,0x25,0x26,0x27,0x28,0x29,0x2A,0x2B,0x2C,0x2D,0x2E,0x2F,
0x30,0x31,0x32,0x33,0x34,0x35,0x36,0x37,0x38,0x39,0x3A,0x3B,0x3C,0x3D,0x3E,0x3F,
0x40,0x41,0x42,0x43,0x44,0x45,0x46,0x47,0x48,0x49,0x4A,0x4B,0x4C,0x4D,0x4E,0x4F,
0x50,0x51,0x52,0x53,0x54,0x55,0x56,0x57,0x58,0x59,0x5A,0x5B,0x5C,0x5D,0x5E,0x5F,
0x60,0x61,0x62,0x63,0x64,0x65,0x66,0x67,0x68,0x69,0x6A,0x6B,0x6C,0x6D,0x6E,0x6F,
0x70,0x71,0x72,0x73,0x74,0x75,0x76,0x77,0x78,0x79,0x7A,0x7B,0x7C,0x7D,0x7E,0x2302,
0xC7,0xFC,0xE9,0xE2,0xE4,0xE0,0xE5,0xE7,0xEA,0xEB,0xE8,0xEF,0xEE,0xEC,0xC4,0xC5,
0xC9,0xE6,0xC6,0xF4,0xF6,0xF2,0xFB,0xF9,0xFF,0xD6,0xDC,0xA2,0xA3,0xA5,0x20A7,0x192,
0xE1,0xED,0xF3,0xFA,0xF1,0xD1,0xAA,0xBA,0xBF,0x2310,0xAC,0xBD,0xBC,0xA1,0xAB,0xBB,
0x2591,0x2592,0x2593,0x2502,0x2524,0x2561,0x2562,0x2556,0x2555,0x2563,0x2551,0x2557,
0x255D,0x255C,0x255B,0x2510,0x2514,0x2534,0x252C,0x251C,0x2500,0x253C,0x255E,0x255F,
0x255A,0x2554,0x2569,0x2566,0x2560,0x2550,0x256C,0x2567,0x2568,0x2564,0x2565,0x2559,
0x2558,0x2552,0x2553,0x256B,0x256A,0x2518,0x250C,0x2588,0x2584,0x258C,0x2590,0x2580,
0x3B1,0xDF,0x393,0x3C0,0x3A3,0x3C3,0xB5,0x3C4,0x3A6,0x398,0x3A9,0x3B4,0x221E,0x3C6,
0x3B5,0x2229,0x2261,0xB1,0x2265,0x2264,0x2320,0x2321,0xF7,0x2248,0xB0,0x2219,0xB7,
0x221A,0x207F,0xB2,0x25A0,0xA0
]);
const CP437_REV = new Map();
for (let i = 1; i < 256; i++) CP437_REV.set(CP437[i], i);
CP437_REV.set(0x20, 0x20);
const TRANSP_BG = 'repeating-conic-gradient(#888 0% 25%,#fff 0% 50%) 0/8px 8px';
const PALETTE = (() => {
const h = n => n.toString(16).padStart(2,'0');
const p = [
'#000000','#aa0000','#00aa00','#aa5500',
'#0000aa','#aa00aa','#00aaaa','#aaaaaa',
'#555555','#ff5555','#55ff55','#ffff55',
'#5555ff','#ff55ff','#55ffff','#ffffff'
];
const lvl = [0,95,135,175,215,255];
for (let r=0;r<6;r++) for (let g=0;g<6;g++) for (let b=0;b<6;b++)
p.push(`#${h(lvl[r])}${h(lvl[g])}${h(lvl[b])}`);
for (let i=0;i<24;i++) { const v=8+i*10; p.push(`#${h(v)}${h(v)}${h(v)}`); }
return p;
})();
// ── Data model ───────────────────────────────────────────────────────────────
let sparseMap = new Map();
let lastBytes = null, lastFile = null;
let _saveTimer = null;
let canvasCols = 80, canvasRows = 25;
let canvasBg = '#000000';
// ── Canvas metrics ───────────────────────────────────────────────────────────
const DPR = window.devicePixelRatio || 1;
let CHAR_W = 8, CHAR_H = 16, fontScale = 1;
document.fonts.ready.then(() => {
const off = document.createElement('canvas');
const octx = off.getContext('2d');
octx.font = '16px "IBM VGA 8x16"';
CHAR_W = Math.round(octx.measureText('\u2588').width) || 8;
if (sparseMap.size > 0) drawAll();
});
// ── Edit state ───────────────────────────────────────────────────────────────
let editMode = false, drawMode = 'text';
let curRow = 0, curCol = 0;
let activeFg = 7, activeBg = -1;
let activeChar = '\u2588';
let isPainting = false, isCentered = false;
let eyedropperActive = false;
let isErasing = false;
let isMoving = false;
let moveOffsetR = 0, moveOffsetC = 0;
let moveSelCells = null;
let moveOrigR1 = 0, moveOrigC1 = 0, moveOrigR2 = 0, moveOrigC2 = 0;
let undoStack = [], redoStack = [];
let selActive = false, selDragging = false;
let selR1 = 0, selC1 = 0, selR2 = 0, selC2 = 0;
let clipboard = null;
let clipboardText = null;
let lastFileHandle = null;
// ── Tabs ──────────────────────────────────────────────────────────────────────
function newTabState() {
return { name:'untitled', sparseMap:new Map(), lastFile:null, lastBytes:null,
lastFileHandle:null, canvasCols:80, canvasRows:25, canvasBg:'#000000',
undoStack:[], redoStack:[], isCentered:false };
}
let tabs = [newTabState()];
let activeTab = 0;
updateColorPreview();
document.getElementById('canvas-bg-swatch').style.background = canvasBg;
renderTabs();
if (!window.showOpenFilePicker) {
const sb = document.getElementById('save-btn');
sb.disabled = true;
sb.title = 'Save function only supported by Chrome and Edge at the moment';
sb.style.opacity = '0.35';
sb.style.cursor = 'not-allowed';
}
// ── Canvas rendering ─────────────────────────────────────────────────────────
const canvas = document.getElementById('screen');
let ctx = null;
function cw() { return Math.round(fontScale * CHAR_W); }
function ch() { return Math.round(fontScale * CHAR_H); }
function getBounds(map) {
let maxRow = 0, maxCol = 0;
for (const key of map.keys()) {
const r = Math.floor(key / 100000), c = key % 100000;
if (r > maxRow) maxRow = r;
if (c > maxCol) maxCol = c;
}
return { maxRow, maxCol };
}
function drawAll() {
const { maxRow, maxCol } = getBounds(sparseMap);
const rows = Math.max(maxRow + 1, canvasRows);
const cols = Math.max(maxCol + 1, canvasCols);
const W = cols * cw(), H = rows * ch();
canvas.style.width = W + 'px';
canvas.style.height = H + 'px';
canvas.width = W * DPR;
canvas.height = H * DPR;
ctx = canvas.getContext('2d');
ctx.scale(DPR, DPR);
ctx.fillStyle = canvasBg;
ctx.fillRect(0, 0, W, H);
ctx.font = `${fontScale * CHAR_H}px "IBM VGA 8x16"`;
ctx.textBaseline = 'top';
for (const [key, cell] of sparseMap) {
const row = Math.floor(key / 100000), col = key % 100000;
const x = col * cw(), y = row * ch();
if (cell.bg >= 0) {
ctx.fillStyle = PALETTE[cell.bg];
ctx.fillRect(x, y, cw(), ch());
}
ctx.fillStyle = PALETTE[cell.fg] || PALETTE[7];
ctx.fillText(cell.ch, x, y);
}
drawSelection();
if (isMoving && moveSelCells) {
const dstR = Math.min(selR1, selR2), dstC = Math.min(selC1, selC2);
ctx.fillStyle = canvasBg;
ctx.fillRect(moveOrigC1 * cw(), moveOrigR1 * ch(),
(moveOrigC2 - moveOrigC1 + 1) * cw(), (moveOrigR2 - moveOrigR1 + 1) * ch());
ctx.font = `${fontScale * CHAR_H}px "IBM VGA 8x16"`;
ctx.textBaseline = 'top';
for (const [key, cell] of moveSelCells) {
const dr = Math.floor(key / 100000), dc = key % 100000;
const x = (dstC + dc) * cw(), y = (dstR + dr) * ch();
if (cell.bg >= 0) { ctx.fillStyle = PALETTE[cell.bg]; ctx.fillRect(x, y, cw(), ch()); }
ctx.fillStyle = PALETTE[cell.fg] || PALETTE[7];
ctx.fillText(cell.ch, x, y);
}
}
updateCursor();
}
function drawCell(row, col, cell) {
if (!ctx) return;
const x = col * cw(), y = row * ch();
ctx.fillStyle = canvasBg;
ctx.fillRect(x, y, cw(), ch());
if (!cell) return;
if (cell.bg >= 0) {
ctx.fillStyle = PALETTE[cell.bg];
ctx.fillRect(x, y, cw(), ch());
}
ctx.font = `${fontScale * CHAR_H}px "IBM VGA 8x16"`;
ctx.textBaseline = 'top';
ctx.fillStyle = PALETTE[cell.fg] || PALETTE[7];
ctx.fillText(cell.ch, x, y);
}
function updateCursor() {
const el = document.getElementById('cursor-el');
if (!editMode || drawMode !== 'text') { el.style.display = 'none'; return; }
el.style.display = 'block';
el.style.width = cw() + 'px';
el.style.height = ch() + 'px';
el.style.left = (curCol * cw()) + 'px';
el.style.top = (curRow * ch()) + 'px';
document.getElementById('cur-pos').textContent = curRow + ',' + curCol;
}
// ── ANSI parser ───────────────────────────────────────────────────────────────
function parse(bytes, cols) {
sparseMap = new Map();
let end = bytes.length;
for (let i = Math.max(0, end - 256); i < end - 5; i++) {
if (bytes[i]===0x53&&bytes[i+1]===0x41&&bytes[i+2]===0x55&&
bytes[i+3]===0x43&&bytes[i+4]===0x45) {
end = i;
if (end > 0 && bytes[end-1] === 0x1a) end--;
break;
}
}
let row = 0, col = 0, fg = 7, bg = -1, bold = false;
const COLS = cols || 80;
let wrapPending = false;
let i = 0;
while (i < end) {
const b = bytes[i];
if (b === 0x1b && i+1 < end && bytes[i+1] === 0x5b) {
i += 2;
let seq = '';
while (i < end && !(bytes[i] >= 0x41 && bytes[i] <= 0x7a))
seq += String.fromCharCode(bytes[i++]);
const cmd = i < end ? String.fromCharCode(bytes[i++]) : '';
const parts = seq.split(';');
const n0 = parts[0]===''||parts[0]==null ? 0 : parseInt(parts[0],10)||0;
if (cmd==='C') { col += n0||1; }
else if (cmd==='m') {
for (const n of parts.map(p=>p===''?0:parseInt(p,10)||0)) {
if (n===0) { fg=7; bg=-1; bold=false; }
else if (n===1) bold=true;
else if (n===22) bold=false;
else if (n>=30&&n<=37) fg=n-30;
else if (n===39) fg=7;
else if (n>=40&&n<=47) bg=n-40;
else if (n===49) bg=-1;
else if (n>=90&&n<=97) fg=n-90+8;
else if (n>=100&&n<=107) bg=n-100+8;
}
} else if (cmd==='A') { row=Math.max(0,row-(n0||1)); }
else if (cmd==='B') { row+=n0||1; }
else if (cmd==='D') { col=Math.max(0,col-(n0||1)); }
else if (cmd==='H'||cmd==='f') {
row=Math.max(0,parseInt(parts[0]||'1',10)-1);
col=Math.max(0,parseInt(parts[1]||'1',10)-1);
} else if (cmd==='J') { if (n0===2) { sparseMap.clear(); row=0; col=0; } }
continue;
}
if (b===0x0d) { col=0; wrapPending=false; i++; continue; }
if (b===0x0a) { if (!wrapPending) row++; wrapPending=false; col=0; i++; continue; }
if (b===0x09) { col=(Math.floor(col/8)+1)*8; i++; continue; }
if (wrapPending) { row++; col=0; wrapPending=false; }
const fgIdx = bold ? Math.min(fg+8,15) : fg;
sparseMap.set(row*100000+col, { ch: String.fromCodePoint(CP437[b]), fg:fgIdx, bg });
col++;
if (COLS>0 && col>=COLS) { col=0; wrapPending=true; }
i++;
}
}
// ── File loading ──────────────────────────────────────────────────────────────
function processFile(file) {
const reader = new FileReader();
reader.onload = e => {
lastBytes = new Uint8Array(e.target.result);
lastFile = file;
doRender(parseInt(document.getElementById('width-sel').value, 10));
};
reader.readAsArrayBuffer(file);
}
function doRender(cols) {
canvasCols = cols || 80;
parse(lastBytes, cols);
const { maxRow: mr } = getBounds(sparseMap);
canvasRows = mr + 1;
drawAll();
const { maxRow, maxCol } = getBounds(sparseMap);
const hasColor = [...sparseMap.values()].some(c => c.fg !== 7 || c.bg >= 0);
showOutput();
document.getElementById('filename').textContent = lastFile.name;
document.getElementById('s-name').textContent = lastFile.name;
document.getElementById('s-size').textContent = fmt(lastFile.size);
document.getElementById('s-type').textContent = hasColor ? 'ANSI color' : 'plain CP437';
document.getElementById('s-dims').textContent = `${maxCol+1}×${maxRow+1}`;
document.getElementById('width-wrap').classList.add('visible');
tabs[activeTab].name = lastFile.name;
renderTabs();
}
function rerender() {
if (!lastBytes) return;
doRender(parseInt(document.getElementById('width-sel').value, 10));
}
function showOutput() {
document.getElementById('output-wrap').classList.add('visible');
document.getElementById('dropzone').style.display = 'none';
document.getElementById('export-wrap').classList.add('visible');
document.getElementById('edit-btn').classList.remove('hidden');
}
function fmt(n) {
if (n < 1024) return n + ' B';
if (n < 1048576) return (n/1024).toFixed(1) + ' KB';
return (n/1048576).toFixed(2) + ' MB';
}
// ── Edit mode ─────────────────────────────────────────────────────────────────
function toggleEdit() {
editMode = !editMode;
const editbar = document.getElementById('editbar');
const btn = document.getElementById('edit-btn');
const sb = document.getElementById('statusbar');
editbar.classList.toggle('visible', editMode);
btn.classList.toggle('active', editMode);
btn.textContent = editMode ? '[ EDITING ]' : '[ EDIT ]';
if (editMode) {
canvas.focus();
requestAnimationFrame(() => { sb.style.bottom = editbar.offsetHeight + 'px'; });
updateCursor();
} else {
sb.style.bottom = '';
document.getElementById('cursor-el').style.display = 'none';
hideCharPicker();
eyedropperActive = false;
canvas.classList.remove('eyedropper');
document.getElementById('pick-btn').classList.remove('active');
}
}
function setMode(m) {
drawMode = m;
eyedropperActive = false;
canvas.classList.remove('eyedropper');
document.getElementById('pick-btn').classList.remove('active');
document.getElementById('mode-text-btn').classList.toggle('active', m === 'text');
document.getElementById('mode-draw-btn').classList.toggle('active', m === 'draw');
document.getElementById('mode-sel-btn').classList.toggle('active', m === 'select');
canvas.style.cursor = (m === 'draw' || m === 'select') ? 'crosshair' : 'text';
if (m !== 'select') { selActive = false; if (ctx) drawAll(); }
updateCursor();
}
function toggleEyedropper() {
eyedropperActive = !eyedropperActive;
canvas.classList.toggle('eyedropper', eyedropperActive);
document.getElementById('pick-btn').classList.toggle('active', eyedropperActive);
}
function pickColorAt(row, col) {
const cell = sparseMap.get(row * 100000 + col);
if (!cell) return;
setActiveFg(cell.fg);
if (cell.bg >= 0) setActiveBg(cell.bg);
}
// ── 256-color picker ──────────────────────────────────────────────────────────
let _colorPickerMode = 'fg';
(function buildColorPicker256() {
function makeCell(i, size) {
const c = document.createElement('div');
c.className = 'c256c';
c.style.cssText = `background:${PALETTE[i]};width:${size}px;height:${size}px`;
c.title = `#${i} ${PALETTE[i]}`;
c.dataset.idx = i;
c.addEventListener('mousedown', e => e.preventDefault());
c.addEventListener('click', () => {
if (_colorPickerMode === 'fg') setActiveFg(i);
else setActiveBg(i);
hideColorPicker();
});
return c;
}
const sys = document.getElementById('c256-sys-grid');
const cube = document.getElementById('c256-cube-grid');
const gray = document.getElementById('c256-gray-grid');
for (let i=0; i<16; i++) sys.appendChild(makeCell(i, 16));
for (let r=0; r<6; r++) {
const plane = document.createElement('div');
plane.className = 'c256-plane';
for (let g=0; g<6; g++)
for (let b=0; b<6; b++)
plane.appendChild(makeCell(16 + r*36 + g*6 + b, 9));
cube.appendChild(plane);
}
for (let i=232; i<256; i++) gray.appendChild(makeCell(i, 12));
})();
function updateColorPicker256Selection() {
document.querySelectorAll('.c256c').forEach(c =>
c.classList.toggle('sel',
_colorPickerMode==='fg' ? +c.dataset.idx===activeFg : +c.dataset.idx===activeBg));
const tb = document.getElementById('c256-transp-btn');
if (tb) tb.classList.toggle('sel', _colorPickerMode==='bg' && activeBg===-1);
}
function openColorPicker(mode, e) {
e.stopPropagation();
const picker = document.getElementById('color256-picker');
if (picker.classList.contains('visible') && _colorPickerMode===mode) {
hideColorPicker(); return;
}
_colorPickerMode = mode;
document.getElementById('c256-title').textContent = mode === 'fg' ? 'FG — TECKENFÄRG' : 'BG — BAKGRUNDSFÄRG';
document.getElementById('c256-transp').style.display = mode === 'bg' ? 'block' : 'none';
updateColorPicker256Selection();
const ebH = document.getElementById('editbar').offsetHeight;
picker.style.bottom = (ebH + 4) + 'px';
const btn = mode === 'fg'
? document.getElementById('cp-fg-box')
: document.getElementById('cp-bg-box');
const rect = btn.getBoundingClientRect();
const pickerW = 374 + 16;
const left = Math.min(rect.left, window.innerWidth - pickerW - 8);
picker.style.left = Math.max(0, left) + 'px';
picker.classList.add('visible');
}
function hideColorPicker() {
document.getElementById('color256-picker').classList.remove('visible');
}
document.addEventListener('click', e => {
const p = document.getElementById('color256-picker');
if (!p.contains(e.target) &&
!document.getElementById('cp-fg-box').contains(e.target) &&
!document.getElementById('cp-bg-box').contains(e.target))
hideColorPicker();
});
function updateColorPreview() {
document.getElementById('cp-fg-box').style.background = PALETTE[activeFg];
document.getElementById('cp-bg-box').style.background =
activeBg >= 0 ? PALETTE[activeBg] : TRANSP_BG;
}
function setActiveFg(i) {
activeFg = i;
updateColorPreview();
}
function setActiveBg(i) {
activeBg = i;
updateColorPreview();
}
// ── Char picker ───────────────────────────────────────────────────────────────
(function buildCharPicker() {
const grid = document.getElementById('cp-grid');
for (let i = 1; i < 256; i++) {
const cell = document.createElement('div');
cell.className = 'cpc';
cell.textContent = String.fromCodePoint(CP437[i]);
cell.dataset.idx = i;
cell.title = '#' + i + ' 0x' + i.toString(16).toUpperCase().padStart(2,'0');
cell.addEventListener('click', () => {
activeChar = String.fromCodePoint(CP437[i]);
document.getElementById('active-char-box').textContent = activeChar;
document.querySelectorAll('.cpc').forEach(c => c.classList.toggle('sel', c === cell));
hideCharPicker();
});
grid.appendChild(cell);
}
})();
function toggleCharPicker(e) {
e.stopPropagation();
const picker = document.getElementById('charpicker');
if (picker.classList.contains('visible')) { hideCharPicker(); return; }
const ebH = document.getElementById('editbar').offsetHeight;
picker.style.bottom = (ebH + 4) + 'px';
picker.style.left = '16px';
picker.classList.add('visible');
}
function hideCharPicker() {
document.getElementById('charpicker').classList.remove('visible');
}
// ── Canvas background picker ──────────────────────────────────────────────────
(function buildCanvasBgPicker() {
const grid = document.getElementById('cbp-grid');
PALETTE.slice(0, 16).forEach((color, i) => {
const s = document.createElement('div');
s.className = 'csw';
s.style.background = color;
s.title = color;
s.addEventListener('mousedown', e => { e.preventDefault(); setCanvasBg(color); });
grid.appendChild(s);
});
})();
function toggleCanvasBgPicker(e) {
e.stopPropagation();
const picker = document.getElementById('canvas-bg-picker');
if (picker.classList.contains('visible')) { hideCanvasBgPicker(); return; }
const ebH = document.getElementById('editbar').offsetHeight;
picker.style.bottom = (ebH + 4) + 'px';
const rect = document.getElementById('canvas-bg-swatch').getBoundingClientRect();
picker.style.left = rect.left + 'px';
picker.classList.add('visible');
}
function hideCanvasBgPicker() {
document.getElementById('canvas-bg-picker').classList.remove('visible');
}
function setCanvasBg(color) {
canvasBg = color;
document.getElementById('canvas-bg-swatch').style.background = color;
hideCanvasBgPicker();
if (ctx) drawAll();
scheduleSessionSave();
}
document.addEventListener('click', e => {
if (!document.getElementById('canvas-bg-picker').contains(e.target) &&
!document.getElementById('canvas-bg-swatch').contains(e.target))
hideCanvasBgPicker();
});
document.addEventListener('click', e => {
if (!document.getElementById('charpicker').contains(e.target) &&
!document.getElementById('active-char-wrap').contains(e.target))
hideCharPicker();
});
// ── Mouse on canvas ───────────────────────────────────────────────────────────
function cellFromEvent(e) {
const rect = canvas.getBoundingClientRect();
return {
row: Math.max(0, Math.floor((e.clientY - rect.top) / ch())),
col: Math.max(0, Math.floor((e.clientX - rect.left) / cw()))
};
}
function eraseCell(row, col) {
const key = row * 100000 + col;
if (!sparseMap.has(key)) return;
sparseMap.delete(key);
drawCell(row, col, null);
}
canvas.addEventListener('contextmenu', e => {
if (!editMode) return;
e.preventDefault();
});
canvas.addEventListener('mousedown', e => {
if (!editMode) return;
e.preventDefault();
canvas.focus();
const { row, col } = cellFromEvent(e);
curRow = row; curCol = col;
if (e.button === 2) {
if (drawMode === 'draw') {
pushUndo();
isErasing = true;
eraseCell(row, col);
} else {
pickColorAt(row, col);
}
return;
}
if (eyedropperActive) {
pickColorAt(row, col);
eyedropperActive = false;
canvas.classList.remove('eyedropper');
document.getElementById('pick-btn').classList.remove('active');
return;
}
if (drawMode === 'select') {
if (selActive) {
const r1=Math.min(selR1,selR2),r2=Math.max(selR1,selR2);
const c1=Math.min(selC1,selC2),c2=Math.max(selC1,selC2);
if (row>=r1&&row<=r2&&col>=c1&&col<=c2) {
pushUndo();
isMoving=true;
moveOrigR1=r1; moveOrigR2=r2; moveOrigC1=c1; moveOrigC2=c2;
moveOffsetR=row-r1; moveOffsetC=col-c1;
moveSelCells=new Map();
for (let r=r1;r<=r2;r++) for (let c=c1;c<=c2;c++) {
const cell=sparseMap.get(r*100000+c);
if (cell) moveSelCells.set((r-r1)*100000+(c-c1),{...cell});
}
return;
}
}
selActive=true; selDragging=true;
selR1=selR2=row; selC1=selC2=col;
drawAll();
} else if (drawMode === 'draw') {
pushUndo();
isPainting = true;
paintCell(row, col);
} else {
updateCursor();
}
});
canvas.addEventListener('mousemove', e => {
if (!editMode) return;
const { row, col } = cellFromEvent(e);
if (isMoving) {
const h=moveOrigR2-moveOrigR1, w=moveOrigC2-moveOrigC1;
selR1=row-moveOffsetR; selR2=selR1+h;
selC1=col-moveOffsetC; selC2=selC1+w;
drawAll(); return;
}
if (drawMode === 'select' && selDragging) {
selR2 = row; selC2 = col; drawAll(); return;
}
if (isErasing && drawMode === 'draw') { eraseCell(row, col); return; }
if (!isPainting || drawMode !== 'draw') return;
paintCell(row, col);
});
function commitMove() {
if (!isMoving) return;
isMoving = false;
const dstR=Math.min(selR1,selR2), dstC=Math.min(selC1,selC2);
for (let r=moveOrigR1;r<=moveOrigR2;r++)
for (let c=moveOrigC1;c<=moveOrigC2;c++)
sparseMap.delete(r*100000+c);
for (const [key,cell] of moveSelCells) {
const dr=Math.floor(key/100000), dc=key%100000;
sparseMap.set((dstR+dr)*100000+(dstC+dc),{...cell});
}
moveSelCells=null;
drawAll();
}
canvas.addEventListener('mouseup', () => { commitMove(); isPainting = false; selDragging = false; isErasing = false; });
canvas.addEventListener('mouseleave', () => { commitMove(); isPainting = false; selDragging = false; isErasing = false; });
function paintCell(row, col) {
const key = row * 100000 + col;
const cell = { ch: activeChar, fg: activeFg, bg: activeBg };
const existing = sparseMap.get(key);
if (existing && existing.ch===cell.ch && existing.fg===cell.fg && existing.bg===cell.bg) return;
sparseMap.set(key, cell);
drawCell(row, col, cell);
}
// ── Keyboard ──────────────────────────────────────────────────────────────────
canvas.addEventListener('keydown', e => {
if (!editMode) return;
const ctrl = e.ctrlKey || e.metaKey;
// ── Shortcuts available in both modes ────────────────────────────────────
if (ctrl && (e.key==='z'||e.key==='Z')) { e.preventDefault(); undo(); return; }
if (ctrl && (e.key==='y'||e.key==='Y')) { e.preventDefault(); redo(); return; }
if (ctrl && (e.key==='c'||e.key==='C')) { e.preventDefault(); copySelection(); return; }
if (ctrl && (e.key==='x'||e.key==='X')) { e.preventDefault(); cutSelection(); return; }
if (ctrl && (e.key==='v'||e.key==='V')) { e.preventDefault(); pasteClipboard(); return; }
if (e.key==='Escape') {
if (selActive) { e.preventDefault(); selActive=false; drawAll(); return; }
e.preventDefault(); toggleEdit(); return;
}
if (e.key==='Tab') { e.preventDefault(); setMode(drawMode==='text'?'draw':'text'); return; }
if (ctrl && e.key==='Delete') { e.preventDefault(); deleteRow(); return; }
if (ctrl && e.key==='Insert') { e.preventDefault(); insertRow(); return; }
if (ctrl && (e.key==='='||e.key==='+')) { e.preventDefault(); adjFont(1); return; }
if (ctrl && e.key==='-') { e.preventDefault(); adjFont(-1); return; }
if (e.key==='F2') { e.preventDefault(); toggleCharPicker({stopPropagation:()=>{}}); return; }
if (e.metaKey && e.key==='Backspace') { e.preventDefault(); if(drawMode==='text'){pushUndo();sparseMap.delete(curRow*100000+curCol);drawCell(curRow,curCol,null);updateCursor();} return; }
if (ctrl) return;
// ── Text mode navigation & editing ───────────────────────────────────────
if (drawMode !== 'text') return;
e.preventDefault();
switch (e.key) {
case 'ArrowRight': curCol++; break;
case 'ArrowLeft': curCol = Math.max(0, curCol-1); break;
case 'ArrowDown': curRow++; break;
case 'ArrowUp': curRow = Math.max(0, curRow-1); break;
case 'Home': curCol = 0; break;
case 'End': {
let mx = 0;
for (const k of sparseMap.keys())
if (Math.floor(k/100000)===curRow) mx = Math.max(mx, k%100000);
curCol = mx + 1;
break;
}
case 'Enter': curRow++; curCol = 0; break;
case 'Backspace':
if (curCol > 0) {
pushUndo();
curCol--;
sparseMap.delete(curRow*100000+curCol);
drawCell(curRow, curCol, null);
}
break;
case 'Delete':
pushUndo();
sparseMap.delete(curRow*100000+curCol);
drawCell(curRow, curCol, null);
break;
default:
if (e.key.length === 1) {
pushUndo();
const cell = { ch: e.key, fg: activeFg, bg: activeBg };
sparseMap.set(curRow*100000+curCol, cell);
drawCell(curRow, curCol, cell);
curCol++;
}
}
updateCursor();
});
// ── Global shortcuts (work outside canvas too) ────────────────────────────
document.addEventListener('keydown', e => {
if (e.key === 'Escape') {
if (document.getElementById('help-overlay').classList.contains('visible')) { hideHelp(); return; }
if (document.getElementById('img-dlg').classList.contains('visible')) { hideImgDlg(); return; }
if (document.getElementById('new-dlg').classList.contains('visible')) { hideNewDlg(); return; }
if (document.getElementById('new-ansi-dlg').classList.contains('visible')) { hideNewAnsiDlg(); return; }
hideNewMenu();
}
// ⌘+Backspace = Delete, works even when canvas lost focus
if (e.metaKey && e.key === 'Backspace' && editMode && drawMode === 'text') {
e.preventDefault();
pushUndo();
sparseMap.delete(curRow * 100000 + curCol);
drawCell(curRow, curCol, null);
updateCursor();
return;
}
if (document.activeElement === canvas) return; // handled above
const ctrl = e.ctrlKey || e.metaKey;
if (!ctrl) return;
if (e.key==='z'||e.key==='Z') { e.preventDefault(); undo(); }
if (e.key==='y'||e.key==='Y') { e.preventDefault(); redo(); }
if (e.key==='c'||e.key==='C') { e.preventDefault(); copySelection(); }
if (e.key==='x'||e.key==='X') { e.preventDefault(); cutSelection(); }
if (e.key==='v'||e.key==='V') { e.preventDefault(); pasteClipboard(); }
if (e.key==='o'||e.key==='O') { e.preventDefault(); openWithPicker(); }
if (e.key==='n'||e.key==='N') { e.preventDefault(); toggleNewMenu(); }
if (e.key==='s'&&!e.shiftKey) { e.preventDefault(); saveANS(); }
if (e.key==='s'&& e.shiftKey) { e.preventDefault(); exportHTML(); }
});
// ── Undo / redo ───────────────────────────────────────────────────────────────
function cloneMap(m) {
const c = new Map();
for (const [k,v] of m) c.set(k, {...v});
return c;
}
function pushUndo() {
undoStack.push(cloneMap(sparseMap));
if (undoStack.length > 50) undoStack.shift();
redoStack = [];
scheduleSessionSave();
}
function undo() {
if (!undoStack.length) return;
redoStack.push(cloneMap(sparseMap));
sparseMap = undoStack.pop();
drawAll();
}
function redo() {
if (!redoStack.length) return;
undoStack.push(cloneMap(sparseMap));
sparseMap = redoStack.pop();
drawAll();
}
// ── Selection & clipboard ─────────────────────────────────────────────────────
function drawSelection() {
if (!selActive || !ctx) return;
const r1=Math.min(selR1,selR2), r2=Math.max(selR1,selR2);
const c1=Math.min(selC1,selC2), c2=Math.max(selC1,selC2);
ctx.fillStyle = 'rgba(0,229,204,0.18)';
ctx.fillRect(c1*cw(), r1*ch(), (c2-c1+1)*cw(), (r2-r1+1)*ch());
ctx.strokeStyle = 'rgba(0,229,204,0.85)';
ctx.lineWidth = 1;
ctx.strokeRect(c1*cw()+0.5, r1*ch()+0.5, (c2-c1+1)*cw()-1, (r2-r1+1)*ch()-1);
}
function copySelection() {
if (!selActive) return;
const r1=Math.min(selR1,selR2), r2=Math.max(selR1,selR2);
const c1=Math.min(selC1,selC2), c2=Math.max(selC1,selC2);
const cells = new Map();
const lines = [];
for (let r=r1; r<=r2; r++) {
let line = '';
for (let c=c1; c<=c2; c++) {
const cell = sparseMap.get(r*100000+c);
if (cell) { cells.set((r-r1)*100000+(c-c1), {...cell}); line += cell.ch; }
else line += ' ';
}
lines.push(line);
}
clipboard = { cells, rows:r2-r1+1, cols:c2-c1+1 };
clipboardText = lines.join('\n');
navigator.clipboard.writeText(clipboardText).catch(()=>{});
}
function cutSelection() {
if (!selActive) return;
copySelection();
pushUndo();
const r1=Math.min(selR1,selR2), r2=Math.max(selR1,selR2);
const c1=Math.min(selC1,selC2), c2=Math.max(selC1,selC2);
for (let r=r1; r<=r2; r++)
for (let c=c1; c<=c2; c++)
sparseMap.delete(r*100000+c);
selActive = false;
drawAll();
}
async function pasteClipboard() {
if (!editMode) return;
let sysText = null;
try { sysText = await navigator.clipboard.readText(); } catch(e) {}
if (sysText !== null && sysText === clipboardText && clipboard) {
// intern kopia matchar systemet → klistra med färger
pushUndo();
for (const [key, cell] of clipboard.cells) {
const dr = Math.floor(key/100000), dc = key%100000;
sparseMap.set((curRow+dr)*100000+(curCol+dc), {...cell});
}
drawAll();
} else if (sysText !== null && sysText !== '') {
// extern text (t.ex. ASCII-art) → klistra rad för rad
pushUndo();
const lines = sysText.split('\n');
const startCol = curCol;
for (let li = 0; li < lines.length; li++) {
const line = lines[li].replace(/\r$/, '');
for (let ci = 0; ci < line.length; ci++) {
const ch = line[ci];
sparseMap.set((curRow+li)*100000+(startCol+ci), { ch, fg: activeFg, bg: activeBg });
}
}
drawAll();
} else if (clipboard) {
// systemklippbord tomt, använd intern kopia
pushUndo();
for (const [key, cell] of clipboard.cells) {
const dr = Math.floor(key/100000), dc = key%100000;
sparseMap.set((curRow+dr)*100000+(curCol+dc), {...cell});
}
drawAll();
}
}
// ── Tabs ──────────────────────────────────────────────────────────────────────
function saveCurrentTab() {
if (activeTab < 0 || activeTab >= tabs.length) return;
const t = tabs[activeTab];
t.name = lastFile ? lastFile.name : t.name;
t.sparseMap=sparseMap; t.lastFile=lastFile; t.lastBytes=lastBytes; t.lastFileHandle=lastFileHandle;
t.canvasCols=canvasCols; t.canvasRows=canvasRows; t.canvasBg=canvasBg;
t.undoStack=undoStack; t.redoStack=redoStack; t.isCentered=isCentered;
}
function renderTabs() {
const bar = document.getElementById('tabbar');
bar.innerHTML = '';
tabs.forEach((t, i) => {
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'tab' + (i===activeTab ? ' active' : '');
btn.dataset.tabIdx = i;
const nm = document.createElement('span');
nm.textContent = t.name;
btn.appendChild(nm);
if (tabs.length > 1) {
const x = document.createElement('span');
x.className = 'tab-x';
x.textContent = '×';
x.dataset.closeIdx = i;
btn.appendChild(x);
}
bar.appendChild(btn);
});
const add = document.createElement('button');
add.type='button'; add.id='tab-add'; add.textContent='+'; add.title='Ny flik';
add.dataset.tabAdd = '1';
bar.appendChild(add);
bar.style.top = document.getElementById('topbar').offsetHeight + 'px';
scheduleSessionSave();
}
function switchTab(idx) {
if (idx === activeTab) return;
saveCurrentTab();
if (editMode) toggleEdit();
activeTab = idx;
const t = tabs[idx];
sparseMap=t.sparseMap; lastFile=t.lastFile; lastBytes=t.lastBytes; lastFileHandle=t.lastFileHandle||null;
canvasCols=t.canvasCols; canvasRows=t.canvasRows; canvasBg=t.canvasBg;
undoStack=t.undoStack; redoStack=t.redoStack; isCentered=t.isCentered;
curRow=0; curCol=0; selActive=false;
document.getElementById('canvas-bg-swatch').style.background = canvasBg;
document.getElementById('output-wrap').classList.toggle('centered', isCentered);
document.getElementById('center-btn').classList.toggle('active', isCentered);
if (sparseMap.size > 0) {
showOutput();
drawAll();
document.getElementById('filename').textContent = t.name;
document.getElementById('s-name').textContent = t.name;
document.getElementById('width-wrap').classList.add('visible');
} else {
document.getElementById('output-wrap').classList.remove('visible');
document.getElementById('dropzone').style.display = '';
document.getElementById('export-wrap').classList.remove('visible');
document.getElementById('edit-btn').classList.add('hidden');
document.getElementById('filename').textContent = 'drop .nfo / .ans anywhere';
document.getElementById('width-wrap').classList.remove('visible');
}
renderTabs();
}
function addTab() {
saveCurrentTab();
if (editMode) toggleEdit();
tabs.push(newTabState());
activeTab = tabs.length - 1;
const t = tabs[activeTab];
sparseMap=t.sparseMap; lastFile=null; lastBytes=null; lastFileHandle=null;
canvasCols=80; canvasRows=25; canvasBg='#000000'; isCentered=false;
undoStack=[]; redoStack=[]; curRow=0; curCol=0; selActive=false;
document.getElementById('canvas-bg-swatch').style.background = canvasBg;
document.getElementById('output-wrap').classList.remove('centered','visible');
document.getElementById('center-btn').classList.remove('active');
document.getElementById('dropzone').style.display = '';
document.getElementById('export-wrap').classList.remove('visible');
document.getElementById('edit-btn').classList.add('hidden');
document.getElementById('filename').textContent = 'drop .nfo / .ans anywhere';
document.getElementById('width-wrap').classList.remove('visible');
renderTabs();
}
function closeTab(idx) {
if (tabs.length === 1) return;
const wasActive = idx === activeTab;
tabs.splice(idx, 1);
if (wasActive) {
activeTab = -1;
switchTab(Math.min(idx, tabs.length-1));
} else {
if (idx < activeTab) activeTab--;
renderTabs();
}
}
// ── New canvas ────────────────────────────────────────────────────────────────
function showHelp() {
document.getElementById('help-overlay').classList.add('visible');
}
function hideHelp() {
document.getElementById('help-overlay').classList.remove('visible');
}
function setOsMode(os) {
document.getElementById('os-win').classList.toggle('active', os === 'win');
document.getElementById('os-mac').classList.toggle('active', os === 'mac');
document.querySelectorAll('#help-box .help-key kbd').forEach(kbd => {
if (kbd.textContent === 'Ctrl' || kbd.textContent === '⌘')
kbd.textContent = os === 'mac' ? '⌘' : 'Ctrl';
});
}
function showNewDlg() {
document.getElementById('overlay').classList.add('visible');
document.getElementById('new-dlg').classList.add('visible');
}
function hideActiveDialog() {
if (document.getElementById('img-dlg').classList.contains('visible')) { hideImgDlg(); return; }
if (document.getElementById('new-ansi-dlg').classList.contains('visible')) { hideNewAnsiDlg(); return; }
hideNewDlg();
}
function hideNewDlg() {
document.getElementById('overlay').classList.remove('visible');
document.getElementById('new-dlg').classList.remove('visible');
}
function createNewCanvas() {
const cols = parseInt(document.getElementById('dlg-cols').value,10) || 80;
const rows = parseInt(document.getElementById('dlg-rows').value,10) || 25;
hideNewDlg();
sparseMap = new Map();
canvasCols = cols; canvasRows = rows;
lastFile = null; lastBytes = null;
undoStack = []; redoStack = [];
drawAll();
showOutput();
document.getElementById('filename').textContent = 'untitled.ans';
document.getElementById('s-name').textContent = 'untitled.ans';
document.getElementById('s-size').textContent = '—';
document.getElementById('s-type').textContent = 'new';
document.getElementById('s-dims').textContent = cols + '×' + rows;
document.getElementById('width-wrap').classList.add('visible');
tabs[activeTab].name = 'untitled.ans';
renderTabs();
if (!editMode) toggleEdit();
curRow = 0; curCol = 0;
updateCursor();
}
// ── New menu ──────────────────────────────────────────────────────────────────
function toggleNewMenu() {
document.getElementById('new-menu').classList.toggle('visible');
}
function hideNewMenu() {
document.getElementById('new-menu').classList.remove('visible');
}
document.addEventListener('click', e => {
if (!document.getElementById('new-wrap').contains(e.target)) hideNewMenu();
});
function textToCP437Bytes(text) {
// Unescape common escape-sequence notations (\x1b, \033) from source/doc pastes
text = text.replace(/\\x1b/gi, '\x1b').replace(/\\033/g, '\x1b');
const out = [];
for (const ch of text) {
const cp = ch.codePointAt(0);
if (cp < 128) {
out.push(cp);
} else {
const b = CP437_REV.get(cp);
out.push(b !== undefined ? b : 0x3F);
}
}
return new Uint8Array(out);
}
function _loadFromBytes(bytes, cols, name) {
sparseMap = new Map();
canvasCols = cols;
parse(bytes, cols);
const { maxRow: mr } = getBounds(sparseMap);
canvasRows = Math.max(mr + 1, 5);
lastFile = null; lastBytes = bytes;
undoStack = []; redoStack = [];
drawAll();
showOutput();
document.getElementById('filename').textContent = name;
document.getElementById('s-name').textContent = name;
document.getElementById('s-size').textContent = fmt(bytes.length);
const hasColor = [...sparseMap.values()].some(c => c.fg !== 7 || c.bg >= 0);
document.getElementById('s-type').textContent = hasColor ? 'ANSI color' : 'plain';
const { maxRow, maxCol } = getBounds(sparseMap);
document.getElementById('s-dims').textContent = `${maxCol+1}×${maxRow+1}`;
document.getElementById('width-wrap').classList.add('visible');
tabs[activeTab].name = name;
renderTabs();
if (!editMode) toggleEdit();
curRow = 0; curCol = 0;
updateCursor();
}
async function newFromClipboard() {
// Read clipboard BEFORE hiding the menu so the user-gesture context is intact
let text = '';
try { text = await navigator.clipboard.readText(); } catch(e) {}
hideNewMenu();
if (!text.trim()) {
showNewAnsiDlg();
return;
}
const stripped = text.replace(/\x1b\[[0-9;]*[A-Za-z]/g, '');
let maxLen = 0;
stripped.split('\n').forEach(l => maxLen = Math.max(maxLen, l.replace(/\r$/, '').length));
const cols = Math.min(Math.max(maxLen || 80, 40), 320);
_loadFromBytes(textToCP437Bytes(text), cols, 'clipboard.ans');
}
function showNewAnsiDlg() {
document.getElementById('overlay').classList.add('visible');
document.getElementById('new-ansi-dlg').classList.add('visible');
document.getElementById('na-text').focus();
}
function hideNewAnsiDlg() {
document.getElementById('overlay').classList.remove('visible');
document.getElementById('new-ansi-dlg').classList.remove('visible');
}
function createFromAnsiCode() {
const cols = parseInt(document.getElementById('na-cols').value, 10) || 80;
const text = document.getElementById('na-text').value;
hideNewAnsiDlg();
if (!text.trim()) return;
_loadFromBytes(textToCP437Bytes(text), cols, 'ansi.ans');
}
// ── Center / font size ────────────────────────────────────────────────────────
function deleteRow() {
pushUndo();
const newMap = new Map();
for (const [k, v] of sparseMap) {
const r = Math.floor(k / 100000), c = k % 100000;
if (r === curRow) continue;
newMap.set((r < curRow ? r : r - 1) * 100000 + c, v);
}
sparseMap = newMap;
curRow = Math.max(0, curRow - (curRow > 0 ? 0 : 0));
drawAll();
}
function insertRow() {
pushUndo();
const newMap = new Map();
for (const [k, v] of sparseMap) {
const r = Math.floor(k / 100000), c = k % 100000;
newMap.set((r < curRow ? r : r + 1) * 100000 + c, v);
}
sparseMap = newMap;
drawAll();
updateCursor();
}
function toggleCenter() {
isCentered = !isCentered;
document.getElementById('output-wrap').classList.toggle('centered', isCentered);
document.getElementById('center-btn').classList.toggle('active', isCentered);
scheduleSessionSave();
}
function adjFont(delta) {
const steps = [0.5,0.75,1,1.25,1.5,2,2.5,3];
let idx = steps.findIndex(s => Math.abs(s - fontScale) < 0.01);
if (idx < 0) idx = 2;
idx = Math.max(0, Math.min(steps.length-1, idx + delta));
fontScale = steps[idx];
if (sparseMap.size > 0 || canvasRows > 0) drawAll();
}
// ── Export HTML ───────────────────────────────────────────────────────────────
function buildHtml(map) {
if (map.size === 0) return '';
const { maxRow, maxCol } = getBounds(map);
const lines = [];
for (let r = 0; r <= maxRow; r++) {
let rowMax = -1;
for (const k of map.keys())
if (Math.floor(k/100000)===r) rowMax = Math.max(rowMax, k%100000);
if (rowMax < 0) { lines.push(''); continue; }
let html = '', curFg = null, curBg = null, spanOpen = false;
for (let c = 0; c <= rowMax; c++) {
let ch2 = ' ', fgI = 7, bgI = -1;
const cell = map.get(r*100000+c);
if (cell) { ch2=cell.ch; fgI=cell.fg; bgI=cell.bg; }
const needStyle = fgI!==7||bgI>=0;
if (fgI!==curFg||bgI!==curBg) {
if (spanOpen) { html+='</span>'; spanOpen=false; }
if (needStyle) {
let st=`color:${PALETTE[fgI]};`;
if (bgI>=0) st+=`background:${PALETTE[bgI]};`;
html+=`<span style="${st}">`; spanOpen=true;
}
curFg=fgI; curBg=bgI;
}
if (ch2==='&') html+='&';
else if (ch2==='<') html+='<';
else if (ch2==='>') html+='>';
else html+=ch2;
}
if (spanOpen) html+='</span>';
lines.push(html);
}
return lines.join('\n');
}
function exportHTML() {
const content = buildHtml(sparseMap);
const fname = lastFile ? lastFile.name.replace(/\.[^.]+$/,'') : 'untitled';
const fs = Math.round(fontScale*16)+'px';
const centered = isCentered ? 'display:flex;justify-content:center' : '';
const blob = new Blob([`<!DOCTYPE html><html><head><meta charset="UTF-8"><title>${fname}</title>
<style>*{margin:0;padding:0}body{background:#000;padding:20px}
#w{${centered}}#s{display:inline-block;font-family:"Courier New",monospace;font-size:${fs};line-height:1;white-space:pre;color:#aaaaaa}
</style></head><body><div id="w"><div id="s">${content}</div></div></body></html>`],
{type:'text/html'});
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = fname + '.html';
a.click();
}
// ── Export menu ───────────────────────────────────────────────────────────────
function toggleExportMenu() {
document.getElementById('export-menu').classList.toggle('visible');
}
function hideExportMenu() {
document.getElementById('export-menu').classList.remove('visible');
}
document.addEventListener('click', e => {
if (!document.getElementById('export-wrap').contains(e.target))
hideExportMenu();
});
// ── Export NFO (plain text, no ANSI) ─────────────────────────────────────────
function exportNFO() {
if (sparseMap.size === 0) return;
const { maxRow } = getBounds(sparseMap);
const rowMaxCol = new Array(maxRow + 1).fill(-1);
for (const k of sparseMap.keys()) {
const r = Math.floor(k / 100000), c = k % 100000;
if (c > rowMaxCol[r]) rowMaxCol[r] = c;
}
const bytes = [];
for (let r = 0; r <= maxRow; r++) {
const lastC = rowMaxCol[r];
if (lastC < 0) { bytes.push(0x0d, 0x0a); continue; }
for (let c = 0; c <= lastC; c++) {
const cell = sparseMap.get(r * 100000 + c);
bytes.push(CP437_REV.get((cell ? cell.ch : ' ').codePointAt(0)) ?? 0x20);
}
if (r < maxRow) bytes.push(0x0d, 0x0a);
}
const fname = lastFile ? lastFile.name.replace(/\.[^.]+$/, '') : 'untitled';
const a = document.createElement('a');
a.href = URL.createObjectURL(new Blob([new Uint8Array(bytes)], {type: 'application/octet-stream'}));
a.download = fname + '.nfo';
a.click();
}
// ── Export ANS ────────────────────────────────────────────────────────────────
function buildANSBytes() {
if (sparseMap.size === 0) return null;
const { maxRow } = getBounds(sparseMap);
const rowMaxCol = new Array(maxRow+1).fill(-1);
for (const k of sparseMap.keys()) {
const r=Math.floor(k/100000), c=k%100000;
if (c>rowMaxCol[r]) rowMaxCol[r]=c;
}
const bytes = [];
function pushStr(s) { for (let i=0;i<s.length;i++) bytes.push(s.charCodeAt(i)); }
function emitColor(fg,bg) {
pushStr('\x1b[0m');
if (fg <= 7) { if (fg!==7) pushStr(`\x1b[${30+fg}m`); }
else if (fg <= 15) { pushStr(`\x1b[1;${30+fg-8}m`); }
else { pushStr(`\x1b[38;5;${fg}m`); }
if (bg >= 0) {
if (bg <= 7) pushStr(`\x1b[${40+bg}m`);
else if (bg <= 15) pushStr(`\x1b[${100+(bg-8)}m`);
else pushStr(`\x1b[48;5;${bg}m`);
}
}
let curFg=7, curBg=-1;
emitColor(7,-1);
for (let r=0;r<=maxRow;r++) {
const lastC=rowMaxCol[r];
if (lastC<0) { pushStr('\r\n'); continue; }
for (let c=0;c<=lastC;c++) {
const cell=sparseMap.get(r*100000+c);
const fg=cell?cell.fg:7, bg=cell?cell.bg:-1, ch2=cell?cell.ch:' ';
if (fg!==curFg||bg!==curBg) { emitColor(fg,bg); curFg=fg; curBg=bg; }
bytes.push(CP437_REV.get(ch2.codePointAt(0)) ?? 0x20);
}
if (r<maxRow) pushStr('\r\n');
}
return new Uint8Array(bytes);
}
function exportANS() {
const bytes = buildANSBytes();
if (!bytes) return;
const fname = lastFile ? lastFile.name.replace(/\.[^.]+$/,'') : 'untitled';
const a = document.createElement('a');
a.href = URL.createObjectURL(new Blob([bytes],{type:'application/octet-stream'}));
a.download = fname + '.ans';
a.click();
}
async function openWithPicker() {
if (!window.showOpenFilePicker) {
document.getElementById('file-input').click();
return;
}
try {
const [handle] = await window.showOpenFilePicker({
types: [{ description: 'ANS / NFO / TXT', accept: { 'application/octet-stream': ['.nfo','.ans','.diz','.asc','.txt','.ansi'] } }],
multiple: false
});
lastFileHandle = handle;
processFile(await handle.getFile());
} catch(e) { /* avbröts */ }
}
function flashSaved() {
const el = document.getElementById('filename');
const orig = el.textContent;
el.textContent = '✓ SAVED';
el.style.color = 'var(--cyan)';
setTimeout(() => { el.textContent = orig; el.style.color = ''; }, 1500);
}
async function saveANS() {
const bytes = buildANSBytes();
if (!bytes) return;
if (lastFileHandle) {
try {
const w = await lastFileHandle.createWritable();
await w.write(bytes);
await w.close();
flashSaved();
return;
} catch(e) { /* tillstånd nekat, fortsätt till dialog */ }
}
if (window.showSaveFilePicker) {
try {
const fname = lastFile ? lastFile.name : 'untitled.ans';
const handle = await window.showSaveFilePicker({
suggestedName: fname,
types: [{ description: 'ANS file', accept: { 'application/octet-stream': ['.ans'] } }]
});
lastFileHandle = handle;
const w = await handle.createWritable();
await w.write(bytes);
await w.close();
flashSaved();
return;
} catch(e) { /* avbröts */ }
}
exportANS();
}
// ── Image → ANSI ─────────────────────────────────────────────────────────────
let imgSrc = null;
document.getElementById('img-input').addEventListener('change', function () {
if (!this.files[0]) return;
const reader = new FileReader();
reader.onload = ev => {
const img = new Image();
img.onload = () => {
imgSrc = img;
imgAutoRows();
// Show tiny preview
const prev = document.getElementById('img-preview-wrap');
prev.innerHTML = '';
const thumb = document.createElement('canvas');
const scale = Math.min(240 / img.width, 80 / img.height, 1);
thumb.width = Math.round(img.width * scale);
thumb.height = Math.round(img.height * scale);
thumb.id = 'img-preview';
thumb.getContext('2d').drawImage(img, 0, 0, thumb.width, thumb.height);
prev.appendChild(thumb);
showImgDlg();
};
img.src = ev.target.result;
};
reader.readAsDataURL(this.files[0]);
this.value = '';
});
const ASCII_RAMP = " .:-=+*#%@";
function imgAsciiToggle() {
const ascii = document.getElementById('img-ascii').checked;
const halfRow = document.getElementById('img-half-row');
halfRow.style.opacity = ascii ? '0.35' : '';
document.getElementById('img-half').disabled = ascii;
}
function imgAutoRows() {
if (!imgSrc) return;
if (!document.getElementById('img-aspect').checked) return;
const cols = parseInt(document.getElementById('img-cols').value, 10) || 80;
const half = document.getElementById('img-half').checked;
const aspect = imgSrc.width / imgSrc.height;
// char cell is CHAR_W × CHAR_H; half-block = CHAR_W × (CHAR_H/2) effective pixel
const charAspect = half ? CHAR_W / (CHAR_H / 2) : CHAR_W / CHAR_H;
const rows = Math.max(1, Math.round(cols / aspect / charAspect));
document.getElementById('img-rows').value = rows;
}
function showImgDlg() {
document.getElementById('overlay').classList.add('visible');
document.getElementById('img-dlg').classList.add('visible');
}
function hideImgDlg() {
document.getElementById('overlay').classList.remove('visible');
document.getElementById('img-dlg').classList.remove('visible');
}
function doImgConvert() {
if (!imgSrc) return;
const cols = parseInt(document.getElementById('img-cols').value, 10) || 80;
const rows = parseInt(document.getElementById('img-rows').value, 10) || 40;
const ascii = document.getElementById('img-ascii').checked;
const half = !ascii && document.getElementById('img-half').checked;
const use256 = document.getElementById('img-256').checked;
const dither = document.getElementById('img-dither').checked;
hideImgDlg();
const srcH = half ? rows * 2 : rows;
const off = document.createElement('canvas');
off.width = cols;
off.height = srcH;
const oc = off.getContext('2d');
oc.drawImage(imgSrc, 0, 0, cols, srcH);
const raw = oc.getImageData(0, 0, cols, srcH).data;
// Mutable float copy for dithering
const pix = new Float32Array(raw);
const palRGB = PALETTE.map(h => [
parseInt(h.slice(1,3),16), parseInt(h.slice(3,5),16), parseInt(h.slice(5,7),16)
]);
const colorLimit = use256 ? 256 : 16;
function nearest(r,g,b) {
let best=0, bestD=Infinity;
for (let i=0;i<colorLimit;i++) {
const [pr,pg,pb]=palRGB[i];
const d=(r-pr)**2+(g-pg)**2+(b-pb)**2;
if (d<bestD) { bestD=d; best=i; }
}
return best;
}
function getP(x,y) {
const i=(y*cols+x)*4;
return [Math.max(0,Math.min(255,pix[i])),Math.max(0,Math.min(255,pix[i+1])),Math.max(0,Math.min(255,pix[i+2]))];
}
function addErr(x,y,er,eg,eb,f) {
if (x<0||x>=cols||y<0||y>=srcH) return;
const i=(y*cols+x)*4; pix[i]+=er*f; pix[i+1]+=eg*f; pix[i+2]+=eb*f;
}
pushUndo();
sparseMap = new Map();
for (let row=0; row<rows; row++) {
for (let col=0; col<cols; col++) {
if (ascii) {
const [r,g,b] = getP(col, row);
const fg = nearest(r,g,b);
if (dither) {
const [pr,pg,pb]=palRGB[fg]; const er=r-pr,eg=g-pg,eb=b-pb;
addErr(col+1,row, er,eg,eb,7/16); addErr(col-1,row+1,er,eg,eb,3/16);
addErr(col, row+1,er,eg,eb,5/16); addErr(col+1,row+1,er,eg,eb,1/16);
}
const lum = (0.299*r + 0.587*g + 0.114*b) / 255;
const ch = ASCII_RAMP[Math.round(lum * (ASCII_RAMP.length - 1))];
if (ch !== ' ') sparseMap.set(row*100000+col, { ch, fg, bg:-1 });
} else if (half) {
const [r1,g1,b1] = getP(col, row*2);
const [r2,g2,b2] = getP(col, row*2+1);
const fg = nearest(r1,g1,b1);
const bg = nearest(r2,g2,b2);
if (dither) {
const [p1r,p1g,p1b]=palRGB[fg]; const e1r=r1-p1r,e1g=g1-p1g,e1b=b1-p1b;
addErr(col+1,row*2, e1r,e1g,e1b,7/16); addErr(col-1,row*2+1,e1r,e1g,e1b,3/16);
addErr(col, row*2+1,e1r,e1g,e1b,5/16); addErr(col+1,row*2+1,e1r,e1g,e1b,1/16);
const [p2r,p2g,p2b]=palRGB[bg]; const e2r=r2-p2r,e2g=g2-p2g,e2b=b2-p2b;
addErr(col+1,row*2+2,e2r,e2g,e2b,7/16); addErr(col-1,row*2+3,e2r,e2g,e2b,3/16);
addErr(col, row*2+3,e2r,e2g,e2b,5/16); addErr(col+1,row*2+3,e2r,e2g,e2b,1/16);
}
sparseMap.set(row*100000+col, { ch:'\u2580', fg, bg }); // ▀
} else {
const [r,g,b] = getP(col, row);
const fg = nearest(r,g,b);
if (dither) {
const [pr,pg,pb]=palRGB[fg]; const er=r-pr,eg=g-pg,eb=b-pb;
addErr(col+1,row, er,eg,eb,7/16); addErr(col-1,row+1,er,eg,eb,3/16);
addErr(col, row+1,er,eg,eb,5/16); addErr(col+1,row+1,er,eg,eb,1/16);
}
sparseMap.set(row*100000+col, { ch:'\u2588', fg, bg:-1 }); // █
}
}
}
canvasCols = cols; canvasRows = rows;
lastFile = null; lastBytes = null;
drawAll();
showOutput();
document.getElementById('filename').textContent = 'image-convert.ans';
document.getElementById('s-name').textContent = 'image-convert.ans';
document.getElementById('s-size').textContent = '—';
document.getElementById('s-type').textContent = 'ANSI color';
document.getElementById('s-dims').textContent = `${cols}×${rows}`;
tabs[activeTab].name = 'image-convert.ans';
renderTabs();
if (!editMode) toggleEdit();
}
// ── Session persistence ───────────────────────────────────────────────────────
function u8ToB64(arr) {
let s = '';
for (let i = 0; i < arr.length; i++) s += String.fromCharCode(arr[i]);
return btoa(s);
}
function b64ToU8(s) {
const b = atob(s), a = new Uint8Array(b.length);
for (let i = 0; i < b.length; i++) a[i] = b.charCodeAt(i);
return a;
}
function scheduleSessionSave() {
clearTimeout(_saveTimer);
_saveTimer = setTimeout(saveSession, 1000);
}
function saveSession() {
saveCurrentTab();
try {
const data = {
activeTab,
tabs: tabs.map(t => ({
name: t.name,
sparseMap: [...t.sparseMap.entries()],
lastBytes: t.lastBytes ? u8ToB64(t.lastBytes) : null,
canvasCols: t.canvasCols,
canvasRows: t.canvasRows,
canvasBg: t.canvasBg,
isCentered: t.isCentered,
}))
};
localStorage.setItem('nfoed_session', JSON.stringify(data));
} catch(e) {
console.warn('Session save failed:', e);
}
}
function loadSession() {
try {
const raw = localStorage.getItem('nfoed_session');
if (!raw) return false;
const data = JSON.parse(raw);
if (!data.tabs || !data.tabs.length) return false;
tabs = data.tabs.map(t => ({
name: t.name,
sparseMap: new Map(t.sparseMap),
lastFile: null,
lastBytes: t.lastBytes ? b64ToU8(t.lastBytes) : null,
canvasCols: t.canvasCols || 80,
canvasRows: t.canvasRows || 25,
canvasBg: t.canvasBg || '#000000',
isCentered: t.isCentered || false,
undoStack: [],
redoStack: [],
}));
activeTab = Math.min(data.activeTab || 0, tabs.length - 1);
const t = tabs[activeTab];
sparseMap = t.sparseMap;
lastFile = null;
lastBytes = t.lastBytes;
canvasCols = t.canvasCols;
canvasRows = t.canvasRows;
canvasBg = t.canvasBg;
isCentered = t.isCentered;
undoStack = []; redoStack = [];
document.getElementById('canvas-bg-swatch').style.background = canvasBg;
document.getElementById('output-wrap').classList.toggle('centered', isCentered);
document.getElementById('center-btn').classList.toggle('active', isCentered);
if (sparseMap.size > 0) {
showOutput();
document.getElementById('filename').textContent = t.name;
document.getElementById('s-name').textContent = t.name;
document.getElementById('width-wrap').classList.add('visible');
const { maxRow, maxCol } = getBounds(sparseMap);
document.getElementById('s-dims').textContent = `${maxCol+1}×${maxRow+1}`;
document.getElementById('s-type').textContent = lastBytes ? 'restored' : 'new';
document.getElementById('s-size').textContent = lastBytes ? fmt(lastBytes.length) : '—';
document.fonts.ready.then(() => drawAll());
}
renderTabs();
return true;
} catch(e) {
console.warn('Session load failed:', e);
return false;
}
}
// ── Tab bar delegation ────────────────────────────────────────────────────────
document.getElementById('tabbar').addEventListener('click', e => {
const closeBtn = e.target.closest('[data-close-idx]');
if (closeBtn) { e.stopPropagation(); closeTab(parseInt(closeBtn.dataset.closeIdx)); return; }
if (e.target.closest('[data-tab-add]')) { addTab(); return; }
const tabBtn = e.target.closest('[data-tab-idx]');
if (tabBtn) switchTab(parseInt(tabBtn.dataset.tabIdx));
});
// ── Drop / file events ────────────────────────────────────────────────────────
const dz = document.getElementById('dropzone');
dz.addEventListener('dragover', e=>{e.preventDefault();dz.classList.add('over')});
dz.addEventListener('dragleave', ()=>dz.classList.remove('over'));
dz.addEventListener('drop', e=>{e.preventDefault();dz.classList.remove('over');const f=e.dataTransfer.files[0];if(f){lastFileHandle=null;processFile(f);}});
document.getElementById('file-input').addEventListener('change', function(){if(this.files[0]){lastFileHandle=null;processFile(this.files[0]);}});
document.addEventListener('dragover', e=>e.preventDefault());
document.addEventListener('drop', e=>{e.preventDefault();const f=e.dataTransfer.files[0];if(f){lastFileHandle=null;processFile(f);}});
loadSession();
</script>
</body>
</html>