"use strict";

/**
 * AtdtEd — the built-in web ANSI editor (SPEC §12), TheDraw's spirit.
 *
 * Two halves, one namespace:
 *  - EditorCore + encodeAns: DOM-free document model — an 80×(1..100)
 *    cell grid over Cp437Render.ScreenBuffer, all tools, cell-delta
 *    undo/redo, and the .ans encoder (CP437 bytes, minimal SGR runs,
 *    SAUCE with iCE flag). Unit-tested under Node.
 *  - EditorUI: canvas painting, mouse/keyboard, palette and glyph
 *    pickers, autosave. Lives at admin/editor.php.
 *
 * Import uses the SAME AnsiParser the board renders with — what you see
 * in the editor is exactly what callers get.
 */
var AtdtEd = (function () {
    var R = (typeof Cp437Render !== "undefined") ? Cp437Render : require("./cp437render.js");
    var T = (typeof AtdtTerm !== "undefined") ? AtdtTerm : require("./term.js");

    var MAX_ROWS = 100;
    var COLS = 80;
    /** VGA index → SGR digit (the parser's SGR_TO_VGA is self-inverse). */
    var VGA_TO_SGR = [0, 4, 2, 6, 1, 5, 3, 7];

    // ---------------------------------------------------------------------
    // EditorCore — the document.

    /**
     * @constructor
     * @param glyphs 256-entry byte → Unicode codepoint table (from PHP
     *               Cp437, the single source of truth).
     */
    function EditorCore(glyphs, rows) {
        this.glyphs = glyphs;
        // Reverse map over the STREAM-safe range only: bytes 0x00-0x1F and
        // 0x7F decode as controls on the board (Cp437::loadAns), so their
        // picture-glyphs can't round-trip a file — the picker skips them.
        this.rev = {};
        for (var b = 0x20; b < 256; b++) {
            if (b !== 0x7F) { this.rev[glyphs[b]] = b; }
        }
        this.rows = Math.max(1, Math.min(MAX_ROWS, rows || 25));
        this.buf = new R.ScreenBuffer(COLS, this.rows);
        this.ice = false;
        this.sauce = { title: "", author: "", group: "" };
        this.undoStack = [];
        this.redoStack = [];
        this.stroke = null;            // deltas of the stroke in progress
        this.dirty = false;
    }

    EditorCore.prototype.idx = function (x, y) { return y * COLS + x; };

    EditorCore.prototype.inBounds = function (x, y) {
        return x >= 0 && x < COLS && y >= 0 && y < this.rows;
    };

    /** Every mutation funnels through here so undo sees it. */
    EditorCore.prototype.setCell = function (x, y, ch, fg, bg, blink) {
        if (!this.inBounds(x, y)) { return; }
        var i = this.idx(x, y);
        var before = this.buf.cells[i];
        this.buf.set(x, y, ch, fg, bg, !!blink);
        var after = this.buf.cells[i];
        if (before === after) { return; }
        this.dirty = true;
        if (this.stroke) {
            if (!(i in this.stroke.seen)) {
                this.stroke.seen[i] = true;
                this.stroke.deltas.push({ i: i, before: before, after: after });
            } else {
                for (var d = this.stroke.deltas.length - 1; d >= 0; d--) {
                    if (this.stroke.deltas[d].i === i) { this.stroke.deltas[d].after = after; break; }
                }
            }
        }
    };

    EditorCore.prototype.getCell = function (x, y) { return this.buf.get(x, y); };

    // ---- undo/redo (cell-delta steps, 100 deep) --------------------------

    EditorCore.prototype.begin = function () {
        this.stroke = { deltas: [], seen: {} };
    };

    EditorCore.prototype.end = function () {
        if (this.stroke && this.stroke.deltas.length) {
            this.undoStack.push(this.stroke.deltas);
            if (this.undoStack.length > 100) { this.undoStack.shift(); }
            this.redoStack = [];
        }
        this.stroke = null;
    };

    EditorCore.prototype.undo = function () {
        var step = this.undoStack.pop();
        if (!step) { return false; }
        for (var i = step.length - 1; i >= 0; i--) { this.buf.cells[step[i].i] = step[i].before; }
        this.redoStack.push(step);
        this.dirty = true;
        return true;
    };

    EditorCore.prototype.redo = function () {
        var step = this.redoStack.pop();
        if (!step) { return false; }
        for (var i = 0; i < step.length; i++) { this.buf.cells[step[i].i] = step[i].after; }
        this.undoStack.push(step);
        this.dirty = true;
        return true;
    };

    // ---- tools -----------------------------------------------------------

    EditorCore.prototype.pencil = function (x, y, glyph, fg, bg) {
        this.setCell(x, y, glyph, fg, this.ice ? bg : (bg & 7), this.ice ? false : bg >= 8);
    };

    /**
     * Half-block brush: the grid as 80×(rows*2) "pixels" via ▀▄█ + colors.
     * color null erases (both halves read as bg 0 → space).
     */
    EditorCore.prototype.halfBlock = function (x, halfY, color) {
        var y = halfY >> 1;
        if (!this.inBounds(x, y)) { return; }
        var px = this.pixelsAt(x, y);
        px[halfY & 1] = (color === null) ? -1 : color;
        var top = px[0], bot = px[1];
        if (top < 0 && bot < 0) { this.setCell(x, y, 0x20, 7, 0, false); return; }
        if (top === bot) { this.setCell(x, y, 0x2588, top, 0, false); return; }
        if (top < 0) { this.setCell(x, y, 0x2584, bot, 0, false); return; }
        if (bot < 0) { this.setCell(x, y, 0x2580, top, 0, false); return; }
        // Both lit, different colors: bottom rides bg — degrade bright
        // bottoms to their dim half unless iCE backgrounds are on.
        var bg = this.ice ? bot : (bot & 7);
        this.setCell(x, y, 0x2580, top, bg, false);
    };

    /** [top, bottom] pixel colors of a cell; -1 = off. */
    EditorCore.prototype.pixelsAt = function (x, y) {
        var c = this.getCell(x, y);
        var bgLit = c.bg > 0 ? c.bg : -1;
        if (c.ch === 0x2588) { return [c.fg, c.fg]; }
        if (c.ch === 0x2580) { return [c.fg, bgLit]; }
        if (c.ch === 0x2584) { return [bgLit, c.fg]; }
        return [bgLit, bgLit]; // any other glyph: treat the paint as its bg
    };

    EditorCore.prototype.line = function (x0, y0, x1, y1, draw) {
        var dx = Math.abs(x1 - x0), dy = Math.abs(y1 - y0);
        var sx = x0 < x1 ? 1 : -1, sy = y0 < y1 ? 1 : -1;
        var err = dx - dy;
        for (;;) {
            draw(x0, y0);
            if (x0 === x1 && y0 === y1) { break; }
            var e2 = 2 * err;
            if (e2 > -dy) { err -= dy; x0 += sx; }
            if (e2 < dx) { err += dx; y0 += sy; }
        }
    };

    EditorCore.prototype.rect = function (x0, y0, x1, y1, draw) {
        var xa = Math.min(x0, x1), xb = Math.max(x0, x1);
        var ya = Math.min(y0, y1), yb = Math.max(y0, y1);
        for (var x = xa; x <= xb; x++) { draw(x, ya); draw(x, yb); }
        for (var y = ya; y <= yb; y++) { draw(xa, y); draw(xb, y); }
    };

    /** Flood fill on exact (ch, fg, bg, blink) match. */
    EditorCore.prototype.fill = function (x, y, glyph, fg, bg) {
        if (!this.inBounds(x, y)) { return; }
        var target = this.buf.cells[this.idx(x, y)];
        var replacement = R.pack(glyph, fg, this.ice ? bg : (bg & 7), this.ice ? false : bg >= 8);
        if (target === replacement) { return; }
        var stack = [[x, y]];
        while (stack.length) {
            var p = stack.pop();
            var px = p[0], py = p[1];
            if (!this.inBounds(px, py) || this.buf.cells[this.idx(px, py)] !== target) { continue; }
            var cell = R.unpack(replacement);
            this.setCell(px, py, cell.ch, cell.fg, cell.bg, cell.blink);
            stack.push([px + 1, py], [px - 1, py], [px, py + 1], [px, py - 1]);
        }
    };

    /** @return clip {w, h, cells: number[]} */
    EditorCore.prototype.copy = function (x0, y0, x1, y1) {
        var xa = Math.min(x0, x1), xb = Math.max(x0, x1);
        var ya = Math.min(y0, y1), yb = Math.max(y0, y1);
        var out = { w: xb - xa + 1, h: yb - ya + 1, cells: [] };
        for (var y = ya; y <= yb; y++) {
            for (var x = xa; x <= xb; x++) { out.cells.push(this.buf.cells[this.idx(x, y)]); }
        }
        return out;
    };

    EditorCore.prototype.paste = function (x0, y0, clip) {
        for (var y = 0; y < clip.h; y++) {
            for (var x = 0; x < clip.w; x++) {
                var c = R.unpack(clip.cells[y * clip.w + x]);
                this.setCell(x0 + x, y0 + y, c.ch, c.fg, c.bg, c.blink);
            }
        }
    };

    EditorCore.prototype.clearRect = function (x0, y0, x1, y1) {
        var xa = Math.min(x0, x1), xb = Math.max(x0, x1);
        var ya = Math.min(y0, y1), yb = Math.max(y0, y1);
        for (var y = ya; y <= yb; y++) {
            for (var x = xa; x <= xb; x++) { this.setCell(x, y, 0x20, 7, 0, false); }
        }
    };

    /** Recolor (1.5.5): new ink, same glyph — nothing gets retyped. */
    EditorCore.prototype.recolor = function (x, y, fg, bg) {
        if (!this.inBounds(x, y)) { return; }
        var c = this.getCell(x, y);
        this.setCell(x, y, c.ch, fg, this.ice ? bg : (bg & 7), this.ice ? false : bg >= 8);
    };

    EditorCore.prototype.recolorRect = function (x0, y0, x1, y1, fg, bg) {
        var xa = Math.min(x0, x1), xb = Math.max(x0, x1);
        var ya = Math.min(y0, y1), yb = Math.max(y0, y1);
        for (var y = ya; y <= yb; y++) {
            for (var x = xa; x <= xb; x++) { this.recolor(x, y, fg, bg); }
        }
    };

    /** Blank the whole canvas (1.5.5); undoable when inside a stroke. */
    EditorCore.prototype.clearAll = function () {
        this.clearRect(0, 0, COLS - 1, this.rows - 1);
    };

    /** Grow/shrink (1..100 rows), content preserved. Not undoable. */
    EditorCore.prototype.resize = function (rows) {
        rows = Math.max(1, Math.min(MAX_ROWS, rows | 0));
        if (rows === this.rows) { return; }
        var next = new R.ScreenBuffer(COLS, rows);
        var keep = Math.min(rows, this.rows);
        next.cells.set(this.buf.cells.subarray(0, keep * COLS));
        this.buf = next;
        this.rows = rows;
        this.undoStack = [];
        this.redoStack = [];
        this.dirty = true;
    };

    /** Last row holding anything non-blank (for import trim / export). */
    EditorCore.prototype.usedRows = function () {
        for (var y = this.rows - 1; y >= 0; y--) {
            for (var x = 0; x < COLS; x++) {
                var c = this.buf.get(x, y);
                if (!(c.ch === 0x20 && c.bg === 0 && !c.blink)) { return y + 1; }
            }
        }
        return 1;
    };

    // ---- serialize (autosave drafts) -------------------------------------

    EditorCore.prototype.toJson = function () {
        return {
            rows: this.rows,
            ice: this.ice,
            sauce: this.sauce,
            cells: Array.prototype.slice.call(this.buf.cells, 0, this.rows * COLS)
        };
    };

    EditorCore.prototype.fromJson = function (j) {
        this.resize(j.rows || 25);
        this.ice = !!j.ice;
        this.sauce = j.sauce || { title: "", author: "", group: "" };
        var cells = j.cells || [];
        for (var i = 0; i < Math.min(cells.length, this.rows * COLS); i++) {
            this.buf.cells[i] = cells[i];
        }
        this.undoStack = [];
        this.redoStack = [];
    };

    /**
     * Import: feed Unicode ANSI text (the server's Cp437::loadAns output)
     * through the board's own parser into a tall buffer, then trim.
     */
    EditorCore.prototype.importText = function (text, ice) {
        this.ice = !!ice;
        this.resize(MAX_ROWS);
        this.buf.clear(7, 0);
        var parser = new T.AnsiParser(this.buf, { ice: this.ice });
        parser.feed(text);
        var used = this.usedRows();
        this.resize(Math.max(used, 1));
        this.undoStack = [];
        this.redoStack = [];
        this.dirty = false;
    };

    // ---------------------------------------------------------------------
    // The .ans encoder (SPEC §12 export): CP437 bytes, one minimal SGR per
    // attribute change, CRLF row ends (the parser's deferred wrap makes
    // that safe on full rows), EOF + SAUCE. Deterministic for golden tests.

    /**
     * @param meta {title, author, group, date} — date CCYYMMDD, passed in
     *             so tests stay deterministic.
     * @return Uint8Array
     */
    function encodeAns(core, meta) {
        var bytes = [];
        var esc = 0x1B;
        var state = { fg: 7, bg: 0, blink: false };
        var sgr = function (fg, bg, blink) {
            var parts = ["0"];
            if (fg >= 8) { parts.push("1"); }
            if (blink) { parts.push("5"); }
            parts.push(String(30 + VGA_TO_SGR[fg & 7]));
            if (bg !== 0) { parts.push(String(40 + VGA_TO_SGR[bg & 7])); }
            var seq = "[" + parts.join(";") + "m";
            bytes.push(esc);
            for (var i = 0; i < seq.length; i++) { bytes.push(seq.charCodeAt(i)); }
            state.fg = fg; state.bg = bg; state.blink = blink;
        };
        for (var y = 0; y < core.rows; y++) {
            var end = -1;
            for (var x = COLS - 1; x >= 0; x--) {
                var probe = core.buf.get(x, y);
                if (!(probe.ch === 0x20 && probe.bg === 0 && !probe.blink)) { end = x; break; }
            }
            for (var x2 = 0; x2 <= end; x2++) {
                var c = core.buf.get(x2, y);
                // File form: bright backgrounds ride the blink bit; the
                // SAUCE iCE flag says how to read it back.
                var fileBlink = c.blink || c.bg >= 8;
                var fileBg = c.bg & 7;
                var fileFg = c.fg;
                // A space's fg is invisible; reuse the current one to
                // spare an SGR (keeps files small, render-identical).
                if (c.ch === 0x20 && fileFg !== state.fg) { fileFg = state.fg; }
                if (fileFg !== state.fg || fileBg !== state.bg || fileBlink !== state.blink) {
                    sgr(fileFg, fileBg, fileBlink);
                }
                var byte = core.rev[c.ch];
                bytes.push(typeof byte === "number" ? byte : 0x3F);
            }
            if (y < core.rows - 1) { bytes.push(0x0D, 0x0A); }
        }
        // EOF + SAUCE00, mirroring PHP Cp437::buildSauce byte for byte.
        bytes.push(0x1A);
        var pad = function (s, n) {
            s = String(s || "").slice(0, n);
            while (s.length < n) { s += " "; }
            for (var i = 0; i < n; i++) { bytes.push(s.charCodeAt(i) & 0xFF); }
        };
        var u16 = function (v) { bytes.push(v & 0xFF, (v >> 8) & 0xFF); };
        var u32 = function (v) { bytes.push(v & 0xFF, (v >> 8) & 0xFF, (v >> 16) & 0xFF, (v >> 24) & 0xFF); };
        pad("SAUCE00", 7);
        pad(meta.title, 35);
        pad(meta.author, 20);
        pad(meta.group, 20);
        pad(meta.date, 8);
        u32(0);                    // FileSize (0 = unspecified, like the PHP side)
        bytes.push(1, 1);          // DataType=Character, FileType=ANSi
        u16(COLS);                 // TInfo1 = width
        u16(core.rows);            // TInfo2 = height
        u16(0); u16(0);            // TInfo3, TInfo4
        bytes.push(0);             // no comments
        bytes.push(core.ice ? 1 : 0);
        for (var z = 0; z < 22; z++) { bytes.push(0); }
        return new Uint8Array(bytes);
    }

    /** Latin-1-safe bytes → binary string (for upload/base64). */
    function bytesToBinary(u8) {
        var s = "";
        for (var i = 0; i < u8.length; i++) { s += String.fromCharCode(u8[i]); }
        return s;
    }

    // ---------------------------------------------------------------------
    // ANSI letter fonts (1.5.5): TheDraw's .TDF spirit. A font is a
    // uniform-height set of glyphs for A-Z 0-9; a glyph is a w×h grid of
    // packed cells with null meaning transparent. Built-ins are computed
    // from one 5-row pixel alphabet; sysop-drawn fonts come from
    // data/fonts/ as JSON with the same shape.

    var FONT_CHARS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
    var FONT_MAX_W = 16;
    var FONT_MAX_H = 8;

    /** The pixel alphabet: 5 rows per glyph, X marks a lit pixel. */
    var FONT_PIX = {
        A: [" XX ", "X  X", "XXXX", "X  X", "X  X"],
        B: ["XXX ", "X  X", "XXX ", "X  X", "XXX "],
        C: [" XXX", "X   ", "X   ", "X   ", " XXX"],
        D: ["XXX ", "X  X", "X  X", "X  X", "XXX "],
        E: ["XXXX", "X   ", "XXX ", "X   ", "XXXX"],
        F: ["XXXX", "X   ", "XXX ", "X   ", "X   "],
        G: [" XXX", "X   ", "X XX", "X  X", " XXX"],
        H: ["X  X", "X  X", "XXXX", "X  X", "X  X"],
        I: ["XXX", " X ", " X ", " X ", "XXX"],
        J: ["  XX", "   X", "   X", "X  X", " XX "],
        K: ["X  X", "X X ", "XX  ", "X X ", "X  X"],
        L: ["X   ", "X   ", "X   ", "X   ", "XXXX"],
        M: ["X   X", "XX XX", "X X X", "X   X", "X   X"],
        N: ["X   X", "XX  X", "X X X", "X  XX", "X   X"],
        O: [" XX ", "X  X", "X  X", "X  X", " XX "],
        P: ["XXX ", "X  X", "XXX ", "X   ", "X   "],
        Q: [" XX ", "X  X", "X  X", "X XX", " XXX"],
        R: ["XXX ", "X  X", "XXX ", "X X ", "X  X"],
        S: [" XXX", "X   ", " XX ", "   X", "XXX "],
        T: ["XXXXX", "  X  ", "  X  ", "  X  ", "  X  "],
        U: ["X  X", "X  X", "X  X", "X  X", " XX "],
        V: ["X   X", "X   X", "X   X", " X X ", "  X  "],
        W: ["X   X", "X   X", "X X X", "XX XX", "X   X"],
        X: ["X   X", " X X ", "  X  ", " X X ", "X   X"],
        Y: ["X   X", " X X ", "  X  ", "  X  ", "  X  "],
        Z: ["XXXX", "   X", " XX ", "X   ", "XXXX"],
        "0": [" XX ", "X XX", "XX X", "X  X", " XX "],
        "1": [" X ", "XX ", " X ", " X ", "XXX"],
        "2": ["XXX ", "   X", " XX ", "X   ", "XXXX"],
        "3": ["XXX ", "   X", " XX ", "   X", "XXX "],
        "4": ["X  X", "X  X", "XXXX", "   X", "   X"],
        "5": ["XXXX", "X   ", "XXX ", "   X", "XXX "],
        "6": [" XX ", "X   ", "XXX ", "X  X", " XX "],
        "7": ["XXXX", "   X", "  X ", " X  ", " X  "],
        "8": [" XX ", "X  X", " XX ", "X  X", " XX "],
        "9": [" XX ", "X  X", " XXX", "   X", " XX "]
    };

    /**
     * The shipped fonts, built from the pixel alphabet. Tinting: cells
     * drawn with fg 15 take the editor's current ink when stamped; the
     * shadow's dark cells (fg 8) keep their gray.
     * @param glyphs byte → codepoint table
     * @return list of {key, name, h, tint, builtin, glyphs}
     */
    function builtinFonts(glyphs) {
        var FULL = glyphs[0xDB], UP = glyphs[0xDF], DOWN = glyphs[0xDC], SHADE = glyphs[0xB2];
        var lit = function (rows, x, y) {
            return y >= 0 && y < rows.length && x >= 0 && x < rows[y].length && rows[y].charAt(x) === "X";
        };
        var build = function (expand) {
            var out = {};
            for (var i = 0; i < FONT_CHARS.length; i++) {
                var ch = FONT_CHARS.charAt(i);
                if (FONT_PIX[ch]) { out[ch] = expand(FONT_PIX[ch]); }
            }
            return out;
        };
        var block = build(function (rows) {
            var w = rows[0].length, cells = [];
            for (var y = 0; y < 5; y++) {
                for (var x = 0; x < w; x++) {
                    cells.push(lit(rows, x, y) ? R.pack(FULL, 15, 0, false) : null);
                }
            }
            return { w: w, cells: cells };
        });
        var wide = build(function (rows) {
            var w = rows[0].length * 2, cells = [];
            for (var y = 0; y < 5; y++) {
                for (var x = 0; x < w; x++) {
                    cells.push(lit(rows, x >> 1, y) ? R.pack(FULL, 15, 0, false) : null);
                }
            }
            return { w: w, cells: cells };
        });
        var shadow = build(function (rows) {
            var w = rows[0].length + 1, cells = [];
            for (var y = 0; y < 6; y++) {
                for (var x = 0; x < w; x++) {
                    if (lit(rows, x, y)) { cells.push(R.pack(FULL, 15, 0, false)); }
                    else if (lit(rows, x - 1, y - 1)) { cells.push(R.pack(SHADE, 8, 0, false)); }
                    else { cells.push(null); }
                }
            }
            return { w: w, cells: cells };
        });
        var small = build(function (rows) {
            var w = rows[0].length, cells = [];
            for (var y = 0; y < 3; y++) {
                for (var x = 0; x < w; x++) {
                    var top = lit(rows, x, y * 2), bot = y * 2 + 1 < 5 ? lit(rows, x, y * 2 + 1) : false;
                    if (top && bot) { cells.push(R.pack(FULL, 15, 0, false)); }
                    else if (top) { cells.push(R.pack(UP, 15, 0, false)); }
                    else if (bot) { cells.push(R.pack(DOWN, 15, 0, false)); }
                    else { cells.push(null); }
                }
            }
            return { w: w, cells: cells };
        });
        return [
            { key: "block", name: "Block", h: 5, tint: true, builtin: true, glyphs: block },
            { key: "wide", name: "Block Wide", h: 5, tint: true, builtin: true, glyphs: wide },
            { key: "shadow", name: "Block Shadow", h: 6, tint: true, builtin: true, glyphs: shadow },
            { key: "small", name: "Small", h: 3, tint: true, builtin: true, glyphs: small }
        ];
    }

    /**
     * Stamp one font glyph onto the core at x,y with the current ink.
     * Transparent cells leave the canvas alone. Returns the width used,
     * or 0 when the font has no such glyph.
     */
    function stampGlyph(core, font, ch, x, y, inkFg) {
        var g = font.glyphs[ch];
        if (!g) { return 0; }
        for (var gy = 0; gy < font.h; gy++) {
            for (var gx = 0; gx < g.w; gx++) {
                var packed = g.cells[gy * g.w + gx];
                if (packed === null || packed === undefined) { continue; }
                var c = R.unpack(packed);
                var fg = (font.tint && c.fg === 15) ? inkFg : c.fg;
                core.setCell(x + gx, y + gy, c.ch, fg, c.bg, c.blink);
            }
        }
        return g.w;
    }

    // ---------------------------------------------------------------------
    // EditorUI — the chrome at admin/editor.php. Everything below needs a
    // DOM; Node never calls boot().

    /** Ten classic TheDraw-spirit sets, ten CP437 bytes each (F1–F10). */
    var GLYPH_SETS = [
        [0xC4, 0xB3, 0xDA, 0xBF, 0xC0, 0xD9, 0xC3, 0xB4, 0xC2, 0xC1],
        [0xCD, 0xBA, 0xC9, 0xBB, 0xC8, 0xBC, 0xCC, 0xB9, 0xCB, 0xCA],
        [0xC5, 0xCE, 0xB5, 0xC6, 0xB8, 0xD5, 0xBE, 0xD4, 0xBD, 0xD3],
        [0xB0, 0xB1, 0xB2, 0xDB, 0xDC, 0xDF, 0xDD, 0xDE, 0xFE, 0x20],
        [0x2E, 0x3A, 0x2A, 0x6F, 0x4F, 0x30, 0xFA, 0xF9, 0xF8, 0xFE],
        [0xE0, 0xE1, 0xE2, 0xE3, 0xE4, 0xE5, 0xE6, 0xE7, 0xE8, 0xE9],
        [0xAB, 0xAC, 0xAD, 0xA8, 0xA9, 0xAA, 0x9C, 0x9B, 0x9D, 0x9E],
        [0x23, 0x24, 0x25, 0x26, 0x40, 0x2B, 0x3D, 0x7E, 0x5E, 0x2D],
        [0xF0, 0xF1, 0xF2, 0xF3, 0xF4, 0xF5, 0xF6, 0xF7, 0xEC, 0xED],
        [0x21, 0x3F, 0x2F, 0x5C, 0x7C, 0x28, 0x29, 0x5B, 0x5D, 0x22]
    ];

    function EditorUI(doc, cfg) {
        var self = this;
        this.doc = doc;
        this.cfg = cfg;
        this.core = new EditorCore(cfg.glyphs, 25);
        this.canvas = doc.getElementById("edcanvas");
        this.painter = new R.CanvasPainter(this.canvas, this.core.buf);
        this.fg = 7; this.bg = 0;
        this.glyph = 0x2588;
        this.tool = "pencil";
        this.zoom = 1;
        this.grid = false;
        this.setIdx = 0;
        this.drag = null;          // {x, y} anchor while dragging
        this.sel = null;           // {x0, y0, x1, y1}
        this.float = null;         // a lifted selection riding the cursor (1.5.5)
        this.caret = null;         // text/font tool {x, y, startX}
        this.slotLoaded = "";
        // Fonts (1.5.5): the shipped four plus whatever the sysop drew.
        this.builtins = builtinFonts(cfg.glyphs);
        this.userFonts = {};       // name → font, fetched on demand
        this.fontSel = "b:0";
        this.lastAdv = 4;          // Backspace steps back by the last stamp
        this.fl = null;            // font-library working state

        R.loadFont(cfg.font, function (fam) {
            self.painter.setFont(fam);
            self.repaint();
            self.flRepaint();
        });
        this.buildPalettes();
        this.buildGlyphRow();
        this.buildPicker();
        this.wireChrome();
        this.wireCanvas();
        this.wireKeys();
        this.fillSlots(cfg.slots);
        this.fillFonts(cfg.fonts || []);
        this.wireFontLib();
        this.recoverDraft();
        setInterval(function () { self.autosave(); }, 10000);
        this.repaint();
        if (cfg.open) { this.open(cfg.open); }
    }

    // ---- painting --------------------------------------------------------

    EditorUI.prototype.syncBuffer = function () {
        // The core reallocates its buffer on resize/import; follow it.
        if (this.painter.buffer !== this.core.buf) {
            this.painter.buffer = this.core.buf;
            this.canvas.width = COLS * R.CELL_W;
            this.canvas.height = this.core.rows * R.CELL_H;
        }
        this.canvas.style.width = (COLS * R.CELL_W * this.zoom) + "px";
        this.canvas.style.height = (this.core.rows * R.CELL_H * this.zoom) + "px";
    };

    EditorUI.prototype.repaint = function () {
        this.syncBuffer();
        this.painter.repaint();
        var g = this.canvas.getContext("2d");
        if (this.grid) {
            g.strokeStyle = "rgba(255,255,255,0.12)";
            g.lineWidth = 1;
            for (var x = 1; x < COLS; x++) {
                g.beginPath(); g.moveTo(x * R.CELL_W + 0.5, 0); g.lineTo(x * R.CELL_W + 0.5, this.canvas.height); g.stroke();
            }
            for (var y = 1; y < this.core.rows; y++) {
                g.beginPath(); g.moveTo(0, y * R.CELL_H + 0.5); g.lineTo(this.canvas.width, y * R.CELL_H + 0.5); g.stroke();
            }
        }
        if (this.sel) {
            g.strokeStyle = "#55FFFF";
            g.setLineDash([4, 3]);
            var xa = Math.min(this.sel.x0, this.sel.x1), xb = Math.max(this.sel.x0, this.sel.x1);
            var ya = Math.min(this.sel.y0, this.sel.y1), yb = Math.max(this.sel.y0, this.sel.y1);
            g.strokeRect(xa * R.CELL_W + 0.5, ya * R.CELL_H + 0.5,
                (xb - xa + 1) * R.CELL_W - 1, (yb - ya + 1) * R.CELL_H - 1);
            g.setLineDash([]);
        }
        if (this.caret && this.tool === "text") {
            g.strokeStyle = "#FFFF55";
            g.strokeRect(this.caret.x * R.CELL_W + 0.5, this.caret.y * R.CELL_H + 0.5, R.CELL_W - 1, R.CELL_H - 1);
        }
        if (this.caret && this.tool === "font") {
            var fh = (this.currentFont() || { h: 1 }).h;
            g.strokeStyle = "#FFFF55";
            g.strokeRect(this.caret.x * R.CELL_W + 0.5, this.caret.y * R.CELL_H + 0.5,
                R.CELL_W - 1, Math.min(fh, this.core.rows - this.caret.y) * R.CELL_H - 1);
        }
        if (this.float) { this.drawFloat(g); }
    };

    /** The lifted selection, painted over the canvas at its ride position. */
    EditorUI.prototype.drawFloat = function (g) {
        var f = this.float;
        if (f.cut) {
            // The source reads as lifted until the block lands somewhere.
            g.fillStyle = "rgba(0,0,0,0.6)";
            g.fillRect(f.sx * R.CELL_W, f.sy * R.CELL_H, f.clip.w * R.CELL_W, f.clip.h * R.CELL_H);
        }
        g.textBaseline = "bottom";
        g.font = R.CELL_H + "px " + this.painter.family;
        for (var y = 0; y < f.clip.h; y++) {
            for (var x = 0; x < f.clip.w; x++) {
                var gx = f.x + x, gy = f.y + y;
                if (gx < 0 || gx >= COLS || gy < 0 || gy >= this.core.rows) { continue; }
                var c = R.unpack(f.clip.cells[y * f.clip.w + x]);
                g.fillStyle = R.PALETTE[c.bg];
                g.fillRect(gx * R.CELL_W, gy * R.CELL_H, R.CELL_W, R.CELL_H);
                if (c.ch !== 0x20 && c.ch !== 0) {
                    g.fillStyle = R.PALETTE[c.fg];
                    g.fillText(String.fromCharCode(c.ch), gx * R.CELL_W, (gy + 1) * R.CELL_H);
                }
            }
        }
        g.strokeStyle = "#FFFF55";
        g.setLineDash([5, 3]);
        g.strokeRect(f.x * R.CELL_W + 0.5, f.y * R.CELL_H + 0.5,
            f.clip.w * R.CELL_W - 1, f.clip.h * R.CELL_H - 1);
        g.setLineDash([]);
    };

    /** Land the float: for a move, lift-from-source and set-down is ONE undo step. */
    EditorUI.prototype.dropFloat = function () {
        var f = this.float;
        if (!f) { return; }
        this.core.begin();
        if (f.cut) { this.core.clearRect(f.sx, f.sy, f.sx + f.clip.w - 1, f.sy + f.clip.h - 1); }
        this.core.paste(f.x, f.y, f.clip);
        this.core.end();
        this.float = null;
        this.repaint();
        this.status(f.cut ? "moved" : "placed");
    };

    EditorUI.prototype.cancelFloat = function () {
        this.float = null;
        this.repaint();
        this.status("put back");
    };

    EditorUI.prototype.nudgeFloat = function (dx, dy) {
        var f = this.float;
        f.pinned = true; // arrow steering wins until the mouse moves again
        f.x = Math.max(1 - f.clip.w, Math.min(COLS - 1, f.x + dx));
        f.y = Math.max(1 - f.clip.h, Math.min(this.core.rows - 1, f.y + dy));
        this.repaint();
        this.status();
    };

    EditorUI.prototype.status = function (extra) {
        var el = this.doc.getElementById("edstatus");
        var glyphCh = String.fromCharCode(this.core.glyphs[this.glyphByte()] || 0x20);
        var font = this.tool === "font" ? this.currentFont() : null;
        el.textContent = (extra ? extra + " · " : "")
            + this.tool + (font ? " " + font.name : "")
            + " · glyph " + glyphCh + " · ink " + this.fg + " on " + this.bg
            + (this.core.ice ? " · iCE" : "")
            + (this.float ? " · steer with mouse or arrows (Shift=5) · Enter or click places · Esc puts it back" : "");
    };

    EditorUI.prototype.glyphByte = function () {
        return this.core.rev[this.glyph] !== undefined ? this.core.rev[this.glyph] : 0x3F;
    };

    // ---- chrome ----------------------------------------------------------

    EditorUI.prototype.buildPalettes = function () {
        var self = this;
        var mk = function (holder, count, get, set) {
            holder.innerHTML = "";
            for (var i = 0; i < count; i++) {
                (function (idx) {
                    var b = self.doc.createElement("button");
                    b.className = "sw";
                    b.style.background = R.PALETTE[idx];
                    b.title = String(idx);
                    b.addEventListener("click", function () { set(idx); self.refreshPalettes(); self.status(); });
                    holder.appendChild(b);
                })(i);
            }
        };
        mk(this.doc.getElementById("edpal-fg"), 16,
            function () { return self.fg; }, function (v) { self.fg = v; });
        mk(this.doc.getElementById("edpal-bg"), 16,
            function () { return self.bg; }, function (v) { self.bg = v; });
        this.refreshPalettes();
    };

    EditorUI.prototype.refreshPalettes = function () {
        var self = this;
        var mark = function (holder, current, dimFrom) {
            var kids = holder.children;
            for (var i = 0; i < kids.length; i++) {
                kids[i].classList.toggle("cur", i === current);
                kids[i].classList.toggle("dim", dimFrom !== null && i >= dimFrom && !self.core.ice);
            }
        };
        mark(this.doc.getElementById("edpal-fg"), this.fg, null);
        mark(this.doc.getElementById("edpal-bg"), this.bg, 8);
    };

    EditorUI.prototype.buildGlyphRow = function () {
        var self = this;
        var row = this.doc.getElementById("edglyphs");
        row.innerHTML = "";
        var set = GLYPH_SETS[this.setIdx];
        for (var i = 0; i < set.length; i++) {
            (function (byte, n) {
                var b = self.doc.createElement("button");
                b.className = "gl" + (self.core.glyphs[byte] === self.glyph ? " cur" : "");
                b.textContent = String.fromCharCode(self.core.glyphs[byte]);
                b.title = (n + 1) % 10 + "";
                b.addEventListener("click", function () {
                    self.glyph = self.core.glyphs[byte];
                    self.buildGlyphRow();
                    self.status();
                });
                row.appendChild(b);
            })(set[i], i);
        }
        this.doc.getElementById("edsetname").textContent = "set F" + (this.setIdx + 1);
    };

    EditorUI.prototype.buildPicker = function () {
        var self = this;
        var box = this.doc.getElementById("edpicker");
        box.innerHTML = "";
        for (var byte = 0x20; byte < 256; byte++) {
            if (byte === 0x7F) { continue; }
            (function (bt) {
                var b = self.doc.createElement("button");
                b.className = "gl";
                b.textContent = String.fromCharCode(self.core.glyphs[bt]);
                b.title = "0x" + bt.toString(16).toUpperCase();
                b.addEventListener("click", function () {
                    self.glyph = self.core.glyphs[bt];
                    box.style.display = "none";
                    self.buildGlyphRow();
                    self.status();
                });
                box.appendChild(b);
            })(byte);
        }
    };

    EditorUI.prototype.fillSlots = function (slots) {
        var sel = this.doc.getElementById("edslots");
        sel.innerHTML = "";
        for (var i = 0; i < slots.length; i++) {
            var o = this.doc.createElement("option");
            o.value = slots[i];
            o.textContent = slots[i];
            sel.appendChild(o);
        }
    };

    EditorUI.prototype.wireChrome = function () {
        var self = this;
        var $ = function (id) { return self.doc.getElementById(id); };

        $("edloadbtn").addEventListener("click", function () { self.open($("edslots").value); });
        $("eddelete").addEventListener("click", function () {
            var piece = $("edslots").value;
            if (!piece) { return; }
            if (!confirm("Delete '" + piece + "' from the gallery? Screens bound to it fall back to their generated look.")) { return; }
            self.req({ action: "delete", slot: piece }, function (j) {
                self.fillSlots(j.slots || []);
                if (self.slotLoaded === piece) {
                    self.slotLoaded = "";
                    self.core.dirty = true; // still on canvas, no longer on the shelf
                }
                self.status("deleted " + piece);
            });
        });
        $("ednew").addEventListener("click", function () {
            if (self.core.dirty && !confirm("Discard unsaved changes?")) { return; }
            self.core.resize(25);
            self.core.buf.clear(7, 0);
            self.core.ice = false;
            self.core.dirty = false;
            self.setMeta({ title: "", author: "", group: "" });
            self.slotLoaded = "";
            $("edslotname").value = "";
            $("edrows").value = 25;
            $("edice").checked = false;
            self.clearDraft();
            self.sel = null; self.caret = null; self.float = null;
            self.repaint(); self.status("new");
        });
        $("edsave").addEventListener("click", function () { self.save(); });
        $("edexport").addEventListener("click", function () { self.exportAns(); });
        $("edimport").addEventListener("change", function () {
            if (this.files && this.files[0]) { self.importFile(this.files[0]); this.value = ""; }
        });
        $("edundo").addEventListener("click", function () { if (self.core.undo()) { self.repaint(); } });
        $("edredo").addEventListener("click", function () { if (self.core.redo()) { self.repaint(); } });
        $("edzoom").addEventListener("click", function () {
            self.zoom = self.zoom === 1 ? 2 : 1;
            this.textContent = self.zoom + "×";
            self.repaint();
        });
        $("edgridbtn").addEventListener("click", function () { self.grid = !self.grid; self.repaint(); });
        $("edrows").addEventListener("change", function () {
            self.core.resize(parseInt(this.value, 10) || 25);
            this.value = self.core.rows;
            self.repaint();
        });
        $("edice").addEventListener("change", function () {
            self.core.ice = this.checked;
            self.refreshPalettes();
            self.status();
        });
        this.doc.getElementById("edtools").addEventListener("click", function (e) {
            var b = e.target.closest("button[data-tool]");
            if (!b) { return; }
            self.setTool(b.getAttribute("data-tool"));
        });
        $("edcopy").addEventListener("click", function () { self.armPaste(false); });
        $("edmove").addEventListener("click", function () { self.armPaste(true); });
        $("eddel").addEventListener("click", function () {
            if (!self.sel) { return; }
            self.core.begin();
            self.core.clearRect(self.sel.x0, self.sel.y0, self.sel.x1, self.sel.y1);
            self.core.end();
            self.repaint();
        });
        $("edtint").addEventListener("click", function () {
            // Recolor the selection with the current ink (1.5.5).
            if (!self.sel) { return; }
            self.core.begin();
            self.core.recolorRect(self.sel.x0, self.sel.y0, self.sel.x1, self.sel.y1, self.fg, self.bg);
            self.core.end();
            self.repaint();
            self.status("tinted");
        });
        $("edclear").addEventListener("click", function () {
            // The whole canvas, gone — confirmed, and still one Ctrl+Z away.
            if (!confirm("Clear the whole canvas? (Undo can bring it back.)")) { return; }
            self.core.begin();
            self.core.clearAll();
            self.core.end();
            self.sel = null; self.caret = null; self.float = null;
            self.repaint();
            self.status("cleared");
        });
        $("edfontsel").addEventListener("change", function () {
            self.fontSel = this.value;
            self.ensureFont(self.fontSel, function () { self.status(); });
            self.status();
            this.blur(); // give the keyboard back to the canvas
        });
        $("eddiscard").addEventListener("click", function () {
            self.clearDraft();
            $("edrecover").style.display = "none";
        });
        var meta = function (id, key) {
            $(id).addEventListener("input", function () { self.core.sauce[key] = this.value; self.core.dirty = true; });
        };
        meta("edtitle", "title"); meta("edauthor", "author"); meta("edgroup", "group");
    };

    EditorUI.prototype.setTool = function (tool) {
        this.tool = tool;
        this.caret = null;
        if (tool !== "select") { this.sel = null; }
        var btns = this.doc.querySelectorAll("#edtools button");
        for (var i = 0; i < btns.length; i++) {
            btns[i].classList.toggle("active", btns[i].getAttribute("data-tool") === tool);
        }
        this.doc.getElementById("edselops").style.display = tool === "select" ? "" : "none";
        this.doc.getElementById("edfontrow").style.display = tool === "font" ? "" : "none";
        this.repaint();
        this.status();
    };

    EditorUI.prototype.setMeta = function (sauce) {
        this.core.sauce = { title: sauce.title || "", author: sauce.author || "", group: sauce.group || "" };
        this.doc.getElementById("edtitle").value = this.core.sauce.title;
        this.doc.getElementById("edauthor").value = this.core.sauce.author;
        this.doc.getElementById("edgroup").value = this.core.sauce.group;
    };

    // ---- mouse -----------------------------------------------------------

    EditorUI.prototype.cellAt = function (e) {
        var r = this.canvas.getBoundingClientRect();
        var x = Math.floor((e.clientX - r.left) / r.width * COLS);
        var y = Math.floor((e.clientY - r.top) / r.height * this.core.rows);
        var hy = Math.floor((e.clientY - r.top) / r.height * this.core.rows * 2);
        return {
            x: Math.max(0, Math.min(COLS - 1, x)),
            y: Math.max(0, Math.min(this.core.rows - 1, y)),
            hy: Math.max(0, Math.min(this.core.rows * 2 - 1, hy))
        };
    };

    EditorUI.prototype.wireCanvas = function () {
        var self = this;
        var cv = this.canvas;
        cv.addEventListener("contextmenu", function (e) { e.preventDefault(); });
        cv.addEventListener("mousedown", function (e) {
            e.preventDefault();
            // preventDefault also blocks the browser's focus transfer, so
            // after touching the font <select> (or any control) the focus
            // stayed there and the document keydown handler treated every
            // keystroke as form typing -- the Font tool looked completely
            // dead (1.5.5 owner report). Clicking the canvas now always
            // takes the keyboard back.
            if (self.doc.activeElement && self.doc.activeElement !== self.doc.body
                && typeof self.doc.activeElement.blur === "function") {
                self.doc.activeElement.blur();
            }
            var p = self.cellAt(e);
            var right = e.button === 2;
            if (self.float) {
                if (right) { self.cancelFloat(); return; }
                self.float.x = p.x;
                self.float.y = p.y;
                self.dropFloat();
                return;
            }
            if (right) { self.eyedrop(p); return; }
            self.drag = { x: p.x, y: p.y, hy: p.hy, moved: false };
            if (self.tool === "pencil" || self.tool === "half" || self.tool === "color") {
                self.core.begin();
                self.applyBrush(p, e);
                self.repaint();
            } else if (self.tool === "fill") {
                self.core.begin();
                self.core.fill(p.x, p.y, self.glyph, self.fg, self.bg);
                self.core.end();
                self.drag = null;
                self.repaint();
            } else if (self.tool === "text" || self.tool === "font") {
                self.caret = { x: p.x, y: p.y, startX: p.x };
                self.drag = null;
                self.repaint();
            } else if (self.tool === "select") {
                self.sel = { x0: p.x, y0: p.y, x1: p.x, y1: p.y };
                self.repaint();
            }
            self.status(p.x + 1 + "," + (p.y + 1));
        });
        cv.addEventListener("mousemove", function (e) {
            var p = self.cellAt(e);
            if (self.float && !self.drag) {
                // The block rides the cursor; arrow steering pins it until
                // the pointer actually lands on a different cell.
                if (self.float.pinned) {
                    if (p.x === self.float.x && p.y === self.float.y) { return; }
                    self.float.pinned = false;
                }
                if (p.x !== self.float.x || p.y !== self.float.y) {
                    self.float.x = p.x;
                    self.float.y = p.y;
                    self.repaint();
                }
                self.status((p.x + 1) + "," + (p.y + 1));
                return;
            }
            if (!self.drag) { self.status((p.x + 1) + "," + (p.y + 1)); return; }
            self.drag.moved = true;
            if (self.tool === "pencil" || self.tool === "half" || self.tool === "color") {
                self.applyBrush(p, e);
                self.repaint();
            } else if (self.tool === "select") {
                self.sel.x1 = p.x; self.sel.y1 = p.y;
                self.repaint();
            } else if (self.tool === "line" || self.tool === "rect") {
                self.repaint();
                self.previewShape(p);
            }
            self.status((p.x + 1) + "," + (p.y + 1));
        });
        var up = function (e) {
            if (!self.drag) { return; }
            var p = self.cellAt(e);
            if (self.tool === "pencil" || self.tool === "half" || self.tool === "color") {
                self.core.end();
            } else if (self.tool === "line" || self.tool === "rect") {
                self.core.begin();
                var draw = function (x, y) { self.core.pencil(x, y, self.glyph, self.fg, self.bg); };
                if (self.tool === "line") { self.core.line(self.drag.x, self.drag.y, p.x, p.y, draw); }
                else { self.core.rect(self.drag.x, self.drag.y, p.x, p.y, draw); }
                self.core.end();
                self.repaint();
            }
            self.drag = null;
        };
        cv.addEventListener("mouseup", up);
        cv.addEventListener("mouseleave", function () { /* keep the stroke; mouseup ends it */ });
        this.doc.addEventListener("mouseup", up);
    };

    EditorUI.prototype.applyBrush = function (p, e) {
        if (this.tool === "pencil" || this.tool === "color") {
            var self = this;
            var put = this.tool === "color"
                ? function (x, y) { self.core.recolor(x, y, self.fg, self.bg); }
                : function (x, y) { self.core.pencil(x, y, self.glyph, self.fg, self.bg); };
            if (this.drag && (this.drag.px !== undefined)) {
                this.core.line(this.drag.px, this.drag.py, p.x, p.y, put);
            } else {
                put(p.x, p.y);
            }
            this.drag.px = p.x; this.drag.py = p.y;
        } else {
            var erase = e.buttons === 2;
            if (this.drag && this.drag.phy !== undefined) {
                var s = this;
                this.core.line(this.drag.phx, this.drag.phy, p.x, p.hy, function (x, hy) {
                    s.core.halfBlock(x, hy, erase ? null : s.fg);
                });
            } else {
                this.core.halfBlock(p.x, p.hy, erase ? null : this.fg);
            }
            this.drag.phx = p.x; this.drag.phy = p.hy;
        }
    };

    EditorUI.prototype.previewShape = function (p) {
        var g = this.canvas.getContext("2d");
        g.strokeStyle = "rgba(255,255,85,0.8)";
        var xa = Math.min(this.drag.x, p.x), xb = Math.max(this.drag.x, p.x);
        var ya = Math.min(this.drag.y, p.y), yb = Math.max(this.drag.y, p.y);
        if (this.tool === "rect") {
            g.strokeRect(xa * R.CELL_W + 0.5, ya * R.CELL_H + 0.5,
                (xb - xa + 1) * R.CELL_W - 1, (yb - ya + 1) * R.CELL_H - 1);
        } else {
            g.beginPath();
            g.moveTo((this.drag.x + 0.5) * R.CELL_W, (this.drag.y + 0.5) * R.CELL_H);
            g.lineTo((p.x + 0.5) * R.CELL_W, (p.y + 0.5) * R.CELL_H);
            g.stroke();
        }
    };

    EditorUI.prototype.eyedrop = function (p) {
        var c = this.core.getCell(p.x, p.y);
        this.glyph = c.ch;
        this.fg = c.fg;
        this.bg = c.blink && !this.core.ice ? c.bg + 8 : c.bg;
        this.refreshPalettes();
        this.buildGlyphRow();
        this.status("picked up");
    };

    /**
     * Lift the selection into a visible float (1.5.5): it rides the mouse
     * or the arrow keys, and nothing touches the canvas until it lands.
     * A move blanks its source only at the drop, so Esc is a free undo.
     */
    EditorUI.prototype.armPaste = function (move) {
        if (!this.sel) { return; }
        var xa = Math.min(this.sel.x0, this.sel.x1), ya = Math.min(this.sel.y0, this.sel.y1);
        this.float = {
            clip: this.core.copy(this.sel.x0, this.sel.y0, this.sel.x1, this.sel.y1),
            x: xa, y: ya,
            sx: xa, sy: ya,
            cut: !!move,
            pinned: false,
        };
        this.sel = null;
        this.repaint();
        this.status(move ? "lifted" : "copied");
    };

    // ---- keyboard --------------------------------------------------------

    EditorUI.prototype.wireKeys = function () {
        var self = this;
        this.doc.addEventListener("keydown", function (e) {
            var t = e.target;
            var typingInForm = t && (t.tagName === "INPUT" || t.tagName === "SELECT" || t.tagName === "TEXTAREA");
            var fkey = /^F([1-9]|10)$/.exec(e.key);
            if (fkey) {
                e.preventDefault();
                self.setIdx = parseInt(fkey[1], 10) - 1;
                self.buildGlyphRow();
                return;
            }
            if (e.key === "Tab" && !typingInForm) {
                e.preventDefault();
                var box = self.doc.getElementById("edpicker");
                box.style.display = box.style.display === "none" ? "" : "none";
                return;
            }
            if (typingInForm) { return; }
            if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "z") {
                e.preventDefault();
                if (self.core.undo()) { self.repaint(); }
                return;
            }
            if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "y") {
                e.preventDefault();
                if (self.core.redo()) { self.repaint(); }
                return;
            }
            if (self.float) {
                var steps = e.shiftKey ? 5 : 1;
                var moves = { ArrowUp: [0, -1], ArrowDown: [0, 1], ArrowLeft: [-1, 0], ArrowRight: [1, 0] };
                if (moves[e.key]) {
                    e.preventDefault();
                    self.nudgeFloat(moves[e.key][0] * steps, moves[e.key][1] * steps);
                    return;
                }
                if (e.key === "Enter") { e.preventDefault(); self.dropFloat(); return; }
                if (e.key === "Escape") { e.preventDefault(); self.cancelFloat(); return; }
                return; // a lifted block eats other keys until it lands
            }
            if (self.tool === "text" && self.caret) { self.textKey(e); return; }
            if (self.tool === "font" && self.caret) { self.fontKey(e); return; }
            if (e.key >= "1" && e.key <= "9" || e.key === "0") {
                var n = e.key === "0" ? 9 : (e.key.charCodeAt(0) - 49);
                var byte = GLYPH_SETS[self.setIdx][n];
                self.glyph = self.core.glyphs[byte];
                self.buildGlyphRow();
                self.status();
                return;
            }
            var tools = { p: "pencil", b: "half", t: "text", l: "line", r: "rect", f: "fill", s: "select", c: "color", o: "font" };
            if (tools[e.key.toLowerCase()]) { self.setTool(tools[e.key.toLowerCase()]); return; }
            if (e.key === "[") { self.fg = (self.fg + 15) % 16; self.refreshPalettes(); self.status(); return; }
            if (e.key === "]") { self.fg = (self.fg + 1) % 16; self.refreshPalettes(); self.status(); return; }
            if (e.key === "{") { self.bg = (self.bg + 15) % 16; self.refreshPalettes(); self.status(); return; }
            if (e.key === "}") { self.bg = (self.bg + 1) % 16; self.refreshPalettes(); self.status(); return; }
            if (e.key.toLowerCase() === "x") {
                var f = self.fg; self.fg = self.bg; self.bg = f;
                self.refreshPalettes(); self.status(); return;
            }
            if (e.key === "Delete" && self.sel) {
                self.core.begin();
                self.core.clearRect(self.sel.x0, self.sel.y0, self.sel.x1, self.sel.y1);
                self.core.end();
                self.repaint();
            }
        });
    };

    EditorUI.prototype.textKey = function (e) {
        var c = this.caret;
        var moves = { ArrowUp: [0, -1], ArrowDown: [0, 1], ArrowLeft: [-1, 0], ArrowRight: [1, 0] };
        if (moves[e.key]) {
            e.preventDefault();
            c.x = Math.max(0, Math.min(COLS - 1, c.x + moves[e.key][0]));
            c.y = Math.max(0, Math.min(this.core.rows - 1, c.y + moves[e.key][1]));
            this.repaint();
            return;
        }
        if (e.key === "Enter") {
            e.preventDefault();
            c.x = c.startX;
            c.y = Math.min(this.core.rows - 1, c.y + 1);
            this.repaint();
            return;
        }
        if (e.key === "Backspace") {
            e.preventDefault();
            if (c.x > 0) {
                c.x--;
                this.core.begin();
                this.core.setCell(c.x, c.y, 0x20, 7, 0, false);
                this.core.end();
                this.repaint();
            }
            return;
        }
        if (e.key === "Escape") { this.caret = null; this.repaint(); return; }
        if (e.key.length === 1 && !e.ctrlKey && !e.metaKey) {
            e.preventDefault();
            var cp = e.key.charCodeAt(0);
            if (this.core.rev[cp] === undefined) { return; }
            this.core.begin();
            this.core.pencil(c.x, c.y, cp, this.fg, this.bg);
            this.core.end();
            if (c.x < COLS - 1) { c.x++; }
            this.repaint();
        }
    };

    // ---- the font tool (1.5.5) -------------------------------------------

    EditorUI.prototype.fillFonts = function (userNames) {
        var sel = this.doc.getElementById("edfontsel");
        sel.innerHTML = "";
        for (var i = 0; i < this.builtins.length; i++) {
            var o = this.doc.createElement("option");
            o.value = "b:" + i;
            o.textContent = this.builtins[i].name;
            sel.appendChild(o);
        }
        for (var j = 0; j < userNames.length; j++) {
            var u = this.doc.createElement("option");
            u.value = "u:" + userNames[j];
            u.textContent = userNames[j] + " (yours)";
            sel.appendChild(u);
        }
        if (sel.querySelector('option[value="' + this.fontSel + '"]') === null) {
            this.fontSel = "b:0";
        }
        sel.value = this.fontSel;
    };

    /** The selected font, or null while a user font is still loading. */
    EditorUI.prototype.currentFont = function () {
        if (this.fontSel.indexOf("b:") === 0) {
            return this.builtins[parseInt(this.fontSel.slice(2), 10)] || this.builtins[0];
        }
        return this.userFonts[this.fontSel.slice(2)] || null;
    };

    /** Fetch a user font once; done() fires when it is usable. */
    EditorUI.prototype.ensureFont = function (selVal, done) {
        var self = this;
        if (selVal.indexOf("u:") !== 0) { done(); return; }
        var name = selVal.slice(2);
        if (this.userFonts[name]) { done(); return; }
        this.req({ action: "font_load", slot: name }, function (j) {
            var f = j.font || {};
            f.name = name;
            f.tint = false;      // sysop-drawn fonts stamp exactly as drawn
            f.builtin = false;
            self.userFonts[name] = f;
            done();
        });
    };

    EditorUI.prototype.fontKey = function (e) {
        var c = this.caret;
        var font = this.currentFont();
        if (e.key === "Escape") { this.caret = null; this.repaint(); return; }
        if (font === null) { this.status("font still loading"); return; }
        if (e.key === "Enter") {
            e.preventDefault();
            c.x = c.startX;
            c.y = Math.min(this.core.rows - 1, c.y + font.h + 1);
            this.repaint();
            return;
        }
        if (e.key === "Backspace") {
            e.preventDefault();
            c.x = Math.max(0, c.x - this.lastAdv);
            this.repaint();
            return;
        }
        if (e.key === " ") {
            e.preventDefault();
            c.x = Math.min(COLS - 1, c.x + 3);
            this.repaint();
            return;
        }
        if (e.key.length !== 1 || e.ctrlKey || e.metaKey) { return; }
        e.preventDefault();
        var ch = e.key.toUpperCase();
        if (FONT_CHARS.indexOf(ch) < 0) { this.status("A-Z and 0-9 only"); return; }
        var g = font.glyphs[ch];
        if (!g) { this.status("'" + ch + "' is not drawn in " + font.name); return; }
        if (c.x + g.w > COLS) {          // wrap like a typewriter
            c.x = c.startX;
            c.y = c.y + font.h + 1;
        }
        if (c.y + font.h > this.core.rows) { this.status("no room below — add rows"); return; }
        this.core.begin();
        stampGlyph(this.core, font, ch, c.x, c.y, this.fg);
        this.core.end();
        this.lastAdv = g.w + 1;
        c.x = Math.min(COLS - 1, c.x + g.w + 1);
        this.repaint();
        this.status();
    };

    // ---- files -----------------------------------------------------------

    EditorUI.prototype.req = function (fields, done) {
        var self = this;
        var data = new FormData();
        data.append("csrf", this.cfg.csrf);
        for (var k in fields) {
            if (Object.prototype.hasOwnProperty.call(fields, k)) { data.append(k, fields[k]); }
        }
        fetch("editor.php", { method: "POST", body: data, credentials: "same-origin" })
            .then(function (r) { return r.json(); })
            .then(function (j) {
                if (!j.ok) { alert(j.err || "That did not work."); return; }
                done(j);
            })
            .catch(function () { alert("The console did not answer - try again."); });
    };

    EditorUI.prototype.applyLoaded = function (j, slotName) {
        this.core.importText(j.text, j.ice);
        if (j.rows > 0) { this.core.resize(Math.max(this.core.rows, Math.min(MAX_ROWS, j.rows))); }
        this.setMeta(j.sauce || {});
        this.doc.getElementById("edice").checked = this.core.ice;
        this.doc.getElementById("edrows").value = this.core.rows;
        this.doc.getElementById("edslotname").value = slotName || "";
        this.slotLoaded = slotName || "";
        this.sel = null; this.caret = null; this.float = null;
        this.refreshPalettes();
        this.repaint();
        this.status(slotName ? "opened " + slotName : "imported");
    };

    EditorUI.prototype.open = function (slot) {
        var self = this;
        if (!slot) { return; }
        if (this.core.dirty && !confirm("Discard unsaved changes?")) { return; }
        this.req({ action: "load", slot: slot }, function (j) {
            self.applyLoaded(j, slot);
            self.clearDraft();
        });
    };

    EditorUI.prototype.save = function () {
        var self = this;
        var slot = this.doc.getElementById("edslotname").value.trim().toLowerCase();
        if (!/^[a-z0-9_-]{1,32}$/.test(slot)) {
            alert("Give it a piece name first: 1-32 chars, a-z 0-9 _ -");
            return;
        }
        var bytes = encodeAns(this.core, this.metaNow());
        this.req({ action: "save", slot: slot, bytes: btoa(bytesToBinary(bytes)) }, function (j) {
            self.core.dirty = false;
            self.slotLoaded = slot;
            self.fillSlots(j.slots || []);
            self.doc.getElementById("edslots").value = slot;
            self.clearDraft();
            self.status("saved " + slot + (self.core.rows > 25 ? " (board shows rows 1-25)" : ""));
        });
    };

    EditorUI.prototype.metaNow = function () {
        var d = new Date();
        var mm = String(d.getMonth() + 1).padStart(2, "0");
        var dd = String(d.getDate()).padStart(2, "0");
        return {
            title: this.core.sauce.title,
            author: this.core.sauce.author,
            group: this.core.sauce.group,
            date: d.getFullYear() + mm + dd
        };
    };

    EditorUI.prototype.exportAns = function () {
        var bytes = encodeAns(this.core, this.metaNow());
        var blob = new Blob([bytes], { type: "application/octet-stream" });
        var a = this.doc.createElement("a");
        a.href = URL.createObjectURL(blob);
        a.download = (this.doc.getElementById("edslotname").value.trim() || "art") + ".ans";
        this.doc.body.appendChild(a);
        a.click();
        a.remove();
        setTimeout(function () { URL.revokeObjectURL(a.href); }, 5000);
    };

    EditorUI.prototype.importFile = function (file) {
        var self = this;
        if (this.core.dirty && !confirm("Discard unsaved changes?")) { return; }
        var data = new FormData();
        data.append("csrf", this.cfg.csrf);
        data.append("action", "import");
        data.append("file", file);
        fetch("editor.php", { method: "POST", body: data, credentials: "same-origin" })
            .then(function (r) { return r.json(); })
            .then(function (j) {
                if (!j.ok) { alert(j.err || "Import failed."); return; }
                self.applyLoaded(j, "");
                self.core.dirty = true; // imported but not saved anywhere yet
            })
            .catch(function () { alert("The console did not answer - try again."); });
    };

    // ---- autosave drafts -------------------------------------------------

    EditorUI.prototype.draftKey = function () {
        return "atdt_ed_gallery_" + (this.slotLoaded || "untitled");
    };

    EditorUI.prototype.autosave = function () {
        if (!this.core.dirty) { return; }
        try {
            localStorage.setItem(this.draftKey(), JSON.stringify(this.core.toJson()));
            localStorage.setItem("atdt_ed_last", this.draftKey() + "|" + (this.slotLoaded || ""));
        } catch (e) { /* storage full: drafts are best-effort */ }
    };

    EditorUI.prototype.clearDraft = function () {
        try {
            localStorage.removeItem(this.draftKey());
            localStorage.removeItem("atdt_ed_last");
        } catch (e) { /* ignore */ }
    };

    EditorUI.prototype.recoverDraft = function () {
        try {
            var last = localStorage.getItem("atdt_ed_last");
            if (!last) { return; }
            var key = last.split("|")[0];
            var slot = last.split("|")[1] || "";
            if (key.indexOf("atdt_ed_gallery_") !== 0) { return; }
            var raw = localStorage.getItem(key);
            if (!raw) { return; }
            var j = JSON.parse(raw);
            this.core.fromJson(j);
            this.core.dirty = true;
            this.slotLoaded = slot;
            this.setMeta(this.core.sauce);
            this.doc.getElementById("edice").checked = this.core.ice;
            this.doc.getElementById("edrows").value = this.core.rows;
            this.doc.getElementById("edslotname").value = slot;
            this.doc.getElementById("edrecover").style.display = "";
            this.repaint();
        } catch (e) { /* corrupt draft: start clean */ }
    };

    // ---- the font library (1.5.5): draw your own letter set --------------

    EditorUI.prototype.wireFontLib = function () {
        var self = this;
        var $ = function (id) { return self.doc.getElementById(id); };
        $("edflopen").addEventListener("click", function () {
            var box = $("edfontlib");
            var opening = box.style.display === "none";
            box.style.display = opening ? "" : "none";
            self.flFillSel();
            if (opening && typeof box.scrollIntoView === "function") {
                // The panel lives below the canvas; without this it opened
                // out of sight and the button read as dead (1.5.5 report).
                box.scrollIntoView({ block: "start", behavior: "smooth" });
            }
        });
        $("edflclose").addEventListener("click", function () { $("edfontlib").style.display = "none"; });
        $("edflnew").addEventListener("click", function () {
            var name = $("edflname").value.trim().toLowerCase();
            if (!/^[a-z0-9_-]{1,24}$/.test(name)) {
                alert("Font names: 1-24 chars, a-z 0-9 _ -");
                return;
            }
            var h = Math.max(1, Math.min(FONT_MAX_H, parseInt($("edflh").value, 10) || 5));
            self.flStart(name, h, {});
        });
        $("edflload").addEventListener("click", function () {
            var name = $("edflsel").value;
            if (!name) { return; }
            self.req({ action: "font_load", slot: name }, function (j) {
                var f = j.font || { h: 5, glyphs: {} };
                $("edflh").value = f.h;
                $("edflname").value = name;
                self.flStart(name, f.h, f.glyphs || {});
            });
        });
        $("edfldelete").addEventListener("click", function () {
            var name = $("edflsel").value;
            if (!name) { return; }
            if (!confirm("Delete the font '" + name + "'? Art already stamped with it is unaffected.")) { return; }
            self.req({ action: "font_delete", slot: name }, function (j) {
                delete self.userFonts[name];
                self.fillFonts(j.fonts || []);
                self.flFillSel();
                if (self.fl && self.fl.name === name) {
                    self.fl = null;
                    $("edflwork").style.display = "none";
                }
            });
        });
        $("edflh").addEventListener("change", function () {
            if (!self.fl) { return; }
            var h = Math.max(1, Math.min(FONT_MAX_H, parseInt(this.value, 10) || self.fl.h));
            this.value = h;
            self.flResize(null, h);
        });
        $("edflw").addEventListener("change", function () {
            if (!self.fl) { return; }
            var w = Math.max(1, Math.min(FONT_MAX_W, parseInt(this.value, 10) || 5));
            this.value = w;
            self.flResize(w, null);
        });
        $("edflclearg").addEventListener("click", function () {
            var g = self.flGlyph();
            if (!g) { return; }
            for (var i = 0; i < g.cells.length; i++) { g.cells[i] = null; }
            self.flRepaint();
            self.flStrip();
        });
        $("edflsave").addEventListener("click", function () { self.flSave(); });

        var cv = $("edflcanvas");
        cv.addEventListener("contextmenu", function (e) { e.preventDefault(); });
        var paint = function (e) {
            var g = self.flGlyph();
            if (!g || !self.fl) { return; }
            var r = cv.getBoundingClientRect();
            var x = Math.floor((e.clientX - r.left) / r.width * FONT_MAX_W);
            var y = Math.floor((e.clientY - r.top) / r.height * self.fl.h);
            if (x < 0 || x >= g.w || y < 0 || y >= self.fl.h) { return; }
            g.cells[y * g.w + x] = (e.buttons === 2)
                ? null
                : R.pack(self.glyph, self.fg, self.core.ice ? self.bg : (self.bg & 7), !self.core.ice && self.bg >= 8);
            self.flRepaint();
        };
        var down = false;
        cv.addEventListener("mousedown", function (e) { e.preventDefault(); down = true; paint(e); });
        cv.addEventListener("mousemove", function (e) { if (down) { paint(e); } });
        this.doc.addEventListener("mouseup", function () { if (down) { down = false; self.flStrip(); } });
    };

    EditorUI.prototype.flFillSel = function () {
        var sel = this.doc.getElementById("edflsel");
        var names = [];
        var opts = this.doc.getElementById("edfontsel").options;
        for (var i = 0; i < opts.length; i++) {
            if (opts[i].value.indexOf("u:") === 0) { names.push(opts[i].value.slice(2)); }
        }
        sel.innerHTML = "";
        for (var j = 0; j < names.length; j++) {
            var o = this.doc.createElement("option");
            o.value = names[j];
            o.textContent = names[j];
            sel.appendChild(o);
        }
    };

    EditorUI.prototype.flStart = function (name, h, glyphs) {
        this.fl = { name: name, h: h, glyphs: glyphs, ch: "A" };
        this.doc.getElementById("edflwork").style.display = "";
        this.doc.getElementById("edflstatus").textContent = "drawing '" + name + "' — " + h + " rows tall";
        this.flStrip();
        this.flPick(this.fl.ch);
    };

    /** The working glyph record, or null. */
    EditorUI.prototype.flGlyph = function () {
        return this.fl ? (this.fl.glyphs[this.fl.ch] || null) : null;
    };

    EditorUI.prototype.flStrip = function () {
        var self = this;
        var row = this.doc.getElementById("edflglyphs");
        if (!this.fl) { row.innerHTML = ""; return; }
        row.innerHTML = "";
        for (var i = 0; i < FONT_CHARS.length; i++) {
            (function (ch) {
                var g = self.fl.glyphs[ch];
                var drawn = false;
                if (g) {
                    for (var k = 0; k < g.cells.length; k++) {
                        if (g.cells[k] !== null && g.cells[k] !== undefined) { drawn = true; break; }
                    }
                }
                var b = self.doc.createElement("button");
                b.className = "gl" + (self.fl.ch === ch ? " cur" : "") + (drawn ? "" : " dim");
                b.textContent = ch;
                b.addEventListener("click", function () { self.flPick(ch); });
                row.appendChild(b);
            })(FONT_CHARS.charAt(i));
        }
    };

    EditorUI.prototype.flPick = function (ch) {
        if (!this.fl) { return; }
        this.fl.ch = ch;
        if (!this.fl.glyphs[ch]) {
            var w = Math.max(1, Math.min(FONT_MAX_W, parseInt(this.doc.getElementById("edflw").value, 10) || 5));
            var cells = [];
            for (var i = 0; i < w * this.fl.h; i++) { cells.push(null); }
            this.fl.glyphs[ch] = { w: w, cells: cells };
        }
        this.doc.getElementById("edflw").value = this.fl.glyphs[ch].w;
        this.flStrip();
        this.flRepaint();
    };

    /** Crop/pad the working glyph (w) or every glyph (h). */
    EditorUI.prototype.flResize = function (newW, newH) {
        if (!this.fl) { return; }
        var self = this;
        var remap = function (g, w, h, oldH) {
            var cells = [];
            for (var y = 0; y < h; y++) {
                for (var x = 0; x < w; x++) {
                    cells.push(y < oldH && x < g.w ? (g.cells[y * g.w + x] !== undefined ? g.cells[y * g.w + x] : null) : null);
                }
            }
            g.w = w;
            g.cells = cells;
        };
        if (newW !== null) {
            var g = this.flGlyph();
            if (g) { remap(g, newW, this.fl.h, this.fl.h); }
        }
        if (newH !== null) {
            for (var ch in this.fl.glyphs) {
                if (Object.prototype.hasOwnProperty.call(this.fl.glyphs, ch)) {
                    remap(this.fl.glyphs[ch], this.fl.glyphs[ch].w, newH, this.fl.h);
                }
            }
            this.fl.h = newH;
            this.doc.getElementById("edflstatus").textContent = "drawing '" + this.fl.name + "' — " + newH + " rows tall";
        }
        this.flRepaint();
    };

    /** The mini canvas: the glyph grid, nulls dark, the width fence dim. */
    EditorUI.prototype.flRepaint = function () {
        var cv = this.doc.getElementById("edflcanvas");
        if (!cv || !this.fl) { return; }
        var g = this.flGlyph();
        if (!g) { return; }
        var h = this.fl.h;
        cv.width = FONT_MAX_W * R.CELL_W;
        cv.height = h * R.CELL_H;
        cv.style.width = (FONT_MAX_W * R.CELL_W * 2) + "px";
        cv.style.height = (h * R.CELL_H * 2) + "px";
        var ctx = cv.getContext("2d");
        ctx.textBaseline = "bottom";
        ctx.font = R.CELL_H + "px " + this.painter.family;
        for (var y = 0; y < h; y++) {
            for (var x = 0; x < FONT_MAX_W; x++) {
                var px = x * R.CELL_W, py = y * R.CELL_H;
                if (x >= g.w) {                       // beyond this glyph's width
                    ctx.fillStyle = "#0a0a0a";
                    ctx.fillRect(px, py, R.CELL_W, R.CELL_H);
                    continue;
                }
                var packed = g.cells[y * g.w + x];
                if (packed === null || packed === undefined) {
                    ctx.fillStyle = "#161616";        // transparent cell
                    ctx.fillRect(px, py, R.CELL_W, R.CELL_H);
                    continue;
                }
                var c = R.unpack(packed);
                ctx.fillStyle = R.PALETTE[c.bg];
                ctx.fillRect(px, py, R.CELL_W, R.CELL_H);
                if (c.ch !== 0x20 && c.ch !== 0) {
                    ctx.fillStyle = R.PALETTE[c.fg];
                    ctx.fillText(String.fromCharCode(c.ch), px, py + R.CELL_H);
                }
            }
        }
        ctx.strokeStyle = "rgba(255,255,255,0.15)";
        for (var gx = 1; gx <= g.w; gx++) {
            ctx.beginPath(); ctx.moveTo(gx * R.CELL_W + 0.5, 0); ctx.lineTo(gx * R.CELL_W + 0.5, cv.height); ctx.stroke();
        }
        for (var gy = 1; gy < h; gy++) {
            ctx.beginPath(); ctx.moveTo(0, gy * R.CELL_H + 0.5); ctx.lineTo(g.w * R.CELL_W, gy * R.CELL_H + 0.5); ctx.stroke();
        }
    };

    EditorUI.prototype.flSave = function () {
        var self = this;
        if (!this.fl) { return; }
        var out = { h: this.fl.h, glyphs: {} };
        var any = false;
        for (var ch in this.fl.glyphs) {
            if (!Object.prototype.hasOwnProperty.call(this.fl.glyphs, ch)) { continue; }
            var g = this.fl.glyphs[ch];
            var drawn = false;
            for (var i = 0; i < g.cells.length; i++) {
                if (g.cells[i] !== null && g.cells[i] !== undefined) { drawn = true; break; }
            }
            if (drawn) {
                out.glyphs[ch] = { w: g.w, cells: g.cells };
                any = true;
            }
        }
        if (!any) {
            alert("Draw at least one glyph first.");
            return;
        }
        this.req({ action: "font_save", slot: this.fl.name, font: JSON.stringify(out) }, function (j) {
            out.name = self.fl.name;
            out.tint = false;
            out.builtin = false;
            self.userFonts[self.fl.name] = out;
            self.fillFonts(j.fonts || []);
            self.flFillSel();
            self.doc.getElementById("edflstatus").textContent = "saved '" + self.fl.name + "'";
        });
    };

    /** Wire the console page together (admin/editor.php). */
    function boot(doc) {
        var cfgEl = doc.getElementById("ed-config");
        var cfg = cfgEl ? JSON.parse(cfgEl.textContent) : {};
        return new EditorUI(doc, cfg);
    }

    return {
        MAX_ROWS: MAX_ROWS,
        COLS: COLS,
        VGA_TO_SGR: VGA_TO_SGR,
        GLYPH_SETS: GLYPH_SETS,
        FONT_CHARS: FONT_CHARS,
        FONT_MAX_W: FONT_MAX_W,
        FONT_MAX_H: FONT_MAX_H,
        EditorCore: EditorCore,
        builtinFonts: builtinFonts,
        stampGlyph: stampGlyph,
        encodeAns: encodeAns,
        bytesToBinary: bytesToBinary,
        boot: boot
    };
})();

if (typeof module !== "undefined" && module.exports) {
    module.exports = AtdtEd;
}
