├── .eslintrc.js ├── .gitignore ├── .nvmrc ├── COPYING ├── README.md ├── babel.config.js ├── package-lock.json ├── package.json ├── src ├── components │ ├── App.ts │ ├── BinaryModal.ts │ ├── BookmarksListModal.ts │ ├── DisplayedDocument │ │ ├── DisplayedDocument.ts │ │ ├── index.ts │ │ ├── layout.ts │ │ ├── shapes │ │ │ ├── Node.ts │ │ │ ├── bookmarkIcon.ts │ │ │ ├── childFoldingIcon.ts │ │ │ ├── parentChildConnector.ts │ │ │ └── roundedRectangle.ts │ │ └── types.ts │ ├── DocumentHeader.ts │ ├── FileExportModal.ts │ ├── FileImportModal.ts │ ├── FileOpenModal.ts │ ├── FileSaveModal.ts │ ├── Menu.ts │ ├── MiscFileOpsModal.ts │ ├── Sidebar.ts │ ├── TextInputModal.ts │ └── menus │ │ ├── BookmarksMenu.ts │ │ ├── EditMenu.ts │ │ ├── FileMenu.ts │ │ ├── MoveNodeMenu.ts │ │ ├── SizeSettingsMenu.ts │ │ ├── UndoRedoMenu.ts │ │ ├── constants.ts │ │ └── images │ │ ├── add-child.svg │ │ ├── add-sibling-disabled.svg │ │ ├── add-sibling.svg │ │ ├── delete-node-disabled.svg │ │ ├── delete-node.svg │ │ ├── edit-node.svg │ │ ├── file-export.svg │ │ ├── file-import.svg │ │ ├── file-new.svg │ │ ├── file-open.svg │ │ ├── file-save.svg │ │ ├── hamburger-button.svg │ │ ├── misc-file-ops.svg │ │ ├── redo-disabled.svg │ │ ├── redo.svg │ │ ├── undo-disabled.svg │ │ └── undo.svg ├── custom.d.ts ├── index.ts ├── state │ ├── canvasState.ts │ ├── documentState.test.ts │ ├── documentState.ts │ ├── state.ts │ └── uiState.ts ├── types.ts └── utils │ ├── file.ts │ ├── importFile.ts │ └── importFreeplane.ts ├── tsconfig.json ├── webpack.common.js ├── webpack.dev.js └── webpack.prod.js /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | browser: true, 4 | es2021: true, 5 | 'jest/globals': true, 6 | }, 7 | extends: [ 8 | 'airbnb-base', 9 | 'plugin:@typescript-eslint/recommended', 10 | 'plugin:import/typescript', 11 | 'plugin:jest/recommended', 12 | 'plugin:jsdoc/recommended', 13 | ], 14 | parser: '@typescript-eslint/parser', 15 | parserOptions: { 16 | ecmaVersion: 12, 17 | sourceType: 'module', 18 | }, 19 | plugins: [ 20 | '@typescript-eslint', 21 | ], 22 | ignorePatterns: ['*.json'], 23 | rules: { 24 | /* eslint-disable indent */ 25 | 26 | 'import/extensions': ['error', 'always', { pattern: { ts: 'never' } }], 27 | // - Typescript doesn't use extensions for code 28 | // - Eventually we'll want to import things like images, which *will* 29 | // require extensions 30 | 31 | 'import/no-extraneous-dependencies': [ 32 | 'error', 33 | { 34 | devDependencies: [ 35 | 'webpack.common.js', 36 | 'webpack.dev.js', 37 | 'webpack.prod.js', 38 | ], 39 | }, 40 | ], 41 | 42 | 'import/prefer-default-export': 'off', 43 | // - This rule only makes sense for components where the thing 44 | // being exported is identical to the filename 45 | // - It doesn't make sense for something like a utility file that 46 | // has a single function (or where you expect more functions to 47 | // to be added later) 48 | 49 | 'jsdoc/require-jsdoc': ['error', { publicOnly: true }], 50 | // The goal is to make code as self-documenting as possible, but 51 | // JSDoc is useful for IDE support -- showing function docs without 52 | // having to navigate to the file containing that function 53 | 54 | 'jsdoc/require-returns-type': 'off', 55 | 'jsdoc/require-param-type': 'off', 56 | // JSDoc types are redundant with typescript types 57 | 58 | 'jsdoc/require-param': 'off', 59 | 'jsdoc/require-returns': 'off', 60 | // Sometimes the combination of parameter name and typescript 61 | // type make the parameter obvious, so JSDoc just adds clutter 62 | 63 | 'jsdoc/tag-lines': 'off', 64 | // Allowing blank lines is useful to make the @returns line stand 65 | // out more 66 | 67 | indent: ['error', 4], 68 | 69 | 'no-mixed-operators': 'off', 70 | // - This is a pain when trying to write formulas that are easily 71 | // understandable 72 | 73 | 'no-multi-spaces': 'off', 74 | // - Allow for flexibility when lining up keys and values, but not be 75 | // forced to 76 | 77 | 'no-use-before-define': [ 78 | 'error', 79 | { 80 | functions: false, 81 | }, 82 | ], 83 | // - Conflicts with convention of alphabetizing methods. This will 84 | // still flag errors in problematic cases 85 | 86 | 'operator-linebreak': 'off', 87 | // - So multi-line conditions have operators at the end of the line 88 | // rather than at the beginning of the next line 89 | 90 | 'object-curly-newline': 'off', 91 | // - This is just goofy 92 | 93 | semi: ['error', 'always'], 94 | // - One less thing to think about (Automatic Semicolon Insertion) 95 | }, 96 | }; 97 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .gitsummaryconfig 2 | *.code-workspace 3 | dist 4 | node_modules 5 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 16.16 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | m3 - Mobile Mind Mapper is a mind mapping program originally intended 2 | to be used in web browsers, and thus is compatible with multiple platforms 3 | (Windows, macOS, Android, iOS, Linux, etc). That version could read and 4 | write moderately complicated 5 | [Freeplane](https://www.freeplane.org/wiki/index.php/Home) mind maps, 6 | however the architecture wasn't scalable (this was my first app using 7 | web technology :smile:), and it became clear that the risk of deleting 8 | your mind maps (since they reside "within" the web browser) was too 9 | great. 10 | 11 | A new version of m3 is being developed which will address the above 12 | shortcomings and more. The 13 | [old version](http://glenreesor.ca/m3) will remain available while the 14 | new one is being developed, however expect the old one to eventually 15 | stop working. 16 | 17 | You may want to try out the [new version](https://glenreesor.ca/projects/m3-demo), 18 | but keep in mind it's still very much a work-in-progress, things may change 19 | (including the file format, which could make your data unretrievable), and will 20 | not work offline. 21 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | '@babel/preset-env', 4 | '@babel/preset-typescript', 5 | ], 6 | }; 7 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "mobilemindmapper", 3 | "version": "0.20.0", 4 | "description": "Cross platform mind mapper", 5 | "author": "Glen Reesor", 6 | "license": "GPL-3.0", 7 | "scripts": { 8 | "build": "webpack --config webpack.prod.js", 9 | "devserver": "webpack serve --config webpack.dev.js", 10 | "lint": "eslint *.js src/*.ts src/**/*.ts", 11 | "test": "jest" 12 | }, 13 | "devDependencies": { 14 | "@babel/core": "^7.16.7", 15 | "@babel/preset-env": "^7.16.8", 16 | "@babel/preset-typescript": "^7.16.7", 17 | "@types/jest": "^28.1.6", 18 | "@types/mithril": "^2.0.8", 19 | "@typescript-eslint/eslint-plugin": "^5.10.0", 20 | "@typescript-eslint/parser": "^5.10.0", 21 | "babel-jest": "^28.1.3", 22 | "clean-webpack-plugin": "^4.0.0", 23 | "eslint": "^8.20.0", 24 | "eslint-config-airbnb-base": "^15.0.0", 25 | "eslint-plugin-import": "^2.25.4", 26 | "eslint-plugin-jest": "^26.6.0", 27 | "eslint-plugin-jsdoc": "^39.3.3", 28 | "file-loader": "^6.2.0", 29 | "html-webpack-plugin": "^5.5.0", 30 | "image-webpack-loader": "^8.1.0", 31 | "jest": "^28.1.3", 32 | "ts-loader": "^9.2.0", 33 | "typescript": "^4.5.4", 34 | "webpack": "^5.65.0", 35 | "webpack-cli": "^4.9.1", 36 | "webpack-dev-server": "^4.7.3", 37 | "webpack-merge": "^5.8.0" 38 | }, 39 | "dependencies": { 40 | "immer": "^9.0.12", 41 | "mithril": "^2.0.4" 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/components/App.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2022, 2023 Glen Reesor 2 | // 3 | // This file is part of m3 Mind Mapper. 4 | // 5 | // m3 Mind Mapper is free software: you can redistribute it and/or 6 | // modify it under the terms of the GNU General Public License as published by 7 | // the Free Software Foundation, either version 3 of the License, or (at your 8 | // option) any later version. 9 | // 10 | // m3 Mind Mapper is distributed in the hope that it will be useful, but 11 | // WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or 12 | // FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more 13 | // details. 14 | // 15 | // You should have received a copy of the GNU General Public License along with 16 | // m3 Mind Mapper. If not, see . 17 | 18 | import * as m from 'mithril'; 19 | 20 | import DisplayedDocument from './DisplayedDocument'; 21 | import DocumentHeader from './DocumentHeader'; 22 | import BinaryModal from './BinaryModal'; 23 | import BookmarksListModal, { BookmarksListModalAttributes } from './BookmarksListModal'; 24 | import FileExportModal, { FileExportModalAttributes } from './FileExportModal'; 25 | import FileImportModal, { FileImportModalAttributes } from './FileImportModal'; 26 | import FileOpenModal, { FileOpenModalAttributes } from './FileOpenModal'; 27 | import FileSaveModal, { FileSaveModalAttributes } from './FileSaveModal'; 28 | import MiscFileOpsModal, { MiscFileOpsModalAttributes } from './MiscFileOpsModal'; 29 | import Menu from './Menu'; 30 | import { MENU_HEIGHT } from './menus/constants'; 31 | import { 32 | FILE_EXISTS, 33 | getLastUsedDocumentName, 34 | getSavedDocument, 35 | getSavedDocumentList, 36 | saveDocument, 37 | } from '../utils/file'; 38 | import importFile from '../utils/importFile'; 39 | import Sidebar from './Sidebar'; 40 | import TextInputModal, { TextInputModalAttributes } from './TextInputModal'; 41 | 42 | import state from '../state/state'; 43 | import { BinaryModalAttributes } from '../state/uiState'; 44 | 45 | /** 46 | * A component that contains the entire app. 47 | * 48 | * @returns An object to be consumed by m() 49 | */ 50 | function App(): m.Component { 51 | /** 52 | * Get the dimensions to be used for the document 53 | * 54 | * @returns The dimensions 55 | */ 56 | function getDocumentDimensions(): {width: number, height: number} { 57 | return { 58 | width: window.innerWidth - 20, 59 | 60 | // TODO: Turn this into a not-hack 61 | height: window.innerHeight - MENU_HEIGHT - state.ui.getCurrentFontSize() - 35, 62 | }; 63 | } 64 | 65 | function getOptionalSidebar(): m.Vnode { 66 | if (state.ui.getSidebarIsVisible()) { 67 | return m(Sidebar); 68 | } 69 | 70 | return m(''); 71 | } 72 | 73 | function getOptionalModalMarkup(): 74 | m.Vnode | 75 | m.Vnode | 76 | m.Vnode | 77 | m.Vnode | 78 | m.Vnode | 79 | m.Vnode | 80 | m.Vnode | 81 | m.Vnode { 82 | const currentModal = state.ui.getCurrentModal(); 83 | 84 | if (currentModal === 'addChild') { 85 | return m( 86 | TextInputModal, 87 | { 88 | initialValue: '', 89 | onCancel: () => { state.ui.setCurrentModal('none'); }, 90 | onSave: (text: string) => { 91 | const newNodeId = state.doc.addChild( 92 | state.doc.getSelectedNodeId(), 93 | text, 94 | ); 95 | state.doc.setSelectedNodeId(newNodeId); 96 | state.ui.setCurrentModal('none'); 97 | }, 98 | }, 99 | ); 100 | } 101 | 102 | if (currentModal === 'addSibling') { 103 | return m( 104 | TextInputModal, 105 | { 106 | initialValue: '', 107 | onCancel: () => { state.ui.setCurrentModal('none'); }, 108 | onSave: (text: string) => { 109 | const newNodeId = state.doc.addSibling( 110 | state.doc.getSelectedNodeId(), 111 | text, 112 | ); 113 | state.doc.setSelectedNodeId(newNodeId); 114 | state.ui.setCurrentModal('none'); 115 | }, 116 | }, 117 | ); 118 | } 119 | 120 | if (currentModal === 'binaryModal') { 121 | const binaryModalAttrs = state.ui.getBinaryModalAttrs(); 122 | return binaryModalAttrs !== undefined 123 | ? 124 | m( 125 | BinaryModal, 126 | binaryModalAttrs, 127 | ) 128 | : m(''); 129 | } 130 | 131 | if (currentModal === 'editNode') { 132 | return m( 133 | TextInputModal, 134 | { 135 | initialValue: state.doc.getNodeContents( 136 | state.doc.getSelectedNodeId(), 137 | ), 138 | onCancel: () => { state.ui.setCurrentModal('none'); }, 139 | onSave: (text: string) => { 140 | state.doc.replaceNodeContents( 141 | state.doc.getSelectedNodeId(), 142 | text, 143 | ); 144 | state.ui.setCurrentModal('none'); 145 | }, 146 | }, 147 | ); 148 | } 149 | 150 | if (currentModal === 'fileExport') { 151 | return m( 152 | FileExportModal, 153 | { 154 | onClose: () => state.ui.setCurrentModal('none'), 155 | }, 156 | ); 157 | } 158 | 159 | if (currentModal === 'fileImport') { 160 | return m( 161 | FileImportModal, 162 | { 163 | onCancel: () => state.ui.setCurrentModal('none'), 164 | onFileContentsRead: (fileContents) => { 165 | importFile(fileContents); 166 | state.ui.setCurrentModal('none'); 167 | state.canvas.resetRootNodeCoords(); 168 | 169 | // This state change was triggered by an async fileReader 170 | // operation, not a DOM event, thus we need to trigger 171 | // a rerender ourselves 172 | m.redraw(); 173 | }, 174 | }, 175 | ); 176 | } 177 | 178 | if (currentModal === 'fileOpen') { 179 | return m( 180 | FileOpenModal, 181 | { 182 | onCancel: () => state.ui.setCurrentModal('none'), 183 | onFileSelected: (filename: string) => { 184 | const documentAsJson = getSavedDocument(filename); 185 | if (typeof documentAsJson === 'number') { 186 | console.log('Unexpected file load error'); 187 | } else { 188 | state.doc.replaceCurrentDocFromJson( 189 | filename, 190 | documentAsJson, 191 | ); 192 | state.ui.setCurrentModal('none'); 193 | state.canvas.resetRootNodeCoords(); 194 | } 195 | }, 196 | }, 197 | ); 198 | } 199 | 200 | if (currentModal === 'fileSave') { 201 | return m( 202 | FileSaveModal, 203 | { 204 | docName: state.doc.getDocName(), 205 | onCancel: () => state.ui.setCurrentModal('none'), 206 | onSave: (filename: string) => { 207 | const saveStatus = saveDocument( 208 | false, 209 | filename, 210 | state.doc.getCurrentDocAsJson(), 211 | ); 212 | 213 | if (saveStatus === FILE_EXISTS) { 214 | state.ui.setCurrentModal('binaryModal'); 215 | state.ui.setBinaryModalAttrs({ 216 | prompt: 'File exists. Overwrite?', 217 | yesButtonText: 'Yes', 218 | noButtonText: 'No', 219 | onYesButtonClick: () => { 220 | saveDocument(true, filename, state.doc.getCurrentDocAsJson()); 221 | state.doc.setDocName(filename); 222 | state.ui.setCurrentModal('none'); 223 | }, 224 | onNoButtonClick: () => state.ui.setCurrentModal('fileSave'), 225 | }); 226 | } else { 227 | state.doc.setDocName(filename); 228 | state.ui.setCurrentModal('none'); 229 | } 230 | }, 231 | }, 232 | ); 233 | } 234 | 235 | if (currentModal === 'miscFileOps') { 236 | return m( 237 | MiscFileOpsModal, 238 | { 239 | onClose: () => state.ui.setCurrentModal('none'), 240 | }, 241 | ); 242 | } 243 | 244 | if (currentModal === 'bookmarksList') { 245 | return m( 246 | BookmarksListModal, 247 | { 248 | onCancel: () => state.ui.setCurrentModal('none'), 249 | onBookmarkSelected: (nodeId) => { 250 | state.doc.ensureNodeVisible(nodeId); 251 | state.doc.setSelectedNodeId(nodeId); 252 | state.ui.setCurrentModal('none'); 253 | 254 | // We need to wait for a redraw before we can trigger 255 | // the scroll because we need the map to be redrawn 256 | // so we have the coordinates of the target node. 257 | // (It may not have been visible if its parent had 258 | // folded children) 259 | setTimeout(() => state.canvas.scrollToNode(nodeId)); 260 | }, 261 | }, 262 | ); 263 | } 264 | 265 | return m(''); 266 | } 267 | 268 | function onWindowResize() { 269 | m.redraw(); 270 | } 271 | 272 | return { 273 | oncreate: () => { 274 | window.addEventListener('resize', onWindowResize); 275 | state.canvas.setCanvasDimensions(getDocumentDimensions()); 276 | state.canvas.resetRootNodeCoords(); 277 | 278 | const lastUsedDocumentName = getLastUsedDocumentName(); 279 | if ( 280 | lastUsedDocumentName !== null && 281 | getSavedDocumentList().includes(lastUsedDocumentName) 282 | ) { 283 | const docToLoad = getSavedDocument(lastUsedDocumentName); 284 | if (typeof docToLoad !== 'number') { 285 | state.doc.replaceCurrentDocFromJson( 286 | lastUsedDocumentName, 287 | docToLoad, 288 | ); 289 | 290 | // Need to do this to get the header to update 291 | m.redraw(); 292 | } 293 | } 294 | }, 295 | 296 | onremove: () => { 297 | window.removeEventListener('resize', onWindowResize); 298 | }, 299 | 300 | view: (): m.Vnode => { 301 | const documentName = state.doc.getDocName(); 302 | const hasUnsavedChanges = state.doc.hasUnsavedChanges(); 303 | const docLastExportedTimestamp = state.doc.getDocLastExportedTimestamp(); 304 | 305 | return m( 306 | 'div', 307 | [ 308 | getOptionalModalMarkup(), 309 | getOptionalSidebar(), 310 | m( 311 | DocumentHeader, 312 | { 313 | documentName, 314 | hasUnsavedChanges, 315 | docLastExportedTimestamp, 316 | }, 317 | ), 318 | m( 319 | DisplayedDocument, 320 | { 321 | documentDimensions: getDocumentDimensions(), 322 | }, 323 | ), 324 | m(Menu), 325 | ], 326 | ); 327 | }, 328 | }; 329 | } 330 | 331 | export default App; 332 | -------------------------------------------------------------------------------- /src/components/BinaryModal.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2022 Glen Reesor 2 | // 3 | // This file is part of m3 Mind Mapper. 4 | // 5 | // m3 Mind Mapper is free software: you can redistribute it and/or 6 | // modify it under the terms of the GNU General Public License as published by 7 | // the Free Software Foundation, either version 3 of the License, or (at your 8 | // option) any later version. 9 | // 10 | // m3 Mind Mapper is distributed in the hope that it will be useful, but 11 | // WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or 12 | // FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more 13 | // details. 14 | // 15 | // You should have received a copy of the GNU General Public License along with 16 | // m3 Mind Mapper. If not, see . 17 | 18 | import * as m from 'mithril'; 19 | 20 | import state from '../state/state'; 21 | import { BinaryModalAttributes } from '../state/uiState'; 22 | 23 | /** 24 | * A component that presents a modal with two buttons, where the modal text, 25 | * button text, and button actions are determined by props. 26 | * 27 | * @returns An object to be consumed by m() 28 | */ 29 | function BinaryModal(): m.Component { 30 | function getBinaryModalMarkup(attrs: BinaryModalAttributes): m.Vnode { 31 | return m( 32 | 'div', 33 | { 34 | // TODO: Don't use embedded styles 35 | style: { 36 | background: '#dddddd', 37 | padding: '10px', 38 | border: '2px solid blue', 39 | fontSize: `${state.ui.getCurrentFontSize()}px`, 40 | position: 'fixed', 41 | left: '50%', 42 | top: '35%', 43 | transform: 'translate(-50%, -50%)', 44 | zIndex: '20', 45 | }, 46 | }, 47 | [ 48 | attrs.prompt, 49 | getButtonsMarkup(attrs), 50 | ], 51 | ); 52 | } 53 | 54 | function getButtonsMarkup(attrs: BinaryModalAttributes) { 55 | return m( 56 | 'div', 57 | { 58 | style: { 59 | marginTop: '20px', 60 | textAlign: 'right', 61 | }, 62 | }, 63 | [ 64 | m( 65 | 'button', 66 | { 67 | style: 'margin-right: 10px', 68 | onclick: attrs.onYesButtonClick, 69 | }, 70 | attrs.yesButtonText, 71 | ), 72 | m( 73 | 'button', 74 | { onclick: attrs.onNoButtonClick }, 75 | attrs.noButtonText, 76 | ), 77 | ], 78 | ); 79 | } 80 | function getOverlayMarkup(): m.Vnode { 81 | return m( 82 | 'div', 83 | { 84 | // TODO: Don't use embedded styles 85 | style: { 86 | position: 'fixed', 87 | top: '0px', 88 | width: '100%', 89 | height: '100vh', 90 | background: 'rgba(255, 255, 255, 0.5)', 91 | zIndex: '10', 92 | }, 93 | }, 94 | ); 95 | } 96 | 97 | return { 98 | view: ({ attrs }): m.Vnode => m( 99 | 'div', 100 | [ 101 | getOverlayMarkup(), 102 | getBinaryModalMarkup(attrs), 103 | ], 104 | ), 105 | }; 106 | } 107 | 108 | export default BinaryModal; 109 | -------------------------------------------------------------------------------- /src/components/BookmarksListModal.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2023 Glen Reesor 2 | // 3 | // This file is part of m3 Mind Mapper. 4 | // 5 | // m3 Mind Mapper is free software: you can redistribute it and/or 6 | // modify it under the terms of the GNU General Public License as published by 7 | // the Free Software Foundation, either version 3 of the License, or (at your 8 | // option) any later version. 9 | // 10 | // m3 Mind Mapper is distributed in the hope that it will be useful, but 11 | // WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or 12 | // FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more 13 | // details. 14 | // 15 | // You should have received a copy of the GNU General Public License along with 16 | // m3 Mind Mapper. If not, see . 17 | 18 | import * as m from 'mithril'; 19 | 20 | import state from '../state/state'; 21 | 22 | export interface BookmarksListModalAttributes { 23 | onCancel: () => void, 24 | onBookmarkSelected: (nodeId: number) => void, 25 | } 26 | 27 | /** 28 | * A component that presents the list of bookmarked nodes and allows the user 29 | * to select one to navigate to 30 | * 31 | * @returns An object to be consumed by m() 32 | */ 33 | function BookmarksListModal(): m.Component { 34 | function getButtonMarkup(attrs: BookmarksListModalAttributes) { 35 | return m( 36 | 'div', 37 | { 38 | style: { 39 | marginTop: '20px', 40 | textAlign: 'right', 41 | }, 42 | }, 43 | [ 44 | m( 45 | 'button', 46 | { onclick: attrs.onCancel }, 47 | 'Cancel', 48 | ), 49 | ], 50 | ); 51 | } 52 | 53 | function getCurrentBookmarksMarkup(attrs: BookmarksListModalAttributes) { 54 | const bookmarkedNodes = state.doc.getBookmarkedNodeIds().map( 55 | (nodeId) => ({ nodeId, name: state.doc.getNodeContents(nodeId) }), 56 | ); 57 | const sortedBookmarkedNodes = bookmarkedNodes.sort((a, b) => { 58 | if (a.name < b.name) return -1; 59 | if (a.name > b.name) return 1; 60 | return 0; 61 | }); 62 | 63 | const currentBookmarksMarkup: Array = []; 64 | sortedBookmarkedNodes.forEach((node, index) => { 65 | currentBookmarksMarkup.push( 66 | m( 67 | 'div', 68 | { 69 | // TODO: Fix using nth child stuff 70 | style: { 71 | background: '#ffffff', 72 | fontSize: `${state.ui.getCurrentFontSize()}px`, 73 | paddingTop: index === 0 ? '10px' : '0', 74 | paddingBottom: '10px', 75 | paddingLeft: '20px', 76 | paddingRight: '20px', 77 | }, 78 | onclick: () => attrs.onBookmarkSelected(node.nodeId), 79 | }, 80 | node.name, 81 | ), 82 | ); 83 | }); 84 | 85 | return m( 86 | 'div', 87 | { 88 | style: { 89 | height: '100px', 90 | overflow: 'auto', 91 | paddingTop: '10px', 92 | paddingBottom: '10px', 93 | paddingLeft: '55px', 94 | 95 | // TODO: Make this a non-hack 96 | width: '200px', 97 | maxWidth: '75%', 98 | }, 99 | }, 100 | currentBookmarksMarkup, 101 | ); 102 | } 103 | 104 | function getBookmarksListModalMarkup(attrs: BookmarksListModalAttributes): m.Vnode { 105 | return m( 106 | 'div', 107 | { 108 | // TODO: Don't use embedded styles 109 | style: { 110 | background: '#dddddd', 111 | padding: '10px', 112 | border: '2px solid blue', 113 | fontSize: '14px', 114 | position: 'fixed', 115 | left: '50%', 116 | top: '35%', 117 | transform: 'translate(-50%, -50%)', 118 | zIndex: '20', 119 | }, 120 | }, 121 | [ 122 | getCurrentBookmarksMarkup(attrs), 123 | getButtonMarkup(attrs), 124 | ], 125 | ); 126 | } 127 | 128 | function getOverlayMarkup(): m.Vnode { 129 | return m( 130 | 'div', 131 | { 132 | // TODO: Don't use embedded styles 133 | style: { 134 | position: 'fixed', 135 | top: '0px', 136 | width: '100%', 137 | height: '100vh', 138 | background: 'rgba(255, 255, 255, 0.5)', 139 | zIndex: '10', 140 | }, 141 | }, 142 | ); 143 | } 144 | 145 | return { 146 | view: ({ attrs }): m.Vnode => m( 147 | 'div', 148 | [ 149 | getOverlayMarkup(), 150 | getBookmarksListModalMarkup(attrs), 151 | ], 152 | ), 153 | }; 154 | } 155 | 156 | export default BookmarksListModal; 157 | -------------------------------------------------------------------------------- /src/components/DisplayedDocument/DisplayedDocument.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2022, 2023 Glen Reesor 2 | // 3 | // This file is part of m3 Mind Mapper. 4 | // 5 | // m3 Mind Mapper is free software: you can redistribute it and/or 6 | // modify it under the terms of the GNU General Public License as published by 7 | // the Free Software Foundation, either version 3 of the License, or (at your 8 | // option) any later version. 9 | // 10 | // m3 Mind Mapper is distributed in the hope that it will be useful, but 11 | // WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or 12 | // FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more 13 | // details. 14 | // 15 | // You should have received a copy of the GNU General Public License along with 16 | // m3 Mind Mapper. If not, see . 17 | 18 | import * as m from 'mithril'; 19 | import canvasState from '../../state/canvasState'; 20 | import documentState from '../../state/documentState'; 21 | import uiState from '../../state/uiState'; 22 | 23 | import { 24 | onCanvasClick, 25 | renderDocument, 26 | } from './layout'; 27 | 28 | interface Attrs { 29 | documentDimensions: { 30 | height: number, 31 | width: number, 32 | }, 33 | } 34 | 35 | /** 36 | * A component to render the user's document in a element 37 | */ 38 | function DisplayedDocument(): m.Component { 39 | const devicePixelRatio = window.devicePixelRatio || 1; 40 | 41 | let canvasElement: HTMLCanvasElement; 42 | let ctx: CanvasRenderingContext2D; 43 | 44 | const currentCanvasDimensions = { 45 | width: -1, 46 | height: -1, 47 | }; 48 | 49 | function getCanvasEventHandlers() { 50 | // Event handlers trigger Mithril redraws (of the entire app). 51 | // So only define movement handlers if we actually need them, which 52 | // is when the document is being dragged by the user. 53 | const alwaysActiveHandlers = { 54 | onclick: (e: MouseEvent) => { 55 | onCanvasClick(e.offsetX, e.offsetY); 56 | }, 57 | onmousedown: (e: MouseEvent) => { 58 | canvasState.handleUserDragStart({ 59 | x: e.pageX, 60 | y: e.pageY, 61 | }); 62 | }, 63 | ontouchstart: (e: TouchEvent) => { 64 | canvasState.handleUserDragStart({ 65 | x: e.touches[0].pageX, 66 | y: e.touches[0].pageY, 67 | }); 68 | }, 69 | }; 70 | 71 | const onlyDraggingModeHandlers = { 72 | onmousemove: (e: MouseEvent) => { 73 | canvasState.handleUserDragMovement({ 74 | x: e.pageX, 75 | y: e.pageY, 76 | }); 77 | }, 78 | onmouseout: canvasState.handleUserDragStop, 79 | onmouseup: canvasState.handleUserDragStop, 80 | ontouchend: canvasState.handleUserDragStop, 81 | ontouchmove: (e: TouchEvent) => { 82 | canvasState.handleUserDragMovement({ 83 | x: e.touches[0].pageX, 84 | y: e.touches[0].pageY, 85 | }); 86 | }, 87 | }; 88 | 89 | return { 90 | ...alwaysActiveHandlers, 91 | ...( 92 | canvasState.getMovementState() === 'userDragging' 93 | ? onlyDraggingModeHandlers 94 | : {} 95 | ), 96 | }; 97 | } 98 | 99 | function saveCanvasDimensionsFromAttrs(attrs: Attrs) { 100 | currentCanvasDimensions.width = attrs.documentDimensions.width; 101 | currentCanvasDimensions.height = attrs.documentDimensions.height; 102 | } 103 | 104 | function redrawCanvas() { 105 | // Clear the existing rendered map 106 | // We need to clear a region larger than the actual canvas so 107 | // parts of the map rendered prior to a translation also get cleared 108 | // 109 | // I don't fully understand why this works, but it needs to be 110 | // big to handle gigantically wide nodes 111 | const MAX_PREV_TRANSLATION = 4000; 112 | ctx.clearRect( 113 | -MAX_PREV_TRANSLATION, 114 | -MAX_PREV_TRANSLATION, 115 | currentCanvasDimensions.width + 2 * MAX_PREV_TRANSLATION, 116 | currentCanvasDimensions.height + 2 * MAX_PREV_TRANSLATION, 117 | ); 118 | 119 | //------------------------------------------------------------------ 120 | // Draw the user's map 121 | //------------------------------------------------------------------ 122 | const fontSize = uiState.getCurrentFontSize(); 123 | const rootNodeId = documentState.getRootNodeId(); 124 | 125 | renderDocument( 126 | ctx, 127 | fontSize, 128 | rootNodeId, 129 | { 130 | width: currentCanvasDimensions.width, 131 | height: currentCanvasDimensions.height, 132 | }, 133 | ); 134 | } 135 | 136 | return { 137 | oncreate: (vnode) => { 138 | canvasState.setRedrawFunction(redrawCanvas); 139 | canvasElement = vnode.dom as HTMLCanvasElement; 140 | ctx = canvasElement.getContext('2d') as CanvasRenderingContext2D; 141 | 142 | saveCanvasDimensionsFromAttrs(vnode.attrs); 143 | 144 | // Scale the canvas properly so everything looks crisp on high DPI 145 | // displays 146 | ctx.scale(devicePixelRatio, devicePixelRatio); 147 | 148 | //------------------------------------------------------------------ 149 | // Draw the user's map 150 | //------------------------------------------------------------------ 151 | const fontSize = uiState.getCurrentFontSize(); 152 | const rootNodeId = documentState.getRootNodeId(); 153 | 154 | renderDocument( 155 | ctx, 156 | fontSize, 157 | rootNodeId, 158 | vnode.attrs.documentDimensions, 159 | ); 160 | }, 161 | 162 | onupdate: (vnode) => { 163 | // Canvas elements reset their scale when their dimensions change, 164 | // so reset scale when that happens 165 | if ( 166 | currentCanvasDimensions.width !== vnode.attrs.documentDimensions.width || 167 | currentCanvasDimensions.height !== vnode.attrs.documentDimensions.height 168 | ) { 169 | saveCanvasDimensionsFromAttrs(vnode.attrs); 170 | 171 | ctx.scale(devicePixelRatio, devicePixelRatio); 172 | } 173 | 174 | redrawCanvas(); 175 | }, 176 | 177 | view: ({ attrs }) => { 178 | const cssPixelsWidth = attrs.documentDimensions.width; 179 | const cssPixelsHeight = attrs.documentDimensions.height; 180 | 181 | // Set actual width and height to be used by the browser's drawing 182 | // engine (i.e. use the full device resolution) 183 | // This relies on the canvas context also being scaled by the 184 | // devicePixelRatio (in oncreate and onupdate) 185 | const canvasActualWidth = cssPixelsWidth * devicePixelRatio; 186 | const canvasActualHeight = cssPixelsHeight * devicePixelRatio; 187 | 188 | return m( 189 | 'canvas', 190 | { 191 | width: canvasActualWidth, 192 | height: canvasActualHeight, 193 | style: { 194 | border: '1px solid black', 195 | width: `${cssPixelsWidth}px`, 196 | height: `${cssPixelsHeight}px`, 197 | }, 198 | ...getCanvasEventHandlers(), 199 | }, 200 | ); 201 | }, 202 | }; 203 | } 204 | 205 | export default DisplayedDocument; 206 | -------------------------------------------------------------------------------- /src/components/DisplayedDocument/index.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2022 Glen Reesor 2 | // 3 | // This file is part of m3 Mind Mapper. 4 | // 5 | // m3 Mind Mapper is free software: you can redistribute it and/or 6 | // modify it under the terms of the GNU General Public License as published by 7 | // the Free Software Foundation, either version 3 of the License, or (at your 8 | // option) any later version. 9 | // 10 | // m3 Mind Mapper is distributed in the hope that it will be useful, but 11 | // WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or 12 | // FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more 13 | // details. 14 | // 15 | // You should have received a copy of the GNU General Public License along with 16 | // m3 Mind Mapper. If not, see . 17 | 18 | import DisplayedDocument from './DisplayedDocument'; 19 | 20 | export default DisplayedDocument; 21 | -------------------------------------------------------------------------------- /src/components/DisplayedDocument/layout.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2023 Glen Reesor 2 | // 3 | // This file is part of m3 Mind Mapper. 4 | // 5 | // m3 Mind Mapper is free software: you can redistribute it and/or 6 | // modify it under the terms of the GNU General Public License as published by 7 | // the Free Software Foundation, either version 3 of the License, or (at your 8 | // option) any later version. 9 | // 10 | // m3 Mind Mapper is distributed in the hope that it will be useful, but 11 | // WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or 12 | // FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more 13 | // details. 14 | // 15 | // You should have received a copy of the GNU General Public License along with 16 | // m3 Mind Mapper. If not, see . 17 | 18 | import canvasState from '../../state/canvasState'; 19 | import documentState from '../../state/documentState'; 20 | import { CHILD_FOLDING_ICON_RADIUS, renderChildFoldingIcon } from './shapes/childFoldingIcon'; 21 | import Node from './shapes/Node'; 22 | import { renderParentChildConnector } from './shapes/parentChildConnector'; 23 | 24 | import { Coordinates, Dimensions } from '../../types'; 25 | import { CircularRegion, RectangularRegion } from './types'; 26 | 27 | type ClickableCircle = CircularRegion & {id: number}; 28 | type ClickableRectangle = RectangularRegion & {id: number}; 29 | 30 | //-------------------------------------------------------------------------- 31 | // ┌──────────────┐ ─┐ 32 | // ┌──| Grand Child1 | | 33 | // ┌────────┐ | └──────────────┘ ┐ | Height of 34 | // --- ┌──| Child1 |────┤ ├┐ ├── Child1 35 | // ┌────────┐ - - | └────────┘ | ┌──────────────┐ ┘| | Including 36 | // | Parent |- -────┤ └──| Grand Child2 | | | it's 37 | // └────────┘ - - | └──────────────┘ | ─┘ Children 38 | // --- | | 39 | // | ┌────────┐ | 40 | // └──| Child2 | CHILD_PADDING.y 41 | // └────────┘ 42 | // └───┬───┘ 43 | // └─── CHILD_PADDING.x 44 | // 45 | //-------------------------------------------------------------------------- 46 | const CHILD_PADDING = { 47 | x: 30, 48 | y: 15, 49 | }; 50 | 51 | // List of clickable regions (created upon each render) 52 | let clickableFoldingIcons: ClickableCircle[]; 53 | let clickableNodes: ClickableRectangle[]; 54 | 55 | let localCtx: CanvasRenderingContext2D; 56 | 57 | /** 58 | * A map where the keys are node IDs and the values are renderable objects 59 | */ 60 | let renderableNodes: Map; 61 | 62 | /** 63 | * A map where the keys are node IDs and the values correspond to each node's 64 | * height including its children 65 | */ 66 | let nodeHeightsIncludingChildren: Map; 67 | 68 | /** 69 | * Render the doc 70 | */ 71 | export function renderDocument( 72 | ctx: CanvasRenderingContext2D, 73 | fontSize: number, 74 | rootNodeId: number, 75 | canvasDimensions: Dimensions, 76 | ) { 77 | localCtx = ctx; 78 | renderableNodes = new Map(); 79 | nodeHeightsIncludingChildren = new Map(); 80 | clickableFoldingIcons = []; 81 | clickableNodes = []; 82 | 83 | ctx.strokeStyle = '#000000'; 84 | ctx.fillStyle = '#000000'; 85 | ctx.font = `${fontSize}px sans-serif`; 86 | 87 | const maxNodeWidth = 0.75 * canvasDimensions.width; 88 | 89 | canvasState.resetAllRenderedNodesCoordinates(); 90 | 91 | createNodeAndChildrenRenderingInfo({ 92 | fontSize, 93 | maxNodeWidth, 94 | nodeId: rootNodeId, 95 | }); 96 | 97 | renderNodeAndChildren( 98 | rootNodeId, 99 | canvasState.getRootNodeCoords(), 100 | ); 101 | } 102 | 103 | /** 104 | * Process a click on the canvas at the specified coordinates, selecting a node, 105 | * folding / unfolding children, etc as required. 106 | * 107 | */ 108 | export function onCanvasClick(pointerX: number, pointerY: number) { 109 | // Compare to clickable regions for nodes 110 | clickableNodes.forEach((region) => { 111 | if ( 112 | ( 113 | pointerX >= region.topLeft.x && 114 | pointerX <= region.topLeft.x + region.dimensions.width 115 | ) && 116 | ( 117 | pointerY >= region.topLeft.y && 118 | pointerY <= region.topLeft.y + region.dimensions.height 119 | ) 120 | ) { 121 | documentState.setSelectedNodeId(region.id); 122 | } 123 | }); 124 | 125 | // Compare to clickable regions for folding icons 126 | clickableFoldingIcons.forEach((region) => { 127 | const distanceToCenter = Math.sqrt( 128 | (pointerX - region.center.x) ** 2 + 129 | (pointerY - region.center.y) ** 2, 130 | ); 131 | 132 | if (distanceToCenter <= region.radius) { 133 | documentState.toggleChildrenVisibility(region.id); 134 | } 135 | }); 136 | } 137 | //------------------------------------------------------------------------------ 138 | // Private Interface 139 | //------------------------------------------------------------------------------ 140 | 141 | /** 142 | * Create all the information required to render a node and its children 143 | * (dimensions, height including children, etc) 144 | */ 145 | function createNodeAndChildrenRenderingInfo(args: { 146 | fontSize: number, 147 | maxNodeWidth: number, 148 | nodeId: number, 149 | }) { 150 | const { 151 | fontSize, 152 | maxNodeWidth, 153 | nodeId, 154 | } = args; 155 | const nodeIsSelected = documentState.getSelectedNodeId() === nodeId; 156 | const nodeIsBookmarked = documentState.getBookmarkedNodeIds().includes(nodeId); 157 | const contents = documentState.getNodeContents(nodeId); 158 | 159 | renderableNodes.set( 160 | nodeId, 161 | new Node({ 162 | ctx: localCtx, 163 | fontSize, 164 | maxWidth: maxNodeWidth, 165 | nodeIsSelected, 166 | nodeIsBookmarked, 167 | contents, 168 | }), 169 | ); 170 | 171 | let totalChildrenHeight = 0; 172 | 173 | if (documentState.getChildrenVisible(nodeId)) { 174 | const childIds = documentState.getNodeChildIds(nodeId); 175 | 176 | childIds.forEach((childId) => { 177 | createNodeAndChildrenRenderingInfo({ 178 | fontSize, 179 | maxNodeWidth, 180 | nodeId: childId, 181 | }); 182 | totalChildrenHeight += safeGetNodeHeightIncludingChildren(childId); 183 | }); 184 | 185 | if (childIds.length > 0) { 186 | totalChildrenHeight += (childIds.length - 1) * CHILD_PADDING.y; 187 | } 188 | } 189 | nodeHeightsIncludingChildren.set( 190 | nodeId, 191 | Math.max( 192 | safeGetRenderableNode(nodeId).getDimensions().height, 193 | totalChildrenHeight, 194 | ), 195 | ); 196 | } 197 | 198 | function renderNodeAndChildren(nodeId: number, coordinatesCenterLeft: Coordinates) { 199 | canvasState.setRenderedNodeCoordinates(nodeId, coordinatesCenterLeft); 200 | 201 | const renderableNode = safeGetRenderableNode(nodeId); 202 | 203 | const clickableNodeRegion = renderableNode.render(coordinatesCenterLeft); 204 | clickableNodes.push({ 205 | ...clickableNodeRegion, 206 | id: nodeId, 207 | }); 208 | 209 | const childIds = documentState.getNodeChildIds(nodeId); 210 | 211 | if (childIds.length > 0) { 212 | const childrenAreVisible = documentState.getChildrenVisible(nodeId); 213 | 214 | // Render the folding icon 215 | const foldingIconX = coordinatesCenterLeft.x + renderableNode.getDimensions().width; 216 | const foldingIconY = coordinatesCenterLeft.y; 217 | 218 | const clickableFoldingIconRegion = renderChildFoldingIcon( 219 | localCtx, 220 | { x: foldingIconX, y: foldingIconY }, 221 | childrenAreVisible, 222 | ); 223 | clickableFoldingIcons.push({ 224 | id: nodeId, 225 | ...clickableFoldingIconRegion, 226 | }); 227 | 228 | if (childrenAreVisible) { 229 | renderChildrenAndConnectors( 230 | { 231 | x: foldingIconX + CHILD_FOLDING_ICON_RADIUS * 2, 232 | y: coordinatesCenterLeft.y, 233 | }, 234 | childIds, 235 | ); 236 | } 237 | } 238 | } 239 | 240 | function renderChildrenAndConnectors( 241 | foldingIconRightCoordinates: Coordinates, 242 | childIds: number[], 243 | ) { 244 | const childrenX = foldingIconRightCoordinates.x + CHILD_PADDING.x; 245 | 246 | let totalChildrenHeight = 0; 247 | childIds.forEach((childId) => { 248 | totalChildrenHeight += safeGetNodeHeightIncludingChildren(childId); 249 | }); 250 | 251 | totalChildrenHeight += (childIds.length - 1) * CHILD_PADDING.y; 252 | 253 | const topOfChildrenRegion = foldingIconRightCoordinates.y - 254 | totalChildrenHeight / 2; 255 | 256 | let childY = topOfChildrenRegion; 257 | 258 | childIds.forEach((childId) => { 259 | const heightIncludingChildren = safeGetNodeHeightIncludingChildren(childId); 260 | 261 | childY += heightIncludingChildren / 2; 262 | 263 | renderParentChildConnector( 264 | localCtx, 265 | { 266 | x: foldingIconRightCoordinates.x, 267 | y: foldingIconRightCoordinates.y, 268 | }, 269 | { 270 | x: childrenX, 271 | y: childY, 272 | }, 273 | ); 274 | 275 | renderNodeAndChildren( 276 | childId, 277 | { 278 | x: childrenX, 279 | y: childY, 280 | }, 281 | ); 282 | 283 | childY += heightIncludingChildren / 2; 284 | childY += CHILD_PADDING.y; 285 | }); 286 | } 287 | 288 | /** 289 | * A helper function to get an entry from nodesHeightIncludingChildren, which 290 | * will throw an exception if that info isn't found (to keep typescript happy 291 | * without polluting calling code) 292 | */ 293 | function safeGetNodeHeightIncludingChildren(nodeId: number): number { 294 | const heightIncludingChildren = nodeHeightsIncludingChildren.get(nodeId); 295 | if (heightIncludingChildren !== undefined) return heightIncludingChildren; 296 | 297 | throw new Error( 298 | `safeGetNodeHeightIncludingChildren: nodeId '${nodeId}' is not present`, 299 | ); 300 | } 301 | 302 | /** 303 | * A helper function to get an entry from renderableNodes, which will throw an 304 | * exception if that info isn't found (to keep typescript happy without polluting 305 | * calling code) 306 | */ 307 | function safeGetRenderableNode(nodeId: number): Node { 308 | const node = renderableNodes.get(nodeId); 309 | if (node !== undefined) return node; 310 | 311 | throw new Error( 312 | `safeGetRenderableNode: nodeId '${nodeId}' is not present`, 313 | ); 314 | } 315 | -------------------------------------------------------------------------------- /src/components/DisplayedDocument/shapes/Node.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2023 Glen Reesor 2 | // 3 | // This file is part of m3 Mind Mapper. 4 | // 5 | // m3 Mind Mapper is free software: you can redistribute it and/or 6 | // modify it under the terms of the GNU General Public License as published by 7 | // the Free Software Foundation, either version 3 of the License, or (at your 8 | // option) any later version. 9 | // 10 | // m3 Mind Mapper is distributed in the hope that it will be useful, but 11 | // WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or 12 | // FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more 13 | // details. 14 | // 15 | // You should have received a copy of the GNU General Public License along with 16 | // m3 Mind Mapper. If not, see . 17 | 18 | // This file contains functions for dealing with individual nodes in a mind map 19 | 20 | // ┌───────────────────────┐ ┐ 21 | // | | ├─── PADDING_Y 22 | // | | ┘ 23 | // | Node Contents | _ 24 | // | | _|─── PADDING_BETWEEN_LINES 25 | // ────┤ Possibly | 26 | // ^ | | 27 | // | | multiple lines | 28 | // | | | 29 | // | | | 30 | // | └───────────────────────┘ 31 | // | └──┬─┘ 32 | // | └────── PADDING_X 33 | // | 34 | // | 35 | // └─── Connector to parent (not rendered or considered by code in this file) 36 | 37 | import { BOOKMARK_ICON_WIDTH, drawBookmarkIcon } from './bookmarkIcon'; 38 | import { drawRoundedRectangle } from './roundedRectangle'; 39 | 40 | import { Coordinates, Dimensions } from '../../../types'; 41 | import { RectangularRegion } from '../types'; 42 | 43 | const PADDING_X = 5; 44 | const PADDING_Y = 5; 45 | const PADDING_BETWEEN_LINES = 4; 46 | 47 | /** 48 | * An object that knows how to render a Node when given its coordinates 49 | */ 50 | export default class Node { 51 | #ctx: CanvasRenderingContext2D; 52 | 53 | #dimensions: Dimensions; 54 | 55 | #fontSize: number; 56 | 57 | #textLinesToRender: string[]; 58 | 59 | #nodeIsBookmarked: boolean; 60 | 61 | #nodeIsSelected: boolean; 62 | 63 | constructor(args: { 64 | ctx: CanvasRenderingContext2D, 65 | fontSize: number, 66 | maxWidth: number, 67 | nodeIsBookmarked: boolean; 68 | nodeIsSelected: boolean; 69 | contents: string, 70 | }) { 71 | const { contents, maxWidth } = args; 72 | 73 | this.#ctx = args.ctx; 74 | this.#fontSize = args.fontSize; 75 | this.#nodeIsBookmarked = args.nodeIsBookmarked; 76 | this.#nodeIsSelected = args.nodeIsSelected; 77 | 78 | this.#textLinesToRender = this.#getTextLinesToRender(maxWidth, contents); 79 | this.#dimensions = this.#calcDimensions(); 80 | } 81 | 82 | getDimensions(): Dimensions { 83 | return this.#dimensions; 84 | } 85 | 86 | render(centerLeftCoordinates: Coordinates): RectangularRegion { 87 | drawRoundedRectangle({ 88 | ctx: this.#ctx, 89 | nodeIsSelected: this.#nodeIsSelected, 90 | topLeftCoordinates: { 91 | x: centerLeftCoordinates.x, 92 | y: centerLeftCoordinates.y - this.#dimensions.height / 2, 93 | }, 94 | dimensions: this.#dimensions, 95 | }); 96 | 97 | const nodeTop = centerLeftCoordinates.y - this.#dimensions.height / 2; 98 | 99 | if (this.#nodeIsBookmarked) { 100 | drawBookmarkIcon({ 101 | ctx: this.#ctx, 102 | centerLeftCoordinates: { 103 | x: centerLeftCoordinates.x + PADDING_X, 104 | y: centerLeftCoordinates.y, 105 | }, 106 | }); 107 | } 108 | 109 | // We want the text centered vertically, with PADDING_Y between the text 110 | // and the edge of the rectangle. 0.75 is a fudge factor to center it 111 | // since I know next to nothing about fonts :-) 112 | let textY = nodeTop + 0.75 * PADDING_Y + this.#fontSize; 113 | 114 | const textX = centerLeftCoordinates.x + PADDING_X + 115 | (this.#nodeIsBookmarked ? PADDING_X + BOOKMARK_ICON_WIDTH : 0); 116 | 117 | this.#textLinesToRender.forEach((line) => { 118 | this.#ctx.fillText( 119 | line, 120 | textX, 121 | textY, 122 | ); 123 | textY += this.#fontSize + PADDING_BETWEEN_LINES; 124 | }); 125 | 126 | return { 127 | topLeft: { 128 | x: centerLeftCoordinates.x, 129 | y: centerLeftCoordinates.y - this.#dimensions.height / 2, 130 | }, 131 | dimensions: this.#dimensions, 132 | }; 133 | } 134 | 135 | #calcDimensions(): Dimensions { 136 | const longestLineLength = this.#textLinesToRender.reduce( 137 | (currentLongestLength, line) => { 138 | const textMetrics = this.#ctx.measureText(line); 139 | return textMetrics.width > currentLongestLength 140 | ? textMetrics.width 141 | : currentLongestLength; 142 | }, 143 | 0, 144 | ); 145 | 146 | const dimensions = { 147 | height: this.#fontSize * this.#textLinesToRender.length + 148 | PADDING_BETWEEN_LINES * (this.#textLinesToRender.length - 1) + 149 | 2 * PADDING_Y, 150 | width: 151 | longestLineLength + 2 * PADDING_X + 152 | (this.#nodeIsBookmarked ? PADDING_X + BOOKMARK_ICON_WIDTH : 0), 153 | }; 154 | 155 | return dimensions; 156 | } 157 | 158 | #getTextLinesToRender(maxWidth: number, contents: string): string[] { 159 | const textLinesToRender = []; 160 | 161 | let remainingContents = contents.slice(); 162 | 163 | // Start with the entire contents and split into shorter lines (if required) 164 | // until the entire contents of the node have been accounted for 165 | while (remainingContents !== '') { 166 | let lastCharIndex = remainingContents.length - 1; 167 | let requiredWidth = this.#ctx.measureText( 168 | remainingContents.substring(0, lastCharIndex + 1), 169 | ).width; 170 | 171 | // Keep shortening text until it fits within the max width 172 | while (requiredWidth > maxWidth && lastCharIndex > 0) { 173 | lastCharIndex -= 1; 174 | 175 | // Text is too long, so shorten by looking backwards for the first 176 | // space (so we don't split words) 177 | while ( 178 | remainingContents.charAt(lastCharIndex) !== ' ' && 179 | lastCharIndex > 0 180 | ) { 181 | lastCharIndex -= 1; 182 | } 183 | requiredWidth = this.#ctx.measureText( 184 | remainingContents.substring(0, lastCharIndex + 1), 185 | ).width; 186 | } 187 | 188 | textLinesToRender.push(remainingContents.substring(0, lastCharIndex + 1)); 189 | remainingContents = remainingContents.slice(lastCharIndex + 1); 190 | } 191 | 192 | return textLinesToRender; 193 | } 194 | } 195 | -------------------------------------------------------------------------------- /src/components/DisplayedDocument/shapes/bookmarkIcon.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2023 Glen Reesor 2 | // 3 | // This file is part of m3 Mind Mapper. 4 | // 5 | // m3 Mind Mapper is free software: you can redistribute it and/or 6 | // modify it under the terms of the GNU General Public License as published by 7 | // the Free Software Foundation, either version 3 of the License, or (at your 8 | // option) any later version. 9 | // 10 | // m3 Mind Mapper is distributed in the hope that it will be useful, but 11 | // WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or 12 | // FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more 13 | // details. 14 | // 15 | // You should have received a copy of the GNU General Public License along with 16 | // m3 Mind Mapper. If not, see . 17 | 18 | import { Coordinates } from '../../../types'; 19 | 20 | export const BOOKMARK_ICON_WIDTH = 18; 21 | 22 | /** 23 | * Draw a bookmark icon at the specified position. 24 | */ 25 | export function drawBookmarkIcon(args: { 26 | ctx: CanvasRenderingContext2D; 27 | centerLeftCoordinates: Coordinates; 28 | }) { 29 | const { ctx, centerLeftCoordinates } = args; 30 | const originalCtxFillStyle = ctx.fillStyle; 31 | 32 | ctx.beginPath(); 33 | ctx.moveTo(centerLeftCoordinates.x, centerLeftCoordinates.y); 34 | ctx.fillStyle = '#7e4fcf'; 35 | 36 | // Middle left to top center 37 | ctx.lineTo( 38 | centerLeftCoordinates.x + BOOKMARK_ICON_WIDTH / 2, 39 | centerLeftCoordinates.y - BOOKMARK_ICON_WIDTH / 2, 40 | ); 41 | 42 | // Top center to middle right 43 | ctx.lineTo( 44 | centerLeftCoordinates.x + BOOKMARK_ICON_WIDTH, 45 | centerLeftCoordinates.y, 46 | ); 47 | 48 | // Middle right to bottom center 49 | ctx.lineTo( 50 | centerLeftCoordinates.x + BOOKMARK_ICON_WIDTH / 2, 51 | centerLeftCoordinates.y + BOOKMARK_ICON_WIDTH / 2, 52 | ); 53 | 54 | // Bottom center to middle left 55 | ctx.lineTo( 56 | centerLeftCoordinates.x, 57 | centerLeftCoordinates.y, 58 | ); 59 | 60 | ctx.fill(); 61 | ctx.closePath(); 62 | ctx.fillStyle = originalCtxFillStyle; 63 | } 64 | -------------------------------------------------------------------------------- /src/components/DisplayedDocument/shapes/childFoldingIcon.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2023 Glen Reesor 2 | // 3 | // This file is part of m3 Mind Mapper. 4 | // 5 | // m3 Mind Mapper is free software: you can redistribute it and/or 6 | // modify it under the terms of the GNU General Public License as published by 7 | // the Free Software Foundation, either version 3 of the License, or (at your 8 | // option) any later version. 9 | // 10 | // m3 Mind Mapper is distributed in the hope that it will be useful, but 11 | // WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or 12 | // FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more 13 | // details. 14 | // 15 | // You should have received a copy of the GNU General Public License along with 16 | // m3 Mind Mapper. If not, see . 17 | 18 | import { Coordinates } from '../../../types'; 19 | import { CircularRegion } from '../types'; 20 | 21 | export const CHILD_FOLDING_ICON_RADIUS = 10; 22 | 23 | /** 24 | * Render a child folding icon at the specified location. 25 | * 26 | * o o 27 | * o o 28 | * ┌──►o o 29 | * │ o o 30 | * │ o o 31 | * │ 32 | * └───── centerLeftCoordinates 33 | * 34 | * @returns A description of the circular region that should respond to clicks 35 | */ 36 | export function renderChildFoldingIcon( 37 | ctx: CanvasRenderingContext2D, 38 | centerLeftCoordinates: Coordinates, 39 | childrenAreVisible: boolean, 40 | ): CircularRegion { 41 | ctx.beginPath(); 42 | ctx.arc( 43 | centerLeftCoordinates.x + CHILD_FOLDING_ICON_RADIUS, 44 | centerLeftCoordinates.y, 45 | CHILD_FOLDING_ICON_RADIUS, 46 | 0, 47 | 2 * Math.PI, 48 | ); 49 | 50 | if (childrenAreVisible) { 51 | ctx.stroke(); 52 | } else { 53 | ctx.fill(); 54 | } 55 | 56 | return { 57 | center: { 58 | x: centerLeftCoordinates.x + CHILD_FOLDING_ICON_RADIUS, 59 | y: centerLeftCoordinates.y, 60 | }, 61 | radius: CHILD_FOLDING_ICON_RADIUS, 62 | }; 63 | } 64 | -------------------------------------------------------------------------------- /src/components/DisplayedDocument/shapes/parentChildConnector.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2023 Glen Reesor 2 | // 3 | // This file is part of m3 Mind Mapper. 4 | // 5 | // m3 Mind Mapper is free software: you can redistribute it and/or 6 | // modify it under the terms of the GNU General Public License as published by 7 | // the Free Software Foundation, either version 3 of the License, or (at your 8 | // option) any later version. 9 | // 10 | // m3 Mind Mapper is distributed in the hope that it will be useful, but 11 | // WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or 12 | // FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more 13 | // details. 14 | // 15 | // You should have received a copy of the GNU General Public License along with 16 | // m3 Mind Mapper. If not, see . 17 | 18 | import { Coordinates } from '../../../types'; 19 | 20 | /** 21 | * Render the curve that connects a parent node's children foldingIcon to a child node 22 | */ 23 | export function renderParentChildConnector( 24 | ctx: CanvasRenderingContext2D, 25 | curveStart: Coordinates, 26 | curveEnd: Coordinates, 27 | ) { 28 | ctx.beginPath(); 29 | ctx.moveTo(curveStart.x, curveStart.y); 30 | ctx.bezierCurveTo( 31 | curveEnd.x, 32 | curveStart.y, 33 | curveStart.x, 34 | curveEnd.y, 35 | curveEnd.x, 36 | curveEnd.y, 37 | ); 38 | 39 | ctx.stroke(); 40 | } 41 | -------------------------------------------------------------------------------- /src/components/DisplayedDocument/shapes/roundedRectangle.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2023 Glen Reesor 2 | // 3 | // This file is part of m3 Mind Mapper. 4 | // 5 | // m3 Mind Mapper is free software: you can redistribute it and/or 6 | // modify it under the terms of the GNU General Public License as published by 7 | // the Free Software Foundation, either version 3 of the License, or (at your 8 | // option) any later version. 9 | // 10 | // m3 Mind Mapper is distributed in the hope that it will be useful, but 11 | // WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or 12 | // FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more 13 | // details. 14 | // 15 | // You should have received a copy of the GNU General Public License along with 16 | // m3 Mind Mapper. If not, see . 17 | 18 | import { Coordinates, Dimensions } from '../../../types'; 19 | 20 | /** 21 | * Draw a rounded rectangle at the specified position. 22 | */ 23 | export function drawRoundedRectangle(args: { 24 | ctx: CanvasRenderingContext2D; 25 | nodeIsSelected: boolean; 26 | topLeftCoordinates: Coordinates; 27 | dimensions: Dimensions; 28 | }) { 29 | const { ctx, nodeIsSelected, topLeftCoordinates, dimensions } = args; 30 | 31 | const originalStrokeStyle = ctx.strokeStyle; 32 | const originalLineWidth = ctx.lineWidth; 33 | 34 | const cornerRadius = 5; 35 | 36 | // Coordinates of corners as if this were a normal rectangle 37 | const topLeft = topLeftCoordinates; 38 | 39 | const topRight = { 40 | x: topLeft.x + dimensions.width, 41 | y: topLeft.y, 42 | }; 43 | 44 | const bottomLeft = { 45 | x: topLeft.x, 46 | y: topLeft.y + dimensions.height, 47 | }; 48 | 49 | const bottomRight = { 50 | x: topRight.x, 51 | y: bottomLeft.y, 52 | }; 53 | 54 | // Draw in a clockwise direction starting at top left 55 | ctx.beginPath(); 56 | if (nodeIsSelected) { 57 | ctx.strokeStyle = '#0000ff'; 58 | ctx.lineWidth = 2; 59 | } else { 60 | ctx.strokeStyle = '#000000'; 61 | ctx.lineWidth = 1; 62 | } 63 | 64 | // Top left corner 65 | ctx.arc( 66 | topLeft.x + cornerRadius, 67 | topLeft.y + cornerRadius, 68 | cornerRadius, 69 | Math.PI, 70 | 1.5 * Math.PI, 71 | ); 72 | 73 | // Top line 74 | ctx.lineTo(topRight.x - cornerRadius, topRight.y); 75 | 76 | // Top right corner 77 | ctx.arc( 78 | topRight.x - cornerRadius, 79 | topRight.y + cornerRadius, 80 | cornerRadius, 81 | 1.5 * Math.PI, 82 | 2 * Math.PI, 83 | ); 84 | 85 | // Right line 86 | ctx.lineTo(bottomRight.x, bottomRight.y - cornerRadius); 87 | 88 | // Bottom right corner 89 | ctx.arc( 90 | bottomRight.x - cornerRadius, 91 | bottomRight.y - cornerRadius, 92 | cornerRadius, 93 | 0, 94 | 0.5 * Math.PI, 95 | ); 96 | 97 | // Bottom line 98 | ctx.lineTo(bottomLeft.x + cornerRadius, bottomLeft.y); 99 | 100 | // Bottom left corner 101 | ctx.arc( 102 | bottomLeft.x + cornerRadius, 103 | bottomLeft.y - cornerRadius, 104 | cornerRadius, 105 | 0.5 * Math.PI, 106 | Math.PI, 107 | ); 108 | 109 | // Left line 110 | ctx.lineTo(topLeft.x, topLeft.y + cornerRadius); 111 | 112 | // And done! 113 | ctx.stroke(); 114 | 115 | ctx.strokeStyle = originalStrokeStyle; 116 | ctx.lineWidth = originalLineWidth; 117 | } 118 | -------------------------------------------------------------------------------- /src/components/DisplayedDocument/types.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2023 Glen Reesor 2 | // 3 | // This file is part of m3 Mind Mapper. 4 | // 5 | // m3 Mind Mapper is free software: you can redistribute it and/or 6 | // modify it under the terms of the GNU General Public License as published by 7 | // the Free Software Foundation, either version 3 of the License, or (at your 8 | // option) any later version. 9 | // 10 | // m3 Mind Mapper is distributed in the hope that it will be useful, but 11 | // WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or 12 | // FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more 13 | // details. 14 | // 15 | // You should have received a copy of the GNU General Public License along with 16 | // m3 Mind Mapper. If not, see . 17 | 18 | import { Coordinates, Dimensions } from '../../types'; 19 | 20 | export type CircularRegion = { 21 | center: Coordinates; 22 | radius: number; 23 | } 24 | 25 | export type RectangularRegion = { 26 | topLeft: Coordinates; 27 | dimensions: Dimensions; 28 | } 29 | -------------------------------------------------------------------------------- /src/components/DocumentHeader.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2022, 2023 Glen Reesor 2 | // 3 | // This file is part of m3 Mind Mapper. 4 | // 5 | // m3 Mind Mapper is free software: you can redistribute it and/or 6 | // modify it under the terms of the GNU General Public License as published by 7 | // the Free Software Foundation, either version 3 of the License, or (at your 8 | // option) any later version. 9 | // 10 | // m3 Mind Mapper is distributed in the hope that it will be useful, but 11 | // WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or 12 | // FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more 13 | // details. 14 | // 15 | // You should have received a copy of the GNU General Public License along with 16 | // m3 Mind Mapper. If not, see . 17 | 18 | import * as m from 'mithril'; 19 | 20 | import state from '../state/state'; 21 | 22 | interface DocumentHeaderAttributes { 23 | documentName: string, 24 | hasUnsavedChanges: boolean, 25 | docLastExportedTimestamp: number | undefined, 26 | } 27 | 28 | /** 29 | * A component that contains the header to be shown above the document 30 | * (name, modified status) 31 | * 32 | * @returns A component to be consumed by m() 33 | */ 34 | function DocumentHeader(): m.Component { 35 | return { 36 | view: ({ attrs }): m.Vnode => { 37 | const docName = attrs.documentName === '' ? 'New Map' : attrs.documentName; 38 | const modifiedIndicator = attrs.hasUnsavedChanges ? ' (Modified)' : ''; 39 | 40 | let formattedExportedTimestamp = ''; 41 | if (attrs.docLastExportedTimestamp) { 42 | const exportedDateObject = new Date(attrs.docLastExportedTimestamp); 43 | const exportedYear = exportedDateObject.getFullYear(); 44 | 45 | // JS Date months are 0-based 46 | const exportedMonth = exportedDateObject.getMonth() + 1; 47 | const exportedDate = exportedDateObject.getDate(); 48 | 49 | const monthPadding = exportedMonth < 10 ? '0' : ''; 50 | const datePadding = exportedDate < 10 ? '0' : ''; 51 | formattedExportedTimestamp = `${exportedYear}-${monthPadding}${exportedMonth}-${datePadding}${exportedDate}`; 52 | } 53 | 54 | const lastExportedInfo = attrs.docLastExportedTimestamp 55 | ? `Last Exported: ${formattedExportedTimestamp}` 56 | : ''; 57 | 58 | return m( 59 | 'div', 60 | { 61 | style: `display: flex; justify-content: space-between; font-size: ${state.ui.getCurrentFontSize()}px`, 62 | }, 63 | m( 64 | 'span', 65 | docName, 66 | m( 67 | 'span', 68 | { style: 'color: blue' }, 69 | modifiedIndicator, 70 | ), 71 | ), 72 | lastExportedInfo, 73 | ); 74 | }, 75 | }; 76 | } 77 | 78 | export default DocumentHeader; 79 | -------------------------------------------------------------------------------- /src/components/FileExportModal.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2022, 2023 Glen Reesor 2 | // 3 | // This file is part of m3 Mind Mapper. 4 | // 5 | // m3 Mind Mapper is free software: you can redistribute it and/or 6 | // modify it under the terms of the GNU General Public License as published by 7 | // the Free Software Foundation, either version 3 of the License, or (at your 8 | // option) any later version. 9 | // 10 | // m3 Mind Mapper is distributed in the hope that it will be useful, but 11 | // WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or 12 | // FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more 13 | // details. 14 | // 15 | // You should have received a copy of the GNU General Public License along with 16 | // m3 Mind Mapper. If not, see . 17 | 18 | import * as m from 'mithril'; 19 | 20 | import state from '../state/state'; 21 | 22 | export interface FileExportModalAttributes { 23 | onClose: () => void, 24 | } 25 | 26 | /** 27 | * A component that provides a Blob link to the current document in JSON format 28 | * 29 | * @returns An object to be consumed by m() 30 | */ 31 | function FileExportModal(): m.Component { 32 | function getButtonMarkup(attrs: FileExportModalAttributes) { 33 | return m( 34 | 'div', 35 | { 36 | style: { 37 | marginTop: '20px', 38 | textAlign: 'right', 39 | }, 40 | }, 41 | [ 42 | m( 43 | 'button', 44 | { onclick: attrs.onClose }, 45 | 'Close', 46 | ), 47 | ], 48 | ); 49 | } 50 | 51 | function getFileExportModalMarkup(attrs: FileExportModalAttributes): m.Vnode { 52 | const dateNow = new Date(Date.now()); 53 | 54 | /* eslint-disable prefer-template */ 55 | const formattedDate = dateNow.getFullYear() + '-' + 56 | 57 | // Don't forget JS months are zero-based. omg 58 | getNumberAsZeroPaddedTwoDigits(dateNow.getMonth() + 1) + '-' + 59 | 60 | getNumberAsZeroPaddedTwoDigits(dateNow.getDate()) + 61 | '---' + 62 | getNumberAsZeroPaddedTwoDigits(dateNow.getHours()) + ':' + 63 | getNumberAsZeroPaddedTwoDigits(dateNow.getMinutes()) + ':' + 64 | getNumberAsZeroPaddedTwoDigits(dateNow.getSeconds()); 65 | 66 | // For now assume that if user opens this dialog, they actually *do* 67 | // export it. This will also be kind of goofy because it's going to 68 | // mark the doc as modified (because we need the user to save it) 69 | state.doc.setDocLastExportedTimestamp(Date.now()); 70 | 71 | const documentAsJson = state.doc.getCurrentDocAsJson(); 72 | const blobUrl = URL.createObjectURL( 73 | new Blob([documentAsJson], { type: 'text/json' }), 74 | ); 75 | const blobFilename = `m3-${state.doc.getDocName()}--${formattedDate}.m3`; 76 | 77 | return m( 78 | 'div', 79 | { 80 | // TODO: Don't use embedded styles 81 | style: { 82 | background: '#dddddd', 83 | padding: '10px', 84 | border: '2px solid blue', 85 | fontSize: `${state.ui.getCurrentFontSize()}px`, 86 | position: 'fixed', 87 | left: '50%', 88 | top: '35%', 89 | transform: 'translate(-50%, -50%)', 90 | zIndex: '20', 91 | }, 92 | }, 93 | [ 94 | m( 95 | 'div', 96 | 'Download: ', 97 | m( 98 | 'a', 99 | { 100 | href: blobUrl, 101 | download: blobFilename, 102 | }, 103 | blobFilename, 104 | ), 105 | ), 106 | getButtonMarkup(attrs), 107 | ], 108 | ); 109 | } 110 | 111 | function getNumberAsZeroPaddedTwoDigits(num: number): string { 112 | return num < 10 ? `0${num.toString()}` : num.toString(); 113 | } 114 | 115 | function getOverlayMarkup(): m.Vnode { 116 | return m( 117 | 'div', 118 | { 119 | // TODO: Don't use embedded styles 120 | style: { 121 | position: 'fixed', 122 | top: '0px', 123 | width: '100%', 124 | height: '100vh', 125 | background: 'rgba(255, 255, 255, 0.5)', 126 | zIndex: '10', 127 | }, 128 | }, 129 | ); 130 | } 131 | 132 | return { 133 | view: ({ attrs }): m.Vnode => m( 134 | 'div', 135 | [ 136 | getOverlayMarkup(), 137 | getFileExportModalMarkup(attrs), 138 | ], 139 | ), 140 | }; 141 | } 142 | 143 | export default FileExportModal; 144 | -------------------------------------------------------------------------------- /src/components/FileImportModal.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2022 Glen Reesor 2 | // 3 | // This file is part of m3 Mind Mapper. 4 | // 5 | // m3 Mind Mapper is free software: you can redistribute it and/or 6 | // modify it under the terms of the GNU General Public License as published by 7 | // the Free Software Foundation, either version 3 of the License, or (at your 8 | // option) any later version. 9 | // 10 | // m3 Mind Mapper is distributed in the hope that it will be useful, but 11 | // WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or 12 | // FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more 13 | // details. 14 | // 15 | // You should have received a copy of the GNU General Public License along with 16 | // m3 Mind Mapper. If not, see . 17 | 18 | import * as m from 'mithril'; 19 | 20 | import state from '../state/state'; 21 | 22 | export interface FileImportModalAttributes { 23 | onCancel: () => void, 24 | onFileContentsRead: (fileContents: string) => void, 25 | } 26 | 27 | /** 28 | * A component that prompts the user to select a file from their device's 29 | * filesystem, then attempts to load that as the current document. 30 | * 31 | * @returns An object to be consumed by m() 32 | */ 33 | function FileImportModal(): m.Component { 34 | function getButtonMarkup(attrs: FileImportModalAttributes) { 35 | return m( 36 | 'div', 37 | { 38 | style: { 39 | marginTop: '20px', 40 | textAlign: 'right', 41 | }, 42 | }, 43 | [ 44 | m( 45 | 'button', 46 | { onclick: attrs.onCancel }, 47 | 'Cancel', 48 | ), 49 | ], 50 | ); 51 | } 52 | 53 | function getFileImportModalMarkup(attrs: FileImportModalAttributes): m.Vnode { 54 | return m( 55 | 'div', 56 | { 57 | // TODO: Don't use embedded styles 58 | style: { 59 | background: '#dddddd', 60 | padding: '10px', 61 | border: '2px solid blue', 62 | fontSize: `${state.ui.getCurrentFontSize()}px`, 63 | position: 'fixed', 64 | left: '50%', 65 | top: '35%', 66 | transform: 'translate(-50%, -50%)', 67 | zIndex: '20', 68 | }, 69 | }, 70 | [ 71 | m( 72 | 'div', 73 | 'Import Freemind or m3 document', 74 | m( 75 | 'input', 76 | { 77 | style: 'padding-top: 25px', 78 | type: 'file', 79 | onchange: (e: Event) => onFileSelected(attrs, e), 80 | }, 81 | ), 82 | ), 83 | getButtonMarkup(attrs), 84 | ], 85 | ); 86 | } 87 | 88 | function getOverlayMarkup(): m.Vnode { 89 | return m( 90 | 'div', 91 | { 92 | // TODO: Don't use embedded styles 93 | style: { 94 | position: 'fixed', 95 | top: '0px', 96 | width: '100%', 97 | height: '100vh', 98 | background: 'rgba(255, 255, 255, 0.5)', 99 | zIndex: '10', 100 | }, 101 | }, 102 | ); 103 | } 104 | 105 | function onFileSelected(attrs: FileImportModalAttributes, e: Event) { 106 | const fileList = (e.target as HTMLInputElement).files; 107 | 108 | if (fileList !== null) { 109 | const fileReader = new FileReader(); 110 | fileReader.onloadend = () => { 111 | attrs.onFileContentsRead(fileReader.result as string); 112 | }; 113 | fileReader.readAsText(fileList[0]); 114 | } 115 | } 116 | 117 | return { 118 | view: ({ attrs }): m.Vnode => m( 119 | 'div', 120 | [ 121 | getOverlayMarkup(), 122 | getFileImportModalMarkup(attrs), 123 | ], 124 | ), 125 | }; 126 | } 127 | 128 | export default FileImportModal; 129 | -------------------------------------------------------------------------------- /src/components/FileOpenModal.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2022 Glen Reesor 2 | // 3 | // This file is part of m3 Mind Mapper. 4 | // 5 | // m3 Mind Mapper is free software: you can redistribute it and/or 6 | // modify it under the terms of the GNU General Public License as published by 7 | // the Free Software Foundation, either version 3 of the License, or (at your 8 | // option) any later version. 9 | // 10 | // m3 Mind Mapper is distributed in the hope that it will be useful, but 11 | // WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or 12 | // FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more 13 | // details. 14 | // 15 | // You should have received a copy of the GNU General Public License along with 16 | // m3 Mind Mapper. If not, see . 17 | 18 | import * as m from 'mithril'; 19 | 20 | import state from '../state/state'; 21 | 22 | import { getSavedDocumentList } from '../utils/file'; 23 | 24 | export interface FileOpenModalAttributes { 25 | onCancel: () => void, 26 | onFileSelected: (filename: string) => void, 27 | } 28 | 29 | /** 30 | * A component that presents the list of saved documents and allows the user 31 | * to select which one to load 32 | * 33 | * @returns An object to be consumed by m() 34 | */ 35 | function FileOpenModal(): m.Component { 36 | function getButtonMarkup(attrs: FileOpenModalAttributes) { 37 | return m( 38 | 'div', 39 | { 40 | style: { 41 | marginTop: '20px', 42 | textAlign: 'right', 43 | }, 44 | }, 45 | [ 46 | m( 47 | 'button', 48 | { onclick: attrs.onCancel }, 49 | 'Cancel', 50 | ), 51 | ], 52 | ); 53 | } 54 | 55 | function getCurrentDocsMarkup(attrs: FileOpenModalAttributes) { 56 | const currentFilenamesMarkup:Array = []; 57 | getSavedDocumentList().forEach((filename, index) => { 58 | currentFilenamesMarkup.push( 59 | m( 60 | 'div', 61 | { 62 | // TODO: Fix using nth child stuff 63 | style: { 64 | background: '#ffffff', 65 | fontSize: `${state.ui.getCurrentFontSize()}px`, 66 | paddingTop: index === 0 ? '10px' : '0', 67 | paddingBottom: '10px', 68 | paddingLeft: '20px', 69 | paddingRight: '20px', 70 | }, 71 | onclick: () => attrs.onFileSelected(filename), 72 | }, 73 | filename, 74 | ), 75 | ); 76 | }); 77 | 78 | return m( 79 | 'div', 80 | { 81 | style: { 82 | height: '100px', 83 | overflow: 'auto', 84 | paddingTop: '10px', 85 | paddingBottom: '10px', 86 | paddingLeft: '55px', 87 | 88 | // TODO: Make this a non-hack 89 | width: '200px', 90 | maxWidth: '75%', 91 | }, 92 | }, 93 | currentFilenamesMarkup, 94 | ); 95 | } 96 | 97 | function getFileOpenModalMarkup(attrs: FileOpenModalAttributes): m.Vnode { 98 | return m( 99 | 'div', 100 | { 101 | // TODO: Don't use embedded styles 102 | style: { 103 | background: '#dddddd', 104 | padding: '10px', 105 | border: '2px solid blue', 106 | fontSize: '14px', 107 | position: 'fixed', 108 | left: '50%', 109 | top: '35%', 110 | transform: 'translate(-50%, -50%)', 111 | zIndex: '20', 112 | }, 113 | }, 114 | [ 115 | getCurrentDocsMarkup(attrs), 116 | getButtonMarkup(attrs), 117 | ], 118 | ); 119 | } 120 | 121 | function getOverlayMarkup(): m.Vnode { 122 | return m( 123 | 'div', 124 | { 125 | // TODO: Don't use embedded styles 126 | style: { 127 | position: 'fixed', 128 | top: '0px', 129 | width: '100%', 130 | height: '100vh', 131 | background: 'rgba(255, 255, 255, 0.5)', 132 | zIndex: '10', 133 | }, 134 | }, 135 | ); 136 | } 137 | 138 | return { 139 | view: ({ attrs }): m.Vnode => m( 140 | 'div', 141 | [ 142 | getOverlayMarkup(), 143 | getFileOpenModalMarkup(attrs), 144 | ], 145 | ), 146 | }; 147 | } 148 | 149 | export default FileOpenModal; 150 | -------------------------------------------------------------------------------- /src/components/FileSaveModal.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2022, 2023 Glen Reesor 2 | // 3 | // This file is part of m3 Mind Mapper. 4 | // 5 | // m3 Mind Mapper is free software: you can redistribute it and/or 6 | // modify it under the terms of the GNU General Public License as published by 7 | // the Free Software Foundation, either version 3 of the License, or (at your 8 | // option) any later version. 9 | // 10 | // m3 Mind Mapper is distributed in the hope that it will be useful, but 11 | // WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or 12 | // FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more 13 | // details. 14 | // 15 | // You should have received a copy of the GNU General Public License along with 16 | // m3 Mind Mapper. If not, see . 17 | 18 | import * as m from 'mithril'; 19 | 20 | import state from '../state/state'; 21 | 22 | import { getSavedDocumentList } from '../utils/file'; 23 | 24 | export interface FileSaveModalAttributes { 25 | docName: string, 26 | onCancel: () => void, 27 | onSave: (filename: string) => void, 28 | } 29 | 30 | /** 31 | * A component that presents the list of saved documents and an input box 32 | * used to specify the name to use when saving the current document 33 | * 34 | * @returns An object to be consumed by m() 35 | */ 36 | function FileSaveModal(): m.Component { 37 | let inputValue = ''; 38 | 39 | function getButtonsMarkup(attrs: FileSaveModalAttributes) { 40 | return m( 41 | 'div', 42 | { 43 | style: { 44 | marginTop: '20px', 45 | textAlign: 'right', 46 | }, 47 | }, 48 | [ 49 | m( 50 | 'button', 51 | { 52 | style: 'margin-right: 10px', 53 | onclick: () => onSave(attrs), 54 | disabled: inputValue === '', 55 | }, 56 | 'Save', 57 | ), 58 | m( 59 | 'button', 60 | { onclick: attrs.onCancel }, 61 | 'Cancel', 62 | ), 63 | ], 64 | ); 65 | } 66 | 67 | function getCurrentDocsMarkup() { 68 | const currentFilenamesMarkup:Array = []; 69 | getSavedDocumentList().forEach((filename, index) => { 70 | currentFilenamesMarkup.push( 71 | m( 72 | 'div', 73 | { 74 | // TODO: Fix using nth child stuff 75 | style: { 76 | background: '#ffffff', 77 | paddingTop: index === 0 ? '10px' : '0', 78 | paddingBottom: '10px', 79 | paddingLeft: '20px', 80 | paddingRight: '20px', 81 | }, 82 | }, 83 | filename, 84 | ), 85 | ); 86 | }); 87 | 88 | return m( 89 | 'div', 90 | { 91 | style: { 92 | height: '100px', 93 | overflow: 'auto', 94 | paddingTop: '10px', 95 | paddingBottom: '10px', 96 | paddingLeft: '55px', 97 | }, 98 | }, 99 | currentFilenamesMarkup, 100 | ); 101 | } 102 | 103 | function getNameInputMarkup(attrs: FileSaveModalAttributes) { 104 | return m( 105 | 'div', 106 | { 107 | style: 'display: flex', 108 | }, 109 | [ 110 | m( 111 | 'div', 112 | 'Name', 113 | ), 114 | m( 115 | 'div', 116 | m( 117 | 'input', 118 | { 119 | oninput: onInputValueChange, 120 | onkeyup: (e: KeyboardEvent) => onInputKeyUp(e, attrs), 121 | style: { 122 | fontSize: `${state.ui.getCurrentFontSize()}px`, 123 | marginLeft: '12px', 124 | }, 125 | value: inputValue, 126 | 127 | }, 128 | ), 129 | ), 130 | ], 131 | ); 132 | } 133 | 134 | function getFileSaveModalMarkup(attrs: FileSaveModalAttributes): m.Vnode { 135 | return m( 136 | 'div', 137 | { 138 | // TODO: Don't use embedded styles 139 | style: { 140 | background: '#dddddd', 141 | padding: '10px', 142 | border: '2px solid blue', 143 | fontSize: `${state.ui.getCurrentFontSize()}px`, 144 | position: 'fixed', 145 | left: '50%', 146 | top: '35%', 147 | transform: 'translate(-50%, -50%)', 148 | zIndex: '20', 149 | }, 150 | }, 151 | [ 152 | getCurrentDocsMarkup(), 153 | getNameInputMarkup(attrs), 154 | getButtonsMarkup(attrs), 155 | ], 156 | ); 157 | } 158 | 159 | function getOverlayMarkup(): m.Vnode { 160 | return m( 161 | 'div', 162 | { 163 | // TODO: Don't use embedded styles 164 | style: { 165 | position: 'fixed', 166 | top: '0px', 167 | width: '100%', 168 | height: '100vh', 169 | background: 'rgba(255, 255, 255, 0.5)', 170 | zIndex: '10', 171 | }, 172 | }, 173 | ); 174 | } 175 | 176 | function onInputKeyUp(e: KeyboardEvent, attrs: FileSaveModalAttributes) { 177 | if (e.key === 'Enter') { 178 | onSave(attrs); 179 | } else if (e.key === 'Escape') { 180 | attrs.onCancel(); 181 | } 182 | } 183 | 184 | function onInputValueChange(e: Event) { 185 | if (e.target !== null) { 186 | inputValue = ((e.target) as HTMLInputElement).value; 187 | } 188 | } 189 | 190 | function onSave(attrs: FileSaveModalAttributes) { 191 | if (inputValue !== '') { 192 | attrs.onSave(inputValue); 193 | } 194 | } 195 | 196 | return { 197 | oninit: (node) => { 198 | if (node.attrs.docName !== '') { 199 | inputValue = node.attrs.docName; 200 | } 201 | }, 202 | 203 | oncreate: (node) => { 204 | node.dom.getElementsByTagName('input')[0].focus(); 205 | }, 206 | 207 | view: ({ attrs }): m.Vnode => m( 208 | 'div', 209 | [ 210 | getOverlayMarkup(), 211 | getFileSaveModalMarkup(attrs), 212 | ], 213 | ), 214 | }; 215 | } 216 | 217 | export default FileSaveModal; 218 | -------------------------------------------------------------------------------- /src/components/Menu.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2022, 2023 Glen Reesor 2 | // 3 | // This file is part of m3 Mind Mapper. 4 | // 5 | // m3 Mind Mapper is free software: you can redistribute it and/or 6 | // modify it under the terms of the GNU General Public License as published by 7 | // the Free Software Foundation, either version 3 of the License, or (at your 8 | // option) any later version. 9 | // 10 | // m3 Mind Mapper is distributed in the hope that it will be useful, but 11 | // WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or 12 | // FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more 13 | // details. 14 | // 15 | // You should have received a copy of the GNU General Public License along with 16 | // m3 Mind Mapper. If not, see . 17 | 18 | import * as m from 'mithril'; 19 | 20 | import state from '../state/state'; 21 | import EditMenu from './menus/EditMenu'; 22 | import FileMenu from './menus/FileMenu'; 23 | import MoveNodeMenu from './menus/MoveNodeMenu'; 24 | import SizeSettingsMenu from './menus/SizeSettingsMenu'; 25 | import UndoRedoMenu from './menus/UndoRedoMenu'; 26 | import BookmarksMenu from './menus/BookmarksMenu'; 27 | 28 | /** 29 | * A component that renders the current menu 30 | * 31 | * @returns An object to be consumed by m() 32 | */ 33 | function Menu(): m.Component { 34 | return { 35 | view: (): m.Vnode => { 36 | switch (state.ui.getCurrentMenu()) { 37 | case 'edit': 38 | return m(EditMenu); 39 | 40 | case 'file': 41 | return m(FileMenu); 42 | 43 | case 'moveNode': 44 | return m(MoveNodeMenu); 45 | 46 | case 'sizeSettings': 47 | return m(SizeSettingsMenu); 48 | 49 | case 'undoRedo': 50 | return m(UndoRedoMenu); 51 | 52 | case 'bookmarks': 53 | return m(BookmarksMenu); 54 | 55 | default: 56 | return m(''); 57 | } 58 | }, 59 | }; 60 | } 61 | 62 | export default Menu; 63 | -------------------------------------------------------------------------------- /src/components/MiscFileOpsModal.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2022 Glen Reesor 2 | // 3 | // This file is part of m3 Mind Mapper. 4 | // 5 | // m3 Mind Mapper is free software: you can redistribute it and/or 6 | // modify it under the terms of the GNU General Public License as published by 7 | // the Free Software Foundation, either version 3 of the License, or (at your 8 | // option) any later version. 9 | // 10 | // m3 Mind Mapper is distributed in the hope that it will be useful, but 11 | // WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or 12 | // FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more 13 | // details. 14 | // 15 | // You should have received a copy of the GNU General Public License along with 16 | // m3 Mind Mapper. If not, see . 17 | 18 | import * as m from 'mithril'; 19 | 20 | import { deleteDocument, getSavedDocumentList, renameDocument } from '../utils/file'; 21 | import state from '../state/state'; 22 | 23 | export interface MiscFileOpsModalAttributes { 24 | onClose: () => void, 25 | } 26 | 27 | /** 28 | * A component that presents the list of saved documents and allows the user 29 | * to rename or delete individual docs. 30 | * 31 | * @returns An object to be consumed by m() 32 | */ 33 | function MiscFileOpsModal(): m.Component { 34 | let renameValue = ''; 35 | let forRealDelete = false; 36 | 37 | function onDeleteButtonClick(documentName: string, onClose: Function) { 38 | if (forRealDelete) { 39 | deleteDocument(documentName); 40 | onClose(); 41 | } 42 | } 43 | 44 | function onRenameButtonClick(documentName: string, onClose: Function) { 45 | if (renameValue !== '') { 46 | renameDocument(documentName, renameValue); 47 | onClose(); 48 | } 49 | } 50 | 51 | function onRenameValueChange(e: Event) { 52 | if (e.target !== null) { 53 | renameValue = ((e.target) as HTMLInputElement).value; 54 | } 55 | } 56 | 57 | function getCloseButtonMarkup(attrs: MiscFileOpsModalAttributes) { 58 | return m( 59 | 'div', 60 | { 61 | style: { 62 | marginTop: '20px', 63 | textAlign: 'right', 64 | }, 65 | }, 66 | [ 67 | m( 68 | 'button', 69 | { onclick: attrs.onClose }, 70 | 'Close', 71 | ), 72 | ], 73 | ); 74 | } 75 | 76 | function getCurrentDocsMarkup(attrs: MiscFileOpsModalAttributes) { 77 | const currentFilenamesMarkup:Array = []; 78 | const currentDocName = state.doc.getDocName(); 79 | 80 | getSavedDocumentList().forEach((filename, index) => { 81 | currentFilenamesMarkup.push( 82 | m( 83 | 'div', 84 | { 85 | // TODO: Fix using nth child stuff 86 | style: { 87 | background: '#ffffff', 88 | fontSize: `${state.ui.getCurrentFontSize()}px`, 89 | paddingTop: index === 0 ? '10px' : '0', 90 | paddingBottom: '10px', 91 | paddingLeft: '20px', 92 | paddingRight: '20px', 93 | }, 94 | }, 95 | filename + (currentDocName === filename ? ' (Current)' : ''), 96 | (currentDocName !== filename) && m( 97 | 'div', 98 | { 99 | style: { 100 | background: '#eeeeee', 101 | padding: '10px', 102 | }, 103 | }, 104 | [ 105 | m( 106 | 'button', 107 | { 108 | style: 'margin-left: 10px', 109 | onclick: () => onRenameButtonClick(filename, attrs.onClose), 110 | }, 111 | 'Rename to:', 112 | ), 113 | m( 114 | 'input', 115 | { 116 | value: renameValue, 117 | style: { 118 | fontSize: `${state.ui.getCurrentFontSize()}px`, 119 | width: `${window.innerWidth / 2}px`, 120 | }, 121 | oninput: onRenameValueChange, 122 | }, 123 | ), 124 | m( 125 | 'button', 126 | { 127 | style: 'margin-left: 10px', 128 | onclick: () => onDeleteButtonClick(filename, attrs.onClose), 129 | }, 130 | 'Delete', 131 | ), 132 | 'For real delete: ', 133 | m( 134 | 'input', 135 | { 136 | type: 'checkbox', 137 | checked: forRealDelete, 138 | onclick: () => { forRealDelete = !forRealDelete; }, 139 | }, 140 | ), 141 | ], 142 | ), 143 | ), 144 | ); 145 | }); 146 | 147 | return m( 148 | 'div', 149 | { 150 | style: { 151 | height: '100px', 152 | overflow: 'auto', 153 | paddingTop: '10px', 154 | paddingBottom: '10px', 155 | 156 | // TODO: Make this a non-hack 157 | }, 158 | }, 159 | currentFilenamesMarkup, 160 | ); 161 | } 162 | 163 | function getModalMarkup(attrs: MiscFileOpsModalAttributes): m.Vnode { 164 | return m( 165 | 'div', 166 | { 167 | // TODO: Don't use embedded styles 168 | style: { 169 | background: '#dddddd', 170 | padding: '10px', 171 | border: '2px solid blue', 172 | fontSize: '14px', 173 | position: 'fixed', 174 | left: '30%', 175 | right: '30%', 176 | top: '35%', 177 | width: '60%', 178 | transform: 'translate(-20%, -50%)', 179 | zIndex: '20', 180 | }, 181 | }, 182 | [ 183 | getCurrentDocsMarkup(attrs), 184 | getCloseButtonMarkup(attrs), 185 | ], 186 | ); 187 | } 188 | 189 | function getOverlayMarkup(): m.Vnode { 190 | return m( 191 | 'div', 192 | { 193 | // TODO: Don't use embedded styles 194 | style: { 195 | position: 'fixed', 196 | top: '0px', 197 | width: '100%', 198 | height: '100vh', 199 | background: 'rgba(255, 255, 255, 0.5)', 200 | zIndex: '10', 201 | }, 202 | }, 203 | ); 204 | } 205 | 206 | return { 207 | view: ({ attrs }): m.Vnode => m( 208 | 'div', 209 | [ 210 | getOverlayMarkup(), 211 | getModalMarkup(attrs), 212 | ], 213 | ), 214 | }; 215 | } 216 | 217 | export default MiscFileOpsModal; 218 | -------------------------------------------------------------------------------- /src/components/Sidebar.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2022, 2023 Glen Reesor 2 | // 3 | // This file is part of m3 Mind Mapper. 4 | // 5 | // m3 Mind Mapper is free software: you can redistribute it and/or 6 | // modify it under the terms of the GNU General Public License as published by 7 | // the Free Software Foundation, either version 3 of the License, or (at your 8 | // option) any later version. 9 | // 10 | // m3 Mind Mapper is distributed in the hope that it will be useful, but 11 | // WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or 12 | // FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more 13 | // details. 14 | // 15 | // You should have received a copy of the GNU General Public License along with 16 | // m3 Mind Mapper. If not, see . 17 | 18 | import * as m from 'mithril'; 19 | 20 | import state from '../state/state'; 21 | 22 | /** 23 | * A sidebar on the right side, that is used to change which set of menu icons 24 | * are shown 25 | * 26 | * @returns An object to be consumed by m() 27 | */ 28 | function Sidebar(): m.Component { 29 | function getOverlayMarkup(): m.Vnode { 30 | return m( 31 | 'div', 32 | { 33 | // TODO: Don't use embedded styles 34 | style: { 35 | position: 'fixed', 36 | top: '0px', 37 | width: '100%', 38 | height: '100vh', 39 | 'z-index': '10', 40 | }, 41 | onclick: () => state.ui.setSidebarVisibility(false), 42 | }, 43 | ); 44 | } 45 | 46 | return { 47 | view: () => m( 48 | 'div', 49 | [ 50 | getOverlayMarkup(), 51 | m( 52 | 'div', 53 | { 54 | style: { 55 | background: '#eeeeee', 56 | position: 'fixed', 57 | bottom: '80px', 58 | right: '0', 59 | 'font-size': `${state.ui.getCurrentFontSize()}px`, 60 | width: '120px', 61 | height: `${state.ui.getCurrentFontSize() * 18}px`, 62 | 'text-align': 'center', 63 | 'z-index': '20', 64 | }, 65 | }, 66 | [ 67 | m('br'), 68 | m( 69 | 'a', 70 | { 71 | href: '#', 72 | onclick: () => { 73 | state.ui.setCurrentMenu('file'); 74 | state.ui.setSidebarVisibility(false); 75 | }, 76 | }, 77 | 'File', 78 | ), 79 | m('br'), 80 | m('br'), 81 | m( 82 | 'a', 83 | { 84 | href: '#', 85 | onclick: () => { 86 | state.ui.setCurrentMenu('edit'); 87 | state.ui.setSidebarVisibility(false); 88 | }, 89 | }, 90 | 'Edit', 91 | ), 92 | m('br'), 93 | m('br'), 94 | m( 95 | 'a', 96 | { 97 | href: '#', 98 | onclick: () => { 99 | state.ui.setCurrentMenu('moveNode'); 100 | state.ui.setSidebarVisibility(false); 101 | }, 102 | }, 103 | 'Move Node', 104 | ), 105 | m('br'), 106 | m('br'), 107 | m( 108 | 'a', 109 | { 110 | href: '#', 111 | onclick: () => { 112 | state.ui.setCurrentMenu('sizeSettings'); 113 | state.ui.setSidebarVisibility(false); 114 | }, 115 | }, 116 | 'Size Settings', 117 | ), 118 | m('br'), 119 | m('br'), 120 | m( 121 | 'a', 122 | { 123 | href: '#', 124 | onclick: () => { 125 | state.ui.setCurrentMenu('undoRedo'); 126 | state.ui.setSidebarVisibility(false); 127 | }, 128 | }, 129 | 'Undo / Redo', 130 | ), 131 | m('br'), 132 | m('br'), 133 | m( 134 | 'a', 135 | { 136 | href: '#', 137 | onclick: () => { 138 | state.ui.setCurrentMenu('bookmarks'); 139 | state.ui.setSidebarVisibility(false); 140 | }, 141 | }, 142 | 'Bookmarks +/-', 143 | ), 144 | m('br'), 145 | m('br'), 146 | m( 147 | 'a', 148 | { 149 | href: '#', 150 | onclick: () => { 151 | state.ui.setCurrentModal('bookmarksList'); 152 | state.ui.setSidebarVisibility(false); 153 | }, 154 | }, 155 | 'Bookmarks', 156 | ), 157 | ], 158 | ), 159 | ], 160 | ), 161 | }; 162 | } 163 | 164 | export default Sidebar; 165 | -------------------------------------------------------------------------------- /src/components/TextInputModal.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2022 Glen Reesor 2 | // 3 | // This file is part of m3 Mind Mapper. 4 | // 5 | // m3 Mind Mapper is free software: you can redistribute it and/or 6 | // modify it under the terms of the GNU General Public License as published by 7 | // the Free Software Foundation, either version 3 of the License, or (at your 8 | // option) any later version. 9 | // 10 | // m3 Mind Mapper is distributed in the hope that it will be useful, but 11 | // WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or 12 | // FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more 13 | // details. 14 | // 15 | // You should have received a copy of the GNU General Public License along with 16 | // m3 Mind Mapper. If not, see . 17 | 18 | import * as m from 'mithril'; 19 | 20 | import state from '../state/state'; 21 | 22 | export interface TextInputModalAttributes { 23 | initialValue: string, 24 | onCancel: () => void, 25 | onSave: (text: string) => void, 26 | } 27 | 28 | /** 29 | * A component that contains a text input element in a centered modal 30 | * 31 | * @returns An object to be consumed by m() 32 | */ 33 | function TextInputModal(): m.Component { 34 | let inputValue = ''; 35 | 36 | function getOverlayMarkup(): m.Vnode { 37 | return m( 38 | 'div', 39 | { 40 | // TODO: Don't use embedded styles 41 | style: { 42 | position: 'fixed', 43 | top: '0px', 44 | width: '100%', 45 | height: '100vh', 46 | background: 'rgba(255, 255, 255, 0.5)', 47 | 'z-index': '10', 48 | }, 49 | }, 50 | ); 51 | } 52 | 53 | function getEditModalMarkup(attrs: TextInputModalAttributes): m.Vnode { 54 | return m( 55 | 'div', 56 | { 57 | // TODO: Don't use embedded styles 58 | style: { 59 | background: '#ffffff', 60 | padding: '10px', 61 | border: '2px solid blue', 62 | position: 'fixed', 63 | top: '50%', 64 | left: '50%', 65 | transform: 'translate(-50%, -50%)', 66 | 'z-index': '20', 67 | }, 68 | }, 69 | [ 70 | m( 71 | 'input', 72 | { 73 | value: inputValue, 74 | style: { 75 | fontSize: `${state.ui.getCurrentFontSize()}px`, 76 | width: `${0.75 * window.innerWidth}px`, 77 | }, 78 | oninput: onInputValueChange, 79 | onkeyup: (e: KeyboardEvent) => onInputKeyUp(e, attrs), 80 | }, 81 | ), 82 | m( 83 | 'br', 84 | ), 85 | m( 86 | 'button', 87 | { 88 | style: 'margin-top: 2px;', 89 | onclick: () => attrs.onSave(inputValue), 90 | }, 91 | 'Save', 92 | ), 93 | m( 94 | 'button', 95 | { 96 | style: 'margin-top: 2px;', 97 | onclick: attrs.onCancel, 98 | }, 99 | 'Cancel', 100 | ), 101 | ], 102 | ); 103 | } 104 | 105 | function onInputKeyUp(e: KeyboardEvent, attrs: TextInputModalAttributes) { 106 | if (e.key === 'Enter') { 107 | attrs.onSave(inputValue); 108 | } else if (e.key === 'Escape') { 109 | attrs.onCancel(); 110 | } 111 | } 112 | 113 | function onInputValueChange(e: Event) { 114 | if (e.target !== null) { 115 | inputValue = ((e.target) as HTMLInputElement).value; 116 | } 117 | } 118 | 119 | return { 120 | oninit: ({ attrs }) => { 121 | inputValue = attrs.initialValue; 122 | }, 123 | 124 | oncreate: (node) => { 125 | node.dom.getElementsByTagName('input')[0].focus(); 126 | }, 127 | 128 | view: ({ attrs }): m.Vnode => m( 129 | 'div', 130 | [ 131 | getOverlayMarkup(), 132 | getEditModalMarkup(attrs), 133 | ], 134 | ), 135 | }; 136 | } 137 | 138 | export default TextInputModal; 139 | -------------------------------------------------------------------------------- /src/components/menus/BookmarksMenu.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2023 Glen Reesor 2 | // 3 | // This file is part of m3 Mind Mapper. 4 | // 5 | // m3 Mind Mapper is free software: you can redistribute it and/or 6 | // modify it under the terms of the GNU General Public License as published by 7 | // the Free Software Foundation, either version 3 of the License, or (at your 8 | // option) any later version. 9 | // 10 | // m3 Mind Mapper is distributed in the hope that it will be useful, but 11 | // WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or 12 | // FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more 13 | // details. 14 | // 15 | // You should have received a copy of the GNU General Public License along with 16 | // m3 Mind Mapper. If not, see . 17 | 18 | import * as m from 'mithril'; 19 | 20 | import state from '../../state/state'; 21 | 22 | import hamburgerMenuButton from './images/hamburger-button.svg'; 23 | 24 | import { MENU_ICONS_HEIGHT, MENU_ICONS_WIDTH } from './constants'; 25 | 26 | /** 27 | * A component that renders the Move Node menu 28 | * 29 | * @returns An object to be consumed by m() 30 | */ 31 | function MoveNodeMenu(): m.Component { 32 | return { 33 | view: (): m.Vnode => { 34 | const selectedNodeId = state.doc.getSelectedNodeId(); 35 | const currentNodeIsBookmarked = state.doc.getBookmarkedNodeIds() 36 | .includes(selectedNodeId); 37 | 38 | return m( 39 | 'div', 40 | { style: 'text-align: right' }, 41 | [ 42 | // Move node up 43 | m( 44 | 'button', 45 | { 46 | onclick: () => state.doc.addBookmark(selectedNodeId), 47 | disabled: currentNodeIsBookmarked, 48 | style: { 49 | marginRight: '20px', 50 | }, 51 | }, 52 | 'Bookmark Node', 53 | ), 54 | // Decrease font size 55 | m( 56 | 'button', 57 | { 58 | onclick: () => state.doc.removeBookmark(selectedNodeId), 59 | disabled: !currentNodeIsBookmarked, 60 | style: { 61 | marginRight: '20px', 62 | }, 63 | }, 64 | 'Unbookmark Node', 65 | ), 66 | // Sidebar Button 67 | m( 68 | 'img', 69 | { 70 | src: hamburgerMenuButton, 71 | width: MENU_ICONS_WIDTH, 72 | height: MENU_ICONS_HEIGHT, 73 | style: 'margin: 2px;', 74 | onclick: () => state.ui.setSidebarVisibility(true), 75 | }, 76 | ), 77 | ], 78 | ); 79 | }, 80 | }; 81 | } 82 | 83 | export default MoveNodeMenu; 84 | -------------------------------------------------------------------------------- /src/components/menus/EditMenu.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2022 Glen Reesor 2 | // 3 | // This file is part of m3 Mind Mapper. 4 | // 5 | // m3 Mind Mapper is free software: you can redistribute it and/or 6 | // modify it under the terms of the GNU General Public License as published by 7 | // the Free Software Foundation, either version 3 of the License, or (at your 8 | // option) any later version. 9 | // 10 | // m3 Mind Mapper is distributed in the hope that it will be useful, but 11 | // WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or 12 | // FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more 13 | // details. 14 | // 15 | // You should have received a copy of the GNU General Public License along with 16 | // m3 Mind Mapper. If not, see . 17 | 18 | import * as m from 'mithril'; 19 | 20 | import state from '../../state/state'; 21 | 22 | import addChildButton from './images/add-child.svg'; 23 | import addSiblingButton from './images/add-sibling.svg'; 24 | import addSiblingButtonDisabled from './images/add-sibling-disabled.svg'; 25 | import deleteNodeButton from './images/delete-node.svg'; 26 | import deleteNodeButtonDisabled from './images/delete-node-disabled.svg'; 27 | import editNodeButton from './images/edit-node.svg'; 28 | import hamburgerMenuButton from './images/hamburger-button.svg'; 29 | 30 | import fileSaveButton from './images/file-save.svg'; 31 | import { onSaveButtonClick } from './FileMenu'; 32 | 33 | import { MENU_ICONS_HEIGHT, MENU_ICONS_WIDTH } from './constants'; 34 | 35 | /** 36 | * A component that renders the Edit menu 37 | * 38 | * @returns An object to be consumed by m() 39 | */ 40 | function EditMenu(): m.Component { 41 | return { 42 | view: (): m.Vnode => { 43 | const rootNodeId = state.doc.getRootNodeId(); 44 | const selectedNodeId = state.doc.getSelectedNodeId(); 45 | 46 | return m( 47 | 'div', 48 | { 49 | style: { 50 | display: 'flex', 51 | justifyContent: 'space-between', 52 | }, 53 | }, 54 | 55 | [ 56 | // Save Button -- not really an "Edit" thing, but put it here 57 | // for user convenience 58 | m( 59 | 'div', 60 | m( 61 | 'img', 62 | { 63 | onclick: onSaveButtonClick, 64 | src: fileSaveButton, 65 | width: MENU_ICONS_WIDTH, 66 | height: MENU_ICONS_HEIGHT, 67 | style: 'margin: 2px;', 68 | }, 69 | ), 70 | ), 71 | ], 72 | [ 73 | m( 74 | 'div', 75 | // Delete Node 76 | m( 77 | 'img', 78 | { 79 | src: selectedNodeId === rootNodeId 80 | ? deleteNodeButtonDisabled 81 | : deleteNodeButton, 82 | height: MENU_ICONS_HEIGHT, 83 | width: MENU_ICONS_WIDTH, 84 | style: 'margin: 2px;', 85 | onclick: onDeleteNodeButtonClick, 86 | }, 87 | ), 88 | 89 | // Edit Node 90 | m( 91 | 'img', 92 | { 93 | src: editNodeButton, 94 | height: MENU_ICONS_HEIGHT, 95 | width: MENU_ICONS_WIDTH, 96 | style: 'margin: 2px;', 97 | onclick: onReplaceNodeContentsButtonClick, 98 | }, 99 | ), 100 | 101 | // Add Child 102 | m( 103 | 'img', 104 | { 105 | src: addChildButton, 106 | height: MENU_ICONS_HEIGHT, 107 | width: MENU_ICONS_WIDTH, 108 | style: 'margin: 2px;', 109 | onclick: onAddChildButtonClick, 110 | }, 111 | ), 112 | 113 | // Add Sibling 114 | m( 115 | 'img', 116 | { 117 | src: selectedNodeId === rootNodeId 118 | ? addSiblingButtonDisabled 119 | : addSiblingButton, 120 | height: MENU_ICONS_HEIGHT, 121 | width: MENU_ICONS_WIDTH, 122 | style: 'margin: 2px;', 123 | onclick: onAddSiblingButtonClick, 124 | }, 125 | ), 126 | 127 | // Sidebar Button 128 | m( 129 | 'img', 130 | { 131 | src: hamburgerMenuButton, 132 | width: MENU_ICONS_WIDTH, 133 | height: MENU_ICONS_HEIGHT, 134 | style: 'margin: 2px;', 135 | onclick: () => state.ui.setSidebarVisibility(true), 136 | }, 137 | ), 138 | ), 139 | ], 140 | ); 141 | }, 142 | }; 143 | } 144 | 145 | function onAddChildButtonClick() { 146 | state.ui.setCurrentModal('addChild'); 147 | } 148 | 149 | function onAddSiblingButtonClick() { 150 | const rootNodeId = state.doc.getRootNodeId(); 151 | const selectedNodeId = state.doc.getSelectedNodeId(); 152 | 153 | if (selectedNodeId !== rootNodeId) { 154 | state.ui.setCurrentModal('addSibling'); 155 | } 156 | } 157 | 158 | function onDeleteNodeButtonClick() { 159 | const rootNodeId = state.doc.getRootNodeId(); 160 | const selectedNodeId = state.doc.getSelectedNodeId(); 161 | 162 | if (selectedNodeId !== rootNodeId) { 163 | state.doc.deleteNode(state.doc.getSelectedNodeId()); 164 | } 165 | } 166 | 167 | function onReplaceNodeContentsButtonClick() { 168 | state.ui.setCurrentModal('editNode'); 169 | } 170 | 171 | export default EditMenu; 172 | -------------------------------------------------------------------------------- /src/components/menus/FileMenu.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2022, 2023 Glen Reesor 2 | // 3 | // This file is part of m3 Mind Mapper. 4 | // 5 | // m3 Mind Mapper is free software: you can redistribute it and/or 6 | // modify it under the terms of the GNU General Public License as published by 7 | // the Free Software Foundation, either version 3 of the License, or (at your 8 | // option) any later version. 9 | // 10 | // m3 Mind Mapper is distributed in the hope that it will be useful, but 11 | // WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or 12 | // FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more 13 | // details. 14 | // 15 | // You should have received a copy of the GNU General Public License along with 16 | // m3 Mind Mapper. If not, see . 17 | 18 | import * as m from 'mithril'; 19 | 20 | import state from '../../state/state'; 21 | 22 | import fileExportButton from './images/file-export.svg'; 23 | import fileImportButton from './images/file-import.svg'; 24 | import fileNewButton from './images/file-new.svg'; 25 | import fileOpenButton from './images/file-open.svg'; 26 | import fileSaveButton from './images/file-save.svg'; 27 | import hamburgerMenuButton from './images/hamburger-button.svg'; 28 | import miscFileOpsButton from './images/misc-file-ops.svg'; 29 | 30 | import { saveDocument } from '../../utils/file'; 31 | import { MENU_ICONS_HEIGHT, MENU_ICONS_WIDTH } from './constants'; 32 | 33 | /** 34 | * A component that renders the File menu 35 | * 36 | * @returns An object to be consumed by m() 37 | */ 38 | function FileMenu(): m.Component { 39 | return { 40 | view: (): m.Vnode => m( 41 | 'div', 42 | { style: 'text-align: right' }, 43 | [ 44 | // Misc File Ops 45 | m( 46 | 'img', 47 | { 48 | onclick: () => state.ui.setCurrentModal('miscFileOps'), 49 | src: miscFileOpsButton, 50 | width: MENU_ICONS_WIDTH, 51 | height: MENU_ICONS_HEIGHT, 52 | style: 'margin: 2px;', 53 | }, 54 | ), 55 | 56 | // Export 57 | m( 58 | 'img', 59 | { 60 | onclick: () => state.ui.setCurrentModal('fileExport'), 61 | src: fileExportButton, 62 | width: MENU_ICONS_WIDTH, 63 | height: MENU_ICONS_HEIGHT, 64 | style: 'margin: 2px;', 65 | }, 66 | ), 67 | 68 | // Import 69 | m( 70 | 'img', 71 | { 72 | onclick: onFileImportButtonClick, 73 | src: fileImportButton, 74 | width: MENU_ICONS_WIDTH, 75 | height: MENU_ICONS_HEIGHT, 76 | style: 'margin: 2px;', 77 | }, 78 | ), 79 | 80 | // New 81 | m( 82 | 'img', 83 | { 84 | onclick: onFileNewButtonClick, 85 | src: fileNewButton, 86 | width: MENU_ICONS_WIDTH, 87 | height: MENU_ICONS_HEIGHT, 88 | style: 'margin: 2px;', 89 | }, 90 | ), 91 | 92 | // Save 93 | m( 94 | 'img', 95 | { 96 | onclick: onSaveButtonClick, 97 | src: fileSaveButton, 98 | width: MENU_ICONS_WIDTH, 99 | height: MENU_ICONS_HEIGHT, 100 | style: 'margin: 2px;', 101 | }, 102 | ), 103 | 104 | // Open 105 | m( 106 | 'img', 107 | { 108 | src: fileOpenButton, 109 | width: MENU_ICONS_WIDTH, 110 | height: MENU_ICONS_HEIGHT, 111 | style: 'margin: 2px;', 112 | onclick: onFileOpenButtonClick, 113 | }, 114 | ), 115 | 116 | // m('button', 'New'), 117 | 118 | // Sidebar Button 119 | m( 120 | 'img', 121 | { 122 | src: hamburgerMenuButton, 123 | width: MENU_ICONS_WIDTH, 124 | height: MENU_ICONS_HEIGHT, 125 | style: 'margin: 2px;', 126 | onclick: () => state.ui.setSidebarVisibility(true), 127 | }, 128 | ), 129 | ], 130 | ), 131 | }; 132 | } 133 | 134 | function onFileImportButtonClick() { 135 | if (state.doc.hasUnsavedChanges()) { 136 | state.ui.setCurrentModal('binaryModal'); 137 | state.ui.setBinaryModalAttrs({ 138 | prompt: 'Current document has unsaved changes. Discard changes and load new document?', 139 | yesButtonText: 'Yes', 140 | noButtonText: 'No', 141 | onYesButtonClick: () => { 142 | state.ui.setCurrentModal('fileImport'); 143 | }, 144 | onNoButtonClick: () => state.ui.setCurrentModal('none'), 145 | }); 146 | } else { 147 | state.ui.setCurrentModal('fileImport'); 148 | } 149 | } 150 | 151 | function onFileOpenButtonClick() { 152 | if (state.doc.hasUnsavedChanges()) { 153 | state.ui.setCurrentModal('binaryModal'); 154 | state.ui.setBinaryModalAttrs({ 155 | prompt: 'Current document has unsaved changes. Discard changes to load next document?', 156 | yesButtonText: 'Yes', 157 | noButtonText: 'No', 158 | onYesButtonClick: () => { 159 | state.ui.setCurrentModal('fileOpen'); 160 | }, 161 | onNoButtonClick: () => state.ui.setCurrentModal('none'), 162 | }); 163 | } else { 164 | state.ui.setCurrentModal('fileOpen'); 165 | } 166 | } 167 | 168 | function onFileNewButtonClick() { 169 | if (state.doc.hasUnsavedChanges()) { 170 | state.ui.setCurrentModal('binaryModal'); 171 | state.ui.setBinaryModalAttrs({ 172 | prompt: 'Current document has unsaved changes. Discard changes and load new document?', 173 | yesButtonText: 'Yes', 174 | noButtonText: 'No', 175 | onYesButtonClick: () => { 176 | state.doc.replaceCurrentDocWithNewEmptyDoc(); 177 | state.ui.setCurrentModal('none'); 178 | state.canvas.resetRootNodeCoords(); 179 | }, 180 | onNoButtonClick: () => state.ui.setCurrentModal('none'), 181 | }); 182 | } else { 183 | state.doc.replaceCurrentDocWithNewEmptyDoc(); 184 | state.canvas.resetRootNodeCoords(); 185 | } 186 | } 187 | 188 | /** 189 | * Handle clicking of the save button 190 | */ 191 | export function onSaveButtonClick() { 192 | const docName = state.doc.getDocName(); 193 | if (docName === '') { 194 | state.ui.setCurrentModal('fileSave'); 195 | } else { 196 | const returnVal = saveDocument( 197 | true, 198 | docName, 199 | state.doc.getCurrentDocAsJson(), 200 | ); 201 | console.log(`Save returnVal: ${returnVal}`); 202 | } 203 | } 204 | 205 | export default FileMenu; 206 | -------------------------------------------------------------------------------- /src/components/menus/MoveNodeMenu.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2022 Glen Reesor 2 | // 3 | // This file is part of m3 Mind Mapper. 4 | // 5 | // m3 Mind Mapper is free software: you can redistribute it and/or 6 | // modify it under the terms of the GNU General Public License as published by 7 | // the Free Software Foundation, either version 3 of the License, or (at your 8 | // option) any later version. 9 | // 10 | // m3 Mind Mapper is distributed in the hope that it will be useful, but 11 | // WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or 12 | // FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more 13 | // details. 14 | // 15 | // You should have received a copy of the GNU General Public License along with 16 | // m3 Mind Mapper. If not, see . 17 | 18 | import * as m from 'mithril'; 19 | 20 | import state from '../../state/state'; 21 | 22 | import hamburgerMenuButton from './images/hamburger-button.svg'; 23 | 24 | import { MENU_ICONS_HEIGHT, MENU_ICONS_WIDTH } from './constants'; 25 | 26 | /** 27 | * A component that renders the Move Node menu 28 | * 29 | * @returns An object to be consumed by m() 30 | */ 31 | function MoveNodeMenu(): m.Component { 32 | return { 33 | view: (): m.Vnode => m( 34 | 'div', 35 | { style: 'text-align: right' }, 36 | [ 37 | // Move node up 38 | m( 39 | 'button', 40 | { 41 | onclick: onMoveNodeUpButtonClick, 42 | style: { 43 | marginRight: '20px', 44 | }, 45 | }, 46 | 'Up', 47 | ), 48 | // Decrease font size 49 | m( 50 | 'button', 51 | { 52 | onclick: onMoveNodeDownButtonClick, 53 | style: { 54 | marginRight: '20px', 55 | }, 56 | }, 57 | 'Down', 58 | ), 59 | // Sidebar Button 60 | m( 61 | 'img', 62 | { 63 | src: hamburgerMenuButton, 64 | width: MENU_ICONS_WIDTH, 65 | height: MENU_ICONS_HEIGHT, 66 | style: 'margin: 2px;', 67 | onclick: () => state.ui.setSidebarVisibility(true), 68 | }, 69 | ), 70 | ], 71 | ), 72 | }; 73 | } 74 | 75 | function onMoveNodeDownButtonClick() { 76 | state.doc.moveNodeDownInSiblingList(state.doc.getSelectedNodeId()); 77 | } 78 | 79 | function onMoveNodeUpButtonClick() { 80 | state.doc.moveNodeUpInSiblingList(state.doc.getSelectedNodeId()); 81 | } 82 | 83 | export default MoveNodeMenu; 84 | -------------------------------------------------------------------------------- /src/components/menus/SizeSettingsMenu.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2022 Glen Reesor 2 | // 3 | // This file is part of m3 Mind Mapper. 4 | // 5 | // m3 Mind Mapper is free software: you can redistribute it and/or 6 | // modify it under the terms of the GNU General Public License as published by 7 | // the Free Software Foundation, either version 3 of the License, or (at your 8 | // option) any later version. 9 | // 10 | // m3 Mind Mapper is distributed in the hope that it will be useful, but 11 | // WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or 12 | // FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more 13 | // details. 14 | // 15 | // You should have received a copy of the GNU General Public License along with 16 | // m3 Mind Mapper. If not, see . 17 | 18 | import * as m from 'mithril'; 19 | 20 | import state from '../../state/state'; 21 | 22 | import hamburgerMenuButton from './images/hamburger-button.svg'; 23 | 24 | import { MENU_ICONS_HEIGHT, MENU_ICONS_WIDTH } from './constants'; 25 | 26 | /** 27 | * A component that renders the Size Settings menu 28 | * 29 | * @returns An object to be consumed by m() 30 | */ 31 | function SizeSettingsMenu(): m.Component { 32 | return { 33 | view: (): m.Vnode => m( 34 | 'div', 35 | { style: 'text-align: right' }, 36 | [ 37 | // Increase font size 38 | m( 39 | 'button', 40 | { 41 | onclick: onFontSizeIncreaseButtonClick, 42 | style: { 43 | marginRight: '20px', 44 | }, 45 | }, 46 | 'Font +', 47 | ), 48 | // Decrease font size 49 | m( 50 | 'button', 51 | { 52 | onclick: onFontSizeDecreaseButtonClick, 53 | style: { 54 | marginRight: '20px', 55 | }, 56 | }, 57 | 'Font -', 58 | ), 59 | // Sidebar Button 60 | m( 61 | 'img', 62 | { 63 | src: hamburgerMenuButton, 64 | width: MENU_ICONS_WIDTH, 65 | height: MENU_ICONS_HEIGHT, 66 | style: 'margin: 2px;', 67 | onclick: () => state.ui.setSidebarVisibility(true), 68 | }, 69 | ), 70 | ], 71 | ), 72 | }; 73 | } 74 | 75 | function onFontSizeDecreaseButtonClick() { 76 | state.ui.setCurrentFontSize( 77 | state.ui.getCurrentFontSize() - 0.5, 78 | ); 79 | } 80 | 81 | function onFontSizeIncreaseButtonClick() { 82 | state.ui.setCurrentFontSize( 83 | state.ui.getCurrentFontSize() + 0.5, 84 | ); 85 | } 86 | 87 | export default SizeSettingsMenu; 88 | -------------------------------------------------------------------------------- /src/components/menus/UndoRedoMenu.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2022 Glen Reesor 2 | // 3 | // This file is part of m3 Mind Mapper. 4 | // 5 | // m3 Mind Mapper is free software: you can redistribute it and/or 6 | // modify it under the terms of the GNU General Public License as published by 7 | // the Free Software Foundation, either version 3 of the License, or (at your 8 | // option) any later version. 9 | // 10 | // m3 Mind Mapper is distributed in the hope that it will be useful, but 11 | // WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or 12 | // FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more 13 | // details. 14 | // 15 | // You should have received a copy of the GNU General Public License along with 16 | // m3 Mind Mapper. If not, see . 17 | 18 | import * as m from 'mithril'; 19 | 20 | import state from '../../state/state'; 21 | 22 | import hamburgerMenuButton from './images/hamburger-button.svg'; 23 | import redoButton from './images/redo.svg'; 24 | import redoButtonDisabled from './images/redo-disabled.svg'; 25 | import undoButton from './images/undo.svg'; 26 | import undoButtonDisabled from './images/undo-disabled.svg'; 27 | 28 | import { MENU_ICONS_HEIGHT, MENU_ICONS_WIDTH } from './constants'; 29 | 30 | /** 31 | * A component that renders the Undo/Redo menu 32 | * 33 | * @returns An object to be consumed by m() 34 | */ 35 | function UndoRedoMenu(): m.Component { 36 | return { 37 | view: (): m.Vnode => m( 38 | 'div', 39 | { style: 'text-align: right' }, 40 | 41 | // Undo 42 | m( 43 | 'img', 44 | { 45 | src: state.doc.getUndoIsAvailable() 46 | ? undoButton 47 | : undoButtonDisabled, 48 | height: MENU_ICONS_HEIGHT, 49 | width: MENU_ICONS_WIDTH, 50 | style: 'margin: 2px;', 51 | onclick: onUndoButtonButtonClick, 52 | }, 53 | ), 54 | 55 | // Redo 56 | m( 57 | 'img', 58 | { 59 | src: state.doc.getRedoIsAvailable() 60 | ? redoButton 61 | : redoButtonDisabled, 62 | height: MENU_ICONS_HEIGHT, 63 | width: MENU_ICONS_WIDTH, 64 | style: 'margin: 2px;', 65 | onclick: onRedoButtonClick, 66 | }, 67 | ), 68 | 69 | // Sidebar Button 70 | m( 71 | 'img', 72 | { 73 | src: hamburgerMenuButton, 74 | width: MENU_ICONS_WIDTH, 75 | height: MENU_ICONS_HEIGHT, 76 | style: 'margin: 2px;', 77 | onclick: () => state.ui.setSidebarVisibility(true), 78 | }, 79 | ), 80 | ), 81 | }; 82 | } 83 | 84 | function onRedoButtonClick() { 85 | if (state.doc.getRedoIsAvailable()) { 86 | state.doc.redo(); 87 | } 88 | } 89 | 90 | function onUndoButtonButtonClick() { 91 | if (state.doc.getUndoIsAvailable()) { 92 | state.doc.undo(); 93 | } 94 | } 95 | 96 | export default UndoRedoMenu; 97 | -------------------------------------------------------------------------------- /src/components/menus/constants.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2022 Glen Reesor 2 | // 3 | // This file is part of m3 Mind Mapper. 4 | // 5 | // m3 Mind Mapper is free software: you can redistribute it and/or 6 | // modify it under the terms of the GNU General Public License as published by 7 | // the Free Software Foundation, either version 3 of the License, or (at your 8 | // option) any later version. 9 | // 10 | // m3 Mind Mapper is distributed in the hope that it will be useful, but 11 | // WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or 12 | // FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more 13 | // details. 14 | // 15 | // You should have received a copy of the GNU General Public License along with 16 | // m3 Mind Mapper. If not, see . 17 | 18 | export const MENU_ICONS_HEIGHT = 45; 19 | export const MENU_ICONS_WIDTH = 45; 20 | export const MENU_ICONS_MARGIN = 2; 21 | export const MENU_HEIGHT = MENU_ICONS_HEIGHT + MENU_ICONS_MARGIN; 22 | -------------------------------------------------------------------------------- /src/components/menus/images/delete-node-disabled.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 20 | 22 | 23 | 25 | pill-button-blue 26 | 27 | 28 | 29 | 30 | webpage 31 | button 32 | shape 33 | 34 | 35 | 36 | 38 | Benji Park 39 | 40 | 41 | 42 | 43 | Benji Park 44 | 45 | 46 | 47 | 48 | Benji Park 49 | 50 | 51 | 52 | image/svg+xml 53 | 55 | 57 | en 58 | 59 | 61 | 63 | 65 | 67 | 68 | 69 | 70 | 72 | 80 | 86 | 87 | 95 | 101 | 102 | 110 | 116 | 117 | 125 | 131 | 132 | 140 | 146 | 147 | 155 | 161 | 162 | 164 | 168 | 172 | 173 | 175 | 179 | 183 | 184 | 194 | 204 | 214 | 215 | 233 | 238 | 242 | 251 | 260 | 261 | 265 | 273 | 280 | 281 | 282 | 283 | -------------------------------------------------------------------------------- /src/components/menus/images/delete-node.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 20 | 22 | 23 | 25 | pill-button-blue 26 | 27 | 28 | 29 | 30 | webpage 31 | button 32 | shape 33 | 34 | 35 | 36 | 38 | Benji Park 39 | 40 | 41 | 42 | 43 | Benji Park 44 | 45 | 46 | 47 | 48 | Benji Park 49 | 50 | 51 | 52 | image/svg+xml 53 | 55 | 57 | en 58 | 59 | 61 | 63 | 65 | 67 | 68 | 69 | 70 | 72 | 80 | 86 | 87 | 95 | 101 | 102 | 110 | 116 | 117 | 125 | 131 | 132 | 140 | 146 | 147 | 155 | 161 | 162 | 164 | 168 | 172 | 173 | 175 | 179 | 183 | 184 | 194 | 204 | 214 | 215 | 233 | 238 | 242 | 251 | 260 | 261 | 264 | 272 | 279 | 280 | 281 | 282 | -------------------------------------------------------------------------------- /src/components/menus/images/edit-node.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 20 | 22 | 23 | 25 | pill-button-blue 26 | 27 | 28 | 29 | 30 | webpage 31 | button 32 | shape 33 | 34 | 35 | 36 | 38 | Benji Park 39 | 40 | 41 | 42 | 43 | Benji Park 44 | 45 | 46 | 47 | 48 | Benji Park 49 | 50 | 51 | 52 | image/svg+xml 53 | 55 | 57 | en 58 | 59 | 61 | 63 | 65 | 67 | 68 | 69 | 70 | 72 | 74 | 78 | 82 | 83 | 85 | 89 | 93 | 94 | 104 | 114 | 124 | 125 | 143 | 148 | 152 | 161 | 170 | 171 | 175 | 183 | 191 | 199 | 200 | 201 | 202 | -------------------------------------------------------------------------------- /src/components/menus/images/hamburger-button.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 19 | 21 | 22 | 24 | pill-button-blue 25 | 26 | 27 | 28 | 29 | webpage 30 | button 31 | shape 32 | 33 | 34 | 35 | 37 | Benji Park 38 | 39 | 40 | 41 | 42 | Benji Park 43 | 44 | 45 | 46 | 47 | Benji Park 48 | 49 | 50 | 51 | image/svg+xml 52 | 54 | 56 | en 57 | 58 | 60 | 62 | 64 | 66 | 67 | 68 | 69 | 71 | 73 | 77 | 81 | 82 | 84 | 88 | 92 | 93 | 94 | 112 | 117 | 124 | 131 | 138 | 139 | 140 | -------------------------------------------------------------------------------- /src/components/menus/images/redo-disabled.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 19 | 21 | 39 | 41 | 42 | 44 | image/svg+xml 45 | 47 | 48 | 49 | 50 | 51 | 56 | 63 | 64 | 65 | -------------------------------------------------------------------------------- /src/components/menus/images/redo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 19 | 21 | 39 | 41 | 42 | 44 | image/svg+xml 45 | 47 | 48 | 49 | 50 | 51 | 56 | 63 | 64 | 65 | -------------------------------------------------------------------------------- /src/components/menus/images/undo-disabled.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 19 | 21 | 39 | 41 | 42 | 44 | image/svg+xml 45 | 47 | 48 | 49 | 50 | 51 | 56 | 63 | 64 | 65 | -------------------------------------------------------------------------------- /src/components/menus/images/undo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 19 | 21 | 39 | 41 | 42 | 44 | image/svg+xml 45 | 47 | 48 | 49 | 50 | 51 | 56 | 63 | 64 | 65 | -------------------------------------------------------------------------------- /src/custom.d.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2022 Glen Reesor 2 | // 3 | // This file is part of m3 Mind Mapper. 4 | // 5 | // m3 Mind Mapper is free software: you can redistribute it and/or 6 | // modify it under the terms of the GNU General Public License as published by 7 | // the Free Software Foundation, either version 3 of the License, or (at your 8 | // option) any later version. 9 | // 10 | // m3 Mind Mapper is distributed in the hope that it will be useful, but 11 | // WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or 12 | // FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more 13 | // details. 14 | // 15 | // You should have received a copy of the GNU General Public License along with 16 | // m3 Mind Mapper. If not, see . 17 | 18 | declare module '*.svg' { 19 | const content: any; 20 | export default content; 21 | } 22 | 23 | declare module '*.png' { 24 | const content: any; 25 | export default content; 26 | } 27 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2022 Glen Reesor 2 | // 3 | // This file is part of m3 Mind Mapper. 4 | // 5 | // m3 Mind Mapper is free software: you can redistribute it and/or 6 | // modify it under the terms of the GNU General Public License as published by 7 | // the Free Software Foundation, either version 3 of the License, or (at your 8 | // option) any later version. 9 | // 10 | // m3 Mind Mapper is distributed in the hope that it will be useful, but 11 | // WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or 12 | // FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more 13 | // details. 14 | // 15 | // You should have received a copy of the GNU General Public License along with 16 | // m3 Mind Mapper. If not, see . 17 | 18 | import * as m from 'mithril'; 19 | import App from './components/App'; 20 | 21 | m.mount(document.body, App); 22 | 23 | // Disable browser pull to refresh so dragging to scroll m3 content doesn't 24 | // trigger a refresh (thereby turfing any unsaved content) 25 | document.getElementsByTagName('body')[0].style.overscrollBehavior = 'contain'; 26 | -------------------------------------------------------------------------------- /src/state/canvasState.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2023 Glen Reesor 2 | // 3 | // This file is part of m3 Mind Mapper. 4 | // 5 | // m3 Mind Mapper is free software: you can redistribute it and/or 6 | // modify it under the terms of the GNU General Public License as published by 7 | // the Free Software Foundation, either version 3 of the License, or (at your 8 | // option) any later version. 9 | // 10 | // m3 Mind Mapper is distributed in the hope that it will be useful, but 11 | // WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or 12 | // FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more 13 | // details. 14 | // 15 | // You should have received a copy of the GNU General Public License along with 16 | // m3 Mind Mapper. If not, see . 17 | 18 | import { Coordinates, Dimensions } from '../types'; 19 | 20 | type MovementState = 'none' | 'userDragging' | 'inertiaScroll'; 21 | 22 | export default ( 23 | (): { 24 | getRootNodeCoords: () => Coordinates, 25 | resetRootNodeCoords: () => void, 26 | translateRootNode: (dx: number, dy: number) => void, 27 | 28 | getMovementState: () => MovementState, 29 | handleUserDragStart: (pointerCoords: Coordinates) => void, 30 | handleUserDragMovement: (newPointerCoords: Coordinates) => void, 31 | handleUserDragStop: () => void, 32 | 33 | resetAllRenderedNodesCoordinates: () => void, 34 | setRenderedNodeCoordinates: (nodeId: number, coords: Coordinates) => void, 35 | 36 | setRedrawFunction: (redrawFunction: () => void) => void, 37 | 38 | setCanvasDimensions: (dimensions: Dimensions) => void, 39 | 40 | scrollToNode: (nodeId: number) => void, 41 | } => { 42 | let canvasDimensions: Dimensions = { width: 0, height: 0 }; 43 | let rootNodeCoords: Coordinates = { x: 0, y: 0 }; 44 | 45 | let movementState: MovementState = 'none'; 46 | 47 | let renderedNodesCoordinates = new Map(); 48 | 49 | // eslint-disable-next-line @typescript-eslint/no-empty-function 50 | let redrawDocument = function placeHolder() { }; 51 | 52 | let userDraggingState = { 53 | previousEventTime: 0, 54 | previousEventPointerCoords: { x: 0, y: 0 }, 55 | previousVelocity: { x: 0, y: 0 }, 56 | previousPreviousVelocity: { x: 0, y: 0 }, 57 | }; 58 | 59 | let inertiaScrollState = { 60 | startTime: 0, 61 | startPosition: { x: 0, y: 0 }, 62 | startVelocity: { x: 0, y: 0 }, 63 | previousPosition: { x: 0, y: 0 }, 64 | }; 65 | 66 | /** 67 | * Scroll the document using inertia calculation based on velocity when 68 | * user stopped dragging 69 | */ 70 | function performInertiaScroll() { 71 | if (movementState !== 'inertiaScroll') { 72 | return; 73 | } 74 | 75 | // Inspired by 76 | // http://ariya.ofilabs.com/2013/11/javascript-kinetic-scrolling-part-2.html 77 | // 78 | // Start with an exponential function of the form: 79 | // d(t) = -a*e^(-b*t) + k (a > 0, b > 0, k > 0) 80 | // v(t) = d'(t) 81 | // = a*b*e^(-b*t) 82 | // 83 | // This provides a curve with an asymptote at d = k like this: 84 | // 85 | // d 86 | // ^ _____________________ <-- k 87 | // | xxxxxxx 88 | // | xxxxxx 89 | // | xxxxx 90 | // | xxxx 91 | // | xxx 92 | // |xx 93 | // x 94 | // --x+-------------------------------> t 95 | // x | 96 | // x | 97 | // | 98 | // 99 | // When user stops dragging (t=0), we know d(0) and v(0) so can solve 100 | // for 2 unknowns. 101 | // 102 | // 'b' controls how quickly the curve approaches its horizontal 103 | // asymptote, so we hard-code that as a tuning constant 104 | // 105 | // Solving for a and k: 106 | // a = v(0) / b 107 | // k = d(0) + a 108 | // 109 | // Substitute into the original function to get: 110 | // d(t) = -[v0 / b ] * e^(-b*t) + [ d0 + v0 / b] 111 | // = v0/b * [-e^(-b*t) + 1 ] + d0 112 | // 113 | // Use this derivation for each of horizontal and vertical directions 114 | 115 | const b = 0.002; 116 | 117 | const now = Date.now(); 118 | const t = now - inertiaScrollState.startTime; 119 | 120 | // In variables below, d0 and v0 correspond to d(0) and v(0) 121 | const d0x = inertiaScrollState.startPosition.x; 122 | const v0x = inertiaScrollState.startVelocity.x; 123 | const newX = v0x / b * (-1 * Math.exp(-1 * b * t) + 1) + d0x; 124 | 125 | const d0y = inertiaScrollState.startPosition.y; 126 | const v0y = inertiaScrollState.startVelocity.y; 127 | const newY = v0y / b * (-1 * Math.exp(-1 * b * t) + 1) + d0y; 128 | 129 | const deltaX = newX - inertiaScrollState.previousPosition.x; 130 | const deltaY = newY - inertiaScrollState.previousPosition.y; 131 | 132 | translateDocument(deltaX, deltaY); 133 | 134 | inertiaScrollState.previousPosition.x += deltaX; 135 | inertiaScrollState.previousPosition.y += deltaY; 136 | 137 | redrawDocument(); 138 | 139 | const vX = deltaX / t; 140 | const vY = deltaY / t; 141 | 142 | // We use * instead of exponentiation since the latter is 143 | // significantly slower (at least on Firefox) 144 | const v = Math.sqrt(vX * vX + vY * vY); 145 | 146 | if (Math.abs(v) > 0.0003) { 147 | window.requestAnimationFrame(performInertiaScroll); 148 | } else { 149 | movementState = 'none'; 150 | } 151 | } 152 | 153 | function translateDocument(dx: number, dy: number) { 154 | rootNodeCoords = { 155 | x: rootNodeCoords.x + dx, 156 | y: rootNodeCoords.y + dy, 157 | }; 158 | } 159 | 160 | return { 161 | getRootNodeCoords: () => rootNodeCoords, 162 | 163 | getMovementState: () => movementState, 164 | 165 | handleUserDragStart: (pointerCoords: Coordinates) => { 166 | movementState = 'userDragging'; 167 | userDraggingState = { 168 | previousEventTime: Date.now(), 169 | previousEventPointerCoords: { 170 | x: pointerCoords.x, 171 | y: pointerCoords.y, 172 | }, 173 | previousVelocity: { x: 0, y: 0 }, 174 | previousPreviousVelocity: { x: 0, y: 0 }, 175 | }; 176 | }, 177 | 178 | handleUserDragMovement: (newPointerCoords: Coordinates) => { 179 | if (movementState !== 'userDragging') return; 180 | 181 | // Calculate how much the pointer moved 182 | const dx = newPointerCoords.x - userDraggingState.previousEventPointerCoords.x; 183 | const dy = newPointerCoords.y - userDraggingState.previousEventPointerCoords.y; 184 | 185 | // Apply this translation to the document (we rely on Mithril redrawing 186 | // after this handler completes) 187 | translateDocument(dx, dy); 188 | 189 | const now = Date.now(); 190 | const deltaT = now - userDraggingState.previousEventTime; 191 | 192 | userDraggingState = { 193 | previousEventTime: now, 194 | previousEventPointerCoords: { 195 | x: newPointerCoords.x, 196 | y: newPointerCoords.y, 197 | }, 198 | previousVelocity: { x: dx / deltaT, y: dy / deltaT }, 199 | previousPreviousVelocity: { 200 | x: userDraggingState.previousVelocity.x, 201 | y: userDraggingState.previousVelocity.y, 202 | }, 203 | }; 204 | }, 205 | 206 | handleUserDragStop: () => { 207 | movementState = 'inertiaScroll'; 208 | 209 | inertiaScrollState = { 210 | startTime: Date.now(), 211 | startPosition: { 212 | x: userDraggingState.previousEventPointerCoords.x, 213 | y: userDraggingState.previousEventPointerCoords.y, 214 | }, 215 | 216 | // The last velocity on a touch device can vary significantly 217 | // from previous ones and thus provide an eratic user experience, 218 | // so don't use it 219 | startVelocity: { 220 | x: userDraggingState.previousPreviousVelocity.x, 221 | y: userDraggingState.previousPreviousVelocity.y, 222 | }, 223 | previousPosition: { 224 | x: userDraggingState.previousEventPointerCoords.x, 225 | y: userDraggingState.previousEventPointerCoords.y, 226 | }, 227 | }; 228 | 229 | window.requestAnimationFrame(performInertiaScroll); 230 | }, 231 | 232 | resetRootNodeCoords: () => { 233 | rootNodeCoords = { 234 | x: 10, 235 | y: canvasDimensions.height / 2, 236 | }; 237 | }, 238 | 239 | setCanvasDimensions: (dimensions: Dimensions) => { 240 | canvasDimensions = { ...dimensions }; 241 | }, 242 | 243 | setRedrawFunction: (documentRedrawFunction: () => void) => { 244 | redrawDocument = documentRedrawFunction; 245 | }, 246 | 247 | resetAllRenderedNodesCoordinates: () => { 248 | renderedNodesCoordinates = new Map(); 249 | }, 250 | 251 | scrollToNode: (nodeId: number): void => { 252 | const destinationNodeCoords = renderedNodesCoordinates.get(nodeId); 253 | 254 | if (!destinationNodeCoords) return; 255 | 256 | const distToMoveX = destinationNodeCoords.x - canvasDimensions.width / 2; 257 | const distToMoveY = destinationNodeCoords.y - canvasDimensions.height / 2; 258 | 259 | const requiredRootNodePositionX = rootNodeCoords.x - distToMoveX; 260 | const requiredRootNodePositionY = rootNodeCoords.y - distToMoveY; 261 | 262 | // We're going to use performInertiaScroll() to nicely scroll 263 | // to our required position. 264 | // 265 | // Looking at the math in performInertiaScroll(), we can see 266 | // the limiting position of the inertia scroll is d(t) = k. 267 | // This limiting position is where we want to end up -- i.e. 268 | // k = requiredRootNodePosition 269 | // 270 | // We can use this information to solve for v(0), which will be 271 | // an initial velocity that will result in scrolling to our desired 272 | // position. 273 | // 274 | // k = d(0) + a 275 | // = d(0) + v(0) / b 276 | // ... 277 | // v(0) = b * (requiredRootNodePosition - current root node position) 278 | const b = 0.002; 279 | 280 | inertiaScrollState = { 281 | startTime: Date.now(), 282 | startPosition: { ...rootNodeCoords }, 283 | previousPosition: { ...rootNodeCoords }, 284 | startVelocity: { 285 | x: b * (requiredRootNodePositionX - rootNodeCoords.x), 286 | y: b * (requiredRootNodePositionY - rootNodeCoords.y), 287 | }, 288 | }; 289 | 290 | movementState = 'inertiaScroll'; 291 | window.requestAnimationFrame(performInertiaScroll); 292 | }, 293 | 294 | setRenderedNodeCoordinates: (nodeId: number, coords: Coordinates) => { 295 | renderedNodesCoordinates.set(nodeId, coords); 296 | }, 297 | 298 | translateRootNode: (dx: number, dy: number) => { 299 | translateDocument(dx, dy); 300 | }, 301 | }; 302 | } 303 | )(); 304 | -------------------------------------------------------------------------------- /src/state/state.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2023 Glen Reesor 2 | // 3 | // This file is part of m3 Mind Mapper. 4 | // 5 | // m3 Mind Mapper is free software: you can redistribute it and/or 6 | // modify it under the terms of the GNU General Public License as published by 7 | // the Free Software Foundation, either version 3 of the License, or (at your 8 | // option) any later version. 9 | // 10 | // m3 Mind Mapper is distributed in the hope that it will be useful, but 11 | // WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or 12 | // FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more 13 | // details. 14 | // 15 | // You should have received a copy of the GNU General Public License along with 16 | // m3 Mind Mapper. If not, see . 17 | 18 | import canvas from './canvasState'; 19 | import doc from './documentState'; 20 | import ui from './uiState'; 21 | 22 | /** 23 | * An object that holds the App's global state and functions for acting on that 24 | * state. 25 | * 26 | * See imported sub-states for full information. 27 | */ 28 | export default { 29 | canvas, 30 | doc, 31 | ui, 32 | }; 33 | -------------------------------------------------------------------------------- /src/state/uiState.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2022, 2023 Glen Reesor 2 | // 3 | // This file is part of m3 Mind Mapper. 4 | // 5 | // m3 Mind Mapper is free software: you can redistribute it and/or 6 | // modify it under the terms of the GNU General Public License as published by 7 | // the Free Software Foundation, either version 3 of the License, or (at your 8 | // option) any later version. 9 | // 10 | // m3 Mind Mapper is distributed in the hope that it will be useful, but 11 | // WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or 12 | // FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more 13 | // details. 14 | // 15 | // You should have received a copy of the GNU General Public License along with 16 | // m3 Mind Mapper. If not, see . 17 | 18 | export interface BinaryModalAttributes { 19 | prompt: string, 20 | yesButtonText: string, 21 | noButtonText: string, 22 | onYesButtonClick: () => void, 23 | onNoButtonClick: () => void, 24 | } 25 | 26 | /** 27 | * An immediately invoked function expression that returns an object with 28 | * actions that operate on closed-over state. 29 | * 30 | * State in this file corresponds to UI properties. 31 | * See return object for action functions. 32 | */ 33 | export default (() => { 34 | type ModalType = ( 35 | 'none' | 36 | 'addChild' | 37 | 'addSibling' | 38 | 'binaryModal' | 39 | 'bookmarksList' | 40 | 'editNode' | 41 | 'fileExport' | 42 | 'fileImport' | 43 | 'fileOpen' | 44 | 'fileSave' | 45 | 'miscFileOps' 46 | ); 47 | type MenuType = 'edit' | 'file' | 'moveNode' | 'sizeSettings' | 'undoRedo' | 'bookmarks'; 48 | 49 | interface State { 50 | binaryModalAttrs?: BinaryModalAttributes, 51 | currentMenu: MenuType, 52 | currentModal: ModalType, 53 | fontSize: number, 54 | sidebarIsVisible: boolean, 55 | } 56 | 57 | const state: State = { 58 | binaryModalAttrs: undefined, 59 | currentMenu: 'edit', 60 | currentModal: 'none', 61 | fontSize: 14, 62 | sidebarIsVisible: false, 63 | }; 64 | 65 | return { 66 | getBinaryModalAttrs: () => state.binaryModalAttrs, 67 | setBinaryModalAttrs: (attrs: BinaryModalAttributes) => { 68 | state.binaryModalAttrs = attrs; 69 | }, 70 | 71 | getCurrentMenu: () => state.currentMenu, 72 | setCurrentMenu: (menu: MenuType) => { state.currentMenu = menu; }, 73 | 74 | getCurrentModal: () => state.currentModal, 75 | setCurrentModal: (modal: ModalType) => { state.currentModal = modal; }, 76 | 77 | getCurrentFontSize: () => state.fontSize, 78 | setCurrentFontSize: (newSize: number) => { 79 | state.fontSize = newSize; 80 | }, 81 | 82 | getSidebarIsVisible: () => state.sidebarIsVisible, 83 | setSidebarVisibility: (visible: boolean) => { 84 | state.sidebarIsVisible = visible; 85 | }, 86 | }; 87 | })(); 88 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2023 Glen Reesor 2 | // 3 | // This file is part of m3 Mind Mapper. 4 | // 5 | // m3 Mind Mapper is free software: you can redistribute it and/or 6 | // modify it under the terms of the GNU General Public License as published by 7 | // the Free Software Foundation, either version 3 of the License, or (at your 8 | // option) any later version. 9 | // 10 | // m3 Mind Mapper is distributed in the hope that it will be useful, but 11 | // WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or 12 | // FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more 13 | // details. 14 | // 15 | // You should have received a copy of the GNU General Public License along with 16 | // m3 Mind Mapper. If not, see . 17 | 18 | export type Coordinates = { 19 | x: number; 20 | y: number; 21 | } 22 | 23 | export type Dimensions = { 24 | width: number; 25 | height: number; 26 | } 27 | -------------------------------------------------------------------------------- /src/utils/file.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2022, 2023 Glen Reesor 2 | // 3 | // This file is part of m3 Mind Mapper. 4 | // 5 | // m3 Mind Mapper is free software: you can redistribute it and/or 6 | // modify it under the terms of the GNU General Public License as published by 7 | // the Free Software Foundation, either version 3 of the License, or (at your 8 | // option) any later version. 9 | // 10 | // m3 Mind Mapper is distributed in the hope that it will be useful, but 11 | // WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or 12 | // FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more 13 | // details. 14 | // 15 | // You should have received a copy of the GNU General Public License along with 16 | // m3 Mind Mapper. If not, see . 17 | 18 | import state from '../state/state'; 19 | 20 | export const FILE_EXISTS = 1; 21 | export const FILE_NOT_FOUND = 2; 22 | export const FILE_OPERATION_SUCCESSFUL = 3; 23 | 24 | const DOCUMENT_LIST_KEY = 'm3DocumentList'; 25 | const LAST_DOCUMENT_USED_NAME_KEY = 'm3LastDocumentUsedName'; 26 | 27 | // This module implements a simple document storage system using the browser's 28 | // localStorage. 29 | // 30 | // LocalStorage key, value pairs: 31 | // - DOCUMENT_LIST_KEY, 32 | // - `m3Document-N`, JSON string of document in Nth position of 33 | // DOCUMENT_LIST_KEY 34 | // 35 | // Example: 36 | // DOCUMENT_LIST_KEY: ['doc1', 'doc2', 'doc3'] 37 | // `m3Document-0`: [JSON representation of 'doc1'] 38 | // `m3Document-1`: [JSON representation of 'doc2'] 39 | // `m3Document-2`: [JSON representation of 'doc3'] 40 | 41 | /** 42 | * Delete the document of the specified name 43 | * 44 | * @param documentName The name of the document to delete 45 | * @returns The status of the delete operation 46 | */ 47 | export function deleteDocument(documentName: string): number { 48 | const documentList = getSavedDocumentList(); 49 | const documentIndex = documentList.indexOf(documentName); 50 | 51 | if (documentIndex === -1) { 52 | return FILE_NOT_FOUND; 53 | } 54 | 55 | const newDocumentList = documentList.filter( 56 | (_name, index) => index !== documentIndex, 57 | ); 58 | const documentKey = getSavedDocumentKey(documentIndex); 59 | 60 | window.localStorage.setItem(DOCUMENT_LIST_KEY, JSON.stringify(newDocumentList)); 61 | window.localStorage.removeItem(documentKey); 62 | 63 | return FILE_OPERATION_SUCCESSFUL; 64 | } 65 | 66 | /** 67 | * Get the name of the last used document 68 | */ 69 | export function getLastUsedDocumentName(): string | null { 70 | return window.localStorage.getItem(LAST_DOCUMENT_USED_NAME_KEY); 71 | } 72 | 73 | /** 74 | * Return a list of documents currently saved in localStorage 75 | * 76 | * @returns An array of names of the saved documents 77 | */ 78 | export function getSavedDocumentList(): string[] { 79 | const documentListJson = window.localStorage.getItem(DOCUMENT_LIST_KEY); 80 | const documentList = JSON.parse(documentListJson || '[]'); 81 | 82 | return documentList; 83 | } 84 | 85 | /** 86 | * Get a document saved in localStorage 87 | * 88 | * @param name The name of the document to be retrieved 89 | * @returns The document or an error code 90 | */ 91 | export function getSavedDocument(name: string): string | number { 92 | const documentList = getSavedDocumentList(); 93 | const documentIndex = documentList.indexOf(name); 94 | 95 | if (documentIndex === -1) { 96 | return FILE_NOT_FOUND; 97 | } 98 | 99 | const documentKey = getSavedDocumentKey(documentIndex); 100 | const doc = window.localStorage.getItem(documentKey) || ''; 101 | saveLastUsedDocumentName(name); 102 | 103 | return doc; 104 | } 105 | 106 | /** 107 | * Get a document name that is guaranteed to be unique among the currently saved 108 | * docs 109 | * 110 | * @param baseName The base of the new filename (characters will be appended 111 | * to this string as required in order to create a unique name) 112 | * @returns The unique filename 113 | */ 114 | export function getUniqueFilename(baseName: string): string { 115 | const existingSavedDocs = getSavedDocumentList(); 116 | let newDocName = baseName; 117 | 118 | while (existingSavedDocs.includes(newDocName)) { 119 | newDocName += '-1'; 120 | } 121 | 122 | return newDocName; 123 | } 124 | 125 | /** 126 | * Rename the specified file 127 | * 128 | * @param currentName The current name of a saved document 129 | * @param newName The new name 130 | * @returns Status of the rename operations 131 | */ 132 | export function renameDocument(currentName: string, newName: string): number { 133 | const documentList = getSavedDocumentList(); 134 | const documentIndex = documentList.indexOf(currentName); 135 | 136 | if (documentIndex === -1) { 137 | return FILE_NOT_FOUND; 138 | } 139 | 140 | documentList[documentIndex] = newName; 141 | 142 | const documentListJson = JSON.stringify(documentList); 143 | window.localStorage.setItem(DOCUMENT_LIST_KEY, documentListJson); 144 | 145 | return FILE_OPERATION_SUCCESSFUL; 146 | } 147 | 148 | /** 149 | * Save a document in localStorage 150 | * 151 | * @param replaceExisting Whether replacing an existing document of the same 152 | * name is permitted 153 | * @param docName The name to be used for the document 154 | * @param doc The contents of the document 155 | * @returns Status of the save operation. 156 | */ 157 | export function saveDocument( 158 | replaceExisting: boolean, 159 | docName: string, 160 | doc: string, 161 | ): number { 162 | const documentList = getSavedDocumentList(); 163 | const currentDocIndex = documentList.indexOf(docName); 164 | let indexForSaveOp; 165 | 166 | if (currentDocIndex !== -1) { 167 | if (!replaceExisting) { 168 | return FILE_EXISTS; 169 | } 170 | 171 | indexForSaveOp = currentDocIndex; 172 | } else { 173 | // Add this document to the current document list 174 | documentList.push(docName); 175 | const documentListJson = JSON.stringify(documentList); 176 | window.localStorage.setItem(DOCUMENT_LIST_KEY, documentListJson); 177 | indexForSaveOp = documentList.length - 1; 178 | } 179 | 180 | // Save the document itself 181 | saveDocumentAtIndex(indexForSaveOp, doc); 182 | saveLastUsedDocumentName(docName); 183 | 184 | return FILE_OPERATION_SUCCESSFUL; 185 | } 186 | 187 | //------------------------------------------------------------------------------ 188 | 189 | function getSavedDocumentKey(index: number) { 190 | return `m3Document-${index}`; 191 | } 192 | 193 | function saveDocumentAtIndex(docIndex: number, doc: string) { 194 | window.localStorage.setItem(getSavedDocumentKey(docIndex), doc); 195 | state.doc.setHasUnsavedChanges(false); 196 | } 197 | 198 | function saveLastUsedDocumentName(docName: string) { 199 | window.localStorage.setItem(LAST_DOCUMENT_USED_NAME_KEY, docName); 200 | } 201 | -------------------------------------------------------------------------------- /src/utils/importFile.ts: -------------------------------------------------------------------------------- 1 | // Copyright 2022 Glen Reesor 2 | // 3 | // This file is part of m3 Mind Mapper. 4 | // 5 | // m3 Mind Mapper is free software: you can redistribute it and/or 6 | // modify it under the terms of the GNU General Public License as published by 7 | // the Free Software Foundation, either version 3 of the License, or (at your 8 | // option) any later version. 9 | // 10 | // m3 Mind Mapper is distributed in the hope that it will be useful, but 11 | // WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or 12 | // FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more 13 | // details. 14 | // 15 | // You should have received a copy of the GNU General Public License along with 16 | // m3 Mind Mapper. If not, see . 17 | 18 | import state from '../state/state'; 19 | import importFreeplane from './importFreeplane'; 20 | import { getUniqueFilename } from './file'; 21 | 22 | /** 23 | * Import the specified content as the current document (replacing whatever the 24 | * currently loaded m3 document is) 25 | * 26 | * @param fileContents The document to be imported -- can be either Freeplane 27 | * or m3. 28 | */ 29 | export default function importFile(fileContents: string) { 30 | if (fileContents.startsWith('. 17 | 18 | import state from '../state/state'; 19 | import { getUniqueFilename } from './file'; 20 | 21 | /** 22 | * Import the specified content as the current document (replacing whatever the 23 | * currently loaded m3 document is) 24 | * 25 | * @param freeplaneDocContents The Freeplane document to be imported 26 | */ 27 | export default function importFreeplane(freeplaneDocContents: string) { 28 | const parser = new DOMParser(); 29 | const doc = parser.parseFromString(freeplaneDocContents, 'application/xml'); 30 | 31 | if (doc.querySelector('parsererror')) { 32 | console.log('error while parsing'); 33 | } else { 34 | processImportedFreeplaneDoc(doc); 35 | } 36 | } 37 | 38 | //------------------------------------------------------------------------------ 39 | 40 | function getFreeplaneChildNodes(parentNode: Element): Element[] { 41 | const childDisplayNodes = []; 42 | 43 | for (let i = 0; i < parentNode.children.length; i += 1) { 44 | // We have to check the nodeName because we're parsing an XML document 45 | // created by the browser, which includes a pile of other child types 46 | const childXmlNode = parentNode.children.item(i); 47 | if (childXmlNode && childXmlNode.nodeName === 'node') { 48 | childDisplayNodes.push(childXmlNode); 49 | } 50 | } 51 | 52 | return childDisplayNodes; 53 | } 54 | 55 | function getFreeplaneTextContent(freeplaneNode: Element): string { 56 | return freeplaneNode.attributes.getNamedItem('TEXT')?.value || ''; 57 | } 58 | 59 | function processImportedFreeplaneDoc(xmlDoc: Document) { 60 | // The root XML element is ''. Double check that it's there 61 | const importMapElement = xmlDoc.documentElement; 62 | 63 | if (importMapElement.nodeName !== 'map') { 64 | console.log('Doesn\'t look like a Freeplane map'); 65 | return; 66 | } 67 | 68 | state.doc.replaceCurrentDocWithNewEmptyDoc(); 69 | state.doc.setDocName(getUniqueFilename('Imported Freeplane Document')); 70 | 71 | // We're assuming Freeplane can only have one root node here. 72 | // Hope that's right 73 | const freeplaneRootNode = getFreeplaneChildNodes(importMapElement)[0]; 74 | const m3RootId = state.doc.getRootNodeId(); 75 | 76 | state.doc.replaceNodeContents( 77 | m3RootId, 78 | getFreeplaneTextContent(freeplaneRootNode), 79 | ); 80 | 81 | getFreeplaneChildNodes(freeplaneRootNode).forEach((child) => { 82 | addFreeplaneNodeToCurrentM3Doc(child, m3RootId); 83 | }); 84 | 85 | state.doc.toggleChildrenVisibility(m3RootId); 86 | } 87 | 88 | function addFreeplaneNodeToCurrentM3Doc( 89 | freeplaneNode: Element, 90 | parentNodeId: number, 91 | ) { 92 | const thisNodeId = state.doc.addChild( 93 | parentNodeId, 94 | getFreeplaneTextContent(freeplaneNode), 95 | ); 96 | const childNodes = getFreeplaneChildNodes(freeplaneNode); 97 | 98 | childNodes.forEach((child) => { 99 | addFreeplaneNodeToCurrentM3Doc(child, thisNodeId); 100 | }); 101 | 102 | state.doc.toggleChildrenVisibility(thisNodeId); 103 | } 104 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowJs": false, 4 | "module": "es6", 5 | "moduleResolution": "node", 6 | "outDir": "./dist/", 7 | "sourceMap": true, 8 | "strict": true, 9 | "target": "es6" 10 | }, 11 | "exclude": [ 12 | "node_modules" 13 | ], 14 | "include": [ 15 | "src/**/*" 16 | ] 17 | } 18 | -------------------------------------------------------------------------------- /webpack.common.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const { CleanWebpackPlugin } = require('clean-webpack-plugin'); 3 | const HtmlWebpackPlugin = require('html-webpack-plugin'); 4 | 5 | module.exports = { 6 | entry: { 7 | app: './src/index.ts', 8 | }, 9 | plugins: [ 10 | new CleanWebpackPlugin(), 11 | new HtmlWebpackPlugin({ 12 | meta: { 13 | viewport: 'width=device-width, initial-scale=1, maximum-scale=1, user-scalable=0', 14 | }, 15 | title: 'm3', 16 | }), 17 | ], 18 | module: { 19 | rules: [ 20 | { 21 | test: /\.ts$/, 22 | use: 'ts-loader', 23 | exclude: /node_modules/, 24 | }, 25 | { 26 | test: /\.(gif|png|jpe?g|svg)$/i, 27 | use: [ 28 | 'file-loader', 29 | { 30 | loader: 'image-webpack-loader', 31 | options: { 32 | disable: true, 33 | }, 34 | }, 35 | ], 36 | }, 37 | ], 38 | }, 39 | resolve: { 40 | extensions: ['.ts', '.js'], 41 | }, 42 | output: { 43 | filename: 'bundle.js', 44 | path: path.resolve(__dirname, 'dist'), 45 | }, 46 | }; 47 | -------------------------------------------------------------------------------- /webpack.dev.js: -------------------------------------------------------------------------------- 1 | const { merge } = require('webpack-merge'); 2 | const common = require('./webpack.common.js'); 3 | 4 | module.exports = merge( 5 | common, 6 | { 7 | mode: 'development', 8 | devtool: 'inline-source-map', 9 | devServer: { 10 | host: '0.0.0.0', 11 | }, 12 | }, 13 | ); 14 | -------------------------------------------------------------------------------- /webpack.prod.js: -------------------------------------------------------------------------------- 1 | const { merge } = require('webpack-merge'); 2 | const common = require('./webpack.common.js'); 3 | 4 | module.exports = merge( 5 | common, 6 | { 7 | mode: 'production', 8 | }, 9 | ); 10 | --------------------------------------------------------------------------------