vomebook commited on
Commit ·
7e93960
1
Parent(s): dc4279b
fix: repair Reader media playback and toolbar compatibility
Browse filesExtract shared full-text search control and cancel pending input on disposal.
- app.py +20 -9
- scripts/check_reader_release.mjs +1 -0
- static/icons/LICENSE-lucide +15 -0
- static/reader-resources.js +1 -0
- static/reader-search-controller.mjs +335 -0
- static/reader.css +21 -0
- static/reader.html +12 -2
- static/reader.js +97 -442
- tests/test_async_proxy.py +86 -4
- tests/test_reader_architecture.js +9 -2
- tests/test_reader_optimizations.py +4 -4
- tests/test_reader_refactor.py +34 -2
- tests/test_reader_style.py +45 -1
- tests/test_static_contract.py +6 -4
app.py
CHANGED
|
@@ -2099,7 +2099,8 @@ async def api_reader_bucket_resource(path: str = Query(...), request: Request =
|
|
| 2099 |
url_validator=lambda url: validate_reader_bucket_redirect_url(url, source_url),
|
| 2100 |
validation_scope=(validate_reader_bucket_redirect_url, source_url),
|
| 2101 |
cache_control=reader_bucket_cache_control(resource_path),
|
| 2102 |
-
max_bytes=
|
|
|
|
| 2103 |
media_type=media_type,
|
| 2104 |
)
|
| 2105 |
try:
|
|
@@ -2383,6 +2384,9 @@ async def api_reader_content(request: Request, url: Optional[str] = Query(defaul
|
|
| 2383 |
target_url = validate_reader_source_url(url)
|
| 2384 |
except ValueError as exc:
|
| 2385 |
return JSONResponse({"error": str(exc)}, status_code=403)
|
|
|
|
|
|
|
|
|
|
| 2386 |
return await stream_reader_document(request, target_url)
|
| 2387 |
|
| 2388 |
|
|
@@ -2439,7 +2443,10 @@ async def _stream_reader_document(
|
|
| 2439 |
url_validator=validate_reader_redirect_url, cache_control: str | None = None,
|
| 2440 |
max_bytes: int | None = None, media_type: str | None = None, validation_scope=None, _share_range: bool = True,
|
| 2441 |
):
|
| 2442 |
-
|
|
|
|
|
|
|
|
|
|
| 2443 |
max_bytes = READER_DOCUMENT_MAX_BYTES
|
| 2444 |
forwarded_headers = {
|
| 2445 |
name: request.headers[name]
|
|
@@ -2492,7 +2499,7 @@ async def _stream_reader_document(
|
|
| 2492 |
upstream = lease.response = await open_download_response(
|
| 2493 |
app.state.http_session,
|
| 2494 |
target_url,
|
| 2495 |
-
aiohttp.ClientTimeout(total=180, connect=15, sock_read=60),
|
| 2496 |
method=request.method,
|
| 2497 |
request_headers=forwarded_headers,
|
| 2498 |
url_validator=url_validator,
|
|
@@ -2509,7 +2516,12 @@ async def _stream_reader_document(
|
|
| 2509 |
if is_immutable_reader_asset(target_url)
|
| 2510 |
else headers.get("Cache-Control", "public, max-age=300")
|
| 2511 |
)
|
| 2512 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2513 |
if media_type:
|
| 2514 |
headers["Content-Type"] = media_type
|
| 2515 |
status = upstream.status
|
|
@@ -2523,7 +2535,7 @@ async def _stream_reader_document(
|
|
| 2523 |
length = int(declared_length)
|
| 2524 |
except (TypeError, ValueError):
|
| 2525 |
return JSONResponse({"error": "上游阅读文件长度无效"}, status_code=502)
|
| 2526 |
-
if length < 0 or length > max_bytes:
|
| 2527 |
return JSONResponse({"error": "阅读文件过大"}, status_code=413)
|
| 2528 |
content_range = upstream.headers.get("Content-Range")
|
| 2529 |
if status == 206:
|
|
@@ -2534,11 +2546,11 @@ async def _stream_reader_document(
|
|
| 2534 |
if (range_start > range_end or range_end >= total_length
|
| 2535 |
or (length is not None and length != range_end - range_start + 1)):
|
| 2536 |
return JSONResponse({"error": "上游阅读文件范围无效"}, status_code=502)
|
| 2537 |
-
if total_length > max_bytes:
|
| 2538 |
return JSONResponse({"error": "阅读文件过大"}, status_code=413)
|
| 2539 |
elif status == 416:
|
| 2540 |
match = re.fullmatch(r"bytes\s+\*/([0-9]{1,20})", str(content_range or ""))
|
| 2541 |
-
if match and int(match.group(1)) > max_bytes:
|
| 2542 |
return JSONResponse({"error": "阅读文件过大"}, status_code=413)
|
| 2543 |
if request.method == "HEAD" or status == 304:
|
| 2544 |
return Response(status_code=status, headers=headers)
|
|
@@ -2555,7 +2567,7 @@ async def _stream_reader_document(
|
|
| 2555 |
try:
|
| 2556 |
async for chunk in upstream.content.iter_chunked(65536):
|
| 2557 |
total += len(chunk)
|
| 2558 |
-
if total > max_bytes:
|
| 2559 |
raise ReaderDocumentLimitError("阅读文件过大")
|
| 2560 |
if cacheable:
|
| 2561 |
chunks.append(chunk)
|
|
@@ -2907,4 +2919,3 @@ async def shared_reader_range_response(request, target_url, range_key, options):
|
|
| 2907 |
finally:
|
| 2908 |
if not transferred:
|
| 2909 |
await subscription.release()
|
| 2910 |
-
|
|
|
|
| 2099 |
url_validator=lambda url: validate_reader_bucket_redirect_url(url, source_url),
|
| 2100 |
validation_scope=(validate_reader_bucket_redirect_url, source_url),
|
| 2101 |
cache_control=reader_bucket_cache_control(resource_path),
|
| 2102 |
+
max_bytes=(None if media_type.startswith(("audio/", "video/")) else
|
| 2103 |
+
READER_DOCUMENT_MAX_BYTES if suffix == "pdf" else READER_RESOURCE_MAX_BYTES),
|
| 2104 |
media_type=media_type,
|
| 2105 |
)
|
| 2106 |
try:
|
|
|
|
| 2384 |
target_url = validate_reader_source_url(url)
|
| 2385 |
except ValueError as exc:
|
| 2386 |
return JSONResponse({"error": str(exc)}, status_code=403)
|
| 2387 |
+
source_type = READER_RESOURCE_TYPES.get(unquote(urlparse(target_url).path).rsplit(".", 1)[-1].lower(), "")
|
| 2388 |
+
if source_type.startswith(("audio/", "video/")):
|
| 2389 |
+
return await stream_reader_document(request, target_url, media_type=source_type)
|
| 2390 |
return await stream_reader_document(request, target_url)
|
| 2391 |
|
| 2392 |
|
|
|
|
| 2443 |
url_validator=validate_reader_redirect_url, cache_control: str | None = None,
|
| 2444 |
max_bytes: int | None = None, media_type: str | None = None, validation_scope=None, _share_range: bool = True,
|
| 2445 |
):
|
| 2446 |
+
# Native media is streamed, not parsed or buffered as a complete document.
|
| 2447 |
+
# Bound transport memory and idle time instead of rejecting large recordings.
|
| 2448 |
+
is_media_stream = bool(media_type and media_type.startswith(("audio/", "video/")))
|
| 2449 |
+
if max_bytes is None and not is_media_stream:
|
| 2450 |
max_bytes = READER_DOCUMENT_MAX_BYTES
|
| 2451 |
forwarded_headers = {
|
| 2452 |
name: request.headers[name]
|
|
|
|
| 2499 |
upstream = lease.response = await open_download_response(
|
| 2500 |
app.state.http_session,
|
| 2501 |
target_url,
|
| 2502 |
+
aiohttp.ClientTimeout(total=None if is_media_stream else 180, connect=15, sock_read=60),
|
| 2503 |
method=request.method,
|
| 2504 |
request_headers=forwarded_headers,
|
| 2505 |
url_validator=url_validator,
|
|
|
|
| 2516 |
if is_immutable_reader_asset(target_url)
|
| 2517 |
else headers.get("Cache-Control", "public, max-age=300")
|
| 2518 |
)
|
| 2519 |
+
# Gecko treats any Content-Encoding header as compressed media and
|
| 2520 |
+
# disables length/range seeking. GZipMiddleware already excludes media.
|
| 2521 |
+
if is_media_stream:
|
| 2522 |
+
headers.pop("Content-Encoding", None)
|
| 2523 |
+
else:
|
| 2524 |
+
headers["Content-Encoding"] = "identity"
|
| 2525 |
if media_type:
|
| 2526 |
headers["Content-Type"] = media_type
|
| 2527 |
status = upstream.status
|
|
|
|
| 2535 |
length = int(declared_length)
|
| 2536 |
except (TypeError, ValueError):
|
| 2537 |
return JSONResponse({"error": "上游阅读文件长度无效"}, status_code=502)
|
| 2538 |
+
if length < 0 or (max_bytes is not None and length > max_bytes):
|
| 2539 |
return JSONResponse({"error": "阅读文件过大"}, status_code=413)
|
| 2540 |
content_range = upstream.headers.get("Content-Range")
|
| 2541 |
if status == 206:
|
|
|
|
| 2546 |
if (range_start > range_end or range_end >= total_length
|
| 2547 |
or (length is not None and length != range_end - range_start + 1)):
|
| 2548 |
return JSONResponse({"error": "上游阅读文件范围无效"}, status_code=502)
|
| 2549 |
+
if max_bytes is not None and total_length > max_bytes:
|
| 2550 |
return JSONResponse({"error": "阅读文件过大"}, status_code=413)
|
| 2551 |
elif status == 416:
|
| 2552 |
match = re.fullmatch(r"bytes\s+\*/([0-9]{1,20})", str(content_range or ""))
|
| 2553 |
+
if match and max_bytes is not None and int(match.group(1)) > max_bytes:
|
| 2554 |
return JSONResponse({"error": "阅读文件过大"}, status_code=413)
|
| 2555 |
if request.method == "HEAD" or status == 304:
|
| 2556 |
return Response(status_code=status, headers=headers)
|
|
|
|
| 2567 |
try:
|
| 2568 |
async for chunk in upstream.content.iter_chunked(65536):
|
| 2569 |
total += len(chunk)
|
| 2570 |
+
if max_bytes is not None and total > max_bytes:
|
| 2571 |
raise ReaderDocumentLimitError("阅读文件过大")
|
| 2572 |
if cacheable:
|
| 2573 |
chunks.append(chunk)
|
|
|
|
| 2919 |
finally:
|
| 2920 |
if not transferred:
|
| 2921 |
await subscription.release()
|
|
|
scripts/check_reader_release.mjs
CHANGED
|
@@ -182,6 +182,7 @@ function crossCheck(githubRoot, hfRoot) {
|
|
| 182 |
"reader-runtime.js",
|
| 183 |
"reader-format-adapters.js",
|
| 184 |
"reader-security.js",
|
|
|
|
| 185 |
"pdf-worker-wrapper.mjs"
|
| 186 |
];
|
| 187 |
for (const file of exact) {
|
|
|
|
| 182 |
"reader-runtime.js",
|
| 183 |
"reader-format-adapters.js",
|
| 184 |
"reader-security.js",
|
| 185 |
+
"reader-search-controller.mjs",
|
| 186 |
"pdf-worker-wrapper.mjs"
|
| 187 |
];
|
| 188 |
for (const file of exact) {
|
static/icons/LICENSE-lucide
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
ISC License
|
| 2 |
+
|
| 3 |
+
Copyright (c) for portions of Lucide are held by Cole Bemis 2013-2022 as part of Feather (MIT). All other copyright (c) for Lucide are held by Lucide Contributors 2022.
|
| 4 |
+
|
| 5 |
+
Permission to use, copy, modify, and/or distribute this software for any
|
| 6 |
+
purpose with or without fee is hereby granted, provided that the above
|
| 7 |
+
copyright notice and this permission notice appear in all copies.
|
| 8 |
+
|
| 9 |
+
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
|
| 10 |
+
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
|
| 11 |
+
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
|
| 12 |
+
ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
|
| 13 |
+
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
|
| 14 |
+
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
|
| 15 |
+
OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
|
static/reader-resources.js
CHANGED
|
@@ -100,6 +100,7 @@
|
|
| 100 |
"reader-runtime.js",
|
| 101 |
"reader-format-adapters.js",
|
| 102 |
"reader-security.js",
|
|
|
|
| 103 |
"reader-pdf-text.js",
|
| 104 |
"reader-v3.mjs",
|
| 105 |
"reader-book-text.js",
|
|
|
|
| 100 |
"reader-runtime.js",
|
| 101 |
"reader-format-adapters.js",
|
| 102 |
"reader-security.js",
|
| 103 |
+
"reader-search-controller.mjs",
|
| 104 |
"reader-pdf-text.js",
|
| 105 |
"reader-v3.mjs",
|
| 106 |
"reader-book-text.js",
|
static/reader-search-controller.mjs
ADDED
|
@@ -0,0 +1,335 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Shared search presentation and task ownership; format matching stays in Reader.
|
| 2 |
+
export function createReaderSearchController({
|
| 3 |
+
document, runtime, signal, nextGeneration, isCurrent, updateSearch,
|
| 4 |
+
backend, navigation, panel
|
| 5 |
+
}) {
|
| 6 |
+
const search = runtime.state.search;
|
| 7 |
+
const state = {
|
| 8 |
+
page: null, failedPage: null, complete: false,
|
| 9 |
+
pdfInProgress: false, chapterInProgress: false, pageLoader: null
|
| 10 |
+
};
|
| 11 |
+
let inputTimer = 0, disposed = false;
|
| 12 |
+
let renderedResults = [], renderedOffset = -1;
|
| 13 |
+
const listeners = [];
|
| 14 |
+
const active = () => !disposed && !signal.aborted;
|
| 15 |
+
const current = generation => active() && isCurrent(generation);
|
| 16 |
+
const busy = () => state.pdfInProgress || state.chapterInProgress;
|
| 17 |
+
const pagedMode = () => ["epub-chapters", "pdf", "pdf-pages", "foliate"].includes(backend.mode());
|
| 18 |
+
|
| 19 |
+
const view = document.createElement("section");
|
| 20 |
+
view.id = "full-search-view";
|
| 21 |
+
view.className = "reader-panel-view full-search-view";
|
| 22 |
+
view.dataset.panelView = "full-search";
|
| 23 |
+
view.hidden = true;
|
| 24 |
+
view.innerHTML =
|
| 25 |
+
'<div class="full-search-bar"><input id="full-search-input" class="full-search-input" type="search" placeholder="搜索正文" aria-label="搜索正文"><button id="full-search-clear" class="icon-button" type="button" aria-label="清除全文搜索" title="清除">×</button></div><div id="full-search-status" class="full-search-status" role="status">输入关键词搜索正文</div><div class="full-search-nav"><button id="full-search-prev" type="button" disabled>上一个</button><button id="full-search-next" type="button" disabled>下一个</button></div><div id="full-search-results" class="full-search-results"></div>';
|
| 26 |
+
document.querySelector("#history-panel").insertBefore(view, document.querySelector(".reader-panel-tabs"));
|
| 27 |
+
const button = document.querySelector("#full-search-toggle");
|
| 28 |
+
button.textContent = "全文搜索";
|
| 29 |
+
button.setAttribute("aria-label", "全文搜索");
|
| 30 |
+
const input = view.querySelector("#full-search-input");
|
| 31 |
+
const status = view.querySelector("#full-search-status");
|
| 32 |
+
const results = view.querySelector("#full-search-results");
|
| 33 |
+
const clear = view.querySelector("#full-search-clear");
|
| 34 |
+
clear.hidden = true;
|
| 35 |
+
const pagination = document.createElement("div");
|
| 36 |
+
pagination.className = "full-search-pagination";
|
| 37 |
+
pagination.hidden = true;
|
| 38 |
+
pagination.innerHTML =
|
| 39 |
+
'<button id="full-search-page-prev" type="button">上一页</button><label>第 <input id="full-search-page" type="number" min="1" value="1" aria-label="搜索结果页码"> 页 <span id="full-search-pages"></span></label><button id="full-search-page-next" type="button">下一页</button>';
|
| 40 |
+
results.before(pagination);
|
| 41 |
+
const cancel = document.createElement("button");
|
| 42 |
+
cancel.type = "button";
|
| 43 |
+
cancel.id = "full-search-cancel";
|
| 44 |
+
cancel.className = "text-action";
|
| 45 |
+
cancel.textContent = "取消搜索";
|
| 46 |
+
cancel.hidden = true;
|
| 47 |
+
status.after(cancel);
|
| 48 |
+
const retry = document.createElement("button");
|
| 49 |
+
retry.type = "button";
|
| 50 |
+
retry.id = "full-search-retry";
|
| 51 |
+
retry.className = "text-action";
|
| 52 |
+
retry.textContent = "重试搜索";
|
| 53 |
+
retry.hidden = true;
|
| 54 |
+
cancel.after(retry);
|
| 55 |
+
|
| 56 |
+
function listen(target, type, callback) {
|
| 57 |
+
target.addEventListener(type, callback);
|
| 58 |
+
listeners.push(() => target.removeEventListener(type, callback));
|
| 59 |
+
}
|
| 60 |
+
function clearInputTimer() {
|
| 61 |
+
clearTimeout(inputTimer);
|
| 62 |
+
inputTimer = 0;
|
| 63 |
+
}
|
| 64 |
+
function updateButton(open) {
|
| 65 |
+
button.textContent = open ? "关闭全文搜索" : "全文搜索";
|
| 66 |
+
button.setAttribute("aria-label", button.textContent);
|
| 67 |
+
}
|
| 68 |
+
function updateClear() {
|
| 69 |
+
clear.hidden = !input.value;
|
| 70 |
+
}
|
| 71 |
+
function updateNavigation() {
|
| 72 |
+
view.querySelectorAll(".full-search-nav button").forEach(node => {
|
| 73 |
+
node.disabled = !search.results.length;
|
| 74 |
+
});
|
| 75 |
+
}
|
| 76 |
+
function updatePagination() {
|
| 77 |
+
const page = state.page;
|
| 78 |
+
pagination.hidden = !page || !page.total;
|
| 79 |
+
if (!page) return;
|
| 80 |
+
const pages = Math.ceil(page.total / page.pageSize);
|
| 81 |
+
const pageInput = pagination.querySelector("#full-search-page");
|
| 82 |
+
pageInput.value = String(Math.floor(page.offset / page.pageSize) + 1);
|
| 83 |
+
pageInput.max = String(Math.max(1, pages));
|
| 84 |
+
pageInput.style.setProperty("--page-width", `${pageInput.max.length}ch`);
|
| 85 |
+
pagination.querySelector("#full-search-pages").textContent = `/ ${pages}`;
|
| 86 |
+
pagination.querySelector("#full-search-page-prev").disabled = busy() || page.offset === 0;
|
| 87 |
+
pagination.querySelector("#full-search-page-next").disabled = busy() || page.offset + page.pageSize >= page.total;
|
| 88 |
+
pageInput.disabled = busy();
|
| 89 |
+
}
|
| 90 |
+
function snippetDom(snippet) {
|
| 91 |
+
const node = document.createElement("span");
|
| 92 |
+
node.className = "full-search-snippet";
|
| 93 |
+
if (snippet.prefix) node.append(snippet.prefix);
|
| 94 |
+
node.append(snippet.text.slice(0, snippet.matchStart));
|
| 95 |
+
const mark = document.createElement("mark");
|
| 96 |
+
mark.className = "search-match";
|
| 97 |
+
mark.textContent = snippet.text.slice(snippet.matchStart, snippet.matchStart + snippet.matchLength);
|
| 98 |
+
node.append(mark, snippet.text.slice(snippet.matchStart + snippet.matchLength));
|
| 99 |
+
if (snippet.suffix) node.append(snippet.suffix);
|
| 100 |
+
return node;
|
| 101 |
+
}
|
| 102 |
+
function render() {
|
| 103 |
+
updatePagination();
|
| 104 |
+
updateNavigation();
|
| 105 |
+
const offset = state.page?.offset || 0;
|
| 106 |
+
if (renderedOffset === offset && results.childElementCount === search.results.length &&
|
| 107 |
+
renderedResults.length === search.results.length &&
|
| 108 |
+
search.results.every((result, index) => result === renderedResults[index])) return;
|
| 109 |
+
const fragment = document.createDocumentFragment();
|
| 110 |
+
for (const [index, result] of search.results.entries()) {
|
| 111 |
+
const row = document.createElement("button");
|
| 112 |
+
const location = document.createElement("small");
|
| 113 |
+
row.type = "button";
|
| 114 |
+
row.className = "full-search-result";
|
| 115 |
+
location.className = "full-search-location";
|
| 116 |
+
location.textContent = result.location;
|
| 117 |
+
const rank = document.createElement("span");
|
| 118 |
+
rank.className = "full-search-rank";
|
| 119 |
+
rank.textContent = `${offset + index + 1}.`;
|
| 120 |
+
row.append(rank, location, snippetDom(result.snippet));
|
| 121 |
+
row.addEventListener("click", () => activate(index));
|
| 122 |
+
fragment.appendChild(row);
|
| 123 |
+
}
|
| 124 |
+
results.replaceChildren(fragment);
|
| 125 |
+
renderedResults = search.results.slice();
|
| 126 |
+
renderedOffset = offset;
|
| 127 |
+
}
|
| 128 |
+
function resetResults(query = search.query) {
|
| 129 |
+
state.complete = false;
|
| 130 |
+
state.pdfInProgress = state.chapterInProgress = false;
|
| 131 |
+
state.page = state.failedPage = state.pageLoader = null;
|
| 132 |
+
updateSearch({ query, results: [], index: -1 });
|
| 133 |
+
render();
|
| 134 |
+
}
|
| 135 |
+
function cancelPending() {
|
| 136 |
+
clearInputTimer();
|
| 137 |
+
nextGeneration();
|
| 138 |
+
navigation.begin();
|
| 139 |
+
backend.chapterClient()?.cancel();
|
| 140 |
+
backend.pdfClient()?.cancel();
|
| 141 |
+
resetResults();
|
| 142 |
+
}
|
| 143 |
+
function close() {
|
| 144 |
+
clearInputTimer();
|
| 145 |
+
if (busy()) state.complete = false;
|
| 146 |
+
nextGeneration();
|
| 147 |
+
if (state.chapterInProgress) backend.chapterClient()?.cancel();
|
| 148 |
+
if (state.pdfInProgress) backend.pdfClient()?.cancel();
|
| 149 |
+
state.pdfInProgress = state.chapterInProgress = false;
|
| 150 |
+
cancel.hidden = retry.hidden = true;
|
| 151 |
+
panel.hide();
|
| 152 |
+
updateButton(false);
|
| 153 |
+
}
|
| 154 |
+
async function run() {
|
| 155 |
+
if (!active()) return;
|
| 156 |
+
clearInputTimer();
|
| 157 |
+
const query = input.value.trim();
|
| 158 |
+
const generation = nextGeneration();
|
| 159 |
+
backend.pdfClient()?.cancel();
|
| 160 |
+
navigation.begin();
|
| 161 |
+
backend.clearMarks();
|
| 162 |
+
resetResults(query);
|
| 163 |
+
retry.hidden = true;
|
| 164 |
+
retry.textContent = "重试搜索";
|
| 165 |
+
cancel.hidden = !pagedMode() || !query;
|
| 166 |
+
state.pdfInProgress = ["pdf", "pdf-pages", "foliate"].includes(backend.mode()) && !!query;
|
| 167 |
+
state.chapterInProgress = backend.mode() === "epub-chapters" && !!query;
|
| 168 |
+
if (!query) {
|
| 169 |
+
backend.chapterClient()?.cancel();
|
| 170 |
+
status.textContent = "输入关键词搜索正文";
|
| 171 |
+
return;
|
| 172 |
+
}
|
| 173 |
+
status.textContent = "正在搜索正文…";
|
| 174 |
+
try {
|
| 175 |
+
await backend.search(query, generation);
|
| 176 |
+
if (!current(generation)) return;
|
| 177 |
+
state.complete = true;
|
| 178 |
+
state.pdfInProgress = state.chapterInProgress = false;
|
| 179 |
+
render();
|
| 180 |
+
const total = state.page?.total ?? search.results.length;
|
| 181 |
+
status.textContent = total ? `${total}${!state.page && total === 100 ? "+" : ""} 个结果` : "未找到正文匹配";
|
| 182 |
+
} catch (error) {
|
| 183 |
+
if (current(generation)) {
|
| 184 |
+
status.textContent = error.message || "正文搜索不可用";
|
| 185 |
+
retry.hidden = !pagedMode();
|
| 186 |
+
}
|
| 187 |
+
} finally {
|
| 188 |
+
if (current(generation)) {
|
| 189 |
+
state.pdfInProgress = state.chapterInProgress = false;
|
| 190 |
+
updatePagination();
|
| 191 |
+
cancel.hidden = true;
|
| 192 |
+
updateNavigation();
|
| 193 |
+
}
|
| 194 |
+
}
|
| 195 |
+
}
|
| 196 |
+
function applyChapterPage(page) {
|
| 197 |
+
state.page = page;
|
| 198 |
+
updateSearch({ results: page.results.map(backend.chapterResult), index: -1 });
|
| 199 |
+
}
|
| 200 |
+
async function loadPage(offset, selectLast = null) {
|
| 201 |
+
if (!active() || busy() || !state.page || (!backend.chapterClient() && !state.pageLoader)) return;
|
| 202 |
+
state.failedPage = null;
|
| 203 |
+
const generation = nextGeneration();
|
| 204 |
+
navigation.begin();
|
| 205 |
+
if (!["foliate", "epub-chapters"].includes(backend.mode())) cancel.hidden = false;
|
| 206 |
+
retry.hidden = true;
|
| 207 |
+
status.textContent = `${state.page.total} 个结果`;
|
| 208 |
+
try {
|
| 209 |
+
const loader = state.pageLoader;
|
| 210 |
+
const page = await (loader ? loader(offset, generation) : backend.chapterClient().page(offset));
|
| 211 |
+
if (!current(generation)) return;
|
| 212 |
+
if (loader) {
|
| 213 |
+
state.page = page;
|
| 214 |
+
updateSearch({ results: page.results, index: -1 });
|
| 215 |
+
} else applyChapterPage(page);
|
| 216 |
+
render();
|
| 217 |
+
status.textContent = `${page.total} 个结果`;
|
| 218 |
+
if (selectLast !== null) await activate(selectLast ? page.results.length - 1 : 0, false);
|
| 219 |
+
} catch (error) {
|
| 220 |
+
if (current(generation)) {
|
| 221 |
+
status.textContent = error.message || "搜索结果加载失败";
|
| 222 |
+
state.failedPage = { offset, selectLast, query: search.query };
|
| 223 |
+
retry.textContent = "重试本页";
|
| 224 |
+
retry.hidden = false;
|
| 225 |
+
}
|
| 226 |
+
} finally {
|
| 227 |
+
if (current(generation)) cancel.hidden = true;
|
| 228 |
+
}
|
| 229 |
+
}
|
| 230 |
+
async function activate(index, closePanel = true) {
|
| 231 |
+
if (!active()) return;
|
| 232 |
+
const result = search.results[index];
|
| 233 |
+
if (!result) return;
|
| 234 |
+
const previousIndex = search.index;
|
| 235 |
+
const panelSerial = panel.serial();
|
| 236 |
+
const searchGeneration = runtime.currentGeneration("search");
|
| 237 |
+
const generation = navigation.begin();
|
| 238 |
+
const ownsResult = () => current(searchGeneration) && search.results[index] === result;
|
| 239 |
+
updateSearch({ index });
|
| 240 |
+
try {
|
| 241 |
+
const activated = await backend.activate(result, generation);
|
| 242 |
+
if (activated === false && navigation.current(generation) && ownsResult()) {
|
| 243 |
+
updateSearch({ index: previousIndex });
|
| 244 |
+
status.textContent = "搜索结果定位失败,请重试";
|
| 245 |
+
return;
|
| 246 |
+
}
|
| 247 |
+
if (activated === false || !navigation.current(generation) || search.results[index] !== result) return;
|
| 248 |
+
if (status.textContent === "搜索结果定位失败,请重试")
|
| 249 |
+
status.textContent = `${state.page?.total ?? search.results.length} 个结果`;
|
| 250 |
+
if (closePanel && panelSerial === panel.serial() && current(searchGeneration)) panel.close();
|
| 251 |
+
else if (!closePanel) results.children[index]?.scrollIntoView({ block: "nearest" });
|
| 252 |
+
} catch (error) {
|
| 253 |
+
if (error?.name !== "AbortError" && navigation.current(generation) && ownsResult()) {
|
| 254 |
+
updateSearch({ index: previousIndex });
|
| 255 |
+
status.textContent = backend.mode() === "epub-chapters"
|
| 256 |
+
? error.message || "搜索结果定位失败,请重试" : "搜索结果定位失败,请重试";
|
| 257 |
+
}
|
| 258 |
+
navigation.reportError(error, generation);
|
| 259 |
+
}
|
| 260 |
+
}
|
| 261 |
+
function move(step) {
|
| 262 |
+
const page = state.page;
|
| 263 |
+
if (page?.total) {
|
| 264 |
+
const next = search.index < 0 ? (step > 0 ? 0 : -1) : search.index + step;
|
| 265 |
+
if (next < 0) return loadPage(page.offset ? page.offset - page.pageSize
|
| 266 |
+
: Math.floor((page.total - 1) / page.pageSize) * page.pageSize, true);
|
| 267 |
+
if (next >= search.results.length) return loadPage(
|
| 268 |
+
page.offset + page.pageSize < page.total ? page.offset + page.pageSize : 0, false);
|
| 269 |
+
return activate(next, false);
|
| 270 |
+
}
|
| 271 |
+
if (search.results.length) return activate((search.index + step + search.results.length) % search.results.length, false);
|
| 272 |
+
}
|
| 273 |
+
function toggle() {
|
| 274 |
+
if (!active()) return;
|
| 275 |
+
if (panel.isOpen() && !view.hidden) return close();
|
| 276 |
+
panel.show();
|
| 277 |
+
updateButton(true);
|
| 278 |
+
updateClear();
|
| 279 |
+
input.focus();
|
| 280 |
+
if (search.query !== input.value.trim() || !state.complete) run();
|
| 281 |
+
else if (state.failedPage) retry.hidden = false;
|
| 282 |
+
}
|
| 283 |
+
function queue() {
|
| 284 |
+
if (!active()) return;
|
| 285 |
+
cancelPending();
|
| 286 |
+
cancel.hidden = retry.hidden = true;
|
| 287 |
+
status.textContent = input.value.trim() ? "正在搜索正文…" : "输入关键词搜索正文";
|
| 288 |
+
inputTimer = setTimeout(run, 180);
|
| 289 |
+
}
|
| 290 |
+
|
| 291 |
+
listen(retry, "click", async () => {
|
| 292 |
+
const failed = state.failedPage;
|
| 293 |
+
if (!failed || search.query !== failed.query) return run();
|
| 294 |
+
if (backend.chapterClient()?.failed) {
|
| 295 |
+
await run();
|
| 296 |
+
if (!state.complete || search.query !== failed.query) return;
|
| 297 |
+
}
|
| 298 |
+
return loadPage(failed.offset, failed.selectLast);
|
| 299 |
+
});
|
| 300 |
+
listen(cancel, "click", () => {
|
| 301 |
+
cancelPending();
|
| 302 |
+
cancel.hidden = true;
|
| 303 |
+
retry.hidden = false;
|
| 304 |
+
retry.textContent = "重试搜索";
|
| 305 |
+
status.textContent = "搜索已取消";
|
| 306 |
+
});
|
| 307 |
+
listen(pagination.querySelector("#full-search-page-prev"), "click", () => loadPage(state.page.offset - state.page.pageSize));
|
| 308 |
+
listen(pagination.querySelector("#full-search-page-next"), "click", () => loadPage(state.page.offset + state.page.pageSize));
|
| 309 |
+
listen(pagination.querySelector("#full-search-page"), "change", event => {
|
| 310 |
+
if (!state.page || !Number.isFinite(event.target.valueAsNumber)) return;
|
| 311 |
+
const page = Math.max(1, Math.min(Math.ceil(state.page.total / state.page.pageSize), Math.floor(event.target.valueAsNumber)));
|
| 312 |
+
const offset = (page - 1) * state.page.pageSize;
|
| 313 |
+
if (offset !== state.page.offset) loadPage(offset);
|
| 314 |
+
});
|
| 315 |
+
listen(button, "click", toggle);
|
| 316 |
+
listen(input, "input", event => { updateClear(); if (!event.isComposing) queue(); });
|
| 317 |
+
listen(input, "focus", panel.focus);
|
| 318 |
+
listen(input, "compositionend", queue);
|
| 319 |
+
listen(clear, "click", () => { input.value = ""; updateClear(); run(); input.focus(); });
|
| 320 |
+
listen(view.querySelector("#full-search-prev"), "click", () => move(-1));
|
| 321 |
+
listen(view.querySelector("#full-search-next"), "click", () => move(1));
|
| 322 |
+
|
| 323 |
+
function dispose() {
|
| 324 |
+
if (disposed) return;
|
| 325 |
+
disposed = true;
|
| 326 |
+
clearInputTimer();
|
| 327 |
+
for (const remove of listeners.splice(0)) remove();
|
| 328 |
+
renderedResults = [];
|
| 329 |
+
state.page = state.failedPage = state.pageLoader = null;
|
| 330 |
+
}
|
| 331 |
+
return Object.freeze({
|
| 332 |
+
state, elements: Object.freeze({ view, button, input, status, results, pagination, cancel, retry }),
|
| 333 |
+
render, updatePagination, applyChapterPage, loadPage, run, close, activate, move, dispose
|
| 334 |
+
});
|
| 335 |
+
}
|
static/reader.css
CHANGED
|
@@ -138,6 +138,11 @@ body {
|
|
| 138 |
.icon-button {
|
| 139 |
width: 32px;
|
| 140 |
height: 32px;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 141 |
border: 0;
|
| 142 |
background: transparent;
|
| 143 |
color: inherit;
|
|
@@ -147,6 +152,22 @@ body {
|
|
| 147 |
cursor: pointer;
|
| 148 |
border-radius: 4px;
|
| 149 |
font-size: 18px;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 150 |
}
|
| 151 |
.text-button {
|
| 152 |
height: 32px;
|
|
|
|
| 138 |
.icon-button {
|
| 139 |
width: 32px;
|
| 140 |
height: 32px;
|
| 141 |
+
flex: none;
|
| 142 |
+
margin: 0;
|
| 143 |
+
padding: 0;
|
| 144 |
+
appearance: none;
|
| 145 |
+
-webkit-appearance: none;
|
| 146 |
border: 0;
|
| 147 |
background: transparent;
|
| 148 |
color: inherit;
|
|
|
|
| 152 |
cursor: pointer;
|
| 153 |
border-radius: 4px;
|
| 154 |
font-size: 18px;
|
| 155 |
+
font-family: inherit;
|
| 156 |
+
line-height: 1;
|
| 157 |
+
}
|
| 158 |
+
.icon-button svg {
|
| 159 |
+
display: block;
|
| 160 |
+
width: 18px;
|
| 161 |
+
height: 18px;
|
| 162 |
+
pointer-events: none;
|
| 163 |
+
}
|
| 164 |
+
.icon-button::-moz-focus-inner {
|
| 165 |
+
padding: 0;
|
| 166 |
+
border: 0;
|
| 167 |
+
}
|
| 168 |
+
.icon-button:focus-visible {
|
| 169 |
+
outline: 2px solid var(--reader-focus-color);
|
| 170 |
+
outline-offset: 1px;
|
| 171 |
}
|
| 172 |
.text-button {
|
| 173 |
height: 32px;
|
static/reader.html
CHANGED
|
@@ -59,7 +59,12 @@
|
|
| 59 |
</head>
|
| 60 |
<body>
|
| 61 |
<header class="reader-toolbar">
|
| 62 |
-
<button id="back" class="icon-button" type="button">
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 63 |
<div class="reader-heading">
|
| 64 |
<strong id="title">在线阅读</strong>
|
| 65 |
<button id="reader-path" class="reader-path" type="button" hidden></button>
|
|
@@ -91,8 +96,13 @@
|
|
| 91 |
title="下载原文件"
|
| 92 |
target="_blank"
|
| 93 |
rel="noopener noreferrer"
|
| 94 |
-
>⇩</a
|
| 95 |
>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 96 |
<span id="download-feedback" role="status" hidden></span>
|
| 97 |
</div>
|
| 98 |
<button id="history" class="icon-button" type="button">☰</button>
|
|
|
|
| 59 |
</head>
|
| 60 |
<body>
|
| 61 |
<header class="reader-toolbar">
|
| 62 |
+
<button id="back" class="icon-button" type="button" aria-label="返回" title="返回">
|
| 63 |
+
<!-- Lucide arrow-left, ISC; see icons/LICENSE-lucide -->
|
| 64 |
+
<svg class="lucide lucide-arrow-left" xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
|
| 65 |
+
<path d="m12 19-7-7 7-7" /><path d="M19 12H5" />
|
| 66 |
+
</svg>
|
| 67 |
+
</button>
|
| 68 |
<div class="reader-heading">
|
| 69 |
<strong id="title">在线阅读</strong>
|
| 70 |
<button id="reader-path" class="reader-path" type="button" hidden></button>
|
|
|
|
| 96 |
title="下载原文件"
|
| 97 |
target="_blank"
|
| 98 |
rel="noopener noreferrer"
|
|
|
|
| 99 |
>
|
| 100 |
+
<!-- Lucide download, ISC; see icons/LICENSE-lucide -->
|
| 101 |
+
<svg class="lucide lucide-download" xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
|
| 102 |
+
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
| 103 |
+
<polyline points="7 10 12 15 17 10" /><line x1="12" x2="12" y1="15" y2="3" />
|
| 104 |
+
</svg>
|
| 105 |
+
</a>
|
| 106 |
<span id="download-feedback" role="status" hidden></span>
|
| 107 |
</div>
|
| 108 |
<button id="history" class="icon-button" type="button">☰</button>
|
static/reader.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
|
|
| 1 |
import "/static/reader-resources.js";
|
| 2 |
import "/static/reader-contract.js";
|
| 3 |
import "/static/reader-request-manager.js";
|
|
@@ -469,10 +470,7 @@ let chapterManifestLoader = null;
|
|
| 469 |
let chapterSearchConfiguration = null;
|
| 470 |
let chapterSearchModulePromise = null;
|
| 471 |
let chapterSearchClient = null;
|
| 472 |
-
|
| 473 |
-
let failedSearchPage = null;
|
| 474 |
-
let chapterSearchInProgress = false;
|
| 475 |
-
let fullSearchComplete = false;
|
| 476 |
trackReaderResource(() => {
|
| 477 |
chapterSearchClient?.dispose();
|
| 478 |
chapterSearchClient = null;
|
|
@@ -5625,12 +5623,14 @@ function renderMedia(mode) {
|
|
| 5625 |
});
|
| 5626 |
mediaElement = media;
|
| 5627 |
let sourceRetried = false;
|
|
|
|
|
|
|
| 5628 |
media.addEventListener("error", () => {
|
| 5629 |
if (readerAbortController.signal.aborted) return;
|
| 5630 |
-
if (!sourceRetried &&
|
| 5631 |
sourceRetried = true;
|
| 5632 |
media._readerSourceRetry = true;
|
| 5633 |
-
media.src =
|
| 5634 |
media.load();
|
| 5635 |
return;
|
| 5636 |
}
|
|
@@ -6725,100 +6725,47 @@ chapterPrev.addEventListener("click", () =>
|
|
| 6725 |
chapterNext.addEventListener("click", () =>
|
| 6726 |
activateChapter(adjacentTocIndex(1))
|
| 6727 |
);
|
| 6728 |
-
const
|
| 6729 |
-
|
| 6730 |
-
|
| 6731 |
-
|
| 6732 |
-
|
| 6733 |
-
|
| 6734 |
-
|
| 6735 |
-
|
| 6736 |
-
|
| 6737 |
-
|
| 6738 |
-
|
| 6739 |
-
|
| 6740 |
-
|
| 6741 |
-
|
| 6742 |
-
|
| 6743 |
-
|
| 6744 |
-
|
| 6745 |
-
|
| 6746 |
-
|
| 6747 |
-
|
| 6748 |
-
|
| 6749 |
-
|
| 6750 |
-
|
| 6751 |
-
|
| 6752 |
-
|
| 6753 |
-
|
| 6754 |
-
|
| 6755 |
-
|
| 6756 |
-
chapterSearchPagination.className = "full-search-pagination";
|
| 6757 |
-
chapterSearchPagination.hidden = true;
|
| 6758 |
-
chapterSearchPagination.innerHTML =
|
| 6759 |
-
'<button id="full-search-page-prev" type="button">上一页</button><label>第 <input id="full-search-page" type="number" min="1" value="1" aria-label="搜索结果页码"> 页 <span id="full-search-pages"></span></label><button id="full-search-page-next" type="button">下一页</button>';
|
| 6760 |
-
fullSearchResultsNode.before(chapterSearchPagination);
|
| 6761 |
-
const fullSearchCancel = document.createElement("button");
|
| 6762 |
-
fullSearchCancel.type = "button";
|
| 6763 |
-
fullSearchCancel.id = "full-search-cancel";
|
| 6764 |
-
fullSearchCancel.className = "text-action";
|
| 6765 |
-
fullSearchCancel.textContent = "取消搜索";
|
| 6766 |
-
fullSearchCancel.hidden = true;
|
| 6767 |
-
fullSearchStatus.after(fullSearchCancel);
|
| 6768 |
-
const fullSearchRetry = document.createElement("button");
|
| 6769 |
-
fullSearchRetry.type = "button";
|
| 6770 |
-
fullSearchRetry.id = "full-search-retry";
|
| 6771 |
-
fullSearchRetry.className = "text-action";
|
| 6772 |
-
fullSearchRetry.textContent = "重试搜索";
|
| 6773 |
-
fullSearchRetry.hidden = true;
|
| 6774 |
-
fullSearchCancel.after(fullSearchRetry);
|
| 6775 |
-
fullSearchRetry.addEventListener("click", async () => {
|
| 6776 |
-
const failed = failedSearchPage;
|
| 6777 |
-
if (!failed || searchState.query !== failed.query) return runFullSearch();
|
| 6778 |
-
if (chapterSearchClient?.failed) {
|
| 6779 |
-
await runFullSearch();
|
| 6780 |
-
if (!fullSearchComplete || searchState.query !== failed.query) return;
|
| 6781 |
}
|
| 6782 |
-
return loadChapterSearchPage(failed.offset, failed.selectLast);
|
| 6783 |
-
});
|
| 6784 |
-
fullSearchCancel.addEventListener("click", () => {
|
| 6785 |
-
nextReaderGeneration("search");
|
| 6786 |
-
beginReaderNavigation();
|
| 6787 |
-
chapterSearchClient?.cancel();
|
| 6788 |
-
pdfBookSearchClient?.cancel();
|
| 6789 |
-
pdfSearchInProgress = false;
|
| 6790 |
-
chapterSearchInProgress = false;
|
| 6791 |
-
chapterSearchPage = null;
|
| 6792 |
-
failedSearchPage = null;
|
| 6793 |
-
pdfSearchPageLoader = null;
|
| 6794 |
-
fullSearchComplete = false;
|
| 6795 |
-
updateSearchState({ results: [], index: -1 });
|
| 6796 |
-
renderFullSearchResults();
|
| 6797 |
-
fullSearchCancel.hidden = true;
|
| 6798 |
-
fullSearchRetry.hidden = false;
|
| 6799 |
-
fullSearchRetry.textContent = "重试搜索";
|
| 6800 |
-
fullSearchStatus.textContent = "搜索已取消";
|
| 6801 |
-
});
|
| 6802 |
-
chapterSearchPagination
|
| 6803 |
-
.querySelector("#full-search-page-prev")
|
| 6804 |
-
.addEventListener("click", () =>
|
| 6805 |
-
loadChapterSearchPage(chapterSearchPage.offset - chapterSearchPage.pageSize)
|
| 6806 |
-
);
|
| 6807 |
-
chapterSearchPagination
|
| 6808 |
-
.querySelector("#full-search-page-next")
|
| 6809 |
-
.addEventListener("click", () =>
|
| 6810 |
-
loadChapterSearchPage(chapterSearchPage.offset + chapterSearchPage.pageSize)
|
| 6811 |
-
);
|
| 6812 |
-
chapterSearchPagination.querySelector("#full-search-page").addEventListener("change", (event) => {
|
| 6813 |
-
if (!chapterSearchPage || !Number.isFinite(event.target.valueAsNumber)) return;
|
| 6814 |
-
const page = Math.max(1, Math.min(
|
| 6815 |
-
Math.ceil(chapterSearchPage.total / chapterSearchPage.pageSize),
|
| 6816 |
-
Math.floor(event.target.valueAsNumber)
|
| 6817 |
-
));
|
| 6818 |
-
const offset = (page - 1) * chapterSearchPage.pageSize;
|
| 6819 |
-
if (offset !== chapterSearchPage.offset) loadChapterSearchPage(offset);
|
| 6820 |
});
|
| 6821 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6822 |
function fullSearchPageOffset(total, requestedOffset, pageSize = 50) {
|
| 6823 |
return Math.max(0, Math.min(
|
| 6824 |
Math.floor(Math.max(0, total - 1) / pageSize),
|
|
@@ -6853,16 +6800,7 @@ async function searchChapterBook(query, generation) {
|
|
| 6853 |
});
|
| 6854 |
if (isReaderGenerationCurrent("search", generation)) applyChapterSearchPage(page);
|
| 6855 |
}
|
| 6856 |
-
|
| 6857 |
-
chapterSearchPage = page;
|
| 6858 |
-
updateSearchState({
|
| 6859 |
-
results: page.results.map((result) => ({
|
| 6860 |
-
...result,
|
| 6861 |
-
activate: (generation) => navigateChapterSearchResult(result, generation)
|
| 6862 |
-
})),
|
| 6863 |
-
index: -1
|
| 6864 |
-
});
|
| 6865 |
-
}
|
| 6866 |
async function navigateChapterSearchResult(result, generation) {
|
| 6867 |
const article = await chapterManifestLoader?.(result.chapterIndex);
|
| 6868 |
if (!article || !isReaderGenerationCurrent("navigation", generation)) return false;
|
|
@@ -6907,54 +6845,7 @@ async function navigateChapterSearchResult(result, generation) {
|
|
| 6907 |
scheduleSave();
|
| 6908 |
return true;
|
| 6909 |
}
|
| 6910 |
-
|
| 6911 |
-
const page = chapterSearchPage;
|
| 6912 |
-
chapterSearchPagination.hidden = !page || !page.total;
|
| 6913 |
-
if (!page) return;
|
| 6914 |
-
const pages = Math.ceil(page.total / page.pageSize);
|
| 6915 |
-
const input = chapterSearchPagination.querySelector("#full-search-page");
|
| 6916 |
-
input.value = String(Math.floor(page.offset / page.pageSize) + 1);
|
| 6917 |
-
input.max = String(Math.max(1, pages));
|
| 6918 |
-
input.style.setProperty("--page-width", `${input.max.length}ch`);
|
| 6919 |
-
chapterSearchPagination.querySelector("#full-search-pages").textContent = `/ ${pages}`;
|
| 6920 |
-
chapterSearchPagination.querySelector("#full-search-page-prev").disabled =
|
| 6921 |
-
pdfSearchInProgress || chapterSearchInProgress || page.offset === 0;
|
| 6922 |
-
chapterSearchPagination.querySelector("#full-search-page-next").disabled =
|
| 6923 |
-
pdfSearchInProgress || chapterSearchInProgress || page.offset + page.pageSize >= page.total;
|
| 6924 |
-
input.disabled = pdfSearchInProgress || chapterSearchInProgress;
|
| 6925 |
-
}
|
| 6926 |
-
async function loadChapterSearchPage(offset, selectLast = null) {
|
| 6927 |
-
if (pdfSearchInProgress || chapterSearchInProgress || !chapterSearchPage ||
|
| 6928 |
-
(!chapterSearchClient && !pdfSearchPageLoader)) return;
|
| 6929 |
-
failedSearchPage = null;
|
| 6930 |
-
const generation = nextReaderGeneration("search");
|
| 6931 |
-
beginReaderNavigation();
|
| 6932 |
-
if (!["foliate", "epub-chapters"].includes(capability.mode)) fullSearchCancel.hidden = false;
|
| 6933 |
-
fullSearchRetry.hidden = true;
|
| 6934 |
-
fullSearchStatus.textContent = `${chapterSearchPage.total} 个结果`;
|
| 6935 |
-
try {
|
| 6936 |
-
const pdfLoader = pdfSearchPageLoader;
|
| 6937 |
-
const page = await (pdfLoader ? pdfLoader(offset, generation) : chapterSearchClient.page(offset));
|
| 6938 |
-
if (!isReaderGenerationCurrent("search", generation)) return;
|
| 6939 |
-
if (pdfLoader) {
|
| 6940 |
-
chapterSearchPage = page;
|
| 6941 |
-
updateSearchState({ results: page.results, index: -1 });
|
| 6942 |
-
} else applyChapterSearchPage(page);
|
| 6943 |
-
renderFullSearchResults();
|
| 6944 |
-
fullSearchStatus.textContent = `${page.total} 个结果`;
|
| 6945 |
-
if (selectLast !== null)
|
| 6946 |
-
await activateFullSearchResult(selectLast ? page.results.length - 1 : 0, false);
|
| 6947 |
-
} catch (error) {
|
| 6948 |
-
if (isReaderGenerationCurrent("search", generation)) {
|
| 6949 |
-
fullSearchStatus.textContent = error.message || "搜索结果加载失败";
|
| 6950 |
-
failedSearchPage = { offset, selectLast, query: searchState.query };
|
| 6951 |
-
fullSearchRetry.textContent = "重试本页";
|
| 6952 |
-
fullSearchRetry.hidden = false;
|
| 6953 |
-
}
|
| 6954 |
-
} finally {
|
| 6955 |
-
if (isReaderGenerationCurrent("search", generation)) fullSearchCancel.hidden = true;
|
| 6956 |
-
}
|
| 6957 |
-
}
|
| 6958 |
async function fullSearchFoliateMatches(query, generation) {
|
| 6959 |
if (!epubRendition?.search) return [];
|
| 6960 |
const groups = [], firstPage = [];
|
|
@@ -6976,7 +6867,7 @@ async function fullSearchFoliateMatches(query, generation) {
|
|
| 6976 |
}
|
| 6977 |
};
|
| 6978 |
};
|
| 6979 |
-
|
| 6980 |
const pageSize = 50;
|
| 6981 |
const offset = fullSearchPageOffset(total, requestedOffset, pageSize);
|
| 6982 |
if (!offset) return { total, offset, pageSize, results: firstPage.slice() };
|
|
@@ -7019,14 +6910,14 @@ async function fullSearchFoliateMatches(query, generation) {
|
|
| 7019 |
firstPage.push(toResult(item, group.label, index));
|
| 7020 |
}
|
| 7021 |
total += group.count;
|
| 7022 |
-
|
| 7023 |
-
updateSearchState({ results:
|
| 7024 |
renderFullSearchResults();
|
| 7025 |
fullSearchStatus.textContent = `${total} 个结果(正在搜索…)`;
|
| 7026 |
await waitForReader();
|
| 7027 |
if (!isReaderGenerationCurrent("search", generation)) return [];
|
| 7028 |
}
|
| 7029 |
-
|
| 7030 |
return firstPage;
|
| 7031 |
}
|
| 7032 |
function fullSearchEscape(value) {
|
|
@@ -7213,21 +7104,7 @@ function fullSearchSnippet(text, index, length) {
|
|
| 7213 |
suffix: end < text.length ? "…" : ""
|
| 7214 |
};
|
| 7215 |
}
|
| 7216 |
-
|
| 7217 |
-
const node = document.createElement("span");
|
| 7218 |
-
node.className = "full-search-snippet";
|
| 7219 |
-
if (snippet.prefix) node.append(snippet.prefix);
|
| 7220 |
-
node.append(snippet.text.slice(0, snippet.matchStart));
|
| 7221 |
-
const mark = document.createElement("mark");
|
| 7222 |
-
mark.className = "search-match";
|
| 7223 |
-
mark.textContent = snippet.text.slice(
|
| 7224 |
-
snippet.matchStart,
|
| 7225 |
-
snippet.matchStart + snippet.matchLength
|
| 7226 |
-
);
|
| 7227 |
-
node.append(mark, snippet.text.slice(snippet.matchStart + snippet.matchLength));
|
| 7228 |
-
if (snippet.suffix) node.append(snippet.suffix);
|
| 7229 |
-
return node;
|
| 7230 |
-
}
|
| 7231 |
function createFullSearchHeadingLookup(root, offsets) {
|
| 7232 |
const entries = navigationState.tocEntries, boundaries = [];
|
| 7233 |
const headings = entries.filter(entry => entry.target);
|
|
@@ -7389,9 +7266,9 @@ async function fullSearchDomMatches(root, fallback, query, generation, progressi
|
|
| 7389 |
groups.push(group);
|
| 7390 |
total += group.count;
|
| 7391 |
if (progressive && (start === 0 || (group.count && total <= 50) || groups.length % 8 === 0)) {
|
| 7392 |
-
|
| 7393 |
-
|
| 7394 |
-
updateSearchState({ results:
|
| 7395 |
renderFullSearchResults();
|
| 7396 |
fullSearchStatus.textContent = `${total} 个结果(正在搜索…)`;
|
| 7397 |
}
|
|
@@ -7399,8 +7276,8 @@ async function fullSearchDomMatches(root, fallback, query, generation, progressi
|
|
| 7399 |
if (!isReaderGenerationCurrent("search", generation)) return [];
|
| 7400 |
}
|
| 7401 |
if (progressive) {
|
| 7402 |
-
|
| 7403 |
-
|
| 7404 |
}
|
| 7405 |
if (occurrence !== null) {
|
| 7406 |
if (!isReaderGenerationCurrent("navigation", navigationGeneration)) return [];
|
|
@@ -7494,8 +7371,7 @@ async function navigateFoliateSearchResult(result, generation) {
|
|
| 7494 |
foliateScrollAnchors.remember();
|
| 7495 |
return true;
|
| 7496 |
}
|
| 7497 |
-
|
| 7498 |
-
let pdfSearchInProgress = false;
|
| 7499 |
async function ensurePdfBookSearchClient(manifest) {
|
| 7500 |
let textUrl = "";
|
| 7501 |
let partitioned = null;
|
|
@@ -7590,19 +7466,19 @@ async function searchConcentratedPdf(query, generation) {
|
|
| 7590 |
if (page.offset === 0) firstPageResults = results;
|
| 7591 |
return { ...page, results };
|
| 7592 |
};
|
| 7593 |
-
|
| 7594 |
const page = await client.search(query, partial => {
|
| 7595 |
if (!isReaderGenerationCurrent("search", generation)) return;
|
| 7596 |
-
|
| 7597 |
-
updateSearchState({ results:
|
| 7598 |
renderFullSearchResults();
|
| 7599 |
fullSearchStatus.textContent = partial.total
|
| 7600 |
? `${partial.total} 个结果(正在搜索… ${partial.scanned} / ${partial.pages} 页)`
|
| 7601 |
: `正在搜索正文… ${partial.scanned} / ${partial.pages} 页`;
|
| 7602 |
});
|
| 7603 |
if (!isReaderGenerationCurrent("search", generation)) return [];
|
| 7604 |
-
|
| 7605 |
-
return
|
| 7606 |
}
|
| 7607 |
|
| 7608 |
function makePdfSearchPageLoader(groups, total, pattern, firstPage) {
|
|
@@ -7641,9 +7517,9 @@ function makePdfSearchPageLoader(groups, total, pattern, firstPage) {
|
|
| 7641 |
|
| 7642 |
function publishPdfSearchProgress(generation, groups, total, pattern, firstPage, scanned, pages) {
|
| 7643 |
if (!isReaderGenerationCurrent("search", generation)) return false;
|
| 7644 |
-
|
| 7645 |
-
|
| 7646 |
-
updateSearchState({ results:
|
| 7647 |
renderFullSearchResults();
|
| 7648 |
fullSearchStatus.textContent = total
|
| 7649 |
? `${total} 个结果(正在搜索… ${scanned} / ${pages} 页)`
|
|
@@ -7776,261 +7652,40 @@ async function fullSearchPdfMatches(query, generation) {
|
|
| 7776 |
}
|
| 7777 |
if (!hasText) throw new Error("此 PDF 尚无可搜索文字层,需要完成 OCR 后才能搜索正文");
|
| 7778 |
publishPdfSearchProgress(generation, groups, total, pattern, firstPage, pdf.numPages, pdf.numPages);
|
| 7779 |
-
return
|
| 7780 |
-
}
|
| 7781 |
-
let fullSearchRenderedResults = [];
|
| 7782 |
-
let fullSearchRenderedOffset = -1;
|
| 7783 |
-
function renderFullSearchResults() {
|
| 7784 |
-
updateChapterSearchPagination();
|
| 7785 |
-
fullSearchView.querySelectorAll(".full-search-nav button").forEach((button) => {
|
| 7786 |
-
button.disabled = !searchState.results.length;
|
| 7787 |
-
});
|
| 7788 |
-
const offset = chapterSearchPage?.offset || 0;
|
| 7789 |
-
if (fullSearchRenderedOffset === offset &&
|
| 7790 |
-
fullSearchResultsNode.childElementCount === searchState.results.length &&
|
| 7791 |
-
fullSearchRenderedResults.length === searchState.results.length &&
|
| 7792 |
-
searchState.results.every((result, index) => result === fullSearchRenderedResults[index])) return;
|
| 7793 |
-
const fragment = document.createDocumentFragment();
|
| 7794 |
-
for (const [index, result] of searchState.results.entries()) {
|
| 7795 |
-
const row = document.createElement("button"),
|
| 7796 |
-
location = document.createElement("small");
|
| 7797 |
-
row.type = "button";
|
| 7798 |
-
row.className = "full-search-result";
|
| 7799 |
-
location.className = "full-search-location";
|
| 7800 |
-
location.textContent = result.location;
|
| 7801 |
-
const rank = document.createElement("span");
|
| 7802 |
-
rank.className = "full-search-rank";
|
| 7803 |
-
rank.textContent = `${offset + index + 1}.`;
|
| 7804 |
-
row.append(rank, location, fullSearchSnippetDom(result.snippet));
|
| 7805 |
-
row.addEventListener("click", () => activateFullSearchResult(index));
|
| 7806 |
-
fragment.appendChild(row);
|
| 7807 |
-
}
|
| 7808 |
-
fullSearchResultsNode.replaceChildren(fragment);
|
| 7809 |
-
fullSearchRenderedResults = searchState.results.slice();
|
| 7810 |
-
fullSearchRenderedOffset = offset;
|
| 7811 |
-
}
|
| 7812 |
-
function closeFullSearchView() {
|
| 7813 |
-
clearTimeout(fullSearchInputTimer);
|
| 7814 |
-
fullSearchInputTimer = 0;
|
| 7815 |
-
if (pdfSearchInProgress || chapterSearchInProgress) fullSearchComplete = false;
|
| 7816 |
-
nextReaderGeneration("search");
|
| 7817 |
-
if (chapterSearchInProgress) chapterSearchClient?.cancel();
|
| 7818 |
-
if (pdfSearchInProgress) pdfBookSearchClient?.cancel();
|
| 7819 |
-
pdfSearchInProgress = false;
|
| 7820 |
-
chapterSearchInProgress = false;
|
| 7821 |
-
fullSearchCancel.hidden = true;
|
| 7822 |
-
fullSearchRetry.hidden = true;
|
| 7823 |
-
selectPanel(mediaElement ? "media" : "toc");
|
| 7824 |
-
updateFullSearchButton(false);
|
| 7825 |
-
}
|
| 7826 |
-
async function runFullSearch() {
|
| 7827 |
-
clearTimeout(fullSearchInputTimer);
|
| 7828 |
-
fullSearchInputTimer = 0;
|
| 7829 |
-
const query = fullSearchInput.value.trim();
|
| 7830 |
-
fullSearchComplete = false;
|
| 7831 |
-
failedSearchPage = null;
|
| 7832 |
-
const generation = nextReaderGeneration("search");
|
| 7833 |
-
pdfBookSearchClient?.cancel();
|
| 7834 |
-
beginReaderNavigation();
|
| 7835 |
-
clearFullSearchMarks();
|
| 7836 |
-
updateSearchState({ query, results: [], index: -1 });
|
| 7837 |
-
chapterSearchPage = null;
|
| 7838 |
-
pdfSearchPageLoader = null;
|
| 7839 |
-
updateChapterSearchPagination();
|
| 7840 |
-
fullSearchRetry.hidden = true;
|
| 7841 |
-
fullSearchRetry.textContent = "重试搜索";
|
| 7842 |
-
fullSearchCancel.hidden = !["epub-chapters", "pdf", "pdf-pages", "foliate"].includes(capability.mode) || !query;
|
| 7843 |
-
pdfSearchInProgress = ["pdf", "pdf-pages", "foliate"].includes(capability.mode) && !!query;
|
| 7844 |
-
chapterSearchInProgress = capability.mode === "epub-chapters" && !!query;
|
| 7845 |
-
fullSearchResultsNode.textContent = "";
|
| 7846 |
-
fullSearchView.querySelectorAll(".full-search-nav button").forEach((button) => {
|
| 7847 |
-
button.disabled = true;
|
| 7848 |
-
});
|
| 7849 |
-
if (!query) {
|
| 7850 |
-
chapterSearchClient?.cancel();
|
| 7851 |
-
fullSearchStatus.textContent = "输入关键词搜索正文";
|
| 7852 |
-
return;
|
| 7853 |
-
}
|
| 7854 |
-
fullSearchStatus.textContent = "正在搜索正文…";
|
| 7855 |
-
try {
|
| 7856 |
-
if ((capability.mode === "pdf" || pdfHybridUrl) && !ocrManifestUrl) {
|
| 7857 |
-
if (pdfHybridUrl) await pdfHybridDocumentPromise;
|
| 7858 |
-
await fullSearchPdfMatches(query, generation);
|
| 7859 |
-
}
|
| 7860 |
-
else if (["pdf", "pdf-pages"].includes(capability.mode))
|
| 7861 |
-
publishSearchResults(generation, await searchConcentratedPdf(query, generation));
|
| 7862 |
-
else if (capability.mode === "foliate")
|
| 7863 |
-
publishSearchResults(generation, await fullSearchFoliateMatches(query, generation));
|
| 7864 |
-
else if (capability.mode === "epub-chapters") await searchChapterBook(query, generation);
|
| 7865 |
-
else if (["text", "markdown", "docx"].includes(capability.mode))
|
| 7866 |
-
publishSearchResults(
|
| 7867 |
-
generation,
|
| 7868 |
-
await fullSearchDomMatches(content, "阅读位置", query, generation, true)
|
| 7869 |
-
);
|
| 7870 |
-
else if (capability.mode === "html")
|
| 7871 |
-
publishSearchResults(
|
| 7872 |
-
generation,
|
| 7873 |
-
htmlFrame?.contentDocument?.body
|
| 7874 |
-
? await fullSearchDomMatches(
|
| 7875 |
-
htmlFrame.contentDocument.body,
|
| 7876 |
-
"HTML 阅读位置",
|
| 7877 |
-
query,
|
| 7878 |
-
generation,
|
| 7879 |
-
true
|
| 7880 |
-
)
|
| 7881 |
-
: []
|
| 7882 |
-
);
|
| 7883 |
-
else throw new Error("此格式没有可搜索文本");
|
| 7884 |
-
if (!isReaderGenerationCurrent("search", generation)) return;
|
| 7885 |
-
fullSearchComplete = true;
|
| 7886 |
-
pdfSearchInProgress = false;
|
| 7887 |
-
chapterSearchInProgress = false;
|
| 7888 |
-
renderFullSearchResults();
|
| 7889 |
-
fullSearchStatus.textContent = chapterSearchPage
|
| 7890 |
-
? chapterSearchPage.total
|
| 7891 |
-
? `${chapterSearchPage.total} 个结果`
|
| 7892 |
-
: "未找到正文匹配"
|
| 7893 |
-
: searchState.results.length
|
| 7894 |
-
? `${searchState.results.length}${searchState.results.length === 100 ? "+" : ""} 个结果`
|
| 7895 |
-
: "未找到正文匹配";
|
| 7896 |
-
} catch (error) {
|
| 7897 |
-
if (isReaderGenerationCurrent("search", generation)) {
|
| 7898 |
-
pdfSearchInProgress = false;
|
| 7899 |
-
chapterSearchInProgress = false;
|
| 7900 |
-
fullSearchStatus.textContent = error.message || "正文搜索不可用";
|
| 7901 |
-
fullSearchRetry.hidden = !["epub-chapters", "pdf", "pdf-pages", "foliate"].includes(capability.mode);
|
| 7902 |
-
}
|
| 7903 |
-
} finally {
|
| 7904 |
-
if (isReaderGenerationCurrent("search", generation))
|
| 7905 |
-
pdfSearchInProgress = false;
|
| 7906 |
-
if (isReaderGenerationCurrent("search", generation)) {
|
| 7907 |
-
chapterSearchInProgress = false;
|
| 7908 |
-
updateChapterSearchPagination();
|
| 7909 |
-
}
|
| 7910 |
-
if (isReaderGenerationCurrent("search", generation)) fullSearchCancel.hidden = true;
|
| 7911 |
-
if (isReaderGenerationCurrent("search", generation))
|
| 7912 |
-
fullSearchView.querySelectorAll(".full-search-nav button").forEach((button) => {
|
| 7913 |
-
button.disabled = !searchState.results.length;
|
| 7914 |
-
});
|
| 7915 |
-
}
|
| 7916 |
}
|
| 7917 |
-
|
| 7918 |
-
|
| 7919 |
-
if (!
|
| 7920 |
-
|
| 7921 |
-
|
| 7922 |
-
|
| 7923 |
-
|
| 7924 |
-
|
| 7925 |
-
|
| 7926 |
-
|
| 7927 |
-
|
| 7928 |
-
|
| 7929 |
-
|
| 7930 |
-
|
| 7931 |
-
|
| 7932 |
-
fullSearchStatus.textContent = "搜索结果定位失败,请重试";
|
| 7933 |
-
return;
|
| 7934 |
-
}
|
| 7935 |
-
if (
|
| 7936 |
-
activated === false ||
|
| 7937 |
-
!isReaderGenerationCurrent("navigation", generation) ||
|
| 7938 |
-
searchState.results[index] !== result
|
| 7939 |
-
)
|
| 7940 |
-
return;
|
| 7941 |
-
if (fullSearchStatus.textContent === "搜索结果定位失败,请重试")
|
| 7942 |
-
fullSearchStatus.textContent = `${chapterSearchPage?.total ?? searchState.results.length} 个结果`;
|
| 7943 |
-
if (closePanel && panelSerial === readerPanelInteractionSerial &&
|
| 7944 |
-
isReaderGenerationCurrent("search", searchGeneration))
|
| 7945 |
-
setReaderPanelOpen(false, true);
|
| 7946 |
-
else if (!closePanel) fullSearchResultsNode.children[index]?.scrollIntoView({ block: "nearest" });
|
| 7947 |
-
} catch (error) {
|
| 7948 |
-
if (error?.name !== "AbortError" && isReaderGenerationCurrent("navigation", generation) &&
|
| 7949 |
-
isReaderGenerationCurrent("search", searchGeneration) && searchState.results[index] === result) {
|
| 7950 |
-
updateSearchState({ index: previousIndex });
|
| 7951 |
-
fullSearchStatus.textContent = capability.mode === "epub-chapters"
|
| 7952 |
-
? error.message || "搜索结果定位失败,请重试"
|
| 7953 |
-
: "搜索结果定位失败,请重试";
|
| 7954 |
-
}
|
| 7955 |
-
reportNavigationError(error, generation);
|
| 7956 |
-
}
|
| 7957 |
-
}
|
| 7958 |
-
function moveFullSearch(step) {
|
| 7959 |
-
if (chapterSearchPage?.total) {
|
| 7960 |
-
const page = chapterSearchPage;
|
| 7961 |
-
const next = searchState.index < 0 ? (step > 0 ? 0 : -1) : searchState.index + step;
|
| 7962 |
-
if (next < 0)
|
| 7963 |
-
return loadChapterSearchPage(
|
| 7964 |
-
page.offset
|
| 7965 |
-
? page.offset - page.pageSize
|
| 7966 |
-
: Math.floor((page.total - 1) / page.pageSize) * page.pageSize,
|
| 7967 |
-
true
|
| 7968 |
-
);
|
| 7969 |
-
if (next >= searchState.results.length)
|
| 7970 |
-
return loadChapterSearchPage(
|
| 7971 |
-
page.offset + page.pageSize < page.total ? page.offset + page.pageSize : 0,
|
| 7972 |
-
false
|
| 7973 |
-
);
|
| 7974 |
-
return activateFullSearchResult(next, false);
|
| 7975 |
-
}
|
| 7976 |
-
if (searchState.results.length)
|
| 7977 |
-
return activateFullSearchResult(
|
| 7978 |
-
(searchState.index + step + searchState.results.length) % searchState.results.length,
|
| 7979 |
-
false
|
| 7980 |
);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7981 |
}
|
| 7982 |
-
|
| 7983 |
-
const panel = document.querySelector("#history-panel");
|
| 7984 |
-
if (panel.classList.contains("is-open") && !fullSearchView.hidden) {
|
| 7985 |
-
closeFullSearchView();
|
| 7986 |
-
return;
|
| 7987 |
-
}
|
| 7988 |
-
setReaderPanelOpen(true);
|
| 7989 |
-
selectPanel("full-search");
|
| 7990 |
-
updateFullSearchButton(true);
|
| 7991 |
-
updateFullSearchClear();
|
| 7992 |
-
fullSearchInput.focus();
|
| 7993 |
-
if (searchState.query !== fullSearchInput.value.trim() || !fullSearchComplete) runFullSearch();
|
| 7994 |
-
else if (failedSearchPage) fullSearchRetry.hidden = false;
|
| 7995 |
-
}
|
| 7996 |
-
fullSearchButton.addEventListener("click", toggleFullSearch);
|
| 7997 |
-
function queueFullSearch() {
|
| 7998 |
-
clearTimeout(fullSearchInputTimer);
|
| 7999 |
-
nextReaderGeneration("search");
|
| 8000 |
-
beginReaderNavigation();
|
| 8001 |
-
chapterSearchClient?.cancel();
|
| 8002 |
-
pdfBookSearchClient?.cancel();
|
| 8003 |
-
fullSearchComplete = false;
|
| 8004 |
-
pdfSearchInProgress = false;
|
| 8005 |
-
chapterSearchInProgress = false;
|
| 8006 |
-
chapterSearchPage = null;
|
| 8007 |
-
failedSearchPage = null;
|
| 8008 |
-
pdfSearchPageLoader = null;
|
| 8009 |
-
updateSearchState({ results: [], index: -1 });
|
| 8010 |
-
renderFullSearchResults();
|
| 8011 |
-
fullSearchCancel.hidden = true;
|
| 8012 |
-
fullSearchRetry.hidden = true;
|
| 8013 |
-
fullSearchStatus.textContent = fullSearchInput.value.trim() ? "正在搜索正文…" : "输入关键词搜索正文";
|
| 8014 |
-
fullSearchInputTimer = setTimeout(runFullSearch, 180);
|
| 8015 |
-
}
|
| 8016 |
-
fullSearchInput.addEventListener("input", (event) => {
|
| 8017 |
-
updateFullSearchClear();
|
| 8018 |
-
if (!event.isComposing) queueFullSearch();
|
| 8019 |
-
});
|
| 8020 |
-
fullSearchInput.addEventListener("focus", () => { readerPanelInteractionSerial++; });
|
| 8021 |
-
fullSearchInput.addEventListener("compositionend", queueFullSearch);
|
| 8022 |
-
fullSearchView.querySelector("#full-search-clear").addEventListener("click", () => {
|
| 8023 |
-
fullSearchInput.value = "";
|
| 8024 |
-
updateFullSearchClear();
|
| 8025 |
-
runFullSearch();
|
| 8026 |
-
fullSearchInput.focus();
|
| 8027 |
-
});
|
| 8028 |
-
fullSearchView
|
| 8029 |
-
.querySelector("#full-search-prev")
|
| 8030 |
-
.addEventListener("click", () => moveFullSearch(-1));
|
| 8031 |
-
fullSearchView
|
| 8032 |
-
.querySelector("#full-search-next")
|
| 8033 |
-
.addEventListener("click", () => moveFullSearch(1));
|
| 8034 |
function syncCapabilityControls() {
|
| 8035 |
content.dataset.mode = capability.mode || "unsupported";
|
| 8036 |
pageInvertToggle.hidden = !["pdf", "pdf-pages"].includes(capability.mode);
|
|
|
|
| 1 |
+
import { createReaderSearchController } from "./reader-search-controller.mjs";
|
| 2 |
import "/static/reader-resources.js";
|
| 3 |
import "/static/reader-contract.js";
|
| 4 |
import "/static/reader-request-manager.js";
|
|
|
|
| 470 |
let chapterSearchConfiguration = null;
|
| 471 |
let chapterSearchModulePromise = null;
|
| 472 |
let chapterSearchClient = null;
|
| 473 |
+
|
|
|
|
|
|
|
|
|
|
| 474 |
trackReaderResource(() => {
|
| 475 |
chapterSearchClient?.dispose();
|
| 476 |
chapterSearchClient = null;
|
|
|
|
| 5623 |
});
|
| 5624 |
mediaElement = media;
|
| 5625 |
let sourceRetried = false;
|
| 5626 |
+
const originalMode = VoiceOfMLReader.capability(resolvedReaderData?.original_extension || extension).mode;
|
| 5627 |
+
const retryUrl = originalMode === mode && validSource(downloadUrl) ? downloadUrl : sourceUrl;
|
| 5628 |
media.addEventListener("error", () => {
|
| 5629 |
if (readerAbortController.signal.aborted) return;
|
| 5630 |
+
if (!sourceRetried && retryUrl !== media.src && validSource(retryUrl)) {
|
| 5631 |
sourceRetried = true;
|
| 5632 |
media._readerSourceRetry = true;
|
| 5633 |
+
media.src = retryUrl;
|
| 5634 |
media.load();
|
| 5635 |
return;
|
| 5636 |
}
|
|
|
|
| 6725 |
chapterNext.addEventListener("click", () =>
|
| 6726 |
activateChapter(adjacentTocIndex(1))
|
| 6727 |
);
|
| 6728 |
+
const fullSearchController = createReaderSearchController({
|
| 6729 |
+
document, runtime: readerRuntime, signal: readerAbortController.signal,
|
| 6730 |
+
nextGeneration: () => nextReaderGeneration("search"),
|
| 6731 |
+
isCurrent: generation => isReaderGenerationCurrent("search", generation),
|
| 6732 |
+
updateSearch: updateSearchState,
|
| 6733 |
+
backend: {
|
| 6734 |
+
mode: () => capability.mode,
|
| 6735 |
+
search: searchReaderText,
|
| 6736 |
+
clearMarks: clearFullSearchMarks,
|
| 6737 |
+
chapterClient: () => chapterSearchClient,
|
| 6738 |
+
pdfClient: () => pdfBookSearchClient,
|
| 6739 |
+
chapterResult: result => ({ ...result,
|
| 6740 |
+
activate: generation => navigateChapterSearchResult(result, generation) }),
|
| 6741 |
+
activate: (result, generation) => result.cfi
|
| 6742 |
+
? navigateFoliateSearchResult(result, generation) : result.activate(generation)
|
| 6743 |
+
},
|
| 6744 |
+
navigation: {
|
| 6745 |
+
begin: beginReaderNavigation,
|
| 6746 |
+
current: generation => isReaderGenerationCurrent("navigation", generation),
|
| 6747 |
+
reportError: reportNavigationError
|
| 6748 |
+
},
|
| 6749 |
+
panel: {
|
| 6750 |
+
isOpen: () => document.querySelector("#history-panel").classList.contains("is-open"),
|
| 6751 |
+
serial: () => readerPanelInteractionSerial,
|
| 6752 |
+
show: () => { setReaderPanelOpen(true); selectPanel("full-search"); },
|
| 6753 |
+
hide: () => selectPanel(mediaElement ? "media" : "toc"),
|
| 6754 |
+
close: () => setReaderPanelOpen(false, true),
|
| 6755 |
+
focus: () => { readerPanelInteractionSerial++; }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6756 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6757 |
});
|
| 6758 |
+
trackReaderResource(fullSearchController.dispose);
|
| 6759 |
+
const fullSearchSession = fullSearchController.state;
|
| 6760 |
+
const {
|
| 6761 |
+
render: renderFullSearchResults, applyChapterPage: applyChapterSearchPage,
|
| 6762 |
+
run: runFullSearch, close: closeFullSearchView
|
| 6763 |
+
} = fullSearchController;
|
| 6764 |
+
const {
|
| 6765 |
+
view: fullSearchView, button: fullSearchButton, input: fullSearchInput,
|
| 6766 |
+
status: fullSearchStatus
|
| 6767 |
+
} = fullSearchController.elements;
|
| 6768 |
+
// Format-specific search backends publish through the shared controller.
|
| 6769 |
function fullSearchPageOffset(total, requestedOffset, pageSize = 50) {
|
| 6770 |
return Math.max(0, Math.min(
|
| 6771 |
Math.floor(Math.max(0, total - 1) / pageSize),
|
|
|
|
| 6800 |
});
|
| 6801 |
if (isReaderGenerationCurrent("search", generation)) applyChapterSearchPage(page);
|
| 6802 |
}
|
| 6803 |
+
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6804 |
async function navigateChapterSearchResult(result, generation) {
|
| 6805 |
const article = await chapterManifestLoader?.(result.chapterIndex);
|
| 6806 |
if (!article || !isReaderGenerationCurrent("navigation", generation)) return false;
|
|
|
|
| 6845 |
scheduleSave();
|
| 6846 |
return true;
|
| 6847 |
}
|
| 6848 |
+
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6849 |
async function fullSearchFoliateMatches(query, generation) {
|
| 6850 |
if (!epubRendition?.search) return [];
|
| 6851 |
const groups = [], firstPage = [];
|
|
|
|
| 6867 |
}
|
| 6868 |
};
|
| 6869 |
};
|
| 6870 |
+
fullSearchSession.pageLoader = async (requestedOffset, pageGeneration = readerRuntime.currentGeneration("search")) => {
|
| 6871 |
const pageSize = 50;
|
| 6872 |
const offset = fullSearchPageOffset(total, requestedOffset, pageSize);
|
| 6873 |
if (!offset) return { total, offset, pageSize, results: firstPage.slice() };
|
|
|
|
| 6910 |
firstPage.push(toResult(item, group.label, index));
|
| 6911 |
}
|
| 6912 |
total += group.count;
|
| 6913 |
+
fullSearchSession.page = { total, offset: 0, pageSize: 50, results: firstPage.slice() };
|
| 6914 |
+
updateSearchState({ results: fullSearchSession.page.results, index: -1 });
|
| 6915 |
renderFullSearchResults();
|
| 6916 |
fullSearchStatus.textContent = `${total} 个结果(正在搜索…)`;
|
| 6917 |
await waitForReader();
|
| 6918 |
if (!isReaderGenerationCurrent("search", generation)) return [];
|
| 6919 |
}
|
| 6920 |
+
fullSearchSession.page = { total, offset: 0, pageSize: 50, results: firstPage.slice() };
|
| 6921 |
return firstPage;
|
| 6922 |
}
|
| 6923 |
function fullSearchEscape(value) {
|
|
|
|
| 7104 |
suffix: end < text.length ? "…" : ""
|
| 7105 |
};
|
| 7106 |
}
|
| 7107 |
+
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7108 |
function createFullSearchHeadingLookup(root, offsets) {
|
| 7109 |
const entries = navigationState.tocEntries, boundaries = [];
|
| 7110 |
const headings = entries.filter(entry => entry.target);
|
|
|
|
| 7266 |
groups.push(group);
|
| 7267 |
total += group.count;
|
| 7268 |
if (progressive && (start === 0 || (group.count && total <= 50) || groups.length % 8 === 0)) {
|
| 7269 |
+
fullSearchSession.pageLoader = page;
|
| 7270 |
+
fullSearchSession.page = page(0);
|
| 7271 |
+
updateSearchState({ results: fullSearchSession.page.results, index: -1 });
|
| 7272 |
renderFullSearchResults();
|
| 7273 |
fullSearchStatus.textContent = `${total} 个结果(正在搜索…)`;
|
| 7274 |
}
|
|
|
|
| 7276 |
if (!isReaderGenerationCurrent("search", generation)) return [];
|
| 7277 |
}
|
| 7278 |
if (progressive) {
|
| 7279 |
+
fullSearchSession.pageLoader = page;
|
| 7280 |
+
fullSearchSession.page = page(0);
|
| 7281 |
}
|
| 7282 |
if (occurrence !== null) {
|
| 7283 |
if (!isReaderGenerationCurrent("navigation", navigationGeneration)) return [];
|
|
|
|
| 7371 |
foliateScrollAnchors.remember();
|
| 7372 |
return true;
|
| 7373 |
}
|
| 7374 |
+
|
|
|
|
| 7375 |
async function ensurePdfBookSearchClient(manifest) {
|
| 7376 |
let textUrl = "";
|
| 7377 |
let partitioned = null;
|
|
|
|
| 7466 |
if (page.offset === 0) firstPageResults = results;
|
| 7467 |
return { ...page, results };
|
| 7468 |
};
|
| 7469 |
+
fullSearchSession.pageLoader = async offset => attach(await client.page(offset));
|
| 7470 |
const page = await client.search(query, partial => {
|
| 7471 |
if (!isReaderGenerationCurrent("search", generation)) return;
|
| 7472 |
+
fullSearchSession.page = attach(partial);
|
| 7473 |
+
updateSearchState({ results: fullSearchSession.page.results, index: -1 });
|
| 7474 |
renderFullSearchResults();
|
| 7475 |
fullSearchStatus.textContent = partial.total
|
| 7476 |
? `${partial.total} 个结果(正在搜索… ${partial.scanned} / ${partial.pages} 页)`
|
| 7477 |
: `正在搜索正文… ${partial.scanned} / ${partial.pages} 页`;
|
| 7478 |
});
|
| 7479 |
if (!isReaderGenerationCurrent("search", generation)) return [];
|
| 7480 |
+
fullSearchSession.page = attach(page);
|
| 7481 |
+
return fullSearchSession.page.results;
|
| 7482 |
}
|
| 7483 |
|
| 7484 |
function makePdfSearchPageLoader(groups, total, pattern, firstPage) {
|
|
|
|
| 7517 |
|
| 7518 |
function publishPdfSearchProgress(generation, groups, total, pattern, firstPage, scanned, pages) {
|
| 7519 |
if (!isReaderGenerationCurrent("search", generation)) return false;
|
| 7520 |
+
fullSearchSession.pageLoader = makePdfSearchPageLoader(groups, total, pattern, firstPage);
|
| 7521 |
+
fullSearchSession.page = { total, offset: 0, pageSize: 50, results: firstPage.slice() };
|
| 7522 |
+
updateSearchState({ results: fullSearchSession.page.results, index: -1 });
|
| 7523 |
renderFullSearchResults();
|
| 7524 |
fullSearchStatus.textContent = total
|
| 7525 |
? `${total} 个结果(正在搜索… ${scanned} / ${pages} 页)`
|
|
|
|
| 7652 |
}
|
| 7653 |
if (!hasText) throw new Error("此 PDF 尚无可搜索文字层,需要完成 OCR 后才能搜索正文");
|
| 7654 |
publishPdfSearchProgress(generation, groups, total, pattern, firstPage, pdf.numPages, pdf.numPages);
|
| 7655 |
+
return fullSearchSession.page.results;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7656 |
}
|
| 7657 |
+
|
| 7658 |
+
async function searchReaderText(query, generation) {
|
| 7659 |
+
if ((capability.mode === "pdf" || pdfHybridUrl) && !ocrManifestUrl) {
|
| 7660 |
+
if (pdfHybridUrl) await pdfHybridDocumentPromise;
|
| 7661 |
+
await fullSearchPdfMatches(query, generation);
|
| 7662 |
+
}
|
| 7663 |
+
else if (["pdf", "pdf-pages"].includes(capability.mode))
|
| 7664 |
+
publishSearchResults(generation, await searchConcentratedPdf(query, generation));
|
| 7665 |
+
else if (capability.mode === "foliate")
|
| 7666 |
+
publishSearchResults(generation, await fullSearchFoliateMatches(query, generation));
|
| 7667 |
+
else if (capability.mode === "epub-chapters") await searchChapterBook(query, generation);
|
| 7668 |
+
else if (["text", "markdown", "docx"].includes(capability.mode))
|
| 7669 |
+
publishSearchResults(
|
| 7670 |
+
generation,
|
| 7671 |
+
await fullSearchDomMatches(content, "阅读位置", query, generation, true)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7672 |
);
|
| 7673 |
+
else if (capability.mode === "html")
|
| 7674 |
+
publishSearchResults(
|
| 7675 |
+
generation,
|
| 7676 |
+
htmlFrame?.contentDocument?.body
|
| 7677 |
+
? await fullSearchDomMatches(
|
| 7678 |
+
htmlFrame.contentDocument.body,
|
| 7679 |
+
"HTML 阅读位置",
|
| 7680 |
+
query,
|
| 7681 |
+
generation,
|
| 7682 |
+
true
|
| 7683 |
+
)
|
| 7684 |
+
: []
|
| 7685 |
+
);
|
| 7686 |
+
else throw new Error("此格式没有可搜索文本");
|
| 7687 |
}
|
| 7688 |
+
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 7689 |
function syncCapabilityControls() {
|
| 7690 |
content.dataset.mode = capability.mode || "unsupported";
|
| 7691 |
pageInvertToggle.hidden = !["pdf", "pdf-pages"].includes(capability.mode);
|
tests/test_async_proxy.py
CHANGED
|
@@ -403,21 +403,103 @@ class AsyncProxyTests(unittest.IsolatedAsyncioTestCase):
|
|
| 403 |
whole_read.assert_not_awaited()
|
| 404 |
self.assertEqual(response.status_code, status)
|
| 405 |
self.assertEqual(response.headers["content-type"], mime)
|
|
|
|
| 406 |
self.assertEqual(session.urls[0][1]["headers"], {"Accept-Encoding": "identity", **forwarded})
|
| 407 |
if method != "HEAD":
|
| 408 |
self.assertEqual([chunk async for chunk in response.body_iterator], [b"ab"])
|
| 409 |
if forwarded:
|
| 410 |
cached = await app.api_reader_bucket_resource(path, SimpleNamespace(method=method, headers=forwarded))
|
| 411 |
self.assertEqual(cached.body, b"ab")
|
|
|
|
| 412 |
self.assertEqual(len(session.urls), 1)
|
| 413 |
self.assertTrue(upstream.released)
|
| 414 |
self.assertEqual(semaphore._value, 1)
|
| 415 |
|
| 416 |
-
async def
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 417 |
path = f"media/video/native/{'a' * 64}/video.mp4"
|
| 418 |
for status, headers in (
|
| 419 |
-
(
|
| 420 |
-
(206, {"Content-Length": "2", "Content-Range":
|
|
|
|
| 421 |
):
|
| 422 |
upstream = FakeResponse(status, headers)
|
| 423 |
app.app.state.http_session = FakeSession([upstream])
|
|
@@ -425,7 +507,7 @@ class AsyncProxyTests(unittest.IsolatedAsyncioTestCase):
|
|
| 425 |
app.app.state.reader_range_cache = OrderedDict()
|
| 426 |
app.app.state.reader_range_cache_bytes = 0
|
| 427 |
response = await app.api_reader_bucket_resource(path, SimpleNamespace(method="GET", headers={"Range": "bytes=0-1"}))
|
| 428 |
-
self.assertEqual(response.status_code,
|
| 429 |
self.assertTrue(upstream.released)
|
| 430 |
self.assertEqual(semaphore._value, 1)
|
| 431 |
with patch.object(app, "open_download_response", AsyncMock(side_effect=asyncio.CancelledError)):
|
|
|
|
| 403 |
whole_read.assert_not_awaited()
|
| 404 |
self.assertEqual(response.status_code, status)
|
| 405 |
self.assertEqual(response.headers["content-type"], mime)
|
| 406 |
+
self.assertNotIn("content-encoding", response.headers)
|
| 407 |
self.assertEqual(session.urls[0][1]["headers"], {"Accept-Encoding": "identity", **forwarded})
|
| 408 |
if method != "HEAD":
|
| 409 |
self.assertEqual([chunk async for chunk in response.body_iterator], [b"ab"])
|
| 410 |
if forwarded:
|
| 411 |
cached = await app.api_reader_bucket_resource(path, SimpleNamespace(method=method, headers=forwarded))
|
| 412 |
self.assertEqual(cached.body, b"ab")
|
| 413 |
+
self.assertNotIn("content-encoding", cached.headers)
|
| 414 |
self.assertEqual(len(session.urls), 1)
|
| 415 |
self.assertTrue(upstream.released)
|
| 416 |
self.assertEqual(semaphore._value, 1)
|
| 417 |
|
| 418 |
+
async def test_media_remains_unencoded_through_real_gzip_middleware_and_range_cache(self):
|
| 419 |
+
path = f"media/audio/native/{'a' * 64}/audio.mp3"
|
| 420 |
+
payload = b"media" * 400
|
| 421 |
+
for status in (200, 206):
|
| 422 |
+
forwarded = {"Range": f"bytes=0-{len(payload) - 1}"} if status == 206 else {}
|
| 423 |
+
headers = {"Content-Length": str(len(payload)), "Content-Encoding": "identity"}
|
| 424 |
+
if status == 206:
|
| 425 |
+
headers["Content-Range"] = f"bytes 0-{len(payload) - 1}/{len(payload) * 2}"
|
| 426 |
+
upstream = FakeResponse(status, headers, chunks=(payload,))
|
| 427 |
+
app.app.state.http_session = FakeSession([upstream])
|
| 428 |
+
app.app.state.reader_range_cache = OrderedDict()
|
| 429 |
+
app.app.state.reader_range_cache_bytes = 0
|
| 430 |
+
for cached in (False, True) if status == 206 else (False,):
|
| 431 |
+
with self.subTest(status=status, cached=cached):
|
| 432 |
+
response = await app.api_reader_bucket_resource(path, SimpleNamespace(method="GET", headers=forwarded))
|
| 433 |
+
messages = []
|
| 434 |
+
async def serve(scope, receive, send):
|
| 435 |
+
await response(scope, receive, send)
|
| 436 |
+
async def receive():
|
| 437 |
+
return {"type": "http.request", "body": b"", "more_body": False}
|
| 438 |
+
async def send(message):
|
| 439 |
+
messages.append(message)
|
| 440 |
+
await app.GZipMiddleware(serve, minimum_size=1)(
|
| 441 |
+
{"type": "http", "method": "GET", "asgi": {"spec_version": "2.4"},
|
| 442 |
+
"headers": [(b"accept-encoding", b"gzip")]}, receive, send)
|
| 443 |
+
start = messages[0]
|
| 444 |
+
self.assertEqual(start["status"], status)
|
| 445 |
+
self.assertNotIn(b"content-encoding", dict(start["headers"]))
|
| 446 |
+
self.assertEqual(dict(start["headers"])[b"content-length"], str(len(payload)).encode())
|
| 447 |
+
self.assertEqual(b"".join(message.get("body", b"") for message in messages[1:]), payload)
|
| 448 |
+
self.assertTrue(upstream.released)
|
| 449 |
+
self.assertEqual(app.app.state.upstream_semaphore._value, 1)
|
| 450 |
+
|
| 451 |
+
async def test_large_media_streams_metadata_and_ranges_without_whole_file_limit(self):
|
| 452 |
+
total = app.READER_DOCUMENT_MAX_BYTES + 100
|
| 453 |
+
for kind, suffix in (("audio", "mp3"), ("video", "mp4")):
|
| 454 |
+
path = f"media/{kind}/native/{'a' * 64}/{kind}.{suffix}"
|
| 455 |
+
for method, forwarded, status, headers in (
|
| 456 |
+
("HEAD", {}, 200, {"Content-Length": str(total)}),
|
| 457 |
+
("GET", {"Range": "bytes=0-1"}, 206,
|
| 458 |
+
{"Content-Length": "2", "Content-Range": f"bytes 0-1/{total}"}),
|
| 459 |
+
("GET", {"Range": "bytes=-2"}, 206,
|
| 460 |
+
{"Content-Length": "2", "Content-Range": f"bytes {total - 2}-{total - 1}/{total}"}),
|
| 461 |
+
("GET", {"Range": f"bytes={total}-"}, 416,
|
| 462 |
+
{"Content-Length": "0", "Content-Range": f"bytes */{total}"}),
|
| 463 |
+
):
|
| 464 |
+
for source in ("bucket", "original"):
|
| 465 |
+
with self.subTest(kind=kind, method=method, range=forwarded, source=source):
|
| 466 |
+
upstream = FakeResponse(status, headers, chunks=() if status == 416 else (b"ab",))
|
| 467 |
+
session = app.app.state.http_session = FakeSession([upstream])
|
| 468 |
+
semaphore = app.app.state.upstream_semaphore = asyncio.Semaphore(1)
|
| 469 |
+
app.app.state.reader_range_cache = OrderedDict()
|
| 470 |
+
app.app.state.reader_range_cache_bytes = 0
|
| 471 |
+
request = SimpleNamespace(method=method, headers=forwarded)
|
| 472 |
+
if source == "bucket":
|
| 473 |
+
response = await app.api_reader_bucket_resource(path, request)
|
| 474 |
+
else:
|
| 475 |
+
url = f"https://huggingface.co/datasets/VoiceOfML/Test/resolve/main/large.{suffix}"
|
| 476 |
+
response = await app.api_reader_content(request, url=url, id=None)
|
| 477 |
+
self.assertEqual(response.status_code, status)
|
| 478 |
+
self.assertIsNone(session.urls[0][1]["timeout"].total)
|
| 479 |
+
self.assertEqual(session.urls[0][1]["timeout"].sock_read, 60)
|
| 480 |
+
if method != "HEAD":
|
| 481 |
+
self.assertEqual(b"".join([chunk async for chunk in response.body_iterator]),
|
| 482 |
+
b"" if status == 416 else b"ab")
|
| 483 |
+
self.assertTrue(upstream.released)
|
| 484 |
+
self.assertEqual(semaphore._value, 1)
|
| 485 |
+
|
| 486 |
+
async def test_media_unknown_length_stream_does_not_use_document_body_limit(self):
|
| 487 |
+
path = f"media/audio/native/{'a' * 64}/audio.mp3"
|
| 488 |
+
upstream = FakeResponse(200, chunks=(b"abc", b"def"))
|
| 489 |
+
app.app.state.http_session = FakeSession([upstream])
|
| 490 |
+
with patch.object(app, "READER_DOCUMENT_MAX_BYTES", 2), patch.object(app, "READER_RESOURCE_MAX_BYTES", 2):
|
| 491 |
+
response = await app.api_reader_bucket_resource(path, SimpleNamespace(method="GET", headers={}))
|
| 492 |
+
self.assertEqual(response.status_code, 200)
|
| 493 |
+
self.assertEqual([chunk async for chunk in response.body_iterator], [b"abc", b"def"])
|
| 494 |
+
self.assertTrue(upstream.released)
|
| 495 |
+
self.assertEqual(app.app.state.upstream_semaphore._value, 1)
|
| 496 |
+
|
| 497 |
+
async def test_bucket_media_preserves_range_validation_and_cancellation_cleanup(self):
|
| 498 |
path = f"media/video/native/{'a' * 64}/video.mp4"
|
| 499 |
for status, headers in (
|
| 500 |
+
(206, {"Content-Length": "2", "Content-Range": "invalid"}),
|
| 501 |
+
(206, {"Content-Length": "2", "Content-Range": "bytes 0-2/100"}),
|
| 502 |
+
(206, {"Content-Length": "2", "Content-Range": "bytes 99-100/100"}),
|
| 503 |
):
|
| 504 |
upstream = FakeResponse(status, headers)
|
| 505 |
app.app.state.http_session = FakeSession([upstream])
|
|
|
|
| 507 |
app.app.state.reader_range_cache = OrderedDict()
|
| 508 |
app.app.state.reader_range_cache_bytes = 0
|
| 509 |
response = await app.api_reader_bucket_resource(path, SimpleNamespace(method="GET", headers={"Range": "bytes=0-1"}))
|
| 510 |
+
self.assertEqual(response.status_code, 502)
|
| 511 |
self.assertTrue(upstream.released)
|
| 512 |
self.assertEqual(semaphore._value, 1)
|
| 513 |
with patch.object(app, "open_download_response", AsyncMock(side_effect=asyncio.CancelledError)):
|
tests/test_reader_architecture.js
CHANGED
|
@@ -5,6 +5,12 @@ const { assertCode, codePattern } = require('./reader_source_contract')
|
|
| 5 |
|
| 6 |
const root = path.resolve(__dirname, '..')
|
| 7 |
const reader = fs.readFileSync(path.join(root, 'static/reader.js'), 'utf8')
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
const css = fs.readFileSync(path.join(root, 'static/reader.css'), 'utf8')
|
| 9 |
const view = fs.readFileSync(path.join(root, 'static/foliate-reader/view.js'), 'utf8')
|
| 10 |
for (const marker of ["import('./epub.js')", "import('./mobi.js')", "import('./fb2.js')", "import('./pdf.js')", "isFBZ(file)", "isFB2(file)"]) assert.ok(view.includes(marker), marker)
|
|
@@ -121,7 +127,7 @@ const renderFoliateSource = reader.match(/async\s+function\s+renderFoliate\s*\([
|
|
| 121 |
assert.strictEqual(renderFoliateSource.includes('loaded = new Set'), false)
|
| 122 |
assert.strictEqual(renderFoliateSource.includes('pending = new Map'), false)
|
| 123 |
assert.match(reader, /stream\.insertBefore\(article, next \|\| null\)/)
|
| 124 |
-
assert.match(reader, /foliateSectionLoader
|
| 125 |
assert.doesNotMatch(reader, /^let navigationGeneration\b/m)
|
| 126 |
assert.doesNotMatch(reader, /syncReaderState/)
|
| 127 |
assert.doesNotMatch(reader, /^(?:let|var|const)\s+title\b/m)
|
|
@@ -129,7 +135,8 @@ for (const mirror of ['zoom', 'currentPage', 'pageCount', 'restoredEntry', 'rest
|
|
| 129 |
assert.doesNotMatch(reader, /readerRuntime\.updateFormat\(/)
|
| 130 |
for (const domain of ['document', 'navigation', 'search', 'panel']) assert.match(reader, new RegExp(`readerRuntime\\.state\\.${domain}`))
|
| 131 |
assert.match(reader, /readerRuntime\.currentGeneration\("pdf"\)/)
|
| 132 |
-
|
|
|
|
| 133 |
assert.match(reader, /isReaderGenerationCurrent\("navigation", generation\)/)
|
| 134 |
assert.match(reader, /registerReaderFormatAdapters\(\)/)
|
| 135 |
assert.match(reader, /function disposeFormatResources\(mode\)/)
|
|
|
|
| 5 |
|
| 6 |
const root = path.resolve(__dirname, '..')
|
| 7 |
const reader = fs.readFileSync(path.join(root, 'static/reader.js'), 'utf8')
|
| 8 |
+
const searchController = fs.readFileSync(path.join(root, 'static/reader-search-controller.mjs'), 'utf8')
|
| 9 |
+
assertCode(reader, 'import { createReaderSearchController } from "./reader-search-controller.mjs"')
|
| 10 |
+
assertCode(reader, 'trackReaderResource(fullSearchController.dispose)')
|
| 11 |
+
assertCode(reader, 'backend: { mode: () => capability.mode, search: searchReaderText')
|
| 12 |
+
assert.doesNotMatch(reader, /dataset\.panelView\s*=\s*"full-search"/)
|
| 13 |
+
assert.doesNotMatch(searchController, /new Worker|\bfetch\(|import\(/)
|
| 14 |
const css = fs.readFileSync(path.join(root, 'static/reader.css'), 'utf8')
|
| 15 |
const view = fs.readFileSync(path.join(root, 'static/foliate-reader/view.js'), 'utf8')
|
| 16 |
for (const marker of ["import('./epub.js')", "import('./mobi.js')", "import('./fb2.js')", "import('./pdf.js')", "isFBZ(file)", "isFB2(file)"]) assert.ok(view.includes(marker), marker)
|
|
|
|
| 127 |
assert.strictEqual(renderFoliateSource.includes('loaded = new Set'), false)
|
| 128 |
assert.strictEqual(renderFoliateSource.includes('pending = new Map'), false)
|
| 129 |
assert.match(reader, /stream\.insertBefore\(article, next \|\| null\)/)
|
| 130 |
+
assert.match(reader, /foliateSectionLoader\s*=\s*(?:\(index\)|index)\s*=>/)
|
| 131 |
assert.doesNotMatch(reader, /^let navigationGeneration\b/m)
|
| 132 |
assert.doesNotMatch(reader, /syncReaderState/)
|
| 133 |
assert.doesNotMatch(reader, /^(?:let|var|const)\s+title\b/m)
|
|
|
|
| 135 |
assert.doesNotMatch(reader, /readerRuntime\.updateFormat\(/)
|
| 136 |
for (const domain of ['document', 'navigation', 'search', 'panel']) assert.match(reader, new RegExp(`readerRuntime\\.state\\.${domain}`))
|
| 137 |
assert.match(reader, /readerRuntime\.currentGeneration\("pdf"\)/)
|
| 138 |
+
assertCode(reader, 'nextGeneration: () => nextReaderGeneration("search")')
|
| 139 |
+
assertCode(searchController, 'const generation = nextGeneration()')
|
| 140 |
assert.match(reader, /isReaderGenerationCurrent\("navigation", generation\)/)
|
| 141 |
assert.match(reader, /registerReaderFormatAdapters\(\)/)
|
| 142 |
assert.match(reader, /function disposeFormatResources\(mode\)/)
|
tests/test_reader_optimizations.py
CHANGED
|
@@ -58,15 +58,15 @@ class ReaderOptimizationTests(unittest.TestCase):
|
|
| 58 |
window.__pdfProbeTask = fullSearchPdfMatches('needle', generation);
|
| 59 |
window.__pdfProbeTask.catch(() => {});
|
| 60 |
};
|
| 61 |
-
window.__pdfProbePage = offset =>
|
| 62 |
-
window.__pdfProbeTotal = () =>
|
| 63 |
-
window.__cancelPdfProbe = () => {nextReaderGeneration('search');
|
| 64 |
window.__retryCancelledPageProbe = () => loadPdfTextContent(2, {
|
| 65 |
getTextContent:async()=>({items:[{str:'fresh demand'}],styles:{}})});
|
| 66 |
window.__query = async query => {
|
| 67 |
fullSearchInput.value = query;
|
| 68 |
await runFullSearch();
|
| 69 |
-
return
|
| 70 |
};
|
| 71 |
window.__goChapter = index => navigationState.tocEntries[index-1].activate(beginReaderNavigation());
|
| 72 |
window.__walkProbe = () => {
|
|
|
|
| 58 |
window.__pdfProbeTask = fullSearchPdfMatches('needle', generation);
|
| 59 |
window.__pdfProbeTask.catch(() => {});
|
| 60 |
};
|
| 61 |
+
window.__pdfProbePage = offset => fullSearchSession.pageLoader(offset);
|
| 62 |
+
window.__pdfProbeTotal = () => fullSearchSession.page.total;
|
| 63 |
+
window.__cancelPdfProbe = () => {nextReaderGeneration('search'); fullSearchSession.page = {total:123};};
|
| 64 |
window.__retryCancelledPageProbe = () => loadPdfTextContent(2, {
|
| 65 |
getTextContent:async()=>({items:[{str:'fresh demand'}],styles:{}})});
|
| 66 |
window.__query = async query => {
|
| 67 |
fullSearchInput.value = query;
|
| 68 |
await runFullSearch();
|
| 69 |
+
return fullSearchSession.page?.total || 0;
|
| 70 |
};
|
| 71 |
window.__goChapter = index => navigationState.tocEntries[index-1].activate(beginReaderNavigation());
|
| 72 |
window.__walkProbe = () => {
|
tests/test_reader_refactor.py
CHANGED
|
@@ -1515,7 +1515,14 @@ class ReaderRefactorTest(unittest.TestCase):
|
|
| 1515 |
self.page.wait_for_function("() => document.querySelector('#full-search-status').textContent === '1 个结果'")
|
| 1516 |
self.assertEqual(requests, [book_path] * 3)
|
| 1517 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1518 |
def test_media_proxy_failure_retries_original_once(self):
|
|
|
|
|
|
|
|
|
|
| 1519 |
buffer = io.BytesIO()
|
| 1520 |
with wave.open(buffer, 'wb') as audio:
|
| 1521 |
audio.setparams((1, 2, 8000, 8000, 'NONE', 'not compressed'))
|
|
@@ -1529,11 +1536,15 @@ class ReaderRefactorTest(unittest.TestCase):
|
|
| 1529 |
route.fulfill(status=206, content_type='audio/wav', body=data[start:],
|
| 1530 |
headers={'Accept-Ranges': 'bytes', 'Content-Range': f'bytes {start}-{len(data)-1}/{len(data)}'})
|
| 1531 |
self.page.route('https://huggingface.co/datasets/VoiceOfML/Test/resolve/main/refactor.wav', original)
|
| 1532 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1533 |
self.page.wait_for_function("() => document.querySelector('audio').readyState >= 1")
|
| 1534 |
self.assertEqual(len(requests), 1)
|
| 1535 |
self.assertEqual(self.page.locator('audio').evaluate('node => node.duration'), 1)
|
| 1536 |
-
self.page.evaluate("async () => { const media = document.querySelector('audio'); media.currentTime = 0.5; await VoiceOfMLReaderStore.put({url:
|
| 1537 |
self.page.reload()
|
| 1538 |
self.page.wait_for_function("() => document.documentElement.dataset.readerPhase === 'ready' && document.querySelector('audio').currentTime === 0.5")
|
| 1539 |
self.assertEqual(len(requests), 2)
|
|
@@ -2205,6 +2216,27 @@ class ReaderRefactorTest(unittest.TestCase):
|
|
| 2205 |
self.assertTrue(self.page.locator("#full-search-next").is_disabled())
|
| 2206 |
self.page.wait_for_function("() => document.querySelector('#full-search-status').textContent === '1 个结果'")
|
| 2207 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2208 |
def test_pending_text_result_cannot_highlight_after_new_search(self):
|
| 2209 |
def hold_activation(route):
|
| 2210 |
response = route.fetch()
|
|
|
|
| 1515 |
self.page.wait_for_function("() => document.querySelector('#full-search-status').textContent === '1 个结果'")
|
| 1516 |
self.assertEqual(requests, [book_path] * 3)
|
| 1517 |
|
| 1518 |
+
def test_bucket_media_failure_retries_download_original_once(self):
|
| 1519 |
+
self.page.route('**/api/reader-bucket-resource**', lambda route: route.fulfill(status=503, body='unavailable'))
|
| 1520 |
+
self.assert_media_failure_retries_original_once(bucket=True)
|
| 1521 |
+
|
| 1522 |
def test_media_proxy_failure_retries_original_once(self):
|
| 1523 |
+
self.assert_media_failure_retries_original_once()
|
| 1524 |
+
|
| 1525 |
+
def assert_media_failure_retries_original_once(self, bucket=False):
|
| 1526 |
buffer = io.BytesIO()
|
| 1527 |
with wave.open(buffer, 'wb') as audio:
|
| 1528 |
audio.setparams((1, 2, 8000, 8000, 'NONE', 'not compressed'))
|
|
|
|
| 1536 |
route.fulfill(status=206, content_type='audio/wav', body=data[start:],
|
| 1537 |
headers={'Accept-Ranges': 'bytes', 'Content-Range': f'bytes {start}-{len(data)-1}/{len(data)}'})
|
| 1538 |
self.page.route('https://huggingface.co/datasets/VoiceOfML/Test/resolve/main/refactor.wav', original)
|
| 1539 |
+
options = {}
|
| 1540 |
+
if bucket:
|
| 1541 |
+
options = dict(url=self.origin + '/api/reader-bucket-resource?path=media/audio/native/' + 'a' * 64 + '/audio.wav',
|
| 1542 |
+
download='https://huggingface.co/datasets/VoiceOfML/Test/resolve/main/refactor.wav')
|
| 1543 |
+
self.open(self.reader_url('wav', **options))
|
| 1544 |
self.page.wait_for_function("() => document.querySelector('audio').readyState >= 1")
|
| 1545 |
self.assertEqual(len(requests), 1)
|
| 1546 |
self.assertEqual(self.page.locator('audio').evaluate('node => node.duration'), 1)
|
| 1547 |
+
self.page.evaluate("async () => { const media = document.querySelector('audio'); media.currentTime = 0.5; await VoiceOfMLReaderStore.put({url: new URL(location.href).searchParams.get('url'), mediaTime: 0.5}); }")
|
| 1548 |
self.page.reload()
|
| 1549 |
self.page.wait_for_function("() => document.documentElement.dataset.readerPhase === 'ready' && document.querySelector('audio').currentTime === 0.5")
|
| 1550 |
self.assertEqual(len(requests), 2)
|
|
|
|
| 2216 |
self.assertTrue(self.page.locator("#full-search-next").is_disabled())
|
| 2217 |
self.page.wait_for_function("() => document.querySelector('#full-search-status').textContent === '1 个结果'")
|
| 2218 |
|
| 2219 |
+
def test_disposal_cancels_debounced_full_search(self):
|
| 2220 |
+
self.page.add_init_script("""const nativeTimeout = window.setTimeout.bind(window);
|
| 2221 |
+
window.__searchDebounceCallbacks = 0;
|
| 2222 |
+
window.setTimeout = (callback, delay, ...args) => nativeTimeout(
|
| 2223 |
+
delay === 180 ? (...values) => {
|
| 2224 |
+
window.__searchDebounceCallbacks++;
|
| 2225 |
+
callback(...values);
|
| 2226 |
+
} : callback, delay, ...args);""")
|
| 2227 |
+
self.serve("needle " * 100)
|
| 2228 |
+
self.open(self.reader_url())
|
| 2229 |
+
self.page.evaluate("""() => {
|
| 2230 |
+
const input = document.querySelector('#full-search-input');
|
| 2231 |
+
input.value = 'needle';
|
| 2232 |
+
input.dispatchEvent(new Event('input', {bubbles: true}));
|
| 2233 |
+
window.dispatchEvent(new Event('voice-reader-dispose'));
|
| 2234 |
+
}""")
|
| 2235 |
+
self.page.wait_for_timeout(300)
|
| 2236 |
+
self.assertEqual(self.page.locator('html').get_attribute('data-reader-phase'), 'disposed')
|
| 2237 |
+
self.assertEqual(self.page.evaluate('window.__searchDebounceCallbacks'), 0)
|
| 2238 |
+
self.assertEqual(self.page.locator('.full-search-result').count(), 0)
|
| 2239 |
+
|
| 2240 |
def test_pending_text_result_cannot_highlight_after_new_search(self):
|
| 2241 |
def hold_activation(route):
|
| 2242 |
response = route.fetch()
|
tests/test_reader_style.py
CHANGED
|
@@ -1,6 +1,7 @@
|
|
| 1 |
"""Focused browser checks for reader CSS, independent of format-engine suites."""
|
| 2 |
|
| 3 |
import io
|
|
|
|
| 4 |
import unittest
|
| 5 |
import urllib.parse
|
| 6 |
import zipfile
|
|
@@ -28,7 +29,7 @@ class ReaderStyleTests(unittest.TestCase):
|
|
| 28 |
cls.playwright = sync_playwright().start()
|
| 29 |
cls.addClassCleanup(cls.playwright.stop)
|
| 30 |
try:
|
| 31 |
-
cls.browser = cls.playwright.chromium.launch(headless=True)
|
| 32 |
except PlaywrightError as error:
|
| 33 |
raise unittest.SkipTest(str(error))
|
| 34 |
cls.addClassCleanup(cls.browser.close)
|
|
@@ -59,6 +60,49 @@ class ReaderStyleTests(unittest.TestCase):
|
|
| 59 |
!document.getAnimations().some(animation => animation.playState === 'running')
|
| 60 |
""")
|
| 61 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 62 |
def assert_contrast(self, selector):
|
| 63 |
result = self.page.locator(selector).first.evaluate("""element => {
|
| 64 |
const rgb = value => value.match(/[\\d.]+/g).map(Number);
|
|
|
|
| 1 |
"""Focused browser checks for reader CSS, independent of format-engine suites."""
|
| 2 |
|
| 3 |
import io
|
| 4 |
+
import os
|
| 5 |
import unittest
|
| 6 |
import urllib.parse
|
| 7 |
import zipfile
|
|
|
|
| 29 |
cls.playwright = sync_playwright().start()
|
| 30 |
cls.addClassCleanup(cls.playwright.stop)
|
| 31 |
try:
|
| 32 |
+
cls.browser = getattr(cls.playwright, os.environ.get("READER_STYLE_BROWSER", "chromium")).launch(headless=True)
|
| 33 |
except PlaywrightError as error:
|
| 34 |
raise unittest.SkipTest(str(error))
|
| 35 |
cls.addClassCleanup(cls.browser.close)
|
|
|
|
| 60 |
!document.getAnimations().some(animation => animation.playState === 'running')
|
| 61 |
""")
|
| 62 |
|
| 63 |
+
def test_back_and_download_icons_have_stable_browser_independent_geometry(self):
|
| 64 |
+
self.page.evaluate("""() => {
|
| 65 |
+
document.querySelector('.page-controls').hidden = false;
|
| 66 |
+
document.querySelector('.zoom-controls').hidden = false;
|
| 67 |
+
}""")
|
| 68 |
+
for theme in ("dark", "light"):
|
| 69 |
+
self.page.evaluate("theme => document.documentElement.dataset.theme = theme", theme)
|
| 70 |
+
for width in (1100, 390, 320):
|
| 71 |
+
self.page.set_viewport_size({"width": width, "height": 900})
|
| 72 |
+
for selector in ("#back", "#download"):
|
| 73 |
+
with self.subTest(theme=theme, width=width, selector=selector):
|
| 74 |
+
button = self.page.locator(selector)
|
| 75 |
+
geometry = button.evaluate("""button => {
|
| 76 |
+
const icon = button.querySelector('svg');
|
| 77 |
+
if (!icon) return null;
|
| 78 |
+
const b = button.getBoundingClientRect(), i = icon.getBoundingClientRect();
|
| 79 |
+
const toolbar = button.closest('header').getBoundingClientRect();
|
| 80 |
+
const style = getComputedStyle(button);
|
| 81 |
+
return {width: b.width, height: b.height, iconWidth: i.width, iconHeight: i.height,
|
| 82 |
+
dx: i.x + i.width / 2 - b.x - b.width / 2,
|
| 83 |
+
dy: i.y + i.height / 2 - b.y - b.height / 2,
|
| 84 |
+
fits: b.left >= 0 && b.right <= innerWidth && b.top >= toolbar.top && b.bottom <= toolbar.bottom,
|
| 85 |
+
padding: style.padding, appearance: style.appearance,
|
| 86 |
+
stroke: getComputedStyle(icon).stroke, color: style.color,
|
| 87 |
+
hiddenIcon: icon.getAttribute('aria-hidden'), focusable: icon.getAttribute('focusable')};
|
| 88 |
+
}""")
|
| 89 |
+
self.assertIsNotNone(geometry)
|
| 90 |
+
self.assertEqual((geometry["width"], geometry["height"]), (32, 32) if width > 600 else (26, 30))
|
| 91 |
+
self.assertEqual((geometry["iconWidth"], geometry["iconHeight"]), (18, 18))
|
| 92 |
+
self.assertAlmostEqual(geometry["dx"], 0, delta=0.5)
|
| 93 |
+
self.assertAlmostEqual(geometry["dy"], 0, delta=0.5)
|
| 94 |
+
self.assertTrue(geometry["fits"], geometry)
|
| 95 |
+
self.assertEqual((geometry["padding"], geometry["appearance"]), ("0px", "none"))
|
| 96 |
+
self.assertEqual(geometry["stroke"], geometry["color"])
|
| 97 |
+
self.assertEqual((geometry["hiddenIcon"], geometry["focusable"]), ("true", "false"))
|
| 98 |
+
self.assertTrue(button.get_attribute("title"))
|
| 99 |
+
self.assertTrue(button.get_attribute("aria-label"))
|
| 100 |
+
before = button.bounding_box()
|
| 101 |
+
button.hover()
|
| 102 |
+
button.focus()
|
| 103 |
+
self.assertEqual(button.bounding_box(), before)
|
| 104 |
+
self.assertEqual(button.evaluate("e => getComputedStyle(e).outlineStyle"), "solid")
|
| 105 |
+
|
| 106 |
def assert_contrast(self, selector):
|
| 107 |
result = self.page.locator(selector).first.evaluate("""element => {
|
| 108 |
const rgb = value => value.match(/[\\d.]+/g).map(Number);
|
tests/test_static_contract.py
CHANGED
|
@@ -113,12 +113,14 @@ class StaticFrontendContractTests(unittest.TestCase):
|
|
| 113 |
reader_css = (ROOT / "static/reader.css").read_text(encoding="utf-8")
|
| 114 |
reader_html = (ROOT / "static/reader.html").read_text(encoding="utf-8")
|
| 115 |
reader_store = (ROOT / "static/reader-store.js").read_text(encoding="utf-8")
|
| 116 |
-
|
|
|
|
|
|
|
| 117 |
self.assertNotIn("window.find", reader)
|
| 118 |
for value in ('id="full-search-input"', 'id="full-search-status"', 'id="full-search-results"', 'id="full-search-prev"', 'id="full-search-next"'):
|
| 119 |
-
self.assertIn(value,
|
| 120 |
-
self.assertIn("
|
| 121 |
-
assert_source_contains(self, 'textContent = "全文搜索"',
|
| 122 |
self.assertIn('circle cx="12" cy="12"', reader)
|
| 123 |
self.assertIn("async function activateChapter", reader)
|
| 124 |
assert_source_contains(self, "progressUndo.hidden = !previousProgressState", reader)
|
|
|
|
| 113 |
reader_css = (ROOT / "static/reader.css").read_text(encoding="utf-8")
|
| 114 |
reader_html = (ROOT / "static/reader.html").read_text(encoding="utf-8")
|
| 115 |
reader_store = (ROOT / "static/reader-store.js").read_text(encoding="utf-8")
|
| 116 |
+
search_controller = (ROOT / "static/reader-search-controller.mjs").read_text(encoding="utf-8")
|
| 117 |
+
self.assertIn('import { createReaderSearchController } from "./reader-search-controller.mjs"', reader)
|
| 118 |
+
self.assertEqual(len(re.findall(r'dataset\.panelView\s*=\s*"full-search"', search_controller)), 1)
|
| 119 |
self.assertNotIn("window.find", reader)
|
| 120 |
for value in ('id="full-search-input"', 'id="full-search-status"', 'id="full-search-results"', 'id="full-search-prev"', 'id="full-search-next"'):
|
| 121 |
+
self.assertIn(value, search_controller)
|
| 122 |
+
self.assertIn("snippetDom", search_controller)
|
| 123 |
+
assert_source_contains(self, 'textContent = "全文搜索"', search_controller)
|
| 124 |
self.assertIn('circle cx="12" cy="12"', reader)
|
| 125 |
self.assertIn("async function activateChapter", reader)
|
| 126 |
assert_source_contains(self, "progressUndo.hidden = !previousProgressState", reader)
|