Переглянути джерело

Add docs page breaks and table tools

Teach Docs to preserve explicit page breaks in the editor and DOCX import/export, and align exported headers/footers with the editor's left-aligned layout. The editor UI also gains a Word-style table inserter, contextual table formatting menus, richer context/edit menu clipboard actions, and related pagination/menu contract updates.
Toby Chui 1 місяць тому
батько
коміт
66ad0d3699

+ 91 - 0
src/mod/office/docx_pagebreak_test.go

@@ -0,0 +1,91 @@
+package office
+
+import (
+	"strings"
+	"testing"
+)
+
+func TestDocxPageBreakExport(t *testing.T) {
+	doc := &Document{
+		HTML: `<p>page one</p><div class="doc-pagebreak" contenteditable="false"></div><p>page two</p>`,
+	}
+	data, err := BuildDocx(doc)
+	if err != nil {
+		t.Fatalf("BuildDocx: %v", err)
+	}
+	body := string(zipPart(t, data, "word/document.xml"))
+	if !strings.Contains(body, `<w:br w:type="page"/>`) {
+		t.Fatalf("document.xml has no page break, got: %s", body)
+	}
+	// the break must sit between the two paragraphs, not swallow their text
+	iOne := strings.Index(body, "page one")
+	iBr := strings.Index(body, `<w:br w:type="page"/>`)
+	iTwo := strings.Index(body, "page two")
+	if iOne < 0 || iTwo < 0 {
+		t.Fatalf("paragraph text lost: %s", body)
+	}
+	if !(iOne < iBr && iBr < iTwo) {
+		t.Errorf("page break out of order: one=%d br=%d two=%d", iOne, iBr, iTwo)
+	}
+	// the marker div must not also leak through as an empty paragraph run
+	if strings.Contains(body, "doc-pagebreak") {
+		t.Error("the marker class leaked into document.xml")
+	}
+}
+
+func TestDocxPageBreakRoundTrip(t *testing.T) {
+	doc := &Document{
+		HTML: `<p>alpha</p><div class="doc-pagebreak" contenteditable="false"></div><p>beta</p>`,
+	}
+	data, err := BuildDocx(doc)
+	if err != nil {
+		t.Fatalf("BuildDocx: %v", err)
+	}
+	back, err := ParseDocx(data)
+	if err != nil {
+		t.Fatalf("ParseDocx: %v", err)
+	}
+	if !strings.Contains(back.HTML, "doc-pagebreak") {
+		t.Errorf("page break lost on import, got: %s", back.HTML)
+	}
+	iA := strings.Index(back.HTML, "alpha")
+	iP := strings.Index(back.HTML, "doc-pagebreak")
+	iB := strings.Index(back.HTML, "beta")
+	if !(iA < iP && iP < iB) {
+		t.Errorf("imported page break out of order: %s", back.HTML)
+	}
+}
+
+func TestDocxHeaderFooterNotCentered(t *testing.T) {
+	doc := &Document{
+		HTML:        "<p>body</p>",
+		Header:      "Introduction to Lapwing",
+		Footer:      "footer text",
+		PageNumbers: true,
+	}
+	data, err := BuildDocx(doc)
+	if err != nil {
+		t.Fatalf("BuildDocx: %v", err)
+	}
+	tests := []struct{ part, want string }{
+		{"word/header1.xml", "Introduction to Lapwing"},
+		{"word/footer1.xml", "footer text"},
+	}
+	for _, tc := range tests {
+		raw := string(zipPart(t, data, tc.part))
+		if raw == "" {
+			t.Fatalf("%s missing", tc.part)
+		}
+		if !strings.Contains(raw, tc.want) {
+			t.Errorf("%s missing its text: %s", tc.part, raw)
+		}
+		// the editor renders header/footer left aligned - the export must match
+		if strings.Contains(raw, `<w:jc w:val="center"/>`) {
+			t.Errorf("%s is centred but the editor left aligns it: %s", tc.part, raw)
+		}
+	}
+	// the PAGE field must survive the alignment fix
+	if !strings.Contains(string(zipPart(t, data, "word/footer1.xml")), "PAGE") {
+		t.Error("footer lost its page number field")
+	}
+}

+ 6 - 1
src/mod/office/docx_reader.go

@@ -456,7 +456,12 @@ func (cv *docxConv) runToHTML(r *xnode) string {
 		case "t":
 			body.WriteString(xmlEscape(n.Text))
 		case "br", "cr":
-			body.WriteString("<br>")
+			if n.attr("type") == "page" {
+				// explicit page break -> the editor's page break block
+				body.WriteString(`<div class="doc-pagebreak" contenteditable="false"></div>`)
+			} else {
+				body.WriteString("<br>")
+			}
 		case "tab":
 			body.WriteString("&nbsp;&nbsp;&nbsp;&nbsp;")
 		case "drawing", "pict", "object":

+ 8 - 1
src/mod/office/docx_writer.go

@@ -365,6 +365,11 @@ func (b *docxBuilder) walkBlocks(n *html.Node, f runFormat, listLevel int, listT
 
 // dispatchBlock emits ONE block-level element
 func (b *docxBuilder) dispatchBlock(c *html.Node, f runFormat, listLevel int, listType string) {
+	// explicit page break inserted in the editor (Insert > Page break)
+	if strings.Contains(" "+htmlAttr(c, "class")+" ", " doc-pagebreak ") {
+		b.body.WriteString(`<w:p><w:r><w:br w:type="page"/></w:r></w:p>`)
+		return
+	}
 	switch c.Data {
 	case "p", "div":
 		b.emitParagraph(c, f, paraProps(c, ""), listLevel, listType)
@@ -745,8 +750,10 @@ func buildHfPart(root, text string, pageNumbers bool) string {
 			`<w:r><w:instrText xml:space="preserve"> PAGE </w:instrText></w:r>` +
 			`<w:r><w:fldChar w:fldCharType="end"/></w:r>`)
 	}
+	// no <w:jc>: the editor renders header/footer text left aligned, so the
+	// export must not silently centre it
 	return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>` + "\n" +
-		`<w:` + root + ` ` + docxNs + `><w:p><w:pPr><w:jc w:val="center"/></w:pPr>` +
+		`<w:` + root + ` ` + docxNs + `><w:p>` +
 		runs.String() + `</w:p></w:` + root + `>`
 }
 

+ 3 - 0
src/web/Office/common/CONTRACT.md

@@ -105,6 +105,9 @@ OfficeApp.init({
 
     // --- menus ---
     menus: [ { title: "Insert", items: [ …items… ] }, … ],  // placed between Edit and View
+    // a menu may carry when: fn -> bool (contextual, e.g. Docs' Table menu);
+    // it starts hidden - call OfficeApp.updateMenus() (e.g. on selection
+    // change) to re-evaluate visibility
     fileMenuExtras: [ …items… ],   // e.g. Export submenu — after Save As
     editMenuExtras: [ …items… ],   // after Cut/Copy/Paste
     viewMenuExtras: [ …items… ],   // after zoom/theme

+ 21 - 4
src/web/Office/common/office.js

@@ -284,15 +284,14 @@ var OfficeApp = (function () {
             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>",
+                    '<span class="of-dim">Start fresh deletes the saved snapshot.</span>',
                 dismissable: true,
                 buttons: [
                     // keeps the snapshot: dismiss now, may be offered next launch
-                    { label: "Start fresh", action: function (close) { close(); checkDraft(); } },
+                    //{ label: "Start fresh", action: function (close) { close(); checkDraft(); } },
                     // deletes the snapshot so it stops prompting
                     {
-                        label: "Discard", danger: true,
+                        label: "Start fresh", danger: true,
                         action: function (close) { close(); deleteSession(); checkDraft(); }
                     },
                     {
@@ -802,6 +801,12 @@ var OfficeApp = (function () {
             var $m = $('<div class="of-menu" tabindex="-1">' + escapeHtml(m.title) + "</div>");
             var $drop = $('<div class="of-menu-drop"></div>');
             $m.append($drop);
+            // contextual menus (e.g. Table) only show when their condition
+            // holds; the app re-evaluates via OfficeApp.updateMenus()
+            if (m.when) {
+                $m.data("when", m.when);
+                $m.hide();
+            }
             $m.on("click", function (ev) {
                 if ($(ev.target).closest(".of-menu-drop").length) return;
                 var wasOpen = $m.hasClass("open");
@@ -831,6 +836,17 @@ var OfficeApp = (function () {
     function refreshMenuChecks() {
         // menus re-render on open; nothing to do live
     }
+    // show/hide conditional menubar menus (menu defs carrying when: fn)
+    function updateMenus() {
+        $(".of-menubar .of-menu").each(function () {
+            var w = $(this).data("when");
+            if (!w) return;
+            var on = false;
+            try { on = !!w(); } catch (e) { }
+            if (!on && $(this).hasClass("open")) closeAllMenus();
+            $(this).toggle(on);
+        });
+    }
     function standardMenus() {
         var fileItems = function () {
             var items = [
@@ -1199,6 +1215,7 @@ var OfficeApp = (function () {
         showBusy: showBusy,
         hideBusy: hideBusy,
         closeAllMenus: closeAllMenus,
+        updateMenus: updateMenus,
         // features
         registerShortcut: registerShortcut,
         print: printDoc,

+ 71 - 2
src/web/Office/docs/docs.css

@@ -25,7 +25,8 @@
     box-sizing: border-box;
     background: #ffffff;
     color: #1f2328;
-    box-shadow: 0 1px 3px var(--of-paper-shadow), 0 4px 14px var(--of-paper-shadow);
+    /*box-shadow: 0 1px 3px var(--of-paper-shadow), 0 4px 14px var(--of-paper-shadow); */
+    border: 1px solid #e2e5e9;
     border-radius: 2px;
 }
 body.dark #page {
@@ -73,7 +74,7 @@ body.dark #page {
     overflow-wrap: break-word;
     caret-color: #1a73e8;
 }
-#editor p, #editor div { margin: 0 0 8pt; }
+#editor p, #editor div { margin: 0 0 0pt; }
 #editor h1, #editor h2, #editor h3, #editor h4 {
     margin: 14pt 0 6pt;
     line-height: 1.25;
@@ -186,6 +187,74 @@ body.dark #page {
     font-family: "Segoe UI", Arial, sans-serif;
 }
 
+/* ============ Page breaks (explicit + automatic pagination) ============
+   Real blocks in the flow, stretched by updatePageGuides() to eat the rest
+   of their sheet. The .doc-pb-gap child paints the cut between the two
+   sheets: it reaches PAST the sheet edges (GAP_OVERHANG_PX) so it also
+   erases the sheet's side shadow across the gap - each page keeps its own
+   separate vertical shadow. The .doc-pb-gap-in child draws the two facing
+   sheet edges across the actual sheet width only. */
+.doc-pagebreak {
+    position: relative;
+    margin: 0;
+    padding: 0;
+    user-select: none;
+    -webkit-user-select: none;
+    break-after: page;
+    page-break-after: always;
+}
+.doc-pb-gap {
+    position: absolute;
+    background: var(--of-bg);
+    pointer-events: none;
+}
+.doc-pb-gap-in {
+    position: absolute;
+    top: 0;
+    bottom: 0;
+    /* left/right set inline to the overhang so edges align with the sheet */
+    /* box-shadow: inset 0 4px 6px -4px var(--of-paper-shadow),
+                inset 0 -4px 6px -4px var(--of-paper-shadow); */
+    border-top: 1px solid rgba(60, 64, 67, 0.16);
+    border-bottom: 1px solid rgba(60, 64, 67, 0.16);
+}
+@media print {
+    /* the sheet cut is a screen affordance; the printer paginates for real */
+    .doc-pagebreak { height: 0 !important; }
+    .doc-pb-gap { display: none; }
+    /* automatic spacers are layout-only - the printer re-paginates */
+    .doc-autobreak { display: none !important; }
+}
+
+/* ============ Table grid creator (Insert > Table) ============ */
+.doc-tgrid-label {
+    text-align: center;
+    font-size: 13px;
+    font-weight: 600;
+    color: var(--of-fg);
+    margin-bottom: 8px;
+}
+.doc-tgrid {
+    display: grid;
+    grid-template-columns: repeat(10, 20px);
+    gap: 3px;
+    justify-content: center;
+    touch-action: none;
+}
+.doc-tgrid-cell {
+    width: 20px;
+    height: 20px;
+    padding: 0;
+    border: 1px solid var(--of-border);
+    border-radius: 3px;
+    background: var(--of-chrome-bg2);
+    cursor: pointer;
+}
+.doc-tgrid-cell.hot {
+    background: var(--of-accent-soft);
+    border-color: var(--of-accent);
+}
+
 /* Layout boxes: visualize which regions span all columns (orange) and
    which flow inside the columns (blue) - View > Layout boxes */
 #page { position: relative; }

+ 495 - 41
src/web/Office/docs/docs.js

@@ -27,7 +27,9 @@
         image-selection classes are stripped before serialisation.
       - Known allowed classes inside html: doc-title (title paragraph),
         of-checklist (on UL), checked (on LI), of-table (on TABLE),
-        doc-cmt (comment anchor), doc-ins / doc-del (tracked changes).
+        doc-cmt (comment anchor), doc-ins / doc-del (tracked changes),
+        doc-pagebreak (explicit page break block; exports as a real
+        <w:br w:type="page"/> and is sized on screen by layoutPageBreaks).
       - Foreign exports (docx/html/md/txt) use resolvedHtml(): suggestions
         applied as if accepted, comment anchors unwrapped.
 */
@@ -225,6 +227,8 @@
     /* ================= serialize / deserialize ================= */
     function cleanedHtml() {
         var clone = editor.cloneNode(true);
+        // strip layout-only automatic page spacers (visual pagination)
+        removeAutoBreaks(clone);
         // strip find highlight spans
         var hits = clone.querySelectorAll("span.of-find-hit");
         for (var i = 0; i < hits.length; i++) {
@@ -269,6 +273,7 @@
         closeFind();
         deselectImage();
         editor.innerHTML = sanitizeHtml(b.html || "<p><br></p>", { keepClasses: true }) || "<p><br></p>";
+        normalizePageBreaks();
         headerEl.textContent = b.header || "";
         footerEl.textContent = b.footer || "";
         var p = b.page || {};
@@ -574,6 +579,8 @@
         stateTimer = setTimeout(updateToolbarState, 90);
     }
     function updateToolbarState() {
+        // contextual menubar menus (Table) follow the caret
+        if (OfficeApp.updateMenus) OfficeApp.updateMenus();
         var states = ["bold", "italic", "underline", "strikeThrough",
             "justifyLeft", "justifyCenter", "justifyRight", "justifyFull",
             "insertUnorderedList", "insertOrderedList"];
@@ -798,39 +805,134 @@
     }
 
     /* ================= tables ================= */
+    /* Word-style drag-over grid table creator (Insert > Table) */
+    var TGRID_COLS = 10, TGRID_ROWS = 8;
+    function insertTable(rows, cols) {
+        var html = '<table class="of-table"><tbody>';
+        for (var r = 0; r < rows; r++) {
+            html += "<tr>";
+            for (var c = 0; c < cols; c++) html += "<td><br></td>";
+            html += "</tr>";
+        }
+        html += "</tbody></table><p><br></p>";
+        restoreSel();
+        try { document.execCommand("insertHTML", false, html); } catch (e) { }
+        afterEdit(true);
+    }
     function insertTableDialog() {
         if (inHeaderFooter()) return;
-        var $b = $('<div class="dlg-two-col"></div>');
-        $b.append("<div><label>Rows</label><input type='number' min='1' max='50' class='tbl-rows' value='3'></div>");
-        $b.append("<div><label>Columns</label><input type='number' min='1' max='20' class='tbl-cols' value='3'></div>");
-        OfficeApp.dialog({
+        var $b = $("<div></div>");
+        var $label = $('<div class="doc-tgrid-label">1 × 1</div>');
+        var $grid = $('<div class="doc-tgrid"></div>');
+        var dlg;
+        var hotR = 1, hotC = 1;
+        function paint() {
+            $grid.children().each(function () {
+                var r = parseInt($(this).attr("data-r"), 10);
+                var c = parseInt($(this).attr("data-c"), 10);
+                $(this).toggleClass("hot", r <= hotR && c <= hotC);
+            });
+            $label.text(hotC + " × " + hotR);
+        }
+        for (var r = 1; r <= TGRID_ROWS; r++) {
+            for (var c = 1; c <= TGRID_COLS; c++) {
+                var $cell = $('<button type="button" class="doc-tgrid-cell"></button>')
+                    .attr("data-r", r).attr("data-c", c);
+                $grid.append($cell);
+            }
+        }
+        // hover / drag over the grid selects the size, click (or release)
+        // inserts - matching the Word ribbon gesture
+        $grid.on("pointerover pointermove", ".doc-tgrid-cell", function () {
+            hotR = parseInt($(this).attr("data-r"), 10);
+            hotC = parseInt($(this).attr("data-c"), 10);
+            paint();
+        });
+        $grid.on("click pointerup", ".doc-tgrid-cell", function () {
+            var rr = parseInt($(this).attr("data-r"), 10);
+            var cc = parseInt($(this).attr("data-c"), 10);
+            dlg.close();
+            insertTable(rr, cc);
+        });
+        var $custom = $('<div class="dlg-two-col" style="margin-top:10px;"></div>');
+        $custom.append("<div><label>Rows</label><input type='number' min='1' max='50' class='tbl-rows' value='3'></div>");
+        $custom.append("<div><label>Columns</label><input type='number' min='1' max='20' class='tbl-cols' value='3'></div>");
+        $b.append($label).append($grid)
+            .append('<label style="margin-top:12px;">Custom size</label>')
+            .append($custom);
+        dlg = OfficeApp.dialog({
             title: "Insert table",
             body: $b,
             buttons: [
                 { label: "Cancel" },
-                { label: "Insert", primary: true, action: function (close, $body) {
-                    var rows = Math.min(50, Math.max(1, parseInt($body.find(".tbl-rows").val(), 10) || 3));
-                    var cols = Math.min(20, Math.max(1, parseInt($body.find(".tbl-cols").val(), 10) || 3));
-                    close();
-                    var html = '<table class="of-table"><tbody>';
-                    for (var r = 0; r < rows; r++) {
-                        html += "<tr>";
-                        for (var c = 0; c < cols; c++) html += "<td><br></td>";
-                        html += "</tr>";
+                {
+                    label: "Insert custom size", action: function (close, $body) {
+                        var rows = Math.min(50, Math.max(1, parseInt($body.find(".tbl-rows").val(), 10) || 3));
+                        var cols = Math.min(20, Math.max(1, parseInt($body.find(".tbl-cols").val(), 10) || 3));
+                        close();
+                        insertTable(rows, cols);
                     }
-                    html += "</tbody></table><p><br></p>";
-                    restoreSel();
-                    try { document.execCommand("insertHTML", false, html); } catch (e) { }
-                    afterEdit(true);
-                } }
+                }
             ]
         });
+        paint();
+    }
+
+    /* ---------- table styling (themes / borders / banding) ----------
+       The style state lives in data attributes on the <table> so toggles
+       know it and re-application after row/column edits keeps banding
+       consistent; the rendered look is plain inline CSS (survives the
+       native format, HTML export and - for shading - docx). Cells with
+       data-cellbg carry a user-picked background that themes/banding
+       never overwrite. */
+    var TABLE_THEMES = [
+        { name: "Plain",       bc: "#b9bec7", headbg: "#eef1f5", bandbg: "#f4f6f9", headfc: "" },
+        { name: "Blue",        bc: "#a9c6e8", headbg: "#d9e7f8", bandbg: "#eef4fc", headfc: "" },
+        { name: "Green",       bc: "#b2d8b4", headbg: "#d9efdb", bandbg: "#effaf0", headfc: "" },
+        { name: "Orange",      bc: "#f0c9a4", headbg: "#fbe3c9", bandbg: "#fdf3e7", headfc: "" },
+        { name: "Dark header", bc: "#9aa0a6", headbg: "#3c4043", bandbg: "#f1f3f4", headfc: "#ffffff" }
+    ];
+    function applyTableStyle(table) {
+        var bw = parseFloat(table.getAttribute("data-th-bw")) || 1;
+        var bc = table.getAttribute("data-th-bc") || "#b9bec7";
+        var headOn = table.getAttribute("data-th-head") === "1";
+        var bandOn = table.getAttribute("data-th-band") === "1";
+        var headBg = table.getAttribute("data-th-headbg") || "#e8eaed";
+        var headFc = table.getAttribute("data-th-headfc") || "";
+        var bandBg = table.getAttribute("data-th-bandbg") || "#f4f5f7";
+        for (var r = 0; r < table.rows.length; r++) {
+            var row = table.rows[r];
+            for (var c = 0; c < row.cells.length; c++) {
+                var cell = row.cells[c];
+                cell.style.border = bw + "px solid " + bc;
+                var custom = cell.getAttribute("data-cellbg");
+                var isHead = headOn && r === 0;
+                var isBand = bandOn && ((r - (headOn ? 1 : 0)) % 2 === 1);
+                if (custom) cell.style.backgroundColor = custom;
+                else if (isHead) cell.style.backgroundColor = headBg;
+                else if (isBand) cell.style.backgroundColor = bandBg;
+                else cell.style.backgroundColor = "";
+                cell.style.fontWeight = isHead ? "700" : "";
+                cell.style.color = (isHead && headFc) ? headFc : "";
+            }
+        }
+        afterEdit(true);
+    }
+    function currentCell() {
+        var n = savedRange ? savedRange.startContainer : null;
+        if (n && n.nodeType === 3) n = n.parentNode;
+        var cell = n && n.closest ? n.closest("td,th") : null;
+        return cell && editor.contains(cell) ? cell : null;
+    }
+    function currentTable() {
+        var cell = currentCell();
+        return cell ? cell.closest("table") : null;
     }
     function tableAddRow(table, index) {
         var cols = (table.rows[0] ? table.rows[0].cells.length : 1);
         var tr = table.insertRow(index);
         for (var i = 0; i < cols; i++) tr.insertCell(-1).innerHTML = "<br>";
-        afterEdit(true);
+        applyTableStyle(table);
     }
     function tableAddCol(table, index) {
         for (var i = 0; i < table.rows.length; i++) {
@@ -838,12 +940,16 @@
             var at = Math.min(index, row.cells.length);
             row.insertCell(at).innerHTML = "<br>";
         }
-        afterEdit(true);
+        applyTableStyle(table);
     }
     function tableDelRow(table, ri) {
         table.deleteRow(ri);
-        if (table.rows.length === 0 && table.parentNode) table.parentNode.removeChild(table);
-        afterEdit(true);
+        if (table.rows.length === 0 && table.parentNode) {
+            table.parentNode.removeChild(table);
+            afterEdit(true);
+            return;
+        }
+        applyTableStyle(table);
     }
     function tableDelCol(table, ci) {
         var empty = true;
@@ -852,8 +958,98 @@
             if (row.cells.length > ci) row.deleteCell(ci);
             if (row.cells.length > 0) empty = false;
         }
-        if (empty && table.parentNode) table.parentNode.removeChild(table);
-        afterEdit(true);
+        if (empty && table.parentNode) {
+            table.parentNode.removeChild(table);
+            afterEdit(true);
+            return;
+        }
+        applyTableStyle(table);
+    }
+    /* the contextual "Table" menubar menu (visible while the caret is in a
+       table) - also reused as a submenu of the table context menu */
+    function tableMenuItems() { return tableMenuItemsFor(currentCell()); }
+    function tableMenuItemsFor(cell) {
+        var table = cell ? cell.closest("table") : null;
+        if (!table) {
+            return [{ label: "Click inside a table first", enabled: function () { return false; }, action: function () { } }];
+        }
+        function toggleAttr(attr) {
+            table.setAttribute(attr, table.getAttribute(attr) === "1" ? "" : "1");
+            applyTableStyle(table);
+        }
+        var items = [
+            {
+                label: "Header row", icon: "heading",
+                checked: function () { return table.getAttribute("data-th-head") === "1"; },
+                action: function () { toggleAttr("data-th-head"); }
+            },
+            {
+                label: "Banded rows", icon: "bars",
+                checked: function () { return table.getAttribute("data-th-band") === "1"; },
+                action: function () { toggleAttr("data-th-band"); }
+            },
+            {
+                label: "Table theme", icon: "paint brush",
+                sub: TABLE_THEMES.map(function (t) {
+                    return {
+                        label: t.name,
+                        action: function () {
+                            table.setAttribute("data-th-bc", t.bc);
+                            table.setAttribute("data-th-headbg", t.headbg);
+                            table.setAttribute("data-th-headfc", t.headfc);
+                            table.setAttribute("data-th-bandbg", t.bandbg);
+                            table.setAttribute("data-th-head", "1");
+                            table.setAttribute("data-th-band", "1");
+                            applyTableStyle(table);
+                        }
+                    };
+                })
+            },
+            {
+                label: "Border weight", icon: "window minimize outline",
+                sub: [0.5, 1, 2, 3].map(function (w) {
+                    return {
+                        label: w + " px",
+                        checked: function () { return (parseFloat(table.getAttribute("data-th-bw")) || 1) === w; },
+                        action: function () {
+                            table.setAttribute("data-th-bw", String(w));
+                            applyTableStyle(table);
+                        }
+                    };
+                })
+            },
+            {
+                label: "Border color...", icon: "pencil alternate",
+                action: function () {
+                    OfficeColorPicker.open({
+                        anchor: cell,
+                        value: table.getAttribute("data-th-bc") || "#b9bec7",
+                        onPick: function (hex) {
+                            if (!hex) return;
+                            table.setAttribute("data-th-bc", hex);
+                            applyTableStyle(table);
+                        }
+                    });
+                }
+            },
+            {
+                label: "Cell background...", icon: "square full",
+                action: function () {
+                    OfficeColorPicker.open({
+                        anchor: cell,
+                        value: cell.getAttribute("data-cellbg") || "#ffffff",
+                        allowNone: true, noneLabel: "Clear (use theme)",
+                        onPick: function (hex) {
+                            if (hex) cell.setAttribute("data-cellbg", hex);
+                            else cell.removeAttribute("data-cellbg");
+                            applyTableStyle(table);
+                        }
+                    });
+                }
+            },
+            { sep: true }
+        ];
+        return items.concat(tableContextItems(cell));
     }
     function tableContextItems(cell) {
         var table = cell.closest("table");
@@ -1086,7 +1282,74 @@
     }
     /* ---------- page guides (visual pagination) ---------- */
     var MM_PX = 96 / 25.4;
+    var PAGE_GAP_PX = 24;    // visual gap between two sheets at a page break
     function pageGuidesOn() { return OfficeApp.getSetting("pageGuides", true); }
+    // element top in layout px relative to #page (CSS zoom safe - unlike
+    // getBoundingClientRect, offsetTop is not scaled)
+    function offsetTopInPage(el) {
+        var y = 0;
+        while (el && el !== pageEl) {
+            y += el.offsetTop;
+            el = el.offsetParent;
+        }
+        return y;
+    }
+    /* True multi-page rendering.
+       Every page boundary - explicit (Insert > Page break) or automatic
+       (content overflow) - is a real block in the flow that eats the rest
+       of its sheet and paints the gap between two separate sheets:
+         - explicit breaks are .doc-pagebreak (persisted, exported to docx)
+         - automatic ones are .doc-pagebreak.doc-autobreak, layout-only
+           spacers inserted before the block that crosses the boundary and
+           STRIPPED from serialization (cleanedHtml)
+       Blocks taller than a page cannot be split, and CSS multi-column
+       layouts reflow around inserted blocks, so those cases fall back to
+       the dotted guide line. */
+    var GAP_OVERHANG_PX = 26;   // gap band reaches past the sheet edge so it
+                                // also cuts the sheet's side shadow
+    function gapMarkup() {
+        return '<div class="doc-pb-gap"><div class="doc-pb-gap-in"></div></div>';
+    }
+    function removeAutoBreaks(root) {
+        var list = root.querySelectorAll(".doc-autobreak");
+        for (var i = 0; i < list.length; i++) {
+            if (list[i].parentNode) list[i].parentNode.removeChild(list[i]);
+        }
+    }
+    /* stretch a break element so the next content starts at the top of the
+       following sheet; returns that sheet's content-top offset */
+    function stretchBreak(el, pageStart, innerHpx, topPx, mLeftPx, mRightPx, mBotPx) {
+        el.style.height = "0px";
+        var top = offsetTopInPage(el);
+        // a break can sit several auto-pages further down (guarded above,
+        // but keep the math safe)
+        while (top >= pageStart + innerHpx) pageStart += innerHpx;
+        var rest = Math.max(0, pageStart + innerHpx - top);
+        el.style.height = (rest + mBotPx + PAGE_GAP_PX + topPx) + "px";
+        var gap = el.querySelector(".doc-pb-gap");
+        if (gap) {
+            gap.style.top = (rest + mBotPx) + "px";
+            gap.style.height = PAGE_GAP_PX + "px";
+            gap.style.left = -(mLeftPx + GAP_OVERHANG_PX) + "px";
+            gap.style.right = -(mRightPx + GAP_OVERHANG_PX) + "px";
+            var inner = gap.firstChild;
+            if (inner) {
+                // the sheet-edge shadows only span the actual sheet width
+                inner.style.left = GAP_OVERHANG_PX + "px";
+                inner.style.right = GAP_OVERHANG_PX + "px";
+            }
+        }
+        return top + parseFloat(el.style.height);
+    }
+    // first top-level editor block extending past the boundary (the one that
+    // must move to the next sheet)
+    function blockAtBoundary(boundary) {
+        for (var c = editor.firstElementChild; c; c = c.nextElementSibling) {
+            var top = offsetTopInPage(c);
+            if (top + c.offsetHeight > boundary + 1) return c;
+        }
+        return null;
+    }
     function updatePageGuides() {
         var holder = document.getElementById("pageGuides");
         if (!holder) {
@@ -1099,23 +1362,98 @@
         var d = PAGE_SIZES[pageConf.size] || PAGE_SIZES.A4;
         var pageHmm = (pageConf.orientation === "landscape") ? d.w : d.h;
         var m = pageConf.margins;
-        var innerHpx = (pageHmm - m.top - m.bottom) * MM_PX;
+        var innerHpx = Math.max(60, (pageHmm - m.top - m.bottom) * MM_PX);
         var topPx = m.top * MM_PX;
-        // small epsilon guards against 1px scrollHeight rounding creating
-        // a phantom extra page
-        var contentH = pageEl.scrollHeight - topPx - m.bottom * MM_PX - 6;
-        var pages = Math.max(1, Math.ceil(contentH / Math.max(60, innerHpx)));
-        if (pageGuidesOn()) {
-            for (var n = 1; n < pages; n++) {
+        var mL = m.left * MM_PX, mR = m.right * MM_PX, mB = m.bottom * MM_PX;
+        var multiCol = pageConf.columns > 1;
+
+        // relayout from the natural flow (self-heal breaks that arrived
+        // without their gap markup, e.g. via raw HTML)
+        removeAutoBreaks(editor);
+        normalizePageBreaks();
+        var breaks = Array.prototype.slice.call(editor.querySelectorAll(".doc-pagebreak"));
+        var i;
+        for (i = 0; i < breaks.length; i++) breaks[i].style.height = "0px";
+
+        var pages = 1;
+        var pageStart = topPx;
+        var bi = 0;
+        var guard = 0;
+        while (guard++ < 400) {
+            var boundary = pageStart + innerHpx;
+            // (a) an explicit break on this page ends it early
+            if (bi < breaks.length && offsetTopInPage(breaks[bi]) <= boundary + 0.5) {
+                pageStart = stretchBreak(breaks[bi++], pageStart, innerHpx, topPx, mL, mR, mB);
+                pages++;
+                continue;
+            }
+            // small epsilon guards against 1px scrollHeight rounding creating
+            // a phantom extra page
+            if (boundary >= pageEl.scrollHeight - mB - 6 - 0.5) break;
+            // (b) automatic overflow: separate the sheets for real by pushing
+            // the crossing block onto the next sheet with a layout-only spacer
+            if (!multiCol) {
+                var block = blockAtBoundary(boundary);
+                if (block && block === breaks[bi]) {
+                    // the boundary lands just before an explicit break - the
+                    // break itself owns this cut
+                    pageStart = stretchBreak(breaks[bi++], pageStart, innerHpx, topPx, mL, mR, mB);
+                    pages++;
+                    continue;
+                }
+                if (block && offsetTopInPage(block) > pageStart + 1) {
+                    var sp = document.createElement("div");
+                    sp.className = "doc-pagebreak doc-autobreak";
+                    sp.setAttribute("contenteditable", "false");
+                    sp.innerHTML = gapMarkup();
+                    block.parentNode.insertBefore(sp, block);
+                    pageStart = stretchBreak(sp, pageStart, innerHpx, topPx, mL, mR, mB);
+                    pages++;
+                    continue;
+                }
+            }
+            // (c) unsplittable block / multi-column layout: dotted fallback
+            if (pageGuidesOn()) {
                 var g = document.createElement("div");
                 g.className = "doc-pageguide";
-                g.style.top = (topPx + n * innerHpx) + "px";
-                g.setAttribute("data-label", "Page " + (n + 1));
+                g.style.top = boundary + "px";
+                g.setAttribute("data-label", "Page " + (pages + 1));
                 holder.appendChild(g);
             }
+            pageStart = boundary;
+            pages++;
         }
+        // the last sheet always shows at full page height
+        pageEl.style.minHeight = (pageStart + innerHpx + mB) + "px";
         return pages;
     }
+    /* ---------- explicit page breaks ---------- */
+    function pageBreakHtml() {
+        return '<div class="doc-pagebreak" contenteditable="false">' +
+            gapMarkup() + "</div>";
+    }
+    // imported / restored breaks may lack the gap child or the guard attribute
+    function normalizePageBreaks() {
+        var list = editor.querySelectorAll(".doc-pagebreak");
+        for (var i = 0; i < list.length; i++) {
+            list[i].setAttribute("contenteditable", "false");
+            if (!list[i].querySelector(".doc-pb-gap-in")) {
+                list[i].innerHTML = gapMarkup();
+            }
+        }
+    }
+    function insertPageBreak() {
+        if (inHeaderFooter()) return;
+        restoreSel();
+        // trailing paragraph so there is always somewhere to type on the new page
+        try {
+            document.execCommand("insertHTML", false, pageBreakHtml() + "<p><br></p>");
+        } catch (e) { return; }
+        normalizePageBreaks();
+        afterEdit(true);
+        updatePageGuides();
+        OfficeApp.setStatus("Page break inserted");
+    }
     function toggleLayoutBoxes() {
         var on = !document.body.classList.contains("doc-show-boxes");
         document.body.classList.toggle("doc-show-boxes", on);
@@ -1615,6 +1953,88 @@
         }
     }
 
+    /* ================= menu-driven clipboard =================
+       Ctrl+X/C/V go through the native events; menu clicks have no event,
+       so use the async clipboard API (image > html > plain text, mirroring
+       handleEditorPaste, including suggest-mode wrapping). */
+    function menuCutCopy(isCut) {
+        if (selectedImg) {
+            // image selection is app-level - write it as HTML ourselves
+            var clone = selectedImg.cloneNode(false);
+            clone.classList.remove("of-selimg");
+            if (!clone.getAttribute("class")) clone.removeAttribute("class");
+            try { clone.src = selectedImg.src; } catch (e) { }
+            OfficeClipboard.writeAsync({ html: clone.outerHTML, text: "" }).catch(function () { });
+            if (isCut) {
+                var img = selectedImg;
+                deselectImage();
+                if (img.parentNode) img.parentNode.removeChild(img);
+                afterEdit(true);
+            } else {
+                OfficeApp.setStatus("Image copied");
+            }
+            return;
+        }
+        restoreSel();
+        try { document.execCommand(isCut ? "cut" : "copy"); } catch (e) { }
+        if (isCut) afterEdit(true);
+    }
+    function pasteFromMenu(plainOnly) {
+        var fallbackText = function () {
+            if (navigator.clipboard && navigator.clipboard.readText) {
+                navigator.clipboard.readText().then(function (t) {
+                    if (!t) return;
+                    restoreSel();
+                    if (suggesting && !inHeaderFooter()) suggestInsert(t);
+                    else { try { document.execCommand("insertText", false, t); } catch (e) { } }
+                    afterEdit(true);
+                }).catch(function () {
+                    OfficeApp.setStatus("Paste blocked by the browser - use Ctrl+V instead", "error");
+                });
+            } else {
+                OfficeApp.setStatus("Use Ctrl+V to paste here", "error");
+            }
+        };
+        if (plainOnly || !(navigator.clipboard && navigator.clipboard.read)) {
+            fallbackText();
+            return;
+        }
+        navigator.clipboard.read().then(function (cbItems) {
+            var img = null, htmlIt = null;
+            cbItems.forEach(function (it) {
+                it.types.forEach(function (ty) {
+                    if (!img && ty.indexOf("image/") === 0) img = { it: it, ty: ty };
+                    if (!htmlIt && ty === "text/html") htmlIt = it;
+                });
+            });
+            if (img) {
+                img.it.getType(img.ty).then(function (blob) {
+                    OfficeApp.blobToSrc(blob, "pasted.png", function (src) {
+                        restoreSel();
+                        insertImage(src);
+                    }, function (msg) { OfficeApp.toast(msg, "error"); });
+                }).catch(fallbackText);
+                return;
+            }
+            if (htmlIt) {
+                htmlIt.getType("text/html").then(function (blob) {
+                    return blob.text();
+                }).then(function (html) {
+                    var clean = sanitizeHtml(html, { keepClasses: false });
+                    if (!clean) { fallbackText(); return; }
+                    if (suggesting && !inHeaderFooter()) {
+                        clean = '<ins class="doc-ins">' + clean + "</ins>";
+                    }
+                    restoreSel();
+                    try { document.execCommand("insertHTML", false, clean); } catch (e) { }
+                    afterEdit(true);
+                }).catch(fallbackText);
+                return;
+            }
+            fallbackText();
+        }).catch(fallbackText);
+    }
+
     /* ================= paste ================= */
     function handleEditorPaste(e) {
         var cd = e.clipboardData;
@@ -2256,13 +2676,13 @@
             }
             var cell = e.target.closest ? e.target.closest("td,th") : null;
             if (cell && cell.closest("table.of-table") && editor.contains(cell)) {
-                items = items.concat(tableContextItems(cell));
+                items.push({ label: "Table", icon: "table", sub: tableMenuItemsFor(cell) });
+                items.push({ sep: true });
             }
             if (pageConf.columns > 1) {
                 // let the user pick which region spans the full page width
                 var block = e.target.closest ? e.target.closest(BLOCK_SEL) : null;
                 if (block && editor.contains(block)) {
-                    if (items.length) items.push({ sep: true });
                     items.push({
                         label: "Full width (span all columns)", icon: "columns",
                         checked: function () { return block.classList.contains("col-span-all"); },
@@ -2276,12 +2696,31 @@
                         checked: function () { return document.body.classList.contains("doc-show-boxes"); },
                         action: toggleLayoutBoxes
                     });
+                    items.push({ sep: true });
                 }
             }
-            if (items.length) {
-                e.preventDefault();
-                OfficeApp.showContextMenu(e.clientX, e.clientY, items);
-            }
+            // standard edit block (like Google Docs / Word)
+            var selNow2 = window.getSelection();
+            var canCopy = !!selectedImg || !!(selNow2 && selNow2.rangeCount &&
+                !selNow2.getRangeAt(0).collapsed &&
+                editor.contains(selNow2.getRangeAt(0).commonAncestorContainer));
+            var copyOk = function () { return canCopy; };
+            items.push({ label: "Cut", icon: "cut", key: "Ctrl+X", enabled: copyOk, action: function () { menuCutCopy(true); } });
+            items.push({ label: "Copy", icon: "copy", key: "Ctrl+C", enabled: copyOk, action: function () { menuCutCopy(false); } });
+            items.push({ label: "Paste", icon: "paste", key: "Ctrl+V", action: function () { pasteFromMenu(false); } });
+            items.push({ label: "Paste without formatting", icon: "clipboard outline", action: function () { pasteFromMenu(true); } });
+            items.push({ sep: true });
+            items.push({ label: "Bold", icon: "bold", key: "Ctrl+B", action: function () { exec("bold"); } });
+            items.push({ label: "Italic", icon: "italic", key: "Ctrl+I", action: function () { exec("italic"); } });
+            items.push({ label: "Underline", icon: "underline", key: "Ctrl+U", action: function () { exec("underline"); } });
+            items.push({ label: "Clear formatting", icon: "eraser", action: clearFormatting });
+            items.push({ sep: true });
+            items.push({ label: "Insert link...", icon: "linkify", key: "Ctrl+K", action: linkDialog });
+            items.push({ label: "Insert page break", icon: "file outline", key: "Ctrl+Enter", action: insertPageBreak });
+            items.push({ sep: true });
+            items.push({ label: "Select all", icon: "i cursor", key: "Ctrl+A", action: selectAll });
+            e.preventDefault();
+            OfficeApp.showContextMenu(e.clientX, e.clientY, items);
         });
         // deselect image when clicking anywhere else / typing starts elsewhere
         document.addEventListener("mousedown", function (e) {
@@ -2321,6 +2760,8 @@
         OfficeApp.registerShortcut("Ctrl+K", linkDialog, { description: "Insert link", group: "Text" });
         OfficeApp.registerShortcut("Ctrl+Alt+M", function () { commentDialog(null); },
             { description: "Add comment", group: "Review" });
+        OfficeApp.registerShortcut("Ctrl+Enter", insertPageBreak,
+            { description: "Insert page break", group: "Text" });
         OfficeApp.registerShortcut("Ctrl+F", function () { openFind(false); }, { description: "Find" });
         OfficeApp.registerShortcut("Ctrl+H", function () { openFind(true); }, { description: "Find and replace" });
         // lists - register both the digit and the shifted symbol (layout dependent)
@@ -2386,6 +2827,12 @@
             canUndo: function () { return undo.canUndo(); },
             canRedo: function () { return undo.canRedo(); },
 
+            // Edit menubar clipboard shares the context-menu implementations
+            // (image-aware copy, async rich paste)
+            onCut: function () { menuCutCopy(true); },
+            onCopy: function () { menuCutCopy(false); },
+            onPaste: function () { pasteFromMenu(false); },
+
             menus: [
                 {
                     title: "Insert",
@@ -2396,6 +2843,7 @@
                             { label: "Link...", icon: "linkify", key: "Ctrl+K", action: linkDialog },
                             { label: "Comment...", icon: "comment outline", key: "Ctrl+Alt+M", action: function () { commentDialog(null); } },
                             { sep: true },
+                            { label: "Page break", icon: "file outline", key: "Ctrl+Enter", action: insertPageBreak },
                             { label: "Horizontal rule", icon: "minus", action: function () { exec("insertHorizontalRule"); } },
                             { label: "Code block", icon: "code", action: function () { applyParagraphStyle("pre"); } },
                             { label: "Block quote", icon: "quote left", action: function () { applyParagraphStyle("blockquote"); } },
@@ -2446,6 +2894,12 @@
                             { label: "Clear formatting", icon: "eraser", action: clearFormatting }
                         ];
                     }
+                },
+                {
+                    // contextual: only visible while the caret is in a table
+                    title: "Table",
+                    when: function () { return !!currentTable(); },
+                    items: tableMenuItems
                 }
             ],
             fileMenuExtras: [
@@ -2495,7 +2949,7 @@
                     }
                 },
                 {
-                    label: "Layout boxes (regions)",
+                    label: "Layout boxes",
                     checked: function () { return document.body.classList.contains("doc-show-boxes"); },
                     action: toggleLayoutBoxes
                 },