├── .editorconfig ├── .eslintignore ├── .eslintrc.cjs ├── .gitignore ├── .prettierignore ├── .prettierrc.yaml ├── .vscode ├── extensions.json ├── launch.json └── settings.json ├── LICENSE ├── README.md ├── electron-builder.yml ├── electron.vite.config.ts ├── package.json ├── postcss.config.js ├── resources ├── icon.png └── welcomeNote.md ├── src ├── main │ ├── index.ts │ └── lib │ │ └── index.ts ├── preload │ ├── index.d.ts │ └── index.ts ├── renderer │ ├── index.html │ └── src │ │ ├── App.tsx │ │ ├── assets │ │ └── index.css │ │ ├── components │ │ ├── ActionButtonsRow.tsx │ │ ├── AppLayout.tsx │ │ ├── Button │ │ │ ├── ActionButton.tsx │ │ │ ├── DeleteNoteButton.tsx │ │ │ ├── NewNoteButton.tsx │ │ │ └── index.ts │ │ ├── DraggableTopBar.tsx │ │ ├── FloatingNoteTitle.tsx │ │ ├── MarkdownEditor.tsx │ │ ├── NotePreview.tsx │ │ ├── NotePreviewList.tsx │ │ └── index.ts │ │ ├── env.d.ts │ │ ├── hooks │ │ ├── useMarkdownEditor.tsx │ │ └── useNotesList.tsx │ │ ├── main.tsx │ │ ├── store │ │ ├── index.ts │ │ └── mocks │ │ │ └── index.ts │ │ └── utils │ │ └── index.ts └── shared │ ├── constants.ts │ ├── models.ts │ └── types.ts ├── tailwind.config.js ├── tsconfig.json ├── tsconfig.node.json ├── tsconfig.web.json └── yarn.lock /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | indent_style = space 6 | indent_size = 2 7 | end_of_line = lf 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | out 4 | .gitignore 5 | -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: [ 3 | 'eslint:recommended', 4 | 'plugin:react/recommended', 5 | 'plugin:react/jsx-runtime', 6 | '@electron-toolkit/eslint-config-ts/recommended', 7 | '@electron-toolkit/eslint-config-prettier' 8 | ], 9 | rules: { 10 | '@typescript-eslint/explicit-function-return-type': 'off', 11 | '@typescript-eslint/no-unused-vars': 'off' 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | build 4 | out 5 | *.log* 6 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | out 2 | dist 3 | pnpm-lock.yaml 4 | LICENSE.md 5 | tsconfig.json 6 | tsconfig.*.json 7 | -------------------------------------------------------------------------------- /.prettierrc.yaml: -------------------------------------------------------------------------------- 1 | singleQuote: true 2 | semi: false 3 | printWidth: 100 4 | trailingComma: none 5 | endOfLine: lf 6 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": ["dbaeumer.vscode-eslint"] 3 | } 4 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "name": "Debug Main Process", 6 | "type": "node", 7 | "request": "launch", 8 | "cwd": "${workspaceRoot}", 9 | "runtimeExecutable": "${workspaceRoot}/node_modules/.bin/electron-vite", 10 | "windows": { 11 | "runtimeExecutable": "${workspaceRoot}/node_modules/.bin/electron-vite.cmd" 12 | }, 13 | "runtimeArgs": ["--sourcemap"], 14 | "env": { 15 | "REMOTE_DEBUGGING_PORT": "9222" 16 | } 17 | }, 18 | { 19 | "name": "Debug Renderer Process", 20 | "port": 9222, 21 | "request": "attach", 22 | "type": "chrome", 23 | "webRoot": "${workspaceFolder}/src/renderer", 24 | "timeout": 60000, 25 | "presentation": { 26 | "hidden": true 27 | } 28 | } 29 | ], 30 | "compounds": [ 31 | { 32 | "name": "Debug All", 33 | "configurations": ["Debug Main Process", "Debug Renderer Process"], 34 | "presentation": { 35 | "order": 1 36 | } 37 | } 38 | ] 39 | } 40 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.codeActionsOnSave": { 3 | "source.fixAll.eslint": "always", 4 | "source.organizeImports": "always" 5 | }, 6 | "editor.formatOnSave": true, 7 | "editor.defaultFormatter": "esbenp.prettier-vscode", 8 | "editor.wordWrap": "on", 9 | "markdownlint.config": { 10 | "MD041": false 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 gionathas 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # NoteMark 2 | 3 | A Note taking desktop app with out-of-the-box markdown support. Built with Electron and React. 4 | 5 | You can follow along the entire development of this project here 👇 6 | 7 | [![Preview](https://github.com/gionathas/NoteMark/assets/16454253/c5072721-8c51-450d-ad74-431c65247715)](https://youtu.be/t8ane4BDyC8?si=QDnKwHR_REREtiSy) 8 | 9 | ## Project Setup 10 | 11 | ### Install 12 | 13 | ```bash 14 | $ yarn 15 | ``` 16 | 17 | ### Development 18 | 19 | ```bash 20 | $ yarn dev 21 | ``` 22 | 23 | ### Build 24 | 25 | ```bash 26 | # For windows 27 | $ yarn build:win 28 | 29 | # For macOS 30 | $ yarn build:mac 31 | 32 | # For Linux 33 | $ yarn build:linux 34 | ``` 35 | -------------------------------------------------------------------------------- /electron-builder.yml: -------------------------------------------------------------------------------- 1 | appId: com.electron.app 2 | productName: note-mark 3 | directories: 4 | buildResources: build 5 | files: 6 | - '!**/.vscode/*' 7 | - '!src/*' 8 | - '!electron.vite.config.{js,ts,mjs,cjs}' 9 | - '!{.eslintignore,.eslintrc.cjs,.prettierignore,.prettierrc.yaml,dev-app-update.yml,CHANGELOG.md,README.md}' 10 | - '!{.env,.env.*,.npmrc,pnpm-lock.yaml}' 11 | - '!{tsconfig.json,tsconfig.node.json,tsconfig.web.json}' 12 | asarUnpack: 13 | - resources/** 14 | win: 15 | executableName: note-mark 16 | nsis: 17 | artifactName: ${name}-${version}-setup.${ext} 18 | shortcutName: ${productName} 19 | uninstallDisplayName: ${productName} 20 | createDesktopShortcut: always 21 | mac: 22 | entitlementsInherit: build/entitlements.mac.plist 23 | extendInfo: 24 | - NSCameraUsageDescription: Application requests access to the device's camera. 25 | - NSMicrophoneUsageDescription: Application requests access to the device's microphone. 26 | - NSDocumentsFolderUsageDescription: Application requests access to the user's Documents folder. 27 | - NSDownloadsFolderUsageDescription: Application requests access to the user's Downloads folder. 28 | notarize: false 29 | dmg: 30 | artifactName: ${name}-${version}.${ext} 31 | linux: 32 | target: 33 | - AppImage 34 | - snap 35 | - deb 36 | maintainer: electronjs.org 37 | category: Utility 38 | appImage: 39 | artifactName: ${name}-${version}.${ext} 40 | npmRebuild: false 41 | publish: 42 | provider: generic 43 | url: https://example.com/auto-updates 44 | -------------------------------------------------------------------------------- /electron.vite.config.ts: -------------------------------------------------------------------------------- 1 | import react from '@vitejs/plugin-react' 2 | import { defineConfig, externalizeDepsPlugin } from 'electron-vite' 3 | import { resolve } from 'path' 4 | 5 | export default defineConfig({ 6 | main: { 7 | plugins: [externalizeDepsPlugin()], 8 | resolve: { 9 | alias: { 10 | '@/lib': resolve('src/main/lib'), 11 | '@shared': resolve('src/shared') 12 | } 13 | } 14 | }, 15 | preload: { 16 | plugins: [externalizeDepsPlugin()] 17 | }, 18 | renderer: { 19 | assetsInclude: 'src/renderer/assets/**', 20 | resolve: { 21 | alias: { 22 | '@renderer': resolve('src/renderer/src'), 23 | '@shared': resolve('src/shared'), 24 | '@/hooks': resolve('src/renderer/src/hooks'), 25 | '@/assets': resolve('src/renderer/src/assets'), 26 | '@/store': resolve('src/renderer/src/store'), 27 | '@/components': resolve('src/renderer/src/components'), 28 | '@/mocks': resolve('src/renderer/src/mocks') 29 | } 30 | }, 31 | plugins: [react()] 32 | } 33 | }) 34 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "note-mark", 3 | "version": "1.0.0", 4 | "description": "A Markdown Note taking app built with Electron", 5 | "license": "MIT", 6 | "main": "./out/main/index.js", 7 | "scripts": { 8 | "format": "prettier --write .", 9 | "lint": "eslint . --ext .js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix", 10 | "typecheck:node": "tsc --noEmit -p tsconfig.node.json --composite false", 11 | "typecheck:web": "tsc --noEmit -p tsconfig.web.json --composite false", 12 | "typecheck": "npm run typecheck:node && npm run typecheck:web", 13 | "start": "electron-vite preview", 14 | "dev": "electron-vite dev", 15 | "build": "npm run typecheck && electron-vite build", 16 | "postinstall": "electron-builder install-app-deps", 17 | "build:win": "npm run build && electron-builder --win --config", 18 | "build:mac": "electron-vite build && electron-builder --mac --config", 19 | "build:linux": "electron-vite build && electron-builder --linux --config" 20 | }, 21 | "dependencies": { 22 | "@electron-toolkit/preload": "^2.0.0", 23 | "@electron-toolkit/utils": "^2.0.0", 24 | "@mdxeditor/editor": "^1.14.3", 25 | "fs-extra": "^11.2.0", 26 | "jotai": "^2.6.1" 27 | }, 28 | "devDependencies": { 29 | "@electron-toolkit/eslint-config-prettier": "^1.0.1", 30 | "@electron-toolkit/eslint-config-ts": "^1.0.0", 31 | "@electron-toolkit/tsconfig": "^1.0.1", 32 | "@tailwindcss/typography": "^0.5.10", 33 | "@types/lodash": "^4.14.202", 34 | "@types/node": "^18.17.5", 35 | "@types/react": "^18.2.20", 36 | "@types/react-dom": "^18.2.7", 37 | "@vitejs/plugin-react": "^4.0.4", 38 | "autoprefixer": "^10.4.16", 39 | "clsx": "^2.1.0", 40 | "electron": "^25.6.0", 41 | "electron-builder": "^24.6.3", 42 | "electron-vite": "^1.0.27", 43 | "eslint": "^8.47.0", 44 | "eslint-plugin-react": "^7.33.2", 45 | "lodash": "^4.17.21", 46 | "postcss": "^8.4.32", 47 | "prettier": "^3.0.2", 48 | "react": "^18.2.0", 49 | "react-dom": "^18.2.0", 50 | "react-icons": "^4.12.0", 51 | "tailwind-merge": "^2.2.0", 52 | "tailwindcss": "^3.4.0", 53 | "typescript": "^5.1.6", 54 | "vite": "^4.4.9" 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {} 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /resources/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CodeWithGionatha-Labs/NoteMark/8e4289fe0218afe80d935d7b94f7d0fbef674cba/resources/icon.png -------------------------------------------------------------------------------- /resources/welcomeNote.md: -------------------------------------------------------------------------------- 1 | ## Welcome to NoteMark 👋🏻 2 | 3 | NoteMark is a simple **note-taking app** that uses **Markdown** syntax to format your notes. 4 | 5 | You can create your first note by clicking on the top-left icon on the sidebar, or delete one by clicking on top right icon. 6 | 7 | Following there's a quick overview of the currently supported Markdown syntax. 8 | 9 | ## Text formatting 10 | 11 | This is a **bold** text. 12 | This is an _italic_ text. 13 | 14 | ## Headings 15 | 16 | Here are all the heading formats currently supported by **_NoteMark_**: 17 | 18 | # Heading 1 19 | 20 | ## Heading 2 21 | 22 | ### Heading 3 23 | 24 | #### Heading 4 25 | 26 | ### Bulleted list 27 | 28 | For example, you can add a list of bullet points: 29 | 30 | - Bullet point 1 31 | - Bullet point 2 32 | - Bullet point 3 33 | 34 | ### Numbered list 35 | 36 | Here we have a numbered list: 37 | 38 | 1. Numbered list item 1 39 | 2. Numbered list item 2 40 | 3. Numbered list item 3 41 | 42 | ### Blockquote 43 | 44 | > This is a blockquote. You can use it to emphasize some text or to cite someone. 45 | 46 | ### Code blocks 47 | 48 | Only `inline code` is currently supported! 49 | 50 | Code block snippets using the following syntax _\`\`\`js\`\`\`_ are **_not supported_** yet! 51 | 52 | ### Links 53 | 54 | Links are **_not supported_** yet! 55 | -------------------------------------------------------------------------------- /src/main/index.ts: -------------------------------------------------------------------------------- 1 | import { createNote, deleteNote, getNotes, readNote, writeNote } from '@/lib' 2 | import { electronApp, is, optimizer } from '@electron-toolkit/utils' 3 | import { CreateNote, DeleteNote, GetNotes, ReadNote, WriteNote } from '@shared/types' 4 | import { BrowserWindow, app, ipcMain, shell } from 'electron' 5 | import { join } from 'path' 6 | import icon from '../../resources/icon.png?asset' 7 | 8 | function createWindow(): void { 9 | // Create the browser window. 10 | const mainWindow = new BrowserWindow({ 11 | width: 900, 12 | height: 670, 13 | show: false, 14 | autoHideMenuBar: true, 15 | ...(process.platform === 'linux' ? { icon } : {}), 16 | center: true, 17 | title: 'NoteMark', 18 | frame: false, 19 | vibrancy: 'under-window', 20 | visualEffectState: 'active', 21 | titleBarStyle: 'hidden', 22 | trafficLightPosition: { x: 15, y: 10 }, 23 | webPreferences: { 24 | preload: join(__dirname, '../preload/index.js'), 25 | sandbox: true, 26 | contextIsolation: true 27 | } 28 | }) 29 | 30 | mainWindow.on('ready-to-show', () => { 31 | mainWindow.show() 32 | }) 33 | 34 | mainWindow.webContents.setWindowOpenHandler((details) => { 35 | shell.openExternal(details.url) 36 | return { action: 'deny' } 37 | }) 38 | 39 | // HMR for renderer base on electron-vite cli. 40 | // Load the remote URL for development or the local html file for production. 41 | if (is.dev && process.env['ELECTRON_RENDERER_URL']) { 42 | mainWindow.loadURL(process.env['ELECTRON_RENDERER_URL']) 43 | } else { 44 | mainWindow.loadFile(join(__dirname, '../renderer/index.html')) 45 | } 46 | } 47 | 48 | // This method will be called when Electron has finished 49 | // initialization and is ready to create browser windows. 50 | // Some APIs can only be used after this event occurs. 51 | app.whenReady().then(() => { 52 | // Set app user model id for windows 53 | electronApp.setAppUserModelId('com.electron') 54 | 55 | // Default open or close DevTools by F12 in development 56 | // and ignore CommandOrControl + R in production. 57 | // see https://github.com/alex8088/electron-toolkit/tree/master/packages/utils 58 | app.on('browser-window-created', (_, window) => { 59 | optimizer.watchWindowShortcuts(window) 60 | }) 61 | 62 | ipcMain.handle('getNotes', (_, ...args: Parameters) => getNotes(...args)) 63 | ipcMain.handle('readNote', (_, ...args: Parameters) => readNote(...args)) 64 | ipcMain.handle('writeNote', (_, ...args: Parameters) => writeNote(...args)) 65 | ipcMain.handle('createNote', (_, ...args: Parameters) => createNote(...args)) 66 | ipcMain.handle('deleteNote', (_, ...args: Parameters) => deleteNote(...args)) 67 | 68 | createWindow() 69 | 70 | app.on('activate', function () { 71 | // On macOS it's common to re-create a window in the app when the 72 | // dock icon is clicked and there are no other windows open. 73 | if (BrowserWindow.getAllWindows().length === 0) createWindow() 74 | }) 75 | }) 76 | 77 | // Quit when all windows are closed, except on macOS. There, it's common 78 | // for applications and their menu bar to stay active until the user quits 79 | // explicitly with Cmd + Q. 80 | app.on('window-all-closed', () => { 81 | if (process.platform !== 'darwin') { 82 | app.quit() 83 | } 84 | }) 85 | 86 | // In this file you can include the rest of your app"s specific main process 87 | // code. You can also put them in separate files and require them here. 88 | -------------------------------------------------------------------------------- /src/main/lib/index.ts: -------------------------------------------------------------------------------- 1 | import { appDirectoryName, fileEncoding, welcomeNoteFilename } from '@shared/constants' 2 | import { NoteInfo } from '@shared/models' 3 | import { CreateNote, DeleteNote, GetNotes, ReadNote, WriteNote } from '@shared/types' 4 | import { dialog } from 'electron' 5 | import { ensureDir, readFile, readdir, remove, stat, writeFile } from 'fs-extra' 6 | import { isEmpty } from 'lodash' 7 | import { homedir } from 'os' 8 | import path from 'path' 9 | import welcomeNoteFile from '../../../resources/welcomeNote.md?asset' 10 | 11 | export const getRootDir = () => { 12 | return `${homedir()}/${appDirectoryName}` 13 | } 14 | 15 | export const getNotes: GetNotes = async () => { 16 | const rootDir = getRootDir() 17 | 18 | await ensureDir(rootDir) 19 | 20 | const notesFileNames = await readdir(rootDir, { 21 | encoding: fileEncoding, 22 | withFileTypes: false 23 | }) 24 | 25 | const notes = notesFileNames.filter((fileName) => fileName.endsWith('.md')) 26 | 27 | if (isEmpty(notes)) { 28 | console.info('No notes found, creating a welcome note') 29 | 30 | const content = await readFile(welcomeNoteFile, { encoding: fileEncoding }) 31 | 32 | // create the welcome note 33 | await writeFile(`${rootDir}/${welcomeNoteFilename}`, content, { encoding: fileEncoding }) 34 | 35 | notes.push(welcomeNoteFilename) 36 | } 37 | 38 | return Promise.all(notes.map(getNoteInfoFromFilename)) 39 | } 40 | 41 | export const getNoteInfoFromFilename = async (filename: string): Promise => { 42 | const fileStats = await stat(`${getRootDir()}/${filename}`) 43 | 44 | return { 45 | title: filename.replace(/\.md$/, ''), 46 | lastEditTime: fileStats.mtimeMs 47 | } 48 | } 49 | 50 | export const readNote: ReadNote = async (filename) => { 51 | const rootDir = getRootDir() 52 | 53 | return readFile(`${rootDir}/${filename}.md`, { encoding: fileEncoding }) 54 | } 55 | 56 | export const writeNote: WriteNote = async (filename, content) => { 57 | const rootDir = getRootDir() 58 | 59 | console.info(`Writing note ${filename}`) 60 | return writeFile(`${rootDir}/${filename}.md`, content, { encoding: fileEncoding }) 61 | } 62 | 63 | export const createNote: CreateNote = async () => { 64 | const rootDir = getRootDir() 65 | 66 | await ensureDir(rootDir) 67 | 68 | const { filePath, canceled } = await dialog.showSaveDialog({ 69 | title: 'New note', 70 | defaultPath: `${rootDir}/Untitled.md`, 71 | buttonLabel: 'Create', 72 | properties: ['showOverwriteConfirmation'], 73 | showsTagField: false, 74 | filters: [{ name: 'Markdown', extensions: ['md'] }] 75 | }) 76 | 77 | if (canceled || !filePath) { 78 | console.info('Note creation canceled') 79 | return false 80 | } 81 | 82 | const { name: filename, dir: parentDir } = path.parse(filePath) 83 | 84 | if (parentDir !== rootDir) { 85 | await dialog.showMessageBox({ 86 | type: 'error', 87 | title: 'Creation failed', 88 | message: `All notes must be saved under ${rootDir}. 89 | Avoid using other directories!` 90 | }) 91 | 92 | return false 93 | } 94 | 95 | console.info(`Creating note: ${filePath}`) 96 | await writeFile(filePath, '') 97 | 98 | return filename 99 | } 100 | 101 | export const deleteNote: DeleteNote = async (filename) => { 102 | const rootDir = getRootDir() 103 | 104 | const { response } = await dialog.showMessageBox({ 105 | type: 'warning', 106 | title: 'Delete note', 107 | message: `Are you sure you want to delete ${filename}?`, 108 | buttons: ['Delete', 'Cancel'], // 0 is Delete, 1 is Cancel 109 | defaultId: 1, 110 | cancelId: 1 111 | }) 112 | 113 | if (response === 1) { 114 | console.info('Note deletion canceled') 115 | return false 116 | } 117 | 118 | console.info(`Deleting note: ${filename}`) 119 | await remove(`${rootDir}/${filename}.md`) 120 | return true 121 | } 122 | -------------------------------------------------------------------------------- /src/preload/index.d.ts: -------------------------------------------------------------------------------- 1 | import { CreateNote, DeleteNote, GetNotes, ReadNote, WriteNote } from '@shared/types' 2 | 3 | declare global { 4 | interface Window { 5 | // electron: ElectronAPI 6 | context: { 7 | locale: string 8 | getNotes: GetNotes 9 | readNote: ReadNote 10 | writeNote: WriteNote 11 | createNote: CreateNote 12 | deleteNote: DeleteNote 13 | } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/preload/index.ts: -------------------------------------------------------------------------------- 1 | import { CreateNote, DeleteNote, GetNotes, ReadNote, WriteNote } from '@shared/types' 2 | import { contextBridge, ipcRenderer } from 'electron' 3 | 4 | if (!process.contextIsolated) { 5 | throw new Error('contextIsolation must be enabled in the BrowserWindow') 6 | } 7 | 8 | try { 9 | contextBridge.exposeInMainWorld('context', { 10 | locale: navigator.language, 11 | getNotes: (...args: Parameters) => ipcRenderer.invoke('getNotes', ...args), 12 | readNote: (...args: Parameters) => ipcRenderer.invoke('readNote', ...args), 13 | writeNote: (...args: Parameters) => ipcRenderer.invoke('writeNote', ...args), 14 | createNote: (...args: Parameters) => ipcRenderer.invoke('createNote', ...args), 15 | deleteNote: (...args: Parameters) => ipcRenderer.invoke('deleteNote', ...args) 16 | }) 17 | } catch (error) { 18 | console.error(error) 19 | } 20 | -------------------------------------------------------------------------------- /src/renderer/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 9 | 10 | 11 | 12 |
13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /src/renderer/src/App.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | ActionButtonsRow, 3 | Content, 4 | DraggableTopBar, 5 | FloatingNoteTitle, 6 | MarkdownEditor, 7 | NotePreviewList, 8 | RootLayout, 9 | Sidebar 10 | } from '@/components' 11 | import { useRef } from 'react' 12 | 13 | const App = () => { 14 | const contentContainerRef = useRef(null) 15 | 16 | const resetScroll = () => { 17 | contentContainerRef.current?.scrollTo(0, 0) 18 | } 19 | 20 | return ( 21 | <> 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | ) 36 | } 37 | 38 | export default App 39 | -------------------------------------------------------------------------------- /src/renderer/src/assets/index.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @layer base { 6 | #root { 7 | @apply h-full; 8 | } 9 | 10 | html, 11 | body { 12 | @apply h-full; 13 | 14 | @apply select-none; 15 | 16 | @apply bg-transparent; 17 | 18 | @apply font-mono antialiased text-white; 19 | 20 | @apply overflow-hidden; 21 | } 22 | 23 | header { 24 | -webkit-app-region: drag; 25 | } 26 | 27 | button { 28 | -webkit-app-region: no-drag; 29 | } 30 | 31 | ::-webkit-scrollbar { 32 | @apply w-2; 33 | } 34 | 35 | ::-webkit-scrollbar-thumb { 36 | @apply bg-[#555] rounded-md; 37 | } 38 | 39 | ::-webkit-scrollbar-track { 40 | @apply bg-transparent; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/renderer/src/components/ActionButtonsRow.tsx: -------------------------------------------------------------------------------- 1 | import { DeleteNoteButton, NewNoteButton } from '@/components' 2 | import { ComponentProps } from 'react' 3 | 4 | export const ActionButtonsRow = ({ ...props }: ComponentProps<'div'>) => { 5 | return ( 6 |
7 | 8 | 9 |
10 | ) 11 | } 12 | -------------------------------------------------------------------------------- /src/renderer/src/components/AppLayout.tsx: -------------------------------------------------------------------------------- 1 | import { ComponentProps, forwardRef } from 'react' 2 | import { twMerge } from 'tailwind-merge' 3 | 4 | export const RootLayout = ({ children, className, ...props }: ComponentProps<'main'>) => { 5 | return ( 6 |
7 | {children} 8 |
9 | ) 10 | } 11 | 12 | export const Sidebar = ({ className, children, ...props }: ComponentProps<'aside'>) => { 13 | return ( 14 | 20 | ) 21 | } 22 | 23 | export const Content = forwardRef>( 24 | ({ children, className, ...props }, ref) => ( 25 |
26 | {children} 27 |
28 | ) 29 | ) 30 | 31 | Content.displayName = 'Content' 32 | -------------------------------------------------------------------------------- /src/renderer/src/components/Button/ActionButton.tsx: -------------------------------------------------------------------------------- 1 | import { ComponentProps } from 'react' 2 | import { twMerge } from 'tailwind-merge' 3 | 4 | export type ActionButtonProps = ComponentProps<'button'> 5 | 6 | export const ActionButton = ({ className, children, ...props }: ActionButtonProps) => { 7 | return ( 8 | 17 | ) 18 | } 19 | -------------------------------------------------------------------------------- /src/renderer/src/components/Button/DeleteNoteButton.tsx: -------------------------------------------------------------------------------- 1 | import { ActionButton, ActionButtonProps } from '@/components' 2 | import { deleteNoteAtom } from '@/store' 3 | import { useSetAtom } from 'jotai' 4 | import { FaRegTrashCan } from 'react-icons/fa6' 5 | 6 | export const DeleteNoteButton = ({ ...props }: ActionButtonProps) => { 7 | const deleteNote = useSetAtom(deleteNoteAtom) 8 | 9 | const handleDelete = async () => { 10 | await deleteNote() 11 | } 12 | 13 | return ( 14 | 15 | 16 | 17 | ) 18 | } 19 | -------------------------------------------------------------------------------- /src/renderer/src/components/Button/NewNoteButton.tsx: -------------------------------------------------------------------------------- 1 | import { ActionButton, ActionButtonProps } from '@/components' 2 | import { createEmptyNoteAtom } from '@renderer/store' 3 | import { useSetAtom } from 'jotai' 4 | import { LuFileSignature } from 'react-icons/lu' 5 | 6 | export const NewNoteButton = ({ ...props }: ActionButtonProps) => { 7 | const createEmptyNote = useSetAtom(createEmptyNoteAtom) 8 | 9 | const handleCreation = async () => { 10 | await createEmptyNote() 11 | } 12 | 13 | return ( 14 | 15 | 16 | 17 | ) 18 | } 19 | -------------------------------------------------------------------------------- /src/renderer/src/components/Button/index.ts: -------------------------------------------------------------------------------- 1 | export * from './ActionButton' 2 | export * from './DeleteNoteButton' 3 | export * from './NewNoteButton' 4 | -------------------------------------------------------------------------------- /src/renderer/src/components/DraggableTopBar.tsx: -------------------------------------------------------------------------------- 1 | export const DraggableTopBar = () => { 2 | return
3 | } 4 | -------------------------------------------------------------------------------- /src/renderer/src/components/FloatingNoteTitle.tsx: -------------------------------------------------------------------------------- 1 | import { selectedNoteAtom } from '@renderer/store' 2 | import { useAtomValue } from 'jotai' 3 | import { ComponentProps } from 'react' 4 | import { twMerge } from 'tailwind-merge' 5 | 6 | export const FloatingNoteTitle = ({ className, ...props }: ComponentProps<'div'>) => { 7 | const selectedNote = useAtomValue(selectedNoteAtom) 8 | 9 | if (!selectedNote) return null 10 | 11 | return ( 12 |
13 | {selectedNote.title} 14 |
15 | ) 16 | } 17 | -------------------------------------------------------------------------------- /src/renderer/src/components/MarkdownEditor.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | MDXEditor, 3 | headingsPlugin, 4 | listsPlugin, 5 | markdownShortcutPlugin, 6 | quotePlugin 7 | } from '@mdxeditor/editor' 8 | import { useMarkdownEditor } from '@renderer/hooks/useMarkdownEditor' 9 | 10 | export const MarkdownEditor = () => { 11 | const { editorRef, selectedNote, handleAutoSaving, handleBlur } = useMarkdownEditor() 12 | 13 | if (!selectedNote) return null 14 | 15 | return ( 16 | 25 | ) 26 | } 27 | -------------------------------------------------------------------------------- /src/renderer/src/components/NotePreview.tsx: -------------------------------------------------------------------------------- 1 | import { cn, formatDateFromMs } from '@renderer/utils' 2 | import { NoteInfo } from '@shared/models' 3 | import { ComponentProps } from 'react' 4 | 5 | export type NotePreviewProps = NoteInfo & { 6 | isActive?: boolean 7 | } & ComponentProps<'div'> 8 | 9 | export const NotePreview = ({ 10 | title, 11 | content, 12 | lastEditTime, 13 | isActive = false, 14 | className, 15 | ...props 16 | }: NotePreviewProps) => { 17 | const date = formatDateFromMs(lastEditTime) 18 | 19 | return ( 20 |
31 |

{title}

32 | {date} 33 |
34 | ) 35 | } 36 | -------------------------------------------------------------------------------- /src/renderer/src/components/NotePreviewList.tsx: -------------------------------------------------------------------------------- 1 | import { NotePreview } from '@/components' 2 | import { useNotesList } from '@/hooks/useNotesList' 3 | import { isEmpty } from 'lodash' 4 | import { ComponentProps } from 'react' 5 | import { twMerge } from 'tailwind-merge' 6 | 7 | export type NotePreviewListProps = ComponentProps<'ul'> & { 8 | onSelect?: () => void 9 | } 10 | 11 | export const NotePreviewList = ({ onSelect, className, ...props }: NotePreviewListProps) => { 12 | const { notes, selectedNoteIndex, handleNoteSelect } = useNotesList({ onSelect }) 13 | 14 | if (!notes) return null 15 | 16 | if (isEmpty(notes)) { 17 | return ( 18 |
    19 | No Notes Yet! 20 |
21 | ) 22 | } 23 | 24 | return ( 25 |
    26 | {notes.map((note, index) => ( 27 | 33 | ))} 34 |
35 | ) 36 | } 37 | -------------------------------------------------------------------------------- /src/renderer/src/components/index.ts: -------------------------------------------------------------------------------- 1 | export * from './ActionButtonsRow' 2 | export * from './AppLayout' 3 | export * from './Button' 4 | export * from './DraggableTopBar' 5 | export * from './FloatingNoteTitle' 6 | export * from './MarkdownEditor' 7 | export * from './NotePreview' 8 | export * from './NotePreviewList' 9 | -------------------------------------------------------------------------------- /src/renderer/src/env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /src/renderer/src/hooks/useMarkdownEditor.tsx: -------------------------------------------------------------------------------- 1 | import { MDXEditorMethods } from '@mdxeditor/editor' 2 | import { saveNoteAtom, selectedNoteAtom } from '@renderer/store' 3 | import { autoSavingTime } from '@shared/constants' 4 | import { NoteContent } from '@shared/models' 5 | import { useAtomValue, useSetAtom } from 'jotai' 6 | import { throttle } from 'lodash' 7 | import { useRef } from 'react' 8 | 9 | export const useMarkdownEditor = () => { 10 | const selectedNote = useAtomValue(selectedNoteAtom) 11 | const saveNote = useSetAtom(saveNoteAtom) 12 | const editorRef = useRef(null) 13 | 14 | const handleAutoSaving = throttle( 15 | async (content: NoteContent) => { 16 | if (!selectedNote) return 17 | 18 | console.info('Auto saving:', selectedNote.title) 19 | 20 | await saveNote(content) 21 | }, 22 | autoSavingTime, 23 | { 24 | leading: false, 25 | trailing: true 26 | } 27 | ) 28 | 29 | const handleBlur = async () => { 30 | if (!selectedNote) return 31 | 32 | handleAutoSaving.cancel() 33 | 34 | const content = editorRef.current?.getMarkdown() 35 | 36 | if (content != null) { 37 | await saveNote(content) 38 | } 39 | } 40 | 41 | return { 42 | editorRef, 43 | selectedNote, 44 | handleAutoSaving, 45 | handleBlur 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/renderer/src/hooks/useNotesList.tsx: -------------------------------------------------------------------------------- 1 | import { notesAtom, selectedNoteIndexAtom } from '@/store' 2 | import { useAtom, useAtomValue } from 'jotai' 3 | 4 | export const useNotesList = ({ onSelect }: { onSelect?: () => void }) => { 5 | const notes = useAtomValue(notesAtom) 6 | 7 | const [selectedNoteIndex, setSelectedNoteIndex] = useAtom(selectedNoteIndexAtom) 8 | 9 | const handleNoteSelect = (index: number) => async () => { 10 | setSelectedNoteIndex(index) 11 | 12 | if (onSelect) { 13 | onSelect() 14 | } 15 | } 16 | 17 | return { 18 | notes, 19 | selectedNoteIndex, 20 | handleNoteSelect 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/renderer/src/main.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import ReactDOM from 'react-dom/client' 3 | import './assets/index.css' 4 | import App from './App' 5 | 6 | ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render( 7 | 8 | 9 | 10 | ) 11 | -------------------------------------------------------------------------------- /src/renderer/src/store/index.ts: -------------------------------------------------------------------------------- 1 | import { NoteContent, NoteInfo } from '@shared/models' 2 | import { atom } from 'jotai' 3 | import { unwrap } from 'jotai/utils' 4 | 5 | const loadNotes = async () => { 6 | const notes = await window.context.getNotes() 7 | 8 | // sort them by most recently edited 9 | return notes.sort((a, b) => b.lastEditTime - a.lastEditTime) 10 | } 11 | 12 | const notesAtomAsync = atom>(loadNotes()) 13 | 14 | export const notesAtom = unwrap(notesAtomAsync, (prev) => prev) 15 | 16 | export const selectedNoteIndexAtom = atom(null) 17 | 18 | const selectedNoteAtomAsync = atom(async (get) => { 19 | const notes = get(notesAtom) 20 | const selectedNoteIndex = get(selectedNoteIndexAtom) 21 | 22 | if (selectedNoteIndex == null || !notes) return null 23 | 24 | const selectedNote = notes[selectedNoteIndex] 25 | 26 | const noteContent = await window.context.readNote(selectedNote.title) 27 | 28 | return { 29 | ...selectedNote, 30 | content: noteContent 31 | } 32 | }) 33 | 34 | export const selectedNoteAtom = unwrap( 35 | selectedNoteAtomAsync, 36 | (prev) => 37 | prev ?? { 38 | title: '', 39 | content: '', 40 | lastEditTime: Date.now() 41 | } 42 | ) 43 | 44 | export const saveNoteAtom = atom(null, async (get, set, newContent: NoteContent) => { 45 | const notes = get(notesAtom) 46 | const selectedNote = get(selectedNoteAtom) 47 | 48 | if (!selectedNote || !notes) return 49 | 50 | // save on disk 51 | await window.context.writeNote(selectedNote.title, newContent) 52 | 53 | // update the saved note's last edit time 54 | set( 55 | notesAtom, 56 | notes.map((note) => { 57 | // this is the note that we want to update 58 | if (note.title === selectedNote.title) { 59 | return { 60 | ...note, 61 | lastEditTime: Date.now() 62 | } 63 | } 64 | 65 | return note 66 | }) 67 | ) 68 | }) 69 | 70 | export const createEmptyNoteAtom = atom(null, async (get, set) => { 71 | const notes = get(notesAtom) 72 | 73 | if (!notes) return 74 | 75 | const title = await window.context.createNote() 76 | 77 | if (!title) return 78 | 79 | const newNote: NoteInfo = { 80 | title, 81 | lastEditTime: Date.now() 82 | } 83 | 84 | set(notesAtom, [newNote, ...notes.filter((note) => note.title !== newNote.title)]) 85 | 86 | set(selectedNoteIndexAtom, 0) 87 | }) 88 | 89 | export const deleteNoteAtom = atom(null, async (get, set) => { 90 | const notes = get(notesAtom) 91 | const selectedNote = get(selectedNoteAtom) 92 | 93 | if (!selectedNote || !notes) return 94 | 95 | const isDeleted = await window.context.deleteNote(selectedNote.title) 96 | 97 | if (!isDeleted) return 98 | 99 | // filter out the deleted note 100 | set( 101 | notesAtom, 102 | notes.filter((note) => note.title !== selectedNote.title) 103 | ) 104 | 105 | // de select any note 106 | set(selectedNoteIndexAtom, null) 107 | }) 108 | -------------------------------------------------------------------------------- /src/renderer/src/store/mocks/index.ts: -------------------------------------------------------------------------------- 1 | import { NoteInfo } from '@shared/models' 2 | 3 | export const notesMock: NoteInfo[] = [ 4 | { 5 | title: `Welcome 👋🏻`, 6 | lastEditTime: new Date().getTime() 7 | }, 8 | { 9 | title: 'Note 1', 10 | lastEditTime: new Date().getTime() 11 | }, 12 | { 13 | title: 'Note 2', 14 | lastEditTime: new Date().getTime() 15 | }, 16 | { 17 | title: 'Note 3', 18 | lastEditTime: new Date().getTime() 19 | } 20 | ] 21 | -------------------------------------------------------------------------------- /src/renderer/src/utils/index.ts: -------------------------------------------------------------------------------- 1 | import clsx, { ClassValue } from 'clsx' 2 | import { twMerge } from 'tailwind-merge' 3 | 4 | const dateFormatter = new Intl.DateTimeFormat(window.context.locale, { 5 | dateStyle: 'short', 6 | timeStyle: 'short', 7 | timeZone: 'UTC' 8 | }) 9 | 10 | export const formatDateFromMs = (ms: number) => dateFormatter.format(ms) 11 | 12 | export const cn = (...args: ClassValue[]) => { 13 | return twMerge(clsx(...args)) 14 | } 15 | -------------------------------------------------------------------------------- /src/shared/constants.ts: -------------------------------------------------------------------------------- 1 | export const appDirectoryName = 'NoteMark' 2 | export const fileEncoding = 'utf8' 3 | 4 | export const autoSavingTime = 3000 5 | export const welcomeNoteFilename = 'Welcome.md' 6 | -------------------------------------------------------------------------------- /src/shared/models.ts: -------------------------------------------------------------------------------- 1 | export type NoteInfo = { 2 | title: string 3 | lastEditTime: number 4 | } 5 | 6 | export type NoteContent = string 7 | -------------------------------------------------------------------------------- /src/shared/types.ts: -------------------------------------------------------------------------------- 1 | import { NoteContent, NoteInfo } from './models' 2 | 3 | export type GetNotes = () => Promise 4 | export type ReadNote = (title: NoteInfo['title']) => Promise 5 | export type WriteNote = (title: NoteInfo['title'], content: NoteContent) => Promise 6 | export type CreateNote = () => Promise 7 | export type DeleteNote = (title: NoteInfo['title']) => Promise 8 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | module.exports = { 3 | content: ['./src/renderer/**/*.{js,ts,jsx,tsx}'], 4 | theme: { 5 | extend: {} 6 | }, 7 | plugins: [require('@tailwindcss/typography')] 8 | } 9 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "files": [], 3 | "references": [{ "path": "./tsconfig.node.json" }, { "path": "./tsconfig.web.json" }] 4 | } 5 | -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@electron-toolkit/tsconfig/tsconfig.node.json", 3 | "include": [ 4 | "electron.vite.config.*", 5 | "src/main/**/*", 6 | "src/preload/*", 7 | "src/shared/**/*" 8 | ], 9 | "compilerOptions": { 10 | "composite": true, 11 | "types": [ 12 | "electron-vite/node" 13 | ], 14 | "baseUrl": ".", 15 | "paths": { 16 | "@/*": [ 17 | "src/main/*" 18 | ], 19 | "@shared/*": [ 20 | "src/shared/*" 21 | ], 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /tsconfig.web.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@electron-toolkit/tsconfig/tsconfig.web.json", 3 | "include": [ 4 | "src/renderer/src/env.d.ts", 5 | "src/renderer/src/**/*", 6 | "src/renderer/src/**/*.tsx", 7 | "src/preload/*.d.ts", 8 | "src/shared/**/*", 9 | ], 10 | "compilerOptions": { 11 | "composite": true, 12 | "jsx": "react-jsx", 13 | "noUnusedLocals": false, 14 | "baseUrl": ".", 15 | "paths": { 16 | "@renderer/*": [ 17 | "src/renderer/src/*" 18 | ], 19 | "@shared/*": [ 20 | "src/shared/*" 21 | ], 22 | "@/*": [ 23 | "src/renderer/src/*" 24 | ], 25 | } 26 | } 27 | } 28 | --------------------------------------------------------------------------------