Kaynağa Gözat

Add cross app copy

Toby Chui 2 ay önce
ebeveyn
işleme
87382ce876

+ 35 - 1
src/web/Office/common/CONTRACT.md

@@ -42,6 +42,7 @@ Apps are registered in `Office/init.agi` (already done — do not edit it).
     <script src="../common/hotkeys.js"></script>
     <script src="../common/hotkeys.js"></script>
     <script src="../common/office.js"></script>
     <script src="../common/office.js"></script>
     <script src="../common/colorpicker.js"></script>
     <script src="../common/colorpicker.js"></script>
+    <script src="../common/clipboard.js"></script>
     <!-- optional: ../common/charts.js, ../common/textedit.js,
     <!-- optional: ../common/charts.js, ../common/textedit.js,
          ../common/lib/marked.min.js, ../common/lib/pdf-lib.min.js,
          ../common/lib/marked.min.js, ../common/lib/pdf-lib.min.js,
          ../common/lib/html2canvas.min.js -->
          ../common/lib/html2canvas.min.js -->
@@ -232,7 +233,40 @@ use `OfficeApp.mediaUrl(vpath)` for storage picks and
 via the system upload endpoint). The packer embeds both forms at save
 via the system upload endpoint). The packer embeds both forms at save
 time. The framework also writes rolling session snapshots to
 time. The framework also writes rolling session snapshots to
 `user:/.appdata/Office/session/<app>.osession` (autosave tick + after
 `user:/.appdata/Office/session/<app>.osession` (autosave tick + after
-save) and offers "Restore from previous session" on blank startup.
+save) and offers "Restore from previous session" on blank startup. That
+dialog's **Discard** button deletes the snapshot (container.agi
+`session-delete`) so it stops prompting; **Start fresh** keeps it for a
+later launch. The framework also intercepts the floatWindow close button
+(overriding `ao_module_close`) to confirm before discarding unsaved
+changes (Cancel / Close without saving / Save & close).
+
+## OfficeClipboard (common/clipboard.js) — cross-app copy/paste
+
+Each app keeps a high-fidelity **text/plain** clipboard format (Slides
+object JSON, Sheets TSV / chart-marker JSON). To move content *between*
+apps, on copy also write a shared **text/html** snapshot, and on paste
+consume it only after your own text/plain marker is absent.
+
+```js
+OfficeClipboard.imageHtml(src, w, h)     // "<img ...>"
+OfficeClipboard.tableHtml(rows, {headerRow})   // rows: [[cellHtml,...]]
+OfficeClipboard.svgImageSrc(svg)         // rasterizable SVG -> data: URL
+OfficeClipboard.parse(html)  // -> {images:[{src,w,h}], tables:[[[cellEl]]],
+                             //     text, html, hasContent}
+OfficeClipboard.isMarker(text)   // true = another app's raw marker JSON;
+                                 // never insert it as plain text
+OfficeClipboard.writeAsync({html, text})  // menu-driven copies (no event)
+```
+
+Copy pattern (in a `copy`/`cut` event handler): set BOTH
+`e.clipboardData.setData("text/plain", myMarker)` and
+`setData("text/html", OfficeClipboard.imageHtml/tableHtml(...))`, then
+`preventDefault()`. Paste pattern: honour your own marker first; else
+`OfficeClipboard.parse(getData("text/html"))` and place images/tables/
+text; guard the plain-text fallback with `!OfficeClipboard.isMarker(t)`
+so a foreign marker never lands as literal JSON. Media picks stay as
+`media?file=` links — Docs and Slides sit at the same `Office/<app>/`
+depth, so the relative URL resolves in both.
 
 
 ## OfficeColorPicker (common/colorpicker.js) — shared color picker
 ## OfficeColorPicker (common/colorpicker.js) — shared color picker
 
 

+ 30 - 6
src/web/Office/common/backend/container.agi

@@ -20,13 +20,15 @@
 
 
     POST parameters:
     POST parameters:
         action = "save" | "load" | "prepare" | "session-save" |
         action = "save" | "load" | "prepare" | "session-save" |
-                 "session-load"
+                 "session-load" | "session-delete"
 
 
-        save:          filepath, content(envelope JSON)      -> "OK"
-        load:          filepath                              -> {"envelope": {...}}
-        prepare:       (none)                                -> "OK" (workdir exists)
-        session-save:  app, content(envelope JSON)           -> "OK"
-        session-load:  app                                   -> {"envelope": {...}} or {"none": true}
+        save:           filepath, content(envelope JSON)     -> "OK"
+        load:           filepath                             -> {"envelope": {...}}
+        prepare:        (none)                               -> "OK" (workdir exists)
+        session-save:   app, content(envelope JSON)          -> "OK"
+        session-load:   app                                  -> {"envelope": {...}} or {"none": true}
+        session-delete: app  (drop the snapshot so it stops  -> "OK"
+                              prompting on next launch)
 
 
     Legacy plain-JSON documents load transparently and are upgraded to the
     Legacy plain-JSON documents load transparently and are upgraded to the
     container format on their next save. When the office lib is unavailable
     container format on their next save. When the office lib is unavailable
@@ -179,6 +181,28 @@ function main(){
         return;
         return;
     }
     }
 
 
+    if (action == "session-delete"){
+        if (typeof app === "undefined"){
+            sendJSONResp(JSON.stringify({ error: "app is required" }));
+            return;
+        }
+        if (!requirelib("filelib")){
+            sendJSONResp(JSON.stringify({ error: "filelib unavailable" }));
+            return;
+        }
+        try {
+            var target = WORKDIR + "/session/" + safeAppName(app) + ".osession";
+            //deleting a non-existent snapshot is a no-op success
+            if (filelib.fileExists(target)){
+                filelib.deleteFile(target);
+            }
+            sendResp("OK");
+        } catch (e) {
+            sendJSONResp(JSON.stringify({ error: "session delete failed: " + e.message }));
+        }
+        return;
+    }
+
     sendJSONResp(JSON.stringify({ error: "unknown action: " + action }));
     sendJSONResp(JSON.stringify({ error: "unknown action: " + action }));
 }
 }
 
 

+ 140 - 0
src/web/Office/common/clipboard.js

@@ -0,0 +1,140 @@
+/*
+    ArozOS Office - cross-app clipboard bridge (OfficeClipboard)
+    =============================================================
+    Each Office app keeps its own high-fidelity clipboard format in
+    text/plain (Slides object JSON, Sheets TSV / chart JSON). This helper
+    adds a SHARED text/html representation on copy and parses it on paste,
+    so content moves between apps (and to external editors):
+
+        Docs picture      -> Slides / Sheets(*)   as <img>
+        Sheets chart       -> Slides / Docs         as <img> (SVG snapshot)
+        Sheets cells       -> Slides / Docs         as <table>
+        Slides image/text/ -> Docs / Sheets         as <img> / <div> /
+              table/shape                            <table> / <img>
+
+    (*) Sheets has no floating-image model, so an image pasted into a cell
+        is declined with a hint rather than mangled.
+
+    On paste an app first honours its OWN text/plain marker (same-app,
+    full fidelity); only when that is absent does it fall back to the
+    shared text/html here. isMarker() lets an app avoid dumping another
+    app's raw JSON marker into itself as plain text.
+*/
+
+var OfficeClipboard = (function () {
+    function escAttr(s) {
+        return String(s == null ? "" : s)
+            .replace(/&/g, "&amp;").replace(/"/g, "&quot;")
+            .replace(/</g, "&lt;").replace(/>/g, "&gt;");
+    }
+
+    /* ---------- builders (copy side) ---------- */
+    function imageHtml(src, w, h) {
+        var dim = "";
+        if (w) dim += ' width="' + Math.round(w) + '"';
+        if (h) dim += ' height="' + Math.round(h) + '"';
+        return '<img src="' + escAttr(src) + '"' + dim + ">";
+    }
+    // rows: array of rows; each cell is an HTML string (caller pre-sanitizes)
+    function tableHtml(rows, opts) {
+        opts = opts || {};
+        var out = '<table style="border-collapse:collapse;">';
+        rows.forEach(function (row, ri) {
+            out += "<tr>";
+            row.forEach(function (cell) {
+                var tag = (opts.headerRow && ri === 0) ? "th" : "td";
+                out += "<" + tag + ' style="border:1px solid #b0b4bb;padding:3px 7px;">' +
+                    (cell == null || cell === "" ? "<br>" : cell) + "</" + tag + ">";
+            });
+            out += "</tr>";
+        });
+        return out + "</table>";
+    }
+    // rasterizable SVG -> an <img>-ready data URL
+    function svgImageSrc(svg) {
+        // guarantee the namespace so the data URL renders as an image
+        if (svg.indexOf("xmlns") < 0) {
+            svg = svg.replace("<svg", '<svg xmlns="http://www.w3.org/2000/svg"');
+        }
+        return "data:image/svg+xml;charset=utf-8," + encodeURIComponent(svg);
+    }
+
+    /* ---------- parser (paste side) ---------- */
+    // returns { images:[{src,w,h}], tables:[[[cellEl,...],...]], text, html,
+    //           hasContent }
+    function parse(html) {
+        var res = { images: [], tables: [], text: "", html: "", hasContent: false };
+        if (!html) return res;
+        var doc;
+        try {
+            doc = new DOMParser().parseFromString(String(html), "text/html");
+        } catch (e) { return res; }
+        var body = doc.body;
+        if (!body) return res;
+
+        var imgs = body.querySelectorAll("img[src]");
+        for (var i = 0; i < imgs.length; i++) {
+            var src = imgs[i].getAttribute("src");
+            if (!src) continue;
+            res.images.push({
+                src: src,
+                w: parseInt(imgs[i].getAttribute("width"), 10) || 0,
+                h: parseInt(imgs[i].getAttribute("height"), 10) || 0
+            });
+        }
+        var tables = body.querySelectorAll("table");
+        for (var t = 0; t < tables.length; t++) {
+            var rows = [];
+            var trs = tables[t].querySelectorAll("tr");
+            for (var r = 0; r < trs.length; r++) {
+                var cells = trs[r].querySelectorAll("td,th");
+                if (!cells.length) continue;
+                var row = [];
+                for (var c = 0; c < cells.length; c++) row.push(cells[c]);
+                rows.push(row);
+            }
+            if (rows.length) res.tables.push(rows);
+        }
+        res.html = body.innerHTML;
+        res.text = body.textContent || "";
+        res.hasContent = !!(res.images.length || res.tables.length || res.text.replace(/\s/g, ""));
+        return res;
+    }
+
+    // is this text/plain another app's raw marker JSON (never real text)?
+    function isMarker(text) {
+        return typeof text === "string" &&
+            /^\s*\{\s*"app"\s*:\s*"arozos-(slides-objects|sheets-chart)"/.test(text);
+    }
+
+    /* ---------- async write (menu-driven copies) ----------
+       Ctrl+C uses the synchronous copy event (setData on both types); menu
+       actions have no event, so write both MIME types via the async API
+       when the browser supports multi-type ClipboardItem. */
+    function writeAsync(parts) {
+        var text = parts.text || "";
+        if (parts.html && window.ClipboardItem &&
+            navigator.clipboard && navigator.clipboard.write) {
+            try {
+                var data = {
+                    "text/html": new Blob([parts.html], { type: "text/html" }),
+                    "text/plain": new Blob([text], { type: "text/plain" })
+                };
+                return navigator.clipboard.write([new window.ClipboardItem(data)]);
+            } catch (e) { /* fall through to text-only */ }
+        }
+        if (navigator.clipboard && navigator.clipboard.writeText) {
+            return navigator.clipboard.writeText(text);
+        }
+        return Promise.reject(new Error("clipboard unavailable"));
+    }
+
+    return {
+        imageHtml: imageHtml,
+        tableHtml: tableHtml,
+        svgImageSrc: svgImageSrc,
+        parse: parse,
+        isMarker: isMarker,
+        writeAsync: writeAsync
+    };
+})();

+ 17 - 0
src/web/Office/common/office.css

@@ -309,6 +309,7 @@ body.of-app {
     flex: 1 1 auto;
     flex: 1 1 auto;
 }
 }
 .of-dialog-body label { display: block; margin: 8px 0 4px; color: var(--of-fg-soft); font-size: 13px; }
 .of-dialog-body label { display: block; margin: 8px 0 4px; color: var(--of-fg-soft); font-size: 13px; }
+.of-dim { color: var(--of-fg-soft); font-size: 12px; }
 .of-dialog-body input[type="text"], .of-dialog-body input[type="number"], .of-dialog-body select, .of-dialog-body textarea {
 .of-dialog-body input[type="text"], .of-dialog-body input[type="number"], .of-dialog-body select, .of-dialog-body textarea {
     width: 100%;
     width: 100%;
     box-sizing: border-box;
     box-sizing: border-box;
@@ -472,6 +473,22 @@ body.of-app {
     background: #202124;
     background: #202124;
     pointer-events: none;
     pointer-events: none;
 }
 }
+/* split colour control: swatch button (applies current) + caret (picker) */
+.of-te-split { display: inline-flex; align-items: stretch; }
+.of-te-split .of-te-cmain {
+    border-top-right-radius: 0;
+    border-bottom-right-radius: 0;
+}
+.of-te-ccaret {
+    width: 13px;
+    min-width: 13px;
+    padding: 0;
+    border-top-left-radius: 0;
+    border-bottom-left-radius: 0;
+    border-left: 1px solid var(--of-border);
+    color: var(--of-fg-soft);
+}
+.of-te-ccaret i.icon { font-size: 9px !important; }
 
 
 /* ============ Keyboard shortcuts help (hotkeys.js) ============ */
 /* ============ Keyboard shortcuts help (hotkeys.js) ============ */
 .of-hk-help { max-height: 60vh; overflow-y: auto; }
 .of-hk-help { max-height: 60vh; overflow-y: auto; }

+ 79 - 17
src/web/Office/common/office.js

@@ -243,6 +243,14 @@ var OfficeApp = (function () {
             content: JSON.stringify(env)
             content: JSON.stringify(env)
         }, function () { }, function () { }, 60000);
         }, function () { }, function () { }, 60000);
     }
     }
+    // drop the saved session snapshot so it stops prompting on next launch
+    function deleteSession() {
+        if (!cfg || !cfg.packed) return;
+        ao_module_agirun(CONTAINER_BACKEND, {
+            action: "session-delete",
+            app: cfg.appType
+        }, function () { }, function () { }, 60000);
+    }
     function trySessionRestore() {
     function trySessionRestore() {
         ao_module_agirun(CONTAINER_BACKEND, {
         ao_module_agirun(CONTAINER_BACKEND, {
             action: "session-load",
             action: "session-load",
@@ -259,24 +267,40 @@ var OfficeApp = (function () {
             var m = env.meta || {};
             var m = env.meta || {};
             var when = m._sessionAt ? new Date(m._sessionAt).toLocaleString() : "an earlier session";
             var when = m._sessionAt ? new Date(m._sessionAt).toLocaleString() : "an earlier session";
             var what = m._origin && m._origin.fn ? escapeHtml(m._origin.fn) : "an unsaved document";
             var what = m._origin && m._origin.fn ? escapeHtml(m._origin.fn) : "an unsaved document";
-            confirmDialog("Restore from previous session?",
-                "You were working on <b>" + what + "</b> (" + escapeHtml(when) + ").",
-                "Restore", "Start fresh",
-                function (restore) {
-                    if (!restore) { checkDraft(); return; }
-                    var origin = m._origin;
-                    delete m._sessionAt;
-                    delete m._origin;
-                    meta = m;
-                    if (origin && origin.fp) {
-                        filepath = origin.fp;
-                        filename = origin.fn;
+            var restore = function () {
+                var origin = m._origin;
+                delete m._sessionAt;
+                delete m._origin;
+                meta = m;
+                if (origin && origin.fp) {
+                    filepath = origin.fp;
+                    filename = origin.fn;
+                }
+                cfg.deserialize(env.body);
+                markDirty();
+                updateTitle();
+                setStatus("Previous session restored - remember to save");
+            };
+            dialog({
+                title: "Restore from previous session?",
+                body: "You were working on <b>" + what + "</b> (" + escapeHtml(when) + ").<br><br>" +
+                    '<span class="of-dim">Discarding deletes the saved snapshot so it won\'t ' +
+                    "be offered again.</span>",
+                dismissable: true,
+                buttons: [
+                    // keeps the snapshot: dismiss now, may be offered next launch
+                    { label: "Start fresh", action: function (close) { close(); checkDraft(); } },
+                    // deletes the snapshot so it stops prompting
+                    {
+                        label: "Discard", danger: true,
+                        action: function (close) { close(); deleteSession(); checkDraft(); }
+                    },
+                    {
+                        label: "Restore", primary: true,
+                        action: function (close) { close(); restore(); }
                     }
                     }
-                    cfg.deserialize(env.body);
-                    markDirty();
-                    updateTitle();
-                    setStatus("Previous session restored - remember to save");
-                });
+                ]
+            });
         }, function () {
         }, function () {
             // backend unreachable (standalone preview): fall back to drafts
             // backend unreachable (standalone preview): fall back to drafts
             checkDraft();
             checkDraft();
@@ -1106,10 +1130,48 @@ var OfficeApp = (function () {
                 e.returnValue = "";
                 e.returnValue = "";
             }
             }
         });
         });
+        installFloatWindowCloseGuard();
 
 
         updateTitle();
         updateTitle();
     }
     }
 
 
+    /* The desktop routes a floatWindow's X button through the iframe's
+       ao_module_close() (see ao_module.js + desktop.html: it calls
+       contentWindow.ao_module_close() when defined). beforeunload does NOT
+       fire when the desktop just removes the iframe, so override that hook
+       to confirm before discarding unsaved changes. */
+    function installFloatWindowCloseGuard() {
+        if (typeof window.ao_module_close !== "function") return;
+        var reallyClose = function () {
+            markClean();   // never re-prompt while the window tears down
+            if (typeof window.ao_module_closeHandler === "function") {
+                ao_module_closeHandler();
+            }
+        };
+        window.ao_module_close = function () {
+            if (!dirty) { reallyClose(); return; }
+            dialog({
+                title: "Unsaved changes",
+                body: "<b>" + escapeHtml(filename || (cfg.defaultFileName + cfg.extension)) +
+                    "</b> has unsaved changes. Close it anyway?",
+                dismissable: true,
+                buttons: [
+                    { label: "Cancel" },
+                    {
+                        label: "Close without saving", danger: true,
+                        action: function (close) { close(); reallyClose(); }
+                    },
+                    {
+                        label: "Save & close", primary: true,
+                        // save() only calls back on success, so a failed or
+                        // cancelled save leaves the window open
+                        action: function (close) { close(); save(reallyClose); }
+                    }
+                ]
+            });
+        };
+    }
+
     /* ---------- public API ---------- */
     /* ---------- public API ---------- */
     return {
     return {
         init: init,
         init: init,

+ 65 - 16
src/web/Office/common/textedit.js

@@ -31,6 +31,9 @@ var OfficeTextEditBar = (function () {
         "Arial", "Georgia", "Times New Roman", "Courier New", "Verdana",
         "Arial", "Georgia", "Times New Roman", "Courier New", "Verdana",
         "Segoe UI", "Tahoma", "Trebuchet MS", "Impact", "Comic Sans MS"
         "Segoe UI", "Tahoma", "Trebuchet MS", "Impact", "Comic Sans MS"
     ];
     ];
+    // ladder the enlarge / shrink buttons step through
+    var SIZE_STEPS = [6, 7, 8, 9, 10, 11, 12, 14, 16, 18, 20, 24, 28, 32, 36,
+        40, 44, 48, 54, 60, 66, 72, 80, 88, 96, 120, 144];
     var $bar = null;
     var $bar = null;
     var anchorEl = null;
     var anchorEl = null;
     var opts = null;
     var opts = null;
@@ -49,6 +52,14 @@ var OfficeTextEditBar = (function () {
     function restoreSelection() {
     function restoreSelection() {
         if (!savedRange) return false;
         if (!savedRange) return false;
         try {
         try {
+            // focus the contenteditable that owns the range so execCommand
+            // targets it - after a modal (the Insert-link prompt) closes,
+            // document.activeElement is <body> and commands would no-op
+            var host = savedRange.commonAncestorContainer;
+            if (host && host.nodeType === 3) host = host.parentNode;
+            var ce = host && host.closest ?
+                host.closest('[contenteditable=""],[contenteditable="true"]') : null;
+            if (ce && ce.focus) ce.focus({ preventScroll: true });
             var sel = window.getSelection();
             var sel = window.getSelection();
             sel.removeAllRanges();
             sel.removeAllRanges();
             sel.addRange(savedRange);
             sel.addRange(savedRange);
@@ -186,7 +197,6 @@ var OfficeTextEditBar = (function () {
             applyFontSizePx(v);
             applyFontSizePx(v);
         });
         });
         $row1.append($size);
         $row1.append($size);
-        $row1.append('<span class="of-te-sep"></span>');
 
 
         function btn(icon, title, fn) {
         function btn(icon, title, fn) {
             var $b = $('<button type="button" class="of-te-btn" title="' + title + '"><i class="' + icon + ' icon"></i></button>');
             var $b = $('<button type="button" class="of-te-btn" title="' + title + '"><i class="' + icon + ' icon"></i></button>');
@@ -195,34 +205,69 @@ var OfficeTextEditBar = (function () {
             $b.on("click", fn);
             $b.on("click", fn);
             return $b;
             return $b;
         }
         }
+        // step to the next / previous size on the ladder (falls back to +/-4)
+        function stepFontSize(dir) {
+            var cur = parseInt($size.val(), 10) || 24;
+            var v = cur, i;
+            if (dir > 0) {
+                for (i = 0; i < SIZE_STEPS.length; i++) {
+                    if (SIZE_STEPS[i] > cur) { v = SIZE_STEPS[i]; break; }
+                }
+                if (v === cur) v = cur + 4;
+            } else {
+                for (i = SIZE_STEPS.length - 1; i >= 0; i--) {
+                    if (SIZE_STEPS[i] < cur) { v = SIZE_STEPS[i]; break; }
+                }
+                if (v === cur) v = cur - 4;
+            }
+            v = Math.max(6, Math.min(200, v));
+            $size.val(v);
+            applyFontSizePx(v);
+        }
+        $row1.append(btn("plus", "Increase font size", function () { stepFontSize(1); }));
+        $row1.append(btn("minus", "Decrease font size", function () { stepFontSize(-1); }));
+        $row1.append('<span class="of-te-sep"></span>');
         $row1.append(btn("bold", "Bold (Ctrl+B)", function () { exec("bold"); }));
         $row1.append(btn("bold", "Bold (Ctrl+B)", function () { exec("bold"); }));
         $row1.append(btn("italic", "Italic (Ctrl+I)", function () { exec("italic"); }));
         $row1.append(btn("italic", "Italic (Ctrl+I)", function () { exec("italic"); }));
         $row1.append(btn("underline", "Underline (Ctrl+U)", function () { exec("underline"); }));
         $row1.append(btn("underline", "Underline (Ctrl+U)", function () { exec("underline"); }));
-        $row1.append('<span class="of-te-sep"></span>');
-        $row1.append(btn("align left", "Align left", function () { exec("justifyLeft"); }));
-        $row1.append(btn("align center", "Align center", function () { exec("justifyCenter"); }));
-        $row1.append(btn("align right", "Align right", function () { exec("justifyRight"); }));
+        //$row1.append('<span class="of-te-sep"></span>');
+        $row2.append(btn("align left", "Align left", function () { exec("justifyLeft"); }));
+        $row2.append(btn("align center", "Align center", function () { exec("justifyCenter"); }));
+        $row2.append(btn("align right", "Align right", function () { exec("justifyRight"); }));
 
 
-        /* icon button with a color bar underneath; opens OfficeColorPicker */
+        /* Split colour control: the icon (with a colour bar underneath)
+           applies the CURRENT colour directly; the narrow caret opens the
+           picker to change it. The returned wrapper carries data("cur") and
+           the .of-te-cbar so syncFromSelection keeps working unchanged. */
         function colorBtn(icon, title, initial, cpOpts, apply) {
         function colorBtn(icon, title, initial, cpOpts, apply) {
-            var $b = $('<button type="button" class="of-te-btn of-te-cbtn" title="' + title + '">' +
-                '<i class="' + icon + ' icon"></i><span class="of-te-cbar"></span></button>');
-            $b.find(".of-te-cbar").css("background", initial);
-            $b.on("mousedown", function (e) { e.preventDefault(); saveSelection(); });
-            $b.on("click", function () {
+            var $wrap = $('<span class="of-te-split"></span>');
+            var $main = $('<button type="button" class="of-te-btn of-te-cbtn of-te-cmain" title="' +
+                title + '"><i class="' + icon + ' icon"></i><span class="of-te-cbar"></span></button>');
+            var $caret = $('<button type="button" class="of-te-btn of-te-ccaret" title="Choose ' +
+                title.toLowerCase() + '"><i class="caret down icon"></i></button>');
+            $wrap.append($main).append($caret);
+            $wrap.data("cur", initial);
+            $main.find(".of-te-cbar").css("background", initial);
+
+            // keep the text selection alive when clicking either half
+            $wrap.on("mousedown", function (e) { e.preventDefault(); saveSelection(); });
+            // icon -> apply the current colour immediately
+            $main.on("click", function () { apply($wrap.data("cur")); });
+            // caret -> open the picker; picking updates the current colour
+            $caret.on("click", function () {
                 OfficeColorPicker.open({
                 OfficeColorPicker.open({
-                    anchor: $b[0],
-                    value: $b.data("cur") || initial,
+                    anchor: $wrap[0],
+                    value: $wrap.data("cur") || initial,
                     allowNone: !!cpOpts.allowNone,
                     allowNone: !!cpOpts.allowNone,
                     noneLabel: cpOpts.noneLabel,
                     noneLabel: cpOpts.noneLabel,
                     onPick: function (hex) {
                     onPick: function (hex) {
-                        $b.data("cur", hex);
-                        $b.find(".of-te-cbar").css("background", hex || "transparent");
+                        $wrap.data("cur", hex);
+                        $main.find(".of-te-cbar").css("background", hex || "transparent");
                         apply(hex);
                         apply(hex);
                     }
                     }
                 });
                 });
             });
             });
-            return $b;
+            return $wrap;
         }
         }
         $row2.append(colorBtn("font", "Text color", "#202124", {}, function (hex) {
         $row2.append(colorBtn("font", "Text color", "#202124", {}, function (hex) {
             if (hex) exec("foreColor", hex);
             if (hex) exec("foreColor", hex);
@@ -268,6 +313,10 @@ var OfficeTextEditBar = (function () {
                     exec("createLink", v);
                     exec("createLink", v);
                 });
                 });
         }));
         }));
+        $row2.append('<span class="of-te-sep"></span>');
+        $row2.append(btn("list ul", "Bulleted list", function () { exec("insertUnorderedList"); }));
+        $row2.append(btn("list ol", "Numbered list", function () { exec("insertOrderedList"); }));
+        $row2.append(btn("eraser", "Clear formatting", function () { exec("removeFormat"); }));
 
 
         // keep selection fresh while the user works inside the editor, and
         // keep selection fresh while the user works inside the editor, and
         // mirror its color/size/font in the controls
         // mirror its color/size/font in the controls

+ 3 - 2
src/web/Office/docs/docs.js

@@ -1650,9 +1650,10 @@
                 return;
                 return;
             }
             }
         }
         }
-        // 3. plain text
+        // 3. plain text (ignore another app's raw marker JSON - its rich
+        //    text/html form was handled above)
         var t = cd.getData("text/plain");
         var t = cd.getData("text/plain");
-        if (t) {
+        if (t && !(window.OfficeClipboard && OfficeClipboard.isMarker(t))) {
             if (suggesting && !inHeaderFooter()) {
             if (suggesting && !inHeaderFooter()) {
                 suggestInsert(t);
                 suggestInsert(t);
                 afterEdit(true);
                 afterEdit(true);

+ 1 - 0
src/web/Office/docs/index.html

@@ -12,6 +12,7 @@
     <script src="../common/hotkeys.js"></script>
     <script src="../common/hotkeys.js"></script>
     <script src="../common/office.js"></script>
     <script src="../common/office.js"></script>
     <script src="../common/colorpicker.js"></script>
     <script src="../common/colorpicker.js"></script>
+    <script src="../common/clipboard.js"></script>
     <script src="../common/textedit.js"></script>
     <script src="../common/textedit.js"></script>
     <script src="../common/lib/marked.min.js"></script>
     <script src="../common/lib/marked.min.js"></script>
     <!-- Dynamic print stylesheet: docs.js writes the @page rule (size,
     <!-- Dynamic print stylesheet: docs.js writes the @page rule (size,

+ 1 - 0
src/web/Office/sheets/index.html

@@ -12,6 +12,7 @@
     <script src="../common/hotkeys.js"></script>
     <script src="../common/hotkeys.js"></script>
     <script src="../common/office.js"></script>
     <script src="../common/office.js"></script>
     <script src="../common/colorpicker.js"></script>
     <script src="../common/colorpicker.js"></script>
+    <script src="../common/clipboard.js"></script>
     <script src="../common/charts.js"></script>
     <script src="../common/charts.js"></script>
     <script src="formula.js"></script>
     <script src="formula.js"></script>
 </head>
 </head>

+ 55 - 9
src/web/Office/sheets/sheets.js

@@ -1472,11 +1472,13 @@ var SheetsApp = (function () {
         var ch = selectedChartObj();
         var ch = selectedChartObj();
         if (!ch) return false;
         if (!ch) return false;
         var txt = JSON.stringify({ app: CHART_CLIP_MARKER, version: 1, chart: deep(ch) });
         var txt = JSON.stringify({ app: CHART_CLIP_MARKER, version: 1, chart: deep(ch) });
+        var html = (window.SheetsIO && SheetsIO.chartToImageHtml) ? SheetsIO.chartToImageHtml(ch) : "";
         if (e && e.clipboardData) {
         if (e && e.clipboardData) {
             e.clipboardData.setData("text/plain", txt);
             e.clipboardData.setData("text/plain", txt);
+            if (html) e.clipboardData.setData("text/html", html);
             e.preventDefault();
             e.preventDefault();
-        } else if (navigator.clipboard && navigator.clipboard.writeText) {
-            navigator.clipboard.writeText(txt).catch(function () { });
+        } else {
+            OfficeClipboard.writeAsync({ text: txt, html: html }).catch(function () { });
         }
         }
         if (isCut) {
         if (isCut) {
             var s = sheet();
             var s = sheet();
@@ -1499,6 +1501,18 @@ var SheetsApp = (function () {
         commit();
         commit();
         OfficeApp.setStatus("Chart pasted");
         OfficeApp.setStatus("Chart pasted");
     }
     }
+    // an HTML <table> snapshot of a range so cells paste into Docs/Slides
+    function htmlOfRange(rg) {
+        var rows = [];
+        for (var r = rg.r1; r <= rg.r2; r++) {
+            var row = [];
+            for (var c = rg.c1; c <= rg.c2; c++) {
+                row.push(esc(displayText(c, r)));
+            }
+            rows.push(row);
+        }
+        return OfficeClipboard.tableHtml(rows);
+    }
     function onCopy(e, isCut) {
     function onCopy(e, isCut) {
         if (editing || isTypingTarget(document.activeElement) && document.activeElement !== gridEl) return;
         if (editing || isTypingTarget(document.activeElement) && document.activeElement !== gridEl) return;
         if (copySelectedChart(isCut, e)) return;
         if (copySelectedChart(isCut, e)) return;
@@ -1508,6 +1522,7 @@ var SheetsApp = (function () {
         clipCut = !!isCut;
         clipCut = !!isCut;
         if (e && e.clipboardData) {
         if (e && e.clipboardData) {
             e.clipboardData.setData("text/plain", clipTsv);
             e.clipboardData.setData("text/plain", clipTsv);
+            e.clipboardData.setData("text/html", htmlOfRange(rg));
             e.preventDefault();
             e.preventDefault();
         }
         }
         OfficeApp.setStatus((isCut ? "Cut " : "Copied ") + (clipInternal.w * clipInternal.h) + " cell(s)");
         OfficeApp.setStatus((isCut ? "Cut " : "Copied ") + (clipInternal.w * clipInternal.h) + " cell(s)");
@@ -1515,16 +1530,46 @@ var SheetsApp = (function () {
     function onPaste(e) {
     function onPaste(e) {
         if (editing) return;
         if (editing) return;
         if (isTypingTarget(document.activeElement) && document.activeElement !== gridEl) return;
         if (isTypingTarget(document.activeElement) && document.activeElement !== gridEl) return;
-        var text = e.clipboardData ? e.clipboardData.getData("text/plain") : "";
+        var cd = e.clipboardData;
+        var text = cd ? cd.getData("text/plain") : "";
+        var html = cd ? cd.getData("text/html") : "";
         e.preventDefault();
         e.preventDefault();
         var chp = parseChartClipboardText(text);
         var chp = parseChartClipboardText(text);
-        if (chp) {
-            pasteChart(chp);
-        } else if (clipInternal && text === clipTsv) {
-            pasteInternal();
-        } else if (text) {
-            pasteText(text);
+        if (chp) { pasteChart(chp); return; }
+        // same-app cells: full fidelity (formulas / styles)
+        if (clipInternal && text === clipTsv) { pasteInternal(); return; }
+        // a foreign app's marker JSON is never real cell text - use the
+        // shared text/html instead (Slides table -> cells)
+        if (OfficeClipboard.isMarker(text)) { pasteForeignHtml(html); return; }
+        if (text) { pasteText(text); return; }
+        pasteForeignHtml(html);
+    }
+    // ingest a shared text/html payload into the grid; images have no cell
+    // home so they are declined with a hint
+    function pasteForeignHtml(html) {
+        var p = OfficeClipboard.parse(html);
+        if (p.tables.length) { pasteHtmlTable(p.tables[0]); return; }
+        if (p.images.length) {
+            OfficeApp.setStatus("Can't paste an image into a cell - paste it into Slides or Docs", "error");
+            return;
         }
         }
+        if (p.text.replace(/\s/g, "")) pasteText(p.text);
+    }
+    function pasteHtmlTable(rows) {
+        var s = sheet();
+        rows.forEach(function (tr, r) {
+            tr.forEach(function (cell, c) {
+                var tc = anchor.c + c, tr2 = anchor.r + r;
+                if (tc >= MAX_COLS || tr2 >= MAX_ROWS) return;
+                growTo(tc + 1, tr2 + 1);
+                setRaw(tc, tr2, (cell.textContent || "").replace(/\s+/g, " ").trim());
+            });
+        });
+        head = {
+            c: clamp(anchor.c + (rows[0] ? rows[0].length - 1 : 0), 0, s.cols - 1),
+            r: clamp(anchor.r + rows.length - 1, 0, s.rows - 1)
+        };
+        commit();
     }
     }
     function pasteInternal() {
     function pasteInternal() {
         var s = sheet();
         var s = sheet();
@@ -1860,6 +1905,7 @@ var SheetsApp = (function () {
                 navigator.clipboard.readText().then(function (t) {
                 navigator.clipboard.readText().then(function (t) {
                     var chp = parseChartClipboardText(t);
                     var chp = parseChartClipboardText(t);
                     if (chp) pasteChart(chp);
                     if (chp) pasteChart(chp);
+                    else if (OfficeClipboard.isMarker(t)) OfficeApp.setStatus("Use Ctrl+V to paste this here", "info");
                     else if (clipInternal && t === clipTsv) pasteInternal();
                     else if (clipInternal && t === clipTsv) pasteInternal();
                     else if (t) pasteText(t);
                     else if (t) pasteText(t);
                     else if (clipInternal) pasteInternal();
                     else if (clipInternal) pasteInternal();

+ 12 - 0
src/web/Office/sheets/sheets_io.js

@@ -56,6 +56,17 @@ var SheetsIO = (function () {
         };
         };
     }
     }
 
 
+    // cross-app copy: a chart as a self-contained <img> (SVG snapshot) so it
+    // can be pasted into Slides / Docs
+    function chartToImageHtml(chart) {
+        var w = Math.max(120, (chart.w || 460) - 10);
+        var h = Math.max(90, (chart.h || 300) - 10);
+        var svg = OfficeCharts.renderToString(specFromChart(chart), w, h);
+        // charts inherit currentColor for their text - pin it for the snapshot
+        svg = svg.replace("<svg ", '<svg color="#202124" ');
+        return OfficeClipboard.imageHtml(OfficeClipboard.svgImageSrc(svg), chart.w, chart.h);
+    }
+
     function renderCharts() {
     function renderCharts() {
         var layer = document.getElementById("shChartLayer");
         var layer = document.getElementById("shChartLayer");
         if (!layer) return;
         if (!layer) return;
@@ -675,6 +686,7 @@ var SheetsIO = (function () {
         exportXlsx: exportXlsx,
         exportXlsx: exportXlsx,
         pivotDialog: pivotDialog,
         pivotDialog: pivotDialog,
         refreshPivot: refreshPivot,
         refreshPivot: refreshPivot,
+        chartToImageHtml: chartToImageHtml,
         fillPrintArea: fillPrintArea
         fillPrintArea: fillPrintArea
     };
     };
 })();
 })();

+ 1 - 0
src/web/Office/slides/index.html

@@ -12,6 +12,7 @@
     <script src="../common/hotkeys.js"></script>
     <script src="../common/hotkeys.js"></script>
     <script src="../common/office.js"></script>
     <script src="../common/office.js"></script>
     <script src="../common/colorpicker.js"></script>
     <script src="../common/colorpicker.js"></script>
+    <script src="../common/clipboard.js"></script>
     <script src="../common/textedit.js"></script>
     <script src="../common/textedit.js"></script>
     <script src="../common/charts.js"></script>
     <script src="../common/charts.js"></script>
     <script src="../common/lib/html2canvas.min.js"></script>
     <script src="../common/lib/html2canvas.min.js"></script>

+ 78 - 6
src/web/Office/slides/slides.js

@@ -846,6 +846,36 @@ var SlidesApp = (function () {
        stale content instead of duplicating the object. Bonus: objects now
        stale content instead of duplicating the object. Bonus: objects now
        paste across two Slides windows. */
        paste across two Slides windows. */
     var OBJ_CLIP_MARKER = "arozos-slides-objects";
     var OBJ_CLIP_MARKER = "arozos-slides-objects";
+    /* A shared text/html snapshot of the copied objects so they can be
+       pasted into Docs/Sheets (and external editors). Images/text/tables/
+       shapes carry over; video/audio/lines are same-app only. */
+    function objectsToHtml(objs) {
+        if (!objs || !objs.length) return "";
+        var parts = [];
+        objs.forEach(function (o) {
+            if (o.type === "image") {
+                parts.push(OfficeClipboard.imageHtml(absoluteMedia(o.props.src), o.w, o.h));
+            } else if (o.type === "text") {
+                parts.push('<div>' + (o.props.html || "") + "</div>");
+            } else if (o.type === "table") {
+                parts.push(tableHtml(o));
+            } else if (o.type === "shape") {
+                parts.push(OfficeClipboard.imageHtml(
+                    OfficeClipboard.svgImageSrc(shapeSvg(o)), o.w, o.h));
+            } else if (o.type === "chart") {
+                // render the chart spec to a self-contained SVG snapshot
+                var csvg = OfficeCharts.renderToString(o.props.spec || {},
+                    Math.max(60, o.w), Math.max(60, o.h));
+                csvg = csvg.replace("<svg ", '<svg color="#202124" ');
+                parts.push(OfficeClipboard.imageHtml(
+                    OfficeClipboard.svgImageSrc(csvg), o.w, o.h));
+            }
+        });
+        return parts.join("\n");
+    }
+    // media?file= links are relative to Office/<app>/; Docs sits at the same
+    // depth so they resolve unchanged, but make device data URLs pass through
+    function absoluteMedia(src) { return src || ""; }
     function objectClipboardText() {
     function objectClipboardText() {
         return JSON.stringify({ app: OBJ_CLIP_MARKER, version: 1, objects: clip });
         return JSON.stringify({ app: OBJ_CLIP_MARKER, version: 1, objects: clip });
     }
     }
@@ -862,11 +892,13 @@ var SlidesApp = (function () {
     function copySelection() {
     function copySelection() {
         if (!sel.length) return;
         if (!sel.length) return;
         clip = selObjs().map(deep);
         clip = selObjs().map(deep);
-        // async system-clipboard sync for menu/toolbar copies; real Ctrl+C
-        // goes through the "copy" event which sets it synchronously
-        if (navigator.clipboard && navigator.clipboard.writeText) {
-            navigator.clipboard.writeText(objectClipboardText()).catch(function () { });
-        }
+        // async system-clipboard sync for menu/toolbar copies (writes both
+        // the object marker and the shared text/html); real Ctrl+C goes
+        // through the "copy" event which sets both synchronously
+        OfficeClipboard.writeAsync({
+            text: objectClipboardText(),
+            html: objectsToHtml(clip)
+        }).catch(function () { });
         OfficeApp.setStatus(clip.length + " object" + (clip.length > 1 ? "s" : "") + " copied");
         OfficeApp.setStatus(clip.length + " object" + (clip.length > 1 ? "s" : "") + " copied");
     }
     }
     function cutSelection() {
     function cutSelection() {
@@ -1636,6 +1668,10 @@ var SlidesApp = (function () {
             var el = objEl(id);
             var el = objEl(id);
             // focus moving into the floating format bar is still "editing"
             // focus moving into the floating format bar is still "editing"
             if (window.OfficeTextEditBar && OfficeTextEditBar.contains(document.activeElement)) return;
             if (window.OfficeTextEditBar && OfficeTextEditBar.contains(document.activeElement)) return;
+            // a dialog opened over the editor (e.g. the Insert-link prompt)
+            // must not tear down the edit - otherwise the box re-renders and
+            // the command applies to a dead selection
+            if ($(".of-dialog-overlay").length) return;
             if (el && !el.contains(document.activeElement)) endEdit(true);
             if (el && !el.contains(document.activeElement)) endEdit(true);
         }, 0);
         }, 0);
     }
     }
@@ -2327,15 +2363,49 @@ var SlidesApp = (function () {
             }
             }
         }
         }
         if (handled) { e.preventDefault(); return; }
         if (handled) { e.preventDefault(); return; }
+        // cross-app: a Docs picture / Sheets chart / cells arrive as text/html
+        var html = cd.getData("text/html");
+        if (html && pasteForeignHtml(html)) { e.preventDefault(); return; }
         if (clip && clip.length) { e.preventDefault(); pasteClipboard(); return; }
         if (clip && clip.length) { e.preventDefault(); pasteClipboard(); return; }
         var t = cd.getData("text/plain");
         var t = cd.getData("text/plain");
-        if (t) {
+        if (t && !OfficeClipboard.isMarker(t)) {
             e.preventDefault();
             e.preventDefault();
             var th = themeOf();
             var th = themeOf();
             addObj("text", { html: esc(t).replace(/\n/g, "<br>"), fontSize: 24, color: th.text, align: "left" },
             addObj("text", { html: esc(t).replace(/\n/g, "<br>"), fontSize: 24, color: th.text, align: "left" },
                 { x: 280, y: 220, w: 400, h: 90 });
                 { x: 280, y: 220, w: 400, h: 90 });
         }
         }
     }
     }
+    /* Build slide objects from a shared text/html payload. Returns true when
+       something was inserted. */
+    function pasteForeignHtml(html) {
+        var p = OfficeClipboard.parse(html);
+        if (!p.hasContent) return false;
+        if (p.images.length) {
+            p.images.forEach(function (im) { placeImage(im.src); });
+            return true;
+        }
+        if (p.tables.length) {
+            objectFromHtmlTable(p.tables[0]);
+            return true;
+        }
+        // rich text -> a text box (sanitize to the inline subset we allow)
+        var frag = sanitizeCellHtml(p.html);
+        if (frag.replace(/<[^>]*>/g, "").replace(/\s/g, "") === "") return false;
+        var th = themeOf();
+        addObj("text", { html: frag, fontSize: 24, color: th.text, align: "left" },
+            { x: 240, y: 200, w: 480, h: 120 });
+        return true;
+    }
+    function objectFromHtmlTable(rows) {
+        var data = rows.map(function (tr) {
+            return tr.map(function (cell) { return sanitizeCellHtml(cell.innerHTML); });
+        });
+        var cols = data[0] ? data[0].length : 1;
+        var w = Math.min(880, Math.max(240, cols * 140));
+        var h = Math.min(480, Math.max(80, data.length * 34));
+        addObj("table", { rows: data, headerRow: false, fontSize: 16 },
+            { x: Math.round((SLIDE_W - w) / 2), y: Math.round((SLIDE_H - h) / 2), w: w, h: h });
+    }
 
 
     /* Drop image files (or an image URL) onto the slide canvas. */
     /* Drop image files (or an image URL) onto the slide canvas. */
     function onCanvasDrop(e) {
     function onCanvasDrop(e) {
@@ -2371,6 +2441,8 @@ var SlidesApp = (function () {
             copySelection();
             copySelection();
             if (e.clipboardData) {
             if (e.clipboardData) {
                 e.clipboardData.setData("text/plain", objectClipboardText());
                 e.clipboardData.setData("text/plain", objectClipboardText());
+                var html = objectsToHtml(clip);
+                if (html) e.clipboardData.setData("text/html", html);
                 e.preventDefault();
                 e.preventDefault();
             }
             }
             if (isCut) deleteSelection();
             if (isCut) deleteSelection();