// EPUB CFI (Canonical Fragment Identifier) navigation // Reuses logic from internal/sync/format.go // Procedural style: Functions, not classes interface CFIComponent { type: "index" | "indirection-step" | "text-location"; value: number; id?: string; textOffset?: number; } // ============================================================ // CFI Parsing Functions // ============================================================ export function parseCFI(cfi: string): CFIComponent[] { const components: CFIComponent[] = []; const cleanCFI = cfi.startsWith("!") ? cfi.substring(1) : cfi; const parts = cleanCFI.split("/").filter(Boolean); for (const part of parts) { const match = part.match(/^(\d+)(?:\[([^\]]+)\])?(?::(\d+))?$/); if (match) { const component: CFIComponent = { type: match[3] !== undefined ? "text-location" : "index", value: parseInt(match[1], 10), id: match[2], textOffset: match[3] !== undefined ? parseInt(match[3], 10) : undefined, }; components.push(component); } } return components; } export function generateCFI( spineIndex: number, elementPath: number[], textOffset: number = 0, spineItemId?: string, ): string { let cfi = `/6/${spineIndex}`; if (spineItemId) { cfi += `[${spineItemId}]`; } for (const index of elementPath) { cfi += `/${index}`; } if (textOffset > 0) { cfi += `:${textOffset}`; } return cfi; } export function navigateToCFI( doc: Document, cfi: string, ): Element | Text | null { const components = parseCFI(cfi); if (components.length === 0) return null; let current: Node | null = doc.body; for (let i = 1; i < components.length; i++) { const component = components[i]; if (component.type === "index") { if (current instanceof Element) { const children = getElementChildren(current); current = children[component.value] || null; } } } return current as Element | Text; } export function getSelectionCFI(doc: Document): string | null { const selection = window.getSelection(); if (!selection || selection.rangeCount === 0) return null; const range = selection.getRangeAt(0); const startContainer = range.startContainer; // Build path to start container const path: number[] = []; let current: Node | null = startContainer; while (current && current !== doc.body) { const parent = current.parentElement; if (parent) { const siblings = getElementChildren(parent); const index = siblings.indexOf(current as Element); path.unshift(index); } current = parent; } const spineIndex = 0; const textOffset = range.startOffset; return generateCFI(spineIndex, path, textOffset); } export function getPercentageFromCFI(cfi: string): number { const components = parseCFI(cfi); const textLocation = components.find((c) => c.type === "text-location"); if (textLocation && textLocation.textOffset !== undefined) { return Math.min(textLocation.textOffset / 10, 100); } return 0; } export function compareCFIs(cfi1: string, cfi2: string): number { const components1 = parseCFI(cfi1); const components2 = parseCFI(cfi2); const maxLen = Math.max(components1.length, components2.length); for (let i = 0; i < maxLen; i++) { const comp1 = components1[i]; const comp2 = components2[i]; if (!comp1) return -1; if (!comp2) return 1; if (comp1.value !== comp2.value) { return comp1.value - comp2.value; } } return 0; } function getElementChildren(element: Element): Element[] { return Array.from(element.children).filter( (el) => el.nodeType === Node.ELEMENT_NODE, ) as Element[]; }