Auto-sizing cell editor, multi-ref F4, and Alt+Enter line breaks

1. In-cell editor width/height ("nur in der Breite der Zelle angezeigt"):
   the editor now lives as an overlay (like the clip/fill markers) instead
   of inside its <td>, so it can grow past the cell -- width first, up to
   an approximated display-width cap, then wrapping and growing height.
   Sized via a hidden mirror element (same font/padding, real DOM
   measurement -- a <textarea> can't self-size to content with CSS alone).

2. F4 now cycles every reference a real text selection overlaps, each from
   its own lock state, not just the one at the caret -- select a stretch of
   formula spanning several references and F4 adjusts all of them at once,
   same as Excel. cycleReferenceLock(formula, caret) -> cycleReferenceLocks
   (formula, start, end); a collapsed selection (start === end) keeps the
   old single-reference caret behaviour. Wired into both the in-cell editor
   and the formula bar.

3. Alt+Enter inserts a literal newline in the editor instead of committing
   (explicit splice at the caret, not relying on the textarea's native
   Enter handling, which isn't consistently reachable for Alt+Enter). A
   cell holding a newline displays it wrapped (.cell.multiline) instead of
   collapsing to one ellipsised line.

Along the way: a <td>'s own max-height doesn't cap table-row growth in any
browser (a table-layout quirk) -- a wrapped multi-line cell was silently
growing its row past rowH(r), which would have desynced every pixel
position computed from rowH()/rowTop() (the overlays, sticky frozen panes,
the row-resize handle). Fixed by moving cell text into its own block-level
.cell-content child, which *does* respect max-height + overflow:hidden.

Verified interactively against the dev server with Playwright: a long
formula gets a ~300px editor instead of the 104px cell; Alt+Enter produces
a real "\n" that survives commit and displays wrapped, with the row height
unchanged (26px, not the ~40px it grew to before the .cell-content fix);
F4 on a full-formula selection turns every reference absolute in one press
and keeps cycling all of them on repeated presses; existing selection,
copy/paste and fill-handle behaviour all regression-checked clean.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
wkh
2026-09-02 11:04:45 +02:00
co-authored by Claude Sonnet 5
parent 830dcb8eeb
commit cc76ea547f
7 changed files with 297 additions and 76 deletions
+163 -33
View File
@@ -11,7 +11,7 @@
* in-cell editing and per-column resize.
*/
import { computed, nextTick, onBeforeUnmount, ref } from 'vue';
import { numberToCol, cycleReferenceLock } from '../engine';
import { numberToCol, cycleReferenceLocks } from '../engine';
import { useDocument } from '../store/document';
import { DEFAULT_ROW_H } from '../store/types';
import ContextMenu from './ContextMenu.vue';
@@ -23,6 +23,11 @@ const table = computed(() => store.tableById(props.tableId));
const HEAD_W = 44;
const DEFAULT_W = 104;
/** How far the in-cell editor may grow beyond the cell before it wraps/scrolls
* -- an approximation of "the visible display width/height" rather than
* exact viewport math (the canvas can be panned/zoomed independently). */
const EDIT_MAX_W = 560;
const EDIT_MAX_H = 320;
const colWidth = (c: number) => table.value?.colWidths[c] ?? DEFAULT_W;
const rowH = (r: number) => store.rowHeightOf(props.tableId, r);
const headerH = computed(() => table.value?.rowHeight ?? DEFAULT_ROW_H);
@@ -58,6 +63,10 @@ function formatStyle(c: number, r: number): Record<string, string> {
return s;
}
/** A cell holding an explicit line break (Alt+Enter while editing) wraps and
* shows it instead of collapsing to a single ellipsised line. */
const isMultiline = (c: number, r: number) => store.cellInput(props.tableId, c, r).includes('\n');
const root = ref<HTMLElement | null>(null);
// ---- fixed viewport + frozen panes -------------------------------
@@ -99,6 +108,17 @@ function cellStyle(c: number, r: number): Record<string, string> {
if (fr || fc) s.zIndex = fr && fc ? '5' : '3';
return s;
}
/** Caps a cell's rendered text to the row's own height. A `<tr height>` is
* only a *minimum* -- a `<td>`'s own `max-height` doesn't clip it either
* (browsers don't cap table-row growth that way); a plain block-level child
* with `max-height` + `overflow: hidden` does, so `.cell-content` (not the
* `<td>` itself) carries this. Without it a wrapped multi-line cell (see
* `.cell.multiline`) would grow its row past rowH(r), desyncing every pixel
* position computed from rowH()/rowTop() -- the clip/fill overlays, sticky
* frozen panes, the row-resize handle, ... */
function cellContentStyle(r: number): Record<string, string> {
return { maxHeight: rowH(r) + 'px' };
}
function rowHeadStyle(r: number): Record<string, string> {
if (!isFixed.value || r >= frozenRows.value) return {};
return { top: rowTop(r) + 'px', zIndex: '5' };
@@ -557,14 +577,53 @@ function selectAll() {
}
// ---- editing ---------------------------------------------------
// The editor overlays the cell (positioned via colLeft/rowTop, like the
// clip/fill overlays) rather than living inside the <td>, so it's free to
// grow past the cell's own width/height -- clipped only by EDIT_MAX_W/H, not
// by the grid's per-cell `overflow: hidden`.
const editing = ref<{ col: number; row: number } | null>(null);
const editValue = ref('');
const editInput = ref<HTMLInputElement | null>(null);
const editInput = ref<HTMLTextAreaElement | null>(null);
const editMirrorEl = ref<HTMLDivElement | null>(null);
const setEditRef = (el: unknown) => {
editInput.value = (el as HTMLInputElement) ?? null;
editInput.value = (el as HTMLTextAreaElement) ?? null;
};
let committing = false;
const editStyle = computed(() => {
const s = editing.value;
if (!s) return {};
return {
left: colLeft(s.col) + 'px',
top: rowTop(s.row) + 'px',
minWidth: colWidth(s.col) + 'px',
minHeight: rowH(s.row) + 'px',
};
});
const editMirrorStyle = computed(() => {
const s = editing.value;
if (!s) return {};
return {
minWidth: colWidth(s.col) + 'px',
minHeight: rowH(s.row) + 'px',
maxWidth: EDIT_MAX_W + 'px',
};
});
/** Grow the editor to fit its content (width first, then wrap and grow
* height), via a hidden mirror element -- a plain `<textarea>` can't size
* itself to its value with CSS alone. Capped at EDIT_MAX_W/H; beyond that
* it wraps/scrolls internally instead of growing further. */
function resizeEditor() {
const ta = editInput.value;
const mirror = editMirrorEl.value;
if (!ta || !mirror || !editing.value) return;
// a trailing zero-width char keeps a trailing "\n"'s empty last line measurable
mirror.textContent = editValue.value + '\u200b';
ta.style.width = mirror.offsetWidth + 'px';
ta.style.height = Math.min(mirror.offsetHeight, EDIT_MAX_H) + 'px';
}
function beginEdit(c: number, r: number, initial?: string) {
editing.value = { col: c, row: r };
editValue.value = initial ?? store.cellInput(props.tableId, c, r);
@@ -574,6 +633,7 @@ function beginEdit(c: number, r: number, initial?: string) {
el.focus();
if (initial === undefined) el.select();
else el.setSelectionRange(el.value.length, el.value.length);
resizeEditor();
});
}
function commitEdit() {
@@ -590,6 +650,23 @@ function cancelEdit() {
focusRoot();
}
function onEditKeydown(e: KeyboardEvent) {
if (e.key === 'Enter' && e.altKey) {
// insert a literal newline at the caret ourselves rather than relying on
// the textarea's native Enter handling, which some browsers/OSes divert
// Alt+Enter away from (e.g. as a window/fullscreen shortcut)
e.preventDefault();
e.stopPropagation();
const el = editInput.value;
if (!el) return;
const start = el.selectionStart ?? editValue.value.length;
const end = el.selectionEnd ?? start;
editValue.value = editValue.value.slice(0, start) + '\n' + editValue.value.slice(end);
nextTick(() => {
el.setSelectionRange(start + 1, start + 1);
resizeEditor();
});
return;
}
if (e.key === 'Enter') {
e.preventDefault();
commitEdit();
@@ -607,15 +684,21 @@ function onEditKeydown(e: KeyboardEvent) {
}
e.stopPropagation();
}
/** F4: cycle relative -> absolute -> row-locked -> col-locked for the cell
* reference at the caret (Excel/Numbers "Bezug sperren"). */
/** F4: cycle relative -> absolute -> row-locked -> col-locked (Excel/Numbers
* "Bezug sperren") -- for the reference at the caret, or every reference a
* real text selection overlaps at once. */
function toggleReferenceLock() {
const el = editInput.value;
if (!el) return;
const res = cycleReferenceLock(editValue.value, el.selectionStart ?? editValue.value.length);
const start = el.selectionStart ?? editValue.value.length;
const end = el.selectionEnd ?? start;
const res = cycleReferenceLocks(editValue.value, start, end);
if (!res) return;
editValue.value = res.text;
nextTick(() => el.setSelectionRange(res.caret, res.caret));
nextTick(() => {
el.setSelectionRange(res.start, res.end);
resizeEditor();
});
}
function onEditBlur() {
if (!committing && editing.value) commitEdit();
@@ -855,8 +938,6 @@ defineExpose({
:class="{
'is-active': isActive(c - 1, r - 1),
'in-range': inSelection(c - 1, r - 1),
numeric: store.cellIsNumeric(tableId, c - 1, r - 1),
error: store.cellIsError(tableId, c - 1, r - 1),
frozen: (c - 1 < frozenCols) || (r - 1 < frozenRows),
'freeze-edge-right': c - 1 === frozenCols - 1,
'freeze-edge-bottom': r - 1 === frozenRows - 1,
@@ -865,20 +946,19 @@ defineExpose({
@mousedown="onCellMouseDown($event, c - 1, r - 1)"
@dblclick="beginEdit(c - 1, r - 1)"
>
<input
v-if="editing && editing.col === c - 1 && editing.row === r - 1"
:ref="setEditRef"
v-model="editValue"
class="cell-input"
type="text"
spellcheck="false"
@keydown="onEditKeydown"
@blur="onEditBlur"
@mousedown.stop
/>
<template v-else>
{{ store.cellDisplay(tableId, c - 1, r - 1) }}
</template>
<div
class="cell-content"
:class="{
numeric: store.cellIsNumeric(tableId, c - 1, r - 1),
error: store.cellIsError(tableId, c - 1, r - 1),
multiline: isMultiline(c - 1, r - 1),
}"
:style="cellContentStyle(r - 1)"
>
<template v-if="!(editing && editing.col === c - 1 && editing.row === r - 1)">
{{ store.cellDisplay(tableId, c - 1, r - 1) }}
</template>
</div>
</td>
</tr>
</tbody>
@@ -893,6 +973,20 @@ defineExpose({
title="Ziehen zum Ausfüllen/Kopieren"
@mousedown="onFillHandleDown"
/>
<textarea
v-if="editing"
:ref="setEditRef"
v-model="editValue"
class="cell-input"
:style="editStyle"
spellcheck="false"
rows="1"
@keydown="onEditKeydown"
@input="resizeEditor"
@blur="onEditBlur"
@mousedown.stop
/>
<div v-if="editing" ref="editMirrorEl" class="cell-input-mirror" :style="editMirrorStyle" aria-hidden="true" />
</div>
<ContextMenu v-model:open="menu.open" :x="menu.x" :y="menu.y" :items="menuItems" />
@@ -990,24 +1084,35 @@ defineExpose({
z-index: 5;
}
.cell {
padding: 0 7px;
padding: 0; /* all padding lives on .cell-content -- see cellContentStyle() */
border-right: 1px solid var(--grid-line);
border-bottom: 1px solid var(--grid-line);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
background: var(--surface);
vertical-align: middle;
cursor: cell;
position: relative;
}
.cell.numeric {
/* the cell's text, in its own block box: a <td>'s own max-height doesn't cap
table-row growth, but a plain block child's max-height + overflow:hidden
does -- see cellContentStyle(). */
.cell-content {
padding: 0 7px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.cell-content.numeric {
text-align: right;
}
.cell.error {
.cell-content.error {
color: var(--danger);
text-align: right;
}
.cell-content.multiline {
white-space: pre-wrap;
text-overflow: clip;
word-break: break-word;
}
.cell.in-range {
background: var(--accent-weak);
}
@@ -1058,17 +1163,42 @@ defineExpose({
background: var(--accent-weak);
z-index: 8;
}
/* the in-cell editor -- an overlay (like the clip/fill markers above), not a
child of its <td>, so it's free to grow past the cell (request: a longer
formula gets a box sized to its content / the display's max width, not
squeezed into the cell). Sized imperatively by resizeEditor(); left/top and
the starting min-width/min-height come from editStyle. */
.cell-input {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
resize: none;
border: 2px solid var(--accent);
padding: 0 5px;
font: inherit;
line-height: 1.35;
color: var(--text);
background: var(--surface);
outline: none;
z-index: 7;
overflow: auto;
white-space: pre-wrap;
word-break: break-word;
box-sizing: border-box;
z-index: 12;
}
/* hidden clone of .cell-input's text box, measured to size the real one --
same font/line-height/padding/border, capped at EDIT_MAX_W so it wraps at
the same point the real editor will. */
.cell-input-mirror {
position: fixed;
top: -9999px;
left: -9999px;
visibility: hidden;
display: inline-block;
white-space: pre-wrap;
word-break: break-word;
box-sizing: border-box;
padding: 0 5px;
border: 2px solid transparent;
font: inherit;
line-height: 1.35;
}
</style>