diff --git a/bun.lock b/bun.lock index 80e8819..b7e7f31 100644 --- a/bun.lock +++ b/bun.lock @@ -23,6 +23,8 @@ "base64-js": "^1.5.1", "codemirror": "^6.0.2", "css-tree": "^3.2.1", + "d3-ease": "^3.0.1", + "d3-timer": "^3.0.1", "defuddle": "^0.19.4", "entities": "^8.1.0", "jsdom": "^30.0.1", @@ -46,6 +48,8 @@ "@types/bun": "^1.4.2", "@types/chrome": "^0.2.7", "@types/css-tree": "^3.2.0", + "@types/d3-ease": "^3.0.2", + "@types/d3-timer": "^3.0.2", "@types/jsdom": "^30.0.0", "@types/react": "^19.3.0", "@types/react-dom": "^19.3.0", @@ -220,6 +224,10 @@ "@types/css-tree": ["@types/css-tree@3.2.0", "", {}, "sha512-J5KXmk6BFIepOT7280FdFyNs4c5fh0Uee0otjKEvzchrRs38Ii9qminqc4ds0L19X8Zd/rT+brR9jkBthygjWw=="], + "@types/d3-ease": ["@types/d3-ease@3.0.2", "", {}, "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA=="], + + "@types/d3-timer": ["@types/d3-timer@3.0.2", "", {}, "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw=="], + "@types/filesystem": ["@types/filesystem@0.0.36", "", { "dependencies": { "@types/filewriter": "*" } }, "sha512-vPDXOZuannb9FZdxgHnqSwAG/jvdGM8Wq+6N4D/d80z+D4HWH+bItqsZaVRQykAn6WEVeEkLm2oQigyHtgb0RA=="], "@types/filewriter": ["@types/filewriter@0.0.33", "", {}, "sha512-xFU8ZXTw4gd358lb2jw25nxY9QAgqn2+bKKjKOYfNCzN4DKCFetK7sPtrlpg66Ywe3vWY9FNxprZawAh9wfJ3g=="], @@ -342,6 +350,10 @@ "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], + "d3-ease": ["d3-ease@3.0.1", "", {}, "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w=="], + + "d3-timer": ["d3-timer@3.0.1", "", {}, "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA=="], + "data-urls": ["data-urls@7.0.0", "", { "dependencies": { "whatwg-mimetype": "^5.0.0", "whatwg-url": "^16.0.0" } }, "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA=="], "decimal.js": ["decimal.js@10.6.0", "", {}, "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg=="], diff --git a/components/bulk-options.tsx b/components/bulk-options.tsx index 6bf1f5d..fedb8e0 100644 --- a/components/bulk-options.tsx +++ b/components/bulk-options.tsx @@ -24,6 +24,7 @@ import { type PageTab, } from "../src/bulk"; import { getOptions, type Options } from "../src/options"; +import { reach } from "../src/progress"; import { errString, safeFilename } from "../src/utils"; // the panel unmounts the moment another tab is picked, so what's chosen, what's @@ -126,6 +127,12 @@ function Favicon({ page }: { page: PageTab }): ReactElement { } } +// a step reports when it starts and then as it goes, so between reports the +// browser eases the ring on toward the end of the step; the long curve is +// front-loaded, which is why a slow step still looks like it's moving +const DRIFT = "6s cubic-bezier(0, 0.7, 0.25, 1)"; +const SETTLE = "400ms cubic-bezier(0.4, 0, 0.2, 1)"; + /** the page's icon, ringed by how far along its export is */ function PageIcon({ page, @@ -157,11 +164,18 @@ function PageIcon({ /> )} diff --git a/components/options-page.tsx b/components/options-page.tsx index a60f8d5..ad3818e 100644 --- a/components/options-page.tsx +++ b/components/options-page.tsx @@ -168,9 +168,7 @@ async function uploadFile(deviceToken: string, file: File): Promise { const { epub, title = file_title } = await render( mhtml.buffer as ArrayBuffer, opts, - file_title, - undefined, - false, + { title: file_title, summarize: false }, ); await uploadEpub(epub, title, deviceToken, opts); } else { diff --git a/package.json b/package.json index 2a94bb4..94933fe 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,8 @@ "base64-js": "^1.5.1", "codemirror": "^6.0.2", "css-tree": "^3.2.1", + "d3-ease": "^3.0.1", + "d3-timer": "^3.0.1", "defuddle": "^0.19.4", "entities": "^8.1.0", "jsdom": "^30.0.1", @@ -59,6 +61,8 @@ "@types/bun": "^1.4.2", "@types/chrome": "^0.2.7", "@types/css-tree": "^3.2.0", + "@types/d3-ease": "^3.0.2", + "@types/d3-timer": "^3.0.2", "@types/jsdom": "^30.0.0", "@types/react": "^19.3.0", "@types/react-dom": "^19.3.0", diff --git a/src/background.ts b/src/background.ts index c5f9231..526df6a 100644 --- a/src/background.ts +++ b/src/background.ts @@ -4,11 +4,15 @@ import { clearProgress } from "./icon"; import type { TitleRequest } from "./messages"; import { getOptions } from "./options"; import { registerPrinter } from "./printer"; +import { steps, within } from "./progress"; import { render } from "./render"; import { getTab } from "./status"; import { uploadEpub } from "./upload"; import { safeFilename, sleep } from "./utils"; +/** the parts of a conversion, weighted by how long each tends to take */ +const stage = steps({ capture: 2, render: 6, send: 2 }); + /** * create and upload epub */ @@ -21,7 +25,7 @@ async function rePub( // get active state for clearing the icon with navigation const tab = getTab(tabId); try { - await tab.init(); + tab.init(); const { deviceToken, outputStyle, downloadAsk, promptTitle, ...opts } = await getOptions(); @@ -35,18 +39,19 @@ async function rePub( return; } - await tab.progress(25); + tab.progress(within(stage.capture, 0)); const mhtml = await pageCapture(tabId); - await tab.progress(50); + tab.progress(within(stage.render, 0)); - const { epub, title = "missing title" } = await render( - mhtml, - opts, - initTitle, - initAuthor, - ); - await tab.progress(75); + const { epub, title = "missing title" } = await render(mhtml, opts, { + title: initTitle, + author: initAuthor, + onProgress: (portion) => { + tab.progress(within(stage.render, portion)); + }, + }); + tab.progress(within(stage.send, 0)); // upload if (outputStyle === "download") { @@ -68,7 +73,7 @@ async function rePub( ); } - await tab.complete(); + tab.complete(); } catch (ex) { const msg = ex instanceof Error ? ex.toString() : "unknown error"; await Promise.all([ diff --git a/src/bulk.spec.ts b/src/bulk.spec.ts index 5dc1d6d..10b6cef 100644 --- a/src/bulk.spec.ts +++ b/src/bulk.spec.ts @@ -7,8 +7,10 @@ import { listable, type PageState, type PageTab, + stage, } from "./bulk"; import { defaultOptions, type Options } from "./options"; +import { within } from "./progress"; const page: PageTab = { tabId: 1, @@ -139,14 +141,38 @@ test("upload a page", async () => { expect(woke).toEqual([1]); expect(sent).toEqual(["parsed title"]); expect(states).toEqual([ - { kind: "running", progress: 0.1 }, - { kind: "running", progress: 0.25 }, - { kind: "running", progress: 0.5 }, - { kind: "running", progress: 0.75 }, + { kind: "running", ...within(stage.wake, 0) }, + { kind: "running", ...within(stage.capture, 0) }, + { kind: "running", ...within(stage.render, 0) }, + { kind: "running", ...within(stage.send, 0) }, { kind: "finished" }, ]); }); +test("a render reports its own progress", async () => { + const opts: Options = { ...defaultOptions, deviceToken: "token" }; + const [states, report] = collect(); + + await exportPage( + page, + undefined, + opts, + report, + fakeEnv({ + convert: async (_, __, extra) => { + extra?.onProgress?.(0.5); + return { epub: new Uint8Array([1]), title: "parsed title" }; + }, + }), + ); + + // half way through the render lands half way through the render's own share + expect(states).toContainEqual({ + kind: "running", + ...within(stage.render, 0.5), + }); +}); + test("upload needs a token", async () => { const [states, report] = collect(); await expect( diff --git a/src/bulk.ts b/src/bulk.ts index 65f448f..a0fc4bb 100644 --- a/src/bulk.ts +++ b/src/bulk.ts @@ -2,6 +2,7 @@ import { fromByteArray } from "base64-js"; import { pageCapture } from "./capture"; import { showFailed, showProgress } from "./icon"; import type { EpubOptions, Options, UploadOptions } from "./options"; +import { steps, within } from "./progress"; import { render } from "./render"; import { uploadEpub } from "./upload"; import { errString, Semaphore, safeFilename, timeout } from "./utils"; @@ -12,6 +13,9 @@ const WAKE_TIMEOUT = 30_000; const room = new Semaphore(4); +/** the parts of an export, weighted by how long each tends to take */ +export const stage = steps({ wake: 1, capture: 2, render: 6, send: 2 }); + /** an open tab offered for export */ export interface PageTab { tabId: number; @@ -26,7 +30,7 @@ export interface PageTab { /** how far along a page is, or what became of it */ export type PageState = - | { kind: "running"; progress: number } + | { kind: "running"; progress: number; upto: number } | { kind: "finished" } | { kind: "failed"; error: string }; @@ -37,6 +41,7 @@ interface ExportEnv { convert: ( mhtml: ArrayBuffer, opts: EpubOptions, + extra?: { onProgress?: (portion: number) => void }, ) => Promise<{ epub: Uint8Array; title?: string }>; send: ( epub: Uint8Array, @@ -53,9 +58,9 @@ async function markTab(tabId: number, state: PageState): Promise { if (state.kind === "failed") { await showFailed(tabId); } else if (state.kind === "finished") { - await showProgress(tabId, 1); + showProgress(tabId, { progress: 1, upto: 1 }); } else { - await showProgress(tabId, state.progress); + showProgress(tabId, state); } } @@ -210,13 +215,21 @@ export async function exportPage( const given = name?.trim() || undefined; await using _permit = await room.lock(); - report({ kind: "running", progress: 0.1 }); + report({ kind: "running", ...within(stage.wake, 0) }); await wake(page.tabId); - report({ kind: "running", progress: 0.25 }); + report({ kind: "running", ...within(stage.capture, 0) }); const mhtml = await capture(page.tabId); - report({ kind: "running", progress: 0.5 }); - const { epub, title = given ?? "missing title" } = await convert(mhtml, rest); - report({ kind: "running", progress: 0.75 }); + report({ kind: "running", ...within(stage.render, 0) }); + const { epub, title = given ?? "missing title" } = await convert( + mhtml, + rest, + { + onProgress: (portion) => { + report({ kind: "running", ...within(stage.render, portion) }); + }, + }, + ); + report({ kind: "running", ...within(stage.send, 0) }); if (outputStyle === "download") { await save( diff --git a/src/cli.ts b/src/cli.ts index ec53438..1087b89 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -192,7 +192,6 @@ global.Image = dom.window["Image"] as typeof Image; rotateTables: false, tableResolution: 1, }, - true, ); if (args.verbose > 0) { diff --git a/src/icon.spec.ts b/src/icon.spec.ts index cd6e57f..a17b22f 100644 --- a/src/icon.spec.ts +++ b/src/icon.spec.ts @@ -1,5 +1,5 @@ import { expect, test } from "bun:test"; -import { arc } from "./icon"; +import { arc, ringAt } from "./icon"; test("arc", () => { const top = -Math.PI / 2; @@ -12,3 +12,36 @@ test("arc", () => { expect(arc(-1)).toEqual([top, top]); expect(arc(2)).toEqual([top, full]); }); + +test("ringAt eases to what was reported", () => { + const spot = { progress: 0.5, upto: 0.5 }; + + expect(ringAt(0.2, spot, 0)).toBe(0.2); + // the ease is fastest at the start, so it's over half way by half way + expect(ringAt(0.2, spot, 175)).toBeGreaterThan(0.35); + expect(ringAt(0.2, spot, 350)).toBeCloseTo(0.5, 10); + expect(ringAt(0.2, spot, 10_000)).toBeCloseTo(0.5, 10); +}); + +test("ringAt drifts on through the step", () => { + const spot = { progress: 0.2, upto: 0.6 }; + const arrived = ringAt(0.2, spot, 350); + const drifted = ringAt(0.2, spot, 4_000); + const later = ringAt(0.2, spot, 60_000); + + expect(arrived).toBeCloseTo(0.2, 10); + expect(drifted).toBeGreaterThan(arrived); + expect(later).toBeGreaterThan(drifted); + // the drift stops short, so a step that drags doesn't look finished + expect(later).toBeLessThan(0.6); +}); + +test("ringAt only moves forward", () => { + const spot = { progress: 0.3, upto: 0.9 }; + let last = 0; + for (let elapsed = 0; elapsed < 30_000; elapsed += 40) { + const at = ringAt(0.1, spot, elapsed); + expect(at).toBeGreaterThanOrEqual(last); + last = at; + } +}); diff --git a/src/icon.ts b/src/icon.ts index efd7228..b86a7aa 100644 --- a/src/icon.ts +++ b/src/icon.ts @@ -1,3 +1,7 @@ +import { easeCubicOut } from "d3-ease"; +import { interval, type Timer } from "d3-timer"; +import { reach, type Spot } from "./progress"; + // the toolbar icon is drawn rather than badged, so a conversion shows as a ring // closing around the icon instead of a number beside it const SIZE = 32; @@ -6,6 +10,15 @@ const TRACK = "rgba(0, 0, 0, 0.15)"; const FILL = "#000000"; const FAILED = "#d62626"; +// how long the ring takes to reach a spot it was just told about +const SWEEP = 350; +// how long the drift past that spot takes to cover most of the step +const DRIFT = 6000; +// how long between frames; the ring is 32 pixels across, so this is plenty +const FRAME = 40; +// the smallest move worth drawing the icon again for +const NUDGE = 1 / 200; + const sizes = [16, 32, 48, 128]; const paths = Object.fromEntries( sizes.map((size) => [size, `images/repub_${size.toFixed()}.png`]), @@ -13,6 +26,7 @@ const paths = Object.fromEntries( let loading: Promise | null = null; let plain: ImageData | null = null; +let canvas: OffscreenCanvas | null = null; function glyph(): Promise { loading ??= fetch(chrome.runtime.getURL("images/repub_128.png")) @@ -32,25 +46,39 @@ export function arc(portion: number): [number, number] { return [start, start + span]; } +/** + * where the ring sits `elapsed` milliseconds into a move + * + * It eases from `start` to the spot it was told about, and then drifts on + * toward the end of the step, slowing as it goes, so that a step which reports + * nothing for a while still looks like it's working. + */ +export function ringAt( + start: number, + { progress, upto }: Spot, + elapsed: number, +): number { + const swept = + start + (progress - start) * easeCubicOut(Math.min(elapsed / SWEEP, 1)); + const drift = 1 - Math.exp(-Math.max(elapsed - SWEEP, 0) / DRIFT); + return swept + (reach({ progress, upto }) - progress) * drift; +} + /** the icon, with a ring around it when there's a conversion to show */ async function paint( ring: { portion: number; color: string } | null, ): Promise { - const canvas = new OffscreenCanvas(SIZE, SIZE); + const bitmap = await glyph(); + canvas ??= new OffscreenCanvas(SIZE, SIZE); const ctx = canvas.getContext("2d"); if (!ctx) { throw new Error("couldn't draw the toolbar icon"); } + ctx.clearRect(0, 0, SIZE, SIZE); // the icon gives up room for the ring, so what it draws stays inside it const inset = ring ? STROKE * 2 : 0; - ctx.drawImage( - await glyph(), - inset, - inset, - SIZE - 2 * inset, - SIZE - 2 * inset, - ); + ctx.drawImage(bitmap, inset, inset, SIZE - 2 * inset, SIZE - 2 * inset); if (ring) { const center = SIZE / 2; @@ -73,17 +101,91 @@ async function paint( return ctx.getImageData(0, 0, SIZE, SIZE); } +/** the moving ring on one tab's icon */ +class Ring { + readonly #tabId: number; + #spot: Spot = { progress: 0, upto: 0 }; + #start = 0; + // the ring as drawn, counted in nudges so that it compares exactly + #drawn = -1; + #clock: Timer | null = null; + #drawing = false; + + constructor(tabId: number) { + this.#tabId = tabId; + } + + /** ease to `spot`, then drift on toward the end of its step */ + aim(spot: Spot): void { + // a conversion starting over, or a step that has got less far than the + // drift already showed, snaps instead of winding backwards + const shown = Math.max(this.#drawn, 0) * NUDGE; + this.#start = spot.progress < shown ? spot.progress : shown; + this.#spot = spot; + this.#clock?.stop(); + this.#clock = interval((elapsed) => { + this.#frame(elapsed); + }, FRAME); + this.#frame(0); + } + + /** stop moving and leave the icon as it is */ + stop(): void { + this.#clock?.stop(); + this.#clock = null; + } + + #frame(elapsed: number): void { + const at = ringAt(this.#start, this.#spot, elapsed); + if (reach(this.#spot) - at < NUDGE) { + // there's nowhere left to drift, so wait for the next thing reported + this.stop(); + } + const nudges = Math.round(at / NUDGE); + if (this.#drawing || nudges === this.#drawn) { + return; + } + this.#drawn = nudges; + this.#drawing = true; + paint({ portion: nudges * NUDGE, color: FILL }) + .then((imageData) => + chrome.action.setIcon({ tabId: this.#tabId, imageData }), + ) + .catch((ex: unknown) => { + console.warn(ex); + }) + .finally(() => { + this.#drawing = false; + }); + } +} + +const rings = new Map(); + +function ringOf(tabId: number): Ring { + const ring = rings.get(tabId); + if (ring) { + return ring; + } else { + const made = new Ring(tabId); + rings.set(tabId, made); + return made; + } +} + +function forget(tabId: number): void { + rings.get(tabId)?.stop(); + rings.delete(tabId); +} + /** ring the icon of `tabId` to show how much of its conversion is done */ -export async function showProgress( - tabId: number, - portion: number, -): Promise { - const imageData = await paint({ portion, color: FILL }); - await chrome.action.setIcon({ tabId, imageData }); +export function showProgress(tabId: number, spot: Spot): void { + ringOf(tabId).aim(spot); } /** ring the icon of `tabId` in red, for a conversion that failed */ export async function showFailed(tabId: number): Promise { + forget(tabId); const imageData = await paint({ portion: 1, color: FAILED }); await chrome.action.setIcon({ tabId, imageData }); } @@ -95,6 +197,7 @@ export async function showFailed(tabId: number): Promise { * again; the files are only read if it can't be drawn at all. */ export async function clearProgress(tabId: number): Promise { + forget(tabId); try { plain ??= await paint(null); await chrome.action.setIcon({ tabId, imageData: plain }); diff --git a/src/lib.ts b/src/lib.ts index ce2c0ea..ab563d5 100644 --- a/src/lib.ts +++ b/src/lib.ts @@ -3,6 +3,7 @@ import { baseTableCss, epubCss } from "./css"; import { epub, type ImageData, type ImageMime } from "./epub"; import type { EpubOptions } from "./options"; import { parse } from "./parse"; +import { steps, within } from "./progress"; type Brighten = ( buffer: Uint8Array, @@ -18,13 +19,31 @@ interface Result { title?: string; } +/** the parts of a render, weighted by how long each tends to take */ +const stage = steps({ read: 2, alter: 5, images: 4, pack: 2 }); + +/** what a render can be told beyond the page itself */ +export interface GenerateExtra { + /** whether to cut the page down to its article first */ + summarize?: boolean; + /** the title to use instead of the one read off the page */ + title?: string; + /** the author to use instead of the one read off the page */ + author?: string; + /** called as the render goes, with how much of it is done */ + onProgress?: (portion: number) => void; +} + export async function generate( mhtml: Uint8Array, brighten: Brighten, options: EpubOptions, - summarize: boolean, - initTitle?: string, - initAuthor?: string, + { + summarize = true, + title: initTitle, + author: initAuthor, + onProgress = () => {}, + }: GenerateExtra = {}, ): Promise { const { imageHrefSimilarityThreshold, @@ -61,6 +80,7 @@ export async function generate( } } + onProgress(stage.alter.from); const matcher = imageHrefSimilarityThreshold > 0 ? closeMatch(assetData, imageHrefSimilarityThreshold) @@ -88,15 +108,26 @@ export async function generate( seen.add(cover); } + // every image is its own slice of the images step, so a page full of them + // reports as each one lands rather than all at once + let done = 0; + const count = seen.size; + const ticked = (value: Value): Value => { + done += 1; + onProgress(within(stage.images, done / count).progress); + return value; + }; + + onProgress(stage.images.from); const proms = []; for (const href of seen) { const { mime, data } = assetData.get(href)!; proms.push( brighten(data, mime).then( - ([data, mime]) => [href, data, mime] as const, + ([data, mime]) => ticked([href, data, mime] as const), (err: unknown) => { console.warn(`problem brightening ${href}:`, err); - return null; + return ticked(null); }, ), ); @@ -113,6 +144,7 @@ export async function generate( brightened.set(url, { data, mime }); } + onProgress(stage.pack.from); const buffer = await epub({ title: initTitle ?? title, content: altered, @@ -123,6 +155,7 @@ export async function generate( byline: bylineHeader, cover: coverHeader ? cover : undefined, }); + onProgress(1); return { epub: buffer, title: initTitle ?? title, diff --git a/src/messages.ts b/src/messages.ts index 189d963..459ff77 100644 --- a/src/messages.ts +++ b/src/messages.ts @@ -42,6 +42,12 @@ export interface TrimResponse { zoom: Partial; } +/** how much of the epub is done, as a portion of the whole render */ +export interface ProgressResponse { + type: "progress"; + portion: number; +} + export interface ErrorMessage { type: "error"; err: string; @@ -49,7 +55,12 @@ export interface ErrorMessage { export type Message = InitMessage | TrimMessage | PartMessage; -export type Response = InitResponse | TrimResponse | PartMessage | ErrorMessage; +export type Response = + | InitResponse + | TrimResponse + | ProgressResponse + | PartMessage + | ErrorMessage; export interface TitleRequest { tabId: number; diff --git a/src/offscreen.ts b/src/offscreen.ts index 35813b7..1b43397 100644 --- a/src/offscreen.ts +++ b/src/offscreen.ts @@ -18,9 +18,15 @@ async function makeEpub( payload, bright, options, - summarize, - title, - author, + { + summarize, + title, + author, + onProgress: (portion) => { + const progress: Response = { type: "progress", portion }; + port.postMessage(progress); + }, + }, ); const encoded = fromByteArray(epub); diff --git a/src/progress.spec.ts b/src/progress.spec.ts new file mode 100644 index 0000000..2440edb --- /dev/null +++ b/src/progress.spec.ts @@ -0,0 +1,25 @@ +import { expect, test } from "bun:test"; +import { steps, within } from "./progress"; + +test("steps", () => { + expect(steps({ one: 1, three: 3 })).toEqual({ + one: { from: 0, to: 0.25 }, + three: { from: 0.25, to: 1 }, + }); + // a job of one step is the whole job + expect(steps({ all: 7 })).toEqual({ all: { from: 0, to: 1 } }); + // thirds don't divide evenly, but the last step still ends at the end + const { last } = steps({ first: 1, second: 1, last: 1 }); + expect(last.to).toBe(1); +}); + +test("within", () => { + const step = { from: 0.2, to: 0.6 }; + + expect(within(step, 0)).toEqual({ progress: 0.2, upto: 0.6 }); + expect(within(step, 0.5)).toEqual({ progress: 0.4, upto: 0.6 }); + expect(within(step, 1)).toEqual({ progress: 0.6, upto: 0.6 }); + // a step that reports more or less than it should stays inside itself + expect(within(step, -1)).toEqual({ progress: 0.2, upto: 0.6 }); + expect(within(step, 2)).toEqual({ progress: 0.6, upto: 0.6 }); +}); diff --git a/src/progress.ts b/src/progress.ts new file mode 100644 index 0000000..6deebde --- /dev/null +++ b/src/progress.ts @@ -0,0 +1,52 @@ +/** the slice of a whole job that one step covers */ +export interface Step { + /** how far along the job is when the step starts */ + from: number; + /** how far along it is once the step is done */ + to: number; +} + +/** how far along a job is, and how far the step it's in can take it */ +export interface Spot { + /** how far along the whole job is */ + progress: number; + /** how far along it will be when the current step is done */ + upto: number; +} + +/** + * cut a job into steps, each sized by how long it tends to take + * + * The weights are relative, so `{ capture: 1, render: 3 }` gives capture the + * first quarter of the job and render the rest. + */ +export function steps( + weights: Record, +): Record { + const named = Object.entries(weights) as [Name, number][]; + const total = named.reduce((sum, [, weight]) => sum + weight, 0); + const cut = {} as Record; + let before = 0; + for (const [name, weight] of named) { + // the ends divide the running total, so the last step ends at exactly one + cut[name] = { from: before / total, to: (before + weight) / total }; + before += weight; + } + return cut; +} + +// a drift stops short of the step's end, so a step that drags on doesn't leave +// the ring looking done +const CREEP = 0.85; + +/** how far along the job a step's ring gets while nothing more is reported */ +export function reach({ progress, upto }: Spot): number { + return progress + Math.max(upto - progress, 0) * CREEP; +} + +/** how far along the job `portion` of the way through `step` is */ +export function within(step: Step, portion: number): Spot { + const { from, to } = step; + const part = Math.min(Math.max(portion, 0), 1); + return { progress: from + (to - from) * part, upto: to }; +} diff --git a/src/render.ts b/src/render.ts index 570e81b..a2f1fd1 100644 --- a/src/render.ts +++ b/src/render.ts @@ -78,12 +78,22 @@ async function exchange( }); } +/** what a render can be told beyond the page itself */ +export interface RenderExtra { + /** the title to use instead of the one read off the page */ + title?: string; + /** the author to use instead of the one read off the page */ + author?: string; + /** whether to cut the page down to its article first */ + summarize?: boolean; + /** called as the render goes, with how much of it is done */ + onProgress?: (portion: number) => void; +} + export async function render( mhtml: ArrayBuffer, opts: EpubOptions, - title?: string, - author?: string, - summarize: boolean = true, + { title, author, summarize = true, onProgress = () => {} }: RenderExtra = {}, ): Promise<{ epub: Uint8Array; title?: string }> { const parts: string[] = []; let receivedParts = 0; @@ -107,6 +117,8 @@ export async function render( } else if (message.type === "info") { expectedParts = message.numParts; parsedTitle = message.title; + } else if (message.type === "progress") { + onProgress(message.portion); } if (expectedParts === receivedParts) { resolve(parts); diff --git a/src/status.ts b/src/status.ts index c09e936..57fd922 100644 --- a/src/status.ts +++ b/src/status.ts @@ -1,4 +1,5 @@ import { clearProgress, showFailed, showProgress } from "./icon"; +import type { Spot } from "./progress"; // store active state for navigation const activeTabs = new Map(); @@ -13,28 +14,16 @@ class Tab { this.#tabId = tabId; } - async init(): Promise { - try { - await showProgress(this.#tabId, 0); - } catch { - // ignore - } + init(): void { + showProgress(this.#tabId, { progress: 0, upto: 0 }); } - async progress(perc: number): Promise { - try { - await showProgress(this.#tabId, perc / 100); - } catch { - // ignore - } + progress(spot: Spot): void { + showProgress(this.#tabId, spot); } - async complete(): Promise { - try { - await showProgress(this.#tabId, 1); - } catch { - // ignore - } + complete(): void { + showProgress(this.#tabId, { progress: 1, upto: 1 }); } async error(): Promise {