@papyrus-sdk/ui-react-native 0.2.35-beta.9 → 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.
@@ -109,6 +109,67 @@
109
109
  /* @papyrus-comic-runtime:end */
110
110
 
111
111
  (function () {
112
+ /* @papyrus-epub-reader:start */
113
+ (function(root){
114
+ function mode(value){return value==='chapter-scroll'?'chapter-scroll':'paged';}
115
+ function validCfi(value){return typeof value==='string' && value.length<=4096 && /^epubcfi\([^\n<>]+\)$/.test(value);}
116
+ function options(value){return {manager:'default',flow:mode(value)==='paged'?'paginated':'scrolled-doc',spread:'none',width:'100%',height:'100%',allowScriptedContent:false};}
117
+ function gesture(start,end,state){
118
+ if(!start || !end || state.selected || state.interactive || state.multitouch)return null;
119
+ var dx=end.x-start.x,dy=end.y-start.y,elapsed=end.time-start.time;
120
+ 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';
121
+ if(elapsed>=0 && elapsed<450 && Math.hypot(dx,dy)<=12)return 'tap';
122
+ return null;
123
+ }
124
+ function location(raw,count,readingMode,geometry){
125
+ var start=raw && raw.start;if(!start || !validCfi(start.cfi))return null;
126
+ var displayed=start.displayed||{},chapter=Math.max(1,(Number(start.index)||0)+1);
127
+ var visualPage=mode(readingMode)==='paged' && Number.isFinite(displayed.page)?displayed.page:null;
128
+ var total=mode(readingMode)==='paged' && Number.isFinite(displayed.total)?displayed.total:null;
129
+ var atStart=Boolean(raw.atStart),atEnd=Boolean(raw.atEnd);
130
+ // Chromium may round a column offset down by a fraction of a CSS pixel.
131
+ // Keep epub.js authoritative outside a 1px boundary, and for RTL layouts.
132
+ if(visualPage!==null && total>0 && geometry && geometry.direction==='ltr' && geometry.pageWidth>0 && Number.isFinite(geometry.offset)){
133
+ var boundary=Math.round(geometry.offset/geometry.pageWidth);
134
+ if(Math.abs(geometry.offset-boundary*geometry.pageWidth)<=1){
135
+ visualPage=Math.min(total,Math.max(1,boundary+1));
136
+ atStart=chapter===(geometry.firstIndex ?? 0)+1 && visualPage===1;
137
+ atEnd=chapter===(geometry.lastIndex ?? count-1)+1 && visualPage===total;
138
+ }
139
+ }
140
+ var fraction=total?Math.max(0,(visualPage-1)/total):0;
141
+ 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};
142
+ }
143
+ function installResizeAnchor(manager,readCfi){
144
+ var original=manager.resize,anchor=null;
145
+ function resize(width,height,target){
146
+ if(validCfi(target))anchor=target;
147
+ if(!anchor){var current=readCfi();if(validCfi(current))anchor=current;}
148
+ // epub.js accepts a logical target as its third argument. Keep it across
149
+ // keyboard/dialog reflows instead of adopting each new viewport start.
150
+ return original.call(this,width,height,anchor || target);
151
+ }
152
+ manager.resize=resize;
153
+ return {cfi:function(){return anchor;},invalidate:function(){anchor=null;},destroy:function(){if(manager.resize===resize)manager.resize=original;anchor=null;}};
154
+ }
155
+ function install(contents,config){
156
+ var doc=contents.document,win=contents.window,start=null,blocked=false;
157
+ 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;}
158
+ function selected(){return Boolean(String(win.getSelection()||'').trim()) || config.blocked();}
159
+ function interactive(target){return Boolean(target && target.closest && target.closest('a,button,input,textarea,select,[contenteditable="true"],[data-papyrus-overlay]'));}
160
+ function begin(e){start=point(e);blocked=e.touches.length!==1 || selected() || interactive(e.target);}
161
+ 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();}
162
+ function move(e){if(e.touches.length!==1)blocked=true;invalidateScroll(point(e));}
163
+ 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);}
164
+ function cancel(){start=null;blocked=true;}
165
+ function link(e){if(config.current() && e.target?.closest?.('a') && config.interact)config.interact();}
166
+ 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);
167
+ return function(){doc.removeEventListener('touchstart',begin);doc.removeEventListener('touchmove',move);doc.removeEventListener('touchend',end);doc.removeEventListener('touchcancel',cancel);doc.removeEventListener('click',link);};
168
+ }
169
+ root.PapyrusEpubReader={mode:mode,validCfi:validCfi,options:options,gesture:gesture,location:location,installResizeAnchor:installResizeAnchor,install:install};
170
+ })(typeof window==='undefined'?globalThis:window);
171
+
172
+ /* @papyrus-epub-reader:end */
112
173
  /* @papyrus-epub-annotations:start */
113
174
  (function (root, factory) {
114
175
  if (typeof module === 'object' && module.exports) module.exports = factory();
@@ -187,6 +248,7 @@
187
248
  var color = /^#[0-9a-f]{6}$/i.test(annotation.color) ? annotation.color : '#FFC928';
188
249
  var style = annotation.markupStyle || annotation.type;
189
250
  var opacity = Number.isFinite(annotation.opacity) ? Math.max(0,Math.min(1,annotation.opacity)) : 0.35;
251
+ if(style === 'highlight')opacity=Math.min(0.35,opacity);
190
252
  rects.forEach(function(rect) {
191
253
  var mark = doc.createElement('div');
192
254
  var bodyRect=doc.body.getBoundingClientRect();
@@ -199,8 +261,10 @@
199
261
  if (hasNote) {
200
262
  var last = rects[rects.length-1], button = doc.createElement('button');
201
263
  button.textContent = '●'; button.setAttribute('aria-label',options.labels().comment || '');
202
- 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';
203
- var key = Math.floor(last.top/44);
264
+ var pageWidth=Math.max(44, options.pageWidth ? options.pageWidth() : win.innerWidth);
265
+ var bodyRect=doc.body.getBoundingClientRect(), column=Math.max(0,Math.floor((last.left-bodyRect.left)/pageWidth));
266
+ 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';
267
+ var key = column+":"+Math.floor(last.top/44);
204
268
  var neighbors=noteGroups.get(key);
205
269
  if(!neighbors){neighbors={button:button,ids:[]};noteGroups.set(key,neighbors);group.appendChild(button);}
206
270
  neighbors.ids.push(annotation.id);
@@ -221,10 +285,10 @@
221
285
  doc.addEventListener('click',tapMarkup);
222
286
  var observer = typeof win.ResizeObserver === 'function' ? new win.ResizeObserver(schedule) : null;
223
287
  if (observer) observer.observe(doc.body);
224
- win.addEventListener('resize',schedule); doc.addEventListener('load',schedule,true);
288
+ win.addEventListener('resize',schedule);win.addEventListener('scroll',schedule); doc.addEventListener('load',schedule,true);
225
289
  if (doc.fonts && doc.fonts.ready) doc.fonts.ready.then(schedule);
226
290
  schedule();
227
- 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();}};
291
+ 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();}};
228
292
  }
229
293
  return {validAnchor:validAnchor,recoverRange:recoverRange,resolveRange:resolveRange,selection:selection,install:install};
230
294
  });
@@ -237,6 +301,7 @@
237
301
  let currentType = null;
238
302
  let book = null;
239
303
  let rendition = null;
304
+ let epubResizeAnchor = null;
240
305
  let documentSessionId = null;
241
306
  let annotationDocumentId;
242
307
  let annotationLabels = {};
@@ -649,6 +714,7 @@
649
714
  };
650
715
 
651
716
  const clearViewer = () => {
717
+ if(epubResizeAnchor){epubResizeAnchor.destroy();epubResizeAnchor=null;}
652
718
  epubAnnotationRenderers.forEach(renderer => renderer.destroy());
653
719
  epubAnnotationRenderers.clear();
654
720
  if (epubInteractionCleanup) {
@@ -1305,26 +1371,57 @@
1305
1371
  pageCount,
1306
1372
  });
1307
1373
 
1374
+ if (book.locations?.generate) {
1375
+ const activeBook=book;
1376
+ activeBook.locations.generate(150).then(() => {
1377
+ if (isCurrentLoad() && activeBook===book && epubLocation) {
1378
+ epubLocation.progress=activeBook.locations.percentageFromCfi(epubLocation.cfi);
1379
+ sendEvent('EPUB_LOCATION',{documentSessionId,location:epubLocation});
1380
+ }
1381
+ }).catch(() => {});
1382
+ }
1383
+ return renderEpubRendition(loadId,generation,null);
1384
+ };
1385
+
1386
+ const renderEpubRendition = async (loadId,generation,target) => {
1387
+ const diagnosticPayload={loadId};
1388
+ const isCurrentLoad=()=>generation===epubLoadGeneration;
1389
+ if(epubInteractionCleanup)epubInteractionCleanup();
1390
+ if(rendition)rendition.destroy();
1308
1391
  clearViewer();
1309
- rendition = book.renderTo(viewer, {
1310
- manager: 'continuous',
1311
- allowScriptedContent: false,
1312
- width: '100%',
1313
- height: '100%',
1314
- flow: 'scrolled-continuous',
1315
- spread: 'none',
1316
- });
1392
+ rendition = book.renderTo(viewer, window.PapyrusEpubReader.options(epubViewMode === 'single' ? 'paged' : 'chapter-scroll'));
1317
1393
  sendEpubDiagnostic('epub.rendition.created', diagnosticPayload);
1318
1394
 
1319
1395
  if (rendition && rendition.hooks && rendition.hooks.content) {
1320
1396
  const session = documentSessionId;
1321
1397
  const activeRendition = rendition;
1322
1398
  rendition.hooks.content.register((contents) => {
1399
+ const removeGestureListeners = window.PapyrusEpubReader.install(contents, {
1400
+ mode: () => epubViewMode === 'single' ? 'paged' : 'chapter-scroll',
1401
+ rtl: () => book.package?.metadata?.direction === 'rtl' || book.spine?.direction === 'rtl',
1402
+ blocked: () => Boolean(nativeMenuSelection && String(nativeMenuSelection.contents?.window?.getSelection() || '').trim()),
1403
+ annotationHit: point => epubAnnotationRenderers.get(contents)?.hit(point.x,point.y),
1404
+ current: () => session === documentSessionId && activeRendition === rendition,
1405
+ interact: () => epubResizeAnchor?.invalidate(),
1406
+ tap: () => sendEvent('EPUB_CONTENT_TAP',{documentSessionId:session}),
1407
+ turn: direction => {
1408
+ epubNavigationQueue = epubNavigationQueue.catch(()=>{}).then(() => {
1409
+ if(session !== documentSessionId || activeRendition !== rendition)return;
1410
+ epubResizeAnchor?.invalidate();
1411
+ return direction<0?activeRendition.prev():activeRendition.next();
1412
+ });
1413
+ epubNavigationQueue.catch(error => sendEvent('EPUB_NAVIGATION_ERROR',{message:String(error)}));
1414
+ },
1415
+ });
1416
+ const gestureCleanup = () => {removeGestureListeners();epubSelectionCleanups.delete(gestureCleanup);};
1417
+ epubSelectionCleanups.add(gestureCleanup);
1418
+ contents.__papyrusGestureCleanup=gestureCleanup;
1323
1419
  const href = spineItems[contents.sectionIndex]?.href;
1324
1420
  if (href && window.PapyrusEpubAnnotations) {
1325
1421
  const renderer = window.PapyrusEpubAnnotations.install(contents, {
1326
1422
  href, get documentId() { return annotationDocumentId; },
1327
1423
  isCurrent: () => session === documentSessionId && activeRendition === rendition,
1424
+ pageWidth: () => activeRendition.layout?.width || viewer.clientWidth,
1328
1425
  annotations: () => epubAnnotations, labels: () => annotationLabels,
1329
1426
  onTap: (id, ids) => sendEvent("EPUB_ANNOTATION_TAP", {documentSessionId: session, id, ids}),
1330
1427
  });
@@ -1343,8 +1440,7 @@
1343
1440
  typeof selectionWindow.getSelection === 'function'
1344
1441
  ) {
1345
1442
  let selectionTimer = null;
1346
- let lastSelectionKey = '';
1347
- let lastSelectionAt = 0;
1443
+
1348
1444
  const reportSelection = () => {
1349
1445
  selectionTimer = null;
1350
1446
  if (session !== documentSessionId || activeRendition !== rendition) return;
@@ -1359,11 +1455,7 @@
1359
1455
  try { cfi = contents.cfiFromRange(range); } catch (_) { return; }
1360
1456
  const anchor = window.PapyrusEpubAnnotations?.selection(contents, cfi, href, annotationDocumentId);
1361
1457
  if (!anchor) return;
1362
- const key = anchor.cfiRange + ':' + anchor.quote;
1363
- const now = Date.now();
1364
- if (key === lastSelectionKey && now - lastSelectionAt < 750) return;
1365
- lastSelectionKey = key;
1366
- lastSelectionAt = now;
1458
+ if (nativeMenuSelection?.payload?.anchor?.cfiRange === anchor.cfiRange && nativeMenuSelection?.payload?.anchor?.quote === anchor.quote) return;
1367
1459
  const payload = {documentSessionId: session, text: anchor.quote,
1368
1460
  pageIndex: contents.sectionIndex + 1, anchor};
1369
1461
  nativeMenuSelection = {contents, payload};
@@ -1378,6 +1470,7 @@
1378
1470
  // The content document still emits selectionchange, so resolve its
1379
1471
  // live Range to a CFI before the native menu action can fire.
1380
1472
  if (text) reportSelection();
1473
+ else if (nativeMenuSelection?.contents === contents) nativeMenuSelection = null;
1381
1474
  };
1382
1475
  selectionDocument.addEventListener('selectionchange', handleSelectionChange);
1383
1476
  const selectionCleanup = () => {
@@ -1400,6 +1493,7 @@
1400
1493
  typeof rendition.hooks.unloaded.register === 'function'
1401
1494
  ) {
1402
1495
  rendition.hooks.unloaded.register((view) => {
1496
+ view?.contents?.__papyrusGestureCleanup?.();
1403
1497
  const cleanup = view && view.contents && view.contents.__papyrusSelectionCleanup;
1404
1498
  if (typeof cleanup === 'function') cleanup();
1405
1499
  const renderer = epubAnnotationRenderers.get(view?.contents);
@@ -1415,21 +1509,37 @@
1415
1509
  if (selectionSession !== documentSessionId || selectionRendition !== rendition) return;
1416
1510
  const href = spineItems[contents.sectionIndex]?.href;
1417
1511
  const anchor = href && window.PapyrusEpubAnnotations?.selection(contents,cfiRange,href,annotationDocumentId);
1418
- if (!anchor) return;
1512
+ if (!anchor || nativeMenuSelection?.payload?.anchor?.cfiRange === anchor.cfiRange && nativeMenuSelection?.payload?.anchor?.quote === anchor.quote) return;
1419
1513
  epubScrollDiagnostics && epubScrollDiagnostics.setSelectionActive(true);
1420
1514
  const payload = {documentSessionId: selectionSession, text: anchor.quote, pageIndex: contents.sectionIndex+1, anchor};
1421
1515
  nativeMenuSelection = {contents, payload};
1422
1516
  sendEvent('EPUB_TEXT_SELECTED', payload);
1423
1517
  });
1424
- ['rendered','resized','layout'].forEach(name => rendition.on(name, () => {
1518
+ ['rendered','resized','layout','relocated'].forEach(name => rendition.on(name, () => {
1425
1519
  if (selectionSession === documentSessionId && selectionRendition === rendition) epubAnnotationRenderers.forEach(renderer => renderer.schedule());
1426
1520
  }));
1427
1521
  rendition.on('relocated', (location) => {
1522
+ if (selectionSession !== documentSessionId || selectionRendition !== rendition) return;
1428
1523
  const start = location && location.start;
1429
1524
  let index = start && Number.isInteger(start.index) ? start.index : -1;
1430
1525
  if (index < 0 && start && start.href) index = getSpineIndexByHref(start.href);
1431
1526
  if (index < 0 || index >= spineItems.length) return;
1432
1527
  epubScrollDiagnostics && epubScrollDiagnostics.relocated(location);
1528
+ let pageGeometry;
1529
+ const manager=rendition.manager,layout=manager?.layout;
1530
+ if(epubViewMode==='single' && layout?.divisor===1 && !manager?.settings?.fullsize && manager?.settings?.direction==='ltr'){
1531
+ const visible=manager.visible?.();
1532
+ const view=visible?.find(view=>view.index===index);
1533
+ if(view && manager.container){
1534
+ pageGeometry={direction:'ltr',firstIndex:book.spine.first()?.index,lastIndex:book.spine.last()?.index,pageWidth:layout.pageWidth,offset:manager.container.getBoundingClientRect().left-view.position().left};
1535
+ }
1536
+ }
1537
+ epubLocation=window.PapyrusEpubReader.location(location,spineItems.length,epubViewMode==='single'?'paged':'chapter-scroll',pageGeometry);
1538
+ if(epubLocation){
1539
+ epubLocation.cfi=epubResizeAnchor?.cfi() || epubLocation.cfi;
1540
+ if(book.locations?.length?.()>0)epubLocation.progress=book.locations.percentageFromCfi(epubLocation.cfi);
1541
+ sendEvent('EPUB_LOCATION',{documentSessionId,location:epubLocation});
1542
+ }
1433
1543
  const nextPage = index + 1;
1434
1544
  if (nextPage === currentPage) return;
1435
1545
  currentPage = nextPage;
@@ -1437,21 +1547,23 @@
1437
1547
  });
1438
1548
  }
1439
1549
 
1550
+ applyEpubZoom();
1440
1551
  sendEpubDiagnostic('epub.display.start', diagnosticPayload);
1441
1552
  await withEpubStageTimeout(
1442
- displayEpubPage(0),
1553
+ rendition.display(target || spineItems[0]?.href),
1443
1554
  'rendition.display',
1444
1555
  diagnosticPayload,
1445
1556
  );
1446
1557
  if (!isCurrentLoad()) throw new Error('EPUB load superseded by a newer load');
1447
1558
  sendEpubDiagnostic('epub.display.end', diagnosticPayload);
1559
+ const resizeRendition=rendition;
1560
+ epubResizeAnchor=window.PapyrusEpubReader.installResizeAnchor(resizeRendition.manager,()=>resizeRendition.location?.start?.cfi);
1448
1561
  if (
1449
1562
  rendition &&
1450
1563
  rendition.manager &&
1451
1564
  typeof rendition.manager.on === 'function' &&
1452
1565
  typeof rendition.on === 'function'
1453
1566
  ) {
1454
- let touchStart = null;
1455
1567
  let lastScrollOffset = -1;
1456
1568
  const manager = rendition.manager;
1457
1569
  epubScrollDiagnostics = createEpubScrollDiagnostics(manager);
@@ -1526,48 +1638,19 @@
1526
1638
  return result;
1527
1639
  };
1528
1640
  }
1529
- const getTouchPoint = (event) => {
1530
- const touch =
1531
- (event && event.changedTouches && event.changedTouches[0]) ||
1532
- (event && event.touches && event.touches[0]);
1533
- if (!touch) return null;
1534
- const x = Number(touch.screenX ?? touch.clientX);
1535
- const y = Number(touch.screenY ?? touch.clientY);
1536
- return Number.isFinite(x) && Number.isFinite(y) ? { x, y } : null;
1537
- };
1538
- const handleScroll = (event) => {
1641
+ const handleScroll = event => {
1539
1642
  const offsetY = Math.max(0, Number(event && event.top) || 0);
1540
1643
  epubScrollDiagnostics && epubScrollDiagnostics.scroll(event);
1541
- if (Math.abs(offsetY - lastScrollOffset) < 1) return;
1542
- lastScrollOffset = offsetY;
1543
- sendEvent('VIEWER_SCROLL', { offsetY });
1544
- };
1545
- const handleTouchStart = (event) => {
1546
- touchStart = getTouchPoint(event);
1547
- epubScrollDiagnostics && epubScrollDiagnostics.touchStart(event);
1644
+ if (Math.abs(offsetY-lastScrollOffset)<1) return;
1645
+ lastScrollOffset=offsetY;
1646
+ sendEvent('VIEWER_SCROLL',{offsetY});
1548
1647
  };
1549
- const handleTouchMove = (event) => {
1550
- epubScrollDiagnostics && epubScrollDiagnostics.touchMove(event);
1551
- };
1552
- const handleTouchEnd = (event) => {
1553
- const end = getTouchPoint(event);
1554
- const start = touchStart;
1555
- touchStart = null;
1556
- const selectedText = event?.target?.ownerDocument?.defaultView?.getSelection?.()?.toString().trim() || '';
1557
- if (!selectedText && start && end && Math.hypot(end.x - start.x, end.y - start.y) <= 12) {
1558
- sendEvent('VIEWER_TAP', {});
1559
- }
1560
- epubScrollDiagnostics && epubScrollDiagnostics.touchEnd(event);
1561
- };
1562
-
1563
1648
  manager.on('scroll', handleScroll);
1564
1649
  const handleScrolled = (event) => {
1565
1650
  epubScrollDiagnostics && epubScrollDiagnostics.settled(event);
1566
1651
  };
1567
1652
  manager.on('scrolled', handleScrolled);
1568
- rendition.on('touchstart', handleTouchStart);
1569
- rendition.on('touchmove', handleTouchMove);
1570
- rendition.on('touchend', handleTouchEnd);
1653
+
1571
1654
  epubInteractionCleanup = () => {
1572
1655
  epubSelectionCleanups.forEach((cleanup) => cleanup());
1573
1656
  epubSelectionCleanups.clear();
@@ -1576,16 +1659,9 @@
1576
1659
  if (originalEnqueue) queue.enqueue = originalEnqueue;
1577
1660
  if (typeof manager.off === 'function') manager.off('scroll', handleScroll);
1578
1661
  if (typeof manager.off === 'function') manager.off('scrolled', handleScrolled);
1579
- if (typeof rendition.off === 'function') {
1580
- rendition.off('touchstart', handleTouchStart);
1581
- rendition.off('touchmove', handleTouchMove);
1582
- rendition.off('touchend', handleTouchEnd);
1583
- }
1584
1662
  epubScrollDiagnostics = null;
1585
1663
  };
1586
1664
  }
1587
- applyEpubZoom();
1588
-
1589
1665
  const outline = await withEpubStageTimeout(
1590
1666
  buildOutline(),
1591
1667
  'outline',
@@ -1599,6 +1675,8 @@
1599
1675
  return { pageCount, outline };
1600
1676
  };
1601
1677
 
1678
+ let epubNavigationQueue = Promise.resolve();
1679
+ let epubNavigationRequest = 0;
1602
1680
  const displayEpubPage = async (pageIndex) => {
1603
1681
  if (!rendition) return;
1604
1682
  const item = spineItems[pageIndex];
@@ -1608,9 +1686,19 @@
1608
1686
  target,
1609
1687
  pageIndex,
1610
1688
  });
1611
- await rendition.display(target);
1612
- currentPage = pageIndex + 1;
1613
- sendState();
1689
+ const activeRendition = rendition;
1690
+ const session = documentSessionId;
1691
+ const request = ++epubNavigationRequest;
1692
+ const navigation = epubNavigationQueue.catch(() => {}).then(async () => {
1693
+ if (request !== epubNavigationRequest || session !== documentSessionId || activeRendition !== rendition) return;
1694
+ epubResizeAnchor?.invalidate();
1695
+ await activeRendition.display(target);
1696
+ if (request !== epubNavigationRequest || session !== documentSessionId || activeRendition !== rendition) return;
1697
+ currentPage = pageIndex + 1;
1698
+ sendState();
1699
+ });
1700
+ epubNavigationQueue = navigation;
1701
+ await navigation;
1614
1702
  };
1615
1703
 
1616
1704
  const getTextContent = async (pageIndex) => {
@@ -1712,6 +1800,8 @@
1712
1800
  };
1713
1801
 
1714
1802
  let nativeMenuSelection = null;
1803
+ let epubViewMode = "single";
1804
+ let epubLocation = null;
1715
1805
 
1716
1806
  const handleCommand = async (message) => {
1717
1807
  const { id, kind, payload } = message;
@@ -1724,6 +1814,11 @@
1724
1814
  epubAnnotations = [];
1725
1815
  annotationDocumentId = undefined;
1726
1816
  zoom = 1.0;
1817
+ epubViewMode = payload.epubMode === "chapter-scroll" ? "continuous" : "single";
1818
+ epubLocation = null;
1819
+ nativeMenuSelection = null;
1820
+ epubNavigationRequest += 1;
1821
+ epubNavigationQueue = Promise.resolve();
1727
1822
 
1728
1823
  if (currentType === 'text') {
1729
1824
  const result = await loadText(
@@ -1778,17 +1873,24 @@
1778
1873
  epubAnnotationRenderers.forEach(renderer => renderer.schedule());
1779
1874
  } else {
1780
1875
  const annotation = epubAnnotations.find(a => a.id === payload.annotationId);
1781
- if (!annotation) throw new Error('Annotation not available');
1876
+ if (!annotation || (annotation.anchor.documentId && annotation.anchor.documentId !== annotationDocumentId)) throw new Error('Annotation not available for this document');
1782
1877
  // Navigation uses the CFI, never a synthetic percentage/page rectangle.
1783
1878
  const navigationSession = documentSessionId;
1784
1879
  const activeRendition = rendition;
1785
1880
  // Open the confirmed chapter first, resolve its quote, then navigate its actual range.
1786
- await activeRendition.display(annotation.anchor.href);
1787
- if(navigationSession !== documentSessionId || activeRendition !== rendition)throw new Error('Stale EPUB navigation');
1788
- const contents = activeRendition.getContents().find(c => spineItems[c.sectionIndex]?.href === annotation.anchor.href);
1789
- const range = contents && window.PapyrusEpubAnnotations.resolveRange(contents,annotation.anchor);
1790
- if(!range)throw new Error('EPUB anchor unresolved');
1791
- await activeRendition.display(contents.cfiFromRange(range));
1881
+ epubNavigationRequest += 1;
1882
+ const navigation = epubNavigationQueue.catch(() => {}).then(async () => {
1883
+ if(navigationSession !== documentSessionId || activeRendition !== rendition)throw new Error('Stale EPUB navigation');
1884
+ epubResizeAnchor?.invalidate();
1885
+ await activeRendition.display(annotation.anchor.href);
1886
+ if(navigationSession !== documentSessionId || activeRendition !== rendition)throw new Error('Stale EPUB navigation');
1887
+ const contents = activeRendition.getContents().find(c => spineItems[c.sectionIndex]?.href === annotation.anchor.href);
1888
+ const range = contents && window.PapyrusEpubAnnotations.resolveRange(contents,annotation.anchor);
1889
+ if(!range)throw new Error('EPUB anchor unresolved');
1890
+ await activeRendition.display(contents.cfiFromRange(range));
1891
+ });
1892
+ epubNavigationQueue = navigation;
1893
+ await navigation;
1792
1894
  }
1793
1895
  sendResponse(id,true,null); return;
1794
1896
  }
@@ -1815,12 +1917,55 @@
1815
1917
  return;
1816
1918
  }
1817
1919
 
1920
+ if (kind === 'epub-turn-page') {
1921
+ if (epubViewMode !== 'single') {sendResponse(id,true,null); return;}
1922
+ if (currentType !== 'epub' || payload.documentSessionId !== documentSessionId) throw new Error('Stale EPUB navigation');
1923
+ const active = rendition;
1924
+ epubNavigationQueue = epubNavigationQueue.catch(() => {}).then(() => {
1925
+ if (active !== rendition || payload.documentSessionId !== documentSessionId) return;
1926
+ epubResizeAnchor?.invalidate();
1927
+ return payload.direction < 0 ? active.prev() : active.next();
1928
+ });
1929
+ await epubNavigationQueue;
1930
+ sendResponse(id,true,null); return;
1931
+ }
1932
+
1933
+ if (kind === 'set-epub-view-mode') {
1934
+ if (currentType !== 'epub' || payload.documentSessionId !== documentSessionId) throw new Error('Stale EPUB layout session');
1935
+ const next=payload.epubMode ? (payload.epubMode==='paged'?'single':'continuous') : (payload.viewMode==='single'?'single':'continuous');
1936
+ {
1937
+ epubNavigationQueue=epubNavigationQueue.catch(()=>{}).then(async()=>{
1938
+ if(payload.documentSessionId!==documentSessionId || next===epubViewMode)return;
1939
+ const target=epubResizeAnchor?.cfi() || epubLocation?.cfi;
1940
+ epubViewMode=next;
1941
+ await renderEpubRendition('layout',epubLoadGeneration,target);
1942
+ });
1943
+ await epubNavigationQueue;
1944
+ }
1945
+ sendResponse(id,true,null);return;
1946
+ }
1947
+ if(kind === 'epub-go-to-location'){
1948
+ if(currentType!=='epub' || payload.documentSessionId!==documentSessionId || !window.PapyrusEpubReader.validCfi(payload.cfi))throw new Error('Invalid EPUB location');
1949
+ epubNavigationQueue=epubNavigationQueue.catch(()=>{}).then(()=>{if(payload.documentSessionId!==documentSessionId)return;epubResizeAnchor?.invalidate();return rendition.display(payload.cfi);});
1950
+ await epubNavigationQueue;sendResponse(id,true,null);return;
1951
+ }
1952
+
1818
1953
  if (kind === 'set-zoom') {
1819
- zoom = Math.max(0.5, Math.min(4.0, payload.zoom || 1.0));
1820
- applyZoom();
1821
- sendState();
1822
- sendResponse(id, true, { zoom });
1823
- return;
1954
+ const requestedZoom=Math.max(0.5,Math.min(4.0,payload.zoom || 1.0));
1955
+ if(currentType === 'epub') {
1956
+ const session=documentSessionId;
1957
+ epubNavigationQueue=epubNavigationQueue.catch(()=>{}).then(async()=>{
1958
+ if(session!==documentSessionId)return;
1959
+ const active=rendition;
1960
+ const cfi=epubResizeAnchor?.cfi() || epubLocation?.cfi;
1961
+ zoom=requestedZoom;applyEpubZoom();
1962
+ // Let the content CSS update before redisplaying the logical anchor.
1963
+ await new Promise(resolve=>(window.requestAnimationFrame || (callback=>setTimeout(callback,0)))(resolve));
1964
+ if(cfi && active===rendition && session===documentSessionId)await active.display(cfi);
1965
+ });
1966
+ await epubNavigationQueue;
1967
+ } else {zoom=requestedZoom;applyZoom();}
1968
+ sendState();sendResponse(id,true,{zoom});return;
1824
1969
  }
1825
1970
 
1826
1971
  if (kind === 'set-rotation') {
@@ -24,11 +24,13 @@ const runtimeBodyStart = runtime.indexOf("\n(function () {", runtime.indexOf(com
24
24
  if (runtimeBodyStart < 0) {
25
25
  throw new Error("Main runtime body not found");
26
26
  }
27
- const runtimeBody = runtime.slice(runtimeBodyStart + 1).trim();
27
+ const readerSource=await readFile(resolve(runtimeDirectory,"epubReader.js"),"utf8");
28
+ const readerRuntime=replaceMarkedSource(runtime,"/* @papyrus-epub-reader:start */","/* @papyrus-epub-reader:end */",`\n${readerSource}\n`);
29
+ const runtimeBody = readerRuntime.slice(readerRuntime.indexOf("\n(function () {",readerRuntime.indexOf(comicRuntimeEnd))+1).trim();
28
30
  await writeFile(
29
31
  runtimePath,
30
32
  replaceMarkedSource(
31
- runtime,
33
+ readerRuntime,
32
34
  "/* @papyrus-comic-runtime:start */",
33
35
  "/* @papyrus-comic-runtime:end */",
34
36
  `\n${source.trim()}\n`
@@ -37,12 +39,13 @@ await writeFile(
37
39
 
38
40
  const htmlPath = resolve(runtimeDirectory, "index.html");
39
41
  const html = await readFile(htmlPath, "utf8");
40
- const htmlWithComicRuntime = replaceMarkedSource(
42
+ let htmlWithComicRuntime = replaceMarkedSource(
41
43
  html,
42
44
  "<!-- @papyrus-comic-runtime:start -->",
43
45
  "<!-- @papyrus-comic-runtime:end -->",
44
46
  `\n <script>\n${source}\n </script>\n`
45
47
  );
48
+ htmlWithComicRuntime=htmlWithComicRuntime.replace(/<!-- @papyrus-epub-reader:start -->[\s\S]*?<!-- @papyrus-epub-reader:end -->\n?/g,"");
46
49
  const htmlComicRuntimeEnd = "<!-- @papyrus-comic-runtime:end -->";
47
50
  const htmlBodyStart = htmlWithComicRuntime.indexOf(
48
51
  "<script>",