@papyrus-sdk/ui-react-native 0.2.35-beta.8 → 0.2.35
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/LICENSE +21 -0
- package/dist/index.d.mts +17 -3
- package/dist/index.d.ts +17 -3
- package/dist/index.js +2423 -1793
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +2033 -1409
- package/dist/index.mjs.map +1 -1
- package/package.json +13 -13
- package/runtime/comicRuntime.test.ts +9 -9
- package/runtime/epubAnnotations.js +121 -0
- package/runtime/epubAnnotations.test.ts +35 -0
- package/runtime/epubReader.js +58 -0
- package/runtime/epubReader.test.ts +74 -0
- package/runtime/epubRuntime.test.ts +48 -0
- package/runtime/epubSelectionMenu.test.ts +39 -0
- package/runtime/index.html +449 -67
- package/runtime/runtime.js +449 -67
- package/runtime/syncComicRuntime.mjs +8 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@papyrus-sdk/ui-react-native",
|
|
3
|
-
"version": "0.2.35
|
|
3
|
+
"version": "0.2.35",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"papyrus",
|
|
@@ -38,17 +38,13 @@
|
|
|
38
38
|
"bugs": {
|
|
39
39
|
"url": "https://github.com/solrachix/Papyrus/issues"
|
|
40
40
|
},
|
|
41
|
-
"scripts": {
|
|
42
|
-
"build": "node runtime/syncComicRuntime.mjs && tsup",
|
|
43
|
-
"check:ink-worklets": "pnpm run build && node ../../scripts/ci/check-native-ink-worklets.cjs"
|
|
44
|
-
},
|
|
45
41
|
"dependencies": {
|
|
46
42
|
"react-native-svg": "^15.5.0"
|
|
47
43
|
},
|
|
48
44
|
"peerDependencies": {
|
|
49
|
-
"@papyrus-sdk/core": "
|
|
50
|
-
"@papyrus-sdk/engine-native": "
|
|
51
|
-
"@papyrus-sdk/types": "
|
|
45
|
+
"@papyrus-sdk/core": "0.2.23",
|
|
46
|
+
"@papyrus-sdk/engine-native": "0.2.22",
|
|
47
|
+
"@papyrus-sdk/types": "0.2.16",
|
|
52
48
|
"@react-native-clipboard/clipboard": "^1.16.3",
|
|
53
49
|
"react": ">=18.2.0 <20",
|
|
54
50
|
"react-native": ">=0.81.0",
|
|
@@ -63,15 +59,19 @@
|
|
|
63
59
|
}
|
|
64
60
|
},
|
|
65
61
|
"devDependencies": {
|
|
66
|
-
"@papyrus-sdk/core": "0.2.23-beta.2",
|
|
67
|
-
"@papyrus-sdk/engine-native": "0.2.22-beta.6",
|
|
68
|
-
"@papyrus-sdk/types": "0.2.16-beta.2",
|
|
69
62
|
"@react-native-clipboard/clipboard": "^1.16.3",
|
|
70
63
|
"@types/react": "19.2.14",
|
|
71
64
|
"react": "^19.1.0",
|
|
72
65
|
"react-native": "^0.81.0",
|
|
73
66
|
"react-native-gesture-handler": "~2.28.0",
|
|
74
67
|
"react-native-safe-area-context": "^5.6.2",
|
|
75
|
-
"react-native-reanimated": "3.19.5"
|
|
68
|
+
"react-native-reanimated": "3.19.5",
|
|
69
|
+
"@papyrus-sdk/core": "0.2.23",
|
|
70
|
+
"@papyrus-sdk/engine-native": "0.2.22",
|
|
71
|
+
"@papyrus-sdk/types": "0.2.16"
|
|
72
|
+
},
|
|
73
|
+
"scripts": {
|
|
74
|
+
"build": "node runtime/syncComicRuntime.mjs && tsup",
|
|
75
|
+
"check:ink-worklets": "pnpm run build && node ../../scripts/ci/check-native-ink-worklets.cjs"
|
|
76
76
|
}
|
|
77
|
-
}
|
|
77
|
+
}
|
|
@@ -188,7 +188,7 @@ describe("mobile comic runtime helpers", () => {
|
|
|
188
188
|
expect(outlineGuard).toBeLessThan(readyEvent);
|
|
189
189
|
});
|
|
190
190
|
|
|
191
|
-
it("
|
|
191
|
+
it("ships the paginated and chapter-scroll EPUB reading policy", () => {
|
|
192
192
|
const runtime = readFileSync(
|
|
193
193
|
resolve(process.cwd(), "packages/ui-react-native/runtime/runtime.js"),
|
|
194
194
|
"utf8"
|
|
@@ -198,18 +198,18 @@ describe("mobile comic runtime helpers", () => {
|
|
|
198
198
|
"utf8"
|
|
199
199
|
);
|
|
200
200
|
|
|
201
|
-
expect(runtime).toContain("manager:
|
|
202
|
-
expect(html).toContain("manager:
|
|
203
|
-
expect(runtime).toContain("
|
|
204
|
-
expect(html).toContain("
|
|
201
|
+
expect(runtime).toContain("manager:'default'");
|
|
202
|
+
expect(html).toContain("manager:'default'");
|
|
203
|
+
expect(runtime).toContain("'scrolled-doc'");
|
|
204
|
+
expect(html).toContain("'scrolled-doc'");
|
|
205
205
|
expect(runtime).toContain("rendition.on('relocated'");
|
|
206
206
|
expect(html).toContain("rendition.on('relocated'");
|
|
207
207
|
expect(runtime).toContain("sendEvent('VIEWER_SCROLL'");
|
|
208
208
|
expect(html).toContain("sendEvent('VIEWER_SCROLL'");
|
|
209
|
-
expect(runtime).toContain("sendEvent('
|
|
210
|
-
expect(html).toContain("sendEvent('
|
|
211
|
-
expect(runtime).
|
|
212
|
-
expect(html).
|
|
209
|
+
expect(runtime).toContain("sendEvent('EPUB_CONTENT_TAP'");
|
|
210
|
+
expect(html).toContain("sendEvent('EPUB_CONTENT_TAP'");
|
|
211
|
+
expect(runtime).toContain("'paginated'");
|
|
212
|
+
expect(html).toContain("'paginated'");
|
|
213
213
|
});
|
|
214
214
|
|
|
215
215
|
it("observes the real EPUB manager queue without changing queue semantics", () => {
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
(function (root, factory) {
|
|
2
|
+
if (typeof module === 'object' && module.exports) module.exports = factory();
|
|
3
|
+
else root.PapyrusEpubAnnotations = factory();
|
|
4
|
+
})(typeof window !== 'undefined' ? window : globalThis, function () {
|
|
5
|
+
function validAnchor(anchor) {
|
|
6
|
+
return anchor && anchor.version === 1 && anchor.kind === 'epub-cfi' &&
|
|
7
|
+
typeof anchor.cfiRange === 'string' && anchor.cfiRange.length <= 4096 && /^epubcfi\([^\n<>]+\)$/.test(anchor.cfiRange) &&
|
|
8
|
+
typeof anchor.href === 'string' && !/^(javascript|data):/i.test(anchor.href) &&
|
|
9
|
+
typeof anchor.quote === 'string' && anchor.quote.length > 0 && anchor.quote.length <= 100000;
|
|
10
|
+
}
|
|
11
|
+
function textNodes(document) {
|
|
12
|
+
var walker = document.createTreeWalker(document.body, 4);
|
|
13
|
+
var nodes = [], text = '', node;
|
|
14
|
+
while ((node = walker.nextNode())) {
|
|
15
|
+
if (node.parentElement.closest('script,style,[data-papyrus-overlay]')) continue;
|
|
16
|
+
nodes.push({node:node, start:text.length}); text += node.textContent;
|
|
17
|
+
}
|
|
18
|
+
return {nodes:nodes,text:text};
|
|
19
|
+
}
|
|
20
|
+
function recoverRange(document, anchor) {
|
|
21
|
+
var source = textNodes(document), match = null, start = source.text.indexOf(anchor.quote);
|
|
22
|
+
while (start >= 0) {
|
|
23
|
+
var end = start + anchor.quote.length;
|
|
24
|
+
if ((!anchor.prefix || source.text.slice(Math.max(0,start-anchor.prefix.length),start) === anchor.prefix) &&
|
|
25
|
+
(!anchor.suffix || source.text.slice(end,end+anchor.suffix.length) === anchor.suffix)) {
|
|
26
|
+
if (match) return null; match = {start:start,end:end};
|
|
27
|
+
}
|
|
28
|
+
start = source.text.indexOf(anchor.quote,start+1);
|
|
29
|
+
}
|
|
30
|
+
if (!match) return null;
|
|
31
|
+
var range = document.createRange();
|
|
32
|
+
var first = source.nodes.find(function(n){return n.start+n.node.length > match.start;});
|
|
33
|
+
var last = source.nodes.find(function(n){return n.start+n.node.length >= match.end;});
|
|
34
|
+
if (!first || !last) return null;
|
|
35
|
+
range.setStart(first.node,match.start-first.start); range.setEnd(last.node,match.end-last.start);
|
|
36
|
+
return range;
|
|
37
|
+
}
|
|
38
|
+
function resolveRange(contents, anchor) {
|
|
39
|
+
if (!validAnchor(anchor)) return null;
|
|
40
|
+
try { var range = contents.range(anchor.cfiRange); if (range && range.toString() === anchor.quote) return range; } catch (_) {}
|
|
41
|
+
return recoverRange(contents.document,anchor);
|
|
42
|
+
}
|
|
43
|
+
function selection(contents, cfiRange, href, documentId) {
|
|
44
|
+
var range;
|
|
45
|
+
try { range = contents.range(cfiRange); } catch (_) { return null; }
|
|
46
|
+
if (!range || !range.toString()) return null;
|
|
47
|
+
var before = contents.document.createRange(); before.selectNodeContents(contents.document.body); before.setEnd(range.startContainer,range.startOffset);
|
|
48
|
+
var after = contents.document.createRange(); after.selectNodeContents(contents.document.body); after.setStart(range.endContainer,range.endOffset);
|
|
49
|
+
var anchor = {version:1,kind:'epub-cfi',cfiRange:cfiRange,href:href,spineIndex:contents.sectionIndex,documentId:documentId,quote:range.toString(),prefix:before.toString().slice(-48),suffix:after.toString().slice(0,48)};
|
|
50
|
+
return validAnchor(anchor) ? anchor : null;
|
|
51
|
+
}
|
|
52
|
+
function install(contents, options) {
|
|
53
|
+
var doc = contents.document, win = contents.window, alive = true, frame = 0;
|
|
54
|
+
var overlay = doc.createElement('div'); overlay.dataset.papyrusOverlay = 'annotations';
|
|
55
|
+
overlay.style.cssText = 'position:absolute;left:0;top:0;width:100%;height:0;pointer-events:none;z-index:3';
|
|
56
|
+
var originalPosition=doc.body.style.position;
|
|
57
|
+
if(win.getComputedStyle(doc.body).position==='static')doc.body.style.position='relative';
|
|
58
|
+
doc.body.appendChild(overlay);
|
|
59
|
+
var registry = new Map(), hitRegions = [];
|
|
60
|
+
function render() {
|
|
61
|
+
frame = 0; if (!alive || !options.isCurrent()) return;
|
|
62
|
+
var keep = new Set(), noteGroups = new Map();
|
|
63
|
+
hitRegions = [];
|
|
64
|
+
options.annotations().forEach(function(annotation) {
|
|
65
|
+
var anchor = annotation.anchor;
|
|
66
|
+
if (!validAnchor(anchor) || anchor.href !== options.href || anchor.documentId && options.documentId && anchor.documentId !== options.documentId) return;
|
|
67
|
+
var range = resolveRange(contents,anchor); if (!range) return;
|
|
68
|
+
var rects = Array.from(range.getClientRects()).filter(function(r){return r.width>0 && r.height>0;});
|
|
69
|
+
if (!rects.length) return;
|
|
70
|
+
keep.add(annotation.id);
|
|
71
|
+
var group = registry.get(annotation.id);
|
|
72
|
+
if (!group) { group = doc.createElement('div'); group.dataset.annotationId = annotation.id; overlay.appendChild(group); registry.set(annotation.id,group); if (!doc.defaultView.matchMedia || !doc.defaultView.matchMedia('(prefers-reduced-motion: reduce)').matches) { if (typeof group.animate === 'function') group.animate([{opacity:0},{opacity:1}],{duration:180,easing:'ease-out'}); } }
|
|
73
|
+
group.replaceChildren();
|
|
74
|
+
hitRegions.push({id:annotation.id,rects:rects});
|
|
75
|
+
var color = /^#[0-9a-f]{6}$/i.test(annotation.color) ? annotation.color : '#FFC928';
|
|
76
|
+
var style = annotation.markupStyle || annotation.type;
|
|
77
|
+
var opacity = Number.isFinite(annotation.opacity) ? Math.max(0,Math.min(1,annotation.opacity)) : 0.35;
|
|
78
|
+
if(style === 'highlight')opacity=Math.min(0.35,opacity);
|
|
79
|
+
rects.forEach(function(rect) {
|
|
80
|
+
var mark = doc.createElement('div');
|
|
81
|
+
var bodyRect=doc.body.getBoundingClientRect();
|
|
82
|
+
var height = style === 'highlight' ? rect.height : 2;
|
|
83
|
+
var y = style === 'strikeout' ? rect.top+rect.height/2 : style === 'underline' ? rect.bottom-2 : rect.top;
|
|
84
|
+
mark.style.cssText = 'position:absolute;pointer-events:none;left:'+(rect.left-bodyRect.left)+'px;top:'+(y-bodyRect.top)+'px;width:'+rect.width+'px;height:'+height+'px;background:'+color+';opacity:'+opacity;
|
|
85
|
+
if (['highlight','underline','strikeout'].includes(style)) group.appendChild(mark);
|
|
86
|
+
});
|
|
87
|
+
var hasNote = annotation.noteContent !== undefined || annotation.type === 'comment' || annotation.type === 'text';
|
|
88
|
+
if (hasNote) {
|
|
89
|
+
var last = rects[rects.length-1], button = doc.createElement('button');
|
|
90
|
+
button.textContent = '●'; button.setAttribute('aria-label',options.labels().comment || '');
|
|
91
|
+
var pageWidth=Math.max(44, options.pageWidth ? options.pageWidth() : win.innerWidth);
|
|
92
|
+
var bodyRect=doc.body.getBoundingClientRect(), column=Math.max(0,Math.floor((last.left-bodyRect.left)/pageWidth));
|
|
93
|
+
button.style.cssText = 'position:absolute;pointer-events:auto;border:0;background:transparent;color:'+color+';width:44px;height:44px;left:'+((column+1)*pageWidth-44)+'px;top:'+(Math.floor(last.top/44)*44-doc.body.getBoundingClientRect().top)+'px';
|
|
94
|
+
var key = column+":"+Math.floor(last.top/44);
|
|
95
|
+
var neighbors=noteGroups.get(key);
|
|
96
|
+
if(!neighbors){neighbors={button:button,ids:[]};noteGroups.set(key,neighbors);group.appendChild(button);}
|
|
97
|
+
neighbors.ids.push(annotation.id);
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
noteGroups.forEach(function(group){
|
|
101
|
+
group.button.textContent=group.ids.length>1?String(group.ids.length):'●';
|
|
102
|
+
group.button.addEventListener('click',function(e){e.stopPropagation();if(options.isCurrent())options.onTap(group.ids[0],group.ids);});
|
|
103
|
+
});
|
|
104
|
+
registry.forEach(function(group,id){if (!keep.has(id)) {group.remove();registry.delete(id);}});
|
|
105
|
+
}
|
|
106
|
+
function schedule() { if (alive && !frame) frame = win.requestAnimationFrame(render); }
|
|
107
|
+
function tapMarkup(event) {
|
|
108
|
+
if(!alive || !options.isCurrent() || String(win.getSelection() || '').length || event.target.closest('[data-papyrus-overlay]'))return;
|
|
109
|
+
var hit=hitRegions.find(function(item){return item.rects.some(function(r){return event.clientX>=r.left && event.clientX<=r.right && event.clientY>=r.top && event.clientY<=r.bottom;});});
|
|
110
|
+
if(hit) {event.stopPropagation();options.onTap(hit.id);}
|
|
111
|
+
}
|
|
112
|
+
doc.addEventListener('click',tapMarkup);
|
|
113
|
+
var observer = typeof win.ResizeObserver === 'function' ? new win.ResizeObserver(schedule) : null;
|
|
114
|
+
if (observer) observer.observe(doc.body);
|
|
115
|
+
win.addEventListener('resize',schedule);win.addEventListener('scroll',schedule); doc.addEventListener('load',schedule,true);
|
|
116
|
+
if (doc.fonts && doc.fonts.ready) doc.fonts.ready.then(schedule);
|
|
117
|
+
schedule();
|
|
118
|
+
return {schedule:schedule, hit:function(x,y){return hitRegions.some(function(item){return item.rects.some(function(r){return x>=r.left && x<=r.right && y>=r.top && y<=r.bottom;});});}, range:function(anchor){return resolveRange(contents,anchor);}, destroy:function(){alive=false;if(frame)win.cancelAnimationFrame(frame);if(observer)observer.disconnect();win.removeEventListener('resize',schedule);win.removeEventListener('scroll',schedule);doc.removeEventListener('load',schedule,true);doc.removeEventListener('click',tapMarkup);overlay.remove();doc.body.style.position=originalPosition;registry.clear();}};
|
|
119
|
+
}
|
|
120
|
+
return {validAnchor:validAnchor,recoverRange:recoverRange,resolveRange:resolveRange,selection:selection,install:install};
|
|
121
|
+
});
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import {createRequire} from 'node:module';
|
|
2
|
+
import {describe,it,expect} from 'vitest';
|
|
3
|
+
const annotations = createRequire(import.meta.url)('./epubAnnotations.js');
|
|
4
|
+
const anchor = {version:1,kind:'epub-cfi',cfiRange:'epubcfi(/6/2!/4/2:0)',href:'chapter.xhtml',quote:'word'};
|
|
5
|
+
describe('EPUB contextual anchors',()=>{
|
|
6
|
+
it('rejects malformed CFI and unsafe hrefs',()=>{expect(annotations.validAnchor(anchor)).toBe(true);expect(annotations.validAnchor({...anchor,cfiRange:'bad'})).toBe(false);expect(annotations.validAnchor({...anchor,href:'javascript:alert(1)'})).toBe(false);});
|
|
7
|
+
it('recovers uniquely in chapter text preserving UTF16 and excludes overlays',()=>{document.body.innerHTML='<p>A 😀 word end</p><div data-papyrus-overlay>word</div>';expect(annotations.recoverRange(document,anchor)?.toString()).toBe('word');});
|
|
8
|
+
it('leaves ambiguous quotes unresolved unless context distinguishes them',()=>{document.body.innerHTML='<p>one word two word end</p>';expect(annotations.recoverRange(document,anchor)).toBeNull();expect(annotations.recoverRange(document,{...anchor,prefix:'two '})?.toString()).toBe('word');});
|
|
9
|
+
it('verifies exact CFI quote before controlled recovery',()=>{document.body.innerHTML='<p>other word</p>';const bad=document.createRange();bad.selectNodeContents(document.body);expect(annotations.resolveRange({document,range:()=>bad},anchor)?.toString()).toBe('word');});
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it('renders true strikeout, grouped notes and reconciles 120 stable IDs after reflow',()=>{
|
|
13
|
+
document.body.innerHTML='<p>word</p>';
|
|
14
|
+
const range=document.createRange();range.selectNodeContents(document.querySelector('p')!);
|
|
15
|
+
let top=20;Object.defineProperty(range,'getClientRects',{value:()=>[{left:10,right:50,top,bottom:top+20,width:40,height:20}]});
|
|
16
|
+
let queue:FrameRequestCallback[]=[];
|
|
17
|
+
const originalRAF=window.requestAnimationFrame;window.requestAnimationFrame=cb=>{queue.push(cb);return queue.length;};
|
|
18
|
+
let snapshot=Array.from({length:120},(_,i)=>({id:`n${i}`,anchor,color:'#ff0000',opacity:1,markupStyle:'strikeout',noteContent:`note ${i}`}));
|
|
19
|
+
const taps:string[]=[];
|
|
20
|
+
const renderer=annotations.install({document,window,range:()=>range},{href:'chapter.xhtml',documentId:undefined,pageWidth:()=>1024,annotations:()=>snapshot,labels:()=>({comment:'Nota'}),isCurrent:()=>true,onTap:(id:string)=>taps.push(id)});
|
|
21
|
+
const flush=()=>{const tasks=queue;queue=[];tasks.forEach(cb=>cb(0));};flush();
|
|
22
|
+
expect(document.querySelectorAll('[data-annotation-id]')).toHaveLength(120);
|
|
23
|
+
expect(document.querySelectorAll('[data-papyrus-overlay] button')).toHaveLength(1);
|
|
24
|
+
const group=document.querySelector('[data-annotation-id="n0"]')!;
|
|
25
|
+
expect((group.firstChild as HTMLElement).style.top).toBe('30px');
|
|
26
|
+
expect((group.lastChild as HTMLElement).style.left).toBe('980px');
|
|
27
|
+
snapshot=snapshot.map(item=>({...item,markupStyle:'highlight'}));renderer.schedule();flush();
|
|
28
|
+
expect(Number((group.firstChild as HTMLElement).style.opacity)).toBeLessThanOrEqual(0.35);
|
|
29
|
+
snapshot=snapshot.map(item=>({...item,markupStyle:'strikeout'}));renderer.schedule();flush();
|
|
30
|
+
expect((group.lastChild as HTMLElement).textContent).toBe('120');
|
|
31
|
+
(group.lastChild as HTMLElement).click();expect(taps).toEqual(['n0']);
|
|
32
|
+
top=80;renderer.schedule();flush();expect(document.querySelector('[data-annotation-id="n0"]')).toBe(group);expect((group.firstChild as HTMLElement).style.top).toBe('90px');
|
|
33
|
+
snapshot=[];renderer.schedule();flush();expect(document.querySelectorAll('[data-annotation-id]')).toHaveLength(0);
|
|
34
|
+
renderer.destroy();expect(document.querySelector('[data-papyrus-overlay]')).toBeNull();window.requestAnimationFrame=originalRAF;
|
|
35
|
+
});
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
(function(root){
|
|
2
|
+
function mode(value){return value==='chapter-scroll'?'chapter-scroll':'paged';}
|
|
3
|
+
function validCfi(value){return typeof value==='string' && value.length<=4096 && /^epubcfi\([^\n<>]+\)$/.test(value);}
|
|
4
|
+
function options(value){return {manager:'default',flow:mode(value)==='paged'?'paginated':'scrolled-doc',spread:'none',width:'100%',height:'100%',allowScriptedContent:false};}
|
|
5
|
+
function gesture(start,end,state){
|
|
6
|
+
if(!start || !end || state.selected || state.interactive || state.multitouch)return null;
|
|
7
|
+
var dx=end.x-start.x,dy=end.y-start.y,elapsed=end.time-start.time;
|
|
8
|
+
if(state.mode==='paged' && Math.abs(dx)>=48 && Math.abs(dx)>Math.abs(dy)*1.5 && elapsed>=0 && elapsed<1500)return (dx<0)!==Boolean(state.rtl)?'next':'prev';
|
|
9
|
+
if(elapsed>=0 && elapsed<450 && Math.hypot(dx,dy)<=12)return 'tap';
|
|
10
|
+
return null;
|
|
11
|
+
}
|
|
12
|
+
function location(raw,count,readingMode,geometry){
|
|
13
|
+
var start=raw && raw.start;if(!start || !validCfi(start.cfi))return null;
|
|
14
|
+
var displayed=start.displayed||{},chapter=Math.max(1,(Number(start.index)||0)+1);
|
|
15
|
+
var visualPage=mode(readingMode)==='paged' && Number.isFinite(displayed.page)?displayed.page:null;
|
|
16
|
+
var total=mode(readingMode)==='paged' && Number.isFinite(displayed.total)?displayed.total:null;
|
|
17
|
+
var atStart=Boolean(raw.atStart),atEnd=Boolean(raw.atEnd);
|
|
18
|
+
// Chromium may round a column offset down by a fraction of a CSS pixel.
|
|
19
|
+
// Keep epub.js authoritative outside a 1px boundary, and for RTL layouts.
|
|
20
|
+
if(visualPage!==null && total>0 && geometry && geometry.direction==='ltr' && geometry.pageWidth>0 && Number.isFinite(geometry.offset)){
|
|
21
|
+
var boundary=Math.round(geometry.offset/geometry.pageWidth);
|
|
22
|
+
if(Math.abs(geometry.offset-boundary*geometry.pageWidth)<=1){
|
|
23
|
+
visualPage=Math.min(total,Math.max(1,boundary+1));
|
|
24
|
+
atStart=chapter===(geometry.firstIndex ?? 0)+1 && visualPage===1;
|
|
25
|
+
atEnd=chapter===(geometry.lastIndex ?? count-1)+1 && visualPage===total;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
var fraction=total?Math.max(0,(visualPage-1)/total):0;
|
|
29
|
+
return {version:1,cfi:start.cfi,href:start.href||'',chapter:chapter,chapterCount:count,visualPage:visualPage,visualPageCount:total,progress:Math.min(1,Math.max(0,(chapter-1+fraction)/Math.max(1,count))),mode:mode(readingMode),atStart:atStart,atEnd:atEnd};
|
|
30
|
+
}
|
|
31
|
+
function installResizeAnchor(manager,readCfi){
|
|
32
|
+
var original=manager.resize,anchor=null;
|
|
33
|
+
function resize(width,height,target){
|
|
34
|
+
if(validCfi(target))anchor=target;
|
|
35
|
+
if(!anchor){var current=readCfi();if(validCfi(current))anchor=current;}
|
|
36
|
+
// epub.js accepts a logical target as its third argument. Keep it across
|
|
37
|
+
// keyboard/dialog reflows instead of adopting each new viewport start.
|
|
38
|
+
return original.call(this,width,height,anchor || target);
|
|
39
|
+
}
|
|
40
|
+
manager.resize=resize;
|
|
41
|
+
return {cfi:function(){return anchor;},invalidate:function(){anchor=null;},destroy:function(){if(manager.resize===resize)manager.resize=original;anchor=null;}};
|
|
42
|
+
}
|
|
43
|
+
function install(contents,config){
|
|
44
|
+
var doc=contents.document,win=contents.window,start=null,blocked=false;
|
|
45
|
+
function point(e){var t=e.changedTouches&&e.changedTouches[0]||e.touches&&e.touches[0];return t?{x:t.clientX,y:t.clientY,time:Date.now()}:null;}
|
|
46
|
+
function selected(){return Boolean(String(win.getSelection()||'').trim()) || config.blocked();}
|
|
47
|
+
function interactive(target){return Boolean(target && target.closest && target.closest('a,button,input,textarea,select,[contenteditable="true"],[data-papyrus-overlay]'));}
|
|
48
|
+
function begin(e){start=point(e);blocked=e.touches.length!==1 || selected() || interactive(e.target);}
|
|
49
|
+
function invalidateScroll(finish){if(config.current() && start && finish && config.mode()==='chapter-scroll' && !blocked && !selected() && Math.abs(finish.y-start.y)>12 && Math.abs(finish.y-start.y)>Math.abs(finish.x-start.x) && config.interact)config.interact();}
|
|
50
|
+
function move(e){if(e.touches.length!==1)blocked=true;invalidateScroll(point(e));}
|
|
51
|
+
function end(e){var finish=point(e);invalidateScroll(finish);var action=gesture(start,finish,{mode:config.mode(),rtl:config.rtl(),selected:selected(),interactive:blocked||interactive(e.target)||Boolean(config.annotationHit && config.annotationHit(point(e))),multitouch:blocked});start=null;if(!config.current())return;if(action==='tap')config.tap();else if(action)config.turn(action==='next'?1:-1);}
|
|
52
|
+
function cancel(){start=null;blocked=true;}
|
|
53
|
+
function link(e){if(config.current() && e.target?.closest?.('a') && config.interact)config.interact();}
|
|
54
|
+
doc.addEventListener('touchstart',begin,{passive:true});doc.addEventListener('touchmove',move,{passive:true});doc.addEventListener('touchend',end,{passive:true});doc.addEventListener('touchcancel',cancel,{passive:true});doc.addEventListener('click',link);
|
|
55
|
+
return function(){doc.removeEventListener('touchstart',begin);doc.removeEventListener('touchmove',move);doc.removeEventListener('touchend',end);doc.removeEventListener('touchcancel',cancel);doc.removeEventListener('click',link);};
|
|
56
|
+
}
|
|
57
|
+
root.PapyrusEpubReader={mode:mode,validCfi:validCfi,options:options,gesture:gesture,location:location,installResizeAnchor:installResizeAnchor,install:install};
|
|
58
|
+
})(typeof window==='undefined'?globalThis:window);
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import {readFileSync} from 'node:fs';
|
|
2
|
+
import {JSDOM} from 'jsdom';
|
|
3
|
+
import {describe,it,expect} from 'vitest';
|
|
4
|
+
const load=()=>{const d=new JSDOM('',{runScripts:'outside-only'});d.window.eval(readFileSync('packages/ui-react-native/runtime/epubReader.js','utf8'));return (d.window as any).PapyrusEpubReader;};
|
|
5
|
+
describe('EPUB reading policy',()=>{
|
|
6
|
+
it('defaults to real paginated default manager and one visual page',()=>{const p=load();expect(p.options('paged')).toMatchObject({manager:'default',flow:'paginated',spread:'none'});expect(p.mode()).toBe('paged');expect(p.options('chapter-scroll').flow).toBe('scrolled-doc');});
|
|
7
|
+
it('recognizes one swipe with RTL reversal, excluding selection/interactivity/multitouch',()=>{const p=load(),start={x:200,y:100,time:0},end={x:40,y:108,time:200};expect(p.gesture(start,end,{mode:'paged'})).toBe('next');expect(p.gesture(start,end,{mode:'paged',rtl:true})).toBe('prev');for(const k of ['selected','interactive','multitouch'])expect(p.gesture(start,end,{mode:'paged',[k]:true})).toBe(null);expect(p.gesture(start,{x:202,y:103,time:600},{mode:'paged'})).toBe(null);expect(p.gesture(start,{x:202,y:103,time:100},{mode:'paged'})).toBe('tap');});
|
|
8
|
+
it('separates chapter and visual page and never treats spine count as pages',()=>{const p=load();expect(p.location({start:{index:2,cfi:'epubcfi(/6/6!/4/2)',displayed:{page:4,total:12}}},10,'paged')).toMatchObject({chapter:3,chapterCount:10,visualPage:4,visualPageCount:12,cfi:'epubcfi(/6/6!/4/2)'});expect(p.location({start:{index:2,cfi:'epubcfi(/6/6!/4/2)'}},10,'chapter-scroll').visualPage).toBe(null);});
|
|
9
|
+
it('rejects invalid location CFIs',()=>{expect(load().validCfi('javascript:bad')).toBe(false);expect(load().validCfi('epubcfi(/6/4!/4/2)')).toBe(true);});
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
describe('EPUB iframe gestures', () => {
|
|
13
|
+
it('cleans listeners and excludes annotations and links from tap', () => {
|
|
14
|
+
const d=new JSDOM('<p>text</p><a href="#">link</a>',{runScripts:'outside-only'});
|
|
15
|
+
d.window.eval(readFileSync('packages/ui-react-native/runtime/epubReader.js','utf8'));
|
|
16
|
+
const p=(d.window as any).PapyrusEpubReader;
|
|
17
|
+
let taps=0,hit=true;
|
|
18
|
+
const cleanup=p.install({document:d.window.document,window:d.window},{mode:()=> 'paged',rtl:()=>false,blocked:()=>false,current:()=>true,annotationHit:()=>hit,tap:()=>taps++,turn:()=>{}});
|
|
19
|
+
function touch(target:Element){for(const type of ['touchstart','touchend']){const e=new d.window.Event(type,{bubbles:true});Object.defineProperties(e,{touches:{value:type==='touchend'?[]:[{clientX:30,clientY:40}]},changedTouches:{value:[{clientX:30,clientY:40}]}});target.dispatchEvent(e);}}
|
|
20
|
+
touch(d.window.document.querySelector('p')!);expect(taps).toBe(0);hit=false;touch(d.window.document.querySelector('a')!);expect(taps).toBe(0);touch(d.window.document.querySelector('p')!);expect(taps).toBe(1);cleanup();touch(d.window.document.querySelector('p')!);expect(taps).toBe(1);
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
describe('EPUB subpixel column boundaries',()=>{
|
|
25
|
+
it('normalizes only a nearby LTR boundary and updates start/end',()=>{
|
|
26
|
+
const p=load(),raw={start:{index:0,cfi:'epubcfi(/6/2!/4/12)',displayed:{page:1,total:17}},atStart:true,atEnd:false};
|
|
27
|
+
expect(p.location(raw,3,'paged',{offset:411.4285888671875,pageWidth:412,direction:'ltr'})).toMatchObject({visualPage:2,atStart:false});
|
|
28
|
+
expect(p.location({...raw,start:{...raw.start,index:1}},4,'paged',{offset:0,pageWidth:412,direction:'ltr',firstIndex:1,lastIndex:2})).toMatchObject({atStart:true});
|
|
29
|
+
expect(p.location(raw,3,'paged',{offset:400,pageWidth:412,direction:'ltr'}).visualPage).toBe(1);
|
|
30
|
+
expect(p.location(raw,3,'paged',{offset:411.5,pageWidth:412,direction:'rtl'}).visualPage).toBe(1);
|
|
31
|
+
expect(p.location({...raw,start:{...raw.start,index:2,displayed:{page:16,total:17}}},3,'paged',{offset:6591.5,pageWidth:412,direction:'ltr'})).toMatchObject({visualPage:17,atEnd:true});
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
describe('EPUB resize anchors',()=>{
|
|
36
|
+
it('preserves a logical CFI through transient resize until real interaction',()=>{
|
|
37
|
+
const p=load(),calls:any[]=[];let current='epubcfi(/6/2!/4/12/1:149)';
|
|
38
|
+
const original=function(this:any,w:any,h:any,target:any){calls.push([this,w,h,target]);return 'resized';};
|
|
39
|
+
const manager={resize:original};const anchor=p.installResizeAnchor(manager,()=>current);
|
|
40
|
+
expect(manager.resize(412,889,undefined)).toBe('resized');
|
|
41
|
+
current='epubcfi(/6/2!/4/12/1:97)';manager.resize(412,863,undefined);
|
|
42
|
+
expect(calls.map(c=>c[3])).toEqual(['epubcfi(/6/2!/4/12/1:149)','epubcfi(/6/2!/4/12/1:149)']);
|
|
43
|
+
expect(anchor.cfi()).toBe('epubcfi(/6/2!/4/12/1:149)');anchor.invalidate();
|
|
44
|
+
current='epubcfi(/6/2!/4/22/1:149)';manager.resize(863,412,undefined);
|
|
45
|
+
expect(calls.at(-1)[3]).toBe(current);expect(calls.every(c=>c[0]===manager)).toBe(true);
|
|
46
|
+
anchor.destroy();expect(manager.resize).toBe(original);
|
|
47
|
+
});
|
|
48
|
+
it('honors explicit valid targets and does not pass invalid CFIs',()=>{
|
|
49
|
+
const p=load(),calls:any[]=[];const manager={resize:(w:any,h:any,target:any)=>calls.push(target)};
|
|
50
|
+
const anchor=p.installResizeAnchor(manager,()=>null);manager.resize(1,2,undefined);expect(calls[0]).toBeUndefined();
|
|
51
|
+
manager.resize(1,2,'epubcfi(/6/4!/4/2)');expect(anchor.cfi()).toBe('epubcfi(/6/4!/4/2)');anchor.destroy();
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('does not discard a resize anchor for tap, selection or note marker',()=>{
|
|
56
|
+
const d=new JSDOM('<p>text</p><a href="#">link</a><button data-papyrus-overlay>note</button>',{runScripts:'outside-only'});
|
|
57
|
+
d.window.eval(readFileSync('packages/ui-react-native/runtime/epubReader.js','utf8'));
|
|
58
|
+
let invalidations=0,readingMode='paged';const p=(d.window as any).PapyrusEpubReader;
|
|
59
|
+
const cleanup=p.install({document:d.window.document,window:d.window},{mode:()=>readingMode,rtl:()=>false,blocked:()=>false,current:()=>true,tap:()=>{},turn:()=>invalidations++,interact:()=>invalidations++});
|
|
60
|
+
function touch(target:Element,y=40){for(const type of ['touchstart','touchend']){const point={clientX:30,clientY:type==='touchend'?y:40};const e=new d.window.Event(type,{bubbles:true});Object.defineProperties(e,{touches:{value:type==='touchend'?[]:[point]},changedTouches:{value:[point]}});target.dispatchEvent(e);}}
|
|
61
|
+
touch(d.window.document.querySelector('p')!);touch(d.window.document.querySelector('button')!);expect(invalidations).toBe(0);
|
|
62
|
+
readingMode='chapter-scroll';touch(d.window.document.querySelector('p')!,200);expect(invalidations).toBe(1);
|
|
63
|
+
d.window.document.querySelector('a')!.dispatchEvent(new d.window.Event('click',{bubbles:true}));expect(invalidations).toBe(2);cleanup();
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('invalidates chapter-scroll before relocation and ignores stale iframe callbacks',()=>{
|
|
67
|
+
const d=new JSDOM('<p>text</p><a href="#">link</a>',{runScripts:'outside-only'});
|
|
68
|
+
d.window.eval(readFileSync('packages/ui-react-native/runtime/epubReader.js','utf8'));
|
|
69
|
+
let invalidations=0,current=true;
|
|
70
|
+
const cleanup=(d.window as any).PapyrusEpubReader.install({document:d.window.document,window:d.window},{mode:()=> 'chapter-scroll',rtl:()=>false,blocked:()=>false,current:()=>current,tap:()=>{},turn:()=>{},interact:()=>invalidations++});
|
|
71
|
+
function dispatch(type:string,y:number){const point={clientX:30,clientY:y},e=new d.window.Event(type,{bubbles:true});Object.defineProperties(e,{touches:{value:type==='touchend'?[]:[point]},changedTouches:{value:[point]}});d.window.document.querySelector('p')!.dispatchEvent(e);}
|
|
72
|
+
dispatch('touchstart',40);dispatch('touchmove',120);expect(invalidations).toBe(1);
|
|
73
|
+
current=false;dispatch('touchend',180);d.window.document.querySelector('a')!.dispatchEvent(new d.window.Event('click',{bubbles:true}));expect(invalidations).toBe(1);cleanup();
|
|
74
|
+
});
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import {readFileSync} from 'node:fs';
|
|
2
|
+
import {JSDOM} from 'jsdom';
|
|
3
|
+
import {describe,it,expect,vi} from 'vitest';
|
|
4
|
+
|
|
5
|
+
function runtime() {
|
|
6
|
+
const dom=new JSDOM('<div id="viewer"></div>',{runScripts:'outside-only'});
|
|
7
|
+
const messages:any[]=[], renditions:any[]=[];
|
|
8
|
+
(dom.window as any).ReactNativeWebView={postMessage:(raw:string)=>messages.push(JSON.parse(raw))};
|
|
9
|
+
for (const helper of ['epubReader.js','epubAnnotations.js']) {
|
|
10
|
+
dom.window.eval(readFileSync(`packages/ui-react-native/runtime/${helper}`,'utf8'));
|
|
11
|
+
}
|
|
12
|
+
const book:any={ready:Promise.resolve(),spine:{items:[{href:'a.xhtml'},{href:'b.xhtml'}]},loaded:{navigation:Promise.resolve({toc:[]})},destroy:vi.fn(),renderTo:vi.fn((_viewer,options)=>{
|
|
13
|
+
const events:any={};
|
|
14
|
+
const r:any={options,manager:{resize:vi.fn(),on:vi.fn(),off:vi.fn()},hooks:{content:{register:vi.fn()},unloaded:{register:vi.fn()}},on:(name:string,fn:any)=>events[name]=fn,themes:{fontSize:vi.fn()},destroy:vi.fn(),display:vi.fn(async()=>{}),next:vi.fn(async()=>{}),prev:vi.fn(async()=>{}),emit:(name:string,data:any)=>events[name]?.(data)};
|
|
15
|
+
renditions.push(r);return r;
|
|
16
|
+
})};
|
|
17
|
+
(dom.window as any).ePub=()=>book;
|
|
18
|
+
dom.window.eval(readFileSync('packages/ui-react-native/runtime/runtime.js','utf8'));
|
|
19
|
+
const send=async(id:string,kind:string,payload:any)=>{
|
|
20
|
+
dom.window.dispatchEvent(new dom.window.MessageEvent('message',{data:JSON.stringify({id,kind,payload})}));
|
|
21
|
+
for(let n=0;n<100;n++){const result=messages.find(m=>m.type==='response'&&m.id===id);if(result)return result;await new Promise(r=>setTimeout(r,1));}
|
|
22
|
+
throw new Error('No response: '+id);
|
|
23
|
+
};
|
|
24
|
+
return {send,messages,renditions,book,dom};
|
|
25
|
+
}
|
|
26
|
+
describe('EPUB runtime bridge',()=>{
|
|
27
|
+
it('loads paginated by default and emits visual location within the same chapter',async()=>{
|
|
28
|
+
const r=runtime();expect((await r.send('load','load',{type:'epub',documentSessionId:'s',source:{kind:'base64',data:'AA=='}})).ok).toBe(true);
|
|
29
|
+
expect(r.renditions[0].options.flow).toBe('paginated');
|
|
30
|
+
r.renditions[0].emit('relocated',{start:{index:0,href:'a.xhtml',cfi:'epubcfi(/6/2!/4/2:10)',displayed:{page:2,total:8}}});
|
|
31
|
+
expect(r.messages).toContainEqual(expect.objectContaining({name:'EPUB_LOCATION',payload:expect.objectContaining({location:expect.objectContaining({visualPage:2,chapter:1})})}));
|
|
32
|
+
});
|
|
33
|
+
it('rebuilds chapter scroll at the current CFI and rejects a stale session',async()=>{
|
|
34
|
+
const r=runtime();await r.send('load','load',{type:'epub',documentSessionId:'s',source:{kind:'base64',data:'AA=='}});
|
|
35
|
+
const cfi='epubcfi(/6/2!/4/2:10)';r.renditions[0].emit('relocated',{start:{index:0,cfi,displayed:{page:2,total:8}}});
|
|
36
|
+
expect((await r.send('mode','set-epub-view-mode',{epubMode:'chapter-scroll',documentSessionId:'s'})).ok).toBe(true);
|
|
37
|
+
expect(r.renditions[1].options.flow).toBe('scrolled-doc');expect(r.renditions[1].display).toHaveBeenCalledWith(cfi);
|
|
38
|
+
expect((await r.send('old','epub-go-to-location',{cfi,documentSessionId:'old'})).ok).toBe(false);
|
|
39
|
+
});
|
|
40
|
+
it('serializes rapid mode changes and restores on the rebuilt rendition',async()=>{
|
|
41
|
+
const r=runtime();await r.send('load','load',{type:'epub',documentSessionId:'s',source:{kind:'base64',data:'AA=='}});
|
|
42
|
+
const cfi='epubcfi(/6/2!/4/2:10)';
|
|
43
|
+
await Promise.all([r.send('scroll','set-epub-view-mode',{epubMode:'chapter-scroll',documentSessionId:'s'}),r.send('paged','set-epub-view-mode',{epubMode:'paged',documentSessionId:'s'}),r.send('restore','epub-go-to-location',{cfi,documentSessionId:'s'})]);
|
|
44
|
+
expect(r.renditions.at(-1).options.flow).toBe('paginated');
|
|
45
|
+
expect(r.renditions.at(-1).display).toHaveBeenCalledWith(cfi);
|
|
46
|
+
});
|
|
47
|
+
it('keeps TXT zoom independent of EPUB state',async()=>{const r=runtime();await r.send('load','load',{type:'text',source:{kind:'text',text:'sample'}});expect((await r.send('zoom','set-zoom',{zoom:1.2})).ok).toBe(true);});
|
|
48
|
+
});
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import {readFileSync} from 'node:fs';
|
|
2
|
+
import {JSDOM} from 'jsdom';
|
|
3
|
+
import {describe,it,expect} from 'vitest';
|
|
4
|
+
const source=readFileSync('packages/ui-react-native/runtime/runtime.js','utf8');
|
|
5
|
+
function fixture() {
|
|
6
|
+
const messages:any[]=[];
|
|
7
|
+
const dom=new JSDOM('<div id="viewer"></div>',{runScripts:'outside-only'});
|
|
8
|
+
dom.window.ReactNativeWebView={postMessage:(raw:string)=>messages.push(JSON.parse(raw))} as never;
|
|
9
|
+
// Expose only the setup boundary in the test copy; execute the production handler.
|
|
10
|
+
dom.window.eval(source.replace("sendMessage({ type: 'ready' });",`
|
|
11
|
+
window.seedMenu=(session,payload,contents)=>{documentSessionId=session;nativeMenuSelection={payload,contents};};`));
|
|
12
|
+
const chapter=new JSDOM('<p>one word and a phrase</p>');
|
|
13
|
+
const text=chapter.window.document.querySelector('p')!.firstChild!;
|
|
14
|
+
const range=chapter.window.document.createRange();range.setStart(text,4);range.setEnd(text,8);
|
|
15
|
+
chapter.window.getSelection()!.addRange(range);
|
|
16
|
+
const payload={documentSessionId:'current',text:'word',pageIndex:1,
|
|
17
|
+
anchor:{kind:'epub-cfi',version:1,cfiRange:'epubcfi(/6/2!/4/2:4)',href:'one.xhtml',quote:'word'}};
|
|
18
|
+
(dom.window as any).seedMenu('current',payload,{document:chapter.window.document,window:chapter.window});
|
|
19
|
+
const send=(key:string,session='current')=>dom.window.dispatchEvent(new dom.window.MessageEvent('message',
|
|
20
|
+
{data:JSON.stringify({type:'epub-selection-action',id:'selection-menu',key,documentSessionId:session})}));
|
|
21
|
+
return {messages,chapter,send,dom};
|
|
22
|
+
}
|
|
23
|
+
describe('native EPUB menu dispatch',()=>{
|
|
24
|
+
it('uses the chapter CFI snapshot even when root selection is empty',()=>{
|
|
25
|
+
const f=fixture();expect(f.dom.window.getSelection()!.toString()).toBe('');f.send('highlight');
|
|
26
|
+
expect(f.messages).toContainEqual(expect.objectContaining({name:'EPUB_SELECTION_ACTION',payload:expect.objectContaining({action:'highlight',text:'word',anchor:expect.objectContaining({href:'one.xhtml'})})}));
|
|
27
|
+
expect(f.chapter.window.getSelection()!.toString()).toBe('');
|
|
28
|
+
});
|
|
29
|
+
it('rejects stale document sessions and unknown actions',()=>{
|
|
30
|
+
const f=fixture();f.send('copy','old');f.send('deleteDocument');expect(f.messages).toHaveLength(0);
|
|
31
|
+
expect(f.chapter.window.getSelection()!.toString()).toBe('word');
|
|
32
|
+
});
|
|
33
|
+
it('selects all inside the chapter, without annotating the previous word',()=>{
|
|
34
|
+
const f=fixture();f.send('selectAll');expect(f.chapter.window.getSelection()!.toString()).toBe('one word and a phrase');expect(f.messages).toHaveLength(0);
|
|
35
|
+
});
|
|
36
|
+
it('consumes each action once',()=>{
|
|
37
|
+
const f=fixture();f.send('comment');f.send('comment');expect(f.messages).toHaveLength(1);
|
|
38
|
+
});
|
|
39
|
+
});
|