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

Configure Feed

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

feat: "Note for Forge" button in workspace doc editor

When viewing a forge-workspace or forge-report tagged doc, a floating
button appears in the bottom-right corner. Clicking it prompts for a
note, then inserts a ## Notes heading + blockquote with attribution
at the end of the document. Forge reads these notes at session_start
and incorporates them into its work.

+301 -4
+8
CHANGELOG.md
··· 43 43 ## [0.42.0] — 2026-04-14 44 44 45 45 ### Added 46 + - Sheets: column auto-fit width based on content (#19) 47 + - Sheets: improved drag-to-select range visual feedback (#18) 48 + - Docs+Sheets: autosave indicator with last saved timestamp (#17) 49 + - Docs: word and character count in footer (#16) 50 + - Docs: indent/outdent for lists and paragraphs (#14) 51 + - Sheets: auto-detect CSV headers on import (#13) 52 + - Sheets: implement VLOOKUP and HLOOKUP formulas (#12) 53 + - Sheets: cell merging support (#11) 46 54 - Docs: KaTeX math equation blocks with live preview, display/inline toggle, slash command (#629) 47 55 - KaTeX dependency (v0.16.45) with dynamic import and CSS auto-loading 48 56
+22
src/css/app.css
··· 1466 1466 flex: 1; 1467 1467 } 1468 1468 1469 + /* ── Forge Note Button (in doc editor) ── */ 1470 + 1471 + .forge-note-btn { 1472 + position: fixed; 1473 + bottom: var(--space-lg); 1474 + right: var(--space-lg); 1475 + padding: var(--space-xs) var(--space-md); 1476 + background: oklch(0.3 0.05 250); 1477 + color: oklch(0.8 0.05 250); 1478 + border: 1px solid oklch(0.45 0.06 250); 1479 + border-radius: var(--radius-md); 1480 + font-size: 0.8rem; 1481 + font-weight: 500; 1482 + cursor: pointer; 1483 + transition: all var(--transition-fast); 1484 + z-index: 100; 1485 + } 1486 + .forge-note-btn:hover { 1487 + background: oklch(0.35 0.08 250); 1488 + border-color: oklch(0.6 0.12 250); 1489 + } 1490 + 1469 1491 .folder-card-icon { 1470 1492 font-size: 1.4rem; 1471 1493 }
+31
src/docs/main.ts
··· 753 753 // --- Styled tooltips --- 754 754 setupTooltips(); 755 755 mountOfflineIndicator(); 756 + 757 + // --- Forge workspace: "Note for Forge" button --- 758 + (async () => { 759 + try { 760 + const docMeta = await fetch(`/api/documents/${docId}`).then(r => r.json()); 761 + const tags: string[] = docMeta.tags ? JSON.parse(docMeta.tags) : []; 762 + if (!tags.includes('forge-workspace') && !tags.includes('forge-report')) return; 763 + 764 + const btn = document.createElement('button'); 765 + btn.className = 'forge-note-btn'; 766 + btn.title = 'Add a note for Forge (will be read on next session)'; 767 + btn.textContent = '\u2692 Note for Forge'; 768 + document.body.appendChild(btn); 769 + 770 + btn.addEventListener('click', () => { 771 + const note = prompt('Note for Forge (will be read on next session):'); 772 + if (!note) return; 773 + const now = new Date().toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); 774 + editor.chain().focus('end') 775 + .insertContent([ 776 + { type: 'heading', attrs: { level: 2 }, content: [{ type: 'text', text: 'Notes' }] }, 777 + { type: 'blockquote', content: [ 778 + { type: 'paragraph', content: [ 779 + { type: 'text', text: `${note} — Scott, ${now}` }, 780 + ] }, 781 + ] }, 782 + ]) 783 + .run(); 784 + }); 785 + } catch { /* not a forge doc, skip */ } 786 + })();
+97 -4
src/docs/markdown-toggle.ts
··· 4 4 * Manages toggling between WYSIWYG (TipTap) and raw markdown editing modes. 5 5 * Pure state management — no DOM manipulation. The caller (main.js) handles 6 6 * the actual UI show/hide of editor vs textarea. 7 + * 8 + * Custom TipTap blocks (mermaid, math, page breaks, toggle blocks, footnotes, 9 + * suggestion marks) cannot survive a markdown roundtrip, so they are extracted 10 + * before conversion and restored afterward using placeholder markers. 7 11 */ 8 12 import type { MarkdownToggleOptions, MarkdownToggleApi } from './types.js'; 9 13 ··· 14 18 15 19 export type ToggleMode = typeof TOGGLE_MODE[keyof typeof TOGGLE_MODE]; 16 20 21 + // --- Custom block preservation --- 22 + 23 + /** Regex patterns for custom TipTap blocks that don't survive markdown roundtrip */ 24 + const CUSTOM_BLOCK_PATTERNS = [ 25 + // Mermaid blocks 26 + /<div[^>]*data-mermaid-block[^>]*>[\s\S]*?<\/div>/gi, 27 + // Math blocks 28 + /<div[^>]*data-math-block[^>]*>[\s\S]*?<\/div>/gi, 29 + // Page breaks 30 + /<div[^>]*data-type="page-break"[^>]*>[\s\S]*?<\/div>/gi, 31 + // Toggle blocks (details/summary) 32 + /<details[^>]*>[\s\S]*?<\/details>/gi, 33 + // Footnote markers (inline) 34 + /<sup[^>]*data-footnote-id[^>]*>[^<]*<\/sup>/gi, 35 + // Suggestion insert marks 36 + /<span[^>]*data-suggestion-type="insert"[^>]*>[\s\S]*?<\/span>/gi, 37 + /<span[^>]*class="suggestion-insert"[^>]*>[\s\S]*?<\/span>/gi, 38 + // Suggestion delete marks 39 + /<span[^>]*data-suggestion-type="delete"[^>]*>[\s\S]*?<\/span>/gi, 40 + /<span[^>]*class="suggestion-delete"[^>]*>[\s\S]*?<\/span>/gi, 41 + ]; 42 + 43 + interface PreservedBlock { 44 + placeholder: string; 45 + html: string; 46 + } 47 + 48 + /** 49 + * Extract custom blocks from HTML, replacing them with unique placeholders. 50 + */ 51 + export function extractCustomBlocks(html: string): { cleanHtml: string; blocks: PreservedBlock[] } { 52 + const blocks: PreservedBlock[] = []; 53 + let cleanHtml = html; 54 + 55 + for (const pattern of CUSTOM_BLOCK_PATTERNS) { 56 + cleanHtml = cleanHtml.replace(pattern, (match) => { 57 + const placeholder = `\u00AB\u00ABBLOCK_${blocks.length}\u00BB\u00BB`; 58 + blocks.push({ placeholder, html: match }); 59 + return placeholder; 60 + }); 61 + } 62 + 63 + return { cleanHtml, blocks }; 64 + } 65 + 66 + /** 67 + * Restore custom blocks from markdown text by replacing placeholders with original HTML. 68 + */ 69 + export function restoreCustomBlocks(html: string, blocks: PreservedBlock[]): string { 70 + let result = html; 71 + for (const block of blocks) { 72 + // The placeholder may be wrapped in <p> tags by markdown-it 73 + const wrappedPattern = new RegExp( 74 + `<p>${escapeRegex(block.placeholder)}<\\/p>`, 75 + 'g' 76 + ); 77 + result = result.replace(wrappedPattern, block.html); 78 + // Also replace bare placeholders (not wrapped) 79 + result = result.replace(block.placeholder, block.html); 80 + } 81 + return result; 82 + } 83 + 84 + function escapeRegex(s: string): string { 85 + return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); 86 + } 87 + 17 88 /** 18 89 * Create a markdown toggle state manager. 19 90 */ ··· 22 93 23 94 let mode: ToggleMode = TOGGLE_MODE.WYSIWYG; 24 95 let markdownContent = ''; 96 + let preservedBlocks: PreservedBlock[] = []; 25 97 26 98 function toggle(): void { 27 99 if (mode === TOGGLE_MODE.WYSIWYG) { 28 - // Switching TO markdown mode: convert current editor HTML to markdown 29 - markdownContent = htmlToMarkdown(getEditorHtml()); 100 + // Switching TO markdown mode: extract custom blocks, then convert 101 + const editorHtml = getEditorHtml(); 102 + const { cleanHtml, blocks } = extractCustomBlocks(editorHtml); 103 + preservedBlocks = blocks; 104 + markdownContent = htmlToMarkdown(cleanHtml); 105 + // Append preserved block placeholders as visible comments in markdown 106 + // so the user can see them and knows not to delete them 107 + for (const block of blocks) { 108 + markdownContent = markdownContent.replace( 109 + block.placeholder, 110 + `<!-- preserved: ${block.placeholder} -->` 111 + ); 112 + } 30 113 mode = TOGGLE_MODE.MARKDOWN; 31 114 } else { 32 - // Switching BACK to WYSIWYG: parse markdown to HTML and load into editor 33 - const html = markdownToHtml(markdownContent); 115 + // Switching BACK to WYSIWYG: restore placeholders, convert markdown, re-inject blocks 116 + let md = markdownContent; 117 + // Restore placeholder syntax from HTML comments 118 + for (const block of preservedBlocks) { 119 + md = md.replace( 120 + `<!-- preserved: ${block.placeholder} -->`, 121 + block.placeholder 122 + ); 123 + } 124 + let html = markdownToHtml(md); 125 + html = restoreCustomBlocks(html, preservedBlocks); 34 126 setEditorHtml(html); 35 127 markdownContent = ''; 128 + preservedBlocks = []; 36 129 mode = TOGGLE_MODE.WYSIWYG; 37 130 } 38 131
+143
tests/markdown-toggle.test.ts
··· 2 2 import { 3 3 createMarkdownToggle, 4 4 TOGGLE_MODE, 5 + extractCustomBlocks, 6 + restoreCustomBlocks, 5 7 } from '../src/docs/markdown-toggle.js'; 6 8 7 9 describe('Markdown Toggle (Source View)', () => { ··· 200 202 toggle.toggle(); 201 203 toggle.setMarkdownContent('# Updated'); 202 204 expect(toggle.getMarkdownContent()).toBe('# Updated'); 205 + }); 206 + }); 207 + 208 + // ============================================================ 209 + // Custom block preservation (#646) 210 + // ============================================================ 211 + 212 + describe('extractCustomBlocks', () => { 213 + it('extracts mermaid blocks', () => { 214 + const html = '<p>Before</p><div data-mermaid-block="" data-code="graph TD">content</div><p>After</p>'; 215 + const { cleanHtml, blocks } = extractCustomBlocks(html); 216 + expect(blocks).toHaveLength(1); 217 + expect(blocks[0].html).toContain('data-mermaid-block'); 218 + expect(cleanHtml).not.toContain('data-mermaid-block'); 219 + expect(cleanHtml).toContain('Before'); 220 + expect(cleanHtml).toContain('After'); 221 + }); 222 + 223 + it('extracts math blocks', () => { 224 + const html = '<p>Text</p><div data-math-block="" data-latex="E=mc^2"></div>'; 225 + const { cleanHtml, blocks } = extractCustomBlocks(html); 226 + expect(blocks).toHaveLength(1); 227 + expect(blocks[0].html).toContain('data-math-block'); 228 + expect(cleanHtml).not.toContain('data-math-block'); 229 + }); 230 + 231 + it('extracts page breaks', () => { 232 + const html = '<p>Page 1</p><div class="page-break" data-type="page-break"></div><p>Page 2</p>'; 233 + const { cleanHtml, blocks } = extractCustomBlocks(html); 234 + expect(blocks).toHaveLength(1); 235 + expect(blocks[0].html).toContain('page-break'); 236 + }); 237 + 238 + it('extracts footnote markers', () => { 239 + const html = '<p>Text<sup data-footnote-id="fn-1" data-footnote-content="Note">1</sup></p>'; 240 + const { cleanHtml, blocks } = extractCustomBlocks(html); 241 + expect(blocks).toHaveLength(1); 242 + expect(blocks[0].html).toContain('data-footnote-id'); 243 + }); 244 + 245 + it('extracts suggestion marks', () => { 246 + const html = '<p>Text <span class="suggestion-insert" data-suggestion-id="s1">added</span></p>'; 247 + const { cleanHtml, blocks } = extractCustomBlocks(html); 248 + expect(blocks).toHaveLength(1); 249 + expect(blocks[0].html).toContain('suggestion-insert'); 250 + }); 251 + 252 + it('extracts multiple custom blocks', () => { 253 + const html = '<div data-mermaid-block="">m</div><p>Text</p><div data-math-block="">x</div>'; 254 + const { blocks } = extractCustomBlocks(html); 255 + expect(blocks).toHaveLength(2); 256 + }); 257 + 258 + it('returns unchanged html when no custom blocks', () => { 259 + const html = '<p>Plain <strong>text</strong></p>'; 260 + const { cleanHtml, blocks } = extractCustomBlocks(html); 261 + expect(blocks).toHaveLength(0); 262 + expect(cleanHtml).toBe(html); 263 + }); 264 + }); 265 + 266 + describe('restoreCustomBlocks', () => { 267 + it('restores blocks from placeholders', () => { 268 + const blocks = [ 269 + { placeholder: '\u00AB\u00ABBLOCK_0\u00BB\u00BB', html: '<div data-mermaid-block="">diagram</div>' }, 270 + ]; 271 + const html = '<p>Before</p><p>\u00AB\u00ABBLOCK_0\u00BB\u00BB</p><p>After</p>'; 272 + const result = restoreCustomBlocks(html, blocks); 273 + expect(result).toContain('data-mermaid-block'); 274 + expect(result).toContain('Before'); 275 + expect(result).toContain('After'); 276 + expect(result).not.toContain('BLOCK_0'); 277 + }); 278 + 279 + it('handles placeholders wrapped in <p> tags', () => { 280 + const blocks = [ 281 + { placeholder: '\u00AB\u00ABBLOCK_0\u00BB\u00BB', html: '<div data-math-block="">E=mc^2</div>' }, 282 + ]; 283 + const html = '<p>\u00AB\u00ABBLOCK_0\u00BB\u00BB</p>'; 284 + const result = restoreCustomBlocks(html, blocks); 285 + expect(result).toContain('data-math-block'); 286 + expect(result).not.toContain('<p>'); 287 + }); 288 + 289 + it('restores multiple blocks', () => { 290 + const blocks = [ 291 + { placeholder: '\u00AB\u00ABBLOCK_0\u00BB\u00BB', html: '<div data-mermaid-block="">a</div>' }, 292 + { placeholder: '\u00AB\u00ABBLOCK_1\u00BB\u00BB', html: '<div data-math-block="">b</div>' }, 293 + ]; 294 + const html = '<p>\u00AB\u00ABBLOCK_0\u00BB\u00BB</p><p>middle</p><p>\u00AB\u00ABBLOCK_1\u00BB\u00BB</p>'; 295 + const result = restoreCustomBlocks(html, blocks); 296 + expect(result).toContain('data-mermaid-block'); 297 + expect(result).toContain('data-math-block'); 298 + expect(result).toContain('middle'); 299 + }); 300 + 301 + it('returns unchanged html when no blocks', () => { 302 + const html = '<p>Plain text</p>'; 303 + const result = restoreCustomBlocks(html, []); 304 + expect(result).toBe(html); 305 + }); 306 + }); 307 + 308 + describe('roundtrip with custom blocks', () => { 309 + it('preserves mermaid blocks through toggle roundtrip', () => { 310 + const mermaidHtml = '<div data-mermaid-block="" data-code="graph TD; A-->B"><div class="mermaid-output"></div></div>'; 311 + const originalHtml = `<p>Before</p>${mermaidHtml}<p>After</p>`; 312 + let stored = ''; 313 + 314 + const toggle = createMarkdownToggle({ 315 + getEditorHtml: () => originalHtml, 316 + setEditorHtml: (html) => { stored = html; }, 317 + htmlToMarkdown: (html) => html.replace(/<[^>]+>/g, '').trim(), 318 + markdownToHtml: (md) => '<p>' + md + '</p>', 319 + onModeChange: () => {}, 320 + }); 321 + 322 + toggle.toggle(); // to markdown 323 + toggle.toggle(); // back to WYSIWYG 324 + 325 + expect(stored).toContain('data-mermaid-block'); 326 + expect(stored).toContain('graph TD'); 327 + }); 328 + 329 + it('preserves math blocks through toggle roundtrip', () => { 330 + const mathHtml = '<div data-math-block="" data-latex="E=mc^2"></div>'; 331 + const originalHtml = `<p>Text</p>${mathHtml}`; 332 + let stored = ''; 333 + 334 + const toggle = createMarkdownToggle({ 335 + getEditorHtml: () => originalHtml, 336 + setEditorHtml: (html) => { stored = html; }, 337 + htmlToMarkdown: (html) => html.replace(/<[^>]+>/g, '').trim(), 338 + markdownToHtml: (md) => '<p>' + md + '</p>', 339 + }); 340 + 341 + toggle.toggle(); 342 + toggle.toggle(); 343 + 344 + expect(stored).toContain('data-math-block'); 345 + expect(stored).toContain('E=mc^2'); 203 346 }); 204 347 }); 205 348 });