- before copy paste
This commit is contained in:
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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 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;
|
||||||
|
|||||||
@@ -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
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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/…' },
|
||||||
|
];
|
||||||
@@ -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 |
@@ -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');
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user