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

Configure Feed

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

Merge pull request 'feat: offline polish, UI fixes, markdown paste' (#207) from feat/offline-polish-markdown-paste into main

scott a062ad8d 027d6df7

+249 -12
+15
CHANGELOG.md
··· 5 5 The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 6 6 and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 7 8 + ## [0.16.0] — 2026-03-31 9 + 10 + ### Added 11 + - Markdown paste: pasting markdown text into docs auto-converts to rich text (#268) 12 + - PWA manifest for installability (`manifest.json`) 13 + - Offline-aware save indicator: shows "Saved locally" when disconnected (#266) 14 + - OfflineManager wiring for sheets (previously only in docs) 15 + 16 + ### Fixed 17 + - Electron: topbar no longer clipped by macOS traffic light buttons (#267) 18 + - Mobile: prevent unwanted zoom on input focus (viewport max-scale + touch-action) 19 + - Mobile: version badge hidden on phones, smaller on tablets (#267) 20 + 8 21 ## [0.15.4] — 2026-03-30 9 22 10 23 ### Fixed ··· 27 40 - Fix sheets chat input: keyboard handler no longer captures typing in AI chat sidebar (#233) 28 41 29 42 ### Changed 43 + - Fix Electron code signing: Developer ID cert + notarization (#264) 30 44 - Electron thin client: auto-connect Tailnet backend (#261) 31 45 - Wire up Apple notarization for Electron builds (#260) 32 46 - Monitor CI for PR #178, merge when green, verify deployment (#255) ··· 50 64 ## [0.12.0] — 2026-03-24 51 65 52 66 ### Added 67 + - Add server-side encryption key sync for seamless cross-device access (#265) 53 68 - Sheets: resizable column widths (#6) 54 69 - Add automated semantic versioning to CI pipeline (#182) 55 70 - AI chat panel on all editor types (docs + sheets) with shared module (#229)
+1 -1
package.json
··· 1 1 { 2 2 "name": "tools", 3 - "version": "0.15.16", 3 + "version": "0.16.0", 4 4 "private": true, 5 5 "type": "module", 6 6 "main": "electron/main.js",
+12
public/manifest.json
··· 1 + { 2 + "name": "Tools — Encrypted Office", 3 + "short_name": "Tools", 4 + "description": "E2EE collaborative docs and sheets", 5 + "start_url": "/", 6 + "display": "standalone", 7 + "background_color": "#111111", 8 + "theme_color": "#3a8a7a", 9 + "icons": [ 10 + { "src": "/favicon.svg", "sizes": "any", "type": "image/svg+xml" } 11 + ] 12 + }
+24
src/css/app.css
··· 180 180 /* --- Reset --- */ 181 181 *, *::before, *::after { box-sizing: border-box; margin: 0; } 182 182 183 + /* Prevent zoom on input focus on mobile (iOS double-tap-to-zoom) */ 184 + input, select, textarea { 185 + touch-action: manipulation; 186 + } 187 + 183 188 html { 184 189 font-size: clamp(15px, 1vw + 12px, 17px); 185 190 -webkit-font-smoothing: antialiased; ··· 1162 1167 border-bottom: 1px solid var(--color-border); 1163 1168 background: var(--color-surface); 1164 1169 flex-shrink: 0; 1170 + } 1171 + 1172 + /* Electron traffic light padding — macOS hiddenInset titlebar */ 1173 + .is-electron .app-topbar { 1174 + padding-left: 80px; 1175 + } 1176 + 1177 + .is-electron .brand { 1178 + padding-left: 72px; 1165 1179 } 1166 1180 1167 1181 .app-logo { ··· 4792 4806 .search-input:focus { 4793 4807 width: 100%; 4794 4808 } 4809 + 4810 + .version-badge { 4811 + font-size: 0.55rem; 4812 + bottom: 0.25rem; 4813 + right: 0.5rem; 4814 + } 4795 4815 } 4796 4816 4797 4817 /* ======================================================== ··· 4839 4859 /* Slightly larger body font */ 4840 4860 body { 4841 4861 font-size: 1rem; 4862 + } 4863 + 4864 + .version-badge { 4865 + display: none; 4842 4866 } 4843 4867 } 4844 4868
+3 -1
src/docs/index.html
··· 2 2 <html lang="en"> 3 3 <head> 4 4 <meta charset="UTF-8"> 5 - <meta name="viewport" content="width=device-width, initial-scale=1.0"> 5 + <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1"> 6 + <link rel="manifest" href="/manifest.json"> 6 7 <meta name="description" content="E2EE collaborative document editor. End-to-end encrypted, real-time collaboration."> 7 8 <meta property="og:title" content="Tools — Docs"> 8 9 <meta property="og:description" content="E2EE collaborative document editor. End-to-end encrypted, real-time collaboration."> ··· 17 18 if (saved === 'dark' || saved === 'light') { 18 19 document.documentElement.setAttribute('data-theme', saved); 19 20 } 21 + if (window.electronAPI) document.documentElement.classList.add('is-electron'); 20 22 // Enable focus rings only when keyboard navigation is detected 21 23 document.addEventListener('keydown', function(e) { 22 24 if (e.key === 'Tab') document.documentElement.setAttribute('data-a11y-focus', '');
+33 -4
src/docs/main.ts
··· 49 49 import { exportDocx } from './docx-export.js'; 50 50 import { importDocx, isValidDocx } from './docx-import.js'; 51 51 import { markdownToHtml } from './markdown-parser.js'; 52 + import { looksLikeMarkdown } from './markdown-paste.js'; 52 53 import { htmlToMarkdown as turndownHtmlToMarkdown } from './markdown-export.js'; 53 54 import { createMarkdownToggle, TOGGLE_MODE } from './markdown-toggle.js'; 54 55 import { VersionManager, computeWordCount } from '../lib/version-history.js'; ··· 233 234 }), 234 235 ], 235 236 autofocus: true, 237 + }); 238 + 239 + // --- Markdown Paste Handler --- 240 + // Convert pasted markdown to rich text using the existing markdown-it parser. 241 + // Only triggers for plain-text paste that looks like markdown (conservative detection). 242 + editor.setOptions({ 243 + editorProps: { 244 + handlePaste: (_view, event) => { 245 + const clip = event.clipboardData; 246 + if (!clip) return false; 247 + // If clipboard has HTML, let TipTap handle it natively 248 + if (clip.types.includes('text/html')) return false; 249 + const text = clip.getData('text/plain'); 250 + if (!text || !looksLikeMarkdown(text)) return false; 251 + const html = markdownToHtml(text); 252 + if (!html) return false; 253 + event.preventDefault(); 254 + editor.commands.insertContent(html); 255 + return true; 256 + }, 257 + }, 236 258 }); 237 259 238 260 // --- Slash Command Menu --- ··· 1177 1199 1178 1200 function updateSaveTimestamp() { 1179 1201 if (saveState !== 'saved') return; 1202 + const prefix = !provider.connected ? 'Saved locally' : 'Saved'; 1180 1203 const seconds = Math.floor((Date.now() - lastSaveTime) / 1000); 1181 - if (seconds < 5) saveText.textContent = 'Saved'; 1182 - else if (seconds < 60) saveText.textContent = `Saved ${seconds}s ago`; 1183 - else saveText.textContent = `Saved ${Math.floor(seconds / 60)} min ago`; 1204 + if (seconds < 5) saveText.textContent = prefix; 1205 + else if (seconds < 60) saveText.textContent = `${prefix} ${seconds}s ago`; 1206 + else saveText.textContent = `${prefix} ${Math.floor(seconds / 60)} min ago`; 1184 1207 } 1185 1208 1186 1209 setInterval(updateSaveTimestamp, 30_000); ··· 1188 1211 // Listen for save-status events from the provider (replaces monkey-patching) 1189 1212 provider.on('save-status', (payload) => { 1190 1213 if (payload.status === 'saving') setSaveState('saving'); 1191 - else if (payload.status === 'saved') setSaveState('saved', Date.now()); 1214 + else if (payload.status === 'saved') { 1215 + setSaveState('saved', Date.now()); 1216 + // Show "Saved locally" when offline (saved to IDB only, not server) 1217 + if (!provider.connected && saveText) { 1218 + saveText.textContent = 'Saved locally'; 1219 + } 1220 + } 1192 1221 else if (payload.status === 'error') setSaveState('unsaved'); 1193 1222 }); 1194 1223
+43
src/docs/markdown-paste.ts
··· 1 + /** 2 + * Markdown paste detection for the docs editor. 3 + * 4 + * Conservatively detects whether pasted plain text is markdown. 5 + * Requires 2+ signals or 1 strong signal (heading, code block, table) 6 + * to avoid false positives on normal text. 7 + */ 8 + 9 + const PATTERNS: readonly RegExp[] = [ 10 + /^#{1,6}\s+\S/m, // ATX headings (strong) 11 + /^\s*[-*+]\s+\S/m, // Unordered lists 12 + /^\s*\d+\.\s+\S/m, // Ordered lists 13 + /^\s*```/m, // Fenced code blocks (strong) 14 + /^\s*>\s+\S/m, // Blockquotes 15 + /\[.+?\]\(.+?\)/, // Links 16 + /!\[.*?\]\(.+?\)/, // Images 17 + /^\s*[-*_]{3,}\s*$/m, // Horizontal rules 18 + /^\s*\|.+\|.+\|/m, // Tables 19 + /^\s*- \[([ xX])\]/m, // Task lists 20 + ]; 21 + 22 + // Strong solo signals — a single match is enough 23 + const HEADING_RE = /^#{1,6}\s+\S/m; 24 + const CODE_FENCE_RE = /^\s*```/m; 25 + const TABLE_WITH_SEPARATOR_RE = /^\s*\|.+\|.+\|\s*\n\s*\|[\s:|-]+\|/m; 26 + 27 + export function looksLikeMarkdown(text: string): boolean { 28 + if (!text || !text.trim()) return false; 29 + 30 + let signals = 0; 31 + for (const p of PATTERNS) { 32 + if (p.test(text)) signals++; 33 + } 34 + 35 + if (signals >= 2) return true; 36 + 37 + // Strong solo signals 38 + if (HEADING_RE.test(text)) return true; 39 + if (CODE_FENCE_RE.test(text)) return true; 40 + if (TABLE_WITH_SEPARATOR_RE.test(text)) return true; 41 + 42 + return false; 43 + }
+3 -1
src/index.html
··· 2 2 <html lang="en"> 3 3 <head> 4 4 <meta charset="UTF-8"> 5 - <meta name="viewport" content="width=device-width, initial-scale=1.0"> 5 + <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1"> 6 + <link rel="manifest" href="/manifest.json"> 6 7 <meta name="description" content="E2EE collaborative docs and sheets. End-to-end encrypted, real-time collaboration."> 7 8 <meta property="og:title" content="Tools — Encrypted Office"> 8 9 <meta property="og:description" content="E2EE collaborative docs and sheets. End-to-end encrypted, real-time collaboration."> ··· 18 19 if (saved === 'dark' || saved === 'light') { 19 20 document.documentElement.setAttribute('data-theme', saved); 20 21 } 22 + if (window.electronAPI) document.documentElement.classList.add('is-electron'); 21 23 document.addEventListener('keydown', function(e) { 22 24 if (e.key === 'Tab') document.documentElement.setAttribute('data-a11y-focus', ''); 23 25 });
+3 -1
src/sheets/index.html
··· 2 2 <html lang="en"> 3 3 <head> 4 4 <meta charset="UTF-8"> 5 - <meta name="viewport" content="width=device-width, initial-scale=1.0"> 5 + <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1"> 6 + <link rel="manifest" href="/manifest.json"> 6 7 <meta name="description" content="E2EE collaborative spreadsheet. End-to-end encrypted, real-time collaboration."> 7 8 <meta property="og:title" content="Tools — Sheets"> 8 9 <meta property="og:description" content="E2EE collaborative spreadsheet. End-to-end encrypted, real-time collaboration."> ··· 17 18 if (saved === 'dark' || saved === 'light') { 18 19 document.documentElement.setAttribute('data-theme', saved); 19 20 } 21 + if (window.electronAPI) document.documentElement.classList.add('is-electron'); 20 22 document.addEventListener('keydown', function(e) { 21 23 if (e.key === 'Tab') document.documentElement.setAttribute('data-a11y-focus', ''); 22 24 });
+12 -4
src/sheets/main.ts
··· 3750 3750 3751 3751 function updateSaveTimestamp() { 3752 3752 if (saveState !== 'saved') return; 3753 + const prefix = !provider.connected ? 'Saved locally' : 'Saved'; 3753 3754 const seconds = Math.floor((Date.now() - lastSaveTime) / 1000); 3754 - if (seconds < 5) saveTextEl.textContent = 'Saved'; 3755 - else if (seconds < 60) saveTextEl.textContent = 'Saved ' + seconds + 's ago'; 3756 - else saveTextEl.textContent = 'Saved ' + Math.floor(seconds / 60) + ' min ago'; 3755 + if (seconds < 5) saveTextEl.textContent = prefix; 3756 + else if (seconds < 60) saveTextEl.textContent = prefix + ' ' + seconds + 's ago'; 3757 + else saveTextEl.textContent = prefix + ' ' + Math.floor(seconds / 60) + ' min ago'; 3757 3758 } 3758 3759 3759 3760 setInterval(updateSaveTimestamp, 30_000); ··· 3761 3762 // Listen for save-status events from the provider (replaces monkey-patching) 3762 3763 provider.on('save-status', (payload) => { 3763 3764 if (payload.status === 'saving') setSaveState('saving'); 3764 - else if (payload.status === 'saved') setSaveState('saved', Date.now()); 3765 + else if (payload.status === 'saved') { 3766 + setSaveState('saved', Date.now()); 3767 + // Show "Saved locally" when offline (saved to IDB only, not server) 3768 + if (!provider.connected && saveTextEl) { 3769 + saveTextEl.textContent = 'Saved locally'; 3770 + } 3771 + } 3765 3772 else if (payload.status === 'error') setSaveState('unsaved'); 3766 3773 }); 3767 3774 ··· 3779 3786 ydoc.on('update', (update, origin) => { 3780 3787 if (origin !== provider && saveState === 'saved') setSaveState('unsaved'); 3781 3788 }); 3789 + 3782 3790 3783 3791 // --- Version Panel (slide-in, Cmd+Shift+H) --- 3784 3792 const sheetsVersionPanel = createVersionPanel({
+100
tests/markdown-paste.test.ts
··· 1 + import { describe, it, expect } from 'vitest'; 2 + import { looksLikeMarkdown } from '../src/docs/markdown-paste.js'; 3 + 4 + describe('looksLikeMarkdown', () => { 5 + // --- Strong solo signals (should return true with just 1) --- 6 + 7 + it('detects ATX headings', () => { 8 + expect(looksLikeMarkdown('# Hello World')).toBe(true); 9 + expect(looksLikeMarkdown('## Subheading')).toBe(true); 10 + expect(looksLikeMarkdown('###### Deep heading')).toBe(true); 11 + }); 12 + 13 + it('detects fenced code blocks', () => { 14 + expect(looksLikeMarkdown('```\nconst x = 1;\n```')).toBe(true); 15 + expect(looksLikeMarkdown('```typescript\nfoo()\n```')).toBe(true); 16 + }); 17 + 18 + it('detects tables with header separator', () => { 19 + expect(looksLikeMarkdown('| Name | Age |\n| --- | --- |\n| Alice | 30 |')).toBe(true); 20 + expect(looksLikeMarkdown('| Col1 | Col2 |\n|:---|:---|\n| val | val |')).toBe(true); 21 + }); 22 + 23 + // --- Multi-signal (2+ needed) --- 24 + 25 + it('detects unordered list + blockquote', () => { 26 + expect(looksLikeMarkdown('- Item one\n- Item two\n> quoted text')).toBe(true); 27 + }); 28 + 29 + it('detects ordered list + link', () => { 30 + expect(looksLikeMarkdown('1. First\n2. [Second](https://example.com)')).toBe(true); 31 + }); 32 + 33 + it('detects blockquote + list', () => { 34 + expect(looksLikeMarkdown('> Quote here\n\n- List item')).toBe(true); 35 + }); 36 + 37 + it('detects task list + heading', () => { 38 + expect(looksLikeMarkdown('# Todo\n- [x] Done\n- [ ] Pending')).toBe(true); 39 + }); 40 + 41 + it('detects image + heading', () => { 42 + expect(looksLikeMarkdown('# Gallery\n![photo](img.png)')).toBe(true); 43 + }); 44 + 45 + it('detects horizontal rule + list', () => { 46 + expect(looksLikeMarkdown('- Item\n\n---\n\n- Other')).toBe(true); 47 + }); 48 + 49 + // --- Negative cases --- 50 + 51 + it('rejects plain text', () => { 52 + expect(looksLikeMarkdown('Hello world, this is just normal text.')).toBe(false); 53 + }); 54 + 55 + it('rejects empty string', () => { 56 + expect(looksLikeMarkdown('')).toBe(false); 57 + }); 58 + 59 + it('rejects whitespace only', () => { 60 + expect(looksLikeMarkdown(' \n \n ')).toBe(false); 61 + }); 62 + 63 + it('rejects single bold markers (too common in normal text)', () => { 64 + expect(looksLikeMarkdown('This is **important** text')).toBe(false); 65 + }); 66 + 67 + it('rejects single italic markers', () => { 68 + expect(looksLikeMarkdown('This is _emphasized_ text')).toBe(false); 69 + }); 70 + 71 + it('rejects plain URLs without markdown syntax', () => { 72 + expect(looksLikeMarkdown('Check out https://example.com for details')).toBe(false); 73 + }); 74 + 75 + it('rejects hash without space (not a heading)', () => { 76 + expect(looksLikeMarkdown('#notheading #hashtag')).toBe(false); 77 + }); 78 + 79 + it('rejects single dash line (not a list)', () => { 80 + expect(looksLikeMarkdown('- ')).toBe(false); 81 + }); 82 + 83 + it('rejects code without fences', () => { 84 + expect(looksLikeMarkdown('const x = 1;\nconst y = 2;')).toBe(false); 85 + }); 86 + 87 + it('rejects table-like pipes without header separator', () => { 88 + expect(looksLikeMarkdown('| just | some | pipes |')).toBe(false); 89 + }); 90 + 91 + // --- Edge cases --- 92 + 93 + it('detects heading at line start but not mid-line', () => { 94 + expect(looksLikeMarkdown('some text\n## Heading\nmore text')).toBe(true); 95 + }); 96 + 97 + it('detects list items with leading whitespace', () => { 98 + expect(looksLikeMarkdown(' - Indented item\n - Another\n> Quote')).toBe(true); 99 + }); 100 + });