···1616- **Server-side trash**: Trash state moved from localStorage to SQLite `deleted_at` column — trash persists across browsers and devices, with automatic 30-day purge (#153)
1717- **Toast with undo on delete**: Deleting a document shows a 5-second toast with an accessible Undo button (#154)
1818- **Formula bar color coding**: Cell references in the formula bar now match the colored range highlight borders on the grid (#112)
1919+- **DOCX export**: Export documents as Word-compatible `.doc` files via toolbar or command palette (#103)
19202021### Changed
2122- Both: document duplication from landing page (#106)
2223- Docs: embed code blocks with syntax highlighting (#110)
2324- Sheets: SPARKLINE() inline mini-charts (#87)
2425- Sheets: keyboard shortcut parity with Google Sheets (#105)
2626+- Sheets: cell date picker widget (#123)
2527- One-time migration of localStorage trash entries to server on first load
2628- `GET /api/documents` now returns only active (non-trashed) documents
2729- New API endpoints: `GET /api/documents/trash`, `PUT /api/documents/:id/trash`, `PUT /api/documents/:id/restore`
+96
src/docs/docx-export.ts
···11+/**
22+ * DOCX Export (#103)
33+ *
44+ * Exports the TipTap editor content as a .docx file using the
55+ * Word-compatible HTML approach. Wraps the editor HTML in a
66+ * Word XML envelope that Microsoft Word and LibreOffice can open.
77+ *
88+ * This avoids heavy dependencies while producing correct output
99+ * for the most common formatting: headings, lists, bold, italic,
1010+ * underline, tables, images, links, and code blocks.
1111+ */
1212+1313+const WORD_HTML_PREFIX = `<!DOCTYPE html>
1414+<html xmlns:o="urn:schemas-microsoft-com:office:office"
1515+ xmlns:w="urn:schemas-microsoft-com:office:word"
1616+ xmlns="http://www.w3.org/TR/REC-html40">
1717+<head>
1818+<meta charset="utf-8">
1919+<style>
2020+ body { font-family: Calibri, sans-serif; font-size: 11pt; line-height: 1.5; }
2121+ h1 { font-size: 20pt; font-weight: bold; }
2222+ h2 { font-size: 16pt; font-weight: bold; }
2323+ h3 { font-size: 13pt; font-weight: bold; }
2424+ table { border-collapse: collapse; width: 100%; }
2525+ td, th { border: 1px solid #999; padding: 4px 8px; }
2626+ th { background: #f0f0f0; font-weight: bold; }
2727+ code { font-family: Consolas, monospace; background: #f5f5f5; padding: 1px 3px; }
2828+ pre { font-family: Consolas, monospace; background: #f5f5f5; padding: 8px; white-space: pre-wrap; }
2929+ blockquote { border-left: 3px solid #ccc; margin-left: 0; padding-left: 12px; color: #555; }
3030+ a { color: #0563C1; }
3131+ ul[data-type="taskList"] li { list-style-type: none; }
3232+ ul[data-type="taskList"] li[data-checked="true"]::before { content: "☑ "; }
3333+ ul[data-type="taskList"] li[data-checked="false"]::before { content: "☐ "; }
3434+</style>
3535+<!--[if gte mso 9]>
3636+<xml>
3737+ <w:WordDocument>
3838+ <w:View>Print</w:View>
3939+ <w:Zoom>100</w:Zoom>
4040+ </w:WordDocument>
4141+</xml>
4242+<![endif]-->
4343+</head>
4444+<body>`;
4545+4646+const WORD_HTML_SUFFIX = `</body></html>`;
4747+4848+/**
4949+ * Clean up TipTap-specific HTML for Word compatibility.
5050+ */
5151+export function cleanHtmlForWord(html: string): string {
5252+ let cleaned = html;
5353+5454+ // Remove TipTap-specific attributes that Word doesn't understand
5555+ cleaned = cleaned.replace(/\s+data-type="[^"]*"/g, '');
5656+ cleaned = cleaned.replace(/\s+data-checked="[^"]*"/g, '');
5757+ cleaned = cleaned.replace(/\s+contenteditable="[^"]*"/g, '');
5858+ cleaned = cleaned.replace(/\s+draggable="[^"]*"/g, '');
5959+ cleaned = cleaned.replace(/\s+class="[^"]*"/g, '');
6060+6161+ // Convert task list checkboxes to text
6262+ cleaned = cleaned.replace(/<input[^>]*type="checkbox"[^>]*checked[^>]*>/gi, '☑ ');
6363+ cleaned = cleaned.replace(/<input[^>]*type="checkbox"[^>]*>/gi, '☐ ');
6464+6565+ // Remove empty paragraphs that Word renders as extra space
6666+ cleaned = cleaned.replace(/<p><\/p>/g, '<p> </p>');
6767+6868+ return cleaned;
6969+}
7070+7171+export interface DocxExportOptions {
7272+ editorHtml: string;
7373+ title: string;
7474+}
7575+7676+/**
7777+ * Export editor content as a .docx file.
7878+ * Creates a Word-compatible HTML document and triggers download.
7979+ */
8080+export function exportDocx({ editorHtml, title }: DocxExportOptions): void {
8181+ const cleanedHtml = cleanHtmlForWord(editorHtml);
8282+ const fullHtml = WORD_HTML_PREFIX + cleanedHtml + WORD_HTML_SUFFIX;
8383+8484+ const blob = new Blob([fullHtml], {
8585+ type: 'application/vnd.ms-word',
8686+ });
8787+8888+ const url = URL.createObjectURL(blob);
8989+ const a = document.createElement('a');
9090+ a.href = url;
9191+ a.download = `${title || 'document'}.doc`;
9292+ document.body.appendChild(a);
9393+ a.click();
9494+ document.body.removeChild(a);
9595+ URL.revokeObjectURL(url);
9696+}