@papyrus-sdk/ui-react-native 0.2.35-beta.10 → 0.2.35-beta.11.epub.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@papyrus-sdk/ui-react-native",
3
- "version": "0.2.35-beta.10",
3
+ "version": "0.2.35-beta.11.epub.1",
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": "0.2.23-beta.3",
50
- "@papyrus-sdk/engine-native": "0.2.22-beta.8",
51
- "@papyrus-sdk/types": "0.2.16-beta.3",
45
+ "@papyrus-sdk/core": "0.2.23-beta.3.epub.1",
46
+ "@papyrus-sdk/engine-native": "0.2.22-beta.9.epub.1",
47
+ "@papyrus-sdk/types": "0.2.16-beta.3.epub.1",
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": "workspace:*",
67
- "@papyrus-sdk/engine-native": "workspace:*",
68
- "@papyrus-sdk/types": "workspace:*",
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-beta.3.epub.1",
70
+ "@papyrus-sdk/types": "0.2.16-beta.3.epub.1",
71
+ "@papyrus-sdk/engine-native": "0.2.22-beta.9.epub.1"
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
+ }
@@ -75,6 +75,7 @@
75
75
  var color = /^#[0-9a-f]{6}$/i.test(annotation.color) ? annotation.color : '#FFC928';
76
76
  var style = annotation.markupStyle || annotation.type;
77
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);
78
79
  rects.forEach(function(rect) {
79
80
  var mark = doc.createElement('div');
80
81
  var bodyRect=doc.body.getBoundingClientRect();
@@ -87,8 +88,10 @@
87
88
  if (hasNote) {
88
89
  var last = rects[rects.length-1], button = doc.createElement('button');
89
90
  button.textContent = '●'; button.setAttribute('aria-label',options.labels().comment || '');
90
- button.style.cssText = 'position:absolute;pointer-events:auto;border:0;background:transparent;color:'+color+';width:44px;height:44px;left:'+Math.max(0,Math.min(doc.documentElement.clientWidth-44,last.right-doc.body.getBoundingClientRect().left))+'px;top:'+(Math.floor(last.top/44)*44-doc.body.getBoundingClientRect().top)+'px';
91
- var key = Math.floor(last.top/44);
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);
92
95
  var neighbors=noteGroups.get(key);
93
96
  if(!neighbors){neighbors={button:button,ids:[]};noteGroups.set(key,neighbors);group.appendChild(button);}
94
97
  neighbors.ids.push(annotation.id);
@@ -109,10 +112,10 @@
109
112
  doc.addEventListener('click',tapMarkup);
110
113
  var observer = typeof win.ResizeObserver === 'function' ? new win.ResizeObserver(schedule) : null;
111
114
  if (observer) observer.observe(doc.body);
112
- win.addEventListener('resize',schedule); doc.addEventListener('load',schedule,true);
115
+ win.addEventListener('resize',schedule);win.addEventListener('scroll',schedule); doc.addEventListener('load',schedule,true);
113
116
  if (doc.fonts && doc.fonts.ready) doc.fonts.ready.then(schedule);
114
117
  schedule();
115
- return {schedule:schedule, range:function(anchor){return resolveRange(contents,anchor);}, destroy:function(){alive=false;if(frame)win.cancelAnimationFrame(frame);if(observer)observer.disconnect();win.removeEventListener('resize',schedule);doc.removeEventListener('load',schedule,true);doc.removeEventListener('click',tapMarkup);overlay.remove();doc.body.style.position=originalPosition;registry.clear();}};
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();}};
116
119
  }
117
120
  return {validAnchor:validAnchor,recoverRange:recoverRange,resolveRange:resolveRange,selection:selection,install:install};
118
121
  });
@@ -15,14 +15,18 @@ it('renders true strikeout, grouped notes and reconciles 120 stable IDs after re
15
15
  let top=20;Object.defineProperty(range,'getClientRects',{value:()=>[{left:10,right:50,top,bottom:top+20,width:40,height:20}]});
16
16
  let queue:FrameRequestCallback[]=[];
17
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',markupStyle:'strikeout',noteContent:`note ${i}`}));
18
+ let snapshot=Array.from({length:120},(_,i)=>({id:`n${i}`,anchor,color:'#ff0000',opacity:1,markupStyle:'strikeout',noteContent:`note ${i}`}));
19
19
  const taps:string[]=[];
20
- const renderer=annotations.install({document,window,range:()=>range},{href:'chapter.xhtml',documentId:undefined,annotations:()=>snapshot,labels:()=>({comment:'Nota'}),isCurrent:()=>true,onTap:(id:string)=>taps.push(id)});
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
21
  const flush=()=>{const tasks=queue;queue=[];tasks.forEach(cb=>cb(0));};flush();
22
22
  expect(document.querySelectorAll('[data-annotation-id]')).toHaveLength(120);
23
23
  expect(document.querySelectorAll('[data-papyrus-overlay] button')).toHaveLength(1);
24
24
  const group=document.querySelector('[data-annotation-id="n0"]')!;
25
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();
26
30
  expect((group.lastChild as HTMLElement).textContent).toBe('120');
27
31
  (group.lastChild as HTMLElement).click();expect(taps).toEqual(['n0']);
28
32
  top=80;renderer.schedule();flush();expect(document.querySelector('[data-annotation-id="n0"]')).toBe(group);expect((group.firstChild as HTMLElement).style.top).toBe('90px');
@@ -0,0 +1,33 @@
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){
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 fraction=total?Math.max(0,(visualPage-1)/total):0;
18
+ 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:Boolean(raw.atStart),atEnd:Boolean(raw.atEnd)};
19
+ }
20
+ function install(contents,config){
21
+ var doc=contents.document,win=contents.window,start=null,blocked=false;
22
+ 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;}
23
+ function selected(){return Boolean(String(win.getSelection()||'').trim()) || config.blocked();}
24
+ function interactive(target){return Boolean(target && target.closest && target.closest('a,button,input,textarea,select,[contenteditable="true"],[data-papyrus-overlay]'));}
25
+ function begin(e){start=point(e);blocked=e.touches.length!==1 || selected() || interactive(e.target);}
26
+ function move(e){if(e.touches.length!==1)blocked=true;}
27
+ function end(e){var action=gesture(start,point(e),{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);}
28
+ function cancel(){start=null;blocked=true;}
29
+ doc.addEventListener('touchstart',begin,{passive:true});doc.addEventListener('touchmove',move,{passive:true});doc.addEventListener('touchend',end,{passive:true});doc.addEventListener('touchcancel',cancel,{passive:true});
30
+ return function(){doc.removeEventListener('touchstart',begin);doc.removeEventListener('touchmove',move);doc.removeEventListener('touchend',end);doc.removeEventListener('touchcancel',cancel);};
31
+ }
32
+ root.PapyrusEpubReader={mode:mode,validCfi:validCfi,options:options,gesture:gesture,location:location,install:install};
33
+ })(typeof window==='undefined'?globalThis:window);
@@ -0,0 +1,22 @@
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
+ });
@@ -0,0 +1,45 @@
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
+ 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)=>{
10
+ const events:any={};
11
+ const r:any={options,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)};
12
+ renditions.push(r);return r;
13
+ })};
14
+ (dom.window as any).ePub=()=>book;
15
+ dom.window.eval(readFileSync('packages/ui-react-native/runtime/runtime.js','utf8'));
16
+ const send=async(id:string,kind:string,payload:any)=>{
17
+ dom.window.dispatchEvent(new dom.window.MessageEvent('message',{data:JSON.stringify({id,kind,payload})}));
18
+ 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));}
19
+ throw new Error('No response: '+id);
20
+ };
21
+ return {send,messages,renditions,book,dom};
22
+ }
23
+ describe('EPUB runtime bridge',()=>{
24
+ it('loads paginated by default and emits visual location within the same chapter',async()=>{
25
+ const r=runtime();expect((await r.send('load','load',{type:'epub',documentSessionId:'s',source:{kind:'base64',data:'AA=='}})).ok).toBe(true);
26
+ expect(r.renditions[0].options.flow).toBe('paginated');
27
+ r.renditions[0].emit('relocated',{start:{index:0,href:'a.xhtml',cfi:'epubcfi(/6/2!/4/2:10)',displayed:{page:2,total:8}}});
28
+ expect(r.messages).toContainEqual(expect.objectContaining({name:'EPUB_LOCATION',payload:expect.objectContaining({location:expect.objectContaining({visualPage:2,chapter:1})})}));
29
+ });
30
+ it('rebuilds chapter scroll at the current CFI and rejects a stale session',async()=>{
31
+ const r=runtime();await r.send('load','load',{type:'epub',documentSessionId:'s',source:{kind:'base64',data:'AA=='}});
32
+ const cfi='epubcfi(/6/2!/4/2:10)';r.renditions[0].emit('relocated',{start:{index:0,cfi,displayed:{page:2,total:8}}});
33
+ expect((await r.send('mode','set-epub-view-mode',{epubMode:'chapter-scroll',documentSessionId:'s'})).ok).toBe(true);
34
+ expect(r.renditions[1].options.flow).toBe('scrolled-doc');expect(r.renditions[1].display).toHaveBeenCalledWith(cfi);
35
+ expect((await r.send('old','epub-go-to-location',{cfi,documentSessionId:'old'})).ok).toBe(false);
36
+ });
37
+ it('serializes rapid mode changes and restores on the rebuilt rendition',async()=>{
38
+ const r=runtime();await r.send('load','load',{type:'epub',documentSessionId:'s',source:{kind:'base64',data:'AA=='}});
39
+ const cfi='epubcfi(/6/2!/4/2:10)';
40
+ 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'})]);
41
+ expect(r.renditions.at(-1).options.flow).toBe('paginated');
42
+ expect(r.renditions.at(-1).display).toHaveBeenCalledWith(cfi);
43
+ });
44
+ 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);});
45
+ });