- before copy paste

This commit is contained in:
wkh
2026-09-02 09:45:44 +02:00
parent c7885067e7
commit 31108977b9
12 changed files with 708 additions and 44 deletions
+22 -3
View File
@@ -13,7 +13,7 @@ Vorbild von **Apple Numbers**. Zwei Dinge stehen im Mittelpunkt:
```bash ```bash
npm install npm install
npm run dev # Vite-Devserver npm run dev # Vite-Devserver
npm run test # Vitest-Suite (Engine + Store + IO), 102 Tests npm run test # Vitest-Suite (Engine + Store + IO), 105 Tests
npm run build # Typecheck + Produktionsbuild npm run build # Typecheck + Produktionsbuild
npm run lint # ESLint (flat config) npm run lint # ESLint (flat config)
``` ```
@@ -66,6 +66,23 @@ umbenennen, löschen, per Drag sortieren). Zoom 40–200 % pro Blatt
- Pfeiltasten / Enter kollabieren wieder auf eine Zelle. Die aktive Zelle - Pfeiltasten / Enter kollabieren wieder auf eine Zelle. Die aktive Zelle
(Formelleiste) ist stets die zuletzt angeklickte. (Formelleiste) ist stets die zuletzt angeklickte.
**Tastaturnavigation (Excel-nah)** — Fokus in der Tabelle:
| Taste | Verhalten |
|---|---|
| Pfeiltasten | eine Zelle; **Shift+Pfeil** erweitert die Auswahl |
| **`Strg`/`Cmd`+Pfeil** | zum Rand des Datenblocks springen (leer↔gefüllt) |
| **Tab / Shift+Tab** | rechts / links |
| **Enter / Shift+Enter** | runter / hoch — nach einer Tab-Folge **zurück zur Startspalte + eine Zeile** |
| bei aktivem **Bereich** | Tab/Enter **rotieren den Cursor im Bereich** (mit Umbruch), der Bereich bleibt markiert |
| Tippen / `F2` | Zelle editieren; Enter/Tab committen und ziehen weiter (auch im Bereich) |
| `Pos1` / `Strg`+`Pos1` / `Ende` / `Strg`+`Ende` | Zeilenanfang / A1 / Zeilenende / letzte Zelle |
| `Bild↑` / `Bild↓` | seitenweise (bei festen Tabellen nach Viewport-Höhe) |
Bei **festen (scrollbaren) Tabellen** wird die aktive Zelle bei jeder Navigation
automatisch in den sichtbaren Bereich gescrollt (unter Beachtung fixierter
Zeilen/Spalten).
### Zell-Formatierung ### Zell-Formatierung
Auswahl (Zelle, Bereich, Mehrfachauswahl, ganze Zeile/Spalte über den Kopf) → im Auswahl (Zelle, Bereich, Mehrfachauswahl, ganze Zeile/Spalte über den Kopf) → im
@@ -268,7 +285,7 @@ Grundrechenarten: `Übersicht!B2 = =SUM(Umsatz!B2:E2)`, `D2 = =B2-C2`,
## Tests ## Tests
`npm run test` — Vitest, 102 Tests: `npm run test` — Vitest, 105 Tests:
- **lexer / parser** — Präzedenz & Assoziativität (`^` vor `*/` vor `+-`, `^` - **lexer / parser** — Präzedenz & Assoziativität (`^` vor `*/` vor `+-`, `^`
rechts, `-2^2 = -(2^2)`), lokale/qualifizierte/quoted Bezüge, `!` **und** `::`, rechts, `-2^2 = -(2^2)`), lokale/qualifizierte/quoted Bezüge, `!` **und** `::`,
@@ -337,7 +354,9 @@ src/
store/ # reaktiver Dokument-Store (v2 Canvas-Modell), Formatierung store/ # reaktiver Dokument-Store (v2 Canvas-Modell), Formatierung
io/ # csv / json (Eigenbau, inkl. v1→v2), xlsx (SheetJS, lazy) io/ # csv / json (Eigenbau, inkl. v1→v2), xlsx (SheetJS, lazy)
components/ # AppToolbar, SettingsPanel, FormulaBar, SheetCanvas, CanvasObject, components/ # AppToolbar, SettingsPanel, FormulaBar, SheetCanvas, CanvasObject,
# TableGrid, ChartView, TextBox, SheetTabs, StatusBar, … # TableGrid, ChartView, TextBox, SheetTabs, StatusBar, TheLogo, …
numolus-logo.svg # Marke: drei überlappende Pop-Art-Quadrate + Schriftzug
config/brand.ts # Herausgeber-Metadaten (wicom.li) + Social-Media-Liste
composables/ # Theme, Toasts composables/ # Theme, Toasts
data/seed.ts # vorbefülltes Dokument data/seed.ts # vorbefülltes Dokument
styles/main.css # Design-Tokens (Light/Dark) styles/main.css # Design-Tokens (Light/Dark)
+40 -2
View File
@@ -4,11 +4,49 @@
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"> <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="color-scheme" content="light dark"> <meta name="color-scheme" content="light dark">
<meta name="theme-color" content="#f5f5f7" media="(prefers-color-scheme: light)">
<meta name="theme-color" content="#1c1c1e" media="(prefers-color-scheme: dark)">
<meta
name="description"
content="Numolus – browserbasierte Tabellenkalkulation nach dem Vorbild von Apple Numbers, mit selbst geschriebener Formel-Engine. Case- und Designstudie von wicom, Vaduz (FL)."
>
<meta name="author" content="Karl-Heinz Wild">
<meta name="publisher" content="wicom – Webentwicklung & digitale Lösungen">
<meta name="copyright" content="© wicom, Birkenweg 6, FL-9490 Vaduz – https://wicom.li">
<meta name="generator" content="Vue 3 + Vite">
<meta name="robots" content="index, follow">
<meta name="contact" content="office@wicom.li">
<link rel="author" href="https://wicom.li">
<link rel="publisher" href="https://wicom.li">
<link rel="canonical" href="https://wicom.li/">
<!-- Open Graph -->
<meta property="og:type" content="website">
<meta property="og:site_name" content="wicom – Webentwicklung & digitale Lösungen">
<meta property="og:locale" content="de_LI">
<meta property="og:title" content="Numolus · Tabellenkalkulation">
<meta
property="og:description"
content="Case- und Designstudie: Tabellenkalkulation à la Apple Numbers mit eigener Formel-Engine – von wicom, Vaduz (FL)."
>
<meta property="og:url" content="https://wicom.li/">
<meta property="og:image" content="https://wicom.li/numolus-logo.svg">
<!-- Twitter / X Card -->
<meta name="twitter:card" content="summary">
<meta name="twitter:title" content="Numolus · Tabellenkalkulation">
<meta
name="twitter:description"
content="Tabellenkalkulation à la Apple Numbers mit eigener Formel-Engine – Studie von wicom (FL)."
>
<meta name="twitter:image" content="https://wicom.li/numolus-logo.svg">
<link <link
rel="icon" rel="icon"
href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='7' fill='%230a84ff'/%3E%3Cg fill='%23fff'%3E%3Crect x='6' y='7' width='20' height='4' rx='1'/%3E%3Crect x='6' y='14' width='9' height='4' rx='1'/%3E%3Crect x='17' y='14' width='9' height='4' rx='1'/%3E%3Crect x='6' y='21' width='9' height='4' rx='1'/%3E%3Crect x='17' y='21' width='9' height='4' rx='1'/%3E%3C/g%3E%3C/svg%3E" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Cg stroke='%23111' stroke-width='1.4'%3E%3Crect x='3' y='9' width='13' height='13' fill='%23FFD400'/%3E%3Crect x='8' y='9' width='13' height='13' fill='%230057FF'/%3E%3Crect x='13' y='9' width='13' height='13' fill='%23E8384F'/%3E%3C/g%3E%3C/svg%3E"
> >
<title>Numolus · Tabellenkalkulation</title> <title>Numolus · Tabellenkalkulation — wicom</title>
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>
+21
View File
@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg width="100%" height="100%" viewBox="0 0 221 131" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
<g id="Artboard1" transform="matrix(1,0,0,1,-37.5288,-29.7995)">
<rect x="37.529" y="29.8" width="220.293" height="130.162" style="fill:none;"/>
<g transform="matrix(0.662418,0,0,0.935173,14.0321,5.27628)">
<rect x="40" y="30" width="90" height="90" style="fill:rgb(255,212,0);"/>
</g>
<g transform="matrix(0.661788,0,0,0.932447,35.0264,5.35805)">
<rect x="102" y="30" width="90" height="90" style="fill:rgb(0,87,255);"/>
</g>
<g transform="matrix(1,0,0,0.934048,0.528827,5.31002)">
<rect x="164" y="30" width="90" height="90" style="fill:rgb(232,56,79);"/>
</g>
<g transform="matrix(1,0,0,1,38.5638,155.804)">
<g transform="matrix(53,0,0,53,154.018,0)">
</g>
<text x="0px" y="0px" style="font-family:'LiGothicMed', 'Apple LiGothic';font-size:53px;">numolus</text>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

+1 -1
View File
@@ -5,7 +5,7 @@
"type": "module", "type": "module",
"description": "Browser-Tabellenkalkulation nach dem Vorbild von Apple Numbers – mit selbst geschriebener Formel-Engine.", "description": "Browser-Tabellenkalkulation nach dem Vorbild von Apple Numbers – mit selbst geschriebener Formel-Engine.",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite --port 3000",
"build": "vue-tsc --noEmit && vite build", "build": "vue-tsc --noEmit && vite build",
"preview": "vite preview", "preview": "vite preview",
"test": "vitest run", "test": "vitest run",
+147 -12
View File
@@ -4,6 +4,8 @@ import { useDocument } from '../store/document';
import { useTheme } from '../composables/useTheme'; import { useTheme } from '../composables/useTheme';
import { useToasts } from '../composables/useToasts'; import { useToasts } from '../composables/useToasts';
import BaseModal from './BaseModal.vue'; import BaseModal from './BaseModal.vue';
import TheLogo from './TheLogo.vue';
import { WICOM, WICOM_SOCIAL } from '../config/brand';
import { import {
exportJson, exportJson,
exportActiveTableCsv, exportActiveTableCsv,
@@ -24,9 +26,12 @@ const { mode: themeMode, cycle: cycleTheme } = useTheme();
const toasts = useToasts(); const toasts = useToasts();
const fileMenuOpen = ref(false); const fileMenuOpen = ref(false);
const aboutOpen = ref(false);
const busy = ref(false); const busy = ref(false);
const fileInput = ref<HTMLInputElement | null>(null); const fileInput = ref<HTMLInputElement | null>(null);
const addr = `${WICOM.address.street}, ${WICOM.address.city}, ${WICOM.address.country}`;
const themeLabel = computed( const themeLabel = computed(
() => ({ system: 'Auto', light: 'Hell', dark: 'Dunkel' })[themeMode.value], () => ({ system: 'Auto', light: 'Hell', dark: 'Dunkel' })[themeMode.value],
); );
@@ -187,6 +192,13 @@ function onReset() {
<template> <template>
<header class="toolbar"> <header class="toolbar">
<div class="brand" title="Numolus — eine Studie von wicom, Vaduz (FL)">
<TheLogo class="brand-mark" />
<span class="wordmark">numolus</span>
<button class="by" type="button" @click="aboutOpen = true">
eine <span>wicom</span>-Studie
</button>
</div>
<button <button
class="gear" class="gear"
:class="{ on: store.ui.settingsOpen }" :class="{ on: store.ui.settingsOpen }"
@@ -196,9 +208,6 @@ function onReset() {
> >
⚙ ⚙
</button> </button>
<div class="brand">
<strong>Numolus</strong>
</div>
<span class="divider" /> <span class="divider" />
<button class="btn accent" @click="insertTable">+ Tabelle</button> <button class="btn accent" @click="insertTable">+ Tabelle</button>
@@ -216,6 +225,8 @@ function onReset() {
<li><button @click="openCsvExport">Export · aktive Tabelle (.csv) …</button></li> <li><button @click="openCsvExport">Export · aktive Tabelle (.csv) …</button></li>
<li><button @click="runXlsxExport">Export · Arbeitsmappe (.xlsx)</button></li> <li><button @click="runXlsxExport">Export · Arbeitsmappe (.xlsx)</button></li>
<li><button @click="exportSelectedChartPng">Export · gewähltes Diagramm (.png)</button></li> <li><button @click="exportSelectedChartPng">Export · gewähltes Diagramm (.png)</button></li>
<li class="sep" />
<li><button @click="aboutOpen = true">Über Numolus …</button></li>
</ul> </ul>
</div> </div>
@@ -293,6 +304,55 @@ function onReset() {
<button v-else class="primary" :disabled="busy" @click="doImport('confirm')">Importieren</button> <button v-else class="primary" :disabled="busy" @click="doImport('confirm')">Importieren</button>
</template> </template>
</BaseModal> </BaseModal>
<!-- Über / Impressum -->
<BaseModal :open="aboutOpen" title="Über Numolus" @close="aboutOpen = false">
<p class="about-lead">
<strong>Numolus</strong> — browserbasierte Tabellenkalkulation nach dem Vorbild von
Apple&nbsp;Numbers, mit selbst geschriebener Formel-Engine. Case- und Designstudie.
</p>
<dl class="about">
<dt>Herausgeber</dt>
<dd>
<a :href="WICOM.url" target="_blank" rel="noopener noreferrer">{{ WICOM.name }}</a>
— {{ WICOM.descriptor }}<br />
<span class="dim">„{{ WICOM.tagline }}" · seit {{ WICOM.since }}</span>
</dd>
<dt>Anschrift</dt>
<dd>{{ addr }}</dd>
<dt>Kontakt</dt>
<dd>
<a :href="`mailto:${WICOM.email}`">{{ WICOM.email }}</a><br />
<a :href="WICOM.phoneHref">{{ WICOM.phone }}</a>
</dd>
<dt>Autor</dt>
<dd>{{ WICOM.author }}</dd>
<dt>Social Media</dt>
<dd>
<template v-if="WICOM_SOCIAL.length">
<a
v-for="s in WICOM_SOCIAL"
:key="s.url"
class="social"
:href="s.url"
target="_blank"
rel="noopener noreferrer"
>{{ s.label }}</a>
</template>
<span v-else class="dim">wicom.li führt keine Social-Media-Profile.</span>
</dd>
</dl>
<template #footer>
<a class="btn-link" :href="WICOM.url" target="_blank" rel="noopener noreferrer">wicom.li ↗</a>
<button class="primary" @click="aboutOpen = false">Schließen</button>
</template>
</BaseModal>
</template> </template>
<style scoped> <style scoped>
@@ -307,9 +367,6 @@ function onReset() {
box-shadow: var(--shadow-sm); box-shadow: var(--shadow-sm);
} }
@media (max-width: 640px) { @media (max-width: 640px) {
.brand .sub {
display: none;
}
.spacer { .spacer {
display: none; display: none;
} }
@@ -320,8 +377,90 @@ function onReset() {
} }
.brand { .brand {
display: flex; display: flex;
align-items: baseline; align-items: center;
gap: 8px; gap: 9px;
margin-right: 2px;
user-select: none;
}
.brand-mark {
height: 24px;
flex: none;
}
.wordmark {
font-size: 17px;
font-weight: 600;
letter-spacing: 0.01em;
color: var(--text);
}
.by {
border: 0;
background: transparent;
padding: 0;
font: inherit;
font-size: 11px;
color: var(--text-dim);
cursor: pointer;
white-space: nowrap;
}
.by span {
font-weight: 600;
}
.by:hover {
color: var(--accent);
}
@media (max-width: 720px) {
.by {
display: none;
}
}
@media (max-width: 520px) {
.wordmark {
display: none;
}
}
/* --- Über-Dialog --- */
.about-lead {
margin: 0 0 14px;
line-height: 1.5;
}
.about {
display: grid;
grid-template-columns: max-content 1fr;
gap: 6px 14px;
margin: 0;
font-size: 13px;
line-height: 1.45;
}
.about dt {
color: var(--text-dim);
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.04em;
padding-top: 2px;
}
.about dd {
margin: 0;
}
.about a {
color: var(--accent);
}
.about .dim {
color: var(--text-dim);
}
.about .social {
display: inline-block;
margin-right: 10px;
}
.btn-link {
margin-right: auto;
align-self: center;
font-size: 12.5px;
color: var(--text-dim);
text-decoration: none;
}
.btn-link:hover {
color: var(--accent);
} }
.gear { .gear {
border: 1px solid var(--border); border: 1px solid var(--border);
@@ -339,10 +478,6 @@ function onReset() {
border-color: var(--accent); border-color: var(--accent);
color: var(--accent); color: var(--accent);
} }
.brand .sub {
color: var(--text-dim);
font-size: 11px;
}
.divider { .divider {
width: 1px; width: 1px;
align-self: stretch; align-self: stretch;
+14 -5
View File
@@ -7,6 +7,7 @@
import { computed, ref } from 'vue'; import { computed, ref } from 'vue';
import { useDocument } from '../store/document'; import { useDocument } from '../store/document';
import CanvasObject from './CanvasObject.vue'; import CanvasObject from './CanvasObject.vue';
import TheLogo from './TheLogo.vue';
const emit = defineEmits<{ (e: 'files', files: FileList): void }>(); const emit = defineEmits<{ (e: 'files', files: FileList): void }>();
@@ -102,9 +103,10 @@ function selectObj(id: string) {
/> />
</div> </div>
</div> </div>
<p v-if="sheet.objects.length === 0" class="hint"> <div v-if="sheet.objects.length === 0" class="hint">
Leeres Blatt — über die Toolbar „Tabelle" oder „Diagramm" einfügen. <TheLogo word class="hint-logo" />
</p> <p>Leeres Blatt — über die Toolbar „Tabelle", „Diagramm" oder „Text" einfügen.</p>
</div>
</div> </div>
<div class="zoom-ctl"> <div class="zoom-ctl">
@@ -155,12 +157,19 @@ function selectObj(id: string) {
} }
.hint { .hint {
position: absolute; position: absolute;
top: 40px; top: 44px;
left: 40px; left: 44px;
color: var(--text-dim); color: var(--text-dim);
font-size: 13px; font-size: 13px;
pointer-events: none; pointer-events: none;
} }
.hint p {
margin: 14px 0 0;
}
.hint-logo {
height: 62px;
opacity: 0.9;
}
.zoom-ctl { .zoom-ctl {
position: absolute; position: absolute;
right: 16px; right: 16px;
+223 -21
View File
@@ -79,6 +79,10 @@ function colLeft(c: number): number {
return x; return x;
} }
/** The internal scroll element of a fixed table (null for auto-size tables). */
const scrollEl = () =>
isFixed.value ? (root.value?.querySelector('.tg-scroll') as HTMLElement | null) : null;
function cellStyle(c: number, r: number): Record<string, string> { function cellStyle(c: number, r: number): Record<string, string> {
const s: Record<string, string> = formatStyle(c, r); const s: Record<string, string> = formatStyle(c, r);
if (!isFixed.value) return s; if (!isFixed.value) return s;
@@ -122,6 +126,157 @@ function select(c: number, r: number, extend = false) {
store.selectCell(props.tableId, clampC(c), clampR(r), extend); store.selectCell(props.tableId, clampC(c), clampR(r), extend);
} }
// ---- keyboard navigation (Excel-like) --------------------------------
/** Rectangle the active cell "flows" through on Tab/Enter (Excel data-entry). */
let flowRect: { c0: number; c1: number; r0: number; r1: number } | null = null;
/** Column a Tab-run started in, so a following Enter returns there + one row. */
let entryCol: number | null = null;
function clearFlow() {
flowRect = null;
entryCol = null;
}
/** Keep the active cell visible inside a fixed table's scroll viewport. */
function scrollActiveIntoView() {
const box = scrollEl();
const s = mine.value;
if (!box || !s) return;
const { col, row } = s.focus;
const left = colLeft(col);
const right = left + colWidth(col);
const top = rowTop(row);
const bottom = top + rowH(row);
const padL = colLeft(frozenCols.value); // width of the frozen-column band + header
const padT = rowTop(frozenRows.value); // height of the frozen-row band + header
if (left - padL < box.scrollLeft) box.scrollLeft = Math.max(0, left - padL);
else if (right > box.scrollLeft + box.clientWidth) box.scrollLeft = right - box.clientWidth;
if (top - padT < box.scrollTop) box.scrollTop = Math.max(0, top - padT);
else if (bottom > box.scrollTop + box.clientHeight) box.scrollTop = bottom - box.clientHeight;
}
function primaryRect() {
const s = mine.value;
if (!s) return null;
return {
c0: Math.min(s.anchor.col, s.focus.col),
c1: Math.max(s.anchor.col, s.focus.col),
r0: Math.min(s.anchor.row, s.focus.row),
r1: Math.max(s.anchor.row, s.focus.row),
};
}
/** Plain move of the active cell (arrow keys). `ext` = grow the selection. */
function navigate(dc: number, dr: number, ext: boolean) {
clearFlow();
const s = mine.value;
if (!s) return;
select(s.focus.col + dc, s.focus.row + dr, ext);
scrollActiveIntoView();
}
/** Tab (`axis='h'`) / Enter (`axis='v'`), Excel style. */
function flowMove(axis: 'h' | 'v', back: boolean) {
const s = mine.value;
const t = table.value;
if (!s || !t) return;
const pr = primaryRect()!;
const multi = pr.c0 !== pr.c1 || pr.r0 !== pr.r1;
if (multi || flowRect) {
if (!flowRect) flowRect = pr;
const R = flowRect;
let { col, row } = s.focus;
if (axis === 'h') {
col += back ? -1 : 1;
if (col > R.c1) {
col = R.c0;
row = row >= R.r1 ? R.r0 : row + 1;
} else if (col < R.c0) {
col = R.c1;
row = row <= R.r0 ? R.r1 : row - 1;
}
} else {
row += back ? -1 : 1;
if (row > R.r1) {
row = R.r0;
col = col >= R.c1 ? R.c0 : col + 1;
} else if (row < R.r0) {
row = R.r1;
col = col <= R.c0 ? R.c1 : col - 1;
}
}
// keep the whole range highlighted; move only the cursor cell
store.setSelection({
tableId: props.tableId,
anchor: { col, row },
focus: { col, row },
ranges: [{ anchor: { col: R.c0, row: R.r0 }, focus: { col: R.c1, row: R.r1 } }],
});
scrollActiveIntoView();
return;
}
// single cell: straight move; remember/return the Tab-run column
let col = s.focus.col;
let row = s.focus.row;
if (axis === 'h') {
if (entryCol === null) entryCol = col;
col = clampC(col + (back ? -1 : 1));
} else if (entryCol !== null) {
col = entryCol;
row = clampR(row + (back ? -1 : 1));
entryCol = null;
} else {
row = clampR(row + (back ? -1 : 1));
}
select(col, row, false);
scrollActiveIntoView();
}
/** Ctrl/Cmd+Arrow: jump to the edge of the current data block. */
function jumpTo(dc: number, dr: number, ext: boolean) {
clearFlow();
const s = mine.value;
const t = table.value;
if (!s || !t) return;
const filled = (c: number, r: number) =>
c >= 0 && r >= 0 && c < t.cols && r < t.rows && store.cellInput(props.tableId, c, r) !== '';
let { col, row } = s.focus;
const inB = (c: number, r: number) => c >= 0 && r >= 0 && c < t.cols && r < t.rows;
let nc = col + dc;
let nr = row + dr;
if (!inB(nc, nr)) {
/* already at edge */
} else if (filled(col, row) && filled(nc, nr)) {
while (inB(nc + dc, nr + dr) && filled(nc + dc, nr + dr)) {
nc += dc;
nr += dr;
}
col = nc;
row = nr;
} else {
while (inB(nc, nr) && !filled(nc, nr)) {
nc += dc;
nr += dr;
}
if (inB(nc, nr)) {
col = nc;
row = nr;
} else {
col = clampC(dc ? (dc > 0 ? t.cols - 1 : 0) : col);
row = clampR(dr ? (dr > 0 ? t.rows - 1 : 0) : row);
}
}
select(col, row, ext);
scrollActiveIntoView();
}
function pageRows(): number {
const box = scrollEl();
if (!box) return 12;
return Math.max(1, Math.floor(box.clientHeight / (table.value?.rowHeight ?? 26)) - 1);
}
// ---- mouse selection --------------------------------------------- // ---- mouse selection ---------------------------------------------
const dragging = ref(false); const dragging = ref(false);
let dragMode: 'replace' | 'additive' = 'replace'; let dragMode: 'replace' | 'additive' = 'replace';
@@ -129,9 +284,6 @@ let lastHit: { c: number; r: number } | null = null;
let lastPointer = { x: 0, y: 0 }; let lastPointer = { x: 0, y: 0 };
let autoScrollRaf = 0; let autoScrollRaf = 0;
const scrollEl = () =>
isFixed.value ? (root.value?.querySelector('.tg-scroll') as HTMLElement | null) : null;
/** Hit-test by cursor position -- robust to fast moves, borders and child /** Hit-test by cursor position -- robust to fast moves, borders and child
* nodes (the event target during a drag is unreliable). Clamps into the * nodes (the event target during a drag is unreliable). Clamps into the
* scroll viewport of a fixed table so dragging past the edge still selects. */ * scroll viewport of a fixed table so dragging past the edge still selects. */
@@ -201,6 +353,7 @@ function startDrag(mode: 'replace' | 'additive') {
function onCellMouseDown(e: MouseEvent, c: number, r: number) { function onCellMouseDown(e: MouseEvent, c: number, r: number) {
if (e.button !== 0) return; if (e.button !== 0) return;
if (editing.value) commitEdit(); if (editing.value) commitEdit();
clearFlow();
lastPointer = { x: e.clientX, y: e.clientY }; lastPointer = { x: e.clientX, y: e.clientY };
if (e.metaKey || e.ctrlKey) { if (e.metaKey || e.ctrlKey) {
store.addToSelection(props.tableId, c, r); store.addToSelection(props.tableId, c, r);
@@ -238,6 +391,7 @@ function onDragUp() {
function selectColumn(c: number, e?: MouseEvent) { function selectColumn(c: number, e?: MouseEvent) {
const t = table.value; const t = table.value;
if (!t) return; if (!t) return;
clearFlow();
const a = { col: c, row: 0 }; const a = { col: c, row: 0 };
const f = { col: c, row: t.rows - 1 }; const f = { col: c, row: t.rows - 1 };
if (e && (e.metaKey || e.ctrlKey)) store.addRangeToSelection(props.tableId, a, f); if (e && (e.metaKey || e.ctrlKey)) store.addRangeToSelection(props.tableId, a, f);
@@ -247,6 +401,7 @@ function selectColumn(c: number, e?: MouseEvent) {
function selectRow(r: number, e?: MouseEvent) { function selectRow(r: number, e?: MouseEvent) {
const t = table.value; const t = table.value;
if (!t) return; if (!t) return;
clearFlow();
const a = { col: 0, row: r }; const a = { col: 0, row: r };
const f = { col: t.cols - 1, row: r }; const f = { col: t.cols - 1, row: r };
if (e && (e.metaKey || e.ctrlKey)) store.addRangeToSelection(props.tableId, a, f); if (e && (e.metaKey || e.ctrlKey)) store.addRangeToSelection(props.tableId, a, f);
@@ -256,6 +411,7 @@ function selectRow(r: number, e?: MouseEvent) {
function selectAll() { function selectAll() {
const t = table.value; const t = table.value;
if (!t) return; if (!t) return;
clearFlow();
store.setSelection({ tableId: props.tableId, anchor: { col: 0, row: 0 }, focus: { col: t.cols - 1, row: t.rows - 1 } }); store.setSelection({ tableId: props.tableId, anchor: { col: 0, row: 0 }, focus: { col: t.cols - 1, row: t.rows - 1 } });
focusRoot(); focusRoot();
} }
@@ -280,17 +436,12 @@ function beginEdit(c: number, r: number, initial?: string) {
else el.setSelectionRange(el.value.length, el.value.length); else el.setSelectionRange(el.value.length, el.value.length);
}); });
} }
function commitEdit(advance: 'down' | 'up' | 'left' | 'right' | 'none' = 'none') { function commitEdit() {
if (!editing.value) return; if (!editing.value) return;
committing = true; committing = true;
const { col, row } = editing.value; const { col, row } = editing.value;
store.setCellInput(props.tableId, col, row, editValue.value); store.setCellInput(props.tableId, col, row, editValue.value);
editing.value = null; editing.value = null;
const d: Record<string, [number, number]> = {
down: [0, 1], up: [0, -1], left: [-1, 0], right: [1, 0], none: [0, 0],
};
const [dc, dr] = d[advance];
select(col + dc, row + dr);
focusRoot(); focusRoot();
setTimeout(() => (committing = false), 0); setTimeout(() => (committing = false), 0);
} }
@@ -301,10 +452,12 @@ function cancelEdit() {
function onEditKeydown(e: KeyboardEvent) { function onEditKeydown(e: KeyboardEvent) {
if (e.key === 'Enter') { if (e.key === 'Enter') {
e.preventDefault(); e.preventDefault();
commitEdit(e.shiftKey ? 'up' : 'down'); commitEdit();
flowMove('v', e.shiftKey);
} else if (e.key === 'Tab') { } else if (e.key === 'Tab') {
e.preventDefault(); e.preventDefault();
commitEdit(e.shiftKey ? 'left' : 'right'); commitEdit();
flowMove('h', e.shiftKey);
} else if (e.key === 'Escape') { } else if (e.key === 'Escape') {
e.preventDefault(); e.preventDefault();
cancelEdit(); cancelEdit();
@@ -312,31 +465,80 @@ function onEditKeydown(e: KeyboardEvent) {
e.stopPropagation(); e.stopPropagation();
} }
function onEditBlur() { function onEditBlur() {
if (!committing && editing.value) commitEdit('none'); if (!committing && editing.value) commitEdit();
} }
// ---- keyboard navigation ------------------------------------- // ---- keyboard navigation (Excel-like) ------------------------
function onKeydown(e: KeyboardEvent) { function onKeydown(e: KeyboardEvent) {
if (editing.value) return; if (editing.value) return;
const s = mine.value; const s = mine.value;
if (!s) return; const t = table.value;
if (!s || !t) return;
const { col, row } = s.focus; const { col, row } = s.focus;
const ext = e.shiftKey; const ext = e.shiftKey;
const mod = e.ctrlKey || e.metaKey;
switch (e.key) { switch (e.key) {
case 'ArrowUp': e.preventDefault(); return select(col, row - 1, ext); case 'ArrowUp':
case 'ArrowDown': e.preventDefault(); return select(col, row + 1, ext); e.preventDefault();
case 'ArrowLeft': e.preventDefault(); return select(col - 1, row, ext); e.stopPropagation();
case 'ArrowRight': e.preventDefault(); return select(col + 1, row, ext); return mod ? jumpTo(0, -1, ext) : navigate(0, -1, ext);
case 'Tab': e.preventDefault(); return select(col + (e.shiftKey ? -1 : 1), row); case 'ArrowDown':
case 'Enter': e.preventDefault(); return e.shiftKey ? select(col, row - 1) : beginEdit(col, row); e.preventDefault();
case 'F2': e.preventDefault(); return beginEdit(col, row); e.stopPropagation();
return mod ? jumpTo(0, 1, ext) : navigate(0, 1, ext);
case 'ArrowLeft':
e.preventDefault();
e.stopPropagation();
return mod ? jumpTo(-1, 0, ext) : navigate(-1, 0, ext);
case 'ArrowRight':
e.preventDefault();
e.stopPropagation();
return mod ? jumpTo(1, 0, ext) : navigate(1, 0, ext);
case 'Tab':
e.preventDefault();
e.stopPropagation();
return flowMove('h', e.shiftKey);
case 'Enter':
e.preventDefault();
e.stopPropagation();
return flowMove('v', e.shiftKey);
case 'F2':
e.preventDefault();
e.stopPropagation();
return beginEdit(col, row);
case 'Home':
e.preventDefault();
e.stopPropagation();
clearFlow();
select(0, mod ? 0 : row, ext);
return scrollActiveIntoView();
case 'End':
e.preventDefault();
e.stopPropagation();
clearFlow();
select(t.cols - 1, mod ? t.rows - 1 : row, ext);
return scrollActiveIntoView();
case 'PageUp':
e.preventDefault();
e.stopPropagation();
return navigate(0, -pageRows(), ext);
case 'PageDown':
e.preventDefault();
e.stopPropagation();
return navigate(0, pageRows(), ext);
case 'Backspace': case 'Backspace':
case 'Delete': case 'Delete':
e.preventDefault(); e.preventDefault();
e.stopPropagation();
return store.clearSelectionCells(); return store.clearSelectionCells();
case 'Escape':
clearFlow();
return;
default: default:
if (e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) { if (e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) {
e.preventDefault(); e.preventDefault();
e.stopPropagation();
beginEdit(col, row, e.key); beginEdit(col, row, e.key);
} }
} }
+46
View File
@@ -0,0 +1,46 @@
<script setup lang="ts">
/**
* Numolus brand mark: three top-aligned, slightly overlapping pop-art squares
* with a keyline. Fills are fixed brand colours; the keyline uses a token so it
* stays visible in dark mode. Pass `word` to also render the "numolus" wordmark.
*/
defineProps<{ word?: boolean }>();
</script>
<template>
<svg
class="logo"
:class="{ 'has-word': word }"
:viewBox="word ? '64 62 322 206' : '64 64 322 142'"
xmlns="http://www.w3.org/2000/svg"
role="img"
aria-label="Numolus"
>
<g stroke="var(--logo-keyline, #111)" stroke-width="6">
<rect x="70" y="70" width="130" height="130" fill="#FFD400" />
<rect x="160" y="70" width="130" height="130" fill="#0057FF" />
<rect x="250" y="70" width="130" height="130" fill="#E8384F" />
</g>
<text
v-if="word"
x="72"
y="255"
font-size="46"
font-weight="600"
fill="currentColor"
>
numolus
</text>
</svg>
</template>
<style scoped>
.logo {
display: block;
height: 100%;
width: auto;
}
.logo text {
font-family: var(--font-ui);
}
</style>
+37
View File
@@ -0,0 +1,37 @@
/**
* Publisher / company metadata.
* Source: https://wicom.li (fetched 2026-08-29).
*/
export const WICOM = {
name: 'wicom',
legalName: 'wicom – Wild Karl-Heinz',
descriptor: 'Webentwicklung & digitale Lösungen',
tagline: 'Unsere Arbeiten – Ihr Erfolg',
since: 1997,
url: 'https://wicom.li',
email: 'office@wicom.li',
phone: '+41 79 446 2901',
phoneHref: 'tel:+41794462901',
address: {
street: 'Birkenweg 6',
city: 'FL-9490 Vaduz',
country: 'Liechtenstein',
},
author: 'Karl-Heinz Wild',
} as const;
/** One-line description of this app, used for `<meta name="description">` / OG. */
export const APP_DESCRIPTION =
'Numolus — browserbasierte Tabellenkalkulation nach dem Vorbild von Apple Numbers, ' +
'mit selbst geschriebener Formel-Engine. Eine Case- und Designstudie von wicom (Vaduz, FL).';
/**
* Social-media profiles of wicom.
* wicom.li lists none — add `{ label, url }` entries here and they appear in the
* header and the "Über"-Dialog automatically.
*/
export const WICOM_SOCIAL: ReadonlyArray<{ label: string; url: string }> = [
// { label: 'LinkedIn', url: 'https://www.linkedin.com/company/…' },
// { label: 'GitHub', url: 'https://github.com/…' },
// { label: 'X', url: 'https://x.com/…' },
];
+11
View File
@@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="64 62 322 206" role="img" aria-label="numolus">
<title>numolus</title>
<desc>Drei am oberen Rand ausgerichtete, leicht überlappende Quadrate in kräftigen Pop-Art-Farben mit schwarzer Umrandung, darunter der Schriftzug numolus.</desc>
<g stroke="#111111" stroke-width="6">
<rect x="70" y="70" width="130" height="130" fill="#FFD400"/>
<rect x="160" y="70" width="130" height="130" fill="#0057FF"/>
<rect x="250" y="70" width="130" height="130" fill="#E8384F"/>
</g>
<text x="72" y="255" font-size="46" font-weight="600" fill="#111111"
font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif">numolus</text>
</svg>

After

Width:  |  Height:  |  Size: 735 B

+143
View File
@@ -515,6 +515,149 @@ describe('per-object background & text colour', () => {
}); });
}); });
describe('full-fidelity JSON export — backgrounds, formats, sheet settings', () => {
/** Populate *every* persisted setting, then verify the export string carries
* it and a round-trip is byte-for-byte identical. */
function loadEverything(s: DocumentStore) {
const analyse = s.doc.sheets[0].id;
const blatt2 = s.doc.sheets[1].id;
const umsatzId = tableId(s, 'Umsatz');
const chartId = s.doc.sheets[0].objects.find((o) => o.kind === 'chart')!.id;
// document
s.setDocumentLocale('en-GB');
s.setCsvDelimiter(';');
// sheet ("Arbeitsblatt") settings — on both sheets
s.setSheetLocale(analyse, 'de-CH');
s.setSheetBackground(analyse, '#eef2ff');
s.setSheetForeground(analyse, '#101828');
s.setSheetShowGrid(analyse, false);
s.setSheetZoom(analyse, 1.25);
s.renameSheet(blatt2, 'Notizen');
s.setSheetBackground(blatt2, '#fff7ed');
// table object: colours + geometry + view + per-row/col + cell formats
s.setObjectBg(umsatzId, '#0b1220');
s.setObjectTextColor(umsatzId, '#f8fafc');
s.setObjectLocale(umsatzId, 'fr-FR');
s.setTableView(umsatzId, { mode: 'fixed', width: 520, height: 300, frozenRows: 1, frozenCols: 1 });
s.setTableRowHeight(umsatzId, 30);
s.setRowHeight(umsatzId, 4, 52);
s.setColWidth(umsatzId, 0, 130);
s.setRangeFormat(umsatzId, { col: 1, row: 1 }, { col: 3, row: 4 }, {
align: 'right',
valign: 'middle',
numFmt: 'pct2',
color: '#dc2626',
bg: '#fee2e2',
bold: true,
italic: true,
});
// chart object
s.setObjectBg(chartId, '#111827');
s.setObjectTextColor(chartId, '#e5e7eb');
s.setChartView(chartId, 'line');
s.resizeChart(chartId, 600, 360);
// text object with full formatting
const txtId = s.addText(analyse);
s.setTextContent(txtId, 'Bericht\nZeile 2');
s.setTextFormat(txtId, { fontSize: 20, bold: true, italic: true, align: 'center' });
s.setObjectTextColor(txtId, '#334155');
s.setObjectBg(txtId, '#f1f5f9');
// multi-range selection
s.setSelection({ tableId: umsatzId, anchor: { col: 0, row: 0 }, focus: { col: 0, row: 0 } });
s.addToSelection(umsatzId, 2, 2);
s.addToSelection(umsatzId, 4, 5);
return { analyse, umsatzId, chartId, txtId };
}
it('the exported JSON text literally contains every setting', () => {
const s = fresh();
loadEverything(s);
const json = serializeDocument(s.snapshot());
const doc = JSON.parse(json);
// document
expect(doc.locale).toBe('en-GB');
expect(doc.csvDelimiter).toBe(';');
// sheet settings
const a = doc.sheets[0];
expect(a).toMatchObject({
locale: 'de-CH',
background: '#eef2ff',
foreground: '#101828',
showGrid: false,
zoom: 1.25,
});
expect(doc.sheets[1]).toMatchObject({ name: 'Notizen', background: '#fff7ed' });
// table object
const tbl = a.objects.find((o: { kind: string }) => o.kind === 'table');
expect(tbl).toMatchObject({
bg: '#0b1220',
fg: '#f8fafc',
locale: 'fr-FR',
rowHeight: 30,
rowHeights: { '4': 52 },
colWidths: { '0': 130 },
view: { mode: 'fixed', width: 520, height: 300, frozenRows: 1, frozenCols: 1 },
});
expect(tbl.formats['1,1']).toEqual({
align: 'right',
valign: 'middle',
numFmt: 'pct2',
color: '#dc2626',
bg: '#fee2e2',
bold: true,
italic: true,
});
// chart object
const cht = a.objects.find((o: { kind: string }) => o.kind === 'chart');
expect(cht).toMatchObject({ bg: '#111827', fg: '#e5e7eb', view: 'line', width: 600, height: 360 });
// text object
const txt = a.objects.find((o: { kind: string }) => o.kind === 'text');
expect(txt).toMatchObject({
text: 'Bericht\nZeile 2',
fontSize: 20,
bold: true,
italic: true,
align: 'center',
color: '#334155',
bg: '#f1f5f9',
});
// multi-range selection
expect(doc.selection.ranges).toHaveLength(2);
});
it('export -> import is lossless for the fully-loaded document', () => {
const s = fresh();
loadEverything(s);
const before = s.snapshot();
s.replaceDocument(parseDocument(serializeDocument(before)));
expect(s.snapshot()).toEqual(before);
});
it('resetToSeed then re-import the exported file restores the exact state', () => {
const s = fresh();
loadEverything(s);
const exported = serializeDocument(s.snapshot());
const before = s.snapshot();
s.resetToSeed();
expect(s.snapshot()).not.toEqual(before);
s.replaceDocument(parseDocument(exported));
expect(s.snapshot()).toEqual(before);
});
});
describe('v1 -> v2 migration', () => { describe('v1 -> v2 migration', () => {
it('turns each old sheet into a canvas sheet holding one table of the same name', async () => { it('turns each old sheet into a canvas sheet holding one table of the same name', async () => {
const { migrateToV2 } = await import('../../io/json'); const { migrateToV2 } = await import('../../io/json');
+3
View File
@@ -18,6 +18,7 @@
--accent-weak: rgba(10, 132, 255, 0.14); --accent-weak: rgba(10, 132, 255, 0.14);
--accent-header: rgba(10, 132, 255, 0.22); --accent-header: rgba(10, 132, 255, 0.22);
--danger: #ff3b30; --danger: #ff3b30;
--logo-keyline: #111111;
--shadow: 0 1px 3px rgba(0, 0, 0, 0.08), 0 6px 20px rgba(0, 0, 0, 0.06); --shadow: 0 1px 3px rgba(0, 0, 0, 0.08), 0 6px 20px rgba(0, 0, 0, 0.06);
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.08); --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.08);
--radius: 12px; --radius: 12px;
@@ -44,6 +45,7 @@
--accent: #0a84ff; --accent: #0a84ff;
--accent-weak: rgba(10, 132, 255, 0.22); --accent-weak: rgba(10, 132, 255, 0.22);
--accent-header: rgba(10, 132, 255, 0.32); --accent-header: rgba(10, 132, 255, 0.32);
--logo-keyline: #f2f2f4;
--shadow: 0 1px 3px rgba(0, 0, 0, 0.5), 0 8px 28px rgba(0, 0, 0, 0.45); --shadow: 0 1px 3px rgba(0, 0, 0, 0.5), 0 8px 28px rgba(0, 0, 0, 0.45);
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.4); --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.4);
} }
@@ -61,6 +63,7 @@
--accent: #0a84ff; --accent: #0a84ff;
--accent-weak: rgba(10, 132, 255, 0.22); --accent-weak: rgba(10, 132, 255, 0.22);
--accent-header: rgba(10, 132, 255, 0.32); --accent-header: rgba(10, 132, 255, 0.32);
--logo-keyline: #f2f2f4;
--shadow: 0 1px 3px rgba(0, 0, 0, 0.5), 0 8px 28px rgba(0, 0, 0, 0.45); --shadow: 0 1px 3px rgba(0, 0, 0, 0.5), 0 8px 28px rgba(0, 0, 0, 0.45);
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.4); --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.4);
} }