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`
+ );
+}