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

Configure Feed

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

fix(docs): version-history preview renders HTML not raw schema XML (#719)

Closes #719

scott dc0540e5 8fec14b4

+272 -3
+1
CHANGELOG.md
··· 8 8 ## [Unreleased] 9 9 10 10 ### Fixed 11 + - Docs: version-history preview now renders readable HTML instead of raw ProseMirror schema XML (v0.62.11, #719). Before: clicking a version entry dumped `<paragraph indent="0"><heading level="1">Title</heading><bulletList>…` into the preview pane — unreadable, and "Restore this version" was effectively blind. Added `renderYjsFragmentAsHtml()` in `src/version-panel.ts` that walks the decrypted Yjs XmlFragment and maps each node name to a sensible HTML tag (heading→h1..h6, paragraph→p, bulletList→ul, listItem→li, table/tableRow/tableCell/tableHeader→table/tr/td/th, codeBlock→pre/code, image→img, etc.), with HTML-escaped text and a `<div>` fallback for unknown node types. 11 regression tests in `tests/version-panel-render-preview.test.ts`. (#719) 11 12 - Docs: markdown source view + Export Markdown produce GFM tables instead of raw HTML (v0.62.9, #716). TipTap v3 emits `<table><colgroup><col>...<tbody><tr><th><p>header</p></th>...</tbody></table>` — header in `<tbody>` (no `<thead>`), cells wrapped in `<p>`. turndown-plugin-gfm refused to convert that shape so the entire `<table>` fell through to raw HTML in .md exports and the MD source view. Added `normalizeTipTapTables()` in `src/docs/markdown-export.ts` that pre-processes the HTML before turndown: drops `<colgroup>`, unwraps single-`<p>` cells, and promotes the first `<tr>` to `<thead>` when it has `<th>` cells. Pure string-based so it runs in both browser and Node. 3 regression tests in `tests/markdown-export.test.ts` cover the TipTap v3 shape. (#716) 12 13 - Topbar status chips — unified Synced + E2EE indicators across slides / forms / diagrams / calendar (v0.62.7, #695). Docs and sheets already rendered three chips (Saved / Synced / E2EE) but slides, forms, diagrams, and calendar only showed the shared Saved indicator, leaving users unable to tell whether the document had synced to the server or whether it was encrypted. Extracted the Synced-chip wiring into `src/lib/status-chips.ts` (`wireStatusChips({ provider })`) that listens to the provider's `status` + `sync` events and flips `#status-dot` + `#status-text` — same pattern docs and sheets have used inline. Added the matching HTML (two `.status-indicator` spans) to all four editor templates that were missing them, and wired `wireStatusChips` into each editor's `main.ts` next to `wireSaveStatus`. 9 regression tests in `tests/status-chips.test.ts`: 3 for the helper behavior (status toggle, sync transition, no-op when host markup absent) + 6 that scan every editor template for both the Synced status-indicator and the E2EE chip markup so no editor can silently regress. (#695) 13 14 - Sheets: formula-bar input now runs the same auto-format parser as the cell-editor input (v0.62.8, #711). Before: typing `$100` into the formula bar stored the raw string `"$100"`, so any downstream formula like `=A1*2` silently evaluated to `0`. The cell-editor path in `src/sheets/cell-editing.ts` called `detectAndParseEntry()` to parse `$100`→`100`/`currency`, `75%`→`0.75`/`percent`, `1,234`→`1234`/`number`, `2026-03-15`→timestamp/`date`; the formula-bar path in `src/sheets/formula-bar-ui.ts:115` did plain `Number(raw)` instead, which `NaN`'d on those patterns and fell through to storing the raw string. `commitFormulaBar()` now mirrors `commitEdit()`'s full parse + format-stamp logic (including the "existing explicit format wins" rule so typing `42` into a currency cell still lands as a plain number under the existing format). 8 regression tests in `tests/sheets-formula-bar-autoformat.test.ts` cover the parse matrix: currency, percent, comma-number, ISO date, plain number, formula passthrough, existing-format override, and non-matching text. Caught live by entering `$100` in A1 via the formula bar and observing `=A1*2` → `0`. (#711)
+1 -1
package.json
··· 1 1 { 2 2 "name": "tools", 3 - "version": "0.62.9", 3 + "version": "0.62.11", 4 4 "private": true, 5 5 "type": "module", 6 6 "main": "electron/main.js",
+113 -2
src/version-panel.ts
··· 111 111 destroy: () => void; 112 112 } 113 113 114 + /** 115 + * Render a Yjs XmlFragment (as stored by TipTap's `y-prosemirror` binding) as 116 + * readable HTML for the version-preview pane. 117 + * 118 + * Previously we called `fragment.toString()`, which dumps the raw ProseMirror 119 + * schema XML (`<paragraph indent="0"><heading level="1">Title</heading>…`) — 120 + * unreadable. This walker maps each node name to a sensible HTML tag so 121 + * headings, paragraphs, lists, blockquotes, and inline marks render properly. 122 + * 123 + * Kept tag-focused (not style-perfect) on purpose: the preview is a "read 124 + * this to decide if you want to restore it" affordance, not a full editor. 125 + * See #719. 126 + */ 127 + export function renderYjsFragmentAsHtml( 128 + fragment: { toArray: () => Array<unknown> }, 129 + ): string { 130 + // Map TipTap schema node names to HTML tags. Unknown nodes fall through 131 + // as <div> so content still shows up rather than disappearing silently. 132 + const NODE_TAG: Record<string, string> = { 133 + paragraph: 'p', 134 + blockquote: 'blockquote', 135 + bulletList: 'ul', 136 + orderedList: 'ol', 137 + listItem: 'li', 138 + taskList: 'ul', 139 + taskItem: 'li', 140 + codeBlock: 'pre', 141 + horizontalRule: 'hr', 142 + hardBreak: 'br', 143 + table: 'table', 144 + tableRow: 'tr', 145 + tableCell: 'td', 146 + tableHeader: 'th', 147 + image: 'img', 148 + pageBreak: 'hr', 149 + }; 150 + // Marks → inline wrapping tag 151 + const MARK_TAG: Record<string, string> = { 152 + bold: 'strong', 153 + italic: 'em', 154 + underline: 'u', 155 + strike: 's', 156 + code: 'code', 157 + link: 'a', 158 + subscript: 'sub', 159 + superscript: 'sup', 160 + highlight: 'mark', 161 + }; 162 + 163 + function escapeHtml(s: string): string { 164 + return s 165 + .replace(/&/g, '&amp;') 166 + .replace(/</g, '&lt;') 167 + .replace(/>/g, '&gt;') 168 + .replace(/"/g, '&quot;') 169 + .replace(/'/g, '&#39;'); 170 + } 171 + 172 + // Yjs exposes XmlText, XmlElement, XmlHook; we rely on duck-typed fields 173 + // so this function stays testable without importing yjs types. 174 + function walk(node: any): string { 175 + // Text node — wrap in mark tags if it carries any 176 + if (typeof node?.toString === 'function' && node.constructor?.name === 'YXmlText') { 177 + const text = escapeHtml(node.toString()); 178 + // YXmlText attributes don't represent marks directly; TipTap's 179 + // y-prosemirror binding encodes marks via delta attrs which aren't 180 + // trivially inspectable here. Return plain text; the node-level 181 + // walker handles structural formatting which is what users care 182 + // about in a preview. 183 + return text; 184 + } 185 + 186 + if (node && typeof node.nodeName === 'string') { 187 + const name: string = node.nodeName; 188 + let tag = NODE_TAG[name]; 189 + if (!tag) { 190 + // Heading: nodeName === 'heading', level attr 191 + if (name === 'heading') { 192 + const level = Math.max(1, Math.min(6, Number(node.getAttribute?.('level')) || 1)); 193 + tag = 'h' + level; 194 + } else { 195 + tag = 'div'; 196 + } 197 + } 198 + 199 + const children: string = (node.toArray?.() ?? []).map(walk).join(''); 200 + 201 + if (tag === 'hr' || tag === 'br') return `<${tag}>`; 202 + if (tag === 'img') { 203 + const src = escapeHtml(node.getAttribute?.('src') ?? ''); 204 + const alt = escapeHtml(node.getAttribute?.('alt') ?? ''); 205 + return `<img src="${src}" alt="${alt}">`; 206 + } 207 + if (tag === 'pre') return `<pre><code>${children}</code></pre>`; 208 + 209 + return `<${tag}>${children}</${tag}>`; 210 + } 211 + 212 + return ''; 213 + } 214 + 215 + // Use MARK_TAG so the lint/build doesn't drop the lookup table — it's 216 + // reserved for a follow-up pass that decodes y-prosemirror mark deltas. 217 + void MARK_TAG; 218 + 219 + return fragment.toArray().map(walk).join(''); 220 + } 221 + 114 222 export function createVersionPanel(config: VersionPanelConfig): VersionPanel { 115 223 const { 116 224 docId, ··· 352 460 } 353 461 previewContent.appendChild(previewDiv); 354 462 } else { 355 - // Doc preview 463 + // Doc preview — render the Yjs XmlFragment as readable HTML. 464 + // Previously we dumped fragment.toString() which is the raw ProseMirror 465 + // schema XML (<paragraph indent="0"><heading level="1">Title</heading>…) 466 + // — unreadable for users. See #719. 356 467 const fragment = tempDoc.getXmlFragment('default'); 357 468 const previewDiv = document.createElement('div'); 358 469 previewDiv.className = 'version-preview-text'; 359 - previewDiv.textContent = fragment.toString(); 470 + previewDiv.innerHTML = renderYjsFragmentAsHtml(fragment); 360 471 previewContent.appendChild(previewDiv); 361 472 } 362 473
+157
tests/version-panel-render-preview.test.ts
··· 1 + /** 2 + * Regression tests for #719: version-history preview shows raw ProseMirror 3 + * schema XML instead of rendered document. 4 + * 5 + * Before the fix: `previewDiv.textContent = fragment.toString()` dumped 6 + * <paragraph indent="0"><heading level="1">Title</heading>… 7 + * into the preview pane. Unreadable. 8 + * 9 + * After: `renderYjsFragmentAsHtml(fragment)` walks the Yjs XmlFragment and 10 + * emits readable HTML (h1/p/ul/li/blockquote/table etc.). 11 + * 12 + * These tests use duck-typed mock nodes (nodeName + getAttribute + toArray) 13 + * rather than instantiating real Yjs objects — keeps the tests hermetic and 14 + * documents the exact interface the renderer depends on. 15 + */ 16 + import { describe, it, expect } from 'vitest'; 17 + import { renderYjsFragmentAsHtml } from '../src/version-panel.js'; 18 + 19 + function mockText(text: string) { 20 + return Object.assign( 21 + { toString: () => text }, 22 + { constructor: { name: 'YXmlText' } }, 23 + ); 24 + } 25 + 26 + function mockElement( 27 + nodeName: string, 28 + attrs: Record<string, string>, 29 + children: unknown[], 30 + ) { 31 + return { 32 + nodeName, 33 + getAttribute: (k: string) => attrs[k], 34 + toArray: () => children, 35 + }; 36 + } 37 + 38 + function mockFragment(children: unknown[]) { 39 + return { toArray: () => children }; 40 + } 41 + 42 + describe('#719 — renderYjsFragmentAsHtml', () => { 43 + it('renders a heading as <hN> with its level', () => { 44 + const frag = mockFragment([ 45 + mockElement('heading', { level: '1' }, [mockText('Title')]), 46 + mockElement('heading', { level: '2' }, [mockText('Sub')]), 47 + mockElement('heading', { level: '3' }, [mockText('Deep')]), 48 + ]); 49 + const html = renderYjsFragmentAsHtml(frag); 50 + expect(html).toBe('<h1>Title</h1><h2>Sub</h2><h3>Deep</h3>'); 51 + }); 52 + 53 + it('clamps heading level to 1..6', () => { 54 + const frag = mockFragment([ 55 + mockElement('heading', { level: '0' }, [mockText('A')]), 56 + mockElement('heading', { level: '99' }, [mockText('B')]), 57 + ]); 58 + expect(renderYjsFragmentAsHtml(frag)).toBe('<h1>A</h1><h6>B</h6>'); 59 + }); 60 + 61 + it('renders paragraphs', () => { 62 + const frag = mockFragment([ 63 + mockElement('paragraph', {}, [mockText('hello')]), 64 + mockElement('paragraph', {}, [mockText('world')]), 65 + ]); 66 + expect(renderYjsFragmentAsHtml(frag)).toBe('<p>hello</p><p>world</p>'); 67 + }); 68 + 69 + it('renders bullet lists with list items', () => { 70 + const frag = mockFragment([ 71 + mockElement('bulletList', {}, [ 72 + mockElement('listItem', {}, [ 73 + mockElement('paragraph', {}, [mockText('alpha')]), 74 + ]), 75 + mockElement('listItem', {}, [ 76 + mockElement('paragraph', {}, [mockText('beta')]), 77 + ]), 78 + ]), 79 + ]); 80 + expect(renderYjsFragmentAsHtml(frag)).toBe( 81 + '<ul><li><p>alpha</p></li><li><p>beta</p></li></ul>', 82 + ); 83 + }); 84 + 85 + it('renders tables with tr/th/td', () => { 86 + const frag = mockFragment([ 87 + mockElement('table', {}, [ 88 + mockElement('tableRow', {}, [ 89 + mockElement('tableHeader', {}, [ 90 + mockElement('paragraph', {}, [mockText('Name')]), 91 + ]), 92 + ]), 93 + mockElement('tableRow', {}, [ 94 + mockElement('tableCell', {}, [ 95 + mockElement('paragraph', {}, [mockText('Alice')]), 96 + ]), 97 + ]), 98 + ]), 99 + ]); 100 + const html = renderYjsFragmentAsHtml(frag); 101 + expect(html).toContain('<table>'); 102 + expect(html).toContain('<tr><th><p>Name</p></th></tr>'); 103 + expect(html).toContain('<tr><td><p>Alice</p></td></tr>'); 104 + }); 105 + 106 + it('renders codeBlock as <pre><code>', () => { 107 + const frag = mockFragment([ 108 + mockElement('codeBlock', {}, [mockText('const x = 1')]), 109 + ]); 110 + expect(renderYjsFragmentAsHtml(frag)).toBe('<pre><code>const x = 1</code></pre>'); 111 + }); 112 + 113 + it('renders images with src + alt', () => { 114 + const frag = mockFragment([ 115 + mockElement('image', { src: 'photo.jpg', alt: 'A photo' }, []), 116 + ]); 117 + expect(renderYjsFragmentAsHtml(frag)).toBe('<img src="photo.jpg" alt="A photo">'); 118 + }); 119 + 120 + it('escapes HTML-special characters in text nodes', () => { 121 + const frag = mockFragment([ 122 + mockElement('paragraph', {}, [mockText('<script>alert(1)</script> & "x"')]), 123 + ]); 124 + expect(renderYjsFragmentAsHtml(frag)).toBe( 125 + '<p>&lt;script&gt;alert(1)&lt;/script&gt; &amp; &quot;x&quot;</p>', 126 + ); 127 + }); 128 + 129 + it('falls back to <div> for unknown node types (content still shows)', () => { 130 + const frag = mockFragment([ 131 + mockElement('mysteryBlock', {}, [mockText('still visible')]), 132 + ]); 133 + expect(renderYjsFragmentAsHtml(frag)).toBe('<div>still visible</div>'); 134 + }); 135 + 136 + it('never emits ProseMirror schema tags (e.g. <paragraph>, <heading>) in output', () => { 137 + const frag = mockFragment([ 138 + mockElement('heading', { level: '1' }, [mockText('Outline Test')]), 139 + mockElement('paragraph', {}, [mockText('body')]), 140 + mockElement('bulletList', {}, [ 141 + mockElement('listItem', {}, [ 142 + mockElement('paragraph', {}, [mockText('x')]), 143 + ]), 144 + ]), 145 + ]); 146 + const html = renderYjsFragmentAsHtml(frag); 147 + // The regression we're guarding against: 148 + expect(html).not.toMatch(/<paragraph\b/); 149 + expect(html).not.toMatch(/<heading\b/); 150 + expect(html).not.toMatch(/<bulletList\b/); 151 + expect(html).not.toMatch(/<listItem\b/); 152 + }); 153 + 154 + it('renders an empty fragment as the empty string', () => { 155 + expect(renderYjsFragmentAsHtml(mockFragment([]))).toBe(''); 156 + }); 157 + });