@papyrus-sdk/ui-react-native 0.2.20 → 0.2.21

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.
@@ -1,3 +1,113 @@
1
+ /* @papyrus-comic-runtime:start */
2
+ (function (root, factory) {
3
+ if (typeof module === "object" && module.exports) {
4
+ module.exports = factory();
5
+ } else {
6
+ root.PapyrusComicRuntime = Object.assign(
7
+ factory(),
8
+ root.PapyrusComicRuntime || {}
9
+ );
10
+ }
11
+ })(typeof self === "object" ? self : this, function () {
12
+ var IMAGE_EXTENSIONS = /\.(?:gif|jpe?g|png|svg|webp)$/i;
13
+
14
+ function isComicImageName(name) {
15
+ return typeof name === "string" && IMAGE_EXTENSIONS.test(name);
16
+ }
17
+
18
+ function naturalCompare(left, right) {
19
+ var leftParts = String(left).split(/(\d+)/);
20
+ var rightParts = String(right).split(/(\d+)/);
21
+ var length = Math.max(leftParts.length, rightParts.length);
22
+
23
+ for (var index = 0; index < length; index += 1) {
24
+ var leftPart = leftParts[index] || "";
25
+ var rightPart = rightParts[index] || "";
26
+ if (leftPart === rightPart) continue;
27
+
28
+ var leftNumber = /^\d+$/.test(leftPart) ? Number(leftPart) : null;
29
+ var rightNumber = /^\d+$/.test(rightPart) ? Number(rightPart) : null;
30
+ if (leftNumber !== null && rightNumber !== null) {
31
+ return leftNumber - rightNumber;
32
+ }
33
+ return leftPart.localeCompare(rightPart, undefined, {
34
+ numeric: true,
35
+ sensitivity: "base",
36
+ });
37
+ }
38
+
39
+ return 0;
40
+ }
41
+
42
+ function sortComicPageNames(names) {
43
+ return names.slice().sort(naturalCompare);
44
+ }
45
+
46
+ function patchCbrWorkerSource(workerSource, wasmUrl) {
47
+ return workerSource.replace(
48
+ /new URL\("libarchive\.wasm",import\.meta\.url\)\.href/g,
49
+ JSON.stringify(wasmUrl)
50
+ );
51
+ }
52
+
53
+ function getComicPreviewSize(width, height, maxWidth, maxHeight) {
54
+ var safeWidth = Math.max(1, Number(width) || 1);
55
+ var safeHeight = Math.max(1, Number(height) || 1);
56
+ var widthLimit = maxWidth || 240;
57
+ var heightLimit = maxHeight || 360;
58
+ var scale = Math.min(1, widthLimit / safeWidth, heightLimit / safeHeight);
59
+ return {
60
+ width: Math.max(1, Math.round(safeWidth * scale)),
61
+ height: Math.max(1, Math.round(safeHeight * scale)),
62
+ };
63
+ }
64
+
65
+ function getComicPageAspectRatio(width, height) {
66
+ var safeWidth = Math.max(1, Number(width) || 1);
67
+ var safeHeight = Math.max(1, Number(height) || 1);
68
+ return safeWidth + " / " + safeHeight;
69
+ }
70
+
71
+ function getProtectedComicPageIndexes(
72
+ currentPage,
73
+ visiblePageIndexes,
74
+ loadingPageIndexes
75
+ ) {
76
+ var protectedIndexes = new Set([
77
+ currentPage - 1,
78
+ currentPage,
79
+ currentPage - 2,
80
+ ]);
81
+ (visiblePageIndexes || []).forEach(function (pageIndex) {
82
+ protectedIndexes.add(pageIndex);
83
+ });
84
+ (loadingPageIndexes || []).forEach(function (pageIndex) {
85
+ protectedIndexes.add(pageIndex);
86
+ });
87
+ return protectedIndexes;
88
+ }
89
+
90
+ function isCurrentComicPageLoad(
91
+ loadGeneration,
92
+ currentGeneration,
93
+ entry,
94
+ currentEntry
95
+ ) {
96
+ return loadGeneration === currentGeneration && entry === currentEntry;
97
+ }
98
+
99
+ return {
100
+ isComicImageName: isComicImageName,
101
+ sortComicPageNames: sortComicPageNames,
102
+ patchCbrWorkerSource: patchCbrWorkerSource,
103
+ getComicPreviewSize: getComicPreviewSize,
104
+ getComicPageAspectRatio: getComicPageAspectRatio,
105
+ getProtectedComicPageIndexes: getProtectedComicPageIndexes,
106
+ isCurrentComicPageLoad: isCurrentComicPageLoad,
107
+ };
108
+ });
109
+ /* @papyrus-comic-runtime:end */
110
+
1
111
  (function () {
2
112
  const viewer = document.getElementById('viewer');
3
113
  const DEFAULT_FONT_SIZE = 16;
@@ -12,6 +122,25 @@
12
122
  let currentPage = 1;
13
123
  let pageCount = 0;
14
124
  let zoom = 1.0;
125
+ let rotation = 0;
126
+ let comicArchive = null;
127
+ let comicEntries = [];
128
+ let comicContainer = null;
129
+ let comicPages = [];
130
+ let comicObserver = null;
131
+ let comicScrollFrame = null;
132
+ const comicVisiblePages = new Set();
133
+ const comicObjectUrls = new Map();
134
+ const comicLoading = new Map();
135
+ const comicDimensions = new Map();
136
+ const comicPreviewCache = new Map();
137
+ const comicPreviewLoading = new Map();
138
+ const COMIC_CACHE_LIMIT = 16;
139
+ const COMIC_PREVIEW_CACHE_LIMIT = 8;
140
+ const comicHelpers = window.PapyrusComicRuntime || {};
141
+ const runtimeConfig = window.__PAPYRUS_RUNTIME_CONFIG__ || {};
142
+ let comicDispose = null;
143
+ let comicGeneration = 0;
15
144
 
16
145
  const sendMessage = (payload) => {
17
146
  if (window.ReactNativeWebView && window.ReactNativeWebView.postMessage) {
@@ -27,6 +156,80 @@
27
156
  sendMessage({ type: 'response', id, ok, data, error });
28
157
  };
29
158
 
159
+ const runtimeAssetRequests = new Map();
160
+ const fileChunkRequests = new Map();
161
+
162
+ const requestRuntimeAsset = (url, encoding) =>
163
+ new Promise((resolve, reject) => {
164
+ const id = `runtime-asset-${Date.now()}-${Math.random()}`;
165
+ const timeout = setTimeout(() => {
166
+ runtimeAssetRequests.delete(id);
167
+ reject(new Error(`Tempo esgotado ao carregar asset CBR: ${url}`));
168
+ }, 15000);
169
+ runtimeAssetRequests.set(id, {resolve, reject, timeout});
170
+ sendMessage({type: 'asset-request', id, url, encoding});
171
+ });
172
+
173
+ const encodeArrayBufferAsDataUrl = async (buffer) => {
174
+ const bytes = new Uint8Array(buffer);
175
+ let binary = '';
176
+ const chunkSize = 0x8000;
177
+ for (let offset = 0; offset < bytes.length; offset += chunkSize) {
178
+ binary += String.fromCharCode(
179
+ ...bytes.subarray(offset, Math.min(offset + chunkSize, bytes.length)),
180
+ );
181
+ }
182
+ return `data:application/wasm;base64,${btoa(binary)}`;
183
+ };
184
+
185
+ const fetchRuntimeAsset = async (url, encoding) => {
186
+ try {
187
+ const response = await fetch(url);
188
+ if (!response.ok) throw new Error(`status ${response.status}`);
189
+ if (encoding === 'text') return await response.text();
190
+ return await encodeArrayBufferAsDataUrl(await response.arrayBuffer());
191
+ } catch {
192
+ return await requestRuntimeAsset(url, encoding);
193
+ }
194
+ };
195
+
196
+ const requestLocalFileChunk = (uri, offset, length) =>
197
+ new Promise((resolve, reject) => {
198
+ const id = `file-chunk-${Date.now()}-${Math.random()}`;
199
+ const timeout = setTimeout(() => {
200
+ fileChunkRequests.delete(id);
201
+ reject(new Error(`Tempo esgotado ao ler arquivo local: ${uri}`));
202
+ }, 30000);
203
+ fileChunkRequests.set(id, {resolve, reject, timeout});
204
+ sendMessage({type: 'file-chunk-request', id, uri, offset, length});
205
+ });
206
+
207
+ const readLocalFile = async (uri) => {
208
+ const chunkSize = 1024 * 1024;
209
+ const chunks = [];
210
+ let offset = 0;
211
+ let totalLength = 0;
212
+ while (true) {
213
+ const result = await requestLocalFileChunk(uri, offset, chunkSize);
214
+ const bytes = decodeBase64(result.data || '');
215
+ if (bytes.length === 0 && !result.done) {
216
+ throw new Error('Leitura nativa retornou um chunk vazio.');
217
+ }
218
+ chunks.push(bytes);
219
+ totalLength += bytes.length;
220
+ offset += bytes.length;
221
+ if (result.done) break;
222
+ }
223
+
224
+ const output = new Uint8Array(totalLength);
225
+ let outputOffset = 0;
226
+ chunks.forEach((chunk) => {
227
+ output.set(chunk, outputOffset);
228
+ outputOffset += chunk.length;
229
+ });
230
+ return output.buffer;
231
+ };
232
+
30
233
  const sendState = (extra) => {
31
234
  sendMessage({
32
235
  type: 'state',
@@ -43,7 +246,57 @@
43
246
  sendMessage({ type: 'event', name, payload });
44
247
  };
45
248
 
249
+ const reportError = (error, context) => {
250
+ const message = error && error.message ? error.message : String(error);
251
+ const stack = error && error.stack ? error.stack : null;
252
+ sendEvent('RUNTIME_ERROR', { message, context, stack });
253
+ };
254
+
255
+ const shouldIgnoreError = (error) => {
256
+ const message = error && error.message ? error.message : String(error || '');
257
+ return message.includes('ResizeObserver loop');
258
+ };
259
+
260
+ window.addEventListener('error', (event) => {
261
+ const error = event.error || event.message;
262
+ if (shouldIgnoreError(error)) return;
263
+ reportError(error, 'window.error');
264
+ });
265
+
266
+ window.addEventListener('unhandledrejection', (event) => {
267
+ if (shouldIgnoreError(event.reason)) return;
268
+ reportError(event.reason, 'unhandledrejection');
269
+ });
270
+
271
+ const clearComicState = () => {
272
+ comicGeneration += 1;
273
+ if (comicObserver) {
274
+ comicObserver.disconnect();
275
+ comicObserver = null;
276
+ }
277
+ if (comicScrollFrame !== null) {
278
+ cancelAnimationFrame(comicScrollFrame);
279
+ comicScrollFrame = null;
280
+ }
281
+ comicObjectUrls.forEach((url) => URL.revokeObjectURL(url));
282
+ comicObjectUrls.clear();
283
+ comicLoading.clear();
284
+ comicVisiblePages.clear();
285
+ comicDimensions.clear();
286
+ comicPreviewCache.clear();
287
+ comicPreviewLoading.clear();
288
+ if (comicDispose) {
289
+ void comicDispose();
290
+ comicDispose = null;
291
+ }
292
+ comicArchive = null;
293
+ comicEntries = [];
294
+ comicContainer = null;
295
+ comicPages = [];
296
+ };
297
+
46
298
  const clearViewer = () => {
299
+ clearComicState();
47
300
  while (viewer.firstChild) {
48
301
  viewer.removeChild(viewer.firstChild);
49
302
  }
@@ -65,6 +318,431 @@
65
318
  return new TextDecoder('utf-8').decode(bytes);
66
319
  };
67
320
 
321
+ const sourceToArrayBuffer = async (source) => {
322
+ if (source.kind === 'uri') {
323
+ if (/^(?:file|content):\/\//i.test(source.uri)) {
324
+ return readLocalFile(source.uri);
325
+ }
326
+ const response = await fetch(source.uri);
327
+ if (!response.ok && response.status !== 0) {
328
+ throw new Error(`Falha ao carregar quadrinho (${response.status})`);
329
+ }
330
+ return response.arrayBuffer();
331
+ }
332
+ if (source.kind === 'base64') {
333
+ return decodeBase64(source.data).buffer;
334
+ }
335
+ throw new Error('Fonte de quadrinho inválida.');
336
+ };
337
+
338
+ const comicMimeType = (name) => {
339
+ const extension = name.split('.').pop()?.toLowerCase();
340
+ if (extension === 'png') return 'image/png';
341
+ if (extension === 'gif') return 'image/gif';
342
+ if (extension === 'svg') return 'image/svg+xml';
343
+ if (extension === 'webp') return 'image/webp';
344
+ return 'image/jpeg';
345
+ };
346
+
347
+ const updateComicCurrentPage = () => {
348
+ if (!comicContainer || comicPages.length === 0) return;
349
+ const viewportTop = comicContainer.scrollTop;
350
+ let closestIndex = currentPage - 1;
351
+ let closestDistance = Number.POSITIVE_INFINITY;
352
+ comicPages.forEach((page, index) => {
353
+ const distance = Math.abs(page.offsetTop - viewportTop);
354
+ if (distance < closestDistance) {
355
+ closestDistance = distance;
356
+ closestIndex = index;
357
+ }
358
+ });
359
+ const nextPage = Math.max(1, Math.min(pageCount, closestIndex + 1));
360
+ if (nextPage !== currentPage) {
361
+ currentPage = nextPage;
362
+ sendState();
363
+ }
364
+ };
365
+
366
+ const scheduleComicCurrentPage = () => {
367
+ if (comicScrollFrame !== null) return;
368
+ comicScrollFrame = requestAnimationFrame(() => {
369
+ comicScrollFrame = null;
370
+ updateComicCurrentPage();
371
+ });
372
+ };
373
+
374
+ const evictComicUrls = () => {
375
+ if (comicObjectUrls.size <= COMIC_CACHE_LIMIT) return;
376
+ const protectedIndexes = comicHelpers.getProtectedComicPageIndexes(
377
+ currentPage,
378
+ Array.from(comicVisiblePages),
379
+ Array.from(comicLoading.keys())
380
+ );
381
+ for (const [index, url] of comicObjectUrls) {
382
+ if (comicObjectUrls.size <= COMIC_CACHE_LIMIT) break;
383
+ if (protectedIndexes.has(index)) continue;
384
+ URL.revokeObjectURL(url);
385
+ comicObjectUrls.delete(index);
386
+ const image = comicPages[index]?.querySelector('img');
387
+ const page = comicPages[index];
388
+ const dimensions = comicDimensions.get(index);
389
+ const width = dimensions?.width || image?.naturalWidth || 0;
390
+ const height = dimensions?.height || image?.naturalHeight || 0;
391
+ if (page && width > 0 && height > 0) {
392
+ page.style.aspectRatio = comicHelpers.getComicPageAspectRatio(
393
+ width,
394
+ height
395
+ );
396
+ }
397
+ if (image) image.removeAttribute('src');
398
+ }
399
+ };
400
+
401
+ const ensureComicPage = async (pageIndex) => {
402
+ if (comicObjectUrls.has(pageIndex)) {
403
+ const image = comicPages[pageIndex]?.querySelector('img');
404
+ if (image && !image.src) image.src = comicObjectUrls.get(pageIndex);
405
+ return comicObjectUrls.get(pageIndex);
406
+ }
407
+ if (comicLoading.has(pageIndex)) return comicLoading.get(pageIndex);
408
+
409
+ const entry = comicEntries[pageIndex];
410
+ if (!entry) throw new Error(`Página de quadrinho inválida: ${pageIndex}`);
411
+ const loadGeneration = comicGeneration;
412
+ const pending = entry.async('blob').then((blob) => {
413
+ if (
414
+ !comicHelpers.isCurrentComicPageLoad(
415
+ loadGeneration,
416
+ comicGeneration,
417
+ entry,
418
+ comicEntries[pageIndex]
419
+ )
420
+ ) {
421
+ return null;
422
+ }
423
+ const url = URL.createObjectURL(
424
+ new Blob([blob], { type: comicMimeType(entry.name) })
425
+ );
426
+ comicObjectUrls.set(pageIndex, url);
427
+ const image = comicPages[pageIndex]?.querySelector('img');
428
+ if (image) image.src = url;
429
+ evictComicUrls();
430
+ return url;
431
+ });
432
+ comicLoading.set(pageIndex, pending);
433
+ try {
434
+ return await pending;
435
+ } finally {
436
+ if (comicLoading.get(pageIndex) === pending) {
437
+ comicLoading.delete(pageIndex);
438
+ evictComicUrls();
439
+ }
440
+ }
441
+ };
442
+
443
+ const renderComicPage = (pageIndex) => {
444
+ const image = comicPages[pageIndex]?.querySelector('img');
445
+ if (!image) return;
446
+ image.style.width = `${Math.max(40, Math.round(100 * zoom))}%`;
447
+ image.style.transform = `rotate(${rotation}deg)`;
448
+ };
449
+
450
+ const applyComicLayout = () => {
451
+ comicPages.forEach((_, pageIndex) => renderComicPage(pageIndex));
452
+ };
453
+
454
+ const loadComic = async (source, format) => {
455
+ clearViewer();
456
+ let entries;
457
+ if (format === 'cbr') {
458
+ const clientUrl = runtimeConfig.cbrClientUrl;
459
+ const workerUrl = runtimeConfig.cbrWorkerUrl;
460
+ const wasmUrl = runtimeConfig.cbrWasmUrl;
461
+ if (!clientUrl || !workerUrl || !wasmUrl) {
462
+ throw new Error(
463
+ 'CBR no mobile exige o pacote opcional de runtime libarchive.'
464
+ );
465
+ }
466
+
467
+ const [clientSource, workerSource, wasmSource] = await Promise.all([
468
+ fetchRuntimeAsset(clientUrl, 'text'),
469
+ fetchRuntimeAsset(workerUrl, 'text'),
470
+ runtimeConfig.cbrWasmDataUrl ||
471
+ fetchRuntimeAsset(wasmUrl, 'base64'),
472
+ ]);
473
+ const patchedWorkerSource = comicHelpers.patchCbrWorkerSource(
474
+ workerSource,
475
+ wasmSource
476
+ );
477
+ const workerObjectUrl = URL.createObjectURL(
478
+ new Blob([patchedWorkerSource], { type: 'text/javascript' })
479
+ );
480
+ const clientObjectUrl = URL.createObjectURL(
481
+ new Blob([clientSource], { type: 'text/javascript' })
482
+ );
483
+ try {
484
+ const archiveModule = await import(clientObjectUrl);
485
+ const createCbrWorker = () => {
486
+ const worker = new Worker(workerObjectUrl, {type: 'module'});
487
+ worker.addEventListener('error', (event) => {
488
+ sendEvent('RUNTIME_ERROR', {
489
+ message: `CBR worker: ${event?.message || 'falha ao executar o worker'}`,
490
+ context: 'comic.worker',
491
+ });
492
+ });
493
+ worker.addEventListener('messageerror', () => {
494
+ sendEvent('RUNTIME_ERROR', {
495
+ message: 'CBR worker: falha ao transferir mensagem',
496
+ context: 'comic.worker',
497
+ });
498
+ });
499
+ return worker;
500
+ };
501
+ archiveModule.Archive.init({ getWorker: createCbrWorker });
502
+ const bytes = await sourceToArrayBuffer(source);
503
+ const file = new Blob([bytes], {
504
+ type: 'application/vnd.comicbook-rar',
505
+ });
506
+ const archive = await archiveModule.Archive.open(file);
507
+ const files = await archive.getFilesArray();
508
+ entries = files
509
+ .filter(
510
+ (item) => {
511
+ const name = `${item?.path || ''}${item?.file?.name || ''}`;
512
+ return item?.file && comicHelpers.isComicImageName(name);
513
+ }
514
+ )
515
+ .map((item) => {
516
+ const name = `${item.path || ''}${item.file.name}`;
517
+ return {
518
+ name,
519
+ async: async () => item.file.extract(),
520
+ };
521
+ });
522
+ comicDispose = async () => {
523
+ await archive.close();
524
+ URL.revokeObjectURL(workerObjectUrl);
525
+ URL.revokeObjectURL(clientObjectUrl);
526
+ };
527
+ } catch (error) {
528
+ URL.revokeObjectURL(workerObjectUrl);
529
+ URL.revokeObjectURL(clientObjectUrl);
530
+ throw error;
531
+ }
532
+ } else {
533
+ if (!window.JSZip) {
534
+ throw new Error('O runtime ZIP do leitor mobile não foi carregado.');
535
+ }
536
+
537
+ const bytes = await sourceToArrayBuffer(source);
538
+ comicArchive = await window.JSZip.loadAsync(bytes);
539
+ entries = Object.keys(comicArchive.files)
540
+ .filter((name) => {
541
+ const entry = comicArchive.files[name];
542
+ return !entry.dir && comicHelpers.isComicImageName(name);
543
+ })
544
+ .map((name) => comicArchive.files[name]);
545
+ }
546
+
547
+ const names = comicHelpers.sortComicPageNames(
548
+ entries.map((entry) => entry.name)
549
+ );
550
+ const entriesByName = new Map(entries.map((entry) => [entry.name, entry]));
551
+ comicEntries = names.map((name) => entriesByName.get(name));
552
+ if (comicEntries.length === 0) {
553
+ if (comicDispose) {
554
+ await comicDispose();
555
+ comicDispose = null;
556
+ }
557
+ throw new Error('O arquivo não contém páginas de imagem.');
558
+ }
559
+
560
+ comicContainer = document.createElement('div');
561
+ comicContainer.style.width = '100%';
562
+ comicContainer.style.height = '100%';
563
+ comicContainer.style.overflow = 'auto';
564
+ comicContainer.style.padding = '16px 0 120px';
565
+ comicContainer.style.boxSizing = 'border-box';
566
+ comicContainer.style.background = '#ffffff';
567
+ comicContainer.addEventListener('scroll', scheduleComicCurrentPage, {
568
+ passive: true,
569
+ });
570
+ viewer.style.overflow = 'hidden';
571
+ viewer.appendChild(comicContainer);
572
+
573
+ comicPages = comicEntries.map((entry, pageIndex) => {
574
+ const page = document.createElement('div');
575
+ page.dataset.pageIndex = String(pageIndex);
576
+ page.style.width = '100%';
577
+ page.style.minHeight = '80px';
578
+ page.style.display = 'flex';
579
+ page.style.justifyContent = 'center';
580
+ page.style.alignItems = 'flex-start';
581
+ page.style.padding = '0 12px 20px';
582
+ page.style.boxSizing = 'border-box';
583
+
584
+ const image = document.createElement('img');
585
+ image.alt = `Página ${pageIndex + 1}`;
586
+ image.draggable = false;
587
+ image.decoding = 'async';
588
+ image.loading = 'lazy';
589
+ image.style.display = 'block';
590
+ image.style.maxWidth = 'none';
591
+ image.style.height = 'auto';
592
+ image.style.userSelect = 'none';
593
+ image.addEventListener('load', () => {
594
+ if (image.naturalWidth > 0 && image.naturalHeight > 0) {
595
+ comicDimensions.set(pageIndex, {
596
+ width: image.naturalWidth,
597
+ height: image.naturalHeight,
598
+ });
599
+ page.style.aspectRatio = comicHelpers.getComicPageAspectRatio(
600
+ image.naturalWidth,
601
+ image.naturalHeight
602
+ );
603
+ }
604
+ });
605
+ page.appendChild(image);
606
+ comicContainer.appendChild(page);
607
+ return page;
608
+ });
609
+
610
+ if (typeof IntersectionObserver === 'function') {
611
+ comicObserver = new IntersectionObserver(
612
+ (observations) => {
613
+ observations.forEach((observation) => {
614
+ const pageIndex = Number(observation.target.dataset.pageIndex);
615
+ if (!observation.isIntersecting) {
616
+ comicVisiblePages.delete(pageIndex);
617
+ return;
618
+ }
619
+ comicVisiblePages.add(pageIndex);
620
+ void ensureComicPage(pageIndex).catch((error) =>
621
+ sendEvent('RUNTIME_ERROR', {
622
+ message: error?.message || String(error),
623
+ context: 'comic.page',
624
+ })
625
+ );
626
+ });
627
+ scheduleComicCurrentPage();
628
+ },
629
+ { root: comicContainer, rootMargin: '600px 0px' }
630
+ );
631
+ comicPages.forEach((page) => comicObserver.observe(page));
632
+ } else {
633
+ for (let pageIndex = 0; pageIndex < Math.min(3, comicPages.length); pageIndex += 1) {
634
+ void ensureComicPage(pageIndex);
635
+ }
636
+ }
637
+ pageCount = comicPages.length;
638
+ currentPage = 1;
639
+ await ensureComicPage(0);
640
+ applyComicLayout();
641
+ return { pageCount };
642
+ };
643
+
644
+ const readComicBlobAsDataUrl = (blob, mimeType) =>
645
+ new Promise((resolve, reject) => {
646
+ const reader = new FileReader();
647
+ reader.onerror = () => reject(new Error('Falha ao gerar preview.'));
648
+ reader.onload = () => resolve(reader.result);
649
+ reader.readAsDataURL(new Blob([blob], { type: mimeType }));
650
+ });
651
+
652
+ const createComicPagePreview = async (pageIndex) => {
653
+ const entry = comicEntries[pageIndex];
654
+ if (!entry) return null;
655
+ const mimeType = comicMimeType(entry.name);
656
+ const sourceUrl = await ensureComicPage(pageIndex);
657
+ if (!sourceUrl) return null;
658
+ try {
659
+ const image = new Image();
660
+ image.decoding = 'async';
661
+ await new Promise((resolve, reject) => {
662
+ image.onload = resolve;
663
+ image.onerror = () => reject(new Error('Falha ao decodificar preview.'));
664
+ image.src = sourceUrl;
665
+ });
666
+ const size = comicHelpers.getComicPreviewSize(
667
+ image.naturalWidth || image.width,
668
+ image.naturalHeight || image.height
669
+ );
670
+ const canvas = document.createElement('canvas');
671
+ canvas.width = size.width;
672
+ canvas.height = size.height;
673
+ const context = canvas.getContext('2d');
674
+ if (!context) throw new Error('Canvas indisponível para preview.');
675
+ context.fillStyle = '#ffffff';
676
+ context.fillRect(0, 0, size.width, size.height);
677
+ context.drawImage(image, 0, 0, size.width, size.height);
678
+ return canvas.toDataURL('image/jpeg', 0.78);
679
+ } catch {
680
+ try {
681
+ const response = await fetch(sourceUrl);
682
+ const blob = await response.blob();
683
+ return await readComicBlobAsDataUrl(blob, mimeType);
684
+ } catch {
685
+ return null;
686
+ }
687
+ }
688
+ };
689
+
690
+ const getComicPagePreview = async (pageIndex) => {
691
+ const entry = comicEntries[pageIndex];
692
+ if (!entry) return null;
693
+ if (comicPreviewCache.has(pageIndex)) {
694
+ return comicPreviewCache.get(pageIndex);
695
+ }
696
+ if (comicPreviewLoading.has(pageIndex)) {
697
+ return comicPreviewLoading.get(pageIndex);
698
+ }
699
+ const loadGeneration = comicGeneration;
700
+ const pending = createComicPagePreview(pageIndex).then((preview) => {
701
+ if (
702
+ !comicHelpers.isCurrentComicPageLoad(
703
+ loadGeneration,
704
+ comicGeneration,
705
+ entry,
706
+ comicEntries[pageIndex]
707
+ )
708
+ ) {
709
+ return null;
710
+ }
711
+ comicPreviewCache.set(pageIndex, preview);
712
+ while (comicPreviewCache.size > COMIC_PREVIEW_CACHE_LIMIT) {
713
+ comicPreviewCache.delete(comicPreviewCache.keys().next().value);
714
+ }
715
+ return preview;
716
+ });
717
+ comicPreviewLoading.set(pageIndex, pending);
718
+ try {
719
+ return await pending;
720
+ } finally {
721
+ if (comicPreviewLoading.get(pageIndex) === pending) {
722
+ comicPreviewLoading.delete(pageIndex);
723
+ }
724
+ }
725
+ };
726
+
727
+ const getComicPageDimensions = async (pageIndex) => {
728
+ const known = comicDimensions.get(pageIndex);
729
+ if (known) return known;
730
+ await ensureComicPage(pageIndex);
731
+ const image = comicPages[pageIndex]?.querySelector('img');
732
+ if (image && !image.complete) {
733
+ await new Promise((resolve) => {
734
+ image.addEventListener('load', resolve, { once: true });
735
+ image.addEventListener('error', resolve, { once: true });
736
+ });
737
+ }
738
+ return (
739
+ comicDimensions.get(pageIndex) || {
740
+ width: image?.naturalWidth || 0,
741
+ height: image?.naturalHeight || 0,
742
+ }
743
+ );
744
+ };
745
+
68
746
  const normalizeHref = (href) => {
69
747
  if (!href) return '';
70
748
  return href.split('#')[0];
@@ -316,6 +994,8 @@
316
994
  applyTextZoom();
317
995
  } else if (currentType === 'epub') {
318
996
  applyEpubZoom();
997
+ } else if (currentType === 'comic') {
998
+ applyComicLayout();
319
999
  }
320
1000
  };
321
1001
 
@@ -341,6 +1021,13 @@
341
1021
  return;
342
1022
  }
343
1023
 
1024
+ if (currentType === 'comic') {
1025
+ const result = await loadComic(payload.source, payload.format || 'cbz');
1026
+ sendState();
1027
+ sendResponse(id, true, result);
1028
+ return;
1029
+ }
1030
+
344
1031
  throw new Error('Unsupported document type');
345
1032
  }
346
1033
 
@@ -352,6 +1039,15 @@
352
1039
  sendState();
353
1040
  } else if (currentType === 'epub') {
354
1041
  await displayEpubPage(page - 1);
1042
+ } else if (currentType === 'comic') {
1043
+ currentPage = page;
1044
+ const target = comicPages[page - 1];
1045
+ if (target) {
1046
+ await ensureComicPage(page - 1);
1047
+ target.scrollIntoView({ block: 'start' });
1048
+ renderComicPage(page - 1);
1049
+ sendState();
1050
+ }
355
1051
  }
356
1052
  sendResponse(id, true, { currentPage });
357
1053
  return;
@@ -366,6 +1062,8 @@
366
1062
  }
367
1063
 
368
1064
  if (kind === 'set-rotation') {
1065
+ rotation = ((payload.rotation || 0) % 360 + 360) % 360;
1066
+ if (currentType === 'comic') applyComicLayout();
369
1067
  sendResponse(id, true, {});
370
1068
  return;
371
1069
  }
@@ -377,7 +1075,28 @@
377
1075
  }
378
1076
 
379
1077
  if (kind === 'get-page-dimensions') {
380
- sendResponse(id, true, getPageDimensions());
1078
+ if (currentType === 'comic') {
1079
+ sendResponse(
1080
+ id,
1081
+ true,
1082
+ await getComicPageDimensions(payload.pageIndex || 0)
1083
+ );
1084
+ } else {
1085
+ sendResponse(id, true, getPageDimensions());
1086
+ }
1087
+ return;
1088
+ }
1089
+
1090
+ if (kind === 'get-page-preview') {
1091
+ if (currentType !== 'comic') {
1092
+ sendResponse(id, true, null);
1093
+ return;
1094
+ }
1095
+ sendResponse(
1096
+ id,
1097
+ true,
1098
+ await getComicPagePreview(payload.pageIndex || 0)
1099
+ );
381
1100
  return;
382
1101
  }
383
1102
 
@@ -421,7 +1140,7 @@
421
1140
  let message = null;
422
1141
  const raw = event && typeof event === 'object' ? event.data : null;
423
1142
 
424
- if (raw && typeof raw === 'object' && raw.kind && raw.id) {
1143
+ if (raw && typeof raw === 'object' && (raw.kind || raw.type) && raw.id) {
425
1144
  message = raw;
426
1145
  } else if (typeof raw === 'string') {
427
1146
  if (raw.startsWith('setImmediate$')) return;
@@ -438,7 +1157,29 @@
438
1157
  return;
439
1158
  }
440
1159
 
441
- if (!message || !message.kind || message.id == null) return;
1160
+ if (!message || message.id == null) return;
1161
+
1162
+ if (message.type === 'asset-response') {
1163
+ const pending = runtimeAssetRequests.get(message.id);
1164
+ if (!pending) return;
1165
+ runtimeAssetRequests.delete(message.id);
1166
+ clearTimeout(pending.timeout);
1167
+ if (message.ok) pending.resolve(message.data);
1168
+ else pending.reject(new Error(message.error || 'Falha ao carregar asset CBR.'));
1169
+ return;
1170
+ }
1171
+
1172
+ if (message.type === 'file-chunk-response') {
1173
+ const pending = fileChunkRequests.get(message.id);
1174
+ if (!pending) return;
1175
+ fileChunkRequests.delete(message.id);
1176
+ clearTimeout(pending.timeout);
1177
+ if (message.ok) pending.resolve(message);
1178
+ else pending.reject(new Error(message.error || 'Falha ao ler arquivo local.'));
1179
+ return;
1180
+ }
1181
+
1182
+ if (!message.kind) return;
442
1183
  handleCommand(message);
443
1184
  };
444
1185