feat(pdf): support TTS and annotation on PDFs, closes #2149 & #3462 (#3493)

* chore: bump jsdom to the latest version

* feat(pdf): support TTS and annotation on PDFs, closes #2149 & closes #3462
This commit is contained in:
Huang Xin 2026-03-09 17:28:19 +08:00 committed by GitHub
parent 93b96d64eb
commit 8850e6c00f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
34 changed files with 841 additions and 252 deletions

4
.gitignore vendored
View file

@ -46,4 +46,8 @@ fastlane/report.xml
# nix
result*
.playwright-mcp/
.claude/

View file

@ -57,6 +57,12 @@ Platform-specific code lives in `src-tauri/src/{macos,windows,android,ios}/`. Cu
## Project Rules
### Test-First Development
- Always write a failing unit test **before** implementing a fix.
- Run the test to confirm it reproduces the bug or fails as expected, then apply the fix and verify the test passes.
- Run the full test suite (`pnpm test`) after changes to ensure no regressions.
### TypeScript
- Never use the `any` type. Use `unknown`, proper types, or generics instead.

View file

@ -15,10 +15,10 @@
"start-web:vinext": "dotenv -e .env.web -- vinext start",
"build-tauri": "dotenv -e .env.tauri -- next build",
"i18n:extract": "i18next-scanner --config i18next-scanner.config.cjs",
"lint": "eslint .",
"lint": "tsc --noEmit && eslint .",
"test": "dotenv -e .env -e .env.test.local vitest",
"test:browser": "vitest --config vitest.browser.config.mts --watch=false",
"test:tauri": "vitest --config vitest.tauri.config.mts --watch=false",
"test:tauri": "bash scripts/test-tauri.sh",
"test:pr:web": "pnpm test -- --watch=false && pnpm test:browser",
"test:pr:tauri": "pnpm test -- --watch=false && bash scripts/test-tauri.sh",
"test:all": "pnpm test -- --watch=false && pnpm test:browser && bash scripts/test-tauri.sh",
@ -213,7 +213,7 @@
"eslint-config-next": "16.1.6",
"eslint-plugin-jsx-a11y": "^6.10.2",
"i18next-scanner": "^4.6.0",
"jsdom": "^26.1.0",
"jsdom": "^28.1.0",
"mkdirp": "^3.0.1",
"node-env-run": "^4.0.2",
"playwright": "^1.58.2",

View file

@ -68,4 +68,4 @@ while ! curl -sf "http://127.0.0.1:${WEBDRIVER_PORT}/status" >/dev/null 2>&1; do
done
echo "WebDriver is ready. Running Tauri tests..."
pnpm test:tauri
pnpm vitest --config vitest.tauri.config.mts --watch=false

View file

@ -41,6 +41,7 @@ describe('ProofreadPopup Component', () => {
key: 'test-book',
text: 'test word',
cfi: 'epubcfi(/6/2[chapter1]!/4/1:0)',
index: 0,
range: {
deleteContents: vi.fn(),
insertNode: vi.fn(),
@ -49,7 +50,7 @@ describe('ProofreadPopup Component', () => {
startOffset: 5,
endOffset: 9,
} as unknown as Range,
index: 0,
page: 1,
},
position: { point: { x: 100, y: 100 } },
trianglePosition: { point: { x: 100, y: 100 } },

View file

@ -6,18 +6,6 @@ import type { BookDoc } from '@/libs/document';
import type { FoliateView } from '@/types/view';
import { wrappedFoliateView } from '@/types/view';
// jsdom's Blob doesn't implement arrayBuffer(), polyfill it via FileReader
if (typeof Blob.prototype.arrayBuffer !== 'function') {
Blob.prototype.arrayBuffer = function () {
return new Promise((res, reject) => {
const reader = new FileReader();
reader.onload = () => res(reader.result as ArrayBuffer);
reader.onerror = () => reject(reader.error);
reader.readAsArrayBuffer(this);
});
};
}
// Register a stub paginator custom element so View.open() doesn't fail in jsdom
if (!customElements.get('foliate-paginator')) {
customElements.define(
@ -38,7 +26,7 @@ let view: FoliateView;
let totalSections: number;
const loadEPUB = async () => {
const epubPath = resolve(__dirname, '../fixtures/sample-alice.epub');
const epubPath = resolve(__dirname, '../fixtures/data/sample-alice.epub');
const buffer = readFileSync(epubPath);
const file = new File([buffer], 'sample-alice.epub', { type: 'application/epub+zip' });
const loader = new DocumentLoader(file);

View file

@ -0,0 +1,241 @@
import { describe, it, expect, beforeAll } from 'vitest';
import { readFileSync } from 'fs';
import { resolve, join } from 'path';
import { parse, toRange, fromRange } from 'foliate-js/epubcfi.js';
import { DocumentLoader } from '@/libs/document';
import type { BookDoc } from '@/libs/document';
const vendorDir = join(process.cwd(), 'public/vendor');
/**
* Tests EPUB CFI resolution with a real PDF loaded via DocumentLoader.
*
* Reference CFIs captured from sample-alice.pdf in a full browser environment:
* - index 3: epubcfi(/6/8!/4/4,/94/1:0,/118/1:10)
* - index 4: epubcfi(/6/10!/4/4/42,/1:0,/1:46)
*
* In jsdom the canvas 2d context is unavailable, so createDocument() uses a
* fallback that produces one <span> per getTextContent() item. The element
* count differs from the full TextLayer render, so these tests generate CFIs
* from the fallback DOM and verify round-trip resolution.
*/
describe('PDF CFI resolution with real document', () => {
let book: BookDoc;
let doc3: Document;
let doc4: Document;
/** Shift the spine-level part from a parsed CFI (as view.js resolveCFI does). */
const shiftSpine = (parts: ReturnType<typeof parse>) => {
(parts.parent ?? parts).shift();
return parts;
};
beforeAll(async () => {
await import('foliate-js/pdf.js');
const pdfjsLib = (globalThis as Record<string, unknown>)['pdfjsLib'] as {
GlobalWorkerOptions: { workerSrc: string };
};
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL(
`file://${join(vendorDir, 'pdfjs/pdf.worker.min.mjs')}`,
).href;
const pdfPath = resolve(__dirname, '../fixtures/data/sample-alice.pdf');
const buffer = readFileSync(pdfPath);
const file = new File([buffer], 'sample-alice.pdf', { type: 'application/pdf' });
const loader = new DocumentLoader(file);
const result = await loader.open();
book = result.book;
doc3 = await book.sections[3]!.createDocument();
doc4 = await book.sections[4]!.createDocument();
}, 30_000);
// ---------- DOM structure -------------------------------------------------
it('should have textLayer, canvas, and annotationLayer wrappers', () => {
expect(doc3.querySelector('#canvas')).toBeTruthy();
expect(doc3.querySelector('.textLayer')).toBeTruthy();
expect(doc3.querySelector('.annotationLayer')).toBeTruthy();
const textLayer = doc3.querySelector('.textLayer')!;
expect(textLayer.children.length).toBeGreaterThan(0);
});
// ---------- Round-trip: fromRange → parse → toRange -----------------------
it('should round-trip a range CFI on page 3', () => {
const textLayer = doc3.querySelector('.textLayer')!;
const spans = textLayer.querySelectorAll('span');
// Find a span with enough text content
let targetSpan: Element | null = null;
for (const span of spans) {
if (span.firstChild && span.firstChild.textContent!.length >= 10) {
targetSpan = span;
break;
}
}
expect(targetSpan).toBeTruthy();
const srcRange = doc3.createRange();
srcRange.setStart(targetSpan!.firstChild!, 0);
srcRange.setEnd(targetSpan!.firstChild!, 10);
const expectedText = srcRange.toString();
const cfi = fromRange(srcRange);
const parts = parse(cfi);
const resolved = toRange(doc3, parts);
expect(resolved).toBeInstanceOf(Range);
expect(resolved!.toString()).toBe(expectedText);
});
it('should round-trip a range CFI on page 4', () => {
const textLayer = doc4.querySelector('.textLayer')!;
const spans = textLayer.querySelectorAll('span');
let targetSpan: Element | null = null;
for (const span of spans) {
if (span.firstChild && span.firstChild.textContent!.length >= 10) {
targetSpan = span;
break;
}
}
expect(targetSpan).toBeTruthy();
const srcRange = doc4.createRange();
srcRange.setStart(targetSpan!.firstChild!, 0);
srcRange.setEnd(targetSpan!.firstChild!, 10);
const expectedText = srcRange.toString();
const cfi = fromRange(srcRange);
const parts = parse(cfi);
const resolved = toRange(doc4, parts);
expect(resolved).toBeInstanceOf(Range);
expect(resolved!.toString()).toBe(expectedText);
});
it('should round-trip a multi-span range CFI', () => {
const textLayer = doc3.querySelector('.textLayer')!;
const spans = textLayer.querySelectorAll('span');
// Select a range spanning two different spans
const span1 = spans[0]!;
const span2 = spans[2]!;
expect(span1.firstChild).toBeTruthy();
expect(span2.firstChild).toBeTruthy();
const srcRange = doc3.createRange();
srcRange.setStart(span1.firstChild!, 0);
const endOffset = Math.min(5, span2.firstChild!.textContent!.length);
srcRange.setEnd(span2.firstChild!, endOffset);
const expectedText = srcRange.toString();
const cfi = fromRange(srcRange);
const parts = parse(cfi);
const resolved = toRange(doc3, parts);
expect(resolved).toBeInstanceOf(Range);
expect(resolved!.toString()).toBe(expectedText);
});
it('should round-trip a collapsed (point) CFI', () => {
const textLayer = doc3.querySelector('.textLayer')!;
const span = textLayer.querySelector('span')!;
expect(span.firstChild).toBeTruthy();
const srcRange = doc3.createRange();
srcRange.setStart(span.firstChild!, 3);
srcRange.collapse(true);
const cfi = fromRange(srcRange);
const parts = parse(cfi);
const resolved = toRange(doc3, parts);
expect(resolved).toBeInstanceOf(Range);
expect(resolved!.collapsed).toBe(true);
});
// ---------- Cross-page CFI mismatch ---------------------------------------
it('should fail to resolve a page 3 CFI on page 4 document', () => {
const textLayer = doc3.querySelector('.textLayer')!;
const spans = textLayer.querySelectorAll('span');
// Pick the last span so its index likely exceeds page 4's element count
const lastSpan = spans[spans.length - 1]!;
expect(lastSpan.firstChild).toBeTruthy();
const srcRange = doc3.createRange();
srcRange.setStart(lastSpan.firstChild!, 0);
const endOffset = Math.min(5, lastSpan.firstChild!.textContent!.length);
srcRange.setEnd(lastSpan.firstChild!, endOffset);
const cfi = fromRange(srcRange);
const parts = parse(cfi);
const range = toRange(doc4, parts);
// May resolve to a different node or return null depending on DOM sizes;
// either way it should NOT match the original text
if (range) {
expect(range.toString()).not.toBe(srcRange.toString());
}
});
// ---------- Reference CFI format verification -----------------------------
it('should parse real browser-captured CFIs correctly', () => {
// These CFIs were captured from sample-alice.pdf in a full browser with
// TextLayer. They verify that the CFI format is structurally valid.
const cfi3 = 'epubcfi(/6/8!/4/4,/94/1:0,/118/1:10)';
const cfi4 = 'epubcfi(/6/10!/4/4/42,/1:0,/1:46)';
const parts3 = parse(cfi3);
expect(parts3.parent).toBeTruthy();
expect(parts3.start).toBeTruthy();
expect(parts3.end).toBeTruthy();
const parts4 = parse(cfi4);
expect(parts4.parent).toBeTruthy();
expect(parts4.start).toBeTruthy();
expect(parts4.end).toBeTruthy();
});
it('should encode the correct section index in CFI spine step', () => {
// Section index 3 → spine step /6/8, section index 4 → spine step /6/10
const cfi3 = 'epubcfi(/6/8!/4/4,/94/1:0,/118/1:10)';
const cfi4 = 'epubcfi(/6/10!/4/4/42,/1:0,/1:46)';
expect(cfi3).toContain('/6/8!');
expect(cfi4).toContain('/6/10!');
// Spine step /6/N: N = (index + 1) * 2
// index 3 → (3+1)*2 = 8, index 4 → (4+1)*2 = 10
const parts3 = parse(cfi3);
expect(parts3.parent[0][0].index).toBe(6);
expect(parts3.parent[0][1].index).toBe(8);
const parts4 = parse(cfi4);
expect(parts4.parent[0][0].index).toBe(6);
expect(parts4.parent[0][1].index).toBe(10);
});
// ---------- Out-of-bounds CFI indices -------------------------------------
it('should return null when CFI child indices exceed the DOM', () => {
const cfi = 'epubcfi(/6/8!/4/4,/9000/1:0,/9002/1:5)';
const parts = shiftSpine(parse(cfi));
const range = toRange(doc3, parts);
expect(range).toBeNull();
});
it('should return null for a simple CFI with an unreachable node', () => {
const cfi = 'epubcfi(/6/8!/4/9000/1:0)';
const parts = shiftSpine(parse(cfi));
const range = toRange(doc3, parts);
expect(range).toBeNull();
});
it('should return null when start resolves but end does not', () => {
const cfi = 'epubcfi(/6/8!/4/4,/2/1:0,/9999/1:5)';
const parts = shiftSpine(parse(cfi));
const range = toRange(doc3, parts);
expect(range).toBeNull();
});
});

View file

@ -0,0 +1,266 @@
import { describe, it, expect, vi, beforeAll } from 'vitest';
import { readFileSync } from 'fs';
import { resolve, join } from 'path';
import { textWalker } from 'foliate-js/text-walker.js';
import { TTS } from 'foliate-js/tts.js';
import { createRejectFilter } from '@/utils/node';
import { DocumentLoader } from '@/libs/document';
import type { BookDoc } from '@/libs/document';
// The @pdfjs alias in vitest.config.mts resolves to public/vendor/pdfjs,
// mirroring how foliate-js/pdf.js does `import '@pdfjs/pdf.min.mjs'`.
const vendorDir = join(process.cwd(), 'public/vendor');
/** Strip all XML/SSML tags to get plain text content */
const stripTags = (ssml: string): string => ssml.replace(/<[^>]+\/?>/g, '').trim();
const highlight = vi.fn();
/**
* Build a document that mimics a rendered PDF page with text layer,
* matching the structure that pdf.js produces in the iframe.
*/
const createPDFTextLayerDoc = (textSpans: string[], annotationText?: string): Document => {
const parser = new DOMParser();
const spans = textSpans.map((t) => `<span>${t}</span>`).join('');
const annotation = annotationText
? `<div class="annotationLayer"><a href="#">${annotationText}</a></div>`
: '<div class="annotationLayer"></div>';
const html =
`<!DOCTYPE html><html lang="en">` +
`<body>` +
`<div id="canvas"><canvas></canvas></div>` +
`<div class="textLayer">${spans}</div>` +
`${annotation}` +
`</body></html>`;
return parser.parseFromString(html, 'text/html');
};
/** Node filter matching what TTSController uses for PDFs */
const pdfNodeFilter = createRejectFilter({
tags: ['rt', 'canvas'],
classes: ['annotationLayer'],
contents: [{ tag: 'a', content: /^[\[\(]?[\*\d]+[\)\]]?$/ }],
});
describe('PDF TTS', () => {
describe('TTS with PDF text layer document', () => {
it('should generate SSML from text layer spans', () => {
const doc = createPDFTextLayerDoc(['Alice was beginning to get very ', 'tired of sitting']);
const tts = new TTS(doc, textWalker, pdfNodeFilter, highlight, 'word');
const ssml = tts.start();
expect(ssml).toBeTruthy();
const text = stripTags(ssml!);
expect(text).toContain('Alice');
expect(text).toContain('tired');
});
it('should filter out canvas content', () => {
const doc = createPDFTextLayerDoc(['Hello world']);
const tts = new TTS(doc, textWalker, pdfNodeFilter, highlight, 'word');
const ssml = tts.start();
expect(ssml).toBeTruthy();
expect(stripTags(ssml!)).toBe('Hello world');
});
it('should filter out annotation layer text', () => {
const doc = createPDFTextLayerDoc(['Main text content'], 'Link annotation text');
const tts = new TTS(doc, textWalker, pdfNodeFilter, highlight, 'word');
const allText: string[] = [];
let ssml = tts.start();
while (ssml) {
allText.push(stripTags(ssml));
ssml = tts.next();
}
const combined = allText.join(' ');
expect(combined).toContain('Main text content');
expect(combined).not.toContain('Link annotation text');
});
it('should produce valid SSML with speak root element', () => {
const doc = createPDFTextLayerDoc(['Test content']);
const tts = new TTS(doc, textWalker, pdfNodeFilter, highlight, 'word');
const ssml = tts.start();
expect(ssml).toBeTruthy();
expect(ssml).toContain('<speak');
expect(ssml).toContain('</speak>');
});
it('should include mark elements in SSML output', () => {
const doc = createPDFTextLayerDoc(['Some text with multiple words']);
const tts = new TTS(doc, textWalker, pdfNodeFilter, highlight, 'word');
const ssml = tts.start();
expect(ssml).toBeTruthy();
expect(ssml).toContain('<mark');
});
});
describe('PDF node filter', () => {
it('should reject canvas elements', () => {
const canvas = document.createElement('canvas');
expect(pdfNodeFilter(canvas)).toBe(NodeFilter.FILTER_REJECT);
});
it('should reject annotationLayer elements', () => {
const div = document.createElement('div');
div.className = 'annotationLayer';
expect(pdfNodeFilter(div)).toBe(NodeFilter.FILTER_REJECT);
});
it('should reject rt elements', () => {
const rt = document.createElement('rt');
expect(pdfNodeFilter(rt)).toBe(NodeFilter.FILTER_REJECT);
});
it('should skip regular div elements', () => {
const div = document.createElement('div');
div.className = 'textLayer';
expect(pdfNodeFilter(div)).toBe(NodeFilter.FILTER_SKIP);
});
it('should accept text nodes', () => {
const text = document.createTextNode('hello');
expect(pdfNodeFilter(text)).toBe(NodeFilter.FILTER_ACCEPT);
});
it('should reject footnote-like anchor content', () => {
const a = document.createElement('a');
a.textContent = '[1]';
expect(pdfNodeFilter(a)).toBe(NodeFilter.FILTER_REJECT);
});
it('should not reject normal anchor content', () => {
const a = document.createElement('a');
a.textContent = 'Click here for more';
expect(pdfNodeFilter(a)).toBe(NodeFilter.FILTER_SKIP);
});
});
describe('DocumentLoader with sample-alice.pdf', () => {
let book: BookDoc;
beforeAll(async () => {
// Override workerSrc to an absolute file path so the pdfjs fake-worker
// can import it inside jsdom (the module-level code in pdf.js sets it
// to a URL path that only works in a real browser).
// Import pdf.js first to trigger the @pdfjs side-effect that sets globalThis.pdfjsLib.
await import('foliate-js/pdf.js');
const pdfjsLib = (globalThis as Record<string, unknown>)['pdfjsLib'] as {
GlobalWorkerOptions: { workerSrc: string };
};
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL(
`file://${join(vendorDir, 'pdfjs/pdf.worker.min.mjs')}`,
).href;
const pdfPath = resolve(__dirname, '../fixtures/data/sample-alice.pdf');
const buffer = readFileSync(pdfPath);
const file = new File([buffer], 'sample-alice.pdf', { type: 'application/pdf' });
const loader = new DocumentLoader(file);
const result = await loader.open();
book = result.book;
expect(result.format).toBe('PDF');
}, 30_000);
it('should load the sample PDF and return a book object', () => {
expect(book).toBeTruthy();
expect(book.rendition.layout).toBe('pre-paginated');
});
it('should have sections matching the number of pages', () => {
expect(book.sections).toBeTruthy();
expect(book.sections.length).toBeGreaterThan(0);
});
it('should extract metadata', () => {
expect(book.metadata).toBeTruthy();
// sample-alice.pdf should have a title
expect(book.metadata.title).toBeTruthy();
});
it('should provide createDocument on every section', () => {
for (const section of book.sections) {
expect(typeof section.createDocument).toBe('function');
}
});
it('should generate TTS SSML from createDocument output', async () => {
const doc = await book.sections[0]!.createDocument();
const tts = new TTS(doc, textWalker, undefined, highlight, 'word');
const ssml = tts.start();
expect(ssml).toBeTruthy();
expect(ssml).toContain('<speak');
expect(ssml).toContain('<mark');
const text = stripTags(ssml!);
expect(text.length).toBeGreaterThan(0);
});
it('should navigate through all TTS blocks of a page', async () => {
const doc = await book.sections[0]!.createDocument();
const tts = new TTS(doc, textWalker, undefined, highlight, 'word');
const blocks: string[] = [];
let ssml = tts.start();
while (ssml) {
blocks.push(stripTags(ssml));
ssml = tts.next();
}
expect(blocks.length).toBeGreaterThan(0);
for (const block of blocks) {
expect(block.length).toBeGreaterThan(0);
}
});
it('should produce createDocument output for multiple pages', async () => {
const pagesToTest = Math.min(book.sections.length, 3);
for (let i = 0; i < pagesToTest; i++) {
const doc = await book.sections[i]!.createDocument();
expect(doc).toBeTruthy();
const tts = new TTS(doc, textWalker, undefined, highlight, 'word');
const ssml = tts.start();
expect(ssml).toBeTruthy();
expect(stripTags(ssml!).length).toBeGreaterThan(0);
}
});
it('should return consistent text across repeated createDocument calls', async () => {
const doc1 = await book.sections[0]!.createDocument();
const doc2 = await book.sections[0]!.createDocument();
const tts1 = new TTS(doc1, textWalker, undefined, highlight, 'word');
const tts2 = new TTS(doc2, textWalker, undefined, highlight, 'word');
expect(stripTags(tts1.start()!)).toBe(stripTags(tts2.start()!));
});
it('should work with sentence granularity on real PDF content', async () => {
const doc = await book.sections[0]!.createDocument();
const tts = new TTS(doc, textWalker, undefined, highlight, 'sentence');
const ssml = tts.start();
expect(ssml).toBeTruthy();
expect(stripTags(ssml!).length).toBeGreaterThan(0);
});
it('should call highlight callback when marking words from PDF', async () => {
highlight.mockClear();
const doc = await book.sections[0]!.createDocument();
const tts = new TTS(doc, textWalker, undefined, highlight, 'word');
tts.start();
const range = tts.setMark('0');
expect(range).toBeTruthy();
expect(highlight).toHaveBeenCalled();
});
});
});

View file

@ -1,18 +1,7 @@
import { describe, it, expect, vi, beforeAll } from 'vitest';
import { describe, it, expect, vi } from 'vitest';
import { textWalker } from 'foliate-js/text-walker.js';
import { TTS } from 'foliate-js/tts.js';
beforeAll(() => {
if (typeof CSS === 'undefined' || !CSS.escape) {
Object.defineProperty(globalThis, 'CSS', {
value: {
escape: (s: string) => s.replace(/([^\w-])/g, '\\$1'),
},
writable: true,
});
}
});
const createHTMLDoc = (bodyHTML: string, attrs: Record<string, string> = {}): Document => {
const parser = new DOMParser();
const attrStr = Object.entries(attrs)

View file

@ -171,9 +171,11 @@ describe('TxtToEpubConverter', () => {
const reader = new ZipReader(new BlobReader(blob));
try {
const entries = await reader.getEntries();
const chapterEntry = entries.find((entry) => entry.filename === 'OEBPS/chapter1.xhtml');
const chapterEntry = entries.find((entry) => entry.filename === 'OEBPS/chapter1.xhtml') as {
getData?: (writer: unknown) => Promise<string>;
};
expect(chapterEntry).toBeDefined();
const chapterContent = await chapterEntry!.getData(new TextWriter());
const chapterContent = await chapterEntry?.getData?.(new TextWriter());
expect(chapterContent).toContain('lang="zh"');
expect(chapterContent).toContain('xml:lang="zh"');
} finally {

View file

@ -657,7 +657,7 @@ const FoliateViewer: React.FC<{
role='document'
aria-label={_('Book Content')}
className={clsx(
'foliate-viewer h-[100%] w-[100%] focus:outline-none',
'foliate-viewer absolute h-[100%] w-[100%] focus:outline-none',
viewState?.loading && 'bg-base-100',
)}
style={{

View file

@ -193,7 +193,7 @@ const ViewMenu: React.FC<ViewMenuProps> = ({ bookKey, setIsDropdownOpen }) => {
)}
onClick={resetZoom}
>
{zoomLevel}%
{Math.round(zoomLevel)}%
</button>
<button
title={_('Zoom In')}

View file

@ -27,7 +27,7 @@ import { findTocItemBS } from '@/utils/toc';
import { throttle } from '@/utils/throttle';
import { runSimpleCC } from '@/utils/simplecc';
import { getWordCount } from '@/utils/word';
import { isCfiInLocation } from '@/utils/cfi';
import { getIndexFromCfi, isCfiInLocation } from '@/utils/cfi';
import { TransformContext } from '@/services/transformers/types';
import { transformContent } from '@/services/transformService';
import { getHighlightColorHex } from '../../utils/annotatorUtil';
@ -273,7 +273,7 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
detail.doc?.addEventListener('selectionchange', handleSelectionchange.bind(null, doc, index));
// For PDF selections, enable right-click context menu to directly open translator popup.
if (bookData.book?.format === 'PDF') {
if (bookData.isFixedLayout) {
detail.doc?.addEventListener('contextmenu', (e: Event) => {
try {
const sel = doc.getSelection?.();
@ -281,7 +281,14 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
const range = sel.getRangeAt(0);
const text = sel.toString();
if (text.trim()) {
setSelection({ key: bookKey, text, range, index, cfi: view?.getCFI(index, range) });
setSelection({
key: bookKey,
text,
range,
index,
cfi: view?.getCFI(index, range),
page: index + 1,
});
// Show translation popup preferentially for PDF right-click
setShowAnnotPopup(false);
setShowDeepLPopup(true);
@ -303,6 +310,22 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
detail.doc?.addEventListener('contextmenu', handleContextmenu);
};
const onCreateOverlay = (event: Event) => {
const detail = (event as CustomEvent).detail;
const { booknotes = [] } = getConfig(bookKey)!;
booknotes
.filter(
(booknote) =>
booknote.type === 'annotation' &&
!booknote.deletedAt &&
getIndexFromCfi(booknote.cfi) === detail.index,
)
.map((annotation) => {
console.log('Adding annotation to overlay', annotation);
view?.addAnnotation(annotation);
});
};
const onDrawAnnotation = (event: Event) => {
const viewSettings = getViewSettings(bookKey)!;
const isBwEink = viewSettings.isEink && !viewSettings.isColorEink;
@ -366,8 +389,9 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
note: note ?? '',
rect: isNote ? detail.rect : undefined,
cfi,
range,
index,
range,
page: annotation.page || progress.page,
};
if (isNote) {
setShowAnnotationNotes(true);
@ -390,7 +414,7 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
handleUpToPopup();
};
useFoliateEvents(view, { onLoad, onDrawAnnotation, onShowAnnotation });
useFoliateEvents(view, { onLoad, onCreateOverlay, onDrawAnnotation, onShowAnnotation });
useEffect(() => {
handleShowPopup(showingPopup);
@ -608,9 +632,9 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
id: uniqueId(),
type: 'excerpt',
cfi,
text: selection.text,
note: '',
page: progress.page,
text: selection.text,
page: selection.page,
createdAt: Date.now(),
updatedAt: Date.now(),
};
@ -729,7 +753,12 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
if (!selection || !selection.text) return;
setShowAnnotPopup(false);
setEditingAnnotation(null);
eventDispatcher.dispatch('tts-speak', { bookKey, range: selection.range, oneTime });
eventDispatcher.dispatch('tts-speak', {
bookKey,
oneTime,
range: selection.range,
index: selection.index,
});
};
const handleProofread = () => {
@ -867,14 +896,12 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
tooltipText: selectionAnnotated ? _('Delete Highlight') : _(label),
Icon: selectionAnnotated ? RiDeleteBinLine : Icon,
onClick: handleHighlight,
disabled: bookData.book?.format === 'PDF',
};
case 'annotate':
return {
tooltipText: _(label),
Icon,
onClick: handleAnnotate,
disabled: bookData.book?.format === 'PDF',
};
case 'search':
return {
@ -894,7 +921,6 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
tooltipText: _(label),
Icon,
onClick: handleSpeakText,
disabled: bookData.book?.format === 'PDF',
};
case 'proofread':
return {

View file

@ -129,7 +129,6 @@ const Notebook: React.FC = ({}) => {
if (!sideBarBookKey) return;
const view = getView(sideBarBookKey);
const config = getConfig(sideBarBookKey)!;
const progress = getProgress(sideBarBookKey)!;
const cfi = view?.getCFI(selection.index, selection.range);
if (!cfi) return;
@ -140,7 +139,7 @@ const Notebook: React.FC = ({}) => {
type: 'annotation',
cfi,
note,
page: progress.page,
page: selection.page,
text: selection.text,
createdAt: Date.now(),
updatedAt: Date.now(),

View file

@ -27,7 +27,7 @@ export const useAnnotationEditor = ({
const { envConfig } = useEnv();
const { settings } = useSettingsStore();
const { getConfig, saveConfig, updateBooknotes } = useBookDataStore();
const { getView, getViewsById } = useReaderStore();
const { getView, getProgress, getViewsById } = useReaderStore();
const view = getView(bookKey);
const editingAnnotationRef = useRef(annotation);
@ -144,14 +144,16 @@ export const useAnnotationEditor = ({
if (newCfi && newText) {
const config = getConfig(bookKey)!;
const progress = getProgress(bookKey)!;
const { booknotes: annotations = [] } = config;
const existingIndex = annotations.findIndex(
(a) => a.id === editingAnnotationRef.current.id && !a.deletedAt,
);
if (existingIndex !== -1) {
const existingAnnotation = annotations[existingIndex]!;
const updatedAnnotation: BookNote = {
...annotations[existingIndex]!,
...existingAnnotation,
cfi: newCfi,
text: newText,
updatedAt: Date.now(),
@ -174,8 +176,9 @@ export const useAnnotationEditor = ({
annotated: true,
text: newText,
cfi: newCfi,
range: newRange,
index: targetIndex,
range: newRange,
page: existingAnnotation.page || progress.page,
});
}
}

View file

@ -6,6 +6,7 @@ type FoliateEventHandler = {
onRelocate?: (event: Event) => void;
onLinkClick?: (event: Event) => void;
onRendererRelocate?: (event: Event) => void;
onCreateOverlay?: (event: Event) => void;
onDrawAnnotation?: (event: Event) => void;
onShowAnnotation?: (event: Event) => void;
};
@ -15,6 +16,7 @@ export const useFoliateEvents = (view: FoliateView | null, handlers?: FoliateEve
const onRelocate = handlers?.onRelocate;
const onLinkClick = handlers?.onLinkClick;
const onRendererRelocate = handlers?.onRendererRelocate;
const onCreateOverlay = handlers?.onCreateOverlay;
const onDrawAnnotation = handlers?.onDrawAnnotation;
const onShowAnnotation = handlers?.onShowAnnotation;
@ -24,6 +26,7 @@ export const useFoliateEvents = (view: FoliateView | null, handlers?: FoliateEve
if (onRelocate) view.addEventListener('relocate', onRelocate);
if (onLinkClick) view.addEventListener('link', onLinkClick);
if (onRendererRelocate) view.renderer.addEventListener('relocate', onRendererRelocate);
if (onCreateOverlay) view.addEventListener('create-overlay', onCreateOverlay);
if (onDrawAnnotation) view.addEventListener('draw-annotation', onDrawAnnotation);
if (onShowAnnotation) view.addEventListener('show-annotation', onShowAnnotation);
@ -32,6 +35,7 @@ export const useFoliateEvents = (view: FoliateView | null, handlers?: FoliateEve
if (onRelocate) view.removeEventListener('relocate', onRelocate);
if (onLinkClick) view.removeEventListener('link', onLinkClick);
if (onRendererRelocate) view.renderer.removeEventListener('relocate', onRendererRelocate);
if (onCreateOverlay) view.removeEventListener('create-overlay', onCreateOverlay);
if (onDrawAnnotation) view.removeEventListener('draw-annotation', onDrawAnnotation);
if (onShowAnnotation) view.removeEventListener('show-annotation', onShowAnnotation);
};

View file

@ -203,7 +203,7 @@ export const useParagraphMode = ({ bookKey, viewRef }: UseParagraphModeProps) =>
if (docIndex !== undefined && renderer.goTo) {
renderer.goTo({ index: docIndex, anchor: range });
} else {
view.renderer.scrollToAnchor(range);
view.renderer.scrollToAnchor?.(range);
}
focusResetTimerRef.current = setTimeout(() => {
isFocusingRef.current = false;
@ -382,7 +382,7 @@ export const useParagraphMode = ({ bookKey, viewRef }: UseParagraphModeProps) =>
docIndex,
};
}
view.renderer.scrollToAnchor(range);
view.renderer.scrollToAnchor?.(range);
}
}
eventDispatcher.dispatch('paragraph-mode-disabled', { bookKey: bookKeyRef.current });

View file

@ -143,7 +143,7 @@ export const useTTSControl = ({ bookKey, onRequestHidePanel }: UseTTSControlProp
const range = anchor(doc);
if (!view.renderer.scrolled) {
view.renderer.scrollToAnchor(range);
view.renderer.scrollToAnchor?.(range);
} else {
const rect = range.getBoundingClientRect();
const { start, size, viewSize, sideProp } = view.renderer;
@ -162,7 +162,7 @@ export const useTTSControl = ({ bookKey, onRequestHidePanel }: UseTTSControlProp
const startInPrevView = offsetStart < start + headerScrollOverlap + scrollingOverlap;
if (endInNextView || startInPrevView) {
const scrollTo = offsetStart - headerScrollOverlap - scrollingOverlap;
view.renderer.scrollToAnchor(scrollTo / viewSize);
view.renderer.scrollToAnchor?.(scrollTo / viewSize);
}
}
};
@ -319,7 +319,7 @@ export const useTTSControl = ({ bookKey, onRequestHidePanel }: UseTTSControlProp
useEffect(() => {
const ttsHighlightOptions = viewSettings?.ttsHighlightOptions;
if (ttsControllerRef.current && ttsHighlightOptions) {
ttsControllerRef.current.initViewTTS(
ttsControllerRef.current.updateHighlightOptions(
getTTSHighlightOptions(ttsHighlightOptions, viewSettings!.isEink),
);
}
@ -355,7 +355,7 @@ export const useTTSControl = ({ bookKey, onRequestHidePanel }: UseTTSControlProp
// handleTTSSpeak / handleTTSStop (plain functions, registered once at mount via closure)
const handleTTSSpeak = async (event: CustomEvent) => {
const { bookKey: ttsBookKey, range, oneTime = false } = event.detail;
const { bookKey: ttsBookKey, range, index, oneTime = false } = event.detail;
if (bookKey !== ttsBookKey) return;
const view = getView(bookKey);
@ -364,16 +364,9 @@ export const useTTSControl = ({ bookKey, onRequestHidePanel }: UseTTSControlProp
const bookData = getBookData(bookKey);
const { location } = progress || {};
if (!view || !progress || !viewSettings || !bookData || !bookData.book) return;
if (bookData.book?.format === 'PDF') {
eventDispatcher.dispatch('toast', {
message: _('TTS not supported for PDF'),
type: 'warning',
});
return;
}
const ttsSpeakRange = range as Range | null;
let ttsFromRange = ttsSpeakRange;
let ttsFromIndex = typeof index === 'number' ? index : null;
if (!ttsFromRange && viewSettings.ttsLocation) {
const ttsCfi = viewSettings.ttsLocation;
if (isCfiInLocation(ttsCfi, location)) {
@ -381,14 +374,16 @@ export const useTTSControl = ({ bookKey, onRequestHidePanel }: UseTTSControlProp
const { doc } = view.renderer.getContents().find((x) => x.index === index) || {};
if (doc) {
ttsFromRange = anchor(doc);
ttsFromIndex = index;
}
}
}
if (!ttsFromRange) {
if (!ttsFromRange || !ttsFromIndex) {
ttsFromRange = progress.range;
ttsFromIndex = progress.index;
}
const currentSection = view.renderer.getContents()[0];
const currentSection = view.renderer.getContents().find((x) => x.index === ttsFromIndex);
if (ttsFromRange && currentSection) {
const ttsLocation = view.getCFI(currentSection?.index || 0, ttsFromRange);
viewSettings.ttsLocation = ttsLocation;
@ -427,7 +422,8 @@ export const useTTSControl = ({ bookKey, onRequestHidePanel }: UseTTSControlProp
setTtsController(ttsController);
await ttsController.init();
await ttsController.initViewTTS(
await ttsController.initViewTTS(ttsFromIndex);
ttsController.updateHighlightOptions(
getTTSHighlightOptions(viewSettings.ttsHighlightOptions, viewSettings.isEink),
);
const ssml =

View file

@ -1,6 +1,7 @@
import { useEffect, useRef } from 'react';
import { useEnv } from '@/context/EnvContext';
import { useReaderStore } from '@/store/readerStore';
import { useBookDataStore } from '@/store/bookDataStore';
import { getOSPlatform } from '@/utils/misc';
import { eventDispatcher } from '@/utils/event';
import { isPointerInsideSelection, TextSelection } from '@/utils/sel';
@ -13,8 +14,11 @@ export const useTextSelector = (
handleDismissPopup: () => void,
) => {
const { appService } = useEnv();
const { getView, getViewSettings } = useReaderStore();
const { getBookData } = useBookDataStore();
const { getView, getViewSettings, getProgress } = useReaderStore();
const view = getView(bookKey);
const bookData = getBookData(bookKey);
const progress = getProgress(bookKey)!;
const osPlatform = getOSPlatform();
const isPopuped = useRef(false);
@ -49,6 +53,7 @@ export const useTextSelector = (
key: bookKey,
text: await getAnnotationText(range),
cfi: view?.getCFI(index, range),
page: bookData?.isFixedLayout ? index + 1 : progress.page,
range,
index,
});
@ -66,6 +71,7 @@ export const useTextSelector = (
key: bookKey,
text: await getAnnotationText(range),
cfi: view?.getCFI(index, range),
page: bookData?.isFixedLayout ? index + 1 : progress.page,
range,
index,
});

View file

@ -65,14 +65,14 @@ export type BookMetadata = {
export interface BookDoc {
metadata: BookMetadata;
rendition?: {
rendition: {
layout?: 'pre-paginated' | 'reflowable';
spread?: 'auto' | 'none';
viewport?: { width: number; height: number };
};
dir: string;
toc?: Array<TOCItem>;
sections?: Array<SectionItem>;
sections: Array<SectionItem>;
transformTarget?: EventTarget;
splitTOCHref(href: string): Array<string | number>;
getCover(): Promise<Blob | null>;

View file

@ -113,7 +113,9 @@ export class TTSController extends EventTarget {
const { style, color } = this.options;
overlayer?.remove(HIGHLIGHT_KEY);
overlayer?.add(HIGHLIGHT_KEY, visibleRange, Overlayer[style], { color });
} catch {}
} catch (e) {
console.error('Failed to highlight range', e);
}
};
}
@ -122,14 +124,15 @@ export class TTSController extends EventTarget {
overlayer?.remove(HIGHLIGHT_KEY);
}
async initViewTTS(options?: TTSHighlightOptions) {
if (options) {
this.options.style = options.style;
this.options.color = options.color;
}
const currentSectionIndex = this.view.renderer.getContents()[0]?.index ?? 0;
updateHighlightOptions(options: TTSHighlightOptions) {
this.options.style = options.style;
this.options.color = options.color;
}
async initViewTTS(index?: number) {
if (this.#ttsSectionIndex === -1) {
await this.#initTTSForSection(currentSectionIndex);
const fromSectionIndex = (index || this.view.renderer.getContents()[0]?.index) ?? 0;
await this.#initTTSForSection(fromSectionIndex);
}
}

View file

@ -362,13 +362,13 @@ export const useReaderStore = create<ReaderStore>((set, get) => ({
location,
sectionHref: tocItem?.href,
sectionLabel: tocItem?.label,
sectionId: tocItem?.id,
section,
pageinfo,
timeinfo,
index: section.current,
range,
page: pageInfo.current + 1, // 1-based page number
},
} as BookProgress,
},
},
};

View file

@ -307,12 +307,12 @@ export interface ViewSettings
export interface BookProgress {
location: string;
sectionId: number;
sectionHref: string;
sectionLabel: string;
section: PageInfo;
pageinfo: PageInfo;
timeinfo: TimeInfo;
index: number;
range: Range;
page: number;
}

View file

@ -45,6 +45,7 @@ export interface FoliateView extends HTMLElement {
) => Promise<void>;
book: BookDoc;
tts: TTS | null;
isFixedLayout: boolean;
language: {
locale?: LocaleWithTextInfo;
isCJK?: boolean;
@ -80,7 +81,7 @@ export interface FoliateView extends HTMLElement {
goTo?: (params: { index: number; anchor?: number | RangeAnchor }) => void;
setStyles?: (css: string) => void;
getContents: () => { doc: Document; index?: number; overlayer?: unknown }[];
scrollToAnchor: (anchor: number | Range) => void;
scrollToAnchor?: (anchor: number | Range) => void;
addEventListener: (
type: string,
listener: EventListener,

View file

@ -8,3 +8,12 @@ export function isCfiInLocation(cfi: string, location: string | null | undefined
return CFI.compare(cfi, start) >= 0 && CFI.compare(cfi, end) <= 0;
}
export function getIndexFromCfi(cfi: string): number | null {
try {
const parts = CFI.parse(cfi);
return CFI.fake.toIndex((parts.parent ?? parts).shift());
} catch {
return null;
}
}

View file

@ -3,7 +3,7 @@ import nunjucks from 'nunjucks';
export type NoteTemplateData = {
title: string;
author: string;
exportDate: number;
exportDate: number | string;
chapters: {
title: string;
annotations: {

View file

@ -25,6 +25,7 @@ export interface Position {
export interface TextSelection {
key: string;
text: string;
page: number;
range: Range;
index: number;
cfi?: string;

View file

@ -1,9 +1,18 @@
import path from 'path';
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
import tsconfigPaths from 'vite-tsconfig-paths';
export default defineConfig({
plugins: [tsconfigPaths(), react()],
resolve: {
alias: {
// The @pdfjs alias from tsconfig only resolves within the app's own
// source files. foliate-js/pdf.js lives outside that scope, so Vite
// needs an explicit alias to find the vendored pdfjs build.
'@pdfjs': path.resolve(__dirname, 'public/vendor/pdfjs'),
},
},
test: {
environment: 'jsdom',
setupFiles: ['./vitest.setup.ts'],

View file

@ -1,49 +1,3 @@
// localStorage mock
if (typeof window !== 'undefined' && !window.localStorage) {
const storage: Record<string, string> = {};
window.localStorage = {
getItem: (key: string) => storage[key] || null,
setItem: (key: string, value: string) => {
storage[key] = value;
},
removeItem: (key: string) => {
delete storage[key];
},
clear: () => {
Object.keys(storage).forEach((key) => delete storage[key]);
},
get length() {
return Object.keys(storage).length;
},
key: (index: number) => {
const keys = Object.keys(storage);
return keys[index] || null;
},
} as Storage;
} else if (typeof window !== 'undefined' && window.localStorage && !window.localStorage.getItem) {
// If localStorage exists but getItem is not a function, replace it
const storage: Record<string, string> = {};
window.localStorage = {
getItem: (key: string) => storage[key] || null,
setItem: (key: string, value: string) => {
storage[key] = value;
},
removeItem: (key: string) => {
delete storage[key];
},
clear: () => {
Object.keys(storage).forEach((key) => delete storage[key]);
},
get length() {
return Object.keys(storage).length;
},
key: (index: number) => {
const keys = Object.keys(storage);
return keys[index] || null;
},
} as Storage;
}
// matchMedia mock
if (typeof window !== 'undefined' && !window.matchMedia) {
window.matchMedia = (query: string) =>

@ -1 +1 @@
Subproject commit e327bfd1ea396f43b0362da5c14d3ee0a416ca91
Subproject commit b00c540fbcb9a778e1d6c66bf163124a7e313182

325
pnpm-lock.yaml generated
View file

@ -496,8 +496,8 @@ importers:
specifier: ^4.6.0
version: 4.6.0(typescript@5.9.3)
jsdom:
specifier: ^26.1.0
version: 26.1.0
specifier: ^28.1.0
version: 28.1.0(@noble/hashes@1.8.0)
mkdirp:
specifier: ^3.0.1
version: 3.0.1
@ -542,7 +542,7 @@ importers:
version: 5.1.4(typescript@5.9.3)(vite@7.3.1(@types/node@22.19.7)(jiti@1.21.7)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))
vitest:
specifier: ^4.0.18
version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@22.19.7)(@vitest/browser-playwright@4.0.18)(@vitest/browser-webdriverio@4.0.18)(jiti@1.21.7)(jsdom@26.1.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@22.19.7)(@vitest/browser-playwright@4.0.18)(@vitest/browser-webdriverio@4.0.18)(jiti@1.21.7)(jsdom@28.1.0(@noble/hashes@1.8.0))(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
wrangler:
specifier: ^4.60.0
version: 4.60.0
@ -583,6 +583,9 @@ importers:
packages:
'@acemir/cssom@0.9.31':
resolution: {integrity: sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA==}
'@ai-sdk/gateway@2.0.29':
resolution: {integrity: sha512-1b7E9F/B5gex/1uCkhs+sGIbH0KsZOItHnNz3iY5ir+nc4ZUA6WOU5Cu2w1USlc+3UVbhf+H+iNLlxVjLe4VvQ==}
engines: {node: '>=18'}
@ -636,8 +639,15 @@ packages:
'@antfu/install-pkg@1.1.0':
resolution: {integrity: sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==}
'@asamuzakjp/css-color@3.2.0':
resolution: {integrity: sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==}
'@asamuzakjp/css-color@5.0.1':
resolution: {integrity: sha512-2SZFvqMyvboVV1d15lMf7XiI3m7SDqXUuKaTymJYLN6dSGadqp+fVojqJlVoMlbZnlTmu3S0TLwLTJpvBMO1Aw==}
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
'@asamuzakjp/dom-selector@6.8.1':
resolution: {integrity: sha512-MvRz1nCqW0fsy8Qz4dnLIvhOlMzqDVBabZx6lH+YywFDdjXhMY37SmpV1XFX3JzG5GWHn63j6HX6QPr3lZXHvQ==}
'@asamuzakjp/nwsapi@2.3.9':
resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==}
'@assistant-ui/react-ai-sdk@1.1.21':
resolution: {integrity: sha512-TRJx6fDoIqUpkl1LuRZB2QHGZ7pXAc4qtX05h+z0yYIMY4Y2/XzwxlitpcS+NSo1PS8YCgZaM9HO1y66H/il2Q==}
@ -1233,6 +1243,10 @@ packages:
'@braintree/sanitize-url@7.1.1':
resolution: {integrity: sha512-i1L7noDNxtFyL5DmZafWy1wRVhGehQmzZaz1HiN5e7iylJMSZR7ekOV7NsIqa5qBldlLrsKv4HbgFUVlQrz8Mw==}
'@bramus/specificity@2.4.2':
resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==}
hasBin: true
'@chevrotain/cst-dts-gen@11.0.3':
resolution: {integrity: sha512-BvIKpRLeS/8UbfxXxgC33xOumsacaeCKAjAeLyOn7Pcp95HiRbrpl14S+9vaZLolnbssPIUuiUd8IvgkRyt6NQ==}
@ -1298,33 +1312,36 @@ packages:
resolution: {integrity: sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==}
engines: {node: '>=12'}
'@csstools/color-helpers@5.1.0':
resolution: {integrity: sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==}
engines: {node: '>=18'}
'@csstools/color-helpers@6.0.2':
resolution: {integrity: sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==}
engines: {node: '>=20.19.0'}
'@csstools/css-calc@2.1.4':
resolution: {integrity: sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==}
engines: {node: '>=18'}
'@csstools/css-calc@3.1.1':
resolution: {integrity: sha512-HJ26Z/vmsZQqs/o3a6bgKslXGFAungXGbinULZO3eMsOyNJHeBBZfup5FiZInOghgoM4Hwnmw+OgbJCNg1wwUQ==}
engines: {node: '>=20.19.0'}
peerDependencies:
'@csstools/css-parser-algorithms': ^3.0.5
'@csstools/css-tokenizer': ^3.0.4
'@csstools/css-parser-algorithms': ^4.0.0
'@csstools/css-tokenizer': ^4.0.0
'@csstools/css-color-parser@3.1.0':
resolution: {integrity: sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==}
engines: {node: '>=18'}
'@csstools/css-color-parser@4.0.2':
resolution: {integrity: sha512-0GEfbBLmTFf0dJlpsNU7zwxRIH0/BGEMuXLTCvFYxuL1tNhqzTbtnFICyJLTNK4a+RechKP75e7w42ClXSnJQw==}
engines: {node: '>=20.19.0'}
peerDependencies:
'@csstools/css-parser-algorithms': ^3.0.5
'@csstools/css-tokenizer': ^3.0.4
'@csstools/css-parser-algorithms': ^4.0.0
'@csstools/css-tokenizer': ^4.0.0
'@csstools/css-parser-algorithms@3.0.5':
resolution: {integrity: sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==}
engines: {node: '>=18'}
'@csstools/css-parser-algorithms@4.0.0':
resolution: {integrity: sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==}
engines: {node: '>=20.19.0'}
peerDependencies:
'@csstools/css-tokenizer': ^3.0.4
'@csstools/css-tokenizer': ^4.0.0
'@csstools/css-tokenizer@3.0.4':
resolution: {integrity: sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==}
engines: {node: '>=18'}
'@csstools/css-syntax-patches-for-csstree@1.1.0':
resolution: {integrity: sha512-H4tuz2nhWgNKLt1inYpoVCfbJbMwX/lQKp3g69rrrIMIYlFD9+zTykOKhNR8uGrAmbS/kT9n6hTFkmDkxLgeTA==}
'@csstools/css-tokenizer@4.0.0':
resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==}
engines: {node: '>=20.19.0'}
'@discoveryjs/json-ext@0.5.7':
resolution: {integrity: sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw==}
@ -1849,6 +1866,15 @@ packages:
resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@exodus/bytes@1.15.0':
resolution: {integrity: sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==}
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
'@fabianlars/tauri-plugin-oauth@2.0.0':
resolution: {integrity: sha512-I1s08ZXrsFuYfNWusAcpLyiCfr5TCvaBrRuKfTG+XQrcaqnAcwjdWH0U5J9QWuMDLwCUMnVxdobtMJzPR8raxQ==}
@ -4957,6 +4983,9 @@ packages:
resolution: {integrity: sha512-VoMINM2rqJwJgfdHq6RiUudKt2BV+FY5ZFezP/ypmwayk68+NzzAQy4XXLlqsGD4MCzq3DrmNFD/uUmBJuGoXw==}
engines: {node: '>=10.0.0'}
bidi-js@1.0.3:
resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==}
big.js@5.2.2:
resolution: {integrity: sha512-vyL2OymJxmarO8gxMr0mhChsO9QGwhynfuu4+MHTAW6czfq9humCB7rKpUjDd9YUiDPU4mzpyupFSvOClAwbmQ==}
@ -5342,6 +5371,10 @@ packages:
css-to-react-native@3.2.0:
resolution: {integrity: sha512-e8RKaLXMOFii+02mOlqwjbD00KSEKqblnpO9e++1aXS1fPQOpS1YoqdVHBqPjHNoxeF2mimzVqawm2KCbEdtHQ==}
css-tree@3.2.1:
resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==}
engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0}
css-value@0.0.1:
resolution: {integrity: sha512-FUV3xaJ63buRLgHrLQVlVgQnQdR4yqdLGaDu7g8CQcWjInDfM9plBTPI9FRfpahju1UBSaMckeb2/46ApS/V1Q==}
@ -5354,9 +5387,9 @@ packages:
engines: {node: '>=4'}
hasBin: true
cssstyle@4.6.0:
resolution: {integrity: sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==}
engines: {node: '>=18'}
cssstyle@6.2.0:
resolution: {integrity: sha512-Fm5NvhYathRnXNVndkUsCCuR63DCLVVwGOOwQw782coXFi5HhkXdu289l59HlXZBawsyNccXfWRYvLzcDCdDig==}
engines: {node: '>=20'}
csstype@3.2.3:
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
@ -5536,9 +5569,9 @@ packages:
resolution: {integrity: sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw==}
engines: {node: '>= 14'}
data-urls@5.0.0:
resolution: {integrity: sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==}
engines: {node: '>=18'}
data-urls@7.0.0:
resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==}
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
data-view-buffer@1.0.2:
resolution: {integrity: sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==}
@ -6527,9 +6560,9 @@ packages:
resolution: {integrity: sha512-Rw/B2DNQaPBICNXEm8balFz9a6WpZrkCGpcWFpy7nCj+NyhSdqXipmfvtmWt9xGfp0wZnBxB+iVpLmQMYt47Tw==}
engines: {node: ^18.17.0 || >=20.5.0}
html-encoding-sniffer@4.0.0:
resolution: {integrity: sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==}
engines: {node: '>=18'}
html-encoding-sniffer@6.0.0:
resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==}
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
html-escaper@2.0.2:
resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==}
@ -6952,9 +6985,9 @@ packages:
resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==}
hasBin: true
jsdom@26.1.0:
resolution: {integrity: sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==}
engines: {node: '>=18'}
jsdom@28.1.0:
resolution: {integrity: sha512-0+MoQNYyr2rBHqO1xilltfDjV9G7ymYGlAUazgcDLQaUf8JDHbuGwsxN6U9qWaElZ4w1B2r7yEGIL3GdeW3Rug==}
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
peerDependencies:
canvas: ^3.0.0
peerDependenciesMeta:
@ -7177,8 +7210,8 @@ packages:
lru-cache@10.4.3:
resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==}
lru-cache@11.2.4:
resolution: {integrity: sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg==}
lru-cache@11.2.6:
resolution: {integrity: sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==}
engines: {node: 20 || >=22}
lru-cache@5.1.1:
@ -7279,6 +7312,9 @@ packages:
mdast-util-to-string@4.0.0:
resolution: {integrity: sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==}
mdn-data@2.27.1:
resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==}
media-typer@1.1.0:
resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==}
engines: {node: '>= 0.8'}
@ -7639,9 +7675,6 @@ packages:
chokidar:
optional: true
nwsapi@2.2.23:
resolution: {integrity: sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ==}
object-assign@4.1.1:
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
engines: {node: '>=0.10.0'}
@ -7812,6 +7845,9 @@ packages:
parse5@7.3.0:
resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==}
parse5@8.0.0:
resolution: {integrity: sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==}
parseurl@1.3.3:
resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==}
engines: {node: '>= 0.8'}
@ -8498,9 +8534,6 @@ packages:
resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==}
engines: {node: '>= 18'}
rrweb-cssom@0.8.0:
resolution: {integrity: sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==}
rsc-html-stream@0.0.7:
resolution: {integrity: sha512-v9+fuY7usTgvXdNl8JmfXCvSsQbq2YMd60kOeeMIqCJFZ69fViuIxztHei7v5mlMMa2h3SqS+v44Gu9i9xANZA==}
@ -9062,11 +9095,11 @@ packages:
resolution: {integrity: sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==}
engines: {node: '>=14.0.0'}
tldts-core@6.1.86:
resolution: {integrity: sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==}
tldts-core@7.0.25:
resolution: {integrity: sha512-ZjCZK0rppSBu7rjHYDYsEaMOIbbT+nWF57hKkv4IUmZWBNrBWBOjIElc0mKRgLM8bm7x/BBlof6t2gi/Oq/Asw==}
tldts@6.1.86:
resolution: {integrity: sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==}
tldts@7.0.25:
resolution: {integrity: sha512-keinCnPbwXEUG3ilrWQZU+CqcTTzHq9m2HhoUP2l7Xmi8l1LuijAXLpAJ5zRW+ifKTNscs4NdCkfkDCBYm352w==}
hasBin: true
to-regex-range@5.0.1:
@ -9085,8 +9118,8 @@ packages:
resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==}
engines: {node: '>=6'}
tough-cookie@5.1.2:
resolution: {integrity: sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==}
tough-cookie@6.0.0:
resolution: {integrity: sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==}
engines: {node: '>=16'}
tr46@0.0.3:
@ -9095,9 +9128,9 @@ packages:
tr46@1.0.1:
resolution: {integrity: sha512-dTpowEjclQ7Kgx5SdBkqRzVhERQXov8/l9Ft9dVM9fmg0W0KQSVaXX9T4i6twCPNtYiZM53lpSSUAwJbFPOHxA==}
tr46@5.1.1:
resolution: {integrity: sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==}
engines: {node: '>=18'}
tr46@6.0.0:
resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==}
engines: {node: '>=20'}
trigram-utils@2.0.1:
resolution: {integrity: sha512-nfWIXHEaB+HdyslAfMxSqWKDdmqY9I32jS7GnqpdWQnLH89r6A5sdk3fDVYqGAZ0CrT8ovAFSAo6HRiWcWNIGQ==}
@ -9577,9 +9610,9 @@ packages:
webidl-conversions@4.0.2:
resolution: {integrity: sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg==}
webidl-conversions@7.0.0:
resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==}
engines: {node: '>=12'}
webidl-conversions@8.0.1:
resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==}
engines: {node: '>=20'}
webpack-bundle-analyzer@4.10.1:
resolution: {integrity: sha512-s3P7pgexgT/HTUSYgxJyn28A+99mmLq4HsJepMPzu0R8ImJc52QNqaFYW1Z2z2uIb1/J3eYgaAWVpaC+v/1aAQ==}
@ -9611,9 +9644,13 @@ packages:
resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==}
engines: {node: '>=18'}
whatwg-url@14.2.0:
resolution: {integrity: sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==}
engines: {node: '>=18'}
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}
whatwg-url@5.0.0:
resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==}
@ -9865,6 +9902,8 @@ packages:
snapshots:
'@acemir/cssom@0.9.31': {}
'@ai-sdk/gateway@2.0.29(zod@4.3.6)':
dependencies:
'@ai-sdk/provider': 2.0.1
@ -9925,13 +9964,23 @@ snapshots:
package-manager-detector: 1.6.0
tinyexec: 1.0.2
'@asamuzakjp/css-color@3.2.0':
'@asamuzakjp/css-color@5.0.1':
dependencies:
'@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)
'@csstools/css-color-parser': 3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)
'@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4)
'@csstools/css-tokenizer': 3.0.4
lru-cache: 10.4.3
'@csstools/css-calc': 3.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)
'@csstools/css-color-parser': 4.0.2(@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
lru-cache: 11.2.6
'@asamuzakjp/dom-selector@6.8.1':
dependencies:
'@asamuzakjp/nwsapi': 2.3.9
bidi-js: 1.0.3
css-tree: 3.2.1
is-potential-custom-element-name: 1.0.1
lru-cache: 11.2.6
'@asamuzakjp/nwsapi@2.3.9': {}
'@assistant-ui/react-ai-sdk@1.1.21(@assistant-ui/react@0.11.56(@types/react-dom@19.2.3(@types/react@19.2.9))(@types/react@19.2.9)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(use-sync-external-store@1.6.0(react@19.2.4)))(@types/react@19.2.9)(assistant-cloud@0.1.13)(react@19.2.4)':
dependencies:
@ -11410,6 +11459,10 @@ snapshots:
'@braintree/sanitize-url@7.1.1': {}
'@bramus/specificity@2.4.2':
dependencies:
css-tree: 3.2.1
'@chevrotain/cst-dts-gen@11.0.3':
dependencies:
'@chevrotain/gast': 11.0.3
@ -11458,25 +11511,27 @@ snapshots:
dependencies:
'@jridgewell/trace-mapping': 0.3.9
'@csstools/color-helpers@5.1.0': {}
'@csstools/color-helpers@6.0.2': {}
'@csstools/css-calc@2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)':
'@csstools/css-calc@3.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)':
dependencies:
'@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4)
'@csstools/css-tokenizer': 3.0.4
'@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0)
'@csstools/css-tokenizer': 4.0.0
'@csstools/css-color-parser@3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)':
'@csstools/css-color-parser@4.0.2(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)':
dependencies:
'@csstools/color-helpers': 5.1.0
'@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)
'@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4)
'@csstools/css-tokenizer': 3.0.4
'@csstools/color-helpers': 6.0.2
'@csstools/css-calc': 3.1.1(@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@3.0.5(@csstools/css-tokenizer@3.0.4)':
'@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0)':
dependencies:
'@csstools/css-tokenizer': 3.0.4
'@csstools/css-tokenizer': 4.0.0
'@csstools/css-tokenizer@3.0.4': {}
'@csstools/css-syntax-patches-for-csstree@1.1.0': {}
'@csstools/css-tokenizer@4.0.0': {}
'@discoveryjs/json-ext@0.5.7': {}
@ -11791,6 +11846,10 @@ snapshots:
'@eslint/core': 0.17.0
levn: 0.4.1
'@exodus/bytes@1.15.0(@noble/hashes@1.8.0)':
optionalDependencies:
'@noble/hashes': 1.8.0
'@fabianlars/tauri-plugin-oauth@2.0.0':
dependencies:
'@tauri-apps/api': 2.10.1
@ -14550,7 +14609,7 @@ snapshots:
'@vitest/mocker': 4.0.18(vite@7.3.1(@types/node@22.19.7)(jiti@1.21.7)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))
playwright: 1.58.2
tinyrainbow: 3.0.3
vitest: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@22.19.7)(@vitest/browser-playwright@4.0.18)(@vitest/browser-webdriverio@4.0.18)(jiti@1.21.7)(jsdom@26.1.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
vitest: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@22.19.7)(@vitest/browser-playwright@4.0.18)(@vitest/browser-webdriverio@4.0.18)(jiti@1.21.7)(jsdom@28.1.0(@noble/hashes@1.8.0))(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
transitivePeerDependencies:
- bufferutil
- msw
@ -14560,7 +14619,7 @@ snapshots:
'@vitest/browser-webdriverio@4.0.18(vite@7.3.1(@types/node@22.19.7)(jiti@1.21.7)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(vitest@4.0.18)(webdriverio@9.24.0)':
dependencies:
'@vitest/browser': 4.0.18(vite@7.3.1(@types/node@22.19.7)(jiti@1.21.7)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(vitest@4.0.18)
vitest: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@22.19.7)(@vitest/browser-playwright@4.0.18)(@vitest/browser-webdriverio@4.0.18)(jiti@1.21.7)(jsdom@26.1.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
vitest: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@22.19.7)(@vitest/browser-playwright@4.0.18)(@vitest/browser-webdriverio@4.0.18)(jiti@1.21.7)(jsdom@28.1.0(@noble/hashes@1.8.0))(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
webdriverio: 9.24.0
transitivePeerDependencies:
- bufferutil
@ -14577,7 +14636,7 @@ snapshots:
pngjs: 7.0.0
sirv: 3.0.2
tinyrainbow: 3.0.3
vitest: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@22.19.7)(@vitest/browser-playwright@4.0.18)(@vitest/browser-webdriverio@4.0.18)(jiti@1.21.7)(jsdom@26.1.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
vitest: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@22.19.7)(@vitest/browser-playwright@4.0.18)(@vitest/browser-webdriverio@4.0.18)(jiti@1.21.7)(jsdom@28.1.0(@noble/hashes@1.8.0))(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
ws: 8.19.0
transitivePeerDependencies:
- bufferutil
@ -15252,6 +15311,10 @@ snapshots:
basic-ftp@5.2.0: {}
bidi-js@1.0.3:
dependencies:
require-from-string: 2.0.2
big.js@5.2.2: {}
bignumber.js@9.3.1: {}
@ -15676,16 +15739,23 @@ snapshots:
css-color-keywords: 1.0.0
postcss-value-parser: 4.2.0
css-tree@3.2.1:
dependencies:
mdn-data: 2.27.1
source-map-js: 1.2.1
css-value@0.0.1: {}
css-what@6.2.2: {}
cssesc@3.0.0: {}
cssstyle@4.6.0:
cssstyle@6.2.0:
dependencies:
'@asamuzakjp/css-color': 3.2.0
rrweb-cssom: 0.8.0
'@asamuzakjp/css-color': 5.0.1
'@csstools/css-syntax-patches-for-csstree': 1.1.0
css-tree: 3.2.1
lru-cache: 11.2.6
csstype@3.2.3: {}
@ -15890,10 +15960,12 @@ snapshots:
data-uri-to-buffer@6.0.2: {}
data-urls@5.0.0:
data-urls@7.0.0(@noble/hashes@1.8.0):
dependencies:
whatwg-mimetype: 4.0.0
whatwg-url: 14.2.0
whatwg-mimetype: 5.0.0
whatwg-url: 16.0.1(@noble/hashes@1.8.0)
transitivePeerDependencies:
- '@noble/hashes'
data-view-buffer@1.0.2:
dependencies:
@ -17271,9 +17343,11 @@ snapshots:
dependencies:
lru-cache: 10.4.3
html-encoding-sniffer@4.0.0:
html-encoding-sniffer@6.0.0(@noble/hashes@1.8.0):
dependencies:
whatwg-encoding: 3.1.1
'@exodus/bytes': 1.15.0(@noble/hashes@1.8.0)
transitivePeerDependencies:
- '@noble/hashes'
html-escaper@2.0.2: {}
@ -17698,32 +17772,32 @@ snapshots:
dependencies:
argparse: 2.0.1
jsdom@26.1.0:
jsdom@28.1.0(@noble/hashes@1.8.0):
dependencies:
cssstyle: 4.6.0
data-urls: 5.0.0
'@acemir/cssom': 0.9.31
'@asamuzakjp/dom-selector': 6.8.1
'@bramus/specificity': 2.4.2
'@exodus/bytes': 1.15.0(@noble/hashes@1.8.0)
cssstyle: 6.2.0
data-urls: 7.0.0(@noble/hashes@1.8.0)
decimal.js: 10.6.0
html-encoding-sniffer: 4.0.0
html-encoding-sniffer: 6.0.0(@noble/hashes@1.8.0)
http-proxy-agent: 7.0.2
https-proxy-agent: 7.0.6
is-potential-custom-element-name: 1.0.1
nwsapi: 2.2.23
parse5: 7.3.0
rrweb-cssom: 0.8.0
parse5: 8.0.0
saxes: 6.0.0
symbol-tree: 3.2.4
tough-cookie: 5.1.2
tough-cookie: 6.0.0
undici: 7.22.0
w3c-xmlserializer: 5.0.0
webidl-conversions: 7.0.0
whatwg-encoding: 3.1.1
whatwg-mimetype: 4.0.0
whatwg-url: 14.2.0
ws: 8.19.0
webidl-conversions: 8.0.1
whatwg-mimetype: 5.0.0
whatwg-url: 16.0.1(@noble/hashes@1.8.0)
xml-name-validator: 5.0.0
transitivePeerDependencies:
- bufferutil
- '@noble/hashes'
- supports-color
- utf-8-validate
jsesc@3.1.0: {}
@ -17936,7 +18010,7 @@ snapshots:
lru-cache@10.4.3: {}
lru-cache@11.2.4: {}
lru-cache@11.2.6: {}
lru-cache@5.1.1:
dependencies:
@ -18139,6 +18213,8 @@ snapshots:
dependencies:
'@types/mdast': 4.0.4
mdn-data@2.27.1: {}
media-typer@1.1.0: {}
memoize-one@5.2.1: {}
@ -18632,8 +18708,6 @@ snapshots:
optionalDependencies:
chokidar: 3.6.0
nwsapi@2.2.23: {}
object-assign@4.1.1: {}
object-hash@3.0.0: {}
@ -18832,6 +18906,10 @@ snapshots:
dependencies:
entities: 6.0.1
parse5@8.0.0:
dependencies:
entities: 6.0.1
parseurl@1.3.3: {}
path-data-parser@0.1.0: {}
@ -18848,7 +18926,7 @@ snapshots:
path-scurry@2.0.1:
dependencies:
lru-cache: 11.2.4
lru-cache: 11.2.6
minipass: 7.1.2
path-to-regexp@6.3.0: {}
@ -19556,8 +19634,6 @@ snapshots:
transitivePeerDependencies:
- supports-color
rrweb-cssom@0.8.0: {}
rsc-html-stream@0.0.7: {}
run-async@4.0.6: {}
@ -20258,11 +20334,11 @@ snapshots:
tinyrainbow@3.0.3: {}
tldts-core@6.1.86: {}
tldts-core@7.0.25: {}
tldts@6.1.86:
tldts@7.0.25:
dependencies:
tldts-core: 6.1.86
tldts-core: 7.0.25
to-regex-range@5.0.1:
dependencies:
@ -20279,9 +20355,9 @@ snapshots:
totalist@3.0.1: {}
tough-cookie@5.1.2:
tough-cookie@6.0.0:
dependencies:
tldts: 6.1.86
tldts: 7.0.25
tr46@0.0.3: {}
@ -20289,7 +20365,7 @@ snapshots:
dependencies:
punycode: 2.3.1
tr46@5.1.1:
tr46@6.0.0:
dependencies:
punycode: 2.3.1
@ -20710,7 +20786,7 @@ snapshots:
optionalDependencies:
vite: 7.3.1(@types/node@22.19.7)(jiti@1.21.7)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@22.19.7)(@vitest/browser-playwright@4.0.18)(@vitest/browser-webdriverio@4.0.18)(jiti@1.21.7)(jsdom@26.1.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2):
vitest@4.0.18(@opentelemetry/api@1.9.0)(@types/node@22.19.7)(@vitest/browser-playwright@4.0.18)(@vitest/browser-webdriverio@4.0.18)(jiti@1.21.7)(jsdom@28.1.0(@noble/hashes@1.8.0))(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2):
dependencies:
'@vitest/expect': 4.0.18
'@vitest/mocker': 4.0.18(vite@7.3.1(@types/node@22.19.7)(jiti@1.21.7)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))
@ -20737,7 +20813,7 @@ snapshots:
'@types/node': 22.19.7
'@vitest/browser-playwright': 4.0.18(playwright@1.58.2)(vite@7.3.1(@types/node@22.19.7)(jiti@1.21.7)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(vitest@4.0.18)
'@vitest/browser-webdriverio': 4.0.18(vite@7.3.1(@types/node@22.19.7)(jiti@1.21.7)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))(vitest@4.0.18)(webdriverio@9.24.0)
jsdom: 26.1.0
jsdom: 28.1.0(@noble/hashes@1.8.0)
transitivePeerDependencies:
- jiti
- less
@ -20860,7 +20936,7 @@ snapshots:
webidl-conversions@4.0.2: {}
webidl-conversions@7.0.0: {}
webidl-conversions@8.0.1: {}
webpack-bundle-analyzer@4.10.1:
dependencies:
@ -20923,10 +20999,15 @@ snapshots:
whatwg-mimetype@4.0.0: {}
whatwg-url@14.2.0:
whatwg-mimetype@5.0.0: {}
whatwg-url@16.0.1(@noble/hashes@1.8.0):
dependencies:
tr46: 5.1.1
webidl-conversions: 7.0.0
'@exodus/bytes': 1.15.0(@noble/hashes@1.8.0)
tr46: 6.0.0
webidl-conversions: 8.0.1
transitivePeerDependencies:
- '@noble/hashes'
whatwg-url@5.0.0:
dependencies: