Ver código fonte

Add new Text Editor

Toby Chui 2 semanas atrás
pai
commit
e58cdd5889

+ 30 - 0
src/web/Text/filesaver.js

@@ -0,0 +1,30 @@
+/*
+    Text - FileSaver backend
+    Writes the editor content to a file on the server.
+
+    Required POST parameters:
+        filepath  - virtual path to the target file
+        content   - text content to write
+*/
+
+function main(){
+    if (!requirelib("filelib")){
+        sendJSONResp(JSON.stringify({ error: "filelib unavailable" }));
+        return;
+    }
+
+    if (!filepath || filepath.trim() === ""){
+        sendJSONResp(JSON.stringify({ error: "filepath is required" }));
+        return;
+    }
+
+    var ok = filelib.writeFile(filepath, content);
+    if (!ok){
+        sendJSONResp(JSON.stringify({ error: "unable to write file" }));
+        return;
+    }
+
+    sendResp("OK");
+}
+
+main();

BIN
src/web/Text/img/desktop_icon.png


BIN
src/web/Text/img/desktop_icon.psd


BIN
src/web/Text/img/module_icon.png


BIN
src/web/Text/img/module_icon.psd


+ 657 - 0
src/web/Text/index.html

@@ -0,0 +1,657 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+    <meta charset="UTF-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
+    <title>Text</title>
+    <script src="../script/jquery.min.js"></script>
+    <script src="../script/ao_module.js"></script>
+    <style>
+        *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
+
+        :root {
+            --toolbar-h:    40px;
+            --statusbar-h:  22px;
+            /* warm parchment chrome */
+            --bg:           #d0dced;
+            --toolbar-bg:   #f2f2f7;
+            --toolbar-bdr:  #e6e6e9;
+            /* near-white warm paper for the actual writing area */
+            --editor-bg:    #f7f7f2;
+            /* warm dark ink */
+            --text:         #2a2010;
+            --text2:        #747c9e;
+            /* amber accent */
+            --accent:       #1863c4;
+            --sep:          #a8c1cb;
+            --hover:        rgba(0,0,0,.06);
+            --btn-active-bg:#1863c4;
+            --btn-active-fg:#fff;
+            /* dirty/edited indicator uses the accent amber, not orange */
+            --dirty-color:  #1863c4;
+        }
+        body.dark {
+            --bg:           #141a24;
+            --toolbar-bg:   #1a2030;
+            --toolbar-bdr:  #28344a;
+            --editor-bg:    #111620;
+            --text:         #cdd8ee;
+            --text2:        #6070a0;
+            --accent:       #5b9cf6;
+            --sep:          #28344a;
+            --hover:        rgba(255,255,255,.06);
+            --btn-active-bg:#5b9cf6;
+            --btn-active-fg:#fff;
+            --dirty-color:  #5b9cf6;
+        }
+
+        html, body {
+            height: 100%;
+            background: var(--bg);
+            color: var(--text);
+            font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
+            font-size: 13px;
+        }
+
+        #app {
+            display: flex;
+            flex-direction: column;
+            height: 100vh;
+            overflow: hidden;
+        }
+
+        /* ── Toolbar ──────────────────────────────────────────────────────── */
+        #toolbar {
+            display: flex;
+            align-items: center;
+            gap: 2px;
+            flex-shrink: 0;
+            height: var(--toolbar-h);
+            padding: 0 8px;
+            background: var(--toolbar-bg);
+            border-bottom: 1px solid var(--toolbar-bdr);
+            box-shadow: 0 1px 4px rgba(0,0,0,.08);
+            overflow-x: auto;
+            overflow-y: hidden;
+            position: relative;
+            z-index: 1;
+        }
+        #toolbar::-webkit-scrollbar { height: 0; }
+
+        .tb-btn {
+            display: inline-flex;
+            align-items: center;
+            gap: 4px;
+            height: 26px;
+            padding: 0 8px;
+            border: none;
+            border-radius: 5px;
+            background: none;
+            color: var(--text);
+            font-size: 12px;
+            font-family: inherit;
+            cursor: pointer;
+            white-space: nowrap;
+            flex-shrink: 0;
+            transition: background .1s;
+            user-select: none;
+        }
+        .tb-btn:hover  { background: var(--hover); }
+        .tb-btn.active { background: var(--btn-active-bg); color: var(--btn-active-fg); }
+
+        .tb-sep {
+            width: 1px;
+            height: 18px;
+            background: var(--sep);
+            flex-shrink: 0;
+            margin: 0 3px;
+        }
+
+        .tb-label {
+            font-size: 11px;
+            color: var(--text2);
+            flex-shrink: 0;
+            white-space: nowrap;
+        }
+
+        .tb-select {
+            height: 24px;
+            padding: 0 6px;
+            border: 1px solid var(--toolbar-bdr);
+            border-radius: 5px;
+            background: var(--bg);
+            color: var(--text);
+            font-size: 12px;
+            font-family: inherit;
+            outline: none;
+            cursor: pointer;
+            flex-shrink: 0;
+            transition: border-color .1s;
+        }
+        .tb-select:focus { border-color: var(--accent); }
+
+        .tb-size-wrap {
+            display: inline-flex;
+            align-items: center;
+            gap: 1px;
+            flex-shrink: 0;
+        }
+        .tb-size-val {
+            min-width: 26px;
+            text-align: center;
+            font-size: 12px;
+            color: var(--text);
+            user-select: none;
+        }
+        .tb-size-btn {
+            width: 22px;
+            height: 22px;
+            border: none;
+            border-radius: 4px;
+            background: none;
+            color: var(--text);
+            font-size: 15px;
+            line-height: 1;
+            cursor: pointer;
+            display: flex;
+            align-items: center;
+            justify-content: center;
+            flex-shrink: 0;
+            transition: background .1s;
+        }
+        .tb-size-btn:hover { background: var(--hover); }
+
+        /* ── Editor ───────────────────────────────────────────────────────── */
+        #editor {
+            flex: 1;
+            width: 100%;
+            min-height: 0;
+            padding: 28px 10%;
+            border: none;
+            outline: none;
+            resize: none;
+            background: var(--editor-bg);
+            color: var(--text);
+            font-size: 14px;
+            font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
+            line-height: 1.6;
+            tab-size: 4;
+            -moz-tab-size: 4;
+            overflow-y: auto;
+            transition: background .2s, color .2s;
+            box-shadow: inset 0 2px 6px rgba(0,0,0,.04);
+        }
+        #editor::placeholder { color: var(--text2); }
+
+        /* ── Status bar ───────────────────────────────────────────────────── */
+        #statusbar {
+            flex-shrink: 0;
+            height: var(--statusbar-h);
+            display: flex;
+            align-items: center;
+            justify-content: space-between;
+            padding: 0 10px;
+            background: var(--toolbar-bg);
+            border-top: 1px solid var(--toolbar-bdr);
+            font-size: 10px;
+            color: var(--text2);
+        }
+        #status-msg.dirty { color: var(--dirty-color); }
+        #status-msg.error { color: #c0392b; }
+
+        /* ── Confirm overlay (in-iframe popup) ────────────────────────────── */
+        #confirm-overlay {
+            display: none;
+            position: fixed;
+            inset: 0;
+            background: rgba(0,0,0,.35);
+            backdrop-filter: blur(4px);
+            -webkit-backdrop-filter: blur(4px);
+            z-index: 500;
+            align-items: center;
+            justify-content: center;
+        }
+        #confirm-overlay.show { display: flex; }
+
+        #confirm-box {
+            background: var(--editor-bg);
+            border: 1px solid var(--toolbar-bdr);
+            border-radius: 13px;
+            padding: 24px 24px 18px;
+            width: 300px;
+            box-shadow: 0 16px 48px rgba(0,0,0,.25), 0 2px 8px rgba(0,0,0,.12);
+        }
+        #confirm-box h3 {
+            font-size: 14px;
+            font-weight: 700;
+            color: var(--text);
+            margin-bottom: 8px;
+        }
+        #confirm-box p {
+            font-size: 12px;
+            color: var(--text2);
+            line-height: 1.55;
+            margin-bottom: 18px;
+        }
+        .confirm-row {
+            display: flex;
+            gap: 8px;
+            justify-content: flex-end;
+        }
+        .cbtn {
+            padding: 6px 14px;
+            border-radius: 6px;
+            font-size: 12px;
+            font-weight: 600;
+            font-family: inherit;
+            cursor: pointer;
+            border: none;
+            transition: opacity .1s, background .1s;
+        }
+        .cbtn:hover { opacity: .85; }
+        .cbtn-cancel  { background: var(--hover); color: var(--text); border: 1px solid var(--toolbar-bdr); }
+        .cbtn-discard { background: rgba(231,76,60,.13); color: #e74c3c; border: 1px solid rgba(231,76,60,.3); }
+        .cbtn-save    { background: var(--accent); color: #fff; }
+    </style>
+</head>
+<body>
+<div id="app">
+
+    <!-- ── Toolbar ──────────────────────────────────────────────────────── -->
+    <div id="toolbar">
+
+        <!-- File operations -->
+        <button class="tb-btn" onclick="openFile()" title="Open file">
+            <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
+                <path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>
+            </svg>
+            Open
+        </button>
+        <button class="tb-btn" onclick="saveFile()" title="Save  Ctrl+S">
+            <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
+                <path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/>
+                <polyline points="17 21 17 13 7 13 7 21"/>
+                <polyline points="7 3 7 8 15 8"/>
+            </svg>
+            Save
+        </button>
+
+        <div class="tb-sep"></div>
+
+        <!-- Font family -->
+        <span class="tb-label">Font</span>
+        <select id="sel-font" class="tb-select" style="width:134px;" onchange="applyFont()" title="Font family">
+            <option value="system">System Default</option>
+            <option value="arial">Arial</option>
+            <option value="times">Times New Roman</option>
+            <option value="courier">Courier New</option>
+            <option value="georgia">Georgia</option>
+            <option value="verdana">Verdana</option>
+            <option value="trebuchet">Trebuchet MS</option>
+            <option value="comic">Comic Sans MS</option>
+        </select>
+
+        <div class="tb-sep"></div>
+
+        <!-- Font size -->
+        <span class="tb-label">Size</span>
+        <div class="tb-size-wrap">
+            <button class="tb-size-btn" onclick="changeFontSize(-1)" title="Decrease font size">−</button>
+            <span class="tb-size-val" id="font-size-lbl">14</span>
+            <button class="tb-size-btn" onclick="changeFontSize(+1)" title="Increase font size">+</button>
+        </div>
+
+        <div class="tb-sep"></div>
+
+        <!-- Bold -->
+        <button class="tb-btn" id="bold-btn" onclick="toggleBold()" title="Bold">
+            <strong style="font-size:13px;">B</strong>
+        </button>
+
+        <div class="tb-sep"></div>
+
+        <!-- Line height -->
+        <span class="tb-label">Line</span>
+        <select id="sel-lh" class="tb-select" style="width:84px;" onchange="applyLineHeight()" title="Line spacing">
+            <option value="1.2">Compact</option>
+            <option value="1.5">Normal</option>
+            <option value="1.6" selected>Relaxed</option>
+            <option value="2.0">Double</option>
+        </select>
+
+        <div class="tb-sep"></div>
+
+        <!-- Theme toggle -->
+        <button class="tb-btn" id="theme-btn" onclick="toggleTheme()" title="Toggle dark / light theme">
+            <svg id="icon-sun" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
+                <circle cx="12" cy="12" r="5"/>
+                <line x1="12" y1="1"  x2="12" y2="3"/>
+                <line x1="12" y1="21" x2="12" y2="23"/>
+                <line x1="4.22" y1="4.22"   x2="5.64"  y2="5.64"/>
+                <line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/>
+                <line x1="1"  y1="12" x2="3"  y2="12"/>
+                <line x1="21" y1="12" x2="23" y2="12"/>
+                <line x1="4.22"  y1="19.78" x2="5.64"  y2="18.36"/>
+                <line x1="18.36" y1="5.64"  x2="19.78" y2="4.22"/>
+            </svg>
+            <svg id="icon-moon" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="display:none;">
+                <path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>
+            </svg>
+            <span id="theme-lbl">Dark</span>
+        </button>
+
+    </div>
+
+    <!-- ── Editor ───────────────────────────────────────────────────────── -->
+    <textarea id="editor" placeholder="Start typing…" spellcheck="false"></textarea>
+
+    <!-- ── Status bar ───────────────────────────────────────────────────── -->
+    <div id="statusbar">
+        <span id="status-msg">Ready</span>
+        <span id="stat-count">0 chars · 1 line</span>
+    </div>
+
+</div><!-- /#app -->
+
+<!-- ── Unsaved-changes dialog (in-iframe popup) ──────────────────────────── -->
+<div id="confirm-overlay">
+    <div id="confirm-box">
+        <h3>Unsaved Changes</h3>
+        <p id="confirm-msg">Your changes haven't been saved. Save before closing?</p>
+        <div class="confirm-row">
+            <button class="cbtn cbtn-cancel"  onclick="dlgCancel()">Cancel</button>
+            <button class="cbtn cbtn-discard" onclick="dlgDiscard()">Discard</button>
+            <button class="cbtn cbtn-save"    onclick="dlgSave()">Save</button>
+        </div>
+    </div>
+</div>
+
+<script>
+// ── State ─────────────────────────────────────────────────────────────────
+var filepath             = "";
+var filename             = "";
+var lastSavedContent     = "";
+var isDark               = false;
+var fontSize             = 14;
+var isBold               = false;
+var dlgCallback          = null;
+var pendingCloseCallback = null;  // used by handleDlgSaveAs to survive the async picker
+
+// Font family map
+var fontMap = {
+    system:   "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif",
+    arial:    "Arial, sans-serif",
+    times:    "'Times New Roman', Times, serif",
+    courier:  "'Courier New', Courier, monospace",
+    georgia:  "Georgia, serif",
+    verdana:  "Verdana, Geneva, sans-serif",
+    trebuchet:"'Trebuchet MS', sans-serif",
+    comic:    "'Comic Sans MS', cursive"
+};
+
+// ── Init ──────────────────────────────────────────────────────────────────
+$(function(){
+    // Restore font size from previous session
+    var savedSize = parseInt(localStorage.getItem("text_fontSize"));
+    if (savedSize && savedSize >= 8 && savedSize <= 72){
+        fontSize = savedSize;
+        $("#font-size-lbl").text(fontSize);
+        $("#editor").css("font-size", fontSize + "px");
+    }
+
+    var files = ao_module_loadInputFiles();
+    if (files && files.length > 0){
+        filepath = files[0].filepath;
+        filename = files[0].filename;
+        loadFile();
+    } else {
+        updateTitle();
+    }
+
+    // Ctrl+S to save
+    $(document).on("keydown", function(e){
+        if (e.ctrlKey && e.key === "s"){
+            e.preventDefault();
+            saveFile();
+        }
+    });
+
+    // Live updates on every keystroke
+    $("#editor").on("input", function(){
+        updateStatBar();
+        updateTitle();
+    });
+
+    updateStatBar();
+});
+
+// ── File loading ──────────────────────────────────────────────────────────
+function loadFile(){
+    $.get(ao_root + "media?file=" + encodeURIComponent(filepath) + "&t=" + Date.now(), function(data){
+        if (typeof data !== "string") data = JSON.stringify(data, null, 2);
+        $("#editor").val(data);
+        lastSavedContent = data;
+        updateTitle();
+        updateStatBar();
+    }).fail(function(){
+        setStatus("Failed to load file", "error");
+    });
+}
+
+// ── Named global callbacks for ao_module_openFileSelector ─────────────────
+// ao_module_openFileSelector resolves the callback by function.name in VDI
+// mode, so all callbacks must be named top-level functions on window.
+
+// Called after the user picks a Save-As path from the toolbar Save button
+function handleSaveAs(filedata){
+    if (!filedata || !filedata.length) return;
+    filepath = filedata[0].filepath;
+    filename = filedata[0].filename;
+    doSave();
+}
+
+// Called after the user picks a file from the toolbar Open button
+function handleOpenFile(filedata){
+    if (!filedata || !filedata.length) return;
+    filepath = filedata[0].filepath;
+    filename = filedata[0].filename;
+    lastSavedContent = "";  // don't treat freshly loaded content as dirty
+    loadFile();
+}
+
+// Called after the user picks a Save-As path from the close-confirmation dialog
+function handleDlgSaveAs(filedata){
+    if (!filedata || !filedata.length){
+        pendingCloseCallback = null;
+        return;
+    }
+    filepath = filedata[0].filepath;
+    filename = filedata[0].filename;
+    doSave(function(){
+        if (pendingCloseCallback){
+            pendingCloseCallback("saved");
+            pendingCloseCallback = null;
+        }
+    });
+}
+
+// ── Saving ────────────────────────────────────────────────────────────────
+function saveFile(){
+    if (!filepath){
+        ao_module_openFileSelector(handleSaveAs, "user:/Desktop", "new", false, { defaultName: "Untitled.txt" });
+        return;
+    }
+    doSave();
+}
+
+function doSave(callback){
+    var content = $("#editor").val();
+    ao_module_agirun("Text/filesaver.js", {
+        filepath: filepath,
+        content:  content
+    }, function(data){
+        if (data.error){
+            setStatus("Save failed: " + data.error, "error");
+        } else {
+            lastSavedContent = content;
+            updateTitle();
+            setStatus("Saved");
+            if (callback) callback();
+        }
+    }, function(){
+        setStatus("Save failed", "error");
+    });
+}
+
+// ── Opening a file from toolbar ───────────────────────────────────────────
+function openFile(){
+    ao_module_openFileSelector(handleOpenFile, "user:/", "file", false, {
+        filter: ["txt","md","csv","log","ini","conf","json","xml","html","css","js","py","sh","yaml","yml"]
+    });
+}
+
+// ── Title management ──────────────────────────────────────────────────────
+function updateTitle(){
+    var title;
+    if (!filepath){
+        title = "Untitled";
+    } else if (isDirty()){
+        title = filename + " - Edited";
+    } else {
+        title = filename;
+    }
+    ao_module_setWindowTitle(title);
+    document.title = title;
+
+    if (isDirty()){
+        setStatus("Unsaved changes", "dirty");
+    } else if (filepath){
+        setStatus("Saved");
+    } else {
+        setStatus("Ready");
+    }
+}
+
+function isDirty(){
+    return $("#editor").val() !== lastSavedContent;
+}
+
+// ── Formatting ────────────────────────────────────────────────────────────
+function applyFont(){
+    var key = $("#sel-font").val();
+    $("#editor").css("font-family", fontMap[key] || fontMap.system);
+}
+
+function changeFontSize(delta){
+    fontSize = Math.max(8, Math.min(72, fontSize + delta));
+    $("#font-size-lbl").text(fontSize);
+    $("#editor").css("font-size", fontSize + "px");
+    localStorage.setItem("text_fontSize", fontSize);
+}
+
+function toggleBold(){
+    isBold = !isBold;
+    $("#editor").css("font-weight", isBold ? "700" : "normal");
+    $("#bold-btn").toggleClass("active", isBold);
+}
+
+function applyLineHeight(){
+    $("#editor").css("line-height", $("#sel-lh").val());
+}
+
+function toggleTheme(){
+    isDark = !isDark;
+    $("body").toggleClass("dark", isDark);
+    if (isDark){
+        $("#icon-sun").hide(); $("#icon-moon").show();
+        $("#theme-lbl").text("Light");
+    } else {
+        $("#icon-sun").show(); $("#icon-moon").hide();
+        $("#theme-lbl").text("Dark");
+    }
+}
+
+// ── Status bar ────────────────────────────────────────────────────────────
+function setStatus(msg, cls){
+    var el = $("#status-msg");
+    el.text(msg).attr("class", cls || "");
+    if (cls === "error"){
+        setTimeout(function(){ updateTitle(); }, 3000);
+    }
+}
+
+function updateStatBar(){
+    var txt   = $("#editor").val();
+    var chars = txt.length;
+    var lines = txt === "" ? 1 : txt.split("\n").length;
+    $("#stat-count").text(
+        chars + " char" + (chars !== 1 ? "s" : "") +
+        " · " + lines + " line" + (lines !== 1 ? "s" : "")
+    );
+}
+
+// ── Confirm dialog ────────────────────────────────────────────────────────
+function showDialog(callback){
+    dlgCallback = callback;
+    $("#confirm-overlay").addClass("show");
+}
+
+function hideDialog(){
+    $("#confirm-overlay").removeClass("show");
+}
+
+function dlgCancel(){
+    var cb = dlgCallback;
+    hideDialog();
+    dlgCallback = null;
+    if (cb) cb("cancel");
+}
+
+function dlgDiscard(){
+    var cb = dlgCallback;
+    hideDialog();
+    dlgCallback = null;
+    if (cb) cb("discard");
+}
+
+function dlgSave(){
+    pendingCloseCallback = dlgCallback;  // preserve across the async picker
+    dlgCallback = null;
+    hideDialog();
+
+    if (!filepath){
+        // New unsaved file — open picker first; result handled by handleDlgSaveAs
+        ao_module_openFileSelector(handleDlgSaveAs, "user:/Desktop", "new", false, { defaultName: "Untitled.txt" });
+    } else {
+        doSave(function(){
+            if (pendingCloseCallback){
+                pendingCloseCallback("saved");
+                pendingCloseCallback = null;
+            }
+        });
+    }
+}
+
+// ── Close handler override ────────────────────────────────────────────────
+function ao_module_close(){
+    if (!isDirty()){
+        ao_module_closeHandler();
+        return;
+    }
+    showDialog(function(result){
+        if (result === "saved" || result === "discard"){
+            ao_module_closeHandler();
+        }
+        // "cancel" → stay open, do nothing
+    });
+}
+
+// Non-VDI fallback (standalone browser tab)
+if (!ao_module_virtualDesktop){
+    window.onbeforeunload = function(){
+        if (isDirty()) return "You have unsaved changes. Leave anyway?";
+    };
+}
+</script>
+</body>
+</html>

+ 20 - 0
src/web/Text/init.agi

@@ -0,0 +1,20 @@
+/*
+    Text - Plain text editor for ArozOS
+    A simple TextEdit-style editor for plain text files.
+*/
+
+var moduleLaunchInfo = {
+    Name: "Text",
+    Desc: "Plain text editor",
+    Group: "Utilities",
+    IconPath: "Text/img/module_icon.png",
+    Version: "1.0",
+    StartDir: "Text/index.html",
+    SupportFW: true,
+    LaunchFWDir: "Text/index.html",
+    SupportEmb: false,
+    InitFWSize: [720, 520],
+    SupportedExt: [".txt", ".md", ".csv", ".log", ".ini", ".conf"]
+}
+
+registerModule(JSON.stringify(moduleLaunchInfo));