@sheetpane/grid 0.4.2 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -13,6 +13,8 @@ import { pluginsFromIds } from '../plugins/host.js';
13
13
  import { COL_MAX, COL_MIN, COL_VIRTUAL_AFTER, DEFAULT_COL_PX, HEAD_H, ROWHEAD_W, ROW_H, ROW_MAX, ROW_MIN, colAt, colWidth, hiddenSpans, isHiddenAt, mergeAt, rowAt, rowHeight, rowTop, sheetGeometry, visibleWindow } from './geometry.js';
14
14
  import { cellCount, clipOf, dataEdge, growForMerges, rangeText, refLabel, spanOf, splitByPanes, totalsOf, usedRange } from './selection.js';
15
15
  import { EXCEL_CSS } from './styles.js';
16
+ import { fillDownTo, fillSeries } from './fill.js';
17
+ import { floatAll } from './float.js';
16
18
  import { parseTarget, readLink, splitSheetRef, writeLink } from './link.js';
17
19
  const ZOOMS = [0.75, 1, 1.25, 1.5];
18
20
  const MAX_BYTES = 50 * 2 ** 20;
@@ -147,6 +149,18 @@ export class SheetpaneExcel extends ElementBase {
147
149
  /** A drag in progress (W-46): drawn by the selection layer each frame; the store hears of it on release. */
148
150
  drag;
149
151
  dragRaf = 0;
152
+ /** The fill handle's drag: the source, the range it now reaches, the pointer, the last cell's value. */
153
+ fill;
154
+ fillRaf = 0;
155
+ /** The in-cell editor (as in Excel): it sits on the active cell; the cell bar's input holds the same draft. */
156
+ ice;
157
+ /** `enter`: started by typing — an arrow commits and moves; `edit`: Enter, F2 or a double-click — arrows move the caret. */
158
+ iceMode;
159
+ /** Where a run of Tabs began: Enter then returns to this column, a row down (Excel). */
160
+ tabCol;
161
+ dropDepth = 0;
162
+ /** The last mouse press on a cell, to tell a double press (edit in place) from two clicks. */
163
+ lastDown;
150
164
  /** The last pointer was a finger: a range shows its two handles. */
151
165
  touchUi = false;
152
166
  /** The range Ctrl/⌘+C copied, outlined until Esc, an edit or the next copy. */
@@ -157,6 +171,8 @@ export class SheetpaneExcel extends ElementBase {
157
171
  totalsKey = [];
158
172
  /** Column widths and row heights the user set, per sheet, px at 100 % — a view setting, never a change (W-47). */
159
173
  _sizes = {};
174
+ /** Per sheet, the least the grid shows after inserts and deletes (SR-GRID 11e): an insert always adds what it says it does. */
175
+ extent = {};
160
176
  sizesVer = 0;
161
177
  /** A header-edge drag in progress (W-47). */
162
178
  rz;
@@ -535,15 +551,22 @@ export class SheetpaneExcel extends ElementBase {
535
551
  if (rsrc && this.rules === undefined)
536
552
  void this.loadRules(rsrc);
537
553
  addEventListener('beforeunload', this.onUnload);
554
+ addEventListener('scroll', this.onViewport, true);
555
+ addEventListener('resize', this.onViewport);
538
556
  addEventListener('hashchange', this.onHash);
539
557
  addEventListener('popstate', this.onHash);
540
558
  this.seenHash = location.hash;
541
559
  this.place();
542
560
  this.schedule();
543
561
  }
562
+ /** The page scrolled or resized: the cards in the top layer follow their anchors (SR-LAW-13). */
563
+ onViewport = () => { if (this.shadowRoot?.querySelector('.sp-float'))
564
+ floatAll(this.shadowRoot); };
544
565
  /** Releases the pane's store. */
545
566
  disconnectedCallback() {
546
567
  removeEventListener('beforeunload', this.onUnload);
568
+ removeEventListener('scroll', this.onViewport, true);
569
+ removeEventListener('resize', this.onViewport);
547
570
  removeEventListener('hashchange', this.onHash);
548
571
  removeEventListener('popstate', this.onHash);
549
572
  clearTimeout(this.linkTimer);
@@ -696,6 +719,7 @@ export class SheetpaneExcel extends ElementBase {
696
719
  }
697
720
  if (e.type === 'load') {
698
721
  this._sizes = {};
722
+ this.extent = {};
699
723
  this.sizesVer++;
700
724
  this.rz = undefined;
701
725
  this.hdrMenu = undefined;
@@ -902,28 +926,30 @@ export class SheetpaneExcel extends ElementBase {
902
926
  if (typeof ResizeObserver !== 'undefined')
903
927
  new ResizeObserver(() => this.fitDock()).observe(q.dock.parentElement);
904
928
  // the grid's focus ring shows for keyboard use only
905
- q.root.addEventListener('keydown', () => q.root.setAttribute('data-kbd', ''), true);
929
+ q.root.addEventListener('keydown', (e) => { if (e.key === 'Tab')
930
+ q.root.setAttribute('data-kbd', '');
931
+ else if (e.key.length === 1 || e.key === 'Enter' || e.key === 'Escape')
932
+ q.root.removeAttribute('data-kbd'); }, true);
906
933
  q.root.addEventListener('pointerdown', () => q.root.removeAttribute('data-kbd'), true);
907
934
  q.dock.addEventListener('keydown', (e) => this.onMenuKey(e, '.moremenu'));
908
935
  q.help.addEventListener('click', () => { this.helpOpen = !this.helpOpen; q.help.setAttribute('aria-expanded', String(this.helpOpen)); this.schedule(); });
936
+ // Esc closes the keyboard help from its button or inside it; the grid's own Esc handles the grid
937
+ q.root.addEventListener('keydown', (e) => {
938
+ if (e.key !== 'Escape' || !this.helpOpen || e.target.closest?.('.grid, .ice'))
939
+ return;
940
+ this.helpOpen = false;
941
+ q.help.setAttribute('aria-expanded', 'false');
942
+ q.help.focus();
943
+ this.schedule();
944
+ });
909
945
  q.revert.addEventListener('click', () => { const sel = s().getSnapshot().selection; s().revert(sel.sheet, sel.ref, this); });
910
946
  // The cell bar edits a draft; Enter, Tab and blur commit it (W-26, events verdict decisions 1–3).
911
947
  const cell = q.cell;
912
- cell.addEventListener('focus', () => {
913
- const sel = s().getSnapshot().selection;
914
- const rich = !!s().runs(sel.sheet, sel.ref); // W-53: the edit writes plain text, the runs' formatting goes
915
- const allowed = fire(this, 'sheetpane-editstart', { sheet: sel.sheet, ref: sel.ref, column: this.column(sel.sheet, sel.ref), value: s().value(sel.sheet, sel.ref), ...(rich ? { richText: true } : {}) }, true);
916
- if (!allowed) {
917
- cell.readOnly = true;
918
- this.editing = undefined;
919
- queueMicrotask(() => q.grid.focus({ preventScroll: true }));
920
- return;
921
- }
922
- cell.readOnly = false;
923
- this.editing = { sheet: sel.sheet, ref: sel.ref, start: cell.value };
924
- this.schedule(); // the docked actions step aside while the draft is edited
925
- });
948
+ cell.addEventListener('focus', () => { if (!this.beginEdit())
949
+ queueMicrotask(() => q.grid.focus({ preventScroll: true })); });
926
950
  cell.addEventListener('input', () => {
951
+ if (this.iceOpen() && this.shadow.activeElement === cell)
952
+ this.ice.value = cell.value; // typed in the bar: the cell's editor follows
927
953
  const ed = this.editing;
928
954
  if (!ed)
929
955
  return;
@@ -931,26 +957,73 @@ export class SheetpaneExcel extends ElementBase {
931
957
  this.checkDraft(ed.sheet, ed.ref, cell.value);
932
958
  });
933
959
  cell.addEventListener('keydown', (e) => {
934
- if (e.key === 'Enter' || e.key === 'Tab') {
960
+ if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
935
961
  e.preventDefault();
936
- this.commitDraft();
937
- this.move(e.key === 'Tab' ? 0 : e.shiftKey ? -1 : 1, e.key === 'Tab' ? (e.shiftKey ? -1 : 1) : 0, false);
938
- q.grid.focus({ preventScroll: true });
962
+ this.fillRangeWithDraft();
963
+ }
964
+ else if (e.key === 'Enter' || e.key === 'Tab') {
965
+ e.preventDefault();
966
+ this.finishEdit(e.key === 'Tab' ? 'tab' : 'enter', e.shiftKey);
939
967
  }
940
968
  else if (e.key === 'Escape') {
941
969
  e.preventDefault();
942
970
  e.stopPropagation();
943
- const ed = this.editing;
944
- if (ed) {
945
- cell.value = ed.start;
946
- this.editing = undefined;
947
- this.clearRuleMsg();
948
- fire(this, 'sheetpane-editcancel', { sheet: ed.sheet, ref: ed.ref });
949
- }
971
+ if (this.iceOpen())
972
+ this.ice.hidden = true;
973
+ this.cancelDraft();
950
974
  q.grid.focus({ preventScroll: true });
951
975
  }
952
976
  });
953
- cell.addEventListener('blur', () => { this.commitDraft(); this.schedule(); });
977
+ cell.addEventListener('blur', (e) => {
978
+ if (e.relatedTarget === this.ice && this.iceOpen())
979
+ return; // the draft moves back into the cell's editor
980
+ if (this.iceOpen()) {
981
+ this.ice.hidden = true;
982
+ this.iceMode = undefined;
983
+ }
984
+ this.commitDraft();
985
+ this.schedule();
986
+ });
987
+ // the in-cell editor: one draft with the cell bar; typing, Enter, F2 or a double-click open it
988
+ const ice = this.ice = document.createElement('input');
989
+ ice.className = 'ice';
990
+ ice.hidden = true;
991
+ ice.autocomplete = 'off';
992
+ ice.spellcheck = false;
993
+ ice.setAttribute('aria-label', 'Cell value');
994
+ ice.dataset.sp = 'cell-editor';
995
+ q.sheet.append(ice);
996
+ for (const ev of ['pointerdown', 'mousedown', 'dblclick', 'click', 'contextmenu'])
997
+ ice.addEventListener(ev, (e) => e.stopPropagation()); // inside the editor a click moves the caret, never the selection
998
+ ice.addEventListener('input', () => { cell.value = ice.value; cell.dispatchEvent(new Event('input')); });
999
+ ice.addEventListener('keydown', (e) => this.onIceKey(e));
1000
+ ice.addEventListener('blur', (e) => {
1001
+ if (ice.hidden || e.relatedTarget === cell)
1002
+ return; // into the cell bar: the same draft carries on there
1003
+ ice.hidden = true;
1004
+ this.iceMode = undefined;
1005
+ this.commitDraft();
1006
+ this.schedule();
1007
+ });
1008
+ // a file dropped anywhere on a loaded sheet opens it — the empty state has its own drop zone
1009
+ const files = (e) => !!e.dataTransfer && Array.from(e.dataTransfer.types).includes('Files') && !q.scroller.hidden;
1010
+ q.root.addEventListener('dragenter', (e) => { if (!files(e))
1011
+ return; e.preventDefault(); this.dropDepth++; this.showDrop(true); });
1012
+ q.root.addEventListener('dragover', (e) => { if (!files(e))
1013
+ return; e.preventDefault(); e.dataTransfer.dropEffect = 'copy'; });
1014
+ q.root.addEventListener('dragleave', (e) => { if (!files(e))
1015
+ return; this.dropDepth = Math.max(0, this.dropDepth - 1); if (!this.dropDepth)
1016
+ this.showDrop(false); });
1017
+ q.root.addEventListener('drop', (e) => {
1018
+ if (!files(e))
1019
+ return;
1020
+ e.preventDefault();
1021
+ this.dropDepth = 0;
1022
+ this.showDrop(false);
1023
+ const f = e.dataTransfer?.files?.[0];
1024
+ if (f)
1025
+ void this.load(f);
1026
+ });
954
1027
  q.grid.addEventListener('paste', (e) => this.onPaste(e));
955
1028
  q.tabs.addEventListener('click', (e) => {
956
1029
  const t = e.target;
@@ -1020,7 +1093,12 @@ export class SheetpaneExcel extends ElementBase {
1020
1093
  // W-46: mouse and pen drag with Pointer Events (captured, so the drag outlives the grid); a finger
1021
1094
  // pans, and its tap arrives as the compatibility mousedown — as do synthetic clicks
1022
1095
  q.scroller.addEventListener('pointerdown', (e) => this.onPointerDown(e));
1023
- q.scroller.addEventListener('pointermove', (e) => { if (this.rz) {
1096
+ q.scroller.addEventListener('pointermove', (e) => { if (this.fill && e.pointerId === this.fill.id) {
1097
+ this.fill.x = e.clientX;
1098
+ this.fill.y = e.clientY;
1099
+ this.queueFill();
1100
+ return;
1101
+ } if (this.rz) {
1024
1102
  this.moveResize(e);
1025
1103
  return;
1026
1104
  } if (this.drag && e.pointerId === this.drag.id) {
@@ -1028,17 +1106,26 @@ export class SheetpaneExcel extends ElementBase {
1028
1106
  this.drag.y = e.clientY;
1029
1107
  this.queueDrag();
1030
1108
  } });
1031
- q.scroller.addEventListener('pointerup', (e) => { if (this.rz && e.pointerId === this.rz.id) {
1109
+ q.scroller.addEventListener('pointerup', (e) => { if (this.fill && e.pointerId === this.fill.id) {
1110
+ this.endFill(true, e.ctrlKey || e.metaKey);
1111
+ return;
1112
+ } if (this.rz && e.pointerId === this.rz.id) {
1032
1113
  this.endResize(true);
1033
1114
  return;
1034
1115
  } if (this.drag && e.pointerId === this.drag.id)
1035
1116
  this.endDrag(true); });
1036
- q.scroller.addEventListener('pointercancel', (e) => { if (this.rz && e.pointerId === this.rz.id) {
1117
+ q.scroller.addEventListener('pointercancel', (e) => { if (this.fill && e.pointerId === this.fill.id) {
1118
+ this.endFill(false, false);
1119
+ return;
1120
+ } if (this.rz && e.pointerId === this.rz.id) {
1037
1121
  this.endResize(false);
1038
1122
  return;
1039
1123
  } if (this.drag && e.pointerId === this.drag.id)
1040
1124
  this.endDrag(false); });
1041
- q.scroller.addEventListener('lostpointercapture', (e) => { if (this.rz && e.pointerId === this.rz.id) {
1125
+ q.scroller.addEventListener('lostpointercapture', (e) => { if (this.fill && e.pointerId === this.fill.id) {
1126
+ this.endFill(true, false);
1127
+ return;
1128
+ } if (this.rz && e.pointerId === this.rz.id) {
1042
1129
  this.endResize(true);
1043
1130
  return;
1044
1131
  } if (this.drag && e.pointerId === this.drag.id)
@@ -1095,10 +1182,17 @@ export class SheetpaneExcel extends ElementBase {
1095
1182
  if (!c || c.dataset.r === undefined)
1096
1183
  return;
1097
1184
  e.preventDefault();
1098
- this.selectCell(Number(c.dataset.r), Number(c.dataset.c), false);
1185
+ // a right-click inside the range keeps it (Copy values, Clear act on all of it), as in Excel
1186
+ const rr = Number(c.dataset.r), cc = Number(c.dataset.c), cur = s().getSnapshot().selection.range, inR = cur ? parseRange(cur) : undefined;
1187
+ if (!(inR && rr >= inR.top && rr <= inR.bottom && cc >= inR.left && cc <= inR.right))
1188
+ this.selectCell(rr, cc, false);
1099
1189
  this.openContextMenu();
1100
1190
  });
1101
1191
  q.scroller.addEventListener('dblclick', (e) => {
1192
+ if (e.target.closest('.fh')) {
1193
+ this.autoFill();
1194
+ return;
1195
+ } // W: fill down beside the data, as Excel
1102
1196
  const edge = e.target.closest('[data-rz-c], [data-rz-r]');
1103
1197
  if (edge) {
1104
1198
  const axis = edge.dataset.rzC !== undefined ? 'col' : 'row';
@@ -1429,8 +1523,8 @@ export class SheetpaneExcel extends ElementBase {
1429
1523
  }
1430
1524
  return out.join('');
1431
1525
  }
1432
- contextMenuHtml(x, y, edit, err) {
1433
- return `<div class="card ctxmenu" role="menu" aria-label="Selection actions" data-sp="menu" style="top:${y}px;left:${x}px">${this.actionItems(edit, err, { flag: true, plugins: true })}</div>`;
1526
+ contextMenuHtml(x, y, edit, err, anchor = 0) {
1527
+ return `<div class="card ctxmenu" role="menu" aria-label="Selection actions" data-sp="menu" data-anchor-h="${anchor}" style="top:${y}px;left:${x}px">${this.actionItems(edit, err, { flag: true, plugins: true })}</div>`;
1434
1528
  }
1435
1529
  /** Arrow keys move through a menu's items; Esc closes it and returns focus. True when handled. */
1436
1530
  onMenuKey(e, sel) {
@@ -1552,7 +1646,10 @@ export class SheetpaneExcel extends ElementBase {
1552
1646
  ['Enter / Tab in a range', 'Next cell of the range (Shift: back)'], ['Drag, Shift + click', 'Select a range'], ['Click a header', 'Select a column, a row or the sheet'],
1553
1647
  ['Drag a header edge', 'Resize the column or row'], ['Double-click a header edge', 'Fit it to the text'], ['Shift + F10 on a whole column or row', 'Fit · width… · reset to the file’s size'],
1554
1648
  ['Home / End', 'Row start / end'], ['Ctrl + Home / End', 'First / last cell'],
1555
- ['Page Up / Down', 'Move a screen'], ['Enter or F2', 'Edit the cell (Edit)'], ['Type', 'Replace the cell (Edit)'], ['Delete', 'Clear the cell or range (Edit)'],
1649
+ ['Page Up / Down', 'Move a screen'], ['Enter, F2 or double-click', 'Edit in the cell (Edit)'], ['Type', 'Replace the cell, in place (Edit)'], ['Delete', 'Clear the cell or range (Edit)'],
1650
+ ['Arrows while typing', 'Commit and move (F2: move the caret instead)'], ['Tab … Enter', 'Across, then back to the first column, a row down'], ['Ctrl/⌘ + Enter', 'Put the entry in every cell of the range (Edit)'],
1651
+ ['Drag the corner square', 'Fill: a series from numbers, copies otherwise; Ctrl/⌘ swaps (Edit)'], ['Double-click the corner square', 'Fill down beside the data (Edit)'], ['Ctrl + D / Ctrl + R', 'Copy down / right (Edit)'],
1652
+ ['Drop a file on the sheet', 'Open it'],
1556
1653
  ['F', 'Flag issue (Edit)'], ['Esc', 'Cancel a drag or copy · close the pop-up'], ['Ctrl/⌘ + Z', 'Undo'], ['Ctrl + Y, ⌘ + Shift + Z', 'Redo'], ['Ctrl/⌘ + C', 'Copy the value or the range'], ['Shift + F10 or Menu', 'Selection actions'],
1557
1654
  ['Ctrl/⌘ + Shift + =', 'Insert rows above (whole columns: columns left) (Edit)'], ['Ctrl/⌘ + −', 'Delete the selected rows or columns (Edit)'],
1558
1655
  ].map(([keys, what]) => ({ keys: keys, what: what }));
@@ -1619,6 +1716,11 @@ export class SheetpaneExcel extends ElementBase {
1619
1716
  } // W-47: a header edge resizes, never selects
1620
1717
  if (t.closest('[data-hid], [data-og], [data-olvl]'))
1621
1718
  return; // W-49: markers and outline buttons act on click
1719
+ if (t.closest('.fh') && e.button === 0 && e.pointerType !== 'touch') {
1720
+ this.startFill(e);
1721
+ return;
1722
+ }
1723
+ this.tabCol = undefined;
1622
1724
  const handle = t.closest('.sh');
1623
1725
  if (handle && e.button === 0) {
1624
1726
  this.startHandleDrag(e, handle.dataset.end === 'start');
@@ -1655,6 +1757,16 @@ export class SheetpaneExcel extends ElementBase {
1655
1757
  return;
1656
1758
  }
1657
1759
  const snap = s.getSnapshot(), before = { ...snap.selection };
1760
+ if (!head) {
1761
+ // a second press on the same cell within 400 ms edits it in place — Pointer Events do not always raise dblclick here
1762
+ const r0 = Number(c.dataset.r), c0 = Number(c.dataset.c), now = performance.now(), last = this.lastDown;
1763
+ this.lastDown = { row: r0, col: c0, t: now };
1764
+ if (last && last.row === r0 && last.col === c0 && now - last.t < 400 && snap.mode === 'edit') {
1765
+ this.lastDown = undefined;
1766
+ this.editCell();
1767
+ return;
1768
+ }
1769
+ }
1658
1770
  if (head)
1659
1771
  this.selectHeader(head, false);
1660
1772
  else
@@ -1788,6 +1900,7 @@ export class SheetpaneExcel extends ElementBase {
1788
1900
  if (!one)
1789
1901
  this.say(r);
1790
1902
  this.drawSelection();
1903
+ this.schedule(); // the name box drops the drag's "1R × 1C" even when the selection did not change
1791
1904
  }
1792
1905
  /** The range a drag spans: corners for cells (grown over merges), whole columns or rows for headers. */
1793
1906
  dragRange(d) {
@@ -2463,6 +2576,12 @@ export class SheetpaneExcel extends ElementBase {
2463
2576
  if (j !== undefined)
2464
2577
  out[j] = v;
2465
2578
  } return out; };
2579
+ // the grid grows (or shrinks) by what was inserted (deleted), even at the edge where the data alone stays the same
2580
+ const was = this.geom?.sheet.name === e.sheet ? this.geom.g : undefined, ext = this.extent[e.sheet] ?? { rows: 0, cols: 0 };
2581
+ if (was) {
2582
+ const n = (rows ? was.rows : was.cols) + (o.op.startsWith('insert') ? o.count : -o.count);
2583
+ this.extent[e.sheet] = rows ? { ...ext, rows: Math.max(0, n) } : { ...ext, cols: Math.max(0, n) };
2584
+ }
2466
2585
  const z = this._sizes[e.sheet];
2467
2586
  if (z)
2468
2587
  this._sizes[e.sheet] = rows ? { ...z, rows: move(z.rows) } : { ...z, cols: move(z.cols) };
@@ -2633,6 +2752,12 @@ export class SheetpaneExcel extends ElementBase {
2633
2752
  e.stopPropagation();
2634
2753
  return;
2635
2754
  }
2755
+ if (this.fill && k === 'Escape') {
2756
+ this.endFill(false, false);
2757
+ e.preventDefault();
2758
+ e.stopPropagation();
2759
+ return;
2760
+ }
2636
2761
  if (this.rz && k === 'Escape') {
2637
2762
  this.endResize(false);
2638
2763
  e.preventDefault();
@@ -2682,6 +2807,8 @@ export class SheetpaneExcel extends ElementBase {
2682
2807
  s.redo(this);
2683
2808
  else if (mod && (k === 'c' || k === 'C'))
2684
2809
  this.copySelection();
2810
+ else if (edit && mod && !e.shiftKey && !e.altKey && (k === 'd' || k === 'D' || k === 'r' || k === 'R'))
2811
+ this.fillKey(k === 'd' || k === 'D');
2685
2812
  else if (edit && mod && !e.altKey && (k === '+' || (e.shiftKey && k === '=')))
2686
2813
  this.structureKey('insert');
2687
2814
  else if (edit && mod && !e.altKey && !e.shiftKey && (k === '-' || k === '_'))
@@ -2892,6 +3019,10 @@ export class SheetpaneExcel extends ElementBase {
2892
3019
  } // W-62: the cell bar's chip says why
2893
3020
  if (cell.hidden)
2894
3021
  return;
3022
+ if (sel && !this.editing)
3023
+ cell.value = this._store.input(sel.sheet, sel.ref); // the draft starts from the cell's own text, even before the next frame
3024
+ if (this.openIce(first))
3025
+ return;
2895
3026
  cell.focus();
2896
3027
  if (first !== undefined) {
2897
3028
  cell.value = first;
@@ -2900,6 +3031,371 @@ export class SheetpaneExcel extends ElementBase {
2900
3031
  else
2901
3032
  cell.select();
2902
3033
  }
3034
+ /** Starts a draft on the active cell (the cell's editor or the cell bar): `sheetpane-editstart` may refuse. */
3035
+ beginEdit() {
3036
+ const s = this._store, cell = this.q.cell, sel = s.getSnapshot().selection;
3037
+ if (this.editing && this.editing.sheet === sel.sheet && this.editing.ref === sel.ref)
3038
+ return true; // the same draft, between the cell and the bar
3039
+ const rich = !!s.runs(sel.sheet, sel.ref); // W-53: the edit writes plain text, the runs' formatting goes
3040
+ const allowed = fire(this, 'sheetpane-editstart', { sheet: sel.sheet, ref: sel.ref, column: this.column(sel.sheet, sel.ref), value: s.value(sel.sheet, sel.ref), ...(rich ? { richText: true } : {}) }, true);
3041
+ if (!allowed) {
3042
+ cell.readOnly = true;
3043
+ this.editing = undefined;
3044
+ return false;
3045
+ }
3046
+ cell.readOnly = false;
3047
+ this.editing = { sheet: sel.sheet, ref: sel.ref, start: cell.value };
3048
+ this.schedule(); // the docked actions step aside while the draft is edited
3049
+ return true;
3050
+ }
3051
+ /** Esc: the draft goes, the cell keeps its value. */
3052
+ cancelDraft() {
3053
+ const ed = this.editing, cell = this.q.cell;
3054
+ if (!ed)
3055
+ return;
3056
+ cell.value = ed.start;
3057
+ this.editing = undefined;
3058
+ this.clearRuleMsg();
3059
+ fire(this, 'sheetpane-editcancel', { sheet: ed.sheet, ref: ed.ref });
3060
+ }
3061
+ iceOpen() { return !!this.ice && !this.ice.hidden; }
3062
+ /** Opens the editor on the active cell: typing replaces the value (`first`); Enter, F2 and a double-click keep it, caret at the end. */
3063
+ openIce(first) {
3064
+ const q = this.q, cell = q.cell, p = parseRef(this._store.getSnapshot().selection.ref);
3065
+ if (!p || !this.ice || !q.grid.querySelector(`.c[data-r="${p.row}"][data-c="${p.col}"]`))
3066
+ return false;
3067
+ if (!this.beginEdit())
3068
+ return true; // refused: nothing opens
3069
+ const ice = this.ice;
3070
+ ice.value = first ?? cell.value;
3071
+ this.iceMode = first !== undefined ? 'enter' : 'edit';
3072
+ ice.hidden = false;
3073
+ this.placeIce();
3074
+ ice.focus({ preventScroll: true });
3075
+ ice.setSelectionRange(ice.value.length, ice.value.length);
3076
+ if (first !== undefined) {
3077
+ cell.value = first;
3078
+ cell.dispatchEvent(new Event('input'));
3079
+ }
3080
+ this.schedule();
3081
+ return true;
3082
+ }
3083
+ /** Keeps the editor on its cell through scrolling and zoom; a selection moved elsewhere (another pane, the errors list) commits it. */
3084
+ placeIce() {
3085
+ const ice = this.ice;
3086
+ if (!ice || ice.hidden)
3087
+ return;
3088
+ const ed = this.editing, sel = this._store?.getSnapshot().selection;
3089
+ if (!ed || !sel || sel.sheet !== ed.sheet || sel.ref !== ed.ref) {
3090
+ ice.hidden = true;
3091
+ this.iceMode = undefined;
3092
+ if (ed)
3093
+ this.commitDraft();
3094
+ return;
3095
+ }
3096
+ const p = parseRef(ed.ref);
3097
+ const el = this.q.grid.querySelector(`.c[data-r="${p.row}"][data-c="${p.col}"]`);
3098
+ if (!el) {
3099
+ ice.style.visibility = 'hidden';
3100
+ return;
3101
+ } // scrolled out of the rendered window: the bar still holds the draft
3102
+ const host = this.q.sheet.getBoundingClientRect(), r = el.getBoundingClientRect(), sc = this.q.scroller.getBoundingClientRect();
3103
+ const cs = getComputedStyle(el);
3104
+ ice.style.cssText = `left:${r.left - host.left}px;top:${r.top - host.top}px;min-width:${r.width}px;height:${r.height}px;max-width:${Math.max(r.width, sc.right - r.left - 12)}px;font-size:${cs.fontSize};font-weight:${cs.fontWeight};font-style:${cs.fontStyle}`;
3105
+ }
3106
+ /** The cell's editor: Enter and Tab commit and move, Esc cancels, an arrow commits when the draft was started by typing. */
3107
+ onIceKey(e) {
3108
+ const k = e.key, q = this.q;
3109
+ e.stopPropagation();
3110
+ if (k === 'Enter' && (e.ctrlKey || e.metaKey)) {
3111
+ e.preventDefault();
3112
+ this.fillRangeWithDraft();
3113
+ return;
3114
+ }
3115
+ if (k === 'Enter' || k === 'Tab') {
3116
+ e.preventDefault();
3117
+ this.finishEdit(k === 'Tab' ? 'tab' : 'enter', e.shiftKey);
3118
+ return;
3119
+ }
3120
+ if (k === 'Escape') {
3121
+ e.preventDefault();
3122
+ this.ice.hidden = true;
3123
+ this.iceMode = undefined;
3124
+ this.cancelDraft();
3125
+ q.grid.focus({ preventScroll: true });
3126
+ this.schedule();
3127
+ return;
3128
+ }
3129
+ if (k === 'F2') {
3130
+ e.preventDefault();
3131
+ this.iceMode = this.iceMode === 'enter' ? 'edit' : 'enter';
3132
+ return;
3133
+ }
3134
+ const arrow = k === 'ArrowDown' ? [1, 0] : k === 'ArrowUp' ? [-1, 0] : k === 'ArrowRight' ? [0, 1] : k === 'ArrowLeft' ? [0, -1] : undefined;
3135
+ if (arrow && this.iceMode === 'enter' && !e.shiftKey && !e.altKey && !e.ctrlKey && !e.metaKey) {
3136
+ e.preventDefault();
3137
+ this.ice.hidden = true;
3138
+ this.iceMode = undefined;
3139
+ this.tabCol = undefined;
3140
+ this.commitDraft();
3141
+ this.move(arrow[0], arrow[1], false);
3142
+ q.grid.focus({ preventScroll: true });
3143
+ }
3144
+ }
3145
+ /** Commit the draft, then move: Enter down (back to where a run of Tabs began), Tab across; inside a range, within it. */
3146
+ finishEdit(how, back) {
3147
+ const s = this._store, g = this.geometry();
3148
+ if (this.iceOpen()) {
3149
+ this.ice.hidden = true;
3150
+ this.iceMode = undefined;
3151
+ }
3152
+ this.commitDraft();
3153
+ const sel = s.getSnapshot().selection, p = parseRef(sel.ref), range = sel.range ? parseRange(sel.range) : undefined;
3154
+ if (range)
3155
+ this.stepInRange(range, how === 'tab', back);
3156
+ else if (how === 'tab') {
3157
+ if (this.tabCol === undefined && !back)
3158
+ this.tabCol = p.col;
3159
+ this.move(0, back ? -1 : 1, false);
3160
+ }
3161
+ else if (this.tabCol !== undefined && !back && g) {
3162
+ const col = this.tabCol;
3163
+ this.tabCol = undefined;
3164
+ this.selectCell(Math.min(g.g.rows - 1, p.row + 1), col, false);
3165
+ }
3166
+ else {
3167
+ this.tabCol = undefined;
3168
+ this.move(back ? -1 : 1, 0, false);
3169
+ }
3170
+ this.q.grid.focus({ preventScroll: true });
3171
+ }
3172
+ /** Ctrl/⌘+Enter: the draft goes into every shown cell of the range, one undo step; the selection stays. */
3173
+ fillRangeWithDraft() {
3174
+ const s = this._store, snap = s.getSnapshot(), sel = snap.selection, ed = this.editing;
3175
+ const range = sel.range ? parseRange(sel.range) : undefined;
3176
+ if (this.iceOpen()) {
3177
+ this.ice.hidden = true;
3178
+ this.iceMode = undefined;
3179
+ }
3180
+ if (!range || !ed) {
3181
+ this.commitDraft();
3182
+ this.q.grid.focus({ preventScroll: true });
3183
+ this.schedule();
3184
+ return;
3185
+ }
3186
+ const v = this.q.cell.value, book = s.book, g = this.geometry().g;
3187
+ this.editing = undefined;
3188
+ this.clearRuleMsg();
3189
+ if (cellCount(range) > 1_000_000) {
3190
+ this.say0('Nothing filled — the range is over 1,000,000 cells');
3191
+ return;
3192
+ }
3193
+ this.lockedRefused = 0;
3194
+ const cells = [];
3195
+ for (let row = range.top; row <= range.bottom; row++)
3196
+ for (let col = range.left; col <= range.right; col++) {
3197
+ if (!this.shownRow(g, row) || !this.shownCol(g, col))
3198
+ continue;
3199
+ const ref = cellRef(row, col);
3200
+ if (this.allowChange(sel.sheet, ref, parseInput(v, s.original(sel.sheet, ref).value, book.source), v))
3201
+ cells.push({ sheet: sel.sheet, ref, input: v });
3202
+ }
3203
+ if (cells.length)
3204
+ s.setCells(cells, { origin: this });
3205
+ this.sayLocked();
3206
+ this.q.grid.focus({ preventScroll: true });
3207
+ this.schedule();
3208
+ }
3209
+ // ── the fill handle (Excel's AutoFill): drag the selection's corner to continue it ──
3210
+ startFill(e) {
3211
+ const s = this._store, sel = s.getSnapshot().selection, g = this.geometry();
3212
+ if (!g || s.getSnapshot().mode !== 'edit')
3213
+ return;
3214
+ const p = parseRef(sel.ref);
3215
+ const src = sel.range ? parseRange(sel.range) : (mergeAt(g.g, p.row, p.col) ?? { top: p.row, left: p.col, bottom: p.row, right: p.col });
3216
+ e.preventDefault();
3217
+ e.stopPropagation();
3218
+ this.q.grid.focus({ preventScroll: true });
3219
+ this.fill = { id: e.pointerId, src, to: src, x: e.clientX, y: e.clientY, tip: '' };
3220
+ try {
3221
+ this.q.scroller.setPointerCapture(e.pointerId);
3222
+ }
3223
+ catch { /* synthetic */ }
3224
+ }
3225
+ queueFill() {
3226
+ if (!this.fillRaf)
3227
+ this.fillRaf = requestAnimationFrame(() => { this.fillRaf = 0; this.fillStep(); });
3228
+ }
3229
+ /** One frame of a fill drag: autoscroll, the axis the pointer has gone furthest along, the preview and the last value. */
3230
+ fillStep() {
3231
+ const f = this.fill, g = this.geometry(), s = this._store;
3232
+ if (!f || !g || !s)
3233
+ return;
3234
+ const sc = this.q.scroller, z = this.zoom, HH = this.headH() * z, RW = this.rowHeadW() * z, rect = sc.getBoundingClientRect();
3235
+ const vx = f.x - rect.left - sc.clientLeft, vy = f.y - rect.top - sc.clientTop;
3236
+ const speed = (into) => Math.min(80, 4 + into * 0.8);
3237
+ let dy = 0, dx = 0;
3238
+ if (vy > sc.clientHeight - 24)
3239
+ dy = speed(vy - (sc.clientHeight - 24));
3240
+ else if (vy < HH + 24 && sc.scrollTop > 0)
3241
+ dy = -speed(HH + 24 - vy);
3242
+ if (vx > sc.clientWidth - 24)
3243
+ dx = speed(vx - (sc.clientWidth - 24));
3244
+ else if (vx < RW + 24 && sc.scrollLeft > 0)
3245
+ dx = -speed(RW + 24 - vx);
3246
+ if (dy)
3247
+ sc.scrollTop += dy;
3248
+ if (dx)
3249
+ sc.scrollLeft += dx;
3250
+ const row = Math.max(0, Math.min(g.g.rows - 1, rowAt(g.g, Math.max(0, (vy + sc.scrollTop - HH) / z))));
3251
+ const col = Math.max(0, Math.min(g.g.cols - 1, colAt(g.g, Math.max(0, (vx + sc.scrollLeft - RW) / z))));
3252
+ const r = f.src;
3253
+ const outV = row > r.bottom ? row - r.bottom : row < r.top ? r.top - row : 0;
3254
+ const outH = col > r.right ? col - r.right : col < r.left ? r.left - col : 0;
3255
+ let to = r;
3256
+ if (outV >= outH && outV > 0)
3257
+ to = row > r.bottom ? { ...r, bottom: row } : { ...r, top: row };
3258
+ else if (outH > 0)
3259
+ to = col > r.right ? { ...r, right: col } : { ...r, left: col };
3260
+ f.to = to;
3261
+ // the value the last cell will hold, shown beside the handle (Excel's fill tooltip)
3262
+ const vertical = to.top !== r.top || to.bottom !== r.bottom, back = vertical ? to.top < r.top : to.left < r.left;
3263
+ const dist = vertical ? (back ? r.top - to.top : to.bottom - r.bottom) : (back ? r.left - to.left : to.right - r.right);
3264
+ const lane = vertical ? r.left : r.top, sheet = s.getSnapshot().selection.sheet;
3265
+ const src = [];
3266
+ if (vertical)
3267
+ for (let rr = r.top; rr <= r.bottom; rr++)
3268
+ src.push(s.input(sheet, cellRef(rr, lane)));
3269
+ else
3270
+ for (let cc = r.left; cc <= r.right; cc++)
3271
+ src.push(s.input(sheet, cellRef(lane, cc)));
3272
+ f.tip = dist > 0 ? (fillSeries(src, dist, { back, unit: vertical ? { dr: 1, dc: 0 } : { dr: 0, dc: 1 } }).at(-1) ?? '') : '';
3273
+ this.selKey = '';
3274
+ this.drawSelection();
3275
+ if (dy || dx)
3276
+ this.queueFill();
3277
+ }
3278
+ endFill(commit, invert) {
3279
+ const f = this.fill;
3280
+ if (!f)
3281
+ return;
3282
+ if (this.fillRaf) {
3283
+ cancelAnimationFrame(this.fillRaf);
3284
+ this.fillRaf = 0;
3285
+ }
3286
+ this.fill = undefined;
3287
+ try {
3288
+ if (this.q.scroller.hasPointerCapture(f.id))
3289
+ this.q.scroller.releasePointerCapture(f.id);
3290
+ }
3291
+ catch { /* synthetic */ }
3292
+ this.selKey = '';
3293
+ if (commit && rangeText(f.to) !== rangeText(f.src))
3294
+ this.applyFill(f.src, f.to, { invert });
3295
+ else
3296
+ this.drawSelection();
3297
+ }
3298
+ /** Writes a fill: per column (or row) of the source, the series beyond it — `beforechange` per cell, hidden cells left alone, one undo step. */
3299
+ applyFill(src, to, o = {}) {
3300
+ const s = this._store, snap = s.getSnapshot(), sheet = snap.selection.sheet, book = s.book, g = this.geometry().g;
3301
+ const vertical = to.top !== src.top || to.bottom !== src.bottom;
3302
+ const back = vertical ? to.top < src.top : to.left < src.left;
3303
+ const cells = [];
3304
+ this.lockedRefused = 0;
3305
+ const [l0, l1] = vertical ? [src.left, src.right] : [src.top, src.bottom];
3306
+ for (let lane = l0; lane <= l1; lane++) {
3307
+ const inputs = [];
3308
+ if (vertical)
3309
+ for (let r = src.top; r <= src.bottom; r++)
3310
+ inputs.push(s.input(sheet, cellRef(r, lane)));
3311
+ else
3312
+ for (let c = src.left; c <= src.right; c++)
3313
+ inputs.push(s.input(sheet, cellRef(lane, c)));
3314
+ const targets = [];
3315
+ if (vertical) {
3316
+ if (back)
3317
+ for (let r = src.top - 1; r >= to.top; r--)
3318
+ targets.push(r);
3319
+ else
3320
+ for (let r = src.bottom + 1; r <= to.bottom; r++)
3321
+ targets.push(r);
3322
+ }
3323
+ else {
3324
+ if (back)
3325
+ for (let c = src.left - 1; c >= to.left; c--)
3326
+ targets.push(c);
3327
+ else
3328
+ for (let c = src.right + 1; c <= to.right; c++)
3329
+ targets.push(c);
3330
+ }
3331
+ const vals = fillSeries(inputs, targets.length, { back, invert: !!o.invert, copy: !!o.copy, unit: vertical ? { dr: 1, dc: 0 } : { dr: 0, dc: 1 } });
3332
+ targets.forEach((t, i) => {
3333
+ const row = vertical ? t : lane, col = vertical ? lane : t;
3334
+ if (!this.shownRow(g, row) || !this.shownCol(g, col))
3335
+ return; // hidden cells are left alone (W-49)
3336
+ const ref = cellRef(row, col), v = vals[i];
3337
+ if (this.allowChange(sheet, ref, parseInput(v, s.original(sheet, ref).value, book.source), v))
3338
+ cells.push({ sheet, ref, input: v });
3339
+ });
3340
+ }
3341
+ if (cells.length)
3342
+ s.setCells(cells, { origin: this });
3343
+ this.sayLocked();
3344
+ if (!o.keep) {
3345
+ const all = { top: Math.min(src.top, to.top), left: Math.min(src.left, to.left), bottom: Math.max(src.bottom, to.bottom), right: Math.max(src.right, to.right) };
3346
+ s.select({ sheet, ref: snap.selection.ref, range: rangeText(all) }, this);
3347
+ this.say(all);
3348
+ }
3349
+ this.selKey = '';
3350
+ this.schedule();
3351
+ }
3352
+ /** Double-click on the handle: fill down as far as the column beside the source has data. */
3353
+ autoFill() {
3354
+ const s = this._store, snap = s.getSnapshot(), g = this.geometry();
3355
+ if (!g || snap.mode !== 'edit')
3356
+ return;
3357
+ const sel = snap.selection, p = parseRef(sel.ref);
3358
+ const src = sel.range ? parseRange(sel.range) : { top: p.row, left: p.col, bottom: p.row, right: p.col };
3359
+ const has = (r, c) => { const v = s.value(sel.sheet, cellRef(r, c)); return v !== null && v !== undefined && v !== ''; };
3360
+ const last = fillDownTo(has, src.bottom, src.left, src.right, g.g.rows);
3361
+ if (last > src.bottom)
3362
+ this.applyFill(src, { ...src, bottom: last });
3363
+ }
3364
+ /** Ctrl+D / Ctrl+R: copy the top row (left column) of the range over the rest — one cell copies from above (left). Formulas move. */
3365
+ fillKey(down) {
3366
+ const s = this._store, sel = s.getSnapshot().selection, p = parseRef(sel.ref);
3367
+ const r = sel.range ? parseRange(sel.range) : { top: p.row, left: p.col, bottom: p.row, right: p.col };
3368
+ if (down) {
3369
+ if (r.top === r.bottom) {
3370
+ if (r.top > 0)
3371
+ this.applyFill({ ...r, top: r.top - 1, bottom: r.top - 1 }, { ...r, top: r.top - 1 }, { copy: true, keep: true });
3372
+ }
3373
+ else
3374
+ this.applyFill({ ...r, bottom: r.top }, r, { copy: true, keep: true });
3375
+ }
3376
+ else if (r.left === r.right) {
3377
+ if (r.left > 0)
3378
+ this.applyFill({ ...r, left: r.left - 1, right: r.left - 1 }, { ...r, left: r.left - 1 }, { copy: true, keep: true });
3379
+ }
3380
+ else
3381
+ this.applyFill({ ...r, right: r.left }, r, { copy: true, keep: true });
3382
+ }
3383
+ /** A file dragged over the loaded sheet: what dropping it does. */
3384
+ showDrop(on) {
3385
+ let ov = this.q.body.querySelector('.dropov');
3386
+ if (!on) {
3387
+ ov?.remove();
3388
+ return;
3389
+ }
3390
+ if (!ov) {
3391
+ ov = document.createElement('div');
3392
+ ov.className = 'dropov';
3393
+ ov.setAttribute('role', 'status');
3394
+ this.q.body.append(ov);
3395
+ }
3396
+ const n = this._store?.getSnapshot().changes.size ?? 0;
3397
+ ov.innerHTML = `${ICON.upload}<span>Drop to open the file here</span><small>.xlsx or .csv — read in this tab, nothing is uploaded${n ? ` · it replaces this workbook and its ${n} change${n === 1 ? '' : 's'} not yet downloaded` : ''}</small>`;
3398
+ }
2903
3399
  /** Flag issue (`F`): the store decides which form opens (contract decision 4). */
2904
3400
  flag() {
2905
3401
  const s = this._store, sel = s.getSnapshot().selection;
@@ -3202,7 +3698,7 @@ export class SheetpaneExcel extends ElementBase {
3202
3698
  // the file's hiding, less what this element reveals for viewing (W-49)
3203
3699
  const vh = this.viewHidden[sheet.name];
3204
3700
  const keep = (list, shown, view) => new Set([...(rev?.all ? [] : list.filter((i) => !shown?.has(i))), ...(view ?? [])]);
3205
- this.geom = { sheet, g: sheetGeometry(sheet, merges, this._sizes[sheet.name], { rows: keep(hid.rows, rev?.rows, vh?.rows), cols: keep(hid.cols, rev?.cols, vh?.cols) }) };
3701
+ this.geom = { sheet, g: sheetGeometry(sheet, merges, this._sizes[sheet.name], { rows: keep(hid.rows, rev?.rows, vh?.rows), cols: keep(hid.cols, rev?.cols, vh?.cols) }, this.extent[sheet.name]) };
3206
3702
  this.geomMerges = mk;
3207
3703
  }
3208
3704
  return this.geom;
@@ -3287,7 +3783,7 @@ export class SheetpaneExcel extends ElementBase {
3287
3783
  q.val.hidden = editable;
3288
3784
  q.cellname.textContent = `Value of ${sel.ref}`;
3289
3785
  const curText = s.input(sel.sheet, sel.ref);
3290
- if (this.shadow.activeElement !== cell)
3786
+ if (this.shadow.activeElement !== cell && !this.iceOpen())
3291
3787
  cell.value = curText;
3292
3788
  q.val.textContent = s.text(sel.sheet, sel.ref) || '(empty)';
3293
3789
  q.merge.hidden = !merge;
@@ -3337,7 +3833,7 @@ export class SheetpaneExcel extends ElementBase {
3337
3833
  q.lockmsg.textContent = `🔒 ${LOCKED_MESSAGE}`;
3338
3834
  q.plugCellbar.innerHTML = this.controlsHtml(controlsFor(s.plugins, 'cellbar', snap.mode), 'btn btn-g');
3339
3835
  // docked selection actions (W-31): Flag issue · selbar plugin controls · ⋯ — hidden while typing or composing
3340
- const typing = !!this.editing && this.shadow.activeElement === cell;
3836
+ const typing = !!this.editing && (this.shadow.activeElement === cell || this.iceOpen());
3341
3837
  q.dock.hidden = typing || composing;
3342
3838
  if (!q.dock.hidden) {
3343
3839
  const flagBtn = edit && !err && s.flagging ? `<button type="button" class="btn btn-g" data-act="flag" data-sp="flag" title="Flag issue (F)" aria-keyshortcuts="F">${ICON.flag}Flag issue<span class="kbd" aria-hidden="true">F</span></button>` : '';
@@ -3438,6 +3934,7 @@ export class SheetpaneExcel extends ElementBase {
3438
3934
  q.status.dataset.html = statusHtml;
3439
3935
  }
3440
3936
  this.renderGrid(g.g, sheet, snap.mode);
3937
+ floatAll(this.shadowRoot); // every menu and card on top, inside the viewport (SR-LAW-13)
3441
3938
  }
3442
3939
  /** Count · Sum · Average of a range of two or more cells (W-46 DoD 8), cached until the range or the cells change. */
3443
3940
  totalsFor(g) {
@@ -3680,6 +4177,7 @@ export class SheetpaneExcel extends ElementBase {
3680
4177
  this.ariaOn.clear(); // new nodes: the layer's bands and aria-selected go on again
3681
4178
  }
3682
4179
  grid.setAttribute('aria-activedescendant', `c${selP.row}_${selP.col}`);
4180
+ this.placeIce();
3683
4181
  this.drawSelection();
3684
4182
  this.renderOverlay(g, sheet, z, RW, HH, Y, H, X, W, win);
3685
4183
  }
@@ -3706,7 +4204,8 @@ export class SheetpaneExcel extends ElementBase {
3706
4204
  const copied = this.copied && this.copied.sheet === g.sheet.name ? this.copied.range : undefined;
3707
4205
  if (d)
3708
4206
  q.ref.textContent = `${range.bottom - range.top + 1}R × ${range.right - range.left + 1}C`;
3709
- const key = [g.sheet.name, z, fr, fc, gg.rows, gg.cols, rangeText(range), rangeText(am), copied ? rangeText(copied) : '', this.touchUi && !d && multi ? 1 : 0].join('|');
4207
+ const f = this.fill, handle = snap.mode === 'edit' && !d && !this.touchUi && !this.iceOpen() && !snap.composing;
4208
+ const key = [g.sheet.name, z, fr, fc, gg.rows, gg.cols, rangeText(range), rangeText(am), copied ? rangeText(copied) : '', this.touchUi && !d && multi ? 1 : 0, handle ? 1 : 0, f ? `${rangeText(f.to)}~${f.tip}` : ''].join('|');
3710
4209
  if (key === this.selKey)
3711
4210
  return;
3712
4211
  this.selKey = key;
@@ -3753,6 +4252,16 @@ export class SheetpaneExcel extends ElementBase {
3753
4252
  layer += `<div class="sh" data-end="${end}" style="position:relative;z-index:3;margin:${HH + Y(row) - 12}px 0 0 ${RW + X(col) - 12}px"></div>`;
3754
4253
  }
3755
4254
  }
4255
+ // the fill handle at the corner (Edit), and while it is dragged, the dashed reach and the last cell's value
4256
+ if (f && rangeText(f.to) !== rangeText(f.src))
4257
+ for (const p of splitByPanes(f.to, fr, fc))
4258
+ put(p.pane, p.top, p.bottom, p.left, p.right, 'sfp', (row) => borders(row === undefined ? p.edges : { ...p.edges, top: p.edges.top && row === p.top, bottom: p.edges.bottom && row === p.bottom }));
4259
+ const reach = f ? f.to : range;
4260
+ if (handle && reach.bottom >= fr && reach.right >= fc) {
4261
+ layer += `<div class="fh" title="Drag to fill — double-click to fill down, Ctrl/⌘ to copy instead" style="position:relative;z-index:4;margin:${HH + Y(reach.bottom + 1) - 4}px 0 0 ${RW + X(reach.right + 1) - 4}px"></div>`;
4262
+ if (f?.tip)
4263
+ layer += `<div class="ftip" style="position:relative;z-index:5;margin:${HH + Y(reach.bottom + 1) + 6}px 0 0 ${RW + X(reach.right + 1) + 6}px">${esc(f.tip)}</div>`;
4264
+ }
3756
4265
  q.sellayer.innerHTML = layer;
3757
4266
  for (const b of Array.from(q.frozen.querySelectorAll('.sband')))
3758
4267
  b.remove();
@@ -3850,7 +4359,7 @@ export class SheetpaneExcel extends ElementBase {
3850
4359
  }
3851
4360
  else if (this.menu && this.menu.sheet === sheet.name && this.menu.ref === sel.ref) {
3852
4361
  const m = this.menu;
3853
- card = `<div class="card menu" role="listbox" aria-label="Choices" style="top:${p.y + H(selP.row)}px;left:${p.x}px;min-width:${Math.max(cw, 140)}px;max-height:260px;overflow:auto">${m.options.map((o) => `<button type="button" role="option" class="btn btn-g" style="justify-content:flex-start;width:100%;border-radius:0" data-act="pick-option" data-v="${esc(o)}">${esc(o) || '(empty)'}</button>`).join('')}</div>`;
4362
+ card = `<div class="card menu" role="listbox" aria-label="Choices" data-anchor-h="${H(selP.row)}" style="top:${p.y + H(selP.row)}px;left:${p.x}px;min-width:${Math.max(cw, 140)}px;max-height:260px;overflow:auto">${m.options.map((o) => `<button type="button" role="option" class="btn btn-g" style="justify-content:flex-start;width:100%;border-radius:0" data-act="pick-option" data-v="${esc(o)}">${esc(o) || '(empty)'}</button>`).join('')}</div>`;
3854
4363
  }
3855
4364
  else if (err && s.flagging) {
3856
4365
  card = this.calloutHtml(err, sideLeft(300), sideTop(260));
@@ -3872,7 +4381,7 @@ export class SheetpaneExcel extends ElementBase {
3872
4381
  // the context menu (right-click, Shift+F10, the Menu key) sits just under the cell, above any card
3873
4382
  if (this.ctxOpen && sel.sheet === sheet.name) {
3874
4383
  const mg = mergeAt(g, selP.row, selP.col);
3875
- card = this.contextMenuHtml(p.x, (mg ? HH + Y(mg.bottom + 1) : p.y + H(selP.row)) + 2, edit, err);
4384
+ card = this.contextMenuHtml(p.x, (mg ? HH + Y(mg.bottom + 1) : p.y + H(selP.row)) + 2, edit, err, (mg ? Y(mg.bottom + 1) - Y(mg.top) : H(selP.row)) + 4);
3876
4385
  }
3877
4386
  if (!card && this.helpOpen) {
3878
4387
  const sc = this.q.scroller;
@@ -3919,6 +4428,7 @@ export class SheetpaneExcel extends ElementBase {
3919
4428
  this.detailRegion.sync(cardEl, card.startsWith('<div class="card callout"') ? err : undefined); // the host's region in the card (W-68)
3920
4429
  this.renderHighlight(g, sheet, RW, HH, Y, X, ov, fixed);
3921
4430
  this.renderPluginOverlays(sheet, z, RW, HH, ov);
4431
+ floatAll(this.shadowRoot);
3922
4432
  }
3923
4433
  /**
3924
4434
  * The host's highlight (W-56): one box per range on this sheet, 2 px outside the cells so the