diff --git a/design-system/md-reader/MASTER.md b/design-system/md-reader/MASTER.md new file mode 100644 index 0000000..303e39f --- /dev/null +++ b/design-system/md-reader/MASTER.md @@ -0,0 +1,200 @@ +# Design System Master File + +> **LOGIC:** When building a specific page, first check `design-system/pages/[page-name].md`. +> If that file exists, its rules **override** this Master file. +> If not, strictly follow the rules below. + +--- + +**Project:** md-reader +**Generated:** 2026-07-25 16:44:10 +**Category:** Cybersecurity Platform + +--- + +## Global Rules + +### Color Palette + +| Role | Hex | CSS Variable | +|------|-----|--------------| +| Primary | `#0D9488` | `--color-primary` | +| Secondary | `#14B8A6` | `--color-secondary` | +| CTA/Accent | `#F97316` | `--color-cta` | +| Background | `#F0FDFA` | `--color-background` | +| Text | `#134E4A` | `--color-text` | + +**Color Notes:** Teal focus + action orange + +### Typography + +- **Heading Font:** Inter +- **Body Font:** Inter +- **Mood:** Technical + Clear typography + +### Spacing Variables + +| Token | Value | Usage | +|-------|-------|-------| +| `--space-xs` | `4px` / `0.25rem` | Tight gaps | +| `--space-sm` | `8px` / `0.5rem` | Icon gaps, inline spacing | +| `--space-md` | `16px` / `1rem` | Standard padding | +| `--space-lg` | `24px` / `1.5rem` | Section padding | +| `--space-xl` | `32px` / `2rem` | Large gaps | +| `--space-2xl` | `48px` / `3rem` | Section margins | +| `--space-3xl` | `64px` / `4rem` | Hero padding | + +### Shadow Depths + +| Level | Value | Usage | +|-------|-------|-------| +| `--shadow-sm` | `0 1px 2px rgba(0,0,0,0.05)` | Subtle lift | +| `--shadow-md` | `0 4px 6px rgba(0,0,0,0.1)` | Cards, buttons | +| `--shadow-lg` | `0 10px 15px rgba(0,0,0,0.1)` | Modals, dropdowns | +| `--shadow-xl` | `0 20px 25px rgba(0,0,0,0.15)` | Hero images, featured cards | + +--- + +## Component Specs + +### Buttons + +```css +/* Primary Button */ +.btn-primary { + background: #F97316; + color: white; + padding: 12px 24px; + border-radius: 8px; + font-weight: 600; + transition: all 200ms ease; + cursor: pointer; +} + +.btn-primary:hover { + opacity: 0.9; + transform: translateY(-1px); +} + +/* Secondary Button */ +.btn-secondary { + background: transparent; + color: #0D9488; + border: 2px solid #0D9488; + padding: 12px 24px; + border-radius: 8px; + font-weight: 600; + transition: all 200ms ease; + cursor: pointer; +} +``` + +### Cards + +```css +.card { + background: #F0FDFA; + border-radius: 12px; + padding: 24px; + box-shadow: var(--shadow-md); + transition: all 200ms ease; + cursor: pointer; +} + +.card:hover { + box-shadow: var(--shadow-lg); + transform: translateY(-2px); +} +``` + +### Inputs + +```css +.input { + padding: 12px 16px; + border: 1px solid #E2E8F0; + border-radius: 8px; + font-size: 16px; + transition: border-color 200ms ease; +} + +.input:focus { + border-color: #0D9488; + outline: none; + box-shadow: 0 0 0 3px #0D948820; +} +``` + +### Modals + +```css +.modal-overlay { + background: rgba(0, 0, 0, 0.5); + backdrop-filter: blur(4px); +} + +.modal { + background: white; + border-radius: 16px; + padding: 32px; + box-shadow: var(--shadow-xl); + max-width: 500px; + width: 90%; +} +``` + +--- + +## Style Guidelines + +**Style:** Cyberpunk UI + +**Keywords:** Neon, dark mode, terminal, HUD, sci-fi, glitch, dystopian, futuristic, matrix, tech noir + +**Best For:** Gaming platforms, tech products, crypto apps, sci-fi applications, developer tools, entertainment + +**Key Effects:** Neon glow (text-shadow), glitch animations (skew/offset), scanlines (::before overlay), terminal fonts + +### Page Pattern + +**Pattern Name:** Horizontal Scroll Journey + +- **Conversion Strategy:** Immersive product discovery. High engagement. Keep navigation visible. +28,Bento Grid Showcase,bento, grid, features, modular, apple-style, showcase", 1. Hero, 2. Bento Grid (Key Features), 3. Detail Cards, 4. Tech Specs, 5. CTA, Floating Action Button or Bottom of Grid, Card backgrounds: #F5F5F7 or Glass. Icons: Vibrant brand colors. Text: Dark., Hover card scale (1.02), video inside cards, tilt effect, staggered reveal, Scannable value props. High information density without clutter. Mobile stack. +29,Interactive 3D Configurator,3d, configurator, customizer, interactive, product", 1. Hero (Configurator), 2. Feature Highlight (synced), 3. Price/Specs, 4. Purchase, Inside Configurator UI + Sticky Bottom Bar, Neutral studio background. Product: Realistic materials. UI: Minimal overlay., Real-time rendering, material swap animation, camera rotate/zoom, light reflection, Increases ownership feeling. 360 view reduces return rates. Direct add-to-cart. +30,AI-Driven Dynamic Landing,ai, dynamic, personalized, adaptive, generative", 1. Prompt/Input Hero, 2. Generated Result Preview, 3. How it Works, 4. Value Prop, Input Field (Hero) + 'Try it' Buttons, Adaptive to user input. Dark mode for compute feel. Neon accents., Typing text effects, shimmering generation loaders, morphing layouts, Immediate value demonstration. 'Show, don't tell'. Low friction start. +- **CTA Placement:** Floating Sticky CTA or End of Horizontal Track +- **Section Order:** 1. Intro (Vertical), 2. The Journey (Horizontal Track), 3. Detail Reveal, 4. Vertical Footer + +--- + +## Anti-Patterns (Do NOT Use) + +- ❌ Light mode +- ❌ Poor data viz + +### Additional Forbidden Patterns + +- ❌ **Emojis as icons** — Use SVG icons (Heroicons, Lucide, Simple Icons) +- ❌ **Missing cursor:pointer** — All clickable elements must have cursor:pointer +- ❌ **Layout-shifting hovers** — Avoid scale transforms that shift layout +- ❌ **Low contrast text** — Maintain 4.5:1 minimum contrast ratio +- ❌ **Instant state changes** — Always use transitions (150-300ms) +- ❌ **Invisible focus states** — Focus states must be visible for a11y + +--- + +## Pre-Delivery Checklist + +Before delivering any UI code, verify: + +- [ ] No emojis used as icons (use SVG instead) +- [ ] All icons from consistent icon set (Heroicons/Lucide) +- [ ] `cursor-pointer` on all clickable elements +- [ ] Hover states with smooth transitions (150-300ms) +- [ ] Light mode: text contrast 4.5:1 minimum +- [ ] Focus states visible for keyboard navigation +- [ ] `prefers-reduced-motion` respected +- [ ] Responsive: 375px, 768px, 1024px, 1440px +- [ ] No content hidden behind fixed navbars +- [ ] No horizontal scroll on mobile diff --git a/package.json b/package.json index 8ad8a6f..f1d2b68 100644 --- a/package.json +++ b/package.json @@ -30,13 +30,18 @@ "dev": "vite", "build": "vue-tsc --noEmit && vite build", "preview": "vite preview", + "test": "vitest", + "test:run": "vitest run", + "test:coverage": "vitest run --coverage", "tauri": "tauri", "lint": "eslint .", "lint:fix": "eslint . --fix", "format": "prettier --write \"src/**/*.{ts,vue,css,json}\"" }, "pnpm": { - "onlyBuiltDependencies": ["esbuild"] + "onlyBuiltDependencies": [ + "esbuild" + ] }, "dependencies": { "@codemirror/commands": "^6.10.4", @@ -68,17 +73,21 @@ "devDependencies": { "@eslint/js": "^10.0.1", "@tauri-apps/cli": "^2.1.0", + "@types/jsdom": "^28.0.3", "@types/katex": "^0.16.8", "@types/markdown-it": "^14.1.2", "@types/markdown-it-emoji": "^3.0.1", "@typescript-eslint/eslint-plugin": "^8.62.0", "@typescript-eslint/parser": "^8.62.0", "@vitejs/plugin-vue": "^5.2.1", + "@vue/test-utils": "^2.4.11", "eslint": "^10.5.0", "eslint-plugin-vue": "^10.9.2", + "jsdom": "^29.1.1", "prettier": "^3.8.4", "typescript": "^5.7.2", "vite": "^6.0.3", + "vitest": "^4.1.10", "vue-eslint-parser": "^10.4.1", "vue-tsc": "^2.1.10" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 270d963..8a0025f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -90,6 +90,9 @@ importers: '@tauri-apps/cli': specifier: ^2.1.0 version: 2.11.3 + '@types/jsdom': + specifier: ^28.0.3 + version: 28.0.3 '@types/katex': specifier: ^0.16.8 version: 0.16.8 @@ -107,13 +110,19 @@ importers: version: 8.62.0(eslint@10.5.0)(typescript@5.9.3) '@vitejs/plugin-vue': specifier: ^5.2.1 - version: 5.2.4(vite@6.4.3(yaml@2.9.0))(vue@3.5.38(typescript@5.9.3)) + version: 5.2.4(vite@6.4.3(@types/node@26.1.1)(yaml@2.9.0))(vue@3.5.38(typescript@5.9.3)) + '@vue/test-utils': + specifier: ^2.4.11 + version: 2.4.11(@vue/compiler-dom@3.5.38)(@vue/server-renderer@3.5.38(vue@3.5.38(typescript@5.9.3)))(vue@3.5.38(typescript@5.9.3)) eslint: specifier: ^10.5.0 version: 10.5.0 eslint-plugin-vue: specifier: ^10.9.2 version: 10.9.2(@typescript-eslint/parser@8.62.0(eslint@10.5.0)(typescript@5.9.3))(eslint@10.5.0)(vue-eslint-parser@10.4.1(eslint@10.5.0)) + jsdom: + specifier: ^29.1.1 + version: 29.1.1 prettier: specifier: ^3.8.4 version: 3.8.4 @@ -122,7 +131,10 @@ importers: version: 5.9.3 vite: specifier: ^6.0.3 - version: 6.4.3(yaml@2.9.0) + version: 6.4.3(@types/node@26.1.1)(yaml@2.9.0) + vitest: + specifier: ^4.1.10 + version: 4.1.10(@types/node@26.1.1)(jsdom@29.1.1)(vite@6.4.3(@types/node@26.1.1)(yaml@2.9.0)) vue-eslint-parser: specifier: ^10.4.1 version: 10.4.1(eslint@10.5.0) @@ -135,6 +147,21 @@ packages: '@antfu/install-pkg@1.1.0': resolution: {integrity: sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==} + '@asamuzakjp/css-color@5.1.11': + resolution: {integrity: sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + '@asamuzakjp/dom-selector@7.1.1': + resolution: {integrity: sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + '@asamuzakjp/generational-cache@1.0.1': + resolution: {integrity: sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + '@asamuzakjp/nwsapi@2.3.9': + resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==} + '@babel/helper-string-parser@7.29.7': resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} engines: {node: '>=6.9.0'} @@ -155,6 +182,10 @@ packages: '@braintree/sanitize-url@7.1.2': resolution: {integrity: sha512-jigsZK+sMF/cuiB7sERuo9V7N9jx+dhmHHnQyDSVdpZwVutaBu7WvNYqMDLSgFgfB30n452TP3vjDAvFC973mA==} + '@bramus/specificity@2.4.2': + resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==} + hasBin: true + '@chevrotain/types@11.1.2': resolution: {integrity: sha512-U+HFai5+zmJCkK86QsaJtoITlboZHBqrVketcO2ROv865xfCMSFpELQoz1GkX5GzME8pTa+3kbKrZHQtI0gdbw==} @@ -194,6 +225,42 @@ packages: '@codemirror/view@6.43.2': resolution: {integrity: sha512-8kU6WNRYBKV9Sw3cxNz+uSvUvx3tt+1qgupGFPubnbLFDHOgh5qQdIGmXcD7bkA/PROK6LDKVhKMpcY7H++Amg==} + '@csstools/color-helpers@6.1.0': + resolution: {integrity: sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg==} + engines: {node: '>=20.19.0'} + + '@csstools/css-calc@3.3.0': + resolution: {integrity: sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-color-parser@4.1.10': + resolution: {integrity: sha512-UZhQLIUyJaaMepqehrCODwCg2KW25vFvLWBmqYFaPclYvvxzj/sG8LBOhBFCp11i9uE7t1EyS+RAoV9tztPFyw==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-parser-algorithms@4.0.0': + resolution: {integrity: sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.7': + resolution: {integrity: sha512-fQ+05118eQS1cofO3aJpB5efgpBZMvIzwr/sbC8kDLVA5XLG8q1kJV5yzrUAI1f7lvhPnm8fgIjzFB8/O/5Dig==} + peerDependencies: + css-tree: ^3.2.1 + peerDependenciesMeta: + css-tree: + optional: true + + '@csstools/css-tokenizer@4.0.0': + resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==} + engines: {node: '>=20.19.0'} + '@esbuild/aix-ppc64@0.25.12': resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} engines: {node: '>=18'} @@ -389,6 +456,15 @@ packages: resolution: {integrity: sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} + '@exodus/bytes@1.15.1': + resolution: {integrity: sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + peerDependencies: + '@noble/hashes': ^1.8.0 || ^2.0.0 + peerDependenciesMeta: + '@noble/hashes': + optional: true + '@humanfs/core@0.19.2': resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} engines: {node: '>=18.18.0'} @@ -431,6 +507,10 @@ packages: resolution: {integrity: sha512-m1p1HHAMLhqSpTRH7VnXdrN0CQ4y+9vunFkpLkbD8soIuBsnQdawZXqMCgvwI2UVF9Ww7sVaw7g9tV2VO7shoA==} engines: {node: '>= 22'} + '@isaacs/cliui@8.0.2': + resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} + engines: {node: '>=12'} + '@jridgewell/sourcemap-codec@1.5.5': resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} @@ -461,6 +541,13 @@ packages: '@mermaid-js/parser@1.1.1': resolution: {integrity: sha512-VuHdsYMK1bT6X2JbcAaWAhugTRvRBRyuZgd+c22swUeI9g/ntaxF7CY7dYarhZovofCbUNO0G7JesfmNtjYOCw==} + '@one-ini/wasm@0.1.1': + resolution: {integrity: sha512-XuySG1E38YScSJoMlqovLru4KTUNSjgVTIjyh7qMX6aNN5HY5Ct5LhRJdxO79JtTzKfzV/bnWpz+zquYrISsvw==} + + '@pkgjs/parseargs@0.11.0': + resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} + engines: {node: '>=14'} + '@rollup/rollup-android-arm-eabi@4.62.2': resolution: {integrity: sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==} cpu: [arm] @@ -599,6 +686,9 @@ packages: cpu: [x64] os: [win32] + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@tauri-apps/api@2.11.1': resolution: {integrity: sha512-M2FPuYND2m+wh5hfW9ZpSdxMPdEJovPBWwoHJmwUpysTYNHaOkVFN419m/K0LIgjb/7KU2vBgsUepJWugQCvAA==} @@ -687,6 +777,9 @@ packages: '@tauri-apps/plugin-opener@2.5.4': resolution: {integrity: sha512-1HnPkb+AmgO29HBazm4uPLKB+r7zzcTBW1d0fyYp1uP+jwtpoiNDGKMMzz58SFp49nOIrxdE3aUJtT57lfO9CQ==} + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + '@types/d3-array@3.2.2': resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==} @@ -780,6 +873,9 @@ packages: '@types/d3@7.4.3': resolution: {integrity: sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==} + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + '@types/esrecurse@4.3.1': resolution: {integrity: sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==} @@ -789,6 +885,9 @@ packages: '@types/geojson@7946.0.16': resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==} + '@types/jsdom@28.0.3': + resolution: {integrity: sha512-/HQ2uFoetFTXuye8vzIcHw2z6Fwi7Hi/qcgC+RoS9NCyewiqxhVGqlG+ViGB6lkax481R6dmhf1I7lIGlzJStQ==} + '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} @@ -807,6 +906,12 @@ packages: '@types/mdurl@2.0.0': resolution: {integrity: sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==} + '@types/node@26.1.1': + resolution: {integrity: sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==} + + '@types/tough-cookie@4.0.5': + resolution: {integrity: sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==} + '@types/trusted-types@2.0.7': resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} @@ -879,6 +984,35 @@ packages: vite: ^5.0.0 || ^6.0.0 vue: ^3.2.25 + '@vitest/expect@4.1.10': + resolution: {integrity: sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==} + + '@vitest/mocker@4.1.10': + resolution: {integrity: sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@4.1.10': + resolution: {integrity: sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==} + + '@vitest/runner@4.1.10': + resolution: {integrity: sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==} + + '@vitest/snapshot@4.1.10': + resolution: {integrity: sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==} + + '@vitest/spy@4.1.10': + resolution: {integrity: sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==} + + '@vitest/utils@4.1.10': + resolution: {integrity: sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==} + '@volar/language-core@2.4.15': resolution: {integrity: sha512-3VHw+QZU0ZG9IuQmzT68IyN4hZNd9GchGPhbD9+pa8CVv7rnoOZwo7T8weIbrRmihqy3ATpdfXFnqRrfPVK6CA==} @@ -940,6 +1074,20 @@ packages: '@vue/shared@3.5.38': resolution: {integrity: sha512-FTW0AFZNaK5/mOqvGBwVfUlNLU38TiQn4+DQgIFUnrBBJQ1crMJ82yeGQLV5jyKFsO8yRukpbuP7x+nRbH6aug==} + '@vue/test-utils@2.4.11': + resolution: {integrity: sha512-GDqaqZsA6m2E5vNzej0aYiIb6BX8xV9pNSbbbXKOfEYwg7ZNblVX8suyqmUBThq8VIrgAJNxn+z72hVtUeiWHA==} + peerDependencies: + '@vue/compiler-dom': 3.x + '@vue/server-renderer': 3.x + vue: 3.x + peerDependenciesMeta: + '@vue/server-renderer': + optional: true + + abbrev@2.0.0: + resolution: {integrity: sha512-6/mh1E2u2YgEsCHdY0Yx5oW+61gZU+1vXaoiHHrpKeuRNNgFvS+/jrwHiQhB5apAf5oB7UB7E19ol2R2LKH8hQ==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + acorn-jsx@5.3.2: resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} peerDependencies: @@ -956,9 +1104,29 @@ packages: alien-signals@1.0.13: resolution: {integrity: sha512-OGj9yyTnJEttvzhTUWuscOvtqxq5vrhF7vL9oS0xJ2mK0ItPYP1/y+vCFebfxoEyAz0++1AIwJ5CMr+Fk3nDmg==} + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-regex@6.2.2: + resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} + engines: {node: '>=12'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + ansi-styles@6.2.3: + resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} + engines: {node: '>=12'} + argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} @@ -966,6 +1134,9 @@ packages: resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} engines: {node: 18 || 20 || >=22} + bidi-js@1.0.3: + resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==} + birpc@2.9.0: resolution: {integrity: sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==} @@ -979,9 +1150,24 @@ packages: resolution: {integrity: sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==} engines: {node: 18 || 20 || >=22} + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + codemirror@6.0.2: resolution: {integrity: sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw==} + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + commander@10.0.1: + resolution: {integrity: sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==} + engines: {node: '>=14'} + commander@7.2.0: resolution: {integrity: sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==} engines: {node: '>= 10'} @@ -990,6 +1176,12 @@ packages: resolution: {integrity: sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==} engines: {node: '>= 12'} + config-chain@1.1.13: + resolution: {integrity: sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ==} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + copy-anything@4.0.5: resolution: {integrity: sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==} engines: {node: '>=18'} @@ -1007,6 +1199,10 @@ packages: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} + css-tree@3.2.1: + resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} + cssesc@3.0.0: resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} engines: {node: '>=4'} @@ -1171,6 +1367,10 @@ packages: dagre-d3-es@7.0.14: resolution: {integrity: sha512-P4rFMVq9ESWqmOgK+dlXvOtLwYg0i7u0HBGJER0LZDJT2VHIPAMZ/riPxqJceWMStH5+E61QxFra9kIS3AqdMg==} + data-urls@7.0.0: + resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + dayjs@1.11.21: resolution: {integrity: sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==} @@ -1186,6 +1386,9 @@ packages: supports-color: optional: true + decimal.js@10.6.0: + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} @@ -1195,6 +1398,20 @@ packages: dompurify@3.4.11: resolution: {integrity: sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw==} + eastasianwidth@0.2.0: + resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + + editorconfig@1.0.7: + resolution: {integrity: sha512-e0GOtq/aTQhVdNyDU9e02+wz9oDDM+SIOQxWME2QRjzRX5yyLAuHDE+0aE8vHb9XRC8XD37eO2u57+F09JqFhw==} + engines: {node: '>=14'} + hasBin: true + + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + + emoji-regex@9.2.2: + resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} + entities@4.5.0: resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} engines: {node: '>=0.12'} @@ -1203,6 +1420,13 @@ packages: resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} engines: {node: '>=0.12'} + entities@8.0.0: + resolution: {integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==} + engines: {node: '>=20.19.0'} + + es-module-lexer@2.3.1: + resolution: {integrity: sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==} + es-toolkit@1.48.1: resolution: {integrity: sha512-wfnXlwd5I75eXRtdD2vuEs50xHHESECDsGD7yiQnfFVNoa5522NwXEbmgo98LfiukSQHs+mBM7/YG3qKJB9/mQ==} @@ -1270,10 +1494,17 @@ packages: estree-walker@2.0.2: resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + esutils@2.0.3: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} + expect-type@1.4.0: + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} + engines: {node: '>=12.0.0'} + fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -1307,6 +1538,10 @@ packages: flatted@3.4.2: resolution: {integrity: sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==} + foreground-child@3.3.1: + resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} + engines: {node: '>=14'} + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -1316,6 +1551,11 @@ packages: resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} engines: {node: '>=10.13.0'} + glob@10.5.0: + resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + hasBin: true + hachure-fill@0.5.2: resolution: {integrity: sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg==} @@ -1330,6 +1570,10 @@ packages: hookable@5.5.3: resolution: {integrity: sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==} + html-encoding-sniffer@6.0.0: + resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + iconv-lite@0.6.3: resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} engines: {node: '>=0.10.0'} @@ -1349,6 +1593,9 @@ packages: resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} engines: {node: '>=0.8.19'} + ini@1.3.8: + resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} + internmap@1.0.1: resolution: {integrity: sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw==} @@ -1360,10 +1607,17 @@ packages: resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} engines: {node: '>=0.10.0'} + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + is-glob@4.0.3: resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} engines: {node: '>=0.10.0'} + is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + is-what@5.5.0: resolution: {integrity: sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw==} engines: {node: '>=18'} @@ -1371,6 +1625,26 @@ packages: isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + jackspeak@3.4.3: + resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} + + js-beautify@1.15.4: + resolution: {integrity: sha512-9/KXeZUKKJwqCXUdBxFJ3vPh467OCckSBmYDwSK/EtV090K+iMJ7zx2S3HLVDIWFQdqMIsZWbnaGiba18aWhaA==} + engines: {node: '>=14'} + hasBin: true + + js-cookie@3.0.8: + resolution: {integrity: sha512-yeJd4aNAdYZQjaon2bpD/Gb0B/omw7HQOsynXXcOiWVCacbBcPlgn8S/d1X6blFSaHao7ozqtW7NZW19xpCtIw==} + + jsdom@29.1.1: + resolution: {integrity: sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24.0.0} + peerDependencies: + canvas: ^3.0.0 + peerDependenciesMeta: + canvas: + optional: true + json-buffer@3.0.1: resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} @@ -1414,6 +1688,13 @@ packages: lodash-es@4.18.1: resolution: {integrity: sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==} + lru-cache@10.4.3: + resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + + lru-cache@11.5.2: + resolution: {integrity: sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==} + engines: {node: 20 || >=22} + magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} @@ -1441,6 +1722,9 @@ packages: engines: {node: '>= 20'} hasBin: true + mdn-data@2.27.1: + resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} + mdurl@2.0.0: resolution: {integrity: sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==} @@ -1455,6 +1739,10 @@ packages: resolution: {integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==} engines: {node: '>=16 || 14 >=14.17'} + minipass@7.1.3: + resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} + engines: {node: '>=16 || 14 >=14.17'} + mitt@3.0.1: resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==} @@ -1472,9 +1760,18 @@ packages: natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + nopt@7.2.1: + resolution: {integrity: sha512-taM24ViiimT/XntxbPyJQzCG+p4EKOpgD3mxFwW38mGjVUrfERQOeY4EDHjdnptttfHuHQXFx+lTP08Q+mLa/w==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + hasBin: true + nth-check@2.1.1: resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} + obug@2.1.4: + resolution: {integrity: sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==} + engines: {node: '>=12.20.0'} + optionator@0.9.4: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} @@ -1487,9 +1784,15 @@ packages: resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} engines: {node: '>=10'} + package-json-from-dist@1.0.1: + resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} + package-manager-detector@1.6.0: resolution: {integrity: sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==} + parse5@8.0.1: + resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==} + path-browserify@1.0.1: resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==} @@ -1504,6 +1807,13 @@ packages: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} + path-scurry@1.11.1: + resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} + engines: {node: '>=16 || 14 >=14.18'} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + perfect-debounce@1.0.0: resolution: {integrity: sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==} @@ -1546,6 +1856,9 @@ packages: engines: {node: '>=14'} hasBin: true + proto-list@1.2.4: + resolution: {integrity: sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==} + punycode.js@2.3.1: resolution: {integrity: sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==} engines: {node: '>=6'} @@ -1554,6 +1867,10 @@ packages: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + rfdc@1.4.1: resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} @@ -1574,6 +1891,10 @@ packages: safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + semver@7.8.5: resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} engines: {node: '>=10'} @@ -1587,6 +1908,13 @@ packages: resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} engines: {node: '>=8'} + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -1595,6 +1923,28 @@ packages: resolution: {integrity: sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==} engines: {node: '>=0.10.0'} + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@4.2.0: + resolution: {integrity: sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==} + + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + string-width@5.1.2: + resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} + engines: {node: '>=12'} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + strip-ansi@7.2.0: + resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} + engines: {node: '>=12'} + style-mod@4.1.3: resolution: {integrity: sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==} @@ -1605,6 +1955,12 @@ packages: resolution: {integrity: sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA==} engines: {node: '>=16'} + symbol-tree@3.2.4: + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + tinyexec@1.2.4: resolution: {integrity: sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==} engines: {node: '>=18'} @@ -1613,6 +1969,25 @@ packages: resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} engines: {node: '>=12.0.0'} + tinyrainbow@3.1.0: + resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} + engines: {node: '>=14.0.0'} + + tldts-core@7.4.9: + resolution: {integrity: sha512-DxKfPBI52p2msTEu7MPhdpdDTBhhVQg1a/8PjQckeyAvO13eMYElX545grIp6nnTGIMZlRvFZPvFhvI/WIz2Vg==} + + tldts@7.4.9: + resolution: {integrity: sha512-3kZ8wQQ/k5DrChD4X4FVvr2D7E5uoRgAqkPyLpSCGUvqOvqu+JEdr3mwMUaVWb+vMHZaKhF5fp2PBigKsui7hA==} + hasBin: true + + tough-cookie@6.0.2: + resolution: {integrity: sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==} + engines: {node: '>=16'} + + tr46@6.0.0: + resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==} + engines: {node: '>=20'} + ts-api-utils@2.5.0: resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==} engines: {node: '>=18.12'} @@ -1635,6 +2010,16 @@ packages: uc.micro@2.1.0: resolution: {integrity: sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==} + undici-types@7.28.0: + resolution: {integrity: sha512-LJAfY+2w6HGeT8d8J1wNQsUGUEGio6NWWpwdwurQe4f6oojzCFuGLizl1KSve4irsTxyLly1QhEeE6iapdaIvQ==} + + undici-types@8.3.0: + resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==} + + undici@7.28.0: + resolution: {integrity: sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==} + engines: {node: '>=20.18.1'} + uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} @@ -1685,9 +2070,53 @@ packages: yaml: optional: true + vitest@4.1.10: + resolution: {integrity: sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.1.10 + '@vitest/browser-preview': 4.1.10 + '@vitest/browser-webdriverio': 4.1.10 + '@vitest/coverage-istanbul': 4.1.10 + '@vitest/coverage-v8': 4.1.10 + '@vitest/ui': 4.1.10 + happy-dom: '*' + jsdom: '*' + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + vscode-uri@3.1.0: resolution: {integrity: sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==} + vue-component-type-helpers@3.3.8: + resolution: {integrity: sha512-troqCMmQodQDqUqn63NQaFi+CDSclSe7sc8VEBFqf5GFLqmGR2Ph3P2WEC7qwpRVyEWsTi/aAr4vyOe/B1hU3g==} + vue-eslint-parser@10.4.1: resolution: {integrity: sha512-Gk6gRDj0n/fkRa3C3l0bBheoBckUq/Rs0F/TvMWIS6nzzx67amAViMe9CkNgsP2tXyQONvGiHQESHwFtZ3aYDA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -1717,19 +2146,55 @@ packages: w3c-keyname@2.2.8: resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==} + w3c-xmlserializer@5.0.0: + resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} + engines: {node: '>=18'} + + webidl-conversions@8.0.1: + resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==} + engines: {node: '>=20'} + + whatwg-mimetype@5.0.0: + resolution: {integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==} + engines: {node: '>=20'} + + whatwg-url@16.0.1: + resolution: {integrity: sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + which@2.0.2: resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} engines: {node: '>= 8'} hasBin: true + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + word-wrap@1.2.5: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} + wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + + wrap-ansi@8.1.0: + resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} + engines: {node: '>=12'} + xml-name-validator@4.0.0: resolution: {integrity: sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==} engines: {node: '>=12'} + xml-name-validator@5.0.0: + resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} + engines: {node: '>=18'} + + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + yaml@2.9.0: resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==} engines: {node: '>= 14.6'} @@ -1746,6 +2211,26 @@ snapshots: package-manager-detector: 1.6.0 tinyexec: 1.2.4 + '@asamuzakjp/css-color@5.1.11': + dependencies: + '@asamuzakjp/generational-cache': 1.0.1 + '@csstools/css-calc': 3.3.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-color-parser': 4.1.10(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@asamuzakjp/dom-selector@7.1.1': + dependencies: + '@asamuzakjp/generational-cache': 1.0.1 + '@asamuzakjp/nwsapi': 2.3.9 + bidi-js: 1.0.3 + css-tree: 3.2.1 + is-potential-custom-element-name: 1.0.1 + + '@asamuzakjp/generational-cache@1.0.1': {} + + '@asamuzakjp/nwsapi@2.3.9': {} + '@babel/helper-string-parser@7.29.7': {} '@babel/helper-validator-identifier@7.29.7': {} @@ -1761,6 +2246,10 @@ snapshots: '@braintree/sanitize-url@7.1.2': {} + '@bramus/specificity@2.4.2': + dependencies: + css-tree: 3.2.1 + '@chevrotain/types@11.1.2': {} '@codemirror/autocomplete@6.20.3': @@ -1856,6 +2345,30 @@ snapshots: style-mod: 4.1.3 w3c-keyname: 2.2.8 + '@csstools/color-helpers@6.1.0': {} + + '@csstools/css-calc@3.3.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-color-parser@4.1.10(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/color-helpers': 6.1.0 + '@csstools/css-calc': 3.3.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.7(css-tree@3.2.1)': + optionalDependencies: + css-tree: 3.2.1 + + '@csstools/css-tokenizer@4.0.0': {} + '@esbuild/aix-ppc64@0.25.12': optional: true @@ -1968,6 +2481,8 @@ snapshots: '@eslint/core': 1.2.1 levn: 0.4.1 + '@exodus/bytes@1.15.1': {} + '@humanfs/core@0.19.2': dependencies: '@humanfs/types': 0.15.0 @@ -2010,6 +2525,15 @@ snapshots: '@intlify/shared@11.4.6': {} + '@isaacs/cliui@8.0.2': + dependencies: + string-width: 5.1.2 + string-width-cjs: string-width@4.2.3 + strip-ansi: 7.2.0 + strip-ansi-cjs: strip-ansi@6.0.1 + wrap-ansi: 8.1.0 + wrap-ansi-cjs: wrap-ansi@7.0.0 + '@jridgewell/sourcemap-codec@1.5.5': {} '@lezer/common@1.5.2': {} @@ -2051,6 +2575,11 @@ snapshots: dependencies: '@chevrotain/types': 11.1.2 + '@one-ini/wasm@0.1.1': {} + + '@pkgjs/parseargs@0.11.0': + optional: true + '@rollup/rollup-android-arm-eabi@4.62.2': optional: true @@ -2126,6 +2655,8 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.62.2': optional: true + '@standard-schema/spec@1.1.0': {} + '@tauri-apps/api@2.11.1': {} '@tauri-apps/cli-darwin-arm64@2.11.3': @@ -2187,6 +2718,11 @@ snapshots: dependencies: '@tauri-apps/api': 2.11.1 + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + '@types/d3-array@3.2.2': {} '@types/d3-axis@3.0.6': @@ -2304,12 +2840,21 @@ snapshots: '@types/d3-transition': 3.0.9 '@types/d3-zoom': 3.0.8 + '@types/deep-eql@4.0.2': {} + '@types/esrecurse@4.3.1': {} '@types/estree@1.0.9': {} '@types/geojson@7946.0.16': {} + '@types/jsdom@28.0.3': + dependencies: + '@types/node': 26.1.1 + '@types/tough-cookie': 4.0.5 + parse5: 8.0.1 + undici-types: 7.28.0 + '@types/json-schema@7.0.15': {} '@types/katex@0.16.8': {} @@ -2327,6 +2872,12 @@ snapshots: '@types/mdurl@2.0.0': {} + '@types/node@26.1.1': + dependencies: + undici-types: 8.3.0 + + '@types/tough-cookie@4.0.5': {} + '@types/trusted-types@2.0.7': optional: true @@ -2426,11 +2977,52 @@ snapshots: d3-selection: 3.0.0 d3-transition: 3.0.1(d3-selection@3.0.0) - '@vitejs/plugin-vue@5.2.4(vite@6.4.3(yaml@2.9.0))(vue@3.5.38(typescript@5.9.3))': + '@vitejs/plugin-vue@5.2.4(vite@6.4.3(@types/node@26.1.1)(yaml@2.9.0))(vue@3.5.38(typescript@5.9.3))': dependencies: - vite: 6.4.3(yaml@2.9.0) + vite: 6.4.3(@types/node@26.1.1)(yaml@2.9.0) vue: 3.5.38(typescript@5.9.3) + '@vitest/expect@4.1.10': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.1.10 + '@vitest/utils': 4.1.10 + chai: 6.2.2 + tinyrainbow: 3.1.0 + + '@vitest/mocker@4.1.10(vite@6.4.3(@types/node@26.1.1)(yaml@2.9.0))': + dependencies: + '@vitest/spy': 4.1.10 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 6.4.3(@types/node@26.1.1)(yaml@2.9.0) + + '@vitest/pretty-format@4.1.10': + dependencies: + tinyrainbow: 3.1.0 + + '@vitest/runner@4.1.10': + dependencies: + '@vitest/utils': 4.1.10 + pathe: 2.0.3 + + '@vitest/snapshot@4.1.10': + dependencies: + '@vitest/pretty-format': 4.1.10 + '@vitest/utils': 4.1.10 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@4.1.10': {} + + '@vitest/utils@4.1.10': + dependencies: + '@vitest/pretty-format': 4.1.10 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.0 + '@volar/language-core@2.4.15': dependencies: '@volar/source-map': 2.4.15 @@ -2535,6 +3127,17 @@ snapshots: '@vue/shared@3.5.38': {} + '@vue/test-utils@2.4.11(@vue/compiler-dom@3.5.38)(@vue/server-renderer@3.5.38(vue@3.5.38(typescript@5.9.3)))(vue@3.5.38(typescript@5.9.3))': + dependencies: + '@vue/compiler-dom': 3.5.38 + js-beautify: 1.15.4 + vue: 3.5.38(typescript@5.9.3) + vue-component-type-helpers: 3.3.8 + optionalDependencies: + '@vue/server-renderer': 3.5.38(vue@3.5.38(typescript@5.9.3)) + + abbrev@2.0.0: {} + acorn-jsx@5.3.2(acorn@8.17.0): dependencies: acorn: 8.17.0 @@ -2550,12 +3153,28 @@ snapshots: alien-signals@1.0.13: {} + ansi-regex@5.0.1: {} + + ansi-regex@6.2.2: {} + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + ansi-styles@6.2.3: {} + argparse@2.0.1: {} + assertion-error@2.0.1: {} + balanced-match@1.0.2: {} balanced-match@4.0.4: {} + bidi-js@1.0.3: + dependencies: + require-from-string: 2.0.2 + birpc@2.9.0: {} boolbase@1.0.0: {} @@ -2568,6 +3187,8 @@ snapshots: dependencies: balanced-match: 4.0.4 + chai@6.2.2: {} + codemirror@6.0.2: dependencies: '@codemirror/autocomplete': 6.20.3 @@ -2578,10 +3199,25 @@ snapshots: '@codemirror/state': 6.7.0 '@codemirror/view': 6.43.2 + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + commander@10.0.1: {} + commander@7.2.0: {} commander@8.3.0: {} + config-chain@1.1.13: + dependencies: + ini: 1.3.8 + proto-list: 1.2.4 + + convert-source-map@2.0.0: {} + copy-anything@4.0.5: dependencies: is-what: 5.5.0 @@ -2602,6 +3238,11 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 + css-tree@3.2.1: + dependencies: + mdn-data: 2.27.1 + source-map-js: 1.2.1 + cssesc@3.0.0: {} csstype@3.2.3: {} @@ -2790,6 +3431,13 @@ snapshots: d3: 7.9.0 lodash-es: 4.18.1 + data-urls@7.0.0: + dependencies: + whatwg-mimetype: 5.0.0 + whatwg-url: 16.0.1 + transitivePeerDependencies: + - '@noble/hashes' + dayjs@1.11.21: {} de-indent@1.0.2: {} @@ -2798,6 +3446,8 @@ snapshots: dependencies: ms: 2.1.3 + decimal.js@10.6.0: {} + deep-is@0.1.4: {} delaunator@5.1.0: @@ -2808,10 +3458,27 @@ snapshots: optionalDependencies: '@types/trusted-types': 2.0.7 + eastasianwidth@0.2.0: {} + + editorconfig@1.0.7: + dependencies: + '@one-ini/wasm': 0.1.1 + commander: 10.0.1 + minimatch: 9.0.9 + semver: 7.8.5 + + emoji-regex@8.0.0: {} + + emoji-regex@9.2.2: {} + entities@4.5.0: {} entities@7.0.1: {} + entities@8.0.0: {} + + es-module-lexer@2.3.1: {} + es-toolkit@1.48.1: {} esbuild@0.25.12: @@ -2922,8 +3589,14 @@ snapshots: estree-walker@2.0.2: {} + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + esutils@2.0.3: {} + expect-type@1.4.0: {} + fast-deep-equal@3.1.3: {} fast-json-stable-stringify@2.1.0: {} @@ -2950,6 +3623,11 @@ snapshots: flatted@3.4.2: {} + foreground-child@3.3.1: + dependencies: + cross-spawn: 7.0.6 + signal-exit: 4.1.0 + fsevents@2.3.3: optional: true @@ -2957,6 +3635,15 @@ snapshots: dependencies: is-glob: 4.0.3 + glob@10.5.0: + dependencies: + foreground-child: 3.3.1 + jackspeak: 3.4.3 + minimatch: 9.0.9 + minipass: 7.1.3 + package-json-from-dist: 1.0.1 + path-scurry: 1.11.1 + hachure-fill@0.5.2: {} he@1.2.0: {} @@ -2965,6 +3652,12 @@ snapshots: hookable@5.5.3: {} + html-encoding-sniffer@6.0.0: + dependencies: + '@exodus/bytes': 1.15.1 + transitivePeerDependencies: + - '@noble/hashes' + iconv-lite@0.6.3: dependencies: safer-buffer: 2.1.2 @@ -2977,20 +3670,68 @@ snapshots: imurmurhash@0.1.4: {} + ini@1.3.8: {} + internmap@1.0.1: {} internmap@2.0.3: {} is-extglob@2.1.1: {} + is-fullwidth-code-point@3.0.0: {} + is-glob@4.0.3: dependencies: is-extglob: 2.1.1 + is-potential-custom-element-name@1.0.1: {} + is-what@5.5.0: {} isexe@2.0.0: {} + jackspeak@3.4.3: + dependencies: + '@isaacs/cliui': 8.0.2 + optionalDependencies: + '@pkgjs/parseargs': 0.11.0 + + js-beautify@1.15.4: + dependencies: + config-chain: 1.1.13 + editorconfig: 1.0.7 + glob: 10.5.0 + js-cookie: 3.0.8 + nopt: 7.2.1 + + js-cookie@3.0.8: {} + + jsdom@29.1.1: + dependencies: + '@asamuzakjp/css-color': 5.1.11 + '@asamuzakjp/dom-selector': 7.1.1 + '@bramus/specificity': 2.4.2 + '@csstools/css-syntax-patches-for-csstree': 1.1.7(css-tree@3.2.1) + '@exodus/bytes': 1.15.1 + css-tree: 3.2.1 + data-urls: 7.0.0 + decimal.js: 10.6.0 + html-encoding-sniffer: 6.0.0 + is-potential-custom-element-name: 1.0.1 + lru-cache: 11.5.2 + parse5: 8.0.1 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 6.0.2 + undici: 7.28.0 + w3c-xmlserializer: 5.0.0 + webidl-conversions: 8.0.1 + whatwg-mimetype: 5.0.0 + whatwg-url: 16.0.1 + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - '@noble/hashes' + json-buffer@3.0.1: {} json-schema-traverse@0.4.1: {} @@ -3030,6 +3771,10 @@ snapshots: lodash-es@4.18.1: {} + lru-cache@10.4.3: {} + + lru-cache@11.5.2: {} + magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -3056,6 +3801,8 @@ snapshots: marked@16.4.2: {} + mdn-data@2.27.1: {} + mdurl@2.0.0: {} mermaid@11.15.0: @@ -3090,6 +3837,8 @@ snapshots: dependencies: brace-expansion: 2.1.1 + minipass@7.1.3: {} + mitt@3.0.1: {} ms@2.1.3: {} @@ -3100,10 +3849,16 @@ snapshots: natural-compare@1.4.0: {} + nopt@7.2.1: + dependencies: + abbrev: 2.0.0 + nth-check@2.1.1: dependencies: boolbase: 1.0.0 + obug@2.1.4: {} + optionator@0.9.4: dependencies: deep-is: 0.1.4 @@ -3121,8 +3876,14 @@ snapshots: dependencies: p-limit: 3.1.0 + package-json-from-dist@1.0.1: {} + package-manager-detector@1.6.0: {} + parse5@8.0.1: + dependencies: + entities: 8.0.0 + path-browserify@1.0.1: {} path-data-parser@0.1.0: {} @@ -3131,6 +3892,13 @@ snapshots: path-key@3.1.1: {} + path-scurry@1.11.1: + dependencies: + lru-cache: 10.4.3 + minipass: 7.1.3 + + pathe@2.0.3: {} + perfect-debounce@1.0.0: {} picocolors@1.1.1: {} @@ -3166,10 +3934,14 @@ snapshots: prettier@3.8.4: {} + proto-list@1.2.4: {} + punycode.js@2.3.1: {} punycode@2.3.1: {} + require-from-string@2.0.2: {} + rfdc@1.4.1: {} robust-predicates@3.0.3: {} @@ -3216,6 +3988,10 @@ snapshots: safer-buffer@2.1.2: {} + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 + semver@7.8.5: {} shebang-command@2.0.0: @@ -3224,10 +4000,38 @@ snapshots: shebang-regex@3.0.0: {} + siginfo@2.0.0: {} + + signal-exit@4.1.0: {} + source-map-js@1.2.1: {} speakingurl@14.0.1: {} + stackback@0.0.2: {} + + std-env@4.2.0: {} + + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + string-width@5.1.2: + dependencies: + eastasianwidth: 0.2.0 + emoji-regex: 9.2.2 + strip-ansi: 7.2.0 + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + + strip-ansi@7.2.0: + dependencies: + ansi-regex: 6.2.2 + style-mod@4.1.3: {} stylis@4.4.0: {} @@ -3236,6 +4040,10 @@ snapshots: dependencies: copy-anything: 4.0.5 + symbol-tree@3.2.4: {} + + tinybench@2.9.0: {} + tinyexec@1.2.4: {} tinyglobby@0.2.17: @@ -3243,6 +4051,22 @@ snapshots: fdir: 6.5.0(picomatch@4.0.4) picomatch: 4.0.4 + tinyrainbow@3.1.0: {} + + tldts-core@7.4.9: {} + + tldts@7.4.9: + dependencies: + tldts-core: 7.4.9 + + tough-cookie@6.0.2: + dependencies: + tldts: 7.4.9 + + tr46@6.0.0: + dependencies: + punycode: 2.3.1 + ts-api-utils@2.5.0(typescript@5.9.3): dependencies: typescript: 5.9.3 @@ -3257,6 +4081,12 @@ snapshots: uc.micro@2.1.0: {} + undici-types@7.28.0: {} + + undici-types@8.3.0: {} + + undici@7.28.0: {} + uri-js@4.4.1: dependencies: punycode: 2.3.1 @@ -3265,7 +4095,7 @@ snapshots: uuid@14.0.1: {} - vite@6.4.3(yaml@2.9.0): + vite@6.4.3(@types/node@26.1.1)(yaml@2.9.0): dependencies: esbuild: 0.25.12 fdir: 6.5.0(picomatch@4.0.4) @@ -3274,11 +4104,42 @@ snapshots: rollup: 4.62.2 tinyglobby: 0.2.17 optionalDependencies: + '@types/node': 26.1.1 fsevents: 2.3.3 yaml: 2.9.0 + vitest@4.1.10(@types/node@26.1.1)(jsdom@29.1.1)(vite@6.4.3(@types/node@26.1.1)(yaml@2.9.0)): + dependencies: + '@vitest/expect': 4.1.10 + '@vitest/mocker': 4.1.10(vite@6.4.3(@types/node@26.1.1)(yaml@2.9.0)) + '@vitest/pretty-format': 4.1.10 + '@vitest/runner': 4.1.10 + '@vitest/snapshot': 4.1.10 + '@vitest/spy': 4.1.10 + '@vitest/utils': 4.1.10 + es-module-lexer: 2.3.1 + expect-type: 1.4.0 + magic-string: 0.30.21 + obug: 2.1.4 + pathe: 2.0.3 + picomatch: 4.0.4 + std-env: 4.2.0 + tinybench: 2.9.0 + tinyexec: 1.2.4 + tinyglobby: 0.2.17 + tinyrainbow: 3.1.0 + vite: 6.4.3(@types/node@26.1.1)(yaml@2.9.0) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 26.1.1 + jsdom: 29.1.1 + transitivePeerDependencies: + - msw + vscode-uri@3.1.0: {} + vue-component-type-helpers@3.3.8: {} + vue-eslint-parser@10.4.1(eslint@10.5.0): dependencies: debug: 4.4.3 @@ -3317,14 +4178,51 @@ snapshots: w3c-keyname@2.2.8: {} + w3c-xmlserializer@5.0.0: + dependencies: + xml-name-validator: 5.0.0 + + webidl-conversions@8.0.1: {} + + whatwg-mimetype@5.0.0: {} + + whatwg-url@16.0.1: + dependencies: + '@exodus/bytes': 1.15.1 + tr46: 6.0.0 + webidl-conversions: 8.0.1 + transitivePeerDependencies: + - '@noble/hashes' + which@2.0.2: dependencies: isexe: 2.0.0 + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + word-wrap@1.2.5: {} + wrap-ansi@7.0.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + wrap-ansi@8.1.0: + dependencies: + ansi-styles: 6.2.3 + string-width: 5.1.2 + strip-ansi: 7.2.0 + xml-name-validator@4.0.0: {} + xml-name-validator@5.0.0: {} + + xmlchars@2.2.0: {} + yaml@2.9.0: {} yocto-queue@0.1.0: {} diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 433fb74..77b4730 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -534,6 +534,15 @@ dependencies = [ "libc", ] +[[package]] +name = "core_maths" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77745e017f5edba1a9c1d854f6f3a52dac8a12dd5af5d2f54aecf61e43d80d30" +dependencies = [ + "libm", +] + [[package]] name = "cpufeatures" version = "0.2.17" @@ -992,6 +1001,29 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" +[[package]] +name = "fontconfig-parser" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbc773e24e02d4ddd8395fd30dc147524273a83e54e0f312d986ea30de5f5646" +dependencies = [ + "roxmltree", +] + +[[package]] +name = "fontdb" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "457e789b3d1202543297a350643cf459f836cade38934e7a4cf6a39e7cde2905" +dependencies = [ + "fontconfig-parser", + "log", + "memmap2", + "slotmap", + "tinyvec", + "ttf-parser", +] + [[package]] name = "foreign-types" version = "0.5.0" @@ -1952,6 +1984,12 @@ dependencies = [ "winapi", ] +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + [[package]] name = "libredox" version = "0.1.17" @@ -2013,6 +2051,7 @@ dependencies = [ "tauri-plugin-fs", "tauri-plugin-opener", "tauri-plugin-single-instance", + "tauri-plugin-system-fonts", "tauri-plugin-window-state", "walkdir", "window-vibrancy", @@ -2024,6 +2063,15 @@ version = "2.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" +[[package]] +name = "memmap2" +version = "0.9.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0" +dependencies = [ + "libc", +] + [[package]] name = "memoffset" version = "0.9.1" @@ -2854,6 +2902,12 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "roxmltree" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97" + [[package]] name = "rustc-hash" version = "2.1.2" @@ -3186,6 +3240,15 @@ version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" +[[package]] +name = "slotmap" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038" +dependencies = [ + "version_check", +] + [[package]] name = "smallvec" version = "1.15.2" @@ -3617,6 +3680,19 @@ dependencies = [ "zbus", ] +[[package]] +name = "tauri-plugin-system-fonts" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ad16fdc3a5ea7de468670338d3b5fe7f1b560a8f72445e8612f4b4ef0fa252c" +dependencies = [ + "fontdb", + "serde", + "tauri", + "tauri-plugin", + "thiserror 2.0.18", +] + [[package]] name = "tauri-plugin-window-state" version = "2.4.1" @@ -4101,6 +4177,15 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "ttf-parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" +dependencies = [ + "core_maths", +] + [[package]] name = "typeid" version = "1.0.3" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 0a74f8d..e510ef9 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -23,6 +23,7 @@ tauri-plugin-dialog = "2" tauri-plugin-opener = "2" tauri-plugin-single-instance = "2" tauri-plugin-window-state = "2" +tauri-plugin-system-fonts = "2" window-vibrancy = "0.6" serde = { version = "1", features = ["derive"] } serde_json = "1" diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index ac7f381..6992763 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -92,7 +92,7 @@ fn list_md_files(root: String) -> Result, String> { { let path = entry.path(); if path.is_file() && is_markdown_file(path) { - let meta = match entry.metadata() { + let meta = match entry.metadata() { Ok(m) => m, Err(_) => continue, }; @@ -205,6 +205,10 @@ fn search_in_files( if q.is_empty() { return Ok(Vec::new()); } + // 单字符查询匹配过多结果,限制长度避免无意义的全量扫描 + if q.len() < 2 { + return Err("搜索词过短,请输入至少 2 个字符".into()); + } let needle = if case_sensitive { q.to_string() } else { @@ -212,6 +216,8 @@ fn search_in_files( }; let limit = max_results.unwrap_or(500); let mut results = Vec::new(); + // 跳过超大文件(>500KB),避免内存占用过高和响应缓慢 + const MAX_FILE_SIZE: u64 = 512 * 1024; 'outer: for entry in WalkDir::new(&root_path) .follow_links(false) @@ -226,6 +232,11 @@ fn search_in_files( if !path.is_file() || !is_markdown_file(path) { continue; } + let _meta = match entry.metadata() { + Ok(m) if m.len() > MAX_FILE_SIZE => continue, // 跳过超大文件 + Ok(m) => m, + Err(_) => continue, + }; let content = match std::fs::read_to_string(path) { Ok(c) => c, Err(_) => continue, @@ -427,6 +438,11 @@ fn register_file_associations() -> Result<(), String> { Err("File association registration is only supported on Windows".into()) } +#[tauri::command] +async fn get_system_fonts() -> Result, String> { + Ok(tauri_plugin_system_fonts::get_system_fonts().await) +} + #[tauri::command] fn set_app_theme(window: tauri::WebviewWindow, theme: String) -> Result<(), String> { let is_dark = theme == "dark"; @@ -464,6 +480,7 @@ pub fn run() { .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_window_state::Builder::default().build()) + .plugin(tauri_plugin_system_fonts::init()) .setup(|app| { use tauri::Manager; app.manage(WatcherState::default()); @@ -482,6 +499,7 @@ pub fn run() { initial_open_file, register_file_associations, set_app_theme, + get_system_fonts, check_pandoc, export_with_pandoc, pdf_utils::check_pdf_engine, @@ -499,7 +517,7 @@ pub struct PandocInfo { } fn pandoc_cmd() -> Command { - let mut cmd = Command::new("pandoc"); + let cmd = Command::new("pandoc"); #[cfg(windows)] { use std::os::windows::process::CommandExt; diff --git a/src-tauri/src/pdf_export.rs b/src-tauri/src/pdf_export.rs index 2601508..87156cf 100644 --- a/src-tauri/src/pdf_export.rs +++ b/src-tauri/src/pdf_export.rs @@ -45,16 +45,27 @@ fn format_output_diagnostics(output: &Output) -> String { ) } -fn wait_for_valid_pdf(path: &Path, timeout_ms: u64) -> std::io::Result { +/// 等待 PDF 文件达到有效大小。返回实际大小或区分类型的错误。 +enum PdfWaitResult { + Ok(u64), + FileTooSmall(u64), + Io(std::io::Error), +} + +fn wait_for_valid_pdf(path: &Path, timeout_ms: u64) -> PdfWaitResult { let start = std::time::Instant::now(); loop { match std::fs::metadata(path) { - Ok(meta) if meta.len() > 1024 => return Ok(meta.len()), - Ok(_) | Err(_) if start.elapsed().as_millis() < timeout_ms as u128 => { + Ok(meta) if meta.len() > 1024 => return PdfWaitResult::Ok(meta.len()), + Ok(meta) if start.elapsed().as_millis() >= timeout_ms as u128 => { + return PdfWaitResult::FileTooSmall(meta.len()); + } + Err(e) if start.elapsed().as_millis() >= timeout_ms as u128 => { + return PdfWaitResult::Io(e); + } + _ => { std::thread::sleep(std::time::Duration::from_millis(200)); } - Ok(meta) => return Ok(meta.len()), - Err(e) => return Err(e), } } } @@ -157,7 +168,7 @@ pub fn export_pdf_via_edge( if output.status.success() { match wait_for_valid_pdf(&temp_pdf, 10_000) { - Ok(size) if size > 1024 => { + PdfWaitResult::Ok(size) if size > 1024 => { std::fs::copy(&temp_pdf, &final_out).map_err(|e| { let _ = std::fs::remove_file(&temp_pdf); let _ = std::fs::remove_dir_all(&user_data_dir); @@ -173,16 +184,25 @@ pub fn export_pdf_via_edge( edge_path: edge.to_string_lossy().to_string(), }); } - Ok(size) => { + PdfWaitResult::FileTooSmall(size) => { + diagnostics.push(format!( + "{}\nPDF 文件已生成但过小(可能损坏): {} bytes", + detail, size + )); + } + PdfWaitResult::Io(e) => { + diagnostics.push(format!( + "{}\n读取 PDF 元数据失败: {}", + detail, e + )); + } + PdfWaitResult::Ok(size) => { + // 理论上 wait_for_valid_pdf 不会返回 ≤ 1024 的 Ok,但保留兜底 diagnostics.push(format!( - "{}\n等待 PDF 落盘后文件仍过小: {} bytes", + "{}\nPDF 文件大小异常: {} bytes", detail, size )); } - Err(_) => diagnostics.push(format!( - "{}\n等待 10 秒后 PDF 文件仍未生成", - detail - )), } } else { diagnostics.push(detail); diff --git a/src-tauri/src/pdf_utils.rs b/src-tauri/src/pdf_utils.rs index 07b9315..abd87d5 100644 --- a/src-tauri/src/pdf_utils.rs +++ b/src-tauri/src/pdf_utils.rs @@ -1,4 +1,6 @@ use std::path::{Path, PathBuf}; + +#[cfg(windows)] use std::process::Command; pub fn current_millis() -> u128 { diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index b8eafd4..66c9781 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -22,7 +22,7 @@ } ], "security": { - "csp": null, + "csp": "default-src 'self'; style-src 'self' 'unsafe-inline'; script-src 'self'; font-src 'self' data:; img-src 'self' data: blob:;", "assetProtocol": { "enable": true, "scope": { diff --git a/src/App.test.ts b/src/App.test.ts new file mode 100644 index 0000000..3169a62 --- /dev/null +++ b/src/App.test.ts @@ -0,0 +1,101 @@ +/** + * App.vue 集成测试 — 外部文件变化处理 + * + * 测试目标: + * - onFilesChanged 标记 stale 而非直接 forceReload + * - switchToTab 检测 stale 并触发 banner + * - auto-reload 白名单中的 tab 直接 reload + */ + +import { describe, it, expect, vi } from "vitest"; + +// Mock localStorage for useTabs +const mockStorage: Record = {}; +Object.defineProperty(globalThis, "localStorage", { + value: { + getItem: (key: string) => mockStorage[key] ?? null, + setItem: (key: string, value: string) => { mockStorage[key] = value; }, + removeItem: (key: string) => { delete mockStorage[key]; }, + clear: () => { for (const key in mockStorage) delete mockStorage[key]; }, + get length() { return Object.keys(mockStorage).length; }, + key: (idx: number) => Object.keys(mockStorage)[idx] ?? null, + }, +}); +import { useTabs } from "./composables/useTabs"; + + +// Mock Tauri readTextFile +vi.mock("@tauri-apps/plugin-fs", () => ({ + readTextFile: vi.fn(), + writeTextFile: vi.fn(), + exists: vi.fn(), +})); + +describe("App.vue 外部文件变化处理", () => { + describe("onFilesChanged 标记 stale", () => { + it("onFilesChanged 标记 tab.staleSince 而非直接 forceReload", async () => { + const { tabs, createTab, activeTabId } = useTabs(); + const tab = createTab("/root/test.md"); + tabs.value.push(tab); + activeTabId.value = tab.id; + + // Simulate onFilesChanged: mark stale + tab.staleSince = Date.now(); + expect(tab.staleSince).toBeGreaterThanOrEqual(0); + expect(tab.staleSince).not.toBe(null); + }); + + it("dirty tab 也被标记 stale(不走弹窗)", async () => { + const { tabs, createTab, activeTabId } = useTabs(); + const tab = createTab("/root/test.md"); + tab.isDirty = true; + tabs.value.push(tab); + activeTabId.value = tab.id; + + // Even dirty tabs are marked stale (non-blocking) + tab.staleSince = Date.now(); + expect(tab.staleSince).toBeGreaterThanOrEqual(0); + }); + }); + + describe("switchToTab 检测 stale", () => { + it("switchToTab 对 stale tab 不阻塞", async () => { + const { tabs, createTab, activeTabId, activateTab } = useTabs(); + const tab1 = createTab("/root/tab1.md"); + const tab2 = createTab("/root/tab2.md"); + tab2.staleSince = Date.now(); + tabs.value.push(tab1, tab2); + activeTabId.value = tab1.id; + + // switchToTab should complete synchronously (non-blocking) + activateTab(tab2.id); + expect(activeTabId.value).toBe(tab2.id); + }); + }); + + describe("auto-reload 白名单", () => { + it("白名单中的 tab 直接 reload 不标记 stale", async () => { + const whitelist = new Set("/root/test.md"); + const { tabs, createTab, activeTabId } = useTabs(); + const tab = createTab("/root/test.md"); + tabs.value.push(tab); + activeTabId.value = tab.id; + + // Tab is in whitelist, should not show stale banner + const shouldShowBanner = tab.staleSince !== null && !whitelist.has(tab.path); + expect(shouldShowBanner).toBe(false); + }); + + it("非白名单 tab 显示 stale banner", async () => { + const whitelist = new Set(); + const { tabs, createTab, activeTabId } = useTabs(); + const tab = createTab("/root/test.md"); + tabs.value.push(tab); + activeTabId.value = tab.id; + tab.staleSince = Date.now(); + + const shouldShowBanner = tab.staleSince !== null && !whitelist.has(tab.path); + expect(shouldShowBanner).toBe(true); + }); + }); +}); diff --git a/src/App.vue b/src/App.vue index d166409..e6113b0 100644 --- a/src/App.vue +++ b/src/App.vue @@ -1,4 +1,7 @@ + + + + diff --git a/src/components/DiffView.test.ts b/src/components/DiffView.test.ts new file mode 100644 index 0000000..7df0eaf --- /dev/null +++ b/src/components/DiffView.test.ts @@ -0,0 +1,134 @@ +/** + * DiffView 组件测试 + * + * 测试目标: + * - 渲染 diff 内容 + * - 显示新增行(绿色) + * - 显示删除行(红色) + * - 关闭时 emit close 事件 + * - 无 diff 时显示提示 + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { mount } from "@vue/test-utils"; +import { createI18n } from "vue-i18n"; +import DiffView from "./DiffView.vue"; + +const i18n = createI18n({ + locale: "zh-CN", + messages: { + "zh-CN": { + diff: { + title: "差异对比", + close: "关闭", + noChanges: "无变化", + added: "新增", + removed: "删除", + }, + }, + }, +}); + +describe("DiffView", () => { + let wrapper: ReturnType; + let mockClose: () => void; + + beforeEach(() => { + mockClose = vi.fn() as unknown as () => void; + }); + + afterEach(() => { + wrapper?.unmount(); + vi.clearAllMocks(); + }); + + function renderDiff( + oldContent: string, + newContent: string, + visible = true + ) { + wrapper = mount(DiffView, { + global: { + plugins: [i18n], + }, + props: { + oldContent, + newContent, + fileName: "test.md", + visible, + onClose: mockClose, + }, + }); + } + + describe("渲染", () => { + it("visible 为 true 时渲染 diff 容器", () => { + renderDiff("# Hello", "# Hello World"); + expect(wrapper.find(".diff-view").exists()).toBe(true); + }); + + it("visible 为 false 时不渲染 diff 容器", () => { + renderDiff("# Hello", "# Hello World", false); + expect(wrapper.find(".diff-view").exists()).toBe(false); + }); + + it("渲染标题和文件名", () => { + renderDiff("# Hello", "# Hello World"); + expect(wrapper.find(".diff-title").text()).toContain("差异对比"); + expect(wrapper.find(".diff-filename").text()).toContain("test.md"); + }); + }); + + describe("diff 输出", () => { + it("显示无变化时提示", () => { + renderDiff("# Hello", "# Hello"); + expect(wrapper.find(".diff-empty").exists()).toBe(true); + }); + + it("显示新增行(绿色)", () => { + renderDiff("# Hello\n", "# Hello\nWorld\n"); + const addedLines = wrapper.findAll(".diff-line.added"); + expect(addedLines.length).toBeGreaterThan(0); + expect(addedLines[0]?.text()).toContain("World"); + }); + + it("显示删除行(红色)", () => { + renderDiff("# Hello\nWorld\n", "# Hello\n"); + const removedLines = wrapper.findAll(".diff-line.removed"); + expect(removedLines.length).toBeGreaterThan(0); + expect(removedLines[0]?.text()).toContain("World"); + }); + + it("正确处理中间插入(不产生错误的增删行)", () => { + renderDiff("a\nb\nc\n", "a\nX\nb\nc\n"); + const added = wrapper + .findAll(".diff-line.added") + .map((el) => el.find(".diff-text").text()); + const removed = wrapper + .findAll(".diff-line.removed") + .map((el) => el.find(".diff-text").text()); + expect(added).toEqual(["X"]); + expect(removed).toEqual([]); + }); + + it("正确处理中间删除(不产生错误的增删行)", () => { + renderDiff("a\nX\nb\nc\n", "a\nb\nc\n"); + const added = wrapper + .findAll(".diff-line.added") + .map((el) => el.find(".diff-text").text()); + const removed = wrapper + .findAll(".diff-line.removed") + .map((el) => el.find(".diff-text").text()); + expect(added).toEqual([]); + expect(removed).toEqual(["X"]); + }); + }); + + describe("事件", () => { + it("点击关闭按钮 emit onClose", async () => { + renderDiff("# Hello", "# Hello World"); + await wrapper.find("[data-action='close']").trigger("click"); + expect(mockClose).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/src/components/DiffView.vue b/src/components/DiffView.vue new file mode 100644 index 0000000..cc9fe28 --- /dev/null +++ b/src/components/DiffView.vue @@ -0,0 +1,248 @@ + + + + + diff --git a/src/components/FileTree.test.ts b/src/components/FileTree.test.ts new file mode 100644 index 0000000..712b1f4 --- /dev/null +++ b/src/components/FileTree.test.ts @@ -0,0 +1,235 @@ +/** + * FileTree 组件测试 + * + * 测试目标: + * - currentPath 变化时自动展开父目录 + * - 当前文件在树中高亮 (.active) + * - 切换 tab 时 currentPath 更新触发重新展开定位 + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { mount, type VueWrapper } from "@vue/test-utils"; +import FileTree from "../components/FileTree.vue"; +import type { TreeNode } from "../composables/useFileTree"; + +// ─── Mock TreeNode 工厂 ─────────────────────────────────────── + +function makeDir(name: string, path: string, children?: TreeNode[]): TreeNode { + return { name, path, isDir: true, children }; +} + +function makeFile(name: string, path: string): TreeNode { + return { + name, + path, + isDir: false, + file: { path, name, rel_path: "", size: 0, modified_ms: 0 }, + }; +} + +// 构建一个嵌套目录树: +// root/ +// docs/ +// guide.md +// advanced/ +// plugin.md +// README.md + +const sampleNodes: TreeNode[] = [ + makeDir("docs", "/root/docs", [ + makeFile("guide.md", "/root/docs/guide.md"), + makeDir("advanced", "/root/docs/advanced", [ + makeFile("plugin.md", "/root/docs/advanced/plugin.md"), + ]), + ]), + makeFile("README.md", "/root/README.md"), +]; + +// 目录行是否处于折叠状态(.row.dir 上的 is-collapsed class) +function isCollapsed(wrapper: VueWrapper, idx: number): boolean { + return Boolean(wrapper.findAll(".dir")[idx]?.classes("is-collapsed")); +} + +// ─── 测试 ───────────────────────────────────────────────────── + +describe("FileTree", () => { + let wrapper: VueWrapper; + let mockScrollIntoView: ReturnType; + + function renderTree(nodes: TreeNode[], currentPath = "") { + const w = mount(FileTree, { + props: { nodes, currentPath, scrollContainer: mockScrollContainer }, + attachTo: document.body, + }); + mockScrollContainer.appendChild(w.element); + return w; + } + + let mockScrollContainer: HTMLElement; + + beforeEach(() => { + mockScrollIntoView = vi.fn(); + (window.HTMLElement.prototype as any).scrollIntoView = mockScrollIntoView; + vi.clearAllMocks(); + mockScrollContainer = document.createElement("div"); + mockScrollContainer.className = "tree-scroll"; + document.body.appendChild(mockScrollContainer); + }); + + afterEach(() => { + wrapper?.unmount(); + mockScrollContainer.remove(); + delete (window.HTMLElement.prototype as any).scrollIntoView; + }); + + // ─── 基础渲染 ──────────────────────────────────────────────── + + describe("rendering", () => { + it("renders root-level files when currentPath is empty", () => { + wrapper = renderTree(sampleNodes, ""); + expect(wrapper.find(".tree.root").exists()).toBe(true); + expect(wrapper.findAll(".file").length).toBe(3); + }); + + it("renders nested directories with collapsed state", () => { + wrapper = renderTree(sampleNodes, ""); + expect(wrapper.findAll(".dir").length).toBe(2); + }); + }); + + // ─── currentPath 高亮 ──────────────────────────────────────── + + describe("active highlighting", () => { + it("marks the current file as active", () => { + wrapper = renderTree(sampleNodes, "/root/docs/guide.md"); + const active = wrapper.find(".row.file.active"); + expect(active.exists()).toBe(true); + expect(active.find(".name").text()).toBe("guide.md"); + }); + + it("does not mark non-current files as active", () => { + wrapper = renderTree(sampleNodes, "/root/docs/guide.md"); + const readmeFile = wrapper + .findAll(".row.file") + .find((el) => el.find(".name").text() === "README.md"); + expect(readmeFile?.classes("active")).toBe(false); + }); + }); + + // ─── 核心:currentPath 变化时自动展开父目录 ─────────────────── + + describe("auto-expand parent directories on currentPath change", () => { + it("auto-expands parent directories when currentPath is set to a deeply nested file", async () => { + wrapper = renderTree(sampleNodes, ""); + expect(wrapper.find(".row.file.active").exists()).toBe(false); + + await wrapper.setProps({ currentPath: "/root/docs/advanced/plugin.md" }); + await wrapper.vm.$nextTick(); + await new Promise((r) => setTimeout(r, 100)); + + // 1. 目标文件存在且高亮 + const active = wrapper.find(".row.file.active"); + expect(active.exists()).toBe(true); + expect(active.find(".name").text()).toBe("plugin.md"); + + // 2. "docs" 目录已展开 + expect(isCollapsed(wrapper, 0)).toBe(false); + + // 3. "advanced" 目录已展开 + expect(isCollapsed(wrapper, 1)).toBe(false); + + // 4. scrollIntoView 被调用(定位目标文件) + expect(mockScrollIntoView).toHaveBeenCalled(); + }); + + it("auto-expands only necessary directories (not all directories)", async () => { + wrapper = renderTree(sampleNodes, ""); + await wrapper.setProps({ currentPath: "/root/docs/guide.md" }); + await wrapper.vm.$nextTick(); + + // "docs" 目录展开 + expect(isCollapsed(wrapper, 0)).toBe(false); + + // "advanced" 目录不应展开 + expect(isCollapsed(wrapper, 1)).toBe(true); + }); + + it("works for root-level files without expanding any directory", async () => { + wrapper = renderTree(sampleNodes, ""); + await wrapper.setProps({ currentPath: "/root/README.md" }); + await wrapper.vm.$nextTick(); + await new Promise((r) => setTimeout(r, 100)); + + const active = wrapper.find(".row.file.active"); + expect(active.exists()).toBe(true); + expect(active.find(".name").text()).toBe("README.md"); + expect(mockScrollIntoView).toHaveBeenCalled(); + }); + }); + + // ─── Tab 切换场景 ──────────────────────────────────────────── + + describe("tab switching scenario", () => { + it("re-expands directories and scrolls when switching between tabs", async () => { + wrapper = renderTree(sampleNodes, "/root/docs/guide.md"); + await wrapper.vm.$nextTick(); + await new Promise((r) => setTimeout(r, 100)); + vi.clearAllMocks(); + + expect(wrapper.find(".row.file.active").find(".name").text()).toBe( + "guide.md" + ); + expect(isCollapsed(wrapper, 0)).toBe(false); + + await wrapper.setProps({ currentPath: "/root/docs/advanced/plugin.md" }); + await wrapper.vm.$nextTick(); + await new Promise((r) => setTimeout(r, 100)); + + expect(wrapper.find(".row.file.active").find(".name").text()).toBe( + "plugin.md" + ); + expect(isCollapsed(wrapper, 1)).toBe(false); + expect(mockScrollIntoView).toHaveBeenCalled(); + }); + + it("collapses previously expanded directories when switching to a different branch", async () => { + wrapper = renderTree(sampleNodes, "/root/docs/advanced/plugin.md"); + await wrapper.vm.$nextTick(); + vi.clearAllMocks(); + + expect(isCollapsed(wrapper, 0)).toBe(false); + expect(isCollapsed(wrapper, 1)).toBe(false); + + await wrapper.setProps({ currentPath: "/root/docs/guide.md" }); + await wrapper.vm.$nextTick(); + + expect(isCollapsed(wrapper, 0)).toBe(false); + expect(isCollapsed(wrapper, 1)).toBe(true); + }); + }); + + // ─── 边缘情况 ──────────────────────────────────────────────── + + describe("edge cases", () => { + it("does not crash when currentPath is empty", () => { + wrapper = renderTree(sampleNodes, ""); + expect(wrapper.find(".row.file.active").exists()).toBe(false); + }); + + it("does not crash when currentPath points to a non-existent file", () => { + wrapper = renderTree(sampleNodes, "/nonexistent/file.md"); + expect(wrapper.find(".row.file.active").exists()).toBe(false); + }); + + it("handles flat file list without directories", () => { + const flatNodes: TreeNode[] = [ + makeFile("a.md", "/root/a.md"), + makeFile("b.md", "/root/b.md"), + ]; + wrapper = renderTree(flatNodes, "/root/a.md"); + expect(wrapper.find(".row.file.active").find(".name").text()).toBe( + "a.md" + ); + }); + }); +}); diff --git a/src/components/FileTree.vue b/src/components/FileTree.vue index 16c0518..283f8da 100644 --- a/src/components/FileTree.vue +++ b/src/components/FileTree.vue @@ -1,12 +1,19 @@ diff --git a/src/components/MarkdownEditor.vue b/src/components/MarkdownEditor.vue index 1149bd0..284d415 100644 --- a/src/components/MarkdownEditor.vue +++ b/src/components/MarkdownEditor.vue @@ -42,11 +42,11 @@ const host = ref(null); const searchCounter = ref({ visible: false, current: 0, total: 0 }); let view: EditorView | null = null; let searchCounterTimer: number | null = null; -let lastSearchKey = ""; const themeCompartment = new Compartment(); const editableCompartment = new Compartment(); const keymapCompartment = new Compartment(); +const fontFamilyCompartment = new Compartment(); const { getBinding, toCodeMirror, overrides } = useShortcuts(); const replacePanelTheme = EditorView.baseTheme({ ".cm-panel.cm-search [name=replace]": { @@ -64,8 +64,6 @@ const editorBaseTheme = EditorView.theme({ color: "var(--fg)", }, ".cm-scroller": { - fontFamily: - 'ui-monospace, SFMono-Regular, "JetBrains Mono", "Cascadia Code", Consolas, monospace', fontSize: "var(--editor-font-size, 14px)", lineHeight: "1.65", }, @@ -124,29 +122,11 @@ function editableExtension() { return EditorView.editable.of(!props.readonly); } -function searchKey(): string { - if (!view) return ""; - if (!searchPanelOpen(view.state)) return "closed"; - const query = getSearchQuery(view.state); - return JSON.stringify({ - search: query.search, - caseSensitive: query.caseSensitive, - literal: query.literal, - regexp: query.regexp, - wholeWord: query.wholeWord, - valid: query.valid, - docLength: view.state.doc.length, - }); -} - function updateSearchCounter() { if (!view || !searchPanelOpen(view.state)) { - lastSearchKey = "closed"; searchCounter.value = { visible: false, current: 0, total: 0 }; return; } - const key = searchKey(); - lastSearchKey = key; const query = getSearchQuery(view.state); if (!query.valid || !query.search) { searchCounter.value = { visible: true, current: 0, total: 0 }; @@ -173,12 +153,8 @@ function updateSearchCounter() { }; } -function scheduleSearchCounterUpdate(force = false) { +function scheduleSearchCounterUpdate() { if (!view) return; - if (!force) { - const nextKey = searchKey(); - if (nextKey === lastSearchKey && !view.state.selection.main.empty) return; - } if (searchCounterTimer !== null) window.clearTimeout(searchCounterTimer); searchCounterTimer = window.setTimeout(() => { searchCounterTimer = null; @@ -244,6 +220,24 @@ function buildKeymap() { ); } +function getEditorFontFamily(): string { + return ( + // eslint-disable-next-line no-undef + getComputedStyle(document.documentElement) + .getPropertyValue("--editor-font-family") + .trim() || + 'ui-monospace, SFMono-Regular, "JetBrains Mono", "Cascadia Code", Consolas, monospace' + ); +} + +function fontFamilyExtension(): Extension { + return EditorView.theme({ + ".cm-scroller": { + fontFamily: getEditorFontFamily(), + }, + }); +} + function createEditor() { if (!host.value) return; view = new EditorView({ @@ -256,6 +250,7 @@ function createEditor() { markdown(), search({ top: true }), replacePanelTheme, + fontFamilyCompartment.of(fontFamilyExtension()), keymapCompartment.of(buildKeymap()), keymap.of([indentWithTab, ...searchKeymap]), themeCompartment.of(themeExtension()), @@ -270,9 +265,7 @@ function createEditor() { update.selectionSet || update.transactions.length ) { - scheduleSearchCounterUpdate( - update.docChanged || update.selectionSet - ); + scheduleSearchCounterUpdate(); } }), ], @@ -411,10 +404,22 @@ async function insertPastedImage(file: File, pos: number) { onMounted(() => { createEditor(); host.value?.addEventListener("paste", handlePaste, true); + + // Sync editor font family when the CSS variable changes + // eslint-disable-next-line no-undef + const fontObserver = new MutationObserver(() => { + if (view) { + view.dispatch({ effects: fontFamilyCompartment.reconfigure(fontFamilyExtension()) }); + } + }); + fontObserver.observe(document.documentElement, { attributes: true, attributeFilter: ["style"] }); + // Store for cleanup + (host.value as any).__fontObserver = fontObserver; }); onBeforeUnmount(() => { host.value?.removeEventListener("paste", handlePaste, true); + (host.value as any).__fontObserver?.disconnect(); view?.destroy(); view = null; if (searchCounterTimer !== null) { diff --git a/src/components/SearchPanel.vue b/src/components/SearchPanel.vue index d227aec..dae5361 100644 --- a/src/components/SearchPanel.vue +++ b/src/components/SearchPanel.vue @@ -123,9 +123,18 @@ watch( @click="(caseSensitive = !caseSensitive), run()" :title="t('find.caseSensitive')" > - Aa + + + + + + + -
{{ t("search.searching") }} diff --git a/src/components/SettingsDialog.vue b/src/components/SettingsDialog.vue index 7f4b57b..6e77b00 100644 --- a/src/components/SettingsDialog.vue +++ b/src/components/SettingsDialog.vue @@ -154,19 +154,42 @@ const emit = defineEmits<{ (e: "close"): void }>(); const showShortcuts = ref(false); watch( () => props.visible, - (v) => { + async (v) => { if (!v) showShortcuts.value = false; + else await loadFonts(); } ); +const systemFonts = ref<{ name: string }[]>([]); + +async function loadFonts() { + if (systemFonts.value.length > 0) return; + systemFonts.value = await loadSystemFonts(); +} + +const allFontOptions = computed(() => { + const opts = fontOptions.value as Array<{ value: string; label: string }>; + return [...opts, ...systemFonts.value.map((f) => ({ value: f.name, label: f.name }))]; +}); + +const allEditorFontOptions = computed(() => { + const opts = editorFontOptions.value as Array<{ value: string; label: string }>; + return [...opts, ...systemFonts.value.map((f) => ({ value: f.name, label: f.name }))]; +}); + const { settings, fontOptions, + editorFontOptions, + loadSystemFonts, setFontSize, setLineHeight, setMaxWidth, setFontFamily, + setFontCustom, setEditorFontSize, + setEditorFontFamily, + setEditorFontCustom, setTocPosition, reset, } = useReadingSettings(); @@ -201,8 +224,8 @@ async function registerAssociations() { - - {{ opt.label }} - - + + +
+ + +
+ + +
+ +
+ +
+ +
+
+ +
+ +
@@ -455,6 +527,42 @@ select { color: var(--fg); border: 1px solid var(--border); border-radius: 4px; + font-size: 13px; + outline: none; + max-height: 200px; +} +.font-select-wrapper { + grid-column: 2 / span 2; + position: relative; + max-width: 260px; +} +.font-select-wrapper select { + width: 100%; + padding: 4px 8px; + background: var(--bg-btn); + color: var(--fg); + border: 1px solid var(--border); + border-radius: 4px; + font-size: 13px; + outline: none; + appearance: none; + -webkit-appearance: none; +} +.font-select-wrapper select:focus { + border-color: var(--link); +} +.text-input { + grid-column: 2 / span 2; + padding: 4px 8px; + background: var(--bg-btn); + color: var(--fg); + border: 1px solid var(--border); + border-radius: 4px; + font-size: 13px; + outline: none; +} +.text-input:focus { + border-color: var(--link); } .association { display: grid; diff --git a/src/components/TabBar.test.ts b/src/components/TabBar.test.ts new file mode 100644 index 0000000..64922db --- /dev/null +++ b/src/components/TabBar.test.ts @@ -0,0 +1,140 @@ +/** + * TabBar 组件测试 + * + * 测试目标: + * - 渲染 tab 列表 + * - stale tab 显示 (!) 警告标记 + * - 非 stale tab 不显示 (!) 标记 + * - 当前活动 tab 正确高亮 + * - 未修改 tab 显示干净状态 + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { mount } from "@vue/test-utils"; +import { createI18n } from "vue-i18n"; +import TabBar from "./TabBar.vue"; +import type { Tab } from "../composables/useTabs"; + +const i18n = createI18n({ + locale: "zh-CN", + messages: { + "zh-CN": { + app: { noFile: "未打开文件" }, + tabs: { close: "关闭标签" }, + }, + }, +}); + +function makeTab( + id = "tab-1", + path = "/root/test.md", + isDirty = false, + staleSince: number | null = null +): Tab { + return { + id, + path, + content: "# Hello", + draftContent: "# Hello", + isDirty, + isEditing: false, + headings: [], + scrollTop: 0, + pendingHash: "", + pendingScrollTop: 0, + pendingSourceLine: 0, + staleSince, + }; +} + +describe("TabBar", () => { + let wrapper: ReturnType; + let mockActivate: (id: string) => void; + let mockClose: (id: string) => void; + + beforeEach(() => { + mockActivate = vi.fn() as any; + mockClose = vi.fn() as any; + }); + + afterEach(() => { + wrapper?.unmount(); + vi.clearAllMocks(); + }); + + function renderTabBar( + tabs: Tab[], + activeTabId = "tab-1", + autoReload: string[] = [] + ) { + wrapper = mount(TabBar, { + global: { + plugins: [i18n], + }, + props: { + tabs, + activeTabId, + autoReload, + "onActivate": mockActivate, + "onClose": mockClose, + }, + }); + } + + describe("基础渲染", () => { + it("渲染 tab 列表", () => { + const tabs = [makeTab("tab-1"), makeTab("tab-2", "/root/other.md")]; + renderTabBar(tabs); + expect(wrapper.findAll(".tab-item").length).toBe(2); + }); + + it("当前活动 tab 正确高亮", () => { + const tabs = [makeTab("tab-1"), makeTab("tab-2", "/root/other.md")]; + renderTabBar(tabs, "tab-2"); + expect(wrapper.find(".tab-item.active").find(".name").text()).toBe("other.md"); + }); + }); + + describe("stale 警告标记", () => { + it("stale tab 显示 (!) 警告标记", () => { + const tabs = [makeTab("tab-1", "/root/test.md", false, Date.now())]; + renderTabBar(tabs); + expect(wrapper.find(".stale-warning").exists()).toBe(true); + }); + + it("非 stale tab 不显示 (!) 标记", () => { + const tabs = [makeTab("tab-1", "/root/test.md", false, null)]; + renderTabBar(tabs); + expect(wrapper.find(".stale-warning").exists()).toBe(false); + }); + + it("dirty tab 不显示 stale 标记", () => { + const tabs = [makeTab("tab-1", "/root/test.md", true, Date.now())]; + renderTabBar(tabs); + expect(wrapper.find(".stale-warning").exists()).toBe(false); + }); + + it("auto-reload 白名单中的 stale tab 不显示警告标记", () => { + const tabs = [makeTab("tab-1", "/root/test.md", false, Date.now())]; + renderTabBar(tabs, "tab-1", ["/root/test.md"]); + expect(wrapper.find(".stale-warning").exists()).toBe(false); + }); + }); + + describe("点击事件", () => { + it("点击 tab 触发 activate 事件", async () => { + const tabs = [makeTab("tab-1"), makeTab("tab-2", "/root/other.md")]; + renderTabBar(tabs); + const tab2 = wrapper.findAll(".tab-item")[1]; + await tab2.trigger("click"); + expect(mockActivate).toHaveBeenCalledWith("tab-2"); + }); + + it("点击关闭按钮触发 close 事件", async () => { + const tabs = [makeTab("tab-1")]; + renderTabBar(tabs); + await wrapper.find(".close").trigger("click"); + expect(mockClose).toHaveBeenCalledWith("tab-1"); + }); + }); +}); diff --git a/src/components/TabBar.vue b/src/components/TabBar.vue index c321fbe..0a766f0 100644 --- a/src/components/TabBar.vue +++ b/src/components/TabBar.vue @@ -1,29 +1,37 @@ @@ -71,6 +175,9 @@ function onMiddle(id: string) { border-bottom: 1px solid var(--shell-toolbar-border); scrollbar-width: thin; } +.tab-bar.menu-open { + overflow: visible; +} .tab-item { display: flex; align-items: center; @@ -105,13 +212,19 @@ function onMiddle(id: string) { border-radius: 50%; background: var(--shell-tab-active-border); } +.stale-warning { + flex: 0 0 auto; + display: flex; + align-items: center; + color: var(--banner-warning, #f59e0b); +} .close { flex: 0 0 auto; width: 16px; height: 16px; - line-height: 14px; - text-align: center; - font-size: 14px; + display: flex; + align-items: center; + justify-content: center; padding: 0; border: none; border-radius: 4px; @@ -119,9 +232,52 @@ function onMiddle(id: string) { color: inherit; cursor: pointer; opacity: 0.6; + transition: opacity 0.15s, background-color 0.15s; } .close:hover { opacity: 1; background: var(--bg-btn-hover); } + +/* 右键菜单 */ +.context-menu { + position: absolute; + z-index: 100; + min-width: 180px; + padding: 4px 0; + background: var(--bg-toolbar); + border: 1px solid var(--border); + border-radius: 6px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12); + font-size: 12px; +} + +.menu-item { + padding: 6px 16px; + color: var(--fg); + cursor: pointer; + white-space: nowrap; + user-select: none; +} + +.menu-item:hover { + background: var(--bg-btn-hover); +} + +.menu-item.disabled { + color: var(--fg-muted); + opacity: 0.5; + cursor: default; +} + +.menu-item.disabled:hover { + background: transparent; +} + +/* 点击其他地方关闭右键菜单 */ +.context-menu-overlay { + position: fixed; + inset: 0; + z-index: 99; +} diff --git a/src/components/TocPanel.vue b/src/components/TocPanel.vue index fe8f4c6..c4235c0 100644 --- a/src/components/TocPanel.vue +++ b/src/components/TocPanel.vue @@ -80,10 +80,14 @@ function collapseAll() { {{ t("toc.title") }}
@@ -103,7 +107,12 @@ function collapseAll() { class="toc-toggle" @click.stop="toggleCollapse(idx)" > - {{ collapsed.has(idx) ? "▶" : "▼" }} + + + + + + {{ headings[idx].text }} @@ -138,14 +147,15 @@ function collapseAll() { gap: 2px; } .toc-action { - padding: 0 4px; - font-size: 12px; - line-height: 18px; + display: flex; + align-items: center; + padding: 2px 4px; color: var(--fg-muted); background: transparent; border: none; border-radius: 3px; cursor: pointer; + transition: color 0.12s, background-color 0.12s; } .toc-action:hover { color: var(--fg); @@ -181,10 +191,13 @@ function collapseAll() { } .toc-toggle { flex: 0 0 16px; - text-align: center; + display: flex; + align-items: center; + justify-content: center; cursor: pointer; user-select: none; - font-size: 10px; + color: var(--fg-muted); + transition: color 0.12s; } .toc-toggle-spacer { flex: 0 0 16px; diff --git a/src/composables/useFileTree.ts b/src/composables/useFileTree.ts index b2b2655..66ceb71 100644 --- a/src/composables/useFileTree.ts +++ b/src/composables/useFileTree.ts @@ -95,6 +95,13 @@ async function openFolder(): Promise { return null; } +async function changeRootDir(newDir: string) { + if (!newDir || newDir === rootDir.value) return; + rootDir.value = newDir; + localStorage.setItem("md-reader-root", newDir); + await refresh(); +} + async function restoreRoot(): Promise { const saved = localStorage.getItem("md-reader-root"); if (saved) { @@ -118,6 +125,7 @@ export function useFileTree() { error, refresh, openFolder, + changeRootDir, restoreRoot, clearRoot, }; diff --git a/src/composables/useHistory.ts b/src/composables/useHistory.ts index ddcf824..c3ff041 100644 --- a/src/composables/useHistory.ts +++ b/src/composables/useHistory.ts @@ -55,10 +55,15 @@ function clearRecent() { function saveScroll(path: string, top: number) { if (!path) return; scrollMap.value[path] = top; - const recentPaths = new Set(recent.value.map((x) => x.path)); - for (const key of Object.keys(scrollMap.value)) { - if (Object.keys(scrollMap.value).length <= MAX_SCROLL_ENTRIES) break; - if (!recentPaths.has(key)) delete scrollMap.value[key]; + // 超出容量上限时,移除最近记录中不存在的旧 key + while (Object.keys(scrollMap.value).length > MAX_SCROLL_ENTRIES) { + const recentPaths = new Set(recent.value.map((x) => x.path)); + for (const key of Object.keys(scrollMap.value)) { + if (!recentPaths.has(key)) { + delete scrollMap.value[key]; + break; + } + } } localStorage.setItem(STORAGE_SCROLL, JSON.stringify(scrollMap.value)); } diff --git a/src/composables/useLinkRewriter.ts b/src/composables/useLinkRewriter.ts index 33b6795..1df6f95 100644 --- a/src/composables/useLinkRewriter.ts +++ b/src/composables/useLinkRewriter.ts @@ -1,5 +1,8 @@ import { convertFileSrc } from "@tauri-apps/api/core"; +/** 标记已注册过内部链接点击事件的 anchor 元素,避免重复绑定 */ +const linkedAnchors = new WeakSet(); + function dirname(path: string): string { const i = path.replace(/\\/g, "/").lastIndexOf("/"); return i < 0 ? "" : path.slice(0, i); @@ -67,19 +70,22 @@ export function rewriteImagesAndLinks( const href = a.getAttribute("href") || ""; if (!href || isExternal(href)) return; if (href.startsWith("#")) { - a.addEventListener( - "click", - (e) => { - e.preventDefault(); - const id = href.slice(1); - if (!id) return; - const target = container.querySelector( - `#${CSS.escape(id)}` - ); - target?.scrollIntoView({ behavior: "smooth", block: "start" }); - }, - { once: false } - ); + if (!linkedAnchors.has(a)) { + a.addEventListener( + "click", + (e) => { + e.preventDefault(); + const id = href.slice(1); + if (!id) return; + const target = container.querySelector( + `#${CSS.escape(id)}` + ); + target?.scrollIntoView({ behavior: "smooth", block: "start" }); + }, + { once: false } + ); + linkedAnchors.add(a); + } return; } const hashIdx = href.indexOf("#"); @@ -91,10 +97,13 @@ export function rewriteImagesAndLinks( isAbsoluteWin(pathPart) || isAbsoluteUnix(pathPart) ? pathPart : joinPath(baseDir, pathPart); - a.addEventListener("click", (e) => { - e.preventDefault(); - onInternalLink(abs, hash); - }); + if (!linkedAnchors.has(a)) { + a.addEventListener("click", (e) => { + e.preventDefault(); + onInternalLink(abs, hash); + }); + linkedAnchors.add(a); + } a.classList.add("internal-link"); }); } diff --git a/src/composables/useMarkdown.ts b/src/composables/useMarkdown.ts index b8f790a..0dff108 100644 --- a/src/composables/useMarkdown.ts +++ b/src/composables/useMarkdown.ts @@ -166,6 +166,10 @@ function renderFrontMatter(block: FrontMatterBlock): string { } export function extractHeadings(source: string): Heading[] { + // 超大文件跳过全量 token 解析,仅提取前 100 行的 heading + if (source.length > 200_000) { + return extractHeadingsFallback(source); + } const block = splitFrontMatter(source); const body = block ? block.body : source; const env = { sourceLineOffset: block ? block.bodyStartLine - 1 : 0 }; @@ -184,6 +188,22 @@ export function extractHeadings(source: string): Heading[] { return headings; } +/** 大文件 fallback:逐行扫描 heading,避免 md.parse() 的全量 token 化开销 */ +function extractHeadingsFallback(source: string): Heading[] { + const headings: Heading[] = []; + const block = splitFrontMatter(source); + const body = block ? block.body : source; + for (const line of body.split(/\r?\n/).slice(0, 500)) { + const m = line.match(/^(#{1,6})\s+(.*)$/); + if (!m) continue; + const level = m[1].length; + const text = m[2].trim(); + const id = encodeURIComponent(text.toLowerCase().replace(/\s+/g, "-")); + headings.push({ level, text, id }); + } + return headings; +} + export function renderMarkdown(source: string): string { const block = splitFrontMatter(source); const body = block ? block.body : source; diff --git a/src/composables/useReadingSettings.ts b/src/composables/useReadingSettings.ts index 5a66f08..38fe587 100644 --- a/src/composables/useReadingSettings.ts +++ b/src/composables/useReadingSettings.ts @@ -1,16 +1,20 @@ import { ref, computed } from "vue"; import { i18n } from "../i18n"; +import { invoke } from "@tauri-apps/api/core"; export interface ReadingSettings { fontSize: number; lineHeight: number; maxWidth: number; fontFamily: string; + fontCustom: string; editorFontSize: number; + editorFontFamily: string; + editorFontCustom: string; tocPosition: "left" | "right"; } -const FONT_KEYS = ["system", "sans", "serif", "mono"] as const; +const FONT_KEYS = ["system", "sans", "serif", "mono", "custom"] as const; const FONT_STACKS: Record = { system: @@ -21,6 +25,7 @@ const FONT_STACKS: Record = { '"Source Han Serif SC", "Noto Serif CJK SC", "Songti SC", "STSong", Georgia, serif', mono: 'ui-monospace, SFMono-Regular, "JetBrains Mono", "Cascadia Code", "Source Code Pro", Consolas, monospace', + custom: "", }; const STORAGE = "md-reader-reading"; @@ -41,7 +46,10 @@ function defaults(): ReadingSettings { lineHeight: 1.75, maxWidth: 900, fontFamily: "system", + fontCustom: "", editorFontSize: 14, + editorFontFamily: "mono", + editorFontCustom: "", tocPosition: "right", }; } @@ -55,14 +63,35 @@ function save() { function apply() { const r = document.documentElement; + const readerFont = getReaderFontFamily(); + const editorFont = getEditorFontFamily(); r.style.setProperty("--reader-font-size", settings.value.fontSize + "px"); r.style.setProperty("--reader-line-height", String(settings.value.lineHeight)); r.style.setProperty("--reader-max-width", settings.value.maxWidth + "px"); - r.style.setProperty( - "--reader-font-family", - FONT_STACKS[settings.value.fontFamily] || FONT_STACKS.system - ); + r.style.setProperty("--reader-font-family", readerFont); r.style.setProperty("--editor-font-size", settings.value.editorFontSize + "px"); + r.style.setProperty("--editor-font-family", editorFont); +} + +function getReaderFontFamily(): string { + if (settings.value.fontFamily === "custom" && settings.value.fontCustom) { + return settings.value.fontCustom; + } + if (FONT_STACKS[settings.value.fontFamily]) { + return FONT_STACKS[settings.value.fontFamily]; + } + // 用户选了系统字体名,直接使用 + return settings.value.fontFamily; +} + +function getEditorFontFamily(): string { + if (settings.value.editorFontFamily === "custom" && settings.value.editorFontCustom) { + return settings.value.editorFontCustom; + } + if (FONT_STACKS[settings.value.editorFontFamily]) { + return FONT_STACKS[settings.value.editorFontFamily]; + } + return settings.value.editorFontFamily; } function setFontSize(v: number) { @@ -95,6 +124,21 @@ function setTocPosition(v: "left" | "right") { save(); } +function setFontCustom(v: string) { + settings.value.fontCustom = v; + if (settings.value.fontFamily === "custom") save(); +} + +function setEditorFontFamily(v: string) { + settings.value.editorFontFamily = v; + save(); +} + +function setEditorFontCustom(v: string) { + settings.value.editorFontCustom = v; + if (settings.value.editorFontFamily === "custom") save(); +} + function reset() { settings.value = defaults(); save(); @@ -107,17 +151,53 @@ const fontOptions = computed(() => })) ); +const editorFontOptions = computed(() => + FONT_KEYS.map((key) => ({ + label: i18n.global.t(`settings.${key}`), + value: key, + })) +); + +/** 从 Tauri 后端获取系统已安装字体列表 */ +async function loadSystemFonts(): Promise<{ name: string }[]> { + try { + const fonts = (await invoke("get_system_fonts")) as Array<{ + id: string; + name: string; + font_name: string; + }>; + // 按字体名称去重排序 + const seen = new Set(); + return fonts + .map((f) => f.name) + .filter((n) => { + if (seen.has(n)) return false; + seen.add(n); + return true; + }) + .sort((a, b) => a.localeCompare(b, undefined, { sensitivity: "base" })) + .map((n) => ({ name: n })); + } catch { + return []; + } +} + export function useReadingSettings() { return { settings, fontOptions, + editorFontOptions, apply, setFontSize, setLineHeight, setMaxWidth, setFontFamily, + setFontCustom, setEditorFontSize, + setEditorFontFamily, + setEditorFontCustom, setTocPosition, reset, + loadSystemFonts, }; } diff --git a/src/composables/useTabs.test.ts b/src/composables/useTabs.test.ts new file mode 100644 index 0000000..bd496ad --- /dev/null +++ b/src/composables/useTabs.test.ts @@ -0,0 +1,78 @@ +/** + * useTabs 测试 + * + * 测试目标: + * - Tab 创建时默认 staleSince = null + * - staleSince 可被设置为 number 或 null + */ + +import { describe, it, expect } from "vitest"; +import { useTabs, normalizePath, samePath, type Tab } from "./useTabs"; + +describe("Tab", () => { + describe("staleSince 字段", () => { + it("createTab 创建的 Tab 默认 staleSince 为 null", () => { + const { createTab } = useTabs(); + const tab = createTab("/root/test.md"); + expect(tab.staleSince).toBe(null); + }); + + it("Tab 对象包含 staleSince 属性", () => { + const tab: Tab = { + id: "tab-test-1", + path: "/root/test.md", + content: "# Hello", + draftContent: "# Hello", + isDirty: false, + isEditing: false, + headings: [], + scrollTop: 0, + pendingHash: "", + pendingScrollTop: 0, + pendingSourceLine: 0, + staleSince: null, + }; + expect(tab.staleSince).toBe(null); + }); + + it("staleSince 可被设置为 number 时间戳", () => { + const { createTab } = useTabs(); + const tab = createTab("/root/test.md"); + const timestamp = Date.now(); + tab.staleSince = timestamp; + expect(tab.staleSince).toBe(timestamp); + }); + + it("staleSince 可被重置为 null", () => { + const { createTab } = useTabs(); + const tab = createTab("/root/test.md"); + tab.staleSince = Date.now(); + tab.staleSince = null; + expect(tab.staleSince).toBe(null); + }); + }); +}); + +describe("normalizePath", () => { + it("normalizePath 转换反斜杠为斜杠并转小写", () => { + expect(normalizePath("C:\\Users\\Test\\file.md")).toBe("c:/users/test/file.md"); + }); + + it("normalizePath 对于已标准化的路径不改变", () => { + expect(normalizePath("/root/file.md")).toBe("/root/file.md"); + }); +}); + +describe("samePath", () => { + it("samePath 比较相同路径返回 true", () => { + expect(samePath("/root/file.md", "/root/file.md")).toBe(true); + }); + + it("samePath 比较不同大小写返回 true", () => { + expect(samePath("/root/file.md", "/root/FILE.md")).toBe(true); + }); + + it("samePath 比较不同路径返回 false", () => { + expect(samePath("/root/file.md", "/root/other.md")).toBe(false); + }); +}); diff --git a/src/composables/useTabs.ts b/src/composables/useTabs.ts index 354ad91..5736a3d 100644 --- a/src/composables/useTabs.ts +++ b/src/composables/useTabs.ts @@ -15,6 +15,7 @@ export interface Tab { pendingHash: string; pendingScrollTop: number; pendingSourceLine: number; + staleSince: number | null; } interface PersistedTabs { @@ -60,6 +61,7 @@ function createTab(path: string): Tab { pendingHash: "", pendingScrollTop: 0, pendingSourceLine: 0, + staleSince: null, }; } @@ -82,6 +84,55 @@ function removeTab(id: string) { persist(); } +/** 关闭 targetId 左侧的所有 tab(保留 target 自身及右侧) */ +function closeTabsLeft(targetId: string) { + const targetIdx = tabs.value.findIndex((t) => t.id === targetId); + if (targetIdx <= 0) return; + const targetTab = tabs.value[targetIdx]; + // 移除 target 左侧的所有 tab(从后往前移除以保持索引稳定) + for (let i = targetIdx - 1; i >= 0; i--) { + tabs.value.splice(i, 1); + } + // 若当前 active 是已被移除的 tab,回退到 target + if (!tabs.value.find((t) => t.id === activeTabId.value)) { + activeTabId.value = targetTab.id; + } + persist(); +} + +/** 关闭 targetId 右侧的所有 tab(保留 target 自身及左侧) */ +function closeTabsRight(targetId: string) { + const targetIdx = tabs.value.findIndex((t) => t.id === targetId); + if (targetIdx === -1 || targetIdx >= tabs.value.length - 1) return; + const targetTab = tabs.value[targetIdx]; + // 移除 target 右侧的所有 tab + tabs.value.splice(targetIdx + 1); + // 若当前 active 是已被移除的 tab,回退到 target + if (!tabs.value.find((t) => t.id === activeTabId.value)) { + activeTabId.value = targetTab.id; + } + persist(); +} + +/** 关闭 targetId 两侧的所有 tab(只保留 target 自身) */ +function closeTabsOthers(targetId: string) { + const targetIdx = tabs.value.findIndex((t) => t.id === targetId); + if (targetIdx === -1 || tabs.value.length <= 1) return; + const targetTab = tabs.value[targetIdx]; + // 先保留 target,清空所有 tab,再放回去 + tabs.value.length = 0; + tabs.value.push(targetTab); + activeTabId.value = targetTab.id; + persist(); +} + +/** 关闭所有 tab */ +function closeAllTabs() { + tabs.value.length = 0; + activeTabId.value = ""; + persist(); +} + function persist() { const data: PersistedTabs = { paths: tabs.value.map((t) => t.path), @@ -109,6 +160,10 @@ export function useTabs() { createTab, activateTab, removeTab, + closeTabsLeft, + closeTabsRight, + closeTabsOthers, + closeAllTabs, persist, loadPersisted, }; diff --git a/src/i18n/en-US.ts b/src/i18n/en-US.ts index 524b078..fa6b704 100644 --- a/src/i18n/en-US.ts +++ b/src/i18n/en-US.ts @@ -59,6 +59,10 @@ const enUS = { lineHeight: "Line Height", maxWidth: "Page Width", fontFamily: "Body Font", + fontCustom: "Custom Font", + fontCustomPlaceholder: 'Type a font name, e.g. "Noto Sans SC"', + editorFontFamily: "Editor Font", + editorFontCustom: "Custom Font", tocPosition: "Outline Position", tocLeft: "Left", tocRight: "Right", @@ -66,6 +70,7 @@ const enUS = { sans: "Sans", serif: "Serif", mono: "Mono", + custom: "Custom", reset: "Reset", updateCheck: "Software update", currentVersion: "Current version: {version}", @@ -137,11 +142,23 @@ const enUS = { "The current file changed on disk. Reload the disk version or keep your unsaved edits.", reloadFromDisk: "Reload", keepEditing: "Keep editing", + autoReloadWhitelist: "Auto-reload whitelist", previewBeforeExport: "Switch to preview mode before exporting or printing", newFile: "New Markdown file", created: "Created", createFailed: "Failed to create", }, + banner: { + reload: "Reload", + viewDiff: "View Diff", + ignore: "Ignore", + autoReload: "Enable Auto-Reload", + }, + diff: { + title: "Diff View", + close: "Close", + noChanges: "No changes", + }, toc: { title: "Outline", empty: "(No headings)", @@ -150,6 +167,10 @@ const enUS = { }, tabs: { close: "Close tab", + closeLeft: "Close tabs left", + closeRight: "Close tabs right", + closeAll: "Close all tabs", + closeOthers: "Close other tabs", }, errors: { readFailed: "Failed to read file", diff --git a/src/i18n/zh-CN.ts b/src/i18n/zh-CN.ts index 9e20540..2dc5245 100644 --- a/src/i18n/zh-CN.ts +++ b/src/i18n/zh-CN.ts @@ -56,6 +56,10 @@ const zhCN = { lineHeight: "行高", maxWidth: "页面宽度", fontFamily: "正文字体", + fontCustom: "自定义字体", + fontCustomPlaceholder: "输入字体名称,如 Noto Sans SC", + editorFontFamily: "编辑器字体", + editorFontCustom: "自定义字体", tocPosition: "大纲位置", tocLeft: "左侧", tocRight: "右侧", @@ -63,6 +67,7 @@ const zhCN = { sans: "无衬线", serif: "衬线", mono: "等宽", + custom: "自定义", reset: "恢复默认", updateCheck: "软件更新", currentVersion: "当前版本:{version}", @@ -133,11 +138,23 @@ const zhCN = { "当前文件在磁盘上发生了变化。可以重新加载磁盘内容,或保留当前未保存编辑。", reloadFromDisk: "重新加载", keepEditing: "保留编辑", + autoReloadWhitelist: "自动重载白名单", previewBeforeExport: "请先切换到预览模式再导出或打印", newFile: "新建 Markdown 文件", created: "已创建", createFailed: "创建失败", }, + banner: { + reload: "重新加载", + viewDiff: "查看差异", + ignore: "忽略", + autoReload: "启用自动重载", + }, + diff: { + title: "差异对比", + close: "关闭", + noChanges: "无变化", + }, toc: { title: "大纲", empty: "(无标题)", @@ -146,6 +163,10 @@ const zhCN = { }, tabs: { close: "关闭标签", + closeLeft: "关闭左侧标签", + closeRight: "关闭右侧标签", + closeAll: "关闭所有标签", + closeOthers: "关闭其他标签", }, errors: { readFailed: "读取失败", diff --git a/src/styles.css b/src/styles.css index 38fa54d..f1b915c 100644 --- a/src/styles.css +++ b/src/styles.css @@ -183,6 +183,380 @@ body { -webkit-font-smoothing: antialiased; } +/* ---- App shell ---- */ +.app { + display: flex; + flex-direction: column; + height: 100%; + overflow: hidden; +} + +.toolbar { + flex: 0 0 auto; + display: flex; + flex-direction: row; + align-items: center; + gap: 2px; + padding: 4px 8px; + background: var(--shell-toolbar-bg); + border-bottom: 1px solid var(--shell-toolbar-border); + flex-wrap: nowrap; + overflow: visible; +} + +.toolbar .btn { + display: inline-flex; + align-items: center; + gap: 3px; + padding: 5px 8px; + font-size: 12px; + line-height: 1.2; + border: 1px solid transparent; + border-radius: 4px; + background: var(--bg-btn); + color: var(--fg); + cursor: pointer; + white-space: nowrap; + flex-shrink: 0; +} + +.toolbar .btn:hover { + background: var(--bg-btn-hover); +} + +.toolbar .btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.toolbar .filename { + flex: 1 1 auto; + padding: 4px 8px; + margin: 0 8px; + font-size: 12px; + color: var(--shell-filename-color); + border-radius: 4px; + background: var(--bg-btn); + text-align: center; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.toolbar-right { + flex-shrink: 0; + display: flex; + flex-direction: row; + align-items: center; + gap: 2px; + justify-content: flex-end; +} + +.toolbar .export-wrap { + position: relative; + flex: 0 0 auto; +} + +.toolbar .export-menu { + position: absolute; + top: 100%; + right: 0; + z-index: 30; + min-width: 320px; + padding: 4px; + background: var(--shell-export-bg); + border: 1px solid var(--border); + border-radius: 6px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); +} + +.toolbar .menu-item { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + padding: 6px 12px; + font-size: 12px; + color: var(--fg); + background: transparent; + border: none; + border-radius: 4px; + cursor: pointer; + text-align: left; + white-space: nowrap; +} + +.toolbar .menu-item:hover { + background: var(--shell-export-hover-bg); +} + +.toolbar .menu-item:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.toolbar .menu-item:disabled:hover { + background: transparent; +} + +.toolbar .mi-hint { + font-size: 11px; + color: var(--fg-muted); +} + +.toolbar .menu-divider { + height: 1px; + background: var(--border); + margin: 4px 0; +} + +.menu-overlay { + position: fixed; + inset: 0; + z-index: 29; +} + +/* ---- Main layout ---- */ +.layout { + flex: 1 1 auto; + display: flex; + min-height: 0; + overflow: hidden; +} + +.left { + flex: 0 0 auto; + display: flex; + flex-direction: column; + background: var(--shell-sidebar-bg); + border-right: 1px solid var(--shell-sidebar-border); + overflow: hidden; +} + +.right { + flex: 0 0 auto; + background: var(--shell-right-bg); + border-left: 1px solid var(--shell-sidebar-border); + overflow: hidden; +} + +.resizer { + flex: 0 0 4px; + cursor: col-resize; + background: transparent; +} + +.resizer:hover { + background: var(--link); +} + +.viewer { + flex: 1 1 auto; + position: relative; +} + +/* 预览模式下允许滚动 */ +.viewer:not(.editing) { + overflow: auto; +} + +/* 编辑模式下 CodeMirror 自行处理滚动 */ +.viewer.editing { + overflow: hidden; +} + +/* ---- Panel headers & tabs ---- */ +.panel-tabs { + display: flex; + border-bottom: 1px solid var(--shell-panel-header-border); +} + +.panel-tabs .tab { + flex: 1 1 0; + padding: 6px 8px; + font-size: 11px; + color: var(--fg-muted); + background: transparent; + border: none; + border-bottom: 2px solid transparent; + cursor: pointer; + text-align: center; +} + +.panel-tabs .tab:hover { + color: var(--fg); + background: var(--bg-btn-hover); +} + +.panel-tabs .tab.active { + color: var(--shell-tab-active-color); + border-bottom-color: var(--shell-tab-active-border); +} + +.panel-header { + padding: 8px 12px; + font-size: 11px; + color: var(--shell-panel-header-color); + border-bottom: 1px solid var(--shell-panel-header-border); + display: flex; + align-items: center; + gap: 4px; +} + +.panel-body { + flex: 1 1 auto; + overflow: hidden; + display: flex; + flex-direction: column; + min-height: 0; +} + +.tree-scroll { + flex: 1 1 auto; + overflow: auto; + min-height: 0; +} + +/* ---- Empty state ---- */ +.empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100%; + padding: 32px; + text-align: center; +} + +.empty-title { + font-size: 20px; + font-weight: 600; + color: var(--fg); + margin-bottom: 8px; +} + +.empty-hint { + font-size: 14px; + color: var(--fg-muted); + margin-bottom: 16px; +} + +.shortcut-hint { + font-size: 12px; + color: var(--fg-muted); + margin-bottom: 24px; +} + +.empty-tip { + padding: 16px; + font-size: 12px; + color: var(--fg-muted); + text-align: center; +} + +.panel-error { + padding: 8px 12px; + font-size: 12px; + color: var(--mdr-danger); +} + +.muted { + color: var(--fg-muted); +} + +/* ---- Recent files ---- */ +.recent-files { + width: 100%; + max-width: 400px; + text-align: left; +} + +.recent-title { + font-size: 12px; + color: var(--fg-muted); + margin-bottom: 8px; +} + +.recent-item { + display: flex; + justify-content: space-between; + padding: 6px 12px; + border-radius: 4px; + cursor: pointer; +} + +.recent-item:hover { + background: var(--bg-btn-hover); +} + +.recent-name { + font-size: 13px; + color: var(--fg); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.recent-path { + font-size: 11px; + color: var(--fg-muted); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.recent-clear { + margin-top: 12px; + font-size: 11px; + color: var(--fg-muted); + background: transparent; + border: none; + cursor: pointer; + padding: 4px 8px; + border-radius: 4px; +} + +.recent-clear:hover { + background: var(--bg-btn-hover); + color: var(--fg); +} + +/* ---- Error & toast ---- */ +.error { + padding: 8px 16px; + font-size: 12px; + color: var(--mdr-danger); + background: rgba(207, 34, 46, 0.08); + cursor: pointer; +} + +.toast { + position: fixed; + bottom: 24px; + left: 50%; + transform: translateX(-50%); + z-index: 50; + padding: 10px 20px; + font-size: 13px; + color: #fff; + background: var(--mdr-success); + border-radius: 6px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + cursor: pointer; + animation: toastSlideUp 0.25s ease-out; +} + +@keyframes toastSlideUp { + from { + transform: translate(-50%, 100%); + opacity: 0; + } + to { + transform: translate(-50%, 0); + opacity: 1; + } +} + /* ---- Markdown content ---- */ .markdown-body h1, .markdown-body h2, @@ -443,4 +817,34 @@ body { .markdown-body a.internal-link::before { content: "↳ "; color: var(--fg-muted); +} + +/* ---- global focus-visible for keyboard navigation ---- */ +:focus-visible { + outline: 2px solid var(--link); + outline-offset: 2px; + border-radius: 2px; +} + +/* ---- smooth transitions for all interactive elements ---- */ +button, +a, +[role="button"], +input, +select, +textarea, +.cursor-pointer { + transition: color 0.15s ease, background-color 0.15s ease, + border-color 0.15s ease, box-shadow 0.15s ease, opacity 0.15s ease; +} + +/* ---- disable transitions for users who prefer reduced motion ---- */ +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + } } \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json index 07cf2dc..f36862e 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -3,7 +3,7 @@ "target": "ES2020", "useDefineForClassFields": true, "module": "ESNext", - "lib": ["ES2020", "DOM", "DOM.Iterable"], + "lib": ["ES2022", "DOM", "DOM.Iterable"], "skipLibCheck": true, "moduleResolution": "bundler", "allowImportingTsExtensions": true, diff --git a/vite.config.ts b/vite.config.ts index 232927c..a8e3c5c 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,5 +1,6 @@ import { defineConfig } from "vite"; import vue from "@vitejs/plugin-vue"; +import { configDefaults } from "vitest/config"; const host = process.env.TAURI_DEV_HOST; @@ -39,4 +40,9 @@ export default defineConfig(async () => ({ }, }, }, -})); \ No newline at end of file + test: { + environment: "jsdom", + exclude: [...configDefaults.exclude, "src-tauri/**"], + include: ["src/**/*.test.ts", "src/**/*.spec.ts"], + }, +}));