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
6 changes: 4 additions & 2 deletions PRD.md
Original file line number Diff line number Diff line change
Expand Up @@ -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**: `<name>.md` (portable Markdown) and `<name>.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.
Expand Down Expand Up @@ -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.
1 change: 1 addition & 0 deletions docs/USER_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
128 changes: 128 additions & 0 deletions e2e/export-pdf.spec.ts
Original file line number Diff line number Diff line change
@@ -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 <del class="track-delete">.
for (let i = 0; i < 4; i++) await page.keyboard.press('Backspace');
await page.waitForTimeout(100);
// Insert " added" → wrapped in <ins class="track-insert">.
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 <del> 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');
});
});
10 changes: 10 additions & 0 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -1598,6 +1599,13 @@ fn build_menu(app: &tauri::AppHandle, recent: &[String]) -> Result<(), Box<dyn s
true,
Some("CmdOrCtrl+Shift+S"),
)?;
let export_pdf_item = MenuItem::with_id(
app,
"menu-export-pdf",
"Export to PDF…",
true,
Some("CmdOrCtrl+P"),
)?;

// Open Recent: one item per remembered path (id carries the full path so
// the click handler can forward it), then Clear Menu — disabled when there
Expand Down Expand Up @@ -1638,6 +1646,8 @@ fn build_menu(app: &tauri::AppHandle, recent: &[String]) -> Result<(), Box<dyn s
&PredefinedMenuItem::separator(app)?,
&save_item,
&save_as_item,
&PredefinedMenuItem::separator(app)?,
&export_pdf_item,
],
)?;

Expand Down
99 changes: 99 additions & 0 deletions src/App.css
Original file line number Diff line number Diff line change
Expand Up @@ -1845,3 +1845,102 @@ del.track-delete {
background: var(--color-border);
border-radius: 2px;
}

/* ── Print / Export to PDF ──────────────────────────────────────────
"Export to PDF" is print-to-PDF (handleExportPdf → window.print()), so
these rules define the printed artifact: a *clean copy* of the document.
We strip all app chrome, collapse the on-screen scroll/zoom containers so
the document reflows naturally across paper pages, and render the
track-changes / comment markup as if every suggestion were accepted —
insertions become plain text, deletions and comment highlights vanish.
The browser's print pipeline (macOS "Save as PDF") owns pagination. */
@media print {
/* US Letter with comfortable margins; the editor's own page padding is
reset below so geometry isn't doubled. */
@page {
size: letter;
margin: 0.75in;
}

/* Hide every piece of chrome and any transient overlay. */
.toolbar,
.footer,
.comment-layer,
.find-bar,
.update-banner,
.add-comment-btn,
.add-comment-compose,
.app-modal-overlay {
display: none !important;
}

/* Collapse the on-screen layout: the app is height:100vh / overflow:hidden
with an internal scroll area, none of which makes sense on paper. Let
content flow and paginate. */
html,
body,
.app,
.workspace,
.editor-scroll-area {
display: block !important;
height: auto !important;
overflow: visible !important;
background: #fff !important;
}
.editor-scroll-area {
padding: 0 !important;
align-items: stretch !important;
}

/* Drop the screen zoom and the dashed page-break guide lines; the page is
the paper now. Full-width, no page card border or rounded corners. */
.editor-page-zoom-wrapper {
zoom: 1 !important;
}
.editor-page {
width: auto !important;
border: none !important;
border-radius: 0 !important;
padding: 0 !important;
background: #fff !important;
}
.ProseMirror {
min-height: 0 !important;
background-image: none !important;
color: #000 !important;
}

/* Clean copy of the suggesting-mode markup: accept insertions (plain text),
drop deletions entirely, and remove comment / find / focus highlights. */
ins.track-insert {
background: none !important;
color: inherit !important;
text-decoration: none !important;
padding: 0 !important;
}
del.track-delete {
display: none !important;
}
mark.comment-mark,
.ProseMirror .pending-comment,
.ProseMirror .find-match,
.ProseMirror .find-match-active,
.ProseMirror .annotation-focus {
background: none !important;
border: none !important;
text-decoration: none !important;
padding: 0 !important;
}

/* Avoid orphaned headings and split code/quote blocks where possible. */
.ProseMirror h1,
.ProseMirror h2,
.ProseMirror h3 {
break-after: avoid;
}
.ProseMirror pre,
.ProseMirror blockquote,
.ProseMirror table {
break-inside: avoid;
}
}
Loading
Loading