papilot 0.8.6 → 0.8.7

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.
Files changed (44) hide show
  1. package/dist/assets/index-BIc9Mnim.css +1 -0
  2. package/dist/assets/index-MvQvvv3I.js +267 -0
  3. package/dist/assets/{math-C7ENJr-a.js → math-CBzEH7Y2.js} +1 -1
  4. package/dist/assets/{pdf-view-B2xQuHmI.js → pdf-view-ByftRBhD.js} +2 -2
  5. package/dist/index.html +2 -2
  6. package/engine/dist/web/assets/{FullPaper-DlgZ1MOz.js → FullPaper-DWwiT43p.js} +2 -2
  7. package/engine/dist/web/assets/{index-BCgVChtV.js → index-DBYJK8Uy.js} +4 -4
  8. package/engine/dist/web/assets/{index-D3_ZFdT8.css → index-DGkq8xQx.css} +1 -1
  9. package/engine/dist/web/index.html +2 -2
  10. package/engine/src/cli.ts +3 -0
  11. package/engine/src/client.ts +24 -7
  12. package/engine/src/core/build.ts +357 -93
  13. package/engine/src/core/changes.ts +39 -10
  14. package/engine/src/core/config.ts +2 -2
  15. package/engine/src/core/current-commit.ts +34 -34
  16. package/engine/src/core/delivery.ts +83 -30
  17. package/engine/src/core/files.ts +19 -0
  18. package/engine/src/core/git.ts +283 -92
  19. package/engine/src/core/process.ts +124 -11
  20. package/engine/src/core/publish.ts +59 -50
  21. package/engine/src/core/reconcile.ts +23 -21
  22. package/engine/src/core/review.ts +43 -24
  23. package/engine/src/core/tasks.ts +85 -28
  24. package/engine/src/core/view.ts +8 -8
  25. package/engine/src/server.ts +62 -6
  26. package/engine/src/types.ts +8 -0
  27. package/engine/src/web/FullPaper.tsx +122 -34
  28. package/engine/src/web/review.tsx +4 -1
  29. package/engine/src/web/style.css +24 -0
  30. package/package.json +1 -1
  31. package/src/server/agents.ts +3 -0
  32. package/src/server/annotated-pdf.ts +392 -0
  33. package/src/server/annotation-placement.ts +18 -3
  34. package/src/server/app.ts +68 -10
  35. package/src/server/changes.ts +12 -7
  36. package/src/server/cursor.ts +1 -0
  37. package/src/server/manuscript.ts +218 -56
  38. package/src/server/pdf.ts +176 -23
  39. package/src/server/references.ts +10 -2
  40. package/src/server/review.ts +6 -2
  41. package/src/server/start.ts +4 -1
  42. package/src/server/workspace.ts +18 -0
  43. package/dist/assets/index-Dm7AYM9e.js +0 -267
  44. package/dist/assets/index-PFQDdu53.css +0 -1
@@ -46,6 +46,12 @@ export function taskMarker(folder: string): [string, string | null] {
46
46
  );
47
47
  return marker ? [marker.root, marker.task] : [repo.toplevel(), null];
48
48
  }
49
+ export async function revisionReaderAsync(repo: Repo, rev: string, folder: string) {
50
+ const files = (await repo.runAsync(["ls-tree", "-r", "-z", "--name-only", rev, ...(folder ? ["--", folder] : [])]))
51
+ .toString().split("\0").filter((p) => p.endsWith(".tex")),
52
+ blobs = await repo.blobsAsync(rev, files);
53
+ return (file: string) => blobs[file]?.toString() ?? null;
54
+ }
49
55
  export function revisionReader(repo: Repo, rev: string, folder: string) {
50
56
  const files = repo.files(rev, folder).filter((p) => p.endsWith(".tex")),
51
57
  blobs = repo.blobs(rev, files);
@@ -73,6 +79,12 @@ export class Tasks {
73
79
  GENERATED,
74
80
  );
75
81
  }
82
+ snapshotAsync(task: Task) {
83
+ return new Repo(task.worktree).snapshotAsync(
84
+ [this.app.project.source_dir, ...task.scope.paths],
85
+ GENERATED,
86
+ );
87
+ }
76
88
  create(
77
89
  name?: string,
78
90
  title?: string,
@@ -80,6 +92,43 @@ export class Tasks {
80
92
  files: string[] = [],
81
93
  deferred = false,
82
94
  ): Task {
95
+ const reserved = this.reserve(name, title, sections, files);
96
+ if ("existing" in reserved) return reserved.existing;
97
+ const base = this.app.repo.snapshot([this.app.project.source_dir, ...reserved.paths], GENERATED);
98
+ const scope = this.scopeOf(sections.length ? revisionReader(this.app.repo, base, this.app.project.doc_dir) : null, sections, reserved.paths);
99
+ fs.mkdirSync(path.dirname(reserved.worktree), { recursive: true });
100
+ this.app.repo.run(["worktree", "add", "--detach", reserved.worktree, base]);
101
+ return this.record(reserved, base, scope, deferred);
102
+ }
103
+ /** `create` for the server: the snapshot and the checkout of the new
104
+ * worktree, seconds for a large paper, run while it answers requests. */
105
+ async createAsync(
106
+ name?: string,
107
+ title?: string,
108
+ sections: string[] = [],
109
+ files: string[] = [],
110
+ deferred = false,
111
+ ): Promise<Task> {
112
+ const reserved = this.reserve(name, title, sections, files);
113
+ if ("existing" in reserved) return reserved.existing;
114
+ this.creating.add(reserved.name);
115
+ try {
116
+ // The manuscript is read between writes, which may be changing it; the
117
+ // much longer checkout of the worktree is not.
118
+ const base = await this.app.exclusive(() => this.app.repo.snapshotAsync([this.app.project.source_dir, ...reserved.paths], GENERATED));
119
+ const scope = this.scopeOf(sections.length ? await revisionReaderAsync(this.app.repo, base, this.app.project.doc_dir) : null, sections, reserved.paths);
120
+ await fs.promises.mkdir(path.dirname(reserved.worktree), { recursive: true });
121
+ await this.app.repo.runAsync(["worktree", "add", "--detach", reserved.worktree, base]);
122
+ return this.record(reserved, base, scope, deferred);
123
+ } finally {
124
+ this.creating.delete(reserved.name);
125
+ }
126
+ }
127
+ // Names of tasks whose worktree is being created.
128
+ private creating = new Set<string>();
129
+ // The task's name, title, worktree and scope paths, or the open task that
130
+ // already covers this request.
131
+ private reserve(name: string | undefined, title: string | undefined, sections: string[], files: string[]): { existing: Task } | { name: string; title: string; worktree: string; paths: string[] } {
83
132
  const { app } = this,
84
133
  project = app.project;
85
134
  if (sections.length && files.length)
@@ -93,18 +142,21 @@ export class Tasks {
93
142
  );
94
143
  let candidate = name,
95
144
  suffix = 1;
96
- for (;;) {
145
+ for (;; candidate = `${name.slice(0, 60)}-${++suffix}`) {
146
+ if (this.creating.has(candidate)) {
147
+ if (explicit) throw new Error(`Task ${JSON.stringify(name)} is being created. Try again in a moment.`);
148
+ continue;
149
+ }
97
150
  const existing = app.store.one<Task>(
98
151
  "SELECT * FROM tasks WHERE name=?",
99
152
  candidate,
100
153
  );
101
154
  if (!existing || existing.closed) break;
102
- if (this.matches(existing, sections, files)) return existing;
155
+ if (this.matches(existing, sections, files)) return { existing };
103
156
  if (explicit)
104
157
  throw new Error(
105
158
  `Task ${JSON.stringify(name)} already exists with a different scope. Finish it first or choose another name.`,
106
159
  );
107
- candidate = `${name.slice(0, 60)}-${++suffix}`;
108
160
  }
109
161
  name = candidate;
110
162
  if (!app.repo.hasHead())
@@ -122,31 +174,30 @@ export class Tasks {
122
174
  throw new Error(
123
175
  `The folder ${worktree} already exists. Remove it or choose another name.`,
124
176
  );
125
- const paths = files.map(cleanPath),
126
- base = app.repo.snapshot([project.source_dir, ...paths], GENERATED);
127
- let scope: Scope;
128
- if (sections.length) {
129
- const outline = buildOutline(
130
- revisionReader(app.repo, base, project.doc_dir),
131
- project.main,
132
- );
133
- const [ranges, chosen] = selectedRanges(
134
- outline.nodes,
135
- resolveSections(outline.nodes, sections),
136
- );
137
- scope = { sections: chosen, ranges, paths: Object.keys(ranges).sort() };
138
- } else
139
- scope = {
177
+ return { name, title, worktree, paths: files.map(cleanPath) };
178
+ }
179
+ private scopeOf(read: ((file: string) => string | null) | null, sections: string[], paths: string[]): Scope {
180
+ const project = this.app.project;
181
+ if (!sections.length || !read)
182
+ return {
140
183
  sections: [],
141
184
  ranges: null,
142
185
  paths: paths.length ? paths : [project.source_dir],
143
186
  };
144
- fs.mkdirSync(path.dirname(worktree), { recursive: true });
145
- app.repo.run(["worktree", "add", "--detach", worktree, base]);
187
+ const outline = buildOutline(read, project.main);
188
+ const [ranges, chosen] = selectedRanges(
189
+ outline.nodes,
190
+ resolveSections(outline.nodes, sections),
191
+ );
192
+ return { sections: chosen, ranges, paths: Object.keys(ranges).sort() };
193
+ }
194
+ // Records a task whose worktree was just checked out at `base`.
195
+ private record({ name, title, worktree }: { name: string; title: string; worktree: string }, base: string, scope: Scope, deferred: boolean) {
196
+ const { app } = this;
146
197
  try {
147
198
  writeJson(path.join(new Repo(worktree).gitDir(), "latex-review.json"), {
148
199
  task: name,
149
- root: project.root,
200
+ root: app.project.root,
150
201
  });
151
202
  app.repo.updateRef(`refs/latex-review/${name}/base`, base);
152
203
  app.store.transaction(() => {
@@ -197,7 +248,14 @@ export class Tasks {
197
248
  JSON.stringify([...task.scope.paths].sort())
198
249
  );
199
250
  }
251
+ /** Closes the task and removes its worktree, after every other write of
252
+ * the review engine; removing a large worktree takes a while. */
200
253
  async finish(name: string, force = false) {
254
+ const result = await this.app.exclusive(() => this.finishNow(name, force));
255
+ await this.app.publisher.pruneCache();
256
+ return result;
257
+ }
258
+ private async finishNow(name: string, force: boolean) {
201
259
  this.app.delivery.assertReady(name);
202
260
  const app = this.app,
203
261
  task = this.get(name);
@@ -212,7 +270,7 @@ export class Tasks {
212
270
  `${counts.pending} pending and ${counts.commented} commented changes`,
213
271
  );
214
272
  const leftover = fs.existsSync(task.worktree)
215
- ? app.repo.changedFiles(task.accepted, this.snapshot(task))
273
+ ? await app.repo.changedFilesAsync(task.accepted, await this.snapshotAsync(task))
216
274
  : [];
217
275
  if (leftover.length)
218
276
  problems.push(
@@ -223,17 +281,16 @@ export class Tasks {
223
281
  `The task still has ${problems.join("; ")}. Publish and finish the review first, or use --force to discard them.`,
224
282
  );
225
283
  if (fs.existsSync(task.worktree))
226
- app.repo.run(["worktree", "remove", "--force", task.worktree]);
227
- app.repo.run(["worktree", "prune"], { check: false });
228
- app.repo.deleteRefs(`refs/latex-review/${name}/`);
229
- fs.rmSync(path.join(app.state, "rounds", name), {
284
+ await app.repo.runAsync(["worktree", "remove", "--force", task.worktree]);
285
+ await app.repo.runAsync(["worktree", "prune"], { check: false });
286
+ await app.repo.deleteRefsAsync(`refs/latex-review/${name}/`);
287
+ await fs.promises.rm(path.join(app.state, "rounds", name), {
230
288
  recursive: true,
231
289
  force: true,
232
290
  });
233
291
  app.store.execute("UPDATE tasks SET closed=? WHERE name=?", now(), name);
234
- await app.publisher.pruneCache();
235
292
  return {
236
- changed: app.repo.changedFiles(task.base, task.accepted),
293
+ changed: await app.repo.changedFilesAsync(task.base, task.accepted),
237
294
  discarded: counts,
238
295
  };
239
296
  }
@@ -3,7 +3,7 @@ import path from "node:path";
3
3
  import { randomBytes } from "node:crypto";
4
4
  import { Builder, BuildError } from "./build.ts";
5
5
  import { cleanPath, within } from "./git.ts";
6
- import { run } from "./process.ts";
6
+ import { capture } from "./process.ts";
7
7
  import { mupdf } from "../render/pdf.ts";
8
8
  import type { App } from "../server.ts";
9
9
  import type { BuildInfo } from "../types.ts";
@@ -72,15 +72,15 @@ function location(app: App, worktree: string, value: string): Location {
72
72
  }
73
73
  // The pages SyncTeX gives for a source line, or for the nearest line that
74
74
  // printed something.
75
- function pagesOf(pdf: string, file: string, line: number) {
75
+ async function pagesOf(pdf: string, file: string, line: number) {
76
76
  for (let distance = 0; distance <= NEARBY; distance++)
77
77
  for (const candidate of distance ? [line - distance, line + distance] : [line]) {
78
78
  if (candidate < 1) continue;
79
- const output = run(
79
+ const output = (await capture(
80
80
  "synctex",
81
81
  ["view", "-i", `${candidate}:0:${file}`, "-o", pdf],
82
82
  { check: false, timeout: 20000 },
83
- ).stdout.toString();
83
+ ).catch(() => ({ stdout: Buffer.alloc(0) }))).stdout.toString();
84
84
  const pages = [
85
85
  ...new Set(
86
86
  [...output.matchAll(/^Page:(\d+)$/gm)].map((m) => Number(m[1])),
@@ -105,12 +105,12 @@ export async function view(
105
105
  labels = (request.pages ?? []).map((p) => p.trim()).filter(Boolean);
106
106
  if (!locations.length && !labels.length)
107
107
  throw new Error("Ask for at least one source location or page number.");
108
- const after = app.tasks.snapshot(task),
108
+ const after = await app.tasks.snapshotAsync(task),
109
109
  before = task.accepted,
110
110
  builder = new Builder(project, app.repo),
111
- targets = builder.plan(before, after, [
111
+ targets = await builder.planAsync(before, after, [
112
112
  ...new Set([
113
- ...app.repo.changedFiles(before, after),
113
+ ...(await app.repo.changedFilesAsync(before, after)),
114
114
  ...locations.map((l) => l.file),
115
115
  ]),
116
116
  ]);
@@ -150,7 +150,7 @@ export async function view(
150
150
  build.root,
151
151
  path.posix.relative(project.source_dir || ".", l.file),
152
152
  ),
153
- found = fs.existsSync(file) ? pagesOf(pdf, file, l.line) : [];
153
+ found = fs.existsSync(file) ? await pagesOf(pdf, file, l.line) : [];
154
154
  if (found.length) found.forEach((page) => ask(page, l.asked));
155
155
  else missing.push(`${l.asked}: SyncTeX places no typeset text at or near this line.`);
156
156
  }
@@ -52,9 +52,23 @@ export class App {
52
52
  this.confirmations = new Confirmations(
53
53
  path.join(this.state, "references-confirmed.json"),
54
54
  );
55
- this.review.recover();
56
- this.delivery.recover();
57
- this.reconciliation.recover();
55
+ // Writes interrupted by a restart are finished first, before any other.
56
+ this.ready = this.exclusive(async () => {
57
+ await this.review.recover();
58
+ await this.delivery.recover();
59
+ await this.reconciliation.recover();
60
+ });
61
+ }
62
+ /** Settles once writes interrupted by a restart are finished. */
63
+ ready: Promise<void>;
64
+ private writing: Promise<unknown> = Promise.resolve();
65
+ /** Runs `work` after every write that started before it. Decisions,
66
+ * deliveries and task changes run Git without blocking the server, so this
67
+ * keeps them from interleaving, as when they ran in one piece. */
68
+ exclusive<T>(work: () => T | Promise<T>): Promise<T> {
69
+ const result = this.writing.then(() => work());
70
+ this.writing = result.then(() => undefined, () => undefined);
71
+ return result;
58
72
  }
59
73
  get project() {
60
74
  const config = path.join(this.root, CONFIG_NAME),
@@ -153,12 +167,14 @@ export function createServer(app: App) {
153
167
  csrf: app.csrf,
154
168
  }));
155
169
  server.get<{ Params: { id: string } }>("/api/jobs/:id", (request) => {
156
- const j = app.publisher.job(request.params.id);
170
+ const j = app.publisher.job(request.params.id),
171
+ revision = app.publisher.revision(j.id);
157
172
  return {
158
173
  id: j.id,
159
174
  task: j.task,
160
175
  status: j.status,
161
176
  phase: j.phase,
177
+ ...(revision ? { revision } : {}),
162
178
  ...j.result,
163
179
  };
164
180
  });
@@ -324,10 +340,10 @@ export function createServer(app: App) {
324
340
  },
325
341
  },
326
342
  },
327
- (request) => {
343
+ async (request) => {
328
344
  const d = request.body;
329
345
  return app.taskInfo(
330
- app.tasks.create(d.name, d.title, d.sections, d.files, d.deferred).name,
346
+ (await app.tasks.createAsync(d.name, d.title, d.sections, d.files, d.deferred)).name,
331
347
  );
332
348
  },
333
349
  );
@@ -351,6 +367,12 @@ export function createServer(app: App) {
351
367
  { schema: { body: { ...objectSchema, additionalProperties: false, properties: { confirmation: { type: "string", maxLength: 100 }, keepCurrent: { type: "boolean" }, mode: { enum: ["workspace", "commit"] }, message: { type: "string", maxLength: 5000 } } } } },
352
368
  request => app.delivery.apply(request.params.name, request.body.keepCurrent === true, request.body.mode, request.body.message, request.body.confirmation),
353
369
  );
370
+ // A speculative build of the manuscript compiles what applying the task
371
+ // would write, so its PDF is ready when the author applies it.
372
+ server.get<{ Params: { name: string } }>("/api/tasks/:name/preview", async (request) => {
373
+ const writes = await app.delivery.preview(request.params.name);
374
+ return { files: writes && Object.fromEntries(Object.entries(writes).map(([file, data]) => [file, data?.toString("base64") ?? null])) };
375
+ });
354
376
  server.post<{ Params: { name: string }; Body: { mode: "workspace" | "branch"; message?: string } }>(
355
377
  "/api/tasks/:name/deliver",
356
378
  { schema: { body: { ...objectSchema, required: ["mode"], properties: { mode: { enum: ["workspace", "branch"] }, message: { type: "string", maxLength: 5000 } } } } },
@@ -470,7 +492,31 @@ export function createServer(app: App) {
470
492
  });
471
493
  return server;
472
494
  }
495
+ interface Owner {
496
+ left: boolean;
497
+ stop: () => void;
498
+ }
473
499
  export async function serve(root: string, port = 0, idle = 12 * 3600) {
500
+ // A server started with an IPC channel belongs to the app that started it
501
+ // (see launch in client.ts) and stops when that app ends, even while it is
502
+ // still starting. Closing the channel on the way out keeps it from holding
503
+ // this process open.
504
+ if (!process.send) return run(root, port, idle);
505
+ if (!process.connected) return;
506
+ const owner: Owner = { left: false, stop: () => {} };
507
+ const left = () => {
508
+ owner.left = true;
509
+ owner.stop();
510
+ };
511
+ process.once("disconnect", left);
512
+ try {
513
+ await run(root, port, idle, owner);
514
+ } finally {
515
+ process.off("disconnect", left);
516
+ if (process.connected) process.disconnect();
517
+ }
518
+ }
519
+ async function run(root: string, port: number, idle: number, owner?: Owner) {
474
520
  root = new Repo(root).toplevel();
475
521
  process.chdir(root);
476
522
  const home = path.join(new Repo(root).gitDir(), "latex-review/server"),
@@ -479,6 +525,8 @@ export async function serve(root: string, port = 0, idle = 12 * 3600) {
479
525
  server = createServer(app);
480
526
  let timer: NodeJS.Timeout | undefined;
481
527
  try {
528
+ // Requests see the state that interrupted writes leave once finished.
529
+ await app.ready.catch(() => undefined);
482
530
  try {
483
531
  await server.listen({ host: "127.0.0.1", port });
484
532
  } catch (error) {
@@ -506,6 +554,14 @@ export async function serve(root: string, port = 0, idle = 12 * 3600) {
506
554
  };
507
555
  process.once("SIGTERM", shutdown);
508
556
  process.once("SIGINT", shutdown);
557
+ // Requests still open, such as a CLI waiting on feedback, end with it.
558
+ if (owner) {
559
+ owner.stop = () => {
560
+ shutdown();
561
+ server.server.closeAllConnections();
562
+ };
563
+ if (owner.left) owner.stop();
564
+ }
509
565
  timer = setInterval(
510
566
  () => {
511
567
  if (now() - app.activity > idle && !app.publisher.busy()) shutdown();
@@ -413,6 +413,14 @@ export interface RoundDetail extends Omit<RenderedRound, "units"> {
413
413
  /** The new version's complete PDF, when the host serves one, so the review
414
414
  * can show the changes in the whole paper. */
415
415
  pdf?: string;
416
+ /** The build whose PDF the strips come from, and the chapters it compiled
417
+ * (["full"] when it is the whole paper). */
418
+ build?: { key: string; targets: string[] };
419
+ /** While that PDF compiles, a host may show each TeX pass of it: this URL
420
+ * answers `?after=N` with `{ pass }`, a later pass whose PDF is `pdf?pass=`
421
+ * that number, `{ pass: null }` once `pdf` is complete, or `{}` to ask
422
+ * again. */
423
+ passes?: string;
416
424
  }
417
425
  export type TaskState =
418
426
  "editing" | "building" | "build_failed" | "reviewing" | "revise" | "done";
@@ -1,6 +1,7 @@
1
1
  import {
2
2
  Fragment,
3
3
  useEffect,
4
+ useLayoutEffect,
4
5
  useMemo,
5
6
  useRef,
6
7
  useState,
@@ -89,8 +90,18 @@ const clock = (ms: number) => {
89
90
  return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, "0")}`;
90
91
  };
91
92
 
93
+ interface Loaded {
94
+ pdf: PDFDocumentProxy;
95
+ width: number;
96
+ height: number;
97
+ labels: string[] | null;
98
+ // An earlier version, shown while the complete PDF compiles.
99
+ partial: boolean;
100
+ }
101
+
92
102
  export function FullPaper({
93
103
  url,
104
+ passes,
94
105
  changes,
95
106
  render,
96
107
  scrollRoot,
@@ -98,38 +109,77 @@ export function FullPaper({
98
109
  onUnavailable,
99
110
  }: {
100
111
  url: string;
112
+ passes?: string;
101
113
  changes: PlacedChange[];
102
114
  render: (block: ChangeBlock, index: number) => ReactNode;
103
115
  scrollRoot: RefObject<HTMLElement | null>;
104
116
  onReady: () => void;
105
117
  onUnavailable: () => void;
106
118
  }) {
107
- const [loaded, setLoaded] = useState<{ pdf: PDFDocumentProxy; width: number; height: number; labels: string[] | null } | null>(null),
119
+ const [loaded, setLoaded] = useState<Loaded | null>(null),
108
120
  [error, setError] = useState(""),
109
- [started] = useState(() => Date.now()),
110
- [now, setNow] = useState(() => Date.now());
121
+ [started, setStarted] = useState(() => Date.now()),
122
+ [now, setNow] = useState(() => Date.now()),
123
+ shown = useRef<PDFDocumentProxy | null>(null),
124
+ announced = useRef(false),
125
+ // Where the change being reviewed was on screen before a newer version
126
+ // replaced the pages, so that it stays there.
127
+ anchor = useRef<{ stop: string; offset: number } | null>(null);
111
128
  useEffect(() => {
112
129
  let live = true;
130
+ const stop = new AbortController(),
131
+ tasks: ReturnType<typeof pdfjs.getDocument>[] = [];
113
132
  setLoaded(null);
114
133
  setError("");
115
- const task = pdfjs.getDocument({ url, enableXfa: false });
116
- task.promise.then(
117
- async (pdf) => {
118
- const first = (await pdf.getPage(1)).getViewport({ scale: 1 }),
119
- labels = await pdf.getPageLabels().catch(() => null);
120
- if (live) setLoaded({ pdf, width: first.width, height: first.height, labels });
121
- },
122
- (failure: Error) => {
123
- if (live) setError(failure.message);
124
- },
125
- );
134
+ setStarted(Date.now());
135
+ announced.current = false;
136
+ const open = async (source: string, partial: boolean) => {
137
+ const task = pdfjs.getDocument({ url: source, enableXfa: false });
138
+ tasks.push(task);
139
+ const pdf = await task.promise,
140
+ first = (await pdf.getPage(1)).getViewport({ scale: 1 }),
141
+ labels = await pdf.getPageLabels().catch(() => null);
142
+ if (!live) return;
143
+ const root = scrollRoot.current,
144
+ current = root?.querySelector<HTMLElement>(".paper .change.current");
145
+ anchor.current = root && current?.dataset.stop
146
+ ? { stop: current.dataset.stop, offset: current.getBoundingClientRect().top - root.getBoundingClientRect().top }
147
+ : null;
148
+ setLoaded({ pdf, width: first.width, height: first.height, labels, partial });
149
+ };
150
+ void (async () => {
151
+ // Earlier versions while the complete PDF compiles, such as the
152
+ // chapters the round compiled and each TeX pass of the whole paper, so
153
+ // the changes show long before every page number has settled.
154
+ for (let after = 0; passes && live; ) {
155
+ const response = await fetch(`${passes}?after=${after}`, { signal: stop.signal }),
156
+ next = (await response.json()) as { pass?: number | null; error?: string };
157
+ if (!response.ok) throw new Error(next.error || `HTTP ${response.status}`);
158
+ if (next.pass === null) break;
159
+ if (typeof next.pass === "number" && next.pass > after) {
160
+ after = next.pass;
161
+ // A version that cannot be shown is skipped for the next one.
162
+ await open(`${url}${url.includes("?") ? "&" : "?"}pass=${after}`, true).catch(() => undefined);
163
+ }
164
+ }
165
+ if (live) await open(url, false);
166
+ })().catch((failure: Error) => {
167
+ if (live) setError(failure.message);
168
+ });
126
169
  return () => {
127
170
  live = false;
128
- void task.destroy();
171
+ stop.abort();
172
+ for (const task of tasks) void task.destroy();
129
173
  };
130
- }, [url]);
174
+ }, [url, passes]);
175
+ // A newer version replaces the one on screen once its pages are in place.
176
+ useEffect(() => {
177
+ const previous = shown.current;
178
+ shown.current = loaded?.pdf ?? null;
179
+ if (previous && previous !== shown.current) void previous.loadingTask.destroy();
180
+ }, [loaded]);
131
181
  useEffect(() => {
132
- if (loaded || error) return;
182
+ if ((loaded && !loaded.partial) || error) return;
133
183
  const timer = setInterval(() => setNow(Date.now()), 1000);
134
184
  return () => clearInterval(timer);
135
185
  }, [loaded, error]);
@@ -143,6 +193,11 @@ export function FullPaper({
143
193
  },
144
194
  [loaded, changes],
145
195
  );
196
+ const preparing = (
197
+ <>
198
+ {t("Preparing the full PDF…")} <span className="paper-clock">{clock(now - started)}</span>
199
+ </>
200
+ );
146
201
  if (error)
147
202
  return (
148
203
  <div className="paper-status" role="alert">
@@ -156,13 +211,36 @@ export function FullPaper({
156
211
  return (
157
212
  <div className="paper-status" role="status">
158
213
  <div className="pulse" />
159
- <p>
160
- {t("Preparing the full PDF…")} <span className="paper-clock">{clock(now - started)}</span>
161
- </p>
214
+ <p>{preparing}</p>
162
215
  </div>
163
216
  );
164
217
  return (
165
- <Pages paper={paper} render={render} scrollRoot={scrollRoot} onReady={onReady} />
218
+ <>
219
+ {loaded?.partial && (
220
+ <div className="paper-updating" role="status">
221
+ <span className="pulse" />
222
+ <span>{preparing}</span>
223
+ </div>
224
+ )}
225
+ <Pages
226
+ paper={paper}
227
+ render={render}
228
+ scrollRoot={scrollRoot}
229
+ onReady={() => {
230
+ if (!announced.current) {
231
+ announced.current = true;
232
+ onReady();
233
+ return;
234
+ }
235
+ // Later versions keep the change being reviewed where it was.
236
+ const root = scrollRoot.current,
237
+ kept = anchor.current,
238
+ current = kept && root?.querySelector<HTMLElement>(`.paper [data-stop="${kept.stop}"]`);
239
+ if (root && kept && current)
240
+ root.scrollTop += current.getBoundingClientRect().top - root.getBoundingClientRect().top - kept.offset;
241
+ }}
242
+ />
243
+ </>
166
244
  );
167
245
  }
168
246
 
@@ -181,8 +259,9 @@ function Pages({
181
259
  ready = useRef(onReady);
182
260
  ready.current = onReady;
183
261
  useEffect(() => paint(box.current!, paper, scrollRoot.current), [paper, scrollRoot]);
184
- // The pages exist now: the selected change can be scrolled to.
185
- useEffect(() => ready.current(), [paper]);
262
+ // The pages exist now: the selected change can be scrolled to, before
263
+ // they are painted.
264
+ useLayoutEffect(() => ready.current(), [paper]);
186
265
  const slice = (page: number, top: number, bottom: number) =>
187
266
  bottom - top > 0.5 && (
188
267
  <div className="paper-context" key={`${page}:${top}`}>
@@ -227,15 +306,18 @@ function paint(container: HTMLElement, paper: Paper, root: HTMLElement | null) {
227
306
  width = container.clientWidth,
228
307
  stopped = false;
229
308
  const pages = () => Array.from(container.querySelectorAll<HTMLElement>(".paper-page"));
309
+ const free = (canvas: HTMLCanvasElement) => {
310
+ canvas.remove();
311
+ canvas.width = canvas.height = 0;
312
+ };
313
+ // Pages painted from an earlier version of the PDF stay until this one's
314
+ // replace them, so a newer version comes in without the pages blanking.
230
315
  const release = (index: number) => {
231
316
  const entry = alive.get(index);
232
- if (!entry) return;
233
317
  alive.delete(index);
234
- entry.task?.cancel();
235
- for (const canvas of entry.canvases) {
236
- canvas.remove();
237
- canvas.width = canvas.height = 0;
238
- }
318
+ entry?.task?.cancel();
319
+ entry?.canvases.forEach(free);
320
+ pages()[index]?.querySelectorAll("canvas").forEach(free);
239
321
  };
240
322
  const pump = () => {
241
323
  while (!stopped && running < 2) {
@@ -254,7 +336,7 @@ function paint(container: HTMLElement, paper: Paper, root: HTMLElement | null) {
254
336
  alive.set(index, entry);
255
337
  try {
256
338
  const page = await paper.pdf.getPage(index + 1);
257
- if (alive.get(index) !== entry) return;
339
+ if (stopped || alive.get(index) !== entry) return;
258
340
  const slices = Array.from(element.querySelectorAll<HTMLElement>(".paper-slice")),
259
341
  cssWidth = slices[0]?.clientWidth || element.clientWidth,
260
342
  size = page.getViewport({ scale: 1 }),
@@ -265,7 +347,7 @@ function paint(container: HTMLElement, paper: Paper, root: HTMLElement | null) {
265
347
  source.height = Math.floor(viewport.height);
266
348
  entry.task = page.render({ canvas: source, viewport });
267
349
  await entry.task.promise;
268
- if (alive.get(index) !== entry) return;
350
+ if (stopped || alive.get(index) !== entry) return;
269
351
  for (const slice of slices) {
270
352
  const top = Math.round(Number(slice.dataset.top) * scale),
271
353
  bottom = Math.min(source.height, Math.round(Number(slice.dataset.bottom) * scale));
@@ -274,12 +356,13 @@ function paint(container: HTMLElement, paper: Paper, root: HTMLElement | null) {
274
356
  canvas.width = source.width;
275
357
  canvas.height = bottom - top;
276
358
  canvas.getContext("2d")!.drawImage(source, 0, top, source.width, bottom - top, 0, 0, source.width, bottom - top);
359
+ slice.querySelectorAll("canvas").forEach(free);
277
360
  slice.append(canvas);
278
361
  entry.canvases.push(canvas);
279
362
  }
280
363
  source.width = source.height = 0;
281
364
  } catch (failure) {
282
- if ((failure as Error)?.name !== "RenderingCancelledException") console.error(failure);
365
+ if (!stopped && (failure as Error)?.name !== "RenderingCancelledException") console.error(failure);
283
366
  if (alive.get(index) === entry) alive.delete(index);
284
367
  } finally {
285
368
  running--;
@@ -305,7 +388,11 @@ function paint(container: HTMLElement, paper: Paper, root: HTMLElement | null) {
305
388
  const resized = new ResizeObserver(() => {
306
389
  if (Math.abs(container.clientWidth - width) < 1) return;
307
390
  width = container.clientWidth;
308
- for (const index of [...alive.keys()]) release(index);
391
+ // Repainted at the new width over the current pages.
392
+ for (const [index, entry] of [...alive]) {
393
+ entry.task?.cancel();
394
+ alive.delete(index);
395
+ }
309
396
  pump();
310
397
  });
311
398
  resized.observe(container);
@@ -313,6 +400,7 @@ function paint(container: HTMLElement, paper: Paper, root: HTMLElement | null) {
313
400
  stopped = true;
314
401
  observer.disconnect();
315
402
  resized.disconnect();
316
- for (const index of [...alive.keys()]) release(index);
403
+ // The canvases stay for the next version, or go with the page.
404
+ for (const entry of alive.values()) entry.task?.cancel();
317
405
  };
318
406
  }
@@ -282,9 +282,11 @@ export function ReviewApp({
282
282
  ? ["pending", "accepted"].includes(decisions[id])
283
283
  : decisions[id] === "pending",
284
284
  );
285
+ // Only undecided changes can be decided: a decided one is changed back
286
+ // with Undo while that is possible.
285
287
  const kinds = (units: string[]): Kind[] =>
286
288
  !changes
287
- ? ["accept", "reject", "comment"]
289
+ ? targets(units, "accept").length ? ["accept", "reject", "comment"] : []
288
290
  : targets(units, "accept").length
289
291
  ? ["accept", "reject"]
290
292
  : targets(units, "unstage").length
@@ -1569,6 +1571,7 @@ export function ReviewApp({
1569
1571
  <Suspense fallback={<div className="paper-status" role="status"><div className="pulse" /></div>}>
1570
1572
  <FullPaper
1571
1573
  url={detail.pdf!}
1574
+ passes={detail.passes}
1572
1575
  changes={placed}
1573
1576
  render={(block, at) => change(block, at, true)}
1574
1577
  scrollRoot={paper}