Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions src/components/Search/Search.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { describe, expect, it } from "vitest";
import { getMatchSummary } from "./Search";

describe("getMatchSummary", () => {
it("summarizes page matches", () => {
expect(getMatchSummary([[2, 8], [], [1]])).toEqual({
count: 3,
pagesAndCount: [
{ pageIndex: 0, firstMatchIndex: 2, count: 2 },
{ pageIndex: 2, firstMatchIndex: 1, count: 1 },
],
});
});

it("ignores sparse and undefined page entries while pdf.js is still calculating matches", () => {
const pageMatches: Array<number[] | undefined> = [];
pageMatches[0] = [4];
pageMatches[2] = undefined;
pageMatches[4] = [1, 3, 5];

expect(getMatchSummary(pageMatches)).toEqual({
count: 4,
pagesAndCount: [
{ pageIndex: 0, firstMatchIndex: 4, count: 1 },
{ pageIndex: 4, firstMatchIndex: 1, count: 3 },
],
});
});
});
56 changes: 42 additions & 14 deletions src/components/Search/Search.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,26 @@ type SearchResultsProps = {
onSearchFinished: (hasQuery: boolean) => void;
};

export function getMatchSummary(pageMatches: ArrayLike<number[] | undefined>): Match {
const pagesAndCount = Array.from({ length: pageMatches.length }, (_, pageIndex) => {
const matchesOnPage = pageMatches[pageIndex];
if (!matchesOnPage || matchesOnPage.length === 0) {
return undefined;
}

return {
pageIndex,
firstMatchIndex: matchesOnPage[0],
count: matchesOnPage.length,
} satisfies PageResults;
}).filter((result): result is PageResults => result !== undefined);

return {
count: pagesAndCount.reduce((sum, result) => sum + result.count, 0),
pagesAndCount,
};
}

function SearchResults({ query, onSearchFinished }: SearchResultsProps) {
const { eventBus, findController, viewer, linkService, pdfDocument } = useContext(PdfContext) as IPdfContext;
const [isLoading, setIsLoading] = useState(false);
Expand All @@ -153,6 +173,8 @@ function SearchResults({ query, onSearchFinished }: SearchResultsProps) {
pagesAndCount: [],
});
const [currentMatch, setCurrentMatch] = useState(0);
const lastSearchQuery = useRef("");
const hasActiveQuery = useRef(false);

const pageSectionMap = usePageSectionMap(pdfDocument);

Expand Down Expand Up @@ -200,32 +222,38 @@ function SearchResults({ query, onSearchFinished }: SearchResultsProps) {
return;
}

if (!query) {
hasActiveQuery.current = false;
clearTimeout(resetTimeout.current);
setIsLoading(false);
setMatches({ count: 0, pagesAndCount: [] });
setCurrentMatch(0);
onSearchFinished(false);

if (lastSearchQuery.current) {
search("");
}
lastSearchQuery.current = "";
return;
}

hasActiveQuery.current = true;
lastSearchQuery.current = query;
resetMatchesLater();
search("");
}, [search, resetMatchesLater, eventBus]);
}, [query, search, resetMatchesLater, eventBus, onSearchFinished]);

useEffect(() => {
const pageMatches = findController?.pageMatches ?? [];
if (!eventBus || !viewer) {
return;
}

const updateMatches = () => {
const count = pageMatches.reduce((sum, x) => sum + x.length, 0);
const pagesAndCount = Array.from(pageMatches.entries())
.filter((x) => x.length > 0 && x[1].length > 0)
.map(
(x) =>
({
pageIndex: x[0],
firstMatchIndex: x[1][0],
count: x[1].length,
}) as PageResults,
);
const { count, pagesAndCount } = getMatchSummary(findController?.pageMatches ?? []);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
clearTimeout(resetTimeout.current);
setIsLoading(false);
setMatches({ count, pagesAndCount });
onSearchFinished(true);
onSearchFinished(hasActiveQuery.current);
};

const updateCurrentMatch = (evt: { matchesCount?: { current: number; total: number } }) => {
Expand Down
5 changes: 5 additions & 0 deletions src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@ import { LocationProvider } from "./components/LocationProvider/LocationProvider
import { MetadataProvider } from "./components/MetadataProvider/MetadataProvider.tsx";
import { migrateLocalStorageKeys } from "./config/localStorageMigration";
import { initDevTools } from "./devtools/initDevTools";
import { installReadableStreamAsyncIteratorPolyfill } from "./utils/readableStreamAsyncIteratorPolyfill";

// pdf.js text extraction uses ReadableStream async iteration, which is missing in some Safari versions.
// Install the polyfill before anything renders so search works there (issue #446).
installReadableStreamAsyncIteratorPolyfill();

import "./tailwind.css";
import "./variables.css";
Expand Down
75 changes: 75 additions & 0 deletions src/utils/readableStreamAsyncIteratorPolyfill.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { afterEach, describe, expect, it } from "vitest";
import { installReadableStreamAsyncIteratorPolyfill } from "./readableStreamAsyncIteratorPolyfill";

const readableStreamPrototype = ReadableStream.prototype as ReadableStream<unknown> & {
values?: (options?: { preventCancel?: boolean }) => AsyncIterableIterator<unknown>;
[Symbol.asyncIterator]?: () => AsyncIterableIterator<unknown>;
};

const originalValues = Object.getOwnPropertyDescriptor(readableStreamPrototype, "values");
const originalAsyncIterator = Object.getOwnPropertyDescriptor(readableStreamPrototype, Symbol.asyncIterator);

function restoreProperty(property: "values" | typeof Symbol.asyncIterator, descriptor: PropertyDescriptor | undefined) {
if (descriptor) {
Object.defineProperty(readableStreamPrototype, property, descriptor);
} else {
delete readableStreamPrototype[property];
}
}

async function collectStreamValues<T>(stream: ReadableStream<T>): Promise<T[]> {
const values: T[] = [];
for await (const value of stream) {
values.push(value);
}
return values;
}

describe("installReadableStreamAsyncIteratorPolyfill", () => {
afterEach(() => {
restoreProperty("values", originalValues);
restoreProperty(Symbol.asyncIterator, originalAsyncIterator);
});

it("adds async iteration support when ReadableStream.values and Symbol.asyncIterator are missing", async () => {
Reflect.deleteProperty(readableStreamPrototype, "values");
Reflect.deleteProperty(readableStreamPrototype, Symbol.asyncIterator);

installReadableStreamAsyncIteratorPolyfill();

expect(typeof readableStreamPrototype.values).toBe("function");
expect(typeof readableStreamPrototype[Symbol.asyncIterator]).toBe("function");
expect(
await collectStreamValues(
new ReadableStream<string>({
start(controller) {
controller.enqueue("hello");
controller.enqueue("safari");
controller.close();
},
}),
),
).toEqual(["hello", "safari"]);
});

it("does not overwrite native implementations", () => {
const values = function* values() {};
const asyncIterator = function* asyncIterator() {};

Object.defineProperty(readableStreamPrototype, "values", {
configurable: true,
writable: true,
value: values,
});
Object.defineProperty(readableStreamPrototype, Symbol.asyncIterator, {
configurable: true,
writable: true,
value: asyncIterator,
});

installReadableStreamAsyncIteratorPolyfill();

expect(readableStreamPrototype.values).toBe(values);
expect(readableStreamPrototype[Symbol.asyncIterator]).toBe(asyncIterator);
});
});
86 changes: 86 additions & 0 deletions src/utils/readableStreamAsyncIteratorPolyfill.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
type ReadableStreamIteratorOptions = {
preventCancel?: boolean;
};

type ReadableStreamPrototypeWithIterator = ReadableStream<unknown> & {
values?: <T>(this: ReadableStream<T>, options?: ReadableStreamIteratorOptions) => AsyncIterableIterator<T>;
[Symbol.asyncIterator]?: <T>(this: ReadableStream<T>) => AsyncIterableIterator<T>;
};

function readableStreamValues<T>(
this: ReadableStream<T>,
options: ReadableStreamIteratorOptions = {},
): AsyncIterableIterator<T> {
const reader = this.getReader();
let isDone = false;

const iterator: AsyncIterableIterator<T> = {
async next() {
if (isDone) {
return { done: true, value: undefined as T };
}

const result = await reader.read();
if (result.done) {
isDone = true;
reader.releaseLock();
return { done: true, value: undefined as T };
}

return { done: false, value: result.value };
},

async return(value?: unknown) {
if (!isDone) {
isDone = true;
try {
if (!options.preventCancel) {
await reader.cancel(value);
}
} finally {
reader.releaseLock();
}
}

return { done: true, value: value as T };
},

[Symbol.asyncIterator]() {
return this;
},
};

return iterator;
}

/**
* Polyfill `ReadableStream` async iteration for Safari versions that expose streams but not
* `ReadableStream.prototype[Symbol.asyncIterator]` / `.values()`.
*
* pdf.js' `PDFPageProxy.getTextContent` consumes text streams with `for await (... of stream)`.
* Without this API Safari throws `TypeError: undefined is not a function` and search indexing
* fails with "Unable to get text content for page ...".
*/
export function installReadableStreamAsyncIteratorPolyfill(): void {
if (typeof ReadableStream === "undefined" || typeof Symbol.asyncIterator === "undefined") {
return;
}

const prototype = ReadableStream.prototype as ReadableStreamPrototypeWithIterator;

if (typeof prototype.values !== "function") {
Object.defineProperty(prototype, "values", {
configurable: true,
writable: true,
value: readableStreamValues,
});
}

if (typeof prototype[Symbol.asyncIterator] !== "function") {
Object.defineProperty(prototype, Symbol.asyncIterator, {
configurable: true,
writable: true,
value: prototype.values,
});
}
}