diff --git a/PRD.md b/PRD.md index 94a3a08..3d0793b 100644 --- a/PRD.md +++ b/PRD.md @@ -88,6 +88,7 @@ Comments and suggestions share one **focus** model, mirroring Google Docs: - Standard file operations available two ways: the native **File menu** (New / Open… / Open Recent / Save / Save As…) and the matching keyboard shortcuts **New (Cmd+N)**, **Open (Cmd+O)**, **Save (Cmd+S)**, **Save As (Cmd+Shift+S)**, both routed through the same handlers and native OS dialogs. The app ships a native menu bar (Quill / File / Edit) so file operations are discoverable, not shortcut-only. - **Open Recent:** the last 10 opened or saved documents, most recent first, deduplicated, with the macOS-conventional **Clear Menu** item (disabled when empty). The list is owned by the frontend (persisted in localStorage); the native submenu mirrors it. Picking an entry routes through the unsaved-changes guard like any other open. +- **Export to PDF (Cmd+P):** **File → Export to PDF…** produces a **clean copy** of the document — a one-shot export for sharing with people who don't have Quill. It is print-to-PDF: a print stylesheet strips the app chrome and renders the suggesting-mode markup as if every suggestion were accepted (insertions become plain text, pending deletions and comment highlights drop out), then `window.print()` opens the OS print dialog, where "Save as PDF" writes the file. Editor typography is preserved; the OS owns pagination (US Letter, 0.75in margins). The dialog defaults the filename to the document's name. - **Window state persistence:** the window's size and position survive relaunch (`tauri-plugin-window-state`). - **Spellcheck:** the editor opts into the OS spellchecker explicitly (red-underline squiggles in the webview). - Every saved document is **two files**: `.md` (portable Markdown) and `.comments.json` (a sidecar holding comments, suggestions, the linked AI session, and the linked reference folder). The sidecar is removed on save when it holds nothing. @@ -130,5 +131,6 @@ Live **filename**, **word count**, **character count**, **line/column**, suggest - **Voice interaction** — talk to the document instead of (or alongside) typing: e.g. dictating comments, speaking a `@claude` request, or a hands-free review pass. Spoken references need a way to point at text without a cursor, so this includes a **line-number display** in the editor (e.g. a toggleable gutter) that users can read aloud ("change line 12…") and that voice commands resolve against. Scope, interaction model, and speech stack (OS-native dictation vs. a speech-to-text API) all unexplored; parked here until prioritized. - **Promote writing from a Claude Code session into Quill** — today the `@claude` integration binds a Quill document to a session that is _about that document_ (the session's transcript references the open `.md`, and Quill discovers it via `find_session_for_markdown`). For broader appeal, a user in an ordinary Claude **Code (CLI)** session — one with no associated `.md` file — should be able to run a slash command that lifts a chosen piece of writing out of the conversation and into Quill as an editable document, **with the conversation that produced it still live**. Continuity is the whole point: not just getting text into an editor, but landing in a doc whose `@claude` replies resume the original thread. That is achievable on the CLI precisely because a CLI session is a resumable on-disk artifact (`~/.claude/projects/*.jsonl`); the slash command runs _inside_ that session, so it already knows the `sessionId` and `cwd` and can **write the sidecar pre-bound** (`aiSession` populated, `createdByQuill: false` — the session genuinely authored the text). Quill then opens the doc, sees `aiSession` already set, and skips discovery entirely — no `find_session_for_markdown` guesswork. So promotion has to (a) extract the intended span — the open product question (whole reply? a fenced block? a model-judged selection?), (b) materialize it as a `.md` plus the pre-bound sidecar in the session's `cwd`, and (c) `open` it via the existing `quill://` deep link. The launch half (deep link, sidecar read, binding) already works; only the span-selection prompt and the promote command (a sibling to `quill-integration`'s `open-in-quill`) are new — **no Quill app change required**, since the pre-bound sidecar short-circuits auto-bind. The span-selection interaction model is the real unexplored piece; parked here until prioritized. (A **Claude Desktop** variant was scoped and **dropped**: Desktop chats are not resumable on-disk sessions and there is no API to rehydrate one, so capture from Desktop could move the text but never the conversation — defeating the continuity that is the feature's entire value. Desktop is the wrong surface for this until/unless its sessions become resumable.) - **Multiple documents open at once (multi-window)** — like having several Word documents open, a user should be able to open multiple Quill windows simultaneously, each holding a different document and each mapped to its own Claude Code session (or, deliberately, the same session across several docs). Today Quill is **single-window, single-document** in load-bearing ways: all editor/comment/suggestion/session state lives in one `App.tsx` instance, the crash-recovery draft uses a single `draft.json` slot justified explicitly by single-window operation, and there is no `tauri-plugin-single-instance` or window registry. So this is a real architectural change, not a toggle: per-window document state, a per-window (or per-document) draft slot, window lifecycle/menu wiring for N windows, and a decision on whether windows are independent app instances or child windows of one process. Interaction model (New Window vs. the existing New, how Open Recent and the unsaved-changes guard scope per window) unexplored; parked here until prioritized. -- **Export to PDF for sharing** — a one-shot export of the current document to PDF so it can be shared with people who don't have Quill. Open questions: whether to render the clean document or optionally include track-changes/comments (a "review copy" vs. a "clean copy"), and the rendering path (print the webview to PDF via Tauri/OS print, vs. a Markdown→HTML→PDF pipeline with controlled styling). Should honor the same typography as the editor. Parked here until prioritized. -- **All-sans-serif typography** — make Quill's type uniformly sans-serif across the editing surface and chrome for a cleaner, more modern look. Mostly a CSS/design-token change, but should be done deliberately: audit current font stacks, ensure Markdown constructs (code blocks stay monospace, headings/body unify on the sans stack), and keep it consistent with any future PDF export. Parked here until prioritized. +- **All-sans-serif typography** — make Quill's type uniformly sans-serif across the editing surface and chrome for a cleaner, more modern look. Mostly a CSS/design-token change, but should be done deliberately: audit current font stacks, ensure Markdown constructs (code blocks stay monospace, headings/body unify on the sans stack), and keep it consistent with the PDF export (§3.5), which reuses the editor's print styling. Parked here until prioritized. + + > **Review copy export** is a deliberate non-goal for now: the implemented export is clean-copy only. An option to export _with_ track-changes and comments visible (a markup view) could be added later, but was scoped out of the initial export to keep the print stylesheet simple and the artifact unambiguous. diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index 8a27986..4d27896 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -26,6 +26,7 @@ Each time Quill starts it checks whether a newer version has been released. If t ## The basics - **Open** a Markdown (`.md`) file with **File → Open…** (or Cmd/Ctrl+O), **save** with Cmd/Ctrl+S. **File → Open Recent** lists your last ten documents. +- **Export to PDF** with **File → Export to PDF…** (or Cmd/Ctrl+P) to share a clean copy with someone who doesn't have Quill. The PDF shows the finished document — any pending suggestions appear as accepted, and comment highlights and track-changes marks are left out. Pick **Save as PDF** in the print dialog that opens. - Quill remembers your window size and position between launches, and misspellings get the usual red squiggle from your system spellchecker. - The toolbar has the usual formatting: bold, italic, headings, lists, quotes. - **Find & replace** with Cmd/Ctrl+F: type to highlight matches, Enter / Shift+Enter to step through them, **Replace** / **All** to swap them out, Esc to close. In Suggesting mode a replacement shows up as a tracked change like any other edit. diff --git a/e2e/export-pdf.spec.ts b/e2e/export-pdf.spec.ts new file mode 100644 index 0000000..b4f5f2b --- /dev/null +++ b/e2e/export-pdf.spec.ts @@ -0,0 +1,128 @@ +import { test, expect } from '@playwright/test'; +import type { Page, Locator } from '@playwright/test'; + +// Export to PDF is print-to-PDF: the artifact is defined entirely by the +// `@media print` rules in App.css (handleExportPdf just calls window.print()). +// CI can't open the OS print dialog or diff a binary PDF, but it *can* emulate +// print media and read computed styles — which is exactly what these rules +// produce. So we drive the editor into a state with all three kinds of markup +// (a tracked insertion, a tracked deletion, a comment highlight), flip the page +// to print media, and assert the clean-copy contract: chrome gone, deletions +// hidden, insertions and comment highlights rendered as plain text. + +async function setup(page: Page): Promise<{ editor: Locator }> { + await page.goto('/'); + const editor = page.locator('.ProseMirror'); + await editor.waitFor({ timeout: 5000 }); + await editor.click(); + await page.waitForTimeout(100); + return { editor }; +} + +async function enableSuggesting(page: Page) { + const badge = page.locator('.mode-switch'); + await expect(badge).toContainText('Editing'); + await badge.click(); + await expect(badge).toContainText('Suggesting'); +} + +function display(page: Page, selector: string) { + return page + .locator(selector) + .first() + .evaluate((el) => getComputedStyle(el).display); +} + +test.describe('Export to PDF — print stylesheet (clean copy)', () => { + test('hides app chrome under print media', async ({ page }) => { + await setup(page); + + // On screen the toolbar and footer are visible… + await expect(page.locator('.toolbar')).toBeVisible(); + await expect(page.locator('.footer')).toBeVisible(); + + await page.emulateMedia({ media: 'print' }); + + // …and gone under print media. + expect(await display(page, '.toolbar')).toBe('none'); + expect(await display(page, '.footer')).toBe('none'); + expect(await display(page, '.comment-layer')).toBe('none'); + }); + + test('renders suggesting-mode markup as an accepted clean copy', async ({ page }) => { + const { editor } = await setup(page); + + // Committed text, then a tracked deletion of part of it and a tracked + // insertion — both halves of suggesting-mode markup present in the doc. + await editor.click(); + await page.keyboard.type('Keep cut'); + await page.waitForTimeout(100); + + await enableSuggesting(page); + await editor.click(); + + // Delete " cut" → wrapped in . + for (let i = 0; i < 4; i++) await page.keyboard.press('Backspace'); + await page.waitForTimeout(100); + // Insert " added" → wrapped in . + await page.keyboard.type(' added'); + await page.waitForTimeout(150); + + const html = await editor.innerHTML(); + expect(html).toContain('track-delete'); + expect(html).toContain('track-insert'); + + await page.emulateMedia({ media: 'print' }); + + // The struck-out original is removed entirely from the printed copy. + expect(await display(page, 'del.track-delete')).toBe('none'); + + // The insertion reads as plain text: no strike/underline decoration, no + // background wash — indistinguishable from accepted body text. + const ins = page.locator('ins.track-insert').first(); + const insStyle = await ins.evaluate((el) => { + const s = getComputedStyle(el); + return { decoration: s.textDecorationLine, bg: s.backgroundColor }; + }); + expect(insStyle.decoration).toBe('none'); + expect(insStyle.bg).toBe('rgba(0, 0, 0, 0)'); + + // The visible text of the clean copy is the kept text plus the insertion, + // with the deletion absent. (textContent still includes text, so we + // assert on the rendered/visible text instead.) + await expect(page.locator('ins.track-insert')).toHaveText(' added'); + }); + + test('strips comment highlight under print media', async ({ page }) => { + const { editor } = await setup(page); + + await editor.click(); + await page.keyboard.type('Commented text'); + await page.waitForTimeout(100); + + // Select all and add a comment via the floating + button. + await page.keyboard.down('ControlOrMeta'); + await page.keyboard.press('a'); + await page.keyboard.up('ControlOrMeta'); + await page.waitForTimeout(50); + await page.locator('.add-comment-btn').click(); + await page.locator('.add-comment-compose textarea').fill('a remark'); + await page.locator('.add-comment-compose .btn-primary').click(); + await page.waitForTimeout(150); + + expect(await editor.innerHTML()).toContain('comment-mark'); + + await page.emulateMedia({ media: 'print' }); + + // The highlight background and underline are gone; the text remains. + const markStyle = await page + .locator('mark.comment-mark') + .first() + .evaluate((el) => { + const s = getComputedStyle(el); + return { bg: s.backgroundColor, borderBottom: s.borderBottomWidth }; + }); + expect(markStyle.bg).toBe('rgba(0, 0, 0, 0)'); + expect(markStyle.borderBottom).toBe('0px'); + }); +}); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 83702b1..8c590df 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1513,6 +1513,7 @@ pub fn run() { | "menu-open" | "menu-save" | "menu-save-as" + | "menu-export-pdf" | "menu-quit" | "menu-clear-recent" | "menu-reveal-logs" @@ -1598,6 +1599,13 @@ fn build_menu(app: &tauri::AppHandle, recent: &[String]) -> Result<(), Box Result<(), Box { + const docName = filePath ? basename(filePath).replace(/\.md$/i, '') : 'Untitled'; + const prevTitle = document.title; + document.title = docName; + try { + window.print(); + } finally { + document.title = prevTitle; + } + }, [filePath]); + const performOpen = useCallback(async () => { const result = await openFile(); if (!result) return; @@ -567,6 +584,7 @@ export default function App() { handleOpen, handleSave, handleSaveAs, + handleExportPdf, handleQuit, handleCopyDiagnostics, handleRevealLogs, @@ -579,6 +597,7 @@ export default function App() { handleOpen, handleSave, handleSaveAs, + handleExportPdf, handleQuit, handleCopyDiagnostics, handleRevealLogs, @@ -600,6 +619,7 @@ export default function App() { await wire('menu-open', () => h.handleOpen()); await wire('menu-save', () => void h.handleSave()); await wire('menu-save-as', () => void h.handleSaveAs()); + await wire('menu-export-pdf', () => h.handleExportPdf()); await wire('menu-quit', () => h.handleQuit()); await wire('menu-clear-recent', () => void syncRecentMenu(clearRecentFiles())); await wire('menu-copy-diagnostics', () => void h.handleCopyDiagnostics()); @@ -687,6 +707,14 @@ export default function App() { handleNew(); return; } + if (e.key === 'p' && !e.shiftKey && !e.altKey) { + // Export to PDF (print-to-PDF). In Tauri the native menu owns this + // accelerator; here we intercept the browser's default print so the + // doc title is set first, matching the native path. + e.preventDefault(); + handleExportPdf(); + return; + } } // Cmd+F opens find & replace (re-focus when already open is handled by @@ -715,7 +743,7 @@ export default function App() { } window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); - }, [handleSave, handleSaveAs, handleOpen, handleNew, hasNativeMenu]); + }, [handleSave, handleSaveAs, handleOpen, handleNew, handleExportPdf, hasNativeMenu]); useEffect(() => { if (!editor) return;