├── .eslintignore
├── .gitignore
├── src
├── renderer
│ ├── src
│ │ ├── env.d.ts
│ │ ├── components
│ │ │ ├── Button
│ │ │ │ ├── index.ts
│ │ │ │ ├── ActionButton.tsx
│ │ │ │ ├── DeleteNoteButton.tsx
│ │ │ │ └── NewNoteButton.tsx
│ │ │ ├── DraggableTopBar.tsx
│ │ │ ├── index.ts
│ │ │ ├── ActionButtonsRow.tsx
│ │ │ ├── FloatingNoteTitle.tsx
│ │ │ ├── AppLayout.tsx
│ │ │ ├── NotePreview.tsx
│ │ │ ├── MarkdownEditor.tsx
│ │ │ └── NotePreviewList.tsx
│ │ ├── main.tsx
│ │ ├── utils
│ │ │ └── index.ts
│ │ ├── store
│ │ │ ├── mocks
│ │ │ │ └── index.ts
│ │ │ └── index.ts
│ │ ├── hooks
│ │ │ ├── useNotesList.tsx
│ │ │ └── useMarkdownEditor.tsx
│ │ ├── assets
│ │ │ └── index.css
│ │ └── App.tsx
│ └── index.html
├── shared
│ ├── models.ts
│ ├── constants.ts
│ └── types.ts
├── preload
│ ├── index.d.ts
│ └── index.ts
└── main
│ ├── index.ts
│ └── lib
│ └── index.ts
├── .vscode
├── extensions.json
├── settings.json
└── launch.json
├── resources
├── icon.png
└── welcomeNote.md
├── .prettierignore
├── .prettierrc.yaml
├── postcss.config.js
├── tsconfig.json
├── .editorconfig
├── tailwind.config.js
├── .eslintrc.cjs
├── tsconfig.node.json
├── tsconfig.web.json
├── README.md
├── electron.vite.config.ts
├── LICENSE
├── electron-builder.yml
└── package.json
/.eslintignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | dist
3 | out
4 | .gitignore
5 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | dist
3 | build
4 | out
5 | *.log*
6 |
--------------------------------------------------------------------------------
/src/renderer/src/env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
--------------------------------------------------------------------------------
/.vscode/extensions.json:
--------------------------------------------------------------------------------
1 | {
2 | "recommendations": ["dbaeumer.vscode-eslint"]
3 | }
4 |
--------------------------------------------------------------------------------
/resources/icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CodeWithGionatha-Labs/NoteMark/HEAD/resources/icon.png
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/postcss.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | plugins: {
3 | tailwindcss: {},
4 | autoprefixer: {}
5 | }
6 | }
7 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "files": [],
3 | "references": [{ "path": "./tsconfig.node.json" }, { "path": "./tsconfig.web.json" }]
4 | }
5 |
--------------------------------------------------------------------------------
/src/shared/models.ts:
--------------------------------------------------------------------------------
1 | export type NoteInfo = {
2 | title: string
3 | lastEditTime: number
4 | }
5 |
6 | export type NoteContent = string
7 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/.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
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/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 |
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/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/renderer/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/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/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/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 | [](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 |
--------------------------------------------------------------------------------
/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/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/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/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/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
21 | )
22 | }
23 |
24 | return (
25 |
26 | {notes.map((note, index) => (
27 |
33 | ))}
34 |
35 | )
36 | }
37 |
--------------------------------------------------------------------------------
/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/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/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 |
--------------------------------------------------------------------------------