Add in-app help, formula reference outlines, underline and auto-closing

- Help dialog (?, F1, Datei > Hilfe): user topics, all shortcuts and the
  formula functions with one search over everything. Topics and shortcuts
  are plain data in data/help.ts; the function list comes from
  codeReference.ts so new built-ins appear by themselves.
- While a formula is edited, the grid outlines each referenced cell/range
  in the colour of its position in the text, and the outline can be
  dragged to move or resize the reference, as in Excel. Point-mode picks
  (click, drag, arrow keys) are outlined too, so keyboard picking is no
  longer blind.
- Underline as a cell format, plus Mod+Shift+B/I/U toggles that switch on
  unless every selected cell already has the format.
- A formula committed with open parentheses or an open text literal gets
  the missing closers appended; anything that still doesn't parse is left
  alone so the usual error shows instead of a guess.
- Cells and tables with a fill but no text colour get dark or light text
  by WCAG contrast instead of the theme colour (no black on black).
This commit is contained in:
wkh
2026-10-24 00:38:01 +02:00
parent 0368e96e0c
commit 0fca61eb65
19 changed files with 1420 additions and 16 deletions
+21
View File
@@ -4,6 +4,7 @@ import { useDocument } from '../store/document';
import { useTheme } from '../composables/useTheme';
import { useToasts } from '../composables/useToasts';
import BaseModal from './BaseModal.vue';
import HelpModal from './HelpModal.vue';
import CustomFunctionsModal from './CustomFunctionsModal.vue';
import NamedRangesModal from './NamedRangesModal.vue';
import MacrosModal from './MacrosModal.vue';
@@ -120,6 +121,17 @@ const uploadOpen = ref(false);
const loginOpen = ref(false);
const loginReason = ref<string | undefined>();
const accountMenuOpen = ref(false);
const helpOpen = ref(false);
// F1 opens the help from anywhere (also out of a cell editor -- the browser's
// own F1 help is what preventDefault keeps away)
function onHelpKey(e: KeyboardEvent) {
if (e.key !== 'F1') return;
e.preventDefault();
helpOpen.value = true;
}
onMounted(() => window.addEventListener('keydown', onHelpKey));
onBeforeUnmount(() => window.removeEventListener('keydown', onHelpKey));
function openLogin(reason?: string) {
loginReason.value = reason;
@@ -470,10 +482,13 @@ async function onSample() {
<li><button @click="runXlsxExport">Export · Arbeitsmappe (.xlsx)</button></li>
<li><button @click="exportSelectedChartPng">Export · gewähltes Diagramm (.png)</button></li>
<li class="sep" />
<li><button @click="helpOpen = true">Hilfe … (F1)</button></li>
<li><button @click="aboutOpen = true">Über Numolus …</button></li>
</ul>
</div>
<button class="ibtn help" title="Hilfe (F1)" aria-label="Hilfe" @click="helpOpen = true">?</button>
<button
class="ibtn"
:title="`Theme: ${themeLabel} (klicken zum Wechseln)`"
@@ -588,6 +603,7 @@ async function onSample() {
/>
<LoginModal :open="loginOpen" :reason="loginReason" @close="loginOpen = false" />
<UploadModal :open="uploadOpen" @close="uploadOpen = false" />
<HelpModal :open="helpOpen" @close="helpOpen = false" />
<!-- Über / Impressum -->
<BaseModal :open="aboutOpen" title="Über Numolus" @close="aboutOpen = false">
@@ -866,6 +882,11 @@ async function onSample() {
cursor: pointer;
transition: background-color 0.12s ease, color 0.12s ease;
}
/* the help button is a plain "?" rather than an icon */
.ibtn.help {
font-size: 15px;
font-weight: 700;
}
.tgroup.accent .ibtn {
color: var(--accent);
}
+5 -1
View File
@@ -1,7 +1,7 @@
<script setup lang="ts">
import { computed, nextTick, ref, watch } from 'vue';
import { useDocument } from '../store/document';
import { cycleReferenceLocks } from '../engine';
import { cycleReferenceLocks, completeFormula } from '../engine';
import { useFormulaAutocomplete } from '../composables/useFormulaAutocomplete';
import { useKeyboardPointMode } from '../composables/useKeyboardPointMode';
import { useToasts } from '../composables/useToasts';
@@ -113,6 +113,10 @@ function backToGrid() {
function commit() {
const sel = store.doc.selection;
// a forgotten closing ")" is completed rather than stored as an error; in
// the draft too, since the sync watch above skips changes made while the
// field still has focus (Enter commits before it blurs)
draft.value = completeFormula(draft.value);
if (sel) store.setCellInput(sel.tableId, sel.anchor.col, sel.anchor.row, draft.value);
}
/** Lets the store insert a reference into this field when the user clicks a
+410
View File
@@ -0,0 +1,410 @@
<script setup lang="ts">
/**
* In-app help: topics and keyboard shortcuts from data/help.ts, plus the
* formula functions and operators from data/codeReference.ts (the same
* entries the macro/function editors' reference shows, so the list can't
* drift from the engine). One search box runs over all of it. Opened from
* the toolbar's "?" button, Datei › Hilfe or F1 (see AppToolbar.vue).
*/
import { computed, nextTick, ref, watch } from 'vue';
import BaseModal from './BaseModal.vue';
import { HELP_TOPICS, SHORTCUT_GROUPS, shortcutKeys, type KeyPlatform, type Shortcut } from '../data/help';
import { matchesQuery, referenceFor } from '../data/codeReference';
const props = defineProps<{ open: boolean }>();
const emit = defineEmits<{ (e: 'close'): void }>();
type Page = string; // a topic id, 'shortcuts' or 'functions'
const page = ref<Page>(HELP_TOPICS[0].id);
const query = ref('');
const searchEl = ref<HTMLInputElement | null>(null);
watch(
() => props.open,
(o) => {
if (!o) return;
query.value = '';
nextTick(() => searchEl.value?.focus());
},
);
const isMac = typeof navigator !== 'undefined' && /Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent);
/** This device's platform: its column in the shortcut tables is highlighted,
* and the topics' running text names its keys. */
const platform: KeyPlatform = isMac ? 'mac' : 'pc';
const keys = (k: string) => shortcutKeys(k, platform);
/** Both columns are always shown -- the help is also read to explain the
* app to someone on the other system. */
const COLUMNS: { platform: KeyPlatform; label: string }[] = [
{ platform: 'pc', label: 'Windows / Linux' },
{ platform: 'mac', label: 'Mac' },
];
/** `**fett**` and `` `Code` `` -> HTML; everything else escaped first, so
* nothing in the data can inject markup. */
function rich(text: string): string {
const esc = keys(text).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
return esc.replace(/`([^`]+)`/g, '<code>$1</code>').replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
}
const FORMULA_REF = referenceFor('expression').filter(
(e) => e.category === 'Formelfunktionen' || e.category === 'Operatoren',
);
const topic = computed(() => HELP_TOPICS.find((t) => t.id === page.value) ?? null);
const plain = (s: string) => s.replace(/[`*]/g, '').toLowerCase();
const hit = (s: string, words: string[]) => words.every((w) => plain(keys(s)).includes(w));
/** Search across topics, shortcuts and functions -- every word must occur. */
const results = computed(() => {
const words = query.value.toLowerCase().split(/\s+/).filter(Boolean);
if (!words.length) return null;
const items = HELP_TOPICS.flatMap((t) =>
t.sections.flatMap((s) =>
s.items
.filter((i) => hit(`${t.title} ${s.heading ?? ''} ${i}`, words))
.map((text) => ({ where: s.heading ? `${t.title} › ${s.heading}` : t.title, text })),
),
);
const shortcuts = SHORTCUT_GROUPS.flatMap((g) =>
g.shortcuts.filter((s: Shortcut) =>
hit(`${g.title} ${shortcutKeys(s, 'pc')} ${shortcutKeys(s, 'mac')} ${s.description}`, words),
),
);
const functions = FORMULA_REF.filter((e) => matchesQuery(e, query.value));
return { items, shortcuts, functions, empty: !items.length && !shortcuts.length && !functions.length };
});
/** Escape in the search box first clears a search, only then closes (an
* empty box would otherwise leave it to BaseModal's own Escape handling). */
function onSearchEscape() {
if (query.value) query.value = '';
else emit('close');
}
function go(p: Page) {
page.value = p;
query.value = '';
}
</script>
<template>
<BaseModal :open="open" title="Hilfe" wide @close="emit('close')">
<div class="help-page">
<input
ref="searchEl"
v-model="query"
class="search"
type="search"
placeholder="Hilfe durchsuchen, z. B. „Bezug“, „fett“, „SUM“ …"
aria-label="Hilfe durchsuchen"
@keydown.esc.prevent.stop="onSearchEscape"
/>
<div class="cols">
<nav class="nav">
<button
v-for="t in HELP_TOPICS"
:key="t.id"
:class="{ on: !results && page === t.id }"
@click="go(t.id)"
>
{{ t.title }}
</button>
<span class="sep" />
<button :class="{ on: !results && page === 'shortcuts' }" @click="go('shortcuts')">Tastenkürzel</button>
<button :class="{ on: !results && page === 'functions' }" @click="go('functions')">Funktionen</button>
</nav>
<div class="content">
<!-- search results -->
<template v-if="results">
<p v-if="results.empty" class="dim">Nichts gefunden.</p>
<section v-if="results.items.length">
<h3>Themen</h3>
<ul class="items">
<li v-for="(r, i) in results.items" :key="i">
<span class="where">{{ r.where }}</span>
<!-- eslint-disable-next-line vue/no-v-html -- escaped in rich() -->
<span v-html="rich(r.text)" />
</li>
</ul>
</section>
<section v-if="results.shortcuts.length">
<h3>Tastenkürzel</h3>
<table class="keys">
<thead>
<tr>
<th>Aktion</th>
<th v-for="c in COLUMNS" :key="c.platform" :class="{ here: c.platform === platform }">{{ c.label }}</th>
</tr>
</thead>
<tr v-for="s in results.shortcuts" :key="s.keys + s.description">
<td>{{ s.description }}</td>
<td v-for="c in COLUMNS" :key="c.platform" :class="{ here: c.platform === platform }" :data-label="c.label">
<kbd>{{ shortcutKeys(s, c.platform) }}</kbd>
</td>
</tr>
</table>
</section>
<section v-if="results.functions.length">
<h3>Funktionen</h3>
<dl class="fns">
<template v-for="f in results.functions" :key="f.name">
<dt><code>{{ f.syntax }}</code></dt>
<dd>{{ f.description }}</dd>
</template>
</dl>
</section>
</template>
<!-- a topic -->
<template v-else-if="topic">
<h3 class="title">{{ topic.title }}</h3>
<section v-for="(s, i) in topic.sections" :key="i">
<h4 v-if="s.heading">{{ s.heading }}</h4>
<ul class="items">
<!-- eslint-disable-next-line vue/no-v-html -- escaped in rich() -->
<li v-for="(item, j) in s.items" :key="j" v-html="rich(item)" />
</ul>
</section>
</template>
<template v-else-if="page === 'shortcuts'">
<h3 class="title">Tastenkürzel</h3>
<p class="dim">
Windows und Linux verwenden dieselben Tasten. Auf dem Mac steht ⌘ für Strg und ⌥ für Alt. Viele
Mac-Tastaturen haben keine Tasten Pos1, Ende, Bild auf/ab und Entf, dort hilft fn. Die
Funktionstasten F1 bis F4 brauchen je nach Einstellung ebenfalls fn.
</p>
<section v-for="g in SHORTCUT_GROUPS" :key="g.title">
<h4>{{ g.title }}</h4>
<table class="keys">
<thead>
<tr>
<th>Aktion</th>
<th v-for="c in COLUMNS" :key="c.platform" :class="{ here: c.platform === platform }">{{ c.label }}</th>
</tr>
</thead>
<tr v-for="s in g.shortcuts" :key="s.keys + s.description">
<td>{{ s.description }}</td>
<td v-for="c in COLUMNS" :key="c.platform" :class="{ here: c.platform === platform }" :data-label="c.label">
<kbd>{{ shortcutKeys(s, c.platform) }}</kbd>
</td>
</tr>
</table>
</section>
</template>
<template v-else-if="page === 'functions'">
<h3 class="title">Funktionen</h3>
<p class="dim">
Eingebaute Funktionen und Rechenzeichen für Formeln. Argumente werden mit <code>;</code> getrennt,
ein <code>?</code> kennzeichnet ein optionales Argument.
</p>
<dl class="fns">
<template v-for="f in FORMULA_REF" :key="f.name">
<dt><code>{{ f.syntax }}</code></dt>
<dd>
{{ f.description }}
<span v-if="f.example" class="example">Beispiel: <code>{{ f.example }}</code></span>
</dd>
</template>
</dl>
</template>
</div>
</div>
</div>
<template #footer>
<button class="primary" @click="emit('close')">Schließen</button>
</template>
</BaseModal>
</template>
<style scoped>
.help-page {
display: flex;
flex-direction: column;
gap: 12px;
/* fixed height: switching pages or searching must not resize (and so
re-centre) the dialog; the content column scrolls instead */
height: min(600px, calc(100dvh - 190px));
}
.search {
width: 100%;
box-sizing: border-box;
padding: 7px 10px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--surface);
color: var(--text);
font: inherit;
}
.cols {
flex: 1;
min-height: 0;
display: flex;
gap: 18px;
}
.nav {
flex: none;
width: 170px;
display: flex;
flex-direction: column;
gap: 2px;
overflow-y: auto;
}
.nav button {
text-align: left;
border: 0;
background: transparent;
color: var(--text);
padding: 6px 10px;
border-radius: var(--radius-sm);
font: inherit;
cursor: pointer;
}
.nav button:hover {
background: var(--surface-2);
}
.nav button.on {
background: var(--accent-weak);
color: var(--accent);
font-weight: 600;
}
.nav .sep {
height: 1px;
background: var(--border);
margin: 6px 0;
}
.content {
flex: 1;
min-width: 0;
overflow-y: auto;
padding-right: 4px;
line-height: 1.5;
}
.title {
margin: 0 0 8px;
font-size: 15px;
}
h3 {
font-size: 13px;
margin: 0 0 6px;
}
h4 {
font-size: 12.5px;
margin: 14px 0 4px;
color: var(--text-dim);
}
section + section h3 {
margin-top: 16px;
}
.items {
margin: 0;
padding-left: 18px;
}
.items li + li {
margin-top: 4px;
}
.where {
display: block;
font-size: 11px;
color: var(--text-dim);
}
.keys {
border-collapse: collapse;
width: 100%;
}
.keys th,
.keys td {
padding: 4px 8px 4px 0;
vertical-align: top;
text-align: left;
border-bottom: 1px solid var(--border);
}
.keys th {
font-size: 11.5px;
font-weight: 600;
color: var(--text-dim);
}
.keys th.here {
color: var(--accent);
}
.keys td:first-child {
width: 36%;
}
.keys td.here kbd {
border-color: var(--accent);
}
kbd {
font: 12px ui-monospace, SFMono-Regular, Menlo, monospace;
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: 4px;
padding: 1px 5px;
}
.fns {
margin: 0;
}
.fns dt {
margin-top: 10px;
}
.fns dd {
margin: 2px 0 0 0;
color: var(--text-dim);
}
.example {
display: block;
}
code {
font: 12px ui-monospace, SFMono-Regular, Menlo, monospace;
background: var(--surface-2);
border-radius: 3px;
padding: 0 3px;
}
.dim {
color: var(--text-dim);
}
/* phones: navigation above the content */
@media (max-width: 640px) {
.cols {
flex-direction: column;
}
.nav {
flex: none;
width: 100%;
flex-direction: row;
flex-wrap: wrap;
}
.nav .sep {
display: none;
}
/* no room for three columns: each shortcut becomes a small block, the
action on top, the two key columns below it with their label */
.keys thead {
display: none;
}
.keys tr {
display: block;
padding: 6px 0;
border-bottom: 1px solid var(--border);
}
.keys td {
display: block;
width: auto !important;
padding: 1px 0;
border: 0;
}
.keys td[data-label]::before {
content: attr(data-label) ': ';
font-size: 11px;
color: var(--text-dim);
}
kbd {
overflow-wrap: anywhere;
}
}
</style>
+3 -2
View File
@@ -208,8 +208,9 @@ function resetDoc() {
</label>
<label class="f row-inline">
<button class="tgl" :class="{ on: selFmt.bold }" @click="applyFmt({ bold: !selFmt.bold })"><b>F</b></button>
<button class="tgl" :class="{ on: selFmt.italic }" @click="applyFmt({ italic: !selFmt.italic })"><i>K</i></button>
<button class="tgl" :class="{ on: selFmt.bold }" title="Fett (Strg+Umschalt+B)" @click="applyFmt({ bold: !selFmt.bold })"><b>F</b></button>
<button class="tgl" :class="{ on: selFmt.italic }" title="Kursiv (Strg+Umschalt+I)" @click="applyFmt({ italic: !selFmt.italic })"><i>K</i></button>
<button class="tgl" :class="{ on: selFmt.underline }" title="Unterstrichen (Strg+Umschalt+U)" @click="applyFmt({ underline: !selFmt.underline })"><u>U</u></button>
</label>
<label class="f">
+220 -4
View File
@@ -10,8 +10,8 @@
* Also handles cell selection (click / shift / drag), keyboard navigation,
* in-cell editing and per-column resize.
*/
import { computed, nextTick, onBeforeUnmount, ref } from 'vue';
import { numberToCol, cycleReferenceLocks } from '../engine';
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { numberToCol, cycleReferenceLocks, completeFormula } from '../engine';
import { useDocument } from '../store/document';
import { useLibrary } from '../store/library';
import { DEFAULT_ROW_H, type SelectionState } from '../store/types';
@@ -116,6 +116,7 @@ function formatStyle(c: number, r: number): Record<string, string> {
if (f.bg) s.background = f.bg;
if (f.bold) s.fontWeight = '600';
if (f.italic) s.fontStyle = 'italic';
if (f.underline) s.textDecoration = 'underline';
return s;
}
@@ -306,6 +307,21 @@ const clipMarqueeStyle = computed(() => {
height: rowTop(k.r1 + 1) - top + 'px',
};
});
/** The cell/range just picked for the formula being edited (point mode, by
* mouse or arrow keys) -- the grid selection itself stays on the edited
* cell meanwhile, so without this the pick would be invisible. */
const pointRectStyle = computed(() => {
const k = store.pointRect.value;
if (!k || k.tableId !== props.tableId) return null;
const left = colLeft(k.c0);
const top = rowTop(k.r0);
return {
left: left + 'px',
top: top + 'px',
width: colLeft(k.c1 + 1) - left + 'px',
height: rowTop(k.r1 + 1) - top + 'px',
};
});
/** The copy/cut selection's *own* first cell, called out inside the marquee
* even once the live selection has moved on to pick a paste destination
* (which is when `is-active` no longer sits there). */
@@ -643,6 +659,81 @@ function onPointDragUp() {
window.removeEventListener('blur', onPointDragUp);
}
// ---- references of the formula being edited (coloured, draggable) -------
/** The edited formula's references that point into this table, outlined in
* their colour. Dragging an outline's edge moves the reference, dragging a
* corner resizes it -- the formula text follows live (store.moveFormulaRef),
* same as Excel. */
const refHighlights = computed(() =>
store.formulaRefs.value
.filter((h) => h.tableId === props.tableId)
.map((h) => {
const left = colLeft(h.c0);
const top = rowTop(h.r0);
return {
...h,
style: {
left: left + 'px',
top: top + 'px',
width: colLeft(h.c1 + 1) - left + 'px',
height: rowTop(h.r1 + 1) - top + 'px',
'--ref': h.color,
},
};
}),
);
type RefCorner = 'nw' | 'ne' | 'sw' | 'se';
let refDrag: {
index: number;
orig: { c0: number; r0: number; c1: number; r1: number };
/** null = move by the edge, else the corner being dragged */
corner: RefCorner | null;
start: { c: number; r: number };
} | null = null;
function onRefHandleDown(e: MouseEvent, h: { index: number; c0: number; r0: number; c1: number; r1: number }, corner: RefCorner | null) {
if (e.button !== 0) return;
e.preventDefault(); // keep focus (and the caret) in the formula editor
e.stopPropagation(); // no cell/canvas-object mousedown underneath
const start = cellFromPoint(e.clientX, e.clientY);
if (!start) return;
refDrag = { index: h.index, orig: { c0: h.c0, r0: h.r0, c1: h.c1, r1: h.r1 }, corner, start };
document.body.style.userSelect = 'none';
window.addEventListener('mousemove', onRefDragMove, true);
window.addEventListener('mouseup', onRefDragUp, true);
window.addEventListener('blur', onRefDragUp);
}
function onRefDragMove(e: MouseEvent) {
const d = refDrag;
const t = table.value;
if (!d || !t) return;
if (e.buttons === 0) {
onRefDragUp(); // a mouseup we never saw -- self-heal a stuck drag
return;
}
const hit = cellFromPoint(e.clientX, e.clientY);
if (!hit) return;
const o = d.orig;
if (!d.corner) {
// shift the whole rectangle, but never past the table's edges
const dc = Math.max(-o.c0, Math.min(t.cols - 1 - o.c1, hit.c - d.start.c));
const dr = Math.max(-o.r0, Math.min(t.rows - 1 - o.r1, hit.r - d.start.r));
store.moveFormulaRef(d.index, o.c0 + dc, o.r0 + dr, o.c1 + dc, o.r1 + dr);
return;
}
// the opposite corner stays put; the dragged one follows the pointer
const fixedC = d.corner === 'nw' || d.corner === 'sw' ? o.c1 : o.c0;
const fixedR = d.corner === 'nw' || d.corner === 'ne' ? o.r1 : o.r0;
store.moveFormulaRef(d.index, fixedC, fixedR, hit.c, hit.r);
}
function onRefDragUp() {
if (!refDrag) return;
refDrag = null;
document.body.style.userSelect = '';
window.removeEventListener('mousemove', onRefDragMove, true);
window.removeEventListener('mouseup', onRefDragUp, true);
window.removeEventListener('blur', onRefDragUp);
}
// ---- fill handle (drag to copy/fill, Excel/Numbers-style) ---------------
/** Small square at the selection's bottom-right corner; dragging it repeats
* the selection's cells (values, formulas -- reference-adjusted like a
@@ -879,7 +970,7 @@ const editStyle = computed(() => {
const s = editing.value;
if (!s) return {};
// look like the cell being edited: its own text/fill colour (else the
// table's), bold/italic -- a white-on-dark cell must not turn into dark
// table's), bold/italic/underline -- a white-on-dark cell must not turn into dark
// text on a dark fill while editing
const f = store.cellFormat(props.tableId, s.col, s.row);
const style: Record<string, string> = {
@@ -892,6 +983,7 @@ const editStyle = computed(() => {
if (f.bg) style['--surface'] = f.bg;
if (f.bold) style.fontWeight = '600';
if (f.italic) style.fontStyle = 'italic';
if (f.underline) style.textDecoration = 'underline';
return style;
});
const editMirrorStyle = computed(() => {
@@ -965,7 +1057,8 @@ function commitEdit() {
if (!editing.value) return;
committing = true;
const { col, row } = editing.value;
store.setCellInput(props.tableId, col, row, editValue.value);
// a forgotten closing ")" is completed rather than stored as an error
store.setCellInput(props.tableId, col, row, completeFormula(editValue.value));
editing.value = null;
store.registerFormulaEditor(null);
autocomplete.close();
@@ -1126,6 +1219,17 @@ function onKeydown(e: KeyboardEvent) {
if (mod) {
const k = e.key.toLowerCase();
// Ctrl/Cmd+Shift+B (or F, "fett") / I / U: toggle bold / italic /
// underline for the whole selection -- on when not every selected cell
// has it yet, else off, same as the toggle buttons in the side panel.
// With Shift, so plain Cmd+B/I/U stay free for the browser/OS.
const toggles: Record<string, 'bold' | 'italic' | 'underline'> = { b: 'bold', f: 'bold', i: 'italic', u: 'underline' };
const toggle = e.shiftKey ? toggles[k] : undefined;
if (toggle) {
e.preventDefault();
e.stopPropagation();
return store.applySelectionFormat({ [toggle]: !store.selectionFormat.value[toggle] });
}
if (k === 'a') {
e.preventDefault();
e.stopPropagation();
@@ -1347,10 +1451,25 @@ function onFocusGridRequest(e: Event) {
}
window.addEventListener('numolus:focus-grid', onFocusGridRequest);
// After a reload (or opening a document / switching sheets) the restored
// selection is visible but nothing had the keyboard, so arrow keys and typing
// went nowhere until the user clicked. Take it whenever the selection sits in
// this table and the keyboard is otherwise unclaimed -- never away from an
// input, the formula bar or another table someone is working in.
function claimIdleKeyboard() {
if (!mine.value || !root.value) return;
const active = document.activeElement;
if (active && active !== document.body) return;
root.value.focus({ preventScroll: true });
}
onMounted(claimIdleKeyboard);
watch(() => mine.value?.tableId, claimIdleKeyboard, { flush: 'post' });
onBeforeUnmount(() => {
window.removeEventListener('numolus:focus-grid', onFocusGridRequest);
onDragUp();
onPointDragUp();
onRefDragUp();
onFillDragUp();
onResizeUp();
onRowResizeUp();
@@ -1456,6 +1575,25 @@ defineExpose({
</div>
<div v-if="clipMarqueeStyle" class="clip-marquee" :style="clipMarqueeStyle" />
<div v-if="clipAnchorStyle" class="clip-anchor" :style="clipAnchorStyle" />
<div v-for="h in refHighlights" :key="'ref' + h.index" class="ref-hl" :style="h.style">
<template v-if="!h.whole">
<span
v-for="edge in ['top', 'right', 'bottom', 'left']"
:key="edge"
class="ref-edge"
:class="'ref-edge-' + edge"
@mousedown="onRefHandleDown($event, h, null)"
/>
<span
v-for="corner in (['nw', 'ne', 'sw', 'se'] as const)"
:key="corner"
class="ref-corner"
:class="'ref-corner-' + corner"
@mousedown="onRefHandleDown($event, h, corner)"
/>
</template>
</div>
<div v-if="pointRectStyle" class="point-rect" :style="pointRectStyle" />
<div v-if="fillPreviewStyle" class="fill-preview" :style="fillPreviewStyle" />
<div
v-if="fillHandleStyle"
@@ -1666,6 +1804,84 @@ defineExpose({
box-shadow: inset 0 0 0 2px var(--accent);
z-index: 8;
}
/* the reference being picked for a formula (point mode) */
.point-rect {
position: absolute;
pointer-events: none;
border: 2px dashed var(--accent);
background: var(--accent-weak);
box-sizing: border-box;
z-index: 8;
}
/* a reference of the formula being edited, in its colour (Excel-style) */
.ref-hl {
position: absolute;
pointer-events: none; /* the inside stays clickable for picking cells */
border: 2px solid var(--ref);
background: color-mix(in srgb, var(--ref) 10%, transparent);
box-sizing: border-box;
z-index: 9;
}
/* the edges move the reference, the corners resize it */
.ref-edge {
position: absolute;
pointer-events: auto;
cursor: move;
}
.ref-edge-top,
.ref-edge-bottom {
left: 0;
right: 0;
height: 6px;
}
.ref-edge-top {
top: -4px;
}
.ref-edge-bottom {
bottom: -4px;
}
.ref-edge-left,
.ref-edge-right {
top: 0;
bottom: 0;
width: 6px;
}
.ref-edge-left {
left: -4px;
}
.ref-edge-right {
right: -4px;
}
.ref-corner {
position: absolute;
pointer-events: auto;
width: 7px;
height: 7px;
background: var(--ref);
border: 1px solid var(--surface);
border-radius: 1px;
z-index: 1;
}
.ref-corner-nw {
left: -5px;
top: -5px;
cursor: nwse-resize;
}
.ref-corner-ne {
right: -5px;
top: -5px;
cursor: nesw-resize;
}
.ref-corner-sw {
left: -5px;
bottom: -5px;
cursor: nesw-resize;
}
.ref-corner-se {
right: -5px;
bottom: -5px;
cursor: nwse-resize;
}
/* small square at the selection's bottom-right corner -- drag to fill/copy */
.fill-handle {
position: absolute;