Selaa lähdekoodia

Enhance Sheets paste and formula hints

Add smarter copy/paste behavior in Office Sheets, including cross-sheet formula linking, multiple paste-special modes, a post-paste options chip, and value/format-aware internal clipboard data. This also adds formula function autocomplete with descriptions and examples, backed by a new help registry and stronger formula tests. Sheet tabs can now be reordered by drag-and-drop, and the README documents the new editing behavior.
Toby Chui 1 viikko sitten
vanhempi
commit
96a94eba38

+ 20 - 0
src/web/Office/README.md

@@ -536,6 +536,26 @@ node web/Office/sheets/test_formula_fns.js  # every function, against Excel's do
 `test_formula_fns.js` fails when a registered function has no test, so a new
 function always comes with one.
 
+**Copy and paste between sheets.** A copy of Sheets cells remembers its
+source sheet and the computed value of every cell. Ctrl+V onto another sheet
+defaults to *Keep source links*: formulas get their plain references pinned
+to the source sheet (`F.qualifyRefs`: `=E13*B2` becomes
+`=SheetA!E13*SheetA!B2`), so they still show the source results. On the same
+sheet, formulas shift relative to where they land, as before. A cut moves the
+cells, so their formulas keep pointing where they did. The other modes
+(values, values with formatting, formatting only, paste link, transpose) are
+listed in the paste-options button at the corner of the pasted block, in Edit
+and in the right-click menu (> Paste special), and values only is also on
+Ctrl+Shift+V (`PASTE_MODES` / `pasteInternal(mode)` in `sheets.js`).
+
+**Function suggestions.** Typing a function name in a formula (`=AVE`) opens
+a popup ([`sheets_suggest.js`](sheets/sheets_suggest.js), `SheetFnSuggest`)
+on the cell editor and the formula bar; the highlighted entry shows a
+description, the usage line (`spec.syntax`) and an example. Descriptions and
+examples live in [`formula_help.js`](sheets/formula_help.js)
+(`SheetFormulaHelp`), and `test_formula.js` fails when a function has no
+entry there or its example does not parse.
+
 **Adding a function.** Register it in the matching module:
 
 ```js

+ 34 - 0
src/web/Office/sheets/formula.js

@@ -19,6 +19,7 @@
         adjustInsertDelete(formula, axis, index, count, [sheet])
                                                  axis "row"|"col", count<0 = delete
         renameSheetRefs(formula, oldName, newName)
+        qualifyRefs(formula, sheetName)          pin unqualified refs to a sheet
         isErr(v), FErr, ERR                      error values
         dateToSerial(date) / serialToDate(n)     Excel-style 1900 date serials
 
@@ -1761,6 +1762,38 @@ var SheetFormula = (function () {
         out += body.slice(last);
         return (hasEq ? "=" : "") + out;
     }
+    /* Pin a formula to the sheet it was written on: every reference that
+       names no sheet (A1, $B$2, A1:B9, A:B, 2:5) gets "sheetName!" in front,
+       so the formula keeps reading the same cells after it is pasted onto
+       another sheet. References that already name a sheet, the second half
+       of a range (Sheet!A1:B2 names its sheet once) and defined names are
+       left alone. */
+    function qualifyRefs(formula, sheetName) {
+        var src = String(formula);
+        var hasEq = src.charAt(0) === "=";
+        var body = hasEq ? src.slice(1) : src;
+        var toks;
+        try { toks = tokenize(body); } catch (e) { return src; }
+        var prefix = quoteSheetName(sheetName) + "!";
+        var out = "", last = 0;
+        for (var i = 0; i < toks.length; i++) {
+            var t = toks[i];
+            // a bare row range (2:5) tokenizes as two numbers around ":"
+            var rowPair = t.t === "num" && /^\d+$/.test(body.substr(t.pos, t.len)) &&
+                toks[i + 1] && toks[i + 1].t === "op" && toks[i + 1].v === ":" &&
+                toks[i + 2] && (toks[i + 2].t === "num" || toks[i + 2].t === "rowref");
+            if (t.t !== "ref" && t.t !== "colref" && t.t !== "rowref" && !rowPair) continue;
+            if (t.prefix || t.sheet !== undefined) continue;
+            var prev = toks[i - 1], head = toks[i - 2];
+            if (prev && prev.t === "op" && prev.v === ":" && head &&
+                (head.t === "ref" || head.t === "colref" || head.t === "rowref")) continue;
+            out += body.slice(last, t.pos) + prefix;
+            last = t.pos;
+        }
+        if (!out) return src;
+        out += body.slice(last);
+        return (hasEq ? "=" : "") + out;
+    }
     /* Optional sheet {target, home} as for rewriteMovedRange: rows/cols are
        inserted on sheet target, the formula lives on sheet home. */
     function adjustInsertDelete(formula, axis, index, count, sheet) {
@@ -1800,6 +1833,7 @@ var SheetFormula = (function () {
         rewriteMovedRange: rewriteMovedRange,
         adjustInsertDelete: adjustInsertDelete,
         renameSheetRefs: renameSheetRefs,
+        qualifyRefs: qualifyRefs,
         quoteSheetName: quoteSheetName,
         dateToSerial: dateToSerial,
         serialToDate: serialToDate,

+ 393 - 0
src/web/Office/sheets/formula_help.js

@@ -0,0 +1,393 @@
+/*
+    ArozOS Office Sheets - function help text
+    =========================================
+    One line of description and one worked example per formula function,
+    shown by the function suggestion popup (sheets_suggest.js). The usage
+    line comes from the engine itself (spec.syntax in formula_fn_*.js), so
+    only prose and examples live here.
+
+        SheetFormulaHelp[NAME] = [description, example]
+
+    test_formula.js fails when a registered function has no entry here, so
+    add one whenever you defineFunction a new name.
+*/
+var SheetFormulaHelp = {
+    "ABS": ["Absolute value of a number.", "ABS(-7)"],
+    "ACOS": ["Inverse cosine of a value, in radians.", "ACOS(0.5)"],
+    "ACOSH": ["Inverse hyperbolic cosine of a number.", "ACOSH(2)"],
+    "ACOT": ["Inverse cotangent of a value, in radians.", "ACOT(1)"],
+    "ACOTH": ["Inverse hyperbolic cotangent of a value (|value| > 1).", "ACOTH(3)"],
+    "ADD": ["Sum of two numbers. Same as the + operator.", "ADD(A1, 5)"],
+    "ADDRESS": ["Cell reference as text, built from a row and column number.", "ADDRESS(2, 3)"],
+    "AND": ["TRUE when every argument is TRUE.", "AND(A1 > 0, B1 > 0)"],
+    "ARABIC": ["Converts a Roman numeral to a number.", "ARABIC(\"XIV\")"],
+    "ARRAYFORMULA": ["Evaluates an expression over whole ranges and spills the result.", "ARRAYFORMULA(A1:A10 * B1:B10)"],
+    "ARRAY_CONSTRAIN": ["Trims an array result to a given number of rows and columns.", "ARRAY_CONSTRAIN(A1:D10, 3, 2)"],
+    "ASC": ["Converts full-width characters to half-width.", "ASC(A1)"],
+    "ASIN": ["Inverse sine of a value, in radians.", "ASIN(0.5)"],
+    "ASINH": ["Inverse hyperbolic sine of a number.", "ASINH(1)"],
+    "ATAN": ["Inverse tangent of a value, in radians.", "ATAN(1)"],
+    "ATAN2": ["Angle between the x-axis and the point (x, y), in radians.", "ATAN2(1, 1)"],
+    "ATANH": ["Inverse hyperbolic tangent of a number.", "ATANH(0.5)"],
+    "AVEDEV": ["Average magnitude of deviations from the mean.", "AVEDEV(A1:A10)"],
+    "AVERAGE": ["Arithmetic mean of the numbers, ignoring text.", "AVERAGE(A1:A10)"],
+    "AVERAGE.WEIGHTED": ["Weighted average of values, given matching weights.", "AVERAGE.WEIGHTED(A1:A5, B1:B5)"],
+    "AVERAGEA": ["Mean of the values, counting text as 0 and TRUE as 1.", "AVERAGEA(A1:A10)"],
+    "AVERAGEIF": ["Average of the cells that meet one criterion.", "AVERAGEIF(A1:A10, \">50\")"],
+    "AVERAGEIFS": ["Average of a range, counting only rows that meet every criterion.", "AVERAGEIFS(C1:C10, A1:A10, \"East\", B1:B10, \">100\")"],
+    "BASE": ["Converts a number to text in another base (2-36).", "BASE(255, 16)"],
+    "BIN2DEC": ["Converts a binary number to decimal.", "BIN2DEC(\"1101\")"],
+    "BIN2HEX": ["Converts a binary number to hexadecimal.", "BIN2HEX(\"11111111\")"],
+    "BIN2OCT": ["Converts a binary number to octal.", "BIN2OCT(\"1001\")"],
+    "BITAND": ["Bitwise AND of two numbers.", "BITAND(12, 10)"],
+    "BITLSHIFT": ["Shifts the bits of a number left.", "BITLSHIFT(4, 2)"],
+    "BITOR": ["Bitwise OR of two numbers.", "BITOR(12, 10)"],
+    "BITRSHIFT": ["Shifts the bits of a number right.", "BITRSHIFT(16, 2)"],
+    "BITXOR": ["Bitwise exclusive OR of two numbers.", "BITXOR(12, 10)"],
+    "CEILING": ["Rounds a number up to the nearest multiple of factor.", "CEILING(4.2, 0.5)"],
+    "CEILING.MATH": ["Rounds up to a multiple of significance, with a mode for negatives.", "CEILING.MATH(-4.2, 1, 1)"],
+    "CEILING.PRECISE": ["Rounds up to a multiple of significance, regardless of sign.", "CEILING.PRECISE(4.2, 2)"],
+    "CELL": ["Information about a cell, such as its address, row or contents.", "CELL(\"address\", B3)"],
+    "CHAR": ["Character for a character-code number.", "CHAR(65)"],
+    "CHOOSE": ["Picks one of the choices by its position.", "CHOOSE(2, \"Low\", \"Mid\", \"High\")"],
+    "CHOOSECOLS": ["Columns picked from an array by their numbers.", "CHOOSECOLS(A1:D10, 1, 3)"],
+    "CHOOSEROWS": ["Rows picked from an array by their numbers.", "CHOOSEROWS(A1:D10, 1, -1)"],
+    "CLEAN": ["Removes non-printable characters from text.", "CLEAN(A1)"],
+    "CODE": ["Character code of the first character of text.", "CODE(\"A\")"],
+    "COLUMN": ["Column number of a cell (of this cell when omitted).", "COLUMN(C5)"],
+    "COLUMNS": ["Number of columns in a range.", "COLUMNS(A1:D10)"],
+    "COMBIN": ["Number of ways to choose k items from n, order ignored.", "COMBIN(10, 3)"],
+    "COMBINA": ["Combinations of k items from n, repetition allowed.", "COMBINA(4, 3)"],
+    "CONCAT": ["Joins values together into one piece of text.", "CONCAT(A1, \" \", B1)"],
+    "CONCATENATE": ["Joins values together into one piece of text.", "CONCATENATE(A1, \" \", B1)"],
+    "CONVERT": ["Converts a value from one unit of measure to another.", "CONVERT(10, \"km\", \"mi\")"],
+    "CORREL": ["Pearson correlation coefficient of two data sets.", "CORREL(B1:B10, A1:A10)"],
+    "COS": ["Cosine of an angle given in radians.", "COS(PI())"],
+    "COSH": ["Hyperbolic cosine of a number.", "COSH(1)"],
+    "COT": ["Cotangent of an angle given in radians.", "COT(PI() / 4)"],
+    "COTH": ["Hyperbolic cotangent of a number.", "COTH(1)"],
+    "COUNT": ["Number of numeric values.", "COUNT(A1:A10)"],
+    "COUNTA": ["Number of non-empty values.", "COUNTA(A1:A10)"],
+    "COUNTBLANK": ["Number of empty cells in a range.", "COUNTBLANK(A1:A10)"],
+    "COUNTIF": ["Number of cells that meet a criterion.", "COUNTIF(A1:A10, \">50\")"],
+    "COUNTIFS": ["Number of rows that meet every criterion.", "COUNTIFS(A1:A10, \"East\", B1:B10, \">100\")"],
+    "COUNTUNIQUE": ["Number of distinct values.", "COUNTUNIQUE(A1:A10)"],
+    "COVAR": ["Population covariance of two data sets.", "COVAR(B1:B10, A1:A10)"],
+    "COVARIANCE.P": ["Population covariance of two data sets.", "COVARIANCE.P(B1:B10, A1:A10)"],
+    "COVARIANCE.S": ["Sample covariance of two data sets.", "COVARIANCE.S(B1:B10, A1:A10)"],
+    "CSC": ["Cosecant of an angle given in radians.", "CSC(PI() / 2)"],
+    "CSCH": ["Hyperbolic cosecant of a number.", "CSCH(1)"],
+    "CUMIPMT": ["Cumulative interest paid on a loan between two periods.", "CUMIPMT(0.05 / 12, 360, 200000, 1, 12, 0)"],
+    "CUMPRINC": ["Cumulative principal paid on a loan between two periods.", "CUMPRINC(0.05 / 12, 360, 200000, 1, 12, 0)"],
+    "DATE": ["Date built from a year, month and day.", "DATE(2026, 9, 22)"],
+    "DATEDIF": ["Difference between two dates in days (\"D\"), months (\"M\") or years (\"Y\").", "DATEDIF(A1, B1, \"M\")"],
+    "DATEVALUE": ["Converts text that looks like a date into a date.", "DATEVALUE(\"2026-09-22\")"],
+    "DAVERAGE": ["Average of a database column, over the records that meet the criteria.", "DAVERAGE(A1:D20, \"Sales\", F1:F2)"],
+    "DAY": ["Day of the month of a date.", "DAY(A1)"],
+    "DAYS": ["Number of days between two dates.", "DAYS(B1, A1)"],
+    "DAYS360": ["Days between two dates on a 360-day year.", "DAYS360(A1, B1)"],
+    "DB": ["Depreciation by the fixed-declining-balance method.", "DB(10000, 1000, 5, 1)"],
+    "DCOUNT": ["Count of numbers in a database column, over matching records.", "DCOUNT(A1:D20, \"Sales\", F1:F2)"],
+    "DCOUNTA": ["Count of non-empty values in a database column, over matching records.", "DCOUNTA(A1:D20, \"Name\", F1:F2)"],
+    "DDB": ["Depreciation by the double-declining-balance method.", "DDB(10000, 1000, 5, 1)"],
+    "DEC2BIN": ["Converts a decimal number to binary.", "DEC2BIN(13)"],
+    "DEC2HEX": ["Converts a decimal number to hexadecimal.", "DEC2HEX(255)"],
+    "DEC2OCT": ["Converts a decimal number to octal.", "DEC2OCT(64)"],
+    "DECIMAL": ["Converts text in another base to a decimal number.", "DECIMAL(\"FF\", 16)"],
+    "DEGREES": ["Converts radians to degrees.", "DEGREES(PI())"],
+    "DELTA": ["1 when two numbers are equal, otherwise 0.", "DELTA(A1, B1)"],
+    "DEVSQ": ["Sum of squared deviations from the mean.", "DEVSQ(A1:A10)"],
+    "DGET": ["The single value of a database column that matches the criteria.", "DGET(A1:D20, \"Price\", F1:F2)"],
+    "DIVIDE": ["One number divided by another. Same as the / operator.", "DIVIDE(A1, B1)"],
+    "DMAX": ["Largest value of a database column, over matching records.", "DMAX(A1:D20, \"Sales\", F1:F2)"],
+    "DMIN": ["Smallest value of a database column, over matching records.", "DMIN(A1:D20, \"Sales\", F1:F2)"],
+    "DOLLAR": ["Formats a number as currency text.", "DOLLAR(1234.567, 2)"],
+    "DOLLARDE": ["Converts a fractional price to a decimal price.", "DOLLARDE(1.02, 16)"],
+    "DOLLARFR": ["Converts a decimal price to a fractional price.", "DOLLARFR(1.125, 16)"],
+    "DPRODUCT": ["Product of a database column, over matching records.", "DPRODUCT(A1:D20, \"Qty\", F1:F2)"],
+    "DROP": ["An array with rows (and columns) removed from the start or end.", "DROP(A1:D10, 1)"],
+    "DSTDEV": ["Sample standard deviation of a database column, over matching records.", "DSTDEV(A1:D20, \"Sales\", F1:F2)"],
+    "DSTDEVP": ["Population standard deviation of a database column, over matching records.", "DSTDEVP(A1:D20, \"Sales\", F1:F2)"],
+    "DSUM": ["Sum of a database column, over the records that meet the criteria.", "DSUM(A1:D20, \"Sales\", F1:F2)"],
+    "DVAR": ["Sample variance of a database column, over matching records.", "DVAR(A1:D20, \"Sales\", F1:F2)"],
+    "DVARP": ["Population variance of a database column, over matching records.", "DVARP(A1:D20, \"Sales\", F1:F2)"],
+    "EDATE": ["Date a number of months before or after a date.", "EDATE(A1, 3)"],
+    "EFFECT": ["Effective annual interest rate from a nominal rate.", "EFFECT(0.05, 12)"],
+    "ENCODEURL": ["Encodes text for use in a URL.", "ENCODEURL(\"a b&c\")"],
+    "EOMONTH": ["Last day of the month, a number of months from a date.", "EOMONTH(A1, 0)"],
+    "EPOCHTODATE": ["Converts a Unix timestamp to a date (unit 1 = seconds, 2 = ms, 3 = us).", "EPOCHTODATE(1700000000)"],
+    "EQ": ["TRUE when two values are equal. Same as the = operator.", "EQ(A1, B1)"],
+    "ERROR.TYPE": ["Number identifying the type of an error value.", "ERROR.TYPE(A1)"],
+    "EVEN": ["Rounds a number up to the nearest even integer.", "EVEN(3.2)"],
+    "EXACT": ["TRUE when two strings are identical, case included.", "EXACT(A1, B1)"],
+    "EXP": ["e raised to a power.", "EXP(1)"],
+    "EXPAND": ["An array grown to the given size, padding new cells.", "EXPAND(A1:B2, 3, 3, \"\")"],
+    "FACT": ["Factorial of a number.", "FACT(5)"],
+    "FACTDOUBLE": ["Double factorial of a number.", "FACTDOUBLE(7)"],
+    "FALSE": ["The logical value FALSE.", "FALSE()"],
+    "FILTER": ["The rows (or columns) of a range that meet the conditions.", "FILTER(A1:C10, B1:B10 > 100)"],
+    "FIND": ["Position of text inside other text, case-sensitive.", "FIND(\"@\", A1)"],
+    "FINDB": ["Byte position of text inside other text, case-sensitive.", "FINDB(\"b\", \"abc\")"],
+    "FISHER": ["Fisher transformation of a value.", "FISHER(0.75)"],
+    "FISHERINV": ["Inverse Fisher transformation of a value.", "FISHERINV(0.97)"],
+    "FIXED": ["Formats a number with a fixed number of decimals, as text.", "FIXED(1234.567, 1)"],
+    "FLATTEN": ["All values of one or more ranges in a single column.", "FLATTEN(A1:C3)"],
+    "FLOOR": ["Rounds a number down to the nearest multiple of factor.", "FLOOR(4.7, 0.5)"],
+    "FLOOR.MATH": ["Rounds down to a multiple of significance, with a mode for negatives.", "FLOOR.MATH(-4.7, 1, 1)"],
+    "FLOOR.PRECISE": ["Rounds down to a multiple of significance, regardless of sign.", "FLOOR.PRECISE(4.7, 2)"],
+    "FORECAST": ["Predicts y for a given x by linear regression.", "FORECAST(30, B1:B10, A1:A10)"],
+    "FORECAST.LINEAR": ["Predicts y for a given x by linear regression.", "FORECAST.LINEAR(30, B1:B10, A1:A10)"],
+    "FORMULATEXT": ["The formula of a cell, as text.", "FORMULATEXT(B2)"],
+    "FREQUENCY": ["How many values fall into each class interval.", "FREQUENCY(A1:A20, C1:C4)"],
+    "FV": ["Future value of an investment with periodic payments.", "FV(0.05 / 12, 120, -100)"],
+    "FVSCHEDULE": ["Future value of a principal after a series of interest rates.", "FVSCHEDULE(1000, {0.05, 0.06, 0.04})"],
+    "GCD": ["Greatest common divisor of the numbers.", "GCD(24, 36)"],
+    "GEOMEAN": ["Geometric mean of the numbers.", "GEOMEAN(A1:A10)"],
+    "GESTEP": ["1 when a value is greater than or equal to step, otherwise 0.", "GESTEP(A1, 50)"],
+    "GROWTH": ["Values predicted along an exponential growth trend.", "GROWTH(B1:B10, A1:A10, A11:A12)"],
+    "GT": ["TRUE when the first value is greater. Same as the > operator.", "GT(A1, B1)"],
+    "GTE": ["TRUE when the first value is greater or equal. Same as >=.", "GTE(A1, B1)"],
+    "HARMEAN": ["Harmonic mean of the numbers.", "HARMEAN(A1:A10)"],
+    "HEX2BIN": ["Converts a hexadecimal number to binary.", "HEX2BIN(\"F\")"],
+    "HEX2DEC": ["Converts a hexadecimal number to decimal.", "HEX2DEC(\"FF\")"],
+    "HEX2OCT": ["Converts a hexadecimal number to octal.", "HEX2OCT(\"FF\")"],
+    "HLOOKUP": ["Finds a key in the first row of a range and returns a value from another row.", "HLOOKUP(\"Qty\", A1:F3, 2, FALSE)"],
+    "HOUR": ["Hour component of a time, 0-23.", "HOUR(A1)"],
+    "HSTACK": ["Arrays placed side by side into one array.", "HSTACK(A1:A5, C1:C5)"],
+    "HYPERLINK": ["A clickable link to a URL.", "HYPERLINK(\"https://example.com\", \"Example\")"],
+    "IF": ["One value when a condition is TRUE, another when it is FALSE.", "IF(A1 > 50, \"Pass\", \"Fail\")"],
+    "IFERROR": ["The value, or a fallback when the value is an error.", "IFERROR(A1 / B1, 0)"],
+    "IFNA": ["The value, or a fallback when the value is #N/A.", "IFNA(VLOOKUP(A1, D1:E10, 2, FALSE), \"Not found\")"],
+    "IFS": ["The value for the first condition that is TRUE.", "IFS(A1 >= 90, \"A\", A1 >= 80, \"B\", TRUE, \"C\")"],
+    "INDEX": ["The cell at a given row and column of a range.", "INDEX(A1:C10, 3, 2)"],
+    "INDIRECT": ["The reference named by a piece of text.", "INDIRECT(\"B\" & A1)"],
+    "INT": ["Rounds a number down to the nearest integer.", "INT(8.9)"],
+    "INTERCEPT": ["Y-intercept of the linear regression line.", "INTERCEPT(B1:B10, A1:A10)"],
+    "IPMT": ["Interest part of a loan payment for a given period.", "IPMT(0.05 / 12, 1, 360, 200000)"],
+    "IRR": ["Internal rate of return of a series of cash flows.", "IRR(A1:A6)"],
+    "ISBETWEEN": ["TRUE when a value lies between two others.", "ISBETWEEN(A1, 1, 10, TRUE, TRUE)"],
+    "ISBLANK": ["TRUE when a cell is empty.", "ISBLANK(A1)"],
+    "ISDATE": ["TRUE when a value is a date.", "ISDATE(A1)"],
+    "ISEMAIL": ["TRUE when a value looks like an email address.", "ISEMAIL(A1)"],
+    "ISERR": ["TRUE for any error except #N/A.", "ISERR(A1)"],
+    "ISERROR": ["TRUE for any error value.", "ISERROR(A1 / B1)"],
+    "ISEVEN": ["TRUE when a number is even.", "ISEVEN(A1)"],
+    "ISFORMULA": ["TRUE when a cell holds a formula.", "ISFORMULA(B2)"],
+    "ISLOGICAL": ["TRUE when a value is TRUE or FALSE.", "ISLOGICAL(A1)"],
+    "ISNA": ["TRUE when a value is the #N/A error.", "ISNA(A1)"],
+    "ISNONTEXT": ["TRUE when a value is not text.", "ISNONTEXT(A1)"],
+    "ISNUMBER": ["TRUE when a value is a number.", "ISNUMBER(A1)"],
+    "ISO.CEILING": ["Rounds up to a multiple of significance, regardless of sign.", "ISO.CEILING(4.2, 2)"],
+    "ISODD": ["TRUE when a number is odd.", "ISODD(A1)"],
+    "ISOWEEKNUM": ["ISO week number of a date.", "ISOWEEKNUM(A1)"],
+    "ISPMT": ["Interest paid in a given period of a straight-line loan.", "ISPMT(0.05 / 12, 1, 36, 8000)"],
+    "ISREF": ["TRUE when a value is a cell reference.", "ISREF(A1)"],
+    "ISTEXT": ["TRUE when a value is text.", "ISTEXT(A1)"],
+    "ISURL": ["TRUE when a value looks like a URL.", "ISURL(A1)"],
+    "JOIN": ["Joins values with a delimiter between them.", "JOIN(\", \", A1:A5)"],
+    "KURT": ["Kurtosis of a data set.", "KURT(A1:A10)"],
+    "LARGE": ["The n-th largest value of a data set.", "LARGE(A1:A10, 2)"],
+    "LCM": ["Least common multiple of the numbers.", "LCM(4, 6)"],
+    "LEFT": ["The first characters of a string.", "LEFT(A1, 3)"],
+    "LEFTB": ["The first bytes of a string.", "LEFTB(A1, 3)"],
+    "LEN": ["Number of characters in text.", "LEN(A1)"],
+    "LENB": ["Number of bytes in text.", "LENB(A1)"],
+    "LINEST": ["Statistics of the best-fit straight line through the data.", "LINEST(B1:B10, A1:A10)"],
+    "LN": ["Natural logarithm of a number.", "LN(10)"],
+    "LOG": ["Logarithm of a number to a base (10 when omitted).", "LOG(8, 2)"],
+    "LOG10": ["Base-10 logarithm of a number.", "LOG10(1000)"],
+    "LOGEST": ["Statistics of the best-fit exponential curve through the data.", "LOGEST(B1:B10, A1:A10)"],
+    "LOOKUP": ["Finds a key in a sorted row or column and returns the matching result.", "LOOKUP(A1, D1:D10, E1:E10)"],
+    "LOWER": ["Converts text to lower case.", "LOWER(A1)"],
+    "LT": ["TRUE when the first value is less. Same as the < operator.", "LT(A1, B1)"],
+    "LTE": ["TRUE when the first value is less or equal. Same as <=.", "LTE(A1, B1)"],
+    "MATCH": ["Position of a value within a row or column.", "MATCH(\"Apple\", A1:A10, 0)"],
+    "MAX": ["Largest number of the values.", "MAX(A1:A10)"],
+    "MAXA": ["Largest of the values, counting text as 0 and TRUE as 1.", "MAXA(A1:A10)"],
+    "MAXIFS": ["Largest value of a range, over rows that meet every criterion.", "MAXIFS(C1:C10, A1:A10, \"East\")"],
+    "MDETERM": ["Determinant of a square matrix.", "MDETERM(A1:C3)"],
+    "MEDIAN": ["Middle value of the numbers.", "MEDIAN(A1:A10)"],
+    "MID": ["Characters from the middle of a string.", "MID(A1, 2, 3)"],
+    "MIDB": ["Bytes from the middle of a string.", "MIDB(A1, 2, 3)"],
+    "MIN": ["Smallest number of the values.", "MIN(A1:A10)"],
+    "MINA": ["Smallest of the values, counting text as 0 and TRUE as 1.", "MINA(A1:A10)"],
+    "MINIFS": ["Smallest value of a range, over rows that meet every criterion.", "MINIFS(C1:C10, A1:A10, \"East\")"],
+    "MINUS": ["One number minus another. Same as the - operator.", "MINUS(A1, B1)"],
+    "MINUTE": ["Minute component of a time, 0-59.", "MINUTE(A1)"],
+    "MINVERSE": ["Inverse of a square matrix.", "MINVERSE(A1:B2)"],
+    "MIRR": ["Modified internal rate of return of a series of cash flows.", "MIRR(A1:A6, 0.1, 0.12)"],
+    "MMULT": ["Matrix product of two arrays.", "MMULT(A1:B2, D1:E2)"],
+    "MOD": ["Remainder after division.", "MOD(10, 3)"],
+    "MODE": ["Most common value of the numbers.", "MODE(A1:A10)"],
+    "MODE.MULT": ["Every most-common value of the numbers, as a column.", "MODE.MULT(A1:A10)"],
+    "MODE.SNGL": ["Most common value of the numbers.", "MODE.SNGL(A1:A10)"],
+    "MONTH": ["Month number of a date, 1-12.", "MONTH(A1)"],
+    "MROUND": ["Rounds a number to the nearest multiple of factor.", "MROUND(17, 5)"],
+    "MULTINOMIAL": ["Factorial of the sum divided by the product of the factorials.", "MULTINOMIAL(2, 3, 4)"],
+    "MULTIPLY": ["Product of two numbers. Same as the * operator.", "MULTIPLY(A1, B1)"],
+    "MUNIT": ["Identity matrix of the given size.", "MUNIT(3)"],
+    "N": ["A value converted to a number (text becomes 0).", "N(A1)"],
+    "NA": ["The #N/A error value.", "NA()"],
+    "NE": ["TRUE when two values differ. Same as the <> operator.", "NE(A1, B1)"],
+    "NETWORKDAYS": ["Working days between two dates, Monday to Friday.", "NETWORKDAYS(A1, B1)"],
+    "NETWORKDAYS.INTL": ["Working days between two dates, with custom weekends.", "NETWORKDAYS.INTL(A1, B1, 7)"],
+    "NOMINAL": ["Nominal annual interest rate from an effective rate.", "NOMINAL(0.0512, 12)"],
+    "NOT": ["Reverses a logical value.", "NOT(A1 > 50)"],
+    "NOW": ["Current date and time.", "NOW()"],
+    "NPER": ["Number of payment periods for an investment or loan.", "NPER(0.05 / 12, -500, 20000)"],
+    "NPV": ["Net present value of a series of cash flows at a discount rate.", "NPV(0.1, B2:B6)"],
+    "NUMBERVALUE": ["Converts text to a number, with custom separators.", "NUMBERVALUE(\"1.234,5\", \",\", \".\")"],
+    "OCT2BIN": ["Converts an octal number to binary.", "OCT2BIN(\"17\")"],
+    "OCT2DEC": ["Converts an octal number to decimal.", "OCT2DEC(\"17\")"],
+    "OCT2HEX": ["Converts an octal number to hexadecimal.", "OCT2HEX(\"17\")"],
+    "ODD": ["Rounds a number up to the nearest odd integer.", "ODD(2.4)"],
+    "OFFSET": ["A range shifted a number of rows and columns from a start cell.", "OFFSET(A1, 2, 1, 3, 1)"],
+    "OR": ["TRUE when any argument is TRUE.", "OR(A1 > 90, B1 = \"VIP\")"],
+    "PDURATION": ["Periods needed for an investment to reach a value.", "PDURATION(0.05, 1000, 2000)"],
+    "PEARSON": ["Pearson correlation coefficient of two data sets.", "PEARSON(B1:B10, A1:A10)"],
+    "PERCENTILE": ["Value at a given percentile of a data set.", "PERCENTILE(A1:A10, 0.9)"],
+    "PERCENTILE.EXC": ["Value at a percentile, exclusive of 0 and 1.", "PERCENTILE.EXC(A1:A10, 0.9)"],
+    "PERCENTILE.INC": ["Value at a percentile, inclusive of 0 and 1.", "PERCENTILE.INC(A1:A10, 0.9)"],
+    "PERCENTRANK": ["Rank of a value in a data set, as a percentage.", "PERCENTRANK(A1:A10, 42)"],
+    "PERCENTRANK.EXC": ["Percentage rank of a value, exclusive of 0 and 1.", "PERCENTRANK.EXC(A1:A10, 42)"],
+    "PERCENTRANK.INC": ["Percentage rank of a value, inclusive of 0 and 1.", "PERCENTRANK.INC(A1:A10, 42)"],
+    "PERMUT": ["Number of ordered arrangements of k items from n.", "PERMUT(10, 3)"],
+    "PERMUTATIONA": ["Ordered arrangements of k items from n, repetition allowed.", "PERMUTATIONA(3, 2)"],
+    "PI": ["The number pi, 3.14159...", "PI()"],
+    "PMT": ["Periodic payment for a loan with constant payments and rate.", "PMT(0.05 / 12, 360, 200000)"],
+    "POW": ["A number raised to a power.", "POW(2, 10)"],
+    "POWER": ["A number raised to a power.", "POWER(2, 10)"],
+    "PPMT": ["Principal part of a loan payment for a given period.", "PPMT(0.05 / 12, 1, 360, 200000)"],
+    "PROB": ["Probability that values fall between two limits.", "PROB(A1:A4, B1:B4, 2, 3)"],
+    "PRODUCT": ["Product of the numbers.", "PRODUCT(A1:A5)"],
+    "PROPER": ["Capitalises the first letter of each word.", "PROPER(\"hello world\")"],
+    "PV": ["Present value of an investment with periodic payments.", "PV(0.05 / 12, 120, -100)"],
+    "QUARTILE": ["Value at a quartile (0-4) of a data set.", "QUARTILE(A1:A10, 1)"],
+    "QUARTILE.EXC": ["Quartile of a data set, exclusive of 0 and 1.", "QUARTILE.EXC(A1:A10, 1)"],
+    "QUARTILE.INC": ["Quartile of a data set, inclusive of 0 and 1.", "QUARTILE.INC(A1:A10, 1)"],
+    "QUOTIENT": ["Integer part of a division.", "QUOTIENT(10, 3)"],
+    "RADIANS": ["Converts degrees to radians.", "RADIANS(180)"],
+    "RAND": ["Random number from 0 up to 1.", "RAND()"],
+    "RANDARRAY": ["An array of random numbers from 0 up to 1.", "RANDARRAY(3, 2)"],
+    "RANDBETWEEN": ["Random integer between two values, inclusive.", "RANDBETWEEN(1, 6)"],
+    "RANK": ["Rank of a value within a data set.", "RANK(A1, A1:A10)"],
+    "RANK.AVG": ["Rank of a value, averaging the ranks of ties.", "RANK.AVG(A1, A1:A10)"],
+    "RANK.EQ": ["Rank of a value, ties sharing the top rank.", "RANK.EQ(A1, A1:A10)"],
+    "RATE": ["Interest rate per period of an annuity.", "RATE(60, -400, 20000)"],
+    "REGEXEXTRACT": ["The first part of text that matches a regular expression.", "REGEXEXTRACT(A1, \"[0-9]+\")"],
+    "REGEXMATCH": ["TRUE when text matches a regular expression.", "REGEXMATCH(A1, \"^[A-Z]\")"],
+    "REGEXREPLACE": ["Replaces the parts of text that match a regular expression.", "REGEXREPLACE(A1, \"[0-9]\", \"#\")"],
+    "REPLACE": ["Replaces part of text at a given position.", "REPLACE(A1, 1, 3, \"New\")"],
+    "REPLACEB": ["Replaces part of text at a given byte position.", "REPLACEB(A1, 1, 3, \"New\")"],
+    "REPT": ["Text repeated a number of times.", "REPT(\"-\", 10)"],
+    "RIGHT": ["The last characters of a string.", "RIGHT(A1, 4)"],
+    "RIGHTB": ["The last bytes of a string.", "RIGHTB(A1, 4)"],
+    "ROMAN": ["Converts a number to a Roman numeral.", "ROMAN(2026)"],
+    "ROUND": ["Rounds a number to a number of decimal places.", "ROUND(3.14159, 2)"],
+    "ROUNDDOWN": ["Rounds a number towards zero.", "ROUNDDOWN(3.789, 1)"],
+    "ROUNDUP": ["Rounds a number away from zero.", "ROUNDUP(3.211, 1)"],
+    "ROW": ["Row number of a cell (of this cell when omitted).", "ROW(C5)"],
+    "ROWS": ["Number of rows in a range.", "ROWS(A1:D10)"],
+    "RRI": ["Equivalent interest rate for an investment's growth.", "RRI(10, 1000, 2000)"],
+    "RSQ": ["Square of the Pearson correlation coefficient.", "RSQ(B1:B10, A1:A10)"],
+    "SEARCH": ["Position of text inside other text, ignoring case.", "SEARCH(\"apple\", A1)"],
+    "SEARCHB": ["Byte position of text inside other text, ignoring case.", "SEARCHB(\"b\", \"ABC\")"],
+    "SEC": ["Secant of an angle given in radians.", "SEC(PI())"],
+    "SECH": ["Hyperbolic secant of a number.", "SECH(1)"],
+    "SECOND": ["Second component of a time, 0-59.", "SECOND(A1)"],
+    "SEQUENCE": ["An array of sequential numbers.", "SEQUENCE(5, 1, 1, 1)"],
+    "SERIESSUM": ["Sum of a power series.", "SERIESSUM(2, 0, 1, {1, 2, 3})"],
+    "SHEET": ["Sheet number of a reference or sheet name.", "SHEET(\"Sheet2\")"],
+    "SHEETS": ["Number of sheets in the workbook (or in a reference).", "SHEETS()"],
+    "SIGN": ["-1, 0 or 1 by the sign of a number.", "SIGN(A1)"],
+    "SIN": ["Sine of an angle given in radians.", "SIN(PI() / 2)"],
+    "SINGLE": ["A single value from a range, by implicit intersection.", "SINGLE(A1:A10)"],
+    "SINH": ["Hyperbolic sine of a number.", "SINH(1)"],
+    "SKEW": ["Sample skewness of a data set.", "SKEW(A1:A10)"],
+    "SKEW.P": ["Population skewness of a data set.", "SKEW.P(A1:A10)"],
+    "SLN": ["Straight-line depreciation for one period.", "SLN(10000, 1000, 5)"],
+    "SLOPE": ["Slope of the linear regression line.", "SLOPE(B1:B10, A1:A10)"],
+    "SMALL": ["The n-th smallest value of a data set.", "SMALL(A1:A10, 2)"],
+    "SORT": ["The rows of a range, sorted by one or more columns.", "SORT(A1:C10, 2, FALSE)"],
+    "SORTN": ["The first n rows of a range after sorting.", "SORTN(A1:C10, 3, 0, 2, FALSE)"],
+    "SPLIT": ["Splits text at a delimiter into separate cells.", "SPLIT(A1, \",\")"],
+    "SQRT": ["Square root of a number.", "SQRT(16)"],
+    "SQRTPI": ["Square root of a number multiplied by pi.", "SQRTPI(2)"],
+    "STANDARDIZE": ["Normalised (z-score) value for a mean and standard deviation.", "STANDARDIZE(A1, 50, 10)"],
+    "STDEV": ["Sample standard deviation.", "STDEV(A1:A10)"],
+    "STDEV.P": ["Population standard deviation.", "STDEV.P(A1:A10)"],
+    "STDEV.S": ["Sample standard deviation.", "STDEV.S(A1:A10)"],
+    "STDEVA": ["Sample standard deviation, counting text as 0 and TRUE as 1.", "STDEVA(A1:A10)"],
+    "STDEVP": ["Population standard deviation.", "STDEVP(A1:A10)"],
+    "STDEVPA": ["Population standard deviation, counting text as 0 and TRUE as 1.", "STDEVPA(A1:A10)"],
+    "STEYX": ["Standard error of the predicted y in a linear regression.", "STEYX(B1:B10, A1:A10)"],
+    "SUBSTITUTE": ["Replaces occurrences of some text with new text.", "SUBSTITUTE(A1, \"old\", \"new\")"],
+    "SUBTOTAL": ["A subtotal (SUM, AVERAGE, ...) chosen by function code, skipping other subtotals.", "SUBTOTAL(9, A1:A10)"],
+    "SUM": ["Sum of the numbers.", "SUM(A1:A10)"],
+    "SUMIF": ["Sum of the cells that meet one criterion.", "SUMIF(A1:A10, \"East\", C1:C10)"],
+    "SUMIFS": ["Sum of a range, over rows that meet every criterion.", "SUMIFS(C1:C10, A1:A10, \"East\", B1:B10, \">100\")"],
+    "SUMPRODUCT": ["Sum of the products of matching entries of arrays.", "SUMPRODUCT(A1:A5, B1:B5)"],
+    "SUMSQ": ["Sum of the squares of the numbers.", "SUMSQ(A1:A5)"],
+    "SUMX2MY2": ["Sum of the differences of squares of two arrays.", "SUMX2MY2(A1:A5, B1:B5)"],
+    "SUMX2PY2": ["Sum of the sums of squares of two arrays.", "SUMX2PY2(A1:A5, B1:B5)"],
+    "SUMXMY2": ["Sum of the squared differences of two arrays.", "SUMXMY2(A1:A5, B1:B5)"],
+    "SWITCH": ["The value matching the first case equal to the expression.", "SWITCH(A1, 1, \"One\", 2, \"Two\", \"Other\")"],
+    "SYD": ["Sum-of-years'-digits depreciation for a period.", "SYD(10000, 1000, 5, 1)"],
+    "T": ["The value if it is text, otherwise empty text.", "T(A1)"],
+    "TAKE": ["Rows (and columns) taken from the start or end of an array.", "TAKE(A1:D10, 3)"],
+    "TAN": ["Tangent of an angle given in radians.", "TAN(PI() / 4)"],
+    "TANH": ["Hyperbolic tangent of a number.", "TANH(1)"],
+    "TEXT": ["Formats a number as text with a number format.", "TEXT(A1, \"0.00%\")"],
+    "TEXTAFTER": ["The text after a delimiter.", "TEXTAFTER(A1, \"@\")"],
+    "TEXTBEFORE": ["The text before a delimiter.", "TEXTBEFORE(A1, \"@\")"],
+    "TEXTJOIN": ["Joins text with a delimiter, optionally skipping empty cells.", "TEXTJOIN(\", \", TRUE, A1:A5)"],
+    "TEXTSPLIT": ["Splits text into columns (and rows) at delimiters.", "TEXTSPLIT(A1, \",\")"],
+    "TIME": ["Time built from an hour, minute and second.", "TIME(14, 30, 0)"],
+    "TIMEVALUE": ["Converts text that looks like a time into a time.", "TIMEVALUE(\"2:30 PM\")"],
+    "TOCOL": ["All values of an array in a single column.", "TOCOL(A1:C3)"],
+    "TODAY": ["Today's date.", "TODAY()"],
+    "TOROW": ["All values of an array in a single row.", "TOROW(A1:C3)"],
+    "TO_DATE": ["A number shown as a date.", "TO_DATE(46287)"],
+    "TO_DOLLARS": ["A number shown as currency.", "TO_DOLLARS(A1)"],
+    "TO_PERCENT": ["A number shown as a percentage.", "TO_PERCENT(0.25)"],
+    "TO_PURE_NUMBER": ["A value stripped of its date, currency or percent formatting.", "TO_PURE_NUMBER(A1)"],
+    "TO_TEXT": ["A value converted to text.", "TO_TEXT(A1)"],
+    "TRANSPOSE": ["Swaps the rows and columns of an array.", "TRANSPOSE(A1:C2)"],
+    "TREND": ["Values predicted along a linear trend.", "TREND(B1:B10, A1:A10, A11:A12)"],
+    "TRIM": ["Removes leading, trailing and repeated spaces.", "TRIM(A1)"],
+    "TRIMMEAN": ["Mean of a data set, excluding a proportion of the extremes.", "TRIMMEAN(A1:A20, 0.1)"],
+    "TRUE": ["The logical value TRUE.", "TRUE()"],
+    "TRUNC": ["Truncates a number to a number of decimal places.", "TRUNC(3.789, 1)"],
+    "TYPE": ["Number for the type of a value (1 number, 2 text, 4 logical, 16 error, 64 array).", "TYPE(A1)"],
+    "UMINUS": ["A number with its sign reversed.", "UMINUS(A1)"],
+    "UNARY_PERCENT": ["A value divided by 100.", "UNARY_PERCENT(25)"],
+    "UNICHAR": ["Unicode character for a code point.", "UNICHAR(9731)"],
+    "UNICODE": ["Unicode code point of the first character of text.", "UNICODE(\"A\")"],
+    "UNIQUE": ["The distinct rows of a range.", "UNIQUE(A1:A20)"],
+    "UPLUS": ["The number unchanged.", "UPLUS(A1)"],
+    "UPPER": ["Converts text to upper case.", "UPPER(A1)"],
+    "VALUE": ["Converts text that looks like a number into a number.", "VALUE(\"42\")"],
+    "VAR": ["Sample variance.", "VAR(A1:A10)"],
+    "VAR.P": ["Population variance.", "VAR.P(A1:A10)"],
+    "VAR.S": ["Sample variance.", "VAR.S(A1:A10)"],
+    "VARA": ["Sample variance, counting text as 0 and TRUE as 1.", "VARA(A1:A10)"],
+    "VARP": ["Population variance.", "VARP(A1:A10)"],
+    "VARPA": ["Population variance, counting text as 0 and TRUE as 1.", "VARPA(A1:A10)"],
+    "VDB": ["Depreciation over a span of periods, by a declining-balance method.", "VDB(10000, 1000, 5, 0, 1)"],
+    "VLOOKUP": ["Finds a key in the first column of a range and returns a value from another column.", "VLOOKUP(A1, D1:F10, 3, FALSE)"],
+    "VSTACK": ["Arrays stacked on top of each other into one array.", "VSTACK(A1:C1, A5:C8)"],
+    "WEEKDAY": ["Day of the week of a date, as a number.", "WEEKDAY(A1, 2)"],
+    "WEEKNUM": ["Week number of the year of a date.", "WEEKNUM(A1)"],
+    "WORKDAY": ["Date a number of working days from a start date.", "WORKDAY(A1, 10)"],
+    "WORKDAY.INTL": ["Date a number of working days away, with custom weekends.", "WORKDAY.INTL(A1, 10, 7)"],
+    "WRAPCOLS": ["A row or column wrapped into columns of a given length.", "WRAPCOLS(A1:A12, 4)"],
+    "WRAPROWS": ["A row or column wrapped into rows of a given length.", "WRAPROWS(A1:A12, 4)"],
+    "XIRR": ["Internal rate of return for cash flows on specific dates.", "XIRR(B1:B5, A1:A5)"],
+    "XLOOKUP": ["Finds a key in one range and returns the matching item from another.", "XLOOKUP(A1, D1:D10, E1:E10, \"Not found\")"],
+    "XMATCH": ["Position of a value in a range, with match and search modes.", "XMATCH(\"Apple\", A1:A10)"],
+    "XNPV": ["Net present value of cash flows on specific dates.", "XNPV(0.09, B1:B5, A1:A5)"],
+    "XOR": ["TRUE when an odd number of arguments are TRUE.", "XOR(A1 > 0, B1 > 0)"],
+    "YEAR": ["Year of a date.", "YEAR(A1)"],
+    "YEARFRAC": ["Fraction of a year between two dates.", "YEARFRAC(A1, B1)"]
+};
+if (typeof module !== "undefined" && module.exports) module.exports = SheetFormulaHelp;

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

@@ -41,6 +41,7 @@
     <script src="formula_fn_ref.js"></script>
     <script src="formula_fn_array.js"></script>
     <script src="formula_fn_finance.js"></script>
+    <script src="formula_help.js"></script>
     <div class="of-toolbar of-noprint" id="toolbar"></div>
     <div id="shFxBar" class="of-noprint">
         <input id="shNameBox" spellcheck="false" title="Cell reference (type a ref and press Enter to jump)">
@@ -72,6 +73,7 @@
         <div id="shTabList"></div>
     </div>
     <div id="shPrintArea"></div>
+    <script src="sheets_suggest.js"></script>
     <script src="sheets.js"></script>
     <script src="sheets_io.js"></script>
     <script src="sheets_cf.js"></script>

+ 125 - 1
src/web/Office/sheets/sheets.css

@@ -336,7 +336,24 @@ body.dark {
 }
 #shAddSheet:hover { background: var(--of-hover); color: var(--of-fg); }
 #shAddSheet i.icon { margin: 0 !important; }
-#shTabList { display: flex; gap: 2px; }
+#shTabList { display: flex; gap: 2px; position: relative; }
+.sh-tab.dragging {
+    position: relative;
+    z-index: 2;
+    opacity: 0.85;
+    background: var(--of-chrome-bg2);
+    box-shadow: 0 2px 8px rgba(0, 0, 0, 0.25);
+    cursor: grabbing;
+}
+.sh-tab-drop {
+    position: absolute;
+    top: 2px;
+    bottom: 2px;
+    width: 2px;
+    background: var(--of-accent, #4c9be8);
+    border-radius: 1px;
+    pointer-events: none;
+}
 .sh-tab {
     display: flex;
     align-items: center;
@@ -539,3 +556,110 @@ body.dark {
 .sh-name-add { display: flex; gap: 6px; align-items: center; }
 .sh-name-add input:first-child { width: 160px; flex: 0 0 auto; }
 .sh-name-add button { flex: 0 0 auto; }
+
+/* ============ formula function suggestions (sheets_suggest.js) ============ */
+.sh-fns {
+    position: fixed;
+    z-index: 1500;
+    width: 340px;
+    background: var(--of-chrome-bg);
+    color: var(--of-fg);
+    border: 1px solid var(--of-border);
+    border-radius: 6px;
+    box-shadow: 0 4px 16px var(--of-shadow);
+    font-size: 13px;
+    user-select: none;
+}
+.sh-fns-close {
+    position: absolute;
+    top: -9px;
+    right: -9px;
+    width: 20px;
+    height: 20px;
+    padding: 0;
+    border: none;
+    border-radius: 50%;
+    background: var(--of-fg-soft);
+    color: var(--of-chrome-bg);
+    cursor: pointer;
+    display: flex;
+    align-items: center;
+    justify-content: center;
+}
+.sh-fns-close:hover { background: var(--of-fg); }
+.sh-fns-close i.icon { margin: 0 !important; font-size: 11px; height: auto; width: auto; }
+.sh-fns-list {
+    max-height: 320px;
+    overflow-y: auto;
+    padding: 4px 0;
+}
+.sh-fns-item {
+    padding: 6px 12px;
+    cursor: pointer;
+}
+.sh-fns-item.active { background: var(--of-hover); }
+.sh-fns-name {
+    font-family: Consolas, "Courier New", monospace;
+    letter-spacing: 0.3px;
+}
+.sh-fns-name b { font-weight: 700; }
+.sh-fns-detail {
+    margin-top: 4px;
+    color: var(--of-fg-soft);
+    white-space: normal;
+    line-height: 1.45;
+}
+.sh-fns-desc { margin-bottom: 4px; }
+.sh-fns-usage {
+    display: flex;
+    gap: 6px;
+    align-items: baseline;
+    margin-top: 2px;
+    font-size: 12px;
+}
+.sh-fns-usage span {
+    flex: 0 0 52px;
+    font-weight: 600;
+}
+.sh-fns-usage code {
+    font-family: Consolas, "Courier New", monospace;
+    color: var(--of-fg);
+    word-break: break-word;
+}
+.sh-fns-foot {
+    border-top: 1px solid var(--of-border);
+    padding: 5px 10px;
+    font-size: 12px;
+    color: var(--of-fg-soft);
+}
+.sh-fns-foot kbd {
+    display: inline-block;
+    padding: 0 5px;
+    margin: 0 1px;
+    border: 1px solid var(--of-border);
+    border-radius: 3px;
+    background: var(--of-chrome-bg2);
+    font-family: inherit;
+    font-size: 11px;
+    line-height: 16px;
+}
+
+/* "Paste options" button at the corner of a pasted block */
+.sh-paste-chip {
+    position: absolute;
+    z-index: 17;
+    display: flex;
+    align-items: center;
+    gap: 1px;
+    height: 22px;
+    padding: 0 3px 0 5px;
+    border: 1px solid var(--of-border);
+    border-radius: 4px;
+    background: var(--of-chrome-bg);
+    color: var(--of-fg);
+    box-shadow: 0 1px 4px var(--of-shadow);
+    cursor: pointer;
+}
+.sh-paste-chip:hover { background: var(--of-hover); border-color: var(--of-accent); }
+.sh-paste-chip i.icon { margin: 0 !important; height: auto; font-size: 12px; }
+.sh-paste-chip i.caret.icon { width: 10px; font-size: 10px; color: var(--of-fg-soft); }

+ 322 - 32
src/web/Office/sheets/sheets.js

@@ -822,7 +822,11 @@ var SheetsApp = (function () {
             var $tab = $('<div class="sh-tab' + (i === body.active ? " active" : "") + '"></div>');
             if (s.color) $tab.append('<span class="sh-tab-color" style="background:' + esc(s.color) + ';"></span>');
             $tab.append($("<span></span>").text(s.name));
-            $tab.on("click", function () { switchSheet(i); });
+            $tab.on("pointerdown", function (e) { startTabDrag(e, i); });
+            $tab.on("click", function () {
+                if (tabDragJustEnded) { tabDragJustEnded = false; return; }
+                switchSheet(i);
+            });
             $tab.on("dblclick", function () { renameSheetDialog(i); });
             $tab.on("contextmenu", function (e) {
                 e.preventDefault();
@@ -832,8 +836,70 @@ var SheetsApp = (function () {
             $t.append($tab);
         });
     }
+    // drag a tab along the bar to reorder: past a small threshold the tab
+    // follows the pointer and a marker shows where it will land
+    var tabDragJustEnded = false;
+    function startTabDrag(e, from) {
+        if (e.button !== 0 || body.sheets.length < 2) return;
+        var $bar = $("#shTabs"), $tabs = $("#shTabList .sh-tab");
+        var $tab = $tabs.eq(from), startX = e.clientX, dragging = false, slot = from;
+        var $marker = null, scrollTimer = null, lastX = startX;
+        function computeSlot(x) {
+            var n = $tabs.length;
+            for (var k = 0; k < n; k++) {
+                if (k === from) continue;
+                var r = $tabs[k].getBoundingClientRect();
+                if (x < r.left + r.width / 2) return k;
+            }
+            return n;
+        }
+        function placeMarker() {
+            var listRect = $("#shTabList")[0].getBoundingClientRect();
+            var ref = slot < $tabs.length ? $tabs[slot].getBoundingClientRect() : $tabs[$tabs.length - 1].getBoundingClientRect();
+            var left = (slot < $tabs.length ? ref.left - 2 : ref.right) - listRect.left;
+            $marker.css({ left: left + "px" });
+        }
+        function update(x) {
+            lastX = x;
+            $tab.css("transform", "translateX(" + (x - startX + ($bar.scrollLeft() - startScroll)) + "px)");
+            slot = computeSlot(x);
+            placeMarker();
+        }
+        var startScroll = $bar.scrollLeft();
+        function autoScroll() {
+            var r = $bar[0].getBoundingClientRect(), d = 0;
+            if (lastX < r.left + 30) d = -8;
+            else if (lastX > r.right - 30) d = 8;
+            if (d) { $bar.scrollLeft($bar.scrollLeft() + d); update(lastX); }
+        }
+        $(document).on("pointermove.shtabdrag", function (ev) {
+            if (!dragging) {
+                if (Math.abs(ev.clientX - startX) < 5) return;
+                dragging = true;
+                $tab.addClass("dragging");
+                $marker = $('<div class="sh-tab-drop"></div>').appendTo("#shTabList");
+                scrollTimer = setInterval(autoScroll, 30);
+            }
+            ev.preventDefault();
+            update(ev.clientX);
+        });
+        $(document).on("pointerup.shtabdrag pointercancel.shtabdrag", function (ev) {
+            $(document).off(".shtabdrag");
+            if (!dragging) return;
+            clearInterval(scrollTimer);
+            $tab.removeClass("dragging").css("transform", "");
+            if ($marker) $marker.remove();
+            // the click that follows a drag must not switch sheets
+            tabDragJustEnded = true;
+            setTimeout(function () { tabDragJustEnded = false; }, 0);
+            if (ev.type === "pointercancel") return;
+            var to = slot > from ? slot - 1 : slot;
+            if (to !== from) moveSheet(from, to);
+        });
+    }
     function switchSheet(i) {
         if (i === body.active) return;
+        hidePasteChip();
         commitEdit(true);
         body.active = clamp(i, 0, body.sheets.length - 1);
         anchor = { c: 0, r: 0 };
@@ -968,15 +1034,18 @@ var SheetsApp = (function () {
         ]);
     }
     function moveSheet(from, to) {
+        // the active sheet stays active wherever it ends up
+        var active = body.sheets[body.active];
         var s = body.sheets.splice(from, 1)[0];
         body.sheets.splice(to, 0, s);
-        body.active = to;
+        body.active = body.sheets.indexOf(active);
         commit();
         renderTabs();
     }
 
     /* ================= commit / undo ================= */
     function commit() {
+        hidePasteChip();
         recalc();
         rebuildMerges();
         rebuildFilter();
@@ -988,6 +1057,7 @@ var SheetsApp = (function () {
     }
     function applyUndoState(state) {
         try { body = normalizeBody(JSON.parse(state)); } catch (e) { return; }
+        hidePasteChip();
         var s = sheet();
         anchor.c = clamp(anchor.c, 0, s.cols - 1);
         anchor.r = clamp(anchor.r, 0, s.rows - 1);
@@ -1002,8 +1072,10 @@ var SheetsApp = (function () {
     function doRedo() { commitEdit(false); undo.redo(); }
 
     /* ================= editing ================= */
+    var fnSuggest = null;    // SheetFnSuggest popup, created in init
     function startEdit(initial, viaFx) {
         if (editing) return;
+        hidePasteChip();
         var c = head.c, r = head.r;
         editing = { c: c, r: r, viaFx: !!viaFx };
         var rect = cellRect(c, r);
@@ -1026,6 +1098,7 @@ var SheetsApp = (function () {
     }
     function commitEdit(keepFocus) {
         if (!editing) return;
+        if (fnSuggest) fnSuggest.hide();
         var c = editing.c, r = editing.r;
         var val = editing.viaFx ? $("#shFxInput").val() : inputEl.value;
         editing = null;
@@ -1042,6 +1115,7 @@ var SheetsApp = (function () {
     }
     function cancelEdit() {
         if (!editing) return;
+        if (fnSuggest) fnSuggest.hide();
         editing = null;
         refPick = null;
         inputEl.style.display = "none";
@@ -1815,16 +1889,36 @@ var SheetsApp = (function () {
         }
         return lines.join("\n");
     }
+    /* A computed value written back as raw cell text that re-reads as the
+       same value: text that would parse as a number, boolean, error or
+       formula is forced to text with a leading apostrophe. */
+    function valueToRaw(v) {
+        if (v === null || v === undefined) return "";
+        if (F.isErr(v)) return v.code;
+        if (typeof v === "boolean") return v ? "TRUE" : "FALSE";
+        if (typeof v === "number") return F.numToText(v);
+        var t = String(v);
+        if (t === "") return "";
+        if (t.charAt(0) === "=" || t.charAt(0) === "'" || F.literalValue(t) !== t) return "'" + t;
+        return t;
+    }
     function buildInternalClip(rg) {
-        var rows = [];
+        var rows = [], vals = [], fmts = [];
         var s = sheet();
         for (var r = rg.r1; r <= rg.r2; r++) {
-            var row = [];
+            var row = [], vrow = [], frow = [];
             for (var c = rg.c1; c <= rg.c2; c++) {
                 var cell = s.cells[key(c, r)];
                 row.push(cell ? deep(cell) : null);
+                // what the cell shows now, for the value paste modes (a
+                // formula or spilled cell pastes as its result there)
+                vrow.push(valueToRaw(valueAt(c, r)));
+                var ef = effFormat(c, r);
+                frow.push(ef.fmt && !(cell && cell.s && cell.s.fmt) ? ef.fmt : null);
             }
             rows.push(row);
+            vals.push(vrow);
+            fmts.push(frow);
         }
         // the cells carry rule ids; the bodies must ride along too, or a
         // paste onto another sheet would land ids that resolve to nothing
@@ -1838,7 +1932,11 @@ var SheetsApp = (function () {
         });
         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
+            src: { c: rg.c1, r: rg.r1 }, cells: rows, rg: rg, defs: defs,
+            vals: vals, fmts: fmts,
+            // the sheet object survives renames and reordering; the name is
+            // the fallback once an undo has rebuilt the workbook objects
+            srcSheet: s, srcName: s.name
         };
     }
     /* charts ride the system clipboard as marker JSON, like cells ride
@@ -1965,41 +2063,124 @@ var SheetsApp = (function () {
         };
         commit();
     }
-    function pasteInternal() {
+    /* ---------- paste modes (Excel's Paste Options) ----------
+        link       formulas keep reading their source cells: pasted onto
+                   another sheet, their references are pinned to the source
+                   sheet (=E13*B2 -> =SheetA!E13*SheetA!B2); formatting kept.
+                   The default for a paste onto another sheet, and what a
+                   cut always does (moved cells keep pointing where they did).
+        formulas   formulas shift relative to where they land (=A1 one row
+                   down becomes =A2); formatting kept. Default on one sheet.
+        values     the results only, in the target cells' own formatting
+        valuesfmt  the results with the source formatting
+        format     the source formatting only, target values kept
+        pastelink  every cell becomes a link to the copied cell (=SheetA!C3)
+        transpose  rows become columns; formulas shift, formatting kept */
+    var PASTE_MODES = [
+        { id: "link", label: "Keep source links", icon: "linkify" },
+        { id: "formulas", label: "Formulas (relative)", icon: "calculator" },
+        { id: "values", label: "Values only", icon: "hashtag", key: "Ctrl+Shift+V" },
+        { id: "valuesfmt", label: "Values and formatting", icon: "font" },
+        { id: "format", label: "Formatting only", icon: "paint brush" },
+        { id: "pastelink", label: "Paste link", icon: "external alternate" },
+        { id: "transpose", label: "Transpose", icon: "exchange" }
+    ];
+    var lastPaste = null;    // {mode, anchor, sheet, state} of the paste the chip can redo
+    function clipSourceIndex() {
+        if (!clipInternal) return -1;
+        var i = body.sheets.indexOf(clipInternal.srcSheet);
+        return i >= 0 ? i : sheetIndexByName(clipInternal.srcName);
+    }
+    function clipSourceName() {
+        var i = clipSourceIndex();
+        return i >= 0 ? body.sheets[i].name : clipInternal.srcName;
+    }
+    function clipIsCrossSheet() {
+        var i = clipSourceIndex();
+        return i >= 0 ? i !== body.active : clipInternal.srcName !== sheet().name;
+    }
+    function pasteInternal(mode) {
+        if (!clipInternal) return;
+        var cut = clipCut;
+        if (cut) mode = "link";
+        mode = mode || (clipIsCrossSheet() ? "link" : "formulas");
         var s = sheet();
-        var w = clipInternal.w, h = clipInternal.h;
+        var clip = clipInternal;
+        var cross = clipIsCrossSheet(), srcName = clipSourceName();
+        var tr = mode === "transpose";
+        var w = tr ? clip.h : clip.w, h = tr ? clip.w : clip.h;
         // bring any rule bodies the copied cells refer to onto this sheet
-        if (clipInternal.defs) {
+        if (mode !== "values" && mode !== "pastelink" && clip.defs) {
             if (!s.cfDefs) s.cfDefs = {};
-            Object.keys(clipInternal.defs).forEach(function (id) {
-                if (!s.cfDefs[id]) s.cfDefs[id] = deep(clipInternal.defs[id]);
+            Object.keys(clip.defs).forEach(function (id) {
+                if (!s.cfDefs[id]) s.cfDefs[id] = deep(clip.defs[id]);
             });
         }
-        var dC = anchor.c - clipInternal.src.c;
-        var dR = anchor.r - clipInternal.src.r;
-        for (var r = 0; r < h; r++) {
-            for (var c = 0; c < w; c++) {
-                var cell = clipInternal.cells[r][c];
-                var tc = anchor.c + c, tr = anchor.r + r;
-                if (tc >= MAX_COLS || tr >= MAX_ROWS) continue;
-                growTo(tc + 1, tr + 1);
-                var k = key(tc, tr);
-                if (!cell) { delete s.cells[k]; continue; }
-                var nc = deep(cell);
-                if (nc.v && String(nc.v).charAt(0) === "=") {
-                    nc.v = F.rewriteRelative(nc.v, dC, dR);
+        for (var r = 0; r < clip.h; r++) {
+            for (var c = 0; c < clip.w; c++) {
+                var cell = clip.cells[r][c];
+                var tc = anchor.c + (tr ? r : c), trow = anchor.r + (tr ? c : r);
+                if (tc >= MAX_COLS || trow >= MAX_ROWS) continue;
+                growTo(tc + 1, trow + 1);
+                var k = key(tc, trow);
+                var old = s.cells[k];
+                var srcC = clip.src.c + c, srcR = clip.src.r + r;
+                var val = clip.vals[r][c];
+                var isFormula = cell && cell.v && String(cell.v).charAt(0) === "=";
+                var nc;
+                switch (mode) {
+                    case "link":
+                        nc = cell ? deep(cell) : null;
+                        if (isFormula && cross) nc.v = F.qualifyRefs(nc.v, srcName);
+                        break;
+                    case "formulas":
+                    case "transpose":
+                        nc = cell ? deep(cell) : null;
+                        if (isFormula) nc.v = F.rewriteRelative(nc.v, tc - srcC, trow - srcR);
+                        break;
+                    case "values":
+                        nc = old ? deep(old) : { v: "" };
+                        nc.v = val;
+                        break;
+                    case "valuesfmt":
+                        nc = cell ? deep(cell) : { v: "" };
+                        nc.v = val;
+                        // a formula's own number format (TODAY, TO_PERCENT ...)
+                        // would leave with the formula - keep it on the value
+                        if (clip.fmts[r][c]) {
+                            if (!nc.s) nc.s = {};
+                            nc.s.fmt = clip.fmts[r][c];
+                        }
+                        break;
+                    case "format":
+                        nc = old ? deep(old) : { v: "" };
+                        if (cell && cell.s) nc.s = deep(cell.s); else delete nc.s;
+                        if (cell && cell.cf) nc.cf = deep(cell.cf); else delete nc.cf;
+                        break;
+                    case "pastelink":
+                        nc = old ? deep(old) : { v: "" };
+                        // blank sources stay blank rather than showing 0
+                        nc.v = val === "" ? "" :
+                            "=" + (cross ? F.quoteSheetName(srcName) + "!" : "") + key(srcC, srcR);
+                        break;
                 }
-                s.cells[k] = nc;
+                if (cellIsBare(nc)) delete s.cells[k];
+                else s.cells[k] = nc;
             }
         }
-        if (clipCut) {
-            var rg = clipInternal.rg;
-            for (var rr = rg.r1; rr <= rg.r2; rr++) {
-                for (var cc = rg.c1; cc <= rg.c2; cc++) {
-                    // don't wipe overlap of the paste target
-                    if (cc >= anchor.c && cc < anchor.c + w &&
-                        rr >= anchor.r && rr < anchor.r + h) continue;
-                    delete s.cells[key(cc, rr)];
+        if (cut) {
+            // clear the cut cells on the sheet they came from
+            var si = clipSourceIndex();
+            var from = si >= 0 ? body.sheets[si] : null;
+            var rg = clip.rg;
+            if (from) {
+                for (var rr = rg.r1; rr <= rg.r2; rr++) {
+                    for (var cc = rg.c1; cc <= rg.c2; cc++) {
+                        // don't wipe overlap of the paste target
+                        if (from === s && cc >= anchor.c && cc < anchor.c + w &&
+                            rr >= anchor.r && rr < anchor.r + h) continue;
+                        delete from.cells[key(cc, rr)];
+                    }
                 }
             }
             clipCut = false;
@@ -2007,11 +2188,102 @@ var SheetsApp = (function () {
             clipTsv = "";
         }
         // reselect the pasted block
+        var at = { c: anchor.c, r: anchor.r };
         head = {
             c: clamp(anchor.c + w - 1, 0, sheet().cols - 1),
             r: clamp(anchor.r + h - 1, 0, sheet().rows - 1)
         };
         commit();
+        if (!cut) {
+            lastPaste = { mode: mode, anchor: at, sheet: body.active, state: undo.stack[undo.pos] };
+            placePasteChip();
+        }
+        var n = clip.w * clip.h;
+        var label = PASTE_MODES.filter(function (m) { return m.id === mode; })[0];
+        OfficeApp.setStatus(cut ? "Moved " + n + " cell(s)" :
+            "Pasted " + n + " cell(s) - " + (label ? label.label.toLowerCase() : mode));
+    }
+    /* Paste special from a menu or Ctrl+Shift+V. Only Sheets' own copied
+       cells carry formulas and formatting; anything else pastes as text,
+       which already is a values paste. */
+    function pasteSpecial(mode) {
+        if (editing) return;
+        if (clipInternal) { pasteInternal(mode); return; }
+        if (mode === "values" && navigator.clipboard && navigator.clipboard.readText) {
+            navigator.clipboard.readText().then(function (t) { if (t) pasteText(t); }).catch(function () {
+                OfficeApp.setStatus("Use Ctrl+V to paste here", "error");
+            });
+            return;
+        }
+        OfficeApp.setStatus("Paste special needs cells copied from Sheets", "error");
+    }
+    function pasteSpecialItems() {
+        return PASTE_MODES.map(function (m) {
+            return {
+                label: m.label, icon: m.icon, key: m.key,
+                enabled: function () { return !!clipInternal || m.id === "values"; },
+                action: function () { pasteSpecial(m.id); }
+            };
+        });
+    }
+    /* Paste again in another mode: while nothing changed since the paste,
+       step back over it first, so the switch replaces the paste in the undo
+       history instead of stacking on it. */
+    function repasteAs(mode) {
+        if (!lastPaste || !clipInternal) return;
+        var lp = lastPaste;
+        if (undo.stack[undo.pos] === lp.state && undo.canUndo()) undo.undo();
+        if (lp.sheet !== body.active && lp.sheet < body.sheets.length) {
+            body.active = lp.sheet;
+            renderAll();
+        }
+        anchor = { c: lp.anchor.c, r: lp.anchor.r };
+        head = { c: lp.anchor.c, r: lp.anchor.r };
+        selCols = selRows = null;
+        pasteInternal(mode);
+    }
+    /* The "Paste options" button at the bottom-right corner of the pasted
+       block (Excel's Ctrl button); any later change removes it. */
+    var pasteChipEl = null;
+    function hidePasteChip() {
+        lastPaste = null;
+        if (pasteChipEl) { pasteChipEl.parentNode.removeChild(pasteChipEl); pasteChipEl = null; }
+    }
+    function placePasteChip() {
+        if (!lastPaste) return;
+        if (!pasteChipEl) {
+            pasteChipEl = document.createElement("button");
+            pasteChipEl.type = "button";
+            pasteChipEl.className = "sh-paste-chip";
+            pasteChipEl.title = "Paste options";
+            pasteChipEl.innerHTML = '<i class="paste icon"></i><i class="caret down icon"></i>';
+            // the button sits inside the grid, whose pointer handlers would
+            // take the press as a cell selection and swallow the click
+            ["pointerdown", "pointermove", "pointerup", "mousedown", "dblclick", "contextmenu"].forEach(function (t) {
+                pasteChipEl.addEventListener(t, function (e) {
+                    e.stopPropagation();
+                    if (t === "mousedown" || t === "contextmenu") e.preventDefault();
+                });
+            });
+            pasteChipEl.addEventListener("click", function (e) {
+                e.stopPropagation();
+                if (!lastPaste) return;
+                var r = pasteChipEl.getBoundingClientRect();
+                var cur = lastPaste.mode;
+                OfficeApp.showContextMenu(r.left, r.bottom + 2, PASTE_MODES.map(function (m) {
+                    return {
+                        label: m.label, icon: m.icon,
+                        checked: function () { return m.id === cur; },
+                        action: function () { repasteAs(m.id); }
+                    };
+                }));
+            });
+            gridEl.appendChild(pasteChipEl);
+        }
+        var rg = selRange();
+        var b = cellRect(rg.c2, rg.r2);
+        pasteChipEl.style.left = (b.x + b.w + 2) + "px";
+        pasteChipEl.style.top = (b.y + b.h + 2) + "px";
     }
     function pasteText(text) {
         var rows = text.replace(/\r/g, "").split("\n");
@@ -2469,6 +2741,7 @@ var SheetsApp = (function () {
             { label: "Cut", icon: "cut", key: "Ctrl+X", action: function () { execClipboard("cut"); } },
             { label: "Copy", icon: "copy", key: "Ctrl+C", action: function () { execClipboard("copy"); } },
             { label: "Paste", icon: "paste", key: "Ctrl+V", action: function () { execClipboard("paste"); } },
+            { label: "Paste special", icon: "clipboard outline", sub: pasteSpecialItems() },
             { sep: true },
             {
                 label: "Insert " + (selRows ? (selRows.r2 - selRows.r1 + 1) + " row(s)" : "row above"),
@@ -2789,6 +3062,18 @@ var SheetsApp = (function () {
         });
 
         inputEl.addEventListener("keydown", onInputKeyDown);
+        // function-name suggestions while typing a formula, in both editors
+        fnSuggest = SheetFnSuggest.create({
+            names: F.functionNames,
+            syntax: function (n) { var sp = F.lookupFunction(n); return sp && sp.syntax; },
+            help: window.SheetFormulaHelp,
+            onApply: function (el) {
+                if (el === inputEl) $("#shFxInput").val(el.value);
+                else inputEl.value = el.value;
+            }
+        });
+        fnSuggest.attach(inputEl);
+        fnSuggest.attach(document.getElementById("shFxInput"));
         inputEl.addEventListener("blur", function () {
             setTimeout(function () {
                 if (editing && !editing.viaFx && document.activeElement !== inputEl &&
@@ -2900,6 +3185,7 @@ var SheetsApp = (function () {
             onCut: function () { execClipboard("cut"); },
             onCopy: function () { execClipboard("copy"); },
             onPaste: function () { execClipboard("paste"); },
+            editMenuExtras: [{ label: "Paste special", icon: "clipboard outline", sub: pasteSpecialItems() }],
 
             menus: [
                 { title: "Insert", items: insertMenuItems },
@@ -2945,6 +3231,10 @@ var SheetsApp = (function () {
         });
 
         OfficeApp.registerShortcut("Ctrl+B", function () { toggleStyleFlag("b"); });
+        OfficeApp.registerShortcut("Ctrl+Shift+V", function () {
+            if (editing) return false;
+            pasteSpecial("values");
+        }, { description: "Paste values only", group: "Edit", allowInInput: false, inDialogs: false });
         OfficeApp.registerShortcut("Ctrl+I", function () { toggleStyleFlag("i"); });
         OfficeApp.registerShortcut("Ctrl+U", function () { toggleStyleFlag("u"); });
         // Google Sheets' keys (Excel's Ctrl+9 is taken by the browser's tab switch)

+ 210 - 0
src/web/Office/sheets/sheets_suggest.js

@@ -0,0 +1,210 @@
+/*
+    ArozOS Office Sheets - formula function suggestions
+    ===================================================
+    While a formula is being typed ("=AVE"), a popup under the editor lists
+    the functions whose name matches the word at the caret. The highlighted
+    entry (hovered, or picked with the arrow keys) expands to show what the
+    function does, its usage line and a worked example.
+
+        var s = SheetFnSuggest.create({
+            names:  function () { return ["ABS", ...]; },
+            syntax: function (name) { return "ABS(value)"; },
+            help:   SheetFormulaHelp,             // { NAME: [desc, example] }
+            onApply: function (el) { ... }        // after text was inserted
+        });
+        s.attach(inputElement);                   // cell editor, formula bar
+        s.hide();
+
+    Tab or Enter accepts, Up / Down move, Escape or the close button dismiss
+    the popup until the word being typed changes.
+*/
+var SheetFnSuggest = (function () {
+    "use strict";
+
+    var MAX_ITEMS = 12;
+    // everyday functions float to the top of an ambiguous prefix
+    var POPULAR = ["SUM", "AVERAGE", "COUNT", "COUNTA", "COUNTIF", "MAX", "MIN", "IF", "IFERROR",
+        "VLOOKUP", "XLOOKUP", "INDEX", "MATCH", "SUMIF", "SUMIFS", "ROUND", "TODAY", "NOW",
+        "CONCAT", "LEFT", "RIGHT", "MID", "LEN", "TRIM", "TEXT", "AND", "OR", "DATE", "FILTER",
+        "SORT", "UNIQUE"];
+    // characters that may come right before a function name
+    var BOUNDARY = "=+-*/(,:<>&^%;{} \t\n";
+
+    function create(opts) {
+        var el = null;              // the editor the popup belongs to
+        var $pop = null;
+        var items = [];             // matching names
+        var active = 0;
+        var token = null;           // {start, end, text}
+        var dismissed = null;       // "start:text" of a token the user closed
+
+        function help(name) { return (opts.help && opts.help[name]) || ["", ""]; }
+
+        /* ---------- which word is being typed ---------- */
+        function tokenAt(input) {
+            var v = input.value;
+            if (v.charAt(0) !== "=") return null;
+            var caret = input.selectionStart;
+            if (caret === null || caret === undefined || caret !== input.selectionEnd) return null;
+            var before = v.substring(0, caret);
+            // inside a string literal: an odd number of quotes precede the caret
+            if ((before.match(/"/g) || []).length % 2 === 1) return null;
+            var m = before.match(/[A-Za-z_][A-Za-z0-9_.]*$/);
+            if (!m) return null;
+            var start = caret - m[0].length;
+            if (start === 0 || BOUNDARY.indexOf(v.charAt(start - 1)) < 0) return null;
+            // the rest of the word after the caret is replaced too
+            var after = v.substring(caret).match(/^[A-Za-z0-9_.]*/)[0];
+            return { start: start, end: caret + after.length, text: m[0].toUpperCase() };
+        }
+        function rank(q) {
+            var names = opts.names();
+            var prefix = [], inner = [];
+            names.forEach(function (n) {
+                var at = n.indexOf(q);
+                if (at === 0) prefix.push(n);
+                else if (at > 0 && q.length >= 2) inner.push(n);
+            });
+            function pop(n) { var i = POPULAR.indexOf(n); return i < 0 ? 999 : i; }
+            prefix.sort(function (a, b) {
+                return (pop(a) - pop(b)) || (a.length - b.length) || (a < b ? -1 : a > b ? 1 : 0);
+            });
+            inner.sort(function (a, b) {
+                return (a.indexOf(q) - b.indexOf(q)) || (a.length - b.length) || (a < b ? -1 : 1);
+            });
+            return prefix.concat(inner).slice(0, MAX_ITEMS);
+        }
+
+        /* ---------- popup ---------- */
+        function build() {
+            $pop = $('<div class="sh-fns" role="listbox">' +
+                '<button type="button" class="sh-fns-close" title="Close"><i class="close icon"></i></button>' +
+                '<div class="sh-fns-list"></div>' +
+                '<div class="sh-fns-foot"><kbd>Tab</kbd> to accept, <kbd>&uarr;</kbd><kbd>&darr;</kbd> to navigate, ' +
+                '<kbd>Esc</kbd> to close</div></div>');
+            // keep the caret in the editor while the popup is clicked
+            $pop.on("mousedown", function (e) { e.preventDefault(); });
+            $pop.find(".sh-fns-close").on("click", dismiss);
+            $pop.on("mouseenter", ".sh-fns-item", function () {
+                var i = +$(this).attr("data-i");
+                if (i !== active) { active = i; render(false); }
+            });
+            $pop.on("click", ".sh-fns-item", function () {
+                active = +$(this).attr("data-i");
+                accept();
+            });
+            $("body").append($pop);
+        }
+        function render(scroll) {
+            var $list = $pop.find(".sh-fns-list").empty();
+            items.forEach(function (n, i) {
+                var $it = $('<div class="sh-fns-item" role="option"></div>').attr("data-i", i);
+                var at = n.indexOf(token.text);
+                $it.append($('<div class="sh-fns-name"></div>')
+                    .append(document.createTextNode(n.substring(0, at)))
+                    .append($("<b></b>").text(n.substr(at, token.text.length)))
+                    .append(document.createTextNode(n.substring(at + token.text.length))));
+                if (i === active) {
+                    var h = help(n);
+                    $it.addClass("active").attr("aria-selected", "true");
+                    var $d = $('<div class="sh-fns-detail"></div>');
+                    if (h[0]) $d.append($('<div class="sh-fns-desc"></div>').text(h[0]));
+                    $d.append($('<div class="sh-fns-usage"><span>Usage</span></div>')
+                        .append($("<code></code>").text(opts.syntax(n) || n + "()")));
+                    if (h[1]) {
+                        $d.append($('<div class="sh-fns-usage"><span>Example</span></div>')
+                            .append($("<code></code>").text("=" + h[1])));
+                    }
+                    $it.append($d);
+                }
+                $list.append($it);
+            });
+            if (scroll) {
+                var act = $list.find(".sh-fns-item.active")[0];
+                if (act && act.scrollIntoView) act.scrollIntoView({ block: "nearest" });
+            }
+            place();
+        }
+        function place() {
+            if (!$pop || !el) return;
+            var r = el.getBoundingClientRect();
+            var w = $pop.outerWidth(), h = $pop.outerHeight();
+            var vw = window.innerWidth, vh = window.innerHeight;
+            var left = Math.max(4, Math.min(r.left, vw - w - 4));
+            var top = r.bottom + 2;
+            if (top + h > vh - 4 && r.top - h - 2 >= 4) top = r.top - h - 2;
+            $pop.css({ left: left + "px", top: top + "px" });
+        }
+        function hide() {
+            if ($pop) { $pop.remove(); $pop = null; }
+            items = [];
+            token = null;
+        }
+        function dismiss() {
+            if (token) dismissed = token.start + ":" + token.text;
+            hide();
+        }
+        function update(input) {
+            el = input;
+            var t = tokenAt(input);
+            if (!t) { dismissed = null; hide(); return; }
+            if (dismissed === t.start + ":" + t.text) { hide(); return; }
+            dismissed = null;
+            var list = rank(t.text);
+            if (!list.length) { hide(); return; }
+            var keep = token && token.start === t.start ? items[active] : null;
+            token = t;
+            items = list;
+            active = Math.max(0, items.indexOf(keep));
+            if (!$pop) build();
+            render(true);
+        }
+        function accept() {
+            if (!token || !el || !items[active]) return;
+            var name = items[active], v = el.value;
+            var rest = v.substring(token.end);
+            var ins = name + (rest.charAt(0) === "(" ? "" : "(");
+            var caret = token.start + ins.length + (rest.charAt(0) === "(" ? 1 : 0);
+            el.value = v.substring(0, token.start) + ins + rest;
+            try { el.setSelectionRange(caret, caret); } catch (e) { }
+            hide();
+            if (opts.onApply) opts.onApply(el);
+        }
+        function onKeyDown(e) {
+            if (!$pop || e.target !== el) return;
+            var k = e.key, handled = true;
+            if (k === "ArrowDown") { active = (active + 1) % items.length; render(true); }
+            else if (k === "ArrowUp") { active = (active - 1 + items.length) % items.length; render(true); }
+            else if ((k === "Tab" && !e.shiftKey) || (k === "Enter" && !e.altKey)) accept();
+            else if (k === "Escape") dismiss();
+            else handled = false;
+            if (handled) {
+                e.preventDefault();
+                e.stopImmediatePropagation();
+            }
+        }
+
+        function attach(input) {
+            // capture: runs before the editor's own Enter / Tab / Escape handling
+            input.addEventListener("keydown", onKeyDown, true);
+            input.addEventListener("input", function () { update(input); });
+            input.addEventListener("click", function () { if ($pop || tokenAt(input)) update(input); });
+            input.addEventListener("keyup", function (e) {
+                if (/^(ArrowLeft|ArrowRight|Home|End)$/.test(e.key)) update(input);
+            });
+            input.addEventListener("blur", function () {
+                setTimeout(function () { if (el === input && document.activeElement !== input) hide(); }, 0);
+            });
+        }
+        window.addEventListener("resize", function () { if ($pop) place(); });
+
+        return {
+            attach: attach,
+            update: update,
+            hide: hide,
+            isOpen: function () { return !!$pop; }
+        };
+    }
+
+    return { create: create };
+})();

+ 20 - 0
src/web/Office/sheets/test_formula.js

@@ -266,6 +266,13 @@ eq("rename sheet refs", F.renameSheetRefs("=Data!A1+'data'!B2+Other!A1", "Data",
     "='Raw data'!A1+'Raw data'!B2+Other!A1");
 eq("rename leaves strings alone", F.renameSheetRefs('="Data!A1"&Data!A1', "Data", "D2"), "=\"Data!A1\"&'D2'!A1");
 eq("quoteSheetName plain", F.quoteSheetName("Analysis"), "Analysis");
+eq("qualify refs", F.qualifyRefs("=E13*B2", "SheetA"), "=SheetA!E13*SheetA!B2");
+eq("qualify range + abs", F.qualifyRefs("=SUM($A$1:B9)+C1", "Data"), "=SUM(Data!$A$1:B9)+Data!C1");
+eq("qualify quoted name", F.qualifyRefs("=A1", "Closed Tickets"), "='Closed Tickets'!A1");
+eq("qualify keeps other sheets", F.qualifyRefs("=Other!A1+'X y'!B2:C3+A2", "S"), "=Other!A1+'X y'!B2:C3+S!A2");
+eq("qualify whole cols/rows", F.qualifyRefs("=SUM(A:B)+SUM(2:5)", "S"), "=SUM(S!A:B)+SUM(S!2:5)");
+eq("qualify skips strings/functions", F.qualifyRefs('="A1"&LOG10(A1)', "S"), '="A1"&LOG10(S!A1)');
+eq("qualify no refs", F.qualifyRefs("=1+2", "S"), "=1+2");
 
 /* helpers */
 eq("colToName", F.colToName(0), "A");
@@ -273,5 +280,18 @@ eq("colToName AA", F.colToName(26), "AA");
 eq("nameToCol", F.nameToCol("AB"), 27);
 eq("cellName", F.cellName(2, 4), "C5");
 
+/* function help (formula_help.js): every function has an entry, every
+   entry names a real function and every example parses */
+var HELP = require("./formula_help.js");
+F.functionNames().forEach(function (n) {
+    eq("help entry for " + n, !!HELP[n], true);
+});
+Object.keys(HELP).forEach(function (n) {
+    eq("help names a function: " + n, !!F.lookupFunction(n), true);
+    var h = HELP[n], ok = false;
+    try { F.parse(h[1]); ok = h[1].toUpperCase().indexOf(n + "(") === 0; } catch (e) { ok = false; }
+    eq("help example parses: " + n, ok, true);
+});
+
 console.log(passes + " passed, " + failures + " failed");
 process.exit(failures ? 1 : 0);