diff --git a/CHANGELOG.md b/CHANGELOG.md index f8a148af1..4a5659bfe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,12 @@ The release run heads these entries with the version and opens a fresh ## Unreleased +- The sheet editor formats cells. A selection is a rectangle, spanned by a + shift click, a shift arrow, a mouse drag or a header click, and + `odr.editing.format` and `toggle` take `fill` and `align` beside the text + keys. `onSelectionChange` reports what the selected cells agree on. One + gesture is one undo step, and the log holds one `setCellStyle` op per cell. + - A cell of an ods or xlsx file takes a style: `Sheet::set_cell_style` and the `setCellStyle` op write the fill, the horizontal alignment, and bold, italic, underline, strikethrough, colour and size. diff --git a/docs/design/spreadsheet-editing.md b/docs/design/spreadsheet-editing.md index f4ba83a38..202a9469b 100644 --- a/docs/design/spreadsheet-editing.md +++ b/docs/design/spreadsheet-editing.md @@ -195,8 +195,8 @@ separate scripts. The coordinates are the ones an op names, never a DOM index. ## Cell formatting -Status: `.ods` and `.xlsx` write a cell style. The steps land as a stack, in -this order: +Status: `.ods` and `.xlsx` write a cell style, and the sheet editor formats a +selection. The steps land as a stack, in this order: 1. The xlsx reader reads what the writer writes: a solid fill from `fgColor`, theme colours with their `tint`, italic, underline and strikethrough, and @@ -255,17 +255,22 @@ is for a run. ### 12. A selection is a rectangle, and the pin is one corner of it `spreadsheet.js` owns it as it owns the pin: shift with a click or an arrow -key, and a drag with a mouse, span it from the pin. `odr.sheet.selection()` -answers `{columns: [first, last], rows: [first, last]}`, and a header click -selects its row or its column across the rendered extent. The editor reports -the keys the selected cells agree on through `onSelectionChange`, as the +key, and a drag with a mouse in the editing mode, span it from the pin. +`odr.sheet.selection()` answers `{columns: [first, last], rows: [first, last], +focus}`, `odr.sheet.select(position)` moves the focus, and a header click +selects its row or its column across the rendered extent. Ctrl or Cmd with B, +I and U toggle, where the config gives the scripts the shortcuts. The editor +reports the keys the selected cells agree on through `onSelectionChange`, as the document editor does, and `odr.editing.format` and `toggle` act on every unlocked cell of it. A lock refuses a value, not a style. The editor patches the `td` and the text inside it with the declarations the renderer writes, and a fill also sets `--odr-dark-fill` with the same mapping -as `html::dark_fill`, so a fill made in the dark scheme shows. Undo holds each -cell's inline style before the gesture, and one gesture is one undo step. +as `html::dark_fill`, so a fill made in the dark scheme shows. The renderer +writes `--odr-fill` beside it, so the editor reads a cell's fill back in the +dark scheme too. `html_common` and `formatting.html` pin the same colours. +Undo holds each cell's inline style before the gesture, and one gesture is one +undo step. ## Formulas, read side diff --git a/src/odr/internal/html/document_style.cpp b/src/odr/internal/html/document_style.cpp index a1b24427b..7960f3466 100644 --- a/src/odr/internal/html/document_style.cpp +++ b/src/odr/internal/html/document_style.cpp @@ -392,6 +392,8 @@ html::translate_table_cell_style(const TableCellStyle &table_cell_style, if (dark_fill) { result.append("--odr-dark-fill:") .append(color(html::dark_fill(*background_color))) + .append(";--odr-fill:") + .append(color(*background_color)) .append(";"); } } diff --git a/src/odr/internal/html/document_style.hpp b/src/odr/internal/html/document_style.hpp index 3b1e8a8dd..549d780d9 100644 --- a/src/odr/internal/html/document_style.hpp +++ b/src/odr/internal/html/document_style.hpp @@ -52,7 +52,8 @@ std::string translate_table_column_style(const TableColumnStyle &table_column_style); std::string translate_table_row_style(const TableRowStyle &table_row_style); /// With @p dark_fill a fill also carries `--odr-dark-fill`, the color -/// `document-dark.css` paints the cell with. +/// `document-dark.css` paints the cell with, and `--odr-fill`, the one it +/// hides. std::string translate_table_cell_style(const TableCellStyle &table_cell_style, bool dark_fill); std::string translate_drawing_style(const GraphicStyle &graphic_style); diff --git a/src/odr/internal/html/frontend/editing.js b/src/odr/internal/html/frontend/editing.js index 010994442..b36de0ffd 100644 --- a/src/odr/internal/html/frontend/editing.js +++ b/src/odr/internal/html/frontend/editing.js @@ -154,8 +154,9 @@ }, /// States @p style on the selection: `bold`, `italic`, `underline`, - /// `strikethrough` (a bool), `highlight` (`#rrggbb` or null), `color` - /// (`#rrggbb`), `size` (`14pt`). False where refused; the channel says why. + /// `strikethrough` (a bool), `color` (`#rrggbb`), `size` (`14pt`), and + /// `highlight` (`#rrggbb` or null) on text, `fill` (the same) and `align` + /// (`left`, `center`, `right`) on cells. False where refused. format: function (style) { for (var i = editors.length - 1; i >= 0; --i) { if (typeof editors[i].format === "function") { diff --git a/src/odr/internal/html/frontend/sheet-editing.js b/src/odr/internal/html/frontend/sheet-editing.js index feaf7bdf7..7619bf6ba 100644 --- a/src/odr/internal/html/frontend/sheet-editing.js +++ b/src/odr/internal/html/frontend/sheet-editing.js @@ -1,5 +1,5 @@ -// The sheet's editor, attached to `odr.editing`: the cell overlay, the locks -// and the `setCell` op. The mode itself is `editing.js`. +// The sheet's editor, attached to `odr.editing`: the cell overlay, the locks, +// the `setCell` and `setCellStyle` ops. The mode itself is `editing.js`. (function () { "use strict"; @@ -43,14 +43,39 @@ var history = []; var undone = []; - /// One op per position, the last write made. + /// One op per kind and position: the last value written, and the style + /// keys merged with the later ones winning. function coalesced() { - var byPosition = new Map(); + var byKey = new Map(); for (var i = 0; i < history.length; ++i) { - var op = history[i].op; - byPosition.set(op.sheet + ":" + op.column + ":" + op.row, op); + var ops = history[i].ops; + for (var j = 0; j < ops.length; ++j) { + var op = ops[j]; + var key = op.op + ":" + op.sheet + ":" + op.column + ":" + op.row; + var earlier = byKey.get(key); + if (op.op === "setCellStyle" && earlier !== undefined) { + op = { + op: op.op, + sheet: op.sheet, + column: op.column, + row: op.row, + style: Object.assign({}, earlier.style, op.style), + }; + } + byKey.set(key, op); + } } - return Array.from(byPosition.values()); + return Array.from(byKey.values()); + } + + /// An undo and a redo are the same move on the page; the log tells them + /// apart. + function replay(move) { + close(); + move(); + repaintStale(); + odr.editing.changed(); + reportSelection(true); } var NUMBER = /^[+-]?([0-9]+(\.[0-9]*)?|\.[0-9]+)([eE][+-]?[0-9]+)?$/; @@ -87,14 +112,15 @@ return false; } history.push({ - op: { - op: "setCell", - sheet: sheet, - column: column, - row: row, - value: value, + ops: [ + { op: "setCell", sheet: sheet, column: column, row: row, value: value }, + ], + undo: function () { + odr.sheet.showValue(column, row, before); + }, + redo: function () { + odr.sheet.showValue(column, row, value); }, - before: before, }); undone = []; repaintStale(); @@ -102,15 +128,6 @@ return true; } - /// An undo and a redo are the same move on the page; the log tells them - /// apart. - function replay(entry, value) { - close(); - odr.sheet.showValue(entry.op.column, entry.op.row, value); - repaintStale(); - odr.editing.changed(); - } - /// What each formula cell reads, off `data-odr-reads`. Only the rectangles /// in this sheet, because an edit here names a position in it. var readers = null; @@ -199,7 +216,9 @@ var written = []; var ops = coalesced(); for (var i = 0; i < ops.length; ++i) { - written.push({ column: ops[i].column, row: ops[i].row }); + if (ops[i].op === "setCell") { + written.push({ column: ops[i].column, row: ops[i].row }); + } } for (var j = 0; j < stale.length; ++j) { @@ -342,19 +361,25 @@ ArrowRight: [1, 0], }; + /// Whether a key at @p target types into a field the page or the host owns. + function typingIn(target) { + return ( + target !== null && + (target.isContentEditable || + /^(INPUT|TEXTAREA|SELECT)$/.test(target.tagName)) + ); + } + /// What a pinned cell does with a key when no editor is open. Captured, so /// these never reach the pin and the sort beneath. The chord is `editing.js`'s. function pinnedKey(event) { - var target = event.target; if ( !odr.editing.isEnabled() || overlay !== null || event.ctrlKey || event.metaKey || event.altKey || - (target && - (target.isContentEditable || - /^(INPUT|TEXTAREA|SELECT)$/.test(target.tagName))) + typingIn(event.target) ) { return; } @@ -367,7 +392,10 @@ var step = arrows[event.key] || (event.key === "Tab" ? [event.shiftKey ? -1 : 1, 0] : null); - if (step !== null) { + if (event.shiftKey && arrows[event.key] !== undefined) { + var to = odr.sheet.selection().focus; + odr.sheet.select({ column: to.column + step[0], row: to.row + step[1] }); + } else if (step !== null) { odr.sheet.pin({ column: at.column + step[0], row: at.row + step[1] }); } else if (event.key === "Enter" || event.key === "F2") { edit(at.column, at.row, null); @@ -441,10 +469,13 @@ return copy; }; - // A locked cell says so on the click, not on the double click. + // A locked cell says so on the click, not on the double click. A shift + // click spans a selection and opens nothing. table.addEventListener("click", function (event) { var at = - odr.editing.isEnabled() && overlay === null ? targetPosition(event) : null; + odr.editing.isEnabled() && overlay === null && !event.shiftKey + ? targetPosition(event) + : null; if (at === null) { return; } @@ -483,6 +514,398 @@ return edit(column, row, null); }; + // ------------------------------------------------------------- formatting + + var TOGGLES = ["bold", "italic", "underline", "strikethrough"]; + var KEYS = TOGGLES.concat(["color", "size", "fill", "align"]); + + /// `#rrggbb` for a computed `rgb(…)`, null for a transparent one. + function hexOf(computed) { + var match = /^rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([\d.]+))?\)$/.exec( + computed + ); + if (match === null || (match[4] !== undefined && Number(match[4]) === 0)) { + return null; + } + var hex = "#"; + for (var i = 1; i <= 3; ++i) { + hex += ("0" + Number(match[i]).toString(16)).slice(-2); + } + return hex; + } + + // `html::dark_fill`: the lightness mirrored in oklab into the band from + // the dark page to the lightest ground its text reads on, the hue kept. + function toLinear(c) { + c /= 255; + return c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4); + } + + function toOklab(r, g, b) { + r = toLinear(r); + g = toLinear(g); + b = toLinear(b); + var l = Math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b); + var m = Math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b); + var s = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b); + return [ + 0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s, + 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s, + 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s, + ]; + } + + function toLinearRgb(lab) { + var l = Math.pow(lab[0] + 0.3963377774 * lab[1] + 0.2158037573 * lab[2], 3); + var m = Math.pow(lab[0] - 0.1055613458 * lab[1] - 0.0638541728 * lab[2], 3); + var s = Math.pow(lab[0] - 0.0894841775 * lab[1] - 1.291485548 * lab[2], 3); + return [ + 4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s, + -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s, + -0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s, + ]; + } + + function inGamut(rgb) { + return rgb.every(function (c) { + return c >= -1e-9 && c <= 1 + 1e-9; + }); + } + + var PAGE = toOklab(0x16, 0x1b, 0x22); + var LIGHTEST = 0.5; + + function darkFill(hex) { + var source = toOklab( + parseInt(hex.slice(1, 3), 16), + parseInt(hex.slice(3, 5), 16), + parseInt(hex.slice(5, 7), 16) + ); + var t = 1 - source[0]; + var lab = [ + PAGE[0] + t * (LIGHTEST - PAGE[0]), + PAGE[1] * (1 - t) + source[1], + PAGE[2] * (1 - t) + source[2], + ]; + if (!inGamut(toLinearRgb(lab))) { + var low = 0; + var high = 1; + for (var i = 0; i < 20; ++i) { + var mid = (low + high) / 2; + if (inGamut(toLinearRgb([lab[0], lab[1] * mid, lab[2] * mid]))) { + low = mid; + } else { + high = mid; + } + } + lab = [lab[0], lab[1] * low, lab[2] * low]; + } + return ( + "#" + + toLinearRgb(lab) + .map(function (c) { + c = c <= 0.0031308 ? c * 12.92 : 1.055 * Math.pow(c, 1 / 2.4) - 0.055; + var byte = Math.round(Math.min(Math.max(c, 0), 1) * 255); + return ("0" + byte.toString(16)).slice(-2); + }) + .join("") + ); + } + + /// The blocks a cell's text sits in, the cell itself where it writes its + /// string straight in. + function holdersOf(cell) { + var blocks = cell.querySelectorAll(":scope>x-p"); + return blocks.length > 0 ? Array.prototype.slice.call(blocks) : [cell]; + } + + /// The style the cell shows, as the wire spells it; computed, so what the + /// hoisted classes give it counts. + function styleOf(cell) { + var text = cell.querySelector("x-s") || cell.querySelector("x-p") || cell; + var computed = getComputedStyle(text); + var lines = ""; + for (var node = text; node !== null; node = node.parentElement) { + lines += " " + getComputedStyle(node).textDecorationLine; + if (node === cell) { + break; + } + } + var weight = computed.fontWeight; + var px = parseFloat(computed.fontSize); + // the dark sheet paints `--odr-dark-fill`, and `--odr-fill` keeps the + // colour it stands for + var cellStyle = getComputedStyle(cell); + var stated = cellStyle.getPropertyValue("--odr-fill").trim(); + var align = getComputedStyle(holdersOf(cell)[0]).textAlign; + return { + bold: weight === "bold" || Number(weight) >= 600, + italic: computed.fontStyle === "italic" || computed.fontStyle === "oblique", + underline: lines.indexOf("underline") !== -1, + strikethrough: lines.indexOf("line-through") !== -1, + color: hexOf(computed.color), + size: isNaN(px) ? null : String(Math.round(px * 75) / 100) + "pt", + fill: + stated === "" + ? hexOf(cellStyle.backgroundColor) + : /^#[0-9a-f]{6}$/i.test(stated) + ? stated.toLowerCase() + : null, + align: + align === "center" + ? "center" + : align === "right" || align === "end" + ? "right" + : align === "left" || align === "start" + ? "left" + : null, + }; + } + + /// What the selected cells agree on: a key per property with one value + /// across them, and none where they differ. + function summary() { + var result = {}; + var cells = odr.sheet.selectedCells(); + if (cells.length === 0) { + return result; + } + var styles = cells.map(function (at) { + return styleOf(at.cell); + }); + KEYS.forEach(function (key) { + for (var i = 1; i < styles.length; ++i) { + if (styles[i][key] !== styles[0][key]) { + return; + } + } + result[key] = styles[0][key]; + }); + return result; + } + + var lastReported = null; + + function reportSelection(force) { + if (!odr.editing.isEnabled()) { + return; + } + var shown = summary(); + var key = JSON.stringify(shown); + if (key === lastReported && force !== true) { + return; + } + lastReported = key; + odr.editing.selectionChanged(shown); + } + + table.addEventListener("odr-sheet-select", function () { + reportSelection(false); + }); + + /// The `style` attribute of the cell and of each element in it, which is + /// all a format writes. + function snapshot(cell) { + return [cell] + .concat(Array.prototype.slice.call(cell.querySelectorAll("x-p,x-s"))) + .map(function (node) { + return { node: node, style: node.getAttribute("style") }; + }); + } + + function restore(shot) { + for (var i = 0; i < shot.length; ++i) { + if (shot[i].style === null) { + shot[i].node.removeAttribute("style"); + } else { + shot[i].node.setAttribute("style", shot[i].style); + } + } + } + + /// Writes @p style onto the cell as the renderer would: the cell and every + /// run in it take the text keys, the blocks take the lines and the + /// alignment, and a fill carries the colour the dark sheet paints. + function paintCell(cell, style) { + var texts = [cell].concat( + Array.prototype.slice.call(cell.querySelectorAll("x-p,x-s")) + ); + var runs = cell.querySelectorAll("x-s"); + var holders = holdersOf(cell); + var shown = styleOf(cell); + texts.forEach(function (node) { + if (style.bold !== undefined) { + node.style.fontWeight = style.bold ? "bold" : "normal"; + } + if (style.italic !== undefined) { + node.style.fontStyle = style.italic ? "italic" : "normal"; + } + if (style.color !== undefined) { + node.style.color = style.color; + } + if (style.size !== undefined) { + node.style.fontSize = style.size; + } + }); + if (style.underline !== undefined || style.strikethrough !== undefined) { + var lines = []; + if (style.underline !== undefined ? style.underline : shown.underline) { + lines.push("underline"); + } + if ( + style.strikethrough !== undefined + ? style.strikethrough + : shown.strikethrough + ) { + lines.push("line-through"); + } + holders.forEach(function (node) { + node.style.textDecorationLine = + lines.length === 0 ? "none" : lines.join(" "); + }); + for (var i = 0; i < runs.length; ++i) { + runs[i].style.textDecorationLine = "none"; + } + } + if (style.align !== undefined) { + holders.concat(holders[0] === cell ? [] : [cell]).forEach(function (node) { + node.style.textAlign = style.align; + }); + } + if (style.fill !== undefined) { + var fill = style.fill === null ? "transparent" : style.fill; + cell.style.backgroundColor = fill; + cell.style.setProperty("--odr-fill", fill); + cell.style.setProperty( + "--odr-dark-fill", + style.fill === null ? "transparent" : darkFill(style.fill) + ); + } + } + + var COLOR = /^#[0-9a-f]{6}$/i; + var SIZE = /^[0-9]*\.?[0-9]+(pt|px|in|cm|mm|pc)$/; + + /// Whether @p style is one the op can carry. + function valid(style) { + return Object.keys(style).every(function (key) { + var value = style[key]; + if (TOGGLES.indexOf(key) !== -1) { + return typeof value === "boolean"; + } + if (key === "color") { + return COLOR.test(value); + } + if (key === "fill") { + return value === null || COLOR.test(value); + } + if (key === "size") { + return SIZE.test(value); + } + if (key === "align") { + return value === "left" || value === "center" || value === "right"; + } + return false; + }); + } + + /// States @p style on every selected cell as one undo step. A lock refuses + /// a value, not a style. + function format(style) { + finish(); + if (!odr.editing.isEnabled()) { + return false; + } + if (!odr.editing.isEditable()) { + odr.editing.refuse("readOnly", { sheet: sheet }); + return false; + } + var cells = odr.sheet.selectedCells(); + if ( + cells.length === 0 || + Object.keys(style).length === 0 || + !valid(style) + ) { + odr.editing.refuse("unsupportedEdit", { sheet: sheet }); + return false; + } + var befores = []; + var afters = []; + var ops = []; + var rows = new Set(); + cells.forEach(function (at) { + befores.push(snapshot(at.cell)); + paintCell(at.cell, style); + afters.push(snapshot(at.cell)); + ops.push({ + op: "setCellStyle", + sheet: sheet, + column: at.column, + row: at.row, + style: Object.assign({}, style), + }); + rows.add(at.row); + }); + var reflow = function () { + rows.forEach(function (row) { + odr.sheet.reflow(row); + }); + }; + history.push({ + ops: ops, + undo: function () { + befores.forEach(restore); + reflow(); + }, + redo: function () { + afters.forEach(restore); + reflow(); + }, + }); + undone = []; + reflow(); + odr.editing.changed(); + reportSelection(true); + return true; + } + + /// A mixed selection turns on, as Word does. + function toggle(property) { + if (TOGGLES.indexOf(property) === -1) { + odr.editing.refuse("unsupportedEdit", { sheet: sheet }); + return false; + } + var style = {}; + style[property] = summary()[property] !== true; + return format(style); + } + + var chords = { b: "bold", i: "italic", u: "underline" }; + + /// The formatting chords, where the config gives the scripts the shortcuts. + function chordKey(event) { + if ( + !odr.editing.isEnabled() || + overlay !== null || + event.altKey || + !(event.ctrlKey || event.metaKey) || + typingIn(event.target) + ) { + return; + } + var property = chords[event.key.toLowerCase()]; + if (property === undefined || odr.sheet.selectedCells().length === 0) { + return; + } + toggle(property); + event.stopPropagation(); + event.preventDefault(); + } + + if (odr.takesKeys("shortcuts")) { + document.addEventListener("keydown", chordKey, true); + } + odr.editing.attach({ // A cell nothing can commit must keep no overlay open over it. disable: close, @@ -493,14 +916,14 @@ canRedo: function () { return undone.length > 0; }, - /// Takes the last write back; false where there is none. + /// Takes the last step back; false where there is none. undo: function () { if (history.length === 0) { return false; } var entry = history.pop(); undone.push(entry); - replay(entry, entry.before); + replay(entry.undo); return true; }, redo: function () { @@ -509,9 +932,14 @@ } var entry = undone.pop(); history.push(entry); - replay(entry, entry.op.value); + replay(entry.redo); return true; }, + format: format, + toggle: toggle, + enable: function () { + reportSelection(true); + }, committed: function () { history = []; undone = []; diff --git a/src/odr/internal/html/frontend/spreadsheet-dark.css b/src/odr/internal/html/frontend/spreadsheet-dark.css index 666a5c775..dbbfc585b 100644 --- a/src/odr/internal/html/frontend/spreadsheet-dark.css +++ b/src/odr/internal/html/frontend/spreadsheet-dark.css @@ -8,6 +8,7 @@ --odr-sheet-wash-pinned:rgba(255,255,255,.10); --odr-sheet-wash-ruler:rgba(255,255,255,.12); --odr-sheet-focus:#4c8dff; +--odr-sheet-wash-selected:rgba(76,141,255,.24); --odr-sheet-refused:#f0665b; --odr-sheet-stale:#e3b341; --odr-sheet-raised:#1c2128; diff --git a/src/odr/internal/html/frontend/spreadsheet.css b/src/odr/internal/html/frontend/spreadsheet.css index c852fc5e5..ab4fb3d2e 100644 --- a/src/odr/internal/html/frontend/spreadsheet.css +++ b/src/odr/internal/html/frontend/spreadsheet.css @@ -8,6 +8,7 @@ --odr-sheet-wash-pinned:rgba(0,0,0,.09); --odr-sheet-wash-ruler:rgba(0,0,0,.10); --odr-sheet-focus:#3c78dc; +--odr-sheet-wash-selected:rgba(60,120,220,.16); --odr-sheet-refused:#d1493f; --odr-sheet-stale:#b8860b; --odr-sheet-raised:#ffffff; @@ -38,6 +39,7 @@ body{margin:0;background:var(--odr-sheet-canvas)} .odr-sheet tbody tr.odr-sheet-pinned>*{background-image:linear-gradient(var(--odr-sheet-wash-pinned),var(--odr-sheet-wash-pinned))} .odr-sheet tbody tr:hover>th,.odr-sheet tbody tr.odr-sheet-pinned>th{background-image:linear-gradient(var(--odr-sheet-wash-ruler),var(--odr-sheet-wash-ruler))} .odr-sheet .odr-sheet-pinned-cell{outline:2px solid var(--odr-sheet-focus);outline-offset:-2px} +.odr-sheet tbody tr>td.odr-sheet-selected{background-image:linear-gradient(var(--odr-sheet-wash-selected),var(--odr-sheet-wash-selected))} /* The clipped cell a reader asked to see: out of flow so the row cannot move, sized to the string, over its neighbours. `.odr-sheet-raised-box` is the wrapper the script adds to a cell that writes its string without one. */ diff --git a/src/odr/internal/html/frontend/spreadsheet.js b/src/odr/internal/html/frontend/spreadsheet.js index 2d6a0be8e..4fc506809 100644 --- a/src/odr/internal/html/frontend/spreadsheet.js +++ b/src/odr/internal/html/frontend/spreadsheet.js @@ -223,6 +223,7 @@ function pin(column, row, cell) { lower(); + focus = null; if (pinnedRow !== null) { pinnedRow.classList.remove("odr-sheet-pinned"); } @@ -242,6 +243,113 @@ raise(pinnedCell); } paint(); + paintSelection(); + } + + // The corner a gesture moved, the pin being the other; null while the + // selection is the pin alone. + var focus = null; + var selected = []; + + function lastRow() { + var last = -1; + indexed().rows.forEach(function (entry, row) { + last = Math.max(last, row); + }); + return last; + } + + function lastColumn() { + return table.tHead.rows[0].children.length - 2; + } + + // The rectangle the pin and the focus span, a header's row or column across + // the rendered extent, null where nothing is pinned. + function selectionOf() { + var at = pinnedPosition(); + if (at === null) { + return null; + } + if (at.column === null && at.row === null) { + return null; + } + if (at.row === null) { + return { + columns: [at.column, at.column], + rows: [0, lastRow()], + focus: null, + }; + } + if (at.column === null) { + return { columns: [0, lastColumn()], rows: [at.row, at.row], focus: null }; + } + var to = focus === null ? at : focus; + return { + columns: [Math.min(at.column, to.column), Math.max(at.column, to.column)], + rows: [Math.min(at.row, to.row), Math.max(at.row, to.row)], + focus: { column: to.column, row: to.row }, + }; + } + + // Every cell of the selection once, at the position an op names it by: a + // merge answers with its anchor. + function selectedCells() { + var range = selectionOf(); + var result = []; + if (range === null) { + return result; + } + var seen = new Set(); + for (var row = range.rows[0]; row <= range.rows[1]; ++row) { + for (var column = range.columns[0]; column <= range.columns[1]; ++column) { + var cell = cellAt(column, row); + if (cell === null || seen.has(cell)) { + continue; + } + seen.add(cell); + var position = positionOf(cell); + result.push({ cell: cell, column: position.column, row: position.row }); + } + } + return result; + } + + // The pin's outline marks a single cell, so only a wider selection is washed. + function paintSelection() { + for (var i = 0; i < selected.length; ++i) { + selected[i].classList.remove("odr-sheet-selected"); + } + selected = []; + var cells = selectedCells(); + if (cells.length > 1) { + for (var j = 0; j < cells.length; ++j) { + cells[j].cell.classList.add("odr-sheet-selected"); + selected.push(cells[j].cell); + } + } + table.dispatchEvent(new CustomEvent("odr-sheet-select")); + } + + // Moves the focus to @p position, the pin staying where it is. False where + // no cell is pinned or the sheet holds none there. + function select(position) { + var at = pinnedPosition(); + if (at === null || at.column === null || at.row === null) { + return false; + } + if (cellAt(position.column, position.row) === null) { + return false; + } + if ( + focus !== null && + focus.column === position.column && + focus.row === position.row + ) { + return true; + } + focus = { column: position.column, row: position.row }; + paintSelection(); + return true; } // What is pinned: a cell, or a whole column or row where a header is, the @@ -440,6 +548,9 @@ formulaAt: formulaAt, showValue: showValue, reflow: reflow, + selection: selectionOf, + select: select, + selectedCells: selectedCells, }; table.addEventListener("mouseover", function (event) { @@ -476,6 +587,10 @@ return; } + if (event.shiftKey && cell.tagName === "TD" && select(positionOf(cell))) { + return; + } + // Clicking what is pinned clears it - but `detail` counts the clicks, and // the second of a double click is the reader selecting a word. Clearing // the pin under that flickers the border off again. A cell in an edited @@ -500,6 +615,39 @@ } }); + // A mouse drawn over the cells in the editing mode spans a selection; a + // touch scrolls, and a read-only sheet leaves the text to select. + var dragFrom = null; + table.addEventListener("pointerdown", function (event) { + var cell = event.target.closest("td"); + dragFrom = + event.pointerType === "mouse" && + event.button === 0 && + !event.shiftKey && + cell !== null && + editingEnabled() + ? cell + : null; + }); + table.addEventListener("pointermove", function (event) { + if (dragFrom === null || (event.buttons & 1) === 0) { + dragFrom = null; + return; + } + var cell = event.target.closest("td"); + if (cell === null || (cell === dragFrom && focus === null)) { + return; + } + if (pinnedCell !== dragFrom) { + pin(rulerColumn(dragFrom), dragFrom.parentElement, dragFrom); + } + event.preventDefault(); + select(positionOf(cell)); + }); + document.addEventListener("pointerup", function () { + dragFrom = null; + }); + // The canvas around the sheet included. document.addEventListener("click", function (event) { if (event.target.closest(".odr-sheet") === null) { diff --git a/test/browser/sheet/README.md b/test/browser/sheet/README.md index d631e5ce8..91bc4e064 100644 --- a/test/browser/sheet/README.md +++ b/test/browser/sheet/README.md @@ -11,6 +11,7 @@ open http://localhost:8732/positions.html open http://localhost:8732/sorting.html open http://localhost:8732/editing.html open http://localhost:8732/keyboard.html +open http://localhost:8732/formatting.html ``` `serve` serves `document.css`, `spreadsheet.css`, `editing.js`, @@ -44,6 +45,11 @@ frame on `` with `data-odr-editable` and `data-odr-keyboard`, where - `keyboard.html`: a page whose config took both key classes away. The arrows, Escape, a printable key and the undo chord belong to the host. The commands (`editAt`, `undo`) and the keys of an open editor still work. +- `formatting.html`: a selection and what `odr.editing.format` and `toggle` + do with it. The rectangle from the pin, a shift click, a shift arrow and a + header; a fill with the dark colour `html::dark_fill` computes; the text + keys over a cell that writes its string straight in and one whose run the + file styled; the ops, one undo step per gesture, and the chords. - `sorting.html`: the same questions after the sort control moved every row. Nothing is merged, because a merged sheet gets no sort control. A row is found by its label, not by its position. diff --git a/test/browser/sheet/formatting.html b/test/browser/sheet/formatting.html new file mode 100644 index 000000000..cbd2c14e4 --- /dev/null +++ b/test/browser/sheet/formatting.html @@ -0,0 +1,266 @@ + + + + + sheet formatting checks + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ABCD
1plainrunred2
2mergedx
312
+ +
+ + + + + + + + diff --git a/test/browser/sheet/serve b/test/browser/sheet/serve index 37b56b84c..85e566323 100755 --- a/test/browser/sheet/serve +++ b/test/browser/sheet/serve @@ -18,5 +18,6 @@ serve( "sorting.html", "editing.html", "keyboard.html", + "formatting.html", ), ) diff --git a/test/data.cmake b/test/data.cmake index 91c390746..f7a838593 100644 --- a/test/data.cmake +++ b/test/data.cmake @@ -17,9 +17,9 @@ odr_test_data( odr_test_data( PATH "reference-output/odr-public" URL "https://github.com/opendocument-app/OpenDocument.test.output.git" - REVISION "f0cea38042eabbb5a67168c9ac3527906eb00559") + REVISION "c063f3c11ff51518d2663c3821fe6ee8b6f78743") odr_test_data( PATH "reference-output/odr-private" URL "https://github.com/opendocument-app/OpenDocument.test-private.output.git" - REVISION "0ebf95002a40653b0d8966c9a4b0acd2d56cf11d") + REVISION "a4713fd23c9ca70749b958c591d79300c362aeb1") diff --git a/test/src/html_test.cpp b/test/src/html_test.cpp index 28b502528..66f9619cd 100644 --- a/test/src/html_test.cpp +++ b/test/src/html_test.cpp @@ -445,6 +445,7 @@ TEST(html, a_cell_fill_turns_over_in_the_dark) { const std::string dark = render(path, config); EXPECT_NE(dark.find("background-color:#ffff00;--odr-dark-fill:#"), std::string::npos); + EXPECT_NE(dark.find(";--odr-fill:#ffff00;"), std::string::npos); EXPECT_NE( dark.find( "td{background-color:var(--odr-dark-fill,transparent)!important}"), diff --git a/test/src/internal/html/common_test.cpp b/test/src/internal/html/common_test.cpp index 80f362f6d..9ad6732a0 100644 --- a/test/src/internal/html/common_test.cpp +++ b/test/src/internal/html/common_test.cpp @@ -443,3 +443,15 @@ TEST(html_common, a_global_locale_does_not_reach_the_css) { EXPECT_EQ(ihtml::color(translucent), "rgba(0,0,0,0.501961)"); } + +// `sheet-editing.js` computes the same colours; its check page pins these. +TEST(html_common, a_dark_fill_is_what_the_sheet_editor_computes) { + const auto dark = [](const std::uint32_t rgb) { + return ihtml::color(ihtml::dark_fill(Color::from_rgb(rgb))); + }; + EXPECT_EQ(dark(0xffff00), "#1d1f00"); + EXPECT_EQ(dark(0x0000ff), "#0027ae"); + EXPECT_EQ(dark(0x000000), "#636363"); + EXPECT_EQ(dark(0xff0000), "#680004"); + EXPECT_EQ(dark(0xdce6f2), "#142130"); +}