diff --git a/typescript/README.md b/typescript/README.md index 09595d7..8ddb3ce 100644 --- a/typescript/README.md +++ b/typescript/README.md @@ -239,16 +239,18 @@ MIT - followed this for ts config setup: https://www.totaltypescript.com/tsconfig-cheat-sheet - ## Release workflow -We using bumpp to bump package.json and lock, create git tag and commit -1. Run: +We using bumpp to bump package.json and lock, create git tag and commit + +1. Run: + ``` npx bumpp ``` 2. You should see: + ``` 4 Commits since the last version: @@ -268,16 +270,14 @@ b7b30c1 : Make more typesafe from 0.2.0-alpha.3 to 0.2.0-alpha.4 ``` + You can choose a different version from the list or create new one. But bumpp is smart enough to use appropriate next version. 3. Verify and confirm 4. Push commit and tag -5. The new tag will trigger a release on github actions. +5. The new tag will trigger a release on github actions. 6. Go to github and create release using the new tag. Make sure you set the correct previous tag prefixed with `typescript-v` - - - ## Todos - [x] setup eslint @@ -295,6 +295,7 @@ You can choose a different version from the list or create new one. But bumpp is - [x] image - [x] blockquote - [x] fix ts and eslint errors in these dirs and remove from ignore list of ts and eslint + ``` "**/generated/**", "src/serialization/**", @@ -306,4 +307,4 @@ You can choose a different version from the list or create new one. But bumpp is - [x] move vite app to typescript root examples dir - [] setup monorepo tooling - [] fix model generation for Image and RichText, then type renderers -- [] use katex or similar package for equations +- [] use katex or similar package for equations diff --git a/typescript/examples/vite_basic/public/test_document.json b/typescript/examples/vite_basic/public/test_document.json index f936f5e..ecccac4 100644 --- a/typescript/examples/vite_basic/public/test_document.json +++ b/typescript/examples/vite_basic/public/test_document.json @@ -22,6 +22,124 @@ } }, "children": [ + { + "object": "block", + "id": "bk_01jxj01879f8cvyq2hqc6p37z2", + "type": "numbered_list_item", + "created_time": "2025-06-12T11:57:32.236290Z", + "created_by": { + "object": "user", + "id": "bcf6c03e-51a1-4f05-97d8-d616405b42a2" + }, + "has_children": false, + "metadata": { + "origin": { + "file_id": "file_01jxwgtg7qfr79j59j7xe777ek", + "page_num": 1 + } + }, + "numbered_list_item": { + "rich_text": [ + { + "type": "text", + "text": { + "content": "Volume and premium OEMs, Tier-1 and Tier-n companies trying to \"shape the market\"" + }, + "annotations": {}, + "plain_text": "Volume and premium OEMs, Tier-1 and Tier-n companies trying to \"shape the market\"" + } + ] + } + }, + + { + "object": "block", + "id": "bk_01jxj01879f8cvyq2hqc6p37z2", + "type": "numbered_list_item", + "created_time": "2025-06-12T11:57:32.236290Z", + "created_by": { + "object": "user", + "id": "bcf6c03e-51a1-4f05-97d8-d616405b42a2" + }, + "has_children": false, + "metadata": { + "origin": { + "file_id": "file_01jxhzyhk6f2bvjjabjx4dxqje", + "page_num": 1 + } + }, + "numbered_list_item": { + "rich_text": [ + { + "type": "text", + "text": { + "content": "Volume and premium OEMs, Tier-1 and Tier-n companies trying to \"shape the market\"" + }, + "annotations": {}, + "plain_text": "Volume and premium OEMs, Tier-1 and Tier-n companies trying to \"shape the market\"" + } + ] + } + }, + { + "object": "block", + "id": "bk_01jxj01879f8cvyq2hqc6p37z2", + "type": "numbered_list_item", + "created_time": "2025-06-12T11:57:32.236290Z", + "created_by": { + "object": "user", + "id": "bcf6c03e-51a1-4f05-97d8-d616405b42a2" + }, + "has_children": false, + "metadata": { + "origin": { + "file_id": "file_01jxhzyhk6f2bvjjabjx4dxqje", + "page_num": 1 + } + }, + "numbered_list_item": { + "rich_text": [ + { + "type": "text", + "text": { + "content": "Volume and premium OEMs, Tier-1 and Tier-n companies trying to \"shape the market\"" + }, + "annotations": {}, + "plain_text": "Volume and premium OEMs, Tier-1 and Tier-n companies trying to \"shape the market\"" + } + ] + } + }, + { + "object": "block", + "id": "bk_01jxj01879f8cvyq2hqc6p37z2", + "type": "numbered_list_item", + "created_time": "2025-06-12T11:57:32.236290Z", + "created_by": { + "object": "user", + "id": "bcf6c03e-51a1-4f05-97d8-d616405b42a2" + }, + "has_children": false, + "metadata": { + "origin": { + "file_id": "file_01jxhzyhk6f2bvjjabjx4dxqje", + "page_num": 1 + } + }, + "numbered_list_item": { + "rich_text": [ + { + "type": "text", + "text": { + "content": "Volume and premium OEMs, Tier-1 and Tier-n companies trying to \"shape the market\"" + }, + "annotations": {}, + "plain_text": "Volume and premium OEMs, Tier-1 and Tier-n companies trying to \"shape the market\"" + } + ] + } + }, + { "object": "block", "id": "bk_01jxwgvydvf8zts3qzst8nbkcq", diff --git a/typescript/examples/vite_basic/src/App.css b/typescript/examples/vite_basic/src/App.css deleted file mode 100644 index e69de29..0000000 diff --git a/typescript/examples/vite_basic/src/App.tsx b/typescript/examples/vite_basic/src/App.tsx index 44be15e..2851e7e 100644 --- a/typescript/examples/vite_basic/src/App.tsx +++ b/typescript/examples/vite_basic/src/App.tsx @@ -4,6 +4,7 @@ import { useState, useEffect } from "react"; const App = () => { const [testPage, setTestPage] = useState(null); + const [devMode, setDevMode] = useState(false); useEffect(() => { const loadData = async () => { @@ -22,6 +23,35 @@ const App = () => { return
Loading...
; } + // Test backrefs for highlighting + const testBackrefs = [ + { + end_idx: 50, + block_id: "bk_01jxwgvye6er08spmyxj99f6cp", + start_idx: 0, + }, + { + end_idx: 100, + block_id: "bk_01jxwgvydyfj6rhm125q1rd4h8", + start_idx: 70, + }, + { + end_idx: 80, + block_id: "bk_01jxwgvydze3bsy2p19cfteqge", + start_idx: 20, + }, + // { + // block_id: "bk_01jxwgvyehecbb2bv3jtnm9bzx", + // start_idx: 0, + // end_idx: 70, + // }, + // { + // block_id: "bk_01jxj01879f8cvyq2hqc6p37z2", + // start_idx: 0, + // end_idx: 170, + // }, + ]; + return (
{ >

JSON-DOC Renderer Development

+ +
+ +
+ { return ; diff --git a/typescript/src/renderer/JsonDocRenderer.tsx b/typescript/src/renderer/JsonDocRenderer.tsx index ec72903..cc72773 100644 --- a/typescript/src/renderer/JsonDocRenderer.tsx +++ b/typescript/src/renderer/JsonDocRenderer.tsx @@ -1,5 +1,5 @@ import "./styles/index.css"; -import React, { useEffect } from "react"; +import React from "react"; import { Page } from "@/models/generated"; // import { validateAgainstSchema } from "@/validation/validator"; @@ -8,6 +8,9 @@ import { BlockRenderer } from "./components/BlockRenderer"; import { PageDelimiter } from "./components/PageDelimiter"; import { JsonViewPanel } from "./components/dev/JsonViewPanel"; import { RendererProvider } from "./context/RendererContext"; +import { HighlightNavigation } from "./components/HighlightNavigation"; +import { useHighlights } from "./hooks/useHighlights"; +import { Backref } from "./utils/highlightUtils"; interface JsonDocRendererProps { page: Page; @@ -21,6 +24,7 @@ interface JsonDocRendererProps { resolveImageUrl?: (url: string) => Promise; devMode?: boolean; viewJson?: boolean; + backrefs?: Backref[]; } export const JsonDocRenderer = ({ @@ -31,23 +35,15 @@ export const JsonDocRenderer = ({ resolveImageUrl, devMode = false, viewJson = false, - // PageDelimiterComponent = PageDelimiter, + backrefs = [], }: JsonDocRendererProps) => { console.log("page: ", page); - const loadAndValidate = async () => { - // const response = await fetch("/schema/page/page_schema.json"); // Updated path - // const data = await response.json(); - // console.log("schema: ", data); - // validateAgainstSchema( - // page, - // ) - }; - - useEffect(() => { - console.log("in jsondocrendererrrr"); - loadAndValidate(); - }, []); + // Use the modular hooks for highlight management + const { highlightCount, currentActiveIndex, navigateToHighlight } = + useHighlights({ + backrefs, + }); // return null; const renderedContent = ( @@ -113,6 +109,14 @@ export const JsonDocRenderer = ({ ) : ( renderedContent )} + {/* Show highlight navigation when there are highlights */} + {highlightCount > 0 && ( + + )}
); diff --git a/typescript/src/renderer/components/HighlightNavigation.tsx b/typescript/src/renderer/components/HighlightNavigation.tsx new file mode 100644 index 0000000..5353471 --- /dev/null +++ b/typescript/src/renderer/components/HighlightNavigation.tsx @@ -0,0 +1,53 @@ +import React from "react"; +import { useHighlightNavigation } from "../hooks/useHighlightNavigation"; + +interface HighlightNavigationProps { + highlightCount: number; + onNavigate: (index: number) => void; + currentIndex?: number; +} + +export const HighlightNavigation: React.FC = ({ + highlightCount, + onNavigate, + currentIndex: externalCurrentIndex, +}) => { + const { currentIndex, navigatePrevious, navigateNext, hasHighlights } = + useHighlightNavigation({ + highlightCount, + onNavigate, + externalCurrentIndex, + }); + + if (!hasHighlights) { + return null; + } + + return ( +
+
+ + + + {currentIndex >= 0 ? currentIndex + 1 : "-"} of {highlightCount} + + + +
+
+ ); +}; diff --git a/typescript/src/renderer/components/blocks/TableBlockRenderer.tsx b/typescript/src/renderer/components/blocks/TableBlockRenderer.tsx index c4949a2..97cb673 100644 --- a/typescript/src/renderer/components/blocks/TableBlockRenderer.tsx +++ b/typescript/src/renderer/components/blocks/TableBlockRenderer.tsx @@ -42,6 +42,7 @@ export const TableBlockRenderer: React.FC = ({ {rowData?.cells?.map( (cell: any, cellIndex: number) => { diff --git a/typescript/src/renderer/hooks/useHighlightNavigation.ts b/typescript/src/renderer/hooks/useHighlightNavigation.ts new file mode 100644 index 0000000..115366c --- /dev/null +++ b/typescript/src/renderer/hooks/useHighlightNavigation.ts @@ -0,0 +1,132 @@ +import { useState, useEffect, useCallback } from "react"; + +export interface UseHighlightNavigationOptions { + highlightCount: number; + onNavigate: (index: number) => void; + externalCurrentIndex?: number; +} + +export interface UseHighlightNavigationReturn { + currentIndex: number; + navigatePrevious: () => void; + navigateNext: () => void; + navigateToIndex: (index: number) => void; + hasHighlights: boolean; + canNavigatePrevious: boolean; + canNavigateNext: boolean; +} + +/** + * Custom hook for managing highlight navigation state and controls + */ +export function useHighlightNavigation({ + highlightCount, + onNavigate, + externalCurrentIndex, +}: UseHighlightNavigationOptions): UseHighlightNavigationReturn { + const [internalCurrentIndex, setInternalCurrentIndex] = useState(-1); + + // Use external index if provided, otherwise use internal + const currentIndex = externalCurrentIndex ?? internalCurrentIndex; + + // Initialize to first highlight when highlights are available + useEffect(() => { + if ( + highlightCount > 0 && + externalCurrentIndex === undefined && + internalCurrentIndex === -1 + ) { + setInternalCurrentIndex(0); + onNavigate(0); + } + }, [highlightCount, externalCurrentIndex, internalCurrentIndex, onNavigate]); + + // Navigate to previous highlight (wraps around) + const navigatePrevious = useCallback(() => { + if (highlightCount === 0) return; + + const newIndex = currentIndex > 0 ? currentIndex - 1 : highlightCount - 1; + + if (externalCurrentIndex === undefined) { + setInternalCurrentIndex(newIndex); + } + + onNavigate(newIndex); + }, [currentIndex, highlightCount, onNavigate, externalCurrentIndex]); + + // Navigate to next highlight (wraps around) + const navigateNext = useCallback(() => { + if (highlightCount === 0) return; + + const newIndex = currentIndex < highlightCount - 1 ? currentIndex + 1 : 0; + + if (externalCurrentIndex === undefined) { + setInternalCurrentIndex(newIndex); + } + + onNavigate(newIndex); + }, [currentIndex, highlightCount, onNavigate, externalCurrentIndex]); + + // Navigate to specific index + const navigateToIndex = useCallback( + (index: number) => { + if (index < 0 || index >= highlightCount) return; + + if (externalCurrentIndex === undefined) { + setInternalCurrentIndex(index); + } + + onNavigate(index); + }, + [highlightCount, onNavigate, externalCurrentIndex] + ); + + // Keyboard navigation support + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if (highlightCount === 0) return; + + // Only handle if no input is focused + if ( + document.activeElement?.tagName === "INPUT" || + document.activeElement?.tagName === "TEXTAREA" + ) { + return; + } + + switch (event.key) { + case "ArrowUp": + case "k": // Vim-style navigation + event.preventDefault(); + navigatePrevious(); + break; + case "ArrowDown": + case "j": // Vim-style navigation + event.preventDefault(); + navigateNext(); + break; + case "Home": + event.preventDefault(); + navigateToIndex(0); + break; + case "End": + event.preventDefault(); + navigateToIndex(highlightCount - 1); + break; + } + }; + + document.addEventListener("keydown", handleKeyDown); + return () => document.removeEventListener("keydown", handleKeyDown); + }, [highlightCount, navigatePrevious, navigateNext, navigateToIndex]); + + return { + currentIndex, + navigatePrevious, + navigateNext, + navigateToIndex, + hasHighlights: highlightCount > 0, + canNavigatePrevious: highlightCount > 0, + canNavigateNext: highlightCount > 0, + }; +} diff --git a/typescript/src/renderer/hooks/useHighlights.ts b/typescript/src/renderer/hooks/useHighlights.ts new file mode 100644 index 0000000..cdf65a7 --- /dev/null +++ b/typescript/src/renderer/hooks/useHighlights.ts @@ -0,0 +1,139 @@ +import { useEffect, useRef, useState } from "react"; + +import { + Backref, + HighlightElement, + processBackref, + sortHighlightsByDOMPosition, + clearActiveHighlights, + setActiveHighlight, + logPerformanceStats, + PerformanceStats, +} from "../utils/highlightUtils"; + +export interface UseHighlightsOptions { + backrefs: Backref[]; + onPerformanceLog?: (stats: PerformanceStats) => void; +} + +export interface UseHighlightsReturn { + highlightElements: HighlightElement[]; + highlightCount: number; + currentActiveIndex: number; + navigateToHighlight: (index: number) => void; + clearHighlights: () => void; +} + +/** + * Custom hook for managing text highlights in the document + */ +export function useHighlights({ + backrefs, + onPerformanceLog, +}: UseHighlightsOptions): UseHighlightsReturn { + const highlightElementsRef = useRef([]); + const [currentActiveIndex, setCurrentActiveIndex] = useState(-1); + const [highlightCount, setHighlightCount] = useState(0); + + // Clear all highlights and reset state + const clearHighlights = () => { + clearActiveHighlights(highlightElementsRef.current); + highlightElementsRef.current = []; + setCurrentActiveIndex(-1); + setHighlightCount(0); + }; + + // Navigate to a specific highlight by index + const navigateToHighlight = (index: number) => { + const highlights = highlightElementsRef.current; + + if (index < 0 || index >= highlights.length) { + return; + } + + // Clear previous active highlight + if (currentActiveIndex >= 0) { + const prevItem = highlights[currentActiveIndex]; + if (prevItem?.element) { + prevItem.element.classList.remove("active"); + } + } + + // Set new active highlight + setActiveHighlight(highlights, index); + setCurrentActiveIndex(index); + }; + + // Process backrefs and create highlights + useEffect(() => { + if (backrefs.length === 0) { + clearHighlights(); + return; + } + + const startTime = performance.now(); + console.log(`🚀 Starting highlighting for ${backrefs.length} backrefs`); + + // Clear existing highlights + clearHighlights(); + + // Process each backref + const newHighlights: HighlightElement[] = []; + + backrefs.forEach((backref, backrefIndex) => { + const firstSpan = processBackref(backref); + + if (firstSpan) { + newHighlights.push({ + element: firstSpan, + backrefIndex, + }); + } + }); + + // Sort highlights by DOM position for natural reading order + const sortStartTime = performance.now(); + const sortedHighlights = sortHighlightsByDOMPosition(newHighlights); + const sortTime = performance.now() - sortStartTime; + + // Update ref with sorted highlights + highlightElementsRef.current = sortedHighlights; + setHighlightCount(sortedHighlights.length); + + // Navigate to first highlight if there are any + if (sortedHighlights.length > 0) { + setActiveHighlight(sortedHighlights, 0); + setCurrentActiveIndex(0); + } + + // Calculate and log performance stats + const totalTime = performance.now() - startTime; + const stats: PerformanceStats = { + totalTime, + highlightCount: sortedHighlights.length, + sortTime, + avgTimePerHighlight: totalTime / backrefs.length, + }; + + if (onPerformanceLog) { + onPerformanceLog(stats); + } else { + logPerformanceStats(stats); + } + }, [backrefs, onPerformanceLog]); + + // Cleanup on unmount + useEffect(() => { + return () => { + clearHighlights(); + }; + }, []); + + return { + highlightElements: highlightElementsRef.current, + highlightCount, + currentActiveIndex, + navigateToHighlight, + clearHighlights, + }; +} diff --git a/typescript/src/renderer/index.ts b/typescript/src/renderer/index.ts index b41cb67..0f8db1f 100644 --- a/typescript/src/renderer/index.ts +++ b/typescript/src/renderer/index.ts @@ -23,4 +23,3 @@ export type { JsonDocRendererProps, BlockRendererProps } from "./types"; // Import default styles - users can override by importing their own after this import "./styles/index.css"; -// diff --git a/typescript/src/renderer/styles/base.css b/typescript/src/renderer/styles/base.css index 486919c..1a5c991 100644 --- a/typescript/src/renderer/styles/base.css +++ b/typescript/src/renderer/styles/base.css @@ -47,3 +47,73 @@ white-space: pre-wrap; word-break: break-word; } + +/* Highlighting styles */ +.json-doc-highlight { + background-color: var(--jsondoc-color-yellow); + color: var(--jsondoc-text-primary); + padding: 1px 2px; + border-radius: var(--jsondoc-radius-sm); + transition: all var(--jsondoc-transition-fast); +} + +/* Active (focused) highlight - more prominent */ +.json-doc-highlight.active { + background-color: var(--jsondoc-color-orange); + color: var(--jsondoc-text-primary); + box-shadow: 0 0 0 2px var(--jsondoc-border-light); + transform: scale(1.02); +} + +/* Highlight Navigation Styles */ +.json-doc-highlight-navigation { + position: fixed; + bottom: 20px; + right: 20px; + z-index: var(--jsondoc-z-modal); + background: var(--jsondoc-bg-code); + border: 1px solid var(--jsondoc-border-medium); + border-radius: var(--jsondoc-radius-sm); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + font-family: var(--jsondoc-font-family-mono); + font-size: var(--jsondoc-font-size-caption); +} + +.highlight-nav-content { + display: flex; + align-items: center; + padding: var(--jsondoc-spacing-sm) var(--jsondoc-spacing-md); + gap: var(--jsondoc-spacing-md); +} + +.highlight-nav-button { + display: flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + background: transparent; + border: none; + border-radius: var(--jsondoc-radius-sm); + color: var(--jsondoc-text-secondary); + cursor: pointer; + transition: all var(--jsondoc-transition-fast); +} + +.highlight-nav-button:hover { + background: var(--jsondoc-bg-inline-code); + color: var(--jsondoc-text-primary); +} + +.highlight-nav-button:active { + background: var(--jsondoc-border-light); +} + +.highlight-nav-counter { + color: var(--jsondoc-text-secondary); + font-size: var(--jsondoc-font-size-caption); + white-space: nowrap; + user-select: none; + min-width: 50px; + text-align: center; +} diff --git a/typescript/src/renderer/utils/highlightUtils.ts b/typescript/src/renderer/utils/highlightUtils.ts new file mode 100644 index 0000000..212aa79 --- /dev/null +++ b/typescript/src/renderer/utils/highlightUtils.ts @@ -0,0 +1,190 @@ +export interface Backref { + end_idx: number; + start_idx: number; + block_id?: string; +} + +export interface HighlightElement { + element: HTMLSpanElement; + backrefIndex: number; +} + +/** + * Creates a highlight span element with the given text content + */ +export function createHighlightSpan(text: string): HTMLSpanElement { + const highlightSpan = document.createElement("span"); + highlightSpan.className = "json-doc-highlight"; + highlightSpan.textContent = text; + return highlightSpan; +} + +/** + * Finds all text nodes within a given element using TreeWalker + */ +export function getTextNodes(element: Element): Text[] { + const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT, null); + + const textNodes: Text[] = []; + let node: Node | null; + + while ((node = walker.nextNode())) { + textNodes.push(node as Text); + } + + return textNodes; +} + +/** + * Applies highlighting to a single text node based on the backref range + */ +export function highlightTextNode( + textNode: Text, + backref: Backref, + currentIndex: number +): HTMLSpanElement | null { + const nodeLength = textNode.textContent?.length || 0; + const nodeStart = currentIndex; + const nodeEnd = currentIndex + nodeLength; + + // Check if this text node overlaps with the backref range + if (backref.start_idx < nodeEnd && backref.end_idx > nodeStart) { + const highlightStart = Math.max(0, backref.start_idx - nodeStart); + const highlightEnd = Math.min(nodeLength, backref.end_idx - nodeStart); + + if (highlightStart < highlightEnd) { + const textContent = textNode.textContent || ""; + const beforeText = textContent.slice(0, highlightStart); + const highlightedText = textContent.slice(highlightStart, highlightEnd); + const afterText = textContent.slice(highlightEnd); + + const fragment = document.createDocumentFragment(); + + // Add text before highlight + if (beforeText) { + fragment.appendChild(document.createTextNode(beforeText)); + } + + // Add highlighted text + let highlightSpan: HTMLSpanElement | null = null; + if (highlightedText) { + highlightSpan = createHighlightSpan(highlightedText); + fragment.appendChild(highlightSpan); + } + + // Add text after highlight + if (afterText) { + fragment.appendChild(document.createTextNode(afterText)); + } + + // Replace the original text node with the fragment + textNode.parentNode?.replaceChild(fragment, textNode); + + return highlightSpan; + } + } + + return null; +} + +/** + * Processes a single backref and applies highlighting to the target block + */ +export function processBackref(backref: Backref): HTMLSpanElement | null { + if (!backref.block_id) { + return null; + } + + const blockElement = document.querySelector( + `[data-block-id="${backref.block_id}"]` + ); + + if (!blockElement) { + return null; + } + + const textNodes = getTextNodes(blockElement); + let currentIndex = 0; + let firstSpanForThisBackref: HTMLSpanElement | null = null; + + for (const textNode of textNodes) { + const highlightSpan = highlightTextNode(textNode, backref, currentIndex); + + if (highlightSpan && !firstSpanForThisBackref) { + firstSpanForThisBackref = highlightSpan; + } + + currentIndex += textNode.textContent?.length || 0; + } + + return firstSpanForThisBackref; +} + +/** + * Sorts highlight elements by their DOM position (reading order) + */ +export function sortHighlightsByDOMPosition( + highlights: HighlightElement[] +): HighlightElement[] { + return highlights.sort((a, b) => { + const position = a.element.compareDocumentPosition(b.element); + + if (position & Node.DOCUMENT_POSITION_FOLLOWING) { + return -1; + } else if (position & Node.DOCUMENT_POSITION_PRECEDING) { + return 1; + } + + return 0; + }); +} + +/** + * Removes all active classes from highlight elements + */ +export function clearActiveHighlights(highlights: HighlightElement[]): void { + highlights.forEach((item) => { + if (item?.element) { + item.element.classList.remove("active"); + } + }); +} + +/** + * Sets the active class on a specific highlight element + */ +export function setActiveHighlight( + highlights: HighlightElement[], + index: number +): void { + const item = highlights[index]; + if (item?.element) { + item.element.classList.add("active"); + item.element.scrollIntoView({ + behavior: "smooth", + block: "center", + inline: "nearest", + }); + } +} + +/** + * Performance measurement utilities + */ +export interface PerformanceStats { + totalTime: number; + highlightCount: number; + sortTime: number; + avgTimePerHighlight: number; +} + +export function logPerformanceStats(stats: PerformanceStats): void { + console.log(`✅ Highlighting complete!`); + console.log(`📊 Performance stats:`); + console.log(` - Total time: ${stats.totalTime.toFixed(2)}ms`); + console.log(` - Highlights created: ${stats.highlightCount}`); + console.log(` - DOM sorting time: ${stats.sortTime.toFixed(2)}ms`); + console.log( + ` - Avg time per highlight: ${stats.avgTimePerHighlight.toFixed(2)}ms` + ); +}