Browse Source

Refactor CF rules to be cell-owned

Conditional formatting rules now belong to cells (cell.cf lists rule ids) rather than the sheet (sheet.cf array with ranges). The sheet holds rule bodies in cfDefs keyed by id.

This makes rules behave like the rest of a cell's formatting: they travel on move/copy/fill automatically, the panel shows only rules on the current selection, and applying to a range stamps the id onto each cell. Editing is copy-on-write — a new body is minted and old orphaned defs are swept up.

Other changes:
- Add Ctrl/Cmd+wheel zoom with delta accumulation for smooth trackpad pinch
- Add favicon links to docs, sheets and slides pages
- Upgrade CF operand inputs to inline grid-range pickers (crosshair button)
- Operand references (e.g. "B3") now shift per-cell like custom formulas do
- Update README and inline doc comments to match
Toby Chui 1 day ago
parent
commit
80cda04459

+ 25 - 8
src/web/Office/README.md

@@ -148,11 +148,27 @@ cross-sheet references. Add new functions to the `call()` switch in
 
 ### Sheets conditional formatting
 
-[`sheets/sheets_cf.js`](sheets/sheets_cf.js) (`SheetsCF`) keeps a list of
-rules per sheet in `cf` and re-evaluates them every time the grid paints.
-Rule kinds: empty/not-empty, the text tests (contains, starts/ends with, is
-exactly), numeric comparisons incl. between, date before/after/on, and a
-**custom formula**.
+[`sheets/sheets_cf.js`](sheets/sheets_cf.js) (`SheetsCF`) re-evaluates rules
+every time the grid paints. Rule kinds: empty/not-empty, the text tests
+(contains, starts/ends with, is exactly), numeric comparisons incl. between,
+date before/after/on, and a **custom formula**.
+
+**Rules belong to cells, not to the sheet.** `cell.cf` lists the rule ids a
+cell carries and `sheet.cfDefs` holds the bodies. That is what makes a rule
+behave like the rest of a cell's formatting: the panel shows only the rules
+on the current selection, and a rule travels on move, copy and fill because
+those already move whole cell objects (`deep(cell)`). Applying to a range
+stamps the id onto every cell in it, so range-wide rules are still one
+action — including onto empty cells, so a value typed there later is still
+formatted. `MAX_STAMP` caps how many cells one apply may touch.
+
+The id is shared, so editing is **copy-on-write**: the edit mints a new def
+and swaps it onto just the selected cells, leaving other cells that shared
+the old rule alone. `sweepDefs()` drops bodies nothing references.
+
+Ids are used rather than inlining rule objects per cell because `snap()`
+JSON-stringifies the whole body into the undo stack on every commit, 80 deep
+— a 1000-cell column rule costs ~27 KB this way instead of well over 100 KB.
 
 Two things make range rules work without a separate rule kind:
 
@@ -178,9 +194,10 @@ mistaken for something the user applied by hand.
 
 Rules ride in the document body, so they persist in `.xlsa` and reach PDF
 export through the print model. **They are dropped on `.xlsx` / `.ods`
-export** — those writers would need real DXF / style-map records. If that
-matters, the alternative is baking the resolved colours into the exported
-cells' own styles at export time.
+export** — those writers would need real DXF / style-map records, and the Go
+structs model neither `cell.cf` nor `sheet.cfDefs`. If that matters, the
+alternative is baking the resolved colours into the exported cells' own
+styles at export time.
 
 ### Saving back into a foreign format
 

+ 26 - 0
src/web/Office/common/office.js

@@ -843,6 +843,30 @@ var OfficeApp = (function () {
         zoom = Math.max(25, Math.min(400, Math.round(z)));
         applyZoom();
     }
+    /*
+        Ctrl/Cmd + wheel zooms the document, not the whole ArozOS desktop.
+        The browser's own page zoom owns that gesture, so the listener has to
+        be non-passive to be allowed to preventDefault it.
+
+        Deltas are accumulated rather than stepped per event: one mouse notch
+        is ~100, but a trackpad pinch arrives as a stream of small deltas
+        (Chrome reports those as wheel events with ctrlKey set) and stepping
+        on each one would rocket through the zoom levels.
+    */
+    var WHEEL_ZOOM_STEP = 40;
+    var wheelAccum = 0;
+    function onWheelZoom(e) {
+        if (!e.ctrlKey && !e.metaKey) return;
+        e.preventDefault();
+        // reversing direction should respond at once rather than first
+        // having to work off the leftover from the other direction
+        if (wheelAccum !== 0 && (wheelAccum < 0) !== (e.deltaY < 0)) wheelAccum = 0;
+        wheelAccum += e.deltaY;
+        // at most one level per event, so a single wheel notch (~100) is one
+        // step while a stream of small pinch deltas still adds up to one
+        if (wheelAccum <= -WHEEL_ZOOM_STEP) { wheelAccum = 0; zoomStep(1); }
+        else if (wheelAccum >= WHEEL_ZOOM_STEP) { wheelAccum = 0; zoomStep(-1); }
+    }
     function zoomStep(dir) {
         var i;
         if (dir > 0) {
@@ -1315,6 +1339,8 @@ var OfficeApp = (function () {
         registerShortcut("Ctrl++", function () { zoomStep(1); });
         registerShortcut("Ctrl+-", function () { zoomStep(-1); }, { description: "Zoom out" });
         registerShortcut("Ctrl+0", function () { setZoom(100); }, { description: "Reset zoom" });
+        // non-passive so the browser's own page zoom can be preventDefault-ed
+        window.addEventListener("wheel", onWheelZoom, { passive: false });
         if (cfg.onUndo) registerShortcut("Ctrl+Z", function () { cfg.onUndo(); }, { description: "Undo" });
         if (cfg.onRedo) {
             registerShortcut("Ctrl+Y", function () { cfg.onRedo(); }, { description: "Redo" });

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

@@ -4,6 +4,7 @@
     <meta charset="UTF-8">
     <meta name="viewport" content="width=device-width, initial-scale=1">
     <title>Docs</title>
+    <link rel="icon" type="image/png" href="docs.png">
     <link rel="stylesheet" href="../../script/semantic/semantic.min.css">
     <link rel="stylesheet" href="../common/office.css">
     <link rel="stylesheet" href="docs.css">

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

@@ -4,6 +4,7 @@
     <meta charset="UTF-8">
     <meta name="viewport" content="width=device-width, initial-scale=1">
     <title>Sheets</title>
+    <link rel="icon" type="image/png" href="sheets.png">
     <link rel="stylesheet" href="../../script/semantic/semantic.min.css">
     <link rel="stylesheet" href="../common/office.css">
     <link rel="stylesheet" href="sheets.css">

+ 29 - 0
src/web/Office/sheets/sheets.css

@@ -326,6 +326,13 @@ body.dark {
 .sh-filter-list input[type="checkbox"] { width: auto !important; }
 
 /* ============ conditional formatting dialog ============ */
+.sh-cf-scope {
+    font-size: 12px;
+    color: var(--of-fg-soft);
+    padding-bottom: 8px;
+    margin-bottom: 8px;
+    border-bottom: 1px solid var(--of-border);
+}
 .sh-cf-empty {
     color: var(--of-fg-soft);
     font-size: 13px;
@@ -365,6 +372,28 @@ body.dark {
 .sh-cf-range { font-size: 12px; color: var(--of-fg-soft); }
 .sh-cf-del { flex: 0 0 auto; }
 .sh-cf-add { margin-top: 4px; }
+/* text box with the grid-picker crosshair tucked into its right edge */
+.sh-cf-refwrap { position: relative; }
+.sh-cf-refwrap input[type="text"] { padding-right: 32px !important; }
+.sh-cf-refpick {
+    position: absolute;
+    right: 4px;
+    top: 50%;
+    transform: translateY(-50%);
+    width: 24px;
+    height: 24px;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+    padding: 0;
+    border: none;
+    border-radius: 3px;
+    background: transparent;
+    color: var(--of-fg-soft);
+    cursor: pointer;
+}
+.sh-cf-refpick:hover { background: var(--of-hover); color: var(--of-accent); }
+.sh-cf-refpick i.icon { margin: 0 !important; font-size: 13px !important; }
 .sh-cf-hint {
     font-size: 12px;
     color: var(--of-fg-soft);

+ 53 - 14
src/web/Office/sheets/sheets.js

@@ -12,6 +12,8 @@
                 cells: {                         // sparse, keyed "A1"
                     "A1": { v: "raw input ('=' prefix = formula)",
                             n: "cell note",      // optional; xlsx comment
+                            cf: ["cf-a1b2"],     // conditional rules this
+                                                 // cell carries (sheets_cf.js)
                             s: {                 // style, all optional
                                 b,i,u: bool,
                                 al: "l"|"c"|"r",
@@ -40,11 +42,11 @@
                          colField: 1,            // offsets in range, -1=none
                          valField: 2,
                          agg: "sum"|"count"|"avg"|"min"|"max" },
-                cf: [ { id, range: "A1:F100",    // conditional formatting
-                        type: "gt"|"contains"|   // (sheets_cf.js); evaluated
-                              "formula"|...,     // top-down while painting
-                        v1, v2,                  // operands, as typed
-                        style: { bg, fc, b, i, u } } ]
+                cfDefs: { "cf-a1b2": {           // conditional rule bodies;
+                    anchor: "B2",                // cells reference them by id
+                    type: "gt"|"contains"|"formula"|...,
+                    v1, v2,                      // operands, as typed
+                    style: { bg, fc, b, i, u } } }
             }
         ],
         active: 0
@@ -104,7 +106,7 @@ var SheetsApp = (function () {
         return {
             name: name, color: null, cols: 26, rows: 200,
             cells: {}, colW: {}, rowH: {}, merges: [],
-            freeze: { r: 0, c: 0 }, filter: null, charts: [], cf: []
+            freeze: { r: 0, c: 0 }, filter: null, charts: [], cfDefs: {}
         };
     }
     function defaultBody() {
@@ -129,11 +131,16 @@ var SheetsApp = (function () {
             s.freeze.c = clamp(parseInt(s.freeze.c, 10) || 0, 0, 10);
             s.filter = s.filter || null;
             s.charts = Array.isArray(s.charts) ? s.charts : [];
-            s.cf = Array.isArray(s.cf) ? s.cf : [];
+            s.cfDefs = (s.cfDefs && typeof s.cfDefs === "object") ? s.cfDefs : {};
         });
         b.active = clamp(parseInt(b.active, 10) || 0, 0, b.sheets.length - 1);
         return b;
     }
+    // a cell stays in the map while it still carries anything at all
+    function cellIsBare(cell) {
+        return !cell || (!cell.v && !cell.s && !cell.n &&
+            !(cell.cf && cell.cf.length));
+    }
     function cellObj(c, r, create) {
         var s = sheet(), k = key(c, r);
         var cell = s.cells[k];
@@ -171,7 +178,7 @@ var SheetsApp = (function () {
             if (cell) {
                 delete cell.v;
                 cell.v = "";
-                if (!cell.s) delete s.cells[k];
+                if (cellIsBare(cell)) delete s.cells[k];
             }
         } else {
             if (!s.cells[k]) s.cells[k] = { v: "" };
@@ -643,7 +650,7 @@ var SheetsApp = (function () {
             cell.n = text;
         } else if (cell) {
             delete cell.n;
-            if (cell.v === "" && !cell.s) delete s.cells[k];
+            if (cellIsBare(cell)) delete s.cells[k];
         }
         commit();
     }
@@ -1385,7 +1392,7 @@ var SheetsApp = (function () {
             fn(cell.s);
             // prune empty style objects / cells
             if (Object.keys(cell.s).length === 0) delete cell.s;
-            if (!cell.v && !cell.s) delete sheet().cells[key(c, r)];
+            if (cellIsBare(cell)) delete sheet().cells[key(c, r)];
         });
         commit();
         syncToolbarFromSel();
@@ -1507,7 +1514,9 @@ var SheetsApp = (function () {
             if (rg.c1 === rg.c2 && rg.r1 === rg.r2) return null;
             return rangeStr(rg);
         }).filter(function (m) { return !!m; });
-        if (window.SheetsCF) SheetsCF.shiftRanges(axis, index, count);
+        // rule ids ride along inside the cells; only the anchors that their
+        // relative references are measured from need moving
+        if (window.SheetsCF) SheetsCF.shiftAnchors(axis, index, count);
         setActive(clamp(head.c, 0, s.cols - 1), clamp(head.r, 0, s.rows - 1));
         commit();
     }
@@ -1536,7 +1545,20 @@ var SheetsApp = (function () {
             }
             rows.push(row);
         }
-        return { w: rg.c2 - rg.c1 + 1, h: rg.r2 - rg.r1 + 1, src: { c: rg.c1, r: rg.r1 }, cells: rows, rg: rg };
+        // the cells carry rule ids; the bodies must ride along too, or a
+        // paste onto another sheet would land ids that resolve to nothing
+        var defs = {};
+        rows.forEach(function (row) {
+            row.forEach(function (cell) {
+                (cell && cell.cf ? cell.cf : []).forEach(function (id) {
+                    if (s.cfDefs && s.cfDefs[id]) defs[id] = deep(s.cfDefs[id]);
+                });
+            });
+        });
+        return {
+            w: rg.c2 - rg.c1 + 1, h: rg.r2 - rg.r1 + 1,
+            src: { c: rg.c1, r: rg.r1 }, cells: rows, rg: rg, defs: defs
+        };
     }
     /* charts ride the system clipboard as marker JSON, like cells ride
        it as TSV - so Ctrl+C on a selected chart pastes a chart, even
@@ -1665,6 +1687,13 @@ var SheetsApp = (function () {
     function pasteInternal() {
         var s = sheet();
         var w = clipInternal.w, h = clipInternal.h;
+        // bring any rule bodies the copied cells refer to onto this sheet
+        if (clipInternal.defs) {
+            if (!s.cfDefs) s.cfDefs = {};
+            Object.keys(clipInternal.defs).forEach(function (id) {
+                if (!s.cfDefs[id]) s.cfDefs[id] = deep(clipInternal.defs[id]);
+            });
+        }
         var dC = anchor.c - clipInternal.src.c;
         var dR = anchor.r - clipInternal.src.r;
         for (var r = 0; r < h; r++) {
@@ -2089,7 +2118,9 @@ var SheetsApp = (function () {
         $("#shBtnItalic").toggleClass("active", !!s.i);
         $("#shBtnUnderline").toggleClass("active", !!s.u);
         $("#shBtnFilter").toggleClass("active", !!sheet().filter);
-        $("#shBtnCondFmt").toggleClass("active", (sheet().cf || []).length > 0);
+        // lit when the cell under the cursor carries a rule of its own
+        var ac = sheet().cells[key(anchor.c, anchor.r)];
+        $("#shBtnCondFmt").toggleClass("active", !!(ac && ac.cf && ac.cf.length));
         $("#shBtnCurrency").toggleClass("active", s.fmt === "currency");
         $("#shBtnPercent").toggleClass("active", s.fmt === "percent");
     }
@@ -2140,7 +2171,9 @@ var SheetsApp = (function () {
             },
             {
                 label: "Clear conditional formatting", icon: "eraser",
-                enabled: function () { return (sheet().cf || []).length > 0; },
+                enabled: function () {
+                    return !!(window.SheetsCF && SheetsCF.selectionHasRules());
+                },
                 action: function () { if (window.SheetsCF) SheetsCF.clearForSelection(); }
             },
             { sep: true },
@@ -2405,6 +2438,12 @@ var SheetsApp = (function () {
         // evaluation context for conditional-format formulas: shares the
         // active sheet's memoized calculator, so rules cost a lookup
         calcCtx: function () { return calc.ctx; },
+        cellObj: cellObj,
+        // drop a cell that has nothing left on it (used after removing rules)
+        pruneCell: function (c, r) {
+            var k = key(c, r);
+            if (cellIsBare(sheet().cells[k])) delete sheet().cells[k];
+        },
         usedRange: usedRange,
         parseRange: parseRange,
         rangeStr: rangeStr,

+ 320 - 130
src/web/Office/sheets/sheets_cf.js

@@ -2,30 +2,40 @@
     ArozOS Office - Sheets: conditional formatting.
     Requires sheets.js (SheetsApp core API) and formula.js.
 
-    A sheet carries its rules in `cf: [rule, ...]`, evaluated top-down every
-    time the grid paints:
-
-        rule = {
-            id:    "cf-...",              // stable id, used by the editor
-            range: "A1:F1000",            // where the rule applies
-            type:  "gt" | "contains" | "formula" | ...   (see CONDS)
-            v1:    "200",                 // operand(s), kept as typed
-            v2:    "",                    // second operand for between
-            style: { bg, fc, b, i, u }    // what a matching cell gets
+    Rules belong to CELLS, not to the sheet. Each cell lists the rules it
+    carries and the sheet holds the rule bodies, keyed by id:
+
+        cell.cf    = ["cf-a1b2", ...]     // this cell's rules, in order
+        sheet.cfDefs["cf-a1b2"] = {
+            anchor: "B2",                 // cell the relative refs are read from
+            type:   "gt" | "contains" | "formula" | ...   (see CONDS)
+            v1:     "200",                // operand(s), kept as typed
+            v2:     "",                   // second operand for between
+            style:  { bg, fc, b, i, u }   // what a matching cell gets
         }
 
+    Ownership per cell is what makes a rule behave like the rest of a cell's
+    formatting: it shows up in the panel only for the cells that actually
+    carry it, and it travels on move, copy and fill because those already
+    move whole cell objects. Applying to a range simply stamps the id onto
+    every cell in it, so range-wide rules still take one action.
+
+    The id is shared, so editing a rule is copy-on-write: the edit mints a
+    new def and swaps it onto just the cells being edited, leaving any other
+    cells that happened to share the old rule alone. Defs nobody references
+    are swept up afterwards.
+
     Rules are first-match-wins *per property*, the way Google Sheets does it:
     the topmost matching rule that sets a background decides the background,
     and a later rule can still contribute a text color the first one left
     alone. Only bg/fc/b/i/u are conditional - number format, alignment and
     borders always come from the cell's own style.
 
-    Operands may be formulas themselves ("=AVERAGE($F$2:$F$99)"), which is
-    what makes range rules work: the operand is evaluated once against the
-    sheet, so "is greater than =AVERAGE(...)" highlights above-average cells.
-    A "Custom formula" rule instead evaluates its formula per cell, with
-    relative references shifted from the range's top-left corner, so
-    "=$F2>=SUM($C2:$E2)" tests every row against its own total.
+    Operands may be cell references or formulas ("=AVERAGE($F$2:$F$99)"),
+    and a "Custom formula" rule evaluates its formula per cell. Both shift
+    relative references by the cell's offset from the rule's anchor, so
+    "=$F2>=SUM($C2:$E2)" stamped down a column tests every row against its
+    own total.
 */
 
 var SheetsCF = (function () {
@@ -157,12 +167,28 @@ var SheetsCF = (function () {
             return null;
         }
     }
-    /* An operand is either a literal the user typed or a formula. Formulas
-       are evaluated once per rule (not per cell) against the anchor, so
-       "=AVERAGE($F$2:$F$99)" costs the same as a plain number. */
-    function operandValue(raw, anchor) {
-        var s = String(raw === undefined || raw === null ? "" : raw);
-        if (/^\s*=/.test(s)) return evalAt(compile(s), anchor.c, anchor.r);
+    // a bare A1-style reference or range and nothing else: "B3", "$B$3", "B1:B3"
+    var REF_RE = /^\$?[A-Za-z]{1,3}\$?\d+(?::\$?[A-Za-z]{1,3}\$?\d+)?$/;
+
+    /*
+        An operand is a literal the user typed, a cell reference, or a
+        formula. References and formulas are shifted per cell exactly like a
+        custom formula is, so a rule over B2:B10 comparing against "C2" tests
+        every row against its own C - and a single-cell rule shifts by zero,
+        which is simply the cell named.
+
+        A bare "B3" counts as a reference for the number and date tests: the
+        literal text could never match one of those anyway. Text tests keep
+        it literal, because product codes really do look like "B3"; write
+        "=B3" there when the cell is what is meant.
+    */
+    function operandValue(raw, cond, dC, dR) {
+        var s = String(raw === undefined || raw === null ? "" : raw).trim();
+        if (s === "") return null;
+        if (s.charAt(0) === "=") return evalAt(compile(s), dC, dR);
+        if (cond && cond.kind !== "text" && REF_RE.test(s)) {
+            return evalAt(compile(s), dC, dR);
+        }
         return F.literalValue(s);
     }
 
@@ -183,7 +209,8 @@ var SheetsCF = (function () {
         if (cond.id === "notempty") return !isBlank;
         if (isBlank) return false;   // every other test needs something to test
 
-        var a = operandValue(rule.v1, anchor);
+        var dC = c - anchor.c, dR = r - anchor.r;
+        var a = operandValue(rule.v1, cond, dC, dR);
         if (cond.kind === "text") {
             var hay = textOf(val).toLowerCase();
             var needle = textOf(a).toLowerCase();
@@ -224,7 +251,7 @@ var SheetsCF = (function () {
             case "lte": return n <= an;
             case "between":
             case "notbetween": {
-                var b = numOf(operandValue(rule.v2, anchor));
+                var b = numOf(operandValue(rule.v2, cond, dC, dR));
                 if (b === null) return false;
                 var lo = Math.min(an, b), hi = Math.max(an, b);
                 var within = n >= lo && n <= hi;
@@ -234,42 +261,49 @@ var SheetsCF = (function () {
         return false;
     }
 
-    /* ================= render hook ================= */
-    var rangeCache = {};    // range string -> parsed range | null
+    /* ================= storage ================= */
+    var anchorCache = {};   // anchor cell key -> {c,r}
 
-    function rangeOf(str) {
-        if (!Object.prototype.hasOwnProperty.call(rangeCache, str)) {
-            rangeCache[str] = Core.parseRange(str);
-        }
-        return rangeCache[str];
-    }
     // caches are keyed by text, so they only need clearing when rules change
     function invalidate() {
         compiled = {};
-        rangeCache = {};
+        anchorCache = {};
     }
-
-    function rules() {
+    function defs() {
         var s = Core.sheet();
-        return (s && Array.isArray(s.cf)) ? s.cf : [];
+        if (!s.cfDefs || typeof s.cfDefs !== "object") s.cfDefs = {};
+        return s.cfDefs;
     }
+    function defOf(id) { return defs()[id] || null; }
+    function anchorOf(def) {
+        var k = (def && def.anchor) || "A1";
+        if (!Object.prototype.hasOwnProperty.call(anchorCache, k)) {
+            var p = F.parseCellKey(k);
+            anchorCache[k] = p ? { c: p.col, r: p.row } : { c: 0, r: 0 };
+        }
+        return anchorCache[k];
+    }
+    // ids on one cell, in the order they were applied
+    function idsAt(c, r) {
+        var cell = Core.sheet().cells[F.cellName(c, r)];
+        return (cell && Array.isArray(cell.cf)) ? cell.cf : null;
+    }
+
+    /* ================= render hook ================= */
     /*
         The conditional part of a cell's style, or null when no rule applies.
-        Called for every painted cell, so it stays allocation-free until
-        something actually matches.
+        Called for every painted cell, so it does nothing at all until the
+        cell actually carries a rule.
     */
     function styleFor(c, r) {
-        var list = rules();
-        if (!list.length) return null;
+        var ids = idsAt(c, r);
+        if (!ids || !ids.length) return null;
         var out = null;
-        for (var i = 0; i < list.length; i++) {
-            var rule = list[i];
-            if (!rule || !rule.style) continue;
-            var rg = rangeOf(rule.range || "");
-            if (!rg || c < rg.c1 || c > rg.c2 || r < rg.r1 || r > rg.r2) continue;
-            var anchor = { c: rg.c1, r: rg.r1 };
-            if (!matches(rule, c, r, anchor)) continue;
-            var st = rule.style;
+        for (var i = 0; i < ids.length; i++) {
+            var def = defOf(ids[i]);
+            if (!def || !def.style) continue;
+            if (!matches(def, c, r, anchorOf(def))) continue;
+            var st = def.style;
             if (!out) out = {};
             // first rule to set a property owns it
             if (st.bg && !out.bg) out.bg = st.bg;
@@ -281,26 +315,110 @@ var SheetsCF = (function () {
         return out;
     }
 
-    /* row/column insert or delete moves the ranges rules point at */
-    function shiftRanges(axis, index, count) {
+    /*
+        Inserting or deleting rows/columns moves the cells themselves - and
+        their rule ids with them - so only the anchors that relative
+        references are measured from have to be adjusted.
+    */
+    function shiftAnchors(axis, index, count) {
+        var d = defs();
+        Object.keys(d).forEach(function (id) {
+            var p = F.parseCellKey(d[id].anchor || "A1");
+            if (!p) return;
+            var v = axis === "col" ? p.col : p.row;
+            if (count > 0) { if (v >= index) v += count; }
+            else {
+                var del = -count;
+                if (v >= index + del) v -= del;
+                else if (v >= index) v = index;
+            }
+            d[id].anchor = F.cellName(axis === "col" ? v : p.col,
+                axis === "row" ? v : p.row);
+        });
+        invalidate();
+    }
+
+    /* ================= per-cell bookkeeping ================= */
+    var MAX_STAMP = 50000;      // guard against "apply to the whole sheet"
+
+    function eachCellIn(rg, fn) {
+        for (var r = rg.r1; r <= rg.r2; r++) {
+            for (var c = rg.c1; c <= rg.c2; c++) fn(c, r);
+        }
+    }
+    function rangeCellCount(rg) {
+        return (rg.c2 - rg.c1 + 1) * (rg.r2 - rg.r1 + 1);
+    }
+    // stamp a rule id onto every cell of a range, replacing `replaces` when given
+    function stampRange(rg, id, replaces) {
+        eachCellIn(rg, function (c, r) {
+            var cell = Core.cellObj(c, r, true);
+            if (!Array.isArray(cell.cf)) cell.cf = [];
+            var at = replaces ? cell.cf.indexOf(replaces) : -1;
+            if (at >= 0) cell.cf[at] = id;
+            else if (cell.cf.indexOf(id) < 0) cell.cf.push(id);
+        });
+    }
+    function unstampRange(rg, id) {
+        eachCellIn(rg, function (c, r) {
+            var cell = Core.sheet().cells[F.cellName(c, r)];
+            if (!cell || !Array.isArray(cell.cf)) return;
+            cell.cf = cell.cf.filter(function (x) { return x !== id; });
+            if (!cell.cf.length) delete cell.cf;
+            Core.pruneCell(c, r);
+        });
+    }
+    // forget rule bodies no cell refers to any more
+    function sweepDefs() {
         var s = Core.sheet();
-        if (!Array.isArray(s.cf) || !s.cf.length) return;
-        s.cf.forEach(function (rule) {
-            var rg = Core.parseRange(rule.range || "");
-            if (!rg) return;
-            var lo = axis === "col" ? "c1" : "r1", hi = axis === "col" ? "c2" : "r2";
-            [lo, hi].forEach(function (kk) {
-                var v = rg[kk];
-                if (count > 0) { if (v >= index) rg[kk] = v + count; }
-                else {
-                    var del = -count;
-                    if (v >= index + del) rg[kk] = v - del;
-                    else if (v >= index) rg[kk] = index;
+        if (!s.cfDefs) return;
+        var live = {};
+        Object.keys(s.cells).forEach(function (k) {
+            var cell = s.cells[k];
+            if (cell && cell.cf) cell.cf.forEach(function (id) { live[id] = true; });
+        });
+        Object.keys(s.cfDefs).forEach(function (id) {
+            if (!live[id]) delete s.cfDefs[id];
+        });
+    }
+    /*
+        The rules present on the current selection, in the order the anchor
+        cell lists them, each with the block of selected cells carrying it.
+        This is what the panel shows - so it only ever describes the cells
+        the user has actually got selected.
+    */
+    function rulesInSelection() {
+        var sel = Core.selRange();
+        var seen = {}, order = [];
+        eachCellIn(sel, function (c, r) {
+            var ids = idsAt(c, r);
+            if (!ids) return;
+            ids.forEach(function (id) {
+                if (!defOf(id)) return;
+                var e = seen[id];
+                if (!e) {
+                    e = seen[id] = { id: id, def: defOf(id), n: 0,
+                        c1: c, c2: c, r1: r, r2: r };
+                    order.push(e);
                 }
+                e.n++;
+                if (c < e.c1) e.c1 = c;
+                if (c > e.c2) e.c2 = c;
+                if (r < e.r1) e.r1 = r;
+                if (r > e.r2) e.r2 = r;
             });
-            rule.range = Core.rangeStr(rg);
         });
-        invalidate();
+        return order;
+    }
+    function selectionHasRules() {
+        var sel = Core.selRange();
+        var found = false;
+        eachCellIn(sel, function (c, r) {
+            if (found) return;
+            var ids = idsAt(c, r);
+            if (ids && ids.length) found = true;
+        });
+        return found;
     }
 
     /* ================= rule descriptions ================= */
@@ -325,23 +443,81 @@ var SheetsCF = (function () {
     function genId() {
         return "cf-" + Date.now().toString(36) + Math.random().toString(36).substring(2, 6);
     }
-    function newRule() {
+    // an editor draft: the rule body plus the cells it is being applied to
+    function newDraft() {
         return {
-            id: genId(),
-            range: Core.rangeStr(Core.selRange()),
-            type: "notempty", v1: "", v2: "",
+            id: null,                                   // null = not saved yet
+            range: tidyRef(Core.rangeStr(Core.selRange())),
+            anchor: "", type: "notempty", v1: "", v2: "",
             style: {
                 bg: DEFAULT_STYLE.bg, fc: DEFAULT_STYLE.fc,
                 b: false, i: false, u: false
             }
         };
     }
+    function draftFrom(entry) {
+        return {
+            id: entry.id,
+            range: tidyRef(Core.rangeStr({
+                c1: entry.c1, r1: entry.r1, c2: entry.c2, r2: entry.r2
+            })),
+            anchor: entry.def.anchor || "",
+            type: entry.def.type, v1: entry.def.v1 || "", v2: entry.def.v2 || "",
+            style: $.extend({}, entry.def.style)
+        };
+    }
     function commitRules() {
+        sweepDefs();
         invalidate();
         Core.commit();
         Core.renderAll();
     }
 
+    // the grid picker always hands back "B3:B3" for one cell; say "B3"
+    function tidyRef(rgStr) {
+        var p = String(rgStr).split(":");
+        return (p.length === 2 && p[0] === p[1]) ? p[0] : String(rgStr);
+    }
+    /*
+        A text box with a crosshair tucked into its right edge: click it,
+        drag on the grid, and the reference lands back in the box. It goes in
+        at the caret when the box has focus, so a range can be dropped into
+        the middle of a half-typed formula; otherwise it replaces the whole
+        value, which is what clicking straight into an untouched box means.
+    */
+    function refInput(id, placeholder, value, onInput) {
+        var $wrap = $('<div class="sh-cf-refwrap"></div>');
+        var $in = $('<input type="text">')
+            .attr({ id: id, placeholder: placeholder }).val(value);
+        var $btn = $('<button type="button" class="sh-cf-refpick" ' +
+            'title="Select a cell or range on the grid"><i class="crosshairs icon"></i></button>');
+        // keep the caret where it was: a plain click would blur the input first
+        $btn.on("mousedown", function (e) { e.preventDefault(); });
+        $btn.on("click", function () {
+            var el = $in[0];
+            var focused = document.activeElement === el;
+            var from = focused ? el.selectionStart : null;
+            var to = focused ? el.selectionEnd : null;
+            Core.pickRangeFromGrid(function (rgStr) {
+                if (!rgStr) return;
+                var ref = tidyRef(rgStr);
+                var pos;
+                if (from === null) {
+                    el.value = ref;
+                    pos = ref.length;
+                } else {
+                    el.value = el.value.slice(0, from) + ref + el.value.slice(to);
+                    pos = from + ref.length;
+                }
+                $in.trigger("input").trigger("change");
+                el.focus();
+                try { el.setSelectionRange(pos, pos); } catch (e) { }
+            });
+        });
+        if (onInput) $in.on("input", onInput);
+        return $wrap.append($in).append($btn);
+    }
+
     // the dialog swaps between the rule list and the single-rule editor
     function open() {
         var $body = $('<div class="sh-cf"></div>');
@@ -354,37 +530,46 @@ var SheetsCF = (function () {
     }
 
     function showList($body) {
-        var list = rules();
+        var sel = tidyRef(Core.rangeStr(Core.selRange()));
+        var list = rulesInSelection();
         $body.empty();
+        $body.append($('<div class="sh-cf-scope"></div>')
+            .text("Rules on " + sel));
         if (!list.length) {
-            $body.append('<div class="sh-cf-empty">No rules on this sheet yet. ' +
-                'A rule paints cells in a range whenever their value matches a condition.</div>');
+            $body.append('<div class="sh-cf-empty">These cells carry no rules. ' +
+                'A rule belongs to the cells you apply it to and travels with them ' +
+                'when they are moved, copied or filled.</div>');
         }
         var $rows = $('<div class="sh-cf-list"></div>');
-        list.forEach(function (rule, i) {
+        list.forEach(function (entry) {
+            var covers = tidyRef(Core.rangeStr({
+                c1: entry.c1, r1: entry.r1, c2: entry.c2, r2: entry.r2
+            }));
             var $row = $('<div class="sh-cf-row"></div>');
             $row.append($('<div class="sh-cf-swatch"></div>')
-                .attr("style", swatchCss(rule.style || {})).text("123"));
+                .attr("style", swatchCss(entry.def.style || {})).text("123"));
             $row.append($('<div class="sh-cf-info"><div class="sh-cf-desc"></div>' +
                 '<div class="sh-cf-range"></div></div>')
-                .find(".sh-cf-desc").text(describe(rule)).end()
-                .find(".sh-cf-range").text(rule.range || "").end());
+                .find(".sh-cf-desc").text(describe(entry.def)).end()
+                .find(".sh-cf-range").text(
+                    covers + (entry.n > 1 ? "  -  " + entry.n + " cells" : "")).end());
             var $del = $('<button type="button" class="of-tbtn sh-cf-del" ' +
-                'title="Delete rule"><i class="trash alternate outline icon"></i></button>');
+                'title="Remove this rule from the selected cells">' +
+                '<i class="trash alternate outline icon"></i></button>');
             $del.on("click", function (e) {
                 e.stopPropagation();
-                Core.sheet().cf.splice(i, 1);
+                unstampRange(Core.selRange(), entry.id);
                 commitRules();
                 showList($body);
             });
             $row.append($del);
-            $row.on("click", function () { showEditor($body, rule, false); });
+            $row.on("click", function () { showEditor($body, draftFrom(entry), false); });
             $rows.append($row);
         });
         $body.append($rows);
         var $add = $('<button type="button" class="of-btn sh-cf-add">' +
             '<i class="plus icon"></i> Add another rule</button>');
-        $add.on("click", function () { showEditor($body, newRule(), true); });
+        $add.on("click", function () { showEditor($body, newDraft(), true); });
         $body.append($add);
     }
 
@@ -392,11 +577,7 @@ var SheetsCF = (function () {
         $body.empty();
         var $ed = $(
             '<label>Apply to range</label>' +
-            '<div style="display:flex;gap:6px;">' +
-            '<input type="text" id="shCfRange" style="flex:1;min-width:0;">' +
-            '<button type="button" class="of-tbtn" id="shCfPick" title="Select the range on the grid"' +
-            ' style="flex:0 0 auto;border:1px solid var(--of-border);"><i class="crosshairs icon"></i></button>' +
-            "</div>" +
+            '<div id="shCfRangeSlot"></div>' +
             '<label style="margin-top:10px;">Format cells if...</label>' +
             '<select id="shCfType"></select>' +
             '<div id="shCfArgs"></div>' +
@@ -412,24 +593,15 @@ var SheetsCF = (function () {
         );
         $body.append($ed);
 
-        var draft = {
-            id: rule.id, range: rule.range, type: rule.type,
-            v1: rule.v1 || "", v2: rule.v2 || "",
-            style: $.extend({}, rule.style)
-        };
+        var draft = rule;   // already a draft object (newDraft / draftFrom)
 
-        $ed.filter("#shCfRange").val(draft.range);
-        $body.find("#shCfRange").val(draft.range).on("change", function () {
+        $body.find("#shCfRangeSlot").append(
+            refInput("shCfRange", "A1:D20", draft.range, function () {
+                draft.range = $(this).val().trim();
+            }));
+        $body.find("#shCfRange").on("change", function () {
             draft.range = $(this).val().trim();
         });
-        $body.find("#shCfPick").on("click", function () {
-            Core.pickRangeFromGrid(function (rgStr) {
-                if (rgStr) {
-                    draft.range = rgStr;
-                    $body.find("#shCfRange").val(rgStr);
-                }
-            });
-        });
 
         var $type = $body.find("#shCfType");
         CONDS.forEach(function (cd) {
@@ -440,26 +612,27 @@ var SheetsCF = (function () {
         var HINTS = {
             formula: "Relative references are read from the top-left cell of the range, " +
                 "so =$F2&gt;=SUM($C2:$E2) tests every row against its own total.",
-            text: "Matching ignores upper/lower case.",
-            date: "Type a date as YYYY-MM-DD or MM/DD/YYYY.",
-            num: "A value, or a formula such as =AVERAGE($F$2:$F$99) to compare " +
-                "against the whole range."
+            text: "Matching ignores upper/lower case. Write =B3 to compare against " +
+                "a cell rather than the text &quot;B3&quot;.",
+            date: "A date as YYYY-MM-DD or MM/DD/YYYY, a cell such as B3, or a formula.",
+            num: "A number, a cell such as B3, or a formula such as " +
+                "=AVERAGE($F$2:$F$99). Relative references shift per row."
         };
         function renderArgs() {
             var cond = condById(draft.type);
             var $args = $body.find("#shCfArgs").empty();
             var ph = cond.kind === "formula" ? "=$F2>100" :
-                (cond.kind === "date" ? "2026-01-31" :
-                    (cond.kind === "text" ? "text to look for" : "value or =formula"));
+                (cond.kind === "date" ? "2026-01-31, B3 or =formula" :
+                    (cond.kind === "text" ? "text to look for" : "value, B3 or =formula"));
             if (cond.args >= 1) {
-                $args.append($('<input type="text" id="shCfV1" style="margin-top:6px;">')
-                    .attr("placeholder", ph).val(draft.v1)
-                    .on("input", function () { draft.v1 = $(this).val(); }));
+                $args.append(refInput("shCfV1", ph, draft.v1, function () {
+                    draft.v1 = $(this).val();
+                }).css("margin-top", "6px"));
             }
             if (cond.args >= 2) {
-                $args.append($('<input type="text" id="shCfV2" style="margin-top:6px;">')
-                    .attr("placeholder", "and").val(draft.v2)
-                    .on("input", function () { draft.v2 = $(this).val(); }));
+                $args.append(refInput("shCfV2", "and", draft.v2, function () {
+                    draft.v2 = $(this).val();
+                }).css("margin-top", "6px"));
             }
             $body.find("#shCfHint").html(HINTS[cond.kind] || "");
         }
@@ -525,11 +698,27 @@ var SheetsCF = (function () {
                 OfficeApp.toast("Pick at least one formatting change", "error");
                 return;
             }
-            var s = Core.sheet();
-            if (!Array.isArray(s.cf)) s.cf = [];
-            var at = -1;
-            for (var i = 0; i < s.cf.length; i++) if (s.cf[i].id === draft.id) at = i;
-            if (at >= 0) s.cf[at] = draft; else s.cf.push(draft);
+            var rg = Core.parseRange(draft.range);
+            if (rangeCellCount(rg) > MAX_STAMP) {
+                OfficeApp.toast("That is " + rangeCellCount(rg) + " cells - apply the rule " +
+                    "to at most " + MAX_STAMP + " at a time", "error");
+                return;
+            }
+            /*
+                Copy-on-write: the edit always mints a new rule body and swaps
+                it in over the cells being edited. Cells elsewhere that happen
+                to share the old rule keep it, which is what per-cell
+                ownership has to mean once a rule can be copied around.
+            */
+            var id = genId();
+            defs()[id] = {
+                // relative references are read from where the rule was applied
+                anchor: draft.anchor || F.cellName(rg.c1, rg.r1),
+                type: draft.type, v1: draft.v1, v2: draft.v2,
+                style: $.extend({}, draft.style)
+            };
+            if (draft.id) unstampRange(Core.selRange(), draft.id);
+            stampRange(rg, id, null);
             commitRules();
             showList($body);
         });
@@ -537,8 +726,7 @@ var SheetsCF = (function () {
         if (!isNew) {
             var $del = $('<button type="button" class="of-btn danger">Delete</button>');
             $del.on("click", function () {
-                var s = Core.sheet();
-                s.cf = (s.cf || []).filter(function (x) { return x.id !== draft.id; });
+                unstampRange(Core.selRange(), draft.id);
                 commitRules();
                 showList($body);
             });
@@ -548,31 +736,33 @@ var SheetsCF = (function () {
     }
 
     /* clear every rule that covers the current selection */
+    /* strip every rule from the selected cells, leaving other cells alone */
     function clearForSelection() {
-        var s = Core.sheet();
-        if (!Array.isArray(s.cf) || !s.cf.length) {
-            OfficeApp.toast("This sheet has no conditional formatting", "error");
+        if (!selectionHasRules()) {
+            OfficeApp.toast("The selected cells have no conditional formatting", "error");
             return;
         }
         var sel = Core.selRange();
-        var before = s.cf.length;
-        s.cf = s.cf.filter(function (rule) {
-            var rg = Core.parseRange(rule.range || "");
-            if (!rg) return false;
-            // drop rules whose range overlaps what the user selected
-            var hit = !(rg.c2 < sel.c1 || rg.c1 > sel.c2 || rg.r2 < sel.r1 || rg.r1 > sel.r2);
-            return !hit;
+        var n = 0;
+        eachCellIn(sel, function (c, r) {
+            var cell = Core.sheet().cells[F.cellName(c, r)];
+            if (!cell || !cell.cf) return;
+            n++;
+            delete cell.cf;
+            Core.pruneCell(c, r);
         });
         commitRules();
-        OfficeApp.setStatus("Removed " + (before - s.cf.length) + " conditional format rule(s)");
+        OfficeApp.setStatus("Cleared conditional formatting from " + n + " cell(s)");
     }
 
     return {
         styleFor: styleFor,
-        shiftRanges: shiftRanges,
+        shiftAnchors: shiftAnchors,
         invalidate: invalidate,
         open: open,
         clearForSelection: clearForSelection,
+        selectionHasRules: selectionHasRules,
+        rulesInSelection: rulesInSelection,
         describe: describe,
         conditions: CONDS
     };

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

@@ -4,6 +4,7 @@
     <meta charset="UTF-8">
     <meta name="viewport" content="width=device-width, initial-scale=1">
     <title>Slides</title>
+    <link rel="icon" type="image/png" href="slides.png">
     <link rel="stylesheet" href="../../script/semantic/semantic.min.css">
     <link rel="stylesheet" href="../common/office.css">
     <link rel="stylesheet" href="slides.css">