@papyrus-sdk/ui-react-native 0.2.35-beta.7 → 0.2.35-beta.9

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.7",
3
+ "version": "0.2.35-beta.9",
4
4
  "license": "MIT",
5
5
  "keywords": [
6
6
  "papyrus",
@@ -38,16 +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
- },
44
41
  "dependencies": {
45
42
  "react-native-svg": "^15.5.0"
46
43
  },
47
44
  "peerDependencies": {
48
- "@papyrus-sdk/core": ">=0.2.18 || 0.2.23-beta.2",
49
- "@papyrus-sdk/engine-native": ">=0.2.17 || 0.2.22-beta.6",
50
- "@papyrus-sdk/types": ">=0.2.11 || 0.2.16-beta.2",
45
+ "@papyrus-sdk/core": "0.2.23-beta.3",
46
+ "@papyrus-sdk/engine-native": "0.2.22-beta.7",
47
+ "@papyrus-sdk/types": "0.2.16-beta.3",
51
48
  "@react-native-clipboard/clipboard": "^1.16.3",
52
49
  "react": ">=18.2.0 <20",
53
50
  "react-native": ">=0.81.0",
@@ -62,15 +59,19 @@
62
59
  }
63
60
  },
64
61
  "devDependencies": {
65
- "@papyrus-sdk/core": "0.2.23-beta.2",
66
- "@papyrus-sdk/engine-native": "0.2.22-beta.6",
67
- "@papyrus-sdk/types": "0.2.16-beta.2",
68
62
  "@react-native-clipboard/clipboard": "^1.16.3",
69
63
  "@types/react": "19.2.14",
70
64
  "react": "^19.1.0",
71
65
  "react-native": "^0.81.0",
72
66
  "react-native-gesture-handler": "~2.28.0",
73
67
  "react-native-safe-area-context": "^5.6.2",
74
- "react-native-reanimated": "3.19.5"
68
+ "react-native-reanimated": "3.19.5",
69
+ "@papyrus-sdk/types": "0.2.16-beta.3",
70
+ "@papyrus-sdk/core": "0.2.23-beta.3",
71
+ "@papyrus-sdk/engine-native": "0.2.22-beta.7"
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"
75
76
  }
76
- }
77
+ }
@@ -0,0 +1,118 @@
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
+ rects.forEach(function(rect) {
79
+ var mark = doc.createElement('div');
80
+ var bodyRect=doc.body.getBoundingClientRect();
81
+ var height = style === 'highlight' ? rect.height : 2;
82
+ var y = style === 'strikeout' ? rect.top+rect.height/2 : style === 'underline' ? rect.bottom-2 : rect.top;
83
+ 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;
84
+ if (['highlight','underline','strikeout'].includes(style)) group.appendChild(mark);
85
+ });
86
+ var hasNote = annotation.noteContent !== undefined || annotation.type === 'comment' || annotation.type === 'text';
87
+ if (hasNote) {
88
+ var last = rects[rects.length-1], button = doc.createElement('button');
89
+ 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);
92
+ var neighbors=noteGroups.get(key);
93
+ if(!neighbors){neighbors={button:button,ids:[]};noteGroups.set(key,neighbors);group.appendChild(button);}
94
+ neighbors.ids.push(annotation.id);
95
+ }
96
+ });
97
+ noteGroups.forEach(function(group){
98
+ group.button.textContent=group.ids.length>1?String(group.ids.length):'●';
99
+ group.button.addEventListener('click',function(e){e.stopPropagation();if(options.isCurrent())options.onTap(group.ids[0],group.ids);});
100
+ });
101
+ registry.forEach(function(group,id){if (!keep.has(id)) {group.remove();registry.delete(id);}});
102
+ }
103
+ function schedule() { if (alive && !frame) frame = win.requestAnimationFrame(render); }
104
+ function tapMarkup(event) {
105
+ if(!alive || !options.isCurrent() || String(win.getSelection() || '').length || event.target.closest('[data-papyrus-overlay]'))return;
106
+ 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;});});
107
+ if(hit) {event.stopPropagation();options.onTap(hit.id);}
108
+ }
109
+ doc.addEventListener('click',tapMarkup);
110
+ var observer = typeof win.ResizeObserver === 'function' ? new win.ResizeObserver(schedule) : null;
111
+ if (observer) observer.observe(doc.body);
112
+ win.addEventListener('resize',schedule); doc.addEventListener('load',schedule,true);
113
+ if (doc.fonts && doc.fonts.ready) doc.fonts.ready.then(schedule);
114
+ 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();}};
116
+ }
117
+ return {validAnchor:validAnchor,recoverRange:recoverRange,resolveRange:resolveRange,selection:selection,install:install};
118
+ });
@@ -0,0 +1,31 @@
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',markupStyle:'strikeout',noteContent:`note ${i}`}));
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)});
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).textContent).toBe('120');
27
+ (group.lastChild as HTMLElement).click();expect(taps).toEqual(['n0']);
28
+ top=80;renderer.schedule();flush();expect(document.querySelector('[data-annotation-id="n0"]')).toBe(group);expect((group.firstChild as HTMLElement).style.top).toBe('90px');
29
+ snapshot=[];renderer.schedule();flush();expect(document.querySelectorAll('[data-annotation-id]')).toHaveLength(0);
30
+ renderer.destroy();expect(document.querySelector('[data-papyrus-overlay]')).toBeNull();window.requestAnimationFrame=originalRAF;
31
+ });
@@ -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
+ });
@@ -20483,6 +20483,127 @@ https://github.com/nodeca/pako/blob/main/LICENSE
20483
20483
  <!-- @papyrus-comic-runtime:end -->
20484
20484
  <script>
20485
20485
  (function () {
20486
+ /* @papyrus-epub-annotations:start */
20487
+ (function (root, factory) {
20488
+ if (typeof module === 'object' && module.exports) module.exports = factory();
20489
+ else root.PapyrusEpubAnnotations = factory();
20490
+ })(typeof window !== 'undefined' ? window : globalThis, function () {
20491
+ function validAnchor(anchor) {
20492
+ return anchor && anchor.version === 1 && anchor.kind === 'epub-cfi' &&
20493
+ typeof anchor.cfiRange === 'string' && anchor.cfiRange.length <= 4096 && /^epubcfi\([^\n<>]+\)$/.test(anchor.cfiRange) &&
20494
+ typeof anchor.href === 'string' && !/^(javascript|data):/i.test(anchor.href) &&
20495
+ typeof anchor.quote === 'string' && anchor.quote.length > 0 && anchor.quote.length <= 100000;
20496
+ }
20497
+ function textNodes(document) {
20498
+ var walker = document.createTreeWalker(document.body, 4);
20499
+ var nodes = [], text = '', node;
20500
+ while ((node = walker.nextNode())) {
20501
+ if (node.parentElement.closest('script,style,[data-papyrus-overlay]')) continue;
20502
+ nodes.push({node:node, start:text.length}); text += node.textContent;
20503
+ }
20504
+ return {nodes:nodes,text:text};
20505
+ }
20506
+ function recoverRange(document, anchor) {
20507
+ var source = textNodes(document), match = null, start = source.text.indexOf(anchor.quote);
20508
+ while (start >= 0) {
20509
+ var end = start + anchor.quote.length;
20510
+ if ((!anchor.prefix || source.text.slice(Math.max(0,start-anchor.prefix.length),start) === anchor.prefix) &&
20511
+ (!anchor.suffix || source.text.slice(end,end+anchor.suffix.length) === anchor.suffix)) {
20512
+ if (match) return null; match = {start:start,end:end};
20513
+ }
20514
+ start = source.text.indexOf(anchor.quote,start+1);
20515
+ }
20516
+ if (!match) return null;
20517
+ var range = document.createRange();
20518
+ var first = source.nodes.find(function(n){return n.start+n.node.length > match.start;});
20519
+ var last = source.nodes.find(function(n){return n.start+n.node.length >= match.end;});
20520
+ if (!first || !last) return null;
20521
+ range.setStart(first.node,match.start-first.start); range.setEnd(last.node,match.end-last.start);
20522
+ return range;
20523
+ }
20524
+ function resolveRange(contents, anchor) {
20525
+ if (!validAnchor(anchor)) return null;
20526
+ try { var range = contents.range(anchor.cfiRange); if (range && range.toString() === anchor.quote) return range; } catch (_) {}
20527
+ return recoverRange(contents.document,anchor);
20528
+ }
20529
+ function selection(contents, cfiRange, href, documentId) {
20530
+ var range;
20531
+ try { range = contents.range(cfiRange); } catch (_) { return null; }
20532
+ if (!range || !range.toString()) return null;
20533
+ var before = contents.document.createRange(); before.selectNodeContents(contents.document.body); before.setEnd(range.startContainer,range.startOffset);
20534
+ var after = contents.document.createRange(); after.selectNodeContents(contents.document.body); after.setStart(range.endContainer,range.endOffset);
20535
+ 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)};
20536
+ return validAnchor(anchor) ? anchor : null;
20537
+ }
20538
+ function install(contents, options) {
20539
+ var doc = contents.document, win = contents.window, alive = true, frame = 0;
20540
+ var overlay = doc.createElement('div'); overlay.dataset.papyrusOverlay = 'annotations';
20541
+ overlay.style.cssText = 'position:absolute;left:0;top:0;width:100%;height:0;pointer-events:none;z-index:3';
20542
+ var originalPosition=doc.body.style.position;
20543
+ if(win.getComputedStyle(doc.body).position==='static')doc.body.style.position='relative';
20544
+ doc.body.appendChild(overlay);
20545
+ var registry = new Map(), hitRegions = [];
20546
+ function render() {
20547
+ frame = 0; if (!alive || !options.isCurrent()) return;
20548
+ var keep = new Set(), noteGroups = new Map();
20549
+ hitRegions = [];
20550
+ options.annotations().forEach(function(annotation) {
20551
+ var anchor = annotation.anchor;
20552
+ if (!validAnchor(anchor) || anchor.href !== options.href || anchor.documentId && options.documentId && anchor.documentId !== options.documentId) return;
20553
+ var range = resolveRange(contents,anchor); if (!range) return;
20554
+ var rects = Array.from(range.getClientRects()).filter(function(r){return r.width>0 && r.height>0;});
20555
+ if (!rects.length) return;
20556
+ keep.add(annotation.id);
20557
+ var group = registry.get(annotation.id);
20558
+ 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'}); } }
20559
+ group.replaceChildren();
20560
+ hitRegions.push({id:annotation.id,rects:rects});
20561
+ var color = /^#[0-9a-f]{6}$/i.test(annotation.color) ? annotation.color : '#FFC928';
20562
+ var style = annotation.markupStyle || annotation.type;
20563
+ var opacity = Number.isFinite(annotation.opacity) ? Math.max(0,Math.min(1,annotation.opacity)) : 0.35;
20564
+ rects.forEach(function(rect) {
20565
+ var mark = doc.createElement('div');
20566
+ var bodyRect=doc.body.getBoundingClientRect();
20567
+ var height = style === 'highlight' ? rect.height : 2;
20568
+ var y = style === 'strikeout' ? rect.top+rect.height/2 : style === 'underline' ? rect.bottom-2 : rect.top;
20569
+ 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;
20570
+ if (['highlight','underline','strikeout'].includes(style)) group.appendChild(mark);
20571
+ });
20572
+ var hasNote = annotation.noteContent !== undefined || annotation.type === 'comment' || annotation.type === 'text';
20573
+ if (hasNote) {
20574
+ var last = rects[rects.length-1], button = doc.createElement('button');
20575
+ button.textContent = '●'; button.setAttribute('aria-label',options.labels().comment || '');
20576
+ 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';
20577
+ var key = Math.floor(last.top/44);
20578
+ var neighbors=noteGroups.get(key);
20579
+ if(!neighbors){neighbors={button:button,ids:[]};noteGroups.set(key,neighbors);group.appendChild(button);}
20580
+ neighbors.ids.push(annotation.id);
20581
+ }
20582
+ });
20583
+ noteGroups.forEach(function(group){
20584
+ group.button.textContent=group.ids.length>1?String(group.ids.length):'●';
20585
+ group.button.addEventListener('click',function(e){e.stopPropagation();if(options.isCurrent())options.onTap(group.ids[0],group.ids);});
20586
+ });
20587
+ registry.forEach(function(group,id){if (!keep.has(id)) {group.remove();registry.delete(id);}});
20588
+ }
20589
+ function schedule() { if (alive && !frame) frame = win.requestAnimationFrame(render); }
20590
+ function tapMarkup(event) {
20591
+ if(!alive || !options.isCurrent() || String(win.getSelection() || '').length || event.target.closest('[data-papyrus-overlay]'))return;
20592
+ 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;});});
20593
+ if(hit) {event.stopPropagation();options.onTap(hit.id);}
20594
+ }
20595
+ doc.addEventListener('click',tapMarkup);
20596
+ var observer = typeof win.ResizeObserver === 'function' ? new win.ResizeObserver(schedule) : null;
20597
+ if (observer) observer.observe(doc.body);
20598
+ win.addEventListener('resize',schedule); doc.addEventListener('load',schedule,true);
20599
+ if (doc.fonts && doc.fonts.ready) doc.fonts.ready.then(schedule);
20600
+ schedule();
20601
+ 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();}};
20602
+ }
20603
+ return {validAnchor:validAnchor,recoverRange:recoverRange,resolveRange:resolveRange,selection:selection,install:install};
20604
+ });
20605
+
20606
+ /* @papyrus-epub-annotations:end */
20486
20607
  const viewer = document.getElementById('viewer');
20487
20608
  const DEFAULT_FONT_SIZE = 16;
20488
20609
  const TEXT_PAGE_CHUNK = 1600;
@@ -20490,6 +20611,11 @@ https://github.com/nodeca/pako/blob/main/LICENSE
20490
20611
  let currentType = null;
20491
20612
  let book = null;
20492
20613
  let rendition = null;
20614
+ let documentSessionId = null;
20615
+ let annotationDocumentId;
20616
+ let annotationLabels = {};
20617
+ let epubAnnotations = [];
20618
+ const epubAnnotationRenderers = new Map();
20493
20619
  let spineItems = [];
20494
20620
  let textPages = [];
20495
20621
  let textContainer = null;
@@ -20897,6 +21023,8 @@ https://github.com/nodeca/pako/blob/main/LICENSE
20897
21023
  };
20898
21024
 
20899
21025
  const clearViewer = () => {
21026
+ epubAnnotationRenderers.forEach(renderer => renderer.destroy());
21027
+ epubAnnotationRenderers.clear();
20900
21028
  if (epubInteractionCleanup) {
20901
21029
  epubInteractionCleanup();
20902
21030
  epubInteractionCleanup = null;
@@ -21554,6 +21682,7 @@ https://github.com/nodeca/pako/blob/main/LICENSE
21554
21682
  clearViewer();
21555
21683
  rendition = book.renderTo(viewer, {
21556
21684
  manager: 'continuous',
21685
+ allowScriptedContent: false,
21557
21686
  width: '100%',
21558
21687
  height: '100%',
21559
21688
  flow: 'scrolled-continuous',
@@ -21562,10 +21691,22 @@ https://github.com/nodeca/pako/blob/main/LICENSE
21562
21691
  sendEpubDiagnostic('epub.rendition.created', diagnosticPayload);
21563
21692
 
21564
21693
  if (rendition && rendition.hooks && rendition.hooks.content) {
21694
+ const session = documentSessionId;
21695
+ const activeRendition = rendition;
21565
21696
  rendition.hooks.content.register((contents) => {
21697
+ const href = spineItems[contents.sectionIndex]?.href;
21698
+ if (href && window.PapyrusEpubAnnotations) {
21699
+ const renderer = window.PapyrusEpubAnnotations.install(contents, {
21700
+ href, get documentId() { return annotationDocumentId; },
21701
+ isCurrent: () => session === documentSessionId && activeRendition === rendition,
21702
+ annotations: () => epubAnnotations, labels: () => annotationLabels,
21703
+ onTap: (id, ids) => sendEvent("EPUB_ANNOTATION_TAP", {documentSessionId: session, id, ids}),
21704
+ });
21705
+ epubAnnotationRenderers.set(contents,renderer);
21706
+ }
21566
21707
  const frame = contents && contents.document ? contents.document.defaultView.frameElement : null;
21567
21708
  if (frame) {
21568
- frame.setAttribute('sandbox', 'allow-scripts allow-same-origin');
21709
+ frame.setAttribute('sandbox', 'allow-same-origin');
21569
21710
  }
21570
21711
  const selectionDocument = contents && contents.document;
21571
21712
  const selectionWindow = contents && contents.window;
@@ -21575,13 +21716,46 @@ https://github.com/nodeca/pako/blob/main/LICENSE
21575
21716
  selectionWindow &&
21576
21717
  typeof selectionWindow.getSelection === 'function'
21577
21718
  ) {
21719
+ let selectionTimer = null;
21720
+ let lastSelectionKey = '';
21721
+ let lastSelectionAt = 0;
21722
+ const reportSelection = () => {
21723
+ selectionTimer = null;
21724
+ if (session !== documentSessionId || activeRendition !== rendition) return;
21725
+ const selected = selectionWindow.getSelection();
21726
+ if (!selected || selected.isCollapsed || selected.rangeCount === 0 ||
21727
+ typeof contents.cfiFromRange !== 'function') return;
21728
+ const range = selected.getRangeAt(0).cloneRange();
21729
+ if (!range.toString().trim()) return;
21730
+ const href = spineItems[contents.sectionIndex]?.href;
21731
+ if (!href) return;
21732
+ let cfi;
21733
+ try { cfi = contents.cfiFromRange(range); } catch (_) { return; }
21734
+ const anchor = window.PapyrusEpubAnnotations?.selection(contents, cfi, href, annotationDocumentId);
21735
+ if (!anchor) return;
21736
+ const key = anchor.cfiRange + ':' + anchor.quote;
21737
+ const now = Date.now();
21738
+ if (key === lastSelectionKey && now - lastSelectionAt < 750) return;
21739
+ lastSelectionKey = key;
21740
+ lastSelectionAt = now;
21741
+ const payload = {documentSessionId: session, text: anchor.quote,
21742
+ pageIndex: contents.sectionIndex + 1, anchor};
21743
+ nativeMenuSelection = {contents, payload};
21744
+ sendEvent('EPUB_TEXT_SELECTED', payload);
21745
+ };
21578
21746
  const handleSelectionChange = () => {
21579
21747
  const selection = selectionWindow.getSelection();
21580
21748
  const text = selection ? selection.toString().trim() : '';
21581
21749
  epubScrollDiagnostics && epubScrollDiagnostics.setSelectionActive(Boolean(text));
21750
+ if (selectionTimer !== null) clearTimeout(selectionTimer);
21751
+ // epub.js "selected" is not reliable on every iOS WKWebView path.
21752
+ // The content document still emits selectionchange, so resolve its
21753
+ // live Range to a CFI before the native menu action can fire.
21754
+ if (text) reportSelection();
21582
21755
  };
21583
21756
  selectionDocument.addEventListener('selectionchange', handleSelectionChange);
21584
21757
  const selectionCleanup = () => {
21758
+ if (selectionTimer !== null) clearTimeout(selectionTimer);
21585
21759
  selectionDocument.removeEventListener('selectionchange', handleSelectionChange);
21586
21760
  epubSelectionCleanups.delete(selectionCleanup);
21587
21761
  };
@@ -21602,21 +21776,28 @@ https://github.com/nodeca/pako/blob/main/LICENSE
21602
21776
  rendition.hooks.unloaded.register((view) => {
21603
21777
  const cleanup = view && view.contents && view.contents.__papyrusSelectionCleanup;
21604
21778
  if (typeof cleanup === 'function') cleanup();
21779
+ const renderer = epubAnnotationRenderers.get(view?.contents);
21780
+ if (renderer) renderer.destroy();
21781
+ epubAnnotationRenderers.delete(view?.contents);
21605
21782
  });
21606
21783
  }
21607
21784
 
21608
21785
  if (rendition && typeof rendition.on === 'function') {
21786
+ const selectionSession = documentSessionId;
21787
+ const selectionRendition = rendition;
21609
21788
  rendition.on('selected', (cfiRange, contents) => {
21610
- const selection = contents && contents.window ? contents.window.getSelection() : null;
21611
- const text = selection ? selection.toString().trim() : '';
21612
- epubScrollDiagnostics && epubScrollDiagnostics.setSelectionActive(Boolean(text));
21613
- if (text) {
21614
- sendEvent('TEXT_SELECTED', { text, pageIndex: Math.max(0, currentPage - 1) });
21615
- }
21616
- if (rendition && rendition.annotations && typeof rendition.annotations.remove === 'function') {
21617
- rendition.annotations.remove(cfiRange, 'highlight');
21618
- }
21789
+ if (selectionSession !== documentSessionId || selectionRendition !== rendition) return;
21790
+ const href = spineItems[contents.sectionIndex]?.href;
21791
+ const anchor = href && window.PapyrusEpubAnnotations?.selection(contents,cfiRange,href,annotationDocumentId);
21792
+ if (!anchor) return;
21793
+ epubScrollDiagnostics && epubScrollDiagnostics.setSelectionActive(true);
21794
+ const payload = {documentSessionId: selectionSession, text: anchor.quote, pageIndex: contents.sectionIndex+1, anchor};
21795
+ nativeMenuSelection = {contents, payload};
21796
+ sendEvent('EPUB_TEXT_SELECTED', payload);
21619
21797
  });
21798
+ ['rendered','resized','layout'].forEach(name => rendition.on(name, () => {
21799
+ if (selectionSession === documentSessionId && selectionRendition === rendition) epubAnnotationRenderers.forEach(renderer => renderer.schedule());
21800
+ }));
21620
21801
  rendition.on('relocated', (location) => {
21621
21802
  const start = location && location.start;
21622
21803
  let index = start && Number.isInteger(start.index) ? start.index : -1;
@@ -21746,7 +21927,8 @@ https://github.com/nodeca/pako/blob/main/LICENSE
21746
21927
  const end = getTouchPoint(event);
21747
21928
  const start = touchStart;
21748
21929
  touchStart = null;
21749
- if (start && end && Math.hypot(end.x - start.x, end.y - start.y) <= 12) {
21930
+ const selectedText = event?.target?.ownerDocument?.defaultView?.getSelection?.()?.toString().trim() || '';
21931
+ if (!selectedText && start && end && Math.hypot(end.x - start.x, end.y - start.y) <= 12) {
21750
21932
  sendEvent('VIEWER_TAP', {});
21751
21933
  }
21752
21934
  epubScrollDiagnostics && epubScrollDiagnostics.touchEnd(event);
@@ -21903,6 +22085,8 @@ https://github.com/nodeca/pako/blob/main/LICENSE
21903
22085
  }
21904
22086
  };
21905
22087
 
22088
+ let nativeMenuSelection = null;
22089
+
21906
22090
  const handleCommand = async (message) => {
21907
22091
  const { id, kind, payload } = message;
21908
22092
 
@@ -21910,6 +22094,9 @@ https://github.com/nodeca/pako/blob/main/LICENSE
21910
22094
  if (kind === 'load') {
21911
22095
  const generation = (epubLoadGeneration += 1);
21912
22096
  currentType = payload.type;
22097
+ documentSessionId = payload.documentSessionId || null;
22098
+ epubAnnotations = [];
22099
+ annotationDocumentId = undefined;
21913
22100
  zoom = 1.0;
21914
22101
 
21915
22102
  if (currentType === 'text') {
@@ -21950,6 +22137,36 @@ https://github.com/nodeca/pako/blob/main/LICENSE
21950
22137
  throw new Error('Unsupported document type');
21951
22138
  }
21952
22139
 
22140
+ if (kind === 'epub-annotations' || kind === 'epub-annotation-navigate') {
22141
+ if (currentType !== 'epub' || !documentSessionId || payload.documentSessionId !== documentSessionId) throw new Error('Stale EPUB annotation session');
22142
+ if (kind === 'epub-annotations') {
22143
+ const incoming = payload.annotations;
22144
+ if (!Array.isArray(incoming) || incoming.length > 10000 || incoming.some(a => !a || typeof a.id !== 'string' || a.id.length > 256)) throw new Error('Invalid EPUB annotations');
22145
+ const ids = new Set();
22146
+ epubAnnotations = incoming.filter(a => {
22147
+ if (ids.has(a.id) || !window.PapyrusEpubAnnotations.validAnchor(a.anchor)) return false;
22148
+ ids.add(a.id); return true;
22149
+ });
22150
+ annotationDocumentId = typeof payload.documentId === 'string' ? payload.documentId : undefined;
22151
+ annotationLabels = {comment: typeof payload.labels?.comment === 'string' ? payload.labels.comment.slice(0,128) : ''};
22152
+ epubAnnotationRenderers.forEach(renderer => renderer.schedule());
22153
+ } else {
22154
+ const annotation = epubAnnotations.find(a => a.id === payload.annotationId);
22155
+ if (!annotation) throw new Error('Annotation not available');
22156
+ // Navigation uses the CFI, never a synthetic percentage/page rectangle.
22157
+ const navigationSession = documentSessionId;
22158
+ const activeRendition = rendition;
22159
+ // Open the confirmed chapter first, resolve its quote, then navigate its actual range.
22160
+ await activeRendition.display(annotation.anchor.href);
22161
+ if(navigationSession !== documentSessionId || activeRendition !== rendition)throw new Error('Stale EPUB navigation');
22162
+ const contents = activeRendition.getContents().find(c => spineItems[c.sectionIndex]?.href === annotation.anchor.href);
22163
+ const range = contents && window.PapyrusEpubAnnotations.resolveRange(contents,annotation.anchor);
22164
+ if(!range)throw new Error('EPUB anchor unresolved');
22165
+ await activeRendition.display(contents.cfiFromRange(range));
22166
+ }
22167
+ sendResponse(id,true,null); return;
22168
+ }
22169
+
21953
22170
  if (kind === 'go-to-page') {
21954
22171
  const page = Math.max(1, payload.page || 1);
21955
22172
  if (currentType === 'text') {
@@ -22119,6 +22336,26 @@ https://github.com/nodeca/pako/blob/main/LICENSE
22119
22336
  return;
22120
22337
  }
22121
22338
 
22339
+ if (message.type === 'epub-selection-action') {
22340
+ const selected = nativeMenuSelection;
22341
+ if (!selected || message.documentSessionId !== documentSessionId ||
22342
+ selected.payload.documentSessionId !== documentSessionId) return;
22343
+ if (message.key === 'selectAll') {
22344
+ const doc = selected.contents.document;
22345
+ const range = doc.createRange();
22346
+ range.selectNodeContents(doc.body);
22347
+ const selection = selected.contents.window.getSelection();
22348
+ selection.removeAllRanges();
22349
+ selection.addRange(range);
22350
+ return;
22351
+ }
22352
+ if (!['copy','define','highlight','underline','strikeout','comment'].includes(message.key)) return;
22353
+ sendEvent('EPUB_SELECTION_ACTION', {...selected.payload, action: message.key});
22354
+ selected.contents.window.getSelection()?.removeAllRanges();
22355
+ nativeMenuSelection = null;
22356
+ return;
22357
+ }
22358
+
22122
22359
  if (!message.kind) return;
22123
22360
  handleCommand(message);
22124
22361
  };