forked from akai/readest
Compare commits
29 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 27fa9ab226 | |||
| c2bbb6119a | |||
| 578b7ba14f | |||
| 9d8062ae27 | |||
| 7128e8964e | |||
| e4bb9fc4b7 | |||
| df66c63a07 | |||
| 963bab0f0f | |||
| f9ddddb6ac | |||
| fe853554a9 | |||
| 4abbc0254c | |||
| 7283a8ac21 | |||
| 726f53a64b | |||
| 5ff18b8f32 | |||
| 274afb0677 | |||
| fe7fe25482 | |||
| 3a81e09911 | |||
| 176b950c92 | |||
| 458ad7510c | |||
| bc9fe67abf | |||
| 45ef5f7515 | |||
| 97191a57c0 | |||
| a92fe0ce2e | |||
| e8675fb7eb | |||
| 9b4db44490 | |||
| de3e4b6d3c | |||
| 53fe8c2683 | |||
| f8d88cca50 | |||
| 99e551cbc6 |
@@ -273,7 +273,18 @@ jobs:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
cd apps/readest-app/
|
||||
curl -sL https://github.com/readest/readest/releases/latest/download/latest.json -o latest.json
|
||||
# Use -f so curl fails on HTTP errors instead of writing the 404 body
|
||||
# ("Not Found") into latest.json and clobbering the release asset with
|
||||
# invalid JSON, which then breaks tauri-action's updater merge on every
|
||||
# subsequent build.
|
||||
if ! curl -fsSL https://github.com/readest/readest/releases/latest/download/latest.json -o latest.json; then
|
||||
echo "::error::Failed to download existing latest.json; aborting to avoid clobbering the release asset."
|
||||
exit 1
|
||||
fi
|
||||
if ! jq empty latest.json 2>/dev/null; then
|
||||
echo "::error::Existing latest.json is not valid JSON; aborting."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
version=${{ needs.get-release.outputs.release_version }}
|
||||
universial_apk_url="https://github.com/readest/readest/releases/download/${{ needs.get-release.outputs.release_tag }}/Readest_${version}_universal.apk"
|
||||
@@ -379,7 +390,18 @@ jobs:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
shell: bash
|
||||
run: |
|
||||
curl -sL https://github.com/readest/readest/releases/latest/download/latest.json -o latest.json
|
||||
# Use -f so curl fails on HTTP errors instead of writing the 404 body
|
||||
# ("Not Found") into latest.json and clobbering the release asset with
|
||||
# invalid JSON, which then breaks tauri-action's updater merge on every
|
||||
# subsequent build.
|
||||
if ! curl -fsSL https://github.com/readest/readest/releases/latest/download/latest.json -o latest.json; then
|
||||
echo "::error::Failed to download existing latest.json; aborting to avoid clobbering the release asset."
|
||||
exit 1
|
||||
fi
|
||||
if ! jq empty latest.json 2>/dev/null; then
|
||||
echo "::error::Existing latest.json is not valid JSON; aborting."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
version=${{ needs.get-release.outputs.release_version }}
|
||||
arch=${{ matrix.config.arch }}
|
||||
|
||||
@@ -14,7 +14,8 @@ on:
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions: read-all
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
upload-to-r2:
|
||||
|
||||
Generated
+340
-95
@@ -33,6 +33,7 @@ dependencies = [
|
||||
"tauri",
|
||||
"tauri-build 2.6.2",
|
||||
"tauri-plugin-cli",
|
||||
"tauri-plugin-clipboard-manager",
|
||||
"tauri-plugin-deep-link",
|
||||
"tauri-plugin-device-info",
|
||||
"tauri-plugin-dialog",
|
||||
@@ -273,6 +274,27 @@ dependencies = [
|
||||
"derive_arbitrary",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "arboard"
|
||||
version = "3.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf"
|
||||
dependencies = [
|
||||
"clipboard-win",
|
||||
"image",
|
||||
"log",
|
||||
"objc2",
|
||||
"objc2-app-kit",
|
||||
"objc2-core-foundation",
|
||||
"objc2-core-graphics",
|
||||
"objc2-foundation",
|
||||
"parking_lot",
|
||||
"percent-encoding",
|
||||
"windows-sys 0.60.2",
|
||||
"wl-clipboard-rs",
|
||||
"x11rb",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "arc-swap"
|
||||
version = "1.9.1"
|
||||
@@ -581,7 +603,7 @@ dependencies = [
|
||||
"quote",
|
||||
"regex",
|
||||
"rustc-hash 1.1.0",
|
||||
"shlex",
|
||||
"shlex 1.3.0",
|
||||
"syn 2.0.117",
|
||||
"which",
|
||||
]
|
||||
@@ -743,9 +765,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "brotli"
|
||||
version = "8.0.2"
|
||||
version = "8.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4bd8b9603c7aa97359dbd97ecf258968c95f3adddd6db2f7e7a5bef101c84560"
|
||||
checksum = "8119e4516436f5708bbc474a9d395bf12f1b5395e93a92a56e647ac3388c8610"
|
||||
dependencies = [
|
||||
"alloc-no-stdlib",
|
||||
"alloc-stdlib",
|
||||
@@ -754,9 +776,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "brotli-decompressor"
|
||||
version = "5.0.0"
|
||||
version = "5.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "874bb8112abecc98cbd6d81ea4fa7e94fb9449648c93cc89aa40c81c24d7de03"
|
||||
checksum = "5962523e1b92ce1b5e793d9169b9943eece10d39f62550bc04bb605d75b94924"
|
||||
dependencies = [
|
||||
"alloc-no-stdlib",
|
||||
"alloc-stdlib",
|
||||
@@ -930,14 +952,14 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cc"
|
||||
version = "1.2.62"
|
||||
version = "1.2.63"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98"
|
||||
checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f"
|
||||
dependencies = [
|
||||
"find-msvc-tools",
|
||||
"jobserver",
|
||||
"libc",
|
||||
"shlex",
|
||||
"shlex 2.0.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -958,7 +980,7 @@ version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766"
|
||||
dependencies = [
|
||||
"nom",
|
||||
"nom 7.1.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -969,7 +991,7 @@ checksum = "d38f2da7a0a2c4ccf0065be06397cc26a81f4e528be095826eee9d4adbb8c60f"
|
||||
dependencies = [
|
||||
"byteorder",
|
||||
"fnv",
|
||||
"uuid 1.23.1",
|
||||
"uuid 1.23.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -984,12 +1006,12 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "cfg-expr"
|
||||
version = "0.20.7"
|
||||
version = "0.20.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3c6b04e07d8080154ed4ac03546d9a2b303cc2fe1901ba0b35b301516e289368"
|
||||
checksum = "fb693542bcafa528e198be0ebd9d3632ca5b7c93dbe7237460e199910835997c"
|
||||
dependencies = [
|
||||
"smallvec",
|
||||
"target-lexicon 0.13.3",
|
||||
"target-lexicon 0.13.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1072,6 +1094,15 @@ version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
|
||||
|
||||
[[package]]
|
||||
name = "clipboard-win"
|
||||
version = "5.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4"
|
||||
dependencies = [
|
||||
"error-code",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cocoa"
|
||||
version = "0.25.0"
|
||||
@@ -1164,24 +1195,6 @@ dependencies = [
|
||||
"version_check",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cookie_store"
|
||||
version = "0.21.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2eac901828f88a5241ee0600950ab981148a18f2f756900ffba1b125ca6a3ef9"
|
||||
dependencies = [
|
||||
"cookie",
|
||||
"document-features",
|
||||
"idna",
|
||||
"log",
|
||||
"publicsuffix",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
"serde_json",
|
||||
"time",
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cookie_store"
|
||||
version = "0.22.1"
|
||||
@@ -1756,9 +1769,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "displaydoc"
|
||||
version = "0.2.5"
|
||||
version = "0.2.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0"
|
||||
checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -1841,6 +1854,12 @@ dependencies = [
|
||||
"tendril 0.5.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "downcast-rs"
|
||||
version = "1.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2"
|
||||
|
||||
[[package]]
|
||||
name = "downcast-rs"
|
||||
version = "2.0.2"
|
||||
@@ -2031,6 +2050,12 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "error-code"
|
||||
version = "3.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dea2df4cf52843e0452895c455a1a2cfbb842a1e7329671acf418fdc53ed4c59"
|
||||
|
||||
[[package]]
|
||||
name = "event-listener"
|
||||
version = "5.4.1"
|
||||
@@ -2081,6 +2106,12 @@ version = "2.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6"
|
||||
|
||||
[[package]]
|
||||
name = "fax"
|
||||
version = "0.2.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "caf1079563223d5d59d83c85886a56e586cfd5c1a26292e971a0fa266531ac5a"
|
||||
|
||||
[[package]]
|
||||
name = "fdeflate"
|
||||
version = "0.3.7"
|
||||
@@ -2134,6 +2165,12 @@ version = "0.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582"
|
||||
|
||||
[[package]]
|
||||
name = "fixedbitset"
|
||||
version = "0.5.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99"
|
||||
|
||||
[[package]]
|
||||
name = "flate2"
|
||||
version = "1.1.9"
|
||||
@@ -2838,6 +2875,17 @@ dependencies = [
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "half"
|
||||
version = "2.7.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"crunchy",
|
||||
"zerocopy",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.12.3"
|
||||
@@ -3011,9 +3059,9 @@ checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
|
||||
|
||||
[[package]]
|
||||
name = "hyper"
|
||||
version = "1.9.0"
|
||||
version = "1.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca"
|
||||
checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498"
|
||||
dependencies = [
|
||||
"atomic-waker",
|
||||
"bytes",
|
||||
@@ -3248,6 +3296,7 @@ dependencies = [
|
||||
"moxcms",
|
||||
"num-traits",
|
||||
"png 0.18.1",
|
||||
"tiff",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3692,9 +3741,9 @@ checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
|
||||
|
||||
[[package]]
|
||||
name = "libredox"
|
||||
version = "0.1.16"
|
||||
version = "0.1.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c"
|
||||
checksum = "f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
@@ -3923,9 +3972,9 @@ checksum = "df39d232f5c40b0891c10216992c2f250c054105cb1e56f0fc9032db6203ecc1"
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.0"
|
||||
version = "2.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
|
||||
checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8"
|
||||
|
||||
[[package]]
|
||||
name = "memmap2"
|
||||
@@ -3997,9 +4046,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "mio"
|
||||
version = "1.2.0"
|
||||
version = "1.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1"
|
||||
checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"log",
|
||||
@@ -4215,6 +4264,15 @@ dependencies = [
|
||||
"minimal-lexical",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nom"
|
||||
version = "8.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df9761775871bdef83bee530e60050f7e54b1105350d6884eb0fb4f46c2f9405"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "notify"
|
||||
version = "8.2.0"
|
||||
@@ -4992,6 +5050,17 @@ dependencies = [
|
||||
"sha2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "petgraph"
|
||||
version = "0.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455"
|
||||
dependencies = [
|
||||
"fixedbitset",
|
||||
"hashbrown 0.15.5",
|
||||
"indexmap 2.14.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phf"
|
||||
version = "0.8.0"
|
||||
@@ -5333,7 +5402,7 @@ version = "3.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f"
|
||||
dependencies = [
|
||||
"toml_edit 0.25.11+spec-1.1.0",
|
||||
"toml_edit 0.25.12+spec-1.1.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5487,6 +5556,12 @@ version = "1.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0"
|
||||
|
||||
[[package]]
|
||||
name = "quick-error"
|
||||
version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
|
||||
|
||||
[[package]]
|
||||
name = "quick-xml"
|
||||
version = "0.39.4"
|
||||
@@ -5877,7 +5952,7 @@ dependencies = [
|
||||
"base64 0.22.1",
|
||||
"bytes",
|
||||
"cookie",
|
||||
"cookie_store 0.22.1",
|
||||
"cookie_store",
|
||||
"encoding_rs",
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
@@ -6009,7 +6084,7 @@ dependencies = [
|
||||
"rkyv_derive",
|
||||
"seahash",
|
||||
"tinyvec",
|
||||
"uuid 1.23.1",
|
||||
"uuid 1.23.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6214,7 +6289,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2"
|
||||
dependencies = [
|
||||
"fnv",
|
||||
"quick-error",
|
||||
"quick-error 1.2.3",
|
||||
"tempfile",
|
||||
"wait-timeout",
|
||||
]
|
||||
@@ -6264,7 +6339,7 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"url",
|
||||
"uuid 1.23.1",
|
||||
"uuid 1.23.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6668,6 +6743,12 @@ version = "1.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
|
||||
|
||||
[[package]]
|
||||
name = "shlex"
|
||||
version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
|
||||
|
||||
[[package]]
|
||||
name = "shuttle"
|
||||
version = "0.8.1"
|
||||
@@ -6785,9 +6866,9 @@ checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
|
||||
|
||||
[[package]]
|
||||
name = "socket2"
|
||||
version = "0.6.3"
|
||||
version = "0.6.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e"
|
||||
checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.61.2",
|
||||
@@ -7101,7 +7182,7 @@ version = "7.0.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "396a35feb67335377e0251fcbc1092fc85c484bd4e3a7a54319399da127796e7"
|
||||
dependencies = [
|
||||
"cfg-expr 0.20.7",
|
||||
"cfg-expr 0.20.8",
|
||||
"heck 0.5.0",
|
||||
"pkg-config",
|
||||
"toml 1.1.2+spec-1.1.0",
|
||||
@@ -7124,7 +7205,7 @@ dependencies = [
|
||||
"crc32fast",
|
||||
"crossbeam-channel",
|
||||
"datasketches",
|
||||
"downcast-rs",
|
||||
"downcast-rs 2.0.2",
|
||||
"fastdivide",
|
||||
"fnv",
|
||||
"fs4",
|
||||
@@ -7157,7 +7238,7 @@ dependencies = [
|
||||
"thiserror 2.0.18",
|
||||
"time",
|
||||
"typetag",
|
||||
"uuid 1.23.1",
|
||||
"uuid 1.23.2",
|
||||
"winapi",
|
||||
]
|
||||
|
||||
@@ -7176,7 +7257,7 @@ version = "0.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c57166f5bcfd478f370ab8445afb4678dce44801fa5ce5c451aaf8595583c5dc"
|
||||
dependencies = [
|
||||
"downcast-rs",
|
||||
"downcast-rs 2.0.2",
|
||||
"fastdivide",
|
||||
"itertools 0.14.0",
|
||||
"serde",
|
||||
@@ -7217,7 +7298,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dfadb8526b6da90704feb293b0701a6aae62ea14983143344be2dc5ce30f1d82"
|
||||
dependencies = [
|
||||
"fnv",
|
||||
"nom",
|
||||
"nom 7.1.3",
|
||||
"ordered-float",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -7330,9 +7411,9 @@ checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1"
|
||||
|
||||
[[package]]
|
||||
name = "target-lexicon"
|
||||
version = "0.13.3"
|
||||
version = "0.13.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df7f62577c25e07834649fc3b39fafdc597c0a3527dc1c60129201ccfcbaa50c"
|
||||
checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca"
|
||||
|
||||
[[package]]
|
||||
name = "tauri"
|
||||
@@ -7388,7 +7469,7 @@ dependencies = [
|
||||
"tracing",
|
||||
"tray-icon",
|
||||
"url",
|
||||
"uuid 1.23.1",
|
||||
"uuid 1.23.2",
|
||||
"webkit2gtk",
|
||||
"webview2-com",
|
||||
"window-vibrancy",
|
||||
@@ -7458,7 +7539,7 @@ dependencies = [
|
||||
"thiserror 2.0.18",
|
||||
"time",
|
||||
"url",
|
||||
"uuid 1.23.1",
|
||||
"uuid 1.23.2",
|
||||
"walkdir",
|
||||
]
|
||||
|
||||
@@ -7505,6 +7586,21 @@ dependencies = [
|
||||
"thiserror 2.0.18",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-clipboard-manager"
|
||||
version = "2.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "206dc20af4ed210748ba945c2774e60fd0acd52b9a73a028402caf809e9b6ecf"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"log",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri",
|
||||
"tauri-plugin",
|
||||
"thiserror 2.0.18",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-deep-link"
|
||||
version = "2.4.9"
|
||||
@@ -7549,9 +7645,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-dialog"
|
||||
version = "2.6.0"
|
||||
version = "2.7.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9204b425d9be8d12aa60c2a83a289cf7d1caae40f57f336ed1155b3a5c0e359b"
|
||||
checksum = "65981abb771e74e571a38196c3baa11c459379164791eba0e67abc1a5fac9884"
|
||||
dependencies = [
|
||||
"log",
|
||||
"raw-window-handle",
|
||||
@@ -7567,7 +7663,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-fs"
|
||||
version = "2.4.5"
|
||||
version = "2.5.1"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"dunce",
|
||||
@@ -7585,7 +7681,7 @@ dependencies = [
|
||||
"tauri-plugin",
|
||||
"tauri-utils 2.9.2",
|
||||
"thiserror 2.0.18",
|
||||
"toml 0.9.12+spec-1.1.0",
|
||||
"toml 1.1.2+spec-1.1.0",
|
||||
"url",
|
||||
]
|
||||
|
||||
@@ -7605,12 +7701,12 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-http"
|
||||
version = "2.5.7"
|
||||
version = "2.5.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d8f069451c4e87e7e2636b7f065a4c52866c4ce5e60e2d53fa1038edb6d184dc"
|
||||
checksum = "b5bd512048e1985b7ec78f96d99083e2ddaf7e0d906b2b63c44ce5bb8b894067"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"cookie_store 0.21.1",
|
||||
"cookie_store",
|
||||
"data-url",
|
||||
"http",
|
||||
"regex",
|
||||
@@ -7733,9 +7829,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-persisted-scope"
|
||||
version = "2.3.5"
|
||||
version = "2.3.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dcb33765b205abc72a1384d0a73489a00bae6ca1e151c5e824ac80115949d2e1"
|
||||
checksum = "9b560a5962bf975d38fb4ec98a0e64e52929992ec708acb812add9c1ab8d186d"
|
||||
dependencies = [
|
||||
"aho-corasick",
|
||||
"bincode",
|
||||
@@ -7901,7 +7997,7 @@ dependencies = [
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"uuid 1.23.1",
|
||||
"uuid 1.23.2",
|
||||
"webkit2gtk",
|
||||
"webview2-com",
|
||||
"windows 0.61.3",
|
||||
@@ -8037,7 +8133,7 @@ dependencies = [
|
||||
"toml 1.1.2+spec-1.1.0",
|
||||
"url",
|
||||
"urlpattern",
|
||||
"uuid 1.23.1",
|
||||
"uuid 1.23.2",
|
||||
"walkdir",
|
||||
]
|
||||
|
||||
@@ -8053,11 +8149,9 @@ dependencies = [
|
||||
"dom_query",
|
||||
"dunce",
|
||||
"glob",
|
||||
"html5ever 0.29.1",
|
||||
"http",
|
||||
"infer",
|
||||
"json-patch",
|
||||
"kuchikiki",
|
||||
"log",
|
||||
"memchr",
|
||||
"phf 0.13.1",
|
||||
@@ -8076,7 +8170,7 @@ dependencies = [
|
||||
"toml 1.1.2+spec-1.1.0",
|
||||
"url",
|
||||
"urlpattern",
|
||||
"uuid 1.23.1",
|
||||
"uuid 1.23.2",
|
||||
"walkdir",
|
||||
]
|
||||
|
||||
@@ -8174,6 +8268,20 @@ dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tiff"
|
||||
version = "0.11.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b63feaf3343d35b6ca4d50483f94843803b0f51634937cc2ec519fc32232bc52"
|
||||
dependencies = [
|
||||
"fax",
|
||||
"flate2",
|
||||
"half",
|
||||
"quick-error 2.0.1",
|
||||
"weezl",
|
||||
"zune-jpeg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "time"
|
||||
version = "0.3.47"
|
||||
@@ -8413,9 +8521,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "toml_edit"
|
||||
version = "0.25.11+spec-1.1.0"
|
||||
version = "0.25.12+spec-1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b59c4d22ed448339746c59b905d24568fcbb3ab65a500494f7b8c3e97739f2b"
|
||||
checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7"
|
||||
dependencies = [
|
||||
"indexmap 2.14.0",
|
||||
"toml_datetime 1.1.1+spec-1.1.0",
|
||||
@@ -8581,6 +8689,17 @@ dependencies = [
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tree_magic_mini"
|
||||
version = "3.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8765b90061cba6c22b5831f675da109ae5561588290f9fa2317adab2714d5a6"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
"nom 8.0.0",
|
||||
"petgraph",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "try-lock"
|
||||
version = "0.2.5"
|
||||
@@ -8682,7 +8801,7 @@ dependencies = [
|
||||
"turso_parser",
|
||||
"twox-hash",
|
||||
"uncased",
|
||||
"uuid 1.23.1",
|
||||
"uuid 1.23.2",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
@@ -8769,7 +8888,7 @@ dependencies = [
|
||||
"tracing",
|
||||
"turso_core",
|
||||
"turso_parser",
|
||||
"uuid 1.23.1",
|
||||
"uuid 1.23.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -8808,9 +8927,9 @@ checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c"
|
||||
|
||||
[[package]]
|
||||
name = "typenum"
|
||||
version = "1.20.0"
|
||||
version = "1.20.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de"
|
||||
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
|
||||
|
||||
[[package]]
|
||||
name = "typetag"
|
||||
@@ -9025,9 +9144,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "uuid"
|
||||
version = "1.23.1"
|
||||
version = "1.23.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76"
|
||||
checksum = "d258b83ceec21034727ecee8c382cfa6c3e133699b0742c64571814fb420c9f7"
|
||||
dependencies = [
|
||||
"getrandom 0.4.2",
|
||||
"js-sys",
|
||||
@@ -9259,6 +9378,76 @@ dependencies = [
|
||||
"semver",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wayland-backend"
|
||||
version = "0.3.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2857dd20b54e916ec7253b3d6b4d5c4d7d4ca2c33c2e11c6c76a99bd8744755d"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"downcast-rs 1.2.1",
|
||||
"rustix 1.1.4",
|
||||
"smallvec",
|
||||
"wayland-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wayland-client"
|
||||
version = "0.31.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "645c7c96bb74690c3189b5c9cb4ca1627062bb23693a4fad9d8c3de958260144"
|
||||
dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
"rustix 1.1.4",
|
||||
"wayland-backend",
|
||||
"wayland-scanner",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wayland-protocols"
|
||||
version = "0.32.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "563a85523cade2429938e790815fd7319062103b9f4a2dc806e9b53b95982d8f"
|
||||
dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
"wayland-backend",
|
||||
"wayland-client",
|
||||
"wayland-scanner",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wayland-protocols-wlr"
|
||||
version = "0.3.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eb04e52f7836d7c7976c78ca0250d61e33873c34156a2a1fc9474828ec268234"
|
||||
dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
"wayland-backend",
|
||||
"wayland-client",
|
||||
"wayland-protocols",
|
||||
"wayland-scanner",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wayland-scanner"
|
||||
version = "0.31.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9c324a910fd86ebdc364a3e61ec1f11737d3b1d6c273c0239ee8ff4bc0d24b4a"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quick-xml",
|
||||
"quote",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wayland-sys"
|
||||
version = "0.31.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be"
|
||||
dependencies = [
|
||||
"pkg-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "web-sys"
|
||||
version = "0.3.99"
|
||||
@@ -9398,6 +9587,12 @@ dependencies = [
|
||||
"windows-core 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "weezl"
|
||||
version = "0.1.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88"
|
||||
|
||||
[[package]]
|
||||
name = "which"
|
||||
version = "4.4.2"
|
||||
@@ -10199,6 +10394,24 @@ dependencies = [
|
||||
"wasmparser",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wl-clipboard-rs"
|
||||
version = "0.9.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e9651471a32e87d96ef3a127715382b2d11cc7c8bb9822ded8a7cc94072eb0a3"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"log",
|
||||
"os_pipe",
|
||||
"rustix 1.1.4",
|
||||
"thiserror 2.0.18",
|
||||
"tree_magic_mini",
|
||||
"wayland-backend",
|
||||
"wayland-client",
|
||||
"wayland-protocols",
|
||||
"wayland-protocols-wlr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wmi"
|
||||
version = "0.13.4"
|
||||
@@ -10295,6 +10508,23 @@ dependencies = [
|
||||
"pkg-config",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "x11rb"
|
||||
version = "0.13.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414"
|
||||
dependencies = [
|
||||
"gethostname",
|
||||
"rustix 1.1.4",
|
||||
"x11rb-protocol",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "x11rb-protocol"
|
||||
version = "0.13.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd"
|
||||
|
||||
[[package]]
|
||||
name = "xattr"
|
||||
version = "1.6.1"
|
||||
@@ -10341,9 +10571,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "zbus"
|
||||
version = "5.15.0"
|
||||
version = "5.16.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c3bcbf15c8708d7fc1be0c993622e0a5cbd5e8b52bfa40afa4c3e0cd8d724ac1"
|
||||
checksum = "eee682d202a77e4a9f3b2c2bdf48a7b28af5c08c34ddf66f98c93e5e39464285"
|
||||
dependencies = [
|
||||
"async-broadcast",
|
||||
"async-executor",
|
||||
@@ -10366,7 +10596,7 @@ dependencies = [
|
||||
"serde_repr",
|
||||
"tracing",
|
||||
"uds_windows",
|
||||
"uuid 1.23.1",
|
||||
"uuid 1.23.2",
|
||||
"windows-sys 0.61.2",
|
||||
"winnow 1.0.3",
|
||||
"zbus_macros",
|
||||
@@ -10376,9 +10606,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "zbus_macros"
|
||||
version = "5.15.0"
|
||||
version = "5.16.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "51fa5406ad9175a8c825a931f8cf347116b531b3634fcb0b627c290f1f2516ff"
|
||||
checksum = "adf1bd45a81a103745b1757754762a26e8cd01e4532e4d6c8ec431624b80d1d6"
|
||||
dependencies = [
|
||||
"proc-macro-crate 3.5.0",
|
||||
"proc-macro2",
|
||||
@@ -10402,18 +10632,18 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "zerocopy"
|
||||
version = "0.8.48"
|
||||
version = "0.8.50"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9"
|
||||
checksum = "3b065d4f0e55f82fae73202e189638116a87c55ab6b8e6c2721e13dd9d854ad1"
|
||||
dependencies = [
|
||||
"zerocopy-derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerocopy-derive"
|
||||
version = "0.8.48"
|
||||
version = "0.8.50"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4"
|
||||
checksum = "0b631b19d36a892ab55420c92dbc83ccd79274f25be714855d3074aa71cab639"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -10541,10 +10771,25 @@ dependencies = [
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zvariant"
|
||||
version = "5.11.0"
|
||||
name = "zune-core"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1c1567a6ec68df868cbbfde844cfc6d81649fe5109a62b116b19fabd53e618ee"
|
||||
checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9"
|
||||
|
||||
[[package]]
|
||||
name = "zune-jpeg"
|
||||
version = "0.5.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296"
|
||||
dependencies = [
|
||||
"zune-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zvariant"
|
||||
version = "5.12.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a192a0bde63360d77a7523c833d4b4ce6070a927e2c53246e4c540b1a3e27be0"
|
||||
dependencies = [
|
||||
"endi",
|
||||
"enumflags2",
|
||||
@@ -10556,9 +10801,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "zvariant_derive"
|
||||
version = "5.11.0"
|
||||
version = "5.12.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c7d5b780599bbde114e39d9a0799577fad1ced5105d38515745f7b3099d8ceda"
|
||||
checksum = "90bc6cde9c01c511074be97f7ccb6c19d0da89e3f8662e812e999dcfd4638737"
|
||||
dependencies = [
|
||||
"proc-macro-crate 3.5.0",
|
||||
"proc-macro2",
|
||||
@@ -10569,9 +10814,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "zvariant_utils"
|
||||
version = "3.3.1"
|
||||
version = "3.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6d464f5733ffa07a3164d656f18533caace9d0638596721355d73256a410d691"
|
||||
checksum = "1e8535915cfa75547e559d8c68e8139909a4aeee076831e4ef7fc59d8172c4d6"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
|
||||
@@ -12,7 +12,11 @@
|
||||
- [Issue #4112 scroll-anchoring](issue-4112-scroll-anchoring.md) — RESOLVED (PR #4349). Scroll-anchoring suppressed at scrollTop 0 when prepending a section in scrolled mode; fix patterns (prepend compensation, eager backward preload, no-blank nav) + test & dev-server gotchas
|
||||
- [Reading ruler line/column-aware](reading-ruler-line-aware.md) — ruler snaps to real lines; multi-column band spans one column; Range.getClientRects() returns tall block boxes that must be dropped; iframe frame-offset mapping; synthetic-key throttling
|
||||
- [TOC expand + auto-scroll](toc-expand-and-autoscroll.md) — #4059 collapse-by-default policy in `tocTree.ts`; pinned-sidebar mounts before progress → dynamic expansion breaks scroll-to-current via (1) spurious onScroll clearing pending and (2) Virtuoso scrollToIndex landing short after row growth (re-assert on rAF)
|
||||
- [BooknoteView auto-scroll (#4352)](booknote-view-autoscroll-4352.md) — virtualizing the annotation/bookmark list dropped auto-scroll-to-nearest; two paths (reload: OverlayScrollbars resets scrollTop → re-apply in `initialized` via ref; tab-switch: synchronous scrollToIndex on fresh-mounted list wedges Virtuoso → use `initialTopMostItemIndex` + skip-gate). Mirrors TOCView. Includes dev-server/Fast-Refresh/screenshot-vs-DOM verification gotchas
|
||||
- [Swipe page-turn bg flash](paginator-swipe-bg-flash.md) — white↔black flash on swipe+animation only; `#background` was static screen-space and didn't track content during drag/snap; fix = sliding per-view full-bleed segments (`computeBackgroundSegments`) rebuilt on scroll + per-rAF synced to the view transform during snap
|
||||
- [Duokan fullscreen cover hidden in scroll mode](duokan-fullscreen-cover-scroll.md) — #4379 `data-duokan-page-fullscreen` cover pinned `position:absolute height:100%` collapses against auto-height scroll container; gate fullscreen on `this.#column` + reset stale absolute props on toggle (`setImageSize` in paginator.js)
|
||||
- [Paginated texture occlusion](paginated-texture-occlusion-4399.md) — #4399 host `.foliate-viewer::before` texture absent in paginated (shown in scrolled); opaque `#background` container (`= fallbackBg`) from the swipe-flash fix occludes it; shared `textureAwareBackground` helper + `hasTexture ? '' : fallbackBg` container
|
||||
- [Background overflows column (#4394, PR #4429)](paginator-gutter-bleed-asymmetry-4394.md) — paginated page bg stretched into the outer `--_outer-min` gutter → mixed cover/title 2-up spread shifted off-centre (~250px at 1920px). KEEP the grid (`--_outer-min` keeps margins symmetric); fix = clamp `computeBackgroundSegments` to `[containerStart,containerEnd]` (Math.max/Math.min) so bg stays in its column. 2 wrong tries first (bleed-gating, "page shouldn't be yellow"); foliate submodule needs dev-server RESTART to pick up edits
|
||||
|
||||
## Critical Files (Most Bug-Prone)
|
||||
- `src/utils/style.ts` - Central EPUB CSS transformation hub (14+ bug fixes)
|
||||
@@ -25,6 +29,16 @@
|
||||
## Sync Notes
|
||||
- [KOSync CFI spine resolution](kosync-cfi-spine-resolution.md) — convert via the CFI's own spine (`getXPointerFromCFI`/`getCFIFromXPointer`), never `new XCFI(primaryDoc, primaryIndex)`; primaryIndex lags during scroll → spine-mismatch throw
|
||||
- [Empty-start CFI sync bug](empty-start-cfi-sync.md) — `epubcfi(/6/24!/4,,/20/1:58)` (empty-start range) from the cfi-inert skip-link transitional window; jumps to wrong section end; `isMalformedLocationCfi` → discard the synced value in `useProgressSync` (NOT the local open path); foliate fix doesn't repair already-synced values
|
||||
- [Custom fonts disappear on cloud sync (#4410)](custom-fonts-reincarnation-4410.md) — CRDT remove-wins: re-import-after-delete needs a `reincarnation` token or the pull re-applies the tombstone; `addFont`/`addTexture` minted none; fix mirrors dictionary (both cases) + OPDS token style; coverage matrix per kind
|
||||
|
||||
## Testing
|
||||
- [Tauri Rust↔JS parser parity tests](tauri-parser-parity-tests.md) — #4369 native Rust EPUB/MOBI parser; how to cross-check vs foliate-js in the `.tauri.test.ts` WebView suite (CWD disk path for Rust, Vite URL for JS, normalizer-based compare, cover presence-only, desc whitespace-collapse); the `dcterms:modified`→`published` divergence fix
|
||||
|
||||
## Build & Vendoring
|
||||
- [pdfjs vendor wasm decoders](pdfjs-vendor-wasm-decoders.md) — scanned PDFs blank in CI build only (0.11.2 regression); pdfjs 5.7.x moved JBIG2 to `jbig2.wasm`, `copy-pdfjs-wasm` allow-list dropped it; `cpx` no-errors on empty glob; local stale `public/vendor` (gitignored, not refreshed by `tauri build`) masked it; fix = copy `wasm/*`
|
||||
|
||||
## Platform Compat
|
||||
- [Window-state sanitizer (#4398)](window-state-sanitize-4398.md) — Windows launch crash (WebView2 0x80070057) from invalid `.window-state.json` (`-32000` minimized sentinel / `0×0`); our plugin already has upstream #253 fix so bad files are stale; defense-in-depth `window-state-sanitizer` plugin registered BEFORE window-state (plugin init = registration order); coord threshold `-16000` (~halfway to the -32000 sentinel; real desktops sit a few thousand px off origin) keeps multi-monitor negatives
|
||||
|
||||
## Feature Notes
|
||||
- [Manage Cache + iOS container layout](manage-cache-ios-layout.md) — `'Cache'` base = `Library/Caches/<bundle>` only (not all of Caches); iOS `Documents/Inbox` cleared too; WebKit cache + tmp out of reach; never touch App Support
|
||||
@@ -32,11 +46,21 @@
|
||||
- [Cloudflare Workers WebSocket](cloudflare-workers-websocket.md) — use fetch() Upgrade pattern (not `ws` npm); CF delivers binary frames as Blob (must serialize async decodes)
|
||||
- [Share-a-Book Feature (in progress)](share-feature.md) — locked decisions for the /s/{token} share-link feature; plan at ~/.claude/plans/ok-we-will-learn-cosmic-acorn.md
|
||||
- [readest.koplugin i18n](koplugin-i18n.md) — gettext loader at `apps/readest.koplugin/i18n.lua`, `.po` catalog at `locales/<i18next-code>/translation.po`, extract/apply scripts in `scripts/`
|
||||
- [koplugin cover upload](koplugin-cover-upload.md) — #4374 uploadBook only shipped cached cloud covers; local-origin books uploaded blank. Fix = `extractLocalCover` via `FileManagerBookInfo:getCoverImage(nil, file)` → `writeToFile(path,"png")`. KOReader checkout at `/Users/chrox/dev/koreader`
|
||||
|
||||
## Patterns
|
||||
- [Virtuoso + OverlayScrollbars](virtuoso_overlayscrollbars.md) — useOverlayScrollbars hook integration for overlay scrollbars on mobile webviews
|
||||
- [Design system → DESIGN.md](feedback_design_system_doc.md) — codify recurring UI/UX rules in `apps/readest-app/DESIGN.md`; never `pl/pr/ml/mr/text-left/text-right` (RTL); §5 boxed list anatomy has uniform `min-h-14` rows and chromeless controls
|
||||
|
||||
## Reader UI Fixes
|
||||
- [Android image callout freeze](android-image-callout-freeze.md) — long-press `<img>` fires WebView native callout that collides with app touch handlers → whole-app freeze; `-webkit-touch-callout: none` doesn't inherit so put `.no-context-menu` on an ancestor of the image (`.no-context-menu img` rule in globals.css); seen on book covers (#4345) + image preview/zoom (#4420, `ImageViewer.tsx`)
|
||||
- [ProgressBar focus-ring line (#4397)](progressbar-focus-ring-4397.md) — decorative `.progressinfo` footer was `tabIndex={-1}` → Android long-press focused it → stray content-width focus-ring line at the bottom every page; fix = drop tabIndex (role='presentation' must not be focusable); ffmpeg-the-video debugging + live-browser `:focus-visible` confirmation
|
||||
- [Table dark-mode tint regression (#4419)](table-dark-mode-tint-4419.md) — `blockquote, table *` color-mix tint in `getColorStyles` must stay gated on `overrideColor` (gate added #2377, removed #4055, re-broke → #4419); safe now that #4392 light-bg rewriters handle #4028 zebra legibility; SAME rule paints vertical-TOC `.space`/▉ spacer cells (▉ U+2589 = blank glyph, contours=0) → "spacing changes" symptom; both fixed by the gate
|
||||
|
||||
## Library Fixes
|
||||
- [Tauri menu append race (#4389)](tauri-menu-append-race-4389.md) — un-awaited `Menu.append()` (async IPC) in `BookshelfItem.tsx` → context-menu items shuffle order every open (native only, invisible in jsdom); fix = single `await Menu.new({ items })` of ordered `MenuItemOptions`; order/inclusion extracted to pure `getBookContextMenuItemIds` for unit testing
|
||||
- [TXT author recognition (#4390)](txt-author-recognition-4390.md) — 【】-titled Chinese web-novels show author missing/garbage; they're TXT→EPUB (title==full filename is the tell, check `txt.ts` not foliate-js); `extractTxtFilenameMetadata` only handled 《》 + greedy header capture grabbed metadata blobs; fix = `parseLabeledAuthor` for any filename + `isPlausibleAuthorName` guard
|
||||
|
||||
## Architecture Notes
|
||||
- foliate-js is a git submodule at `packages/foliate-js/`
|
||||
- Multiview paginator: loads adjacent sections in background, multiple View/Overlayer instances per book
|
||||
@@ -46,6 +70,7 @@
|
||||
- Safe area insets flow: Native plugin -> useSafeAreaInsets hook -> component styles
|
||||
- Dropdown menus use `DropdownContext` (not blur-based) for screen reader compat
|
||||
- [Foliate touch-listener capture phase](foliate-touch-listener-capture-phase.md) — to suppress reader gestures from the app, use `{capture:true}`; the paginator registers bubble-phase doc listeners first (during `view.open()`)
|
||||
- [iframe cross-realm instanceof](iframe-cross-realm-instanceof.md) — app-bundle code (style.ts, iframeEventHandlers.ts) runs in top realm; `iframeEl instanceof Element` is ALWAYS false → guards silently drop all iframe elements (passes jsdom, dead in app). Duck-type `'closest' in target` instead. Bit PR #4391's touch routing + applyTableStyle dedupe
|
||||
|
||||
## Workflow
|
||||
- [Test file filter](feedback_test_file_filter.md) — use `pnpm test <path>` without `--` to run a single file
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
---
|
||||
name: android-image-callout-freeze
|
||||
description: "Android WebView native long-press image callout collides with app touch handlers and freezes the app; reusable `.no-context-menu` fix"
|
||||
metadata:
|
||||
node_type: memory
|
||||
type: project
|
||||
originSessionId: 50bec34f-7090-4bf4-a194-9bf4029527bf
|
||||
---
|
||||
|
||||
Recurring Android-only freeze: long-pressing an `<img>` triggers the WebView's
|
||||
native image callout (context menu / drag / magnifier) which collides with the
|
||||
app's own touch handlers (long-press multi-select, or pinch/pan) and freezes the
|
||||
whole app until restart.
|
||||
|
||||
**Root cause:** `-webkit-touch-callout: none` does NOT inherit, so a
|
||||
`.no-context-menu` class on a *container* never reaches descendant images.
|
||||
|
||||
**Fix:** the `.no-context-menu img, .no-context-menu a` rule in
|
||||
`src/styles/globals.css` (sets `-webkit-touch-callout: none; -webkit-user-drag:
|
||||
none; user-select: none`). Apply the `no-context-menu` class to an *ancestor* of
|
||||
the image so the descendant rule reaches it. Harmless on desktop
|
||||
(`-webkit-touch-callout` is a no-op there; right-click-save still works).
|
||||
|
||||
Occurrences so far:
|
||||
- Book covers on the bookshelf — PR #4345 (`BookshelfItem.tsx`, gated on
|
||||
`appService?.isMobileApp`; added the `.no-context-menu img` rule).
|
||||
- Image preview / zoom viewer — issue #4420, `ImageViewer.tsx` root container
|
||||
(applied unconditionally — no selectable text there, so no need to gate).
|
||||
|
||||
**How to apply:** when a new "Android freezes on long-press of an image" report
|
||||
comes in, find the `<img>` and put `no-context-menu` on a containing element.
|
||||
@@ -0,0 +1,48 @@
|
||||
---
|
||||
name: booknote-view-autoscroll-4352
|
||||
description: "Annotation/bookmark list (BooknoteView) auto-scroll-to-nearest regression after virtualization (#4352) and its TOCView-mirroring fix"
|
||||
metadata:
|
||||
node_type: memory
|
||||
type: project
|
||||
originSessionId: bda988b9-28ec-450f-874e-ee9c104f7603
|
||||
---
|
||||
|
||||
After #4352 virtualized `BooknoteView` (sidebar annotations/bookmarks list,
|
||||
`src/app/reader/components/sidebar/BooknoteView.tsx`), the list stopped
|
||||
auto-scrolling to the nearest annotation for the current reading position (it
|
||||
stranded at the top showing Chapter 1). #4352 replaced the per-item
|
||||
`useScrollToItem` with a single `virtuosoRef.scrollToIndex`, but missed the
|
||||
machinery `TOCView` already had. Two distinct failure paths:
|
||||
|
||||
1. **Reload (annotations tab active at load; progress arrives AFTER mount):** the
|
||||
OverlayScrollbars `initialized` callback (deferred init) resets the wrapped
|
||||
viewport `scrollTop` to 0, clobbering the scroll; the `lastScrolledCfiRef`
|
||||
guard then blocks any retry. Fix = re-apply `scrollToIndex` to the *current*
|
||||
nearest index inside the `initialized` callback, read via a **ref**
|
||||
(`nearestIndexRef`) because that callback is the mount-time closure. Double
|
||||
rAF (settle the reset, then re-assert once rows are measured).
|
||||
2. **Tab-switch (open panel while reading; progress KNOWN at mount):** firing a
|
||||
`scrollToIndex` synchronously on the freshly mounted, unmeasured list either
|
||||
no-ops (`behavior:'smooth'`) or **wedges Virtuoso into rendering nothing**
|
||||
(`behavior:'auto'`). Fix = mount Virtuoso *natively* centered via
|
||||
`initialTopMostItemIndex` + an `initialScrollHandledRef` gate so the scroll
|
||||
effect SKIPS that first jump. This is exactly TOCView's design.
|
||||
|
||||
The fix mirrors [[toc-expand-and-autoscroll]] (same OverlayScrollbars-resets-
|
||||
scrollTop + Virtuoso-lands-short-on-unmeasured-rows pattern). Test:
|
||||
`src/__tests__/components/BooknoteView.test.tsx` stubs Virtuoso (spy-able
|
||||
`scrollToIndex`, captures `initialTopMostItemIndex`) and captures the mount-time
|
||||
`initialized` callback — forcing a ref-based fix (modeled on `TOCView.test.tsx`).
|
||||
|
||||
**Dev-server verification gotchas (cost hours here):**
|
||||
- The `localhost:3000` dev server was running from a *different worktree*
|
||||
(`/Users/chrox/dev/readest-fix-4394-bg-gutter-bleed`), not the main checkout.
|
||||
Edits weren't compiled until copied into that worktree's path. Check
|
||||
`ps aux | grep next-server` for the serving cwd. Book data is per-origin
|
||||
(OPFS/IndexedDB on localhost:3000) so you can't verify on another port.
|
||||
- After ~10 rapid file syncs, **Fast Refresh corrupts the mounted tab's state**
|
||||
— identical code that worked started rendering 0 items. A *brand-new tab*
|
||||
(close the old one) renders correctly. Always verify in a fresh tab.
|
||||
- Chrome MCP `javascript_tool` querying `.booknote-item` count catches Virtuoso
|
||||
mid-render (returns 0 even when it later paints fine). Trust the *screenshot*
|
||||
(the painted frame), not a synchronous DOM count, for virtualized lists.
|
||||
@@ -0,0 +1,27 @@
|
||||
---
|
||||
name: custom-fonts-reincarnation-4410
|
||||
description: Custom fonts/textures disappear when logged into cloud sync after re-import-after-delete; CRDT remove-wins needs a reincarnation token
|
||||
metadata:
|
||||
node_type: memory
|
||||
type: project
|
||||
originSessionId: 2b61b392-4d32-4516-84bd-f362bba22378
|
||||
---
|
||||
|
||||
# #4410 — disappearing custom fonts when logged into cloud
|
||||
|
||||
**Symptom:** custom fonts vanish a few seconds after opening a book (or ~1 min idle) ONLY when logged into cloud sync; logging out fixes it; a brand-new never-deleted font is fine; problem starts after deleting a font / "Clear Custom Fonts" then re-uploading the same file.
|
||||
|
||||
**Root cause — CRDT remove-wins.** The replica sync (`src/libs/crdt.ts`, `src/libs/replicaInterpret.ts`) is remove-wins: once a row has a `deleted_at_ts` tombstone, a plain field upsert does NOT revive it. Only a `reincarnation` token whose effective HLC beats the tombstone revives it. `isReplicaRowAlive(row)` = `!deleted_at_ts || (reincarnation && updated_at_ts >= deleted_at_ts)`. In `mergeReplica` the reincarnation candidate's timestamp is the **row's `updated_at_ts`** (fresh on every upsert), NOT the token's mint time — so preserving an old token still revives, as long as the upsert carries it.
|
||||
|
||||
Flow that broke: import→delete writes a server tombstone (`publishReplicaDelete`). Re-upload same file → same `contentId` → `addFont` cleared `deletedAt` locally and called `publishFontUpsert` with `reincarnation = undefined` → server tombstone survives → next pull (boot 5s / periodic / book-open / visibility) sees `isReplicaRowAlive===false` → `softDeleteByContentId` → font disappears.
|
||||
|
||||
**Fix (PR for #4410):** in `addFont` (`src/store/customFontStore.ts`) and `addTexture` (`src/store/customTextureStore.ts`), when re-adding an existing entry, mint a reincarnation token (`Math.random().toString(36).slice(2)`, matching OPDS) when `!!contentId && !existing.reincarnation && (existing.deletedAt || existing.contentId === new.contentId)`; otherwise preserve `existing.reincarnation`. Covers both re-import-after-local-delete AND the stale-local race (local still live but another device tombstoned the row). Token is inert without a tombstone, so live re-imports are safe.
|
||||
|
||||
**Coverage matrix across collection kinds (all share the remove-wins replica):**
|
||||
- Dictionary — handles BOTH cases (gold standard): `dictionaryService.ts importDictionaries` via `findTombstonedDictionaryMatches` + `shouldMintReincarnationForLiveReimport` (helpers in `dictionaries/dictionaryDedup.ts`), mints `uuidv4()`.
|
||||
- OPDS — case 1 only: `customOPDSStore.addCatalog` (`existing?.deletedAt && !input.reincarnation`).
|
||||
- Fonts / Textures — handled NEITHER → this bug. Now fixed to dictionary-parity.
|
||||
|
||||
Whole chain carries the token: returned font → `publishFontUpsert` upsert AND `queueReplicaBinaryUpload` → manifest publish (`replicaBinaryUpload.ts` uses `record.reincarnation`).
|
||||
|
||||
Note: `saveCustomFonts`/`saveCustomTextures` persist tombstoned (deletedAt) entries too, so the soft-deleted entry is still in the store at re-import time → the `existing.deletedAt` branch fires. (OPDS strips deleted at save; fonts/textures keep them.)
|
||||
@@ -0,0 +1,21 @@
|
||||
---
|
||||
name: duokan-fullscreen-cover-scroll
|
||||
description: "Duokan fullscreen cover image invisible in scrolled mode (#4379) — paginator pins it position:absolute height:100% which collapses against auto-height scroll container"
|
||||
metadata:
|
||||
node_type: memory
|
||||
type: project
|
||||
originSessionId: c45aabf0-e8a3-42b6-a5fd-c04d6eb2345c
|
||||
---
|
||||
|
||||
Issue #4379: an EPUB cover with `data-duokan-page-fullscreen` on `<html>` (Duokan/DangDang convention) shows in paginated mode but is **blank in scrolled mode**; only the first cover image, other images fine in both modes.
|
||||
|
||||
**Root cause** — `View.setImageSize()` in `packages/foliate-js/paginator.js` has a `pageFullscreen` branch that pins each img with `position:absolute; inset:0; width:100%; height:100%` and forces ancestors + `<html>` to `height:100%`/`position:relative`. This fills the page in paginated/columnized mode (html has a fixed pixel height). In scrolled mode `scrolled()` sets `html`/`body` height to `auto`, so the `height:100%` chain resolves to **0** and the absolutely-positioned cover collapses out of view (offsetHeight 0).
|
||||
|
||||
**Fix** — gate the fullscreen treatment on column mode: `const applyFullscreen = pageFullscreen && this.#column`. Use `applyFullscreen` for the max-height/max-width margin term and the `if` block. Add an `else if (pageFullscreen)` that `removeProperty`s the stale `position/inset/width/height/margin` on the img (and `width/height/margin/padding` on ancestors, `position` on html) so toggling paginated→scrolled doesn't leave the cover collapsed (same iframe/img is reused via `view.render(layout)` on `flow` change). In scrolled mode the cover then flows like a normal full-page image bounded by `max-height = availableHeight`.
|
||||
|
||||
**Key facts**
|
||||
- `this.#column = layout.flow !== 'scrolled'` (set in `render()` before `setImageSize`), so it's reliable inside `setImageSize`.
|
||||
- Foliate writes these styles as **inline `!important`** → cannot be overridden from `src/utils/style.ts`; the fix must live in the paginator.
|
||||
- Regression test: `src/__tests__/document/paginator-duokan-cover.browser.test.ts` + fixture `repro-4379.epub` (cover xhtml with the duokan attr + dimensionless `<img>`). Asserts cover `img.offsetHeight > 0` in scrolled mode, paginated sanity, and paginated→scrolled toggle. Browser test (real layout) is required — jsdom can't compute the collapse.
|
||||
|
||||
Related: [[paginator-swipe-bg-flash]], [[css-style-fixes]].
|
||||
@@ -0,0 +1,25 @@
|
||||
---
|
||||
name: iframe-cross-realm-instanceof
|
||||
description: "App-bundle code handling foliate iframe DOM must not use `instanceof Element/HTMLElement` — cross-realm, always false"
|
||||
metadata:
|
||||
node_type: memory
|
||||
type: project
|
||||
originSessionId: 2e8d274e-a4da-4d63-8afb-d7a600d560b2
|
||||
---
|
||||
|
||||
Reader content lives in foliate iframes, each with its **own JS realm**. App-bundle
|
||||
code (e.g. `src/utils/style.ts`, `iframeEventHandlers.ts`) runs in the **top window
|
||||
realm**, so its `Element`/`HTMLElement` constructors differ from the iframe's.
|
||||
|
||||
`someIframeEl instanceof Element` (top-realm `Element`) is **always `false`** — verified:
|
||||
`Element === iframeWin.Element` is false. So any guard like `if (!(target instanceof Element)) return`
|
||||
silently drops every real iframe element. Functions still pass jsdom unit tests (single realm)
|
||||
yet are dead in the running app.
|
||||
|
||||
**Use duck-typing instead**: `if (!target || !('closest' in target)) return;` then
|
||||
`(target as Element).closest(...)`. For node-type checks use `node.nodeType === Node.ELEMENT_NODE`
|
||||
(numeric constant, realm-safe) or check `classList`/`closest` presence.
|
||||
|
||||
Seen in PR #4391 (wide-table horizontal scroll): the touch `findWrapper` and `applyTableStyle`'s
|
||||
re-entrancy guard both used `instanceof`, so the touch routing never fired and the dedupe guard
|
||||
never tripped. Related: [[foliate-touch-listener-capture-phase]].
|
||||
@@ -0,0 +1,18 @@
|
||||
---
|
||||
name: koplugin-cover-upload
|
||||
description: "koplugin cover system — where local/cloud covers come from, and the"
|
||||
metadata:
|
||||
node_type: memory
|
||||
type: reference
|
||||
originSessionId: 95cdff70-ce2c-4077-82a8-922f9578a22f
|
||||
---
|
||||
|
||||
readest.koplugin cover handling (in `apps/readest.koplugin/library/`):
|
||||
|
||||
- **Local book covers** come from coverbrowser.koplugin's `BookInfoManager` (a hard dependency). `coverprovider.get_local_cover(file_path)` returns `BIM:getBookInfo(file_path, true).cover_bb` (a downscaled thumbnail bb) and kicks off background extraction on a miss.
|
||||
- **Native-resolution cover** for a file (no open doc needed): `require("apps/filemanager/filemanagerbookinfo"):getCoverImage(nil, file_path)` — opens+closes the doc itself, honors KOReader custom covers, returns a blitbuffer. Same `(nil, file)` call form `calibre.koplugin` uses. Write it to PNG with `bb:writeToFile(path, "png")` (pcall-wrapped internally) then `bb:free()`.
|
||||
- **Cloud covers** are downloaded to `DataStorage:getSettingsDir()/readest_covers/<hash>.png` (`cloud_covers.covers_dir()`); cloud storage key is `<user_id>/Readest/Books/<hash>/cover.png` (`build_cover_key`), which matches readest's `getCoverFilename` (`<hash>/cover.png`).
|
||||
|
||||
**Issue #4374 (fixed):** `syncbooks.uploadBook` only shipped a `cover.png` if one was *already cached* under `readest_covers/<hash>.png` from a prior cloud download — so books that originated locally in KOReader uploaded with no cover and showed blank in readest. Fix = `syncbooks.extractLocalCover(file_path, dst_png)` (extracts via `getCoverImage` → writeToFile), called from `uploadBook` when `has_cover` is false. Only file-upload path is `librarywidget.lua` "Upload to Cloud" → `uploadBook` (FileManager `addToReadest` only stages a local row).
|
||||
|
||||
Tests: network/live parts of `syncbooks.lua` aren't unit-tested (manual matrix in `docs/library-design.md`); `extractLocalCover` IS tested by injecting a fake `apps/filemanager/filemanagerbookinfo` via `package.loaded` in `spec/library/syncbooks_spec.lua`. A real KOReader checkout lives at `/Users/chrox/dev/koreader` for verifying KOReader APIs. See [[koplugin-i18n]].
|
||||
@@ -0,0 +1,47 @@
|
||||
---
|
||||
name: paginated-texture-occlusion-4399
|
||||
description: Background texture absent in paginated mode (shown in scrolled) — opaque
|
||||
metadata:
|
||||
node_type: memory
|
||||
type: project
|
||||
originSessionId: 1bd8a73e-f279-4ed8-9562-98e96d9723d5
|
||||
---
|
||||
|
||||
RESOLVED — merged. foliate-js `142bf11` (on main) + readest pointer bump/test
|
||||
(`fix(reader): show background texture in paginated mode (#4399)`).
|
||||
|
||||
#4399: a background texture (Settings → Color → texture, e.g. Leaves) shows in
|
||||
**scrolled** mode but is **absent in paginated** mode. The texture is NOT in the
|
||||
iframe — it's mounted on the HOST as `.foliate-viewer::before` (`src/styles/textures.ts`,
|
||||
`mountBackgroundTexture`): `position:absolute; inset:0; z-index:0; mix-blend-mode:multiply;
|
||||
opacity:.6` over the reader container. For it to show, the whole foliate view tree
|
||||
(iframe page bg + the paginator `#background` layer) must be transparent so the host
|
||||
`::before` composites over the white page fill that a **parent** element provides
|
||||
(`oklch(1 0 0)` on the reader grid cell, NOT on `.foliate-viewer`, which is transparent).
|
||||
|
||||
**Root cause.** foliate-js `paginator.js` `#replaceBackground`. Scrolled mode already
|
||||
left things transparent under a texture (`#background.style.background = hasTexture ? '' : fallbackBg`
|
||||
+ blanking transparent view elements). The **paginated** branch hard-set
|
||||
`this.#background.style.background = fallbackBg` (opaque theme color, e.g. white) on the
|
||||
segment **container** — that opaque container, a descendant of `.foliate-viewer`, paints
|
||||
over the host `::before` texture. The per-view *segments* were already transparent for
|
||||
transparent pages (`rgba(0,0,0,0)`); only the container was the occluder. Verified live
|
||||
via Chrome MCP: `#background` inline bg was `rgb(255,255,255)`; setting it `''` revealed
|
||||
the leaves texture instantly.
|
||||
|
||||
**Regression** = commit `167757a` "Fix background flash when swiping between
|
||||
differently-colored pages" (2026-05-31, see [[paginator-swipe-bg-flash]]). The OLD
|
||||
paginated path was a CSS `grid` of per-column divs and never set the `#background`
|
||||
container background (kept the `''` reset), so a transparent page let the texture through.
|
||||
167757a swapped to `computeBackgroundSegments` and added the opaque container line.
|
||||
|
||||
**Fix.** Extract a shared exported pure helper `textureAwareBackground(resolved, hasTexture)`
|
||||
→ returns `''` when `hasTexture && isTransparent(resolved)`, else `resolved`. Use it for
|
||||
BOTH scrolled view elements and paginated segment bgs, and set the paginated container
|
||||
`this.#background.style.background = hasTexture ? '' : fallbackBg` (mirroring scrolled).
|
||||
No-texture path unchanged → swipe-flash fix for colored pages fully preserved; a
|
||||
book-forced opaque page still paints its segment (texture correctly does not show there).
|
||||
|
||||
Test: `src/__tests__/document/paginator-background-segments.test.ts` (new `describe` for
|
||||
the pure helper, alongside `computeBackgroundSegments`). foliate-js is a submodule —
|
||||
commit there + bump the pointer. See [[paginator-swipe-bg-flash]].
|
||||
@@ -0,0 +1,71 @@
|
||||
---
|
||||
name: paginator-gutter-bleed-asymmetry-4394
|
||||
description: "Paginated page background overflowed its column into the outer --_outer-min gutter (asymmetric on mixed spreads); fix = clamp computeBackgroundSegments to the content area, keep the grid"
|
||||
metadata:
|
||||
node_type: memory
|
||||
type: project
|
||||
originSessionId: 108a70bd-5af9-4a31-9b93-9d6df7637579
|
||||
---
|
||||
|
||||
#4394 "page viewport offset / content clipping." On a 2-up cover spread the
|
||||
left page (cover `<img>`, transparent page bg) had a white outer gutter while
|
||||
the right page (`<body class="c3">` body-colour yellow) bled its colour past its
|
||||
column into the right outer gutter → spread looked shifted right. On a wide
|
||||
screen (1920px, 720 max-inline) the gutters are ~250px so it reads as a "massive
|
||||
white blank gap." 0.10.6 filled colour edge-to-edge; 0.11.2 regressed.
|
||||
|
||||
**Geometry.** Grid `#top` = `minmax(--_outer-min-left,1fr) margin-left
|
||||
minmax(0,maxw) margin-right minmax(--_outer-min-right,1fr)` (foliate
|
||||
`paginator.js`). `#container` = grid-column 2/5 (inset by the outer gutter
|
||||
tracks); `#background` = grid-column 1/-1 (spans the gutters). `--_outer-min =
|
||||
(col_count-1)*(margin/4 + gap/4)` — non-zero ONLY in multi-column (0 in
|
||||
1-column), added in commit `0a0ceda` so the outer margin matches half the
|
||||
inter-column gap (symmetric spacing). `inset = containerLeft - bgLeft =
|
||||
--_outer-min-left`.
|
||||
|
||||
**Cause.** `computeBackgroundSegments` (the swipe-flash fix, commit `167757a`)
|
||||
positioned each page's colour segment at `inset + offset - scrollPos` and then
|
||||
STRETCHED any segment touching a container edge OUT to the bg edge (`start=0` /
|
||||
`end=bgSize`) — i.e. bled into the outer gutter. A body-coloured page bled into
|
||||
its gutter; a transparent/image page (cover) did not (its `#background` is
|
||||
transparent; its `<img>` is clamped inside the inset `#container`). Mixed spread
|
||||
→ one gutter coloured, one not → asymmetric.
|
||||
|
||||
**Fix (what the maintainer wanted).** Do NOT touch `--_outer-min` (it keeps the
|
||||
left/right margins symmetric with the centre gap). Instead CLAMP each segment to
|
||||
the content area so the background stays inside its column and never overflows
|
||||
into the outer gutter:
|
||||
```js
|
||||
const start = Math.max(segStart, containerStart) // was: if(...) start = 0
|
||||
const end = Math.min(segEnd, containerEnd) // was: if(...) end = bgSize
|
||||
if (end <= start) continue
|
||||
```
|
||||
`containerStart=inset`, `containerEnd=inset+containerSize`. In single-column the
|
||||
gutters are 0 (`--_outer-min`=0 → inset 0) so this still fills the viewport edge
|
||||
to edge (matches 0.10.6); in multi-column each page stays in its column with
|
||||
symmetric gutter margins. Image pages (cover/彩页) were already correct (`<img>`
|
||||
sits in its column); only the body-colour `#background` overflow needed fixing.
|
||||
|
||||
**Dead ends (took 2 wrong tries before the maintainer steered me right).**
|
||||
1. Thought it was the gutter-bleed and "gated" the stretch on both-edges-coloured
|
||||
— maintainer: "nothing to do with the gutter" (meaning don't change the bleed
|
||||
GATING, the real issue is the offset).
|
||||
2. Thought the title page shouldn't be yellow at all — it genuinely is
|
||||
`.c3{background:#ffe43f}` on `<body>`, captured as `docBackground` and painted
|
||||
full-bleed (body is zeroed via `doc.body.style.background='none'`, the old
|
||||
f087826 "dismiss iframe background, paint via root" design). Yellow is correct,
|
||||
it was just overflowing.
|
||||
3. Proposed dropping `--_outer-min` (revert `0a0ceda`) — maintainer rejected:
|
||||
that desymmetrises the centre-vs-side gaps. Keep the grid; fix the background.
|
||||
|
||||
**Verifying live.** foliate-js is a submodule; `next dev` / turbopack did NOT
|
||||
hot-reload an edit to `packages/foliate-js/paginator.js` even across full
|
||||
navigations — had to RESTART the dev server (`pnpm dev-web`, port 3000) to pick
|
||||
it up. Cheap interim check: clamp the live `#background` segment divs in the page
|
||||
and screenshot. Segment colours are render-timing dependent (a probe right after
|
||||
nav can show all-transparent before the body-colour is captured).
|
||||
|
||||
Tests: `apps/readest-app/src/__tests__/document/paginator-background-segments.test.ts`
|
||||
(tests "centered page" + "two-up spread" flipped from full-bleed to confined; the
|
||||
inset=0 swipe-flash tests are unchanged). Related: [[paginator-swipe-bg-flash]]
|
||||
[[paginated-texture-occlusion-4399]].
|
||||
@@ -0,0 +1,21 @@
|
||||
---
|
||||
name: pdfjs-vendor-wasm-decoders
|
||||
description: Scanned PDFs blank in CI builds but fine locally — pdfjs wasm decoders (jbig2.wasm) not copied to public/vendor/pdfjs
|
||||
metadata:
|
||||
node_type: memory
|
||||
type: project
|
||||
originSessionId: 9066c0b0-71a6-4db0-92c1-c3ccacf1ff0d
|
||||
---
|
||||
|
||||
CI-built DMG rendered scanned PDFs as blank pages (could still turn pages); text PDFs and EPUBs fine. Local build of the same commit worked. Regression between 0.11.1 (fine) and 0.11.2 (broken).
|
||||
|
||||
**Root cause:** `pdfjs-dist` was bumped 5.4.530 → 5.7.284 in #4143 (commit e8df651d5, between the 0.11.1 and 0.11.2 tags). pdfjs 5.7.x moved image decoders — notably **JBIG2** (the codec used by virtually every black-and-white *scanned* PDF) — from pure JS into WebAssembly modules the worker fetches at runtime from `wasmUrl` (`/vendor/pdfjs/`, set in `packages/foliate-js/pdf.js`: `wasmUrl: pdfjsPath('')`). The `copy-pdfjs-wasm` npm script only copied an allow-list `{openjpeg.wasm,qcms_bg.wasm}` and silently dropped `jbig2.wasm`. **`cpx` does not error when a glob matches nothing**, so the missing decoder was invisible: worker loads, pages turn, JBIG2 decode fails → blank.
|
||||
|
||||
**Why local masked it:** `pnpm build` / `tauri build` do NOT run `setup-vendors`. Local builds reuse whatever stale `public/vendor/pdfjs/` is already on disk (gitignored — `/public/vendor`). The dev's local copy was the old 5.4.530 worker (pure-JS JBIG2) → worked. CI runs `setup-vendors` fresh (release.yml:192) → ships the new 5.7.284 worker that needs jbig2.wasm → broke.
|
||||
|
||||
**Fix:** changed `copy-pdfjs-wasm` to copy the whole `wasm/*` dir instead of an allow-list (mirrors the `{cmaps,standard_fonts}/*` fonts pattern). Robust against future codecs moving to wasm; also ships the `*_nowasm_fallback.js` files for graceful degradation. Regression test: `src/__tests__/document/pdfjs-wasm-assets.test.ts` asserts every `.wasm` the bundled pdf.js references is covered by `copy-pdfjs-wasm`.
|
||||
|
||||
**Gotchas for future:**
|
||||
- Vendor assets in `public/vendor/` are gitignored and only refreshed by `setup-vendors`. Local stale vendor can mask CI breakage — `git status` won't show it.
|
||||
- `cpx` allow-lists are fragile: any upstream-added required file is dropped silently. Prefer copying whole dirs.
|
||||
- Related: [[platform-compat-fixes]], [[bug-patterns]].
|
||||
@@ -0,0 +1,18 @@
|
||||
---
|
||||
name: progressbar-focus-ring-4397
|
||||
description: ProgressBar footer painted a stray focus-ring line on Android long-press; fix = remove tabIndex from the decorative div
|
||||
metadata:
|
||||
node_type: memory
|
||||
type: project
|
||||
originSessionId: 7d650c44-2d60-4a88-a9f5-6b5956bca59b
|
||||
---
|
||||
|
||||
#4397 "Strange line on the bottom of the page when long-pressing the footer" (Android 13, 0.11.2).
|
||||
|
||||
**Symptom:** a thin horizontal line, content-column wide, across the last text line / bottom margin. Appears after long-pressing the footer + turning a page; persists across page turns; cleared by double-tapping the footer + turning a page. Maintainer couldn't repro on eink (device/WebView-specific long-press focus behavior).
|
||||
|
||||
**Root cause:** `ProgressBar.tsx` (the always-on page-info footer, `.progressinfo`) rendered as `<div role='presentation' tabIndex={-1} onClick=...>`. The `tabIndex={-1}` made the decorative div click/touch-focusable. Android WebView long-press focused it and painted the browser default focus ring (`outline: auto`, matched `:focus-visible`). It's a top-document element pinned `absolute bottom-0` at book-view width, so the ring shows as a content-width box at the bottom on *every* page until focus clears. NOT a range/slider input — those are `opacity-0`/`visibility:hidden` (red herring: globals.css excludes `input[type='range']` from its outline-suppression rule, but that's unrelated here).
|
||||
|
||||
**Fix:** remove `tabIndex={-1}`. A `role='presentation'` decorative element must not be focusable. `onClick` (tap-to-cycle progress mode / dismiss annotation popup) fires regardless of tabindex, and no ancestor has a real `tabindex` to grab focus instead (verified: focus falls through to `<body>`). Nothing programmatically focuses `.progressinfo`. Test asserts `.progressinfo` has no `tabindex` attribute (in `ProgressBar.test.tsx`).
|
||||
|
||||
**Debugging technique that worked:** download the issue video (`gh` attachment URL) → `ffmpeg` extract frames → zoom on the line; the **downward corner at the right end** revealed it was a rectangular *outline* (focus box), not a strikethrough/selection. Then in the live dev app (`mcp__claude-in-chrome__javascript_tool`): `el.focus(); el.matches(':focus-visible'); getComputedStyle(el).outlineStyle` → returned `true` / `auto`, confirming the element + mechanism. See [[annotator-reader-fixes]] [[layout-ui-fixes]].
|
||||
@@ -0,0 +1,38 @@
|
||||
---
|
||||
name: table-dark-mode-tint-4419
|
||||
description: "Dark-mode table tint must stay gated on overrideColor; blanket `table *` color-mix breaks plain tables AND vertical-TOC spacer cells"
|
||||
metadata:
|
||||
node_type: memory
|
||||
type: project
|
||||
originSessionId: 114eeb22-c9b6-480d-8305-3a3190855638
|
||||
---
|
||||
|
||||
`getColorStyles` in `src/utils/style.ts` has a rule:
|
||||
`blockquote, table * { background-color: color-mix(in srgb, ${bg} 80%, #000) }` in dark mode.
|
||||
The `table *` part **must** be gated on `overrideColor` — `${isDarkMode && overrideColor ? ...}`.
|
||||
|
||||
**This gate has regressed twice.** #2377 (PR #2379) first added it ("avoid overriding
|
||||
table background by default"). #4055 (commit `cead0f42e`, closes #4028/#4029) **removed**
|
||||
the gate to darken illegible white zebra-stripe rows — which re-broke it and caused #4419:
|
||||
*every* dark-mode table (and its cells) gets a tint a few shades off the page bg, even
|
||||
tables with no background of their own.
|
||||
|
||||
Why the gate is safe now: #4392 (`176b950c9`) added light-background rewriters that map
|
||||
only actually-light backgrounds → page `bg` in dark mode, regardless of overrideColor —
|
||||
`getDarkModeLightBackgroundOverrides` (inline styles) + the `transformStylesheet` dark-mode
|
||||
block (stylesheet rules, `isLightCssColor` luminance > 0.85). So #4028's white zebra rows
|
||||
stay legible without the blanket tint. The blockquote-only tint (standalone `blockquote {}`
|
||||
rule, from #2538) stays unconditional in dark mode — that's intended.
|
||||
|
||||
**Symptom #2 shares this root cause.** #4419 also reported "spacing between words changes"
|
||||
on a vertical (writing-mode) 目录/TOC page. Those CJK web-novel books lay the TOC out as a
|
||||
`<table>` with empty `<td>` spacer columns and `<span class="space">▉</span>` spacers
|
||||
(custom "space" font; ▉ U+2589 is a **blank glyph, contours=0** — pure advance width, no
|
||||
outline). The blanket `table *` rule paints a background on those spacer spans/cells,
|
||||
making the invisible gaps show as tinted blocks → looks like the spacing changed. It's the
|
||||
painted **background**, not text color, that reveals them — recoloring `.co0` (`color:#000`
|
||||
→ fg) does nothing because the glyph has no outline. Gating the tint fixes both symptoms.
|
||||
|
||||
Tests live in `src/__tests__/utils/style-get-styles.test.ts` (assert the `blockquote,
|
||||
table *` block has no `color-mix` when overrideColor false; keep the override-true and
|
||||
blockquote-still-tinted cases). Related: [[css-style-fixes]].
|
||||
@@ -0,0 +1,36 @@
|
||||
---
|
||||
name: tauri-menu-append-race-4389
|
||||
description: "Un-awaited Tauri Menu.append() races on IPC → context menu items shuffle order randomly; fix = single Menu.new({ items })"
|
||||
metadata:
|
||||
node_type: memory
|
||||
type: project
|
||||
originSessionId: 8169b903-f66e-4c35-b90f-6b9110837588
|
||||
---
|
||||
|
||||
#4389 — the bookshelf right-click context menu (Windows/Edge WebView2) showed the
|
||||
same items but in a **randomly changing order** on every open.
|
||||
|
||||
**Root cause:** `Menu.append()` from `@tauri-apps/api/menu` returns `Promise<void>`
|
||||
— each call is an async IPC round-trip to the Rust backend. `BookshelfItem.tsx`
|
||||
fired ~11 `menu.append(item)` calls **without awaiting** (then `menu.popup()` also
|
||||
un-awaited). The concurrent IPC requests resolve in non-deterministic order on the
|
||||
Rust side, so items land shuffled. Synchronous JS call order is correct — the race
|
||||
is purely in async resolution, which is why it's invisible in jsdom and only shows
|
||||
on the native app.
|
||||
|
||||
**Fix:** build the items array in order and create the menu in ONE call:
|
||||
`const menu = await Menu.new({ items })` then `await menu.popup()`.
|
||||
`MenuOptions.items` / `append()` accept plain `MenuItemOptions` (`{ text, action }`)
|
||||
arrays — no need to pre-create `MenuItem.new()` per item at all. Applied to both
|
||||
book and group handlers.
|
||||
|
||||
**Testability:** extracted the order/inclusion logic into a pure
|
||||
`getBookContextMenuItemIds(book): BookContextMenuItemId[]` in
|
||||
`src/app/library/utils/libraryUtils.ts` (dep-light, already test-covered) so the
|
||||
deterministic ordering is unit-testable without mounting the component or mocking
|
||||
Tauri. Test: `src/__tests__/app/library/book-context-menu.test.ts`. The pure fn
|
||||
guards the order contract; the `Menu.new({ items })` wiring is what kills the race.
|
||||
|
||||
**General lesson:** any un-awaited sequence of Tauri/IPC mutations that must stay
|
||||
ordered (append, insert, etc.) is a latent race — batch into one call or await each.
|
||||
See [[bug-patterns]].
|
||||
@@ -0,0 +1,29 @@
|
||||
---
|
||||
name: tauri-parser-parity-tests
|
||||
description: "How to test the native Rust EPUB/MOBI parser against foliate-js in the Tauri WebView suite, plus the dcterms:modified→published parity gotcha"
|
||||
metadata:
|
||||
node_type: memory
|
||||
type: reference
|
||||
originSessionId: 90d14df3-59ce-438f-ae91-b72e9d2b6f99
|
||||
---
|
||||
|
||||
PR #4369 added native Rust EPUB/MOBI parsers (`src-tauri/src/epub_parser.rs`, `mobi_parser.rs`, shared `parser_common.rs`) with foliate-js fallback. Parity between the two parsers is the key risk.
|
||||
|
||||
**Cross-language parity test harness** (`src/__tests__/tauri/epub-parser-parity.tauri.test.ts`):
|
||||
- The `.tauri.test.ts` suite (run by `scripts/test-tauri.sh` / `pnpm test:tauri`, included only by `vitest.tauri.config.mts`) is the ONLY env where both parsers coexist: Rust via `invoke()` (`./tauri-invoke.ts`), foliate-js via `DocumentLoader`. Default `pnpm test` excludes `**/*.tauri.test.ts`.
|
||||
- Rust commands read an absolute on-disk path → build it from `process.env.CWD` (injected by the tauri config = readest-app dir): `${CWD}/src/__tests__/fixtures/data/<name>`. The JS side fetches the SAME file via a Vite URL: `new URL('../fixtures/data/<name>', import.meta.url).href` (same pattern as `paginator-stabilization.browser.test.ts`).
|
||||
- Compare via the app's own normalizers so you compare user-visible values, not raw parser shapes: `formatTitle`, `formatAuthors(authors, lang)` (Rust gives `string[]`, foliate gives `string|Contributor|array` — both through formatAuthors), `getPrimaryLanguage`, `formatPublisher`, `formatDescription`.
|
||||
- **Cover is presence-only**: Rust downscales/re-encodes the cover to a ~512px JPEG (`parser_common::maybe_resize_cover`), so bytes never match foliate's original — assert `(rust.coverBase64 != null) === ((await js.getCover()) != null)`.
|
||||
- **Description needs whitespace-collapse**: foliate collapses an internal source newline to a space; Rust preserves the raw `\n`. Compare `formatDescription(x).replace(/\s+/g,' ').trim()`.
|
||||
- Section `href` is undefined on foliate `SectionItem` — compare sections by `{id,size,linear}` only. `linear` can be `null`.
|
||||
- Strongest test = open the same EPUB twice through `DocumentLoader` (with `{nativeFilePath}` → Rust prefetch, and without → pure zip.js) and assert identical `metadata`, sections, `toc`, and `computeBookNav` output (toc tree + per-section fragment CFIs/sizes). This validates `parse_epub_full` prefetch + the parallelized TOC pipeline are behavior-preserving in one shot. `isTauriAppPlatform()` is true in the webview (`.env.tauri` sets `NEXT_PUBLIC_APP_PLATFORM=tauri`) so the prefetch fires.
|
||||
- No `.mobi`/`.azw3` fixture exists anywhere in the repo → MOBI parity is NOT covered by this harness; add a Kindle fixture to extend. Rust MOBI is covered by `mobi_parser`'s own unit tests.
|
||||
- Offline verification trick (no chromedriver/tauri-driver locally): dump foliate `book.metadata` via a temp node vitest test, dump Rust `parse_epub_metadata_sync`/`compute_partial_md5` via a temp `#[cfg(test)]` mod using `env!("CARGO_MANIFEST_DIR")/../src/__tests__/fixtures/data`, diff. Lets you lock every expected value before CI runs the WebView suite.
|
||||
|
||||
**Running the `.tauri.test.ts` suite locally on macOS** (no chromedriver/tauri-driver needed — `tauri-plugin-webdriver` 0.2 embeds an axum WebDriver server with a `platform/macos.rs` WKWebView impl, default port 4445):
|
||||
- `pnpm test:tauri` runs `scripts/test-tauri.sh`: starts `next dev` on :3000, `tauri dev --features webdriver --no-watch`, waits for :4445/status, then `vitest --config vitest.tauri.config.mts`. Its cleanup does `lsof -ti :3000 | xargs kill` — so it KILLS whatever is on :3000 (e.g. another worktree's dev server). To avoid that, copy the script and shift `DEV_PORT`/`next dev -p`/`--config devUrl` to a free port (e.g. 3100). Run via `pnpm exec bash <script>` so npm bins (`dotenv`, `next`, `tauri`, `vitest`) resolve, not the Ruby `dotenv` gem.
|
||||
- **Single-instance blocker**: `tauri_plugin_single_instance` (lib.rs, keyed on bundle id `com.bilingify.readest`) is registered unconditionally for desktop and is NOT gated by the webdriver feature. If `/Applications/Readest.app` (or any Readest instance) is running, the test instance forwards to it and exits → "Tauri exited before WebDriver ready." Quit Readest.app before running. CI doesn't hit this (Linux, no Readest installed).
|
||||
- **`vitest.tauri.config.mts` needs an `optimizeDeps` block** mirroring `vitest.browser.config.mts` (include the CJS deps `js-md5`/`@zip.js/zip.js`/`franc-min`/`iso-639-*`/etc.; exclude `@pdfjs/pdf.min.mjs` + the turso wasm). Without it, esbuild's dep-scan can't resolve foliate-js's `import '@pdfjs/pdf.min.mjs'`, skips pre-bundling, and any tauri test that imports `@/libs/document` fails to load with "Importing a module script failed" on a cold `.vite` cache. (A warm cache from a prior run masks it.)
|
||||
- **Pre-existing failure, not parity-related**: `tauri-app-service.tauri.test.ts` has 13 failures on macOS — `forbidden path: …/src/__tests__/fixtures/data/sample-alice.epub … allow-open permission`. importBook's library-copy goes through the fs plugin (scoped), and `capabilities-extra/webdriver.json` doesn't grant the fixtures dir. Confirmed identical at base config (no regression from parity work). Parser parity tests avoid this: Rust `File::open` is unscoped, and the JS side fetches fixtures via a Vite URL. To fix separately, add the fixtures path to the webdriver capability scope.
|
||||
|
||||
**Parity gotcha found + fixed**: Rust `handle_meta` mapped `dc:date` AND `dcterms:modified` → `published`. foliate keeps `dcterms:modified` as a separate `modified` field and leaves `published` empty. EPUB3 mandates `dcterms:modified`, so native-imported EPUB3 books with no `<dc:date>` got a bogus publication date. Fix: map only `"date"` (not `"modified"`) to published. Regression tests: `epub_parser::tests::{dcterms_modified_does_not_populate_published, dc_date_populates_published}`.
|
||||
@@ -0,0 +1,37 @@
|
||||
---
|
||||
name: txt-author-recognition-4390
|
||||
metadata:
|
||||
node_type: memory
|
||||
type: project
|
||||
originSessionId: f151827f-0bf2-4307-ac92-e6077df54d19
|
||||
---
|
||||
|
||||
Issue #4390: imported Chinese web-novels showed author either **missing (未知)** or
|
||||
as an **irrelevant metadata blob** (e.g. `2024/08/01发表于:是否首发:是字数1023150字116:01`).
|
||||
|
||||
**Key debugging tell:** the displayed *title* was the **entire filename** including
|
||||
`作者:X` (e.g. `【月如无恨月长圆】(1-154)作者:陈西`). A real EPUB's `dc:title` would
|
||||
be just the book name. Full-filename title ⇒ the file went through Readest's
|
||||
**TXT→EPUB converter** (`bookService.ts` `/\.txt$/` → `TxtToEpubConverter`), which
|
||||
sets `bookTitle = base filename` in the no-`《》` branch of `extractTxtFilenameMetadata`.
|
||||
So "EPUB format" books can still be TXT-origin — check `src/utils/txt.ts`, not foliate-js,
|
||||
when title looks like a filename. (Native Rust EPUB parser #4369 was NOT shipped in 0.11.2.)
|
||||
|
||||
Two root causes in `src/utils/txt.ts`:
|
||||
1. `extractTxtFilenameMetadata` only pulled an author from `《》`-wrapped names; `【】`
|
||||
names (the web-novel norm) got no filename author.
|
||||
2. Greedy header capture `/[【\[]?作者[】\]]?[::\s]\s*(.+)\r?\n/` grabbed a whole noisy
|
||||
metadata line as the author.
|
||||
|
||||
Fix:
|
||||
- `parseLabeledAuthor(base)` extracts the labeled `作者:X` from ANY filename; title stays
|
||||
the full name. Only the *labeled* form is safe on a full filename — a bracketed/bare
|
||||
fallback would mistake a leading `【title】` for the author.
|
||||
- `isPlausibleAuthorName()` rejects a header match that looks like a blob (contains `:`/`:`,
|
||||
a `\d{4,}` run, or length > 20) → falls back to the filename author. Applied in BOTH
|
||||
`convertSmallFile` and `extractAuthorAndLanguage` (large-file path).
|
||||
|
||||
Note: the `著` form is handled for file *content* headers but NOT for filenames (kept minimal).
|
||||
Reported files were 455 kB / 1.25 MB → both under the 8 MB `LARGE_TXT_THRESHOLD_BYTES`
|
||||
(`convertSmallFile` path). Tests live in `txt-converter.test.ts` (faithful repro of both
|
||||
reported strings). Related: [[bug-patterns]].
|
||||
@@ -0,0 +1,18 @@
|
||||
---
|
||||
name: window-state-sanitize-4398
|
||||
description: Windows launch crash (WebView2 0x80070057) from invalid .window-state.json; defensive sanitizer plugin
|
||||
metadata:
|
||||
node_type: memory
|
||||
type: project
|
||||
originSessionId: 40a168fd-c90e-4f00-9fbc-7effe7c0c45f
|
||||
---
|
||||
|
||||
Issue #4398 (PR #4401) — Windows app fails to launch ("WebView2 error 0x80070057 / The parameter is incorrect") when `%APPDATA%\com.bilingify.readest\.window-state.json` holds the minimized sentinel (`x/y = -32000`) or `0×0` size.
|
||||
|
||||
Non-obvious facts:
|
||||
- Our `tauri-plugin-window-state` (2.4.1, and every shipped 2.2.1+) ALREADY contains the upstream fix for [tauri-apps/plugins-workspace#253](https://github.com/tauri-apps/plugins-workspace/issues/253): `update_state` + the Moved/Resized handlers skip saving while `is_minimized()`, and restore only re-applies position if it `intersects()` a monitor. So current builds shouldn't generate bad state — a bad file is almost certainly stale from an old build. The version-inquiry comment is on the issue.
|
||||
- Fix = **defense-in-depth**, NOT a behavior change: `src-tauri/src/window_state.rs` — a tiny `window-state-sanitizer` Tauri plugin whose `setup` strips window entries with invalid geometry (`width<=0 || height<=0 || x<=-30000 || y<=-30000`) from the file before window-state reads it. Pure `sanitize_json(&str)->Option<String>` is unit-tested (`cargo test -p Readest window_state`).
|
||||
- **Plugin init order matters**: Tauri's `PluginStore::initialize_all` iterates the store in registration order (Vec push), and window-state loads the file in its `setup` (runs during `initialize`). So the sanitizer plugin MUST be registered BEFORE `tauri_plugin_window_state` in `lib.rs` (it is). The app-level `.setup()` closure runs AFTER all plugin setups → too late to sanitize there.
|
||||
- Coord threshold is `-16000` (sentinel is exactly `-32000`; ~halfway). Real desktops sit a few thousand px off origin (4K-left = `-3840`, 3×4K ≈ `-11520`), so `-16000` is well past normal use yet clearly the sentinel zone; a legit negative like `-1920` is kept. Do NOT justify by extreme N-monitor walls (user pushed back on that). The size check (`<=0`) is the real WebView2-crash guard; this coord check is secondary (the plugin already refuses to restore an off-screen position via its `intersects()` monitor check). Missing fields default to valid so a schema change never drops a good entry.
|
||||
|
||||
Repo Rust testing reality: zero pre-existing `#[cfg(test)]` modules; CI only runs `cargo fmt --check` + `cargo clippy -p Readest --no-deps -D warnings` (NOT `--all-targets`, so it won't compile test code). `generate_context!` tolerates a missing `frontendDist` (`../out`), so `cargo test`/clippy build without a frontend build. See [[tauri-parser-parity-tests]].
|
||||
@@ -45,7 +45,7 @@
|
||||
"format:check": "pnpm -w format:check",
|
||||
"prepare-public-vendor": "mkdirp ./public/vendor/pdfjs ./public/vendor/simplecc ./public/vendor/jieba",
|
||||
"copy-pdfjs-js": "cpx \"../../packages/foliate-js/node_modules/pdfjs-dist/legacy/build/{pdf.worker.min.mjs,pdf.min.mjs,pdf.d.mts}\" ./public/vendor/pdfjs",
|
||||
"copy-pdfjs-wasm": "cpx \"../../packages/foliate-js/node_modules/pdfjs-dist/wasm/{openjpeg.wasm,qcms_bg.wasm}\" ./public/vendor/pdfjs",
|
||||
"copy-pdfjs-wasm": "cpx \"../../packages/foliate-js/node_modules/pdfjs-dist/wasm/*\" ./public/vendor/pdfjs",
|
||||
"copy-pdfjs-fonts": "cpx \"../../packages/foliate-js/node_modules/pdfjs-dist/{cmaps,standard_fonts}/*\" ./public/vendor/pdfjs",
|
||||
"copy-flatten-pdfjs-annotation-layer-css": "npx postcss \"../../packages/foliate-js/vendor/pdfjs/annotation_layer_builder.css\" --no-map -u postcss-nested > ./public/vendor/pdfjs/annotation_layer_builder.css",
|
||||
"copy-flatten-pdfjs-text-layer-css": "npx postcss \"../../packages/foliate-js/vendor/pdfjs/text_layer_builder.css\" --no-map -u postcss-nested > ./public/vendor/pdfjs/text_layer_builder.css",
|
||||
@@ -124,6 +124,7 @@
|
||||
"@supabase/supabase-js": "^2.76.1",
|
||||
"@tauri-apps/api": "2.10.1",
|
||||
"@tauri-apps/plugin-cli": "^2.4.1",
|
||||
"@tauri-apps/plugin-clipboard-manager": "^2.3.0",
|
||||
"@tauri-apps/plugin-deep-link": "^2.4.7",
|
||||
"@tauri-apps/plugin-dialog": "^2.6.0",
|
||||
"@tauri-apps/plugin-fs": "^2.4.5",
|
||||
|
||||
@@ -1827,5 +1827,9 @@
|
||||
"No Annotations": "لا توجد تعليقات توضيحية",
|
||||
"No Bookmarks": "لا توجد إشارات مرجعية",
|
||||
"Select some text to highlight": "حدّد نصًا لتمييزه",
|
||||
"Bookmark This Page": "أضف إشارة مرجعية لهذه الصفحة"
|
||||
"Bookmark This Page": "أضف إشارة مرجعية لهذه الصفحة",
|
||||
"Book file is not available locally": "ملف الكتاب غير متوفر محليًا",
|
||||
"Failed to send book": "فشل إرسال الكتاب",
|
||||
"Send": "إرسال",
|
||||
"Highlight Current Sentence": "تمييز الجملة الحالية"
|
||||
}
|
||||
|
||||
@@ -1695,5 +1695,9 @@
|
||||
"No Annotations": "কোনো টীকা নেই",
|
||||
"No Bookmarks": "কোনো বুকমার্ক নেই",
|
||||
"Select some text to highlight": "হাইলাইট করতে কিছু লেখা নির্বাচন করুন",
|
||||
"Bookmark This Page": "এই পৃষ্ঠা বুকমার্ক করুন"
|
||||
"Bookmark This Page": "এই পৃষ্ঠা বুকমার্ক করুন",
|
||||
"Book file is not available locally": "বইয়ের ফাইলটি স্থানীয়ভাবে উপলব্ধ নয়",
|
||||
"Failed to send book": "বই পাঠাতে ব্যর্থ হয়েছে",
|
||||
"Send": "পাঠান",
|
||||
"Highlight Current Sentence": "বর্তমান বাক্য হাইলাইট করুন"
|
||||
}
|
||||
|
||||
@@ -1662,5 +1662,9 @@
|
||||
"No Annotations": "མཆན་མེད།",
|
||||
"No Bookmarks": "དཔེ་རྟགས་མེད།",
|
||||
"Select some text to highlight": "འོག་ཐིག་གཏོང་བར་ཡི་གེ་འདེམས་རོགས།",
|
||||
"Bookmark This Page": "ཤོག་ངོས་འདིར་དཔེ་རྟགས་འགོད།"
|
||||
"Bookmark This Page": "ཤོག་ངོས་འདིར་དཔེ་རྟགས་འགོད།",
|
||||
"Book file is not available locally": "དེབ་ཀྱི་ཡིག་ཆ་དེ་ས་གནས་སུ་མི་འདུག",
|
||||
"Failed to send book": "དེབ་སྐུར་གཏོང་མ་ཐུབ།",
|
||||
"Send": "སྐུར་གཏོང་།",
|
||||
"Highlight Current Sentence": "ད་ལྟའི་ཚིག་གྲུབ་ལ་འོག་ཐིག་གཏོང་།"
|
||||
}
|
||||
|
||||
@@ -1695,5 +1695,9 @@
|
||||
"No Annotations": "Keine Anmerkungen",
|
||||
"No Bookmarks": "Keine Lesezeichen",
|
||||
"Select some text to highlight": "Wähle Text zum Hervorheben aus",
|
||||
"Bookmark This Page": "Diese Seite mit Lesezeichen versehen"
|
||||
"Bookmark This Page": "Diese Seite mit Lesezeichen versehen",
|
||||
"Book file is not available locally": "Buchdatei ist nicht lokal verfügbar",
|
||||
"Failed to send book": "Senden des Buchs fehlgeschlagen",
|
||||
"Send": "Senden",
|
||||
"Highlight Current Sentence": "Aktuellen Satz hervorheben"
|
||||
}
|
||||
|
||||
@@ -1695,5 +1695,9 @@
|
||||
"No Annotations": "Κανένας σχολιασμός",
|
||||
"No Bookmarks": "Κανένας σελιδοδείκτης",
|
||||
"Select some text to highlight": "Επιλέξτε κείμενο για επισήμανση",
|
||||
"Bookmark This Page": "Προσθήκη σελιδοδείκτη σε αυτή τη σελίδα"
|
||||
"Bookmark This Page": "Προσθήκη σελιδοδείκτη σε αυτή τη σελίδα",
|
||||
"Book file is not available locally": "Το αρχείο του βιβλίου δεν είναι διαθέσιμο τοπικά",
|
||||
"Failed to send book": "Αποτυχία αποστολής βιβλίου",
|
||||
"Send": "Αποστολή",
|
||||
"Highlight Current Sentence": "Επισήμανση τρέχουσας πρότασης"
|
||||
}
|
||||
|
||||
@@ -1728,5 +1728,9 @@
|
||||
"No Annotations": "Sin anotaciones",
|
||||
"No Bookmarks": "Sin marcadores",
|
||||
"Select some text to highlight": "Selecciona texto para resaltar",
|
||||
"Bookmark This Page": "Añadir esta página a marcadores"
|
||||
"Bookmark This Page": "Añadir esta página a marcadores",
|
||||
"Book file is not available locally": "El archivo del libro no está disponible localmente",
|
||||
"Failed to send book": "No se pudo enviar el libro",
|
||||
"Send": "Enviar",
|
||||
"Highlight Current Sentence": "Resaltar la oración actual"
|
||||
}
|
||||
|
||||
@@ -1695,5 +1695,9 @@
|
||||
"No Annotations": "هیچ حاشیهنویسیای نیست",
|
||||
"No Bookmarks": "هیچ نشانکی نیست",
|
||||
"Select some text to highlight": "برای هایلایت کردن، متنی را انتخاب کنید",
|
||||
"Bookmark This Page": "افزودن این صفحه به نشانکها"
|
||||
"Bookmark This Page": "افزودن این صفحه به نشانکها",
|
||||
"Book file is not available locally": "فایل کتاب به صورت محلی در دسترس نیست",
|
||||
"Failed to send book": "ارسال کتاب ناموفق بود",
|
||||
"Send": "ارسال",
|
||||
"Highlight Current Sentence": "برجستهسازی جملهٔ فعلی"
|
||||
}
|
||||
|
||||
@@ -1728,5 +1728,9 @@
|
||||
"No Annotations": "Aucune annotation",
|
||||
"No Bookmarks": "Aucun signet",
|
||||
"Select some text to highlight": "Sélectionnez du texte à surligner",
|
||||
"Bookmark This Page": "Ajouter cette page aux signets"
|
||||
"Bookmark This Page": "Ajouter cette page aux signets",
|
||||
"Book file is not available locally": "Le fichier du livre n'est pas disponible localement",
|
||||
"Failed to send book": "Échec de l'envoi du livre",
|
||||
"Send": "Envoyer",
|
||||
"Highlight Current Sentence": "Surligner la phrase actuelle"
|
||||
}
|
||||
|
||||
@@ -1728,5 +1728,9 @@
|
||||
"No Annotations": "אין הדגשות",
|
||||
"No Bookmarks": "אין סימניות",
|
||||
"Select some text to highlight": "בחרו טקסט להדגשה",
|
||||
"Bookmark This Page": "הוסיפו דף זה לסימניות"
|
||||
"Bookmark This Page": "הוסיפו דף זה לסימניות",
|
||||
"Book file is not available locally": "קובץ הספר אינו זמין באופן מקומי",
|
||||
"Failed to send book": "שליחת הספר נכשלה",
|
||||
"Send": "שליחה",
|
||||
"Highlight Current Sentence": "הדגשת המשפט הנוכחי"
|
||||
}
|
||||
|
||||
@@ -1695,5 +1695,9 @@
|
||||
"No Annotations": "कोई टिप्पणी नहीं",
|
||||
"No Bookmarks": "कोई बुकमार्क नहीं",
|
||||
"Select some text to highlight": "हाइलाइट करने के लिए कुछ टेक्स्ट चुनें",
|
||||
"Bookmark This Page": "इस पृष्ठ को बुकमार्क करें"
|
||||
"Bookmark This Page": "इस पृष्ठ को बुकमार्क करें",
|
||||
"Book file is not available locally": "पुस्तक फ़ाइल स्थानीय रूप से उपलब्ध नहीं है",
|
||||
"Failed to send book": "पुस्तक भेजने में विफल",
|
||||
"Send": "भेजें",
|
||||
"Highlight Current Sentence": "वर्तमान वाक्य को हाइलाइट करें"
|
||||
}
|
||||
|
||||
@@ -1695,5 +1695,9 @@
|
||||
"No Annotations": "Nincsenek megjegyzések",
|
||||
"No Bookmarks": "Nincsenek könyvjelzők",
|
||||
"Select some text to highlight": "Jelölj ki szöveget a kiemeléshez",
|
||||
"Bookmark This Page": "Oldal hozzáadása a könyvjelzőkhöz"
|
||||
"Bookmark This Page": "Oldal hozzáadása a könyvjelzőkhöz",
|
||||
"Book file is not available locally": "A könyvfájl nem érhető el helyileg",
|
||||
"Failed to send book": "A könyv küldése sikertelen",
|
||||
"Send": "Küldés",
|
||||
"Highlight Current Sentence": "Aktuális mondat kiemelése"
|
||||
}
|
||||
|
||||
@@ -1662,5 +1662,9 @@
|
||||
"No Annotations": "Tidak ada anotasi",
|
||||
"No Bookmarks": "Tidak ada penanda",
|
||||
"Select some text to highlight": "Pilih teks untuk disorot",
|
||||
"Bookmark This Page": "Tandai halaman ini"
|
||||
"Bookmark This Page": "Tandai halaman ini",
|
||||
"Book file is not available locally": "Berkas buku tidak tersedia secara lokal",
|
||||
"Failed to send book": "Gagal mengirim buku",
|
||||
"Send": "Kirim",
|
||||
"Highlight Current Sentence": "Sorot kalimat saat ini"
|
||||
}
|
||||
|
||||
@@ -1728,5 +1728,9 @@
|
||||
"No Annotations": "Nessuna annotazione",
|
||||
"No Bookmarks": "Nessun segnalibro",
|
||||
"Select some text to highlight": "Seleziona del testo da evidenziare",
|
||||
"Bookmark This Page": "Aggiungi questa pagina ai segnalibri"
|
||||
"Bookmark This Page": "Aggiungi questa pagina ai segnalibri",
|
||||
"Book file is not available locally": "Il file del libro non è disponibile localmente",
|
||||
"Failed to send book": "Invio del libro non riuscito",
|
||||
"Send": "Invia",
|
||||
"Highlight Current Sentence": "Evidenzia la frase corrente"
|
||||
}
|
||||
|
||||
@@ -1662,5 +1662,9 @@
|
||||
"No Annotations": "注釈がありません",
|
||||
"No Bookmarks": "ブックマークがありません",
|
||||
"Select some text to highlight": "ハイライトするテキストを選択してください",
|
||||
"Bookmark This Page": "このページをブックマーク"
|
||||
"Bookmark This Page": "このページをブックマーク",
|
||||
"Book file is not available locally": "書籍ファイルがローカルに存在しません",
|
||||
"Failed to send book": "書籍の送信に失敗しました",
|
||||
"Send": "送信",
|
||||
"Highlight Current Sentence": "現在の文をハイライト"
|
||||
}
|
||||
|
||||
@@ -1662,5 +1662,9 @@
|
||||
"No Annotations": "주석 없음",
|
||||
"No Bookmarks": "북마크 없음",
|
||||
"Select some text to highlight": "하이라이트할 텍스트를 선택하세요",
|
||||
"Bookmark This Page": "이 페이지 북마크"
|
||||
"Bookmark This Page": "이 페이지 북마크",
|
||||
"Book file is not available locally": "책 파일을 로컬에서 사용할 수 없습니다",
|
||||
"Failed to send book": "책 보내기에 실패했습니다",
|
||||
"Send": "보내기",
|
||||
"Highlight Current Sentence": "현재 문장 강조"
|
||||
}
|
||||
|
||||
@@ -1662,5 +1662,9 @@
|
||||
"No Annotations": "Tiada anotasi",
|
||||
"No Bookmarks": "Tiada penanda buku",
|
||||
"Select some text to highlight": "Pilih teks untuk ditonjolkan",
|
||||
"Bookmark This Page": "Tanda halaman ini"
|
||||
"Bookmark This Page": "Tanda halaman ini",
|
||||
"Book file is not available locally": "Fail buku tidak tersedia secara setempat",
|
||||
"Failed to send book": "Gagal menghantar buku",
|
||||
"Send": "Hantar",
|
||||
"Highlight Current Sentence": "Serlahkan ayat semasa"
|
||||
}
|
||||
|
||||
@@ -1695,5 +1695,9 @@
|
||||
"No Annotations": "Geen aantekeningen",
|
||||
"No Bookmarks": "Geen bladwijzers",
|
||||
"Select some text to highlight": "Selecteer tekst om te markeren",
|
||||
"Bookmark This Page": "Deze pagina als bladwijzer toevoegen"
|
||||
"Bookmark This Page": "Deze pagina als bladwijzer toevoegen",
|
||||
"Book file is not available locally": "Boekbestand is niet lokaal beschikbaar",
|
||||
"Failed to send book": "Verzenden van boek mislukt",
|
||||
"Send": "Verzenden",
|
||||
"Highlight Current Sentence": "Huidige zin markeren"
|
||||
}
|
||||
|
||||
@@ -1761,5 +1761,9 @@
|
||||
"No Annotations": "Brak adnotacji",
|
||||
"No Bookmarks": "Brak zakładek",
|
||||
"Select some text to highlight": "Wybierz tekst do podświetlenia",
|
||||
"Bookmark This Page": "Dodaj tę stronę do zakładek"
|
||||
"Bookmark This Page": "Dodaj tę stronę do zakładek",
|
||||
"Book file is not available locally": "Plik książki nie jest dostępny lokalnie",
|
||||
"Failed to send book": "Nie udało się wysłać książki",
|
||||
"Send": "Wyślij",
|
||||
"Highlight Current Sentence": "Podświetl bieżące zdanie"
|
||||
}
|
||||
|
||||
@@ -1728,5 +1728,9 @@
|
||||
"No Annotations": "Nenhuma anotação",
|
||||
"No Bookmarks": "Nenhum marcador",
|
||||
"Select some text to highlight": "Selecione um texto para destacar",
|
||||
"Bookmark This Page": "Adicionar esta página aos marcadores"
|
||||
"Bookmark This Page": "Adicionar esta página aos marcadores",
|
||||
"Book file is not available locally": "O arquivo do livro não está disponível localmente",
|
||||
"Failed to send book": "Falha ao enviar o livro",
|
||||
"Send": "Enviar",
|
||||
"Highlight Current Sentence": "Destacar a frase atual"
|
||||
}
|
||||
|
||||
@@ -1728,5 +1728,9 @@
|
||||
"No Annotations": "Sem anotações",
|
||||
"No Bookmarks": "Sem marcadores",
|
||||
"Select some text to highlight": "Selecione texto para destacar",
|
||||
"Bookmark This Page": "Adicionar esta página aos marcadores"
|
||||
"Bookmark This Page": "Adicionar esta página aos marcadores",
|
||||
"Book file is not available locally": "O ficheiro do livro não está disponível localmente",
|
||||
"Failed to send book": "Falha ao enviar o livro",
|
||||
"Send": "Enviar",
|
||||
"Highlight Current Sentence": "Destacar a frase atual"
|
||||
}
|
||||
|
||||
@@ -1728,5 +1728,9 @@
|
||||
"No Annotations": "Nicio adnotare",
|
||||
"No Bookmarks": "Niciun marcaj",
|
||||
"Select some text to highlight": "Selectează text pentru evidențiere",
|
||||
"Bookmark This Page": "Adaugă această pagină la marcaje"
|
||||
"Bookmark This Page": "Adaugă această pagină la marcaje",
|
||||
"Book file is not available locally": "Fișierul cărții nu este disponibil local",
|
||||
"Failed to send book": "Trimiterea cărții a eșuat",
|
||||
"Send": "Trimite",
|
||||
"Highlight Current Sentence": "Evidențiază propoziția curentă"
|
||||
}
|
||||
|
||||
@@ -1761,5 +1761,9 @@
|
||||
"No Annotations": "Нет аннотаций",
|
||||
"No Bookmarks": "Нет закладок",
|
||||
"Select some text to highlight": "Выберите текст для выделения",
|
||||
"Bookmark This Page": "Добавить эту страницу в закладки"
|
||||
"Bookmark This Page": "Добавить эту страницу в закладки",
|
||||
"Book file is not available locally": "Файл книги недоступен локально",
|
||||
"Failed to send book": "Не удалось отправить книгу",
|
||||
"Send": "Отправить",
|
||||
"Highlight Current Sentence": "Выделять текущее предложение"
|
||||
}
|
||||
|
||||
@@ -1695,5 +1695,9 @@
|
||||
"No Annotations": "අනුසටහන් නැත",
|
||||
"No Bookmarks": "පොත් සලකුණු නැත",
|
||||
"Select some text to highlight": "ඉස්මතු කිරීමට පෙළක් තෝරන්න",
|
||||
"Bookmark This Page": "මෙම පිටුව පොත් සලකුණක් කරන්න"
|
||||
"Bookmark This Page": "මෙම පිටුව පොත් සලකුණක් කරන්න",
|
||||
"Book file is not available locally": "පොත් ගොනුව දේශීයව ලබා ගත නොහැක",
|
||||
"Failed to send book": "පොත යැවීමට අසමත් විය",
|
||||
"Send": "යවන්න",
|
||||
"Highlight Current Sentence": "වර්තමාන වාක්යය ඉස්මතු කරන්න"
|
||||
}
|
||||
|
||||
@@ -1761,5 +1761,9 @@
|
||||
"No Annotations": "Ni opomb",
|
||||
"No Bookmarks": "Ni zaznamkov",
|
||||
"Select some text to highlight": "Izberite besedilo za označevanje",
|
||||
"Bookmark This Page": "Dodaj to stran med zaznamke"
|
||||
"Bookmark This Page": "Dodaj to stran med zaznamke",
|
||||
"Book file is not available locally": "Datoteka knjige ni na voljo lokalno",
|
||||
"Failed to send book": "Pošiljanje knjige ni uspelo",
|
||||
"Send": "Pošlji",
|
||||
"Highlight Current Sentence": "Označi trenutni stavek"
|
||||
}
|
||||
|
||||
@@ -1695,5 +1695,9 @@
|
||||
"No Annotations": "Inga kommentarer",
|
||||
"No Bookmarks": "Inga bokmärken",
|
||||
"Select some text to highlight": "Markera text att framhäva",
|
||||
"Bookmark This Page": "Bokmärk den här sidan"
|
||||
"Bookmark This Page": "Bokmärk den här sidan",
|
||||
"Book file is not available locally": "Bokfilen är inte tillgänglig lokalt",
|
||||
"Failed to send book": "Det gick inte att skicka boken",
|
||||
"Send": "Skicka",
|
||||
"Highlight Current Sentence": "Markera aktuell mening"
|
||||
}
|
||||
|
||||
@@ -1695,5 +1695,9 @@
|
||||
"No Annotations": "சிறுகுறிப்புகள் இல்லை",
|
||||
"No Bookmarks": "புக்மார்க்குகள் இல்லை",
|
||||
"Select some text to highlight": "சிறப்பிக்க உரையைத் தேர்ந்தெடுக்கவும்",
|
||||
"Bookmark This Page": "இந்தப் பக்கத்தைப் புக்மார்க் செய்யவும்"
|
||||
"Bookmark This Page": "இந்தப் பக்கத்தைப் புக்மார்க் செய்யவும்",
|
||||
"Book file is not available locally": "புத்தக கோப்பு உள்ளூரில் கிடைக்கவில்லை",
|
||||
"Failed to send book": "புத்தகத்தை அனுப்ப முடியவில்லை",
|
||||
"Send": "அனுப்பு",
|
||||
"Highlight Current Sentence": "தற்போதைய வாக்கியத்தை தனிப்படுத்து"
|
||||
}
|
||||
|
||||
@@ -1662,5 +1662,9 @@
|
||||
"No Annotations": "ไม่มีคำอธิบายประกอบ",
|
||||
"No Bookmarks": "ไม่มีบุ๊กมาร์ก",
|
||||
"Select some text to highlight": "เลือกข้อความเพื่อไฮไลต์",
|
||||
"Bookmark This Page": "บุ๊กมาร์กหน้านี้"
|
||||
"Bookmark This Page": "บุ๊กมาร์กหน้านี้",
|
||||
"Book file is not available locally": "ไม่มีไฟล์หนังสือในเครื่อง",
|
||||
"Failed to send book": "ส่งหนังสือไม่สำเร็จ",
|
||||
"Send": "ส่ง",
|
||||
"Highlight Current Sentence": "ไฮไลต์ประโยคปัจจุบัน"
|
||||
}
|
||||
|
||||
@@ -1695,5 +1695,9 @@
|
||||
"No Annotations": "Açıklama yok",
|
||||
"No Bookmarks": "Yer işareti yok",
|
||||
"Select some text to highlight": "Vurgulamak için metin seçin",
|
||||
"Bookmark This Page": "Bu Sayfayı Yer İşaretine Ekle"
|
||||
"Bookmark This Page": "Bu Sayfayı Yer İşaretine Ekle",
|
||||
"Book file is not available locally": "Kitap dosyası yerel olarak mevcut değil",
|
||||
"Failed to send book": "Kitap gönderilemedi",
|
||||
"Send": "Gönder",
|
||||
"Highlight Current Sentence": "Geçerli cümleyi vurgula"
|
||||
}
|
||||
|
||||
@@ -1761,5 +1761,9 @@
|
||||
"No Annotations": "Немає анотацій",
|
||||
"No Bookmarks": "Немає закладок",
|
||||
"Select some text to highlight": "Виділіть текст для підсвічування",
|
||||
"Bookmark This Page": "Додати цю сторінку до закладок"
|
||||
"Bookmark This Page": "Додати цю сторінку до закладок",
|
||||
"Book file is not available locally": "Файл книги недоступний локально",
|
||||
"Failed to send book": "Не вдалося надіслати книгу",
|
||||
"Send": "Надіслати",
|
||||
"Highlight Current Sentence": "Виділяти поточне речення"
|
||||
}
|
||||
|
||||
@@ -1695,5 +1695,9 @@
|
||||
"No Annotations": "Izohlar yoʻq",
|
||||
"No Bookmarks": "Xatchoʻplar yoʻq",
|
||||
"Select some text to highlight": "Belgilash uchun matn tanlang",
|
||||
"Bookmark This Page": "Bu sahifani xatchoʻpga qoʻshish"
|
||||
"Bookmark This Page": "Bu sahifani xatchoʻpga qoʻshish",
|
||||
"Book file is not available locally": "Kitob fayli mahalliy tarzda mavjud emas",
|
||||
"Failed to send book": "Kitobni yuborib bo'lmadi",
|
||||
"Send": "Yuborish",
|
||||
"Highlight Current Sentence": "Joriy jumlani ajratib ko'rsatish"
|
||||
}
|
||||
|
||||
@@ -1662,5 +1662,9 @@
|
||||
"No Annotations": "Không có chú thích",
|
||||
"No Bookmarks": "Không có dấu trang",
|
||||
"Select some text to highlight": "Chọn một đoạn văn bản để tô sáng",
|
||||
"Bookmark This Page": "Đánh dấu trang này"
|
||||
"Bookmark This Page": "Đánh dấu trang này",
|
||||
"Book file is not available locally": "Tệp sách không có sẵn trên thiết bị",
|
||||
"Failed to send book": "Gửi sách thất bại",
|
||||
"Send": "Gửi",
|
||||
"Highlight Current Sentence": "Tô sáng câu hiện tại"
|
||||
}
|
||||
|
||||
@@ -1662,5 +1662,9 @@
|
||||
"No Annotations": "暂无注释",
|
||||
"No Bookmarks": "暂无书签",
|
||||
"Select some text to highlight": "选择文本以划线",
|
||||
"Bookmark This Page": "为此页添加书签"
|
||||
"Bookmark This Page": "为此页添加书签",
|
||||
"Book file is not available locally": "本地没有该图书文件",
|
||||
"Failed to send book": "发送图书失败",
|
||||
"Send": "发送",
|
||||
"Highlight Current Sentence": "高亮当前句子"
|
||||
}
|
||||
|
||||
@@ -1662,5 +1662,9 @@
|
||||
"No Annotations": "尚無註解",
|
||||
"No Bookmarks": "尚無書籤",
|
||||
"Select some text to highlight": "選取文字以劃線",
|
||||
"Bookmark This Page": "將此頁加入書籤"
|
||||
"Bookmark This Page": "將此頁加入書籤",
|
||||
"Book file is not available locally": "本機沒有此書籍檔案",
|
||||
"Failed to send book": "傳送書籍失敗",
|
||||
"Send": "傳送",
|
||||
"Highlight Current Sentence": "醒目標示目前句子"
|
||||
}
|
||||
|
||||
@@ -59,6 +59,7 @@ tauri-plugin-native-tts = { path = "./plugins/tauri-plugin-native-tts" }
|
||||
tauri-plugin-webview-upgrade = { path = "./plugins/tauri-plugin-webview-upgrade" }
|
||||
tauri-plugin-websocket = "2"
|
||||
tauri-plugin-sharekit = "0.3"
|
||||
tauri-plugin-clipboard-manager = "2"
|
||||
tauri-plugin-device-info = "1.0.1"
|
||||
tauri-plugin-turso = { path = "./plugins/tauri-plugin-turso" }
|
||||
tauri-plugin-webdriver = { version = "0.2", optional = true }
|
||||
|
||||
@@ -196,6 +196,8 @@
|
||||
"haptics:allow-selection-feedback",
|
||||
"deep-link:default",
|
||||
"sharekit:default",
|
||||
"clipboard-manager:allow-write-text",
|
||||
"clipboard-manager:allow-read-text",
|
||||
"device-info:default",
|
||||
"turso:default",
|
||||
"native-tts:default",
|
||||
|
||||
+90
-20
@@ -159,28 +159,98 @@ class NativeBridgePlugin(private val activity: Activity): Plugin(activity) {
|
||||
}
|
||||
|
||||
private fun handleIntent(intent: Intent?) {
|
||||
val uri = intent?.data ?: return
|
||||
Log.e("NativeBridgePlugin", "Received intent: $uri")
|
||||
when {
|
||||
uri.scheme == "readest" && uri.host == "auth-callback" -> {
|
||||
val result = JSObject().apply {
|
||||
put("redirectUrl", uri.toString())
|
||||
}
|
||||
pendingInvoke?.resolve(result)
|
||||
pendingInvoke = null
|
||||
}
|
||||
if (intent == null) return
|
||||
Log.d("NativeBridgePlugin", "Received intent: action=${intent.action} data=${intent.data}")
|
||||
|
||||
intent.action == Intent.ACTION_VIEW -> {
|
||||
try {
|
||||
activity.contentResolver.takePersistableUriPermission(
|
||||
uri,
|
||||
Intent.FLAG_GRANT_READ_URI_PERMISSION
|
||||
)
|
||||
} catch (e: SecurityException) {
|
||||
Log.e("NativeBridgePlugin", "Failed to take persistable URI permission: ${e.message}")
|
||||
}
|
||||
}
|
||||
// OAuth callback uses a custom scheme on intent.data and is handled
|
||||
// separately from any user-shared content.
|
||||
intent.data?.let { uri ->
|
||||
if (uri.scheme == "readest" && uri.host == "auth-callback") {
|
||||
val result = JSObject().apply {
|
||||
put("redirectUrl", uri.toString())
|
||||
}
|
||||
pendingInvoke?.resolve(result)
|
||||
pendingInvoke = null
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
when (intent.action) {
|
||||
Intent.ACTION_VIEW -> {
|
||||
// "Open with Readest": the OS hands us a single content://
|
||||
// (or file://) URI on `intent.data`. Take the persistable
|
||||
// permission so we can read it through any subsequent app
|
||||
// launch, then forward it to the JS side via the existing
|
||||
// shared-intent channel — without this trigger, the URI
|
||||
// silently dies in Kotlin and the user just sees the
|
||||
// library splash with nothing happening.
|
||||
val uri = intent.data ?: return
|
||||
tryTakePersistableReadPermission(uri)
|
||||
emitSharedIntent("VIEW", listOf(uri))
|
||||
}
|
||||
|
||||
Intent.ACTION_SEND -> {
|
||||
// System share-sheet → "Send to Readest" (single file).
|
||||
// The URI lives on EXTRA_STREAM, not on intent.data, which
|
||||
// is why the previous data-only handler never saw share
|
||||
// captures at all.
|
||||
val uri = getExtraStream(intent) ?: return
|
||||
tryTakePersistableReadPermission(uri)
|
||||
emitSharedIntent("SEND", listOf(uri))
|
||||
}
|
||||
|
||||
Intent.ACTION_SEND_MULTIPLE -> {
|
||||
val uris = getExtraStreamList(intent)
|
||||
if (uris.isEmpty()) return
|
||||
uris.forEach { tryTakePersistableReadPermission(it) }
|
||||
emitSharedIntent("SEND", uris)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun tryTakePersistableReadPermission(uri: Uri) {
|
||||
// Only content:// URIs support persistable permissions; file://
|
||||
// URIs are accessible directly and would throw SecurityException
|
||||
// here. Skip the call rather than swallow noisy logs.
|
||||
if (uri.scheme != "content") return
|
||||
try {
|
||||
activity.contentResolver.takePersistableUriPermission(
|
||||
uri,
|
||||
Intent.FLAG_GRANT_READ_URI_PERMISSION
|
||||
)
|
||||
} catch (e: SecurityException) {
|
||||
Log.w("NativeBridgePlugin", "takePersistableUriPermission failed for $uri: ${e.message}")
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
private fun getExtraStream(intent: Intent): Uri? {
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
intent.getParcelableExtra(Intent.EXTRA_STREAM, Uri::class.java)
|
||||
} else {
|
||||
intent.getParcelableExtra(Intent.EXTRA_STREAM) as? Uri
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
private fun getExtraStreamList(intent: Intent): List<Uri> {
|
||||
val list: ArrayList<Uri>? =
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM, Uri::class.java)
|
||||
} else {
|
||||
intent.getParcelableArrayListExtra(Intent.EXTRA_STREAM)
|
||||
}
|
||||
return list ?: emptyList()
|
||||
}
|
||||
|
||||
private fun emitSharedIntent(action: String, uris: List<Uri>) {
|
||||
val payload = JSObject().apply {
|
||||
put("action", action)
|
||||
val arr = JSArray()
|
||||
uris.forEach { arr.put(it.toString()) }
|
||||
put("urls", arr)
|
||||
}
|
||||
triggerEvent("shared-intent", payload)
|
||||
}
|
||||
|
||||
@Command
|
||||
|
||||
@@ -29,6 +29,8 @@ mod discord_rpc;
|
||||
#[cfg(target_os = "macos")]
|
||||
mod macos;
|
||||
mod transfer_file;
|
||||
#[cfg(desktop)]
|
||||
mod window_state;
|
||||
#[cfg(target_os = "windows")]
|
||||
use tauri::webview::ScrollBarStyle;
|
||||
use tauri::{command, Emitter, WebviewUrl, WebviewWindowBuilder, Window};
|
||||
@@ -288,6 +290,7 @@ pub fn run() {
|
||||
.plugin(tauri_plugin_os::init())
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.plugin(tauri_plugin_sharekit::init())
|
||||
.plugin(tauri_plugin_clipboard_manager::init())
|
||||
.plugin(tauri_plugin_device_info::init())
|
||||
.plugin(tauri_plugin_turso::init())
|
||||
.plugin(tauri_plugin_native_bridge::init())
|
||||
@@ -317,6 +320,13 @@ pub fn run() {
|
||||
#[cfg(desktop)]
|
||||
let builder = builder.plugin(tauri_plugin_updater::Builder::new().build());
|
||||
|
||||
// Strip invalid geometry from the saved window state before the
|
||||
// window-state plugin loads it, so a bad `.window-state.json` (e.g. the
|
||||
// Windows minimized `-32000` sentinel) can't crash WebView2 on launch.
|
||||
// See https://github.com/readest/readest/issues/4398.
|
||||
#[cfg(desktop)]
|
||||
let builder = builder.plugin(window_state::init());
|
||||
|
||||
#[cfg(desktop)]
|
||||
let builder = builder.plugin(tauri_plugin_window_state::Builder::default().build());
|
||||
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
//! Defensive sanitizer for the `.window-state.json` file written by
|
||||
//! `tauri-plugin-window-state`.
|
||||
//!
|
||||
//! On Windows a minimized window reports its position as `(-32000, -32000)`
|
||||
//! and its size as `0x0`. The plugin already guards against persisting those
|
||||
//! values, but a state file written by an older build (or a future
|
||||
//! regression) can still contain them, and WebView2 then rejects the restored
|
||||
//! bounds with `0x80070057` ("The parameter is incorrect"), leaving the app
|
||||
//! unable to launch. See https://github.com/readest/readest/issues/4398.
|
||||
//!
|
||||
//! This module strips any window entry with invalid geometry from the state
|
||||
//! file *before* the window-state plugin loads it, so the affected window
|
||||
//! falls back to its default position and size instead of crashing.
|
||||
|
||||
use std::path::Path;
|
||||
use tauri::{
|
||||
plugin::{Builder, TauriPlugin},
|
||||
Manager, Runtime,
|
||||
};
|
||||
|
||||
/// Default filename used by `tauri-plugin-window-state`.
|
||||
const STATE_FILENAME: &str = ".window-state.json";
|
||||
|
||||
/// Windows parks a minimized window at exactly `(-32000, -32000)`. Real
|
||||
/// monitors sit only a few thousand pixels off the origin even in multi-display
|
||||
/// setups (a 4K display left of the primary is `-3840`), so a saved coordinate
|
||||
/// at or below `-16000` — roughly halfway to the sentinel and well past any
|
||||
/// normal desktop — is the minimize marker rather than a real position. A
|
||||
/// normal negative like `-1920` stays well above the cutoff and is kept.
|
||||
const MIN_VALID_COORD: i64 = -16000;
|
||||
|
||||
/// Returns a sanitized copy of the window-state JSON when one or more window
|
||||
/// entries have invalid geometry, or `None` when nothing needs to change
|
||||
/// (already valid, empty, or unparseable).
|
||||
fn sanitize_json(content: &str) -> Option<String> {
|
||||
let mut windows: serde_json::Map<String, serde_json::Value> =
|
||||
serde_json::from_str(content).ok()?;
|
||||
let before = windows.len();
|
||||
windows.retain(|_, state| has_valid_geometry(state));
|
||||
if windows.len() == before {
|
||||
return None;
|
||||
}
|
||||
serde_json::to_string_pretty(&windows).ok()
|
||||
}
|
||||
|
||||
/// A window entry is only usable if it has a positive size and an on-screen
|
||||
/// position. Missing fields are treated as valid so a schema change never
|
||||
/// drops an otherwise-good entry.
|
||||
fn has_valid_geometry(state: &serde_json::Value) -> bool {
|
||||
let int = |key: &str, default: i64| {
|
||||
state
|
||||
.get(key)
|
||||
.and_then(serde_json::Value::as_i64)
|
||||
.unwrap_or(default)
|
||||
};
|
||||
int("width", 1) > 0
|
||||
&& int("height", 1) > 0
|
||||
&& int("x", 0) > MIN_VALID_COORD
|
||||
&& int("y", 0) > MIN_VALID_COORD
|
||||
}
|
||||
|
||||
/// Reads, sanitizes, and rewrites the window-state file at `path`. Removes the
|
||||
/// file entirely when sanitizing leaves no valid entries.
|
||||
fn sanitize_file(path: &Path) {
|
||||
let Ok(content) = std::fs::read_to_string(path) else {
|
||||
return;
|
||||
};
|
||||
let Some(sanitized) = sanitize_json(&content) else {
|
||||
return;
|
||||
};
|
||||
log::warn!("Removing invalid window geometry from {}", path.display());
|
||||
if sanitized.trim() == "{}" {
|
||||
let _ = std::fs::remove_file(path);
|
||||
} else {
|
||||
let _ = std::fs::write(path, sanitized);
|
||||
}
|
||||
}
|
||||
|
||||
/// Tauri plugin that sanitizes the saved window state during setup. Register it
|
||||
/// immediately **before** `tauri-plugin-window-state` so the bad geometry is
|
||||
/// gone before that plugin loads the file.
|
||||
pub fn init<R: Runtime>() -> TauriPlugin<R> {
|
||||
Builder::new("window-state-sanitizer")
|
||||
.setup(|app, _api| {
|
||||
if let Ok(dir) = app.path().app_config_dir() {
|
||||
sanitize_file(&dir.join(STATE_FILENAME));
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.build()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::sanitize_json;
|
||||
|
||||
const VALID: &str = r#"{"main":{"width":1280,"height":800,"x":100,"y":100,"prev_x":0,"prev_y":0,"maximized":false,"visible":true,"decorated":true,"fullscreen":false}}"#;
|
||||
|
||||
#[test]
|
||||
fn keeps_valid_state() {
|
||||
assert!(sanitize_json(VALID).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keeps_negative_multi_monitor_position() {
|
||||
// A monitor to the left yields a legitimately negative x (e.g. -1920).
|
||||
let json = r#"{"main":{"width":1280,"height":800,"x":-1920,"y":0}}"#;
|
||||
assert!(sanitize_json(json).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keeps_deep_multi_monitor_position() {
|
||||
// Even a few stacked displays left of the primary stay well above the
|
||||
// cutoff (three 4K monitors reach only ~ -11520).
|
||||
let json = r#"{"main":{"width":1280,"height":800,"x":-11520,"y":0}}"#;
|
||||
assert!(sanitize_json(json).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drops_minimized_sentinel_position() {
|
||||
let json = r#"{"main":{"width":800,"height":600,"x":-32000,"y":-32000}}"#;
|
||||
assert_eq!(sanitize_json(json).as_deref().map(str::trim), Some("{}"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drops_zero_size() {
|
||||
let json = r#"{"main":{"width":0,"height":0,"x":100,"y":100}}"#;
|
||||
assert!(sanitize_json(json).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keeps_good_entry_drops_bad_entry() {
|
||||
let json = r#"{"good":{"width":1280,"height":800,"x":0,"y":0},"bad":{"width":0,"height":0,"x":-32000,"y":-32000}}"#;
|
||||
let out = sanitize_json(json).expect("file changed");
|
||||
assert!(out.contains("good"));
|
||||
assert!(!out.contains("bad"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_unparseable_content() {
|
||||
assert!(sanitize_json("not json").is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { getBookContextMenuItemIds } from '@/app/library/utils/libraryUtils';
|
||||
import { Book } from '@/types/book';
|
||||
|
||||
const createBook = (overrides: Partial<Book> = {}): Book => ({
|
||||
hash: 'hash-1',
|
||||
format: 'EPUB',
|
||||
title: 'Test Book',
|
||||
author: 'Test Author',
|
||||
createdAt: 0,
|
||||
updatedAt: 0,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('getBookContextMenuItemIds', () => {
|
||||
it('returns a deterministic order for a local downloaded book', () => {
|
||||
const book = createBook({ downloadedAt: 1 });
|
||||
expect(getBookContextMenuItemIds(book)).toEqual([
|
||||
'select',
|
||||
'group',
|
||||
'markFinished',
|
||||
'showDetails',
|
||||
'showInFinder',
|
||||
'upload',
|
||||
'share',
|
||||
'delete',
|
||||
]);
|
||||
});
|
||||
|
||||
it('shows "Mark as Unread" + "Clear Status" for a finished book', () => {
|
||||
const book = createBook({ downloadedAt: 1, readingStatus: 'finished' });
|
||||
expect(getBookContextMenuItemIds(book)).toEqual([
|
||||
'select',
|
||||
'group',
|
||||
'markUnread',
|
||||
'clearStatus',
|
||||
'showDetails',
|
||||
'showInFinder',
|
||||
'upload',
|
||||
'share',
|
||||
'delete',
|
||||
]);
|
||||
});
|
||||
|
||||
it('shows "Mark as Finished" + "Clear Status" for an unread book', () => {
|
||||
const book = createBook({ downloadedAt: 1, readingStatus: 'unread' });
|
||||
expect(getBookContextMenuItemIds(book)).toEqual([
|
||||
'select',
|
||||
'group',
|
||||
'markFinished',
|
||||
'clearStatus',
|
||||
'showDetails',
|
||||
'showInFinder',
|
||||
'upload',
|
||||
'share',
|
||||
'delete',
|
||||
]);
|
||||
});
|
||||
|
||||
it('offers Download (not Upload) for a cloud-only book', () => {
|
||||
const book = createBook({ uploadedAt: 1 });
|
||||
expect(getBookContextMenuItemIds(book)).toEqual([
|
||||
'select',
|
||||
'group',
|
||||
'markFinished',
|
||||
'showDetails',
|
||||
'showInFinder',
|
||||
'download',
|
||||
'share',
|
||||
'delete',
|
||||
]);
|
||||
});
|
||||
|
||||
it('omits download/upload/share for a book that is neither downloaded nor uploaded', () => {
|
||||
const book = createBook({ filePath: '/some/external/file.epub' });
|
||||
expect(getBookContextMenuItemIds(book)).toEqual([
|
||||
'select',
|
||||
'group',
|
||||
'markFinished',
|
||||
'showDetails',
|
||||
'showInFinder',
|
||||
'delete',
|
||||
]);
|
||||
});
|
||||
|
||||
it('produces the same order on repeated calls and never duplicates an item (issue #4389)', () => {
|
||||
const book = createBook({ downloadedAt: 1, uploadedAt: 1, readingStatus: 'finished' });
|
||||
const first = getBookContextMenuItemIds(book);
|
||||
const second = getBookContextMenuItemIds(book);
|
||||
expect(second).toEqual(first);
|
||||
expect(new Set(first).size).toBe(first.length);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
import { act, cleanup, render, screen } from '@testing-library/react';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
MigrateDataWindow,
|
||||
setMigrateDataDirDialogVisible,
|
||||
} from '@/app/library/components/MigrateDataWindow';
|
||||
|
||||
vi.mock('@/hooks/useTranslation', () => ({
|
||||
useTranslation: () => (key: string, options?: Record<string, string | number>) => {
|
||||
if (!options) return key;
|
||||
return key.replace(/{{(\w+)}}/g, (_match, name) => String(options[name] ?? ''));
|
||||
},
|
||||
}));
|
||||
|
||||
const appService = {
|
||||
isAndroidApp: false,
|
||||
isDesktopApp: true,
|
||||
distChannel: 'github',
|
||||
resolveFilePath: vi.fn(async () => '/current/data/dir'),
|
||||
readDirectory: vi.fn(async () => []),
|
||||
};
|
||||
|
||||
vi.mock('@/context/EnvContext', () => ({
|
||||
useEnv: () => ({ appService, envConfig: {} }),
|
||||
}));
|
||||
|
||||
vi.mock('@/store/settingsStore', () => ({
|
||||
useSettingsStore: () => ({ settings: {}, setSettings: vi.fn(), saveSettings: vi.fn() }),
|
||||
}));
|
||||
|
||||
vi.mock('@tauri-apps/api/path', () => ({
|
||||
documentDir: vi.fn(async () => '/docs'),
|
||||
join: vi.fn(async (...parts: string[]) => parts.join('/')),
|
||||
}));
|
||||
vi.mock('@tauri-apps/plugin-process', () => ({ relaunch: vi.fn() }));
|
||||
vi.mock('@tauri-apps/plugin-opener', () => ({ revealItemInDir: vi.fn() }));
|
||||
vi.mock('@/utils/bridge', () => ({ getExternalSDCardPath: vi.fn(async () => ({ path: '' })) }));
|
||||
vi.mock('@/utils/permission', () => ({ requestStoragePermission: vi.fn(async () => true) }));
|
||||
|
||||
// Preserve the dialog id so the component's getElementById event wiring works.
|
||||
vi.mock('@/components/Dialog', () => ({
|
||||
__esModule: true,
|
||||
default: ({
|
||||
id,
|
||||
title,
|
||||
children,
|
||||
}: {
|
||||
id?: string;
|
||||
title?: string;
|
||||
children: React.ReactNode;
|
||||
}) => (
|
||||
<div id={id} role='dialog' aria-label={title}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('MigrateDataWindow e-ink button hierarchy', () => {
|
||||
it('uses btn-ghost (not btn-outline) for the Cancel button so it stays distinct from the primary CTA in e-ink', async () => {
|
||||
render(<MigrateDataWindow />);
|
||||
|
||||
await act(async () => {
|
||||
setMigrateDataDirDialogVisible(true);
|
||||
});
|
||||
|
||||
const cancelButton = await screen.findByRole('button', { name: 'Cancel' });
|
||||
const startButton = screen.getByRole('button', { name: 'Start Migration' });
|
||||
|
||||
// The primary CTA keeps btn-primary, which e-ink inverts to a solid black fill.
|
||||
expect(startButton.className).toContain('btn-primary');
|
||||
|
||||
// The Cancel button must NOT use btn-outline: in e-ink, btn-outline inverts
|
||||
// to the SAME solid black fill as btn-primary, leaving the two buttons
|
||||
// indistinguishable (see issue #4396). It must be a borderless ghost instead.
|
||||
expect(cancelButton.className).not.toContain('btn-outline');
|
||||
expect(cancelButton.className).toContain('btn-ghost');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,223 @@
|
||||
import { render, act, cleanup } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest';
|
||||
|
||||
import type { BookNote } from '@/types/book';
|
||||
|
||||
// ---------- Shared mutable test state (captured by the mock factories) ----------
|
||||
let scrollToIndexSpy: Mock<(arg: unknown) => void>;
|
||||
// Only the FIRST (mount-time) `initialized` callback is captured, mirroring how
|
||||
// OverlayScrollbars binds its event handlers when it initializes the viewport.
|
||||
// A fix that relied on a fresher render closure would pass against the latest
|
||||
// callback but still break in the real app — so the test forces a ref-based fix.
|
||||
let capturedInitialized:
|
||||
| ((instance: { elements: () => { viewport: HTMLElement } }) => void)
|
||||
| undefined;
|
||||
// Latest props Virtuoso was rendered with, so tests can assert the mount-time
|
||||
// position (initialTopMostItemIndex) the panel hands it.
|
||||
let capturedVirtuosoProps: Record<string, unknown> | undefined;
|
||||
let mockProgress: { location: string } | null;
|
||||
let mockBooknotes: BookNote[];
|
||||
|
||||
// ---------- Mocks ----------
|
||||
vi.mock('@/store/bookDataStore', () => ({
|
||||
useBookDataStore: () => ({ getConfig: () => ({ booknotes: mockBooknotes }) }),
|
||||
}));
|
||||
|
||||
vi.mock('@/store/readerStore', () => ({
|
||||
useReaderStore: () => ({ getProgress: () => mockProgress }),
|
||||
}));
|
||||
|
||||
vi.mock('@/store/sidebarStore', () => ({
|
||||
useSidebarStore: () => ({
|
||||
setActiveBooknoteType: vi.fn(),
|
||||
setBooknoteResults: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
// Derive a per-chapter TOC group from the spine step of each note's CFI so the
|
||||
// flattened list is [header, note, header, note, ...] sorted by chapter.
|
||||
vi.mock('@/services/nav', () => ({
|
||||
findTocItemBS: (_toc: unknown, cfi: string) => {
|
||||
const match = cfi.match(/\/6\/(\d+)!/);
|
||||
const n = match ? Number(match[1]) : 0;
|
||||
return { id: n, href: `ch${n}.html`, label: `Chapter ${n}`, index: n };
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/hooks/useTranslation', () => ({
|
||||
useTranslation: () => (s: string) => s,
|
||||
}));
|
||||
|
||||
vi.mock('@/utils/event', () => ({
|
||||
eventDispatcher: { dispatch: vi.fn(), on: vi.fn(), off: vi.fn() },
|
||||
}));
|
||||
|
||||
vi.mock('@/app/reader/components/sidebar/BooknoteItem', () => ({
|
||||
default: () => null,
|
||||
}));
|
||||
|
||||
vi.mock('@/app/reader/components/EmptyState', () => ({
|
||||
default: () => null,
|
||||
}));
|
||||
|
||||
// Virtuoso is replaced with a stub that exposes a spy-able `scrollToIndex`
|
||||
// through the imperative handle and hands BooknoteView a scroller element.
|
||||
vi.mock('react-virtuoso', async () => {
|
||||
const ReactMod = await import('react');
|
||||
return {
|
||||
Virtuoso: ReactMod.forwardRef(
|
||||
(
|
||||
props: { scrollerRef?: (el: HTMLElement | Window | null) => void },
|
||||
ref: React.Ref<unknown>,
|
||||
) => {
|
||||
capturedVirtuosoProps = props as Record<string, unknown>;
|
||||
ReactMod.useImperativeHandle(ref, () => ({
|
||||
scrollToIndex: (arg: unknown) => scrollToIndexSpy(arg),
|
||||
}));
|
||||
ReactMod.useEffect(() => {
|
||||
props.scrollerRef?.(document.createElement('div'));
|
||||
}, []);
|
||||
return null;
|
||||
},
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
// Capture the OverlayScrollbars `initialized` callback so the test can fire it
|
||||
// on demand (the real hook fires it after a deferred, timing-dependent init).
|
||||
vi.mock('overlayscrollbars-react', () => ({
|
||||
useOverlayScrollbars: (opts: {
|
||||
events?: { initialized?: (i: { elements: () => { viewport: HTMLElement } }) => void };
|
||||
}) => {
|
||||
if (!capturedInitialized) capturedInitialized = opts.events?.initialized;
|
||||
return [vi.fn(), () => undefined];
|
||||
},
|
||||
}));
|
||||
|
||||
// eslint-disable-next-line import/first
|
||||
import BooknoteView from '@/app/reader/components/sidebar/BooknoteView';
|
||||
|
||||
const makeNote = (cfi: string): BookNote =>
|
||||
({
|
||||
id: cfi,
|
||||
type: 'annotation',
|
||||
cfi,
|
||||
text: cfi,
|
||||
note: '',
|
||||
createdAt: 0,
|
||||
updatedAt: 0,
|
||||
}) as BookNote;
|
||||
|
||||
const fireOverlayScrollbarsInitialized = () => {
|
||||
act(() => {
|
||||
capturedInitialized?.({ elements: () => ({ viewport: document.createElement('div') }) });
|
||||
});
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
scrollToIndexSpy = vi.fn<(arg: unknown) => void>();
|
||||
capturedInitialized = undefined;
|
||||
capturedVirtuosoProps = undefined;
|
||||
mockProgress = null;
|
||||
mockBooknotes = [
|
||||
makeNote('epubcfi(/6/4!/4/2:0)'),
|
||||
makeNote('epubcfi(/6/6!/4/4:0)'),
|
||||
makeNote('epubcfi(/6/8!/4/2:0)'),
|
||||
makeNote('epubcfi(/6/10!/4/6:0)'),
|
||||
makeNote('epubcfi(/6/26!/4/2:0)'),
|
||||
];
|
||||
// Run rAF synchronously so the callback's scroll happens inside act().
|
||||
vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => {
|
||||
cb(0);
|
||||
return 0;
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe('BooknoteView — OverlayScrollbars init does not rewind the list to the top', () => {
|
||||
it('re-applies the auto-scroll to the nearest note when OverlayScrollbars initializes after the reading position arrives', () => {
|
||||
// Fresh open: BooknoteView mounts before the first relocate, so `progress`
|
||||
// (and thus the nearest cfi) has no reading position yet.
|
||||
mockProgress = null;
|
||||
const { rerender } = render(<BooknoteView type='annotation' bookKey='book1' toc={[]} />);
|
||||
|
||||
// The relocate arrives → the normal auto-scroll effect centers the nearest
|
||||
// note. The list is [h4, n4, h6, n6, h8, n8, h10, n10, h26, n26]; the
|
||||
// reading position is in the last chapter, so the nearest note is index 9.
|
||||
mockProgress = { location: 'epubcfi(/6/26!/4/10:0)' };
|
||||
act(() => {
|
||||
rerender(<BooknoteView type='annotation' bookKey='book1' toc={[]} />);
|
||||
});
|
||||
|
||||
// Ignore that first scroll; we only care whether the OverlayScrollbars init
|
||||
// (which clobbers scrollTop) re-applies it instead of stranding the top.
|
||||
scrollToIndexSpy.mockClear();
|
||||
|
||||
fireOverlayScrollbarsInitialized();
|
||||
|
||||
expect(scrollToIndexSpy).toHaveBeenCalledWith(expect.objectContaining({ index: 9 }));
|
||||
});
|
||||
|
||||
it('does not force a scroll on OverlayScrollbars init when there is no reading position', () => {
|
||||
mockProgress = null;
|
||||
render(<BooknoteView type='annotation' bookKey='book1' toc={[]} />);
|
||||
|
||||
scrollToIndexSpy.mockClear();
|
||||
fireOverlayScrollbarsInitialized();
|
||||
|
||||
expect(scrollToIndexSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('positions Virtuoso natively on mount (without a racing scrollToIndex) when the reading position is already known', () => {
|
||||
// Switching to the panel while reading: the reading position is available at
|
||||
// mount. A scrollToIndex against the freshly mounted, unmeasured list no-ops
|
||||
// or wedges it into rendering nothing — so the panel must mount Virtuoso
|
||||
// already centered via initialTopMostItemIndex and the scroll effect must
|
||||
// skip its first jump.
|
||||
mockProgress = { location: 'epubcfi(/6/26!/4/10:0)' };
|
||||
act(() => {
|
||||
render(<BooknoteView type='annotation' bookKey='book1' toc={[]} />);
|
||||
});
|
||||
|
||||
expect(capturedVirtuosoProps?.['initialTopMostItemIndex']).toEqual({
|
||||
index: 9,
|
||||
align: 'center',
|
||||
});
|
||||
expect(scrollToIndexSpy).not.toHaveBeenCalled();
|
||||
|
||||
// The deferred OverlayScrollbars init resets scrollTop; the re-apply then
|
||||
// restores the centered position (now that the rows have been measured).
|
||||
fireOverlayScrollbarsInitialized();
|
||||
expect(scrollToIndexSpy).toHaveBeenCalledWith(expect.objectContaining({ index: 9 }));
|
||||
});
|
||||
|
||||
it('jumps instantly (behavior auto) for a far scroll instead of animating it, like TOCView', () => {
|
||||
// 12 notes in distinct chapters → flat list [h, n, h, n, ...] of 24 rows;
|
||||
// the nearest note (last chapter) sits at index 23, far from the top.
|
||||
mockBooknotes = Array.from({ length: 12 }, (_, i) =>
|
||||
makeNote(`epubcfi(/6/${4 + i * 2}!/4/2:0)`),
|
||||
);
|
||||
|
||||
// Reload-style: no reading position at mount, so initialTopMostItemIndex
|
||||
// does not handle it and the scroll effect performs the jump.
|
||||
mockProgress = null;
|
||||
const { rerender } = render(<BooknoteView type='annotation' bookKey='book1' toc={[]} />);
|
||||
|
||||
mockProgress = { location: 'epubcfi(/6/26!/4/10:0)' };
|
||||
act(() => {
|
||||
rerender(<BooknoteView type='annotation' bookKey='book1' toc={[]} />);
|
||||
});
|
||||
|
||||
// distance (23 - 0) > 16 → instant jump, not a smooth animation.
|
||||
expect(scrollToIndexSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ index: 23, behavior: 'auto' }),
|
||||
);
|
||||
expect(scrollToIndexSpy).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({ behavior: 'smooth' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { render, cleanup } from '@testing-library/react';
|
||||
|
||||
import ImageViewer from '@/app/reader/components/ImageViewer';
|
||||
|
||||
vi.mock('@/hooks/useTranslation', () => ({
|
||||
useTranslation: () => (s: string) => s,
|
||||
}));
|
||||
|
||||
// useKeyDownActions pulls in EnvContext + device store; not under test here.
|
||||
vi.mock('@/hooks/useKeyDownActions', () => ({
|
||||
useKeyDownActions: () => {},
|
||||
}));
|
||||
|
||||
// ZoomControls reaches into the theme store and Tauri window APIs; stub it out.
|
||||
vi.mock('@/app/reader/components/ZoomControls', () => ({
|
||||
__esModule: true,
|
||||
default: () => null,
|
||||
}));
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
const gridInsets = { top: 0, right: 0, bottom: 0, left: 0 };
|
||||
|
||||
describe('ImageViewer', () => {
|
||||
it('suppresses the native image callout on the zoomed image', () => {
|
||||
// The WebView's native long-press image callout collides with the
|
||||
// viewer's own pinch/pan handlers on Android and freezes the app. The
|
||||
// zoomed <img> must live under a `.no-context-menu` ancestor so the
|
||||
// global `.no-context-menu img { -webkit-touch-callout: none }` rule
|
||||
// disables that callout. Mirrors the book-cover fix (PR #4345).
|
||||
const { container } = render(
|
||||
<ImageViewer src='blob:test-image' onClose={vi.fn()} gridInsets={gridInsets} />,
|
||||
);
|
||||
|
||||
const calloutSafeImage = container.querySelector('.no-context-menu img');
|
||||
expect(calloutSafeImage).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -178,3 +178,26 @@ describe('ProgressBar — fixed-layout remaining pages', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ProgressBar — decorative footer is not focusable', () => {
|
||||
it('does not make the progress info container focusable (no stray focus ring)', () => {
|
||||
// The footer info is a decorative role="presentation" element. A negative
|
||||
// tabindex made it focusable, so long-pressing it on Android focused the
|
||||
// div and the WebView painted its default focus ring as a persistent line
|
||||
// across the bottom of the page (issue #4397). A decorative element must
|
||||
// not be focusable so it can never receive a focus ring.
|
||||
currentViewSettings = {
|
||||
...baseSettings,
|
||||
progressInfoMode: 'all',
|
||||
} as ViewSettings;
|
||||
currentProgress = makeProgress(2, 5);
|
||||
currentBookData = { isFixedLayout: false };
|
||||
currentRenderer = { page: 1, pages: 4 };
|
||||
|
||||
const { container } = renderProgressBar();
|
||||
|
||||
const progressInfo = container.querySelector('.progressinfo');
|
||||
expect(progressInfo).not.toBeNull();
|
||||
expect(progressInfo!.hasAttribute('tabindex')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,21 +1,89 @@
|
||||
import { cleanup, fireEvent, render, waitFor } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import ReadingRuler from '@/app/reader/components/ReadingRuler';
|
||||
import { ViewSettings } from '@/types/book';
|
||||
import { BookFormat, ViewSettings } from '@/types/book';
|
||||
import { eventDispatcher } from '@/utils/event';
|
||||
|
||||
const saveViewSettings = vi.fn();
|
||||
|
||||
vi.mock('@/context/EnvContext', () => ({
|
||||
useEnv: () => ({ envConfig: {} }),
|
||||
}));
|
||||
type RulerTestRect = {
|
||||
top: number;
|
||||
bottom: number;
|
||||
left: number;
|
||||
right: number;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
vi.mock('@/store/readerStore', () => ({
|
||||
useReaderStore: () => ({
|
||||
getProgress: () => null,
|
||||
getView: () => ({ renderer: { columnCount: 1 } }),
|
||||
}),
|
||||
}));
|
||||
// Mutable doubles read lazily by the store mock so individual tests can drive
|
||||
// the progress range and the scrolled-mode visible contents.
|
||||
let mockProgress: { range: unknown; pageinfo: { current: number } } | null = null;
|
||||
let mockContents: Array<{ doc: unknown }> = [];
|
||||
|
||||
vi.mock('@/context/EnvContext', () => {
|
||||
// Stable envConfig ref; an unstable one would churn the throttled save → ruler
|
||||
// setter → applyBlock callbacks and make the cache effect re-run every render.
|
||||
const env = { envConfig: {} };
|
||||
return { useEnv: () => env };
|
||||
});
|
||||
|
||||
vi.mock('@/store/readerStore', () => {
|
||||
// Stable store-method references (zustand returns the same selectors across
|
||||
// renders); the ReadingRuler cache effect lists getView as a dependency, so an
|
||||
// unstable ref would make it re-run on every render.
|
||||
const getProgress = () => mockProgress;
|
||||
const getView = () => ({ renderer: { columnCount: 1, getContents: () => mockContents } });
|
||||
const store = { getProgress, getView };
|
||||
return { useReaderStore: () => store };
|
||||
});
|
||||
|
||||
// Evenly spaced single-column text lines in iframe-content coordinates.
|
||||
const makeLineRects = (count: number, pitch: number, height: number): RulerTestRect[] =>
|
||||
Array.from({ length: count }, (_, i) => ({
|
||||
top: i * pitch,
|
||||
bottom: i * pitch + height,
|
||||
left: 50,
|
||||
right: 750,
|
||||
width: 700,
|
||||
height,
|
||||
}));
|
||||
|
||||
// A single visible section whose iframe is offset by `frameTop` along the scroll
|
||||
// axis (negative = scrolled down). `buildScrolledLineBoxes` walks these contents.
|
||||
const makeScrolledContents = (
|
||||
frameTop: number,
|
||||
lineRects: RulerTestRect[],
|
||||
): Array<{ doc: unknown }> => {
|
||||
const doc: {
|
||||
body: object;
|
||||
createRange: () => unknown;
|
||||
defaultView: { frameElement: { getBoundingClientRect: () => DOMRect } };
|
||||
} = {
|
||||
body: {},
|
||||
createRange: () => ({
|
||||
startContainer: { ownerDocument: doc },
|
||||
selectNodeContents: () => {},
|
||||
getClientRects: () => lineRects,
|
||||
}),
|
||||
defaultView: {
|
||||
frameElement: {
|
||||
getBoundingClientRect: () =>
|
||||
({
|
||||
x: 0,
|
||||
y: frameTop,
|
||||
top: frameTop,
|
||||
left: 0,
|
||||
right: 800,
|
||||
bottom: frameTop + 5000,
|
||||
width: 800,
|
||||
height: 5000,
|
||||
toJSON: () => ({}),
|
||||
}) as DOMRect,
|
||||
},
|
||||
},
|
||||
};
|
||||
return [{ doc }];
|
||||
};
|
||||
|
||||
vi.mock('@/helpers/settings', () => ({
|
||||
saveViewSettings: (...args: unknown[]) => saveViewSettings(...args),
|
||||
@@ -38,6 +106,8 @@ describe('ReadingRuler', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockProgress = null;
|
||||
mockContents = [];
|
||||
|
||||
Object.defineProperty(HTMLElement.prototype, 'clientHeight', {
|
||||
configurable: true,
|
||||
@@ -188,4 +258,79 @@ describe('ReadingRuler', () => {
|
||||
expect(consumed).toBe(false);
|
||||
expect(saveViewSettings).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Regression: issue #4386 — in scrolled mode the ruler used to re-snap on every
|
||||
// relocate fired while scrolling, so its position crept down the page. It must
|
||||
// stay fixed on screen while scrolling; snapping only happens on click.
|
||||
it('keeps the ruler fixed on screen while scrolling in scrolled mode', () => {
|
||||
const lineRects = makeLineRects(50, 100, 40);
|
||||
mockProgress = { range: {}, pageinfo: { current: 0 } };
|
||||
mockContents = makeScrolledContents(0, lineRects);
|
||||
const scrolledSettings = { ...viewSettings, scrolled: true } as ViewSettings;
|
||||
|
||||
const props = {
|
||||
bookKey: 'book-1',
|
||||
isVertical: false,
|
||||
rtl: false,
|
||||
lines: 2,
|
||||
position: 50,
|
||||
opacity: 0.5,
|
||||
color: 'transparent' as const,
|
||||
bookFormat: 'EPUB' as BookFormat,
|
||||
viewSettings: scrolledSettings,
|
||||
gridInsets: { top: 0, right: 0, bottom: 0, left: 0 },
|
||||
};
|
||||
|
||||
const { container, rerender } = render(<ReadingRuler {...props} />);
|
||||
const rulerTop = () =>
|
||||
parseFloat((container.querySelector('.ruler') as HTMLDivElement).style.top);
|
||||
|
||||
// Initial mount snaps the band to a real line, moving it off the 50% prop.
|
||||
const initialTop = rulerTop();
|
||||
expect(initialTop).toBeGreaterThan(50);
|
||||
|
||||
// Simulate scrolling: a new relocate range arrives with the content shifted
|
||||
// up, but the section/page is unchanged.
|
||||
mockProgress = { range: {}, pageinfo: { current: 0 } };
|
||||
mockContents = makeScrolledContents(-130, lineRects);
|
||||
rerender(<ReadingRuler {...props} />);
|
||||
|
||||
// The band must not move on its own as the reader scrolls.
|
||||
expect(rulerTop()).toBeCloseTo(initialTop, 5);
|
||||
});
|
||||
|
||||
it('still snaps the ruler to lines when advancing by click in scrolled mode', async () => {
|
||||
const lineRects = makeLineRects(50, 100, 40);
|
||||
mockProgress = { range: {}, pageinfo: { current: 0 } };
|
||||
mockContents = makeScrolledContents(0, lineRects);
|
||||
const scrolledSettings = { ...viewSettings, scrolled: true } as ViewSettings;
|
||||
|
||||
const { container } = render(
|
||||
<ReadingRuler
|
||||
bookKey='book-1'
|
||||
isVertical={false}
|
||||
rtl={false}
|
||||
lines={2}
|
||||
position={50}
|
||||
opacity={0.5}
|
||||
color='transparent'
|
||||
bookFormat='EPUB'
|
||||
viewSettings={scrolledSettings}
|
||||
gridInsets={{ top: 0, right: 0, bottom: 0, left: 0 }}
|
||||
/>,
|
||||
);
|
||||
const rulerTop = () =>
|
||||
parseFloat((container.querySelector('.ruler') as HTMLDivElement).style.top);
|
||||
const before = rulerTop();
|
||||
|
||||
const consumed = eventDispatcher.dispatchSync('reading-ruler-move', {
|
||||
bookKey: 'book-1',
|
||||
direction: 'forward',
|
||||
});
|
||||
|
||||
expect(consumed).toBe(true);
|
||||
await waitFor(() => {
|
||||
expect(rulerTop()).toBeGreaterThan(before);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
// See readest/readest swipe background flash.
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { computeBackgroundSegments } from 'foliate-js/paginator.js';
|
||||
import { computeBackgroundSegments, textureAwareBackground } from 'foliate-js/paginator.js';
|
||||
|
||||
describe('computeBackgroundSegments', () => {
|
||||
it('splits the background at the content seam mid-swipe (incoming page keeps its own colour)', () => {
|
||||
@@ -43,24 +43,27 @@ describe('computeBackgroundSegments', () => {
|
||||
expect(segments).toEqual([{ start: 0, size: 430, bg: 'rgb(0, 0, 0)' }]);
|
||||
});
|
||||
|
||||
it('stretches a centered page into the full-bleed gutters (inset > 0)', () => {
|
||||
// Desktop-style: container is inset 50px inside a 430px background.
|
||||
it('keeps a centered page inside its content area (inset > 0)', () => {
|
||||
// Desktop-style: container is inset 50px inside a 430px background. The page
|
||||
// background stays inside its column [50, 380] — it must NOT bleed into the
|
||||
// outer margin gutters, so the page stays centered (readest#4394).
|
||||
const views = [{ size: 330, bg: 'rgb(0, 0, 0)' }];
|
||||
const segments = computeBackgroundSegments(views, 0, 430, 50, 330);
|
||||
// Extends out to both background edges so the page is full-bleed.
|
||||
expect(segments).toEqual([{ start: 0, size: 430, bg: 'rgb(0, 0, 0)' }]);
|
||||
expect(segments).toEqual([{ start: 50, size: 330, bg: 'rgb(0, 0, 0)' }]);
|
||||
});
|
||||
|
||||
it('gives each section its own full-bleed half in a two-up spread', () => {
|
||||
it('keeps each section inside its own column in a two-up spread', () => {
|
||||
const views = [
|
||||
{ size: 215, bg: 'rgb(255, 0, 0)' },
|
||||
{ size: 215, bg: 'rgb(0, 0, 255)' },
|
||||
];
|
||||
// bg 500 wide, container 430 inset 35 → seam at 35 + 215 = 250.
|
||||
const segments = computeBackgroundSegments(views, 0, 500, 35, 430);
|
||||
// Each page fills its column up to the seam; neither bleeds into its outer
|
||||
// gutter ([0, 35] and [465, 500] stay as symmetric margins).
|
||||
expect(segments).toEqual([
|
||||
{ start: 0, size: 250, bg: 'rgb(255, 0, 0)' }, // left page bleeds into left gutter
|
||||
{ start: 250, size: 250, bg: 'rgb(0, 0, 255)' }, // right page bleeds into right gutter
|
||||
{ start: 35, size: 215, bg: 'rgb(255, 0, 0)' },
|
||||
{ start: 250, size: 215, bg: 'rgb(0, 0, 255)' },
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -74,4 +77,65 @@ describe('computeBackgroundSegments', () => {
|
||||
const segments = computeBackgroundSegments(views, 430, 430, 0, 430);
|
||||
expect(segments).toEqual([{ start: 0, size: 430, bg: 'rgb(1, 1, 1)' }]);
|
||||
});
|
||||
|
||||
// Regression test for readest/readest#4394. Backgrounds used to bleed into the
|
||||
// outer margin gutter on the side they touched, so a mixed two-up spread — a
|
||||
// transparent cover (no segment) beside a body-coloured title page — filled
|
||||
// the right gutter with colour while the left gutter stayed the plain margin.
|
||||
// The spread then looked shifted off-centre (badly so on wide screens, where
|
||||
// each gutter is ~250px). Each page now stays inside its own column.
|
||||
it('keeps symmetric margins when only the right page of a two-up spread is colored', () => {
|
||||
const views = [
|
||||
{ size: 215, bg: '' }, // left page — transparent cover image, no segment
|
||||
{ size: 215, bg: 'rgb(255, 228, 63)' }, // right page — yellow title
|
||||
];
|
||||
// bg 500 wide, container 430 inset 35 → seam at 35 + 215 = 250.
|
||||
const segments = computeBackgroundSegments(views, 0, 500, 35, 430);
|
||||
// The yellow page stops at the content edge (465); it must NOT bleed into
|
||||
// the right gutter [465, 500] while the transparent left gutter shows white.
|
||||
expect(segments).toEqual([{ start: 250, size: 215, bg: 'rgb(255, 228, 63)' }]);
|
||||
});
|
||||
|
||||
it('keeps symmetric margins when only the left page of a two-up spread is colored', () => {
|
||||
const views = [
|
||||
{ size: 215, bg: 'rgb(255, 228, 63)' }, // left page — yellow
|
||||
{ size: 215, bg: '' }, // right page — transparent, no segment
|
||||
];
|
||||
const segments = computeBackgroundSegments(views, 0, 500, 35, 430);
|
||||
// The yellow page starts at the content edge (35); it must NOT bleed into
|
||||
// the left gutter [0, 35] while the transparent right gutter shows white.
|
||||
expect(segments).toEqual([{ start: 35, size: 215, bg: 'rgb(255, 228, 63)' }]);
|
||||
});
|
||||
});
|
||||
|
||||
// Regression test for readest/readest#4399: a background texture mounted on the
|
||||
// reader container (`.foliate-viewer::before`) shows in scrolled mode but is
|
||||
// absent in paginated mode. The texture is occluded when the paginator paints an
|
||||
// opaque fill over a page whose own background is transparent. Both modes must
|
||||
// drop the fill for transparent pages while a texture is active so the texture
|
||||
// shows through; pages that force their own opaque colour keep painting.
|
||||
describe('textureAwareBackground', () => {
|
||||
it('drops a transparent page background when a texture is active (texture shows through)', () => {
|
||||
expect(textureAwareBackground('rgba(0, 0, 0, 0)', true)).toBe('');
|
||||
expect(textureAwareBackground('transparent', true)).toBe('');
|
||||
expect(textureAwareBackground('', true)).toBe('');
|
||||
// The real value captured from the iframe html computed `background`.
|
||||
expect(
|
||||
textureAwareBackground(
|
||||
'rgba(0, 0, 0, 0) none repeat scroll 0% 0% / auto padding-box border-box',
|
||||
true,
|
||||
),
|
||||
).toBe('');
|
||||
});
|
||||
|
||||
it('keeps an opaque page background even when a texture is active', () => {
|
||||
expect(textureAwareBackground('rgb(0, 0, 0)', true)).toBe('rgb(0, 0, 0)');
|
||||
expect(textureAwareBackground('rgb(255, 255, 255)', true)).toBe('rgb(255, 255, 255)');
|
||||
});
|
||||
|
||||
it('never drops a background when no texture is active (no regression)', () => {
|
||||
expect(textureAwareBackground('rgba(0, 0, 0, 0)', false)).toBe('rgba(0, 0, 0, 0)');
|
||||
expect(textureAwareBackground('rgb(0, 0, 0)', false)).toBe('rgb(0, 0, 0)');
|
||||
expect(textureAwareBackground('', false)).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
import { describe, it, expect, beforeAll, afterEach } from 'vitest';
|
||||
import { DocumentLoader } from '@/libs/document';
|
||||
import type { BookDoc } from '@/libs/document';
|
||||
import type { Renderer } from '@/types/view';
|
||||
|
||||
// repro-4379: a Duokan/DangDang full-page cover — `data-duokan-page-fullscreen`
|
||||
// on <html> with a dimensionless <img> inside a `.cover` wrapper. The paginator
|
||||
// pins such covers with `position:absolute; inset:0; height:100%`, which fills
|
||||
// the fixed-height page in paginated mode but collapses to zero against the
|
||||
// auto-height scroll container in scrolled mode (the cover disappears).
|
||||
const EPUB_URL = new URL('../fixtures/data/repro-4379.epub', import.meta.url).href;
|
||||
|
||||
let book: BookDoc;
|
||||
|
||||
const loadEPUB = async () => {
|
||||
const resp = await fetch(EPUB_URL);
|
||||
const buffer = await resp.arrayBuffer();
|
||||
const file = new File([buffer], 'repro-4379.epub', { type: 'application/epub+zip' });
|
||||
const loader = new DocumentLoader(file);
|
||||
const { book } = await loader.open();
|
||||
return book;
|
||||
};
|
||||
|
||||
const waitForStabilized = (el: HTMLElement, timeout = 10000) =>
|
||||
new Promise<void>((resolve, reject) => {
|
||||
const timer = setTimeout(() => reject(new Error('stabilized timeout')), timeout);
|
||||
el.addEventListener(
|
||||
'stabilized',
|
||||
() => {
|
||||
clearTimeout(timer);
|
||||
resolve();
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
});
|
||||
|
||||
const getCoverImg = (paginator: Renderer): HTMLImageElement | null => {
|
||||
const cover = paginator.getContents().find((c) => c.index === 0);
|
||||
return (cover?.doc.body.querySelector('img') as HTMLImageElement | undefined) ?? null;
|
||||
};
|
||||
|
||||
/** Wait for the cover <img> resource to decode so layout has a natural size. */
|
||||
const waitForImgLoaded = async (img: HTMLImageElement, timeout = 5000) => {
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < timeout) {
|
||||
if (img.complete && img.naturalHeight > 0) return;
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
}
|
||||
};
|
||||
|
||||
/** Poll until the element has a non-zero rendered height (or time out). */
|
||||
const waitForVisibleHeight = async (img: HTMLImageElement, timeout = 3000) => {
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < timeout) {
|
||||
if (img.offsetHeight > 0) return;
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
}
|
||||
};
|
||||
|
||||
describe('Paginator Duokan fullscreen cover (#4379)', () => {
|
||||
let paginator: Renderer;
|
||||
|
||||
beforeAll(async () => {
|
||||
book = await loadEPUB();
|
||||
await import('foliate-js/paginator.js');
|
||||
}, 30000);
|
||||
|
||||
const createPaginator = () => {
|
||||
const el = document.createElement('foliate-paginator') as Renderer;
|
||||
Object.assign(el.style, {
|
||||
width: '800px',
|
||||
height: '600px',
|
||||
position: 'absolute',
|
||||
left: '0',
|
||||
top: '0',
|
||||
});
|
||||
document.body.appendChild(el);
|
||||
return el;
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
if (paginator) {
|
||||
try {
|
||||
paginator.destroy();
|
||||
} catch {
|
||||
/* iframe body may already be torn down */
|
||||
}
|
||||
paginator.remove();
|
||||
}
|
||||
});
|
||||
|
||||
it('shows the cover image in paginated mode (sanity)', async () => {
|
||||
paginator = createPaginator();
|
||||
paginator.open(book);
|
||||
|
||||
const stabilized = waitForStabilized(paginator);
|
||||
await paginator.goTo({ index: 0 });
|
||||
await stabilized;
|
||||
|
||||
const img = getCoverImg(paginator);
|
||||
expect(img).toBeTruthy();
|
||||
await waitForImgLoaded(img!);
|
||||
await waitForVisibleHeight(img!);
|
||||
expect(img!.offsetHeight).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('shows the cover image in scrolled mode', async () => {
|
||||
paginator = createPaginator();
|
||||
paginator.open(book);
|
||||
paginator.setAttribute('flow', 'scrolled');
|
||||
|
||||
const stabilized = waitForStabilized(paginator);
|
||||
await paginator.goTo({ index: 0 });
|
||||
await stabilized;
|
||||
|
||||
const img = getCoverImg(paginator);
|
||||
expect(img).toBeTruthy();
|
||||
await waitForImgLoaded(img!);
|
||||
await waitForVisibleHeight(img!);
|
||||
|
||||
// Before the fix the cover is absolutely positioned at height:100% inside
|
||||
// an auto-height (zero) container, so it collapses and never renders.
|
||||
expect(img!.offsetHeight).toBeGreaterThan(0);
|
||||
// It must also stay bounded by the viewport rather than overflowing it.
|
||||
expect(img!.offsetHeight).toBeLessThanOrEqual(600);
|
||||
});
|
||||
|
||||
it('shows the cover image after toggling paginated -> scrolled', async () => {
|
||||
paginator = createPaginator();
|
||||
paginator.open(book);
|
||||
|
||||
const stabilized = waitForStabilized(paginator);
|
||||
await paginator.goTo({ index: 0 });
|
||||
await stabilized;
|
||||
|
||||
const img = getCoverImg(paginator);
|
||||
expect(img).toBeTruthy();
|
||||
await waitForImgLoaded(img!);
|
||||
await waitForVisibleHeight(img!);
|
||||
|
||||
const stabilized2 = waitForStabilized(paginator);
|
||||
paginator.setAttribute('flow', 'scrolled');
|
||||
await stabilized2;
|
||||
|
||||
// The same <img> element is re-rendered; stale absolute positioning from
|
||||
// the paginated render must not leave it collapsed in scrolled mode.
|
||||
await waitForVisibleHeight(img!);
|
||||
expect(img!.offsetHeight).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,191 @@
|
||||
import { describe, it, expect, beforeAll, afterEach } from 'vitest';
|
||||
import { DocumentLoader } from '@/libs/document';
|
||||
import type { BookDoc } from '@/libs/document';
|
||||
import type { ViewSettings } from '@/types/book';
|
||||
import type { Renderer } from '@/types/view';
|
||||
import { getStyles } from '@/utils/style';
|
||||
import { applyScrollableStyle, SCROLL_WRAPPER_CLASS } from '@/utils/scrollable';
|
||||
import {
|
||||
DEFAULT_BOOK_FONT,
|
||||
DEFAULT_BOOK_LAYOUT,
|
||||
DEFAULT_BOOK_STYLE,
|
||||
DEFAULT_BOOK_LANGUAGE,
|
||||
DEFAULT_VIEW_CONFIG,
|
||||
DEFAULT_TTS_CONFIG,
|
||||
DEFAULT_TRANSLATOR_CONFIG,
|
||||
DEFAULT_ANNOTATOR_CONFIG,
|
||||
DEFAULT_SCREEN_CONFIG,
|
||||
} from '@/services/constants';
|
||||
|
||||
// sample-table-layout.epub: CSS layout tables (fixed em widths, images +
|
||||
// wrapping prose) for character/glossary pages. They are designed to fit the
|
||||
// reading column, so they must NOT get a horizontal scrollbar. Before the fix,
|
||||
// `.readest-table-scroll > table { width: max-content }` forced every table to
|
||||
// its unwrapped width, which overflowed the column and showed a scrollbar.
|
||||
const LAYOUT_EPUB_URL = new URL('../fixtures/data/sample-table-layout.epub', import.meta.url).href;
|
||||
// sample-table-wide.epub: a single section with a genuinely wide (many-column,
|
||||
// non-wrapping) data table — it cannot fit the column, so it must SCROLL rather
|
||||
// than be clipped.
|
||||
const WIDE_EPUB_URL = new URL('../fixtures/data/sample-table-wide.epub', import.meta.url).href;
|
||||
|
||||
// Spine indices of sample-table-layout.epub whose sections contain <table>.
|
||||
const TABLE_SECTION_INDICES = [2, 3, 4, 9, 10];
|
||||
// Ignore sub-pixel / border slop when deciding whether a table really overflows.
|
||||
const TOLERANCE_PX = 4;
|
||||
|
||||
const loadEPUB = async (url: string, name: string) => {
|
||||
const resp = await fetch(url);
|
||||
const buffer = await resp.arrayBuffer();
|
||||
const file = new File([buffer], name, { type: 'application/epub+zip' });
|
||||
const { book } = await new DocumentLoader(file).open();
|
||||
return book;
|
||||
};
|
||||
|
||||
const makeViewSettings = (): ViewSettings =>
|
||||
({
|
||||
...DEFAULT_BOOK_FONT,
|
||||
...DEFAULT_BOOK_LAYOUT,
|
||||
...DEFAULT_BOOK_STYLE,
|
||||
...DEFAULT_BOOK_LANGUAGE,
|
||||
...DEFAULT_VIEW_CONFIG,
|
||||
...DEFAULT_TTS_CONFIG,
|
||||
...DEFAULT_TRANSLATOR_CONFIG,
|
||||
...DEFAULT_ANNOTATOR_CONFIG,
|
||||
...DEFAULT_SCREEN_CONFIG,
|
||||
}) as ViewSettings;
|
||||
|
||||
const waitForStabilized = (el: HTMLElement, timeout = 10000) =>
|
||||
new Promise<void>((resolve, reject) => {
|
||||
const timer = setTimeout(() => reject(new Error('stabilized timeout')), timeout);
|
||||
el.addEventListener(
|
||||
'stabilized',
|
||||
() => {
|
||||
clearTimeout(timer);
|
||||
resolve();
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
});
|
||||
|
||||
const poll = async (fn: () => boolean, timeout = 2000) => {
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < timeout) {
|
||||
if (fn()) return true;
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
}
|
||||
return fn();
|
||||
};
|
||||
|
||||
const getSectionDoc = (paginator: Renderer, index: number): Document | null =>
|
||||
paginator.getContents().find((c) => c.index === index)?.doc ?? null;
|
||||
|
||||
const overflow = (w: HTMLElement) => w.scrollWidth - w.clientWidth;
|
||||
const overflowX = (w: HTMLElement) => w.ownerDocument.defaultView!.getComputedStyle(w).overflowX;
|
||||
const showsScrollbar = (w: HTMLElement) =>
|
||||
['auto', 'scroll'].includes(overflowX(w)) && overflow(w) > TOLERANCE_PX;
|
||||
const isClipped = (w: HTMLElement) =>
|
||||
!['auto', 'scroll'].includes(overflowX(w)) && overflow(w) > TOLERANCE_PX;
|
||||
|
||||
let layoutBook: BookDoc;
|
||||
let wideBook: BookDoc;
|
||||
|
||||
describe('Paginator table layout', () => {
|
||||
let paginator: Renderer;
|
||||
|
||||
beforeAll(async () => {
|
||||
layoutBook = await loadEPUB(LAYOUT_EPUB_URL, 'sample-table-layout.epub');
|
||||
wideBook = await loadEPUB(WIDE_EPUB_URL, 'sample-table-wide.epub');
|
||||
await import('foliate-js/paginator.js');
|
||||
}, 30000);
|
||||
|
||||
const createPaginator = (width: number) => {
|
||||
const el = document.createElement('foliate-paginator') as Renderer;
|
||||
Object.assign(el.style, {
|
||||
width: `${width}px`,
|
||||
height: '800px',
|
||||
position: 'absolute',
|
||||
left: '0',
|
||||
top: '0',
|
||||
});
|
||||
document.body.appendChild(el);
|
||||
return el;
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
if (paginator) {
|
||||
try {
|
||||
paginator.destroy();
|
||||
} catch {
|
||||
/* iframe body may already be torn down */
|
||||
}
|
||||
paginator.remove();
|
||||
}
|
||||
});
|
||||
|
||||
it('renders layout tables without a horizontal scrollbar', async () => {
|
||||
paginator = createPaginator(320);
|
||||
paginator.open(layoutBook);
|
||||
paginator.setStyles?.(getStyles(makeViewSettings(), undefined, []));
|
||||
|
||||
let checked = 0;
|
||||
// Measure each section while it is still loaded (the paginator unloads
|
||||
// sections as we navigate, so earlier docs would be detached afterwards).
|
||||
for (const index of TABLE_SECTION_INDICES) {
|
||||
const stabilized = waitForStabilized(paginator);
|
||||
await paginator.goTo({ index });
|
||||
await stabilized;
|
||||
const doc = getSectionDoc(paginator, index);
|
||||
expect(doc, `section ${index} doc`).toBeTruthy();
|
||||
paginator.setStyles?.(getStyles(makeViewSettings(), undefined, []));
|
||||
applyScrollableStyle(doc!);
|
||||
|
||||
for (const wrapper of doc!.querySelectorAll<HTMLElement>(`.${SCROLL_WRAPPER_CLASS}`)) {
|
||||
expect(showsScrollbar(wrapper), `section ${index}: layout table shows a scrollbar`).toBe(
|
||||
false,
|
||||
);
|
||||
expect(isClipped(wrapper), `section ${index}: layout table is clipped`).toBe(false);
|
||||
checked += 1;
|
||||
}
|
||||
}
|
||||
expect(checked).toBeGreaterThan(0);
|
||||
}, 60000);
|
||||
|
||||
it('scrolls a table too wide for its column instead of clipping it', async () => {
|
||||
paginator = createPaginator(600);
|
||||
paginator.open(wideBook);
|
||||
paginator.setStyles?.(getStyles(makeViewSettings(), undefined, []));
|
||||
|
||||
const stabilized = waitForStabilized(paginator);
|
||||
await paginator.goTo({ index: 0 });
|
||||
await stabilized;
|
||||
const doc = getSectionDoc(paginator, 0);
|
||||
expect(doc, 'wide section doc').toBeTruthy();
|
||||
paginator.setStyles?.(getStyles(makeViewSettings(), undefined, []));
|
||||
applyScrollableStyle(doc!);
|
||||
|
||||
const wrapper = doc!.querySelector<HTMLElement>(`.${SCROLL_WRAPPER_CLASS}`)!;
|
||||
expect(wrapper, 'wide-table wrapper').toBeTruthy();
|
||||
const table = wrapper.querySelector(':scope > table') as HTMLElement;
|
||||
|
||||
// Confirm this really is a wide table whose non-wrapping cells can't shrink
|
||||
// to fit a reading column.
|
||||
table.style.width = 'min-content';
|
||||
const minContent = table.getBoundingClientRect().width;
|
||||
table.style.removeProperty('width');
|
||||
expect(minContent, 'wide table min-content width').toBeGreaterThan(500);
|
||||
|
||||
// Constrain the wrapper well below the table — the real-app situation where a
|
||||
// wide table can't fit its column. (The bare paginator otherwise widens the
|
||||
// column to fit content, so we reproduce the column constraint explicitly.)
|
||||
wrapper.style.width = '250px';
|
||||
wrapper.style.maxWidth = '250px';
|
||||
// The wrapper is statically overflow-x:auto, so an over-wide table scrolls.
|
||||
await poll(() => overflow(wrapper) > TOLERANCE_PX);
|
||||
|
||||
expect(overflow(wrapper), 'wide table should overflow its constrained column').toBeGreaterThan(
|
||||
TOLERANCE_PX,
|
||||
);
|
||||
expect(['auto', 'scroll'], 'wide table overflow-x').toContain(overflowX(wrapper));
|
||||
expect(isClipped(wrapper), 'wide table is clipped instead of scrolling').toBe(false);
|
||||
}, 60000);
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { existsSync, readdirSync, readFileSync } from 'fs';
|
||||
import { isAbsolute, resolve } from 'path';
|
||||
|
||||
/**
|
||||
* Regression guard for a build-packaging bug (scanned PDFs rendered blank in
|
||||
* CI builds of 0.11.2, but fine locally).
|
||||
*
|
||||
* pdfjs-dist 5.7.x moved several image decoders (notably JBIG2 — the codec used
|
||||
* by virtually every black-and-white *scanned* PDF) from pure JS into
|
||||
* WebAssembly modules that the worker fetches at runtime from `wasmUrl`
|
||||
* (`/vendor/pdfjs/` — see packages/foliate-js/pdf.js). The `copy-pdfjs-wasm`
|
||||
* npm script only copied an explicit allow-list (`openjpeg.wasm`, `qcms_bg.wasm`)
|
||||
* and silently dropped `jbig2.wasm`. `cpx` does not error when a glob matches
|
||||
* nothing, so the missing decoder went unnoticed: the worker loaded, pages
|
||||
* turned, but JBIG2 image decoding failed → blank pages.
|
||||
*
|
||||
* Invariant: every `.wasm` file the *bundled* pdf.js (main + worker) references
|
||||
* and that actually exists in pdfjs-dist's `wasm/` directory must be copied into
|
||||
* the public vendor folder by `copy-pdfjs-wasm`.
|
||||
*/
|
||||
|
||||
const appRoot = process.cwd();
|
||||
|
||||
/** Extract the first double-quoted argument from a cpx-based npm script. */
|
||||
const sourceGlobOf = (script: string): string => {
|
||||
const match = script.match(/"([^"]+)"/);
|
||||
if (!match) throw new Error(`No quoted source glob in script: ${script}`);
|
||||
return match[1]!;
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolve which files a cpx source glob of the form `<dir>/<token>` copies,
|
||||
* where `<token>` is `*`, a `{a,b,c}` brace list, or a single filename.
|
||||
* Returns the absolute directory and the concrete set of copied basenames.
|
||||
*/
|
||||
const resolveCpxGlob = (glob: string): { dir: string; files: Set<string> } => {
|
||||
const slash = glob.lastIndexOf('/');
|
||||
const dirPart = glob.slice(0, slash);
|
||||
const token = glob.slice(slash + 1);
|
||||
const dir = isAbsolute(dirPart) ? dirPart : resolve(appRoot, dirPart);
|
||||
|
||||
let names: string[];
|
||||
if (token.includes('*')) {
|
||||
names = readdirSync(dir);
|
||||
} else if (token.startsWith('{') && token.endsWith('}')) {
|
||||
names = token.slice(1, -1).split(',');
|
||||
} else {
|
||||
names = [token];
|
||||
}
|
||||
return { dir, files: new Set(names) };
|
||||
};
|
||||
|
||||
describe('pdfjs vendor wasm assets', () => {
|
||||
const pkg = JSON.parse(readFileSync(resolve(appRoot, 'package.json'), 'utf8')) as {
|
||||
scripts: Record<string, string>;
|
||||
};
|
||||
|
||||
it('copies every wasm decoder the bundled pdf.js references', () => {
|
||||
// Files the worker/main bundle are copied from (source of truth for CI).
|
||||
const jsGlob = sourceGlobOf(pkg.scripts['copy-pdfjs-js']!);
|
||||
const { dir: jsDir, files: jsFiles } = resolveCpxGlob(jsGlob);
|
||||
|
||||
// The wasm modules available in pdfjs-dist and what the copy script ships.
|
||||
const wasmGlob = sourceGlobOf(pkg.scripts['copy-pdfjs-wasm']!);
|
||||
const { dir: wasmDir, files: copiedWasm } = resolveCpxGlob(wasmGlob);
|
||||
|
||||
const availableWasm = new Set(readdirSync(wasmDir).filter((f) => f.endsWith('.wasm')));
|
||||
|
||||
// Scan the bundled JS for `*.wasm` references, keeping only ones that map to
|
||||
// a real file (drops minified false positives like `e.wasm`/`t.wasm`).
|
||||
const referenced = new Set<string>();
|
||||
for (const file of jsFiles) {
|
||||
const full = resolve(jsDir, file);
|
||||
if (!existsSync(full)) continue;
|
||||
const text = readFileSync(full, 'utf8');
|
||||
for (const m of text.matchAll(/[A-Za-z0-9_-]+\.wasm/g)) {
|
||||
if (availableWasm.has(m[0])) referenced.add(m[0]);
|
||||
}
|
||||
}
|
||||
|
||||
// Sanity: the bump that caused the bug must still ship jbig2 as wasm.
|
||||
expect(referenced.has('jbig2.wasm'), 'expected pdf.js to reference jbig2.wasm').toBe(true);
|
||||
|
||||
const missing = [...referenced].filter((w) => !copiedWasm.has(w));
|
||||
expect(missing, `copy-pdfjs-wasm must copy these referenced wasm files: ${missing}`).toEqual(
|
||||
[],
|
||||
);
|
||||
});
|
||||
});
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -107,4 +107,25 @@ describe('NativeAppService.saveFile share gating', () => {
|
||||
expect(shareFileMock).not.toHaveBeenCalled();
|
||||
expect(saveDialogMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
// The book "Send" flow hands an already-on-disk file straight to the share
|
||||
// sheet via `filePath` and passes `null` content so nothing gets re-buffered
|
||||
// into memory. The file at `filePath` must be shared verbatim without any
|
||||
// write happening first.
|
||||
test('shares the file at filePath without buffering when content is null', async () => {
|
||||
const service = await loadServiceWithOS('macos');
|
||||
await service.saveFile('book.epub', null, {
|
||||
share: true,
|
||||
mimeType: 'application/epub+zip',
|
||||
filePath: '/abs/path/book.epub',
|
||||
});
|
||||
expect(shareFileMock).toHaveBeenCalledTimes(1);
|
||||
expect(shareFileMock).toHaveBeenCalledWith(
|
||||
'/abs/path/book.epub',
|
||||
expect.objectContaining({ mimeType: 'application/epub+zip' }),
|
||||
);
|
||||
expect(writeFileMock).not.toHaveBeenCalled();
|
||||
expect(writeTextFileMock).not.toHaveBeenCalled();
|
||||
expect(saveDialogMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,9 +1,19 @@
|
||||
import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { fetch as tauriFetch } from '@tauri-apps/plugin-http';
|
||||
import { ReadwiseClient } from '@/services/readwise/ReadwiseClient';
|
||||
import { READWISE_API_BASE_URL } from '@/services/constants';
|
||||
import { isTauriAppPlatform } from '@/services/environment';
|
||||
import type { ReadwiseSettings } from '@/types/settings';
|
||||
import type { Book, BookNote } from '@/types/book';
|
||||
|
||||
vi.mock('@tauri-apps/plugin-http', () => ({
|
||||
fetch: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@/services/environment', () => ({
|
||||
isTauriAppPlatform: vi.fn(() => false),
|
||||
}));
|
||||
|
||||
const makeSettings = (overrides: Partial<ReadwiseSettings> = {}): ReadwiseSettings => ({
|
||||
enabled: true,
|
||||
accessToken: 'test-token',
|
||||
@@ -37,6 +47,10 @@ describe('ReadwiseClient base URL', () => {
|
||||
beforeEach(() => {
|
||||
fetchMock = vi.fn().mockResolvedValue(new Response(null, { status: 204 }));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
vi.mocked(tauriFetch)
|
||||
.mockReset()
|
||||
.mockResolvedValue(new Response(null, { status: 204 }));
|
||||
vi.mocked(isTauriAppPlatform).mockReturnValue(false);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -79,4 +93,20 @@ describe('ReadwiseClient base URL', () => {
|
||||
expect(init.method).toBe('POST');
|
||||
expect((init.headers as Record<string, string>)['Authorization']).toBe('Token test-token');
|
||||
});
|
||||
|
||||
test('validateToken uses the Tauri HTTP transport in the desktop app', async () => {
|
||||
vi.mocked(isTauriAppPlatform).mockReturnValue(true);
|
||||
const client = new ReadwiseClient(makeSettings({ baseUrl: 'https://example.com/api/v2' }));
|
||||
|
||||
await client.validateToken();
|
||||
|
||||
expect(tauriFetch).toHaveBeenCalledWith('https://example.com/api/v2/auth/', {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: 'Token test-token',
|
||||
},
|
||||
body: undefined,
|
||||
});
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -118,6 +118,66 @@ describe('customFontStore', () => {
|
||||
const restored = useCustomFontStore.getState().fonts[0]!;
|
||||
expect(restored.deletedAt).toBeUndefined();
|
||||
});
|
||||
|
||||
// ── reincarnation on re-import (issue #4410) ───────────────────
|
||||
// Deleting a font writes a server-side tombstone. Under CRDT
|
||||
// remove-wins a plain re-upload can't revive it, so the next pull
|
||||
// re-applies the delete and the font silently disappears while
|
||||
// logged into cloud sync. Re-import must mint a reincarnation token.
|
||||
|
||||
test('re-import after a local delete mints + publishes a reincarnation token', () => {
|
||||
useCustomFontStore.getState().addFont('/fonts/MyFont.ttf', { contentId: 'cid-1' });
|
||||
useCustomFontStore.getState().removeFont(useCustomFontStore.getState().fonts[0]!.id);
|
||||
mockPublishReplicaUpsert.mockClear();
|
||||
|
||||
const revived = useCustomFontStore.getState().addFont('/fonts/MyFont.ttf', {
|
||||
contentId: 'cid-1',
|
||||
});
|
||||
|
||||
expect(revived.deletedAt).toBeUndefined();
|
||||
expect(revived.reincarnation).toBeTruthy();
|
||||
expect(mockPublishReplicaUpsert).toHaveBeenCalledTimes(1);
|
||||
const call = mockPublishReplicaUpsert.mock.calls[0]!;
|
||||
expect(call[0]).toBe('font');
|
||||
expect(call[2]).toBe('cid-1');
|
||||
// 4th arg is the reincarnation token handed to publishReplicaUpsert.
|
||||
expect(call[3]).toBe(revived.reincarnation);
|
||||
});
|
||||
|
||||
test('re-import of a still-live font with the same contentId mints a token (stale-local race)', () => {
|
||||
// Another device may have tombstoned the row while this device still
|
||||
// has the font live. Minting on live re-import lets the upsert win
|
||||
// remove-wins on every device's next pull. Mirrors dictionaryService.
|
||||
useCustomFontStore.getState().addFont('/fonts/MyFont.ttf', { contentId: 'cid-1' });
|
||||
expect(useCustomFontStore.getState().fonts[0]!.reincarnation).toBeUndefined();
|
||||
|
||||
const reimported = useCustomFontStore.getState().addFont('/fonts/MyFont.ttf', {
|
||||
contentId: 'cid-1',
|
||||
});
|
||||
expect(reimported.deletedAt).toBeUndefined();
|
||||
expect(reimported.reincarnation).toBeTruthy();
|
||||
});
|
||||
|
||||
test('re-import preserves an existing reincarnation token instead of churning a new one', () => {
|
||||
useCustomFontStore.getState().addFont('/fonts/MyFont.ttf', { contentId: 'cid-1' });
|
||||
useCustomFontStore.getState().removeFont(useCustomFontStore.getState().fonts[0]!.id);
|
||||
const firstToken = useCustomFontStore
|
||||
.getState()
|
||||
.addFont('/fonts/MyFont.ttf', { contentId: 'cid-1' }).reincarnation;
|
||||
expect(firstToken).toBeTruthy();
|
||||
|
||||
const secondToken = useCustomFontStore
|
||||
.getState()
|
||||
.addFont('/fonts/MyFont.ttf', { contentId: 'cid-1' }).reincarnation;
|
||||
expect(secondToken).toBe(firstToken);
|
||||
});
|
||||
|
||||
test('brand-new import does not mint a reincarnation token', () => {
|
||||
const fresh = useCustomFontStore.getState().addFont('/fonts/Brand-New.ttf', {
|
||||
contentId: 'cid-new',
|
||||
});
|
||||
expect(fresh.reincarnation).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ── removeFont ─────────────────────────────────────────────────
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useSettingsStore } from '@/store/settingsStore';
|
||||
import { CustomTexture } from '@/styles/textures';
|
||||
import { SystemSettings } from '@/types/settings';
|
||||
import { EnvConfigType } from '@/services/environment';
|
||||
import { publishReplicaUpsert } from '@/services/sync/replicaPublish';
|
||||
|
||||
// Mock textures module - we need createCustomTexture, and the mount/unmount functions
|
||||
vi.mock('@/styles/textures', async (importOriginal) => {
|
||||
@@ -15,6 +16,15 @@ vi.mock('@/styles/textures', async (importOriginal) => {
|
||||
};
|
||||
});
|
||||
|
||||
// Replica-publish helpers fan out to the network — stub them so tests stay
|
||||
// hermetic. We assert the reincarnation token reaches the upsert via a spy.
|
||||
vi.mock('@/services/sync/replicaPublish', () => ({
|
||||
publishReplicaUpsert: vi.fn(),
|
||||
publishReplicaDelete: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockPublishReplicaUpsert = vi.mocked(publishReplicaUpsert);
|
||||
|
||||
function makeTexture(
|
||||
overrides: Partial<CustomTexture> & { id: string; name: string },
|
||||
): CustomTexture {
|
||||
@@ -100,6 +110,65 @@ describe('customTextureStore', () => {
|
||||
const updated = useCustomTextureStore.getState().textures[0]!;
|
||||
expect(updated.deletedAt).toBeUndefined();
|
||||
});
|
||||
|
||||
// ── reincarnation on re-import (same latent bug as issue #4410) ──
|
||||
// A deleted texture writes a server-side tombstone; under CRDT
|
||||
// remove-wins a plain re-import can't revive it, so the next pull
|
||||
// re-applies the delete. Re-import must mint a reincarnation token.
|
||||
|
||||
test('re-import after a local delete mints + publishes a reincarnation token', () => {
|
||||
useCustomTextureStore.getState().addTexture('/images/wood.png', { contentId: 'cid-1' });
|
||||
useCustomTextureStore
|
||||
.getState()
|
||||
.removeTexture(useCustomTextureStore.getState().textures[0]!.id);
|
||||
mockPublishReplicaUpsert.mockClear();
|
||||
|
||||
const revived = useCustomTextureStore.getState().addTexture('/images/wood.png', {
|
||||
contentId: 'cid-1',
|
||||
});
|
||||
|
||||
expect(revived.deletedAt).toBeUndefined();
|
||||
expect(revived.reincarnation).toBeTruthy();
|
||||
expect(mockPublishReplicaUpsert).toHaveBeenCalledTimes(1);
|
||||
const call = mockPublishReplicaUpsert.mock.calls[0]!;
|
||||
expect(call[0]).toBe('texture');
|
||||
expect(call[2]).toBe('cid-1');
|
||||
expect(call[3]).toBe(revived.reincarnation);
|
||||
});
|
||||
|
||||
test('re-import of a still-live texture with the same contentId mints a token (stale-local race)', () => {
|
||||
useCustomTextureStore.getState().addTexture('/images/wood.png', { contentId: 'cid-1' });
|
||||
expect(useCustomTextureStore.getState().textures[0]!.reincarnation).toBeUndefined();
|
||||
|
||||
const reimported = useCustomTextureStore.getState().addTexture('/images/wood.png', {
|
||||
contentId: 'cid-1',
|
||||
});
|
||||
expect(reimported.deletedAt).toBeUndefined();
|
||||
expect(reimported.reincarnation).toBeTruthy();
|
||||
});
|
||||
|
||||
test('re-import preserves an existing reincarnation token instead of churning a new one', () => {
|
||||
useCustomTextureStore.getState().addTexture('/images/wood.png', { contentId: 'cid-1' });
|
||||
useCustomTextureStore
|
||||
.getState()
|
||||
.removeTexture(useCustomTextureStore.getState().textures[0]!.id);
|
||||
const firstToken = useCustomTextureStore
|
||||
.getState()
|
||||
.addTexture('/images/wood.png', { contentId: 'cid-1' }).reincarnation;
|
||||
expect(firstToken).toBeTruthy();
|
||||
|
||||
const secondToken = useCustomTextureStore
|
||||
.getState()
|
||||
.addTexture('/images/wood.png', { contentId: 'cid-1' }).reincarnation;
|
||||
expect(secondToken).toBe(firstToken);
|
||||
});
|
||||
|
||||
test('brand-new import does not mint a reincarnation token', () => {
|
||||
const fresh = useCustomTextureStore.getState().addTexture('/images/brand-new.png', {
|
||||
contentId: 'cid-new',
|
||||
});
|
||||
expect(fresh.reincarnation).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ── removeTexture ──────────────────────────────────────────────
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import * as CFI from 'foliate-js/epubcfi.js';
|
||||
|
||||
const XHTML = (str: string) => new DOMParser().parseFromString(str, 'application/xhtml+xml');
|
||||
|
||||
// cfi-skip marks a layout-only wrapper as invisible to CFI: unlike cfi-inert (which
|
||||
// removes the node AND its subtree), cfi-skip hoists the node's children into its
|
||||
// parent, so the wrapped content keeps the exact CFI it had before being wrapped.
|
||||
// This mirrors what applyScrollableStyle does when it wraps a wide <table>/<math>.
|
||||
|
||||
const TABLE = `<table id="tbl"><tbody><tr><td><p id="cell">xxx<em>yyy</em>0123456789</p></td></tr></tbody></table>`;
|
||||
|
||||
const body = (tableMarkup: string) =>
|
||||
XHTML(`<html xmlns="http://www.w3.org/1999/xhtml">
|
||||
<head><title>Test</title></head>
|
||||
<body id="body01">
|
||||
<p id="p1">First paragraph</p>
|
||||
<p>Second paragraph</p>
|
||||
${tableMarkup}
|
||||
<p>Fourth paragraph</p>
|
||||
<img id="svgimg" src="foo.svg" alt="an image"/>
|
||||
</body>
|
||||
</html>`);
|
||||
|
||||
// Baseline: a table sitting directly in body, no wrapper.
|
||||
const basePage = () => body(TABLE);
|
||||
// The same table wrapped in a cfi-skip layout div (as applyScrollableStyle does).
|
||||
const pageWithSkipWrapper = () => body(`<div class="scroll-wrapper" cfi-skip="">${TABLE}</div>`);
|
||||
// Wrapper nested two deep, to prove the hoisting recurses.
|
||||
const pageWithNestedSkipWrappers = () =>
|
||||
body(`<div cfi-skip=""><div cfi-skip="">${TABLE}</div></div>`);
|
||||
|
||||
// Build a representative set of ranges (inside the table and around it) in a doc,
|
||||
// keyed by label so the same logical range can be built in every variant.
|
||||
const ranges = (doc: Document): Record<string, Range> => {
|
||||
const cell = doc.getElementById('cell')!;
|
||||
const xxx = cell.firstChild!; // "xxx"
|
||||
const em = cell.childNodes[1]!.firstChild!; // "yyy" inside <em>
|
||||
const digits = cell.childNodes[2]!; // "0123456789"
|
||||
const r = (build: (range: Range) => void) => {
|
||||
const range = doc.createRange();
|
||||
build(range);
|
||||
return range;
|
||||
};
|
||||
const point = (node: Node, offset: number) =>
|
||||
r((g) => {
|
||||
g.setStart(node, offset);
|
||||
g.collapse(true);
|
||||
});
|
||||
return {
|
||||
beforeTable: point(doc.getElementById('p1')!.firstChild!, 0),
|
||||
tableElement: point(doc.getElementById('tbl')!, 0), // collapsed at element → element CFI
|
||||
cellStart: r((g) => {
|
||||
g.setStart(xxx, 0);
|
||||
g.setEnd(xxx, 3);
|
||||
}),
|
||||
cellEm: point(em, 0),
|
||||
cellDigits: r((g) => {
|
||||
g.setStart(digits, 1);
|
||||
g.setEnd(digits, 5);
|
||||
}),
|
||||
spanningCell: r((g) => {
|
||||
g.setStart(xxx, 1);
|
||||
g.setEnd(digits, 4);
|
||||
}),
|
||||
afterTable: point(doc.getElementById('svgimg')!, 0),
|
||||
};
|
||||
};
|
||||
|
||||
const variants = () => [
|
||||
['skip wrapper', pageWithSkipWrapper()] as const,
|
||||
['nested skip wrappers', pageWithNestedSkipWrappers()] as const,
|
||||
];
|
||||
|
||||
describe('epubcfi cfi-skip wrapper transparency', () => {
|
||||
it('every base-page range round-trips unchanged (fromRange → toRange → fromRange)', () => {
|
||||
const doc = basePage();
|
||||
for (const [label, range] of Object.entries(ranges(doc))) {
|
||||
const cfi = CFI.fromRange(range);
|
||||
const resolved = CFI.toRange(doc, CFI.parse(cfi));
|
||||
expect(resolved, label).not.toBeNull();
|
||||
expect(CFI.fromRange(resolved!), label).toBe(cfi);
|
||||
}
|
||||
});
|
||||
|
||||
it('produces the SAME CFI for an equivalent range with and without the skip wrapper', () => {
|
||||
const baseCFIs = Object.fromEntries(
|
||||
Object.entries(ranges(basePage())).map(([k, range]) => [k, CFI.fromRange(range)]),
|
||||
);
|
||||
for (const [variantLabel, doc] of variants()) {
|
||||
for (const [key, range] of Object.entries(ranges(doc))) {
|
||||
expect(CFI.fromRange(range), `${variantLabel}: ${key}`).toBe(baseCFIs[key]);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('resolves a base-page CFI to the same content inside the wrapped table', () => {
|
||||
const base = basePage();
|
||||
const baseCFIs = Object.entries(ranges(base)).map(
|
||||
([key, range]) => [key, CFI.fromRange(range), range.toString()] as const,
|
||||
);
|
||||
for (const [variantLabel, doc] of variants()) {
|
||||
for (const [key, cfi, text] of baseCFIs) {
|
||||
const resolved = CFI.toRange(doc, CFI.parse(cfi));
|
||||
expect(resolved, `${variantLabel}: ${key}`).not.toBeNull();
|
||||
expect(resolved!.toString(), `${variantLabel}: ${key}`).toBe(text);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps the wrapper div out of the generated CFI (table stays at the same step)', () => {
|
||||
const baseCFI = CFI.fromRange(ranges(basePage())['tableElement']);
|
||||
for (const [variantLabel, doc] of variants()) {
|
||||
const wrappedCFI = CFI.fromRange(ranges(doc)['tableElement']);
|
||||
expect(wrappedCFI, variantLabel).toBe(baseCFI);
|
||||
expect(wrappedCFI, variantLabel).toContain('[tbl]');
|
||||
}
|
||||
});
|
||||
|
||||
it('resolves the table element across the wrapper via toElement', () => {
|
||||
const tblParts = CFI.parse(CFI.fromRange(ranges(basePage())['tableElement']));
|
||||
for (const [variantLabel, doc] of variants()) {
|
||||
const el = CFI.toElement(doc, tblParts[0]);
|
||||
expect(el.id, variantLabel).toBe('tbl');
|
||||
}
|
||||
});
|
||||
|
||||
it('does NOT hoist a plain wrapper that lacks cfi-skip (it stays a CFI level)', () => {
|
||||
const plain = body(`<div class="scroll-wrapper">${TABLE}</div>`);
|
||||
const wrappedCFI = CFI.fromRange(ranges(plain)['tableElement']);
|
||||
const baseCFI = CFI.fromRange(ranges(basePage())['tableElement']);
|
||||
// The table now sits one element step deeper (inside the counted div), so the
|
||||
// CFI differs from the unwrapped baseline — proving cfi-skip is what makes the
|
||||
// wrapper transparent, not merely the presence of a wrapper div.
|
||||
expect(wrappedCFI).not.toBe(baseCFI);
|
||||
// It still resolves to the table, just at a deeper path.
|
||||
expect(CFI.toElement(plain, CFI.parse(wrappedCFI)[0]).id).toBe('tbl');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,308 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import {
|
||||
applyScrollableStyle,
|
||||
applyTableTouchScroll,
|
||||
findScrollableBox,
|
||||
updateTableFit,
|
||||
shouldTableScrollConsumeTouch,
|
||||
shouldTableScrollConsumeWheel,
|
||||
SCROLL_WRAPPER_CLASS,
|
||||
SCROLL_WRAPPER_FIT_CLASS,
|
||||
} from '@/utils/scrollable';
|
||||
|
||||
describe('applyScrollableStyle', () => {
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
it('wraps a table in a horizontal scroll container', () => {
|
||||
document.body.innerHTML = `
|
||||
<div>
|
||||
<table>
|
||||
<tr>
|
||||
<td width="100">Cell 1</td>
|
||||
<td width="200">Cell 2</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
`;
|
||||
applyScrollableStyle(document);
|
||||
const table = document.querySelector('table')!;
|
||||
const wrapper = table.parentElement;
|
||||
expect(wrapper?.classList.contains(SCROLL_WRAPPER_CLASS)).toBe(true);
|
||||
expect(table.style.transform).toBe('');
|
||||
});
|
||||
|
||||
it('wraps multiple tables independently', () => {
|
||||
document.body.innerHTML = `
|
||||
<div>
|
||||
<table id="t1"><tr><td>A</td></tr></table>
|
||||
<table id="t2"><tr><td>B</td></tr></table>
|
||||
</div>
|
||||
`;
|
||||
applyScrollableStyle(document);
|
||||
const wrappers = document.querySelectorAll(`.${SCROLL_WRAPPER_CLASS}`);
|
||||
expect(wrappers).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('does not double-wrap when applyScrollableStyle runs twice', () => {
|
||||
document.body.innerHTML = `
|
||||
<div>
|
||||
<table><tr><td>Cell</td></tr></table>
|
||||
</div>
|
||||
`;
|
||||
applyScrollableStyle(document);
|
||||
applyScrollableStyle(document);
|
||||
expect(document.querySelectorAll(`.${SCROLL_WRAPPER_CLASS}`)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('does not crash on a table whose parent is body', () => {
|
||||
document.body.innerHTML = `
|
||||
<table>
|
||||
<tr><td width="100">A</td></tr>
|
||||
</table>
|
||||
`;
|
||||
applyScrollableStyle(document);
|
||||
const table = document.querySelector('table')!;
|
||||
expect(table.parentElement?.classList.contains(SCROLL_WRAPPER_CLASS)).toBe(true);
|
||||
});
|
||||
|
||||
it('wraps a display equation (math that is the sole content of its container)', () => {
|
||||
document.body.innerHTML = `
|
||||
<div data-type="equation">
|
||||
<math><mrow><mi>x</mi></mrow></math>
|
||||
</div>
|
||||
`;
|
||||
applyScrollableStyle(document);
|
||||
const math = document.querySelector('math')!;
|
||||
expect(math.parentElement?.classList.contains(SCROLL_WRAPPER_CLASS)).toBe(true);
|
||||
});
|
||||
|
||||
it('does not wrap inline math that flows with surrounding text', () => {
|
||||
document.body.innerHTML = `<p>tokens <math><mi>x</mi></math>, then more text</p>`;
|
||||
applyScrollableStyle(document);
|
||||
const math = document.querySelector('math')!;
|
||||
expect(math.parentElement?.classList.contains(SCROLL_WRAPPER_CLASS)).toBe(false);
|
||||
expect(math.parentElement?.tagName.toLowerCase()).toBe('p');
|
||||
});
|
||||
|
||||
it('wraps math[display="block"] even when it has a sibling (e.g. an equation number)', () => {
|
||||
document.body.innerHTML = `<div><math display="block"><mi>x</mi></math><span>(1)</span></div>`;
|
||||
applyScrollableStyle(document);
|
||||
const math = document.querySelector('math')!;
|
||||
expect(math.parentElement?.classList.contains(SCROLL_WRAPPER_CLASS)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateTableFit', () => {
|
||||
const makeWrapper = (scrollWidth: number, clientWidth: number) => {
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.className = SCROLL_WRAPPER_CLASS;
|
||||
Object.defineProperty(wrapper, 'scrollWidth', { value: scrollWidth, configurable: true });
|
||||
Object.defineProperty(wrapper, 'clientWidth', { value: clientWidth, configurable: true });
|
||||
return wrapper;
|
||||
};
|
||||
|
||||
it('marks a table that fits its column as not-a-scroll-container', () => {
|
||||
const wrapper = makeWrapper(200, 200);
|
||||
updateTableFit(wrapper);
|
||||
expect(wrapper.classList.contains(SCROLL_WRAPPER_FIT_CLASS)).toBe(true);
|
||||
});
|
||||
|
||||
it('leaves a table wider than its column scrollable', () => {
|
||||
const wrapper = makeWrapper(400, 200);
|
||||
updateTableFit(wrapper);
|
||||
expect(wrapper.classList.contains(SCROLL_WRAPPER_FIT_CLASS)).toBe(false);
|
||||
});
|
||||
|
||||
it('treats overflow within tolerance as fitting', () => {
|
||||
const wrapper = makeWrapper(203, 200); // 3px slop ≤ tolerance
|
||||
updateTableFit(wrapper);
|
||||
expect(wrapper.classList.contains(SCROLL_WRAPPER_FIT_CLASS)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('shouldTableScrollConsumeTouch', () => {
|
||||
it('returns false when the table is not wider than its container', () => {
|
||||
const wrapper = document.createElement('div');
|
||||
Object.defineProperty(wrapper, 'scrollWidth', { value: 100, configurable: true });
|
||||
Object.defineProperty(wrapper, 'clientWidth', { value: 100, configurable: true });
|
||||
expect(shouldTableScrollConsumeTouch(wrapper, -40, 0)).toBe(false);
|
||||
});
|
||||
|
||||
it('consumes a horizontal swipe when more content is available to the right', () => {
|
||||
const wrapper = document.createElement('div');
|
||||
Object.defineProperty(wrapper, 'scrollWidth', { value: 400, configurable: true });
|
||||
Object.defineProperty(wrapper, 'clientWidth', { value: 200, configurable: true });
|
||||
Object.defineProperty(wrapper, 'scrollLeft', { value: 0, configurable: true });
|
||||
expect(shouldTableScrollConsumeTouch(wrapper, -40, 0)).toBe(true);
|
||||
});
|
||||
|
||||
it('still consumes at the right edge so it never chains to a page turn', () => {
|
||||
const wrapper = document.createElement('div');
|
||||
Object.defineProperty(wrapper, 'scrollWidth', { value: 400, configurable: true });
|
||||
Object.defineProperty(wrapper, 'clientWidth', { value: 200, configurable: true });
|
||||
Object.defineProperty(wrapper, 'scrollLeft', { value: 200, configurable: true });
|
||||
expect(shouldTableScrollConsumeTouch(wrapper, -40, 0)).toBe(true);
|
||||
});
|
||||
|
||||
it('ignores a vertical swipe on a box that only scrolls horizontally', () => {
|
||||
const wrapper = document.createElement('div');
|
||||
Object.defineProperty(wrapper, 'scrollWidth', { value: 400, configurable: true });
|
||||
Object.defineProperty(wrapper, 'clientWidth', { value: 200, configurable: true });
|
||||
Object.defineProperty(wrapper, 'scrollLeft', { value: 0, configurable: true });
|
||||
expect(shouldTableScrollConsumeTouch(wrapper, -5, -40)).toBe(false);
|
||||
});
|
||||
|
||||
it('consumes a vertical swipe when more content is available below (tall code block)', () => {
|
||||
const wrapper = document.createElement('div');
|
||||
Object.defineProperty(wrapper, 'scrollHeight', { value: 400, configurable: true });
|
||||
Object.defineProperty(wrapper, 'clientHeight', { value: 200, configurable: true });
|
||||
Object.defineProperty(wrapper, 'scrollTop', { value: 0, configurable: true });
|
||||
// Finger up (dy < 0) scrolls down; content remains below the viewport.
|
||||
expect(shouldTableScrollConsumeTouch(wrapper, 0, -40)).toBe(true);
|
||||
});
|
||||
|
||||
it('still consumes a vertical swipe at the bottom edge (never turns the page)', () => {
|
||||
const wrapper = document.createElement('div');
|
||||
Object.defineProperty(wrapper, 'scrollHeight', { value: 400, configurable: true });
|
||||
Object.defineProperty(wrapper, 'clientHeight', { value: 200, configurable: true });
|
||||
Object.defineProperty(wrapper, 'scrollTop', { value: 200, configurable: true });
|
||||
expect(shouldTableScrollConsumeTouch(wrapper, 0, -40)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('shouldTableScrollConsumeWheel', () => {
|
||||
it('returns false when the table is not wider than its container', () => {
|
||||
const wrapper = document.createElement('div');
|
||||
Object.defineProperty(wrapper, 'scrollWidth', { value: 100, configurable: true });
|
||||
Object.defineProperty(wrapper, 'clientWidth', { value: 100, configurable: true });
|
||||
expect(shouldTableScrollConsumeWheel(wrapper, 40, 0)).toBe(false);
|
||||
});
|
||||
|
||||
it('consumes a horizontal wheel when more content is available to the right', () => {
|
||||
const wrapper = document.createElement('div');
|
||||
Object.defineProperty(wrapper, 'scrollWidth', { value: 400, configurable: true });
|
||||
Object.defineProperty(wrapper, 'clientWidth', { value: 200, configurable: true });
|
||||
Object.defineProperty(wrapper, 'scrollLeft', { value: 0, configurable: true });
|
||||
expect(shouldTableScrollConsumeWheel(wrapper, 40, 0)).toBe(true);
|
||||
});
|
||||
|
||||
it('still consumes at the right edge so it never chains to a page turn', () => {
|
||||
const wrapper = document.createElement('div');
|
||||
Object.defineProperty(wrapper, 'scrollWidth', { value: 400, configurable: true });
|
||||
Object.defineProperty(wrapper, 'clientWidth', { value: 200, configurable: true });
|
||||
// scrolled fully to the right edge
|
||||
Object.defineProperty(wrapper, 'scrollLeft', { value: 200, configurable: true });
|
||||
expect(shouldTableScrollConsumeWheel(wrapper, 40, 0)).toBe(true);
|
||||
});
|
||||
|
||||
it('still consumes at the left edge so it never chains to a page turn', () => {
|
||||
const wrapper = document.createElement('div');
|
||||
Object.defineProperty(wrapper, 'scrollWidth', { value: 400, configurable: true });
|
||||
Object.defineProperty(wrapper, 'clientWidth', { value: 200, configurable: true });
|
||||
Object.defineProperty(wrapper, 'scrollLeft', { value: 0, configurable: true });
|
||||
expect(shouldTableScrollConsumeWheel(wrapper, -40, 0)).toBe(true);
|
||||
});
|
||||
|
||||
it('ignores a vertical wheel on a box that only scrolls horizontally', () => {
|
||||
const wrapper = document.createElement('div');
|
||||
Object.defineProperty(wrapper, 'scrollWidth', { value: 400, configurable: true });
|
||||
Object.defineProperty(wrapper, 'clientWidth', { value: 200, configurable: true });
|
||||
Object.defineProperty(wrapper, 'scrollLeft', { value: 0, configurable: true });
|
||||
expect(shouldTableScrollConsumeWheel(wrapper, 5, 40)).toBe(false);
|
||||
});
|
||||
|
||||
it('consumes a vertical wheel down while a tall box can still scroll down', () => {
|
||||
const wrapper = document.createElement('div');
|
||||
Object.defineProperty(wrapper, 'scrollHeight', { value: 400, configurable: true });
|
||||
Object.defineProperty(wrapper, 'clientHeight', { value: 200, configurable: true });
|
||||
Object.defineProperty(wrapper, 'scrollTop', { value: 0, configurable: true });
|
||||
expect(shouldTableScrollConsumeWheel(wrapper, 0, 40)).toBe(true);
|
||||
});
|
||||
|
||||
it('still consumes a vertical wheel at the bottom edge (never turns the page)', () => {
|
||||
const wrapper = document.createElement('div');
|
||||
Object.defineProperty(wrapper, 'scrollHeight', { value: 400, configurable: true });
|
||||
Object.defineProperty(wrapper, 'clientHeight', { value: 200, configurable: true });
|
||||
Object.defineProperty(wrapper, 'scrollTop', { value: 200, configurable: true });
|
||||
expect(shouldTableScrollConsumeWheel(wrapper, 0, 40)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findScrollableBox', () => {
|
||||
const setScroll = (el: HTMLElement, scrollWidth: number, clientWidth: number) => {
|
||||
Object.defineProperty(el, 'scrollWidth', { value: scrollWidth, configurable: true });
|
||||
Object.defineProperty(el, 'clientWidth', { value: clientWidth, configurable: true });
|
||||
};
|
||||
|
||||
const makeWrapper = () => {
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.className = SCROLL_WRAPPER_CLASS;
|
||||
const inner = document.createElement('span');
|
||||
wrapper.appendChild(inner);
|
||||
document.body.appendChild(wrapper);
|
||||
return { wrapper, inner };
|
||||
};
|
||||
|
||||
it('routes a scrollable wrapper (table / equation) from a target inside it', () => {
|
||||
const { wrapper, inner } = makeWrapper();
|
||||
setScroll(wrapper, 400, 200);
|
||||
expect(findScrollableBox(inner)).toBe(wrapper);
|
||||
wrapper.remove();
|
||||
});
|
||||
|
||||
it('only routes the scroll-wrapper — bare pre / code / math do not capture gestures', () => {
|
||||
// pre/code/math rely on native overflow scrolling; only the wrapper that
|
||||
// applyScrollableStyle adds (tables, display equations) is routed.
|
||||
for (const tag of ['pre', 'code', 'math'] as const) {
|
||||
const box = document.createElement(tag);
|
||||
const inner = document.createElement('span');
|
||||
box.appendChild(inner);
|
||||
document.body.appendChild(box);
|
||||
setScroll(box, 400, 200);
|
||||
expect(findScrollableBox(inner), `${tag} should NOT route`).toBeNull();
|
||||
box.remove();
|
||||
}
|
||||
});
|
||||
|
||||
it('routes a wrapper that only overflows vertically (tall block)', () => {
|
||||
const { wrapper, inner } = makeWrapper();
|
||||
setScroll(wrapper, 200, 200); // fits horizontally
|
||||
Object.defineProperty(wrapper, 'scrollHeight', { value: 400, configurable: true });
|
||||
Object.defineProperty(wrapper, 'clientHeight', { value: 200, configurable: true });
|
||||
expect(findScrollableBox(inner)).toBe(wrapper);
|
||||
wrapper.remove();
|
||||
});
|
||||
|
||||
it('ignores a wrapper that fits (overflow within tolerance)', () => {
|
||||
const { wrapper, inner } = makeWrapper();
|
||||
setScroll(wrapper, 202, 200); // 2px slop ≤ tolerance
|
||||
expect(findScrollableBox(inner)).toBeNull();
|
||||
wrapper.remove();
|
||||
});
|
||||
|
||||
it('returns null for targets outside any scroll box', () => {
|
||||
const p = document.createElement('p');
|
||||
document.body.appendChild(p);
|
||||
expect(findScrollableBox(p)).toBeNull();
|
||||
expect(findScrollableBox(null)).toBeNull();
|
||||
p.remove();
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyTableTouchScroll', () => {
|
||||
it('attaches capture-phase touch and wheel listeners once per document', () => {
|
||||
document.documentElement.removeAttribute('data-readest-table-touch-scroll');
|
||||
const addSpy = vi.spyOn(document, 'addEventListener');
|
||||
applyTableTouchScroll(document);
|
||||
applyTableTouchScroll(document);
|
||||
const touchMoves = addSpy.mock.calls.filter(([type]) => type === 'touchmove');
|
||||
expect(touchMoves).toHaveLength(1);
|
||||
expect(touchMoves[0]?.[2]).toEqual({ capture: true, passive: false });
|
||||
const wheels = addSpy.mock.calls.filter(([type]) => type === 'wheel');
|
||||
expect(wheels).toHaveLength(1);
|
||||
expect(wheels[0]?.[2]).toEqual({ capture: true, passive: true });
|
||||
addSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -26,7 +26,6 @@ import {
|
||||
getStyles,
|
||||
applyImageStyle,
|
||||
keepTextAlignment,
|
||||
applyTableStyle,
|
||||
} from '@/utils/style';
|
||||
import {
|
||||
DEFAULT_BOOK_FONT,
|
||||
@@ -540,120 +539,3 @@ describe('keepTextAlignment', () => {
|
||||
expect(document.querySelector('blockquote')!.classList.contains('aligned-justify')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// applyTableStyle
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('applyTableStyle', () => {
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
it('applies scale transform to a table with td width attributes', () => {
|
||||
document.body.innerHTML = `
|
||||
<div>
|
||||
<table>
|
||||
<tr>
|
||||
<td width="100">Cell 1</td>
|
||||
<td width="200">Cell 2</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
`;
|
||||
applyTableStyle(document);
|
||||
const table = document.querySelector('table')!;
|
||||
// totalTableWidth = 100 + 200 = 300
|
||||
expect(table.style.transform).toContain('scale(');
|
||||
expect(table.style.transform).toContain('300');
|
||||
expect(table.style.transformOrigin).toBe('left top');
|
||||
});
|
||||
|
||||
it('applies scale transform using px width from td elements', () => {
|
||||
document.body.innerHTML = `
|
||||
<div>
|
||||
<table>
|
||||
<tr>
|
||||
<td width="150px">Cell 1</td>
|
||||
<td width="250px">Cell 2</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
`;
|
||||
applyTableStyle(document);
|
||||
const table = document.querySelector('table')!;
|
||||
// totalTableWidth = 150 + 250 = 400
|
||||
expect(table.style.transform).toContain('400');
|
||||
});
|
||||
|
||||
it('uses the widest row when multiple rows exist', () => {
|
||||
document.body.innerHTML = `
|
||||
<div>
|
||||
<table>
|
||||
<tr>
|
||||
<td width="100">A</td>
|
||||
<td width="100">B</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td width="200">C</td>
|
||||
<td width="300">D</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
`;
|
||||
applyTableStyle(document);
|
||||
const table = document.querySelector('table')!;
|
||||
// Second row: 200 + 300 = 500
|
||||
expect(table.style.transform).toContain('500');
|
||||
});
|
||||
|
||||
it('applies center-top origin when no cell widths are specified', () => {
|
||||
document.body.innerHTML = `
|
||||
<div style="width: 600px;">
|
||||
<table>
|
||||
<tr>
|
||||
<td>Cell 1</td>
|
||||
<td>Cell 2</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
`;
|
||||
applyTableStyle(document);
|
||||
const table = document.querySelector('table')!;
|
||||
// No cell widths => totalTableWidth = 0
|
||||
// jsdom getComputedStyle may return parentWidth as "" or "0px" so transform may not be set
|
||||
// But if parentContainerWidth > 0 it would use center top
|
||||
// In jsdom getComputedStyle returns "" for inline styles on div so parentContainerWidth = 0
|
||||
// Neither branch applies; verify no crash
|
||||
expect(table).toBeTruthy();
|
||||
});
|
||||
|
||||
it('handles tables with inline style width on td', () => {
|
||||
document.body.innerHTML = `
|
||||
<div>
|
||||
<table>
|
||||
<tr>
|
||||
<td style="width: 120px;">Cell 1</td>
|
||||
<td style="width: 180px;">Cell 2</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
`;
|
||||
applyTableStyle(document);
|
||||
const table = document.querySelector('table')!;
|
||||
// totalTableWidth = 120 + 180 = 300
|
||||
expect(table.style.transform).toContain('300');
|
||||
});
|
||||
|
||||
it('does not crash on a table without parent element', () => {
|
||||
// Table at root level inside body
|
||||
document.body.innerHTML = `
|
||||
<table>
|
||||
<tr><td width="100">A</td></tr>
|
||||
</table>
|
||||
`;
|
||||
// body is the parent, which is an Element, so it proceeds
|
||||
applyTableStyle(document);
|
||||
const table = document.querySelector('table')!;
|
||||
expect(table.style.transform).toContain('100');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,6 +9,7 @@ vi.mock('@/utils/misc', async (importOriginal) => {
|
||||
});
|
||||
|
||||
import { getStyles, ThemeCode } from '@/utils/style';
|
||||
import { CustomFont } from '@/styles/fonts';
|
||||
import { ViewSettings } from '@/types/book';
|
||||
import {
|
||||
DEFAULT_BOOK_FONT,
|
||||
@@ -570,6 +571,22 @@ describe('getColorStyles branches (via getStyles)', () => {
|
||||
expect(css).not.toMatch(/body\.pbg\s*\{[^}]*background-color:[^}]*!important/);
|
||||
});
|
||||
|
||||
it('overrides inline white backgrounds in dark mode when overrideColor is false', () => {
|
||||
const vs = makeViewSettings({ overrideColor: false });
|
||||
const theme = makeThemeCode({ isDarkMode: true, bg: '#1a1a1a', fg: '#e0e0e0' });
|
||||
const css = getStyles(vs, theme);
|
||||
expect(css).toContain('background-color: #1a1a1a !important');
|
||||
expect(css).toContain('background-color: #fff"]');
|
||||
expect(css).toContain('body.theme-dark');
|
||||
});
|
||||
|
||||
it('does not add inline white background overrides in light mode', () => {
|
||||
const vs = makeViewSettings({ overrideColor: false });
|
||||
const theme = makeThemeCode({ isDarkMode: false });
|
||||
const css = getStyles(vs, theme);
|
||||
expect(css).not.toContain('background-color: #fff"]');
|
||||
});
|
||||
|
||||
it('applies dark mode link color lightblue when overrideColor is false', () => {
|
||||
const vs = makeViewSettings({ overrideColor: false });
|
||||
const theme = makeThemeCode({ isDarkMode: true, bg: '#1a1a1a', fg: '#e0e0e0' });
|
||||
@@ -622,6 +639,33 @@ describe('getColorStyles branches (via getStyles)', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('does not tint table descendants in dark mode when overrideColor is false (#4419)', () => {
|
||||
const vs = makeViewSettings({ overrideColor: false });
|
||||
const theme = makeThemeCode({ isDarkMode: true, bg: '#1a1a1a', fg: '#e0e0e0' });
|
||||
const css = getStyles(vs, theme);
|
||||
// When the user has NOT enabled color override, the `blockquote, table *`
|
||||
// rule must not paint a tinted background on table descendants. Otherwise
|
||||
// plain tables — and the invisible spacer cells some books use for vertical
|
||||
// layout — render with a color different from the page background, and the
|
||||
// spacing between words appears to change in dark mode. Regression from
|
||||
// #4055; the #4028 zebra-row legibility case is now handled by the
|
||||
// light-background rewriters from #4392. See issue #4419.
|
||||
const match = css.match(/blockquote,\s*table\s*\*\s*\{([^}]*)\}/);
|
||||
expect(match).not.toBeNull();
|
||||
expect(match![1]).not.toContain('color-mix');
|
||||
});
|
||||
|
||||
it('still tints blockquotes in dark mode when overrideColor is false', () => {
|
||||
const vs = makeViewSettings({ overrideColor: false });
|
||||
const theme = makeThemeCode({ isDarkMode: true, bg: '#1a1a1a', fg: '#e0e0e0' });
|
||||
const css = getStyles(vs, theme);
|
||||
// The standalone `blockquote` rule keeps its dark-mode tint regardless of
|
||||
// overrideColor — only the shared `table *` part is gated.
|
||||
expect(css).toMatch(
|
||||
/blockquote\s*\{[^}]*background:\s*color-mix\(in srgb,\s*#1a1a1a\s*80%,\s*#000\)/,
|
||||
);
|
||||
});
|
||||
|
||||
it('makes svg/img backgrounds transparent when overrideColor is true', () => {
|
||||
const vs = makeViewSettings({ overrideColor: true });
|
||||
const theme = makeThemeCode();
|
||||
@@ -719,3 +763,63 @@ describe('getStyles integration', () => {
|
||||
expect(css).toContain('--theme-bg-color');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// custom @font-face inlining
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Build a loaded CustomFont (blob URL in memory) for testing. */
|
||||
function makeCustomFont(overrides: Partial<CustomFont> = {}): CustomFont {
|
||||
return {
|
||||
id: 'my-test-font',
|
||||
name: 'My Test Font',
|
||||
path: '/fonts/my-test-font.ttf',
|
||||
blobUrl: 'blob:http://localhost/my-test-font',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('custom @font-face inlining (via getStyles)', () => {
|
||||
const theme = makeThemeCode();
|
||||
|
||||
it('inlines an @font-face rule for each loaded custom font', () => {
|
||||
const vs = makeViewSettings();
|
||||
const css = getStyles(vs, theme, [makeCustomFont()]);
|
||||
expect(css).toContain('@font-face');
|
||||
expect(css).toContain('font-family: "My Test Font"');
|
||||
expect(css).toContain('blob:http://localhost/my-test-font');
|
||||
});
|
||||
|
||||
it('inlines the @font-face rules ahead of the rest of the stylesheet', () => {
|
||||
const vs = makeViewSettings();
|
||||
const css = getStyles(vs, theme, [makeCustomFont()]);
|
||||
// Paginator writes this CSS into the iframe before its first paint,
|
||||
// so the custom @font-face must precede the font-family declarations
|
||||
// that reference it (layout styles begin with `@namespace epub`).
|
||||
expect(css.indexOf('@font-face')).toBeLessThan(css.indexOf('@namespace epub'));
|
||||
});
|
||||
|
||||
it('emits one @font-face per loaded font', () => {
|
||||
const vs = makeViewSettings();
|
||||
const fonts = [
|
||||
makeCustomFont({ id: 'font-a', name: 'Font A', blobUrl: 'blob:http://localhost/a' }),
|
||||
makeCustomFont({ id: 'font-b', name: 'Font B', blobUrl: 'blob:http://localhost/b' }),
|
||||
];
|
||||
const css = getStyles(vs, theme, fonts);
|
||||
expect(css.split('@font-face').length - 1).toBe(2);
|
||||
expect(css).toContain('font-family: "Font A"');
|
||||
expect(css).toContain('font-family: "Font B"');
|
||||
});
|
||||
|
||||
it('skips fonts that have no blob URL', () => {
|
||||
const vs = makeViewSettings();
|
||||
const css = getStyles(vs, theme, [makeCustomFont({ blobUrl: undefined })]);
|
||||
expect(css).not.toContain('font-family: "My Test Font"');
|
||||
});
|
||||
|
||||
it('emits no custom @font-face when no fonts are passed', () => {
|
||||
const vs = makeViewSettings();
|
||||
const css = getStyles(vs, theme);
|
||||
expect(css).not.toContain('font-family: "My Test Font"');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -313,6 +313,27 @@ describe('transformStylesheet', () => {
|
||||
expect(result).toBe(css);
|
||||
});
|
||||
});
|
||||
|
||||
describe('dark mode light backgrounds', () => {
|
||||
it('rewrites white backgrounds in EPUB CSS to theme bg', () => {
|
||||
localStorage.setItem('themeMode', 'dark');
|
||||
localStorage.setItem('themeColor', 'default');
|
||||
localStorage.setItem('systemIsDarkMode', 'false');
|
||||
const css = '.callout { background-color: #ffffff; padding: 1em; }';
|
||||
const result = transformStylesheet(css, VW, VH, VERTICAL);
|
||||
expect(result).toMatch(/background-color:\s*#[0-9a-f]{6}/i);
|
||||
expect(result).not.toContain('background-color: #ffffff');
|
||||
localStorage.removeItem('themeMode');
|
||||
});
|
||||
|
||||
it('leaves dark backgrounds unchanged in dark mode', () => {
|
||||
localStorage.setItem('themeMode', 'dark');
|
||||
const css = '.panel { background-color: #222; }';
|
||||
const result = transformStylesheet(css, VW, VH, VERTICAL);
|
||||
expect(result).toContain('background-color: #222');
|
||||
localStorage.removeItem('themeMode');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getFootnoteStyles', () => {
|
||||
|
||||
@@ -492,4 +492,65 @@ describe('extractTxtFilenameMetadata', () => {
|
||||
it('returns empty object for empty input', () => {
|
||||
expect(extractTxtFilenameMetadata('')).toEqual({ title: '' });
|
||||
});
|
||||
|
||||
// Chinese web-novel TXT files are commonly named with a 【】 title and a
|
||||
// labeled author tacked on, e.g. 【书名】1-129 作者:起落.txt. There are no 《》,
|
||||
// so the whole name stays the title, but the labeled author must still be
|
||||
// extracted. See issue #4390.
|
||||
it('extracts a labeled author from a 【】-titled filename without 《》 (issue #4390)', () => {
|
||||
expect(extractTxtFilenameMetadata('【细雨飘香】1-129 作者:起落.txt')).toEqual({
|
||||
title: '【细雨飘香】1-129 作者:起落',
|
||||
author: '起落',
|
||||
});
|
||||
expect(extractTxtFilenameMetadata('【月如无恨月长圆】(1-154)作者:陈西.txt')).toEqual({
|
||||
title: '【月如无恨月长圆】(1-154)作者:陈西',
|
||||
author: '陈西',
|
||||
});
|
||||
});
|
||||
|
||||
it('does not mistake a leading 【tag】 for the author when no 作者 label is present', () => {
|
||||
expect(extractTxtFilenameMetadata('【完结】斗破苍穹.txt')).toEqual({
|
||||
title: '【完结】斗破苍穹',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('author resolution during conversion (issue #4390)', () => {
|
||||
const convertAndCaptureMetadata = async (name: string, content: string) => {
|
||||
const converter = new TxtToEpubConverter() as unknown as TxtConverterFlowPrivateAPI;
|
||||
let captured: TestMetadata | undefined;
|
||||
converter.detectEncoding = () => 'utf-8';
|
||||
converter.createEpub = async (_chapters, metadata) => {
|
||||
captured = metadata;
|
||||
return new Blob();
|
||||
};
|
||||
converter.extractChapters = () => [{ title: '第一章', content: '正文', isVolume: false }];
|
||||
const file = new File([content], name);
|
||||
await converter.convert({ file });
|
||||
return captured;
|
||||
};
|
||||
|
||||
it('falls back to the filename author when the header has none (missing author)', async () => {
|
||||
const metadata = await convertAndCaptureMetadata(
|
||||
'【细雨飘香】1-129 作者:起落.txt',
|
||||
'第一章 初见\n正文内容……\n',
|
||||
);
|
||||
expect(metadata?.author).toBe('起落');
|
||||
});
|
||||
|
||||
it('rejects a metadata-blob header author and uses the filename author (irrelevant content)', async () => {
|
||||
const metadata = await convertAndCaptureMetadata(
|
||||
'【月如无恨月长圆】(1-154)作者:陈西.txt',
|
||||
'作者:2024/08/01发表于:是否首发:是字数1023150字116:01\n第一章 初见\n正文内容……\n',
|
||||
);
|
||||
expect(metadata?.author).toBe('陈西');
|
||||
});
|
||||
|
||||
it('keeps a clean labeled author parsed from the file header', async () => {
|
||||
const metadata = await convertAndCaptureMetadata(
|
||||
'【幻灵幽火】1-23未完结 作者:月夜银狐.txt',
|
||||
'作者:月夜银狐\n第一章 初见\n正文内容……\n',
|
||||
);
|
||||
expect(metadata?.author).toBe('月夜银狐');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -530,4 +530,70 @@ describe('CFIToXPointerConverter', () => {
|
||||
expect(cfi).toMatch(/^epubcfi\(/);
|
||||
});
|
||||
});
|
||||
|
||||
// applyScrollableStyle wraps wide tables/equations in a cfi-skip <div>. KOReader's
|
||||
// CREngine DOM has no such wrapper, so CFI ↔ XPointer must treat the wrapper as
|
||||
// transparent: the same KOReader XPointer must map to the same CFI (and back) with
|
||||
// or without the wrapper — otherwise KOSync positions would drift.
|
||||
describe('cfi-skip wrapper is invisible to CFI <-> XPointer', () => {
|
||||
// identical content; only difference is the wrapping cfi-skip div around the table
|
||||
const inner = `<table><tbody><tr><td><p>Hello scrollable world</p></td></tr></tbody></table>`;
|
||||
const html = (table: string) =>
|
||||
`<html><body><p>Intro paragraph</p>${table}<p>Outro paragraph</p></body></html>`;
|
||||
const baseDoc = () => new DOMParser().parseFromString(html(inner), 'text/html');
|
||||
const wrappedDoc = () =>
|
||||
new DOMParser().parseFromString(
|
||||
html(`<div class="scroll-wrapper" cfi-skip="">${inner}</div>`),
|
||||
'text/html',
|
||||
);
|
||||
|
||||
// KOReader-style XPointers (no wrapper) — the wrapper-less DOM CREngine produces.
|
||||
const cellText = '/body/DocFragment[1]/body/table/tbody/tr/td/p/text().5';
|
||||
const tableElement = '/body/DocFragment[1]/body/table';
|
||||
const afterTable = '/body/DocFragment[1]/body/p[2]/text().3';
|
||||
|
||||
it('maps a KOReader XPointer to the same CFI with and without the wrapper', () => {
|
||||
const base = new XCFI(baseDoc(), 0);
|
||||
const wrapped = new XCFI(wrappedDoc(), 0);
|
||||
for (const xp of [cellText, tableElement, afterTable]) {
|
||||
// The wrapper must not change the CFI the XPointer resolves to.
|
||||
expect(wrapped.xPointerToCFI(xp), xp).toBe(base.xPointerToCFI(xp));
|
||||
}
|
||||
});
|
||||
|
||||
it('round-trips the table element XPointer back to itself through the wrapped doc', () => {
|
||||
const wrapped = new XCFI(wrappedDoc(), 0);
|
||||
const cfi = wrapped.xPointerToCFI(tableElement);
|
||||
const built = wrapped.cfiToXPointer(cfi);
|
||||
expect(built.xpointer).toBe(tableElement);
|
||||
// The wrapper is a <div>; a transparent wrapper must not appear in the path.
|
||||
expect(built.xpointer).not.toContain('/div');
|
||||
});
|
||||
|
||||
it('round-trips a KOReader range (highlight) inside the table unchanged', () => {
|
||||
// A highlight is a range; xPointerToCFI(pos0,pos1) -> cfiToXPointer pos0/pos1
|
||||
// exercises rangePointToXPointer, the path real highlights use.
|
||||
const pos0 = '/body/DocFragment[1]/body/table/tbody/tr/td/p/text().0';
|
||||
const pos1 = '/body/DocFragment[1]/body/table/tbody/tr/td/p/text().5';
|
||||
const base = new XCFI(baseDoc(), 0);
|
||||
const wrapped = new XCFI(wrappedDoc(), 0);
|
||||
|
||||
const baseCfi = base.xPointerToCFI(pos0, pos1);
|
||||
const wrappedCfi = wrapped.xPointerToCFI(pos0, pos1);
|
||||
expect(wrappedCfi).toBe(baseCfi); // wrapper doesn't change the highlight CFI
|
||||
|
||||
const back = wrapped.cfiToXPointer(wrappedCfi);
|
||||
expect(back.pos0).toBe(pos0);
|
||||
expect(back.pos1).toBe(pos1);
|
||||
expect(back.pos0).not.toContain('/div');
|
||||
expect(back.pos1).not.toContain('/div');
|
||||
});
|
||||
|
||||
it('builds the same CFI->XPointer for the table element with and without the wrapper', () => {
|
||||
const base = new XCFI(baseDoc(), 0);
|
||||
const wrapped = new XCFI(wrappedDoc(), 0);
|
||||
const cfi = base.xPointerToCFI(tableElement);
|
||||
expect(wrapped.cfiToXPointer(cfi).xpointer).toBe(base.cfiToXPointer(cfi).xpointer);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -45,6 +45,9 @@ import {
|
||||
resolveEffectiveSecondarySort,
|
||||
} from '../utils/libraryUtils';
|
||||
import { eventDispatcher } from '@/utils/event';
|
||||
import { getLocalBookFilename } from '@/utils/book';
|
||||
import { MIMETYPES, EXTS } from '@/libs/document';
|
||||
import { makeSafeFilename } from '@/utils/misc';
|
||||
|
||||
import { useSpatialNavigation } from '../hooks/useSpatialNavigation';
|
||||
import Alert from '@/components/Alert';
|
||||
@@ -425,6 +428,113 @@ const Bookshelf: React.FC<BookshelfProps> = ({
|
||||
setShowStatusAlert(true);
|
||||
};
|
||||
|
||||
const sendSelectedBook = async () => {
|
||||
// "Send" hands the actual book file (epub/pdf/...) to the OS share
|
||||
// sheet (UIActivityViewController on iOS, Intent.ACTION_SEND on
|
||||
// Android, NSSharingServicePicker on macOS) so the user can fire it
|
||||
// off to Mail / Messages / WeChat / AirDrop / etc. Backed by
|
||||
// tauri-plugin-sharekit via appService.saveFile({ share: true }).
|
||||
//
|
||||
// This is intentionally distinct from the per-item "Share Book"
|
||||
// context menu, which uploads the book to the readest backend and
|
||||
// generates a public link. "Send" is offline file egress; "Share
|
||||
// Book" is remote collaboration. They share zero infra.
|
||||
//
|
||||
// Linux has no system share sheet, and Windows is intentionally
|
||||
// disabled (issue #4343 — WebView2's native share UI blocks the main
|
||||
// thread waiting on cancel/complete callbacks that may never fire).
|
||||
// We hide the button entirely on those platforms (see sendEnabled
|
||||
// in the JSX) so users don't see an action that can't be honoured.
|
||||
|
||||
const ids = getSelectedBooks();
|
||||
if (ids.length !== 1) return;
|
||||
const book = filteredBooks.find((b) => b.hash === ids[0]);
|
||||
if (!book || !appService) return;
|
||||
|
||||
// Anchor the macOS share popover to the selected book's cover, not
|
||||
// to the Send button — the user just tapped/clicked the book, so
|
||||
// their visual focus is on the cover. We look the cover up via the
|
||||
// `data-book-hash` attribute that BookshelfItem stamps on its root
|
||||
// div. The rect must be captured *before* setShowSelectModeActions
|
||||
// tears the popup down (the bookshelf itself stays mounted, but we
|
||||
// still want to grab it up front to keep the share-call site
|
||||
// simple). preferredEdge='bottom' maps to NSMinYEdge, which in
|
||||
// WKWebView's flipped coord space is the rect's top edge, so the
|
||||
// popover renders above the cover (and only auto-flips below when
|
||||
// there's no room above). On iOS / Android the share sheet is modal
|
||||
// and ignores sharePosition, so this work is harmless there.
|
||||
const coverEl = document.querySelector<HTMLElement>(`[data-book-hash="${book.hash}"]`);
|
||||
const anchorRect = coverEl?.getBoundingClientRect();
|
||||
const sharePosition = anchorRect
|
||||
? {
|
||||
x: anchorRect.left + anchorRect.width / 2,
|
||||
y: anchorRect.top + anchorRect.height / 2,
|
||||
preferredEdge: 'bottom' as const,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
setShowSelectModeActions(false);
|
||||
handleSetSelectMode(false);
|
||||
|
||||
try {
|
||||
// Resolve the file the same way bookContent.resolveBookContentSource
|
||||
// does, but via the public AppService surface (the underlying `fs`
|
||||
// is protected): managed copy under Books/<hash>/ first, then the
|
||||
// device-local in-place import path. Cloud-only books or remote
|
||||
// URL books can't be shared without first downloading them.
|
||||
const managedPath = getLocalBookFilename(book);
|
||||
let path: string;
|
||||
let base: 'Books' | 'None';
|
||||
if (await appService.exists(managedPath, 'Books')) {
|
||||
path = managedPath;
|
||||
base = 'Books';
|
||||
} else if (book.filePath && (await appService.exists(book.filePath, 'None'))) {
|
||||
path = book.filePath;
|
||||
base = 'None';
|
||||
} else {
|
||||
eventDispatcher.dispatch('toast', {
|
||||
type: 'warning',
|
||||
message: _('Book file is not available locally'),
|
||||
timeout: 2500,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const ext = EXTS[book.format] ?? 'bin';
|
||||
const mimeType = MIMETYPES[book.format]?.[0] ?? 'application/octet-stream';
|
||||
const baseName = makeSafeFilename(book.sourceTitle || book.title || book.hash);
|
||||
const shareFilename = `${baseName}.${ext}`;
|
||||
|
||||
// Native (Tauri) only — the Share button is hidden on web because
|
||||
// browsers can't surface a real "share to <app>" sheet for an
|
||||
// arbitrary local file. Hand the already-on-disk file straight to
|
||||
// the OS share sheet via `options.filePath`. Without it,
|
||||
// saveFile() falls back to writing a temp copy under
|
||||
// BaseDirectory.Temp, which on Android resolves to
|
||||
// /data/local/tmp/ — the app sandbox has no write permission
|
||||
// there and the call fails with EACCES ("failed to open file at
|
||||
// path: /data/local/tmp/...epub Permission denied (os error
|
||||
// 13)"). Passing the absolute path also avoids re-buffering the
|
||||
// whole epub/pdf into memory just to have saveFile write it back
|
||||
// to disk.
|
||||
const absoluteFilePath = await appService.resolveFilePath(path, base);
|
||||
// `null` content: there's nothing to write — the file already lives at
|
||||
// `filePath`, which the native share path reads directly.
|
||||
await appService.saveFile(shareFilename, null, {
|
||||
share: true,
|
||||
mimeType,
|
||||
filePath: absoluteFilePath,
|
||||
sharePosition,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Failed to send book file:', err);
|
||||
eventDispatcher.dispatch('toast', {
|
||||
type: 'error',
|
||||
message: _('Failed to send book'),
|
||||
timeout: 2500,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const updateBooksStatus = async (status: ReadingStatus | undefined) => {
|
||||
const selectedIds = getSelectedBooks();
|
||||
const booksToUpdate: Book[] = [];
|
||||
@@ -688,10 +798,22 @@ const Bookshelf: React.FC<BookshelfProps> = ({
|
||||
<SelectModeActions
|
||||
selectedBooks={selectedBooks}
|
||||
safeAreaBottom={safeAreaInsets?.bottom || 0}
|
||||
// Native send targets: iOS, Android, macOS — route through
|
||||
// tauri-plugin-sharekit (UIActivityViewController /
|
||||
// Intent.ACTION_SEND / NSSharingServicePicker). Linux has no
|
||||
// system share sheet, Windows WebView2 share UI is disabled
|
||||
// upstream (issue #4343 — deadlocks the main thread), and web
|
||||
// browsers don't expose a real "send file to <app>" sheet, so
|
||||
// the button is hidden on those platforms.
|
||||
sendEnabled={
|
||||
!!appService &&
|
||||
(appService.isIOSApp || appService.isAndroidApp || appService.isMacOSApp)
|
||||
}
|
||||
onOpen={openSelectedBooks}
|
||||
onGroup={groupSelectedBooks}
|
||||
onDetails={openBookDetails}
|
||||
onStatus={showStatusSelection}
|
||||
onSend={sendSelectedBook}
|
||||
onDelete={deleteSelectedBooks}
|
||||
onCancel={() => handleSetSelectMode(false)}
|
||||
/>
|
||||
|
||||
@@ -6,7 +6,7 @@ import { useSettingsStore } from '@/store/settingsStore';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { useAppRouter } from '@/hooks/useAppRouter';
|
||||
import { useLongPress } from '@/hooks/useLongPress';
|
||||
import { Menu, MenuItem } from '@tauri-apps/api/menu';
|
||||
import { Menu, type MenuItemOptions } from '@tauri-apps/api/menu';
|
||||
import { revealItemInDir } from '@tauri-apps/plugin-opener';
|
||||
import { eventDispatcher } from '@/utils/event';
|
||||
import { getOSPlatform } from '@/utils/misc';
|
||||
@@ -16,6 +16,10 @@ import { LibraryCoverFitType, LibraryViewModeType } from '@/types/settings';
|
||||
import { BOOK_UNGROUPED_ID, BOOK_UNGROUPED_NAME } from '@/services/constants';
|
||||
import { FILE_REVEAL_LABELS, FILE_REVEAL_PLATFORMS } from '@/utils/os';
|
||||
import { Book, BooksGroup, ReadingStatus } from '@/types/book';
|
||||
import {
|
||||
getBookContextMenuItemIds,
|
||||
type BookContextMenuItemId,
|
||||
} from '@/app/library/utils/libraryUtils';
|
||||
import { md5Fingerprint } from '@/utils/md5';
|
||||
import BookItem from './BookItem';
|
||||
import GroupItem from './GroupItem';
|
||||
@@ -214,144 +218,127 @@ const BookshelfItem: React.FC<BookshelfItemProps> = ({
|
||||
const osPlatform = getOSPlatform();
|
||||
const fileRevealLabel =
|
||||
FILE_REVEAL_LABELS[osPlatform as FILE_REVEAL_PLATFORMS] || FILE_REVEAL_LABELS.default;
|
||||
const selectBookMenuItem = await MenuItem.new({
|
||||
text: itemSelected ? _('Deselect Book') : _('Select Book'),
|
||||
action: async () => {
|
||||
if (!isSelectMode) handleSetSelectMode(true);
|
||||
toggleSelection(book.hash);
|
||||
},
|
||||
});
|
||||
const groupBooksMenuItem = await MenuItem.new({
|
||||
text: _('Group Books'),
|
||||
action: async () => {
|
||||
if (!isSelectMode) handleSetSelectMode(true);
|
||||
if (!itemSelected) {
|
||||
// Build every item up front, then create the menu from the ordered subset
|
||||
// in a single Menu.new({ items }) call. Appending items one-by-one with
|
||||
// un-awaited Menu.append() promises races on the Tauri IPC boundary and
|
||||
// shuffles the order on every open (issue #4389).
|
||||
const itemOptions: Record<BookContextMenuItemId, MenuItemOptions> = {
|
||||
select: {
|
||||
text: itemSelected ? _('Deselect Book') : _('Select Book'),
|
||||
action: async () => {
|
||||
if (!isSelectMode) handleSetSelectMode(true);
|
||||
toggleSelection(book.hash);
|
||||
}
|
||||
handleGroupBooks();
|
||||
},
|
||||
},
|
||||
});
|
||||
const markAsFinishedMenuItem = await MenuItem.new({
|
||||
text: _('Mark as Finished'),
|
||||
action: async () => {
|
||||
handleUpdateReadingStatus(book, 'finished');
|
||||
group: {
|
||||
text: _('Group Books'),
|
||||
action: async () => {
|
||||
if (!isSelectMode) handleSetSelectMode(true);
|
||||
if (!itemSelected) {
|
||||
toggleSelection(book.hash);
|
||||
}
|
||||
handleGroupBooks();
|
||||
},
|
||||
},
|
||||
});
|
||||
const markAsUnreadMenuItem = await MenuItem.new({
|
||||
text: _('Mark as Unread'),
|
||||
action: async () => {
|
||||
handleUpdateReadingStatus(book, 'unread');
|
||||
markFinished: {
|
||||
text: _('Mark as Finished'),
|
||||
action: async () => {
|
||||
handleUpdateReadingStatus(book, 'finished');
|
||||
},
|
||||
},
|
||||
});
|
||||
const clearStatusMenuItem = await MenuItem.new({
|
||||
text: _('Clear Status'),
|
||||
action: async () => {
|
||||
handleUpdateReadingStatus(book, undefined);
|
||||
markUnread: {
|
||||
text: _('Mark as Unread'),
|
||||
action: async () => {
|
||||
handleUpdateReadingStatus(book, 'unread');
|
||||
},
|
||||
},
|
||||
});
|
||||
const showBookInFinderMenuItem = await MenuItem.new({
|
||||
text: _(fileRevealLabel),
|
||||
action: async () => {
|
||||
const folder = `${settings.localBooksDir}/${book.hash}`;
|
||||
revealItemInDir(folder);
|
||||
clearStatus: {
|
||||
text: _('Clear Status'),
|
||||
action: async () => {
|
||||
handleUpdateReadingStatus(book, undefined);
|
||||
},
|
||||
},
|
||||
});
|
||||
const showBookDetailsMenuItem = await MenuItem.new({
|
||||
text: _('Show Book Details'),
|
||||
action: async () => {
|
||||
showBookDetailsModal(book);
|
||||
showDetails: {
|
||||
text: _('Show Book Details'),
|
||||
action: async () => {
|
||||
showBookDetailsModal(book);
|
||||
},
|
||||
},
|
||||
});
|
||||
const downloadBookMenuItem = await MenuItem.new({
|
||||
text: _('Download Book'),
|
||||
action: async () => {
|
||||
handleBookDownload(book, { queued: true });
|
||||
showInFinder: {
|
||||
text: _(fileRevealLabel),
|
||||
action: async () => {
|
||||
const folder = `${settings.localBooksDir}/${book.hash}`;
|
||||
revealItemInDir(folder);
|
||||
},
|
||||
},
|
||||
});
|
||||
const uploadBookMenuItem = await MenuItem.new({
|
||||
text: _('Upload Book'),
|
||||
action: async () => {
|
||||
handleBookUpload(book);
|
||||
download: {
|
||||
text: _('Download Book'),
|
||||
action: async () => {
|
||||
handleBookDownload(book, { queued: true });
|
||||
},
|
||||
},
|
||||
});
|
||||
const shareBookMenuItem = await MenuItem.new({
|
||||
text: _('Share Book'),
|
||||
action: async () => {
|
||||
// Bookshelf.tsx hosts the dialog; we dispatch and let it route
|
||||
// unauthenticated users into the login flow first.
|
||||
eventDispatcher.dispatch('show-share-dialog', { book });
|
||||
upload: {
|
||||
text: _('Upload Book'),
|
||||
action: async () => {
|
||||
handleBookUpload(book);
|
||||
},
|
||||
},
|
||||
});
|
||||
const deleteBookMenuItem = await MenuItem.new({
|
||||
text: _('Delete'),
|
||||
action: async () => {
|
||||
eventDispatcher.dispatch('delete-books', { ids: [book.hash] });
|
||||
share: {
|
||||
text: _('Share Book'),
|
||||
action: async () => {
|
||||
// Bookshelf.tsx hosts the dialog; we dispatch and let it route
|
||||
// unauthenticated users into the login flow first.
|
||||
eventDispatcher.dispatch('show-share-dialog', { book });
|
||||
},
|
||||
},
|
||||
});
|
||||
const menu = await Menu.new();
|
||||
menu.append(selectBookMenuItem);
|
||||
menu.append(groupBooksMenuItem);
|
||||
if (book.readingStatus === 'finished') {
|
||||
menu.append(markAsUnreadMenuItem);
|
||||
} else {
|
||||
menu.append(markAsFinishedMenuItem);
|
||||
}
|
||||
// show "Clear Status" option when book has an explicit status set
|
||||
if (book.readingStatus === 'finished' || book.readingStatus === 'unread') {
|
||||
menu.append(clearStatusMenuItem);
|
||||
}
|
||||
menu.append(showBookDetailsMenuItem);
|
||||
menu.append(showBookInFinderMenuItem);
|
||||
if (book.uploadedAt && !book.downloadedAt) {
|
||||
menu.append(downloadBookMenuItem);
|
||||
}
|
||||
if (!book.uploadedAt && book.downloadedAt) {
|
||||
menu.append(uploadBookMenuItem);
|
||||
}
|
||||
// Share is offered for any local-or-uploaded book; the dialog will trigger
|
||||
// an upload first if the book hasn't been pushed yet.
|
||||
if (book.downloadedAt || book.uploadedAt) {
|
||||
menu.append(shareBookMenuItem);
|
||||
}
|
||||
menu.append(deleteBookMenuItem);
|
||||
menu.popup();
|
||||
delete: {
|
||||
text: _('Delete'),
|
||||
action: async () => {
|
||||
eventDispatcher.dispatch('delete-books', { ids: [book.hash] });
|
||||
},
|
||||
},
|
||||
};
|
||||
const items = getBookContextMenuItemIds(book).map((id) => itemOptions[id]);
|
||||
const menu = await Menu.new({ items });
|
||||
await menu.popup();
|
||||
};
|
||||
|
||||
const groupContextMenuHandler = async (group: BooksGroup) => {
|
||||
if (!appService?.hasContextMenu) return;
|
||||
const selectGroupMenuItem = await MenuItem.new({
|
||||
text: itemSelected ? _('Deselect Group') : _('Select Group'),
|
||||
action: async () => {
|
||||
if (!isSelectMode) handleSetSelectMode(true);
|
||||
toggleSelection(group.id);
|
||||
},
|
||||
});
|
||||
const groupBooksMenuItem = await MenuItem.new({
|
||||
text: _('Group Books'),
|
||||
action: async () => {
|
||||
if (!isSelectMode) handleSetSelectMode(true);
|
||||
if (!itemSelected) {
|
||||
// Single Menu.new({ items }) call keeps the order deterministic — see the
|
||||
// note in bookContextMenuHandler about the Menu.append() IPC race (#4389).
|
||||
const items: MenuItemOptions[] = [
|
||||
{
|
||||
text: itemSelected ? _('Deselect Group') : _('Select Group'),
|
||||
action: async () => {
|
||||
if (!isSelectMode) handleSetSelectMode(true);
|
||||
toggleSelection(group.id);
|
||||
}
|
||||
handleGroupBooks();
|
||||
},
|
||||
},
|
||||
});
|
||||
const deleteGroupMenuItem = await MenuItem.new({
|
||||
text: _('Delete'),
|
||||
action: async () => {
|
||||
// Dispatch the constituent book hashes — `group.books` is the
|
||||
// rendered rollup and already includes books from nested sub-
|
||||
// folders, so the deletion path doesn't need to re-derive what
|
||||
// belongs to the group from the id alone.
|
||||
const ids = group.books.filter((book) => !book.deletedAt).map((book) => book.hash);
|
||||
eventDispatcher.dispatch('delete-books', { ids });
|
||||
{
|
||||
text: _('Group Books'),
|
||||
action: async () => {
|
||||
if (!isSelectMode) handleSetSelectMode(true);
|
||||
if (!itemSelected) {
|
||||
toggleSelection(group.id);
|
||||
}
|
||||
handleGroupBooks();
|
||||
},
|
||||
},
|
||||
});
|
||||
const menu = await Menu.new();
|
||||
menu.append(selectGroupMenuItem);
|
||||
menu.append(groupBooksMenuItem);
|
||||
menu.append(deleteGroupMenuItem);
|
||||
menu.popup();
|
||||
{
|
||||
text: _('Delete'),
|
||||
action: async () => {
|
||||
// Dispatch the constituent book hashes — `group.books` is the
|
||||
// rendered rollup and already includes books from nested sub-
|
||||
// folders, so the deletion path doesn't need to re-derive what
|
||||
// belongs to the group from the id alone.
|
||||
const ids = group.books.filter((book) => !book.deletedAt).map((book) => book.hash);
|
||||
eventDispatcher.dispatch('delete-books', { ids });
|
||||
},
|
||||
},
|
||||
];
|
||||
const menu = await Menu.new({ items });
|
||||
await menu.popup();
|
||||
};
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
@@ -427,6 +414,14 @@ const BookshelfItem: React.FC<BookshelfItemProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
// Tag the rendered DOM with the book/group identity so feature code
|
||||
// (e.g. the Send action's macOS share-popover anchor) can locate the
|
||||
// exact bookshelf cell the user is acting on without threading refs
|
||||
// through every parent. Books carry their content-hash; groups carry
|
||||
// their full group name.
|
||||
const itemDataAttrs =
|
||||
'format' in item ? { 'data-book-hash': item.hash } : { 'data-group-name': item.name };
|
||||
|
||||
return (
|
||||
<div className={clsx(mode === 'grid' ? 'h-full' : 'sm:hover:bg-base-300/50 px-4 sm:px-6')}>
|
||||
<div
|
||||
@@ -445,6 +440,7 @@ const BookshelfItem: React.FC<BookshelfItemProps> = ({
|
||||
transition: 'transform 0.2s',
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
{...itemDataAttrs}
|
||||
{...handlers}
|
||||
>
|
||||
<div className='flex h-full flex-col justify-end'>
|
||||
|
||||
@@ -448,7 +448,7 @@ export const MigrateDataWindow = () => {
|
||||
<div className='flex gap-3 pt-2'>
|
||||
{migrationStatus === 'completed' ? (
|
||||
<>
|
||||
<button className='btn btn-outline flex-1' onClick={handleClose}>
|
||||
<button className='btn btn-ghost flex-1' onClick={handleClose}>
|
||||
{_('Close')}
|
||||
</button>
|
||||
<button className='btn btn-primary flex-1' onClick={handleRestartApp}>
|
||||
@@ -458,7 +458,7 @@ export const MigrateDataWindow = () => {
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
className='btn btn-outline flex-1'
|
||||
className='btn btn-ghost flex-1'
|
||||
onClick={handleClose}
|
||||
disabled={migrationStatus === 'migrating'}
|
||||
>
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
MdInfoOutline,
|
||||
MdCheckCircleOutline,
|
||||
} from 'react-icons/md';
|
||||
import { IoShareSocialOutline } from 'react-icons/io5';
|
||||
import { LuFolderPlus } from 'react-icons/lu';
|
||||
import { useKeyDownActions } from '@/hooks/useKeyDownActions';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
@@ -14,10 +15,21 @@ import { isMd5 } from '@/utils/md5';
|
||||
interface SelectModeActionsProps {
|
||||
selectedBooks: string[];
|
||||
safeAreaBottom: number;
|
||||
// When false (Linux desktop, Windows desktop, web) the Send button is
|
||||
// hidden entirely — those platforms can't surface a system share sheet
|
||||
// so the affordance would be misleading. Note: this is *file send* (hands
|
||||
// the book file to the OS share sheet), distinct from "Share Book" in
|
||||
// the per-item context menu, which generates a remote share link.
|
||||
sendEnabled?: boolean;
|
||||
onOpen: () => void;
|
||||
onGroup: () => void;
|
||||
onDetails: () => void;
|
||||
onStatus: () => void;
|
||||
// The macOS / iPad share popover is anchored to the selected book's
|
||||
// cover (located via its data-book-hash attribute), not to this
|
||||
// button — the user's visual focus is on the cover they just tapped.
|
||||
// On iOS / Android the share sheet is modal and ignores position.
|
||||
onSend: () => void;
|
||||
onDelete: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
@@ -25,10 +37,12 @@ interface SelectModeActionsProps {
|
||||
const SelectModeActions: React.FC<SelectModeActionsProps> = ({
|
||||
selectedBooks,
|
||||
safeAreaBottom,
|
||||
sendEnabled = true,
|
||||
onOpen,
|
||||
onGroup,
|
||||
onDetails,
|
||||
onStatus,
|
||||
onSend,
|
||||
onDelete,
|
||||
onCancel,
|
||||
}) => {
|
||||
@@ -96,11 +110,30 @@ const SelectModeActions: React.FC<SelectModeActionsProps> = ({
|
||||
<MdInfoOutline />
|
||||
<div>{_('Details')}</div>
|
||||
</button>
|
||||
{sendEnabled && (
|
||||
<button
|
||||
onClick={onSend}
|
||||
className={clsx(
|
||||
'flex flex-col items-center justify-center gap-1',
|
||||
// Wraps to the start of the second row on narrow viewports.
|
||||
'max-[500px]:col-start-1',
|
||||
(!hasSingleSelection || !hasValidBooks) && 'btn-disabled opacity-50',
|
||||
)}
|
||||
>
|
||||
<IoShareSocialOutline />
|
||||
<div>{_('Send')}</div>
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={onDelete}
|
||||
className={clsx(
|
||||
'flex flex-col items-center justify-center gap-1',
|
||||
'max-[500px]:col-start-2',
|
||||
// Without Send (Linux/Windows/web), Delete needs an explicit
|
||||
// col-start-2 so the wrapped row {Delete, Cancel} stays centred
|
||||
// under the 4-col grid. With Send present, the layout is
|
||||
// {Send, Delete, Cancel} starting at col-start-1, so Delete
|
||||
// naturally lands in col-start-2 without an override.
|
||||
!sendEnabled && 'max-[500px]:col-start-2',
|
||||
!hasSelection && 'btn-disabled opacity-50',
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -583,3 +583,41 @@ export const createGroupSorter =
|
||||
|
||||
return 0;
|
||||
};
|
||||
|
||||
export type BookContextMenuItemId =
|
||||
| 'select'
|
||||
| 'group'
|
||||
| 'markFinished'
|
||||
| 'markUnread'
|
||||
| 'clearStatus'
|
||||
| 'showDetails'
|
||||
| 'showInFinder'
|
||||
| 'download'
|
||||
| 'upload'
|
||||
| 'share'
|
||||
| 'delete';
|
||||
|
||||
/**
|
||||
* Resolve the ordered list of context-menu item ids for a book from its state.
|
||||
*
|
||||
* The native menu MUST be built from this list in a single `Menu.new({ items })`
|
||||
* call. Appending items one at a time with un-awaited `Menu.append()` promises
|
||||
* races on the Tauri IPC boundary, so the items land in a non-deterministic
|
||||
* order and the menu appears to shuffle on every open (issue #4389).
|
||||
*/
|
||||
export const getBookContextMenuItemIds = (book: Book): BookContextMenuItemId[] => {
|
||||
const ids: BookContextMenuItemId[] = ['select', 'group'];
|
||||
ids.push(book.readingStatus === 'finished' ? 'markUnread' : 'markFinished');
|
||||
// "Clear Status" is offered only when the book has an explicit status set.
|
||||
if (book.readingStatus === 'finished' || book.readingStatus === 'unread') {
|
||||
ids.push('clearStatus');
|
||||
}
|
||||
ids.push('showDetails', 'showInFinder');
|
||||
if (book.uploadedAt && !book.downloadedAt) ids.push('download');
|
||||
if (!book.uploadedAt && book.downloadedAt) ids.push('upload');
|
||||
// Share is offered for any local-or-uploaded book; the dialog uploads first
|
||||
// if the book hasn't been pushed yet.
|
||||
if (book.downloadedAt || book.uploadedAt) ids.push('share');
|
||||
ids.push('delete');
|
||||
return ids;
|
||||
};
|
||||
|
||||
@@ -31,7 +31,6 @@ import {
|
||||
applyImageStyle,
|
||||
applyScrollbarStyle,
|
||||
applyScrollModeClass,
|
||||
applyTableStyle,
|
||||
applyThemeModeClass,
|
||||
applyTranslationStyle,
|
||||
getStyles,
|
||||
@@ -39,6 +38,7 @@ import {
|
||||
keepTextAlignment,
|
||||
transformStylesheet,
|
||||
} from '@/utils/style';
|
||||
import { applyScrollableStyle, applyTableTouchScroll } from '@/utils/scrollable';
|
||||
import { mountAdditionalFonts, mountCustomFont } from '@/styles/fonts';
|
||||
import { layoutWarichu, relayoutWarichu } from '@/utils/warichu';
|
||||
import { getBookDirFromLanguage, getBookDirFromWritingMode } from '@/utils/book';
|
||||
@@ -262,7 +262,8 @@ const FoliateViewer: React.FC<{
|
||||
}
|
||||
|
||||
applyImageStyle(detail.doc);
|
||||
applyTableStyle(detail.doc);
|
||||
applyScrollableStyle(detail.doc);
|
||||
applyTableTouchScroll(detail.doc);
|
||||
applyThemeModeClass(detail.doc, isDarkMode);
|
||||
applyScrollModeClass(detail.doc, viewSettings.scrolled || false);
|
||||
applyScrollbarStyle(document, viewSettings.hideScrollbar || false);
|
||||
@@ -558,7 +559,7 @@ const FoliateViewer: React.FC<{
|
||||
const width = viewWidth - insets.left - insets.right;
|
||||
const height = viewHeight - insets.top - insets.bottom;
|
||||
book.transformTarget?.addEventListener('data', getDocTransformHandler({ width, height }));
|
||||
view.renderer.setStyles?.(getStyles(viewSettings));
|
||||
view.renderer.setStyles?.(getStyles(viewSettings, undefined, getLoadedFonts()));
|
||||
applyTranslationStyle(viewSettings);
|
||||
|
||||
doubleClickDisabled.current = viewSettings.disableDoubleClick!;
|
||||
@@ -685,7 +686,7 @@ const FoliateViewer: React.FC<{
|
||||
if (viewRef.current && viewRef.current.renderer) {
|
||||
const renderer = viewRef.current.renderer;
|
||||
const viewSettings = getViewSettings(bookKey)!;
|
||||
viewRef.current.renderer.setStyles?.(getStyles(viewSettings));
|
||||
viewRef.current.renderer.setStyles?.(getStyles(viewSettings, undefined, getLoadedFonts()));
|
||||
const docs = viewRef.current.renderer.getContents();
|
||||
docs.forEach(({ doc }) => {
|
||||
if (bookDoc.rendition?.layout === 'pre-paginated') {
|
||||
|
||||
@@ -150,7 +150,7 @@ const FootnotePopup: React.FC<FootnotePopupProps> = ({ bookKey, bookDoc }) => {
|
||||
const backgroundColor = getComputedStyle(popupContainer).backgroundColor;
|
||||
popupTheme.bg = backgroundColor;
|
||||
}
|
||||
const mainStyles = getStyles(viewSettings, popupTheme);
|
||||
const mainStyles = getStyles(viewSettings, popupTheme, getLoadedFonts());
|
||||
const footnoteStyles = getFootnoteStyles();
|
||||
renderer.setStyles?.(`${mainStyles}\n${footnoteStyles}`);
|
||||
};
|
||||
|
||||
@@ -367,12 +367,15 @@ const ImageViewer: React.FC<ImageViewerProps> = ({
|
||||
if (!src) return null;
|
||||
|
||||
return (
|
||||
// `no-context-menu` suppresses the WebView's native long-press image
|
||||
// callout (via the `.no-context-menu img` rule). On Android it otherwise
|
||||
// collides with the pinch/pan handlers below and freezes the app.
|
||||
<div
|
||||
ref={containerRef}
|
||||
tabIndex={-1}
|
||||
role='button'
|
||||
aria-label={_('Image viewer')}
|
||||
className='fixed inset-0 z-50 flex items-center justify-center outline-none'
|
||||
className='no-context-menu fixed inset-0 z-50 flex items-center justify-center outline-none'
|
||||
onKeyDown={handleKeyDown}
|
||||
onWheel={handleWheel}
|
||||
onTouchMove={onTouchMove}
|
||||
|
||||
@@ -188,7 +188,6 @@ const ProgressBar: React.FC<ProgressBarProps> = ({
|
||||
return (
|
||||
<div
|
||||
role='presentation'
|
||||
tabIndex={-1}
|
||||
className={clsx(
|
||||
'progressinfo absolute bottom-0 flex items-center justify-between font-sans',
|
||||
isEink ? 'text-sm font-normal' : 'text-neutral-content text-xs font-extralight',
|
||||
|
||||
@@ -176,6 +176,12 @@ const ReadingRuler: React.FC<ReadingRulerProps> = ({
|
||||
// In scrolled mode, set when a tap advances past the view edge and scrolls the
|
||||
// view; the next relocate realigns the band to the start/end of the new view.
|
||||
const pendingScrollAlignRef = useRef<'forward' | 'backward' | null>(null);
|
||||
// In scrolled mode the band is fixed to the screen: it is snapped into place on
|
||||
// the initial mount (and after a viewport-dimension change), but never re-snapped
|
||||
// on a plain scroll relocate — that made it creep down the page (issue #4386).
|
||||
// Snapping while scrolled is driven by clicks (the reading-ruler-move handler).
|
||||
const scrolledPlacedRef = useRef(false);
|
||||
const scrolledPlacedDimensionRef = useRef(0);
|
||||
|
||||
const supportsLineSnap = !FIXED_LAYOUT_FORMATS.has(bookFormat);
|
||||
const columnCount = getView(bookKey)?.renderer?.columnCount ?? 1;
|
||||
@@ -333,12 +339,32 @@ const ReadingRuler: React.FC<ReadingRulerProps> = ({
|
||||
pending === 'forward'
|
||||
? snapReadingRulerToLines(-Infinity, -Infinity, lines, 'forward', derivBoxes)
|
||||
: snapReadingRulerToLines(Infinity, Infinity, lines, 'backward', derivBoxes);
|
||||
if (block) applyBlock(block.start, block.end, dimension, true);
|
||||
if (block) {
|
||||
applyBlock(block.start, block.end, dimension, true);
|
||||
scrolledPlacedRef.current = true;
|
||||
scrolledPlacedDimensionRef.current = dimension;
|
||||
}
|
||||
} else if (!pageChanged) {
|
||||
const block =
|
||||
snapReadingRulerToLines(anchor, anchor, lines, 'forward', derivBoxes) ??
|
||||
snapReadingRulerToLines(Infinity, Infinity, lines, 'backward', derivBoxes);
|
||||
if (block) applyBlock(block.start, block.end, dimension, false);
|
||||
// In scrolled mode, only snap the band on the initial mount or after the
|
||||
// viewport dimension changes (resize/relayout). A plain scroll fires a
|
||||
// relocate without changing the dimension; re-snapping then would walk
|
||||
// the band down the page as the reader scrolls (issue #4386).
|
||||
const alreadyPlaced =
|
||||
scrolled &&
|
||||
scrolledPlacedRef.current &&
|
||||
scrolledPlacedDimensionRef.current === dimension;
|
||||
if (!alreadyPlaced) {
|
||||
const block =
|
||||
snapReadingRulerToLines(anchor, anchor, lines, 'forward', derivBoxes) ??
|
||||
snapReadingRulerToLines(Infinity, Infinity, lines, 'backward', derivBoxes);
|
||||
if (block) {
|
||||
applyBlock(block.start, block.end, dimension, false);
|
||||
if (scrolled) {
|
||||
scrolledPlacedRef.current = true;
|
||||
scrolledPlacedDimensionRef.current = dimension;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
|
||||
@@ -45,6 +45,7 @@ import {
|
||||
import { runSimpleCC } from '@/utils/simplecc';
|
||||
import { getWordCount } from '@/utils/word';
|
||||
import { getIndexFromCfi, isCfiInLocation } from '@/utils/cfi';
|
||||
import { writeTextToClipboard } from '@/utils/clipboard';
|
||||
import { TransformContext } from '@/services/transformers/types';
|
||||
import { transformContent } from '@/services/transformService';
|
||||
import {
|
||||
@@ -729,9 +730,10 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
|
||||
const handleCopy = (dismissPopup = true) => {
|
||||
if (!selection || !selection.text) return;
|
||||
const textToCopy = selection.text;
|
||||
setTimeout(() => {
|
||||
// Delay to ensure it won't be overridden by system clipboard actions
|
||||
navigator.clipboard?.writeText(selection.text);
|
||||
void writeTextToClipboard(textToCopy);
|
||||
}, 100);
|
||||
if (dismissPopup) {
|
||||
handleDismissPopupAndSelection();
|
||||
@@ -1208,7 +1210,7 @@ const Annotator: React.FC<{ bookKey: string }> = ({ bookKey }) => {
|
||||
|
||||
setTimeout(() => {
|
||||
// Delay to ensure it won't be overridden by system clipboard actions
|
||||
navigator.clipboard?.writeText(content);
|
||||
void writeTextToClipboard(content);
|
||||
}, 100);
|
||||
|
||||
const ext = isPlainText ? 'txt' : 'md';
|
||||
|
||||
@@ -96,6 +96,17 @@ const BooknoteView: React.FC<{
|
||||
return findNearestCfi(allSorted, progress?.location);
|
||||
}, [progress?.location, sortedGroups]);
|
||||
|
||||
// Index of the nearest note in the flattened list (-1 when none). Memoized so
|
||||
// the scroll effect and the OverlayScrollbars `initialized` callback share a
|
||||
// single source of truth.
|
||||
const nearestIndex = useMemo(
|
||||
() =>
|
||||
nearestCfi
|
||||
? flatItems.findIndex((row) => row.kind === 'note' && row.item.cfi === nearestCfi)
|
||||
: -1,
|
||||
[nearestCfi, flatItems],
|
||||
);
|
||||
|
||||
const handleBrowseBookNotes = useCallback(() => {
|
||||
if (filteredNotes.length === 0) return;
|
||||
const sorted = [...filteredNotes].sort((a, b) => CFI.compare(a.cfi, b.cfi));
|
||||
@@ -111,6 +122,27 @@ const BooknoteView: React.FC<{
|
||||
const [containerHeight, setContainerHeight] = useState(400);
|
||||
const lastScrolledCfiRef = useRef<string | null>(null);
|
||||
|
||||
// Mirror the nearest index so the OverlayScrollbars `initialized` callback —
|
||||
// created at mount but fired after a deferred, timing-dependent init — reads
|
||||
// the current target instead of its stale mount-time closure.
|
||||
const nearestIndexRef = useRef(nearestIndex);
|
||||
nearestIndexRef.current = nearestIndex;
|
||||
|
||||
// Center index of the currently visible window, kept fresh by Virtuoso's
|
||||
// rangeChanged. Lets the scroll effect jump instantly for far moves and
|
||||
// animate only short ones (mirrors TOCView).
|
||||
const visibleCenterRef = useRef(0);
|
||||
|
||||
// When the reading position is already known at open time (the common case of
|
||||
// switching to the panel while reading), mount Virtuoso *natively* centered on
|
||||
// the nearest note via initialTopMostItemIndex. A scrollToIndex against a
|
||||
// freshly mounted, unmeasured list no-ops (smooth) or wedges it, so the scroll
|
||||
// effect skips that first jump and lets initialTopMostItemIndex handle it; the
|
||||
// OverlayScrollbars `initialized` re-apply restores it after the deferred init
|
||||
// resets scrollTop (mirrors TOCView).
|
||||
const [initialTopIndex] = useState(() => nearestIndex);
|
||||
const initialScrollHandledRef = useRef(initialTopIndex > 0);
|
||||
|
||||
const [initialize, osInstance] = useOverlayScrollbars({
|
||||
defer: true,
|
||||
options: { scrollbars: { autoHide: 'scroll' } },
|
||||
@@ -119,6 +151,22 @@ const BooknoteView: React.FC<{
|
||||
const { viewport } = instance.elements();
|
||||
viewport.style.overflowX = 'var(--os-viewport-overflow-x)';
|
||||
viewport.style.overflowY = 'var(--os-viewport-overflow-y)';
|
||||
// OverlayScrollbars resets the wrapped viewport's scrollTop to 0 as it
|
||||
// initializes (deferred), clobbering the mount-time auto-scroll and
|
||||
// stranding the list at the top. Re-apply it to the *current* nearest
|
||||
// note — read via ref since this is the mount-time closure. The first
|
||||
// rAF lets the reset settle; the second re-asserts once the freshly
|
||||
// mounted rows are measured (a lone scrollToIndex to a far, unmeasured
|
||||
// row otherwise lands short).
|
||||
const reapply = () => {
|
||||
const index = nearestIndexRef.current;
|
||||
if (index < 0) return;
|
||||
virtuosoRef.current?.scrollToIndex({ index, align: 'center', behavior: 'auto' });
|
||||
};
|
||||
requestAnimationFrame(() => {
|
||||
reapply();
|
||||
requestAnimationFrame(reapply);
|
||||
});
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -169,18 +217,35 @@ const BooknoteView: React.FC<{
|
||||
// per-item useScrollToItem (which forced 1000 layout reads) with a single
|
||||
// virtuosoRef.scrollToIndex call.
|
||||
useEffect(() => {
|
||||
if (!nearestCfi || !flatItems.length) return;
|
||||
if (nearestIndex < 0) return;
|
||||
if (nearestCfi === lastScrolledCfiRef.current) return;
|
||||
const idx = flatItems.findIndex((row) => row.kind === 'note' && row.item.cfi === nearestCfi);
|
||||
if (idx < 0) return;
|
||||
const isEink = document.documentElement.getAttribute('data-eink') === 'true';
|
||||
virtuosoRef.current?.scrollToIndex({
|
||||
index: idx,
|
||||
align: 'center',
|
||||
behavior: isEink ? 'auto' : 'smooth',
|
||||
});
|
||||
lastScrolledCfiRef.current = nearestCfi;
|
||||
}, [nearestCfi, flatItems]);
|
||||
// initialTopMostItemIndex already centered the mount position; a
|
||||
// scrollToIndex that races Virtuoso's first render no-ops or wedges it, so
|
||||
// skip this one and let the `initialized` re-apply restore it if needed.
|
||||
if (initialScrollHandledRef.current) {
|
||||
initialScrollHandledRef.current = false;
|
||||
return;
|
||||
}
|
||||
const isEink = document.documentElement.getAttribute('data-eink') === 'true';
|
||||
// Jump instantly for far moves (and on eink, which ghosts during a smooth
|
||||
// animation) to avoid blanking the virtualized list mid-animation; keep
|
||||
// smooth only for short, in-session progress updates (mirrors TOCView). A
|
||||
// far instant jump can land short until the target rows are measured, so
|
||||
// re-assert once on the next frame.
|
||||
const distance = Math.abs(nearestIndex - visibleCenterRef.current);
|
||||
const behavior = isEink || distance > 16 ? 'auto' : 'smooth';
|
||||
virtuosoRef.current?.scrollToIndex({ index: nearestIndex, align: 'center', behavior });
|
||||
if (behavior === 'auto') {
|
||||
requestAnimationFrame(() => {
|
||||
virtuosoRef.current?.scrollToIndex({
|
||||
index: nearestIndex,
|
||||
align: 'center',
|
||||
behavior: 'auto',
|
||||
});
|
||||
});
|
||||
}
|
||||
}, [nearestCfi, nearestIndex]);
|
||||
|
||||
const renderItem = useCallback(
|
||||
(index: number) => {
|
||||
@@ -250,6 +315,12 @@ const BooknoteView: React.FC<{
|
||||
<Virtuoso
|
||||
ref={virtuosoRef}
|
||||
scrollerRef={handleScrollerRef}
|
||||
initialTopMostItemIndex={
|
||||
initialTopIndex > 0 ? { index: initialTopIndex, align: 'center' } : 0
|
||||
}
|
||||
rangeChanged={({ startIndex, endIndex }) => {
|
||||
visibleCenterRef.current = Math.floor((startIndex + endIndex) / 2);
|
||||
}}
|
||||
style={{ height: containerHeight }}
|
||||
totalCount={flatItems.length}
|
||||
computeItemKey={(index) => flatItems[index]?.key ?? index}
|
||||
|
||||
@@ -17,6 +17,15 @@ interface OpenFilesPayload {
|
||||
|
||||
interface SharedIntentPayload {
|
||||
urls: string[];
|
||||
/**
|
||||
* Android-only. Distinguishes "Open with Readest" (`VIEW` — the user
|
||||
* tapped a file in their file browser and chose Readest) from "Send to
|
||||
* Readest" (`SEND` / `SEND_MULTIPLE` — share-sheet capture). We forward
|
||||
* it on the `app-incoming-url` event so consumers can pick the right
|
||||
* import strategy: VIEW should open the file directly without writing
|
||||
* it to the library, SEND should ingest it like a sync capture.
|
||||
*/
|
||||
action?: 'VIEW' | 'SEND';
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -53,10 +62,10 @@ export function useAppUrlIngress() {
|
||||
if (listened.current) return;
|
||||
listened.current = true;
|
||||
|
||||
const dispatch = (urls: string[]) => {
|
||||
const dispatch = (urls: string[], action?: 'VIEW' | 'SEND') => {
|
||||
if (!urls.length) return;
|
||||
console.log('App incoming URL:', urls);
|
||||
eventDispatcher.dispatch('app-incoming-url', { urls });
|
||||
console.log('App incoming URL:', urls, 'action:', action);
|
||||
eventDispatcher.dispatch('app-incoming-url', { urls, action });
|
||||
};
|
||||
|
||||
const unlistenSingleInstance = getCurrentWindow().listen<SingleInstancePayload>(
|
||||
@@ -83,7 +92,7 @@ export function useAppUrlIngress() {
|
||||
'native-bridge',
|
||||
'shared-intent',
|
||||
(payload) => {
|
||||
if (payload.urls?.length) dispatch(payload.urls);
|
||||
if (payload.urls?.length) dispatch(payload.urls, payload.action);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,8 +5,9 @@ import { useEnv } from '@/context/EnvContext';
|
||||
import { useLibraryStore } from '@/store/libraryStore';
|
||||
import { useSettingsStore } from '@/store/settingsStore';
|
||||
import { isTauriAppPlatform } from '@/services/environment';
|
||||
import { navigateToLibrary, showLibraryWindow } from '@/utils/nav';
|
||||
import { navigateToLibrary, navigateToReader, showLibraryWindow } from '@/utils/nav';
|
||||
import { eventDispatcher } from '@/utils/event';
|
||||
import { partialMD5 } from '@/utils/md5';
|
||||
|
||||
/**
|
||||
* Handle "Open with Readest" file imports. Consumes the `app-incoming-url`
|
||||
@@ -18,6 +19,26 @@ import { eventDispatcher } from '@/utils/event';
|
||||
*
|
||||
* Mount this hook alongside `useAppUrlIngress` so the ingress dispatcher is
|
||||
* actually running when URLs arrive.
|
||||
*
|
||||
* Two routing modes by `action`:
|
||||
*
|
||||
* `'VIEW'` (Android only — user picked Readest from the system "Open with"
|
||||
* chooser for an epub/pdf): we MUST NOT silently import the file into the
|
||||
* library. We first hash the file ourselves and check the in-memory library:
|
||||
* - if a non-deleted entry with the same hash already exists, jump
|
||||
* straight into the reader on that entry — no `importBook` call, so the
|
||||
* managed `Books/<hash>/` filePath, createdAt and reading progress are
|
||||
* all preserved untouched;
|
||||
* - otherwise call `appService.importBook` with `transient: true`, which
|
||||
* creates an ephemeral `Book` (with `deletedAt` set and `filePath`
|
||||
* pointing at the original URI) without writing to `Books/<hash>/` or
|
||||
* uploading to the cloud, then navigate to the reader on that hash.
|
||||
*
|
||||
* `'SEND'` / undefined (iOS / macOS / desktop / Android share-sheet
|
||||
* capture): keep the existing flow that pushes the URLs through
|
||||
* `window.OPEN_WITH_FILES` so `library/page.tsx::processOpenWithFiles`
|
||||
* does a full ingest + cloud upload — that's the contract a "Send to
|
||||
* Readest" share is meant to honour.
|
||||
*/
|
||||
export function useOpenWithBooks() {
|
||||
const router = useRouter();
|
||||
@@ -34,7 +55,7 @@ export function useOpenWithBooks() {
|
||||
return sortedWindows[0]?.label === currentWindow.label;
|
||||
};
|
||||
|
||||
const handle = async (urls: string[]) => {
|
||||
const normalizeUrls = (urls: string[]): string[] => {
|
||||
const filePaths: string[] = [];
|
||||
for (let url of urls) {
|
||||
if (url.startsWith('file://')) {
|
||||
@@ -44,8 +65,102 @@ export function useOpenWithBooks() {
|
||||
filePaths.push(url);
|
||||
}
|
||||
}
|
||||
return filePaths;
|
||||
};
|
||||
|
||||
const openTransient = async (filePaths: string[]) => {
|
||||
// For each file, first try to recognise it as a book the user has
|
||||
// already imported (hash match in the live library). If yes, we route
|
||||
// straight to the reader without ever calling importBook — that avoids
|
||||
// mutating the managed entry's filePath / createdAt (importBook in
|
||||
// transient mode unconditionally rewrites filePath to the incoming
|
||||
// content:// URI and bumps createdAt, both of which we want to keep
|
||||
// untouched for already-imported books).
|
||||
//
|
||||
// Only files that are NOT in the library go through importBook in
|
||||
// transient mode, which creates an ephemeral Book (deletedAt set,
|
||||
// filePath pointing at the original URI) without writing Books/<hash>/
|
||||
// or uploading to the cloud. We also setLibrary so the reader's
|
||||
// initViewState (which looks the book up via getBookByHash) can find
|
||||
// the ephemeral entry.
|
||||
const { library, setLibrary, getBookByHash } = useLibraryStore.getState();
|
||||
const bookIds: string[] = [];
|
||||
let libraryMutated = false;
|
||||
|
||||
for (const file of filePaths) {
|
||||
try {
|
||||
// Lightweight pre-check: hash the file and look it up. A miss here
|
||||
// means we still have to fall back to importBook because we need
|
||||
// its full metadata-extraction + ephemeral entry creation.
|
||||
let existingHash: string | undefined;
|
||||
try {
|
||||
const fileobj = await appService.openFile(file, 'None');
|
||||
try {
|
||||
existingHash = await partialMD5(fileobj);
|
||||
} finally {
|
||||
const closable = fileobj as File & { close?: () => Promise<void> };
|
||||
if (closable.close) await closable.close();
|
||||
}
|
||||
} catch (e) {
|
||||
// Path/permission issue — let importBook surface the real error.
|
||||
console.warn('Pre-hash failed, falling back to transient import:', file, e);
|
||||
}
|
||||
|
||||
if (existingHash) {
|
||||
const existing = getBookByHash(existingHash);
|
||||
if (existing && !existing.deletedAt) {
|
||||
bookIds.push(existing.hash);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
const book = await appService.importBook(file, library, { transient: true });
|
||||
if (book) {
|
||||
bookIds.push(book.hash);
|
||||
// importBook may have mutated `library` (added the ephemeral
|
||||
// entry, or — for a hash hit on a previously-deleted entry —
|
||||
// refreshed an existing one); either way we want store sync.
|
||||
libraryMutated = true;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Failed to open file transiently:', file, e);
|
||||
}
|
||||
}
|
||||
|
||||
if (bookIds.length === 0) return;
|
||||
if (libraryMutated) {
|
||||
setLibrary(library);
|
||||
}
|
||||
// Defensive: only navigate if the reader will actually be able to
|
||||
// resolve the book via getBookByHash (managed entries are already in
|
||||
// the store; ephemeral entries are after the setLibrary above).
|
||||
const reachable = bookIds.filter((h) => !!getBookByHash(h));
|
||||
if (reachable.length > 0) {
|
||||
navigateToReader(router, reachable);
|
||||
}
|
||||
};
|
||||
|
||||
const handle = async (urls: string[], action?: 'VIEW' | 'SEND') => {
|
||||
const filePaths = normalizeUrls(urls);
|
||||
if (filePaths.length === 0) return;
|
||||
|
||||
// Android "Open with" → straight to reader, no library write.
|
||||
if (action === 'VIEW') {
|
||||
// If a reader is already mounted, ignore the second tap rather
|
||||
// than try to swap books underneath it. The in-place URL swap
|
||||
// would otherwise leave ReaderContent's init effect (gated by
|
||||
// an isInitiating ref + empty deps) stuck on the previous book
|
||||
// and the user would see the spinner hang. The user can close
|
||||
// the current book to return to the library, then re-open the
|
||||
// new one — same UX as most OS image viewers.
|
||||
if (typeof window !== 'undefined' && window.location.pathname.startsWith('/reader')) {
|
||||
console.log('Ignoring Open-with VIEW intent: reader already active');
|
||||
return;
|
||||
}
|
||||
await openTransient(filePaths);
|
||||
return;
|
||||
}
|
||||
|
||||
const settings = useSettingsStore.getState().settings;
|
||||
if (appService?.hasWindow && settings.openBookInNewWindow) {
|
||||
if (await isFirstWindow()) {
|
||||
@@ -59,8 +174,8 @@ export function useOpenWithBooks() {
|
||||
};
|
||||
|
||||
const onIncoming = (event: CustomEvent) => {
|
||||
const { urls } = event.detail as { urls: string[] };
|
||||
handle(urls);
|
||||
const { urls, action } = event.detail as { urls: string[]; action?: 'VIEW' | 'SEND' };
|
||||
handle(urls, action);
|
||||
};
|
||||
eventDispatcher.on('app-incoming-url', onIncoming);
|
||||
|
||||
|
||||
@@ -77,7 +77,7 @@ export abstract class BaseAppService implements AppService {
|
||||
abstract selectFiles(name: string, extensions: string[]): Promise<string[]>;
|
||||
abstract saveFile(
|
||||
filename: string,
|
||||
content: string | ArrayBuffer,
|
||||
content: string | ArrayBuffer | null,
|
||||
options?: {
|
||||
filePath?: string;
|
||||
mimeType?: string;
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { Book } from '@/types/book';
|
||||
import type { FileSystem } from '@/types/system';
|
||||
import { EXTS } from '@/libs/document';
|
||||
import { getDir, getLocalBookFilename } from '@/utils/book';
|
||||
import { isValidURL } from '@/utils/misc';
|
||||
import { isContentURI, isValidURL } from '@/utils/misc';
|
||||
import { isPseStreamFileName } from './opds/pseStream';
|
||||
|
||||
export type BookContentSource =
|
||||
@@ -49,8 +49,20 @@ export async function resolveBookContentSource(
|
||||
return { kind: 'managed', path: managedPath, base: 'Books' };
|
||||
}
|
||||
|
||||
if (book.filePath && (await fs.exists(book.filePath, 'None'))) {
|
||||
return { kind: 'external', path: book.filePath, base: 'None' };
|
||||
if (book.filePath) {
|
||||
// Android "Open with Readest" hands us a content:// URI as the
|
||||
// book.filePath (e.g. content://media/external/file/1322). Tauri's
|
||||
// fs.exists() doesn't understand content URIs and returns false,
|
||||
// which would route us to `missing` here even though the URI is
|
||||
// perfectly readable through appService.openFile (which copies the
|
||||
// content to Cache on demand). Skip the existence probe for URIs
|
||||
// we know openFile knows how to resolve.
|
||||
if (isContentURI(book.filePath)) {
|
||||
return { kind: 'external', path: book.filePath, base: 'None' };
|
||||
}
|
||||
if (await fs.exists(book.filePath, 'None')) {
|
||||
return { kind: 'external', path: book.filePath, base: 'None' };
|
||||
}
|
||||
}
|
||||
|
||||
if (book.url) {
|
||||
|
||||
@@ -633,7 +633,7 @@ export class NativeAppService extends BaseAppService {
|
||||
|
||||
async saveFile(
|
||||
filename: string,
|
||||
content: string | ArrayBuffer,
|
||||
content: string | ArrayBuffer | null,
|
||||
options?: {
|
||||
filePath?: string;
|
||||
mimeType?: string;
|
||||
@@ -655,7 +655,7 @@ export class NativeAppService extends BaseAppService {
|
||||
shareablePath = await this.resolveFilePath(filename, 'Temp');
|
||||
if (typeof content === 'string') {
|
||||
await writeTextFile(shareablePath, content);
|
||||
} else {
|
||||
} else if (content) {
|
||||
await writeFile(shareablePath, new Uint8Array(content));
|
||||
}
|
||||
}
|
||||
@@ -667,10 +667,17 @@ export class NativeAppService extends BaseAppService {
|
||||
// WebView's top-left corner.
|
||||
...(options?.sharePosition ? { position: options.sharePosition } : {}),
|
||||
});
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('shareFile failed; falling back to saveDialog:', error);
|
||||
// The plugin throws on user cancellation (e.g. dismissing the
|
||||
// Android share sheet returns "Share cancelled"). That's not a
|
||||
// failure — the user explicitly chose not to share, so we must
|
||||
// NOT fall back to saveDialog and pop a "Save As..." prompt.
|
||||
// Same goes for any other share error: the caller asked for a
|
||||
// share sheet, fulfilled or not, the saveDialog flow is a
|
||||
// completely different user intent.
|
||||
console.warn('shareFile did not complete:', error);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
const filePath = await saveDialog({
|
||||
@@ -681,7 +688,7 @@ export class NativeAppService extends BaseAppService {
|
||||
|
||||
if (typeof content === 'string') {
|
||||
await writeTextFile(filePath, content);
|
||||
} else {
|
||||
} else if (content) {
|
||||
await writeFile(filePath, new Uint8Array(content));
|
||||
}
|
||||
return true;
|
||||
|
||||
@@ -386,7 +386,7 @@ export class NodeAppService extends BaseAppService {
|
||||
|
||||
async saveFile(
|
||||
_filename: string,
|
||||
content: string | ArrayBuffer,
|
||||
content: string | ArrayBuffer | null,
|
||||
options?: {
|
||||
filePath?: string;
|
||||
mimeType?: string;
|
||||
@@ -399,7 +399,7 @@ export class NodeAppService extends BaseAppService {
|
||||
await fsp.mkdir(nodePath.dirname(filepath), { recursive: true });
|
||||
if (typeof content === 'string') {
|
||||
await fsp.writeFile(filepath, content, 'utf-8');
|
||||
} else {
|
||||
} else if (content) {
|
||||
await fsp.writeFile(filepath, Buffer.from(content));
|
||||
}
|
||||
return true;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user