├── .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