Full document, spreadsheet, slideshow, and diagram tooling
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
13const 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
46const WORD_HTML_SUFFIX = `</body></html>`;
47
48/**
49 * Clean up TipTap-specific HTML for Word compatibility.
50 */
51export 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> </p>');
67
68 return cleaned;
69}
70
71export 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 */
80export 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}