- before copy paste
This commit is contained in:
@@ -13,7 +13,7 @@ Vorbild von **Apple Numbers**. Zwei Dinge stehen im Mittelpunkt:
|
||||
```bash
|
||||
npm install
|
||||
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 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
|
||||
(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
|
||||
|
||||
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
|
||||
|
||||
`npm run test` — Vitest, 102 Tests:
|
||||
`npm run test` — Vitest, 105 Tests:
|
||||
|
||||
- **lexer / parser** — Präzedenz & Assoziativität (`^` vor `*/` vor `+-`, `^`
|
||||
rechts, `-2^2 = -(2^2)`), lokale/qualifizierte/quoted Bezüge, `!` **und** `::`,
|
||||
@@ -337,7 +354,9 @@ src/
|
||||
store/ # reaktiver Dokument-Store (v2 Canvas-Modell), Formatierung
|
||||
io/ # csv / json (Eigenbau, inkl. v1→v2), xlsx (SheetJS, lazy)
|
||||
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
|
||||
data/seed.ts # vorbefülltes Dokument
|
||||
styles/main.css # Design-Tokens (Light/Dark)
|
||||
|
||||
+40
-2
@@ -4,11 +4,49 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<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
|
||||
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>
|
||||
<body>
|
||||
<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",
|
||||
"description": "Browser-Tabellenkalkulation nach dem Vorbild von Apple Numbers – mit selbst geschriebener Formel-Engine.",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"dev": "vite --port 3000",
|
||||
"build": "vue-tsc --noEmit && vite build",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run",
|
||||
|
||||
+147
-12
@@ -4,6 +4,8 @@ import { useDocument } from '../store/document';
|
||||
import { useTheme } from '../composables/useTheme';
|
||||
import { useToasts } from '../composables/useToasts';
|
||||
import BaseModal from './BaseModal.vue';
|
||||
import TheLogo from './TheLogo.vue';
|
||||
import { WICOM, WICOM_SOCIAL } from '../config/brand';
|
||||
import {
|
||||
exportJson,
|
||||
exportActiveTableCsv,
|
||||
@@ -24,9 +26,12 @@ const { mode: themeMode, cycle: cycleTheme } = useTheme();
|
||||
const toasts = useToasts();
|
||||
|
||||
const fileMenuOpen = ref(false);
|
||||
const aboutOpen = ref(false);
|
||||
const busy = ref(false);
|
||||
const fileInput = ref<HTMLInputElement | null>(null);
|
||||
|
||||
const addr = `${WICOM.address.street}, ${WICOM.address.city}, ${WICOM.address.country}`;
|
||||
|
||||
const themeLabel = computed(
|
||||
() => ({ system: 'Auto', light: 'Hell', dark: 'Dunkel' })[themeMode.value],
|
||||
);
|
||||
@@ -187,6 +192,13 @@ function onReset() {
|
||||
|
||||
<template>
|
||||
<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
|
||||
class="gear"
|
||||
:class="{ on: store.ui.settingsOpen }"
|
||||
@@ -196,9 +208,6 @@ function onReset() {
|
||||
>
|
||||
⚙
|
||||
</button>
|
||||
<div class="brand">
|
||||
<strong>Numolus</strong>
|
||||
</div>
|
||||
|
||||
<span class="divider" />
|
||||
<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="runXlsxExport">Export · Arbeitsmappe (.xlsx)</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>
|
||||
</div>
|
||||
|
||||
@@ -293,6 +304,55 @@ function onReset() {
|
||||
<button v-else class="primary" :disabled="busy" @click="doImport('confirm')">Importieren</button>
|
||||
</template>
|
||||
</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>
|
||||
|
||||
<style scoped>
|
||||
@@ -307,9 +367,6 @@ function onReset() {
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.brand .sub {
|
||||
display: none;
|
||||
}
|
||||
.spacer {
|
||||
display: none;
|
||||
}
|
||||
@@ -320,8 +377,90 @@ function onReset() {
|
||||
}
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
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 {
|
||||
border: 1px solid var(--border);
|
||||
@@ -339,10 +478,6 @@ function onReset() {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
.brand .sub {
|
||||
color: var(--text-dim);
|
||||
font-size: 11px;
|
||||
}
|
||||
.divider {
|
||||
width: 1px;
|
||||
align-self: stretch;
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
import { computed, ref } from 'vue';
|
||||
import { useDocument } from '../store/document';
|
||||
import CanvasObject from './CanvasObject.vue';
|
||||
import TheLogo from './TheLogo.vue';
|
||||
|
||||
const emit = defineEmits<{ (e: 'files', files: FileList): void }>();
|
||||
|
||||
@@ -102,9 +103,10 @@ function selectObj(id: string) {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="sheet.objects.length === 0" class="hint">
|
||||
Leeres Blatt — über die Toolbar „Tabelle" oder „Diagramm" einfügen.
|
||||
</p>
|
||||
<div v-if="sheet.objects.length === 0" class="hint">
|
||||
<TheLogo word class="hint-logo" />
|
||||
<p>Leeres Blatt — über die Toolbar „Tabelle", „Diagramm" oder „Text" einfügen.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="zoom-ctl">
|
||||
@@ -155,12 +157,19 @@ function selectObj(id: string) {
|
||||
}
|
||||
.hint {
|
||||
position: absolute;
|
||||
top: 40px;
|
||||
left: 40px;
|
||||
top: 44px;
|
||||
left: 44px;
|
||||
color: var(--text-dim);
|
||||
font-size: 13px;
|
||||
pointer-events: none;
|
||||
}
|
||||
.hint p {
|
||||
margin: 14px 0 0;
|
||||
}
|
||||
.hint-logo {
|
||||
height: 62px;
|
||||
opacity: 0.9;
|
||||
}
|
||||
.zoom-ctl {
|
||||
position: absolute;
|
||||
right: 16px;
|
||||
|
||||
+223
-21
@@ -79,6 +79,10 @@ function colLeft(c: number): number {
|
||||
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> {
|
||||
const s: Record<string, string> = formatStyle(c, r);
|
||||
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);
|
||||
}
|
||||
|
||||
// ---- 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 ---------------------------------------------
|
||||
const dragging = ref(false);
|
||||
let dragMode: 'replace' | 'additive' = 'replace';
|
||||
@@ -129,9 +284,6 @@ let lastHit: { c: number; r: number } | null = null;
|
||||
let lastPointer = { x: 0, y: 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
|
||||
* 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. */
|
||||
@@ -201,6 +353,7 @@ function startDrag(mode: 'replace' | 'additive') {
|
||||
function onCellMouseDown(e: MouseEvent, c: number, r: number) {
|
||||
if (e.button !== 0) return;
|
||||
if (editing.value) commitEdit();
|
||||
clearFlow();
|
||||
lastPointer = { x: e.clientX, y: e.clientY };
|
||||
if (e.metaKey || e.ctrlKey) {
|
||||
store.addToSelection(props.tableId, c, r);
|
||||
@@ -238,6 +391,7 @@ function onDragUp() {
|
||||
function selectColumn(c: number, e?: MouseEvent) {
|
||||
const t = table.value;
|
||||
if (!t) return;
|
||||
clearFlow();
|
||||
const a = { col: c, row: 0 };
|
||||
const f = { col: c, row: t.rows - 1 };
|
||||
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) {
|
||||
const t = table.value;
|
||||
if (!t) return;
|
||||
clearFlow();
|
||||
const a = { col: 0, row: r };
|
||||
const f = { col: t.cols - 1, row: r };
|
||||
if (e && (e.metaKey || e.ctrlKey)) store.addRangeToSelection(props.tableId, a, f);
|
||||
@@ -256,6 +411,7 @@ function selectRow(r: number, e?: MouseEvent) {
|
||||
function selectAll() {
|
||||
const t = table.value;
|
||||
if (!t) return;
|
||||
clearFlow();
|
||||
store.setSelection({ tableId: props.tableId, anchor: { col: 0, row: 0 }, focus: { col: t.cols - 1, row: t.rows - 1 } });
|
||||
focusRoot();
|
||||
}
|
||||
@@ -280,17 +436,12 @@ function beginEdit(c: number, r: number, initial?: string) {
|
||||
else el.setSelectionRange(el.value.length, el.value.length);
|
||||
});
|
||||
}
|
||||
function commitEdit(advance: 'down' | 'up' | 'left' | 'right' | 'none' = 'none') {
|
||||
function commitEdit() {
|
||||
if (!editing.value) return;
|
||||
committing = true;
|
||||
const { col, row } = editing.value;
|
||||
store.setCellInput(props.tableId, col, row, editValue.value);
|
||||
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();
|
||||
setTimeout(() => (committing = false), 0);
|
||||
}
|
||||
@@ -301,10 +452,12 @@ function cancelEdit() {
|
||||
function onEditKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
commitEdit(e.shiftKey ? 'up' : 'down');
|
||||
commitEdit();
|
||||
flowMove('v', e.shiftKey);
|
||||
} else if (e.key === 'Tab') {
|
||||
e.preventDefault();
|
||||
commitEdit(e.shiftKey ? 'left' : 'right');
|
||||
commitEdit();
|
||||
flowMove('h', e.shiftKey);
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
cancelEdit();
|
||||
@@ -312,31 +465,80 @@ function onEditKeydown(e: KeyboardEvent) {
|
||||
e.stopPropagation();
|
||||
}
|
||||
function onEditBlur() {
|
||||
if (!committing && editing.value) commitEdit('none');
|
||||
if (!committing && editing.value) commitEdit();
|
||||
}
|
||||
|
||||
// ---- keyboard navigation -------------------------------------
|
||||
// ---- keyboard navigation (Excel-like) ------------------------
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
if (editing.value) return;
|
||||
const s = mine.value;
|
||||
if (!s) return;
|
||||
const t = table.value;
|
||||
if (!s || !t) return;
|
||||
const { col, row } = s.focus;
|
||||
const ext = e.shiftKey;
|
||||
const mod = e.ctrlKey || e.metaKey;
|
||||
|
||||
switch (e.key) {
|
||||
case 'ArrowUp': e.preventDefault(); return select(col, row - 1, ext);
|
||||
case 'ArrowDown': e.preventDefault(); return select(col, row + 1, ext);
|
||||
case 'ArrowLeft': e.preventDefault(); return select(col - 1, row, ext);
|
||||
case 'ArrowRight': e.preventDefault(); return select(col + 1, row, ext);
|
||||
case 'Tab': e.preventDefault(); return select(col + (e.shiftKey ? -1 : 1), row);
|
||||
case 'Enter': e.preventDefault(); return e.shiftKey ? select(col, row - 1) : beginEdit(col, row);
|
||||
case 'F2': e.preventDefault(); return beginEdit(col, row);
|
||||
case 'ArrowUp':
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
return mod ? jumpTo(0, -1, ext) : navigate(0, -1, ext);
|
||||
case 'ArrowDown':
|
||||
e.preventDefault();
|
||||
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 'Delete':
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
return store.clearSelectionCells();
|
||||
case 'Escape':
|
||||
clearFlow();
|
||||
return;
|
||||
default:
|
||||
if (e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
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', () => {
|
||||
it('turns each old sheet into a canvas sheet holding one table of the same name', async () => {
|
||||
const { migrateToV2 } = await import('../../io/json');
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
--accent-weak: rgba(10, 132, 255, 0.14);
|
||||
--accent-header: rgba(10, 132, 255, 0.22);
|
||||
--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-sm: 0 1px 2px rgba(0, 0, 0, 0.08);
|
||||
--radius: 12px;
|
||||
@@ -44,6 +45,7 @@
|
||||
--accent: #0a84ff;
|
||||
--accent-weak: rgba(10, 132, 255, 0.22);
|
||||
--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-sm: 0 1px 2px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
@@ -61,6 +63,7 @@
|
||||
--accent: #0a84ff;
|
||||
--accent-weak: rgba(10, 132, 255, 0.22);
|
||||
--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-sm: 0 1px 2px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user