Full document, spreadsheet, slideshow, and diagram tooling
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(docs): DOCX export via Word-compatible HTML envelope (#103)

Add .doc export that wraps editor HTML in a Word XML envelope,
compatible with Microsoft Word and LibreOffice. Includes toolbar
button, command palette action, and HTML cleanup for TipTap attributes.

+200
+2
CHANGELOG.md
··· 16 16 - **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) 17 17 - **Toast with undo on delete**: Deleting a document shows a 5-second toast with an accessible Undo button (#154) 18 18 - **Formula bar color coding**: Cell references in the formula bar now match the colored range highlight borders on the grid (#112) 19 + - **DOCX export**: Export documents as Word-compatible `.doc` files via toolbar or command palette (#103) 19 20 20 21 ### Changed 21 22 - Both: document duplication from landing page (#106) 22 23 - Docs: embed code blocks with syntax highlighting (#110) 23 24 - Sheets: SPARKLINE() inline mini-charts (#87) 24 25 - Sheets: keyboard shortcut parity with Google Sheets (#105) 26 + - Sheets: cell date picker widget (#123) 25 27 - One-time migration of localStorage trash entries to server on first load 26 28 - `GET /api/documents` now returns only active (non-trashed) documents 27 29 - New API endpoints: `GET /api/documents/trash`, `PUT /api/documents/:id/trash`, `PUT /api/documents/:id/restore`
+96
src/docs/docx-export.ts
··· 1 + /** 2 + * DOCX Export (#103) 3 + * 4 + * Exports the TipTap editor content as a .docx file using the 5 + * Word-compatible HTML approach. Wraps the editor HTML in a 6 + * Word XML envelope that Microsoft Word and LibreOffice can open. 7 + * 8 + * This avoids heavy dependencies while producing correct output 9 + * for the most common formatting: headings, lists, bold, italic, 10 + * underline, tables, images, links, and code blocks. 11 + */ 12 + 13 + const WORD_HTML_PREFIX = `<!DOCTYPE html> 14 + <html xmlns:o="urn:schemas-microsoft-com:office:office" 15 + xmlns:w="urn:schemas-microsoft-com:office:word" 16 + xmlns="http://www.w3.org/TR/REC-html40"> 17 + <head> 18 + <meta charset="utf-8"> 19 + <style> 20 + body { font-family: Calibri, sans-serif; font-size: 11pt; line-height: 1.5; } 21 + h1 { font-size: 20pt; font-weight: bold; } 22 + h2 { font-size: 16pt; font-weight: bold; } 23 + h3 { font-size: 13pt; font-weight: bold; } 24 + table { border-collapse: collapse; width: 100%; } 25 + td, th { border: 1px solid #999; padding: 4px 8px; } 26 + th { background: #f0f0f0; font-weight: bold; } 27 + code { font-family: Consolas, monospace; background: #f5f5f5; padding: 1px 3px; } 28 + pre { font-family: Consolas, monospace; background: #f5f5f5; padding: 8px; white-space: pre-wrap; } 29 + blockquote { border-left: 3px solid #ccc; margin-left: 0; padding-left: 12px; color: #555; } 30 + a { color: #0563C1; } 31 + ul[data-type="taskList"] li { list-style-type: none; } 32 + ul[data-type="taskList"] li[data-checked="true"]::before { content: "☑ "; } 33 + ul[data-type="taskList"] li[data-checked="false"]::before { content: "☐ "; } 34 + </style> 35 + <!--[if gte mso 9]> 36 + <xml> 37 + <w:WordDocument> 38 + <w:View>Print</w:View> 39 + <w:Zoom>100</w:Zoom> 40 + </w:WordDocument> 41 + </xml> 42 + <![endif]--> 43 + </head> 44 + <body>`; 45 + 46 + const WORD_HTML_SUFFIX = `</body></html>`; 47 + 48 + /** 49 + * Clean up TipTap-specific HTML for Word compatibility. 50 + */ 51 + export function cleanHtmlForWord(html: string): string { 52 + let cleaned = html; 53 + 54 + // Remove TipTap-specific attributes that Word doesn't understand 55 + cleaned = cleaned.replace(/\s+data-type="[^"]*"/g, ''); 56 + cleaned = cleaned.replace(/\s+data-checked="[^"]*"/g, ''); 57 + cleaned = cleaned.replace(/\s+contenteditable="[^"]*"/g, ''); 58 + cleaned = cleaned.replace(/\s+draggable="[^"]*"/g, ''); 59 + cleaned = cleaned.replace(/\s+class="[^"]*"/g, ''); 60 + 61 + // Convert task list checkboxes to text 62 + cleaned = cleaned.replace(/<input[^>]*type="checkbox"[^>]*checked[^>]*>/gi, '☑ '); 63 + cleaned = cleaned.replace(/<input[^>]*type="checkbox"[^>]*>/gi, '☐ '); 64 + 65 + // Remove empty paragraphs that Word renders as extra space 66 + cleaned = cleaned.replace(/<p><\/p>/g, '<p>&nbsp;</p>'); 67 + 68 + return cleaned; 69 + } 70 + 71 + export interface DocxExportOptions { 72 + editorHtml: string; 73 + title: string; 74 + } 75 + 76 + /** 77 + * Export editor content as a .docx file. 78 + * Creates a Word-compatible HTML document and triggers download. 79 + */ 80 + export function exportDocx({ editorHtml, title }: DocxExportOptions): void { 81 + const cleanedHtml = cleanHtmlForWord(editorHtml); 82 + const fullHtml = WORD_HTML_PREFIX + cleanedHtml + WORD_HTML_SUFFIX; 83 + 84 + const blob = new Blob([fullHtml], { 85 + type: 'application/vnd.ms-word', 86 + }); 87 + 88 + const url = URL.createObjectURL(blob); 89 + const a = document.createElement('a'); 90 + a.href = url; 91 + a.download = `${title || 'document'}.doc`; 92 + document.body.appendChild(a); 93 + a.click(); 94 + document.body.removeChild(a); 95 + URL.revokeObjectURL(url); 96 + }
+3
src/docs/index.html
··· 241 241 <button class="toolbar-dropdown-item" id="tb-export-pdf" title="Export as PDF (Cmd+Shift+P)" role="menuitem"> 242 242 <span class="item-icon"><svg class="tb-icon" viewBox="0 0 16 16"><path d="M4 2h6l3 3v9H4z"/><text x="5.5" y="12" font-size="5" fill="currentColor" stroke="none" font-family="sans-serif" font-weight="bold">PDF</text></svg></span><span class="item-label">Export PDF</span><span class="item-shortcut">&#8984;&#8679;P</span> 243 243 </button> 244 + <button class="toolbar-dropdown-item" id="tb-export-docx" title="Export as Word document" role="menuitem"> 245 + <span class="item-icon"><svg class="tb-icon" viewBox="0 0 16 16"><path d="M4 2h6l3 3v9H4z"/><text x="4.5" y="12" font-size="5" fill="currentColor" stroke="none" font-family="sans-serif" font-weight="bold">DOC</text></svg></span><span class="item-label">Export Word</span> 246 + </button> 244 247 <button class="toolbar-dropdown-item" id="tb-import" title="Import file" role="menuitem"> 245 248 <span class="item-icon"><svg class="tb-icon" viewBox="0 0 16 16"><path d="M8 10V2"/><path d="M5 5l3-3 3 3"/><path d="M2 10v3h12v-3"/></svg></span><span class="item-label">Import file</span> 246 249 </button>
+11
src/docs/main.ts
··· 43 43 import { TabSupport } from './tab-support.js'; 44 44 import { MarkdownAutoformat } from './extensions/markdown-autoformat.js'; 45 45 import { exportPdf } from './pdf-export.js'; 46 + import { exportDocx } from './docx-export.js'; 46 47 import { importDocx, isValidDocx } from './docx-import.js'; 47 48 import { markdownToHtml } from './markdown-parser.js'; 48 49 import { htmlToMarkdown as turndownHtmlToMarkdown } from './markdown-export.js'; ··· 1031 1032 }); 1032 1033 } 1033 1034 1035 + // --- DOCX Export --- 1036 + function doExportDocx() { 1037 + exportDocx({ 1038 + editorHtml: editor.getHTML(), 1039 + title: titleInput.value.trim() || 'Untitled Document', 1040 + }); 1041 + } 1042 + 1034 1043 // --- Import functions --- 1035 1044 async function handleImportedFile(file: File): Promise<void> { 1036 1045 const ext = file.name.split('.').pop().toLowerCase(); ··· 1093 1102 $('tb-export-md').addEventListener('click', () => { closeAllDropdowns(); exportMarkdown(); }); 1094 1103 $('tb-export-txt').addEventListener('click', () => { closeAllDropdowns(); exportText(); }); 1095 1104 $('tb-export-pdf').addEventListener('click', () => { closeAllDropdowns(); doExportPdf(); }); 1105 + $('tb-export-docx').addEventListener('click', () => { closeAllDropdowns(); doExportDocx(); }); 1096 1106 $('tb-import').addEventListener('click', () => { closeAllDropdowns(); importFile(); }); 1097 1107 $('tb-print').addEventListener('click', () => { closeAllDropdowns(); printDocument(); }); 1098 1108 ··· 2178 2188 { id: 'markdown', label: 'Toggle Markdown', category: 'action', icon: 'MD', shortcut: '\u2318\u21e7M', action: () => mdToggle.toggle() }, 2179 2189 { id: 'zen', label: 'Zen Mode', category: 'action', icon: '\u2022', shortcut: '\u2318\u21e7F', action: () => toggleZenMode() }, 2180 2190 { id: 'export-pdf', label: 'Export PDF', category: 'action', icon: '\u2193', shortcut: '\u2318\u21e7P', action: () => doExportPdf() }, 2191 + { id: 'export-docx', label: 'Export Word', category: 'action', icon: '\u2193', action: () => doExportDocx() }, 2181 2192 { id: 'import', label: 'Import File', category: 'action', icon: '\u2191', action: () => importFile() }, 2182 2193 { id: 'find', label: 'Find & Replace', category: 'action', icon: '\u2315', shortcut: '\u2318F', action: () => { editor.commands.openSearch(); updateFindBar(); } }, 2183 2194 { id: 'toggle', label: 'Insert Toggle Block', category: 'action', icon: '\u25B6', action: () => editor.chain().focus().insertToggleBlock().run() },
+88
tests/docx-export.test.ts
··· 1 + import { describe, it, expect } from 'vitest'; 2 + import { cleanHtmlForWord } from '../src/docs/docx-export.js'; 3 + 4 + describe('cleanHtmlForWord', () => { 5 + it('removes data-type attributes', () => { 6 + const html = '<ul data-type="taskList"><li>item</li></ul>'; 7 + expect(cleanHtmlForWord(html)).toBe('<ul><li>item</li></ul>'); 8 + }); 9 + 10 + it('removes data-checked attributes', () => { 11 + const html = '<li data-checked="true">done</li>'; 12 + expect(cleanHtmlForWord(html)).toBe('<li>done</li>'); 13 + }); 14 + 15 + it('removes contenteditable attributes', () => { 16 + const html = '<div contenteditable="true">text</div>'; 17 + expect(cleanHtmlForWord(html)).toBe('<div>text</div>'); 18 + }); 19 + 20 + it('removes draggable attributes', () => { 21 + const html = '<img draggable="true" src="x.png">'; 22 + expect(cleanHtmlForWord(html)).toBe('<img src="x.png">'); 23 + }); 24 + 25 + it('removes class attributes', () => { 26 + const html = '<p class="tiptap-node">Hello</p>'; 27 + expect(cleanHtmlForWord(html)).toBe('<p>Hello</p>'); 28 + }); 29 + 30 + it('converts checked checkbox inputs to checkmark', () => { 31 + const html = '<input type="checkbox" checked>Task'; 32 + expect(cleanHtmlForWord(html)).toBe('☑ Task'); 33 + }); 34 + 35 + it('converts unchecked checkbox inputs to empty box', () => { 36 + const html = '<input type="checkbox">Task'; 37 + expect(cleanHtmlForWord(html)).toBe('☐ Task'); 38 + }); 39 + 40 + it('handles checkbox with extra attributes', () => { 41 + const html = '<input disabled type="checkbox" checked data-id="1">'; 42 + expect(cleanHtmlForWord(html)).toBe('☑ '); 43 + }); 44 + 45 + it('replaces empty paragraphs with non-breaking space', () => { 46 + const html = '<p></p>'; 47 + expect(cleanHtmlForWord(html)).toBe('<p>&nbsp;</p>'); 48 + }); 49 + 50 + it('leaves non-empty paragraphs unchanged', () => { 51 + const html = '<p>Hello world</p>'; 52 + expect(cleanHtmlForWord(html)).toBe('<p>Hello world</p>'); 53 + }); 54 + 55 + it('handles multiple attributes on the same element', () => { 56 + const html = '<li data-type="taskItem" data-checked="false" class="task">text</li>'; 57 + expect(cleanHtmlForWord(html)).toBe('<li>text</li>'); 58 + }); 59 + 60 + it('handles complex HTML with all transformations', () => { 61 + const html = [ 62 + '<ul data-type="taskList">', 63 + '<li data-checked="true" class="task-item"><input type="checkbox" checked>Done</li>', 64 + '<li data-checked="false" class="task-item"><input type="checkbox">Todo</li>', 65 + '</ul>', 66 + '<p></p>', 67 + '<div contenteditable="false" draggable="true">block</div>', 68 + ].join(''); 69 + const result = cleanHtmlForWord(html); 70 + expect(result).not.toContain('data-type'); 71 + expect(result).not.toContain('data-checked'); 72 + expect(result).not.toContain('class='); 73 + expect(result).not.toContain('contenteditable'); 74 + expect(result).not.toContain('draggable'); 75 + expect(result).not.toContain('<input'); 76 + expect(result).toContain('☑ Done'); 77 + expect(result).toContain('☐ Todo'); 78 + expect(result).toContain('<p>&nbsp;</p>'); 79 + }); 80 + 81 + it('returns empty string unchanged', () => { 82 + expect(cleanHtmlForWord('')).toBe(''); 83 + }); 84 + 85 + it('passes through plain text unchanged', () => { 86 + expect(cleanHtmlForWord('just text')).toBe('just text'); 87 + }); 88 + });