divan/web/src/lib/pdfreader.ts
Anas Rashid c4bf52dd97 Icons and colour roles: header icons with tooltips and a red log out; buttons coloured by what they do; محفوظ شدہ and پسندیدہ لکھاری
- Icon component (Lucide shapes, ISC), currentColor, decorative (the control keeps its words in aria-label/title).
- Colour roles: --action (primary, filled), --ok (approve, publish), --danger (log out, delete, reject); .btn variants
  and .icon-btn; light and dark.
- Header: admin, moderation, saved, word book, account/sign in, font, theme as icons; log out in red.
- Review: save/send filled, approve/publish green, return plain, reject red, with icons. Account: save, log out, password,
  delete. Reading pages: edit, history, arrange, tag. E-books: add, download, covers; the reader's controls as icons.
- نشانات is now محفوظ شدہ; favourite writers have their own page, پسندیدہ لکھاری (/writers).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 15:23:06 +02:00

73 lines
5.4 KiB
TypeScript

// The PDF reader (pdf.js), right to left as an Urdu book: the next page is to the left (button, ← key, a click on
// the left half), and on wide screens pages face each other with the cover alone, then 2|3 with page 2 on the
// right. A book marked left-to-right (dir="ltr" on the reader, e.g. English) turns the other way. Pages are fetched by byte range as they are shown, so a large scan starts quickly. Signed-in readers
// bookmark pages (the right-hand page of a spread) in their library; their bookmarks jump to the page.
export async function pdfReader(root: HTMLElement) {
const pdfjs = await import('pdfjs-dist');
pdfjs.GlobalWorkerOptions.workerSrc = (await import('pdfjs-dist/build/pdf.worker.min.mjs?url')).default;
const rtl = root.dir !== 'ltr';
const pagesBox = root.querySelector<HTMLElement>('.pdf-pages')!, input = root.querySelector<HTMLInputElement>('.pdf-page')!;
const doc = await pdfjs.getDocument({ url: root.dataset.src!, disableAutoFetch: true, disableStream: true, rangeChunkSize: 262144 }).promise;
root.querySelector('.pdf-total')!.textContent = String(doc.numPages).replace(/\d/g, (d) => '۰۱۲۳۴۵۶۷۸۹'[+d]);
input.max = String(doc.numPages);
const wide = () => pagesBox.clientWidth > 900;
// the pages shown together, starting with page p
const spread = (p: number) => (!wide() || p === 1 ? [p] : [p % 2 ? p - 1 : p, (p % 2 ? p - 1 : p) + 1].filter((n) => n >= 2 && n <= doc.numPages));
let page = 1, job = 0;
async function show(p: number) {
page = Math.min(Math.max(1, p), doc.numPages);
const nums = spread(page), mine = ++job;
input.value = String(nums[0]);
const canvases = await Promise.all(nums.map(async (n) => {
const pg = await doc.getPage(n), base = pg.getViewport({ scale: 1 });
const tall = document.fullscreenElement === root ? window.innerHeight - 70 : window.innerHeight * 0.82;
const fit = Math.min((pagesBox.clientWidth - 8 * nums.length) / nums.length / base.width, tall / base.height);
const v = pg.getViewport({ scale: fit * devicePixelRatio }), c = document.createElement('canvas');
c.width = Math.floor(v.width); c.height = Math.floor(v.height);
c.style.width = `${Math.floor(v.width / devicePixelRatio)}px`; c.style.height = `${Math.floor(v.height / devicePixelRatio)}px`;
await pg.render({ canvasContext: c.getContext('2d')!, viewport: v, canvas: c } as any).promise;
return c;
}));
if (mine === job) { pagesBox.replaceChildren(...canvases); marks(); } // the container is RTL: the first page is on the right
}
const step = (d: number) => { const nums = spread(page); show(d > 0 ? nums[nums.length - 1] + 1 : spread(Math.max(1, nums[0] - 1))[0]); };
root.querySelector<HTMLButtonElement>('.pdf-next')!.onclick = () => step(1);
root.querySelector<HTMLButtonElement>('.pdf-prev')!.onclick = () => step(-1);
input.onchange = () => show(Number(input.value) || 1);
// page bookmarks
const markBtn = root.querySelector<HTMLButtonElement>('.pdf-mark'), list = root.querySelector<HTMLElement>('.pdf-marks');
let saved: number[] = JSON.parse(root.dataset.marks ?? '[]');
const digits = (n: number) => String(n).replace(/\d/g, (d) => '۰۱۲۳۴۵۶۷۸۹'[+d]);
function marks() {
if (!markBtn || !list) return;
const on = saved.includes(spread(page)[0]);
markBtn.setAttribute('aria-pressed', String(on)); markBtn.textContent = on ? 'صفحہ محفوظ شدہ' : 'صفحہ محفوظ کریں';
list.replaceChildren(...saved.map((n) => { const b = document.createElement('button'); b.type = 'button'; b.className = 'tag-chip'; b.textContent = `صفحہ ${digits(n)}`; b.onclick = () => show(n); return b; }));
list.hidden = !saved.length;
}
markBtn?.addEventListener('click', async () => {
if (!document.body.dataset.user) { location.href = '/signin?next=' + encodeURIComponent(location.pathname); return; }
const p = spread(page)[0];
const r = await fetch('/api/library', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ kind: 'page', ebookId: Number(root.dataset.ebook), page: p }) });
if (!r.ok) return;
saved = (await r.json()).saved ? [...saved, p].sort((a, b) => a - b) : saved.filter((n) => n !== p);
marks();
});
// the whole screen for the reader (the page is redrawn to the new size)
const full = root.querySelector<HTMLButtonElement>('.pdf-full')!;
const toggle = () => (document.fullscreenElement ? document.exitFullscreen() : root.requestFullscreen()).catch(() => {});
full.onclick = toggle;
document.addEventListener('fullscreenchange', () => { full.title = document.fullscreenElement === root ? 'پوری اسکرین سے واپس (F)' : 'پوری اسکرین (F)'; show(page); });
// a click on the half the book turns towards goes forward
pagesBox.onclick = (e) => { const left = e.clientX < pagesBox.getBoundingClientRect().left + pagesBox.clientWidth / 2; step(left === rtl ? 1 : -1); };
document.addEventListener('keydown', (e) => {
if ((e.target as HTMLElement).closest('input, textarea')) return;
if (e.key === 'ArrowLeft') step(rtl ? 1 : -1);
if (e.key === 'ArrowRight') step(rtl ? -1 : 1);
if (e.key === 'f' || e.key === 'F') toggle();
});
let resize = 0;
window.addEventListener('resize', () => { clearTimeout(resize); resize = window.setTimeout(() => show(page), 200); });
await show(Number(new URLSearchParams(location.search).get('page')) || 1); // ?page= from a bookmark
}