Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions src/apps/desktop/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -155,10 +155,15 @@ when moving or sharing the project. **Save PDF** writes an editable
on the viewing device. Saved PDFs embed their review state so notes and threads
can be edited again when reopened in OpenBitFun; other PDF applications see
standard PDF annotations. Replacing an existing output checks its current
version and asks before overwriting an externally changed file.
version and asks before overwriting an externally changed file. If the review
file changes elsewhere while this window has unsaved notes, saving pauses until
you choose **Replace with my notes** or **Load the file's notes**; Undo restores
the notes replaced by loading. Encrypted PDFs can be reviewed, but **Save PDF**
and **Export annotated PDF** are unavailable for them.

Annotations retain PDF page coordinates across compilation. Check their positions
when a revision changes the layout; the preview shows a reminder after recompiling.
when a revision changes the layout; after recompiling, the preview shows a
reminder until you select **Positions checked**.
LaTeX source remains the place to change manuscript text and figures.

![LaTeX source, PDF preview, and review notes in a sample writing workspace](../../../docs/images/paper-writing-workbench.png)
Expand Down
6 changes: 6 additions & 0 deletions src/web-ui/src/locales/en-US/tools.json
Original file line number Diff line number Diff line change
Expand Up @@ -296,6 +296,12 @@
"reopen": "Reopen thread",
"remove": "Delete annotation",
"recompiled": "The PDF was recompiled. Annotations are retained by page; check their positions before saving.",
"encrypted": "This PDF is encrypted. Notes are saved in the review file, but an annotated PDF copy can't be created.",
"exportUnavailable": "This PDF can't be prepared for an annotated copy. Notes are still saved in the review file.",
"conflict": "The review file changed on the workspace host. Choose which notes to keep.",
"keepMine": "Replace with my notes",
"reloadFile": "Load the file's notes",
"confirmLayout": "Positions checked",
"hints": {
"highlight": "Select text in the PDF to highlight it.",
"note": "Click a position on the PDF to add a note.",
Expand Down
6 changes: 6 additions & 0 deletions src/web-ui/src/locales/zh-CN/tools.json
Original file line number Diff line number Diff line change
Expand Up @@ -296,6 +296,12 @@
"reopen": "重新打开评论",
"remove": "删除批注",
"recompiled": "PDF 已重新编译,批注按页保留;保存前请核对位置。",
"encrypted": "该 PDF 已加密。批注会保存在批注文件中,但无法生成带批注的 PDF 副本。",
"exportUnavailable": "无法为该 PDF 生成带批注的副本。批注仍会保存在批注文件中。",
"conflict": "批注文件已在工作区主机上被修改。请选择要保留的批注。",
"keepMine": "用我的批注覆盖",
"reloadFile": "载入文件中的批注",
"confirmLayout": "已核对位置",
"hints": {
"highlight": "在 PDF 中选中文字即可添加高亮。",
"note": "点击 PDF 中的位置即可添加笔记。",
Expand Down
6 changes: 6 additions & 0 deletions src/web-ui/src/locales/zh-TW/tools.json
Original file line number Diff line number Diff line change
Expand Up @@ -296,6 +296,12 @@
"reopen": "重新開啟評論",
"remove": "刪除註解",
"recompiled": "PDF 已重新編譯,註解按頁保留;儲存前請核對位置。",
"encrypted": "此 PDF 已加密。註解會儲存在註解檔中,但無法產生含註解的 PDF 副本。",
"exportUnavailable": "無法為此 PDF 產生含註解的副本。註解仍會儲存在註解檔中。",
"conflict": "註解檔已在工作區主機上被修改。請選擇要保留的註解。",
"keepMine": "以我的註解覆寫",
"reloadFile": "載入檔案中的註解",
"confirmLayout": "已核對位置",
"hints": {
"highlight": "在 PDF 中選取文字即可加入醒目提示。",
"note": "點擊 PDF 中的位置即可新增筆記。",
Expand Down
56 changes: 55 additions & 1 deletion src/web-ui/src/tools/editor/components/LatexEditor.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ vi.mock('./CodeEditor', () => ({
},
}));
vi.mock('../pdf-review/PdfReviewEditor', () => ({
default: ({ pdfBase64 }: import('./PdfViewer').PdfViewerProps) => <div data-pdf={pdfBase64} />,
default: ({ pdfBase64, filePath }: import('./PdfViewer').PdfViewerProps) => <div data-pdf={pdfBase64} data-file={filePath} />,
}));

let container: HTMLDivElement;
Expand Down Expand Up @@ -201,3 +201,57 @@ it.each([false, true])('inserts body snippets after the document start without r
expect(source).toContain('\\begin{document}\n\\section{Title}\n');
expect(source).toContain('Existing text\n\\end{document}');
});

function mainFileInput() {
const input = container.querySelector<HTMLInputElement>('input[aria-label="editor.latexEditor.mainFile"]');
if (!input) throw new Error('Main file input is missing');
return input;
}
async function typeMainFile(value: string) {
const input = mainFileInput();
await act(async () => {
Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!.call(input, value);
input.dispatchEvent(new Event('input', { bubbles: true }));
await vi.advanceTimersByTimeAsync(1000);
});
}
async function enableAutoCompile() {
await act(async () => { container.querySelector<HTMLInputElement>('[role="switch"]')!.click(); });
await act(async () => { await vi.advanceTimersByTimeAsync(400); });
}

it('keeps the build target and review owner until the main-file entry is committed', async () => {
vi.useFakeTimers();
mocks.invoke.mockResolvedValue({ pdfBase64: 'build' });
await act(async () => { renderEditor(); });
await enableAutoCompile();
expect(mocks.invoke).toHaveBeenCalledTimes(1);
await act(async () => mainFileInput().focus());
for (const partial of ['c', 'chapters/', 'chapters/paper.tex']) await typeMainFile(partial);
expect(mocks.invoke).toHaveBeenCalledTimes(1);
expect(container.querySelector('[data-pdf]')?.getAttribute('data-file')).toBe(path);

await act(async () => { mainFileInput().dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); });
await act(async () => { await vi.advanceTimersByTimeAsync(400); });
expect(mocks.invoke).toHaveBeenCalledTimes(2);
expect(mocks.invoke).toHaveBeenLastCalledWith(origin, '/project/chapters/paper.tex', 'pdflatex');
expect(container.querySelector('[data-pdf]')?.getAttribute('data-file')).toBe('/project/chapters/paper.tex');
});

it('restores the committed main file on Escape and commits an edited entry on blur', async () => {
vi.useFakeTimers();
mocks.invoke.mockResolvedValue({ pdfBase64: 'build' });
await act(async () => { renderEditor(); });
await enableAutoCompile();
await act(async () => mainFileInput().focus());
await typeMainFile('draft.tex');
await act(async () => { mainFileInput().dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); });
expect(mainFileInput().value).toBe('');
await act(async () => { await vi.advanceTimersByTimeAsync(1000); });
expect(mocks.invoke).toHaveBeenCalledTimes(1);

await typeMainFile('appendix.tex');
await act(async () => mainFileInput().blur());
await act(async () => { await vi.advanceTimersByTimeAsync(400); });
expect(mocks.invoke).toHaveBeenLastCalledWith(origin, '/project/appendix.tex', 'pdflatex');
});
23 changes: 20 additions & 3 deletions src/web-ui/src/tools/editor/components/LatexEditor.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Suspense, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { Suspense, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type KeyboardEvent } from 'react';
import { Button, IconButton, Input, Select, SplitView, Switch, Tooltip } from '@openbitfun/ui';
import { Code2, FileText, ListTree, Logs, PanelLeft, PanelRight, Play, Redo2, Save, Search, Undo2, X } from 'lucide-react';
import type * as Monaco from 'monaco-editor';
Expand Down Expand Up @@ -33,6 +33,8 @@ export default function LatexEditor(props: CodeEditorProps) {
const [pdfOpen, setPdfOpen] = useState(retained?.pdf ?? true);
const [previewWidth, setPreviewWidth] = useState(retained?.width ?? INITIAL_PREVIEW_WIDTH);
const [mainFile, setMainFile] = useState(retained?.mainFile ?? '');
// The build target and review owner change only on commit, never per keystroke.
const [mainFileDraft, setMainFileDraft] = useState(mainFile);
const [source, setSource] = useState(document?.snapshot?.content ?? '');
const [outlineOpen, setOutlineOpen] = useState(false);
const [logsOpen, setLogsOpen] = useState(false);
Expand Down Expand Up @@ -173,6 +175,21 @@ export default function LatexEditor(props: CodeEditorProps) {
try { await document?.saveAll(); }
catch (failure) { setError(String(failure)); setLog(String(failure)); setLogsOpen(true); }
};
const commitMainFile = () => {
const value = mainFileDraft.trim();
setMainFileDraft(value);
setMainFile(value);
};
const handleMainFileKey = (event: KeyboardEvent<HTMLInputElement>) => {
if (event.key === 'Enter') {
event.preventDefault();
commitMainFile();
} else if (event.key === 'Escape' && mainFileDraft !== mainFile) {
event.preventDefault();
event.stopPropagation();
setMainFileDraft(mainFile);
}
};

return <section className="latex-editor" data-openbitfun-component="latex-editor" data-openbitfun-part="root">
<div className="latex-editor__header" data-openbitfun-component="latex-editor" data-openbitfun-part="header">
Expand Down Expand Up @@ -214,8 +231,8 @@ export default function LatexEditor(props: CodeEditorProps) {
<div className="latex-editor__source-bar">
<Tooltip content={t('editor.latexEditor.outline')} placement="bottom"><IconButton size="sm" icon={<ListTree size={15} />}
aria-label={t('editor.latexEditor.outline')} aria-pressed={outlineOpen} onClick={() => setOutlineOpen(value => !value)} /></Tooltip>
<Input size="sm" aria-label={t('editor.latexEditor.mainFile')} value={mainFile} placeholder={latexRootHint(source) || fileName}
onValueChange={setMainFile} title={t('editor.latexEditor.mainFileHint')} />
<Input size="sm" aria-label={t('editor.latexEditor.mainFile')} value={mainFileDraft} placeholder={latexRootHint(source) || fileName}
onValueChange={setMainFileDraft} onBlur={commitMainFile} onKeyDown={handleMainFileKey} title={t('editor.latexEditor.mainFileHint')} />
<IconButton size="sm" aria-label={t('editor.latexEditor.closeSource')} icon={<X size={14} />} onClick={() => setSourceOpen(false)} />
</div>
<div className="latex-editor__source-body">
Expand Down
83 changes: 82 additions & 1 deletion src/web-ui/src/tools/editor/pdf-review/PdfReviewEditor.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,27 @@
import { webcrypto } from 'node:crypto';
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { PDFDocument } from 'pdf-lib';
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
import { activateSurface } from '@/infrastructure/peer-device/deviceSurface';
import { EditorDocument, EditorDocumentContext } from '../services/EditorDocument';
import PdfReviewEditor from './PdfReviewEditor';
import { getPdfReviewSession } from './PdfReviewSession';
import { parsePdfReview, type PdfReviewAnnotation } from './pdfReview';
import { encryptedPdf } from './pdfReview.test-support';

const viewer = vi.hoisted(() => ({ pdf: undefined as unknown }));

vi.mock('@/infrastructure/i18n', () => ({
useI18n: () => ({ t: (key: string) => key, formatNumber: String }),
}));
vi.mock('../components/PdfViewer', () => ({ default: () => <div data-pdf-viewer /> }));
vi.mock('../components/PdfViewer', async () => {
const { useEffect } = await import('react');
return { default: ({ onDocumentLoaded }: { onDocumentLoaded?: (pdf: unknown) => void }) => {
useEffect(() => { if (viewer.pdf) onDocumentLoaded?.(viewer.pdf); }, []);
return <div data-pdf-viewer />;
} };
});

let container: HTMLDivElement;
let root: Root;
Expand Down Expand Up @@ -48,6 +58,7 @@ beforeEach(async () => {
afterEach(async () => {
await act(async () => root.unmount());
container.remove();
viewer.pdf = undefined;
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
Expand All @@ -57,6 +68,25 @@ async function click(selector: string) {
if (!button) throw new Error(`Missing review control: ${selector}`);
await act(async () => button.click());
}
function buttonWithText(text: string) {
const button = [...container.querySelectorAll('button')].find(candidate => candidate.textContent === text);
if (!button) throw new Error(`Missing review control: ${text}`);
return button;
}
/** Asynchronous PDF inspection and host writes settle outside the triggering event. */
async function waitUntil(condition: () => boolean) {
for (let attempt = 0; attempt < 100 && !condition(); attempt++) {
await act(async () => { await new Promise(resolve => setTimeout(resolve, 10)); });
}
expect(condition()).toBe(true);
}
async function openLoadedPdf(bytes: Uint8Array, fingerprint = 'compiled-b') {
viewer.pdf = { fingerprints: [fingerprint], getData: async () => bytes.slice() };
await act(async () => root.render(null));
await act(async () => root.render(<EditorDocumentContext.Provider value={document}>
<PdfReviewEditor filePath={path} />
</EditorDocumentContext.Provider>));
}
const thread = (index: number) => `.pdf-review-editor__thread:nth-child(${index})`;
async function writeDraft(value: string) {
const field = container.querySelector<HTMLTextAreaElement>('textarea')!;
Expand Down Expand Up @@ -139,3 +169,54 @@ it('keeps a collapsed comment draft and includes it in document Save All', async
const content = writes[writes.length - 1]![1].request.content as string;
expect(parsePdfReview(content).annotations[0]!.comments.at(-1)?.body).toBe('Pending reply while the comments are closed');
});

it('keeps an encrypted PDF reviewable without offering a broken annotated copy', async () => {
await openLoadedPdf(await encryptedPdf());
await waitUntil(() => container.textContent?.includes('editor.pdfReview.encrypted') ?? false);
expect(container.querySelector('[role="alert"]')).toBeNull();
expect(buttonWithText('editor.pdfReview.savePdf').disabled).toBe(true);
expect(container.querySelector<HTMLButtonElement>('[aria-label="editor.pdfReview.export"]')?.disabled).toBe(true);
});

it('keeps the recompiled-layout notice until the user confirms the positions', async () => {
const session = getPdfReviewSession(document, path);
session.setFingerprint('compiled-a');
session.replace([...session.getSnapshot().data.annotations, note('third')]);
await session.save();
const pdf = await PDFDocument.create(); pdf.addPage();
await openLoadedPdf(await pdf.save(), 'compiled-b');
await waitUntil(() => container.textContent?.includes('editor.pdfReview.recompiled') ?? false);
await act(async () => buttonWithText('editor.pdfReview.confirmLayout').click());
await waitUntil(() => !container.textContent?.includes('editor.pdfReview.recompiled'));
expect(session.getSnapshot().data.fingerprint).toBe('compiled-b');
});

it('asks which notes to keep after the review file changes on the host', async () => {
const session = getPdfReviewSession(document, path);
const theirs = JSON.stringify({ version: 1, fingerprint: '', annotations: [note('theirs')] }, null, 2) + '\n';
vi.spyOn(document.files, 'readFileContent').mockResolvedValue(theirs);
vi.mocked(document.invoke).mockRejectedValueOnce(new Error('FILE_CONFLICT: File changed after it was read; refresh before saving'));
await act(async () => {
session.replace([note('mine')]);
await session.save().catch(() => undefined);
});
expect(container.textContent).toContain('editor.pdfReview.conflict');
expect(container.textContent).not.toContain('editor.common.retry');
await act(async () => buttonWithText('editor.pdfReview.keepMine').click());
await waitUntil(() => !container.textContent?.includes('editor.pdfReview.conflict'));
const writes = vi.mocked(document.invoke).mock.calls;
expect(parsePdfReview(writes.at(-1)![1].request.content as string).annotations.map(item => item.id)).toEqual(['mine']);
});

it('does not fail document Save All for an unreadable review file without edits', async () => {
const blocked = new EditorDocument('review-blocked', { surfaceId: 'local', workspaceId: 'workspace-a' }, path);
vi.spyOn(blocked.files, 'getFileMetadata').mockRejectedValue(new Error('Permission denied'));
const write = vi.spyOn(blocked, 'invoke').mockResolvedValue(undefined);
await act(async () => root.render(<EditorDocumentContext.Provider value={blocked}>
<PdfReviewEditor filePath={path} />
</EditorDocumentContext.Provider>));
await act(async () => { await getPdfReviewSession(blocked, path).load(); });
expect(getPdfReviewSession(blocked, path).getSnapshot().readOnly).toBe(true);
await expect(blocked.saveAll()).resolves.toBeUndefined();
expect(write).not.toHaveBeenCalled();
});
Loading
Loading