├── .gitignore ├── LICENSE ├── README.md ├── backend ├── .dockerignore ├── .eslintrc.js ├── .gitignore ├── Dockerfile ├── package.json ├── src │ ├── helpers │ │ ├── addPage.ts │ │ ├── clients │ │ │ ├── ElasticSearchClient.ts │ │ │ ├── OpenAIClient.ts │ │ │ ├── QDrantClient.d.ts │ │ │ └── QDrantClient.ts │ │ ├── deletePage.ts │ │ ├── exporters │ │ │ ├── exporter.d.ts │ │ │ └── mdExporter.ts │ │ ├── getChatResponse.ts │ │ ├── operations │ │ │ ├── queryAggregator.ts │ │ │ └── queryGenerators │ │ │ │ ├── blocks │ │ │ │ ├── addBlockQuery.ts │ │ │ │ ├── deleteBlockQuery.ts │ │ │ │ └── editBlockQuery.ts │ │ │ │ └── index.ts │ │ ├── refreshEmbeds.ts │ │ └── updateElasticsearchIndexes.ts │ ├── index.ts │ ├── middleware │ │ └── verifyPermissions.ts │ ├── models │ │ ├── pageMap.ts │ │ ├── pageModel.ts │ │ ├── pageTreeModel.ts │ │ └── userModel.ts │ ├── routes │ │ ├── account │ │ │ ├── editPageTreeExpansion.ts │ │ │ ├── getPageTree.ts │ │ │ ├── index.ts │ │ │ └── search.ts │ │ ├── index.ts │ │ └── page │ │ │ ├── chat.ts │ │ │ ├── complete.ts │ │ │ ├── export.ts │ │ │ ├── getHomePage.ts │ │ │ ├── getPage.ts │ │ │ ├── getPageInfo.ts │ │ │ ├── index.ts │ │ │ ├── modify.ts │ │ │ ├── modifyPage │ │ │ ├── addPage.ts │ │ │ ├── deletePage.ts │ │ │ ├── editPage.ts │ │ │ └── index.ts │ │ │ └── updatePermissions.ts │ └── setupAuth.ts ├── tsconfig.json └── yarn.lock ├── config ├── example.env └── load-env.ts ├── cypress.config.js ├── cypress ├── e2e │ ├── edit-page.cy.js │ └── login.cy.js ├── fixtures │ └── example.json └── support │ ├── commands.js │ └── e2e.js ├── docker-compose.dev.yaml ├── docker-compose.yaml ├── images ├── Desktop_Current_State_Dark.png └── Note Rack Page.pdf ├── package.json ├── packages ├── editor │ ├── .babelrc │ ├── .gitignore │ ├── .npmignore │ ├── index.html │ ├── jest.config.cjs │ ├── package.json │ ├── scripts │ │ └── build.sh │ ├── src │ │ ├── __tests__ │ │ │ ├── helpers │ │ │ │ ├── checkKeybind.test.ts │ │ │ │ └── checkModifers.test.ts │ │ │ └── mutations │ │ │ │ ├── addBlock.test.ts │ │ │ │ ├── editBlock.test.ts │ │ │ │ ├── moveBlock.test.ts │ │ │ │ └── removeBlock.test.ts │ │ ├── components │ │ │ ├── BlockWrapper.tsx │ │ │ ├── ContentEditable.tsx │ │ │ ├── Editor.tsx │ │ │ └── createStyledTextRenderer.tsx │ │ ├── demo │ │ │ └── index.tsx │ │ ├── index.ts │ │ ├── lib │ │ │ ├── factories │ │ │ │ ├── blockKeybindFactory.ts │ │ │ │ ├── blockRegexFactory.ts │ │ │ │ ├── inlineBlockKeybindFactory.ts │ │ │ │ └── inlineBlockRegexFactory.ts │ │ │ ├── getEditorSelection.ts │ │ │ ├── handlePotentialBlockChange.ts │ │ │ ├── helpers │ │ │ │ ├── caret │ │ │ │ │ └── getCursorOffset.ts │ │ │ │ ├── checkKeybind.ts │ │ │ │ ├── checkModifers.ts │ │ │ │ ├── focusElement.ts │ │ │ │ ├── generateUUID.ts │ │ │ │ ├── getBlockByID.ts │ │ │ │ ├── getFirstLineLength.ts │ │ │ │ ├── getLastLineLength.ts │ │ │ │ ├── inlineBlocks │ │ │ │ │ ├── renderInlineBlocks.tsx │ │ │ │ │ └── saveInlineBlocks.ts │ │ │ │ ├── intervals │ │ │ │ │ ├── mergeIntervals.ts │ │ │ │ │ ├── optionalMergeIntervalValues.ts │ │ │ │ │ ├── splitOnNonNestables.ts │ │ │ │ │ └── xOrMergeIntervalValues.ts │ │ │ │ ├── isElementEditable.ts │ │ │ │ ├── isElementFocused.ts │ │ │ │ ├── mergeObjects.ts │ │ │ │ └── restoreSelection.ts │ │ │ ├── keybinds │ │ │ │ ├── handleDownArrowNavigation.ts │ │ │ │ └── handleUpArrowNavigation.ts │ │ │ └── postEditorMutations │ │ │ │ ├── focusAddedBlock.ts │ │ │ │ └── focusRemovedBlock.ts │ │ ├── mutations │ │ │ ├── addBlock.ts │ │ │ ├── editBlock.ts │ │ │ ├── index.ts │ │ │ ├── moveBlock.ts │ │ │ └── removeBlock.ts │ │ └── types │ │ │ ├── BlockRenderer.ts │ │ │ ├── BlockState.ts │ │ │ ├── InlineBlockRenderer.ts │ │ │ ├── Keybind.ts │ │ │ ├── KeybindHandler.ts │ │ │ ├── Plugin.ts │ │ │ ├── RichTextKeybindHandler.ts │ │ │ ├── SelectionState.ts │ │ │ └── helpers │ │ │ ├── RemoveFirstFromTuple.ts │ │ │ ├── ReverseArr.ts │ │ │ ├── Split.ts │ │ │ └── StringToUnion.ts │ ├── tsconfig.json │ ├── webpack.config.js │ └── yarn.lock ├── plugin-dnd │ ├── .babelrc │ ├── .gitignore │ ├── .npmignore │ ├── index.html │ ├── package.json │ ├── scripts │ │ └── build.sh │ ├── src │ │ ├── components │ │ │ ├── createHandle.tsx │ │ │ └── createWrapper.tsx │ │ ├── createDnDPlugin.ts │ │ ├── demo │ │ │ └── index.tsx │ │ └── index.ts │ ├── tsconfig.json │ ├── webpack.config.js │ └── yarn.lock ├── plugin-inline-link │ ├── .babelrc │ ├── .gitignore │ ├── .npmignore │ ├── index.html │ ├── package.json │ ├── scripts │ │ └── build.sh │ ├── src │ │ ├── components │ │ │ └── createFloatingLinkEditor.tsx │ │ ├── createInlineLinkRenderer.tsx │ │ └── demo │ │ │ └── index.tsx │ ├── tsconfig.json │ ├── webpack.config.js │ └── yarn.lock ├── plugin-math-latex │ ├── .babelrc │ ├── .gitignore │ ├── .npmignore │ ├── index.html │ ├── package.json │ ├── scripts │ │ └── build.sh │ ├── src │ │ ├── createMathRenderer.tsx │ │ ├── demo │ │ │ └── index.tsx │ │ └── index.ts │ ├── tsconfig.json │ ├── webpack.config.js │ └── yarn.lock ├── plugin-virtual-select │ ├── .babelrc │ ├── .gitignore │ ├── .npmignore │ ├── index.html │ ├── package.json │ ├── scripts │ │ └── build.sh │ ├── src │ │ ├── components │ │ │ └── createWrapper.tsx │ │ ├── createVirtualSelectPlugin.ts │ │ ├── demo │ │ │ └── index.tsx │ │ └── lib │ │ │ └── keybinds │ │ │ └── handleSelectAll.ts │ ├── tsconfig.json │ ├── webpack.config.js │ └── yarn.lock └── react-virtual-selection │ ├── .babelrc │ ├── .eslintrc.js │ ├── .gitignore │ ├── .npmignore │ ├── .swcrc │ ├── README.md │ ├── images │ └── Example.gif │ ├── index.html │ ├── package.json │ ├── postcss.config.js │ ├── src │ ├── classes │ │ └── SelectionManager.ts │ ├── components │ │ └── Selectable.tsx │ ├── demo │ │ ├── App.tsx │ │ └── components │ │ │ └── ExampleSelectable.tsx │ ├── hooks │ │ ├── useSelectable.ts │ │ └── useSelectionCollector.ts │ ├── index.ts │ └── index.tsx │ ├── tailwind.config.js │ ├── tsconfig.json │ ├── webpack.config.js │ └── yarn.lock ├── web ├── .eslintrc.js ├── .gitignore ├── Dockerfile ├── jest.config.js ├── package.json ├── postcss.config.js ├── src │ ├── components │ │ ├── Chat.tsx │ │ ├── Editor.tsx │ │ ├── LoadingPage.tsx │ │ ├── MenuBar.tsx │ │ ├── Spinner.tsx │ │ ├── blocks │ │ │ ├── BaseBlock.tsx │ │ │ ├── BlockHandle.tsx │ │ │ ├── Icon.tsx │ │ │ ├── MathBlock.tsx │ │ │ ├── PageBlock.tsx │ │ │ └── TextBlock.tsx │ │ ├── home │ │ │ ├── AuthButton.tsx │ │ │ ├── AuthNavBar.tsx │ │ │ ├── Info.tsx │ │ │ ├── Intro.tsx │ │ │ ├── LaptopScreen.tsx │ │ │ └── NavBar.tsx │ │ ├── menus │ │ │ ├── Button.tsx │ │ │ ├── DropDown.tsx │ │ │ ├── OptionsMenu │ │ │ │ └── OptionsMenu.tsx │ │ │ ├── ShareMenu │ │ │ │ ├── EmailShareMenu.tsx │ │ │ │ ├── ShareMenu.tsx │ │ │ │ ├── ShareOptionsDropdown.tsx │ │ │ │ └── UserPermission.tsx │ │ │ └── SlashMenu.tsx │ │ ├── modals │ │ │ ├── BaseModal.tsx │ │ │ ├── ExportModal.tsx │ │ │ └── SearchModal.tsx │ │ ├── pageCustomization │ │ │ ├── ColoursPicker.tsx │ │ │ ├── OptionsButton.tsx │ │ │ ├── PageThumbnail.tsx │ │ │ ├── ShareButton.tsx │ │ │ └── Title.tsx │ │ └── pageInfo │ │ │ ├── PagePath.tsx │ │ │ └── PageSidebar │ │ │ ├── PageSidebar.tsx │ │ │ └── PageSidebarItem.tsx │ ├── contexts │ │ ├── PageContext.ts │ │ └── PagePermissionsContext.ts │ ├── hooks │ │ └── useSlashMenu.tsx │ ├── lib │ │ ├── classes │ │ │ └── SaveManager.ts │ │ ├── config │ │ │ └── superTokensConfig.ts │ │ ├── constants │ │ │ ├── BlockTypes.ts │ │ │ ├── Colours.ts │ │ │ ├── InlineTextStyles.ts │ │ │ ├── ShareOptions.ts │ │ │ └── TextStyles.ts │ │ ├── deletePage.ts │ │ ├── helpers │ │ │ ├── caret │ │ │ │ ├── getCurrentCaretCoordinates.ts │ │ │ │ ├── getCursorOffset.ts │ │ │ │ ├── isAfterNewLine.ts │ │ │ │ ├── isCaretAtBottom.ts │ │ │ │ └── isCaretAtTop.ts │ │ │ ├── findNextBlock.ts │ │ │ ├── focusElement.ts │ │ │ ├── getCompletion.ts │ │ │ ├── getLastLineLength.ts │ │ │ ├── getOffsetCoordinates.ts │ │ │ ├── getStringDistance.ts │ │ │ ├── getStyleScale.ts │ │ │ ├── inlineBlocks │ │ │ │ ├── findNodesInRange.ts │ │ │ │ ├── handlePotentialInlineBlocks.ts │ │ │ │ ├── renderInlineBlocks.tsx │ │ │ │ └── renderNewInlineBlocks.ts │ │ │ ├── isElementFocused.ts │ │ │ └── saveBlock.ts │ │ ├── inlineTextKeybinds.ts │ │ ├── pageTrees │ │ │ ├── editPageTree.ts │ │ │ └── getPageTree.ts │ │ ├── pages │ │ │ ├── editStyle.ts │ │ │ ├── getPageInfo.ts │ │ │ └── updatePage.ts │ │ ├── textKeybinds.ts │ │ └── types │ │ │ ├── blockTypes.d.ts │ │ │ └── pageTypes.d.ts │ ├── next-env.d.ts │ ├── next.config.js │ ├── pages │ │ ├── _app.tsx │ │ ├── _document.tsx │ │ ├── auth │ │ │ ├── callback │ │ │ │ └── [provider].tsx │ │ │ └── index.tsx │ │ ├── index.tsx │ │ └── note-rack │ │ │ ├── [page].tsx │ │ │ └── index.tsx │ ├── public │ │ ├── Globe.svg │ │ ├── Memo.svg │ │ ├── blockExamples │ │ │ ├── Call Out Icon.svg │ │ │ ├── H1 Icon.svg │ │ │ ├── H2 Icon.svg │ │ │ ├── H3 Icon.svg │ │ │ ├── Math Icon.svg │ │ │ └── Quote Icon.svg │ │ ├── icons │ │ │ ├── Brain.svg │ │ │ └── Trash.svg │ │ ├── logos │ │ │ ├── apple.svg │ │ │ ├── github.svg │ │ │ └── google.svg │ │ └── promo │ │ │ ├── Biology-Notes-Example.png │ │ │ ├── Chat-Example.png │ │ │ ├── Lab-Report-Example.png │ │ │ ├── Math-Example.png │ │ │ ├── Notes-Example.png │ │ │ ├── Search-Example.png │ │ │ └── Share-Example.png │ ├── styles │ │ ├── emojiPicker.css │ │ └── globals.css │ └── tsconfig.json ├── tailwind.config.js └── yarn.lock └── yarn.lock /.gitignore: -------------------------------------------------------------------------------- 1 | # -=- MacOS Specific Ignores -=- 2 | **/.DS_Store 3 | 4 | # -=- Eroxl's Device Specific Ignores -=- 5 | Icon? 6 | !icons 7 | 8 | # -=- Node Modules -=- 9 | **/node_modules/** 10 | 11 | # -=- VS Code -=- 12 | **/.vscode 13 | 14 | # -=- Cypress -=- 15 | **/cypress/screenshots 16 | **/cypress/videos 17 | 18 | # -=- Security -=- 19 | /config/.env 20 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 |

3 | 📝 Note Rack 4 |

5 |
6 | 7 |
8 | Wakatime Note Rack stats 9 |
10 | 11 |
12 | 13 |
14 | 15 | 16 | ## 🌳 Features 17 | * Markdown 18 | * Headings (H1 - H5) 19 | * Quotes 20 | * Call Outs 21 | * Math 22 | * Inline Blocks 23 | * Bold 24 | * Italic 25 | * Underline 26 | * Strikethrough 27 | * Other Features 28 | * Global Search 29 | * [PDF Exporting](./images/Note%20Rack%20Page.pdf) 30 | * [Chat GPT Integration](https://github.com/Eroxl/Note-Rack/releases/tag/v1.0.5) 31 | 32 | ## 📄 Markdown Syntax 33 | - Headings 34 | - `#` - H1 35 | - `##` - H2 36 | - `###` - H3 37 | - `####` - H4 38 | - `#####` - H5 39 | - Inline Blocks 40 | - `**` Bold 41 | - `*` Italic 42 | - `__` Underline 43 | - `--` Strikethrough 44 | - Other 45 | - `>` Quote 46 | - `|` Callout 47 | - `$$` Math ([KaTeX](https://katex.org/)) 48 | - `[[ Page Name ]]` Page ("Page Name" can be any string) 49 | 50 | ## 🎹 Keyboard Shortcuts 51 | - `Ctrl + F` or `Cmd + F` Global Search 52 | - `Ctrl + P` or `Cmd + P` Save Page 53 | 54 | ## 📦 Installation 55 | 1. Clone the repo 56 | ```bash 57 | git clone https://github.com/Eroxl/Note-Rack.git 58 | ``` 59 | 60 | 2. Navigate to the repository 61 | ```bash 62 | cd ./Note-Rack 63 | ``` 64 | 65 | 3. Copy the server environment file and fill in the values 66 | ```bash 67 | cp ./config/.env.example ./config/.env 68 | ``` 69 | 70 | 4. Install Docker and Docker Compose 71 | - [Docker](https://docs.docker.com/get-docker/) 72 | 73 | 5. Run the Docker Compose file 74 | ```bash 75 | yarn start 76 | ``` 77 | 78 | 6. Navigate to the web application at [http://127.0.0.1:3000](http://127.0.0.1:3000) 79 | -------------------------------------------------------------------------------- /backend/.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | npm-debug.log 3 | .env.example -------------------------------------------------------------------------------- /backend/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | es2021: true, 4 | node: true, 5 | }, 6 | extends: ['eslint:recommended', 'airbnb'], 7 | parser: '@typescript-eslint/parser', 8 | parserOptions: { 9 | ecmaVersion: 13, 10 | sourceType: 'module', 11 | }, 12 | plugins: ['@typescript-eslint'], 13 | rules: { 14 | 'no-use-before-define': 'off', 15 | '@typescript-eslint/no-use-before-define': ['error'], 16 | 'import/order': [ 17 | 'error', 18 | { 19 | groups: [['external', 'builtin'], 'internal', ['parent', 'sibling', 'index']], 20 | 'newlines-between': 'always', 21 | }, 22 | ], 23 | 'import/extensions': 0, 24 | 'react/function-component-definition': 'off', 25 | 'no-underscore-dangle': { 26 | allow: ['_id'], 27 | }, 28 | }, 29 | settings: { 30 | 'import/resolver': { 31 | node: { 32 | extensions: ['.js', '.ts'], 33 | }, 34 | }, 35 | }, 36 | }; 37 | -------------------------------------------------------------------------------- /backend/.gitignore: -------------------------------------------------------------------------------- 1 | # -=- Enviornment Variables -=- 2 | **/.env 3 | 4 | # -=- TypeScript Output -=- 5 | dist/** -------------------------------------------------------------------------------- /backend/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:20.2.0 2 | 3 | WORKDIR /usr/src/ 4 | 5 | # -=- Install Packages -=- 6 | COPY package.json ./ 7 | COPY yarn.lock ./ 8 | RUN yarn 9 | 10 | # -=- Copy Source Code -=- 11 | COPY . . 12 | 13 | # -=- Expose The Port -=- 14 | EXPOSE 8000 15 | 16 | # -=- Build / Run The Code -=- 17 | CMD [ "yarn", "run", "build" ] -------------------------------------------------------------------------------- /backend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "backend", 3 | "version": "1.0.0", 4 | "description": "", 5 | "private": true, 6 | "main": "dist/index.js", 7 | "devDependencies": { 8 | "@types/cors": "^2.8.12", 9 | "@types/express": "^4.17.13", 10 | "@types/swagger-jsdoc": "^6.0.1", 11 | "@types/swagger-ui-express": "^4.1.3", 12 | "@types/yamljs": "^0.2.31", 13 | "@typescript-eslint/eslint-plugin": "^5.10.0", 14 | "@typescript-eslint/parser": "^5.10.0", 15 | "eslint": "^8.7.0", 16 | "eslint-config-airbnb": "^19.0.4", 17 | "eslint-plugin-import": "^2.25.4", 18 | "eslint-plugin-jsx-a11y": "^6.5.1", 19 | "eslint-plugin-react": "^7.28.0", 20 | "eslint-plugin-react-hooks": "^4.3.0", 21 | "ts-node": "^10.9.1", 22 | "ts-node-dev": "^2.0.0", 23 | "typescript": "^4.5.5" 24 | }, 25 | "scripts": { 26 | "dev": "ts-node-dev --respawn --transpile-only --ignore-watch node_modules src/index.ts", 27 | "build": "tsc && node ./" 28 | }, 29 | "keywords": [], 30 | "author": "", 31 | "license": "gpl-3.0", 32 | "dependencies": { 33 | "@elastic/elasticsearch": "^8.6.0", 34 | "@types/bcrypt": "^5.0.0", 35 | "@types/cookie-parser": "^1.4.2", 36 | "@types/ioredis": "^4.28.8", 37 | "@types/jsonwebtoken": "^8.5.8", 38 | "@types/node": "^17.0.10", 39 | "@zilliz/milvus2-sdk-node": "^2.2.7", 40 | "add": "^2.0.6", 41 | "bcrypt": "^5.0.1", 42 | "body-parser": "^1.19.1", 43 | "commonjs": "^0.0.1", 44 | "cors": "^2.8.5", 45 | "dotenv": "^14.2.0", 46 | "express": "^4.17.2", 47 | "fastest-levenshtein": "^1.0.16", 48 | "ioredis": "^4.28.5", 49 | "jsonwebtoken": "^8.5.1", 50 | "mongoose": "^6.1.7", 51 | "openai": "^3.2.1", 52 | "rate-limiter-flexible": "^2.3.6", 53 | "supertokens-node": "^12.1.3", 54 | "swagger-jsdoc": "^6.2.1", 55 | "swagger-ui-express": "^4.5.0", 56 | "yamljs": "^0.3.0", 57 | "yarn": "^1.22.19" 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /backend/src/helpers/addPage.ts: -------------------------------------------------------------------------------- 1 | import PageMapModel from '../models/pageMap'; 2 | import PageTreeModel from '../models/pageTreeModel'; 3 | import PageModel from '../models/pageModel'; 4 | 5 | const addPage = async ( 6 | page: string, 7 | username: string, 8 | newPageId?: string, 9 | newPageName?: string, 10 | pagePermissions?: { 11 | read: boolean, 12 | write: boolean, 13 | admin: boolean, 14 | username: string, 15 | }[], 16 | ) => { 17 | const pageMap = await PageMapModel.findById(page).lean(); 18 | 19 | if (!pageMap) { 20 | // NOTE:EROXL: Should never happen 21 | throw new Error('Page not found'); 22 | } 23 | 24 | const arrayFilters: Record[] = []; 25 | let queryString = 'subPages'; 26 | 27 | if (pageMap) { 28 | pageMap.pathToPage.push(page); 29 | pageMap.pathToPage.forEach((element: string, index: number) => { 30 | arrayFilters.push({ 31 | [`a${index}._id`]: element, 32 | }); 33 | 34 | queryString += `.$[a${index}].subPages`; 35 | }); 36 | } 37 | 38 | await PageTreeModel.updateOne( 39 | { 40 | _id: username, 41 | }, 42 | { 43 | $push: { 44 | [queryString]: { 45 | $each: [{ 46 | _id: newPageId, 47 | expanded: false, 48 | style: { 49 | colour: { 50 | r: 147, 51 | g: 197, 52 | b: 253, 53 | }, 54 | icon: '📝', 55 | name: newPageName || 'New Notebook', 56 | }, 57 | subPages: [], 58 | }], 59 | }, 60 | }, 61 | }, 62 | { 63 | arrayFilters, 64 | }, 65 | ); 66 | 67 | const newPageMap = pageMap !== null ? pageMap.pathToPage : []; 68 | 69 | await PageMapModel.create({ 70 | _id: newPageId, 71 | pathToPage: newPageMap, 72 | }); 73 | 74 | await PageModel.create({ 75 | _id: newPageId, 76 | user: username, 77 | style: { 78 | colour: { 79 | r: 147, 80 | g: 197, 81 | b: 253, 82 | }, 83 | icon: '📝', 84 | name: newPageName || 'New Notebook', 85 | }, 86 | permissions: pagePermissions || {}, 87 | data: [], 88 | }); 89 | }; 90 | 91 | export default addPage; 92 | -------------------------------------------------------------------------------- /backend/src/helpers/clients/ElasticSearchClient.ts: -------------------------------------------------------------------------------- 1 | import { Client } from '@elastic/elasticsearch'; 2 | 3 | if (!process.env.ELASTICSEARCH_URL && !process.env.ELASTICSEARCH_CLOUD_ID) { 4 | throw new Error('ELASTICSEARCH_URL is not defined or ELASTICSEARCH_CLOUD_ID is not defined'); 5 | } 6 | 7 | if (!process.env.ELASTICSEARCH_PASSWORD) { 8 | throw new Error('ELASTICSEARCH_PASSWORD is not defined'); 9 | } 10 | 11 | if (!process.env.ELASTICSEARCH_USERNAME) { 12 | throw new Error('ELASTICSEARCH_USERNAME is not defined'); 13 | } 14 | 15 | const URLConnection = process.env.ELASTICSEARCH_URL !== undefined 16 | ? { 17 | node: process.env.ELASTICSEARCH_URL as string, 18 | } 19 | : { 20 | cloud: { 21 | id: process.env.ELASTICSEARCH_CLOUD_ID as string, 22 | } 23 | } 24 | 25 | const client = new Client({ 26 | ...URLConnection, 27 | auth: { 28 | password: process.env.ELASTICSEARCH_PASSWORD, 29 | username: process.env.ELASTICSEARCH_USERNAME, 30 | }, 31 | }); 32 | 33 | client.indices.exists({ 34 | index: 'blocks', 35 | }) 36 | .then((exists) => { 37 | if (exists) return; 38 | 39 | client.indices.create({ 40 | index: 'blocks', 41 | }).then(() => { 42 | client.indices.putMapping({ 43 | index: 'blocks', 44 | body: { 45 | properties: { 46 | blockId: { 47 | type: 'keyword', 48 | }, 49 | content: { 50 | type: 'text', 51 | }, 52 | pageId: { 53 | type: 'keyword', 54 | }, 55 | userID: { 56 | type: 'keyword', 57 | }, 58 | }, 59 | }, 60 | }); 61 | }); 62 | }) 63 | .catch((err) => { 64 | if (err?.meta?.body?.error?.type !== 'resource_already_exists_exception') { 65 | console.error(err); 66 | } 67 | }); 68 | 69 | export default client; -------------------------------------------------------------------------------- /backend/src/helpers/clients/OpenAIClient.ts: -------------------------------------------------------------------------------- 1 | import { Configuration, OpenAIApi } from 'openai'; 2 | 3 | let OpenAIClient: OpenAIApi | undefined; 4 | 5 | if (process.env.NEXT_PUBLIC_IS_CHAT_ENABLED !== 'false') { 6 | 7 | if (!process.env.OPENAI_API_KEY) { 8 | throw new Error('OPENAI_API_KEY is not defined'); 9 | } 10 | 11 | const configuration = new Configuration({ 12 | apiKey: process.env.OPENAI_API_KEY, 13 | }); 14 | 15 | OpenAIClient = new OpenAIApi(configuration); 16 | } 17 | 18 | export default OpenAIClient; 19 | -------------------------------------------------------------------------------- /backend/src/helpers/exporters/exporter.d.ts: -------------------------------------------------------------------------------- 1 | import type { IPage } from '../../models/pageModel'; 2 | 3 | type exporter = (page: IPage) => Promise; 4 | 5 | export default exporter; 6 | -------------------------------------------------------------------------------- /backend/src/helpers/operations/queryAggregator.ts: -------------------------------------------------------------------------------- 1 | import PageModel from '../../models/pageModel'; 2 | 3 | const queryAggregator = async (queries: Promise>[], page: string) => { 4 | Promise.all(queries).then((results) => { 5 | PageModel.bulkWrite( 6 | results.map((query) => ({ 7 | updateOne: { 8 | filter: { 9 | _id: page, 10 | }, 11 | update: query[0], 12 | arrayFilters: query[1], 13 | upsert: true, 14 | }, 15 | })), 16 | { 17 | ordered: true, 18 | }, 19 | ); 20 | }); 21 | }; 22 | 23 | export default queryAggregator; 24 | -------------------------------------------------------------------------------- /backend/src/helpers/operations/queryGenerators/blocks/addBlockQuery.ts: -------------------------------------------------------------------------------- 1 | import { Types } from 'mongoose'; 2 | 3 | export interface addBlockQueryProps { 4 | 'doc-ids': string[] | undefined; 5 | 'new-block-type': string; 6 | 'new-block-index': number, 7 | 'new-block-properties': Record, 8 | 'new-block-id': string, 9 | } 10 | 11 | const addBlockQuery = async (props: unknown) => { 12 | const { 13 | 'doc-ids': docIDs, 14 | 'new-block-type': newBlockType, 15 | 'new-block-index': newBlockIndex, 16 | 'new-block-properties': newBlockProperties, 17 | 'new-block-id': newBlockId, 18 | } = props as addBlockQueryProps; 19 | 20 | const arrayFilters: Record[] = []; 21 | let queryString = 'data.'; 22 | 23 | if (docIDs) { 24 | docIDs.forEach((element, docIDIndex) => { 25 | arrayFilters.push({ 26 | [`a${docIDIndex}._id`]: new Types.ObjectId(element), 27 | }); 28 | 29 | if (docIDIndex < (docIDs.length - 1)) { 30 | queryString += `$[a${docIDIndex}].children.`; 31 | return; 32 | } 33 | 34 | queryString += `$[a${docIDIndex}]`; 35 | }); 36 | } 37 | 38 | return [ 39 | { 40 | $push: { 41 | [queryString !== 'data.' ? queryString : 'data']: { 42 | $each: [{ 43 | _id: newBlockId, 44 | blockType: newBlockType, 45 | properties: newBlockProperties, 46 | }], 47 | $position: newBlockIndex, 48 | }, 49 | }, 50 | }, 51 | arrayFilters, 52 | ]; 53 | }; 54 | 55 | export default addBlockQuery; 56 | -------------------------------------------------------------------------------- /backend/src/helpers/operations/queryGenerators/blocks/deleteBlockQuery.ts: -------------------------------------------------------------------------------- 1 | import { Types } from 'mongoose'; 2 | 3 | export interface deleteBlockQueryProps { 4 | 'doc-ids': string[]; 5 | } 6 | 7 | const deleteBlockQuery = async (props: unknown) => { 8 | const { 9 | 'doc-ids': docIDs, 10 | } = props as deleteBlockQueryProps; 11 | 12 | const arrayFilters: Record[] = []; 13 | let queryString = 'data'; 14 | 15 | (docIDs as string[]).forEach((element, index) => { 16 | if (index === docIDs.length - 1) return; 17 | 18 | queryString += `.$[a${index}].children`; 19 | arrayFilters.push({ 20 | [`a${index}._id`]: new Types.ObjectId(element), 21 | }); 22 | }); 23 | 24 | return [ 25 | { 26 | $pull: { 27 | [queryString]: { 28 | _id: docIDs[docIDs.length - 1], 29 | }, 30 | }, 31 | }, 32 | arrayFilters, 33 | ]; 34 | }; 35 | 36 | export default deleteBlockQuery; 37 | -------------------------------------------------------------------------------- /backend/src/helpers/operations/queryGenerators/blocks/editBlockQuery.ts: -------------------------------------------------------------------------------- 1 | import { Types } from 'mongoose'; 2 | 3 | export interface editBlockQueryProps { 4 | 'doc-ids': string[]; 5 | 'block-type': string, 6 | 'block-properties': Record, 7 | } 8 | 9 | const editBlockQuery = async (props: unknown) => { 10 | const { 11 | 'doc-ids': docIDs, 12 | 'block-type': blockType, 13 | 'block-properties': blockProperties, 14 | } = props as editBlockQueryProps; 15 | 16 | const arrayFilters: Record[] = []; 17 | let queryString = 'data.'; 18 | 19 | docIDs.forEach((element, index) => { 20 | arrayFilters.push({ 21 | [`a${index}._id`]: new Types.ObjectId(element), 22 | }); 23 | 24 | if (index < (docIDs.length - 1)) { 25 | queryString += `$[a${index}].children.`; 26 | return; 27 | } 28 | 29 | queryString += `$[a${index}]`; 30 | }); 31 | 32 | return [ 33 | { 34 | $set: { 35 | ...(blockType !== undefined && { [`${queryString}.blockType`]: blockType }), 36 | ...(blockProperties !== undefined && { [`${queryString}.properties`]: blockProperties }), 37 | }, 38 | }, 39 | arrayFilters, 40 | ]; 41 | }; 42 | 43 | export default editBlockQuery; 44 | -------------------------------------------------------------------------------- /backend/src/helpers/operations/queryGenerators/index.ts: -------------------------------------------------------------------------------- 1 | import addBlockQuery from './blocks/addBlockQuery'; 2 | import deleteBlockQuery from './blocks/deleteBlockQuery'; 3 | import editBlockQuery from './blocks/editBlockQuery'; 4 | 5 | const queryGenerator = (operations: Record[]) => { 6 | const completedOperations = operations.map((operation) => { 7 | // -=- Add Block -=- 8 | if (operation.type === 'addBlock') { 9 | return addBlockQuery(operation.data); 10 | } 11 | 12 | // -=- Delete Block -=- 13 | if (operation.type === 'deleteBlock') { 14 | return deleteBlockQuery(operation.data); 15 | } 16 | 17 | // -=- Edit Block -=- 18 | if (operation.type === 'editBlock') { 19 | return editBlockQuery(operation.data); 20 | } 21 | 22 | return Promise.resolve({}); 23 | }); 24 | 25 | return completedOperations; 26 | }; 27 | 28 | export default queryGenerator; 29 | -------------------------------------------------------------------------------- /backend/src/helpers/refreshEmbeds.ts: -------------------------------------------------------------------------------- 1 | import OpenAIClient from './clients/OpenAIClient'; 2 | import QdrantClient from './clients/QDrantClient'; 3 | 4 | export interface EmbedOperation { 5 | type: 'update' | 'delete'; 6 | id: string; 7 | context: string[]; 8 | value?: string; 9 | } 10 | 11 | /** 12 | * Update the embeds on a page in chunks. 13 | * 14 | * @param page The page to update the embeds for. 15 | * @param pageData The page data of the page that is being updated. 16 | */ 17 | const refreshEmbeds = async (updates: EmbedOperation[], page: string) => { 18 | await QdrantClient!.deletePoints( 19 | 'blocks', 20 | { 21 | must: [ 22 | { 23 | key: 'block_id', 24 | match: { 25 | any: updates.map((operation) => operation.id) 26 | }, 27 | }, 28 | { 29 | key: 'page_id', 30 | match: { 31 | value: page, 32 | } 33 | } 34 | ], 35 | }, 36 | ); 37 | 38 | const updateOperations = updates.filter((update) => update.type === 'update'); 39 | 40 | if (!updateOperations.length) { 41 | return; 42 | } 43 | 44 | const embeddings = await OpenAIClient!.createEmbedding({ 45 | input: updateOperations.map((operation) => operation.value), 46 | model: 'text-embedding-ada-002', 47 | }); 48 | 49 | const fieldsData = new Array(updateOperations.length) 50 | .fill(undefined) 51 | .map((_, index) => ({ 52 | id: Math.floor(Math.random() * 2 ** 64), 53 | vector: embeddings.data.data[index].embedding, 54 | payload: { 55 | block_id: updateOperations[index].id, 56 | page_id: page, 57 | content: updateOperations[index].value, 58 | context: updateOperations[index].context, 59 | }, 60 | })); 61 | 62 | await QdrantClient!.upsertPoints('blocks', fieldsData); 63 | }; 64 | 65 | export default refreshEmbeds; 66 | -------------------------------------------------------------------------------- /backend/src/index.ts: -------------------------------------------------------------------------------- 1 | import { middleware, errorHandler } from 'supertokens-node/framework/express'; 2 | import supertokens from 'supertokens-node'; 3 | import express from 'express'; 4 | import bodyParser from 'body-parser'; 5 | import mongoose from 'mongoose'; 6 | import dotenv from 'dotenv'; 7 | import cors from 'cors'; 8 | 9 | import setupAuth from './setupAuth'; 10 | 11 | // -=- Connect to MongoDB with dotenv file -=- 12 | dotenv.config(); 13 | mongoose.connect( 14 | process.env.MONGO_URL ?? '', 15 | ).catch((err) => { 16 | console.log(err); 17 | }); 18 | 19 | // -=- Setup express -=- 20 | const app = express(); 21 | const port = 8000; 22 | 23 | // -=- Setup Super Tokens -=- 24 | setupAuth(); 25 | 26 | // -=- Setup body parser -=- 27 | app.use(bodyParser.json()); 28 | 29 | // -=- Warnings -=- 30 | if (process.env.NEXT_PUBLIC_IS_CHAT_ENABLED === 'false') { 31 | console.log( 32 | '\x1b[1m\x1b[4m\x1b[33m%s\x1b[0m', 33 | 'Warning:', 34 | 'Chat features are disabled, please enable them in your .env file to use them!' 35 | ); 36 | } 37 | 38 | // -=- URL Info -=- 39 | const { 40 | WEBSITE_DOMAIN, 41 | } = process.env; 42 | 43 | // -=- Check URL Info Exists -=- 44 | if (!WEBSITE_DOMAIN) throw Error('Missing Website Domain'); 45 | 46 | // -=- Add CORS headers -=- 47 | app.use(cors({ 48 | origin: WEBSITE_DOMAIN, 49 | allowedHeaders: ['content-type', ...supertokens.getAllCORSHeaders()], 50 | methods: ['GET', 'PATCH', 'POST', 'DELETE', 'OPTIONS'], 51 | credentials: true, 52 | })); 53 | 54 | // -=- Add Super Tokens Middleware -=- 55 | app.use(middleware()); 56 | 57 | import routes from './routes/index'; 58 | 59 | // -=- Add API Routes -=- 60 | app.use('/', routes); 61 | 62 | // -=- Setup Super Tokens Error Handling -=- 63 | app.use(errorHandler()); 64 | 65 | console.log( 66 | '\x1b[1m\x1b[4m\x1b[32m%s\x1b[0m', 67 | 'Success:', 68 | `Server running on port ${port}` 69 | ); 70 | 71 | // -=- Start The Express Server -=- 72 | app.listen(port); 73 | -------------------------------------------------------------------------------- /backend/src/models/pageMap.ts: -------------------------------------------------------------------------------- 1 | import mongoose, { Schema } from 'mongoose'; 2 | 3 | export interface IPageMap { 4 | _id: string; 5 | pathToPage: string[]; 6 | } 7 | 8 | const PageMapScheme = new Schema({ 9 | _id: Schema.Types.String, 10 | pathToPage: [Schema.Types.String], 11 | }); 12 | 13 | const PageMapModel = mongoose.models.pageMap as mongoose.Model || mongoose.model('pageMap', PageMapScheme); 14 | 15 | export default PageMapModel; 16 | -------------------------------------------------------------------------------- /backend/src/models/pageModel.ts: -------------------------------------------------------------------------------- 1 | import mongoose, { Schema, Document } from 'mongoose'; 2 | 3 | export interface Block { 4 | blockType: string; 5 | properties: Record; 6 | children: Block[]; 7 | } 8 | 9 | export interface IPage extends Document { 10 | user: string; 11 | permissions: { 12 | [key: string]: { 13 | read: boolean; 14 | write: boolean; 15 | admin: boolean; 16 | email: string; 17 | }; 18 | }; 19 | style: {}; 20 | data: Block[]; 21 | } 22 | 23 | const PageSchema = new Schema({ 24 | user: String, 25 | permissions: {}, 26 | style: {}, 27 | data: [ 28 | { 29 | blockType: String, 30 | properties: {}, 31 | children: [], 32 | }, 33 | ], 34 | }); 35 | 36 | const PageModel = mongoose.models.page as mongoose.Model || mongoose.model('page', PageSchema); 37 | 38 | export default PageModel; 39 | -------------------------------------------------------------------------------- /backend/src/models/pageTreeModel.ts: -------------------------------------------------------------------------------- 1 | import mongoose, { Schema } from 'mongoose'; 2 | 3 | interface IPageTree { 4 | _id: string; 5 | expanded: boolean; 6 | style: {}; 7 | subPages: IPageTree[]; 8 | } 9 | 10 | const PageTreeSchema = new Schema({}); 11 | 12 | PageTreeSchema.add({ 13 | _id: Schema.Types.String, 14 | expanded: Schema.Types.Boolean, 15 | style: {}, 16 | subPages: [PageTreeSchema], 17 | }); 18 | 19 | const PageTreeModel = mongoose.models.pageTree as mongoose.Model || mongoose.model('pageTree', PageTreeSchema); 20 | 21 | export default PageTreeModel; 22 | -------------------------------------------------------------------------------- /backend/src/models/userModel.ts: -------------------------------------------------------------------------------- 1 | import mongoose, { Schema } from 'mongoose'; 2 | 3 | interface IUser { 4 | username: string; 5 | homePage: string; 6 | } 7 | 8 | const UserScheme = new Schema({ 9 | username: { 10 | type: Schema.Types.String, 11 | required: true, 12 | unique: true, 13 | }, 14 | homePage: Schema.Types.String, 15 | }); 16 | 17 | const UserModel = mongoose.models.user as mongoose.Model || mongoose.model('user', UserScheme); 18 | 19 | export default UserModel; 20 | -------------------------------------------------------------------------------- /backend/src/routes/account/editPageTreeExpansion.ts: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import { SessionRequest } from 'supertokens-node/framework/express'; 3 | import { verifySession } from 'supertokens-node/recipe/session/framework/express'; 4 | 5 | import PageMapModel from '../../models/pageMap'; 6 | import PageTreeModel from '../../models/pageTreeModel'; 7 | 8 | const router = express.Router(); 9 | 10 | router.patch( 11 | '/edit-page-tree/:page', 12 | verifySession(), 13 | async (req: SessionRequest, res) => { 14 | const username = req.session!.getUserId(); 15 | const { page } = req.params; 16 | const { 'new-expansion-state': newExpansionState } = req.body; 17 | 18 | if (!username) { 19 | res.statusCode = 401; 20 | res.json({ 21 | status: 'error', 22 | message: 'Please login to view your page tree!', 23 | }); 24 | return; 25 | } 26 | 27 | const pageMap = await PageMapModel.findById(page).lean(); 28 | 29 | const arrayFilters: Record[] = []; 30 | let queryString = 'subPages'; 31 | 32 | if (pageMap) { 33 | pageMap.pathToPage.push(page); 34 | 35 | pageMap.pathToPage.forEach((element: string, index: number) => { 36 | arrayFilters.push({ 37 | [`a${index}._id`]: element, 38 | }); 39 | 40 | if (index < (pageMap.pathToPage.length - 1)) { 41 | queryString += `.$[a${index}].subPages`; 42 | } else { 43 | queryString += `.$[a${index}]`; 44 | } 45 | }); 46 | } 47 | 48 | await PageTreeModel.updateOne( 49 | { 50 | _id: username, 51 | }, 52 | { 53 | $set: { 54 | [`${queryString}.expanded`]: newExpansionState, 55 | }, 56 | }, 57 | { 58 | arrayFilters, 59 | }, 60 | ); 61 | 62 | res.statusCode = 200; 63 | res.json({ 64 | status: 'success', 65 | message: 'Succesfully changed page tree expansion state', 66 | }); 67 | }, 68 | ); 69 | 70 | export default router; 71 | -------------------------------------------------------------------------------- /backend/src/routes/account/getPageTree.ts: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import { SessionRequest } from 'supertokens-node/framework/express'; 3 | import { verifySession } from 'supertokens-node/recipe/session/framework/express'; 4 | 5 | import PageTreeModel from '../../models/pageTreeModel'; 6 | 7 | const router = express.Router(); 8 | 9 | router.get( 10 | '/get-page-tree', 11 | verifySession(), 12 | async (req: SessionRequest, res) => { 13 | const username = req.session!.getUserId(); 14 | 15 | if (!username) { 16 | res.statusCode = 401; 17 | res.json({ 18 | status: 'error', 19 | message: 'Please login to view your page tree!', 20 | }); 21 | return; 22 | } 23 | 24 | const userTree = await PageTreeModel.findById(username).lean(); 25 | 26 | if (!userTree) { 27 | res.statusCode = 401; 28 | res.json({ 29 | status: 'error', 30 | message: 'Your account does not have a tree! Please login with a different account...', 31 | }); 32 | return; 33 | } 34 | 35 | res.statusCode = 200; 36 | res.json({ 37 | status: 'success', 38 | message: userTree, 39 | }); 40 | }, 41 | ); 42 | 43 | export default router; 44 | -------------------------------------------------------------------------------- /backend/src/routes/account/index.ts: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | 3 | import editPageTreeExpansion from './editPageTreeExpansion'; 4 | import getPageTree from './getPageTree'; 5 | import search from './search'; 6 | 7 | const router = express.Router(); 8 | 9 | // -=- Create Get Page Tree API -=- 10 | router.use( 11 | '/', 12 | getPageTree, 13 | ); 14 | 15 | // -=- Create Edit Page Tree API -=- 16 | router.use( 17 | '/', 18 | editPageTreeExpansion, 19 | ); 20 | 21 | // -=- Create Search API -=- 22 | router.use( 23 | '/', 24 | search, 25 | ); 26 | 27 | export default router; 28 | -------------------------------------------------------------------------------- /backend/src/routes/account/search.ts: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import { SessionRequest } from 'supertokens-node/framework/express'; 3 | import { verifySession } from 'supertokens-node/recipe/session/framework/express'; 4 | import ElasticSearchClient from '../../helpers/clients/ElasticSearchClient'; 5 | 6 | const router = express.Router(); 7 | 8 | router.get( 9 | '/search', 10 | verifySession(), 11 | async (req: SessionRequest, res) => { 12 | const username = req.session!.getUserId(); 13 | const filter = req.query.filter; 14 | 15 | if (typeof filter !== 'string') { 16 | res.statusCode = 401; 17 | res.json({ 18 | status: 'error', 19 | message: 'Please enter a search term!', 20 | }); 21 | return; 22 | } 23 | 24 | const results = await ElasticSearchClient.search({ 25 | index: 'blocks', 26 | query: { 27 | bool: { 28 | must: { 29 | match: { 30 | content: filter, 31 | }, 32 | }, 33 | filter: { 34 | term: { 35 | userID: username, 36 | }, 37 | }, 38 | }, 39 | }, 40 | highlight: { 41 | fields: { 42 | content: {}, 43 | }, 44 | number_of_fragments: 1, 45 | } 46 | }); 47 | 48 | res.statusCode = 200; 49 | res.json({ 50 | status: 'success', 51 | message: results.hits.hits.map((hit) => { 52 | const sources = (hit?._source as Record) ?? {}; 53 | 54 | return ({ 55 | content: (hit?.highlight?.content || [''])[0], 56 | blockID: sources?.blockId || '', 57 | pageID: sources?.pageId || '', 58 | }) 59 | }) 60 | }); 61 | }, 62 | ); 63 | 64 | export default router; 65 | -------------------------------------------------------------------------------- /backend/src/routes/index.ts: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | 3 | import pageRouter from './page/index'; 4 | import accountRouter from './account/index'; 5 | 6 | const router = express.Router(); 7 | 8 | // -=- Account API -=- 9 | router.use( 10 | '/account/', 11 | accountRouter, 12 | ); 13 | 14 | // -=- Pages API -=- 15 | router.use( 16 | '/page/', 17 | pageRouter, 18 | ); 19 | 20 | export default router; 21 | -------------------------------------------------------------------------------- /backend/src/routes/page/chat.ts: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import type { SessionRequest } from 'supertokens-node/framework/express'; 3 | import verifyPermissions from '../../middleware/verifyPermissions'; 4 | 5 | import type { ChatCompletionRequestMessage } from 'openai'; 6 | 7 | import getChatResponse from '../../helpers/getChatResponse'; 8 | 9 | const router = express.Router(); 10 | 11 | router.get( 12 | '/chat/:page', 13 | verifyPermissions(['read']), 14 | async (req: SessionRequest, res) => { 15 | const { page } = req.params; 16 | const { message, previousMessages } = req.query; 17 | 18 | if (process.env.NEXT_PUBLIC_IS_CHAT_ENABLED === 'false') { 19 | res.statusCode = 401; 20 | res.json({ 21 | status: 'error', 22 | message: 'Chat is not enabled!', 23 | }); 24 | return; 25 | } 26 | 27 | if (typeof message !== 'string') { 28 | res.statusCode = 401; 29 | res.json({ 30 | status: 'error', 31 | message: 'Please enter a message!', 32 | }); 33 | return; 34 | } 35 | 36 | if (previousMessages && typeof previousMessages !== 'string') { 37 | res.statusCode = 401; 38 | res.json({ 39 | status: 'error', 40 | message: 'Please enter a valid previousMessages!', 41 | }); 42 | return; 43 | } 44 | 45 | let messages: ChatCompletionRequestMessage[] = []; 46 | 47 | if (previousMessages) { 48 | try { 49 | const parsedPreviousMessages = JSON.parse(previousMessages) as ChatCompletionRequestMessage[]; 50 | 51 | if (!Array.isArray(parsedPreviousMessages)) throw new Error('previousMessages is not an array'); 52 | 53 | messages = parsedPreviousMessages.slice(-10); 54 | } catch (e) { 55 | res.statusCode = 401; 56 | res.json({ 57 | status: 'error', 58 | message: 'Please enter a valid previousMessages!', 59 | }); 60 | return; 61 | } 62 | } 63 | 64 | if (messages.length > 10) { 65 | messages = messages.slice(-10); 66 | } 67 | 68 | await getChatResponse( 69 | messages, 70 | message, 71 | page, 72 | res 73 | ); 74 | } 75 | ) 76 | 77 | export default router; -------------------------------------------------------------------------------- /backend/src/routes/page/complete.ts: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import { verifySession } from 'supertokens-node/recipe/session/framework/express'; 3 | 4 | import OpenAIClient from '../../helpers/clients/OpenAIClient'; 5 | 6 | const router = express.Router(); 7 | 8 | router.get( 9 | '/complete', 10 | verifySession(), 11 | async (req, res) => { 12 | const { context } = req.query; 13 | 14 | if (process.env.NEXT_PUBLIC_IS_CHAT_ENABLED === 'false') { 15 | res.statusCode = 401; 16 | res.json({ 17 | status: 'error', 18 | message: 'Chat is not enabled!', 19 | }); 20 | return; 21 | } 22 | 23 | if (typeof context !== 'string' || !context) { 24 | res.statusCode = 401; 25 | res.json({ 26 | status: 'error', 27 | message: 'Please enter a context!', 28 | }); 29 | return; 30 | } 31 | 32 | const response = await OpenAIClient!.createCompletion({ 33 | model: 'text-davinci-003', 34 | prompt: context.slice(1, -1), 35 | stop: ['\n', '\\n'], 36 | max_tokens: 10, 37 | n: 1, 38 | }); 39 | 40 | res.statusCode = 200; 41 | res.json({ 42 | status: 'success', 43 | message: response.data.choices[0].text || '', 44 | }); 45 | }, 46 | ); 47 | 48 | export default router; 49 | -------------------------------------------------------------------------------- /backend/src/routes/page/export.ts: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | 3 | import verifyPermissions from '../../middleware/verifyPermissions'; 4 | import type { PageRequest } from '../../middleware/verifyPermissions'; 5 | 6 | import mdExporter from '../../helpers/exporters/mdExporter'; 7 | 8 | const router = express.Router(); 9 | 10 | router.get( 11 | '/export/:page', 12 | verifyPermissions(['read']), 13 | async (req: PageRequest, res) => { 14 | const pageData = req.pageData!; 15 | 16 | const format = req.query.format as string | undefined; 17 | 18 | if (!format) { 19 | res.statusCode = 400; 20 | res.json({ 21 | status: 'error', 22 | message: { 23 | statusMessage: 'No format specified!', 24 | }, 25 | }); 26 | return; 27 | } 28 | 29 | if (format !== 'md') { 30 | res.statusCode = 400; 31 | res.json({ 32 | status: 'error', 33 | message: { 34 | statusMessage: 'Invalid format specified!', 35 | }, 36 | }); 37 | return; 38 | } 39 | 40 | res.setHeader('Access-Control-Expose-Headers','Content-Disposition'); 41 | 42 | // ~ NOTE:EROXL: Could re-write this way better but I'm lazy 43 | if (format === 'md') { 44 | res.statusCode = 200; 45 | res.setHeader('Content-Type', 'text/plain'); 46 | res.attachment(`${(pageData.style as {name: string})?.name}.md`); 47 | 48 | res.send(await mdExporter(pageData)); 49 | } 50 | }, 51 | ); 52 | 53 | export default router; 54 | -------------------------------------------------------------------------------- /backend/src/routes/page/getHomePage.ts: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | import { SessionRequest } from 'supertokens-node/framework/express'; 3 | import { verifySession } from 'supertokens-node/recipe/session/framework/express'; 4 | 5 | import UserModel from '../../models/userModel'; 6 | 7 | const router = express.Router(); 8 | 9 | router.post( 10 | '/get-home-page', 11 | verifySession(), 12 | async (req: SessionRequest, res) => { 13 | const username = req.session?.getUserId(); 14 | 15 | if (!username) { 16 | res.statusCode = 401; 17 | res.json({ 18 | status: 'error', 19 | message: 'Please login to view your home page!', 20 | }); 21 | return; 22 | } 23 | 24 | const user = await UserModel.findOne({ username }).lean(); 25 | 26 | if (!user) { 27 | res.statusCode = 401; 28 | res.json({ 29 | status: 'error', 30 | message: 'Your account does not exist! Please login with a different account...', 31 | }); 32 | return; 33 | } 34 | 35 | if (!user.homePage) { 36 | res.statusCode = 401; 37 | res.json({ 38 | status: 'error', 39 | message: 'Your account does not have a homepage! Please verify your account to gain one...', 40 | }); 41 | return; 42 | } 43 | 44 | res.statusCode = 200; 45 | res.json({ 46 | status: 'success', 47 | message: user.homePage, 48 | }); 49 | }, 50 | ); 51 | 52 | export default router; 53 | -------------------------------------------------------------------------------- /backend/src/routes/page/getPage.ts: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | 3 | import verifyPermissions from '../../middleware/verifyPermissions'; 4 | import type { PageRequest } from '../../middleware/verifyPermissions'; 5 | 6 | const router = express.Router(); 7 | 8 | router.get( 9 | '/get-page/:page', 10 | verifyPermissions(['read']), 11 | async (req: PageRequest, res) => { 12 | const pageData = req.pageData!; 13 | const permissions = req.permissions!; 14 | 15 | res.statusCode = 200; 16 | res.json({ 17 | status: 'success', 18 | message: { 19 | style: pageData.style, 20 | data: pageData.data, 21 | userPermissions: { 22 | read: true, 23 | write: permissions.includes('write'), 24 | admin: permissions.includes('admin'), 25 | }, 26 | ...( 27 | permissions.includes('admin') 28 | ? { permissions: pageData.permissions } 29 | : {} 30 | ) 31 | }, 32 | }); 33 | }, 34 | ); 35 | 36 | export default router; 37 | -------------------------------------------------------------------------------- /backend/src/routes/page/getPageInfo.ts: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | 3 | import verifyPermissions from '../../middleware/verifyPermissions'; 4 | import type { PageRequest } from '../../middleware/verifyPermissions'; 5 | 6 | const router = express.Router(); 7 | 8 | router.get( 9 | '/get-page-info/:page', 10 | verifyPermissions(['read']), 11 | async (req: PageRequest, res) => { 12 | const pageData = req.pageData!; 13 | 14 | res.statusCode = 200; 15 | res.json({ 16 | status: 'success', 17 | message: { 18 | style: pageData.style, 19 | }, 20 | }); 21 | }, 22 | ); 23 | 24 | export default router; 25 | -------------------------------------------------------------------------------- /backend/src/routes/page/index.ts: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | 3 | import modifyPage from './modifyPage'; 4 | import modify from './modify'; 5 | import getHomePage from './getHomePage'; 6 | import getPage from './getPage'; 7 | import getPageInfo from './getPageInfo'; 8 | import updatePermissions from './updatePermissions'; 9 | import chat from './chat'; 10 | import complete from './complete'; 11 | import exportFile from './export'; 12 | 13 | const router = express.Router(); 14 | 15 | // -=- Create Get Home Page API -=- 16 | router.use( 17 | '/', 18 | getHomePage, 19 | ); 20 | 21 | // -=- Create Get Page API -=- 22 | router.use( 23 | '/', 24 | getPage, 25 | ); 26 | 27 | // -=- Create Get Page Info API -=- 28 | router.use( 29 | '/', 30 | getPageInfo, 31 | ); 32 | 33 | // -=- Create Modify Block API -=- 34 | router.use( 35 | '/', 36 | modify, 37 | ); 38 | 39 | // -=- Create Update Permissions API -=- 40 | router.use( 41 | '/', 42 | updatePermissions, 43 | ); 44 | 45 | // -=- Create Chat API -=- 46 | router.use( 47 | '/', 48 | chat, 49 | ); 50 | 51 | // -=- Create Autocomplete API -=- 52 | router.use( 53 | '/', 54 | complete, 55 | ); 56 | 57 | // -=- Create Modify Page API -=- 58 | router.use( 59 | '/modify-page/', 60 | modifyPage, 61 | ); 62 | 63 | // -=- Create Export API -=- 64 | router.use( 65 | '/', 66 | exportFile, 67 | ); 68 | 69 | export default router; 70 | -------------------------------------------------------------------------------- /backend/src/routes/page/modifyPage/addPage.ts: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | 3 | import addPage from '../../../helpers/addPage'; 4 | import verifyPermissions from '../../../middleware/verifyPermissions'; 5 | import type { PageRequest } from '../../../middleware/verifyPermissions'; 6 | 7 | const router = express.Router(); 8 | 9 | router.post( 10 | '/:page/', 11 | verifyPermissions(['write']), 12 | async (req: PageRequest, res) => { 13 | const pageOwner = req.pageData!.user; 14 | 15 | const { page } = req.params; 16 | const { 17 | 'new-page-id': newPageId, 18 | 'new-page-name': newPageName, 19 | } = req.body; 20 | 21 | await addPage( 22 | page, 23 | pageOwner, 24 | newPageId, 25 | newPageName, 26 | ); 27 | 28 | res.statusCode = 200; 29 | res.json({ 30 | status: 'success', 31 | message: { 32 | statusMessage: 'Succesfully created page!', 33 | }, 34 | }); 35 | }, 36 | ); 37 | 38 | export default router; 39 | -------------------------------------------------------------------------------- /backend/src/routes/page/modifyPage/deletePage.ts: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | 3 | import deletePage from '../../../helpers/deletePage'; 4 | import verifyPermissions from '../../../middleware/verifyPermissions'; 5 | import type { PageRequest } from '../../../middleware/verifyPermissions'; 6 | 7 | const router = express.Router(); 8 | 9 | router.delete( 10 | '/:page/', 11 | verifyPermissions(['admin']), 12 | async (req: PageRequest, res) => { 13 | const { page } = req.params; 14 | const pageOwner = req.pageData!.user; 15 | 16 | await deletePage( 17 | page, 18 | pageOwner, 19 | ); 20 | 21 | res.statusCode = 200; 22 | res.json({ 23 | status: 'success', 24 | message: { 25 | statusMessage: 'Succesfully deleted page!', 26 | }, 27 | }); 28 | }, 29 | ); 30 | 31 | export default router; 32 | -------------------------------------------------------------------------------- /backend/src/routes/page/modifyPage/index.ts: -------------------------------------------------------------------------------- 1 | import express from 'express'; 2 | 3 | import addPage from './addPage'; 4 | import editPage from './editPage'; 5 | import deletePage from './deletePage'; 6 | 7 | const router = express.Router(); 8 | 9 | router.use( 10 | '/', 11 | editPage, 12 | ); 13 | 14 | router.use( 15 | '/', 16 | addPage, 17 | ); 18 | 19 | router.use( 20 | '/', 21 | deletePage, 22 | ); 23 | 24 | export default router; 25 | -------------------------------------------------------------------------------- /backend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es6", 4 | "allowJs": true, 5 | "skipLibCheck": true, 6 | "strict": true, 7 | "forceConsistentCasingInFileNames": true, 8 | "esModuleInterop": true, 9 | "module": "commonjs", 10 | "moduleResolution": "Node", 11 | "resolveJsonModule": true, 12 | "incremental": true, 13 | "outDir": "dist", 14 | "sourceMap": true, 15 | "lib": [ 16 | "dom", 17 | "es6" 18 | ], 19 | }, 20 | "include": ["**/*.ts"], 21 | "exclude": ["node_modules"] 22 | } 23 | -------------------------------------------------------------------------------- /cypress.config.js: -------------------------------------------------------------------------------- 1 | const { defineConfig } = require("cypress"); 2 | 3 | module.exports = defineConfig({ 4 | e2e: { 5 | setupNodeEvents(on, config) { 6 | // implement node event listeners here 7 | }, 8 | }, 9 | }); 10 | -------------------------------------------------------------------------------- /cypress/e2e/login.cy.js: -------------------------------------------------------------------------------- 1 | describe('Login and Signup Page', () => { 2 | it('Navigates to the login page', () => { 3 | cy.visit('http://127.0.0.1:3000'); 4 | cy.get('a[href="/login#"]').click(); 5 | 6 | cy.url().should('include', '/login'); 7 | }); 8 | 9 | it('Switches to the signup page', () => { 10 | cy.visit('http://127.0.0.1:3000/login'); 11 | cy.get('button').contains('Sign Up').click(); 12 | 13 | cy.get('button[type="submit"]').contains('Sign up').should('exist'); 14 | }); 15 | 16 | it('Registers with valid credentials', () => { 17 | cy.visit('http://127.0.0.1:3000/login'); 18 | cy.get('button[type="button"]').contains('Sign Up').click(); 19 | 20 | cy.get('input[name="username"]').type('test'); 21 | cy.get('input[name="email"]').type('test@test.com'); 22 | cy.get('input[name="password"]').type('test'); 23 | 24 | cy.get('button[type="submit"]').contains('Sign up').click(); 25 | 26 | cy.url().should('include', '/login'); 27 | }); 28 | 29 | it('Logs in with valid credentials', () => { 30 | cy.visit('http://127.0.0.1:3000/login'); 31 | 32 | cy.get('input[name="email"]').type('test@test.com'); 33 | cy.get('input[name="password"]').type('test'); 34 | 35 | cy.get('button[type="submit"]').contains('Log in').click(); 36 | 37 | cy.url().should('include', '/note-rack/'); 38 | }); 39 | }); 40 | -------------------------------------------------------------------------------- /cypress/fixtures/example.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Using fixtures to represent data", 3 | "email": "hello@cypress.io", 4 | "body": "Fixtures are a great way to mock data for responses to routes" 5 | } 6 | -------------------------------------------------------------------------------- /cypress/support/commands.js: -------------------------------------------------------------------------------- 1 | // *********************************************** 2 | // This example commands.js shows you how to 3 | // create various custom commands and overwrite 4 | // existing commands. 5 | // 6 | // For more comprehensive examples of custom 7 | // commands please read more here: 8 | // https://on.cypress.io/custom-commands 9 | // *********************************************** 10 | // 11 | // 12 | // -- This is a parent command -- 13 | // Cypress.Commands.add('login', (email, password) => { ... }) 14 | // 15 | // 16 | // -- This is a child command -- 17 | // Cypress.Commands.add('drag', { prevSubject: 'element'}, (subject, options) => { ... }) 18 | // 19 | // 20 | // -- This is a dual command -- 21 | // Cypress.Commands.add('dismiss', { prevSubject: 'optional'}, (subject, options) => { ... }) 22 | // 23 | // 24 | // -- This will overwrite an existing command -- 25 | // Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... }) -------------------------------------------------------------------------------- /cypress/support/e2e.js: -------------------------------------------------------------------------------- 1 | // *********************************************************** 2 | // This example support/e2e.js is processed and 3 | // loaded automatically before your test files. 4 | // 5 | // This is a great place to put global configuration and 6 | // behavior that modifies Cypress. 7 | // 8 | // You can change the location of this file or turn off 9 | // automatically serving support files with the 10 | // 'supportFile' configuration option. 11 | // 12 | // You can read more here: 13 | // https://on.cypress.io/configuration 14 | // *********************************************************** 15 | 16 | // Import commands.js using ES2015 syntax: 17 | import './commands' 18 | 19 | // Alternatively you can use CommonJS syntax: 20 | // require('./commands') -------------------------------------------------------------------------------- /images/Desktop_Current_State_Dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Eroxl/Note-Rack/1e457868c76256bf8de2390bd09608e3f6927c9e/images/Desktop_Current_State_Dark.png -------------------------------------------------------------------------------- /images/Note Rack Page.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Eroxl/Note-Rack/1e457868c76256bf8de2390bd09608e3f6927c9e/images/Note Rack Page.pdf -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "note-rack", 3 | "version": "1.0.0", 4 | "description": "Note Rack", 5 | "main": "index.js", 6 | "devDependencies": { 7 | "cypress": "^11.2.0", 8 | "ts-node": "^10.9.1", 9 | "typescript": "^5.0.4" 10 | }, 11 | "scripts": { 12 | "test": "docker compose down -v && docker-compose build && docker-compose up -d && cypress run --browser chrome", 13 | "test:manual": "docker-compose down -v && docker-compose build && docker-compose up -d && cypress open", 14 | "setup-env": "ts-node ./config/load-env.ts", 15 | "dev:down": "docker compose --env-file ./config/.env --file ./docker-compose.dev.yaml down", 16 | "dev:build": "docker compose --env-file ./config/.env --file ./docker-compose.dev.yaml build", 17 | "dev": "yarn dev:build && docker compose --env-file ./config/.env --file ./docker-compose.dev.yaml up", 18 | "build": "docker compose --env-file ./config/.env build", 19 | "start": "yarn build && docker compose --env-file ./config/.env up", 20 | "start:down": "docker compose --env-file ./config/.env down" 21 | }, 22 | "repository": { 23 | "type": "git", 24 | "url": "git+https://github.com/Eroxl/Note-Rack.git" 25 | }, 26 | "keywords": [], 27 | "author": "", 28 | "license": "ISC", 29 | "bugs": { 30 | "url": "https://github.com/Eroxl/Note-Rack/issues" 31 | }, 32 | "homepage": "https://github.com/Eroxl/Note-Rack#readme", 33 | "dependencies": {} 34 | } 35 | -------------------------------------------------------------------------------- /packages/editor/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "@babel/preset-env", 4 | "@babel/preset-react", 5 | "@babel/preset-typescript" 6 | ], 7 | "plugins": [ 8 | "@babel/plugin-syntax-dynamic-import" 9 | ] 10 | } -------------------------------------------------------------------------------- /packages/editor/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/** 2 | dist/** -------------------------------------------------------------------------------- /packages/editor/.npmignore: -------------------------------------------------------------------------------- 1 | __tests__/** 2 | demo/** -------------------------------------------------------------------------------- /packages/editor/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Project 1 7 | 8 | 9 |
10 | 11 | 12 | -------------------------------------------------------------------------------- /packages/editor/jest.config.cjs: -------------------------------------------------------------------------------- 1 | /** @type {import('ts-jest').JestConfigWithTsJest} */ 2 | module.exports = { 3 | globals: { 4 | 'ts-jest': { 5 | useESM: true, 6 | tsconfig: { 7 | verbatimModuleSyntax: false, 8 | }, 9 | }, 10 | }, 11 | preset: 'ts-jest/presets/default-esm', 12 | testEnvironment: 'node', 13 | };`` -------------------------------------------------------------------------------- /packages/editor/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@note-rack/editor", 3 | "description": "A rich text editor for Note Rack", 4 | "version": "1.0.19", 5 | "module": "dist/index.js", 6 | "umd": "dist/index.umd.js", 7 | "main": "dist/index.js", 8 | "types": "dist/index.d.ts", 9 | "repository": "https://github.com/Eroxl/Note-Rack/tree/main/packages/react-virtual-selection", 10 | "author": "Eroxl", 11 | "license": "AGPL-3.0-only", 12 | "private": false, 13 | "type": "commonjs", 14 | "files": [ 15 | "src", 16 | "dist" 17 | ], 18 | "scripts": { 19 | "dev": "webpack-dev-server --hot --config ./webpack.config.js", 20 | "build": "sh ./scripts/build.sh", 21 | "test": "jest" 22 | }, 23 | "devDependencies": { 24 | "@babel/core": "^7.23.7", 25 | "@babel/plugin-syntax-dynamic-import": "^7.8.3", 26 | "@babel/preset-env": "^7.23.7", 27 | "@babel/preset-react": "^7.23.3", 28 | "@babel/preset-typescript": "^7.23.3", 29 | "@jest/globals": "^29.7.0", 30 | "@types/dompurify": "^3.0.5", 31 | "@types/react": "^18.2.28", 32 | "@types/react-dom": "^18.2.13", 33 | "@webpack-cli/generators": "^3.0.7", 34 | "babel-loader": "^9.1.3", 35 | "html-webpack-plugin": "^5.6.0", 36 | "jest": "^29.7.0", 37 | "react": "^18.2.0", 38 | "react-dom": "^18.2.0", 39 | "swc": "^1.0.11", 40 | "ts-jest": "^29.1.1", 41 | "ts-loader": "^9.5.1", 42 | "webpack": "^5.89.0", 43 | "webpack-cli": "^5.1.4", 44 | "webpack-dev-server": "^4.15.1" 45 | }, 46 | "dependencies": { 47 | "dompurify": "^3.0.6" 48 | }, 49 | "peerDependencies": { 50 | "react": "<18.0.0", 51 | "react-dom": "<18.0.0" 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /packages/editor/scripts/build.sh: -------------------------------------------------------------------------------- 1 | # ~ Compile the typescript code 2 | swc -C module.type=commonjs -d dist src/ 3 | 4 | # ~ Compile the typescript types 5 | npx tsc -b . 6 | 7 | # ~ Copy the package.json and .npmignore file to the dist folder 8 | cp package.json dist/package.json 9 | cp .npmignore dist/.npmignore 10 | 11 | # NOTE: The -e flag is used to make the following sed command work on MacOS 12 | 13 | # ~ Remove the dist folder from the package.json file 14 | sed -i -e 's|"dist/|"./|g' ./dist/package.json 15 | sed -i -e '/"files": \[/,/\]/d' ./dist/package.json 16 | 17 | # ~ Remove the backup file created by the sed command 18 | rm ./dist/package.json-e 19 | -------------------------------------------------------------------------------- /packages/editor/src/__tests__/mutations/addBlock.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from '@jest/globals'; 2 | 3 | import addBlock from "../../mutations/addBlock"; 4 | import type BlockState from '../../types/BlockState'; 5 | 6 | describe('addBlock', () => { 7 | const state: BlockState[] = [ 8 | { 9 | id: '1', 10 | type: 'text', 11 | properties: {} 12 | }, 13 | { 14 | id: '2', 15 | type: 'text', 16 | properties: {} 17 | } 18 | ] 19 | 20 | const newBlock = { 21 | id: '3', 22 | type: 'text', 23 | properties: {} 24 | }; 25 | 26 | test( 27 | 'Adds a block to the end of the block list when no ID is specified', 28 | () => { 29 | const result = addBlock(state, newBlock); 30 | 31 | expect(result).toEqual([ 32 | ...state, 33 | newBlock 34 | ]); 35 | } 36 | ); 37 | 38 | test( 39 | 'Adds a block after the specified block ID', 40 | () => { 41 | const result = addBlock( 42 | state, 43 | newBlock, 44 | '1' 45 | ); 46 | 47 | expect(result).toEqual([ 48 | state[0], 49 | newBlock, 50 | state[1] 51 | ]); 52 | } 53 | ); 54 | 55 | test( 56 | 'Does not mutate the original state', 57 | () => { 58 | const originalState = [...state]; 59 | addBlock(state, newBlock); 60 | expect(state).toEqual(originalState); 61 | } 62 | ); 63 | 64 | test( 65 | 'Returns a new array', 66 | () => { 67 | const result = addBlock(state, newBlock); 68 | expect(result).not.toBe(state); 69 | } 70 | ); 71 | 72 | test( 73 | 'Throws an error when the specified block ID does not exist', 74 | () => { 75 | expect(() => addBlock(state, newBlock, 'nonexistentID')).toThrow(); 76 | } 77 | ); 78 | }); 79 | -------------------------------------------------------------------------------- /packages/editor/src/__tests__/mutations/moveBlock.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from '@jest/globals'; 2 | 3 | import moveBlock from '../../mutations/moveBlock'; 4 | import type BlockState from '../../types/BlockState'; 5 | 6 | describe('addBlock', () => { 7 | const state: BlockState[] = [ 8 | { 9 | id: '1', 10 | type: 'text', 11 | properties: {} 12 | }, 13 | { 14 | id: '2', 15 | type: 'text', 16 | properties: {} 17 | }, 18 | { 19 | id: '3', 20 | type: 'text', 21 | properties: {} 22 | } 23 | ] 24 | 25 | test( 26 | 'Moves a block between two other blocks', 27 | () => { 28 | const result = moveBlock(state, '3', '1'); 29 | 30 | expect(result).toEqual([ 31 | state[0], 32 | state[2], 33 | state[1] 34 | ]); 35 | } 36 | ); 37 | 38 | test( 39 | 'Moves a block to the end if no afterId is provided', 40 | () => { 41 | const result = moveBlock(state, '1'); 42 | 43 | expect(result).toEqual([ 44 | state[1], 45 | state[2], 46 | state[0] 47 | ]); 48 | } 49 | ); 50 | 51 | test( 52 | 'Does not mutate the original state', 53 | () => { 54 | const originalState = [...state]; 55 | moveBlock(state, '3'); 56 | expect(state).toEqual(originalState); 57 | } 58 | ); 59 | 60 | test( 61 | 'Returns a new array', 62 | () => { 63 | const result = moveBlock(state, '3'); 64 | expect(result).not.toBe(state); 65 | } 66 | ); 67 | 68 | test( 69 | 'Throws an error when the specified block ID does not exist', 70 | () => { 71 | expect(() => moveBlock(state, 'nonexistentID')).toThrow(); 72 | } 73 | ); 74 | }); 75 | -------------------------------------------------------------------------------- /packages/editor/src/__tests__/mutations/removeBlock.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from '@jest/globals'; 2 | 3 | import removeBlock from "../../mutations/removeBlock"; 4 | import type BlockState from '../../types/BlockState'; 5 | 6 | describe('removeBlock', () => { 7 | const state: BlockState[] = [ 8 | { 9 | id: '1', 10 | type: 'text', 11 | properties: {} 12 | }, 13 | { 14 | id: '2', 15 | type: 'text', 16 | properties: {} 17 | } 18 | ] 19 | 20 | test( 21 | 'Removes a block by ID', 22 | () => { 23 | const result = removeBlock(state, '1'); 24 | 25 | expect(result).toEqual([ 26 | state[1] 27 | ]); 28 | } 29 | ); 30 | 31 | test( 32 | 'Does not mutate the original state', 33 | () => { 34 | const originalState = [...state]; 35 | removeBlock(state, '1'); 36 | expect(state).toEqual(originalState); 37 | } 38 | ); 39 | 40 | test( 41 | 'Returns a new array', 42 | () => { 43 | const result = removeBlock(state, '1'); 44 | expect(result).not.toBe(state); 45 | } 46 | ); 47 | 48 | test( 49 | 'Throws an error when the specified block ID does not exist', 50 | () => { 51 | expect(() => removeBlock(state, 'nonexistentID')).toThrow(); 52 | } 53 | ); 54 | }); 55 | -------------------------------------------------------------------------------- /packages/editor/src/components/BlockWrapper.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import type { InBlockMutations } from '../types/BlockRenderer'; 3 | import type BlockState from '../types/BlockState'; 4 | 5 | type BlockWrapperProps = { 6 | mutations: InBlockMutations; 7 | block: BlockState; 8 | children: React.ReactNode; 9 | }; 10 | 11 | const BlockWrapper: React.FC = (props) => { 12 | const { 13 | block, 14 | children 15 | } = props; 16 | 17 | const { id } = block; 18 | 19 | return ( 20 |
23 | {children} 24 |
25 | ); 26 | } 27 | 28 | export default BlockWrapper; 29 | -------------------------------------------------------------------------------- /packages/editor/src/index.ts: -------------------------------------------------------------------------------- 1 | import Editor from "./components/Editor"; 2 | 3 | export { Editor }; 4 | -------------------------------------------------------------------------------- /packages/editor/src/lib/factories/blockKeybindFactory.ts: -------------------------------------------------------------------------------- 1 | import type KeybindHandler from "../../types/KeybindHandler"; 2 | import restoreSelection from "../../lib/helpers/restoreSelection"; 3 | 4 | const blockKeybindFactory = (type: string) => { 5 | const handler: KeybindHandler['handler'] = (mutations, _, selection, event) => { 6 | const currentBlockID = selection?.blockId; 7 | 8 | if (!currentBlockID) return; 9 | 10 | event.preventDefault(); 11 | event.stopPropagation(); 12 | 13 | mutations.editBlock( 14 | currentBlockID, 15 | undefined, 16 | type, 17 | ); 18 | 19 | setTimeout(() => { 20 | restoreSelection(selection); 21 | }); 22 | } 23 | 24 | return handler; 25 | } 26 | 27 | export default blockKeybindFactory; 28 | -------------------------------------------------------------------------------- /packages/editor/src/lib/factories/blockRegexFactory.ts: -------------------------------------------------------------------------------- 1 | import type RichTextKeybindHandler from "../../types/RichTextKeybindHandler"; 2 | import getBlockById from "../helpers/getBlockByID"; 3 | import focusElement from "../helpers/focusElement"; 4 | 5 | const blockRegexFactory = (type: string) => ( 6 | ((mutations, block, searchResult) => { 7 | mutations.editBlock( 8 | block.id, 9 | { 10 | text: searchResult[1], 11 | }, 12 | type 13 | ); 14 | 15 | 16 | setTimeout(() => { 17 | const blockElement = getBlockById(block.id); 18 | 19 | if (!blockElement) return; 20 | 21 | focusElement(blockElement, 0); 22 | }, 7) 23 | }) as RichTextKeybindHandler['handler'] 24 | ) 25 | 26 | export default blockRegexFactory; 27 | -------------------------------------------------------------------------------- /packages/editor/src/lib/getEditorSelection.ts: -------------------------------------------------------------------------------- 1 | import type SelectionState from "../types/SelectionState"; 2 | import getCursorOffset from "./helpers/caret/getCursorOffset"; 3 | 4 | const getBlockElementFromChild = ( 5 | child: Node, 6 | editorElement: HTMLElement 7 | ): HTMLElement | void => { 8 | const parent = child.parentElement; 9 | 10 | if (!parent || parent === document.body) return; 11 | 12 | if (parent === editorElement) return child as HTMLElement; 13 | 14 | return getBlockElementFromChild(parent, editorElement); 15 | } 16 | 17 | const getEditorSelection = (editorElement: HTMLElement): SelectionState | undefined => { 18 | const activeElement = document.activeElement; 19 | 20 | if (!activeElement) return; 21 | 22 | const container = getBlockElementFromChild(activeElement, editorElement); 23 | 24 | if (!container) return; 25 | 26 | const endingSelectionIndex = getCursorOffset(container); 27 | const selectionLength = window.getSelection()?.toString().length || 0; 28 | 29 | return { 30 | blockId: container.id.replace('block-', ''), 31 | offset: endingSelectionIndex - selectionLength, 32 | length: selectionLength 33 | }; 34 | }; 35 | 36 | export default getEditorSelection; 37 | -------------------------------------------------------------------------------- /packages/editor/src/lib/handlePotentialBlockChange.ts: -------------------------------------------------------------------------------- 1 | import mutations from "../mutations"; 2 | import type { InBlockMutations } from "../types/BlockRenderer"; 3 | import type BlockState from "../types/BlockState"; 4 | import type RichTextKeybindHandler from "../types/RichTextKeybindHandler"; 5 | import type RemoveFirstFromTuple from "../types/helpers/RemoveFirstFromTuple"; 6 | import getEditorSelection from "./getEditorSelection"; 7 | 8 | const handlePotentialBlockChange = ( 9 | args: any[], 10 | state: BlockState[], 11 | editorMutations: InBlockMutations, 12 | richTextKeybinds: RichTextKeybindHandler[], 13 | editorElement: HTMLElement, 14 | ) => { 15 | const [ 16 | blockId, 17 | updatedProperties, 18 | ] = args as RemoveFirstFromTuple>; 19 | 20 | const block = state.find((block) => block.id === blockId); 21 | 22 | if (!block) return; 23 | 24 | let newText: string | undefined; 25 | 26 | if (typeof updatedProperties === 'function') { 27 | newText = updatedProperties(block?.properties).text as string | undefined; 28 | } else if (updatedProperties) { 29 | newText = updatedProperties.text as string | undefined; 30 | } 31 | 32 | if (!newText) return false; 33 | 34 | let found = false; 35 | 36 | richTextKeybinds.forEach((keybind) => { 37 | const { 38 | regex, 39 | handler 40 | } = keybind; 41 | 42 | const regexSearch = regex.exec(newText!); 43 | 44 | if (!regexSearch) return; 45 | 46 | found = true; 47 | 48 | const selection = getEditorSelection(editorElement) 49 | 50 | handler(editorMutations, block, regexSearch, selection); 51 | }); 52 | 53 | return found; 54 | }; 55 | 56 | export default handlePotentialBlockChange; 57 | -------------------------------------------------------------------------------- /packages/editor/src/lib/helpers/caret/getCursorOffset.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Get the number of characters between the start of the element and the 3 | * cursor 4 | * @param element Element to get the cursor offset for 5 | * @returns Number of characters between the start of the element and the 6 | */ 7 | const getCursorOffset = (element: HTMLElement): number => { 8 | // ~ Get the range and selection 9 | const selection = window.getSelection(); 10 | 11 | if (!selection) return 0; 12 | 13 | if (selection.rangeCount === 0) return 0; 14 | 15 | const range = selection.getRangeAt(0); 16 | 17 | if (!range) return 0; 18 | 19 | try { 20 | // ~ Clone the range and select the contents of the element 21 | const preCaretRange = range.cloneRange(); 22 | preCaretRange.selectNodeContents(element); 23 | 24 | // ~ Set the end of the range to the start of the selection 25 | preCaretRange.setEnd(range.endContainer, range.endOffset); 26 | 27 | // ~ Return the length between the start of the element and the cursor 28 | return preCaretRange.toString().length; 29 | } catch (error) { 30 | // ~ If there is an error, return 0 31 | return 0; 32 | } 33 | }; 34 | 35 | export default getCursorOffset; 36 | -------------------------------------------------------------------------------- /packages/editor/src/lib/helpers/checkModifers.ts: -------------------------------------------------------------------------------- 1 | import type { ModifierKey, ShiftKey } from '../../types/Keybind'; 2 | 3 | const checkModifers = (modifers: (ModifierKey | ShiftKey)[], event: KeyboardEvent): boolean => { 4 | const modifierToEventsMap = { 5 | altKey: 'Alt', 6 | ctrlKey: 'Control', 7 | metaKey: 'Meta', 8 | shiftKey: 'Shift', 9 | } as const; 10 | 11 | for (const eventKey of Object.keys(modifierToEventsMap)) { 12 | const isModifierPressed = event[eventKey as keyof typeof modifierToEventsMap]; 13 | const isModifierRequired = modifers.includes(modifierToEventsMap[eventKey as keyof typeof modifierToEventsMap]); 14 | 15 | if (isModifierPressed === undefined) continue; 16 | 17 | if (isModifierRequired !== isModifierPressed) return false; 18 | } 19 | 20 | return true; 21 | } 22 | 23 | export default checkModifers; 24 | -------------------------------------------------------------------------------- /packages/editor/src/lib/helpers/generateUUID.ts: -------------------------------------------------------------------------------- 1 | const generateUUID = () => Math.ceil((Math.random() * 1e16)).toString(16) 2 | 3 | export default generateUUID; 4 | -------------------------------------------------------------------------------- /packages/editor/src/lib/helpers/getBlockByID.ts: -------------------------------------------------------------------------------- 1 | import type BlockState from "../../types/BlockState"; 2 | 3 | const getBlockById = (blockId: string) => { 4 | return document.getElementById(`block-${blockId}`)?.firstChild as (HTMLElement | undefined); 5 | } 6 | 7 | export default getBlockById; 8 | -------------------------------------------------------------------------------- /packages/editor/src/lib/helpers/getFirstLineLength.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Get the length of the first line of a contenteditable element 3 | * @param element The element to get the length of the first line from 4 | * @returns The length of the first line of the element 5 | */ 6 | const getFirstLineLength = (element: HTMLElement) => { 7 | if (!element.textContent) return 0; 8 | 9 | const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT); 10 | 11 | let length = 0; 12 | 13 | let node; 14 | 15 | while (true) { 16 | node = walker.nextNode(); 17 | 18 | if (!node) break; 19 | 20 | if (!node.textContent) continue; 21 | 22 | const range = document.createRange(); 23 | 24 | range.selectNodeContents(node); 25 | 26 | // ~ If the node only spans one line, add its length to the total length 27 | if (range.getClientRects().length <= 1) { 28 | length += node.textContent.length || 0; 29 | 30 | continue; 31 | } 32 | 33 | range.setEnd(node, node.textContent.length - 1); 34 | 35 | // ~ If the node spans multiple lines, find the length of the last line 36 | for (let i = node.textContent.length; i >= 0; i -= 1) { 37 | range.setStart(node, i); 38 | 39 | if (range.getClientRects().length <= 1) continue; 40 | 41 | length += i; 42 | 43 | break; 44 | } 45 | } 46 | 47 | return length; 48 | }; 49 | 50 | export default getFirstLineLength; 51 | -------------------------------------------------------------------------------- /packages/editor/src/lib/helpers/inlineBlocks/saveInlineBlocks.ts: -------------------------------------------------------------------------------- 1 | import type { Interval } from "../intervals/mergeIntervals"; 2 | 3 | /** 4 | * Walk up the tree to get the full text style of the node 5 | * @param node The node to get the full text style of 6 | * @param topNode The top node to stop at 7 | * @returns The full text style of the node 8 | */ 9 | const getFullTextMetaData = (node: Node, topNode: HTMLElement) => { 10 | let currentNode = node; 11 | const style: string[] = []; 12 | const properties: (Record | undefined)[] = []; 13 | 14 | while (currentNode.parentElement && currentNode.parentElement !== topNode) { 15 | currentNode = currentNode.parentElement; 16 | 17 | const type = (currentNode as HTMLElement).getAttribute('data-type'); 18 | const property = (currentNode as HTMLElement).getAttribute('data-properties') 19 | 20 | if (!type) continue; 21 | 22 | if (!property) properties.push(undefined); 23 | else properties.push(JSON.parse(property)); 24 | 25 | style.push(type); 26 | } 27 | 28 | return { 29 | style, 30 | properties 31 | }; 32 | }; 33 | 34 | const saveInlineBlocks = ( 35 | element: HTMLElement 36 | ): (Interval & { type: string[], properties: (Record | undefined)[] })[] => { 37 | const treeWalker = document.createTreeWalker( 38 | element, 39 | NodeFilter.SHOW_TEXT, 40 | null 41 | ); 42 | 43 | const style: (Interval & { type: string[], properties: (Record | undefined)[] })[] = []; 44 | let length = 0; 45 | 46 | while (treeWalker.nextNode()) { 47 | const node = treeWalker.currentNode; 48 | 49 | if (!node.textContent) continue; 50 | 51 | length += node.textContent.length; 52 | 53 | const { 54 | style: type, 55 | properties 56 | } = getFullTextMetaData(node, element); 57 | 58 | if (!type.length) continue; 59 | 60 | style.push({ 61 | type, 62 | properties, 63 | start: length - node.textContent.length, 64 | end: length, 65 | }); 66 | } 67 | 68 | return style; 69 | }; 70 | 71 | export default saveInlineBlocks; 72 | -------------------------------------------------------------------------------- /packages/editor/src/lib/helpers/intervals/optionalMergeIntervalValues.ts: -------------------------------------------------------------------------------- 1 | import type { ValueMerger } from "./mergeIntervals"; 2 | 3 | /** 4 | * Merge interval values, and removes values that are prefixed with a dash. 5 | * 6 | * @example 7 | * ```ts 8 | * optionalMergeIntervalValues( 9 | * { start: 0, end: 10, type: ['bold', '-italic'] }, 10 | * { start: 0, end: 10, type: ['italic'] }, 11 | * ) 12 | * // => { start: 0, end: 10, type: ['bold'] } 13 | * 14 | * optionalMergeIntervalValues( 15 | * { start: 0, end: 10, type: ['bold'] }, 16 | * { start: 0, end: 10, type: ['italic'] }, 17 | * ) 18 | * // => { start: 0, end: 10, type: ['bold', 'italic'] } 19 | * ``` 20 | */ 21 | const optionalMergeIntervalValues: ValueMerger = (a, b) => { 22 | const newInterval: Record = {}; 23 | 24 | Object.keys(b).forEach((key) => { 25 | if (key === 'start' || key === 'end') return; 26 | 27 | const currentIntervalValue = a[key]; 28 | const intervalToMergeValue = b[key]; 29 | 30 | if (!Array.isArray(currentIntervalValue) || !Array.isArray(intervalToMergeValue)) return; 31 | 32 | newInterval[key] = [ 33 | ...intervalToMergeValue.filter((value) => ( 34 | !currentIntervalValue.includes(`-${value}`) 35 | && !`${value}`.startsWith('-') 36 | )), 37 | ...currentIntervalValue.filter((value) => ( 38 | !intervalToMergeValue.includes(`-${value}`) 39 | && !`${value}`.startsWith('-') 40 | )), 41 | ] 42 | return; 43 | }); 44 | 45 | return newInterval; 46 | } 47 | 48 | export default optionalMergeIntervalValues 49 | -------------------------------------------------------------------------------- /packages/editor/src/lib/helpers/intervals/splitOnNonNestables.ts: -------------------------------------------------------------------------------- 1 | import { Interval } from "./mergeIntervals"; 2 | 3 | const splitOnNonNestables = ( 4 | start: number, 5 | end: number, 6 | styles: T[], 7 | nestables: string[] = [], 8 | ) => { 9 | const sortedStyles = styles.sort((a, b) => a.start - b.start); 10 | 11 | const outputIntervals: (Partial)[] = [{ 12 | start, 13 | }]; 14 | 15 | sortedStyles.forEach((style) => { 16 | if ( 17 | style.end > end 18 | || style.start < start 19 | ) return; 20 | 21 | const areAllStylesNestable = style.type.every((type) => nestables.includes(type)) 22 | 23 | if (areAllStylesNestable) return; 24 | 25 | outputIntervals.at(-1)!.end = style.start; 26 | 27 | outputIntervals.push({ 28 | start: style.end, 29 | }) 30 | }); 31 | 32 | 33 | outputIntervals[outputIntervals.length - 1].end = end; 34 | 35 | return outputIntervals as T[] 36 | }; 37 | 38 | export default splitOnNonNestables; 39 | -------------------------------------------------------------------------------- /packages/editor/src/lib/helpers/intervals/xOrMergeIntervalValues.ts: -------------------------------------------------------------------------------- 1 | import type { ValueMerger } from "./mergeIntervals"; 2 | 3 | const xOrMergeIntervalValues: ValueMerger = (a, b) => { 4 | const newInterval: Record = {}; 5 | 6 | // ~ Merge the values of the intervals 7 | Object.keys(b).forEach((key) => { 8 | if (key === 'start' || key === 'end') return; 9 | 10 | const currentIntervalValue = a[key]; 11 | const intervalToMergeValue = b[key]; 12 | 13 | if (!Array.isArray(currentIntervalValue) || !Array.isArray(intervalToMergeValue)) return; 14 | 15 | // ~ Remove the values that are in both intervals 16 | newInterval[key] = [ 17 | ...currentIntervalValue.filter((value) => !intervalToMergeValue.includes(value)), 18 | ...intervalToMergeValue.filter((value) => !currentIntervalValue.includes(value)), 19 | ] 20 | return; 21 | }); 22 | 23 | return newInterval; 24 | } 25 | 26 | export default xOrMergeIntervalValues 27 | -------------------------------------------------------------------------------- /packages/editor/src/lib/helpers/isElementEditable.ts: -------------------------------------------------------------------------------- 1 | const isElementEditable = (element: HTMLElement): boolean => { 2 | if (element.isContentEditable === false) return false; 3 | 4 | if (element.firstChild) return isElementEditable(element.firstChild as HTMLElement); 5 | 6 | return true; 7 | }; 8 | 9 | export default isElementEditable; 10 | -------------------------------------------------------------------------------- /packages/editor/src/lib/helpers/isElementFocused.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Checks if the given element is focused or contains the focused element 3 | * @param element The element to check 4 | * @returns Whether the element is focused or contains the focused element 5 | */ 6 | const isElementFocused = (element: HTMLElement) => ( 7 | document.activeElement === element 8 | || element.contains(document.activeElement) 9 | ); 10 | 11 | export default isElementFocused; -------------------------------------------------------------------------------- /packages/editor/src/lib/helpers/mergeObjects.ts: -------------------------------------------------------------------------------- 1 | type MergedObject = ( 2 | { 3 | [K in keyof (T & U)]: (T & U)[K]; 4 | } & { 5 | [K in keyof T as T[K] extends any[] ? never : K]: T[K]; 6 | } & { 7 | [K in keyof U as U[K] extends any[] ? never : K]: U[K]; 8 | } 9 | ) 10 | 11 | 12 | /** 13 | * Merge the arrays of two objects, preserving the keys of non-array values, if there are 14 | * two overlapping keys that are non-array values the value from `obj1` is overwritten with 15 | * the one from `obj2` 16 | * 17 | * TODO: This should be re-done in a more maintainable way I feel like this function has 18 | * weird outcomes and handles too many cases. 19 | * 20 | * @param obj1 21 | * @param obj2 22 | * @returns 23 | */ 24 | const mergeObjects = < 25 | T extends Record, 26 | U extends Record, 27 | >(obj1: T, obj2: U) => { 28 | const merged: Record = { 29 | ...obj1, 30 | }; 31 | 32 | Object.entries(obj2).forEach(([key, value]) => { 33 | if (merged[key] === undefined) { 34 | merged[key] = value; 35 | return; 36 | } 37 | 38 | const obj1Value = merged[key]; 39 | 40 | if (typeof obj1Value === 'object' && typeof value === 'object') { 41 | merged[key] = { 42 | ...obj1Value, 43 | ...value, 44 | } 45 | } 46 | 47 | if (!Array.isArray(obj1Value) || !Array.isArray(value)) return; 48 | 49 | merged[key] = [...obj1Value, ...value]; 50 | }); 51 | 52 | return merged as MergedObject; 53 | } 54 | 55 | export default mergeObjects; 56 | -------------------------------------------------------------------------------- /packages/editor/src/lib/helpers/restoreSelection.ts: -------------------------------------------------------------------------------- 1 | import type SelectionState from "../../types/SelectionState"; 2 | import getBlockById from "./getBlockByID"; 3 | import focusElement from "./focusElement"; 4 | 5 | const restoreSelection = (selectionState: SelectionState) => { 6 | const { blockId } = selectionState; 7 | 8 | const selectionBlock = getBlockById(blockId); 9 | 10 | if (!selectionBlock) return; 11 | 12 | focusElement( 13 | selectionBlock, 14 | selectionState.offset, 15 | selectionState.length 16 | ) 17 | } 18 | 19 | export default restoreSelection; 20 | -------------------------------------------------------------------------------- /packages/editor/src/lib/keybinds/handleDownArrowNavigation.ts: -------------------------------------------------------------------------------- 1 | import KeybindHandler from "../../types/KeybindHandler"; 2 | import SelectionState from "../../types/SelectionState"; 3 | import getBlockById from "../helpers/getBlockByID"; 4 | import getFirstLineLength from "../helpers/getFirstLineLength"; 5 | import getLastLineLength from "../helpers/getLastLineLength"; 6 | import restoreSelection from "../helpers/restoreSelection"; 7 | 8 | const handleDownArrowNavigation: KeybindHandler['handler'] = (_, state, currentSelection, event) => { 9 | if (!currentSelection) return; 10 | 11 | const selectionElement = getBlockById(currentSelection.blockId); 12 | 13 | if (!selectionElement) return; 14 | 15 | const lastLineLength = getLastLineLength(selectionElement); 16 | 17 | const isOnLastLine = currentSelection.offset >= (selectionElement.textContent?.length || 0) - lastLineLength; 18 | 19 | if (!isOnLastLine) return; 20 | 21 | const currentBlockIndex = state.findIndex(({ id }) => id === currentSelection.blockId); 22 | 23 | const nextBlock = state[currentBlockIndex + 1]; 24 | 25 | if (!nextBlock) return; 26 | 27 | const nextBlockElement = getBlockById(nextBlock.id); 28 | 29 | if (!nextBlockElement) return; 30 | 31 | event.preventDefault(); 32 | event.stopPropagation(); 33 | 34 | const nextBlockSelection: SelectionState = { 35 | blockId: nextBlock.id, 36 | offset: Math.min( 37 | getFirstLineLength(nextBlockElement), 38 | currentSelection.offset - (selectionElement.textContent?.length || 0) + lastLineLength 39 | ), 40 | length: 0, 41 | } 42 | 43 | restoreSelection(nextBlockSelection); 44 | } 45 | 46 | export default handleDownArrowNavigation; 47 | -------------------------------------------------------------------------------- /packages/editor/src/lib/keybinds/handleUpArrowNavigation.ts: -------------------------------------------------------------------------------- 1 | import KeybindHandler from "../../types/KeybindHandler"; 2 | import SelectionState from "../../types/SelectionState"; 3 | import getBlockById from "../helpers/getBlockByID"; 4 | import getFirstLineLength from "../helpers/getFirstLineLength"; 5 | import getLastLineLength from "../helpers/getLastLineLength"; 6 | import restoreSelection from "../helpers/restoreSelection"; 7 | 8 | const handleUpArrowNavigation: KeybindHandler['handler'] = (_, state, currentSelection, event) => { 9 | if (!currentSelection) return; 10 | 11 | const selectionElement = getBlockById(currentSelection.blockId); 12 | 13 | if (!selectionElement) return; 14 | 15 | const firstLineLength = getFirstLineLength(selectionElement); 16 | 17 | const isOnFirstLine = currentSelection.offset <= firstLineLength; 18 | 19 | if (!isOnFirstLine) return; 20 | 21 | const currentBlockIndex = state.findIndex(({ id }) => id === currentSelection.blockId); 22 | 23 | const nextBlock = state[currentBlockIndex - 1]; 24 | 25 | if (!nextBlock) return; 26 | 27 | const previousBlockElement = getBlockById(nextBlock.id); 28 | 29 | if (!previousBlockElement) return; 30 | 31 | event.preventDefault(); 32 | event.stopPropagation(); 33 | 34 | const nextBlockSelection: SelectionState = { 35 | blockId: nextBlock.id, 36 | offset: Math.min( 37 | getLastLineLength(previousBlockElement), 38 | currentSelection.offset 39 | ), 40 | length: 0, 41 | } 42 | 43 | restoreSelection(nextBlockSelection); 44 | } 45 | 46 | export default handleUpArrowNavigation; 47 | -------------------------------------------------------------------------------- /packages/editor/src/lib/postEditorMutations/focusAddedBlock.ts: -------------------------------------------------------------------------------- 1 | import type BlockState from '../../types/BlockState'; 2 | import focusElement from '../helpers/focusElement'; 3 | import getBlockById from '../helpers/getBlockByID'; 4 | 5 | const focusAddedBlock = (_: BlockState[], block: BlockState) => { 6 | // ~ Focus the new block 7 | setTimeout(() => { 8 | const newBlock = getBlockById(block.id); 9 | 10 | if (!newBlock) return; 11 | 12 | newBlock.scrollIntoView({ 13 | behavior: 'smooth', 14 | block: 'nearest', 15 | inline: 'start' 16 | }); 17 | 18 | focusElement(newBlock); 19 | }, 25); 20 | } 21 | 22 | export default focusAddedBlock; 23 | -------------------------------------------------------------------------------- /packages/editor/src/lib/postEditorMutations/focusRemovedBlock.ts: -------------------------------------------------------------------------------- 1 | import type BlockState from '../../types/BlockState'; 2 | import focusElement from '../helpers/focusElement'; 3 | import getBlockById from '../helpers/getBlockByID'; 4 | 5 | const focusRemovedBlock = (state: BlockState[], id: string) => { 6 | const previousBlockIndex = Math.max(state.findIndex(block => block.id === id) - 1, 0); 7 | const previousBlockId = state[previousBlockIndex]?.id 8 | 9 | // ~ Focus the next block 10 | setTimeout(() => { 11 | if (!previousBlockId) return; 12 | 13 | const nextBlock = getBlockById(previousBlockId); 14 | 15 | if (!nextBlock) return; 16 | 17 | nextBlock.scrollIntoView({ 18 | behavior: 'smooth', 19 | block: 'nearest', 20 | inline: 'start' 21 | }); 22 | 23 | focusElement(nextBlock, (nextBlock.textContent?.length || 1) - 1); 24 | }, 25); 25 | } 26 | 27 | export default focusRemovedBlock; 28 | -------------------------------------------------------------------------------- /packages/editor/src/mutations/addBlock.ts: -------------------------------------------------------------------------------- 1 | import type BlockState from "../types/BlockState"; 2 | 3 | /** 4 | * Add a block to the editor state. 5 | * @param state The editor state to modify. 6 | * @param block The block to add. 7 | * @param afterId The ID of the block to insert the new block after. 8 | * 9 | * @returns The modified editor state. 10 | */ 11 | const addBlock = ( 12 | state: BlockState[], 13 | block: BlockState, 14 | afterId?: string, 15 | ): BlockState[] => { 16 | const newState = [...state]; 17 | 18 | if (!afterId) { 19 | newState.push(block); 20 | return newState; 21 | } 22 | 23 | const afterIndex = newState.findIndex((b) => b.id === afterId); 24 | 25 | if (afterIndex === -1) { 26 | throw new Error(`Attempted to insert block after non-existent block with ID "${afterId}".`); 27 | } 28 | 29 | newState.splice(afterIndex + 1, 0, block); 30 | 31 | return newState; 32 | }; 33 | 34 | export default addBlock; -------------------------------------------------------------------------------- /packages/editor/src/mutations/editBlock.ts: -------------------------------------------------------------------------------- 1 | import type BlockState from "../types/BlockState"; 2 | 3 | /** 4 | * Edit a block in the editor state. 5 | * @param state The editor state to modify. 6 | * @param block The block to edit. 7 | * @param updatedProperties The properties to update on the block. 8 | * @param updatedType The type to update on the block. 9 | * 10 | * @returns The modified editor state. 11 | */ 12 | const editBlock = ( 13 | state: BlockState[], 14 | blockId: string, 15 | updatedProperties?: Record | ((currentProperties: Record) => Record), 16 | updatedType?: string | ((currentType: string) => string), 17 | ): BlockState[] => { 18 | const newState: BlockState[] = [...state]; 19 | 20 | const blockIndex = newState.findIndex((b) => b.id === blockId); 21 | 22 | const block = newState[blockIndex]; 23 | 24 | if (!block) { 25 | throw new Error(`Attempted to edit non-existent block with ID "${blockId}".`); 26 | } 27 | 28 | newState[blockIndex] = { 29 | ...block, 30 | properties: { 31 | ...block.properties, 32 | ...( 33 | typeof updatedProperties === 'function' 34 | ? updatedProperties(block.properties) 35 | : updatedProperties 36 | ), 37 | }, 38 | type: ( 39 | ( 40 | typeof updatedType === 'function' 41 | ? updatedType(block.type) 42 | : updatedType 43 | ) 44 | || block.type 45 | ) 46 | }; 47 | 48 | return newState; 49 | } 50 | 51 | export default editBlock; 52 | -------------------------------------------------------------------------------- /packages/editor/src/mutations/index.ts: -------------------------------------------------------------------------------- 1 | import addBlock from "./addBlock"; 2 | import editBlock from "./editBlock"; 3 | import removeBlock from "./removeBlock"; 4 | import moveBlock from "./moveBlock"; 5 | 6 | const mutations = { 7 | addBlock, 8 | removeBlock, 9 | editBlock, 10 | moveBlock, 11 | } 12 | 13 | export default mutations; 14 | -------------------------------------------------------------------------------- /packages/editor/src/mutations/moveBlock.ts: -------------------------------------------------------------------------------- 1 | import type BlockState from "../types/BlockState"; 2 | import addBlock from "./addBlock"; 3 | import removeBlock from "./removeBlock"; 4 | 5 | /** 6 | * Move a block to a new position in the editor state. 7 | * @param state The editor state to modify. 8 | * @param blockId The ID of the block to move. 9 | * @param afterId The ID of the block to move the block after (if not provided, the block will be moved to the start of the editor state 10 | * 11 | * @returns The modified editor state. 12 | */ 13 | const moveBlock = ( 14 | state: BlockState[], 15 | blockId: string, 16 | afterId?: string, 17 | ): BlockState[] => { 18 | const block = state.find((b) => b.id === blockId); 19 | 20 | if (!block) { 21 | throw new Error(`Attempted to move non-existent block with ID "${blockId}".`); 22 | } 23 | 24 | return addBlock( 25 | removeBlock(state, blockId), 26 | block, 27 | afterId 28 | ); 29 | }; 30 | 31 | export default moveBlock; -------------------------------------------------------------------------------- /packages/editor/src/mutations/removeBlock.ts: -------------------------------------------------------------------------------- 1 | import type BlockState from "../types/BlockState"; 2 | 3 | /** 4 | * Remove a block from the editor state. 5 | * @param state The editor state to modify. 6 | * @param blockId The ID of the block to remove. 7 | * 8 | * @returns The modified editor state. 9 | */ 10 | const removeBlock = ( 11 | state: BlockState[], 12 | blockId: string, 13 | ): BlockState[] => { 14 | const newState = [...state]; 15 | 16 | const blockIndex = newState.findIndex((b) => b.id === blockId); 17 | 18 | if (blockIndex === -1) { 19 | throw new Error(`Attempted to remove non-existent block with ID "${blockId}".`); 20 | } 21 | 22 | newState.splice(blockIndex, 1); 23 | 24 | return newState; 25 | }; 26 | 27 | export default removeBlock; 28 | -------------------------------------------------------------------------------- /packages/editor/src/types/BlockRenderer.ts: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import mutations from '../mutations'; 4 | import type BlockState from './BlockState'; 5 | import type RemoveFirstFromTuple from './helpers/RemoveFirstFromTuple'; 6 | import InlineBlockRenderer from './InlineBlockRenderer'; 7 | 8 | 9 | export type InBlockMutations = { 10 | [key in keyof typeof mutations]: ( 11 | ...args: RemoveFirstFromTuple> 12 | ) => void; 13 | }; 14 | 15 | 16 | export type BlockRendererProps> = BlockState & { 17 | mutations: InBlockMutations; 18 | editorRef: React.RefObject; 19 | inlineBlocks: { 20 | [type: string]: InlineBlockRenderer, 21 | } 22 | }; 23 | 24 | type BlockRenderer> = React.FC>; 25 | 26 | export default BlockRenderer; 27 | -------------------------------------------------------------------------------- /packages/editor/src/types/BlockState.ts: -------------------------------------------------------------------------------- 1 | type BlockState> = { 2 | id: string; 3 | type: string; 4 | properties: T; 5 | }; 6 | 7 | export default BlockState; 8 | -------------------------------------------------------------------------------- /packages/editor/src/types/InlineBlockRenderer.ts: -------------------------------------------------------------------------------- 1 | import type React from "react"; 2 | 3 | import { InBlockMutations } from "./BlockRenderer"; 4 | 5 | type InlineBlockRenderer< 6 | Props extends Record, 7 | > = React.FC<( 8 | { 9 | children: React.ReactNode | string, 10 | mutations: InBlockMutations, 11 | properties: Props & { 12 | blockID: string, 13 | blockStart: number, 14 | blockEnd: number, 15 | }, 16 | } 17 | )>; 18 | 19 | export default InlineBlockRenderer; 20 | -------------------------------------------------------------------------------- /packages/editor/src/types/Keybind.ts: -------------------------------------------------------------------------------- 1 | import type StringToUnion from "./helpers/StringToUnion" 2 | 3 | type LowercaseKey = StringToUnion<'abcdefghijklmnopqrstuvwxyz'> 4 | type UppercaseKey = StringToUnion<'ABCDEFGHIJKLMNOPQRSTUVWXYZ'> 5 | type NumberKey = StringToUnion<'0123456789'> 6 | type SpecialKey = ( 7 | StringToUnion<'`-=[]\\;\',./'> | 8 | 'Enter' | 9 | 'Space' | 10 | 'Tab' 11 | ) 12 | type NavigationKey = ( 13 | 'ArrowUp' | 14 | 'ArrowDown' | 15 | 'ArrowLeft' | 16 | 'ArrowRight' 17 | ) 18 | 19 | type ModifierKey = ( 20 | 'Alt' | 21 | 'Control' | 22 | 'Meta' 23 | ) 24 | 25 | type ShiftKey = 'Shift' 26 | 27 | /** 28 | * Valid keys for shortcuts. 29 | */ 30 | type ValidKeys = ( 31 | | LowercaseKey 32 | | UppercaseKey 33 | | NumberKey 34 | | SpecialKey 35 | | NavigationKey 36 | ) 37 | 38 | /** 39 | * A combination of modifier keys that is unique. 40 | */ 41 | type UniqueModifierCombination = ( 42 | | ModifierKey 43 | | `${ModifierKey}+${ShiftKey}` 44 | ) 45 | 46 | /** 47 | * Valid keybinds for blocks and shortcuts. 48 | */ 49 | type Keybind = ( 50 | | ValidKeys 51 | | `${UniqueModifierCombination}+${ValidKeys}` 52 | ) 53 | 54 | export type { 55 | LowercaseKey, 56 | UppercaseKey, 57 | NumberKey, 58 | SpecialKey, 59 | ValidKeys, 60 | ModifierKey, 61 | ShiftKey, 62 | NavigationKey 63 | }; 64 | 65 | export default Keybind; -------------------------------------------------------------------------------- /packages/editor/src/types/KeybindHandler.ts: -------------------------------------------------------------------------------- 1 | import type Keybind from "./Keybind"; 2 | import SelectionState from "./SelectionState"; 3 | import type { InBlockMutations } from "./BlockRenderer"; 4 | import BlockState from "./BlockState"; 5 | 6 | type KeybindHandler = { 7 | keybind: Keybind, 8 | handler: ( 9 | mutations: InBlockMutations, 10 | state: BlockState[], 11 | selection: SelectionState | undefined, 12 | event: KeyboardEvent, 13 | ) => void, 14 | } 15 | 16 | export default KeybindHandler; 17 | -------------------------------------------------------------------------------- /packages/editor/src/types/Plugin.ts: -------------------------------------------------------------------------------- 1 | import type { EditorProps } from "../components/Editor"; 2 | 3 | type Plugin = (Partial>); 4 | 5 | export default Plugin; -------------------------------------------------------------------------------- /packages/editor/src/types/RichTextKeybindHandler.ts: -------------------------------------------------------------------------------- 1 | import type BlockState from "./BlockState"; 2 | import type { InBlockMutations } from "./BlockRenderer"; 3 | import type SelectionState from "./SelectionState"; 4 | 5 | type RichTextKeybindHandler = { 6 | regex: RegExp, 7 | handler: ( 8 | mutations: InBlockMutations, 9 | activeBlock: BlockState, 10 | searchResult: RegExpExecArray, 11 | selection?: SelectionState, 12 | ) => void 13 | } 14 | 15 | export default RichTextKeybindHandler; 16 | -------------------------------------------------------------------------------- /packages/editor/src/types/SelectionState.ts: -------------------------------------------------------------------------------- 1 | type SelectionState = { 2 | blockId: string, 3 | offset: number, 4 | length: number 5 | } 6 | 7 | export default SelectionState; 8 | -------------------------------------------------------------------------------- /packages/editor/src/types/helpers/RemoveFirstFromTuple.ts: -------------------------------------------------------------------------------- 1 | type RemoveFirstFromTuple = (((...b: T) => void) extends (a: infer _, ...b: infer I) => void ? I : []) 2 | 3 | export default RemoveFirstFromTuple; 4 | -------------------------------------------------------------------------------- /packages/editor/src/types/helpers/ReverseArr.ts: -------------------------------------------------------------------------------- 1 | type ReverseArr = T extends [infer F, ...infer R] 2 | ? [...ReverseArr, F] 3 | : T; 4 | 5 | export default ReverseArr; 6 | 7 | -------------------------------------------------------------------------------- /packages/editor/src/types/helpers/Split.ts: -------------------------------------------------------------------------------- 1 | type Split = string extends T 2 | ? string[] 3 | : T extends '' 4 | ? [] 5 | : T extends `${infer F}${D}${infer R}` 6 | ? [F, ...Split] 7 | : [T]; 8 | 9 | export default Split; 10 | -------------------------------------------------------------------------------- /packages/editor/src/types/helpers/StringToUnion.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Helper type to convert a string to a union of its characters. 3 | */ 4 | type StringToUnion = T extends `${infer F}${infer R}` 5 | ? F | StringToUnion 6 | : never; 7 | 8 | export default StringToUnion; 9 | -------------------------------------------------------------------------------- /packages/editor/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es6", 4 | "allowJs": true, 5 | "skipLibCheck": true, 6 | "strict": true, 7 | "forceConsistentCasingInFileNames": true, 8 | "esModuleInterop": true, 9 | "module": "commonjs", 10 | "moduleResolution": "node", 11 | "resolveJsonModule": true, 12 | "incremental": true, 13 | "outDir": "dist", 14 | "sourceMap": true, 15 | "jsx": "preserve", 16 | "declaration": true, 17 | "declarationMap": true, 18 | }, 19 | "include": ["**/*.ts", "**/*.tsx"], 20 | "exclude": [ 21 | "node_modules", "dist", "src/demo/**", "./src/__tests__" 22 | ], 23 | } -------------------------------------------------------------------------------- /packages/editor/webpack.config.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-var-requires */ 2 | // Generated using webpack-cli https://github.com/webpack/webpack-cli 3 | 4 | const path = require('path'); 5 | const HtmlWebpackPlugin = require('html-webpack-plugin'); 6 | 7 | const isProduction = process.env.NODE_ENV === 'production'; 8 | 9 | const config = { 10 | entry: { 11 | index: './src/demo/index.tsx', 12 | }, 13 | output: { 14 | path: path.resolve(__dirname, 'dist'), 15 | }, 16 | devServer: { 17 | open: false, 18 | host: 'localhost', 19 | }, 20 | plugins: [ 21 | new HtmlWebpackPlugin({ 22 | template: 'index.html', 23 | }), 24 | 25 | // Add your plugins here 26 | // Learns more about plugins from https://webpack.js.org/configuration/plugins/ 27 | ], 28 | module: { 29 | rules: [ 30 | { 31 | test: /\.(ts|tsx)$/i, 32 | loader: 'babel-loader', 33 | exclude: [ 34 | '/node_modules/', 35 | '/src/__tests__/', 36 | ], 37 | }, 38 | { 39 | test: /\.(eot|svg|ttf|woff|woff2|png|jpg|gif)$/i, 40 | type: 'asset', 41 | }, 42 | 43 | // Add your rules for custom modules here 44 | // Learn more about loaders from https://webpack.js.org/loaders/ 45 | ], 46 | }, 47 | resolve: { 48 | extensions: ['.tsx', '.ts', '.js'], 49 | }, 50 | }; 51 | 52 | module.exports = () => { 53 | if (isProduction) { 54 | config.mode = 'production'; 55 | } else { 56 | config.mode = 'development'; 57 | } 58 | return config; 59 | }; 60 | -------------------------------------------------------------------------------- /packages/plugin-dnd/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "@babel/preset-env", 4 | "@babel/preset-react", 5 | "@babel/preset-typescript" 6 | ], 7 | "plugins": [ 8 | "@babel/plugin-syntax-dynamic-import" 9 | ] 10 | } -------------------------------------------------------------------------------- /packages/plugin-dnd/.gitignore: -------------------------------------------------------------------------------- 1 | dist/** -------------------------------------------------------------------------------- /packages/plugin-dnd/.npmignore: -------------------------------------------------------------------------------- 1 | __tests__/** 2 | demo/** -------------------------------------------------------------------------------- /packages/plugin-dnd/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Styled Text Plugin 7 | 8 | 9 | 10 |
11 | 12 | 13 | -------------------------------------------------------------------------------- /packages/plugin-dnd/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@note-rack/plugin-dnd", 3 | "description": "A plugin for Note Rack that allows you to move blocks around by dragging and dropping them.", 4 | "version": "1.0.4", 5 | "module": "dist/index.js", 6 | "umd": "dist/index.umd.js", 7 | "main": "dist/index.js", 8 | "types": "dist/index.d.ts", 9 | "repository": "https://github.com/Eroxl/Note-Rack/tree/main/packages/plugin-math-latex", 10 | "author": "Eroxl", 11 | "license": "AGPL-3.0-only", 12 | "private": false, 13 | "type": "commonjs", 14 | "files": [ 15 | "src", 16 | "dist" 17 | ], 18 | "scripts": { 19 | "build": "sh ./scripts/build.sh", 20 | "dev": "webpack-dev-server --hot --config ./webpack.config.js" 21 | }, 22 | "devDependencies": { 23 | "@babel/core": "^7.23.7", 24 | "@babel/plugin-syntax-dynamic-import": "^7.8.3", 25 | "@babel/preset-env": "^7.23.7", 26 | "@babel/preset-react": "^7.23.3", 27 | "@babel/preset-typescript": "^7.23.3", 28 | "@note-rack/plugin-styled-text": "^1.0.6", 29 | "@types/react": "^18.2.28", 30 | "@types/react-dom": "^18.2.13", 31 | "@webpack-cli/generators": "^3.0.7", 32 | "babel-loader": "^9.1.3", 33 | "html-webpack-plugin": "^5.6.0", 34 | "react": "^18.2.0", 35 | "react-dnd": "^16.0.1", 36 | "react-dnd-html5-backend": "^16.0.1", 37 | "react-dom": "^18.2.0", 38 | "swc": "^1.0.11", 39 | "ts-loader": "^9.5.1", 40 | "webpack": "^5.89.0", 41 | "webpack-cli": "^5.1.4", 42 | "webpack-dev-server": "^4.15.1" 43 | }, 44 | "dependencies": { 45 | "@note-rack/editor": "^1.0.16" 46 | }, 47 | "peerDependencies": { 48 | "@note-rack/editor": "^1.0.6", 49 | "react": "<18.0.0", 50 | "react-dnd": "^16.0.1", 51 | "react-dnd-html5-backend": "^16.0.1", 52 | "react-dom": "<18.0.0" 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /packages/plugin-dnd/scripts/build.sh: -------------------------------------------------------------------------------- 1 | # ~ Compile the typescript code 2 | swc -C module.type=commonjs -d dist src/ 3 | 4 | # ~ Compile the typescript types 5 | npx tsc -b . 6 | 7 | # ~ Copy the package.json and .npmignore file to the dist folder 8 | cp package.json dist/package.json 9 | cp .npmignore dist/.npmignore 10 | 11 | # NOTE: The -e flag is used to make the following sed command work on MacOS 12 | 13 | # ~ Remove the dist folder from the package.json file 14 | sed -i -e 's|"dist/|"./|g' ./dist/package.json 15 | sed -i -e '/"files": \[/,/\]/d' ./dist/package.json 16 | 17 | # ~ Remove the backup file created by the sed command 18 | rm ./dist/package.json-e 19 | -------------------------------------------------------------------------------- /packages/plugin-dnd/src/components/createHandle.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { ConnectDragSource } from 'react-dnd/src/types/index'; 3 | 4 | type HandleProps = { 5 | innerRef: ConnectDragSource, 6 | }; 7 | 8 | const createHandle = ( 9 | handleIcon?: React.ReactNode, 10 | style?: React.CSSProperties, 11 | className?: string, 12 | ) => { 13 | const Handle: React.FC = ({ innerRef }) => { 14 | return ( 15 |
20 | {handleIcon} 21 |
22 | ); 23 | }; 24 | 25 | return Handle; 26 | } 27 | 28 | export default createHandle; 29 | -------------------------------------------------------------------------------- /packages/plugin-dnd/src/components/createWrapper.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useDrop, useDrag } from 'react-dnd'; 3 | import type { EditorProps } from '@note-rack/editor/components/Editor'; 4 | import type BlockState from '@note-rack/editor/types/BlockState'; 5 | 6 | import createHandle from './createHandle'; 7 | 8 | type Wrapper = Required['blockWrappers'][number] 9 | 10 | type DragableItem = { 11 | block: BlockState, 12 | } 13 | 14 | const createWrapper = ( 15 | HandleComponent: ReturnType, 16 | style?: React.CSSProperties, 17 | className?: string, 18 | hoveredStyle?: React.CSSProperties, 19 | hoveredClass?: string, 20 | ): Wrapper => { 21 | return ({ children, block, mutations }) => { 22 | const [, drag, preview] = useDrag(() => ({ 23 | type: 'draggableBlock', 24 | item: () => ({ 25 | block: block, 26 | }), 27 | }), [block.id]); 28 | 29 | const [{ hovered }, drop] = useDrop< 30 | DragableItem, 31 | unknown, 32 | { hovered: boolean } 33 | >(() => ({ 34 | accept: 'draggableBlock', 35 | collect: (monitor) => ({ 36 | hovered: monitor.isOver() && monitor.getItem().block.id !== block.id 37 | }), 38 | drop: async (item) => { 39 | mutations.moveBlock(item.block.id, block.id); 40 | }, 41 | }), [block.id]); 42 | 43 | return ( 44 |
48 | 51 |
59 | {children} 60 |
61 |
62 | ); 63 | } 64 | } 65 | 66 | export default createWrapper; 67 | -------------------------------------------------------------------------------- /packages/plugin-dnd/src/createDnDPlugin.ts: -------------------------------------------------------------------------------- 1 | import Plugin from '@note-rack/editor/types/Plugin'; 2 | 3 | import createHandle from './components/createHandle'; 4 | import createWrapper from './components/createWrapper'; 5 | 6 | const createDnDPlugin = ( 7 | wrapperStyle: { 8 | style?: React.CSSProperties, 9 | className?: string, 10 | hoveredStyle?: React.CSSProperties, 11 | hoveredClass?: string, 12 | } = {}, 13 | handleIcon?: React.ReactNode, 14 | handleStyle: { 15 | style?: React.CSSProperties, 16 | className?: string, 17 | } = {}, 18 | ): Plugin => { 19 | return { 20 | blockWrappers: [ 21 | createWrapper( 22 | createHandle( 23 | handleIcon, 24 | handleStyle.style, 25 | handleStyle.className, 26 | ), 27 | wrapperStyle.style, 28 | wrapperStyle.className, 29 | wrapperStyle.hoveredStyle, 30 | wrapperStyle.hoveredClass, 31 | ), 32 | ], 33 | } 34 | }; 35 | 36 | export default createDnDPlugin; 37 | -------------------------------------------------------------------------------- /packages/plugin-dnd/src/index.ts: -------------------------------------------------------------------------------- 1 | import createDnDPlugin from "./createDnDPlugin"; 2 | 3 | export default createDnDPlugin; 4 | -------------------------------------------------------------------------------- /packages/plugin-dnd/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es6", 4 | "allowJs": true, 5 | "skipLibCheck": true, 6 | "strict": true, 7 | "forceConsistentCasingInFileNames": true, 8 | "esModuleInterop": true, 9 | "module": "commonjs", 10 | "moduleResolution": "node", 11 | "resolveJsonModule": true, 12 | "incremental": true, 13 | "outDir": "dist", 14 | "sourceMap": true, 15 | "jsx": "preserve", 16 | "declaration": true, 17 | "declarationMap": true, 18 | }, 19 | "include": ["**/*.ts", "**/*.tsx"], 20 | "exclude": [ 21 | "node_modules", "dist", 22 | ], 23 | } -------------------------------------------------------------------------------- /packages/plugin-dnd/webpack.config.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-var-requires */ 2 | // Generated using webpack-cli https://github.com/webpack/webpack-cli 3 | 4 | const path = require('path'); 5 | const HtmlWebpackPlugin = require('html-webpack-plugin'); 6 | 7 | const isProduction = process.env.NODE_ENV === 'production'; 8 | 9 | const config = { 10 | entry: { 11 | index: './src/demo/index.tsx', 12 | }, 13 | output: { 14 | path: path.resolve(__dirname, 'dist'), 15 | }, 16 | devServer: { 17 | open: false, 18 | host: 'localhost', 19 | }, 20 | plugins: [ 21 | new HtmlWebpackPlugin({ 22 | template: 'index.html', 23 | }), 24 | 25 | // Add your plugins here 26 | // Learns more about plugins from https://webpack.js.org/configuration/plugins/ 27 | ], 28 | module: { 29 | rules: [ 30 | { 31 | test: /\.(ts|tsx)$/i, 32 | loader: 'babel-loader', 33 | exclude: [ 34 | '/node_modules/', 35 | '/src/__tests__/', 36 | ], 37 | }, 38 | { 39 | test: /\.(eot|svg|ttf|woff|woff2|png|jpg|gif)$/i, 40 | type: 'asset', 41 | }, 42 | 43 | // Add your rules for custom modules here 44 | // Learn more about loaders from https://webpack.js.org/loaders/ 45 | ], 46 | }, 47 | resolve: { 48 | extensions: ['.tsx', '.ts', '.js'], 49 | }, 50 | }; 51 | 52 | module.exports = () => { 53 | if (isProduction) { 54 | config.mode = 'production'; 55 | } else { 56 | config.mode = 'development'; 57 | } 58 | return config; 59 | }; -------------------------------------------------------------------------------- /packages/plugin-inline-link/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "@babel/preset-env", 4 | "@babel/preset-react", 5 | "@babel/preset-typescript" 6 | ], 7 | "plugins": [ 8 | "@babel/plugin-syntax-dynamic-import" 9 | ] 10 | } -------------------------------------------------------------------------------- /packages/plugin-inline-link/.gitignore: -------------------------------------------------------------------------------- 1 | dist/** -------------------------------------------------------------------------------- /packages/plugin-inline-link/.npmignore: -------------------------------------------------------------------------------- 1 | __tests__/** 2 | demo/** -------------------------------------------------------------------------------- /packages/plugin-inline-link/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |
9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /packages/plugin-inline-link/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@note-rack/plugin-inline-link", 3 | "description": "An inline block for the note rack editor to provide links", 4 | "version": "1.0.0", 5 | "module": "dist/index.js", 6 | "umd": "dist/index.umd.js", 7 | "main": "dist/index.js", 8 | "types": "dist/index.d.ts", 9 | "repository": "https://github.com/Eroxl/Note-Rack/tree/main/packages/plugin-inline-link", 10 | "author": "Eroxl", 11 | "license": "AGPL-3.0-only", 12 | "private": false, 13 | "type": "commonjs", 14 | "files": [ 15 | "src", 16 | "dist" 17 | ], 18 | "scripts": { 19 | "build": "sh ./scripts/build.sh", 20 | "dev": "webpack-dev-server --hot --config ./webpack.config.js" 21 | }, 22 | "devDependencies": { 23 | "@babel/core": "^7.23.7", 24 | "@babel/plugin-syntax-dynamic-import": "^7.8.3", 25 | "@babel/preset-env": "^7.23.7", 26 | "@babel/preset-react": "^7.23.3", 27 | "@babel/preset-typescript": "^7.23.3", 28 | "@types/react": "^18.2.28", 29 | "@types/react-dom": "^18.2.13", 30 | "@webpack-cli/generators": "^3.0.7", 31 | "babel-loader": "^9.1.3", 32 | "html-webpack-plugin": "^5.6.0", 33 | "react": "^18.2.0", 34 | "react-dom": "^18.2.0", 35 | "swc": "^1.0.11", 36 | "ts-loader": "^9.5.1", 37 | "webpack": "^5.89.0", 38 | "webpack-cli": "^5.1.4", 39 | "webpack-dev-server": "^4.15.1" 40 | }, 41 | "dependencies": { 42 | "@floating-ui/react": "^0.26.24", 43 | "@note-rack/editor": "^1.0.16" 44 | }, 45 | "peerDependencies": { 46 | "react": "<18.0.0", 47 | "react-dom": "<18.0.0" 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /packages/plugin-inline-link/scripts/build.sh: -------------------------------------------------------------------------------- 1 | # ~ Compile the typescript code 2 | swc -C module.type=commonjs -d dist src/ 3 | 4 | # ~ Compile the typescript types 5 | npx tsc -b . 6 | 7 | # ~ Copy the package.json and .npmignore file to the dist folder 8 | cp package.json dist/package.json 9 | cp .npmignore dist/.npmignore 10 | 11 | # NOTE: The -e flag is used to make the following sed command work on MacOS 12 | 13 | # ~ Remove the dist folder from the package.json file 14 | sed -i -e 's|"dist/|"./|g' ./dist/package.json 15 | sed -i -e '/"files": \[/,/\]/d' ./dist/package.json 16 | 17 | # ~ Remove the backup file created by the sed command 18 | rm ./dist/package.json-e 19 | -------------------------------------------------------------------------------- /packages/plugin-inline-link/src/components/createFloatingLinkEditor.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export type FloatingLinkEditorProps = { 4 | href: string; 5 | setHref: React.Dispatch> 6 | floatingStyles: React.CSSProperties; 7 | setFloating: ((node: HTMLElement | null) => void) & ((node: HTMLElement | null) => void); 8 | }; 9 | 10 | const createFloatingLinkEditor = ( 11 | style?: React.CSSProperties, 12 | className?: string, 13 | ) => { 14 | const FloatingLinkEditor: React.FC = (props) => { 15 | const { 16 | href, 17 | setHref, 18 | floatingStyles, 19 | setFloating, 20 | ...floatingProps 21 | } = props; 22 | 23 | return ( 24 |
33 | {href} 34 |
35 | ); 36 | }; 37 | 38 | return FloatingLinkEditor; 39 | } 40 | 41 | export default createFloatingLinkEditor; 42 | -------------------------------------------------------------------------------- /packages/plugin-inline-link/src/demo/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom"; 3 | import { Editor } from "@note-rack/editor"; 4 | import createStyledTextRenderer from "@note-rack/editor/components/createStyledTextRenderer"; 5 | import inlineBlockRegexFactory from "@note-rack/editor/lib/factories/inlineBlockRegexFactory"; 6 | import InlineBlockRenderer from "@note-rack/editor/types/InlineBlockRenderer"; 7 | 8 | import createInlineLinkRenderer from "../createInlineLinkRenderer"; 9 | import createFloatingLinkEditor from "../components/createFloatingLinkEditor"; 10 | 11 | const inlineBlocks = { 12 | link: createInlineLinkRenderer(createFloatingLinkEditor()), 13 | } as Record>; 14 | 15 | const Demo: React.FC = () => ( 16 | 56 | ); 57 | 58 | ReactDOM.render( 59 | , 60 | document.getElementById('root') 61 | ); -------------------------------------------------------------------------------- /packages/plugin-inline-link/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es6", 4 | "allowJs": true, 5 | "skipLibCheck": true, 6 | "strict": true, 7 | "forceConsistentCasingInFileNames": true, 8 | "esModuleInterop": true, 9 | "module": "commonjs", 10 | "moduleResolution": "node", 11 | "resolveJsonModule": true, 12 | "incremental": true, 13 | "outDir": "dist", 14 | "sourceMap": true, 15 | "jsx": "preserve", 16 | "declaration": true, 17 | "declarationMap": true, 18 | }, 19 | "include": ["**/*.ts", "**/*.tsx"], 20 | "exclude": [ 21 | "node_modules", "dist", 22 | ], 23 | } -------------------------------------------------------------------------------- /packages/plugin-inline-link/webpack.config.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-var-requires */ 2 | // Generated using webpack-cli https://github.com/webpack/webpack-cli 3 | 4 | const path = require('path'); 5 | const HtmlWebpackPlugin = require('html-webpack-plugin'); 6 | 7 | const isProduction = process.env.NODE_ENV === 'production'; 8 | 9 | const config = { 10 | entry: { 11 | index: './src/demo/index.tsx', 12 | }, 13 | output: { 14 | path: path.resolve(__dirname, 'dist'), 15 | }, 16 | devServer: { 17 | open: false, 18 | host: 'localhost', 19 | }, 20 | plugins: [ 21 | new HtmlWebpackPlugin({ 22 | template: 'index.html', 23 | }), 24 | 25 | // Add your plugins here 26 | // Learns more about plugins from https://webpack.js.org/configuration/plugins/ 27 | ], 28 | module: { 29 | rules: [ 30 | { 31 | test: /\.(ts|tsx)$/i, 32 | loader: 'babel-loader', 33 | exclude: [ 34 | '/node_modules/', 35 | '/src/__tests__/', 36 | ], 37 | }, 38 | { 39 | test: /\.(eot|svg|ttf|woff|woff2|png|jpg|gif)$/i, 40 | type: 'asset', 41 | }, 42 | 43 | // Add your rules for custom modules here 44 | // Learn more about loaders from https://webpack.js.org/loaders/ 45 | ], 46 | }, 47 | resolve: { 48 | extensions: ['.tsx', '.ts', '.js'], 49 | }, 50 | }; 51 | 52 | module.exports = () => { 53 | if (isProduction) { 54 | config.mode = 'production'; 55 | } else { 56 | config.mode = 'development'; 57 | } 58 | return config; 59 | }; -------------------------------------------------------------------------------- /packages/plugin-math-latex/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "@babel/preset-env", 4 | "@babel/preset-react", 5 | "@babel/preset-typescript" 6 | ], 7 | "plugins": [ 8 | "@babel/plugin-syntax-dynamic-import" 9 | ] 10 | } -------------------------------------------------------------------------------- /packages/plugin-math-latex/.gitignore: -------------------------------------------------------------------------------- 1 | dist/** -------------------------------------------------------------------------------- /packages/plugin-math-latex/.npmignore: -------------------------------------------------------------------------------- 1 | __tests__/** 2 | demo/** -------------------------------------------------------------------------------- /packages/plugin-math-latex/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Styled Text Plugin 7 | 8 | 9 | 10 |
11 | 12 | 13 | -------------------------------------------------------------------------------- /packages/plugin-math-latex/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@note-rack/plugin-math-latex", 3 | "description": "The math plugin for Note Rack that uses KaTeX to render LaTeX", 4 | "version": "1.0.4", 5 | "module": "dist/index.js", 6 | "umd": "dist/index.umd.js", 7 | "main": "dist/index.js", 8 | "types": "dist/index.d.ts", 9 | "repository": "https://github.com/Eroxl/Note-Rack/tree/main/packages/plugin-math-latex", 10 | "author": "Eroxl", 11 | "license": "AGPL-3.0-only", 12 | "private": false, 13 | "type": "commonjs", 14 | "files": [ 15 | "src", 16 | "dist" 17 | ], 18 | "scripts": { 19 | "build": "sh ./scripts/build.sh", 20 | "dev": "webpack-dev-server --hot --config ./webpack.config.js" 21 | }, 22 | "devDependencies": { 23 | "@babel/core": "^7.23.7", 24 | "@babel/plugin-syntax-dynamic-import": "^7.8.3", 25 | "@babel/preset-env": "^7.23.7", 26 | "@babel/preset-react": "^7.23.3", 27 | "@babel/preset-typescript": "^7.23.3", 28 | "@types/react": "^18.2.28", 29 | "@types/react-dom": "^18.2.13", 30 | "@webpack-cli/generators": "^3.0.7", 31 | "babel-loader": "^9.1.3", 32 | "html-webpack-plugin": "^5.6.0", 33 | "react": "^18.2.0", 34 | "react-dom": "^18.2.0", 35 | "swc": "^1.0.11", 36 | "ts-loader": "^9.5.1", 37 | "webpack": "^5.89.0", 38 | "webpack-cli": "^5.1.4", 39 | "webpack-dev-server": "^4.15.1" 40 | }, 41 | "dependencies": { 42 | "@note-rack/editor": "^1.0.16", 43 | "@types/katex": "^0.16.7", 44 | "katex": "^0.16.9" 45 | }, 46 | "peerDependencies": { 47 | "react": "<18.0.0", 48 | "react-dom": "<18.0.0" 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /packages/plugin-math-latex/scripts/build.sh: -------------------------------------------------------------------------------- 1 | # ~ Compile the typescript code 2 | swc -C module.type=commonjs -d dist src/ 3 | 4 | # ~ Compile the typescript types 5 | npx tsc -b . 6 | 7 | # ~ Copy the package.json and .npmignore file to the dist folder 8 | cp package.json dist/package.json 9 | cp .npmignore dist/.npmignore 10 | 11 | # NOTE: The -e flag is used to make the following sed command work on MacOS 12 | 13 | # ~ Remove the dist folder from the package.json file 14 | sed -i -e 's|"dist/|"./|g' ./dist/package.json 15 | sed -i -e '/"files": \[/,/\]/d' ./dist/package.json 16 | 17 | # ~ Remove the backup file created by the sed command 18 | rm ./dist/package.json-e 19 | -------------------------------------------------------------------------------- /packages/plugin-math-latex/src/demo/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom"; 3 | import { Editor } from "@note-rack/editor"; 4 | import blockRegexFactory from "@note-rack/editor/lib/factories/blockRegexFactory"; 5 | 6 | import createMathRenderer from '../createMathRenderer'; 7 | 8 | const Demo: React.FC = () => ( 9 | ( 13 |

17 | {properties.text} 18 |

19 | ) 20 | }} 21 | startingBlocks={[ 22 | { 23 | id: "1", 24 | type: "math", 25 | properties: { 26 | text: "x^2 + y^2 = z^2", 27 | } 28 | }, 29 | { 30 | id: "2", 31 | type: "math", 32 | properties: { 33 | text: ` 34 | \\begin{aligned} 35 | x &= 2 \\\\ 36 | y &= 3 37 | \\end{aligned} 38 | ` 39 | } 40 | }, 41 | ]} 42 | inlineBlocks={{}} 43 | richTextKeybinds={[ 44 | { 45 | handler: blockRegexFactory("math"), 46 | regex: /^\$\$ (.*)/g, 47 | } 48 | ]} 49 | /> 50 | ); 51 | 52 | ReactDOM.render( 53 | , 54 | document.getElementById('root') 55 | ); -------------------------------------------------------------------------------- /packages/plugin-math-latex/src/index.ts: -------------------------------------------------------------------------------- 1 | import createMathRenderer from "./createMathRenderer"; 2 | 3 | export default createMathRenderer; 4 | 5 | -------------------------------------------------------------------------------- /packages/plugin-math-latex/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es6", 4 | "allowJs": true, 5 | "skipLibCheck": true, 6 | "strict": true, 7 | "forceConsistentCasingInFileNames": true, 8 | "esModuleInterop": true, 9 | "module": "commonjs", 10 | "moduleResolution": "node", 11 | "resolveJsonModule": true, 12 | "incremental": true, 13 | "outDir": "dist", 14 | "sourceMap": true, 15 | "jsx": "preserve", 16 | "declaration": true, 17 | "declarationMap": true, 18 | }, 19 | "include": ["**/*.ts", "**/*.tsx"], 20 | "exclude": [ 21 | "node_modules", "dist", 22 | ], 23 | } -------------------------------------------------------------------------------- /packages/plugin-math-latex/webpack.config.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-var-requires */ 2 | // Generated using webpack-cli https://github.com/webpack/webpack-cli 3 | 4 | const path = require('path'); 5 | const HtmlWebpackPlugin = require('html-webpack-plugin'); 6 | 7 | const isProduction = process.env.NODE_ENV === 'production'; 8 | 9 | const config = { 10 | entry: { 11 | index: './src/demo/index.tsx', 12 | }, 13 | output: { 14 | path: path.resolve(__dirname, 'dist'), 15 | }, 16 | devServer: { 17 | open: false, 18 | host: 'localhost', 19 | }, 20 | plugins: [ 21 | new HtmlWebpackPlugin({ 22 | template: 'index.html', 23 | }), 24 | 25 | // Add your plugins here 26 | // Learns more about plugins from https://webpack.js.org/configuration/plugins/ 27 | ], 28 | module: { 29 | rules: [ 30 | { 31 | test: /\.(ts|tsx)$/i, 32 | loader: 'babel-loader', 33 | exclude: [ 34 | '/node_modules/', 35 | '/src/__tests__/', 36 | ], 37 | }, 38 | { 39 | test: /\.(eot|svg|ttf|woff|woff2|png|jpg|gif)$/i, 40 | type: 'asset', 41 | }, 42 | 43 | // Add your rules for custom modules here 44 | // Learn more about loaders from https://webpack.js.org/loaders/ 45 | ], 46 | }, 47 | resolve: { 48 | extensions: ['.tsx', '.ts', '.js'], 49 | }, 50 | }; 51 | 52 | module.exports = () => { 53 | if (isProduction) { 54 | config.mode = 'production'; 55 | } else { 56 | config.mode = 'development'; 57 | } 58 | return config; 59 | }; -------------------------------------------------------------------------------- /packages/plugin-virtual-select/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "@babel/preset-env", 4 | "@babel/preset-react", 5 | "@babel/preset-typescript" 6 | ], 7 | "plugins": [ 8 | "@babel/plugin-syntax-dynamic-import" 9 | ] 10 | } -------------------------------------------------------------------------------- /packages/plugin-virtual-select/.gitignore: -------------------------------------------------------------------------------- 1 | dist/** -------------------------------------------------------------------------------- /packages/plugin-virtual-select/.npmignore: -------------------------------------------------------------------------------- 1 | __tests__/** 2 | demo/** -------------------------------------------------------------------------------- /packages/plugin-virtual-select/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |
9 | 10 | 11 | -------------------------------------------------------------------------------- /packages/plugin-virtual-select/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@note-rack/plugin-virtual-select", 3 | "description": "A plugin for Note Rack that allows you to move blocks around by dragging and dropping them.", 4 | "version": "1.0.3", 5 | "module": "dist/index.js", 6 | "umd": "dist/index.umd.js", 7 | "main": "dist/index.js", 8 | "types": "dist/index.d.ts", 9 | "repository": "https://github.com/Eroxl/Note-Rack/tree/main/packages/plugin-math-latex", 10 | "author": "Eroxl", 11 | "license": "AGPL-3.0-only", 12 | "private": false, 13 | "type": "commonjs", 14 | "files": [ 15 | "src", 16 | "dist" 17 | ], 18 | "scripts": { 19 | "build": "sh ./scripts/build.sh", 20 | "dev": "webpack-dev-server --hot --config ./webpack.config.js" 21 | }, 22 | "devDependencies": { 23 | "@babel/core": "^7.23.7", 24 | "@babel/plugin-syntax-dynamic-import": "^7.8.3", 25 | "@babel/preset-env": "^7.23.7", 26 | "@babel/preset-react": "^7.23.3", 27 | "@babel/preset-typescript": "^7.23.3", 28 | "@note-rack/plugin-styled-text": "^1.0.6", 29 | "@types/react": "^18.2.28", 30 | "@types/react-dom": "^18.2.13", 31 | "@webpack-cli/generators": "^3.0.7", 32 | "babel-loader": "^9.1.3", 33 | "html-webpack-plugin": "^5.6.0", 34 | "react": "^18.2.0", 35 | "react-dom": "^18.2.0", 36 | "swc": "^1.0.11", 37 | "ts-loader": "^9.5.1", 38 | "webpack": "^5.89.0", 39 | "webpack-cli": "^5.1.4", 40 | "webpack-dev-server": "^4.15.1" 41 | }, 42 | "dependencies": { 43 | "@note-rack/editor": "^1.0.16", 44 | "react-virtual-selection": "^1.1.0" 45 | }, 46 | "peerDependencies": { 47 | "@note-rack/editor": "^1.0.6", 48 | "react": "<18.0.0", 49 | "react-dom": "<18.0.0", 50 | "react-virtual-selection": "^1.1.0" 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /packages/plugin-virtual-select/scripts/build.sh: -------------------------------------------------------------------------------- 1 | # ~ Compile the typescript code 2 | swc -C module.type=commonjs -d dist src/ 3 | 4 | # ~ Compile the typescript types 5 | npx tsc -b . 6 | 7 | # ~ Copy the package.json and .npmignore file to the dist folder 8 | cp package.json dist/package.json 9 | cp .npmignore dist/.npmignore 10 | 11 | # NOTE: The -e flag is used to make the following sed command work on MacOS 12 | 13 | # ~ Remove the dist folder from the package.json file 14 | sed -i -e 's|"dist/|"./|g' ./dist/package.json 15 | sed -i -e '/"files": \[/,/\]/d' ./dist/package.json 16 | 17 | # ~ Remove the backup file created by the sed command 18 | rm ./dist/package.json-e 19 | -------------------------------------------------------------------------------- /packages/plugin-virtual-select/src/components/createWrapper.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from 'react'; 2 | import type { EditorProps } from '@note-rack/editor/components/Editor'; 3 | import { SelectionManager, useSelectable } from 'react-virtual-selection'; 4 | 5 | type Wrapper = Required['blockWrappers'][number] 6 | 7 | const createWrapper = ( 8 | selectedStyle?: React.CSSProperties, 9 | selectedClassName?: string, 10 | ): Wrapper => { 11 | return ({ children, block }) => { 12 | const [isSelected, selectableElement] = useSelectable( 13 | 'selectableBlock', 14 | () => block, 15 | [block], 16 | ) 17 | 18 | useEffect(() => { 19 | document.getSelection()?.removeAllRanges(); 20 | }, [isSelected]); 21 | 22 | return ( 23 |
{ 28 | e.stopPropagation(); 29 | }} 30 | 31 | onFocus={() => { 32 | SelectionManager.Instance.highlightSelected({ 33 | top: Infinity, bottom: Infinity, left: Infinity, right: Infinity, 34 | }, 'selectableBlock'); 35 | }} 36 | 37 | draggable={false} 38 | ref={selectableElement as React.LegacyRef} 39 | > 40 | {children} 41 |
42 | ); 43 | } 44 | } 45 | 46 | export default createWrapper; 47 | -------------------------------------------------------------------------------- /packages/plugin-virtual-select/src/createVirtualSelectPlugin.ts: -------------------------------------------------------------------------------- 1 | import Plugin from "@note-rack/editor/types/Plugin"; 2 | 3 | import createWrapper from "./components/createWrapper"; 4 | import handleSelectAll from "./lib/keybinds/handleSelectAll"; 5 | 6 | const createVirtualSelectPlugin = ( 7 | selectedBlockStyle?: React.CSSProperties, 8 | selectedBlockClassName?: string, 9 | ): Plugin => ({ 10 | blockWrappers: [ 11 | createWrapper(selectedBlockStyle, selectedBlockClassName) 12 | ], 13 | keybinds: [ 14 | handleSelectAll, 15 | ], 16 | }); 17 | 18 | export default createVirtualSelectPlugin; 19 | -------------------------------------------------------------------------------- /packages/plugin-virtual-select/src/demo/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom"; 3 | import { Editor } from "@note-rack/editor"; 4 | import createStyledTextRenderer from "@note-rack/plugin-styled-text"; 5 | import { Selectable } from "react-virtual-selection"; 6 | import createVirtualSelectPlugin from "../createVirtualSelectPlugin"; 7 | 8 | const selectedStyle = { 9 | backgroundColor: "rgb(125 211 252 / 0.2)", 10 | } 11 | 12 | const virtualSelectPlugin = createVirtualSelectPlugin( 13 | selectedStyle, 14 | ) 15 | 16 | const Demo: React.FC = () => ( 17 | 32 |
37 | 78 |
79 |
80 | ); 81 | 82 | ReactDOM.render( 83 | , 84 | document.getElementById('root') 85 | ); 86 | -------------------------------------------------------------------------------- /packages/plugin-virtual-select/src/lib/keybinds/handleSelectAll.ts: -------------------------------------------------------------------------------- 1 | import KeybindHandler from "@note-rack/editor/types/KeybindHandler"; 2 | import { SelectionManager } from 'react-virtual-selection'; 3 | 4 | const handleSelectAll: KeybindHandler = { 5 | keybind: "Meta+A", 6 | handler: (_, __, ___, e) => { 7 | SelectionManager.Instance.highlightSelected({ 8 | top: -Infinity, bottom: Infinity, left: -Infinity, right: Infinity, 9 | }, 'selectableBlock'); 10 | 11 | e.preventDefault(); 12 | }, 13 | } 14 | 15 | export default handleSelectAll; 16 | -------------------------------------------------------------------------------- /packages/plugin-virtual-select/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es6", 4 | "allowJs": true, 5 | "skipLibCheck": true, 6 | "strict": true, 7 | "forceConsistentCasingInFileNames": true, 8 | "esModuleInterop": true, 9 | "module": "commonjs", 10 | "moduleResolution": "node", 11 | "resolveJsonModule": true, 12 | "incremental": true, 13 | "outDir": "dist", 14 | "sourceMap": true, 15 | "jsx": "preserve", 16 | "declaration": true, 17 | "declarationMap": true, 18 | }, 19 | "include": ["**/*.ts", "**/*.tsx"], 20 | "exclude": [ 21 | "node_modules", "dist", 22 | ], 23 | } -------------------------------------------------------------------------------- /packages/plugin-virtual-select/webpack.config.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-var-requires */ 2 | // Generated using webpack-cli https://github.com/webpack/webpack-cli 3 | 4 | const path = require('path'); 5 | const HtmlWebpackPlugin = require('html-webpack-plugin'); 6 | 7 | const isProduction = process.env.NODE_ENV === 'production'; 8 | 9 | const config = { 10 | entry: { 11 | index: './src/demo/index.tsx', 12 | }, 13 | output: { 14 | path: path.resolve(__dirname, 'dist'), 15 | }, 16 | devServer: { 17 | open: false, 18 | host: 'localhost', 19 | }, 20 | plugins: [ 21 | new HtmlWebpackPlugin({ 22 | template: 'index.html', 23 | }), 24 | 25 | // Add your plugins here 26 | // Learns more about plugins from https://webpack.js.org/configuration/plugins/ 27 | ], 28 | module: { 29 | rules: [ 30 | { 31 | test: /\.(ts|tsx)$/i, 32 | loader: 'babel-loader', 33 | exclude: [ 34 | '/node_modules/', 35 | '/src/__tests__/', 36 | ], 37 | }, 38 | { 39 | test: /\.(eot|svg|ttf|woff|woff2|png|jpg|gif)$/i, 40 | type: 'asset', 41 | }, 42 | 43 | // Add your rules for custom modules here 44 | // Learn more about loaders from https://webpack.js.org/loaders/ 45 | ], 46 | }, 47 | resolve: { 48 | extensions: ['.tsx', '.ts', '.js'], 49 | }, 50 | }; 51 | 52 | module.exports = () => { 53 | if (isProduction) { 54 | config.mode = 'production'; 55 | } else { 56 | config.mode = 'development'; 57 | } 58 | return config; 59 | }; -------------------------------------------------------------------------------- /packages/react-virtual-selection/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "@babel/preset-env", 4 | "@babel/preset-react", 5 | "@babel/preset-typescript" 6 | ], 7 | "plugins": [ 8 | "@babel/plugin-syntax-dynamic-import", 9 | "@babel/plugin-proposal-class-properties" 10 | ] 11 | } -------------------------------------------------------------------------------- /packages/react-virtual-selection/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | browser: true, 4 | es2021: true, 5 | }, 6 | extends: [ 7 | 'eslint:recommended', 8 | 'plugin:react/recommended', 9 | 'airbnb', 10 | 'plugin:@typescript-eslint/eslint-recommended', 11 | 'plugin:@typescript-eslint/recommended', 12 | ], 13 | parser: '@typescript-eslint/parser', 14 | parserOptions: { 15 | ecmaFeatures: { 16 | jsx: true, 17 | }, 18 | ecmaVersion: 13, 19 | sourceType: 'module', 20 | }, 21 | plugins: ['react', '@typescript-eslint'], 22 | rules: { 23 | 'react/jsx-filename-extension': [2, { extensions: ['.js', '.jsx', '.ts', '.tsx'] }], 24 | 'no-use-before-define': 'off', 25 | '@typescript-eslint/no-use-before-define': ['error'], 26 | 'import/order': [ 27 | 'error', 28 | { 29 | groups: [['external', 'builtin'], 'internal', ['parent', 'sibling', 'index']], 30 | 'newlines-between': 'always', 31 | }, 32 | ], 33 | 'import/extensions': 0, 34 | 'react/function-component-definition': 'off', 35 | 'no-param-reassign': 0, 36 | '@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }], 37 | 'react/require-default-props': 'off', 38 | }, 39 | settings: { 40 | 'import/resolver': { 41 | node: { 42 | extensions: ['.js', '.jsx', '.ts', '.tsx'], 43 | }, 44 | }, 45 | }, 46 | }; 47 | -------------------------------------------------------------------------------- /packages/react-virtual-selection/.gitignore: -------------------------------------------------------------------------------- 1 | dist/ -------------------------------------------------------------------------------- /packages/react-virtual-selection/.npmignore: -------------------------------------------------------------------------------- 1 | src/ 2 | images/ 3 | .eslintrc.js 4 | index.html 5 | postcss.config.js 6 | tailwind.config.js -------------------------------------------------------------------------------- /packages/react-virtual-selection/.swcrc: -------------------------------------------------------------------------------- 1 | { 2 | "sourceMaps": true, 3 | "jsc": { 4 | "target": "es2017", 5 | "parser": { 6 | "syntax": "typescript", 7 | "tsx": true 8 | }, 9 | "transform": { 10 | "react": { "runtime": "automatic", "useBuiltins": true } 11 | } 12 | } 13 | } -------------------------------------------------------------------------------- /packages/react-virtual-selection/images/Example.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Eroxl/Note-Rack/1e457868c76256bf8de2390bd09608e3f6927c9e/packages/react-virtual-selection/images/Example.gif -------------------------------------------------------------------------------- /packages/react-virtual-selection/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | React Virtual Selection 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /packages/react-virtual-selection/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-virtual-selection", 3 | "version": "1.1.0", 4 | "description": "A component to select children like in native OS desktops.", 5 | "main": "dist/index.js", 6 | "types": "dist/index.d.ts", 7 | "repository": "https://github.com/Eroxl/Note-Rack/tree/main/packages/react-virtual-selection", 8 | "author": "Eroxl", 9 | "license": "AGPL-3.0-only", 10 | "private": false, 11 | "type": "commonjs", 12 | "scripts": { 13 | "dev": "webpack-dev-server --hot --config ./webpack.config.js", 14 | "build:types": "tsc -b .", 15 | "build:esm": "swc -C module.type=commonjs -d dist src/", 16 | "build": "yarn run build:esm && yarn run build:types", 17 | "lint": "eslint ./src/ --fix", 18 | "lint:check": "eslint ./src/" 19 | }, 20 | "devDependencies": { 21 | "@babel/core": "^7.17.10", 22 | "@babel/plugin-proposal-class-properties": "^7.16.7", 23 | "@babel/plugin-syntax-dynamic-import": "^7.8.3", 24 | "@babel/preset-env": "^7.17.10", 25 | "@babel/preset-react": "^7.16.7", 26 | "@babel/preset-typescript": "^7.16.7", 27 | "@types/react": "^18.0.9", 28 | "@types/react-dom": "^18.0.3", 29 | "@typescript-eslint/eslint-plugin": "^5.23.0", 30 | "@typescript-eslint/parser": "^5.23.0", 31 | "@webpack-cli/generators": "^2.4.2", 32 | "autoprefixer": "^10.4.7", 33 | "babel-loader": "^8.2.5", 34 | "css-loader": "^6.7.1", 35 | "eslint": "^7.32.0", 36 | "eslint-config-airbnb": "^19.0.4", 37 | "eslint-plugin-import": "^2.25.3", 38 | "eslint-plugin-jsx-a11y": "^6.5.1", 39 | "eslint-plugin-react": "^7.28.0", 40 | "eslint-plugin-react-hooks": "^4.3.0", 41 | "html-webpack-plugin": "^5.5.0", 42 | "postcss": "^8.4.13", 43 | "postcss-loader": "^6.2.1", 44 | "style-loader": "^3.3.1", 45 | "swc": "^1.0.11", 46 | "ts-loader": "^9.3.0", 47 | "typescript": "^4.6.4", 48 | "webpack": "^5.72.1", 49 | "webpack-cli": "^4.9.2", 50 | "webpack-dev-server": "^4.9.0" 51 | }, 52 | "peerDependencies": { 53 | "react": "^16.13.1" 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /packages/react-virtual-selection/postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | // Add you postcss configuration here 3 | // Learn more about it at https://github.com/webpack-contrib/postcss-loader#config-files 4 | plugins: { 5 | autoprefixer: {}, 6 | }, 7 | }; 8 | -------------------------------------------------------------------------------- /packages/react-virtual-selection/src/demo/App.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from 'react'; 2 | 3 | import useSelectionCollector from '../hooks/useSelectionCollector'; 4 | import Selectable from '../components/Selectable'; 5 | import ExampleSelectable from './components/ExampleSelectable'; 6 | 7 | const App = () => { 8 | const selectionData = useSelectionCollector('selectable-1'); 9 | 10 | useEffect(() => { 11 | // eslint-disable-next-line no-console 12 | console.log(selectionData); 13 | }, [selectionData]); 14 | 15 | return ( 16 | 17 |
18 |
19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 |
29 |
30 |
31 | ); 32 | }; 33 | 34 | export default App; 35 | -------------------------------------------------------------------------------- /packages/react-virtual-selection/src/demo/components/ExampleSelectable.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import useSelectable from '../../hooks/useSelectable'; 4 | 5 | interface ExampleSelectableProps { 6 | exampleData: unknown, 7 | } 8 | 9 | const ExampleSelectable: React.FC = (props) => { 10 | const { exampleData } = props; 11 | 12 | const [isSelected, selectableElement] = useSelectable( 13 | 'selectable-1', 14 | () => ({ exampleData }), 15 | ); 16 | 17 | return ( 18 |
} 22 | /> 23 | ); 24 | }; 25 | 26 | export default ExampleSelectable; 27 | -------------------------------------------------------------------------------- /packages/react-virtual-selection/src/hooks/useSelectable.ts: -------------------------------------------------------------------------------- 1 | import { useState, useRef, useEffect } from 'react'; 2 | 3 | import SelectionManger from '../classes/SelectionManager'; 4 | 5 | const useSelectable = (type: string, item: () => unknown, deps: unknown[] = []): [boolean, React.MutableRefObject] => { 6 | const [selected, setSelected] = useState(false); 7 | const selectableRef = useRef(null); 8 | 9 | useEffect(() => { 10 | const items = item(); 11 | 12 | SelectionManger.Instance.addToSelectable(selectableRef, type, () => items, setSelected); 13 | return () => SelectionManger.Instance.removeFromSelectable(selectableRef, type); 14 | }, deps); 15 | 16 | return [selected, selectableRef]; 17 | }; 18 | 19 | export default useSelectable; 20 | -------------------------------------------------------------------------------- /packages/react-virtual-selection/src/hooks/useSelectionCollector.ts: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | 3 | import SelectionManager from '../classes/SelectionManager'; 4 | 5 | const useSelectionCollector = (type: string) => { 6 | const [selectionData, setSelectionData] = useState([]); 7 | 8 | SelectionManager.Instance.registerSelectableWatcher(type, setSelectionData); 9 | 10 | return selectionData; 11 | }; 12 | 13 | export default useSelectionCollector; 14 | -------------------------------------------------------------------------------- /packages/react-virtual-selection/src/index.ts: -------------------------------------------------------------------------------- 1 | // -=- Hooks -=- 2 | import useSelectable from './hooks/useSelectable'; 3 | import useSelectionCollector from './hooks/useSelectionCollector'; 4 | import Selectable from './components/Selectable'; 5 | import SelectionManager from './classes/SelectionManager'; 6 | 7 | export { 8 | useSelectable, 9 | useSelectionCollector, 10 | Selectable, 11 | SelectionManager, 12 | }; 13 | -------------------------------------------------------------------------------- /packages/react-virtual-selection/src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | 4 | import App from './demo/App'; 5 | 6 | ReactDOM.render(, document.getElementById('root')); 7 | -------------------------------------------------------------------------------- /packages/react-virtual-selection/tailwind.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | content: ['./src/**/*.{ts,tsx}'], 3 | theme: { 4 | extend: {}, 5 | }, 6 | plugins: [], 7 | }; 8 | -------------------------------------------------------------------------------- /packages/react-virtual-selection/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es6", 4 | "allowJs": true, 5 | "skipLibCheck": true, 6 | "strict": true, 7 | "forceConsistentCasingInFileNames": true, 8 | "esModuleInterop": true, 9 | "module": "commonjs", 10 | "moduleResolution": "node", 11 | "resolveJsonModule": true, 12 | "incremental": true, 13 | "outDir": "dist", 14 | "sourceMap": true, 15 | "jsx": "preserve", 16 | "declaration": true, 17 | "declarationMap": true, 18 | }, 19 | "include": ["**/*.ts", "**/*.tsx"], 20 | "exclude": ["node_modules", "dist", "src/index.tsx", "src/demo/**"], 21 | } -------------------------------------------------------------------------------- /packages/react-virtual-selection/webpack.config.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-var-requires */ 2 | // Generated using webpack-cli https://github.com/webpack/webpack-cli 3 | 4 | const path = require('path'); 5 | const HtmlWebpackPlugin = require('html-webpack-plugin'); 6 | 7 | const isProduction = process.env.NODE_ENV === 'production'; 8 | 9 | const stylesHandler = 'style-loader'; 10 | 11 | const config = { 12 | entry: { 13 | index: './src/index.tsx', 14 | }, 15 | output: { 16 | path: path.resolve(__dirname, 'dist'), 17 | }, 18 | devServer: { 19 | open: false, 20 | host: 'localhost', 21 | }, 22 | plugins: [ 23 | new HtmlWebpackPlugin({ 24 | template: 'index.html', 25 | }), 26 | 27 | // Add your plugins here 28 | // Learns more about plugins from https://webpack.js.org/configuration/plugins/ 29 | ], 30 | module: { 31 | rules: [ 32 | { 33 | test: /\.(ts|tsx)$/i, 34 | loader: 'babel-loader', 35 | exclude: ['/node_modules/'], 36 | }, 37 | { 38 | test: /\.css$/i, 39 | use: [stylesHandler, 'css-loader', 'postcss-loader'], 40 | }, 41 | { 42 | test: /\.(eot|svg|ttf|woff|woff2|png|jpg|gif)$/i, 43 | type: 'asset', 44 | }, 45 | 46 | // Add your rules for custom modules here 47 | // Learn more about loaders from https://webpack.js.org/loaders/ 48 | ], 49 | }, 50 | resolve: { 51 | extensions: ['.tsx', '.ts', '.js'], 52 | }, 53 | }; 54 | 55 | module.exports = () => { 56 | if (isProduction) { 57 | config.mode = 'production'; 58 | } else { 59 | config.mode = 'development'; 60 | } 61 | return config; 62 | }; 63 | -------------------------------------------------------------------------------- /web/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | browser: true, 4 | es2021: true, 5 | node: true, 6 | }, 7 | extends: [ 8 | 'eslint:recommended', 9 | 'plugin:react/recommended', 10 | 'airbnb', 11 | 'plugin:@typescript-eslint/eslint-recommended', 12 | 'plugin:@typescript-eslint/recommended', 13 | ], 14 | parser: '@typescript-eslint/parser', 15 | parserOptions: { 16 | ecmaFeatures: { 17 | jsx: true, 18 | }, 19 | ecmaVersion: 13, 20 | sourceType: 'module', 21 | }, 22 | plugins: ['react', '@typescript-eslint'], 23 | rules: { 24 | 'react/jsx-filename-extension': [2, { extensions: ['.js', '.jsx', '.ts', '.tsx'] }], 25 | 'no-use-before-define': 'off', 26 | '@typescript-eslint/no-use-before-define': ['error'], 27 | 'import/order': [ 28 | 'error', 29 | { 30 | groups: [['external', 'builtin'], 'internal', ['parent', 'sibling', 'index']], 31 | 'newlines-between': 'always', 32 | }, 33 | ], 34 | 'import/extensions': 0, 35 | 'react/function-component-definition': 'off', 36 | 'no-param-reassign': 0, 37 | '@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }], 38 | 'no-underscore-dangle': ['error', { allow: ['_id'] }], 39 | }, 40 | settings: { 41 | 'import/resolver': { 42 | node: { 43 | extensions: ['.js', '.jsx', '.ts', '.tsx'], 44 | }, 45 | }, 46 | }, 47 | }; 48 | -------------------------------------------------------------------------------- /web/.gitignore: -------------------------------------------------------------------------------- 1 | # -=- NextJS -=- 2 | **/.next/** 3 | 4 | # -=- Enviornment Variables -=- 5 | **/.env.local 6 | -------------------------------------------------------------------------------- /web/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:20.2.0 2 | 3 | WORKDIR /usr/src/ 4 | 5 | # -=- Install Packages -=- 6 | COPY package.json ./ 7 | COPY yarn.lock ./ 8 | RUN yarn 9 | 10 | # -=- Copy Source Code -=- 11 | COPY . . 12 | 13 | # -=- Expose The Port -=- 14 | EXPOSE 3000 15 | 16 | # -=- Build / Run The Code -=- 17 | CMD [ "yarn", "run", "start" ] -------------------------------------------------------------------------------- /web/jest.config.js: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line @typescript-eslint/no-var-requires 2 | const nextJest = require('next/jest'); 3 | 4 | const createJestConfig = nextJest({ 5 | dir: './src/', 6 | }); 7 | 8 | const jestConfig = { 9 | moduleNameMapper: { 10 | '^@/components/(.*)$': '/components/$1', 11 | '^@/pages/(.*)$': '/pages/$1', 12 | }, 13 | testPathIgnorePatterns: [ 14 | 'renderWrapper.tsx', 15 | 'setupTests.ts', 16 | ], 17 | testEnvironment: 'jest-environment-jsdom', 18 | automock: false, 19 | resetMocks: false, 20 | setupFilesAfterEnv: [ 21 | './src/__tests__/setupTests.ts', 22 | ], 23 | }; 24 | 25 | module.exports = createJestConfig(jestConfig); 26 | -------------------------------------------------------------------------------- /web/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "web", 3 | "private": true, 4 | "scripts": { 5 | "dev": "next dev ./src/", 6 | "start": "next build ./src/ && next start ./src/", 7 | "lint": "next lint ./src/" 8 | }, 9 | "resolutions": { 10 | "@types/react": "17.0.2", 11 | "@types/react-dom": "17.0.2" 12 | }, 13 | "dependencies": { 14 | "@emotion/react": "^11.11.1", 15 | "@emotion/styled": "^11.11.0", 16 | "@mui/icons-material": "^5.14.11", 17 | "@mui/material": "^5.14.11", 18 | "dompurify": "^3.0.5", 19 | "dotenv": "^14.2.0", 20 | "emoji-mart": "^3.0.1", 21 | "express": "^4.17.2", 22 | "katex": "^0.16.4", 23 | "next": "12.0.8", 24 | "react": "17.0.2", 25 | "react-contenteditable": "^3.3.7", 26 | "react-dnd": "^15.1.2", 27 | "react-dnd-html5-backend": "^15.1.3", 28 | "react-dom": "17.0.2", 29 | "react-virtual-selection": "1.1.0", 30 | "supertokens-auth-react": "^0.28.1" 31 | }, 32 | "devDependencies": { 33 | "@testing-library/dom": "^8.12.0", 34 | "@testing-library/jest-dom": "^5.16.2", 35 | "@testing-library/react": "^12.1.3", 36 | "@testing-library/user-event": "^13.5.0", 37 | "@types/dompurify": "^2.3.3", 38 | "@types/emoji-mart": "^3.0.9", 39 | "@types/jest": "^27.4.0", 40 | "@types/katex": "^0.16.0", 41 | "@types/node": "17.0.10", 42 | "@types/react": "17.0.38", 43 | "@typescript-eslint/eslint-plugin": "^5.10.0", 44 | "@typescript-eslint/parser": "^5.10.0", 45 | "autoprefixer": "^10.4.2", 46 | "eslint": "^8.7.0", 47 | "eslint-config-airbnb": "^19.0.4", 48 | "eslint-config-next": "12.0.8", 49 | "eslint-plugin-import": "^2.25.4", 50 | "eslint-plugin-jsx-a11y": "^6.5.1", 51 | "eslint-plugin-react": "^7.28.0", 52 | "eslint-plugin-react-hooks": "^4.3.0", 53 | "jest": "^27.5.1", 54 | "jest-fetch-mock": "^3.0.3", 55 | "postcss": "^8.4.5", 56 | "tailwindcss": "^3.0.15", 57 | "typescript": "^4.5.5" 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /web/postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /web/src/components/LoadingPage.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import Spinner from './Spinner'; 4 | 5 | const LoadingPage = () => ( 6 |
7 |
8 |
9 | 10 |
11 |
12 |
13 | ); 14 | 15 | export default LoadingPage; 16 | -------------------------------------------------------------------------------- /web/src/components/Spinner.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | const Spinner = () => ( 4 |
5 | ); 6 | 7 | export default Spinner; 8 | -------------------------------------------------------------------------------- /web/src/components/blocks/BlockHandle.tsx: -------------------------------------------------------------------------------- 1 | import React, { useRef } from 'react'; 2 | 3 | interface BlockHandleProps { 4 | draggableRef: React.LegacyRef, 5 | } 6 | 7 | const BlockHandle = (props: BlockHandleProps) => { 8 | const menuButtonRef = useRef(null); 9 | const { draggableRef } = props; 10 | 11 | return ( 12 |
13 |
14 |
18 | 19 | 20 | 21 | 22 |
23 |
24 |
25 | ); 26 | }; 27 | 28 | export default BlockHandle; 29 | -------------------------------------------------------------------------------- /web/src/components/blocks/PageBlock.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from 'react'; 2 | import Link from 'next/link'; 3 | 4 | import getPageInfo from '../../lib/pages/getPageInfo'; 5 | 6 | interface PageBlockProps { 7 | blockID: string; 8 | properties: { 9 | value: string, 10 | } 11 | } 12 | 13 | const PageBlock = (props: PageBlockProps) => { 14 | const { blockID, properties } = props; 15 | const { value } = properties; 16 | 17 | const [currentValue, setCurrentValue] = useState(value); 18 | 19 | useEffect(() => { 20 | (async () => { 21 | const { style } = await getPageInfo(blockID); 22 | const { icon, name } = style; 23 | 24 | setCurrentValue(`${icon} ${name}`); 25 | })(); 26 | }, [value]); 27 | 28 | return ( 29 | 36 | 42 | [[ 43 | {' '} 44 | 45 | {currentValue} 46 | 47 | {' '} 48 | ]] 49 | 50 | 51 | ); 52 | }; 53 | 54 | export default PageBlock; 55 | -------------------------------------------------------------------------------- /web/src/components/home/AuthButton.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable max-len */ 2 | import React from 'react'; 3 | import { 4 | getAuthorisationURLWithQueryParamsAndSetState, 5 | } from 'supertokens-auth-react/recipe/thirdparty'; 6 | 7 | type ValidProviders = 'Google'; 8 | 9 | const authButtonClicked = async (company: ValidProviders) => { 10 | const authURL = await getAuthorisationURLWithQueryParamsAndSetState({ 11 | providerId: company.toLowerCase(), 12 | authorisationURL: `${process.env.NEXT_PUBLIC_BASE_URL}/auth/callback/${company.toLowerCase()}`, 13 | }); 14 | 15 | window.location 16 | .assign(authURL); 17 | }; 18 | 19 | const AuthButton = (props: { company: ValidProviders }) => { 20 | const { company } = props; 21 | 22 | return ( 23 | 41 | ); 42 | }; 43 | 44 | export default AuthButton; 45 | -------------------------------------------------------------------------------- /web/src/components/home/AuthNavBar.tsx: -------------------------------------------------------------------------------- 1 | import Link from 'next/link'; 2 | import React from 'react'; 3 | 4 | const AuthNavBar = () => ( 5 |
6 |
7 | 8 | 9 | 10 |

Note Rack

11 |
12 | 13 |
14 |
15 | ); 16 | 17 | export default AuthNavBar; 18 | -------------------------------------------------------------------------------- /web/src/components/home/Intro.tsx: -------------------------------------------------------------------------------- 1 | import Link from 'next/link'; 2 | import React from 'react'; 3 | import Image from 'next/image'; 4 | 5 | import LaptopScreen from './LaptopScreen'; 6 | 7 | const Intro = () => ( 8 |
9 |

12 | Organize your academic life, 13 |

14 | effortlessly. 15 | 23 | 24 | 25 |

26 |

27 | 28 |
29 |

30 | Streamline your study routine with Note Rack: the unified 31 |
32 | hub for all your notes and tasks. 33 |

34 |
35 | 36 |
37 | 38 | 42 | Join now! 43 | 44 | 45 |
46 | 47 |
48 | 49 | Note Rack Example 54 | 55 |
56 |
57 | ); 58 | 59 | export default Intro; 60 | -------------------------------------------------------------------------------- /web/src/components/home/LaptopScreen.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | interface LaptopScreenProps { 4 | children: React.ReactNode, 5 | } 6 | 7 | const LaptopScreen = (props: LaptopScreenProps) => { 8 | const { children } = props; 9 | 10 | return ( 11 |
12 |
13 | {children} 14 |
15 | ); 16 | }; 17 | 18 | export default LaptopScreen; 19 | -------------------------------------------------------------------------------- /web/src/components/home/NavBar.tsx: -------------------------------------------------------------------------------- 1 | import Link from 'next/link'; 2 | import React from 'react'; 3 | 4 | const NavBar = () => ( 5 |
6 |
7 | 8 | 9 | 10 |

Note Rack

11 |
12 | 13 |
14 |
15 | 16 | 20 | Login 21 | 22 | 23 |
24 |
25 | ); 26 | 27 | export default NavBar; 28 | -------------------------------------------------------------------------------- /web/src/components/menus/Button.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | interface ButtonProps { 4 | style: 'primary' | 'secondary' 5 | label: string, 6 | onClick: () => void, 7 | } 8 | 9 | const Button: React.FC = (props) => { 10 | const { 11 | style, 12 | label, 13 | onClick 14 | } = props; 15 | 16 | const styles: Record = { 17 | primary: 'bg-blue-500 border border-blue-600 rounded text-amber-50', 18 | secondary: 'mr-2 text-amber-50/70' 19 | }; 20 | 21 | return ( 22 | 28 | ); 29 | }; 30 | 31 | export default Button; 32 | -------------------------------------------------------------------------------- /web/src/components/pageCustomization/OptionsButton.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useRef } from 'react'; 2 | import { useRouter } from 'next/router'; 3 | import MoreHoriz from '@mui/icons-material/MoreHoriz'; 4 | 5 | import OptionsMenu from '../menus/OptionsMenu/OptionsMenu'; 6 | 7 | const ShareButton = () => { 8 | const { page } = useRouter().query; 9 | const [isMenuOpen, setIsMenuOpen] = useState(false); 10 | const buttonRef = useRef(null); 11 | 12 | return ( 13 |
16 |
17 |
{ 20 | setIsMenuOpen(!isMenuOpen); 21 | }} 22 | > 23 | 26 |
27 |
28 | { 29 | isMenuOpen && ( 30 | 35 | ) 36 | } 37 |
38 | ); 39 | }; 40 | 41 | export default ShareButton; 42 | -------------------------------------------------------------------------------- /web/src/contexts/PageContext.ts: -------------------------------------------------------------------------------- 1 | import { createContext } from 'react'; 2 | 3 | import PageDataInterface from '../lib/types/pageTypes'; 4 | 5 | interface PageContextProps { 6 | pageData?: PageDataInterface['message'], 7 | setPageData: React.Dispatch>, 8 | } 9 | 10 | const PageContext = createContext({ 11 | setPageData: (_) => {}, 12 | }); 13 | 14 | export default PageContext; 15 | -------------------------------------------------------------------------------- /web/src/contexts/PagePermissionsContext.ts: -------------------------------------------------------------------------------- 1 | import { createContext } from 'react'; 2 | 3 | import type { UserPermissions, Permissions } from '../lib/types/pageTypes'; 4 | 5 | interface PagePermissionsContextProps { 6 | permissionsOnPage?: UserPermissions, 7 | isMenuOpen: boolean, 8 | setIsMenuOpen: (isMenuOpen: boolean) => void, 9 | isPagePublic: boolean, 10 | setIsPagePublic: (isPagePublic: boolean) => void, 11 | currentPermissions: Permissions, 12 | setCurrentPermissions: (currentPermissions: Permissions) => void, 13 | } 14 | 15 | const PagePermissionContext = createContext({ 16 | isMenuOpen: false, 17 | setIsMenuOpen: () => {}, 18 | isPagePublic: false, 19 | setIsPagePublic: () => {}, 20 | currentPermissions: {}, 21 | setCurrentPermissions: () => {}, 22 | }); 23 | 24 | export default PagePermissionContext; 25 | -------------------------------------------------------------------------------- /web/src/lib/config/superTokensConfig.ts: -------------------------------------------------------------------------------- 1 | import SessionReact from 'supertokens-auth-react/recipe/session'; 2 | import ThirdParty, { 3 | Github, 4 | Google, 5 | } from 'supertokens-auth-react/recipe/thirdparty'; 6 | import Router from 'next/router'; 7 | 8 | const superTokensConfig = () => ({ 9 | appInfo: { 10 | appName: 'Note Rack', 11 | apiDomain: process.env.NEXT_PUBLIC_API_URL || 'http://127.0.0.1:8000', 12 | websiteDomain: process.env.NEXT_PUBLIC_BASE_URL || 'http://127.0.0.1:3000', 13 | apiBasePath: `${process.env.NEXT_PUBLIC_API_URL?.replace(/https?:\/\/.*?\//, '')}/auth` || '/auth', 14 | websiteBasePath: '/auth', 15 | }, 16 | recipeList: [ 17 | ThirdParty.init({ 18 | signInAndUpFeature: { 19 | providers: [ 20 | Google.init(), 21 | ], 22 | }, 23 | }), 24 | SessionReact.init(), 25 | ], 26 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 27 | windowHandler: (oI: any) => ({ 28 | ...oI, 29 | location: { 30 | ...oI.location, 31 | setHref: (href: string) => { 32 | Router.push(href); 33 | }, 34 | }, 35 | }), 36 | }); 37 | 38 | export default superTokensConfig; 39 | -------------------------------------------------------------------------------- /web/src/lib/constants/BlockTypes.ts: -------------------------------------------------------------------------------- 1 | import Icon from '../../components/blocks/Icon'; 2 | import Title from '../../components/pageCustomization/Title'; 3 | import TextBlock from '../../components/blocks/TextBlock'; 4 | import PageBlock from '../../components/blocks/PageBlock'; 5 | import MathBlock from '../../components/blocks/MathBlock'; 6 | 7 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 8 | const BlockTypes = { 9 | // -=- Page Components -=- 10 | 'page-icon': Icon, 11 | 'page-title': Title, 12 | 13 | // -=- Text Based Components -=- 14 | text: TextBlock, 15 | h1: TextBlock, 16 | h2: TextBlock, 17 | h3: TextBlock, 18 | h4: TextBlock, 19 | h5: TextBlock, 20 | 21 | // -=- Semi-Text Based Components -=- 22 | quote: TextBlock, 23 | callout: TextBlock, 24 | 25 | // -=- Inline Page Component -=- 26 | page: PageBlock, 27 | 28 | // -=- Other Components -=- 29 | math: MathBlock 30 | } as const; 31 | 32 | export default BlockTypes; 33 | -------------------------------------------------------------------------------- /web/src/lib/constants/InlineTextStyles.ts: -------------------------------------------------------------------------------- 1 | import inlineTextKeybinds from "../inlineTextKeybinds"; 2 | 3 | const InlineTextStyles: { 4 | [key in (typeof inlineTextKeybinds)[number]['type']]: string 5 | } = { 6 | bold: 'font-bold', 7 | italic: 'italic', 8 | underline: 'border-b-[0.1em] dark:border-amber-50 print:dark:border-black border-black', 9 | strikethrough: 'line-through', 10 | } as const; 11 | 12 | export default InlineTextStyles; 13 | -------------------------------------------------------------------------------- /web/src/lib/constants/ShareOptions.ts: -------------------------------------------------------------------------------- 1 | const dropdownInfo: { 2 | title: string, 3 | description: string, 4 | permissions: { 5 | admin: boolean, 6 | write: boolean, 7 | read: boolean, 8 | } 9 | }[] = [ 10 | { 11 | title: 'Full access', 12 | description: 'Can edit, delete, and share', 13 | permissions: { 14 | admin: true, 15 | write: true, 16 | read: true, 17 | }, 18 | }, 19 | { 20 | title: 'Edit only', 21 | description: 'Can edit, but not delete or share', 22 | permissions: { 23 | admin: false, 24 | write: true, 25 | read: true, 26 | }, 27 | }, 28 | { 29 | title: 'View only', 30 | description: 'Cannot edit, delete, or share', 31 | permissions: { 32 | admin: false, 33 | write: false, 34 | read: true, 35 | }, 36 | }, 37 | ]; 38 | 39 | export { dropdownInfo }; 40 | -------------------------------------------------------------------------------- /web/src/lib/constants/TextStyles.ts: -------------------------------------------------------------------------------- 1 | // ~ Styling lookup table for elements 2 | const TextStyles: {[key: string]: string} = { 3 | text: '', 4 | h1: 'text-4xl font-bold', 5 | h2: 'text-3xl font-bold', 6 | h3: 'text-2xl font-bold', 7 | h4: 'text-xl font-bold', 8 | h5: 'text-lg font-bold', 9 | quote: 'border-l-4 pl-3 border-zinc-700 dark:border-amber-50 print:dark:border-zinc-700', 10 | callout: ` 11 | p-3 12 | bg-black/5 dark:bg-white/5 13 | print:bg-transparent print:dark:bg-transparent 14 | print:before:content-['test'] 15 | print:h-full print:w-full 16 | print:before:h-full print:before:w-full 17 | print:before:border-[999px] print:before:-mt-3 print:before:-ml-3 print:before:border-black/5 18 | relative print:overflow-hidden print:before:absolute 19 | `, 20 | }; 21 | 22 | export default TextStyles; 23 | -------------------------------------------------------------------------------- /web/src/lib/deletePage.ts: -------------------------------------------------------------------------------- 1 | const deletePage = async (page: string) => { 2 | // -=- Request -=- 3 | // ~ Send a DELETE request to the API 4 | const deletePageResp = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/page/modify-page/${page}`, { 5 | method: 'DELETE', 6 | headers: { 7 | 'Content-Type': 'application/json', 8 | }, 9 | credentials: 'include', 10 | }); 11 | 12 | // -=- Success Handling -=- 13 | // ~ Get the response as JSON 14 | const deletePageRespJSON = await deletePageResp.json(); 15 | 16 | // ~ If the response is 200 (Ok), return 17 | if (deletePageResp.status === 200) return; 18 | 19 | // -=- Error Handling -=- 20 | // ~ If the response is not 200 (Ok), throw an error 21 | throw new Error(`Couldn't delete page because: ${deletePageRespJSON.message}`); 22 | }; 23 | 24 | export default deletePage; 25 | -------------------------------------------------------------------------------- /web/src/lib/helpers/caret/getCurrentCaretCoordinates.ts: -------------------------------------------------------------------------------- 1 | import isAfterNewLine from './isAfterNewLine'; 2 | 3 | /** 4 | * Get the carets coordinates in the window 5 | * @returns The carets coordinates in the window or undefined if the caret is not in the window 6 | */ 7 | const getCurrentCaretCoordinates = (): { x: number, y: number } | undefined => { 8 | const selection = window.getSelection(); 9 | 10 | if (!selection) return undefined; 11 | 12 | const range = selection.getRangeAt(0).cloneRange(); 13 | range.collapse(true); 14 | 15 | const rect = range.getClientRects()[0]; 16 | 17 | if (!rect) return undefined; 18 | 19 | // ~ Hack because if the caret is at the end of a line, the 20 | // rect will be the end of the line 21 | if (isAfterNewLine(range)) { 22 | return { 23 | x: rect.left, 24 | y: rect.top + rect.height, 25 | }; 26 | } 27 | 28 | return { 29 | x: rect.left, 30 | y: rect.top, 31 | }; 32 | }; 33 | 34 | export default getCurrentCaretCoordinates; 35 | -------------------------------------------------------------------------------- /web/src/lib/helpers/caret/getCursorOffset.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Get the number of characters between the start of the element and the 3 | * cursor 4 | * @param element Element to get the cursor offset for 5 | * @returns Number of characters between the start of the element and the 6 | */ 7 | const getCursorOffset = (element: HTMLElement): number => { 8 | // ~ Get the range and selection 9 | const selection = window.getSelection(); 10 | 11 | if (!selection) return 0; 12 | 13 | if (selection.rangeCount === 0) return 0; 14 | 15 | const range = selection.getRangeAt(0); 16 | 17 | if (!range) return 0; 18 | 19 | try { 20 | // ~ Clone the range and select the contents of the element 21 | const preCaretRange = range.cloneRange(); 22 | preCaretRange.selectNodeContents(element); 23 | 24 | // ~ Set the end of the range to the start of the selection 25 | preCaretRange.setEnd(range.endContainer, range.endOffset); 26 | 27 | // ~ Return the length between the start of the element and the cursor 28 | return preCaretRange.toString().length; 29 | } catch (error) { 30 | // ~ If there is an error, return 0 31 | return 0; 32 | } 33 | }; 34 | 35 | export default getCursorOffset; 36 | -------------------------------------------------------------------------------- /web/src/lib/helpers/caret/isAfterNewLine.ts: -------------------------------------------------------------------------------- 1 | import getLastLineLength from "../getLastLineLength"; 2 | 3 | /** 4 | * Check if the caret is after a new line 5 | * @param range The range to check 6 | * @returns Whether the caret is after a new line 7 | */ 8 | const isAfterNewLine = (range: Range) => { 9 | const rangeContainer = ( 10 | range.startContainer.parentElement 11 | || range.startContainer as HTMLElement 12 | ); 13 | 14 | if ( 15 | !range.startContainer 16 | || !rangeContainer.textContent 17 | || range.startOffset === 0 18 | ) return false; 19 | 20 | const lengthExcludingLastLine = rangeContainer.textContent.length - getLastLineLength(rangeContainer); 21 | 22 | if (lengthExcludingLastLine === 0) return false; 23 | 24 | return lengthExcludingLastLine === range.startOffset; 25 | }; 26 | 27 | export default isAfterNewLine; 28 | -------------------------------------------------------------------------------- /web/src/lib/helpers/caret/isCaretAtBottom.ts: -------------------------------------------------------------------------------- 1 | import getStyleScale from "../getStyleScale"; 2 | import isElementFocused from '../isElementFocused'; 3 | import getCurrentCaretCoordinates from "./getCurrentCaretCoordinates"; 4 | 5 | const isCaretAtBottom = (element: HTMLElement) => { 6 | // ~ Check if the element is focused 7 | if (!isElementFocused(element)) return false; 8 | 9 | const caretCoordinates = getCurrentCaretCoordinates(); 10 | 11 | if (!caretCoordinates) return false; 12 | 13 | const { y } = caretCoordinates; 14 | 15 | // ~ Get the caret position relative to the bottom of the element 16 | const bottomPadding = getStyleScale(element, 'paddingBottom'); 17 | 18 | const elementPosition = element.getBoundingClientRect().bottom - bottomPadding; 19 | 20 | let lineHeight = getStyleScale(element, 'lineHeight'); 21 | const fontSize = getStyleScale(element, 'fontSize'); 22 | 23 | if (Number.isNaN(lineHeight)) lineHeight = 1.2; 24 | 25 | const caretPosition = (elementPosition - y) - lineHeight - fontSize; 26 | 27 | // ~ Check if the caret is at the bottom of the element (within 5px) 28 | return caretPosition < 5; 29 | }; 30 | 31 | export default isCaretAtBottom; 32 | -------------------------------------------------------------------------------- /web/src/lib/helpers/caret/isCaretAtTop.ts: -------------------------------------------------------------------------------- 1 | import getStyleScale from "../getStyleScale"; 2 | import isElementFocused from '../isElementFocused'; 3 | import getCurrentCaretCoordinates from "./getCurrentCaretCoordinates"; 4 | 5 | const isCaretAtTop = (element: HTMLElement) => { 6 | // ~ Check if the element is focused 7 | if (!isElementFocused(element)) return false; 8 | 9 | const caretCoordinates = getCurrentCaretCoordinates(); 10 | 11 | if (!caretCoordinates) return false; 12 | 13 | const { y } = caretCoordinates; 14 | 15 | // ~ Get the caret position relative to the element 16 | const topPadding = getStyleScale(element, 'paddingTop'); 17 | const elementPosition = element.getBoundingClientRect().top + topPadding; 18 | const caretPosition = y - elementPosition; 19 | 20 | // ~ Check if the caret is at the top of the element (within 5px) 21 | return caretPosition < 5; 22 | }; 23 | 24 | export default isCaretAtTop; 25 | -------------------------------------------------------------------------------- /web/src/lib/helpers/findNextBlock.ts: -------------------------------------------------------------------------------- 1 | import type PageDataInterface from "../types/pageTypes"; 2 | 3 | /** 4 | * Get the closest block to the element 5 | * @param element The element to start the search from 6 | * @returns The closest block to the element or null if none found 7 | */ 8 | export const getClosestBlock = ( 9 | element: HTMLElement | null 10 | ): (HTMLElement & { dataset: { blockIndex: string } }) | null => { 11 | if (!element) return null; 12 | 13 | if (element.dataset.blockIndex) { 14 | return element as (HTMLElement & { dataset: { blockIndex: string } }); 15 | } 16 | 17 | return getClosestBlock(element.parentElement); 18 | }; 19 | 20 | /** 21 | * Find the next block in the editor 22 | * @param element The element to start the search from 23 | * @param iterator The iterator function to use to find the next block 24 | * @param pageData The page data to use to find the next block 25 | * @param editor The editor to search in 26 | * @returns The next block or undefined if none found 27 | */ 28 | const findNextBlock = ( 29 | element: HTMLElement | null, 30 | iterator: (start: number) => number, 31 | pageData: PageDataInterface['message'], 32 | editor: HTMLElement | null = document.querySelector('.editor') 33 | ): HTMLElement | undefined => { 34 | if (!element || !pageData || !editor) return; 35 | 36 | const block = getClosestBlock(element); 37 | 38 | if (!block) return; 39 | 40 | const blockIndex = parseInt(block.dataset.blockIndex); 41 | 42 | const nextBlockID = pageData.data[iterator(blockIndex)]?._id; 43 | 44 | const nextBlock = editor.querySelector(`#block-${nextBlockID}`) as HTMLElement; 45 | 46 | if (!nextBlock) { 47 | const title = document.getElementById('page-title-text'); 48 | 49 | if (!title) return; 50 | 51 | return title as HTMLElement; 52 | } 53 | 54 | if (!nextBlock.dataset.blockIndex) return findNextBlock(nextBlock, iterator, pageData, editor); 55 | 56 | return nextBlock; 57 | } 58 | 59 | export default findNextBlock; 60 | -------------------------------------------------------------------------------- /web/src/lib/helpers/focusElement.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Focuses an element 3 | * @param element The element to focus 4 | * @param offset The offset to move the cursor to 5 | */ 6 | const focusElement = (element: HTMLElement, offset: number = 0) => { 7 | element.focus(); 8 | 9 | // ~ Move the cursor to the end of the block unless the only text is a newline 10 | if (element.textContent === '\n') return; 11 | 12 | const range = document.createRange(); 13 | const selection = window.getSelection(); 14 | 15 | if (!selection) return; 16 | 17 | const iterator = document.createNodeIterator( 18 | element, 19 | // eslint-disable-next-line no-bitwise 20 | NodeFilter.SHOW_TEXT | NodeFilter.SHOW_ELEMENT, 21 | { 22 | acceptNode: (childNode) => { 23 | if (childNode.nodeName === 'BR') return NodeFilter.FILTER_ACCEPT; 24 | if (childNode.nodeName === '#text') return NodeFilter.FILTER_ACCEPT; 25 | return NodeFilter.FILTER_SKIP; 26 | }, 27 | }, 28 | ); 29 | 30 | while (iterator.nextNode()) { 31 | const node = iterator.referenceNode; 32 | 33 | const length = node.nodeName === '#text' 34 | ? node.textContent?.length || 0 35 | : 1; 36 | 37 | offset -= length; 38 | 39 | if (offset <= 0) { 40 | const index = Math.max(Math.min(offset + length, length), 0); 41 | 42 | if (node.textContent?.at(index - 1) === '\n') { 43 | range.setStart(node, Math.max(index - 1, 0)); 44 | } else { 45 | range.setStart(node, index); 46 | } 47 | 48 | break; 49 | } 50 | } 51 | 52 | if (offset > 0) { 53 | range.setStart(iterator.referenceNode, iterator.referenceNode.textContent?.length || 0); 54 | } 55 | 56 | selection.removeAllRanges(); 57 | selection.addRange(range); 58 | }; 59 | 60 | export default focusElement; 61 | -------------------------------------------------------------------------------- /web/src/lib/helpers/getCompletion.ts: -------------------------------------------------------------------------------- 1 | import crypto from 'crypto'; 2 | 3 | /** 4 | * Get the completion for a block 5 | * @returns The completion 6 | */ 7 | const getCompletion = async ( 8 | blockIndex: number, 9 | ): Promise => ( 10 | new Promise((resolve, reject) => { 11 | const eventID = crypto.randomBytes(12).toString('hex'); 12 | 13 | document.dispatchEvent( 14 | new CustomEvent('completionRequest', { 15 | detail: { 16 | index: blockIndex, 17 | eventID, 18 | }, 19 | }) 20 | ); 21 | 22 | const handleCompletion = (event: CustomEvent<{ completion: string, eventID: string }>) => { 23 | if (eventID !== event.detail.eventID) return; 24 | 25 | document.removeEventListener('completion', handleCompletion as EventListener); 26 | 27 | resolve(event.detail.completion); 28 | }; 29 | 30 | setTimeout(() => { 31 | document.removeEventListener('completion', handleCompletion as EventListener); 32 | reject('Failed to get completion in time'); 33 | }, 1000); 34 | 35 | document.addEventListener('completion', handleCompletion as EventListener); 36 | }) 37 | ); 38 | 39 | export default getCompletion; 40 | -------------------------------------------------------------------------------- /web/src/lib/helpers/getOffsetCoordinates.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Get the x and y coordinates of a cursor at a given offset 3 | * @param offset Offset of the cursor 4 | * @returns X and y coordinates of the cursor 5 | */ 6 | const getOffsetCoordinates = (element: HTMLElement, offset: number): { x: number, y: number } => { 7 | const range = document.createRange(); 8 | 9 | const iterator = document.createNodeIterator( 10 | element, 11 | NodeFilter.SHOW_TEXT | NodeFilter.SHOW_ELEMENT, 12 | { 13 | acceptNode: (childNode) => { 14 | if (childNode.nodeName === 'BR') return NodeFilter.FILTER_ACCEPT; 15 | if (childNode.nodeName === '#text') return NodeFilter.FILTER_ACCEPT; 16 | return NodeFilter.FILTER_SKIP; 17 | }, 18 | }, 19 | ); 20 | if (!iterator.referenceNode) return { x: 0, y: 0 }; 21 | 22 | while (iterator.nextNode()) { 23 | const textNode = iterator.referenceNode; 24 | 25 | if (!textNode) continue; 26 | 27 | if (textNode.textContent?.length! >= offset) { 28 | range.setStart(textNode, offset); 29 | range.collapse(true); 30 | break; 31 | } 32 | 33 | offset -= textNode.textContent?.length!; 34 | } 35 | 36 | const rect = range.getClientRects()[0]; 37 | 38 | if (!rect) return { x: 0, y: 0 }; 39 | 40 | return { 41 | x: rect.left, 42 | y: rect.top, 43 | }; 44 | }; 45 | 46 | export default getOffsetCoordinates; 47 | -------------------------------------------------------------------------------- /web/src/lib/helpers/getStringDistance.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Get the distance between two strings 3 | * @param a 4 | * @param b 5 | * @returns Distance between the two strings 6 | */ 7 | const getStringDistance = (a: string, b: string): number => { 8 | if (a.length === 0) return b.length; 9 | 10 | if (b.length === 0) return a.length; 11 | 12 | const matrix = []; 13 | 14 | // ~ Increment along the first column of each row 15 | let i; 16 | for (i = 0; i <= b.length; i++) { 17 | matrix[i] = [i]; 18 | } 19 | 20 | // ~ Increment each column in the first row 21 | let j; 22 | for (j = 0; j <= a.length; j++) { 23 | matrix[0][j] = j; 24 | } 25 | 26 | // ~ Fill in the rest of the matrix 27 | for (i = 1; i <= b.length; i++) { 28 | for (j = 1; j <= a.length; j++) { 29 | if (b.charAt(i - 1) === a.charAt(j - 1)) { 30 | matrix[i][j] = matrix[i - 1][j - 1]; 31 | } else { 32 | matrix[i][j] = Math.min( 33 | matrix[i - 1][j - 1], // ~ substitution 34 | matrix[i][j - 1], // ~ insertion 35 | matrix[i - 1][j], // ~ deletion 36 | ) + 1; 37 | } 38 | } 39 | } 40 | 41 | return matrix[b.length][a.length]; 42 | }; 43 | 44 | export default getStringDistance; 45 | -------------------------------------------------------------------------------- /web/src/lib/helpers/getStyleScale.ts: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | /** 4 | * Get the scale of a style property of an element in pixels 5 | * @param element The element to get the style scale from 6 | * @param style The css style property to get the scale from 7 | * @returns The scale of the style property in pixels 8 | */ 9 | const getStyleScale = ( 10 | element: HTMLElement, 11 | style: keyof React.CSSProperties 12 | ) => ( 13 | +window 14 | .getComputedStyle(element) 15 | .getPropertyValue(style.replace(/([A-Z])/g, '-$1')) 16 | .toLowerCase() 17 | .replace('px', '') 18 | ); 19 | 20 | export default getStyleScale; 21 | -------------------------------------------------------------------------------- /web/src/lib/helpers/inlineBlocks/findNodesInRange.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Find the text nodes that contain a given range 3 | * @param element The element to search in 4 | * @param range The range to search for 5 | * @returns An object containing the nodes and the start offset of the nodes 6 | */ 7 | const findNodesInRange = ( 8 | element: HTMLElement, 9 | range: { 10 | start: number; 11 | end: number; 12 | } 13 | ): { 14 | nodes: Node[]; 15 | startOffset: number; 16 | } => { 17 | let startOffset = 0; 18 | let endOffset = 0; 19 | const treeWalker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT, null); 20 | 21 | let blocksContainingRegex: Node[] = []; 22 | 23 | // ~ Find the text nodes that contains the regex 24 | while (treeWalker.nextNode()) { 25 | const length = treeWalker.currentNode.textContent?.length || 0; 26 | 27 | endOffset += length; 28 | 29 | if (endOffset >= range.start) { 30 | blocksContainingRegex.push(treeWalker.currentNode) 31 | 32 | // ~ All of the text nodes the regex could be in have been found 33 | if (endOffset > range.end) break; 34 | 35 | continue; 36 | } 37 | 38 | startOffset += length; 39 | } 40 | 41 | return { 42 | nodes: blocksContainingRegex, 43 | startOffset, 44 | } 45 | }; 46 | 47 | export default findNodesInRange; 48 | -------------------------------------------------------------------------------- /web/src/lib/helpers/isElementFocused.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Checks if the given element is focused or contains the focused element 3 | * @param element The element to check 4 | * @returns Whether the element is focused or contains the focused element 5 | */ 6 | const isElementFocused = (element: HTMLElement) => ( 7 | document.activeElement === element 8 | || element.contains(document.activeElement) 9 | ); 10 | 11 | export default isElementFocused; -------------------------------------------------------------------------------- /web/src/lib/helpers/saveBlock.ts: -------------------------------------------------------------------------------- 1 | import InlineTextStyles from "../constants/InlineTextStyles"; 2 | import type { EditableText } from "../types/blockTypes"; 3 | 4 | /** 5 | * Walk up the tree to get the full text style of the node 6 | * @param node The node to get the full text style of 7 | * @param topNode The top node to stop at 8 | * @returns The full text style of the node 9 | */ 10 | const getFullTextStyle = (node: Node, topNode: HTMLElement) => { 11 | let currentNode = node; 12 | let style: string[] = []; 13 | 14 | while (currentNode.parentElement && currentNode.parentElement !== topNode) { 15 | currentNode = currentNode.parentElement; 16 | 17 | const type = (currentNode as HTMLElement).getAttribute('data-inline-type'); 18 | 19 | if (!type) continue; 20 | 21 | style.push(...JSON.parse(type)); 22 | } 23 | 24 | return style as (keyof typeof InlineTextStyles)[]; 25 | }; 26 | 27 | /** 28 | * Get the representation of the block to save 29 | * @param element The element to save 30 | * @param completionText The completion text to remove 31 | * @returns The value and style of the block 32 | */ 33 | const saveBlock = ( 34 | element: HTMLDivElement, 35 | completionText: string | null = null, 36 | ) => { 37 | const style: EditableText['properties']['style'] = []; 38 | 39 | const treeWalker = document.createTreeWalker( 40 | element, 41 | NodeFilter.SHOW_TEXT, 42 | null 43 | ); 44 | 45 | let length = 0; 46 | 47 | while (treeWalker.nextNode()) { 48 | const node = treeWalker.currentNode; 49 | 50 | if (!node.textContent) continue; 51 | 52 | length += node.textContent.length; 53 | 54 | const type = getFullTextStyle(node, element); 55 | 56 | if (!type.length) continue; 57 | 58 | style.push({ 59 | type, 60 | start: length - node.textContent.length, 61 | end: length, 62 | }); 63 | } 64 | 65 | // ~ Remove the completion text from the end of the block 66 | const completionOffset = completionText?.length || 0; 67 | const elementValue = element.innerText.substring(0, element.innerText.length - completionOffset); 68 | 69 | return { 70 | value: elementValue, 71 | style, 72 | }; 73 | }; 74 | 75 | export default saveBlock; 76 | -------------------------------------------------------------------------------- /web/src/lib/inlineTextKeybinds.ts: -------------------------------------------------------------------------------- 1 | const inlineTextKeybinds = [ 2 | { 3 | keybind: /(\*\*)(.*?)\1/g, 4 | plainTextKeybind: '**', 5 | type: 'bold', 6 | }, 7 | { 8 | keybind: /(? { 2 | // -=- Request -=- 3 | // ~ Send a PATCH request to the API to change the expansion state of the page 4 | const pageTreeResponse = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/account/edit-page-tree/${page}`, { 5 | method: 'PATCH', 6 | headers: { 'Content-Type': 'application/json' }, 7 | credentials: 'include', 8 | body: JSON.stringify({ 9 | 'new-expansion-state': expanded, 10 | }), 11 | }); 12 | 13 | // -=- Success Handling -=- 14 | // ~ If the response is 200 (Ok), return 15 | if (pageTreeResponse.status === 200) return; 16 | 17 | // -=- Error Handling -=- 18 | // ~ If the response is not 200 (Ok), throw an error 19 | const pageTree = await pageTreeResponse.json(); 20 | 21 | throw new Error(`Couldn't get page info because of: ${pageTree.message}`); 22 | }; 23 | 24 | export default editPageTree; 25 | -------------------------------------------------------------------------------- /web/src/lib/pageTrees/getPageTree.ts: -------------------------------------------------------------------------------- 1 | const getPageTree = async () => { 2 | // -=- Request -=- 3 | // ~ Send a GET request to the API to get the page tree 4 | const pageTreeResponse = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/account/get-page-tree`, { 5 | method: 'GET', 6 | credentials: 'include', 7 | }); 8 | 9 | // -=- Success Handling -=- 10 | // ~ Get the response as JSON 11 | const pageTree = await pageTreeResponse.json(); 12 | 13 | // ~ If the response is 200 (Ok), return the page tree 14 | if (pageTreeResponse.status === 200) return pageTree.message; 15 | 16 | // -=- Error Handling -=- 17 | // ~ If the response is not 200 (Ok), throw an error 18 | throw new Error(`Couldn't get page info because of: ${pageTree.message}`); 19 | }; 20 | 21 | export default getPageTree; 22 | -------------------------------------------------------------------------------- /web/src/lib/pages/editStyle.ts: -------------------------------------------------------------------------------- 1 | const editStyle = async (style: Record, page: string) => { 2 | // -=- Fetch -=- 3 | // ~ Send a PATCH request to the API to update the page's style 4 | const styleUpdateResponse = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/page/modify-page/${page}`, { 5 | method: 'PATCH', 6 | headers: { 'Content-Type': 'application/json' }, 7 | credentials: 'include', 8 | body: JSON.stringify({ 9 | style, 10 | }), 11 | }); 12 | 13 | // -=- Response -=- 14 | // ~ If the response is not 200, throw an error 15 | const styleUpdateResponseJSON = await styleUpdateResponse.json(); 16 | 17 | // ~ If the response is 200 (Ok), return 18 | if (styleUpdateResponse.status === 200) return; 19 | 20 | // -=- Error Handling -=- 21 | // ~ If the response is not 200 (Ok), throw an error 22 | throw new Error(`Couldn't update pages style because: ${styleUpdateResponseJSON.message}`); 23 | }; 24 | 25 | export default editStyle; 26 | -------------------------------------------------------------------------------- /web/src/lib/pages/getPageInfo.ts: -------------------------------------------------------------------------------- 1 | const getPageInfo = async (page: string) => { 2 | // -=- Fetch -=- 3 | // ~ Send a GET request to the API to get the page's info 4 | const pageInfoResponse = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/page/get-page-info/${page}`, { 5 | method: 'GET', 6 | credentials: 'include', 7 | }); 8 | 9 | // -=- Response -=- 10 | // ~ Get the response as JSON 11 | const pageInfo = await pageInfoResponse.json(); 12 | 13 | // ~ If the response is 200 (Ok), return the page info 14 | if (pageInfoResponse.status === 200) return pageInfo.message; 15 | 16 | // -=- Error Handling -=- 17 | // ~ If the response is not 200 (Ok), throw an error 18 | throw new Error(`Couldn't get page info because of: ${pageInfo.message}`); 19 | }; 20 | 21 | export default getPageInfo; 22 | -------------------------------------------------------------------------------- /web/src/lib/types/blockTypes.d.ts: -------------------------------------------------------------------------------- 1 | import type { Dispatch, SetStateAction } from 'react'; 2 | 3 | import BlockTypes from '../constants/BlockTypes'; 4 | import type PageDataInterface from './pageTypes'; 5 | import InlineTextStyles from '../../lib/constants/InlineTextStyles'; 6 | 7 | // -=- Used for the base block -=- 8 | interface BaseBlockProps { 9 | blockType: keyof typeof BlockTypes, 10 | blockID: string, 11 | properties: Record, 12 | page: string, 13 | index: number, 14 | } 15 | 16 | // -=- Used for blocks that can't be deleted and are only controlled by the server -=- 17 | interface PermanentBlock { 18 | page: string, 19 | } 20 | 21 | // -=- Used for for blocks that can't be deleted but can be edited -=- 22 | interface PermanentEditableText extends PermanentBlock { 23 | index: number, 24 | } 25 | 26 | // -=- Used for p through h1 -=- 27 | interface EditableText extends PermanentEditableText { 28 | properties: { 29 | value: string, 30 | style?: { 31 | type: (keyof typeof InlineTextStyles)[], 32 | start: number, 33 | end: number, 34 | }[], 35 | }, 36 | type: string, 37 | blockID: string, 38 | setCurrentBlockType: (_type: string) => void, 39 | } 40 | 41 | // -=- Used for check list elements -=- 42 | interface EditableCheckList extends EditableList { 43 | properties: { 44 | value: string, 45 | checked: boolean, 46 | relationship: 'sibling' | 'child', 47 | }, 48 | } 49 | 50 | export type { 51 | BaseBlockProps, 52 | PermanentBlock, 53 | PermanentEditableText, 54 | EditableText, 55 | EditableCheckList, 56 | }; 57 | -------------------------------------------------------------------------------- /web/src/lib/types/pageTypes.d.ts: -------------------------------------------------------------------------------- 1 | import BlockTypes from "../constants/BlockTypes" 2 | 3 | interface Block { 4 | _id: string, 5 | blockType: keyof typeof BlockTypes, 6 | properties: Record, 7 | children: Block[] 8 | } 9 | 10 | export interface UserPermissions { 11 | read: boolean, 12 | write: boolean, 13 | admin: boolean, 14 | } 15 | 16 | export interface Permissions { 17 | [key: string]: { 18 | read: boolean, 19 | write: boolean, 20 | admin: boolean, 21 | email: string, 22 | } 23 | } 24 | 25 | interface PageDataInterface { 26 | status: string, 27 | message?: { 28 | style: { 29 | colour: { 30 | r: number, 31 | g: number, 32 | b: number, 33 | } 34 | name: string, 35 | icon: string, 36 | }, 37 | data: Block[], 38 | userPermissions: UserPermissions, 39 | permissions?: Permissions, 40 | }, 41 | } 42 | 43 | export default PageDataInterface; 44 | -------------------------------------------------------------------------------- /web/src/next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | // NOTE: This file should not be edited 5 | // see https://nextjs.org/docs/basic-features/typescript for more information. 6 | -------------------------------------------------------------------------------- /web/src/next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | reactStrictMode: true, 4 | eslint: { 5 | ignoreDuringBuilds: true, 6 | }, 7 | }; 8 | 9 | module.exports = nextConfig; 10 | -------------------------------------------------------------------------------- /web/src/pages/_app.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable react/jsx-props-no-spreading */ 2 | import React from 'react'; 3 | import type { AppProps } from 'next/app'; 4 | import SuperTokensReact, { SuperTokensWrapper } from 'supertokens-auth-react'; 5 | 6 | import '../styles/globals.css'; 7 | import '../styles/emojiPicker.css'; 8 | import superTokensConfig from '../lib/config/superTokensConfig'; 9 | 10 | // -=- Initialization -=- 11 | // ~ If we're in the browser, initialize SuperTokens 12 | if (typeof window !== 'undefined') { 13 | // ~ Initialize SuperTokens 14 | SuperTokensReact.init(superTokensConfig()); 15 | } 16 | 17 | // -=- App -=- 18 | // ~ The main app component 19 | const App = ({ Component, pageProps }: AppProps) => ( 20 | 21 |
22 | 23 |
24 |
25 | ); 26 | 27 | export default App; 28 | -------------------------------------------------------------------------------- /web/src/pages/_document.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Html, 3 | Head, 4 | Main, 5 | NextScript, 6 | } from 'next/document'; 7 | import React from 'react'; 8 | 9 | // -=- Document -=- 10 | // ~ The main document component 11 | const Document = () => ( 12 | 13 | 14 | {/* ~ Favicon */} 15 | 16 | 17 | {/* ~ Body w/ dark mode enabled */} 18 | 19 |
20 | 21 | 22 | 23 | ); 24 | 25 | export default Document; 26 | -------------------------------------------------------------------------------- /web/src/pages/auth/callback/[provider].tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from 'react'; 2 | import { redirectToAuth } from 'supertokens-auth-react'; 3 | import { useRouter } from 'next/router'; 4 | import { signInAndUp } from 'supertokens-auth-react/recipe/thirdparty'; 5 | 6 | import Spinner from '../../../components/Spinner'; 7 | import AuthNavBar from '../../../components/home/AuthNavBar'; 8 | 9 | const Callback = () => { 10 | const router = useRouter(); 11 | const { provider } = router.query; 12 | 13 | useEffect(() => { 14 | (async () => { 15 | try { 16 | const response = await signInAndUp(); 17 | 18 | if (response.status === 'OK') { 19 | router.push('/note-rack'); 20 | 21 | window.location.assign('/note-rack'); 22 | } else { 23 | window.location.assign('/auth?error=Something went wrong'); 24 | } 25 | } catch (error) { 26 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 27 | if ((error as any).isSuperTokensGeneralError) { 28 | redirectToAuth({ 29 | queryParams: { 30 | error: (error as Error).message, 31 | }, 32 | }); 33 | } else { 34 | window.location.assign('/auth?error=Something went wrong'); 35 | } 36 | } 37 | })(); 38 | }, [provider]); 39 | 40 | return ( 41 |
42 |
43 | 44 |
45 | {/* Login / Signup Page */} 46 |
47 | {/* Sign Up / Sign In Providers */} 48 |
49 |
50 | 51 |
52 |
53 |
54 |
55 | ); 56 | }; 57 | 58 | export default Callback; 59 | -------------------------------------------------------------------------------- /web/src/pages/index.tsx: -------------------------------------------------------------------------------- 1 | import type { GetServerSideProps, NextPage } from 'next'; 2 | import React from 'react'; 3 | 4 | import NavBar from '../components/home/NavBar'; 5 | import Intro from '../components/home/Intro'; 6 | import Info from '../components/home/Info'; 7 | 8 | const Home: NextPage = () => ( 9 |
10 | 11 | 12 | 13 |
14 | ); 15 | 16 | const getServerSideProps: GetServerSideProps = async (context) => { 17 | const { req, res } = context; 18 | const { cookies } = req; 19 | 20 | if (cookies.sIRTFrontend && cookies.sIRTFrontend !== '' && cookies.sIRTFrontend !== 'remove') { 21 | res.setHeader('location', '/note-rack'); 22 | res.statusCode = 302; 23 | } 24 | 25 | return { 26 | props: {}, 27 | }; 28 | }; 29 | 30 | export { getServerSideProps }; 31 | 32 | export default Home; 33 | -------------------------------------------------------------------------------- /web/src/pages/note-rack/index.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-empty */ 2 | import React, { useEffect } from 'react'; 3 | import Router from 'next/router'; 4 | import Session from 'supertokens-auth-react/recipe/session'; 5 | 6 | import MenuBar from '../../components/MenuBar'; 7 | 8 | const NoteRack = () => { 9 | useEffect(() => { 10 | (async () => { 11 | // -=- Verification -=- 12 | // ~ Check if the user is logged in 13 | const session = await Session.doesSessionExist(); 14 | 15 | // ~ If the user is not logged in, redirect them to the login page 16 | if (session === false) { 17 | Router.push('/auth'); 18 | return; 19 | } 20 | 21 | if (localStorage.getItem('latestPageID') !== null) { 22 | Router.push(`./note-rack/${localStorage.getItem('latestPageID')}`); 23 | return; 24 | }; 25 | 26 | // -=- Fetching -=- 27 | // ~ Get the user's home page 28 | const pageID = await fetch( 29 | `${process.env.NEXT_PUBLIC_API_URL}/page/get-home-page`, 30 | { 31 | method: 'POST', 32 | credentials: 'include', 33 | }, 34 | ); 35 | 36 | // -=- Redirection -=- 37 | // ~ Get the json response from the server 38 | const pageIDJSON = await pageID.json(); 39 | 40 | // ~ If the user has a home page, redirect them to it 41 | if (pageIDJSON.status !== 'error') { 42 | Router.push(`./note-rack/${pageIDJSON.message}`); 43 | return; 44 | } 45 | 46 | Router.push('/auth'); 47 | })(); 48 | }, []); 49 | 50 | // -=- Render -=- 51 | // ~ Return a loading page while the user is being redirected 52 | return ( 53 | 56 |
57 | 58 | ); 59 | }; 60 | 61 | export default NoteRack; 62 | -------------------------------------------------------------------------------- /web/src/public/blockExamples/Call Out Icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /web/src/public/blockExamples/H1 Icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /web/src/public/blockExamples/H2 Icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /web/src/public/icons/Brain.svg: -------------------------------------------------------------------------------- 1 | 2 | 6 | -------------------------------------------------------------------------------- /web/src/public/icons/Trash.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /web/src/public/logos/apple.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /web/src/public/logos/github.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /web/src/public/logos/google.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /web/src/public/promo/Biology-Notes-Example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Eroxl/Note-Rack/1e457868c76256bf8de2390bd09608e3f6927c9e/web/src/public/promo/Biology-Notes-Example.png -------------------------------------------------------------------------------- /web/src/public/promo/Chat-Example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Eroxl/Note-Rack/1e457868c76256bf8de2390bd09608e3f6927c9e/web/src/public/promo/Chat-Example.png -------------------------------------------------------------------------------- /web/src/public/promo/Lab-Report-Example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Eroxl/Note-Rack/1e457868c76256bf8de2390bd09608e3f6927c9e/web/src/public/promo/Lab-Report-Example.png -------------------------------------------------------------------------------- /web/src/public/promo/Math-Example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Eroxl/Note-Rack/1e457868c76256bf8de2390bd09608e3f6927c9e/web/src/public/promo/Math-Example.png -------------------------------------------------------------------------------- /web/src/public/promo/Notes-Example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Eroxl/Note-Rack/1e457868c76256bf8de2390bd09608e3f6927c9e/web/src/public/promo/Notes-Example.png -------------------------------------------------------------------------------- /web/src/public/promo/Search-Example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Eroxl/Note-Rack/1e457868c76256bf8de2390bd09608e3f6927c9e/web/src/public/promo/Search-Example.png -------------------------------------------------------------------------------- /web/src/public/promo/Share-Example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Eroxl/Note-Rack/1e457868c76256bf8de2390bd09608e3f6927c9e/web/src/public/promo/Share-Example.png -------------------------------------------------------------------------------- /web/src/styles/emojiPicker.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | .emoji-mart { 6 | @apply text-zinc-700 dark:text-amber-50 bg-stone-100 dark:bg-neutral-600 border-black border-opacity-5 rounded-md !important; 7 | } 8 | 9 | .emoji-mart-bar { 10 | @apply border-0 border-solid border-black border-opacity-5 !important; 11 | } 12 | 13 | .emoji-mart-anchor { 14 | @apply text-zinc-700/80 dark:text-amber-50/80 !important; 15 | } 16 | 17 | .emoji-mart-anchor-selected { 18 | @apply text-zinc-700 dark:text-amber-50 !important; 19 | } 20 | 21 | .emoji-mart-anchor-bar { 22 | @apply bg-purple-400 transition-all !important; 23 | } 24 | 25 | .emoji-mart-search input { 26 | @apply border-0 border-solid border-black border-opacity-5 dark:text-black !important; 27 | } 28 | 29 | .emoji-mart-category-label span { 30 | @apply bg-stone-100 dark:bg-neutral-600 opacity-90 !important; 31 | } 32 | 33 | .emoji-mart-no-results { 34 | @apply text-zinc-700 dark:text-amber-50 !important; 35 | } 36 | 37 | .emoji-mart-category .emoji-mart-emoji:hover:before { 38 | @apply bg-black dark:bg-white bg-opacity-5 dark:bg-opacity-10 !important; 39 | } 40 | 41 | .emoji-mart-scroll { 42 | -ms-overflow-style: none; 43 | scrollbar-width: none; 44 | } 45 | 46 | .emoji-mart-scroll::-webkit-scrollbar { 47 | display: none; 48 | } -------------------------------------------------------------------------------- /web/src/styles/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | editor.body { 6 | @apply w-screen h-screen overflow-x-hidden 7 | } 8 | 9 | .no-scrollbar::-webkit-scrollbar { 10 | display: none; 11 | } 12 | 13 | .no-scrollbar { 14 | -ms-overflow-style: none; 15 | scrollbar-width: none; 16 | } 17 | 18 | .emoji { 19 | display: inline-block; 20 | height: 1em; 21 | width: 1em; 22 | margin: 0 .05em 0 .1em; 23 | vertical-align: -0.1em; 24 | background-repeat: no-repeat; 25 | background-position: center center; 26 | background-size: 1em 1em; 27 | } 28 | 29 | .emoji-mart-preview { 30 | display: none; 31 | } 32 | 33 | .emoji-mart-category .emoji-mart-emoji span { 34 | @apply hover:cursor-pointer 35 | } 36 | 37 | .emoji-mart-anchor-icon { 38 | @apply flex flex-row items-center justify-center 39 | } 40 | 41 | [placeholder]:empty:focus:before { 42 | content: attr(placeholder); 43 | } 44 | 45 | .print-forced-background { 46 | position: relative; 47 | overflow: hidden; 48 | background-color: var(--forced-background-colour); 49 | } 50 | 51 | @media print { 52 | .print-forced-background:before { 53 | content: ''; 54 | position: absolute; 55 | top: 0; 56 | right: 0; 57 | left: 0; 58 | bottom: 0; 59 | border: 999px var(--forced-background-colour) solid; 60 | } 61 | } -------------------------------------------------------------------------------- /web/src/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "forceConsistentCasingInFileNames": true, 9 | "noEmit": true, 10 | "esModuleInterop": true, 11 | "module": "esnext", 12 | "moduleResolution": "node", 13 | "resolveJsonModule": true, 14 | "isolatedModules": true, 15 | "jsx": "preserve", 16 | "incremental": true 17 | }, 18 | "include": ["src/next-env.d.ts", "**/*.ts", "**/*.tsx"], 19 | "exclude": ["node_modules"] 20 | } 21 | -------------------------------------------------------------------------------- /web/tailwind.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | content: [ 3 | './src/**/*.{js,ts,jsx,tsx}', 4 | ], 5 | theme: { 6 | extend: { 7 | screens: { 8 | print: { 9 | raw: 'print', 10 | }, 11 | }, 12 | boxShadow: { 13 | 'under': '0.15rem 0.15rem 0px', 14 | }, 15 | }, 16 | }, 17 | variants: { 18 | }, 19 | plugins: [], 20 | important: true, 21 | darkMode: 'class', 22 | }; 23 | --------------------------------------------------------------------------------