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

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,127 @@
109
109
  /* @papyrus-comic-runtime:end */
110
110
 
111
111
  (function () {
112
+ /* @papyrus-epub-annotations:start */
113
+ (function (root, factory) {
114
+ if (typeof module === 'object' && module.exports) module.exports = factory();
115
+ else root.PapyrusEpubAnnotations = factory();
116
+ })(typeof window !== 'undefined' ? window : globalThis, function () {
117
+ function validAnchor(anchor) {
118
+ return anchor && anchor.version === 1 && anchor.kind === 'epub-cfi' &&
119
+ typeof anchor.cfiRange === 'string' && anchor.cfiRange.length <= 4096 && /^epubcfi\([^\n<>]+\)$/.test(anchor.cfiRange) &&
120
+ typeof anchor.href === 'string' && !/^(javascript|data):/i.test(anchor.href) &&
121
+ typeof anchor.quote === 'string' && anchor.quote.length > 0 && anchor.quote.length <= 100000;
122
+ }
123
+ function textNodes(document) {
124
+ var walker = document.createTreeWalker(document.body, 4);
125
+ var nodes = [], text = '', node;
126
+ while ((node = walker.nextNode())) {
127
+ if (node.parentElement.closest('script,style,[data-papyrus-overlay]')) continue;
128
+ nodes.push({node:node, start:text.length}); text += node.textContent;
129
+ }
130
+ return {nodes:nodes,text:text};
131
+ }
132
+ function recoverRange(document, anchor) {
133
+ var source = textNodes(document), match = null, start = source.text.indexOf(anchor.quote);
134
+ while (start >= 0) {
135
+ var end = start + anchor.quote.length;
136
+ if ((!anchor.prefix || source.text.slice(Math.max(0,start-anchor.prefix.length),start) === anchor.prefix) &&
137
+ (!anchor.suffix || source.text.slice(end,end+anchor.suffix.length) === anchor.suffix)) {
138
+ if (match) return null; match = {start:start,end:end};
139
+ }
140
+ start = source.text.indexOf(anchor.quote,start+1);
141
+ }
142
+ if (!match) return null;
143
+ var range = document.createRange();
144
+ var first = source.nodes.find(function(n){return n.start+n.node.length > match.start;});
145
+ var last = source.nodes.find(function(n){return n.start+n.node.length >= match.end;});
146
+ if (!first || !last) return null;
147
+ range.setStart(first.node,match.start-first.start); range.setEnd(last.node,match.end-last.start);
148
+ return range;
149
+ }
150
+ function resolveRange(contents, anchor) {
151
+ if (!validAnchor(anchor)) return null;
152
+ try { var range = contents.range(anchor.cfiRange); if (range && range.toString() === anchor.quote) return range; } catch (_) {}
153
+ return recoverRange(contents.document,anchor);
154
+ }
155
+ function selection(contents, cfiRange, href, documentId) {
156
+ var range;
157
+ try { range = contents.range(cfiRange); } catch (_) { return null; }
158
+ if (!range || !range.toString()) return null;
159
+ var before = contents.document.createRange(); before.selectNodeContents(contents.document.body); before.setEnd(range.startContainer,range.startOffset);
160
+ var after = contents.document.createRange(); after.selectNodeContents(contents.document.body); after.setStart(range.endContainer,range.endOffset);
161
+ 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)};
162
+ return validAnchor(anchor) ? anchor : null;
163
+ }
164
+ function install(contents, options) {
165
+ var doc = contents.document, win = contents.window, alive = true, frame = 0;
166
+ var overlay = doc.createElement('div'); overlay.dataset.papyrusOverlay = 'annotations';
167
+ overlay.style.cssText = 'position:absolute;left:0;top:0;width:100%;height:0;pointer-events:none;z-index:3';
168
+ var originalPosition=doc.body.style.position;
169
+ if(win.getComputedStyle(doc.body).position==='static')doc.body.style.position='relative';
170
+ doc.body.appendChild(overlay);
171
+ var registry = new Map(), hitRegions = [];
172
+ function render() {
173
+ frame = 0; if (!alive || !options.isCurrent()) return;
174
+ var keep = new Set(), noteGroups = new Map();
175
+ hitRegions = [];
176
+ options.annotations().forEach(function(annotation) {
177
+ var anchor = annotation.anchor;
178
+ if (!validAnchor(anchor) || anchor.href !== options.href || anchor.documentId && options.documentId && anchor.documentId !== options.documentId) return;
179
+ var range = resolveRange(contents,anchor); if (!range) return;
180
+ var rects = Array.from(range.getClientRects()).filter(function(r){return r.width>0 && r.height>0;});
181
+ if (!rects.length) return;
182
+ keep.add(annotation.id);
183
+ var group = registry.get(annotation.id);
184
+ 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'}); } }
185
+ group.replaceChildren();
186
+ hitRegions.push({id:annotation.id,rects:rects});
187
+ var color = /^#[0-9a-f]{6}$/i.test(annotation.color) ? annotation.color : '#FFC928';
188
+ var style = annotation.markupStyle || annotation.type;
189
+ var opacity = Number.isFinite(annotation.opacity) ? Math.max(0,Math.min(1,annotation.opacity)) : 0.35;
190
+ rects.forEach(function(rect) {
191
+ var mark = doc.createElement('div');
192
+ var bodyRect=doc.body.getBoundingClientRect();
193
+ var height = style === 'highlight' ? rect.height : 2;
194
+ var y = style === 'strikeout' ? rect.top+rect.height/2 : style === 'underline' ? rect.bottom-2 : rect.top;
195
+ 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;
196
+ if (['highlight','underline','strikeout'].includes(style)) group.appendChild(mark);
197
+ });
198
+ var hasNote = annotation.noteContent !== undefined || annotation.type === 'comment' || annotation.type === 'text';
199
+ if (hasNote) {
200
+ var last = rects[rects.length-1], button = doc.createElement('button');
201
+ 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);
204
+ var neighbors=noteGroups.get(key);
205
+ if(!neighbors){neighbors={button:button,ids:[]};noteGroups.set(key,neighbors);group.appendChild(button);}
206
+ neighbors.ids.push(annotation.id);
207
+ }
208
+ });
209
+ noteGroups.forEach(function(group){
210
+ group.button.textContent=group.ids.length>1?String(group.ids.length):'●';
211
+ group.button.addEventListener('click',function(e){e.stopPropagation();if(options.isCurrent())options.onTap(group.ids[0],group.ids);});
212
+ });
213
+ registry.forEach(function(group,id){if (!keep.has(id)) {group.remove();registry.delete(id);}});
214
+ }
215
+ function schedule() { if (alive && !frame) frame = win.requestAnimationFrame(render); }
216
+ function tapMarkup(event) {
217
+ if(!alive || !options.isCurrent() || String(win.getSelection() || '').length || event.target.closest('[data-papyrus-overlay]'))return;
218
+ 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;});});
219
+ if(hit) {event.stopPropagation();options.onTap(hit.id);}
220
+ }
221
+ doc.addEventListener('click',tapMarkup);
222
+ var observer = typeof win.ResizeObserver === 'function' ? new win.ResizeObserver(schedule) : null;
223
+ if (observer) observer.observe(doc.body);
224
+ win.addEventListener('resize',schedule); doc.addEventListener('load',schedule,true);
225
+ if (doc.fonts && doc.fonts.ready) doc.fonts.ready.then(schedule);
226
+ 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();}};
228
+ }
229
+ return {validAnchor:validAnchor,recoverRange:recoverRange,resolveRange:resolveRange,selection:selection,install:install};
230
+ });
231
+
232
+ /* @papyrus-epub-annotations:end */
112
233
  const viewer = document.getElementById('viewer');
113
234
  const DEFAULT_FONT_SIZE = 16;
114
235
  const TEXT_PAGE_CHUNK = 1600;
@@ -116,6 +237,11 @@
116
237
  let currentType = null;
117
238
  let book = null;
118
239
  let rendition = null;
240
+ let documentSessionId = null;
241
+ let annotationDocumentId;
242
+ let annotationLabels = {};
243
+ let epubAnnotations = [];
244
+ const epubAnnotationRenderers = new Map();
119
245
  let spineItems = [];
120
246
  let textPages = [];
121
247
  let textContainer = null;
@@ -523,6 +649,8 @@
523
649
  };
524
650
 
525
651
  const clearViewer = () => {
652
+ epubAnnotationRenderers.forEach(renderer => renderer.destroy());
653
+ epubAnnotationRenderers.clear();
526
654
  if (epubInteractionCleanup) {
527
655
  epubInteractionCleanup();
528
656
  epubInteractionCleanup = null;
@@ -1180,6 +1308,7 @@
1180
1308
  clearViewer();
1181
1309
  rendition = book.renderTo(viewer, {
1182
1310
  manager: 'continuous',
1311
+ allowScriptedContent: false,
1183
1312
  width: '100%',
1184
1313
  height: '100%',
1185
1314
  flow: 'scrolled-continuous',
@@ -1188,10 +1317,22 @@
1188
1317
  sendEpubDiagnostic('epub.rendition.created', diagnosticPayload);
1189
1318
 
1190
1319
  if (rendition && rendition.hooks && rendition.hooks.content) {
1320
+ const session = documentSessionId;
1321
+ const activeRendition = rendition;
1191
1322
  rendition.hooks.content.register((contents) => {
1323
+ const href = spineItems[contents.sectionIndex]?.href;
1324
+ if (href && window.PapyrusEpubAnnotations) {
1325
+ const renderer = window.PapyrusEpubAnnotations.install(contents, {
1326
+ href, get documentId() { return annotationDocumentId; },
1327
+ isCurrent: () => session === documentSessionId && activeRendition === rendition,
1328
+ annotations: () => epubAnnotations, labels: () => annotationLabels,
1329
+ onTap: (id, ids) => sendEvent("EPUB_ANNOTATION_TAP", {documentSessionId: session, id, ids}),
1330
+ });
1331
+ epubAnnotationRenderers.set(contents,renderer);
1332
+ }
1192
1333
  const frame = contents && contents.document ? contents.document.defaultView.frameElement : null;
1193
1334
  if (frame) {
1194
- frame.setAttribute('sandbox', 'allow-scripts allow-same-origin');
1335
+ frame.setAttribute('sandbox', 'allow-same-origin');
1195
1336
  }
1196
1337
  const selectionDocument = contents && contents.document;
1197
1338
  const selectionWindow = contents && contents.window;
@@ -1201,13 +1342,46 @@
1201
1342
  selectionWindow &&
1202
1343
  typeof selectionWindow.getSelection === 'function'
1203
1344
  ) {
1345
+ let selectionTimer = null;
1346
+ let lastSelectionKey = '';
1347
+ let lastSelectionAt = 0;
1348
+ const reportSelection = () => {
1349
+ selectionTimer = null;
1350
+ if (session !== documentSessionId || activeRendition !== rendition) return;
1351
+ const selected = selectionWindow.getSelection();
1352
+ if (!selected || selected.isCollapsed || selected.rangeCount === 0 ||
1353
+ typeof contents.cfiFromRange !== 'function') return;
1354
+ const range = selected.getRangeAt(0).cloneRange();
1355
+ if (!range.toString().trim()) return;
1356
+ const href = spineItems[contents.sectionIndex]?.href;
1357
+ if (!href) return;
1358
+ let cfi;
1359
+ try { cfi = contents.cfiFromRange(range); } catch (_) { return; }
1360
+ const anchor = window.PapyrusEpubAnnotations?.selection(contents, cfi, href, annotationDocumentId);
1361
+ 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;
1367
+ const payload = {documentSessionId: session, text: anchor.quote,
1368
+ pageIndex: contents.sectionIndex + 1, anchor};
1369
+ nativeMenuSelection = {contents, payload};
1370
+ sendEvent('EPUB_TEXT_SELECTED', payload);
1371
+ };
1204
1372
  const handleSelectionChange = () => {
1205
1373
  const selection = selectionWindow.getSelection();
1206
1374
  const text = selection ? selection.toString().trim() : '';
1207
1375
  epubScrollDiagnostics && epubScrollDiagnostics.setSelectionActive(Boolean(text));
1376
+ if (selectionTimer !== null) clearTimeout(selectionTimer);
1377
+ // epub.js "selected" is not reliable on every iOS WKWebView path.
1378
+ // The content document still emits selectionchange, so resolve its
1379
+ // live Range to a CFI before the native menu action can fire.
1380
+ if (text) reportSelection();
1208
1381
  };
1209
1382
  selectionDocument.addEventListener('selectionchange', handleSelectionChange);
1210
1383
  const selectionCleanup = () => {
1384
+ if (selectionTimer !== null) clearTimeout(selectionTimer);
1211
1385
  selectionDocument.removeEventListener('selectionchange', handleSelectionChange);
1212
1386
  epubSelectionCleanups.delete(selectionCleanup);
1213
1387
  };
@@ -1228,22 +1402,30 @@
1228
1402
  rendition.hooks.unloaded.register((view) => {
1229
1403
  const cleanup = view && view.contents && view.contents.__papyrusSelectionCleanup;
1230
1404
  if (typeof cleanup === 'function') cleanup();
1405
+ const renderer = epubAnnotationRenderers.get(view?.contents);
1406
+ if (renderer) renderer.destroy();
1407
+ epubAnnotationRenderers.delete(view?.contents);
1231
1408
  });
1232
1409
  }
1233
1410
 
1234
1411
  if (rendition && typeof rendition.on === 'function') {
1412
+ const selectionSession = documentSessionId;
1413
+ const selectionRendition = rendition;
1235
1414
  rendition.on('selected', (cfiRange, contents) => {
1236
- const selection = contents && contents.window ? contents.window.getSelection() : null;
1237
- const text = selection ? selection.toString().trim() : '';
1238
- epubScrollDiagnostics && epubScrollDiagnostics.setSelectionActive(Boolean(text));
1239
- if (text) {
1240
- sendEvent('TEXT_SELECTED', { text, pageIndex: Math.max(0, currentPage - 1) });
1241
- }
1242
- if (rendition && rendition.annotations && typeof rendition.annotations.remove === 'function') {
1243
- rendition.annotations.remove(cfiRange, 'highlight');
1244
- }
1415
+ if (selectionSession !== documentSessionId || selectionRendition !== rendition) return;
1416
+ const href = spineItems[contents.sectionIndex]?.href;
1417
+ const anchor = href && window.PapyrusEpubAnnotations?.selection(contents,cfiRange,href,annotationDocumentId);
1418
+ if (!anchor) return;
1419
+ epubScrollDiagnostics && epubScrollDiagnostics.setSelectionActive(true);
1420
+ const payload = {documentSessionId: selectionSession, text: anchor.quote, pageIndex: contents.sectionIndex+1, anchor};
1421
+ nativeMenuSelection = {contents, payload};
1422
+ sendEvent('EPUB_TEXT_SELECTED', payload);
1245
1423
  });
1424
+ ['rendered','resized','layout'].forEach(name => rendition.on(name, () => {
1425
+ if (selectionSession === documentSessionId && selectionRendition === rendition) epubAnnotationRenderers.forEach(renderer => renderer.schedule());
1426
+ }));
1246
1427
  rendition.on('relocated', (location) => {
1428
+ if (selectionSession !== documentSessionId || selectionRendition !== rendition) return;
1247
1429
  const start = location && location.start;
1248
1430
  let index = start && Number.isInteger(start.index) ? start.index : -1;
1249
1431
  if (index < 0 && start && start.href) index = getSpineIndexByHref(start.href);
@@ -1362,17 +1544,27 @@
1362
1544
  sendEvent('VIEWER_SCROLL', { offsetY });
1363
1545
  };
1364
1546
  const handleTouchStart = (event) => {
1365
- touchStart = getTouchPoint(event);
1547
+ touchStart = event?.touches?.length > 1 ? null : getTouchPoint(event);
1366
1548
  epubScrollDiagnostics && epubScrollDiagnostics.touchStart(event);
1367
1549
  };
1368
1550
  const handleTouchMove = (event) => {
1551
+ if (event?.touches?.length > 1) touchStart = null;
1369
1552
  epubScrollDiagnostics && epubScrollDiagnostics.touchMove(event);
1370
1553
  };
1371
1554
  const handleTouchEnd = (event) => {
1372
1555
  const end = getTouchPoint(event);
1373
1556
  const start = touchStart;
1374
1557
  touchStart = null;
1375
- if (start && end && Math.hypot(end.x - start.x, end.y - start.y) <= 12) {
1558
+ const selectedText = event?.target?.ownerDocument?.defaultView?.getSelection?.()?.toString().trim() || '';
1559
+ if (!selectedText && start && end && epubViewMode === 'single' && Math.abs(end.x-start.x) >= 48 && Math.abs(end.x-start.x) > Math.abs(end.y-start.y)*1.5) {
1560
+ const active = rendition;
1561
+ const interactionSession = documentSessionId;
1562
+ epubNavigationQueue = epubNavigationQueue.catch(() => {}).then(() => {
1563
+ if (active !== rendition || interactionSession !== documentSessionId) return;
1564
+ return end.x < start.x ? active.next() : active.prev();
1565
+ });
1566
+ epubNavigationQueue.catch(error => sendEvent('EPUB_NAVIGATION_ERROR', {message:String(error)}));
1567
+ } else if (!selectedText && start && end && Math.hypot(end.x - start.x, end.y - start.y) <= 12) {
1376
1568
  sendEvent('VIEWER_TAP', {});
1377
1569
  }
1378
1570
  epubScrollDiagnostics && epubScrollDiagnostics.touchEnd(event);
@@ -1417,6 +1609,8 @@
1417
1609
  return { pageCount, outline };
1418
1610
  };
1419
1611
 
1612
+ let epubNavigationQueue = Promise.resolve();
1613
+ let epubNavigationRequest = 0;
1420
1614
  const displayEpubPage = async (pageIndex) => {
1421
1615
  if (!rendition) return;
1422
1616
  const item = spineItems[pageIndex];
@@ -1426,9 +1620,18 @@
1426
1620
  target,
1427
1621
  pageIndex,
1428
1622
  });
1429
- await rendition.display(target);
1430
- currentPage = pageIndex + 1;
1431
- sendState();
1623
+ const activeRendition = rendition;
1624
+ const session = documentSessionId;
1625
+ const request = ++epubNavigationRequest;
1626
+ const navigation = epubNavigationQueue.catch(() => {}).then(async () => {
1627
+ if (request !== epubNavigationRequest || session !== documentSessionId || activeRendition !== rendition) return;
1628
+ await activeRendition.display(target);
1629
+ if (request !== epubNavigationRequest || session !== documentSessionId || activeRendition !== rendition) return;
1630
+ currentPage = pageIndex + 1;
1631
+ sendState();
1632
+ });
1633
+ epubNavigationQueue = navigation;
1634
+ await navigation;
1432
1635
  };
1433
1636
 
1434
1637
  const getTextContent = async (pageIndex) => {
@@ -1529,6 +1732,9 @@
1529
1732
  }
1530
1733
  };
1531
1734
 
1735
+ let nativeMenuSelection = null;
1736
+ let epubViewMode = "continuous";
1737
+
1532
1738
  const handleCommand = async (message) => {
1533
1739
  const { id, kind, payload } = message;
1534
1740
 
@@ -1536,7 +1742,13 @@
1536
1742
  if (kind === 'load') {
1537
1743
  const generation = (epubLoadGeneration += 1);
1538
1744
  currentType = payload.type;
1745
+ documentSessionId = payload.documentSessionId || null;
1746
+ epubAnnotations = [];
1747
+ annotationDocumentId = undefined;
1539
1748
  zoom = 1.0;
1749
+ epubViewMode = "continuous";
1750
+ epubNavigationRequest += 1;
1751
+ epubNavigationQueue = Promise.resolve();
1540
1752
 
1541
1753
  if (currentType === 'text') {
1542
1754
  const result = await loadText(
@@ -1576,6 +1788,42 @@
1576
1788
  throw new Error('Unsupported document type');
1577
1789
  }
1578
1790
 
1791
+ if (kind === 'epub-annotations' || kind === 'epub-annotation-navigate') {
1792
+ if (currentType !== 'epub' || !documentSessionId || payload.documentSessionId !== documentSessionId) throw new Error('Stale EPUB annotation session');
1793
+ if (kind === 'epub-annotations') {
1794
+ const incoming = payload.annotations;
1795
+ 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');
1796
+ const ids = new Set();
1797
+ epubAnnotations = incoming.filter(a => {
1798
+ if (ids.has(a.id) || !window.PapyrusEpubAnnotations.validAnchor(a.anchor)) return false;
1799
+ ids.add(a.id); return true;
1800
+ });
1801
+ annotationDocumentId = typeof payload.documentId === 'string' ? payload.documentId : undefined;
1802
+ annotationLabels = {comment: typeof payload.labels?.comment === 'string' ? payload.labels.comment.slice(0,128) : ''};
1803
+ epubAnnotationRenderers.forEach(renderer => renderer.schedule());
1804
+ } else {
1805
+ const annotation = epubAnnotations.find(a => a.id === payload.annotationId);
1806
+ if (!annotation || (annotation.anchor.documentId && annotation.anchor.documentId !== annotationDocumentId)) throw new Error('Annotation not available for this document');
1807
+ // Navigation uses the CFI, never a synthetic percentage/page rectangle.
1808
+ const navigationSession = documentSessionId;
1809
+ const activeRendition = rendition;
1810
+ // Open the confirmed chapter first, resolve its quote, then navigate its actual range.
1811
+ epubNavigationRequest += 1;
1812
+ const navigation = epubNavigationQueue.catch(() => {}).then(async () => {
1813
+ if(navigationSession !== documentSessionId || activeRendition !== rendition)throw new Error('Stale EPUB navigation');
1814
+ await activeRendition.display(annotation.anchor.href);
1815
+ if(navigationSession !== documentSessionId || activeRendition !== rendition)throw new Error('Stale EPUB navigation');
1816
+ const contents = activeRendition.getContents().find(c => spineItems[c.sectionIndex]?.href === annotation.anchor.href);
1817
+ const range = contents && window.PapyrusEpubAnnotations.resolveRange(contents,annotation.anchor);
1818
+ if(!range)throw new Error('EPUB anchor unresolved');
1819
+ await activeRendition.display(contents.cfiFromRange(range));
1820
+ });
1821
+ epubNavigationQueue = navigation;
1822
+ await navigation;
1823
+ }
1824
+ sendResponse(id,true,null); return;
1825
+ }
1826
+
1579
1827
  if (kind === 'go-to-page') {
1580
1828
  const page = Math.max(1, payload.page || 1);
1581
1829
  if (currentType === 'text') {
@@ -1598,6 +1846,33 @@
1598
1846
  return;
1599
1847
  }
1600
1848
 
1849
+ if (kind === 'epub-turn-page') {
1850
+ if (epubViewMode !== 'single') {sendResponse(id,true,null); return;}
1851
+ if (currentType !== 'epub' || payload.documentSessionId !== documentSessionId) throw new Error('Stale EPUB navigation');
1852
+ const active = rendition;
1853
+ epubNavigationQueue = epubNavigationQueue.catch(() => {}).then(() => {
1854
+ if (active !== rendition || payload.documentSessionId !== documentSessionId) return;
1855
+ return payload.direction < 0 ? active.prev() : active.next();
1856
+ });
1857
+ await epubNavigationQueue;
1858
+ sendResponse(id,true,null); return;
1859
+ }
1860
+
1861
+ if (kind === 'set-epub-view-mode') {
1862
+ if (currentType !== 'epub' || payload.documentSessionId !== documentSessionId) throw new Error('Stale EPUB layout session');
1863
+ epubViewMode = payload.viewMode === 'single' ? 'single' : 'continuous';
1864
+ const active = rendition;
1865
+ epubNavigationQueue = epubNavigationQueue.catch(() => {}).then(() => {
1866
+ if (active !== rendition || payload.documentSessionId !== documentSessionId) return;
1867
+ const flow = epubViewMode === 'single' ? 'paginated' : 'scrolled-continuous';
1868
+ if (active.settings.flow === flow) return;
1869
+ active.flow(flow);
1870
+ return active.q.enqueue(() => Promise.resolve());
1871
+ });
1872
+ await epubNavigationQueue;
1873
+ sendResponse(id,true,null); return;
1874
+ }
1875
+
1601
1876
  if (kind === 'set-zoom') {
1602
1877
  zoom = Math.max(0.5, Math.min(4.0, payload.zoom || 1.0));
1603
1878
  applyZoom();
@@ -1745,6 +2020,26 @@
1745
2020
  return;
1746
2021
  }
1747
2022
 
2023
+ if (message.type === 'epub-selection-action') {
2024
+ const selected = nativeMenuSelection;
2025
+ if (!selected || message.documentSessionId !== documentSessionId ||
2026
+ selected.payload.documentSessionId !== documentSessionId) return;
2027
+ if (message.key === 'selectAll') {
2028
+ const doc = selected.contents.document;
2029
+ const range = doc.createRange();
2030
+ range.selectNodeContents(doc.body);
2031
+ const selection = selected.contents.window.getSelection();
2032
+ selection.removeAllRanges();
2033
+ selection.addRange(range);
2034
+ return;
2035
+ }
2036
+ if (!['copy','define','highlight','underline','strikeout','comment'].includes(message.key)) return;
2037
+ sendEvent('EPUB_SELECTION_ACTION', {...selected.payload, action: message.key});
2038
+ selected.contents.window.getSelection()?.removeAllRanges();
2039
+ nativeMenuSelection = null;
2040
+ return;
2041
+ }
2042
+
1748
2043
  if (!message.kind) return;
1749
2044
  handleCommand(message);
1750
2045
  };
@@ -17,7 +17,8 @@ const replaceMarkedSource = (content, start, end, replacement) => {
17
17
  };
18
18
 
19
19
  const runtimePath = resolve(runtimeDirectory, "runtime.js");
20
- const runtime = await readFile(runtimePath, "utf8");
20
+ const epubSource = await readFile(resolve(runtimeDirectory,"epubAnnotations.js"),"utf8");
21
+ const runtime = replaceMarkedSource(await readFile(runtimePath, "utf8"), "/* @papyrus-epub-annotations:start */", "/* @papyrus-epub-annotations:end */", `\n${epubSource}\n`);
21
22
  const comicRuntimeEnd = "/* @papyrus-comic-runtime:end */";
22
23
  const runtimeBodyStart = runtime.indexOf("\n(function () {", runtime.indexOf(comicRuntimeEnd));
23
24
  if (runtimeBodyStart < 0) {