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

Configure Feed

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

feat: Waves 7-10 — slides, diagrams, comments, follow, focus, REST API (#212)

scott 9e4f4a4d b8147f3a

+2123 -8
+17
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.19.0] — 2026-04-01 9 + 10 + ### Added 11 + - Slide presentation editor with canvas rendering, themes, layouts, transitions, and presenter mode (#279) 12 + - Diagrams/whiteboard editor with freeform SVG canvas, shapes, arrows, freehand drawing, and pan/zoom (#279) 13 + - Threaded comments sidebar in docs with reply, resolve, and reopen (#279) 14 + - Follow mode to sync viewport with a collaborator's cursor position (#279) 15 + - Typewriter/focus mode that dims non-active paragraphs and auto-scrolls (#279) 16 + - Document minimap with heading outline and viewport indicator (already wired, now styled) 17 + - Cross-document wiki links supporting all document types (docs, sheets, forms, slides, diagrams) (#279) 18 + - REST API v1 endpoints: list documents, get document metadata, batch resolve for cross-doc embeds (#279) 19 + - Command palette entries for creating presentations and diagrams 20 + - Landing page create cards for "New Presentation" and "New Diagram" 21 + - CSS for slides editor, diagrams whiteboard, presenter overlay, comments sidebar, follow banner, split view, typewriter mode 22 + - Responsive styles for all new surfaces (768px and 480px breakpoints) 23 + - Electron traffic-light padding for macOS desktop app 24 + 8 25 ## [0.18.0] — 2026-03-31 9 26 10 27 ### Added
+1 -1
package.json
··· 1 1 { 2 2 "name": "tools", 3 - "version": "0.18.0", 3 + "version": "0.19.0", 4 4 "private": true, 5 5 "type": "module", 6 6 "main": "electron/main.js",
+78 -3
server/index.ts
··· 157 157 db.exec(` 158 158 CREATE TABLE IF NOT EXISTS documents ( 159 159 id TEXT PRIMARY KEY, 160 - type TEXT NOT NULL CHECK(type IN ('doc','sheet')), 160 + type TEXT NOT NULL CHECK(type IN ('doc','sheet','form','slide','diagram')), 161 161 name_encrypted TEXT, 162 162 snapshot BLOB, 163 163 share_mode TEXT DEFAULT 'edit', ··· 192 192 db.exec("ALTER TABLE documents ADD COLUMN tags TEXT"); 193 193 console.log('Migrated: added tags column'); 194 194 } 195 + 196 + // Migration: expand type CHECK constraint to include form, slide, diagram 197 + try { 198 + const tableInfo = db.prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='documents'").get() as { sql: string } | undefined; 199 + if (tableInfo && !tableInfo.sql.includes("'slide'")) { 200 + db.exec(` 201 + CREATE TABLE documents_new ( 202 + id TEXT PRIMARY KEY, 203 + type TEXT NOT NULL CHECK(type IN ('doc','sheet','form','slide','diagram')), 204 + name_encrypted TEXT, 205 + snapshot BLOB, 206 + share_mode TEXT DEFAULT 'edit', 207 + expires_at TEXT, 208 + deleted_at TEXT, 209 + tags TEXT, 210 + owner TEXT, 211 + created_at TEXT DEFAULT (datetime('now')), 212 + updated_at TEXT DEFAULT (datetime('now')) 213 + ); 214 + INSERT INTO documents_new SELECT id, type, name_encrypted, snapshot, share_mode, expires_at, deleted_at, tags, owner, created_at, updated_at FROM documents; 215 + DROP TABLE documents; 216 + ALTER TABLE documents_new RENAME TO documents; 217 + `); 218 + console.log('Migrated: expanded type CHECK for slide/diagram/form'); 219 + } 220 + } catch (e) { 221 + console.log('Type migration skipped:', (e as Error).message); 222 + } 223 + 195 224 db.exec(` 196 225 CREATE TABLE IF NOT EXISTS versions ( 197 226 id TEXT PRIMARY KEY, ··· 372 401 app.post('/api/documents', (req: Request<Record<string, string>, unknown, CreateDocumentBody> & { tsUser?: TailscaleUser | null }, res: Response) => { 373 402 const id = randomUUID(); 374 403 const { type, name_encrypted } = req.body; 375 - if (!type || !['doc', 'sheet'].includes(type)) { 376 - res.status(400).json({ error: 'type must be doc or sheet' }); 404 + if (!type || !['doc', 'sheet', 'form', 'slide', 'diagram'].includes(type)) { 405 + res.status(400).json({ error: 'type must be doc, sheet, form, slide, or diagram' }); 377 406 return; 378 407 } 379 408 const owner = req.tsUser?.login || null; ··· 701 730 res.json({ ok: true }); 702 731 }); 703 732 733 + // ── REST API v1 (Wave 9) ────────────────────────────────────────────── 734 + 735 + // Search documents by name (encrypted names are matched client-side, but 736 + // the server can filter by type and return metadata for cross-doc linking) 737 + app.get('/api/v1/documents', (req: Request, res: Response) => { 738 + const { type, limit: lim, offset: off } = req.query; 739 + const validTypes = ['doc', 'sheet', 'form', 'slide', 'diagram']; 740 + let query = 'SELECT id, type, name_encrypted, created_at, updated_at FROM documents WHERE trashed = 0'; 741 + const params: unknown[] = []; 742 + 743 + if (type && validTypes.includes(type as string)) { 744 + query += ' AND type = ?'; 745 + params.push(type); 746 + } 747 + 748 + query += ' ORDER BY updated_at DESC'; 749 + 750 + const limit = Math.min(Math.max(parseInt(lim as string) || 50, 1), 200); 751 + const offset = Math.max(parseInt(off as string) || 0, 0); 752 + query += ` LIMIT ? OFFSET ?`; 753 + params.push(limit, offset); 754 + 755 + const rows = db.prepare(query).all(...params); 756 + const total = (db.prepare('SELECT COUNT(*) as count FROM documents WHERE trashed = 0').get() as { count: number }).count; 757 + res.json({ data: rows, total, limit, offset }); 758 + }); 759 + 760 + // Get single document metadata 761 + app.get('/api/v1/documents/:id', (req: Request<{ id: string }>, res: Response) => { 762 + const row = db.prepare('SELECT id, type, name_encrypted, created_at, updated_at FROM documents WHERE id = ?').get(req.params.id); 763 + if (!row) return res.status(404).json({ error: 'Not found' }); 764 + res.json(row); 765 + }); 766 + 767 + // Batch resolve document metadata (for cross-doc embeds / wiki links) 768 + app.post('/api/v1/documents/resolve', (req: Request<Record<string, string>, unknown, { ids: string[] }>, res: Response) => { 769 + const { ids } = req.body; 770 + if (!Array.isArray(ids) || ids.length === 0) return res.status(400).json({ error: 'ids array required' }); 771 + const capped = ids.slice(0, 50); 772 + const placeholders = capped.map(() => '?').join(','); 773 + const rows = db.prepare(`SELECT id, type, name_encrypted, updated_at FROM documents WHERE id IN (${placeholders}) AND trashed = 0`).all(...capped); 774 + res.json({ data: rows }); 775 + }); 776 + 704 777 // Health check 705 778 app.get('/health', (_req: Request, res: Response) => { 706 779 try { ··· 741 814 app.get('/docs/:id', (_req: Request, res: Response) => { res.set(htmlNoCacheHeaders); res.sendFile(path.join(distPath, 'docs/index.html')); }); 742 815 app.get('/sheets/:id', (_req: Request, res: Response) => { res.set(htmlNoCacheHeaders); res.sendFile(path.join(distPath, 'sheets/index.html')); }); 743 816 app.get('/forms/:id', (_req: Request, res: Response) => { res.set(htmlNoCacheHeaders); res.sendFile(path.join(distPath, 'forms/index.html')); }); 817 + app.get('/slides/:id', (_req: Request, res: Response) => { res.set(htmlNoCacheHeaders); res.sendFile(path.join(distPath, 'slides/index.html')); }); 818 + app.get('/diagrams/:id', (_req: Request, res: Response) => { res.set(htmlNoCacheHeaders); res.sendFile(path.join(distPath, 'diagrams/index.html')); }); 744 819 app.get('*', (_req: Request, res: Response) => { res.set(htmlNoCacheHeaders); res.sendFile(path.join(distPath, 'index.html')); }); 745 820 746 821 // --- HTTP + HTTPS + WebSocket server ---
+607
src/css/app.css
··· 7767 7767 z-index: 200; 7768 7768 } 7769 7769 } 7770 + 7771 + /* ── Electron traffic-light padding ─────────────────────────────────── */ 7772 + .is-electron .app-topbar { 7773 + padding-left: 80px; 7774 + } 7775 + .is-electron .landing-header .brand { 7776 + padding-left: 72px; 7777 + } 7778 + 7779 + /* ── Slides Editor ──────────────────────────────────────────────────── */ 7780 + 7781 + .slides-app { 7782 + display: flex; 7783 + flex-direction: column; 7784 + height: 100vh; 7785 + overflow: hidden; 7786 + } 7787 + 7788 + .slides-main { 7789 + display: flex; 7790 + flex: 1; 7791 + overflow: hidden; 7792 + } 7793 + 7794 + /* Left: thumbnail panel */ 7795 + .slides-panel { 7796 + width: 200px; 7797 + flex-shrink: 0; 7798 + border-right: 1px solid var(--color-border); 7799 + background: var(--color-surface); 7800 + display: flex; 7801 + flex-direction: column; 7802 + overflow: hidden; 7803 + } 7804 + 7805 + .slides-thumbnail-list { 7806 + flex: 1; 7807 + overflow-y: auto; 7808 + padding: var(--space-sm); 7809 + display: flex; 7810 + flex-direction: column; 7811 + gap: var(--space-xs); 7812 + } 7813 + 7814 + .slides-thumbnail { 7815 + aspect-ratio: 16/9; 7816 + border: 2px solid var(--color-border); 7817 + border-radius: var(--radius-sm); 7818 + background: var(--color-bg); 7819 + cursor: pointer; 7820 + display: flex; 7821 + align-items: center; 7822 + justify-content: center; 7823 + font-size: 0.65rem; 7824 + color: var(--color-text-muted); 7825 + transition: border-color var(--transition-fast); 7826 + position: relative; 7827 + overflow: hidden; 7828 + } 7829 + 7830 + .slides-thumbnail:hover { 7831 + border-color: var(--color-border-strong); 7832 + } 7833 + 7834 + .slides-thumbnail.active { 7835 + border-color: var(--color-teal); 7836 + } 7837 + 7838 + .slides-thumbnail-number { 7839 + position: absolute; 7840 + top: 2px; 7841 + left: 4px; 7842 + font-size: 0.55rem; 7843 + color: var(--color-text-faint); 7844 + } 7845 + 7846 + .slides-add-btn { 7847 + margin: var(--space-sm); 7848 + padding: var(--space-xs) var(--space-sm); 7849 + border: 1px dashed var(--color-border-strong); 7850 + border-radius: var(--radius-sm); 7851 + background: transparent; 7852 + color: var(--color-text-muted); 7853 + cursor: pointer; 7854 + font-size: 0.75rem; 7855 + transition: background var(--transition-fast); 7856 + } 7857 + 7858 + .slides-add-btn:hover { 7859 + background: var(--color-hover); 7860 + } 7861 + 7862 + /* Center: canvas area */ 7863 + .slides-canvas-area { 7864 + flex: 1; 7865 + display: flex; 7866 + flex-direction: column; 7867 + overflow: hidden; 7868 + } 7869 + 7870 + .slides-toolbar { 7871 + display: flex; 7872 + align-items: center; 7873 + gap: var(--space-sm); 7874 + padding: var(--space-xs) var(--space-sm); 7875 + border-bottom: 1px solid var(--color-border); 7876 + background: var(--color-surface); 7877 + flex-wrap: wrap; 7878 + } 7879 + 7880 + .slides-layout-select, 7881 + .slides-theme-select, 7882 + .slides-transition-select { 7883 + padding: 3px 6px; 7884 + border: 1px solid var(--color-border); 7885 + border-radius: var(--radius-sm); 7886 + background: var(--color-bg); 7887 + color: var(--color-text); 7888 + font-size: 0.75rem; 7889 + } 7890 + 7891 + .slides-canvas-wrapper { 7892 + flex: 1; 7893 + display: flex; 7894 + align-items: center; 7895 + justify-content: center; 7896 + background: var(--color-surface-alt); 7897 + padding: var(--space-lg); 7898 + overflow: auto; 7899 + } 7900 + 7901 + .slides-canvas { 7902 + width: 960px; 7903 + height: 540px; 7904 + max-width: 100%; 7905 + background: white; 7906 + border-radius: var(--radius-md); 7907 + box-shadow: var(--shadow-lg); 7908 + position: relative; 7909 + overflow: hidden; 7910 + } 7911 + 7912 + [data-theme="dark"] .slides-canvas { 7913 + background: oklch(0.25 0.01 75); 7914 + } 7915 + 7916 + /* Right: notes panel */ 7917 + .slides-notes-panel { 7918 + width: 240px; 7919 + flex-shrink: 0; 7920 + border-left: 1px solid var(--color-border); 7921 + background: var(--color-surface); 7922 + display: flex; 7923 + flex-direction: column; 7924 + padding: var(--space-sm); 7925 + } 7926 + 7927 + .slides-notes-title { 7928 + font-size: 0.75rem; 7929 + font-weight: 600; 7930 + color: var(--color-text-muted); 7931 + text-transform: uppercase; 7932 + letter-spacing: 0.05em; 7933 + margin-bottom: var(--space-xs); 7934 + } 7935 + 7936 + .slides-notes-input { 7937 + flex: 1; 7938 + resize: none; 7939 + border: 1px solid var(--color-border); 7940 + border-radius: var(--radius-sm); 7941 + padding: var(--space-xs); 7942 + font-family: var(--font-body); 7943 + font-size: 0.8rem; 7944 + color: var(--color-text); 7945 + background: var(--color-bg); 7946 + } 7947 + 7948 + /* Slide elements */ 7949 + .slide-element { 7950 + position: absolute; 7951 + cursor: move; 7952 + user-select: none; 7953 + } 7954 + 7955 + .slide-element.selected { 7956 + outline: 2px solid var(--color-teal); 7957 + outline-offset: 1px; 7958 + } 7959 + 7960 + .slide-element-text { 7961 + min-width: 40px; 7962 + min-height: 20px; 7963 + padding: 4px 8px; 7964 + } 7965 + 7966 + .slide-element-text[contenteditable="true"] { 7967 + cursor: text; 7968 + outline: 1px dashed var(--color-border-strong); 7969 + } 7970 + 7971 + /* ── Presenter Overlay ──────────────────────────────────────────────── */ 7972 + 7973 + .presenter-overlay { 7974 + position: fixed; 7975 + inset: 0; 7976 + z-index: 1000; 7977 + background: oklch(0.10 0 0); 7978 + color: oklch(0.90 0 0); 7979 + display: flex; 7980 + } 7981 + 7982 + .presenter-current { 7983 + flex: 1; 7984 + display: flex; 7985 + align-items: center; 7986 + justify-content: center; 7987 + padding: var(--space-lg); 7988 + } 7989 + 7990 + .presenter-current .slides-canvas { 7991 + width: 100%; 7992 + height: 100%; 7993 + max-width: none; 7994 + } 7995 + 7996 + .presenter-sidebar { 7997 + width: 320px; 7998 + display: flex; 7999 + flex-direction: column; 8000 + border-left: 1px solid oklch(0.30 0 0); 8001 + background: oklch(0.14 0 0); 8002 + } 8003 + 8004 + .presenter-next { 8005 + padding: var(--space-sm); 8006 + border-bottom: 1px solid oklch(0.30 0 0); 8007 + } 8008 + 8009 + .presenter-next h4 { 8010 + font-size: 0.7rem; 8011 + text-transform: uppercase; 8012 + color: oklch(0.60 0 0); 8013 + margin-bottom: var(--space-xs); 8014 + } 8015 + 8016 + .presenter-next-preview { 8017 + aspect-ratio: 16/9; 8018 + background: oklch(0.20 0 0); 8019 + border-radius: var(--radius-sm); 8020 + overflow: hidden; 8021 + } 8022 + 8023 + .presenter-notes { 8024 + flex: 1; 8025 + padding: var(--space-sm); 8026 + overflow-y: auto; 8027 + font-size: 0.85rem; 8028 + line-height: 1.5; 8029 + color: oklch(0.80 0 0); 8030 + } 8031 + 8032 + .presenter-controls { 8033 + display: flex; 8034 + align-items: center; 8035 + gap: var(--space-sm); 8036 + padding: var(--space-sm); 8037 + border-top: 1px solid oklch(0.30 0 0); 8038 + } 8039 + 8040 + .presenter-timer { 8041 + font-family: var(--font-mono); 8042 + font-size: 1.2rem; 8043 + color: oklch(0.70 0 0); 8044 + } 8045 + 8046 + .presenter-progress { 8047 + font-size: 0.8rem; 8048 + color: oklch(0.60 0 0); 8049 + margin-left: auto; 8050 + } 8051 + 8052 + /* ── Diagrams / Whiteboard ──────────────────────────────────────────── */ 8053 + 8054 + .diagrams-app { 8055 + display: flex; 8056 + flex-direction: column; 8057 + height: 100vh; 8058 + overflow: hidden; 8059 + } 8060 + 8061 + .diagrams-main { 8062 + flex: 1; 8063 + display: flex; 8064 + position: relative; 8065 + overflow: hidden; 8066 + } 8067 + 8068 + .diagrams-toolbar { 8069 + position: absolute; 8070 + top: var(--space-sm); 8071 + left: 50%; 8072 + transform: translateX(-50%); 8073 + display: flex; 8074 + align-items: center; 8075 + gap: 2px; 8076 + padding: 4px 8px; 8077 + background: var(--color-surface); 8078 + border: 1px solid var(--color-border); 8079 + border-radius: var(--radius-lg); 8080 + box-shadow: var(--shadow-md); 8081 + z-index: 10; 8082 + } 8083 + 8084 + .diagrams-tool.active { 8085 + background: var(--color-btn-active-bg); 8086 + color: var(--color-accent); 8087 + } 8088 + 8089 + .diagrams-zoom-label { 8090 + font-size: 0.7rem; 8091 + font-family: var(--font-mono); 8092 + color: var(--color-text-muted); 8093 + min-width: 3em; 8094 + text-align: center; 8095 + } 8096 + 8097 + .diagrams-canvas-area { 8098 + flex: 1; 8099 + overflow: hidden; 8100 + cursor: crosshair; 8101 + } 8102 + 8103 + .diagrams-canvas { 8104 + width: 100%; 8105 + height: 100%; 8106 + display: block; 8107 + } 8108 + 8109 + .diagrams-grid { 8110 + pointer-events: none; 8111 + } 8112 + 8113 + .diagrams-layer { 8114 + pointer-events: all; 8115 + } 8116 + 8117 + /* Properties panel */ 8118 + .diagrams-props { 8119 + position: absolute; 8120 + top: var(--space-sm); 8121 + right: var(--space-sm); 8122 + width: 200px; 8123 + background: var(--color-surface); 8124 + border: 1px solid var(--color-border); 8125 + border-radius: var(--radius-md); 8126 + box-shadow: var(--shadow-md); 8127 + padding: var(--space-sm); 8128 + z-index: 10; 8129 + } 8130 + 8131 + .diagrams-props-title { 8132 + font-size: 0.7rem; 8133 + text-transform: uppercase; 8134 + letter-spacing: 0.05em; 8135 + color: var(--color-text-muted); 8136 + margin-bottom: var(--space-sm); 8137 + } 8138 + 8139 + .diagrams-prop-label { 8140 + display: block; 8141 + font-size: 0.75rem; 8142 + color: var(--color-text-muted); 8143 + margin-bottom: var(--space-xs); 8144 + } 8145 + 8146 + .diagrams-prop-input { 8147 + width: 100%; 8148 + padding: 3px 6px; 8149 + border: 1px solid var(--color-border); 8150 + border-radius: var(--radius-sm); 8151 + background: var(--color-bg); 8152 + color: var(--color-text); 8153 + font-size: 0.8rem; 8154 + margin-top: 2px; 8155 + } 8156 + 8157 + /* ── Comments Sidebar ───────────────────────────────────────────────── */ 8158 + 8159 + .comments-sidebar { 8160 + width: 300px; 8161 + flex-shrink: 0; 8162 + border-left: 1px solid var(--color-border); 8163 + background: var(--color-surface); 8164 + display: flex; 8165 + flex-direction: column; 8166 + overflow: hidden; 8167 + } 8168 + 8169 + .comments-header { 8170 + display: flex; 8171 + align-items: center; 8172 + justify-content: space-between; 8173 + padding: var(--space-sm) var(--space-md); 8174 + border-bottom: 1px solid var(--color-border); 8175 + } 8176 + 8177 + .comments-header h3 { 8178 + font-size: 0.8rem; 8179 + font-weight: 600; 8180 + text-transform: uppercase; 8181 + letter-spacing: 0.04em; 8182 + color: var(--color-text-muted); 8183 + } 8184 + 8185 + .comments-list { 8186 + flex: 1; 8187 + overflow-y: auto; 8188 + padding: var(--space-sm); 8189 + } 8190 + 8191 + .comment-thread { 8192 + border: 1px solid var(--color-border); 8193 + border-radius: var(--radius-md); 8194 + padding: var(--space-sm); 8195 + margin-bottom: var(--space-sm); 8196 + background: var(--color-bg); 8197 + transition: border-color var(--transition-fast); 8198 + } 8199 + 8200 + .comment-thread:hover { 8201 + border-color: var(--color-border-strong); 8202 + } 8203 + 8204 + .comment-thread.resolved { 8205 + opacity: 0.5; 8206 + } 8207 + 8208 + .comment-author { 8209 + font-size: 0.75rem; 8210 + font-weight: 600; 8211 + color: var(--color-text); 8212 + } 8213 + 8214 + .comment-time { 8215 + font-size: 0.65rem; 8216 + color: var(--color-text-faint); 8217 + margin-left: var(--space-xs); 8218 + } 8219 + 8220 + .comment-body { 8221 + font-size: 0.8rem; 8222 + color: var(--color-text); 8223 + margin-top: var(--space-xs); 8224 + line-height: 1.45; 8225 + } 8226 + 8227 + .comment-reply-input { 8228 + width: 100%; 8229 + margin-top: var(--space-xs); 8230 + padding: 4px 6px; 8231 + border: 1px solid var(--color-border); 8232 + border-radius: var(--radius-sm); 8233 + font-size: 0.75rem; 8234 + background: var(--color-surface); 8235 + color: var(--color-text); 8236 + } 8237 + 8238 + .comment-actions { 8239 + display: flex; 8240 + gap: var(--space-xs); 8241 + margin-top: var(--space-xs); 8242 + } 8243 + 8244 + .comment-actions button { 8245 + font-size: 0.65rem; 8246 + padding: 2px 6px; 8247 + border: 1px solid var(--color-border); 8248 + border-radius: var(--radius-sm); 8249 + background: transparent; 8250 + color: var(--color-text-muted); 8251 + cursor: pointer; 8252 + } 8253 + 8254 + .comment-actions button:hover { 8255 + background: var(--color-hover); 8256 + } 8257 + 8258 + /* ── Follow Mode ────────────────────────────────────────────────────── */ 8259 + 8260 + .follow-banner { 8261 + position: fixed; 8262 + top: var(--space-sm); 8263 + left: 50%; 8264 + transform: translateX(-50%); 8265 + padding: 6px 16px; 8266 + background: var(--color-teal); 8267 + color: var(--color-btn-primary-text); 8268 + border-radius: 999px; 8269 + font-size: 0.75rem; 8270 + font-weight: 600; 8271 + z-index: 100; 8272 + display: flex; 8273 + align-items: center; 8274 + gap: var(--space-sm); 8275 + box-shadow: var(--shadow-md); 8276 + } 8277 + 8278 + .follow-banner button { 8279 + background: transparent; 8280 + border: 1px solid currentColor; 8281 + color: inherit; 8282 + border-radius: var(--radius-sm); 8283 + padding: 2px 8px; 8284 + cursor: pointer; 8285 + font-size: 0.7rem; 8286 + } 8287 + 8288 + /* ── Split View ─────────────────────────────────────────────────────── */ 8289 + 8290 + .split-container { 8291 + display: grid; 8292 + width: 100%; 8293 + height: 100%; 8294 + overflow: hidden; 8295 + } 8296 + 8297 + .split-pane { 8298 + overflow: auto; 8299 + border-right: 1px solid var(--color-border); 8300 + position: relative; 8301 + } 8302 + 8303 + .split-pane:last-child { 8304 + border-right: none; 8305 + } 8306 + 8307 + .split-handle { 8308 + width: 4px; 8309 + background: var(--color-border); 8310 + cursor: col-resize; 8311 + transition: background var(--transition-fast); 8312 + } 8313 + 8314 + .split-handle:hover { 8315 + background: var(--color-teal); 8316 + } 8317 + 8318 + /* ── Focus / Typewriter Mode ────────────────────────────────────────── */ 8319 + 8320 + .typewriter-mode .tiptap .ProseMirror { 8321 + padding-top: 40vh; 8322 + padding-bottom: 40vh; 8323 + } 8324 + 8325 + .typewriter-mode .tiptap .ProseMirror .is-active-node { 8326 + opacity: 1; 8327 + } 8328 + 8329 + .typewriter-mode .tiptap .ProseMirror > *:not(.is-active-node) { 8330 + opacity: 0.4; 8331 + transition: opacity var(--transition-med); 8332 + } 8333 + 8334 + /* ── Responsive: Slides ─────────────────────────────────────────────── */ 8335 + 8336 + @media (max-width: 768px) { 8337 + .slides-panel { 8338 + width: 120px; 8339 + } 8340 + .slides-notes-panel { 8341 + display: none; 8342 + } 8343 + .slides-canvas { 8344 + width: 100%; 8345 + height: auto; 8346 + aspect-ratio: 16/9; 8347 + } 8348 + .presenter-sidebar { 8349 + width: 200px; 8350 + } 8351 + .diagrams-toolbar { 8352 + flex-wrap: wrap; 8353 + gap: 1px; 8354 + padding: 2px 4px; 8355 + } 8356 + .comments-sidebar { 8357 + position: fixed; 8358 + top: 0; 8359 + right: 0; 8360 + bottom: 0; 8361 + width: 100%; 8362 + z-index: 200; 8363 + } 8364 + } 8365 + 8366 + @media (max-width: 480px) { 8367 + .slides-panel { 8368 + display: none; 8369 + } 8370 + .presenter-sidebar { 8371 + display: none; 8372 + } 8373 + .diagrams-props { 8374 + width: 160px; 8375 + } 8376 + }
+84
src/diagrams/index.html
··· 1 + <!DOCTYPE html> 2 + <html lang="en"> 3 + <head> 4 + <meta charset="UTF-8"> 5 + <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1"> 6 + <link rel="manifest" href="/manifest.json"> 7 + <meta name="description" content="E2EE diagrams and whiteboard. End-to-end encrypted, real-time collaboration."> 8 + <title>Tools — Diagrams</title> 9 + <link rel="icon" type="image/svg+xml" href="/favicon.svg"> 10 + <link rel="stylesheet" href="../css/app.css"> 11 + <script> 12 + (function() { 13 + var saved = localStorage.getItem('tools-theme'); 14 + if (saved === 'dark' || saved === 'light') { 15 + document.documentElement.setAttribute('data-theme', saved); 16 + } 17 + if (window.electronAPI) document.documentElement.classList.add('is-electron'); 18 + })(); 19 + </script> 20 + </head> 21 + <body> 22 + <a class="skip-link" href="#main-content">Skip to content</a> 23 + <div class="diagrams-app" id="app"> 24 + <!-- Top bar --> 25 + <div class="app-topbar"> 26 + <a class="app-logo" href="/">Tools</a> 27 + <input class="doc-title-input" id="diagram-title" type="text" value="Untitled Diagram" spellcheck="false"> 28 + <span class="topbar-spacer"></span> 29 + <span class="save-status" id="save-status"></span> 30 + </div> 31 + 32 + <main class="diagrams-main" id="main-content"> 33 + <!-- Toolbar --> 34 + <div class="diagrams-toolbar" id="diagrams-toolbar"> 35 + <button class="btn-icon diagrams-tool active" id="tool-select" title="Select (V)" data-tool="select">&#9995;</button> 36 + <button class="btn-icon diagrams-tool" id="tool-rectangle" title="Rectangle (R)" data-tool="rectangle">&#9634;</button> 37 + <button class="btn-icon diagrams-tool" id="tool-ellipse" title="Ellipse (E)" data-tool="ellipse">&#9711;</button> 38 + <button class="btn-icon diagrams-tool" id="tool-diamond" title="Diamond (D)" data-tool="diamond">&#9670;</button> 39 + <button class="btn-icon diagrams-tool" id="tool-text" title="Text (T)" data-tool="text">T</button> 40 + <button class="btn-icon diagrams-tool" id="tool-freehand" title="Freehand (P)" data-tool="freehand">&#9997;</button> 41 + <button class="btn-icon diagrams-tool" id="tool-arrow" title="Arrow (A)" data-tool="arrow">&#8594;</button> 42 + <span class="toolbar-divider"></span> 43 + <button class="btn-icon" id="btn-snap-grid" title="Toggle grid snap">&#9638;</button> 44 + <button class="btn-icon" id="btn-zoom-in" title="Zoom in">+</button> 45 + <span class="diagrams-zoom-label" id="zoom-label">100%</span> 46 + <button class="btn-icon" id="btn-zoom-out" title="Zoom out">&minus;</button> 47 + <button class="btn-icon" id="btn-zoom-fit" title="Zoom to fit">&#8690;</button> 48 + <span class="toolbar-divider"></span> 49 + <button class="btn-icon" id="btn-delete" title="Delete selected">&#128465;</button> 50 + </div> 51 + 52 + <!-- Canvas --> 53 + <div class="diagrams-canvas-area" id="canvas-area"> 54 + <svg class="diagrams-canvas" id="diagram-canvas" xmlns="http://www.w3.org/2000/svg"> 55 + <defs> 56 + <pattern id="grid-pattern" width="20" height="20" patternUnits="userSpaceOnUse"> 57 + <path d="M 20 0 L 0 0 0 20" fill="none" stroke="var(--color-border)" stroke-width="0.5" opacity="0.3"/> 58 + </pattern> 59 + </defs> 60 + <rect class="diagrams-grid" width="100%" height="100%" fill="url(#grid-pattern)"/> 61 + <g class="diagrams-layer" id="diagram-layer"></g> 62 + </svg> 63 + </div> 64 + 65 + <!-- Properties panel (right sidebar, shown when element selected) --> 66 + <div class="diagrams-props" id="props-panel" style="display:none"> 67 + <h3 class="diagrams-props-title">Properties</h3> 68 + <label class="diagrams-prop-label">Label 69 + <input type="text" class="diagrams-prop-input" id="prop-label" placeholder="Label"> 70 + </label> 71 + <label class="diagrams-prop-label">Width 72 + <input type="number" class="diagrams-prop-input" id="prop-width" min="10"> 73 + </label> 74 + <label class="diagrams-prop-label">Height 75 + <input type="number" class="diagrams-prop-input" id="prop-height" min="10"> 76 + </label> 77 + </div> 78 + </main> 79 + </div> 80 + 81 + <div class="version-badge">v%APP_VERSION%</div> 82 + <script type="module" src="./main.ts"></script> 83 + </body> 84 + </html>
+503
src/diagrams/main.ts
··· 1 + // @ts-nocheck — DOM entry point; strict typing planned for follow-up 2 + /** 3 + * Tools Diagrams — E2EE collaborative whiteboard/diagrams. 4 + * Backed by Yjs for real-time collaboration. 5 + */ 6 + 7 + import * as Y from 'yjs'; 8 + import { importKey } from '../lib/crypto.js'; 9 + import { EncryptedProvider } from '../lib/provider.js'; 10 + import { 11 + createWhiteboard, addShape, removeShape, moveShape, resizeShape, setShapeLabel, 12 + addArrow, removeArrow, toggleSnap, pan, setZoom, 13 + hitTestShape, shapeAtPoint, arrowsForShape, getBoundingBox, elementCounts, 14 + } from './whiteboard.js'; 15 + import type { WhiteboardState, Shape, Arrow, ShapeKind, ArrowEndpoint, Point } from './whiteboard.js'; 16 + 17 + // --- DOM refs --- 18 + const $ = (id: string) => document.getElementById(id)!; 19 + const diagramTitle = $('diagram-title') as HTMLInputElement; 20 + const canvas = $('diagram-canvas') as unknown as SVGSVGElement; 21 + const layer = $('diagram-layer') as unknown as SVGGElement; 22 + const zoomLabel = $('zoom-label'); 23 + const propsPanel = $('props-panel'); 24 + const propLabel = $('prop-label') as HTMLInputElement; 25 + const propWidth = $('prop-width') as HTMLInputElement; 26 + const propHeight = $('prop-height') as HTMLInputElement; 27 + 28 + // --- State --- 29 + let wb: WhiteboardState = createWhiteboard(); 30 + let activeTool: string = 'select'; 31 + let selectedShapeId: string | null = null; 32 + let isDragging = false; 33 + let isPanning = false; 34 + let dragStart: Point = { x: 0, y: 0 }; 35 + let dragShapeStart: Point = { x: 0, y: 0 }; 36 + let panStart: Point = { x: 0, y: 0 }; 37 + let panWbStart: Point = { x: 0, y: 0 }; 38 + let isDrawingArrow = false; 39 + let arrowFromShape: string | null = null; 40 + let freehandPoints: Point[] = []; 41 + let isDrawingFreehand = false; 42 + 43 + // --- Yjs setup --- 44 + const docId = window.location.pathname.split('/').pop() || ''; 45 + const keyFragment = window.location.hash.slice(1); 46 + let cryptoKey: CryptoKey | null = null; 47 + const ydoc = new Y.Doc(); 48 + const yBoard = ydoc.getMap('board'); 49 + 50 + async function initCrypto() { 51 + if (keyFragment) { 52 + try { cryptoKey = await importKey(keyFragment); } catch { /* anon */ } 53 + } 54 + } 55 + 56 + function syncToYjs() { 57 + const shapes: Record<string, Shape> = {}; 58 + wb.shapes.forEach((v, k) => { shapes[k] = v; }); 59 + const arrows: Record<string, Arrow> = {}; 60 + wb.arrows.forEach((v, k) => { arrows[k] = v; }); 61 + yBoard.set('shapes', JSON.stringify(shapes)); 62 + yBoard.set('arrows', JSON.stringify(arrows)); 63 + yBoard.set('panX', wb.panX); 64 + yBoard.set('panY', wb.panY); 65 + yBoard.set('zoom', wb.zoom); 66 + yBoard.set('snapToGrid', wb.snapToGrid); 67 + } 68 + 69 + function loadFromYjs() { 70 + try { 71 + const shapesJson = yBoard.get('shapes') as string; 72 + const arrowsJson = yBoard.get('arrows') as string; 73 + if (shapesJson) { 74 + const parsed = JSON.parse(shapesJson); 75 + wb = { ...wb, shapes: new Map(Object.entries(parsed)) }; 76 + } 77 + if (arrowsJson) { 78 + const parsed = JSON.parse(arrowsJson); 79 + wb = { ...wb, arrows: new Map(Object.entries(parsed)) }; 80 + } 81 + if (yBoard.has('zoom')) wb = { ...wb, zoom: yBoard.get('zoom') as number }; 82 + if (yBoard.has('panX')) wb = { ...wb, panX: yBoard.get('panX') as number, panY: yBoard.get('panY') as number }; 83 + if (yBoard.has('snapToGrid')) wb = { ...wb, snapToGrid: yBoard.get('snapToGrid') as boolean }; 84 + } catch { /* defaults */ } 85 + } 86 + 87 + // --- Rendering --- 88 + function render() { 89 + layer.innerHTML = ''; 90 + const transform = `translate(${wb.panX}, ${wb.panY}) scale(${wb.zoom})`; 91 + layer.setAttribute('transform', transform); 92 + 93 + // Render shapes 94 + wb.shapes.forEach((shape) => { 95 + const g = document.createElementNS('http://www.w3.org/2000/svg', 'g'); 96 + g.setAttribute('data-shape-id', shape.id); 97 + g.setAttribute('transform', `translate(${shape.x}, ${shape.y})${shape.rotation ? ` rotate(${shape.rotation})` : ''}`); 98 + g.classList.add('diagram-shape'); 99 + if (shape.id === selectedShapeId) g.classList.add('selected'); 100 + 101 + const fill = shape.style?.fill || 'var(--color-surface)'; 102 + const stroke = shape.style?.stroke || 'var(--color-text)'; 103 + 104 + switch (shape.kind) { 105 + case 'rectangle': 106 + appendRect(g, shape.width, shape.height, fill, stroke); 107 + break; 108 + case 'ellipse': 109 + appendEllipse(g, shape.width, shape.height, fill, stroke); 110 + break; 111 + case 'diamond': 112 + appendDiamond(g, shape.width, shape.height, fill, stroke); 113 + break; 114 + case 'text': 115 + appendRect(g, shape.width, shape.height, 'transparent', 'transparent'); 116 + break; 117 + case 'freehand': 118 + if (shape.points && shape.points.length > 1) { 119 + const path = document.createElementNS('http://www.w3.org/2000/svg', 'path'); 120 + const d = shape.points.map((p, i) => `${i === 0 ? 'M' : 'L'}${p.x},${p.y}`).join(' '); 121 + path.setAttribute('d', d); 122 + path.setAttribute('fill', 'none'); 123 + path.setAttribute('stroke', stroke); 124 + path.setAttribute('stroke-width', '2'); 125 + path.setAttribute('stroke-linecap', 'round'); 126 + g.appendChild(path); 127 + } 128 + break; 129 + } 130 + 131 + // Label 132 + if (shape.label) { 133 + const text = document.createElementNS('http://www.w3.org/2000/svg', 'text'); 134 + text.setAttribute('x', String(shape.width / 2)); 135 + text.setAttribute('y', String(shape.height / 2)); 136 + text.setAttribute('text-anchor', 'middle'); 137 + text.setAttribute('dominant-baseline', 'central'); 138 + text.setAttribute('fill', 'var(--color-text)'); 139 + text.setAttribute('font-size', '14'); 140 + text.setAttribute('font-family', 'system-ui'); 141 + text.textContent = shape.label; 142 + g.appendChild(text); 143 + } 144 + 145 + layer.appendChild(g); 146 + }); 147 + 148 + // Render arrows 149 + wb.arrows.forEach((arrow) => { 150 + const from = resolveEndpoint(arrow.from); 151 + const to = resolveEndpoint(arrow.to); 152 + if (!from || !to) return; 153 + 154 + const line = document.createElementNS('http://www.w3.org/2000/svg', 'line'); 155 + line.setAttribute('x1', String(from.x)); 156 + line.setAttribute('y1', String(from.y)); 157 + line.setAttribute('x2', String(to.x)); 158 + line.setAttribute('y2', String(to.y)); 159 + line.setAttribute('stroke', arrow.style?.stroke || 'var(--color-text)'); 160 + line.setAttribute('stroke-width', '2'); 161 + line.setAttribute('marker-end', 'url(#arrowhead)'); 162 + line.classList.add('diagram-arrow'); 163 + line.setAttribute('data-arrow-id', arrow.id); 164 + layer.appendChild(line); 165 + 166 + if (arrow.label) { 167 + const text = document.createElementNS('http://www.w3.org/2000/svg', 'text'); 168 + text.setAttribute('x', String((from.x + to.x) / 2)); 169 + text.setAttribute('y', String((from.y + to.y) / 2 - 8)); 170 + text.setAttribute('text-anchor', 'middle'); 171 + text.setAttribute('fill', 'var(--color-text-secondary)'); 172 + text.setAttribute('font-size', '12'); 173 + text.textContent = arrow.label; 174 + layer.appendChild(text); 175 + } 176 + }); 177 + 178 + // Ensure arrowhead marker exists 179 + let defs = canvas.querySelector('defs'); 180 + if (defs && !defs.querySelector('#arrowhead')) { 181 + const marker = document.createElementNS('http://www.w3.org/2000/svg', 'marker'); 182 + marker.setAttribute('id', 'arrowhead'); 183 + marker.setAttribute('markerWidth', '10'); 184 + marker.setAttribute('markerHeight', '7'); 185 + marker.setAttribute('refX', '10'); 186 + marker.setAttribute('refY', '3.5'); 187 + marker.setAttribute('orient', 'auto'); 188 + const polygon = document.createElementNS('http://www.w3.org/2000/svg', 'polygon'); 189 + polygon.setAttribute('points', '0 0, 10 3.5, 0 7'); 190 + polygon.setAttribute('fill', 'var(--color-text)'); 191 + marker.appendChild(polygon); 192 + defs.appendChild(marker); 193 + } 194 + 195 + zoomLabel.textContent = `${Math.round(wb.zoom * 100)}%`; 196 + updateToolbar(); 197 + updateProps(); 198 + } 199 + 200 + function resolveEndpoint(ep: ArrowEndpoint): Point | null { 201 + if ('x' in ep) return { x: ep.x, y: ep.y }; 202 + const shape = wb.shapes.get(ep.shapeId); 203 + if (!shape) return null; 204 + const cx = shape.x + shape.width / 2; 205 + const cy = shape.y + shape.height / 2; 206 + switch (ep.anchor) { 207 + case 'top': return { x: cx, y: shape.y }; 208 + case 'bottom': return { x: cx, y: shape.y + shape.height }; 209 + case 'left': return { x: shape.x, y: cy }; 210 + case 'right': return { x: shape.x + shape.width, y: cy }; 211 + default: return { x: cx, y: cy }; 212 + } 213 + } 214 + 215 + function appendRect(g: SVGGElement, w: number, h: number, fill: string, stroke: string) { 216 + const rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect'); 217 + rect.setAttribute('width', String(w)); 218 + rect.setAttribute('height', String(h)); 219 + rect.setAttribute('rx', '4'); 220 + rect.setAttribute('fill', fill); 221 + rect.setAttribute('stroke', stroke); 222 + rect.setAttribute('stroke-width', '2'); 223 + g.appendChild(rect); 224 + } 225 + 226 + function appendEllipse(g: SVGGElement, w: number, h: number, fill: string, stroke: string) { 227 + const el = document.createElementNS('http://www.w3.org/2000/svg', 'ellipse'); 228 + el.setAttribute('cx', String(w / 2)); 229 + el.setAttribute('cy', String(h / 2)); 230 + el.setAttribute('rx', String(w / 2)); 231 + el.setAttribute('ry', String(h / 2)); 232 + el.setAttribute('fill', fill); 233 + el.setAttribute('stroke', stroke); 234 + el.setAttribute('stroke-width', '2'); 235 + g.appendChild(el); 236 + } 237 + 238 + function appendDiamond(g: SVGGElement, w: number, h: number, fill: string, stroke: string) { 239 + const poly = document.createElementNS('http://www.w3.org/2000/svg', 'polygon'); 240 + poly.setAttribute('points', `${w/2},0 ${w},${h/2} ${w/2},${h} 0,${h/2}`); 241 + poly.setAttribute('fill', fill); 242 + poly.setAttribute('stroke', stroke); 243 + poly.setAttribute('stroke-width', '2'); 244 + g.appendChild(poly); 245 + } 246 + 247 + function updateToolbar() { 248 + document.querySelectorAll('.diagrams-tool').forEach(btn => { 249 + btn.classList.toggle('active', (btn as HTMLElement).dataset.tool === activeTool); 250 + }); 251 + $('btn-snap-grid').classList.toggle('active', wb.snapToGrid); 252 + } 253 + 254 + function updateProps() { 255 + if (!selectedShapeId) { 256 + propsPanel.style.display = 'none'; 257 + return; 258 + } 259 + const shape = wb.shapes.get(selectedShapeId); 260 + if (!shape) { propsPanel.style.display = 'none'; return; } 261 + propsPanel.style.display = ''; 262 + propLabel.value = shape.label || ''; 263 + propWidth.value = String(shape.width); 264 + propHeight.value = String(shape.height); 265 + } 266 + 267 + // --- Canvas event handling --- 268 + function screenToCanvas(sx: number, sy: number): Point { 269 + const rect = canvas.getBoundingClientRect(); 270 + return { 271 + x: (sx - rect.left - wb.panX) / wb.zoom, 272 + y: (sy - rect.top - wb.panY) / wb.zoom, 273 + }; 274 + } 275 + 276 + canvas.addEventListener('mousedown', (e) => { 277 + const pt = screenToCanvas(e.clientX, e.clientY); 278 + 279 + if (activeTool === 'select') { 280 + const hit = shapeAtPoint(wb, pt.x, pt.y); 281 + if (hit) { 282 + selectedShapeId = hit.id; 283 + isDragging = true; 284 + dragStart = { x: e.clientX, y: e.clientY }; 285 + dragShapeStart = { x: hit.x, y: hit.y }; 286 + } else { 287 + selectedShapeId = null; 288 + isPanning = true; 289 + panStart = { x: e.clientX, y: e.clientY }; 290 + panWbStart = { x: wb.panX, y: wb.panY }; 291 + } 292 + render(); 293 + } else if (activeTool === 'arrow') { 294 + const hit = shapeAtPoint(wb, pt.x, pt.y); 295 + if (hit) { 296 + isDrawingArrow = true; 297 + arrowFromShape = hit.id; 298 + } 299 + } else if (activeTool === 'freehand') { 300 + isDrawingFreehand = true; 301 + freehandPoints = [pt]; 302 + } else { 303 + // Shape creation tools 304 + const kind = activeTool as ShapeKind; 305 + if (['rectangle', 'ellipse', 'diamond', 'text'].includes(kind)) { 306 + wb = addShape(wb, kind, pt.x, pt.y, 120, 80, kind === 'text' ? 'Text' : ''); 307 + syncToYjs(); 308 + render(); 309 + activeTool = 'select'; 310 + updateToolbar(); 311 + } 312 + } 313 + }); 314 + 315 + canvas.addEventListener('mousemove', (e) => { 316 + if (isDragging && selectedShapeId) { 317 + const dx = (e.clientX - dragStart.x) / wb.zoom; 318 + const dy = (e.clientY - dragStart.y) / wb.zoom; 319 + wb = moveShape(wb, selectedShapeId, dragShapeStart.x + dx, dragShapeStart.y + dy); 320 + render(); 321 + } else if (isPanning) { 322 + const dx = e.clientX - panStart.x; 323 + const dy = e.clientY - panStart.y; 324 + wb = { ...wb, panX: panWbStart.x + dx, panY: panWbStart.y + dy }; 325 + render(); 326 + } else if (isDrawingFreehand) { 327 + const pt = screenToCanvas(e.clientX, e.clientY); 328 + freehandPoints.push(pt); 329 + // Live preview: draw temporary path 330 + let tempPath = layer.querySelector('.freehand-preview'); 331 + if (!tempPath) { 332 + tempPath = document.createElementNS('http://www.w3.org/2000/svg', 'path'); 333 + tempPath.classList.add('freehand-preview'); 334 + tempPath.setAttribute('fill', 'none'); 335 + tempPath.setAttribute('stroke', 'var(--color-text)'); 336 + tempPath.setAttribute('stroke-width', '2'); 337 + tempPath.setAttribute('stroke-linecap', 'round'); 338 + layer.appendChild(tempPath); 339 + } 340 + const d = freehandPoints.map((p, i) => `${i === 0 ? 'M' : 'L'}${p.x},${p.y}`).join(' '); 341 + tempPath.setAttribute('d', d); 342 + } 343 + }); 344 + 345 + canvas.addEventListener('mouseup', (e) => { 346 + if (isDragging) { 347 + isDragging = false; 348 + syncToYjs(); 349 + } 350 + if (isPanning) { 351 + isPanning = false; 352 + } 353 + if (isDrawingArrow && arrowFromShape) { 354 + const pt = screenToCanvas(e.clientX, e.clientY); 355 + const hit = shapeAtPoint(wb, pt.x, pt.y); 356 + if (hit && hit.id !== arrowFromShape) { 357 + wb = addArrow(wb, { shapeId: arrowFromShape, anchor: 'center' }, { shapeId: hit.id, anchor: 'center' }); 358 + syncToYjs(); 359 + render(); 360 + } 361 + isDrawingArrow = false; 362 + arrowFromShape = null; 363 + } 364 + if (isDrawingFreehand && freehandPoints.length > 2) { 365 + // Calculate bounding box for freehand 366 + let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity; 367 + freehandPoints.forEach(p => { minX = Math.min(minX, p.x); minY = Math.min(minY, p.y); maxX = Math.max(maxX, p.x); maxY = Math.max(maxY, p.y); }); 368 + const normalized = freehandPoints.map(p => ({ x: p.x - minX, y: p.y - minY })); 369 + wb = addShape(wb, 'freehand', minX, minY, maxX - minX || 10, maxY - minY || 10); 370 + // Attach points to the last added shape 371 + const shapes = [...wb.shapes.values()]; 372 + const lastShape = shapes[shapes.length - 1]; 373 + if (lastShape) { 374 + wb.shapes.set(lastShape.id, { ...lastShape, points: normalized }); 375 + } 376 + syncToYjs(); 377 + render(); 378 + const preview = layer.querySelector('.freehand-preview'); 379 + if (preview) preview.remove(); 380 + isDrawingFreehand = false; 381 + freehandPoints = []; 382 + activeTool = 'select'; 383 + updateToolbar(); 384 + } else if (isDrawingFreehand) { 385 + isDrawingFreehand = false; 386 + freehandPoints = []; 387 + const preview = layer.querySelector('.freehand-preview'); 388 + if (preview) preview.remove(); 389 + } 390 + }); 391 + 392 + // Zoom with wheel 393 + canvas.addEventListener('wheel', (e) => { 394 + e.preventDefault(); 395 + const delta = e.deltaY > 0 ? -0.1 : 0.1; 396 + wb = setZoom(wb, wb.zoom + delta); 397 + render(); 398 + }, { passive: false }); 399 + 400 + // --- Tool buttons --- 401 + document.querySelectorAll('.diagrams-tool').forEach(btn => { 402 + btn.addEventListener('click', () => { 403 + activeTool = (btn as HTMLElement).dataset.tool || 'select'; 404 + updateToolbar(); 405 + }); 406 + }); 407 + 408 + $('btn-snap-grid').addEventListener('click', () => { wb = toggleSnap(wb); render(); }); 409 + $('btn-zoom-in').addEventListener('click', () => { wb = setZoom(wb, wb.zoom + 0.25); render(); }); 410 + $('btn-zoom-out').addEventListener('click', () => { wb = setZoom(wb, wb.zoom - 0.25); render(); }); 411 + $('btn-zoom-fit').addEventListener('click', () => { 412 + const box = getBoundingBox(wb); 413 + if (!box) return; 414 + const canvasRect = canvas.getBoundingClientRect(); 415 + const scaleX = canvasRect.width / (box.width + 100); 416 + const scaleY = canvasRect.height / (box.height + 100); 417 + const zoom = Math.min(scaleX, scaleY, 3); 418 + wb = setZoom(wb, zoom); 419 + wb = { ...wb, panX: canvasRect.width / 2 - (box.x + box.width / 2) * zoom, panY: canvasRect.height / 2 - (box.y + box.height / 2) * zoom }; 420 + render(); 421 + }); 422 + 423 + $('btn-delete').addEventListener('click', () => { 424 + if (selectedShapeId) { 425 + wb = removeShape(wb, selectedShapeId); 426 + selectedShapeId = null; 427 + syncToYjs(); 428 + render(); 429 + } 430 + }); 431 + 432 + // Properties panel 433 + propLabel.addEventListener('change', () => { 434 + if (selectedShapeId) { wb = setShapeLabel(wb, selectedShapeId, propLabel.value); syncToYjs(); render(); } 435 + }); 436 + propWidth.addEventListener('change', () => { 437 + if (selectedShapeId) { wb = resizeShape(wb, selectedShapeId, Number(propWidth.value), Number(propHeight.value)); syncToYjs(); render(); } 438 + }); 439 + propHeight.addEventListener('change', () => { 440 + if (selectedShapeId) { wb = resizeShape(wb, selectedShapeId, Number(propWidth.value), Number(propHeight.value)); syncToYjs(); render(); } 441 + }); 442 + 443 + // Keyboard shortcuts 444 + document.addEventListener('keydown', (e) => { 445 + if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return; 446 + switch (e.key) { 447 + case 'v': case 'V': activeTool = 'select'; updateToolbar(); break; 448 + case 'r': case 'R': activeTool = 'rectangle'; updateToolbar(); break; 449 + case 'e': case 'E': activeTool = 'ellipse'; updateToolbar(); break; 450 + case 'd': case 'D': activeTool = 'diamond'; updateToolbar(); break; 451 + case 't': case 'T': activeTool = 'text'; updateToolbar(); break; 452 + case 'p': case 'P': activeTool = 'freehand'; updateToolbar(); break; 453 + case 'a': case 'A': activeTool = 'arrow'; updateToolbar(); break; 454 + case 'Delete': case 'Backspace': 455 + if (selectedShapeId) { wb = removeShape(wb, selectedShapeId); selectedShapeId = null; syncToYjs(); render(); } 456 + break; 457 + } 458 + }); 459 + 460 + // Title editing 461 + diagramTitle.addEventListener('change', async () => { 462 + if (!cryptoKey) return; 463 + const { encrypt } = await import('../lib/crypto.js'); 464 + const nameBytes = new TextEncoder().encode(diagramTitle.value); 465 + const encrypted = await encrypt(nameBytes, cryptoKey); 466 + const b64 = btoa(String.fromCharCode(...new Uint8Array(encrypted))); 467 + fetch(`/api/documents/${docId}/name`, { 468 + method: 'PUT', 469 + headers: { 'Content-Type': 'application/json' }, 470 + body: JSON.stringify({ name_encrypted: b64 }), 471 + }); 472 + }); 473 + 474 + // --- Initialize --- 475 + async function init() { 476 + await initCrypto(); 477 + 478 + if (cryptoKey) { 479 + const provider = new EncryptedProvider(ydoc, docId, cryptoKey); 480 + provider.on('sync', () => { 481 + loadFromYjs(); 482 + render(); 483 + }); 484 + } 485 + 486 + // Load title 487 + try { 488 + const res = await fetch(`/api/documents/${docId}`); 489 + if (res.ok) { 490 + const doc = await res.json(); 491 + if (doc.name_encrypted && cryptoKey) { 492 + const bytes = Uint8Array.from(atob(doc.name_encrypted), c => c.charCodeAt(0)); 493 + const { decrypt } = await import('../lib/crypto.js'); 494 + const plain = await decrypt(bytes.buffer, cryptoKey); 495 + diagramTitle.value = new TextDecoder().decode(plain); 496 + } 497 + } 498 + } catch { /* ignore */ } 499 + 500 + render(); 501 + } 502 + 503 + init();
+2 -1
src/docs/extensions/wiki-link.ts
··· 69 69 const docId = node.attrs.docId; 70 70 const docType = node.attrs.docType || 'doc'; 71 71 72 + const typePathMap: Record<string, string> = { doc: 'docs', sheet: 'sheets', form: 'forms', slide: 'slides', diagram: 'diagrams' }; 72 73 const href = docId 73 - ? `/${docType === 'sheet' ? 'sheets' : 'docs'}/${docId}` 74 + ? `/${typePathMap[docType] || 'docs'}/${docId}` 74 75 : '#'; 75 76 76 77 return [
+26
src/docs/index.html
··· 55 55 <button class="btn-icon" id="btn-history" title="Version history"> 56 56 <svg class="tb-icon" viewBox="0 0 16 16" style="width:16px;height:16px" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="8" cy="8" r="6"/><path d="M8 4.5V8l2.5 1.5"/></svg> 57 57 </button> 58 + <!-- Comments sidebar --> 59 + <button class="btn-icon" id="btn-comments" title="Comments"> 60 + <svg class="tb-icon" viewBox="0 0 16 16" style="width:16px;height:16px" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M2.5 2.5h11a1 1 0 0 1 1 1v7a1 1 0 0 1-1 1h-3l-2.5 2.5v-2.5h-5.5a1 1 0 0 1-1-1v-7a1 1 0 0 1 1-1z"/><line x1="5" y1="5.5" x2="11" y2="5.5"/><line x1="5" y1="8" x2="9" y2="8"/></svg> 61 + </button> 62 + <!-- Typewriter / focus mode --> 63 + <button class="btn-icon" id="btn-typewriter" title="Typewriter mode"> 64 + <svg class="tb-icon" viewBox="0 0 16 16" style="width:16px;height:16px" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><line x1="3" y1="4" x2="13" y2="4"/><line x1="3" y1="8" x2="13" y2="8" stroke-width="2.5"/><line x1="3" y1="12" x2="13" y2="12"/></svg> 65 + </button> 58 66 <!-- AI Chat --> 59 67 <button class="btn-icon" id="btn-ai-chat" title="AI Chat (Cmd+Shift+L)"> 60 68 <svg class="tb-icon" viewBox="0 0 16 16" style="width:16px;height:16px" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M2 3h12a1 1 0 0 1 1 1v7a1 1 0 0 1-1 1H5l-3 3V4a1 1 0 0 1 1-1z"/><circle cx="5.5" cy="7.5" r="0.5" fill="currentColor" stroke="none"/><circle cx="8" cy="7.5" r="0.5" fill="currentColor" stroke="none"/><circle cx="10.5" cy="7.5" r="0.5" fill="currentColor" stroke="none"/></svg> ··· 396 404 <div class="footnote-section" id="footnote-section" style="display:none"></div> 397 405 </div> 398 406 407 + <!-- Comments sidebar (right) --> 408 + <div class="comments-sidebar" id="comments-sidebar" style="display:none"> 409 + <div class="comments-header"> 410 + <h3>Comments</h3> 411 + <button class="btn-icon" id="comments-sidebar-close" title="Close">&times;</button> 412 + </div> 413 + <div class="comments-list" id="comments-list"></div> 414 + <div class="comment-compose"> 415 + <input type="text" class="comment-reply-input" id="comment-new-input" placeholder="Add comment to selection..."> 416 + </div> 417 + </div> 418 + 399 419 <!-- Version history sidebar --> 400 420 <div class="version-sidebar" id="version-sidebar" style="display:none"> 401 421 <div class="version-sidebar-header"> ··· 413 433 <div class="version-preview-content" id="version-preview-content"></div> 414 434 </div> 415 435 </div> 436 + </div> 437 + 438 + <!-- Follow mode banner --> 439 + <div class="follow-banner" id="follow-banner" style="display:none"> 440 + <span id="follow-label">Following</span> 441 + <button id="follow-stop">Stop</button> 416 442 </div> 417 443 418 444 <!-- Word/character count footer -->
+224
src/docs/main.ts
··· 55 55 import { VersionManager, computeWordCount } from '../lib/version-history.js'; 56 56 import { createVersionPanel } from '../version-panel.js'; 57 57 import { extractHeadings, computeViewportIndicator } from './minimap.js'; 58 + import { 59 + type CommentThread, 60 + createThread, 61 + addReply, 62 + resolveThread, 63 + reopenThread, 64 + sortThreads, 65 + unresolvedCount, 66 + findThreadByAnchor, 67 + generateCommentId, 68 + } from '../lib/comment-threads.js'; 69 + import { 70 + type FollowState, 71 + createFollowState, 72 + startFollowing, 73 + stopFollowing, 74 + shouldScrollToFollow, 75 + computeFollowScroll, 76 + getFollowableUsers, 77 + handleLocalScroll, 78 + type CursorPosition, 79 + } from '../lib/follow-mode.js'; 58 80 import { SuggestionManager, createSuggestionAttrs } from '../lib/suggesting.js'; 59 81 import { OfflineManager } from '../lib/offline.js'; 60 82 import { extractHeadings, OutlineState } from './outline.js'; ··· 2517 2539 chatUI.stopBtn.style.display = 'none'; 2518 2540 } 2519 2541 2542 + // ────────────────────────────────────────────────────────────────────── 2543 + // Wave 8: Threaded Comments Sidebar 2544 + // ────────────────────────────────────────────────────────────────────── 2545 + 2546 + const commentsSidebar = document.getElementById('comments-sidebar') as HTMLElement; 2547 + const commentsListEl = document.getElementById('comments-list') as HTMLElement; 2548 + const commentsSidebarClose = document.getElementById('comments-sidebar-close') as HTMLElement; 2549 + const commentNewInput = document.getElementById('comment-new-input') as HTMLInputElement; 2550 + const btnComments = document.getElementById('btn-comments') as HTMLElement; 2551 + 2552 + let commentThreads: CommentThread[] = []; 2553 + 2554 + function renderCommentThreads() { 2555 + const sorted = sortThreads(commentThreads, 'unresolved'); 2556 + if (sorted.length === 0) { 2557 + commentsListEl.innerHTML = '<div style="padding:var(--space-sm);color:var(--color-text-faint);font-size:0.8rem;">No comments yet. Select text and click the comment button to start a thread.</div>'; 2558 + return; 2559 + } 2560 + commentsListEl.innerHTML = sorted.map(t => { 2561 + const allComments = [t.root, ...t.replies]; 2562 + return `<div class="comment-thread ${t.resolved ? 'resolved' : ''}" data-thread-id="${t.id}"> 2563 + ${allComments.map(c => `<div> 2564 + <span class="comment-author">${escapeHtml(c.author)}</span> 2565 + <span class="comment-time">${new Date(c.createdAt).toLocaleString()}</span> 2566 + <div class="comment-body">${escapeHtml(c.text)}</div> 2567 + </div>`).join('')} 2568 + <div class="comment-actions"> 2569 + ${t.resolved 2570 + ? `<button data-action="reopen" data-id="${t.id}">Reopen</button>` 2571 + : `<button data-action="resolve" data-id="${t.id}">Resolve</button>`} 2572 + <button data-action="reply" data-id="${t.id}">Reply</button> 2573 + </div> 2574 + </div>`; 2575 + }).join(''); 2576 + 2577 + commentsListEl.querySelectorAll('[data-action]').forEach(btn => { 2578 + btn.addEventListener('click', () => { 2579 + const action = btn.getAttribute('data-action'); 2580 + const threadId = btn.getAttribute('data-id')!; 2581 + const idx = commentThreads.findIndex(t => t.id === threadId); 2582 + if (idx === -1) return; 2583 + 2584 + if (action === 'resolve') { 2585 + commentThreads[idx] = resolveThread(commentThreads[idx], userName); 2586 + renderCommentThreads(); 2587 + syncCommentsToYjs(); 2588 + } else if (action === 'reopen') { 2589 + commentThreads[idx] = reopenThread(commentThreads[idx]); 2590 + renderCommentThreads(); 2591 + syncCommentsToYjs(); 2592 + } else if (action === 'reply') { 2593 + const text = prompt('Reply:'); 2594 + if (text) { 2595 + commentThreads[idx] = addReply(commentThreads[idx], userName, text); 2596 + renderCommentThreads(); 2597 + syncCommentsToYjs(); 2598 + } 2599 + } 2600 + }); 2601 + }); 2602 + } 2603 + 2604 + function syncCommentsToYjs() { 2605 + yDoc.getMap('meta').set('commentThreads', JSON.stringify(commentThreads)); 2606 + } 2607 + 2608 + function loadCommentsFromYjs() { 2609 + const raw = yDoc.getMap('meta').get('commentThreads') as string | undefined; 2610 + if (raw) { 2611 + try { commentThreads = JSON.parse(raw); } catch { commentThreads = []; } 2612 + } 2613 + renderCommentThreads(); 2614 + } 2615 + 2616 + // Sync comments on yjs changes 2617 + yDoc.getMap('meta').observe(() => { loadCommentsFromYjs(); }); 2618 + 2619 + btnComments.addEventListener('click', () => { 2620 + const showing = commentsSidebar.style.display !== 'none'; 2621 + commentsSidebar.style.display = showing ? 'none' : ''; 2622 + if (!showing) renderCommentThreads(); 2623 + }); 2624 + 2625 + commentsSidebarClose.addEventListener('click', () => { 2626 + commentsSidebar.style.display = 'none'; 2627 + }); 2628 + 2629 + // Add comment from sidebar input on Enter (requires text selection in editor) 2630 + commentNewInput.addEventListener('keydown', (e) => { 2631 + if (e.key !== 'Enter') return; 2632 + const text = commentNewInput.value.trim(); 2633 + if (!text) return; 2634 + 2635 + const { from, to } = editor.state.selection; 2636 + if (from === to) { 2637 + commentNewInput.placeholder = 'Select text first...'; 2638 + return; 2639 + } 2640 + 2641 + const anchorId = generateCommentId(); 2642 + editor.chain().focus().setComment({ 2643 + commentId: anchorId, 2644 + author: userName, 2645 + timestamp: new Date().toISOString(), 2646 + text, 2647 + }).run(); 2648 + 2649 + const thread = createThread(anchorId, userName, text); 2650 + commentThreads.push(thread); 2651 + renderCommentThreads(); 2652 + syncCommentsToYjs(); 2653 + commentNewInput.value = ''; 2654 + }); 2655 + 2656 + // ────────────────────────────────────────────────────────────────────── 2657 + // Wave 8: Follow Mode 2658 + // ────────────────────────────────────────────────────────────────────── 2659 + 2660 + const followBanner = document.getElementById('follow-banner') as HTMLElement; 2661 + const followLabel = document.getElementById('follow-label') as HTMLElement; 2662 + const followStop = document.getElementById('follow-stop') as HTMLElement; 2663 + 2664 + let followState = createFollowState(); 2665 + let remoteCursors: CursorPosition[] = []; 2666 + let isFollowScroll = false; 2667 + 2668 + followStop.addEventListener('click', () => { 2669 + followState = stopFollowing(followState); 2670 + followBanner.style.display = 'none'; 2671 + }); 2672 + 2673 + // Listen for manual scroll to auto-unfollow (reuse editorContainer from line ~1178) 2674 + if (editorContainer) { 2675 + editorContainer.addEventListener('scroll', () => { 2676 + if (isFollowScroll) { isFollowScroll = false; return; } 2677 + followState = handleLocalScroll(followState, true); 2678 + if (!followState.active) followBanner.style.display = 'none'; 2679 + }, { passive: true }); 2680 + } 2681 + 2682 + // Follow a collaborator: triggered by clicking their avatar in the topbar 2683 + document.getElementById('collab-avatars')?.addEventListener('click', (e) => { 2684 + const avatarEl = (e.target as HTMLElement).closest('[data-user-id]'); 2685 + if (!avatarEl) return; 2686 + const userId = avatarEl.getAttribute('data-user-id')!; 2687 + const displayName = avatarEl.getAttribute('title') || userId; 2688 + 2689 + followState = startFollowing(followState, userId); 2690 + followLabel.textContent = `Following ${displayName}`; 2691 + followBanner.style.display = ''; 2692 + }); 2693 + 2694 + // Process remote cursor updates for follow mode 2695 + function processFollowUpdate(cursor: CursorPosition) { 2696 + if (!shouldScrollToFollow(followState, cursor, Date.now())) return; 2697 + if (!editorContainer) return; 2698 + 2699 + const scrollTarget = computeFollowScroll(cursor.scrollTop, editorContainer.clientHeight); 2700 + isFollowScroll = true; 2701 + editorContainer.scrollTo({ top: scrollTarget, behavior: 'smooth' }); 2702 + } 2703 + 2704 + // ────────────────────────────────────────────────────────────────────── 2705 + // Wave 10: Typewriter / Focus Mode 2706 + // ────────────────────────────────────────────────────────────────────── 2707 + 2708 + const btnTypewriter = document.getElementById('btn-typewriter') as HTMLElement; 2709 + let typewriterActive = false; 2710 + 2711 + btnTypewriter.addEventListener('click', () => { 2712 + typewriterActive = !typewriterActive; 2713 + document.body.classList.toggle('typewriter-mode', typewriterActive); 2714 + btnTypewriter.classList.toggle('active', typewriterActive); 2715 + 2716 + if (typewriterActive) { 2717 + updateTypewriterFocus(); 2718 + } 2719 + }); 2720 + 2721 + function updateTypewriterFocus() { 2722 + if (!typewriterActive) return; 2723 + const prosemirror = editor.view.dom; 2724 + // Remove previous active marks 2725 + prosemirror.querySelectorAll('.is-active-node').forEach(el => el.classList.remove('is-active-node')); 2726 + 2727 + // Find the block containing the cursor 2728 + const { $anchor } = editor.state.selection; 2729 + const resolvedPos = editor.view.domAtPos($anchor.pos); 2730 + let node = resolvedPos.node; 2731 + if (node.nodeType === Node.TEXT_NODE) node = node.parentElement!; 2732 + const block = (node as HTMLElement).closest('p, h1, h2, h3, h4, h5, h6, li, blockquote, pre, .task-item') as HTMLElement | null; 2733 + if (block) { 2734 + block.classList.add('is-active-node'); 2735 + block.scrollIntoView({ behavior: 'smooth', block: 'center' }); 2736 + } 2737 + } 2738 + 2739 + editor.on('selectionUpdate', updateTypewriterFocus); 2740 + 2741 + // Initial load of comments from yjs 2742 + setTimeout(loadCommentsFromYjs, 500); 2743 +
+10
src/index.html
··· 58 58 <span class="create-card-title">New Form</span> 59 59 <span class="create-card-desc">E2EE form builder with responses pipeline to sheets</span> 60 60 </a> 61 + <a class="create-card" id="new-slide" href="#"> 62 + <span class="create-card-icon">&#9654;</span> 63 + <span class="create-card-title">New Presentation</span> 64 + <span class="create-card-desc">Slide decks with themes, transitions, and presenter mode</span> 65 + </a> 66 + <a class="create-card" id="new-diagram" href="#"> 67 + <span class="create-card-icon">&#9998;</span> 68 + <span class="create-card-title">New Diagram</span> 69 + <span class="create-card-desc">Freeform whiteboard with shapes, arrows, and freehand drawing</span> 70 + </a> 61 71 <a class="create-card create-card-accent" id="daily-note" href="#"> 62 72 <span class="create-card-icon">&#128197;</span> 63 73 <span class="create-card-title">Today's Note</span>
+28 -3
src/landing.ts
··· 38 38 const newDocBtn = document.getElementById('new-doc') as HTMLElement; 39 39 const newSheetBtn = document.getElementById('new-sheet') as HTMLElement; 40 40 const newFormBtn = document.getElementById('new-form') as HTMLElement; 41 + const newSlideBtn = document.getElementById('new-slide') as HTMLElement; 42 + const newDiagramBtn = document.getElementById('new-diagram') as HTMLElement; 41 43 const dailyNoteBtn = document.getElementById('daily-note') as HTMLElement; 42 44 const searchInput = document.getElementById('search-input') as HTMLInputElement; 43 45 const searchClear = document.getElementById('search-clear') as HTMLElement; ··· 190 192 }); 191 193 192 194 // --- Create document --- 193 - async function createDocument(type: 'doc' | 'sheet' | 'form'): Promise<void> { 195 + async function createDocument(type: 'doc' | 'sheet' | 'form' | 'slide' | 'diagram'): Promise<void> { 194 196 const key = await generateKey(); 195 197 const keyStr = await exportKey(key); 196 198 197 - const nameMap = { doc: 'Untitled Document', sheet: 'Untitled Spreadsheet', form: 'Untitled Form' }; 199 + const nameMap = { doc: 'Untitled Document', sheet: 'Untitled Spreadsheet', form: 'Untitled Form', slide: 'Untitled Presentation', diagram: 'Untitled Diagram' }; 198 200 const defaultName = nameMap[type]; 199 201 const nameBytes = new TextEncoder().encode(defaultName); 200 202 const { encrypt } = await import('./lib/crypto.js'); ··· 220 222 recentIds = trackRecentDoc(recentIds, id); 221 223 localStorage.setItem('tools-recent', JSON.stringify(recentIds)); 222 224 223 - const pathMap = { doc: '/docs', sheet: '/sheets', form: '/forms' }; 225 + const pathMap = { doc: '/docs', sheet: '/sheets', form: '/forms', slide: '/slides', diagram: '/diagrams' }; 224 226 window.location.href = `${pathMap[type]}/${id}#${keyStr}`; 225 227 } 226 228 ··· 265 267 newDocBtn.addEventListener('click', (e) => { e.preventDefault(); createDocument('doc'); }); 266 268 newSheetBtn.addEventListener('click', (e) => { e.preventDefault(); createDocument('sheet'); }); 267 269 newFormBtn.addEventListener('click', (e) => { e.preventDefault(); createDocument('form'); }); 270 + newSlideBtn.addEventListener('click', (e) => { e.preventDefault(); createDocument('slide'); }); 271 + newDiagramBtn.addEventListener('click', (e) => { e.preventDefault(); createDocument('diagram'); }); 268 272 269 273 // --- Daily Note --- 270 274 async function openDailyNote(): Promise<void> { ··· 1241 1245 category: 'action', 1242 1246 icon: '\u25a6', 1243 1247 action: () => createDocument('sheet'), 1248 + }, 1249 + { 1250 + id: 'new-form', 1251 + label: 'New Form', 1252 + category: 'action', 1253 + icon: '\u2610', 1254 + action: () => createDocument('form'), 1255 + }, 1256 + { 1257 + id: 'new-slide', 1258 + label: 'New Presentation', 1259 + category: 'action', 1260 + icon: '\u25b6', 1261 + action: () => createDocument('slide'), 1262 + }, 1263 + { 1264 + id: 'new-diagram', 1265 + label: 'New Diagram', 1266 + category: 'action', 1267 + icon: '\u270e', 1268 + action: () => createDocument('diagram'), 1244 1269 }, 1245 1270 { 1246 1271 id: 'daily-note',
+88
src/slides/index.html
··· 1 + <!DOCTYPE html> 2 + <html lang="en"> 3 + <head> 4 + <meta charset="UTF-8"> 5 + <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1"> 6 + <link rel="manifest" href="/manifest.json"> 7 + <meta name="description" content="E2EE slide presentations. End-to-end encrypted, real-time collaboration."> 8 + <title>Tools — Slides</title> 9 + <link rel="icon" type="image/svg+xml" href="/favicon.svg"> 10 + <link rel="stylesheet" href="../css/app.css"> 11 + <script> 12 + (function() { 13 + var saved = localStorage.getItem('tools-theme'); 14 + if (saved === 'dark' || saved === 'light') { 15 + document.documentElement.setAttribute('data-theme', saved); 16 + } 17 + if (window.electronAPI) document.documentElement.classList.add('is-electron'); 18 + })(); 19 + </script> 20 + </head> 21 + <body> 22 + <a class="skip-link" href="#main-content">Skip to content</a> 23 + <div class="slides-app" id="app"> 24 + <!-- Top bar --> 25 + <div class="app-topbar"> 26 + <a class="app-logo" href="/">Tools</a> 27 + <input class="doc-title-input" id="deck-title" type="text" value="Untitled Presentation" spellcheck="false"> 28 + <span class="topbar-spacer"></span> 29 + <span class="save-status" id="save-status"></span> 30 + <button class="btn-secondary" id="btn-present" title="Present (F5)">&#9654; Present</button> 31 + <button class="btn-secondary" id="btn-export" title="Export">Export</button> 32 + </div> 33 + 34 + <main class="slides-main" id="main-content"> 35 + <!-- Left: Slide panel (thumbnails) --> 36 + <div class="slides-panel" id="slide-panel"> 37 + <div class="slides-thumbnail-list" id="thumbnail-list"></div> 38 + <button class="slides-add-btn" id="btn-add-slide" title="Add slide">+ Slide</button> 39 + </div> 40 + 41 + <!-- Center: Canvas --> 42 + <div class="slides-canvas-area" id="canvas-area"> 43 + <div class="slides-toolbar" id="slides-toolbar"> 44 + <select class="slides-layout-select" id="layout-select" title="Slide layout"></select> 45 + <select class="slides-theme-select" id="theme-select" title="Theme"></select> 46 + <select class="slides-transition-select" id="transition-select" title="Transition"></select> 47 + <span class="toolbar-divider"></span> 48 + <button class="btn-icon" id="btn-add-text" title="Add text">T</button> 49 + <button class="btn-icon" id="btn-add-shape" title="Add shape">&#9632;</button> 50 + <button class="btn-icon" id="btn-add-image" title="Add image">&#128247;</button> 51 + <button class="btn-icon" id="btn-delete-element" title="Delete selected">&#128465;</button> 52 + </div> 53 + <div class="slides-canvas-wrapper"> 54 + <div class="slides-canvas" id="slide-canvas"></div> 55 + </div> 56 + </div> 57 + 58 + <!-- Right: Notes panel --> 59 + <div class="slides-notes-panel" id="notes-panel"> 60 + <h3 class="slides-notes-title">Speaker Notes</h3> 61 + <textarea class="slides-notes-input" id="notes-input" placeholder="Add speaker notes..."></textarea> 62 + </div> 63 + </main> 64 + 65 + <!-- Presenter overlay (hidden) --> 66 + <div class="presenter-overlay" id="presenter-overlay" style="display:none"> 67 + <div class="presenter-current" id="presenter-current"></div> 68 + <div class="presenter-sidebar"> 69 + <div class="presenter-next" id="presenter-next"> 70 + <h4>Next Slide</h4> 71 + <div class="presenter-next-preview" id="presenter-next-preview"></div> 72 + </div> 73 + <div class="presenter-notes" id="presenter-notes"></div> 74 + <div class="presenter-controls"> 75 + <span class="presenter-timer" id="presenter-timer">00:00</span> 76 + <span class="presenter-progress" id="presenter-progress">1 / 1</span> 77 + <button class="btn-icon" id="btn-presenter-prev" title="Previous">&#9664;</button> 78 + <button class="btn-icon" id="btn-presenter-next" title="Next">&#9654;</button> 79 + <button class="btn-icon" id="btn-presenter-exit" title="Exit">&#10005;</button> 80 + </div> 81 + </div> 82 + </div> 83 + </div> 84 + 85 + <div class="version-badge">v%APP_VERSION%</div> 86 + <script type="module" src="./main.ts"></script> 87 + </body> 88 + </html>
+453
src/slides/main.ts
··· 1 + // @ts-nocheck — DOM entry point; strict typing planned for follow-up 2 + /** 3 + * Tools Slides — E2EE collaborative presentations. 4 + * Backed by Yjs for real-time collaboration. 5 + */ 6 + 7 + import * as Y from 'yjs'; 8 + import { importKey, encryptString, decryptString } from '../lib/crypto.js'; 9 + import { EncryptedProvider } from '../lib/provider.js'; 10 + import { 11 + createDeck, addSlide, removeSlide, moveSlide, duplicateSlide, goToSlide, 12 + addElement, removeElement, moveElement, resizeElement, bringToFront, sendToBack, 13 + currentSlide, slideCount, elementCount, 14 + SLIDE_WIDTH, SLIDE_HEIGHT, 15 + } from './canvas-engine.js'; 16 + import type { DeckState, SlideElement, ElementType } from './canvas-engine.js'; 17 + import { 18 + getLayouts, getLayout, getThemes, getTheme, createThemedDeck, 19 + setSlideLayout, setDeckTheme, themeToCSS, 20 + } from './layouts-themes.js'; 21 + import type { LayoutType, Theme } from './layouts-themes.js'; 22 + import { 23 + createTransition, createSlideTransitions, setDefaultTransition, 24 + getTransitionForSlide, getTransitionTypes, transitionCSS, generateDeckCSS, 25 + } from './transitions.js'; 26 + import type { SlideTransitions } from './transitions.js'; 27 + import { 28 + createPresenterState, startPresentation, stopPresentation, 29 + nextSlide as presenterNext, prevSlide as presenterPrev, 30 + tickTimer, toggleTimer, setNotes, currentNotes, formatTime, 31 + progressPercent, isOverTime, 32 + } from './presenter-mode.js'; 33 + import type { PresenterState } from './presenter-mode.js'; 34 + 35 + // --- DOM refs --- 36 + const $ = (id: string) => document.getElementById(id)!; 37 + const deckTitle = $('deck-title') as HTMLInputElement; 38 + const thumbnailList = $('thumbnail-list'); 39 + const slideCanvas = $('slide-canvas'); 40 + const layoutSelect = $('layout-select') as HTMLSelectElement; 41 + const themeSelect = $('theme-select') as HTMLSelectElement; 42 + const transitionSelect = $('transition-select') as HTMLSelectElement; 43 + const notesInput = $('notes-input') as HTMLTextAreaElement; 44 + const presenterOverlay = $('presenter-overlay'); 45 + const presenterCurrent = $('presenter-current'); 46 + const presenterNextPreview = $('presenter-next-preview'); 47 + const presenterNotesEl = $('presenter-notes'); 48 + const presenterTimerEl = $('presenter-timer'); 49 + const presenterProgressEl = $('presenter-progress'); 50 + 51 + // --- State --- 52 + let deck: DeckState = createDeck(); 53 + let themedDeck = createThemedDeck(1); 54 + let transitions: SlideTransitions = createSlideTransitions(); 55 + let presenter: PresenterState = createPresenterState(1); 56 + let selectedElementId: string | null = null; 57 + let isDragging = false; 58 + let dragStartX = 0; 59 + let dragStartY = 0; 60 + let dragElStartX = 0; 61 + let dragElStartY = 0; 62 + 63 + // --- Yjs setup --- 64 + const docId = window.location.pathname.split('/').pop() || ''; 65 + const keyFragment = window.location.hash.slice(1); 66 + let cryptoKey: CryptoKey | null = null; 67 + const ydoc = new Y.Doc(); 68 + const yDeck = ydoc.getMap('deck'); 69 + 70 + async function initCrypto() { 71 + if (keyFragment) { 72 + try { cryptoKey = await importKey(keyFragment); } catch { /* anon */ } 73 + } 74 + } 75 + 76 + function syncDeckToYjs() { 77 + yDeck.set('slides', JSON.stringify(deck.slides)); 78 + yDeck.set('currentSlide', deck.currentSlide); 79 + yDeck.set('themed', JSON.stringify(themedDeck)); 80 + yDeck.set('transitions', JSON.stringify(transitions)); 81 + } 82 + 83 + function loadDeckFromYjs() { 84 + try { 85 + const slidesJson = yDeck.get('slides') as string; 86 + if (slidesJson) { 87 + const slides = JSON.parse(slidesJson); 88 + deck = { ...deck, slides, currentSlide: (yDeck.get('currentSlide') as number) || 0 }; 89 + } 90 + const themedJson = yDeck.get('themed') as string; 91 + if (themedJson) themedDeck = JSON.parse(themedJson); 92 + const transJson = yDeck.get('transitions') as string; 93 + if (transJson) { 94 + const parsed = JSON.parse(transJson); 95 + transitions = { ...parsed, overrides: new Map(Object.entries(parsed.overrides || {})) }; 96 + } 97 + } catch { /* use defaults */ } 98 + } 99 + 100 + // --- Populate dropdowns --- 101 + function initDropdowns() { 102 + getLayouts().forEach(l => { 103 + const opt = document.createElement('option'); 104 + opt.value = l.type; 105 + opt.textContent = l.label; 106 + layoutSelect.appendChild(opt); 107 + }); 108 + getThemes().forEach(t => { 109 + const opt = document.createElement('option'); 110 + opt.value = t.id; 111 + opt.textContent = t.name; 112 + themeSelect.appendChild(opt); 113 + }); 114 + getTransitionTypes().forEach(t => { 115 + const opt = document.createElement('option'); 116 + opt.value = t.type; 117 + opt.textContent = t.label; 118 + transitionSelect.appendChild(opt); 119 + }); 120 + } 121 + 122 + // --- Rendering --- 123 + function renderThumbnails() { 124 + thumbnailList.innerHTML = ''; 125 + deck.slides.forEach((slide, i) => { 126 + const thumb = document.createElement('div'); 127 + thumb.className = 'slides-thumbnail' + (i === deck.currentSlide ? ' active' : ''); 128 + thumb.dataset.index = String(i); 129 + const num = document.createElement('span'); 130 + num.className = 'slides-thumb-num'; 131 + num.textContent = String(i + 1); 132 + const preview = document.createElement('div'); 133 + preview.className = 'slides-thumb-preview'; 134 + preview.style.background = slide.background; 135 + preview.style.aspectRatio = '16/9'; 136 + // Mini element indicators 137 + if (slide.elements.length > 0) { 138 + preview.innerHTML = `<span class="slides-thumb-count">${slide.elements.length}</span>`; 139 + } 140 + thumb.appendChild(num); 141 + thumb.appendChild(preview); 142 + thumb.addEventListener('click', () => { 143 + deck = goToSlide(deck, i); 144 + syncDeckToYjs(); 145 + render(); 146 + }); 147 + // Context menu for delete/duplicate 148 + thumb.addEventListener('contextmenu', (e) => { 149 + e.preventDefault(); 150 + if (deck.slides.length > 1) { 151 + const action = confirm('Delete this slide? (Cancel to duplicate)'); 152 + if (action) { 153 + deck = removeSlide(deck, i); 154 + themedDeck = { ...themedDeck, layouts: themedDeck.layouts.filter((_, idx) => idx !== i) }; 155 + } else { 156 + deck = duplicateSlide(deck, i); 157 + } 158 + syncDeckToYjs(); 159 + render(); 160 + } 161 + }); 162 + thumbnailList.appendChild(thumb); 163 + }); 164 + } 165 + 166 + function renderCanvas() { 167 + const slide = currentSlide(deck); 168 + const theme = getTheme(themedDeck.themeId); 169 + const cssVars = theme ? themeToCSS(theme) : {}; 170 + 171 + let style = `width:${SLIDE_WIDTH}px;height:${SLIDE_HEIGHT}px;position:relative;overflow:hidden;background:${slide.background};`; 172 + for (const [k, v] of Object.entries(cssVars)) { 173 + style += `${k}:${v};`; 174 + } 175 + slideCanvas.setAttribute('style', style); 176 + 177 + slideCanvas.innerHTML = ''; 178 + const sorted = [...slide.elements].sort((a, b) => a.zIndex - b.zIndex); 179 + 180 + for (const el of sorted) { 181 + const div = document.createElement('div'); 182 + div.className = 'slide-element' + (el.id === selectedElementId ? ' selected' : ''); 183 + div.dataset.elementId = el.id; 184 + div.style.cssText = `position:absolute;left:${el.x}px;top:${el.y}px;width:${el.width}px;height:${el.height}px;` 185 + + (el.rotation ? `transform:rotate(${el.rotation}deg);` : ''); 186 + 187 + if (el.type === 'text') { 188 + div.innerHTML = `<div class="slide-el-text" contenteditable="true" style="width:100%;height:100%;font-family:${theme?.fonts.body || 'system-ui'};color:${theme?.palette.text || '#1a1815'};padding:8px;outline:none;">${el.content || 'Text'}</div>`; 189 + } else if (el.type === 'shape') { 190 + const fill = el.style?.fill || theme?.palette.primary || '#3a8a7a'; 191 + div.innerHTML = renderShapeSVG(el.shapeType || 'rectangle', el.width, el.height, fill); 192 + } else if (el.type === 'image') { 193 + div.innerHTML = `<img src="${el.content}" style="width:100%;height:100%;object-fit:contain;" alt="">`; 194 + } 195 + 196 + // Click to select 197 + div.addEventListener('mousedown', (e) => { 198 + e.stopPropagation(); 199 + selectedElementId = el.id; 200 + isDragging = true; 201 + dragStartX = e.clientX; 202 + dragStartY = e.clientY; 203 + dragElStartX = el.x; 204 + dragElStartY = el.y; 205 + renderCanvas(); 206 + }); 207 + 208 + // Inline text editing 209 + const textEl = div.querySelector('[contenteditable]'); 210 + if (textEl) { 211 + textEl.addEventListener('blur', () => { 212 + const slide = currentSlide(deck); 213 + const elIdx = slide.elements.findIndex(e => e.id === el.id); 214 + if (elIdx >= 0) { 215 + slide.elements[elIdx] = { ...slide.elements[elIdx], content: (textEl as HTMLElement).innerHTML }; 216 + syncDeckToYjs(); 217 + } 218 + }); 219 + } 220 + 221 + slideCanvas.appendChild(div); 222 + } 223 + } 224 + 225 + function renderShapeSVG(shapeType: string, w: number, h: number, fill: string): string { 226 + switch (shapeType) { 227 + case 'ellipse': 228 + return `<svg width="${w}" height="${h}" viewBox="0 0 ${w} ${h}"><ellipse cx="${w/2}" cy="${h/2}" rx="${w/2-2}" ry="${h/2-2}" fill="${fill}" stroke="${fill}" stroke-width="2" opacity="0.8"/></svg>`; 229 + case 'triangle': 230 + return `<svg width="${w}" height="${h}" viewBox="0 0 ${w} ${h}"><polygon points="${w/2},2 ${w-2},${h-2} 2,${h-2}" fill="${fill}" opacity="0.8"/></svg>`; 231 + case 'line': 232 + return `<svg width="${w}" height="${h}" viewBox="0 0 ${w} ${h}"><line x1="0" y1="${h/2}" x2="${w}" y2="${h/2}" stroke="${fill}" stroke-width="3"/></svg>`; 233 + case 'arrow': 234 + return `<svg width="${w}" height="${h}" viewBox="0 0 ${w} ${h}"><line x1="0" y1="${h/2}" x2="${w-10}" y2="${h/2}" stroke="${fill}" stroke-width="3"/><polygon points="${w},${h/2} ${w-12},${h/2-6} ${w-12},${h/2+6}" fill="${fill}"/></svg>`; 235 + default: // rectangle 236 + return `<svg width="${w}" height="${h}" viewBox="0 0 ${w} ${h}"><rect x="2" y="2" width="${w-4}" height="${h-4}" rx="4" fill="${fill}" opacity="0.8"/></svg>`; 237 + } 238 + } 239 + 240 + function render() { 241 + renderThumbnails(); 242 + renderCanvas(); 243 + // Sync notes 244 + const slide = currentSlide(deck); 245 + notesInput.value = slide.notes || ''; 246 + // Update dropdown selections 247 + if (themedDeck.layouts[deck.currentSlide]) { 248 + layoutSelect.value = themedDeck.layouts[deck.currentSlide]; 249 + } 250 + themeSelect.value = themedDeck.themeId; 251 + presenter = { ...presenter, totalSlides: slideCount(deck) }; 252 + } 253 + 254 + // --- Presenter mode --- 255 + let timerInterval: ReturnType<typeof setInterval> | null = null; 256 + 257 + function enterPresenter() { 258 + presenter = startPresentation({ ...presenter, totalSlides: slideCount(deck) }); 259 + presenterOverlay.style.display = ''; 260 + document.body.classList.add('presenting'); 261 + renderPresenter(); 262 + timerInterval = setInterval(() => { 263 + presenter = tickTimer(presenter); 264 + renderPresenter(); 265 + }, 1000); 266 + } 267 + 268 + function exitPresenter() { 269 + presenter = stopPresentation(presenter); 270 + presenterOverlay.style.display = 'none'; 271 + document.body.classList.remove('presenting'); 272 + if (timerInterval) { clearInterval(timerInterval); timerInterval = null; } 273 + } 274 + 275 + function renderPresenter() { 276 + // Current slide 277 + const slide = deck.slides[presenter.currentSlide]; 278 + if (slide) { 279 + presenterCurrent.style.background = slide.background; 280 + presenterCurrent.innerHTML = `<div style="padding:40px;font-size:24px;color:${getTheme(themedDeck.themeId)?.palette.text || '#1a1815'}">${slide.elements.filter(e => e.type === 'text').map(e => e.content).join('<br>') || ''}</div>`; 281 + } 282 + // Next slide preview 283 + const next = deck.slides[presenter.currentSlide + 1]; 284 + if (next) { 285 + presenterNextPreview.style.background = next.background; 286 + presenterNextPreview.innerHTML = `<div style="padding:8px;font-size:10px;">${next.elements.length} elements</div>`; 287 + } else { 288 + presenterNextPreview.innerHTML = '<div style="padding:8px;opacity:0.5;">End</div>'; 289 + } 290 + // Notes 291 + presenterNotesEl.textContent = currentNotes(presenter); 292 + // Timer 293 + presenterTimerEl.textContent = formatTime(presenter.elapsedSeconds); 294 + if (isOverTime(presenter)) presenterTimerEl.classList.add('over-time'); 295 + else presenterTimerEl.classList.remove('over-time'); 296 + // Progress 297 + presenterProgressEl.textContent = `${presenter.currentSlide + 1} / ${presenter.totalSlides}`; 298 + } 299 + 300 + // --- Event handlers --- 301 + $('btn-add-slide').addEventListener('click', () => { 302 + deck = addSlide(deck, deck.currentSlide + 1); 303 + deck = goToSlide(deck, deck.currentSlide + 1); 304 + themedDeck = { ...themedDeck, layouts: [...themedDeck.layouts, 'titleContent'] }; 305 + syncDeckToYjs(); 306 + render(); 307 + }); 308 + 309 + $('btn-present').addEventListener('click', () => enterPresenter()); 310 + $('btn-presenter-exit').addEventListener('click', () => exitPresenter()); 311 + $('btn-presenter-next').addEventListener('click', () => { presenter = presenterNext(presenter); renderPresenter(); }); 312 + $('btn-presenter-prev').addEventListener('click', () => { presenter = presenterPrev(presenter); renderPresenter(); }); 313 + 314 + $('btn-add-text').addEventListener('click', () => { 315 + deck = addElement(deck, 'text', 100, 100, 300, 60, 'Click to edit'); 316 + syncDeckToYjs(); 317 + renderCanvas(); 318 + }); 319 + 320 + $('btn-add-shape').addEventListener('click', () => { 321 + deck = addElement(deck, 'shape', 200, 150, 150, 150, '', { fill: getTheme(themedDeck.themeId)?.palette.primary }); 322 + syncDeckToYjs(); 323 + renderCanvas(); 324 + }); 325 + 326 + $('btn-add-image').addEventListener('click', () => { 327 + const url = prompt('Image URL:'); 328 + if (url) { 329 + deck = addElement(deck, 'image', 150, 100, 300, 200, url); 330 + syncDeckToYjs(); 331 + renderCanvas(); 332 + } 333 + }); 334 + 335 + $('btn-delete-element').addEventListener('click', () => { 336 + if (selectedElementId) { 337 + deck = removeElement(deck, selectedElementId); 338 + selectedElementId = null; 339 + syncDeckToYjs(); 340 + renderCanvas(); 341 + } 342 + }); 343 + 344 + layoutSelect.addEventListener('change', () => { 345 + themedDeck = setSlideLayout(themedDeck, deck.currentSlide, layoutSelect.value as LayoutType); 346 + syncDeckToYjs(); 347 + render(); 348 + }); 349 + 350 + themeSelect.addEventListener('change', () => { 351 + themedDeck = setDeckTheme(themedDeck, themeSelect.value); 352 + syncDeckToYjs(); 353 + render(); 354 + }); 355 + 356 + transitionSelect.addEventListener('change', () => { 357 + transitions = setDefaultTransition(transitions, createTransition(transitionSelect.value as any)); 358 + syncDeckToYjs(); 359 + }); 360 + 361 + notesInput.addEventListener('input', () => { 362 + presenter = setNotes(presenter, deck.currentSlide, notesInput.value); 363 + const slide = currentSlide(deck); 364 + slide.notes = notesInput.value; 365 + syncDeckToYjs(); 366 + }); 367 + 368 + // Canvas click to deselect 369 + slideCanvas.addEventListener('mousedown', (e) => { 370 + if (e.target === slideCanvas) { 371 + selectedElementId = null; 372 + renderCanvas(); 373 + } 374 + }); 375 + 376 + // Drag handling 377 + document.addEventListener('mousemove', (e) => { 378 + if (!isDragging || !selectedElementId) return; 379 + const dx = e.clientX - dragStartX; 380 + const dy = e.clientY - dragStartY; 381 + deck = moveElement(deck, selectedElementId, dragElStartX + dx, dragElStartY + dy); 382 + renderCanvas(); 383 + }); 384 + 385 + document.addEventListener('mouseup', () => { 386 + if (isDragging) { 387 + isDragging = false; 388 + syncDeckToYjs(); 389 + } 390 + }); 391 + 392 + // Keyboard shortcuts 393 + document.addEventListener('keydown', (e) => { 394 + if (presenterOverlay.style.display !== 'none') { 395 + if (e.key === 'ArrowRight' || e.key === ' ') { presenter = presenterNext(presenter); renderPresenter(); } 396 + else if (e.key === 'ArrowLeft') { presenter = presenterPrev(presenter); renderPresenter(); } 397 + else if (e.key === 'Escape') exitPresenter(); 398 + return; 399 + } 400 + if (e.key === 'F5') { e.preventDefault(); enterPresenter(); } 401 + if (e.key === 'Delete' && selectedElementId && !(e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement)) { 402 + deck = removeElement(deck, selectedElementId); 403 + selectedElementId = null; 404 + syncDeckToYjs(); 405 + renderCanvas(); 406 + } 407 + }); 408 + 409 + // Title editing 410 + deckTitle.addEventListener('change', async () => { 411 + if (!cryptoKey) return; 412 + const { encrypt } = await import('../lib/crypto.js'); 413 + const nameBytes = new TextEncoder().encode(deckTitle.value); 414 + const encrypted = await encrypt(nameBytes, cryptoKey); 415 + const b64 = btoa(String.fromCharCode(...new Uint8Array(encrypted))); 416 + fetch(`/api/documents/${docId}/name`, { 417 + method: 'PUT', 418 + headers: { 'Content-Type': 'application/json' }, 419 + body: JSON.stringify({ name_encrypted: b64 }), 420 + }); 421 + }); 422 + 423 + // --- Initialize --- 424 + async function init() { 425 + await initCrypto(); 426 + initDropdowns(); 427 + 428 + if (cryptoKey) { 429 + const provider = new EncryptedProvider(ydoc, docId, cryptoKey); 430 + provider.on('sync', () => { 431 + loadDeckFromYjs(); 432 + render(); 433 + }); 434 + } 435 + 436 + // Load title 437 + try { 438 + const res = await fetch(`/api/documents/${docId}`); 439 + if (res.ok) { 440 + const doc = await res.json(); 441 + if (doc.name_encrypted && cryptoKey) { 442 + const bytes = Uint8Array.from(atob(doc.name_encrypted), c => c.charCodeAt(0)); 443 + const { decrypt } = await import('../lib/crypto.js'); 444 + const plain = await decrypt(bytes.buffer, cryptoKey); 445 + deckTitle.value = new TextDecoder().decode(plain); 446 + } 447 + } 448 + } catch { /* ignore */ } 449 + 450 + render(); 451 + } 452 + 453 + init();
+2
vite.config.ts
··· 31 31 docs: resolve(__dirname, 'src/docs/index.html'), 32 32 sheets: resolve(__dirname, 'src/sheets/index.html'), 33 33 forms: resolve(__dirname, 'src/forms/index.html'), 34 + slides: resolve(__dirname, 'src/slides/index.html'), 35 + diagrams: resolve(__dirname, 'src/diagrams/index.html'), 34 36 }, 35 37 }, 36 38 },