Explorar o código

Add cross app copy

Toby Chui hai 1 mes
pai
achega
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/office.js"></script>
     <script src="../common/colorpicker.js"></script>
+    <script src="../common/clipboard.js"></script>
     <!-- optional: ../common/charts.js, ../common/textedit.js,
          ../common/lib/marked.min.js, ../common/lib/pdf-lib.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
 time. The framework also writes rolling session snapshots to
 `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
 

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

@@ -20,13 +20,15 @@
 
     POST parameters:
         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
     container format on their next save. When the office lib is unavailable
@@ -179,6 +181,28 @@ function main(){
         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 }));
 }
 

+ 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;
 }
 .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 {
     width: 100%;
     box-sizing: border-box;
@@ -472,6 +473,22 @@ body.of-app {
     background: #202124;
     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) ============ */
 .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)
         }, 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() {
         ao_module_agirun(CONTAINER_BACKEND, {
             action: "session-load",
@@ -259,24 +267,40 @@ var OfficeApp = (function () {
             var m = env.meta || {};
             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";
-            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 () {
             // backend unreachable (standalone preview): fall back to drafts
             checkDraft();
@@ -1106,10 +1130,48 @@ var OfficeApp = (function () {
                 e.returnValue = "";
             }
         });
+        installFloatWindowCloseGuard();
 
         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 ---------- */
     return {
         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",
         "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 anchorEl = null;
     var opts = null;
@@ -49,6 +52,14 @@ var OfficeTextEditBar = (function () {
     function restoreSelection() {
         if (!savedRange) return false;
         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();
             sel.removeAllRanges();
             sel.addRange(savedRange);
@@ -186,7 +197,6 @@ var OfficeTextEditBar = (function () {
             applyFontSizePx(v);
         });
         $row1.append($size);
-        $row1.append('<span class="of-te-sep"></span>');
 
         function btn(icon, title, fn) {
             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);
             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("italic", "Italic (Ctrl+I)", function () { exec("italic"); }));
         $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) {
-            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({
-                    anchor: $b[0],
-                    value: $b.data("cur") || initial,
+                    anchor: $wrap[0],
+                    value: $wrap.data("cur") || initial,
                     allowNone: !!cpOpts.allowNone,
                     noneLabel: cpOpts.noneLabel,
                     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);
                     }
                 });
             });
-            return $b;
+            return $wrap;
         }
         $row2.append(colorBtn("font", "Text color", "#202124", {}, function (hex) {
             if (hex) exec("foreColor", hex);
@@ -268,6 +313,10 @@ var OfficeTextEditBar = (function () {
                     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
         // mirror its color/size/font in the controls

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

@@ -1650,9 +1650,10 @@
                 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");
-        if (t) {
+        if (t && !(window.OfficeClipboard && OfficeClipboard.isMarker(t))) {
             if (suggesting && !inHeaderFooter()) {
                 suggestInsert(t);
                 afterEdit(true);

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

@@ -12,6 +12,7 @@
     <script src="../common/hotkeys.js"></script>
     <script src="../common/office.js"></script>
     <script src="../common/colorpicker.js"></script>
+    <script src="../common/clipboard.js"></script>
     <script src="../common/textedit.js"></script>
     <script src="../common/lib/marked.min.js"></script>
     <!-- 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/office.js"></script>
     <script src="../common/colorpicker.js"></script>
+    <script src="../common/clipboard.js"></script>
     <script src="../common/charts.js"></script>
     <script src="formula.js"></script>
 </head>

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

@@ -1472,11 +1472,13 @@ var SheetsApp = (function () {
         var ch = selectedChartObj();
         if (!ch) return false;
         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) {
             e.clipboardData.setData("text/plain", txt);
+            if (html) e.clipboardData.setData("text/html", html);
             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) {
             var s = sheet();
@@ -1499,6 +1501,18 @@ var SheetsApp = (function () {
         commit();
         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) {
         if (editing || isTypingTarget(document.activeElement) && document.activeElement !== gridEl) return;
         if (copySelectedChart(isCut, e)) return;
@@ -1508,6 +1522,7 @@ var SheetsApp = (function () {
         clipCut = !!isCut;
         if (e && e.clipboardData) {
             e.clipboardData.setData("text/plain", clipTsv);
+            e.clipboardData.setData("text/html", htmlOfRange(rg));
             e.preventDefault();
         }
         OfficeApp.setStatus((isCut ? "Cut " : "Copied ") + (clipInternal.w * clipInternal.h) + " cell(s)");
@@ -1515,16 +1530,46 @@ var SheetsApp = (function () {
     function onPaste(e) {
         if (editing) 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();
         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() {
         var s = sheet();
@@ -1860,6 +1905,7 @@ var SheetsApp = (function () {
                 navigator.clipboard.readText().then(function (t) {
                     var chp = parseChartClipboardText(t);
                     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 (t) pasteText(t);
                     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() {
         var layer = document.getElementById("shChartLayer");
         if (!layer) return;
@@ -675,6 +686,7 @@ var SheetsIO = (function () {
         exportXlsx: exportXlsx,
         pivotDialog: pivotDialog,
         refreshPivot: refreshPivot,
+        chartToImageHtml: chartToImageHtml,
         fillPrintArea: fillPrintArea
     };
 })();

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

@@ -12,6 +12,7 @@
     <script src="../common/hotkeys.js"></script>
     <script src="../common/office.js"></script>
     <script src="../common/colorpicker.js"></script>
+    <script src="../common/clipboard.js"></script>
     <script src="../common/textedit.js"></script>
     <script src="../common/charts.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
        paste across two Slides windows. */
     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() {
         return JSON.stringify({ app: OBJ_CLIP_MARKER, version: 1, objects: clip });
     }
@@ -862,11 +892,13 @@ var SlidesApp = (function () {
     function copySelection() {
         if (!sel.length) return;
         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");
     }
     function cutSelection() {
@@ -1636,6 +1668,10 @@ var SlidesApp = (function () {
             var el = objEl(id);
             // focus moving into the floating format bar is still "editing"
             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);
         }, 0);
     }
@@ -2327,15 +2363,49 @@ var SlidesApp = (function () {
             }
         }
         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; }
         var t = cd.getData("text/plain");
-        if (t) {
+        if (t && !OfficeClipboard.isMarker(t)) {
             e.preventDefault();
             var th = themeOf();
             addObj("text", { html: esc(t).replace(/\n/g, "<br>"), fontSize: 24, color: th.text, align: "left" },
                 { 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. */
     function onCanvasDrop(e) {
@@ -2371,6 +2441,8 @@ var SlidesApp = (function () {
             copySelection();
             if (e.clipboardData) {
                 e.clipboardData.setData("text/plain", objectClipboardText());
+                var html = objectsToHtml(clip);
+                if (html) e.clipboardData.setData("text/html", html);
                 e.preventDefault();
             }
             if (isCut) deleteSelection();