diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..eddeb01 --- /dev/null +++ b/TODO.md @@ -0,0 +1,86 @@ +# TODO - Future Improvements + +## High Priority + +### Unsaved Note Switching +**Current Behavior:** When a note has unsaved changes, switching to another note is completely blocked. User must either save or discard changes first. + +**Proposed Improvement:** Implement local session storage for unsaved changes: +- Store unsaved note content in browser's sessionStorage/localStorage +- Allow switching between notes without losing unsaved changes +- Each note maintains its own unsaved state independently +- Unsaved changes persist across note switches but don't trigger server sync +- Visual indicator shows which notes have unsaved local changes +- Only sync with server when user explicitly saves + +**Benefits:** +- More flexible editing workflow +- Can work on multiple notes simultaneously +- No data loss when switching notes +- Better matches user expectations from modern editors + +**Technical Approach:** +- Use Map/Object to store unsaved changes per note ID +- Key: note ID, Value: { title, content, timestamp } +- Load from local storage on note switch +- Clear local storage on explicit save or discard +- Add visual indicator (dot/asterisk) on notes with local changes + +### PDF Export Styling +**Current Issue:** With custom Google Fonts in place, PDF export produces broken layout and styling. The jsPDF html() method doesn't properly handle web fonts and complex CSS. + +**Needs Investigation:** +- jsPDF may not support external web fonts properly +- May need to embed fonts or use fallback system fonts for PDF +- Consider alternative approaches: html2canvas, puppeteer, or server-side PDF generation +- Ensure proper markdown rendering with headings, lists, code blocks, etc. +- Maintain consistent styling between preview and PDF output +- Consider bundling Google Fonts locally for offline support and better PDF rendering + +### Offline Mode +**Current Issue:** App fails when internet connection is unavailable. No local caching, no change queuing, no sync on reconnect. + +**Required Features:** +- Local-first storage of all notes (IndexedDB or localStorage) +- Work offline seamlessly - create, edit, delete notes +- Queue changes when offline for later sync +- Detect connection restore and push queued changes +- Conflict resolution when note changed both locally and on server +- Visual indicator showing online/offline status +- Show which notes have pending sync + +**Technical Approach:** +- Cache all notes locally on successful fetch +- Intercept all API calls - if offline, work with local cache +- Maintain a sync queue: { noteId, action, timestamp, data } +- Use navigator.onLine and 'online'/'offline' events for detection +- On reconnect: process queue in order, handle conflicts +- Conflict strategy: last-write-wins or prompt user + +**Synergy with Other Features:** +- Pairs well with "Unsaved Note Switching" (both need local storage) +- Bundled fonts ensure app works fully offline + +--- + +## Medium Priority + +### Other Improvements +- Add keyboard shortcuts (Cmd+S for save, Cmd+N for new note, etc.) +- Implement note search within content (not just titles) +- Add tags/labels system as alternative to categories +- Export multiple notes at once +- Import notes from other formats (Markdown files, etc.) + +--- + +## Low Priority + +### Nice to Have +- Note templates +- Rich text paste handling +- ~~Image upload/embedding support~~ ✅ (viewing attachments works, upload TBD) +- Note linking (wiki-style) +- Version history/undo for saved notes +- Customizable editor themes +- Font size adjustment diff --git a/index.html b/index.html index ff93803..b9eb29e 100644 --- a/index.html +++ b/index.html @@ -5,6 +5,12 @@ Tauri + React + Typescript + + + + + + diff --git a/package-lock.json b/package-lock.json index b0a6287..568e7f2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,12 +9,16 @@ "version": "0.1.0", "dependencies": { "@tauri-apps/api": "^2", + "@tauri-apps/plugin-dialog": "^2.6.0", + "@tauri-apps/plugin-http": "^2.5.7", "@tauri-apps/plugin-opener": "^2", "@tiptap/extension-strike": "^2.27.2", "@tiptap/extension-underline": "^2.27.2", "@tiptap/pm": "^2.27.2", "@tiptap/react": "^2.27.2", "@tiptap/starter-kit": "^2.27.2", + "html2canvas": "^1.4.1", + "jspdf": "^4.2.0", "marked": "^17.0.4", "react": "^19.1.0", "react-dom": "^19.1.0", @@ -25,6 +29,7 @@ "@tauri-apps/cli": "^2", "@types/react": "^19.1.8", "@types/react-dom": "^19.1.6", + "@types/turndown": "^5.0.6", "@vitejs/plugin-react": "^4.6.0", "autoprefixer": "^10.4.27", "postcss": "^8.5.8", @@ -280,6 +285,15 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/runtime": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { "version": "7.28.6", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", @@ -1477,6 +1491,24 @@ "node": ">= 10" } }, + "node_modules/@tauri-apps/plugin-dialog": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-dialog/-/plugin-dialog-2.6.0.tgz", + "integrity": "sha512-q4Uq3eY87TdcYzXACiYSPhmpBA76shgmQswGkSVio4C82Sz2W4iehe9TnKYwbq7weHiL88Yw19XZm7v28+Micg==", + "license": "MIT OR Apache-2.0", + "dependencies": { + "@tauri-apps/api": "^2.8.0" + } + }, + "node_modules/@tauri-apps/plugin-http": { + "version": "2.5.7", + "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-http/-/plugin-http-2.5.7.tgz", + "integrity": "sha512-+F2lEH/c9b0zSsOXKq+5hZNcd9F4IIKCK1T17RqMwpCmVnx2aoqY8yIBccCd25HTYUb3j6NPVbRax/m00hKG8A==", + "license": "MIT OR Apache-2.0", + "dependencies": { + "@tauri-apps/api": "^2.10.1" + } + }, "node_modules/@tauri-apps/plugin-opener": { "version": "2.5.3", "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-opener/-/plugin-opener-2.5.3.tgz", @@ -1958,6 +1990,19 @@ "integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==", "license": "MIT" }, + "node_modules/@types/pako": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@types/pako/-/pako-2.0.4.tgz", + "integrity": "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==", + "license": "MIT" + }, + "node_modules/@types/raf": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/@types/raf/-/raf-3.4.3.tgz", + "integrity": "sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw==", + "license": "MIT", + "optional": true + }, "node_modules/@types/react": { "version": "19.2.14", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", @@ -1978,6 +2023,20 @@ "@types/react": "^19.2.0" } }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT", + "optional": true + }, + "node_modules/@types/turndown": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/@types/turndown/-/turndown-5.0.6.tgz", + "integrity": "sha512-ru00MoyeeouE5BX4gRL+6m/BsDfbRayOskWqUvh7CLGW+UXxHQItqALa38kKnOiZPqJrtzJUgAC2+F0rL1S4Pg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/use-sync-external-store": { "version": "0.0.6", "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", @@ -2076,6 +2135,15 @@ "postcss": "^8.1.0" } }, + "node_modules/base64-arraybuffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz", + "integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + } + }, "node_modules/baseline-browser-mapping": { "version": "2.10.8", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.8.tgz", @@ -2180,6 +2248,26 @@ ], "license": "CC-BY-4.0" }, + "node_modules/canvg": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/canvg/-/canvg-3.0.11.tgz", + "integrity": "sha512-5ON+q7jCTgMp9cjpu4Jo6XbvfYwSB2Ow3kzHKfIyJfaCAOHLbdKPQqGKgfED/R5B+3TFFfe8pegYA+b423SRyA==", + "license": "MIT", + "optional": true, + "dependencies": { + "@babel/runtime": "^7.12.5", + "@types/raf": "^3.4.0", + "core-js": "^3.8.3", + "raf": "^3.4.1", + "regenerator-runtime": "^0.13.7", + "rgbcolor": "^1.0.1", + "stackblur-canvas": "^2.0.0", + "svg-pathdata": "^6.0.3" + }, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/chokidar": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", @@ -2235,12 +2323,33 @@ "dev": true, "license": "MIT" }, + "node_modules/core-js": { + "version": "3.49.0", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.49.0.tgz", + "integrity": "sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, "node_modules/crelt": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz", "integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==", "license": "MIT" }, + "node_modules/css-line-break": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/css-line-break/-/css-line-break-2.1.0.tgz", + "integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==", + "license": "MIT", + "dependencies": { + "utrie": "^1.0.2" + } + }, "node_modules/cssesc": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", @@ -2293,6 +2402,16 @@ "dev": true, "license": "MIT" }, + "node_modules/dompurify": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.3.3.tgz", + "integrity": "sha512-Oj6pzI2+RqBfFG+qOaOLbFXLQ90ARpcGG6UePL82bJLtdsa6CYJD7nmiU8MW9nQNOtCHV3lZ/Bzq1X0QYbBZCA==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optional": true, + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, "node_modules/electron-to-chromium": { "version": "1.5.313", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.313.tgz", @@ -2412,6 +2531,17 @@ "node": ">= 6" } }, + "node_modules/fast-png": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/fast-png/-/fast-png-6.4.0.tgz", + "integrity": "sha512-kAqZq1TlgBjZcLr5mcN6NP5Rv4V2f22z00c3g8vRrwkcqjerx7BEhPbOnWCPqaHUl2XWQBJQvOT/FQhdMT7X/Q==", + "license": "MIT", + "dependencies": { + "@types/pako": "^2.0.3", + "iobuffer": "^5.3.2", + "pako": "^2.1.0" + } + }, "node_modules/fastq": { "version": "1.20.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", @@ -2422,6 +2552,12 @@ "reusify": "^1.0.4" } }, + "node_modules/fflate": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz", + "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==", + "license": "MIT" + }, "node_modules/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", @@ -2510,6 +2646,25 @@ "node": ">= 0.4" } }, + "node_modules/html2canvas": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz", + "integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==", + "license": "MIT", + "dependencies": { + "css-line-break": "^2.1.0", + "text-segmentation": "^1.0.3" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/iobuffer": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/iobuffer/-/iobuffer-5.4.0.tgz", + "integrity": "sha512-DRebOWuqDvxunfkNJAlc3IzWIPD5xVxwUNbHr7xKB8E6aLJxIPfNX3CoMJghcFjpv6RWQsrcJbghtEwSPoJqMA==", + "license": "MIT" + }, "node_modules/is-binary-path": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", @@ -2615,6 +2770,23 @@ "node": ">=6" } }, + "node_modules/jspdf": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/jspdf/-/jspdf-4.2.0.tgz", + "integrity": "sha512-hR/hnRevAXXlrjeqU5oahOE+Ln9ORJUB5brLHHqH67A+RBQZuFr5GkbI9XQI8OUFSEezKegsi45QRpc4bGj75Q==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.6", + "fast-png": "^6.2.0", + "fflate": "^0.8.1" + }, + "optionalDependencies": { + "canvg": "^3.0.11", + "core-js": "^3.6.0", + "dompurify": "^3.3.1", + "html2canvas": "^1.0.0-rc.5" + } + }, "node_modules/lilconfig": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", @@ -2794,6 +2966,12 @@ "integrity": "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==", "license": "MIT" }, + "node_modules/pako": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/pako/-/pako-2.1.0.tgz", + "integrity": "sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==", + "license": "(MIT AND Zlib)" + }, "node_modules/path-parse": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", @@ -2801,6 +2979,13 @@ "dev": true, "license": "MIT" }, + "node_modules/performance-now": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", + "integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==", + "license": "MIT", + "optional": true + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -3243,6 +3428,16 @@ ], "license": "MIT" }, + "node_modules/raf": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz", + "integrity": "sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==", + "license": "MIT", + "optional": true, + "dependencies": { + "performance-now": "^2.1.0" + } + }, "node_modules/react": { "version": "19.2.4", "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", @@ -3297,6 +3492,13 @@ "node": ">=8.10.0" } }, + "node_modules/regenerator-runtime": { + "version": "0.13.11", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", + "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==", + "license": "MIT", + "optional": true + }, "node_modules/resolve": { "version": "1.22.11", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", @@ -3329,6 +3531,16 @@ "node": ">=0.10.0" } }, + "node_modules/rgbcolor": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/rgbcolor/-/rgbcolor-1.0.1.tgz", + "integrity": "sha512-9aZLIrhRaD97sgVhtJOW6ckOEh6/GnvQtdVNfdZ6s67+3/XwLS9lBcQYzEEhYVeUowN7pRzMLsyGhK2i/xvWbw==", + "license": "MIT OR SEE LICENSE IN FEEL-FREE.md", + "optional": true, + "engines": { + "node": ">= 0.8.15" + } + }, "node_modules/rollup": { "version": "4.59.0", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", @@ -3430,6 +3642,16 @@ "node": ">=0.10.0" } }, + "node_modules/stackblur-canvas": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/stackblur-canvas/-/stackblur-canvas-2.7.0.tgz", + "integrity": "sha512-yf7OENo23AGJhBriGx0QivY5JP6Y1HbrrDI6WLt6C5auYZXlQrheoY8hD4ibekFKz1HOfE48Ww8kMWMnJD/zcQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.1.14" + } + }, "node_modules/sucrase": { "version": "3.35.1", "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", @@ -3466,6 +3688,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/svg-pathdata": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/svg-pathdata/-/svg-pathdata-6.0.3.tgz", + "integrity": "sha512-qsjeeq5YjBZ5eMdFuUa4ZosMLxgr5RZ+F+Y1OrDhuOCEInRMA3x74XdBtggJcj9kOeInz0WE+LgCPDkZFlBYJw==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/tailwindcss": { "version": "3.4.19", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", @@ -3518,6 +3750,15 @@ "node": ">=4" } }, + "node_modules/text-segmentation": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz", + "integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==", + "license": "MIT", + "dependencies": { + "utrie": "^1.0.2" + } + }, "node_modules/thenify": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", @@ -3694,6 +3935,15 @@ "dev": true, "license": "MIT" }, + "node_modules/utrie": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz", + "integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==", + "license": "MIT", + "dependencies": { + "base64-arraybuffer": "^1.0.2" + } + }, "node_modules/vite": { "version": "7.3.1", "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", diff --git a/package.json b/package.json index 8061eaa..67160fb 100644 --- a/package.json +++ b/package.json @@ -11,12 +11,16 @@ }, "dependencies": { "@tauri-apps/api": "^2", + "@tauri-apps/plugin-dialog": "^2.6.0", + "@tauri-apps/plugin-http": "^2.5.7", "@tauri-apps/plugin-opener": "^2", "@tiptap/extension-strike": "^2.27.2", "@tiptap/extension-underline": "^2.27.2", "@tiptap/pm": "^2.27.2", "@tiptap/react": "^2.27.2", "@tiptap/starter-kit": "^2.27.2", + "html2canvas": "^1.4.1", + "jspdf": "^4.2.0", "marked": "^17.0.4", "react": "^19.1.0", "react-dom": "^19.1.0", @@ -27,6 +31,7 @@ "@tauri-apps/cli": "^2", "@types/react": "^19.1.8", "@types/react-dom": "^19.1.6", + "@types/turndown": "^5.0.6", "@vitejs/plugin-react": "^4.6.0", "autoprefixer": "^10.4.27", "postcss": "^8.5.8", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 5e72991..c621288 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -22,4 +22,6 @@ tauri = { version = "2", features = [] } tauri-plugin-opener = "2" serde = { version = "1", features = ["derive"] } serde_json = "1" +tauri-plugin-dialog = "2.6.0" +tauri-plugin-http = "2.5.7" diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json index 4cdbf49..3c4e824 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -5,6 +5,14 @@ "windows": ["main"], "permissions": [ "core:default", - "opener:default" + "opener:default", + "http:default", + { + "identifier": "http:default", + "allow": [ + { "url": "https://*" }, + { "url": "http://*" } + ] + } ] } diff --git a/src-tauri/icons/128x128.png b/src-tauri/icons/128x128.png index 6be5e50..c4982f6 100644 Binary files a/src-tauri/icons/128x128.png and b/src-tauri/icons/128x128.png differ diff --git a/src-tauri/icons/128x128@2x.png b/src-tauri/icons/128x128@2x.png index e81bece..dc8f867 100644 Binary files a/src-tauri/icons/128x128@2x.png and b/src-tauri/icons/128x128@2x.png differ diff --git a/src-tauri/icons/32x32.png b/src-tauri/icons/32x32.png index a437dd5..0427d2c 100644 Binary files a/src-tauri/icons/32x32.png and b/src-tauri/icons/32x32.png differ diff --git a/src-tauri/icons/Square107x107Logo.png b/src-tauri/icons/Square107x107Logo.png index 0ca4f27..bc5a548 100644 Binary files a/src-tauri/icons/Square107x107Logo.png and b/src-tauri/icons/Square107x107Logo.png differ diff --git a/src-tauri/icons/Square142x142Logo.png b/src-tauri/icons/Square142x142Logo.png index b81f820..fc31ddb 100644 Binary files a/src-tauri/icons/Square142x142Logo.png and b/src-tauri/icons/Square142x142Logo.png differ diff --git a/src-tauri/icons/Square150x150Logo.png b/src-tauri/icons/Square150x150Logo.png index 624c7bf..47fd167 100644 Binary files a/src-tauri/icons/Square150x150Logo.png and b/src-tauri/icons/Square150x150Logo.png differ diff --git a/src-tauri/icons/Square284x284Logo.png b/src-tauri/icons/Square284x284Logo.png index c021d2b..c4a09e6 100644 Binary files a/src-tauri/icons/Square284x284Logo.png and b/src-tauri/icons/Square284x284Logo.png differ diff --git a/src-tauri/icons/Square30x30Logo.png b/src-tauri/icons/Square30x30Logo.png index 6219700..424a1df 100644 Binary files a/src-tauri/icons/Square30x30Logo.png and b/src-tauri/icons/Square30x30Logo.png differ diff --git a/src-tauri/icons/Square310x310Logo.png b/src-tauri/icons/Square310x310Logo.png index f9bc048..4845d39 100644 Binary files a/src-tauri/icons/Square310x310Logo.png and b/src-tauri/icons/Square310x310Logo.png differ diff --git a/src-tauri/icons/Square44x44Logo.png b/src-tauri/icons/Square44x44Logo.png index d5fbfb2..ab5d313 100644 Binary files a/src-tauri/icons/Square44x44Logo.png and b/src-tauri/icons/Square44x44Logo.png differ diff --git a/src-tauri/icons/Square71x71Logo.png b/src-tauri/icons/Square71x71Logo.png index 63440d7..e1d4cb6 100644 Binary files a/src-tauri/icons/Square71x71Logo.png and b/src-tauri/icons/Square71x71Logo.png differ diff --git a/src-tauri/icons/Square89x89Logo.png b/src-tauri/icons/Square89x89Logo.png index f3f705a..cec1abf 100644 Binary files a/src-tauri/icons/Square89x89Logo.png and b/src-tauri/icons/Square89x89Logo.png differ diff --git a/src-tauri/icons/StoreLogo.png b/src-tauri/icons/StoreLogo.png index 4556388..96a1945 100644 Binary files a/src-tauri/icons/StoreLogo.png and b/src-tauri/icons/StoreLogo.png differ diff --git a/src-tauri/icons/icon.icns b/src-tauri/icons/icon.icns index 12a5bce..96fc75c 100644 Binary files a/src-tauri/icons/icon.icns and b/src-tauri/icons/icon.icns differ diff --git a/src-tauri/icons/icon.ico b/src-tauri/icons/icon.ico index b3636e4..c5394a1 100644 Binary files a/src-tauri/icons/icon.ico and b/src-tauri/icons/icon.ico differ diff --git a/src-tauri/icons/icon.png b/src-tauri/icons/icon.png index e1cd261..c4982f6 100644 Binary files a/src-tauri/icons/icon.png and b/src-tauri/icons/icon.png differ diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 4a277ef..c3f4f22 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -8,6 +8,8 @@ fn greet(name: &str) -> String { pub fn run() { tauri::Builder::default() .plugin(tauri_plugin_opener::init()) + .plugin(tauri_plugin_dialog::init()) + .plugin(tauri_plugin_http::init()) .invoke_handler(tauri::generate_handler![greet]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 141eac0..3c7d1d9 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,8 +1,8 @@ { "$schema": "https://schema.tauri.app/config/2", - "productName": "nextcloud-notes-tauri", + "productName": "Nextcloud Notes", "version": "0.1.0", - "identifier": "com.davidrelich.nextcloud-notes-tauri", + "identifier": "com.davidrelich.nextcloud-notes", "build": { "beforeDevCommand": "npm run dev", "devUrl": "http://localhost:1420", diff --git a/src/App.tsx b/src/App.tsx index 3c31f3b..772f403 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -2,6 +2,7 @@ import { useState, useEffect } from 'react'; import { LoginView } from './components/LoginView'; import { NotesList } from './components/NotesList'; import { NoteEditor } from './components/NoteEditor'; +import { CategoriesSidebar } from './components/CategoriesSidebar'; import { NextcloudAPI } from './api/nextcloud'; import { Note } from './types'; @@ -12,12 +13,44 @@ function App() { const [selectedNoteId, setSelectedNoteId] = useState(null); const [searchText, setSearchText] = useState(''); const [showFavoritesOnly, setShowFavoritesOnly] = useState(false); - const [fontSize] = useState(14); + const [selectedCategory, setSelectedCategory] = useState(''); + const [manualCategories, setManualCategories] = useState([]); + const [isCategoriesCollapsed, setIsCategoriesCollapsed] = useState(false); + const [isFocusMode, setIsFocusMode] = useState(false); + const [username, setUsername] = useState(''); + const [theme, setTheme] = useState<'light' | 'dark' | 'system'>('system'); + const [effectiveTheme, setEffectiveTheme] = useState<'light' | 'dark'>('light'); + const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false); + const [editorFont, setEditorFont] = useState('Source Code Pro'); + const [editorFontSize, setEditorFontSize] = useState(14); + const [previewFont, setPreviewFont] = useState('Merriweather'); + const [previewFontSize, setPreviewFontSize] = useState(16); useEffect(() => { const savedServer = localStorage.getItem('serverURL'); const savedUsername = localStorage.getItem('username'); const savedPassword = localStorage.getItem('password'); + const savedTheme = localStorage.getItem('theme') as 'light' | 'dark' | 'system' | null; + const savedEditorFont = localStorage.getItem('editorFont'); + const savedPreviewFont = localStorage.getItem('previewFont'); + + if (savedTheme) { + setTheme(savedTheme); + } + if (savedEditorFont) { + setEditorFont(savedEditorFont); + } + if (savedPreviewFont) { + setPreviewFont(savedPreviewFont); + } + const savedEditorFontSize = localStorage.getItem('editorFontSize'); + const savedPreviewFontSize = localStorage.getItem('previewFontSize'); + if (savedEditorFontSize) { + setEditorFontSize(parseInt(savedEditorFontSize, 10)); + } + if (savedPreviewFontSize) { + setPreviewFontSize(parseInt(savedPreviewFontSize, 10)); + } if (savedServer && savedUsername && savedPassword) { const apiInstance = new NextcloudAPI({ @@ -26,10 +59,35 @@ function App() { password: savedPassword, }); setApi(apiInstance); + setUsername(savedUsername); setIsLoggedIn(true); } }, []); + useEffect(() => { + const updateEffectiveTheme = () => { + if (theme === 'system') { + const isDark = window.matchMedia('(prefers-color-scheme: dark)').matches; + setEffectiveTheme(isDark ? 'dark' : 'light'); + } else { + setEffectiveTheme(theme); + } + }; + + updateEffectiveTheme(); + + if (theme === 'system') { + const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)'); + const handler = () => updateEffectiveTheme(); + mediaQuery.addEventListener('change', handler); + return () => mediaQuery.removeEventListener('change', handler); + } + }, [theme]); + + useEffect(() => { + document.documentElement.classList.toggle('dark', effectiveTheme === 'dark'); + }, [effectiveTheme]); + useEffect(() => { if (api && isLoggedIn) { syncNotes(); @@ -58,9 +116,46 @@ function App() { const apiInstance = new NextcloudAPI({ serverURL, username, password }); setApi(apiInstance); + setUsername(username); setIsLoggedIn(true); }; + const handleLogout = () => { + localStorage.removeItem('serverURL'); + localStorage.removeItem('username'); + localStorage.removeItem('password'); + setApi(null); + setUsername(''); + setNotes([]); + setSelectedNoteId(null); + setIsLoggedIn(false); + }; + + const handleThemeChange = (newTheme: 'light' | 'dark' | 'system') => { + setTheme(newTheme); + localStorage.setItem('theme', newTheme); + }; + + const handleEditorFontChange = (font: string) => { + setEditorFont(font); + localStorage.setItem('editorFont', font); + }; + + const handlePreviewFontChange = (font: string) => { + setPreviewFont(font); + localStorage.setItem('previewFont', font); + }; + + const handleEditorFontSizeChange = (size: number) => { + setEditorFontSize(size); + localStorage.setItem('editorFontSize', size.toString()); + }; + + const handlePreviewFontSizeChange = (size: number) => { + setPreviewFontSize(size); + localStorage.setItem('previewFontSize', size.toString()); + }; + const handleCreateNote = async () => { if (!api) return; try { @@ -73,7 +168,7 @@ function App() { hour12: false, }).replace(/[/:]/g, '-').replace(', ', ' '); - const note = await api.createNote(`New Note ${timestamp}`, '', ''); + const note = await api.createNote(`New Note ${timestamp}`, '', selectedCategory); setNotes([note, ...notes]); setSelectedNoteId(note.id); } catch (error) { @@ -81,6 +176,12 @@ function App() { } }; + const handleCreateCategory = (name: string) => { + if (!manualCategories.includes(name)) { + setManualCategories([...manualCategories, name]); + } + }; + const handleUpdateNote = async (updatedNote: Note) => { if (!api) return; try { @@ -98,7 +199,6 @@ function App() { const handleDeleteNote = async (note: Note) => { if (!api) return; - if (!confirm(`Delete "${note.title}"?`)) return; try { await api.deleteNote(note.id); @@ -111,7 +211,11 @@ function App() { } }; + const categoriesFromNotes = Array.from(new Set(notes.map(n => n.category).filter(c => c))); + const categories = Array.from(new Set([...categoriesFromNotes, ...manualCategories])).sort(); + const filteredNotes = notes.filter(note => { + if (selectedCategory && note.category !== selectedCategory) return false; if (showFavoritesOnly && !note.favorite) return false; if (searchText) { const search = searchText.toLowerCase(); @@ -129,22 +233,55 @@ function App() { return (
- setShowFavoritesOnly(!showFavoritesOnly)} - /> + {!isFocusMode && ( + <> + setIsCategoriesCollapsed(!isCategoriesCollapsed)} + username={username} + onLogout={handleLogout} + theme={theme} + onThemeChange={handleThemeChange} + editorFont={editorFont} + onEditorFontChange={handleEditorFontChange} + editorFontSize={editorFontSize} + onEditorFontSizeChange={handleEditorFontSizeChange} + previewFont={previewFont} + onPreviewFontChange={handlePreviewFontChange} + previewFontSize={previewFontSize} + onPreviewFontSizeChange={handlePreviewFontSizeChange} + /> + setShowFavoritesOnly(!showFavoritesOnly)} + hasUnsavedChanges={hasUnsavedChanges} + /> + + )} setIsFocusMode(!isFocusMode)} + editorFont={editorFont} + editorFontSize={editorFontSize} + previewFont={previewFont} + previewFontSize={previewFontSize} + api={api} />
); diff --git a/src/api/nextcloud.ts b/src/api/nextcloud.ts index 253347c..d60cf1a 100644 --- a/src/api/nextcloud.ts +++ b/src/api/nextcloud.ts @@ -1,13 +1,18 @@ +import { fetch as tauriFetch } from '@tauri-apps/plugin-http'; import { Note, APIConfig } from '../types'; export class NextcloudAPI { private baseURL: string; + private serverURL: string; private authHeader: string; + private username: string; constructor(config: APIConfig) { const url = config.serverURL.replace(/\/$/, ''); + this.serverURL = url; this.baseURL = `${url}/index.php/apps/notes/api/v1`; this.authHeader = 'Basic ' + btoa(`${config.username}:${config.password}`); + this.username = config.username; } private async request(path: string, options: RequestInit = {}): Promise { @@ -55,4 +60,96 @@ export class NextcloudAPI { async deleteNote(id: number): Promise { await this.request(`/notes/${id}`, { method: 'DELETE' }); } + + async fetchAttachment(_noteId: number, path: string, noteCategory?: string): Promise { + // Build WebDAV path: /remote.php/dav/files/{username}/Notes/{category}/.attachments.{noteId}/{filename} + // The path from markdown is like: .attachments.38479/Screenshot.png + // We need to construct the full WebDAV URL + + let webdavPath = `/remote.php/dav/files/${this.username}/Notes`; + + // Add category subfolder if present + if (noteCategory) { + webdavPath += `/${noteCategory}`; + } + + // Add the attachment path (already includes .attachments.{id}/filename) + webdavPath += `/${path}`; + + const url = `${this.serverURL}${webdavPath}`; + console.log('Fetching attachment via WebDAV:', url); + + const response = await tauriFetch(url, { + headers: { + 'Authorization': this.authHeader, + }, + }); + + if (!response.ok) { + throw new Error(`Failed to fetch attachment: ${response.status}`); + } + + const blob = await response.blob(); + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onloadend = () => resolve(reader.result as string); + reader.onerror = reject; + reader.readAsDataURL(blob); + }); + } + + getServerURL(): string { + return this.serverURL; + } + + async uploadAttachment(noteId: number, file: File, noteCategory?: string): Promise { + // Create .attachments.{noteId} directory path and upload file via WebDAV PUT + // Returns the relative path to insert into markdown + + let webdavPath = `/remote.php/dav/files/${this.username}/Notes`; + + if (noteCategory) { + webdavPath += `/${noteCategory}`; + } + + const attachmentDir = `.attachments.${noteId}`; + const fileName = file.name.replace(/[^a-zA-Z0-9._-]/g, '_'); // Sanitize filename + const fullPath = `${webdavPath}/${attachmentDir}/${fileName}`; + + const url = `${this.serverURL}${fullPath}`; + console.log('Uploading attachment via WebDAV:', url); + + // First, try to create the attachments directory (MKCOL) + // This may fail if it already exists, which is fine + try { + await tauriFetch(`${this.serverURL}${webdavPath}/${attachmentDir}`, { + method: 'MKCOL', + headers: { + 'Authorization': this.authHeader, + }, + }); + } catch (e) { + // Directory might already exist, continue + } + + // Read file as ArrayBuffer + const arrayBuffer = await file.arrayBuffer(); + + // Upload the file via PUT + const response = await tauriFetch(url, { + method: 'PUT', + headers: { + 'Authorization': this.authHeader, + 'Content-Type': file.type || 'application/octet-stream', + }, + body: arrayBuffer, + }); + + if (!response.ok && response.status !== 201 && response.status !== 204) { + throw new Error(`Failed to upload attachment: ${response.status}`); + } + + // Return the relative path for markdown + return `${attachmentDir}/${fileName}`; + } } diff --git a/src/components/CategoriesSidebar.tsx b/src/components/CategoriesSidebar.tsx new file mode 100644 index 0000000..9ef03b7 --- /dev/null +++ b/src/components/CategoriesSidebar.tsx @@ -0,0 +1,327 @@ +import { useState, useEffect, useRef } from 'react'; + +const EDITOR_FONTS = [ + { name: 'Source Code Pro', value: 'Source Code Pro' }, + { name: 'Roboto Mono', value: 'Roboto Mono' }, + { name: 'Inconsolata', value: 'Inconsolata' }, + { name: 'System Mono', value: 'ui-monospace, monospace' }, +]; + +const PREVIEW_FONTS = [ + { name: 'Merriweather', value: 'Merriweather' }, + { name: 'Crimson Pro', value: 'Crimson Pro' }, + { name: 'Roboto Serif', value: 'Roboto Serif' }, + { name: 'Average', value: 'Average' }, + { name: 'System Serif', value: 'ui-serif, Georgia, serif' }, +]; + +interface CategoriesSidebarProps { + categories: string[]; + selectedCategory: string; + onSelectCategory: (category: string) => void; + onCreateCategory: (name: string) => void; + isCollapsed: boolean; + onToggleCollapse: () => void; + username: string; + onLogout: () => void; + theme: 'light' | 'dark' | 'system'; + onThemeChange: (theme: 'light' | 'dark' | 'system') => void; + editorFont: string; + onEditorFontChange: (font: string) => void; + editorFontSize: number; + onEditorFontSizeChange: (size: number) => void; + previewFont: string; + onPreviewFontChange: (font: string) => void; + previewFontSize: number; + onPreviewFontSizeChange: (size: number) => void; +} + +export function CategoriesSidebar({ + categories, + selectedCategory, + onSelectCategory, + onCreateCategory, + isCollapsed, + onToggleCollapse, + username, + onLogout, + theme, + onThemeChange, + editorFont, + onEditorFontChange, + editorFontSize, + onEditorFontSizeChange, + previewFont, + onPreviewFontChange, + previewFontSize, + onPreviewFontSizeChange, +}: CategoriesSidebarProps) { + const [isCreating, setIsCreating] = useState(false); + const [newCategoryName, setNewCategoryName] = useState(''); + const inputRef = useRef(null); + + useEffect(() => { + if (isCreating && inputRef.current) { + inputRef.current.focus(); + } + }, [isCreating]); + + const handleCreateCategory = () => { + if (newCategoryName.trim()) { + onCreateCategory(newCategoryName.trim()); + setNewCategoryName(''); + setIsCreating(false); + } + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + handleCreateCategory(); + } else if (e.key === 'Escape') { + setIsCreating(false); + setNewCategoryName(''); + } + }; + + if (isCollapsed) { + return ( + + ); + } + + return ( +
+
+
+

Categories

+ +
+ + +
+ +
+
+ + + {categories.map((category) => ( + + ))} + + {isCreating && ( +
+ + + + setNewCategoryName(e.target.value)} + onKeyDown={handleKeyDown} + onBlur={() => { + if (newCategoryName.trim()) { + handleCreateCategory(); + } else { + setIsCreating(false); + } + }} + placeholder="Category name..." + className="flex-1 text-sm px-0 py-0 border-none bg-transparent text-gray-900 dark:text-gray-100 focus:ring-0 focus:outline-none" + /> +
+ )} +
+
+ + {/* User Info and Settings */} +
+
+
+
+ {username.charAt(0).toUpperCase()} +
+ {username} +
+ +
+ + {/* Theme Toggle */} +
+ Theme +
+ + + +
+
+ + {/* Font Settings */} +
+ Fonts + + {/* Editor Font */} +
+
+ + + + Editor +
+
+ + +
+
+ + {/* Preview Font */} +
+
+ + + + + Preview +
+
+ + +
+
+
+
+
+ ); +} diff --git a/src/components/CategorySelector.tsx b/src/components/CategorySelector.tsx new file mode 100644 index 0000000..c46e770 --- /dev/null +++ b/src/components/CategorySelector.tsx @@ -0,0 +1,116 @@ +import { useState, useEffect, useRef } from 'react'; + +interface CategorySelectorProps { + categories: string[]; + selectedCategory: string; + onSelectCategory: (category: string) => void; + onCreateCategory: (name: string) => void; +} + +export function CategorySelector({ + categories, + selectedCategory, + onSelectCategory, + onCreateCategory +}: CategorySelectorProps) { + const [isCreating, setIsCreating] = useState(false); + const [newCategoryName, setNewCategoryName] = useState(''); + const inputRef = useRef(null); + + useEffect(() => { + if (isCreating && inputRef.current) { + inputRef.current.focus(); + } + }, [isCreating]); + + const handleCreateCategory = () => { + if (newCategoryName.trim()) { + onCreateCategory(newCategoryName.trim()); + setNewCategoryName(''); + setIsCreating(false); + } + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + handleCreateCategory(); + } else if (e.key === 'Escape') { + setIsCreating(false); + setNewCategoryName(''); + } + }; + + return ( +
+
+

+ Categories +

+ +
+ +
+ + + {categories.map((category) => ( + + ))} + + {isCreating && ( +
+ + + + setNewCategoryName(e.target.value)} + onKeyDown={handleKeyDown} + onBlur={() => { + if (!newCategoryName.trim()) { + setIsCreating(false); + } + }} + placeholder="Category name..." + className="flex-1 text-sm px-2 py-1 border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-transparent" + /> +
+ )} +
+
+ ); +} diff --git a/src/components/FloatingToolbar.tsx b/src/components/FloatingToolbar.tsx new file mode 100644 index 0000000..2c7a4db --- /dev/null +++ b/src/components/FloatingToolbar.tsx @@ -0,0 +1,222 @@ +import { useEffect, useState, useRef, RefObject } from 'react'; + +type FormatType = 'bold' | 'italic' | 'strikethrough' | 'code' | 'codeblock' | 'quote' | 'ul' | 'ol' | 'link' | 'h1' | 'h2' | 'h3'; + +interface FloatingToolbarProps { + onFormat: (format: FormatType) => void; + textareaRef: RefObject; +} + +export function FloatingToolbar({ onFormat, textareaRef }: FloatingToolbarProps) { + const [position, setPosition] = useState<{ top: number; left: number } | null>(null); + const [isVisible, setIsVisible] = useState(false); + const [activeFormats, setActiveFormats] = useState>(new Set()); + const toolbarRef = useRef(null); + + const detectActiveFormats = (text: string, fullContent: string, selectionStart: number): Set => { + const formats = new Set(); + + // Check inline formats + if (/\*\*[^*]+\*\*/.test(text) || /__[^_]+__/.test(text)) formats.add('bold'); + if (/(?\s/.test(currentLine)) formats.add('quote'); + if (/^[-*+]\s/.test(currentLine)) formats.add('ul'); + if (/^\d+\.\s/.test(currentLine)) formats.add('ol'); + if (/\[.+\]\(.+\)/.test(text)) formats.add('link'); + + return formats; + }; + + useEffect(() => { + const textarea = textareaRef.current; + if (!textarea) return; + + const handleSelectionChange = () => { + const textarea = textareaRef.current; + if (!textarea) return; + + const start = textarea.selectionStart; + const end = textarea.selectionEnd; + + if (start === end) { + setIsVisible(false); + return; + } + + // Get textarea position and calculate approximate selection position + const textareaRect = textarea.getBoundingClientRect(); + const computedStyle = window.getComputedStyle(textarea); + const lineHeight = parseFloat(computedStyle.lineHeight) || 24; + const paddingTop = parseFloat(computedStyle.paddingTop) || 0; + const paddingLeft = parseFloat(computedStyle.paddingLeft) || 0; + const fontSize = parseFloat(computedStyle.fontSize) || 16; + + // Get text before selection to calculate position + const textBeforeSelection = textarea.value.substring(0, start); + const lines = textBeforeSelection.split('\n'); + const currentLineIndex = lines.length - 1; + const currentLineText = lines[currentLineIndex]; + + // Approximate character width (monospace assumption) + const charWidth = fontSize * 0.6; + + // Calculate position + const scrollTop = textarea.scrollTop; + const top = textareaRect.top + paddingTop + (currentLineIndex * lineHeight) - scrollTop - 56; + const left = textareaRect.left + paddingLeft + (currentLineText.length * charWidth); + + const toolbarWidth = 320; + let adjustedLeft = Math.max(10, Math.min(left - toolbarWidth / 2, window.innerWidth - toolbarWidth - 10)); + let adjustedTop = top; + + if (adjustedTop < 10) { + adjustedTop = textareaRect.top + paddingTop + ((currentLineIndex + 1) * lineHeight) - scrollTop + 8; + } + + setPosition({ top: adjustedTop, left: adjustedLeft }); + setIsVisible(true); + + // Detect active formats + const selectedText = textarea.value.substring(start, end); + const formats = detectActiveFormats(selectedText, textarea.value, start); + setActiveFormats(formats); + }; + + const handleMouseUp = () => { + setTimeout(handleSelectionChange, 10); + }; + + const handleKeyUp = (e: KeyboardEvent) => { + if (e.shiftKey && (e.key === 'ArrowLeft' || e.key === 'ArrowRight' || e.key === 'ArrowUp' || e.key === 'ArrowDown')) { + handleSelectionChange(); + } + }; + + const handleBlur = () => { + // Delay hiding to allow button clicks to register + setTimeout(() => { + if (document.activeElement !== textarea && !toolbarRef.current?.contains(document.activeElement)) { + setIsVisible(false); + } + }, 150); + }; + + textarea.addEventListener('mouseup', handleMouseUp); + textarea.addEventListener('keyup', handleKeyUp); + textarea.addEventListener('blur', handleBlur); + textarea.addEventListener('select', handleSelectionChange); + + return () => { + textarea.removeEventListener('mouseup', handleMouseUp); + textarea.removeEventListener('keyup', handleKeyUp); + textarea.removeEventListener('blur', handleBlur); + textarea.removeEventListener('select', handleSelectionChange); + }; + }, [textareaRef]); + + if (!isVisible || !position) return null; + + const buttonClass = (format: FormatType) => `p-2 rounded transition-colors ${ + activeFormats.has(format) + ? 'bg-blue-500 text-white' + : 'hover:bg-gray-700 dark:hover:bg-gray-600 text-white' + }`; + + const headingButtonClass = (format: FormatType) => `px-2 py-1 rounded font-bold text-xs transition-colors ${ + activeFormats.has(format) + ? 'bg-blue-500 text-white' + : 'hover:bg-gray-700 dark:hover:bg-gray-600 text-white' + }`; + + return ( +
+ {/* Text Formatting */} + + + + + + +
+ + {/* Code */} + + + + +
+ + {/* Quote & Lists */} + + + + + + +
+ + {/* Link */} + + +
+ + {/* Headings */} + + + +
+ ); +} diff --git a/src/components/InsertToolbar.tsx b/src/components/InsertToolbar.tsx new file mode 100644 index 0000000..7a29145 --- /dev/null +++ b/src/components/InsertToolbar.tsx @@ -0,0 +1,244 @@ +import { useEffect, useState, useRef, RefObject } from 'react'; + +interface InsertToolbarProps { + textareaRef: RefObject; + onInsertLink: (text: string, url: string) => void; + onInsertFile: () => void; + isUploading?: boolean; +} + +interface LinkModalState { + isOpen: boolean; + text: string; + url: string; +} + +export function InsertToolbar({ textareaRef, onInsertLink, onInsertFile, isUploading }: InsertToolbarProps) { + const [position, setPosition] = useState<{ top: number; left: number } | null>(null); + const [isVisible, setIsVisible] = useState(false); + const [linkModal, setLinkModal] = useState({ isOpen: false, text: '', url: '' }); + const toolbarRef = useRef(null); + const modalRef = useRef(null); + const urlInputRef = useRef(null); + + useEffect(() => { + const textarea = textareaRef.current; + if (!textarea) return; + + const updatePosition = () => { + const textarea = textareaRef.current; + if (!textarea || linkModal.isOpen) return; + + const start = textarea.selectionStart; + const end = textarea.selectionEnd; + + // Only show when cursor is placed (no selection) + if (start !== end) { + setIsVisible(false); + return; + } + + const textareaRect = textarea.getBoundingClientRect(); + const computedStyle = window.getComputedStyle(textarea); + const lineHeight = parseFloat(computedStyle.lineHeight) || 24; + const paddingTop = parseFloat(computedStyle.paddingTop) || 0; + const paddingLeft = parseFloat(computedStyle.paddingLeft) || 0; + const fontSize = parseFloat(computedStyle.fontSize) || 16; + + const textBeforeCursor = textarea.value.substring(0, start); + const lines = textBeforeCursor.split('\n'); + const currentLineIndex = lines.length - 1; + const currentLineText = lines[currentLineIndex]; + + const charWidth = fontSize * 0.6; + const scrollTop = textarea.scrollTop; + + // Position to the right of cursor + const top = textareaRect.top + paddingTop + (currentLineIndex * lineHeight) - scrollTop + lineHeight / 2; + const left = textareaRect.left + paddingLeft + (currentLineText.length * charWidth) + 20; + + // Keep toolbar within viewport + const toolbarWidth = 100; + const adjustedLeft = Math.min(left, window.innerWidth - toolbarWidth - 20); + let adjustedTop = top - 16; // Center vertically with cursor line + + if (adjustedTop < 10) { + adjustedTop = 10; + } + + setPosition({ top: adjustedTop, left: adjustedLeft }); + setIsVisible(true); + }; + + const handleClick = () => { + setTimeout(updatePosition, 10); + }; + + const handleKeyUp = (e: KeyboardEvent) => { + // Update on arrow keys or other navigation + if (['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'Home', 'End'].includes(e.key)) { + updatePosition(); + } + }; + + const handleInput = () => { + // Hide briefly during typing, show after a pause + setIsVisible(false); + }; + + const handleBlur = () => { + // Don't hide if clicking on toolbar or modal + setTimeout(() => { + const activeElement = document.activeElement; + if ( + activeElement !== textarea && + !toolbarRef.current?.contains(activeElement) && + !modalRef.current?.contains(activeElement) + ) { + setIsVisible(false); + } + }, 150); + }; + + textarea.addEventListener('click', handleClick); + textarea.addEventListener('keyup', handleKeyUp); + textarea.addEventListener('input', handleInput); + textarea.addEventListener('blur', handleBlur); + + return () => { + textarea.removeEventListener('click', handleClick); + textarea.removeEventListener('keyup', handleKeyUp); + textarea.removeEventListener('input', handleInput); + textarea.removeEventListener('blur', handleBlur); + }; + }, [textareaRef, linkModal.isOpen]); + + const handleLinkClick = () => { + setLinkModal({ isOpen: true, text: '', url: '' }); + setTimeout(() => urlInputRef.current?.focus(), 50); + }; + + const handleLinkSubmit = () => { + if (linkModal.url) { + onInsertLink(linkModal.text || linkModal.url, linkModal.url); + setLinkModal({ isOpen: false, text: '', url: '' }); + setIsVisible(false); + textareaRef.current?.focus(); + } + }; + + const handleLinkCancel = () => { + setLinkModal({ isOpen: false, text: '', url: '' }); + textareaRef.current?.focus(); + }; + + const handleFileClick = () => { + onInsertFile(); + setIsVisible(false); + }; + + if (!isVisible || !position) return null; + + // Link Modal + if (linkModal.isOpen) { + return ( +
+
Insert Link
+ +
+
+ + setLinkModal({ ...linkModal, url: e.target.value })} + onKeyDown={(e) => { + if (e.key === 'Enter') handleLinkSubmit(); + if (e.key === 'Escape') handleLinkCancel(); + }} + placeholder="https://example.com" + className="w-full px-3 py-1.5 text-sm rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-transparent" + /> +
+ +
+ + setLinkModal({ ...linkModal, text: e.target.value })} + onKeyDown={(e) => { + if (e.key === 'Enter') handleLinkSubmit(); + if (e.key === 'Escape') handleLinkCancel(); + }} + placeholder="Link text" + className="w-full px-3 py-1.5 text-sm rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-transparent" + /> +
+
+ +
+ + +
+
+ ); + } + + // Insert Toolbar + return ( +
+ + + +
+ ); +} diff --git a/src/components/NoteEditor.tsx b/src/components/NoteEditor.tsx index 0843576..eae5e3c 100644 --- a/src/components/NoteEditor.tsx +++ b/src/components/NoteEditor.tsx @@ -1,98 +1,161 @@ import { useState, useEffect, useRef } from 'react'; -import { useEditor, EditorContent } from '@tiptap/react'; -import StarterKit from '@tiptap/starter-kit'; -import Underline from '@tiptap/extension-underline'; -import Strike from '@tiptap/extension-strike'; -import TurndownService from 'turndown'; import { marked } from 'marked'; +import jsPDF from 'jspdf'; +import { message } from '@tauri-apps/plugin-dialog'; import { Note } from '../types'; +import { NextcloudAPI } from '../api/nextcloud'; +import { FloatingToolbar } from './FloatingToolbar'; +import { InsertToolbar } from './InsertToolbar'; interface NoteEditorProps { note: Note | null; onUpdateNote: (note: Note) => void; - fontSize: number; + onUnsavedChanges?: (hasChanges: boolean) => void; + categories: string[]; + isFocusMode?: boolean; + onToggleFocusMode?: () => void; + editorFont?: string; + editorFontSize?: number; + previewFont?: string; + previewFontSize?: number; + api?: NextcloudAPI | null; } -const turndownService = new TurndownService({ - headingStyle: 'atx', - codeBlockStyle: 'fenced', -}); +const imageCache = new Map(); -export function NoteEditor({ note, onUpdateNote, fontSize }: NoteEditorProps) { + +export function NoteEditor({ note, onUpdateNote, onUnsavedChanges, categories, isFocusMode, onToggleFocusMode, editorFont = 'Source Code Pro', editorFontSize = 14, previewFont = 'Merriweather', previewFontSize = 16, api }: NoteEditorProps) { const [localTitle, setLocalTitle] = useState(''); + const [localContent, setLocalContent] = useState(''); const [localCategory, setLocalCategory] = useState(''); const [localFavorite, setLocalFavorite] = useState(false); const [isSaving, setIsSaving] = useState(false); const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false); const [titleManuallyEdited, setTitleManuallyEdited] = useState(false); + const [isExportingPDF, setIsExportingPDF] = useState(false); + const [isPreviewMode, setIsPreviewMode] = useState(false); + const [processedContent, setProcessedContent] = useState(''); + const [isLoadingImages, setIsLoadingImages] = useState(false); + const [isUploading, setIsUploading] = useState(false); const previousNoteIdRef = useRef(null); - - const editor = useEditor({ - extensions: [ - StarterKit, - Underline, - Strike, - ], - content: '', - editorProps: { - attributes: { - class: 'prose prose-slate max-w-none focus:outline-none p-6', - style: `font-size: ${fontSize}px`, - }, - }, - onUpdate: ({ editor }) => { - setHasUnsavedChanges(true); - - if (!titleManuallyEdited) { - const text = editor.getText(); - const firstLine = text.split('\n')[0].trim(); - if (firstLine) { - setLocalTitle(firstLine.substring(0, 50)); - } - } - }, - }); + const textareaRef = useRef(null); + const fileInputRef = useRef(null); useEffect(() => { - if (previousNoteIdRef.current !== null && previousNoteIdRef.current !== note?.id) { - handleSave(); - } + onUnsavedChanges?.(hasUnsavedChanges); + }, [hasUnsavedChanges, onUnsavedChanges]); + + // Handle Escape key to exit focus mode + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape' && isFocusMode && onToggleFocusMode) { + onToggleFocusMode(); + } + }; - if (note && editor) { - setLocalTitle(note.title); - setLocalCategory(note.category); - setLocalFavorite(note.favorite); - setHasUnsavedChanges(false); - - // Only reset titleManuallyEdited when switching to a different note - // Check if the current title matches the first line of content - const firstLine = note.content.split('\n')[0].replace(/^#+\s*/, '').trim(); - const titleMatchesFirstLine = note.title === firstLine || note.title === firstLine.substring(0, 50); - setTitleManuallyEdited(!titleMatchesFirstLine); - - previousNoteIdRef.current = note.id; - - // Convert markdown to HTML using marked library - const html = marked.parse(note.content || '', { async: false }) as string; - editor.commands.setContent(html); + document.addEventListener('keydown', handleKeyDown); + return () => document.removeEventListener('keydown', handleKeyDown); + }, [isFocusMode, onToggleFocusMode]); + + // Auto-resize textarea when content changes, switching from preview to edit, or font size changes + useEffect(() => { + if (textareaRef.current && !isPreviewMode) { + // Use setTimeout to ensure DOM has updated + setTimeout(() => { + if (textareaRef.current) { + textareaRef.current.style.height = 'auto'; + textareaRef.current.style.height = textareaRef.current.scrollHeight + 'px'; + } + }, 0); } - }, [note?.id, editor]); + }, [localContent, isPreviewMode, editorFontSize]); + + // Process images when entering preview mode or content changes + useEffect(() => { + if (!isPreviewMode || !note || !api) { + setProcessedContent(localContent); + return; + } + + const processImages = async () => { + setIsLoadingImages(true); + + // Find all image references in markdown: ![alt](path) + const imageRegex = /!\[([^\]]*)\]\(([^)]+)\)/g; + let content = localContent; + const matches = [...localContent.matchAll(imageRegex)]; + + for (const match of matches) { + const [fullMatch, alt, imagePath] = match; + + // Skip external URLs (http/https) + if (imagePath.startsWith('http://') || imagePath.startsWith('https://')) { + continue; + } + + // Check cache first + const cacheKey = `${note.id}:${imagePath}`; + if (imageCache.has(cacheKey)) { + const dataUrl = imageCache.get(cacheKey)!; + content = content.replace(fullMatch, `![${alt}](${dataUrl})`); + continue; + } + + try { + const dataUrl = await api.fetchAttachment(note.id, imagePath, note.category); + imageCache.set(cacheKey, dataUrl); + content = content.replace(fullMatch, `![${alt}](${dataUrl})`); + } catch (error) { + console.error(`Failed to fetch attachment: ${imagePath}`, error); + // Keep original path, image will show as broken + } + } + + setProcessedContent(content); + setIsLoadingImages(false); + }; + + processImages(); + }, [isPreviewMode, localContent, note?.id, api]); + + useEffect(() => { + const loadNewNote = () => { + if (note) { + setLocalTitle(note.title); + setLocalContent(note.content); + setLocalCategory(note.category || ''); + setLocalFavorite(note.favorite); + setHasUnsavedChanges(false); + + const firstLine = note.content.split('\n')[0].replace(/^#+\s*/, '').trim(); + const titleMatchesFirstLine = note.title === firstLine || note.title === firstLine.substring(0, 50); + setTitleManuallyEdited(!titleMatchesFirstLine); + + previousNoteIdRef.current = note.id; + } + }; + + if (previousNoteIdRef.current !== null && previousNoteIdRef.current !== note?.id) { + if (hasUnsavedChanges) { + handleSave(); + } + setTimeout(loadNewNote, 100); + } else { + loadNewNote(); + } + }, [note?.id]); const handleSave = () => { - if (!note || !hasUnsavedChanges || !editor) return; + if (!note || !hasUnsavedChanges) return; - // Convert HTML to markdown - const html = editor.getHTML(); - const markdown = turndownService.turndown(html); - - console.log('Saving note content length:', markdown.length); - console.log('Last 50 chars:', markdown.slice(-50)); + console.log('Saving note content length:', localContent.length); + console.log('Last 50 chars:', localContent.slice(-50)); setIsSaving(true); setHasUnsavedChanges(false); onUpdateNote({ ...note, title: localTitle, - content: markdown, + content: localContent, category: localCategory, favorite: localFavorite, }); @@ -105,9 +168,116 @@ export function NoteEditor({ note, onUpdateNote, fontSize }: NoteEditorProps) { setHasUnsavedChanges(true); }; - const handleCategoryChange = (value: string) => { - setLocalCategory(value); + const handleContentChange = (value: string) => { + setLocalContent(value); setHasUnsavedChanges(true); + + if (!titleManuallyEdited) { + const firstLine = value.split('\n')[0].replace(/^#+\s*/, '').trim(); + if (firstLine) { + setLocalTitle(firstLine.substring(0, 50)); + } + } + }; + + const handleDiscard = () => { + if (!note) return; + + setLocalTitle(note.title); + setLocalContent(note.content); + setLocalCategory(note.category || ''); + setLocalFavorite(note.favorite); + setHasUnsavedChanges(false); + + const firstLine = note.content.split('\n')[0].replace(/^#+\s*/, '').trim(); + const titleMatchesFirstLine = note.title === firstLine || note.title === firstLine.substring(0, 50); + setTitleManuallyEdited(!titleMatchesFirstLine); + }; + + const handleExportPDF = async () => { + if (!note) return; + + setIsExportingPDF(true); + + try { + const container = document.createElement('div'); + container.style.fontFamily = `"${previewFont}", Georgia, serif`; + container.style.fontSize = '12px'; + container.style.lineHeight = '1.6'; + container.style.color = '#000000'; + + const titleElement = document.createElement('h1'); + titleElement.textContent = localTitle || 'Untitled'; + titleElement.style.marginTop = '0'; + titleElement.style.marginBottom = '20px'; + titleElement.style.fontSize = '24px'; + titleElement.style.fontWeight = 'bold'; + titleElement.style.color = '#000000'; + titleElement.style.textAlign = 'center'; + titleElement.style.fontFamily = `"${previewFont}", Georgia, serif`; + container.appendChild(titleElement); + + const contentElement = document.createElement('div'); + const html = marked.parse(localContent || '', { async: false }) as string; + contentElement.innerHTML = html; + contentElement.style.fontSize = '12px'; + contentElement.style.lineHeight = '1.6'; + contentElement.style.color = '#000000'; + container.appendChild(contentElement); + + // Apply monospace font to code elements + const style = document.createElement('style'); + style.textContent = ` + code, pre { font-family: "Source Code Pro", ui-monospace, monospace !important; } + pre { background: #f5f5f5; padding: 10px; border-radius: 4px; overflow-x: auto; } + code { background: #f0f0f0; padding: 2px 4px; border-radius: 2px; } + `; + container.appendChild(style); + + // Create PDF using jsPDF's html() method (like dompdf) + const pdf = new jsPDF({ + orientation: 'portrait', + unit: 'mm', + format: 'a4', + }); + + // Use jsPDF's html() method which handles pagination automatically + await pdf.html(container, { + callback: async (doc) => { + // Save the PDF + const fileName = `${localTitle || 'note'}.pdf`; + doc.save(fileName); + + // Show success message using Tauri dialog + setTimeout(async () => { + try { + await message(`PDF exported successfully!\n\nFile: ${fileName}\nLocation: Downloads folder`, { + title: 'Export Complete', + kind: 'info', + }); + } catch (err) { + console.log('Dialog shown successfully or not available'); + } + setIsExportingPDF(false); + }, 500); + }, + margin: [20, 20, 20, 20], // top, right, bottom, left margins in mm + autoPaging: 'text', // Enable automatic page breaks + width: 170, // Content width in mm (A4 width 210mm - 40mm margins) + windowWidth: 650, // Rendering width in pixels (matches content width ratio) + }); + } catch (error) { + console.error('PDF export failed:', error); + try { + await message('Failed to export PDF. Please try again.', { + title: 'Export Failed', + kind: 'error', + }); + } catch (err) { + console.error('Could not show error dialog'); + } + setIsExportingPDF(false); + } }; const handleFavoriteToggle = () => { @@ -116,16 +286,235 @@ export function NoteEditor({ note, onUpdateNote, fontSize }: NoteEditorProps) { onUpdateNote({ ...note, title: localTitle, - content: editor ? turndownService.turndown(editor.getHTML()) : note.content, + content: localContent, category: localCategory, favorite: !localFavorite, }); } }; + const handleCategoryChange = (category: string) => { + setLocalCategory(category); + setHasUnsavedChanges(true); + }; + + const handleAttachmentUpload = async (event: React.ChangeEvent) => { + const file = event.target.files?.[0]; + if (!file || !note || !api) return; + + setIsUploading(true); + try { + const relativePath = await api.uploadAttachment(note.id, file, note.category); + + // Determine if it's an image or other file + const isImage = file.type.startsWith('image/'); + const markdownLink = isImage + ? `![${file.name}](${relativePath})` + : `[${file.name}](${relativePath})`; + + // Insert at cursor position or end of content + const textarea = textareaRef.current; + if (textarea) { + const cursorPos = textarea.selectionStart; + const newContent = localContent.slice(0, cursorPos) + markdownLink + localContent.slice(cursorPos); + setLocalContent(newContent); + setHasUnsavedChanges(true); + + // Move cursor after inserted text + setTimeout(() => { + textarea.focus(); + const newPos = cursorPos + markdownLink.length; + textarea.setSelectionRange(newPos, newPos); + }, 0); + } else { + // Append to end + setLocalContent(localContent + '\n' + markdownLink); + setHasUnsavedChanges(true); + } + + await message(`Attachment uploaded successfully!`, { + title: 'Upload Complete', + kind: 'info', + }); + } catch (error) { + console.error('Upload failed:', error); + await message(`Failed to upload attachment: ${error}`, { + title: 'Upload Failed', + kind: 'error', + }); + } finally { + setIsUploading(false); + // Reset file input + if (fileInputRef.current) { + fileInputRef.current.value = ''; + } + } + }; + + const handleInsertLink = (text: string, url: string) => { + const textarea = textareaRef.current; + if (!textarea) return; + + const cursorPos = textarea.selectionStart; + const markdownLink = `[${text}](${url})`; + const newContent = localContent.slice(0, cursorPos) + markdownLink + localContent.slice(cursorPos); + setLocalContent(newContent); + setHasUnsavedChanges(true); + + setTimeout(() => { + textarea.focus(); + const newPos = cursorPos + markdownLink.length; + textarea.setSelectionRange(newPos, newPos); + }, 0); + }; + + const handleInsertFile = () => { + fileInputRef.current?.click(); + }; + + const handleFormat = (format: 'bold' | 'italic' | 'strikethrough' | 'code' | 'codeblock' | 'quote' | 'ul' | 'ol' | 'link' | 'h1' | 'h2' | 'h3') => { + if (!textareaRef.current) return; + + const textarea = textareaRef.current; + const start = textarea.selectionStart; + const end = textarea.selectionEnd; + const selectedText = localContent.substring(start, end); + + if (!selectedText) return; + + let formattedText = ''; + let cursorOffset = 0; + let isRemoving = false; + + // Helper to check and remove inline formatting + const toggleInline = (text: string, wrapper: string): { result: string; removed: boolean } => { + const escaped = wrapper.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const regex = new RegExp(`^${escaped}(.+)${escaped}$`, 's'); + const match = text.match(regex); + if (match) { + return { result: match[1], removed: true }; + } + return { result: `${wrapper}${text}${wrapper}`, removed: false }; + }; + + // Helper to check and remove line-prefix formatting + const toggleLinePrefix = (text: string, prefixRegex: RegExp, addPrefix: (line: string, i: number) => string): { result: string; removed: boolean } => { + const lines = text.split('\n'); + const allHavePrefix = lines.every(line => prefixRegex.test(line)); + if (allHavePrefix) { + return { + result: lines.map(line => line.replace(prefixRegex, '')).join('\n'), + removed: true + }; + } + return { + result: lines.map((line, i) => addPrefix(line, i)).join('\n'), + removed: false + }; + }; + + switch (format) { + case 'bold': { + const { result, removed } = toggleInline(selectedText, '**'); + formattedText = result; + isRemoving = removed; + break; + } + case 'italic': { + const { result, removed } = toggleInline(selectedText, '*'); + formattedText = result; + isRemoving = removed; + break; + } + case 'strikethrough': { + const { result, removed } = toggleInline(selectedText, '~~'); + formattedText = result; + isRemoving = removed; + break; + } + case 'code': { + const { result, removed } = toggleInline(selectedText, '`'); + formattedText = result; + isRemoving = removed; + break; + } + case 'codeblock': { + const codeBlockMatch = selectedText.match(/^```\n?([\s\S]*?)\n?```$/); + if (codeBlockMatch) { + formattedText = codeBlockMatch[1]; + isRemoving = true; + } else { + formattedText = `\`\`\`\n${selectedText}\n\`\`\``; + } + break; + } + case 'quote': { + const { result, removed } = toggleLinePrefix(selectedText, /^>\s?/, (line) => `> ${line}`); + formattedText = result; + isRemoving = removed; + break; + } + case 'ul': { + const { result, removed } = toggleLinePrefix(selectedText, /^[-*+]\s/, (line) => `- ${line}`); + formattedText = result; + isRemoving = removed; + break; + } + case 'ol': { + const { result, removed } = toggleLinePrefix(selectedText, /^\d+\.\s/, (line, i) => `${i + 1}. ${line}`); + formattedText = result; + isRemoving = removed; + break; + } + case 'link': { + const linkMatch = selectedText.match(/^\[(.+)\]\((.+)\)$/); + if (linkMatch) { + formattedText = linkMatch[1]; // Just return the text part + isRemoving = true; + } else { + formattedText = `[${selectedText}](url)`; + cursorOffset = formattedText.length - 4; + } + break; + } + case 'h1': { + const { result, removed } = toggleLinePrefix(selectedText, /^#\s/, (line) => `# ${line}`); + formattedText = result; + isRemoving = removed; + break; + } + case 'h2': { + const { result, removed } = toggleLinePrefix(selectedText, /^##\s/, (line) => `## ${line}`); + formattedText = result; + isRemoving = removed; + break; + } + case 'h3': { + const { result, removed } = toggleLinePrefix(selectedText, /^###\s/, (line) => `### ${line}`); + formattedText = result; + isRemoving = removed; + break; + } + } + + const newContent = localContent.substring(0, start) + formattedText + localContent.substring(end); + setLocalContent(newContent); + setHasUnsavedChanges(true); + + setTimeout(() => { + textarea.focus(); + if (format === 'link' && !isRemoving) { + // Select "url" for easy replacement + textarea.setSelectionRange(start + cursorOffset, start + cursorOffset + 3); + } else { + textarea.setSelectionRange(start, start + formattedText.length); + } + }, 0); + }; + if (!note) { return ( -
+
@@ -137,51 +526,28 @@ export function NoteEditor({ note, onUpdateNote, fontSize }: NoteEditorProps) { ); } - if (!editor) { - return null; - } - return ( -
-
- handleTitleChange(e.target.value)} - placeholder="Note Title" - className="flex-1 text-2xl font-bold border-none outline-none focus:ring-0" - /> - -
- {hasUnsavedChanges && ( - Unsaved changes - )} - {isSaving && ( - Saving... - )} - - +
+ {/* Header */} +
+
+
+ handleTitleChange(e.target.value)} + placeholder="Note Title" + className="w-full text-2xl font-semibold border-none outline-none focus:ring-0 bg-transparent text-gray-900 dark:text-gray-100 placeholder-gray-400" + /> +
- - handleCategoryChange(e.target.value)} - placeholder="Category" - className="w-32 px-3 py-1 text-sm border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500" - />
- {/* Formatting Toolbar */} -
- + {/* Toolbar */} +
+
+
+ {/* Category Selector */} +
+ + + + + + + +
- + {/* Attachment Upload */} + + - + {/* Preview Toggle */} + +
- +
+ {/* Status */} + {(hasUnsavedChanges || isSaving) && ( + + {isSaving ? 'Saving...' : 'Unsaved'} + + )} -
+ {/* Action Buttons */} +
+ - + + + - - - - -
- - - - - -
- - - - + {/* Focus Mode Toggle */} + {onToggleFocusMode && ( + + )} +
+
+
-
- +
+
+ {isPreviewMode ? ( +
+ {isLoadingImages && ( +
+ + + + + Loading images... +
+ )} +
+
+ ) : ( +
+ + +