@quran.ws/qvp-react-native 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +148 -0
- package/android/build.gradle +53 -0
- package/android/src/main/AndroidManifest.xml +1 -0
- package/android/src/main/java/net/quranpedia/qvp/rn/Marshal.kt +191 -0
- package/android/src/main/java/net/quranpedia/qvp/rn/QvpModule.kt +149 -0
- package/android/src/main/java/net/quranpedia/qvp/rn/QvpPackage.kt +11 -0
- package/android/src/main/java/net/quranpedia/qvp/rn/QvpPageViewManager.kt +48 -0
- package/android/src/main/java/net/quranpedia/qvp/rn/QvpRnPageView.kt +235 -0
- package/package.json +46 -0
- package/src/index.tsx +346 -0
package/README.md
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
# @quran.ws/qvp-react-native
|
|
2
|
+
|
|
3
|
+
React Native wrapper for the QVP vector mushaf engine. Android-first: a native view
|
|
4
|
+
(`<QvpPageView />`) hosting the Kotlin library's `QvpPageView` (`net.quranpedia.qvp`,
|
|
5
|
+
`packages/android/qvp`, JNI over `crates/qvp-ffi/include/qvp.h`) plus a promise-based module
|
|
6
|
+
(`QvpModule`) for queries. The wrapper is *thin*: no hit-testing, layout or styling logic in JS or
|
|
7
|
+
in the module — it marshals props/targets/selectors and reconciles declarative arrays against engine
|
|
8
|
+
handles. Names follow `docs/API.md`. **The package ships no page data.**
|
|
9
|
+
|
|
10
|
+
## Install
|
|
11
|
+
|
|
12
|
+
```sh
|
|
13
|
+
npm install @quran.ws/qvp-react-native # or a file:/git dependency inside the monorepo
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Android needs the Kotlin library. Preferred: include it as a Gradle project in your app's
|
|
17
|
+
`android/settings.gradle` (this is what `../example` does):
|
|
18
|
+
|
|
19
|
+
```groovy
|
|
20
|
+
include ':qvp'
|
|
21
|
+
project(':qvp').projectDir = file('<repo>/packages/android/qvp') // e.g. '../../../android/qvp'
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
The module then depends on `project(':qvp')`. If `:qvp` is not included, the module compiles the
|
|
25
|
+
library's Kotlin + JNI sources itself from `qvpAndroidDir` (Gradle property; default
|
|
26
|
+
`../../../android/qvp`, right for the monorepo). Either way the engine comes from
|
|
27
|
+
`packages/android/qvp/prebuilt/<abi>/libqvp_ffi.a` — produce it once with
|
|
28
|
+
`scripts/build-engine-android.sh` from the repo root.
|
|
29
|
+
|
|
30
|
+
Requirements: RN ≥ 0.76 (legacy view manager / native module through the interop layer, verified on
|
|
31
|
+
0.87 with the New Architecture), minSdk 24, NDK 27, CMake 3.22.
|
|
32
|
+
|
|
33
|
+
Page data: `cargo run -p qvp-convert --release -- batch pages dist/pages` → `NNN.qvp`,
|
|
34
|
+
`NNN.words.json`, `atlas.qva`. Put them wherever you like (assets, files dir, downloads) and pass
|
|
35
|
+
URIs: `asset://pages/042.qvp`, `file:///…`, `/abs/path`, or `base64:…`.
|
|
36
|
+
|
|
37
|
+
## The view
|
|
38
|
+
|
|
39
|
+
```tsx
|
|
40
|
+
import { QvpPageView, useQvp, Sel, T, LAYER, KIND, DECO, rgba } from '@quran.ws/qvp-react-native';
|
|
41
|
+
|
|
42
|
+
const qvp = useQvp();
|
|
43
|
+
<QvpPageView
|
|
44
|
+
ref={qvp.ref}
|
|
45
|
+
style={{ flex: 1 }}
|
|
46
|
+
pageUri="asset://pages/042.qvp"
|
|
47
|
+
wordsUri="asset://pages/042.words.json" // optional sidecar (rasm_imlai/qpc/rasm/search forms)
|
|
48
|
+
padTop={12} padBottom={12} padSide={8} // dp
|
|
49
|
+
lineSpacing={1} lineGap={0} fillHeight={false} // engine layout knobs (page units for lineGap)
|
|
50
|
+
paperColor="#fffdf7" defaultInk="#231f20"
|
|
51
|
+
theme={{ diacritics: '#1a73e8', dots: '#c62828', waqf: '#0a7d32', ms: 200 }} // page.theme(...) — one handle
|
|
52
|
+
styles={[
|
|
53
|
+
{ id: 'hide-marks', selector: Sel.kind(KIND.MARK), hide: true }, // page.hide(sel)
|
|
54
|
+
{ id: 'gold', selector: Sel.deco(DECO.AYAH_MARK), color: '#b8860b', ms: 300, layer: LAYER.THEME + 1 },
|
|
55
|
+
{ id: 'mark', selector: Sel.wordMark(12, 1), color: '#ef6c00', ms: 200, layer: LAYER.TOP }, // 2nd diacritic of word 12
|
|
56
|
+
{ id: 'ayah', target: '2:255', color: '#0a7d32' }, // page.styleTarget(...)
|
|
57
|
+
]}
|
|
58
|
+
highlights={[
|
|
59
|
+
{ id: 'sel', target: T.word(12), style: { mode: 'both', ink: '#1a73e8', band: rgba('#1a73e8', 0.18), radius: 1.5, ms: 250, layer: LAYER.SELECTION } },
|
|
60
|
+
{ id: 'search', target: T.words([3, 9, 41]), style: { mode: 'both', ink: '#c62828', band: rgba('#c62828', 0.12), height: 'ink' } },
|
|
61
|
+
]}
|
|
62
|
+
mask={{ target: '2:255', mode: 'hide' }} // or 'block' | 'blur' (+ blockColor, padX, padY, radius)
|
|
63
|
+
reveal={{ lit: 2, grey: '#c9c4b8', ink: '#231f20', ms: 150, at: 3 }} // greyed page; `at` drives revealGoto
|
|
64
|
+
onWordTap={({ word, hit }) => …} // word: {idx, surah, ayah, word, line, …, text, wordKey, aid, forms, label, paths[]}
|
|
65
|
+
onDecoTap={({ deco, hit }) => …} // ayah markers, banners, basmalah, rosettes, sajdah signs
|
|
66
|
+
onEmptyTap={() => …}
|
|
67
|
+
onSelectionChanged={({ words, text, citation, textWithCitation }) => …} // long-press-drag selection
|
|
68
|
+
onPageLoad={info => …} // {page, width, height, nLines, nAyahs, nWords, nPaths, nDecos, forms, loadMs, bytes}
|
|
69
|
+
onRevealChanged={({ steps, at }) => …}
|
|
70
|
+
onError={({ message }) => …}
|
|
71
|
+
/>
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Reconciliation: each `highlights` entry maps to one engine handle. A new id → `highlight`; a changed
|
|
75
|
+
`target` → `rehighlight` (the band slides); a changed `style` → `restyleHighlight`; a removed id →
|
|
76
|
+
`unhighlight` (fades out). `styles` works the same over `style` / `styleTarget` / `hide` / `restyle` /
|
|
77
|
+
`unstyle`; `theme` is one handle; `mask` re-applies `maskOptions` + `mask` when the object changes
|
|
78
|
+
(`null` → `unmask`); `reveal` calls `revealStart` when its config changes and `revealGoto` when `at`
|
|
79
|
+
changes (`null` → `revealStop`). When `pageUri` changes, the old page is freed and every declarative
|
|
80
|
+
prop is re-applied to the new page.
|
|
81
|
+
|
|
82
|
+
Gestures are the Kotlin view's: tap → gap-aware `hitTestViewEx` → `onWordTap` / `onDecoTap` /
|
|
83
|
+
`onEmptyTap`; long-press-drag → engine `select` with a band in `LAYER.SELECTION`; pinch / pan;
|
|
84
|
+
double-tap resets. Props `selectionEnabled`, `zoomEnabled`, `hitMaxDistance`, `selectionBand`.
|
|
85
|
+
|
|
86
|
+
Colours: `'#rgb' | '#rrggbb' | '#rrggbbaa' | 0xRRGGBBAA` (`css()` / `rgba(c, alpha)` helpers).
|
|
87
|
+
Targets: `'page' | '2:255' | '2:255:3' | '2:255-257' | 'line:7' | 'surah:2' | wordIdx | [idx…] | T.*`.
|
|
88
|
+
Selectors: `Sel.page/path/wordPath/wordMark/wordMarkNamed/wordBody/wordMarks/word/ayah/line/mark/category/family/kind/deco/decoIdx`.
|
|
89
|
+
|
|
90
|
+
## The module
|
|
91
|
+
|
|
92
|
+
Everything takes the view's react tag (`findNodeHandle`) — or use the bound forms:
|
|
93
|
+
`useQvp()` returns `{ ref, tag, ...api }`, and the component ref exposes the same `api`.
|
|
94
|
+
|
|
95
|
+
```ts
|
|
96
|
+
const qvp = useQvp();
|
|
97
|
+
await qvp.info(); qvp.words(); qvp.word(i); qvp.ayahs(); qvp.lines(); qvp.decos();
|
|
98
|
+
await qvp.search('الرحمان', { mode: 'includes' }); // [{word, wordKey, text, index, loose}]
|
|
99
|
+
await qvp.text('2:255', { form: 'search', wordSep: ' ' });
|
|
100
|
+
await qvp.resolve('line:7'); qvp.findWord(2, 255, 3); qvp.wordForm(i, 'rasm_imlai'); qvp.hasForm('qpc');
|
|
101
|
+
await qvp.attachWords(jsonString); // when you do not use the wordsUri prop
|
|
102
|
+
await qvp.surahs(); qvp.divisions(); qvp.markers(); qvp.rosettes(); qvp.sajdahs(); qvp.ayahKeys();
|
|
103
|
+
await qvp.ayahWordCount(2, 255); qvp.reciteMap(2, 255, 4); qvp.wordLabel(i); qvp.ayahLabel(ai);
|
|
104
|
+
await qvp.citation([12, 13, 14]);
|
|
105
|
+
await qvp.cropSvg('2:255', { pad: 3, keepMarkers: true, background: '#fffdf7' }); qvp.cropBox(target);
|
|
106
|
+
await qvp.select(anchor, focus); qvp.clearSelection(); qvp.selection(); qvp.selectionText('rasm_uthmani', true);
|
|
107
|
+
await qvp.revealNext(1); qvp.hideBack(1); qvp.revealWord(i); qvp.hideWord(i); qvp.revealAll(); qvp.hideAll();
|
|
108
|
+
await qvp.maskHidden(); qvp.maskWords(); qvp.revealSteps(); qvp.revealAt(); qvp.revealStepOf(i);
|
|
109
|
+
await qvp.hitTestViewEx(x, y, { maxDistance: 6 }); qvp.wordBoxView(i); qvp.currentLayout(); qvp.relayout(); qvp.resetView(); qvp.stats();
|
|
110
|
+
|
|
111
|
+
// engine-wide (Qvp.*)
|
|
112
|
+
await Qvp.strip(s); Qvp.fold(s); Qvp.normalize(s); Qvp.looseKey(s); // = Qvp.arabic(kind, s)
|
|
113
|
+
await Qvp.gapToFill(pageW, pageH, lines, viewW, viewH); Qvp.wastedFraction(...)
|
|
114
|
+
Qvp.markName(7); Qvp.kindName(1); Qvp.categoryName(1); Qvp.familyName(3); Qvp.version
|
|
115
|
+
|
|
116
|
+
// atlas (cross-page)
|
|
117
|
+
const atlas = await QvpAtlas.load('asset://pages/atlas.qva');
|
|
118
|
+
await atlas.pageOf(2, 255); atlas.pageRange(42); atlas.surah(36); atlas.surahs(); atlas.pageOfSurah(36);
|
|
119
|
+
await atlas.juz(30); atlas.hizb(3); atlas.rubuAlHizb(7); atlas.juzAt(2, 255); atlas.divisionAt('hizb', 2, 255);
|
|
120
|
+
await atlas.pagesOfJuz(30); atlas.findSurah('cow' | 'البقرة' | '2'); atlas.free();
|
|
121
|
+
// tag-level equivalents: Qvp.atlasPageOf(id, s, a), Qvp.atlasFindSurah(id, text), Qvp.atlasPagesOfJuz(id, n), Qvp.atlasJuzAt(id, s, a), …
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
All page calls run on the UI thread (where the view draws), so the engine is never touched from two
|
|
125
|
+
threads. Only `maskHidden`, `unmask` (via the `mask` prop), `revealNext` etc. mutate state; queries are
|
|
126
|
+
microseconds.
|
|
127
|
+
|
|
128
|
+
## Layout of this package
|
|
129
|
+
|
|
130
|
+
```
|
|
131
|
+
android/build.gradle library module; depends on project(':qvp') or compiles its sources
|
|
132
|
+
android/src/main/java/net/quranpedia/qvp/rn/
|
|
133
|
+
QvpRnPageView.kt QvpPageView subclass: page loading, prop → handle reconciliation, events
|
|
134
|
+
QvpPageViewManager.kt SimpleViewManager<QvpRnPageView> (props, events, commit after each prop batch)
|
|
135
|
+
QvpModule.kt ReactContextBaseJavaModule: page queries by react tag, engine helpers, atlas
|
|
136
|
+
Marshal.kt JS shapes ↔ Kotlin types (targets, selectors, styles, records)
|
|
137
|
+
QvpPackage.kt
|
|
138
|
+
src/index.tsx typed API: <QvpPageView/>, useQvp(), Qvp, QvpAtlas, Sel, T, constants
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
## iOS — TODO
|
|
142
|
+
|
|
143
|
+
Not implemented (no macOS in the build environment). The iOS side mirrors the Android module over the
|
|
144
|
+
same `qvp.h`: a Swift `QvpPage`/`QvpAtlas` over `libqvp_ffi.a` (build with
|
|
145
|
+
`cargo build -p qvp-ffi --release --target aarch64-apple-ios[-sim]`), a `UIView` renderer with the
|
|
146
|
+
same draw order (highlight bands → cached base ink → styled ink → mask boxes, `tick(now)` per
|
|
147
|
+
`CADisplayLink` frame), an `RCTViewManager` exposing the props above with the same reconciliation, and
|
|
148
|
+
an `RCTBridgeModule` with the same method names. `src/index.tsx` needs no changes.
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// @quran.ws/qvp-react-native — Android module. Thin: marshalling + handle reconciliation over the Kotlin
|
|
2
|
+
// library `net.quranpedia.qvp` (packages/android/qvp), which is JNI over crates/qvp-ffi/include/qvp.h.
|
|
3
|
+
//
|
|
4
|
+
// Linking the Kotlin library, two ways:
|
|
5
|
+
// 1. (preferred) the app's settings.gradle includes it as a Gradle project:
|
|
6
|
+
// include(":qvp"); project(":qvp").projectDir = file("<repo>/packages/android/qvp")
|
|
7
|
+
// and this module depends on `project(":qvp")`.
|
|
8
|
+
// 2. otherwise this module compiles the library's Kotlin + JNI sources straight from `qvpAndroidDir`
|
|
9
|
+
// (gradle property; default: ../../../android/qvp inside the monorepo). Same code, one AAR.
|
|
10
|
+
buildscript {
|
|
11
|
+
if (project == rootProject) {
|
|
12
|
+
repositories { google(); mavenCentral() }
|
|
13
|
+
dependencies {
|
|
14
|
+
classpath("com.android.tools.build:gradle:8.7.3")
|
|
15
|
+
classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:2.0.21")
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
apply plugin: "com.android.library"
|
|
21
|
+
apply plugin: "org.jetbrains.kotlin.android"
|
|
22
|
+
|
|
23
|
+
def safeExtGet(prop, fallback) { rootProject.ext.has(prop) ? rootProject.ext.get(prop) : fallback }
|
|
24
|
+
def qvpProject = findProject(":qvp")
|
|
25
|
+
def qvpDir = file(rootProject.findProperty("qvpAndroidDir") ?: "$projectDir/../../../android/qvp")
|
|
26
|
+
|
|
27
|
+
android {
|
|
28
|
+
namespace "net.quranpedia.qvp.rn"
|
|
29
|
+
compileSdk safeExtGet("compileSdkVersion", 35)
|
|
30
|
+
defaultConfig {
|
|
31
|
+
minSdkVersion safeExtGet("minSdkVersion", 24)
|
|
32
|
+
if (qvpProject == null) {
|
|
33
|
+
externalNativeBuild { cmake { arguments "-DANDROID_STL=none" } }
|
|
34
|
+
ndk { abiFilters "arm64-v8a", "x86_64", "armeabi-v7a" }
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
if (qvpProject == null) {
|
|
38
|
+
ndkVersion safeExtGet("ndkVersion", "27.2.12479018")
|
|
39
|
+
externalNativeBuild { cmake { path file("$qvpDir/src/main/cpp/CMakeLists.txt"); version "3.22.1" } }
|
|
40
|
+
sourceSets { main { kotlin.srcDirs += "$qvpDir/src/main/kotlin"; java.srcDirs += "$qvpDir/src/main/kotlin" } }
|
|
41
|
+
}
|
|
42
|
+
compileOptions { sourceCompatibility JavaVersion.VERSION_17; targetCompatibility JavaVersion.VERSION_17 }
|
|
43
|
+
buildFeatures { buildConfig false }
|
|
44
|
+
}
|
|
45
|
+
kotlin { compilerOptions { jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17 } }
|
|
46
|
+
|
|
47
|
+
repositories { google(); mavenCentral() }
|
|
48
|
+
|
|
49
|
+
dependencies {
|
|
50
|
+
// version pinned by the React Native Gradle Plugin of the consuming app
|
|
51
|
+
implementation "com.facebook.react:react-android"
|
|
52
|
+
if (qvpProject != null) implementation project(":qvp")
|
|
53
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<manifest xmlns:android="http://schemas.android.com/apk/res/android" />
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
package net.quranpedia.qvp.rn
|
|
2
|
+
|
|
3
|
+
import com.facebook.react.bridge.Arguments
|
|
4
|
+
import com.facebook.react.bridge.ReadableArray
|
|
5
|
+
import com.facebook.react.bridge.ReadableMap
|
|
6
|
+
import com.facebook.react.bridge.WritableArray
|
|
7
|
+
import com.facebook.react.bridge.WritableMap
|
|
8
|
+
import net.quranpedia.qvp.*
|
|
9
|
+
import net.quranpedia.qvp.Target
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Marshalling only: JS shapes (docs/API.md) ↔ Kotlin library types. No hit-testing, layout or
|
|
13
|
+
* styling decisions live here — every call goes to [QvpPage] / [QvpAtlas], which go to the engine.
|
|
14
|
+
*
|
|
15
|
+
* Targets from JS: "page" | "2:255" | "2:255:3" | "2:255-257" | "line:7" | "surah:2" | word index |
|
|
16
|
+
* [word indices] | {kind, a, b, c, words} (kind = QVP_TARGET_* or its name).
|
|
17
|
+
* Selectors from JS: {kind, a, b, c} (kind = QVP_SEL_* or its name). Colours: '#rgb' | '#rrggbb' | '#rrggbbaa'.
|
|
18
|
+
*/
|
|
19
|
+
object Marshal {
|
|
20
|
+
// ── colours ──
|
|
21
|
+
fun color(v: Any?, default: Int): Int = when (v) {
|
|
22
|
+
null -> default
|
|
23
|
+
is String -> if (v.isBlank()) default else runCatching { QvpColor.parse(v) }.getOrDefault(default)
|
|
24
|
+
is Number -> v.toLong().toInt()
|
|
25
|
+
else -> default
|
|
26
|
+
}
|
|
27
|
+
fun colorOrNull(v: Any?): Int? = if (v == null) null else color(v, 0).let { if (it == 0) null else it }
|
|
28
|
+
|
|
29
|
+
private fun num(v: Any?): Int? = (v as? Number)?.toInt() ?: (v as? String)?.toIntOrNull()
|
|
30
|
+
private fun flt(v: Any?, d: Float): Float = (v as? Number)?.toFloat() ?: (v as? String)?.toFloatOrNull() ?: d
|
|
31
|
+
private fun bool(v: Any?, d: Boolean): Boolean = (v as? Boolean) ?: (v as? Number)?.let { it.toInt() != 0 } ?: d
|
|
32
|
+
private fun ints(v: Any?): IntArray = when (v) {
|
|
33
|
+
is Iterable<*> -> v.mapNotNull { num(it) }.toIntArray()
|
|
34
|
+
is IntArray -> v
|
|
35
|
+
is Number -> intArrayOf(v.toInt())
|
|
36
|
+
else -> IntArray(0)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
private val TARGET_KINDS = listOf("page", "word", "words", "ayah", "ayahRange", "line", "surah", "range")
|
|
40
|
+
private val SEL_KINDS = listOf("page", "path", "wordPath", "wordMark", "wordMarkNamed", "wordBody", "wordMarks", "word", "ayah", "line", "mark", "category", "family", "kind", "deco", "decoIdx")
|
|
41
|
+
private fun kindOf(v: Any?, names: List<String>): Int = when (v) {
|
|
42
|
+
is Number -> v.toInt()
|
|
43
|
+
is String -> names.indexOf(v).let { if (it >= 0) it else v.toIntOrNull() ?: -1 }
|
|
44
|
+
else -> -1
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** JS target (already converted with toHashMap/toArrayList or a plain value) → [Target]. Unknown → empty word list. */
|
|
48
|
+
fun target(v: Any?, page: QvpPage?): Target = when (v) {
|
|
49
|
+
null -> Target.page()
|
|
50
|
+
is String -> runCatching { Target.parse(v, page) }.getOrElse { Target.words(IntArray(0)) }
|
|
51
|
+
is Number -> Target.word(v.toInt())
|
|
52
|
+
is Iterable<*> -> Target.words(ints(v))
|
|
53
|
+
is IntArray -> Target.words(v)
|
|
54
|
+
is Map<*, *> -> {
|
|
55
|
+
val a = num(v["a"]) ?: 0; val b = num(v["b"]) ?: 0; val c = num(v["c"]) ?: 0
|
|
56
|
+
when (kindOf(v["kind"], TARGET_KINDS)) {
|
|
57
|
+
0 -> Target.page(); 1 -> Target.word(a); 2 -> Target.words(ints(v["words"] ?: v["a"]))
|
|
58
|
+
3 -> Target.ayah(a, b); 4 -> Target.ayahRange(a, b, c); 5 -> Target.line(a); 6 -> Target.surah(a); 7 -> Target.range(a, b)
|
|
59
|
+
else -> Target.words(IntArray(0))
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
else -> Target.words(IntArray(0))
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** JS selector {kind, a, b, c} → [Selector], or null when malformed. */
|
|
66
|
+
fun selector(v: Any?): Selector? {
|
|
67
|
+
val m = v as? Map<*, *> ?: return null
|
|
68
|
+
val a = num(m["a"]) ?: 0; val b = num(m["b"]) ?: 0; val c = num(m["c"]) ?: 0
|
|
69
|
+
return when (kindOf(m["kind"], SEL_KINDS)) {
|
|
70
|
+
0 -> Selector.page(); 1 -> Selector.path(a); 2 -> Selector.wordPath(a, b); 3 -> Selector.wordMark(a, b)
|
|
71
|
+
4 -> Selector.wordMarkNamed(a, (m["mark"] as? String) ?: QVP_MARKS.getOrElse(b) { "" }, c)
|
|
72
|
+
5 -> Selector.wordBody(a); 6 -> Selector.wordMarks(a); 7 -> Selector.word(a); 8 -> Selector.ayah(a, b); 9 -> Selector.line(a)
|
|
73
|
+
10 -> (m["mark"] as? String)?.let { Selector.mark(it) } ?: Selector.mark(a)
|
|
74
|
+
11 -> Selector.category(a); 12 -> Selector.family(a); 13 -> Selector.kind(a); 14 -> Selector.deco(a); 15 -> Selector.decoIdx(a)
|
|
75
|
+
else -> null
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
fun form(v: Any?, d: Form = Form.RASM_UTHMANI): Form = when (v) {
|
|
80
|
+
is Number -> Form.entries.getOrElse(v.toInt()) { d }
|
|
81
|
+
is String -> Form.entries.firstOrNull { it.name.equals(v, true) } ?: d
|
|
82
|
+
else -> d
|
|
83
|
+
}
|
|
84
|
+
fun searchMode(v: Any?): SearchMode = when (v) {
|
|
85
|
+
is Number -> SearchMode.entries.getOrElse(v.toInt()) { SearchMode.INCLUDES }
|
|
86
|
+
is String -> SearchMode.entries.firstOrNull { it.name.equals(v, true) } ?: SearchMode.INCLUDES
|
|
87
|
+
else -> SearchMode.INCLUDES
|
|
88
|
+
}
|
|
89
|
+
fun maskMode(v: Any?): MaskMode = when (v) {
|
|
90
|
+
is Number -> MaskMode.entries.getOrElse(v.toInt()) { MaskMode.HIDE }
|
|
91
|
+
is String -> MaskMode.entries.firstOrNull { it.name.equals(v, true) } ?: MaskMode.HIDE
|
|
92
|
+
else -> MaskMode.HIDE
|
|
93
|
+
}
|
|
94
|
+
fun division(v: Any?): Division = when (v) {
|
|
95
|
+
is Number -> Division.entries.getOrElse(v.toInt()) { Division.JUZ }
|
|
96
|
+
is String -> Division.entries.firstOrNull { it.name.equals(v, true) } ?: Division.JUZ
|
|
97
|
+
else -> Division.JUZ
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** {mode, ink, band, height, padX, padY, radius, seam, ms, layer} → [QvpHighlightStyle] (missing keys keep the library defaults). */
|
|
101
|
+
fun highlightStyle(v: Any?): QvpHighlightStyle {
|
|
102
|
+
val m = v as? Map<*, *> ?: return QvpHighlightStyle()
|
|
103
|
+
val d = QvpHighlightStyle()
|
|
104
|
+
val mode = when (val x = m["mode"]) { is Number -> HighlightMode.entries.getOrElse(x.toInt()) { d.mode }; is String -> HighlightMode.entries.firstOrNull { it.name.equals(x, true) } ?: d.mode; else -> d.mode }
|
|
105
|
+
val height = when (val x = m["height"]) { is Number -> BandHeight.entries.getOrElse(x.toInt()) { d.height }; is String -> BandHeight.entries.firstOrNull { it.name.equals(x, true) } ?: d.height; else -> d.height }
|
|
106
|
+
return QvpHighlightStyle(mode = mode, ink = color(m["ink"], d.ink), band = color(m["band"], d.band), height = height, padX = flt(m["padX"], d.padX), padY = flt(m["padY"], d.padY),
|
|
107
|
+
radius = flt(m["radius"], d.radius), seam = flt(m["seam"], d.seam), transitionMs = num(m["ms"] ?: m["transitionMs"]) ?: d.transitionMs, layer = num(m["layer"]) ?: d.layer)
|
|
108
|
+
}
|
|
109
|
+
/** {ink, diacritics, dots, waqf, sifr, ayahMark, numeral, headers, marks: {name: colour}, ms} → [QvpTheme]. */
|
|
110
|
+
fun theme(v: Any?): QvpTheme {
|
|
111
|
+
val m = v as? Map<*, *> ?: return QvpTheme()
|
|
112
|
+
val marks = (m["marks"] as? Map<*, *>)?.entries?.mapNotNull { (k, c) -> colorOrNull(c)?.let { (k as String) to it } }?.toMap() ?: emptyMap()
|
|
113
|
+
return QvpTheme(ink = colorOrNull(m["ink"]), diacritics = colorOrNull(m["diacritics"]), dots = colorOrNull(m["dots"]), waqf = colorOrNull(m["waqf"]), sifr = colorOrNull(m["sifr"]),
|
|
114
|
+
ayahMark = colorOrNull(m["ayahMark"]), numeral = colorOrNull(m["numeral"]), headers = colorOrNull(m["headers"]), marks = marks, transitionMs = num(m["ms"] ?: m["transitionMs"]) ?: 0)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ── Kotlin → JS ──
|
|
118
|
+
fun word(p: QvpPage, w: QvpWord): Map<String, Any?> {
|
|
119
|
+
val forms = LinkedHashMap<String, String>()
|
|
120
|
+
for (f in Form.entries) if (f == Form.RASM_UTHMANI || p.hasForm(f)) forms[f.name.lowercase()] = p.wordForm(w.idx, f)
|
|
121
|
+
val paths = (w.firstPath until w.firstPath + w.nPaths).map { i ->
|
|
122
|
+
val kind = p.pathKind(i); val mark = p.pathMark(i); val cat = p.pathCategory(i); val fam = p.pathFamily(i)
|
|
123
|
+
mapOf("idx" to i, "kind" to kind, "kindName" to QvpEngine.kindName(kind), "mark" to mark, "markName" to QvpEngine.markName(mark), "nthMark" to p.pathNthMark(i),
|
|
124
|
+
"nthInWord" to p.pathNthInWord(i), "category" to cat, "categoryName" to QvpEngine.categoryName(cat), "family" to fam, "familyName" to QvpEngine.familyName(fam), "line" to p.pathLine(i))
|
|
125
|
+
}
|
|
126
|
+
return mapOf("idx" to w.idx, "surah" to w.surah, "ayah" to w.ayah, "word" to w.word, "line" to w.line, "lineIdx" to w.lineIdx, "ayahIdx" to w.ayahIdx,
|
|
127
|
+
"x0" to w.x0, "y0" to w.y0, "x1" to w.x1, "y1" to w.y1, "text" to w.text, "firstPath" to w.firstPath, "nPaths" to w.nPaths,
|
|
128
|
+
"wordKey" to w.wordKey, "ayahKey" to w.ayahKey, "forms" to forms, "label" to p.wordLabel(w.idx), "paths" to paths)
|
|
129
|
+
}
|
|
130
|
+
fun deco(d: QvpDecoration): Map<String, Any?> = mapOf("idx" to d.idx, "kind" to d.kind, "kindName" to listOf("ayah-ayahMark", "surah-name", "basmalah", "division-mark", "sajdah-mark").getOrElse(d.kind) { "other" },
|
|
131
|
+
"surah" to d.surah, "ayah" to d.ayah, "line" to d.line, "x0" to d.x0, "y0" to d.y0, "x1" to d.x1, "y1" to d.y1, "text" to d.text, "firstPath" to d.firstPath, "nPaths" to d.nPaths)
|
|
132
|
+
fun hit(p: QvpPage, h: QvpHitEx): Map<String, Any?> {
|
|
133
|
+
val w = if (h.word >= 0) p.words[h.word] else null
|
|
134
|
+
return mapOf("word" to h.word, "path" to h.path, "deco" to h.deco, "line" to h.line, "distance" to h.distance, "exact" to h.exact, "wordKey" to w?.wordKey, "ayahKey" to w?.ayahKey)
|
|
135
|
+
}
|
|
136
|
+
fun ayah(a: QvpAyah): Map<String, Any?> = mapOf("idx" to a.idx, "surah" to a.surah, "ayah" to a.ayah, "fragment" to a.fragment, "fragments" to a.fragments, "flags" to a.flags, "rubuAlHizb" to a.rubuAlHizb, "firstWord" to a.firstWord, "nWords" to a.nWords,
|
|
137
|
+
"ayahMarkDeco" to a.ayahMarkDeco, "bbox" to listOf(a.x0, a.y0, a.x1, a.y1))
|
|
138
|
+
fun line(l: QvpLine): Map<String, Any?> = mapOf("idx" to l.idx, "lineNo" to l.lineNo, "isHeader" to l.isHeader, "firstWord" to l.firstWord, "nWords" to l.nWords, "bbox" to listOf(l.x0, l.y0, l.x1, l.y1),
|
|
139
|
+
"bandY0" to l.bandY0, "bandY1" to l.bandY1, "centre" to l.centre)
|
|
140
|
+
fun surah(s: QvpSurah): Map<String, Any?> = mapOf("number" to s.number, "ayahCount" to s.ayahCount, "hasBanner" to s.hasBanner, "hasBasmalah" to s.hasBasmalah, "place" to s.place, "bannerDeco" to s.bannerDeco,
|
|
141
|
+
"arabic" to s.arabic, "latin" to s.latin, "english" to s.english)
|
|
142
|
+
fun division(d: QvpDivision): Map<String, Any?> = mapOf("kind" to d.kind.name.lowercase(), "n" to d.n, "surah" to d.surah, "ayah" to d.ayah, "line" to d.line, "ayahIdx" to d.ayahIdx)
|
|
143
|
+
fun ayahMark(m: QvpAyahMark): Map<String, Any?> = mapOf("deco" to m.deco, "surah" to m.surah, "ayah" to m.ayah, "line" to m.line, "cx" to m.cx, "cy" to m.cy, "r" to m.r, "ornamentPath" to m.ornamentPath, "numeralPath" to m.numeralPath)
|
|
144
|
+
fun rosette(r: QvpRosette): Map<String, Any?> = mapOf("deco" to r.deco, "surah" to r.surah, "ayah" to r.ayah, "juz" to r.juz, "hizb" to r.hizb, "nisf" to r.nisf, "rubuAlHizb" to r.rubuAlHizb, "rubuAlHizbInHizb" to r.rubuAlHizbInHizb)
|
|
145
|
+
fun sajdah(s: QvpSajdah): Map<String, Any?> = mapOf("deco" to s.deco, "surah" to s.surah, "ayah" to s.ayah, "signPath" to s.signPath)
|
|
146
|
+
fun match(m: QvpMatch): Map<String, Any?> = mapOf("word" to m.word, "index" to m.index, "loose" to m.loose, "wordKey" to m.wordKey, "text" to m.text)
|
|
147
|
+
fun cropBox(c: QvpCropBox): Map<String, Any?> = mapOf("x0" to c.x0, "y0" to c.y0, "x1" to c.x1, "y1" to c.y1, "nWords" to c.nWords, "ayahMarkDeco" to c.ayahMarkDeco)
|
|
148
|
+
fun atlasSurah(s: QvpAtlasSurah): Map<String, Any?> = mapOf("n" to s.n, "number" to s.n, "page" to s.page, "ayahCount" to s.ayahCount, "place" to s.place, "arabic" to s.arabic, "latin" to s.latin, "english" to s.english)
|
|
149
|
+
fun atlasRubuAlHizb(r: QvpAtlasRubuAlHizb): Map<String, Any?> = mapOf("rubuAlHizb" to r.rubuAlHizb, "surah" to r.surah, "ayah" to r.ayah, "page" to r.page, "ayahKey" to r.ayahKey)
|
|
150
|
+
fun layout(l: QvpLayout): Map<String, Any?> = mapOf("scale" to l.scale, "ox" to l.ox, "oy" to l.oy, "contentW" to l.contentW, "contentH" to l.contentH, "pitch" to l.pitch, "lineDy" to l.lineDy.toList(),
|
|
151
|
+
"slots" to l.slotTop.indices.map { listOf(l.slotTop[it], l.slotBottom[it]) })
|
|
152
|
+
fun pageInfo(p: QvpPage): Map<String, Any?> = mapOf("page" to p.pageNo, "width" to p.width, "height" to p.height, "nLines" to p.nLines, "nAyahs" to p.nAyahs, "nWords" to p.nWords, "nPaths" to p.nPaths, "nDecos" to p.nDecos,
|
|
153
|
+
"naturalPitch" to p.naturalPitch, "forms" to Form.entries.filter { it == Form.RASM_UTHMANI || p.hasForm(it) }.map { it.name.lowercase() })
|
|
154
|
+
fun selection(p: QvpPage): Map<String, Any?> {
|
|
155
|
+
val ws = p.selection()
|
|
156
|
+
return mapOf("words" to ws.toList(), "text" to (if (ws.isEmpty()) "" else p.text(Target.words(ws))), "citation" to (if (ws.isEmpty()) "" else p.citation(ws)),
|
|
157
|
+
"textWithCitation" to (if (ws.isEmpty()) "" else p.selectionText(Form.RASM_UTHMANI, true)))
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Any Kotlin value (maps, lists, arrays, primitives) → a bridge value. */
|
|
161
|
+
fun toJs(v: Any?): Any? = when (v) {
|
|
162
|
+
null -> null
|
|
163
|
+
is Boolean, is String, is Int, is Double -> v
|
|
164
|
+
is Float -> v.toDouble(); is Long -> v.toDouble(); is Short -> v.toInt(); is Byte -> v.toInt()
|
|
165
|
+
is Map<*, *> -> toMap(v)
|
|
166
|
+
is Iterable<*> -> toArray(v)
|
|
167
|
+
is IntArray -> toArray(v.toList()); is FloatArray -> toArray(v.toList()); is Array<*> -> toArray(v.toList())
|
|
168
|
+
is Pair<*, *> -> toArray(listOf(v.first, v.second))
|
|
169
|
+
is Enum<*> -> v.name.lowercase()
|
|
170
|
+
else -> v.toString()
|
|
171
|
+
}
|
|
172
|
+
fun toMap(m: Map<*, *>): WritableMap {
|
|
173
|
+
val out = Arguments.createMap()
|
|
174
|
+
for ((k, v) in m) put(out, k.toString(), toJs(v))
|
|
175
|
+
return out
|
|
176
|
+
}
|
|
177
|
+
fun toArray(l: Iterable<*>): WritableArray {
|
|
178
|
+
val out = Arguments.createArray()
|
|
179
|
+
for (v in l) when (val j = toJs(v)) {
|
|
180
|
+
null -> out.pushNull(); is Boolean -> out.pushBoolean(j); is Int -> out.pushInt(j); is Double -> out.pushDouble(j); is String -> out.pushString(j)
|
|
181
|
+
is WritableMap -> out.pushMap(j); is WritableArray -> out.pushArray(j); else -> out.pushString(j.toString())
|
|
182
|
+
}
|
|
183
|
+
return out
|
|
184
|
+
}
|
|
185
|
+
private fun put(out: WritableMap, k: String, j: Any?) { when (j) {
|
|
186
|
+
null -> out.putNull(k); is Boolean -> out.putBoolean(k, j); is Int -> out.putInt(k, j); is Double -> out.putDouble(k, j); is String -> out.putString(k, j)
|
|
187
|
+
is WritableMap -> out.putMap(k, j); is WritableArray -> out.putArray(k, j); else -> out.putString(k, j.toString())
|
|
188
|
+
} }
|
|
189
|
+
fun plain(m: ReadableMap?): HashMap<String, Any?>? = m?.toHashMap()
|
|
190
|
+
fun plain(a: ReadableArray?): ArrayList<Any?>? = a?.toArrayList()
|
|
191
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
package net.quranpedia.qvp.rn
|
|
2
|
+
|
|
3
|
+
import com.facebook.react.bridge.*
|
|
4
|
+
import com.facebook.react.module.annotations.ReactModule
|
|
5
|
+
import com.facebook.react.uimanager.UIManagerHelper
|
|
6
|
+
import net.quranpedia.qvp.*
|
|
7
|
+
import net.quranpedia.qvp.Target
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Promise-based queries on the page behind a `<QvpPageView />` (looked up by react tag) plus engine-wide
|
|
11
|
+
* helpers and the atlas. Everything runs on the UI thread, where the view draws, so the engine is never
|
|
12
|
+
* touched from two threads. Names follow docs/API.md.
|
|
13
|
+
*/
|
|
14
|
+
@ReactModule(name = QvpModule.NAME)
|
|
15
|
+
class QvpModule(private val ctx: ReactApplicationContext) : ReactContextBaseJavaModule(ctx) {
|
|
16
|
+
override fun getName() = NAME
|
|
17
|
+
private val atlases = HashMap<Int, QvpAtlas>()
|
|
18
|
+
private val atlasByUri = HashMap<String, Int>()
|
|
19
|
+
private var nextAtlas = 1
|
|
20
|
+
|
|
21
|
+
private fun view(tag: Int): QvpRnPageView? = QvpRegistry.get(tag) ?: runCatching { UIManagerHelper.getUIManagerForReactTag(ctx, tag)?.resolveView(tag) as? QvpRnPageView }.getOrNull()
|
|
22
|
+
// Every @ReactMethod must be `void` for the TurboModule interop layer: these helpers return Unit on purpose.
|
|
23
|
+
private fun ui(promise: Promise, block: () -> Any?) {
|
|
24
|
+
UiThreadUtil.runOnUiThread {
|
|
25
|
+
try { promise.resolve(Marshal.toJs(block())) } catch (e: Throwable) { promise.reject("qvp", e.message ?: e.toString(), e) }
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
private fun withPage(tag: Int, promise: Promise, block: (QvpRnPageView, QvpPage) -> Any?) {
|
|
29
|
+
ui(promise) {
|
|
30
|
+
val v = view(tag) ?: throw IllegalStateException("no QvpPageView with tag $tag")
|
|
31
|
+
val p = v.page ?: throw IllegalStateException("QvpPageView $tag has no page loaded")
|
|
32
|
+
block(v, p)
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
private fun withAtlas(id: Int, promise: Promise, block: (QvpAtlas) -> Any?) { ui(promise) { block(atlases[id] ?: throw IllegalStateException("no atlas $id")) } }
|
|
36
|
+
private fun opt(m: ReadableMap?): Map<String, Any?> = m?.toHashMap() ?: emptyMap()
|
|
37
|
+
private fun tgt(d: Dynamic, p: QvpPage): Target = Marshal.target(when (d.type) {
|
|
38
|
+
ReadableType.String -> d.asString(); ReadableType.Number -> d.asDouble(); ReadableType.Array -> d.asArray()?.toArrayList(); ReadableType.Map -> d.asMap()?.toHashMap(); else -> null }, p)
|
|
39
|
+
private fun ints(a: ReadableArray?): IntArray = a?.toArrayList()?.mapNotNull { (it as? Number)?.toInt() }?.toIntArray() ?: IntArray(0)
|
|
40
|
+
|
|
41
|
+
// ── page facts ──
|
|
42
|
+
@ReactMethod fun info(tag: Int, promise: Promise) = withPage(tag, promise) { v, p -> Marshal.pageInfo(p) + mapOf("loadMs" to v.loadMs, "bytes" to v.pageBytes) }
|
|
43
|
+
@ReactMethod fun words(tag: Int, promise: Promise) = withPage(tag, promise) { _, p -> p.words.map { Marshal.word(p, it) } }
|
|
44
|
+
@ReactMethod fun word(tag: Int, idx: Int, promise: Promise) = withPage(tag, promise) { _, p -> p.words.getOrNull(idx)?.let { Marshal.word(p, it) } }
|
|
45
|
+
@ReactMethod fun ayahs(tag: Int, promise: Promise) = withPage(tag, promise) { _, p -> p.ayahs.map { Marshal.ayah(it) } }
|
|
46
|
+
@ReactMethod fun lines(tag: Int, promise: Promise) = withPage(tag, promise) { _, p -> p.lines.map { Marshal.line(it) } }
|
|
47
|
+
@ReactMethod fun decos(tag: Int, promise: Promise) = withPage(tag, promise) { _, p -> p.decos.map { Marshal.deco(it) } }
|
|
48
|
+
@ReactMethod fun findWord(tag: Int, s: Int, a: Int, w: Int, promise: Promise) = withPage(tag, promise) { _, p -> p.findWord(s, a, w) }
|
|
49
|
+
@ReactMethod fun resolve(tag: Int, target: Dynamic, promise: Promise) = withPage(tag, promise) { _, p -> p.resolve(tgt(target, p)).toList() }
|
|
50
|
+
@ReactMethod fun wordForm(tag: Int, idx: Int, form: String?, promise: Promise) = withPage(tag, promise) { _, p -> p.wordForm(idx, Marshal.form(form)) }
|
|
51
|
+
@ReactMethod fun hasForm(tag: Int, form: String?, promise: Promise) = withPage(tag, promise) { _, p -> p.hasForm(Marshal.form(form)) }
|
|
52
|
+
@ReactMethod fun attachWords(tag: Int, json: String, promise: Promise) = withPage(tag, promise) { _, p -> p.attachWords(json) }
|
|
53
|
+
|
|
54
|
+
// ── metadata ──
|
|
55
|
+
@ReactMethod fun surahs(tag: Int, promise: Promise) = withPage(tag, promise) { _, p -> p.surahs().map { Marshal.surah(it) } }
|
|
56
|
+
@ReactMethod fun divisions(tag: Int, promise: Promise) = withPage(tag, promise) { _, p -> p.divisions().map { Marshal.division(it) } }
|
|
57
|
+
@ReactMethod fun ayahMarks(tag: Int, promise: Promise) = withPage(tag, promise) { _, p -> p.ayahMarks().map { Marshal.ayahMark(it) } }
|
|
58
|
+
@ReactMethod fun rosettes(tag: Int, promise: Promise) = withPage(tag, promise) { _, p -> p.rosettes().map { Marshal.rosette(it) } }
|
|
59
|
+
@ReactMethod fun sajdahs(tag: Int, promise: Promise) = withPage(tag, promise) { _, p -> p.sajdahs().map { Marshal.sajdah(it) } }
|
|
60
|
+
@ReactMethod fun ayahKeys(tag: Int, promise: Promise) = withPage(tag, promise) { _, p -> p.ayahKeys().map { mapOf("surah" to it.first, "ayah" to it.second, "ayahKey" to "${it.first}:${it.second}") } }
|
|
61
|
+
@ReactMethod fun ayahWordCount(tag: Int, s: Int, a: Int, promise: Promise) = withPage(tag, promise) { _, p -> p.ayahWordCount(s, a).let { mapOf("count" to it.first, "complete" to it.second) } }
|
|
62
|
+
@ReactMethod fun reciteMap(tag: Int, s: Int, a: Int, n: Int, promise: Promise) = withPage(tag, promise) { _, p -> p.reciteMap(s, a, n)?.toList() }
|
|
63
|
+
@ReactMethod fun wordLabel(tag: Int, i: Int, promise: Promise) = withPage(tag, promise) { _, p -> p.wordLabel(i) }
|
|
64
|
+
@ReactMethod fun ayahLabel(tag: Int, i: Int, promise: Promise) = withPage(tag, promise) { _, p -> p.ayahLabel(i) }
|
|
65
|
+
|
|
66
|
+
// ── text & search ──
|
|
67
|
+
@ReactMethod fun text(tag: Int, target: Dynamic, opts: ReadableMap?, promise: Promise) = withPage(tag, promise) { _, p ->
|
|
68
|
+
val o = opt(opts); p.text(tgt(target, p), Marshal.form(o["form"]), (o["wordSep"] as? String) ?: " ", (o["lineSep"] as? String) ?: "\n") }
|
|
69
|
+
@ReactMethod fun search(tag: Int, query: String, opts: ReadableMap?, promise: Promise) = withPage(tag, promise) { _, p ->
|
|
70
|
+
val o = opt(opts)
|
|
71
|
+
p.search(query, Marshal.form(o["form"], Form.SEARCH), Marshal.searchMode(o["mode"]), o["normalize"] != false, o["loose"] != false, (o["limit"] as? Number)?.toInt() ?: 0).map { Marshal.match(it) } }
|
|
72
|
+
@ReactMethod fun citation(tag: Int, words: ReadableArray, promise: Promise) = withPage(tag, promise) { _, p -> p.citation(ints(words)) }
|
|
73
|
+
@ReactMethod fun arabic(kind: String, s: String, promise: Promise) = ui(promise) { when (kind) { "strip" -> QvpEngine.strip(s); "fold" -> QvpEngine.fold(s); "normalize" -> QvpEngine.normalize(s); "loose", "looseKey" -> QvpEngine.looseKey(s); else -> s } }
|
|
74
|
+
|
|
75
|
+
// ── hit testing / layout (the view already does gestures; these are for scroll-into-view etc.) ──
|
|
76
|
+
@ReactMethod fun hitTestViewEx(tag: Int, x: Double, y: Double, opts: ReadableMap?, promise: Promise) = withPage(tag, promise) { v, p ->
|
|
77
|
+
val o = opt(opts); val d = v.resources.displayMetrics.density
|
|
78
|
+
p.hitTestViewEx(((x * d).toFloat() - v.inner.viewOx) / v.inner.viewScale, ((y * d).toFloat() - v.inner.viewOy) / v.inner.viewScale, QvpHitOptions((o["maxDistance"] as? Number)?.toFloat() ?: 6f, (o["gapBias"] as? Number)?.toFloat() ?: 0.6f, o["exactFirst"] != false))?.let { Marshal.hit(p, it) } }
|
|
79
|
+
@ReactMethod fun wordBoxView(tag: Int, i: Int, promise: Promise) = withPage(tag, promise) { v, p -> val d = v.resources.displayMetrics.density
|
|
80
|
+
p.wordBoxView(i)?.let { b -> mapOf("x0" to (v.inner.viewOx + b[0] * v.inner.viewScale) / d, "y0" to (v.inner.viewOy + b[1] * v.inner.viewScale) / d, "x1" to (v.inner.viewOx + b[2] * v.inner.viewScale) / d, "y1" to (v.inner.viewOy + b[3] * v.inner.viewScale) / d) } }
|
|
81
|
+
@ReactMethod fun currentLayout(tag: Int, promise: Promise) = withPage(tag, promise) { _, p -> p.currentLayout?.let { Marshal.layout(it) } }
|
|
82
|
+
@ReactMethod fun relayout(tag: Int, promise: Promise) = withPage(tag, promise) { v, _ -> v.inner.relayout(); v.resetView(); null }
|
|
83
|
+
@ReactMethod fun resetView(tag: Int, promise: Promise) = withPage(tag, promise) { v, _ -> v.resetView(); null }
|
|
84
|
+
@ReactMethod fun gapToFill(pw: Double, ph: Double, lines: Int, vw: Double, vh: Double, max: Double, promise: Promise) = ui(promise) { QvpEngine.gapToFill(pw.toFloat(), ph.toFloat(), lines, vw.toFloat(), vh.toFloat(), max.toFloat()) }
|
|
85
|
+
@ReactMethod fun wastedFraction(pw: Double, ph: Double, vw: Double, vh: Double, promise: Promise) = ui(promise) { QvpEngine.wastedFraction(pw.toFloat(), ph.toFloat(), vw.toFloat(), vh.toFloat()) }
|
|
86
|
+
@ReactMethod fun stats(tag: Int, promise: Promise) = ui(promise) { view(tag)?.stats() }
|
|
87
|
+
@ReactMethod fun invalidate(tag: Int, promise: Promise) = ui(promise) { view(tag)?.inner?.invalidate(); null }
|
|
88
|
+
|
|
89
|
+
// ── selection ──
|
|
90
|
+
@ReactMethod fun select(tag: Int, anchor: Int, focus: Int, promise: Promise) = withPage(tag, promise) { v, p -> p.select(anchor, focus); v.inner.invalidate(); Marshal.selection(p) }
|
|
91
|
+
@ReactMethod fun clearSelection(tag: Int, promise: Promise) = withPage(tag, promise) { v, _ -> v.inner.clearSelection(); null }
|
|
92
|
+
@ReactMethod fun selection(tag: Int, promise: Promise) = withPage(tag, promise) { _, p -> Marshal.selection(p) }
|
|
93
|
+
@ReactMethod fun selectionText(tag: Int, form: String?, citation: Boolean, promise: Promise) = withPage(tag, promise) { _, p -> p.selectionText(Marshal.form(form), citation) }
|
|
94
|
+
|
|
95
|
+
// ── memorisation (stepwise ops; `mask` / `reveal` props hold the declarative part) ──
|
|
96
|
+
@ReactMethod fun revealNext(tag: Int, n: Int, promise: Promise) = withPage(tag, promise) { v, p -> p.revealNext(n).also { v.inner.invalidate() } }
|
|
97
|
+
@ReactMethod fun hideBack(tag: Int, n: Int, promise: Promise) = withPage(tag, promise) { v, p -> p.hideBack(n).also { v.inner.invalidate() } }
|
|
98
|
+
@ReactMethod fun revealWord(tag: Int, i: Int, promise: Promise) = withPage(tag, promise) { v, p -> p.revealWord(i).also { v.inner.invalidate() } }
|
|
99
|
+
@ReactMethod fun hideWord(tag: Int, i: Int, promise: Promise) = withPage(tag, promise) { v, p -> p.hideWord(i).also { v.inner.invalidate() } }
|
|
100
|
+
@ReactMethod fun revealAll(tag: Int, promise: Promise) = withPage(tag, promise) { v, p -> p.revealAll(); v.inner.invalidate(); null }
|
|
101
|
+
@ReactMethod fun hideAll(tag: Int, promise: Promise) = withPage(tag, promise) { v, p -> p.hideAll(); v.inner.invalidate(); null }
|
|
102
|
+
@ReactMethod fun maskHidden(tag: Int, promise: Promise) = withPage(tag, promise) { _, p -> p.maskHidden().toList() }
|
|
103
|
+
@ReactMethod fun maskWords(tag: Int, promise: Promise) = withPage(tag, promise) { _, p -> p.maskWords().toList() }
|
|
104
|
+
@ReactMethod fun revealSteps(tag: Int, promise: Promise) = withPage(tag, promise) { _, p -> p.revealSteps() }
|
|
105
|
+
@ReactMethod fun revealAt(tag: Int, promise: Promise) = withPage(tag, promise) { _, p -> p.revealAt()?.toDouble() }
|
|
106
|
+
@ReactMethod fun revealStepOf(tag: Int, i: Int, promise: Promise) = withPage(tag, promise) { _, p -> p.revealStepOf(i).toDouble() }
|
|
107
|
+
|
|
108
|
+
// ── crop ──
|
|
109
|
+
@ReactMethod fun cropBox(tag: Int, target: Dynamic, opts: ReadableMap?, promise: Promise) = withPage(tag, promise) { _, p ->
|
|
110
|
+
val o = opt(opts); p.cropBox(tgt(target, p), (o["pad"] as? Number)?.toFloat() ?: 2f, o["keepAyahMarks"] != false)?.let { Marshal.cropBox(it) } }
|
|
111
|
+
@ReactMethod fun cropSvg(tag: Int, target: Dynamic, opts: ReadableMap?, promise: Promise) = withPage(tag, promise) { _, p ->
|
|
112
|
+
val o = opt(opts); p.cropSvg(tgt(target, p), (o["pad"] as? Number)?.toFloat() ?: 2f, o["keepAyahMarks"] != false, Marshal.color(o["background"], 0)) }
|
|
113
|
+
|
|
114
|
+
// ── atlas ──
|
|
115
|
+
@ReactMethod fun loadAtlas(uri: String, promise: Promise) = ui(promise) {
|
|
116
|
+
atlasByUri[uri] ?: run {
|
|
117
|
+
val bytes = when {
|
|
118
|
+
uri.startsWith("asset://") -> ctx.assets.open(uri.removePrefix("asset://")).use { it.readBytes() }
|
|
119
|
+
uri.startsWith("asset:") -> ctx.assets.open(uri.removePrefix("asset:")).use { it.readBytes() }
|
|
120
|
+
uri.startsWith("file://") -> java.io.File(uri.removePrefix("file://")).readBytes()
|
|
121
|
+
uri.startsWith("/") -> java.io.File(uri).readBytes()
|
|
122
|
+
uri.startsWith("base64:") -> android.util.Base64.decode(uri.removePrefix("base64:"), android.util.Base64.DEFAULT)
|
|
123
|
+
else -> ctx.assets.open(uri).use { it.readBytes() }
|
|
124
|
+
}
|
|
125
|
+
val id = nextAtlas++; atlases[id] = QvpAtlas(bytes); atlasByUri[uri] = id; id
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
@ReactMethod fun freeAtlas(id: Int, promise: Promise) = ui(promise) { atlases.remove(id)?.close(); atlasByUri.entries.removeAll { it.value == id }; null }
|
|
129
|
+
@ReactMethod fun atlasPageOf(id: Int, s: Int, a: Int, promise: Promise) = withAtlas(id, promise) { it.pageOf(s, a) }
|
|
130
|
+
@ReactMethod fun atlasPageRange(id: Int, page: Int, promise: Promise) = withAtlas(id, promise) { it.pageRange(page)?.let { r -> mapOf("first" to mapOf("surah" to r.first.first, "ayah" to r.first.second), "last" to mapOf("surah" to r.second.first, "ayah" to r.second.second)) } }
|
|
131
|
+
@ReactMethod fun atlasPages(id: Int, promise: Promise) = withAtlas(id, promise) { it.pages() }
|
|
132
|
+
@ReactMethod fun atlasSurah(id: Int, n: Int, promise: Promise) = withAtlas(id, promise) { it.surah(n)?.let { s -> Marshal.atlasSurah(s) } }
|
|
133
|
+
@ReactMethod fun atlasSurahs(id: Int, promise: Promise) = withAtlas(id, promise) { it.surahs().map { s -> Marshal.atlasSurah(s) } }
|
|
134
|
+
@ReactMethod fun atlasPageOfSurah(id: Int, n: Int, promise: Promise) = withAtlas(id, promise) { it.pageOfSurah(n) }
|
|
135
|
+
@ReactMethod fun atlasDivision(id: Int, kind: String, n: Int, promise: Promise) = withAtlas(id, promise) { it.division(Marshal.division(kind), n)?.let { r -> Marshal.atlasRubuAlHizb(r) } }
|
|
136
|
+
@ReactMethod fun atlasDivisionAt(id: Int, kind: String, s: Int, a: Int, promise: Promise) = withAtlas(id, promise) { it.divisionAt(Marshal.division(kind), s, a) }
|
|
137
|
+
@ReactMethod fun atlasJuzAt(id: Int, s: Int, a: Int, promise: Promise) = withAtlas(id, promise) { it.juzAt(s, a) }
|
|
138
|
+
@ReactMethod fun atlasPagesOfJuz(id: Int, n: Int, promise: Promise) = withAtlas(id, promise) { it.pagesOfJuz(n)?.let { r -> listOf(r.first, r.second) } }
|
|
139
|
+
@ReactMethod fun atlasFindSurah(id: Int, text: String, promise: Promise) = withAtlas(id, promise) { it.findSurah(text).map { s -> Marshal.atlasSurah(s) } }
|
|
140
|
+
|
|
141
|
+
// ── names ──
|
|
142
|
+
@ReactMethod fun markName(m: Int, promise: Promise) = ui(promise) { QvpEngine.markName(m) }
|
|
143
|
+
@ReactMethod fun markFromName(s: String, promise: Promise) = ui(promise) { QvpEngine.markFromName(s) }
|
|
144
|
+
override fun getConstants(): Map<String, Any> = mapOf("version" to QvpEngine.version(), "marks" to QVP_MARKS,
|
|
145
|
+
"kinds" to (0..4).map { QvpEngine.kindName(it) }, "families" to (0..7).map { QvpEngine.familyName(it) }, "categories" to (0..8).map { QvpEngine.categoryName(it) })
|
|
146
|
+
|
|
147
|
+
override fun invalidate() { atlases.values.forEach { it.close() }; atlases.clear(); atlasByUri.clear(); super.invalidate() }
|
|
148
|
+
companion object { const val NAME = "QvpModule" }
|
|
149
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
package net.quranpedia.qvp.rn
|
|
2
|
+
|
|
3
|
+
import com.facebook.react.ReactPackage
|
|
4
|
+
import com.facebook.react.bridge.NativeModule
|
|
5
|
+
import com.facebook.react.bridge.ReactApplicationContext
|
|
6
|
+
import com.facebook.react.uimanager.ViewManager
|
|
7
|
+
|
|
8
|
+
class QvpPackage : ReactPackage {
|
|
9
|
+
override fun createNativeModules(ctx: ReactApplicationContext): List<NativeModule> = listOf(QvpModule(ctx))
|
|
10
|
+
override fun createViewManagers(ctx: ReactApplicationContext): List<ViewManager<*, *>> = listOf(QvpPageViewManager())
|
|
11
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
package net.quranpedia.qvp.rn
|
|
2
|
+
|
|
3
|
+
import com.facebook.react.bridge.ReadableArray
|
|
4
|
+
import com.facebook.react.bridge.ReadableMap
|
|
5
|
+
import com.facebook.react.uimanager.SimpleViewManager
|
|
6
|
+
import com.facebook.react.uimanager.ThemedReactContext
|
|
7
|
+
import com.facebook.react.uimanager.annotations.ReactProp
|
|
8
|
+
import net.quranpedia.qvp.QvpColor
|
|
9
|
+
|
|
10
|
+
/** `<QvpPageView />`: props are marshalled onto [QvpRnPageView]; [onAfterUpdateTransaction] commits them in one pass. */
|
|
11
|
+
class QvpPageViewManager : SimpleViewManager<QvpRnPageView>() {
|
|
12
|
+
override fun getName() = NAME
|
|
13
|
+
override fun createViewInstance(c: ThemedReactContext) = QvpRnPageView(c)
|
|
14
|
+
override fun onDropViewInstance(view: QvpRnPageView) { view.destroy(); super.onDropViewInstance(view) }
|
|
15
|
+
override fun onAfterUpdateTransaction(view: QvpRnPageView) { super.onAfterUpdateTransaction(view); view.commit() }
|
|
16
|
+
|
|
17
|
+
override fun getExportedCustomDirectEventTypeConstants(): MutableMap<String, Any> {
|
|
18
|
+
val m = HashMap<String, Any>()
|
|
19
|
+
for (e in EVENTS) m[e] = mapOf("registrationName" to e)
|
|
20
|
+
return m
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
@ReactProp(name = "pageUri") fun setPageUri(v: QvpRnPageView, s: String?) { v.pageUri = s }
|
|
24
|
+
@ReactProp(name = "pageBase64") fun setPageBase64(v: QvpRnPageView, s: String?) { v.pageBase64 = s }
|
|
25
|
+
@ReactProp(name = "wordsUri") fun setWordsUri(v: QvpRnPageView, s: String?) { v.wordsUri = s }
|
|
26
|
+
@ReactProp(name = "padTop", defaultFloat = 0f) fun setPadTop(v: QvpRnPageView, f: Float) = v.setPadTopDp(f)
|
|
27
|
+
@ReactProp(name = "padBottom", defaultFloat = 0f) fun setPadBottom(v: QvpRnPageView, f: Float) = v.setPadBottomDp(f)
|
|
28
|
+
@ReactProp(name = "padSide", defaultFloat = 0f) fun setPadSide(v: QvpRnPageView, f: Float) = v.setPadSideDp(f)
|
|
29
|
+
@ReactProp(name = "lineSpacing", defaultFloat = 1f) fun setLineSpacing(v: QvpRnPageView, f: Float) = v.setLineSpacingProp(f)
|
|
30
|
+
@ReactProp(name = "lineGap", defaultFloat = 0f) fun setLineGap(v: QvpRnPageView, f: Float) = v.setLineGapProp(f)
|
|
31
|
+
@ReactProp(name = "fillHeight", defaultBoolean = false) fun setFillHeight(v: QvpRnPageView, b: Boolean) = v.setFillHeightProp(b)
|
|
32
|
+
@ReactProp(name = "paperColor") fun setPaperColor(v: QvpRnPageView, s: String?) { v.paperColor = if (s.isNullOrBlank()) 0 else QvpColor.argb(QvpColor.parse(s)); v.invalidate() }
|
|
33
|
+
@ReactProp(name = "defaultInk") fun setDefaultInk(v: QvpRnPageView, s: String?) { v.defaultInkProp = s }
|
|
34
|
+
@ReactProp(name = "selectionBand") fun setSelectionBand(v: QvpRnPageView, s: String?) { if (!s.isNullOrBlank()) v.selectionBand = QvpColor.parse(s) }
|
|
35
|
+
@ReactProp(name = "selectionEnabled", defaultBoolean = true) fun setSelectionEnabled(v: QvpRnPageView, b: Boolean) { v.selectionEnabled = b }
|
|
36
|
+
@ReactProp(name = "zoomEnabled", defaultBoolean = true) fun setZoomEnabled(v: QvpRnPageView, b: Boolean) { v.zoomEnabled = b }
|
|
37
|
+
@ReactProp(name = "hitMaxDistance", defaultFloat = 6f) fun setHitMaxDistance(v: QvpRnPageView, f: Float) { v.hitOptions = v.hitOptions.copy(maxDistance = f) }
|
|
38
|
+
@ReactProp(name = "theme") fun setTheme(v: QvpRnPageView, m: ReadableMap?) { v.themeProp = Marshal.plain(m) }
|
|
39
|
+
@ReactProp(name = "styles") fun setStyles(v: QvpRnPageView, a: ReadableArray?) { v.stylesProp = Marshal.plain(a) }
|
|
40
|
+
@ReactProp(name = "highlights") fun setHighlights(v: QvpRnPageView, a: ReadableArray?) { v.highlightsProp = Marshal.plain(a) }
|
|
41
|
+
@ReactProp(name = "mask") fun setMask(v: QvpRnPageView, m: ReadableMap?) { v.maskProp = Marshal.plain(m) }
|
|
42
|
+
@ReactProp(name = "reveal") fun setReveal(v: QvpRnPageView, m: ReadableMap?) { v.revealProp = Marshal.plain(m) }
|
|
43
|
+
|
|
44
|
+
companion object {
|
|
45
|
+
const val NAME = "QvpPageView"
|
|
46
|
+
val EVENTS = listOf("onWordTap", "onDecoTap", "onEmptyTap", "onSelectionChanged", "onPageLoad", "onRevealChanged", "onError")
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
package net.quranpedia.qvp.rn
|
|
2
|
+
|
|
3
|
+
import android.util.Base64
|
|
4
|
+
import android.util.Log
|
|
5
|
+
import com.facebook.react.bridge.Arguments
|
|
6
|
+
import com.facebook.react.bridge.WritableMap
|
|
7
|
+
import com.facebook.react.uimanager.ThemedReactContext
|
|
8
|
+
import com.facebook.react.uimanager.UIManagerHelper
|
|
9
|
+
import com.facebook.react.uimanager.events.Event
|
|
10
|
+
import android.widget.FrameLayout
|
|
11
|
+
import net.quranpedia.qvp.*
|
|
12
|
+
import net.quranpedia.qvp.Target
|
|
13
|
+
import java.io.File
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The React Native host of [QvpPageView]. It owns the loaded [QvpPage], loads bytes from the prop-given
|
|
17
|
+
* URI, and reconciles the declarative props (theme / styles / highlights / mask / reveal) against engine
|
|
18
|
+
* handles so JS never sees a handle. Every decision (hit-test, layout, style resolution, bands, masks)
|
|
19
|
+
* is the engine's; this class only marshals and forwards.
|
|
20
|
+
*/
|
|
21
|
+
class QvpRnPageView(private val ctx: ThemedReactContext) : FrameLayout(ctx) {
|
|
22
|
+
private val density = ctx.resources.displayMetrics.density
|
|
23
|
+
/** The Kotlin library's renderer (final class, so hosted rather than subclassed). */
|
|
24
|
+
val inner = QvpPageView(ctx).also { addView(it, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)) }
|
|
25
|
+
var page: QvpPage?
|
|
26
|
+
get() = inner.page
|
|
27
|
+
set(v) { inner.page = v }
|
|
28
|
+
var paperColor: Int get() = inner.paperColor; set(v) { inner.paperColor = v; inner.invalidate() }
|
|
29
|
+
var selectionBand: Int get() = inner.selectionBand; set(v) { inner.selectionBand = v }
|
|
30
|
+
var selectionEnabled: Boolean get() = inner.selectionEnabled; set(v) { inner.selectionEnabled = v }
|
|
31
|
+
var zoomEnabled: Boolean get() = inner.zoomEnabled; set(v) { inner.zoomEnabled = v }
|
|
32
|
+
var hitOptions: QvpHitOptions get() = inner.hitOptions; set(v) { inner.hitOptions = v }
|
|
33
|
+
|
|
34
|
+
/** React Native sizes this host directly (no measure pass), so size the child by hand. */
|
|
35
|
+
override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
|
|
36
|
+
val w = r - l; val h = b - t
|
|
37
|
+
inner.measure(MeasureSpec.makeMeasureSpec(w, MeasureSpec.EXACTLY), MeasureSpec.makeMeasureSpec(h, MeasureSpec.EXACTLY))
|
|
38
|
+
inner.layout(0, 0, w, h)
|
|
39
|
+
centre()
|
|
40
|
+
}
|
|
41
|
+
/** Fit + centre: the library's resetView fits by height and pins the pan at x = 0; centre the pan horizontally (pan is host state). */
|
|
42
|
+
fun resetView() { inner.resetView(); centre() }
|
|
43
|
+
private fun centre() {
|
|
44
|
+
val l = page?.currentLayout ?: return
|
|
45
|
+
inner.viewOx = ((inner.width - l.contentW * inner.viewScale) / 2f).coerceAtLeast(0f); inner.invalidate()
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// ── desired props (set by the manager) ──
|
|
49
|
+
var pageUri: String? = null
|
|
50
|
+
var pageBase64: String? = null
|
|
51
|
+
var wordsUri: String? = null
|
|
52
|
+
var defaultInkProp: String? = null
|
|
53
|
+
var themeProp: Map<String, Any?>? = null
|
|
54
|
+
var stylesProp: List<Any?>? = null
|
|
55
|
+
var highlightsProp: List<Any?>? = null
|
|
56
|
+
var maskProp: Map<String, Any?>? = null
|
|
57
|
+
var revealProp: Map<String, Any?>? = null
|
|
58
|
+
var layoutDirty = true
|
|
59
|
+
|
|
60
|
+
// ── applied state ──
|
|
61
|
+
private var loadedKey: String? = null
|
|
62
|
+
private var appliedTheme: Map<String, Any?>? = null
|
|
63
|
+
private var themeHandle = 0
|
|
64
|
+
private class Rule(val handle: Int, val entry: Map<*, *>)
|
|
65
|
+
private val styleHandles = LinkedHashMap<String, Rule>()
|
|
66
|
+
private val highlightHandles = LinkedHashMap<String, Rule>()
|
|
67
|
+
private var appliedMask: Map<String, Any?>? = null
|
|
68
|
+
private var appliedReveal: Map<String, Any?>? = null // without "at"
|
|
69
|
+
private var appliedRevealAt: Long? = null
|
|
70
|
+
private var appliedInk: Int? = null
|
|
71
|
+
var revealSteps = 0; private set
|
|
72
|
+
var loadMs = 0.0; private set
|
|
73
|
+
var pageBytes = 0; private set
|
|
74
|
+
|
|
75
|
+
init {
|
|
76
|
+
inner.onWordTap = { w, h -> page?.let { p -> emit("onWordTap", mapOf("word" to Marshal.word(p, w), "hit" to Marshal.hit(p, h))) } }
|
|
77
|
+
inner.onDecoTap = { d, h -> page?.let { p -> emit("onDecoTap", mapOf("deco" to Marshal.deco(d), "hit" to Marshal.hit(p, h))) } }
|
|
78
|
+
inner.onEmptyTap = { emit("onEmptyTap", emptyMap()) }
|
|
79
|
+
inner.onSelectionChanged = { page?.let { p -> emit("onSelectionChanged", Marshal.selection(p)) } }
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
override fun onAttachedToWindow() { super.onAttachedToWindow(); QvpRegistry.register(this) }
|
|
83
|
+
override fun onDetachedFromWindow() { QvpRegistry.unregister(this); super.onDetachedFromWindow() }
|
|
84
|
+
|
|
85
|
+
fun destroy() { QvpRegistry.unregister(this); page?.close(); page = null; loadedKey = null }
|
|
86
|
+
|
|
87
|
+
/** Padding props arrive in dp. */
|
|
88
|
+
fun setPadTopDp(v: Float) { inner.padTop = v * density; layoutDirty = true }
|
|
89
|
+
fun setPadBottomDp(v: Float) { inner.padBottom = v * density; layoutDirty = true }
|
|
90
|
+
fun setPadSideDp(v: Float) { inner.padSide = v * density; layoutDirty = true }
|
|
91
|
+
fun setLineSpacingProp(v: Float) { inner.lineSpacing = v; layoutDirty = true }
|
|
92
|
+
fun setLineGapProp(v: Float) { inner.lineGap = v; layoutDirty = true }
|
|
93
|
+
fun setFillHeightProp(v: Boolean) { inner.fillHeight = v; layoutDirty = true }
|
|
94
|
+
|
|
95
|
+
// ── bytes ──
|
|
96
|
+
private fun readUri(uri: String): ByteArray {
|
|
97
|
+
val u = uri.trim()
|
|
98
|
+
return when {
|
|
99
|
+
u.startsWith("asset://") -> ctx.assets.open(u.removePrefix("asset://")).use { it.readBytes() }
|
|
100
|
+
u.startsWith("asset:") -> ctx.assets.open(u.removePrefix("asset:")).use { it.readBytes() }
|
|
101
|
+
u.startsWith("file://") -> File(u.removePrefix("file://")).readBytes()
|
|
102
|
+
u.startsWith("/") -> File(u).readBytes()
|
|
103
|
+
u.startsWith("base64:") -> Base64.decode(u.removePrefix("base64:"), Base64.DEFAULT)
|
|
104
|
+
else -> ctx.assets.open(u).use { it.readBytes() }
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Called by the manager after every prop batch. */
|
|
109
|
+
fun commit() {
|
|
110
|
+
try {
|
|
111
|
+
ensurePage()
|
|
112
|
+
val p = page
|
|
113
|
+
if (p != null) {
|
|
114
|
+
applyInk(p); applyTheme(p); applyStyles(p); applyHighlights(p); applyMask(p); applyReveal(p)
|
|
115
|
+
}
|
|
116
|
+
if (layoutDirty) { layoutDirty = false; inner.relayout(); resetView() }
|
|
117
|
+
inner.invalidate()
|
|
118
|
+
} catch (e: Exception) {
|
|
119
|
+
Log.e("QvpRn", "commit failed", e)
|
|
120
|
+
emit("onError", mapOf("message" to (e.message ?: e.toString())))
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
private fun ensurePage() {
|
|
125
|
+
val key = pageBase64?.let { "b64:" + it.hashCode() } ?: pageUri
|
|
126
|
+
if (key == loadedKey) return
|
|
127
|
+
// drop everything bound to the old page: handles die with it
|
|
128
|
+
themeHandle = 0; appliedTheme = null; styleHandles.clear(); highlightHandles.clear(); appliedMask = null; appliedReveal = null; appliedRevealAt = null; appliedInk = null; revealSteps = 0
|
|
129
|
+
page?.close(); page = null; loadedKey = key
|
|
130
|
+
if (key == null) return
|
|
131
|
+
val bytes = pageBase64?.let { Base64.decode(it, Base64.DEFAULT) } ?: readUri(pageUri!!)
|
|
132
|
+
val t0 = System.nanoTime()
|
|
133
|
+
val p = QvpPage(bytes); p.buildPaths()
|
|
134
|
+
loadMs = (System.nanoTime() - t0) / 1e6; pageBytes = bytes.size
|
|
135
|
+
wordsUri?.let { runCatching { p.attachWords(readUri(it)) }.onFailure { e -> Log.w("QvpRn", "words sidecar: $e") } }
|
|
136
|
+
page = p; layoutDirty = true
|
|
137
|
+
emit("onPageLoad", Marshal.pageInfo(p) + mapOf("loadMs" to loadMs, "bytes" to pageBytes, "uri" to pageUri))
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
private fun applyInk(p: QvpPage) {
|
|
141
|
+
val ink = Marshal.color(defaultInkProp, 0x231f20ff.toInt())
|
|
142
|
+
if (appliedInk != ink) { appliedInk = ink; p.setDefaultInk(ink) }
|
|
143
|
+
}
|
|
144
|
+
private fun applyTheme(p: QvpPage) {
|
|
145
|
+
if (themeProp == appliedTheme) return
|
|
146
|
+
if (themeHandle != 0) { p.unstyle(themeHandle); themeHandle = 0 }
|
|
147
|
+
appliedTheme = themeProp
|
|
148
|
+
themeProp?.let { themeHandle = p.theme(Marshal.theme(it)) }
|
|
149
|
+
}
|
|
150
|
+
private fun entries(list: List<Any?>?): LinkedHashMap<String, Map<*, *>> {
|
|
151
|
+
val out = LinkedHashMap<String, Map<*, *>>()
|
|
152
|
+
list?.forEach { e -> (e as? Map<*, *>)?.let { m -> (m["id"]?.toString())?.let { out[it] = m } } }
|
|
153
|
+
return out
|
|
154
|
+
}
|
|
155
|
+
private fun applyStyles(p: QvpPage) {
|
|
156
|
+
val want = entries(stylesProp)
|
|
157
|
+
for (id in styleHandles.keys.toList()) if (!want.containsKey(id)) { p.unstyle(styleHandles.remove(id)!!.handle) }
|
|
158
|
+
for ((id, e) in want) {
|
|
159
|
+
val old = styleHandles[id]
|
|
160
|
+
if (old != null && old.entry == e) continue
|
|
161
|
+
val sameShape = old != null && old.entry["selector"] == e["selector"] && old.entry["target"] == e["target"] && old.entry["layer"] == e["layer"] && old.entry["hide"] == e["hide"] && e["hide"] != true
|
|
162
|
+
if (sameShape) { p.restyle(old!!.handle, Marshal.color(e["color"], 0), (e["ms"] as? Number)?.toInt() ?: 0); styleHandles[id] = Rule(old.handle, e); continue }
|
|
163
|
+
if (old != null) p.unstyle(old.handle)
|
|
164
|
+
val ms = (e["ms"] as? Number)?.toInt() ?: 0
|
|
165
|
+
val layer = (e["layer"] as? Number)?.toInt() ?: QvpLayer.BASE
|
|
166
|
+
val color = Marshal.color(e["color"], 0)
|
|
167
|
+
val h = when {
|
|
168
|
+
e["hide"] == true -> Marshal.selector(e["selector"])?.let { p.hide(it) } ?: 0
|
|
169
|
+
e["selector"] != null -> Marshal.selector(e["selector"])?.let { p.style(it, color, ms, layer) } ?: 0
|
|
170
|
+
e["target"] != null -> p.styleTarget(Marshal.target(e["target"], p), color, ms, layer)
|
|
171
|
+
else -> 0
|
|
172
|
+
}
|
|
173
|
+
if (h != 0) styleHandles[id] = Rule(h, e) else styleHandles.remove(id)
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
private fun applyHighlights(p: QvpPage) {
|
|
177
|
+
val want = entries(highlightsProp)
|
|
178
|
+
for (id in highlightHandles.keys.toList()) if (!want.containsKey(id)) { p.unhighlight(highlightHandles.remove(id)!!.handle) }
|
|
179
|
+
for ((id, e) in want) {
|
|
180
|
+
val old = highlightHandles[id]
|
|
181
|
+
if (old != null && old.entry == e) continue
|
|
182
|
+
val style = Marshal.highlightStyle(e["style"])
|
|
183
|
+
if (old == null) { val h = p.highlight(Marshal.target(e["target"], p), style); if (h != 0) highlightHandles[id] = Rule(h, e); continue }
|
|
184
|
+
if (old.entry["style"] != e["style"]) p.restyleHighlight(old.handle, style)
|
|
185
|
+
if (old.entry["target"] != e["target"]) p.rehighlight(old.handle, Marshal.target(e["target"], p))
|
|
186
|
+
highlightHandles[id] = Rule(old.handle, e)
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
private fun applyMask(p: QvpPage) {
|
|
190
|
+
if (maskProp == appliedMask) return
|
|
191
|
+
appliedMask = maskProp
|
|
192
|
+
p.unmask()
|
|
193
|
+
val m = maskProp ?: return
|
|
194
|
+
val paper = Marshal.color(m["blockColor"], 0xd9d4c8ff.toInt())
|
|
195
|
+
p.maskOptions(paper, (m["padX"] as? Number)?.toFloat() ?: 0.6f, (m["padY"] as? Number)?.toFloat() ?: 0.6f, (m["radius"] as? Number)?.toFloat() ?: 0.8f, m["reverse"] == true)
|
|
196
|
+
p.mask(Marshal.target(m["target"], p), Marshal.maskMode(m["mode"]))
|
|
197
|
+
}
|
|
198
|
+
private fun applyReveal(p: QvpPage) {
|
|
199
|
+
val r = revealProp
|
|
200
|
+
val cfg = r?.filterKeys { it != "at" }
|
|
201
|
+
if (cfg != appliedReveal) {
|
|
202
|
+
appliedReveal = cfg; appliedRevealAt = null
|
|
203
|
+
if (cfg == null) { p.revealStop(); revealSteps = 0; emit("onRevealChanged", mapOf("steps" to 0, "at" to null)); return }
|
|
204
|
+
revealSteps = p.revealStart((cfg["lit"] as? Number)?.toInt() ?: 1, cfg["byAyah"] == true, Marshal.color(cfg["grey"], 0xc9c4b8ff.toInt()), Marshal.color(cfg["ink"], appliedInk ?: 0x231f20ff.toInt()),
|
|
205
|
+
cfg["ayahMarks"] != false, (cfg["ms"] as? Number)?.toInt() ?: 0)
|
|
206
|
+
emit("onRevealChanged", mapOf("steps" to revealSteps, "at" to -1))
|
|
207
|
+
}
|
|
208
|
+
if (r == null) return
|
|
209
|
+
val at = (r["at"] as? Number)?.toLong() ?: -1L
|
|
210
|
+
if (at != appliedRevealAt) { appliedRevealAt = at; p.revealGoto(at) }
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// ── events ──
|
|
214
|
+
private class QvpEvent(surfaceId: Int, viewTag: Int, private val name: String, private val payload: WritableMap) : Event<QvpEvent>(surfaceId, viewTag) {
|
|
215
|
+
override fun getEventName() = name
|
|
216
|
+
override fun getEventData(): WritableMap = payload
|
|
217
|
+
}
|
|
218
|
+
fun emit(name: String, payload: Map<String, Any?>) {
|
|
219
|
+
if (id <= 0) return
|
|
220
|
+
val dispatcher = UIManagerHelper.getEventDispatcherForReactTag(ctx, id) ?: return
|
|
221
|
+
dispatcher.dispatchEvent(QvpEvent(UIManagerHelper.getSurfaceId(this), id, name, Marshal.toMap(payload)))
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
fun stats(): Map<String, Any?> = mapOf("loadMs" to loadMs, "bytes" to pageBytes, "baseMs" to inner.lastBaseMs, "overlayMs" to inner.lastOverlayMs, "basePaths" to inner.lastBasePaths, "overlayPaths" to inner.lastOverlayPaths,
|
|
225
|
+
"bands" to inner.lastBands, "hitUs" to inner.lastHitUs, "animating" to inner.animating, "styleHandles" to (page?.styleHandles()?.size ?: 0), "highlightHandles" to (page?.highlightHandles()?.size ?: 0),
|
|
226
|
+
"engineVersion" to QvpEngine.version(), "viewScale" to inner.viewScale, "layout" to page?.currentLayout?.let { Marshal.layout(it) })
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** Views by react tag, so the module can find the page behind a `viewTag` without going through the UIManager. */
|
|
230
|
+
object QvpRegistry {
|
|
231
|
+
private val views = HashMap<Int, QvpRnPageView>()
|
|
232
|
+
fun register(v: QvpRnPageView) { if (v.id > 0) views[v.id] = v }
|
|
233
|
+
fun unregister(v: QvpRnPageView) { views.entries.removeAll { it.value === v } }
|
|
234
|
+
fun get(tag: Int): QvpRnPageView? = views[tag]
|
|
235
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@quran.ws/qvp-react-native",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "React Native wrapper for the QVP vector mushaf engine (Android; iOS TODO). Thin marshalling over the Kotlin library, which is JNI over qvp.h.",
|
|
5
|
+
"main": "src/index.tsx",
|
|
6
|
+
"types": "src/index.tsx",
|
|
7
|
+
"react-native": "src/index.tsx",
|
|
8
|
+
"source": "src/index.tsx",
|
|
9
|
+
"files": [
|
|
10
|
+
"src",
|
|
11
|
+
"android/build.gradle",
|
|
12
|
+
"android/src",
|
|
13
|
+
"README.md",
|
|
14
|
+
"package.json"
|
|
15
|
+
],
|
|
16
|
+
"scripts": {
|
|
17
|
+
"typecheck": "tsc --noEmit",
|
|
18
|
+
"build": "tsc -p tsconfig.build.json"
|
|
19
|
+
},
|
|
20
|
+
"keywords": [
|
|
21
|
+
"react-native",
|
|
22
|
+
"quran",
|
|
23
|
+
"mushaf",
|
|
24
|
+
"qvp",
|
|
25
|
+
"vector"
|
|
26
|
+
],
|
|
27
|
+
"license": "MIT",
|
|
28
|
+
"publishConfig": {
|
|
29
|
+
"access": "public"
|
|
30
|
+
},
|
|
31
|
+
"repository": {
|
|
32
|
+
"type": "git",
|
|
33
|
+
"url": "git+https://github.com/quran-ws/quran-engine.git",
|
|
34
|
+
"directory": "packages/react-native/qvp-react-native"
|
|
35
|
+
},
|
|
36
|
+
"peerDependencies": {
|
|
37
|
+
"react": "*",
|
|
38
|
+
"react-native": "*"
|
|
39
|
+
},
|
|
40
|
+
"devDependencies": {
|
|
41
|
+
"@types/react": "^19.1.0",
|
|
42
|
+
"react": "19.2.3",
|
|
43
|
+
"react-native": "0.87.1",
|
|
44
|
+
"typescript": "^5.8.0"
|
|
45
|
+
}
|
|
46
|
+
}
|
package/src/index.tsx
ADDED
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @quran.ws/qvp-react-native — typed JS API over the native module.
|
|
3
|
+
*
|
|
4
|
+
* Names follow docs/API.md. Nothing here decides anything: targets, selectors and colours are marshalled
|
|
5
|
+
* to the Kotlin library (`net.quranpedia.qvp`), which is JNI over crates/qvp-ffi/include/qvp.h.
|
|
6
|
+
* Declarative props (`highlights`, `styles`, `theme`, `mask`, `reveal`) are reconciled against engine
|
|
7
|
+
* handles on the native side, so JS never sees a handle.
|
|
8
|
+
*/
|
|
9
|
+
import React, { forwardRef, useImperativeHandle, useMemo, useRef } from 'react';
|
|
10
|
+
import {
|
|
11
|
+
findNodeHandle,
|
|
12
|
+
NativeModules,
|
|
13
|
+
requireNativeComponent,
|
|
14
|
+
type HostComponent,
|
|
15
|
+
type NativeSyntheticEvent,
|
|
16
|
+
type ViewProps,
|
|
17
|
+
} from 'react-native';
|
|
18
|
+
|
|
19
|
+
// ── constants (mirror qvp.h) ─────────────────────────────────────────────────────────────────
|
|
20
|
+
export const KIND = { BODY: 0, MARK: 1, AYAH_NUMBER: 2, AYAH_MARK_ORNAMENT: 3, HEADER_INK: 4, OTHER: 255 } as const;
|
|
21
|
+
export const FAMILY = { NONE: 0, DIACRITIC: 1, TANWIN: 2, DOTS: 3, WAQF: 4, SIFR: 5, SAJDAH: 6, READING_SIGN: 7 } as const;
|
|
22
|
+
export const CATEGORY = { NONE: 0, HARAKAH: 1, TANWIN: 2, LETTER_DOT: 3, ORTHOGRAPHIC: 4, DABT: 5, WAQF: 6, READING_SIGN: 7, STANDALONE: 8 } as const;
|
|
23
|
+
export const DECO = { AYAH_MARK: 0, SURAH_NAME: 1, BASMALAH: 2, DIVISION_MARK: 3, SAJDAH_MARK: 4 } as const;
|
|
24
|
+
export const LAYER = { BASE: 0, THEME: 10, HIGHLIGHT: 50, SELECTION: 60, TOP: 100 } as const;
|
|
25
|
+
export const DIVISION = { JUZ: 0, HIZB: 1, NISF: 2, RUBU_AL_HIZB: 3 } as const;
|
|
26
|
+
export const MARKS = ['', 'fathah', 'kasrah', 'dammah', 'tanwin_al_fath', 'tanwin_al_kasr', 'tanwin_al_damm', 'shaddah', 'sukun', 'maddah', 'hamzah', 'hamzat_al_wasl', 'omitted_alif', 'small_waw', 'small_yaa', 'small_noon', 'dot', 'two_dots', 'three_dots', 'rounded_zero', 'rectangular_zero', 'waqf_jaiz_mustawi_al_tarafayn', 'waqf_jaiz_waqf_awla', 'waqf_jaiz_wasl_awla', 'waqf_lazim', 'waqf_al_muanaqah', 'saktah', 'small_meem', 'hizb', 'sajdah', 'sajdah_mark', 'sajdah_line', 'seen_al_qiraah', 'tashil', 'ishmam', 'imalah'];
|
|
27
|
+
const KIND_NAMES = ['body', 'mark', 'ayah-number', 'ayah-ornament', 'header-ink'];
|
|
28
|
+
const FAMILY_NAMES = ['none', 'diacritic', 'tanwin', 'dots', 'waqf', 'sifr', 'sajdah', 'reading_sign'];
|
|
29
|
+
const CATEGORY_NAMES = ['none', 'harakah', 'tanwin', 'letter_dot', 'orthographic', 'dabt', 'waqf', 'reading_sign', 'standalone'];
|
|
30
|
+
const DECO_NAMES = ['ayah-mark', 'surah-name', 'basmalah', 'division-mark', 'sajdah-mark', 'page-number', 'running-head'];
|
|
31
|
+
export function markId(name: string | number): number {
|
|
32
|
+
if (typeof name === 'number') return name;
|
|
33
|
+
const i = MARKS.indexOf(name);
|
|
34
|
+
return i < 0 ? 255 : i;
|
|
35
|
+
}
|
|
36
|
+
const named = (v: string | number, names: string[]) => (typeof v === 'number' ? v : Math.max(0, names.indexOf(v)));
|
|
37
|
+
|
|
38
|
+
// ── colours ──────────────────────────────────────────────────────────────────────────────────
|
|
39
|
+
export type Color = string | number;
|
|
40
|
+
/** Any accepted colour ('#rgb' | '#rrggbb' | '#rrggbbaa' | 0xRRGGBBAA) → '#rrggbbaa'. */
|
|
41
|
+
export function css(c: Color | null | undefined): string | undefined {
|
|
42
|
+
if (c == null) return undefined;
|
|
43
|
+
if (typeof c === 'number') return '#' + (c >>> 0).toString(16).padStart(8, '0');
|
|
44
|
+
let h = c.trim().replace(/^#/, '');
|
|
45
|
+
if (h.length === 3) h = h.split('').map(x => x + x).join('');
|
|
46
|
+
if (h.length === 6) h += 'ff';
|
|
47
|
+
return '#' + h.toLowerCase();
|
|
48
|
+
}
|
|
49
|
+
/** Colour with a replaced alpha (0..1). */
|
|
50
|
+
export function rgba(c: Color, alpha: number): string {
|
|
51
|
+
const h = css(c)!;
|
|
52
|
+
return h.slice(0, 7) + Math.round(Math.max(0, Math.min(1, alpha)) * 255).toString(16).padStart(2, '0');
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// ── targets and selectors (same shapes as web/qvp.js T / Sel) ────────────────────────────────
|
|
56
|
+
export interface TargetObj { kind: number; a?: number; b?: number; c?: number; words?: number[] }
|
|
57
|
+
/** 'page' | '2:255' | '2:255:3' | '2:255-257' | 'line:7' | 'surah:2' | word index | [indices] | T.* */
|
|
58
|
+
export type Target = string | number | number[] | TargetObj;
|
|
59
|
+
export const T = {
|
|
60
|
+
page: (): TargetObj => ({ kind: 0 }),
|
|
61
|
+
word: (i: number): TargetObj => ({ kind: 1, a: i }),
|
|
62
|
+
words: (ws: number[]): TargetObj => ({ kind: 2, words: ws }),
|
|
63
|
+
ayah: (s: number, a: number): TargetObj => ({ kind: 3, a: s, b: a }),
|
|
64
|
+
ayahRange: (s: number, a: number, b: number): TargetObj => ({ kind: 4, a: s, b: a, c: b }),
|
|
65
|
+
line: (n: number): TargetObj => ({ kind: 5, a: n }),
|
|
66
|
+
surah: (s: number): TargetObj => ({ kind: 6, a: s }),
|
|
67
|
+
range: (a: number, b: number): TargetObj => ({ kind: 7, a, b }),
|
|
68
|
+
};
|
|
69
|
+
export interface Selector { kind: number; a?: number; b?: number; c?: number; mark?: string }
|
|
70
|
+
export const Sel = {
|
|
71
|
+
page: (): Selector => ({ kind: 0 }),
|
|
72
|
+
path: (p: number): Selector => ({ kind: 1, a: p }),
|
|
73
|
+
wordPath: (w: number, nth: number): Selector => ({ kind: 2, a: w, b: nth }),
|
|
74
|
+
/** nth mark of the word (0-based, marks only) */
|
|
75
|
+
wordMark: (w: number, nth: number): Selector => ({ kind: 3, a: w, b: nth }),
|
|
76
|
+
wordMarkNamed: (w: number, mark: string, nth = 0): Selector => ({ kind: 4, a: w, b: markId(mark), c: nth, mark }),
|
|
77
|
+
wordBody: (w: number): Selector => ({ kind: 5, a: w }),
|
|
78
|
+
wordMarks: (w: number): Selector => ({ kind: 6, a: w }),
|
|
79
|
+
word: (w: number): Selector => ({ kind: 7, a: w }),
|
|
80
|
+
ayah: (s: number, a: number): Selector => ({ kind: 8, a: s, b: a }),
|
|
81
|
+
line: (n: number): Selector => ({ kind: 9, a: n }),
|
|
82
|
+
mark: (m: string | number): Selector => ({ kind: 10, a: markId(m) }),
|
|
83
|
+
category: (c: string | number): Selector => ({ kind: 11, a: named(c, CATEGORY_NAMES) }),
|
|
84
|
+
family: (f: string | number): Selector => ({ kind: 12, a: named(f, FAMILY_NAMES) }),
|
|
85
|
+
kind: (k: string | number): Selector => ({ kind: 13, a: named(k, KIND_NAMES) }),
|
|
86
|
+
deco: (k: string | number): Selector => ({ kind: 14, a: named(k, DECO_NAMES) }),
|
|
87
|
+
decoIdx: (d: number): Selector => ({ kind: 15, a: d }),
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
// ── records ──────────────────────────────────────────────────────────────────────────────────
|
|
91
|
+
export type Form = 'rasmUthmani' | 'rasmImlai' | 'qpc' | 'rasm' | 'search';
|
|
92
|
+
export type HighlightMode = 'ink' | 'band' | 'both';
|
|
93
|
+
export type MaskMode = 'hide' | 'block' | 'blur';
|
|
94
|
+
export type DivisionKind = 'juz' | 'hizb' | 'nisf' | 'rubuAlHizb';
|
|
95
|
+
export interface WordPath { idx: number; kind: number; kindName: string; mark: number; markName: string; nthMark: number; nthInWord: number; category: number; categoryName: string; family: number; familyName: string; line: number }
|
|
96
|
+
export interface Word {
|
|
97
|
+
idx: number; surah: number; ayah: number; word: number; line: number; lineIdx: number; ayahIdx: number;
|
|
98
|
+
x0: number; y0: number; x1: number; y1: number; text: string; firstPath: number; nPaths: number;
|
|
99
|
+
wordKey: string; ayahKey: string; forms: Partial<Record<Form, string>>; label: string; paths: WordPath[];
|
|
100
|
+
}
|
|
101
|
+
export interface Deco { idx: number; kind: number; kindName: string; surah: number; ayah: number; line: number; x0: number; y0: number; x1: number; y1: number; text: string; firstPath: number; nPaths: number }
|
|
102
|
+
export interface Hit { word: number; path: number; deco: number; line: number; distance: number; exact: boolean; wordKey: string | null; ayahKey: string | null }
|
|
103
|
+
export interface Ayah { idx: number; surah: number; ayah: number; fragment: number; fragments: number; flags: number; rubuAlHizb: number; firstWord: number; nWords: number; ayahMarkDeco: number; bbox: number[] }
|
|
104
|
+
export interface Line { idx: number; lineNo: number; isHeader: boolean; firstWord: number; nWords: number; bbox: number[]; bandY0: number; bandY1: number; centre: number }
|
|
105
|
+
export interface SelectionInfo { words: number[]; text: string; citation: string; textWithCitation: string }
|
|
106
|
+
export interface PageInfo { page: number; width: number; height: number; nLines: number; nAyahs: number; nWords: number; nPaths: number; nDecos: number; naturalPitch: number; forms: Form[]; loadMs: number; bytes: number; uri?: string }
|
|
107
|
+
export interface Match { word: number; index: number; loose: boolean; wordKey: string; text: string }
|
|
108
|
+
export interface Surah { number: number; ayahCount: number; hasBanner: boolean; hasBasmalah: boolean; place: string; bannerDeco: number; arabic: string; latin: string; english: string }
|
|
109
|
+
export interface Division { kind: DivisionKind; n: number; surah: number; ayah: number; line: number; ayahIdx: number }
|
|
110
|
+
export interface Marker { deco: number; surah: number; ayah: number; line: number; cx: number; cy: number; r: number; ornamentPath: number; numeralPath: number }
|
|
111
|
+
export interface Rosette { deco: number; surah: number; ayah: number; juz: number; hizb: number; nisf: number; rubuAlHizb: number; rubuAlHizbInHizb: number }
|
|
112
|
+
export interface Sajdah { deco: number; surah: number; ayah: number; signPath: number }
|
|
113
|
+
export interface CropBox { x0: number; y0: number; x1: number; y1: number; nWords: number; ayahMarkDeco: number }
|
|
114
|
+
export interface Layout { scale: number; ox: number; oy: number; contentW: number; contentH: number; pitch: number; lineDy: number[]; slots: number[][] }
|
|
115
|
+
export interface Stats { loadMs: number; bytes: number; baseMs: number; overlayMs: number; basePaths: number; overlayPaths: number; bands: number; hitUs: number; animating: boolean; styleHandles: number; highlightHandles: number; engineVersion: number; viewScale: number; layout: Layout | null }
|
|
116
|
+
export interface AtlasSurah { n: number; number: number; page: number; ayahCount: number; place: string; arabic: string; latin: string; english: string }
|
|
117
|
+
export interface AtlasRubuAlHizb { rubuAlHizb: number; surah: number; ayah: number; page: number; ayahKey: string }
|
|
118
|
+
|
|
119
|
+
export interface HighlightStyle { mode?: HighlightMode; ink?: Color; band?: Color; height?: 'pitch' | 'ink'; padX?: number; padY?: number; radius?: number; seam?: number; ms?: number; layer?: number }
|
|
120
|
+
/** One declarative highlight; the native side keeps id → handle and calls highlight / rehighlight / restyleHighlight / unhighlight on diff. */
|
|
121
|
+
export interface Highlight { id: string; target: Target; style?: HighlightStyle }
|
|
122
|
+
/** One declarative style rule: `selector` (→ page.style) or `target` (→ page.styleTarget); `hide` → page.hide(selector). */
|
|
123
|
+
export interface StyleRule { id: string; selector?: Selector; target?: Target; color?: Color; ms?: number; layer?: number; hide?: boolean }
|
|
124
|
+
export interface Theme { ink?: Color; diacritics?: Color; dots?: Color; waqf?: Color; sifr?: Color; ayahMark?: Color; numeral?: Color; headers?: Color; marks?: Record<string, Color>; ms?: number }
|
|
125
|
+
export interface Mask { target: Target; mode?: MaskMode; blockColor?: Color; padX?: number; padY?: number; radius?: number; reverse?: boolean }
|
|
126
|
+
/** Greyed-page reveal: `revealStart(lit, byAyah, grey, ink, ayahMarks, ms)` then `revealGoto(at)` whenever `at` changes; null → revealStop. */
|
|
127
|
+
export interface Reveal { lit?: number; byAyah?: boolean; grey?: Color; ink?: Color; ayahMarks?: boolean; ms?: number; at?: number }
|
|
128
|
+
|
|
129
|
+
// ── the view ─────────────────────────────────────────────────────────────────────────────────
|
|
130
|
+
export interface QvpPageViewProps extends ViewProps {
|
|
131
|
+
/** 'asset://pages/042.qvp' | 'file:///…' | '/abs/path' | 'base64:…' — the package ships no page data. */
|
|
132
|
+
pageUri?: string;
|
|
133
|
+
pageBase64?: string;
|
|
134
|
+
/** optional NNN.words.json sidecar (derived forms), same URI schemes */
|
|
135
|
+
wordsUri?: string;
|
|
136
|
+
padTop?: number; padBottom?: number; padSide?: number;
|
|
137
|
+
lineSpacing?: number; lineGap?: number; fillHeight?: boolean;
|
|
138
|
+
paperColor?: Color; defaultInk?: Color; selectionBand?: Color;
|
|
139
|
+
selectionEnabled?: boolean; zoomEnabled?: boolean; hitMaxDistance?: number;
|
|
140
|
+
theme?: Theme | null;
|
|
141
|
+
styles?: StyleRule[];
|
|
142
|
+
highlights?: Highlight[];
|
|
143
|
+
mask?: Mask | null;
|
|
144
|
+
reveal?: Reveal | null;
|
|
145
|
+
onWordTap?: (e: { word: Word; hit: Hit }) => void;
|
|
146
|
+
onDecoTap?: (e: { deco: Deco; hit: Hit }) => void;
|
|
147
|
+
onEmptyTap?: () => void;
|
|
148
|
+
onSelectionChanged?: (e: SelectionInfo) => void;
|
|
149
|
+
onPageLoad?: (e: PageInfo) => void;
|
|
150
|
+
onRevealChanged?: (e: { steps: number; at: number | null }) => void;
|
|
151
|
+
onError?: (e: { message: string }) => void;
|
|
152
|
+
}
|
|
153
|
+
type NativeProps = ViewProps & Omit<QvpPageViewProps, 'onWordTap' | 'onDecoTap' | 'onEmptyTap' | 'onSelectionChanged' | 'onPageLoad' | 'onRevealChanged' | 'onError'> & {
|
|
154
|
+
onWordTap?: (e: NativeSyntheticEvent<{ word: Word; hit: Hit }>) => void;
|
|
155
|
+
onDecoTap?: (e: NativeSyntheticEvent<{ deco: Deco; hit: Hit }>) => void;
|
|
156
|
+
onEmptyTap?: (e: NativeSyntheticEvent<{}>) => void;
|
|
157
|
+
onSelectionChanged?: (e: NativeSyntheticEvent<SelectionInfo>) => void;
|
|
158
|
+
onPageLoad?: (e: NativeSyntheticEvent<PageInfo>) => void;
|
|
159
|
+
onRevealChanged?: (e: NativeSyntheticEvent<{ steps: number; at: number | null }>) => void;
|
|
160
|
+
onError?: (e: NativeSyntheticEvent<{ message: string }>) => void;
|
|
161
|
+
};
|
|
162
|
+
const NativeQvpPageView = requireNativeComponent<NativeProps>('QvpPageView') as HostComponent<NativeProps>;
|
|
163
|
+
|
|
164
|
+
const colorKeys = ['ink', 'band', 'diacritics', 'dots', 'waqf', 'sifr', 'ayahMark', 'numeral', 'headers', 'color', 'blockColor', 'grey'];
|
|
165
|
+
function normColors<X>(o: X): X {
|
|
166
|
+
if (o == null || typeof o !== 'object') return o;
|
|
167
|
+
if (Array.isArray(o)) return o.map(normColors) as X;
|
|
168
|
+
const out: any = {};
|
|
169
|
+
for (const [k, v] of Object.entries(o as any)) {
|
|
170
|
+
if (colorKeys.includes(k) && (typeof v === 'string' || typeof v === 'number')) out[k] = css(v);
|
|
171
|
+
else if (k === 'marks' && v && typeof v === 'object') out[k] = Object.fromEntries(Object.entries(v as any).map(([m, c]) => [m, css(c as Color)]));
|
|
172
|
+
else if (k === 'style' || k === 'target' || k === 'selector') out[k] = k === 'style' ? normColors(v) : v;
|
|
173
|
+
else out[k] = v;
|
|
174
|
+
}
|
|
175
|
+
return out;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Imperative handle: the page-bound module API plus the react tag. */
|
|
179
|
+
export type QvpPageViewHandle = PageApi & { tag: () => number };
|
|
180
|
+
|
|
181
|
+
export const QvpPageView = forwardRef<QvpPageViewHandle, QvpPageViewProps>(function QvpPageView(props, ref) {
|
|
182
|
+
const native = useRef<any>(null);
|
|
183
|
+
const { theme, styles, highlights, mask, reveal, paperColor, defaultInk, selectionBand, onWordTap, onDecoTap, onEmptyTap, onSelectionChanged, onPageLoad, onRevealChanged, onError, ...rest } = props;
|
|
184
|
+
const tag = () => findNodeHandle(native.current) ?? -1;
|
|
185
|
+
useImperativeHandle(ref, () => ({ tag, ...bindPage(tag) }), []);
|
|
186
|
+
const nTheme = useMemo(() => (theme ? normColors(theme) : null), [theme]);
|
|
187
|
+
const nStyles = useMemo(() => (styles ? normColors(styles) : []), [styles]);
|
|
188
|
+
const nHighlights = useMemo(() => (highlights ? normColors(highlights) : []), [highlights]);
|
|
189
|
+
const nMask = useMemo(() => (mask ? normColors(mask) : null), [mask]);
|
|
190
|
+
const nReveal = useMemo(() => (reveal ? normColors(reveal) : null), [reveal]);
|
|
191
|
+
return (
|
|
192
|
+
<NativeQvpPageView
|
|
193
|
+
ref={native}
|
|
194
|
+
{...rest}
|
|
195
|
+
paperColor={css(paperColor)}
|
|
196
|
+
defaultInk={css(defaultInk)}
|
|
197
|
+
selectionBand={css(selectionBand)}
|
|
198
|
+
theme={nTheme}
|
|
199
|
+
styles={nStyles}
|
|
200
|
+
highlights={nHighlights}
|
|
201
|
+
mask={nMask}
|
|
202
|
+
reveal={nReveal}
|
|
203
|
+
onWordTap={onWordTap ? e => onWordTap(e.nativeEvent) : undefined}
|
|
204
|
+
onDecoTap={onDecoTap ? e => onDecoTap(e.nativeEvent) : undefined}
|
|
205
|
+
onEmptyTap={onEmptyTap ? () => onEmptyTap() : undefined}
|
|
206
|
+
onSelectionChanged={onSelectionChanged ? e => onSelectionChanged(e.nativeEvent) : undefined}
|
|
207
|
+
onPageLoad={onPageLoad ? e => onPageLoad(e.nativeEvent) : undefined}
|
|
208
|
+
onRevealChanged={onRevealChanged ? e => onRevealChanged(e.nativeEvent) : undefined}
|
|
209
|
+
onError={onError ? e => onError(e.nativeEvent) : undefined}
|
|
210
|
+
/>
|
|
211
|
+
);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
// ── the module ───────────────────────────────────────────────────────────────────────────────
|
|
215
|
+
const M = NativeModules.QvpModule as any;
|
|
216
|
+
if (!M) throw new Error('@quran.ws/qvp-react-native: native module QvpModule not linked (Android only for now; see README)');
|
|
217
|
+
|
|
218
|
+
export interface SearchOptions { form?: Form; mode?: 'includes' | 'exact' | 'prefix'; normalize?: boolean; loose?: boolean; limit?: number }
|
|
219
|
+
export interface TextOptions { form?: Form; wordSep?: string; lineSep?: string }
|
|
220
|
+
export interface CropOptions { pad?: number; keepAyahMarks?: boolean; background?: Color }
|
|
221
|
+
export interface HitOptions { maxDistance?: number; gapBias?: number; exactFirst?: boolean }
|
|
222
|
+
|
|
223
|
+
/** Page-bound queries; `tag` is the react tag of a mounted `<QvpPageView />` (`findNodeHandle`). */
|
|
224
|
+
export const Qvp = {
|
|
225
|
+
info: (tag: number): Promise<PageInfo> => M.info(tag),
|
|
226
|
+
words: (tag: number): Promise<Word[]> => M.words(tag),
|
|
227
|
+
word: (tag: number, idx: number): Promise<Word | null> => M.word(tag, idx),
|
|
228
|
+
ayahs: (tag: number): Promise<Ayah[]> => M.ayahs(tag),
|
|
229
|
+
lines: (tag: number): Promise<Line[]> => M.lines(tag),
|
|
230
|
+
decos: (tag: number): Promise<Deco[]> => M.decos(tag),
|
|
231
|
+
findWord: (tag: number, s: number, a: number, w: number): Promise<number> => M.findWord(tag, s, a, w),
|
|
232
|
+
resolve: (tag: number, target: Target): Promise<number[]> => M.resolve(tag, target),
|
|
233
|
+
wordForm: (tag: number, idx: number, form: Form = 'rasmUthmani'): Promise<string> => M.wordForm(tag, idx, form),
|
|
234
|
+
hasForm: (tag: number, form: Form): Promise<boolean> => M.hasForm(tag, form),
|
|
235
|
+
attachWords: (tag: number, json: string | object): Promise<number> => M.attachWords(tag, typeof json === 'string' ? json : JSON.stringify(json)),
|
|
236
|
+
surahs: (tag: number): Promise<Surah[]> => M.surahs(tag),
|
|
237
|
+
divisions: (tag: number): Promise<Division[]> => M.divisions(tag),
|
|
238
|
+
ayahMarks: (tag: number): Promise<Marker[]> => M.ayahMarks(tag),
|
|
239
|
+
rosettes: (tag: number): Promise<Rosette[]> => M.rosettes(tag),
|
|
240
|
+
sajdahs: (tag: number): Promise<Sajdah[]> => M.sajdahs(tag),
|
|
241
|
+
ayahKeys: (tag: number): Promise<{ surah: number; ayah: number; ayahKey: string }[]> => M.ayahKeys(tag),
|
|
242
|
+
ayahWordCount: (tag: number, s: number, a: number): Promise<{ count: number; complete: boolean }> => M.ayahWordCount(tag, s, a),
|
|
243
|
+
reciteMap: (tag: number, s: number, a: number, nSegments: number): Promise<number[] | null> => M.reciteMap(tag, s, a, nSegments),
|
|
244
|
+
wordLabel: (tag: number, i: number): Promise<string> => M.wordLabel(tag, i),
|
|
245
|
+
ayahLabel: (tag: number, i: number): Promise<string> => M.ayahLabel(tag, i),
|
|
246
|
+
text: (tag: number, target: Target = 'page', opts?: TextOptions): Promise<string> => M.text(tag, target, opts ?? null),
|
|
247
|
+
search: (tag: number, query: string, opts?: SearchOptions): Promise<Match[]> => M.search(tag, query, opts ?? null),
|
|
248
|
+
citation: (tag: number, words: number[]): Promise<string> => M.citation(tag, words),
|
|
249
|
+
hitTestViewEx: (tag: number, x: number, y: number, opts?: HitOptions): Promise<Hit | null> => M.hitTestViewEx(tag, x, y, opts ?? null),
|
|
250
|
+
wordBoxView: (tag: number, i: number): Promise<{ x0: number; y0: number; x1: number; y1: number } | null> => M.wordBoxView(tag, i),
|
|
251
|
+
currentLayout: (tag: number): Promise<Layout | null> => M.currentLayout(tag),
|
|
252
|
+
relayout: (tag: number): Promise<void> => M.relayout(tag),
|
|
253
|
+
resetView: (tag: number): Promise<void> => M.resetView(tag),
|
|
254
|
+
stats: (tag: number): Promise<Stats | null> => M.stats(tag),
|
|
255
|
+
select: (tag: number, anchor: number, focus: number = anchor): Promise<SelectionInfo> => M.select(tag, anchor, focus),
|
|
256
|
+
clearSelection: (tag: number): Promise<void> => M.clearSelection(tag),
|
|
257
|
+
selection: (tag: number): Promise<SelectionInfo> => M.selection(tag),
|
|
258
|
+
selectionText: (tag: number, form: Form = 'rasmUthmani', citation = false): Promise<string> => M.selectionText(tag, form, citation),
|
|
259
|
+
revealNext: (tag: number, n = 1): Promise<number> => M.revealNext(tag, n),
|
|
260
|
+
hideBack: (tag: number, n = 1): Promise<number> => M.hideBack(tag, n),
|
|
261
|
+
revealWord: (tag: number, i: number): Promise<boolean> => M.revealWord(tag, i),
|
|
262
|
+
hideWord: (tag: number, i: number): Promise<boolean> => M.hideWord(tag, i),
|
|
263
|
+
revealAll: (tag: number): Promise<void> => M.revealAll(tag),
|
|
264
|
+
hideAll: (tag: number): Promise<void> => M.hideAll(tag),
|
|
265
|
+
maskHidden: (tag: number): Promise<number[]> => M.maskHidden(tag),
|
|
266
|
+
maskWords: (tag: number): Promise<number[]> => M.maskWords(tag),
|
|
267
|
+
revealSteps: (tag: number): Promise<number> => M.revealSteps(tag),
|
|
268
|
+
revealAt: (tag: number): Promise<number | null> => M.revealAt(tag),
|
|
269
|
+
revealStepOf: (tag: number, i: number): Promise<number> => M.revealStepOf(tag, i),
|
|
270
|
+
cropBox: (tag: number, target: Target, opts?: CropOptions): Promise<CropBox | null> => M.cropBox(tag, target, opts ?? null),
|
|
271
|
+
cropSvg: (tag: number, target: Target, opts?: CropOptions): Promise<string | null> => M.cropSvg(tag, target, opts ? { ...opts, background: css(opts.background) } : null),
|
|
272
|
+
|
|
273
|
+
// engine-wide
|
|
274
|
+
strip: (s: string): Promise<string> => M.arabic('strip', s),
|
|
275
|
+
fold: (s: string): Promise<string> => M.arabic('fold', s),
|
|
276
|
+
normalize: (s: string): Promise<string> => M.arabic('normalize', s),
|
|
277
|
+
looseKey: (s: string): Promise<string> => M.arabic('loose', s),
|
|
278
|
+
arabic: (kind: 'strip' | 'fold' | 'normalize' | 'loose', s: string): Promise<string> => M.arabic(kind, s),
|
|
279
|
+
gapToFill: (pageW: number, pageH: number, lines: number, viewW: number, viewH: number, max = 0): Promise<number> => M.gapToFill(pageW, pageH, lines, viewW, viewH, max),
|
|
280
|
+
wastedFraction: (pageW: number, pageH: number, viewW: number, viewH: number): Promise<number> => M.wastedFraction(pageW, pageH, viewW, viewH),
|
|
281
|
+
markName: (m: number): string => MARKS[m] ?? 'unknown',
|
|
282
|
+
kindName: (k: number): string => KIND_NAMES[k] ?? 'other',
|
|
283
|
+
categoryName: (c: number): string => CATEGORY_NAMES[c] ?? '',
|
|
284
|
+
familyName: (f: number): string => FAMILY_NAMES[f] ?? '',
|
|
285
|
+
version: (M.getConstants?.().version ?? 0) as number,
|
|
286
|
+
|
|
287
|
+
// atlas
|
|
288
|
+
loadAtlas: (uri: string): Promise<number> => M.loadAtlas(uri),
|
|
289
|
+
freeAtlas: (id: number): Promise<void> => M.freeAtlas(id),
|
|
290
|
+
atlasPageOf: (id: number, s: number, a: number): Promise<number | null> => M.atlasPageOf(id, s, a),
|
|
291
|
+
atlasPageRange: (id: number, page: number): Promise<{ first: { surah: number; ayah: number }; last: { surah: number; ayah: number } } | null> => M.atlasPageRange(id, page),
|
|
292
|
+
atlasPages: (id: number): Promise<number> => M.atlasPages(id),
|
|
293
|
+
atlasSurah: (id: number, n: number): Promise<AtlasSurah | null> => M.atlasSurah(id, n),
|
|
294
|
+
atlasSurahs: (id: number): Promise<AtlasSurah[]> => M.atlasSurahs(id),
|
|
295
|
+
atlasPageOfSurah: (id: number, n: number): Promise<number | null> => M.atlasPageOfSurah(id, n),
|
|
296
|
+
atlasDivision: (id: number, kind: DivisionKind, n: number): Promise<AtlasRubuAlHizb | null> => M.atlasDivision(id, kind, n),
|
|
297
|
+
atlasDivisionAt: (id: number, kind: DivisionKind, s: number, a: number): Promise<number | null> => M.atlasDivisionAt(id, kind, s, a),
|
|
298
|
+
atlasJuzAt: (id: number, s: number, a: number): Promise<number | null> => M.atlasJuzAt(id, s, a),
|
|
299
|
+
atlasPagesOfJuz: (id: number, n: number): Promise<[number, number] | null> => M.atlasPagesOfJuz(id, n),
|
|
300
|
+
atlasFindSurah: (id: number, text: string): Promise<AtlasSurah[]> => M.atlasFindSurah(id, text),
|
|
301
|
+
};
|
|
302
|
+
|
|
303
|
+
/** Cross-page lookup (atlas.qva). `const atlas = await QvpAtlas.load('asset://pages/atlas.qva')`. */
|
|
304
|
+
export class QvpAtlas {
|
|
305
|
+
private constructor(public readonly id: number) {}
|
|
306
|
+
static async load(uri: string): Promise<QvpAtlas> { return new QvpAtlas(await Qvp.loadAtlas(uri)); }
|
|
307
|
+
free() { return Qvp.freeAtlas(this.id); }
|
|
308
|
+
pageOf(s: number, a: number) { return Qvp.atlasPageOf(this.id, s, a); }
|
|
309
|
+
pageRange(page: number) { return Qvp.atlasPageRange(this.id, page); }
|
|
310
|
+
pages() { return Qvp.atlasPages(this.id); }
|
|
311
|
+
surah(n: number) { return Qvp.atlasSurah(this.id, n); }
|
|
312
|
+
surahs() { return Qvp.atlasSurahs(this.id); }
|
|
313
|
+
pageOfSurah(n: number) { return Qvp.atlasPageOfSurah(this.id, n); }
|
|
314
|
+
division(kind: DivisionKind, n: number) { return Qvp.atlasDivision(this.id, kind, n); }
|
|
315
|
+
juz(n: number) { return Qvp.atlasDivision(this.id, 'juz', n); }
|
|
316
|
+
hizb(n: number) { return Qvp.atlasDivision(this.id, 'hizb', n); }
|
|
317
|
+
rubuAlHizb(n: number) { return Qvp.atlasDivision(this.id, 'rubuAlHizb', n); }
|
|
318
|
+
divisionAt(kind: DivisionKind, s: number, a: number) { return Qvp.atlasDivisionAt(this.id, kind, s, a); }
|
|
319
|
+
juzAt(s: number, a: number) { return Qvp.atlasJuzAt(this.id, s, a); }
|
|
320
|
+
pagesOfJuz(n: number) { return Qvp.atlasPagesOfJuz(this.id, n); }
|
|
321
|
+
findSurah(text: string) { return Qvp.atlasFindSurah(this.id, text); }
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
type Tail<F> = F extends (tag: number, ...rest: infer R) => infer Ret ? (...rest: R) => Ret : never;
|
|
325
|
+
const pageMethods = ['info', 'words', 'word', 'ayahs', 'lines', 'decos', 'findWord', 'resolve', 'wordForm', 'hasForm', 'attachWords', 'surahs', 'divisions', 'ayahMarks', 'rosettes', 'sajdahs', 'ayahKeys', 'ayahWordCount', 'reciteMap', 'wordLabel', 'ayahLabel', 'text', 'search', 'citation', 'hitTestViewEx', 'wordBoxView', 'currentLayout', 'relayout', 'resetView', 'stats', 'select', 'clearSelection', 'selection', 'selectionText', 'revealNext', 'hideBack', 'revealWord', 'hideWord', 'revealAll', 'hideAll', 'maskHidden', 'maskWords', 'revealSteps', 'revealAt', 'revealStepOf', 'cropBox', 'cropSvg'] as const;
|
|
326
|
+
type PageMethod = (typeof pageMethods)[number];
|
|
327
|
+
export type PageApi = { [K in PageMethod]: Tail<(typeof Qvp)[K]> };
|
|
328
|
+
function bindPage(tag: () => number): PageApi {
|
|
329
|
+
const out: any = {};
|
|
330
|
+
for (const k of pageMethods) out[k] = (...args: any[]) => (Qvp[k] as any)(tag(), ...args);
|
|
331
|
+
return out;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* `const qvp = useQvp(); <QvpPageView ref={qvp.ref} … />; await qvp.search('الله')`
|
|
336
|
+
* The page-bound API without passing a tag around.
|
|
337
|
+
*/
|
|
338
|
+
export function useQvp(): PageApi & { ref: React.RefObject<QvpPageViewHandle | null>; tag: () => number } {
|
|
339
|
+
const ref = useRef<QvpPageViewHandle | null>(null);
|
|
340
|
+
return useMemo(() => {
|
|
341
|
+
const tag = () => ref.current?.tag() ?? -1;
|
|
342
|
+
return { ref, tag, ...bindPage(tag) };
|
|
343
|
+
}, []);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
export default QvpPageView;
|