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:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
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>
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user